2
This commit is contained in:
@@ -72,7 +72,9 @@ class SearchConfig:
|
||||
grid_step_m: float = 700.0 # P4 覆盖网格步长;单元对角 700*sqrt(2)≈989.95<=1000
|
||||
grid_half_cells: int = 3 # i,j ∈ [-3,3],49 点覆盖 [-2100,2100]^2 ⊇ Ω
|
||||
fallback_clear_grid_m: float = 20.0 # P5 后备清除网格;10*sqrt(2)<=20
|
||||
bearing_rounding_margin_deg: float = 0.0 # 默认不加舍入裕量;敏感性分析时显式设 0.005
|
||||
# 舍入裕量:服务器对示向度保留两位小数,引入 <=0.005° 偏差 [题面];
|
||||
# 不加裕量时合法真源可能被角域裁掉,故库默认与 CLI run 默认一致取 0.005 [工程]。
|
||||
bearing_rounding_margin_deg: float = 0.005
|
||||
# ---- P3/P4 在线定位与闭环参数 [工程] ----
|
||||
polygon_sides: int = 192 # 圆盘外切多边形边数;Ω 近似误差 1800(1-cos(π/192))≈0.24m
|
||||
witness_spacing_m: float = 60.0 # 最坏后验评估的边界采样间距;选定后再按 1/4 加密复核
|
||||
@@ -82,7 +84,8 @@ class SearchConfig:
|
||||
max_localize_steps: int = 8 # 单频道定位阶段允许的追加测量上限
|
||||
max_anomalies_per_channel: int = 3 # 单频道异常记录上限,防止异常刷屏
|
||||
# ---- P5 定向扩展参数 [工程] ----
|
||||
max_blocked_probes: int = 4 # 定位单轮内被朝向遮挡的探点预算(对跖复核+环探)
|
||||
max_blocked_probes: int = 4 # 定位单轮内被朝向遮挡的探点预算
|
||||
# (首次选点+对跖复核+2 次环探;达限转后备清除)
|
||||
fallback_max_cells: int = 400 # 后备清除格数硬顶;超过则不盲扫(宽候选集继续定位)
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
All calculations are floating point with normalized station coordinates.
|
||||
The reference algorithm deliberately favors auditability over asymptotic speed.
|
||||
Near-parallel plane pairs are intersected instead of skipped (only exactly
|
||||
parallel pairs are discarded), and the feasibility tolerance scales with the
|
||||
candidate vertex magnitude, so legitimate far-away vertices of long thin
|
||||
regions are never silently dropped.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
@@ -180,6 +184,13 @@ def solve(measurements: list[Measurement], eps: float = EPS) -> dict:
|
||||
so they possess a vertex, even if unbounded. Feasibility follows from vertex
|
||||
enumeration; unboundedness follows from a nonzero recession direction.
|
||||
Empty diameter is null, unbounded diameter is null with diameter_infinite=true.
|
||||
|
||||
Near-parallel plane pairs are intersected rather than skipped: their
|
||||
intersection can be a legitimate vertex of a very long thin region
|
||||
(e.g. stations 1cm apart with bearings 1 and 358.99999999999 deg give a
|
||||
vertex ~5.7e10 m away). Only exactly-parallel pairs (det == 0) have no
|
||||
intersection. The feasibility tolerance scales with the vertex magnitude,
|
||||
since absolute residuals grow with coordinates.
|
||||
"""
|
||||
if not isfinite(eps) or eps <= 0:
|
||||
raise ValueError("eps必须为有限正数")
|
||||
@@ -197,15 +208,16 @@ def solve(measurements: list[Measurement], eps: float = EPS) -> dict:
|
||||
planes = [h for m in local for h in halfplanes(m)]
|
||||
|
||||
def inside(p):
|
||||
return all(a * p[0] + b * p[1] <= c + eps for a, b, c in planes)
|
||||
tol = eps * max(1.0, abs(p[0]), abs(p[1]))
|
||||
return all(a * p[0] + b * p[1] <= c + tol for a, b, c in planes)
|
||||
|
||||
feasible_vertices = []
|
||||
for (a, b, c), (d, e, f) in combinations(planes, 2):
|
||||
det = a * e - b * d
|
||||
if abs(det) <= PARALLEL_EPS:
|
||||
continue
|
||||
if det == 0.0:
|
||||
continue # 精确平行:无交点(近平行对的交点是合法远端顶点,保留)
|
||||
p = ((c * e - b * f) / det, (a * f - c * d) / det)
|
||||
if inside(p):
|
||||
if isfinite(p[0]) and isfinite(p[1]) and inside(p):
|
||||
feasible_vertices.append(p)
|
||||
|
||||
if not feasible_vertices:
|
||||
|
||||
@@ -14,6 +14,8 @@ P4 全向闭环(验收:10/16 源正确退出;边界朝外不漏;停止
|
||||
(清除数达 16,或每频道均有清除成功/不存在证明)→ /exit。
|
||||
发现 10 源、连续无信号、候选采样为空都不是停止条件;
|
||||
现实预算耗尽退出记为未证明完成(completed=false),不与成功混记。
|
||||
断连/协议错误/守恒性破坏中断时同样尽力 /exit 并产出未完成 summary
|
||||
(stop_reason 记中断类别,附逐频道状态快照),不静默丢失会话。
|
||||
|
||||
P5 定向扩展(问题4混合源闭环):
|
||||
- 49 点覆盖的发现保证对定向源同样成立(源在单元凸包内,任意发射半平面
|
||||
@@ -31,13 +33,15 @@ import math
|
||||
from dataclasses import dataclass
|
||||
|
||||
from ..belief import (BeliefState, CandidateRegion, ChannelStatus,
|
||||
clip_halfplane)
|
||||
InconsistentBelief, clip_halfplane)
|
||||
from ..config import ProblemConfig, Vec2
|
||||
from ..coverage import CoverageLedger
|
||||
from ..evaluation import recompute_virtual_time, summarize
|
||||
from ..protocol.client import DeadlineExceeded, SimulatorClient
|
||||
from ..protocol.client import (DeadlineExceeded, ProtocolError,
|
||||
SimulatorClient)
|
||||
from ..protocol.rules import CLEAR_SUCCESS, DIRECTION, NEAR, NO_SIGNAL
|
||||
from ..protocol.timing import move_time
|
||||
from ..protocol.transport import TransportError
|
||||
|
||||
|
||||
def bearing_deg(a: Vec2, b: Vec2) -> float:
|
||||
@@ -45,6 +49,11 @@ def bearing_deg(a: Vec2, b: Vec2) -> float:
|
||||
return math.degrees(math.atan2(b[1] - a[1], b[0] - a[0])) % 360.0
|
||||
|
||||
|
||||
def _error_brief(exc: BaseException) -> dict:
|
||||
"""异常摘要(summary 留档用):类型名 + 截断消息。"""
|
||||
return {"type": type(exc).__name__, "message": str(exc)[:500]}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SecondPointPlan:
|
||||
"""下一检测点的候选与依据(P3 输出)。"""
|
||||
@@ -91,11 +100,14 @@ def candidate_points(center: Vec2, safe_radius_m: float, rings, angles: int) ->
|
||||
|
||||
|
||||
def _worst_posterior_radius(region: CandidateRegion, q: Vec2,
|
||||
witnesses: list[Vec2], half_angle_deg: float) -> float:
|
||||
"""对候选点 q,遍历"目标边界点 × 读数区间端点"的最坏后验包围半径。
|
||||
witnesses: list[Vec2], half_angle_deg: float,
|
||||
near_radius_m: float, disk_sides: int) -> float:
|
||||
"""对候选点 q,遍历"目标边界点 × 读数分支"的最坏后验包围半径。
|
||||
|
||||
读数 θ' 与目标 g 一致当且仅当 |θ' - bearing(q→g)| <= δ,因此端点
|
||||
φ(g)±δ 是每个 g 的最不利读数。这是启发式近似,不构成最优性证明 [工程]。
|
||||
读数分支:direction 的区间端点 φ(g)±δ(端点是每个 g 的最不利读数),
|
||||
以及 near——源落在 q 的 near_radius_m 内时后验为 B(q, near)∩候选集。
|
||||
保证接收盘内 no_signal 不可能发生,故不枚举该分支。
|
||||
这是启发式近似,不构成最优性证明 [工程]。
|
||||
"""
|
||||
posterior_radii = []
|
||||
for g in witnesses:
|
||||
@@ -105,6 +117,14 @@ def _worst_posterior_radius(region: CandidateRegion, q: Vec2,
|
||||
probe.clip_wedge(q, reading, half_angle_deg)
|
||||
if probe.polygon:
|
||||
posterior_radii.append(probe.mec()[1])
|
||||
near_probe = CandidateRegion(list(region.polygon), region.slack_m)
|
||||
try:
|
||||
near_probe.clip_disk(q, near_radius_m, disk_sides)
|
||||
except InconsistentBelief:
|
||||
pass # B(q, near) 与候选集不相交:near 分支不可能发生,跳过
|
||||
else:
|
||||
if near_probe.polygon:
|
||||
posterior_radii.append(near_probe.mec()[1])
|
||||
if not posterior_radii:
|
||||
return region.mec()[1] # 无有效采样点时退回当前半径
|
||||
return max(posterior_radii)
|
||||
@@ -144,15 +164,17 @@ def select_measurement_point(region: CandidateRegion, from_position: Vec2,
|
||||
coarse = region.witnesses(search.witness_spacing_m)
|
||||
samples = candidate_points(center, safe_radius,
|
||||
search.candidate_rings, search.candidate_angles)
|
||||
near_radius, sides = field.near_radius_m, search.polygon_sides
|
||||
best_key, best_point, best_worst = None, None, None
|
||||
for point in samples:
|
||||
worst = _worst_posterior_radius(region, point, coarse, half_angle)
|
||||
worst = _worst_posterior_radius(region, point, coarse, half_angle,
|
||||
near_radius, sides)
|
||||
key = (round(worst, 6), round(move_cost(point), 6))
|
||||
if best_key is None or key < best_key:
|
||||
best_key, best_point, best_worst = key, point, worst
|
||||
dense = _worst_posterior_radius(region, best_point,
|
||||
region.witnesses(search.witness_spacing_m / 4),
|
||||
half_angle)
|
||||
half_angle, near_radius, sides)
|
||||
reason = (f"安全盘 B(centre,{safe_radius:.0f}m) 内 {len(samples)} 候选;"
|
||||
f"粗采样最坏后验 {best_worst:.1f}m,1/4 间距加密复核 {dense:.1f}m")
|
||||
return SecondPointPlan(tuple(best_point), dense, move_cost(best_point), reason)
|
||||
@@ -190,19 +212,41 @@ class OmnidirectionalLoop:
|
||||
# 证书有效但 /clear 失败后必须先补测再重试,避免在同一证书上
|
||||
# 反复消耗 3 秒/次的失败清除。
|
||||
self._may_clear: set[int] = set()
|
||||
# 断连/协议错误/守恒性破坏等中断:留档异常,收尾时写入 summary。
|
||||
self._abort_detail: BaseException | None = None
|
||||
|
||||
# ------------------------------------------------------------ 主流程
|
||||
def run(self) -> dict:
|
||||
self.client.enter()
|
||||
"""主闭环。任何异常都必须尽力 /exit 并产出 summary,不得静默丢会话。"""
|
||||
try:
|
||||
self.client.enter()
|
||||
self._coverage_phase()
|
||||
if self.stop_reason is None:
|
||||
self._mark_absent_after_coverage()
|
||||
self.stop_reason = self._resolve_detected()
|
||||
except DeadlineExceeded:
|
||||
self.stop_reason = "real_budget_exhausted"
|
||||
info = self.client.exit()
|
||||
return self._summary(info)
|
||||
except (TransportError, ProtocolError, InconsistentBelief) as exc:
|
||||
self._abort_detail = exc
|
||||
if isinstance(exc, TransportError):
|
||||
self.stop_reason = "transport_failure"
|
||||
elif isinstance(exc, InconsistentBelief):
|
||||
self.stop_reason = "inconsistent_belief"
|
||||
else:
|
||||
self.stop_reason = "protocol_error"
|
||||
except Exception as exc: # 意外异常同样收尾,不阻断 /exit 与结果汇总
|
||||
self._abort_detail = exc
|
||||
self.stop_reason = "unexpected_error"
|
||||
info = None
|
||||
exit_error = None
|
||||
try:
|
||||
info = self.client.exit()
|
||||
except Exception as exc: # 接口不可用:记录后仍产出未完成状态
|
||||
exit_error = exc
|
||||
summary = self._summary(info)
|
||||
if exit_error is not None:
|
||||
summary["exit_error"] = _error_brief(exit_error)
|
||||
return summary
|
||||
|
||||
# ------------------------------------------------------------ 覆盖阶段
|
||||
def _pending_channels_at(self, index: int) -> list[int]:
|
||||
@@ -321,6 +365,7 @@ class OmnidirectionalLoop:
|
||||
return "all_resolved" if not unresolved else "unresolved_channels"
|
||||
|
||||
def _summary(self, info) -> dict:
|
||||
"""结果汇总。info 为 None 表示 /exit 未能完成(断连等),按未完成上报。"""
|
||||
events = self.client.log.action_events()
|
||||
stats = summarize(events)
|
||||
report = recompute_virtual_time(events)
|
||||
@@ -331,21 +376,37 @@ class OmnidirectionalLoop:
|
||||
if channel not in resolved]
|
||||
completed = (self.stop_reason == "sixteen_cleared"
|
||||
or (self.stop_reason == "all_resolved" and not unresolved))
|
||||
return {
|
||||
summary = {
|
||||
"completed": completed,
|
||||
"stop_reason": self.stop_reason,
|
||||
"exit_reason": info.reason,
|
||||
"virtual_time_s": round(info.virtual_time_s, 3),
|
||||
"exit_reason": "exit_unreachable" if info is None else info.reason,
|
||||
"virtual_time_s": round((info.virtual_time_s if info is not None
|
||||
else self.client.state.virtual_time_s), 3),
|
||||
"cleared_channels": cleared,
|
||||
"absent_channels": absent,
|
||||
"unresolved_channels": unresolved,
|
||||
"measures": stats["measures"],
|
||||
"clears": stats["clears"],
|
||||
"clear_success": stats["cleared"],
|
||||
"total_requests": stats["measures"] + stats["clears"],
|
||||
"move_distance_m": round(stats["distance_m"], 1),
|
||||
"anomalies": self.anomalies,
|
||||
"virtual_recompute_mismatches": len(report["mismatches"]),
|
||||
"channel_status": self._channel_status_snapshot(),
|
||||
}
|
||||
if self._abort_detail is not None:
|
||||
summary["abort_detail"] = _error_brief(self._abort_detail)
|
||||
return summary
|
||||
|
||||
def _channel_status_snapshot(self) -> dict:
|
||||
"""逐频道状态快照:中断收尾时的未完成状态留档(每频道一条)。"""
|
||||
snapshot = {}
|
||||
for channel, belief in sorted(self.belief.channels.items()):
|
||||
entry = {"status": belief.status.value}
|
||||
if belief.region is not None:
|
||||
entry["posterior_radius_m"] = round(belief.region.mec()[1], 3)
|
||||
snapshot[str(channel)] = entry
|
||||
return snapshot
|
||||
|
||||
|
||||
class DirectionalExtension(OmnidirectionalLoop):
|
||||
@@ -389,7 +450,7 @@ class DirectionalExtension(OmnidirectionalLoop):
|
||||
return True
|
||||
self._may_clear.discard(channel) # 证书失败:补测后才可重试
|
||||
continue
|
||||
if len(self.blocked_probes.get(channel, [])) > search.max_blocked_probes:
|
||||
if len(self.blocked_probes.get(channel, [])) >= search.max_blocked_probes:
|
||||
break # 梯子用尽仍被遮挡:定位停滞,转后备清除
|
||||
point = self._next_probe(region, channel)
|
||||
outcome = self.client.measure(point, channel)
|
||||
@@ -424,11 +485,12 @@ class DirectionalExtension(OmnidirectionalLoop):
|
||||
"""定向遮挡下的探点升级梯子 [工程]。
|
||||
|
||||
- 未被遮挡过:worst-posterior 选点器(保证接收盘内);
|
||||
- 第 1 次被遮挡:对 MEC 圆心取对跖点,仍在保证接收盘内;
|
||||
- 第 1 次被遮挡:对 MEC 圆心取对跖方向,偏移裁进保证接收盘;
|
||||
- 之后:绕 MEC 圆心、半径 = ρ 的环探,方位自首个被遮挡点起
|
||||
每次 +45°。环包围任意候选源(|g-c| <= ρ),发射半平面在环上
|
||||
截出 >=180° 弧,等距采样大概率落入;环点距离不构成接收保证,
|
||||
失败由后备清除兜底。
|
||||
每次 +45°。几何边界必须如实陈述:环上可见弧没有下界——
|
||||
源可位于环上(|g-c| = ρ)且朝向沿外切线,可见集退化为单个
|
||||
切点,固定步进环探可以全部漏过。梯子只提高提前命中概率,
|
||||
不构成接收或命中保证;完备性完全由后备清除兜底。
|
||||
"""
|
||||
blocked = self.blocked_probes.get(channel, [])
|
||||
center, radius = region.mec()
|
||||
@@ -441,10 +503,11 @@ class DirectionalExtension(OmnidirectionalLoop):
|
||||
dx, dy = center[0] - first[0], center[1] - first[1]
|
||||
norm = math.hypot(dx, dy)
|
||||
if norm <= 1e-9:
|
||||
probe = min(safe, 1.0)
|
||||
return (center[0] + probe, center[1])
|
||||
# 首个被遮挡探点恰在圆心:方向信息为零,沿 +x 走满安全盘。
|
||||
return (center[0] + safe, center[1])
|
||||
scale = min(1.0, safe / norm)
|
||||
return (center[0] + dx / norm * scale, center[1] + dy / norm * scale)
|
||||
# 对跖方向,偏移模长 = min(norm, safe):圆心对称点裁进保证接收盘。
|
||||
return (center[0] + dx * scale, center[1] + dy * scale)
|
||||
base = math.atan2(first[1] - center[1], first[0] - center[0])
|
||||
azimuth = base + math.radians(45.0 * (len(blocked) - 1))
|
||||
return (center[0] + radius * math.cos(azimuth),
|
||||
@@ -496,6 +559,9 @@ class DirectionalExtension(OmnidirectionalLoop):
|
||||
report = super()._summary(info)
|
||||
report["directional_observed_channels"] = list(self.directional_observed)
|
||||
report["fallback_clears"] = self.fallback_log
|
||||
# 后备 /clear 请求计入总请求口径,仅统计检测次数会低估现实耗时。
|
||||
report["fallback_clear_attempts"] = sum(
|
||||
entry.get("attempts", 0) for entry in self.fallback_log)
|
||||
return report
|
||||
|
||||
|
||||
|
||||
@@ -5,15 +5,16 @@
|
||||
|
||||
与官方实现的边界:本类只承诺"协议可观测行为一致"(响应字段、
|
||||
耗时推进、幂等与拒绝语义);示向度误差分布是测试假设,不代表官方实现。
|
||||
误差场为逐点固定:同一(源频道,位置)的示向度误差由种子确定性决定,
|
||||
重复测量读数不变 [题面/官方演练实测];不同位置误差独立(白噪声假设)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
from math import atan2, degrees, hypot, isfinite
|
||||
from random import Random
|
||||
from time import time
|
||||
|
||||
from ..config import ProblemConfig
|
||||
@@ -58,7 +59,7 @@ class LocalSimulator:
|
||||
self.cost = config.cost
|
||||
self.error_half_deg = (self.field.bearing_error_half_deg
|
||||
if error_half_deg is None else error_half_deg)
|
||||
self._rng = Random(error_seed)
|
||||
self.error_seed = error_seed
|
||||
self._rtt_ms = rtt_ms
|
||||
self.entered = False
|
||||
self.exited = False
|
||||
@@ -190,10 +191,21 @@ class LocalSimulator:
|
||||
if distance <= self.field.near_radius_m:
|
||||
return NEAR, None
|
||||
true_bearing = degrees(atan2(source.y - pos[1], source.x - pos[0]))
|
||||
error = self._rng.uniform(-self.error_half_deg, self.error_half_deg)
|
||||
error = self._bearing_error(channel, pos)
|
||||
svd = round(true_bearing + error, self.field.bearing_decimals) % 360.0
|
||||
return DIRECTION, svd
|
||||
|
||||
def _bearing_error(self, channel: int, pos) -> float:
|
||||
"""逐点固定误差场:同一(频道, 位置)误差恒定 [题面:同一地点误差不变]。
|
||||
|
||||
坐标按 1e-6 m 量化后与种子、频道一起哈希,映射到
|
||||
[-error_half_deg, +error_half_deg);不同位置近似独立(白噪声假设)。
|
||||
"""
|
||||
key = f"{self.error_seed}|{channel}|{round(pos[0], 6)}|{round(pos[1], 6)}"
|
||||
digest = hashlib.sha256(key.encode("utf-8")).digest()
|
||||
unit = int.from_bytes(digest[:8], "big") / 2.0 ** 64
|
||||
return (2.0 * unit - 1.0) * self.error_half_deg
|
||||
|
||||
def _source(self, channel: int):
|
||||
for source in self.sources:
|
||||
if source.channel == channel:
|
||||
|
||||
Reference in New Issue
Block a user