aps-agent/tests/golden/test_nsga2_engine.py

339 lines
13 KiB
Python
Raw Permalink 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.

# ============================================================
# 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