389 lines
19 KiB
Python
389 lines
19 KiB
Python
"""位置×朝向联合可行集(第四问定向源)。
|
||
|
||
现状问题(docs/第三四问架构优化与运行提速方案.md §1.3 F3):第四问的失败**全部**
|
||
来自定向源(评测机两局:全向 17/17 清除,定向仅 2/6)。原因不是覆盖不足,而是
|
||
策略只知道"源可能在某个空间区域",不知道"它朝着哪边"——于是选点只能靠固定
|
||
45° 阶梯试探,一旦试探失败就退化到 ≤400 格 20 m 盲清。
|
||
|
||
本模块把"看不看得见"变成可推理的量:为已在 Ω 上维护的网格幸存集
|
||
(:class:`~mcm26b.particles.ChannelParticles`)附加每格 24 档朝向可行性掩码,
|
||
得到 (位置 × 朝向) 联合可行集。观测语义严格对齐评测机
|
||
``referee.simulator._observe``:
|
||
|
||
- 距离 > r_eff → no_signal;定向且源→读点方向与朝向夹角 > 90° → no_signal;
|
||
- 距离 ≤ 5 m → near(**仍需可见**:3 m 处背向的定向源同样返回 no_signal);
|
||
- 其余 → direction(读数 = 源→读点方位 + 有界误差)。
|
||
|
||
更新规则(全部保守,只删"必然不可能"的格/档):
|
||
|
||
=============== ============================================ ==================
|
||
观测 位置 朝向
|
||
=============== ============================================ ==================
|
||
``direction``@s 距离 ≤ 1500、角向 |gap| ≤ δ_eff + 格裕量 必须**可能**覆盖 s
|
||
``near``@s 距离 ≤ 5 必须**可能**覆盖 s
|
||
``no_signal``@s **不删**(可能是朝向遮挡或距离不足) 必须**确定不**覆盖 s
|
||
(仅当整格必在 r_eff_min 内才约束朝向)
|
||
=============== ============================================ ==================
|
||
|
||
阈值全部取保守侧,并计入**格内角裕量**
|
||
``asin(margin / max(dist − margin, 1))``(格内任一点相对格心方位偏差上界):
|
||
|
||
- "必须可能覆盖":``gap ≤ 90° + 半档 + 查表舍入 + 格内角裕量``(宁可保留);
|
||
- "必须确定不覆盖":先删去 ``gap ≤ 90° − 半档 − 查表舍入 − 格内角裕量`` 的档,
|
||
只保留确定看不见 s 的档(同样宁可保留)。
|
||
|
||
因此真源 (位置, 朝向) 永不被删——这是网格幸存集真源保留定理的二维推广。
|
||
(2026-09-12 修正:no_signal 分支原先误用"确定覆盖"掩码做交集,等价于把
|
||
规则反向;keep=True 分支也漏计了格内角裕量。两处都会删掉真源朝向档,
|
||
现由 ``tests/test_joint_belief.py`` 的随机观测序列保留性测试固化。)
|
||
|
||
用途:
|
||
1. ``prob_detect``:该点能看见该频道任一可行 (位置, 朝向) 的概率质量,
|
||
用于**可见性选点**(取代固定阶梯)——只影响选点顺序,不承担安全责任;
|
||
2. ``expected_posterior_radius``:一次测量后的期望后验 MEC 半径(D1 值函数);
|
||
3. ``is_absent``:联合集空 ⟹ 该频道不存在源(严格,与覆盖证明同一条件);
|
||
4. 位置边际仍由凸证书负责 20 m 清除判定,本模块不改变任何证书口径。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
|
||
from ..config import ProblemConfig, Vec2
|
||
from ..particles import ChannelParticles, _branch_radius_array
|
||
from ..protocol.rules import DIRECTION, NEAR, NO_SIGNAL
|
||
|
||
HEADING_BINS = 24 # 15° 一档
|
||
_BIN_HALF_DEG = 180.0 / HEADING_BINS # 7.5°
|
||
_FULL_MASK = (1 << HEADING_BINS) - 1
|
||
_GRID_DEG = 3.0 # 角度查表分辨率
|
||
_GRID_N = int(round(360.0 / _GRID_DEG))
|
||
|
||
try: # numpy 仅作可选加速后端(与 particles 一致)
|
||
import numpy as _np
|
||
from ..particles import _np as _particles_np
|
||
if _particles_np is None: # MCM26B_NO_NUMPY 或缺失:强制纯 Python
|
||
_np = None
|
||
except Exception: # pragma: no cover - 环境无 numpy
|
||
_np = None
|
||
|
||
|
||
def heading_gap(a_deg: float, b_deg: float) -> float:
|
||
"""两个朝向角的最小夹角(度)。"""
|
||
return abs((a_deg - b_deg + 180.0) % 360.0 - 180.0)
|
||
|
||
|
||
def _cover_mask(bearing_deg: float, half_deg: float) -> int:
|
||
"""与该方位夹角不超过 ``half_deg`` 的朝向档掩码。"""
|
||
mask = 0
|
||
for k in range(HEADING_BINS):
|
||
if heading_gap(360.0 * k / HEADING_BINS, bearing_deg) <= half_deg:
|
||
mask |= 1 << k
|
||
return mask
|
||
|
||
|
||
# 查表:以 bearing 为轴、半角 90° 的可见半平面。
|
||
# - COVER:**可能**覆盖该方位(含半档与查表舍入的保守侧);
|
||
# - SILENT:**确定**覆盖该方位(内缩半档与舍入)——离散对照表,供审计与
|
||
# 文档比对;实际裁剪走 _cover_mask / _far_mask,因为还必须逐格计入
|
||
# "格内角裕量"(格心到格内任一点的方位偏差上界),无法预先制表。
|
||
_COVER_TABLE = tuple(
|
||
_cover_mask(_GRID_DEG * i, 90.0 + _BIN_HALF_DEG + _GRID_DEG / 2)
|
||
for i in range(_GRID_N))
|
||
_SILENT_TABLE = tuple(
|
||
_cover_mask(_GRID_DEG * i, 90.0 - _BIN_HALF_DEG - _GRID_DEG / 2)
|
||
for i in range(_GRID_N))
|
||
|
||
|
||
def _far_mask(bearing_deg: float, exclude_half_deg: float) -> int:
|
||
"""与方位夹角**大于** ``exclude_half_deg`` 的朝向档掩码(no_signal 用)。
|
||
|
||
与 :func:`_cover_mask` 互补:no_signal 只允许保留"确定看不见"的朝向档。
|
||
"""
|
||
mask = 0
|
||
for k in range(HEADING_BINS):
|
||
if heading_gap(360.0 * k / HEADING_BINS, bearing_deg) > exclude_half_deg:
|
||
mask |= 1 << k
|
||
return mask
|
||
|
||
if _np is not None: # 12 位半字 popcount 查表(朝向掩码 24 位)与半平面查表数组
|
||
_POP16 = _np.array([bin(value).count("1") for value in range(1 << 12)],
|
||
dtype=_np.int64)
|
||
_COVER_ARR = _np.array(_COVER_TABLE, dtype=_np.int64)
|
||
else: # pragma: no cover - 无 numpy 分支
|
||
_POP16 = _COVER_ARR = None
|
||
|
||
|
||
def _popcount(mask: int) -> int:
|
||
return bin(mask).count("1")
|
||
|
||
|
||
def _table_index(bearing_deg: float) -> int:
|
||
return int(round((bearing_deg % 360.0) / _GRID_DEG)) % _GRID_N
|
||
|
||
|
||
class JointBelief:
|
||
"""单频道 (位置 × 朝向) 联合可行集。"""
|
||
|
||
def __init__(self, config: ProblemConfig = ProblemConfig()):
|
||
self.config = config
|
||
self.field = config.field
|
||
self.position = ChannelParticles(config)
|
||
self.masks: dict[int, int] = {} # 格索引 -> 朝向掩码(缺省为全档)
|
||
self._sample_cache: tuple[int, list[int]] | None = None
|
||
|
||
# ------------------------------------------------------------ 查询
|
||
def __len__(self) -> int:
|
||
return len(self.position)
|
||
|
||
def is_absent(self) -> bool:
|
||
"""联合集空 ⟹ 该频道不存在源(真源保留定理的二维推广)。"""
|
||
return not self.position.alive
|
||
|
||
def _mask(self, index: int) -> int:
|
||
return self.masks.get(index, _FULL_MASK)
|
||
|
||
def sample(self, size: int = 256) -> list[int]:
|
||
"""均匀抽样幸存格(评分用;证书始终用完整集合)。"""
|
||
cached = self._sample_cache
|
||
if cached is not None and cached[0] == self.position.version:
|
||
return cached[1]
|
||
alive = sorted(self.position.alive)
|
||
if len(alive) > size:
|
||
step = len(alive) / size
|
||
alive = [alive[int(k * step) % len(alive)] for k in range(size)]
|
||
self._sample_cache = (self.position.version, alive)
|
||
return alive
|
||
|
||
def _mask_popcount(self, index: int) -> int:
|
||
return _popcount(self._mask(index))
|
||
|
||
def expected_posterior_radius(self, reader: Vec2, size: int | None = None) -> float:
|
||
"""一次测量后的期望后验 MEC 半径(米);D1 值函数。
|
||
|
||
与 :meth:`ChannelParticles.expected_posterior_radius` 同族,差别只在
|
||
no_signal 分支概率:定向源不仅会因距离 > r_eff 静默,还会因朝向遮挡
|
||
静默,因此
|
||
|
||
p_vis(p) = P(r_eff ≥ |p−q|) × cover_ratio(p, q)
|
||
|
||
其中 ``cover_ratio`` 是该格朝向掩码中"可能覆盖 q 方位"的档位占比。
|
||
分支半径语义:
|
||
|
||
- direction(可见,非 near):读数为 q 处的方位 ±δ,后验收缩到该方位
|
||
楔形与当前可行集之交,半径取幸存子采样的质心最大距离;
|
||
- no_signal:定向源的静默**不删空间候选**,位置后验半径保持当前值;
|
||
- near:后验为 5m 盘。
|
||
|
||
返回值只用于**选点排序**,不参与任何安全判定;证书仍由凸/网格口径给出。
|
||
"""
|
||
pf = self.position
|
||
if not pf.alive:
|
||
return 0.0
|
||
if size is None:
|
||
size = self.config.bayes.score_subsample
|
||
indices = self.sample(max(2, size))
|
||
if not indices:
|
||
return 0.0
|
||
stride = max(len(indices) // max(self.config.bayes.mc_representatives, 1), 1)
|
||
representatives = indices[::stride]
|
||
if _np is not None:
|
||
return self._epr_vector(reader, indices, representatives)
|
||
return self._epr_python(reader, indices, representatives) # pragma: no cover
|
||
|
||
def _epr_vector(self, reader: Vec2, indices: list[int],
|
||
representatives: list[int]) -> float:
|
||
pf = self.position
|
||
points = _np.array([pf.cell_center(index) for index in indices], dtype=float)
|
||
masks = _np.array([self._mask(index) for index in indices], dtype=_np.int64)
|
||
reps = _np.array([pf.cell_center(index) for index in representatives],
|
||
dtype=float)
|
||
rep_masks = _np.array([self._mask(index) for index in representatives],
|
||
dtype=_np.int64)
|
||
dx, dy = points[:, 0] - reader[0], points[:, 1] - reader[1]
|
||
dist = _np.hypot(dx, dy)
|
||
bearing = _np.degrees(_np.arctan2(dy, dx))
|
||
rdx, rdy = reps[:, 0] - reader[0], reps[:, 1] - reader[1]
|
||
rdist = _np.hypot(rdx, rdy)
|
||
rbearing = _np.degrees(_np.arctan2(rdy, rdx))
|
||
cover = _COVER_ARR[_np.round(_np.mod(rbearing, 360.0) / _GRID_DEG)
|
||
.astype(_np.int64) % _GRID_N]
|
||
visible_mask = rep_masks & cover
|
||
bits = (_POP16[visible_mask & 0xFFF]
|
||
+ _POP16[(visible_mask >> 12) & 0xFFF])
|
||
cover_ratio = bits / float(HEADING_BINS)
|
||
p_silent_range = _np.clip((rdist - self.field.r_eff_min_m)
|
||
/ (self.field.r_eff_max_m
|
||
- self.field.r_eff_min_m), 0.0, 1.0)
|
||
p_vis = (1.0 - p_silent_range) * cover_ratio
|
||
p_ns = 1.0 - p_vis
|
||
gaps = _np.abs((bearing[None, :] - rbearing[:, None] + 180.0) % 360.0 - 180.0)
|
||
in_wedge = ((gaps <= self.config.field.bearing_error_half_deg
|
||
+ self.config.search.bearing_rounding_margin_deg + 1e-9)
|
||
& (dist[None, :] <= self.field.r_eff_max_m + pf.margin_m))
|
||
eps_radius = pf.spacing_m / 2.0
|
||
radii = []
|
||
for row in in_wedge:
|
||
member = points[row]
|
||
radii.append(_branch_radius_array(member)
|
||
if len(member) else eps_radius)
|
||
radii = _np.array(radii, dtype=float)
|
||
current = pf.mec()[1]
|
||
near = rdist <= self.field.near_radius_m + pf.margin_m
|
||
values = _np.where(near, self.field.near_radius_m + pf.margin_m,
|
||
p_ns * current + p_vis * radii)
|
||
return float(values.mean())
|
||
|
||
def _epr_python(self, reader: Vec2, indices: list[int],
|
||
representatives: list[int]) -> float: # pragma: no cover
|
||
"""纯 Python 回退(与向量版逐分支同语义,供无 numpy 环境与对拍)。"""
|
||
pf = self.position
|
||
points = [pf.cell_center(index) for index in indices]
|
||
masks = [self._mask(index) for index in indices]
|
||
eps_radius = pf.spacing_m / 2.0
|
||
current = pf.mec()[1]
|
||
half_angle = (self.config.field.bearing_error_half_deg
|
||
+ self.config.search.bearing_rounding_margin_deg)
|
||
total = 0.0
|
||
for rep_index in representatives:
|
||
rx, ry = pf.cell_center(rep_index)
|
||
distance = math.hypot(rx - reader[0], ry - reader[1])
|
||
if distance <= self.field.near_radius_m + pf.margin_m:
|
||
total += self.field.near_radius_m + pf.margin_m
|
||
continue
|
||
reading = math.degrees(math.atan2(ry - reader[1], rx - reader[0]))
|
||
cover = _COVER_TABLE[_table_index(reading)]
|
||
bits = _popcount(self._mask(rep_index) & cover)
|
||
ratio = bits / float(HEADING_BINS)
|
||
if distance <= self.field.r_eff_min_m:
|
||
p_range = 1.0
|
||
elif distance >= self.field.r_eff_max_m:
|
||
p_range = 0.0
|
||
else:
|
||
p_range = ((self.field.r_eff_max_m - distance)
|
||
/ (self.field.r_eff_max_m - self.field.r_eff_min_m))
|
||
p_vis = p_range * ratio
|
||
members = []
|
||
for point, mask in zip(points, masks):
|
||
if math.dist(point, reader) > self.field.r_eff_max_m + pf.margin_m:
|
||
continue
|
||
bearing = math.degrees(math.atan2(point[1] - reader[1],
|
||
point[0] - reader[0]))
|
||
if heading_gap(bearing, reading) <= half_angle + 1e-9:
|
||
members.append(point)
|
||
if members:
|
||
mx = sum(p[0] for p in members) / len(members)
|
||
my = sum(p[1] for p in members) / len(members)
|
||
radius = max(math.hypot(p[0] - mx, p[1] - my) for p in members)
|
||
else:
|
||
radius = eps_radius
|
||
total += (1.0 - p_vis) * current + p_vis * radius
|
||
return total / max(len(representatives), 1)
|
||
|
||
|
||
def prob_detect(self, reader: Vec2, size: int = 256) -> float:
|
||
"""在 ``reader`` 处能看见任一可行 (位置, 朝向) 的概率质量(启发式)。
|
||
|
||
r_eff ~ U[1000, 1500] 解析积分;朝向按可行档位占比计权。
|
||
只用于选点排序,不参与任何安全判定。
|
||
"""
|
||
cells = self.sample(size)
|
||
if not cells:
|
||
return 0.0
|
||
total = 0.0
|
||
span = self.field.r_eff_max_m - self.field.r_eff_min_m
|
||
for index in cells:
|
||
gx, gy = self.position.cell_center(index)
|
||
distance = math.hypot(reader[0] - gx, reader[1] - gy)
|
||
if distance > self.field.r_eff_max_m:
|
||
continue
|
||
if distance <= self.field.r_eff_min_m:
|
||
p_range = 1.0
|
||
else:
|
||
p_range = (self.field.r_eff_max_m - distance) / span
|
||
if p_range <= 0.0:
|
||
continue
|
||
bearing = math.degrees(math.atan2(reader[1] - gy, reader[0] - gx))
|
||
cover = _COVER_TABLE[_table_index(bearing)]
|
||
bits = bin(self._mask(index) & cover).count("1")
|
||
if bits:
|
||
total += p_range * bits / HEADING_BINS
|
||
return total / len(cells)
|
||
|
||
# ------------------------------------------------------------ 观测更新
|
||
def _apply_heading(self, station: Vec2, keep: bool,
|
||
within_m: float | None = None) -> None:
|
||
"""按可见性约束裁剪朝向档(保守:只删"必然不可能"的档)。
|
||
|
||
``keep=True``(direction / near)⟹ 朝向必须**可能**覆盖 station:
|
||
保留与方位夹角 ≤ 90° + 半档 + 查表舍入 + 格内角裕量的档。
|
||
|
||
``keep=False``(no_signal 且整格必在 r_eff_min 内)⟹ 朝向必须
|
||
**确定不覆盖** station:只保留与方位夹角 > 90° − 半档 − 查表舍入
|
||
− 格内角裕量的档。
|
||
|
||
``格内角裕量 = asin(margin / max(dist − margin, 1))``:格内任一点
|
||
相对格心的方位偏差上界。两个方向都必须计入它,否则会误删真源的
|
||
朝向档(keep=True 漏计)或保留必然可见的档(keep=False 漏计)。
|
||
"""
|
||
margin_m = self.position.margin_m
|
||
doomed: list[int] = []
|
||
for index in self.position.alive:
|
||
gx, gy = self.position.cell_center(index)
|
||
dx, dy = station[0] - gx, station[1] - gy
|
||
distance = math.hypot(dx, dy)
|
||
if within_m is not None and distance > within_m:
|
||
continue
|
||
bearing = math.degrees(math.atan2(dy, dx))
|
||
if distance <= margin_m + 1.0:
|
||
# 格贴测点:格内方位可指向任意方向,任何一侧都不可据此裁剪
|
||
continue
|
||
angular_margin = math.degrees(
|
||
math.asin(min(0.9999, margin_m / (distance - margin_m))))
|
||
if keep:
|
||
updated = self._mask(index) & _cover_mask(
|
||
bearing, 90.0 + _BIN_HALF_DEG + _GRID_DEG / 2 + angular_margin)
|
||
else:
|
||
updated = self._mask(index) & _far_mask(
|
||
bearing, 90.0 - _BIN_HALF_DEG - _GRID_DEG / 2 - angular_margin)
|
||
if updated == 0:
|
||
doomed.append(index)
|
||
self.masks.pop(index, None)
|
||
elif updated != _FULL_MASK:
|
||
self.masks[index] = updated
|
||
if doomed:
|
||
self.position.discard(doomed)
|
||
|
||
def apply(self, station: Vec2, result: str,
|
||
svd_deg: float | None = None) -> None:
|
||
"""按读数更新联合可行集(保守,真源 (位置, 朝向) 永不被删)。"""
|
||
if result == DIRECTION:
|
||
self.position.apply_measure(station, DIRECTION, svd_deg,
|
||
directional=True)
|
||
self._apply_heading(station, keep=True)
|
||
elif result == NEAR:
|
||
# near 同样要求可见(评测机先判朝向再判距离)
|
||
self.position.apply_measure(station, NEAR, directional=True)
|
||
self._apply_heading(station, keep=True)
|
||
elif result == NO_SIGNAL:
|
||
# 距离阈值必须取**保守侧**:只有"整格都必然落在 r_eff_min 圆盘内"
|
||
# 时,no_signal 才一定由朝向遮挡解释。格心到格内任一点 ≤ margin,
|
||
# 故阈值取 r_eff_min − margin;取 + margin 会把 1000~1010m 的模糊带
|
||
# 也当成"必须不覆盖",从而可能删掉真源的朝向档(破坏真源保留性)。
|
||
self._apply_heading(station, keep=False,
|
||
within_m=max(
|
||
0.0, self.field.r_eff_min_m
|
||
- self.position.margin_m))
|
||
else:
|
||
raise ValueError(f"未知测向结果: {result!r}")
|
||
|
||
# ------------------------------------------------------------ 诊断
|
||
def summary(self) -> dict:
|
||
"""联合集规模快照(写入策略 summary,供审计与论文复算)。"""
|
||
cells = self.sample(256)
|
||
bins = [bin(self._mask(index)).count("1") for index in cells]
|
||
return {"cells": len(self.position),
|
||
"sampled_mean_heading_bins": (round(sum(bins) / len(bins), 2)
|
||
if bins else 0.0),
|
||
"heading_bins_total": HEADING_BINS}
|