aps-agent/tests/golden/test_nsga2_engine.py

339 lines
13 KiB
Python
Raw Normal View History

# ============================================================
# NSGA-II 多目标求解器黄金测试(矩阵 85,round-40 方向 U)
# 覆盖:非支配排序/拥挤度/交叉变异可行性/确定性/Pareto 解集性质/
# 参数模板 manifest/rank_scenarios 消费/大算例 GA 基线边界与性能
# ============================================================
from __future__ import annotations
import math
from random import Random
import pytest
from server.agent_core.algolib import AlgorithmManifest
from server.aps_domain.scenario_selection import (
nsga2_solutions_to_cards,
rank_scenarios,
)
from server.engines import NSGA2Engine, get_engine
from server.engines.base import EngineParams
from server.engines.ga_engine import optimize_genetic_assignment
from server.engines.nsga2_engine import (
_base_individual,
_build_jobs,
_order_crossover,
_partially_mapped_crossover,
_resource_migration,
_swap_mutation,
build_nsga2_manifest,
crowding_distance,
fast_non_dominated_sort,
nsga2_defaults,
solve_nsga2,
)
from server.engines.rule_engine import RuleEngine
from server.state.seed import seed_world
from server.timeutil import add_minutes, fmt_date, today0
def _params(engine_type: str = "NSGA2", time_limit: float = 5.0) -> EngineParams:
return EngineParams(
engineType=engine_type,
strategyTemplate="COMPREHENSIVE",
planningHorizonDays=14,
startDate=fmt_date(add_minutes(today0(), 24 * 60)),
timeLimitSeconds=time_limit,
)
def _world_entries(world=None):
world = world if world is not None else seed_world()
entries, _, _ = RuleEngine().collect_and_order(world, _params("RULE"))
return world, entries
def _obj_tuple(solution: dict) -> tuple[float, int, float]:
objs = solution["objectives"]
return (float(objs["totalTardiness"]), int(objs["conflictCount"]), -float(objs["loadBalance"]))
def _dominates_left(left: dict, right: dict) -> bool:
a, b = _obj_tuple(left), _obj_tuple(right)
return all(av <= bv for av, bv in zip(a, b)) and any(av < bv for av, bv in zip(a, b))
def _assert_feasible_sequence(sequence, jobs) -> None:
counts = [0] * len(jobs)
for job_id in sequence:
counts[job_id] += 1
assert counts == [job.op_count for job in jobs]
# 同单工序沿工艺路线保序:出现次数即路由序(occurrence 隐含),染色体恒为合法工序序列
def _random_feasible_sequence(jobs, rng):
order = list(range(len(jobs)))
rng.shuffle(order)
return tuple(j for j in order for _ in range(jobs[j].op_count))
# ---------------- 非支配排序 / 拥挤度 ----------------
def test_fast_non_dominated_sort_returns_expected_fronts():
objectives = [
(1.0, 1.0, 0.0), # 0: 与 1/3 互不支配
(2.0, 0.0, 0.0), # 1: 与 0/3 互不支配
(3.0, 3.0, 0.0), # 2: 被 0 支配
(0.0, 2.0, 0.0), # 3: 与 0/1 互不支配
(2.0, 2.0, 0.0), # 4: 被 0/1/3 支配
]
fronts = fast_non_dominated_sort(objectives)
assert [sorted(f) for f in fronts] == [[0, 1, 3], [4], [2]]
assert fast_non_dominated_sort([]) == [[]]
def test_crowding_distance_boundary_infinite_and_interior_sum():
objectives = [
(0.0, 4.0, 0.0), # A 边界(obj1 最小)
(1.0, 2.0, 0.0), # B
(2.0, 1.0, 0.0), # C
(4.0, 0.0, 0.0), # D 边界(obj1 最大)
]
dist = crowding_distance(objectives, [0, 1, 2, 3])
assert math.isinf(dist[0]) and math.isinf(dist[3])
assert dist[1] == pytest.approx(1.25)
assert dist[2] == pytest.approx(1.25)
# ---------------- 交叉 / 变异可行性 ----------------
def test_order_crossover_and_pmx_preserve_operation_feasibility():
world, entries = _world_entries()
jobs = _build_jobs(world, entries, _params())
rng = Random(7)
for _ in range(120):
a = _random_feasible_sequence(jobs, rng)
b = _random_feasible_sequence(jobs, rng)
_assert_feasible_sequence(_order_crossover(a, b, rng), jobs)
_assert_feasible_sequence(_partially_mapped_crossover(a, b, rng), jobs)
mutated = _swap_mutation(a, rng)
_assert_feasible_sequence(mutated, jobs)
assert len(mutated) == len(a)
def test_pmx_child_preserves_job_occurrence_counts():
a = (0, 0, 1, 2, 1, 2)
b = (2, 1, 0, 2, 1, 0)
rng = Random(3)
child = _partially_mapped_crossover(a, b, rng)
assert sorted(child) == sorted(a)
assert [child.count(0), child.count(1), child.count(2)] == [2, 2, 2]
def test_resource_migration_switches_to_valid_alternate_line():
world, entries = _world_entries()
jobs = _build_jobs(world, entries, _params())
base = _base_individual(jobs)
assert any(len(job.line_options) > 1 for job in jobs), "演示算例应存在多产线订单"
seen_switch = False
for trial in range(300):
rng = Random(1000 + trial)
migrated = _resource_migration(base.lines, jobs, rng)
assert len(migrated) == len(base.lines)
for idx, line in enumerate(migrated):
assert line in jobs[idx].line_options
if migrated != base.lines:
seen_switch = True
assert seen_switch
# ---------------- 参数模板 ----------------
def test_nsga2_defaults_template_and_manifest_registration():
for key in (
"populationSize", "generations", "crossoverRate", "mutationRate",
"lineMutationRate", "crossover", "tournamentSize", "seed",
):
assert key in nsga2_defaults
assert nsga2_defaults["crossover"] in ("OX", "PMX")
assert 0.0 <= nsga2_defaults["crossoverRate"] <= 1.0
assert 0.0 <= nsga2_defaults["mutationRate"] <= 1.0
assert isinstance(nsga2_defaults["seed"], int)
manifest = build_nsga2_manifest()
model = AlgorithmManifest(**manifest)
assert model.validate_manifest() == []
assert manifest["parameters"] == nsga2_defaults
assert model.entrypoint == "server.engines.nsga2_engine:solve_nsga2"
assert model.deterministic is True
assert model.random_seed == nsga2_defaults["seed"]
assert model.category == "C"
assert "tests/golden/test_nsga2_engine.py" in model.golden_tests
# ---------------- 求解器:确定性 / Pareto 性质 ----------------
def test_solve_nsga2_deterministic_and_pareto_properties():
params = _params(time_limit=8.0)
outputs = []
for _ in range(2):
world, entries = _world_entries()
pareto, meta = solve_nsga2(world, params, entries=entries, seed=42)
outputs.append((pareto, meta))
(pareto_a, meta_a), (pareto_b, meta_b) = outputs
signature = lambda out: [
(s["objectives"], s["jobOrder"], s["lines"]) for s in out
]
assert signature(pareto_a) == signature(pareto_b)
assert meta_a["generations"] == meta_b["generations"]
assert meta_a["generations"] == meta_a["generationBudget"]
assert meta_a["backend"] == "NSGA-II"
assert meta_a["paretoSize"] == len(pareto_a)
assert len(pareto_a) >= 2, "Pareto 前沿应非空且有多解(多目标)"
for i, left in enumerate(pareto_a):
for j, right in enumerate(pareto_a):
if i != j:
assert not _dominates_left(left, right), "Pareto 前沿内不应互相支配"
kpi = left["kpi"]
assert kpi["totalTardiness"] >= 0
assert kpi["conflictCount"] >= 0
assert 0.0 <= kpi["loadBalance"] <= 1.0
assert 0.0 <= kpi["avgUtilization"] <= 1.0
assert left["hardFeasible"] is True
assert sorted(left["jobOrder"]) == list(range(len(entries)))
base = meta_a["baselineObjectives"]
assert base["conflictCount"] >= 0 and base["loadBalance"] >= 0
def test_solve_nsga2_time_budget_deterministic_and_bounded():
params = _params(time_limit=0.05)
outputs = []
for _ in range(2):
world, entries = _world_entries()
pareto, meta = solve_nsga2(world, params, entries=entries, seed=1)
outputs.append((pareto, meta))
(pareto_a, meta_a), (pareto_b, meta_b) = outputs
assert meta_a["generations"] == meta_b["generations"]
assert 1 <= meta_a["generations"] <= 5
assert meta_a["generationBudget"] == 5
assert [s["objectives"] for s in pareto_a] == [s["objectives"] for s in pareto_b]
# ---------------- scenario_selection 衔接 ----------------
def test_rank_scenarios_consumes_nsga2_pareto_candidates():
world, entries = _world_entries()
pareto, _ = solve_nsga2(world, _params(), entries=entries, seed=7)
cards = nsga2_solutions_to_cards(pareto)
assert len(cards) == len(pareto)
ranked = rank_scenarios(cards)
assert len(ranked) == len(cards)
assert all("normalizedKpi" in card and "weightedScore" in card for card in ranked)
assert any(card["isPareto"] and card["selectionStatus"] == "PARETO" for card in ranked)
recommended = [card for card in ranked if card["isRecommended"]]
assert len(recommended) == 1
assert recommended[0]["rank"] == 1
assert recommended[0]["isPareto"] is True
assert recommended[0]["source"]["algo"] == "NSGA-II"
# 混入被支配候选:rank_scenarios 应如实标为 DOMINATED(消费路径生效)
dominated = {
"scenarioId": "junk",
"strategy": "JUNK",
"hardFeasible": True,
"robustness": 0.1,
"kpi": {
"totalTardiness": 1e12,
"conflictCount": 10 ** 9,
"totalCost": 1e12,
"avgUtilization": 0.0,
"loadBalance": 0.0,
"totalChangeoverMin": 0.0,
},
}
mixed = rank_scenarios([*cards, dominated])
assert next(card for card in mixed if card["strategy"] == "JUNK")["selectionStatus"] == "DOMINATED"
# ---------------- 大算例质量/性能基线 ----------------
def test_medium_instance_pareto_not_worse_than_single_objective_ga_baseline():
world, entries = _world_entries()
big_entries = [dict(entry) for _ in range(5) for entry in entries] # 35 订单(30-50 区间)
assert len(big_entries) >= 30
params = _params(time_limit=30.0)
# 单目标 GA 基线(确定性种子 42)
ga_ordered, ga_meta = optimize_genetic_assignment(world, big_entries, params)
used: set[int] = set()
ga_order: list[int] = []
ga_lines: list[int] = []
for target in ga_ordered:
probe = {k: v for k, v in target.items() if k != "forcedLineId"}
for idx, src in enumerate(big_entries):
if idx not in used and src == probe:
used.add(idx)
ga_order.append(idx)
forced = target.get("forcedLineId")
ga_lines.append(int(forced) if forced is not None and int(forced) >= 0 else -1)
break
else:
raise AssertionError("GA 基线回映射失败")
assert sorted(ga_order) == list(range(len(big_entries)))
pareto, meta = solve_nsga2(
world, params, entries=big_entries, seed=42,
seed_solutions=[(tuple(ga_order), tuple(ga_lines))],
nsga2_params={"populationSize": 40, "generations": 50},
)
assert len(pareto) >= 1
# 性能上限:固定代数内完成(不依赖超时中断)
assert meta["generations"] == meta["generationBudget"] == 50
assert meta["wallTimeSec"] < 15.0
# 解集性质:Pareto 前沿互不支配
for i, left in enumerate(pareto):
for j, right in enumerate(pareto):
if i != j:
assert not _dominates_left(left, right)
# 边界约束:存在前沿成员在 总延迟 与 冲突 两个目标上均不劣于 GA 基线
seed_obj = meta["seedObjectives"][0]
assert seed_obj["conflictCount"] >= 0
assert any(
s["kpi"]["totalTardiness"] <= seed_obj["totalTardiness"]
and s["kpi"]["conflictCount"] <= seed_obj["conflictCount"]
for s in pareto
), "Pareto 前沿应存在不劣于单目标 GA 基线的成员(精英保留保证)"
assert ga_meta["objective"] >= 0
# ---------------- 引擎集成 ----------------
def test_get_engine_nsga2_is_real_engine_and_materializes_schedule():
engine = get_engine("NSGA2")
assert isinstance(engine, NSGA2Engine)
assert engine.name == "NSGA2"
assert engine.supports_anytime is True
def _next_id_factory():
counters: dict[str, int] = {}
def next_id(kind: str) -> int:
counters[kind] = counters.get(kind, 0) + 1
return counters[kind]
return next_id
worlds = [seed_world(), seed_world()]
results = []
metas = []
for world in worlds:
result = engine.solve(world, _params(time_limit=6.0), _next_id_factory())
meta = world["scheduleVersions"][0]["solverMeta"]
results.append(result)
metas.append(meta)
assert results[0].solveStatus == "FEASIBLE"
assert results[0].poCount == 7
assert results[0].woCount == 30
assert len(worlds[0]["productionOrders"]) == 7
# 确定性种子支持:两次求解元信息一致
assert metas[0]["backend"] == "NSGA-II"
assert metas[0]["engineType"] == "NSGA2"
assert metas[0]["paretoSize"] == metas[1]["paretoSize"]
assert metas[0]["generations"] == metas[1]["generations"]
assert results[0].totalTardiness == results[1].totalTardiness