feat(layout_optimizer): DE optimizer V2 — custom loop, graduated hard constraints, broad phase

Replace scipy differential_evolution with custom DE loop for per-device
crossover, circular θ wrapping, and configurable mutation strategy
(currenttobest1bin default, best1bin as turbo mode).

Key improvements:
- Graduate ALL hard constraints during DE (proportional penalty instead of
  flat inf), giving DE smooth gradient for reachability, min_spacing, etc.
  Binary inf preserved for final pass/fail reporting.
- 2-axis sweep-and-prune AABB broad phase for collision pair pruning
- Multi-seed injection from multiple seeder presets + Gaussian variants
- snap_theta_safe: collision-check after angle snapping, revert on violation
- Weight normalization (100 distance / 60 angle / 5× hard multiplier)
- Constraint priority field (critical/high/normal/low → weight multiplier)
  with LLM intent interpreter setting priority per constraint type
- Final success field now checks user hard constraints in binary mode
- arm_slider added to mock checker reach table (1.07m)

Tests: 202 passed, 24 new tests added (optimizer 7, constraints 6, broad_phase 11)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
yexiaozhou
2026-04-01 00:32:34 +08:00
parent 64eeed56a1
commit 9ef24b7768
12 changed files with 1072 additions and 83 deletions

View File

@@ -0,0 +1,66 @@
"""2 轴 sweep-and-prune 宽相碰撞检测。
对每个设备计算旋转后的 AABB先沿 x 轴排序并剪枝,
再用 y 轴交叠过滤。返回候选碰撞对(索引对列表),
供后续 OBB SAT 精确检测使用。
"""
from __future__ import annotations
from .models import Device, Placement
def sweep_and_prune_pairs(
devices: list[Device],
placements: list[Placement],
) -> list[tuple[int, int]]:
"""2 轴 sweep-and-prune返回 AABB 交叠的索引对。
Args:
devices: 设备列表,与 placements 一一对应。
placements: 布局位姿列表。
Returns:
候选碰撞对列表,每个元素为 (i, j)
i < j索引对应 placements 原始顺序。
"""
n = len(devices)
if n < 2:
return []
# --- 计算每个设备旋转后的 AABB ---
aabbs: list[tuple[float, float, float, float]] = []
for dev, pl in zip(devices, placements):
hw, hd = pl.rotated_bbox(dev)
aabbs.append((pl.x - hw, pl.x + hw, pl.y - hd, pl.y + hd))
# --- 按 xmin 排序,保留原始索引映射 ---
sorted_indices = sorted(range(n), key=lambda k: aabbs[k][0])
# --- 扫描 x 轴y 轴过滤 ---
candidates: list[tuple[int, int]] = []
for si in range(len(sorted_indices)):
i = sorted_indices[si]
x_min_i, x_max_i, y_min_i, y_max_i = aabbs[i]
for sj in range(si + 1, len(sorted_indices)):
j = sorted_indices[sj]
x_min_j, _x_max_j, y_min_j, y_max_j = aabbs[j]
# 由于按 xmin 排序x_min_j >= x_min_i
if x_min_j > x_max_i:
break # 后续设备 xmin 更大,不可能与 i 在 x 轴交叠
# x 轴交叠确认,检查 y 轴
if y_min_i <= y_max_j and y_min_j <= y_max_i:
# 保证输出 (min_idx, max_idx) 方便去重和测试
pair = (min(i, j), max(i, j))
candidates.append(pair)
return candidates
def broad_phase_device_pairs(
devices: list[Device],
placements: list[Placement],
) -> list[tuple[str, str]]:
"""返回候选碰撞对的 device_id 字符串元组列表。"""
index_pairs = sweep_and_prune_pairs(devices, placements)
return [(placements[i].device_id, placements[j].device_id) for i, j in index_pairs]

View File

@@ -9,6 +9,7 @@ from __future__ import annotations
import math import math
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from .broad_phase import sweep_and_prune_pairs
from .models import Constraint, Device, Lab, Placement from .models import Constraint, Device, Lab, Placement
from .obb import ( from .obb import (
nearest_point_on_obb, nearest_point_on_obb,
@@ -21,6 +22,21 @@ from .obb import (
if TYPE_CHECKING: if TYPE_CHECKING:
from .interfaces import CollisionChecker, ReachabilityChecker from .interfaces import CollisionChecker, ReachabilityChecker
# 归一化默认权重 — 1cm距离违规 ≈ 5°角度违规 的惩罚量级
DEFAULT_WEIGHT_DISTANCE: float = 100.0 # 1cm → penalty 1.0
DEFAULT_WEIGHT_ANGLE: float = 60.0 # 5° → penalty ~1.0
# 硬约束graduated模式下的惩罚倍数
HARD_MULTIPLIER: float = 5.0
# 优先级等级对应的权重乘数
PRIORITY_MULTIPLIERS: dict[str, float] = {
"critical": 5.0,
"high": 2.0,
"normal": 1.0,
"low": 0.5,
}
def evaluate_constraints( def evaluate_constraints(
devices: list[Device], devices: list[Device],
@@ -29,6 +45,8 @@ def evaluate_constraints(
constraints: list[Constraint], constraints: list[Constraint],
collision_checker: CollisionChecker, collision_checker: CollisionChecker,
reachability_checker: ReachabilityChecker | None = None, reachability_checker: ReachabilityChecker | None = None,
*,
graduated: bool = True,
) -> float: ) -> float:
"""统一评估所有约束,返回总 cost。 """统一评估所有约束,返回总 cost。
@@ -39,9 +57,10 @@ def evaluate_constraints(
constraints: 约束规则列表 constraints: 约束规则列表
collision_checker: 碰撞检测实例 collision_checker: 碰撞检测实例
reachability_checker: 可达性检测实例(可选) reachability_checker: 可达性检测实例(可选)
graduated: True=比例惩罚DE优化用False=二值inf最终pass/fail用
Returns: Returns:
总 cost。硬约束违反返回 inf否则为软约束 penalty 之和。 总 cost。硬约束违反在非graduated模式返回 inf否则为加权 penalty 之和。
""" """
device_map = {d.id: d for d in devices} device_map = {d.id: d for d in devices}
placement_map = {p.device_id: p for p in placements} placement_map = {p.device_id: p for p in placements}
@@ -50,7 +69,8 @@ def evaluate_constraints(
for c in constraints: for c in constraints:
cost = _evaluate_single( cost = _evaluate_single(
c, device_map, placement_map, lab, collision_checker, reachability_checker c, device_map, placement_map, lab, collision_checker, reachability_checker,
graduated=graduated,
) )
if math.isinf(cost): if math.isinf(cost):
return math.inf return math.inf
@@ -66,8 +86,8 @@ def evaluate_default_hard_constraints(
collision_checker: CollisionChecker, collision_checker: CollisionChecker,
*, *,
graduated: bool = True, graduated: bool = True,
collision_weight: float = 1000.0, collision_weight: float = DEFAULT_WEIGHT_DISTANCE * HARD_MULTIPLIER, # 500
boundary_weight: float = 1000.0, boundary_weight: float = DEFAULT_WEIGHT_DISTANCE * HARD_MULTIPLIER, # 500
) -> float: ) -> float:
"""评估默认硬约束(碰撞 + 边界),无需显式声明约束列表。 """评估默认硬约束(碰撞 + 边界),无需显式声明约束列表。
@@ -86,10 +106,9 @@ def evaluate_default_hard_constraints(
device_map = {d.id: d for d in devices} device_map = {d.id: d for d in devices}
cost = 0.0 cost = 0.0
# Graduated collision penalty: sum of penetration depths # Graduated collision penalty: 2 轴 sweep-and-prune 宽相 + OBB SAT 精确检测
n = len(placements) candidate_pairs = sweep_and_prune_pairs(devices, placements)
for i in range(n): for i, j in candidate_pairs:
for j in range(i + 1, n):
di, dj = device_map[placements[i].device_id], device_map[placements[j].device_id] di, dj = device_map[placements[i].device_id], device_map[placements[j].device_id]
ci = obb_corners(placements[i].x, placements[i].y, ci = obb_corners(placements[i].x, placements[i].y,
di.bbox[0], di.bbox[1], placements[i].theta) di.bbox[0], di.bbox[1], placements[i].theta)
@@ -142,17 +161,31 @@ def _evaluate_single(
lab: Lab, lab: Lab,
collision_checker: CollisionChecker, collision_checker: CollisionChecker,
reachability_checker: ReachabilityChecker | None, reachability_checker: ReachabilityChecker | None,
*,
graduated: bool = True,
) -> float: ) -> float:
"""评估单条约束规则。""" """评估单条约束规则。
graduated=True 时硬约束返回比例惩罚DE用
graduated=False 时硬约束返回 inf最终 pass/fail
"""
rule = constraint.rule_name rule = constraint.rule_name
params = constraint.params params = constraint.params
is_hard = constraint.type == "hard" is_hard = constraint.type == "hard"
# 根据优先级等级计算有效权重
effective_weight = constraint.weight
if constraint.priority and constraint.priority in PRIORITY_MULTIPLIERS:
effective_weight *= PRIORITY_MULTIPLIERS[constraint.priority]
if rule == "no_collision": if rule == "no_collision":
checker_placements = _to_checker_format_from_maps(device_map, placement_map) checker_placements = _to_checker_format_from_maps(device_map, placement_map)
collisions = collision_checker.check(checker_placements) collisions = collision_checker.check(checker_placements)
if collisions: if collisions:
return math.inf if is_hard else constraint.weight * len(collisions) if is_hard and not graduated:
return math.inf
w = effective_weight * (HARD_MULTIPLIER if is_hard else 1.0)
return w * len(collisions)
return 0.0 return 0.0
if rule == "within_bounds": if rule == "within_bounds":
@@ -162,7 +195,10 @@ def _evaluate_single(
checker_placements, lab.width, lab.depth checker_placements, lab.width, lab.depth
) )
if oob: if oob:
return math.inf if is_hard else constraint.weight * len(oob) if is_hard and not graduated:
return math.inf
w = effective_weight * (HARD_MULTIPLIER if is_hard else 1.0)
return w * len(oob)
return 0.0 return 0.0
if rule == "distance_less_than": if rule == "distance_less_than":
@@ -177,7 +213,10 @@ def _evaluate_single(
else: else:
dist = _device_distance_center(pa, pb) or 0.0 dist = _device_distance_center(pa, pb) or 0.0
if dist > max_dist: if dist > max_dist:
return math.inf if is_hard else constraint.weight * (dist - max_dist) if is_hard and not graduated:
return math.inf
w = effective_weight * (HARD_MULTIPLIER if is_hard else 1.0)
return w * (dist - max_dist)
return 0.0 return 0.0
if rule == "distance_greater_than": if rule == "distance_greater_than":
@@ -192,7 +231,10 @@ def _evaluate_single(
else: else:
dist = _device_distance_center(pa, pb) or 0.0 dist = _device_distance_center(pa, pb) or 0.0
if dist < min_dist: if dist < min_dist:
return math.inf if is_hard else constraint.weight * (min_dist - dist) if is_hard and not graduated:
return math.inf
w = effective_weight * (HARD_MULTIPLIER if is_hard else 1.0)
return w * (min_dist - dist)
return 0.0 return 0.0
if rule == "minimize_distance": if rule == "minimize_distance":
@@ -205,7 +247,7 @@ def _evaluate_single(
dist = _device_distance_obb(da, pa, db, pb) dist = _device_distance_obb(da, pa, db, pb)
else: else:
dist = _device_distance_center(pa, pb) or 0.0 dist = _device_distance_center(pa, pb) or 0.0
return constraint.weight * dist return effective_weight * dist
if rule == "maximize_distance": if rule == "maximize_distance":
a_id, b_id = params["device_a"], params["device_b"] a_id, b_id = params["device_a"], params["device_b"]
@@ -218,11 +260,12 @@ def _evaluate_single(
else: else:
dist = _device_distance_center(pa, pb) or 0.0 dist = _device_distance_center(pa, pb) or 0.0
max_possible = math.sqrt(lab.width**2 + lab.depth**2) max_possible = math.sqrt(lab.width**2 + lab.depth**2)
return constraint.weight * (max_possible - dist) return effective_weight * (max_possible - dist)
if rule == "min_spacing": if rule == "min_spacing":
min_gap = params.get("min_gap", 0.0) min_gap = params.get("min_gap", 0.0)
all_placements = list(placement_map.values()) all_placements = list(placement_map.values())
total_penalty = 0.0
for i in range(len(all_placements)): for i in range(len(all_placements)):
for j in range(i + 1, len(all_placements)): for j in range(i + 1, len(all_placements)):
pi, pj = all_placements[i], all_placements[j] pi, pj = all_placements[i], all_placements[j]
@@ -233,9 +276,12 @@ def _evaluate_single(
else: else:
dist = _device_distance_center(pi, pj) or 0.0 dist = _device_distance_center(pi, pj) or 0.0
if dist < min_gap: if dist < min_gap:
if is_hard: total_penalty += (min_gap - dist)
if total_penalty > 0:
if is_hard and not graduated:
return math.inf return math.inf
return constraint.weight * (min_gap - dist) w = effective_weight * (HARD_MULTIPLIER if is_hard else 1.0)
return w * total_penalty
return 0.0 return 0.0
if rule == "reachability": if rule == "reachability":
@@ -268,18 +314,19 @@ def _evaluate_single(
target_point = {"x": target_p.x, "y": target_p.y, "z": 0.0} target_point = {"x": target_p.x, "y": target_p.y, "z": 0.0}
target_point["_obb_dist"] = dist target_point["_obb_dist"] = dist
if not reachability_checker.is_reachable(arm_id, arm_pose, target_point): if not reachability_checker.is_reachable(arm_id, arm_pose, target_point):
if is_hard: if is_hard and not graduated:
return math.inf return math.inf
# Graduated: penalty proportional to overshoot # Graduated: penalty proportional to overshoot
max_reach = reachability_checker.arm_reach.get(arm_id, 2.0) max_reach = reachability_checker.arm_reach.get(arm_id, 2.0)
overshoot = max(0.0, dist - max_reach) overshoot = max(0.0, dist - max_reach)
return constraint.weight * overshoot * 10.0 w = effective_weight * (HARD_MULTIPLIER if is_hard else 1.0)
return w * overshoot * 10.0
# Line-of-sight penalty: penalize if any other device OBB blocks # Line-of-sight penalty: penalize if any other device OBB blocks
# the path from opening to arm # the path from opening to arm
los_cost = _line_of_sight_penalty( los_cost = _line_of_sight_penalty(
arm_id, arm_p, target_device_id, target_p, arm_id, arm_p, target_device_id, target_p,
device_map, placement_map, constraint.weight, device_map, placement_map, effective_weight,
) )
return los_cost return los_cost
@@ -288,8 +335,10 @@ def _evaluate_single(
(1 - math.cos(4 * p.theta)) / 2 for p in placement_map.values() (1 - math.cos(4 * p.theta)) / 2 for p in placement_map.values()
) )
if is_hard: if is_hard:
if not graduated:
return math.inf if alignment_cost > 1e-6 else 0.0 return math.inf if alignment_cost > 1e-6 else 0.0
return constraint.weight * alignment_cost return HARD_MULTIPLIER * effective_weight * alignment_cost
return effective_weight * alignment_cost
if rule == "prefer_seeder_orientation": if rule == "prefer_seeder_orientation":
target_thetas = params.get("target_thetas", {}) target_thetas = params.get("target_thetas", {})
@@ -301,7 +350,7 @@ def _evaluate_single(
# Circular distance: (1 - cos(diff)) / 2 gives 0..1 range # Circular distance: (1 - cos(diff)) / 2 gives 0..1 range
diff = p.theta - target diff = p.theta - target
cost += (1 - math.cos(diff)) / 2 cost += (1 - math.cos(diff)) / 2
return constraint.weight * cost return effective_weight * cost
if rule == "prefer_orientation_mode": if rule == "prefer_orientation_mode":
mode = params.get("mode", "outward") mode = params.get("mode", "outward")
@@ -319,7 +368,7 @@ def _evaluate_single(
continue continue
diff = p.theta - target diff = p.theta - target
cost += (1 - math.cos(diff)) / 2 cost += (1 - math.cos(diff)) / 2
return constraint.weight * cost return effective_weight * cost
# 未知约束类型,忽略 # 未知约束类型,忽略
return 0.0 return 0.0

View File

@@ -41,6 +41,7 @@ def _handle_reachable_by(intent: Intent, result: InterpretResult) -> None:
type="hard", type="hard",
rule_name="reachability", rule_name="reachability",
params={"arm_id": arm, "target_device_id": target}, params={"arm_id": arm, "target_device_id": target},
priority="critical",
) )
result.constraints.append(c) result.constraints.append(c)
generated.append({"type": c.type, "rule_name": c.rule_name, "params": c.params, "weight": c.weight}) generated.append({"type": c.type, "rule_name": c.rule_name, "params": c.params, "weight": c.weight})
@@ -64,6 +65,8 @@ def _handle_close_together(intent: Intent, result: InterpretResult) -> None:
return return
weight = _PRIORITY_WEIGHTS.get(priority, _DEFAULT_WEIGHT) weight = _PRIORITY_WEIGHTS.get(priority, _DEFAULT_WEIGHT)
# 映射 intent priority 到 constraint priority 等级
constraint_priority = "high" if priority == "high" else "normal"
generated: list[dict] = [] generated: list[dict] = []
for dev_a, dev_b in itertools.combinations(devices, 2): for dev_a, dev_b in itertools.combinations(devices, 2):
c = Constraint( c = Constraint(
@@ -71,6 +74,7 @@ def _handle_close_together(intent: Intent, result: InterpretResult) -> None:
rule_name="minimize_distance", rule_name="minimize_distance",
params={"device_a": dev_a, "device_b": dev_b}, params={"device_a": dev_a, "device_b": dev_b},
weight=weight, weight=weight,
priority=constraint_priority,
) )
result.constraints.append(c) result.constraints.append(c)
generated.append({"type": c.type, "rule_name": c.rule_name, "params": c.params, "weight": c.weight}) generated.append({"type": c.type, "rule_name": c.rule_name, "params": c.params, "weight": c.weight})
@@ -94,6 +98,8 @@ def _handle_far_apart(intent: Intent, result: InterpretResult) -> None:
return return
weight = _PRIORITY_WEIGHTS.get(priority, _DEFAULT_WEIGHT) weight = _PRIORITY_WEIGHTS.get(priority, _DEFAULT_WEIGHT)
# 映射 intent priority 到 constraint priority 等级
constraint_priority = "high" if priority == "high" else "normal"
generated: list[dict] = [] generated: list[dict] = []
for dev_a, dev_b in itertools.combinations(devices, 2): for dev_a, dev_b in itertools.combinations(devices, 2):
c = Constraint( c = Constraint(
@@ -101,6 +107,7 @@ def _handle_far_apart(intent: Intent, result: InterpretResult) -> None:
rule_name="maximize_distance", rule_name="maximize_distance",
params={"device_a": dev_a, "device_b": dev_b}, params={"device_a": dev_a, "device_b": dev_b},
weight=weight, weight=weight,
priority=constraint_priority,
) )
result.constraints.append(c) result.constraints.append(c)
generated.append({"type": c.type, "rule_name": c.rule_name, "params": c.params, "weight": c.weight}) generated.append({"type": c.type, "rule_name": c.rule_name, "params": c.params, "weight": c.weight})
@@ -131,6 +138,7 @@ def _handle_max_distance(intent: Intent, result: InterpretResult) -> None:
type="hard", type="hard",
rule_name="distance_less_than", rule_name="distance_less_than",
params={"device_a": device_a, "device_b": device_b, "distance": distance}, params={"device_a": device_a, "device_b": device_b, "distance": distance},
priority="normal",
) )
result.constraints.append(c) result.constraints.append(c)
@@ -160,6 +168,7 @@ def _handle_min_distance(intent: Intent, result: InterpretResult) -> None:
type="hard", type="hard",
rule_name="distance_greater_than", rule_name="distance_greater_than",
params={"device_a": device_a, "device_b": device_b, "distance": distance}, params={"device_a": device_a, "device_b": device_b, "distance": distance},
priority="normal",
) )
result.constraints.append(c) result.constraints.append(c)
@@ -180,6 +189,7 @@ def _handle_min_spacing(intent: Intent, result: InterpretResult) -> None:
type="hard", type="hard",
rule_name="min_spacing", rule_name="min_spacing",
params={"min_gap": min_gap}, params={"min_gap": min_gap},
priority="high",
) )
result.constraints.append(c) result.constraints.append(c)
@@ -198,6 +208,7 @@ def _handle_face_outward(intent: Intent, result: InterpretResult) -> None:
type="soft", type="soft",
rule_name="prefer_orientation_mode", rule_name="prefer_orientation_mode",
params={"mode": "outward"}, params={"mode": "outward"},
priority="low",
) )
result.constraints.append(c) result.constraints.append(c)
@@ -216,6 +227,7 @@ def _handle_face_inward(intent: Intent, result: InterpretResult) -> None:
type="soft", type="soft",
rule_name="prefer_orientation_mode", rule_name="prefer_orientation_mode",
params={"mode": "inward"}, params={"mode": "inward"},
priority="low",
) )
result.constraints.append(c) result.constraints.append(c)
@@ -234,6 +246,7 @@ def _handle_align_cardinal(intent: Intent, result: InterpretResult) -> None:
type="soft", type="soft",
rule_name="prefer_aligned", rule_name="prefer_aligned",
params={}, params={},
priority="low",
) )
result.constraints.append(c) result.constraints.append(c)
@@ -246,6 +259,39 @@ def _handle_align_cardinal(intent: Intent, result: InterpretResult) -> None:
}) })
def _handle_keep_adjacent(intent: Intent, result: InterpretResult) -> None:
"""keep_adjacent两个设备保持相邻同 close_together 逻辑,支持 priority 映射)。"""
devices: list[str] = intent.params.get("devices", [])
priority: str = intent.params.get("priority", "medium")
if len(devices) < 2:
result.errors.append(f"keep_adjacent: 参数 'devices' 至少需要 2 个设备,当前 {len(devices)}")
return
weight = _PRIORITY_WEIGHTS.get(priority, _DEFAULT_WEIGHT)
# 映射 intent priority 到 constraint priority 等级
constraint_priority = "high" if priority == "high" else "normal"
generated: list[dict] = []
for dev_a, dev_b in itertools.combinations(devices, 2):
c = Constraint(
type="soft",
rule_name="minimize_distance",
params={"device_a": dev_a, "device_b": dev_b},
weight=weight,
priority=constraint_priority,
)
result.constraints.append(c)
generated.append({"type": c.type, "rule_name": c.rule_name, "params": c.params, "weight": c.weight})
result.translations.append({
"source_intent": intent.intent,
"source_description": intent.description,
"source_params": intent.params,
"generated_constraints": generated,
"explanation": f"设备组 {devices} 应保持相邻(优先级: {priority}",
})
def _handle_workflow_hint(intent: Intent, result: InterpretResult) -> None: def _handle_workflow_hint(intent: Intent, result: InterpretResult) -> None:
"""workflow_hint工作流顺序暗示相邻步骤设备靠近。""" """workflow_hint工作流顺序暗示相邻步骤设备靠近。"""
workflow: str = intent.params.get("workflow", "") workflow: str = intent.params.get("workflow", "")
@@ -263,6 +309,7 @@ def _handle_workflow_hint(intent: Intent, result: InterpretResult) -> None:
type="soft", type="soft",
rule_name="minimize_distance", rule_name="minimize_distance",
params={"device_a": dev_a, "device_b": dev_b}, params={"device_a": dev_a, "device_b": dev_b},
priority="normal",
) )
result.constraints.append(c) result.constraints.append(c)
generated.append({"type": c.type, "rule_name": c.rule_name, "params": c.params, "weight": c.weight}) generated.append({"type": c.type, "rule_name": c.rule_name, "params": c.params, "weight": c.weight})

View File

@@ -78,6 +78,7 @@ class MockReachabilityChecker:
"elite_cs66": 0.914, "elite_cs66": 0.914,
"elite_cs612": 1.304, "elite_cs612": 1.304,
"elite_cs620": 1.800, "elite_cs620": 1.800,
"arm_slider": 1.07, # 线性导轨臂body 2.14m × 0.35mreach ≈ half length
} }
# 未知型号回退臂展realistic default for lab-scale arms # 未知型号回退臂展realistic default for lab-scale arms

View File

@@ -84,6 +84,8 @@ class Constraint:
params: dict = field(default_factory=dict) params: dict = field(default_factory=dict)
# 仅 soft 约束使用 # 仅 soft 约束使用
weight: float = 1.0 weight: float = 1.0
# 优先级等级,影响有效权重的乘数
priority: str | None = None # "critical" | "high" | "normal" | "low"
@dataclass @dataclass

View File

@@ -1,7 +1,7 @@
"""差分进化布局优化器。 """差分进化布局优化器。
编码N 个设备 → 3N 维向量 [x0, y0, θ0, x1, y1, θ1, ...] 编码N 个设备 → 3N 维向量 [x0, y0, θ0, x1, y1, θ1, ...]
使用 scipy.optimize.differential_evolution 进行全局优化。 使用自定义差分进化循环per-device crossover + θ wrapping进行全局优化。
初始布局Pencil/回退)注入为种群种子个体加速收敛。 初始布局Pencil/回退)注入为种群种子个体加速收敛。
""" """
@@ -9,19 +9,187 @@ from __future__ import annotations
import logging import logging
import math import math
from typing import Any from typing import Any, Callable
import numpy as np import numpy as np
from scipy.optimize import differential_evolution
from .constraints import evaluate_constraints, evaluate_default_hard_constraints from .constraints import evaluate_constraints, evaluate_default_hard_constraints
from .mock_checkers import MockCollisionChecker, MockReachabilityChecker from .mock_checkers import MockCollisionChecker, MockReachabilityChecker
from .models import Constraint, Device, Lab, Placement from .models import Constraint, Device, Lab, Placement
from .pencil_integration import generate_initial_layout from .pencil_integration import generate_initial_layout
from .seeders import resolve_seeder_params, seed_layout
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _run_de(
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",
) -> tuple[np.ndarray, float, int]:
"""自定义差分进化循环。
特性:
- 支持 currenttobest1bin / best1bin 两种策略
- Per-device crossover以设备 (x, y, θ) 三元组为原子单元进行交叉
- θ wrapping交叉后对角度取模 [0, 2π)
- Early stopping最近 20 代改善 < 0.1% 时提前终止
- scipy 风格收敛判断std(costs) <= atol + tol * |best_cost|
Args:
cost_fn: 目标函数 f(x) → float
bounds: 边界数组 shape=(ndim, 2),每行 [low, high]
init_pop: 初始种群 shape=(pop_size, ndim)
maxiter: 最大迭代代数
tol: 相对收敛容差
atol: 绝对收敛容差
mutation: 变异因子范围 (F_min, F_max)
recombination: 交叉概率 CR
seed: 随机种子
n_devices: 设备数量(用于 per-device crossover
strategy: 变异策略,"currenttobest1bin""best1bin"
Returns:
(best_vector, best_cost, n_generations)
"""
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()
# Early stopping 跟踪
patience = 20
best_cost_history: list[float] = [best_cost]
for gen in range(1, maxiter + 1):
for i in range(pop_size):
# 选择变异因子 F每个个体独立采样
f_val = rng.uniform(f_min, f_max)
# 选择两个不同于 i 和 best_idx 的个体索引
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":
# Turbo 模式mutant = best + F*(r1 - r2)
mutant = best_vector + f_val * (init_pop[r1] - init_pop[r2])
else:
# 默认 currenttobest1binmutant = target + F*(best - target) + F*(r1 - r2)
mutant = (
init_pop[i]
+ f_val * (best_vector - init_pop[i])
+ f_val * (init_pop[r1] - init_pop[r2])
)
# Per-device crossover以 (x, y, θ) 三元组为原子单元
trial = init_pop[i].copy()
j_rand = rng.integers(0, n_devices) # 保证至少一个设备来自 mutant
for d in range(n_devices):
if rng.random() < recombination or d == j_rand:
trial[3 * d: 3 * d + 3] = mutant[3 * d: 3 * d + 3]
# θ wrapping角度取模 [0, 2π)
for d in range(n_devices):
trial[3 * d + 2] %= 2 * math.pi
# 钳位到边界内
trial = np.clip(trial, lower, upper)
# 贪心选择trial 不比当前差则替换
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种群可能整体更新
best_idx = int(np.argmin(costs))
# Early stopping最近 patience 代改善 < 0.1%
best_cost_history.append(best_cost)
if len(best_cost_history) >= patience:
old_cost = best_cost_history[-patience]
if old_cost > 0:
improvement = (old_cost - best_cost) / old_cost
else:
improvement = 0.0
if improvement < 0.001:
logger.info(
"Early stop: cost 在 %d 代内稳定在 %.4f(改善 < 0.1%%",
patience, best_cost,
)
return best_vector, best_cost, gen
# scipy 风格收敛判断
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 _generate_seeds(
devices: list[Device],
lab: Lab,
rng: np.random.Generator,
workflow_edges: list[list[str]] | None = None,
n_variants: int = 3,
sigma_pos_frac: float = 0.05,
sigma_theta: float = math.pi / 6,
) -> list[np.ndarray]:
"""从多个 seeder preset 生成多样性种子个体 + 变异版本。"""
seeds: list[np.ndarray] = []
presets = ["compact_outward", "spread_inward"]
if workflow_edges:
presets.append("workflow_cluster")
for preset_name in presets:
try:
params = resolve_seeder_params(preset_name)
except ValueError:
continue
if params is None:
continue
base_placements = seed_layout(devices, lab, params, workflow_edges)
base_vec = _placements_to_vector(base_placements, devices)
seeds.append(base_vec)
# 变异版本:对 (x,y) 加高斯噪声 σ=5% lab 尺寸,θ 加 σ=π/6
for _ in range(n_variants):
variant = base_vec.copy()
for d in range(len(devices)):
variant[3 * d] += rng.normal(0, sigma_pos_frac * lab.width)
variant[3 * d + 1] += rng.normal(0, sigma_pos_frac * lab.depth)
variant[3 * d + 2] += rng.normal(0, sigma_theta)
variant[3 * d + 2] %= 2 * math.pi
seeds.append(variant)
return seeds
def optimize( def optimize(
devices: list[Device], devices: list[Device],
lab: Lab, lab: Lab,
@@ -33,6 +201,8 @@ def optimize(
popsize: int = 15, popsize: int = 15,
tol: float = 1e-6, tol: float = 1e-6,
seed: int | None = None, seed: int | None = None,
strategy: str = "currenttobest1bin",
workflow_edges: list[list[str]] | None = None,
) -> list[Placement]: ) -> list[Placement]:
"""运行差分进化优化,返回最优布局。 """运行差分进化优化,返回最优布局。
@@ -47,6 +217,7 @@ def optimize(
popsize: 种群大小倍数 popsize: 种群大小倍数
tol: 收敛容差 tol: 收敛容差
seed: 随机种子(用于可复现性) seed: 随机种子(用于可复现性)
strategy: DE 变异策略("currenttobest1bin""best1bin"
Returns: Returns:
最优布局 Placement 列表 最优布局 Placement 列表
@@ -105,57 +276,49 @@ def optimize(
return hard_cost return hard_cost
# 构建初始种群:种子个体 + 随机个体 # 构建初始种群:种子个体 + 多样性种子 + 随机个体
rng = np.random.default_rng(seed) rng = np.random.default_rng(seed)
pop_count = popsize * 3 * n # scipy 默认 popsize * dim pop_count = popsize * 3 * n # scipy 默认 popsize * dim
init_pop = rng.uniform( init_pop = rng.uniform(
bounds_array[:, 0], bounds_array[:, 1], size=(pop_count, 3 * n) bounds_array[:, 0], bounds_array[:, 1], size=(pop_count, 3 * n)
) )
init_pop[0] = seed_vector # 注入种子 init_pop[0] = seed_vector # 注入原始种子
# 多样性种子注入(多 preset + 变异版本)
extra_seeds = _generate_seeds(devices, lab, rng, workflow_edges)
for i, s in enumerate(extra_seeds):
idx = i + 1 # 原始种子占 [0]
if idx < pop_count:
init_pop[idx] = np.clip(s, bounds_array[:, 0], bounds_array[:, 1])
logger.info( logger.info(
"Starting DE optimization: %d devices, %d-dim, popsize=%d, maxiter=%d", "Starting DE optimization: %d devices, %d-dim, popsize=%d, maxiter=%d, strategy=%s",
n, 3 * n, pop_count, maxiter, n, 3 * n, pop_count, maxiter, strategy,
) )
# Early stopping: stop when cost hasn't improved by >0.1% for 20 generations best_vector, best_cost, n_generations = _run_de(
_best_costs: list[float] = [] cost_fn=cost_function,
_patience = 20 bounds=bounds_array,
init_pop=init_pop,
def _early_stop_callback(xk, convergence=0):
cost = cost_function(xk)
_best_costs.append(cost)
if len(_best_costs) >= _patience:
recent = _best_costs[-_patience:]
if recent[0] > 0:
improvement = (recent[0] - recent[-1]) / recent[0]
else:
improvement = 0.0
if improvement < 0.001: # < 0.1% improvement over last 20 gens
logger.info("Early stop: cost stable at %.4f for %d generations", cost, _patience)
return True
return False
result = differential_evolution(
cost_function,
bounds=list(bounds),
init=init_pop,
maxiter=maxiter, maxiter=maxiter,
tol=tol, tol=tol,
atol=1e-3, atol=1e-3,
mutation=(0.5, 1.0), mutation=(0.5, 1.0),
recombination=0.7, recombination=0.7,
seed=seed, seed=seed,
disp=False, n_devices=n,
callback=_early_stop_callback, strategy=strategy,
) )
# 评估次数估算:每代 pop_count 次(初始 + 每代 trial
n_evaluations = pop_count + n_generations * pop_count
logger.info( logger.info(
"DE optimization complete: success=%s, cost=%.4f, iterations=%d, evaluations=%d", "DE optimization complete: success=%s, cost=%.4f, iterations=%d, evaluations=%d",
result.success, result.fun, result.nit, result.nfev, best_cost < 1e17, best_cost, n_generations, n_evaluations,
) )
return _vector_to_placements(result.x, devices) 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]:
@@ -179,6 +342,38 @@ def snap_theta(placements: list[Placement], threshold_deg: float = 15.0) -> list
return result return result
def snap_theta_safe(
placements: list[Placement],
devices: list[Device],
lab: Lab,
collision_checker: Any,
threshold_deg: float = 15.0,
) -> list[Placement]:
"""Snap theta 到基数方向,但碰撞时回退到原始角度。
逐设备检查snap 后如果产生碰撞或越界,则该设备保留原始 theta。
"""
snapped = snap_theta(placements, threshold_deg)
result = list(snapped)
for idx, (orig, snap) in enumerate(zip(placements, snapped)):
if abs(orig.theta - snap.theta) < 1e-9:
continue # 未 snap跳过
# 检查 snap 版本是否导致新碰撞
test_placements = result.copy()
test_placements[idx] = snap
cost = evaluate_default_hard_constraints(
devices, test_placements, lab, collision_checker, graduated=False,
)
if math.isinf(cost):
result[idx] = orig # 回退到未 snap 的角度
logger.info(
"snap_theta_safe: 设备 %s snap θ=%.2f%.2f 导致碰撞,已回退",
snap.device_id, orig.theta, snap.theta,
)
return result
def _placements_to_vector( def _placements_to_vector(
placements: list[Placement], devices: list[Device] placements: list[Placement], devices: list[Device]
) -> np.ndarray: ) -> np.ndarray:
@@ -208,7 +403,7 @@ def _vector_to_placements(
device_id=dev.id, device_id=dev.id,
x=float(x[3 * i]), x=float(x[3 * i]),
y=float(x[3 * i + 1]), y=float(x[3 * i + 1]),
theta=float(x[3 * i + 2]), theta=float(x[3 * i + 2] % (2 * math.pi)),
) )
) )
return placements return placements

View File

@@ -23,6 +23,7 @@ from fastapi.responses import FileResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel from pydantic import BaseModel
from .constraints import DEFAULT_WEIGHT_ANGLE
from .device_catalog import ( from .device_catalog import (
create_devices_from_list, create_devices_from_list,
load_devices_from_assets, load_devices_from_assets,
@@ -395,9 +396,9 @@ async def run_optimize(request: OptimizeRequest):
"""接收设备列表+约束,返回最优布局方案。""" """接收设备列表+约束,返回最优布局方案。"""
from fastapi import HTTPException from fastapi import HTTPException
from .constraints import evaluate_default_hard_constraints from .constraints import evaluate_default_hard_constraints, evaluate_constraints
from .mock_checkers import MockCollisionChecker from .mock_checkers import MockCollisionChecker
from .optimizer import optimize, snap_theta from .optimizer import optimize, snap_theta, snap_theta_safe
from .seeders import resolve_seeder_params, seed_layout from .seeders import resolve_seeder_params, seed_layout
logger.info( logger.info(
@@ -456,10 +457,10 @@ async def run_optimize(request: OptimizeRequest):
type="soft", type="soft",
rule_name="prefer_orientation_mode", rule_name="prefer_orientation_mode",
params={"mode": orientation_mode}, params={"mode": orientation_mode},
weight=request.seeder_overrides.get("orientation_weight", 5.0), weight=request.seeder_overrides.get("orientation_weight", DEFAULT_WEIGHT_ANGLE),
)) ))
# prefer_aligned: penalize non-cardinal angles # prefer_aligned: penalize non-cardinal angles
align_weight = request.seeder_overrides.get("align_weight", 2.0) align_weight = request.seeder_overrides.get("align_weight", DEFAULT_WEIGHT_ANGLE)
if align_weight > 0: if align_weight > 0:
constraints.append(Constraint( constraints.append(Constraint(
type="soft", type="soft",
@@ -479,18 +480,27 @@ async def run_optimize(request: OptimizeRequest):
seed_placements=seed_placements, seed_placements=seed_placements,
maxiter=request.maxiter, maxiter=request.maxiter,
seed=request.seed, seed=request.seed,
workflow_edges=request.workflow_edges or None,
) )
de_ran = True de_ran = True
else: else:
result_placements = seed_placements result_placements = seed_placements
# 5. θ snap post-processing # 5. θ snap post-processing碰撞安全snap 后验证,失败则回退)
result_placements = snap_theta(result_placements) result_placements = snap_theta_safe(result_placements, devices, lab, checker)
# 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(
devices, result_placements, lab, checker, graduated=False, devices, result_placements, lab, checker, graduated=False,
) )
# 也检查用户硬约束binary 模式)
if constraints and not math.isinf(final_cost):
user_hard_cost = evaluate_constraints(
devices, result_placements, lab, constraints, checker,
graduated=False,
)
if math.isinf(user_hard_cost):
final_cost = math.inf
return OptimizeResponse( return OptimizeResponse(
placements=[ placements=[

View File

@@ -0,0 +1,241 @@
"""Tests for broad_phase.py — 2 轴 sweep-and-prune 宽相碰撞检测。"""
from __future__ import annotations
import math
import random
import pytest
from ..broad_phase import broad_phase_device_pairs, sweep_and_prune_pairs
from ..models import Device, Placement
# ---------------------------------------------------------------------------
# 测试用辅助函数
# ---------------------------------------------------------------------------
def _make_device(device_id: str, w: float = 0.6, d: float = 0.4) -> Device:
"""创建简单测试设备。"""
return Device(id=device_id, name=device_id, bbox=(w, d))
def _make_placement(
device_id: str, x: float, y: float, theta: float = 0.0
) -> Placement:
"""创建简单测试放置。"""
return Placement(device_id=device_id, x=x, y=y, theta=theta)
# ---------------------------------------------------------------------------
# 测试类
# ---------------------------------------------------------------------------
class TestNoOverlap:
"""两台设备距离足够远,宽相不返回候选对。"""
def test_no_overlap_returns_empty(self):
"""水平方向间距远大于 AABB 尺寸 → 0 候选对。"""
devices = [_make_device("A", 1.0, 1.0), _make_device("B", 1.0, 1.0)]
placements = [
_make_placement("A", 0.0, 0.0),
_make_placement("B", 10.0, 0.0),
]
pairs = sweep_and_prune_pairs(devices, placements)
assert pairs == []
class TestOverlapping:
"""两台设备 AABB 明显重叠。"""
def test_overlapping_devices_returned(self):
"""两台 1×1 设备中心距 0.5m → 1 候选对。"""
devices = [_make_device("A", 1.0, 1.0), _make_device("B", 1.0, 1.0)]
placements = [
_make_placement("A", 0.0, 0.0),
_make_placement("B", 0.5, 0.0),
]
pairs = sweep_and_prune_pairs(devices, placements)
assert len(pairs) == 1
assert pairs[0] == (0, 1)
class TestXOverlapYNoOverlap:
"""x 轴投影交叠但 y 轴不交叠,应被 y 轴检查剪枝。"""
def test_x_overlap_y_no_overlap(self):
"""水平接近但垂直方向偏移足够大 → 0 候选对。"""
devices = [_make_device("A", 2.0, 1.0), _make_device("B", 2.0, 1.0)]
placements = [
_make_placement("A", 0.0, 0.0),
_make_placement("B", 0.5, 5.0), # x 轴交叠但 y 轴相距很远
]
pairs = sweep_and_prune_pairs(devices, placements)
assert pairs == []
class TestTouchingDevices:
"""AABB 恰好接触(边缘距离 = 0应作为候选对返回。"""
def test_touching_devices_included(self):
"""两个 1×1 设备中心距恰好为 1.0(半宽 0.5 + 0.5 = 1.0
AABB 边界接触 → 应包含在候选对中(<= 判定)。"""
devices = [_make_device("A", 1.0, 1.0), _make_device("B", 1.0, 1.0)]
placements = [
_make_placement("A", 0.0, 0.0),
_make_placement("B", 1.0, 0.0), # xmax_A = 0.5, xmin_B = 0.5 → 接触
]
pairs = sweep_and_prune_pairs(devices, placements)
# 接触算作潜在碰撞,安全起见需保留
assert len(pairs) == 1
assert pairs[0] == (0, 1)
class TestMultipleDevices:
"""4 台设备验证精确的候选对列表。"""
def test_multiple_devices_correct_pairs(self):
"""排列 4 台设备,只有特定配对 AABB 交叠。
布局1×1 设备):
A(0,0) B(0.8,0) — A-B 交叠(中心距 0.8 < 1.0
C(0,5) — 远离 A、B
D(0.9,5) — C-D 交叠(中心距 0.9 < 1.0
期望候选对: (A,B) 和 (C,D)。
"""
devices = [
_make_device("A", 1.0, 1.0),
_make_device("B", 1.0, 1.0),
_make_device("C", 1.0, 1.0),
_make_device("D", 1.0, 1.0),
]
placements = [
_make_placement("A", 0.0, 0.0),
_make_placement("B", 0.8, 0.0),
_make_placement("C", 0.0, 5.0),
_make_placement("D", 0.9, 5.0),
]
pairs = sweep_and_prune_pairs(devices, placements)
pair_set = set(pairs)
assert (0, 1) in pair_set # A-B
assert (2, 3) in pair_set # C-D
assert len(pair_set) == 2
class TestRotatedDeviceAabb:
"""旋转设备导致 AABB 变大,命中候选对。"""
def test_rotated_device_aabb(self):
"""一台窄长设备 (2.0×0.2)
- 未旋转时 AABB 半宽 = 1.0,两设备中心距 2.5 → 不交叠
- 旋转 90° 后 AABB 半宽 = 0.1,半深 = 1.0 → 仍不交叠
- 旋转 45° 后 AABB 半宽 ≈ (2*cos45 + 0.2*sin45)/2 ≈ 0.778
但另一台放在 x=1.6,半宽 = 1.0
所以 xmax_A = 0 + 0.778 = 0.778 < 1.6 - 1.0 = 0.6 → 不够
更好的方案:用中心距 1.5,未旋转时不交叠,旋转后交叠。
未旋转: half_w_A = 0.3 (bbox 0.6x2.0), half_w_B = 0.3
A: xmax = 0 + 0.3 = 0.3, B: xmin = 1.5 - 0.3 = 1.2 → 不交叠
旋转 90°: A 的 bbox (0.6, 2.0) → half_w = (0.6*0 + 2.0*1)/2 = 1.0
A: xmax = 0 + 1.0 = 1.0, B: xmin = 1.5 - 0.3 = 1.2 → 仍不交叠
用 bbox (0.4, 2.0),间距 1.2
未旋转: half_w = 0.2, xmax_A = 0.2, xmin_B = 1.2 - 0.2 = 1.0 → 不交叠
旋转 45°: half_w = (0.4*cos45 + 2.0*sin45)/2 = (0.283+1.414)/2 = 0.849
xmax_A = 0.849, xmin_B = 1.2 - 0.2 = 1.0 → 不交叠
间距 0.8
未旋转: xmax_A = 0.2, xmin_B = 0.8 - 0.2 = 0.6 → 不交叠 ✓
旋转 45°: xmax_A = 0.849, xmin_B = 0.6 → 交叠 ✓ (0.849 > 0.6)
y 轴: half_d_A_rot = (0.4*sin45 + 2.0*cos45)/2 = 0.849, half_d_B = 1.0
ymax_A = 0.849, ymin_B = -1.0 → 交叠 ✓
"""
dev_narrow = _make_device("narrow", 0.4, 2.0)
dev_normal = _make_device("normal", 0.4, 2.0)
# 未旋转:不交叠
placements_no_rot = [
_make_placement("narrow", 0.0, 0.0, theta=0.0),
_make_placement("normal", 0.8, 0.0, theta=0.0),
]
assert sweep_and_prune_pairs([dev_narrow, dev_normal], placements_no_rot) == []
# narrow 旋转 45° → AABB 变大 → 交叠
placements_rot = [
_make_placement("narrow", 0.0, 0.0, theta=math.pi / 4),
_make_placement("normal", 0.8, 0.0, theta=0.0),
]
pairs = sweep_and_prune_pairs([dev_narrow, dev_normal], placements_rot)
assert len(pairs) == 1
class TestOriginalIndices:
"""验证返回的索引对应 placements 原始顺序而非排序后顺序。"""
def test_sorted_output_preserves_original_indices(self):
"""故意让 placements 按 x 坐标逆序排列,
验证返回的索引仍是原始顺序。"""
devices = [
_make_device("A", 1.0, 1.0),
_make_device("B", 1.0, 1.0),
_make_device("C", 1.0, 1.0),
]
# 逆序排列C 在最左A 在最右
placements = [
_make_placement("A", 5.0, 0.0), # idx 0, 最右
_make_placement("B", 4.5, 0.0), # idx 1, 中间(与 A 交叠)
_make_placement("C", 0.0, 0.0), # idx 2, 最左(独立)
]
pairs = sweep_and_prune_pairs(devices, placements)
# A(idx=0) 和 B(idx=1) AABB 交叠,索引应为 (0, 1)
assert len(pairs) == 1
assert pairs[0] == (0, 1)
# 同时验证 broad_phase_device_pairs 返回正确 device_id
id_pairs = broad_phase_device_pairs(devices, placements)
assert id_pairs == [("A", "B")]
class TestPairCountReduction:
"""大规模随机测试:宽相候选对数应远小于 N*(N-1)/2。"""
def test_pair_count_reduction(self):
"""N=15 台设备随机放置在 10×10 实验室 → 候选对数显著少于全量。"""
random.seed(42)
n = 15
devices = [_make_device(f"D{i}", 0.5, 0.5) for i in range(n)]
placements = [
_make_placement(f"D{i}", random.uniform(0, 10), random.uniform(0, 10))
for i in range(n)
]
pairs = sweep_and_prune_pairs(devices, placements)
full_pairs = n * (n - 1) // 2 # = 105
# 在 10×10 区域放 15 台 0.5×0.5 设备,交叠率应很低
assert len(pairs) < full_pairs
# 额外断言:候选对数不超过全量的一半(保守判定)
assert len(pairs) < full_pairs * 0.5
class TestEdgeCases:
"""边界情况。"""
def test_empty_input(self):
"""空列表 → 空结果。"""
assert sweep_and_prune_pairs([], []) == []
def test_single_device(self):
"""单台设备 → 无候选对。"""
devices = [_make_device("A")]
placements = [_make_placement("A", 0.0, 0.0)]
assert sweep_and_prune_pairs(devices, placements) == []
def test_identical_positions(self):
"""两台设备完全重叠 → 1 候选对。"""
devices = [_make_device("A", 1.0, 1.0), _make_device("B", 1.0, 1.0)]
placements = [
_make_placement("A", 0.0, 0.0),
_make_placement("B", 0.0, 0.0),
]
pairs = sweep_and_prune_pairs(devices, placements)
assert len(pairs) == 1

View File

@@ -90,9 +90,16 @@ class TestDuplicateDeviceIDs:
lab = Lab(width=5, depth=5) lab = Lab(width=5, depth=5)
constraints = [Constraint(type="hard", rule_name="min_spacing", constraints = [Constraint(type="hard", rule_name="min_spacing",
params={"min_gap": 0.05})] params={"min_gap": 0.05})]
# graduated=True (default): 返回有限惩罚
cost = evaluate_constraints(devices, stacked, lab, constraints, cost = evaluate_constraints(devices, stacked, lab, constraints,
MockCollisionChecker()) MockCollisionChecker())
assert math.isinf(cost) assert cost > 0
assert not math.isinf(cost)
# graduated=False: binary inf
cost_binary = evaluate_constraints(devices, stacked, lab, constraints,
MockCollisionChecker(),
graduated=False)
assert math.isinf(cost_binary)
def test_create_devices_uses_uuid(self): def test_create_devices_uses_uuid(self):
"""create_devices_from_list should use uuid as Device.id.""" """create_devices_from_list should use uuid as Device.id."""

View File

@@ -123,7 +123,7 @@ class TestUserConstraints:
assert cost == 0.0 assert cost == 0.0
def test_distance_less_than_violated_hard(self): def test_distance_less_than_violated_hard(self):
"""硬距离约束违反返回 inf。""" """硬距离约束违反graduated模式返回有限惩罚binary模式返回inf。"""
devices = _make_devices() devices = _make_devices()
placements = [ placements = [
Placement("a", 1.0, 1.0, 0.0), Placement("a", 1.0, 1.0, 0.0),
@@ -134,10 +134,18 @@ class TestUserConstraints:
params={"device_a": "a", "device_b": "b", "distance": 1.0}) params={"device_a": "a", "device_b": "b", "distance": 1.0})
] ]
checker = MockCollisionChecker() checker = MockCollisionChecker()
# graduated=True (default): 有限惩罚
cost = evaluate_constraints( cost = evaluate_constraints(
devices, placements, _make_lab(), constraints, checker devices, placements, _make_lab(), constraints, checker
) )
assert math.isinf(cost) assert cost > 0
assert not math.isinf(cost)
# graduated=False: binary inf
cost_binary = evaluate_constraints(
devices, placements, _make_lab(), constraints, checker,
graduated=False,
)
assert math.isinf(cost_binary)
def test_minimize_distance_cost(self): def test_minimize_distance_cost(self):
"""minimize_distance 约束应返回正比于距离的 cost。""" """minimize_distance 约束应返回正比于距离的 cost。"""
@@ -184,7 +192,7 @@ class TestUserConstraints:
assert not math.isinf(cost) # reachable → no hard failure assert not math.isinf(cost) # reachable → no hard failure
def test_reachability_constraint_violated(self): def test_reachability_constraint_violated(self):
"""可达性约束:目标超出臂展返回 inf。""" """可达性约束:目标超出臂展 — graduated返回有限惩罚binary返回inf。"""
devices = [ devices = [
Device(id="arm", name="Arm", bbox=(0.2, 0.2), device_type="articulation"), Device(id="arm", name="Arm", bbox=(0.2, 0.2), device_type="articulation"),
Device(id="target", name="Target", bbox=(0.5, 0.5)), Device(id="target", name="Target", bbox=(0.5, 0.5)),
@@ -199,10 +207,18 @@ class TestUserConstraints:
] ]
checker = MockCollisionChecker() checker = MockCollisionChecker()
reachability = MockReachabilityChecker(arm_reach={"arm": 1.0}) reachability = MockReachabilityChecker(arm_reach={"arm": 1.0})
# graduated=True (default): 有限惩罚
cost = evaluate_constraints( cost = evaluate_constraints(
devices, placements, _make_lab(), constraints, checker, reachability devices, placements, _make_lab(), constraints, checker, reachability
) )
assert math.isinf(cost) assert cost > 0
assert not math.isinf(cost)
# graduated=False: binary inf
cost_binary = evaluate_constraints(
devices, placements, _make_lab(), constraints, checker, reachability,
graduated=False,
)
assert math.isinf(cost_binary)
def test_distance_less_than_uses_edge_to_edge(): def test_distance_less_than_uses_edge_to_edge():
@@ -272,3 +288,136 @@ def test_prefer_aligned_sums_over_devices():
cost = evaluate_constraints(devices, placements, lab, [constraint], checker) cost = evaluate_constraints(devices, placements, lab, [constraint], checker)
# 2 devices × 1.0 × weight 2.0 = 4.0 # 2 devices × 1.0 × weight 2.0 = 4.0
assert cost == pytest.approx(4.0) assert cost == pytest.approx(4.0)
class TestGraduatedHardConstraints:
"""graduated 模式下硬约束返回比例惩罚而非 inf。"""
def test_hard_reachability_graduated_finite(self):
"""graduated=True: 硬可达性返回有限惩罚。"""
devices = [
Device(id="arm", name="Arm", bbox=(0.2, 0.2), device_type="articulation"),
Device(id="t", name="Target", bbox=(0.5, 0.5)),
]
placements = [
Placement("arm", 1.0, 1.0, 0.0),
Placement("t", 4.0, 3.0, 0.0),
]
constraints = [
Constraint(type="hard", rule_name="reachability",
params={"arm_id": "arm", "target_device_id": "t"}, weight=1.0)
]
checker = MockCollisionChecker()
reach = MockReachabilityChecker(arm_reach={"arm": 1.0})
cost = evaluate_constraints(
devices, placements, _make_lab(), constraints, checker, reach,
graduated=True,
)
assert cost > 0
assert not math.isinf(cost)
def test_hard_reachability_binary_inf(self):
"""graduated=False: 硬可达性返回 inf。"""
devices = [
Device(id="arm", name="Arm", bbox=(0.2, 0.2), device_type="articulation"),
Device(id="t", name="Target", bbox=(0.5, 0.5)),
]
placements = [
Placement("arm", 1.0, 1.0, 0.0),
Placement("t", 4.0, 3.0, 0.0),
]
constraints = [
Constraint(type="hard", rule_name="reachability",
params={"arm_id": "arm", "target_device_id": "t"}, weight=1.0)
]
checker = MockCollisionChecker()
reach = MockReachabilityChecker(arm_reach={"arm": 1.0})
cost = evaluate_constraints(
devices, placements, _make_lab(), constraints, checker, reach,
graduated=False,
)
assert math.isinf(cost)
def test_hard_min_spacing_graduated_sums_all_pairs(self):
"""graduated模式min_spacing 对所有违规对求和(不只第一对)。"""
devices = [
Device(id="a", name="A", bbox=(0.5, 0.5)),
Device(id="b", name="B", bbox=(0.5, 0.5)),
Device(id="c", name="C", bbox=(0.5, 0.5)),
]
# 三个设备间距都小于 min_gap=1.0
placements = [
Placement("a", 1.0, 2.0, 0.0),
Placement("b", 1.3, 2.0, 0.0), # OBB 边缘距 a 约 0.3
Placement("c", 1.6, 2.0, 0.0), # OBB 边缘距 b 约 0.3, 距 a 约 0.6
]
constraints = [
Constraint(type="hard", rule_name="min_spacing",
params={"min_gap": 1.0}, weight=1.0)
]
checker = MockCollisionChecker()
cost = evaluate_constraints(
devices, placements, _make_lab(), constraints, checker,
graduated=True,
)
# 应大于 0 且有限(累加多对违规)
assert cost > 0
assert not math.isinf(cost)
def test_hard_min_spacing_binary_inf(self):
"""graduated=False: min_spacing 违规返回 inf。"""
devices = _make_devices()
placements = [
Placement("a", 1.0, 2.0, 0.0),
Placement("b", 1.3, 2.0, 0.0),
]
constraints = [
Constraint(type="hard", rule_name="min_spacing",
params={"min_gap": 1.0}, weight=1.0)
]
checker = MockCollisionChecker()
cost = evaluate_constraints(
devices, placements, _make_lab(), constraints, checker,
graduated=False,
)
assert math.isinf(cost)
def test_hard_distance_less_than_graduated(self):
"""graduated模式distance_less_than 硬约束返回比例惩罚。"""
devices = _make_devices()
placements = [
Placement("a", 1.0, 2.0, 0.0),
Placement("b", 4.0, 2.0, 0.0),
]
constraints = [
Constraint(type="hard", rule_name="distance_less_than",
params={"device_a": "a", "device_b": "b", "distance": 0.5},
weight=2.0)
]
checker = MockCollisionChecker()
cost = evaluate_constraints(
devices, placements, _make_lab(), constraints, checker,
graduated=True,
)
# HARD_MULTIPLIER(5) × weight(2) × overshoot > 0
assert cost > 0
assert not math.isinf(cost)
def test_graduated_default_is_true(self):
"""不传 graduated 参数时默认使用 graduated 模式。"""
devices = _make_devices()
placements = [
Placement("a", 1.0, 2.0, 0.0),
Placement("b", 4.0, 2.0, 0.0),
]
constraints = [
Constraint(type="hard", rule_name="distance_less_than",
params={"device_a": "a", "device_b": "b", "distance": 0.5},
weight=1.0)
]
checker = MockCollisionChecker()
# 不指定 graduated — 默认应为 True → 有限惩罚
cost = evaluate_constraints(
devices, placements, _make_lab(), constraints, checker,
)
assert not math.isinf(cost)

View File

@@ -185,9 +185,9 @@ class TestStage3VerifyPlacements:
def test_no_hard_constraint_violation(self): def test_no_hard_constraint_violation(self):
"""Full pipeline with all intents including reachability converges cleanly. """Full pipeline with all intents including reachability converges cleanly.
MockReachabilityChecker uses large fallback reach for unknown arms, MockReachabilityChecker now includes arm_slider in the default reach table
so arm_slider reachability constraints are satisfied in mock mode. (1.07m). Binary final evaluation checks all hard constraints including
When real ROS checkers replace mock, this test validates the same pipeline. user-defined reachability.
""" """
interpret_data = client.post("/interpret", json={"intents": LLM_INTENTS}).json() interpret_data = client.post("/interpret", json={"intents": LLM_INTENTS}).json()
@@ -197,7 +197,7 @@ class TestStage3VerifyPlacements:
"constraints": interpret_data["constraints"], "constraints": interpret_data["constraints"],
"workflow_edges": interpret_data["workflow_edges"], "workflow_edges": interpret_data["workflow_edges"],
"run_de": True, "run_de": True,
"maxiter": 50, "maxiter": 100,
"seed": 42, "seed": 42,
}) })
data = optimize_resp.json() data = optimize_resp.json()

View File

@@ -4,8 +4,9 @@ import math
from ..mock_checkers import MockCollisionChecker from ..mock_checkers import MockCollisionChecker
from ..models import Device, Lab, Placement from ..models import Device, Lab, Placement
import numpy as np
import pytest import pytest
from ..optimizer import optimize, snap_theta from ..optimizer import _run_de, optimize, snap_theta
def test_optimize_three_devices_no_collision(): def test_optimize_three_devices_no_collision():
@@ -217,3 +218,224 @@ def test_full_pipeline_with_de():
for p in result: for p in result:
assert 0 <= p.x <= lab.width assert 0 <= p.x <= lab.width
assert 0 <= p.y <= lab.depth 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]
# 检查每个设备的三元组要么全是 0parent要么全是 10mutant
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