203 lines
7.4 KiB
Plaintext
203 lines
7.4 KiB
Plaintext
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import copy
|
|||
|
|
import random
|
|||
|
|
from typing import Any, Callable
|
|||
|
|
|
|||
|
|
from server.aps_domain.constraints import hard_blocking_conflicts
|
|||
|
|
from server.contracts import ScheduleResult
|
|||
|
|
from server.engines import get_engine
|
|||
|
|
from server.engines.base import EngineParams
|
|||
|
|
from server.timeutil import add_minutes, fmt_date, today0
|
|||
|
|
|
|||
|
|
World = dict[str, Any]
|
|||
|
|
|
|||
|
|
DEFAULT_MONTE_CARLO_SEED = 20260731
|
|||
|
|
DEFAULT_MONTE_CARLO_TRIALS = 24
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _counter() -> Callable[[str], int]:
|
|||
|
|
values: dict[str, int] = {}
|
|||
|
|
|
|||
|
|
def next_id(kind: str) -> int:
|
|||
|
|
values[kind] = values.get(kind, 3000000) + 1
|
|||
|
|
return values[kind]
|
|||
|
|
|
|||
|
|
return next_id
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _bounded_normal(rng: random.Random, mean: float, sigma: float, low: float, high: float) -> float:
|
|||
|
|
return max(low, min(high, rng.gauss(mean, sigma)))
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _perturb_world(
|
|||
|
|
world: World,
|
|||
|
|
rng: random.Random,
|
|||
|
|
*,
|
|||
|
|
reset_schedule_products: bool,
|
|||
|
|
) -> tuple[World, dict[str, Any]]:
|
|||
|
|
sandbox = copy.deepcopy(world)
|
|||
|
|
if reset_schedule_products:
|
|||
|
|
sandbox["scheduleVersions"] = []
|
|||
|
|
sandbox["productionOrders"] = []
|
|||
|
|
sandbox["workOrders"] = []
|
|||
|
|
sandbox["conflicts"] = []
|
|||
|
|
quantity_scale = _bounded_normal(rng, 1.0, 0.10, 0.80, 1.20)
|
|||
|
|
efficiency_scale = _bounded_normal(rng, 1.0, 0.08, 0.75, 1.25)
|
|||
|
|
|
|||
|
|
for order in sandbox.get("salesOrders") or []:
|
|||
|
|
for item in order.get("items") or []:
|
|||
|
|
quantity = float(item.get("quantity") or 0.0)
|
|||
|
|
if quantity > 0:
|
|||
|
|
item["quantity"] = max(1, int(round(quantity * quantity_scale)))
|
|||
|
|
for line in sandbox.get("lines") or []:
|
|||
|
|
base = float(line.get("efficiencyFactor") or 1.0)
|
|||
|
|
line["efficiencyFactor"] = round(base * efficiency_scale, 6)
|
|||
|
|
|
|||
|
|
capacity_shock_line = None
|
|||
|
|
lines = sandbox.get("lines") or []
|
|||
|
|
if lines and rng.random() < 0.15:
|
|||
|
|
shocked = lines[rng.randrange(len(lines))]
|
|||
|
|
shocked["efficiencyFactor"] = round(float(shocked.get("efficiencyFactor") or 1.0) * 0.55, 6)
|
|||
|
|
capacity_shock_line = shocked.get("id")
|
|||
|
|
|
|||
|
|
return sandbox, {
|
|||
|
|
"quantityScale": round(quantity_scale, 6),
|
|||
|
|
"efficiencyScale": round(efficiency_scale, 6),
|
|||
|
|
"capacityShockLineId": capacity_shock_line,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _percentile(values: list[float], percentile: float) -> float:
|
|||
|
|
if not values:
|
|||
|
|
return 0.0
|
|||
|
|
ordered = sorted(values)
|
|||
|
|
position = (len(ordered) - 1) * percentile
|
|||
|
|
lower = int(position)
|
|||
|
|
upper = min(lower + 1, len(ordered) - 1)
|
|||
|
|
fraction = position - lower
|
|||
|
|
return round(ordered[lower] * (1.0 - fraction) + ordered[upper] * fraction, 4)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def run_monte_carlo(
|
|||
|
|
world: World,
|
|||
|
|
*,
|
|||
|
|
strategy: str = "COMPREHENSIVE",
|
|||
|
|
engine_type: str = "RULE",
|
|||
|
|
trials: int = DEFAULT_MONTE_CARLO_TRIALS,
|
|||
|
|
seed: int = DEFAULT_MONTE_CARLO_SEED,
|
|||
|
|
baseline_kpi: dict[str, Any] | None = None,
|
|||
|
|
planning_horizon_days: int = 14,
|
|||
|
|
start_date: str | None = None,
|
|||
|
|
delivery_buffer_ratio: float | None = None,
|
|||
|
|
freeze_window_hours: float | None = None,
|
|||
|
|
constraints: dict[str, bool] | None = None,
|
|||
|
|
reset_schedule_products: bool = False,
|
|||
|
|
) -> dict[str, Any]:
|
|||
|
|
"""Re-solve fixed-seed demand/capacity perturbations in isolated sandboxes.
|
|||
|
|
|
|||
|
|
经统一 Explore 通道执行(矩阵 55 行):fn 只拿深拷贝沙盒,主干永不外泄写引用;
|
|||
|
|
每 trial 的 _perturb_world 再在沙盒上做独立扰动,互不污染。
|
|||
|
|
"""
|
|||
|
|
from server.aps_domain.explore_boundary import run_explore
|
|||
|
|
return run_explore(world, lambda sandbox: _monte_carlo_impl(
|
|||
|
|
sandbox,
|
|||
|
|
strategy=strategy, engine_type=engine_type, trials=trials, seed=seed,
|
|||
|
|
baseline_kpi=baseline_kpi, planning_horizon_days=planning_horizon_days,
|
|||
|
|
start_date=start_date, delivery_buffer_ratio=delivery_buffer_ratio,
|
|||
|
|
freeze_window_hours=freeze_window_hours, constraints=constraints,
|
|||
|
|
reset_schedule_products=reset_schedule_products,
|
|||
|
|
))
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _monte_carlo_impl(
|
|||
|
|
world: World,
|
|||
|
|
*,
|
|||
|
|
strategy: str,
|
|||
|
|
engine_type: str,
|
|||
|
|
trials: int,
|
|||
|
|
seed: int,
|
|||
|
|
baseline_kpi: dict[str, Any] | None,
|
|||
|
|
planning_horizon_days: int,
|
|||
|
|
start_date: str | None,
|
|||
|
|
delivery_buffer_ratio: float | None,
|
|||
|
|
freeze_window_hours: float | None,
|
|||
|
|
constraints: dict[str, bool] | None,
|
|||
|
|
reset_schedule_products: bool,
|
|||
|
|
) -> dict[str, Any]:
|
|||
|
|
"""Monte Carlo 核心实现(在统一通道沙盒内运行)。"""
|
|||
|
|
sample_count = max(1, min(500, int(trials)))
|
|||
|
|
baseline = baseline_kpi or {}
|
|||
|
|
baseline_tardiness = max(0.0, float(baseline.get("totalTardiness") or baseline.get("tardiness") or 0.0))
|
|||
|
|
baseline_conflicts = max(0, int(baseline.get("conflictCount") or baseline.get("conflicts") or 0))
|
|||
|
|
criteria = {
|
|||
|
|
"maxTotalTardiness": round(baseline_tardiness + max(1.0, baseline_tardiness * 0.10), 4),
|
|||
|
|
"maxConflictCount": baseline_conflicts,
|
|||
|
|
"requireNoHardViolations": True,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
rng = random.Random(int(seed))
|
|||
|
|
start = start_date or fmt_date(add_minutes(today0(), 24 * 60))
|
|||
|
|
outcomes: list[dict[str, Any]] = []
|
|||
|
|
for trial in range(sample_count):
|
|||
|
|
sandbox, perturbation = _perturb_world(
|
|||
|
|
world,
|
|||
|
|
rng,
|
|||
|
|
reset_schedule_products=reset_schedule_products,
|
|||
|
|
)
|
|||
|
|
params = EngineParams(
|
|||
|
|
orderIds=[],
|
|||
|
|
engineType=engine_type,
|
|||
|
|
strategyTemplate=strategy,
|
|||
|
|
planningHorizonDays=planning_horizon_days,
|
|||
|
|
startDate=start,
|
|||
|
|
deliveryBufferRatio=delivery_buffer_ratio,
|
|||
|
|
freezeWindowHours=freeze_window_hours,
|
|||
|
|
name=f"mc-{seed}-{trial + 1}",
|
|||
|
|
constraints=constraints or EngineParams().constraints,
|
|||
|
|
)
|
|||
|
|
result: ScheduleResult = get_engine(engine_type).solve(sandbox, params, _counter())
|
|||
|
|
hard_rows = hard_blocking_conflicts(sandbox, result.versionId, track="fixed")
|
|||
|
|
hard_ids = {row.get("id") for row in hard_rows}
|
|||
|
|
critical_unmapped = [
|
|||
|
|
row for row in sandbox.get("conflicts") or []
|
|||
|
|
if row.get("versionId") == result.versionId
|
|||
|
|
and row.get("severity") == "CRITICAL"
|
|||
|
|
and row.get("id") not in hard_ids
|
|||
|
|
]
|
|||
|
|
hard_count = len(hard_rows) + len(critical_unmapped)
|
|||
|
|
accepted = (
|
|||
|
|
hard_count == 0
|
|||
|
|
and float(result.totalTardiness) <= criteria["maxTotalTardiness"] + 1e-9
|
|||
|
|
and int(result.conflictCount) <= criteria["maxConflictCount"]
|
|||
|
|
)
|
|||
|
|
outcomes.append({
|
|||
|
|
"trial": trial + 1,
|
|||
|
|
**perturbation,
|
|||
|
|
"totalTardiness": round(float(result.totalTardiness), 4),
|
|||
|
|
"conflictCount": int(result.conflictCount),
|
|||
|
|
"hardViolationCount": hard_count,
|
|||
|
|
"accepted": accepted,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
accepted_count = sum(1 for outcome in outcomes if outcome["accepted"])
|
|||
|
|
tardiness = [float(outcome["totalTardiness"]) for outcome in outcomes]
|
|||
|
|
return {
|
|||
|
|
"method": "fixed-seed-monte-carlo",
|
|||
|
|
"seed": int(seed),
|
|||
|
|
"trials": sample_count,
|
|||
|
|
"acceptanceCriteria": criteria,
|
|||
|
|
"acceptedTrials": accepted_count,
|
|||
|
|
"robustness": round(accepted_count / sample_count, 6),
|
|||
|
|
"distribution": {
|
|||
|
|
"tardinessP50": _percentile(tardiness, 0.50),
|
|||
|
|
"tardinessP90": _percentile(tardiness, 0.90),
|
|||
|
|
"tardinessP95": _percentile(tardiness, 0.95),
|
|||
|
|
"tardinessMax": round(max(tardiness, default=0.0), 4),
|
|||
|
|
"meanConflicts": round(
|
|||
|
|
sum(outcome["conflictCount"] for outcome in outcomes) / sample_count, 4
|
|||
|
|
),
|
|||
|
|
},
|
|||
|
|
"outcomes": outcomes,
|
|||
|
|
}
|