100本ノック / 確率統計 / 確率・統計マーケティング応用100本ノック

製造業シミュレーションをPythonで学ぶ|モンテカルロからデジタルツインまで10本ノック

変動を先読みする工場経営:産業用ポンプ工場のシミュレーション10本ノック

本記事では、架空の産業用ポンプ工場を題材に、モンテカルロ、離散イベント、エージェントベース、待ち行列、システムダイナミクス、在庫、生産ライン、サプライチェーン、需要、デジタルツインの10テーマを一続きの意思決定として扱います。

目的は手法の実装自体ではなく、「利益計画はどこまで下振れするか」「設備・要員・在庫をどこへ配分するか」「供給途絶や需要変動にどう備えるか」を、再現可能な仮想実験で説明できるようにすることです。掲載データは Python で生成し、外部データには依存しません。

[!NOTE] 本資料は、数理工房 (もしくは代表である和山個人) が過去に企業研修において使用した notebook を企業様の許可を得て再構成・編集のうえ公開しています。
掲載データはすべて架空のものであり、実在する企業・工場・数値とは一切関係ありません。

はじめに:この記事で扱う製造業の実務課題

対象工場は、標準型と高耐圧型の産業用ポンプを、加工・組立・検査して法人顧客へ出荷しています。需要、加工時間、設備故障、部材の納期は日々変動します。生産管理者は、月次計画だけでなく、日々の仕掛、部材在庫、応援要員、特急輸送、顧客への納期回答を同時に判断しなければなりません。

シミュレーションは未来を一点で言い当てる道具ではありません。現実を意思決定に必要な範囲でモデル化し、まだ実行していない施策を安全に比較する「仮想実験」です。本記事では平均値に加え、分布、時間順序、相互作用、フィードバックを段階的にモデルへ入れます。

現場でよくある状況

  • 年間利益は黒字計画だが、需要減・材料高・停止が重なる下振れを説明できない
  • 各設備の平均稼働率は分かるが、仕掛がどこで何分待つか分からない
  • 保全員、作業者、搬送車、仕入先が局所判断し、全体KPIへの影響が見えない
  • 安全在庫を一律に増やし、欠品は減ったが在庫金額と廃棄が増えた
  • 需要予測、生産計画、調達計画の前提が部門ごとに異なる
  • デジタルツインを導入したが、現場実績でモデルを更新する運用がない

共通する課題は、変動と時間的な依存関係を平均値へ押し込めていることです。

なぜこの問題は判断が難しいのか

製造システムには非線形性があります。稼働率が100%へ近づくと待ち時間は急増し、一つの部材欠品が後工程の停止へ波及します。発注量を増やせば欠品は減りますが、在庫費用と陳腐化リスクは増えます。また、現場の主体は同じ情報を持たず、それぞれ異なるルールで行動します。

したがって、平均利益や平均リードタイムだけでは不十分です。本記事では、利益の下方分位点、納期遵守率、待ち時間、仕掛在庫、充足率、バックログ、回復時間などを併記し、期待値・リスク・応答速度・費用を同時に評価します。

今回扱うノックの全体像

No.テーマ主な問い主な出力
081モンテカルロ利益はどこまで下振れするか利益分布、赤字確率
082離散イベントジョブがいつ流れ、どこで待つかイベントログ、リードタイム
083エージェントベース個々の保全判断が全体へどう波及するか停止率、予防保全率
084待ち行列検査員を何名配置すべきか待ち時間、サービス水準
085システムダイナミクス受注・能力・バックログはどう循環するか在庫・受注残の推移
086在庫シミュレーション発注政策をどう選ぶか充足率、在庫費用
087生産ラインシミュレーション改善投資をどの工程へ向けるかスループット、工程利用率
088サプライチェーンシミュレーション供給途絶へどう備えるか欠品、総費用、回復時間
089需要シミュレーションS&OPで能力案をどう比較するか月間需要分布、超過確率
090デジタルツイン実績でモデルをどう更新するか状態更新、シナリオ比較

Python 環境の準備

NumPy は乱数と数値計算、pandas はイベント・KPIの表現、matplotlib は可視化に利用します。乱数生成器は用途ごとに default_rng(SEED + 番号) として固定し、コードを再実行しても同じ結果になるようにします。グラフはすべて matplotlib で作成します。

import heapq
import platform

import japanize_matplotlib
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from IPython.display import display

SEED = 42
pd.set_option("display.max_columns", 20)
pd.set_option("display.float_format", lambda x: f"{x:,.2f}")

print(f"Python: {platform.python_version()}")
print(f"NumPy: {np.__version__} / pandas: {pd.__version__}")
print(f"乱数seed: {SEED}")
Python: 3.13.1
NumPy: 2.5.1 / pandas: 3.0.3
乱数seed: 42

架空データの作成

標準型 P-100 と高耐圧型 P-200 の2製品を想定します。原価・価格・需要、主要3工程の標準時間、日次能力を共通マスタとして用意します。個別ノックでは、このマスタを基準に故障、需要、納期の変動を加えます。

実務では、標準時間が正味時間か余裕込みか、需要が受注日か希望納期日か、欠品が受注残になるか失注になるかを最初に定義します。シミュレーションの精度以前に、データの意味の不一致が結論を変えるためです。

products = pd.DataFrame({
    "製品": ["P-100 標準型", "P-200 高耐圧型"],
    "平均月間需要_台": [620, 310],
    "販売価格_万円": [18.0, 27.0],
    "材料費_万円": [8.2, 12.5],
    "加工_分": [18, 26],
    "組立_分": [22, 34],
    "検査_分": [14, 24],
}).set_index("製品")

resources = pd.DataFrame({
    "工程": ["加工", "組立", "検査"],
    "設備・要員数": [2, 3, 2],
    "日次正味時間_分": [450, 450, 450],
    "変動係数": [0.18, 0.12, 0.22],
}).set_index("工程")

print("製品マスタ")
display(products)
print("工程能力マスタ")
display(resources)
製品マスタ
平均月間需要_台 販売価格_万円 材料費_万円 加工_分 組立_分 検査_分
製品
P-100 標準型 620 18.00 8.20 18 22 14
P-200 高耐圧型 310 27.00 12.50 26 34 24
工程能力マスタ
設備・要員数 日次正味時間_分 変動係数
工程
加工 2 450 0.18
組立 3 450 0.12
検査 2 450 0.22

No.081:モンテカルロ — 年間利益の下振れを確率で測る

実務での意味

予算の売上・材料単価・停止時間を一点で置くと、計画利益は一つに決まります。しかし経営判断では、平均利益だけでなく、赤字確率や厳しいケースの損失額が必要です。設備投資枠、運転資金、長期契約価格の検討に直結します。

分析・モデル化の考え方

シナリオ ss の年間利益を

Πs=Qs(PCm,sCv)FCd,s\Pi_s=Q_s(P-C_{m,s}-C_v)-F-C_{d,s}

とします。QQ は販売数量、PP は価格、CmC_m は材料費、CvC_v は変動加工費、FF は固定費、CdC_d は停止損失です。入力変数を確率分布から繰り返し生成し、P(Π<0)P(\Pi<0) と5%分位点(95%のケースが上回る利益)を評価します。

Pythonで確認する

mc_rng = np.random.default_rng(SEED + 81)
n_mc = 20_000
annual_demand = np.maximum(0, mc_rng.normal(11_200, 1_250, n_mc))
material_cost = mc_rng.triangular(9.2, 10.0, 12.4, n_mc)  # 万円/台
downtime_days = mc_rng.poisson(8, n_mc)
selling_price = 21.2
variable_cost = 3.6
fixed_cost = 73_000
capacity = 11_800
sales = np.minimum(annual_demand, capacity - downtime_days * 28)
profit = sales * (selling_price - material_cost - variable_cost) - fixed_cost - downtime_days * 55

profit_kpi = pd.Series({
    "平均利益_万円": profit.mean(),
    "利益5%分位_万円": np.quantile(profit, 0.05),
    "利益95%分位_万円": np.quantile(profit, 0.95),
    "赤字確率": (profit < 0).mean(),
}, name="値")
display(profit_kpi.to_frame())

fig, ax = plt.subplots(figsize=(8, 3.8))
ax.hist(profit, bins=45, color="#4472C4", edgecolor="white")
ax.axvline(0, color="#C00000", linestyle="--", label="損益分岐")
ax.axvline(np.quantile(profit, 0.05), color="#ED7D31", linestyle=":", label="5%分位")
ax.set_title("需要・材料費・停止を反映した年間利益分布")
ax.set_xlabel("年間利益(万円)")
ax.set_ylabel("シナリオ数")
ax.grid(axis="y", alpha=0.3)
ax.legend()
plt.tight_layout()
plt.show()
平均利益_万円 3,384.79
利益5%分位_万円 -13,233.85
利益95%分位_万円 17,770.93
赤字確率 0.36

png

結果の読み取り

平均利益がプラスでも、0より左側に分布があれば赤字リスクは残ります。5%分位点は資金余力や投資上限を議論する保守的な基準です。ただし、分布の形は入力仮定に依存します。実務では需要と材料価格の相関、価格転嫁の時差、能力上限を実績で推定し、赤字シナリオの入力条件も併せて確認します。

No.082:離散イベント — 工程の競合とジョブの待ちを再現する

実務での意味

製品は加工完了後に組立へ進み、設備が空くまで待ちます。このように状態が「到着」「加工完了」などのイベント時点で変わるシステムでは、時間を細かく刻むより離散イベントシミュレーションが効率的です。納期回答、仕掛削減、段取り順の検討に使えます。

分析・モデル化の考え方

ジョブ jj の工程 kk の開始・完了時刻を

Sj,k=max(Aj,k,Rk),Cj,k=Sj,k+Pj,kS_{j,k}=\max(A_{j,k},R_k),\qquad C_{j,k}=S_{j,k}+P_{j,k}

とします。AA は工程への到着、RR は資源が空く時刻、PP は処理時間です。イベントを時刻順に優先度付きキューで処理し、イベントログから待ち時間とリードタイムを集計します。

Pythonで確認する

des_rng = np.random.default_rng(SEED + 82)
routes = ["加工", "組立", "検査"]
resource_ready = {m: 0.0 for m in routes}
event_queue = []
log = []
n_jobs = 18
arrival_times = np.cumsum(des_rng.exponential(16, n_jobs))

for j, arrival in enumerate(arrival_times):
    heapq.heappush(event_queue, (arrival, j, 0))

while event_queue:
    arrival, job, step = heapq.heappop(event_queue)
    machine = routes[step]
    means = [20, 27, 18]
    duration = des_rng.lognormal(np.log(means[step]), 0.18)
    start = max(arrival, resource_ready[machine])
    finish = start + duration
    resource_ready[machine] = finish
    log.append([job, machine, step + 1, arrival, start, finish, start - arrival])
    if step + 1 < len(routes):
        heapq.heappush(event_queue, (finish, job, step + 1))

event_log = pd.DataFrame(log, columns=["job", "工程", "工程順", "到着", "開始", "完了", "待ち時間"])
job_kpi = event_log.groupby("job").agg(投入=("到着", "min"), 完了=("完了", "max"), 総待ち=("待ち時間", "sum"))
job_kpi["リードタイム"] = job_kpi["完了"] - job_kpi["投入"]
display(event_log.head(10))
display(job_kpi.describe().loc[["mean", "50%", "max"]])

fig, ax = plt.subplots(figsize=(8, 3.8))
for i, machine in enumerate(routes):
    part = event_log[event_log["工程"] == machine]
    ax.scatter(part["開始"], [i] * len(part), s=part["待ち時間"] * 5 + 20, label=machine)
ax.set_yticks(range(len(routes)), routes)
ax.set_title("工程別の処理開始時刻(点の大きさ=直前待ち時間)")
ax.set_xlabel("シミュレーション時刻(分)")
ax.set_ylabel("工程")
ax.grid(axis="x", alpha=0.3)
plt.tight_layout()
plt.show()
job 工程 工程順 到着 開始 完了 待ち時間
0 0 加工 1 17.26 17.26 41.15 0.00
1 1 加工 1 36.88 41.15 62.93 4.27
2 0 組立 2 41.15 41.15 75.09 0.00
3 1 組立 2 62.93 75.09 102.12 12.16
4 2 加工 1 73.18 73.18 91.40 0.00
5 0 検査 3 75.09 75.09 93.83 0.00
6 3 加工 1 81.55 91.40 112.45 9.85
7 4 加工 1 87.67 112.45 128.27 24.78
8 2 組立 2 91.40 102.12 127.52 10.72
9 1 検査 3 102.12 102.12 117.42 0.00
投入 完了 総待ち リードタイム
mean 177.21 322.24 79.07 145.04
50% 182.37 325.33 77.44 144.69
max 379.36 555.73 176.19 233.52

png

結果の読み取り

同じ標準時間でも、到着の重なりによりジョブ別リードタイムは異なります。点が大きい工程・時間帯は待ちが蓄積した箇所です。平均待ちだけでなく最大値とジョブ別内訳を見て、増員、優先順、ロット分割の候補を作ります。本番モデルでは複数台設備、故障、段取り、休憩、手直しをイベントとして追加します。

No.083:エージェントベース — 保全員の局所判断と設備群の挙動を捉える

実務での意味

設備ごとの劣化状態は異なり、保全員は限られた時間で巡回します。各設備が警告を出し、保全員が優先順位を判断する相互作用は、集計式だけでは表しにくい問題です。予防保全ルール、巡回能力、アラーム閾値の設計に使えます。

分析・モデル化の考え方

各設備を状態 hi(t)[0,1]h_i(t)\in[0,1] を持つエージェントとし、毎期の劣化 di(t)d_i(t)、保全による回復 ri(t)r_i(t)

hi(t+1)=min{1,max[0,hi(t)di(t)+ri(t)]}h_i(t+1)=\min\{1,\max[0,h_i(t)-d_i(t)+r_i(t)]\}

で更新します。保全員エージェントは、閾値未満の設備を健康度の低い順に1日2台まで処置します。閾値を変えて故障停止と保全回数のトレードオフを比較します。

Pythonで確認する

def run_agents(threshold, seed, days=120, n_machines=18):
    agent_rng = np.random.default_rng(seed)
    health = agent_rng.uniform(0.65, 1.0, n_machines)
    failures = maintenance = lost_hours = 0
    history = []
    for day in range(days):
        health -= agent_rng.gamma(shape=2.0, scale=0.015, size=n_machines)
        failed = health <= 0.12
        failures += failed.sum()
        lost_hours += failed.sum() * 7.5
        health[failed] = 0.58  # 事後修理
        targets = np.where((health < threshold) & (~failed))[0]
        targets = targets[np.argsort(health[targets])][:1]
        maintenance += len(targets)
        lost_hours += len(targets) * 1.2
        health[targets] = np.minimum(1.0, health[targets] + 0.38)
        history.append([day, health.mean(), failures, maintenance])
    return failures, maintenance, lost_hours, pd.DataFrame(history, columns=["日", "平均健康度", "累積故障", "累積予防保全"])

agent_rows = []
histories = {}
for threshold in [0.30, 0.45, 0.60]:
    # 同じ劣化乱数で保全ルールだけを比較する(共通乱数法)
    f, m, lost, hist = run_agents(threshold, SEED + 83)
    agent_rows.append([threshold, f, m, lost])
    histories[threshold] = hist
agent_result = pd.DataFrame(agent_rows, columns=["保全閾値", "故障件数", "予防保全件数", "停止換算時間"])
display(agent_result)

fig, ax = plt.subplots(figsize=(8, 3.8))
for threshold, hist in histories.items():
    ax.plot(hist["日"], hist["平均健康度"], label=f"閾値 {threshold:.2f}")
ax.set_title("保全ルール別の設備エージェント平均健康度")
ax.set_xlabel("経過日")
ax.set_ylabel("平均健康度")
ax.grid(alpha=0.3)
ax.legend()
plt.tight_layout()
plt.show()
保全閾値 故障件数 予防保全件数 停止換算時間
0 0.30 32 109 370.80
1 0.45 28 114 346.80
2 0.60 26 119 337.80

png

結果の読み取り

高い閾値は早めの予防保全を増やし、故障を抑える一方で計画停止を増やします。評価は故障件数だけでなく停止換算時間や保全費用で行います。結果には確率変動があるため、本来は各ルールを複数seedで反復します。実務では設備重要度、部品在庫、保全員スキル、同時故障時の優先ルールもエージェント属性にします。

No.084:待ち行列 — 最終検査の要員数をサービス水準で決める

実務での意味

検査工程へ製品が不規則に到着すると、平均処理能力が平均到着量を上回っていても待ちが発生します。検査員を増やせば待ちは減りますが、労務費は増えます。出荷締切までの検査完了率を使うと、要員案を納期サービス水準で比較できます。

分析・モデル化の考え方

到着率を λ\lambda、1人当たり処理率を μ\mu、窓口数を cc とすると利用率は

ρ=λcμ\rho=\frac{\lambda}{c\mu}

です。ρ1\rho\ge1 では待ち行列が長期的に発散します。ここでは指数分布の到着・処理を仮定した M/M/c をシミュレーションし、平均待ちと「10分以内に検査開始」の割合を比較します。

Pythonで確認する

def simulate_queue(servers, seed, n=3000, arrival_rate=5.2, service_rate=3.0):
    queue_rng = np.random.default_rng(seed)
    arrivals = np.cumsum(queue_rng.exponential(60 / arrival_rate, n))
    service = queue_rng.exponential(60 / service_rate, n)
    ready = np.zeros(servers)
    waits = np.zeros(n)
    for i, (arrival, duration) in enumerate(zip(arrivals, service)):
        k = np.argmin(ready)
        start = max(arrival, ready[k])
        waits[i] = start - arrival
        ready[k] = start + duration
    return waits

queue_rows = []
queue_samples = {}
for c in [2, 3, 4]:
    waits = simulate_queue(c, SEED + 840 + c)
    queue_samples[c] = waits
    queue_rows.append([c, 5.2 / (c * 3.0), waits.mean(), np.quantile(waits, 0.95), (waits <= 10).mean()])
queue_result = pd.DataFrame(queue_rows, columns=["検査員数", "利用率", "平均待ち_分", "待ち95%分位_分", "10分以内開始率"])
display(queue_result)

fig, ax = plt.subplots(figsize=(8, 3.8))
ax.boxplot([queue_samples[c] for c in [2, 3, 4]], tick_labels=["2名", "3名", "4名"], showfliers=False)
ax.axhline(10, color="#C00000", linestyle="--", label="10分基準")
ax.set_title("検査員数別の待ち時間分布")
ax.set_xlabel("検査員数")
ax.set_ylabel("待ち時間(分)")
ax.grid(axis="y", alpha=0.3)
ax.legend()
plt.tight_layout()
plt.show()
検査員数 利用率 平均待ち_分 待ち95%分位_分 10分以内開始率
0 2 0.87 48.88 156.23 0.31
1 3 0.58 5.24 29.70 0.82
2 4 0.43 1.08 8.09 0.97

png

結果の読み取り

利用率が高い案では待ち時間の上側が大きく伸びます。平均待ちだけを見ず、95%分位や10分以内開始率を出荷締切と結び付けます。4名案の改善幅が小さければ、常時増員ではなく繁忙時間帯だけの応援が候補です。到着がロット状、処理時間が製品別なら、実績分布を直接再標本化します。

No.085:システムダイナミクス — 受注残と能力調整のフィードバックを見る

実務での意味

受注が増えると受注残が積み上がり、残業・応援で出荷能力を増やします。しかし能力調整には遅れがあり、増やし過ぎると在庫が膨らみ、その後に削減する振動が起こり得ます。S&OP、人員計画、増産判断の時間軸を揃えるために使えます。

分析・モデル化の考え方

在庫 ItI_t、受注残 BtB_t をストック、生産・出荷をフローとし、1日刻みの差分方程式で

It+1=It+PtSt,Bt+1=Bt+DtStI_{t+1}=I_t+P_t-S_t,\qquad B_{t+1}=B_t+D_t-S_t

と更新します。能力 CtC_t は目標受注残との差に反応しますが、調整係数により遅れて動くとします。急反応と平滑反応を比較します。

Pythonで確認する

def system_dynamics(adjustment, days=120):
    demand = np.full(days, 42.0)
    demand[25:65] = 58.0
    inventory = np.zeros(days)
    backlog = np.zeros(days)
    capacity = np.zeros(days)
    shipment = np.zeros(days)
    inventory[0], backlog[0], capacity[0] = 90, 10, 44
    for t in range(days - 1):
        production = capacity[t]
        shipment[t] = min(inventory[t] + production, demand[t] + backlog[t])
        inventory[t + 1] = max(0, inventory[t] + production - shipment[t])
        backlog[t + 1] = max(0, backlog[t] + demand[t] - shipment[t])
        target_capacity = 44 + 0.22 * (backlog[t] - 20) - 0.08 * (inventory[t] - 80)
        capacity[t + 1] = np.clip(capacity[t] + adjustment * (target_capacity - capacity[t]), 30, 70)
    return pd.DataFrame({"日": np.arange(days), "需要": demand, "在庫": inventory, "受注残": backlog, "能力": capacity})

fast_sd = system_dynamics(0.55)
smooth_sd = system_dynamics(0.12)
sd_kpi = pd.DataFrame({
    "調整方針": ["急反応", "平滑反応"],
    "最大受注残": [fast_sd["受注残"].max(), smooth_sd["受注残"].max()],
    "最大在庫": [fast_sd["在庫"].max(), smooth_sd["在庫"].max()],
    "最大日次能力変更": [fast_sd["能力"].diff().abs().max(), smooth_sd["能力"].diff().abs().max()],
})
display(sd_kpi)

fig, axes = plt.subplots(1, 2, figsize=(10, 3.8), sharey=True)
for ax, df, title in zip(axes, [fast_sd, smooth_sd], ["急反応", "平滑反応"]):
    ax.plot(df["日"], df["在庫"], label="完成品在庫")
    ax.plot(df["日"], df["受注残"], label="受注残")
    ax.set_title(f"能力調整:{title}")
    ax.set_xlabel("日")
    ax.set_ylabel("台")
    ax.grid(alpha=0.3)
    ax.legend()
plt.tight_layout()
plt.show()
調整方針 最大受注残 最大在庫 最大日次能力変更
0 急反応 58.05 90.00 2.97
1 平滑反応 110.36 91.40 1.86

png

結果の読み取り

急反応は受注残を早く抑えられる一方、能力変更と在庫の振れが大きくなりやすい方針です。平滑反応は運用を安定させますが、需要急増時の顧客待ちを増やします。どちらを選ぶかは、納期遅延費用、残業・応援の変更費用、在庫費用で決まります。実務では採用・教育・設備調達の遅れを明示的な遅延として組み込みます。

No.086:在庫シミュレーション — 発注点と発注量を費用・充足率で比較する

実務での意味

主要シール部品の欠品はラインを止めますが、過剰在庫は資金を固定し、設計変更時の廃棄を増やします。需要と調達リードタイムが変動する環境では、平均値の計算だけでなく、発注政策を日次運用として再現する必要があります。

分析・モデル化の考え方

在庫ポジションを IP=IP= 手持在庫+発注残-受注残とし、IPRIP\le R なら QQ 個を発注する (Q,R)(Q,R) 政策を評価します。日次費用は

C=hI+bU+K1(q>0)C=hI+bU+K\,\mathbf{1}(q>0)

で、hh は保管費、bb は欠品費、KK は発注費です。共通の需要・納期シナリオで3政策を比較します。

Pythonで確認する

def inventory_sim(R, Q, seed, days=365):
    inv_rng = np.random.default_rng(seed)
    demand = inv_rng.poisson(18, days)
    lead_times = inv_rng.integers(3, 9, days)
    on_hand, backlog = 130, 0
    pipeline = []
    total_demand = filled = holding = shortage = order_count = 0
    history = []
    for day in range(days):
        arrivals = sum(q for due, q in pipeline if due == day)
        pipeline = [(due, q) for due, q in pipeline if due > day]
        on_hand += arrivals
        requested = backlog + demand[day]
        shipped = min(on_hand, requested)
        on_hand -= shipped
        backlog = requested - shipped
        total_demand += demand[day]
        filled += min(demand[day], max(0, shipped - max(0, requested - demand[day])))
        inventory_position = on_hand + sum(q for _, q in pipeline) - backlog
        if inventory_position <= R:
            pipeline.append((day + int(lead_times[day]), Q))
            order_count += 1
        holding += on_hand
        shortage += backlog
        history.append([day, on_hand, backlog, inventory_position])
    cost = holding * 35 + shortage * 1200 + order_count * 2500
    return filled / total_demand, cost, np.mean([x[1] for x in history]), pd.DataFrame(history, columns=["日", "手持在庫", "受注残", "在庫ポジション"])

policies = [(70, 120), (100, 140), (140, 180)]
inv_rows, inv_hist = [], {}
for R, Q in policies:
    fill, cost, avg_inv, hist = inventory_sim(R, Q, SEED + 86)
    name = f"R={R}, Q={Q}"
    inv_rows.append([name, fill, avg_inv, cost])
    inv_hist[name] = hist
inventory_result = pd.DataFrame(inv_rows, columns=["政策", "即納充足率", "平均手持在庫", "年間関連費用_円"])
display(inventory_result)

fig, ax = plt.subplots(figsize=(8, 3.8))
for name, hist in inv_hist.items():
    ax.plot(hist["日"], hist["手持在庫"], label=name, alpha=0.8)
ax.set_title("発注政策別の手持在庫推移")
ax.set_xlabel("日")
ax.set_ylabel("手持在庫(個)")
ax.grid(alpha=0.3)
ax.legend()
plt.tight_layout()
plt.show()
政策 即納充足率 平均手持在庫 年間関連費用_円
0 R=70, Q=120 0.78 35.92 3951485
1 R=100, Q=140 0.94 75.34 1807165
2 R=140, Q=180 1.00 135.67 1856865

png

結果の読み取り

発注点と発注量を増やすと欠品は抑えやすい一方、平均在庫と保管費が増えます。費用最小案が会社の目標充足率を満たすかを先に確認し、未達ならサービス水準を制約として政策を選びます。実務ではロット制約、休日カレンダー、発注済み残、最小発注量、欠品時の代替部品も入れます。

No.087:生産ラインシミュレーション — ボトルネック改善案を比較する

実務での意味

加工・組立・検査のどこを改善すれば出荷量が増えるかは、単純な標準時間の比較だけでは決まりません。上流停止で下流が空き、下流渋滞で仕掛が増えるためです。設備増強、サイクル短縮、予防保全の投資効果検証に使えます。

分析・モデル化の考え方

直列3工程の完了時刻をジョブ順に

Cj,k=max(Cj1,k,Cj,k1)+Pj,kC_{j,k}=\max(C_{j-1,k},C_{j,k-1})+P_{j,k}

で更新します。処理時間 Pj,kP_{j,k} は対数正規分布、故障停止は一定確率で付加します。基準、組立10%短縮、検査増強の3案を同じ乱数系列で比較します。

Pythonで確認する

def line_sim(scenario, seed, jobs=240):
    line_rng = np.random.default_rng(seed)
    means = np.array([18.0, 25.0, 21.0])
    if scenario == "組立10%短縮":
        means[1] *= 0.90
    if scenario == "検査15%短縮":
        means[2] *= 0.85
    durations = line_rng.lognormal(np.log(means), [0.18, 0.14, 0.22], size=(jobs, 3))
    failures = line_rng.random((jobs, 3)) < [0.025, 0.015, 0.035]
    durations += failures * line_rng.uniform(20, 55, size=(jobs, 3))
    completion = np.zeros((jobs, 3))
    busy = durations.sum(axis=0)
    for j in range(jobs):
        for k in range(3):
            prev_job = completion[j - 1, k] if j else 0
            prev_stage = completion[j, k - 1] if k else 0
            completion[j, k] = max(prev_job, prev_stage) + durations[j, k]
    makespan = completion[-1, -1]
    return makespan, jobs / (makespan / 450), busy / makespan, completion

line_rows = []
line_completion = {}
for scenario in ["基準", "組立10%短縮", "検査15%短縮"]:
    makespan, daily, utilization, completion = line_sim(scenario, SEED + 87)
    line_rows.append([scenario, makespan, daily, *utilization])
    line_completion[scenario] = completion
line_result = pd.DataFrame(line_rows, columns=["シナリオ", "完了時間_分", "日産換算_台", "加工利用率", "組立利用率", "検査利用率"])
display(line_result)

fig, ax = plt.subplots(figsize=(8, 3.8))
plot_data = line_result.set_index("シナリオ")[["加工利用率", "組立利用率", "検査利用率"]] * 100
plot_data.plot.bar(ax=ax, color=["#4472C4", "#ED7D31", "#70AD47"], rot=0)
ax.set_title("改善シナリオ別の工程利用率")
ax.set_xlabel("シナリオ")
ax.set_ylabel("利用率(%)")
ax.grid(axis="y", alpha=0.3)
plt.tight_layout()
plt.show()
シナリオ 完了時間_分 日産換算_台 加工利用率 組立利用率 検査利用率
0 基準 6,127.66 17.62 0.80 0.99 0.91
1 組立10%短縮 5,698.83 18.95 0.86 0.96 0.98
2 検査15%短縮 6,124.45 17.63 0.80 0.99 0.79

png

結果の読み取り

サイクル短縮率と日産向上率は一致しません。制約工程以外を改善しても、全体完了時刻への効果は限定的です。利用率が高い工程を改善候補としつつ、故障による長い停止や工程間仕掛も確認します。投資判断では、複数反復の平均・下方分位点と、増産1台当たりの費用を比較します。

No.088:サプライチェーンシミュレーション — 供給途絶への緩衝策を選ぶ

実務での意味

主要部材の仕入先停止は、数日遅れて工場欠品となり、さらに顧客納期へ波及します。安全在庫、代替調達、特急輸送は回復力を高めますが、平時費用がかかります。BCPを「念のため」ではなく、欠品削減と費用で比較するために使えます。

分析・モデル化の考え方

仕入先から工場への入荷をリードタイム付きイベントとして管理し、工場在庫から日需要を充当します。途絶期間中は通常発注が届かず、対策案では発注点上昇と、欠品時の代替調達を許します。評価指標は充足率、最大受注残、年間総費用、途絶後の回復日数です。

Pythonで確認する

def supply_chain_sim(policy, seed, days=180):
    sc_rng = np.random.default_rng(seed)
    on_hand, backlog = 280, 0
    pipeline = []
    total_demand = immediate_total = holding = expedite = 0
    max_backlog = 0
    history = []
    R, Q = ((180, 260) if policy == "標準" else (280, 320))
    for day in range(days):
        arrivals = sum(q for due, q in pipeline if due == day)
        pipeline = [(due, q) for due, q in pipeline if due > day]
        on_hand += arrivals
        demand = int(sc_rng.poisson(42))
        total_demand += demand
        old_backlog = backlog
        immediate_total += min(demand, max(0, on_hand - old_backlog))
        requested = old_backlog + demand
        shipped = min(on_hand, requested)
        on_hand -= shipped
        backlog = requested - shipped
        inventory_position = on_hand + sum(q for _, q in pipeline) - backlog
        disruption = 55 <= day < 72
        if inventory_position <= R and not disruption:
            pipeline.append((day + int(sc_rng.integers(5, 10)), Q))
        if policy == "BCP対策" and backlog > 80:
            emergency = min(120, backlog)
            pipeline.append((day + 2, emergency))
            expedite += emergency
        holding += on_hand
        max_backlog = max(max_backlog, backlog)
        history.append([day, on_hand, backlog])
    cost = holding * 28 + backlog * 1800 + expedite * 650
    hist = pd.DataFrame(history, columns=["日", "工場在庫", "受注残"])
    after = hist.loc[hist["日"] >= 72]
    recovered = after.loc[after["受注残"] <= 5, "日"]
    recovery_days = (recovered.iloc[0] - 72) if len(recovered) else np.nan
    return immediate_total / total_demand, max_backlog, cost, recovery_days, hist

sc_rows, sc_hist = [], {}
for policy in ["標準", "BCP対策"]:
    fill, max_b, cost, recovery, hist = supply_chain_sim(policy, SEED + 88)
    sc_rows.append([policy, fill, max_b, cost, recovery])
    sc_hist[policy] = hist
supply_result = pd.DataFrame(sc_rows, columns=["方針", "即納充足率", "最大受注残", "総費用_円", "途絶後回復日数"])
display(supply_result)

fig, ax = plt.subplots(figsize=(8, 3.8))
for policy, hist in sc_hist.items():
    ax.plot(hist["日"], hist["受注残"], label=policy)
ax.axvspan(55, 72, color="#C00000", alpha=0.12, label="仕入先途絶")
ax.set_title("供給途絶時の受注残推移")
ax.set_xlabel("日")
ax.set_ylabel("受注残(台)")
ax.grid(alpha=0.3)
ax.legend()
plt.tight_layout()
plt.show()
方針 即納充足率 最大受注残 総費用_円 途絶後回復日数
0 標準 0.64 621 277452 9
1 BCP対策 0.88 152 1448626 4

png

結果の読み取り

BCP対策は平時在庫と特急費を増やしますが、途絶時の最大受注残と回復時間を抑えます。平均年間費用だけでなく、重要顧客の停止損失や契約違約金を含めて選びます。一つの固定途絶だけでは結論が偏るため、発生時期・長さ・複数仕入先の同時被災を変えたストレステストが必要です。

No.089:需要シミュレーション — S&OPの能力案を確率で比較する

実務での意味

営業予測が月間1,000台でも、販促効果、季節性、大口案件の受注で実績は上下します。能力を予測平均に合わせると、繁忙月の残業・欠品が増えます。S&OPでは需要シナリオを用い、通常能力・残業・外注の組合せを合意します。

分析・モデル化の考え方

tt の需要を

Dt=(L+Tt)St+Mt+εtD_t=(L+T_t)S_t+M_t+\varepsilon_t

とし、LL は基準需要、TT は傾向、SS は季節係数、MM は販促・大口案件、ε\varepsilon は通常変動です。1,000本の年間パスを生成し、能力超過確率と年間不足量を能力案別に評価します。

Pythonで確認する

demand_rng = np.random.default_rng(SEED + 89)
n_paths, months = 3000, 12
season = np.array([0.90, 0.92, 0.98, 1.02, 1.05, 1.08, 0.95, 0.93, 1.00, 1.08, 1.16, 1.22])
trend = np.arange(months) * 8
base = (900 + trend) * season
noise = demand_rng.normal(0, 75, size=(n_paths, months))
campaign = (demand_rng.random((n_paths, months)) < 0.16) * demand_rng.normal(180, 45, (n_paths, months))
large_order = (demand_rng.random((n_paths, months)) < 0.08) * demand_rng.integers(180, 380, (n_paths, months))
demand_paths = np.maximum(0, base + noise + campaign + large_order)

capacity_options = {"通常能力": 1050, "残業枠": 1180, "外注枠込み": 1320}
demand_rows = []
for name, cap in capacity_options.items():
    shortage = np.maximum(0, demand_paths - cap)
    demand_rows.append([name, cap, (demand_paths > cap).mean(), shortage.sum(axis=1).mean(), np.quantile(shortage.sum(axis=1), 0.95)])
demand_result = pd.DataFrame(demand_rows, columns=["能力案", "月能力_台", "月次能力超過確率", "平均年間不足_台", "年間不足95%分位_台"])
display(demand_result)

q10, q50, q90 = np.quantile(demand_paths, [0.1, 0.5, 0.9], axis=0)
fig, ax = plt.subplots(figsize=(8, 3.8))
ax.fill_between(np.arange(1, 13), q10, q90, color="#4472C4", alpha=0.2, label="需要10–90%範囲")
ax.plot(np.arange(1, 13), q50, color="#4472C4", marker="o", label="需要中央値")
for name, cap in capacity_options.items():
    ax.axhline(cap, linestyle="--", linewidth=1, label=name)
ax.set_title("月別需要シナリオと能力案")
ax.set_xlabel("月")
ax.set_ylabel("需要(台/月)")
ax.set_xticks(range(1, 13))
ax.grid(alpha=0.3)
ax.legend(ncol=2)
plt.tight_layout()
plt.show()
能力案 月能力_台 月次能力超過確率 平均年間不足_台 年間不足95%分位_台
0 通常能力 1050 0.38 670.75 1,188.59
1 残業枠 1180 0.18 242.43 613.50
2 外注枠込み 1320 0.05 65.70 289.95

png

結果の読み取り

中央値が能力内でも、需要分布の上側では不足します。通常能力を固定費、残業・外注を変動費として、能力超過確率と不足費用の許容範囲を合意します。販促や大口案件は純粋な乱数ではなく営業活動と連動するため、案件確度別シナリオを営業部門と共同で設定し、毎月更新します。

No.090:デジタルツイン — 現場イベントで状態を更新し施策を先回り評価する

実務での意味

デジタルツインは3D表示そのものではなく、現場の現在状態をデータで同期し、その状態から将来シナリオを比較する仕組みです。標準時間が古いままでは、精巧なモデルでも判断を誤ります。日々のサイクル実績でモデルを更新し、当日残数の完了見込みを出します。

分析・モデル化の考え方

工程 kk の推定サイクル時間を、観測 yk,ty_{k,t} により指数平滑で

μ^k,t=αyk,t+(1α)μ^k,t1\hat\mu_{k,t}=\alpha y_{k,t}+(1-\alpha)\hat\mu_{k,t-1}

と更新します。最小構成は、(1) 対象KPI、(2) 状態データ、(3) 更新規則、(4)将来シミュレーション、(5)意思決定、(6)実績での検証です。現在の推定値を初期状態として、基準・応援・停止短縮案を比較します。

Pythonで確認する

twin_rng = np.random.default_rng(SEED + 90)
standards = {"加工": 18.0, "組立": 25.0, "検査": 21.0}
events = []
for machine, standard in standards.items():
    drift = {"加工": 1.03, "組立": 1.12, "検査": 0.98}[machine]
    observed = twin_rng.lognormal(np.log(standard * drift), 0.12, 40)
    events.extend([[machine, i + 1, value] for i, value in enumerate(observed)])
event_data = pd.DataFrame(events, columns=["工程", "完了順", "実績CT_分"])

alpha = 0.20
state_rows = []
for machine, group in event_data.groupby("工程", sort=False):
    estimate = standards[machine]
    for value in group["実績CT_分"]:
        estimate = alpha * value + (1 - alpha) * estimate
    state_rows.append([machine, standards[machine], estimate, estimate / standards[machine] - 1])
twin_state = pd.DataFrame(state_rows, columns=["工程", "標準CT_分", "更新CT_分", "標準乖離率"]).set_index("工程")
display(twin_state)

def twin_forecast(ct, scenario, seed, remaining=80, reps=3000):
    r = np.random.default_rng(seed)
    adjusted = ct.copy()
    downtime_mean = 38
    if scenario == "組立応援":
        adjusted["組立"] *= 0.88
    if scenario == "停止短縮":
        downtime_mean = 22
    bottleneck_ct = max(adjusted.values())
    finish = remaining * r.lognormal(np.log(bottleneck_ct), 0.06, reps) + r.gamma(2, downtime_mean / 2, reps)
    return finish

ct_now = twin_state["更新CT_分"].to_dict()
twin_rows, twin_samples = [], {}
for i, scenario in enumerate(["基準", "組立応援", "停止短縮"]):
    samples = twin_forecast(ct_now, scenario, SEED + 900 + i)
    twin_samples[scenario] = samples
    twin_rows.append([scenario, samples.mean(), np.quantile(samples, 0.90), (samples <= 2100).mean()])
twin_result = pd.DataFrame(twin_rows, columns=["シナリオ", "平均完了見込_分", "完了90%分位_分", "2100分以内完了確率"])
display(twin_result)

fig, ax = plt.subplots(figsize=(8, 3.8))
for scenario, samples in twin_samples.items():
    ax.hist(samples, bins=35, alpha=0.35, label=scenario)
ax.axvline(2100, color="#C00000", linestyle="--", label="納期基準")
ax.set_title("更新状態から予測した残り80台の完了時間")
ax.set_xlabel("完了までの時間(分)")
ax.set_ylabel("シミュレーション回数")
ax.grid(axis="y", alpha=0.3)
ax.legend()
plt.tight_layout()
plt.show()
標準CT_分 更新CT_分 標準乖離率
工程
加工 18.00 18.70 0.04
組立 25.00 27.68 0.11
検査 21.00 20.09 -0.04
シナリオ 平均完了見込_分 完了90%分位_分 2100分以内完了確率
0 基準 2,257.37 2,433.68 0.12
1 組立応援 1,987.86 2,138.01 0.83
2 停止短縮 2,239.69 2,412.84 0.15

png

結果の読み取り

標準乖離率が大きい工程は、劣化、品種構成、作業方法の変化を調べるシグナルです。更新状態から算出した納期内完了確率により、応援と停止短縮を同じ尺度で比較できます。本番では予測誤差を継続監視し、センサー欠損や時刻ずれを検知します。自動提案を導入しても、製造指示の承認権限、モデル停止時の手動運用、変更履歴を明確にします。

対象ノックを通して見える実務上の示唆

  1. 問いに合う時間表現を選ぶ:総利益の分布ならモンテカルロ、工程順序なら離散イベント、主体間相互作用ならエージェントベースが適します。
  2. 平均値から分布へ進む:平均利益・平均需要だけでなく、赤字確率、下方分位点、能力超過確率を意思決定に使います。
  3. 高稼働を目的にしない:待ち行列では利用率上昇に対して待ちが非線形に増えます。納期サービスと費用を併記します。
  4. 局所改善を全体KPIで評価する:工程短縮、予防保全、安全在庫は、スループット・停止・総費用への効果で比較します。
  5. 時間遅れを明示する:能力調整、発注、供給途絶の影響には遅れがあります。月次集計だけでは振動と波及を見落とします。
  6. モデルを現場実績へ閉じる:デジタルツインでは、状態同期、将来比較、実績検証を一つの更新サイクルとして運用します。

実務導入する場合に必要なこと

1. 意思決定・対象範囲・KPIを先に定義する

「工場全体を再現する」から始めず、検査員数、発注点、残業枠、代替調達など、誰がいつ何を決めるかを定義します。KPIには平均だけでなく、納期遵守率、95%分位、最大受注残、総費用を含めます。

2. 入力データと業務ルールを版管理する

品目、工程、設備、シフト、段取り、発注残、停止理由、受注・失注の定義を揃えます。データ抽出日時、単位、欠損補完、標準時間の適用開始日を記録し、再現可能にします。

3. 妥当性確認を段階的に行う

イベント順序や在庫収支の単体確認、過去期間の再現、現場担当者による極端ケースの確認を行います。モデルの数値が実績と合うことに加え、原因と結果の方向が現場知識と整合するかを確認します。

4. 小さな意思決定ループから運用する

1部材・1工程で「データ更新→シナリオ比較→承認→実行→実績評価」を回し、判断時間や欠品削減などの効果を測ります。その後、ERP・MES・設備・調達データとの連携範囲を広げます。

5. 不確実性と責任分界を伝える

予測区間、前提、適用外条件を画面と会議資料に表示します。自動提案の採否、緊急時の上書き権限、モデル停止時の代替手順、監査ログを業務設計へ含めます。

まとめ

No.081〜No.090では、利益リスクのモンテカルロ評価から、工程イベント、設備・保全員の相互作用、待ち行列、需給フィードバック、在庫政策、生産ライン、供給途絶、需要シナリオ、実績連動型デジタルツインまでを扱いました。

シミュレーションの価値は、複雑なモデルを作ることではなく、実行前に選択肢の結果とリスクを比較できることです。小さな対象で収支とイベントを検証し、意思決定に必要な要素だけを段階的に追加します。そして予測と実績の差を残し続けることで、モデルを一度きりの分析から現場の意思決定基盤へ育てられます。

法人向けのご相談

数理工房では、製造業におけるシミュレーション、在庫・生産・サプライチェーン設計、デジタルツインの構想策定から実装・内製化支援までご相談を承ります。

  • 工程・物流・保全を対象とした離散イベントシミュレーション
  • 需要・供給・利益リスクのモンテカルロ評価とシナリオ設計
  • 在庫政策、要員配置、設備投資、BCP施策の比較検証
  • ERP・MES・設備データをつなぐデジタルツイン/意思決定基盤
  • Python・統計・シミュレーションを扱う法人研修

📩 お問い合わせ: surikobo.co.jp/contact
まずはお気軽にご相談ください。