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

203 lines
7.4 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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