This commit is contained in:
Horldsence
2026-09-11 02:32:31 +08:00
parent 8c18a1f499
commit 4f91f242fe
19 changed files with 971 additions and 45 deletions
+5 -2
View File
@@ -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 # 后备清除格数硬顶;超过则不盲扫(宽候选集继续定位)
+16 -4
View File
@@ -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:
+88 -22
View File
@@ -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
+16 -4
View File
@@ -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: