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