feat(layout_optimizer): add angle-first hybrid discrete-theta mode

This commit is contained in:
yexiaozhou
2026-04-03 01:09:00 +08:00
parent 306b787aa7
commit 00bdf9b822
4 changed files with 677 additions and 99 deletions

View File

@@ -129,6 +129,7 @@ Returns all 10 intent types with parameter specs. LLM agent should call this bef
"workflow_edges": [["device_a", "device_b"]], "workflow_edges": [["device_a", "device_b"]],
"seeder": "compact_outward", "seeder": "compact_outward",
"run_de": true, "run_de": true,
"angle_granularity": 4,
"maxiter": 200, "maxiter": 200,
"seed": 42 "seed": 42
} }
@@ -154,6 +155,7 @@ Returns all 10 intent types with parameter specs. LLM agent should call this bef
``` ```
`position`/`rotation` format matches Cloud's `CommonPositionType`. `rotation.z` is θ in radians. `position`/`rotation` format matches Cloud's `CommonPositionType`. `rotation.z` is θ in radians.
`angle_granularity` is optional and opt-in. Supported values are `4`, `8`, `12`, `24`. When set, the optimizer uses an angle-first hybrid mode: snap all device angles onto the global lattice, greedily sweep angles, then run DE on `x/y` only. `4` is the practical axis-aligned mode for tidy lab layouts.
### `GET /devices` — Device catalog ### `GET /devices` — Device catalog
@@ -438,6 +440,7 @@ curl -X POST http://localhost:8000/optimize \
["agilent_plateloc", "inheco_odtc_96xl"] ["agilent_plateloc", "inheco_odtc_96xl"]
], ],
"run_de": true, "run_de": true,
"angle_granularity": 4,
"maxiter": 100, "maxiter": 100,
"seed": 42 "seed": 42
}' | python3 -m json.tool }' | python3 -m json.tool

View File

@@ -203,6 +203,417 @@ def _generate_seeds(
return seeds return seeds
def _build_bounds(
devices: list[Device], lab: Lab, *, include_theta: bool,
) -> np.ndarray:
"""构建搜索边界。"""
bounds = []
for dev in devices:
half_min = min(dev.bbox[0], dev.bbox[1]) / 2
bounds.append((half_min, lab.width - half_min))
bounds.append((half_min, lab.depth - half_min))
if include_theta:
bounds.append((0, 2 * math.pi))
return np.array(bounds)
def _evaluate_layout_cost(
devices: list[Device],
placements: list[Placement],
lab: Lab,
collision_checker: Any,
reachability_checker: Any,
constraints: list[Constraint],
) -> float:
"""统一计算布局总 cost。"""
hard_cost = evaluate_default_hard_constraints(
devices, placements, lab, collision_checker,
)
if math.isinf(hard_cost):
return 1e18
if constraints:
user_cost = evaluate_constraints(
devices, placements, lab, constraints,
collision_checker, reachability_checker,
)
if math.isinf(user_cost):
return 1e18
return hard_cost + user_cost
return hard_cost
def _make_progress_callback(
devices: list[Device],
lab: Lab,
constraints: list[Constraint],
collision_checker: Any,
reachability_checker: Any,
placements_from_vector: Callable[[np.ndarray], list[Placement]],
) -> Callable[[int, np.ndarray, float], None]:
"""构造统一的 DEBUG 进度回调。"""
def _progress_cb(gen: int, best_vec: np.ndarray, best_cost_val: float) -> None:
if not logger.isEnabledFor(logging.DEBUG):
return
pls = placements_from_vector(best_vec)
hard_bd = evaluate_default_hard_constraints_breakdown(
devices, pls, lab, collision_checker,
)
lines = [f"=== DE Gen {gen} | best_cost={best_cost_val:.4f} ==="]
lines.append(f" {'Constraint':<45} {'Type':<6} {'Weight':>8} {'Cost':>10}")
lines.append(f" {'' * 71}")
lines.append(
f" {'[predefined] collision':<45} {'hard':<6} {hard_bd['collision_weight']:>8.0f} {hard_bd['collision']:>10.4f}"
)
lines.append(
f" {'[predefined] boundary':<45} {'hard':<6} {hard_bd['boundary_weight']:>8.0f} {hard_bd['boundary']:>10.4f}"
)
if constraints:
user_bd = evaluate_constraints_breakdown(
devices, pls, lab, constraints,
collision_checker, reachability_checker,
)
for item in user_bd:
lines.append(
f" {item['name']:<45} {item['type']:<6} {item['weight']:>8.1f} {item['cost']:>10.4f}"
)
lines.append(f" {'' * 71}")
lines.append(f" {'TOTAL':<45} {'':6} {'':>8} {best_cost_val:>10.4f}")
logger.debug("\n".join(lines))
return _progress_cb
def _log_final_summary(
devices: list[Device],
final_placements: list[Placement],
lab: Lab,
constraints: list[Constraint],
collision_checker: Any,
reachability_checker: Any,
best_cost: float,
n_generations: int,
n_evaluations: int,
) -> None:
"""输出最终布局分项明细。"""
hard_bd = evaluate_default_hard_constraints_breakdown(
devices, final_placements, lab, collision_checker,
)
all_hard_met = hard_bd["total"] == 0.0
all_violators: list[dict] = [
{"name": "[predefined] collision", "cost": hard_bd["collision"]},
{"name": "[predefined] boundary", "cost": hard_bd["boundary"]},
]
if constraints:
user_bd = evaluate_constraints_breakdown(
devices, final_placements, lab, constraints,
collision_checker, reachability_checker,
)
user_total = sum(item["cost"] for item in user_bd)
for c_item in user_bd:
all_violators.append({"name": c_item["name"], "cost": c_item["cost"]})
if c_item["type"] == "hard" and c_item["cost"] > 0:
all_hard_met = False
else:
user_total = 0.0
summary = [
"DE complete: success=%s, cost=%.4f, %d gens, %d evals"
% (all_hard_met, best_cost, n_generations, n_evaluations),
" Predefined: subtotal=%.4f" % hard_bd["total"],
]
if constraints:
summary.append(f" User: subtotal={user_total:.4f}")
top_violators = sorted(all_violators, key=lambda x: x["cost"], reverse=True)[:3]
top_violators = [v for v in top_violators if v["cost"] > 0]
if top_violators:
summary.append(" Top violators:")
for v in top_violators:
summary.append(f" {v['name']} = {v['cost']:.4f}")
logger.info("\n".join(summary))
def _angle_lattice(granularity: int) -> list[float]:
"""生成角度离散格点。"""
return [(2 * math.pi * idx) / granularity for idx in range(granularity)]
def _nearest_lattice_theta(theta: float, angles: list[float]) -> float:
"""返回距离最近的离散角度。"""
theta_mod = theta % (2 * math.pi)
return min(
angles,
key=lambda angle: min(
abs(theta_mod - angle),
2 * math.pi - abs(theta_mod - angle),
),
)
def _snap_placements_to_lattice(
placements: list[Placement], angles: list[float],
) -> list[Placement]:
"""将所有设备角度吸附到离散格点。"""
return [
Placement(
device_id=p.device_id,
x=p.x,
y=p.y,
theta=_nearest_lattice_theta(p.theta, angles),
uuid=p.uuid,
)
for p in placements
]
def _placements_to_position_vector(
placements: list[Placement], devices: list[Device],
) -> np.ndarray:
"""将 Placement 列表编码为 2N 维位置向量。"""
placement_map = {p.device_id: p for p in placements}
vec = np.zeros(2 * len(devices))
for i, dev in enumerate(devices):
p = placement_map.get(dev.id)
if p is not None:
vec[2 * i] = p.x
vec[2 * i + 1] = p.y
return vec
def _position_vector_to_placements(
x: np.ndarray,
devices: list[Device],
base_placements: list[Placement],
) -> list[Placement]:
"""将 2N 维位置向量解码为保留 theta 的 Placement 列表。"""
base_map = {p.device_id: p for p in base_placements}
placements = []
for i, dev in enumerate(devices):
base = base_map.get(dev.id)
theta = base.theta if base is not None else 0.0
uuid = base.uuid if base is not None else ""
placements.append(
Placement(
device_id=dev.id,
x=float(x[2 * i]),
y=float(x[2 * i + 1]),
theta=float(theta % (2 * math.pi)),
uuid=uuid,
)
)
return placements
def _run_de_xy(
cost_fn: Callable[[np.ndarray], float],
bounds: np.ndarray,
init_pop: np.ndarray,
maxiter: int,
tol: float,
atol: float,
mutation: tuple[float, float],
recombination: float,
seed: int | None,
n_devices: int,
strategy: str = "currenttobest1bin",
progress_callback: Callable[[int, np.ndarray, float], None] | None = None,
) -> tuple[np.ndarray, float, int]:
"""固定 theta 的 2N 维位置 DE。"""
rng = np.random.default_rng(seed)
pop_size, ndim = init_pop.shape
lower = bounds[:, 0]
upper = bounds[:, 1]
f_min, f_max = mutation
costs = np.array([cost_fn(ind) for ind in init_pop])
best_idx = int(np.argmin(costs))
best_cost = costs[best_idx]
best_vector = init_pop[best_idx].copy()
patience = 200
best_cost_history: list[float] = [best_cost]
for gen in range(1, maxiter + 1):
for i in range(pop_size):
f_val = rng.uniform(f_min, f_max)
candidates = list(range(pop_size))
candidates.remove(i)
chosen = rng.choice(candidates, size=2, replace=False)
r1, r2 = int(chosen[0]), int(chosen[1])
if strategy == "best1bin":
mutant = best_vector + f_val * (init_pop[r1] - init_pop[r2])
else:
mutant = (
init_pop[i]
+ f_val * 0.1 * (upper - lower) * rng.uniform(-1, 1, size=ndim)
+ f_val * (best_vector - init_pop[i])
+ f_val * (init_pop[r1] - init_pop[r2])
)
trial = init_pop[i].copy()
j_rand = rng.integers(0, n_devices)
for d in range(n_devices):
if rng.random() < recombination or d == j_rand:
trial[2 * d: 2 * d + 2] = mutant[2 * d: 2 * d + 2]
trial = np.clip(trial, lower, upper)
trial_cost = cost_fn(trial)
if trial_cost <= costs[i]:
init_pop[i] = trial
costs[i] = trial_cost
if trial_cost < best_cost:
best_cost = trial_cost
best_vector = trial.copy()
best_idx = int(np.argmin(costs))
if progress_callback and gen % 10 == 0:
progress_callback(gen, best_vector, best_cost)
best_cost_history.append(best_cost)
if len(best_cost_history) >= patience:
old_cost = best_cost_history[-patience]
improvement = (old_cost - best_cost) / old_cost if old_cost > 0 else 0.0
if improvement < 0.001:
logger.info(
"Early stop: cost 在 %d 代内稳定在 %.4f(改善 < 0.1%%",
patience, best_cost,
)
return best_vector, best_cost, gen
if np.std(costs) <= atol + tol * abs(best_cost):
logger.info(
"收敛终止std(costs)=%.6f <= atol+tol*|best|=%.6f,第 %d",
np.std(costs), atol + tol * abs(best_cost), gen,
)
return best_vector, best_cost, gen
return best_vector, best_cost, maxiter
def _angle_sweep_once(
devices: list[Device],
placements: list[Placement],
angles: list[float],
lab: Lab,
constraints: list[Constraint],
collision_checker: Any,
reachability_checker: Any,
) -> tuple[list[Placement], float, bool]:
"""固定位置做一轮逐设备离散角度贪心扫描。"""
current = list(placements)
current_cost = _evaluate_layout_cost(
devices, current, lab, collision_checker, reachability_checker, constraints,
)
changed = False
for idx, dev in enumerate(devices):
best_theta = current[idx].theta
best_cost = current_cost
for angle in angles:
if abs((best_theta - angle) % (2 * math.pi)) < 1e-9:
continue
candidate = list(current)
base = candidate[idx]
candidate[idx] = Placement(
device_id=base.device_id,
x=base.x,
y=base.y,
theta=angle,
uuid=base.uuid,
)
candidate_cost = _evaluate_layout_cost(
devices, candidate, lab, collision_checker, reachability_checker, constraints,
)
if candidate_cost < best_cost - 1e-9:
best_theta = angle
best_cost = candidate_cost
if abs((current[idx].theta - best_theta) % (2 * math.pi)) >= 1e-9:
base = current[idx]
current[idx] = Placement(
device_id=base.device_id,
x=base.x,
y=base.y,
theta=best_theta,
uuid=base.uuid,
)
current_cost = best_cost
changed = True
return current, current_cost, changed
def _optimize_positions_fixed_theta(
devices: list[Device],
lab: Lab,
constraints: list[Constraint],
collision_checker: Any,
reachability_checker: Any,
seed_placements: list[Placement],
maxiter: int,
popsize: int,
tol: float,
seed: int | None,
strategy: str,
) -> tuple[list[Placement], float, int, int]:
"""在固定离散 theta 下,只优化位置。"""
n = len(devices)
bounds_array = _build_bounds(devices, lab, include_theta=False)
seed_vector = np.clip(
_placements_to_position_vector(seed_placements, devices),
bounds_array[:, 0],
bounds_array[:, 1],
)
def cost_function(x: np.ndarray) -> float:
placements = _position_vector_to_placements(x, devices, seed_placements)
return _evaluate_layout_cost(
devices, placements, lab, collision_checker, reachability_checker, constraints,
)
rng = np.random.default_rng(seed)
pop_count = popsize * 2 * n
init_pop = rng.uniform(
bounds_array[:, 0], bounds_array[:, 1], size=(pop_count, 2 * n),
)
init_pop[0] = seed_vector
progress_cb = _make_progress_callback(
devices,
lab,
constraints,
collision_checker,
reachability_checker,
lambda vec: _position_vector_to_placements(vec, devices, seed_placements),
)
best_vector, best_cost, n_generations = _run_de_xy(
cost_fn=cost_function,
bounds=bounds_array,
init_pop=init_pop,
maxiter=maxiter,
tol=tol,
atol=1e-3,
mutation=(0.5, 1.0),
recombination=0.7,
seed=seed,
n_devices=n,
strategy=strategy,
progress_callback=progress_cb,
)
return (
_position_vector_to_placements(best_vector, devices, seed_placements),
best_cost,
n_generations,
pop_count + n_generations * pop_count,
)
def optimize( def optimize(
devices: list[Device], devices: list[Device],
lab: Lab, lab: Lab,
@@ -216,6 +627,7 @@ def optimize(
seed: int | None = None, seed: int | None = None,
strategy: str = "currenttobest1bin", strategy: str = "currenttobest1bin",
workflow_edges: list[list[str]] | None = None, workflow_edges: list[list[str]] | None = None,
angle_granularity: int | None = None,
) -> list[Placement]: ) -> list[Placement]:
"""运行差分进化优化,返回最优布局。 """运行差分进化优化,返回最优布局。
@@ -246,17 +658,7 @@ def optimize(
constraints = [] constraints = []
n = len(devices) n = len(devices)
bounds_array = _build_bounds(devices, lab, include_theta=True)
# 构建边界:每个设备 (x, y, θ)
# 使用较小半径作为搜索边界,让 graduated boundary penalty 处理实际越界
# 对角线半径过于保守,会阻止长设备贴边对齐
bounds = []
for dev in devices:
half_min = min(dev.bbox[0], dev.bbox[1]) / 2
bounds.append((half_min, lab.width - half_min)) # x
bounds.append((half_min, lab.depth - half_min)) # y
bounds.append((0, 2 * math.pi)) # θ
bounds_array = np.array(bounds)
# 生成种子个体 # 生成种子个体
if seed_placements is None: if seed_placements is None:
@@ -269,25 +671,86 @@ def optimize(
def cost_function(x: np.ndarray) -> float: def cost_function(x: np.ndarray) -> float:
placements = _vector_to_placements(x, devices) placements = _vector_to_placements(x, devices)
return _evaluate_layout_cost(
# 默认硬约束(碰撞 + 边界) devices, placements, lab, collision_checker, reachability_checker, constraints,
hard_cost = evaluate_default_hard_constraints(
devices, placements, lab, collision_checker
) )
if math.isinf(hard_cost):
return 1e18 # DE 不接受 inf用大数替代
# 用户自定义约束 if angle_granularity is not None:
if constraints: angles = _angle_lattice(angle_granularity)
user_cost = evaluate_constraints( current_placements = _snap_placements_to_lattice(seed_placements, angles)
devices, placements, lab, constraints, best_placements = current_placements
collision_checker, reachability_checker, best_cost = _evaluate_layout_cost(
devices, best_placements, lab, collision_checker, reachability_checker, constraints,
)
total_generations = 0
total_evaluations = 0
logger.info(
"Starting hybrid optimization: %d devices, granularity=%d, outer_rounds=%d, strategy=%s",
n, angle_granularity, 3, strategy,
)
maxiter_xy = max(40, math.ceil(maxiter / 3))
for round_idx in range(3):
round_start_best = best_cost
angle_placements, angle_cost, changed = _angle_sweep_once(
devices,
current_placements,
angles,
lab,
constraints,
collision_checker,
reachability_checker,
) )
if math.isinf(user_cost):
return 1e18
return hard_cost + user_cost
return hard_cost round_seed = None if seed is None else seed + round_idx
polished_placements, polished_cost, n_generations, n_evaluations = (
_optimize_positions_fixed_theta(
devices=devices,
lab=lab,
constraints=constraints,
collision_checker=collision_checker,
reachability_checker=reachability_checker,
seed_placements=angle_placements,
maxiter=maxiter_xy,
popsize=popsize,
tol=tol,
seed=round_seed,
strategy=strategy,
)
)
total_generations += n_generations
total_evaluations += n_evaluations
current_placements = polished_placements
if polished_cost < best_cost:
best_cost = polished_cost
best_placements = polished_placements
improved = polished_cost < round_start_best - 1e-9
logger.info(
"Hybrid round %d complete: changed=%s, angle_cost=%.4f, polished_cost=%.4f",
round_idx + 1, changed, angle_cost, polished_cost,
)
if not changed and not improved:
logger.info(
"Hybrid early stop: 第 %d 轮无角度变化且无 cost 改善",
round_idx + 1,
)
break
_log_final_summary(
devices,
best_placements,
lab,
constraints,
collision_checker,
reachability_checker,
best_cost,
total_generations,
total_evaluations,
)
return best_placements
# 构建初始种群:种子个体 + 多样性种子 + 随机个体 # 构建初始种群:种子个体 + 多样性种子 + 随机个体
rng = np.random.default_rng(seed) rng = np.random.default_rng(seed)
@@ -309,35 +772,14 @@ def optimize(
n, 3 * n, pop_count, maxiter, strategy, n, 3 * n, pop_count, maxiter, strategy,
) )
# DEBUG 模式进度回调:每 10 代输出完整约束分项表格 progress_cb = _make_progress_callback(
def _progress_cb(gen: int, best_vec: np.ndarray, best_cost_val: float) -> None: devices,
if not logger.isEnabledFor(logging.DEBUG): lab,
return constraints,
pls = _vector_to_placements(best_vec, devices) collision_checker,
hard_bd = evaluate_default_hard_constraints_breakdown( reachability_checker,
devices, pls, lab, collision_checker, lambda vec: _vector_to_placements(vec, devices),
) )
lines = [f"=== DE Gen {gen} | best_cost={best_cost_val:.4f} ==="]
lines.append(f" {'Constraint':<45} {'Type':<6} {'Weight':>8} {'Cost':>10}")
lines.append(f" {'' * 71}")
lines.append(
f" {'[predefined] collision':<45} {'hard':<6} {hard_bd['collision_weight']:>8.0f} {hard_bd['collision']:>10.4f}"
)
lines.append(
f" {'[predefined] boundary':<45} {'hard':<6} {hard_bd['boundary_weight']:>8.0f} {hard_bd['boundary']:>10.4f}"
)
if constraints:
user_bd = evaluate_constraints_breakdown(
devices, pls, lab, constraints,
collision_checker, reachability_checker,
)
for item in user_bd:
lines.append(
f" {item['name']:<45} {item['type']:<6} {item['weight']:>8.1f} {item['cost']:>10.4f}"
)
lines.append(f" {'' * 71}")
lines.append(f" {'TOTAL':<45} {'':6} {'':>8} {best_cost_val:>10.4f}")
logger.debug("\n".join(lines))
best_vector, best_cost, n_generations = _run_de( best_vector, best_cost, n_generations = _run_de(
cost_fn=cost_function, cost_fn=cost_function,
@@ -351,54 +793,26 @@ def optimize(
seed=seed, seed=seed,
n_devices=n, n_devices=n,
strategy=strategy, strategy=strategy,
progress_callback=_progress_cb, progress_callback=progress_cb,
) )
# 评估次数估算:每代 pop_count 次(初始 + 每代 trial # 评估次数估算:每代 pop_count 次(初始 + 每代 trial
n_evaluations = pop_count + n_generations * pop_count n_evaluations = pop_count + n_generations * pop_count
# 最终布局分项明细INFO 级别)
final_placements = _vector_to_placements(best_vector, devices) final_placements = _vector_to_placements(best_vector, devices)
hard_bd = evaluate_default_hard_constraints_breakdown( _log_final_summary(
devices, final_placements, lab, collision_checker, devices,
final_placements,
lab,
constraints,
collision_checker,
reachability_checker,
best_cost,
n_generations,
n_evaluations,
) )
# success = 所有 hard 约束均满足predefined + 用户 hard
all_hard_met = hard_bd["total"] == 0.0
# 所有约束的 top violators 候选池predefined + user
all_violators: list[dict] = [
{"name": "[predefined] collision", "cost": hard_bd["collision"]},
{"name": "[predefined] boundary", "cost": hard_bd["boundary"]},
]
if constraints:
user_bd = evaluate_constraints_breakdown(
devices, final_placements, lab, constraints,
collision_checker, reachability_checker,
)
user_total = sum(item["cost"] for item in user_bd)
for c_item in user_bd:
all_violators.append({"name": c_item["name"], "cost": c_item["cost"]})
if c_item["type"] == "hard" and c_item["cost"] > 0:
all_hard_met = False
else:
user_bd = []
user_total = 0.0
summary = [ return final_placements
"DE complete: success=%s, cost=%.4f, %d gens, %d evals"
% (all_hard_met, best_cost, n_generations, n_evaluations),
" Predefined: subtotal=%.4f" % hard_bd["total"],
]
if constraints:
summary.append(f" User: subtotal={user_total:.4f}")
top_violators = sorted(all_violators, key=lambda x: x["cost"], reverse=True)[:3]
top_violators = [v for v in top_violators if v["cost"] > 0]
if top_violators:
summary.append(" Top violators:")
for v in top_violators:
summary.append(f" {v['name']} = {v['cost']:.4f}")
logger.info("\n".join(summary))
return _vector_to_placements(best_vector, devices)
def snap_theta(placements: list[Placement], threshold_deg: float = 15.0) -> list[Placement]: def snap_theta(placements: list[Placement], threshold_deg: float = 15.0) -> list[Placement]:

View File

@@ -409,6 +409,7 @@ class OptimizeRequest(BaseModel):
maxiter: int = 200 maxiter: int = 200
seed: int | None = None seed: int | None = None
snap_cardinal: bool = False snap_cardinal: bool = False
angle_granularity: int | None = None
class PositionXYZ(BaseModel): class PositionXYZ(BaseModel):
@@ -443,15 +444,22 @@ async def run_optimize(request: OptimizeRequest):
from .seeders import resolve_seeder_params, seed_layout from .seeders import resolve_seeder_params, seed_layout
logger.info( logger.info(
"Optimize request: %d devices, lab %.1f×%.1f, %d constraints, seeder=%s, run_de=%s", "Optimize request: %d devices, lab %.1f×%.1f, %d constraints, seeder=%s, run_de=%s, angle_granularity=%s",
len(request.devices), len(request.devices),
request.lab.width, request.lab.width,
request.lab.depth, request.lab.depth,
len(request.constraints), len(request.constraints),
request.seeder, request.seeder,
request.run_de, request.run_de,
request.angle_granularity,
) )
if request.angle_granularity not in (None, 4, 8, 12, 24):
raise HTTPException(
status_code=400,
detail="angle_granularity must be one of: 4, 8, 12, 24",
)
# Build mapping: internal uuid-based id → (catalog_id, uuid) # Build mapping: internal uuid-based id → (catalog_id, uuid)
# create_devices_from_list uses uuid as Device.id when available # create_devices_from_list uses uuid as Device.id when available
id_to_catalog: dict[str, str] = {} id_to_catalog: dict[str, str] = {}
@@ -522,14 +530,20 @@ async def run_optimize(request: OptimizeRequest):
maxiter=request.maxiter, maxiter=request.maxiter,
seed=request.seed, seed=request.seed,
workflow_edges=request.workflow_edges or None, workflow_edges=request.workflow_edges or None,
angle_granularity=request.angle_granularity,
) )
de_ran = True de_ran = True
else: else:
result_placements = seed_placements result_placements = seed_placements
# 5. θ snap post-processingopt-in默认关闭 # 5. θ snap post-processingopt-in默认关闭
if request.snap_cardinal: if request.snap_cardinal and request.angle_granularity is None:
result_placements = snap_theta_safe(result_placements, devices, lab, checker) result_placements = snap_theta_safe(result_placements, devices, lab, checker)
elif request.snap_cardinal and request.angle_granularity is not None:
logger.info(
"snap_cardinal ignored because angle_granularity=%s already constrains theta",
request.angle_granularity,
)
# 6. Evaluate final cost (binary mode for pass/fail reporting) # 6. Evaluate final cost (binary mode for pass/fail reporting)
final_cost = evaluate_default_hard_constraints( final_cost = evaluate_default_hard_constraints(

View File

@@ -1,12 +1,45 @@
"""差分进化优化器端到端测试。""" """差分进化优化器端到端测试。"""
import asyncio
import math import math
import httpx
from ..mock_checkers import MockCollisionChecker from ..mock_checkers import MockCollisionChecker
from ..models import Device, Lab, Placement from ..models import Constraint, Device, Lab, Placement
import numpy as np import numpy as np
import pytest import pytest
from ..optimizer import _run_de, optimize, snap_theta from ..optimizer import _angle_sweep_once, _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(): def test_optimize_three_devices_no_collision():
@@ -150,6 +183,18 @@ def test_optimize_endpoint_unknown_seeder_returns_400():
assert resp.status_code == 400 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(): def test_optimize_endpoint_backward_compatible():
"""Existing calls without seeder/run_de fields still work.""" """Existing calls without seeder/run_de fields still work."""
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@@ -166,6 +211,108 @@ def test_optimize_endpoint_backward_compatible():
assert "de_ran" 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_full_pipeline_seed_only(): def test_full_pipeline_seed_only():
"""Full pipeline: seeder → snap_theta → correct count and bounds. """Full pipeline: seeder → snap_theta → correct count and bounds.