refactor(arch): redesign the code structure

This commit is contained in:
Horldsence
2026-09-10 22:38:27 +08:00
parent 21e8a8bf52
commit 83d2e24448
42 changed files with 2514 additions and 251 deletions
+3
View File
@@ -21,3 +21,6 @@ docs/paper/*.fls
docs/paper/*.fdb_latexmk
docs/paper/*.xdv
docs/paper/*.synctex.gz
# pytest
.pytest_cache/
+83 -20
View File
@@ -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,程序避免异常高频循环。
+123
View File
@@ -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
View File
@@ -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`。禁止在正式测试会话中运行。
+3 -3
View File
@@ -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
View File
@@ -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='';}
+21
View File
@@ -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"]
+58
View File
@@ -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
+78
View File
@@ -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())
+88
View File
@@ -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()
+56
View File
@@ -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)
+81
View File
@@ -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"]]
+80
View File
@@ -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")
+15
View File
@@ -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"]
+288
View File
@@ -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
+34
View File
@@ -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]
+94
View File
@@ -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, "未知状态码")
+97
View File
@@ -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 的响应不允许更新状态")
+55
View File
@@ -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
+44
View File
@@ -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)
+8
View File
@@ -0,0 +1,8 @@
"""自建模拟器与测试侧传输:只对测试端开放真值,策略端仅见观测。"""
from .playback import PlaybackTransport
from .simulator import LocalSimulator, Source
from .transport import LocalSimTransport, ScriptedFaultTransport
__all__ = ["LocalSimTransport", "LocalSimulator", "PlaybackTransport",
"ScriptedFaultTransport", "Source"]
+38
View File
@@ -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"))
+231
View File
@@ -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()
+53
View File
@@ -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]
+253
View File
@@ -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()
+207
View File
@@ -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()
Generated
+360 -3
View File
@@ -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" },
]