第一问建模完成

This commit is contained in:
2026-09-10 20:46:45 +08:00
parent 86ffc8ee65
commit abf21b2cbf
77 changed files with 3892 additions and 24 deletions
+152
View File
@@ -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. 正式测试时间安排,务必尽早演练并保留正式机会。
下一步最有价值的工作仍然是:**协议计时基线 + 几何内核 + 全向闭环**。完成这条链路后,再用演练数据决定是否加入加速策略。
+61 -20
View File
@@ -2,9 +2,23 @@
根据你们对工程实践和物理模型的熟悉程度,确定研究 B 题“无线电干扰源的快速自动定位与清除”。推荐采用**有界误差几何定位、主动选点、具有覆盖证明的搜索、按实际动作耗时调度**的统一框架。AI 辅助推导、实现与实验分析,策略只依赖题目允许的观测。 根据你们对工程实践和物理模型的熟悉程度,确定研究 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. 四问应当串成一条实现主线** **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. \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。圆盘交角域可能包含曲线边界;若用多边形近似,必须构造外包近似,才能维持“不丢失真源”的保证。用圆周采样点连成的内接多边形不能直接作为保守外包。 在线定位还可用 Ω 和“收到信号意味着距离≤1500”约束 K。圆盘交角域可能包含曲线边界;若用多边形近似,必须构造外包近似,才能维持“不丢失真源”的保证。用圆周采样点连成的内接多边形不能直接作为保守外包。
第 2 问建议先明确“较好的定位效果”:以新观测后的最坏包围圆半径为主要几何指标,再考虑移动和检测成本。交会角大、面积小可作为候选点筛选指标,但不是对所有未知位置都最优的证明。 第 2 问建议先明确“较好的定位效果”:以新观测后的最坏包围圆半径为主要几何指标,再考虑移动和检测成本。交会角大、面积小可作为候选点筛选指标,但不是对所有未知位置都最优的证明。
第一次测向得到 K₁ 后,对全向源,一个保守且可以说明的第二检测点候选区域为: 第一次收到direction后,明确取K₁=Ω∩W(s₁,θ₁±δ)∩B(s₁,1500)。保留整个距离圆盘是保守处理,忽略direction还隐含的近距离排除不会丢失真源。对全向源,第二检测点的安全候选区域为:
\[ \[
\mathcal Q=\{q:\max_{g\in K_1}\|q-g\|\le1000\}. \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. 搜索完整性和停止条件要有可验证的依据** **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小时虚拟上限内有设计空间,现实可行性仍要测接口延迟、总请求数和日志大小。 作为规模检查,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,不并发调用不同动作。 协议必须同时检查 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米的边界;重复请求、拒绝动作及短暂断连。样例数量按剩余时间和运行成本安排,不把有限样例通过等同于数学保证。 基准实验至少覆盖:全向与混合源;数量10和16;接收半径1000和1500米;目标贴近圆域边界且朝外;测向接近0°/360°;误差取界值;检测点距离5米及清除距离20米的边界;重复请求、拒绝动作及短暂断连。样例数量按剩余时间和运行成本安排,不把有限样例通过等同于数学保证。
@@ -147,19 +183,20 @@ AI 的适合用法是把这些模块逐一变成可测试程序、生成边界
**7. 建议实施次序与阶段交付** **7. 建议实施次序与阶段交付**
以下工期是规划估计,不是已经完成的工作。依据附件中的北京时间截止安排,先完成可演练版本,再优化。 下面按P0–P7列出可执行交付与当前状态。排期以验收门槛推进,避免把“几何代码完成”误认为“全清策略完成”。
| 阶段 | 建议投入 | 具体工作 | 进入下一阶段的条件 | | 阶段 | 交付与执行任务 | 验收门槛 | 当前状态 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| P0 规则固定 | 1–2小时 | 整理规则表、模块接口、日志字段;核实模拟器可运行 | 成本口径和状态更新规则一致 | | P0 规则固定 | 规则表、状态机、四类API字段与成本 | 示例逐步105/111/194/199秒;clear不切换频道 | 已核对规则,完整状态机待工程化 |
| P1 工程基线 | 2–4小时 | 实现4类请求、幂等重试、计时回放和自建测试环境 | 199秒示例一致,失败与重试不污染状态 | | P1 协议基线 | enter/measure/clear/exit客户端;串行、幂等、截止时间 | accepted=false和超时重试不污染状态;主动退出;故障注入回放 | 已有少量测向采集器及真实计时回放;完整客户端待实现 |
| P2 几何内核 | 4–6小时 | 完成问题1,实现角域交、包围圆、问题2候选点 | 退化几何和误差边界检查通过 | | P2 几何内核 | 问题一解答、区域分类、直径圆证据、MEC、清除余量 | 跨界、退化、无界、解析反例、独立算法对照 | 已实现并优化,20项自动化测试通过 |
| P3 全向闭环 | 4–6小时 | 覆盖发现、局部定位、清除、停止、统计 | 问题3演练可完整退出,输出可追溯 | | P3 问题二选点 | K₁先验、安全候选盘、后验半径采样与near分支 | 距离证书有效;采样加密;不把探索当接收保证 | 解析非空证明及证书已实现;选点器与实验待实现 |
| P4 定向扩展 | 4–6小时 | 验证49点覆盖;实现失去信号处理与后备清除 | 问题4边界朝外案例和演练通过 | | P4 全向闭环 | 49点覆盖记录、定位队列、清除、停止、日志统计 | 10/16源、边界源和错误响应案例;官方演练全清并正常退出 | 待实现 |
| P5 加速与定稿 | 4–8小时 | 做共享测量、目标排序等消融,固定参数和版本 | 性能改善有证据,完整性标准不退化 | | P5 定向扩展 | no_signal联合约束、覆盖发现、有限清除后备与预算 | 朝外边界源不漏;真源不误删;格数与退出预算检查 | 已有证明和设计;闭环待实现 |
| P6 正式与论文 | 单独留出半天以上 | 分别完成3次正式测试、核查上传、导出原始日志、完成论文表格 | 6次记录、案例编码和交付材料一致 | | 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. 当前已落实的结论与下一步** **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加速投入。
+29
View File
@@ -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` 和本局案例编码。参见完整解答中的采集说明。
+249
View File
@@ -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。
+20
View File
@@ -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
}
]
}
+29
View File
@@ -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
}
]
}
+29
View File
@@ -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
}
]
}
+23
View File
@@ -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
}
]
}
+29
View File
@@ -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
}
]
}
+6
View File
@@ -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"]
+38
View File
@@ -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()
+269
View File
@@ -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
+32
View File
@@ -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}
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1 -2
View File
@@ -1,5 +1,4 @@
def main(): from geometry.__main__ import main
print("Hello from math!")
if __name__ == "__main__": if __name__ == "__main__":
+815
View File
@@ -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
]
}
}
]
}
]
+8
View File
@@ -0,0 +1,8 @@
{
"status": "empty",
"vertices": [],
"diameter_m": null,
"diameter_infinite": false,
"tolerance_m": 1.0000000000000001e-07,
"message": "测向角域没有公共点;检查频道、数据及误差界"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.5 KiB

+77
View File
@@ -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
]
}
}
+67
View File
@@ -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
}
}
+33
View File
@@ -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": "存在非零衰退方向,区域无界,直径为无穷大"
}
@@ -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
]
}
}
@@ -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
}
]
@@ -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"
}
File diff suppressed because one or more lines are too long
+59
View File
@@ -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
]
}
}
+48
View File
@@ -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
}
+68
View File
@@ -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
]
}
}
+7
View File
@@ -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."
}
+2 -2
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "math" name = "math"
version = "0.1.0" version = "0.1.0"
description = "Add your description here" description = "B题问题一:带误差方位交会定位与可视化"
readme = "README.md" readme = "README.md"
requires-python = ">=3.13" requires-python = ">=3.10"
dependencies = [] dependencies = []
+56
View File
@@ -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()
+71
View File
@@ -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='<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>B题问题一交会定位</title><style>'+theme+'</style><body>'+fragment+'</body></html>'
(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()
+123
View File
@@ -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()
+94
View File
@@ -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))
+61
View File
@@ -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(/<script>([\s\S]*?)<\/script>/)[1];
class Element {
constructor(tag) {this.tag=tag;this.attrs={};this.children=[];this.style={};this.events={};this.value='';this.max='3';this.textContent='';}
setAttribute(k,v){this.attrs[k]=String(v);}
appendChild(x){this.children.push(x);return x;}
replaceChildren(...x){this.children=x;this.textContent='';}
addEventListener(k,f){this.events[k]=f;}
add(o){this.children.push(o);}
getBoundingClientRect(){return {width:width};}
}
const esc=s=>String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/"/g,'&quot;');
const palette={light:{'--foreground':'#24282e','--border':'#d9dfe5','--viz-series-1':'#146e9a','--viz-series-2':'#c6503a','--viz-series-3':'#198064'},dark:{'--foreground':'#eceff3','--border':'#424a55','--viz-series-1':'#6cc8ed','--viz-series-2':'#ff947d','--viz-series-3':'#64d6ac'}};
function serialize(el,theme){
if(el.tag==='text')Object.assign(el.attrs,{fill:palette[theme]['--foreground'],'font-size':12,'font-family':'Microsoft YaHei, sans-serif'});
const attrs=Object.entries(el.attrs).map(([k,v])=>`${k}="${esc(String(v).replace(/var\(([^)]+)\)/g,(_,n)=>palette[theme][n]||n))}"`).join(' ');
return `<${el.tag} ${attrs}>${esc(el.textContent)}${el.children.map(x=>serialize(x,theme)).join('')}</${el.tag}>`;
}
function text(el){return el.textContent+el.children.map(text).join(' ');}
const elements={};
for(const key of ['case','step','view','plot','step-label','stats','status','rows'])elements[key]=new Element(key==='plot'?'svg':'div');
elements.view.value='region';
const root=new Element('div');
root.querySelector=s=>{const m=s.match(/data-q1="([^"]+)"/);assert(m&&elements[m[1]],'missing selector '+s);return elements[m[1]];};
const document={getElementById:id=>{assert.equal(id,'q1-localization');return root;},createElement:tag=>new Element(tag),createElementNS:(_,tag)=>new Element(tag)};
let width=736;
vm.runInNewContext(script,{document,Option:function(label,value){this.label=label;this.value=String(value);},ResizeObserver:class{constructor(f){this.f=f;}observe(){}},Number,Math,String,Object,console},{timeout:5000});
const output=path.join(rootDir,'output/q1/figures');fs.mkdirSync(output,{recursive:true});
let checks=0;
for(let i=0;i<elements.case.children.length;i++) {
elements.case.value=String(i);elements.case.events.change();
for(let n=1;n<=+elements.step.max;n++) {
elements.step.value=String(n);elements.step.events.input();
assert.equal(elements['step-label'].textContent,`${n} / ${elements.step.max}`);
for(const v of ['global','region']) {
elements.view.value=v;elements.view.events.change();
const svg=serialize(elements.plot,'light');
assert(!/NaN|undefined|Infinity/.test(svg));
assert(svg.includes('data-axis="x"')&&svg.includes('data-axis="y"'));
checks++;
}
}
}
for(const w of [360,736])for(const theme of ['light','dark'])for(const index of [1,elements.case.children.length-1]) {
width=w;elements.case.value=String(index);elements.case.events.change();
elements.view.value='region';elements.view.events.change();
const height=elements.plot.attrs.viewBox.split(' ')[3];
const svg=serialize(elements.plot,theme).replace('<svg ',`<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${height}" `);
const filename=(index===1?'counterexample':'practice')+`-${w}-${theme}.svg`;
fs.writeFileSync(path.join(output,filename),svg,'utf8');
}
const report={interaction_states_checked:checks,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.'};
fs.writeFileSync(path.join(rootDir,'output/q1/visualization_checks.json'),JSON.stringify(report,null,2));
console.log(report);
+131
View File
@@ -0,0 +1,131 @@
import math
import random
import unittest
from geometry import Measurement, solve, smallest_enclosing_circle
from geometry.localization import halfplanes, distance
class GeometryTests(unittest.TestCase):
def test_equilateral_from_legal_bearings(self):
h = 20 * math.sqrt(3)
stations = [Measurement(-1000, 0, 1),
Measurement(540, -500 * math.sqrt(3), 121),
Measurement(520, h + 500 * math.sqrt(3), 241)]
result = solve(stations)
self.assertEqual(result['status'], 'polygon')
self.assertEqual(len(result['vertices']), 3)
self.assertAlmostEqual(result['diameter_m'], 40, places=7)
self.assertAlmostEqual(result['minimum_enclosing_circle']['radius_m'], 40/math.sqrt(3), places=7)
self.assertFalse(result['diameter_circle']['covers'])
self.assertFalse(result['guaranteed_clearable_at_mec_center'])
def test_no_observations_and_single_bearing(self):
self.assertEqual(solve([])['status'], 'unbounded')
r = solve([Measurement(20, 30, 359.8)])
self.assertEqual(r['status'], 'unbounded')
self.assertTrue(r['diameter_infinite'])
self.assertIsNone(r['diameter_m'])
def test_inconsistent(self):
result = solve([Measurement(-100, 0, 180), Measurement(100, 0, 0)])
self.assertEqual(result['status'], 'empty')
def test_exact_segment_and_point(self):
segment = [Measurement(0, 0, 0, 0), Measurement(100, 0, 180, 0)]
r = solve(segment)
self.assertEqual(r['status'], 'segment')
self.assertAlmostEqual(r['diameter_m'], 100)
self.assertTrue(r['diameter_circle']['covers'])
r = solve(segment + [Measurement(30, -30, 90, 0)])
self.assertEqual(r['status'], 'point')
self.assertAlmostEqual(r['vertices'][0][0], 30)
self.assertAlmostEqual(r['diameter_m'], 0)
def test_forward_ray_not_line(self):
r = solve([Measurement(0, 0, 0, 0), Measurement(-1, 0, 180, 0)])
self.assertEqual(r['status'], 'empty')
def test_angle_wrap_and_repeated_station(self):
a = [Measurement(-500, -1, 359.9), Measurement(0, -500, 90)]
b = [Measurement(-500, -1, -.1), Measurement(0, -500, 450)]
self.assertAlmostEqual(solve(a)['diameter_m'], solve(b)['diameter_m'])
self.assertAlmostEqual(solve(a+a)['diameter_m'], solve(a)['diameter_m'])
def test_close_parallel_remains_finite(self):
# Two nearly parallel wedges can still be bounded, very far away.
r = solve([Measurement(0, 0, 1), Measurement(0, 100, -1.000001)])
self.assertEqual(r['status'], 'polygon')
self.assertGreater(r['diameter_m'], 1e9)
def test_circle_known_shapes(self):
c, r = smallest_enclosing_circle([(0,0),(2,0),(2,2),(0,2)])
self.assertEqual(c, (1,1))
self.assertAlmostEqual(r, math.sqrt(2))
c, r = smallest_enclosing_circle([(0,0),(2,0),(.4,.2)])
self.assertAlmostEqual(r, 1)
c, r = smallest_enclosing_circle([(0,0),(1,0),(3,0)])
self.assertAlmostEqual(r, 1.5)
def test_random_bounded_truth_containment_and_monotonicity(self):
rng = random.Random(20260910)
for _ in range(100):
target = (rng.uniform(-300,300), rng.uniform(-300,300))
ms = []
for i in range(4):
ang = (i*90 + rng.uniform(-20,20))*math.pi/180
radius = rng.uniform(300,1200)
p = (target[0]+radius*math.cos(ang), target[1]+radius*math.sin(ang))
bearing = math.degrees(math.atan2(target[1]-p[1],target[0]-p[0]))
ms.append(Measurement(*p, bearing+rng.uniform(-.99,.99)))
r3, r4 = solve(ms[:3]), solve(ms)
self.assertEqual(r4['status'], 'polygon')
self.assertLessEqual(r4['diameter_m'], r3['diameter_m']+1e-5)
for m in ms:
for a,b,c in halfplanes(m):
self.assertLessEqual(a*target[0]+b*target[1], c+1e-7)
mec = r4['minimum_enclosing_circle']
self.assertLessEqual(distance(mec['center'], target), mec['radius_m']+1e-5)
for v in r4['vertices']:
self.assertLessEqual(distance(mec['center'], v), mec['radius_m']+1e-5)
def test_translation_rotation_scaling(self):
ms = [Measurement(-500,0,1), Measurement(0,-500,89), Measurement(400,400,225)]
original = solve(ms)
a=.37
changed = [Measurement(1e6+3*(m.x*math.cos(a)-m.y*math.sin(a)),
-2e6+3*(m.x*math.sin(a)+m.y*math.cos(a)),
m.bearing_deg+math.degrees(a)) for m in ms]
moved=solve(changed)
self.assertAlmostEqual(moved['diameter_m'], original['diameter_m']*3, places=6)
def test_invalid_input(self):
for args in [(float('nan'),0,0),(0,0,float('inf')),(0,0,0,90),(0,0,0,-1),(True,0,0)]:
with self.assertRaises(ValueError): Measurement(*args)
def test_independent_rectangle_clipping_reference(self):
# A second construction: clip a demonstrably inactive finite box.
# The production algorithm never uses this box.
rng=random.Random(4001)
for _ in range(60):
ms=[]
for x,y in [(-500,-300),(600,-200),(0,700)]:
ms.append(Measurement(x,y,math.degrees(math.atan2(-y,-x))+rng.uniform(-.9,.9)))
poly=[(-2000.,-2000.),(2000.,-2000.),(2000.,2000.),(-2000.,2000.)]
for m in ms:
for a,b,c in halfplanes(m):
out=[]
for p,q in zip(poly,poly[1:]+poly[:1]):
f=a*p[0]+b*p[1]-c;g=a*q[0]+b*q[1]-c
if f<=0:out.append(p)
if f*g<0:
t=f/(f-g);out.append((p[0]+t*(q[0]-p[0]),p[1]+t*(q[1]-p[1])))
poly=out
self.assertTrue(poly)
self.assertTrue(all(max(abs(x),abs(y))<1900 for x,y in poly))
expected=max(math.dist(p,q) for p in poly for q in poly)
self.assertAlmostEqual(solve(ms)['diameter_m'],expected,places=6)
if __name__ == '__main__':
unittest.main()
+107
View File
@@ -0,0 +1,107 @@
import json
import math
from pathlib import Path
import random
import subprocess
import sys
import unittest
from geometry import Measurement, solve, smallest_enclosing_circle, first_bearing_candidate_disk
from geometry.localization import _enclosing_circle_reference, distance
ROOT = Path(__file__).resolve().parents[1]
class CertificateTests(unittest.TestCase):
def test_incremental_circle_against_exhaustive_reference(self):
rng = random.Random(271828)
for _ in range(250):
points = [(rng.uniform(-20, 20), rng.uniform(-20, 20))
for _ in range(rng.randrange(1, 20))]
expected_c, expected_r = _enclosing_circle_reference(points)
center, radius = smallest_enclosing_circle(points)
self.assertAlmostEqual(radius, expected_r, places=7)
self.assertLess(distance(center, expected_c), 1e-6)
self.assertTrue(all(distance(center, p) <= radius for p in points))
def test_circle_large_cocircular_duplicate_and_collinear_sets(self):
# More than Python's recursion limit, with analytically known answers.
points = [(1e6+30*math.cos(i*math.tau/1500),
-2e6+30*math.sin(i*math.tau/1500)) for i in range(1500)]
center, radius = smallest_enclosing_circle(points+points[:100])
self.assertLess(distance(center, (1e6, -2e6)), 1e-7)
self.assertAlmostEqual(radius, 30, places=7)
c, r = smallest_enclosing_circle([(i, i*2) for i in range(1500)])
self.assertAlmostEqual(r, 1499*math.sqrt(5)/2)
self.assertEqual(c, (749.5, 1499))
self.assertEqual(smallest_enclosing_circle([(3, 4)]*10), ((3, 4), 0))
def test_witness_and_clearance_margin_from_saved_observations(self):
path = next((ROOT/'output/q1/practice').glob('*_observations.json'))
rows = json.loads(path.read_text(encoding='utf-8'))['measurements']
result = solve([Measurement(**m) for m in rows])
circle = result['diameter_circle']
self.assertFalse(circle['covers'])
self.assertIn(circle['witness_vertex'], result['vertices'])
self.assertAlmostEqual(distance(circle['witness_vertex'], circle['center'])-circle['radius_m'],
circle['max_excess_m'])
for i in circle['outside_vertex_indices']:
self.assertGreater(distance(result['vertices'][i], circle['center']),
circle['radius_m']+circle['coverage_tolerance_m'])
cert = result['clearance_certificate']
self.assertGreater(cert['remaining_margin_m'], 7.9)
self.assertTrue(result['guaranteed_clearable_at_mec_center'])
self.assertAlmostEqual(cert['remaining_margin_m']+cert['radius_with_margin_m'], 20)
def test_clearance_at_threshold_is_conservative(self):
for length, expected in [(39.999, True), (40, False), (40.001, False)]:
r = solve([Measurement(0, 0, 0, 0), Measurement(length, 0, 180, 0)])
self.assertEqual(r['guaranteed_clearable_at_mec_center'], expected)
self.assertTrue(r['diameter_circle']['covers'])
self.assertIsNone(r['diameter_circle']['witness_vertex'])
def test_first_sector_disk_covers_all_angles_and_distances(self):
for angle in [0, 359.9, -180]:
for delta in [0, 1, 1.005, 45]:
m = Measurement(-800, 1200, angle, delta)
cert = first_bearing_candidate_disk(m)
enclosing = cert['sector_enclosing_circle']
candidate = cert['sufficient_candidate_disk']
for j in range(21):
for k in range(11):
phi = math.radians(angle-delta+2*delta*j/20)
p = (m.x+150*k*math.cos(phi), m.y+150*k*math.sin(phi))
self.assertLessEqual(distance(p, enclosing['center']), enclosing['radius_m']+1e-9)
if candidate:
for t in range(8):
q = (candidate['center'][0]+candidate['radius_m']*math.cos(t*math.tau/8),
candidate['center'][1]+candidate['radius_m']*math.sin(t*math.tau/8))
self.assertLessEqual(distance(p, q), 1000+1e-9)
if delta <= 1.005:
self.assertTrue(cert['candidate_nonempty_certified'])
self.assertGreater(candidate['radius_m'], 249.88)
def test_candidate_equality_and_no_certificate(self):
equality = first_bearing_candidate_disk(Measurement(0, 0, 0, 0), max_range_m=2000)
self.assertTrue(equality['candidate_nonempty_certified'])
self.assertEqual(equality['sufficient_candidate_disk']['radius_m'], 0)
no_certificate = first_bearing_candidate_disk(Measurement(0, 0, 0), max_range_m=3000)
self.assertIsNone(no_certificate['sufficient_candidate_disk'])
self.assertNotIn('candidate_empty', no_certificate)
self.assertTrue(no_certificate['distance_only'])
with self.assertRaises(ValueError):
first_bearing_candidate_disk(Measurement(0, 0, 0, 46))
def test_cli_explicit_rounding_margin(self):
base = [sys.executable, '-m', 'geometry', 'examples/q1/triangle.json']
outputs = [json.loads(subprocess.check_output(base+args, cwd=ROOT, encoding='utf-8'))
for args in [[], ['--rounding-margin-deg', '0.005']]]
self.assertEqual(outputs[0]['error_policy']['effective_half_angles_deg'], [1, 1, 1])
self.assertEqual(outputs[1]['error_policy']['effective_half_angles_deg'], [1.005]*3)
self.assertGreater(outputs[1]['diameter_m'], outputs[0]['diameter_m'])
bad = subprocess.run(base+['--rounding-margin-deg', '-1'], cwd=ROOT, capture_output=True)
self.assertNotEqual(bad.returncode, 0)
if __name__ == '__main__':
unittest.main()
+47
View File
@@ -0,0 +1,47 @@
"""Offline verification of captured practice evidence; never contacts simulator."""
import json
import math
from pathlib import Path
import unittest
from geometry import Measurement, solve
ROOT=Path(__file__).resolve().parents[1]
class PracticeReplayTests(unittest.TestCase):
def test_action_log_and_derived_geometry(self):
folder=ROOT/'output/q1/practice'
files=list(folder.glob('*_actions.json'))
self.assertTrue(files, '缺少本次演练采集证据')
for path in files:
events=json.loads(path.read_text(encoding='utf-8'))
pos=(0,0);channel=1;virtual=0
self.assertEqual(events[0]['path'],'/enter')
self.assertEqual(events[-1]['path'],'/exit')
self.assertEqual(events[-1]['response']['exit_reason'],'user_exit')
ids=[]
for event in events:
self.assertIn(event['path'],('/enter','/measure','/exit'))
request,response=event['request'],event['response']
self.assertTrue(response['accepted'])
ids.append(request['request_id'])
if event['path']=='/measure':
new=(request['position']['x'],request['position']['y'])
virtual+=math.dist(pos,new)/5+5+(request['channel']!=channel)
pos,channel=new,request['channel']
self.assertAlmostEqual(virtual,response['virtual_time_s'],places=5)
self.assertEqual(len(ids),len(set(ids)))
observation_path=path.with_name(path.name.replace('_actions','_observations'))
data=json.loads(observation_path.read_text(encoding='utf-8'))
ms=[Measurement(**m) for m in data['measurements']]
recomputed=solve(ms)
self.assertAlmostEqual(recomputed['diameter_m'],data['result']['diameter_m'],places=9)
self.assertEqual(data['source'],'official_practice')
self.assertIsNone(data['ground_truth'])
directions=[e for e in events if e['response'].get('measure_result')=='direction']
self.assertEqual(directions[0]['request']['position'],directions[1]['request']['position'])
self.assertEqual(directions[0]['response']['svd_deg'],directions[1]['response']['svd_deg'])
if __name__=='__main__': unittest.main()
+185
View File
@@ -0,0 +1,185 @@
<div id="q1-localization">
<style>
#q1-localization .q1-plot { width:100%; display:block; overflow:hidden; }
#q1-localization .q1-legend { display:flex; flex-wrap:wrap; gap:12px; margin-block:12px; }
#q1-localization .q1-legend span { display:inline-flex; align-items:center; gap:6px; }
#q1-localization .q1-swatch { display:inline-block; width:18px; height:3px; }
#q1-localization .q1-stats { display:flex; flex-wrap:wrap; gap:12px 24px; margin-block:12px; }
#q1-localization .q1-status { margin-block:8px; }
#q1-localization .q1-plot text { fill:var(--foreground); font-size:12px; font-weight:400; }
#q1-localization details { margin-block:12px; }
#q1-localization summary { cursor:pointer; }
</style>
<h2>带误差的交会定位</h2>
<div class="viz-controls">
<label class="form-label">观测案例
<select class="form-select" data-q1="case" aria-label="观测案例"></select>
</label>
<label class="form-label">视图
<select class="form-select" data-q1="view" aria-label="视图">
<option value="region">定位区域放大</option>
<option value="global">检测点全景</option>
</select>
</label>
<label class="form-label">已加入测点 <span data-q1="step-label" class="tabular-nums"></span>
<input class="form-range" data-q1="step" type="range" min="1" max="3" value="3" step="1" aria-label="已加入测点数量">
</label>
</div>
<div class="q1-stats tabular-nums" aria-live="polite" data-q1="stats"></div>
<svg class="q1-plot" data-q1="plot" role="img" aria-label="检测角域、定位多边形、直径线段和两个包围圆"></svg>
<div class="q1-legend text-small">
<span><i class="q1-swatch" style="background:var(--viz-series-1)"></i>定位区域</span>
<span><i class="q1-swatch" style="background:var(--viz-series-2)"></i>直径线段及直径圆</span>
<span><i class="q1-swatch" style="background:var(--viz-series-3)"></i>最小包围圆</span>
<span>□ 检测点 + 合成真值 ◎ 覆盖反例顶点</span>
</div>
<div class="q1-status text-small" data-q1="status" aria-live="polite"></div>
<details>
<summary>检测点与示向度</summary>
<div class="table-responsive">
<table class="table table-sm tabular-nums">
<thead><tr><th>测点</th><th class="text-end">x / m</th><th class="text-end">y / m</th><th class="text-end">示向度 / °</th><th class="text-end">误差半角 / °</th></tr></thead>
<tbody data-q1="rows"></tbody>
</table>
</div>
</details>
<script>
(() => {
const root=document.getElementById('q1-localization');
const cases=/*__Q1_DATA__*/;
const get=name=>root.querySelector('[data-q1="'+name+'"]');
const picker=get('case'), step=get('step'), view=get('view'), svg=get('plot');
const NS='http://www.w3.org/2000/svg';
const fmt=x=>Number.isFinite(x)?x.toLocaleString('zh-CN',{maximumFractionDigits:4}):'—';
const names={polygon:'有界多边形',segment:'线段',point:'单点',empty:'空集',unbounded:'无界区域'};
function node(tag,attrs={},parent=svg,text=null) {
const el=document.createElementNS(NS,tag);
Object.entries(attrs).forEach(([k,v])=>el.setAttribute(k,String(v)));
if(text!==null)el.textContent=text;
parent.appendChild(el);return el;
}
cases.forEach((c,i)=>picker.add(new Option(c.name,i)));
picker.value=String(Math.max(0,cases.findIndex(c=>c.source==='official_practice')));
function load() {
const c=cases[+picker.value];
step.max=String(c.measurements.length);step.value=step.max;draw();
}
function draw() {
const c=cases[+picker.value], n=+step.value, ms=c.measurements.slice(0,n), r=c.frames[n-1];
get('step-label').textContent=n+' / '+c.measurements.length;
get('stats').replaceChildren();
const values=[['区域',names[r.status]],['直径 D',r.diameter_infinite?'∞':fmt(r.diameter_m)+' m'],
['最小包围半径',r.minimum_enclosing_circle?fmt(r.minimum_enclosing_circle.radius_m)+' m':'—']];
values.forEach(([label,value])=>{const span=document.createElement('span');span.textContent=label+':'+value;get('stats').appendChild(span);});
const source=c.source==='official_practice'?'官方演练观测;真实源位置未公开':'本地合成案例';
get('status').textContent=source+' · '+(r.status==='unbounded'?'阴影只显示视窗内部分,真实区域无界':
r.status==='empty'?'这些观测在给定误差界下不相容':
'直径圆'+(r.diameter_circle.covers?'可以覆盖':'不能覆盖,最大超出 '+fmt(r.diameter_circle.max_excess_m)+' m')+
' · 清除半径余量 '+fmt(r.clearance_certificate.remaining_margin_m)+' m');
get('rows').replaceChildren();
ms.forEach((m,i)=>{
const tr=document.createElement('tr');
[String(i+1),fmt(m.x),fmt(m.y),fmt(m.bearing_deg),fmt(m.half_angle_deg)].forEach((v,j)=>{
const td=document.createElement('td');td.textContent=v;if(j)td.className='text-end';tr.appendChild(td);
});get('rows').appendChild(tr);
});
svg.replaceChildren();
const width=Math.max(280,root.getBoundingClientRect().width);
const height=Math.max(310,Math.min(440,width*.65));
svg.setAttribute('viewBox',`0 0 ${width} ${height}`);
svg.style.height=height+'px';
node('title',{},svg,c.name+':'+names[r.status]);
node('desc',{},svg,get('status').textContent);
const l=64,t=20,right=20,bottom=46,w=width-l-right,h=height-t-bottom;
const finite=['polygon','segment','point'].includes(r.status);
let pts=(view.value==='region'&&finite)?r.vertices.map(p=>[...p]):ms.map(m=>[m.x,m.y]);
if(!pts.length)pts=[[0,0]];
if(view.value==='global'&&finite)pts.push(...r.vertices);
if(c.target)pts.push(c.target);
if(finite&&view.value==='region') {
[r.diameter_circle,r.minimum_enclosing_circle].forEach(circle=>{
const [x,y]=circle.center,d=circle.radius_m;
pts.push([x-d,y-d],[x+d,y+d]);
});
}
const xs=pts.map(p=>p[0]),ys=pts.map(p=>p[1]);
const cx=(Math.min(...xs)+Math.max(...xs))/2, cy=(Math.min(...ys)+Math.max(...ys))/2;
let dx=Math.max(...xs)-Math.min(...xs),dy=Math.max(...ys)-Math.min(...ys);
const minSpan=finite&&view.value==='region'?Math.max(2,(r.diameter_m||0)*.08):200;
const scale=Math.min(w/Math.max(dx*1.3,minSpan),h/Math.max(dy*1.3,minSpan));
const xmin=cx-w/(2*scale),xmax=cx+w/(2*scale),ymin=cy-h/(2*scale),ymax=cy+h/(2*scale);
const X=x=>l+(x-xmin)*scale,Y=y=>t+h-(y-ymin)*scale;
const defs=node('defs');const clip=node('clipPath',{id:'q1-clip'},defs);
node('rect',{x:l,y:t,width:w,height:h},clip);
for(let i=0;i<4;i++) {
const x=xmin+(xmax-xmin)*i/3, y=ymin+(ymax-ymin)*i/3;
node('line',{x1:X(x),x2:X(x),y1:t,y2:t+h,stroke:'var(--border)'});
node('line',{x1:l,x2:l+w,y1:Y(y),y2:Y(y),stroke:'var(--border)'});
const tick=z=>Math.abs(z)>=1e6?z.toExponential(1):Math.abs(xmax-xmin)<20?z.toFixed(1):Math.round(z).toString();
node('text',{x:X(x),y:t+h+19,'text-anchor':i===0?'start':i===3?'end':'middle'},svg,tick(x));
node('text',{x:l-8,y:Y(y)+4,'text-anchor':'end'},svg,tick(y));
}
node('text',{x:l+w/2,y:height-7,'text-anchor':'middle',class:'axis-title','data-axis':'x'},svg,'x / m');
node('text',{x:14,y:t+h/2,'text-anchor':'middle',transform:`rotate(-90 14 ${t+h/2})`,class:'axis-title','data-axis':'y'},svg,'y / m');
const layer=node('g',{'clip-path':'url(#q1-clip)'});
let displayPoly;
if(finite)displayPoly=r.vertices;
else if(r.status==='empty')displayPoly=[];
else {
// Display clipping ONLY: Python's result remains explicitly unbounded.
displayPoly=[[xmin,ymin],[xmax,ymin],[xmax,ymax],[xmin,ymax]];
ms.forEach(m=>{
const theta=m.bearing_deg*Math.PI/180,d=m.half_angle_deg*Math.PI/180;
const normals=[[Math.sin(theta-d),-Math.cos(theta-d)],[-Math.sin(theta+d),Math.cos(theta+d)],[-Math.cos(theta),-Math.sin(theta)]];
normals.forEach(([a,b])=>{
const bound=a*m.x+b*m.y,out=[];
for(let i=0;i<displayPoly.length;i++) {
const p=displayPoly[i],q=displayPoly[(i+1)%displayPoly.length];
const f=a*p[0]+b*p[1]-bound,g=a*q[0]+b*q[1]-bound;
if(f<=1e-8)out.push(p);
if((f<0&&g>0)||(f>0&&g<0))out.push([p[0]+(q[0]-p[0])*f/(f-g),p[1]+(q[1]-p[1])*f/(f-g)]);
}displayPoly=out;
});
});
}
if(displayPoly.length>=3)node('polygon',{points:displayPoly.map(p=>X(p[0])+','+Y(p[1])).join(' '),fill:'var(--viz-series-1)','fill-opacity':.18,stroke:'var(--viz-series-1)','stroke-width':1.5},layer);
const reach=Math.hypot(xmax-xmin,ymax-ymin)+Math.max(...ms.map(m=>Math.hypot(m.x-cx,m.y-cy)))*2;
ms.forEach((m,i)=>{
[-m.half_angle_deg,0,m.half_angle_deg].forEach(offset=>{
const a=(m.bearing_deg+offset)*Math.PI/180;
node('line',{x1:X(m.x),y1:Y(m.y),x2:X(m.x+reach*Math.cos(a)),y2:Y(m.y+reach*Math.sin(a)),
stroke:'var(--foreground)','stroke-opacity':offset===0?.35:.22,'stroke-width':1,'stroke-dasharray':offset===0?'none':'4 4'},layer);
});
if(m.x>=xmin&&m.x<=xmax&&m.y>=ymin&&m.y<=ymax) {
node('rect',{x:X(m.x)-4,y:Y(m.y)-4,width:8,height:8,fill:'var(--foreground)'},layer);
node('text',{x:Math.min(l+w-22,X(m.x)+9),y:Math.max(t+14,Y(m.y)-9)},layer,'S'+(i+1));
}
});
if(finite) {
[r.diameter_circle,r.minimum_enclosing_circle].forEach((circle,i)=>{
node('circle',{cx:X(circle.center[0]),cy:Y(circle.center[1]),r:circle.radius_m*scale,
fill:'none',stroke:`var(--viz-series-${i+2})`,'stroke-width':2,'stroke-dasharray':i===0?'6 4':'none'},layer);
});
const [a,b]=r.diameter_pair;
node('line',{x1:X(a[0]),y1:Y(a[1]),x2:X(b[0]),y2:Y(b[1]),stroke:'var(--viz-series-2)','stroke-width':2.5},layer);
r.vertices.forEach((p,i)=>{
const mark=node('circle',{cx:X(p[0]),cy:Y(p[1]),r:4,fill:'var(--viz-series-1)'},layer);
node('title',{},mark,`V${i+1} (${fmt(p[0])}, ${fmt(p[1])})`);
});
const witness=r.diameter_circle.witness_vertex;
if(witness)node('circle',{cx:X(witness[0]),cy:Y(witness[1]),r:8,
fill:'none',stroke:'var(--viz-series-2)','stroke-width':2},layer);
}
if(c.target&&c.target[0]>=xmin&&c.target[0]<=xmax&&c.target[1]>=ymin&&c.target[1]<=ymax) {
const x=X(c.target[0]),y=Y(c.target[1]);
node('path',{d:`M${x-5},${y}h10 M${x},${y-5}v10`,stroke:'var(--foreground)','stroke-width':2},layer);
}
node('rect',{x:l,y:t,width:w,height:h,fill:'none',stroke:'var(--border)','data-chart-frame':''});
}
picker.addEventListener('change',load);step.addEventListener('input',draw);view.addEventListener('change',draw);
new ResizeObserver(draw).observe(root);
load();
})();
</script>
</div>
File diff suppressed because one or more lines are too long
+25
View File
@@ -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;}}