492 lines
19 KiB
Python
492 lines
19 KiB
Python
from __future__ import annotations
|
||
|
||
import copy
|
||
import hashlib
|
||
import json
|
||
from collections.abc import Callable, Mapping
|
||
from datetime import date
|
||
from typing import Any
|
||
|
||
from server.aps_domain.constraints import engine_constraint_flags
|
||
from server.aps_domain.params import get_schedule_params
|
||
from server.engines.base import EngineParams
|
||
from server.engines.cp_engine import CP_DIAGNOSTIC_RELAXABLE_CONSTRAINTS, CpSatEngine
|
||
from server.engines.solver_process import (
|
||
SolverProcessError,
|
||
run_cp_constraint_diagnostic,
|
||
)
|
||
|
||
World = dict[str, Any]
|
||
_METHOD = "cp-one-constraint-at-a-time-resolve.v1"
|
||
_OBJECTIVE_UNIT = "weighted-tardiness-minute"
|
||
_LABELS = {
|
||
"C1_precedence": "工艺先后序",
|
||
"C2_no_overlap": "工位/设备独占",
|
||
"C3_calendar": "班次日历窗口",
|
||
"C7_capacity": "产线日产能上限",
|
||
"C10_changeover": "顺序相关换型",
|
||
"C11_freeze": "冻结窗口",
|
||
"C12_team": "班组并发容量",
|
||
"C12_tooling": "工装并发容量",
|
||
}
|
||
|
||
|
||
def _resolve_start_date(world: World, start_date: str | None) -> tuple[str, str]:
|
||
source = "request" if start_date is not None else "world.businessDate"
|
||
raw = start_date if start_date is not None else world.get("businessDate")
|
||
if not isinstance(raw, str) or not raw.strip():
|
||
raise ValueError("startDate 必填;仅可省略于 world.businessDate 已设置时")
|
||
value = raw.strip()
|
||
try:
|
||
parsed = date.fromisoformat(value)
|
||
except ValueError as exc:
|
||
raise ValueError("startDate 须为 YYYY-MM-DD") from exc
|
||
if parsed.isoformat() != value:
|
||
raise ValueError("startDate 须为 YYYY-MM-DD")
|
||
return value, source
|
||
|
||
|
||
def _digest(payload: Any) -> str:
|
||
text = json.dumps(
|
||
payload, ensure_ascii=False, allow_nan=False, sort_keys=True,
|
||
separators=(",", ":"), default=str,
|
||
)
|
||
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||
|
||
|
||
def _solver_snapshot(meta: dict[str, Any]) -> dict[str, Any]:
|
||
process = meta.get("solverProcess") or {}
|
||
raw_c3 = meta.get("c3Calendar")
|
||
c3_calendar = (
|
||
{
|
||
key: raw_c3.get(key)
|
||
for key in (
|
||
"schemaVersion", "modelMode", "pausePolicy", "anchor", "horizonMinutes",
|
||
"coverageStart", "coverageEnd", "coverageComplete",
|
||
"normalizedCalendarDigest", "calendarBucketCount", "lineWindowCounts",
|
||
"segmentIntervalCount", "segmentIntervalLimit", "maxSegmentsPerOperation",
|
||
"selectedMode", "active",
|
||
)
|
||
}
|
||
if isinstance(raw_c3, Mapping)
|
||
else None
|
||
)
|
||
return {
|
||
"status": meta.get("status"),
|
||
"objective": meta.get("objective"),
|
||
"bestBound": meta.get("bestBound"),
|
||
"gap": meta.get("gap"),
|
||
"wallTimeSec": meta.get("wallTimeSec"),
|
||
"timeLimitSec": meta.get("timeLimitSec"),
|
||
"assumptionConstraints": meta.get("assumptionConstraints") or [],
|
||
"activeAssumptionConstraints": meta.get("activeAssumptionConstraints") or [],
|
||
"enforcedAssumptionConstraints": meta.get("enforcedAssumptionConstraints") or [],
|
||
"relaxedConstraintIds": meta.get("relaxedConstraintIds") or [],
|
||
"constraintInstanceCounts": meta.get("constraintInstanceCounts") or {},
|
||
"diagnosticMode": meta.get("diagnosticMode"),
|
||
"numSearchWorkers": meta.get("numSearchWorkers"),
|
||
"randomSeed": meta.get("randomSeed"),
|
||
"requestId": process.get("requestId"),
|
||
"invocationId": process.get("invocationId"),
|
||
"operation": process.get("operation"),
|
||
"runtimeSafe": (process.get("runtimeIdentity") or {}).get("safe"),
|
||
"c3Calendar": c3_calendar,
|
||
}
|
||
|
||
|
||
def _model_identity(snapshot: dict[str, Any]) -> dict[str, Any]:
|
||
c3_calendar = snapshot.get("c3Calendar")
|
||
c3_topology = (
|
||
{
|
||
key: c3_calendar.get(key)
|
||
for key in (
|
||
"schemaVersion", "modelMode", "pausePolicy", "anchor", "horizonMinutes",
|
||
"coverageStart", "coverageEnd", "coverageComplete",
|
||
"normalizedCalendarDigest", "calendarBucketCount", "lineWindowCounts",
|
||
"segmentIntervalCount", "segmentIntervalLimit", "maxSegmentsPerOperation",
|
||
)
|
||
}
|
||
if isinstance(c3_calendar, Mapping)
|
||
else None
|
||
)
|
||
return {
|
||
"assumptionConstraints": snapshot.get("assumptionConstraints") or [],
|
||
"activeAssumptionConstraints": snapshot.get("activeAssumptionConstraints") or [],
|
||
"constraintInstanceCounts": snapshot.get("constraintInstanceCounts") or {},
|
||
"c3Topology": c3_topology,
|
||
"objectiveUnit": _OBJECTIVE_UNIT,
|
||
}
|
||
|
||
|
||
def run_cp_marginal_resolve(
|
||
world: World,
|
||
*,
|
||
start_date: str | None = None,
|
||
strategy: str = "COMPREHENSIVE",
|
||
planning_horizon_days: int = 14,
|
||
time_limit_seconds: float = 4.0,
|
||
constraint_ids: list[str] | None = None,
|
||
cancel_check: Callable[[], None] | None = None,
|
||
) -> dict[str, Any]:
|
||
"""Compare the CP business objective after whole-constraint removal, one at a time."""
|
||
raw_constraint_ids = (
|
||
list(CP_DIAGNOSTIC_RELAXABLE_CONSTRAINTS)
|
||
if constraint_ids is None
|
||
else list(constraint_ids)
|
||
)
|
||
if not raw_constraint_ids:
|
||
raise ValueError("constraintIds 不能为空")
|
||
if len(raw_constraint_ids) != len(set(raw_constraint_ids)):
|
||
raise ValueError("constraintIds 不得重复")
|
||
requested = sorted(raw_constraint_ids)
|
||
unknown = set(requested) - CP_DIAGNOSTIC_RELAXABLE_CONSTRAINTS
|
||
if unknown:
|
||
raise ValueError(f"不支持 CP 诊断约束:{sorted(unknown)}")
|
||
if not 1 <= int(planning_horizon_days) <= 90:
|
||
raise ValueError("planningHorizonDays 须在 1~90")
|
||
if not 0.5 <= float(time_limit_seconds) <= 10.0:
|
||
raise ValueError("timeLimitSeconds 须在 0.5~10")
|
||
resolved_start, start_source = _resolve_start_date(world, start_date)
|
||
sandbox = copy.deepcopy(world)
|
||
schedule_params = get_schedule_params(sandbox)
|
||
params = EngineParams(
|
||
orderIds=[],
|
||
engineType="CP",
|
||
strategyTemplate=strategy,
|
||
planningHorizonDays=int(planning_horizon_days),
|
||
startDate=resolved_start,
|
||
constraints=engine_constraint_flags(sandbox),
|
||
deliveryBufferRatio=float(schedule_params.get("deliveryBufferRatio") or 0.95),
|
||
freezeWindowHours=float(schedule_params.get("freezeWindowHours") or 0.0),
|
||
timeLimitSeconds=float(time_limit_seconds),
|
||
name="cp-marginal-diagnostic",
|
||
)
|
||
entries, _, source_count = CpSatEngine().collect_and_order(sandbox, params)
|
||
world_digest = _digest(sandbox)
|
||
entries_digest = _digest(entries)
|
||
params_digest = _digest(params.model_dump(mode="json"))
|
||
objective_spec = {
|
||
"kind": "min-weighted-tardiness",
|
||
"unit": _OBJECTIVE_UNIT,
|
||
"late": "max(0, jobEndMin-dueMin)",
|
||
"weight": "customerLevelWeight*100 + rush/forecast adjustments",
|
||
}
|
||
objective_spec_digest = _digest(objective_spec)
|
||
digest = _digest({
|
||
"worldDigest": world_digest,
|
||
"entriesDigest": entries_digest,
|
||
"paramsDigest": params_digest,
|
||
"objectiveSpecDigest": objective_spec_digest,
|
||
"constraintIds": requested,
|
||
})
|
||
common = {
|
||
"method": _METHOD,
|
||
"modelScope": "operation-level-cp-sat-re-solve",
|
||
"counterfactualMode": "one-constraint-at-a-time-whole-removal",
|
||
"objectiveUnit": _OBJECTIVE_UNIT,
|
||
"isDualValue": False,
|
||
"isUnitMarginalValue": False,
|
||
"nonAdditiveAcrossConstraints": True,
|
||
"cancellationGranularity": "between-solves",
|
||
"cancellationDoesNotInterruptActiveSolve": True,
|
||
"activeSolveSupervisionTimeoutSeconds": max(
|
||
15.0, float(time_limit_seconds) + 12.0,
|
||
),
|
||
"cancellationCleanupTimeoutSeconds": 11.0,
|
||
"cancellationLatencyUpperBoundSeconds": (
|
||
max(15.0, float(time_limit_seconds) + 12.0) + 11.0
|
||
),
|
||
"track": "fixed",
|
||
"strategy": strategy,
|
||
"startDate": resolved_start,
|
||
"startDateSource": start_source,
|
||
"planningHorizonDays": int(planning_horizon_days),
|
||
"timeLimitSeconds": float(time_limit_seconds),
|
||
"inputDigest": digest,
|
||
"worldDigest": world_digest,
|
||
"entriesDigest": entries_digest,
|
||
"paramsDigest": params_digest,
|
||
"objectiveSpec": objective_spec,
|
||
"objectiveSpecDigest": objective_spec_digest,
|
||
"entryCount": len(entries),
|
||
"sourceOrderCount": source_count,
|
||
}
|
||
if not entries:
|
||
return {
|
||
**common,
|
||
"status": "no_demand",
|
||
"evaluations": 0,
|
||
"baseline": None,
|
||
"rows": [],
|
||
"summary": "当前 fixed 轨没有可进入 CP-SAT 的待排订单,未执行重解。",
|
||
}
|
||
|
||
if cancel_check is not None:
|
||
cancel_check()
|
||
try:
|
||
_, baseline_meta = run_cp_constraint_diagnostic(
|
||
sandbox,
|
||
entries,
|
||
params,
|
||
pipeline_label="CP-MARGINAL-BASELINE",
|
||
)
|
||
except SolverProcessError as exc:
|
||
return {
|
||
**common,
|
||
"status": "baseline_solver_error",
|
||
"evaluations": 1,
|
||
"baseline": None,
|
||
"rows": [],
|
||
"summary": f"CP 基线求解失败:{exc.code}",
|
||
"error": exc.as_dict(),
|
||
}
|
||
if cancel_check is not None:
|
||
cancel_check()
|
||
baseline = _solver_snapshot(baseline_meta)
|
||
active = set(baseline_meta.get("activeAssumptionConstraints") or [])
|
||
baseline_status = str(baseline.get("status") or "")
|
||
baseline_objective = baseline.get("objective")
|
||
if baseline_status not in {"OPTIMAL", "FEASIBLE", "INFEASIBLE"}:
|
||
return {
|
||
**common,
|
||
"status": "baseline_unavailable",
|
||
"evaluations": 1,
|
||
"baseline": baseline,
|
||
"rows": [],
|
||
"summary": f"CP 基线状态 {baseline_status or 'UNKNOWN'},无法比较业务目标。",
|
||
}
|
||
if baseline_status in {"OPTIMAL", "FEASIBLE"} and not isinstance(
|
||
baseline_objective, (int, float)
|
||
):
|
||
return {
|
||
**common,
|
||
"status": "baseline_unavailable",
|
||
"evaluations": 1,
|
||
"baseline": baseline,
|
||
"rows": [],
|
||
"summary": "CP 基线缺少可比较 objective。",
|
||
}
|
||
|
||
rows: list[dict[str, Any]] = []
|
||
evaluations = 1
|
||
monotonicity_violation = False
|
||
for constraint_id in requested:
|
||
base_row = {
|
||
"constraintId": constraint_id,
|
||
"constraintName": _LABELS[constraint_id],
|
||
"wholeConstraintRemoval": True,
|
||
"isDualValue": False,
|
||
"isUnitMarginalValue": False,
|
||
"nonAdditiveAcrossConstraints": True,
|
||
}
|
||
if constraint_id not in active:
|
||
rows.append({
|
||
**base_row,
|
||
"status": "inactive",
|
||
"reason": "该约束在本次 CP 模型中未启用,未执行反事实重解。",
|
||
"objectiveImprovement": None,
|
||
})
|
||
continue
|
||
if cancel_check is not None:
|
||
cancel_check()
|
||
evaluations += 1
|
||
try:
|
||
_, relaxed_meta = run_cp_constraint_diagnostic(
|
||
sandbox,
|
||
entries,
|
||
params,
|
||
pipeline_label=f"CP-MARGINAL-{constraint_id}",
|
||
relaxed_constraint_id=constraint_id,
|
||
)
|
||
except SolverProcessError as exc:
|
||
rows.append({
|
||
**base_row,
|
||
"status": "solver_error",
|
||
"reason": exc.code,
|
||
"error": exc.as_dict(),
|
||
"objectiveImprovement": None,
|
||
})
|
||
continue
|
||
if cancel_check is not None:
|
||
cancel_check()
|
||
relaxed_snapshot = _solver_snapshot(relaxed_meta)
|
||
if _model_identity(relaxed_snapshot) != _model_identity(baseline):
|
||
rows.append({
|
||
**base_row,
|
||
"status": "solver_error",
|
||
"reason": "基线与松弛重解的模型实例清单不一致",
|
||
"relaxed": relaxed_snapshot,
|
||
"objectiveImprovement": None,
|
||
})
|
||
continue
|
||
relaxed_status = str(relaxed_snapshot.get("status") or "")
|
||
relaxed_objective = relaxed_snapshot.get("objective")
|
||
if baseline_status == "INFEASIBLE":
|
||
if relaxed_status in {"OPTIMAL", "FEASIBLE"}:
|
||
rows.append({
|
||
**base_row,
|
||
"status": "restores_feasibility",
|
||
"comparisonQuality": (
|
||
"exact-optimal" if relaxed_status == "OPTIMAL" else "feasible-only"
|
||
),
|
||
"relaxed": relaxed_snapshot,
|
||
"objectiveImprovement": None,
|
||
"interpretation": "基线不可行,完整移除该约束后恢复可行;不计算目标差。",
|
||
})
|
||
elif relaxed_status == "INFEASIBLE":
|
||
rows.append({
|
||
**base_row,
|
||
"status": "does_not_restore_feasibility",
|
||
"reason": "基线和松弛后均不可行,整约束移除未恢复可行性。",
|
||
"relaxed": relaxed_snapshot,
|
||
"objectiveImprovement": None,
|
||
})
|
||
elif relaxed_status == "MODEL_INVALID":
|
||
rows.append({
|
||
**base_row,
|
||
"status": "solver_error",
|
||
"reason": "松弛后 CP 模型无效,无法得出可行性结论。",
|
||
"relaxed": relaxed_snapshot,
|
||
"objectiveImprovement": None,
|
||
})
|
||
else:
|
||
rows.append({
|
||
**base_row,
|
||
"status": "unavailable",
|
||
"reason": f"松弛后状态 {relaxed_status or 'UNKNOWN'},未得出可行性结论。",
|
||
"relaxed": relaxed_snapshot,
|
||
"objectiveImprovement": None,
|
||
})
|
||
continue
|
||
if relaxed_status == "MODEL_INVALID":
|
||
rows.append({
|
||
**base_row,
|
||
"status": "solver_error",
|
||
"reason": "松弛后 CP 模型无效,无法比较业务目标。",
|
||
"relaxed": relaxed_snapshot,
|
||
"objectiveImprovement": None,
|
||
})
|
||
continue
|
||
if relaxed_status == "INFEASIBLE":
|
||
monotonicity_violation = True
|
||
rows.append({
|
||
**base_row,
|
||
"status": "monotonicity_violation",
|
||
"reason": "移除约束后反而不可行,违反单调性,整份报告失败关闭。",
|
||
"relaxed": relaxed_snapshot,
|
||
"objectiveImprovement": None,
|
||
})
|
||
continue
|
||
if relaxed_status not in {"OPTIMAL", "FEASIBLE"} or not isinstance(
|
||
relaxed_objective, (int, float)
|
||
):
|
||
rows.append({
|
||
**base_row,
|
||
"status": "unavailable",
|
||
"reason": f"松弛后求解状态 {relaxed_status or 'UNKNOWN'}",
|
||
"relaxed": relaxed_snapshot,
|
||
"objectiveImprovement": None,
|
||
})
|
||
continue
|
||
exact = baseline_status == "OPTIMAL" and relaxed_status == "OPTIMAL"
|
||
incumbent_difference = float(baseline_objective) - float(relaxed_objective)
|
||
if exact:
|
||
if incumbent_difference < -1e-6:
|
||
monotonicity_violation = True
|
||
rows.append({
|
||
**base_row,
|
||
"status": "monotonicity_violation",
|
||
"reason": "OPTIMAL 结果显示移除约束后目标恶化,违反单调性。",
|
||
"relaxed": relaxed_snapshot,
|
||
"objectiveImprovement": None,
|
||
})
|
||
continue
|
||
rows.append({
|
||
**base_row,
|
||
"status": "available",
|
||
"comparisonQuality": "exact-optimal",
|
||
"isExactCounterfactual": True,
|
||
"baselineObjective": float(baseline_objective),
|
||
"relaxedObjective": float(relaxed_objective),
|
||
"objectiveImprovement": round(incumbent_difference, 6),
|
||
"improvementLowerBound": round(incumbent_difference, 6),
|
||
"improvementUpperBound": round(incumbent_difference, 6),
|
||
"relaxed": relaxed_snapshot,
|
||
"interpretation": (
|
||
"完整移除该约束后 CP 加权延期最优目标的精确 one-at-a-time 改善;"
|
||
"该值不是对偶,也不是每分钟边际价格。"
|
||
),
|
||
})
|
||
continue
|
||
baseline_bound = float(baseline["bestBound"])
|
||
relaxed_bound = float(relaxed_snapshot["bestBound"])
|
||
lower = max(0.0, baseline_bound - float(relaxed_objective))
|
||
upper = float(baseline_objective) - relaxed_bound
|
||
if upper < -1e-6:
|
||
monotonicity_violation = True
|
||
rows.append({
|
||
**base_row,
|
||
"status": "monotonicity_violation",
|
||
"reason": "FEASIBLE 界限显示移除约束可能恶化最优目标,报告失败关闭。",
|
||
"relaxed": relaxed_snapshot,
|
||
"objectiveImprovement": None,
|
||
})
|
||
continue
|
||
rows.append({
|
||
**base_row,
|
||
"status": "bounded",
|
||
"comparisonQuality": "bound-interval",
|
||
"isExactCounterfactual": False,
|
||
"objectiveImprovement": None,
|
||
"incumbentDifference": round(incumbent_difference, 6),
|
||
"improvementLowerBound": round(lower, 6),
|
||
"improvementUpperBound": round(max(0.0, upper), 6),
|
||
"relaxed": relaxed_snapshot,
|
||
"interpretation": (
|
||
"至少一侧仅 FEASIBLE,只报告由 incumbent/bound 推导的改善区间;"
|
||
"incumbent 差值不用于排序。"
|
||
),
|
||
})
|
||
|
||
if monotonicity_violation:
|
||
for row in rows:
|
||
if row["status"] != "monotonicity_violation":
|
||
row["suppressedStatus"] = row["status"]
|
||
row["status"] = "suppressed"
|
||
row["reason"] = "报告存在单调性违反,本行改善值已抑制。"
|
||
for field in (
|
||
"objectiveImprovement", "objectiveDelta", "incumbentDifference",
|
||
"improvementLowerBound", "improvementUpperBound",
|
||
):
|
||
row[field] = None
|
||
rows.sort(key=lambda row: (
|
||
0 if row["status"] == "available" else 1,
|
||
-(row.get("objectiveImprovement") or 0.0),
|
||
row["constraintId"],
|
||
))
|
||
if cancel_check is not None:
|
||
cancel_check()
|
||
partial = any(row["status"] in {"solver_error", "unavailable"} for row in rows)
|
||
report_status = (
|
||
"monotonicity_violation" if monotonicity_violation
|
||
else "partial" if partial
|
||
else "completed"
|
||
)
|
||
return {
|
||
**common,
|
||
"status": report_status,
|
||
"evaluations": evaluations,
|
||
"baseline": baseline,
|
||
"rows": rows,
|
||
"summary": (
|
||
"检测到约束移除单调性违反,报告失败关闭。"
|
||
if monotonicity_violation
|
||
else "部分约束重解失败或未得出结论;仅保留可验证行。"
|
||
if partial
|
||
else (
|
||
f"完成 CP 基线 + {evaluations - 1} 次整约束移除重解;"
|
||
"结果为业务目标反事实差值/区间,不是影子价。"
|
||
)
|
||
),
|
||
}
|