refactor(arch): redesign the code structure
This commit is contained in:
@@ -21,3 +21,6 @@ docs/paper/*.fls
|
||||
docs/paper/*.fdb_latexmk
|
||||
docs/paper/*.xdv
|
||||
docs/paper/*.synctex.gz
|
||||
|
||||
# pytest
|
||||
.pytest_cache/
|
||||
|
||||
@@ -1,29 +1,92 @@
|
||||
# B题问题一 交会定位
|
||||
# mcm26b — CUMCM 2026 B题:无线电干扰源的快速自动定位与清除
|
||||
|
||||
给定同一干扰源的检测点坐标及带 $\pm 1^\circ$ 误差的示向度,计算前向角域交集、定位区域直径、直径圆覆盖判断与最小包围圆。
|
||||
统一框架:有界误差几何定位、主动选点、带覆盖证明的搜索、按真实动作耗时调度。
|
||||
分析依据 `issue/B题.pdf`、附件1《模拟器使用说明》、附件2《通信接口说明及编程指南》。
|
||||
|
||||
- [完整中文解答与算法证明](docs/问题一解答.md)
|
||||
- [修订后的四问思路与P0–P7计划](B题建模分析与实施计划.md)
|
||||
- [核心代码](geometry/localization.py)
|
||||
- [离线交互模拟](output/q1/question1.html)
|
||||
- [官方演练观测与原始记录](output/q1/practice/)
|
||||
- [优化与舍入敏感性报告](output/q1/optimization_report.json)
|
||||
- [四问建模分析与 P0–P7 实施计划](docs/B题建模分析与实施计划.md)
|
||||
- [协议规则表、字段表与状态机(P0 交付)](docs/协议规则表与状态机.md)
|
||||
- [问题一完整解答](docs/问题一解答.md)
|
||||
- [论文](docs/paper/main.tex)
|
||||
|
||||
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
|
||||
```
|
||||
src/mcm26b/
|
||||
├── config.py # 参数中心:题面常量[题面] + 工程参数[工程],唯一字面量来源
|
||||
├── protocol/ # P0/P1 字段表、计时模型、状态机、串行幂等客户端(已完工)
|
||||
├── geometry/ # P2 角域交会、直径、MEC、问题二安全候选盘证书(已完工)
|
||||
├── sim/ # 自建模拟器、故障注入、演练日志回放(测试专用,已完工)
|
||||
├── belief/ # P3 逐频道候选集合 K_c(骨架)
|
||||
├── coverage/ # P4 49点覆盖账本(骨架 + 网格生成)
|
||||
├── planner/ # P3 选点 / P4 全向闭环 / P5 定向扩展(骨架)
|
||||
└── evaluation/ # 日志复算与统计(复算已完工)
|
||||
tests/ # pytest(兼容 unittest 风格),53 项
|
||||
scripts/ # 采集、基准、可视化与校核脚本
|
||||
output/ examples/ # 演练证据、算例与生成产物
|
||||
```
|
||||
|
||||
用浏览器打开 `output/q1/question1.html`。可选择合成反例或官方演练,逐个加入测点,切换全景与定位区域放大。
|
||||
## 快速开始
|
||||
|
||||
输出新增直径圆覆盖反例顶点、最大超出距离和 20 米清除余量;默认仍为题设 $\pm 1^\circ$,舍入裕量须显式启用。最小包围圆使用可复现的随机增量算法,原枚举法保留作独立对照。`geometry.first_bearing_candidate_disk` 提供问题二首次测向安全区域的解析证书;问题二选点优化器及问题三、四的全清闭环仍待实现。
|
||||
Python ≥ 3.10,依赖仅 `requests`(测试另需 `pytest`)。
|
||||
|
||||
可视化事件检查使用Node.js:`node scripts/verify_visualization.cjs`。可选PNG重绘使用Pillow及Windows微软雅黑字体:`python scripts/render_q1_figures.py`;这些只用于图形校核,不是核心求解依赖。
|
||||
```bash
|
||||
uv sync # 安装(可编辑挂载本包)
|
||||
uv run pytest # 全部测试
|
||||
uv run mcm26b sim-check # P0 自检:复现附件计时示例 105/111/194/199
|
||||
uv run mcm26b q1 examples/q1/normal.json # 问题一求解(等价 python -m mcm26b.geometry ...)
|
||||
uv run python main.py examples/q1/triangle.json --rounding-margin-deg 0.005
|
||||
```
|
||||
|
||||
本项目只使用官方**演练**采集了问题一所需观测,未使用正式测试。采集器不会启动测试,必须先在界面确认“问题3演练”、接口已就绪,再提供 `--practice-confirmed` 和本局案例编码。参见完整解答中的采集说明。
|
||||
## 问题一(P2,已完工)
|
||||
|
||||
给定同频道多个检测点及 ±1° 误差示向度,计算前向角域交集(半平面交)、
|
||||
定位区域直径、直径圆覆盖判断(含反例顶点与超出距离)与最小包围圆、20m 清除余量。
|
||||
|
||||
```bash
|
||||
uv run mcm26b q1 examples/q1/normal.json -o output/q1/result.json
|
||||
uv run python scripts/build_q1_demo.py # 重建离线交互可视化 output/q1/question1.html
|
||||
uv run python scripts/benchmark_q1.py # MEC 基准与舍入敏感性
|
||||
```
|
||||
|
||||
离线交互页:`output/q1/question1.html`。可选 PNG 重绘需要 Pillow 与 Windows
|
||||
雅黑字体:`python scripts/render_q1_figures.py`;事件校核需要 Node.js:
|
||||
`node scripts/verify_visualization.cjs`。二者仅为图形校核,非求解依赖。
|
||||
|
||||
## 协议基线(P0/P1,已完工)
|
||||
|
||||
- `protocol.rules`:四条指令的字段表、结果码与错误语义(可执行表格);
|
||||
- `protocol.timing`:纯计时模型,逐步复现附件示例 105/111/194/199;
|
||||
- `protocol.state.RobotState`:位置/频道/虚拟时刻状态机;accepted=false 不更新,
|
||||
`/clear` 不切频道,虚拟时刻以 accepted 响应为准;
|
||||
- `protocol.client.SimulatorClient`:串行发送、幂等重试(同 ID 同内容原样重发)、
|
||||
连接/读超时、基于 `remaining_real_duration_s` 的本地截止时钟与退出预留、JSONL 会话日志;
|
||||
- `sim.LocalSimulator`:按附件语义复现模拟器(观测门限、定向覆盖角、幂等回放、
|
||||
400/accepted=false 语义),真值只对测试端开放;`ScriptedFaultTransport` 注入断连/超时/500,
|
||||
`PlaybackTransport` 回放官方演练日志。
|
||||
|
||||
```python
|
||||
from mcm26b.protocol import SimulatorClient
|
||||
client = SimulatorClient("参赛队号") # 默认 http://127.0.0.1:2026
|
||||
client.enter()
|
||||
outcome = client.measure((300, 400), 1) # MeasureOutcome(result, svd_deg, ...)
|
||||
client.exit()
|
||||
```
|
||||
|
||||
## 阶段状态(对照 P0–P6 计划)
|
||||
|
||||
| 阶段 | 状态 |
|
||||
| --- | --- |
|
||||
| P0 规则固定(规则表/状态机/字段表,示例逐步 105/111/194/199) | ✅ 完成 |
|
||||
| P1 协议基线(客户端 + 自建回放/故障注入测试) | ✅ 完成 |
|
||||
| P2 几何内核(角域交、直径、MEC、证书) | ✅ 完成(迁入 `mcm26b.geometry`) |
|
||||
| P3 问题二选点(K1、候选区域 Q、采样评估) | 🦴 骨架(证书已完工) |
|
||||
| P4 问题3全向闭环(49点覆盖、定位队列、清除、停止) | 🦴 骨架 |
|
||||
| P5 问题4定向扩展(联合约束、后备清除、预算) | 🦴 骨架 |
|
||||
| P6 加速与消融 | 🦴 未开始 |
|
||||
|
||||
## 采集与正式测试约束
|
||||
|
||||
本项目只使用官方**演练**采集观测,从未使用正式测试。`scripts/capture_practice.py`
|
||||
不会启动测试:必须先在界面确认“问题3演练”、接口已就绪,再提供
|
||||
`--practice-confirmed` 与本局案例编码。正式测试由团队按计划另行安排;
|
||||
加密日志上传上限 2MB,程序避免异常高频循环。
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
# 协议规则表、字段表与状态机(P0 交付)
|
||||
|
||||
依据:`issue/附件/附件1.docx`《模拟器使用说明》、`issue/附件/附件2.docx`《模拟器通信接口说明及编程指南》。
|
||||
本文是人类可读版本;**可执行版本**是 `src/mcm26b/protocol/rules.py`(字段表与错误语义)、
|
||||
`src/mcm26b/protocol/timing.py`(计时模型)与 `src/mcm26b/config.py`(全部常量),
|
||||
三者由 `tests/test_protocol_timing.py`、`tests/test_protocol_client.py` 固化验收。
|
||||
|
||||
## 1. 动作与耗时规则表
|
||||
|
||||
| 动作 | 指令 | 虚拟耗时 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| 开始 | `POST /enter` | 0 | 初始位置 (0,0),初始频道 1;虚拟/程序时钟开始 |
|
||||
| 检测 | `POST /measure` | 5 + 移动 + 换频道 | 无论结果(no_signal/near/direction)均为 5s |
|
||||
| 清除 | `POST /clear` | 未发现 3 / 成功 5 + 移动 | 5 = 光学定位 3 + 激光清除 2;**不切换测向频道** |
|
||||
| 结束 | `POST /exit` | 0 | 成功响应中 exit_reason 固定 `user_exit` |
|
||||
| 切换频道 | 无独立指令 | 1 | 只由合法 `/measure` 的 channel 变化推断 |
|
||||
| 移动 | 无独立指令 | 直线距离/5(m/s) | 由 `/measure` 或 `/clear` 的 position 推断 |
|
||||
|
||||
派生事实(易错点):
|
||||
- 一次合法 `/measure` 完成后,测向机频道更新为本次检测频道;
|
||||
- `/clear` 的 channel 只是目标频道,既不切频道、也不产生切换耗时,因此
|
||||
"清除后原地再测"的耗时只取决于上一次 **/measure** 的频道;
|
||||
- 只有 accepted=true 的请求推进虚拟时钟;`accepted=false` 的 virtual_time_s=0 不是当前时刻;
|
||||
- 5 秒检测只是虚拟耗时,不要求现实等待。
|
||||
|
||||
## 2. 字段表
|
||||
|
||||
公共请求字段(4 条指令都必填):
|
||||
|
||||
| 字段 | 类型 | 约束 |
|
||||
| --- | --- | --- |
|
||||
| arena_id | string | 必须为 `"default"` |
|
||||
| robot_id | string | 逐字节等于登录参赛队号;UTF-8 长度 1..64;无控制字符 |
|
||||
| request_id | string | 会话内幂等键;UTF-8 长度 1..128;无控制字符 |
|
||||
|
||||
动作字段(实现见 `rules.ACTION_FIELDS`):
|
||||
|
||||
| 指令 | 字段 | 约束 |
|
||||
| --- | --- | --- |
|
||||
| /enter | (无附加) | |
|
||||
| /measure | position{x,y}, channel | x/y 有限数值且 \|v\|<=2,000,000;channel 整数 1..20(1.0 亦接受,1.5 拒绝) |
|
||||
| /clear | position{x,y}, channel | 同上;channel 是目标频道 |
|
||||
| /exit | (无附加) | |
|
||||
|
||||
响应字段:
|
||||
|
||||
| 场景 | 字段 |
|
||||
| --- | --- |
|
||||
| 所有响应 | accepted, real_timestamp_ms, virtual_time_s |
|
||||
| accepted=false | 仅上述 3 个字段(virtual_time_s=0) |
|
||||
| /enter 成功 | + max_virtual_duration_s, max_real_duration_s, remaining_real_duration_s |
|
||||
| /measure 成功 | + measure_result ∈ {no_signal, near, direction};direction 时另有 svd_deg(保留两位小数) |
|
||||
| /clear 成功 | + clear_result ∈ {success, no_target_in_range} |
|
||||
| /exit 成功 | + exit_reason = "user_exit" |
|
||||
|
||||
错误语义(必须同时检查 HTTP 状态与 accepted):
|
||||
|
||||
| 情形 | 结果 | 对 request_id 的影响 |
|
||||
| --- | --- | --- |
|
||||
| JSON 语法/重复键/缺字段/类型/坐标频道范围 | HTTP 400 | 不占用,修正后可复用 |
|
||||
| 未知字段(含 position 下)、arena/robot 不匹配、测试状态拒绝 | HTTP 200, accepted=false | 不占用 |
|
||||
| 动作被接受 | HTTP 200, accepted=true | 占用 |
|
||||
| 同 ID 不同内容,或并发不同动作 | HTTP 409 | — |
|
||||
| 网络失败重试同一动作 | 复用原 ID+原内容,服务器回放首次响应 | 不重复生效 |
|
||||
| 429/500 | 可原样重试 | — |
|
||||
| 404/405/413/415 | 确定性错误,不得重试 | — |
|
||||
|
||||
物理规则速查:Ω 半径 1800m;源数 10–16;频道 1–20;接收半径 [1000,1500]m(逐源未知);
|
||||
near 阈值 5m;清除半径 20m(与定向朝向无关);定向覆盖 = 朝向两侧各 90°(含边界);
|
||||
示向度误差幅度 ±1°,保留两位小数;虚拟限时 360000s;程序限时 1200s(以 /enter 的
|
||||
remaining_real_duration_s 为准,不得假定恒为 1200);测试窗口 25 分钟。
|
||||
|
||||
## 3. 状态机
|
||||
|
||||
机器狗会话状态(`protocol.state.RobotState` + 客户端会话标志):
|
||||
|
||||
```
|
||||
/enter accepted /exit accepted
|
||||
[未进入] ───────────────▶ [已进入] ───────────────▶ [已退出]
|
||||
│ accepted=false │ accepted=false │ 任何动作 accepted=false
|
||||
└── 不变 └── 不变 └── 不变
|
||||
```
|
||||
|
||||
[已进入] 状态内跟踪:position、测向机 channel、virtual_time_s。转移规则:
|
||||
|
||||
| 事件 | position | channel | virtual_time_s |
|
||||
| --- | --- | --- | --- |
|
||||
| /enter accepted | (0,0) | 1 | 0 |
|
||||
| /measure accepted | ← 本次 position | ← 本次 channel | ← 响应值(=旧值+移动+切换+5) |
|
||||
| /clear accepted | ← 本次 position | **不变** | ← 响应值(=旧值+移动+3或5) |
|
||||
| /exit accepted | 不变 | 不变 | ← 响应值 |
|
||||
| accepted=false / 网络 / 400 | 不变 | 不变 | 不变 |
|
||||
|
||||
虚拟时刻以最近一次 accepted=true 的响应为准;本地预测(timing 模块)只用于
|
||||
规划与一致性核对,偏差记入 `RobotState.mismatches` 并写日志(strict 模式抛错)。
|
||||
|
||||
## 4. 计时示例逐步推演(附件1表2 = 附件2第10节)
|
||||
|
||||
| 步 | 指令 | 移动 | 换频道 | 动作 | 本步 | 累计 |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| 1 | /enter | 0 | 0 | 0 | 0 | **0** |
|
||||
| 2 | /measure (300,400) ch1 | 500m→100s | 0(初始即1) | 5 | 105 | **105** |
|
||||
| 3 | /measure (300,400) ch2 | 0 | 1 | 5 | 6 | **111** |
|
||||
| 4 | /clear (300,0) 目标ch3 | 400m→80s | 0(clear不切频道) | 3(未发现) | 83 | **194** |
|
||||
| 5 | /measure (300,0) ch2 | 0 | 0(第3步后已是2,clear未改) | 5 | 5 | **199** |
|
||||
| 6 | /exit | 0 | 0 | 0 | 0 | **199** |
|
||||
|
||||
验收:`uv run mcm26b sim-check` 通过完整客户端→本地模拟器链路复现
|
||||
`[105, 111, 194, 199]`;`tests/test_protocol_timing.py::TimingModelTests`
|
||||
逐步断言每个分项。
|
||||
|
||||
## 5. 客户端设计要点(P1 交付)
|
||||
|
||||
- **串行**:完全同步,一个动作未决不发下一个;
|
||||
- **幂等重试**:请求体一次性序列化为 bytes,网络失败/429/500 时原样重发
|
||||
(同 request_id 同内容),最多 `max_attempts` 次,指数退避;400/404/405/409/413/415 立即失败;
|
||||
- **accepted=false**:抛 `ActionRejected`,状态不更新;virtual_time_s=0 不入时钟;
|
||||
- **现实预算**:/enter 成功后 `deadline = monotonic() + remaining_real_duration_s`;
|
||||
新动作前检查 `剩余 > reserve_exit_s`(默认 60s [工程]),/exit 豁免;
|
||||
- **日志**:`SessionLog` 逐事件追加 JSONL(attempt/transport_error/accepted),
|
||||
进程崩溃不丢已发记录,回放测试与 evaluation 复算共用同一格式;
|
||||
- **本地预校验**:channel 范围、坐标有限性与 2e6 上限、robot_id/request_id 格式
|
||||
在发送前拒绝(与服务器 400 语义一致,省一次往返)。
|
||||
+12
-12
@@ -182,15 +182,15 @@ $$
|
||||
|
||||
## 八、运行方式和代码入口
|
||||
|
||||
核心算法仅用Python标准库,支持Python3.10及以上;本次在Python3.12.14中验证。在项目根目录运行:
|
||||
核心算法仅用Python标准库,支持Python3.10及以上;工程依赖仅 `requests`(测试另需 `pytest`)。在项目根目录运行:
|
||||
|
||||
```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
|
||||
uv run python -m mcm26b.geometry examples/q1/triangle.json -o output/q1/triangle_recomputed.json
|
||||
uv run mcm26b q1 examples/q1/normal.json
|
||||
uv run python -m mcm26b.geometry examples/q1/normal.json --rounding-margin-deg 0.005
|
||||
uv run pytest
|
||||
uv run python scripts/build_q1_demo.py
|
||||
uv run python scripts/benchmark_q1.py
|
||||
```
|
||||
|
||||
输入JSON的最小格式:
|
||||
@@ -209,22 +209,22 @@ python scripts/benchmark_q1.py
|
||||
|
||||
| 文件 | 职责 |
|
||||
| --- | --- |
|
||||
| `geometry/localization.py` | 测向角域、半平面、区域分类、直径和包围圆 |
|
||||
| `geometry/planning.py` | 问题二首次测向的距离保证候选圆盘;独立于问题一模型 |
|
||||
| `geometry/__main__.py`、`main.py` | JSON命令行输入输出 |
|
||||
| `src/mcm26b/geometry/localization.py` | 测向角域、半平面、区域分类、直径和包围圆 |
|
||||
| `src/mcm26b/geometry/planning.py` | 问题二首次测向的距离保证候选圆盘;独立于问题一模型 |
|
||||
| `src/mcm26b/geometry/__main__.py`、`src/mcm26b/cli.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` | 可视化源模板;不另写一套求解算法 |
|
||||
| `scripts/assets/q1.template.html` | 可视化源模板;不另写一套求解算法 |
|
||||
| `output/q1/question1.html` | 可直接打开的离线交互式模拟,所有数据已内嵌 |
|
||||
|
||||
如需再次采集,先在模拟器明确启动“问题3演练测试”,等待接口就绪,读取案例编码,然后运行:
|
||||
|
||||
```powershell
|
||||
python scripts/capture_practice.py --robot-id 你的参赛队号 --practice-confirmed --case-code 界面中的演练案例编码
|
||||
uv run python scripts/capture_practice.py --robot-id 你的参赛队号 --practice-confirmed --case-code 界面中的演练案例编码
|
||||
```
|
||||
|
||||
接口协议没有返回演练/正式模式的字段,`--practice-confirmed` 是操作者确认已核验界面的显式参数,不能替代界面核查。采集脚本没有启动测试的功能,没有正式模式选项,只会调用 `/enter`、`/measure`、`/exit`。禁止在正式测试会话中运行。
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from geometry.__main__ import main
|
||||
|
||||
"""兼容入口,转发到统一 CLI(mcm26b)。"""
|
||||
from mcm26b.cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
raise SystemExit(main())
|
||||
|
||||
+24
-4
@@ -1,7 +1,27 @@
|
||||
[project]
|
||||
name = "math"
|
||||
version = "0.1.0"
|
||||
description = "B题问题一:带误差方位交会定位与可视化"
|
||||
name = "mcm26b"
|
||||
version = "0.2.0"
|
||||
description = "CUMCM 2026 B题:无线电干扰源的快速自动定位与清除(协议基线、几何内核与策略骨架)"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = []
|
||||
dependencies = [
|
||||
"requests>=2.31",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=8",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/mcm26b"]
|
||||
|
||||
[project.scripts]
|
||||
mcm26b = "mcm26b.cli:main"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
@@ -8,10 +8,11 @@ from statistics import median
|
||||
import sys
|
||||
from time import perf_counter
|
||||
|
||||
from mcm26b.geometry import (Measurement, solve, smallest_enclosing_circle,
|
||||
first_bearing_candidate_disk)
|
||||
from mcm26b.geometry.localization import _enclosing_circle_reference
|
||||
|
||||
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):
|
||||
@@ -4,11 +4,11 @@ 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
|
||||
from mcm26b import Measurement, solve
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
ASSETS = Path(__file__).resolve().parent / "assets"
|
||||
|
||||
|
||||
def measured(stations, target, errors):
|
||||
@@ -56,11 +56,11 @@ def main():
|
||||
(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')
|
||||
template=(ASSETS / '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')
|
||||
(ROOT/'output'/'q1'/'question1_full.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')
|
||||
theme=(ASSETS / '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')),
|
||||
@@ -8,14 +8,12 @@ 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
|
||||
from mcm26b import Measurement, solve
|
||||
|
||||
|
||||
class PracticeClient:
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
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 source=fs.readFileSync(path.join(rootDir,'output/q1/question1_full.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='';}
|
||||
@@ -0,0 +1,21 @@
|
||||
"""mcm26b:CUMCM 2026 B题"无线电干扰源的快速自动定位与清除"求解包。
|
||||
|
||||
模块分层(docs/B题建模分析与实施计划.md §7 的七模块划分):
|
||||
- config 全项目参数中心(题面常量与工程参数,唯一字面量来源)
|
||||
- protocol P0/P1 字段表、计时模型、状态机与串行幂等客户端
|
||||
- geometry P2 角域交会定位、直径、最小包围圆与 P2 证书(已完工)
|
||||
- sim 自建模拟器、故障注入与演练日志回放(测试专用)
|
||||
- belief P3 逐频道候选集合(骨架)
|
||||
- coverage P4 49 点覆盖记录(骨架+网格生成)
|
||||
- planner P3 选点 / P4 全向闭环 / P5 定向扩展(骨架)
|
||||
- evaluation 日志复算与统计
|
||||
"""
|
||||
|
||||
__version__ = "0.2.0"
|
||||
|
||||
from .config import DEFAULT_CONFIG, ProblemConfig
|
||||
from .geometry import Measurement, first_bearing_candidate_disk, solve, \
|
||||
smallest_enclosing_circle
|
||||
|
||||
__all__ = ["DEFAULT_CONFIG", "Measurement", "ProblemConfig", "solve",
|
||||
"first_bearing_candidate_disk", "smallest_enclosing_circle"]
|
||||
@@ -0,0 +1,58 @@
|
||||
"""P3 逐频道信念状态:候选集合 K_c 维护与频道处理状态。
|
||||
|
||||
现状:骨架(P3 起填充)。接口与不变式先行固定,防止后续实现漂移。
|
||||
|
||||
核心不变式(验收标准):
|
||||
- 保守性:真源永远属于维护中的候选集合;任何更新不得因误差、
|
||||
圆弧离散或浮点容差把真点排除(自建真值案例中真源始终保留);
|
||||
- direction 读数:K_c ⊆ Ω ∩ W(s, θ±δ) ∩ B(s, 1500)(首次测向构造见
|
||||
geometry.planning.first_bearing_candidate_disk 的解析证书);
|
||||
- no_signal 读数(P4,全向源):仅可排除 B(s, 1000);用于定向源时
|
||||
是"位置×朝向"联合约束,单独维护空间候选会误删真源(P5 才启用);
|
||||
- /clear 成功:状态转 CLEARED;失败只说明"该频道 20m 内无可清除源",
|
||||
不得直接删除超出 20m 的候选。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
|
||||
from ..config import ProblemConfig
|
||||
|
||||
|
||||
class ChannelStatus(Enum):
|
||||
UNKNOWN = "unknown" # 尚无有效证据
|
||||
DETECTED = "detected" # 收到过 direction/near,待定位
|
||||
LOCATED = "located" # 候选集包围圆半径 <= 20,可清除
|
||||
CLEARED = "cleared" # 清除成功
|
||||
ABSENT = "absent" # 覆盖证明不存在(P4)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChannelBelief:
|
||||
channel: int
|
||||
status: ChannelStatus = ChannelStatus.UNKNOWN
|
||||
measurements: list = field(default_factory=list) # (位置, 结果, svd_deg) 记录
|
||||
|
||||
|
||||
class BeliefState:
|
||||
"""全部 20 个频道的信念集合。策略端唯一的事实来源。"""
|
||||
|
||||
def __init__(self, config: ProblemConfig = ProblemConfig()):
|
||||
self.config = config
|
||||
self.channels = {channel: ChannelBelief(channel)
|
||||
for channel in range(config.field.channel_min,
|
||||
config.field.channel_max + 1)}
|
||||
|
||||
def update_measure(self, channel: int, position, result: str,
|
||||
svd_deg: float | None) -> None:
|
||||
"""按读数收敛候选集合。未实现:需要几何模块的在线圆盘角域交。"""
|
||||
raise NotImplementedError("P3:接入 geometry 的圆盘约束角域交后实现")
|
||||
|
||||
def candidate_set(self, channel: int):
|
||||
"""返回该频道当前候选集合(保守外包形式)。未实现。"""
|
||||
raise NotImplementedError("P3")
|
||||
|
||||
def mark_cleared(self, channel: int) -> None:
|
||||
self.channels[channel].status = ChannelStatus.CLEARED
|
||||
@@ -0,0 +1,78 @@
|
||||
"""统一命令行入口:mcm26b <子命令>。
|
||||
|
||||
- q1 问题一角域交会求解(P2 已完工)
|
||||
- sim-check 用自建模拟器复现附件计时示例(P0 自检)
|
||||
- run 全向闭环(P4 骨架,未实现)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
from .config import ProblemConfig
|
||||
from .geometry.__main__ import main as q1_main
|
||||
from .protocol.client import SimulatorClient
|
||||
from .protocol.rules import EXIT_USER
|
||||
from .sim import LocalSimTransport, LocalSimulator, Source
|
||||
|
||||
|
||||
def _sim_check(args) -> int:
|
||||
"""附件1表2/附件2第10节计时示例:期望 105/111/194/199 秒。"""
|
||||
simulator = LocalSimulator([], robot_id=args.robot_id)
|
||||
client = SimulatorClient(args.robot_id, transport=LocalSimTransport(simulator))
|
||||
client.enter()
|
||||
steps = []
|
||||
steps.append(client.measure((300, 400), 1).virtual_time_s) # 移动500m+检测5s
|
||||
steps.append(client.measure((300, 400), 2).virtual_time_s) # 换频道1s+检测5s
|
||||
steps.append(client.clear((300, 0), 3).virtual_time_s) # 移动400m+未发现3s
|
||||
steps.append(client.measure((300, 0), 2).virtual_time_s) # 原地检测频道2
|
||||
info = client.exit()
|
||||
expected = [105, 111, 194, 199]
|
||||
ok = [round(value, 6) for value in steps] == expected \
|
||||
and round(info.virtual_time_s, 6) == 199 and info.reason == EXIT_USER
|
||||
print(json.dumps({"expected": expected, "got": steps,
|
||||
"final_virtual_time_s": info.virtual_time_s,
|
||||
"exit_reason": info.reason, "pass": ok},
|
||||
ensure_ascii=False))
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
def _run(args) -> int:
|
||||
raise NotImplementedError(
|
||||
"P4 全向闭环尚未实现;当前可用子命令:q1(问题一求解)、sim-check(协议自检)")
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
parser = argparse.ArgumentParser(prog="mcm26b", description=__doc__)
|
||||
parser.add_argument("--config", default="",
|
||||
help="预留:覆盖 ProblemConfig 的 JSON 文件(参数化入口)")
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
q1 = subparsers.add_parser("q1", help="问题一角域交会求解")
|
||||
q1.add_argument("input", type=str)
|
||||
q1.add_argument("-o", "--output", type=str, default=None)
|
||||
q1.add_argument("--rounding-margin-deg", type=float, default=0)
|
||||
q1.set_defaults(func=lambda a: q1_main([a.input] + (["-o", a.output] if a.output else [])
|
||||
+ (["--rounding-margin-deg", str(a.rounding_margin_deg)]
|
||||
if a.rounding_margin_deg else [])) or 0)
|
||||
|
||||
check = subparsers.add_parser("sim-check", help="自建模拟器复现附件计时示例")
|
||||
check.add_argument("--robot-id", default="2026001")
|
||||
check.set_defaults(func=_sim_check)
|
||||
|
||||
run = subparsers.add_parser("run", help="P4 全向闭环(骨架)")
|
||||
run.add_argument("--robot-id", required=True)
|
||||
run.set_defaults(func=_run)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
return args.func(args)
|
||||
except NotImplementedError as exc:
|
||||
print(f"未实现: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,88 @@
|
||||
"""全项目参数中心:题面常量、协议限制、会话策略与搜索规划参数。
|
||||
|
||||
任何魔法数字只允许在这里出现一次。各模块通过 :class:`ProblemConfig` 读取;
|
||||
命令行需要覆盖时用 ``dataclasses.replace`` 派生新配置,不散落字面量。
|
||||
|
||||
来源标注约定:
|
||||
- ``[题面]`` 出自 issue/B题.pdf、附件1《模拟器使用说明》、附件2《通信接口说明》原文;
|
||||
- ``[工程]`` 本队工程参数,可依演练实测调整,不代表官方规定。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
Vec2 = tuple[float, float]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FieldConfig:
|
||||
"""题目物理世界与协议硬限制。[题面]"""
|
||||
|
||||
omega_radius_m: float = 1800.0 # 目标区域半径,圆心为原点
|
||||
channel_min: int = 1 # 有效频道范围
|
||||
channel_max: int = 20
|
||||
source_count_min: int = 10 # 一局源总数范围(接口不直接告知)
|
||||
source_count_max: int = 16
|
||||
r_eff_min_m: float = 1000.0 # 有效接收半径下界:no_signal 只能排除该圆盘
|
||||
r_eff_max_m: float = 1500.0 # 有效接收半径上界:direction 隐含距离上界
|
||||
near_radius_m: float = 5.0 # "距离过近"阈值,不返回示向度
|
||||
clear_radius_m: float = 20.0 # 清除半径;与定向朝向无关
|
||||
bearing_error_half_deg: float = 1.0 # 示向度误差幅度(题设模型)
|
||||
bearing_decimals: int = 2 # 返回示向度小数位
|
||||
coord_abs_limit_m: float = 2_000_000.0 # 提交坐标的绝对值上限(超出返回 HTTP 400)
|
||||
directional_half_angle_deg: float = 90.0 # 定向源朝向两侧各90度覆盖(含边界)
|
||||
max_virtual_duration_s: float = 360_000.0 # 虚拟世界限时(100小时)
|
||||
max_real_duration_s: float = 1200.0 # /enter 成功后程序运行上限(20分钟)
|
||||
test_window_s: float = 1500.0 # 确认开始后的测试窗口(25分钟)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CostConfig:
|
||||
"""虚拟世界动作耗时模型。[题面] 附件1表1、附件2第4节。"""
|
||||
|
||||
speed_mps: float = 5.0 # 移动速度;耗时=直线距离/speed
|
||||
measure_s: float = 5.0 # 每次检测动作耗时(无论结果)
|
||||
switch_s: float = 1.0 # 任意两频道间切换耗时,仅由合法 /measure 触发
|
||||
clear_success_s: float = 5.0 # 清除成功耗时 = 光学定位3s + 激光清除2s
|
||||
clear_fail_s: float = 3.0 # 未发现可清除目标 = 仅光学定位3s
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SessionConfig:
|
||||
"""会话与网络策略。arena/robot/request 规则为 [题面],其余为 [工程]。"""
|
||||
|
||||
arena_id: str = "default" # [题面] 固定值
|
||||
base_url: str = "http://127.0.0.1:2026" # [题面] 模拟器默认接口地址
|
||||
robot_id_max_bytes: int = 64 # [题面] UTF-8 长度 1..64
|
||||
request_id_max_bytes: int = 128 # [题面] UTF-8 长度 1..128
|
||||
request_body_max_bytes: int = 65_536 # [题面] 超出返回 HTTP 413
|
||||
connect_timeout_s: float = 5.0 # [工程] 连接超时
|
||||
read_timeout_s: float = 15.0 # [工程] 读超时;覆盖"服务器已处理但响应丢失"
|
||||
max_attempts: int = 3 # [工程] 单动作最多尝试次数(含首次)
|
||||
retry_backoff_s: float = 0.25 # [工程] 重试退避基数,按 2^n 放大
|
||||
reserve_exit_s: float = 60.0 # [工程] 预留退出与故障恢复的现实秒数
|
||||
real_rtt_p95_s: float = 1.0 # [工程] 现实单请求高分位估计,预算核算用
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SearchConfig:
|
||||
"""搜索与覆盖规划参数。[工程] 推导依据见 docs/B题建模分析与实施计划.md。"""
|
||||
|
||||
grid_step_m: float = 700.0 # P4 覆盖网格步长;单元对角 700*sqrt(2)≈989.95<=1000
|
||||
grid_half_cells: int = 3 # i,j ∈ [-3,3],49 点覆盖 [-2100,2100]^2 ⊇ Ω
|
||||
fallback_clear_grid_m: float = 20.0 # P5 后备清除网格;10*sqrt(2)<=20
|
||||
bearing_rounding_margin_deg: float = 0.0 # 默认不加舍入裕量;敏感性分析时显式设 0.005
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProblemConfig:
|
||||
"""聚合配置。各模块函数的第一个默认参数,便于单测替换。"""
|
||||
|
||||
field: FieldConfig = FieldConfig()
|
||||
cost: CostConfig = CostConfig()
|
||||
session: SessionConfig = SessionConfig()
|
||||
search: SearchConfig = SearchConfig()
|
||||
|
||||
|
||||
DEFAULT_CONFIG = ProblemConfig()
|
||||
@@ -0,0 +1,56 @@
|
||||
"""P4 全域覆盖:49 点网格与逐频道覆盖记录。
|
||||
|
||||
覆盖基线(推导见 docs/B题建模分析与实施计划.md §4):
|
||||
S = {(700i, 700j) : i, j ∈ [-3,3]},|S| = 49,覆盖 [-2100,2100]² ⊇ Ω,
|
||||
且允许访问目标圆域外(边界朝外发射的定向源不漏)。
|
||||
单元内任意点到 4 顶点距离 <= 700·√2 ≈ 989.95 <= 1000 <= 最小接收半径。
|
||||
|
||||
验收标准:
|
||||
- "路过某点"不算"测过某频道":必须按 (频道, 点) 二维记录;
|
||||
- 已清除/已定位频道可跳过剩余点,但跳过原因必须留痕;
|
||||
- 自适应跳点(P6)必须保留逐频道覆盖证据,否则不得启用。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from ..config import ProblemConfig, Vec2
|
||||
|
||||
|
||||
def coverage_points(config: ProblemConfig = ProblemConfig()) -> list[Vec2]:
|
||||
"""49 点覆盖网格生成(纯几何,可独立测试)。"""
|
||||
step = config.search.grid_step_m
|
||||
half = config.search.grid_half_cells
|
||||
return [(step * i, step * j)
|
||||
for j in range(half, -half - 1, -1)
|
||||
for i in range(-half, half + 1)]
|
||||
|
||||
|
||||
@dataclass
|
||||
class CoverageLedger:
|
||||
"""逐频道覆盖账本:(频道, 网格点) -> 是否已测。"""
|
||||
|
||||
config: ProblemConfig = field(default_factory=ProblemConfig)
|
||||
points: list[Vec2] = field(default_factory=lambda: coverage_points())
|
||||
measured: set = field(default_factory=set) # {(channel, point_index)}
|
||||
skipped: dict = field(default_factory=dict) # channel -> 原因
|
||||
|
||||
def mark_measured(self, channel: int, point_index: int) -> None:
|
||||
self.measured.add((channel, point_index))
|
||||
|
||||
def measured_at(self, channel: int, point_index: int) -> bool:
|
||||
return (channel, point_index) in self.measured
|
||||
|
||||
def skip_channel(self, channel: int, reason: str) -> None:
|
||||
"""记录整频道跳过原因(如 already_cleared / located_pending_clear)。"""
|
||||
self.skipped[channel] = reason
|
||||
|
||||
def pending_points(self, channel: int) -> list[int]:
|
||||
"""该频道尚未测量的网格点下标。扫描调度策略未实现(P4)。"""
|
||||
return [index for index in range(len(self.points))
|
||||
if not self.measured_at(channel, index)]
|
||||
|
||||
def coverage_complete(self, channel: int) -> bool:
|
||||
"""该频道覆盖是否完成(含显式跳过)。"""
|
||||
return channel in self.skipped or not self.pending_points(channel)
|
||||
@@ -0,0 +1,81 @@
|
||||
"""评估与统计:从自身动作日志离线复算虚拟时刻与统计量。
|
||||
|
||||
"演练统计"验收要求:总时间与分项耗时可独立复算 [题面/计划 §6]。
|
||||
recompute_virtual_time 是回放测试与本模块共用的唯一复算实现,
|
||||
客户端日志格式为 SessionLog 中 accepted 事件的 {path, request, response}。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ..config import CostConfig
|
||||
from ..protocol.rules import CLEAR_SUCCESS
|
||||
from ..protocol.timing import clear_cost, measure_cost
|
||||
|
||||
|
||||
def recompute_virtual_time(events: list[dict],
|
||||
cost: CostConfig = CostConfig()) -> dict:
|
||||
"""从动作日志复算逐步虚拟时刻。
|
||||
|
||||
events 每项形如 {path, request, response}(accepted=true)。
|
||||
返回逐步表与最终时刻;任何一步与服务器响应偏差超过容差记入 mismatches。
|
||||
"""
|
||||
position = (0.0, 0.0)
|
||||
channel = 1
|
||||
virtual = 0.0
|
||||
steps, mismatches = [], []
|
||||
for index, event in enumerate(events):
|
||||
path, request = event["path"], event["request"]
|
||||
response = event["response"]
|
||||
if response.get("accepted") is not True:
|
||||
mismatches.append({"index": index, "issue": "响应未被接受"})
|
||||
continue
|
||||
if path == "/enter":
|
||||
position, channel, virtual = (0.0, 0.0), 1, response["virtual_time_s"]
|
||||
elif path == "/measure":
|
||||
pos = (request["position"]["x"], request["position"]["y"])
|
||||
predicted = measure_cost(position, pos, channel, request["channel"], cost)
|
||||
position, channel = pos, request["channel"]
|
||||
virtual += predicted.total_s
|
||||
elif path == "/clear":
|
||||
pos = (request["position"]["x"], request["position"]["y"])
|
||||
success = response.get("clear_result") == CLEAR_SUCCESS
|
||||
predicted = clear_cost(position, pos, success, cost)
|
||||
position = pos
|
||||
virtual += predicted.total_s
|
||||
elif path == "/exit":
|
||||
pass # /exit 不推进虚拟时钟 [题面]
|
||||
else:
|
||||
mismatches.append({"index": index, "issue": f"未知路径 {path}"})
|
||||
continue
|
||||
delta = response["virtual_time_s"] - virtual
|
||||
if abs(delta) > 1e-6:
|
||||
mismatches.append({"index": index, "path": path,
|
||||
"recomputed_s": virtual,
|
||||
"recorded_s": response["virtual_time_s"],
|
||||
"gap_s": delta})
|
||||
steps.append({"index": index, "path": path,
|
||||
"predicted_s": round(virtual, 9),
|
||||
"recorded_s": response["virtual_time_s"]})
|
||||
return {"steps": steps, "final_virtual_time_s": virtual,
|
||||
"mismatches": mismatches}
|
||||
|
||||
|
||||
def summarize(events: list[dict]) -> dict:
|
||||
"""动作计数摘要:检测/清除/移动距离等基础统计。"""
|
||||
position = (0.0, 0.0)
|
||||
measures, clears, cleared, distance_m = 0, 0, 0, 0.0
|
||||
for event in events:
|
||||
request = event["request"]
|
||||
if event["path"] in ("/measure", "/clear"):
|
||||
pos = (request["position"]["x"], request["position"]["y"])
|
||||
distance_m += ((pos[0] - position[0]) ** 2
|
||||
+ (pos[1] - position[1]) ** 2) ** 0.5
|
||||
position = pos
|
||||
if event["path"] == "/measure":
|
||||
measures += 1
|
||||
elif event["path"] == "/clear":
|
||||
clears += 1
|
||||
if event["response"].get("clear_result") == CLEAR_SUCCESS:
|
||||
cleared += 1
|
||||
return {"measures": measures, "clears": clears, "cleared": cleared,
|
||||
"distance_m": distance_m}
|
||||
@@ -1,4 +1,7 @@
|
||||
"""B题问题一:有界误差方位交会定位。仅使用 Python 标准库。"""
|
||||
"""P2 几何内核:B题问题一有界误差方位交会定位。仅用标准库,已完工。
|
||||
|
||||
问题二首次测向安全候选盘证书一并放在本包(planning 模块)。
|
||||
"""
|
||||
|
||||
from .localization import Measurement, solve, smallest_enclosing_circle
|
||||
from .planning import first_bearing_candidate_disk
|
||||
@@ -1,4 +1,4 @@
|
||||
"""python -m geometry observations.json [-o result.json]"""
|
||||
"""python -m mcm26b.geometry observations.json [-o result.json]"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
@@ -8,13 +8,13 @@ from pathlib import Path
|
||||
from .localization import Measurement, solve
|
||||
|
||||
|
||||
def main():
|
||||
def main(argv=None):
|
||||
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()
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
source = json.loads(args.input.read_text(encoding="utf-8-sig"))
|
||||
measurements = [Measurement(**row) for row in source["measurements"]]
|
||||
@@ -0,0 +1,80 @@
|
||||
"""P3/P4/P5 策略层:主动选点、全向闭环与定向扩展。
|
||||
|
||||
现状:骨架。各入口先固定签名与验收标准,逐阶段填充实现。
|
||||
|
||||
P3 第二检测点(验收:保证收到信号;Q 空时退化;near 分支):
|
||||
- K1 = Ω ∩ W(s1, θ1±δ) ∩ B(s1, 1500);安全候选盘证书见
|
||||
geometry.planning.first_bearing_candidate_disk(δ<=1.005° 时非空);
|
||||
- 在证书圆盘内围绕测向轴两侧采样候选点,以"最坏后验包围圆半径"
|
||||
为主指标、移动+检测代价为次指标;
|
||||
- 返回 near 时:候选源在 5m 内,直接对当前频道尝试 /clear。
|
||||
|
||||
P4 全向闭环(验收:10/16 源正确退出;边界朝外不漏;停止条件正确):
|
||||
发现(coverage.CoverageLedger)→ 定位(belief 收敛)→ 清除 → 停止判定
|
||||
(清除数达 16,或每频道均有清除成功/不存在证明)→ /exit。
|
||||
发现 10 源、连续无信号、候选采样为空都不是停止条件。
|
||||
|
||||
P5 定向扩展(验收:朝外边界源能发现;失去信号能清除;不误删真源):
|
||||
no_signal 是"位置×朝向"联合约束,基线不据此删空间候选;
|
||||
失去信号后对有界候选做 <=20m 有限圆盘覆盖逐点 /clear,
|
||||
并按预算上界(格数×请求耗时)决定是否执行。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from ..config import ProblemConfig
|
||||
from ..protocol.client import SimulatorClient
|
||||
|
||||
|
||||
@dataclass
|
||||
class SecondPointPlan:
|
||||
"""第二检测点的候选与依据(P3 输出形态预览)。"""
|
||||
|
||||
position: tuple[float, float]
|
||||
worst_posterior_radius_m: float
|
||||
move_cost_s: float
|
||||
reason: str
|
||||
|
||||
|
||||
def select_second_point(client: SimulatorClient,
|
||||
first_position, first_bearing_deg: float,
|
||||
config: ProblemConfig = ProblemConfig()):
|
||||
"""在安全候选盘内选择第二检测点。未实现:P3。"""
|
||||
raise NotImplementedError("P3:先实现 belief 候选集,再实现采样评估")
|
||||
|
||||
|
||||
class OmnidirectionalLoop:
|
||||
"""问题3全向"发现—定位—清除—停止"闭环。未实现:P4。"""
|
||||
|
||||
def __init__(self, client: SimulatorClient,
|
||||
config: ProblemConfig = ProblemConfig()):
|
||||
self.client = client
|
||||
self.config = config
|
||||
|
||||
def run(self) -> dict:
|
||||
raise NotImplementedError("P4:依赖 coverage + belief + planner 就位")
|
||||
|
||||
|
||||
class DirectionalExtension:
|
||||
"""问题4定向源扩展:朝向不确定下的覆盖与后备清除。未实现:P5。"""
|
||||
|
||||
def __init__(self, client: SimulatorClient,
|
||||
config: ProblemConfig = ProblemConfig()):
|
||||
self.client = client
|
||||
self.config = config
|
||||
|
||||
def run(self) -> dict:
|
||||
raise NotImplementedError("P5:依赖 P4 闭环稳定后扩展")
|
||||
|
||||
|
||||
def fallback_clear_cells(center, width_m: float, height_m: float,
|
||||
config: ProblemConfig = ProblemConfig()):
|
||||
"""失去信号后的后备清除格心列表(<=20m 圆盘覆盖)。
|
||||
|
||||
半开区间统一索引,源在格线上时至少归入一格;相交判定数值
|
||||
不确定时保留格子(宁可多测)。格数即 /clear 请求预算上界。
|
||||
未实现:P5(先由演练实测请求预算决定启用规模)。
|
||||
"""
|
||||
raise NotImplementedError("P5")
|
||||
@@ -0,0 +1,15 @@
|
||||
"""P0/P1 协议层:字段表、规则表、计时模型、状态机与客户端。"""
|
||||
|
||||
from .client import (ActionRejected, ClearOutcome, DeadlineExceeded,
|
||||
ExitInfo, MeasureOutcome, ProtocolError,
|
||||
ProtocolViolation, SimulatorClient)
|
||||
from .log import SessionLog
|
||||
from .state import RobotState, StateError
|
||||
from .timing import CostBreakdown, clear_cost, measure_cost, move_time
|
||||
from .transport import RequestsTransport, Transport, TransportError, TransportResponse
|
||||
|
||||
__all__ = ["ActionRejected", "ClearOutcome", "CostBreakdown", "DeadlineExceeded",
|
||||
"ExitInfo", "MeasureOutcome", "ProtocolError", "ProtocolViolation",
|
||||
"RequestsTransport", "RobotState", "SessionLog", "SimulatorClient",
|
||||
"StateError", "Transport", "TransportError", "TransportResponse",
|
||||
"clear_cost", "measure_cost", "move_time"]
|
||||
@@ -0,0 +1,288 @@
|
||||
"""P1 协议基线:串行、幂等、带现实预算的模拟器客户端。
|
||||
|
||||
协议硬规则 [题面] 与实现对应:
|
||||
- 每个新动作使用新的 request_id;仅在网络失败后重试"完全相同的动作"时,
|
||||
复用原 request_id 与原请求体 —— 本类通过一次性序列化出 bytes 并原样重发保证;
|
||||
- 必须同时检查 HTTP 状态与 accepted;accepted=false 不更新状态,
|
||||
其 virtual_time_s=0 不当作当前虚拟时刻;
|
||||
- 逐次等待响应、不得并发;本类完全同步,一个动作未决不发起下一个;
|
||||
- 现实截止用 /enter 响应的 remaining_real_duration_s 建立本地单调时钟,
|
||||
预留退出余量;/exit 不做预算拦截。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from time import monotonic, sleep
|
||||
|
||||
from ..config import ProblemConfig, Vec2
|
||||
from .log import SessionLog
|
||||
from .rules import (CLEAR_RESULTS, DIRECTION, EXIT_USER, MEASURE_RESULTS,
|
||||
POSITION_FIELDS, STATUS_OK, STATUS_RETRYABLE)
|
||||
from .state import RobotState
|
||||
from .timing import CostBreakdown
|
||||
from .transport import RequestsTransport, Transport, TransportError
|
||||
|
||||
|
||||
class ProtocolError(RuntimeError):
|
||||
"""协议层失败,含 HTTP 状态与响应体;默认不重试(确定性错误)。"""
|
||||
|
||||
def __init__(self, message: str, status: int | None = None, body=None):
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
self.body = body
|
||||
|
||||
|
||||
class ActionRejected(ProtocolError):
|
||||
"""HTTP 200 但 accepted=false:动作未生效,状态不得更新。"""
|
||||
|
||||
def __init__(self, message: str, body=None):
|
||||
super().__init__(message, status=STATUS_OK, body=body)
|
||||
|
||||
|
||||
class ProtocolViolation(ProtocolError):
|
||||
"""响应与协议约定不符(字段缺失、值非法、虚拟时刻回退等)。"""
|
||||
|
||||
|
||||
class DeadlineExceeded(RuntimeError):
|
||||
"""现实预算不足:预留退出余量后剩余时间不够执行新动作。"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MeasureOutcome:
|
||||
result: str
|
||||
svd_deg: float | None
|
||||
virtual_time_s: float
|
||||
predicted: CostBreakdown
|
||||
raw: dict
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClearOutcome:
|
||||
result: str
|
||||
virtual_time_s: float
|
||||
predicted: CostBreakdown
|
||||
raw: dict
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExitInfo:
|
||||
reason: str
|
||||
virtual_time_s: float
|
||||
raw: dict
|
||||
|
||||
|
||||
def _validate_identifier(name: str, value: str, max_bytes: int) -> None:
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ValueError(f"{name} 必须是非空字符串")
|
||||
encoded = value.encode("utf-8")
|
||||
if not 1 <= len(encoded) <= max_bytes:
|
||||
raise ValueError(f"{name} 的 UTF-8 长度须在 1..{max_bytes} 字节")
|
||||
if any(code < 0x20 or code == 0x7F for code in encoded):
|
||||
raise ValueError(f"{name} 不能包含控制字符")
|
||||
|
||||
|
||||
def _validate_position(position: dict, limit: float) -> None:
|
||||
if set(position) != set(POSITION_FIELDS):
|
||||
raise ValueError("position 字段必须是且只能是 x、y")
|
||||
for key in ("x", "y"):
|
||||
value = position[key]
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise ValueError(f"position.{key} 必须是数值")
|
||||
if value != value or value in (float("inf"), float("-inf")):
|
||||
raise ValueError(f"position.{key} 必须是有限数值")
|
||||
if abs(value) > limit:
|
||||
raise ValueError(f"position.{key} 绝对值不得超过 {limit} 米")
|
||||
|
||||
|
||||
def _validate_channel(channel) -> int:
|
||||
# 写成 JSON number 1.0 且恰为整数也接受 [题面];1.5 拒绝。
|
||||
if isinstance(channel, bool) or not isinstance(channel, (int, float)):
|
||||
raise ValueError("channel 必须是数值")
|
||||
if isinstance(channel, float) and not channel.is_integer():
|
||||
raise ValueError("channel 必须是整数")
|
||||
channel = int(channel)
|
||||
if not 1 <= channel <= 20:
|
||||
raise ValueError("channel 必须在 1..20 范围内")
|
||||
return channel
|
||||
|
||||
|
||||
class SimulatorClient:
|
||||
"""四条指令的同步客户端。状态只在 accepted=true 后推进。"""
|
||||
|
||||
def __init__(self, robot_id: str, *,
|
||||
config: ProblemConfig = ProblemConfig(),
|
||||
transport: Transport | None = None,
|
||||
log: SessionLog | None = None,
|
||||
clock=monotonic):
|
||||
_validate_identifier("robot_id", robot_id, config.session.robot_id_max_bytes)
|
||||
self.robot_id = robot_id
|
||||
self.config = config
|
||||
self.session = config.session
|
||||
self.transport = transport if transport is not None else RequestsTransport()
|
||||
self.log = log if log is not None else SessionLog()
|
||||
self.clock = clock
|
||||
self.state = RobotState()
|
||||
self._prefix = uuid.uuid4().hex[:12]
|
||||
self._seq = 0
|
||||
self._deadline: float | None = None # 本地单调时钟截止
|
||||
self._server_deadline_ms: float | None = None # 服务器时间戳交叉核对
|
||||
|
||||
# ------------------------------------------------------------ 公共动作 API
|
||||
def enter(self) -> dict:
|
||||
response = self._call("/enter", self._payload("/enter"))
|
||||
self.state.apply_enter(response)
|
||||
remaining = response["remaining_real_duration_s"]
|
||||
if not isinstance(remaining, (int, float)) or remaining <= 0:
|
||||
raise ProtocolViolation("remaining_real_duration_s 缺失或非法", body=response)
|
||||
self._deadline = self.clock() + remaining
|
||||
self._server_deadline_ms = (response["real_timestamp_ms"]
|
||||
+ remaining * 1000.0)
|
||||
return response
|
||||
|
||||
def measure(self, position: Vec2, channel: int) -> MeasureOutcome:
|
||||
payload = self._payload("/measure", position=position, channel=channel)
|
||||
response = self._call("/measure", payload)
|
||||
predicted = self.state.apply_measure(payload, response,
|
||||
cost=self.config.cost)
|
||||
result = response.get("measure_result")
|
||||
if result not in MEASURE_RESULTS:
|
||||
raise ProtocolViolation(f"measure_result 非法: {result!r}", body=response)
|
||||
svd = response.get("svd_deg")
|
||||
if result == DIRECTION:
|
||||
if not isinstance(svd, (int, float)) or isinstance(svd, bool):
|
||||
raise ProtocolViolation("direction 响应缺少数值 svd_deg", body=response)
|
||||
return MeasureOutcome(result, float(svd) if svd is not None else None,
|
||||
response["virtual_time_s"], predicted, response)
|
||||
|
||||
def clear(self, position: Vec2, channel: int) -> ClearOutcome:
|
||||
payload = self._payload("/clear", position=position, channel=channel)
|
||||
response = self._call("/clear", payload)
|
||||
predicted = self.state.apply_clear(payload, response,
|
||||
cost=self.config.cost)
|
||||
result = response.get("clear_result")
|
||||
if result not in CLEAR_RESULTS:
|
||||
raise ProtocolViolation(f"clear_result 非法: {result!r}", body=response)
|
||||
return ClearOutcome(result, response["virtual_time_s"], predicted, response)
|
||||
|
||||
def exit(self) -> ExitInfo:
|
||||
response = self._call("/exit", self._payload("/exit"))
|
||||
self.state.apply_exit(response)
|
||||
reason = response.get("exit_reason")
|
||||
if reason != EXIT_USER:
|
||||
raise ProtocolViolation(f"exit_reason 非法: {reason!r}", body=response)
|
||||
return ExitInfo(reason, response["virtual_time_s"], response)
|
||||
|
||||
# ---------------------------------------------------------------- 预算控制
|
||||
@property
|
||||
def real_remaining_s(self) -> float | None:
|
||||
"""按本地单调时钟估计的剩余现实秒数(未 enter 时为 None)。"""
|
||||
return None if self._deadline is None else self._deadline - self.clock()
|
||||
|
||||
def _check_budget(self, path: str) -> None:
|
||||
if path == "/exit" or self._deadline is None:
|
||||
return
|
||||
margin = self.session.reserve_exit_s
|
||||
if self.real_remaining_s <= margin:
|
||||
raise DeadlineExceeded(
|
||||
f"剩余现实时间 {self.real_remaining_s:.1f}s 不足以预留 {margin:.1f}s 退出余量")
|
||||
|
||||
# ---------------------------------------------------------------- 内部机制
|
||||
def _payload(self, path: str, *, position: Vec2 | None = None,
|
||||
channel: int | None = None) -> dict:
|
||||
payload = {"arena_id": self.session.arena_id,
|
||||
"robot_id": self.robot_id,
|
||||
"request_id": self._next_request_id()}
|
||||
if position is not None:
|
||||
x, y = float(position[0]), float(position[1])
|
||||
_validate_position({"x": x, "y": y}, self.config.field.coord_abs_limit_m)
|
||||
payload["position"] = {"x": x, "y": y}
|
||||
if channel is not None:
|
||||
payload["channel"] = _validate_channel(channel)
|
||||
return payload
|
||||
|
||||
def _next_request_id(self) -> str:
|
||||
self._seq += 1
|
||||
request_id = f"{self._prefix}-{self._seq:04d}"
|
||||
_validate_identifier("request_id", request_id,
|
||||
self.session.request_id_max_bytes)
|
||||
return request_id
|
||||
|
||||
def _call(self, path: str, payload: dict) -> dict:
|
||||
"""串行执行一个动作;网络失败原样重发,HTTP 确定性错误立即抛出。"""
|
||||
self._check_budget(path)
|
||||
body = json.dumps(payload, ensure_ascii=False, allow_nan=False).encode("utf-8")
|
||||
if len(body) > self.session.request_body_max_bytes:
|
||||
raise ValueError("请求体超过 65536 字节")
|
||||
headers = {"Content-Type": "application/json"}
|
||||
url = self.session.base_url + path
|
||||
attempts, last_error = 0, None
|
||||
while attempts < self.session.max_attempts:
|
||||
attempts += 1
|
||||
started = self.clock()
|
||||
self.log.append({"event": "attempt", "path": path,
|
||||
"request_id": payload["request_id"],
|
||||
"attempt": attempts, "request": payload})
|
||||
try:
|
||||
result = self.transport.post(
|
||||
url, body, headers,
|
||||
timeout=(self.session.connect_timeout_s,
|
||||
self.session.read_timeout_s))
|
||||
except TransportError as exc:
|
||||
last_error = exc
|
||||
self.log.append({"event": "transport_error", "path": path,
|
||||
"request_id": payload["request_id"],
|
||||
"attempt": attempts, "error": str(exc),
|
||||
"elapsed_s": self.clock() - started})
|
||||
if attempts < self.session.max_attempts:
|
||||
sleep(self.session.retry_backoff_s * (2 ** (attempts - 1)))
|
||||
continue
|
||||
if result.status in STATUS_RETRYABLE:
|
||||
self.log.append({"event": "retryable_status", "path": path,
|
||||
"request_id": payload["request_id"],
|
||||
"attempt": attempts, "status": result.status})
|
||||
if attempts < self.session.max_attempts:
|
||||
sleep(self.session.retry_backoff_s * (2 ** (attempts - 1)))
|
||||
continue
|
||||
if result.status != STATUS_OK:
|
||||
raise ProtocolError(
|
||||
f"{path} 返回 HTTP {result.status}", status=result.status,
|
||||
body=self._safe_json(result.body))
|
||||
response = self._safe_json(result.body)
|
||||
if response is None or response.get("accepted") is not True:
|
||||
raise ActionRejected(
|
||||
f"{path} 未被接受: {response}", body=response)
|
||||
self._verify_clock(response)
|
||||
self.log.append({"event": "accepted", "path": path,
|
||||
"request_id": payload["request_id"],
|
||||
"request": payload, "response": response,
|
||||
"attempts": attempts,
|
||||
"elapsed_s": self.clock() - started})
|
||||
return response
|
||||
raise TransportError(
|
||||
f"{path} 重试 {attempts} 次仍失败: {last_error}") from last_error
|
||||
|
||||
def _verify_clock(self, response: dict) -> None:
|
||||
"""accepted=true 的虚拟时刻必须单调不减;现实时间戳用于漂移核对。"""
|
||||
virtual = response.get("virtual_time_s")
|
||||
if not isinstance(virtual, (int, float)) or isinstance(virtual, bool):
|
||||
raise ProtocolViolation("accepted 响应缺少 virtual_time_s", body=response)
|
||||
if virtual < self.state.virtual_time_s - 1e-9:
|
||||
raise ProtocolViolation(
|
||||
f"虚拟时刻回退: {self.state.virtual_time_s} -> {virtual}",
|
||||
body=response)
|
||||
if self._server_deadline_ms is not None:
|
||||
drift = (self._server_deadline_ms - response["real_timestamp_ms"]) / 1000.0
|
||||
if drift < self.session.reserve_exit_s / 2:
|
||||
# 服务器视角余量告急(本地时钟漂移或请求排队);仅记录,交由上层决策。
|
||||
self.log.append({"event": "server_time_warning",
|
||||
"server_remaining_s": drift})
|
||||
|
||||
@staticmethod
|
||||
def _safe_json(body: bytes):
|
||||
try:
|
||||
return json.loads(body.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
return None
|
||||
@@ -0,0 +1,34 @@
|
||||
"""会话日志:机器狗程序必须自行记录指令序列与响应 [题面] 附件2 12节。
|
||||
|
||||
事件逐条追加写入 JSONL,进程崩溃时已完成的动作不丢;
|
||||
内存中的 events 同时供回放测试与 evaluation 复算使用。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from time import time
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionLog:
|
||||
path: Path | None = None
|
||||
events: list = field(default_factory=list)
|
||||
|
||||
def __post_init__(self):
|
||||
if self.path is not None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def append(self, event: dict) -> None:
|
||||
event = {**event, "logged_at_unix_s": time()}
|
||||
self.events.append(event)
|
||||
if self.path is not None:
|
||||
with self.path.open("a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(event, ensure_ascii=False, allow_nan=False) + "\n")
|
||||
|
||||
def action_events(self) -> list[dict]:
|
||||
"""仅最终被接受的动作事件(每个 request_id 一条,含重试次数)。"""
|
||||
return [event for event in self.events if event.get("response") is not None
|
||||
and event["response"].get("accepted") is True]
|
||||
@@ -0,0 +1,94 @@
|
||||
"""P0 规则固定:四条指令的字段表、结果码与错误语义,全部落成可执行数据。
|
||||
|
||||
依据 issue/附件/附件2.docx《模拟器通信接口说明及编程指南》第 5-9 节。
|
||||
客户端用它构造与预校验请求,自建模拟器用它校验并拒绝未知字段;
|
||||
表格本身即交付物"字段表/规则表",人类可读版本见 docs/协议规则与状态机.md。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
# ---- 结果码 [题面] ----------------------------------------------------------
|
||||
|
||||
NO_SIGNAL = "no_signal" # 收不到信号:无未清除源 / 超接收半径 / 定向覆盖角外
|
||||
NEAR = "near" # 距离 <=5m 且在覆盖角内;不返回 svd_deg
|
||||
DIRECTION = "direction" # 有信号;附 svd_deg(含 ±1° 误差,保留两位小数)
|
||||
MEASURE_RESULTS = (NO_SIGNAL, NEAR, DIRECTION)
|
||||
|
||||
CLEAR_SUCCESS = "success" # 20m 内发现并清除;耗时 5s
|
||||
CLEAR_NO_TARGET = "no_target_in_range" # 未发现(无源/已清除/超20m);耗时 3s
|
||||
CLEAR_RESULTS = (CLEAR_SUCCESS, CLEAR_NO_TARGET)
|
||||
|
||||
EXIT_USER = "user_exit" # /exit 成功响应中的固定退出原因
|
||||
|
||||
# accepted=false 的响应只含这 3 个字段;virtual_time_s 恒为 0,不是当前时刻 [题面]
|
||||
BASE_RESPONSE_FIELDS = ("accepted", "real_timestamp_ms", "virtual_time_s")
|
||||
|
||||
|
||||
# ---- 请求字段表 --------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FieldSpec:
|
||||
"""单个请求字段的约束。[题面] 附件2 5.1 节与各指令小节。"""
|
||||
|
||||
kind: type # str / dict;数值字段由 sim 按整型/浮点细则单独校验
|
||||
required: bool = True
|
||||
max_bytes: int | None = None # 字符串字段的 UTF-8 长度上限
|
||||
|
||||
|
||||
COMMON_FIELDS: dict[str, FieldSpec] = {
|
||||
"arena_id": FieldSpec(str), # 必须为 "default"
|
||||
"robot_id": FieldSpec(str, max_bytes=64), # 逐字节等于登录参赛队号
|
||||
"request_id": FieldSpec(str, max_bytes=128), # 会话内幂等键,新动作用新 ID
|
||||
}
|
||||
|
||||
POSITION_FIELDS: dict[str, FieldSpec] = {
|
||||
"x": FieldSpec(float), # 有限 JSON number,|x| <= 2_000_000
|
||||
"y": FieldSpec(float),
|
||||
}
|
||||
|
||||
# 路径 -> 除公共字段外的动作字段;/enter 与 /exit 无附加字段
|
||||
ACTION_FIELDS: dict[str, dict[str, FieldSpec]] = {
|
||||
"/enter": {},
|
||||
"/measure": {"position": FieldSpec(dict), "channel": FieldSpec(float)},
|
||||
"/clear": {"position": FieldSpec(dict), "channel": FieldSpec(float)},
|
||||
"/exit": {},
|
||||
}
|
||||
ACTION_PATHS = tuple(ACTION_FIELDS)
|
||||
|
||||
# channel 写成 JSON number 1.0 且恰为整数也接受;1.5 返回 HTTP 400 [题面]
|
||||
|
||||
|
||||
# ---- 响应字段表(accepted=true 时的附加字段) --------------------------------
|
||||
|
||||
RESPONSE_FIELDS: dict[str, tuple[str, ...]] = {
|
||||
"/enter": ("max_virtual_duration_s", "max_real_duration_s", "remaining_real_duration_s"),
|
||||
"/measure": ("measure_result",), # direction 时另有 svd_deg
|
||||
"/clear": ("clear_result",),
|
||||
"/exit": ("exit_reason",),
|
||||
}
|
||||
|
||||
|
||||
# ---- 错误语义 [题面] 附件2 5.3 节 --------------------------------------------
|
||||
|
||||
STATUS_OK = 200
|
||||
STATUS_BAD_REQUEST = 400 # JSON 语法/重复键/缺字段/类型/频道或坐标范围
|
||||
STATUS_CONFLICT = 409 # 同 request_id 对应不同动作,或并发不同动作
|
||||
STATUS_RETRYABLE = (429, 500) # 流量保护/幂等记录上限、服务器内部错误:可原样重试
|
||||
|
||||
|
||||
def http_status_meaning(status: int) -> str:
|
||||
table = {
|
||||
200: "动作已执行,或 JSON 合法但被业务拒绝(看 accepted)",
|
||||
400: "请求结构错误;不占用 request_id,修正后可复用该 ID",
|
||||
404: "路径未知或不精确(不接受尾随斜线与查询参数)",
|
||||
405: "已知路径使用了非 POST 方法",
|
||||
409: "request_id 幂等冲突或并发发送不同动作",
|
||||
413: "请求体超过 65536 字节",
|
||||
415: "Content-Type 或 Content-Encoding 不受支持",
|
||||
429: "连接/流量保护触发,或本局幂等记录达到上限",
|
||||
500: "模拟器内部错误",
|
||||
}
|
||||
return table.get(status, "未知状态码")
|
||||
@@ -0,0 +1,97 @@
|
||||
"""P0/P1 机器狗本地状态机:位置、测向频道、虚拟时刻与进出状态。
|
||||
|
||||
更新规则 [题面]:
|
||||
- 只有 accepted=true 的响应才允许调用 apply_*;accepted=false 一律不更新,
|
||||
其 virtual_time_s=0 不是当前虚拟时刻;
|
||||
- 虚拟时刻以最近一次 accepted=true 的响应为准,本地预测值仅作一致性核对;
|
||||
- /clear 不改变测向机当前频道,也不产生切换耗时;
|
||||
- 只有合法 /measure 会把测向机频道更新为本次检测频道。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from ..config import CostConfig, Vec2
|
||||
from .rules import CLEAR_SUCCESS
|
||||
from .timing import CostBreakdown, clear_cost, measure_cost
|
||||
|
||||
|
||||
class StateError(RuntimeError):
|
||||
"""状态机被以非法顺序驱动(例如未 enter 就 apply_measure)。"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class RobotState:
|
||||
position: Vec2 = (0.0, 0.0) # /enter 成功后的初始位置 [题面]
|
||||
channel: int = 1 # /enter 成功后的初始频道 [题面]
|
||||
virtual_time_s: float = 0.0
|
||||
entered: bool = False
|
||||
exited: bool = False
|
||||
action_count: int = 0
|
||||
# 预测与实测的偏差记录;strict 模式下出现偏差会直接抛错。
|
||||
mismatches: list = field(default_factory=list)
|
||||
|
||||
# -------------------------------------------------------------- enter/exit
|
||||
def apply_enter(self, response: dict, *, strict: bool = False) -> None:
|
||||
self._require_accepted(response)
|
||||
if strict and (self.entered or self.exited):
|
||||
raise StateError("重复 /enter 或已退出后再 enter")
|
||||
self.entered = True
|
||||
self.virtual_time_s = response["virtual_time_s"]
|
||||
|
||||
def apply_exit(self, response: dict) -> None:
|
||||
self._require_accepted(response)
|
||||
self.exited = True
|
||||
self.virtual_time_s = response["virtual_time_s"]
|
||||
|
||||
# ----------------------------------------------------------------- measure
|
||||
def apply_measure(self, request: dict, response: dict, *,
|
||||
cost: CostConfig = CostConfig(),
|
||||
strict: bool = False) -> CostBreakdown:
|
||||
self._require_accepted(response)
|
||||
if strict and (not self.entered or self.exited):
|
||||
raise StateError("/measure 要求已 enter 且未退出")
|
||||
pos = (request["position"]["x"], request["position"]["y"])
|
||||
channel = request["channel"]
|
||||
predicted = measure_cost(self.position, pos, self.channel, channel, cost)
|
||||
predicted = self._advance(pos, channel, response, predicted, strict)
|
||||
return predicted
|
||||
|
||||
# ------------------------------------------------------------------- clear
|
||||
def apply_clear(self, request: dict, response: dict, *,
|
||||
cost: CostConfig = CostConfig(),
|
||||
strict: bool = False) -> CostBreakdown:
|
||||
self._require_accepted(response)
|
||||
if strict and (not self.entered or self.exited):
|
||||
raise StateError("/clear 要求已 enter 且未退出")
|
||||
pos = (request["position"]["x"], request["position"]["y"])
|
||||
success = response["clear_result"] == CLEAR_SUCCESS
|
||||
predicted = clear_cost(self.position, pos, success, cost)
|
||||
# 频道保持不变:/clear 不切换测向机频道 [题面]
|
||||
return self._advance(pos, self.channel, response, predicted, strict)
|
||||
|
||||
# ------------------------------------------------------------------ 共用
|
||||
def _advance(self, pos: Vec2, channel: int, response: dict,
|
||||
predicted: CostBreakdown, strict: bool) -> CostBreakdown:
|
||||
# 先完成全部校验再落子:strict 模式下失败时不留半更新状态。
|
||||
actual_delta = response["virtual_time_s"] - self.virtual_time_s
|
||||
if abs(actual_delta - predicted.total_s) > 1e-6:
|
||||
message = (f"耗时预测不一致: 预测 {predicted.total_s:.6f}s, "
|
||||
f"实测 {actual_delta:.6f}s ({predicted})")
|
||||
if strict:
|
||||
raise StateError(message)
|
||||
# 非严格模式:状态仍以响应为准,偏差记入日志供复盘。
|
||||
self.mismatches.append(
|
||||
{"action": self.action_count + 1, "predicted_s": predicted.total_s,
|
||||
"actual_s": actual_delta, "message": message})
|
||||
self.position = pos
|
||||
self.channel = channel
|
||||
self.virtual_time_s = response["virtual_time_s"]
|
||||
self.action_count += 1
|
||||
return predicted
|
||||
|
||||
@staticmethod
|
||||
def _require_accepted(response: dict) -> None:
|
||||
if response.get("accepted") is not True:
|
||||
raise StateError("accepted=false 的响应不允许更新状态")
|
||||
@@ -0,0 +1,55 @@
|
||||
"""P0 纯计时模型:/measure 与 /clear 的耗时分解,不涉及网络与状态。
|
||||
|
||||
附件1表2 与附件2第10节的计时示例是唯一权威锚点:
|
||||
逐步得到 105、111、194、199 秒(tests/test_protocol_timing.py 固化)。
|
||||
客户端用本模块预测动作代价,模拟器用同一函数推进虚拟时钟,
|
||||
保证"预测-执行-复算"三方一致。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from math import dist
|
||||
|
||||
from ..config import CostConfig, Vec2
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CostBreakdown:
|
||||
"""一次合法动作的三段耗时 [题面]:移动 + 换频道(仅检测)+ 动作本身。"""
|
||||
|
||||
move_s: float
|
||||
switch_s: float
|
||||
action_s: float
|
||||
|
||||
@property
|
||||
def total_s(self) -> float:
|
||||
return self.move_s + self.switch_s + self.action_s
|
||||
|
||||
|
||||
def measure_cost(prev_pos: Vec2, pos: Vec2, prev_channel: int, channel: int,
|
||||
cost: CostConfig = CostConfig()) -> CostBreakdown:
|
||||
"""合法 /measure 的耗时。移动按直线距离;换频道只看前后频道是否不同。"""
|
||||
return CostBreakdown(
|
||||
move_s=dist(prev_pos, pos) / cost.speed_mps,
|
||||
switch_s=0.0 if channel == prev_channel else cost.switch_s,
|
||||
action_s=cost.measure_s,
|
||||
)
|
||||
|
||||
|
||||
def clear_cost(prev_pos: Vec2, pos: Vec2, success: bool,
|
||||
cost: CostConfig = CostConfig()) -> CostBreakdown:
|
||||
"""合法 /clear 的耗时。
|
||||
|
||||
/clear 的 channel 是目标干扰源频道,不切换测向机频道,也不产生换频道耗时 [题面]。
|
||||
"""
|
||||
return CostBreakdown(
|
||||
move_s=dist(prev_pos, pos) / cost.speed_mps,
|
||||
switch_s=0.0,
|
||||
action_s=cost.clear_success_s if success else cost.clear_fail_s,
|
||||
)
|
||||
|
||||
|
||||
def move_time(a: Vec2, b: Vec2, cost: CostConfig = CostConfig()) -> float:
|
||||
"""任意两点间的移动耗时(规划用)。"""
|
||||
return dist(a, b) / cost.speed_mps
|
||||
@@ -0,0 +1,44 @@
|
||||
"""传输抽象:HTTP 细节与协议客户端解耦,便于故障注入与本地模拟。
|
||||
|
||||
真实运行用 :class:`RequestsTransport`;自建模拟器经 :class:`LocalSimTransport`
|
||||
(sim 模块)挂载;回放用 PlaybackTransport。连接失败与读超时统一抛
|
||||
:class:`TransportError` —— 此类失败后重试"同一动作"必须复用原 request_id
|
||||
与原请求体 [题面],由 client 层保证。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
class TransportError(Exception):
|
||||
"""网络层失败:连接不上、读写超时。动作可能未送达,也可能结果未知。"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TransportResponse:
|
||||
status: int
|
||||
body: bytes
|
||||
|
||||
|
||||
class Transport:
|
||||
"""最小 POST 接口。同步阻塞,一次一个请求(协议要求串行)。"""
|
||||
|
||||
def post(self, url: str, body: bytes, headers: dict[str, str],
|
||||
timeout: tuple[float, float]) -> TransportResponse:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class RequestsTransport(Transport):
|
||||
"""基于 requests 的默认实现。timeout=(连接, 读取)。"""
|
||||
|
||||
def post(self, url, body, headers, timeout):
|
||||
try:
|
||||
response = requests.post(url, data=body, headers=headers, timeout=timeout)
|
||||
except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as exc:
|
||||
raise TransportError(str(exc)) from exc
|
||||
except requests.exceptions.RequestException as exc: # 其它请求级异常同样按网络失败处理
|
||||
raise TransportError(str(exc)) from exc
|
||||
return TransportResponse(response.status_code, response.content)
|
||||
@@ -0,0 +1,8 @@
|
||||
"""自建模拟器与测试侧传输:只对测试端开放真值,策略端仅见观测。"""
|
||||
|
||||
from .playback import PlaybackTransport
|
||||
from .simulator import LocalSimulator, Source
|
||||
from .transport import LocalSimTransport, ScriptedFaultTransport
|
||||
|
||||
__all__ = ["LocalSimTransport", "LocalSimulator", "PlaybackTransport",
|
||||
"ScriptedFaultTransport", "Source"]
|
||||
@@ -0,0 +1,38 @@
|
||||
"""回放传输:把既有演练日志作为服务器响应原样回放给客户端。
|
||||
|
||||
用于 P1 验收"本地状态机与官方响应一致":官方演练记录
|
||||
(output/q1/practice/*_actions.json)逐条喂给客户端,客户端重建的
|
||||
状态必须与当时服务器返回的虚拟时刻逐步吻合。
|
||||
|
||||
按录制顺序匹配(客户端的 request_id 生成规则与采集器不同,
|
||||
故不参与匹配;故障重试场景请用 ScriptedFaultTransport)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from ..protocol.transport import Transport, TransportResponse
|
||||
|
||||
|
||||
class PlaybackTransport(Transport):
|
||||
"""events: 演练日志 [{path, request, response, ...}, ...]。"""
|
||||
|
||||
def __init__(self, events: list[dict]):
|
||||
self.events = list(events)
|
||||
self.index = 0
|
||||
self.served: list[dict] = []
|
||||
|
||||
def post(self, url, body, headers, timeout):
|
||||
if self.index >= len(self.events):
|
||||
raise AssertionError("回放日志已耗尽仍有请求")
|
||||
event = self.events[self.index]
|
||||
path = urlsplit(url).path
|
||||
if path != event["path"]:
|
||||
raise AssertionError(
|
||||
f"回放顺序不一致: 客户端请求 {path}, 录制预期 {event['path']}")
|
||||
self.index += 1
|
||||
self.served.append(event)
|
||||
return TransportResponse(
|
||||
200, json.dumps(event["response"], ensure_ascii=False).encode("utf-8"))
|
||||
@@ -0,0 +1,231 @@
|
||||
"""自建本地模拟器:按附件1/2语义复现官方模拟器的可观测行为。
|
||||
|
||||
用途(P1 验收"自建回放测试"):不依赖官方模拟器即可测试客户端、
|
||||
故障恢复与后续策略。真值 Source 只对测试端开放,策略端只能拿到响应。
|
||||
|
||||
与官方实现的边界:本类只承诺"协议可观测行为一致"(响应字段、
|
||||
耗时推进、幂等与拒绝语义);示向度误差分布是测试假设,不代表官方实现。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from math import atan2, degrees, hypot, isfinite
|
||||
from random import Random
|
||||
from time import time
|
||||
|
||||
from ..config import ProblemConfig
|
||||
from ..protocol.rules import (ACTION_FIELDS, CLEAR_NO_TARGET, CLEAR_RESULTS,
|
||||
CLEAR_SUCCESS, COMMON_FIELDS, DIRECTION,
|
||||
EXIT_USER, MEASURE_RESULTS, NEAR, NO_SIGNAL,
|
||||
POSITION_FIELDS)
|
||||
from ..protocol.timing import clear_cost, measure_cost
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Source:
|
||||
"""一局真值。每个频道最多一个源 [题面];构造时强制检查。"""
|
||||
|
||||
channel: int
|
||||
x: float
|
||||
y: float
|
||||
r_eff_m: float # 有效接收半径,题目范围 1000..1500
|
||||
directional: bool = False
|
||||
heading_deg: float = 0.0 # 定向源朝向;全向源忽略
|
||||
|
||||
|
||||
class LocalSimulator:
|
||||
"""handle(path, payload) -> (http_status, body_dict)。"""
|
||||
|
||||
def __init__(self, sources: list[Source], *, robot_id: str = "2026001",
|
||||
config: ProblemConfig = ProblemConfig(),
|
||||
error_half_deg: float | None = None, error_seed: int = 20260910,
|
||||
rtt_ms: float = 40.0):
|
||||
channels = [s.channel for s in sources]
|
||||
if len(channels) != len(set(channels)):
|
||||
raise ValueError("每个频道最多对应一个干扰源")
|
||||
for s in sources:
|
||||
if not (config.field.channel_min <= s.channel <= config.field.channel_max):
|
||||
raise ValueError("频道超出 1..20")
|
||||
if not s.directional and s.heading_deg != 0.0:
|
||||
raise ValueError("全向源不应有朝向")
|
||||
self.sources = list(sources)
|
||||
self.robot_id = robot_id
|
||||
self.config = config
|
||||
self.field = config.field
|
||||
self.cost = config.cost
|
||||
self.error_half_deg = (self.field.bearing_error_half_deg
|
||||
if error_half_deg is None else error_half_deg)
|
||||
self._rng = Random(error_seed)
|
||||
self._rtt_ms = rtt_ms
|
||||
self.entered = False
|
||||
self.exited = False
|
||||
self.position = (0.0, 0.0)
|
||||
self.channel = self.field.channel_min
|
||||
self.virtual_time_s = 0.0
|
||||
self.cleared: set[int] = set()
|
||||
self._idempotency: dict[str, tuple[str, int, dict]] = {} # id -> (指纹, 状态码, 响应)
|
||||
|
||||
# ------------------------------------------------------------------ 主入口
|
||||
def handle(self, path: str, payload) -> tuple[int, dict]:
|
||||
if path not in ACTION_FIELDS:
|
||||
return 404, self._reject(path)
|
||||
if not isinstance(payload, dict):
|
||||
return 400, self._reject(path)
|
||||
# 1) 公共字段存在性与类型(缺失/类型错 -> 400 [题面])
|
||||
for name, spec in COMMON_FIELDS.items():
|
||||
value = payload.get(name)
|
||||
if not isinstance(value, str) or (spec.max_bytes
|
||||
and len(value.encode("utf-8")) > spec.max_bytes):
|
||||
return 400, self._reject(path)
|
||||
if not value:
|
||||
return 400, self._reject(path)
|
||||
# 2) 动作字段存在性 -> 400
|
||||
spec = ACTION_FIELDS[path]
|
||||
for name in spec:
|
||||
if name not in payload:
|
||||
return 400, self._reject(path)
|
||||
# 3) 未知字段 -> 200 accepted=false(不占用 request_id [题面])
|
||||
known = set(COMMON_FIELDS) | set(spec)
|
||||
if set(payload) != known:
|
||||
return 200, self._not_accepted()
|
||||
# 4) arena / robot 不匹配 -> 200 accepted=false(不占用 request_id [题面])
|
||||
if payload["arena_id"] != "default" or payload["robot_id"] != self.robot_id:
|
||||
return 200, self._not_accepted()
|
||||
# 4.5) position 下的未知字段同样 -> 200 accepted=false [题面 7.4]
|
||||
if path in ("/measure", "/clear"):
|
||||
position = payload.get("position")
|
||||
if isinstance(position, dict) and set(position) - set(POSITION_FIELDS):
|
||||
return 200, self._not_accepted()
|
||||
# 5) 幂等:同一 ID 同一内容回放首次响应;不同内容 409 [题面]
|
||||
request_id = payload["request_id"]
|
||||
fingerprint = self._fingerprint(path, payload)
|
||||
if request_id in self._idempotency:
|
||||
seen_fp, seen_status, seen_body = self._idempotency[request_id]
|
||||
if seen_fp == fingerprint:
|
||||
return seen_status, dict(seen_body)
|
||||
return 409, self._reject(path)
|
||||
# 6) 业务状态机
|
||||
status, body = self._execute(path, payload)
|
||||
if status == 200 and body.get("accepted") is True:
|
||||
self._idempotency[request_id] = (fingerprint, status, dict(body))
|
||||
return status, body
|
||||
|
||||
# ------------------------------------------------------------------ 动作
|
||||
def _execute(self, path: str, payload: dict) -> tuple[int, dict]:
|
||||
if path == "/enter":
|
||||
if self.entered or self.exited:
|
||||
return 200, self._not_accepted()
|
||||
self.entered = True
|
||||
return 200, self._ok({
|
||||
"max_virtual_duration_s": self.field.max_virtual_duration_s,
|
||||
"max_real_duration_s": self.field.max_real_duration_s,
|
||||
"remaining_real_duration_s": self.field.max_real_duration_s,
|
||||
})
|
||||
if not self.entered or self.exited:
|
||||
return 200, self._not_accepted()
|
||||
if path == "/exit":
|
||||
self.exited = True
|
||||
return 200, self._ok({"exit_reason": EXIT_USER})
|
||||
# /measure 与 /clear 共同的字段校验 -> 400 [题面]
|
||||
position = payload.get("position")
|
||||
if not isinstance(position, dict) or not set(POSITION_FIELDS) <= set(position):
|
||||
return 400, self._reject(path)
|
||||
for key in ("x", "y"):
|
||||
value = position[key]
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)) \
|
||||
or not isfinite(value) or abs(value) > self.field.coord_abs_limit_m:
|
||||
return 400, self._reject(path)
|
||||
channel = payload.get("channel")
|
||||
if isinstance(channel, bool) or not isinstance(channel, (int, float)) \
|
||||
or not float(channel).is_integer() \
|
||||
or not self.field.channel_min <= channel <= self.field.channel_max:
|
||||
return 400, self._reject(path)
|
||||
channel = int(channel)
|
||||
pos = (float(position["x"]), float(position["y"]))
|
||||
|
||||
if path == "/measure":
|
||||
return self._measure(pos, channel)
|
||||
return self._clear(pos, channel)
|
||||
|
||||
def _measure(self, pos, channel: int) -> tuple[int, dict]:
|
||||
breakdown = measure_cost(self.position, pos, self.channel, channel, self.cost)
|
||||
result, svd = self._observe(pos, channel)
|
||||
self.position = pos
|
||||
self.channel = channel # 只有合法 /measure 更新频道 [题面]
|
||||
self._advance(breakdown.total_s)
|
||||
body = {"measure_result": result}
|
||||
if result == DIRECTION:
|
||||
body["svd_deg"] = svd
|
||||
return 200, self._ok(body)
|
||||
|
||||
def _clear(self, pos, channel: int) -> tuple[int, dict]:
|
||||
source = self._source(channel)
|
||||
success = (source is not None and source.channel not in self.cleared
|
||||
and hypot(pos[0] - source.x, pos[1] - source.y)
|
||||
<= self.field.clear_radius_m)
|
||||
breakdown = clear_cost(self.position, pos, success, self.cost)
|
||||
self.position = pos # /clear 不改变频道 [题面]
|
||||
if success:
|
||||
self.cleared.add(channel)
|
||||
self._advance(breakdown.total_s)
|
||||
result = CLEAR_SUCCESS if success else CLEAR_NO_TARGET
|
||||
return 200, self._ok({"clear_result": result})
|
||||
|
||||
# ------------------------------------------------------------------ 观测
|
||||
def _observe(self, pos, channel: int):
|
||||
source = self._source(channel)
|
||||
if source is None or channel in self.cleared:
|
||||
return NO_SIGNAL, None
|
||||
distance = hypot(pos[0] - source.x, pos[1] - source.y)
|
||||
if distance > source.r_eff_m:
|
||||
return NO_SIGNAL, None
|
||||
if source.directional:
|
||||
bearing_to_point = degrees(atan2(pos[1] - source.y, pos[0] - source.x))
|
||||
if self._angular_gap(bearing_to_point, source.heading_deg) \
|
||||
> self.field.directional_half_angle_deg:
|
||||
return NO_SIGNAL, None
|
||||
if distance <= self.field.near_radius_m:
|
||||
return NEAR, None
|
||||
true_bearing = degrees(atan2(source.y - pos[1], source.x - pos[0]))
|
||||
error = self._rng.uniform(-self.error_half_deg, self.error_half_deg)
|
||||
svd = round(true_bearing + error, self.field.bearing_decimals) % 360.0
|
||||
return DIRECTION, svd
|
||||
|
||||
def _source(self, channel: int):
|
||||
for source in self.sources:
|
||||
if source.channel == channel:
|
||||
return source
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _angular_gap(a: float, b: float) -> float:
|
||||
return abs((a - b + 180.0) % 360.0 - 180.0)
|
||||
|
||||
# ------------------------------------------------------------------ 计时与响应
|
||||
def _advance(self, seconds: float) -> None:
|
||||
self.virtual_time_s += seconds
|
||||
|
||||
def _ok(self, extra: dict) -> dict:
|
||||
self._rtt_ms += 40.0
|
||||
body = {"accepted": True,
|
||||
"real_timestamp_ms": 1_760_000_000_000.0 + self._rtt_ms,
|
||||
"virtual_time_s": self.virtual_time_s, **extra}
|
||||
return body
|
||||
|
||||
def _not_accepted(self) -> dict:
|
||||
return {"accepted": False, "real_timestamp_ms": 1_760_000_000_000.0,
|
||||
"virtual_time_s": 0} # accepted=false 时恒为 0 [题面]
|
||||
|
||||
def _reject(self, path: str) -> dict:
|
||||
return {"accepted": False, "real_timestamp_ms": 1_760_000_000_000.0,
|
||||
"virtual_time_s": 0, "detail": f"{path} 请求被拒绝"}
|
||||
|
||||
@staticmethod
|
||||
def _fingerprint(path: str, payload) -> str:
|
||||
canonical = json.dumps({"path": path, "payload": payload},
|
||||
sort_keys=True, ensure_ascii=False,
|
||||
separators=(",", ":"))
|
||||
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
||||
@@ -0,0 +1,53 @@
|
||||
"""测试侧传输:把 LocalSimulator 挂到 Transport 接口 + 故障注入脚本。
|
||||
|
||||
故障脚本用于 P1 验收"失败重试不污染状态":
|
||||
- ``"drop_before"`` 连接失败,动作未送达服务器(可安全重试);
|
||||
- ``"timeout_after"`` 服务器已受理并生效,但响应丢失(必须靠幂等回放);
|
||||
- ``("status", n)`` 服务器返回指定 HTTP 状态码(如 500/429);
|
||||
- ``None`` 正常放行。
|
||||
脚本耗尽后一律放行。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from ..protocol.transport import Transport, TransportError, TransportResponse
|
||||
from .simulator import LocalSimulator
|
||||
|
||||
|
||||
class LocalSimTransport(Transport):
|
||||
"""内存回环:不经过网络栈的本地模拟器传输。"""
|
||||
|
||||
def __init__(self, simulator: LocalSimulator):
|
||||
self.simulator = simulator
|
||||
self.received: list[tuple[str, dict]] = []
|
||||
|
||||
def post(self, url, body, headers, timeout):
|
||||
path = urlsplit(url).path
|
||||
payload = json.loads(body.decode("utf-8"))
|
||||
self.received.append((path, payload))
|
||||
status, response = self.simulator.handle(path, payload)
|
||||
return TransportResponse(status, json.dumps(response, ensure_ascii=False).encode("utf-8"))
|
||||
|
||||
|
||||
class ScriptedFaultTransport(Transport):
|
||||
"""按脚本注入故障后转发给内层传输。"""
|
||||
|
||||
def __init__(self, inner: Transport, script: list):
|
||||
self.inner = inner
|
||||
self.script = list(script)
|
||||
self.sent_requests: list[bytes] = []
|
||||
|
||||
def post(self, url, body, headers, timeout):
|
||||
self.sent_requests.append(body)
|
||||
fault = self.script.pop(0) if self.script else None
|
||||
if fault == "drop_before":
|
||||
raise TransportError("injected: connection refused before send")
|
||||
if fault == "timeout_after":
|
||||
self.inner.post(url, body, headers, timeout) # 服务器已受理
|
||||
raise TransportError("injected: read timeout after server applied")
|
||||
if isinstance(fault, tuple) and fault and fault[0] == "status":
|
||||
return TransportResponse(int(fault[1]), b'{"accepted": false, "real_timestamp_ms": 0, "virtual_time_s": 0}')
|
||||
return self.inner.post(url, body, headers, timeout)
|
||||
File diff suppressed because one or more lines are too long
@@ -2,8 +2,8 @@ import math
|
||||
import random
|
||||
import unittest
|
||||
|
||||
from geometry import Measurement, solve, smallest_enclosing_circle
|
||||
from geometry.localization import halfplanes, distance
|
||||
from mcm26b.geometry import Measurement, solve, smallest_enclosing_circle
|
||||
from mcm26b.geometry.localization import halfplanes, distance
|
||||
|
||||
|
||||
class GeometryTests(unittest.TestCase):
|
||||
@@ -6,8 +6,9 @@ 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
|
||||
from mcm26b.geometry import (Measurement, solve, smallest_enclosing_circle,
|
||||
first_bearing_candidate_disk)
|
||||
from mcm26b.geometry.localization import _enclosing_circle_reference, distance
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
@@ -93,7 +94,7 @@ class CertificateTests(unittest.TestCase):
|
||||
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']
|
||||
base = [sys.executable, '-m', 'mcm26b.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])
|
||||
@@ -4,7 +4,7 @@ import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
from geometry import Measurement, solve
|
||||
from mcm26b.geometry import Measurement, solve
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
"""P1 验收测试:幂等重试、故障恢复、现实预算与真实演练日志回放。
|
||||
|
||||
对应验收标准(docs/B题建模分析与实施计划.md P1 行):
|
||||
- accepted=false 和超时重试不污染状态;
|
||||
- 失败重试复用原 request_id 与原内容,服务器端动作恰好生效一次;
|
||||
- 用 /enter 的 remaining_real_duration_s 建立本地截止时钟;
|
||||
- 官方演练日志可完整回放,本地重建状态与服务器记录逐步吻合。
|
||||
"""
|
||||
|
||||
import json
|
||||
import math
|
||||
import tempfile
|
||||
import unittest
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
|
||||
from mcm26b.config import ProblemConfig
|
||||
from mcm26b.evaluation import recompute_virtual_time, summarize
|
||||
from mcm26b.protocol.client import (DeadlineExceeded, ProtocolError,
|
||||
SimulatorClient)
|
||||
from mcm26b.protocol.log import SessionLog
|
||||
from mcm26b.protocol.transport import TransportError
|
||||
from mcm26b.sim import (LocalSimTransport, LocalSimulator,
|
||||
PlaybackTransport, ScriptedFaultTransport, Source)
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class FaultRecoveryTests(unittest.TestCase):
|
||||
"""故障注入:失败重试不污染状态、不重复生效。"""
|
||||
|
||||
def _client(self, script, sources=None):
|
||||
simulator = LocalSimulator(sources or [], robot_id="T001")
|
||||
inner = LocalSimTransport(simulator)
|
||||
faults = ScriptedFaultTransport(inner, script)
|
||||
client = SimulatorClient("T001", transport=faults)
|
||||
return client, faults, simulator
|
||||
|
||||
def test_timeout_after_apply_replays_cached_response(self):
|
||||
"""读超时(服务器已生效)后重试:同一响应回放,动作不二次生效。"""
|
||||
# 脚本按请求顺序消耗:enter 放行,measure 第一次注入"已生效但超时"。
|
||||
client, faults, simulator = self._client([None, "timeout_after", None, None])
|
||||
client.enter()
|
||||
outcome = client.measure((0, 0), 1)
|
||||
self.assertEqual(outcome.virtual_time_s, 5.0)
|
||||
# measure 只被服务器执行了一次:请求发出两次,但虚拟时刻只推进一次。
|
||||
measure_bodies = [body for body in faults.sent_requests[1:]]
|
||||
self.assertEqual(len(measure_bodies), 2, "应发出原始请求+一次重试")
|
||||
first, retry = (json.loads(b) for b in measure_bodies)
|
||||
self.assertEqual(first["request_id"], retry["request_id"],
|
||||
"重试必须复用原 request_id")
|
||||
self.assertEqual(first, retry, "重试请求体必须逐字段一致")
|
||||
self.assertEqual(simulator.virtual_time_s, 5.0, "动作只生效一次")
|
||||
self.assertEqual(client.state.virtual_time_s, 5.0)
|
||||
|
||||
def test_drop_before_apply_is_safe_to_retry(self):
|
||||
"""连接失败(未送达)后重试成功,无残留效果。"""
|
||||
client, faults, simulator = self._client(["drop_before", None])
|
||||
client.enter()
|
||||
client.measure((100, 0), 1)
|
||||
self.assertEqual(simulator.virtual_time_s, 25.0) # 100/5 移动 + 5 检测
|
||||
self.assertEqual(client.state.virtual_time_s, 25.0)
|
||||
|
||||
def test_server_500_retried_with_same_id(self):
|
||||
client, faults, simulator = self._client([None, ("status", 500), None, None])
|
||||
client.enter()
|
||||
client.measure((0, 0), 1)
|
||||
self.assertEqual(client.state.virtual_time_s, 5.0)
|
||||
|
||||
def test_exhausted_retries_raise_and_leave_state_clean(self):
|
||||
# enter 放行后,measure 连续注入断连直至超过 max_attempts=3。
|
||||
client, faults, simulator = self._client([None, "drop_before", "drop_before",
|
||||
"drop_before"])
|
||||
client.enter()
|
||||
with self.assertRaises(TransportError):
|
||||
client.measure((0, 0), 1)
|
||||
self.assertEqual(client.state.virtual_time_s, 0.0,
|
||||
"失败动作不得污染本地状态")
|
||||
self.assertTrue(simulator.entered) # enter 已成功,会话仍然有效
|
||||
# 后续新动作用新 request_id 正常执行(脚本已耗尽,全部放行)。
|
||||
outcome = client.measure((0, 0), 1)
|
||||
self.assertEqual(outcome.virtual_time_s, 5.0)
|
||||
|
||||
def test_accepted_false_raises_and_state_untouched(self):
|
||||
client, faults, simulator = self._client([None])
|
||||
with self.assertRaises(ProtocolError) as ctx:
|
||||
client.measure((0, 0), 1) # 未 enter -> accepted=false
|
||||
self.assertEqual(ctx.exception.status, 200)
|
||||
self.assertEqual(client.state.virtual_time_s, 0.0)
|
||||
|
||||
|
||||
class DeadlineBudgetTests(unittest.TestCase):
|
||||
"""现实预算:本地单调时钟、预留余量、/exit 豁免。"""
|
||||
|
||||
def test_deadline_from_enter_remaining(self):
|
||||
now = [1000.0]
|
||||
simulator = LocalSimulator([], robot_id="T001")
|
||||
client = SimulatorClient("T001", transport=LocalSimTransport(simulator),
|
||||
clock=lambda: now[0])
|
||||
client.enter()
|
||||
self.assertAlmostEqual(client.real_remaining_s, 1200.0)
|
||||
now[0] += 300.0
|
||||
self.assertAlmostEqual(client.real_remaining_s, 900.0)
|
||||
|
||||
def test_action_blocked_when_reserve_margin_cannot_be_kept(self):
|
||||
now = [0.0]
|
||||
simulator = LocalSimulator([], robot_id="T001")
|
||||
client = SimulatorClient("T001", transport=LocalSimTransport(simulator),
|
||||
clock=lambda: now[0])
|
||||
client.enter() # remaining = 1200s
|
||||
now[0] += 1145.0 # 只剩 55s < 60s 预留
|
||||
with self.assertRaises(DeadlineExceeded):
|
||||
client.measure((0, 0), 1)
|
||||
info = client.exit() # /exit 不受预算拦截
|
||||
self.assertEqual(info.reason, "user_exit")
|
||||
|
||||
def test_custom_reserve_margin(self):
|
||||
now = [0.0]
|
||||
simulator = LocalSimulator([], robot_id="T001")
|
||||
config = replace(ProblemConfig(),
|
||||
session=replace(ProblemConfig().session,
|
||||
reserve_exit_s=10.0))
|
||||
client = SimulatorClient("T001", config=config,
|
||||
transport=LocalSimTransport(simulator),
|
||||
clock=lambda: now[0])
|
||||
client.enter()
|
||||
now[0] += 1195.0 # 剩 5s < 新预留 10s
|
||||
with self.assertRaises(DeadlineExceeded):
|
||||
client.measure((0, 0), 1)
|
||||
|
||||
|
||||
class PracticeReplayTests(unittest.TestCase):
|
||||
"""官方演练日志经 PlaybackTransport 驱动完整客户端。"""
|
||||
|
||||
def _events(self):
|
||||
files = sorted((ROOT / "output" / "q1" / "practice").glob("*_actions.json"))
|
||||
self.assertTrue(files, "缺少演练采集证据")
|
||||
events = []
|
||||
for path in files:
|
||||
events.extend(json.loads(path.read_text(encoding="utf-8")))
|
||||
return events
|
||||
|
||||
def test_client_state_matches_recorded_server_clock(self):
|
||||
events = self._events()
|
||||
client = SimulatorClient("任意队号", transport=PlaybackTransport(events))
|
||||
for event in events:
|
||||
path, request = event["path"], event["request"]
|
||||
if path == "/enter":
|
||||
client.enter()
|
||||
elif path == "/measure":
|
||||
client.measure((request["position"]["x"], request["position"]["y"]),
|
||||
request["channel"])
|
||||
elif path == "/exit":
|
||||
client.exit()
|
||||
self.assertAlmostEqual(
|
||||
client.state.virtual_time_s,
|
||||
event["response"]["virtual_time_s"], places=5,
|
||||
msg=f"{path} 后本地虚拟时刻应与服务器记录一致")
|
||||
self.assertTrue(client.state.exited)
|
||||
|
||||
def test_evaluation_recompute_agrees_with_recording(self):
|
||||
events = [dict(path=e["path"], request=e["request"], response=e["response"])
|
||||
for e in self._events()
|
||||
if e["response"].get("accepted") is True]
|
||||
report = recompute_virtual_time(events)
|
||||
self.assertEqual(report["mismatches"], [])
|
||||
self.assertAlmostEqual(report["final_virtual_time_s"],
|
||||
events[-1]["response"]["virtual_time_s"], places=5)
|
||||
|
||||
def test_summarize_counts(self):
|
||||
events = [dict(path=e["path"], request=e["request"], response=e["response"])
|
||||
for e in self._events()]
|
||||
stats = summarize(events)
|
||||
directions = [e for e in events
|
||||
if e["response"].get("measure_result") == "direction"]
|
||||
self.assertGreater(stats["measures"], 0)
|
||||
self.assertEqual(stats["clears"], 0, "该次演练只采集观测,未调用 /clear")
|
||||
|
||||
|
||||
class LocalSimObservationTests(unittest.TestCase):
|
||||
"""观测语义:no_signal / near / direction 与定向覆盖角。"""
|
||||
|
||||
def test_distance_and_coverage_gate(self):
|
||||
source = Source(channel=1, x=500, y=0, r_eff_m=1000)
|
||||
simulator = LocalSimulator([source], robot_id="T001")
|
||||
client = SimulatorClient("T001", transport=LocalSimTransport(simulator))
|
||||
client.enter()
|
||||
self.assertEqual(client.measure((1505, 0), 1).result, "no_signal") # 距源1005>1000
|
||||
self.assertEqual(client.measure((500, 998), 1).result, "direction") # 1000内
|
||||
self.assertEqual(client.measure((500, 0), 1).result, "near") # <=5m
|
||||
|
||||
def test_directional_source_backside_invisible(self):
|
||||
source = Source(channel=1, x=500, y=0, r_eff_m=1500,
|
||||
directional=True, heading_deg=90.0) # 朝北
|
||||
simulator = LocalSimulator([source], robot_id="T001")
|
||||
client = SimulatorClient("T001", transport=LocalSimTransport(simulator))
|
||||
client.enter()
|
||||
self.assertEqual(client.measure((500, 600), 1).result, "direction") # 正北
|
||||
self.assertEqual(client.measure((500, -300), 1).result, "no_signal") # 正南背面
|
||||
self.assertEqual(client.measure((500, 998), 1).result, "direction") # 边界含90度
|
||||
|
||||
def test_clear_ignores_directional_facing(self):
|
||||
"""清除只看距离:定向源背面同样可清除 [题面]。"""
|
||||
source = Source(channel=7, x=0, y=0, r_eff_m=1500,
|
||||
directional=True, heading_deg=180.0)
|
||||
simulator = LocalSimulator([source], robot_id="T001")
|
||||
client = SimulatorClient("T001", transport=LocalSimTransport(simulator))
|
||||
client.enter()
|
||||
self.assertEqual(client.state.channel, 1)
|
||||
outcome = client.clear((15, 0), 7) # 距离15m<=20,位于背面
|
||||
self.assertEqual(outcome.result, "success")
|
||||
outcome = client.clear((0, 0), 7) # 同源只能清除一次
|
||||
self.assertEqual(outcome.result, "no_target_in_range")
|
||||
# 两次 /clear 后测向机频道仍是初始 1:/clear 不切换频道 [题面]。
|
||||
self.assertEqual(client.state.channel, 1)
|
||||
# 清除后同频道无信号;/measure 会把频道更新为 7。
|
||||
self.assertEqual(client.measure((100, 0), 7).result, "no_signal")
|
||||
self.assertEqual(client.state.channel, 7)
|
||||
|
||||
def test_bearing_error_within_bound(self):
|
||||
source = Source(channel=1, x=1000, y=300, r_eff_m=1500)
|
||||
simulator = LocalSimulator([source], error_half_deg=1.0, robot_id="T001")
|
||||
client = SimulatorClient("T001", transport=LocalSimTransport(simulator))
|
||||
client.enter()
|
||||
outcome = client.measure((0, 0), 1)
|
||||
true_bearing = math.degrees(math.atan2(300, 1000))
|
||||
# 误差在 ±1° 内均匀分布,返回值再保留两位小数。
|
||||
self.assertLessEqual(abs(outcome.svd_deg - true_bearing), 1.005)
|
||||
|
||||
|
||||
class SessionLogTests(unittest.TestCase):
|
||||
def test_log_records_attempts_and_accepted(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
path = Path(folder) / "session.jsonl"
|
||||
simulator = LocalSimulator([], robot_id="T001")
|
||||
faults = ScriptedFaultTransport(LocalSimTransport(simulator),
|
||||
[None, "drop_before", None, None])
|
||||
client = SimulatorClient("T001", transport=faults,
|
||||
log=SessionLog(path=path))
|
||||
client.enter()
|
||||
client.measure((0, 0), 1)
|
||||
client.exit()
|
||||
rows = [json.loads(line) for line in
|
||||
path.read_text(encoding="utf-8").splitlines()]
|
||||
accepted = [row for row in rows if row["event"] == "accepted"]
|
||||
self.assertEqual(len(accepted), 3)
|
||||
self.assertEqual(accepted[1]["attempts"], 2, "重试后成功应记录尝试次数")
|
||||
self.assertEqual(len({row["request_id"] for row in accepted}), 3,
|
||||
"每个被接受动作各占一个 request_id")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,207 @@
|
||||
"""P0 验收测试:计时示例、状态机规则与字段表语义。
|
||||
|
||||
对应验收标准(docs/B题建模分析与实施计划.md P0 行):
|
||||
- 附件计时示例能逐步得到 105、111、194、199 秒;
|
||||
- /clear 不切换测向频道;
|
||||
- accepted=false 不更新状态,其 virtual_time_s=0 不当作当前虚拟时刻。
|
||||
"""
|
||||
|
||||
import unittest
|
||||
|
||||
from mcm26b.config import CostConfig
|
||||
from mcm26b.protocol.rules import CLEAR_NO_TARGET
|
||||
from mcm26b.protocol.state import RobotState, StateError
|
||||
from mcm26b.protocol.timing import clear_cost, measure_cost
|
||||
from mcm26b.sim import LocalSimTransport, LocalSimulator
|
||||
from mcm26b.protocol.client import ActionRejected, SimulatorClient
|
||||
|
||||
|
||||
class TimingModelTests(unittest.TestCase):
|
||||
"""附件1表2 / 附件2第10节示例逐步分解。"""
|
||||
|
||||
def setUp(self):
|
||||
self.cost = CostConfig()
|
||||
|
||||
def test_measure_step2_move_500_no_switch(self):
|
||||
breakdown = measure_cost((0, 0), (300, 400), 1, 1, self.cost)
|
||||
self.assertAlmostEqual(breakdown.move_s, 100) # 500m / 5(m/s)
|
||||
self.assertAlmostEqual(breakdown.switch_s, 0) # 初始频道即1
|
||||
self.assertAlmostEqual(breakdown.action_s, 5)
|
||||
self.assertAlmostEqual(breakdown.total_s, 105)
|
||||
|
||||
def test_measure_step3_in_place_switch_channel(self):
|
||||
breakdown = measure_cost((300, 400), (300, 400), 1, 2, self.cost)
|
||||
self.assertAlmostEqual(breakdown.move_s, 0)
|
||||
self.assertAlmostEqual(breakdown.switch_s, 1)
|
||||
self.assertAlmostEqual(breakdown.total_s, 6)
|
||||
|
||||
def test_clear_step4_move_400_no_target(self):
|
||||
breakdown = clear_cost((300, 400), (300, 0), success=False, cost=self.cost)
|
||||
self.assertAlmostEqual(breakdown.move_s, 80) # 400m / 5
|
||||
self.assertAlmostEqual(breakdown.switch_s, 0) # /clear 不产生换频道耗时
|
||||
self.assertAlmostEqual(breakdown.action_s, 3) # 未发现=光学定位3s
|
||||
self.assertAlmostEqual(breakdown.total_s, 83)
|
||||
|
||||
def test_measure_step5_same_channel_after_clear(self):
|
||||
# 第4步 /clear 后测向机仍是频道2,原地再测频道2无切换耗时。
|
||||
breakdown = measure_cost((300, 0), (300, 0), 2, 2, self.cost)
|
||||
self.assertAlmostEqual(breakdown.total_s, 5)
|
||||
|
||||
def test_virtual_clock_walk(self):
|
||||
"""累积虚拟时刻逐步得到附件给定值 0/105/111/194/199/199。"""
|
||||
virtual = 0.0
|
||||
expected = [0, 105, 111, 194, 199, 199]
|
||||
sequence = [
|
||||
measure_cost((0, 0), (300, 400), 1, 1, self.cost),
|
||||
measure_cost((300, 400), (300, 400), 1, 2, self.cost),
|
||||
clear_cost((300, 400), (300, 0), False, self.cost),
|
||||
measure_cost((300, 0), (300, 0), 2, 2, self.cost),
|
||||
None, # /exit
|
||||
]
|
||||
results = [virtual]
|
||||
for breakdown in sequence:
|
||||
virtual += 0 if breakdown is None else breakdown.total_s
|
||||
results.append(round(virtual, 6))
|
||||
self.assertEqual(results, [float(v) for v in expected])
|
||||
|
||||
|
||||
class StateMachineTests(unittest.TestCase):
|
||||
"""状态更新规则:accepted=false 不更新;/clear 不动频道。"""
|
||||
|
||||
@staticmethod
|
||||
def _accepted_measure(virtual):
|
||||
return {"accepted": True, "real_timestamp_ms": 0,
|
||||
"virtual_time_s": virtual,
|
||||
"measure_result": "no_signal"}
|
||||
|
||||
def test_clear_keeps_channel(self):
|
||||
state = RobotState()
|
||||
state.entered = True
|
||||
measure_req = {"position": {"x": 300, "y": 400}, "channel": 2}
|
||||
state.apply_measure(measure_req, self._accepted_measure(106))
|
||||
self.assertEqual(state.channel, 2)
|
||||
clear_req = {"position": {"x": 300, "y": 0}, "channel": 3}
|
||||
response = {"accepted": True, "real_timestamp_ms": 0,
|
||||
"virtual_time_s": 189,
|
||||
"clear_result": CLEAR_NO_TARGET}
|
||||
state.apply_clear(clear_req, response)
|
||||
self.assertEqual(state.channel, 2, "/clear 不得改变测向机频道")
|
||||
|
||||
def test_rejected_response_never_updates(self):
|
||||
state = RobotState(position=(10, 20), channel=5, virtual_time_s=77)
|
||||
rejected = {"accepted": False, "real_timestamp_ms": 1, "virtual_time_s": 0}
|
||||
request = {"position": {"x": 0, "y": 0}, "channel": 1}
|
||||
with self.assertRaises(StateError):
|
||||
state.apply_measure(request, rejected)
|
||||
with self.assertRaises(StateError):
|
||||
state.apply_clear(request, rejected)
|
||||
with self.assertRaises(StateError):
|
||||
state.apply_enter(rejected)
|
||||
self.assertEqual((state.position, state.channel, state.virtual_time_s),
|
||||
((10, 20), 5, 77), "accepted=false 不得更新任何状态")
|
||||
|
||||
def test_virtual_time_zero_of_rejection_is_not_a_clock(self):
|
||||
"""accepted=false 的 virtual_time_s=0 不是当前时刻(回归附件2 4.1)。"""
|
||||
state = RobotState(virtual_time_s=300)
|
||||
rejected = {"accepted": False, "real_timestamp_ms": 9, "virtual_time_s": 0}
|
||||
with self.assertRaises(StateError):
|
||||
state.apply_measure({"position": {"x": 0, "y": 0}, "channel": 1}, rejected)
|
||||
self.assertEqual(state.virtual_time_s, 300)
|
||||
|
||||
def test_strict_mode_flags_prediction_gap(self):
|
||||
state = RobotState()
|
||||
state.entered = True
|
||||
request = {"position": {"x": 100, "y": 0}, "channel": 1}
|
||||
bad = {"accepted": True, "real_timestamp_ms": 0, "virtual_time_s": 3,
|
||||
"measure_result": "no_signal"} # 应为 25s,伪造 3s
|
||||
with self.assertRaises(StateError):
|
||||
state.apply_measure(request, bad, strict=True)
|
||||
|
||||
|
||||
class FieldTableSimTests(unittest.TestCase):
|
||||
"""字段表与错误语义(附件2 5.1/5.3/6.3/7.4)。"""
|
||||
|
||||
def setUp(self):
|
||||
self.sim = LocalSimulator([], robot_id="T001")
|
||||
self.base = {"arena_id": "default", "robot_id": "T001"}
|
||||
|
||||
def _measure(self, **override):
|
||||
payload = {**self.base, "request_id": "m1",
|
||||
"position": {"x": 0, "y": 0}, "channel": 1, **override}
|
||||
return self.sim.handle("/measure", payload)
|
||||
|
||||
def test_measure_before_enter_rejected(self):
|
||||
status, body = self._measure()
|
||||
self.assertEqual((status, body["accepted"]), (200, False))
|
||||
self.assertEqual(body["virtual_time_s"], 0)
|
||||
self.assertEqual(set(body) >= {"accepted", "real_timestamp_ms", "virtual_time_s"}, True)
|
||||
|
||||
def test_unknown_field_rejected_but_id_not_consumed(self):
|
||||
self.sim.handle("/enter", {**self.base, "request_id": "e1"})
|
||||
status, body = self._measure(extra_field=1) # 未声明字段
|
||||
self.assertEqual((status, body["accepted"]), (200, False))
|
||||
status, body = self._measure() # 同 ID 修正后可复用
|
||||
self.assertEqual((status, body["accepted"]), (200, True))
|
||||
|
||||
def test_wrong_robot_id_rejected(self):
|
||||
status, body = self.sim.handle("/enter", {**self.base, "robot_id": "OTHER",
|
||||
"request_id": "e1"})
|
||||
self.assertEqual((status, body["accepted"]), (200, False))
|
||||
|
||||
def test_channel_and_coordinate_validation_400(self):
|
||||
self.sim.handle("/enter", {**self.base, "request_id": "e1"})
|
||||
for channel in (0, 21, 1.5):
|
||||
status, _ = self._measure(request_id=f"c{channel}", channel=channel)
|
||||
self.assertEqual(status, 400)
|
||||
status, _ = self._measure(request_id="far",
|
||||
position={"x": 2_000_001, "y": 0})
|
||||
self.assertEqual(status, 400)
|
||||
|
||||
def test_position_unknown_field_rejected(self):
|
||||
self.sim.handle("/enter", {**self.base, "request_id": "e1"})
|
||||
status, body = self._measure(request_id="p1",
|
||||
position={"x": 0, "y": 0, "z": 0})
|
||||
self.assertEqual((status, body["accepted"]), (200, False))
|
||||
|
||||
def test_double_enter_rejected_then_measure_ok(self):
|
||||
status, body = self.sim.handle("/enter", {**self.base, "request_id": "e1"})
|
||||
self.assertEqual(body["accepted"], True)
|
||||
status, body = self.sim.handle("/enter", {**self.base, "request_id": "e2"})
|
||||
self.assertEqual((status, body["accepted"]), (200, False))
|
||||
status, body = self._measure(request_id="m1")
|
||||
self.assertEqual(body["accepted"], True)
|
||||
|
||||
|
||||
class WorkedExampleEndToEndTests(unittest.TestCase):
|
||||
"""完整链路:客户端 → 本地模拟器复现附件示例。"""
|
||||
|
||||
def test_attachment_example_reaches_199(self):
|
||||
simulator = LocalSimulator([], robot_id="T001")
|
||||
client = SimulatorClient("T001", transport=LocalSimTransport(simulator))
|
||||
client.enter()
|
||||
self.assertTrue(client.state.entered)
|
||||
self.assertEqual((client.state.position, client.state.channel), ((0.0, 0.0), 1))
|
||||
got = [client.measure((300, 400), 1).virtual_time_s,
|
||||
client.measure((300, 400), 2).virtual_time_s,
|
||||
client.clear((300, 0), 3).virtual_time_s,
|
||||
client.measure((300, 0), 2).virtual_time_s]
|
||||
self.assertEqual([round(v, 6) for v in got], [105, 111, 194, 199])
|
||||
self.assertEqual(client.state.channel, 2)
|
||||
info = client.exit()
|
||||
self.assertEqual((info.reason, round(info.virtual_time_s, 6)),
|
||||
("user_exit", 199))
|
||||
self.assertEqual(client.state.mismatches, [], "预测与服务器计时应完全一致")
|
||||
|
||||
def test_rejected_action_keeps_client_state(self):
|
||||
simulator = LocalSimulator([], robot_id="T001")
|
||||
client = SimulatorClient("T001", transport=LocalSimTransport(simulator))
|
||||
with self.assertRaises(ActionRejected):
|
||||
client.measure((0, 0), 1) # 未 enter
|
||||
self.assertEqual(client.state.virtual_time_s, 0)
|
||||
self.assertFalse(client.state.entered)
|
||||
client.enter()
|
||||
self.assertEqual(client.state.virtual_time_s, 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -3,6 +3,363 @@ revision = 3
|
||||
requires-python = ">=3.10"
|
||||
|
||||
[[package]]
|
||||
name = "math"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
name = "certifi"
|
||||
version = "2026.7.22"
|
||||
source = { registry = "https://mirrors.aliyun.com/pypi/simple/" }
|
||||
sdist = { url = "https://mirrors.aliyun.com/pypi/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55" }
|
||||
wheels = [
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "charset-normalizer"
|
||||
version = "3.5.1"
|
||||
source = { registry = "https://mirrors.aliyun.com/pypi/simple/" }
|
||||
sdist = { url = "https://mirrors.aliyun.com/pypi/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3" }
|
||||
wheels = [
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/71/aa/554e2614f38fc34c58ff1d0911ae8535ad2516440d5482d76fe59f1088b0/charset_normalizer-3.5.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1ee1e296209fdce05b81b663250eefa02213a2da7b41bf26f7829b8ba3545aa" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/03/6d/439231dfc3ccfa6f8c06477b7da2219cbd41a2de3d49084df8ec7b5100f2/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9fbdce1e47394b09bc9f26ab117dfc8d6491977a11d86f592bb42c779db2fda" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/55/53/7d819bd23a00ef45039146fa2cce1daa2f0771e758c5653ee1f6edac91ed/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:00668ebb0609751758682eb0b5857e7c35b9f00e84dfdef062e103244ec94d45" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/b2/2c/45847198c16f4b38090cc7423b2b6a9008e438704d8ab413211832498d31/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba2f37ee79e6338845261a3c5b1784e5d1acdff2c0785b284f1b633033d136ab" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/69/2b/d8be3523ddf9f0b0f3e56d1359034aa10653a4d11564c697f802b4775766/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ce854f5f478050ade5a238731c4ca985a7d3b3cb53ff600a9b5c3b689b5f0a7a" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/32/cd/4f564b8f132de25db594efc706897069f016790cea63a5669c9df2675f64/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:96eefc178f8636b9c760c5829345307fd81cfae9ab1e80997dbddeb0f54ee9a3" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/f5/e3/38b975422534a608f98c360e79c2f07c763d66dd4272300d45fb1fee54b0/charset_normalizer-3.5.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:366ec70f5547c640d3ce1985722490f23faf4eb5216a7eeba78277490e78dacb" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/87/bd/fbc24d825c66f1c74f6ccdea3742c3d8354a4888e86d1315a197fee69061/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:950f23cb393f85543777b0433f082cddd25b51ab398eac7971146495679efe5f" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/b9/2d/918d0e98a0e679469ed05bb2d90c2088b4d315bb612969d8499f76fb5210/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c1dcc36dcb96abc02236e182d17e0f71430152a6c2c7447421da2d2dc144edea" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/20/c8/c36f6e0b2dfec351bd38cbc05362697e58bcd073d7dbd95154290c9714ce/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:07ffd07412fc5d5e84cd8952acf9ff7e4ed7a708e69d1bada19d8ba91711353f" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/ca/7b/311b3e02e8c4092400c449c850a760d8c45d900983c83a70cc07208c551d/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:f5542f9b941279d82d41eb0aa9f98eba36fe4df5c7086c651df7944935b37182" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/b9/90/082cc45599c392f28c036a497f49e0634041a785fc3849c80ccf396d096f/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a545775cfe815855ea32d7c27731d79da358ef2055b4a25830231b1622dd18aa" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/58/ad/b9aecf38d805cbcf84fa94f14c5d972a16561e20296a11dc799a5dcf3763/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:494b70049a4d69aec6e8137c13af4cf8db8c9f9820a1392ac293b0dd2987a818" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/b7/23/b38a20598d5a825f85d9d7636860e56ff0db1479f86497a6e485aa9326f7/charset_normalizer-3.5.1-cp310-cp310-win32.whl", hash = "sha256:94fbf1c0c6cc0d3d5e50f9a9313a8cdca90dd696d34b381cd1704f8c9e939f20" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/d2/21/83fffb77864408b8bf0fe1ca603926401d6f8775a8e150b39aacc9958f8a/charset_normalizer-3.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:be47f99644b208bff7766314013f9acf57b056b04191d570d68ad14022cf5b1d" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/86/2e/b93135b5034b1157fb29554b0d06d4844ce62282f0e0a14036f93d7ee2e7/charset_normalizer-3.5.1-cp310-cp310-win_arm64.whl", hash = "sha256:a6d095662e73e74f0a49988e0593373e243e3a52e27bfeea0a859e88acf4a0f5" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/6a/b6/034f6802e9c3f6418966cfabb7db8c9252cc2429c5098f41cc43af804149/charset_normalizer-3.5.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eda059b6bc8bc0812d626fd91a7ce01bf583df0a61296eff390fd94141a34e30" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/d5/fa/6a7e2a7c4b5451912b8c417732df79574354443592a88d616de03da66ae5/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa2bb0b37202dca27175591f761108b5d34096ade1191ffe4808bdf6b1571488" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/a4/c8/ab42b07cfd82e919f427fcfaa7c41abae8242833ad1aad66d42bae40b669/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b2b1b3fa5670c127b246df1d0c059defd41f689a868a3b9d79df9b1cac42d22" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/e7/80/b9348b5d3041209f98b4cdad7655766369233f1d533f4f4f7558e9717bec/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e5e4d73d588ca5ed09df1b7dcd1b203d1df3c542e3f50d126c947d432b10731" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/82/38/083a24028304bc85bb9e376fed801178423dcbb67495f73b6ea0624e1894/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b54e7e13267d49ffbfe68e25b3cbd774dab38fa37238f71265e91b36146eb21c" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/0d/35/731ac04aa0a097fc1c97f0994c375bdb230c6c96619db794208fe664e9ce/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7b742bf31c88566b4bb6335a7f393bb322e580b6bb98df7bd0c25e6e3519ce8" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/f5/28/c2028e7021fb89c6e56868ed0e387b8e9aa811abdd2ab3208d6578d2c930/charset_normalizer-3.5.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ba32c4d2abf1d2fe7cf27d280f4cca5664233b0f885549c7761719eb977f486" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/28/f0/0c0ceec6d98b7daa62e361e418135d59685811d79ba11529aad5cdf15e84/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0722590aabf9dc6a6c0343d523c05458fa2b5047dbe6302fd526bb570600753f" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/f0/3e/48f4cd187b1c33189d86039e9cbe4f92c05454175504b44ff81806d4d1bf/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:aa1099b956fb795e686d073568f6dc002a0bb89765ea6d5b055dd7d9bf1b116c" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/42/85/f9e22af69af67c54cce42be9455d9c81294f918b4ccc454db01f66efcac2/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bd6c173f04743d483881bffa1478d5a4624475b8cd1d2194956a75548e191c18" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/fd/4c/9044135f42127630b6fa742feb51256353f6ab87a78f2fdd1de3de955a7f/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:f298e218441525d3794428b4c8b8fb8662c6d3ea79925d4807ee6b9a96a3bca5" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/ba/ed/1dd7cfebb4e75812934c49ca3b79757d11948053f7937ab7070c151f3c55/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:6e2912d4babbc65196ac13c2f53468dc57fb8b9c25ef913e8c59ddf7c6dc0e1b" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/bf/eb/239c84503cc9e3ba6eb34686a24bc66e84f3924efdd7e38e751a19f6bc10/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3d27167433c0d5f18dc850f07d0b3816221984fecdc405d6c157a6f0b8f8e9e6" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/37/ab/4e4510e1e288478e2c8333131d1c1382382ba8cd2165053c79e39d1da961/charset_normalizer-3.5.1-cp311-cp311-win32.whl", hash = "sha256:ac00177c4831ffa650f8609e4bdddd5fe09c03b1c0c47acece7e6ea20421598b" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/e3/57/32f0ccea59e8612057c61d6fd22ef2cb63cca93c9fe594094919696ac170/charset_normalizer-3.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:f9b1e28d0e8dbfa858abdba91d6b547beaf2df1a59bec6da6faae7b96a4991a9" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/17/d4/b65c433fc521e58b5f54293982a5e51c05cb5f2dd3f1c7a6acb65b75324e/charset_normalizer-3.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:ae31a1a1db2ee6cc2942fccaf695c934bc7f3db9f2133a3fef1f367cf1a4ab10" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/30/27/78873dc8b6a56357517b74b6bb9568b80450e7bb4f6ef7e3fa9d22aa0bd7/charset_normalizer-3.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/9a/4c/be49ada26b1f0232d57aa89bbebf997a5cc2332a5616b6eca26ff680044d/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/76/84/6f1290fa07ae6978d3960caa3eb1b8019bf9284ab7c2297b00c099ef4250/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1d1c7a53a6c2103925cdd6d7229f8c567379f211c869793df679f2e9f738c369" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/e7/a0/47b18adeed31c8f16ba9700f32c1b18594cfa09f47eb672a488c273c22bf/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/38/fe/341861ac118dae06f3ec0eb487488af52128f2ef2faf0b11003944d22259/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/6f/89/bb5108dc6c3651dca963f2b0a3ba19bbcb370c94e1b6d3e0e844a58e6dca/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/b1/ba/ef83ae3aca816393decfa3530976f38a79812d707b80b580ac33b83f9877/charset_normalizer-3.5.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/f6/0b/c5292a2462d69b7378ea89793bbb5b2b6fcf6f7dd6d1667f9619094ad553/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/46/22/111e5be3b740d5c2a5bfcedb3d237b6591e5c2e82ae9d6ffcb121fe0909c/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/f9/d2/d2aad6fe0dbb44b194bf3becb60f5a0ac48446ade999a47fe7bb41eb09a7/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/35/5a/337e4663a5eae6de99db940ee8066d4145caafb61327db62deda15313cce/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/ca/85/f82f8a92e31c7519410e2e1afdc630f28ec47490ce2c09a11c1a43cbb459/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/b7/52/643d11ffd60e9ac2fd1fb87e167a19285b9eefeff4a40e63c87cbfbeab36/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/62/16/46556278c2168d12df9da7fede5dc6fc70e60301b26a82bbeec238c9cfe3/charset_normalizer-3.5.1-cp312-cp312-win32.whl", hash = "sha256:cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/9d/7a/4c6c298171e6b3e745633180ff59350fc0ca0db1ffd28df1e369e0579f71/charset_normalizer-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/cd/d7/eb95a042f0dd22e304b0b6472b154f3546a1a039a9ee89ccb2a7f61591fc/charset_normalizer-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/bc/61/2cb6ad133dbbb449fa2d37ccae973232f4827e799af258d15e589a3d1e9e/charset_normalizer-3.5.1-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/18/57/a305c968be1ca13f3dd1b32f445877e97addf55d80b65c7cb35fac82b777/charset_normalizer-3.5.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/09/0a/d3646670292ce8d8f8cc11ac067d44885e697a5591f57a9221128da5e7b3/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/de/93/d51ec556e01042fed6f993ea859311bc7917b466684182fbbceb6ca24762/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/a4/a0/562247944386f7d4ef94467e84876600cc1e0f1b93239aaa9213d2bc3cbd/charset_normalizer-3.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/31/e7/1d994be1b93d41e9502b8b0460eaa88a1dd8df335df415db87d6c3e91ab2/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/09/53/27923ce5cc6cbccb832037b27dca98882d9c53e9b69e866bbbef4aae7fc8/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/ce/48/5a97e84d63af1d55c07439cb80e56d99a8efb4295700eb4e18c0d1615d2c/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/7a/c2/071575791dcc88316c0a9a65ce38897a82e4cfe4a325f0f7fe1b1ac47bcf/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/fb/af/63240b0c0248c075c2535a1f1bd992821d8251b9f173abc13329661d09e4/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/4d/66/70dfad64f15be09c15ccfee81330a7e515895dbe296dd23114e9a231268a/charset_normalizer-3.5.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/c0/24/ef36367d38b9ddd4bccbf72888c342e8de1f5ae506fa0b2dcf970e2732a1/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/db/ab/55e683ba0fff2e43adafc10daa3001eac90fdaa419a97227d5a7067eedde/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/bd/67/0f40eaf8d1b6e7cf15e82382a2965efaca787fc1c2794b7021d37aaf5036/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/5c/64/12b4c2a11ee8df4fcc518c78b0d93e3a92bd3d5253d1617ce74ff0e8c7ef/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/37/2e/651d910af6d0fba325eee1cda37ec5443462ed25360e666c144166eb6091/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/90/c6/b09e05e6db7f64338e0dc067c79577b1138da86c1e38369096851d96be88/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/76/4e/362d4f9fdcdf5556fb2aa3ce7d4a58ebce03ed1ff03aa1d9aca8d02f13f3/charset_normalizer-3.5.1-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/b4/d4/703be739b26acce318bd29eb3b25b7209e1b1f527f9eae3d1f1f01fdde2b/charset_normalizer-3.5.1-cp313-cp313-win32.whl", hash = "sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/8a/33/56d97ade41c8db611e727168c52ae46c9224c362ec28d4b65d7e9869e8da/charset_normalizer-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/5b/75/5b20dd1e6573a01a08158fe104104fa2c8abf941745596954185726cd46c/charset_normalizer-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/29/cd/2b812ce5e888f1ce69a5350281e58aab07ae64a958ecae8912f30865718e/charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/9e/4a/a6ee107430768a5334e6d63f31f148a04a1a491ef161a1ac9415a73f2fa8/charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/c3/d9/35ae3f64f29d0179c35c3baefe575904df2913dde519129c7f75995a2b1d/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/74/76/f2fc7380f056cc273a53af37f50d08ad54b2c59f61078f31432edcf1c2bd/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/e9/40/095ce62fa078483cccc1fa2b36e6bc9580b85422a20ee9f925341c50e44f/charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/f1/5a/0e58b1c04a1596e0256f407274a92d5fb2ee21324409d1fab1da48a65b5b/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/22/95/b4618ce912e6db0b1aae89ba788e38e8a7eba0f3025cc66e8c0699f977b2/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/8a/76/c681192bbda3d55356db5dadd64381d5202b37c6b598fcda5282e88b5d3d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/88/be/55127bfca72c0cff6c022488d140d7c5b04c771e3b72e9bdb4836d54979d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/e0/91/39c3af510b0aa32bbda03374259200f28430febfd1bf5e511fe765282ce5/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/1c/a5/cbe418bbc6ecdfc3e05a0116002897c4b403a5e838d697e64c78e9f0190d/charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/cc/a4/689bb42e8e7cd492f3cb64907c6bc00ad247ec9a3628cd3f8eed126e8ae1/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/c1/ce/9962938e179cf9f699d3f1e7b3114b5d7642dee6a893745229f9dd04f274/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/85/54/46000450ada53bd9eac5429a2c8c54cd2d9b39c0c255f229aea9af0948a5/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/3d/bb/618749d70f792b44252a777bf89bfb86823b9bbc1ea13fe8ce759b07f38a/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/7e/3f/ffb64458527c7668031d5eb095d978de561958dc9f5b53f8e488a533e603/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/4f/ab/74a55fd803916a35ac461daf002708191aac19b546b80dc8cabfedc63d98/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/a0/2a/6a9034b7d3c60b17499afb482df5878bf9fa20b50cc3887d5ef017a833db/charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/f3/46/1d362e1a00d035d66b9869e1281eee115907f7e390a16a07824ab5737360/charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/7a/7c/4938c329b6a9d446f6a59aa2092ff7118f274209b5ed0e26893d1d30a63c/charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/ac/33/eeb384dbd8dec570661354592f4f2e1b2fcc92585624d146a000caf53841/charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/1c/6c/c73fa9d5a85f6ab05395de61c5f6984e0a9ff40bb5ff888d46dff02526c6/charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/30/c7/63565f860921457feba93bae6c86fb7746deb4cffeed2f375cb845318146/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/06/ae/7ae8807410dfa33f8e6f1715740adeaafa8a816cc4cb33508f54b1f7c896/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/e9/a3/887c1642f0da26000b0e0652d91071113c0e72cea33952e225cf589f49a9/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/3e/11/e6f5b9a3d0e55b0ef7505cd3765cdd48f22db89994c947b316f52f801fd8/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/1b/ee/e4e10a94d51cd1ee638aa7e00b65399e6b2a4e8376ab6d2eac9f95586671/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/c4/25/d5f4198819e6059735a84e8d0bfb72dc33976da67b97adcd3fb5a5e07ec6/charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/a5/e9/e925ca7569cf9fb9701fd82503fee73eea5268fdb856bdd64947092d3daa/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/34/17/672c251a888ed2aebcdd2fe830ad0104e25ff83c43f5c4f9c15e9fc6853c/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/3f/fc/f6a85abebd42ce4da2f1db0aa56cc6a0df1995e318b3875d14401b8381d1/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/98/66/7c42677e739ba66746b297e2046918d793078094dc239e1e72768cffccc6/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/de/d8/a50b79237f417af10f8c2a501ce8d1ca87829a22e69117891ca4ba20a69e/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/2e/1d/0fc91aeaeb3c83b748f532399ce67cf84604b48297405d740000f7a9e786/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/ae/10/3d8c777cf9024615295aa1b808324ad5b4a77855869c00824bad74ffaf8a/charset_normalizer-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/4d/81/ae557d3c44d1a1d688696d60563413a0866a91b7ebc50f20df838be3d8c8/charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/27/e9/61c01fb8b804692569c036b3fc50495814502dcf13a60649c6055390b02c/charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/4a/4e/8544831ef59d8f27ce92c80871380fdacc8076a8a56ed62f82e54f991333/charset_normalizer-3.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/7f/a6/e3b46852424246065355644f4fb6dbccc0239a42a2eee27ecfc8957f0bcd/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/03/3b/0cc9a26777334ab2f2e3089b948bbf4e4fe72ea70b897715ef6415043ec8/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/8c/c2/027335f0aa337a2a2e121bac1ad88c4f02ba6053ea0926802784f3db11af/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/86/d3/e367787febe4e74769dec0f406f2c3c8d1b955fce5aee1fd0f94e8367a45/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/af/3d/391b193eb9f3e84b02f9314088c386debdc0debee843535aaea2e2c6715d/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/2e/57/de221f1745a90d418199761967e2776bfe2c275a1194220985e8c1d37833/charset_normalizer-3.5.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/c8/e3/d119f86a01f9331e8186175f24873b1d74a7ee9e2e4b4d68f9947dae5afd/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/26/de/d8e48c135ae480879539cdb179c8d3b50c7879497d75dd899b5763b69cee/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/67/c4/217755fd1abc50d326c252922cd642002758095a81ff45010337b8b3ef65/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/b8/d7/34d8e404e358d2adcc5a228c2134643af00104c8fb0bf525f3688d756f05/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/5e/fa/40414471acf0aa0692ca77305aa00e434fcd8288f0941c93c30e9a5f8f2f/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/32/90/fcc850bae791abd2e0c041847f13e270aa08692a79f3e00de6d2dce1cb50/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/af/af/53afe99068b3c10b4cbae592a52ef72a7c92c0188440e83ee3a078fd8f75/charset_normalizer-3.5.1-cp315-cp315-win32.whl", hash = "sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/c9/bc/f46a132041b29e4a8779ed712d3df1bf112e94ca8de58b66d7ec2c0cf8b9/charset_normalizer-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/a1/5d/9ed554480eda8e447b673648628fdc29574d23dbad01fe11837adedd1cae/charset_normalizer-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/3b/32/9b8929bf384061ee1fe5d9c27c6f9776d3d824039ad4e14c88ec00c7808e/charset_normalizer-3.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/96/10/e9aa7923d3ddac652c99a1c5f7be494e737e151566a44abe018daf757f2c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/28/53/a2d249ebddf47b889a100c0bdcb61a2f9dbb8bc24ef325cc062e4f476877/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/7d/07/469f78af590f7d5cd48e20d8dbfa3d66deeff9ba37768c04d886b5afd45c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/55/66/3bb56a47f7dcba014055b1a1d33c6f08bbe9c1e74dba154cfa25f90ae885/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/ff/c1/2adc2800903fb013210349313b710a5376856578d9e33e6b9a1d8b36714a/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/95/b5/a18d0dd1157ab655cc2cb14a545f4a4784bbad70ab3502412e36097502d9/charset_normalizer-3.5.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/ad/c3/525f508cd1e58d0450ac55ed40ac75bc3a97482c59def5278456a5fbf03c/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/7c/c1/49a91fe7e97c8140094ca5c64161ab623a70d9f636bf834eace14048acb5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/d3/58/56a48c296601274c4689b864a8e2dfb209b81dfcb39472753ce95eea662b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/10/4c/dc48409274a1817ff349711d26c62aa0c597df865d4d69ef79160c859193/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/81/58/d325912115caec62d6bdd77bbab5e0b7da5d234a9f20affdffcbcb530d0b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/34/f7/b13b1ccae2c8ec63980d13be1890eb73f8aeabbfce02a24aabc0908788f5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/1e/25/ed3f9919c5aef8cc818be1f972f565f7610d7b2076b8ebb98839516ffc3c/charset_normalizer-3.5.1-cp315-cp315t-win32.whl", hash = "sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/69/d5/43c2b3e9d8267092b913eb8b0603f0f71993c395632886bd37a7223f96cf/charset_normalizer-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/a8/76/9aad3e9c8865e5e0efa9a7f6f81c37a67635a985145ecd44528a81e088ee/charset_normalizer-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://mirrors.aliyun.com/pypi/simple/" }
|
||||
sdist = { url = "https://mirrors.aliyun.com/pypi/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44" }
|
||||
wheels = [
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "exceptiongroup"
|
||||
version = "1.3.1"
|
||||
source = { registry = "https://mirrors.aliyun.com/pypi/simple/" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://mirrors.aliyun.com/pypi/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219" }
|
||||
wheels = [
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.19"
|
||||
source = { registry = "https://mirrors.aliyun.com/pypi/simple/" }
|
||||
sdist = { url = "https://mirrors.aliyun.com/pypi/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15" }
|
||||
wheels = [
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://mirrors.aliyun.com/pypi/simple/" }
|
||||
sdist = { url = "https://mirrors.aliyun.com/pypi/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730" }
|
||||
wheels = [
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mcm26b"
|
||||
version = "0.2.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "requests" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "pytest" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [{ name = "requests", specifier = ">=2.31" }]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [{ name = "pytest", specifier = ">=8" }]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.3"
|
||||
source = { registry = "https://mirrors.aliyun.com/pypi/simple/" }
|
||||
sdist = { url = "https://mirrors.aliyun.com/pypi/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79" }
|
||||
wheels = [
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://mirrors.aliyun.com/pypi/simple/" }
|
||||
sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3" }
|
||||
wheels = [
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.21.0"
|
||||
source = { registry = "https://mirrors.aliyun.com/pypi/simple/" }
|
||||
sdist = { url = "https://mirrors.aliyun.com/pypi/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c" }
|
||||
wheels = [
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.1.1"
|
||||
source = { registry = "https://mirrors.aliyun.com/pypi/simple/" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://mirrors.aliyun.com/pypi/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313" }
|
||||
wheels = [
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.34.2"
|
||||
source = { registry = "https://mirrors.aliyun.com/pypi/simple/" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "charset-normalizer" },
|
||||
{ name = "idna" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://mirrors.aliyun.com/pypi/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed" }
|
||||
wheels = [
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tomli"
|
||||
version = "2.4.1"
|
||||
source = { registry = "https://mirrors.aliyun.com/pypi/simple/" }
|
||||
sdist = { url = "https://mirrors.aliyun.com/pypi/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f" }
|
||||
wheels = [
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396" },
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.16.0"
|
||||
source = { registry = "https://mirrors.aliyun.com/pypi/simple/" }
|
||||
sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5" }
|
||||
wheels = [
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.7.0"
|
||||
source = { registry = "https://mirrors.aliyun.com/pypi/simple/" }
|
||||
sdist = { url = "https://mirrors.aliyun.com/pypi/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c" }
|
||||
wheels = [
|
||||
{ url = "https://mirrors.aliyun.com/pypi/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897" },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user