aps-agent/server/aps_domain/cp_rhs.py

771 lines
35 KiB
Python
Raw Normal View History

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"]