aps-agent/server/aps_domain/scenario_selection.py

190 lines
7.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

from __future__ import annotations
from collections.abc import Iterable
from copy import deepcopy
from dataclasses import dataclass
from math import isfinite
from typing import Any
@dataclass(frozen=True)
class MetricSpec:
key: str
direction: str
default_weight: float = 0.0
PARETO_METRICS: tuple[MetricSpec, ...] = (
MetricSpec("totalTardiness", "min", 0.40),
MetricSpec("conflictCount", "min", 0.0),
MetricSpec("totalCost", "min", 0.30),
MetricSpec("avgUtilization", "max", 0.20),
MetricSpec("loadBalance", "max", 0.10),
MetricSpec("totalChangeoverMin", "min", 0.0),
)
def _number(value: Any) -> float:
try:
number = float(value)
except (TypeError, ValueError):
return 0.0
return number if isfinite(number) else 0.0
def load_balance_score(loads: Iterable[float]) -> float:
"""Return a bounded 0..1 balance score based on the coefficient of variation."""
values = [max(0.0, _number(value)) for value in loads]
if not values or max(values, default=0.0) <= 1e-12:
return 1.0
mean = sum(values) / len(values)
variance = sum((value - mean) ** 2 for value in values) / len(values)
coefficient = variance ** 0.5 / mean if mean > 1e-12 else 0.0
return round(1.0 / (1.0 + coefficient), 6)
def _dominates(left: dict[str, Any], right: dict[str, Any], metrics: tuple[MetricSpec, ...]) -> bool:
no_worse = True
strictly_better = False
for metric in metrics:
lval = _number((left.get("kpi") or {}).get(metric.key))
rval = _number((right.get("kpi") or {}).get(metric.key))
if metric.direction == "max":
no_worse = no_worse and lval >= rval
strictly_better = strictly_better or lval > rval
else:
no_worse = no_worse and lval <= rval
strictly_better = strictly_better or lval < rval
if not no_worse:
return False
return strictly_better
def pareto_front_indices(
cards: list[dict[str, Any]],
*,
metrics: tuple[MetricSpec, ...] = PARETO_METRICS,
eligible_indices: Iterable[int] | None = None,
) -> set[int]:
candidates = list(eligible_indices if eligible_indices is not None else range(len(cards)))
front: set[int] = set()
for idx in candidates:
if not any(
other != idx and _dominates(cards[other], cards[idx], metrics)
for other in candidates
):
front.add(idx)
return front
def _normalized_values(
cards: list[dict[str, Any]],
indices: list[int],
metrics: tuple[MetricSpec, ...],
) -> dict[int, dict[str, float]]:
normalized = {idx: {} for idx in indices}
for metric in metrics:
values = [_number((cards[idx].get("kpi") or {}).get(metric.key)) for idx in indices]
low, high = min(values), max(values)
span = high - low
for idx, value in zip(indices, values):
if span <= 1e-12:
score = 1.0
elif metric.direction == "max":
score = (value - low) / span
else:
score = (high - value) / span
normalized[idx][metric.key] = round(score, 6)
return normalized
def rank_scenarios(
cards: list[dict[str, Any]],
*,
weights: dict[str, float] | None = None,
metrics: tuple[MetricSpec, ...] = PARETO_METRICS,
) -> list[dict[str, Any]]:
"""Hard-filter, Pareto-filter, then rank the front using normalized weighted KPIs."""
ranked = deepcopy(cards)
if not ranked:
return ranked
hard_eligible = [idx for idx, card in enumerate(ranked) if card.get("hardFeasible", True)]
front = pareto_front_indices(ranked, metrics=metrics, eligible_indices=hard_eligible)
normalized = _normalized_values(ranked, sorted(front), metrics) if front else {}
requested = weights or {metric.key: metric.default_weight for metric in metrics}
active = {
metric.key: max(0.0, _number(requested.get(metric.key, metric.default_weight)))
for metric in metrics
}
total_weight = sum(active.values())
if total_weight <= 1e-12:
active = {metric.key: metric.default_weight for metric in metrics}
total_weight = sum(active.values())
active = {key: round(value / total_weight, 6) for key, value in active.items()}
front_scores: list[tuple[float, float, str, int]] = []
for idx, card in enumerate(ranked):
card["normalizedKpi"] = normalized.get(idx, {})
card["rankingWeights"] = active
card["isPareto"] = idx in front
card["isRecommended"] = False
card["rank"] = None
card["weightedScore"] = None
if idx not in hard_eligible:
card["selectionStatus"] = "HARD_REJECTED"
elif idx not in front:
card["selectionStatus"] = "DOMINATED"
else:
score = sum(
active.get(metric.key, 0.0) * normalized[idx].get(metric.key, 0.0)
for metric in metrics
)
card["weightedScore"] = round(score, 6)
card["selectionStatus"] = "PARETO"
tie_key = str(card.get("strategy") or card.get("scenarioId") or idx)
robustness = max(0.0, min(1.0, _number(card.get("robustness"))))
front_scores.append((-score, -robustness, tie_key, idx))
front_scores.sort()
for rank, (_, _, _, idx) in enumerate(front_scores, start=1):
ranked[idx]["rank"] = rank
if front_scores:
ranked[front_scores[0][3]]["isRecommended"] = True
return ranked
# ---------------- NSGA-II 鍊欓€夊崱閫傞厤锛堢煩闃?85锛宺ound-40 鏂瑰悜 U锛?----------------
def nsga2_solutions_to_cards(
solutions: list[dict[str, Any]],
*,
prefix: str = "NSGA2",
) -> list[dict[str, Any]]:
"""鎶?NSGA-II 姹傝В鍣紙engines.nsga2_engine.solve_nsga2锛変骇鍑虹殑 Pareto 瑙? 閫傞厤涓?rank_scenarios 鍙秷璐圭殑鍊欓€夊崱銆?
瑙e崱瀛楁濂戠害锛歴olution 鑷冲皯鍚?strategy/hardFeasible/robustness/kpi锛? kpi 閿笌 PARETO_METRICS 瀵归綈锛坱otalTardiness/conflictCount/totalCost/
avgUtilization/loadBalance/totalChangeoverMin锛夛紱jobOrder/lines 淇濈暀鍦? source 渚涚墿鍖栧洖鏀撅紙濡?NSGA2Engine 鐨勬帹鑽愯В閫夋嫨锛夈€? """
cards: list[dict[str, Any]] = []
for idx, solution in enumerate(solutions):
kpi = solution.get("kpi") or {}
cards.append({
"scenarioId": f"{prefix.lower()}-{idx:02d}",
"strategy": solution.get("strategy") or f"{prefix}-{idx:02d}",
"hardFeasible": bool(solution.get("hardFeasible", True)),
"robustness": float(solution.get("robustness") or 0.5),
"kpi": {
"totalTardiness": kpi.get("totalTardiness", 0.0),
"conflictCount": kpi.get("conflictCount", 0),
"totalCost": kpi.get("totalCost", 0.0),
"avgUtilization": kpi.get("avgUtilization", 0.0),
"loadBalance": kpi.get("loadBalance", 0.0),
"totalChangeoverMin": kpi.get("totalChangeoverMin", 0.0),
},
"source": {
"algo": "NSGA-II",
"solutionId": solution.get("solutionId"),
"order": solution.get("jobOrder") or [],
"lines": solution.get("lines") or [],
},
})
return cards