4000s上限优化
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user