先に結論 — 稼働ログが CSV で手元にあるなら、必要なのは pandas と matplotlib だけです。区間化の核は次の 1 行で、ここから停止区間・OEE・パレートまで全部が出ます。

gid = (df["state"] != df["state"].shift()).cumsum()

そして、チョコ停の閾値は最初に決めないでください。 本記事の架空データでは、1 日分(停止 39 件)で閾値を振ると 5 分・10 分・15 分のどれでもチョコ停 38 件・停止時間シェア 60.6% で動かず、分布に谷があるように見えました。ところが同じ生成ルールで作った 30 日分(1,108 件)で同じことをやると谷は消え、停止時間シェアは 31.8%(5 分)から 51.9%(15 分)へ 20 ポイント動きます(6 章)。会議で閾値を議論する前に分布を描く。そしてその分布が何日分なのかを確かめる。順番はこれです。

python -m pip install pandas matplotlib

会社の PC でこの 1 行が通らない(管理者権限が無い・社内プロキシで弾かれる・そもそも外に出られない)なら、先にそちらを片づけてください。詰まりどころと申請の材料は 製造業エンジニアのための Python 学習ロードマップ — Windows の現場 PC で動くアプリを作るまでの 5 ステップの「会社の PC で最初に詰まる 3 つの壁」にまとめてあります。

とにかく先に動かしたい方へ — 12 章に 218 行の完成コードがあります。python oee_report.py で架空ログの集計が、python oee_report.py 自分のログ.csv で手元の CSV の集計が走ります。自分のログを読ませると文字コードや列名でつまずきます。その 3 か所は 10 章にまとめました。理屈は後から読めます。

この記事の前提: 扱うログはすべて乱数シードから生成した架空データです。当サイトは実機 PLC も実設備の稼働ログも所有していません。 測定はすべて自宅の検証機(Windows 11 Pro)で行い、会社支給の端末は使っていません。すでに MES や稼働監視ツールが入っているなら、そちらで出すほうが確実です。本記事は手元に CSV しか無い人向けです。

この記事の目次

1. この記事で作るもの — 1 日分の稼働ログから OEE とチョコ停を 1 コマンドで

入力は、設備の状態が 1 秒ごとに 1 行記録された CSV です。列は 5 つだけ(時刻・状態・停止理由・良品カウント・不良カウント)。出力は次の 4 つになります。

  • OEE(設備総合効率)と、その内訳の 3 つの率 — 本記事の架空データでは 70.21%(7 章)
  • 停止区間の一覧 — 39 件・合計 3,963 秒。うちチョコ停 38 件(5 章・6 章)
  • 停止理由のパレート — 上位 3 つで停止時間の 84.1% を占めた(8 章)
  • 1 日の稼働帯・時間帯ヒートマップ — どの時間に短い停止が集中しているか(8 章)

完成形を先に見てもらうほうが早いので、1 日分の稼働帯を貼ります。これは 12 章の完成コードが書き出した SVG を、Web 埋め込み用に調整(フォント指定の置換・viewBox と代替テキストの付与・id の重複回避)して貼ったものです(8 章)。

1日分の稼働ログをガントチャート状に描いた稼働帯 8時から17時までの1日を、運転・停止・異常・段取り・計画休止の5状態で色分けした横帯。朝の段取りのあと運転帯が続き、短い停止が散発し、昼休みの計画休止帯が入る。架空データ。 8:00 9:00 10:00 11:00 12:00 13:00 14:00 15:00 16:00 17:00 時刻 設備の稼働帯(架空データ・1日分) 運転 停止 異常 段取り 計画休止
図 1: 1 日分の稼働帯(broken_barh)。緑=運転/橙=停止/赤=異常/青=段取り/灰=計画休止。乱数シード 20261027 で生成した架空データです。

緑が運転、橙が停止、赤が異常、青が段取り、灰が計画休止です。朝の立ち上げに 818 秒かかり、その後は運転の帯の間に細い停止が何度も入り、12 時台に 45 分の計画休止がある——この見え方が、以降の数字の土台になります。

本記事のログはすべて乱数で生成した架空データです。 設備名・工程名・サイクルタイム・不良率はいずれも記事用の仮の値です。4 章に生成コードを全部載せるので、同じ乱数シードなら手元でも同じ数字が出ます。

2. OEE の定義 — 時間稼働率 × 性能稼働率 × 良品率の計算式

OEE(Overall Equipment Effectiveness/日本語では設備総合効率)は、3 つの率の掛け算で定義されます。

OEE = 時間稼働率 × 性能稼働率 × 良品率(英語圏では Availability × Performance × Quality)

因子何を見ているか分子 ÷ 分母本記事の架空データ
時間稼働率
Availability
動かせるはずの時間のうち、実際に動いていた割合稼働時間 ÷ 負荷時間21,723 ÷ 28,500 = 76.22%
性能稼働率
Performance
動いている間、本来の速さで作れていた割合(理論サイクルタイム × 生産数)÷ 稼働時間(30.0 × 676) ÷ 21,723 = 93.36%
良品率
Quality
作ったもののうち、良品だった割合良品数 ÷ 生産数667 ÷ 676 = 98.67%

掛け合わせると 70.21% になります。ここで押さえておきたいのは、3 つとも分母が違うことです。時間稼働率の分母は負荷時間(就業時間から計画休止を引いたもの)、性能稼働率の分母は稼働時間、良品率の分母は生産数。「稼働率 76% なのに OEE が 70% にしかならない」のは、後ろの 2 つが別の損失を拾っているからです。

2.1 出典について正直に書いておくこと

この式は TPM(全員参加の生産保全)の文脈で広まったもので、日本では JIPM(日本プラントメンテナンス協会)の体系に由来するとされます。なお「TPM」は公益社団法人日本プラントメンテナンス協会(JIPM)の登録商標です。本記事では手法の一般名称として、説明のために使用しています。ただし、当サイトでは JIPM の公式サイト上に計算式そのものを示した一次ページを特定できませんでした。 「TPM の父」とされる中嶋清一氏の原典書籍も未確認で、複数の実務解説サイトが同じ式を示していることを根拠にしています。

国際規格としては ISO 22400-2:2014(製造オペレーション管理の KPI)が実在し、OEE を含む指標と、その計算に使う時間区分を定義しています。有償規格のため当サイトは原文を入手していません。 時間区分の名称や Performance の厳密な定義は、規格の解説記事が一致して伝えるところに基づく二次情報です。細部の定義が必要なら規格を購入して確認してください。

もうひとつ、よく見る「世界水準は OEE 85%」という数字について。これは時間稼働率 90% × 性能稼働率 95% × 良品率 99.9% という内訳で説明されるのを複数の実務解説サイトで見かけますが、当サイトはその出所を一次資料まで辿れていません。「業界標準」と断定できる根拠も、この内訳がどこから来たのかも、確認できていないということです。そもそも負荷時間に何を含めるか(段取りを計画休止に入れるかどうかなど)で OEE の値は簡単に数ポイント動きます。他社の数値と比べるより、自分の設備の先月と今月を同じ定義で比べるほうが意味があります。

2.2 チョコ停とドカ停

チョコ停は、数十秒から数分程度の短い停止で、作業者がその場で復旧できるもの(ワークを取り除く、センサを拭く、リセットを押す)を指します。部品交換や保全の手配が要る長時間の停止はドカ停と呼び分けます。

この境目が何分かは、資料によって 5 分以内から 10 分程度まで幅があります。 「JIS では小故障に位置づけられる」とする解説も複数見かけますが、当サイトでは該当する条文を確認できていないので、ここでは断定しません。同様に「六大ロス」「八大ロス」という分類も、資料によって呼称と粒度が異なります。

つまり OEE の定義も閾値の取り方も、資料と現場によって幅があるということです。本記事はその中の一つの実装例で、唯一の正解を示すものではありません。だからこそ 6 章では、閾値を「決める」のではなく「自分のログから選ぶ」手順にしています。

3. 稼働ログの形 — 状態遷移型と固定周期型

現場から出てくる稼働ログは、だいたい 2 つの形のどちらかです。

# 状態遷移型(状態が変わった瞬間だけ 1 行)
timestamp,state
2026-10-05 08:13:38,運転
2026-10-05 08:19:00,停止
2026-10-05 08:20:30,運転

# 固定周期型(1 秒ごとに 1 行・本記事はこちら)
timestamp,state,reason,good_count,ng_count
2026-10-05 08:00:00,段取り,朝の立ち上げ,0,0
2026-10-05 08:00:01,段取り,朝の立ち上げ,0,0
2026-10-05 08:00:02,段取り,朝の立ち上げ,0,0
状態遷移型固定周期型
記録のしかた状態が変わった瞬間だけ 1 行1 秒/1 分ごとに必ず 1 行
よくある出どころMES・SCADA のイベント履歴、アラーム履歴PLC のビットを一定周期で読んだログ、データロガー
1 日分の行数数十〜数百行1 秒粒度なら 32,400 行(9 時間)
継続時間の出し方次の行の時刻との差区間化してから差を取る(5 章)
収集が止まったとき見た目は変わらない(気づきにくい)行が消えるので検出できる(11.1)

本記事は固定周期型(1 秒粒度)を入力にします。 PLC のビットを周期で読む構成が現場では作りやすく、収集が止まった区間を後から検出できるからです。状態遷移型しか無い場合は、すでに区間表の形になっているので 5 章の区間化は不要です(本記事のコードに流し込みたいなら resample("1s").ffill() で固定周期に直せます)。

必要な列は 5 つです。timestamp(時刻)、state(運転 / 停止 / 異常 / 段取り / 計画休止)、reason(停止理由。運転中は空)、good_count と ng_count(その 1 秒に良品・不良が何個できたか)。停止理由が入っていないログでも OEE は出せますが、パレートは描けません。 理由コードを後から人手で埋めるのは現実的ではないので、収集の段階で拾えるようにしておくのが結局は近道です。なお 12 章の完成コードが必須にしているのは timestamp と state の 2 列だけで、残り 3 つは無ければ無いなりに動きます(10 章)。

4. 架空の稼働ログを作る — 乱数シードで完全再現

手元に実データが無くても試せるように、まずログを作ります。分布の形は「それらしく見える」よう選んだだけで、現実の設備がこうなるという主張はしていません。

設定は、就業 9 時間(08:00〜17:00)、計画休止は 10 時に 10 分・12 時に 45 分・15 時に 10 分。理論サイクルタイムは 30.0 秒/個。運転が続く長さは対数正規分布、停止は 90% が指数分布の短いもの、6% が 3〜15 分、4% が 15〜40 分の異常停止です。

# 抜粋です(全文は 12 章)。実機のログは 1 行も使っていない、架空ログの生成部。
SEED = 20261027          # 乱数シード。この値を変えなければ何度でも同じログが出る
CYCLE_TIME_SEC = 30.0    # 理論サイクルタイム[秒/個](架空値)
SHIFT_START, SHIFT_HOURS = "2026-10-05 08:00:00", 9
BREAKS = [("10:00:00", 10), ("12:00:00", 45), ("15:00:00", 10)]  # 計画休止(開始, 分)

rng = np.random.default_rng(SEED)        # レガシーな np.random.seed() は使わない
events, t = [(SETUP, float(rng.integers(600, 900)), "朝の立ち上げ")], 0.0
t += events[0][1]
while t < n:
    run = float(np.clip(rng.lognormal(6.2, 0.65), 30, 3600))   # 運転が続く秒数
    events.append((RUN, run, None))
    t += run
    u = rng.random()
    if u < 0.90:                                   # 短い停止(チョコ停になりやすい)
        dur, st = min(float(rng.exponential(40)) + 8, 600), STOP
    elif u < 0.96:                                 # 中くらいの停止
        dur, st = float(rng.uniform(180, 900)), STOP
    else:                                          # 長時間の異常停止
        dur, st = float(rng.uniform(900, 2400)), FAULT
    events.append((st, dur, str(rng.choice(REASONS, p=REASON_P))))
    t += dur
    if rng.random() < 0.05:                        # たまに品種切替が入る
        events.append((SETUP, float(rng.uniform(600, 1200)), "品種切替"))
        t += events[-1][1]

乱数は np.random.default_rng(seed) で作ります。NumPy が新規コードに推奨している API で、レガシーな np.random.seed() は使いません。シードを 20261027 に固定しているので、同じ pandas / numpy の環境なら本記事とまったく同じログが出ます。

[1] 1日分ログ生成: 32,400 行 / 0.013 秒
            timestamp state  reason  good_count  ng_count
0 2026-10-05 08:00:00   段取り  朝の立ち上げ           0         0
1 2026-10-05 08:00:01   段取り  朝の立ち上げ           0         0
2 2026-10-05 08:00:02   段取り  朝の立ち上げ           0         0
... state 内訳(秒):
state
運転      21723
計画休止     3900
段取り      2814
停止       2132
異常       1831

32,400 行の生成に 0.013 秒。状態の内訳は運転 21,723 秒、計画休止 3,900 秒、段取り 2,814 秒、停止 2,132 秒、異常 1,831 秒です。合計すると 32,400 秒= 9 時間ちょうどになります。固定周期型のログでは「1 行 = 1 秒」なので、状態の行数を数えるだけで各状態の時間が出ます。 ここは後で効いてきます(11.1 で、欠測があるとこの前提が崩れます)。

5. 区間化 — shift + != + cumsum で停止区間を切り出す

行のままでは「8 時 19 分から 90 秒止まった」という単位が取れません。連続する同じ状態を 1 行にまとめる処理を区間化と呼ぶことにします。定石は次の 1 行です。

gid = (df["state"] != df["state"].shift()).cumsum()

shift() で 1 行ずらした列と比べると、状態が変わった行だけ True になります。それを cumsum() で足し上げると、変化するたびに 1 ずつ増える通し番号ができる——これが区間 ID です。あとは groupby するだけです。

def to_intervals(df: pd.DataFrame) -> pd.DataFrame:
    gid = (df["state"] != df["state"].shift()).cumsum()   # 変化点で ID を増やす
    iv = df.groupby(gid).agg(state=("state", "first"), reason=("reason", "first"),
                             start=("timestamp", "first"), end=("timestamp", "last"))
    iv["duration_s"] = (iv["end"] - iv["start"]).dt.total_seconds() + SAMPLE_SEC
    return iv.reset_index(drop=True)

継続時間に SAMPLE_SEC(= 1 秒)を足しているのは、最後の 1 行が占める 1 秒分を含めるためです。08:19:00 から 08:20:29 までの行は 90 行あり、時刻の差は 89 秒。この設備は 90 秒止まっていたので、差に 1 周期を足します。ここを足し忘れると、区間の数だけ時間が足りなくなります(本記事のログなら 86 区間 × 1 秒= 86 秒の目減り)。

[2] 区間化: 86 区間
  state  reason               start                 end  duration_s
0   段取り  朝の立ち上げ 2026-10-05 08:00:00 2026-10-05 08:13:37       818.0
1    運転     NaN 2026-10-05 08:13:38 2026-10-05 08:18:59       322.0
2    停止  センサ誤検知 2026-10-05 08:19:00 2026-10-05 08:20:29        90.0
3    運転     NaN 2026-10-05 08:20:30 2026-10-05 08:25:23       294.0
4    停止  ワーク詰まり 2026-10-05 08:25:24 2026-10-05 08:25:51        28.0
5    運転     NaN 2026-10-05 08:25:52 2026-10-05 08:27:55       124.0
6    停止    品質調整 2026-10-05 08:27:56 2026-10-05 08:28:36        41.0
7    運転     NaN 2026-10-05 08:28:37 2026-10-05 08:37:09       513.0

32,400 行が 86 区間になりました。停止と異常だけを抜き出すと 39 件・合計 3,963 秒です。ここまでくれば、「何回止まったか」「1 回あたり何秒か」「どの理由で止まったか」が全部この表から出ます。

注意点が 2 つあります。ひとつは、この方法は行が等間隔に並んでいることを前提にしていること。 途中で収集が止まってログの行が消えていると、区間の幅が穴をまたいで水増しされます(11.1)。もうひとつは、1 秒だけのノイズがあると区間が爆発すること(11.2)。どちらも、実データを扱うときには想定しておくべき事象です。

6. チョコ停の閾値を決める — 1 日分で見えた「谷」は 30 日分で消えた

ここが本記事の本題です。「5 分未満をチョコ停とする」と最初に決めてしまう記事は多いのですが、その 5 分に根拠はありますか。2 章で見たとおり、資料によって 5 分以内とも 10 分程度とも書かれています。会議で決めようとすると、だいたい結論が出ません。

決め方を変えます。閾値は先に決めず、自分のログの分布を描き、閾値を振って結論が動くかを確かめる。 そして本記事では、その手順を架空データで実演したときに1 日分で見つけた「谷」が 30 日分では消えました。都合の悪い結果ですが、実務でいちばん効くのはここなので、失敗の過程ごと載せます。

6.1 まず停止時間の分布を見る

stops = iv[iv["state"].isin(["停止", "異常"])].copy()
print(stops["duration_s"].describe(percentiles=[.25, .5, .75, .9, .95]).round(1))

bins = [0, 60, 180, 300, 600, 900, 1800, 10**9]
labels = ["〜1分", "1〜3分", "3〜5分", "5〜10分", "10〜15分", "15〜30分", "30分〜"]
b = pd.cut(stops["duration_s"], bins=bins, labels=labels, right=False)
dist = pd.DataFrame({"件数": b.value_counts().reindex(labels),
                     "合計秒": stops.groupby(b, observed=False)["duration_s"].sum().reindex(labels)})
[3] 停止(停止+異常)区間 39 件 / 合計 3963 秒
  分位点(秒):
count      39.0
mean      101.6
std       248.8
min         8.0
25%        21.5
50%        41.0
75%        80.0
90%       169.2
95%       272.0
max      1560.0

中央値 41 秒、75 パーセンタイルで 80 秒、90 パーセンタイルで 169 秒。75 パーセンタイルとは、短いほうから数えて全体の 4 分の 3 番目にあたる値のことです(39 件なら 30 件目あたり)。つまり 4 回に 3 回の停止は 80 秒以内に復旧していて、最大だけ 1,560 秒(26 分)という、いかにも歪んだ分布です。平均 101.6 秒は最大値に引っ張られていて、代表値として使えません。停止時間は平均で語らないでください。

区分ごとに数えると、形がはっきりします。

停止時間件数合計秒件数比時間比
〜1 分2568664.1%17.3%
1〜3 分1094725.6%23.9%
3〜5 分37707.7%19.4%
5〜10 分000.0%0.0%
10〜15 分000.0%0.0%
15〜30 分11,5602.6%39.4%
30 分〜000.0%0.0%

5〜10 分にも 10〜15 分にも 1 件も無い。 短い停止の山と、26 分の停止 1 件の間が空いています。分布に谷がある——この時点では、そう読めます。ただし母数は 39 件です。この数字を覚えておいてください。

6.2 閾値を動かして、結論が動くかを見る

谷があるなら、その谷のどこで切っても結果は同じはずです。確かめます。閾値を 1・3・5・10・15 分と振って、チョコ停の件数と停止時間シェアがどう動くかを並べるだけの関数です。

def threshold_scan(stops: pd.DataFrame, minutes=(1, 3, 5, 10, 15)) -> pd.DataFrame:
    n, s_all = len(stops), stops["duration_s"].sum()
    rows = []
    for mi in minutes:
        s = stops[stops["duration_s"] < mi * 60]
        rows.append({"閾値(分)": mi, "チョコ停件数": len(s),
                     "件数シェア%": round(len(s) / n * 100, 1),
                     "停止時間シェア%": round(s["duration_s"].sum() / s_all * 100, 1),
                     "ドカ停件数": n - len(s),
                     "ドカ停合計(分)": round((s_all - s["duration_s"].sum()) / 60, 1)})
    return pd.DataFrame(rows)
  --- 1 日分 / 停止 39 件 / 合計 66.0 分 ---
   閾値(分)  チョコ停件数  件数シェア%  停止時間シェア%  ドカ停件数  ドカ停合計(分)
       1      25    64.1      17.3     14      54.6
       3      35    89.7      41.2      4      38.8
       5      38    97.4      60.6      1      26.0
      10      38    97.4      60.6      1      26.0
      15      38    97.4      60.6      1      26.0

5 分・10 分・15 分で、数字が 1 つも動きません。 38 件、97.4%、60.6%(チョコ停の合計は 40.0 分)。谷の中で切っている限り閾値をどこにしても同じ結論になる——1 日分を見るかぎり、そう読めます。

一方、1 分と 3 分の間は大きく動きます。25 件 → 35 件、停止時間シェア 17.3% → 41.2%。この範囲で切ると、閾値の選び方そのものが結論を左右します。もし誰かが「うちは 1 分未満をチョコ停と呼んでいる」と言い出したら、それは 3 分と比べて別の設備を見ているのとほぼ同じです。

ここで「谷で切ればいい」と結論して終わるつもりでした。それが早すぎました。

6.3 サンプルを増やす — 5 日分・30 日分で同じスキャンを回す

停止 39 件。この数で「5〜10 分は 0 件だから谷だ」と言えるでしょうか。母集団に谷があるのか、39 件だからたまたま空いただけなのかは、この表からは区別できません。 生成ルールを変えずに日数だけ増やして、同じスキャンを回します。

for days in (1, 5, 30):                      # 生成ルールは同じで日数だけ変える
    df = make_dummy_log(days=days)
    iv = to_intervals(df)
    stops = iv[iv["state"].isin(STOP_STATES)]
    print(f"--- {days} 日分 / 停止 {len(stops)} 件 / "
          f"合計 {stops['duration_s'].sum()/60:.1f} 分 ---")
    print(threshold_scan(stops).to_string(index=False))
  --- 5 日分 / 停止 179 件 / 合計 421.7 分 ---
   閾値(分)  チョコ停件数  件数シェア%  停止時間シェア%  ドカ停件数  ドカ停合計(分)
       1     117    65.4      13.5     62     364.7
       3     156    87.2      27.9     23     304.2
       5     163    91.1      34.1     16     277.7
      10     166    92.7      39.4     13     255.4
      15     170    95.0      50.2      9     210.1
  分布: 〜1分 117件 / 1〜3分 39件 / 3〜5分 7件 / 5〜10分 3件 / 10〜15分 4件 / 15〜30分 7件 / 30分〜 2件

  --- 30 日分 / 停止 1108 件 / 合計 2641.4 分 ---
   閾値(分)  チョコ停件数  件数シェア%  停止時間シェア%  ドカ停件数  ドカ停合計(分)
       1     719    64.9      12.1    389    2321.6
       3     973    87.8      27.2    135    1922.0
       5    1006    90.8      31.8    102    1800.2
      10    1036    93.5      40.4     72    1574.1
      15    1061    95.8      51.9     47    1269.4
  分布: 〜1分 719件 / 1〜3分 254件 / 3〜5分 33件 / 5〜10分 30件 / 10〜15分 25件 / 15〜30分 29件 / 30分〜 18件

谷が埋まりました。区分ごとの件数を並べます。

停止時間1 日分(39 件)5 日分(179 件)30 日分(1,108 件)
〜1 分25117719
1〜3 分1039254
3〜5 分3733
5〜10 分0330
10〜15 分0425
15〜30 分1729
30 分〜0218

5〜10 分に 30 件、10〜15 分に 25 件。 1 日分では 0 件だった区分に、30 日分では合計 55 件が入りました。谷は母集団の性質ではなく、母数が 39 件だったから空いていただけだったということです。

谷が消えると、閾値の選び方はそのまま結論に効いてきます。30 日分で見比べてください。

閾値チョコ停件数件数シェア停止時間シェアドカ停件数ドカ停合計
5 分1,00690.8%31.8%1021,800.2 分
10 分1,03693.5%40.4%721,574.1 分
15 分1,06195.8%51.9%471,269.4 分

件数の差は 1,006 件と 1,061 件で 55 件。ところが停止時間シェアは 31.8% と 51.9% で、20.1 ポイント動きます。 「チョコ停は停止時間の 3 割」と報告するか「5 割」と報告するかが、5 分か 15 分かだけで決まってしまう。ドカ停として保全に回す時間も 1,800.2 分と 1,269.4 分で 530.8 分違います。1 日分では「どこで切っても同じ」だった設備が、30 日分では「どこで切るかが全部」になりました。

もうひとつ。同じ 5 分の閾値でも、停止時間シェアは 1 日分で 60.6%、30 日分では 31.8% です。 1 日分の数字を月次の代表値として報告していたら、倍近く外していたことになります。26 分の停止が 1 件あるかないかで、1 日分の割合は簡単に跳ねます。

6.4 だから手順はこうなる

チョコ停の閾値を決める手順

  1. 停止時間の分布を描く(区分ごとの件数と合計時間の両方)
  2. 谷が見えたら、まずサンプルを増やす(谷が見えなければ 3 へ)。1 日分の谷は偶然かもしれません。1 週間・1 か月と期間を延ばして、同じ区分が空いたままかを見ます。最初から谷が無く、短い停止から長い停止まで分布が連続している場合は、「自然な切れ目」を探す作業は打ち切ってかまいません——どこで切るかで結論が動く設備だと分かった、というのが結果です
  3. 複数の期間で感度分析を回す。閾値を前後に振って、件数と時間シェアが期間ごとにどう動くかを並べます
  4. 期間を変えても結論が動かないなら、その閾値でよい。動くなら 1 つに丸めない。「5 分なら 31.8%、15 分なら 51.9%」と複数の閾値を並べて報告します

本記事の架空データは手順 2 で引っかかり、手順 4 の後半(動くなら丸めない)に落ちました。「チョコ停は 5 分未満」と決め打ちした報告書は、集計期間を変えると別の結論になり得る——1 日分から 30 日分まで回して分かったのはこれです。実設備の停止時間分布に谷があるかどうかは、当サイトでは検証できていません(14 章)。それでも、谷を見つけたら喜ぶ前にサンプルを増やすという手順のほうは、どの設備でも同じように使えます。

そして最後は「決めて、書き残す」で終わりです。 「複数の閾値を並べて報告する」で止めると、上に出す側からは「決めていない」と読まれます。複数の閾値を見せたうえで、定型の報告に 1 つの数字が要るなら「5 分・直近 30 日分」のように閾値と集計期間をセットで固定し、その 2 つを定義書に残してください(12.2)。どの数字にするかを決めるのは解析ではなく現場です。 まずは 2〜3 の候補のまま 1 か月回し、どの閾値なら打ち手(保全に回すのか、段取りを見直すのか)に結びつくかを見てから合意すれば間に合います。翌月に数字が動いたとき、設備が変わったのか定義が変わったのかを切り分けられるのは、これを書き残した人だけです。

もうひとつ、表の読み方で大事な点。件数シェアと時間シェアは別物です。 閾値 1 分で見ると、チョコ停は件数の 64.1% を占めるのに停止時間では 17.3% しかありません。「件数が多いから最優先」と言えるかどうかは、何を減らしたいか次第です。ライン停止の回数(=作業者が呼ばれる回数)を減らしたいなら件数、生産量を取り戻したいなら時間で見ます。この 2 つを混ぜて議論すると話が噛み合いません。

7. OEE を計算して検算する

区間表ができていれば、OEE の計算は足し算と割り算だけです。

def calc_oee(df: pd.DataFrame, iv: pd.DataFrame) -> dict:
    sec = iv.groupby("state")["duration_s"].sum()
    loading = float(len(df) * SAMPLE_SEC) - float(sec.get(IDLE, 0.0))  # 就業 - 計画休止
    run = float(sec.get(RUN, 0.0))
    good, ng = int(df["good_count"].sum()), int(df["ng_count"].sum())
    total = good + ng
    a = run / loading
    # 生産カウンタの無いログ(PLC のビットログ等)は時間稼働率だけ返す
    p = CYCLE_TIME_SEC * total / run if total else None
    q = good / total if total else None
    return {"負荷時間": loading, "稼働時間": run, "生産数": total, "良品数": good, "不良数": ng,
            "時間稼働率": a, "性能稼働率": p, "良品率": q,
            "OEE": a * p * q if total else None}

生産数が 0 のときに None を返しているのは、生産カウンタが入っていないログでも時間稼働率だけは出せるようにするためです(10 章)。PLC のビットログには良品数の列が無いことがよくあります。

7.1 負荷時間に何を入れるかを先に決める

最初に決めるのは負荷時間です。ここでは「就業時間 − 計画休止」としました。昼休みや朝礼で計画的に止めている時間まで分母に入れると、稼働率は実態より低く出ます。逆に段取り替えを計画休止に入れるかどうかは現場の判断で、入れれば OEE は上がります。どちらでもいいのですが、決めたら変えないでください。 途中で定義を変えると、前月との比較が無意味になります。本記事は段取りを負荷時間に含めています(=損失として数えています)。

7.2 3 因子を掛けたら、別経路で検算する

[5] OEE 計算
   calendar      =     32400 秒 =  9.000 時間
   planned_stop  =      3900 秒 =  1.083 時間
   loading       =     28500 秒 =  7.917 時間
   run           =     21723 秒 =  6.034 時間
   setup         =      2814 秒 =  0.782 時間
   stop          =      2132 秒 =  0.592 時間
   fault         =      1831 秒 =  0.509 時間
   生産数 676 個(良品 667 / 不良 9)
   時間稼働率 A = 運転 21723 / 負荷 28500 = 76.22%
   性能稼働率 P = 30.0*676 / 21723 = 93.36%
   良品率     Q = 667 / 676 = 98.67%
   OEE = 76.22% x 93.36% x 98.67% = 70.21%
   検算(理論CT×良品数/負荷時間) = 70.21%  差 0.0000 ポイント

OEE 70.21%。内訳は時間稼働率 76.22%、性能稼働率 93.36%、良品率 98.67% です。時間稼働率だけを見て「76%」と報告していたら、性能と品質で失っている 6 ポイントは見えません。

最後の行が検算です。OEE は定義を展開すると (理論サイクルタイム × 良品数)÷ 負荷時間 に一致します。30.0 × 667 ÷ 28,500 = 70.21%。3 つの率を掛けた値との差は 0.0000 ポイントでした。この検算は必ず入れてください。 分母に何を入れたかを間違えると 3 因子の掛け算は通ってしまいますが、この別経路とは一致しなくなります。

7.3 性能稼働率は理論サイクルタイムの仮定に全依存する

3 因子のうち、ログだけでは決まらないものが 1 つあります。性能稼働率は、理論サイクルタイム(本記事では 30.0 秒)という仮定に全面的に依存します。 理論値が実力より甘いと 100% を超えますし、厳しすぎると万年 80% になります。性能稼働率が 100% を超えたら設備が速くなったのではなく、理論サイクルタイムの設定が間違っています。この数字だけは、ログからは出てきません。 設備の仕様書か、最良条件での実測から持ってくる必要があります。

8. 可視化 3 種 — 稼働帯・停止理由パレート・時間帯ヒートマップ

数字が出たら、次は「どこを触るか」を決める材料にします。3 枚だけ描きます。

なお本記事に貼っている図は、matplotlib の出力を PNG にせず SVG の文字列として書き出し、HTML に直接埋め込んだものです。画像ファイルを 1 つも置かずに済み、拡大しても劣化しません。

import io
import matplotlib
matplotlib.use("Agg")                       # 画面のない環境でも描ける
import matplotlib.pyplot as plt

plt.rcParams["font.family"] = "Yu Gothic"   # 日本語が豆腐になる環境では変える
plt.rcParams["svg.fonttype"] = "none"       # 文字を <text> のまま出す=軽い

buf = io.StringIO()
fig.savefig(buf, format="svg", bbox_inches="tight")
svg = buf.getvalue()                        # そのまま HTML に貼れる文字列

肝は plt.rcParams["svg.fonttype"] = "none" です。既定では文字が図形(パス)に変換されてファイルが膨らみますが、これを none にすると文字が text 要素のまま出ます。本記事に埋め込んだ 3 枚の svg 要素は 25,713 / 17,625 / 27,201 バイト、合計 70,539 バイト(約 69 KB)でした。ただし表示側のフォントに依存するので、読者の環境にそのフォントが無ければ別の書体で表示されます。図の中の文字が図形として固定されていないと困る用途(帳票など)では PNG のほうが安全です。

なお、本記事に貼った 3 枚は matplotlib の出力そのままではありません。HTML に直接貼るために、次の 5 点を後処理しています。

  • XML 宣言と DOCTYPE を落とす(HTML 文書の途中には置けないため)
  • metadata 要素を落とす
  • font-family を Web 用のスタック(Yu Gothic → ヒラギノ角ゴ → Noto Sans JP → Meiryo → sans-serif)へ置換する
  • viewBox / min-width / role / aria-labelledby と title / desc を付ける(拡大縮小と読み上げのため)
  • clipPath などの id に図ごとの接頭辞を付ける

最後の 1 つが、実際に貼ってみるまで気づきにくい点です。matplotlib が振る id は figure_1 / axes_1 / patch_1 のような総称名なので、同じページに 2 枚以上貼ると id がぶつかります。 本記事では図ごとに fig-gantt- / fig-pareto- / fig-heatmap- を付けて、参照(url(#…) と xlink:href)も同時に書き換えています。

8.1 稼働帯 — broken_barh でガントチャート風に 1 日を 1 本の帯で

1 章に貼った図がこれです。設備の稼働状況を時間軸の帯で見せる、いわゆるガントチャート形式で、matplotlib なら専用ライブラリを入れずに broken_barh だけで描けます。渡すのは「(開始, 幅)」のタプルを状態ごとにまとめたリストだけです。

COLOR = {RUN: "#2b8a5a", STOP: "#e8a33d", FAULT: "#c0392b", SETUP: "#5b7ea8", IDLE: "#b8bec7"}
base = iv["start"].iloc[0]

fig, ax = plt.subplots(figsize=(9.5, 2.4))
for st, color in COLOR.items():
    xr = [((r.start - base).total_seconds() / 3600, r.duration_s / 3600)
          for r in iv[iv["state"] == st].itertuples()]      # (開始, 幅) のタプル列
    if xr:
        ax.broken_barh(xr, (0.2, 0.6), facecolors=color, label=st)
ax.set_yticks([]); ax.set_xlim(0, SHIFT_HOURS)

この図は数値を読むためのものではなく、おかしな形をしていないかを確かめるためのものです。朝一番に長い段取りがある、昼前に停止が集中している、といった形はここで気づけます。集計を始める前に 1 枚描いておくと、後で「そんなはずはない」と言われたときの確認が早くなります。

8.2 停止理由パレート — 上位 3 つで 84.1%

pareto = (stops.groupby("reason", observed=True)
          .agg(件数=("duration_s", "size"), 合計秒=("duration_s", "sum"))
          .sort_values("合計秒", ascending=False))
pareto["合計分"] = (pareto["合計秒"] / 60).round(1)
pareto["累積%"] = (pareto["合計秒"].cumsum() / pareto["合計秒"].sum() * 100).round(1)

fig, ax = plt.subplots(figsize=(7.5, 3.6))
x = np.arange(len(pareto))
ax.bar(x, pareto["合計分"], color="#5b7ea8")
ax2 = ax.twinx()                                   # 右軸に累積構成比を重ねる
ax2.plot(x, pareto["累積%"], color="#c0392b", marker="o")
ax2.axhline(80, color="#c0392b", ls="--", lw=0.8, alpha=0.6)
ax2.set_ylim(0, 105)
停止理由別の停止時間パレート図 停止理由を停止時間の合計が多い順に棒で並べ、右軸に累積構成比の折れ線を重ねた図。上位3つの理由で累積84.1%に達し、80%の補助線との交点が改善の絞り込み範囲を示す。架空データ。 品質調整 センサ誤検知 ワーク詰まり 刃具交換 材料切れ 起動待ち 0 5 10 15 20 25 30 35 停止時間の合計(分) 停止理由パレート図(時間基準・架空データ) 0 20 40 60 80 100 累積構成比(%)
図 2: 停止理由別のパレート図(bar + twinx・時間基準)。破線は累積 80%。架空データです。
停止理由件数合計時間累積構成比
品質調整736.3 分55.0%
センサ誤検知1113.9 分76.0%
ワーク詰まり115.3 分84.1%
刃具交換24.6 分91.0%
材料切れ43.0 分95.6%
起動待ち42.9 分100.0%

時間で見ると品質調整が突出しています(7 件で 36.3 分=全体の 55.0%)。ところが件数で並べ替えると順位が入れ替わります。 チョコ停(5 分未満)に絞って件数で数えると、センサ誤検知 11 件(13.9 分)、ワーク詰まり 11 件(5.3 分)、品質調整 6 件(10.3 分)の順です。品質調整は1 件だけ 26 分の長い停止を含んでいて、それが時間基準の 1 位を作っていました。

パレート図は 1 枚では足りません。 時間基準と件数基準の 2 枚を並べて、初めて「長く止まる 1 件」と「何度も止まる多数」が区別できます。前者は保全の計画、後者は治具やセンサの手当てで、打つ手がまったく違います。

なおパレート図の描き方そのものと、不良項目別のパレート・Cp / Cpk・管理図は Python で製造データを可視化する — pandas と Matplotlib で品質管理グラフを作る で扱っています。本記事のパレートは停止理由に限定しているので、品質特性を見たい場合はそちらへ。

8.3 時間帯ヒートマップ — いつ集中しているか

1 日分では傾向が見えないので、5 日分(162,000 行)のログを作り、チョコ停 184 件を「日 × 時間帯」で数えます。

図 3 だけは 12 章の完成コードの出力ではありません。 完成コードは 1 日分を前提にしているので、この図は下のスニペットを単体で走らせて描いたものです。またこの 5 日分は「毎朝の立ち上げから始まる 1 日」を 5 回ぶん並べた生成で、6 章で使う連続 5 日分(停止 179 件)とは生成のしかたが違います。停止の件数が一致しないのはそのためです。

# 5 日分の架空ログからチョコ停(5 分未満)だけを取り出し、日 × 時間帯で数える
s5 = iv5[(iv5["state"].isin(["停止", "異常"])) & (iv5["duration_s"] < 300)].copy()
sec5 = (s5["start"] - pd.Timestamp(SHIFT_START)).dt.total_seconds()
s5["day"] = (sec5 // (SHIFT_HOURS * 3600)).astype(int)
s5["hour"] = 8 + (sec5 % (SHIFT_HOURS * 3600)) // 3600
mat = (s5.pivot_table(index="day", columns="hour", values="duration_s", aggfunc="size")
       .reindex(index=range(5), columns=range(8, 8 + SHIFT_HOURS)).fillna(0).to_numpy())

fig, ax = plt.subplots(figsize=(7.5, 3.0))
mesh = ax.pcolormesh(np.arange(SHIFT_HOURS + 1), np.arange(6), mat,
                     cmap="YlOrRd", shading="flat", edgecolors="white", linewidth=0.6)
ax.invert_yaxis()
fig.colorbar(mesh, ax=ax, label="件数")
チョコ停の件数を日 x 時間帯で示したヒートマップ 縦軸に5日分の日付、横軸に8時から16時台までの時間帯を取り、5分未満の短時間停止の件数を色の濃さで示した格子図。架空データ。 8時 9時 10時 11時 12時 13時 14時 15時 16時 1日目 2日目 3日目 4日目 5日目 チョコ停(5分未満)の件数ヒートマップ(架空データ・5日分) 0 1 2 3 4 5 6 7 8 件数
図 3: チョコ停(5 分未満)の件数を日 × 時間帯で見たヒートマップ(pcolormesh・5 日分 184 件)。架空データです。

色が濃いマスが、短い停止の多い時間帯です。この図の役割は、原因の候補を絞ることです。 特定の時間帯に偏るなら、材料ロットの切り替わり・段取り直後・作業者の交代・室温といった、時間に紐づく要因を疑えます。どこにも偏らないなら、時間帯以外の軸(品種・金型・シフト)で数え直すことになります。「朝一番と昼休み明けに濃い」なら立ち上げ、「夕方だけ濃い」なら疲労や刃具の摩耗、という具合に仮説の当たりを付ける道具です。ただし本記事のこの図は架空データなので、模様自体に意味はありません。 読み方の型として使ってください。

3 枚を毎回コマンドで描くのが面倒になったら、画面にする手もあります。ブラウザで期間を選んで見られるようにするなら Streamlit で品質管理ダッシュボードを 1 日で作る — pandas から配信まで が次の一歩です。この記事の公開は 2026 年 12 月の予定です。

9. 100 万行で測る — 区間化 0.202 秒・メモリ 34.6 MB

1 秒粒度は行数が増えます。「1 か月分を一度に集計したい」と言われたときに耐えるのかを測りました。自宅の検証機(Windows 11 Pro / Python 3.12.10 / pandas 3.0.3)での実測値で、環境が違えば当然変わります。

日数行数生成区間化集計区間数メモリ
(object)
メモリ
(category)
区間化
(category)
132,4000.008 秒0.011 秒0.0006 秒861.1 MB0.4 MB0.006 秒
5162,0000.039 秒0.036 秒0.0009 秒4195.5 MB1.9 MB0.009 秒
311,004,4000.552 秒0.202 秒0.0018 秒2,50234.6 MB11.5 MB0.038 秒
1113,596,4006.586 秒0.857 秒0.0051 秒9,031123.9 MB41.2 MB0.139 秒

表の「生成」列の 1 日分は 0.008 秒ですが、1 章と 14 章に書いた 0.013 秒とは別の計測です。0.013 秒は 1 日分を単独で 1 回だけ生成したときの値、この表は同じプロセスの中で日数を変えて連続して回したときの値で、連続実行で温まっているぶん速く出たものと見ています。どちらも検証機での実測値で、値そのものは加工していません。

100 万行の区間化が 0.202 秒、そこからの集計が 0.0018 秒。 1 か月分を一度に処理しても待ち時間になりません。行数が 31 倍で区間化時間は約 18 倍、111 倍でも約 78 倍と、行数の増え方ほどには伸びていません。メモリのほうはほぼ比例で、100 万行で 34.6 MB。この規模で pandas をやめる理由はありません。

効くのは category です。本記事の架空ログでは state が 5 種類、reason も 8 種類(停止理由 6 つ+段取りの「朝の立ち上げ」「品種切替」。運転中と計画休止中は空)しか値が無いのに、既定では 1 行ごとに Python の文字列オブジェクトを持ちます。df["state"].unique() と df["reason"].unique() を叩けば、自分のログでも同じことがすぐ分かります。

df["state"] = df["state"].astype("category")     # 5 種類しかない文字列を category に
df["reason"] = df["reason"].astype("category")

これだけで 100 万行のメモリが 34.6 MB → 11.5 MB、区間化が 0.202 秒 → 0.038 秒(約 5.3 倍速)になりました。文字列の比較が整数コードの比較に変わるためです。CSV を読む時点で dtype に指定してしまうのが楽です(10 章)。

ここまでは「1 か月分をまとめて処理する」話です。秒単位のデータを止めずに貯め続けて、いつでも任意区間を切り出したいという要件になると、ファイルに貯めるやり方は限界が来ます。時系列データベースに移す選択肢は InfluxDB と Grafana で製造データを時系列可視化する完全ガイド で扱います。この記事の公開は 2026 年 12 月の予定です。

10. 自分のログを読ませる — cp932・列名違い・生産カウンタ無しの CSV を通す

ここが架空データと現場のログの境目です。設備から吸い出した CSV を python oee_report.py machine_log.csv に渡すと、たいてい集計結果ではなくトレースバックが出ます。落ちる場所は決まっていて、文字コード・列名・生産カウンタの 3 か所です。現場でよくある形の CSV を検証機で作って、順に確かめました。

その前に、自分のログの「形」を確かめてください。 ここから先で直すのは文字コード・列名・カウンタの 3 つですが、それ以前に形が違うログは、エラーを出さずに間違った数字を返します。 読ませる前に、記録の間隔と列の並びを見ます。

df = load_log("machine_log.csv")               # load_log() は 10.2 で定義します
df["timestamp"].diff().value_counts().head()   # 記録の間隔が一定か
list(df.columns)                               # 設備を区別する列があるか(=複数台が混ざっていないか)

この 3 行は先に 10.2 の load_log() を定義してから実行してください(前方参照です)。素の pd.read_csv() で読むと、日本語列名の CSV ではこの先で直す KeyError のほうが先に出ます。

  • 記録の間隔が一定(1 秒なら 1 秒)なら固定周期型で、この章と 12 章のコードを使えます。ただしSAMPLE_SEC をその周期(秒)に必ず合わせてください。合わせ忘れても例外は出ず、負荷時間の計算(行数 × SAMPLE_SEC − 計画休止)だけが狂います。1 分ごとに 1 行のログを SAMPLE_SEC = 1 のまま渡した実測では、540 行 × 1 秒 − 計画休止 3,723 秒で負荷時間 −3,183 秒・時間稼働率 −628.56% になりました。マイナスならすぐ気づけますが、条件次第では「それらしい別の数字」になり得ます。SAMPLE_SEC = 60 に直すと負荷時間 28,500 秒・時間稼働率 75.79% と、1 秒粒度の 76.22%(7 章)に一致する水準に戻りました
  • ただし 1 分粒度では、チョコ停の分析には粒度が足りません。 同じ 1 日を 1 分ごとに間引くと 1 分未満の停止が消え、実測では停止区間 39 件 → 25 件・チョコ停 38 件 → 22 件。生産数も間引きで失われます(676 個 → 9 個)。粒度を変えるなら、カウンタの取り方から見直してください
  • 間隔がばらばらなら状態遷移型(状態が変わった瞬間だけ 1 行)です。このまま渡すと load_log() も to_intervals() も例外を出しません。変化点しか行が無いので区間が 1 行ずつになり、継続時間が全部 1 秒(SAMPLE_SEC)として集計されます——止まるのではなく、もっともらしい別物の数字が出ます。resample("1s").ffill() で固定周期に直してから流し込んでください(3 章・FAQ)
  • 1 ファイルに複数台ぶんが入っているなら、設備ごとに分けてから渡します。状態だけをキーに区間化すると、区間が台をまたいでつながります。設備を区別する列で groupby し、1 台ずつ to_intervals() に通してください。本記事は 1 設備・1 ファイルを前提にしています。

10.1 最初に出るのは文字コードのエラー

設備や MES から出てくる CSV は、Excel でそのまま開ける cp932(Shift_JIS)で書かれていることがよくあります。pandas の既定は UTF-8 なので、そのまま読むと最初の日本語のところで落ちます。

  cp932 で書き出し: 1.04 MB
  pd.read_csv(..., encoding='utf-8') -> UnicodeDecodeError: 'utf-8' codec can't decode byte 0x92 in position 0: invalid start byte
    参考: 0x92 はファイル内のオフセット 64(先頭から 65 バイト目)にあるが、pandas は読み込みバッファ内の相対位置(position 0)を報告する
  load_log() 経由 -> 32,400 行 / OEE 70.21% / utf-8 版 70.21% -> 一致: True

UnicodeDecodeError: 'utf-8' codec can't decode byte 0x92 in position 0。同じログを後述の load_log() で読むと 32,400 行を読み切り、OEE は 70.21%。7 章で出した utf-8 版の値と一致しました(上の実測でも 一致: True)。文字コードが違っても中身は同じなので、読めさえすれば数字は変わりません。

ひとつ誤読しやすいのが position 0 です。これは「ファイルの 1 バイト目」という意味ではありません。 今回のファイルで 0x92 が実際に現れるのはオフセット 64(0 から数えた値。1 から数えれば先頭から 65 バイト目で、最初のデータ行の「段取り」の「段」の 1 バイト目)でしたが、pandas の C パーサは読み込みバッファの中での相対位置を報告するため position 0 と出ます。この数字を頼りに CSV の先頭を見に行っても、たいてい何も見つかりません。 位置ではなく「cp932 のファイルを UTF-8 で読んだ」という事実のほうを見てください。

手で書くなら encoding="cp932"、先頭の列名に見えない文字(BOM)が付くなら encoding="utf-8-sig"。この 2 つでほぼ通ります。文字化けの判別(読めたのに文字が壊れている場合)や、書き出し側・print 側まで含めた対処は Python の文字化けを直す|cp932 と UTF-8 の完全対策(Windows 現場PC編) にまとめています。

10.2 列名と状態名を吸収する — load_log()

次に来るのが列名です。日時 / 状態 / 停止理由 のような日本語列名のログを素の read_csv で読んで calc_oee() に渡すと、こうなります。

  列名 ['日時', '状態', '停止理由'] / 状態は英語 / 生産カウンタ無し・cp932
  素の read_csv + calc_oee -> KeyError: "Label(s) ['reason'] do not exist"
  load_log() 経由 -> 32,400 行 / 時間稼働率 76.22% / OEE=None(生産数 0)
  停止 39 件 / チョコ停(5 分未満)38 件 — 時間側の分析はそのまま通る

この 3 つを 1 か所で吸収するのが、完成コードの load_log() です。列名は COLUMN_ALIAS、状態名は STATE_ALIAS に 1 行足せば増やせます。 自分のログの言葉をここに書き写すのが、最初にやる作業になります。

ENCODINGS = ("utf-8-sig", "cp932")        # 現場の CSV は cp932 のことが多い
COLUMN_ALIAS = {"日時": "timestamp", "時刻": "timestamp", "datetime": "timestamp", "状態": "state",
                "status": "state", "停止理由": "reason", "良品数": "good_count", "不良数": "ng_count"}
STATE_ALIAS = {"RUN": RUN, "稼働": RUN, "STOP": STOP, "IDLE": IDLE, "ALARM": FAULT,
               "故障": FAULT, "SETUP": SETUP, "段取": SETUP}


def load_log(path: str) -> pd.DataFrame:
    """CSV を読み、列名・状態名・文字コードの違いを吸収する。"""
    for enc in ENCODINGS:
        try:
            df = pd.read_csv(path, encoding=enc)
            break
        except UnicodeDecodeError:              # 文字コード違いは次の候補へ
            continue
    else:
        raise SystemExit(f"文字コードを判定できません(試した: {ENCODINGS})")
    df = df.rename(columns={c: COLUMN_ALIAS.get(c.strip(), c.strip()) for c in df.columns})
    for col in ("timestamp", "state"):
        if col not in df.columns:
            raise SystemExit(f"列 '{col}' がありません。実際の列: {list(df.columns)}")
    df["timestamp"] = pd.to_datetime(df["timestamp"])
    df["state"] = df["state"].astype(str).str.strip().replace(STATE_ALIAS)
    unknown = set(df["state"].unique()) - {RUN, STOP, FAULT, SETUP, IDLE}
    if unknown:
        print(f"警告: 未知の状態 {sorted(unknown)} は稼働にも停止にも数えません")
    if "reason" not in df.columns:
        df["reason"] = None
    for col in ("good_count", "ng_count"):      # 生産カウンタが無いログでも動かす
        if col not in df.columns:
            df[col] = 0
        df[col] = pd.to_numeric(df[col], errors="coerce").fillna(0).astype("int32")
    return df.sort_values("timestamp").reset_index(drop=True)

知らない状態名が混ざっていたら、黙って捨てずに警告を出します。 運転にも停止にも数えないので、その時間は負荷時間の中に残ったままになります。

[E] 未知の状態が混ざったログ
  load_log の標準出力: 警告: 未知の状態 ['WARMUP'] は稼働にも停止にも数えません
  時間稼働率 76.22% / OEE 70.21%(未知の状態 100 秒は負荷時間に残り、稼働には数えない)

WARMUP が 100 行混ざったログで試したところ、警告が出たうえで時間稼働率 76.22%・OEE 70.21%。この扱いでいいのかは、その状態が何なのかを知っている人にしか決められません。 暖機を「稼働」に数えるのか「計画休止」に寄せるのかで、時間稼働率は変わります。警告が出たら STATE_ALIAS に行を足して、どちらに寄せたかを記録してください。

10.3 生産カウンタが無いログでも、ここまでは出せる

PLC のビットログには、良品数・不良数のカウンタが入っていないことがよくあります。 この場合 OEE の 3 因子のうち性能稼働率と良品率は計算できません。理論サイクルタイムを掛ける相手(生産数)も、良品率の分母も無いからです。

それでも止まりません。時間稼働率 76.22%、停止 39 件、チョコ停(5 分未満)38 件——6 章でやった停止分析と 8 章のパレートは、そのまま出ます。calc_oee() は生産数が 0 なら性能稼働率・良品率・OEE を None で返し、時間稼働率だけを報告します。

ここが「まず始められる」入口です。 一般に「稼働率」と呼ばれているのは時間稼働率のことなので(2 章)、カウンタが無くても今の報告と同じ土俵には立てます。OEE まで出したいなら、生産数を日報や検査記録など別のソースから 1 日 1 個の値として持ってきて、good_count / ng_count の列を自分で足してから流し込んでください。

10.4 CSV と SQLite の読み書きを測る

読めるようになったところで、速度とサイズです。1 日分 32,400 行で測りました。

# CSV:列の型を指定して読む(文字コードと列名は load_log() が吸収する)
df = pd.read_csv("machine_log.csv", parse_dates=["timestamp"],
                 dtype={"state": "category", "reason": "category",
                        "good_count": "int8", "ng_count": "int8"})

# SQLite:必要な期間だけを取り出す
con = sqlite3.connect("machine_log.sqlite3")
df.assign(timestamp=df["timestamp"].astype(str)).to_sql("machine_log", con, index=False)
con.execute("CREATE INDEX idx_ts ON machine_log(timestamp)")
df = pd.read_sql_query(
    "SELECT timestamp, state, reason, good_count, ng_count FROM machine_log "
    "WHERE timestamp >= '2026-10-05 08:00:00' AND timestamp < '2026-10-05 17:00:00' "
    "ORDER BY timestamp", con, parse_dates=["timestamp"])
[9] CSV / SQLite 入出力の実測(1日分 32,400 行)
   CSV 書き 0.042 秒 / 読み 0.036 秒 / サイズ 1.14 MB
   CSV 読み(dtype 指定)0.038 秒 / メモリ 1.5 MB -> 0.4 MB
   SQLite 書き 0.066 秒 / 期間指定 SELECT 0.066 秒 / 32,400 行 / DB 2.11 MB

CSV は 1 日分で 1.14 MB、読み書きとも 0.04 秒前後。1 か月分を 1 ファイルにしても単純計算で 35 MB 程度なので、ファイルで回す運用でも当面は困りません。

ここで正直に書いておくと、dtype を指定した読み込みは速くなりませんでした(0.036 秒 → 0.038 秒)。category への変換コストが読み込み中にかかるためです。効いたのはメモリのほうで、1.5 MB → 0.4 MB。読み込み自体を速くしたくて指定するのではなく、読み込んだ後の処理(区間化)を速くするために指定します。

SQLite なら、期間を指定して必要な分だけ取り出せます。1 日分の書き込みが 0.066 秒、時刻範囲を指定した SELECT が 0.066 秒、DB サイズは 2.11 MB(CSV の約 1.9 倍)でした。サイズは増えますが、「先月の第 2 週だけ」を取るのにファイルを全部読まなくて済みます。 スキーマの設計や肥大化への対処は 製造データを SQLite で管理する — Excel からデータベース運用へ にまとめています。

11. 現場データでつまずきやすい 4 つの落とし穴

ここまでは、きれいなログを前提にしてきました。実データはきれいではありません。4 つとも、人工的にその状況を作って再現したものです。 実機で起きた事象を観測したわけではないので、頻度や周期については何も言えません。

11.1 欠測 — 行数で数えても区間で数えても合わない

収集プログラムが落ちていた、ネットワークが切れていた、PC が再起動した。理由は何であれ、ログには穴が空きます。10 分ぶん(600 行)を削って測りました。

   (a) 欠測 600 行を落とす -> 行数 31,800
       行数で数えた運転時間   : 21,198 秒
       timestamp.diff() の最大: 0 days 00:10:01
       穴のある箇所           : 1
       欠測ありの OEE 69.89% / 欠測なし 70.21% -> 差 -0.32 ポイント
       ...
[13] 欠測があると『行数』と『区間の幅』がズレる
   行数 = 31,800 秒 / 区間幅の合計 = 32400 秒(差 +600 秒)
   運転: 行数 21,198 秒 / 区間幅 21798 秒(欠測なしの正解 21723 秒)
   -> 区間幅は穴をまたいで水増しされる。欠測を検出してから積むこと
   欠測の検出: (diff > 1s) が 1 箇所 / 最大 0 days 00:10:01
   resample('1s').ffill() で穴を埋め直す -> 32,400 行(元 31,800 行)

この 2 つの出力が示しているのは、欠測があると「行数で数える方法」も「区間の幅で数える方法」も、どちらも間違えるということです。

  • 行数で数えると足りなくなる — 運転 21,198 秒(正解は 21,723 秒)。消えた行の分だけ減ります
  • 区間の幅で数えると増える — 運転 21,798 秒。穴の前後が 1 つの区間としてつながり、穴の 600 秒が運転時間に化けました

OEE への影響は 70.21% → 69.89%(−0.32 ポイント)。報告書の数字としては誤差に見えるのが、かえって厄介です。 気づかないまま毎月ずれ続けます。

検出は簡単で、時刻の差分を取るだけです。

gap = df["timestamp"].diff()
holes = df.loc[gap > pd.Timedelta("1s"), "timestamp"]   # 穴が空いた位置
print(len(holes), gap.max())

# 穴を埋め直してから積む(頻度文字列は小文字が正)
fixed = df.set_index("timestamp")["state"].resample("1s").ffill()

本記事のログでは diff が 1 秒を超えた箇所が 1 つ、最大 0 days 00:10:01 でした。削ったのは 600 行(10 分)ちょうどなのに 10 分 1 秒と出るのは、穴の前の行と後ろの行の間隔を測っているからです(消えた 600 秒+もともとの 1 秒)。見つけたら、resample("1s").ffill() で直前の状態を伸ばして埋めるか(31,800 行 → 32,400 行に復元)、その区間を「不明」として集計から外します。ただし上の 1 行が返すのは state だけの Series なので、区間化に回すには DataFrame のまま埋め直す必要があります。

r = df.set_index("timestamp").resample("1s").first()
gap = r["state"].isna()                     # 欠測で埋まった行
r["state"] = r["state"].ffill()             # 状態だけ直前の値を伸ばす
r.loc[gap, "reason"] = "欠測"                # 埋めた行に印を残す(reason は前方補完しない)
r[["good_count", "ng_count"]] = r[["good_count", "ng_count"]].fillna(0).astype("int32")
fixed = r.reset_index()

reason まで前方補完してはいけません。 運転中の理由は空なので、直前の停止理由が運転区間に塗り広がってパレートが狂います。カウンタ列も 0 埋めが正解で、前方補完すると生産数が水増しされます。そして埋め直しで戻るのは時刻と状態だけで、消えた区間に載っていた生産数は戻りません。どちらにしても、穴があったことを報告に残してください。 「この日は 10 分ぶん記録が無いため、負荷時間から除外した」と 1 行書いてあるかどうかで、数字の信頼性が変わります。

なお resample の頻度文字列は小文字が正です。検証機の pandas 3.0.3 で全部試しました。

  pandas 3.0.3
  resample("1min") -> OK(警告 0 件)
  resample("1MIN") -> OK(警告 1 件) / Pandas4Warning: 'MIN' is deprecated and will be removed in a future version, please us
  resample("1h") -> OK(警告 0 件)
  resample("1H") -> ValueError: Invalid frequency: 1H. Failed to parse with error message: ValueError("Invalid frequency:
  resample("1s") -> OK(警告 0 件)
  resample("1S") -> ValueError: Invalid frequency: 1S. Failed to parse with error message: ValueError("Invalid frequency:
  resample("1T") -> ValueError: Invalid frequency: 1T. Failed to parse with error message: ValueError("Invalid frequency: 

大文字の "1H" / "1S" / "1T" は、非推奨の警告ではなく ValueError: Invalid frequency でその場で落ちます(筆者環境の pandas 3.0.3 での実測)。"1MIN" は動きますが Pandas4Warning: 'MIN' is deprecated が出ます。古い記事のコードをコピーして Invalid frequency が出たら、ここを疑ってください。

11.2 チャタリング — 1 秒のノイズで停止が 434 件に増える

センサのばたつきや通信の取りこぼしで、運転中に 1 秒だけ「停止」が混じることがあります。運転中の 400 行を単発で停止に反転させて測りました。

   (b) 運転中の 400 行を単発で停止に反転(1 秒のチャタリング)
       区間数 86 -> 876(+790) / 停止区間 39 件 -> 434 件
       3 秒未満をデバウンス -> 区間数 86 / 停止区間 39 件 / 所要 0.006 秒 / OEE 70.21%

区間が 86 から 876 に、停止区間が 39 件から 434 件に。 チョコ停の件数を数えているつもりで、センサのノイズを数えていたことになります。「チョコ停が月に 400 件あります」という報告が出てきたら、まず疑うのはここです。

本来の手当ては収集より前にあります。 接点のばたつきなら PLC 側の入力フィルタやオンディレイタイマで殺すのが先で、そこを直せるなら Python 側の処理は要りません。収集周期を落とすのも手です。ここで扱うのは「すでに溜まってしまったログを後から読む」場合の話だと思ってください。

後処理で消すならデバウンス(短すぎる区間を無かったことにする)です。1 回塗りつぶすと隣同士がつながって新しい短区間ができることがあるので、収束するまで繰り返します。

def debounce(frame, min_sec=3, freq_sec=1):
    """min_sec 未満の区間を直前の状態で塗りつぶす(収束するまで繰り返す)"""
    f = frame.copy()
    while True:
        g = (f["state"] != f["state"].shift()).cumsum()
        size = f.groupby(g)["state"].transform("size") * freq_sec
        short = (size < min_sec) & (g > 1)      # 先頭の区間は対象外
        if not short.any():
            return f
        f.loc[short, "state"] = np.nan
        f["state"] = f["state"].ffill()

3 秒未満を消したところ、区間数 86・停止区間 39 件と元に戻り、OEE も 70.21% に一致しました。所要 0.006 秒です。while は塗りつぶしが収束するまで回るので、min_sec を大きく取るときは反復回数の上限も決めておくと安全です。

ただしこれは、本物の短い停止も一緒に消す処理です。 実際に 2 秒で復帰する停止がある設備なら、それも消えます。何秒未満を消したのかは、必ず集計結果と一緒に記録してください。 閾値を変えれば件数が変わる、という点では 6 章の感度分析と同じ話で、デバウンス幅も 1・3・5 秒と振って結果が動くかを見ておくのが安全です。

「先月よりチョコ停が増えた気がする」を自動で拾いたくなったら、統計的な異常検知に載せる道もあります(Python と scikit-learn で製造ラインの異常検知を実装する入門)。

11.3 日跨ぎ — 夜間が 15 時間の 1 区間になる

24 時間収集を続けている設備で、夜間も「計画休止」として記録される場合。2 日分(172,800 行)で試しました。

[12] 日跨ぎ(24 時間ログ)の再現
   24 時間 x 2 日 = 172,800 行 (2026-10-05 08:00:00 〜 2026-10-07 07:59:59)
   単純な shift+cumsum の区間数: 157
   最長の計画休止区間 3 件:
     2026-10-05 17:00:00 〜 2026-10-06 07:59:59  54000 秒 (15.00 時間)
     2026-10-06 17:00:00 〜 2026-10-07 07:59:59  54000 秒 (15.00 時間)
     2026-10-06 12:00:00 〜 2026-10-06 12:44:59  2700 秒 (0.75 時間)
   日付をキーに含めた区間数: 159(+2)
   ...
   日別集計(区間の開始日で寄せた場合・秒):
state           停止     段取り      異常     計画休止       運転
d
2026-10-05  2132.0  2814.0  1831.0  57900.0  21723.0
2026-10-06  2247.0  2478.0  1289.0  57900.0  22486.0
   日別集計(日付キーで割った場合・秒):
state           停止     段取り      異常     計画休止       運転
d
2026-10-05  2132.0  2814.0  1831.0  29100.0  21723.0
2026-10-06  2247.0  2478.0  1289.0  57900.0  22486.0
2026-10-07     0.0     0.0     0.0  28800.0      0.0

状態だけをキーに区間化すると、17:00 から翌 07:59:59 までの計画休止が 54,000 秒(15.00 時間)の 1 区間になります。それ自体は事実として正しいのですが、日別に集計しようとして「区間の開始日」で寄せると、10/05 の計画休止が 57,900 秒になります。 1 日は 86,400 秒なので、まだ破綻はしていません。しかし夜勤をまたぐ運転区間で同じことが起きると、翌日の稼働時間が前日に全部吸い込まれます。

区間化のキーに日付を足すだけで直ります。

key = df["state"].astype(str) + "|" + df["timestamp"].dt.date.astype(str)
gid = (key != key.shift()).cumsum()     # 状態が同じでも日付が変われば別区間にする

区間数は 157 から 159 に増え(夜間が 2 か所で日付の境に割れた)、10/05 の計画休止は 29,100 秒、10/07 に 28,800 秒と正しく分かれました。日単位で報告するなら、日単位で区間を割る。 シフト単位(例: 20:00 始まり)で見たいなら、日付の代わりに「シフト ID」を作って同じことをします。

11.4 タイムゾーンと時刻のずれ

日本国内だけで完結するなら、夏時間の心配はありません。 日本には夏時間制度が無いので、1 日が 23 時間や 25 時間になることもありません。時刻列に日本時間を明示しておきたい場合は次のようにします。

df["timestamp"] = df["timestamp"].dt.tz_localize("Asia/Tokyo")   # 素朴な時刻に日本時間を付ける

海外拠点のログを一緒に集計する場合は話が変わります。 収集側で UTC として保存し、表示するときだけ現地時間に変換するのが定石とされますが、当サイトはこの構成を検証していません。 夏時間の切り替わり日には同じ時刻が 2 回現れたり、存在しない時刻ができたりするため、区間化が壊れる可能性があります。海外拠点を含む集計をする方は、切り替わり日のログで必ず確認してください。

もうひとつ、設備 PC の時計がずれているという問題もあります。複数の設備のログを突き合わせるときに効いてきますが、本記事では未検証です。

12. 毎朝のレポートにする — 完成コード 218 行と運用への接続

ここまでの処理を 1 ファイルにまとめたものが oee_report.py です(218 行)。引数なしで実行すると架空ログを生成して集計し、CSV のパスを渡すと 10 章の load_log() で読んで集計します。

python oee_report.py                  # 架空ログを生成して集計する
python oee_report.py machine_log.csv  # 手元の CSV を集計する

どちらも同じ形式で、OEE・閾値の感度分析・停止理由パレートが順に表示され、gantt.svg と pareto.svg が書き出されます。CSV に必須の列は timestamp と state の 2 つだけです。列名や状態名が違っても COLUMN_ALIAS / STATE_ALIAS に 1 行足せば吸収でき、reason が無ければパレートが空になり、生産カウンタが無ければ時間稼働率までが出ます(10 章)。

"""oee_report.py — 稼働ログから OEE とチョコ停を集計する。

    python oee_report.py                  # 架空ログを生成して集計
    python oee_report.py machine_log.csv  # 手元の CSV を集計

必須列は timestamp と state。列名は COLUMN_ALIAS、状態名は STATE_ALIAS で吸収する。
"""
from __future__ import annotations

import sys
from pathlib import Path

import matplotlib
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd

# ---- 現場ごとに決める値(すべて記事用の架空値)--------------------------
SEED = 20261027          # 架空ログの乱数シード
CYCLE_TIME_SEC = 30.0    # 理論サイクルタイム[秒/個]
CHOCO_STOP_SEC = 300     # チョコ停の上限[秒]。6 章の感度分析で決める
SAMPLE_SEC = 1           # ログの記録周期[秒]
RUN, STOP, FAULT, SETUP, IDLE = "運転", "停止", "異常", "段取り", "計画休止"
STOP_STATES = (STOP, FAULT)
ENCODINGS = ("utf-8-sig", "cp932")        # 現場の CSV は cp932 のことが多い
COLUMN_ALIAS = {"日時": "timestamp", "時刻": "timestamp", "datetime": "timestamp", "状態": "state",
                "status": "state", "停止理由": "reason", "良品数": "good_count", "不良数": "ng_count"}
STATE_ALIAS = {"RUN": RUN, "稼働": RUN, "STOP": STOP, "IDLE": IDLE, "ALARM": FAULT,
               "故障": FAULT, "SETUP": SETUP, "段取": SETUP}
REASONS = ["ワーク詰まり", "センサ誤検知", "材料切れ", "刃具交換", "品質調整", "起動待ち"]
REASON_P = [0.30, 0.22, 0.14, 0.10, 0.12, 0.12]
SHIFT_START, SHIFT_HOURS = "2026-10-05 08:00:00", 9
BREAKS = [("10:00:00", 10), ("12:00:00", 45), ("15:00:00", 10)]

# ---- 1. 架空の稼働ログを作る ------------------------------------------
def make_dummy_log(seed: int = SEED, days: int = 1) -> pd.DataFrame:
    """1 秒粒度の架空稼働ログを作る。実機のログは一切使っていない。"""
    rng = np.random.default_rng(seed)
    n = SHIFT_HOURS * 3600 * days
    idx = pd.date_range(SHIFT_START, periods=n // SAMPLE_SEC, freq=f"{SAMPLE_SEC}s")
    state = np.full(len(idx), RUN, dtype=object)
    reason = np.full(len(idx), None, dtype=object)
    events = [(SETUP, float(rng.integers(600, 900)), "朝の立ち上げ")]
    t = events[0][1]
    while t < n:
        run = float(np.clip(rng.lognormal(6.2, 0.65), 30, 3600))
        events.append((RUN, run, None))
        t += run
        u = rng.random()   # 停止の 9 割は短く、まれに長時間の異常停止になる
        if u < 0.90:
            dur, st = min(float(rng.exponential(40)) + 8, 600), STOP
        elif u < 0.96:
            dur, st = float(rng.uniform(180, 900)), STOP
        else:
            dur, st = float(rng.uniform(900, 2400)), FAULT
        events.append((st, dur, str(rng.choice(REASONS, p=REASON_P))))
        t += dur
        if rng.random() < 0.05:
            events.append((SETUP, float(rng.uniform(600, 1200)), "品種切替"))
            t += events[-1][1]

    pos = 0
    for st, dur, rs in events:                  # イベント列を 1 秒刻みの配列へ展開
        end = min(pos + int(dur // SAMPLE_SEC), len(idx))
        state[pos:end], reason[pos:end], pos = st, rs, end
    for d in range(days):                       # 休憩は計画休止で上書き
        for hhmmss, minutes in BREAKS:
            b0 = pd.Timestamp(f"{idx[0].date()} {hhmmss}") + pd.Timedelta(hours=SHIFT_HOURS * d)
            m = (idx >= b0) & (idx < b0 + pd.Timedelta(minutes=minutes))
            state[m], reason[m] = IDLE, None

    df = pd.DataFrame({"timestamp": idx, "state": state, "reason": reason})
    good = np.zeros(len(df), dtype=np.int8); ng = np.zeros(len(df), dtype=np.int8)
    run_pos = np.flatnonzero(state == RUN)      # 運転中だけ実サイクルごとに 1 個数える
    step = rng.normal(CYCLE_TIME_SEC * 1.07, 2.0, size=len(run_pos) // 20 + 2).clip(20, 60)
    marks, acc, i = [], 0.0, 0
    while int(acc // SAMPLE_SEC) < len(run_pos):
        marks.append(run_pos[int(acc // SAMPLE_SEC)])
        acc, i = acc + float(step[i % len(step)]), i + 1
    marks = np.asarray(marks[1:], dtype=np.int64)
    is_ng = rng.random(len(marks)) < 0.015
    good[marks[~is_ng]], ng[marks[is_ng]] = 1, 1
    return df.assign(good_count=good, ng_count=ng)

# ---- 2. 手元の CSV を読む ----------------------------------------------
def load_log(path: str) -> pd.DataFrame:
    """CSV を読み、列名・状態名・文字コードの違いを吸収する。"""
    for enc in ENCODINGS:
        try:
            df = pd.read_csv(path, encoding=enc)
            break
        except UnicodeDecodeError:              # 文字コード違いは次の候補へ
            continue
    else:
        raise SystemExit(f"文字コードを判定できません(試した: {ENCODINGS})")
    df = df.rename(columns={c: COLUMN_ALIAS.get(c.strip(), c.strip()) for c in df.columns})
    for col in ("timestamp", "state"):
        if col not in df.columns:
            raise SystemExit(f"列 '{col}' がありません。実際の列: {list(df.columns)}")
    df["timestamp"] = pd.to_datetime(df["timestamp"])
    df["state"] = df["state"].astype(str).str.strip().replace(STATE_ALIAS)
    unknown = set(df["state"].unique()) - {RUN, STOP, FAULT, SETUP, IDLE}
    if unknown:
        print(f"警告: 未知の状態 {sorted(unknown)} は稼働にも停止にも数えません")
    if "reason" not in df.columns:
        df["reason"] = None
    for col in ("good_count", "ng_count"):      # 生産カウンタが無いログでも動かす
        if col not in df.columns:
            df[col] = 0
        df[col] = pd.to_numeric(df[col], errors="coerce").fillna(0).astype("int32")
    return df.sort_values("timestamp").reset_index(drop=True)

# ---- 3. 区間化:連続する同じ状態を 1 行にまとめる -----------------------
def to_intervals(df: pd.DataFrame) -> pd.DataFrame:
    gid = (df["state"] != df["state"].shift()).cumsum()   # 変化点で ID を増やす
    iv = df.groupby(gid).agg(state=("state", "first"), reason=("reason", "first"),
                             start=("timestamp", "first"), end=("timestamp", "last"))
    iv["duration_s"] = (iv["end"] - iv["start"]).dt.total_seconds() + SAMPLE_SEC
    return iv.reset_index(drop=True)

# ---- 4. OEE を計算する -------------------------------------------------
def calc_oee(df: pd.DataFrame, iv: pd.DataFrame) -> dict:
    sec = iv.groupby("state")["duration_s"].sum()
    loading = float(len(df) * SAMPLE_SEC) - float(sec.get(IDLE, 0.0))  # 就業 - 計画休止
    run = float(sec.get(RUN, 0.0))
    good, ng = int(df["good_count"].sum()), int(df["ng_count"].sum())
    total = good + ng
    a = run / loading
    # 生産カウンタの無いログ(PLC のビットログ等)は時間稼働率だけ返す
    p = CYCLE_TIME_SEC * total / run if total else None
    q = good / total if total else None
    return {"負荷時間": loading, "稼働時間": run, "生産数": total, "良品数": good, "不良数": ng,
            "時間稼働率": a, "性能稼働率": p, "良品率": q,
            "OEE": a * p * q if total else None}

# ---- 5. チョコ停の閾値を感度分析で決める -------------------------------
def threshold_scan(stops: pd.DataFrame, minutes=(1, 3, 5, 10, 15)) -> pd.DataFrame:
    n, s_all = len(stops), stops["duration_s"].sum()
    rows = []
    for mi in minutes:
        s = stops[stops["duration_s"] < mi * 60]
        rows.append({"閾値(分)": mi, "チョコ停件数": len(s),
                     "件数シェア%": round(len(s) / n * 100, 1),
                     "停止時間シェア%": round(s["duration_s"].sum() / s_all * 100, 1),
                     "ドカ停件数": n - len(s),
                     "ドカ停合計(分)": round((s_all - s["duration_s"].sum()) / 60, 1)})
    return pd.DataFrame(rows)

# ---- 6. 図を描く -------------------------------------------------------
COLOR = {RUN: "#2b8a5a", STOP: "#e8a33d", FAULT: "#c0392b", SETUP: "#5b7ea8", IDLE: "#b8bec7"}

def draw(iv: pd.DataFrame, pareto: pd.DataFrame, outdir: Path) -> None:
    plt.rcParams["font.family"] = "Yu Gothic"      # 環境に合わせて変える
    plt.rcParams["svg.fonttype"] = "none"          # 文字を <text> のまま出す=軽い
    base = iv["start"].iloc[0]
    fig, ax = plt.subplots(figsize=(9.5, 2.4))     # 稼働帯(ガントチャート風)
    for st, color in COLOR.items():
        xr = [((r.start - base).total_seconds() / 3600, r.duration_s / 3600)
              for r in iv[iv["state"] == st].itertuples()]
        if xr:
            ax.broken_barh(xr, (0.2, 0.6), facecolors=color, label=st)
    ax.set_yticks([]); ax.set_xlim(0, SHIFT_HOURS); ax.set_xlabel("時刻")
    ax.set_xticks(range(SHIFT_HOURS + 1))
    ax.set_xticklabels([f"{base.hour + h}:00" for h in range(SHIFT_HOURS + 1)])
    ax.set_title("設備の稼働帯(架空データ・1日分)")
    ax.legend(ncol=5, loc="upper center", bbox_to_anchor=(0.5, -0.35), frameon=False)
    fig.savefig(outdir / "gantt.svg", format="svg", bbox_inches="tight"); plt.close(fig)

    fig, ax = plt.subplots(figsize=(7.5, 3.6))     # 停止理由パレート図
    x = np.arange(len(pareto))
    ax.bar(x, pareto["合計分"], color="#5b7ea8")
    ax.set_xticks(x); ax.set_xticklabels(pareto.index, rotation=20, ha="right")
    ax.set_ylabel("停止時間の合計(分)")
    ax.set_title("停止理由パレート図(時間基準・架空データ)")
    ax2 = ax.twinx()
    ax2.plot(x, pareto["累積%"], color="#c0392b", marker="o")
    ax2.axhline(80, color="#c0392b", ls="--", lw=0.8, alpha=0.6)
    ax2.set_ylim(0, 105); ax2.set_ylabel("累積構成比(%)")
    fig.savefig(outdir / "pareto.svg", format="svg", bbox_inches="tight"); plt.close(fig)

# ---- 7. 実行 -----------------------------------------------------------
def main() -> None:
    outdir = Path(".")
    df = load_log(sys.argv[1]) if len(sys.argv) > 1 else make_dummy_log()
    iv = to_intervals(df)
    stops = iv[iv["state"].isin(STOP_STATES)]
    m = calc_oee(df, iv)
    print(f"負荷時間 {m['負荷時間']/3600:.2f} h / 稼働時間 {m['稼働時間']/3600:.2f} h / "
          f"生産 {m['生産数']} 個(不良 {m['不良数']})")
    if m["OEE"] is None:
        print(f"時間稼働率 {m['時間稼働率']*100:.2f}%(生産数が 0 なので OEE は出せません)")
    else:
        print(f"時間稼働率 {m['時間稼働率']*100:.2f}% x 性能稼働率 {m['性能稼働率']*100:.2f}% "
              f"x 良品率 {m['良品率']*100:.2f}% = OEE {m['OEE']*100:.2f}%")
        print("検算: 理論CT x 良品数 / 負荷時間 = "
              f"{CYCLE_TIME_SEC * m['良品数'] / m['負荷時間'] * 100:.2f}%")
    print("\n[閾値の感度分析]")
    print(threshold_scan(stops).to_string(index=False))
    span_h = (df["timestamp"].max() - df["timestamp"].min()).total_seconds() / 3600
    if span_h < 24 * 7 or len(stops) < 100:    # 6 章:少ない標本の「谷」は偶然のことがある
        print(f"注意: 対象は約 {span_h:.1f} 時間・停止 {len(stops)} 件だけです。"
              "この表だけで閾値を決めず、1 週間・1 か月でも同じ表を作って見比べてください。")
    choco = stops[stops["duration_s"] < CHOCO_STOP_SEC]
    print(f"\nチョコ停({CHOCO_STOP_SEC // 60} 分未満){len(choco)} 件 / "
          f"合計 {choco['duration_s'].sum()/60:.1f} 分")
    pareto = (stops.groupby("reason", observed=True)
              .agg(件数=("duration_s", "size"), 合計秒=("duration_s", "sum"))
              .sort_values("合計秒", ascending=False))
    pareto["合計分"] = (pareto["合計秒"] / 60).round(1)
    pareto["累積%"] = (pareto["合計秒"].cumsum() / pareto["合計秒"].sum() * 100).round(1)
    print("\n[停止理由パレート(時間基準)]")
    print(pareto[["件数", "合計分", "累積%"]].to_string())
    draw(iv, pareto, outdir)
    print(f"\n図を {outdir.resolve()} に gantt.svg / pareto.svg として書き出しました")

if __name__ == "__main__":
    matplotlib.use("Agg")
    main()

架空ログで実行した結果です。

負荷時間 7.92 h / 稼働時間 6.03 h / 生産 676 個(不良 9)
時間稼働率 76.22% x 性能稼働率 93.36% x 良品率 98.67% = OEE 70.21%
検算: 理論CT x 良品数 / 負荷時間 = 70.21%

[閾値の感度分析]
 閾値(分)  チョコ停件数  件数シェア%  停止時間シェア%  ドカ停件数  ドカ停合計(分)
     1      25    64.1      17.3     14      54.6
     3      35    89.7      41.2      4      38.8
     5      38    97.4      60.6      1      26.0
    10      38    97.4      60.6      1      26.0
    15      38    97.4      60.6      1      26.0
注意: 対象は約 9.0 時間・停止 39 件だけです。この表だけで閾値を決めず、1 週間・1 か月でも同じ表を作って見比べてください。

チョコ停(5 分未満)38 件 / 合計 40.0 分

[停止理由パレート(時間基準)]
        件数   合計分    累積%
reason
品質調整     7  36.3   55.0
センサ誤検知  11  13.9   76.0
ワーク詰まり  11   5.3   84.1
刃具交換     2   4.6   91.0
材料切れ     4   3.0   95.6
起動待ち     4   2.9  100.0

図を(実行したディレクトリ)に gantt.svg / pareto.svg として書き出しました   ...(パスは省略)

出力の途中に入っている「注意: 対象は約 9.0 時間・停止 39 件だけです」は、6 章の結論をそのままコードに埋め込んだものです。このスクリプトは、渡された CSV が何日分なのかを知りません。 1 日分を渡せば 1 日分の感度分析が出て、6.1 で見たのと同じ「谷があるように見える表」がそのまま画面に出ます。そこで対象期間が 1 週間に満たないか、停止が 100 件に満たないときは、表の下に警告を出すようにしました(コードの span_h の 4 行)。閾値を決める材料として使うなら、複数日を 1 本にまとめた CSV を渡してください(6.3)。

冒頭の定数がこのスクリプトの設定項目のすべてです。CYCLE_TIME_SEC(理論サイクルタイム)と CHOCO_STOP_SEC(チョコ停の上限)は、あなたの設備の値に必ず置き換えてください。 30.0 秒と 300 秒は記事用の架空値です。前者は 7 章のとおり性能稼働率を直接動かし、後者は 6 章の感度分析で選びます。ログが 1 分ごとに 1 行なら SAMPLE_SEC を 60 にしてください。区間の継続時間の計算がここに依存しています。

なお、9 章で勧めた category 化を、この完成コードは使っていません。読みやすさを優先して型変換を 1 か所も挟まない構成にしてあります。 1 か月分を毎朝回すくらいなら object のままでも 0.2 秒台なので、速度が問題になってから load_log() の最後に astype("category") を足すので十分です。

このスクリプトは 11 章の対策を入れていません。 欠測の検出と補間・チャタリングのデバウンス・日跨ぎの区間割りは、どれも「自分のログにその症状があるか」を確かめてから足すものです。まず素の数字を出し、11 章に戻って症状の有無を確認してから、必要な処理だけを組み込んでください。ヒートマップ(8.3)も 5 日分のログを前提にしているので含めていません。

12.1 毎朝 1 回、自動で回す

手で叩いているうちは「手作業が 1 日から 1 分になった」だけです。朝礼に間に合わせるなら、前日ぶんのログを決まった時刻に処理させます。Windows なら追加インストールなしでタスクスケジューラが使えますが、手動なら動くのにタスクとして登録すると動かない、という詰まり方をよくします。切り分けは タスクスケジューラで Python が実行されない — 戻り値 0x1 / 0x2 から原因を切り分ける にまとめました。実行結果をファイルに残す設計も一緒に決めておくと、動かなかった朝に原因を追えます。

集計結果を紙で配る必要があるなら、PDF にする経路もあります(検査成績書 PDF を Python で自動作成する — ReportLab 帳票入門)。本記事が出した表と SVG は、そのまま帳票の材料になります。

12.2 現場に持ち込む前に確認すること

本記事のコードはログファイルを読むだけで、設備にも PLC にも接続しません。それでも、運用に載せる前に次の 4 点を確認してください。

  • ログの取得は読み出し専用に限る — 稼働中の PLC や設備 PC からデータを取るなら、書き込み・設定変更をしない構成にし、設備保全部門と情報システム部門の承認を得てから接続します。24 時間動いている設備の通信に、検証していないプログラムをつながないでください
  • 既存の集計と値を突き合わせる — 同じ日のデータで、現行の Excel 集計と Python の値が一致することを確認してから切り替えます。ズレたときは、たいてい負荷時間の定義(7 章)か欠測(11.1)が原因です
  • 定義を文書に残す — 負荷時間に何を含めたか、チョコ停の閾値を何分にしたか(そして何日分の分布から選んだか)、デバウンスを何秒で掛けたか。この 3 つが書いていない稼働率の数字は、翌年には誰も再現できません
  • 数字を人の評価に使わない — OEE が下がった原因の多くは設備と段取りの側にあります。作業者個人の成績として扱い始めると、その瞬間からログの信頼性が落ちます

13. よくある質問(FAQ)

チョコ停は何分未満にすればいいですか

先に決めず、自分のログの停止時間の分布を描いてから決めてください。ただし 1 日分では足りません。本記事の架空データでは、1 日分(停止 39 件)だと 5 分・10 分・15 分のどれで切ってもチョコ停 38 件・停止時間シェア 60.6% で動かず、分布に谷があるように見えました。ところが同じ生成ルールで作った 30 日分(1,108 件)で同じスキャンを回すと谷は消え、閾値 5 分で 1,006 件・31.8%、15 分で 1,061 件・51.9% と停止時間シェアが 20 ポイント動きました。谷が見えたら、まずサンプルを増やして谷が残るかを確かめてください。期間を変えて結論が動くなら、1 つの数字に丸めず複数の閾値を並べて報告します(6 章)。

稼働率と OEE は何が違うのですか

一般に「稼働率」と呼ばれるものは、OEE の 3 因子のうち時間稼働率にあたります。本記事の架空データでは時間稼働率が 76.22% でしたが、性能稼働率 93.36% と良品率 98.67% を掛けた OEE は 70.21% でした。時間稼働率だけを見ていると、ゆっくり動いている分と不良を作っている分が見えません(2 章)。

うちのログは状態が変わった瞬間しか記録されていません

状態遷移型のログです。区間化は不要で、すでに区間表の形になっています。次の行の時刻との差が継続時間になるので、shift で次の時刻を引いて duration を作ってください。本記事のコードをそのまま使いたい場合は、resample("1s").ffill() で固定周期に直してから流し込めます(3 章)。

自分のログを読ませたら UnicodeDecodeError や KeyError が出ます

現場の CSV は cp932(Shift_JIS)で書き出されていることが多く、pandas の既定(UTF-8)で読むと UnicodeDecodeError: 'utf-8' codec can't decode byte 0x92 in position 0 で止まります。列名が 日時 / 状態 のように違う場合や、生産カウンタの列が無い場合は KeyError: "Label(s) ['reason'] do not exist" になります。完成コードの load_log() は文字コード・列名・状態名の違いを吸収し、無いカウンタ列は 0 で補います。生産カウンタが無いログでも、時間稼働率(実測 76.22%)とチョコ停 38 件の分析はそのまま出せます(10 章)。

100 万行を超えると重くなりませんか

検証機では 31 日分 1,004,400 行の区間化が 0.202 秒、集計が 0.0018 秒、メモリが 34.6 MB でした。state と reason を category にすると 0.038 秒・11.5 MB になります。111 日分 3,596,400 行でも区間化 0.857 秒です。pandas で扱えなくなる規模ではありませんでした(9 章)。

1 秒だけ停止が入るノイズで停止件数が跳ね上がります

チャタリングです。運転中の 400 行を 1 秒だけ停止に反転させたところ、区間数が 86 → 876、停止区間が 39 件 → 434 件に増えました。3 秒未満の区間を直前の状態で塗りつぶすデバウンスを入れると 39 件に戻り、所要は 0.006 秒でした。何秒未満を消したかは必ず記録してください。本物の短時間停止も一緒に消えます(11.2)。

夜勤で日をまたぐと集計が合いません

状態だけをキーに区間化すると、夜間の計画休止が日付をまたいで 1 区間になります。24 時間ログ 2 日分では 54,000 秒(15.00 時間)の区間ができ、日別集計で 10/05 に 57,900 秒が寄りました。状態と日付を連結した文字列をキーにすると区間が 157 → 159 に割れ、10/05 が 29,100 秒、10/07 が 28,800 秒に正しく分かれます(11.3)。

14. 検証環境と、当サイトが測れなかったこと

右の列は「当サイトでは確認できていない」という意味で、間違いという意味ではありません。 結果が本記事と食い違ったら、それはあなたの環境での事実です。

実測した(検証機で実行)実測していない・扱っていない
架空ログ 32,400 行の生成(0.013 秒)・区間化 86 区間・停止 39 件 3,963 秒・OEE 70.21% と別経路での検算(差 0.0000 ポイント) 実設備の稼働ログでの検証(PLC のビットログ・MES の状態ログ)。当サイトは実機 PLC も実設備の稼働ログも所有していません。 本記事の数値はすべて架空データに対するものです
閾値 1 / 3 / 5 / 10 / 15 分の感度分析を 1 日分(停止 39 件)・5 日分(179 件)・30 日分(1,108 件)で実行。1 日分にだけ現れた 5〜15 分の「谷」が 30 日分では消え、停止時間シェアが 31.8%(5 分)〜51.9%(15 分)と 20.1 ポイント動いた 実設備の停止時間分布に「谷」があるかどうかは未検証です。 本記事の架空データでは、1 日分で見えた谷が 30 日分では消えました。実機の分布がどちらに近いかは、当サイトには確かめる手段がありません
1 / 5 / 31 / 111 日分(最大 3,596,400 行)の区間化・集計時間とメモリ・category 化の効果 1 秒より細かい粒度(100 ms 等)・複数設備の同時集計・並列処理。 いずれも未計測です
CSV / SQLite の読み書き(1 日分)・cp932 の CSV・列名と状態名が違うログ・生産カウンタが無いログ・未知の状態が混ざったログの読み込み・欠測 600 行・1 秒チャタリング 400 行・24 時間 × 2 日の日跨ぎの再現 実機のチャタリング周期とセンサの誤検知率。 11.2 は人工的に 1 秒の反転を注入しただけで、実際の設備でどの程度起きるかは分かりません
matplotlib の SVG 出力(svg.fonttype = "none")と HTML への直接埋め込み(3 枚で約 69 KB) 海外拠点の夏時間を含むログ・設備 PC 間の時刻ずれ(11.4)。日本国内の tz_localize("Asia/Tokyo") 以外は未検証です
— ISO 22400-2:2014 の規格原文(有償・未入手)。Performance の厳密な定義は二次情報ベースです。また JIPM 公式サイト上で計算式を示した一次ページは特定できませんでした(2 章)

検証環境: 自宅の検証機(Windows 11 Pro・10.0.26200)で確認しました。会社支給の端末は使っていません。 Python 3.12.10 / pandas 3.0.3 / numpy 2.4.6 / matplotlib 3.11.1。実測日は 2026 年 9 月 17 日、乱数シードは 20261027 です。処理時間は同じ機械でも実行するたびに数 % 前後します。

本記事の数値・手順は当サイトの検証機で架空データに対して確認したもので、実際の設備から算出される稼働率の妥当性や、社内・社外の報告要件への適合を保証するものではありません。

関連記事

参考文献・一次情報