"""差分进化优化器端到端测试。""" import asyncio import math import httpx from ..mock_checkers import MockCollisionChecker from ..models import Constraint, Device, Lab, Placement import numpy as np import pytest from ..optimizer import ( _angle_sweep_once, _circular_diff, _compute_mutant, _run_de, optimize, snap_theta, ) def _is_on_angle_lattice(theta: float, granularity: int) -> bool: """检查 theta 是否落在离散角度格点上。""" step = 2 * math.pi / granularity theta_mod = theta % (2 * math.pi) quotient = theta_mod / step return abs(quotient - round(quotient)) < 1e-6 PCR_DEVICES = [ {"id": "thermo_orbitor_rs2_hotel", "name": "Plate Hotel", "device_type": "static"}, {"id": "arm_slider", "name": "Robot Arm", "device_type": "articulation"}, {"id": "opentrons_liquid_handler", "name": "Liquid Handler", "device_type": "static"}, {"id": "agilent_plateloc", "name": "Plate Sealer", "device_type": "static"}, {"id": "inheco_odtc_96xl", "name": "Thermal Cycler", "device_type": "static"}, ] PCR_LAB = {"width": 6.0, "depth": 4.0} def _post_app(path: str, payload: dict) -> httpx.Response: """通过 ASGITransport 调用应用,避免 TestClient 在沙箱中卡住。""" from ..server import app async def _run() -> httpx.Response: transport = httpx.ASGITransport(app=app) async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: return await client.post(path, json=payload) return asyncio.run(_run()) def test_optimize_three_devices_no_collision(): """3 台设备在 5m×5m 实验室中优化,结果应无碰撞且在边界内。""" devices = [ Device(id="a", name="A", bbox=(0.8, 0.6)), Device(id="b", name="B", bbox=(0.6, 0.5)), Device(id="c", name="C", bbox=(0.5, 0.5)), ] lab = Lab(width=5.0, depth=5.0) placements = optimize(devices, lab, seed=42, maxiter=100, popsize=10) assert len(placements) == 3 # 验证无碰撞 checker = MockCollisionChecker() checker_placements = [ {"id": p.device_id, "bbox": next(d.bbox for d in devices if d.id == p.device_id), "pos": (p.x, p.y, p.theta)} for p in placements ] collisions = checker.check(checker_placements) assert collisions == [], f"Unexpected collisions: {collisions}" # 验证在边界内 oob = checker.check_bounds(checker_placements, lab.width, lab.depth) assert oob == [], f"Devices out of bounds: {oob}" def test_optimize_single_device(): """单个设备应直接放置成功。""" devices = [Device(id="solo", name="Solo", bbox=(0.5, 0.5))] lab = Lab(width=3.0, depth=3.0) placements = optimize(devices, lab, seed=42, maxiter=50) assert len(placements) == 1 p = placements[0] assert 0.25 <= p.x <= 2.75 assert 0.25 <= p.y <= 2.75 def test_optimize_tight_space(): """紧凑空间:2 台设备在刚好够大的实验室中。""" devices = [ Device(id="x", name="X", bbox=(1.0, 1.0)), Device(id="y", name="Y", bbox=(1.0, 1.0)), ] # 2.5m 宽足够放 2 个 1m 宽设备(加间距) lab = Lab(width=2.5, depth=2.0) placements = optimize(devices, lab, seed=42, maxiter=100) assert len(placements) == 2 checker = MockCollisionChecker() checker_placements = [ {"id": p.device_id, "bbox": next(d.bbox for d in devices if d.id == p.device_id), "pos": (p.x, p.y, p.theta)} for p in placements ] collisions = checker.check(checker_placements) assert collisions == [] def test_optimize_returns_valid_placement_ids(): """验证返回的 placement device_id 与输入设备一致。""" devices = [ Device(id="dev_1", name="D1", bbox=(0.5, 0.5)), Device(id="dev_2", name="D2", bbox=(0.5, 0.5)), ] lab = Lab(width=5.0, depth=5.0) placements = optimize(devices, lab, seed=42, maxiter=50) result_ids = {p.device_id for p in placements} expected_ids = {d.id for d in devices} assert result_ids == expected_ids def test_snap_theta_near_cardinal(): """Theta within 15° of 90° snaps to 90°.""" placements = [Placement(device_id="a", x=1, y=1, theta=math.radians(85))] result = snap_theta(placements, threshold_deg=15) assert result[0].theta == pytest.approx(math.pi / 2) def test_snap_theta_far_from_cardinal(): """Theta 30° away from nearest cardinal: no snap.""" placements = [Placement(device_id="a", x=1, y=1, theta=math.radians(60))] result = snap_theta(placements, threshold_deg=15) assert result[0].theta == pytest.approx(math.radians(60)) def test_snap_theta_at_cardinal(): """Already at cardinal: unchanged.""" placements = [Placement(device_id="a", x=1, y=1, theta=math.pi)] result = snap_theta(placements, threshold_deg=15) assert result[0].theta == pytest.approx(math.pi) def test_snap_theta_near_360(): """Theta near 360° (=0°) snaps correctly.""" placements = [Placement(device_id="a", x=1, y=1, theta=math.radians(355))] result = snap_theta(placements, threshold_deg=15) snapped = result[0].theta % (2 * math.pi) assert snapped == pytest.approx(0.0, abs=0.01) or snapped == pytest.approx(2 * math.pi, abs=0.01) def test_optimize_endpoint_accepts_seeder_field(): """POST /optimize should accept seeder and run_de fields.""" from fastapi.testclient import TestClient from ..server import app client = TestClient(app) resp = client.post("/optimize", json={ "devices": [{"id": "test_device", "name": "Test"}], "lab": {"width": 5, "depth": 4}, "seeder": "compact_outward", "run_de": False, }) assert resp.status_code == 200 data = resp.json() assert data["seeder_used"] == "compact_outward" assert data["de_ran"] is False assert len(data["placements"]) == 1 def test_optimize_endpoint_unknown_seeder_returns_400(): """Unknown seeder preset should return 400.""" from fastapi.testclient import TestClient from ..server import app client = TestClient(app) resp = client.post("/optimize", json={ "devices": [{"id": "test_device", "name": "Test"}], "lab": {"width": 5, "depth": 4}, "seeder": "nonexistent_preset", "run_de": False, }) assert resp.status_code == 400 def test_optimize_endpoint_invalid_angle_granularity_returns_400(): """Unsupported angle_granularity should be rejected.""" resp = _post_app("/optimize", { "devices": [{"id": "test_device", "name": "Test"}], "lab": {"width": 5, "depth": 4}, "run_de": True, "angle_granularity": 6, }) assert resp.status_code == 400 assert "angle_granularity" in resp.json()["detail"] def test_optimize_endpoint_backward_compatible(): """Existing calls without seeder/run_de fields still work.""" from fastapi.testclient import TestClient from ..server import app client = TestClient(app) resp = client.post("/optimize", json={ "devices": [{"id": "test_device", "name": "Test"}], "lab": {"width": 5, "depth": 4}, }) assert resp.status_code == 200 data = resp.json() assert "seeder_used" in data assert "de_ran" in data def test_optimize_none_angle_granularity_matches_default(): """Explicit None should keep the continuous-theta path unchanged.""" devices = [ Device(id="a", name="A", bbox=(0.8, 0.6)), Device(id="b", name="B", bbox=(0.6, 0.5)), Device(id="c", name="C", bbox=(0.5, 0.5)), ] lab = Lab(width=5.0, depth=5.0) seed_placements = [ Placement(device_id="a", x=1.0, y=1.0, theta=0.13), Placement(device_id="b", x=2.2, y=1.5, theta=1.21), Placement(device_id="c", x=3.4, y=3.2, theta=2.42), ] baseline = optimize( devices, lab, seed_placements=seed_placements, seed=42, maxiter=40, popsize=8, ) explicit_none = optimize( devices, lab, seed_placements=seed_placements, seed=42, maxiter=40, popsize=8, angle_granularity=None, ) assert len(baseline) == len(explicit_none) for p_base, p_none in zip(baseline, explicit_none): assert p_base.device_id == p_none.device_id assert p_base.x == pytest.approx(p_none.x) assert p_base.y == pytest.approx(p_none.y) assert p_base.theta == pytest.approx(p_none.theta) def test_angle_sweep_picks_lowest_cost_lattice_angle(): """逐设备离散扫描应选出当前最优格点角度。""" devices = [Device(id="a", name="A", bbox=(0.8, 0.6))] lab = Lab(width=4.0, depth=4.0) placements = [Placement(device_id="a", x=2.0, y=2.0, theta=math.pi / 4)] constraints = [Constraint(type="soft", rule_name="prefer_aligned", weight=10.0)] angles = [0.0, math.pi / 2, math.pi, 3 * math.pi / 2] swept, cost, changed = _angle_sweep_once( devices=devices, placements=placements, angles=angles, lab=lab, constraints=constraints, collision_checker=MockCollisionChecker(), reachability_checker=None, ) assert changed is True assert swept[0].theta == pytest.approx(0.0) assert cost == pytest.approx(0.0, abs=1e-6) def test_optimize_angle_granularity_returns_lattice_thetas(): """Hybrid mode should keep all returned thetas on the chosen lattice.""" devices = [ Device(id="a", name="A", bbox=(0.8, 0.6)), Device(id="b", name="B", bbox=(0.6, 0.5)), Device(id="c", name="C", bbox=(0.5, 0.5)), ] lab = Lab(width=5.0, depth=5.0) seed_placements = [ Placement(device_id="a", x=1.0, y=1.0, theta=0.13), Placement(device_id="b", x=2.3, y=1.5, theta=1.21), Placement(device_id="c", x=3.6, y=3.2, theta=2.42), ] placements = optimize( devices, lab, seed_placements=seed_placements, seed=42, maxiter=45, popsize=8, angle_granularity=4, ) assert len(placements) == 3 for placement in placements: assert _is_on_angle_lattice(placement.theta, 4), ( f"{placement.device_id} theta={placement.theta} not on 4-angle lattice" ) def test_optimize_endpoint_accepts_angle_granularity_with_pcr_fixture(): """POST /optimize should accept angle_granularity on the 5-device PCR fixture.""" resp = _post_app("/optimize", { "devices": PCR_DEVICES, "lab": PCR_LAB, "seeder": "row_fallback", "run_de": True, "maxiter": 20, "seed": 42, "angle_granularity": 4, }) assert resp.status_code == 200 data = resp.json() assert len(data["placements"]) == 5 assert data["de_ran"] is True for placement in data["placements"]: assert _is_on_angle_lattice(placement["rotation"]["z"], 4), ( f"{placement['device_id']} rotation.z={placement['rotation']['z']} not on lattice" ) def test_optimize_endpoint_binary_reachability_uses_checker(): """Final binary evaluation should fail when hard reachability is impossible.""" resp = _post_app("/optimize", { "devices": [ {"id": "arm_slider", "name": "Arm", "device_type": "articulation", "size": [1.0, 0.2]}, {"id": "opentrons_liquid_handler", "name": "Target", "size": [0.6, 0.5]}, ], "lab": {"width": 8.0, "depth": 4.0}, "constraints": [ { "type": "hard", "rule_name": "reachability", "params": { "arm_id": "arm_slider", "target_device_id": "opentrons_liquid_handler", }, "weight": 1.0, } ], "seeder": "row_fallback", "run_de": False, "arm_reach": {"arm_slider": 0.0}, }) assert resp.status_code == 200 data = resp.json() assert data["success"] is False assert data["cost"] is None or not math.isfinite(data["cost"]) def test_expand_constraints_for_duplicates_fans_out_bare_catalog_ids(): """Bare catalog IDs should expand to all duplicate instances.""" from ..device_catalog import create_devices_from_list from ..server import _expand_constraints_for_duplicates devices = create_devices_from_list([ {"id": "opentrons_liquid_handler", "uuid": "u1"}, {"id": "opentrons_liquid_handler", "uuid": "u2"}, {"id": "arm_slider", "uuid": "arm-u1"}, ]) constraints = [ Constraint( type="hard", rule_name="reachability", params={"arm_id": "arm_slider", "target_device_id": "opentrons_liquid_handler"}, ) ] expanded = _expand_constraints_for_duplicates(constraints, devices) assert len(expanded) == 2 assert {c.params["target_device_id"] for c in expanded} == { "opentrons_liquid_handler", "opentrons_liquid_handler#2", } assert all(c.params["arm_id"] == "arm_slider" for c in expanded) def test_maybe_add_prefer_aligned_constraint_skips_existing(): """Explicit prefer_aligned should win over server auto-injection.""" from ..server import _maybe_add_prefer_aligned_constraint constraints = [Constraint(type="soft", rule_name="prefer_aligned", weight=0.5)] result = _maybe_add_prefer_aligned_constraint(constraints, 3.0) assert result is constraints assert [c.weight for c in result if c.rule_name == "prefer_aligned"] == [0.5] def test_optimize_endpoint_duplicate_instances_return_catalog_id_and_unique_uuid(): """Duplicate catalog devices should round-trip with shared catalog ID and distinct UUIDs.""" resp = _post_app("/optimize", { "devices": [ {"id": "opentrons_liquid_handler", "uuid": "u1"}, {"id": "opentrons_liquid_handler", "uuid": "u2"}, ], "lab": {"width": 3.0, "depth": 3.0}, "seeder": "row_fallback", "run_de": False, }) assert resp.status_code == 200 placements = resp.json()["placements"] assert [p["device_id"] for p in placements] == [ "opentrons_liquid_handler", "opentrons_liquid_handler", ] assert {p["uuid"] for p in placements} == {"u1", "u2"} def test_full_pipeline_seed_only(): """Full pipeline: seeder → snap_theta → correct count and bounds. compact_outward is tested for collision-free (devices clustered, not at walls). spread_inward pushes to walls where rotated AABB bounds may flag — tested separately. """ from ..seeders import seed_layout, PRESETS from ..constraints import evaluate_default_hard_constraints devices = [ Device(id=f"dev{i}", name=f"Device {i}", bbox=(0.6, 0.4)) for i in range(6) ] lab = Lab(width=6.0, depth=5.0) # compact_outward: devices cluster toward center, should be collision-free placements = seed_layout(devices, lab, PRESETS["compact_outward"]) placements = snap_theta(placements) assert len(placements) == len(devices) checker = MockCollisionChecker() cost = evaluate_default_hard_constraints(devices, placements, lab, checker) assert cost < 1e17, f"compact_outward: hard constraint violation (cost={cost})" # spread_inward: verify correct count + all positions within lab canvas placements = seed_layout(devices, lab, PRESETS["spread_inward"]) placements = snap_theta(placements) assert len(placements) == len(devices) for p in placements: assert 0 <= p.x <= lab.width, f"spread_inward: x={p.x} out of bounds" assert 0 <= p.y <= lab.depth, f"spread_inward: y={p.y} out of bounds" def test_full_pipeline_with_de(): """Full pipeline: seeder → DE → snap_theta.""" from ..seeders import seed_layout, PRESETS devices = [ Device(id=f"dev{i}", name=f"Device {i}", bbox=(0.6, 0.4)) for i in range(4) ] lab = Lab(width=5.0, depth=4.0) checker = MockCollisionChecker() seed = seed_layout(devices, lab, PRESETS["compact_outward"]) result = optimize(devices, lab, seed_placements=seed, collision_checker=checker, maxiter=50, seed=42) result = snap_theta(result) assert len(result) == len(devices) for p in result: assert 0 <= p.x <= lab.width assert 0 <= p.y <= lab.depth # ────────────────────────────────────────────────────────────── # DE V2 新增测试 # ────────────────────────────────────────────────────────────── def test_theta_wrapping_convergence(): """θ 在 0/2π 边界附近应正确收敛,不发散。 构造一个简单的 cost function,最优解 θ≈0(即 2π 附近也可以)。 验证自定义 DE 在 θ wrapping 下能收敛。 """ n_devices = 2 def cost_fn(x: np.ndarray) -> float: # 最优:两设备分开放置,θ 接近 0 cost = 0.0 for d in range(n_devices): theta = x[3 * d + 2] # θ penalty: 偏离 0 的圆周距离 cost += (1 - math.cos(theta)) * 10.0 # 两设备距离 penalty dx = x[0] - x[3] dy = x[1] - x[4] dist = math.sqrt(dx * dx + dy * dy) if dist < 1.0: cost += (1.0 - dist) * 100.0 return cost bounds = np.array([ [0.5, 4.5], [0.5, 4.5], [0.0, 2 * math.pi], # 设备 0 [0.5, 4.5], [0.5, 4.5], [0.0, 2 * math.pi], # 设备 1 ]) rng = np.random.default_rng(42) pop_size = 30 init_pop = rng.uniform(bounds[:, 0], bounds[:, 1], size=(pop_size, 6)) # 故意注入 θ 接近 2π 的个体,测试 wrapping init_pop[0, 2] = 2 * math.pi - 0.01 init_pop[0, 5] = 2 * math.pi - 0.05 best_vec, best_cost, n_gen = _run_de( cost_fn=cost_fn, bounds=bounds, init_pop=init_pop, maxiter=100, tol=1e-6, atol=1e-3, mutation=(0.5, 1.0), recombination=0.7, seed=42, n_devices=n_devices, ) # θ 应在 [0, 2π) 范围内 for d in range(n_devices): theta = best_vec[3 * d + 2] assert 0 <= theta < 2 * math.pi, f"θ 超出 [0, 2π): {theta}" # cost 应显著下降(不发散) assert best_cost < 50.0, f"θ wrapping 未正确收敛,cost={best_cost}" def test_per_device_crossover_atomicity(): """验证 per-device crossover 的原子性:同一设备的 (x, y, θ) 来自同一来源。 构造 2 设备场景,跟踪 crossover 后每个设备三元组是否完整来自 mutant 或 parent。 """ rng = np.random.default_rng(123) n_devices = 3 ndim = 3 * n_devices # 构造明显不同的 parent 和 mutant parent = np.zeros(ndim) mutant = np.ones(ndim) * 10.0 # 模拟多次 per-device crossover,检查原子性 violations = 0 n_trials = 200 for _ in range(n_trials): trial = parent.copy() j_rand = rng.integers(0, n_devices) for d in range(n_devices): if rng.random() < 0.7 or d == j_rand: trial[3 * d: 3 * d + 3] = mutant[3 * d: 3 * d + 3] # 检查每个设备的三元组要么全是 0(parent),要么全是 10(mutant) for d in range(n_devices): triple = trial[3 * d: 3 * d + 3] all_parent = np.allclose(triple, 0.0) all_mutant = np.allclose(triple, 10.0) if not (all_parent or all_mutant): violations += 1 assert violations == 0, f"Per-device crossover 原子性违反 {violations}/{n_trials * n_devices} 次" def test_strategy_currenttobest1bin_converges(): """currenttobest1bin 策略在简单 2 设备问题上应能收敛。""" devices = [ Device(id="a", name="A", bbox=(0.6, 0.4)), Device(id="b", name="B", bbox=(0.6, 0.4)), ] lab = Lab(width=5.0, depth=5.0) placements = optimize( devices, lab, seed=42, maxiter=80, popsize=10, strategy="currenttobest1bin", ) assert len(placements) == 2 checker = MockCollisionChecker() checker_placements = [ {"id": p.device_id, "bbox": next(d.bbox for d in devices if d.id == p.device_id), "pos": (p.x, p.y, p.theta)} for p in placements ] collisions = checker.check(checker_placements) assert collisions == [], f"currenttobest1bin 策略产生碰撞: {collisions}" def test_strategy_best1bin_converges(): """best1bin 策略在简单 2 设备问题上应能收敛。""" devices = [ Device(id="a", name="A", bbox=(0.6, 0.4)), Device(id="b", name="B", bbox=(0.6, 0.4)), ] lab = Lab(width=5.0, depth=5.0) placements = optimize( devices, lab, seed=42, maxiter=80, popsize=10, strategy="best1bin", ) assert len(placements) == 2 checker = MockCollisionChecker() checker_placements = [ {"id": p.device_id, "bbox": next(d.bbox for d in devices if d.id == p.device_id), "pos": (p.x, p.y, p.theta)} for p in placements ] collisions = checker.check(checker_placements) assert collisions == [], f"best1bin 策略产生碰撞: {collisions}" def test_convergence_quality_two_devices(): """2 设备无碰撞放置,cost 应低于阈值,验证优化质量。""" devices = [ Device(id="d1", name="D1", bbox=(0.8, 0.6)), Device(id="d2", name="D2", bbox=(0.6, 0.5)), ] lab = Lab(width=5.0, depth=5.0) placements = optimize(devices, lab, seed=42, maxiter=100, popsize=10) # 验证结果质量:cost 应很低(无碰撞、在边界内) from ..constraints import evaluate_default_hard_constraints checker = MockCollisionChecker() cost = evaluate_default_hard_constraints(devices, placements, lab, checker) assert cost < 1.0, f"优化质量不佳,cost={cost}" def test_run_de_early_stopping(): """验证 _run_de 的 early stopping 机制:简单 cost function 应提前终止。""" def trivial_cost(x: np.ndarray) -> float: # 简单二次函数,最优解在原点附近 return float(np.sum(x ** 2)) ndim = 6 n_devices = 2 bounds = np.array([[-5.0, 5.0]] * ndim) rng = np.random.default_rng(42) pop_size = 20 init_pop = rng.uniform(-5, 5, size=(pop_size, ndim)) _, _, n_gen = _run_de( cost_fn=trivial_cost, bounds=bounds, init_pop=init_pop, maxiter=500, tol=1e-6, atol=1e-3, mutation=(0.5, 1.0), recombination=0.7, seed=42, n_devices=n_devices, ) # 简单问题应在远少于 maxiter=500 代内收敛 assert n_gen < 500, f"Early stopping 未生效,运行了 {n_gen} 代" def test_run_de_returns_correct_tuple(): """验证 _run_de 返回值格式正确。""" def const_cost(x: np.ndarray) -> float: return 42.0 bounds = np.array([[0.0, 1.0]] * 3) init_pop = np.random.default_rng(0).uniform(0, 1, size=(5, 3)) result = _run_de( cost_fn=const_cost, bounds=bounds, init_pop=init_pop, maxiter=10, tol=1e-6, atol=1e-3, mutation=(0.5, 1.0), recombination=0.7, seed=0, n_devices=1, ) assert isinstance(result, tuple) and len(result) == 3 best_vec, best_cost, n_gen = result assert isinstance(best_vec, np.ndarray) assert best_vec.shape == (3,) assert best_cost == pytest.approx(42.0) assert isinstance(n_gen, int) and n_gen >= 1 # ────────────────────────────────────────────────────────────── # DE 重构测试:_circular_diff / _compute_mutant / 新策略 / 解耦变异 / 交叉模式 / joint 离散角度 / API # ────────────────────────────────────────────────────────────── class TestCircularDiff: """_circular_diff 圆周角度差测试。""" def test_near_zero_boundary(self): """0.1 vs 2π-0.1 应≈0.2,不是 -6.08。""" a = np.array([1.0, 2.0, 0.1]) b = np.array([1.0, 2.0, 2 * math.pi - 0.1]) result = _circular_diff(a, b, n_devices=1, dims_per_device=3) assert result[0] == pytest.approx(0.0) # x unchanged assert result[1] == pytest.approx(0.0) # y unchanged assert abs(result[2]) == pytest.approx(0.2, abs=1e-6) # theta shortest def test_same_angle(self): """相同角度差为 0。""" a = np.array([0, 0, math.pi, 0, 0, math.pi]) b = np.array([0, 0, math.pi, 0, 0, math.pi]) result = _circular_diff(a, b, n_devices=2, dims_per_device=3) for d in range(2): assert result[3 * d + 2] == pytest.approx(0.0) def test_opposite_angles(self): """π vs 0 应≈π。""" a = np.array([0, 0, math.pi]) b = np.array([0, 0, 0.0]) result = _circular_diff(a, b, n_devices=1, dims_per_device=3) assert abs(result[2]) == pytest.approx(math.pi, abs=1e-6) def test_dims_per_device_2_is_plain_diff(self): """dims_per_device=2 时退化为普通减法。""" a = np.array([3.0, 5.0, 1.0, 2.0]) b = np.array([1.0, 2.0, 0.5, 1.0]) result = _circular_diff(a, b, n_devices=2, dims_per_device=2) np.testing.assert_array_almost_equal(result, a - b) class TestComputeMutant: """_compute_mutant 统一变异向量测试。""" def _make_pop(self, n_devices=2, pop_size=10, seed=42): rng = np.random.default_rng(seed) ndim = 3 * n_devices return rng.uniform(0, 5, size=(pop_size, ndim)), rng def test_currenttobest1bin_no_noise(self): """currenttobest1bin 结果 = target + F*(best-target) + F*(r1-r2),无随机噪声。""" pop, rng = self._make_pop() best = pop[0].copy() f_val = 0.7 # 固定 rng 以确定 r1, r2 rng_copy = np.random.default_rng(123) mutant = _compute_mutant( "currenttobest1bin", pop, best, target_idx=3, f_val=f_val, f_val_theta=f_val, rng=rng_copy, n_devices=2, dims_per_device=3, ) # 重建:获取 rng_copy 选的 r1, r2 rng_verify = np.random.default_rng(123) candidates = list(range(10)) candidates.remove(3) chosen = rng_verify.choice(candidates, size=2, replace=False) r1, r2 = int(chosen[0]), int(chosen[1]) diff_best = _circular_diff(best, pop[3], 2, 3) diff_rand = _circular_diff(pop[r1], pop[r2], 2, 3) expected = pop[3] + f_val * diff_best + f_val * diff_rand np.testing.assert_array_almost_equal(mutant, expected) def test_rand1bin_three_distinct(self): """rand1bin 应选 3 个不同于 target 的个体。""" pop, rng = self._make_pop(pop_size=5) # 不应 raise mutant = _compute_mutant( "rand1bin", pop, pop[0], target_idx=0, f_val=0.5, f_val_theta=0.5, rng=rng, n_devices=2, dims_per_device=3, ) assert mutant.shape == (6,) def test_unknown_strategy_raises(self): """未知策略应 raise ValueError。""" pop, rng = self._make_pop() with pytest.raises(ValueError, match="Unknown DE strategy"): _compute_mutant( "nonexistent", pop, pop[0], target_idx=0, f_val=0.5, f_val_theta=0.5, rng=rng, n_devices=2, dims_per_device=3, ) def test_best1bin_formula(self): """best1bin 结果 = best + F*(r1-r2)。""" pop, _ = self._make_pop() best = pop[0].copy() f_val = 0.6 rng1 = np.random.default_rng(99) mutant = _compute_mutant( "best1bin", pop, best, target_idx=2, f_val=f_val, f_val_theta=f_val, rng=rng1, n_devices=2, dims_per_device=3, ) rng2 = np.random.default_rng(99) candidates = list(range(10)) candidates.remove(2) chosen = rng2.choice(candidates, size=2, replace=False) r1, r2 = int(chosen[0]), int(chosen[1]) diff = _circular_diff(pop[r1], pop[r2], 2, 3) expected = best + f_val * diff np.testing.assert_array_almost_equal(mutant, expected) class TestDecoupledMutation: """解耦 theta 变异测试。""" def test_decoupled_scales_theta_only(self): """theta_mutation < mutation 时,theta 变异幅度应更小。""" rng = np.random.default_rng(42) pop = rng.uniform(0, 5, size=(10, 6)) best = pop[0].copy() # 大 F 给位置+theta rng1 = np.random.default_rng(77) mutant_same = _compute_mutant( "best1bin", pop, best, 1, f_val=0.8, f_val_theta=0.8, rng=rng1, n_devices=2, dims_per_device=3, ) # 大 F 给位置,小 F 给 theta rng2 = np.random.default_rng(77) mutant_decoupled = _compute_mutant( "best1bin", pop, best, 1, f_val=0.8, f_val_theta=0.2, rng=rng2, n_devices=2, dims_per_device=3, ) # x, y 应相同 for d in range(2): assert mutant_same[3 * d] == pytest.approx(mutant_decoupled[3 * d]) assert mutant_same[3 * d + 1] == pytest.approx(mutant_decoupled[3 * d + 1]) # theta 差值应更小(绝对值) for d in range(2): theta_diff_same = abs(mutant_same[3 * d + 2] - best[3 * d + 2]) theta_diff_decoupled = abs(mutant_decoupled[3 * d + 2] - best[3 * d + 2]) assert theta_diff_decoupled <= theta_diff_same + 1e-9 def test_decoupled_no_effect_when_same(self): """theta_mutation == mutation 时行为应完全一致。""" rng = np.random.default_rng(42) pop = rng.uniform(0, 5, size=(10, 6)) best = pop[0].copy() rng1 = np.random.default_rng(77) m1 = _compute_mutant( "currenttobest1bin", pop, best, 2, 0.7, 0.7, rng1, 2, 3, ) rng2 = np.random.default_rng(77) m2 = _compute_mutant( "currenttobest1bin", pop, best, 2, 0.7, 0.7, rng2, 2, 3, ) np.testing.assert_array_almost_equal(m1, m2) class TestCrossoverMode: """交叉模式测试。""" def test_crossover_device_mode_atomicity(self): """device 模式:(x,y,θ) 三元组始终整体复制。""" rng = np.random.default_rng(42) n_devices = 3 parent = np.zeros(9) mutant = np.ones(9) * 10.0 violations = 0 for _ in range(200): trial = parent.copy() j_rand = rng.integers(0, n_devices) for d in range(n_devices): if rng.random() < 0.7 or d == j_rand: trial[3 * d: 3 * d + 3] = mutant[3 * d: 3 * d + 3] for d in range(n_devices): triple = trial[3 * d: 3 * d + 3] if not (np.allclose(triple, 0.0) or np.allclose(triple, 10.0)): violations += 1 assert violations == 0 def test_crossover_dimension_mode_can_split_triplet(self): """dimension 模式:单个维度可以独立交叉,三元组可被拆分。""" rng = np.random.default_rng(42) n_devices = 3 ndim = 9 parent = np.zeros(ndim) mutant = np.ones(ndim) * 10.0 split_seen = False for _ in range(500): trial = parent.copy() j_rand = rng.integers(0, ndim) for j in range(ndim): if rng.random() < 0.5 or j == j_rand: trial[j] = mutant[j] for d in range(n_devices): triple = trial[3 * d: 3 * d + 3] if not (np.allclose(triple, 0.0) or np.allclose(triple, 10.0)): split_seen = True break if split_seen: break assert split_seen, "dimension 模式应允许三元组拆分" class TestJointDiscreteAngle: """joint 模式离散角度 DE 测试。""" def test_joint_mode_returns_lattice_thetas(self): """angle_granularity=4, angle_mode='joint' → 所有 theta 在格点上。""" devices = [ Device(id="a", name="A", bbox=(0.8, 0.6)), Device(id="b", name="B", bbox=(0.6, 0.5)), Device(id="c", name="C", bbox=(0.5, 0.5)), ] lab = Lab(width=5.0, depth=5.0) seed_placements = [ Placement(device_id="a", x=1.0, y=1.0, theta=0.13), Placement(device_id="b", x=2.3, y=1.5, theta=1.21), Placement(device_id="c", x=3.6, y=3.2, theta=2.42), ] placements = optimize( devices, lab, seed_placements=seed_placements, seed=42, maxiter=45, popsize=8, angle_granularity=4, angle_mode="joint", ) assert len(placements) == 3 for p in placements: assert _is_on_angle_lattice(p.theta, 4), ( f"{p.device_id} theta={p.theta} not on 4-angle lattice" ) def test_hybrid_mode_still_works(self): """angle_mode='hybrid' → 现有 hybrid 行为不变。""" devices = [ Device(id="a", name="A", bbox=(0.8, 0.6)), Device(id="b", name="B", bbox=(0.6, 0.5)), ] lab = Lab(width=5.0, depth=5.0) placements = optimize( devices, lab, seed=42, maxiter=30, popsize=8, angle_granularity=4, angle_mode="hybrid", ) assert len(placements) == 2 for p in placements: assert _is_on_angle_lattice(p.theta, 4) def test_joint_default_when_granularity_set(self): """省略 angle_mode + 设定 angle_granularity → 默认 joint 模式。""" devices = [ Device(id="a", name="A", bbox=(0.8, 0.6)), Device(id="b", name="B", bbox=(0.6, 0.5)), ] lab = Lab(width=5.0, depth=5.0) placements = optimize( devices, lab, seed=42, maxiter=30, popsize=8, angle_granularity=4, ) assert len(placements) == 2 for p in placements: assert _is_on_angle_lattice(p.theta, 4) class TestNewStrategies: """rand1bin 策略集成测试。""" def test_rand1bin_converges(self): """rand1bin 策略应能收敛。""" devices = [ Device(id="a", name="A", bbox=(0.6, 0.4)), Device(id="b", name="B", bbox=(0.6, 0.4)), ] lab = Lab(width=5.0, depth=5.0) placements = optimize( devices, lab, seed=42, maxiter=80, popsize=10, strategy="rand1bin", ) assert len(placements) == 2 checker = MockCollisionChecker() checker_placements = [ {"id": p.device_id, "bbox": next(d.bbox for d in devices if d.id == p.device_id), "pos": (p.x, p.y, p.theta)} for p in placements ] collisions = checker.check(checker_placements) assert collisions == [], f"rand1bin 策略产生碰撞: {collisions}" class TestAPINewParams: """POST /optimize 新参数 API 测试。""" def test_api_accepts_strategy(self): resp = _post_app("/optimize", { "devices": [{"id": "test_device", "name": "Test"}], "lab": {"width": 5, "depth": 4}, "strategy": "rand1bin", "maxiter": 10, "seed": 42, }) assert resp.status_code == 200 def test_api_rejects_invalid_strategy(self): resp = _post_app("/optimize", { "devices": [{"id": "test_device", "name": "Test"}], "lab": {"width": 5, "depth": 4}, "strategy": "invalid", "run_de": False, }) assert resp.status_code == 400 assert "strategy" in resp.json()["detail"] def test_api_accepts_angle_mode(self): resp = _post_app("/optimize", { "devices": [{"id": "test_device", "name": "Test"}], "lab": {"width": 5, "depth": 4}, "angle_mode": "hybrid", "angle_granularity": 4, "maxiter": 10, "seed": 42, }) assert resp.status_code == 200 def test_api_rejects_invalid_angle_mode(self): resp = _post_app("/optimize", { "devices": [{"id": "test_device", "name": "Test"}], "lab": {"width": 5, "depth": 4}, "angle_mode": "bogus", "run_de": False, }) assert resp.status_code == 400 assert "angle_mode" in resp.json()["detail"] def test_api_accepts_theta_mutation(self): resp = _post_app("/optimize", { "devices": [{"id": "test_device", "name": "Test"}], "lab": {"width": 5, "depth": 4}, "theta_mutation": [0.2, 0.5], "maxiter": 10, "seed": 42, }) assert resp.status_code == 200 def test_api_accepts_mutation_range(self): resp = _post_app("/optimize", { "devices": [{"id": "test_device", "name": "Test"}], "lab": {"width": 5, "depth": 4}, "mutation": [0.4, 0.8], "maxiter": 10, "seed": 42, }) assert resp.status_code == 200 def test_api_rejects_invalid_mutation(self): resp = _post_app("/optimize", { "devices": [{"id": "test_device", "name": "Test"}], "lab": {"width": 5, "depth": 4}, "mutation": [0.8, 0.4], # min > max "run_de": False, }) assert resp.status_code == 400 def test_api_accepts_recombination(self): resp = _post_app("/optimize", { "devices": [{"id": "test_device", "name": "Test"}], "lab": {"width": 5, "depth": 4}, "recombination": 0.95, "maxiter": 10, "seed": 42, }) assert resp.status_code == 200 def test_api_rejects_invalid_recombination(self): resp = _post_app("/optimize", { "devices": [{"id": "test_device", "name": "Test"}], "lab": {"width": 5, "depth": 4}, "recombination": 1.5, "run_de": False, }) assert resp.status_code == 400 def test_api_accepts_crossover_mode(self): resp = _post_app("/optimize", { "devices": [{"id": "test_device", "name": "Test"}], "lab": {"width": 5, "depth": 4}, "crossover_mode": "dimension", "maxiter": 10, "seed": 42, }) assert resp.status_code == 200 def test_api_rejects_invalid_crossover_mode(self): resp = _post_app("/optimize", { "devices": [{"id": "test_device", "name": "Test"}], "lab": {"width": 5, "depth": 4}, "crossover_mode": "bogus", "run_de": False, }) assert resp.status_code == 400 def test_api_backward_compatible_new_fields(self): """旧 payload(无新字段)应继续正常工作。""" resp = _post_app("/optimize", { "devices": [{"id": "test_device", "name": "Test"}], "lab": {"width": 5, "depth": 4}, "maxiter": 10, "seed": 42, }) assert resp.status_code == 200 data = resp.json() assert data["de_ran"] is True class TestOrientationConstraint: """prefer_orientation_mode 不再自动注入测试。""" def test_no_auto_orientation_constraint(self): """不带 face_outward/face_inward 意图时不应有 prefer_orientation_mode 约束。""" from ..models import Constraint as ConstraintModel from ..server import _expand_constraints_for_duplicates # 模拟 server 逻辑:构建 constraints 但不自动注入 orientation constraints = [ ConstraintModel(type="soft", rule_name="prefer_aligned", weight=1.0), ] # 验证没有 prefer_orientation_mode assert not any(c.rule_name == "prefer_orientation_mode" for c in constraints)