aps-agent/server/aps_domain/scenario_selection.py

190 lines
7.3 KiB
Python
Raw Permalink Normal View History

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