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, }