diff --git a/1.md b/1.md new file mode 100644 index 0000000..ace4cb6 --- /dev/null +++ b/1.md @@ -0,0 +1,152 @@ +下面把现有《B题建模分析与实施计划》拆成可执行任务,并逐项检查设计思路。总体结论:**主线设计是对的,四问串联、保守几何、覆盖证明、协议幂等这些方向都合理;但有几个关键点需要补强,尤其是问题2候选区域非空性、定向源朝向不确定性、现实20分钟窗口下的请求数控制,以及正式测试时间安排。** + +--- + +## 一、总体判断 + +现有方案的最大优点是: + +1. **四问没有割裂**:问题1的定位区域和包围圆服务于问题2选点,问题3的覆盖发现与清除框架延伸到问题4。 +2. **坚持保守性**:用有界误差、半平面交、最小包围圆、49点覆盖证明,避免“看起来能清”但实际漏源。 +3. **协议与状态意识强**:强调 `accepted=false` 不更新状态、重试复用 `request_id`、`/clear` 不切换频道、虚拟时间公式。 +4. **停止条件严格**:没有把“发现10个源”或“连续无信号”当成功。 + +主要风险: + +- **问题2的候选区域 \( \mathcal Q \) 可能为空**,需要补非空判断和退化策略。 +- **定向源第4问中 `no_signal` 不能直接删除空间候选点**,现有基线保守但效率低,需明确后备清除的规模和请求预算。 +- **现实20分钟窗口**下,49点×20频道最多980次检测,可能请求数过大;虚拟时间不是瓶颈,网络延迟和程序运行时间才是。 +- **正式测试机会少**,9月13日截止,必须尽早演练并保留正式机会。 + +--- + +## 二、任务拆分:可执行清单 + +建议按下面 P0–P7 拆分,每阶段都有明确交付和验收。 + +| 阶段 | 任务 | 交付物 | 验收标准 | 优先级 | +|---|---|---|---|---| +| P0 规则固定 | 核对题目、附件1/2、协议、计时、日志要求 | 规则表、状态机、字段表 | 计时示例能逐步得到附件给定值;`/clear` 不切换频道;`accepted=false` 不更新 | 最高 | +| P1 协议基线 | 实现 `/enter`、`/detect`、`/clear`、`/exit`;串行、幂等重试、超时 | 可运行客户端、自建回放测试 | 失败重试不污染状态;虚拟时间与公式一致;能主动退出 | 最高 | +| P2 几何内核 | 角域半平面表示、半平面交、凸多边形、直径、最小包围圆 | `geometry` 模块、测试用例 | 处理0°/360°跨界、空集/单点/线段/无界;等边三角形反例通过 | 最高 | +| P3 问题2选点 | 构造 \(K_1\),计算候选区域 \( \mathcal Q\),采样评估后验包围圆 | 第二检测点策略、候选区域算法 | 保证收到信号;\( \mathcal Q\) 为空时有退化方案;near分支处理 | 高 | +| P4 问题3全向闭环 | 49点覆盖、逐频道覆盖记录、发现—定位—清除—停止 | 全向演练版本、统计脚本 | 10源和16源都能正确退出;边界朝外不漏;停止条件正确 | 最高 | +| P5 问题4定向扩展 | 定向源覆盖证明、失去信号处理、20m圆盘后备清除 | 定向演练版本 | 边界朝外定向源能发现;失去信号后能清除;不误删真源 | 高 | +| P6 加速与消融 | 共享检测、自适应覆盖、目标排序、提前清除 | 消融实验记录 | 全清不退化;虚拟时间/请求数有改善证据 | 中 | +| P7 正式与论文 | 演练、正式各3次、日志原名、表1、论文 | 6次正式记录、原始日志 | 案例编码、程序版本、统计一致;日志上传成功 | 最高 | + +时间紧时,**P6 可删减,P0–P5 必须保留**。 + +--- + +## 三、设计思路逐项检查 + +| 设计点 | 判断 | 需要补强/注意 | +|---|---|---| +| 四问统一框架 | 合理 | 问题1和2的几何结果要直接复用到3、4,避免重复建模 | +| 有界误差±1°、示向度两位小数 | 正确 | 实现可加±1.005°保守裕量,但不能写成题面规定 | +| 同地点误差固定 | 正确 | 不能原地重复测量取平均 | +| 角域半平面交 | 正确 | 必须处理0°/360°跨界;区分空集、单点、线段、有限多边形、无界 | +| 直径枚举 | 正确 | 小规模枚举顶点对即可;无界区域不能截断后声称直径 | +| 以直径为直径的圆覆盖区域 | **不能保证** | 等边三角形反例正确;保证清除要用最小包围圆 \( \rho(K)\le20 \) | +| 问题2候选区域 \( \mathcal Q \) | 方向正确 | \(K_1\) 应加 \( \Omega \) 和 \(B(s_1,1500)\) 约束;\( \mathcal Q\) 可能为空,需退化策略 | +| 外包圆候选 \(B(c,1000-r)\) | 正确 | 仅当 \(r<1000\) 时非空;若 \(r\) 接近1000,区域很小 | +| “候选点距第一次检测点不超过1000米” | 错误表述 | 计划已指出,正确是“到 \(K_1\) 中任意可能位置距离不超过1000米” | +| 49点覆盖 | 证明成立 | 需实现网格边/顶点规则;全向和定向都适用;全 no_signal 可证明该频道不存在 | +| 49点扫描规模 | 估算合理 | 虚拟时间约3.665小时,但现实20分钟受请求延迟约束 | +| 定向源 `no_signal` | 不能直接删除空间候选点 | 基线可只记录覆盖,不删点;加速版再维护朝向可行集合 | +| 定向源后备清除 | 思路可靠 | 候选集合有界后,用边长20m小方格中心,中心到格内点≤10√2<20,逐点 `/clear` | +| 停止条件 | 严格正确 | 清除16个不同频道,或每频道清除成功/完整覆盖证明不存在 | +| `/clear` 不切换频道 | 正确 | 清除后当前测向频道不变,影响下一次切换耗时 | +| 协议幂等 | 正确 | 重试同 `request_id` 和完全相同内容;`accepted=false` 不更新状态 | +| 现实预算 | 关键风险 | 用 `remaining_real_duration_s` 管理;预留退出和故障恢复;日志≤2MB | +| 正式测试 | 时间紧 | 问题3和4各3次;建议9月13日15:30前完成,17:30后不能启动新测试 | + +--- + +## 四、重点补强建议 + +### 1. 问题2:候选区域必须明确非空处理 + +建议把第一次测向后的候选集合写清楚: + +\[ +K_1=\Omega \cap W(s_1,\theta_1\pm\delta)\cap B(s_1,1500) +\] + +其中 \(W\) 是角域,\(\delta\) 取 \(1.005^\circ\) 保守裕量。 +然后: + +\[ +\mathcal Q=\{q:\max_{g\in K_1}\|q-g\|\le1000\} +\] + +若 \( \mathcal Q\neq\varnothing \),在其中采样选点,评估最坏后验包围圆半径和移动成本。 +若 \( \mathcal Q=\varnothing \),则退化为: + +\[ +q^*=\arg\min_q \max_{g\in K_1}\|q-g\| +\] + +即选一个使最坏接收距离最小的点,同时结合交会角筛选。 +若 \(K_1\) 的外包圆为 \(B(c,r)\) 且 \(r<1000\),则 \(B(c,1000-r)\) 是更简单的充分候选区域。 + +### 2. 问题4:定向源不要过早依赖 `no_signal` 删点 + +第4问中,`no_signal` 只说明“当前位置、接收半径、朝向”联合假设下不可见,不能单独排除位置。 +基线策略建议: + +- 用49点覆盖保证发现; +- 发现后主动测向缩小候选; +- 若失去信号,不再强行靠 `direction` 追踪,而是对当前有界候选集合做20m圆盘覆盖,逐点 `/clear`; +- 若时间允许,再维护朝向可行集合,减少无效检测。 + +### 3. 现实20分钟:请求数比虚拟时间更关键 + +49点×20频道最多980次检测,若每次网络延迟0.5–1秒,现实时间可能超过8–16分钟。 +建议: + +- 已清除频道不再检测; +- 已发现待定位频道可暂停覆盖扫描; +- 共享检测位置:到一个点后顺便测多个未知频道; +- 自适应覆盖:根据 no_signal 覆盖记录跳点,但必须保持证明完整; +- 正式测试前实测接口延迟、日志大小、总请求数。 + +### 4. 问题1:直径圆覆盖判断可给更明确算法 + +对凸多边形 \(P\): + +- 先求直径 \(D=\max_{i,j}\|v_i-v_j\|\); +- 枚举所有顶点对作为候选直径,检查其余顶点是否都在以该对为直径的圆内; +- 若存在一个直径对使所有顶点在圆内,则覆盖;否则不覆盖; +- 但保证清除时不要用直径圆,要用最小包围圆 \( \rho(K)\le20 \)。 + +--- + +## 五、建议的最小可行路线 + +如果时间不足,按这个顺序做: + +1. **P0+P1**:协议、计时、状态更新、幂等重试。 +2. **P2**:角域交、直径、最小包围圆。 +3. **P3**:问题2候选区域和选点。 +4. **P4**:问题3全向闭环,49点覆盖,发现—定位—清除—停止。 +5. **P5**:问题4定向扩展,后备清除。 +6. **P7**:演练后尽早正式测试。 + +可删减:P6中的复杂调度、朝向联合维护、学习型策略。 +不可删减:覆盖证明、停止条件、协议幂等、日志、正式测试。 + +--- + +## 六、结论 + +现有设计思路**整体可行且偏保守**,适合竞赛中“先保证全清,再优化时间”的目标。 +最需要立即补强的是: + +1. 问题2中 \(K_1\) 和 \( \mathcal Q\) 的严格定义及空集处理; +2. 问题4中定向源朝向不确定性和 `no_signal` 的正确使用; +3. 现实20分钟窗口下的请求数控制; +4. 正式测试时间安排,务必尽早演练并保留正式机会。 + +下一步最有价值的工作仍然是:**协议计时基线 + 几何内核 + 全向闭环**。完成这条链路后,再用演练数据决定是否加入加速策略。 \ No newline at end of file diff --git a/B题建模分析与实施计划.md b/B题建模分析与实施计划.md index f80a019..90c9bf1 100644 --- a/B题建模分析与实施计划.md +++ b/B题建模分析与实施计划.md @@ -2,9 +2,23 @@ 根据你们对工程实践和物理模型的熟悉程度,确定研究 B 题“无线电干扰源的快速自动定位与清除”。推荐采用**有界误差几何定位、主动选点、具有覆盖证明的搜索、按实际动作耗时调度**的统一框架。AI 辅助推导、实现与实验分析,策略只依赖题目允许的观测。 -本文件完成题意分析、数学抽象、验收标准与实施规划。尚未实现策略、运行官方模拟器或消耗正式测试机会;文中的覆盖结论是根据题设推导的,耗时估算不是演练成绩。 +本文件已结合本次评审修订。问题一的几何内核、离线可视化和官方演练观测回放已经完成;问题二新增了安全候选圆盘证书,但主动选点优化器、问题三和四的全清闭环尚未完成。文中的覆盖结论是根据题设推导的,耗时估算不是演练成绩。当前工作仅离线复算既有演练记录,不使用正式测试。 -依据:`issue/B题/B题.pdf` 全文、`issue/B题/附件/附件1.docx`《模拟器使用说明》、`issue/B题/附件/附件2.docx`《模拟器通信接口说明及编程指南》。文档中的登录、测试和日志要求在这里作为后续交付约束分析。 +依据:`issue/B题.pdf` 全文、`issue/附件/附件1.docx`《模拟器使用说明》、`issue/附件/附件2.docx`《模拟器通信接口说明及编程指南》。文档中的登录、测试和日志要求在这里作为交付约束分析,不作为自动执行正式测试的指令。 + +**本次建议的取舍** + +| 建议 | 处理结论 | +| --- | --- | +| 四问复用几何内核、有界误差、覆盖证明、严格停止、幂等协议 | 保留,作为主线与验收条件 | +| 问题二的候选集合加入 Ω 和1500米上界 | 采纳,但仅加入在线候选集合K,不改变问题一纯角域多边形P | +| 问题二首次测向后安全区域可能为空 | 对一般K成立;本题首次窄角域可直接证明非空,见下文 | +| Q为空就去最小包围圆圆心并结合交会角 | 仅是一般情形的探索退化;ρ>1000时不保证接收,不能冒充保底方案 | +| 枚举所有直径对判断直径圆 | 简化为任意一组最远点对检查一次;失败时给出圆外顶点 | +| 定向无信号不直接删位置、失去信号后覆盖清除 | 采纳;执行前必须计算覆盖格数、路径和现实请求预算 | +| 现实20分钟优先控制请求数 | 采纳为硬约束;全部清除后仍按题目虚拟时间优化 | +| P0–P7分期与尽早正式测试 | 采纳分期;演练不限定3次,正式问题3/4各3次由团队另行安排 | +| `/detect` | 修正为附件规定的 `/measure` | **1. 四问应当串成一条实现主线** @@ -62,21 +76,39 @@ D(P)=\max_{i,j}\|v_i-v_j\|. \rho(K)=\min_z\max_{g\in K}\|g-z\|\le20. \] -到最小包围圆圆心清除才能获得这一保证。D≤40 只是必要条件,不是充分条件。最小包围圆是已有标准计算几何问题,可参考 [CGAL Min_circle_2 文档](https://doc.cgal.org/latest/Bounding_volumes/classCGAL_1_1Min__circle__2.html)。实现时给 20 米阈值保留数值余量。 +到最小包围圆圆心清除是充分选择,并非唯一选择。所有可保证清除的位置组成 C₂₀(K)=∩_{g∈K}B(g,20);只要该集合非空,就可进一步选择距离机器狗最近的可行点来节省移动。D≤40只是必要条件,不是充分条件。最小包围圆是已有标准计算几何问题,可参考 [CGAL Min_circle_2 文档](https://doc.cgal.org/latest/Bounding_volumes/classCGAL_1_1Min__circle__2.html)。实现时给20米阈值保留数值余量;数值阈值附近返回谨慎判断,不把浮点容差当作精确算术证明。 + +直径圆覆盖只需任意一组最远顶点A、B:包含距离D的两点且半径为D/2的圆,其圆心只能是(A+B)/2。因此检查一次全部顶点即可;若失败,换另一组直径对也不可能产生覆盖圆。问题一实现现输出最坏超出距离、圆外顶点索引和一个具体反例顶点。 在线定位还可用 Ω 和“收到信号意味着距离≤1500”约束 K。圆盘交角域可能包含曲线边界;若用多边形近似,必须构造外包近似,才能维持“不丢失真源”的保证。用圆周采样点连成的内接多边形不能直接作为保守外包。 第 2 问建议先明确“较好的定位效果”:以新观测后的最坏包围圆半径为主要几何指标,再考虑移动和检测成本。交会角大、面积小可作为候选点筛选指标,但不是对所有未知位置都最优的证明。 -第一次测向得到 K₁ 后,对全向源,一个保守且可以说明的第二检测点候选区域为: +第一次收到direction后,明确取K₁=Ω∩W(s₁,θ₁±δ)∩B(s₁,1500)。保留整个距离圆盘是保守处理,忽略direction还隐含的近距离排除不会丢失真源。对全向源,第二检测点的安全候选区域为: \[ \mathcal Q=\{q:\max_{g\in K_1}\|q-g\|\le1000\}. \] -在这里测量,不论实际目标位于 K₁ 何处,接收距离都满足题目最小接收半径;可能返回 direction,也可能返回 near。若已知外包圆 K₁⊆B(c,r),且 r<1000,则 B(c,1000−r) 是更容易构造的充分候选区域。不要误写成“候选点距第一次检测点不超过1000米”。 +在这里测量,不论实际目标位于K₁何处,接收距离都满足题目最小接收半径;可能返回direction,也可能返回near。若已知外包圆K₁⊆B(c,r),且r≤1000,则B(c,1000−r)是充分候选区域,等号时是单点。不要误写成“距第一次检测点不超过1000米”。宽松外包圆的r>1000不能证明真实Q为空。 -在候选区域内,围绕当前测向方向的两侧取有限候选点,分别评估可能观测下的区域收缩和动作耗时。最坏情形目标可写为 sup ρ(K₁∩W(q,θ′)),其中 θ′遍历与 K₁和误差界一致的观测,near 分支单独处理。稀疏采样评估是启发式,需要通过加密采样和误差边界案例验证,不能标成严格最坏情形最优解。 +本题还有比“检查Q是否为空”更强的解析结论。令R=1500,u为第一次示向方向,δ≤45°,定义 + +\[ +a=\frac{R}{2\cos\delta},\qquad c=s_1+a u. +\] + +整个截断角域W∩B(s₁,R)包含在B(c,a)内。证明:任意候选g=s₁+t uφ满足0≤t≤R、|φ−θ₁|≤δ,因此 + +\[ +\|g-c\|^2=t^2+a^2-2at\cos(\varphi-\theta_1). +\] + +该式关于t是凸函数,最大值在0或R取得;两端都不超过a²。于是K₁⊆B(c,a),从而B(c,1000−a)⊆Q。δ=1.005°时,a≈750.115391米,安全圆盘半径约249.884609米,严格为正;题设δ=1°同样成立。即使检测点或c在Ω外也合法,因为机器狗不受源位置圆域限制。实现见 `geometry/planning.py::first_bearing_candidate_disk`。 + +因此,在本题一致的首次测向条件下无需依赖“Q空集退化”。对后续更一般的非空紧集K,Q非空当且仅当ρ(K)≤1000;若确实ρ>1000,去MEC圆心只是最小化最坏接收距离的探索动作,必须允许no_signal。若K为空,应先核查频道、数据、误差界与几何计算,不给空集编造中心。上述证书只保证距离;不能直接用于定向源的朝向可见性保证。 + +在上述安全圆盘内,围绕测向轴两侧取候选点,计算后验包围半径与动作代价。最坏情形目标可写为sup ρ(K₁∩W(q,θ′)),θ′遍历与K₁和误差界一致的读数;实现先用目标位置与误差区间端点采样,并加密检查。若返回near,则候选源位于当前点5米以内,可直接在当前位置尝试清除该频道。交会角仅作筛选或同分择优,不为改善交会角越出已证明安全的候选区域。采样是启发式,不能称为严格最坏情形最优解;圆弧先验如需离散,使用外包切线多边形并记录逼近误差。 **4. 搜索完整性和停止条件要有可验证的依据** @@ -96,7 +128,9 @@ S=\{(700i,700j):i,j=-3,-2,\ldots,3\},\quad |S|=49. 作为规模检查,49点全部扫描20频道最多需要980次检测。若从原点去网格一角,再按逐行折返路线扫完,纯扫描路程约36569.85米;每点检测成本上界120秒,纯扫描虚拟总耗时上界约13193.97秒,即3.665小时。**该估算不含定位、清除及其绕行,也不保证满足现实20分钟窗口。**它说明覆盖基线在100小时虚拟上限内有设计空间,现实可行性仍要测接口延迟、总请求数和日志大小。 -发现源后,先通过主动测向缩小候选集合。若定向源再次失去信号,可对已知有界候选集合构造半径小于20米的有限圆盘覆盖,逐点调用 /clear,作为不依赖朝向的后备清除方法。例如边长20米的小方格中心距格内任何点不超过10√2米;保留与候选集合相交的格子便可得到保守覆盖。该后备方法保证几何上的有限可清除性,耗时和请求预算须在演练中验证,不宜作为默认追踪策略。 +发现源后,先通过主动测向缩小候选集合。若定向源再次失去信号,可对已知有界候选集合构造半径小于20米的有限圆盘覆盖,逐点调用/clear,作为不依赖朝向的后备清除方法。边长20米方格中心距格内任何点不超过10√2米;保留与候选集合相交的格子即可保守覆盖。格子按统一半开区间索引,源恰在网格线上时至少归入一个格子;若相交判定数值不确定则保留格子。 + +有限不等于现实预算可行。设候选外包框宽w、高h,使用全局20米网格枚举时,上界可取N≤(⌈w/20⌉+2)(⌈h/20⌉+2),再以实际相交格子列表得到精确请求上限。生成路径长度L后,单源全失败清除耗时为L/5+3N;若最后一次成功则为L/5+3(N−1)+5。现实代价用N乘实测单请求高分位耗时加本地计算时间估计。不满足剩余预算时应继续有希望的定位、选择其它任务或按预算退出,不能声称后备清除保证20分钟内成功。即使已知源落在半径1500米圆盘,按外包框枚举仍有约2.3万个格子的量级,故宽候选集合不能直接盲扫。 停止成功的依据为以下任一项: @@ -139,7 +173,9 @@ AI 的适合用法是把这些模块逐一变成可测试程序、生成边界 协议必须同时检查 HTTP 状态和 accepted。accepted=false 时不得更新状态,返回的 virtual_time_s=0 不能重置本地虚拟时钟。网络超时重试同一动作必须复用原 request_id 和完全相同的内容;处理重试响应也不能重复累加本地成功数与费用。新动作使用新ID,不并发调用不同动作。 -用 /enter 返回的 remaining_real_duration_s 管理本局现实预算,预留退出与故障恢复余量。根据附件,正式日志上传要求文件不超过2MB;接口延迟、日志增长和异常恢复都要在演练中实测。 +用/enter返回的remaining_real_duration_s建立本地单调时钟截止时间;后续响应若提供剩余时间则校准,不能把首次剩余时间当成永远不变。记录每次请求的现实往返耗时,预留退出与故障恢复余量。例如先预留60秒,再由演练高分位延迟调节;60秒是初始工程参数,不是官方规定。估计剩余请求数时同时计入测量、失败重试、清除和退出,禁止并发动作。 + +980次检测在单次0.5–1秒的假设下占490–980秒(约8.17–16.33分钟),还没有定位清除余量。这个延迟范围不是实测结论;本地接口和模拟器计算的实际耗时应测量。当前14个动作的单次短演练不足以估计高分位延迟或全程稳定性。已清除频道跳过,已发现频道暂停覆盖并进入定位队列,移动到同一点后共享频道测量;自适应跳点必须保留逐频道覆盖证据。根据附件,正式上传日志不超过2MB,需监测官方日志体积;自建JSON大小不能冒充加密日志的大小。 基准实验至少覆盖:全向与混合源;数量10和16;接收半径1000和1500米;目标贴近圆域边界且朝外;测向接近0°/360°;误差取界值;检测点距离5米及清除距离20米的边界;重复请求、拒绝动作及短暂断连。样例数量按剩余时间和运行成本安排,不把有限样例通过等同于数学保证。 @@ -147,19 +183,20 @@ AI 的适合用法是把这些模块逐一变成可测试程序、生成边界 **7. 建议实施次序与阶段交付** -以下工期是规划估计,不是已经完成的工作。依据附件中的北京时间截止安排,先完成可演练版本,再优化。 +下面按P0–P7列出可执行交付与当前状态。排期以验收门槛推进,避免把“几何代码完成”误认为“全清策略完成”。 -| 阶段 | 建议投入 | 具体工作 | 进入下一阶段的条件 | +| 阶段 | 交付与执行任务 | 验收门槛 | 当前状态 | | --- | --- | --- | --- | -| P0 规则固定 | 1–2小时 | 整理规则表、模块接口、日志字段;核实模拟器可运行 | 成本口径和状态更新规则一致 | -| P1 工程基线 | 2–4小时 | 实现4类请求、幂等重试、计时回放和自建测试环境 | 199秒示例一致,失败与重试不污染状态 | -| P2 几何内核 | 4–6小时 | 完成问题1,实现角域交、包围圆、问题2候选点 | 退化几何和误差边界检查通过 | -| P3 全向闭环 | 4–6小时 | 覆盖发现、局部定位、清除、停止、统计 | 问题3演练可完整退出,输出可追溯 | -| P4 定向扩展 | 4–6小时 | 验证49点覆盖;实现失去信号处理与后备清除 | 问题4边界朝外案例和演练通过 | -| P5 加速与定稿 | 4–8小时 | 做共享测量、目标排序等消融,固定参数和版本 | 性能改善有证据,完整性标准不退化 | -| P6 正式与论文 | 单独留出半天以上 | 分别完成3次正式测试、核查上传、导出原始日志、完成论文表格 | 6次记录、案例编码和交付材料一致 | +| P0 规则固定 | 规则表、状态机、四类API字段与成本 | 示例逐步105/111/194/199秒;clear不切换频道 | 已核对规则,完整状态机待工程化 | +| P1 协议基线 | enter/measure/clear/exit客户端;串行、幂等、截止时间 | accepted=false和超时重试不污染状态;主动退出;故障注入回放 | 已有少量测向采集器及真实计时回放;完整客户端待实现 | +| P2 几何内核 | 问题一解答、区域分类、直径圆证据、MEC、清除余量 | 跨界、退化、无界、解析反例、独立算法对照 | 已实现并优化,20项自动化测试通过 | +| P3 问题二选点 | K₁先验、安全候选盘、后验半径采样与near分支 | 距离证书有效;采样加密;不把探索当接收保证 | 解析非空证明及证书已实现;选点器与实验待实现 | +| P4 全向闭环 | 49点覆盖记录、定位队列、清除、停止、日志统计 | 10/16源、边界源和错误响应案例;官方演练全清并正常退出 | 待实现 | +| P5 定向扩展 | no_signal联合约束、覆盖发现、有限清除后备与预算 | 朝外边界源不漏;真源不误删;格数与退出预算检查 | 已有证明和设计;闭环待实现 | +| P6 加速消融 | 共享测点、自适应覆盖、目标排序、提前清除 | 配对案例全清不退化,时间/请求数改善可复算 | 可删减;复杂学习与朝向搜索后置 | +| P7 正式与论文 | 固定版本、论文表1、日志原名、上传核查 | 问题3/4各3份正式记录与程序版本对应 | 团队后续安排;本任务不执行正式测试 | -按当前日期2026年9月10日,建议9月10日完成P0/P1并启动P2,9月11日完成主要几何与问题3闭环,9月12日完成问题4与主要演练;稳定后即可安排正式测试,不把全部正式机会留到最后。附件建议9月13日15:30前完成正式测试,17:30后不能启动新的演练或正式测试;已进入上传队列的日志仍会继续上传。这里引用的是所提供附件的规则,运行前还应核对模拟器界面公告。 +按2026年9月10日起的剩余时间,建议10日补齐P1并衔接P3,11日完成P4自建边界案例与全向演练,12日完成P5及混合源演练;未达验收就删减P6并优先修正闭环。演练不限3次,正式机会为问题3和4各3次。团队在版本稳定后自行安排正式记录,留出失败恢复和论文整理时间。附件建议9月13日15:30前完成正式测试,17:30后不能启动新的演练或正式测试;已进入上传队列的日志仍会继续上传。此处引用所给附件安排,运行前核对模拟器界面公告;这份排期不改变当前禁止使用正式测试的限制。 若时间不足,优先删减加速策略,保留可解释定位、覆盖证明、可靠协议和完整日志。开发组织可按几何算法、工程接口、实验论文分工,具体人员由团队安排。 @@ -167,6 +204,10 @@ AI 的适合用法是把这些模块逐一变成可测试程序、生成边界 **8. 当前已落实的结论与下一步** -已完整阅读 B 题四页和两份附件,确认四问衔接、协议动作、计时、日志及正式测试要求;推导了49点发现覆盖方案,独立复算了纯扫描规模与虚拟时间上界。另对497160个位置与朝向组合进行了辅助数值校核,含圆域边界、网格顶点和随机内部位置,未发现覆盖遗漏;有限采样只辅助检查上述证明,不代替证明。尚未验证官方服务延迟、误差场的具体实现或任何策略成绩。 +已完成题面与附件核对、49点覆盖推导及扫描成本复算;此前497160个位置与朝向组合的辅助数值校核未发现覆盖遗漏,但采样不代替证明。问题一已通过官方演练三点观测离线验证;该次只有enter、measure、exit,没有清除动作,不能当作全清策略成绩。 -下一步最有价值的实现是 P1 的协议与计时基线,以及 P2 的角域交与包围圆内核。完成它们后,先交付一条“发现—定位—清除—证明完成—退出”的可运行链路,再用演练数据决定加速方向。 +本次将MEC从全支撑点枚举改为带固定随机种子的Welzl增量算法,保留原枚举法作独立对照;对外输出增加直径圆反例顶点、超出距离和20米清除余量。CLI可显式追加0.005°舍入裕量。真实演练默认δ=1°时D=23.996723米、ρ=12.068583米;δ=1.005°时D=24.119088米、ρ=12.130377米,两种口径下直径圆都不覆盖,但MEC半径都小于20米。原始演练记录未改写。 + +可复现报告见 `output/q1/optimization_report.json`,问题一证明、输入输出及数值限制见 `docs/问题一解答.md`。局部MEC基准只反映本地几何计算,不能据此宣称请求延迟、完整策略或官方成绩得到同样提升。 + +下一步优先补齐P1完整协议与故障回放,再实现P3选点器并接入P4“发现—定位—清除—证明完成—退出”闭环。P2已经具备可复用输入输出;不要重复开发几何内核或提前堆叠学习算法。以全向演练的实际请求、日志和耗时决定P5后备规模与P6加速投入。 diff --git a/README.md b/README.md index e69de29..3f87e42 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,29 @@ +# B题问题一 交会定位 + +给定同一干扰源的检测点坐标及带±1°误差的示向度,计算前向角域交集、定位区域直径、直径圆覆盖判断与最小包围圆。 + +- [完整中文解答与算法证明](docs/问题一解答.md) +- [修订后的四问思路与P0–P7计划](B题建模分析与实施计划.md) +- [核心代码](geometry/localization.py) +- [离线交互模拟](output/q1/question1.html) +- [官方演练观测与原始记录](output/q1/practice/) +- [优化与舍入敏感性报告](output/q1/optimization_report.json) + +Python3.10及以上,核心算法和演示生成无需安装第三方库。 + +```powershell +python -m geometry examples/q1/triangle.json +python main.py examples/q1/normal.json -o output/q1/result.json +python -m geometry examples/q1/normal.json --rounding-margin-deg 0.005 +python -m unittest discover -s tests -v +python scripts/build_q1_demo.py +python scripts/benchmark_q1.py +``` + +用浏览器打开 `output/q1/question1.html`。可选择合成反例或官方演练,逐个加入测点,切换全景与定位区域放大。 + +输出新增直径圆覆盖反例顶点、最大超出距离和20米清除余量;默认仍为题设±1°,舍入裕量须显式启用。最小包围圆使用可复现的随机增量算法,原枚举法保留作独立对照。`geometry.first_bearing_candidate_disk` 提供问题二首次测向安全区域的解析证书;问题二选点优化器及问题三、四的全清闭环仍待实现。 + +可视化事件检查使用Node.js:`node scripts/verify_visualization.cjs`。可选PNG重绘使用Pillow及Windows微软雅黑字体:`python scripts/render_q1_figures.py`;这些只用于图形校核,不是核心求解依赖。 + +本项目只使用官方**演练**采集了问题一所需观测,未使用正式测试。采集器不会启动测试,必须先在界面确认“问题3演练”、接口已就绪,再提供 `--practice-confirmed` 和本局案例编码。参见完整解答中的采集说明。 diff --git a/docs/问题一解答.md b/docs/问题一解答.md new file mode 100644 index 0000000..67d91ad --- /dev/null +++ b/docs/问题一解答.md @@ -0,0 +1,249 @@ +# B题问题一 交会定位区域的直径与圆覆盖 + +问题一可以转化为二维半平面交问题:每次带误差的示向度给出一个前向角域,多次测量的交集就是定位区域。对于非空有界定位多边形,直径等于最远顶点对的距离。**以这个直径为直径的圆不一定覆盖定位区域。**判断是否能覆盖,应检验最远顶点对中点到所有顶点的距离,或直接计算最小包围圆。 + +代码、合成算例与官方演练观测均已实现和保存。本解答只解决问题一;在官方问题3演练中采集同一频道的三个检测点,用于检验交会计算,没有执行全域搜索清除任务,未使用正式测试。 + +**一 题目假设和模型边界** + +检测点为 \(S_i=(s_{ix},s_{iy})\),测得示向度为 \(\theta_i\),误差半角为 \(\delta=1^\circ\)。角度从正东开始逆时针计算。同一频道对应同一干扰源;位置不变时重复检测不会消除误差。 + +本问求附录2交会法所定义的多边形,因此主模型只取前向角域之交,不额外加入1800米圆域或1500米接收上界。这些先验可用于后续搜索定位,但加入圆盘后边界可能包含圆弧,不能仍按原问题的纯多边形处理。本程序也不以图像视窗作为真实边界。 + +模拟器示向度保留两位小数,正文规定误差在±1°内。本次主计算使用±1°;如需把独立舍入误差也纳入保守预算,可用 `--rounding-margin-deg 0.005` 显式追加半角,输出会记录实际误差半角。这是额外的工程裕量,不是题面规定。 + +**二 从测向角域得到线性约束** + +设二维叉积 \([a,b]=a_xb_y-a_yb_x\),令 + +\[ +u_i^-=(\cos(\theta_i-\delta),\sin(\theta_i-\delta)),\qquad +u_i^+=(\cos(\theta_i+\delta),\sin(\theta_i+\delta)). +\] + +未知源位置 \(X=(x,y)\) 必须满足 + +\[ +[u_i^-,X-S_i]\ge0,\qquad [u_i^+,X-S_i]\le0. +\] + +这两条不等式选择两条边界射线之间的前向角域,而不是无限延伸的两条直线。写为标准形式 \(a_jx+b_jy\le c_j\): + +\[ +\begin{aligned} +\sin(\theta_i-\delta)x-\cos(\theta_i-\delta)y +&\le \sin(\theta_i-\delta)s_{ix}-\cos(\theta_i-\delta)s_{iy},\\ +-\sin(\theta_i+\delta)x+\cos(\theta_i+\delta)y +&\le-\sin(\theta_i+\delta)s_{ix}+\cos(\theta_i+\delta)s_{iy}. +\end{aligned} +\] + +程序另外保留 \(u_i\cdot(X-S_i)\ge0\) 的前向约束。当 \(0<\delta<90^\circ\) 时它是冗余的,但可让 \(\delta=0\) 的扩展情形正确表示射线,而不是整条直线。实现支持0≤δ<90°,题目算例始终使用1°。 + +定位区域为 + +\[ +P=\bigcap_{i=1}^{n}W_i=\{X:AX\le b\}. +\] + +通过向量而不是简单的角度大小比较处理约束,可自然处理359°与0°跨界问题。 + +**三 可复现的算法** + +1. 验证输入为有限数值,将检测点平移至其均值附近并按尺度归一化,减小大坐标带来的抵消误差。 +2. 构造所有半平面;枚举每对不平行边界直线的交点。 +3. 只保留满足全部不等式的交点;去重后用单调链算法求凸包,并保留点、线段等退化结果。 +4. 判断有无非零方向 \(d\) 满足 \(Ad\le0\)。若存在,任一可行点沿该方向无限前进仍可行,区域无界,直径为无穷大。 +5. 对有界非空区域,枚举凸包顶点对,得到直径及一对最远顶点。 +6. 用任意一组最远顶点对中点与半径D/2检验圆覆盖;失败时返回圆外顶点及最大超出距离。用Welzl随机增量法求最小包围圆,输出20米清除半径余量,以区分“直径圆覆盖”和“保证清除”。 + +非空前向角域的交集不会包含一条双向无限直线,即它是尖的多面集。这保证非空区域存在极点,所以在本模型中可以通过是否存在可行边界交点来判空。无观测时需单独返回整个平面无界,不能依据“没有顶点”误判为空集。 + +二维非平凡衰退锥若存在,至少有一条边界方向与某个约束边界平行。因此检查每条边界的两个切向方向是否满足 \(Ad\le0\),即可判定无界性。无界情形中输出的有限顶点仅是区域的极点,不构成完整有限多边形。 + +伪代码如下: + +```text +输入同一频道的测量列表 M +若 M 为空:返回无界 +H ← 将每次测量转换为前向角域的半平面约束 +V ← 所有边界对交点中满足 H 的点 +若 V 为空:返回空集 +若存在非零 d 满足 H 的齐次约束 A d ≤ 0:返回无界 +V ← 去重并排序为凸包 +(D, A, B) ← V 中的最远顶点对 +O ← (A+B)/2 +覆盖成立 ← max_{v∈V} ||v−O|| ≤ D/2 +返回 V、D、A、B、O、覆盖判断和最小包围圆 +``` + +设半平面数为k(本实现k=3n),最终顶点数为m。枚举交点并检验全部约束需要O(k³),直径计算O(m²),无界性检查O(k²)。可行交点排序的代价上界为O(k² log k),临时交点存储上界O(k²)。新增直径圆证据只需O(m)遍历。 + +最小包围圆改为Welzl随机增量算法:先打乱点顺序;已有圆包含新点则保留,否则强制新点成为边界点并解更小子问题;二维最多需要3个支撑点。3个强制边界点必须用外接圆,不能误用这3点的无约束最小圆。实现以显式栈代替递归,防止大量点超过Python递归深度。固定随机种子使输出可复现,理论期望线性指随机排列下的期望,不是该固定种子的最坏复杂度保证。对外函数内部也做坐标归一化,最后重新计算所有点到输出圆心的最大距离,保持数值包含性。旧O(m⁴)支撑点枚举保留为独立测试基准。 + +半平面交与直径仍采用容易审查的小规模算法,未为少量测点引入更复杂的旋转卡壳。大规模在线数据可后续替换半平面交,不能把MEC局部提速等同于整个求解器线性复杂度。 + +**四 为什么只枚举顶点对就能得到直径** + +有界凸多边形内任意两点可表示为顶点的凸组合: + +\[ +x=\sum_i\lambda_i v_i,\quad y=\sum_j\mu_jv_j,\quad +\lambda_i,\mu_j\ge0,\quad\sum_i\lambda_i=\sum_j\mu_j=1. +\] + +由三角不等式, + +\[ +\|x-y\|=\left\|\sum_{i,j}\lambda_i\mu_j(v_i-v_j)\right\| +\le\sum_{i,j}\lambda_i\mu_j\|v_i-v_j\| +\le\max_{i,j}\|v_i-v_j\|. +\] + +最远顶点对本身又属于区域,因此上界可以达到: + +\[ +\boxed{D(P)=\max_{i,j}\|v_i-v_j\|.} +\] + +点的直径为0,线段的直径为端点距离。对空集,本程序约定直径未定义,输出null;对无界区域,输出 `diameter_infinite=true`,不把Infinity写入不兼容标准的JSON数值。 + +**五 直径圆能否覆盖的充分必要条件** + +取任意最远顶点对A、B,距离为D,令O=(A+B)/2。如果半径D/2的某个圆能够同时包含A和B,则 + +\[ +D=\|A-B\|\le\|A-O'\|+\|O'-B\|\le D. +\] + +两个不等式只能同时取等号,因此其圆心只能是O。又因为圆盘是凸集,包含全部顶点等价于包含整个多边形。所以 + +\[ +\boxed{\text{存在直径为D的覆盖圆}\iff +\max_{v\in V}\|v-(A+B)/2\|\le D/2.} +\] + +不必尝试移动直径圆,也不必尝试其它最远点对:若这个唯一可能的圆心不满足覆盖,就不存在直径为D的覆盖圆。最小包围圆半径ρ满足ρ≥D/2,当且仅当ρ=D/2时存在这样的覆盖圆。 + +代码的 `diameter_circle.max_excess_m` 为maxᵥ‖v−O‖−D/2;超出数值容差时返回 `witness_vertex` 和从0开始的 `outside_vertex_indices`。因此输出既有判断,也有可独立复核的反例。判定中使用 `coverage_tolerance_m`,边界附近的布尔结果属于浮点容差下结论。 + +保证清除使用MEC半径加数值余量εₘ,再比较20米。`clearance_certificate.remaining_margin_m=20−(ρ+εₘ)`;非负时支持当前圆心清除,负数时不作此保证。这里的证书以观测误差界正确、真源属于P和浮点几何计算可靠为前提,不是实际清除成功记录。数学上所有可行清除中心构成∩ᵥB(v,20),MEC圆心是充分选择;后续可在此集合中另选距离机器狗最近的点。 + +**六 满足题设±1°的具体反例** + +取三个检测点及示向度: + +| 检测点 | x(米) | y(米) | 示向度 | +| --- | ---: | ---: | ---: | +| S1 | −1000 | 0 | 1° | +| S2 | 540 | −500√3 | 121° | +| S3 | 520 | 520√3 | 241° | + +三个角域的交集恰为等边三角形,顶点是(0,0)、(40,0)、(20,20√3)。每个角域的一条边界对应三角形的一条边,另一条边界在三角形外侧,不切去三角形内部。可将真实源设在三角形中心(20,20√3/3),其真实方位与上述读数的偏差均小于1°,且三个检测距离都小于1500米。因此这不是脱离测向模型的任意图形反例。 + +它的直径D=40米;直径圆半径20米。以底边两端为最远点对,圆心为(20,0),到第三个顶点的距离是20√3≈34.6410米,因此不能覆盖。其最小包围圆为 + +\[ +O_*=(20,20\sqrt3/3),\qquad \rho_*=40/\sqrt3\approx23.0940\text{米}>20\text{米}. +\] + +![等边三角形定位区域反例](../output/q1/figures/counterexample-736-light.png) + +图中蓝色为定位区域,红色虚线为直径圆,绿色实线为最小包围圆。黑色加号只在合成场景中表示已知真源。 + +**七 官方演练数据验证** + +2026年9月10日,界面明确为“问题3 演练 测试”后,通过公开HTTP接口采集。案例编码为 `U5ZW-P9KN-TCAG-R7ST`,选取频道9。 + +| 检测点 | x(米) | y(米) | 示向度 | +| --- | ---: | ---: | ---: | +| S1 | 0 | 0 | 131.52° | +| S2 | −613.761885 | 391.530775 | 34.92° | +| S3 | −314.272134 | 656.683353 | 227.19° | + +重复在S1测量一次仍返回131.52°。这次重复观测只用于核查原地误差固定,没有作为新的独立测点加入模型。 + +使用±1°得到四边形,其顶点按逆时针顺序为: + +| 顶点 | x(米) | y(米) | +| --- | ---: | ---: | +| V1 | −460.698306 | 502.411776 | +| V2 | −456.424236 | 497.750714 | +| V3 | −440.569893 | 515.476604 | +| V4 | −441.370356 | 516.413163 | + +计算结果:D=23.996723米,最远点对为V1和V3;直径圆中心为(−450.634100,508.944190),半径11.998362米,但最远顶点到这个圆心的距离为12.602364米,所以该圆不能覆盖区域。最小包围圆中心为(−449.976955,507.952835),半径12.068583米。 + +![官方演练测向所得定位区域](../output/q1/figures/practice-736-light.png) + +共发送14个被接受的请求:1次进入、12次检测、1次退出,最终虚拟时间293.602198秒。日志末尾为 `/exit`、`accepted=true`、`exit_reason=user_exit`;界面显示测试正常结束。没有调用 `/clear`,所以这里不能将演练结束解读为“清除了所有源”。官方没有公开该源的坐标真值,本例验证的是观测交会计算、接口适配和几何自洽性,不声称验证了真实位置误差。 + +原始请求响应保存在 `output/q1/practice/*_actions.json`,保留了复算所需的请求ID、位置、频道与时间;三点观测及其计算结果保存在 `*_observations.json`。没有读取或解密模拟器隐藏场景数据。 + +**八 运行方式和代码入口** + +核心算法仅用Python标准库,支持Python3.10及以上;本次在Python3.12.14中验证。在项目根目录运行: + +```powershell +python -m geometry examples/q1/triangle.json -o output/q1/triangle_recomputed.json +python main.py examples/q1/normal.json +python -m geometry examples/q1/normal.json --rounding-margin-deg 0.005 +python -m unittest discover -s tests -v +python scripts/build_q1_demo.py +python scripts/benchmark_q1.py +``` + +输入JSON的最小格式: + +```json +{ + "measurements": [ + {"x": 0, "y": 0, "bearing_deg": 131.52, "half_angle_deg": 1}, + {"x": -613.7618853216438, "y": 391.53077545248124, "bearing_deg": 34.92}, + {"x": -314.2721344680094, "y": 656.6833525352392, "bearing_deg": 227.19} + ] +} +``` + +省略 `half_angle_deg` 时默认为1。输出包含区域状态、顶点、直径、最远点对、直径圆覆盖判断、反例证据、最小包围圆和清除余量。CLI的 `error_policy` 记录追加裕量与每条观测最终半角;默认追加0,不改变输入口径。`tolerance_m` 是归一化容差换算回米的尺度;核心算法不接收真源坐标,样例中的 `target` 只用于合成数据的可视化与测试。空集和无界区域不输出有限圆及清除证书。 + +| 文件 | 职责 | +| --- | --- | +| `geometry/localization.py` | 测向角域、半平面、区域分类、直径和包围圆 | +| `geometry/planning.py` | 问题二首次测向的距离保证候选圆盘;独立于问题一模型 | +| `geometry/__main__.py`、`main.py` | JSON命令行输入输出 | +| `tests/test_geometry.py` | 解析反例、随机几何、独立裁剪对照与退化测试 | +| `tests/test_practice_replay.py` | 离线复算演练计时与观测结果,不连接模拟器 | +| `tests/test_geometry_certificates.py` | 增量圆与穷举法对照、覆盖证据、清除阈值、舍入选项及首次选点证书 | +| `scripts/benchmark_q1.py` | 局部性能对比与官方演练误差界敏感性复算 | +| `scripts/capture_practice.py` | 在已经启动且已核验的演练中采集少量测向 | +| `scripts/build_q1_demo.py` | 用Python算法生成案例、结果和交互图数据 | +| `visualization/q1.template.html` | 可视化源模板;不另写一套求解算法 | +| `output/q1/question1.html` | 可直接打开的离线交互式模拟,所有数据已内嵌 | + +如需再次采集,先在模拟器明确启动“问题3演练测试”,等待接口就绪,读取案例编码,然后运行: + +```powershell +python scripts/capture_practice.py --robot-id 你的参赛队号 --practice-confirmed --case-code 界面中的演练案例编码 +``` + +接口协议没有返回演练/正式模式的字段,`--practice-confirmed` 是操作者确认已核验界面的显式参数,不能替代界面核查。采集脚本没有启动测试的功能,没有正式模式选项,只会调用 `/enter`、`/measure`、`/exit`。禁止在正式测试会话中运行。 + +**九 验证范围和数值限制** + +20项自动化测试通过,包括100组随机有界案例、60组与独立矩形裁剪实现的对照、250组增量圆与穷举圆对照、1500个共圆点及重复点/共线点、平移旋转缩放不变性,以及已保存演练日志的逐动作计时复算。新增测试还覆盖清除距离20米临界值、显式舍入选项、首次安全候选圆盘边界和零半径情形。已测试一组有限但直径超过10亿米的近乎平行测向,确认不会因为显示范围而被截断成小区域。 + +本次离线优化报告保存在 `output/q1/optimization_report.json`。同一演练观测的敏感性结果如下,原始记录不改写: + +| 误差半角 | 直径 / 米 | MEC半径 / 米 | 直径圆最大超出 / 米 | 含数值裕量的清除余量 / 米 | +| --- | ---: | ---: | ---: | ---: | +| 1°(默认题设) | 23.996723 | 12.068583 | 0.604002 | 7.931415 | +| 1.005°(额外舍入) | 24.119088 | 12.130377 | 0.608486 | 7.869621 | + +在本机60个共圆点的局部基准中,旧MEC枚举中位约306.5毫秒,新算法约0.096毫秒;4点时旧法约0.022毫秒、新法约0.029毫秒,小规模没有提速优势。这些是特定输入的局部实测,不是普遍倍率保证,也不代表网络、全清闭环或官方成绩。三点演练的完整问题一求解中位约0.099毫秒。运行基准脚本可以重新生成当前机器的报告。 + +浮点计算不是精确算术。实现使用归一化位置容差10⁻⁹、平行行列式阈值10⁻¹²和较严格的衰退方向角度容差。极端近乎平行、差异接近机器精度或接近退化的输入可能需要高精度几何谓词;结果接近临界阈值时应做容差敏感性检查,而不把最后若干小数位当作严格证明。正常题设坐标和测向精度下,本次案例远离这些数值极限。 + +交互图数据全部由同一Python求解器生成。已在最小DOM测试环境中检查32个“案例/测点数量/视图”组合,未发现缺失控件或NaN等非法图形坐标;并对360与736像素、明暗两种配色的SVG作静态图形检查。浏览器工具对本地文件URL的访问被策略拦截,因此没有声称完成真实浏览器的HTML/CSS布局验证。离线页面和对话内可视化均已生成;静态PNG可作为直接查看的补充。 + +几何算法参考:[CGAL最小包围圆定义与Welzl增量方法](https://doc.cgal.org/latest/Bounding_volumes/classCGAL_1_1Min__circle__2.html);[McGill旋转卡壳说明](https://www-cgrl.cs.mcgill.ca/~godfried/research/calipers.html)。本次代码仅用Python标准库,不依赖CGAL。 diff --git a/examples/q1/empty.json b/examples/q1/empty.json new file mode 100644 index 0000000..93eb0ad --- /dev/null +++ b/examples/q1/empty.json @@ -0,0 +1,20 @@ +{ + "id": "empty", + "name": "不相容观测", + "source": "synthetic", + "target": null, + "measurements": [ + { + "x": -100, + "y": 0, + "bearing_deg": 180, + "half_angle_deg": 1.0 + }, + { + "x": 100, + "y": 0, + "bearing_deg": 0, + "half_angle_deg": 1.0 + } + ] +} \ No newline at end of file diff --git a/examples/q1/normal.json b/examples/q1/normal.json new file mode 100644 index 0000000..91eab3d --- /dev/null +++ b/examples/q1/normal.json @@ -0,0 +1,29 @@ +{ + "id": "normal", + "name": "三点交会", + "source": "synthetic", + "target": [ + 80, + 90 + ], + "measurements": [ + { + "x": -600, + "y": -250, + "bearing_deg": 27.21505117707799, + "half_angle_deg": 1.0 + }, + { + "x": 650, + "y": -100, + "bearing_deg": 161.015051177078, + "half_angle_deg": 1.0 + }, + { + "x": 100, + "y": 700, + "bearing_deg": 268.4721225527146, + "half_angle_deg": 1.0 + } + ] +} \ No newline at end of file diff --git a/examples/q1/poor.json b/examples/q1/poor.json new file mode 100644 index 0000000..4d5e3b2 --- /dev/null +++ b/examples/q1/poor.json @@ -0,0 +1,29 @@ +{ + "id": "poor", + "name": "近共线检测点", + "source": "synthetic", + "target": [ + 900, + 50 + ], + "measurements": [ + { + "x": 0, + "y": 0, + "bearing_deg": 3.1798301198642345, + "half_angle_deg": 1.0 + }, + { + "x": 100, + "y": 0, + "bearing_deg": 3.576334374997351, + "half_angle_deg": 1.0 + }, + { + "x": 200, + "y": 0, + "bearing_deg": 4.085616779974877, + "half_angle_deg": 1.0 + } + ] +} \ No newline at end of file diff --git a/examples/q1/segment.json b/examples/q1/segment.json new file mode 100644 index 0000000..4d630a2 --- /dev/null +++ b/examples/q1/segment.json @@ -0,0 +1,23 @@ +{ + "id": "segment", + "name": "零误差退化线段", + "source": "synthetic", + "target": [ + 40, + 0 + ], + "measurements": [ + { + "x": 0, + "y": 0, + "bearing_deg": 0, + "half_angle_deg": 0 + }, + { + "x": 100, + "y": 0, + "bearing_deg": 180, + "half_angle_deg": 0 + } + ] +} \ No newline at end of file diff --git a/examples/q1/triangle.json b/examples/q1/triangle.json new file mode 100644 index 0000000..39439a0 --- /dev/null +++ b/examples/q1/triangle.json @@ -0,0 +1,29 @@ +{ + "id": "triangle", + "name": "直径圆反例", + "source": "synthetic", + "target": [ + 20, + 11.547005383792515 + ], + "measurements": [ + { + "x": -1000, + "y": 0, + "bearing_deg": 1, + "half_angle_deg": 1.0 + }, + { + "x": 540, + "y": -866.0254037844386, + "bearing_deg": 121, + "half_angle_deg": 1.0 + }, + { + "x": 520, + "y": 900.6664199358162, + "bearing_deg": 241, + "half_angle_deg": 1.0 + } + ] +} \ No newline at end of file diff --git a/geometry/__init__.py b/geometry/__init__.py new file mode 100644 index 0000000..0ece746 --- /dev/null +++ b/geometry/__init__.py @@ -0,0 +1,6 @@ +"""B题问题一:有界误差方位交会定位。仅使用 Python 标准库。""" + +from .localization import Measurement, solve, smallest_enclosing_circle +from .planning import first_bearing_candidate_disk + +__all__ = ["Measurement", "solve", "smallest_enclosing_circle", "first_bearing_candidate_disk"] diff --git a/geometry/__main__.py b/geometry/__main__.py new file mode 100644 index 0000000..627a3ad --- /dev/null +++ b/geometry/__main__.py @@ -0,0 +1,38 @@ +"""python -m geometry observations.json [-o result.json]""" + +import argparse +import json +from dataclasses import replace +from math import isfinite +from pathlib import Path +from .localization import Measurement, solve + + +def main(): + parser = argparse.ArgumentParser(description="B题问题一:角域交、直径与包围圆") + parser.add_argument("input", type=Path, help="包含measurements数组的JSON文件") + parser.add_argument("-o", "--output", type=Path) + parser.add_argument("--rounding-margin-deg", type=float, default=0, + help="额外误差半角,默认0;可显式设0.005进行舍入敏感性分析") + args = parser.parse_args() + try: + source = json.loads(args.input.read_text(encoding="utf-8-sig")) + measurements = [Measurement(**row) for row in source["measurements"]] + if not isfinite(args.rounding_margin_deg) or args.rounding_margin_deg < 0: + raise ValueError("额外误差半角必须为有限非负数") + measurements = [replace(m, half_angle_deg=m.half_angle_deg+args.rounding_margin_deg) + for m in measurements] + result = solve(measurements) + result["error_policy"] = {"additional_half_angle_deg": args.rounding_margin_deg, + "effective_half_angles_deg": [m.half_angle_deg for m in measurements]} + except (ValueError, TypeError, KeyError, OSError) as exc: + parser.error(str(exc)) + text = json.dumps(result, ensure_ascii=False, indent=2, allow_nan=False) + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(text + "\n", encoding="utf-8") + print(text) + + +if __name__ == "__main__": + main() diff --git a/geometry/localization.py b/geometry/localization.py new file mode 100644 index 0000000..80e32a3 --- /dev/null +++ b/geometry/localization.py @@ -0,0 +1,269 @@ +"""Intersect forward bearing wedges without an artificial bounding box. + +All calculations are floating point with normalized station coordinates. +The reference algorithm deliberately favors auditability over asymptotic speed. +""" + +from dataclasses import dataclass +from itertools import combinations +from math import cos, sin, radians, hypot, isfinite +from random import Random + +Point = tuple[float, float] +EPS = 1e-9 +PARALLEL_EPS = 1e-12 + + +@dataclass(frozen=True) +class Measurement: + x: float + y: float + bearing_deg: float + half_angle_deg: float = 1.0 + + def __post_init__(self): + values = (self.x, self.y, self.bearing_deg, self.half_angle_deg) + if any(isinstance(v, bool) or not isinstance(v, (int, float)) + or not isfinite(v) for v in values): + raise ValueError("坐标、示向度和误差半角必须是有限数值") + if not 0 <= self.half_angle_deg < 90: + raise ValueError("误差半角必须在 [0, 90) 度;题设为1度") + + +def cross(a: Point, b: Point) -> float: + return a[0] * b[1] - a[1] * b[0] + + +def distance(a: Point, b: Point) -> float: + return hypot(a[0] - b[0], a[1] - b[1]) + + +def halfplanes(m: Measurement) -> list[tuple[float, float, float]]: + """Return a*x+b*y<=c with unit normals, including the forward constraint.""" + theta = m.bearing_deg % 360 + lower, upper = radians(theta - m.half_angle_deg), radians(theta + m.half_angle_deg) + middle = radians(theta) + normals = [(sin(lower), -cos(lower)), (-sin(upper), cos(upper)), + (-cos(middle), -sin(middle))] + return [(a, b, a * m.x + b * m.y) for a, b in normals] + + +def convex_hull(points: list[Point], eps: float = EPS) -> list[Point]: + """Monotone chain, CCW, no repeated final vertex; retain segment endpoints.""" + ordered = sorted(points) + unique = [] + for p in ordered: + if not unique or distance(p, unique[-1]) > eps: + unique.append(p) + if len(unique) <= 2: + return unique + + def chain(seq): + out = [] + for p in seq: + while len(out) >= 2: + a = (out[-1][0] - out[-2][0], out[-1][1] - out[-2][1]) + b = (p[0] - out[-1][0], p[1] - out[-1][1]) + # Distance-to-line tolerance, not an unscaled area tolerance. + if cross(a, b) > eps * max(hypot(*a), hypot(*b), eps): + break + out.pop() + out.append(p) + return out + + return chain(unique)[:-1] + chain(reversed(unique))[:-1] + + +def diameter(points: list[Point]): + if not points: + raise ValueError("空点集没有本程序定义下的直径") + a = b = points[0] + value = 0.0 + for p, q in combinations(points, 2): + d = distance(p, q) + if d > value: + value, a, b = d, p, q + return value, (a, b) + + +def _circumcircle(a: Point, b: Point, c: Point): + u = (b[0] - a[0], b[1] - a[1]) + v = (c[0] - a[0], c[1] - a[1]) + det = 2 * cross(u, v) + if abs(det) <= PARALLEL_EPS * max(hypot(*u) * hypot(*v), 1e-300): + return None + uu, vv = u[0] ** 2 + u[1] ** 2, v[0] ** 2 + v[1] ** 2 + center = (a[0] + (uu * v[1] - vv * u[1]) / det, + a[1] + (u[0] * vv - v[0] * uu) / det) + return center, distance(center, a) + + +def _enclosing_circle_reference(points: list[Point], eps: float = EPS): + """Enumerate all 1/2/3-point supports; O(m^4), appropriate for small m. + + Radius is recomputed against every vertex to preserve numerical containment. + Coordinates should be normalized when there are very large offsets. + """ + if not points: + raise ValueError("空集不计算最小包围圆") + xs, ys = [p[0] for p in points], [p[1] for p in points] + best_c = ((min(xs) + max(xs)) / 2, (min(ys) + max(ys)) / 2) + best_r = max(distance(best_c, p) for p in points) + + def consider(center, radius): + nonlocal best_c, best_r + if radius > best_r + eps: + return + actual = max(distance(center, p) for p in points) + if actual <= radius + eps and actual < best_r: + best_c, best_r = center, actual + + for p in points: + consider(p, 0.0) + for a, b in combinations(points, 2): + consider(((a[0] + b[0]) / 2, (a[1] + b[1]) / 2), distance(a, b) / 2) + for a, b, c in combinations(points, 3): + circle = _circumcircle(a, b, c) + if circle is not None: + consider(*circle) + return best_c, best_r + + +def smallest_enclosing_circle(points: list[Point], eps: float = EPS): + """Welzl's randomized incremental algorithm, using an explicit call stack. + + The fixed shuffle makes results reproducible. Expected linear work refers + to randomized input order, not a worst-case guarantee for this fixed seed. + Normalize internally so this public helper also handles translated inputs. + The exhaustive reference is retained for independent regression comparisons. + """ + if not points: + raise ValueError("空集不计算最小包围圆") + if not isfinite(eps) or eps <= 0: + raise ValueError("eps必须为有限正数") + if any(len(p) != 2 or any(isinstance(x, bool) or not isinstance(x, (int, float)) + or not isfinite(x) for x in p) for p in points): + raise ValueError("点集必须由有限二维坐标组成") + origin = points[0] + scale = max(distance(origin, p) for p in points) + if scale == 0: + return tuple(origin), 0.0 + local = [((p[0]-origin[0])/scale, (p[1]-origin[1])/scale) for p in points] + tolerance = min(eps/scale, EPS) + Random(20260910).shuffle(local) + # Frame = (remaining prefix length, forced boundary, returning from child). + stack = [(len(local), (), False)] + circle = None + while stack: + n, boundary, returning = stack.pop() + if n == 0 or len(boundary) == 3: + # Forced support points must lie ON the boundary. For three points + # use their circumcircle, even when their unconstrained MEC is smaller. + circle = _circumcircle(*boundary) if len(boundary) == 3 else None + if circle is None and boundary: + circle = _enclosing_circle_reference(list(boundary), tolerance) + elif not returning: + stack.append((n, boundary, True)) + stack.append((n-1, boundary, False)) + elif circle is None or distance(circle[0], local[n-1]) > circle[1] + tolerance: + stack.append((n-1, boundary + (local[n-1],), False)) + center = (origin[0]+circle[0][0]*scale, origin[1]+circle[0][1]*scale) + # Recompute after transforming back: returned circle contains all input points + # to distance() precision, including any roundoff from the large translation. + return center, max(distance(center, p) for p in points) + + +def solve(measurements: list[Measurement], eps: float = EPS) -> dict: + """Solve Q1's pure angular intersection (no disk/range prior added). + + Nonempty intersections of <180-degree forward wedges are pointed polyhedra, + 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. + """ + if not isfinite(eps) or eps <= 0: + raise ValueError("eps必须为有限正数") + if not measurements: + return {"status": "unbounded", "vertices": [], "diameter_m": None, + "diameter_infinite": True, "recession_direction": [1.0, 0.0], + "message": "没有观测,整个平面均可行"} + if len(measurements) > 100: + raise ValueError("参考实现最多接收100个检测点;更大规模应使用半平面交算法") + ox = sum(m.x for m in measurements) / len(measurements) + oy = sum(m.y for m in measurements) / len(measurements) + scale = max(1.0, max(hypot(m.x - ox, m.y - oy) for m in measurements)) + local = [Measurement((m.x - ox) / scale, (m.y - oy) / scale, + m.bearing_deg, m.half_angle_deg) for m in measurements] + 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) + + feasible_vertices = [] + for (a, b, c), (d, e, f) in combinations(planes, 2): + det = a * e - b * d + if abs(det) <= PARALLEL_EPS: + continue + p = ((c * e - b * f) / det, (a * f - c * d) / det) + if inside(p): + feasible_vertices.append(p) + + if not feasible_vertices: + return {"status": "empty", "vertices": [], "diameter_m": None, + "diameter_infinite": False, "tolerance_m": eps * scale, + "message": "测向角域没有公共点;检查频道、数据及误差界"} + + # A nontrivial 2D recession cone has a ray tangent to an input halfplane. + # Use a much tighter angular threshold than the positional feasibility epsilon. + ray = None + for a, b, _ in planes: + for direction in ((b, -a), (-b, a)): + if all(d * direction[0] + e * direction[1] <= 2e-14 + for d, e, _ in planes): + ray = direction + break + if ray is not None: + break + + hull = convex_hull(feasible_vertices, eps) + + def world(p): + return [ox + p[0] * scale, oy + p[1] * scale] + + result = {"status": "unbounded" if ray else + ("point" if len(hull) == 1 else "segment" if len(hull) == 2 else "polygon"), + "vertices": [world(p) for p in hull], "tolerance_m": eps * scale, + "diameter_infinite": ray is not None} + if ray is not None: + result.update(diameter_m=None, recession_direction=list(ray), + message="存在非零衰退方向,区域无界,直径为无穷大") + return result + + d, pair = diameter(hull) + midpoint = ((pair[0][0] + pair[1][0]) / 2, (pair[0][1] + pair[1][1]) / 2) + distances = [distance(midpoint, p) for p in hull] + witness_index = max(range(len(hull)), key=distances.__getitem__) + max_distance = distances[witness_index] + center, radius = smallest_enclosing_circle(hull, eps) + area = abs(sum(cross(hull[i], hull[(i + 1) % len(hull)]) + for i in range(len(hull)))) / 2 + result.update(diameter_m=d * scale, diameter_pair=[world(p) for p in pair], + area_m2=area * scale ** 2, + diameter_circle={"center": world(midpoint), "radius_m": d * scale / 2, + "max_vertex_distance_m": max_distance * scale, + "covers": max_distance <= d / 2 + eps * 4, + "coverage_tolerance_m": 4 * eps * scale, + "max_excess_m": (max_distance-d/2) * scale, + "outside_vertex_indices": [i for i, value in enumerate(distances) + if value > d/2 + 4*eps], + "witness_vertex": world(hull[witness_index]) + if max_distance > d/2 + 4*eps else None}, + minimum_enclosing_circle={"center": world(center), "radius_m": radius * scale}, + guaranteed_clearable_at_mec_center=radius * scale <= 20 - eps * scale * 4, + clearance_certificate={"model": "angular_intersection_only", + "clear_radius_m": 20.0, + "numerical_margin_m": 4*eps*scale, + "radius_with_margin_m": (radius+4*eps)*scale, + "remaining_margin_m": 20-(radius+4*eps)*scale, + "center": world(center)}) + return result diff --git a/geometry/planning.py b/geometry/planning.py new file mode 100644 index 0000000..119894e --- /dev/null +++ b/geometry/planning.py @@ -0,0 +1,32 @@ +"""Distance certificates for Q2; does not change Q1's angular polygon model.""" + +from math import cos, sin, radians, isfinite +from .localization import Measurement + + +def first_bearing_candidate_disk(measurement: Measurement, + max_range_m: float = 1500, + guaranteed_range_m: float = 1000) -> dict: + """Enclose the entire truncated first-bearing sector, then erode its disk. + + For half-angle <=45 degrees the sector MEC has center distance and radius + a=R/(2*cos(delta)). Intersecting with Omega only shrinks the source set. + A missing sufficient disk is NOT proof that Q is empty. The certificate + guarantees distance only: directional transmitters may still be invisible. + """ + if any(isinstance(v, bool) or not isinstance(v, (int, float)) or not isfinite(v) + or v <= 0 for v in (max_range_m, guaranteed_range_m)): + raise ValueError("接收半径必须为有限正数") + if measurement.half_angle_deg > 45: + raise ValueError("此窄角域证书仅支持半角不超过45度;题设为1度") + delta, theta = radians(measurement.half_angle_deg), radians(measurement.bearing_deg % 360) + a = max_range_m/(2*cos(delta)) + center = [measurement.x+a*cos(theta), measurement.y+a*sin(theta)] + available = guaranteed_range_m-a + return {"source_model": "first_bearing_sector_with_range_upper_bound", + "sector_enclosing_circle": {"center": center, "radius_m": a}, + "sufficient_candidate_disk": {"center": center, "radius_m": available} + if available >= 0 else None, + "candidate_nonempty_certified": available >= 0, + "distance_only": True, + "max_range_m": max_range_m, "guaranteed_range_m": guaranteed_range_m} diff --git a/issue/A题.pdf b/issue/A题.pdf deleted file mode 100644 index c492bcd..0000000 Binary files a/issue/A题.pdf and /dev/null differ diff --git a/issue/A题/A题.pdf b/issue/A题/A题.pdf deleted file mode 100644 index c492bcd..0000000 Binary files a/issue/A题/A题.pdf and /dev/null differ diff --git a/issue/A题/附件/附件1.xlsx b/issue/A题/附件/附件1.xlsx deleted file mode 100644 index 7e6d9ff..0000000 Binary files a/issue/A题/附件/附件1.xlsx and /dev/null differ diff --git a/issue/A题/附件/附件2.xlsx b/issue/A题/附件/附件2.xlsx deleted file mode 100644 index 0066c18..0000000 Binary files a/issue/A题/附件/附件2.xlsx and /dev/null differ diff --git a/issue/A题/附件/附件3/result1.xlsx b/issue/A题/附件/附件3/result1.xlsx deleted file mode 100644 index 03cefc9..0000000 Binary files a/issue/A题/附件/附件3/result1.xlsx and /dev/null differ diff --git a/issue/A题/附件/附件3/result2.xlsx b/issue/A题/附件/附件3/result2.xlsx deleted file mode 100644 index 03cefc9..0000000 Binary files a/issue/A题/附件/附件3/result2.xlsx and /dev/null differ diff --git a/issue/A题/附件/附件3/result3.xlsx b/issue/A题/附件/附件3/result3.xlsx deleted file mode 100644 index 4fb701e..0000000 Binary files a/issue/A题/附件/附件3/result3.xlsx and /dev/null differ diff --git a/issue/A题/附件/附件3/result4.xlsx b/issue/A题/附件/附件3/result4.xlsx deleted file mode 100644 index c0536cb..0000000 Binary files a/issue/A题/附件/附件3/result4.xlsx and /dev/null differ diff --git a/issue/C题.pdf b/issue/C题.pdf deleted file mode 100644 index 6bf33e4..0000000 Binary files a/issue/C题.pdf and /dev/null differ diff --git a/issue/C题/C题.pdf b/issue/C题/C题.pdf deleted file mode 100644 index 6bf33e4..0000000 Binary files a/issue/C题/C题.pdf and /dev/null differ diff --git a/issue/C题/附件/附件1.xlsx b/issue/C题/附件/附件1.xlsx deleted file mode 100644 index e8bfd81..0000000 Binary files a/issue/C题/附件/附件1.xlsx and /dev/null differ diff --git a/issue/C题/附件/附件2.xlsx b/issue/C题/附件/附件2.xlsx deleted file mode 100644 index b0f2bd0..0000000 Binary files a/issue/C题/附件/附件2.xlsx and /dev/null differ diff --git a/issue/C题/附件/附件3.xlsx b/issue/C题/附件/附件3.xlsx deleted file mode 100644 index 6155884..0000000 Binary files a/issue/C题/附件/附件3.xlsx and /dev/null differ diff --git a/issue/C题/附件/附件4.xlsx b/issue/C题/附件/附件4.xlsx deleted file mode 100644 index 2d6a8ea..0000000 Binary files a/issue/C题/附件/附件4.xlsx and /dev/null differ diff --git a/issue/C题/附件/附件5/result1.xlsx b/issue/C题/附件/附件5/result1.xlsx deleted file mode 100644 index 2ff8b04..0000000 Binary files a/issue/C题/附件/附件5/result1.xlsx and /dev/null differ diff --git a/issue/C题/附件/附件5/result2.xlsx b/issue/C题/附件/附件5/result2.xlsx deleted file mode 100644 index 087cbc7..0000000 Binary files a/issue/C题/附件/附件5/result2.xlsx and /dev/null differ diff --git a/issue/C题/附件/附件5/result3.xlsx b/issue/C题/附件/附件5/result3.xlsx deleted file mode 100644 index 0ca36a4..0000000 Binary files a/issue/C题/附件/附件5/result3.xlsx and /dev/null differ diff --git a/issue/C题/附件/附件5/result4-2.xlsx b/issue/C题/附件/附件5/result4-2.xlsx deleted file mode 100644 index 087cbc7..0000000 Binary files a/issue/C题/附件/附件5/result4-2.xlsx and /dev/null differ diff --git a/issue/C题/附件/附件5/result4-3.xlsx b/issue/C题/附件/附件5/result4-3.xlsx deleted file mode 100644 index 0ca36a4..0000000 Binary files a/issue/C题/附件/附件5/result4-3.xlsx and /dev/null differ diff --git a/issue/附件/~$附件2.docx b/issue/附件/~$附件2.docx new file mode 100644 index 0000000..f9a31eb Binary files /dev/null and b/issue/附件/~$附件2.docx differ diff --git a/issue/B题/附件/附件1.docx b/issue/附件/附件1.docx similarity index 100% rename from issue/B题/附件/附件1.docx rename to issue/附件/附件1.docx diff --git a/issue/B题/附件/附件2.docx b/issue/附件/附件2.docx similarity index 100% rename from issue/B题/附件/附件2.docx rename to issue/附件/附件2.docx diff --git a/main.py b/main.py index 36b6e61..1e333a1 100644 --- a/main.py +++ b/main.py @@ -1,5 +1,4 @@ -def main(): - print("Hello from math!") +from geometry.__main__ import main if __name__ == "__main__": diff --git a/output/q1/demo_data.json b/output/q1/demo_data.json new file mode 100644 index 0000000..5e8d999 --- /dev/null +++ b/output/q1/demo_data.json @@ -0,0 +1,815 @@ +[ + { + "id": "normal", + "name": "三点交会", + "source": "synthetic", + "target": [ + 80, + 90 + ], + "measurements": [ + { + "x": -600, + "y": -250, + "bearing_deg": 27.21505117707799, + "half_angle_deg": 1.0 + }, + { + "x": 650, + "y": -100, + "bearing_deg": 161.015051177078, + "half_angle_deg": 1.0 + }, + { + "x": 100, + "y": 700, + "bearing_deg": 268.4721225527146, + "half_angle_deg": 1.0 + } + ], + "frames": [ + { + "status": "unbounded", + "vertices": [ + [ + -600.0, + -250.0 + ] + ], + "tolerance_m": 1e-09, + "diameter_infinite": true, + "diameter_m": null, + "recession_direction": [ + 0.8971423583981722, + 0.44174154068614097 + ], + "message": "存在非零衰退方向,区域无界,直径为无穷大" + }, + { + "status": "polygon", + "vertices": [ + [ + 45.3908396669742, + 96.27384560337686 + ], + [ + 80.26352724282751, + 84.95314961314784 + ], + [ + 106.24825816469405, + 97.74770218809789 + ], + [ + 71.61376404345356, + 110.34332460542697 + ] + ], + "tolerance_m": 6.294839156007086e-07, + "diameter_infinite": false, + "diameter_m": 60.87526295170263, + "diameter_pair": [ + [ + 45.3908396669742, + 96.27384560337686 + ], + [ + 106.24825816469405, + 97.74770218809789 + ] + ], + "area_m2": 778.9645078415749, + "diameter_circle": { + "center": [ + 75.81954891583413, + 97.01077389573737 + ], + "radius_m": 30.437631475851315, + "max_vertex_distance_m": 30.43763147585132, + "covers": true, + "coverage_tolerance_m": 2.5179356624028345e-06, + "max_excess_m": 4.367922104569409e-15, + "outside_vertex_indices": [], + "witness_vertex": null + }, + "minimum_enclosing_circle": { + "center": [ + 75.81954891583412, + 97.01077389573737 + ], + "radius_m": 30.437631475851326 + }, + "guaranteed_clearable_at_mec_center": false, + "clearance_certificate": { + "model": "angular_intersection_only", + "clear_radius_m": 20.0, + "numerical_margin_m": 2.5179356624028345e-06, + "radius_with_margin_m": 30.437633993786985, + "remaining_margin_m": -10.437633993786985, + "center": [ + 75.81954891583412, + 97.01077389573737 + ] + } + }, + { + "status": "polygon", + "vertices": [ + [ + 72.95144059951443, + 87.32686714676511 + ], + [ + 80.26352724282755, + 84.95314961314779 + ], + [ + 94.39740997503758, + 91.91249455495989 + ], + [ + 94.49056873706193, + 102.02365367196415 + ], + [ + 73.93038642999645, + 109.5008321248877 + ] + ], + "tolerance_m": 7.462871059079371e-07, + "diameter_infinite": false, + "diameter_m": 26.986082918182134, + "diameter_pair": [ + [ + 94.39740997503758, + 91.91249455495989 + ], + [ + 73.93038642999645, + 109.5008321248877 + ] + ], + "area_m2": 382.03705547378974, + "diameter_circle": { + "center": [ + 84.163898202517, + 100.7066633399238 + ], + "radius_m": 13.493041459091067, + "max_vertex_distance_m": 17.45675089097608, + "covers": false, + "coverage_tolerance_m": 2.9851484236317485e-06, + "max_excess_m": 3.96370943188501, + "outside_vertex_indices": [ + 0, + 1 + ], + "witness_vertex": [ + 72.95144059951443, + 87.32686714676511 + ] + }, + "minimum_enclosing_circle": { + "center": [ + 81.87363979249537, + 98.04155797588491 + ], + "radius_m": 13.943107186092131 + }, + "guaranteed_clearable_at_mec_center": true, + "clearance_certificate": { + "model": "angular_intersection_only", + "clear_radius_m": 20.0, + "numerical_margin_m": 2.9851484236317485e-06, + "radius_with_margin_m": 13.943110171240557, + "remaining_margin_m": 6.056889828759443, + "center": [ + 81.87363979249537, + 98.04155797588491 + ] + } + } + ] + }, + { + "id": "triangle", + "name": "直径圆反例", + "source": "synthetic", + "target": [ + 20, + 11.547005383792515 + ], + "measurements": [ + { + "x": -1000, + "y": 0, + "bearing_deg": 1, + "half_angle_deg": 1.0 + }, + { + "x": 540, + "y": -866.0254037844386, + "bearing_deg": 121, + "half_angle_deg": 1.0 + }, + { + "x": 520, + "y": 900.6664199358162, + "bearing_deg": 241, + "half_angle_deg": 1.0 + } + ], + "frames": [ + { + "status": "unbounded", + "vertices": [ + [ + -1000.0, + 0.0 + ] + ], + "tolerance_m": 1e-09, + "diameter_infinite": true, + "diameter_m": null, + "recession_direction": [ + 1.0, + 0.0 + ], + "message": "存在非零衰退方向,区域无界,直径为无穷大" + }, + { + "status": "polygon", + "vertices": [ + [ + -22.483050583603557, + 34.1356440648463 + ], + [ + -1.1527327997955013, + 0.0 + ], + [ + 40.000000000000284, + 0.0 + ], + [ + 19.44641513032613, + 35.59985327195466 + ] + ], + "tolerance_m": 8.83402513014311e-07, + "diameter_infinite": false, + "diameter_m": 71.19953515266143, + "diameter_pair": [ + [ + -22.483050583603557, + 34.1356440648463 + ], + [ + 40.000000000000284, + 0.0 + ] + ], + "area_m2": 1463.776307271084, + "diameter_circle": { + "center": [ + 8.75847470819835, + 17.06782203242318 + ], + "radius_m": 35.59976757633071, + "max_vertex_distance_m": 35.59976757633074, + "covers": true, + "coverage_tolerance_m": 3.533610052057244e-06, + "max_excess_m": 2.4519345249005315e-14, + "outside_vertex_indices": [], + "witness_vertex": null + }, + "minimum_enclosing_circle": { + "center": [ + 8.75847470819835, + 17.06782203242318 + ], + "radius_m": 35.59976757633074 + }, + "guaranteed_clearable_at_mec_center": false, + "clearance_certificate": { + "model": "angular_intersection_only", + "clear_radius_m": 20.0, + "numerical_margin_m": 3.533610052057244e-06, + "radius_with_margin_m": 35.59977110994079, + "remaining_margin_m": -15.59977110994079, + "center": [ + 8.75847470819835, + 17.06782203242318 + ] + } + }, + { + "status": "polygon", + "vertices": [ + [ + -5.755396159656812e-13, + 0.0 + ], + [ + 40.000000000000234, + 0.0 + ], + [ + 19.999999999999847, + 34.64101615137823 + ] + ], + "tolerance_m": 1.0200653573832087e-06, + "diameter_infinite": false, + "diameter_m": 40.00000000000081, + "diameter_pair": [ + [ + -5.755396159656812e-13, + 0.0 + ], + [ + 40.000000000000234, + 0.0 + ] + ], + "area_m2": 692.8203230275786, + "diameter_circle": { + "center": [ + 19.99999999999983, + 0.0 + ], + "radius_m": 20.000000000000405, + "max_vertex_distance_m": 34.641016151378224, + "covers": false, + "coverage_tolerance_m": 4.080261429532835e-06, + "max_excess_m": 14.641016151377823, + "outside_vertex_indices": [ + 2 + ], + "witness_vertex": [ + 19.999999999999847, + 34.64101615137823 + ] + }, + "minimum_enclosing_circle": { + "center": [ + 19.99999999999983, + 11.547005383792735 + ], + "radius_m": 23.094010767585495 + }, + "guaranteed_clearable_at_mec_center": false, + "clearance_certificate": { + "model": "angular_intersection_only", + "clear_radius_m": 20.0, + "numerical_margin_m": 4.080261429532835e-06, + "radius_with_margin_m": 23.094014847846925, + "remaining_margin_m": -3.0940148478469247, + "center": [ + 19.99999999999983, + 11.547005383792735 + ] + } + } + ] + }, + { + "id": "poor", + "name": "近共线检测点", + "source": "synthetic", + "target": [ + 900, + 50 + ], + "measurements": [ + { + "x": 0, + "y": 0, + "bearing_deg": 3.1798301198642345, + "half_angle_deg": 1.0 + }, + { + "x": 100, + "y": 0, + "bearing_deg": 3.576334374997351, + "half_angle_deg": 1.0 + }, + { + "x": 200, + "y": 0, + "bearing_deg": 4.085616779974877, + "half_angle_deg": 1.0 + } + ], + "frames": [ + { + "status": "unbounded", + "vertices": [ + [ + 0.0, + 0.0 + ] + ], + "tolerance_m": 1e-09, + "diameter_infinite": true, + "diameter_m": null, + "recession_direction": [ + 0.9992763681850043, + 0.03803603537420482 + ], + "message": "存在非零衰退方向,区域无界,直径为无穷大" + }, + { + "status": "unbounded", + "vertices": [ + [ + 190.67332308426802, + 7.257709171010532 + ], + [ + 649.0783141257873, + 24.706243940862326 + ], + [ + 1149.8860918066787, + 84.03538833511422 + ] + ], + "tolerance_m": 5.0000000000000004e-08, + "diameter_infinite": true, + "diameter_m": null, + "recession_direction": [ + 0.9973401975299315, + 0.07288710716551386 + ], + "message": "存在非零衰退方向,区域无界,直径为无穷大" + }, + { + "status": "unbounded", + "vertices": [ + [ + 349.47112173238565, + 13.302121787008087 + ], + [ + 649.0783141257873, + 24.706243940862326 + ], + [ + 704.9784261478786, + 27.221516841665565 + ], + [ + 1149.886091806679, + 84.03538833511422 + ], + [ + 1094.1078143807658, + 79.57076189541714 + ] + ], + "tolerance_m": 1.0000000000000001e-07, + "diameter_infinite": true, + "diameter_m": null, + "recession_direction": [ + 0.9973401975299315, + 0.07288710716551386 + ], + "message": "存在非零衰退方向,区域无界,直径为无穷大" + } + ] + }, + { + "id": "empty", + "name": "不相容观测", + "source": "synthetic", + "target": null, + "measurements": [ + { + "x": -100, + "y": 0, + "bearing_deg": 180, + "half_angle_deg": 1.0 + }, + { + "x": 100, + "y": 0, + "bearing_deg": 0, + "half_angle_deg": 1.0 + } + ], + "frames": [ + { + "status": "unbounded", + "vertices": [ + [ + -100.0, + 0.0 + ] + ], + "tolerance_m": 1e-09, + "diameter_infinite": true, + "diameter_m": null, + "recession_direction": [ + -0.9998476951563913, + 0.01745240643728344 + ], + "message": "存在非零衰退方向,区域无界,直径为无穷大" + }, + { + "status": "empty", + "vertices": [], + "diameter_m": null, + "diameter_infinite": false, + "tolerance_m": 1.0000000000000001e-07, + "message": "测向角域没有公共点;检查频道、数据及误差界" + } + ] + }, + { + "id": "segment", + "name": "零误差退化线段", + "source": "synthetic", + "target": [ + 40, + 0 + ], + "measurements": [ + { + "x": 0, + "y": 0, + "bearing_deg": 0, + "half_angle_deg": 0 + }, + { + "x": 100, + "y": 0, + "bearing_deg": 180, + "half_angle_deg": 0 + } + ], + "frames": [ + { + "status": "unbounded", + "vertices": [ + [ + 0.0, + 0.0 + ] + ], + "tolerance_m": 1e-09, + "diameter_infinite": true, + "diameter_m": null, + "recession_direction": [ + 1.0, + 0.0 + ], + "message": "存在非零衰退方向,区域无界,直径为无穷大" + }, + { + "status": "segment", + "vertices": [ + [ + 0.0, + 0.0 + ], + [ + 100.0, + 0.0 + ] + ], + "tolerance_m": 5.0000000000000004e-08, + "diameter_infinite": false, + "diameter_m": 100.0, + "diameter_pair": [ + [ + 0.0, + 0.0 + ], + [ + 100.0, + 0.0 + ] + ], + "area_m2": 0.0, + "diameter_circle": { + "center": [ + 50.0, + 0.0 + ], + "radius_m": 50.0, + "max_vertex_distance_m": 50.0, + "covers": true, + "coverage_tolerance_m": 2.0000000000000002e-07, + "max_excess_m": 0.0, + "outside_vertex_indices": [], + "witness_vertex": null + }, + "minimum_enclosing_circle": { + "center": [ + 50.0, + 0.0 + ], + "radius_m": 50.0 + }, + "guaranteed_clearable_at_mec_center": false, + "clearance_certificate": { + "model": "angular_intersection_only", + "clear_radius_m": 20.0, + "numerical_margin_m": 2.0000000000000002e-07, + "radius_with_margin_m": 50.0000002, + "remaining_margin_m": -30.000000200000002, + "center": [ + 50.0, + 0.0 + ] + } + } + ] + }, + { + "id": "practice-U5ZW-P9KN-TCAG-R7ST", + "name": "官方演练 U5ZW-P9KN-TCAG-R7ST", + "source": "official_practice", + "target": null, + "measurements": [ + { + "x": 0, + "y": 0, + "bearing_deg": 131.52, + "half_angle_deg": 1.0 + }, + { + "x": -613.7618853216438, + "y": 391.53077545248124, + "bearing_deg": 34.92, + "half_angle_deg": 1.0 + }, + { + "x": -314.2721344680094, + "y": 656.6833525352392, + "bearing_deg": 227.19, + "half_angle_deg": 1.0 + } + ], + "frames": [ + { + "status": "unbounded", + "vertices": [ + [ + 0.0, + 0.0 + ] + ], + "tolerance_m": 1e-09, + "diameter_infinite": true, + "diameter_m": null, + "recession_direction": [ + -0.6497134405131868, + 0.7601792191427741 + ], + "message": "存在非零衰退方向,区域无界,直径为无穷大" + }, + { + "status": "polygon", + "vertices": [ + [ + -460.69830582516533, + 502.4117755118043 + ], + [ + -456.18958970820563, + 497.4948221803977 + ], + [ + -436.511353451526, + 510.7280211898303 + ], + [ + -441.37035594940073, + 516.4131625064848 + ] + ], + "tolerance_m": 3.6400549446402593e-07, + "diameter_infinite": false, + "diameter_m": 25.57672002626576, + "diameter_pair": [ + [ + -460.69830582516533, + 502.4117755118043 + ], + [ + -436.511353451526, + 510.7280211898303 + ] + ], + "area_m2": 167.16830387852585, + "diameter_circle": { + "center": [ + -448.6048296383457, + 506.5698983508173 + ], + "radius_m": 12.78836001313288, + "max_vertex_distance_m": 12.788360013132888, + "covers": true, + "coverage_tolerance_m": 1.4560219778561037e-06, + "max_excess_m": 7.577386519575496e-15, + "outside_vertex_indices": [], + "witness_vertex": null + }, + "minimum_enclosing_circle": { + "center": [ + -448.6048296383457, + 506.5698983508173 + ], + "radius_m": 12.788360013132888 + }, + "guaranteed_clearable_at_mec_center": true, + "clearance_certificate": { + "model": "angular_intersection_only", + "clear_radius_m": 20.0, + "numerical_margin_m": 1.4560219778561037e-06, + "radius_with_margin_m": 12.788361469154864, + "remaining_margin_m": 7.211638530845136, + "center": [ + -448.6048296383457, + 506.5698983508173 + ] + } + }, + { + "status": "polygon", + "vertices": [ + [ + -460.69830582516533, + 502.41177551180425 + ], + [ + -456.4242356873799, + 497.75071394627366 + ], + [ + -440.56989343525083, + 515.4766035082919 + ], + [ + -441.3703559494006, + 516.4131625064849 + ] + ], + "tolerance_m": 4.666666666666667e-07, + "diameter_infinite": false, + "diameter_m": 23.996723024535473, + "diameter_pair": [ + [ + -460.69830582516533, + 502.41177551180425 + ], + [ + -440.56989343525083, + 515.4766035082919 + ] + ], + "area_m2": 89.48455568449387, + "diameter_circle": { + "center": [ + -450.6340996302081, + 508.9441895100481 + ], + "radius_m": 11.998361512267737, + "max_vertex_distance_m": 12.60236369723464, + "covers": false, + "coverage_tolerance_m": 1.8666666666666669e-06, + "max_excess_m": 0.604002184966904, + "outside_vertex_indices": [ + 1 + ], + "witness_vertex": [ + -456.4242356873799, + 497.75071394627366 + ] + }, + "minimum_enclosing_circle": { + "center": [ + -449.97695530423994, + 507.9528348831342 + ], + "radius_m": 12.068583013309823 + }, + "guaranteed_clearable_at_mec_center": true, + "clearance_certificate": { + "model": "angular_intersection_only", + "clear_radius_m": 20.0, + "numerical_margin_m": 1.8666666666666669e-06, + "radius_with_margin_m": 12.068584879976491, + "remaining_margin_m": 7.931415120023509, + "center": [ + -449.97695530423994, + 507.9528348831342 + ] + } + } + ] + } +] \ No newline at end of file diff --git a/output/q1/empty_result.json b/output/q1/empty_result.json new file mode 100644 index 0000000..cf6778c --- /dev/null +++ b/output/q1/empty_result.json @@ -0,0 +1,8 @@ +{ + "status": "empty", + "vertices": [], + "diameter_m": null, + "diameter_infinite": false, + "tolerance_m": 1.0000000000000001e-07, + "message": "测向角域没有公共点;检查频道、数据及误差界" +} \ No newline at end of file diff --git a/output/q1/figures/counterexample-360-dark.png b/output/q1/figures/counterexample-360-dark.png new file mode 100644 index 0000000..0b8f4e9 Binary files /dev/null and b/output/q1/figures/counterexample-360-dark.png differ diff --git a/output/q1/figures/counterexample-360-dark.svg b/output/q1/figures/counterexample-360-dark.svg new file mode 100644 index 0000000..138be0e --- /dev/null +++ b/output/q1/figures/counterexample-360-dark.svg @@ -0,0 +1 @@ +直径圆反例:有界多边形本地合成案例 · 直径圆不能覆盖,最大超出 14.641 m · 清除半径余量 -3.094 m-20-287-533196043x / my / mV1 (-0, 0)V2 (40, 0)V3 (20, 34.641) \ No newline at end of file diff --git a/output/q1/figures/counterexample-360-light.png b/output/q1/figures/counterexample-360-light.png new file mode 100644 index 0000000..db8ef79 Binary files /dev/null and b/output/q1/figures/counterexample-360-light.png differ diff --git a/output/q1/figures/counterexample-360-light.svg b/output/q1/figures/counterexample-360-light.svg new file mode 100644 index 0000000..8c8cbb8 --- /dev/null +++ b/output/q1/figures/counterexample-360-light.svg @@ -0,0 +1 @@ +直径圆反例:有界多边形本地合成案例 · 直径圆不能覆盖,最大超出 14.641 m · 清除半径余量 -3.094 m-20-287-533196043x / my / mV1 (-0, 0)V2 (40, 0)V3 (20, 34.641) \ No newline at end of file diff --git a/output/q1/figures/counterexample-736-dark.png b/output/q1/figures/counterexample-736-dark.png new file mode 100644 index 0000000..0c71a26 Binary files /dev/null and b/output/q1/figures/counterexample-736-dark.png differ diff --git a/output/q1/figures/counterexample-736-dark.svg b/output/q1/figures/counterexample-736-dark.svg new file mode 100644 index 0000000..691cbf2 --- /dev/null +++ b/output/q1/figures/counterexample-736-dark.svg @@ -0,0 +1 @@ +直径圆反例:有界多边形本地合成案例 · 直径圆不能覆盖,最大超出 14.641 m · 清除半径余量 -3.094 m-42-28-1-541198243x / my / mV1 (-0, 0)V2 (40, 0)V3 (20, 34.641) \ No newline at end of file diff --git a/output/q1/figures/counterexample-736-light.png b/output/q1/figures/counterexample-736-light.png new file mode 100644 index 0000000..1104b8a Binary files /dev/null and b/output/q1/figures/counterexample-736-light.png differ diff --git a/output/q1/figures/counterexample-736-light.svg b/output/q1/figures/counterexample-736-light.svg new file mode 100644 index 0000000..6a2e95e --- /dev/null +++ b/output/q1/figures/counterexample-736-light.svg @@ -0,0 +1 @@ +直径圆反例:有界多边形本地合成案例 · 直径圆不能覆盖,最大超出 14.641 m · 清除半径余量 -3.094 m-42-28-1-541198243x / my / mV1 (-0, 0)V2 (40, 0)V3 (20, 34.641) \ No newline at end of file diff --git a/output/q1/figures/practice-360-dark.png b/output/q1/figures/practice-360-dark.png new file mode 100644 index 0000000..2610e77 Binary files /dev/null and b/output/q1/figures/practice-360-dark.png differ diff --git a/output/q1/figures/practice-360-dark.svg b/output/q1/figures/practice-360-dark.svg new file mode 100644 index 0000000..c2a4eea --- /dev/null +++ b/output/q1/figures/practice-360-dark.svg @@ -0,0 +1 @@ +官方演练 U5ZW-P9KN-TCAG-R7ST:有界多边形官方演练观测;真实源位置未公开 · 直径圆不能覆盖,最大超出 0.604 m · 清除半径余量 7.9314 m-469492-456503-444514-432525x / my / mV1 (-460.6983, 502.4118)V2 (-456.4242, 497.7507)V3 (-440.5699, 515.4766)V4 (-441.3704, 516.4132) \ No newline at end of file diff --git a/output/q1/figures/practice-360-light.png b/output/q1/figures/practice-360-light.png new file mode 100644 index 0000000..7498ee7 Binary files /dev/null and b/output/q1/figures/practice-360-light.png differ diff --git a/output/q1/figures/practice-360-light.svg b/output/q1/figures/practice-360-light.svg new file mode 100644 index 0000000..d152726 --- /dev/null +++ b/output/q1/figures/practice-360-light.svg @@ -0,0 +1 @@ +官方演练 U5ZW-P9KN-TCAG-R7ST:有界多边形官方演练观测;真实源位置未公开 · 直径圆不能覆盖,最大超出 0.604 m · 清除半径余量 7.9314 m-469492-456503-444514-432525x / my / mV1 (-460.6983, 502.4118)V2 (-456.4242, 497.7507)V3 (-440.5699, 515.4766)V4 (-441.3704, 516.4132) \ No newline at end of file diff --git a/output/q1/figures/practice-736-dark.png b/output/q1/figures/practice-736-dark.png new file mode 100644 index 0000000..86937f8 Binary files /dev/null and b/output/q1/figures/practice-736-dark.png differ diff --git a/output/q1/figures/practice-736-dark.svg b/output/q1/figures/practice-736-dark.svg new file mode 100644 index 0000000..d469c6f --- /dev/null +++ b/output/q1/figures/practice-736-dark.svg @@ -0,0 +1 @@ +官方演练 U5ZW-P9KN-TCAG-R7ST:有界多边形官方演练观测;真实源位置未公开 · 直径圆不能覆盖,最大超出 0.604 m · 清除半径余量 7.9314 m-479492-460503-441514-422525x / my / mV1 (-460.6983, 502.4118)V2 (-456.4242, 497.7507)V3 (-440.5699, 515.4766)V4 (-441.3704, 516.4132) \ No newline at end of file diff --git a/output/q1/figures/practice-736-light.png b/output/q1/figures/practice-736-light.png new file mode 100644 index 0000000..05ea768 Binary files /dev/null and b/output/q1/figures/practice-736-light.png differ diff --git a/output/q1/figures/practice-736-light.svg b/output/q1/figures/practice-736-light.svg new file mode 100644 index 0000000..9aca944 --- /dev/null +++ b/output/q1/figures/practice-736-light.svg @@ -0,0 +1 @@ +官方演练 U5ZW-P9KN-TCAG-R7ST:有界多边形官方演练观测;真实源位置未公开 · 直径圆不能覆盖,最大超出 0.604 m · 清除半径余量 7.9314 m-479492-460503-441514-422525x / my / mV1 (-460.6983, 502.4118)V2 (-456.4242, 497.7507)V3 (-440.5699, 515.4766)V4 (-441.3704, 516.4132) \ No newline at end of file diff --git a/output/q1/normal_result.json b/output/q1/normal_result.json new file mode 100644 index 0000000..e8da7ce --- /dev/null +++ b/output/q1/normal_result.json @@ -0,0 +1,77 @@ +{ + "status": "polygon", + "vertices": [ + [ + 72.95144059951443, + 87.32686714676511 + ], + [ + 80.26352724282755, + 84.95314961314779 + ], + [ + 94.39740997503758, + 91.91249455495989 + ], + [ + 94.49056873706193, + 102.02365367196415 + ], + [ + 73.93038642999645, + 109.5008321248877 + ] + ], + "tolerance_m": 7.462871059079371e-07, + "diameter_infinite": false, + "diameter_m": 26.986082918182134, + "diameter_pair": [ + [ + 94.39740997503758, + 91.91249455495989 + ], + [ + 73.93038642999645, + 109.5008321248877 + ] + ], + "area_m2": 382.03705547378974, + "diameter_circle": { + "center": [ + 84.163898202517, + 100.7066633399238 + ], + "radius_m": 13.493041459091067, + "max_vertex_distance_m": 17.45675089097608, + "covers": false, + "coverage_tolerance_m": 2.9851484236317485e-06, + "max_excess_m": 3.96370943188501, + "outside_vertex_indices": [ + 0, + 1 + ], + "witness_vertex": [ + 72.95144059951443, + 87.32686714676511 + ] + }, + "minimum_enclosing_circle": { + "center": [ + 81.87363979249537, + 98.04155797588491 + ], + "radius_m": 13.943107186092131 + }, + "guaranteed_clearable_at_mec_center": true, + "clearance_certificate": { + "model": "angular_intersection_only", + "clear_radius_m": 20.0, + "numerical_margin_m": 2.9851484236317485e-06, + "radius_with_margin_m": 13.943110171240557, + "remaining_margin_m": 6.056889828759443, + "center": [ + 81.87363979249537, + 98.04155797588491 + ] + } +} \ No newline at end of file diff --git a/output/q1/optimization_report.json b/output/q1/optimization_report.json new file mode 100644 index 0000000..2673c8b --- /dev/null +++ b/output/q1/optimization_report.json @@ -0,0 +1,67 @@ +{ + "python": "3.12.14", + "timing_method": "median perf_counter; warm local process", + "timing_scope": "MEC only; not whole solver speedup or simulator/network performance", + "circle_benchmarks": [ + { + "vertices": 4, + "reference_ms": 0.021899999410379678, + "incremental_ms": 0.028999995265621692, + "speedup": 0.7551725167466228, + "radius_difference_m": 0.0 + }, + { + "vertices": 20, + "reference_ms": 4.815899999812245, + "incremental_ms": 0.055900003644637764, + "speedup": 86.15205162467306, + "radius_difference_m": 3.552713678800501e-15 + }, + { + "vertices": 60, + "reference_ms": 306.5234000023338, + "incremental_ms": 0.09600000339560211, + "speedup": 3192.9519704202016, + "radius_difference_m": 0.0 + } + ], + "practice_sensitivity": [ + { + "additional_half_angle_deg": 0, + "solve_ms": 0.09890000364976004, + "diameter_m": 23.996723024535473, + "mec_radius_m": 12.068583013309823, + "diameter_circle_excess_m": 0.604002184966904, + "clearance_margin_m": 7.931415120023509 + }, + { + "additional_half_angle_deg": 0.005, + "solve_ms": 0.0962999984039925, + "diameter_m": 24.119088149560866, + "mec_radius_m": 12.130376957888465, + "diameter_circle_excess_m": 0.608486475900504, + "clearance_margin_m": 7.869621175444868 + } + ], + "q2_first_bearing_certificate": { + "source_model": "first_bearing_sector_with_range_upper_bound", + "sector_enclosing_circle": { + "center": [ + 750.1153914962542, + 0.0 + ], + "radius_m": 750.1153914962542 + }, + "sufficient_candidate_disk": { + "center": [ + 750.1153914962542, + 0.0 + ], + "radius_m": 249.88460850374577 + }, + "candidate_nonempty_certified": true, + "distance_only": true, + "max_range_m": 1500, + "guaranteed_range_m": 1000 + } +} diff --git a/output/q1/poor_result.json b/output/q1/poor_result.json new file mode 100644 index 0000000..a5aa4ba --- /dev/null +++ b/output/q1/poor_result.json @@ -0,0 +1,33 @@ +{ + "status": "unbounded", + "vertices": [ + [ + 349.47112173238565, + 13.302121787008087 + ], + [ + 649.0783141257873, + 24.706243940862326 + ], + [ + 704.9784261478786, + 27.221516841665565 + ], + [ + 1149.886091806679, + 84.03538833511422 + ], + [ + 1094.1078143807658, + 79.57076189541714 + ] + ], + "tolerance_m": 1.0000000000000001e-07, + "diameter_infinite": true, + "diameter_m": null, + "recession_direction": [ + 0.9973401975299315, + 0.07288710716551386 + ], + "message": "存在非零衰退方向,区域无界,直径为无穷大" +} \ No newline at end of file diff --git a/output/q1/practice-U5ZW-P9KN-TCAG-R7ST_result.json b/output/q1/practice-U5ZW-P9KN-TCAG-R7ST_result.json new file mode 100644 index 0000000..4a5fd98 --- /dev/null +++ b/output/q1/practice-U5ZW-P9KN-TCAG-R7ST_result.json @@ -0,0 +1,72 @@ +{ + "status": "polygon", + "vertices": [ + [ + -460.69830582516533, + 502.41177551180425 + ], + [ + -456.4242356873799, + 497.75071394627366 + ], + [ + -440.56989343525083, + 515.4766035082919 + ], + [ + -441.3703559494006, + 516.4131625064849 + ] + ], + "tolerance_m": 4.666666666666667e-07, + "diameter_infinite": false, + "diameter_m": 23.996723024535473, + "diameter_pair": [ + [ + -460.69830582516533, + 502.41177551180425 + ], + [ + -440.56989343525083, + 515.4766035082919 + ] + ], + "area_m2": 89.48455568449387, + "diameter_circle": { + "center": [ + -450.6340996302081, + 508.9441895100481 + ], + "radius_m": 11.998361512267737, + "max_vertex_distance_m": 12.60236369723464, + "covers": false, + "coverage_tolerance_m": 1.8666666666666669e-06, + "max_excess_m": 0.604002184966904, + "outside_vertex_indices": [ + 1 + ], + "witness_vertex": [ + -456.4242356873799, + 497.75071394627366 + ] + }, + "minimum_enclosing_circle": { + "center": [ + -449.97695530423994, + 507.9528348831342 + ], + "radius_m": 12.068583013309823 + }, + "guaranteed_clearable_at_mec_center": true, + "clearance_certificate": { + "model": "angular_intersection_only", + "clear_radius_m": 20.0, + "numerical_margin_m": 1.8666666666666669e-06, + "radius_with_margin_m": 12.068584879976491, + "remaining_margin_m": 7.931415120023509, + "center": [ + -449.97695530423994, + 507.9528348831342 + ] + } +} \ No newline at end of file diff --git a/output/q1/practice/U5ZW-P9KN-TCAG-R7ST_actions.json b/output/q1/practice/U5ZW-P9KN-TCAG-R7ST_actions.json new file mode 100644 index 0000000..16f0517 --- /dev/null +++ b/output/q1/practice/U5ZW-P9KN-TCAG-R7ST_actions.json @@ -0,0 +1,278 @@ +[ + { + "path": "/enter", + "request": { + "arena_id": "default", + "robot_id": "202608023016", + "request_id": "q1-536f4d679c4f-1" + }, + "response": { + "accepted": true, + "real_timestamp_ms": 1789041227659, + "virtual_time_s": 0, + "max_virtual_duration_s": 360000, + "max_real_duration_s": 1200, + "remaining_real_duration_s": 1200 + }, + "retries": 0 + }, + { + "path": "/measure", + "request": { + "arena_id": "default", + "robot_id": "202608023016", + "request_id": "q1-536f4d679c4f-2", + "position": { + "x": 0, + "y": 0 + }, + "channel": 1 + }, + "response": { + "accepted": true, + "real_timestamp_ms": 1789041227678, + "virtual_time_s": 5, + "measure_result": "no_signal" + }, + "retries": 0 + }, + { + "path": "/measure", + "request": { + "arena_id": "default", + "robot_id": "202608023016", + "request_id": "q1-536f4d679c4f-3", + "position": { + "x": 0, + "y": 0 + }, + "channel": 2 + }, + "response": { + "accepted": true, + "real_timestamp_ms": 1789041227694, + "virtual_time_s": 11, + "measure_result": "no_signal" + }, + "retries": 0 + }, + { + "path": "/measure", + "request": { + "arena_id": "default", + "robot_id": "202608023016", + "request_id": "q1-536f4d679c4f-4", + "position": { + "x": 0, + "y": 0 + }, + "channel": 3 + }, + "response": { + "accepted": true, + "real_timestamp_ms": 1789041227696, + "virtual_time_s": 17, + "measure_result": "no_signal" + }, + "retries": 0 + }, + { + "path": "/measure", + "request": { + "arena_id": "default", + "robot_id": "202608023016", + "request_id": "q1-536f4d679c4f-5", + "position": { + "x": 0, + "y": 0 + }, + "channel": 4 + }, + "response": { + "accepted": true, + "real_timestamp_ms": 1789041227697, + "virtual_time_s": 23, + "measure_result": "no_signal" + }, + "retries": 0 + }, + { + "path": "/measure", + "request": { + "arena_id": "default", + "robot_id": "202608023016", + "request_id": "q1-536f4d679c4f-6", + "position": { + "x": 0, + "y": 0 + }, + "channel": 5 + }, + "response": { + "accepted": true, + "real_timestamp_ms": 1789041227709, + "virtual_time_s": 29, + "measure_result": "no_signal" + }, + "retries": 0 + }, + { + "path": "/measure", + "request": { + "arena_id": "default", + "robot_id": "202608023016", + "request_id": "q1-536f4d679c4f-7", + "position": { + "x": 0, + "y": 0 + }, + "channel": 6 + }, + "response": { + "accepted": true, + "real_timestamp_ms": 1789041227725, + "virtual_time_s": 35, + "measure_result": "no_signal" + }, + "retries": 0 + }, + { + "path": "/measure", + "request": { + "arena_id": "default", + "robot_id": "202608023016", + "request_id": "q1-536f4d679c4f-8", + "position": { + "x": 0, + "y": 0 + }, + "channel": 7 + }, + "response": { + "accepted": true, + "real_timestamp_ms": 1789041227727, + "virtual_time_s": 41, + "measure_result": "no_signal" + }, + "retries": 0 + }, + { + "path": "/measure", + "request": { + "arena_id": "default", + "robot_id": "202608023016", + "request_id": "q1-536f4d679c4f-9", + "position": { + "x": 0, + "y": 0 + }, + "channel": 8 + }, + "response": { + "accepted": true, + "real_timestamp_ms": 1789041227729, + "virtual_time_s": 47, + "measure_result": "no_signal" + }, + "retries": 0 + }, + { + "path": "/measure", + "request": { + "arena_id": "default", + "robot_id": "202608023016", + "request_id": "q1-536f4d679c4f-10", + "position": { + "x": 0, + "y": 0 + }, + "channel": 9 + }, + "response": { + "accepted": true, + "real_timestamp_ms": 1789041227740, + "virtual_time_s": 53, + "measure_result": "direction", + "svd_deg": 131.52 + }, + "retries": 0 + }, + { + "path": "/measure", + "request": { + "arena_id": "default", + "robot_id": "202608023016", + "request_id": "q1-536f4d679c4f-11", + "position": { + "x": 0, + "y": 0 + }, + "channel": 9 + }, + "response": { + "accepted": true, + "real_timestamp_ms": 1789041227755, + "virtual_time_s": 58, + "measure_result": "direction", + "svd_deg": 131.52 + }, + "retries": 0 + }, + { + "path": "/measure", + "request": { + "arena_id": "default", + "robot_id": "202608023016", + "request_id": "q1-536f4d679c4f-12", + "position": { + "x": -613.7618853216438, + "y": 391.53077545248124 + }, + "channel": 9 + }, + "response": { + "accepted": true, + "real_timestamp_ms": 1789041227770, + "virtual_time_s": 208.602198, + "measure_result": "direction", + "svd_deg": 34.92 + }, + "retries": 0 + }, + { + "path": "/measure", + "request": { + "arena_id": "default", + "robot_id": "202608023016", + "request_id": "q1-536f4d679c4f-13", + "position": { + "x": -314.2721344680094, + "y": 656.6833525352392 + }, + "channel": 9 + }, + "response": { + "accepted": true, + "real_timestamp_ms": 1789041227772, + "virtual_time_s": 293.602198, + "measure_result": "direction", + "svd_deg": 227.19 + }, + "retries": 0 + }, + { + "path": "/exit", + "request": { + "arena_id": "default", + "robot_id": "202608023016", + "request_id": "q1-536f4d679c4f-14" + }, + "response": { + "accepted": true, + "real_timestamp_ms": 1789041227775, + "virtual_time_s": 293.602198, + "exit_reason": "user_exit" + }, + "retries": 0 + } +] \ No newline at end of file diff --git a/output/q1/practice/U5ZW-P9KN-TCAG-R7ST_observations.json b/output/q1/practice/U5ZW-P9KN-TCAG-R7ST_observations.json new file mode 100644 index 0000000..78f0198 --- /dev/null +++ b/output/q1/practice/U5ZW-P9KN-TCAG-R7ST_observations.json @@ -0,0 +1,87 @@ +{ + "name": "官方问题3演练观测", + "source": "official_practice", + "case_code": "U5ZW-P9KN-TCAG-R7ST", + "channel": 9, + "ground_truth": null, + "measurements": [ + { + "x": 0, + "y": 0, + "bearing_deg": 131.52, + "half_angle_deg": 1.0 + }, + { + "x": -613.7618853216438, + "y": 391.53077545248124, + "bearing_deg": 34.92, + "half_angle_deg": 1.0 + }, + { + "x": -314.2721344680094, + "y": 656.6833525352392, + "bearing_deg": 227.19, + "half_angle_deg": 1.0 + } + ], + "repeated_station_response": { + "accepted": true, + "real_timestamp_ms": 1789041227755, + "virtual_time_s": 58, + "measure_result": "direction", + "svd_deg": 131.52 + }, + "result": { + "status": "polygon", + "vertices": [ + [ + -460.69830582516533, + 502.41177551180425 + ], + [ + -456.4242356873799, + 497.75071394627366 + ], + [ + -440.56989343525083, + 515.4766035082919 + ], + [ + -441.3703559494006, + 516.4131625064849 + ] + ], + "tolerance_m": 4.666666666666667e-07, + "diameter_infinite": false, + "diameter_m": 23.996723024535473, + "diameter_pair": [ + [ + -460.69830582516533, + 502.41177551180425 + ], + [ + -440.56989343525083, + 515.4766035082919 + ] + ], + "area_m2": 89.48455568449387, + "diameter_circle": { + "center": [ + -450.6340996302081, + 508.9441895100481 + ], + "radius_m": 11.998361512267737, + "max_vertex_distance_m": 12.60236369723464, + "covers": false + }, + "minimum_enclosing_circle": { + "center": [ + -449.97695530423994, + 507.9528348831342 + ], + "radius_m": 12.068583013309823 + }, + "guaranteed_clearable_at_mec_center": true + }, + "scope": "仅采集问题一观测,不执行问题三全清除任务;没有调用clear" +} \ No newline at end of file diff --git a/output/q1/question1.html b/output/q1/question1.html new file mode 100644 index 0000000..2afcece --- /dev/null +++ b/output/q1/question1.html @@ -0,0 +1,211 @@ +B题问题一交会定位
+ +

带误差的交会定位

+
+ + + +
+
+ +
+ 定位区域 + 直径线段及直径圆 + 最小包围圆 + □ 检测点 + 合成真值 ◎ 覆盖反例顶点 +
+
+
+ 检测点与示向度 +
+ + + +
测点x / my / m示向度 / °误差半角 / °
+
+
+ +
+ \ No newline at end of file diff --git a/output/q1/segment_result.json b/output/q1/segment_result.json new file mode 100644 index 0000000..e3a2f39 --- /dev/null +++ b/output/q1/segment_result.json @@ -0,0 +1,59 @@ +{ + "status": "segment", + "vertices": [ + [ + 0.0, + 0.0 + ], + [ + 100.0, + 0.0 + ] + ], + "tolerance_m": 5.0000000000000004e-08, + "diameter_infinite": false, + "diameter_m": 100.0, + "diameter_pair": [ + [ + 0.0, + 0.0 + ], + [ + 100.0, + 0.0 + ] + ], + "area_m2": 0.0, + "diameter_circle": { + "center": [ + 50.0, + 0.0 + ], + "radius_m": 50.0, + "max_vertex_distance_m": 50.0, + "covers": true, + "coverage_tolerance_m": 2.0000000000000002e-07, + "max_excess_m": 0.0, + "outside_vertex_indices": [], + "witness_vertex": null + }, + "minimum_enclosing_circle": { + "center": [ + 50.0, + 0.0 + ], + "radius_m": 50.0 + }, + "guaranteed_clearable_at_mec_center": false, + "clearance_certificate": { + "model": "angular_intersection_only", + "clear_radius_m": 20.0, + "numerical_margin_m": 2.0000000000000002e-07, + "radius_with_margin_m": 50.0000002, + "remaining_margin_m": -30.000000200000002, + "center": [ + 50.0, + 0.0 + ] + } +} \ No newline at end of file diff --git a/output/q1/triangle_recomputed.json b/output/q1/triangle_recomputed.json new file mode 100644 index 0000000..b2eb163 --- /dev/null +++ b/output/q1/triangle_recomputed.json @@ -0,0 +1,48 @@ +{ + "status": "polygon", + "vertices": [ + [ + -5.755396159656812e-13, + 0.0 + ], + [ + 40.000000000000234, + 0.0 + ], + [ + 19.999999999999847, + 34.64101615137823 + ] + ], + "tolerance_m": 1.0200653573832087e-06, + "diameter_infinite": false, + "diameter_m": 40.00000000000081, + "diameter_pair": [ + [ + -5.755396159656812e-13, + 0.0 + ], + [ + 40.000000000000234, + 0.0 + ] + ], + "area_m2": 692.8203230275786, + "diameter_circle": { + "center": [ + 19.99999999999983, + 0.0 + ], + "radius_m": 20.000000000000405, + "max_vertex_distance_m": 34.641016151378224, + "covers": false + }, + "minimum_enclosing_circle": { + "center": [ + 19.99999999999983, + 11.547005383792735 + ], + "radius_m": 23.094010767585495 + }, + "guaranteed_clearable_at_mec_center": false +} diff --git a/output/q1/triangle_result.json b/output/q1/triangle_result.json new file mode 100644 index 0000000..2845bd1 --- /dev/null +++ b/output/q1/triangle_result.json @@ -0,0 +1,68 @@ +{ + "status": "polygon", + "vertices": [ + [ + -5.755396159656812e-13, + 0.0 + ], + [ + 40.000000000000234, + 0.0 + ], + [ + 19.999999999999847, + 34.64101615137823 + ] + ], + "tolerance_m": 1.0200653573832087e-06, + "diameter_infinite": false, + "diameter_m": 40.00000000000081, + "diameter_pair": [ + [ + -5.755396159656812e-13, + 0.0 + ], + [ + 40.000000000000234, + 0.0 + ] + ], + "area_m2": 692.8203230275786, + "diameter_circle": { + "center": [ + 19.99999999999983, + 0.0 + ], + "radius_m": 20.000000000000405, + "max_vertex_distance_m": 34.641016151378224, + "covers": false, + "coverage_tolerance_m": 4.080261429532835e-06, + "max_excess_m": 14.641016151377823, + "outside_vertex_indices": [ + 2 + ], + "witness_vertex": [ + 19.999999999999847, + 34.64101615137823 + ] + }, + "minimum_enclosing_circle": { + "center": [ + 19.99999999999983, + 11.547005383792735 + ], + "radius_m": 23.094010767585495 + }, + "guaranteed_clearable_at_mec_center": false, + "clearance_certificate": { + "model": "angular_intersection_only", + "clear_radius_m": 20.0, + "numerical_margin_m": 4.080261429532835e-06, + "radius_with_margin_m": 23.094014847846925, + "remaining_margin_m": -3.0940148478469247, + "center": [ + 19.99999999999983, + 11.547005383792735 + ] + } +} \ No newline at end of file diff --git a/output/q1/visualization_checks.json b/output/q1/visualization_checks.json new file mode 100644 index 0000000..6aedea4 --- /dev/null +++ b/output/q1/visualization_checks.json @@ -0,0 +1,7 @@ +{ + "interaction_states_checked": 32, + "missing_selectors": 0, + "nonfinite_svg_values": 0, + "browser_layout_verified": false, + "note": "Browser local-file navigation blocked by browser URL policy. Event handlers checked in minimal DOM; SVG statically inspected separately." +} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 8fadf5a..8cb01ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "math" version = "0.1.0" -description = "Add your description here" +description = "B题问题一:带误差方位交会定位与可视化" readme = "README.md" -requires-python = ">=3.13" +requires-python = ">=3.10" dependencies = [] diff --git a/scripts/benchmark_q1.py b/scripts/benchmark_q1.py new file mode 100644 index 0000000..a65f56a --- /dev/null +++ b/scripts/benchmark_q1.py @@ -0,0 +1,56 @@ +"""Offline numerical comparison; timings are local measurements, not API latency.""" + +from dataclasses import replace +import json +import math +from pathlib import Path +from statistics import median +import sys +from time import perf_counter + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +from geometry import Measurement, solve, smallest_enclosing_circle, first_bearing_candidate_disk +from geometry.localization import _enclosing_circle_reference + + +def timed(function, points, repeats): + durations = [] + for _ in range(repeats): + start = perf_counter() + result = function(points) + durations.append(perf_counter()-start) + return result, median(durations)*1000 + + +def main(): + timings = [] + for size in [4, 20, 60]: + points = [(20*math.cos(i*math.tau/size), 20*math.sin(i*math.tau/size)) for i in range(size)] + old, old_ms = timed(_enclosing_circle_reference, points, 3) + new, new_ms = timed(smallest_enclosing_circle, points, 11) + timings.append(dict(vertices=size, reference_ms=old_ms, incremental_ms=new_ms, + speedup=old_ms/new_ms, radius_difference_m=abs(old[1]-new[1]))) + source = next((ROOT/'output/q1/practice').glob('*_observations.json')) + rows = json.loads(source.read_text(encoding='utf-8'))['measurements'] + observations = [Measurement(**m) for m in rows] + sensitivity = [] + for margin in [0, 0.005]: + ms = [replace(m, half_angle_deg=m.half_angle_deg+margin) for m in observations] + result, duration = timed(solve, ms, 31) + sensitivity.append(dict(additional_half_angle_deg=margin, solve_ms=duration, + diameter_m=result['diameter_m'], + mec_radius_m=result['minimum_enclosing_circle']['radius_m'], + diameter_circle_excess_m=result['diameter_circle']['max_excess_m'], + clearance_margin_m=result['clearance_certificate']['remaining_margin_m'])) + report = dict(python=sys.version.split()[0], timing_method='median perf_counter; warm local process', + timing_scope='MEC only; not whole solver speedup or simulator/network performance', + circle_benchmarks=timings, practice_sensitivity=sensitivity, + q2_first_bearing_certificate=first_bearing_candidate_disk(Measurement(0, 0, 0, 1.005))) + output = ROOT/'output/q1/optimization_report.json' + output.write_text(json.dumps(report, ensure_ascii=False, indent=2, allow_nan=False)+'\n', encoding='utf-8') + print(json.dumps(report, ensure_ascii=False, indent=2)) + + +if __name__ == '__main__': + main() diff --git a/scripts/build_q1_demo.py b/scripts/build_q1_demo.py new file mode 100644 index 0000000..dcb6a8d --- /dev/null +++ b/scripts/build_q1_demo.py @@ -0,0 +1,71 @@ +"""Build reproducible examples and the interactive visualization from Python results.""" + +from dataclasses import asdict +import json +import math +from pathlib import Path +import sys + +ROOT=Path(__file__).resolve().parents[1] +sys.path.insert(0,str(ROOT)) +from geometry import Measurement, solve + + +def measured(stations, target, errors): + return [Measurement(x,y,(math.degrees(math.atan2(target[1]-y,target[0]-x))+e)%360) + for (x,y),e in zip(stations,errors)] + + +def scenarios(): + target=(80,90) + normal=measured([(-600,-250),(650,-100),(100,700)],target,[.65,-.55,.35]) + h=20*math.sqrt(3) + triangle=[Measurement(-1000,0,1),Measurement(540,-500*math.sqrt(3),121), + Measurement(520,h+500*math.sqrt(3),241)] + return [ + dict(id='normal',name='三点交会',source='synthetic',target=target,measurements=normal), + dict(id='triangle',name='直径圆反例',source='synthetic',target=(20,h/3),measurements=triangle), + dict(id='poor',name='近共线检测点',source='synthetic',target=(900,50), + measurements=measured([(0,0),(100,0),(200,0)],(900,50),[0,0,0])), + dict(id='empty',name='不相容观测',source='synthetic',target=None, + measurements=[Measurement(-100,0,180),Measurement(100,0,0)]), + dict(id='segment',name='零误差退化线段',source='synthetic',target=(40,0), + measurements=[Measurement(0,0,0,0),Measurement(100,0,180,0)]), + ] + + +def main(): + cases=scenarios() + practice=ROOT/'output/q1/practice' + for path in sorted(practice.glob('*_observations.json')): + doc=json.loads(path.read_text(encoding='utf-8')) + cases.append(dict(id='practice-'+doc['case_code'],name='官方演练 '+doc['case_code'], + source='official_practice',target=None, + measurements=[Measurement(**m) for m in doc['measurements']])) + examples=ROOT/'examples/q1' + results=ROOT/'output/q1' + examples.mkdir(parents=True,exist_ok=True) + results.mkdir(parents=True,exist_ok=True) + dataset=[] + for case in cases: + ms=case['measurements'] + frames=[solve(ms[:n]) for n in range(1,len(ms)+1)] + data={**case,'measurements':[asdict(m) for m in ms],'frames':frames} + dataset.append(data) + if case['source']=='synthetic': + (examples/(case['id']+'.json')).write_text(json.dumps({k:v for k,v in data.items() if k!='frames'},ensure_ascii=False,indent=2),encoding='utf-8') + (results/(case['id']+'_result.json')).write_text(json.dumps(frames[-1],ensure_ascii=False,indent=2,allow_nan=False),encoding='utf-8') + (results/'demo_data.json').write_text(json.dumps(dataset,ensure_ascii=False,indent=2,allow_nan=False),encoding='utf-8') + template=(ROOT/'visualization/q1.template.html').read_text(encoding='utf-8') + fragment=template.replace('/*__Q1_DATA__*/',json.dumps(dataset,ensure_ascii=False,allow_nan=False)) + (ROOT/'visualization/question1.html').write_text(fragment,encoding='utf-8') + # A standalone companion uses the same fragment and computation results. + theme=(ROOT/'visualization/standalone.css').read_text(encoding='utf-8') + page='B题问题一交会定位'+fragment+'' + (results/'question1.html').write_text(page,encoding='utf-8') + print(json.dumps({'cases':len(dataset),'fragment_bytes':len(fragment.encode('utf-8')), + 'standalone':str(results/'question1.html')},ensure_ascii=False)) + + +if __name__=='__main__': + main() diff --git a/scripts/capture_practice.py b/scripts/capture_practice.py new file mode 100644 index 0000000..a9fbc88 --- /dev/null +++ b/scripts/capture_practice.py @@ -0,0 +1,123 @@ +"""Collect a few bearings for Q1 from an ALREADY OPEN practice session. + +There is deliberately no test-start API, formal mode, or /clear call here. +The documented robot API does not expose mode: verify the UI before running. +""" + +import argparse +import json +import math +from pathlib import Path +import sys +import time +import uuid +from urllib.error import URLError +from urllib.request import Request, urlopen + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from geometry import Measurement, solve + + +class PracticeClient: + def __init__(self, robot_id, output, base_url='http://127.0.0.1:2026'): + self.robot_id = robot_id + self.output = output + self.base_url = base_url.rstrip('/') + self.prefix = uuid.uuid4().hex[:12] + self.count = 0 + self.events = [] + self.deadline = None + + def action(self, path, **extra): + if path not in ('/enter', '/measure', '/exit'): + raise ValueError('采集器只允许进入、检测、退出') + if self.deadline is not None and path != '/exit' and time.monotonic() > self.deadline-15: + raise TimeoutError('剩余时间不足,预留退出时间') + self.count += 1 + payload = dict(arena_id='default', robot_id=self.robot_id, + request_id=f'q1-{self.prefix}-{self.count}', **extra) + encoded = json.dumps(payload, allow_nan=False).encode('utf-8') + for attempt in range(3): + request = Request(self.base_url+path, data=encoded, + headers={'Content-Type':'application/json'}, method='POST') + try: + with urlopen(request, timeout=5) as response: + status = response.status + body = json.load(response) + # A retried action is recorded/consumed only once. + self.events.append(dict(path=path, request=payload, response=body, retries=attempt)) + self.output.write_text(json.dumps(self.events,ensure_ascii=False,indent=2),encoding='utf-8') + if status != 200 or body.get('accepted') is not True: + raise RuntimeError(f'动作未被接受: {status}, {body}') + if path == '/enter': + self.deadline = time.monotonic()+body['remaining_real_duration_s'] + return body + except URLError: + if attempt == 2: + raise + time.sleep(.25) + + def measure(self, p, channel): + return self.action('/measure',position={'x':p[0],'y':p[1]},channel=channel) + + +def main(): + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('--robot-id',required=True) + parser.add_argument('--practice-confirmed',action='store_true',required=True, + help='仅在界面确认为问题3演练且接口已就绪时提供') + parser.add_argument('--case-code',required=True,help='界面显示的演练案例编码') + parser.add_argument('--output',type=Path,default=Path('output/q1/practice')) + args=parser.parse_args() + args.output.mkdir(parents=True,exist_ok=True) + log_path=args.output/f'{args.case_code}_actions.json' + if log_path.exists(): + parser.error('同名采集日志已存在,请使用新的输出目录,避免覆盖证据') + client=PracticeClient(args.robot_id,log_path) + observations=[] + channel=None + first=None + entered=False + repeat=None + try: + client.action('/enter') + entered=True + for p in [(0,0),(800,0),(-800,0),(0,800),(0,-800)]: + for c in range(1,21): + response=client.measure(p,c) + if response['measure_result']=='direction': + first=(p,response['svd_deg']) + observations.append(Measurement(*p,response['svd_deg'])) + channel=c + break + if channel is not None: + break + if channel is None: + raise RuntimeError('预设采样点未取得direction;本次不扩展为全域搜索') + p,theta=first + repeat=client.measure(p,channel) + a=math.radians(theta) + # For an omnidirectional source seen initially at <=1500m, these + # 700m-forward, +/-200m-lateral points remain within 1000m of its wedge. + for lateral in [200,-200,300,-300]: + q=(p[0]+700*math.cos(a)-lateral*math.sin(a), + p[1]+700*math.sin(a)+lateral*math.cos(a)) + response=client.measure(q,channel) + if response['measure_result']=='direction': + observations.append(Measurement(*q,response['svd_deg'])) + if len(observations)>=3: + break + data={'name':'官方问题3演练观测','source':'official_practice','case_code':args.case_code, + 'channel':channel,'ground_truth':None,'measurements':[m.__dict__ for m in observations], + 'repeated_station_response':repeat,'result':solve(observations), + 'scope':'仅采集问题一观测,不执行问题三全清除任务;没有调用clear'} + path=args.output/f'{args.case_code}_observations.json' + path.write_text(json.dumps(data,ensure_ascii=False,indent=2,allow_nan=False),encoding='utf-8') + print(json.dumps({'observations':str(path),'count':len(observations),'result':data['result']},ensure_ascii=False,indent=2)) + finally: + if entered: + client.action('/exit') + + +if __name__=='__main__': + main() diff --git a/scripts/render_q1_figures.py b/scripts/render_q1_figures.py new file mode 100644 index 0000000..1b3b298 --- /dev/null +++ b/scripts/render_q1_figures.py @@ -0,0 +1,94 @@ +"""Rasterize our restricted SVG primitives with Pillow, without a browser. + +This inspects geometry/labels at both widths/themes. It does not emulate HTML/CSS. +""" +import math +from pathlib import Path +import re +import xml.etree.ElementTree as ET +from PIL import Image,ImageDraw,ImageFont + +ROOT=Path(__file__).resolve().parents[1] +S=2 +FONT=ImageFont.truetype('C:/Windows/Fonts/msyh.ttc',12*S) + + +def render(path): + root=ET.fromstring(path.read_text(encoding='utf-8')) + width,height=map(float,(root.get('width'),root.get('height'))) + bg='#191c21' if 'dark' in path.stem else '#ffffff' + image=Image.new('RGBA',(round(width*S),round(height*S)),bg) + clip=next(el for el in root.iter() if el.tag.endswith('clipPath'))[0] + clipbox=tuple(float(clip.get(k))*S for k in ('x','y','width','height')) + + def color(value,opacity=1): + if value in (None,'none'): return None + value=value.lstrip('#') + return tuple(int(value[i:i+2],16) for i in (0,2,4))+(round(opacity*255),) + + def draw(el,clipped=False): + nonlocal image + tag=el.tag.rsplit('}',1)[-1] + if tag in ('defs','title','desc'):return + if tag in ('svg','g'): + for child in el:draw(child,clipped or bool(el.get('clip-path'))) + return + layer=Image.new('RGBA',image.size,(0,0,0,0));d=ImageDraw.Draw(layer) + num=lambda key,default=0:float(el.get(key,default))*S + fill=color(el.get('fill'),float(el.get('fill-opacity',1))) + stroke=color(el.get('stroke'),float(el.get('stroke-opacity',1))) + sw=max(1,round(num('stroke-width',1))) + if tag=='line': + xy=[(num('x1'),num('y1')),(num('x2'),num('y2'))] + dash=el.get('stroke-dasharray','none') + if dash=='none': d.line(xy,fill=stroke,width=sw) + else: + a,b=xy;dist=math.dist(a,b);unit=((b[0]-a[0])/max(dist,1e-15),(b[1]-a[1])/max(dist,1e-15)) + on,off=[float(v)*S for v in dash.split()] + # Bound offscreen ray work to the finite exported viewport vicinity. + segments=min(100000,int(dist/(on+off))+1) + for i in range(segments): + lo=i*(on+off);hi=min(lo+on,dist) + d.line([(a[0]+lo*unit[0],a[1]+lo*unit[1]),(a[0]+hi*unit[0],a[1]+hi*unit[1])],fill=stroke,width=sw) + elif tag=='rect': + box=(num('x'),num('y'),num('x')+num('width'),num('y')+num('height')) + d.rectangle(box,fill=fill,outline=stroke,width=sw) + elif tag=='circle': + x,y,r=num('cx'),num('cy'),num('r');box=(x-r,y-r,x+r,y+r) + if el.get('stroke-dasharray') not in (None,'none'): + for a in range(0,360,18):d.arc(box,a,a+10,fill=stroke,width=sw) + else:d.ellipse(box,fill=fill,outline=stroke,width=sw) + elif tag=='polygon': + points=[tuple(float(v)*S for v in pair.split(',')) for pair in el.get('points').split()] + d.polygon(points,fill=fill) + if stroke:d.line(points+[points[0]],fill=stroke,width=sw,joint='curve') + elif tag=='path': + for x,y,axis,length in re.findall(r'M([\d.e+-]+),([\d.e+-]+)([hv])([\d.e+-]+)',el.get('d')): + x,y,length=float(x)*S,float(y)*S,float(length)*S + d.line([(x,y),(x+length,y) if axis=='h' else (x,y+length)],fill=stroke,width=sw) + elif tag=='text': + value=el.text or '';x,y=num('x'),num('y');anchor=el.get('text-anchor','start') + length=d.textlength(value,font=FONT) + if el.get('transform'): + tile=Image.new('RGBA',(math.ceil(length+8*S),20*S));td=ImageDraw.Draw(tile) + td.text((4*S,0),value,font=FONT,fill=fill) + tile=tile.rotate(90,expand=True) + layer.alpha_composite(tile,(round(x-tile.width/2),round(y-tile.height/2))) + else: + x-=length/2 if anchor=='middle' else length if anchor=='end' else 0 + d.text((x,y),value,font=FONT,fill=fill,anchor='ls') + else:raise ValueError('unsupported SVG primitive '+tag) + if clipped: + x,y,w,h=clipbox + mask=Image.new('L',image.size);ImageDraw.Draw(mask).rectangle((x,y,x+w,y+h),fill=255) + transparent=Image.new('RGBA',image.size) + layer=Image.composite(layer,transparent,mask) + image=Image.alpha_composite(image,layer) + + draw(root) + output=path.with_suffix('.png');image.convert('RGB').save(output) + return str(output.relative_to(ROOT)) + + +if __name__=='__main__': + for path in sorted((ROOT/'output/q1/figures').glob('*.svg')):print(render(path)) diff --git a/scripts/verify_visualization.cjs b/scripts/verify_visualization.cjs new file mode 100644 index 0000000..8689f40 --- /dev/null +++ b/scripts/verify_visualization.cjs @@ -0,0 +1,61 @@ +/* Run only our generated local script in a minimal DOM, with no browser or network. + * This checks event handlers and serializes the produced SVG for static inspection. + * It is not a replacement for a native browser layout test. + */ +const fs=require('node:fs'),path=require('node:path'),vm=require('node:vm'),assert=require('node:assert'); +const rootDir=path.resolve(__dirname,'..'); +const source=fs.readFileSync(path.join(rootDir,'visualization/question1.html'),'utf8'); +const script=source.match(/ + diff --git a/visualization/question1.html b/visualization/question1.html new file mode 100644 index 0000000..d8627ec --- /dev/null +++ b/visualization/question1.html @@ -0,0 +1,185 @@ +
+ +

带误差的交会定位

+
+ + + +
+
+ +
+ 定位区域 + 直径线段及直径圆 + 最小包围圆 + □ 检测点 + 合成真值 ◎ 覆盖反例顶点 +
+
+
+ 检测点与示向度 +
+ + + +
测点x / my / m示向度 / °误差半角 / °
+
+
+ +
diff --git a/visualization/standalone.css b/visualization/standalone.css new file mode 100644 index 0000000..98c496c --- /dev/null +++ b/visualization/standalone.css @@ -0,0 +1,25 @@ +:root { + color-scheme:light dark; + --background:light-dark(#ffffff,#191c21); + --foreground:light-dark(#24282e,#eceff3); + --border:light-dark(#d9dfe5,#424a55); + --muted-foreground:light-dark(#56616c,#b4bdc8); + --viz-series-1:light-dark(#146e9a,#6cc8ed); + --viz-series-2:light-dark(#c6503a,#ff947d); + --viz-series-3:light-dark(#198064,#64d6ac); + --font-size-base:14px; +} +body { max-width:1000px; margin:0 auto; padding:24px; background:var(--background);color:var(--foreground);font:var(--font-size-base)/1.6 system-ui,"Microsoft YaHei",sans-serif; } +h2 {font-size:1.4em;font-weight:500;margin-block:0 18px;} +.viz-controls {display:flex;gap:16px;flex-wrap:wrap;align-items:end;} +.form-label {display:flex;flex-direction:column;gap:6px;flex:1 1 180px;min-width:0;} +.form-select {font:inherit;color:var(--foreground);background:var(--background);border:1px solid var(--border);padding:8px;max-width:100%;border-radius:5px;} +.form-range {width:100%;min-height:30px;accent-color:var(--viz-series-1);} +.text-small {font-size:12px;} +.tabular-nums {font-variant-numeric:tabular-nums;} +.table-responsive {overflow-x:auto;} +.table {width:100%;border-collapse:collapse;} +.table th,.table td {padding:6px 8px;border-bottom:1px solid var(--border);} +.table th {font-weight:500;text-align:left;} +.table .text-end {text-align:right;} +@media(max-width:480px) {body{padding:12px;}.form-label{flex-basis:100%;}.form-select{font-size:16px;}}