"""位置×朝向联合可行集(第四问定向源)。 现状问题(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}