4000s上限优化

This commit is contained in:
2026-09-11 11:16:23 +08:00
parent 9cf94281a9
commit 7dedcd0629
202 changed files with 8609 additions and 38 deletions
@@ -0,0 +1,429 @@
"""P4b 贝叶斯网格滤波闭环(问题3新策略 ``bayes``)。
在 :class:`OmnidirectionalLoop` 骨架(run/异常收尾/summary/预算/异常留痕)
上的三层重构,对应 docs/algo-3-bayes.md 的架构:
1. 感知层:每频道一个网格幸存集(particles.ChannelParticles),全部读数
(含 no_signal 负观测)即时更新;幸存集为空即缺席证明,无需等覆盖账本;
2. 决策层:统一任务循环——站点扫频、定位测量、清除三类任务合成任务表,
开放路径最近邻 + 2-opt 排序,每执行一个任务滚动重排(动态 TSP);
3. 选点层:定位测量点在保证接收盘(凸证书)或 PF MEC 探索环(宽候选集)
内按"期望 log 幸存占比 + λ·移动时间"评分(信息增益贪心)。
与基线 SevenBatch 的差异:负观测跳站/跳测(必 no_signal 的 (站,频道) 对
不消耗检测与换频)、PF 缺席证明提前证毕、全局任务排序替代"站内最近邻"。
清除证书仍以凸证书为主(PF 网格证书仅在负观测挖洞使幸存集更紧时提前
触发),两者均满足 Voronoi/保守裕量,不牺牲全清率。
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from ..belief import ChannelStatus
from ..config import ProblemConfig, Vec2
from ..coverage import CoverageLedger
from ..particles import ChannelParticles
from ..protocol.client import SimulatorClient
from ..protocol.rules import DIRECTION, NEAR, NO_SIGNAL
from ..protocol.timing import move_time
from . import (ClearCertificate, OmnidirectionalLoop, clear_certificate,
candidate_points, safe_disk_radius, select_measurement_point)
from .seven import seven_points
@dataclass(frozen=True)
class Task:
"""统一任务表的一项。service_s 为不含移动的固有耗时(检测+换频/清除)。"""
kind: str # "station" | "locate" | "clear"
position: Vec2
service_s: float
channel: int | None = None
station_index: int | None = None
def _point_key(point: Vec2) -> tuple[int, int]:
"""候选点去重键:0.5m 网格取整(同一物理点不重复测量)。"""
return (int(round(point[0] * 2)), int(round(point[1] * 2)))
class BayesLoop(OmnidirectionalLoop):
"""问题3贝叶斯闭环:负观测感知 + 信息增益选点 + 滚动任务排序。"""
def __init__(self, client: SimulatorClient,
config: ProblemConfig = ProblemConfig()):
super().__init__(client, config)
self.ledger = CoverageLedger(
config, points=seven_points(config.bayes.station_ring_radius_m))
self.particles = {channel: ChannelParticles(config)
for channel in self.belief.channels}
self._measured_points: dict[int, set[tuple[int, int]]] = {}
self._clear_failed: set[int] = set() # 证书清除失败后须先补测再重试
self._stall: dict[int, int] = {} # 连续无收缩计数(异常留痕用)
self._skip_cache: dict[tuple[int, int], tuple[int, bool]] = {}
self.skipped_measures = 0 # 跳测规则省下的检测次数
self.no_shrink_measures = 0 # 未收缩幸存集的定位检测(诊断用)
self.pf_absent_channels: list[int] = [] # 由幸存集为空证明缺席的频道
# ------------------------------------------------------------ 主循环
def _coverage_phase(self) -> None:
"""统一任务循环:发现 + 定位 + 清除滚动调度,直到任务表为空。"""
while self.stop_reason is None:
if self._count_limit_reached() or self._virtual_cap_reached():
return
if not self._step_once():
return
def _step_once(self) -> bool:
"""构建任务表、排序并执行首任务;无可执行任务返回 False。"""
tasks = self._build_tasks()
if not tasks:
return False
order = self._order_tasks(tasks)
self._execute_task(order[0])
return True
# ------------------------------------------------------------ 任务构建
def _station_pending(self, index: int) -> list[int]:
"""站点待测频道:UNKNOWN、未测过、非"必 no_signal"且未证缺席。"""
point = self.ledger.points[index]
pending = []
for channel, belief in self.belief.channels.items():
if belief.status is not ChannelStatus.UNKNOWN:
continue
if self.ledger.measured_at(channel, index):
continue
if self._skip_station_channel(channel, index, point):
continue
pending.append(channel)
return pending
def _skip_station_channel(self, channel: int, index: int, point: Vec2) -> bool:
"""跳测规则(按 pf.version 缓存;幸存集单调收缩,判定单调)。"""
pf = self.particles[channel]
if not len(pf):
return True
cached = self._skip_cache.get((channel, index))
if cached is not None and cached[0] == pf.version:
return cached[1]
value = pf.guaranteed_no_signal(point)
self._skip_cache[(channel, index)] = (pf.version, value)
return value
def _build_tasks(self) -> list[Task]:
tasks: list[Task] = []
for index, point in enumerate(self.ledger.points):
pending = self._station_pending(index)
if not pending:
continue
switches = len(pending) - (1 if self.client.state.channel in pending else 0)
tasks.append(Task("station", tuple(point),
self.config.cost.measure_s * len(pending)
+ self.config.cost.switch_s * max(switches, 0),
station_index=index))
for channel in self.belief.channels_by_status(ChannelStatus.DETECTED):
cert = self._best_certificate(channel)
if cert is not None and cert.certified \
and channel not in self._clear_failed:
tasks.append(Task("clear", cert.center,
self.config.cost.clear_success_s,
channel=channel))
continue
point = self._select_point(channel)
if point is None:
continue
tasks.append(Task("locate", point,
self.config.cost.measure_s + self.config.cost.switch_s,
channel=channel))
return tasks
# ------------------------------------------------------------ 任务排序
def _order_tasks(self, tasks: list[Task]) -> list[Task]:
"""开放路径排序:最近邻打底,2-opt 改进(终点自由,不回起点)。"""
if len(tasks) <= 2:
return sorted(tasks, key=lambda t: (
move_time(self.client.state.position, t.position, self.config.cost),
t.kind, t.channel or 0))
order = self._nearest_order(tasks)
if not self.config.bayes.two_opt_enabled:
return order
return self._two_opt(order)
def _nearest_order(self, tasks: list[Task]) -> list[Task]:
remaining = list(tasks)
order = []
position = self.client.state.position
while remaining:
best = min(remaining, key=lambda t: (
move_time(position, t.position, self.config.cost),
t.kind, t.channel or 0))
remaining.remove(best)
order.append(best)
position = best.position
return order
def _path_cost(self, order: list[Task]) -> float:
total = 0.0
position = self.client.state.position
for task in order:
total += move_time(position, task.position, self.config.cost)
total += task.service_s
position = task.position
return total
def _two_opt(self, order: list[Task]) -> list[Task]:
best = list(order)
best_cost = self._path_cost(best)
for _ in range(self.config.bayes.two_opt_passes):
improved = False
for i in range(len(best) - 1):
for j in range(i + 1, len(best)):
candidate = best[:i] + best[i:j + 1][::-1] + best[j + 1:]
cost = self._path_cost(candidate)
if cost < best_cost - 1e-6:
best, best_cost, improved = candidate, cost, True
if not improved:
break
return best
# ------------------------------------------------------------ 任务执行
def _execute_task(self, task: Task) -> None:
if task.kind == "station":
self._execute_station(task.station_index)
elif task.kind == "clear":
cert = self._best_certificate(task.channel)
if cert is not None and cert.certified:
if not self._attempt_clear(task.channel, cert.center, cert):
self._clear_failed.add(task.channel)
self._may_clear.discard(task.channel)
else:
self._clear_failed.discard(task.channel)
elif task.kind == "locate":
self._execute_locate(task.channel, task.position)
def _execute_station(self, index: int) -> None:
"""单站批量扫频:只测待测频道(跳测规则已过滤),扫完即返回重排。"""
point = self.ledger.points[index]
pending = self._station_pending(index)
unknown = [c for c, b in self.belief.channels.items()
if b.status is ChannelStatus.UNKNOWN]
self.skipped_measures += len(unknown) - len(pending)
# 当前频道优先,减少换频;同站 near 直接清除不打断扫频
pending.sort(key=lambda c: (c != self.client.state.channel, c))
for channel in pending:
if self.stop_reason is not None or self._count_limit_reached() \
or self._virtual_cap_reached():
return
outcome = self.client.measure(point, channel)
self.ledger.mark_measured(channel, index)
self._record_measure(channel, point, outcome)
if outcome.result in (DIRECTION, NEAR):
self._may_clear.add(channel)
if outcome.result == NEAR:
region = self.belief.candidate_set(channel)
cert = clear_certificate(region, channel, self.config)
if cert.certified:
if self._attempt_clear(channel, point, cert):
self._clear_failed.discard(channel)
else:
self._clear_failed.add(channel)
self._may_clear.discard(channel)
else:
self._anomaly("near_without_certificate", channel, position=point)
def _execute_locate(self, channel: int, point: Vec2) -> None:
"""单步定位测量:执行后立即重排(滚动时域),收敛则顺路清除。"""
pf = self.particles[channel]
before = len(pf)
outcome = self.client.measure(point, channel)
self._record_measure(channel, point, outcome)
self._may_clear.add(channel)
if outcome.result == NO_SIGNAL:
region = self.belief.candidate_set(channel)
if region is not None and safe_disk_radius(region, self.config) >= 0 \
and math.dist(point, region.mec()[0]) \
<= safe_disk_radius(region, self.config):
self._anomaly("no_signal_in_safe_disk", channel, position=point)
after = len(pf)
cert = self._best_certificate(channel)
if cert is not None and cert.certified:
if self._attempt_clear(channel, cert.center, cert):
self._clear_failed.discard(channel)
self._stall.pop(channel, None)
return
self._clear_failed.add(channel)
self._may_clear.discard(channel)
if after == before:
self._stall[channel] = self._stall.get(channel, 0) + 1
self.no_shrink_measures += 1 # 只是浪费不破坏守恒性,不进异常表
else:
self._stall[channel] = 0
# ------------------------------------------------------------ 感知与证书
def _record_measure(self, channel: int, position: Vec2, outcome) -> None:
"""belief(凸证书)与 PF(幸存集)同步更新;PF 空即证缺席。"""
self.belief.update_measure(channel, position, outcome.result,
outcome.svd_deg)
self._measured_points.setdefault(channel, set()).add(_point_key(position))
self._clear_failed.discard(channel) # 新证据落地:允许再次尝试清除
belief = self.belief.channels[channel]
if belief.status in (ChannelStatus.CLEARED, ChannelStatus.ABSENT):
return # 已证毕:清除/缺席后的 no_signal 与距离无关,不进 PF
pf = self.particles[channel]
pf.apply_measure(position, outcome.result, outcome.svd_deg)
if not len(pf):
if belief.status is ChannelStatus.UNKNOWN:
self.belief.mark_absent(channel)
if channel not in self.pf_absent_channels:
self.pf_absent_channels.append(channel)
else:
self._anomaly("pf_empty_while_detected", channel, position=position)
def _best_certificate(self, channel: int) -> ClearCertificate | None:
"""凸证书优先;负观测挖洞使 PF 幸存集更紧时用网格证书补位。"""
region = self.belief.candidate_set(channel)
cert = clear_certificate(region, channel, self.config) \
if region is not None else None
if cert is not None and cert.certified:
return cert
if self.config.bayes.grid_cert_enabled:
pf = self.particles[channel]
if len(pf):
center, radius = pf.mec()
limit = (self.config.field.clear_radius_m - pf.margin_m
- self.config.search.clear_margin_m)
if radius <= limit:
return ClearCertificate(channel, center, radius, limit, True)
return cert
# ------------------------------------------------------------ 信息增益选点
def _select_point(self, channel: int) -> Vec2 | None:
"""定位测量点:期望 log 幸存占比 + λ·移动时间 最小的候选。"""
bayes, search = self.config.bayes, self.config.search
pf = self.particles[channel]
if not len(pf):
return None
region = self.belief.candidate_set(channel)
center, radius = pf.mec()
measured = self._measured_points.get(channel, set())
candidates: list[Vec2] = []
safe = safe_disk_radius(region, self.config) if region is not None else -1.0
if region is not None and safe >= 0:
# 保证接收盘(凸证书口径):任意候选必产生 direction/near
candidates = candidate_points(tuple(region.mec()[0]), safe,
search.candidate_rings,
search.candidate_angles)
else:
# 宽候选集:PF MEC 探索环(负观测挖洞后幸存集的包围圆)
for fraction in bayes.wide_rings:
ring = max(radius * fraction, 2.0 * pf.margin_m)
candidates.extend(candidate_points(center, ring, (1.0,),
search.candidate_angles))
candidates.append(tuple(center))
candidates = [c for c in candidates if _point_key(c) not in measured]
if not candidates:
return None
subsample = pf.subsample(bayes.score_subsample)
stride = max(len(subsample) // bayes.mc_representatives, 1)
representatives = [subsample[k]
for k in range(0, len(subsample), stride)]
position = self.client.state.position
def score(q: Vec2) -> float:
move = bayes.lambda_move_s * move_time(position, q, self.config.cost)
if bayes.score_mode == "entropy":
return (pf.expected_survival(q, subsample, representatives)
+ move)
return (pf.expected_posterior_radius(q, subsample, representatives)
+ move)
return min(candidates, key=lambda q: (score(q), _point_key(q)))
# ------------------------------------------------------------ 安全网与复扫
def _localize_and_clear(self, channel: int) -> bool:
"""阻塞式定位子循环(resolve 阶段安全网):PF 选点,失败退凸选点。"""
search = self.config.search
self._may_clear.add(channel)
for _ in range(search.max_localize_steps):
if self._virtual_cap_reached():
return False
cert = self._best_certificate(channel)
if cert is not None and cert.certified and channel in self._may_clear:
if self._attempt_clear(channel, cert.center, cert):
self._clear_failed.discard(channel)
return True
self._clear_failed.add(channel)
self._may_clear.discard(channel)
continue
point = self._select_point(channel)
if point is None:
region = self.belief.candidate_set(channel)
if region is None:
return False
point = select_measurement_point(
region, self.client.state.position, self.config).position
outcome = self.client.measure(point, channel)
self._record_measure(channel, point, outcome)
self._may_clear.add(channel)
if outcome.result == NO_SIGNAL:
region = self.belief.candidate_set(channel)
if region is not None and safe_disk_radius(region, self.config) >= 0 \
and math.dist(point, region.mec()[0]) \
<= safe_disk_radius(region, self.config):
self._anomaly("no_signal_in_safe_disk", channel, position=point)
cert = self._best_certificate(channel)
if cert is not None and cert.certified:
if self._attempt_clear(channel, cert.center, cert):
self._clear_failed.discard(channel)
return True
self._clear_failed.add(channel)
self._may_clear.discard(channel)
return self.belief.channels[channel].status is ChannelStatus.CLEARED
def _persistent_round(self) -> None:
"""一轮复扫:与基线一致的全站复测,但读数同步进 PF(防假设偏差)。"""
measured: set[tuple[int, int]] = set()
while self.stop_reason is None:
index = self._next_resweep_point(measured)
if index is None:
return
point = self.ledger.points[index]
for channel in self._resweep_channels_at(index, measured):
if self._virtual_cap_reached():
return
measured.add((channel, index))
was_absent = (self.belief.channels[channel].status
is ChannelStatus.ABSENT)
outcome = self.client.measure(point, channel)
self._record_measure(channel, point, outcome)
if was_absent and outcome.result in (DIRECTION, NEAR):
# 复测推翻了缺席证明:守恒性留痕,按新证据定位清除。
self._anomaly("absent_detected_on_resweep", channel,
position=point, result=outcome.result)
if outcome.result == DIRECTION:
self._localize_and_clear(channel)
elif outcome.result == NEAR:
cert = self._best_certificate(channel)
if cert is not None and cert.certified:
if not self._attempt_clear(channel, cert.center, cert):
self._clear_failed.add(channel)
else:
self._anomaly("near_without_certificate", channel,
position=point)
if self.stop_reason is not None:
return
# ------------------------------------------------------------ 收尾
def _summary(self, info) -> dict:
report = super()._summary(info)
report["strategy"] = "bayes"
report["skipped_measures"] = self.skipped_measures
report["no_shrink_measures"] = self.no_shrink_measures
report["pf_absent_channels"] = sorted(self.pf_absent_channels)
report["pf_alive_final"] = {
str(channel): len(self.particles[channel])
for channel in sorted(self.particles)
if self.belief.channels[channel].status is ChannelStatus.DETECTED}
return report