aps-agent/server/aps_domain/robustness.py.bak33

203 lines
7.4 KiB
Plaintext
Raw Normal View History

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