771 lines
35 KiB
Python
771 lines
35 KiB
Python
from __future__ import annotations
|
||
|
||
import copy
|
||
import hashlib
|
||
import json
|
||
import math
|
||
from collections.abc import Callable, Mapping
|
||
from datetime import date, timedelta
|
||
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_RHS_PARAMETERS, CpSatEngine
|
||
from server.engines.queries import get_available_minutes
|
||
from server.engines.solver_process import SolverProcessError, run_cp_rhs_diagnostic
|
||
|
||
World = dict[str, Any]
|
||
_METHOD = "cp-rhs-one-parameter-at-a-time-resolve.v1"
|
||
_OBJECTIVE_UNIT = "weighted-tardiness-minute"
|
||
_DEFAULT_INCREMENTS = {
|
||
"C8_due_date_allowance": 60,
|
||
"C12_team_capacity": 1,
|
||
"C12_tooling_capacity": 1,
|
||
}
|
||
_AUTO_PARAMETERS = frozenset(_DEFAULT_INCREMENTS)
|
||
_LABELS = {
|
||
"C7_line_day_capacity_minutes": "产线日容量分钟",
|
||
"C8_due_date_allowance": "交期容差",
|
||
"C12_team_capacity": "班组并发容量",
|
||
"C12_tooling_capacity": "工装并发容量",
|
||
}
|
||
_UNITS = {
|
||
"C7_line_day_capacity_minutes": "minute",
|
||
"C8_due_date_allowance": "minute",
|
||
"C12_team_capacity": "capacity-unit",
|
||
"C12_tooling_capacity": "capacity-unit",
|
||
}
|
||
_RATE_UNITS = {
|
||
"C7_line_day_capacity_minutes": "currency-per-line-day-rhs-minute",
|
||
"C8_due_date_allowance": "currency-per-due-allowance-minute",
|
||
"C12_team_capacity": "currency-per-capacity-unit-horizon",
|
||
"C12_tooling_capacity": "currency-per-capacity-unit-horizon",
|
||
}
|
||
|
||
|
||
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 _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 _solver_snapshot(meta: Mapping[str, Any]) -> dict[str, Any]:
|
||
process = meta.get("solverProcess") or {}
|
||
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 [],
|
||
"constraintInstanceCounts": meta.get("constraintInstanceCounts") or {},
|
||
"rhsDiagnosticMode": meta.get("rhsDiagnosticMode"),
|
||
"rhsPerturbation": meta.get("rhsPerturbation"),
|
||
"rhsParameterState": meta.get("rhsParameterState"),
|
||
"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"),
|
||
}
|
||
|
||
|
||
def _rhs_topology(state: Mapping[str, Any] | None) -> dict[str, Any]:
|
||
state = state or {}
|
||
return {
|
||
"dueDateEntryCount": state.get("dueDateEntryCount"),
|
||
"lineDayResources": sorted(
|
||
(
|
||
item.get("lineId"), item.get("bucketDate"),
|
||
item.get("bucketStartMin"), item.get("bucketEndMin"),
|
||
item.get("baseCapacityMinutes"), item.get("candidateLoadTermCount"),
|
||
item.get("changeoverTermCount"), item.get("fixedFrozenLoadMinutes"),
|
||
tuple(item.get("shiftIds") or []),
|
||
tuple(item.get("shiftCalendarRowIds") or []),
|
||
)
|
||
for item in (state.get("lineDailyCapacities") or [])
|
||
if isinstance(item, Mapping)
|
||
),
|
||
"teamResources": sorted(
|
||
(item.get("resourceId"), item.get("intervalCount"))
|
||
for item in (state.get("teamCapacities") or [])
|
||
if isinstance(item, Mapping)
|
||
),
|
||
"toolingResources": sorted(
|
||
(item.get("resourceId"), item.get("intervalCount"))
|
||
for item in (state.get("toolingCapacities") or [])
|
||
if isinstance(item, Mapping)
|
||
),
|
||
}
|
||
|
||
|
||
def _model_identity(snapshot: Mapping[str, Any]) -> dict[str, Any]:
|
||
return {
|
||
"assumptionConstraints": snapshot.get("assumptionConstraints") or [],
|
||
"activeAssumptionConstraints": snapshot.get("activeAssumptionConstraints") or [],
|
||
"constraintInstanceCounts": snapshot.get("constraintInstanceCounts") or {},
|
||
"rhsTopology": _rhs_topology(snapshot.get("rhsParameterState")),
|
||
"objectiveUnit": _OBJECTIVE_UNIT,
|
||
}
|
||
|
||
|
||
def _capacity_map(state: Mapping[str, Any], field: str) -> dict[int, int]:
|
||
return {
|
||
int(item["resourceId"]): int(item["capacity"])
|
||
for item in state.get(field) or []
|
||
if isinstance(item, Mapping)
|
||
}
|
||
|
||
|
||
def _line_capacity_map(state: Mapping[str, Any]) -> dict[tuple[int, str], int]:
|
||
return {
|
||
(int(item["lineId"]), str(item["bucketDate"])): int(item["capacityMinutes"])
|
||
for item in state.get("lineDailyCapacities") or []
|
||
if isinstance(item, Mapping)
|
||
}
|
||
|
||
|
||
def _parse_line_day_instance(instance_id: str) -> tuple[int, str]:
|
||
parts = str(instance_id).split(":")
|
||
if len(parts) != 3 or parts[0] != "line-day":
|
||
raise ValueError(f"C7 rhsInstanceId 非法:{instance_id}")
|
||
try:
|
||
line_id = int(parts[1])
|
||
bucket_date = date.fromisoformat(parts[2]).isoformat()
|
||
except (TypeError, ValueError) as exc:
|
||
raise ValueError(f"C7 rhsInstanceId 非法:{instance_id}") from exc
|
||
if line_id <= 0 or bucket_date != parts[2]:
|
||
raise ValueError(f"C7 rhsInstanceId 非法:{instance_id}")
|
||
return line_id, bucket_date
|
||
|
||
|
||
def _validate_state_delta(
|
||
baseline: Mapping[str, Any], perturbed: Mapping[str, Any], perturbation: Mapping[str, Any],
|
||
) -> tuple[int, int] | None:
|
||
baseline_state = baseline.get("rhsParameterState")
|
||
perturbed_state = perturbed.get("rhsParameterState")
|
||
if not isinstance(baseline_state, Mapping) or not isinstance(perturbed_state, Mapping):
|
||
return None
|
||
parameter_id = str(perturbation["parameterId"])
|
||
increment = int(perturbation["increment"])
|
||
if parameter_id == "C7_line_day_capacity_minutes":
|
||
baseline_caps = _line_capacity_map(baseline_state)
|
||
perturbed_caps = _line_capacity_map(perturbed_state)
|
||
key = (int(perturbation["lineId"]), str(perturbation["bucketDate"]))
|
||
if key not in baseline_caps or set(baseline_caps) != set(perturbed_caps):
|
||
return None
|
||
expected = dict(baseline_caps)
|
||
expected[key] += increment
|
||
if perturbed_caps != expected:
|
||
return None
|
||
if perturbed_state.get("dueDateAllowanceMinutes") != baseline_state.get(
|
||
"dueDateAllowanceMinutes"
|
||
):
|
||
return None
|
||
for field in ("teamCapacities", "toolingCapacities"):
|
||
if _capacity_map(baseline_state, field) != _capacity_map(perturbed_state, field):
|
||
return None
|
||
return baseline_caps[key], perturbed_caps[key]
|
||
if parameter_id == "C8_due_date_allowance":
|
||
baseline_rhs = int(baseline_state.get("dueDateAllowanceMinutes") or 0)
|
||
perturbed_rhs = int(perturbed_state.get("dueDateAllowanceMinutes") or 0)
|
||
if perturbed_rhs != baseline_rhs + increment:
|
||
return None
|
||
if _capacity_map(baseline_state, "teamCapacities") != _capacity_map(
|
||
perturbed_state, "teamCapacities"
|
||
) or _capacity_map(baseline_state, "toolingCapacities") != _capacity_map(
|
||
perturbed_state, "toolingCapacities"
|
||
):
|
||
return None
|
||
if _line_capacity_map(baseline_state) != _line_capacity_map(perturbed_state):
|
||
return None
|
||
return baseline_rhs, perturbed_rhs
|
||
field = "teamCapacities" if parameter_id == "C12_team_capacity" else "toolingCapacities"
|
||
other = "toolingCapacities" if field == "teamCapacities" else "teamCapacities"
|
||
baseline_caps = _capacity_map(baseline_state, field)
|
||
perturbed_caps = _capacity_map(perturbed_state, field)
|
||
resource_id = int(perturbation["resourceId"])
|
||
if resource_id not in baseline_caps or set(baseline_caps) != set(perturbed_caps):
|
||
return None
|
||
expected = dict(baseline_caps)
|
||
expected[resource_id] += increment
|
||
if perturbed_caps != expected:
|
||
return None
|
||
if _capacity_map(baseline_state, other) != _capacity_map(perturbed_state, other):
|
||
return None
|
||
if _line_capacity_map(baseline_state) != _line_capacity_map(perturbed_state):
|
||
return None
|
||
if perturbed_state.get("dueDateAllowanceMinutes") != baseline_state.get(
|
||
"dueDateAllowanceMinutes"
|
||
):
|
||
return None
|
||
return baseline_caps[resource_id], perturbed_caps[resource_id]
|
||
|
||
|
||
def _cost_fields(
|
||
parameter_id: str,
|
||
increment: int,
|
||
cost_rates: Mapping[str, float],
|
||
currency: str,
|
||
*,
|
||
instance_id: str | None = None,
|
||
instance_cost_rates: Mapping[str, float] | None = None,
|
||
) -> dict[str, Any]:
|
||
instance_rates = instance_cost_rates or {}
|
||
if instance_id is not None and instance_id in instance_rates:
|
||
rate = float(instance_rates[instance_id])
|
||
return {
|
||
"costStatus": "configured",
|
||
"costRate": rate,
|
||
"costRateUnit": _RATE_UNITS[parameter_id],
|
||
"costCurrency": currency,
|
||
"estimatedCost": round(rate * increment, 6),
|
||
"costScope": "rhs-instance",
|
||
"costSource": "request.instanceCostRates",
|
||
}
|
||
if parameter_id not in cost_rates:
|
||
return {
|
||
"costStatus": "not_configured",
|
||
"costRate": None,
|
||
"costRateUnit": None,
|
||
"costCurrency": None,
|
||
"estimatedCost": None,
|
||
"costScope": None,
|
||
"costSource": None,
|
||
}
|
||
rate = float(cost_rates[parameter_id])
|
||
return {
|
||
"costStatus": "configured",
|
||
"costRate": rate,
|
||
"costRateUnit": _RATE_UNITS[parameter_id],
|
||
"costCurrency": currency,
|
||
"estimatedCost": round(rate * increment, 6),
|
||
"costScope": "site-default-for-parameter",
|
||
"costSource": "request",
|
||
}
|
||
|
||
|
||
def run_cp_rhs_resolve(
|
||
world: World,
|
||
*,
|
||
start_date: str | None = None,
|
||
strategy: str = "COMPREHENSIVE",
|
||
planning_horizon_days: int = 14,
|
||
time_limit_seconds: float = 4.0,
|
||
parameter_ids: list[str] | None = None,
|
||
increments: Mapping[str, int] | None = None,
|
||
cost_rates: Mapping[str, float] | None = None,
|
||
instance_increments: Mapping[str, int] | None = None,
|
||
instance_cost_rates: Mapping[str, float] | None = None,
|
||
currency: str = "CNY",
|
||
cancel_check: Callable[[], None] | None = None,
|
||
) -> dict[str, Any]:
|
||
"""Resolve one positive C7/C8/C12 RHS increment at a time without materialization."""
|
||
requested = sorted(
|
||
_AUTO_PARAMETERS if parameter_ids is None else list(parameter_ids)
|
||
)
|
||
if not requested:
|
||
raise ValueError("parameterIds 不能为空")
|
||
if len(requested) != len(set(requested)) or not all(isinstance(value, str) for value in requested):
|
||
raise ValueError("parameterIds 须为不重复字符串数组")
|
||
unknown = set(requested) - CP_DIAGNOSTIC_RHS_PARAMETERS
|
||
if unknown:
|
||
raise ValueError(f"不支持 CP RHS 参数:{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")
|
||
normalized_increments = dict(_DEFAULT_INCREMENTS)
|
||
if increments is not None:
|
||
if not isinstance(increments, Mapping) or set(increments) - set(requested):
|
||
raise ValueError("increments 只能配置本次请求参数")
|
||
normalized_increments.update(increments)
|
||
for parameter_id in requested:
|
||
if parameter_id == "C7_line_day_capacity_minutes":
|
||
continue
|
||
value = normalized_increments.get(parameter_id)
|
||
limit = 10_080 if parameter_id == "C8_due_date_allowance" else 100
|
||
if isinstance(value, bool) or not isinstance(value, int) or not 1 <= value <= limit:
|
||
raise ValueError(f"{parameter_id} increment 须为 1~{limit} 整数")
|
||
normalized_instance_increments = dict(instance_increments or {})
|
||
if not isinstance(instance_increments or {}, Mapping):
|
||
raise TypeError("instanceIncrements 须为对象")
|
||
if len(normalized_instance_increments) > 128:
|
||
raise ValueError("instanceIncrements 单次最多 128 个实例")
|
||
if normalized_instance_increments and "C7_line_day_capacity_minutes" not in requested:
|
||
raise ValueError("instanceIncrements 仅可用于本次请求的 C7 参数")
|
||
if "C7_line_day_capacity_minutes" in requested and not normalized_instance_increments:
|
||
raise ValueError("C7 参数须显式提供 instanceIncrements")
|
||
parsed_instances: dict[str, tuple[int, str]] = {}
|
||
for instance_id, value in normalized_instance_increments.items():
|
||
if not isinstance(instance_id, str):
|
||
raise TypeError("instanceIncrements 键须为 line-day 实例 ID")
|
||
parsed_instances[instance_id] = _parse_line_day_instance(instance_id)
|
||
if isinstance(value, bool) or not isinstance(value, int) or not 1 <= value <= 1_440:
|
||
raise ValueError(f"{instance_id} increment 须为 1~1440 整数")
|
||
normalized_rates = dict(cost_rates or {})
|
||
if not isinstance(cost_rates or {}, Mapping) or set(normalized_rates) - set(requested):
|
||
raise ValueError("costRates 只能配置本次请求参数")
|
||
for parameter_id, value in normalized_rates.items():
|
||
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(
|
||
float(value)
|
||
) or float(value) < 0:
|
||
raise ValueError(f"{parameter_id} cost rate 须为非负有限数")
|
||
normalized_instance_rates = dict(instance_cost_rates or {})
|
||
if not isinstance(instance_cost_rates or {}, Mapping):
|
||
raise TypeError("instanceCostRates 须为对象")
|
||
if set(normalized_instance_rates) - set(normalized_instance_increments):
|
||
raise ValueError("instanceCostRates 只能配置本次 instanceIncrements 实例")
|
||
for instance_id, value in normalized_instance_rates.items():
|
||
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(
|
||
float(value)
|
||
) or float(value) < 0:
|
||
raise ValueError(f"{instance_id} instance cost rate 须为非负有限数")
|
||
if not isinstance(currency, str) or len(currency) != 3 or not currency.isascii() or not currency.isalpha():
|
||
raise ValueError("currency 须为 3 位 ASCII 字母")
|
||
currency = currency.upper()
|
||
resolved_start, start_source = _resolve_start_date(world, start_date)
|
||
start_day = date.fromisoformat(resolved_start)
|
||
max_model_day = start_day + timedelta(days=max(int(planning_horizon_days) * 2, 7))
|
||
for instance_id, (line_id, bucket_date) in parsed_instances.items():
|
||
line = next((
|
||
row for row in world.get("lines") or []
|
||
if int(row.get("id", -1)) == line_id
|
||
and row.get("status", "ACTIVE") == "ACTIVE"
|
||
), None)
|
||
bucket_day = date.fromisoformat(bucket_date)
|
||
if line is None:
|
||
raise ValueError(f"C7 产线未启用:{line_id}")
|
||
if not start_day <= bucket_day <= max_model_day:
|
||
raise ValueError(f"C7 实例日期超出 CP 时间域:{instance_id}")
|
||
if get_available_minutes(world, line_id, bucket_date) <= 0:
|
||
raise ValueError(f"C7 实例不是有效工作日:{instance_id}")
|
||
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-rhs-diagnostic",
|
||
)
|
||
entries, _, source_count = CpSatEngine().collect_and_order(sandbox, params)
|
||
objective_spec = {
|
||
"kind": "min-weighted-tardiness", "unit": _OBJECTIVE_UNIT,
|
||
"late": "max(0, jobEndMin-dueMin)",
|
||
"weight": "customerLevelWeight*100 + rush/forecast adjustments",
|
||
}
|
||
world_digest = _digest(sandbox)
|
||
entries_digest = _digest(entries)
|
||
params_digest = _digest(params.model_dump(mode="json"))
|
||
objective_spec_digest = _digest(objective_spec)
|
||
input_digest = _digest({
|
||
"worldDigest": world_digest, "entriesDigest": entries_digest,
|
||
"paramsDigest": params_digest, "objectiveSpecDigest": objective_spec_digest,
|
||
"parameterIds": requested,
|
||
"increments": {
|
||
key: normalized_increments[key] for key in requested if key in normalized_increments
|
||
},
|
||
"costRates": normalized_rates,
|
||
"instanceIncrements": normalized_instance_increments,
|
||
"instanceCostRates": normalized_instance_rates,
|
||
"currency": currency,
|
||
})
|
||
common = {
|
||
"method": _METHOD,
|
||
"modelScope": "operation-level-cp-sat-re-solve",
|
||
"counterfactualMode": "one-rhs-parameter-increment-at-a-time",
|
||
"objectiveUnit": _OBJECTIVE_UNIT,
|
||
"isDualValue": False,
|
||
"isUnitMarginalValue": False,
|
||
"isFiniteDifference": True,
|
||
"nonAdditiveAcrossParameters": True,
|
||
"costCalibration": {
|
||
"status": (
|
||
"configured" if normalized_rates or normalized_instance_rates else "not_configured"
|
||
),
|
||
"configuredParameterIds": sorted(normalized_rates),
|
||
"configuredInstanceIds": sorted(normalized_instance_rates),
|
||
"currency": currency if normalized_rates or normalized_instance_rates else None,
|
||
"objectiveAndCostAreNotNettable": 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": input_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 的待排订单,未执行 RHS 重解。",
|
||
}
|
||
if cancel_check is not None:
|
||
cancel_check()
|
||
try:
|
||
_, baseline_meta = run_cp_rhs_diagnostic(
|
||
sandbox, entries, params, pipeline_label="CP-RHS-BASELINE",
|
||
)
|
||
except SolverProcessError as exc:
|
||
return {
|
||
**common, "status": "baseline_solver_error", "evaluations": 1,
|
||
"baseline": None, "rows": [], "summary": f"CP RHS 基线求解失败:{exc.code}",
|
||
"error": exc.as_dict(),
|
||
}
|
||
if cancel_check is not None:
|
||
cancel_check()
|
||
baseline = _solver_snapshot(baseline_meta)
|
||
baseline_status = str(baseline.get("status") or "")
|
||
baseline_objective = baseline.get("objective")
|
||
if baseline_status not in {"OPTIMAL", "FEASIBLE", "INFEASIBLE"} or (
|
||
baseline_status in {"OPTIMAL", "FEASIBLE"}
|
||
and not isinstance(baseline_objective, (int, float))
|
||
):
|
||
return {
|
||
**common, "status": "baseline_unavailable", "evaluations": 1,
|
||
"baseline": baseline, "rows": [],
|
||
"summary": f"CP RHS 基线状态 {baseline_status or 'UNKNOWN'},无法比较业务目标。",
|
||
}
|
||
|
||
active_constraints = set(baseline.get("activeAssumptionConstraints") or [])
|
||
cumulative_resources = baseline_meta.get("cumulative", {}).get("resources", [])
|
||
line_day_resources = baseline_meta.get("lineDailyCapacity", {}).get("resources", [])
|
||
variants: list[dict[str, Any]] = []
|
||
inactive_rows: list[dict[str, Any]] = []
|
||
for parameter_id in requested:
|
||
if parameter_id == "C7_line_day_capacity_minutes":
|
||
for instance_id in sorted(normalized_instance_increments):
|
||
line_id, bucket_date = parsed_instances[instance_id]
|
||
increment = int(normalized_instance_increments[instance_id])
|
||
resource = next((
|
||
item for item in line_day_resources
|
||
if isinstance(item, Mapping)
|
||
and int(item.get("lineId", -1)) == line_id
|
||
and item.get("bucketDate") == bucket_date
|
||
and int(item.get("candidateLoadTermCount") or 0) > 0
|
||
and int(item.get("baseCapacityMinutes") or 0) > 0
|
||
), None)
|
||
base_row = {
|
||
"parameterId": parameter_id,
|
||
"parameterName": _LABELS[parameter_id],
|
||
"rhsInstanceId": instance_id,
|
||
"lineId": line_id,
|
||
"lineCode": resource.get("lineCode") if resource else None,
|
||
"bucketDate": bucket_date,
|
||
"bucketStartMin": resource.get("bucketStartMin") if resource else None,
|
||
"bucketEndMin": resource.get("bucketEndMin") if resource else None,
|
||
"bucketKind": "natural-calendar-day",
|
||
"accountingMethod": "start-day-full-duration.v1",
|
||
"capacitySource": "shift-calendar-effective-minutes",
|
||
"isMaterializedShiftModel": False,
|
||
"increment": increment,
|
||
"parameterUnit": _UNITS[parameter_id],
|
||
"isDualValue": False,
|
||
"isUnitMarginalValue": False,
|
||
"isFiniteDifference": True,
|
||
"nonAdditiveAcrossParameters": True,
|
||
**_cost_fields(
|
||
parameter_id,
|
||
increment,
|
||
normalized_rates,
|
||
currency,
|
||
instance_id=instance_id,
|
||
instance_cost_rates=normalized_instance_rates,
|
||
),
|
||
}
|
||
if resource is None or "C7_capacity" not in active_constraints:
|
||
inactive_cost = {}
|
||
if base_row["costStatus"] == "configured":
|
||
inactive_cost = {
|
||
"costStatus": "configured_inactive",
|
||
"estimatedCost": None,
|
||
}
|
||
inactive_rows.append({
|
||
**base_row,
|
||
"status": "inactive",
|
||
"reason": "该 line/day 在本次 CP 模型中没有可增量的工作日产能实例。",
|
||
"objectiveImprovement": None,
|
||
**inactive_cost,
|
||
})
|
||
continue
|
||
variants.append({
|
||
**base_row,
|
||
"perturbation": {
|
||
"parameterId": parameter_id,
|
||
"lineId": line_id,
|
||
"bucketDate": bucket_date,
|
||
"increment": increment,
|
||
},
|
||
})
|
||
continue
|
||
increment = int(normalized_increments[parameter_id])
|
||
base_row = {
|
||
"parameterId": parameter_id, "parameterName": _LABELS[parameter_id],
|
||
"increment": increment, "parameterUnit": _UNITS[parameter_id],
|
||
"isDualValue": False, "isUnitMarginalValue": False,
|
||
"isFiniteDifference": True, "nonAdditiveAcrossParameters": True,
|
||
**_cost_fields(parameter_id, increment, normalized_rates, currency),
|
||
}
|
||
if parameter_id == "C8_due_date_allowance":
|
||
variants.append({
|
||
**base_row, "rhsInstanceId": "fixed-entries:due-date-allowance",
|
||
"perturbation": {"parameterId": parameter_id, "increment": increment},
|
||
})
|
||
continue
|
||
constraint_id = "C12_team" if parameter_id == "C12_team_capacity" else "C12_tooling"
|
||
kind = "team" if parameter_id == "C12_team_capacity" else "tooling"
|
||
resources = sorted(
|
||
(
|
||
resource for resource in cumulative_resources
|
||
if isinstance(resource, Mapping)
|
||
and resource.get("kind") == kind
|
||
and int(resource.get("intervalCount") or 0) >= 2
|
||
),
|
||
key=lambda resource: int(resource["id"]),
|
||
) if constraint_id in active_constraints else []
|
||
if not resources:
|
||
inactive_cost = {}
|
||
if base_row["costStatus"] == "configured":
|
||
inactive_cost = {
|
||
"costStatus": "configured_inactive",
|
||
"estimatedCost": None,
|
||
}
|
||
inactive_rows.append({
|
||
**base_row, "status": "inactive", "resourceKind": kind,
|
||
"resourceId": None, "resourceCode": None,
|
||
"reason": "该容量参数在本次 CP 模型中没有可增量的资源实例。",
|
||
"objectiveImprovement": None,
|
||
**inactive_cost,
|
||
})
|
||
continue
|
||
for resource in resources:
|
||
resource_id = int(resource["id"])
|
||
variants.append({
|
||
**base_row, "resourceKind": kind, "resourceId": resource_id,
|
||
"resourceCode": resource.get("code"),
|
||
"rhsInstanceId": f"{kind}:{resource_id}",
|
||
"perturbation": {
|
||
"parameterId": parameter_id, "resourceId": resource_id,
|
||
"increment": increment,
|
||
},
|
||
})
|
||
|
||
rows = list(inactive_rows)
|
||
evaluations = 1
|
||
monotonicity_violation = False
|
||
for variant in variants:
|
||
perturbation = variant.pop("perturbation")
|
||
if cancel_check is not None:
|
||
cancel_check()
|
||
evaluations += 1
|
||
try:
|
||
_, perturbed_meta = run_cp_rhs_diagnostic(
|
||
sandbox, entries, params,
|
||
pipeline_label=(
|
||
f"CP-RHS-{perturbation['parameterId']}-"
|
||
f"{perturbation.get('resourceId', perturbation.get('lineId', 'ALL'))}"
|
||
),
|
||
rhs_perturbation=perturbation,
|
||
)
|
||
except SolverProcessError as exc:
|
||
rows.append({
|
||
**variant, "status": "solver_error", "reason": exc.code,
|
||
"error": exc.as_dict(), "objectiveImprovement": None,
|
||
})
|
||
continue
|
||
if cancel_check is not None:
|
||
cancel_check()
|
||
perturbed = _solver_snapshot(perturbed_meta)
|
||
rhs_pair = _validate_state_delta(baseline, perturbed, perturbation)
|
||
if _model_identity(perturbed) != _model_identity(baseline) or rhs_pair is None:
|
||
rows.append({
|
||
**variant, "status": "solver_error",
|
||
"reason": "基线与增量重解的模型拓扑或 RHS 差值不一致",
|
||
"perturbed": perturbed, "objectiveImprovement": None,
|
||
})
|
||
continue
|
||
baseline_rhs, perturbed_rhs = rhs_pair
|
||
variant = {**variant, "baselineRhs": baseline_rhs, "perturbedRhs": perturbed_rhs}
|
||
perturbed_status = str(perturbed.get("status") or "")
|
||
perturbed_objective = perturbed.get("objective")
|
||
if baseline_status == "INFEASIBLE":
|
||
if perturbed_status in {"OPTIMAL", "FEASIBLE"}:
|
||
if variant["parameterId"] == "C8_due_date_allowance":
|
||
monotonicity_violation = True
|
||
rows.append({
|
||
**variant, "status": "monotonicity_violation",
|
||
"reason": "C8 仅改变延期目标 RHS,却改变了可行性,报告失败关闭。",
|
||
"perturbed": perturbed, "objectiveImprovement": None,
|
||
})
|
||
continue
|
||
rows.append({
|
||
**variant, "status": "restores_feasibility",
|
||
"comparisonQuality": (
|
||
"exact-optimal" if perturbed_status == "OPTIMAL" else "feasible-only"
|
||
),
|
||
"perturbed": perturbed, "objectiveImprovement": None,
|
||
"interpretation": "基线不可行,单个 RHS 增量后恢复可行;不计算目标差。",
|
||
})
|
||
elif perturbed_status == "INFEASIBLE":
|
||
rows.append({
|
||
**variant, "status": "does_not_restore_feasibility",
|
||
"reason": "基线和 RHS 增量后均不可行。",
|
||
"perturbed": perturbed, "objectiveImprovement": None,
|
||
})
|
||
else:
|
||
rows.append({
|
||
**variant,
|
||
"status": "solver_error" if perturbed_status == "MODEL_INVALID" else "unavailable",
|
||
"reason": f"RHS 增量后状态 {perturbed_status or 'UNKNOWN'}",
|
||
"perturbed": perturbed, "objectiveImprovement": None,
|
||
})
|
||
continue
|
||
if perturbed_status == "INFEASIBLE":
|
||
monotonicity_violation = True
|
||
rows.append({
|
||
**variant, "status": "monotonicity_violation",
|
||
"reason": "放宽 RHS 后反而不可行,违反单调性,整份报告失败关闭。",
|
||
"perturbed": perturbed, "objectiveImprovement": None,
|
||
})
|
||
continue
|
||
if perturbed_status == "MODEL_INVALID":
|
||
rows.append({
|
||
**variant, "status": "solver_error", "reason": "RHS 增量后 CP 模型无效。",
|
||
"perturbed": perturbed, "objectiveImprovement": None,
|
||
})
|
||
continue
|
||
if perturbed_status not in {"OPTIMAL", "FEASIBLE"} or not isinstance(
|
||
perturbed_objective, (int, float)
|
||
):
|
||
rows.append({
|
||
**variant, "status": "unavailable",
|
||
"reason": f"RHS 增量后求解状态 {perturbed_status or 'UNKNOWN'}",
|
||
"perturbed": perturbed, "objectiveImprovement": None,
|
||
})
|
||
continue
|
||
exact = baseline_status == "OPTIMAL" and perturbed_status == "OPTIMAL"
|
||
difference = float(baseline_objective) - float(perturbed_objective)
|
||
increment = int(variant["increment"])
|
||
if exact:
|
||
if difference < -1e-6:
|
||
monotonicity_violation = True
|
||
rows.append({
|
||
**variant, "status": "monotonicity_violation",
|
||
"reason": "OPTIMAL 结果显示放宽 RHS 后目标恶化,违反单调性。",
|
||
"perturbed": perturbed, "objectiveImprovement": None,
|
||
})
|
||
continue
|
||
rows.append({
|
||
**variant, "status": "available", "comparisonQuality": "exact-optimal",
|
||
"isExactCounterfactual": True,
|
||
"baselineObjective": float(baseline_objective),
|
||
"perturbedObjective": float(perturbed_objective),
|
||
"objectiveImprovement": round(difference, 6),
|
||
"objectiveImprovementPerIncrementUnit": round(difference / increment, 6),
|
||
"improvementLowerBound": round(difference, 6),
|
||
"improvementUpperBound": round(difference, 6),
|
||
"perturbed": perturbed,
|
||
"interpretation": "单个正向 RHS 增量的 one-at-a-time 有限差分;不是对偶或货币净收益。",
|
||
})
|
||
continue
|
||
baseline_bound = float(baseline["bestBound"])
|
||
perturbed_bound = float(perturbed["bestBound"])
|
||
lower = max(0.0, baseline_bound - float(perturbed_objective))
|
||
upper = float(baseline_objective) - perturbed_bound
|
||
if upper < -1e-6:
|
||
monotonicity_violation = True
|
||
rows.append({
|
||
**variant, "status": "monotonicity_violation",
|
||
"reason": "FEASIBLE 界限显示放宽 RHS 可能恶化最优目标,报告失败关闭。",
|
||
"perturbed": perturbed, "objectiveImprovement": None,
|
||
})
|
||
continue
|
||
if lower > upper + 1e-6:
|
||
rows.append({
|
||
**variant, "status": "solver_error",
|
||
"reason": "FEASIBLE 改善区间上下界矛盾,未输出数值。",
|
||
"perturbed": perturbed, "objectiveImprovement": None,
|
||
})
|
||
continue
|
||
rows.append({
|
||
**variant, "status": "bounded", "comparisonQuality": "bound-interval",
|
||
"isExactCounterfactual": False, "objectiveImprovement": None,
|
||
"improvementLowerBound": round(lower, 6),
|
||
"improvementUpperBound": round(max(0.0, upper), 6),
|
||
"improvementLowerBoundPerIncrementUnit": round(lower / increment, 6),
|
||
"improvementUpperBoundPerIncrementUnit": round(max(0.0, upper) / increment, 6),
|
||
"perturbed": perturbed,
|
||
"interpretation": "至少一侧仅 FEASIBLE,只报告由 incumbent/bound 推导的改善区间。",
|
||
})
|
||
|
||
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", "objectiveImprovementPerIncrementUnit",
|
||
"improvementLowerBound", "improvementUpperBound",
|
||
"improvementLowerBoundPerIncrementUnit",
|
||
"improvementUpperBoundPerIncrementUnit",
|
||
):
|
||
row[field] = None
|
||
rows.sort(key=lambda row: (
|
||
0 if row["status"] == "available" else 1,
|
||
-(row.get("objectiveImprovement") or 0.0),
|
||
row["parameterId"], row.get("resourceId") or 0,
|
||
row.get("lineId") or 0, row.get("bucketDate") or "",
|
||
))
|
||
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": (
|
||
"RHS 报告因单调性异常失败关闭,所有改善值已抑制。"
|
||
if monotonicity_violation
|
||
else f"完成 {len(variants)} 个正向 RHS one-at-a-time 重解;成本仅在显式费率下估算。"
|
||
),
|
||
}
|
||
|
||
|
||
__all__ = ["run_cp_rhs_resolve"]
|