CM93 MySQL importer validated baseline
This commit is contained in:
9
.env.example
Normal file
9
.env.example
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
CM93_DB_HOST=127.0.0.1
|
||||||
|
CM93_DB_PORT=3306
|
||||||
|
CM93_DB_NAME=cm93_chart
|
||||||
|
CM93_DB_USER=root
|
||||||
|
CM93_DB_PASSWORD=change_me
|
||||||
|
CM93_DB_CHARSET=utf8mb4
|
||||||
|
|
||||||
|
# CM93 根目录。正式导入器将递归寻找 Z/A~G cell 与字典。
|
||||||
|
CM93_SOURCE_DIR=D:\Ship\CM93V2\CM93JAPAN
|
||||||
46
.gitignore
vendored
Normal file
46
.gitignore
vendored
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.env
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.egg-info/
|
||||||
|
logs/
|
||||||
|
tmp/
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
|
||||||
|
# 本地数据库/环境配置
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# 最终测试产生的临时输出目录
|
||||||
|
cm93_final_geometry_audit_*/
|
||||||
|
cm93_non_geometry_final_audit_dataset_*/
|
||||||
|
cm93_importer_source_audit_*/
|
||||||
|
cm93_normalization_dryrun_*/
|
||||||
|
cm93_normalization_canonical_*/
|
||||||
|
cm93_invalid_area_audit_*/
|
||||||
|
cm93_invalid_trace_*/
|
||||||
|
cm93_ogc_validity_audit_*/
|
||||||
|
cm93_raw_edge_audit_*/
|
||||||
|
cm93_spatial_audit_*/
|
||||||
|
|
||||||
|
# 临时打包文件
|
||||||
|
*.zip
|
||||||
|
|
||||||
|
# IDE / OS
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
3.12.10
|
||||||
61
AGENTS.md
Normal file
61
AGENTS.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# Codex / AI Agent 项目规则
|
||||||
|
|
||||||
|
## 当前目标
|
||||||
|
|
||||||
|
第一阶段已经具备 `CM93 → MySQL` 完整导入与审计代码。当前工作是用真实 `CM93JAPAN` 数据运行、诊断和修复实际兼容问题。
|
||||||
|
|
||||||
|
**不要提前实现 PBF。**
|
||||||
|
|
||||||
|
## 固定基线
|
||||||
|
|
||||||
|
- Windows 11
|
||||||
|
- Python 3.12.10
|
||||||
|
- MySQL 5.7.34-log
|
||||||
|
- CM93 根目录:`D:\Ship\CM93V2\CM93JAPAN`
|
||||||
|
- DB Schema:`sql/001_schema_mysql57.sql`
|
||||||
|
|
||||||
|
## 不可破坏规则
|
||||||
|
|
||||||
|
1. 禁止 MySQL 8 专用 SQL。
|
||||||
|
2. 禁止 float32 存最终经纬度。
|
||||||
|
3. 禁止 importer simplify/snap/quantize/round geometry。
|
||||||
|
4. 禁止自动 make_valid 后覆盖原 Polygon。
|
||||||
|
5. raw decoded tables、raw feature、raw attribute 必须可保留。
|
||||||
|
6. `_wgsox/_wgsoy` 必须保存。
|
||||||
|
7. 每个 feature 必须可追溯 dataset/cell/source_record_index。
|
||||||
|
8. 未知 object/attribute 不得静默跳过;无法确定长度时 cell 必须 FAILED。
|
||||||
|
9. parser 任一 header/count/EOF 不一致时 cell 必须 FAILED。
|
||||||
|
10. 每个 cell 独立事务;失败不得破坏其他 DONE cell。
|
||||||
|
11. DONE cell 支持续导跳过。
|
||||||
|
12. 不复制 OpenCPN GPL 源文件或类实现;保持独立实现。
|
||||||
|
13. PBF 不属于当前阶段。
|
||||||
|
|
||||||
|
## 实际运行顺序
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cm93db env-check
|
||||||
|
cm93db db-check
|
||||||
|
cm93db scan
|
||||||
|
cm93db inspect-dicts
|
||||||
|
cm93db import --scale G --limit 10
|
||||||
|
cm93db audit --scale G --limit 10 --precision-samples 32
|
||||||
|
```
|
||||||
|
|
||||||
|
Pilot PASS 后再全量。
|
||||||
|
|
||||||
|
## 修复要求
|
||||||
|
|
||||||
|
发现失败时,优先保留一个具体失败 cell 作为回归输入,并报告:
|
||||||
|
|
||||||
|
- cell 路径
|
||||||
|
- parser stage
|
||||||
|
- byte position/record index
|
||||||
|
- header source counts
|
||||||
|
- 实际异常
|
||||||
|
- 修改前后审计结果
|
||||||
|
|
||||||
|
不得通过 catch-and-skip 使任务“看起来完成”。
|
||||||
|
|
||||||
|
## 文档
|
||||||
|
|
||||||
|
项目说明、报告、设计记录使用中文;代码稳定标识符、数据库字段使用英文。
|
||||||
37
MANIFEST.sha256
Normal file
37
MANIFEST.sha256
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
eb70074aa5e04f09d8eb0ddca51a4df731e512211912097a03a0356034b70554 ./.env.example
|
||||||
|
1b4fdd7c2d13638d81c8d477a87a555d604edbc8f206b4f3ea3f86cb2962a1a8 ./.gitignore
|
||||||
|
5b703ca38d3fd391f3e889f7ba893f94b921ff9e7b7e5d8d6622d5f7daca7049 ./.python-version
|
||||||
|
8b8a8fa81c0bf2b82b1c5c07adaa8f91943187e34df4bbdd0c802dc1f8fee2c6 ./AGENTS.md
|
||||||
|
67b929d05d979a0eeebfd81f8bb482e70c8dd2c336fb6798a83bd37f0a61b9f3 ./README.md
|
||||||
|
c681b8ab9eb555ea79601e421c1a59f1dd97f2a0e6a787fdf1952e0c0bf68f74 ./codex/10_第一阶段接手任务.md
|
||||||
|
f9540f72e60242c3c6ece6f8e105eeb60458e4828e91f47ba2d8606122ef44de ./codex/ARCHITECTURE.md
|
||||||
|
f55a5b2cd4a8c95b76860523cd5da000a608d93eec86548aa70cf210ac51faef ./docs/Win11环境安装.md
|
||||||
|
54052bd183c8d5e021cde70d406f05a64500e19bafd186fa6a3a9311e16c4e47 ./docs/外部参考.md
|
||||||
|
4f48b830dd2ea32e329781c20870f9f1ba39cf65644d7cf98ab0b43bb3d06a03 ./docs/导入与审计.md
|
||||||
|
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 ./docs/报告/.gitkeep
|
||||||
|
e67539ec6ea0173487f90a5b2906c8083a91f13abd3b86ddb0c054f9290f238e ./docs/报告/CM93_Importer_v0.2.0_离线验证报告.md
|
||||||
|
2e32a838656fda45fcb7a86b574aa174f5dc656733a8281c94d16b75fe39a1de ./docs/数据库设计.md
|
||||||
|
0041bfc53607e619d752cad94e8d2b6e38c735d4d42b54d9da822dde65b88ccd ./docs/架构设计.md
|
||||||
|
3353d08b5bfa81af16cb1fc7a4c9a7b02eb971224c9fa6642dfce0d95cde1ef6 ./docs/精度规范.md
|
||||||
|
0420e31b40c75c4aa96a1d9eb5c6ec68a6d38ea697e07f0ae1818080a26e7dfe ./pyproject.toml
|
||||||
|
10aa41c34072cfa8b5c0c6cb7ff11de35b6c88951064dc058883da4c6ebbf9c8 ./requirements.txt
|
||||||
|
44168b993990c13b0c4f127af4e63d517571d42ac699b784bbfc975ccd02c50d ./samples/README.md
|
||||||
|
2ba8827a3eb730f997edcc696d4f6e3372ab6ee9d53502b363be8eddb681b210 ./scripts/audit_cm93.ps1
|
||||||
|
7029d7216c38003e670c4a799ea6c56cee0554154c2638a4767106b68ee2799c ./scripts/import_cm93.ps1
|
||||||
|
daf081a2ff981f4d8c2da5df84917f9f18b574c639b040ce3e66d21fbe9e700a ./scripts/init_db.ps1
|
||||||
|
6b34552119e6a5ad6af0bdfdfa1ddde01b4fba3f066d5982ea8dd67d4b34f169 ./scripts/repair_env.ps1
|
||||||
|
c477b0f32b5b9b636ff2160956cf19d2c4164597ec40687337f488bc3527b8fb ./scripts/setup_env.ps1
|
||||||
|
7818c17d282f5cd1f7dfb6faa5f0d3b00246508090f5516d7d08c9fe28497125 ./sql/001_schema_mysql57.sql
|
||||||
|
667d4a15b970b851e20d17510224670c14646cfb6d5a1e388ca6b9cc6da8bf41 ./src/cm93db/__init__.py
|
||||||
|
420bc6ea2968415d011c17fd390506990c63249c5fd7a3daa13bd121aeb8f689 ./src/cm93db/audit.py
|
||||||
|
da2d555eb10ecf3209c19381b540811c650bf88ea9c53e09e66663652abd59dc ./src/cm93db/cell_parser.py
|
||||||
|
c8fce15a77a42eda7bdb5e1677e7f306872f691779fae0aa72cd310063bc0616 ./src/cm93db/cli.py
|
||||||
|
f5b6adcbd898daba8a798119c5050792e6e0f836fa6928976937ec9180139213 ./src/cm93db/config.py
|
||||||
|
3488b6265d343829762b5204283c0fec012b3009b651972ff9d48a162b1f9c10 ./src/cm93db/database.py
|
||||||
|
0d5aec70e3eabcdf88e13b5e6c79b8ab40b4fed8b4664aaf4e4d4d7a42770838 ./src/cm93db/decoder.py
|
||||||
|
3d8f4fbdff0e0a3d77a902c11b40d7431fba4d6fd10abcdcc05d77e00359b322 ./src/cm93db/dictionaries.py
|
||||||
|
fe5db556801d55c47ae81c211f1237c19288adac837db523bc17f727dbf913a1 ./src/cm93db/geometry.py
|
||||||
|
7ebbf16369fd717d777ad98cc14c5c477e533ddb2b4f8fa7b1c681334b69c8c0 ./src/cm93db/importer.py
|
||||||
|
8ccffe2edcd8fcc50f7433c587e341e5254e868820260f4479e35e886ca6eef5 ./src/cm93db/precision.py
|
||||||
|
21347b9d043b87e2e7fe7eb7ae845e53632b3d7e26e58db920cf19c7b6e0ba14 ./src/cm93db/scanner.py
|
||||||
|
700d0990032ca3021ade98135f0a52756f8f80c2144835d1fa1d53a2ec8d4650 ./src/cm93db/schema_contract.py
|
||||||
236
README.md
Normal file
236
README.md
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
# CM93 → MySQL 高精度母数据库导入器 v0.2.0
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
本工程完成第一阶段:
|
||||||
|
|
||||||
|
**CM93 Z/A/B/C/D/E/F/G → 完整解析 → MySQL 5.7.34 母数据库 → 源/库双重审计。**
|
||||||
|
|
||||||
|
本阶段不生成 PBF。PBF 是第二阶段,可从本母数据库反复生成。
|
||||||
|
|
||||||
|
## 固定环境
|
||||||
|
|
||||||
|
- Windows 11
|
||||||
|
- pyenv-win
|
||||||
|
- Python **3.12.10**
|
||||||
|
- MySQL **5.7.34**
|
||||||
|
- 当前 CM93 日本数据目录:`D:\Ship\CM93V2\CM93JAPAN`
|
||||||
|
- 标准化几何:经纬度/WGS84 语义,SRID 4326
|
||||||
|
- 坐标计算:Python double / MySQL WKB double
|
||||||
|
|
||||||
|
## 已实现
|
||||||
|
|
||||||
|
- CM93 256-byte substitution decode
|
||||||
|
- 10-byte prolog + 128-byte header
|
||||||
|
- 文件长度完整性检查
|
||||||
|
- vector table
|
||||||
|
- 2D point table
|
||||||
|
- 3D sounding table
|
||||||
|
- feature table
|
||||||
|
- object dictionary
|
||||||
|
- typed attribute dictionary / attribute blob
|
||||||
|
- Point / Line / Area / Sounding
|
||||||
|
- edge reverse flag
|
||||||
|
- Area contour/ring 组装
|
||||||
|
- `_m_sor` coverage
|
||||||
|
- `_wgsox/_wgsoy` 保存与标准化坐标修正
|
||||||
|
- CM93 6378388.0 m Mercator
|
||||||
|
- typed MySQL attributes
|
||||||
|
- raw feature / raw attribute
|
||||||
|
- 解码后的全部 CM93 table 分块保存
|
||||||
|
- 每 cell 独立事务
|
||||||
|
- DONE 自动跳过
|
||||||
|
- FAILED 默认重试
|
||||||
|
- 全目录断点续导
|
||||||
|
- import job / event 日志
|
||||||
|
- 源文件重新解析审计
|
||||||
|
- 数据库逐 cell 数量审计
|
||||||
|
- raw 数据字节完整性审计
|
||||||
|
- SHA-256 审计
|
||||||
|
- MySQL WKB 双精度回读审计
|
||||||
|
|
||||||
|
## 已验证的真实 CM93 小样本
|
||||||
|
|
||||||
|
开发时对用户提供的 147 个真实 CM93 cell 执行完整 parser 回归:
|
||||||
|
|
||||||
|
- Cell: 147/147 PASS
|
||||||
|
- Feature: 100,621
|
||||||
|
- Attribute: 112,746
|
||||||
|
- 未发生 record 越界或文件尾剩余
|
||||||
|
- header attribute-byte count 与解析结果一致
|
||||||
|
- header geometry-ref count 与解析结果一致
|
||||||
|
- header related-pointer count 与解析结果一致
|
||||||
|
- decoded raw table 字节合计与原文件长度一致
|
||||||
|
|
||||||
|
MySQL 写入需要在目标 Windows/MySQL 5.7.34 上做 pilot 验收;本包提供对应命令和审计。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 1. 升级已有 v0.1.x 工程
|
||||||
|
|
||||||
|
如果原目录已经有正确的 `.venv` 和 `.env`,把 v0.2.0 ZIP 中整个 `cm93-mysql-importer-starter` 目录覆盖到原工程即可。
|
||||||
|
|
||||||
|
ZIP 不包含 `.env`,所以你已有的数据库密码不会被覆盖。
|
||||||
|
|
||||||
|
然后执行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd D:\React\cm93-mysql-importer-starter
|
||||||
|
& ".\.venv\Scripts\python.exe" -m pip install -e .
|
||||||
|
& ".\.venv\Scripts\cm93db.exe" --help
|
||||||
|
```
|
||||||
|
|
||||||
|
确认出现 `import` 和 `audit`。
|
||||||
|
|
||||||
|
# 2. .env
|
||||||
|
|
||||||
|
确保:
|
||||||
|
|
||||||
|
```text
|
||||||
|
CM93_DB_HOST=127.0.0.1
|
||||||
|
CM93_DB_PORT=3306
|
||||||
|
CM93_DB_NAME=cm93_chart
|
||||||
|
CM93_DB_USER=你的用户
|
||||||
|
CM93_DB_PASSWORD=你的密码
|
||||||
|
CM93_DB_CHARSET=utf8mb4
|
||||||
|
CM93_SOURCE_DIR=D:\Ship\CM93V2\CM93JAPAN
|
||||||
|
```
|
||||||
|
|
||||||
|
`scan / inspect-dicts / import / audit` 现在都可以省略路径,自动读取 `CM93_SOURCE_DIR`。
|
||||||
|
|
||||||
|
# 3. 环境/数据库检查
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
& ".\.venv\Scripts\cm93db.exe" env-check
|
||||||
|
& ".\.venv\Scripts\cm93db.exe" db-check
|
||||||
|
& ".\.venv\Scripts\cm93db.exe" scan
|
||||||
|
& ".\.venv\Scripts\cm93db.exe" inspect-dicts
|
||||||
|
```
|
||||||
|
|
||||||
|
# 4. 先做 G 层 10-cell pilot
|
||||||
|
|
||||||
|
不要第一次就直接跑完整日本数据。先验证 MySQL 实际写入:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
& ".\.venv\Scripts\cm93db.exe" import --scale G --limit 10
|
||||||
|
```
|
||||||
|
|
||||||
|
然后立即审计同一批:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
& ".\.venv\Scripts\cm93db.exe" audit --scale G --limit 10 --precision-samples 32
|
||||||
|
```
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
```text
|
||||||
|
source_parse_failed : 0
|
||||||
|
db_missing_cells : 0
|
||||||
|
db_not_done_cells : 0
|
||||||
|
count_mismatches : 0
|
||||||
|
hash_mismatches : 0
|
||||||
|
raw_preservation_bad : 0
|
||||||
|
unknown_objects : 0
|
||||||
|
unknown_attributes : 0
|
||||||
|
precision_failed : 0
|
||||||
|
RESULT : PASS
|
||||||
|
```
|
||||||
|
|
||||||
|
# 5. 全量导入日本数据
|
||||||
|
|
||||||
|
Pilot PASS 后:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
& ".\.venv\Scripts\cm93db.exe" import
|
||||||
|
```
|
||||||
|
|
||||||
|
程序逐 cell 工作。已经 `DONE` 且 **SHA-256 未变化** 的 cell 自动跳过,因此中断后重新执行同一命令即可续导。
|
||||||
|
|
||||||
|
FAILED cell 默认在下一次执行时重试。
|
||||||
|
|
||||||
|
# 6. 全量审计
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
& ".\.venv\Scripts\cm93db.exe" audit --precision-samples 256
|
||||||
|
```
|
||||||
|
|
||||||
|
这会重新读取 CM93 源文件,并与数据库逐 cell 对比:
|
||||||
|
|
||||||
|
- SHA-256
|
||||||
|
- Feature 数
|
||||||
|
- Attribute 数
|
||||||
|
- Point geometry 数
|
||||||
|
- Line geometry 数
|
||||||
|
- Area geometry 数
|
||||||
|
- `_m_sor` coverage 数
|
||||||
|
- Sounding point 数
|
||||||
|
- raw table 保存字节数
|
||||||
|
- 未知 object / attribute
|
||||||
|
- 抽样 WKB 双精度 round-trip
|
||||||
|
|
||||||
|
## 更严格的 geometry validity 检查
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
& ".\.venv\Scripts\cm93db.exe" audit --precision-samples 256 --validate-geometry
|
||||||
|
```
|
||||||
|
|
||||||
|
`--validate-geometry` 会额外用 Shapely 全量检查拓扑,明显更重。CM93 中存在自相交/边界类 contour 并不自动等于解析错误,因此这项结果作为信息单独显示,不会静默修复原 geometry。
|
||||||
|
|
||||||
|
# 7. 常用控制
|
||||||
|
|
||||||
|
只处理 F/G:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cm93db import --scale F --scale G
|
||||||
|
```
|
||||||
|
|
||||||
|
只处理一个 cell:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cm93db import --cell 04501075.G
|
||||||
|
cm93db audit --cell 04501075.G --precision-samples 16
|
||||||
|
```
|
||||||
|
|
||||||
|
强制重导:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cm93db import --cell 04501075.G --reimport
|
||||||
|
```
|
||||||
|
|
||||||
|
只审计源文件 parser,不访问数据库内容:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cm93db audit --source-only
|
||||||
|
```
|
||||||
|
|
||||||
|
正式母库**不建议**使用 `--no-raw-tables`。如果有意不保存 raw table,审计时需显式:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cm93db audit --allow-missing-raw
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 精度原则
|
||||||
|
|
||||||
|
1. CM93 raw X/Y 保持 16-bit 原始语义。
|
||||||
|
2. Cell easting/northing header 值使用 double。
|
||||||
|
3. CM93 Mercator 半长轴固定 `6378388.0`。
|
||||||
|
4. `_wgsox/_wgsoy` 以米为单位,在 Mercator→经纬度之前按 CM93 Transform 语义减去。
|
||||||
|
5. MySQL geometry 由 WKB 写入;WKB 坐标为 IEEE-754 8-byte double。
|
||||||
|
6. importer 阶段不 simplify、不 quantize、不 snap、不 round。
|
||||||
|
7. Polygon 不自动 `make_valid`。
|
||||||
|
8. 原始 decoded table + raw feature/attribute 与标准化 geometry 同时保留。
|
||||||
|
9. PBF 将来是可重新生成的发布格式,不能反向影响母数据库精度。
|
||||||
|
|
||||||
|
详见 `docs/精度规范.md` 和 `docs/CM93解析与审计设计.md`。
|
||||||
|
|
||||||
|
# Codex 接手
|
||||||
|
|
||||||
|
Codex 不需要重新实现 parser。先读:
|
||||||
|
|
||||||
|
- `AGENTS.md`
|
||||||
|
- `codex/ARCHITECTURE.md`
|
||||||
|
- `codex/20_导入与审计接手.md`
|
||||||
|
|
||||||
|
后续主要职责是:在你的 Win11 + MySQL 5.7.34 + CM93JAPAN 实际环境运行 pilot、分析失败 cell、修正真实兼容问题,并保留精度/审计边界。
|
||||||
96
V10_1_结果摘要_v10_2.json
Normal file
96
V10_1_结果摘要_v10_2.json
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
{
|
||||||
|
"version": "v10.2-result-digest",
|
||||||
|
"generated_at": "2026-09-01T08:32:55",
|
||||||
|
"input": "D:\\React\\cm93-mysql-importer-starter\\cm93_normalization_canonical_v10_1_dataset_1_20260831_215602",
|
||||||
|
"files_present": {
|
||||||
|
"summary": true,
|
||||||
|
"report": true,
|
||||||
|
"golden_csv": true,
|
||||||
|
"golden_json": true,
|
||||||
|
"variant": true,
|
||||||
|
"face_order": true,
|
||||||
|
"failures": true,
|
||||||
|
"bbox": true
|
||||||
|
},
|
||||||
|
"complete_core_set": true,
|
||||||
|
"checks": {
|
||||||
|
"report": {
|
||||||
|
"overall_pass": true,
|
||||||
|
"total_polygon_records": 82236,
|
||||||
|
"valid_polygon_records": 82181,
|
||||||
|
"invalid_polygon_records": 55,
|
||||||
|
"valid_policy_changed_count": 0,
|
||||||
|
"valid_hash_changed_count": 0,
|
||||||
|
"golden_vector_count": 55,
|
||||||
|
"golden_record_status_counts": {
|
||||||
|
"PASS": 55
|
||||||
|
},
|
||||||
|
"input_variant_case_count": 330,
|
||||||
|
"input_variant_status_counts": {
|
||||||
|
"PASS": 330
|
||||||
|
},
|
||||||
|
"face_order_case_count": 220,
|
||||||
|
"face_order_hash_mismatch_count": 0,
|
||||||
|
"canonical_topology_failure_count": 0,
|
||||||
|
"repeat_hash_failure_count": 0,
|
||||||
|
"input_variant_failure_record_count": 0,
|
||||||
|
"future_area_feature_bbox_update_needed_count": 26
|
||||||
|
},
|
||||||
|
"golden": {
|
||||||
|
"rows": 55,
|
||||||
|
"status_counts": {
|
||||||
|
"PASS": 55
|
||||||
|
},
|
||||||
|
"unique_hash_count": 45,
|
||||||
|
"missing_hash_count": 0,
|
||||||
|
"variant_failure_records": 0,
|
||||||
|
"face_order_unstable_records": 0,
|
||||||
|
"repeat_unstable_records": 0,
|
||||||
|
"canonical_topology_changed_records": 0
|
||||||
|
},
|
||||||
|
"variant": {
|
||||||
|
"rows": 330,
|
||||||
|
"status_counts": {
|
||||||
|
"PASS": 330
|
||||||
|
},
|
||||||
|
"fail_rows": 0,
|
||||||
|
"hash_mismatch_rows": 0,
|
||||||
|
"topology_mismatch_rows": 0
|
||||||
|
},
|
||||||
|
"face_order": {
|
||||||
|
"rows": 220,
|
||||||
|
"hash_mismatch_rows": 0
|
||||||
|
},
|
||||||
|
"failures": {
|
||||||
|
"rows": 0
|
||||||
|
},
|
||||||
|
"bbox": {
|
||||||
|
"rows": 55,
|
||||||
|
"bbox_changed_rows": 27,
|
||||||
|
"future_feature_bbox_update_needed_rows": 26,
|
||||||
|
"rows_with_connector": 41
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overall": "PASS",
|
||||||
|
"pass_conditions": [
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
}
|
||||||
64
V10_1_结果摘要_v10_2.txt
Normal file
64
V10_1_结果摘要_v10_2.txt
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
CM93 V10.1 结果汇总与完整性验证 v10.2
|
||||||
|
==============================================================================
|
||||||
|
Input: D:\React\cm93-mysql-importer-starter\cm93_normalization_canonical_v10_1_dataset_1_20260831_215602
|
||||||
|
Overall: PASS
|
||||||
|
|
||||||
|
FILES
|
||||||
|
summary YES normalization_canonical_summary_v10_1.txt
|
||||||
|
report YES normalization_canonical_report_v10_1.json
|
||||||
|
golden_csv YES normalization_golden_vectors_v10_1.csv
|
||||||
|
golden_json YES normalization_golden_vectors_v10_1.json
|
||||||
|
variant YES normalization_variant_results_v10_1.csv
|
||||||
|
face_order YES normalization_face_order_results_v10_1.csv
|
||||||
|
failures YES normalization_canonical_failures_v10_1.csv
|
||||||
|
bbox YES normalization_bbox_metadata_impact_v10_1.csv
|
||||||
|
|
||||||
|
REPORT
|
||||||
|
overall_pass: True
|
||||||
|
total_polygon_records: 82236
|
||||||
|
valid_polygon_records: 82181
|
||||||
|
invalid_polygon_records: 55
|
||||||
|
valid_policy_changed_count: 0
|
||||||
|
valid_hash_changed_count: 0
|
||||||
|
golden_vector_count: 55
|
||||||
|
golden_record_status_counts: {'PASS': 55}
|
||||||
|
input_variant_case_count: 330
|
||||||
|
input_variant_status_counts: {'PASS': 330}
|
||||||
|
face_order_case_count: 220
|
||||||
|
face_order_hash_mismatch_count: 0
|
||||||
|
canonical_topology_failure_count: 0
|
||||||
|
repeat_hash_failure_count: 0
|
||||||
|
input_variant_failure_record_count: 0
|
||||||
|
future_area_feature_bbox_update_needed_count: 26
|
||||||
|
|
||||||
|
GOLDEN
|
||||||
|
rows: 55
|
||||||
|
status_counts: {'PASS': 55}
|
||||||
|
unique_hash_count: 45
|
||||||
|
missing_hash_count: 0
|
||||||
|
variant_failure_records: 0
|
||||||
|
face_order_unstable_records: 0
|
||||||
|
repeat_unstable_records: 0
|
||||||
|
canonical_topology_changed_records: 0
|
||||||
|
|
||||||
|
VARIANT
|
||||||
|
rows: 330
|
||||||
|
status_counts: {'PASS': 330}
|
||||||
|
fail_rows: 0
|
||||||
|
hash_mismatch_rows: 0
|
||||||
|
topology_mismatch_rows: 0
|
||||||
|
|
||||||
|
FACE_ORDER
|
||||||
|
rows: 220
|
||||||
|
hash_mismatch_rows: 0
|
||||||
|
|
||||||
|
FAILURES
|
||||||
|
rows: 0
|
||||||
|
|
||||||
|
BBOX
|
||||||
|
rows: 55
|
||||||
|
bbox_changed_rows: 27
|
||||||
|
future_feature_bbox_update_needed_rows: 26
|
||||||
|
rows_with_connector: 41
|
||||||
|
|
||||||
|
PASS:V10.1 canonical normalization 全部门槛通过。
|
||||||
569
cm93_importer_normalization_source_audit_standalone_v1.py
Normal file
569
cm93_importer_normalization_source_audit_standalone_v1.py
Normal file
@@ -0,0 +1,569 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
CM93 Importer Normalization 独立源码审计
|
||||||
|
Version: standalone-v1.0
|
||||||
|
|
||||||
|
完全独立:
|
||||||
|
- 不读取 V10/V11/V12 历史结果
|
||||||
|
- 不连接 MySQL
|
||||||
|
- 不修改源码
|
||||||
|
- 不生成 patch
|
||||||
|
- 不开始 PBF
|
||||||
|
|
||||||
|
用途:
|
||||||
|
从当前 importer 项目源码零开始定位:
|
||||||
|
1. Area / Coverage geometry 构造
|
||||||
|
2. chart_area_geometry / chart_coverage / chart_feature 写库
|
||||||
|
3. geometry_issue / geometry missing
|
||||||
|
4. raw_geotype / geometry_type / META / AREA
|
||||||
|
5. geometry_invalid_count / geometry_repaired_count
|
||||||
|
6. bbox 计算与写入
|
||||||
|
7. Shapely / GEOS / Polygon / MultiPolygon / WKB
|
||||||
|
8. raw_feature_blob / chart_raw_record / source_record_index
|
||||||
|
9. import job / summary / counters
|
||||||
|
10. 可能的 normalization 接入点
|
||||||
|
|
||||||
|
运行:
|
||||||
|
python .\\cm93_importer_normalization_source_audit_standalone_v1.py `
|
||||||
|
--project-root "."
|
||||||
|
|
||||||
|
输出自动生成 ZIP。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
import argparse
|
||||||
|
import ast
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import zipfile
|
||||||
|
from collections import Counter, defaultdict
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
||||||
|
|
||||||
|
TEXT_EXTENSIONS = {
|
||||||
|
".py", ".sql", ".toml", ".ini", ".cfg", ".json",
|
||||||
|
".yaml", ".yml", ".md", ".txt", ".env", ".example",
|
||||||
|
}
|
||||||
|
|
||||||
|
SKIP_DIRS = {
|
||||||
|
".git", ".hg", ".svn", "__pycache__", ".pytest_cache",
|
||||||
|
".mypy_cache", ".ruff_cache", ".venv", "venv", "env",
|
||||||
|
"node_modules", "dist", "build", ".idea", ".vscode",
|
||||||
|
}
|
||||||
|
|
||||||
|
SEARCH_GROUPS = {
|
||||||
|
"GEOMETRY_BUILD": [
|
||||||
|
r"\bPolygon\s*\(", r"\bMultiPolygon\s*\(", r"\bLineString\s*\(",
|
||||||
|
r"\bPoint\s*\(", r"\bshapely\b", r"\bGEOS\b", r"\bwkb\b", r"\bgeom\b",
|
||||||
|
],
|
||||||
|
"NORMALIZATION": [
|
||||||
|
r"\bmake_valid\b", r"\bpolygonize_full\b", r"\bpolygonize\b",
|
||||||
|
r"\bunary_union\b", r"\bnormalize\b", r"\bis_valid\b",
|
||||||
|
r"\bexplain_validity\b", r"\bbuffer\s*\(\s*0",
|
||||||
|
],
|
||||||
|
"GEOMETRY_ISSUE": [
|
||||||
|
r"geometry_issue", r"geometry missing", r"geometry_missing",
|
||||||
|
r"invalid geometry", r"geometry_invalid", r"geometry_repaired",
|
||||||
|
],
|
||||||
|
"GEOTYPE": [
|
||||||
|
r"raw_geotype", r"\bgeotype\b", r"geometry_type",
|
||||||
|
r"\bMETA\b", r"\bAREA\b", r"\bSOUNDING\b", r"\bPOINT\b", r"\bLINE\b",
|
||||||
|
],
|
||||||
|
"BBOX": [
|
||||||
|
r"bbox_min_lon", r"bbox_min_lat", r"bbox_max_lon", r"bbox_max_lat",
|
||||||
|
r"\bbbox\b", r"\bbounds\b", r"min_lon", r"max_lon", r"min_lat", r"max_lat",
|
||||||
|
],
|
||||||
|
"COUNTERS": [
|
||||||
|
r"geometry_invalid_count", r"geometry_repaired_count",
|
||||||
|
r"invalid_count", r"repaired_count", r"summary_json",
|
||||||
|
r"chart_import_job", r"import_job",
|
||||||
|
],
|
||||||
|
"DB_AREA": [r"chart_area_geometry", r"area_geometry"],
|
||||||
|
"DB_COVERAGE": [r"chart_coverage", r"coverage.*geom"],
|
||||||
|
"DB_FEATURE": [r"chart_feature", r"feature_attribute"],
|
||||||
|
"DB_WRITE": [
|
||||||
|
r"\bINSERT\s+INTO\b", r"\bUPDATE\b", r"\bexecutemany\s*\(",
|
||||||
|
r"\bexecute\s*\(", r"\bcommit\s*\(", r"\bflush\s*\(", r"\badd\s*\(",
|
||||||
|
],
|
||||||
|
"RAW_SOURCE": [
|
||||||
|
r"raw_feature_blob", r"chart_raw_record", r"source_record_index",
|
||||||
|
r"raw_blob", r"raw_metadata_json", r"record_kind",
|
||||||
|
],
|
||||||
|
"TOPOLOGY": [
|
||||||
|
r"edge_index", r"segment_usage", r"vector_record", r"vector_table",
|
||||||
|
r"n_elements", r"\bring\b", r"\bcontour\b", r"\bedge\b", r"\bnode\b",
|
||||||
|
],
|
||||||
|
"SRID": [r"normalized_srid", r"\bSRID\b", r"\bsrid\b", r"4326"],
|
||||||
|
}
|
||||||
|
|
||||||
|
WEIGHTS = {
|
||||||
|
"GEOMETRY_BUILD": 5, "NORMALIZATION": 8, "GEOMETRY_ISSUE": 10,
|
||||||
|
"GEOTYPE": 7, "BBOX": 6, "COUNTERS": 9, "DB_AREA": 10,
|
||||||
|
"DB_COVERAGE": 10, "DB_FEATURE": 7, "DB_WRITE": 6,
|
||||||
|
"RAW_SOURCE": 5, "TOPOLOGY": 5, "SRID": 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def write_csv(path: Path, rows: Iterable[Dict[str, Any]]) -> None:
|
||||||
|
rows = list(rows)
|
||||||
|
keys: List[str] = []
|
||||||
|
seen = set()
|
||||||
|
for row in rows:
|
||||||
|
for key in row:
|
||||||
|
if key not in seen:
|
||||||
|
seen.add(key)
|
||||||
|
keys.append(key)
|
||||||
|
|
||||||
|
with path.open("w", encoding="utf-8-sig", newline="") as f:
|
||||||
|
if not keys:
|
||||||
|
return
|
||||||
|
w = csv.DictWriter(f, fieldnames=keys, extrasaction="ignore")
|
||||||
|
w.writeheader()
|
||||||
|
for row in rows:
|
||||||
|
out = {}
|
||||||
|
for key in keys:
|
||||||
|
val = row.get(key)
|
||||||
|
if isinstance(val, (dict, list, tuple)):
|
||||||
|
val = json.dumps(val, ensure_ascii=False, default=str)
|
||||||
|
out[key] = "" if val is None else val
|
||||||
|
w.writerow(out)
|
||||||
|
|
||||||
|
|
||||||
|
def safe_text(path: Path) -> Optional[str]:
|
||||||
|
for enc in ("utf-8", "utf-8-sig", "cp932", "latin-1"):
|
||||||
|
try:
|
||||||
|
return path.read_text(encoding=enc)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def should_skip(path: Path) -> bool:
|
||||||
|
return any(part in SKIP_DIRS for part in path.parts)
|
||||||
|
|
||||||
|
|
||||||
|
def rel(path: Path, root: Path) -> str:
|
||||||
|
try:
|
||||||
|
return str(path.resolve().relative_to(root.resolve()))
|
||||||
|
except Exception:
|
||||||
|
return str(path)
|
||||||
|
|
||||||
|
|
||||||
|
def list_files(root: Path, outdir: Path) -> List[Path]:
|
||||||
|
result = []
|
||||||
|
for p in root.rglob("*"):
|
||||||
|
if not p.is_file() or should_skip(p):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
p.resolve().relative_to(outdir.resolve())
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if p.suffix.lower() in TEXT_EXTENSIONS:
|
||||||
|
result.append(p)
|
||||||
|
return sorted(result)
|
||||||
|
|
||||||
|
|
||||||
|
def context(lines: List[str], line_no: int, radius: int = 3) -> str:
|
||||||
|
start = max(1, line_no - radius)
|
||||||
|
end = min(len(lines), line_no + radius)
|
||||||
|
return "\n".join(f"{i:05d}: {lines[i-1]}" for i in range(start, end + 1))
|
||||||
|
|
||||||
|
|
||||||
|
def scan_hits(path: Path, text: str, root: Path) -> List[Dict[str, Any]]:
|
||||||
|
rows = []
|
||||||
|
lines = text.splitlines()
|
||||||
|
compiled = {
|
||||||
|
group: [re.compile(p, re.I) for p in pats]
|
||||||
|
for group, pats in SEARCH_GROUPS.items()
|
||||||
|
}
|
||||||
|
for no, line in enumerate(lines, 1):
|
||||||
|
for group, pats in compiled.items():
|
||||||
|
matched = [p.pattern for p in pats if p.search(line)]
|
||||||
|
if matched:
|
||||||
|
rows.append({
|
||||||
|
"file": rel(path, root),
|
||||||
|
"line": no,
|
||||||
|
"group": group,
|
||||||
|
"matched_patterns": matched,
|
||||||
|
"code": line.strip(),
|
||||||
|
"context": context(lines, no),
|
||||||
|
})
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def ast_inventory(path: Path, text: str, root: Path):
|
||||||
|
funcs, classes, deps, sqls = [], [], [], []
|
||||||
|
if path.suffix.lower() != ".py":
|
||||||
|
return funcs, classes, deps, sqls
|
||||||
|
try:
|
||||||
|
tree = ast.parse(text)
|
||||||
|
except Exception:
|
||||||
|
return funcs, classes, deps, sqls
|
||||||
|
|
||||||
|
class V(ast.NodeVisitor):
|
||||||
|
def __init__(self):
|
||||||
|
self.cls: List[str] = []
|
||||||
|
self.fn: List[str] = []
|
||||||
|
|
||||||
|
def visit_ClassDef(self, node):
|
||||||
|
classes.append({
|
||||||
|
"file": rel(path, root),
|
||||||
|
"class_name": node.name,
|
||||||
|
"line": node.lineno,
|
||||||
|
"end_line": getattr(node, "end_lineno", None),
|
||||||
|
})
|
||||||
|
self.cls.append(node.name)
|
||||||
|
self.generic_visit(node)
|
||||||
|
self.cls.pop()
|
||||||
|
|
||||||
|
def visit_FunctionDef(self, node):
|
||||||
|
funcs.append({
|
||||||
|
"file": rel(path, root),
|
||||||
|
"class_name": ".".join(self.cls),
|
||||||
|
"function_name": node.name,
|
||||||
|
"line": node.lineno,
|
||||||
|
"end_line": getattr(node, "end_lineno", None),
|
||||||
|
"async": 0,
|
||||||
|
"args": [a.arg for a in node.args.args],
|
||||||
|
})
|
||||||
|
self.fn.append(node.name)
|
||||||
|
self.generic_visit(node)
|
||||||
|
self.fn.pop()
|
||||||
|
|
||||||
|
def visit_AsyncFunctionDef(self, node):
|
||||||
|
funcs.append({
|
||||||
|
"file": rel(path, root),
|
||||||
|
"class_name": ".".join(self.cls),
|
||||||
|
"function_name": node.name,
|
||||||
|
"line": node.lineno,
|
||||||
|
"end_line": getattr(node, "end_lineno", None),
|
||||||
|
"async": 1,
|
||||||
|
"args": [a.arg for a in node.args.args],
|
||||||
|
})
|
||||||
|
self.fn.append(node.name)
|
||||||
|
self.generic_visit(node)
|
||||||
|
self.fn.pop()
|
||||||
|
|
||||||
|
def visit_Import(self, node):
|
||||||
|
for a in node.names:
|
||||||
|
deps.append({
|
||||||
|
"file": rel(path, root), "line": node.lineno,
|
||||||
|
"type": "import", "module": a.name, "names": "",
|
||||||
|
})
|
||||||
|
|
||||||
|
def visit_ImportFrom(self, node):
|
||||||
|
deps.append({
|
||||||
|
"file": rel(path, root), "line": node.lineno,
|
||||||
|
"type": "from", "module": node.module or "",
|
||||||
|
"names": [x.name for x in node.names],
|
||||||
|
})
|
||||||
|
|
||||||
|
def visit_Constant(self, node):
|
||||||
|
if isinstance(node.value, str) and re.search(
|
||||||
|
r"\b(SELECT|INSERT\s+INTO|UPDATE|DELETE\s+FROM|CREATE\s+TABLE|ALTER\s+TABLE)\b",
|
||||||
|
node.value, re.I
|
||||||
|
):
|
||||||
|
sqls.append({
|
||||||
|
"file": rel(path, root),
|
||||||
|
"line": getattr(node, "lineno", None),
|
||||||
|
"class_name": ".".join(self.cls),
|
||||||
|
"function_name": ".".join(self.fn),
|
||||||
|
"sql": node.value.strip(),
|
||||||
|
})
|
||||||
|
|
||||||
|
V().visit(tree)
|
||||||
|
return funcs, classes, deps, sqls
|
||||||
|
|
||||||
|
|
||||||
|
def score_file(path: Path, hits: List[Dict[str, Any]], root: Path) -> Dict[str, Any]:
|
||||||
|
c = Counter(x["group"] for x in hits)
|
||||||
|
score = sum(WEIGHTS.get(g, 1) * min(n, 15) for g, n in c.items())
|
||||||
|
name = rel(path, root).lower()
|
||||||
|
for token, bonus in (
|
||||||
|
("import", 10), ("cm93", 10), ("geom", 7), ("coverage", 6),
|
||||||
|
("feature", 5), ("parser", 5), ("decoder", 5), ("mysql", 4),
|
||||||
|
("database", 4), ("repository", 3), ("model", 2),
|
||||||
|
):
|
||||||
|
if token in name:
|
||||||
|
score += bonus
|
||||||
|
row = {"file": rel(path, root), "score": score, "hit_count": len(hits)}
|
||||||
|
for g in SEARCH_GROUPS:
|
||||||
|
row[f"hits_{g.lower()}"] = c.get(g, 0)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def derive_points(hits: List[Dict[str, Any]]) -> Dict[str, List[Dict[str, Any]]]:
|
||||||
|
buckets = {
|
||||||
|
"area_geometry_build": [], "coverage_geometry_build": [],
|
||||||
|
"area_geometry_write": [], "coverage_geometry_write": [],
|
||||||
|
"feature_write": [], "geometry_issue_logic": [],
|
||||||
|
"geotype_logic": [], "invalid_counter_logic": [],
|
||||||
|
"repaired_counter_logic": [], "bbox_logic": [],
|
||||||
|
"raw_source_preservation": [], "srid_logic": [],
|
||||||
|
"normalization_existing": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
for r in hits:
|
||||||
|
code = r["code"].lower()
|
||||||
|
grp = r["group"]
|
||||||
|
|
||||||
|
if grp == "GEOMETRY_BUILD":
|
||||||
|
if "coverage" in code or "coverage" in r["file"].lower():
|
||||||
|
buckets["coverage_geometry_build"].append(r)
|
||||||
|
else:
|
||||||
|
buckets["area_geometry_build"].append(r)
|
||||||
|
|
||||||
|
if "chart_area_geometry" in code or "area_geometry" in code:
|
||||||
|
buckets["area_geometry_write"].append(r)
|
||||||
|
if "chart_coverage" in code:
|
||||||
|
buckets["coverage_geometry_write"].append(r)
|
||||||
|
if "chart_feature" in code and grp in {"DB_FEATURE", "DB_WRITE"}:
|
||||||
|
buckets["feature_write"].append(r)
|
||||||
|
if grp == "GEOMETRY_ISSUE":
|
||||||
|
buckets["geometry_issue_logic"].append(r)
|
||||||
|
if grp == "GEOTYPE" or "geometry missing" in code:
|
||||||
|
buckets["geotype_logic"].append(r)
|
||||||
|
if "geometry_invalid_count" in code:
|
||||||
|
buckets["invalid_counter_logic"].append(r)
|
||||||
|
if "geometry_repaired_count" in code:
|
||||||
|
buckets["repaired_counter_logic"].append(r)
|
||||||
|
if grp == "BBOX":
|
||||||
|
buckets["bbox_logic"].append(r)
|
||||||
|
if grp == "RAW_SOURCE":
|
||||||
|
buckets["raw_source_preservation"].append(r)
|
||||||
|
if grp == "SRID":
|
||||||
|
buckets["srid_logic"].append(r)
|
||||||
|
if grp == "NORMALIZATION":
|
||||||
|
buckets["normalization_existing"].append(r)
|
||||||
|
|
||||||
|
for key, rows in buckets.items():
|
||||||
|
uniq = {(r["file"], int(r["line"])): r for r in rows}
|
||||||
|
buckets[key] = sorted(
|
||||||
|
uniq.values(),
|
||||||
|
key=lambda x: (x["file"], int(x["line"]))
|
||||||
|
)[:60]
|
||||||
|
|
||||||
|
return buckets
|
||||||
|
|
||||||
|
|
||||||
|
def build_md(candidates, points) -> str:
|
||||||
|
titles = {
|
||||||
|
"area_geometry_build": "Area geometry 构造",
|
||||||
|
"coverage_geometry_build": "Coverage geometry 构造",
|
||||||
|
"area_geometry_write": "Area geometry 写库",
|
||||||
|
"coverage_geometry_write": "Coverage geometry 写库",
|
||||||
|
"feature_write": "Feature 写库",
|
||||||
|
"geometry_issue_logic": "geometry_issue 判定",
|
||||||
|
"geotype_logic": "raw_geotype / META / geometry missing",
|
||||||
|
"invalid_counter_logic": "geometry_invalid_count",
|
||||||
|
"repaired_counter_logic": "geometry_repaired_count",
|
||||||
|
"bbox_logic": "bbox 计算与写入",
|
||||||
|
"raw_source_preservation": "raw source 保留",
|
||||||
|
"srid_logic": "SRID / normalized_srid",
|
||||||
|
"normalization_existing": "现有 validity / normalization",
|
||||||
|
}
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
"# CM93 Importer Normalization 独立源码审计 v1",
|
||||||
|
"",
|
||||||
|
"本报告仅依据当前项目源码,不依赖任何历史测试结果。",
|
||||||
|
"",
|
||||||
|
"## 高优先级候选文件",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
for r in candidates[:20]:
|
||||||
|
lines.append(f"- `{r['file']}` — score={r['score']}, hits={r['hit_count']}")
|
||||||
|
|
||||||
|
for key, title in titles.items():
|
||||||
|
lines += ["", f"## {title}", ""]
|
||||||
|
rows = points.get(key, [])
|
||||||
|
if not rows:
|
||||||
|
lines.append("- 未自动定位。")
|
||||||
|
else:
|
||||||
|
for r in rows[:30]:
|
||||||
|
lines.append(f"- `{r['file']}:{r['line']}` — `{r['code']}`")
|
||||||
|
|
||||||
|
lines += [
|
||||||
|
"",
|
||||||
|
"## 下一阶段人工确认重点",
|
||||||
|
"",
|
||||||
|
"1. 确认 Area geometry 的唯一最终构造入口。",
|
||||||
|
"2. 确认 Coverage geometry 的唯一最终构造入口。",
|
||||||
|
"3. 确认 validity 检查发生在构造后、写库前还是写库后。",
|
||||||
|
"4. 确认 raw_geotype / META 为什么会进入 geometry missing。",
|
||||||
|
"5. 确认 geometry_invalid_count 的实际累计条件。",
|
||||||
|
"6. 确认 bbox 来自 raw topology 还是最终 geometry。",
|
||||||
|
"7. 确认数据库写入点对 Polygon/MultiPolygon 的处理方式。",
|
||||||
|
"8. 确认 raw_feature_blob / chart_raw_record 始终保留。",
|
||||||
|
"",
|
||||||
|
"本轮不修改源码,不修改数据库,不进入 PBF。",
|
||||||
|
]
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args():
|
||||||
|
p = argparse.ArgumentParser(description="CM93 importer 独立源码审计")
|
||||||
|
p.add_argument("--project-root", default=".")
|
||||||
|
p.add_argument("--output", default=None)
|
||||||
|
return p.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = parse_args()
|
||||||
|
root = Path(args.project_root).expanduser().resolve()
|
||||||
|
if not root.is_dir():
|
||||||
|
print(f"项目目录不存在:{root}")
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
outdir = Path(
|
||||||
|
args.output or root / f"cm93_importer_source_audit_standalone_v1_{stamp}"
|
||||||
|
).expanduser().resolve()
|
||||||
|
outdir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
print("[1/6] 枚举源码文件 ...")
|
||||||
|
files = list_files(root, outdir)
|
||||||
|
print(f" files={len(files)}")
|
||||||
|
|
||||||
|
file_rows, hits = [], []
|
||||||
|
funcs, classes, deps, ast_sql = [], [], [], []
|
||||||
|
candidates = []
|
||||||
|
|
||||||
|
print("[2/6] 关键字 + AST 扫描 ...")
|
||||||
|
for i, path in enumerate(files, 1):
|
||||||
|
text = safe_text(path)
|
||||||
|
file_rows.append({
|
||||||
|
"file": rel(path, root),
|
||||||
|
"suffix": path.suffix.lower(),
|
||||||
|
"size_bytes": path.stat().st_size,
|
||||||
|
"readable_text": int(text is not None),
|
||||||
|
})
|
||||||
|
if text is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
h = scan_hits(path, text, root)
|
||||||
|
hits.extend(h)
|
||||||
|
f, c, d, s = ast_inventory(path, text, root)
|
||||||
|
funcs.extend(f)
|
||||||
|
classes.extend(c)
|
||||||
|
deps.extend(d)
|
||||||
|
ast_sql.extend(s)
|
||||||
|
if h:
|
||||||
|
candidates.append(score_file(path, h, root))
|
||||||
|
|
||||||
|
if i % 100 == 0:
|
||||||
|
print(f" scanned={i}/{len(files)}")
|
||||||
|
|
||||||
|
candidates.sort(key=lambda r: (-int(r["score"]), r["file"]))
|
||||||
|
|
||||||
|
print("[3/6] 归类 SQL 与接入点 ...")
|
||||||
|
sql_hits = [
|
||||||
|
r for r in hits
|
||||||
|
if r["group"] in {"DB_AREA", "DB_COVERAGE", "DB_FEATURE", "DB_WRITE"}
|
||||||
|
or re.search(r"\b(SELECT|INSERT|UPDATE|DELETE|CREATE|ALTER)\b", r["code"], re.I)
|
||||||
|
]
|
||||||
|
for r in ast_sql:
|
||||||
|
sql_hits.append({
|
||||||
|
"file": r["file"], "line": r["line"],
|
||||||
|
"group": "AST_SQL_LITERAL", "code": r["sql"],
|
||||||
|
"context": r["sql"], "class_name": r.get("class_name"),
|
||||||
|
"function_name": r.get("function_name"),
|
||||||
|
})
|
||||||
|
|
||||||
|
points = derive_points(hits)
|
||||||
|
|
||||||
|
print("[4/6] 写结构化输出 ...")
|
||||||
|
write_csv(outdir / "项目文件清单_v1.csv", file_rows)
|
||||||
|
write_csv(outdir / "候选源码文件_v1.csv", candidates)
|
||||||
|
write_csv(outdir / "关键代码命中_v1.csv", hits)
|
||||||
|
write_csv(outdir / "SQL命中_v1.csv", sql_hits)
|
||||||
|
write_csv(outdir / "Python函数清单_v1.csv", funcs)
|
||||||
|
write_csv(outdir / "Python类清单_v1.csv", classes)
|
||||||
|
write_csv(outdir / "Python依赖清单_v1.csv", deps)
|
||||||
|
|
||||||
|
(outdir / "normalization接入点_v1.md").write_text(
|
||||||
|
build_md(candidates, points), encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
print("[5/6] 写摘要 ...")
|
||||||
|
group_counts = Counter(r["group"] for r in hits)
|
||||||
|
report = {
|
||||||
|
"version": "standalone-v1.0",
|
||||||
|
"generated_at": datetime.now().isoformat(timespec="seconds"),
|
||||||
|
"project_root": str(root),
|
||||||
|
"history_dependency": False,
|
||||||
|
"database_dependency": False,
|
||||||
|
"read_only": True,
|
||||||
|
"pbf": "OUT_OF_SCOPE",
|
||||||
|
"source_file_count": len(files),
|
||||||
|
"candidate_file_count": len(candidates),
|
||||||
|
"code_hit_count": len(hits),
|
||||||
|
"sql_hit_count": len(sql_hits),
|
||||||
|
"function_count": len(funcs),
|
||||||
|
"class_count": len(classes),
|
||||||
|
"dependency_count": len(deps),
|
||||||
|
"hit_group_counts": dict(group_counts),
|
||||||
|
"integration_point_counts": {k: len(v) for k, v in points.items()},
|
||||||
|
"top_candidate_files": candidates[:25],
|
||||||
|
}
|
||||||
|
|
||||||
|
(outdir / "独立源码审计报告_v1.json").write_text(
|
||||||
|
json.dumps(report, ensure_ascii=False, indent=2, default=str),
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
"CM93 Importer Normalization 独立源码审计 v1",
|
||||||
|
"=" * 88,
|
||||||
|
f"Generated: {report['generated_at']}",
|
||||||
|
f"Project: {root}",
|
||||||
|
"",
|
||||||
|
"DEPENDENCY",
|
||||||
|
" 历史测试依赖: NO",
|
||||||
|
" 数据库依赖: NO",
|
||||||
|
" 源码修改: NO",
|
||||||
|
" PBF: OUT OF SCOPE",
|
||||||
|
"",
|
||||||
|
"SCAN",
|
||||||
|
f" source files: {len(files)}",
|
||||||
|
f" candidate files: {len(candidates)}",
|
||||||
|
f" code hits: {len(hits)}",
|
||||||
|
f" SQL hits: {len(sql_hits)}",
|
||||||
|
f" Python functions: {len(funcs)}",
|
||||||
|
f" Python classes: {len(classes)}",
|
||||||
|
"",
|
||||||
|
"TOP CANDIDATE FILES",
|
||||||
|
]
|
||||||
|
for r in candidates[:15]:
|
||||||
|
lines.append(f" {int(r['score']):4d} {r['file']}")
|
||||||
|
lines += [
|
||||||
|
"",
|
||||||
|
"SOURCE AUDIT COMPLETE: YES",
|
||||||
|
"NO SOURCE WRITE / NO DATABASE WRITE / NO PBF",
|
||||||
|
]
|
||||||
|
|
||||||
|
(outdir / "独立源码审计摘要_v1.txt").write_text(
|
||||||
|
"\n".join(lines), encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
print("[6/6] 压缩 ...")
|
||||||
|
zpath = outdir.with_suffix(".zip")
|
||||||
|
with zipfile.ZipFile(zpath, "w", compression=zipfile.ZIP_DEFLATED) as z:
|
||||||
|
for p in sorted(outdir.rglob("*")):
|
||||||
|
if p.is_file():
|
||||||
|
z.write(p, p.relative_to(outdir.parent))
|
||||||
|
|
||||||
|
print("")
|
||||||
|
print("SOURCE AUDIT COMPLETE: YES")
|
||||||
|
print(f"Output: {outdir}")
|
||||||
|
print(f"ZIP: {zpath}")
|
||||||
|
print("把 ZIP 上传给 ChatGPT。")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
880
cm93_importer_normalization_source_audit_v12.py
Normal file
880
cm93_importer_normalization_source_audit_v12.py
Normal file
@@ -0,0 +1,880 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
CM93 Importer Normalization 接入点只读调查
|
||||||
|
Version: v12.0
|
||||||
|
|
||||||
|
用途
|
||||||
|
====
|
||||||
|
V12 不修改数据库,也不修改源码。
|
||||||
|
|
||||||
|
它只做两件事:
|
||||||
|
|
||||||
|
Gate A:
|
||||||
|
读取 V11 输出目录或 ZIP,要求:
|
||||||
|
IMPLEMENTATION PRECHECK PASS = YES
|
||||||
|
或 report JSON 中:
|
||||||
|
summary.implementation_precheck_pass = true
|
||||||
|
|
||||||
|
Gate B:
|
||||||
|
只读扫描当前 importer 项目源码,自动定位:
|
||||||
|
|
||||||
|
1. CM93 Feature / Area geometry 构造入口
|
||||||
|
2. chart_area_geometry / chart_coverage 写入位置
|
||||||
|
3. geometry_issue = "geometry missing" 或同义逻辑
|
||||||
|
4. geometry_invalid_count / geometry_repaired_count 更新位置
|
||||||
|
5. raw_geotype / geometry_type 判定位置
|
||||||
|
6. bbox_min_lon / bbox_min_lat / bbox_max_lon / bbox_max_lat 计算位置
|
||||||
|
7. Shapely / GEOS / WKB / Polygon / MultiPolygon 相关代码
|
||||||
|
8. chart_feature / chart_area_geometry / chart_coverage SQL 写入语句
|
||||||
|
9. raw_feature_blob / chart_raw_record 保留逻辑
|
||||||
|
10. importer job summary / counters
|
||||||
|
|
||||||
|
输出的目标是让下一阶段可以基于当前真实源码生成精准修改版,
|
||||||
|
而不是凭文件名或架构猜测。
|
||||||
|
|
||||||
|
重要:
|
||||||
|
- 不修改项目文件
|
||||||
|
- 不修改 MySQL
|
||||||
|
- 不开始 PBF
|
||||||
|
- 不生成自动 apply patch
|
||||||
|
- 只输出调查报告和代码片段
|
||||||
|
|
||||||
|
执行
|
||||||
|
====
|
||||||
|
假设当前就在 importer 项目根目录:
|
||||||
|
|
||||||
|
python .\\cm93_importer_normalization_source_audit_v12.py ^
|
||||||
|
--v11-input ".\\cm93_normalization_implementation_precheck_v11_dataset_1_20260901_084147" ^
|
||||||
|
--project-root "."
|
||||||
|
|
||||||
|
也可以给 V11 ZIP:
|
||||||
|
|
||||||
|
python .\\cm93_importer_normalization_source_audit_v12.py ^
|
||||||
|
--v11-input ".\\cm93_normalization_implementation_precheck_v11_dataset_1_20260901_084147.zip" ^
|
||||||
|
--project-root "."
|
||||||
|
|
||||||
|
输出
|
||||||
|
====
|
||||||
|
- importer_source_audit_summary_v12.txt
|
||||||
|
- importer_source_audit_report_v12.json
|
||||||
|
- importer_candidate_files_v12.csv
|
||||||
|
- importer_code_hits_v12.csv
|
||||||
|
- importer_sql_hits_v12.csv
|
||||||
|
- importer_function_inventory_v12.csv
|
||||||
|
- importer_dependency_inventory_v12.csv
|
||||||
|
- importer_normalization_integration_points_v12.md
|
||||||
|
- importer_source_audit_v12.zip
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import ast
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
from collections import Counter, defaultdict
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Configuration
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
TEXT_EXTENSIONS = {
|
||||||
|
".py", ".sql", ".toml", ".ini", ".cfg", ".json",
|
||||||
|
".yaml", ".yml", ".md", ".txt",
|
||||||
|
}
|
||||||
|
|
||||||
|
SKIP_DIRS = {
|
||||||
|
".git", ".hg", ".svn",
|
||||||
|
"__pycache__", ".pytest_cache", ".mypy_cache",
|
||||||
|
".venv", "venv", "env",
|
||||||
|
"node_modules", "dist", "build",
|
||||||
|
".idea", ".vscode",
|
||||||
|
}
|
||||||
|
|
||||||
|
SEARCH_GROUPS = {
|
||||||
|
"NORMALIZATION": [
|
||||||
|
r"\bmake_valid\b",
|
||||||
|
r"\bpolygonize_full\b",
|
||||||
|
r"\bunary_union\b",
|
||||||
|
r"\bnormalize\b",
|
||||||
|
r"\bMultiPolygon\b",
|
||||||
|
r"\bPolygon\b",
|
||||||
|
r"\bShapely\b",
|
||||||
|
r"\bshapely\b",
|
||||||
|
r"\bWKB\b",
|
||||||
|
r"\bwkb\b",
|
||||||
|
],
|
||||||
|
"GEOMETRY_ISSUE": [
|
||||||
|
r"geometry_issue",
|
||||||
|
r"geometry missing",
|
||||||
|
r"geometry_missing",
|
||||||
|
r"invalid geometry",
|
||||||
|
r"geometry_invalid",
|
||||||
|
r"geometry_repaired",
|
||||||
|
],
|
||||||
|
"GEOTYPE": [
|
||||||
|
r"raw_geotype",
|
||||||
|
r"geotype",
|
||||||
|
r"geometry_type",
|
||||||
|
r"\bMETA\b",
|
||||||
|
r"\bAREA\b",
|
||||||
|
],
|
||||||
|
"BBOX": [
|
||||||
|
r"bbox_min_lon",
|
||||||
|
r"bbox_min_lat",
|
||||||
|
r"bbox_max_lon",
|
||||||
|
r"bbox_max_lat",
|
||||||
|
r"\bbounds\b",
|
||||||
|
r"\bbbox\b",
|
||||||
|
],
|
||||||
|
"COUNTERS": [
|
||||||
|
r"geometry_invalid_count",
|
||||||
|
r"geometry_repaired_count",
|
||||||
|
r"invalid_count",
|
||||||
|
r"repaired_count",
|
||||||
|
r"summary_json",
|
||||||
|
r"chart_import_job",
|
||||||
|
],
|
||||||
|
"DB_WRITE": [
|
||||||
|
r"chart_area_geometry",
|
||||||
|
r"chart_coverage",
|
||||||
|
r"chart_feature",
|
||||||
|
r"INSERT\s+INTO",
|
||||||
|
r"UPDATE\s+",
|
||||||
|
r"executemany",
|
||||||
|
r"execute\(",
|
||||||
|
],
|
||||||
|
"RAW_SOURCE": [
|
||||||
|
r"raw_feature_blob",
|
||||||
|
r"chart_raw_record",
|
||||||
|
r"source_record_index",
|
||||||
|
r"raw_blob",
|
||||||
|
r"raw_metadata_json",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def write_csv(
|
||||||
|
path: Path,
|
||||||
|
rows: Iterable[Dict[str, Any]],
|
||||||
|
fieldnames: Optional[List[str]] = None,
|
||||||
|
):
|
||||||
|
rows = list(rows)
|
||||||
|
|
||||||
|
if fieldnames is None:
|
||||||
|
seen = set()
|
||||||
|
fieldnames = []
|
||||||
|
for row in rows:
|
||||||
|
for k in row.keys():
|
||||||
|
if k not in seen:
|
||||||
|
seen.add(k)
|
||||||
|
fieldnames.append(k)
|
||||||
|
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
with path.open("w", encoding="utf-8-sig", newline="") as f:
|
||||||
|
if not fieldnames:
|
||||||
|
return
|
||||||
|
w = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore")
|
||||||
|
w.writeheader()
|
||||||
|
for row in rows:
|
||||||
|
out = {}
|
||||||
|
for k in fieldnames:
|
||||||
|
v = row.get(k)
|
||||||
|
if isinstance(v, (dict, list, tuple)):
|
||||||
|
v = json.dumps(v, ensure_ascii=False, default=str)
|
||||||
|
out[k] = "" if v is None else v
|
||||||
|
w.writerow(out)
|
||||||
|
|
||||||
|
|
||||||
|
def safe_read_text(path: Path) -> Optional[str]:
|
||||||
|
for enc in ("utf-8", "utf-8-sig", "cp932", "latin-1"):
|
||||||
|
try:
|
||||||
|
return path.read_text(encoding=enc)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def relpath(path: Path, root: Path) -> str:
|
||||||
|
try:
|
||||||
|
return str(path.resolve().relative_to(root.resolve()))
|
||||||
|
except Exception:
|
||||||
|
return str(path)
|
||||||
|
|
||||||
|
|
||||||
|
def should_skip(path: Path) -> bool:
|
||||||
|
return any(part in SKIP_DIRS for part in path.parts)
|
||||||
|
|
||||||
|
|
||||||
|
def list_source_files(root: Path) -> List[Path]:
|
||||||
|
out = []
|
||||||
|
for p in root.rglob("*"):
|
||||||
|
if not p.is_file():
|
||||||
|
continue
|
||||||
|
if should_skip(p):
|
||||||
|
continue
|
||||||
|
if p.suffix.lower() in TEXT_EXTENSIONS:
|
||||||
|
out.append(p)
|
||||||
|
return sorted(out)
|
||||||
|
|
||||||
|
|
||||||
|
def open_input(src: Path) -> Tuple[Path, Optional[Path]]:
|
||||||
|
if src.is_dir():
|
||||||
|
return src, None
|
||||||
|
|
||||||
|
if src.is_file() and src.suffix.lower() == ".zip":
|
||||||
|
temp = Path(tempfile.mkdtemp(prefix="cm93_v12_v11_"))
|
||||||
|
with zipfile.ZipFile(src, "r") as z:
|
||||||
|
z.extractall(temp)
|
||||||
|
return temp, temp
|
||||||
|
|
||||||
|
raise ValueError("V11 输入必须是目录或 ZIP")
|
||||||
|
|
||||||
|
|
||||||
|
def find_file(root: Path, filename: str) -> Optional[Path]:
|
||||||
|
direct = root / filename
|
||||||
|
if direct.exists():
|
||||||
|
return direct
|
||||||
|
hits = list(root.rglob(filename))
|
||||||
|
return hits[0] if hits else None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# V11 gate
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def load_v11_gate(root: Path) -> Dict[str, Any]:
|
||||||
|
result = {
|
||||||
|
"source": None,
|
||||||
|
"implementation_precheck_pass": False,
|
||||||
|
"db_invalid_count": None,
|
||||||
|
"golden_revalidation_pass_count": None,
|
||||||
|
"golden_revalidation_fail_count": None,
|
||||||
|
"area_geometry_column_type": None,
|
||||||
|
"coverage_geometry_column_type": None,
|
||||||
|
"future_area_bbox_update_needed_count": None,
|
||||||
|
"raw_geotype16_meta_count": None,
|
||||||
|
"raw_geotype16_with_issue_count": None,
|
||||||
|
"total_feature_geometry_issue_count": None,
|
||||||
|
"import_job_geometry_invalid_sum": None,
|
||||||
|
"errors": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
report_path = find_file(root, "implementation_precheck_report_v11.json")
|
||||||
|
summary_path = find_file(root, "implementation_precheck_summary_v11.txt")
|
||||||
|
|
||||||
|
if report_path:
|
||||||
|
report = json.loads(report_path.read_text(encoding="utf-8"))
|
||||||
|
s = report.get("summary", {})
|
||||||
|
result["source"] = "implementation_precheck_report_v11.json"
|
||||||
|
result["implementation_precheck_pass"] = bool(
|
||||||
|
s.get("implementation_precheck_pass")
|
||||||
|
)
|
||||||
|
for key in (
|
||||||
|
"db_invalid_count",
|
||||||
|
"golden_revalidation_pass_count",
|
||||||
|
"golden_revalidation_fail_count",
|
||||||
|
"area_geometry_column_type",
|
||||||
|
"coverage_geometry_column_type",
|
||||||
|
"future_area_bbox_update_needed_count",
|
||||||
|
"raw_geotype16_meta_count",
|
||||||
|
"raw_geotype16_with_issue_count",
|
||||||
|
"total_feature_geometry_issue_count",
|
||||||
|
"import_job_geometry_invalid_sum",
|
||||||
|
):
|
||||||
|
result[key] = s.get(key)
|
||||||
|
|
||||||
|
elif summary_path:
|
||||||
|
text = summary_path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
result["source"] = "implementation_precheck_summary_v11.txt"
|
||||||
|
result["implementation_precheck_pass"] = bool(
|
||||||
|
re.search(
|
||||||
|
r"IMPLEMENTATION\s+PRECHECK\s+PASS:\s*YES",
|
||||||
|
text,
|
||||||
|
flags=re.I,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
result["errors"].append(
|
||||||
|
"找不到 implementation_precheck_report_v11.json "
|
||||||
|
"或 implementation_precheck_summary_v11.txt"
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Source analysis
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def scan_hits(
|
||||||
|
path: Path,
|
||||||
|
text: str,
|
||||||
|
root: Path,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
rows = []
|
||||||
|
lines = text.splitlines()
|
||||||
|
|
||||||
|
compiled = {
|
||||||
|
group: [re.compile(pat, re.I) for pat in patterns]
|
||||||
|
for group, patterns in SEARCH_GROUPS.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
for lineno, line in enumerate(lines, start=1):
|
||||||
|
stripped = line.strip()
|
||||||
|
|
||||||
|
for group, patterns in compiled.items():
|
||||||
|
matched = []
|
||||||
|
for pat in patterns:
|
||||||
|
if pat.search(line):
|
||||||
|
matched.append(pat.pattern)
|
||||||
|
|
||||||
|
if not matched:
|
||||||
|
continue
|
||||||
|
|
||||||
|
start = max(1, lineno - 2)
|
||||||
|
end = min(len(lines), lineno + 2)
|
||||||
|
context = "\n".join(
|
||||||
|
f"{n:05d}: {lines[n-1]}"
|
||||||
|
for n in range(start, end + 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
rows.append({
|
||||||
|
"file": relpath(path, root),
|
||||||
|
"line": lineno,
|
||||||
|
"group": group,
|
||||||
|
"matched_patterns": matched,
|
||||||
|
"code": stripped,
|
||||||
|
"context": context,
|
||||||
|
})
|
||||||
|
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def python_function_inventory(
|
||||||
|
path: Path,
|
||||||
|
text: str,
|
||||||
|
root: Path,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
if path.suffix.lower() != ".py":
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
tree = ast.parse(text)
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||||
|
rows.append({
|
||||||
|
"file": relpath(path, root),
|
||||||
|
"name": node.name,
|
||||||
|
"line": node.lineno,
|
||||||
|
"end_line": getattr(node, "end_lineno", None),
|
||||||
|
"async": int(isinstance(node, ast.AsyncFunctionDef)),
|
||||||
|
"args": [
|
||||||
|
arg.arg
|
||||||
|
for arg in (
|
||||||
|
list(node.args.posonlyargs)
|
||||||
|
+ list(node.args.args)
|
||||||
|
+ list(node.args.kwonlyargs)
|
||||||
|
)
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def python_dependency_inventory(
|
||||||
|
path: Path,
|
||||||
|
text: str,
|
||||||
|
root: Path,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
if path.suffix.lower() != ".py":
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
tree = ast.parse(text)
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if isinstance(node, ast.Import):
|
||||||
|
for alias in node.names:
|
||||||
|
rows.append({
|
||||||
|
"file": relpath(path, root),
|
||||||
|
"line": node.lineno,
|
||||||
|
"module": alias.name,
|
||||||
|
"import_type": "import",
|
||||||
|
})
|
||||||
|
|
||||||
|
elif isinstance(node, ast.ImportFrom):
|
||||||
|
rows.append({
|
||||||
|
"file": relpath(path, root),
|
||||||
|
"line": node.lineno,
|
||||||
|
"module": node.module or "",
|
||||||
|
"import_type": "from",
|
||||||
|
"names": [x.name for x in node.names],
|
||||||
|
})
|
||||||
|
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def candidate_score(
|
||||||
|
path: Path,
|
||||||
|
hits: List[Dict[str, Any]],
|
||||||
|
root: Path,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
groups = Counter(r["group"] for r in hits)
|
||||||
|
score = 0
|
||||||
|
|
||||||
|
weights = {
|
||||||
|
"GEOMETRY_ISSUE": 8,
|
||||||
|
"GEOTYPE": 5,
|
||||||
|
"BBOX": 5,
|
||||||
|
"COUNTERS": 7,
|
||||||
|
"DB_WRITE": 7,
|
||||||
|
"RAW_SOURCE": 4,
|
||||||
|
"NORMALIZATION": 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
for group, count in groups.items():
|
||||||
|
score += weights.get(group, 1) * min(count, 10)
|
||||||
|
|
||||||
|
name = path.name.lower()
|
||||||
|
if "import" in name:
|
||||||
|
score += 8
|
||||||
|
if "geometry" in name or "geom" in name:
|
||||||
|
score += 6
|
||||||
|
if "cm93" in name:
|
||||||
|
score += 6
|
||||||
|
if "db" in name or "mysql" in name:
|
||||||
|
score += 3
|
||||||
|
|
||||||
|
return {
|
||||||
|
"file": relpath(path, root),
|
||||||
|
"score": score,
|
||||||
|
"hit_count": len(hits),
|
||||||
|
**{f"hits_{g.lower()}": groups.get(g, 0) for g in SEARCH_GROUPS},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def choose_integration_points(
|
||||||
|
hits: List[Dict[str, Any]]
|
||||||
|
) -> Dict[str, List[Dict[str, Any]]]:
|
||||||
|
buckets = {
|
||||||
|
"area_geometry_build_or_write": [],
|
||||||
|
"coverage_geometry_build_or_write": [],
|
||||||
|
"geotype16_issue_logic": [],
|
||||||
|
"geometry_invalid_counter": [],
|
||||||
|
"bbox_update": [],
|
||||||
|
"raw_source_preservation": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
for r in hits:
|
||||||
|
code = r["code"].lower()
|
||||||
|
file = r["file"].lower()
|
||||||
|
|
||||||
|
if (
|
||||||
|
"chart_area_geometry" in code
|
||||||
|
or (
|
||||||
|
r["group"] == "NORMALIZATION"
|
||||||
|
and "area" in file
|
||||||
|
)
|
||||||
|
):
|
||||||
|
buckets["area_geometry_build_or_write"].append(r)
|
||||||
|
|
||||||
|
if "chart_coverage" in code:
|
||||||
|
buckets["coverage_geometry_build_or_write"].append(r)
|
||||||
|
|
||||||
|
if (
|
||||||
|
"raw_geotype" in code
|
||||||
|
or "geotype" in code
|
||||||
|
or "geometry missing" in code
|
||||||
|
):
|
||||||
|
buckets["geotype16_issue_logic"].append(r)
|
||||||
|
|
||||||
|
if "geometry_invalid_count" in code:
|
||||||
|
buckets["geometry_invalid_counter"].append(r)
|
||||||
|
|
||||||
|
if (
|
||||||
|
"bbox_min_lon" in code
|
||||||
|
or "bbox_max_lon" in code
|
||||||
|
or ".bounds" in code
|
||||||
|
):
|
||||||
|
buckets["bbox_update"].append(r)
|
||||||
|
|
||||||
|
if (
|
||||||
|
"raw_feature_blob" in code
|
||||||
|
or "chart_raw_record" in code
|
||||||
|
):
|
||||||
|
buckets["raw_source_preservation"].append(r)
|
||||||
|
|
||||||
|
for key in buckets:
|
||||||
|
buckets[key] = buckets[key][:30]
|
||||||
|
|
||||||
|
return buckets
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Report
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def build_integration_md(
|
||||||
|
gate: Dict[str, Any],
|
||||||
|
candidates: List[Dict[str, Any]],
|
||||||
|
integration: Dict[str, List[Dict[str, Any]]],
|
||||||
|
) -> str:
|
||||||
|
lines = []
|
||||||
|
|
||||||
|
lines.append("# CM93 Importer Normalization 接入点调查 v12")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("## V11 门控")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(
|
||||||
|
f"- IMPLEMENTATION PRECHECK:"
|
||||||
|
f"{'PASS' if gate['implementation_precheck_pass'] else 'FAIL'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
for key in (
|
||||||
|
"db_invalid_count",
|
||||||
|
"golden_revalidation_pass_count",
|
||||||
|
"golden_revalidation_fail_count",
|
||||||
|
"area_geometry_column_type",
|
||||||
|
"coverage_geometry_column_type",
|
||||||
|
"future_area_bbox_update_needed_count",
|
||||||
|
"raw_geotype16_meta_count",
|
||||||
|
"raw_geotype16_with_issue_count",
|
||||||
|
"import_job_geometry_invalid_sum",
|
||||||
|
):
|
||||||
|
lines.append(f"- {key}: `{gate.get(key)}`")
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
lines.append("## 高优先级候选源码文件")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
for row in candidates[:15]:
|
||||||
|
lines.append(
|
||||||
|
f"- `{row['file']}` — score={row['score']}, "
|
||||||
|
f"hits={row['hit_count']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
title_map = {
|
||||||
|
"area_geometry_build_or_write": "Area geometry 构造/写入",
|
||||||
|
"coverage_geometry_build_or_write": "Coverage geometry 构造/写入",
|
||||||
|
"geotype16_issue_logic": "raw_geotype=16 / geometry missing 判定",
|
||||||
|
"geometry_invalid_counter": "geometry_invalid_count 统计",
|
||||||
|
"bbox_update": "bbox 计算/写入",
|
||||||
|
"raw_source_preservation": "raw source 保留",
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, title in title_map.items():
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"## {title}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
rows = integration.get(key, [])
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
lines.append("- 未自动定位,需要人工继续查。")
|
||||||
|
continue
|
||||||
|
|
||||||
|
seen = set()
|
||||||
|
for row in rows:
|
||||||
|
marker = (row["file"], row["line"])
|
||||||
|
if marker in seen:
|
||||||
|
continue
|
||||||
|
seen.add(marker)
|
||||||
|
lines.append(
|
||||||
|
f"- `{row['file']}:{row['line']}` "
|
||||||
|
f"`{row['code']}`"
|
||||||
|
)
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
lines.append("## 下一阶段修改边界")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("下一阶段只应修改真实定位到的 importer 代码,不应重新设计数据库。")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("冻结要求:")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("1. valid geometry 原样 PASS-THROUGH。")
|
||||||
|
lines.append("2. invalid Area/Coverage 才执行 odd-even face selection。")
|
||||||
|
lines.append("3. normalization 后执行 GEOS normalize canonicalization。")
|
||||||
|
lines.append("4. 输出必须为 valid Polygon/MultiPolygon。")
|
||||||
|
lines.append("5. Area normalized 后同步重新计算 feature bbox。")
|
||||||
|
lines.append("6. retraced connector 不保存为 Area 面积,但 raw source 必须保留。")
|
||||||
|
lines.append("7. raw_geotype=16 / META 不生成 `geometry missing`。")
|
||||||
|
lines.append("8. raw_geotype=16 不增加 `geometry_invalid_count`。")
|
||||||
|
lines.append("9. 不删除 `raw_feature_blob` 或 `chart_raw_record`。")
|
||||||
|
lines.append("10. 当前仍不进入 PBF。")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args():
|
||||||
|
p = argparse.ArgumentParser(
|
||||||
|
description="CM93 importer normalization source audit v12"
|
||||||
|
)
|
||||||
|
p.add_argument("--v11-input", required=True)
|
||||||
|
p.add_argument("--project-root", default=".")
|
||||||
|
p.add_argument("--output", default=None)
|
||||||
|
return p.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = parse_args()
|
||||||
|
|
||||||
|
v11_src = Path(args.v11_input).expanduser().resolve()
|
||||||
|
project_root = Path(args.project_root).expanduser().resolve()
|
||||||
|
|
||||||
|
if not v11_src.exists():
|
||||||
|
print(f"V11 输入不存在:{v11_src}")
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
if not project_root.is_dir():
|
||||||
|
print(f"项目目录不存在:{project_root}")
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
temp_v11 = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
v11_root, temp_v11 = open_input(v11_src)
|
||||||
|
|
||||||
|
print("[1/6] V11 implementation precheck gate ...")
|
||||||
|
gate = load_v11_gate(v11_root)
|
||||||
|
|
||||||
|
print(json.dumps(gate, ensure_ascii=False, indent=2))
|
||||||
|
|
||||||
|
if not gate["implementation_precheck_pass"]:
|
||||||
|
print("")
|
||||||
|
print("STOP:V11 IMPLEMENTATION PRECHECK 未通过。")
|
||||||
|
print("V12 不扫描 importer 源码。")
|
||||||
|
sys.exit(3)
|
||||||
|
|
||||||
|
print(" V11 gate: PASS")
|
||||||
|
|
||||||
|
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
|
||||||
|
outdir = Path(
|
||||||
|
args.output
|
||||||
|
or project_root / f"cm93_importer_source_audit_v12_{stamp}"
|
||||||
|
)
|
||||||
|
outdir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
print("[2/6] 扫描项目文本源码 ...")
|
||||||
|
files = list_source_files(project_root)
|
||||||
|
print(f" files={len(files)}")
|
||||||
|
|
||||||
|
all_hits = []
|
||||||
|
function_rows = []
|
||||||
|
dependency_rows = []
|
||||||
|
candidate_rows = []
|
||||||
|
|
||||||
|
for i, path in enumerate(files, start=1):
|
||||||
|
# 避免扫描当前输出目录自身
|
||||||
|
try:
|
||||||
|
path.resolve().relative_to(outdir.resolve())
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
text = safe_read_text(path)
|
||||||
|
if text is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
hits = scan_hits(path, text, project_root)
|
||||||
|
all_hits.extend(hits)
|
||||||
|
|
||||||
|
function_rows.extend(
|
||||||
|
python_function_inventory(path, text, project_root)
|
||||||
|
)
|
||||||
|
|
||||||
|
dependency_rows.extend(
|
||||||
|
python_dependency_inventory(path, text, project_root)
|
||||||
|
)
|
||||||
|
|
||||||
|
if hits:
|
||||||
|
candidate_rows.append(
|
||||||
|
candidate_score(path, hits, project_root)
|
||||||
|
)
|
||||||
|
|
||||||
|
if i % 100 == 0:
|
||||||
|
print(f" scanned={i}/{len(files)}")
|
||||||
|
|
||||||
|
candidate_rows.sort(
|
||||||
|
key=lambda r: (-int(r["score"]), r["file"])
|
||||||
|
)
|
||||||
|
|
||||||
|
print("[3/6] 分类 SQL 命中与函数 ...")
|
||||||
|
sql_hits = [
|
||||||
|
r for r in all_hits
|
||||||
|
if (
|
||||||
|
r["group"] == "DB_WRITE"
|
||||||
|
or re.search(
|
||||||
|
r"\b(SELECT|INSERT|UPDATE|DELETE|CREATE|ALTER)\b",
|
||||||
|
r["code"],
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
print("[4/6] 推导 normalization 接入点 ...")
|
||||||
|
integration = choose_integration_points(all_hits)
|
||||||
|
|
||||||
|
print("[5/6] 写报告 ...")
|
||||||
|
write_csv(
|
||||||
|
outdir / "importer_candidate_files_v12.csv",
|
||||||
|
candidate_rows
|
||||||
|
)
|
||||||
|
write_csv(
|
||||||
|
outdir / "importer_code_hits_v12.csv",
|
||||||
|
all_hits
|
||||||
|
)
|
||||||
|
write_csv(
|
||||||
|
outdir / "importer_sql_hits_v12.csv",
|
||||||
|
sql_hits
|
||||||
|
)
|
||||||
|
write_csv(
|
||||||
|
outdir / "importer_function_inventory_v12.csv",
|
||||||
|
function_rows
|
||||||
|
)
|
||||||
|
write_csv(
|
||||||
|
outdir / "importer_dependency_inventory_v12.csv",
|
||||||
|
dependency_rows
|
||||||
|
)
|
||||||
|
|
||||||
|
integration_md = build_integration_md(
|
||||||
|
gate,
|
||||||
|
candidate_rows,
|
||||||
|
integration,
|
||||||
|
)
|
||||||
|
|
||||||
|
(
|
||||||
|
outdir / "importer_normalization_integration_points_v12.md"
|
||||||
|
).write_text(
|
||||||
|
integration_md,
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
report = {
|
||||||
|
"version": "v12.0",
|
||||||
|
"generated_at": datetime.now().isoformat(timespec="seconds"),
|
||||||
|
"project_root": str(project_root),
|
||||||
|
"read_only": True,
|
||||||
|
"v11_gate": gate,
|
||||||
|
"source_file_count": len(files),
|
||||||
|
"candidate_file_count": len(candidate_rows),
|
||||||
|
"code_hit_count": len(all_hits),
|
||||||
|
"sql_hit_count": len(sql_hits),
|
||||||
|
"function_count": len(function_rows),
|
||||||
|
"dependency_count": len(dependency_rows),
|
||||||
|
"top_candidate_files": candidate_rows[:25],
|
||||||
|
"integration_point_counts": {
|
||||||
|
k: len(v)
|
||||||
|
for k, v in integration.items()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
(
|
||||||
|
outdir / "importer_source_audit_report_v12.json"
|
||||||
|
).write_text(
|
||||||
|
json.dumps(
|
||||||
|
report,
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
default=str,
|
||||||
|
),
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
lines.append("CM93 Importer Normalization 接入点只读调查 v12")
|
||||||
|
lines.append("=" * 88)
|
||||||
|
lines.append(f"Generated: {report['generated_at']}")
|
||||||
|
lines.append(f"Project: {project_root}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("V11 GATE")
|
||||||
|
lines.append(" PASS")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("SOURCE SCAN")
|
||||||
|
lines.append(f" files: {len(files)}")
|
||||||
|
lines.append(f" candidate files:{len(candidate_rows)}")
|
||||||
|
lines.append(f" code hits: {len(all_hits)}")
|
||||||
|
lines.append(f" SQL hits: {len(sql_hits)}")
|
||||||
|
lines.append(f" functions: {len(function_rows)}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("TOP CANDIDATE FILES")
|
||||||
|
|
||||||
|
for row in candidate_rows[:15]:
|
||||||
|
lines.append(
|
||||||
|
f" {row['score']:4d} {row['file']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
lines.append("INTEGRATION POINT COUNTS")
|
||||||
|
|
||||||
|
for key, rows in integration.items():
|
||||||
|
lines.append(
|
||||||
|
f" {key:36s} {len(rows)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
lines.append(
|
||||||
|
"NO SOURCE WRITE / NO DATABASE WRITE / NO PBF"
|
||||||
|
)
|
||||||
|
|
||||||
|
(
|
||||||
|
outdir / "importer_source_audit_summary_v12.txt"
|
||||||
|
).write_text(
|
||||||
|
"\n".join(lines),
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
print("[6/6] 压缩 ...")
|
||||||
|
zpath = outdir.with_suffix(".zip")
|
||||||
|
|
||||||
|
with zipfile.ZipFile(
|
||||||
|
zpath,
|
||||||
|
"w",
|
||||||
|
compression=zipfile.ZIP_DEFLATED
|
||||||
|
) as z:
|
||||||
|
for p in sorted(outdir.rglob("*")):
|
||||||
|
if p.is_file():
|
||||||
|
z.write(
|
||||||
|
p,
|
||||||
|
p.relative_to(outdir.parent)
|
||||||
|
)
|
||||||
|
|
||||||
|
print("")
|
||||||
|
print("SOURCE AUDIT PASS: YES")
|
||||||
|
print(f"ZIP: {zpath.resolve()}")
|
||||||
|
print("把 V12 ZIP 上传给 ChatGPT。")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if temp_v11 and temp_v11.exists():
|
||||||
|
shutil.rmtree(temp_v11, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
613
cm93_importer_production_source_snapshot_v1.py
Normal file
613
cm93_importer_production_source_snapshot_v1.py
Normal file
@@ -0,0 +1,613 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
CM93 Importer 生产源码快照与接入点提取
|
||||||
|
Version: production-snapshot-v1
|
||||||
|
|
||||||
|
目的
|
||||||
|
====
|
||||||
|
完全独立,不连接数据库,不读取 V10/V11/V12,不扫描历史审计脚本。
|
||||||
|
|
||||||
|
只针对当前项目真正的生产源码:
|
||||||
|
- src/cm93db/importer.py
|
||||||
|
- src/cm93db/geometry.py
|
||||||
|
- src/cm93db/cm93_binary.py
|
||||||
|
- src/cm93db/cell_parser.py
|
||||||
|
- src/cm93db/audit.py
|
||||||
|
- sql/001_schema_mysql57.sql
|
||||||
|
- README.md
|
||||||
|
- AGENTS.md(若存在)
|
||||||
|
|
||||||
|
生成:
|
||||||
|
1. 上述文件的完整只读快照副本;
|
||||||
|
2. SHA-256;
|
||||||
|
3. Python 函数/类清单;
|
||||||
|
4. 与 normalization / geometry issue / geotype / bbox /
|
||||||
|
counters / DB write / raw source 相关的完整函数摘录;
|
||||||
|
5. SQL schema 中关键表完整 CREATE TABLE 片段;
|
||||||
|
6. 一个小 ZIP,供 ChatGPT 精确分析后生成真正修改版。
|
||||||
|
|
||||||
|
不做:
|
||||||
|
- 不修改源码
|
||||||
|
- 不连接 MySQL
|
||||||
|
- 不执行 SQL
|
||||||
|
- 不生成 patch
|
||||||
|
- 不生成 PBF
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import ast
|
||||||
|
import csv
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import zipfile
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
||||||
|
|
||||||
|
|
||||||
|
PRODUCTION_FILES = [
|
||||||
|
"src/cm93db/importer.py",
|
||||||
|
"src/cm93db/geometry.py",
|
||||||
|
"src/cm93db/cm93_binary.py",
|
||||||
|
"src/cm93db/cell_parser.py",
|
||||||
|
"src/cm93db/audit.py",
|
||||||
|
"sql/001_schema_mysql57.sql",
|
||||||
|
"README.md",
|
||||||
|
"AGENTS.md",
|
||||||
|
]
|
||||||
|
|
||||||
|
KEY_PATTERNS = {
|
||||||
|
"normalization": [
|
||||||
|
r"\bis_valid\b",
|
||||||
|
r"\bmake_valid\b",
|
||||||
|
r"\bpolygonize\b",
|
||||||
|
r"\bpolygonize_full\b",
|
||||||
|
r"\bunary_union\b",
|
||||||
|
r"\bnormalize\b",
|
||||||
|
r"\bexplain_validity\b",
|
||||||
|
r"\bbuffer\s*\(\s*0",
|
||||||
|
],
|
||||||
|
"geometry_issue": [
|
||||||
|
r"geometry_issue",
|
||||||
|
r"geometry missing",
|
||||||
|
r"geometry_missing",
|
||||||
|
r"geometry_repaired",
|
||||||
|
r"invalid geometry",
|
||||||
|
],
|
||||||
|
"geotype": [
|
||||||
|
r"raw_geotype",
|
||||||
|
r"\bgeotype\b",
|
||||||
|
r"geometry_type",
|
||||||
|
r"\bMETA\b",
|
||||||
|
r"\bAREA\b",
|
||||||
|
],
|
||||||
|
"bbox": [
|
||||||
|
r"bbox_min_lon",
|
||||||
|
r"bbox_min_lat",
|
||||||
|
r"bbox_max_lon",
|
||||||
|
r"bbox_max_lat",
|
||||||
|
r"\bbbox\b",
|
||||||
|
r"\bbounds\b",
|
||||||
|
],
|
||||||
|
"counters": [
|
||||||
|
r"geometry_invalid_count",
|
||||||
|
r"geometry_repaired_count",
|
||||||
|
r"summary_json",
|
||||||
|
r"chart_import_job",
|
||||||
|
],
|
||||||
|
"db_write": [
|
||||||
|
r"chart_area_geometry",
|
||||||
|
r"chart_coverage",
|
||||||
|
r"chart_feature",
|
||||||
|
r"INSERT\s+INTO",
|
||||||
|
r"UPDATE\s+",
|
||||||
|
r"executemany\s*\(",
|
||||||
|
r"execute\s*\(",
|
||||||
|
],
|
||||||
|
"raw_source": [
|
||||||
|
r"raw_feature_blob",
|
||||||
|
r"chart_raw_record",
|
||||||
|
r"source_record_index",
|
||||||
|
r"raw_blob",
|
||||||
|
r"raw_metadata_json",
|
||||||
|
],
|
||||||
|
"geometry_build": [
|
||||||
|
r"\bPolygon\s*\(",
|
||||||
|
r"\bMultiPolygon\s*\(",
|
||||||
|
r"\bLineString\s*\(",
|
||||||
|
r"\bPoint\s*\(",
|
||||||
|
r"\bwkb\b",
|
||||||
|
r"\bshapely\b",
|
||||||
|
],
|
||||||
|
"srid": [
|
||||||
|
r"\bSRID\b",
|
||||||
|
r"\bsrid\b",
|
||||||
|
r"4326",
|
||||||
|
r"normalized_srid",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
SCHEMA_TABLES = [
|
||||||
|
"chart_feature",
|
||||||
|
"chart_area_geometry",
|
||||||
|
"chart_coverage",
|
||||||
|
"chart_import_job",
|
||||||
|
"chart_raw_record",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def sha256_file(path: Path) -> str:
|
||||||
|
h = hashlib.sha256()
|
||||||
|
with path.open("rb") as f:
|
||||||
|
while True:
|
||||||
|
chunk = f.read(1024 * 1024)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
h.update(chunk)
|
||||||
|
return h.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def safe_text(path: Path) -> Optional[str]:
|
||||||
|
for enc in ("utf-8", "utf-8-sig", "cp932", "latin-1"):
|
||||||
|
try:
|
||||||
|
return path.read_text(encoding=enc)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def write_csv(path: Path, rows: Iterable[Dict[str, Any]]) -> None:
|
||||||
|
rows = list(rows)
|
||||||
|
keys: List[str] = []
|
||||||
|
seen = set()
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
for key in row:
|
||||||
|
if key not in seen:
|
||||||
|
seen.add(key)
|
||||||
|
keys.append(key)
|
||||||
|
|
||||||
|
with path.open("w", encoding="utf-8-sig", newline="") as f:
|
||||||
|
if not keys:
|
||||||
|
return
|
||||||
|
w = csv.DictWriter(f, fieldnames=keys, extrasaction="ignore")
|
||||||
|
w.writeheader()
|
||||||
|
for row in rows:
|
||||||
|
out = {}
|
||||||
|
for key in keys:
|
||||||
|
v = row.get(key)
|
||||||
|
if isinstance(v, (list, tuple, dict)):
|
||||||
|
v = json.dumps(v, ensure_ascii=False, default=str)
|
||||||
|
out[key] = "" if v is None else v
|
||||||
|
w.writerow(out)
|
||||||
|
|
||||||
|
|
||||||
|
def ast_functions(path: Path, text: str, rel: str):
|
||||||
|
functions: List[Dict[str, Any]] = []
|
||||||
|
classes: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
if path.suffix.lower() != ".py":
|
||||||
|
return functions, classes
|
||||||
|
|
||||||
|
try:
|
||||||
|
tree = ast.parse(text)
|
||||||
|
except Exception as e:
|
||||||
|
functions.append({
|
||||||
|
"file": rel,
|
||||||
|
"function_name": "__AST_PARSE_ERROR__",
|
||||||
|
"line": None,
|
||||||
|
"end_line": None,
|
||||||
|
"error": f"{type(e).__name__}: {e}",
|
||||||
|
})
|
||||||
|
return functions, classes
|
||||||
|
|
||||||
|
class_stack: List[str] = []
|
||||||
|
|
||||||
|
class Visitor(ast.NodeVisitor):
|
||||||
|
def visit_ClassDef(self, node: ast.ClassDef):
|
||||||
|
classes.append({
|
||||||
|
"file": rel,
|
||||||
|
"class_name": node.name,
|
||||||
|
"line": node.lineno,
|
||||||
|
"end_line": getattr(node, "end_lineno", None),
|
||||||
|
})
|
||||||
|
class_stack.append(node.name)
|
||||||
|
self.generic_visit(node)
|
||||||
|
class_stack.pop()
|
||||||
|
|
||||||
|
def visit_FunctionDef(self, node: ast.FunctionDef):
|
||||||
|
functions.append({
|
||||||
|
"file": rel,
|
||||||
|
"class_name": ".".join(class_stack),
|
||||||
|
"function_name": node.name,
|
||||||
|
"line": node.lineno,
|
||||||
|
"end_line": getattr(node, "end_lineno", None),
|
||||||
|
"async": 0,
|
||||||
|
"args": [a.arg for a in node.args.args],
|
||||||
|
})
|
||||||
|
self.generic_visit(node)
|
||||||
|
|
||||||
|
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef):
|
||||||
|
functions.append({
|
||||||
|
"file": rel,
|
||||||
|
"class_name": ".".join(class_stack),
|
||||||
|
"function_name": node.name,
|
||||||
|
"line": node.lineno,
|
||||||
|
"end_line": getattr(node, "end_lineno", None),
|
||||||
|
"async": 1,
|
||||||
|
"args": [a.arg for a in node.args.args],
|
||||||
|
})
|
||||||
|
self.generic_visit(node)
|
||||||
|
|
||||||
|
Visitor().visit(tree)
|
||||||
|
return functions, classes
|
||||||
|
|
||||||
|
|
||||||
|
def line_hits(rel: str, text: str) -> List[Dict[str, Any]]:
|
||||||
|
rows = []
|
||||||
|
lines = text.splitlines()
|
||||||
|
|
||||||
|
compiled = {
|
||||||
|
group: [re.compile(p, re.I) for p in pats]
|
||||||
|
for group, pats in KEY_PATTERNS.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
for no, line in enumerate(lines, 1):
|
||||||
|
for group, pats in compiled.items():
|
||||||
|
matched = [p.pattern for p in pats if p.search(line)]
|
||||||
|
if not matched:
|
||||||
|
continue
|
||||||
|
start = max(1, no - 3)
|
||||||
|
end = min(len(lines), no + 3)
|
||||||
|
context = "\n".join(
|
||||||
|
f"{i:05d}: {lines[i-1]}"
|
||||||
|
for i in range(start, end + 1)
|
||||||
|
)
|
||||||
|
rows.append({
|
||||||
|
"file": rel,
|
||||||
|
"line": no,
|
||||||
|
"group": group,
|
||||||
|
"code": line.strip(),
|
||||||
|
"matched_patterns": matched,
|
||||||
|
"context": context,
|
||||||
|
})
|
||||||
|
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def function_key_groups(
|
||||||
|
fn: Dict[str, Any],
|
||||||
|
text: str,
|
||||||
|
) -> List[str]:
|
||||||
|
if fn.get("line") is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
lines = text.splitlines()
|
||||||
|
start = max(1, int(fn["line"]))
|
||||||
|
end = fn.get("end_line")
|
||||||
|
if end is None:
|
||||||
|
end = min(len(lines), start + 80)
|
||||||
|
else:
|
||||||
|
end = min(len(lines), int(end))
|
||||||
|
|
||||||
|
body = "\n".join(lines[start - 1:end])
|
||||||
|
|
||||||
|
groups = []
|
||||||
|
for group, pats in KEY_PATTERNS.items():
|
||||||
|
if any(re.search(p, body, re.I) for p in pats):
|
||||||
|
groups.append(group)
|
||||||
|
|
||||||
|
return groups
|
||||||
|
|
||||||
|
|
||||||
|
def make_function_excerpts(
|
||||||
|
root: Path,
|
||||||
|
function_rows: List[Dict[str, Any]],
|
||||||
|
texts: Dict[str, str],
|
||||||
|
) -> str:
|
||||||
|
chunks: List[str] = []
|
||||||
|
|
||||||
|
for fn in sorted(
|
||||||
|
function_rows,
|
||||||
|
key=lambda x: (
|
||||||
|
x.get("file") or "",
|
||||||
|
int(x.get("line") or 0),
|
||||||
|
)
|
||||||
|
):
|
||||||
|
rel = fn.get("file")
|
||||||
|
text = texts.get(rel)
|
||||||
|
if not text or fn.get("line") is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
groups = function_key_groups(fn, text)
|
||||||
|
if not groups:
|
||||||
|
continue
|
||||||
|
|
||||||
|
lines = text.splitlines()
|
||||||
|
start = max(1, int(fn["line"]))
|
||||||
|
end_val = fn.get("end_line")
|
||||||
|
end = (
|
||||||
|
min(len(lines), int(end_val))
|
||||||
|
if end_val is not None
|
||||||
|
else min(len(lines), start + 100)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 防止单个超大函数让结果失控,但保留足够上下文。
|
||||||
|
if end - start > 260:
|
||||||
|
end = start + 260
|
||||||
|
|
||||||
|
cls = fn.get("class_name") or ""
|
||||||
|
name = fn.get("function_name") or ""
|
||||||
|
display = f"{cls}.{name}" if cls else name
|
||||||
|
|
||||||
|
chunks.append("=" * 110)
|
||||||
|
chunks.append(
|
||||||
|
f"FILE={rel} FUNCTION={display} "
|
||||||
|
f"LINES={start}-{fn.get('end_line')} "
|
||||||
|
f"GROUPS={','.join(groups)}"
|
||||||
|
)
|
||||||
|
chunks.append("=" * 110)
|
||||||
|
|
||||||
|
for no in range(start, end + 1):
|
||||||
|
chunks.append(f"{no:05d}: {lines[no-1]}")
|
||||||
|
|
||||||
|
chunks.append("")
|
||||||
|
|
||||||
|
return "\n".join(chunks)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_create_table(sql_text: str, table: str) -> Optional[str]:
|
||||||
|
# 兼容 CREATE TABLE `name` (...) ENGINE=...
|
||||||
|
pat = re.compile(
|
||||||
|
rf"CREATE\s+TABLE\s+`?{re.escape(table)}`?\s*\(",
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
m = pat.search(sql_text)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
|
||||||
|
start = m.start()
|
||||||
|
pos = m.end() - 1
|
||||||
|
depth = 0
|
||||||
|
in_single = False
|
||||||
|
in_double = False
|
||||||
|
escape = False
|
||||||
|
|
||||||
|
for i in range(pos, len(sql_text)):
|
||||||
|
ch = sql_text[i]
|
||||||
|
|
||||||
|
if escape:
|
||||||
|
escape = False
|
||||||
|
continue
|
||||||
|
|
||||||
|
if ch == "\\":
|
||||||
|
escape = True
|
||||||
|
continue
|
||||||
|
|
||||||
|
if ch == "'" and not in_double:
|
||||||
|
in_single = not in_single
|
||||||
|
continue
|
||||||
|
|
||||||
|
if ch == '"' and not in_single:
|
||||||
|
in_double = not in_double
|
||||||
|
continue
|
||||||
|
|
||||||
|
if in_single or in_double:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if ch == "(":
|
||||||
|
depth += 1
|
||||||
|
elif ch == ")":
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0:
|
||||||
|
# 到分号为止
|
||||||
|
semi = sql_text.find(";", i)
|
||||||
|
if semi == -1:
|
||||||
|
semi = i
|
||||||
|
return sql_text[start:semi + 1]
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args():
|
||||||
|
p = argparse.ArgumentParser(
|
||||||
|
description="CM93 importer production source snapshot"
|
||||||
|
)
|
||||||
|
p.add_argument("--project-root", default=".")
|
||||||
|
p.add_argument("--output", default=None)
|
||||||
|
return p.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = parse_args()
|
||||||
|
root = Path(args.project_root).expanduser().resolve()
|
||||||
|
|
||||||
|
if not root.is_dir():
|
||||||
|
print(f"项目目录不存在:{root}")
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
outdir = Path(
|
||||||
|
args.output
|
||||||
|
or root / f"cm93_importer_production_snapshot_v1_{stamp}"
|
||||||
|
).expanduser().resolve()
|
||||||
|
outdir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
snapshot_dir = outdir / "source_snapshot"
|
||||||
|
snapshot_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
inventory = []
|
||||||
|
all_functions = []
|
||||||
|
all_classes = []
|
||||||
|
all_hits = []
|
||||||
|
texts: Dict[str, str] = {}
|
||||||
|
|
||||||
|
print("[1/5] 收集生产文件 ...")
|
||||||
|
|
||||||
|
for rel in PRODUCTION_FILES:
|
||||||
|
src = root / rel
|
||||||
|
|
||||||
|
if not src.exists():
|
||||||
|
inventory.append({
|
||||||
|
"file": rel,
|
||||||
|
"exists": 0,
|
||||||
|
"size_bytes": None,
|
||||||
|
"sha256": None,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
dest = snapshot_dir / rel
|
||||||
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(src, dest)
|
||||||
|
|
||||||
|
inventory.append({
|
||||||
|
"file": rel,
|
||||||
|
"exists": 1,
|
||||||
|
"size_bytes": src.stat().st_size,
|
||||||
|
"sha256": sha256_file(src),
|
||||||
|
})
|
||||||
|
|
||||||
|
text = safe_text(src)
|
||||||
|
if text is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
texts[rel] = text
|
||||||
|
|
||||||
|
funcs, classes = ast_functions(src, text, rel)
|
||||||
|
all_functions.extend(funcs)
|
||||||
|
all_classes.extend(classes)
|
||||||
|
all_hits.extend(line_hits(rel, text))
|
||||||
|
|
||||||
|
print("[2/5] 提取相关完整函数 ...")
|
||||||
|
|
||||||
|
excerpts = make_function_excerpts(
|
||||||
|
root,
|
||||||
|
all_functions,
|
||||||
|
texts,
|
||||||
|
)
|
||||||
|
|
||||||
|
(outdir / "生产源码关键函数完整摘录_v1.txt").write_text(
|
||||||
|
excerpts,
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
print("[3/5] 提取关键 schema ...")
|
||||||
|
|
||||||
|
schema_text = texts.get("sql/001_schema_mysql57.sql", "")
|
||||||
|
schema_chunks = []
|
||||||
|
|
||||||
|
for table in SCHEMA_TABLES:
|
||||||
|
ddl = extract_create_table(schema_text, table)
|
||||||
|
schema_chunks.append("=" * 100)
|
||||||
|
schema_chunks.append(f"TABLE: {table}")
|
||||||
|
schema_chunks.append("=" * 100)
|
||||||
|
schema_chunks.append(
|
||||||
|
ddl if ddl is not None else "NOT FOUND"
|
||||||
|
)
|
||||||
|
schema_chunks.append("")
|
||||||
|
|
||||||
|
(outdir / "关键表DDL_v1.txt").write_text(
|
||||||
|
"\n".join(schema_chunks),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
print("[4/5] 写清单与报告 ...")
|
||||||
|
|
||||||
|
write_csv(outdir / "生产文件SHA256_v1.csv", inventory)
|
||||||
|
write_csv(outdir / "生产Python函数清单_v1.csv", all_functions)
|
||||||
|
write_csv(outdir / "生产Python类清单_v1.csv", all_classes)
|
||||||
|
write_csv(outdir / "生产源码关键命中_v1.csv", all_hits)
|
||||||
|
|
||||||
|
group_counts = {}
|
||||||
|
for row in all_hits:
|
||||||
|
group_counts[row["group"]] = (
|
||||||
|
group_counts.get(row["group"], 0) + 1
|
||||||
|
)
|
||||||
|
|
||||||
|
report = {
|
||||||
|
"version": "production-snapshot-v1",
|
||||||
|
"generated_at": datetime.now().isoformat(timespec="seconds"),
|
||||||
|
"project_root": str(root),
|
||||||
|
"database_dependency": False,
|
||||||
|
"history_dependency": False,
|
||||||
|
"read_only": True,
|
||||||
|
"pbf": "OUT_OF_SCOPE",
|
||||||
|
"production_files": inventory,
|
||||||
|
"function_count": len(all_functions),
|
||||||
|
"class_count": len(all_classes),
|
||||||
|
"key_hit_count": len(all_hits),
|
||||||
|
"key_hit_group_counts": group_counts,
|
||||||
|
"snapshot_contains_only_explicit_production_files": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
(outdir / "生产源码快照报告_v1.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
report,
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
default=str,
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
summary = [
|
||||||
|
"CM93 Importer 生产源码快照与接入点提取 v1",
|
||||||
|
"=" * 90,
|
||||||
|
f"Project: {root}",
|
||||||
|
"",
|
||||||
|
"仅包含明确指定的生产文件,不扫描任何 V* 历史审计脚本。",
|
||||||
|
"",
|
||||||
|
"FILES",
|
||||||
|
]
|
||||||
|
|
||||||
|
for row in inventory:
|
||||||
|
summary.append(
|
||||||
|
f" {'OK' if row['exists'] else 'MISSING':7s} "
|
||||||
|
f"{row['file']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
summary += [
|
||||||
|
"",
|
||||||
|
f"Python functions: {len(all_functions)}",
|
||||||
|
f"Python classes: {len(all_classes)}",
|
||||||
|
f"Key hits: {len(all_hits)}",
|
||||||
|
"",
|
||||||
|
"NO DATABASE / NO SOURCE MODIFY / NO PBF",
|
||||||
|
]
|
||||||
|
|
||||||
|
(outdir / "生产源码快照摘要_v1.txt").write_text(
|
||||||
|
"\n".join(summary),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
print("[5/5] 压缩 ...")
|
||||||
|
|
||||||
|
zip_path = outdir.with_suffix(".zip")
|
||||||
|
|
||||||
|
with zipfile.ZipFile(
|
||||||
|
zip_path,
|
||||||
|
"w",
|
||||||
|
compression=zipfile.ZIP_DEFLATED,
|
||||||
|
) as z:
|
||||||
|
for p in sorted(outdir.rglob("*")):
|
||||||
|
if p.is_file():
|
||||||
|
z.write(
|
||||||
|
p,
|
||||||
|
p.relative_to(outdir.parent),
|
||||||
|
)
|
||||||
|
|
||||||
|
print("")
|
||||||
|
print("PRODUCTION SNAPSHOT COMPLETE: YES")
|
||||||
|
print(f"Output: {outdir}")
|
||||||
|
print(f"ZIP: {zip_path}")
|
||||||
|
print("把这个 ZIP 上传给 ChatGPT。")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
734
cm93_mysql_audit.py
Normal file
734
cm93_mysql_audit.py
Normal file
@@ -0,0 +1,734 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
CM93 -> MySQL Dataset Audit + Geometry Issue Classification
|
||||||
|
v1.0
|
||||||
|
|
||||||
|
目标:
|
||||||
|
1. 审计 Dataset ID=1
|
||||||
|
2. 分类 geometry issue
|
||||||
|
3. 导出 CSV / JSON / TXT
|
||||||
|
|
||||||
|
安全:
|
||||||
|
- 只读事务
|
||||||
|
- 不执行 INSERT / UPDATE / DELETE / DDL
|
||||||
|
- 不修 geometry
|
||||||
|
- 不涉及 PBF
|
||||||
|
|
||||||
|
依赖:
|
||||||
|
pip install pymysql
|
||||||
|
|
||||||
|
示例:
|
||||||
|
python cm93_mysql_audit.py --host 127.0.0.1 --port 3306 --user root --database YOUR_DB --dataset-id 1
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import getpass
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import traceback
|
||||||
|
from collections import Counter, defaultdict
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
||||||
|
|
||||||
|
try:
|
||||||
|
import pymysql
|
||||||
|
from pymysql.cursors import DictCursor
|
||||||
|
except ImportError:
|
||||||
|
print("缺少 pymysql,请执行: pip install pymysql")
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
|
||||||
|
CATEGORY_RULES: List[Tuple[str, List[str]]] = [
|
||||||
|
("INVALID_COORDINATE", [
|
||||||
|
r"\bnan\b", r"\binf(?:inity)?\b", r"invalid coordinate",
|
||||||
|
r"coordinate.*out of range", r"latitude.*out of range",
|
||||||
|
r"longitude.*out of range", r"non[- ]?finite"
|
||||||
|
]),
|
||||||
|
("INSUFFICIENT_POINTS", [
|
||||||
|
r"insufficient point", r"too few point", r"not enough point",
|
||||||
|
r"minimum.*point", r"less than .* points?", r"requires? .* points?"
|
||||||
|
]),
|
||||||
|
("RING_NOT_CLOSED", [
|
||||||
|
r"ring.*not closed", r"not closed.*ring", r"unclosed ring",
|
||||||
|
r"closed linestring"
|
||||||
|
]),
|
||||||
|
("SELF_INTERSECTION", [
|
||||||
|
r"self[- ]?intersection", r"self intersect", r"ring self", r"bow[- ]?tie"
|
||||||
|
]),
|
||||||
|
("INVALID_RING", [
|
||||||
|
r"invalid ring", r"ring.*invalid", r"illegal ring",
|
||||||
|
r"shell.*invalid", r"hole.*invalid", r"interior ring", r"exterior ring"
|
||||||
|
]),
|
||||||
|
("DEGENERATE_GEOMETRY", [
|
||||||
|
r"degenerate", r"zero[- ]?length", r"zero[- ]?area",
|
||||||
|
r"collapsed", r"duplicate point", r"all points.*same",
|
||||||
|
r"too few distinct"
|
||||||
|
]),
|
||||||
|
("MULTIPART_ASSEMBLY", [
|
||||||
|
r"multipart", r"multi[- ]?part", r"cannot.*assemble",
|
||||||
|
r"failed.*assemble", r"assembly failed", r"cannot.*merge",
|
||||||
|
r"failed.*merge", r"stitch"
|
||||||
|
]),
|
||||||
|
("TOPOLOGY_MISMATCH", [
|
||||||
|
r"topolog", r"missing edge", r"edge.*missing", r"missing node",
|
||||||
|
r"node.*missing", r"edge reference", r"node reference",
|
||||||
|
r"primitive reference", r"dangling edge", r"broken chain", r"disconnected"
|
||||||
|
]),
|
||||||
|
("GEOMETRY_TYPE_MISMATCH", [
|
||||||
|
r"type mismatch", r"geometry type", r"expected.*(?:point|line|polygon|area)",
|
||||||
|
r"unexpected.*(?:point|line|polygon|area)", r"wrong.*geometry"
|
||||||
|
]),
|
||||||
|
("EMPTY_GEOMETRY", [
|
||||||
|
r"empty geometry", r"geometry.*empty", r"no geometry", r"null geometry",
|
||||||
|
r"geometry is null", r"no coordinates?", r"empty point",
|
||||||
|
r"empty line", r"empty polygon"
|
||||||
|
]),
|
||||||
|
("CONVERSION_EXCEPTION", [
|
||||||
|
r"\bwkb\b", r"\bwkt\b", r"parse exception", r"conversion exception",
|
||||||
|
r"geometry exception", r"failed to convert", r"cannot convert",
|
||||||
|
r"invalid gis data", r"data truncation", r"spatial.*exception"
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
|
||||||
|
STAGE_RULES: List[Tuple[str, List[str]]] = [
|
||||||
|
("MYSQL_WRITE", [r"mysql", r"insert", r"database", r"spatial", r"\bwkb\b", r"\bwkt\b"]),
|
||||||
|
("VALIDATION", [r"valid", r"self[- ]?intersect", r"ring", r"geometry.*invalid"]),
|
||||||
|
("ASSEMBLY", [r"assemble", r"assembly", r"merge", r"stitch", r"multipart", r"chain"]),
|
||||||
|
("TOPOLOGY", [r"topolog", r"edge", r"node", r"primitive"]),
|
||||||
|
("DECODE", [r"decode", r"parse", r"binary", r"record"]),
|
||||||
|
("SOURCE", [r"source", r"raw", r"cm93"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TableInfo:
|
||||||
|
name: str
|
||||||
|
columns: List[str]
|
||||||
|
|
||||||
|
|
||||||
|
def qi(name: str) -> str:
|
||||||
|
return "`" + name.replace("`", "``") + "`"
|
||||||
|
|
||||||
|
|
||||||
|
def text(v: Any) -> str:
|
||||||
|
if v is None:
|
||||||
|
return ""
|
||||||
|
if isinstance(v, (bytes, bytearray)):
|
||||||
|
return v.decode("utf-8", errors="replace")
|
||||||
|
return str(v)
|
||||||
|
|
||||||
|
|
||||||
|
def score_name(name: str, candidates: Sequence[str]) -> int:
|
||||||
|
n = name.lower()
|
||||||
|
best = 0
|
||||||
|
for c in candidates:
|
||||||
|
c = c.lower()
|
||||||
|
if n == c:
|
||||||
|
best = max(best, 100)
|
||||||
|
elif n.endswith("_" + c) or n.startswith(c + "_"):
|
||||||
|
best = max(best, 45)
|
||||||
|
elif c in n:
|
||||||
|
best = max(best, 10)
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
def best_col(cols: Sequence[str], candidates: Sequence[str]) -> Optional[str]:
|
||||||
|
ranked = sorted(((score_name(c, candidates), c) for c in cols), reverse=True)
|
||||||
|
return ranked[0][1] if ranked and ranked[0][0] > 0 else None
|
||||||
|
|
||||||
|
|
||||||
|
def best_table(tables: List[TableInfo], candidates: Sequence[str], must_have: Sequence[str] = ()) -> Optional[TableInfo]:
|
||||||
|
ranked = []
|
||||||
|
for t in tables:
|
||||||
|
if must_have and not all(any(x.lower() in c.lower() for c in t.columns) for x in must_have):
|
||||||
|
continue
|
||||||
|
ranked.append((score_name(t.name, candidates), t.name, t))
|
||||||
|
ranked.sort(reverse=True)
|
||||||
|
return ranked[0][2] if ranked and ranked[0][0] > 0 else None
|
||||||
|
|
||||||
|
|
||||||
|
def discover_tables(cur, db: str) -> List[TableInfo]:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT TABLE_NAME, COLUMN_NAME
|
||||||
|
FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA=%s
|
||||||
|
ORDER BY TABLE_NAME, ORDINAL_POSITION
|
||||||
|
""", (db,))
|
||||||
|
grouped: Dict[str, List[str]] = defaultdict(list)
|
||||||
|
for r in cur.fetchall():
|
||||||
|
grouped[r["TABLE_NAME"]].append(r["COLUMN_NAME"])
|
||||||
|
return [TableInfo(k, v) for k, v in grouped.items()]
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_schema(tables: List[TableInfo]) -> Dict[str, Optional[TableInfo]]:
|
||||||
|
issue_candidates = []
|
||||||
|
for t in tables:
|
||||||
|
n = t.name.lower()
|
||||||
|
if ("geometry" in n or "geom" in n) and ("issue" in n or "error" in n):
|
||||||
|
issue_candidates.append(t)
|
||||||
|
issue_candidates.sort(key=lambda t: (len(t.name), t.name))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"dataset": best_table(tables, ["datasets", "dataset"]),
|
||||||
|
"cell": best_table(tables, ["cells", "cell"]),
|
||||||
|
"feature": best_table(tables, ["features", "feature"]),
|
||||||
|
"attribute": best_table(tables, ["feature_attributes", "feature_attribute", "attributes", "attribute"]),
|
||||||
|
"sounding": best_table(tables, ["sounding_points", "sounding_point", "soundings", "sounding"]),
|
||||||
|
"geometry_issue": issue_candidates[0] if issue_candidates else best_table(tables, ["geometry_issues", "geometry_issue", "geom_issue"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def scalar(cur, sql: str, params=()) -> Any:
|
||||||
|
cur.execute(sql, params)
|
||||||
|
r = cur.fetchone()
|
||||||
|
return next(iter(r.values())) if r else None
|
||||||
|
|
||||||
|
|
||||||
|
def dataset_col(t: Optional[TableInfo]) -> Optional[str]:
|
||||||
|
if not t:
|
||||||
|
return None
|
||||||
|
return best_col(t.columns, ["dataset_id", "datasetid", "dataset"])
|
||||||
|
|
||||||
|
|
||||||
|
def count_table(cur, t: Optional[TableInfo], dataset_id: int) -> Dict[str, Any]:
|
||||||
|
if not t:
|
||||||
|
return {"table": None, "count": None, "dataset_column": None, "error": "table not found"}
|
||||||
|
ds = dataset_col(t)
|
||||||
|
try:
|
||||||
|
if ds:
|
||||||
|
n = scalar(cur, f"SELECT COUNT(*) AS n FROM {qi(t.name)} WHERE {qi(ds)}=%s", (dataset_id,))
|
||||||
|
else:
|
||||||
|
n = scalar(cur, f"SELECT COUNT(*) AS n FROM {qi(t.name)}")
|
||||||
|
return {"table": t.name, "count": int(n), "dataset_column": ds, "error": None}
|
||||||
|
except Exception as e:
|
||||||
|
return {"table": t.name, "count": None, "dataset_column": ds, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def orphan_count(cur, child: Optional[TableInfo], parent: Optional[TableInfo], child_fk_names, parent_pk_names, dataset_id: int) -> Dict[str, Any]:
|
||||||
|
out = {
|
||||||
|
"child": child.name if child else None,
|
||||||
|
"parent": parent.name if parent else None,
|
||||||
|
"child_fk": None,
|
||||||
|
"parent_pk": None,
|
||||||
|
"count": None,
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
|
if not child or not parent:
|
||||||
|
out["error"] = "table not found"
|
||||||
|
return out
|
||||||
|
|
||||||
|
cfk = best_col(child.columns, child_fk_names)
|
||||||
|
ppk = best_col(parent.columns, parent_pk_names)
|
||||||
|
cds = dataset_col(child)
|
||||||
|
out["child_fk"] = cfk
|
||||||
|
out["parent_pk"] = ppk
|
||||||
|
if not cfk or not ppk:
|
||||||
|
out["error"] = "required columns not found"
|
||||||
|
return out
|
||||||
|
|
||||||
|
try:
|
||||||
|
where = [f"c.{qi(cfk)} IS NOT NULL", f"p.{qi(ppk)} IS NULL"]
|
||||||
|
params = []
|
||||||
|
if cds:
|
||||||
|
where.insert(0, f"c.{qi(cds)}=%s")
|
||||||
|
params.append(dataset_id)
|
||||||
|
sql = f"""
|
||||||
|
SELECT COUNT(*) AS n
|
||||||
|
FROM {qi(child.name)} c
|
||||||
|
LEFT JOIN {qi(parent.name)} p
|
||||||
|
ON c.{qi(cfk)} = p.{qi(ppk)}
|
||||||
|
WHERE {' AND '.join(where)}
|
||||||
|
"""
|
||||||
|
out["count"] = int(scalar(cur, sql, params))
|
||||||
|
except Exception as e:
|
||||||
|
out["error"] = str(e)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def geometry_audit(cur, feature: Optional[TableInfo], dataset_id: int) -> Dict[str, Any]:
|
||||||
|
out: Dict[str, Any] = {
|
||||||
|
"table": feature.name if feature else None,
|
||||||
|
"geometry_column": None,
|
||||||
|
"null_count": None,
|
||||||
|
"empty_count": None,
|
||||||
|
"valid_count": None,
|
||||||
|
"invalid_count": None,
|
||||||
|
"types": {},
|
||||||
|
"errors": [],
|
||||||
|
}
|
||||||
|
if not feature:
|
||||||
|
out["errors"].append("feature table not found")
|
||||||
|
return out
|
||||||
|
|
||||||
|
geom = best_col(feature.columns, ["geometry", "geom", "shape"])
|
||||||
|
ds = dataset_col(feature)
|
||||||
|
out["geometry_column"] = geom
|
||||||
|
if not geom:
|
||||||
|
out["errors"].append("geometry column not found")
|
||||||
|
return out
|
||||||
|
|
||||||
|
params = [dataset_id] if ds else []
|
||||||
|
base = f"{qi(ds)}=%s" if ds else "1=1"
|
||||||
|
|
||||||
|
try:
|
||||||
|
out["null_count"] = int(scalar(cur,
|
||||||
|
f"SELECT COUNT(*) AS n FROM {qi(feature.name)} WHERE {base} AND {qi(geom)} IS NULL", params))
|
||||||
|
except Exception as e:
|
||||||
|
out["errors"].append(f"NULL count failed: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
cur.execute(f"""
|
||||||
|
SELECT ST_GeometryType({qi(geom)}) AS geometry_type, COUNT(*) AS n
|
||||||
|
FROM {qi(feature.name)}
|
||||||
|
WHERE {base} AND {qi(geom)} IS NOT NULL
|
||||||
|
GROUP BY ST_GeometryType({qi(geom)})
|
||||||
|
ORDER BY n DESC
|
||||||
|
""", params)
|
||||||
|
out["types"] = {text(r["geometry_type"] or "NULL"): int(r["n"]) for r in cur.fetchall()}
|
||||||
|
except Exception as e:
|
||||||
|
out["errors"].append(f"ST_GeometryType failed: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
cur.execute(f"""
|
||||||
|
SELECT
|
||||||
|
SUM(CASE WHEN ST_IsEmpty({qi(geom)}) THEN 1 ELSE 0 END) AS empty_count,
|
||||||
|
SUM(CASE WHEN ST_IsValid({qi(geom)}) THEN 1 ELSE 0 END) AS valid_count,
|
||||||
|
SUM(CASE WHEN NOT ST_IsValid({qi(geom)}) THEN 1 ELSE 0 END) AS invalid_count
|
||||||
|
FROM {qi(feature.name)}
|
||||||
|
WHERE {base} AND {qi(geom)} IS NOT NULL
|
||||||
|
""", params)
|
||||||
|
r = cur.fetchone() or {}
|
||||||
|
out["empty_count"] = int(r.get("empty_count") or 0)
|
||||||
|
out["valid_count"] = int(r.get("valid_count") or 0)
|
||||||
|
out["invalid_count"] = int(r.get("invalid_count") or 0)
|
||||||
|
except Exception as e:
|
||||||
|
out["errors"].append(f"ST_IsEmpty/ST_IsValid failed: {e}")
|
||||||
|
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def issue_text(row: Dict[str, Any]) -> str:
|
||||||
|
first = []
|
||||||
|
rest = []
|
||||||
|
for k, v in row.items():
|
||||||
|
if v is None:
|
||||||
|
continue
|
||||||
|
s = text(v).strip()
|
||||||
|
if not s:
|
||||||
|
continue
|
||||||
|
kl = k.lower()
|
||||||
|
part = f"{k}={s}"
|
||||||
|
if any(x in kl for x in ["issue", "error", "reason", "message", "detail", "exception", "stage", "type", "status"]):
|
||||||
|
first.append(part)
|
||||||
|
else:
|
||||||
|
rest.append(part)
|
||||||
|
return " | ".join(first + rest)
|
||||||
|
|
||||||
|
|
||||||
|
def classify_issue(s: str) -> str:
|
||||||
|
for category, patterns in CATEGORY_RULES:
|
||||||
|
for p in patterns:
|
||||||
|
if re.search(p, s, re.I):
|
||||||
|
return category
|
||||||
|
return "OTHER"
|
||||||
|
|
||||||
|
|
||||||
|
def classify_stage(s: str) -> str:
|
||||||
|
for stage, patterns in STAGE_RULES:
|
||||||
|
for p in patterns:
|
||||||
|
if re.search(p, s, re.I):
|
||||||
|
return stage
|
||||||
|
return "UNKNOWN"
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_issues(cur, issue: Optional[TableInfo], feature: Optional[TableInfo], cell: Optional[TableInfo], dataset_id: int, max_rows: int):
|
||||||
|
meta = {
|
||||||
|
"table": issue.name if issue else None,
|
||||||
|
"dataset_column": None,
|
||||||
|
"feature_fk": None,
|
||||||
|
"joined_feature": False,
|
||||||
|
"joined_cell": False,
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
|
if not issue:
|
||||||
|
meta["error"] = "geometry issue table not found"
|
||||||
|
return [], meta
|
||||||
|
|
||||||
|
ids = dataset_col(issue)
|
||||||
|
ifk = best_col(issue.columns, ["feature_id", "featureid", "feature"])
|
||||||
|
meta["dataset_column"] = ids
|
||||||
|
meta["feature_fk"] = ifk
|
||||||
|
|
||||||
|
joins = []
|
||||||
|
extras = []
|
||||||
|
where = []
|
||||||
|
params = []
|
||||||
|
|
||||||
|
fpk = best_col(feature.columns, ["id", "feature_id", "featureid"]) if feature else None
|
||||||
|
fds = dataset_col(feature)
|
||||||
|
fcell = best_col(feature.columns, ["cell_id", "cellid", "cell"]) if feature else None
|
||||||
|
|
||||||
|
if feature and ifk and fpk:
|
||||||
|
joins.append(f"LEFT JOIN {qi(feature.name)} f ON i.{qi(ifk)} = f.{qi(fpk)}")
|
||||||
|
meta["joined_feature"] = True
|
||||||
|
for c in feature.columns:
|
||||||
|
cl = c.lower()
|
||||||
|
if any(x in cl for x in ["object", "class", "type", "geom", "cell", "feature"]):
|
||||||
|
extras.append(f"f.{qi(c)} AS {qi('feature__' + c)}")
|
||||||
|
|
||||||
|
if cell and fcell:
|
||||||
|
cpk = best_col(cell.columns, ["id", "cell_id", "cellid"])
|
||||||
|
if cpk:
|
||||||
|
joins.append(f"LEFT JOIN {qi(cell.name)} c ON f.{qi(fcell)} = c.{qi(cpk)}")
|
||||||
|
meta["joined_cell"] = True
|
||||||
|
for cc in cell.columns:
|
||||||
|
ccl = cc.lower()
|
||||||
|
if any(x in ccl for x in ["name", "cell", "file", "path"]):
|
||||||
|
extras.append(f"c.{qi(cc)} AS {qi('cell__' + cc)}")
|
||||||
|
|
||||||
|
if ids:
|
||||||
|
where.append(f"i.{qi(ids)}=%s")
|
||||||
|
params.append(dataset_id)
|
||||||
|
elif meta["joined_feature"] and fds:
|
||||||
|
where.append(f"f.{qi(fds)}=%s")
|
||||||
|
params.append(dataset_id)
|
||||||
|
|
||||||
|
select_extra = (", " + ", ".join(extras)) if extras else ""
|
||||||
|
sql = f"""
|
||||||
|
SELECT i.*{select_extra}
|
||||||
|
FROM {qi(issue.name)} i
|
||||||
|
{' '.join(joins)}
|
||||||
|
{'WHERE ' + ' AND '.join(where) if where else ''}
|
||||||
|
LIMIT {int(max_rows)}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
cur.execute(sql, params)
|
||||||
|
return list(cur.fetchall()), meta
|
||||||
|
except Exception as e:
|
||||||
|
meta["error"] = str(e)
|
||||||
|
return [], meta
|
||||||
|
|
||||||
|
|
||||||
|
def find_object_class(row: Dict[str, Any]) -> str:
|
||||||
|
for k, v in row.items():
|
||||||
|
kl = k.lower()
|
||||||
|
if v is not None and "object" in kl and ("class" in kl or "type" in kl or "code" in kl or "name" in kl):
|
||||||
|
return text(v)
|
||||||
|
for k, v in row.items():
|
||||||
|
if v is not None and "class" in k.lower():
|
||||||
|
return text(v)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def find_cell(row: Dict[str, Any]) -> str:
|
||||||
|
for k, v in row.items():
|
||||||
|
kl = k.lower()
|
||||||
|
if v is not None and kl.startswith("cell__") and any(x in kl for x in ["name", "file", "path", "cell"]):
|
||||||
|
return text(v)
|
||||||
|
for k, v in row.items():
|
||||||
|
if v is not None and "cell" in k.lower():
|
||||||
|
return text(v)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def enrich(rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||||
|
out = []
|
||||||
|
for r in rows:
|
||||||
|
s = issue_text(r)
|
||||||
|
x = dict(r)
|
||||||
|
x["_issue_text"] = s
|
||||||
|
x["_issue_category"] = classify_issue(s)
|
||||||
|
x["_issue_stage"] = classify_stage(s)
|
||||||
|
x["_object_class"] = find_object_class(r)
|
||||||
|
x["_cell"] = find_cell(r)
|
||||||
|
out.append(x)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def summarize(rows: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||||
|
by_cat = Counter(r["_issue_category"] for r in rows)
|
||||||
|
by_stage = Counter(r["_issue_stage"] for r in rows)
|
||||||
|
cross = Counter((r["_issue_stage"], r["_issue_category"]) for r in rows)
|
||||||
|
obj = Counter(r["_object_class"] for r in rows if r["_object_class"])
|
||||||
|
cell = Counter(r["_cell"] for r in rows if r["_cell"])
|
||||||
|
return {
|
||||||
|
"total": len(rows),
|
||||||
|
"by_category": dict(by_cat.most_common()),
|
||||||
|
"by_stage": dict(by_stage.most_common()),
|
||||||
|
"by_stage_category": [
|
||||||
|
{"stage": s, "category": c, "count": n}
|
||||||
|
for (s, c), n in cross.most_common()
|
||||||
|
],
|
||||||
|
"by_object_class_top20": [
|
||||||
|
{"object_class": k, "count": v} for k, v in obj.most_common(20)
|
||||||
|
],
|
||||||
|
"by_cell_top30": [
|
||||||
|
{"cell": k, "count": v} for k, v in cell.most_common(30)
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def duplicate_audit(rows: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||||
|
groups = defaultdict(int)
|
||||||
|
for r in rows:
|
||||||
|
parts = []
|
||||||
|
for k, v in r.items():
|
||||||
|
kl = k.lower()
|
||||||
|
if v is not None and any(x in kl for x in ["feature_id", "featureid", "reason", "message", "detail", "exception", "stage", "issue_type", "error_type"]):
|
||||||
|
parts.append(f"{k}={text(v)}")
|
||||||
|
if parts:
|
||||||
|
groups[tuple(parts)] += 1
|
||||||
|
dup = [(k, n) for k, n in groups.items() if n > 1]
|
||||||
|
dup.sort(key=lambda x: x[1], reverse=True)
|
||||||
|
return {
|
||||||
|
"duplicate_groups": len(dup),
|
||||||
|
"duplicate_rows_beyond_first": sum(n - 1 for _, n in dup),
|
||||||
|
"examples": [{"count": n, "identity": " | ".join(k)[:1500]} for k, n in dup[:20]],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def write_csv(path: Path, rows: List[Dict[str, Any]], fields: Optional[List[str]] = None):
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if fields is None:
|
||||||
|
fields = []
|
||||||
|
seen = set()
|
||||||
|
for r in rows:
|
||||||
|
for k in r:
|
||||||
|
if k not in seen:
|
||||||
|
seen.add(k)
|
||||||
|
fields.append(k)
|
||||||
|
with path.open("w", encoding="utf-8-sig", newline="") as f:
|
||||||
|
w = csv.DictWriter(f, fieldnames=fields, extrasaction="ignore")
|
||||||
|
w.writeheader()
|
||||||
|
for r in rows:
|
||||||
|
w.writerow({k: text(r.get(k, "")) for k in fields})
|
||||||
|
|
||||||
|
|
||||||
|
def make_samples(rows: List[Dict[str, Any]], n=5) -> List[Dict[str, Any]]:
|
||||||
|
buckets = defaultdict(list)
|
||||||
|
for r in rows:
|
||||||
|
cat = r["_issue_category"]
|
||||||
|
if len(buckets[cat]) < n:
|
||||||
|
buckets[cat].append(r)
|
||||||
|
result = []
|
||||||
|
for cat in sorted(buckets):
|
||||||
|
result.extend(buckets[cat])
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args():
|
||||||
|
p = argparse.ArgumentParser(description="CM93 MySQL Dataset audit + geometry issue classification")
|
||||||
|
p.add_argument("--host", default=os.getenv("MYSQL_HOST", "127.0.0.1"))
|
||||||
|
p.add_argument("--port", type=int, default=int(os.getenv("MYSQL_PORT", "3306")))
|
||||||
|
p.add_argument("--user", default=os.getenv("MYSQL_USER", "root"))
|
||||||
|
p.add_argument("--password", default=os.getenv("MYSQL_PASSWORD"))
|
||||||
|
p.add_argument("--database", default=os.getenv("MYSQL_DATABASE"))
|
||||||
|
p.add_argument("--dataset-id", type=int, default=1)
|
||||||
|
p.add_argument("--output", default=None)
|
||||||
|
p.add_argument("--max-issues", type=int, default=1000000)
|
||||||
|
return p.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
a = parse_args()
|
||||||
|
if not a.database:
|
||||||
|
a.database = input("MySQL database name: ").strip()
|
||||||
|
if not a.password:
|
||||||
|
a.password = getpass.getpass("MySQL password: ")
|
||||||
|
|
||||||
|
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
outdir = Path(a.output or f"cm93_audit_dataset_{a.dataset_id}_{stamp}")
|
||||||
|
outdir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
report: Dict[str, Any] = {
|
||||||
|
"generated_at": datetime.now().isoformat(timespec="seconds"),
|
||||||
|
"database": a.database,
|
||||||
|
"dataset_id": a.dataset_id,
|
||||||
|
"read_only": True,
|
||||||
|
"schema": {},
|
||||||
|
"counts": {},
|
||||||
|
"relationships": {},
|
||||||
|
"geometry": {},
|
||||||
|
"issues": {},
|
||||||
|
"warnings": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
conn = None
|
||||||
|
try:
|
||||||
|
conn = pymysql.connect(
|
||||||
|
host=a.host,
|
||||||
|
port=a.port,
|
||||||
|
user=a.user,
|
||||||
|
password=a.password,
|
||||||
|
database=a.database,
|
||||||
|
charset="utf8mb4",
|
||||||
|
cursorclass=DictCursor,
|
||||||
|
autocommit=False,
|
||||||
|
connect_timeout=20,
|
||||||
|
read_timeout=300,
|
||||||
|
write_timeout=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
try:
|
||||||
|
cur.execute("SET SESSION TRANSACTION READ ONLY")
|
||||||
|
except Exception as e:
|
||||||
|
report["warnings"].append(f"SET SESSION TRANSACTION READ ONLY failed: {e}")
|
||||||
|
cur.execute("START TRANSACTION READ ONLY")
|
||||||
|
|
||||||
|
print("[1/7] 扫描 schema...")
|
||||||
|
tables = discover_tables(cur, a.database)
|
||||||
|
schema = resolve_schema(tables)
|
||||||
|
report["schema"]["resolved_tables"] = {k: (v.name if v else None) for k, v in schema.items()}
|
||||||
|
report["schema"]["all_tables"] = {t.name: t.columns for t in tables}
|
||||||
|
for k, v in report["schema"]["resolved_tables"].items():
|
||||||
|
print(f" {k:16s}: {v}")
|
||||||
|
|
||||||
|
print("[2/7] 数量审计...")
|
||||||
|
for k in ["dataset", "cell", "feature", "attribute", "sounding", "geometry_issue"]:
|
||||||
|
report["counts"][k] = count_table(cur, schema[k], a.dataset_id)
|
||||||
|
|
||||||
|
print("[3/7] 关系完整性审计...")
|
||||||
|
report["relationships"]["feature_to_cell"] = orphan_count(
|
||||||
|
cur, schema["feature"], schema["cell"],
|
||||||
|
["cell_id", "cellid", "cell"], ["id", "cell_id", "cellid"], a.dataset_id)
|
||||||
|
report["relationships"]["attribute_to_feature"] = orphan_count(
|
||||||
|
cur, schema["attribute"], schema["feature"],
|
||||||
|
["feature_id", "featureid", "feature"], ["id", "feature_id", "featureid"], a.dataset_id)
|
||||||
|
report["relationships"]["sounding_to_feature"] = orphan_count(
|
||||||
|
cur, schema["sounding"], schema["feature"],
|
||||||
|
["feature_id", "featureid", "feature"], ["id", "feature_id", "featureid"], a.dataset_id)
|
||||||
|
report["relationships"]["issue_to_feature"] = orphan_count(
|
||||||
|
cur, schema["geometry_issue"], schema["feature"],
|
||||||
|
["feature_id", "featureid", "feature"], ["id", "feature_id", "featureid"], a.dataset_id)
|
||||||
|
|
||||||
|
print("[4/7] Geometry 基础审计...")
|
||||||
|
report["geometry"] = geometry_audit(cur, schema["feature"], a.dataset_id)
|
||||||
|
|
||||||
|
print("[5/7] 读取 geometry issues...")
|
||||||
|
raw, meta = fetch_issues(cur, schema["geometry_issue"], schema["feature"], schema["cell"], a.dataset_id, a.max_issues)
|
||||||
|
report["issues"]["meta"] = meta
|
||||||
|
print(f" loaded={len(raw)}")
|
||||||
|
|
||||||
|
print("[6/7] 分类 geometry issues...")
|
||||||
|
rows = enrich(raw)
|
||||||
|
sm = summarize(rows)
|
||||||
|
dup = duplicate_audit(rows)
|
||||||
|
report["issues"]["summary"] = sm
|
||||||
|
report["issues"]["duplicates"] = dup
|
||||||
|
|
||||||
|
write_csv(outdir / "geometry_issues_classified.csv", rows)
|
||||||
|
write_csv(outdir / "geometry_issue_samples.csv", make_samples(rows, 5))
|
||||||
|
write_csv(outdir / "geometry_issue_stage_category.csv", sm["by_stage_category"], ["stage", "category", "count"])
|
||||||
|
write_csv(outdir / "geometry_issue_object_class_top20.csv", sm["by_object_class_top20"], ["object_class", "count"])
|
||||||
|
write_csv(outdir / "geometry_issue_cell_top30.csv", sm["by_cell_top30"], ["cell", "count"])
|
||||||
|
|
||||||
|
expected = {
|
||||||
|
"cell": 784,
|
||||||
|
"feature": 322465,
|
||||||
|
"attribute": 405153,
|
||||||
|
"sounding": 275700,
|
||||||
|
"geometry_issue": 4406,
|
||||||
|
}
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
lines.append("CM93 -> MySQL Dataset Audit + Geometry Issue Classification")
|
||||||
|
lines.append("=" * 72)
|
||||||
|
lines.append(f"Generated: {report['generated_at']}")
|
||||||
|
lines.append(f"Database: {a.database}")
|
||||||
|
lines.append(f"Dataset ID: {a.dataset_id}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("Resolved tables:")
|
||||||
|
for k, v in report["schema"]["resolved_tables"].items():
|
||||||
|
lines.append(f" {k:16s}: {v}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("Counts:")
|
||||||
|
for k, v in report["counts"].items():
|
||||||
|
lines.append(f" {k:16s}: count={v['count']} table={v['table']} dataset_col={v['dataset_column']} error={v['error']}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("Relationships:")
|
||||||
|
for k, v in report["relationships"].items():
|
||||||
|
lines.append(f" {k:24s}: orphan={v['count']} error={v['error']}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("Geometry audit:")
|
||||||
|
lines.append(json.dumps(report["geometry"], ensure_ascii=False, indent=2, default=str))
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"Geometry issues loaded: {sm['total']}")
|
||||||
|
lines.append("By category:")
|
||||||
|
for k, n in sm["by_category"].items():
|
||||||
|
pct = n / sm["total"] * 100 if sm["total"] else 0
|
||||||
|
lines.append(f" {k:28s} {n:8d} {pct:7.3f}%")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("By stage:")
|
||||||
|
for k, n in sm["by_stage"].items():
|
||||||
|
pct = n / sm["total"] * 100 if sm["total"] else 0
|
||||||
|
lines.append(f" {k:16s} {n:8d} {pct:7.3f}%")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"Duplicate groups: {dup['duplicate_groups']}")
|
||||||
|
lines.append(f"Duplicate rows beyond first: {dup['duplicate_rows_beyond_first']}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("Acceptance checks:")
|
||||||
|
for k, exp in expected.items():
|
||||||
|
actual = report["counts"].get(k, {}).get("count")
|
||||||
|
lines.append(f" {k:16s}: actual={actual} expected={exp} => {'PASS' if actual == exp else 'CHECK'}")
|
||||||
|
category_sum = sum(sm["by_category"].values())
|
||||||
|
lines.append(f" issue loaded total: actual={sm['total']} expected=4406 => {'PASS' if sm['total'] == 4406 else 'CHECK'}")
|
||||||
|
lines.append(f" category sum: actual={category_sum} expected={sm['total']} => {'PASS' if category_sum == sm['total'] else 'FAIL'}")
|
||||||
|
lines.append(f" OTHER count: {sm['by_category'].get('OTHER', 0)}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("说明:")
|
||||||
|
lines.append(" 1. 本脚本仅根据数据库现有 schema、issue 文本和空间字段进行只读审计。")
|
||||||
|
lines.append(" 2. OTHER 表示现有文本证据不足,不表示该 issue 可以忽略。")
|
||||||
|
lines.append(" 3. 本脚本没有修复 geometry,也没有修改任何数据库记录。")
|
||||||
|
lines.append(" 4. 不包含任何 PBF 逻辑。")
|
||||||
|
|
||||||
|
(outdir / "audit_summary.txt").write_text("\n".join(lines), encoding="utf-8")
|
||||||
|
(outdir / "audit_report.json").write_text(json.dumps(report, ensure_ascii=False, indent=2, default=str), encoding="utf-8")
|
||||||
|
|
||||||
|
conn.rollback()
|
||||||
|
|
||||||
|
print("[7/7] 完成")
|
||||||
|
print("")
|
||||||
|
print("\n".join(lines[-18:]))
|
||||||
|
print("")
|
||||||
|
print(f"输出目录: {outdir.resolve()}")
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
if conn:
|
||||||
|
try:
|
||||||
|
conn.rollback()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
print("已中断。")
|
||||||
|
sys.exit(130)
|
||||||
|
except Exception:
|
||||||
|
if conn:
|
||||||
|
try:
|
||||||
|
conn.rollback()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
err = traceback.format_exc()
|
||||||
|
print(err)
|
||||||
|
try:
|
||||||
|
(outdir / "fatal_error.txt").write_text(err, encoding="utf-8")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
sys.exit(1)
|
||||||
|
finally:
|
||||||
|
if conn:
|
||||||
|
try:
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
719
cm93_mysql_audit_v2.py
Normal file
719
cm93_mysql_audit_v2.py
Normal file
@@ -0,0 +1,719 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
CM93 -> MySQL Dataset ID audit + geometry issue classification v2
|
||||||
|
Target schema discovered from Dataset 1 audit:
|
||||||
|
chart_dataset
|
||||||
|
chart_cell
|
||||||
|
chart_feature
|
||||||
|
chart_feature_attribute
|
||||||
|
chart_sounding_point
|
||||||
|
chart_point_geometry
|
||||||
|
chart_line_geometry
|
||||||
|
chart_area_geometry
|
||||||
|
chart_coverage
|
||||||
|
chart_import_job
|
||||||
|
chart_import_event
|
||||||
|
|
||||||
|
READ ONLY: SELECT only; transaction is opened READ ONLY.
|
||||||
|
No geometry repair, no re-import, no PBF.
|
||||||
|
|
||||||
|
Dependency:
|
||||||
|
pip install pymysql
|
||||||
|
|
||||||
|
Example:
|
||||||
|
python .\\cm93_mysql_audit_v2.py --host 127.0.0.1 --port 3306 --user root --database cm93_chart --dataset-id 1
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import getpass
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import traceback
|
||||||
|
from collections import Counter, defaultdict
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
|
||||||
|
|
||||||
|
try:
|
||||||
|
import pymysql
|
||||||
|
from pymysql.cursors import DictCursor
|
||||||
|
except ImportError:
|
||||||
|
print("Missing dependency: pymysql")
|
||||||
|
print("Install with: pip install pymysql")
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
EXPECTED = {
|
||||||
|
"cells": 784,
|
||||||
|
"features": 322465,
|
||||||
|
"attributes": 405153,
|
||||||
|
"sounding_points": 275700,
|
||||||
|
"geometry_issues": 4406,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Conservative text classification. Exact raw issue text is always preserved.
|
||||||
|
CATEGORY_RULES: List[Tuple[str, Sequence[str]]] = [
|
||||||
|
("INVALID_COORDINATE", [
|
||||||
|
r"\bnan\b", r"\binf(?:inity)?\b", r"invalid coordinate", r"coordinate.*out of range",
|
||||||
|
r"latitude.*out of range", r"longitude.*out of range", r"non[- ]?finite",
|
||||||
|
]),
|
||||||
|
("INSUFFICIENT_POINTS", [
|
||||||
|
r"insufficient point", r"too few point", r"not enough point", r"minimum.*point",
|
||||||
|
r"less than .* points?", r"requires? .* points?",
|
||||||
|
]),
|
||||||
|
("RING_NOT_CLOSED", [
|
||||||
|
r"ring.*not closed", r"not closed.*ring", r"unclosed ring", r"closed linestring",
|
||||||
|
]),
|
||||||
|
("SELF_INTERSECTION", [
|
||||||
|
r"self[- ]?intersection", r"self intersect", r"ring self", r"bow[- ]?tie",
|
||||||
|
]),
|
||||||
|
("INVALID_RING", [
|
||||||
|
r"invalid ring", r"ring.*invalid", r"illegal ring", r"shell.*invalid", r"hole.*invalid",
|
||||||
|
r"interior ring", r"exterior ring",
|
||||||
|
]),
|
||||||
|
("DEGENERATE_GEOMETRY", [
|
||||||
|
r"degenerate", r"zero[- ]?length", r"zero[- ]?area", r"collapsed", r"duplicate point",
|
||||||
|
r"all points.*same", r"too few distinct",
|
||||||
|
]),
|
||||||
|
("MULTIPART_ASSEMBLY", [
|
||||||
|
r"multipart", r"multi[- ]?part", r"cannot.*assemble", r"failed.*assemble", r"assembly failed",
|
||||||
|
r"cannot.*merge", r"failed.*merge", r"stitch",
|
||||||
|
]),
|
||||||
|
("TOPOLOGY_MISMATCH", [
|
||||||
|
r"topolog", r"missing edge", r"edge.*missing", r"missing node", r"node.*missing",
|
||||||
|
r"edge reference", r"node reference", r"primitive reference", r"dangling edge", r"broken chain",
|
||||||
|
r"disconnected",
|
||||||
|
]),
|
||||||
|
("GEOMETRY_TYPE_MISMATCH", [
|
||||||
|
r"type mismatch", r"geometry type", r"expected.*(?:point|line|polygon|area)",
|
||||||
|
r"unexpected.*(?:point|line|polygon|area)", r"wrong.*geometry",
|
||||||
|
]),
|
||||||
|
("EMPTY_GEOMETRY", [
|
||||||
|
r"empty geometry", r"geometry.*empty", r"no geometry", r"null geometry", r"geometry is null",
|
||||||
|
r"no coordinates?", r"empty point", r"empty line", r"empty polygon",
|
||||||
|
]),
|
||||||
|
("CONVERSION_EXCEPTION", [
|
||||||
|
r"wkb", r"wkt", r"parse exception", r"conversion exception", r"geometry exception",
|
||||||
|
r"failed to convert", r"cannot convert", r"invalid gis data", r"spatial.*exception",
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
|
||||||
|
STAGE_RULES: List[Tuple[str, Sequence[str]]] = [
|
||||||
|
("MYSQL_WRITE", [r"mysql", r"database", r"insert", r"spatial", r"wkb", r"wkt"]),
|
||||||
|
("VALIDATION", [r"valid", r"self[- ]?intersect", r"ring", r"geometry.*invalid"]),
|
||||||
|
("ASSEMBLY", [r"assemble", r"assembly", r"merge", r"stitch", r"multipart", r"chain"]),
|
||||||
|
("TOPOLOGY", [r"topolog", r"edge", r"node", r"primitive"]),
|
||||||
|
("DECODE", [r"decode", r"parse", r"read", r"binary", r"record"]),
|
||||||
|
("SOURCE", [r"source", r"raw", r"cm93"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def qi(s: str) -> str:
|
||||||
|
return "`" + s.replace("`", "``") + "`"
|
||||||
|
|
||||||
|
|
||||||
|
def text(v: Any) -> str:
|
||||||
|
if v is None:
|
||||||
|
return ""
|
||||||
|
if isinstance(v, (bytes, bytearray)):
|
||||||
|
return v.decode("utf-8", errors="replace")
|
||||||
|
return str(v)
|
||||||
|
|
||||||
|
|
||||||
|
def classify_category(raw: str) -> str:
|
||||||
|
for category, pats in CATEGORY_RULES:
|
||||||
|
if any(re.search(p, raw, re.I) for p in pats):
|
||||||
|
return category
|
||||||
|
return "OTHER"
|
||||||
|
|
||||||
|
|
||||||
|
def classify_stage(raw: str) -> str:
|
||||||
|
for stage, pats in STAGE_RULES:
|
||||||
|
if any(re.search(p, raw, re.I) for p in pats):
|
||||||
|
return stage
|
||||||
|
return "UNKNOWN"
|
||||||
|
|
||||||
|
|
||||||
|
def scalar(cur, sql: str, params: Sequence[Any] = ()) -> Any:
|
||||||
|
cur.execute(sql, params)
|
||||||
|
row = cur.fetchone()
|
||||||
|
return next(iter(row.values())) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def rows(cur, sql: str, params: Sequence[Any] = ()) -> List[Dict[str, Any]]:
|
||||||
|
cur.execute(sql, params)
|
||||||
|
return list(cur.fetchall())
|
||||||
|
|
||||||
|
|
||||||
|
def table_exists(cur, db: str, table: str) -> bool:
|
||||||
|
return bool(scalar(cur, "SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA=%s AND TABLE_NAME=%s", (db, table)))
|
||||||
|
|
||||||
|
|
||||||
|
def columns(cur, db: str, table: str) -> Dict[str, Dict[str, Any]]:
|
||||||
|
out = {}
|
||||||
|
for r in rows(cur, """
|
||||||
|
SELECT COLUMN_NAME, DATA_TYPE, COLUMN_TYPE, IS_NULLABLE, COLUMN_KEY
|
||||||
|
FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA=%s AND TABLE_NAME=%s
|
||||||
|
ORDER BY ORDINAL_POSITION
|
||||||
|
""", (db, table)):
|
||||||
|
out[r["COLUMN_NAME"]] = r
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def write_csv(path: Path, data: Iterable[Dict[str, Any]], fieldnames: Optional[List[str]] = None) -> None:
|
||||||
|
data = list(data)
|
||||||
|
if fieldnames is None:
|
||||||
|
fieldnames = []
|
||||||
|
seen = set()
|
||||||
|
for r in data:
|
||||||
|
for k in r:
|
||||||
|
if k not in seen:
|
||||||
|
fieldnames.append(k)
|
||||||
|
seen.add(k)
|
||||||
|
with path.open("w", encoding="utf-8-sig", newline="") as f:
|
||||||
|
w = csv.DictWriter(f, fieldnames=fieldnames or [], extrasaction="ignore")
|
||||||
|
w.writeheader()
|
||||||
|
for r in data:
|
||||||
|
w.writerow({k: text(r.get(k)) for k in (fieldnames or [])})
|
||||||
|
|
||||||
|
|
||||||
|
def safe(cur, sql: str, params: Sequence[Any] = ()) -> Dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return {"ok": True, "rows": rows(cur, sql, params)}
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_issue_value(v: Any) -> str:
|
||||||
|
s = text(v).strip()
|
||||||
|
if not s:
|
||||||
|
return ""
|
||||||
|
# If JSON, preserve a stable compact textual form to improve grouping.
|
||||||
|
try:
|
||||||
|
obj = json.loads(s)
|
||||||
|
return json.dumps(obj, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||||
|
except Exception:
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def issue_is_present_sql(col: str) -> str:
|
||||||
|
# Works for text/varchar/json-like columns; CAST keeps this generic.
|
||||||
|
return f"{qi(col)} IS NOT NULL AND TRIM(CAST({qi(col)} AS CHAR)) <> '' AND LOWER(TRIM(CAST({qi(col)} AS CHAR))) NOT IN ('0','false','none','null','[]','{{}}')"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--host", default=os.getenv("MYSQL_HOST", "127.0.0.1"))
|
||||||
|
ap.add_argument("--port", type=int, default=int(os.getenv("MYSQL_PORT", "3306")))
|
||||||
|
ap.add_argument("--user", default=os.getenv("MYSQL_USER", "root"))
|
||||||
|
ap.add_argument("--password", default=os.getenv("MYSQL_PASSWORD"))
|
||||||
|
ap.add_argument("--database", default=os.getenv("MYSQL_DATABASE", "cm93_chart"))
|
||||||
|
ap.add_argument("--dataset-id", type=int, default=1)
|
||||||
|
ap.add_argument("--output", default=None)
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
if args.password is None:
|
||||||
|
args.password = getpass.getpass("MySQL password: ")
|
||||||
|
|
||||||
|
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
out = Path(args.output or f"cm93_audit_v2_dataset_{args.dataset_id}_{stamp}")
|
||||||
|
out.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
report: Dict[str, Any] = {
|
||||||
|
"generated_at": datetime.now().isoformat(timespec="seconds"),
|
||||||
|
"database": args.database,
|
||||||
|
"dataset_id": args.dataset_id,
|
||||||
|
"read_only": True,
|
||||||
|
"expected": EXPECTED,
|
||||||
|
"schema": {},
|
||||||
|
"counts": {},
|
||||||
|
"relationships": {},
|
||||||
|
"import_jobs": [],
|
||||||
|
"cell_status": [],
|
||||||
|
"geometry_issue_reconciliation": {},
|
||||||
|
"geometry_storage": {},
|
||||||
|
"issue_summary": {},
|
||||||
|
"warnings": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
conn = None
|
||||||
|
try:
|
||||||
|
conn = pymysql.connect(
|
||||||
|
host=args.host, port=args.port, user=args.user, password=args.password,
|
||||||
|
database=args.database, charset="utf8mb4", cursorclass=DictCursor,
|
||||||
|
autocommit=False, connect_timeout=20, read_timeout=600, write_timeout=30,
|
||||||
|
)
|
||||||
|
cur = conn.cursor()
|
||||||
|
try:
|
||||||
|
cur.execute("SET SESSION TRANSACTION READ ONLY")
|
||||||
|
except Exception as e:
|
||||||
|
report["warnings"].append(f"SET SESSION TRANSACTION READ ONLY failed: {e}")
|
||||||
|
cur.execute("START TRANSACTION READ ONLY")
|
||||||
|
|
||||||
|
required = [
|
||||||
|
"chart_dataset", "chart_cell", "chart_feature", "chart_feature_attribute",
|
||||||
|
"chart_sounding_point", "chart_point_geometry", "chart_line_geometry",
|
||||||
|
"chart_area_geometry", "chart_coverage", "chart_import_job", "chart_import_event",
|
||||||
|
]
|
||||||
|
existing = {t: table_exists(cur, args.database, t) for t in required}
|
||||||
|
report["schema"]["tables"] = existing
|
||||||
|
report["schema"]["columns"] = {t: columns(cur, args.database, t) for t, ok in existing.items() if ok}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Basic counts, scoped correctly through joins when table has no dataset_id.
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
print("[1/8] Basic counts")
|
||||||
|
report["counts"]["datasets"] = int(scalar(cur, "SELECT COUNT(*) FROM chart_dataset WHERE id=%s", (args.dataset_id,)))
|
||||||
|
report["counts"]["cells"] = int(scalar(cur, "SELECT COUNT(*) FROM chart_cell WHERE dataset_id=%s", (args.dataset_id,)))
|
||||||
|
report["counts"]["features"] = int(scalar(cur, "SELECT COUNT(*) FROM chart_feature WHERE dataset_id=%s", (args.dataset_id,)))
|
||||||
|
report["counts"]["attributes"] = int(scalar(cur, """
|
||||||
|
SELECT COUNT(*) FROM chart_feature_attribute a
|
||||||
|
JOIN chart_feature f ON f.id=a.feature_id
|
||||||
|
WHERE f.dataset_id=%s
|
||||||
|
""", (args.dataset_id,)))
|
||||||
|
report["counts"]["sounding_points"] = int(scalar(cur, """
|
||||||
|
SELECT COUNT(*) FROM chart_sounding_point s
|
||||||
|
JOIN chart_feature f ON f.id=s.feature_id
|
||||||
|
WHERE f.dataset_id=%s
|
||||||
|
""", (args.dataset_id,)))
|
||||||
|
|
||||||
|
# Unknown object/attr dictionary mapping checks.
|
||||||
|
report["counts"]["feature_object_class_null"] = int(scalar(cur, """
|
||||||
|
SELECT COUNT(*) FROM chart_feature
|
||||||
|
WHERE dataset_id=%s AND (object_class IS NULL OR TRIM(object_class)='')
|
||||||
|
""", (args.dataset_id,)))
|
||||||
|
report["counts"]["attribute_name_null"] = int(scalar(cur, """
|
||||||
|
SELECT COUNT(*) FROM chart_feature_attribute a
|
||||||
|
JOIN chart_feature f ON f.id=a.feature_id
|
||||||
|
WHERE f.dataset_id=%s AND (a.attribute_name IS NULL OR TRIM(a.attribute_name)='')
|
||||||
|
""", (args.dataset_id,)))
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Relational integrity.
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
print("[2/8] Relational integrity")
|
||||||
|
rel_queries = {
|
||||||
|
"feature_to_cell_orphan": """
|
||||||
|
SELECT COUNT(*) n FROM chart_feature f
|
||||||
|
LEFT JOIN chart_cell c ON c.id=f.cell_id
|
||||||
|
WHERE f.dataset_id=%s AND c.id IS NULL
|
||||||
|
""",
|
||||||
|
"feature_cell_wrong_dataset": """
|
||||||
|
SELECT COUNT(*) n FROM chart_feature f
|
||||||
|
JOIN chart_cell c ON c.id=f.cell_id
|
||||||
|
WHERE f.dataset_id=%s AND c.dataset_id<>f.dataset_id
|
||||||
|
""",
|
||||||
|
"attribute_to_feature_orphan_global": """
|
||||||
|
SELECT COUNT(*) n FROM chart_feature_attribute a
|
||||||
|
LEFT JOIN chart_feature f ON f.id=a.feature_id
|
||||||
|
WHERE f.id IS NULL
|
||||||
|
""",
|
||||||
|
"sounding_to_feature_orphan_global": """
|
||||||
|
SELECT COUNT(*) n FROM chart_sounding_point s
|
||||||
|
LEFT JOIN chart_feature f ON f.id=s.feature_id
|
||||||
|
WHERE f.id IS NULL
|
||||||
|
""",
|
||||||
|
"point_geometry_orphan_global": """
|
||||||
|
SELECT COUNT(*) n FROM chart_point_geometry g
|
||||||
|
LEFT JOIN chart_feature f ON f.id=g.feature_id
|
||||||
|
WHERE f.id IS NULL
|
||||||
|
""",
|
||||||
|
"line_geometry_orphan_global": """
|
||||||
|
SELECT COUNT(*) n FROM chart_line_geometry g
|
||||||
|
LEFT JOIN chart_feature f ON f.id=g.feature_id
|
||||||
|
WHERE f.id IS NULL
|
||||||
|
""",
|
||||||
|
"area_geometry_orphan_global": """
|
||||||
|
SELECT COUNT(*) n FROM chart_area_geometry g
|
||||||
|
LEFT JOIN chart_feature f ON f.id=g.feature_id
|
||||||
|
WHERE f.id IS NULL
|
||||||
|
""",
|
||||||
|
}
|
||||||
|
for k, sql in rel_queries.items():
|
||||||
|
params = (args.dataset_id,) if "%s" in sql else ()
|
||||||
|
report["relationships"][k] = int(scalar(cur, sql, params))
|
||||||
|
|
||||||
|
# Duplicate geometry rows by feature_id should not exist.
|
||||||
|
for table in ["chart_point_geometry", "chart_line_geometry", "chart_area_geometry"]:
|
||||||
|
report["relationships"][f"{table}_duplicate_feature_ids"] = int(scalar(cur, f"""
|
||||||
|
SELECT COUNT(*) FROM (
|
||||||
|
SELECT g.feature_id FROM {table} g
|
||||||
|
JOIN chart_feature f ON f.id=g.feature_id
|
||||||
|
WHERE f.dataset_id=%s
|
||||||
|
GROUP BY g.feature_id HAVING COUNT(*)>1
|
||||||
|
) x
|
||||||
|
""", (args.dataset_id,)))
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Import jobs and cell status: this is likely where 4406 comes from.
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
print("[3/8] Import job / cell status")
|
||||||
|
report["import_jobs"] = rows(cur, """
|
||||||
|
SELECT id, dataset_id, status, started_at, finished_at,
|
||||||
|
cells_discovered, cells_done, cells_failed,
|
||||||
|
features_source, features_imported,
|
||||||
|
attributes_source, attributes_imported,
|
||||||
|
unknown_object_count, unknown_attribute_count,
|
||||||
|
geometry_invalid_count, geometry_repaired_count, summary_json
|
||||||
|
FROM chart_import_job
|
||||||
|
WHERE dataset_id=%s
|
||||||
|
ORDER BY id
|
||||||
|
""", (args.dataset_id,))
|
||||||
|
report["cell_status"] = rows(cur, """
|
||||||
|
SELECT import_status, COUNT(*) AS cell_count,
|
||||||
|
SUM(feature_count) AS feature_count,
|
||||||
|
SUM(attribute_count) AS attribute_count,
|
||||||
|
SUM(CASE WHEN error_message IS NOT NULL AND TRIM(error_message)<>'' THEN 1 ELSE 0 END) AS cells_with_error_message
|
||||||
|
FROM chart_cell
|
||||||
|
WHERE dataset_id=%s
|
||||||
|
GROUP BY import_status
|
||||||
|
ORDER BY cell_count DESC
|
||||||
|
""", (args.dataset_id,))
|
||||||
|
write_csv(out / "import_jobs.csv", report["import_jobs"])
|
||||||
|
write_csv(out / "cell_status.csv", report["cell_status"])
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Geometry issue storage in chart_feature and chart_coverage.
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
print("[4/8] Geometry issue reconciliation")
|
||||||
|
feature_cols = report["schema"]["columns"].get("chart_feature", {})
|
||||||
|
coverage_cols = report["schema"]["columns"].get("chart_coverage", {})
|
||||||
|
|
||||||
|
if "geometry_issue" not in feature_cols:
|
||||||
|
raise RuntimeError("chart_feature.geometry_issue not found")
|
||||||
|
|
||||||
|
issue_pred = issue_is_present_sql("geometry_issue")
|
||||||
|
feature_issue_count = int(scalar(cur, f"SELECT COUNT(*) FROM chart_feature WHERE dataset_id=%s AND {issue_pred}", (args.dataset_id,)))
|
||||||
|
coverage_issue_count = 0
|
||||||
|
if "geometry_issue" in coverage_cols:
|
||||||
|
coverage_issue_count = int(scalar(cur, f"SELECT COUNT(*) FROM chart_coverage WHERE dataset_id=%s AND {issue_pred}", (args.dataset_id,)))
|
||||||
|
|
||||||
|
feature_repaired_count = int(scalar(cur, "SELECT COUNT(*) FROM chart_feature WHERE dataset_id=%s AND COALESCE(geometry_repaired,0)<>0", (args.dataset_id,)))
|
||||||
|
coverage_repaired_count = 0
|
||||||
|
if "geometry_repaired" in coverage_cols:
|
||||||
|
coverage_repaired_count = int(scalar(cur, "SELECT COUNT(*) FROM chart_coverage WHERE dataset_id=%s AND COALESCE(geometry_repaired,0)<>0", (args.dataset_id,)))
|
||||||
|
|
||||||
|
job_invalid_total = sum(int(r.get("geometry_invalid_count") or 0) for r in report["import_jobs"])
|
||||||
|
job_repaired_total = sum(int(r.get("geometry_repaired_count") or 0) for r in report["import_jobs"])
|
||||||
|
report["geometry_issue_reconciliation"] = {
|
||||||
|
"feature_geometry_issue_rows": feature_issue_count,
|
||||||
|
"coverage_geometry_issue_rows": coverage_issue_count,
|
||||||
|
"feature_geometry_repaired_rows": feature_repaired_count,
|
||||||
|
"coverage_geometry_repaired_rows": coverage_repaired_count,
|
||||||
|
"sum_import_job_geometry_invalid_count": job_invalid_total,
|
||||||
|
"sum_import_job_geometry_repaired_count": job_repaired_total,
|
||||||
|
"expected_geometry_issues": EXPECTED["geometry_issues"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Distribution of raw feature geometry_issue values.
|
||||||
|
issue_value_counts = rows(cur, f"""
|
||||||
|
SELECT CAST(geometry_issue AS CHAR) AS geometry_issue, COUNT(*) AS n
|
||||||
|
FROM chart_feature
|
||||||
|
WHERE dataset_id=%s AND {issue_pred}
|
||||||
|
GROUP BY CAST(geometry_issue AS CHAR)
|
||||||
|
ORDER BY n DESC, geometry_issue
|
||||||
|
""", (args.dataset_id,))
|
||||||
|
write_csv(out / "geometry_issue_raw_value_counts.csv", issue_value_counts)
|
||||||
|
|
||||||
|
# Import events related to geometry, including raw event_code/message patterns.
|
||||||
|
geom_events = rows(cur, """
|
||||||
|
SELECT e.id, e.job_id, e.cell_id, e.level, e.event_code, e.message, e.details_json, e.created_at,
|
||||||
|
c.cell_name, c.source_relative_path
|
||||||
|
FROM chart_import_event e
|
||||||
|
LEFT JOIN chart_import_job j ON j.id=e.job_id
|
||||||
|
LEFT JOIN chart_cell c ON c.id=e.cell_id
|
||||||
|
WHERE j.dataset_id=%s
|
||||||
|
AND (
|
||||||
|
LOWER(COALESCE(e.event_code,'')) LIKE '%%geom%%'
|
||||||
|
OR LOWER(COALESCE(e.message,'')) LIKE '%%geom%%'
|
||||||
|
OR LOWER(COALESCE(e.message,'')) LIKE '%%ring%%'
|
||||||
|
OR LOWER(COALESCE(e.message,'')) LIKE '%%polygon%%'
|
||||||
|
OR LOWER(COALESCE(e.message,'')) LIKE '%%line%%'
|
||||||
|
OR LOWER(COALESCE(e.message,'')) LIKE '%%point%%'
|
||||||
|
OR LOWER(COALESCE(e.message,'')) LIKE '%%topolog%%'
|
||||||
|
)
|
||||||
|
ORDER BY e.id
|
||||||
|
""", (args.dataset_id,))
|
||||||
|
write_csv(out / "geometry_related_import_events.csv", geom_events)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Pull exact issue feature rows. Preserve raw geometry_issue and enough context.
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
print("[5/8] Classifying feature.geometry_issue rows")
|
||||||
|
issue_rows = rows(cur, f"""
|
||||||
|
SELECT
|
||||||
|
f.id AS feature_id,
|
||||||
|
f.dataset_id,
|
||||||
|
f.cell_id,
|
||||||
|
c.cell_name,
|
||||||
|
c.source_relative_path,
|
||||||
|
f.coverage_id,
|
||||||
|
f.source_record_index,
|
||||||
|
f.object_class_id,
|
||||||
|
f.object_class,
|
||||||
|
f.geometry_type,
|
||||||
|
f.raw_geotype,
|
||||||
|
f.scale_code,
|
||||||
|
f.native_scale,
|
||||||
|
f.geometry_repaired,
|
||||||
|
CAST(f.geometry_issue AS CHAR) AS geometry_issue,
|
||||||
|
f.bbox_min_lon, f.bbox_min_lat, f.bbox_max_lon, f.bbox_max_lat,
|
||||||
|
CASE WHEN pg.feature_id IS NOT NULL THEN 1 ELSE 0 END AS has_point_geometry,
|
||||||
|
CASE WHEN lg.feature_id IS NOT NULL THEN 1 ELSE 0 END AS has_line_geometry,
|
||||||
|
CASE WHEN ag.feature_id IS NOT NULL THEN 1 ELSE 0 END AS has_area_geometry,
|
||||||
|
(SELECT COUNT(*) FROM chart_sounding_point sp WHERE sp.feature_id=f.id) AS sounding_point_count
|
||||||
|
FROM chart_feature f
|
||||||
|
JOIN chart_cell c ON c.id=f.cell_id
|
||||||
|
LEFT JOIN chart_point_geometry pg ON pg.feature_id=f.id
|
||||||
|
LEFT JOIN chart_line_geometry lg ON lg.feature_id=f.id
|
||||||
|
LEFT JOIN chart_area_geometry ag ON ag.feature_id=f.id
|
||||||
|
WHERE f.dataset_id=%s AND {issue_pred.replace(qi('geometry_issue'), 'f.'+qi('geometry_issue'))}
|
||||||
|
ORDER BY f.id
|
||||||
|
""", (args.dataset_id,))
|
||||||
|
|
||||||
|
for r in issue_rows:
|
||||||
|
raw = normalize_issue_value(r.get("geometry_issue"))
|
||||||
|
r["issue_normalized"] = raw
|
||||||
|
r["issue_category"] = classify_category(raw)
|
||||||
|
r["issue_stage"] = classify_stage(raw)
|
||||||
|
has_geom = bool(r.get("has_point_geometry") or r.get("has_line_geometry") or r.get("has_area_geometry") or int(r.get("sounding_point_count") or 0) > 0)
|
||||||
|
r["has_any_geometry_storage"] = 1 if has_geom else 0
|
||||||
|
|
||||||
|
# Conservative evidence label. Do not claim importer bug from text alone.
|
||||||
|
if r["issue_category"] == "OTHER":
|
||||||
|
r["assessment"] = "NEEDS_TEXT_REVIEW"
|
||||||
|
elif r["issue_category"] in {"TOPOLOGY_MISMATCH", "MULTIPART_ASSEMBLY", "CONVERSION_EXCEPTION"}:
|
||||||
|
r["assessment"] = "IMPORTER_OR_SOURCE_CANDIDATE"
|
||||||
|
else:
|
||||||
|
r["assessment"] = "GEOMETRY_DATA_OR_ASSEMBLY_ISSUE"
|
||||||
|
|
||||||
|
write_csv(out / "geometry_issues_classified_v2.csv", issue_rows)
|
||||||
|
|
||||||
|
# Counts by category/stage/object/cell/raw issue text.
|
||||||
|
by_cat = Counter(r["issue_category"] for r in issue_rows)
|
||||||
|
by_stage = Counter(r["issue_stage"] for r in issue_rows)
|
||||||
|
by_cross = Counter((r["issue_stage"], r["issue_category"]) for r in issue_rows)
|
||||||
|
by_obj = Counter((text(r.get("object_class_id")), text(r.get("object_class"))) for r in issue_rows)
|
||||||
|
by_cell = Counter((text(r.get("cell_name")), text(r.get("source_relative_path"))) for r in issue_rows)
|
||||||
|
by_raw = Counter(r["issue_normalized"] for r in issue_rows)
|
||||||
|
|
||||||
|
issue_summary = {
|
||||||
|
"feature_issue_rows_loaded": len(issue_rows),
|
||||||
|
"category_sum": sum(by_cat.values()),
|
||||||
|
"other_count": by_cat.get("OTHER", 0),
|
||||||
|
"by_category": [{"category": k, "count": v} for k, v in by_cat.most_common()],
|
||||||
|
"by_stage": [{"stage": k, "count": v} for k, v in by_stage.most_common()],
|
||||||
|
"by_stage_category": [{"stage": k[0], "category": k[1], "count": v} for k, v in by_cross.most_common()],
|
||||||
|
"by_object_class_top50": [{"object_class_id": k[0], "object_class": k[1], "count": v} for k, v in by_obj.most_common(50)],
|
||||||
|
"by_cell_top50": [{"cell_name": k[0], "source_relative_path": k[1], "count": v} for k, v in by_cell.most_common(50)],
|
||||||
|
"raw_issue_top100": [{"geometry_issue": k, "count": v} for k, v in by_raw.most_common(100)],
|
||||||
|
}
|
||||||
|
report["issue_summary"] = issue_summary
|
||||||
|
write_csv(out / "geometry_issue_by_category.csv", issue_summary["by_category"])
|
||||||
|
write_csv(out / "geometry_issue_by_stage.csv", issue_summary["by_stage"])
|
||||||
|
write_csv(out / "geometry_issue_stage_category.csv", issue_summary["by_stage_category"])
|
||||||
|
write_csv(out / "geometry_issue_object_class_top50.csv", issue_summary["by_object_class_top50"])
|
||||||
|
write_csv(out / "geometry_issue_cell_top50.csv", issue_summary["by_cell_top50"])
|
||||||
|
write_csv(out / "geometry_issue_raw_top100.csv", issue_summary["raw_issue_top100"])
|
||||||
|
|
||||||
|
# Samples: up to 10 per category, prioritizing different object/cell/raw combinations.
|
||||||
|
samples = []
|
||||||
|
seen_per_cat: Dict[str, set] = defaultdict(set)
|
||||||
|
for r in issue_rows:
|
||||||
|
cat = r["issue_category"]
|
||||||
|
sig = (r.get("object_class_id"), r.get("cell_id"), r.get("issue_normalized"))
|
||||||
|
if len(seen_per_cat[cat]) < 10 and sig not in seen_per_cat[cat]:
|
||||||
|
samples.append(r)
|
||||||
|
seen_per_cat[cat].add(sig)
|
||||||
|
write_csv(out / "geometry_issue_samples_v2.csv", samples)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Geometry storage audit: true spatial columns live in separate tables.
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
print("[6/8] Spatial geometry storage audit")
|
||||||
|
storage = {}
|
||||||
|
for name, table in [("POINT", "chart_point_geometry"), ("LINE", "chart_line_geometry"), ("AREA", "chart_area_geometry")]:
|
||||||
|
item: Dict[str, Any] = {}
|
||||||
|
item["row_count"] = int(scalar(cur, f"""
|
||||||
|
SELECT COUNT(*) FROM {table} g
|
||||||
|
JOIN chart_feature f ON f.id=g.feature_id
|
||||||
|
WHERE f.dataset_id=%s
|
||||||
|
""", (args.dataset_id,)))
|
||||||
|
item["feature_count"] = int(scalar(cur, f"""
|
||||||
|
SELECT COUNT(DISTINCT g.feature_id) FROM {table} g
|
||||||
|
JOIN chart_feature f ON f.id=g.feature_id
|
||||||
|
WHERE f.dataset_id=%s
|
||||||
|
""", (args.dataset_id,)))
|
||||||
|
item["null_geom"] = int(scalar(cur, f"""
|
||||||
|
SELECT COUNT(*) FROM {table} g
|
||||||
|
JOIN chart_feature f ON f.id=g.feature_id
|
||||||
|
WHERE f.dataset_id=%s AND g.geom IS NULL
|
||||||
|
""", (args.dataset_id,)))
|
||||||
|
|
||||||
|
# Spatial functions may fail on a bad row. Capture rather than abort entire audit.
|
||||||
|
st = safe(cur, f"""
|
||||||
|
SELECT
|
||||||
|
ST_GeometryType(g.geom) AS geometry_type,
|
||||||
|
COUNT(*) AS n,
|
||||||
|
SUM(CASE WHEN ST_IsEmpty(g.geom) THEN 1 ELSE 0 END) AS empty_count,
|
||||||
|
SUM(CASE WHEN ST_IsValid(g.geom) THEN 1 ELSE 0 END) AS valid_count,
|
||||||
|
SUM(CASE WHEN NOT ST_IsValid(g.geom) THEN 1 ELSE 0 END) AS invalid_count
|
||||||
|
FROM {table} g
|
||||||
|
JOIN chart_feature f ON f.id=g.feature_id
|
||||||
|
WHERE f.dataset_id=%s AND g.geom IS NOT NULL
|
||||||
|
GROUP BY ST_GeometryType(g.geom)
|
||||||
|
ORDER BY n DESC
|
||||||
|
""", (args.dataset_id,))
|
||||||
|
item["spatial_function_result"] = st
|
||||||
|
storage[name] = item
|
||||||
|
|
||||||
|
# Sounding points have their own geom.
|
||||||
|
sounding_item: Dict[str, Any] = {
|
||||||
|
"row_count": report["counts"]["sounding_points"],
|
||||||
|
"null_geom": int(scalar(cur, """
|
||||||
|
SELECT COUNT(*) FROM chart_sounding_point s
|
||||||
|
JOIN chart_feature f ON f.id=s.feature_id
|
||||||
|
WHERE f.dataset_id=%s AND s.geom IS NULL
|
||||||
|
""", (args.dataset_id,))),
|
||||||
|
}
|
||||||
|
sounding_item["spatial_function_result"] = safe(cur, """
|
||||||
|
SELECT ST_GeometryType(s.geom) AS geometry_type, COUNT(*) AS n,
|
||||||
|
SUM(CASE WHEN ST_IsEmpty(s.geom) THEN 1 ELSE 0 END) AS empty_count,
|
||||||
|
SUM(CASE WHEN ST_IsValid(s.geom) THEN 1 ELSE 0 END) AS valid_count,
|
||||||
|
SUM(CASE WHEN NOT ST_IsValid(s.geom) THEN 1 ELSE 0 END) AS invalid_count
|
||||||
|
FROM chart_sounding_point s
|
||||||
|
JOIN chart_feature f ON f.id=s.feature_id
|
||||||
|
WHERE f.dataset_id=%s AND s.geom IS NOT NULL
|
||||||
|
GROUP BY ST_GeometryType(s.geom)
|
||||||
|
""", (args.dataset_id,))
|
||||||
|
storage["SOUNDING"] = sounding_item
|
||||||
|
report["geometry_storage"] = storage
|
||||||
|
|
||||||
|
# Geometry type distribution from feature semantic field, separate from spatial columns.
|
||||||
|
geom_type_dist = rows(cur, """
|
||||||
|
SELECT geometry_type, raw_geotype, COUNT(*) AS n,
|
||||||
|
SUM(CASE WHEN geometry_repaired<>0 THEN 1 ELSE 0 END) AS repaired_n,
|
||||||
|
SUM(CASE WHEN geometry_issue IS NOT NULL AND TRIM(CAST(geometry_issue AS CHAR))<>'' THEN 1 ELSE 0 END) AS issue_n
|
||||||
|
FROM chart_feature
|
||||||
|
WHERE dataset_id=%s
|
||||||
|
GROUP BY geometry_type, raw_geotype
|
||||||
|
ORDER BY n DESC
|
||||||
|
""", (args.dataset_id,))
|
||||||
|
write_csv(out / "feature_geometry_type_distribution.csv", geom_type_dist)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Cell-level issue rates.
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
print("[7/8] Cell issue rates")
|
||||||
|
cell_issue_rates = rows(cur, f"""
|
||||||
|
SELECT c.id AS cell_id, c.cell_name, c.source_relative_path, c.feature_count,
|
||||||
|
COUNT(f.id) AS feature_rows,
|
||||||
|
SUM(CASE WHEN {issue_pred.replace(qi('geometry_issue'), 'f.'+qi('geometry_issue'))} THEN 1 ELSE 0 END) AS issue_count,
|
||||||
|
ROUND(100.0 * SUM(CASE WHEN {issue_pred.replace(qi('geometry_issue'), 'f.'+qi('geometry_issue'))} THEN 1 ELSE 0 END) / NULLIF(COUNT(f.id),0), 6) AS issue_rate_pct
|
||||||
|
FROM chart_cell c
|
||||||
|
LEFT JOIN chart_feature f ON f.cell_id=c.id AND f.dataset_id=c.dataset_id
|
||||||
|
WHERE c.dataset_id=%s
|
||||||
|
GROUP BY c.id, c.cell_name, c.source_relative_path, c.feature_count
|
||||||
|
ORDER BY issue_count DESC, c.id
|
||||||
|
""", (args.dataset_id,))
|
||||||
|
write_csv(out / "geometry_issue_cell_rates_all.csv", cell_issue_rates)
|
||||||
|
write_csv(out / "geometry_issue_cell_top30_count.csv", cell_issue_rates[:30])
|
||||||
|
write_csv(out / "geometry_issue_cell_top30_rate.csv", sorted(cell_issue_rates, key=lambda r: float(r.get("issue_rate_pct") or 0), reverse=True)[:30])
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Final report and acceptance checks.
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
print("[8/8] Writing report")
|
||||||
|
report["acceptance"] = {
|
||||||
|
"cells_match": report["counts"]["cells"] == EXPECTED["cells"],
|
||||||
|
"features_match": report["counts"]["features"] == EXPECTED["features"],
|
||||||
|
"attributes_match": report["counts"]["attributes"] == EXPECTED["attributes"],
|
||||||
|
"sounding_points_match": report["counts"]["sounding_points"] == EXPECTED["sounding_points"],
|
||||||
|
"feature_issue_rows_match_4406": len(issue_rows) == EXPECTED["geometry_issues"],
|
||||||
|
"category_sum_equals_issue_rows": sum(by_cat.values()) == len(issue_rows),
|
||||||
|
"no_feature_cell_orphans": report["relationships"]["feature_to_cell_orphan"] == 0,
|
||||||
|
"no_attribute_orphans": report["relationships"]["attribute_to_feature_orphan_global"] == 0,
|
||||||
|
"no_sounding_orphans": report["relationships"]["sounding_to_feature_orphan_global"] == 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
(out / "audit_report_v2.json").write_text(json.dumps(report, ensure_ascii=False, indent=2, default=str), encoding="utf-8")
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
lines.append("CM93 -> MySQL Dataset ID=1 Audit v2")
|
||||||
|
lines.append("=" * 78)
|
||||||
|
lines.append(f"Generated: {report['generated_at']}")
|
||||||
|
lines.append(f"Database: {args.database}")
|
||||||
|
lines.append(f"Dataset ID: {args.dataset_id}")
|
||||||
|
lines.append("Mode: READ ONLY / NO PBF")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("COUNTS")
|
||||||
|
for k, v in report["counts"].items():
|
||||||
|
lines.append(f" {k:30s} {v}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("RELATIONSHIPS")
|
||||||
|
for k, v in report["relationships"].items():
|
||||||
|
lines.append(f" {k:40s} {v}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("GEOMETRY ISSUE RECONCILIATION")
|
||||||
|
for k, v in report["geometry_issue_reconciliation"].items():
|
||||||
|
lines.append(f" {k:42s} {v}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("ISSUE CATEGORY")
|
||||||
|
for r in issue_summary["by_category"]:
|
||||||
|
pct = (100.0 * r["count"] / len(issue_rows)) if issue_rows else 0
|
||||||
|
lines.append(f" {r['category']:30s} {r['count']:7d} {pct:8.3f}%")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("ISSUE STAGE")
|
||||||
|
for r in issue_summary["by_stage"]:
|
||||||
|
pct = (100.0 * r["count"] / len(issue_rows)) if issue_rows else 0
|
||||||
|
lines.append(f" {r['stage']:20s} {r['count']:7d} {pct:8.3f}%")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("TOP RAW ISSUE VALUES")
|
||||||
|
for r in issue_summary["raw_issue_top100"][:30]:
|
||||||
|
s = r["geometry_issue"].replace("\n", " ")
|
||||||
|
lines.append(f" {r['count']:7d} {s[:180]}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("ACCEPTANCE")
|
||||||
|
for k, v in report["acceptance"].items():
|
||||||
|
lines.append(f" {k:42s} {'PASS' if v else 'CHECK'}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("IMPORTANT")
|
||||||
|
lines.append(" - category_sum must equal feature_issue_rows_loaded.")
|
||||||
|
lines.append(" - OTHER is not considered fully understood; inspect raw top values/samples.")
|
||||||
|
lines.append(" - No repair or database mutation was performed.")
|
||||||
|
lines.append(" - PBF remains out of scope.")
|
||||||
|
(out / "audit_summary_v2.txt").write_text("\n".join(lines), encoding="utf-8")
|
||||||
|
|
||||||
|
conn.rollback()
|
||||||
|
print("\n".join(lines))
|
||||||
|
print(f"\nOutput: {out.resolve()}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
if conn:
|
||||||
|
try:
|
||||||
|
conn.rollback()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
err = traceback.format_exc()
|
||||||
|
print(err)
|
||||||
|
try:
|
||||||
|
(out / "fatal_error.txt").write_text(err, encoding="utf-8")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return 1
|
||||||
|
finally:
|
||||||
|
if conn:
|
||||||
|
try:
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
1162
cm93_mysql_fillrule_audit_v8.py
Normal file
1162
cm93_mysql_fillrule_audit_v8.py
Normal file
File diff suppressed because it is too large
Load Diff
182
cm93_mysql_final_geometry_audit_v1.py
Normal file
182
cm93_mysql_final_geometry_audit_v1.py
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import annotations
|
||||||
|
import argparse, csv, getpass, json, sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
import pymysql
|
||||||
|
from pymysql.cursors import DictCursor
|
||||||
|
except ImportError:
|
||||||
|
print("缺少 pymysql:pip install pymysql")
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
from shapely import from_wkb
|
||||||
|
def load_wkb(x): return from_wkb(x)
|
||||||
|
except ImportError:
|
||||||
|
from shapely import wkb
|
||||||
|
def load_wkb(x): return wkb.loads(x)
|
||||||
|
except ImportError:
|
||||||
|
print("缺少 shapely:pip install shapely")
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
def scalar(cur, sql, params=()):
|
||||||
|
cur.execute(sql, params)
|
||||||
|
r = cur.fetchone()
|
||||||
|
return None if not r else next(iter(r.values()))
|
||||||
|
|
||||||
|
def as_bytes(v):
|
||||||
|
if isinstance(v, memoryview): return v.tobytes()
|
||||||
|
if isinstance(v, bytearray): return bytes(v)
|
||||||
|
return v
|
||||||
|
|
||||||
|
def wkb_fn(cur, table):
|
||||||
|
for fn in ("ST_AsBinary","ST_AsWKB","AsBinary","AsWKB"):
|
||||||
|
try:
|
||||||
|
cur.execute(f"SELECT {fn}(geom) AS g FROM {table} LIMIT 1")
|
||||||
|
cur.fetchone()
|
||||||
|
return fn
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise RuntimeError(f"{table}: 无可用 Geometry->WKB 函数")
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description="CM93 normalization 最终全库 Geometry 验收")
|
||||||
|
ap.add_argument("--host", default="127.0.0.1")
|
||||||
|
ap.add_argument("--port", type=int, default=3306)
|
||||||
|
ap.add_argument("--user", default="root")
|
||||||
|
ap.add_argument("--password")
|
||||||
|
ap.add_argument("--database", default="cm93_chart")
|
||||||
|
ap.add_argument("--dataset-id", type=int, default=1)
|
||||||
|
ap.add_argument("--bbox-tolerance", type=float, default=5e-6)
|
||||||
|
a = ap.parse_args()
|
||||||
|
if a.password is None:
|
||||||
|
a.password = getpass.getpass("MySQL password: ")
|
||||||
|
|
||||||
|
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
outdir = Path(f"cm93_final_geometry_audit_{stamp}")
|
||||||
|
outdir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
conn = pymysql.connect(
|
||||||
|
host=a.host, port=a.port, user=a.user, password=a.password,
|
||||||
|
database=a.database, charset="utf8mb4",
|
||||||
|
cursorclass=DictCursor, autocommit=False,
|
||||||
|
connect_timeout=20, read_timeout=900
|
||||||
|
)
|
||||||
|
|
||||||
|
failures = []
|
||||||
|
report = {"dataset_id": a.dataset_id, "generated_at": datetime.now().isoformat()}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
try: cur.execute("SET SESSION TRANSACTION READ ONLY")
|
||||||
|
except Exception: pass
|
||||||
|
try: cur.execute("START TRANSACTION READ ONLY")
|
||||||
|
except Exception: cur.execute("START TRANSACTION")
|
||||||
|
|
||||||
|
counts = {
|
||||||
|
"cells": int(scalar(cur,"SELECT COUNT(*) FROM chart_cell WHERE dataset_id=%s",(a.dataset_id,)) or 0),
|
||||||
|
"features": int(scalar(cur,"SELECT COUNT(*) FROM chart_feature WHERE dataset_id=%s",(a.dataset_id,)) or 0),
|
||||||
|
"feature_repaired": int(scalar(cur,"SELECT COUNT(*) FROM chart_feature WHERE dataset_id=%s AND geometry_repaired=1",(a.dataset_id,)) or 0),
|
||||||
|
"coverage_repaired": int(scalar(cur,"SELECT COUNT(*) FROM chart_coverage WHERE dataset_id=%s AND geometry_repaired=1",(a.dataset_id,)) or 0),
|
||||||
|
"feature_issues": int(scalar(cur,"SELECT COUNT(*) FROM chart_feature WHERE dataset_id=%s AND geometry_issue IS NOT NULL",(a.dataset_id,)) or 0),
|
||||||
|
"coverage_issues": int(scalar(cur,"SELECT COUNT(*) FROM chart_coverage WHERE dataset_id=%s AND geometry_issue IS NOT NULL",(a.dataset_id,)) or 0),
|
||||||
|
"geotype16_total": int(scalar(cur,"SELECT COUNT(*) FROM chart_feature WHERE dataset_id=%s AND raw_geotype=16",(a.dataset_id,)) or 0),
|
||||||
|
"geotype16_issues": int(scalar(cur,"SELECT COUNT(*) FROM chart_feature WHERE dataset_id=%s AND raw_geotype=16 AND geometry_issue IS NOT NULL",(a.dataset_id,)) or 0),
|
||||||
|
}
|
||||||
|
report["counts"] = counts
|
||||||
|
|
||||||
|
expected = {
|
||||||
|
"cells": 784, "features": 322465,
|
||||||
|
"feature_repaired": 55, "coverage_repaired": 3,
|
||||||
|
"feature_issues": 0, "coverage_issues": 0,
|
||||||
|
"geotype16_total": 4406, "geotype16_issues": 0,
|
||||||
|
}
|
||||||
|
for k,v in expected.items():
|
||||||
|
if counts[k] != v:
|
||||||
|
failures.append({"check":k,"expected":v,"actual":counts[k]})
|
||||||
|
|
||||||
|
afn = wkb_fn(cur, "chart_area_geometry")
|
||||||
|
cur.execute(f"""
|
||||||
|
SELECT f.id,f.object_class,f.geometry_repaired,
|
||||||
|
f.bbox_min_lon,f.bbox_min_lat,f.bbox_max_lon,f.bbox_max_lat,
|
||||||
|
{afn}(g.geom) AS wkb
|
||||||
|
FROM chart_feature f
|
||||||
|
JOIN chart_area_geometry g ON g.feature_id=f.id
|
||||||
|
WHERE f.dataset_id=%s
|
||||||
|
""",(a.dataset_id,))
|
||||||
|
area_total=area_valid=bbox_bad=0
|
||||||
|
for r in cur:
|
||||||
|
area_total += 1
|
||||||
|
try:
|
||||||
|
g = load_wkb(as_bytes(r["wkb"]))
|
||||||
|
if g.is_empty or g.geom_type not in ("Polygon","MultiPolygon") or not g.is_valid:
|
||||||
|
failures.append({"check":"area_invalid","feature_id":r["id"],"object_class":r["object_class"],"geom_type":g.geom_type,"is_valid":bool(g.is_valid)})
|
||||||
|
continue
|
||||||
|
area_valid += 1
|
||||||
|
dbb=(float(r["bbox_min_lon"]),float(r["bbox_min_lat"]),float(r["bbox_max_lon"]),float(r["bbox_max_lat"]))
|
||||||
|
gb=tuple(float(x) for x in g.bounds)
|
||||||
|
if any(abs(x-y)>a.bbox_tolerance for x,y in zip(dbb,gb)):
|
||||||
|
bbox_bad += 1
|
||||||
|
failures.append({"check":"area_bbox","feature_id":r["id"],"db_bbox":dbb,"geom_bbox":gb})
|
||||||
|
except Exception as e:
|
||||||
|
failures.append({"check":"area_wkb","feature_id":r["id"],"error":repr(e)})
|
||||||
|
|
||||||
|
cfn = wkb_fn(cur, "chart_coverage")
|
||||||
|
cur.execute(f"""
|
||||||
|
SELECT id,cell_id,source_record_index,geometry_repaired,{cfn}(geom) AS wkb
|
||||||
|
FROM chart_coverage WHERE dataset_id=%s
|
||||||
|
""",(a.dataset_id,))
|
||||||
|
cov_total=cov_valid=0
|
||||||
|
for r in cur:
|
||||||
|
cov_total += 1
|
||||||
|
try:
|
||||||
|
g = load_wkb(as_bytes(r["wkb"]))
|
||||||
|
if g.is_empty or g.geom_type not in ("Polygon","MultiPolygon") or not g.is_valid:
|
||||||
|
failures.append({"check":"coverage_invalid","coverage_id":r["id"],"cell_id":r["cell_id"],"geom_type":g.geom_type,"is_valid":bool(g.is_valid)})
|
||||||
|
continue
|
||||||
|
cov_valid += 1
|
||||||
|
except Exception as e:
|
||||||
|
failures.append({"check":"coverage_wkb","coverage_id":r["id"],"error":repr(e)})
|
||||||
|
|
||||||
|
report["area"]={"total":area_total,"valid":area_valid,"bbox_mismatch":bbox_bad}
|
||||||
|
report["coverage"]={"total":cov_total,"valid":cov_valid}
|
||||||
|
conn.rollback()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
report["failure_count"]=len(failures)
|
||||||
|
report["overall"]="PASS" if not failures else "FAIL"
|
||||||
|
|
||||||
|
with (outdir/"失败明细_v1.csv").open("w",encoding="utf-8-sig",newline="") as f:
|
||||||
|
keys=["check","expected","actual","feature_id","coverage_id","cell_id","object_class","geom_type","is_valid","db_bbox","geom_bbox","error"]
|
||||||
|
w=csv.DictWriter(f,fieldnames=keys,extrasaction="ignore"); w.writeheader(); w.writerows(failures)
|
||||||
|
|
||||||
|
(outdir/"最终验收报告_v1.json").write_text(json.dumps(report,ensure_ascii=False,indent=2),encoding="utf-8")
|
||||||
|
summary = f"""CM93 normalization 最终全库 Geometry 验收 v1
|
||||||
|
Dataset ID : {a.dataset_id}
|
||||||
|
Cells : {report['counts']['cells']}
|
||||||
|
Features : {report['counts']['features']}
|
||||||
|
Feature repaired : {report['counts']['feature_repaired']}
|
||||||
|
Coverage repaired : {report['counts']['coverage_repaired']}
|
||||||
|
Feature issues : {report['counts']['feature_issues']}
|
||||||
|
Coverage issues : {report['counts']['coverage_issues']}
|
||||||
|
Geotype16 total : {report['counts']['geotype16_total']}
|
||||||
|
Geotype16 issues : {report['counts']['geotype16_issues']}
|
||||||
|
Area total : {report['area']['total']}
|
||||||
|
Area valid : {report['area']['valid']}
|
||||||
|
Area bbox mismatch : {report['area']['bbox_mismatch']}
|
||||||
|
Coverage total : {report['coverage']['total']}
|
||||||
|
Coverage valid : {report['coverage']['valid']}
|
||||||
|
Failure count : {report['failure_count']}
|
||||||
|
OVERALL: {report['overall']}
|
||||||
|
"""
|
||||||
|
(outdir/"最终验收摘要_v1.txt").write_text(summary,encoding="utf-8")
|
||||||
|
print(summary)
|
||||||
|
print("报告目录:", outdir.resolve())
|
||||||
|
|
||||||
|
if __name__=="__main__":
|
||||||
|
main()
|
||||||
977
cm93_mysql_invalid_area_audit_v4.py
Normal file
977
cm93_mysql_invalid_area_audit_v4.py
Normal file
@@ -0,0 +1,977 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
CM93 -> MySQL 无效 Area / Coverage 深度分类审计
|
||||||
|
Version: v4.0
|
||||||
|
|
||||||
|
背景:
|
||||||
|
- Dataset ID=1 为日本沿海局部 CM93 数据子集,不是完整 CM93。
|
||||||
|
- V3 已确认:
|
||||||
|
* Point 113346:全部有效
|
||||||
|
* Line 121773:全部有效
|
||||||
|
* Sounding 275700:全部有效
|
||||||
|
* Area 81080:52 个 Shapely invalid
|
||||||
|
* Coverage 1156:3 个 Shapely invalid
|
||||||
|
* feature ↔ geometry table mapping issue = 0
|
||||||
|
* raw_geotype=16 / META 的 4406 条为 EXPECTED_PARENT_NO_GEOMETRY
|
||||||
|
|
||||||
|
本脚本目标:
|
||||||
|
1. 重新找出 invalid Area / Coverage;
|
||||||
|
2. 对每一个 Shapely 报告的 invalidity point 做局部几何分析;
|
||||||
|
3. 区分:
|
||||||
|
- invalid 点本身位于 Cell 边界;
|
||||||
|
- geometry 只是“整体碰 Cell 边界”,但 invalid 点在内部;
|
||||||
|
- 重复顶点自接触;
|
||||||
|
- 线段内部真正交叉;
|
||||||
|
- shell / hole / ring 结构问题;
|
||||||
|
- Interior disconnected;
|
||||||
|
4. 找出多个 Feature 共享同一 invalid point 的簇;
|
||||||
|
5. 找出 Area 与 Coverage 在同一 Cell、同一 invalid point 的关联;
|
||||||
|
6. 输出当前数据库中 topology/edge/node/primitive 等相关 schema 线索,供下一阶段追 importer。
|
||||||
|
7. 全程只读,不修 geometry,不改 MySQL,不做 PBF。
|
||||||
|
|
||||||
|
重要:
|
||||||
|
“当前 Dataset 不是完整 CM93”只影响跨未导入海域的连续性判断。
|
||||||
|
一个已经生成的 Polygon 如果在自身内部发生真正 segment crossing,
|
||||||
|
仍属于需要调查的内在 geometry 问题。
|
||||||
|
但如果 invalidity point 正好位于 Cell 裁切边界、并被多个对象/coverage 共用,
|
||||||
|
更可能是 Cell clipping / shared topology / ring assembly 类问题,不能直接归为原始数据损坏。
|
||||||
|
|
||||||
|
依赖:
|
||||||
|
pip install pymysql shapely
|
||||||
|
|
||||||
|
运行:
|
||||||
|
python cm93_mysql_invalid_area_audit_v4.py ^
|
||||||
|
--host 127.0.0.1 ^
|
||||||
|
--port 3306 ^
|
||||||
|
--user root ^
|
||||||
|
--database cm93_chart ^
|
||||||
|
--dataset-id 1
|
||||||
|
|
||||||
|
输出目录会自动压缩成 zip,直接把 zip 发给 ChatGPT。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import getpass
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import traceback
|
||||||
|
import zipfile
|
||||||
|
from collections import Counter, defaultdict
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
|
||||||
|
|
||||||
|
try:
|
||||||
|
import pymysql
|
||||||
|
from pymysql.cursors import DictCursor
|
||||||
|
except ImportError:
|
||||||
|
print("缺少 pymysql:pip install pymysql shapely")
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
from shapely import from_wkb as _from_wkb
|
||||||
|
def load_wkb(data: bytes):
|
||||||
|
return _from_wkb(data)
|
||||||
|
except ImportError:
|
||||||
|
from shapely import wkb as _wkb
|
||||||
|
def load_wkb(data: bytes):
|
||||||
|
return _wkb.loads(data)
|
||||||
|
|
||||||
|
from shapely.geometry import Point, LineString
|
||||||
|
from shapely.validation import explain_validity
|
||||||
|
except ImportError:
|
||||||
|
print("缺少 shapely:pip install pymysql shapely")
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
|
||||||
|
INVALID_POINT_RE = re.compile(
|
||||||
|
r"\[\s*([-+]?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?)\s+"
|
||||||
|
r"([-+]?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?)\s*\]"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def qi(name: str) -> str:
|
||||||
|
return "`" + name.replace("`", "``") + "`"
|
||||||
|
|
||||||
|
|
||||||
|
def scalar(cur, sql: str, params: Sequence[Any] = ()) -> Any:
|
||||||
|
cur.execute(sql, params)
|
||||||
|
row = cur.fetchone()
|
||||||
|
return next(iter(row.values())) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_all(cur, sql: str, params: Sequence[Any] = ()) -> List[Dict[str, Any]]:
|
||||||
|
cur.execute(sql, params)
|
||||||
|
return list(cur.fetchall())
|
||||||
|
|
||||||
|
|
||||||
|
def write_csv(path: Path, rows: Iterable[Dict[str, Any]], fieldnames: Optional[List[str]] = None):
|
||||||
|
rows = list(rows)
|
||||||
|
if fieldnames is None:
|
||||||
|
seen = set()
|
||||||
|
fieldnames = []
|
||||||
|
for row in rows:
|
||||||
|
for k in row.keys():
|
||||||
|
if k not in seen:
|
||||||
|
seen.add(k)
|
||||||
|
fieldnames.append(k)
|
||||||
|
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with path.open("w", encoding="utf-8-sig", newline="") as f:
|
||||||
|
if not fieldnames:
|
||||||
|
return
|
||||||
|
w = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore")
|
||||||
|
w.writeheader()
|
||||||
|
for row in rows:
|
||||||
|
out = {}
|
||||||
|
for k in fieldnames:
|
||||||
|
v = row.get(k)
|
||||||
|
if isinstance(v, (tuple, list, dict)):
|
||||||
|
v = json.dumps(v, ensure_ascii=False)
|
||||||
|
out[k] = "" if v is None else v
|
||||||
|
w.writerow(out)
|
||||||
|
|
||||||
|
|
||||||
|
def begin_read_only(cur, warnings: List[str]):
|
||||||
|
try:
|
||||||
|
cur.execute("SET SESSION TRANSACTION READ ONLY")
|
||||||
|
except Exception as e:
|
||||||
|
warnings.append(f"SET SESSION TRANSACTION READ ONLY failed: {e}")
|
||||||
|
try:
|
||||||
|
cur.execute("START TRANSACTION READ ONLY")
|
||||||
|
except Exception as e:
|
||||||
|
warnings.append(f"START TRANSACTION READ ONLY failed: {e}")
|
||||||
|
try:
|
||||||
|
cur.execute("START TRANSACTION")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def probe_wkb_fn(cur) -> str:
|
||||||
|
for fn in ("ST_AsBinary", "ST_AsWKB", "AsBinary", "AsWKB"):
|
||||||
|
try:
|
||||||
|
cur.execute(f"SELECT {fn}(geom) AS x FROM chart_area_geometry LIMIT 1")
|
||||||
|
cur.fetchone()
|
||||||
|
return fn
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise RuntimeError("找不到可用的 geometry -> WKB 函数")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_invalid_point(reason: str) -> Optional[Tuple[float, float]]:
|
||||||
|
if not reason:
|
||||||
|
return None
|
||||||
|
m = INVALID_POINT_RE.search(reason)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(m.group(1)), float(m.group(2))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def bbox_from_row(row: Dict[str, Any], prefix: str = "") -> Optional[Tuple[float, float, float, float]]:
|
||||||
|
keys = [
|
||||||
|
prefix + "bbox_min_lon",
|
||||||
|
prefix + "bbox_min_lat",
|
||||||
|
prefix + "bbox_max_lon",
|
||||||
|
prefix + "bbox_max_lat",
|
||||||
|
]
|
||||||
|
vals = []
|
||||||
|
for k in keys:
|
||||||
|
try:
|
||||||
|
v = float(row[k])
|
||||||
|
if not math.isfinite(v):
|
||||||
|
return None
|
||||||
|
vals.append(v)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return vals[0], vals[1], vals[2], vals[3]
|
||||||
|
|
||||||
|
|
||||||
|
def point_cell_edge_context(
|
||||||
|
p: Optional[Tuple[float, float]],
|
||||||
|
bbox: Optional[Tuple[float, float, float, float]],
|
||||||
|
tol: float,
|
||||||
|
) -> Tuple[bool, str, Optional[float]]:
|
||||||
|
if not p or not bbox:
|
||||||
|
return False, "", None
|
||||||
|
x, y = p
|
||||||
|
minx, miny, maxx, maxy = bbox
|
||||||
|
d = {
|
||||||
|
"WEST": abs(x - minx),
|
||||||
|
"EAST": abs(x - maxx),
|
||||||
|
"SOUTH": abs(y - miny),
|
||||||
|
"NORTH": abs(y - maxy),
|
||||||
|
}
|
||||||
|
edge, dist = min(d.items(), key=lambda kv: kv[1])
|
||||||
|
return dist <= tol, edge if dist <= tol else "", dist
|
||||||
|
|
||||||
|
|
||||||
|
def point_feature_bbox_edge_context(
|
||||||
|
p: Optional[Tuple[float, float]],
|
||||||
|
bbox: Optional[Tuple[float, float, float, float]],
|
||||||
|
tol: float,
|
||||||
|
) -> Tuple[bool, str, Optional[float]]:
|
||||||
|
return point_cell_edge_context(p, bbox, tol)
|
||||||
|
|
||||||
|
|
||||||
|
def ring_coordinate_lists(geom) -> List[Tuple[str, int, List[Tuple[float, float]]]]:
|
||||||
|
out = []
|
||||||
|
|
||||||
|
def add_polygon(poly, poly_index: int):
|
||||||
|
try:
|
||||||
|
out.append((f"poly{poly_index}.shell", 0, [(float(x), float(y)) for x, y, *rest in poly.exterior.coords]))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
for i, ring in enumerate(poly.interiors):
|
||||||
|
try:
|
||||||
|
out.append((f"poly{poly_index}.hole", i, [(float(x), float(y)) for x, y, *rest in ring.coords]))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if geom.geom_type == "Polygon":
|
||||||
|
add_polygon(geom, 0)
|
||||||
|
elif geom.geom_type == "MultiPolygon":
|
||||||
|
for pi, poly in enumerate(geom.geoms):
|
||||||
|
add_polygon(poly, pi)
|
||||||
|
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def point_close(a: Tuple[float, float], b: Tuple[float, float], tol: float) -> bool:
|
||||||
|
return abs(a[0]-b[0]) <= tol and abs(a[1]-b[1]) <= tol
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_invalid_point_on_rings(
|
||||||
|
geom,
|
||||||
|
invalid_point: Optional[Tuple[float, float]],
|
||||||
|
tol: float,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
result = {
|
||||||
|
"polygon_count": 0,
|
||||||
|
"ring_count": 0,
|
||||||
|
"hole_count": 0,
|
||||||
|
"total_vertices": 0,
|
||||||
|
"invalid_point_vertex_occurrences": 0,
|
||||||
|
"invalid_point_distinct_ring_hits": 0,
|
||||||
|
"invalid_point_segment_hits": 0,
|
||||||
|
"invalid_point_segment_interior_hits": 0,
|
||||||
|
"invalid_point_endpoint_hits": 0,
|
||||||
|
"repeated_nonconsecutive_vertex_count": 0,
|
||||||
|
"zero_length_segment_count": 0,
|
||||||
|
"mechanism": "UNRESOLVED",
|
||||||
|
"ring_hit_labels": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
if geom.geom_type == "Polygon":
|
||||||
|
result["polygon_count"] = 1
|
||||||
|
elif geom.geom_type == "MultiPolygon":
|
||||||
|
result["polygon_count"] = len(geom.geoms)
|
||||||
|
else:
|
||||||
|
return result
|
||||||
|
|
||||||
|
rings = ring_coordinate_lists(geom)
|
||||||
|
result["ring_count"] = len(rings)
|
||||||
|
result["hole_count"] = sum(1 for label, _, _ in rings if ".hole" in label)
|
||||||
|
|
||||||
|
ring_hits = set()
|
||||||
|
vertex_occurrences = 0
|
||||||
|
segment_hits = 0
|
||||||
|
segment_interior_hits = 0
|
||||||
|
endpoint_hits = 0
|
||||||
|
repeated_nonconsecutive = 0
|
||||||
|
zero_segments = 0
|
||||||
|
|
||||||
|
pgeom = Point(invalid_point) if invalid_point else None
|
||||||
|
|
||||||
|
for label, ring_index, coords in rings:
|
||||||
|
result["total_vertices"] += len(coords)
|
||||||
|
|
||||||
|
# 去掉 ring 最后一个闭合重复点后做“非连续重复顶点”统计。
|
||||||
|
core = coords[:-1] if len(coords) > 1 and point_close(coords[0], coords[-1], tol) else coords[:]
|
||||||
|
seen_positions: Dict[Tuple[int, int], List[int]] = defaultdict(list)
|
||||||
|
scale = 1.0 / max(tol, 1e-12)
|
||||||
|
for i, c in enumerate(core):
|
||||||
|
key = (round(c[0] * scale), round(c[1] * scale))
|
||||||
|
seen_positions[key].append(i)
|
||||||
|
for positions in seen_positions.values():
|
||||||
|
if len(positions) > 1:
|
||||||
|
# 相邻重复点不归到 nonconsecutive;它们通常会形成 zero segment。
|
||||||
|
has_nonconsecutive = False
|
||||||
|
for i in range(len(positions)):
|
||||||
|
for j in range(i+1, len(positions)):
|
||||||
|
if abs(positions[j] - positions[i]) > 1:
|
||||||
|
has_nonconsecutive = True
|
||||||
|
break
|
||||||
|
if has_nonconsecutive:
|
||||||
|
break
|
||||||
|
if has_nonconsecutive:
|
||||||
|
repeated_nonconsecutive += 1
|
||||||
|
|
||||||
|
if invalid_point:
|
||||||
|
for c in core:
|
||||||
|
if point_close(c, invalid_point, tol):
|
||||||
|
vertex_occurrences += 1
|
||||||
|
ring_hits.add(f"{label}[{ring_index}]")
|
||||||
|
|
||||||
|
for i in range(len(coords)-1):
|
||||||
|
a = coords[i]
|
||||||
|
b = coords[i+1]
|
||||||
|
|
||||||
|
if point_close(a, b, tol):
|
||||||
|
zero_segments += 1
|
||||||
|
|
||||||
|
if not invalid_point:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
seg = LineString([a, b])
|
||||||
|
if seg.distance(pgeom) <= tol:
|
||||||
|
segment_hits += 1
|
||||||
|
ring_hits.add(f"{label}[{ring_index}]")
|
||||||
|
at_a = point_close(a, invalid_point, tol)
|
||||||
|
at_b = point_close(b, invalid_point, tol)
|
||||||
|
if at_a or at_b:
|
||||||
|
endpoint_hits += 1
|
||||||
|
else:
|
||||||
|
segment_interior_hits += 1
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
result["invalid_point_vertex_occurrences"] = vertex_occurrences
|
||||||
|
result["invalid_point_distinct_ring_hits"] = len(ring_hits)
|
||||||
|
result["invalid_point_segment_hits"] = segment_hits
|
||||||
|
result["invalid_point_segment_interior_hits"] = segment_interior_hits
|
||||||
|
result["invalid_point_endpoint_hits"] = endpoint_hits
|
||||||
|
result["repeated_nonconsecutive_vertex_count"] = repeated_nonconsecutive
|
||||||
|
result["zero_length_segment_count"] = zero_segments
|
||||||
|
result["ring_hit_labels"] = "|".join(sorted(ring_hits))
|
||||||
|
|
||||||
|
# 机制分类:只描述几何表现,不直接断言根因。
|
||||||
|
if not invalid_point:
|
||||||
|
result["mechanism"] = "NO_INVALID_POINT_FROM_VALIDATOR"
|
||||||
|
elif segment_interior_hits >= 2 and vertex_occurrences == 0:
|
||||||
|
result["mechanism"] = "TRUE_SEGMENT_CROSSING"
|
||||||
|
elif vertex_occurrences >= 2:
|
||||||
|
result["mechanism"] = "REPEATED_VERTEX_SELF_TOUCH"
|
||||||
|
elif vertex_occurrences >= 1 and segment_interior_hits >= 1:
|
||||||
|
result["mechanism"] = "VERTEX_TOUCHES_NONADJACENT_SEGMENT"
|
||||||
|
elif len(ring_hits) >= 2:
|
||||||
|
result["mechanism"] = "MULTI_RING_TOUCH_OR_CROSS"
|
||||||
|
elif segment_hits >= 3:
|
||||||
|
result["mechanism"] = "MULTI_SEGMENT_TOUCH"
|
||||||
|
elif segment_hits >= 2:
|
||||||
|
result["mechanism"] = "TWO_SEGMENT_INTERACTION"
|
||||||
|
else:
|
||||||
|
result["mechanism"] = "UNRESOLVED_LOCAL_TOPOLOGY"
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def validator_reason_category(reason: str) -> str:
|
||||||
|
s = (reason or "").lower()
|
||||||
|
if "ring self-intersection" in s:
|
||||||
|
return "RING_SELF_INTERSECTION"
|
||||||
|
if "self-intersection" in s:
|
||||||
|
return "SELF_INTERSECTION"
|
||||||
|
if "interior is disconnected" in s:
|
||||||
|
return "INTERIOR_DISCONNECTED"
|
||||||
|
if "hole lies outside shell" in s:
|
||||||
|
return "HOLE_OUTSIDE_SHELL"
|
||||||
|
if "nested shells" in s:
|
||||||
|
return "NESTED_SHELLS"
|
||||||
|
if "nested holes" in s:
|
||||||
|
return "NESTED_HOLES"
|
||||||
|
if "too few points" in s:
|
||||||
|
return "TOO_FEW_POINTS"
|
||||||
|
return "OTHER_INVALID_GEOMETRY"
|
||||||
|
|
||||||
|
|
||||||
|
def scan_invalid_geometries(
|
||||||
|
cur,
|
||||||
|
*,
|
||||||
|
table: str,
|
||||||
|
kind: str,
|
||||||
|
dataset_id: int,
|
||||||
|
wkb_fn: str,
|
||||||
|
chunk_size: int,
|
||||||
|
point_tol: float,
|
||||||
|
edge_tol: float,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
last_id = 0
|
||||||
|
invalids = []
|
||||||
|
id_col = "feature_id" if kind == "AREA" else "id"
|
||||||
|
|
||||||
|
while True:
|
||||||
|
if kind == "AREA":
|
||||||
|
sql = f"""
|
||||||
|
SELECT
|
||||||
|
g.feature_id AS row_id,
|
||||||
|
g.feature_id,
|
||||||
|
NULL AS coverage_id,
|
||||||
|
{wkb_fn}(g.geom) AS geom_wkb,
|
||||||
|
f.object_class,
|
||||||
|
f.raw_geotype,
|
||||||
|
f.geometry_type,
|
||||||
|
f.geometry_issue,
|
||||||
|
f.geometry_repaired,
|
||||||
|
f.source_record_index,
|
||||||
|
f.cell_id,
|
||||||
|
c.cell_name,
|
||||||
|
f.bbox_min_lon, f.bbox_min_lat, f.bbox_max_lon, f.bbox_max_lat,
|
||||||
|
c.bbox_min_lon AS cell_bbox_min_lon,
|
||||||
|
c.bbox_min_lat AS cell_bbox_min_lat,
|
||||||
|
c.bbox_max_lon AS cell_bbox_max_lon,
|
||||||
|
c.bbox_max_lat AS cell_bbox_max_lat
|
||||||
|
FROM chart_area_geometry g
|
||||||
|
JOIN chart_feature f ON f.id=g.feature_id
|
||||||
|
JOIN chart_cell c ON c.id=f.cell_id
|
||||||
|
WHERE f.dataset_id=%s
|
||||||
|
AND g.feature_id>%s
|
||||||
|
ORDER BY g.feature_id
|
||||||
|
LIMIT %s
|
||||||
|
"""
|
||||||
|
else:
|
||||||
|
sql = f"""
|
||||||
|
SELECT
|
||||||
|
v.id AS row_id,
|
||||||
|
NULL AS feature_id,
|
||||||
|
v.id AS coverage_id,
|
||||||
|
{wkb_fn}(v.geom) AS geom_wkb,
|
||||||
|
'COVERAGE' AS object_class,
|
||||||
|
NULL AS raw_geotype,
|
||||||
|
'COVERAGE' AS geometry_type,
|
||||||
|
v.geometry_issue,
|
||||||
|
v.geometry_repaired,
|
||||||
|
v.source_record_index,
|
||||||
|
v.cell_id,
|
||||||
|
c.cell_name,
|
||||||
|
NULL AS bbox_min_lon, NULL AS bbox_min_lat,
|
||||||
|
NULL AS bbox_max_lon, NULL AS bbox_max_lat,
|
||||||
|
c.bbox_min_lon AS cell_bbox_min_lon,
|
||||||
|
c.bbox_min_lat AS cell_bbox_min_lat,
|
||||||
|
c.bbox_max_lon AS cell_bbox_max_lon,
|
||||||
|
c.bbox_max_lat AS cell_bbox_max_lat
|
||||||
|
FROM chart_coverage v
|
||||||
|
JOIN chart_cell c ON c.id=v.cell_id
|
||||||
|
WHERE v.dataset_id=%s
|
||||||
|
AND v.id>%s
|
||||||
|
ORDER BY v.id
|
||||||
|
LIMIT %s
|
||||||
|
"""
|
||||||
|
|
||||||
|
cur.execute(sql, (dataset_id, last_id, chunk_size))
|
||||||
|
batch = list(cur.fetchall())
|
||||||
|
if not batch:
|
||||||
|
break
|
||||||
|
|
||||||
|
for r in batch:
|
||||||
|
last_id = int(r["row_id"])
|
||||||
|
wkb = r["geom_wkb"]
|
||||||
|
if isinstance(wkb, memoryview):
|
||||||
|
wkb = wkb.tobytes()
|
||||||
|
elif isinstance(wkb, bytearray):
|
||||||
|
wkb = bytes(wkb)
|
||||||
|
|
||||||
|
try:
|
||||||
|
geom = load_wkb(wkb)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
valid = bool(geom.is_valid)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if valid:
|
||||||
|
continue
|
||||||
|
|
||||||
|
reason = explain_validity(geom)
|
||||||
|
p = parse_invalid_point(reason)
|
||||||
|
cb = bbox_from_row(r, "cell_")
|
||||||
|
fb = bbox_from_row(r)
|
||||||
|
|
||||||
|
on_cell_edge, cell_edge, cell_edge_dist = point_cell_edge_context(p, cb, edge_tol)
|
||||||
|
on_feature_bbox_edge, feature_bbox_edge, feature_bbox_edge_dist = point_feature_bbox_edge_context(
|
||||||
|
p, fb, edge_tol
|
||||||
|
)
|
||||||
|
|
||||||
|
local = analyze_invalid_point_on_rings(geom, p, point_tol)
|
||||||
|
|
||||||
|
geom_bounds = tuple(float(v) for v in geom.bounds)
|
||||||
|
|
||||||
|
row = {
|
||||||
|
"kind": kind,
|
||||||
|
"feature_id": r.get("feature_id"),
|
||||||
|
"coverage_id": r.get("coverage_id"),
|
||||||
|
"object_class": r.get("object_class"),
|
||||||
|
"cell_id": r.get("cell_id"),
|
||||||
|
"cell_name": r.get("cell_name"),
|
||||||
|
"source_record_index": r.get("source_record_index"),
|
||||||
|
"raw_geotype": r.get("raw_geotype"),
|
||||||
|
"geometry_type": r.get("geometry_type"),
|
||||||
|
"db_geometry_issue": r.get("geometry_issue"),
|
||||||
|
"db_geometry_repaired": r.get("geometry_repaired"),
|
||||||
|
"validator_category": validator_reason_category(reason),
|
||||||
|
"validator_reason": reason,
|
||||||
|
"invalid_x": p[0] if p else None,
|
||||||
|
"invalid_y": p[1] if p else None,
|
||||||
|
"cell_bbox": cb,
|
||||||
|
"feature_bbox": fb,
|
||||||
|
"geometry_bounds": geom_bounds,
|
||||||
|
"invalid_point_on_cell_edge": int(on_cell_edge),
|
||||||
|
"cell_edge": cell_edge,
|
||||||
|
"cell_edge_distance_deg": cell_edge_dist,
|
||||||
|
"invalid_point_on_feature_bbox_edge": int(on_feature_bbox_edge),
|
||||||
|
"feature_bbox_edge": feature_bbox_edge,
|
||||||
|
"feature_bbox_edge_distance_deg": feature_bbox_edge_dist,
|
||||||
|
}
|
||||||
|
row.update(local)
|
||||||
|
invalids.append(row)
|
||||||
|
|
||||||
|
print(f" {kind:<8} scanned_to={last_id} invalid_found={len(invalids)}")
|
||||||
|
|
||||||
|
return invalids
|
||||||
|
|
||||||
|
|
||||||
|
def rounded_point_key(row: Dict[str, Any], decimals: int = 7) -> Optional[Tuple[float, float]]:
|
||||||
|
try:
|
||||||
|
x = float(row["invalid_x"])
|
||||||
|
y = float(row["invalid_y"])
|
||||||
|
if math.isfinite(x) and math.isfinite(y):
|
||||||
|
return round(x, decimals), round(y, decimals)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def build_shared_point_groups(all_rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||||
|
groups = defaultdict(list)
|
||||||
|
for r in all_rows:
|
||||||
|
k = rounded_point_key(r)
|
||||||
|
if k is not None:
|
||||||
|
groups[k].append(r)
|
||||||
|
|
||||||
|
out = []
|
||||||
|
for p, rs in groups.items():
|
||||||
|
if len(rs) < 2:
|
||||||
|
continue
|
||||||
|
kinds = Counter(r["kind"] for r in rs)
|
||||||
|
cells = sorted({str(r.get("cell_name") or "") for r in rs})
|
||||||
|
objs = Counter(str(r.get("object_class") or "") for r in rs)
|
||||||
|
out.append({
|
||||||
|
"invalid_x": p[0],
|
||||||
|
"invalid_y": p[1],
|
||||||
|
"record_count": len(rs),
|
||||||
|
"area_count": kinds.get("AREA", 0),
|
||||||
|
"coverage_count": kinds.get("COVERAGE", 0),
|
||||||
|
"cells": "|".join(cells),
|
||||||
|
"object_classes": "|".join(f"{k}:{v}" for k, v in objs.most_common()),
|
||||||
|
"cell_edge_records": sum(int(r.get("invalid_point_on_cell_edge") or 0) for r in rs),
|
||||||
|
"feature_ids": "|".join(str(r["feature_id"]) for r in rs if r.get("feature_id") is not None),
|
||||||
|
"coverage_ids": "|".join(str(r["coverage_id"]) for r in rs if r.get("coverage_id") is not None),
|
||||||
|
})
|
||||||
|
|
||||||
|
out.sort(key=lambda r: (-r["record_count"], r["invalid_x"], r["invalid_y"]))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def mark_area_coverage_links(
|
||||||
|
areas: List[Dict[str, Any]],
|
||||||
|
coverages: List[Dict[str, Any]],
|
||||||
|
decimals: int = 7,
|
||||||
|
):
|
||||||
|
cov_index = defaultdict(list)
|
||||||
|
for c in coverages:
|
||||||
|
p = rounded_point_key(c, decimals)
|
||||||
|
if p is not None:
|
||||||
|
cov_index[(str(c.get("cell_name")), p)].append(c)
|
||||||
|
|
||||||
|
for a in areas:
|
||||||
|
p = rounded_point_key(a, decimals)
|
||||||
|
matches = cov_index.get((str(a.get("cell_name")), p), []) if p is not None else []
|
||||||
|
a["matches_invalid_coverage_same_cell_point"] = int(bool(matches))
|
||||||
|
a["matching_coverage_ids"] = "|".join(
|
||||||
|
str(x.get("coverage_id")) for x in matches
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def schema_topology_inventory(cur, database: str) -> List[Dict[str, Any]]:
|
||||||
|
keywords = (
|
||||||
|
"edge", "node", "vector", "primitive", "segment",
|
||||||
|
"geom", "geometry", "coverage", "ring", "topolog"
|
||||||
|
)
|
||||||
|
rs = fetch_all(cur, """
|
||||||
|
SELECT TABLE_NAME, COLUMN_NAME, DATA_TYPE, COLUMN_TYPE, IS_NULLABLE
|
||||||
|
FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA=%s
|
||||||
|
ORDER BY TABLE_NAME, ORDINAL_POSITION
|
||||||
|
""", (database,))
|
||||||
|
out = []
|
||||||
|
for r in rs:
|
||||||
|
blob = (r["TABLE_NAME"] + " " + r["COLUMN_NAME"]).lower()
|
||||||
|
if any(k in blob for k in keywords):
|
||||||
|
out.append(r)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def table_inventory(cur, database: str) -> List[Dict[str, Any]]:
|
||||||
|
return fetch_all(cur, """
|
||||||
|
SELECT TABLE_NAME, TABLE_ROWS, ENGINE, TABLE_COMMENT
|
||||||
|
FROM information_schema.TABLES
|
||||||
|
WHERE TABLE_SCHEMA=%s
|
||||||
|
ORDER BY TABLE_NAME
|
||||||
|
""", (database,))
|
||||||
|
|
||||||
|
|
||||||
|
def classify_for_next_step(r: Dict[str, Any]) -> str:
|
||||||
|
"""
|
||||||
|
这是“调查优先级/表现分类”,不是最终根因。
|
||||||
|
"""
|
||||||
|
vc = r.get("validator_category")
|
||||||
|
mechanism = r.get("mechanism")
|
||||||
|
on_edge = bool(r.get("invalid_point_on_cell_edge"))
|
||||||
|
coverage_link = bool(r.get("matches_invalid_coverage_same_cell_point"))
|
||||||
|
|
||||||
|
if vc == "INTERIOR_DISCONNECTED":
|
||||||
|
return "INTERNAL_RING_STRUCTURE_NEEDS_REVIEW"
|
||||||
|
|
||||||
|
if on_edge and coverage_link:
|
||||||
|
return "CELL_BOUNDARY_SHARED_WITH_COVERAGE"
|
||||||
|
if on_edge and mechanism in {
|
||||||
|
"REPEATED_VERTEX_SELF_TOUCH",
|
||||||
|
"VERTEX_TOUCHES_NONADJACENT_SEGMENT",
|
||||||
|
"MULTI_RING_TOUCH_OR_CROSS",
|
||||||
|
"MULTI_SEGMENT_TOUCH",
|
||||||
|
"TWO_SEGMENT_INTERACTION",
|
||||||
|
}:
|
||||||
|
return "CELL_BOUNDARY_TOPOLOGY_CANDIDATE"
|
||||||
|
if on_edge and mechanism == "TRUE_SEGMENT_CROSSING":
|
||||||
|
return "CELL_BOUNDARY_SEGMENT_CROSSING"
|
||||||
|
if not on_edge and mechanism == "TRUE_SEGMENT_CROSSING":
|
||||||
|
return "INTERNAL_TRUE_SEGMENT_CROSSING"
|
||||||
|
if not on_edge and mechanism == "REPEATED_VERTEX_SELF_TOUCH":
|
||||||
|
return "INTERNAL_REPEATED_VERTEX_SELF_TOUCH"
|
||||||
|
if not on_edge:
|
||||||
|
return "INTERNAL_INVALID_GEOMETRY_NEEDS_REVIEW"
|
||||||
|
return "BOUNDARY_INVALID_GEOMETRY_NEEDS_REVIEW"
|
||||||
|
|
||||||
|
|
||||||
|
def zip_dir(folder: Path) -> Path:
|
||||||
|
zpath = folder.with_suffix(".zip")
|
||||||
|
with zipfile.ZipFile(zpath, "w", compression=zipfile.ZIP_DEFLATED) as z:
|
||||||
|
for p in sorted(folder.rglob("*")):
|
||||||
|
if p.is_file():
|
||||||
|
z.write(p, p.relative_to(folder.parent))
|
||||||
|
return zpath
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args():
|
||||||
|
p = argparse.ArgumentParser(description="CM93 invalid Area/Coverage deep audit v4")
|
||||||
|
p.add_argument("--host", default=os.getenv("MYSQL_HOST", "127.0.0.1"))
|
||||||
|
p.add_argument("--port", type=int, default=int(os.getenv("MYSQL_PORT", "3306")))
|
||||||
|
p.add_argument("--user", default=os.getenv("MYSQL_USER", "root"))
|
||||||
|
p.add_argument("--password", default=os.getenv("MYSQL_PASSWORD"))
|
||||||
|
p.add_argument("--database", default=os.getenv("MYSQL_DATABASE", "cm93_chart"))
|
||||||
|
p.add_argument("--dataset-id", type=int, default=1)
|
||||||
|
p.add_argument("--chunk-size", type=int, default=2000)
|
||||||
|
p.add_argument(
|
||||||
|
"--point-tolerance",
|
||||||
|
type=float,
|
||||||
|
default=1e-8,
|
||||||
|
help="invalid point 与 ring 顶点/segment 的比较容差(degree)"
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--cell-edge-tolerance",
|
||||||
|
type=float,
|
||||||
|
default=5e-6,
|
||||||
|
help="invalidity point 是否位于 Cell bbox 边界的容差(degree)"
|
||||||
|
)
|
||||||
|
p.add_argument("--output", default=None)
|
||||||
|
return p.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = parse_args()
|
||||||
|
if not args.password:
|
||||||
|
args.password = getpass.getpass("MySQL password: ")
|
||||||
|
|
||||||
|
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
outdir = Path(args.output or f"cm93_invalid_area_audit_v4_dataset_{args.dataset_id}_{stamp}")
|
||||||
|
outdir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
report = {
|
||||||
|
"version": "v4.0",
|
||||||
|
"generated_at": datetime.now().isoformat(timespec="seconds"),
|
||||||
|
"database": args.database,
|
||||||
|
"dataset_id": args.dataset_id,
|
||||||
|
"read_only": True,
|
||||||
|
"pbf": "OUT_OF_SCOPE",
|
||||||
|
"dataset_scope_note": (
|
||||||
|
"CM93JAPAN 只是局部子集。跨未导入海域连续性不作为 FAIL;"
|
||||||
|
"本轮只深挖已经生成的 invalid Polygon/Coverage 自身。"
|
||||||
|
),
|
||||||
|
"warnings": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
conn = None
|
||||||
|
try:
|
||||||
|
conn = pymysql.connect(
|
||||||
|
host=args.host,
|
||||||
|
port=args.port,
|
||||||
|
user=args.user,
|
||||||
|
password=args.password,
|
||||||
|
database=args.database,
|
||||||
|
charset="utf8mb4",
|
||||||
|
cursorclass=DictCursor,
|
||||||
|
autocommit=False,
|
||||||
|
connect_timeout=20,
|
||||||
|
read_timeout=600,
|
||||||
|
write_timeout=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
begin_read_only(cur, report["warnings"])
|
||||||
|
report["mysql_version"] = scalar(cur, "SELECT VERSION()")
|
||||||
|
wkb_fn = probe_wkb_fn(cur)
|
||||||
|
report["wkb_fn"] = wkb_fn
|
||||||
|
|
||||||
|
print("[1/6] 深扫 invalid Area ...")
|
||||||
|
areas = scan_invalid_geometries(
|
||||||
|
cur,
|
||||||
|
table="chart_area_geometry",
|
||||||
|
kind="AREA",
|
||||||
|
dataset_id=args.dataset_id,
|
||||||
|
wkb_fn=wkb_fn,
|
||||||
|
chunk_size=args.chunk_size,
|
||||||
|
point_tol=args.point_tolerance,
|
||||||
|
edge_tol=args.cell_edge_tolerance,
|
||||||
|
)
|
||||||
|
|
||||||
|
print("[2/6] 深扫 invalid Coverage ...")
|
||||||
|
coverages = scan_invalid_geometries(
|
||||||
|
cur,
|
||||||
|
table="chart_coverage",
|
||||||
|
kind="COVERAGE",
|
||||||
|
dataset_id=args.dataset_id,
|
||||||
|
wkb_fn=wkb_fn,
|
||||||
|
chunk_size=args.chunk_size,
|
||||||
|
point_tol=args.point_tolerance,
|
||||||
|
edge_tol=args.cell_edge_tolerance,
|
||||||
|
)
|
||||||
|
|
||||||
|
print("[3/6] 建立 Area ↔ Coverage 同点关联 ...")
|
||||||
|
mark_area_coverage_links(areas, coverages)
|
||||||
|
|
||||||
|
for r in areas:
|
||||||
|
r["next_step_class"] = classify_for_next_step(r)
|
||||||
|
for r in coverages:
|
||||||
|
r["matches_invalid_coverage_same_cell_point"] = 0
|
||||||
|
r["matching_coverage_ids"] = ""
|
||||||
|
r["next_step_class"] = classify_for_next_step(r)
|
||||||
|
|
||||||
|
all_invalid = areas + coverages
|
||||||
|
shared_groups = build_shared_point_groups(all_invalid)
|
||||||
|
|
||||||
|
print("[4/6] 导出 schema/topology 线索 ...")
|
||||||
|
topology_schema = schema_topology_inventory(cur, args.database)
|
||||||
|
tables = table_inventory(cur, args.database)
|
||||||
|
|
||||||
|
print("[5/6] 汇总分类 ...")
|
||||||
|
validator_counts = Counter(r["validator_category"] for r in all_invalid)
|
||||||
|
mechanism_counts = Counter(r["mechanism"] for r in all_invalid)
|
||||||
|
next_counts = Counter(r["next_step_class"] for r in all_invalid)
|
||||||
|
area_next_counts = Counter(r["next_step_class"] for r in areas)
|
||||||
|
cov_next_counts = Counter(r["next_step_class"] for r in coverages)
|
||||||
|
|
||||||
|
summary = {
|
||||||
|
"invalid_area_count": len(areas),
|
||||||
|
"invalid_coverage_count": len(coverages),
|
||||||
|
"invalid_total": len(all_invalid),
|
||||||
|
"invalid_point_on_cell_edge_total": sum(int(r["invalid_point_on_cell_edge"]) for r in all_invalid),
|
||||||
|
"invalid_point_internal_total": sum(1 for r in all_invalid if not r["invalid_point_on_cell_edge"]),
|
||||||
|
"area_invalid_point_on_cell_edge": sum(int(r["invalid_point_on_cell_edge"]) for r in areas),
|
||||||
|
"area_invalid_point_internal": sum(1 for r in areas if not r["invalid_point_on_cell_edge"]),
|
||||||
|
"coverage_invalid_point_on_cell_edge": sum(int(r["invalid_point_on_cell_edge"]) for r in coverages),
|
||||||
|
"area_matches_invalid_coverage_same_cell_point": sum(
|
||||||
|
int(r.get("matches_invalid_coverage_same_cell_point") or 0)
|
||||||
|
for r in areas
|
||||||
|
),
|
||||||
|
"validator_categories": dict(validator_counts),
|
||||||
|
"mechanisms": dict(mechanism_counts),
|
||||||
|
"next_step_classes": dict(next_counts),
|
||||||
|
"area_next_step_classes": dict(area_next_counts),
|
||||||
|
"coverage_next_step_classes": dict(cov_next_counts),
|
||||||
|
"shared_invalid_point_group_count": len(shared_groups),
|
||||||
|
}
|
||||||
|
report["summary"] = summary
|
||||||
|
|
||||||
|
write_csv(outdir / "invalid_areas_deep_classified.csv", areas)
|
||||||
|
write_csv(outdir / "invalid_coverages_deep_classified.csv", coverages)
|
||||||
|
write_csv(outdir / "invalid_all_deep_classified.csv", all_invalid)
|
||||||
|
write_csv(outdir / "shared_invalid_point_groups.csv", shared_groups)
|
||||||
|
write_csv(outdir / "topology_schema_inventory.csv", topology_schema)
|
||||||
|
write_csv(outdir / "database_table_inventory.csv", tables)
|
||||||
|
|
||||||
|
write_csv(
|
||||||
|
outdir / "validator_category_counts.csv",
|
||||||
|
[{"category": k, "count": v} for k, v in validator_counts.most_common()],
|
||||||
|
["category", "count"]
|
||||||
|
)
|
||||||
|
write_csv(
|
||||||
|
outdir / "local_mechanism_counts.csv",
|
||||||
|
[{"mechanism": k, "count": v} for k, v in mechanism_counts.most_common()],
|
||||||
|
["mechanism", "count"]
|
||||||
|
)
|
||||||
|
write_csv(
|
||||||
|
outdir / "next_step_class_counts.csv",
|
||||||
|
[{"next_step_class": k, "count": v} for k, v in next_counts.most_common()],
|
||||||
|
["next_step_class", "count"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# 重点样本:每一 next_step_class 最多 10 条
|
||||||
|
samples = []
|
||||||
|
sc = Counter()
|
||||||
|
for r in sorted(
|
||||||
|
all_invalid,
|
||||||
|
key=lambda x: (
|
||||||
|
x.get("next_step_class", ""),
|
||||||
|
str(x.get("cell_name", "")),
|
||||||
|
int(x.get("feature_id") or x.get("coverage_id") or 0)
|
||||||
|
)
|
||||||
|
):
|
||||||
|
c = r["next_step_class"]
|
||||||
|
if sc[c] < 10:
|
||||||
|
samples.append(r)
|
||||||
|
sc[c] += 1
|
||||||
|
write_csv(outdir / "deep_classification_samples.csv", samples)
|
||||||
|
|
||||||
|
(outdir / "invalid_area_audit_report_v4.json").write_text(
|
||||||
|
json.dumps(report, ensure_ascii=False, indent=2, default=str),
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
lines.append("CM93 -> MySQL Invalid Area/Coverage 深度分类审计 v4")
|
||||||
|
lines.append("=" * 82)
|
||||||
|
lines.append(f"Generated: {report['generated_at']}")
|
||||||
|
lines.append(f"Database: {args.database}")
|
||||||
|
lines.append(f"Dataset ID: {args.dataset_id}")
|
||||||
|
lines.append(f"MySQL: {report['mysql_version']}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("DATASET SCOPE")
|
||||||
|
lines.append(" 当前 CM93JAPAN 是局部数据,不要求与未导入海域连续。")
|
||||||
|
lines.append(" 但本轮检查的是已生成 Polygon 本身的自交/自接触机制。")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("COUNTS")
|
||||||
|
lines.append(f" invalid Area: {len(areas)}")
|
||||||
|
lines.append(f" invalid Coverage: {len(coverages)}")
|
||||||
|
lines.append(f" total: {len(all_invalid)}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("INVALID POINT LOCATION")
|
||||||
|
lines.append(
|
||||||
|
f" Area invalid point ON cell edge: "
|
||||||
|
f"{summary['area_invalid_point_on_cell_edge']}"
|
||||||
|
)
|
||||||
|
lines.append(
|
||||||
|
f" Area invalid point INTERNAL: "
|
||||||
|
f"{summary['area_invalid_point_internal']}"
|
||||||
|
)
|
||||||
|
lines.append(
|
||||||
|
f" Coverage invalid point ON cell edge:"
|
||||||
|
f" {summary['coverage_invalid_point_on_cell_edge']}"
|
||||||
|
)
|
||||||
|
lines.append("")
|
||||||
|
lines.append("AREA ↔ COVERAGE")
|
||||||
|
lines.append(
|
||||||
|
f" Area same cell + same invalid point as invalid Coverage: "
|
||||||
|
f"{summary['area_matches_invalid_coverage_same_cell_point']}"
|
||||||
|
)
|
||||||
|
lines.append("")
|
||||||
|
lines.append("VALIDATOR CATEGORY")
|
||||||
|
for k, v in validator_counts.most_common():
|
||||||
|
lines.append(f" {k:42s} {v}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("LOCAL MECHANISM")
|
||||||
|
for k, v in mechanism_counts.most_common():
|
||||||
|
lines.append(f" {k:42s} {v}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("NEXT-STEP CLASS (表现分类,不是最终根因)")
|
||||||
|
for k, v in next_counts.most_common():
|
||||||
|
lines.append(f" {k:42s} {v}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("SHARED INVALID POINT GROUPS")
|
||||||
|
lines.append(f" groups with >=2 invalid records: {len(shared_groups)}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("INTERPRETATION RULES")
|
||||||
|
lines.append(
|
||||||
|
" CELL_BOUNDARY_SHARED_WITH_COVERAGE / CELL_BOUNDARY_TOPOLOGY_CANDIDATE "
|
||||||
|
"优先调查 Cell clipping / shared topology / importer ring assembly。"
|
||||||
|
)
|
||||||
|
lines.append(
|
||||||
|
" INTERNAL_TRUE_SEGMENT_CROSSING 优先调查源拓扑与 importer edge/ring 顺序。"
|
||||||
|
)
|
||||||
|
lines.append(
|
||||||
|
" INTERNAL_REPEATED_VERTEX_SELF_TOUCH 需要判断 CM93 是否允许 touch-ring 表达,"
|
||||||
|
"以及 importer 是否应输出 MultiPolygon/拆环。"
|
||||||
|
)
|
||||||
|
lines.append(
|
||||||
|
" 本脚本不执行 make_valid、buffer(0)、闭环、补点、删点或任何数据库写入。"
|
||||||
|
)
|
||||||
|
lines.append("")
|
||||||
|
lines.append("PBF: OUT OF SCOPE / NOT STARTED")
|
||||||
|
|
||||||
|
(outdir / "invalid_area_audit_summary_v4.txt").write_text(
|
||||||
|
"\n".join(lines),
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.rollback()
|
||||||
|
|
||||||
|
print("[6/6] 压缩输出 ...")
|
||||||
|
zpath = zip_dir(outdir)
|
||||||
|
print("")
|
||||||
|
print("完成。")
|
||||||
|
print(f"Output: {outdir.resolve()}")
|
||||||
|
print(f"ZIP: {zpath.resolve()}")
|
||||||
|
print("请把 ZIP 整包发给 ChatGPT。")
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
if conn:
|
||||||
|
try:
|
||||||
|
conn.rollback()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
print("\n用户中断。")
|
||||||
|
sys.exit(130)
|
||||||
|
except Exception:
|
||||||
|
if conn:
|
||||||
|
try:
|
||||||
|
conn.rollback()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
tb = traceback.format_exc()
|
||||||
|
print(tb)
|
||||||
|
try:
|
||||||
|
(outdir / "fatal_error.txt").write_text(tb, encoding="utf-8")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
sys.exit(1)
|
||||||
|
finally:
|
||||||
|
if conn:
|
||||||
|
try:
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
1041
cm93_mysql_invalid_trace_v5.py
Normal file
1041
cm93_mysql_invalid_trace_v5.py
Normal file
File diff suppressed because it is too large
Load Diff
1272
cm93_mysql_non_geometry_final_audit_v1.py
Normal file
1272
cm93_mysql_non_geometry_final_audit_v1.py
Normal file
File diff suppressed because it is too large
Load Diff
1633
cm93_mysql_normalization_canonical_v10.py
Normal file
1633
cm93_mysql_normalization_canonical_v10.py
Normal file
File diff suppressed because it is too large
Load Diff
1651
cm93_mysql_normalization_canonical_v10_1.py
Normal file
1651
cm93_mysql_normalization_canonical_v10_1.py
Normal file
File diff suppressed because it is too large
Load Diff
1713
cm93_mysql_normalization_dryrun_v9.py
Normal file
1713
cm93_mysql_normalization_dryrun_v9.py
Normal file
File diff suppressed because it is too large
Load Diff
1179
cm93_mysql_ogc_validity_audit_v7.py
Normal file
1179
cm93_mysql_ogc_validity_audit_v7.py
Normal file
File diff suppressed because it is too large
Load Diff
1461
cm93_mysql_raw_edge_audit_v6.py
Normal file
1461
cm93_mysql_raw_edge_audit_v6.py
Normal file
File diff suppressed because it is too large
Load Diff
1570
cm93_mysql_spatial_audit_v3.py
Normal file
1570
cm93_mysql_spatial_audit_v3.py
Normal file
File diff suppressed because it is too large
Load Diff
1270
cm93_normalization_implementation_precheck_v11.py
Normal file
1270
cm93_normalization_implementation_precheck_v11.py
Normal file
File diff suppressed because it is too large
Load Diff
475
cm93_v10_1_result_digest_v10_2.py
Normal file
475
cm93_v10_1_result_digest_v10_2.py
Normal file
@@ -0,0 +1,475 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
CM93 V10.1 结果汇总与完整性验证器
|
||||||
|
Version: v10.2-result-digest
|
||||||
|
|
||||||
|
用途:
|
||||||
|
- 不连接 MySQL。
|
||||||
|
- 不重新运行 normalization。
|
||||||
|
- 直接读取 V10.1 的输出目录或 ZIP。
|
||||||
|
- 自动判断 V10.1 是否整体 PASS。
|
||||||
|
- 生成一个很小的结果摘要 ZIP,便于上传给 ChatGPT。
|
||||||
|
|
||||||
|
支持输入:
|
||||||
|
1. V10.1 完整输出目录,例如:
|
||||||
|
cm93_normalization_canonical_v10_1_dataset_1_20260831_215602
|
||||||
|
|
||||||
|
2. V10.1 完整 ZIP,例如:
|
||||||
|
cm93_normalization_canonical_v10_1_dataset_1_20260831_215602.zip
|
||||||
|
|
||||||
|
3. 只有部分 CSV 的 ZIP。
|
||||||
|
此时脚本会明确显示“INCOMPLETE”,不会误判整体 PASS。
|
||||||
|
|
||||||
|
执行:
|
||||||
|
python cm93_v10_1_result_digest_v10_2.py ^
|
||||||
|
--input "D:\...\cm93_normalization_canonical_v10_1_dataset_1_20260831_215602"
|
||||||
|
|
||||||
|
或:
|
||||||
|
|
||||||
|
python cm93_v10_1_result_digest_v10_2.py ^
|
||||||
|
--input "D:\...\cm93_normalization_canonical_v10_1_dataset_1_20260831_215602.zip"
|
||||||
|
|
||||||
|
输出:
|
||||||
|
- V10_1_结果摘要_v10_2.txt
|
||||||
|
- V10_1_结果摘要_v10_2.json
|
||||||
|
- cm93_v10_1_result_digest_v10_2.zip
|
||||||
|
|
||||||
|
只读,不修改原始结果。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
from collections import Counter
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
|
||||||
|
EXPECTED_FILES = {
|
||||||
|
"summary": "normalization_canonical_summary_v10_1.txt",
|
||||||
|
"report": "normalization_canonical_report_v10_1.json",
|
||||||
|
"golden_csv": "normalization_golden_vectors_v10_1.csv",
|
||||||
|
"golden_json": "normalization_golden_vectors_v10_1.json",
|
||||||
|
"variant": "normalization_variant_results_v10_1.csv",
|
||||||
|
"face_order": "normalization_face_order_results_v10_1.csv",
|
||||||
|
"failures": "normalization_canonical_failures_v10_1.csv",
|
||||||
|
"bbox": "normalization_bbox_metadata_impact_v10_1.csv",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args():
|
||||||
|
p = argparse.ArgumentParser(
|
||||||
|
description="CM93 V10.1 result digest verifier v10.2"
|
||||||
|
)
|
||||||
|
p.add_argument("--input", required=True, help="V10.1 输出目录或 ZIP")
|
||||||
|
p.add_argument("--output-dir", default=None)
|
||||||
|
return p.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def find_file(root: Path, filename: str) -> Optional[Path]:
|
||||||
|
direct = root / filename
|
||||||
|
if direct.exists():
|
||||||
|
return direct
|
||||||
|
|
||||||
|
hits = list(root.rglob(filename))
|
||||||
|
if hits:
|
||||||
|
return hits[0]
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def read_csv(path: Path) -> List[Dict[str, str]]:
|
||||||
|
with path.open("r", encoding="utf-8-sig", newline="") as f:
|
||||||
|
return list(csv.DictReader(f))
|
||||||
|
|
||||||
|
|
||||||
|
def intv(v: Any, default: int = 0) -> int:
|
||||||
|
try:
|
||||||
|
return int(v)
|
||||||
|
except Exception:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def boolv(v: Any) -> bool:
|
||||||
|
if isinstance(v, bool):
|
||||||
|
return v
|
||||||
|
if v is None:
|
||||||
|
return False
|
||||||
|
s = str(v).strip().lower()
|
||||||
|
return s in {"1", "true", "yes", "y", "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = parse_args()
|
||||||
|
src = Path(args.input).expanduser().resolve()
|
||||||
|
|
||||||
|
if not src.exists():
|
||||||
|
print(f"输入不存在:{src}")
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
temp_dir = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
if src.is_file() and src.suffix.lower() == ".zip":
|
||||||
|
temp_dir = Path(tempfile.mkdtemp(prefix="cm93_v10_1_digest_"))
|
||||||
|
with zipfile.ZipFile(src, "r") as z:
|
||||||
|
z.extractall(temp_dir)
|
||||||
|
root = temp_dir
|
||||||
|
elif src.is_dir():
|
||||||
|
root = src
|
||||||
|
else:
|
||||||
|
print("输入必须是 V10.1 输出目录或 ZIP。")
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
found: Dict[str, Optional[Path]] = {
|
||||||
|
key: find_file(root, filename)
|
||||||
|
for key, filename in EXPECTED_FILES.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
present = {k: bool(v) for k, v in found.items()}
|
||||||
|
|
||||||
|
digest: Dict[str, Any] = {
|
||||||
|
"version": "v10.2-result-digest",
|
||||||
|
"generated_at": datetime.now().isoformat(timespec="seconds"),
|
||||||
|
"input": str(src),
|
||||||
|
"files_present": present,
|
||||||
|
"complete_core_set": all(
|
||||||
|
present[k]
|
||||||
|
for k in (
|
||||||
|
"report",
|
||||||
|
"golden_csv",
|
||||||
|
"variant",
|
||||||
|
"face_order",
|
||||||
|
"failures",
|
||||||
|
"bbox",
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"checks": {},
|
||||||
|
"overall": "INCOMPLETE",
|
||||||
|
}
|
||||||
|
|
||||||
|
checks = digest["checks"]
|
||||||
|
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
# Report JSON
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
if found["report"]:
|
||||||
|
report = json.loads(
|
||||||
|
found["report"].read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
summary = report.get("summary", {})
|
||||||
|
|
||||||
|
checks["report"] = {
|
||||||
|
"overall_pass": summary.get("overall_pass"),
|
||||||
|
"total_polygon_records": summary.get("total_polygon_records"),
|
||||||
|
"valid_polygon_records": summary.get("valid_polygon_records"),
|
||||||
|
"invalid_polygon_records": summary.get("invalid_polygon_records"),
|
||||||
|
"valid_policy_changed_count":
|
||||||
|
summary.get("valid_policy_changed_count"),
|
||||||
|
"valid_hash_changed_count":
|
||||||
|
summary.get("valid_hash_changed_count"),
|
||||||
|
"golden_vector_count":
|
||||||
|
summary.get("golden_vector_count"),
|
||||||
|
"golden_record_status_counts":
|
||||||
|
summary.get("golden_record_status_counts"),
|
||||||
|
"input_variant_case_count":
|
||||||
|
summary.get("input_variant_case_count"),
|
||||||
|
"input_variant_status_counts":
|
||||||
|
summary.get("input_variant_status_counts"),
|
||||||
|
"face_order_case_count":
|
||||||
|
summary.get("face_order_case_count"),
|
||||||
|
"face_order_hash_mismatch_count":
|
||||||
|
summary.get("face_order_hash_mismatch_count"),
|
||||||
|
"canonical_topology_failure_count":
|
||||||
|
summary.get("canonical_topology_failure_count"),
|
||||||
|
"repeat_hash_failure_count":
|
||||||
|
summary.get("repeat_hash_failure_count"),
|
||||||
|
"input_variant_failure_record_count":
|
||||||
|
summary.get("input_variant_failure_record_count"),
|
||||||
|
"future_area_feature_bbox_update_needed_count":
|
||||||
|
summary.get("future_area_feature_bbox_update_needed_count"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
# Golden vectors
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
if found["golden_csv"]:
|
||||||
|
rows = read_csv(found["golden_csv"])
|
||||||
|
statuses = Counter(
|
||||||
|
str(r.get("record_status", "")).strip()
|
||||||
|
for r in rows
|
||||||
|
)
|
||||||
|
hashes = [
|
||||||
|
r.get("canonical_wkb_sha256", "")
|
||||||
|
for r in rows
|
||||||
|
if r.get("canonical_wkb_sha256")
|
||||||
|
]
|
||||||
|
|
||||||
|
checks["golden"] = {
|
||||||
|
"rows": len(rows),
|
||||||
|
"status_counts": dict(statuses),
|
||||||
|
"unique_hash_count": len(set(hashes)),
|
||||||
|
"missing_hash_count":
|
||||||
|
sum(not bool(r.get("canonical_wkb_sha256")) for r in rows),
|
||||||
|
"variant_failure_records":
|
||||||
|
sum(
|
||||||
|
intv(r.get("input_variant_failure_count")) > 0
|
||||||
|
for r in rows
|
||||||
|
),
|
||||||
|
"face_order_unstable_records":
|
||||||
|
sum(
|
||||||
|
not boolv(r.get("face_order_hash_stable"))
|
||||||
|
for r in rows
|
||||||
|
),
|
||||||
|
"repeat_unstable_records":
|
||||||
|
sum(
|
||||||
|
not boolv(r.get("repeat_hash_stable"))
|
||||||
|
for r in rows
|
||||||
|
),
|
||||||
|
"canonical_topology_changed_records":
|
||||||
|
sum(
|
||||||
|
not boolv(r.get("canonical_topology_unchanged"))
|
||||||
|
for r in rows
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
# Variant
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
if found["variant"]:
|
||||||
|
rows = read_csv(found["variant"])
|
||||||
|
statuses = Counter(
|
||||||
|
str(r.get("status", "")).strip()
|
||||||
|
for r in rows
|
||||||
|
)
|
||||||
|
|
||||||
|
checks["variant"] = {
|
||||||
|
"rows": len(rows),
|
||||||
|
"status_counts": dict(statuses),
|
||||||
|
"fail_rows":
|
||||||
|
sum(str(r.get("status", "")).strip() != "PASS" for r in rows),
|
||||||
|
"hash_mismatch_rows":
|
||||||
|
sum(
|
||||||
|
not boolv(r.get("matches_baseline_hash"))
|
||||||
|
for r in rows
|
||||||
|
if str(r.get("status", "")).strip() != "EXCEPTION"
|
||||||
|
),
|
||||||
|
"topology_mismatch_rows":
|
||||||
|
sum(
|
||||||
|
not boolv(r.get("topology_equal_baseline"))
|
||||||
|
for r in rows
|
||||||
|
if str(r.get("status", "")).strip() != "EXCEPTION"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
# Face order
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
if found["face_order"]:
|
||||||
|
rows = read_csv(found["face_order"])
|
||||||
|
checks["face_order"] = {
|
||||||
|
"rows": len(rows),
|
||||||
|
"hash_mismatch_rows":
|
||||||
|
sum(
|
||||||
|
not boolv(r.get("matches_baseline_hash"))
|
||||||
|
for r in rows
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
# Failure CSV
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
if found["failures"]:
|
||||||
|
rows = read_csv(found["failures"])
|
||||||
|
# 只有表头时 DictReader 返回 0 rows,正是期望。
|
||||||
|
checks["failures"] = {
|
||||||
|
"rows": len(rows),
|
||||||
|
}
|
||||||
|
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
# BBox metadata impact
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
if found["bbox"]:
|
||||||
|
rows = read_csv(found["bbox"])
|
||||||
|
checks["bbox"] = {
|
||||||
|
"rows": len(rows),
|
||||||
|
"bbox_changed_rows":
|
||||||
|
sum(
|
||||||
|
boolv(r.get("bbox_changed_over_tolerance"))
|
||||||
|
for r in rows
|
||||||
|
),
|
||||||
|
"future_feature_bbox_update_needed_rows":
|
||||||
|
sum(
|
||||||
|
boolv(r.get("future_feature_bbox_update_needed"))
|
||||||
|
for r in rows
|
||||||
|
),
|
||||||
|
"rows_with_connector":
|
||||||
|
sum(
|
||||||
|
float(r.get("discarded_connector_length_deg") or 0) > 0
|
||||||
|
for r in rows
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
# Independent overall verdict
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
required_for_pass = all(
|
||||||
|
k in checks
|
||||||
|
for k in (
|
||||||
|
"report",
|
||||||
|
"golden",
|
||||||
|
"variant",
|
||||||
|
"face_order",
|
||||||
|
"failures",
|
||||||
|
"bbox",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if required_for_pass:
|
||||||
|
report_check = checks["report"]
|
||||||
|
golden = checks["golden"]
|
||||||
|
variant = checks["variant"]
|
||||||
|
face = checks["face_order"]
|
||||||
|
failures = checks["failures"]
|
||||||
|
|
||||||
|
pass_conditions = [
|
||||||
|
boolv(report_check.get("overall_pass")),
|
||||||
|
intv(report_check.get("invalid_polygon_records")) == 55,
|
||||||
|
intv(report_check.get("valid_policy_changed_count")) == 0,
|
||||||
|
intv(report_check.get("valid_hash_changed_count")) == 0,
|
||||||
|
|
||||||
|
golden["rows"] == 55,
|
||||||
|
golden["status_counts"].get("PASS", 0) == 55,
|
||||||
|
golden["status_counts"].get("FAIL", 0) == 0,
|
||||||
|
golden["missing_hash_count"] == 0,
|
||||||
|
golden["variant_failure_records"] == 0,
|
||||||
|
golden["face_order_unstable_records"] == 0,
|
||||||
|
golden["repeat_unstable_records"] == 0,
|
||||||
|
golden["canonical_topology_changed_records"] == 0,
|
||||||
|
|
||||||
|
variant["rows"] == 55 * 6,
|
||||||
|
variant["fail_rows"] == 0,
|
||||||
|
variant["hash_mismatch_rows"] == 0,
|
||||||
|
variant["topology_mismatch_rows"] == 0,
|
||||||
|
|
||||||
|
face["rows"] == 55 * 4,
|
||||||
|
face["hash_mismatch_rows"] == 0,
|
||||||
|
|
||||||
|
failures["rows"] == 0,
|
||||||
|
]
|
||||||
|
|
||||||
|
digest["overall"] = (
|
||||||
|
"PASS"
|
||||||
|
if all(pass_conditions)
|
||||||
|
else "FAIL"
|
||||||
|
)
|
||||||
|
digest["pass_conditions"] = pass_conditions
|
||||||
|
else:
|
||||||
|
digest["overall"] = "INCOMPLETE"
|
||||||
|
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
# Output
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
outdir = (
|
||||||
|
Path(args.output_dir).expanduser().resolve()
|
||||||
|
if args.output_dir
|
||||||
|
else src.parent
|
||||||
|
)
|
||||||
|
outdir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
txt_path = outdir / "V10_1_结果摘要_v10_2.txt"
|
||||||
|
json_path = outdir / "V10_1_结果摘要_v10_2.json"
|
||||||
|
zip_path = outdir / "cm93_v10_1_result_digest_v10_2.zip"
|
||||||
|
|
||||||
|
json_path.write_text(
|
||||||
|
json.dumps(digest, ensure_ascii=False, indent=2),
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
lines.append("CM93 V10.1 结果汇总与完整性验证 v10.2")
|
||||||
|
lines.append("=" * 78)
|
||||||
|
lines.append(f"Input: {src}")
|
||||||
|
lines.append(f"Overall: {digest['overall']}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append("FILES")
|
||||||
|
for key, filename in EXPECTED_FILES.items():
|
||||||
|
lines.append(
|
||||||
|
f" {key:12s} "
|
||||||
|
f"{'YES' if present[key] else 'NO ':3s} "
|
||||||
|
f"{filename}"
|
||||||
|
)
|
||||||
|
|
||||||
|
for section, value in checks.items():
|
||||||
|
lines.append("")
|
||||||
|
lines.append(section.upper())
|
||||||
|
for k, v in value.items():
|
||||||
|
lines.append(f" {k}: {v}")
|
||||||
|
|
||||||
|
if digest["overall"] == "INCOMPLETE":
|
||||||
|
lines.append("")
|
||||||
|
lines.append(
|
||||||
|
"INCOMPLETE:当前输入不足以判定 V10.1 整体 PASS。"
|
||||||
|
)
|
||||||
|
elif digest["overall"] == "PASS":
|
||||||
|
lines.append("")
|
||||||
|
lines.append(
|
||||||
|
"PASS:V10.1 canonical normalization 全部门槛通过。"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
lines.append("")
|
||||||
|
lines.append(
|
||||||
|
"FAIL:至少一个 V10.1 canonical validation 门槛未通过。"
|
||||||
|
)
|
||||||
|
|
||||||
|
txt_path.write_text(
|
||||||
|
"\n".join(lines),
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
with zipfile.ZipFile(
|
||||||
|
zip_path,
|
||||||
|
"w",
|
||||||
|
compression=zipfile.ZIP_DEFLATED
|
||||||
|
) as z:
|
||||||
|
z.write(txt_path, txt_path.name)
|
||||||
|
z.write(json_path, json_path.name)
|
||||||
|
|
||||||
|
# 把关键小文件一起放进 digest ZIP。
|
||||||
|
for key in (
|
||||||
|
"summary",
|
||||||
|
"report",
|
||||||
|
"golden_csv",
|
||||||
|
"variant",
|
||||||
|
"face_order",
|
||||||
|
"failures",
|
||||||
|
"bbox",
|
||||||
|
):
|
||||||
|
p = found.get(key)
|
||||||
|
if p and p.exists():
|
||||||
|
z.write(p, p.name)
|
||||||
|
|
||||||
|
print("")
|
||||||
|
print(f"Overall: {digest['overall']}")
|
||||||
|
print(f"TXT: {txt_path}")
|
||||||
|
print(f"JSON: {json_path}")
|
||||||
|
print(f"ZIP: {zip_path}")
|
||||||
|
print("")
|
||||||
|
print("把这个很小的 digest ZIP 上传给 ChatGPT。")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if temp_dir and temp_dir.exists():
|
||||||
|
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
87
codex/10_第一阶段接手任务.md
Normal file
87
codex/10_第一阶段接手任务.md
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# 已归档:该任务已由 v0.2.0 实现
|
||||||
|
|
||||||
|
当前请执行 `codex/20_导入与审计接手.md`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Codex:CM93 → MySQL 第一阶段接手任务
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
在现有 starter 基线上完成可实际导入大目录 CM93 的 importer。
|
||||||
|
|
||||||
|
最终标准不是“部分对象能显示”,而是:
|
||||||
|
- cell 可完整解析;
|
||||||
|
- feature/attribute 计数可核对;
|
||||||
|
- geometry 可追踪;
|
||||||
|
- 数据可断点续导;
|
||||||
|
- 精度满足 `docs/精度规范.md`。
|
||||||
|
|
||||||
|
## 开始前
|
||||||
|
|
||||||
|
1. 完整阅读:
|
||||||
|
- `AGENTS.md`
|
||||||
|
- `README.md`
|
||||||
|
- `docs/精度规范.md`
|
||||||
|
- `docs/数据库设计.md`
|
||||||
|
2. 运行:
|
||||||
|
- `python --version`
|
||||||
|
- `cm93db env-check`
|
||||||
|
- `cm93db db-check`
|
||||||
|
3. 不修改第二阶段 PBF 范围。
|
||||||
|
|
||||||
|
## 必做清单
|
||||||
|
|
||||||
|
- [ ] 完成 CM93 256-byte substitution decoder 的独立实现
|
||||||
|
- [ ] 完成 prolog + 128-byte header
|
||||||
|
- [ ] 校验 cell 文件长度公式
|
||||||
|
- [ ] 完成 vector record table
|
||||||
|
- [ ] 完成 2D point table
|
||||||
|
- [ ] 完成 3D sounding table
|
||||||
|
- [ ] 完成 feature record table
|
||||||
|
- [ ] 按 `CM93OBJ.DIC` 映射 object class
|
||||||
|
- [ ] 按 `CM93ATTR.DIC/ATTRLUT.DIC` typed decode attribute
|
||||||
|
- [ ] 支持 B/S/R/C/L/W/G 属性类型
|
||||||
|
- [ ] 保留 raw feature / raw attribute
|
||||||
|
- [ ] 完成 Point
|
||||||
|
- [ ] 完成 Line edge chain + reverse flag
|
||||||
|
- [ ] 完成 Area ring 组装
|
||||||
|
- [ ] 完成 Sounding 深度
|
||||||
|
- [ ] 完成 `_m_sor` coverage
|
||||||
|
- [ ] 保存 `_wgsox/_wgsoy`
|
||||||
|
- [ ] 形成并文档化 WGS offset policy
|
||||||
|
- [ ] 标准化 geometry 为 WGS84 double
|
||||||
|
- [ ] 完成 MySQL bulk insert
|
||||||
|
- [ ] 每 cell 独立事务
|
||||||
|
- [ ] DONE 跳过 / FAILED 重试
|
||||||
|
- [ ] 未知 ID 不静默丢弃
|
||||||
|
- [ ] 完成 import summary
|
||||||
|
- [ ] 增加精度审计命令
|
||||||
|
- [ ] 用实际 CM93 小样本全链路验证
|
||||||
|
|
||||||
|
## 禁止
|
||||||
|
|
||||||
|
- 不做 PBF
|
||||||
|
- 不 simplify
|
||||||
|
- 不 quantize
|
||||||
|
- 不为了通过 geometry validity 静默改数据
|
||||||
|
- 不使用 MySQL 8 专用 SQL
|
||||||
|
- 不复制 OpenCPN GPL 源码
|
||||||
|
|
||||||
|
## 完成报告
|
||||||
|
|
||||||
|
写入:
|
||||||
|
`docs/报告/CM93_MySQL导入器完成报告_v1.md`
|
||||||
|
|
||||||
|
至少包括:
|
||||||
|
- 实现范围
|
||||||
|
- 实际样本
|
||||||
|
- cell 数
|
||||||
|
- source/imported feature 数
|
||||||
|
- attribute 数
|
||||||
|
- unknown 数
|
||||||
|
- geometry invalid/repaired 数
|
||||||
|
- 精度审计结果
|
||||||
|
- MySQL 版本
|
||||||
|
- Python 版本
|
||||||
|
- 已知限制
|
||||||
82
codex/20_导入与审计接手.md
Normal file
82
codex/20_导入与审计接手.md
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
# Codex:CM93JAPAN 导入与审计接手任务
|
||||||
|
|
||||||
|
## 当前状态
|
||||||
|
|
||||||
|
`cm93db import` 和 `cm93db audit` 已实现。
|
||||||
|
|
||||||
|
不要重新设计 parser,也不要开始 PBF。
|
||||||
|
|
||||||
|
## 固定环境
|
||||||
|
|
||||||
|
- Windows 11
|
||||||
|
- Python 3.12.10
|
||||||
|
- MySQL 5.7.34-log
|
||||||
|
- DB: cm93_chart
|
||||||
|
- CM93: `D:\Ship\CM93V2\CM93JAPAN`
|
||||||
|
|
||||||
|
## 接手顺序
|
||||||
|
|
||||||
|
- [ ] 阅读 AGENTS.md
|
||||||
|
- [ ] 阅读 README.md
|
||||||
|
- [ ] 阅读 docs/精度规范.md
|
||||||
|
- [ ] 阅读 docs/CM93解析与审计设计.md
|
||||||
|
- [ ] `cm93db env-check`
|
||||||
|
- [ ] `cm93db db-check`
|
||||||
|
- [ ] `cm93db scan`
|
||||||
|
- [ ] `cm93db inspect-dicts`
|
||||||
|
- [ ] G 层 10-cell pilot import
|
||||||
|
- [ ] 同批 audit
|
||||||
|
- [ ] 若失败,只修真实失败原因
|
||||||
|
- [ ] Pilot PASS 后全量 import
|
||||||
|
- [ ] 全量 audit
|
||||||
|
- [ ] 记录 FAILED cell,不得静默跳过
|
||||||
|
|
||||||
|
## Pilot
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
& ".\.venv\Scripts\cm93db.exe" import --scale G --limit 10
|
||||||
|
& ".\.venv\Scripts\cm93db.exe" audit --scale G --limit 10 --precision-samples 32
|
||||||
|
```
|
||||||
|
|
||||||
|
## 全量
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
& ".\.venv\Scripts\cm93db.exe" import
|
||||||
|
& ".\.venv\Scripts\cm93db.exe" audit --precision-samples 256
|
||||||
|
```
|
||||||
|
|
||||||
|
## 修复边界
|
||||||
|
|
||||||
|
允许修改:
|
||||||
|
|
||||||
|
- src/cm93db/*
|
||||||
|
- SQL(仅确认 MySQL 5.7.34 实际兼容问题时)
|
||||||
|
- docs
|
||||||
|
- scripts
|
||||||
|
|
||||||
|
禁止为了“跑完”而:
|
||||||
|
|
||||||
|
- 跳过无法解析 attribute
|
||||||
|
- 降低坐标精度
|
||||||
|
- round/simplify geometry
|
||||||
|
- 删除 raw preservation
|
||||||
|
- 对 Polygon 静默 make_valid
|
||||||
|
- 把失败 cell 标记 DONE
|
||||||
|
- 改为 MySQL 8 语法
|
||||||
|
- 开始 PBF
|
||||||
|
|
||||||
|
## 完成报告
|
||||||
|
|
||||||
|
写入:`docs/报告/CM93JAPAN_导入审计完成报告_v1.md`
|
||||||
|
|
||||||
|
记录:
|
||||||
|
|
||||||
|
- source cell 数及 Z/A~G 分布
|
||||||
|
- DONE / FAILED / SKIP
|
||||||
|
- features/attributes
|
||||||
|
- unknown IDs
|
||||||
|
- raw preservation
|
||||||
|
- precision audit
|
||||||
|
- geometry validity informational issues
|
||||||
|
- MySQL/Python 版本
|
||||||
|
- 失败 cell 清单
|
||||||
55
codex/ARCHITECTURE.md
Normal file
55
codex/ARCHITECTURE.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# 架构设计
|
||||||
|
|
||||||
|
## 数据流
|
||||||
|
|
||||||
|
CM93 原始目录
|
||||||
|
→ SourceScanner
|
||||||
|
→ DictionaryLoader
|
||||||
|
→ CellBinaryReader
|
||||||
|
→ FeatureDecoder
|
||||||
|
→ CoverageResolver
|
||||||
|
→ PrecisionProjection
|
||||||
|
→ ChartFeature Model
|
||||||
|
→ MySQLBatchWriter
|
||||||
|
→ MySQL 母数据库
|
||||||
|
|
||||||
|
## 分层
|
||||||
|
|
||||||
|
### `cm93`
|
||||||
|
只负责格式解析和坐标/几何语义,不知道 MySQL。
|
||||||
|
|
||||||
|
### `model`
|
||||||
|
定义稳定中间模型。
|
||||||
|
|
||||||
|
### `database`
|
||||||
|
负责 MySQL 5.7 写入、事务、批量提交和恢复。
|
||||||
|
|
||||||
|
### `cli`
|
||||||
|
只做命令组织。
|
||||||
|
|
||||||
|
## 数据库与 CM93 边界
|
||||||
|
|
||||||
|
写入数据库以后:
|
||||||
|
- geometry 已标准化为 WGS84 经纬度;
|
||||||
|
- 原始 CM93 元数据仍保留;
|
||||||
|
- 后续 PBF builder 不需要理解 CM93 二进制。
|
||||||
|
|
||||||
|
## Cell 事务
|
||||||
|
|
||||||
|
每个 cell 为独立事务边界:
|
||||||
|
|
||||||
|
DISCOVERED → IMPORTING → DONE
|
||||||
|
|
||||||
|
失败:
|
||||||
|
|
||||||
|
IMPORTING → FAILED
|
||||||
|
|
||||||
|
再次运行时:
|
||||||
|
- DONE 默认跳过;
|
||||||
|
- FAILED 可重试;
|
||||||
|
- 强制参数可重新导入指定 cell。
|
||||||
|
|
||||||
|
## Geometry
|
||||||
|
|
||||||
|
母数据库保留标准化 geometry,但不依赖 geometry 作为唯一事实来源。
|
||||||
|
原始记录和 source index 是审计入口。
|
||||||
150
docs/CM93解析与审计设计.md
Normal file
150
docs/CM93解析与审计设计.md
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
# CM93 解析与审计设计 v1
|
||||||
|
|
||||||
|
## 1. 原始格式解析
|
||||||
|
|
||||||
|
独立 Python 实现依据已公开的 OpenCPN CM93 行为进行互操作核对,但不复制 OpenCPN 的类结构或 GUI/S52 代码。
|
||||||
|
|
||||||
|
核心数据顺序:
|
||||||
|
|
||||||
|
1. 10-byte prolog
|
||||||
|
2. 128-byte header
|
||||||
|
3. vector records
|
||||||
|
4. 3D point descriptors
|
||||||
|
5. 2D points
|
||||||
|
6. feature records
|
||||||
|
|
||||||
|
完整性条件:
|
||||||
|
|
||||||
|
`word0 + table1_length + table2_length == file_size`
|
||||||
|
|
||||||
|
且 `word0 == 138`。
|
||||||
|
|
||||||
|
进一步核对:
|
||||||
|
|
||||||
|
- vector point 总数 = header `n_vector_record_points`
|
||||||
|
- 3D point 总数 = header `m_50`
|
||||||
|
- feature 总数 = header `usn_feature_records`
|
||||||
|
- attribute payload bytes = header `m_78`
|
||||||
|
- feature geometry refs = header `m_46 + m_4a`
|
||||||
|
- related object pointers = header `m_nrelated_object_pointers`
|
||||||
|
- 最终 cursor = EOF
|
||||||
|
|
||||||
|
任何一项不成立,该 cell 解析失败,不允许“尽量导入”。
|
||||||
|
|
||||||
|
## 2. 属性
|
||||||
|
|
||||||
|
按 `CM93ATTR.DIC/ATTRLUT.DIC`:
|
||||||
|
|
||||||
|
- B: 1-byte integer
|
||||||
|
- W: uint16 / 10.0
|
||||||
|
- G: int32
|
||||||
|
- R: float32,标准化后以 Python double/MySQL DOUBLE 保存
|
||||||
|
- S: NUL-terminated string
|
||||||
|
- C: 3-byte prefix + NUL-terminated string,prefix 与 text 均保留
|
||||||
|
- L: count byte + count 个 byte values
|
||||||
|
|
||||||
|
raw value bytes 始终另存。
|
||||||
|
|
||||||
|
## 3. Geometry
|
||||||
|
|
||||||
|
Feature geotype 低 4 bit:
|
||||||
|
|
||||||
|
- 1 Point
|
||||||
|
- 2 Line
|
||||||
|
- 4 Area
|
||||||
|
- 8 3D Sounding
|
||||||
|
|
||||||
|
Line/Area edge index:低 13 bit 是 edge index,高位是 usage;`usage & 4` 表示反向遍历 edge。
|
||||||
|
|
||||||
|
Area 按 edge chain 闭合形成 contour/ring。系统不会调用 `make_valid` 或改变原始顶点坐标。
|
||||||
|
|
||||||
|
## 4. 坐标
|
||||||
|
|
||||||
|
CM93 raw X/Y:0..65535。
|
||||||
|
|
||||||
|
Cell 变换:
|
||||||
|
|
||||||
|
`easting = raw_x * x_rate + easting_min`
|
||||||
|
|
||||||
|
`northing = raw_y * y_rate + northing_min`
|
||||||
|
|
||||||
|
CM93 半长轴:`6378388.0 m`。
|
||||||
|
|
||||||
|
若 coverage 给出 `_wgsox/_wgsoy`,标准化母数据采用:
|
||||||
|
|
||||||
|
`easting -= wgsox`
|
||||||
|
|
||||||
|
`northing -= wgsoy`
|
||||||
|
|
||||||
|
然后执行 CM93 Mercator → lon/lat。
|
||||||
|
|
||||||
|
Coverage polygon 自身保留未偏移坐标,用于判断 feature 属于哪个 coverage;offset 元数据独立保存。
|
||||||
|
|
||||||
|
## 5. Sounding
|
||||||
|
|
||||||
|
CM93 Z:
|
||||||
|
|
||||||
|
- `z >= 12000` → `depth = z - 12000`
|
||||||
|
- 否则 → `depth = z / 10.0`
|
||||||
|
|
||||||
|
每个 sounding point 同时保存 raw_x/raw_y/raw_z、depth 和 WGS84 Point。
|
||||||
|
|
||||||
|
## 6. Raw preservation
|
||||||
|
|
||||||
|
每个 cell 保存 decoded:
|
||||||
|
|
||||||
|
- prolog
|
||||||
|
- header
|
||||||
|
- vector table
|
||||||
|
- point3d table
|
||||||
|
- point2d table
|
||||||
|
- feature table
|
||||||
|
|
||||||
|
较大的 table 按 512 KiB 分块写入 `chart_raw_record`,避免 MySQL `max_allowed_packet` 造成单大包问题。
|
||||||
|
|
||||||
|
审计要求所有 raw chunk `OCTET_LENGTH` 总和 = 原 cell 文件字节数。
|
||||||
|
|
||||||
|
## 7. 审计分层
|
||||||
|
|
||||||
|
### Source parser audit
|
||||||
|
|
||||||
|
重新读取原 CM93,验证所有结构计数和 EOF。
|
||||||
|
|
||||||
|
### Database completeness audit
|
||||||
|
|
||||||
|
逐 cell 对比源数据与:
|
||||||
|
|
||||||
|
- chart_feature
|
||||||
|
- chart_feature_attribute
|
||||||
|
- point/line/area geometry
|
||||||
|
- chart_coverage
|
||||||
|
- chart_sounding_point
|
||||||
|
|
||||||
|
### Raw preservation audit
|
||||||
|
|
||||||
|
比较 raw chunk 总字节数与原文件长度。
|
||||||
|
|
||||||
|
### Hash audit
|
||||||
|
|
||||||
|
比较数据库 `source_sha256` 与当前原文件。
|
||||||
|
|
||||||
|
### Precision audit
|
||||||
|
|
||||||
|
从 Z/A~G 分层抽样,重新从 CM93 raw geometry 生成 WKB,然后从 MySQL `ST_AsWKB()` 回读,使用 `equals_exact` 比较。
|
||||||
|
|
||||||
|
默认 tolerance:`1e-12 degree`。
|
||||||
|
|
||||||
|
该标准比最初讨论的 `1e-9 degree` 更严格。
|
||||||
|
|
||||||
|
## 8. 不做的事情
|
||||||
|
|
||||||
|
第一阶段不做:
|
||||||
|
|
||||||
|
- PBF
|
||||||
|
- zoom selection
|
||||||
|
- simplify
|
||||||
|
- topology repair
|
||||||
|
- scale 去重
|
||||||
|
- cartographic style 映射
|
||||||
|
|
||||||
|
这些都属于第二阶段。
|
||||||
50
docs/Win11环境安装.md
Normal file
50
docs/Win11环境安装.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# Windows 11 + pyenv 环境安装
|
||||||
|
|
||||||
|
已知机器:
|
||||||
|
- pyenv 已安装
|
||||||
|
- Python 3.9.2 当前全局默认
|
||||||
|
- Python 3.12.10 已安装
|
||||||
|
|
||||||
|
本项目不要修改全局默认版本,推荐只设置项目 local:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd <项目目录>
|
||||||
|
pyenv local 3.12.10
|
||||||
|
python --version
|
||||||
|
```
|
||||||
|
|
||||||
|
必须为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Python 3.12.10
|
||||||
|
```
|
||||||
|
|
||||||
|
建立虚拟环境:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -m venv .venv
|
||||||
|
.\.venv\Scripts\Activate.ps1
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install -e .
|
||||||
|
```
|
||||||
|
|
||||||
|
或者直接:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\scripts\setup_env.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
数据库配置:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Copy-Item .env.example .env
|
||||||
|
notepad .env
|
||||||
|
```
|
||||||
|
|
||||||
|
然后:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cm93db env-check
|
||||||
|
cm93db db-init --create-database
|
||||||
|
cm93db db-check
|
||||||
|
```
|
||||||
25
docs/外部参考.md
Normal file
25
docs/外部参考.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# 外部参考
|
||||||
|
|
||||||
|
本项目独立实现 CM93 importer,不复制 OpenCPN GPL 源码。
|
||||||
|
|
||||||
|
用于核对行为和 MySQL 兼容性的公开参考:
|
||||||
|
|
||||||
|
- OpenCPN CM93 implementation:
|
||||||
|
https://github.com/OpenCPN/OpenCPN/blob/master/gui/src/cm93.cpp
|
||||||
|
|
||||||
|
- MySQL 5.7 Spatial Data Types:
|
||||||
|
https://dev.mysql.com/doc/refman/5.7/en/spatial-type-overview.html
|
||||||
|
|
||||||
|
- MySQL 5.7 Geometry Class:
|
||||||
|
https://dev.mysql.com/doc/refman/5.7/en/gis-class-geometry.html
|
||||||
|
|
||||||
|
- MySQL 5.7 Spatial Index:
|
||||||
|
https://dev.mysql.com/doc/refman/5.7/en/creating-spatial-indexes.html
|
||||||
|
|
||||||
|
注意:MySQL 5.7 Geometry 坐标本身为 double precision,但对实际 SRID 的地理计算能力不能按 MySQL 8 的行为假设。
|
||||||
|
|
||||||
|
## v0.2.0 互操作说明
|
||||||
|
|
||||||
|
CM93 binary 行为用于互操作核对的核心公开实现是 OpenCPN `cm93.cpp` 的 decode/header/table/feature/attribute/Transform 逻辑。
|
||||||
|
|
||||||
|
本工程采用独立 Python 数据模型、数据库模型和实现结构。CM93 substitution mapping 属于解码互操作所需的固定映射;如未来公开分发或商业分发本工具,应自行进行许可证/数据授权审查。
|
||||||
151
docs/导入与审计.md
Normal file
151
docs/导入与审计.md
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
# CM93 导入与审计操作说明
|
||||||
|
|
||||||
|
## 1. 适用环境
|
||||||
|
|
||||||
|
- Windows 11
|
||||||
|
- Python 3.12.10
|
||||||
|
- MySQL 5.7.34
|
||||||
|
- 已完成 `cm93db db-init --create-database`
|
||||||
|
|
||||||
|
## 2. 正式导入
|
||||||
|
|
||||||
|
如果 `.env` 已配置:
|
||||||
|
|
||||||
|
```text
|
||||||
|
CM93_SOURCE_DIR=D:\Ship\CM93V2\CM93_2014
|
||||||
|
```
|
||||||
|
|
||||||
|
可直接运行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
& ".\.venv\Scripts\cm93db.exe" import
|
||||||
|
```
|
||||||
|
|
||||||
|
也可显式给路径:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
& ".\.venv\Scripts\cm93db.exe" import D:\Ship\CM93V2\CM93_2014
|
||||||
|
```
|
||||||
|
|
||||||
|
默认行为:
|
||||||
|
|
||||||
|
- 递归扫描 Z/A/B/C/D/E/F/G;
|
||||||
|
- 每个 cell 独立事务;
|
||||||
|
- DONE + 文件 SHA256 相同自动跳过;
|
||||||
|
- FAILED cell 下次自动重试;
|
||||||
|
- 保存 typed attributes;
|
||||||
|
- 保存标准化 WGS84 geometry;
|
||||||
|
- 保存 decoded CM93 原始 table block;
|
||||||
|
- `_wgsox/_wgsoy` 默认按 `embedded` policy 应用;
|
||||||
|
- 不 simplify / snap / quantize / round;
|
||||||
|
- 不自动 `make_valid`。
|
||||||
|
|
||||||
|
### 先小批验证
|
||||||
|
|
||||||
|
推荐正式 30,097 cell 前先跑:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
& ".\.venv\Scripts\cm93db.exe" import --scale G --limit-cells 5
|
||||||
|
```
|
||||||
|
|
||||||
|
然后:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
& ".\.venv\Scripts\cm93db.exe" audit --sample-cells 5
|
||||||
|
```
|
||||||
|
|
||||||
|
通过后再跑完整导入。
|
||||||
|
|
||||||
|
### 只导某些尺度
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
& ".\.venv\Scripts\cm93db.exe" import --scale G --scale F
|
||||||
|
```
|
||||||
|
|
||||||
|
### 强制重导
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
& ".\.venv\Scripts\cm93db.exe" import --force
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 审计
|
||||||
|
|
||||||
|
### 快速审计
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
& ".\.venv\Scripts\cm93db.exe" audit --sample-cells 20
|
||||||
|
```
|
||||||
|
|
||||||
|
包括:
|
||||||
|
|
||||||
|
1. 数据库 cell / feature / attribute 计数;
|
||||||
|
2. 每个 DONE cell 的 source count 与数据库 count 对账;
|
||||||
|
3. Point / Line / Area geometry presence;
|
||||||
|
4. 随机重新解析源 CM93 cell;
|
||||||
|
5. 源 feature / attribute count 对比;
|
||||||
|
6. 从 MySQL `ST_AsWKB` 读回 geometry;
|
||||||
|
7. 与重新计算的 double precision geometry 做逐顶点比较;
|
||||||
|
8. sounding raw X/Y/Z、depth、lon/lat 比较。
|
||||||
|
|
||||||
|
默认坐标容差:
|
||||||
|
|
||||||
|
```text
|
||||||
|
1e-12 degree
|
||||||
|
```
|
||||||
|
|
||||||
|
这个容差用于发现数据库序列化/写入造成的精度损失,不是让 importer 人为 round 到该精度。
|
||||||
|
|
||||||
|
### 全源最终审计
|
||||||
|
|
||||||
|
完整导入后:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
& ".\.venv\Scripts\cm93db.exe" audit --all-source
|
||||||
|
```
|
||||||
|
|
||||||
|
这会重新解析所有 DONE cell,耗时明显高于随机抽样,但适合作为母数据库最终验收。
|
||||||
|
|
||||||
|
报告自动写入:
|
||||||
|
|
||||||
|
```text
|
||||||
|
logs/audit-YYYYMMDD-HHMMSS.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. offset policy
|
||||||
|
|
||||||
|
正式母库默认:
|
||||||
|
|
||||||
|
```text
|
||||||
|
--offset-policy embedded
|
||||||
|
```
|
||||||
|
|
||||||
|
含义:
|
||||||
|
|
||||||
|
1. `_m_sor` coverage 自身使用未偏移 cell 坐标建立;
|
||||||
|
2. 保存 `_wgsox/_wgsoy` 原始值;
|
||||||
|
3. 对能够匹配 coverage 的普通几何,在 CM93 Mercator 阶段减去对应 offset;
|
||||||
|
4. 然后使用 CM93 半长轴 `6378388.0` 转换 WGS84 lon/lat;
|
||||||
|
5. feature JSON 中记录实际应用的 offset 和 coverage match method。
|
||||||
|
|
||||||
|
为研究/对照可运行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
--offset-policy none
|
||||||
|
```
|
||||||
|
|
||||||
|
但不要把同一个 dataset 混用两种 policy。改变 policy 时应 `--force` 全部重导,并重新执行全源审计。
|
||||||
|
|
||||||
|
## 5. Geometry issue
|
||||||
|
|
||||||
|
源 CM93 中可能存在自交 polygon 等问题。
|
||||||
|
|
||||||
|
规则:
|
||||||
|
|
||||||
|
- raw record 永久保留;
|
||||||
|
- `geometry_issue` 记录 Shapely validity / ring assembly 问题;
|
||||||
|
- `geometry_repaired=0`;
|
||||||
|
- 当前版本不自动修复;
|
||||||
|
- 可以保存结构上可编码的 invalid WKB;
|
||||||
|
- 无法形成 geometry 时不伪造 geometry row。
|
||||||
|
|
||||||
|
第二阶段 PBF 如需要显示修复,应在发布层制定独立策略,不能修改母数据库事实。
|
||||||
0
docs/报告/.gitkeep
Normal file
0
docs/报告/.gitkeep
Normal file
69
docs/报告/CM93_Importer_v0.2.0_离线验证报告.md
Normal file
69
docs/报告/CM93_Importer_v0.2.0_离线验证报告.md
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# CM93 Importer v0.2.0 离线验证报告
|
||||||
|
|
||||||
|
## 范围
|
||||||
|
|
||||||
|
本报告验证 CM93 parser、geometry 标准化和 MySQL 写入批次结构。
|
||||||
|
由于 ChatGPT 执行环境无法访问用户本机 MySQL,本报告不声称已经替用户执行 MySQL 实际插入;真实数据库验收由 `import --limit-cells 5` + `audit` 完成。
|
||||||
|
|
||||||
|
## 固定真实样本
|
||||||
|
|
||||||
|
用户提供的 147 个 A~G CM93 cell。
|
||||||
|
|
||||||
|
结果:
|
||||||
|
|
||||||
|
```text
|
||||||
|
cells 147
|
||||||
|
parse failures 0
|
||||||
|
features 100621
|
||||||
|
attributes 112746
|
||||||
|
```
|
||||||
|
|
||||||
|
与前期独立解析计数一致。
|
||||||
|
|
||||||
|
## Geometry + 写入结构回归
|
||||||
|
|
||||||
|
对 147 个 cell 全部执行:
|
||||||
|
|
||||||
|
```text
|
||||||
|
parse
|
||||||
|
→ coverage build
|
||||||
|
→ Point/Line/Area/Sounding normalization
|
||||||
|
→ embedded offset matching
|
||||||
|
→ WKB generation
|
||||||
|
→ 模拟 MySQL insert tuple / dependent-id mapping
|
||||||
|
```
|
||||||
|
|
||||||
|
结果:
|
||||||
|
|
||||||
|
```text
|
||||||
|
cells 147
|
||||||
|
features 100621
|
||||||
|
attributes 112746
|
||||||
|
geometry issues 36
|
||||||
|
unmatched coverage 4
|
||||||
|
```
|
||||||
|
|
||||||
|
36 个 geometry issue 主要为源 polygon self-intersection;v0.2.0 不自动 make-valid。
|
||||||
|
4 个 unmatched coverage 为仍有可保存几何、但 representative point 未落入 `_m_sor` coverage 的 cartographic/text 类对象;按 policy 保存 0 offset 并在 `_cm93.coverage_match_method` 中记录 `unmatched`,不静默猜测最近 coverage。
|
||||||
|
|
||||||
|
## 精度规则
|
||||||
|
|
||||||
|
- Python float / C double
|
||||||
|
- MySQL Geometry double
|
||||||
|
- CM93 Mercator `6378388.0`
|
||||||
|
- 不 round
|
||||||
|
- 不 simplify
|
||||||
|
- 不 quantize
|
||||||
|
- sounding 原始 X/Y/Z 同时保存
|
||||||
|
- raw feature / raw attribute / decoded CM93 table block 保存
|
||||||
|
|
||||||
|
## 下一验证门槛
|
||||||
|
|
||||||
|
用户机器:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cm93db import --scale G --limit-cells 5
|
||||||
|
cm93db audit --sample-cells 5
|
||||||
|
```
|
||||||
|
|
||||||
|
只有数据库实际写入和 WKB 读回精度审计 PASS 后,再启动 30,097 cell 全量导入。
|
||||||
69
docs/报告/CM93导入器开发验证报告_v0.2.0.md
Normal file
69
docs/报告/CM93导入器开发验证报告_v0.2.0.md
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# CM93 导入器开发验证报告 v0.2.0
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
|
||||||
|
v0.2.0 已完成 CM93 binary parser、标准化 geometry、MySQL 5.7 writer、断点续导和 audit 代码。
|
||||||
|
|
||||||
|
## 实际样本回归
|
||||||
|
|
||||||
|
输入:用户上传 `04501020.zip` + `CM93OBJ.DIC` + `CM93ATTR.DIC`。
|
||||||
|
|
||||||
|
结果:
|
||||||
|
|
||||||
|
- Cell: 147
|
||||||
|
- Parser PASS: 147
|
||||||
|
- Parser FAIL: 0
|
||||||
|
- Feature: 100,621
|
||||||
|
- Attribute: 112,746
|
||||||
|
- 未知 object ID: 0
|
||||||
|
- 未知 attribute ID: 0
|
||||||
|
- Sounding points: 76,126
|
||||||
|
- `_m_sor` coverage: 258
|
||||||
|
- 非零 `_wgsox/_wgsoy` coverage: 34
|
||||||
|
|
||||||
|
结构级验证:
|
||||||
|
|
||||||
|
- prolog/header/file-size formula: PASS
|
||||||
|
- vector point header count: PASS
|
||||||
|
- 3D point header count: PASS
|
||||||
|
- feature header count: PASS
|
||||||
|
- attribute payload byte header count: PASS
|
||||||
|
- geometry-reference header count: PASS
|
||||||
|
- related-pointer header count: PASS
|
||||||
|
- EOF exact: PASS
|
||||||
|
- decoded raw table 总字节 == 原文件字节: PASS
|
||||||
|
|
||||||
|
## Geometry
|
||||||
|
|
||||||
|
样本中所有应产生 Point/Line/Area 的 source feature 均生成了 geometry;没有 geometry missing。
|
||||||
|
|
||||||
|
部分 CM93 Area 在严格拓扑检查下存在自相交/边界问题。v0.2.0 不自动修复,只在 `--validate-geometry` 时报告;这符合母数据库不改变原数据顶点的原则。
|
||||||
|
|
||||||
|
## MySQL
|
||||||
|
|
||||||
|
目标:MySQL 5.7.34-log。
|
||||||
|
|
||||||
|
用户已在前一版本实际验证:
|
||||||
|
|
||||||
|
- 数据库创建成功
|
||||||
|
- 14 张表初始化成功
|
||||||
|
- Schema contract PASS
|
||||||
|
|
||||||
|
v0.2.0 未修改表数量或需要迁移的核心 Schema,因此现有数据库可继续使用。
|
||||||
|
|
||||||
|
v0.2.0 新增的实际 feature/geometry bulk write 尚需在用户 Windows/MySQL 上执行 10-cell pilot;README 和 Codex 接手文档已固定 pilot → audit → full import 顺序。
|
||||||
|
|
||||||
|
## 当前正式数据目录
|
||||||
|
|
||||||
|
`D:\Ship\CM93V2\CM93JAPAN`
|
||||||
|
|
||||||
|
不再使用旧的 `CM93_2014` 全目录作为当前正式导入目标。
|
||||||
|
|
||||||
|
## 下一验收点
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cm93db import --scale G --limit 10
|
||||||
|
cm93db audit --scale G --limit 10 --precision-samples 32
|
||||||
|
```
|
||||||
|
|
||||||
|
只有审计 `RESULT: PASS` 后才继续全量日本数据。
|
||||||
50
docs/数据库设计.md
Normal file
50
docs/数据库设计.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# MySQL 5.7.34 数据库设计
|
||||||
|
|
||||||
|
## 核心表
|
||||||
|
|
||||||
|
- `chart_dataset`:一个 CM93 数据集根目录
|
||||||
|
- `chart_dictionary_object`:对象字典快照
|
||||||
|
- `chart_dictionary_attribute`:属性字典快照
|
||||||
|
- `chart_cell`:CM93 cell 元数据
|
||||||
|
- `chart_coverage`:`_m_sor` coverage
|
||||||
|
- `chart_feature`:公共 feature 元数据
|
||||||
|
- `chart_feature_attribute`:规范化属性
|
||||||
|
- `chart_point_geometry`
|
||||||
|
- `chart_line_geometry`
|
||||||
|
- `chart_area_geometry`
|
||||||
|
- `chart_sounding_point`
|
||||||
|
- `chart_raw_record`
|
||||||
|
- `chart_import_job`
|
||||||
|
- `chart_import_event`
|
||||||
|
|
||||||
|
## Geometry
|
||||||
|
|
||||||
|
MySQL 5.7 不使用 MySQL 8 的 SRID 列限制。
|
||||||
|
所有标准化 geometry 写入时显式:
|
||||||
|
|
||||||
|
`ST_GeomFromWKB(%s, 4326)`
|
||||||
|
|
||||||
|
并在 `chart_feature.normalized_srid` 记录 4326。
|
||||||
|
|
||||||
|
## 原始值
|
||||||
|
|
||||||
|
CM93 的:
|
||||||
|
- header bounds
|
||||||
|
- source scale
|
||||||
|
- raw geotype
|
||||||
|
- raw feature bytes
|
||||||
|
- raw attribute bytes
|
||||||
|
- coverage offset
|
||||||
|
|
||||||
|
必须保存。
|
||||||
|
|
||||||
|
## 数值属性
|
||||||
|
|
||||||
|
`chart_feature_attribute` 同时提供:
|
||||||
|
- `value_int`
|
||||||
|
- `value_real`
|
||||||
|
- `value_text`
|
||||||
|
- `value_json`
|
||||||
|
- `raw_value_blob`
|
||||||
|
|
||||||
|
这样既保留 typed value,也允许审计原始数据。
|
||||||
55
docs/架构设计.md
Normal file
55
docs/架构设计.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# 架构设计
|
||||||
|
|
||||||
|
## 数据流
|
||||||
|
|
||||||
|
CM93 原始目录
|
||||||
|
→ SourceScanner
|
||||||
|
→ DictionaryLoader
|
||||||
|
→ CellBinaryReader
|
||||||
|
→ FeatureDecoder
|
||||||
|
→ CoverageResolver
|
||||||
|
→ PrecisionProjection
|
||||||
|
→ ChartFeature Model
|
||||||
|
→ MySQLBatchWriter
|
||||||
|
→ MySQL 母数据库
|
||||||
|
|
||||||
|
## 分层
|
||||||
|
|
||||||
|
### `cm93`
|
||||||
|
只负责格式解析和坐标/几何语义,不知道 MySQL。
|
||||||
|
|
||||||
|
### `model`
|
||||||
|
定义稳定中间模型。
|
||||||
|
|
||||||
|
### `database`
|
||||||
|
负责 MySQL 5.7 写入、事务、批量提交和恢复。
|
||||||
|
|
||||||
|
### `cli`
|
||||||
|
只做命令组织。
|
||||||
|
|
||||||
|
## 数据库与 CM93 边界
|
||||||
|
|
||||||
|
写入数据库以后:
|
||||||
|
- geometry 已标准化为 WGS84 经纬度;
|
||||||
|
- 原始 CM93 元数据仍保留;
|
||||||
|
- 后续 PBF builder 不需要理解 CM93 二进制。
|
||||||
|
|
||||||
|
## Cell 事务
|
||||||
|
|
||||||
|
每个 cell 为独立事务边界:
|
||||||
|
|
||||||
|
DISCOVERED → IMPORTING → DONE
|
||||||
|
|
||||||
|
失败:
|
||||||
|
|
||||||
|
IMPORTING → FAILED
|
||||||
|
|
||||||
|
再次运行时:
|
||||||
|
- DONE 默认跳过;
|
||||||
|
- FAILED 可重试;
|
||||||
|
- 强制参数可重新导入指定 cell。
|
||||||
|
|
||||||
|
## Geometry
|
||||||
|
|
||||||
|
母数据库保留标准化 geometry,但不依赖 geometry 作为唯一事实来源。
|
||||||
|
原始记录和 source index 是审计入口。
|
||||||
55
docs/精度规范.md
Normal file
55
docs/精度规范.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# CM93 母数据库精度规范 v2
|
||||||
|
|
||||||
|
## 固定常量
|
||||||
|
|
||||||
|
- CM93 raw grid: 0..65535
|
||||||
|
- CM93 Mercator semi-major axis: **6378388.0 m**
|
||||||
|
- 标准化母库:WGS84 经纬度语义 / SRID 4326
|
||||||
|
- Python 坐标计算:CPython float(C double)
|
||||||
|
- WKB X/Y:IEEE-754 8-byte double
|
||||||
|
|
||||||
|
## 禁止
|
||||||
|
|
||||||
|
- numpy.float32 作为坐标
|
||||||
|
- MySQL FLOAT 保存最终经纬度
|
||||||
|
- importer 阶段 round
|
||||||
|
- simplify
|
||||||
|
- snap
|
||||||
|
- quantize
|
||||||
|
- 自动 make_valid 后替换原 geometry
|
||||||
|
|
||||||
|
## 转换
|
||||||
|
|
||||||
|
1. 读取 raw uint16 X/Y。
|
||||||
|
2. 使用 header 的 easting/northing bounds 恢复 CM93 Mercator。
|
||||||
|
3. 若 feature 所属 `_m_sor` coverage 有 `_wgsox/_wgsoy`:在 Mercator 米坐标中减去 offset。
|
||||||
|
4. 使用 6378388.0 执行 CM93 Mercator → lon/lat。
|
||||||
|
5. WKB 写 MySQL,第二参数显式 SRID=4326。
|
||||||
|
|
||||||
|
## 数据可追踪
|
||||||
|
|
||||||
|
必须同时保存:
|
||||||
|
|
||||||
|
- source cell path
|
||||||
|
- SHA-256
|
||||||
|
- header
|
||||||
|
- raw decoded tables
|
||||||
|
- raw feature record
|
||||||
|
- raw attribute payload/value
|
||||||
|
- `_wgsox/_wgsoy`
|
||||||
|
- normalized geometry
|
||||||
|
|
||||||
|
因此标准化公式未来如需修订,不必重新取得原 CM93 光盘/目录即可重新解释数据库 raw 数据。
|
||||||
|
|
||||||
|
## 审计门槛
|
||||||
|
|
||||||
|
默认:
|
||||||
|
|
||||||
|
- parser 结构计数全部一致
|
||||||
|
- EOF 精确一致
|
||||||
|
- source SHA-256 一致
|
||||||
|
- raw chunk 总字节 = source file bytes
|
||||||
|
- source/database Feature/Attribute/Geometry/Sounding 计数一致
|
||||||
|
- WKB round-trip precision tolerance = **1e-12 degree**
|
||||||
|
|
||||||
|
`--validate-geometry` 的拓扑 validity 是额外信息,不允许通过自动修复掩盖原数据结构。
|
||||||
24
pyproject.toml
Normal file
24
pyproject.toml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "cm93-mysql-importer"
|
||||||
|
version = "0.2.0"
|
||||||
|
description = "CM93 complete high-precision importer and auditor for MySQL 5.7"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.12,<3.13"
|
||||||
|
dependencies = [
|
||||||
|
"PyMySQL>=1.1,<2",
|
||||||
|
"python-dotenv>=1.0,<2",
|
||||||
|
"Shapely>=2.0,<3",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
cm93db = "cm93db.cli:main"
|
||||||
|
|
||||||
|
[tool.setuptools]
|
||||||
|
package-dir = {"" = "src"}
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["src"]
|
||||||
3
requirements.txt
Normal file
3
requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
PyMySQL>=1.1,<2
|
||||||
|
python-dotenv>=1.0,<2
|
||||||
|
Shapely>=2.0,<3
|
||||||
17
samples/README.md
Normal file
17
samples/README.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# 样本数据
|
||||||
|
|
||||||
|
本 ZIP 不重新分发用户的 CM93 数据。
|
||||||
|
|
||||||
|
建议将已经验证的小样本放在项目外,例如:
|
||||||
|
|
||||||
|
`D:\Ship\CM93V2\CM93JAPAN_TEST\04501020\`
|
||||||
|
|
||||||
|
然后运行:
|
||||||
|
|
||||||
|
`cm93db scan D:\Ship\CM93V2\CM93JAPAN_TEST\04501020`
|
||||||
|
|
||||||
|
和:
|
||||||
|
|
||||||
|
`cm93db inspect-dicts D:\Ship\CM93V2\CM93JAPAN_TEST`
|
||||||
|
|
||||||
|
正式 Codex 开发时,以真实样本作为格式回归基线。
|
||||||
7
scripts/audit_cm93.ps1
Normal file
7
scripts/audit_cm93.ps1
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
if (-not (Test-Path ".\.venv\Scripts\cm93db.exe")) {
|
||||||
|
throw "未发现 cm93db,请先运行 scripts\setup_env.ps1"
|
||||||
|
}
|
||||||
|
|
||||||
|
& ".\.venv\Scripts\cm93db.exe" audit @args
|
||||||
10
scripts/import_cm93.ps1
Normal file
10
scripts/import_cm93.ps1
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
if (-not (Test-Path ".\.venv\Scripts\cm93db.exe")) {
|
||||||
|
throw "未发现 cm93db,请先运行 scripts\setup_env.ps1"
|
||||||
|
}
|
||||||
|
if (-not (Test-Path ".env")) {
|
||||||
|
throw "未发现 .env,请从 .env.example 复制并配置 CM93_SOURCE_DIR / MySQL"
|
||||||
|
}
|
||||||
|
|
||||||
|
& ".\.venv\Scripts\cm93db.exe" import @args
|
||||||
11
scripts/init_db.ps1
Normal file
11
scripts/init_db.ps1
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
if (-not (Test-Path ".venv")) {
|
||||||
|
throw "未发现 .venv,请先运行 .\scripts\setup_env.ps1"
|
||||||
|
}
|
||||||
|
if (-not (Test-Path ".env")) {
|
||||||
|
throw "未发现 .env,请从 .env.example 复制并填写数据库信息"
|
||||||
|
}
|
||||||
|
|
||||||
|
& ".\.venv\Scripts\cm93db.exe" db-init --create-database
|
||||||
|
& ".\.venv\Scripts\cm93db.exe" db-check
|
||||||
23
scripts/repair_env.ps1
Normal file
23
scripts/repair_env.ps1
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$Python312 = (pyenv which python | Select-Object -First 1).Trim()
|
||||||
|
if (-not (Test-Path $Python312)) {
|
||||||
|
throw "找不到 pyenv Python: $Python312"
|
||||||
|
}
|
||||||
|
|
||||||
|
$Version = (& $Python312 --version 2>&1).Trim()
|
||||||
|
if ($Version -ne "Python 3.12.10") {
|
||||||
|
throw "pyenv 当前不是 Python 3.12.10: $Version"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Test-Path ".venv") {
|
||||||
|
Remove-Item -Recurse -Force ".venv"
|
||||||
|
}
|
||||||
|
|
||||||
|
& $Python312 -m venv .venv
|
||||||
|
$VenvPython = (Resolve-Path ".\.venv\Scripts\python.exe").Path
|
||||||
|
& $VenvPython -m pip install --upgrade pip
|
||||||
|
& $VenvPython -m pip install -e .
|
||||||
|
|
||||||
|
& ".\.venv\Scripts\cm93db.exe" --help
|
||||||
|
Write-Host "修复完成。"
|
||||||
61
scripts/setup_env.ps1
Normal file
61
scripts/setup_env.ps1
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
Write-Host "[1/6] 设置项目 Python 3.12.10"
|
||||||
|
pyenv local 3.12.10
|
||||||
|
|
||||||
|
Write-Host "[2/6] 通过 pyenv 获取项目 Python"
|
||||||
|
$Python312 = (pyenv which python | Select-Object -First 1).Trim()
|
||||||
|
|
||||||
|
if (-not $Python312) {
|
||||||
|
throw "pyenv which python 未返回 Python 路径"
|
||||||
|
}
|
||||||
|
if (-not (Test-Path $Python312)) {
|
||||||
|
throw "pyenv 返回的 Python 不存在: $Python312"
|
||||||
|
}
|
||||||
|
|
||||||
|
$Version = (& $Python312 --version 2>&1).Trim()
|
||||||
|
Write-Host "Python executable: $Python312"
|
||||||
|
Write-Host "Python version: $Version"
|
||||||
|
|
||||||
|
if ($Version -ne "Python 3.12.10") {
|
||||||
|
throw "项目要求 Python 3.12.10,但 pyenv 当前返回: $Version"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "[3/6] 检查/创建虚拟环境"
|
||||||
|
if (Test-Path ".venv\Scripts\python.exe") {
|
||||||
|
$ExistingVersion = (& ".\.venv\Scripts\python.exe" --version 2>&1).Trim()
|
||||||
|
if ($ExistingVersion -ne "Python 3.12.10") {
|
||||||
|
Write-Host "发现错误虚拟环境 ($ExistingVersion),正在删除..."
|
||||||
|
Remove-Item -Recurse -Force ".venv"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-Path ".venv\Scripts\python.exe")) {
|
||||||
|
& $Python312 -m venv .venv
|
||||||
|
}
|
||||||
|
|
||||||
|
$VenvPython = (Resolve-Path ".\.venv\Scripts\python.exe").Path
|
||||||
|
$VenvVersion = (& $VenvPython --version 2>&1).Trim()
|
||||||
|
Write-Host "Venv Python: $VenvVersion"
|
||||||
|
|
||||||
|
if ($VenvVersion -ne "Python 3.12.10") {
|
||||||
|
throw "虚拟环境 Python 版本错误: $VenvVersion"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "[4/6] 升级 pip"
|
||||||
|
& $VenvPython -m pip install --upgrade pip
|
||||||
|
|
||||||
|
Write-Host "[5/6] 安装项目"
|
||||||
|
& $VenvPython -m pip install -e .
|
||||||
|
|
||||||
|
$Cm93Db = (Resolve-Path ".\.venv\Scripts\cm93db.exe").Path
|
||||||
|
|
||||||
|
Write-Host "[6/6] 基础命令检查"
|
||||||
|
& $Cm93Db --help
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Python 3.12.10 虚拟环境已建立。"
|
||||||
|
Write-Host "下一步:"
|
||||||
|
Write-Host " 1. Copy-Item .env.example .env"
|
||||||
|
Write-Host " 2. 修改 .env 中的 MySQL 5.7.34 参数"
|
||||||
|
Write-Host " 3. .\scripts\init_db.ps1"
|
||||||
281
sql/001_schema_mysql57.sql
Normal file
281
sql/001_schema_mysql57.sql
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
SET NAMES utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS chart_dataset (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
source_type VARCHAR(16) NOT NULL DEFAULT 'CM93',
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
source_root TEXT NOT NULL,
|
||||||
|
source_fingerprint CHAR(64) NULL,
|
||||||
|
parser_version VARCHAR(32) NOT NULL,
|
||||||
|
normalized_srid INT UNSIGNED NOT NULL DEFAULT 4326,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_dataset_source_type (source_type)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS chart_dictionary_object (
|
||||||
|
dataset_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
object_class_id SMALLINT UNSIGNED NOT NULL,
|
||||||
|
object_class VARCHAR(16) NOT NULL,
|
||||||
|
geometry_code VARCHAR(8) NULL,
|
||||||
|
display_code VARCHAR(8) NULL,
|
||||||
|
description VARCHAR(255) NULL,
|
||||||
|
raw_line TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (dataset_id, object_class_id),
|
||||||
|
KEY idx_dict_object_class (object_class),
|
||||||
|
CONSTRAINT fk_dict_object_dataset FOREIGN KEY (dataset_id)
|
||||||
|
REFERENCES chart_dataset(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS chart_dictionary_attribute (
|
||||||
|
dataset_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
attribute_id SMALLINT UNSIGNED NOT NULL,
|
||||||
|
attribute_name VARCHAR(16) NOT NULL,
|
||||||
|
source_type_name VARCHAR(32) NOT NULL,
|
||||||
|
decoder_type CHAR(1) NOT NULL,
|
||||||
|
min_value DOUBLE NULL,
|
||||||
|
max_value DOUBLE NULL,
|
||||||
|
raw_line TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (dataset_id, attribute_id),
|
||||||
|
KEY idx_dict_attribute_name (attribute_name),
|
||||||
|
CONSTRAINT fk_dict_attr_dataset FOREIGN KEY (dataset_id)
|
||||||
|
REFERENCES chart_dataset(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS chart_cell (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
dataset_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
source_relative_path VARCHAR(1024) NOT NULL,
|
||||||
|
source_file_name VARCHAR(255) NOT NULL,
|
||||||
|
source_size BIGINT UNSIGNED NOT NULL,
|
||||||
|
source_sha256 CHAR(64) NULL,
|
||||||
|
scale_code CHAR(1) NOT NULL,
|
||||||
|
native_scale INT UNSIGNED NOT NULL,
|
||||||
|
cell_name VARCHAR(64) NOT NULL,
|
||||||
|
|
||||||
|
prolog_word0 INT UNSIGNED NULL,
|
||||||
|
table1_length BIGINT UNSIGNED NULL,
|
||||||
|
table2_length BIGINT UNSIGNED NULL,
|
||||||
|
|
||||||
|
easting_min DOUBLE NULL,
|
||||||
|
northing_min DOUBLE NULL,
|
||||||
|
easting_max DOUBLE NULL,
|
||||||
|
northing_max DOUBLE NULL,
|
||||||
|
|
||||||
|
bbox_min_lon DOUBLE NULL,
|
||||||
|
bbox_min_lat DOUBLE NULL,
|
||||||
|
bbox_max_lon DOUBLE NULL,
|
||||||
|
bbox_max_lat DOUBLE NULL,
|
||||||
|
|
||||||
|
vector_record_count INT UNSIGNED NULL,
|
||||||
|
vector_point_count BIGINT UNSIGNED NULL,
|
||||||
|
point2d_count INT UNSIGNED NULL,
|
||||||
|
point3d_count INT UNSIGNED NULL,
|
||||||
|
feature_count INT UNSIGNED NULL,
|
||||||
|
attribute_count BIGINT UNSIGNED NULL,
|
||||||
|
|
||||||
|
import_status ENUM('DISCOVERED','IMPORTING','DONE','FAILED') NOT NULL DEFAULT 'DISCOVERED',
|
||||||
|
error_message TEXT NULL,
|
||||||
|
imported_at DATETIME NULL,
|
||||||
|
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uq_cell_dataset_path (dataset_id, source_relative_path(255)),
|
||||||
|
KEY idx_cell_scale (dataset_id, scale_code),
|
||||||
|
KEY idx_cell_status (dataset_id, import_status),
|
||||||
|
CONSTRAINT fk_cell_dataset FOREIGN KEY (dataset_id)
|
||||||
|
REFERENCES chart_dataset(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS chart_coverage (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
dataset_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
cell_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
source_record_index INT UNSIGNED NOT NULL,
|
||||||
|
object_class_id SMALLINT UNSIGNED NOT NULL,
|
||||||
|
compilation_scale INT UNSIGNED NULL,
|
||||||
|
horizontal_datum INT NULL,
|
||||||
|
vertical_datum INT NULL,
|
||||||
|
wgsox DOUBLE NULL,
|
||||||
|
wgsoy DOUBLE NULL,
|
||||||
|
attributes_json JSON NULL,
|
||||||
|
geom GEOMETRY NOT NULL,
|
||||||
|
geometry_repaired TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
geometry_issue VARCHAR(255) NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uq_cov_cell_record (cell_id, source_record_index),
|
||||||
|
KEY idx_cov_cell (cell_id),
|
||||||
|
SPATIAL KEY spx_cov_geom (geom),
|
||||||
|
CONSTRAINT fk_cov_dataset FOREIGN KEY (dataset_id)
|
||||||
|
REFERENCES chart_dataset(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_cov_cell FOREIGN KEY (cell_id)
|
||||||
|
REFERENCES chart_cell(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS chart_feature (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
dataset_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
cell_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
coverage_id BIGINT UNSIGNED NULL,
|
||||||
|
source_record_index INT UNSIGNED NOT NULL,
|
||||||
|
|
||||||
|
object_class_id SMALLINT UNSIGNED NULL,
|
||||||
|
object_class VARCHAR(16) NOT NULL,
|
||||||
|
geometry_type ENUM('POINT','LINE','AREA','SOUNDING','META','UNKNOWN') NOT NULL,
|
||||||
|
|
||||||
|
scale_code CHAR(1) NOT NULL,
|
||||||
|
native_scale INT UNSIGNED NOT NULL,
|
||||||
|
|
||||||
|
raw_geotype SMALLINT UNSIGNED NULL,
|
||||||
|
normalized_srid INT UNSIGNED NOT NULL DEFAULT 4326,
|
||||||
|
|
||||||
|
bbox_min_lon DOUBLE NULL,
|
||||||
|
bbox_min_lat DOUBLE NULL,
|
||||||
|
bbox_max_lon DOUBLE NULL,
|
||||||
|
bbox_max_lat DOUBLE NULL,
|
||||||
|
|
||||||
|
attributes_json JSON NULL,
|
||||||
|
raw_feature_blob MEDIUMBLOB NULL,
|
||||||
|
raw_attribute_blob MEDIUMBLOB NULL,
|
||||||
|
|
||||||
|
geometry_repaired TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
geometry_issue VARCHAR(255) NULL,
|
||||||
|
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uq_feature_cell_record (cell_id, source_record_index),
|
||||||
|
KEY idx_feature_class (dataset_id, object_class),
|
||||||
|
KEY idx_feature_scale (dataset_id, native_scale),
|
||||||
|
KEY idx_feature_cell (cell_id),
|
||||||
|
KEY idx_feature_coverage (coverage_id),
|
||||||
|
CONSTRAINT fk_feature_dataset FOREIGN KEY (dataset_id)
|
||||||
|
REFERENCES chart_dataset(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_feature_cell FOREIGN KEY (cell_id)
|
||||||
|
REFERENCES chart_cell(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_feature_coverage FOREIGN KEY (coverage_id)
|
||||||
|
REFERENCES chart_coverage(id) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS chart_feature_attribute (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
feature_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
ordinal_no SMALLINT UNSIGNED NOT NULL,
|
||||||
|
attribute_id SMALLINT UNSIGNED NOT NULL,
|
||||||
|
attribute_name VARCHAR(16) NOT NULL,
|
||||||
|
value_type ENUM('BYTE','INT','LONG','REAL','WORD10','STRING','LIST','COMPLEX','UNKNOWN') NOT NULL,
|
||||||
|
value_int BIGINT NULL,
|
||||||
|
value_real DOUBLE NULL,
|
||||||
|
value_text TEXT NULL,
|
||||||
|
value_json JSON NULL,
|
||||||
|
raw_value_blob BLOB NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uq_feature_attr_ordinal (feature_id, ordinal_no),
|
||||||
|
KEY idx_feature_attr_name (attribute_name),
|
||||||
|
KEY idx_feature_attr_id (attribute_id),
|
||||||
|
CONSTRAINT fk_feature_attr_feature FOREIGN KEY (feature_id)
|
||||||
|
REFERENCES chart_feature(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS chart_point_geometry (
|
||||||
|
feature_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
geom POINT NOT NULL,
|
||||||
|
PRIMARY KEY (feature_id),
|
||||||
|
SPATIAL KEY spx_point_geom (geom),
|
||||||
|
CONSTRAINT fk_point_feature FOREIGN KEY (feature_id)
|
||||||
|
REFERENCES chart_feature(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS chart_line_geometry (
|
||||||
|
feature_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
geom GEOMETRY NOT NULL,
|
||||||
|
PRIMARY KEY (feature_id),
|
||||||
|
SPATIAL KEY spx_line_geom (geom),
|
||||||
|
CONSTRAINT fk_line_feature FOREIGN KEY (feature_id)
|
||||||
|
REFERENCES chart_feature(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS chart_area_geometry (
|
||||||
|
feature_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
geom GEOMETRY NOT NULL,
|
||||||
|
PRIMARY KEY (feature_id),
|
||||||
|
SPATIAL KEY spx_area_geom (geom),
|
||||||
|
CONSTRAINT fk_area_feature FOREIGN KEY (feature_id)
|
||||||
|
REFERENCES chart_feature(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS chart_sounding_point (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
feature_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
point_index INT UNSIGNED NOT NULL,
|
||||||
|
raw_x INT UNSIGNED NOT NULL,
|
||||||
|
raw_y INT UNSIGNED NOT NULL,
|
||||||
|
raw_z INT UNSIGNED NOT NULL,
|
||||||
|
depth DOUBLE NOT NULL,
|
||||||
|
geom POINT NOT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uq_sounding_feature_point (feature_id, point_index),
|
||||||
|
KEY idx_sounding_depth (depth),
|
||||||
|
SPATIAL KEY spx_sounding_geom (geom),
|
||||||
|
CONSTRAINT fk_sounding_feature FOREIGN KEY (feature_id)
|
||||||
|
REFERENCES chart_feature(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS chart_raw_record (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
cell_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
record_kind ENUM('HEADER','VECTOR','POINT2D','POINT3D','FEATURE','ATTRIBUTE','OTHER') NOT NULL,
|
||||||
|
source_record_index INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
raw_blob MEDIUMBLOB NOT NULL,
|
||||||
|
raw_metadata_json JSON NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_raw_cell_kind (cell_id, record_kind, source_record_index),
|
||||||
|
CONSTRAINT fk_raw_cell FOREIGN KEY (cell_id)
|
||||||
|
REFERENCES chart_cell(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS chart_import_job (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
dataset_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
status ENUM('RUNNING','DONE','FAILED','CANCELLED') NOT NULL DEFAULT 'RUNNING',
|
||||||
|
started_at DATETIME NOT NULL,
|
||||||
|
finished_at DATETIME NULL,
|
||||||
|
cells_discovered INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
cells_done INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
cells_failed INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
features_source BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
features_imported BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
attributes_source BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
attributes_imported BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
unknown_object_count BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
unknown_attribute_count BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
geometry_invalid_count BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
geometry_repaired_count BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
summary_json JSON NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_job_dataset (dataset_id, started_at),
|
||||||
|
CONSTRAINT fk_job_dataset FOREIGN KEY (dataset_id)
|
||||||
|
REFERENCES chart_dataset(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS chart_import_event (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
job_id BIGINT UNSIGNED NULL,
|
||||||
|
cell_id BIGINT UNSIGNED NULL,
|
||||||
|
level ENUM('DEBUG','INFO','WARNING','ERROR') NOT NULL,
|
||||||
|
event_code VARCHAR(64) NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
details_json JSON NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_event_job (job_id, id),
|
||||||
|
KEY idx_event_cell (cell_id, id),
|
||||||
|
KEY idx_event_code (event_code),
|
||||||
|
CONSTRAINT fk_event_job FOREIGN KEY (job_id)
|
||||||
|
REFERENCES chart_import_job(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_event_cell FOREIGN KEY (cell_id)
|
||||||
|
REFERENCES chart_cell(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
1
src/cm93db/__init__.py
Normal file
1
src/cm93db/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
__version__ = "0.2.0"
|
||||||
294
src/cm93db/audit.py
Normal file
294
src/cm93db/audit.py
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from collections import defaultdict
|
||||||
|
import math
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from shapely import from_wkb
|
||||||
|
|
||||||
|
from .cm93_binary import parse_cell
|
||||||
|
from .config import DbConfig
|
||||||
|
from .database import connect, assert_mysql57, server_version
|
||||||
|
from .dictionaries import load_object_dictionary, load_attribute_dictionary
|
||||||
|
from .geometry import build_coverages, build_feature
|
||||||
|
from .scanner import scan_cm93, CellFile
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SourceCellStat:
|
||||||
|
rel: str
|
||||||
|
sha256: str
|
||||||
|
file_size: int
|
||||||
|
scale: str
|
||||||
|
features: int
|
||||||
|
attributes: int
|
||||||
|
points: int
|
||||||
|
lines: int
|
||||||
|
areas: int
|
||||||
|
coverages: int
|
||||||
|
sounding_features: int
|
||||||
|
sounding_points: int
|
||||||
|
unknown_objects: int
|
||||||
|
unknown_attributes: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PrecisionSample:
|
||||||
|
rel: str
|
||||||
|
record_index: int
|
||||||
|
geometry_type: str
|
||||||
|
expected_wkb: bytes
|
||||||
|
point_index: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AuditResult:
|
||||||
|
source_cells: int = 0
|
||||||
|
source_parse_failed: int = 0
|
||||||
|
db_missing_cells: int = 0
|
||||||
|
db_not_done_cells: int = 0
|
||||||
|
count_mismatches: int = 0
|
||||||
|
hash_mismatches: int = 0
|
||||||
|
unknown_objects: int = 0
|
||||||
|
unknown_attributes: int = 0
|
||||||
|
precision_checked: int = 0
|
||||||
|
precision_failed: int = 0
|
||||||
|
max_precision_delta_deg: float = 0.0
|
||||||
|
raw_preservation_mismatches: int = 0
|
||||||
|
geometry_validity_issues: int = 0
|
||||||
|
messages: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def passed(self) -> bool:
|
||||||
|
return not any((self.source_parse_failed,self.db_missing_cells,self.db_not_done_cells,self.count_mismatches,self.hash_mismatches,self.raw_preservation_mismatches,self.unknown_objects,self.unknown_attributes,self.precision_failed))
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"passed": self.passed,
|
||||||
|
"source_cells": self.source_cells,
|
||||||
|
"source_parse_failed": self.source_parse_failed,
|
||||||
|
"db_missing_cells": self.db_missing_cells,
|
||||||
|
"db_not_done_cells": self.db_not_done_cells,
|
||||||
|
"count_mismatches": self.count_mismatches,
|
||||||
|
"hash_mismatches": self.hash_mismatches,
|
||||||
|
"raw_preservation_mismatches": self.raw_preservation_mismatches,
|
||||||
|
"geometry_validity_issues": self.geometry_validity_issues,
|
||||||
|
"unknown_objects": self.unknown_objects,
|
||||||
|
"unknown_attributes": self.unknown_attributes,
|
||||||
|
"precision_checked": self.precision_checked,
|
||||||
|
"precision_failed": self.precision_failed,
|
||||||
|
"max_precision_delta_deg": self.max_precision_delta_deg,
|
||||||
|
"messages": self.messages,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _rel(cf: CellFile) -> str:
|
||||||
|
return str(cf.relative_path).replace("/", "\\")
|
||||||
|
|
||||||
|
|
||||||
|
def _select_cells(scan, limit: int | None, scales: set[str] | None, cell_name: str | None):
|
||||||
|
cells=list(scan.cells)
|
||||||
|
if scales:
|
||||||
|
cells=[c for c in cells if c.scale_code in scales]
|
||||||
|
if cell_name:
|
||||||
|
t=cell_name.lower()
|
||||||
|
cells=[c for c in cells if c.path.name.lower()==t or c.path.stem.lower()==t]
|
||||||
|
if limit is not None:
|
||||||
|
cells=cells[:limit]
|
||||||
|
return cells
|
||||||
|
|
||||||
|
|
||||||
|
def _source_stat(cell, cf: CellFile) -> SourceCellStat:
|
||||||
|
p=l=a=cov=sf=sp=0
|
||||||
|
for f in cell.features:
|
||||||
|
if f.object_class == "_m_sor":
|
||||||
|
cov += 1
|
||||||
|
continue
|
||||||
|
if f.geometry_primitive == 1: p += 1
|
||||||
|
elif f.geometry_primitive == 2: l += 1
|
||||||
|
elif f.geometry_primitive == 4: a += 1
|
||||||
|
elif f.geometry_primitive == 8:
|
||||||
|
sf += 1
|
||||||
|
if f.point3d_index is not None:
|
||||||
|
sp += len(cell.point3d[f.point3d_index].points)
|
||||||
|
return SourceCellStat(
|
||||||
|
rel=_rel(cf),sha256=cell.sha256,file_size=cell.file_size,scale=cf.scale_code,features=len(cell.features),attributes=cell.attribute_count,
|
||||||
|
points=p,lines=l,areas=a,coverages=cov,sounding_features=sf,sounding_points=sp,
|
||||||
|
unknown_objects=len(cell.unknown_object_ids),unknown_attributes=len(cell.unknown_attribute_ids),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def collect_source(root: Path, limit: int | None = None, scales: set[str] | None = None, cell_name: str | None = None,
|
||||||
|
precision_samples: int = 0, validate_geometry: bool = False):
|
||||||
|
scan=scan_cm93(root)
|
||||||
|
if not scan.object_dictionary or not scan.attribute_dictionary:
|
||||||
|
raise RuntimeError("CM93 字典未找到")
|
||||||
|
objs={x.object_id:x for x in load_object_dictionary(scan.object_dictionary)}
|
||||||
|
attrs={x.attribute_id:x for x in load_attribute_dictionary(scan.attribute_dictionary)}
|
||||||
|
cells=_select_cells(scan,limit,scales,cell_name)
|
||||||
|
stats: dict[str,SourceCellStat]={}
|
||||||
|
failures: list[str]=[]
|
||||||
|
samples: list[PrecisionSample]=[]
|
||||||
|
geom_issues=0
|
||||||
|
quota=max(1, math.ceil(precision_samples / max(1,len({c.scale_code for c in cells})))) if precision_samples else 0
|
||||||
|
per_scale=defaultdict(int)
|
||||||
|
|
||||||
|
for n,cf in enumerate(cells,1):
|
||||||
|
try:
|
||||||
|
cell=parse_cell(cf.path,objs,attrs)
|
||||||
|
st=_source_stat(cell,cf); stats[st.rel]=st
|
||||||
|
want = precision_samples and per_scale[cf.scale_code] < quota and len(samples) < precision_samples
|
||||||
|
if want or validate_geometry:
|
||||||
|
covs=build_coverages(cell)
|
||||||
|
for f in cell.features:
|
||||||
|
b=build_feature(cell,f,covs,validate=validate_geometry)
|
||||||
|
if validate_geometry and b.geometry_issue:
|
||||||
|
geom_issues += 1
|
||||||
|
if want and per_scale[cf.scale_code] < quota and len(samples) < precision_samples:
|
||||||
|
if b.geom is not None and b.geometry_type in {"POINT","LINE","AREA"}:
|
||||||
|
samples.append(PrecisionSample(st.rel,f.index,b.geometry_type,b.geom.wkb))
|
||||||
|
per_scale[cf.scale_code]+=1
|
||||||
|
elif b.geometry_type=="SOUNDING" and b.soundings:
|
||||||
|
samples.append(PrecisionSample(st.rel,f.index,"SOUNDING",b.soundings[0][4].wkb,0))
|
||||||
|
per_scale[cf.scale_code]+=1
|
||||||
|
if n % 250 == 0 or n == len(cells):
|
||||||
|
print(f"source audit [{n}/{len(cells)}]")
|
||||||
|
except Exception as e:
|
||||||
|
failures.append(f"{_rel(cf)}: {e}")
|
||||||
|
return stats,failures,samples,geom_issues
|
||||||
|
|
||||||
|
|
||||||
|
def _group_counts(conn, sql: str) -> dict[int,int]:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(sql)
|
||||||
|
return {int(r["cell_id"]):int(r["n"]) for r in cur.fetchall()}
|
||||||
|
|
||||||
|
|
||||||
|
def _dataset_id(conn, root: Path) -> int | None:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("SELECT id FROM chart_dataset WHERE source_type='CM93' AND source_root=%s ORDER BY id LIMIT 1", (str(root.resolve()),))
|
||||||
|
r=cur.fetchone(); return int(r["id"]) if r else None
|
||||||
|
|
||||||
|
|
||||||
|
def _precision_db_geom(conn, cell_id: int, sample: PrecisionSample):
|
||||||
|
if sample.geometry_type == "SOUNDING":
|
||||||
|
sql=("SELECT ST_AsWKB(s.geom) AS wkb FROM chart_feature f JOIN chart_sounding_point s ON s.feature_id=f.id "
|
||||||
|
"WHERE f.cell_id=%s AND f.source_record_index=%s AND s.point_index=%s")
|
||||||
|
params=(cell_id,sample.record_index,sample.point_index or 0)
|
||||||
|
else:
|
||||||
|
table={"POINT":"chart_point_geometry","LINE":"chart_line_geometry","AREA":"chart_area_geometry"}[sample.geometry_type]
|
||||||
|
sql=(f"SELECT ST_AsWKB(g.geom) AS wkb FROM chart_feature f JOIN {table} g ON g.feature_id=f.id "
|
||||||
|
"WHERE f.cell_id=%s AND f.source_record_index=%s")
|
||||||
|
params=(cell_id,sample.record_index)
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(sql,params); r=cur.fetchone()
|
||||||
|
return bytes(r["wkb"]) if r and r["wkb"] is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def run_audit(cfg: DbConfig, root: Path, limit: int | None = None, scales: set[str] | None = None, cell_name: str | None = None,
|
||||||
|
precision_samples: int = 128, tolerance_deg: float = 1e-12, validate_geometry: bool = False, source_only: bool = False, require_raw: bool = True) -> AuditResult:
|
||||||
|
result=AuditResult()
|
||||||
|
stats,failures,samples,geom_issues=collect_source(root,limit,scales,cell_name,precision_samples if not source_only else 0,validate_geometry)
|
||||||
|
result.source_cells=len(stats)+len(failures)
|
||||||
|
result.source_parse_failed=len(failures)
|
||||||
|
result.messages.extend(f"SOURCE_PARSE_FAILED {m}" for m in failures[:100])
|
||||||
|
result.unknown_objects=sum(s.unknown_objects for s in stats.values())
|
||||||
|
result.unknown_attributes=sum(s.unknown_attributes for s in stats.values())
|
||||||
|
print(f"Source cells parsed: {len(stats)}, failed: {len(failures)}")
|
||||||
|
print(f"Source features: {sum(s.features for s in stats.values())}")
|
||||||
|
print(f"Source attributes: {sum(s.attributes for s in stats.values())}")
|
||||||
|
result.geometry_validity_issues=geom_issues
|
||||||
|
if validate_geometry:
|
||||||
|
print(f"Source geometry issues (Shapely validity/structure): {geom_issues}")
|
||||||
|
if source_only:
|
||||||
|
return result
|
||||||
|
|
||||||
|
conn=connect(cfg)
|
||||||
|
try:
|
||||||
|
assert_mysql57(server_version(conn))
|
||||||
|
did=_dataset_id(conn,root)
|
||||||
|
if did is None:
|
||||||
|
result.db_missing_cells=len(stats)
|
||||||
|
result.messages.append("数据库中找不到该 source_root 对应 dataset")
|
||||||
|
return result
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("SELECT id,source_relative_path,import_status,source_sha256,feature_count,attribute_count FROM chart_cell WHERE dataset_id=%s", (did,))
|
||||||
|
rows=cur.fetchall()
|
||||||
|
db_by_rel={str(r["source_relative_path"]):r for r in rows}
|
||||||
|
cellids=[int(r["id"]) for r in rows]
|
||||||
|
fcnt=_group_counts(conn,f"SELECT cell_id,COUNT(*) n FROM chart_feature WHERE cell_id IN (SELECT id FROM chart_cell WHERE dataset_id={did}) GROUP BY cell_id")
|
||||||
|
acnt=_group_counts(conn,f"SELECT f.cell_id,COUNT(*) n FROM chart_feature_attribute a JOIN chart_feature f ON f.id=a.feature_id JOIN chart_cell c ON c.id=f.cell_id WHERE c.dataset_id={did} GROUP BY f.cell_id")
|
||||||
|
pcnt=_group_counts(conn,f"SELECT f.cell_id,COUNT(*) n FROM chart_point_geometry g JOIN chart_feature f ON f.id=g.feature_id JOIN chart_cell c ON c.id=f.cell_id WHERE c.dataset_id={did} GROUP BY f.cell_id")
|
||||||
|
lcnt=_group_counts(conn,f"SELECT f.cell_id,COUNT(*) n FROM chart_line_geometry g JOIN chart_feature f ON f.id=g.feature_id JOIN chart_cell c ON c.id=f.cell_id WHERE c.dataset_id={did} GROUP BY f.cell_id")
|
||||||
|
arcnt=_group_counts(conn,f"SELECT f.cell_id,COUNT(*) n FROM chart_area_geometry g JOIN chart_feature f ON f.id=g.feature_id JOIN chart_cell c ON c.id=f.cell_id WHERE c.dataset_id={did} GROUP BY f.cell_id")
|
||||||
|
ccnt=_group_counts(conn,f"SELECT cell_id,COUNT(*) n FROM chart_coverage WHERE cell_id IN (SELECT id FROM chart_cell WHERE dataset_id={did}) GROUP BY cell_id")
|
||||||
|
scnt=_group_counts(conn,f"SELECT f.cell_id,COUNT(*) n FROM chart_sounding_point s JOIN chart_feature f ON f.id=s.feature_id JOIN chart_cell c ON c.id=f.cell_id WHERE c.dataset_id={did} GROUP BY f.cell_id")
|
||||||
|
rawbytes=_group_counts(conn,f"SELECT cell_id,SUM(OCTET_LENGTH(raw_blob)) n FROM chart_raw_record WHERE cell_id IN (SELECT id FROM chart_cell WHERE dataset_id={did}) GROUP BY cell_id")
|
||||||
|
|
||||||
|
for rel,s in stats.items():
|
||||||
|
r=db_by_rel.get(rel)
|
||||||
|
if not r:
|
||||||
|
result.db_missing_cells+=1; result.messages.append(f"DB_MISSING {rel}"); continue
|
||||||
|
cid=int(r["id"])
|
||||||
|
if r["import_status"]!="DONE":
|
||||||
|
result.db_not_done_cells+=1; result.messages.append(f"DB_NOT_DONE {rel} status={r['import_status']}")
|
||||||
|
if r.get("source_sha256") and str(r["source_sha256"]) != s.sha256:
|
||||||
|
result.hash_mismatches+=1; result.messages.append(f"HASH_MISMATCH {rel}")
|
||||||
|
actual=(fcnt.get(cid,0),acnt.get(cid,0),pcnt.get(cid,0),lcnt.get(cid,0),arcnt.get(cid,0),ccnt.get(cid,0),scnt.get(cid,0))
|
||||||
|
expected=(s.features,s.attributes,s.points,s.lines,s.areas,s.coverages,s.sounding_points)
|
||||||
|
if actual != expected:
|
||||||
|
result.count_mismatches+=1
|
||||||
|
result.messages.append(f"COUNT_MISMATCH {rel} expected={expected} actual={actual}")
|
||||||
|
if require_raw and rawbytes.get(cid,0) != s.file_size:
|
||||||
|
result.raw_preservation_mismatches += 1
|
||||||
|
result.messages.append(f"RAW_BYTES_MISMATCH {rel} expected={s.file_size} actual={rawbytes.get(cid,0)}")
|
||||||
|
|
||||||
|
# Precision audit: source-derived geometry versus geometry round-tripped through MySQL.
|
||||||
|
rel_to_id={k:int(v["id"]) for k,v in db_by_rel.items()}
|
||||||
|
for sample in samples:
|
||||||
|
cid=rel_to_id.get(sample.rel)
|
||||||
|
if not cid:
|
||||||
|
continue
|
||||||
|
dbwkb=_precision_db_geom(conn,cid,sample)
|
||||||
|
result.precision_checked+=1
|
||||||
|
if dbwkb is None:
|
||||||
|
result.precision_failed+=1
|
||||||
|
result.messages.append(f"PRECISION_MISSING {sample.rel} record={sample.record_index}")
|
||||||
|
continue
|
||||||
|
expected=from_wkb(sample.expected_wkb); actual=from_wkb(dbwkb)
|
||||||
|
try:
|
||||||
|
ok=expected.equals_exact(actual,tolerance_deg)
|
||||||
|
delta=expected.hausdorff_distance(actual) if not ok else 0.0
|
||||||
|
except Exception:
|
||||||
|
ok=False; delta=float("inf")
|
||||||
|
if math.isfinite(delta):
|
||||||
|
result.max_precision_delta_deg=max(result.max_precision_delta_deg,float(delta))
|
||||||
|
if not ok:
|
||||||
|
result.precision_failed+=1
|
||||||
|
result.messages.append(f"PRECISION_FAIL {sample.rel} record={sample.record_index} type={sample.geometry_type} delta_deg={delta}")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def print_result(r: AuditResult) -> None:
|
||||||
|
print("\n=== CM93 AUDIT ===")
|
||||||
|
print(f"source_cells : {r.source_cells}")
|
||||||
|
print(f"source_parse_failed : {r.source_parse_failed}")
|
||||||
|
print(f"db_missing_cells : {r.db_missing_cells}")
|
||||||
|
print(f"db_not_done_cells : {r.db_not_done_cells}")
|
||||||
|
print(f"count_mismatches : {r.count_mismatches}")
|
||||||
|
print(f"hash_mismatches : {r.hash_mismatches}")
|
||||||
|
print(f"raw_preservation_bad : {r.raw_preservation_mismatches}")
|
||||||
|
print(f"geometry_validity : {r.geometry_validity_issues} (informational)")
|
||||||
|
print(f"unknown_objects : {r.unknown_objects}")
|
||||||
|
print(f"unknown_attributes : {r.unknown_attributes}")
|
||||||
|
print(f"precision_checked : {r.precision_checked}")
|
||||||
|
print(f"precision_failed : {r.precision_failed}")
|
||||||
|
print(f"max_precision_delta° : {r.max_precision_delta_deg:.16g}")
|
||||||
|
print(f"RESULT : {'PASS' if r.passed else 'FAIL'}")
|
||||||
|
if r.messages:
|
||||||
|
print("\nDetails (max 100 shown):")
|
||||||
|
for m in r.messages[:100]: print(" -",m)
|
||||||
468
src/cm93db/cell_parser.py
Normal file
468
src/cm93db/cell_parser.py
Normal file
@@ -0,0 +1,468 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
import hashlib
|
||||||
|
import struct
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .decoder import decode_cm93
|
||||||
|
from .dictionaries import AttributeDef, ObjectDef
|
||||||
|
from .precision import CM93_COORD_MAX, CM93_SEMIMAJOR_M
|
||||||
|
|
||||||
|
|
||||||
|
class CM93ParseError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CM93Header:
|
||||||
|
lon_min: float
|
||||||
|
lat_min: float
|
||||||
|
lon_max: float
|
||||||
|
lat_max: float
|
||||||
|
easting_min: float
|
||||||
|
northing_min: float
|
||||||
|
easting_max: float
|
||||||
|
northing_max: float
|
||||||
|
vector_records: int
|
||||||
|
vector_record_points: int
|
||||||
|
m_46: int
|
||||||
|
m_4a: int
|
||||||
|
point3d_records: int
|
||||||
|
point3d_points: int
|
||||||
|
m_54: int
|
||||||
|
point2d_records: int
|
||||||
|
m_5a: int
|
||||||
|
m_5c: int
|
||||||
|
feature_records: int
|
||||||
|
m_60: int
|
||||||
|
m_64: int
|
||||||
|
m_68: int
|
||||||
|
m_6a: int
|
||||||
|
m_6c: int
|
||||||
|
related_object_pointers: int
|
||||||
|
m_72: int
|
||||||
|
m_76: int
|
||||||
|
attribute_bytes: int
|
||||||
|
m_7c: int
|
||||||
|
|
||||||
|
@property
|
||||||
|
def transform_x_rate(self) -> float:
|
||||||
|
delta_x = self.easting_max - self.easting_min
|
||||||
|
if delta_x < 0.0:
|
||||||
|
delta_x += CM93_SEMIMAJOR_M * 2.0 * 3.141592653589793
|
||||||
|
return delta_x / CM93_COORD_MAX
|
||||||
|
|
||||||
|
@property
|
||||||
|
def transform_y_rate(self) -> float:
|
||||||
|
return (self.northing_max - self.northing_min) / CM93_COORD_MAX
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class EdgeRecord:
|
||||||
|
index: int
|
||||||
|
points: tuple[tuple[int, int], ...]
|
||||||
|
raw: bytes
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SoundingGroup:
|
||||||
|
index: int
|
||||||
|
points: tuple[tuple[int, int, int], ...]
|
||||||
|
raw: bytes
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ParsedAttribute:
|
||||||
|
ordinal: int
|
||||||
|
attribute_id: int
|
||||||
|
name: str
|
||||||
|
source_type: str
|
||||||
|
decoder_type: str
|
||||||
|
value_type: str
|
||||||
|
value: Any
|
||||||
|
value_int: int | None
|
||||||
|
value_real: float | None
|
||||||
|
value_text: str | None
|
||||||
|
value_json: Any | None
|
||||||
|
raw_value: bytes
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class GeometryRef:
|
||||||
|
edge_index: int
|
||||||
|
segment_usage: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ParsedFeature:
|
||||||
|
record_index: int
|
||||||
|
object_type: int
|
||||||
|
object_class: str
|
||||||
|
raw_geotype: int
|
||||||
|
primitive: int
|
||||||
|
geometry_refs: tuple[GeometryRef, ...] = ()
|
||||||
|
point2d_index: int | None = None
|
||||||
|
point3d_index: int | None = None
|
||||||
|
related_indices: tuple[int, ...] = ()
|
||||||
|
related_count_hint: int | None = None
|
||||||
|
n_attributes: int = 0
|
||||||
|
attributes: tuple[ParsedAttribute, ...] = ()
|
||||||
|
raw_record: bytes = b""
|
||||||
|
raw_attribute_block: bytes = b""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ParsedCell:
|
||||||
|
path: Path
|
||||||
|
source_size: int
|
||||||
|
sha256: str
|
||||||
|
encoded_bytes: bytes
|
||||||
|
decoded_bytes: bytes
|
||||||
|
prolog_word0: int
|
||||||
|
table1_length: int
|
||||||
|
table2_length: int
|
||||||
|
header: CM93Header
|
||||||
|
edges: tuple[EdgeRecord, ...]
|
||||||
|
point2d: tuple[tuple[int, int], ...]
|
||||||
|
point3d: tuple[SoundingGroup, ...]
|
||||||
|
features: tuple[ParsedFeature, ...]
|
||||||
|
end_offset: int
|
||||||
|
table_offsets: dict[str, int]
|
||||||
|
attribute_count: int
|
||||||
|
|
||||||
|
|
||||||
|
class _Reader:
|
||||||
|
def __init__(self, data: bytes, offset: int = 0):
|
||||||
|
self.data = data
|
||||||
|
self.offset = offset
|
||||||
|
|
||||||
|
def _need(self, n: int) -> None:
|
||||||
|
if self.offset + n > len(self.data):
|
||||||
|
raise CM93ParseError(
|
||||||
|
f"unexpected EOF at {self.offset}, need {n}, size={len(self.data)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def u8(self) -> int:
|
||||||
|
self._need(1)
|
||||||
|
v = self.data[self.offset]
|
||||||
|
self.offset += 1
|
||||||
|
return v
|
||||||
|
|
||||||
|
def u16(self) -> int:
|
||||||
|
self._need(2)
|
||||||
|
v = struct.unpack_from("<H", self.data, self.offset)[0]
|
||||||
|
self.offset += 2
|
||||||
|
return v
|
||||||
|
|
||||||
|
def i32(self) -> int:
|
||||||
|
self._need(4)
|
||||||
|
v = struct.unpack_from("<i", self.data, self.offset)[0]
|
||||||
|
self.offset += 4
|
||||||
|
return v
|
||||||
|
|
||||||
|
def f32(self) -> float:
|
||||||
|
self._need(4)
|
||||||
|
v = struct.unpack_from("<f", self.data, self.offset)[0]
|
||||||
|
self.offset += 4
|
||||||
|
return float(v)
|
||||||
|
|
||||||
|
def f64(self) -> float:
|
||||||
|
self._need(8)
|
||||||
|
v = struct.unpack_from("<d", self.data, self.offset)[0]
|
||||||
|
self.offset += 8
|
||||||
|
return float(v)
|
||||||
|
|
||||||
|
def take(self, n: int) -> bytes:
|
||||||
|
self._need(n)
|
||||||
|
b = self.data[self.offset:self.offset+n]
|
||||||
|
self.offset += n
|
||||||
|
return b
|
||||||
|
|
||||||
|
def cstring(self) -> bytes:
|
||||||
|
end = self.data.find(b"\x00", self.offset)
|
||||||
|
if end < 0:
|
||||||
|
raise CM93ParseError(f"unterminated string at {self.offset}")
|
||||||
|
b = self.data[self.offset:end]
|
||||||
|
self.offset = end + 1
|
||||||
|
return b
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_header(r: _Reader) -> CM93Header:
|
||||||
|
start = r.offset
|
||||||
|
vals: list[Any] = [r.f64() for _ in range(8)]
|
||||||
|
vector_records = r.u16()
|
||||||
|
vector_record_points = r.i32()
|
||||||
|
m_46 = r.i32()
|
||||||
|
m_4a = r.i32()
|
||||||
|
point3d_records = r.u16()
|
||||||
|
point3d_points = r.i32()
|
||||||
|
m_54 = r.i32()
|
||||||
|
point2d_records = r.u16()
|
||||||
|
m_5a = r.u16()
|
||||||
|
m_5c = r.u16()
|
||||||
|
feature_records = r.u16()
|
||||||
|
m_60 = r.i32()
|
||||||
|
m_64 = r.i32()
|
||||||
|
m_68 = r.u16()
|
||||||
|
m_6a = r.u16()
|
||||||
|
m_6c = r.u16()
|
||||||
|
related = r.i32()
|
||||||
|
m_72 = r.i32()
|
||||||
|
m_76 = r.u16()
|
||||||
|
attr_bytes = r.i32()
|
||||||
|
m_7c = r.i32()
|
||||||
|
if r.offset - start != 128:
|
||||||
|
raise CM93ParseError(f"header size != 128: {r.offset-start}")
|
||||||
|
if min(vector_records, vector_record_points, point3d_records, point3d_points,
|
||||||
|
point2d_records, feature_records, related, attr_bytes) < 0:
|
||||||
|
raise CM93ParseError("negative header count")
|
||||||
|
return CM93Header(
|
||||||
|
*vals, vector_records, vector_record_points, m_46, m_4a,
|
||||||
|
point3d_records, point3d_points, m_54, point2d_records,
|
||||||
|
m_5a, m_5c, feature_records, m_60, m_64, m_68, m_6a, m_6c,
|
||||||
|
related, m_72, m_76, attr_bytes, m_7c,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _text(b: bytes) -> str:
|
||||||
|
# CM93 data in the field is commonly ANSI/Windows text. Preserve bytes separately.
|
||||||
|
return b.decode("cp1252", errors="replace")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_attributes(
|
||||||
|
block: bytes,
|
||||||
|
nattr: int,
|
||||||
|
attr_defs: dict[int, AttributeDef],
|
||||||
|
) -> tuple[ParsedAttribute, ...]:
|
||||||
|
r = _Reader(block)
|
||||||
|
out: list[ParsedAttribute] = []
|
||||||
|
for ordinal in range(nattr):
|
||||||
|
item_start = r.offset
|
||||||
|
aid = r.u8()
|
||||||
|
d = attr_defs.get(aid)
|
||||||
|
if d is None:
|
||||||
|
raise CM93ParseError(f"unknown attribute id={aid} at attr {ordinal}")
|
||||||
|
t = d.decoder_type
|
||||||
|
value: Any
|
||||||
|
vi: int | None = None
|
||||||
|
vr: float | None = None
|
||||||
|
vt: str | None = None
|
||||||
|
vj: Any | None = None
|
||||||
|
value_type = "UNKNOWN"
|
||||||
|
if t == "B":
|
||||||
|
vi = r.u8(); value = vi; value_type = "BYTE"
|
||||||
|
elif t == "S":
|
||||||
|
raw_s = r.cstring(); vt = _text(raw_s); value = vt; value_type = "STRING"
|
||||||
|
elif t == "R":
|
||||||
|
vr = r.f32(); value = vr; value_type = "REAL"
|
||||||
|
elif t == "W":
|
||||||
|
vi = r.u16(); vr = vi / 10.0; value = vr; value_type = "WORD10"
|
||||||
|
elif t == "G":
|
||||||
|
vi = r.i32(); value = vi; value_type = "LONG"
|
||||||
|
elif t == "C":
|
||||||
|
prefix = list(r.take(3)); raw_s = r.cstring(); vt = _text(raw_s)
|
||||||
|
vj = {"prefix": prefix, "text": vt}; value = vj; value_type = "COMPLEX"
|
||||||
|
elif t == "L":
|
||||||
|
n = r.u8(); arr = list(r.take(n)); vj = arr; value = arr; value_type = "LIST"
|
||||||
|
elif t == "I":
|
||||||
|
# Kept for compatibility with old CM93 decoders; not present in supplied dictionary.
|
||||||
|
vi = r.u16(); value = vi; value_type = "INT"
|
||||||
|
else:
|
||||||
|
raise CM93ParseError(
|
||||||
|
f"unsupported attribute type={d.source_type_name}/{t} id={aid}"
|
||||||
|
)
|
||||||
|
raw_value = block[item_start+1:r.offset]
|
||||||
|
out.append(ParsedAttribute(
|
||||||
|
ordinal=ordinal,
|
||||||
|
attribute_id=aid,
|
||||||
|
name=d.name,
|
||||||
|
source_type=d.source_type_name,
|
||||||
|
decoder_type=t,
|
||||||
|
value_type=value_type,
|
||||||
|
value=value,
|
||||||
|
value_int=vi,
|
||||||
|
value_real=vr,
|
||||||
|
value_text=vt,
|
||||||
|
value_json=vj,
|
||||||
|
raw_value=raw_value,
|
||||||
|
))
|
||||||
|
if r.offset != len(block):
|
||||||
|
raise CM93ParseError(
|
||||||
|
f"attribute block length mismatch: parsed={r.offset} actual={len(block)} nattr={nattr}"
|
||||||
|
)
|
||||||
|
return tuple(out)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_cell(
|
||||||
|
path: Path,
|
||||||
|
object_defs: dict[int, ObjectDef],
|
||||||
|
attr_defs: dict[int, AttributeDef],
|
||||||
|
*,
|
||||||
|
keep_source_bytes: bool = True,
|
||||||
|
) -> ParsedCell:
|
||||||
|
encoded = path.read_bytes()
|
||||||
|
decoded = decode_cm93(encoded)
|
||||||
|
r = _Reader(decoded)
|
||||||
|
word0 = r.u16()
|
||||||
|
table1 = r.i32()
|
||||||
|
table2 = r.i32()
|
||||||
|
if word0 + table1 + table2 != len(decoded):
|
||||||
|
raise CM93ParseError(
|
||||||
|
f"cell length check failed: {word0}+{table1}+{table2}!={len(decoded)}"
|
||||||
|
)
|
||||||
|
if word0 != 138:
|
||||||
|
raise CM93ParseError(f"unexpected prolog+header length: {word0}")
|
||||||
|
header = _parse_header(r)
|
||||||
|
table_offsets = {"prolog": 0, "header": 10, "vector": r.offset}
|
||||||
|
|
||||||
|
edges: list[EdgeRecord] = []
|
||||||
|
total_vector_points = 0
|
||||||
|
for i in range(header.vector_records):
|
||||||
|
st = r.offset
|
||||||
|
n = r.u16()
|
||||||
|
pts = tuple((r.u16(), r.u16()) for _ in range(n))
|
||||||
|
total_vector_points += n
|
||||||
|
edges.append(EdgeRecord(i, pts, decoded[st:r.offset]))
|
||||||
|
if total_vector_points != header.vector_record_points:
|
||||||
|
raise CM93ParseError(
|
||||||
|
f"vector point count mismatch: parsed={total_vector_points} header={header.vector_record_points}"
|
||||||
|
)
|
||||||
|
|
||||||
|
table_offsets["point3d"] = r.offset
|
||||||
|
groups: list[SoundingGroup] = []
|
||||||
|
total_3d = 0
|
||||||
|
for i in range(header.point3d_records):
|
||||||
|
st = r.offset
|
||||||
|
n = r.u16()
|
||||||
|
pts = tuple((r.u16(), r.u16(), r.u16()) for _ in range(n))
|
||||||
|
total_3d += n
|
||||||
|
groups.append(SoundingGroup(i, pts, decoded[st:r.offset]))
|
||||||
|
if total_3d != header.point3d_points:
|
||||||
|
raise CM93ParseError(
|
||||||
|
f"3D point count mismatch: parsed={total_3d} header={header.point3d_points}"
|
||||||
|
)
|
||||||
|
|
||||||
|
table_offsets["point2d"] = r.offset
|
||||||
|
point2d = tuple((r.u16(), r.u16()) for _ in range(header.point2d_records))
|
||||||
|
|
||||||
|
table_offsets["feature"] = r.offset
|
||||||
|
features: list[ParsedFeature] = []
|
||||||
|
attr_count = 0
|
||||||
|
total_attr_bytes = 0
|
||||||
|
for i in range(header.feature_records):
|
||||||
|
st = r.offset
|
||||||
|
object_type = r.u8()
|
||||||
|
geotype = r.u8()
|
||||||
|
record_len = r.u16()
|
||||||
|
if record_len < 4:
|
||||||
|
raise CM93ParseError(f"feature {i}: record length {record_len} < 4")
|
||||||
|
record_end = st + record_len
|
||||||
|
if record_end > len(decoded):
|
||||||
|
raise CM93ParseError(f"feature {i}: record beyond EOF")
|
||||||
|
primitive = geotype & 0x0F
|
||||||
|
refs: list[GeometryRef] = []
|
||||||
|
p2idx = None
|
||||||
|
p3idx = None
|
||||||
|
if primitive in (2, 4):
|
||||||
|
n = r.u16()
|
||||||
|
for _ in range(n):
|
||||||
|
raw_idx = r.u16()
|
||||||
|
edge_idx = raw_idx & 0x1FFF
|
||||||
|
usage = raw_idx >> 13
|
||||||
|
if edge_idx >= len(edges):
|
||||||
|
raise CM93ParseError(f"feature {i}: edge index {edge_idx} out of range")
|
||||||
|
refs.append(GeometryRef(edge_idx, usage))
|
||||||
|
elif primitive == 1:
|
||||||
|
p2idx = r.u16()
|
||||||
|
if p2idx >= len(point2d):
|
||||||
|
raise CM93ParseError(f"feature {i}: point2d index {p2idx} out of range")
|
||||||
|
elif primitive == 8:
|
||||||
|
p3idx = r.u16()
|
||||||
|
if p3idx >= len(groups):
|
||||||
|
raise CM93ParseError(f"feature {i}: point3d index {p3idx} out of range")
|
||||||
|
elif primitive == 0:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# Unknown low primitive cannot be skipped safely unless record boundary is honored.
|
||||||
|
# We preserve the record and mark geometry unknown; remaining bytes are consumed below.
|
||||||
|
pass
|
||||||
|
|
||||||
|
related: list[int] = []
|
||||||
|
related_hint: int | None = None
|
||||||
|
if geotype & 0x10:
|
||||||
|
nr = r.u8()
|
||||||
|
for _ in range(nr):
|
||||||
|
idx = r.u16()
|
||||||
|
if idx >= header.feature_records:
|
||||||
|
raise CM93ParseError(f"feature {i}: related index {idx} out of range")
|
||||||
|
related.append(idx)
|
||||||
|
if geotype & 0x20:
|
||||||
|
related_hint = r.u16() & 0xFF
|
||||||
|
|
||||||
|
nattr = 0
|
||||||
|
attrs: tuple[ParsedAttribute, ...] = ()
|
||||||
|
attr_block = b""
|
||||||
|
if geotype & 0x80:
|
||||||
|
nattr = r.u8()
|
||||||
|
attr_len = record_end - r.offset
|
||||||
|
if attr_len < 0:
|
||||||
|
raise CM93ParseError(f"feature {i}: negative attribute length")
|
||||||
|
attr_block = r.take(attr_len)
|
||||||
|
attrs = _parse_attributes(attr_block, nattr, attr_defs)
|
||||||
|
attr_count += nattr
|
||||||
|
total_attr_bytes += len(attr_block)
|
||||||
|
elif r.offset < record_end:
|
||||||
|
# Known flags consumed fewer bytes than descriptor length. Preserve but do not guess.
|
||||||
|
r.offset = record_end
|
||||||
|
|
||||||
|
if r.offset != record_end:
|
||||||
|
raise CM93ParseError(
|
||||||
|
f"feature {i}: record boundary mismatch parsed={r.offset-st} expected={record_len}"
|
||||||
|
)
|
||||||
|
obj = object_defs.get(object_type)
|
||||||
|
object_class = obj.name if obj else f"UNKNOWN_{object_type}"
|
||||||
|
features.append(ParsedFeature(
|
||||||
|
record_index=i,
|
||||||
|
object_type=object_type,
|
||||||
|
object_class=object_class,
|
||||||
|
raw_geotype=geotype,
|
||||||
|
primitive=primitive,
|
||||||
|
geometry_refs=tuple(refs),
|
||||||
|
point2d_index=p2idx,
|
||||||
|
point3d_index=p3idx,
|
||||||
|
related_indices=tuple(related),
|
||||||
|
related_count_hint=related_hint,
|
||||||
|
n_attributes=nattr,
|
||||||
|
attributes=attrs,
|
||||||
|
raw_record=decoded[st:record_end],
|
||||||
|
raw_attribute_block=attr_block,
|
||||||
|
))
|
||||||
|
|
||||||
|
if total_attr_bytes != header.attribute_bytes:
|
||||||
|
raise CM93ParseError(
|
||||||
|
f"attribute byte count mismatch: parsed={total_attr_bytes} header={header.attribute_bytes}"
|
||||||
|
)
|
||||||
|
if r.offset != len(decoded):
|
||||||
|
raise CM93ParseError(f"cell end mismatch: parsed={r.offset} file={len(decoded)}")
|
||||||
|
|
||||||
|
sha = hashlib.sha256(encoded).hexdigest()
|
||||||
|
return ParsedCell(
|
||||||
|
path=path,
|
||||||
|
source_size=len(encoded),
|
||||||
|
sha256=sha,
|
||||||
|
encoded_bytes=encoded if keep_source_bytes else b"",
|
||||||
|
decoded_bytes=decoded if keep_source_bytes else b"",
|
||||||
|
prolog_word0=word0,
|
||||||
|
table1_length=table1,
|
||||||
|
table2_length=table2,
|
||||||
|
header=header,
|
||||||
|
edges=tuple(edges),
|
||||||
|
point2d=point2d,
|
||||||
|
point3d=tuple(groups),
|
||||||
|
features=tuple(features),
|
||||||
|
end_offset=r.offset,
|
||||||
|
table_offsets=table_offsets,
|
||||||
|
attribute_count=attr_count,
|
||||||
|
)
|
||||||
189
src/cm93db/cli.py
Normal file
189
src/cm93db/cli.py
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
import argparse
|
||||||
|
import platform
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .config import load_config, load_source_dir
|
||||||
|
from .database import connect, server_version, assert_mysql57, create_database, apply_schema
|
||||||
|
from .scanner import scan_cm93
|
||||||
|
from .dictionaries import load_object_dictionary, load_attribute_dictionary
|
||||||
|
from .schema_contract import REQUIRED_TABLES
|
||||||
|
from .importer import ImportOptions, run_import
|
||||||
|
from .audit import run_audit, print_result
|
||||||
|
|
||||||
|
|
||||||
|
def project_root() -> Path:
|
||||||
|
return Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
|
||||||
|
def _root(arg: str | None) -> Path:
|
||||||
|
return Path(arg) if arg else load_source_dir()
|
||||||
|
|
||||||
|
|
||||||
|
def _scales(values: list[str] | None) -> set[str] | None:
|
||||||
|
if not values:
|
||||||
|
return None
|
||||||
|
out=set()
|
||||||
|
for v in values:
|
||||||
|
for ch in v.upper().replace(",",""):
|
||||||
|
if ch.strip(): out.add(ch)
|
||||||
|
bad=out-set("ZABCDEFG")
|
||||||
|
if bad:
|
||||||
|
raise SystemExit(f"无效比例尺: {sorted(bad)}")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_env_check(_args):
|
||||||
|
print(f"Python: {sys.version.split()[0]}")
|
||||||
|
if sys.version_info[:2] != (3, 12):
|
||||||
|
raise SystemExit("ERROR: 本项目固定 Python 3.12.x;推荐 3.12.10")
|
||||||
|
print(f"Platform: {platform.platform()}")
|
||||||
|
cfg = load_config()
|
||||||
|
conn = connect(cfg)
|
||||||
|
try:
|
||||||
|
v = server_version(conn)
|
||||||
|
print(f"MySQL: {v}")
|
||||||
|
assert_mysql57(v)
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("SHOW VARIABLES LIKE 'max_allowed_packet'")
|
||||||
|
row=cur.fetchone()
|
||||||
|
if row:
|
||||||
|
val=int(row.get("Value",0)); print(f"max_allowed_packet: {val} bytes")
|
||||||
|
if val < 4*1024*1024:
|
||||||
|
print("WARNING: max_allowed_packet 较小;建议至少 4 MiB。raw table 已按 512 KiB 分块。")
|
||||||
|
print("MySQL 5.7 compatibility: OK")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_db_init(args):
|
||||||
|
cfg = load_config()
|
||||||
|
if args.create_database:
|
||||||
|
create_database(cfg)
|
||||||
|
print(f"Database ensured: {cfg.database}")
|
||||||
|
schema = project_root() / "sql" / "001_schema_mysql57.sql"
|
||||||
|
apply_schema(cfg, schema)
|
||||||
|
print(f"Schema initialized: {schema.name}")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_db_check(_args):
|
||||||
|
cfg = load_config()
|
||||||
|
conn = connect(cfg)
|
||||||
|
try:
|
||||||
|
v = server_version(conn)
|
||||||
|
assert_mysql57(v)
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("SELECT table_name FROM information_schema.tables WHERE table_schema=%s", (cfg.database,))
|
||||||
|
tables = {row["table_name"] for row in cur.fetchall()}
|
||||||
|
missing = sorted(REQUIRED_TABLES - tables)
|
||||||
|
print(f"MySQL: {v}")
|
||||||
|
print(f"Tables: {len(tables)}")
|
||||||
|
if missing:
|
||||||
|
print("Missing:")
|
||||||
|
for t in missing: print(f" - {t}")
|
||||||
|
raise SystemExit(2)
|
||||||
|
print("Schema contract: OK")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_scan(args):
|
||||||
|
result = scan_cm93(_root(args.root))
|
||||||
|
by_scale = {}
|
||||||
|
for c in result.cells: by_scale[c.scale_code] = by_scale.get(c.scale_code, 0) + 1
|
||||||
|
print(f"Root: {result.root}")
|
||||||
|
print(f"Cells: {len(result.cells)}")
|
||||||
|
for scale in sorted(by_scale): print(f" {scale}: {by_scale[scale]}")
|
||||||
|
print(f"CM93OBJ.DIC: {result.object_dictionary or 'NOT FOUND'}")
|
||||||
|
print(f"ATTR dictionary: {result.attribute_dictionary or 'NOT FOUND'}")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_inspect_dicts(args):
|
||||||
|
result = scan_cm93(_root(args.root))
|
||||||
|
if not result.object_dictionary: raise SystemExit("CM93OBJ.DIC 未找到")
|
||||||
|
if not result.attribute_dictionary: raise SystemExit("CM93ATTR.DIC/ATTRLUT.DIC 未找到")
|
||||||
|
objects = load_object_dictionary(result.object_dictionary)
|
||||||
|
attrs = load_attribute_dictionary(result.attribute_dictionary)
|
||||||
|
print(f"Object dictionary: {len(objects)} records")
|
||||||
|
print(f"Attribute dictionary: {len(attrs)} records")
|
||||||
|
for key in ("DEPARE", "DEPCNT", "LIGHTS", "SOUNDG", "WRECKS", "_m_sor"):
|
||||||
|
item = next((x for x in objects if x.name == key), None)
|
||||||
|
print(f" {key}: {item.object_id if item else 'missing'}")
|
||||||
|
for key in ("DRVAL1", "VALSOU", "_wgsox", "_wgsoy"):
|
||||||
|
item = next((x for x in attrs if x.name == key), None)
|
||||||
|
print(f" {key}: {item.attribute_id if item else 'missing'} / {item.source_type_name if item else '-'}")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_import(args):
|
||||||
|
cfg=load_config(); root=_root(args.root)
|
||||||
|
opt=ImportOptions(root=root,limit=args.limit,scales=_scales(args.scale),cell_name=args.cell,
|
||||||
|
reimport=args.reimport,retry_failed=not args.skip_failed,fail_fast=args.fail_fast,
|
||||||
|
store_raw_tables=not args.no_raw_tables)
|
||||||
|
s=run_import(cfg,opt)
|
||||||
|
print("\n=== IMPORT SUMMARY ===")
|
||||||
|
for k in ("cells_discovered","cells_done","cells_skipped","cells_failed","features_source","features_imported","attributes_source","attributes_imported","unknown_object_count","unknown_attribute_count","geometry_issue_count","sounding_points"):
|
||||||
|
print(f"{k:24}: {getattr(s,k)}")
|
||||||
|
if s.cells_failed or s.unknown_object_count or s.unknown_attribute_count:
|
||||||
|
raise SystemExit(2)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_audit(args):
|
||||||
|
cfg=load_config() if not args.source_only else load_config()
|
||||||
|
r=run_audit(cfg,_root(args.root),limit=args.limit,scales=_scales(args.scale),cell_name=args.cell,
|
||||||
|
precision_samples=args.precision_samples,tolerance_deg=args.tolerance,
|
||||||
|
validate_geometry=args.validate_geometry,source_only=args.source_only,require_raw=not args.allow_missing_raw)
|
||||||
|
print_result(r)
|
||||||
|
report = Path(args.report) if args.report else (project_root() / "logs" / f"cm93_audit_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json")
|
||||||
|
report.parent.mkdir(parents=True,exist_ok=True)
|
||||||
|
report.write_text(json.dumps(r.as_dict(),ensure_ascii=False,indent=2),encoding="utf-8")
|
||||||
|
print(f"Audit report: {report}")
|
||||||
|
if not r.passed: raise SystemExit(2)
|
||||||
|
|
||||||
|
|
||||||
|
def _add_source_args(s, optional=True):
|
||||||
|
s.add_argument("root", nargs="?" if optional else None, help="CM93 根目录;省略时读取 .env 的 CM93_SOURCE_DIR")
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser():
|
||||||
|
p = argparse.ArgumentParser(prog="cm93db")
|
||||||
|
sub = p.add_subparsers(dest="cmd", required=True)
|
||||||
|
s = sub.add_parser("env-check"); s.set_defaults(func=cmd_env_check)
|
||||||
|
s = sub.add_parser("db-init"); s.add_argument("--create-database", action="store_true"); s.set_defaults(func=cmd_db_init)
|
||||||
|
s = sub.add_parser("db-check"); s.set_defaults(func=cmd_db_check)
|
||||||
|
s = sub.add_parser("scan"); _add_source_args(s); s.set_defaults(func=cmd_scan)
|
||||||
|
s = sub.add_parser("inspect-dicts"); _add_source_args(s); s.set_defaults(func=cmd_inspect_dicts)
|
||||||
|
|
||||||
|
s = sub.add_parser("import", help="完整解析 CM93 并逐 cell 导入 MySQL")
|
||||||
|
_add_source_args(s)
|
||||||
|
s.add_argument("--limit", type=int)
|
||||||
|
s.add_argument("--scale", action="append", help="限定比例尺,例如 --scale G 或 --scale F,G")
|
||||||
|
s.add_argument("--cell", help="只导入指定 cell 文件名/不带扩展名")
|
||||||
|
s.add_argument("--reimport", action="store_true", help="重新导入已 DONE cell")
|
||||||
|
s.add_argument("--skip-failed", action="store_true", help="不重试之前 FAILED 的 cell")
|
||||||
|
s.add_argument("--fail-fast", action="store_true")
|
||||||
|
s.add_argument("--no-raw-tables", action="store_true", help="不保存 decoded 原始 table;不建议正式母库使用")
|
||||||
|
s.set_defaults(func=cmd_import)
|
||||||
|
|
||||||
|
s = sub.add_parser("audit", help="重新解析源 CM93 并与数据库逐 cell 审计")
|
||||||
|
_add_source_args(s)
|
||||||
|
s.add_argument("--limit", type=int)
|
||||||
|
s.add_argument("--scale", action="append")
|
||||||
|
s.add_argument("--cell")
|
||||||
|
s.add_argument("--precision-samples", type=int, default=128, help="数据库 WKB 双精度回读样本数")
|
||||||
|
s.add_argument("--tolerance", type=float, default=1e-12, help="经纬度精度比较容差(degree)")
|
||||||
|
s.add_argument("--validate-geometry", action="store_true", help="额外执行 Shapely validity 全量检查,较慢")
|
||||||
|
s.add_argument("--source-only", action="store_true", help="只审计源文件解析,不查询数据库")
|
||||||
|
s.add_argument("--allow-missing-raw", action="store_true", help="不把 raw table 未保存视为审计失败")
|
||||||
|
s.add_argument("--report", help="JSON 审计报告路径;默认写入项目 logs/")
|
||||||
|
s.set_defaults(func=cmd_audit)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = build_parser().parse_args()
|
||||||
|
args.func(args)
|
||||||
|
|
||||||
|
if __name__ == "__main__": main()
|
||||||
478
src/cm93db/cm93_binary.py
Normal file
478
src/cm93db/cm93_binary.py
Normal file
@@ -0,0 +1,478 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
import hashlib
|
||||||
|
import math
|
||||||
|
import struct
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .dictionaries import AttributeDef, ObjectDef
|
||||||
|
from .precision import CM93_SEMIMAJOR_M
|
||||||
|
|
||||||
|
TABLE_0 = bytes([
|
||||||
|
0xCD,0xEA,0xDC,0x48,0x3E,0x6D,0xCA,0x7B,0x52,0xE1,0xA4,0x8E,0xAB,0x05,0xA7,0x97,
|
||||||
|
0xB9,0x60,0x39,0x85,0x7C,0x56,0x7A,0xBA,0x68,0x6E,0xF5,0x5D,0x02,0x4E,0x0F,0xA1,
|
||||||
|
0x27,0x24,0x41,0x34,0x00,0x5A,0xFE,0xCB,0xD0,0xFA,0xF8,0x6C,0x74,0x96,0x9E,0x0E,
|
||||||
|
0xC2,0x49,0xE3,0xE5,0xC0,0x3B,0x59,0x18,0xA9,0x86,0x8F,0x30,0xC3,0xA8,0x22,0x0A,
|
||||||
|
0x14,0x1A,0xB2,0xC9,0xC7,0xED,0xAA,0x29,0x94,0x75,0x0D,0xAC,0x0C,0xF4,0xBB,0xC5,
|
||||||
|
0x3F,0xFD,0xD9,0x9C,0x4F,0xD5,0x84,0x1E,0xB1,0x81,0x69,0xB4,0x09,0xB8,0x3C,0xAF,
|
||||||
|
0xA3,0x08,0xBF,0xE0,0x9A,0xD7,0xF7,0x8C,0x67,0x66,0xAE,0xD4,0x4C,0xA5,0xEC,0xF9,
|
||||||
|
0xB6,0x64,0x78,0x06,0x5B,0x9B,0xF2,0x99,0xCE,0xDB,0x53,0x55,0x65,0x8D,0x07,0x33,
|
||||||
|
0x04,0x37,0x92,0x26,0x23,0xB5,0x58,0xDA,0x2F,0xB3,0x40,0x5E,0x7F,0x4B,0x62,0x80,
|
||||||
|
0xE4,0x6F,0x73,0x1D,0xDF,0x17,0xCC,0x28,0x25,0x2D,0xEE,0x3A,0x98,0xE2,0x01,0xEB,
|
||||||
|
0xDD,0xBC,0x90,0xB0,0xFC,0x95,0x76,0x93,0x46,0x57,0x2C,0x2B,0x50,0x11,0x0B,0xC1,
|
||||||
|
0xF0,0xE7,0xD6,0x21,0x31,0xDE,0xFF,0xD8,0x12,0xA6,0x4D,0x8A,0x13,0x43,0x45,0x38,
|
||||||
|
0xD2,0x87,0xA0,0xEF,0x82,0xF1,0x47,0x89,0x6A,0xC8,0x54,0x1B,0x16,0x7E,0x79,0xBD,
|
||||||
|
0x6B,0x91,0xA2,0x71,0x36,0xB7,0x03,0x3D,0x72,0xC6,0x44,0x8B,0xCF,0x15,0x9F,0x32,
|
||||||
|
0xC4,0x77,0x83,0x63,0x20,0x88,0xF6,0xAD,0xF3,0xE8,0x4A,0xE9,0x35,0x1C,0x5F,0x19,
|
||||||
|
0x1F,0x7D,0x70,0xFB,0xD1,0x51,0x10,0xD3,0x2E,0x61,0x9D,0x5C,0x2A,0x42,0xBE,0xE6,
|
||||||
|
])
|
||||||
|
|
||||||
|
_encode = bytes((v ^ 8) for v in TABLE_0)
|
||||||
|
_decode_arr = bytearray(256)
|
||||||
|
for i, v in enumerate(_encode):
|
||||||
|
_decode_arr[v] = i
|
||||||
|
DECODE_TABLE = bytes(_decode_arr)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_bytes(data: bytes) -> bytes:
|
||||||
|
return data.translate(DECODE_TABLE)
|
||||||
|
|
||||||
|
|
||||||
|
class CM93ParseError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Header:
|
||||||
|
lon_min: float
|
||||||
|
lat_min: float
|
||||||
|
lon_max: float
|
||||||
|
lat_max: float
|
||||||
|
easting_min: float
|
||||||
|
northing_min: float
|
||||||
|
easting_max: float
|
||||||
|
northing_max: float
|
||||||
|
usn_vector_records: int
|
||||||
|
n_vector_record_points: int
|
||||||
|
m_46: int
|
||||||
|
m_4a: int
|
||||||
|
usn_point3d_records: int
|
||||||
|
m_50: int
|
||||||
|
m_54: int
|
||||||
|
usn_point2d_records: int
|
||||||
|
m_5a: int
|
||||||
|
m_5c: int
|
||||||
|
usn_feature_records: int
|
||||||
|
m_60: int
|
||||||
|
m_64: int
|
||||||
|
m_68: int
|
||||||
|
m_6a: int
|
||||||
|
m_6c: int
|
||||||
|
m_nrelated_object_pointers: int
|
||||||
|
m_72: int
|
||||||
|
m_76: int
|
||||||
|
m_78: int
|
||||||
|
m_7c: int
|
||||||
|
|
||||||
|
@property
|
||||||
|
def x_rate(self) -> float:
|
||||||
|
delta = self.easting_max - self.easting_min
|
||||||
|
if delta < 0:
|
||||||
|
delta += CM93_SEMIMAJOR_M * 2.0 * math.pi
|
||||||
|
return delta / 65535.0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def y_rate(self) -> float:
|
||||||
|
return (self.northing_max - self.northing_min) / 65535.0
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return {k: getattr(self, k) for k in self.__dataclass_fields__}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Edge:
|
||||||
|
index: int
|
||||||
|
points: tuple[tuple[int, int], ...]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Point3DDescriptor:
|
||||||
|
index: int
|
||||||
|
points: tuple[tuple[int, int, int], ...]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ParsedAttribute:
|
||||||
|
ordinal: int
|
||||||
|
attribute_id: int
|
||||||
|
name: str
|
||||||
|
decoder_type: str
|
||||||
|
source_type_name: str
|
||||||
|
value: Any
|
||||||
|
raw_value: bytes
|
||||||
|
raw_entry: bytes
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FeatureRecord:
|
||||||
|
index: int
|
||||||
|
object_type: int
|
||||||
|
object_class: str
|
||||||
|
geotype: int
|
||||||
|
geometry_primitive: int
|
||||||
|
geometry_refs: list[tuple[int, int]] = field(default_factory=list) # (edge index, usage)
|
||||||
|
point2d_index: int | None = None
|
||||||
|
point3d_index: int | None = None
|
||||||
|
related_indices: list[int] = field(default_factory=list)
|
||||||
|
relation20_raw: int | None = None
|
||||||
|
attributes: list[ParsedAttribute] = field(default_factory=list)
|
||||||
|
raw_record: bytes = b""
|
||||||
|
raw_attribute_blob: bytes = b""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RawTables:
|
||||||
|
prolog: bytes
|
||||||
|
header: bytes
|
||||||
|
vectors: bytes
|
||||||
|
point3d: bytes
|
||||||
|
point2d: bytes
|
||||||
|
features: bytes
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ParsedCell:
|
||||||
|
path: Path
|
||||||
|
file_size: int
|
||||||
|
sha256: str
|
||||||
|
word0: int
|
||||||
|
table1_length: int
|
||||||
|
table2_length: int
|
||||||
|
header: Header
|
||||||
|
edges: list[Edge]
|
||||||
|
point3d: list[Point3DDescriptor]
|
||||||
|
point2d: list[tuple[int, int]]
|
||||||
|
features: list[FeatureRecord]
|
||||||
|
raw_tables: RawTables
|
||||||
|
unknown_object_ids: set[int]
|
||||||
|
unknown_attribute_ids: set[int]
|
||||||
|
attribute_count: int
|
||||||
|
|
||||||
|
|
||||||
|
class Cursor:
|
||||||
|
def __init__(self, data: bytes, pos: int = 0):
|
||||||
|
self.data = data
|
||||||
|
self.pos = pos
|
||||||
|
|
||||||
|
def remaining(self) -> int:
|
||||||
|
return len(self.data) - self.pos
|
||||||
|
|
||||||
|
def take(self, n: int) -> bytes:
|
||||||
|
if n < 0 or self.pos + n > len(self.data):
|
||||||
|
raise CM93ParseError(f"越界读取: pos={self.pos}, n={n}, size={len(self.data)}")
|
||||||
|
out = self.data[self.pos:self.pos+n]
|
||||||
|
self.pos += n
|
||||||
|
return out
|
||||||
|
|
||||||
|
def u8(self) -> int:
|
||||||
|
return self.take(1)[0]
|
||||||
|
|
||||||
|
def u16(self) -> int:
|
||||||
|
return struct.unpack("<H", self.take(2))[0]
|
||||||
|
|
||||||
|
def i32(self) -> int:
|
||||||
|
return struct.unpack("<i", self.take(4))[0]
|
||||||
|
|
||||||
|
def f32(self) -> float:
|
||||||
|
return struct.unpack("<f", self.take(4))[0]
|
||||||
|
|
||||||
|
def f64(self) -> float:
|
||||||
|
return struct.unpack("<d", self.take(8))[0]
|
||||||
|
|
||||||
|
|
||||||
|
HEADER_FMT = "<8dHiiiHiiHHHHiiHHH i H ii".replace(" ", "")
|
||||||
|
# Explicit parsing is used instead of struct.unpack on the full header so that
|
||||||
|
# field order remains auditable against the documented 128-byte CM93 sequence.
|
||||||
|
|
||||||
|
|
||||||
|
def parse_header(decoded_header: bytes) -> Header:
|
||||||
|
if len(decoded_header) != 128:
|
||||||
|
raise CM93ParseError(f"CM93 header 长度应为 128,实际 {len(decoded_header)}")
|
||||||
|
c = Cursor(decoded_header)
|
||||||
|
vals = [c.f64() for _ in range(8)]
|
||||||
|
h = Header(
|
||||||
|
lon_min=vals[0], lat_min=vals[1], lon_max=vals[2], lat_max=vals[3],
|
||||||
|
easting_min=vals[4], northing_min=vals[5], easting_max=vals[6], northing_max=vals[7],
|
||||||
|
usn_vector_records=c.u16(),
|
||||||
|
n_vector_record_points=c.i32(),
|
||||||
|
m_46=c.i32(),
|
||||||
|
m_4a=c.i32(),
|
||||||
|
usn_point3d_records=c.u16(),
|
||||||
|
m_50=c.i32(),
|
||||||
|
m_54=c.i32(),
|
||||||
|
usn_point2d_records=c.u16(),
|
||||||
|
m_5a=c.u16(),
|
||||||
|
m_5c=c.u16(),
|
||||||
|
usn_feature_records=c.u16(),
|
||||||
|
m_60=c.i32(),
|
||||||
|
m_64=c.i32(),
|
||||||
|
m_68=c.u16(),
|
||||||
|
m_6a=c.u16(),
|
||||||
|
m_6c=c.u16(),
|
||||||
|
m_nrelated_object_pointers=c.i32(),
|
||||||
|
m_72=c.i32(),
|
||||||
|
m_76=c.u16(),
|
||||||
|
m_78=c.i32(),
|
||||||
|
m_7c=c.i32(),
|
||||||
|
)
|
||||||
|
if c.remaining() != 0:
|
||||||
|
raise CM93ParseError(f"header 未完全消费: {c.remaining()} bytes")
|
||||||
|
return h
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_text(raw: bytes) -> str:
|
||||||
|
# Byte-preserving fallback is intentional. raw_value is stored separately.
|
||||||
|
for enc in ("utf-8", "cp1252", "latin1"):
|
||||||
|
try:
|
||||||
|
return raw.decode(enc)
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
pass
|
||||||
|
return raw.decode("latin1")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_attr_entry(c: Cursor, ordinal: int, attr_defs: dict[int, AttributeDef]) -> tuple[ParsedAttribute, bool]:
|
||||||
|
start = c.pos
|
||||||
|
aid = c.u8()
|
||||||
|
d = attr_defs.get(aid)
|
||||||
|
unknown = d is None
|
||||||
|
if d is None:
|
||||||
|
raise CM93ParseError(f"未知 attribute id={aid},无法确定值长度")
|
||||||
|
vstart = c.pos
|
||||||
|
t = d.decoder_type
|
||||||
|
if t == "B":
|
||||||
|
value = c.u8()
|
||||||
|
elif t == "W":
|
||||||
|
value = c.u16() / 10.0
|
||||||
|
elif t == "G":
|
||||||
|
value = c.i32()
|
||||||
|
elif t == "R":
|
||||||
|
value = c.f32()
|
||||||
|
elif t == "S":
|
||||||
|
z = c.data.find(b"\x00", c.pos)
|
||||||
|
if z < 0:
|
||||||
|
raise CM93ParseError(f"字符串属性 {d.name} 缺少 NUL 终止符")
|
||||||
|
raw = c.data[c.pos:z]
|
||||||
|
c.pos = z + 1
|
||||||
|
value = _decode_text(raw)
|
||||||
|
elif t == "C":
|
||||||
|
prefix = list(c.take(3))
|
||||||
|
z = c.data.find(b"\x00", c.pos)
|
||||||
|
if z < 0:
|
||||||
|
raise CM93ParseError(f"复杂属性 {d.name} 缺少 NUL 终止符")
|
||||||
|
raw = c.data[c.pos:z]
|
||||||
|
c.pos = z + 1
|
||||||
|
value = {"prefix": prefix, "text": _decode_text(raw)}
|
||||||
|
elif t == "L":
|
||||||
|
n = c.u8()
|
||||||
|
value = list(c.take(n))
|
||||||
|
elif t == "I":
|
||||||
|
value = c.u16()
|
||||||
|
else:
|
||||||
|
raise CM93ParseError(f"属性 {d.name}({aid}) 类型 {d.source_type_name}/{t} 无法解码")
|
||||||
|
vend = c.pos
|
||||||
|
return ParsedAttribute(
|
||||||
|
ordinal=ordinal,
|
||||||
|
attribute_id=aid,
|
||||||
|
name=d.name,
|
||||||
|
decoder_type=t,
|
||||||
|
source_type_name=d.source_type_name,
|
||||||
|
value=value,
|
||||||
|
raw_value=c.data[vstart:vend],
|
||||||
|
raw_entry=c.data[start:vend],
|
||||||
|
), unknown
|
||||||
|
|
||||||
|
|
||||||
|
def parse_cell(path: Path, object_defs: dict[int, ObjectDef], attr_defs: dict[int, AttributeDef]) -> ParsedCell:
|
||||||
|
encoded = path.read_bytes()
|
||||||
|
decoded = decode_bytes(encoded)
|
||||||
|
if len(decoded) < 138:
|
||||||
|
raise CM93ParseError(f"文件过短: {path}")
|
||||||
|
c = Cursor(decoded)
|
||||||
|
prolog_start = c.pos
|
||||||
|
word0 = c.u16()
|
||||||
|
table1 = c.i32()
|
||||||
|
table2 = c.i32()
|
||||||
|
prolog = decoded[prolog_start:c.pos]
|
||||||
|
if word0 != 138:
|
||||||
|
raise CM93ParseError(f"prolog/header 长度异常: word0={word0}, expected=138")
|
||||||
|
if word0 + table1 + table2 != len(decoded):
|
||||||
|
raise CM93ParseError(
|
||||||
|
f"文件完整性失败: {word0}+{table1}+{table2}!={len(decoded)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
header_start = c.pos
|
||||||
|
header = parse_header(c.take(128))
|
||||||
|
raw_header = decoded[header_start:c.pos]
|
||||||
|
|
||||||
|
vector_start = c.pos
|
||||||
|
edges: list[Edge] = []
|
||||||
|
total_vector_points = 0
|
||||||
|
for i in range(header.usn_vector_records):
|
||||||
|
n = c.u16()
|
||||||
|
pts = tuple((c.u16(), c.u16()) for _ in range(n))
|
||||||
|
total_vector_points += n
|
||||||
|
edges.append(Edge(i, pts))
|
||||||
|
vector_end = c.pos
|
||||||
|
if total_vector_points != header.n_vector_record_points:
|
||||||
|
raise CM93ParseError(
|
||||||
|
f"vector point count mismatch: header={header.n_vector_record_points}, parsed={total_vector_points}"
|
||||||
|
)
|
||||||
|
|
||||||
|
p3_start = c.pos
|
||||||
|
point3d: list[Point3DDescriptor] = []
|
||||||
|
total_p3_points = 0
|
||||||
|
for i in range(header.usn_point3d_records):
|
||||||
|
n = c.u16()
|
||||||
|
pts = tuple((c.u16(), c.u16(), c.u16()) for _ in range(n))
|
||||||
|
total_p3_points += n
|
||||||
|
point3d.append(Point3DDescriptor(i, pts))
|
||||||
|
p3_end = c.pos
|
||||||
|
if header.m_50 >= 0 and total_p3_points != header.m_50:
|
||||||
|
raise CM93ParseError(f"3D point count mismatch: header={header.m_50}, parsed={total_p3_points}")
|
||||||
|
|
||||||
|
p2_start = c.pos
|
||||||
|
point2d = [(c.u16(), c.u16()) for _ in range(header.usn_point2d_records)]
|
||||||
|
p2_end = c.pos
|
||||||
|
|
||||||
|
feature_start = c.pos
|
||||||
|
features: list[FeatureRecord] = []
|
||||||
|
unknown_obj: set[int] = set()
|
||||||
|
unknown_attr: set[int] = set()
|
||||||
|
attr_total = 0
|
||||||
|
|
||||||
|
for fi in range(header.usn_feature_records):
|
||||||
|
rec_start = c.pos
|
||||||
|
object_type = c.u8()
|
||||||
|
geotype = c.u8()
|
||||||
|
desc_len = c.u16()
|
||||||
|
if desc_len < 4:
|
||||||
|
raise CM93ParseError(f"feature {fi}: obj_desc_bytes={desc_len}<4")
|
||||||
|
rec_end = rec_start + desc_len
|
||||||
|
if rec_end > len(decoded):
|
||||||
|
raise CM93ParseError(f"feature {fi}: record 越界")
|
||||||
|
od = object_defs.get(object_type)
|
||||||
|
if od is None:
|
||||||
|
unknown_obj.add(object_type)
|
||||||
|
object_class = f"UNKNOWN_{object_type}"
|
||||||
|
else:
|
||||||
|
object_class = od.name
|
||||||
|
prim = geotype & 0x0F
|
||||||
|
f = FeatureRecord(fi, object_type, object_class, geotype, prim)
|
||||||
|
|
||||||
|
if prim in (2, 4):
|
||||||
|
n = c.u16()
|
||||||
|
for _ in range(n):
|
||||||
|
packed = c.u16()
|
||||||
|
edge_idx = packed & 0x1FFF
|
||||||
|
usage = packed >> 13
|
||||||
|
if edge_idx >= len(edges):
|
||||||
|
raise CM93ParseError(f"feature {fi}: edge index {edge_idx} >= {len(edges)}")
|
||||||
|
f.geometry_refs.append((edge_idx, usage))
|
||||||
|
elif prim == 1:
|
||||||
|
idx = c.u16()
|
||||||
|
if idx >= len(point2d):
|
||||||
|
raise CM93ParseError(f"feature {fi}: point2d index {idx} >= {len(point2d)}")
|
||||||
|
f.point2d_index = idx
|
||||||
|
elif prim == 8:
|
||||||
|
idx = c.u16()
|
||||||
|
if idx >= len(point3d):
|
||||||
|
raise CM93ParseError(f"feature {fi}: point3d index {idx} >= {len(point3d)}")
|
||||||
|
f.point3d_index = idx
|
||||||
|
|
||||||
|
if geotype & 0x10:
|
||||||
|
nrel = c.u8()
|
||||||
|
f.related_indices = [c.u16() for _ in range(nrel)]
|
||||||
|
for rel in f.related_indices:
|
||||||
|
if rel >= header.usn_feature_records:
|
||||||
|
raise CM93ParseError(f"feature {fi}: related index {rel} 越界")
|
||||||
|
if geotype & 0x20:
|
||||||
|
f.relation20_raw = c.u16()
|
||||||
|
# 0x40 is observed as a flag without a documented payload in OpenCPN.
|
||||||
|
|
||||||
|
if geotype & 0x80:
|
||||||
|
nattr = c.u8()
|
||||||
|
attr_blob_start = c.pos
|
||||||
|
# By format definition obj_desc_bytes is the total record length.
|
||||||
|
# Attribute payload is the remaining bytes in this record.
|
||||||
|
attr_blob = decoded[attr_blob_start:rec_end]
|
||||||
|
ac = Cursor(attr_blob)
|
||||||
|
attrs: list[ParsedAttribute] = []
|
||||||
|
for ordinal in range(nattr):
|
||||||
|
try:
|
||||||
|
a, was_unknown = _parse_attr_entry(ac, ordinal, attr_defs)
|
||||||
|
except CM93ParseError as e:
|
||||||
|
# Capture the leading id when possible for audit diagnostics.
|
||||||
|
if ac.pos < len(attr_blob):
|
||||||
|
unknown_attr.add(attr_blob[ac.pos])
|
||||||
|
raise CM93ParseError(f"feature {fi} attribute {ordinal}: {e}") from e
|
||||||
|
if was_unknown:
|
||||||
|
unknown_attr.add(a.attribute_id)
|
||||||
|
attrs.append(a)
|
||||||
|
if ac.remaining() != 0:
|
||||||
|
raise CM93ParseError(
|
||||||
|
f"feature {fi}: attribute blob 剩余 {ac.remaining()} bytes; nattr={nattr}"
|
||||||
|
)
|
||||||
|
f.attributes = attrs
|
||||||
|
f.raw_attribute_blob = attr_blob
|
||||||
|
attr_total += len(attrs)
|
||||||
|
c.pos = rec_end
|
||||||
|
else:
|
||||||
|
# Preserve any unparsed tail, but do not silently cross the record boundary.
|
||||||
|
if c.pos > rec_end:
|
||||||
|
raise CM93ParseError(f"feature {fi}: 已解析字段超过 record 长度")
|
||||||
|
c.pos = rec_end
|
||||||
|
|
||||||
|
f.raw_record = decoded[rec_start:rec_end]
|
||||||
|
features.append(f)
|
||||||
|
|
||||||
|
feature_end = c.pos
|
||||||
|
if feature_end != len(decoded):
|
||||||
|
raise CM93ParseError(f"文件尾不匹配: parsed={feature_end}, size={len(decoded)}, remaining={len(decoded)-feature_end}")
|
||||||
|
parsed_attr_bytes = sum(len(f.raw_attribute_blob) for f in features)
|
||||||
|
if parsed_attr_bytes != header.m_78:
|
||||||
|
raise CM93ParseError(f"attribute byte count mismatch: header={header.m_78}, parsed={parsed_attr_bytes}")
|
||||||
|
parsed_geom_refs = sum(len(f.geometry_refs) for f in features)
|
||||||
|
if parsed_geom_refs != header.m_46 + header.m_4a:
|
||||||
|
raise CM93ParseError(f"geometry ref count mismatch: header={header.m_46 + header.m_4a}, parsed={parsed_geom_refs}")
|
||||||
|
parsed_related = sum(len(f.related_indices) for f in features)
|
||||||
|
if parsed_related != header.m_nrelated_object_pointers:
|
||||||
|
raise CM93ParseError(f"related pointer count mismatch: header={header.m_nrelated_object_pointers}, parsed={parsed_related}")
|
||||||
|
|
||||||
|
# Table-1/Table-2 lengths are retained and audited at file level. The internal
|
||||||
|
# semantic subtable boundaries are determined from header record counts.
|
||||||
|
return ParsedCell(
|
||||||
|
path=path,
|
||||||
|
file_size=len(encoded),
|
||||||
|
sha256=hashlib.sha256(encoded).hexdigest(),
|
||||||
|
word0=word0,
|
||||||
|
table1_length=table1,
|
||||||
|
table2_length=table2,
|
||||||
|
header=header,
|
||||||
|
edges=edges,
|
||||||
|
point3d=point3d,
|
||||||
|
point2d=point2d,
|
||||||
|
features=features,
|
||||||
|
raw_tables=RawTables(
|
||||||
|
prolog=prolog,
|
||||||
|
header=raw_header,
|
||||||
|
vectors=decoded[vector_start:vector_end],
|
||||||
|
point3d=decoded[p3_start:p3_end],
|
||||||
|
point2d=decoded[p2_start:p2_end],
|
||||||
|
features=decoded[feature_start:feature_end],
|
||||||
|
),
|
||||||
|
unknown_object_ids=unknown_obj,
|
||||||
|
unknown_attribute_ids=unknown_attr,
|
||||||
|
attribute_count=attr_total,
|
||||||
|
)
|
||||||
35
src/cm93db/config.py
Normal file
35
src/cm93db/config.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DbConfig:
|
||||||
|
host: str
|
||||||
|
port: int
|
||||||
|
database: str
|
||||||
|
user: str
|
||||||
|
password: str
|
||||||
|
charset: str = "utf8mb4"
|
||||||
|
|
||||||
|
def load_config(require_database: bool = True) -> DbConfig:
|
||||||
|
load_dotenv()
|
||||||
|
database = os.getenv("CM93_DB_NAME", "cm93_chart")
|
||||||
|
if require_database and not database:
|
||||||
|
raise RuntimeError("CM93_DB_NAME 未配置")
|
||||||
|
return DbConfig(
|
||||||
|
host=os.getenv("CM93_DB_HOST", "127.0.0.1"),
|
||||||
|
port=int(os.getenv("CM93_DB_PORT", "3306")),
|
||||||
|
database=database,
|
||||||
|
user=os.getenv("CM93_DB_USER", "root"),
|
||||||
|
password=os.getenv("CM93_DB_PASSWORD", ""),
|
||||||
|
charset=os.getenv("CM93_DB_CHARSET", "utf8mb4"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def load_source_dir() -> Path:
|
||||||
|
load_dotenv()
|
||||||
|
raw=os.getenv("CM93_SOURCE_DIR", "").strip()
|
||||||
|
if not raw:
|
||||||
|
raise RuntimeError("未传入 CM93 路径,且 .env 中 CM93_SOURCE_DIR 未配置")
|
||||||
|
return Path(raw)
|
||||||
101
src/cm93db/database.py
Normal file
101
src/cm93db/database.py
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
import re
|
||||||
|
from .config import DbConfig
|
||||||
|
|
||||||
|
|
||||||
|
def connect(cfg: DbConfig, with_database: bool = True):
|
||||||
|
import pymysql
|
||||||
|
kwargs = dict(
|
||||||
|
host=cfg.host,
|
||||||
|
port=cfg.port,
|
||||||
|
user=cfg.user,
|
||||||
|
password=cfg.password,
|
||||||
|
charset=cfg.charset,
|
||||||
|
autocommit=False,
|
||||||
|
cursorclass=pymysql.cursors.DictCursor,
|
||||||
|
)
|
||||||
|
if with_database:
|
||||||
|
kwargs["database"] = cfg.database
|
||||||
|
return pymysql.connect(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def server_version(conn) -> str:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("SELECT VERSION() AS v")
|
||||||
|
return str(cur.fetchone()["v"])
|
||||||
|
|
||||||
|
|
||||||
|
def assert_mysql57(version: str) -> None:
|
||||||
|
m = re.match(r"^(\d+)\.(\d+)\.(\d+)", version)
|
||||||
|
if not m:
|
||||||
|
raise RuntimeError(f"无法识别 MySQL 版本: {version}")
|
||||||
|
major, minor, _patch = map(int, m.groups())
|
||||||
|
if (major, minor) != (5, 7):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"项目数据库基线是 MySQL 5.7.34;当前服务器为 {version}。"
|
||||||
|
"不要直接继续初始化,先确认兼容策略。"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_database(cfg: DbConfig) -> None:
|
||||||
|
safe = cfg.database.replace("`", "``")
|
||||||
|
conn = connect(cfg, with_database=False)
|
||||||
|
try:
|
||||||
|
assert_mysql57(server_version(conn))
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
f"CREATE DATABASE IF NOT EXISTS `{safe}` "
|
||||||
|
"CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _split_sql(sql: str):
|
||||||
|
statements = []
|
||||||
|
buf = []
|
||||||
|
in_single = in_double = in_backtick = False
|
||||||
|
i = 0
|
||||||
|
while i < len(sql):
|
||||||
|
ch = sql[i]
|
||||||
|
nxt = sql[i+1] if i + 1 < len(sql) else ""
|
||||||
|
if not (in_single or in_double or in_backtick) and ch == "-" and nxt == "-":
|
||||||
|
while i < len(sql) and sql[i] != "\n": i += 1
|
||||||
|
continue
|
||||||
|
if not (in_single or in_double or in_backtick) and ch == "#":
|
||||||
|
while i < len(sql) and sql[i] != "\n": i += 1
|
||||||
|
continue
|
||||||
|
if ch == "'" and not in_double and not in_backtick and (i == 0 or sql[i-1] != "\\"):
|
||||||
|
in_single = not in_single
|
||||||
|
elif ch == '"' and not in_single and not in_backtick and (i == 0 or sql[i-1] != "\\"):
|
||||||
|
in_double = not in_double
|
||||||
|
elif ch == "`" and not in_single and not in_double:
|
||||||
|
in_backtick = not in_backtick
|
||||||
|
if ch == ";" and not (in_single or in_double or in_backtick):
|
||||||
|
s = "".join(buf).strip()
|
||||||
|
if s: statements.append(s)
|
||||||
|
buf = []
|
||||||
|
else:
|
||||||
|
buf.append(ch)
|
||||||
|
i += 1
|
||||||
|
tail = "".join(buf).strip()
|
||||||
|
if tail: statements.append(tail)
|
||||||
|
return statements
|
||||||
|
|
||||||
|
|
||||||
|
def apply_schema(cfg: DbConfig, schema_path: Path) -> None:
|
||||||
|
sql = schema_path.read_text(encoding="utf-8")
|
||||||
|
conn = connect(cfg)
|
||||||
|
try:
|
||||||
|
assert_mysql57(server_version(conn))
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
for stmt in _split_sql(sql): cur.execute(stmt)
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
38
src/cm93db/decoder.py
Normal file
38
src/cm93db/decoder.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# CM93 byte substitution table. This is a format constant, not executable code.
|
||||||
|
# Decode table is derived as: encode[i] = TABLE_0[i] ^ 8; decode[encode[i]] = i.
|
||||||
|
TABLE_0 = bytes([
|
||||||
|
0xCD,0xEA,0xDC,0x48,0x3E,0x6D,0xCA,0x7B,0x52,0xE1,0xA4,0x8E,0xAB,0x05,0xA7,0x97,
|
||||||
|
0xB9,0x60,0x39,0x85,0x7C,0x56,0x7A,0xBA,0x68,0x6E,0xF5,0x5D,0x02,0x4E,0x0F,0xA1,
|
||||||
|
0x27,0x24,0x41,0x34,0x00,0x5A,0xFE,0xCB,0xD0,0xFA,0xF8,0x6C,0x74,0x96,0x9E,0x0E,
|
||||||
|
0xC2,0x49,0xE3,0xE5,0xC0,0x3B,0x59,0x18,0xA9,0x86,0x8F,0x30,0xC3,0xA8,0x22,0x0A,
|
||||||
|
0x14,0x1A,0xB2,0xC9,0xC7,0xED,0xAA,0x29,0x94,0x75,0x0D,0xAC,0x0C,0xF4,0xBB,0xC5,
|
||||||
|
0x3F,0xFD,0xD9,0x9C,0x4F,0xD5,0x84,0x1E,0xB1,0x81,0x69,0xB4,0x09,0xB8,0x3C,0xAF,
|
||||||
|
0xA3,0x08,0xBF,0xE0,0x9A,0xD7,0xF7,0x8C,0x67,0x66,0xAE,0xD4,0x4C,0xA5,0xEC,0xF9,
|
||||||
|
0xB6,0x64,0x78,0x06,0x5B,0x9B,0xF2,0x99,0xCE,0xDB,0x53,0x55,0x65,0x8D,0x07,0x33,
|
||||||
|
0x04,0x37,0x92,0x26,0x23,0xB5,0x58,0xDA,0x2F,0xB3,0x40,0x5E,0x7F,0x4B,0x62,0x80,
|
||||||
|
0xE4,0x6F,0x73,0x1D,0xDF,0x17,0xCC,0x28,0x25,0x2D,0xEE,0x3A,0x98,0xE2,0x01,0xEB,
|
||||||
|
0xDD,0xBC,0x90,0xB0,0xFC,0x95,0x76,0x93,0x46,0x57,0x2C,0x2B,0x50,0x11,0x0B,0xC1,
|
||||||
|
0xF0,0xE7,0xD6,0x21,0x31,0xDE,0xFF,0xD8,0x12,0xA6,0x4D,0x8A,0x13,0x43,0x45,0x38,
|
||||||
|
0xD2,0x87,0xA0,0xEF,0x82,0xF1,0x47,0x89,0x6A,0xC8,0x54,0x1B,0x16,0x7E,0x79,0xBD,
|
||||||
|
0x6B,0x91,0xA2,0x71,0x36,0xB7,0x03,0x3D,0x72,0xC6,0x44,0x8B,0xCF,0x15,0x9F,0x32,
|
||||||
|
0xC4,0x77,0x83,0x63,0x20,0x88,0xF6,0xAD,0xF3,0xE8,0x4A,0xE9,0x35,0x1C,0x5F,0x19,
|
||||||
|
0x1F,0x7D,0x70,0xFB,0xD1,0x51,0x10,0xD3,0x2E,0x61,0x9D,0x5C,0x2A,0x42,0xBE,0xE6,
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def _make_decode_table() -> bytes:
|
||||||
|
decode = bytearray(256)
|
||||||
|
for plain, base in enumerate(TABLE_0):
|
||||||
|
encoded = base ^ 0x08
|
||||||
|
decode[encoded] = plain
|
||||||
|
return bytes(decode)
|
||||||
|
|
||||||
|
|
||||||
|
DECODE_TABLE = _make_decode_table()
|
||||||
|
|
||||||
|
|
||||||
|
def decode_cm93(data: bytes) -> bytes:
|
||||||
|
"""Decode a complete CM93 cell. Byte substitution is position-independent."""
|
||||||
|
return data.translate(DECODE_TABLE)
|
||||||
94
src/cm93db/dictionaries.py
Normal file
94
src/cm93db/dictionaries.py
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
TYPE_MAP = {
|
||||||
|
"aFLOAT": "R",
|
||||||
|
"aBYTE": "B",
|
||||||
|
"aSTRING": "S",
|
||||||
|
"aCMPLX": "C",
|
||||||
|
"aLIST": "L",
|
||||||
|
"aWORD10": "W",
|
||||||
|
"aLONG": "G",
|
||||||
|
"aNotUsed": "?",
|
||||||
|
}
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ObjectDef:
|
||||||
|
name: str
|
||||||
|
object_id: int
|
||||||
|
geometry_code: str
|
||||||
|
display_code: str | None
|
||||||
|
description: str | None
|
||||||
|
raw_line: str
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AttributeDef:
|
||||||
|
name: str
|
||||||
|
attribute_id: int
|
||||||
|
source_type_name: str
|
||||||
|
decoder_type: str
|
||||||
|
min_value: float | None
|
||||||
|
max_value: float | None
|
||||||
|
raw_line: str
|
||||||
|
|
||||||
|
def _read_lines(path: Path):
|
||||||
|
for enc in ("utf-8-sig", "cp1252", "latin1"):
|
||||||
|
try:
|
||||||
|
return path.read_text(encoding=enc).splitlines()
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
pass
|
||||||
|
raise UnicodeDecodeError("unknown", b"", 0, 1, f"无法读取 {path}")
|
||||||
|
|
||||||
|
def load_object_dictionary(path: Path) -> list[ObjectDef]:
|
||||||
|
out = []
|
||||||
|
for line in _read_lines(path):
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
parts = line.rstrip("\r\n").split("|")
|
||||||
|
if len(parts) < 3:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
object_id = int(parts[1].strip())
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
out.append(ObjectDef(
|
||||||
|
name=parts[0].strip(),
|
||||||
|
object_id=object_id,
|
||||||
|
geometry_code=parts[2].strip(),
|
||||||
|
display_code=parts[3].strip() if len(parts) > 3 else None,
|
||||||
|
description=parts[4].strip() if len(parts) > 4 else None,
|
||||||
|
raw_line=line,
|
||||||
|
))
|
||||||
|
return out
|
||||||
|
|
||||||
|
def load_attribute_dictionary(path: Path) -> list[AttributeDef]:
|
||||||
|
out = []
|
||||||
|
for line in _read_lines(path):
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
parts = [p.strip() for p in line.rstrip("\r\n").split("|")]
|
||||||
|
if len(parts) < 3:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
attribute_id = int(parts[1])
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
source_type = parts[2]
|
||||||
|
def f(i):
|
||||||
|
if len(parts) <= i or parts[i] == "":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(parts[i])
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
out.append(AttributeDef(
|
||||||
|
name=parts[0],
|
||||||
|
attribute_id=attribute_id,
|
||||||
|
source_type_name=source_type,
|
||||||
|
decoder_type=TYPE_MAP.get(source_type, "?"),
|
||||||
|
min_value=f(3),
|
||||||
|
max_value=f(4),
|
||||||
|
raw_line=line,
|
||||||
|
))
|
||||||
|
return out
|
||||||
870
src/cm93db/geometry.py
Normal file
870
src/cm93db/geometry.py
Normal file
@@ -0,0 +1,870 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import math
|
||||||
|
from typing import Iterable, Any
|
||||||
|
|
||||||
|
try:
|
||||||
|
from shapely import normalize as _shapely_normalize
|
||||||
|
except ImportError:
|
||||||
|
_shapely_normalize = None
|
||||||
|
|
||||||
|
from shapely.geometry import Point, LineString, MultiLineString, Polygon, MultiPolygon
|
||||||
|
from shapely.geometry.base import BaseGeometry
|
||||||
|
from shapely.ops import unary_union, polygonize_full
|
||||||
|
from shapely.validation import explain_validity
|
||||||
|
|
||||||
|
from .cm93_binary import ParsedCell, FeatureRecord
|
||||||
|
from .precision import CM93_SEMIMAJOR_M
|
||||||
|
|
||||||
|
|
||||||
|
# 已通过 CM93 实际数据审计的 Area normalization 规则:
|
||||||
|
# 1. valid Polygon/MultiPolygon 完全原样 PASS-THROUGH;
|
||||||
|
# 2. invalid Polygon/MultiPolygon 仅使用原始 boundary 做 noding + polygonize_full;
|
||||||
|
# 3. planar face 采用 odd-even parity 选择;
|
||||||
|
# 4. 只对修复后的 candidate 做 GEOS canonical normalize;
|
||||||
|
# 5. 不使用 make_valid() / buffer(0) 覆盖原始 CM93 语义;
|
||||||
|
# 6. raw_feature_blob / chart_raw_record 仍由 importer 原样保留。
|
||||||
|
_NORMALIZATION_BOUNDARY_TOLERANCE = 1e-10
|
||||||
|
_POINT_ON_SEGMENT_EPS = 1e-12
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CoverageInfo:
|
||||||
|
source_record_index: int
|
||||||
|
geom_unshifted: BaseGeometry
|
||||||
|
wgsox: float
|
||||||
|
wgsoy: float
|
||||||
|
compilation_scale: int | None
|
||||||
|
horizontal_datum: int | None
|
||||||
|
vertical_datum: int | None
|
||||||
|
attributes: dict[str, Any]
|
||||||
|
geometry_repaired: bool
|
||||||
|
geometry_issue: str | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BuiltFeature:
|
||||||
|
feature: FeatureRecord
|
||||||
|
geometry_type: str
|
||||||
|
geom: BaseGeometry | None
|
||||||
|
coverage_record_index: int | None
|
||||||
|
geometry_issue: str | None
|
||||||
|
geometry_repaired: bool
|
||||||
|
soundings: list[tuple[int, int, int, float, Point, int | None]]
|
||||||
|
attributes_map: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
def attr_map(feature: FeatureRecord) -> dict[str, Any]:
|
||||||
|
out: dict[str, Any] = {}
|
||||||
|
for a in feature.attributes:
|
||||||
|
if a.name in out:
|
||||||
|
prev = out[a.name]
|
||||||
|
if not isinstance(prev, list) or (prev and not isinstance(prev[0], (dict, list))):
|
||||||
|
out[a.name] = [prev, a.value]
|
||||||
|
else:
|
||||||
|
prev.append(a.value)
|
||||||
|
else:
|
||||||
|
out[a.name] = a.value
|
||||||
|
if feature.related_indices:
|
||||||
|
out["_cm93_related_indices"] = feature.related_indices
|
||||||
|
if feature.relation20_raw is not None:
|
||||||
|
out["_cm93_relation20_raw"] = feature.relation20_raw
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def raw_xy_to_mercator(
|
||||||
|
cell: ParsedCell,
|
||||||
|
x: int,
|
||||||
|
y: int,
|
||||||
|
wgsox: float = 0.0,
|
||||||
|
wgsoy: float = 0.0,
|
||||||
|
) -> tuple[float, float]:
|
||||||
|
h = cell.header
|
||||||
|
easting = x * h.x_rate + h.easting_min - wgsox
|
||||||
|
northing = y * h.y_rate + h.northing_min - wgsoy
|
||||||
|
return easting, northing
|
||||||
|
|
||||||
|
|
||||||
|
def mercator_to_lonlat(easting: float, northing: float) -> tuple[float, float]:
|
||||||
|
lon = math.degrees(easting / CM93_SEMIMAJOR_M)
|
||||||
|
lat = math.degrees(
|
||||||
|
2.0 * math.atan(math.exp(northing / CM93_SEMIMAJOR_M))
|
||||||
|
- math.pi / 2.0
|
||||||
|
)
|
||||||
|
# Normalize only after exact formula. This handles cells crossing the dateline.
|
||||||
|
while lon > 180.0:
|
||||||
|
lon -= 360.0
|
||||||
|
while lon < -180.0:
|
||||||
|
lon += 360.0
|
||||||
|
return lon, lat
|
||||||
|
|
||||||
|
|
||||||
|
def raw_xy_to_lonlat(
|
||||||
|
cell: ParsedCell,
|
||||||
|
x: int,
|
||||||
|
y: int,
|
||||||
|
wgsox: float = 0.0,
|
||||||
|
wgsoy: float = 0.0,
|
||||||
|
) -> tuple[float, float]:
|
||||||
|
return mercator_to_lonlat(
|
||||||
|
*raw_xy_to_mercator(cell, x, y, wgsox, wgsoy)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _oriented_edge_points(
|
||||||
|
cell: ParsedCell,
|
||||||
|
edge_idx: int,
|
||||||
|
usage: int,
|
||||||
|
) -> list[tuple[int, int]]:
|
||||||
|
pts = list(cell.edges[edge_idx].points)
|
||||||
|
if usage & 4:
|
||||||
|
pts.reverse()
|
||||||
|
return pts
|
||||||
|
|
||||||
|
|
||||||
|
def _append_joined(
|
||||||
|
dst: list[tuple[int, int]],
|
||||||
|
src: list[tuple[int, int]],
|
||||||
|
) -> None:
|
||||||
|
if not src:
|
||||||
|
return
|
||||||
|
if not dst:
|
||||||
|
dst.extend(src)
|
||||||
|
elif dst[-1] == src[0]:
|
||||||
|
dst.extend(src[1:])
|
||||||
|
else:
|
||||||
|
# Preserve discontinuity; audit will report it through geometry validity/issue.
|
||||||
|
dst.extend(src)
|
||||||
|
|
||||||
|
|
||||||
|
def raw_line_parts(
|
||||||
|
cell: ParsedCell,
|
||||||
|
feature: FeatureRecord,
|
||||||
|
) -> list[list[tuple[int, int]]]:
|
||||||
|
parts: list[list[tuple[int, int]]] = []
|
||||||
|
current: list[tuple[int, int]] = []
|
||||||
|
for edge_idx, usage in feature.geometry_refs:
|
||||||
|
seg = _oriented_edge_points(cell, edge_idx, usage)
|
||||||
|
if not seg:
|
||||||
|
continue
|
||||||
|
if not current:
|
||||||
|
current = list(seg)
|
||||||
|
elif current[-1] == seg[0]:
|
||||||
|
current.extend(seg[1:])
|
||||||
|
else:
|
||||||
|
if len(current) >= 2:
|
||||||
|
parts.append(current)
|
||||||
|
current = list(seg)
|
||||||
|
if len(current) >= 2:
|
||||||
|
parts.append(current)
|
||||||
|
return parts
|
||||||
|
|
||||||
|
|
||||||
|
def raw_line_points(
|
||||||
|
cell: ParsedCell,
|
||||||
|
feature: FeatureRecord,
|
||||||
|
) -> list[tuple[int, int]]:
|
||||||
|
# Compatibility helper for diagnostics. Disconnected parts are concatenated here;
|
||||||
|
# production geometry uses raw_line_parts() and preserves disconnection.
|
||||||
|
out: list[tuple[int, int]] = []
|
||||||
|
for part in raw_line_parts(cell, feature):
|
||||||
|
out.extend(part)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def raw_area_rings(
|
||||||
|
cell: ParsedCell,
|
||||||
|
feature: FeatureRecord,
|
||||||
|
) -> list[list[tuple[int, int]]]:
|
||||||
|
rings: list[list[tuple[int, int]]] = []
|
||||||
|
current: list[tuple[int, int]] = []
|
||||||
|
start: tuple[int, int] | None = None
|
||||||
|
for edge_idx, usage in feature.geometry_refs:
|
||||||
|
seg = _oriented_edge_points(cell, edge_idx, usage)
|
||||||
|
if not seg:
|
||||||
|
continue
|
||||||
|
if start is None:
|
||||||
|
start = seg[0]
|
||||||
|
_append_joined(current, seg)
|
||||||
|
if current and start is not None and current[-1] == start:
|
||||||
|
if len(current) >= 4:
|
||||||
|
rings.append(current)
|
||||||
|
current = []
|
||||||
|
start = None
|
||||||
|
if current:
|
||||||
|
# Keep an open ring visible to audit. Shapely Polygon will close it, so the issue
|
||||||
|
# is returned explicitly by the Area builder.
|
||||||
|
rings.append(current)
|
||||||
|
return rings
|
||||||
|
|
||||||
|
|
||||||
|
def _transform_points(
|
||||||
|
cell: ParsedCell,
|
||||||
|
pts: Iterable[tuple[int, int]],
|
||||||
|
wgsox: float,
|
||||||
|
wgsoy: float,
|
||||||
|
) -> list[tuple[float, float]]:
|
||||||
|
return [
|
||||||
|
raw_xy_to_lonlat(cell, x, y, wgsox, wgsoy)
|
||||||
|
for x, y in pts
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _area_from_rings(
|
||||||
|
coord_rings: list[list[tuple[float, float]]],
|
||||||
|
) -> BaseGeometry | None:
|
||||||
|
valid_rings = [r for r in coord_rings if len(r) >= 3]
|
||||||
|
if not valid_rings:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# CM93 edge chains are emitted as closed contours. Most features are one exterior
|
||||||
|
# followed by holes. If a later contour is not inside the current exterior, treat it
|
||||||
|
# as another exterior rather than forcing an invalid hole.
|
||||||
|
polygons: list[Polygon] = []
|
||||||
|
current_exterior = valid_rings[0]
|
||||||
|
holes: list[list[tuple[float, float]]] = []
|
||||||
|
exterior_poly = Polygon(current_exterior)
|
||||||
|
|
||||||
|
for r in valid_rings[1:]:
|
||||||
|
rp = Polygon(r)
|
||||||
|
try:
|
||||||
|
inside = exterior_poly.covers(rp.representative_point())
|
||||||
|
except Exception:
|
||||||
|
inside = False
|
||||||
|
|
||||||
|
if inside:
|
||||||
|
holes.append(r)
|
||||||
|
else:
|
||||||
|
polygons.append(Polygon(current_exterior, holes))
|
||||||
|
current_exterior = r
|
||||||
|
holes = []
|
||||||
|
exterior_poly = Polygon(current_exterior)
|
||||||
|
|
||||||
|
polygons.append(Polygon(current_exterior, holes))
|
||||||
|
|
||||||
|
if len(polygons) == 1:
|
||||||
|
return polygons[0]
|
||||||
|
return MultiPolygon(polygons)
|
||||||
|
|
||||||
|
|
||||||
|
def _geom_issue(
|
||||||
|
geom: BaseGeometry | None,
|
||||||
|
extra: str | None = None,
|
||||||
|
validate: bool = False,
|
||||||
|
) -> str | None:
|
||||||
|
parts = []
|
||||||
|
if extra:
|
||||||
|
parts.append(extra)
|
||||||
|
if geom is None:
|
||||||
|
parts.append("geometry missing")
|
||||||
|
elif geom.is_empty:
|
||||||
|
parts.append("geometry empty")
|
||||||
|
elif validate and not geom.is_valid:
|
||||||
|
parts.append(explain_validity(geom))
|
||||||
|
return "; ".join(parts) if parts else None
|
||||||
|
|
||||||
|
|
||||||
|
def _polygon_rings(
|
||||||
|
geom: BaseGeometry,
|
||||||
|
) -> list[list[tuple[float, float]]]:
|
||||||
|
rings: list[list[tuple[float, float]]] = []
|
||||||
|
|
||||||
|
def add_polygon(poly: Polygon) -> None:
|
||||||
|
rings.append([
|
||||||
|
(float(c[0]), float(c[1]))
|
||||||
|
for c in poly.exterior.coords
|
||||||
|
])
|
||||||
|
for ring in poly.interiors:
|
||||||
|
rings.append([
|
||||||
|
(float(c[0]), float(c[1]))
|
||||||
|
for c in ring.coords
|
||||||
|
])
|
||||||
|
|
||||||
|
if geom.geom_type == "Polygon":
|
||||||
|
add_polygon(geom)
|
||||||
|
elif geom.geom_type == "MultiPolygon":
|
||||||
|
for poly in geom.geoms:
|
||||||
|
add_polygon(poly)
|
||||||
|
|
||||||
|
return rings
|
||||||
|
|
||||||
|
|
||||||
|
def _point_on_segment(
|
||||||
|
px: float,
|
||||||
|
py: float,
|
||||||
|
ax: float,
|
||||||
|
ay: float,
|
||||||
|
bx: float,
|
||||||
|
by: float,
|
||||||
|
eps: float = _POINT_ON_SEGMENT_EPS,
|
||||||
|
) -> bool:
|
||||||
|
vx = bx - ax
|
||||||
|
vy = by - ay
|
||||||
|
wx = px - ax
|
||||||
|
wy = py - ay
|
||||||
|
|
||||||
|
cross_value = abs(vx * wy - vy * wx)
|
||||||
|
if cross_value > eps * max(1.0, abs(vx), abs(vy)):
|
||||||
|
return False
|
||||||
|
|
||||||
|
dot_value = wx * vx + wy * vy
|
||||||
|
if dot_value < -eps:
|
||||||
|
return False
|
||||||
|
|
||||||
|
vv = vx * vx + vy * vy
|
||||||
|
if dot_value - vv > eps:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _odd_even_inside(
|
||||||
|
point: tuple[float, float],
|
||||||
|
rings: list[list[tuple[float, float]]],
|
||||||
|
) -> bool:
|
||||||
|
x, y = point
|
||||||
|
crossings = 0
|
||||||
|
|
||||||
|
for ring in rings:
|
||||||
|
for i in range(len(ring) - 1):
|
||||||
|
x1, y1 = ring[i]
|
||||||
|
x2, y2 = ring[i + 1]
|
||||||
|
|
||||||
|
if _point_on_segment(x, y, x1, y1, x2, y2):
|
||||||
|
# polygonize face representative_point should not normally lie
|
||||||
|
# on the input boundary. Treat a numerical boundary hit as inside.
|
||||||
|
return True
|
||||||
|
|
||||||
|
if (y1 > y) != (y2 > y):
|
||||||
|
x_intersection = (
|
||||||
|
x1
|
||||||
|
+ (y - y1) * (x2 - x1) / (y2 - y1)
|
||||||
|
)
|
||||||
|
if x_intersection > x:
|
||||||
|
crossings += 1
|
||||||
|
|
||||||
|
return (crossings % 2) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def _canonicalize_polygonal(
|
||||||
|
geom: BaseGeometry,
|
||||||
|
) -> BaseGeometry:
|
||||||
|
if _shapely_normalize is not None:
|
||||||
|
return _shapely_normalize(geom)
|
||||||
|
|
||||||
|
normalizer = getattr(geom, "normalize", None)
|
||||||
|
if callable(normalizer):
|
||||||
|
normalized = normalizer()
|
||||||
|
if normalized is not None:
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
raise RuntimeError(
|
||||||
|
"CM93 Area normalization requires Shapely/GEOS normalize support"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_cm93_area(
|
||||||
|
geom: BaseGeometry | None,
|
||||||
|
structural_issue: str | None = None,
|
||||||
|
) -> tuple[BaseGeometry | None, bool, str | None]:
|
||||||
|
"""
|
||||||
|
Normalize an OGC-invalid CM93 Area using the audited CM93 fill semantics.
|
||||||
|
|
||||||
|
Valid geometry is returned byte/structure untouched.
|
||||||
|
Open/discontinuous source rings are not silently repaired.
|
||||||
|
"""
|
||||||
|
if geom is None:
|
||||||
|
return None, False, _geom_issue(None, structural_issue)
|
||||||
|
|
||||||
|
if geom.is_empty:
|
||||||
|
return geom, False, _geom_issue(geom, structural_issue)
|
||||||
|
|
||||||
|
if geom.geom_type not in ("Polygon", "MultiPolygon"):
|
||||||
|
return (
|
||||||
|
geom,
|
||||||
|
False,
|
||||||
|
f"area geometry has unexpected type {geom.geom_type}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Structural source problems are evidence, not retraced-connector semantics.
|
||||||
|
# Preserve them for audit instead of silently normalizing them away.
|
||||||
|
if structural_issue:
|
||||||
|
issue = _geom_issue(
|
||||||
|
geom,
|
||||||
|
structural_issue,
|
||||||
|
validate=True,
|
||||||
|
)
|
||||||
|
return geom, False, issue
|
||||||
|
|
||||||
|
# Critical compatibility rule: already-valid geometry stays exactly as built.
|
||||||
|
if geom.is_valid:
|
||||||
|
return geom, False, None
|
||||||
|
|
||||||
|
original_reason = explain_validity(geom)
|
||||||
|
|
||||||
|
try:
|
||||||
|
original_rings = _polygon_rings(geom)
|
||||||
|
noded_boundary = unary_union(geom.boundary)
|
||||||
|
polygonized, _cuts, _dangles, _invalid_rings = polygonize_full(
|
||||||
|
noded_boundary
|
||||||
|
)
|
||||||
|
|
||||||
|
if polygonized.geom_type == "Polygon":
|
||||||
|
faces = [polygonized]
|
||||||
|
elif polygonized.geom_type in ("MultiPolygon", "GeometryCollection"):
|
||||||
|
faces = [
|
||||||
|
g
|
||||||
|
for g in polygonized.geoms
|
||||||
|
if g.geom_type == "Polygon"
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
faces = []
|
||||||
|
|
||||||
|
selected: list[Polygon] = []
|
||||||
|
|
||||||
|
for face in faces:
|
||||||
|
rp = face.representative_point()
|
||||||
|
if _odd_even_inside(
|
||||||
|
(float(rp.x), float(rp.y)),
|
||||||
|
original_rings,
|
||||||
|
):
|
||||||
|
selected.append(face)
|
||||||
|
|
||||||
|
if not selected:
|
||||||
|
return (
|
||||||
|
geom,
|
||||||
|
False,
|
||||||
|
f"{original_reason}; odd-even normalization produced no polygon face",
|
||||||
|
)
|
||||||
|
|
||||||
|
candidate = unary_union(selected)
|
||||||
|
|
||||||
|
if candidate.geom_type not in ("Polygon", "MultiPolygon"):
|
||||||
|
return (
|
||||||
|
geom,
|
||||||
|
False,
|
||||||
|
f"{original_reason}; odd-even normalization produced {candidate.geom_type}",
|
||||||
|
)
|
||||||
|
|
||||||
|
if candidate.is_empty:
|
||||||
|
return (
|
||||||
|
geom,
|
||||||
|
False,
|
||||||
|
f"{original_reason}; odd-even normalization produced empty geometry",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not candidate.is_valid:
|
||||||
|
return (
|
||||||
|
geom,
|
||||||
|
False,
|
||||||
|
f"{original_reason}; normalized candidate invalid: "
|
||||||
|
f"{explain_validity(candidate)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Safety guard learned from the dry-run audit: normalization may remove
|
||||||
|
# retraced zero-area connector linework, but must never invent a new
|
||||||
|
# boundary outside the original CM93 line network.
|
||||||
|
candidate_extra_boundary = candidate.boundary.difference(
|
||||||
|
noded_boundary
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
not candidate_extra_boundary.is_empty
|
||||||
|
and float(candidate_extra_boundary.length)
|
||||||
|
> _NORMALIZATION_BOUNDARY_TOLERANCE
|
||||||
|
):
|
||||||
|
return (
|
||||||
|
geom,
|
||||||
|
False,
|
||||||
|
f"{original_reason}; normalized candidate introduces "
|
||||||
|
f"new boundary length={candidate_extra_boundary.length}",
|
||||||
|
)
|
||||||
|
|
||||||
|
candidate = _canonicalize_polygonal(candidate)
|
||||||
|
|
||||||
|
if candidate.geom_type not in ("Polygon", "MultiPolygon"):
|
||||||
|
return (
|
||||||
|
geom,
|
||||||
|
False,
|
||||||
|
f"{original_reason}; canonical candidate has type "
|
||||||
|
f"{candidate.geom_type}",
|
||||||
|
)
|
||||||
|
|
||||||
|
if candidate.is_empty or not candidate.is_valid:
|
||||||
|
return (
|
||||||
|
geom,
|
||||||
|
False,
|
||||||
|
f"{original_reason}; canonical candidate is not valid",
|
||||||
|
)
|
||||||
|
|
||||||
|
return candidate, True, None
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
return (
|
||||||
|
geom,
|
||||||
|
False,
|
||||||
|
f"{original_reason}; odd-even normalization failed: "
|
||||||
|
f"{type(exc).__name__}: {exc}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_unshifted_geometry(
|
||||||
|
cell: ParsedCell,
|
||||||
|
feature: FeatureRecord,
|
||||||
|
validate: bool = False,
|
||||||
|
) -> tuple[BaseGeometry | None, str | None]:
|
||||||
|
prim = feature.geometry_primitive
|
||||||
|
|
||||||
|
if prim == 1 and feature.point2d_index is not None:
|
||||||
|
x, y = cell.point2d[feature.point2d_index]
|
||||||
|
return Point(raw_xy_to_lonlat(cell, x, y)), None
|
||||||
|
|
||||||
|
if prim == 2:
|
||||||
|
parts = raw_line_parts(cell, feature)
|
||||||
|
if not parts:
|
||||||
|
return None, "line has fewer than 2 points"
|
||||||
|
coords = [
|
||||||
|
_transform_points(cell, p, 0.0, 0.0)
|
||||||
|
for p in parts
|
||||||
|
]
|
||||||
|
g = (
|
||||||
|
LineString(coords[0])
|
||||||
|
if len(coords) == 1
|
||||||
|
else MultiLineString(coords)
|
||||||
|
)
|
||||||
|
return g, _geom_issue(g, validate=validate)
|
||||||
|
|
||||||
|
if prim == 4:
|
||||||
|
raw_rings = raw_area_rings(cell, feature)
|
||||||
|
open_count = sum(
|
||||||
|
1
|
||||||
|
for r in raw_rings
|
||||||
|
if r and r[0] != r[-1]
|
||||||
|
)
|
||||||
|
rings = [
|
||||||
|
_transform_points(cell, r, 0.0, 0.0)
|
||||||
|
for r in raw_rings
|
||||||
|
]
|
||||||
|
g = _area_from_rings(rings)
|
||||||
|
extra = f"{open_count} open ring(s)" if open_count else None
|
||||||
|
return g, _geom_issue(g, extra, validate=validate)
|
||||||
|
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def build_coverages(cell: ParsedCell) -> list[CoverageInfo]:
|
||||||
|
out: list[CoverageInfo] = []
|
||||||
|
|
||||||
|
for f in cell.features:
|
||||||
|
if f.object_class != "_m_sor":
|
||||||
|
continue
|
||||||
|
|
||||||
|
geom, pre_issue = build_unshifted_geometry(
|
||||||
|
cell,
|
||||||
|
f,
|
||||||
|
validate=False,
|
||||||
|
)
|
||||||
|
if geom is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
raw_rings = raw_area_rings(cell, f) if f.geometry_primitive == 4 else []
|
||||||
|
open_count = sum(
|
||||||
|
1
|
||||||
|
for r in raw_rings
|
||||||
|
if r and r[0] != r[-1]
|
||||||
|
)
|
||||||
|
structural_issue = (
|
||||||
|
f"{open_count} open ring(s)"
|
||||||
|
if open_count
|
||||||
|
else pre_issue
|
||||||
|
)
|
||||||
|
|
||||||
|
if f.geometry_primitive == 4:
|
||||||
|
geom, repaired, issue = _normalize_cm93_area(
|
||||||
|
geom,
|
||||||
|
structural_issue=structural_issue,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
repaired = False
|
||||||
|
issue = _geom_issue(
|
||||||
|
geom,
|
||||||
|
pre_issue,
|
||||||
|
validate=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
if geom is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
a = attr_map(f)
|
||||||
|
|
||||||
|
def number(name: str, default=0.0):
|
||||||
|
v = a.get(name, default)
|
||||||
|
if isinstance(v, list):
|
||||||
|
v = v[0] if v else default
|
||||||
|
try:
|
||||||
|
return float(v)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return float(default)
|
||||||
|
|
||||||
|
def integer(name: str):
|
||||||
|
if name not in a:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(a[name])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
out.append(CoverageInfo(
|
||||||
|
source_record_index=f.index,
|
||||||
|
geom_unshifted=geom,
|
||||||
|
wgsox=number("_wgsox", 0.0),
|
||||||
|
wgsoy=number("_wgsoy", 0.0),
|
||||||
|
compilation_scale=integer("CSCALE"),
|
||||||
|
horizontal_datum=integer("HORDAT"),
|
||||||
|
vertical_datum=integer("VERDAT"),
|
||||||
|
attributes=a,
|
||||||
|
geometry_repaired=repaired,
|
||||||
|
geometry_issue=issue,
|
||||||
|
))
|
||||||
|
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def choose_coverage(
|
||||||
|
coverages: list[CoverageInfo],
|
||||||
|
geom_unshifted: BaseGeometry | None,
|
||||||
|
) -> CoverageInfo | None:
|
||||||
|
if not coverages:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if len(coverages) == 1:
|
||||||
|
return coverages[0]
|
||||||
|
|
||||||
|
if geom_unshifted is None or geom_unshifted.is_empty:
|
||||||
|
return coverages[0]
|
||||||
|
|
||||||
|
minx, miny, maxx, maxy = geom_unshifted.bounds
|
||||||
|
probe = Point(
|
||||||
|
(minx + maxx) / 2.0,
|
||||||
|
(miny + maxy) / 2.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
for cov in coverages:
|
||||||
|
try:
|
||||||
|
if cov.geom_unshifted.covers(probe):
|
||||||
|
return cov
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
return coverages[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _depth(raw_z: int) -> float:
|
||||||
|
return (
|
||||||
|
float(raw_z - 12000)
|
||||||
|
if raw_z >= 12000
|
||||||
|
else raw_z / 10.0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_feature(
|
||||||
|
cell: ParsedCell,
|
||||||
|
feature: FeatureRecord,
|
||||||
|
coverages: list[CoverageInfo],
|
||||||
|
validate: bool = False,
|
||||||
|
) -> BuiltFeature:
|
||||||
|
amap = attr_map(feature)
|
||||||
|
prim = feature.geometry_primitive
|
||||||
|
|
||||||
|
# _m_sor is stored spatially in chart_coverage. Reuse the already-normalized
|
||||||
|
# coverage object here so chart_feature bbox/repair metadata matches it.
|
||||||
|
if feature.object_class == "_m_sor":
|
||||||
|
cov = next(
|
||||||
|
(
|
||||||
|
c
|
||||||
|
for c in coverages
|
||||||
|
if c.source_record_index == feature.index
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if cov is not None:
|
||||||
|
return BuiltFeature(
|
||||||
|
feature=feature,
|
||||||
|
geometry_type="META",
|
||||||
|
geom=cov.geom_unshifted,
|
||||||
|
coverage_record_index=feature.index,
|
||||||
|
geometry_issue=cov.geometry_issue,
|
||||||
|
geometry_repaired=cov.geometry_repaired,
|
||||||
|
soundings=[],
|
||||||
|
attributes_map=amap,
|
||||||
|
)
|
||||||
|
|
||||||
|
g, issue = build_unshifted_geometry(
|
||||||
|
cell,
|
||||||
|
feature,
|
||||||
|
validate=validate,
|
||||||
|
)
|
||||||
|
return BuiltFeature(
|
||||||
|
feature=feature,
|
||||||
|
geometry_type="META",
|
||||||
|
geom=g,
|
||||||
|
coverage_record_index=feature.index,
|
||||||
|
geometry_issue=issue,
|
||||||
|
geometry_repaired=False,
|
||||||
|
soundings=[],
|
||||||
|
attributes_map=amap,
|
||||||
|
)
|
||||||
|
|
||||||
|
# CM93 raw geotype 16 is a parent/no-geometry object. It is expected to
|
||||||
|
# have no own Point/Line/Area and must not be counted as geometry invalid.
|
||||||
|
if feature.geotype == 16:
|
||||||
|
return BuiltFeature(
|
||||||
|
feature=feature,
|
||||||
|
geometry_type="META",
|
||||||
|
geom=None,
|
||||||
|
coverage_record_index=None,
|
||||||
|
geometry_issue=None,
|
||||||
|
geometry_repaired=False,
|
||||||
|
soundings=[],
|
||||||
|
attributes_map=amap,
|
||||||
|
)
|
||||||
|
|
||||||
|
if prim == 8 and feature.point3d_index is not None:
|
||||||
|
desc = cell.point3d[feature.point3d_index]
|
||||||
|
soundings = []
|
||||||
|
|
||||||
|
for x, y, z in desc.points:
|
||||||
|
unshifted = Point(raw_xy_to_lonlat(cell, x, y))
|
||||||
|
cov = choose_coverage(coverages, unshifted)
|
||||||
|
wx = cov.wgsox if cov else 0.0
|
||||||
|
wy = cov.wgsoy if cov else 0.0
|
||||||
|
p = Point(
|
||||||
|
raw_xy_to_lonlat(
|
||||||
|
cell,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
wx,
|
||||||
|
wy,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
soundings.append((
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
z,
|
||||||
|
_depth(z),
|
||||||
|
p,
|
||||||
|
cov.source_record_index if cov else None,
|
||||||
|
))
|
||||||
|
|
||||||
|
return BuiltFeature(
|
||||||
|
feature=feature,
|
||||||
|
geometry_type="SOUNDING",
|
||||||
|
geom=None,
|
||||||
|
coverage_record_index=None,
|
||||||
|
geometry_issue=None,
|
||||||
|
geometry_repaired=False,
|
||||||
|
soundings=soundings,
|
||||||
|
attributes_map=amap,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Unshifted geometry is used only to choose the WGS offset coverage.
|
||||||
|
# Do not let optional validation of this intermediate copy create a
|
||||||
|
# duplicate Area issue; final shifted Area is normalized below.
|
||||||
|
unshifted, pre_issue = build_unshifted_geometry(
|
||||||
|
cell,
|
||||||
|
feature,
|
||||||
|
validate=False,
|
||||||
|
)
|
||||||
|
cov = choose_coverage(coverages, unshifted)
|
||||||
|
wx = cov.wgsox if cov else 0.0
|
||||||
|
wy = cov.wgsoy if cov else 0.0
|
||||||
|
|
||||||
|
geometry_repaired = False
|
||||||
|
|
||||||
|
if prim == 1 and feature.point2d_index is not None:
|
||||||
|
x, y = cell.point2d[feature.point2d_index]
|
||||||
|
geom = Point(
|
||||||
|
raw_xy_to_lonlat(
|
||||||
|
cell,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
wx,
|
||||||
|
wy,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
gtype = "POINT"
|
||||||
|
issue = _geom_issue(
|
||||||
|
geom,
|
||||||
|
pre_issue,
|
||||||
|
validate=validate,
|
||||||
|
)
|
||||||
|
|
||||||
|
elif prim == 2:
|
||||||
|
parts = raw_line_parts(cell, feature)
|
||||||
|
coords = [
|
||||||
|
_transform_points(cell, p, wx, wy)
|
||||||
|
for p in parts
|
||||||
|
]
|
||||||
|
geom = (
|
||||||
|
(
|
||||||
|
LineString(coords[0])
|
||||||
|
if len(coords) == 1
|
||||||
|
else MultiLineString(coords)
|
||||||
|
)
|
||||||
|
if coords
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
gtype = "LINE"
|
||||||
|
issue = _geom_issue(
|
||||||
|
geom,
|
||||||
|
pre_issue,
|
||||||
|
validate=validate,
|
||||||
|
)
|
||||||
|
|
||||||
|
elif prim == 4:
|
||||||
|
raw_rings = raw_area_rings(cell, feature)
|
||||||
|
open_count = sum(
|
||||||
|
1
|
||||||
|
for r in raw_rings
|
||||||
|
if r and r[0] != r[-1]
|
||||||
|
)
|
||||||
|
rings = [
|
||||||
|
_transform_points(cell, r, wx, wy)
|
||||||
|
for r in raw_rings
|
||||||
|
]
|
||||||
|
raw_geom = _area_from_rings(rings)
|
||||||
|
structural_issue = (
|
||||||
|
f"{open_count} open ring(s)"
|
||||||
|
if open_count
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
geom, geometry_repaired, issue = _normalize_cm93_area(
|
||||||
|
raw_geom,
|
||||||
|
structural_issue=structural_issue,
|
||||||
|
)
|
||||||
|
gtype = "AREA"
|
||||||
|
|
||||||
|
else:
|
||||||
|
geom = None
|
||||||
|
gtype = "META" if prim == 0 else "UNKNOWN"
|
||||||
|
issue = _geom_issue(
|
||||||
|
geom,
|
||||||
|
pre_issue,
|
||||||
|
validate=validate,
|
||||||
|
)
|
||||||
|
|
||||||
|
return BuiltFeature(
|
||||||
|
feature=feature,
|
||||||
|
geometry_type=gtype,
|
||||||
|
geom=geom,
|
||||||
|
coverage_record_index=(
|
||||||
|
cov.source_record_index
|
||||||
|
if cov
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
geometry_issue=issue,
|
||||||
|
geometry_repaired=geometry_repaired,
|
||||||
|
soundings=[],
|
||||||
|
attributes_map=amap,
|
||||||
|
)
|
||||||
1104
src/cm93db/importer.py
Normal file
1104
src/cm93db/importer.py
Normal file
File diff suppressed because it is too large
Load Diff
39
src/cm93db/precision.py
Normal file
39
src/cm93db/precision.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
import math
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
CM93_COORD_MAX = 65535.0
|
||||||
|
CM93_SEMIMAJOR_M = 6378388.0
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CellTransform:
|
||||||
|
easting_min: float
|
||||||
|
northing_min: float
|
||||||
|
easting_max: float
|
||||||
|
northing_max: float
|
||||||
|
|
||||||
|
@property
|
||||||
|
def x_rate(self) -> float:
|
||||||
|
delta=self.easting_max-self.easting_min
|
||||||
|
if delta < 0:
|
||||||
|
delta += CM93_SEMIMAJOR_M * 2.0 * math.pi
|
||||||
|
return delta / CM93_COORD_MAX
|
||||||
|
|
||||||
|
@property
|
||||||
|
def y_rate(self) -> float:
|
||||||
|
return (self.northing_max - self.northing_min) / CM93_COORD_MAX
|
||||||
|
|
||||||
|
def raw_xy_to_cm93_mercator(raw_x: int, raw_y: int, t: CellTransform) -> tuple[float, float]:
|
||||||
|
if not 0 <= raw_x <= 65535 or not 0 <= raw_y <= 65535:
|
||||||
|
raise ValueError("CM93 raw x/y 必须在 0..65535")
|
||||||
|
return float(raw_x*t.x_rate+t.easting_min), float(raw_y*t.y_rate+t.northing_min)
|
||||||
|
|
||||||
|
def apply_mercator_offset(easting: float, northing: float, wgsox: float, wgsoy: float) -> tuple[float, float]:
|
||||||
|
# CM93 coverage attributes are metre offsets. CM93 Transform applies them by
|
||||||
|
# subtraction before the Mercator→lon/lat conversion.
|
||||||
|
return easting-wgsox, northing-wgsoy
|
||||||
|
|
||||||
|
def cm93_mercator_to_lonlat(easting: float, northing: float) -> tuple[float, float]:
|
||||||
|
lon=math.degrees(easting/CM93_SEMIMAJOR_M)
|
||||||
|
lat=math.degrees(2.0*math.atan(math.exp(northing/CM93_SEMIMAJOR_M))-math.pi/2.0)
|
||||||
|
return lon,lat
|
||||||
59
src/cm93db/scanner.py
Normal file
59
src/cm93db/scanner.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
SCALE_NATIVE = {
|
||||||
|
"Z": 20_000_000,
|
||||||
|
"A": 3_000_000,
|
||||||
|
"B": 1_000_000,
|
||||||
|
"C": 200_000,
|
||||||
|
"D": 100_000,
|
||||||
|
"E": 50_000,
|
||||||
|
"F": 20_000,
|
||||||
|
"G": 7_500,
|
||||||
|
}
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CellFile:
|
||||||
|
path: Path
|
||||||
|
relative_path: Path
|
||||||
|
scale_code: str
|
||||||
|
native_scale: int
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ScanResult:
|
||||||
|
root: Path
|
||||||
|
cells: tuple[CellFile, ...]
|
||||||
|
object_dictionary: Path | None
|
||||||
|
attribute_dictionary: Path | None
|
||||||
|
|
||||||
|
def _find_case_insensitive(root: Path, names: set[str]) -> Path | None:
|
||||||
|
lowers = {n.lower() for n in names}
|
||||||
|
for p in root.rglob("*"):
|
||||||
|
if p.is_file() and p.name.lower() in lowers:
|
||||||
|
return p
|
||||||
|
return None
|
||||||
|
|
||||||
|
def scan_cm93(root: Path) -> ScanResult:
|
||||||
|
root = root.resolve()
|
||||||
|
if not root.exists():
|
||||||
|
raise FileNotFoundError(root)
|
||||||
|
cells = []
|
||||||
|
for p in root.rglob("*"):
|
||||||
|
if not p.is_file():
|
||||||
|
continue
|
||||||
|
ext = p.suffix.upper().lstrip(".")
|
||||||
|
if ext in SCALE_NATIVE:
|
||||||
|
cells.append(
|
||||||
|
CellFile(
|
||||||
|
path=p,
|
||||||
|
relative_path=p.relative_to(root),
|
||||||
|
scale_code=ext,
|
||||||
|
native_scale=SCALE_NATIVE[ext],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
cells.sort(key=lambda c: (c.scale_code, str(c.relative_path).lower()))
|
||||||
|
obj = _find_case_insensitive(root, {"CM93OBJ.DIC"})
|
||||||
|
attr = _find_case_insensitive(root, {"ATTRLUT.DIC", "CM93ATTR.DIC"})
|
||||||
|
return ScanResult(root, tuple(cells), obj, attr)
|
||||||
22
src/cm93db/schema_contract.py
Normal file
22
src/cm93db/schema_contract.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
SCHEMA_VERSION = 1
|
||||||
|
TARGET_MYSQL_MAJOR = 5
|
||||||
|
TARGET_MYSQL_MINOR = 7
|
||||||
|
TARGET_MYSQL_PATCH = 34
|
||||||
|
NORMALIZED_SRID = 4326
|
||||||
|
|
||||||
|
REQUIRED_TABLES = {
|
||||||
|
"chart_dataset",
|
||||||
|
"chart_dictionary_object",
|
||||||
|
"chart_dictionary_attribute",
|
||||||
|
"chart_cell",
|
||||||
|
"chart_coverage",
|
||||||
|
"chart_feature",
|
||||||
|
"chart_feature_attribute",
|
||||||
|
"chart_point_geometry",
|
||||||
|
"chart_line_geometry",
|
||||||
|
"chart_area_geometry",
|
||||||
|
"chart_sounding_point",
|
||||||
|
"chart_raw_record",
|
||||||
|
"chart_import_job",
|
||||||
|
"chart_import_event",
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user