feat: separate S-52 mariner runtime presentation
This commit is contained in:
@@ -82,6 +82,8 @@ rules:
|
||||
style_fields:
|
||||
least_depth_m:
|
||||
from: VALDCO
|
||||
contour_depth_m:
|
||||
from: VALDCO
|
||||
chart_text_style:
|
||||
const: depth_text
|
||||
chart_label_text:
|
||||
|
||||
228
docs/CM95_S52_NavSea运行时呈现契约_v1.md
Normal file
228
docs/CM95_S52_NavSea运行时呈现契约_v1.md
Normal file
@@ -0,0 +1,228 @@
|
||||
# CM95 S-52 NavSea 运行时呈现契约 v1
|
||||
|
||||
## 1. 目标
|
||||
|
||||
本契约定义 NavSea 如何消费 CM95 Delivery PBF 中的 S-52 海图事实,并在用户修改水深、安全等深线或颜色设置后立即重新呈现,而不重新生成、下载或替换 PBF。
|
||||
|
||||
本契约只规定数据与算法责任边界,不在 CM95 Server 中实现 NavSea UI,也不把 NavSea Store/业务生命周期引入 PBF Builder。
|
||||
|
||||
## 2. 总体责任
|
||||
|
||||
### CM95/PBF 负责
|
||||
|
||||
- 几何;
|
||||
- 原始水深事实的规范化数值;
|
||||
- Lookup identity 与静态 S-52 Presentation 提示;
|
||||
- 与用户设置无关的属性派生;
|
||||
- runtime CSP 所需的最小事实;
|
||||
- 不可从单个 Feature 自身恢复的必要空间上下文摘要。
|
||||
|
||||
### NavSea Runtime 负责
|
||||
|
||||
- 当前用户的水深颜色设置;
|
||||
- `shallowContour / safetyContour / safetyDepth / deepContour`;
|
||||
- `twoShades`;
|
||||
- safety contour 实际选择;
|
||||
- SOUNDG 深/浅字形;
|
||||
- OBSTRN/WRECKS isolated danger;
|
||||
- isolated danger 引起的 DisplayBase / priority promotion;
|
||||
- 设置变化后的即时重算和 MapLibre style 更新。
|
||||
|
||||
## 3. Runtime Settings
|
||||
|
||||
建议 NavSea 运行时形成一个统一的只读快照,例如:
|
||||
|
||||
```text
|
||||
S52MarinerRuntimeSettings
|
||||
shallowContourM
|
||||
safetyContourM
|
||||
safetyDepthM
|
||||
deepContourM
|
||||
twoShades
|
||||
depthColorRules
|
||||
```
|
||||
|
||||
`depthColorRules` 属于 NavSea 现有的用户自定义显示能力,可以独立于 S-52 四个标准深度阈值。服务器不得把用户颜色写入 PBF。
|
||||
|
||||
一次重算必须使用同一个 settings snapshot,避免 DEPARE、DEPCNT、SOUNDG 与 hazard 在同一帧使用不同设置。
|
||||
|
||||
## 4. PBF Runtime 字段
|
||||
|
||||
### DEPARE / DRGARE
|
||||
|
||||
主要字段:
|
||||
|
||||
- `depth_min_m`
|
||||
- `depth_max_m`
|
||||
- `s52_runtime_csp = DEPARE01 | DEPARE02`
|
||||
|
||||
`chart_fill_style` 仅作为当前 NavSea 既有 style 的兼容语义,不应覆盖精确 `depth_min_m/depth_max_m` 的事实地位。
|
||||
|
||||
### DEPCNT
|
||||
|
||||
主要字段:
|
||||
|
||||
- `contour_depth_m`
|
||||
- `least_depth_m`(兼容)
|
||||
- `s52_runtime_csp = DEPCNT02`
|
||||
- `s52_quapos_code`(有值时)
|
||||
|
||||
### SOUNDG
|
||||
|
||||
主要字段:
|
||||
|
||||
- `depth_value_m`
|
||||
- `s52_sounding_depth_m`
|
||||
- `s52_sounding_swept`
|
||||
- `s52_sounding_low_quality`
|
||||
- `s52_runtime_csp = SOUNDG02`
|
||||
|
||||
### OBSTRN / UWTROC / WRECKS
|
||||
|
||||
主要字段:
|
||||
|
||||
- `s52_hazard_depth_m`
|
||||
- `s52_surrounding_max_drval1_m`
|
||||
- `s52_surrounding_min_drval2_m`
|
||||
- `s52_watlev_code`
|
||||
- `s52_expsou_code`
|
||||
- `s52_catobs_code`
|
||||
- `s52_catwrk_code`
|
||||
- `s52_quapos_code`
|
||||
- `s52_quasou_codes`
|
||||
- `s52_tecsou_codes`
|
||||
- `s52_status_codes`
|
||||
- `s52_runtime_csp = OBSTRN04 | WRECKS02`
|
||||
|
||||
数组类 code 在 Delivery MVT 中以 JSON 字符串保存,NavSea Adapter 负责一次标准化解析,不由 MapLibre style 直接解析 JSON。
|
||||
|
||||
## 5. DEPARE 运行时规则
|
||||
|
||||
NavSea 可以有两层规则:
|
||||
|
||||
1. 用户自定义 `depthColorRules` 优先决定颜色;
|
||||
2. 未启用自定义颜色时,可按 S-52 `DEPARE01/02` 的 shallow/safety/deep/twoShades 规则得到标准颜色角色。
|
||||
|
||||
颜色计算以 PBF 的精确 `depth_min_m/depth_max_m` 为输入。用户修改阈值只改变已有 DEPARE polygon 的显示,不生成原海图不存在的新等深边界,也不在客户端重新切割 polygon。
|
||||
|
||||
因此,例如原始海图区间为 `5–10m`,用户将颜色阈值设为 `7m`,NavSea 可以按规则重新给整个 `5–10m` 区域着色,但不能假装原数据中存在一条精确的 `7m` 面边界。
|
||||
|
||||
## 6. DEPCNT 与 safety contour
|
||||
|
||||
`contour_depth_m` 是事实;`s52_is_safety_contour` 是运行时结果。
|
||||
|
||||
规则语义保持:
|
||||
|
||||
- 若存在与 `safetyContourM` 相等的实际等深线,选该深度;
|
||||
- 否则选择下一条更深的实际等深线;
|
||||
- 不允许服务器把默认 `3m` 的选择永久写入 runtime PBF。
|
||||
|
||||
这里不能只靠单个 Feature 的 MapLibre expression 完成“下一条更深”判断,因为它需要知道可用 contour depth 集合。
|
||||
|
||||
NavSea Runtime 因此需要维护稳定的 `ContourCatalog`(按当前有效海图/尺度上下文的已知等深值排序),先计算:
|
||||
|
||||
```text
|
||||
effectiveSafetyContourM = min(depth >= safetyContourM)
|
||||
```
|
||||
|
||||
再把该值注入 DEPCNT style expression。
|
||||
|
||||
若当前没有足够的 ContourCatalog 信息,必须 fail-closed:不错误突出某一条线为 safety contour。后续若 Runtime 收集 catalog 的成本过高,可在 PBF 契约增加相邻等深值元数据,但 v1 不先制造新的冗余字段。
|
||||
|
||||
## 7. SOUNDG 运行时规则
|
||||
|
||||
SOUNDG 的数值深度、扫测状态和质量状态是事实;`SOUNDS* / SOUNDG*` 前缀及最终 symbol sequence 属于运行时结果。
|
||||
|
||||
核心规则:
|
||||
|
||||
```text
|
||||
depth <= safetyDepthM -> shallow sounding family
|
||||
otherwise -> normal sounding family
|
||||
```
|
||||
|
||||
随后根据 swept / low-quality 和数值位数生成最终 S-52 symbol sequence。
|
||||
|
||||
用户修改 `safetyDepthM` 后只需重新计算当前可见 SOUNDG 的 presentation,不重新加载 PBF。
|
||||
|
||||
## 8. Hazard / isolated danger 运行时规则
|
||||
|
||||
OBSTRN/WRECKS 必须根据同一个 `safetyContourM` settings snapshot 重新执行对应 CSP。
|
||||
|
||||
PBF 中的 hazard depth、WATLEV、EXPSOU、QUASOU 等是输入事实;以下均为运行时输出,不能直接信任旧 tile 中的固定值:
|
||||
|
||||
- isolated danger yes/no;
|
||||
- `ISODGR51` 等最终符号;
|
||||
- DisplayBase promotion;
|
||||
- Hazards priority promotion;
|
||||
- hazard 内附带 sounding 的 shallow/normal 字形。
|
||||
|
||||
当前 PBF 已将相交 DEPARE 的必要深度摘要一起交付,避免 NavSea 为每个 hazard 再做昂贵的 polygon 空间相交。
|
||||
|
||||
## 9. 静态 Presentation
|
||||
|
||||
以下不依赖水深 mariner settings 的属性仍可直接消费 PBF 物化结果:
|
||||
|
||||
- LIGHTS05 静态结果;
|
||||
- TOPMAR01;
|
||||
- 静态 TX/TE;
|
||||
- 静态 SY/LS/LC/AP/AC/CA;
|
||||
- `s52_symbol_sequence` / `s52_line_sequence`;
|
||||
- `s52_symbol_rotation_deg`;
|
||||
- 静态 `OP` 已应用后的 `s52_display_priority`。
|
||||
|
||||
NavSea 不应为这些规则重复实现一套与 Server 不同的 Lookup 解释器。
|
||||
|
||||
## 10. MapLibre Adapter 边界
|
||||
|
||||
建议 NavSea 增加一个纯 Presentation Adapter,而不是把判断分散到各个 UI/Layer:
|
||||
|
||||
```text
|
||||
PBF feature properties
|
||||
+
|
||||
S52MarinerRuntimeSettings snapshot
|
||||
+
|
||||
ContourCatalog
|
||||
↓
|
||||
S52RuntimePresentationAdapter
|
||||
↓
|
||||
normalized render properties / style expressions
|
||||
↓
|
||||
MapLibre
|
||||
```
|
||||
|
||||
Adapter 不获取网络、不修改用户设置、不拥有 PBF 下载生命周期,只做确定性转换。
|
||||
|
||||
## 11. 设置变化
|
||||
|
||||
用户修改任何 mariner setting 后:
|
||||
|
||||
1. 形成新的 settings snapshot;
|
||||
2. 更新 effective safety contour;
|
||||
3. 重新生成/更新 DEPARE、DEPCNT、SOUNDG、hazard 对应 style/runtime properties;
|
||||
4. 请求 MapLibre repaint;
|
||||
5. 不清 tile cache,不重新下载 PBF,不重新生成 Server 数据。
|
||||
|
||||
禁止继续显示基于旧 settings 推导的 safety contour / isolated danger / sounding shallow 状态。
|
||||
|
||||
## 12. Fail-closed
|
||||
|
||||
- 缺少精确水深事实时,不凭颜色或旧派生字段反推水深;
|
||||
- 缺少 ContourCatalog 时,不伪造 safety contour;
|
||||
- hazard 所需事实不足时,保留普通危险物显示,但不错误声明 isolated danger;
|
||||
- JSON code 字段解析失败时,视为空集合并记录诊断;
|
||||
- 运行时设置无效时回退 NavSea 明确定义的默认 settings snapshot,而不是使用 PBF 内历史派生值。
|
||||
|
||||
## 13. v1 暂未纳入
|
||||
|
||||
`Plain / Symbolized Boundaries` 会改变 Area Lookup Table 选择,尚未完全纳入上述 runtime Adapter。第一版继续沿用当前 boundary table 编译路径;待 NavSea/Style Compiler 能同时持有 Plain 与 Symbolized 两套 Area rule 后再统一运行时切换。
|
||||
|
||||
## 14. 接入门槛
|
||||
|
||||
NavSea 正式消费 runtime PBF 前至少验证:
|
||||
|
||||
- 修改水深颜色不触发 PBF 网络请求;
|
||||
- 修改 safety depth 后 SOUNDG 即时变化;
|
||||
- 修改 safety contour 后 DEPCNT 和 hazard 使用同一个新值;
|
||||
- runtime PBF 中不存在旧固定 `s52_is_safety_contour` / `s52_isolated_danger` 等结果;
|
||||
- reference PBF 仍可用于 OpenCPN 对照;
|
||||
- 同一设置、同一 Feature 输入必须得到确定性相同输出。
|
||||
140
docs/CM95_S52_静态物化与运行时呈现边界_v1.md
Normal file
140
docs/CM95_S52_静态物化与运行时呈现边界_v1.md
Normal file
@@ -0,0 +1,140 @@
|
||||
# CM95 S-52 静态物化与运行时呈现边界 v1
|
||||
|
||||
## 1. 目的
|
||||
|
||||
本文件冻结 CM95 → PBF → NavSea 链路中 S-52 Presentation 的责任边界,避免把船员可修改的显示参数提前固化进全国 PBF。
|
||||
|
||||
核心原则:PBF 保存海图事实、稳定语义和与船员设置无关的静态 Presentation 提示;NavSea Runtime 负责随用户设置变化的 Presentation 结果。
|
||||
|
||||
`S52MarinerSettings` 继续保留,但只作为 OpenCPN/S-52 对照渲染的固定参考配置,不再作为 NavSea Delivery PBF 的唯一真值。
|
||||
|
||||
## 2. 两种模式
|
||||
|
||||
### 2.1 runtime
|
||||
|
||||
`runtime` 是 NavSea Delivery 的默认方向。
|
||||
|
||||
对于依赖船员水深设置的 CSP,不在 PBF 构建阶段决定最终颜色、安全等深线、浅深测深字形或 isolated danger 最终状态,而是保留 CSP 名称和运行时所需事实字段。
|
||||
|
||||
当前 v1 延迟到 Runtime 的 CSP:
|
||||
|
||||
- `DEPARE01`
|
||||
- `DEPARE02`
|
||||
- `DEPCNT02`
|
||||
- `SOUNDG02`
|
||||
- `OBSTRN04`
|
||||
- `WRECKS02`
|
||||
|
||||
### 2.2 reference
|
||||
|
||||
`reference` 用于 OpenCPN/S-52 对照、PoC 和回归验证。
|
||||
|
||||
该模式继续使用固定 `S52MarinerSettings`:
|
||||
|
||||
- `shallow_contour_m = 2.0`
|
||||
- `safety_contour_m = 3.0`
|
||||
- `safety_depth_m = 3.0`
|
||||
- `deep_contour_m = 6.0`
|
||||
- `two_shades = false`
|
||||
|
||||
只有该模式允许把上述参数推导出的最终 CSP 结果物化到 PBF,用于参考画面对照;这些结果不得被当作 NavSea 用户运行时的永久真值。
|
||||
|
||||
## 3. PBF 中必须保留的事实
|
||||
|
||||
水深相关事实至少包括:
|
||||
|
||||
- `DEPARE/DRGARE`:`depth_min_m`、`depth_max_m`
|
||||
- `DEPCNT`:`contour_depth_m`,同时兼容现有 `least_depth_m`
|
||||
- `SOUNDG`:`depth_value_m` / `s52_sounding_depth_m`
|
||||
- 危险物:`s52_hazard_depth_m`
|
||||
- 与 isolated danger 判断相关的 `WATLEV`、`EXPSOU`、`CATOBS`、`CATWRK`、`QUAPOS`、`QUASOU`、`TECSOU`、`STATUS` 归一化运行时字段
|
||||
- 相交水深区摘要:`s52_surrounding_max_drval1_m`、`s52_surrounding_min_drval2_m`
|
||||
|
||||
运行时 CSP 通过:
|
||||
|
||||
- `s52_mariner_dependent = 1`
|
||||
- `s52_runtime_csp = <CSP>`
|
||||
- 未解析的 `s52_csp`
|
||||
|
||||
明确标识。
|
||||
|
||||
## 4. 不得在 runtime PBF 中固定的结果
|
||||
|
||||
以下结果依赖用户/船员设置,不得作为 runtime PBF 的永久真值:
|
||||
|
||||
- DEPARE 最终浅水/中水/深水颜色
|
||||
- `s52_is_safety_contour`
|
||||
- `s52_sounding_prefix`
|
||||
- `s52_sounding_is_shallow`
|
||||
- 由 safety contour 决定的 `s52_isolated_danger`
|
||||
- 由 isolated danger 引起的 `DisplayBase` promotion
|
||||
- 由 safety depth/contour 决定的危险物最终符号
|
||||
|
||||
`two_shades` 同样属于运行时设置,由 DEPARE Runtime Presentation 处理。
|
||||
|
||||
## 5. 仍可静态物化的内容
|
||||
|
||||
与船员设置无关、只依赖对象属性和固定 S-52 规则的内容继续在服务器物化,例如:
|
||||
|
||||
- LIGHTS05 的灯色、sector 几何、灯质文本和静态符号提示
|
||||
- TOPMAR01 在已确定 floating/rigid 上下文后的符号
|
||||
- QUAPOS01、SLCONS03、RESTRN01 等与船员水深阈值无关的 CSP
|
||||
- 静态 `TX/TE`
|
||||
- 静态 `SY/LS/LC/AP/AC/CA`
|
||||
- Lookup 自身的 Display Category / Display Priority,除非 CSP 在运行时明确覆盖
|
||||
|
||||
## 6. 对四个待修问题的重新分类
|
||||
|
||||
### 6.1 DisplayBase promotion
|
||||
|
||||
`UDWHAZ03` 导致的 DisplayBase promotion 属于 mariner-dependent runtime presentation。
|
||||
|
||||
因此 runtime PBF 不应把当前固定 safety contour 推导出的 `s52_display_base_promoted=1` 写成永久结果。reference 模式仍可保留该行为用于对照。
|
||||
|
||||
### 6.2 OP priority override
|
||||
|
||||
`OP(...)` 本身属于 Presentation Rule IR。
|
||||
|
||||
- 静态 Lookup/静态 CSP 中的 `OP`:服务器可物化并正确覆盖 `s52_display_priority`
|
||||
- mariner-dependent CSP 中的 `OP`:必须随 Runtime CSP 一起延迟执行
|
||||
|
||||
### 6.3 多个 SY/LS
|
||||
|
||||
多个 `SY`、多个 `LS` 属于静态规则序列保真问题,与船员水深设置无关,下一阶段应完整保留顺序,不能只取第一条。
|
||||
|
||||
### 6.4 SY ORIENT
|
||||
|
||||
通用 `SY(symbol, ORIENT)` 的旋转角属于对象/规则事实,不属于船员水深设置;下一阶段应加入 PBF Presentation Contract,并保留属性引用解析后的角度。
|
||||
|
||||
## 7. Symbolized Boundaries
|
||||
|
||||
`Plain / Symbolized Boundaries` 本质上也是 mariner presentation 设置,但它会影响 Area Lookup Table 本身,不只是单个 CSP 的输出。
|
||||
|
||||
因此 v1 不把它伪装成已完全 runtime 化:
|
||||
|
||||
- 当前 `runtime` 模式先解决水深阈值、测深、危险物等深度相关动态结果;
|
||||
- `s52_boundary_table` 暂时仍用于参考/编译路径;
|
||||
- 后续由 S-52 Style Compiler / NavSea Runtime 同时持有 Plain 与 Symbolized 规则后,再彻底从 PBF 构建参数中移除该选择。
|
||||
|
||||
## 8. CLI 约定
|
||||
|
||||
`build-tile` 与 `build-sample-aoi` 增加:
|
||||
|
||||
```text
|
||||
--s52-mariner-mode runtime|reference
|
||||
```
|
||||
|
||||
默认值为 `runtime`。
|
||||
|
||||
OpenCPN 对照或需要固定参考画面的任务必须显式使用 `reference`,避免参考参数误进入 NavSea 正式 PBF。
|
||||
|
||||
## 9. 本轮后续修复状态
|
||||
|
||||
边界冻结后,本轮已经继续完成四项 Presentation 物化修复:
|
||||
|
||||
1. 静态 `OP(...)` 已能按 S-52 priority code 覆盖 `s52_display_priority`,并保留 `s52_priority_override`;
|
||||
2. 多 `LS` 已新增 `s52_line_sequence` / `s52_line_count`,多 `SY` 保留 `s52_symbol_sequence` 并新增 `s52_symbol_count`;
|
||||
3. `SY(symbol, ORIENT)` 已解析对象属性角度,新增 `s52_symbol_rotation_deg`、`s52_symbol_rotation_source` 与 `s52_symbol_rule_sequence`;
|
||||
4. reference 模式中的 isolated danger 已将 Display Category 提升为 `Displaybase`、Display Priority 提升为 `Hazards`;runtime 模式只保留事实与 CSP,不固化该结果。
|
||||
|
||||
后续应进入单 tile PBF 合同回归和 NavSea Runtime 消费接口设计,而不是重新把上述结果烧回全国 PBF。
|
||||
128
docs/报告/CM95_S52_静态运行时边界与四项物化修复阶段报告_v1.md
Normal file
128
docs/报告/CM95_S52_静态运行时边界与四项物化修复阶段报告_v1.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# CM95 S-52 静态运行时边界与四项物化修复阶段报告 v1
|
||||
|
||||
日期:2026-09-17
|
||||
|
||||
## 本阶段目标
|
||||
|
||||
本阶段先解决 NavSea 用户可自定义水深显示与 CM95 固定 S-52 参考参数之间的架构冲突,再继续修复此前发现的四项 PBF Presentation 物化问题。
|
||||
|
||||
## 已完成的架构调整
|
||||
|
||||
新增 `runtime` / `reference` 两种 S-52 mariner mode。
|
||||
|
||||
`runtime` 为 NavSea Delivery 默认方向。以下依赖船员水深设置的 CSP 不再按固定 `S52MarinerSettings` 提前物化最终结果:
|
||||
|
||||
- `DEPARE01 / DEPARE02`
|
||||
- `DEPCNT02`
|
||||
- `SOUNDG02`
|
||||
- `OBSTRN04`
|
||||
- `WRECKS02`
|
||||
|
||||
PBF 改为保留运行时所需事实,包括:
|
||||
|
||||
- `depth_min_m / depth_max_m`
|
||||
- `contour_depth_m`
|
||||
- `s52_sounding_depth_m`
|
||||
- `s52_hazard_depth_m`
|
||||
- hazard 周围水深摘要和相关 S-57 code
|
||||
- `s52_mariner_dependent`
|
||||
- `s52_runtime_csp`
|
||||
- 未解析的 `s52_csp`
|
||||
|
||||
`reference` 继续按固定 `S52MarinerSettings` 解析,专用于 OpenCPN/S-52 对照。
|
||||
|
||||
本次 Delivery 字段与语义发生变化,因此 Builder Contract 已提升为 `cm95pbf-builder/0.4.8-s52-mariner-runtime-boundary`;后续正式 Production Plan 必须重新生成,旧 Plan 不应复用于本版本全国构建。
|
||||
|
||||
## 四项物化问题修复
|
||||
|
||||
### 1. DisplayBase / Priority side effect
|
||||
|
||||
reference 模式下,isolated danger 触发后现在会把:
|
||||
|
||||
- `s52_display_category` 更新为 `Displaybase`
|
||||
- `s52_display_priority` 更新为 `Hazards`
|
||||
|
||||
runtime 模式不会写入该动态提升结果。
|
||||
|
||||
### 2. OP priority override
|
||||
|
||||
`OP(6---)` 等 priority override 现在按 S-52 priority code 映射到最终 `s52_display_priority`,同时保留原始 `s52_priority_override` 供审计。
|
||||
|
||||
### 3. 多 SY / LS 保真
|
||||
|
||||
- 多 `SY`:保留 `s52_symbol_sequence`,新增 `s52_symbol_count`
|
||||
- 多 `LS`:新增 `s52_line_sequence` 与 `s52_line_count`
|
||||
|
||||
第一条兼容字段继续保留,避免破坏当前消费者。
|
||||
|
||||
### 4. SY ORIENT
|
||||
|
||||
通用 `SY(symbol,ORIENT)` 现在根据对象属性解析角度,并输出:
|
||||
|
||||
- `s52_symbol_rotation_deg`
|
||||
- `s52_symbol_rotation_source`
|
||||
- `s52_symbol_rule_sequence`
|
||||
|
||||
无法解析角度时 fail-closed,不制造默认方向。
|
||||
|
||||
## 验证结果
|
||||
|
||||
定向 Python 验证已通过:
|
||||
|
||||
- `compileall`:PASS
|
||||
- runtime DEPARE/DEPCNT/SOUNDG/WRECKS 不再固化 mariner-dependent 最终结果:PASS
|
||||
- reference WRECKS isolated danger → `Displaybase / Hazards`:PASS
|
||||
- runtime WRECKS 不产生 `s52_display_base_promoted`:PASS
|
||||
- `OP(6---)` → `Area Symbol`:PASS
|
||||
- 双 `LS` sequence 保留:PASS
|
||||
- `SY(...,ORIENT)` 123.5° 解析:PASS
|
||||
- Delivery allowlist 对新增深度事实和 structured symbol rule scalarize:PASS
|
||||
|
||||
## 已知边界
|
||||
|
||||
`Plain / Symbolized Boundaries` 本身也是 mariner presentation 设置,但它涉及 Area Lookup Table 选择,不只是 CSP 输出。当前 v1 没有伪装成已完全 runtime 化;后续需由 S-52 Style Compiler / NavSea Runtime 持有两套边界规则后再解除构建时选择。
|
||||
|
||||
## 福冈湾单 Tile PBF 合同回归
|
||||
|
||||
已对 `z12/3531/1641` 分别生成 Delivery runtime/reference PBF。两种模式均:
|
||||
|
||||
- 输出 Feature:3164
|
||||
- 输出 Layer:22
|
||||
- `hard_gate_unmapped`:0
|
||||
|
||||
runtime 模式中共有 2260 个 `s52_mariner_dependent=1` Feature:
|
||||
|
||||
- `SOUNDG02`:1881
|
||||
- `DEPCNT02`:188
|
||||
- `DEPARE01`:172
|
||||
- `OBSTRN04`:19
|
||||
|
||||
这些 Feature 中 `s52_area_color`、`s52_is_safety_contour`、`s52_sounding_prefix`、`s52_sounding_is_shallow`、`s52_isolated_danger`、`s52_display_base_promoted` 均为 0 个,确认固定 mariner 结果没有泄漏进 runtime PBF。
|
||||
|
||||
同时实际进入 runtime Delivery PBF 的事实字段包括:
|
||||
|
||||
- `depth_min_m`:172
|
||||
- `depth_max_m`:168(其余对象源数据无有效 DRVAL2)
|
||||
- `contour_depth_m`:188
|
||||
- `s52_sounding_depth_m`:1881
|
||||
- `s52_hazard_depth_m`:19
|
||||
- 周围水深摘要字段:19
|
||||
|
||||
reference 模式中:
|
||||
|
||||
- runtime marker:0
|
||||
- `s52_is_safety_contour=1`:91
|
||||
- 多 SOUNDG `s52_symbol_count`:1684 个 Feature
|
||||
- ORIENT rotation 字段:60 个 Feature
|
||||
|
||||
本 tile 没有触发 isolated danger / OP override / 多 LS,因此这三项仍由定向规则验证覆盖;reference isolated danger、`OP(6---)` 和双 `LS` 的定向验证均已 PASS。
|
||||
|
||||
已新增可重复执行的合同审计工具 `tools/cm95_s52_runtime_contract_check.py`。对本次 runtime/reference 两张 PBF 执行结果为 `PASS`,`dynamic_final_leaks={}`。
|
||||
|
||||
## NavSea Runtime 消费契约
|
||||
|
||||
已新增 `docs/CM95_S52_NavSea运行时呈现契约_v1.md`,冻结 DEPARE、DEPCNT、SOUNDG、hazard 的 Runtime Adapter 输入/输出、设置快照、ContourCatalog、fail-closed 和无需重载 PBF 的更新流程。
|
||||
|
||||
## 下一步
|
||||
|
||||
CM95/PBF 侧本阶段可以冻结。下一阶段应在 NavSea 当前实际代码基线上调查现有水深颜色设置、MapLibre style 更新入口和图层 Adapter,再按该契约做最小接入;在 NavSea Runtime 接口验证前,不做全国 PBF 重建。
|
||||
@@ -1,2 +1,2 @@
|
||||
__version__ = "0.4.7-production-audited-nav-name-conflict-omission"
|
||||
BUILDER_CONTRACT_VERSION = "cm95pbf-builder/0.4.6-spatial-candidate-query"
|
||||
__version__ = "0.4.8-s52-mariner-runtime-boundary"
|
||||
BUILDER_CONTRACT_VERSION = "cm95pbf-builder/0.4.8-s52-mariner-runtime-boundary"
|
||||
|
||||
@@ -273,6 +273,7 @@ class Cm95TileBuilder:
|
||||
s52_lookups_path: Path | None = None,
|
||||
s52_point_table: str = "Paper",
|
||||
s52_boundary_table: str = "Plain",
|
||||
s52_mariner_mode: str = "runtime",
|
||||
):
|
||||
self.db = db
|
||||
self.dataset_id = int(dataset_id)
|
||||
@@ -287,12 +288,14 @@ class Cm95TileBuilder:
|
||||
Path(s52_lookups_path),
|
||||
point_table=s52_point_table,
|
||||
boundary_table=s52_boundary_table,
|
||||
mariner_mode=s52_mariner_mode,
|
||||
)
|
||||
if s52_lookups_path is not None
|
||||
else None
|
||||
)
|
||||
self.s52_point_table = str(s52_point_table)
|
||||
self.s52_boundary_table = str(s52_boundary_table)
|
||||
self.s52_mariner_mode = str(s52_mariner_mode)
|
||||
self._s52_context: dict[str, Any] = {}
|
||||
if self.profile not in {"delivery", "engineering"}:
|
||||
raise ValueError("profile must be delivery or engineering")
|
||||
|
||||
@@ -68,6 +68,12 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
choices=["Plain", "Symbolized"],
|
||||
default="Plain",
|
||||
)
|
||||
tile.add_argument(
|
||||
"--s52-mariner-mode",
|
||||
choices=["runtime", "reference"],
|
||||
default="runtime",
|
||||
help="runtime=保留船员设置相关事实;reference=按固定 S52MarinerSettings 物化,用于 OpenCPN 对照",
|
||||
)
|
||||
|
||||
sample = sub.add_parser(
|
||||
"build-sample-aoi",
|
||||
@@ -97,6 +103,12 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
choices=["Plain", "Symbolized"],
|
||||
default="Plain",
|
||||
)
|
||||
sample.add_argument(
|
||||
"--s52-mariner-mode",
|
||||
choices=["runtime", "reference"],
|
||||
default="runtime",
|
||||
help="runtime=保留船员设置相关事实;reference=按固定 S52MarinerSettings 物化,用于 OpenCPN 对照",
|
||||
)
|
||||
|
||||
|
||||
prod_plan = sub.add_parser(
|
||||
@@ -321,6 +333,7 @@ def main() -> None:
|
||||
s52_lookups_path=(Path(args.s52_lookups) if args.s52_lookups else None),
|
||||
s52_point_table=args.s52_point_table,
|
||||
s52_boundary_table=args.s52_boundary_table,
|
||||
s52_mariner_mode=args.s52_mariner_mode,
|
||||
)
|
||||
result = builder.build_tile(
|
||||
TileJob(args.z, args.x, args.y)
|
||||
@@ -356,6 +369,7 @@ def main() -> None:
|
||||
s52_lookups_path=(Path(args.s52_lookups) if args.s52_lookups else None),
|
||||
s52_point_table=args.s52_point_table,
|
||||
s52_boundary_table=args.s52_boundary_table,
|
||||
s52_mariner_mode=args.s52_mariner_mode,
|
||||
)
|
||||
for z, x, y in jobs:
|
||||
result = builder.build_tile(TileJob(z, x, y))
|
||||
|
||||
@@ -4,7 +4,11 @@ from dataclasses import dataclass
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
from .presentation import parse_instruction_string, static_rule_hints
|
||||
from .presentation import (
|
||||
materialize_symbol_hints,
|
||||
parse_instruction_string,
|
||||
static_rule_hints,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -17,6 +21,19 @@ class S52MarinerSettings:
|
||||
symbolized_boundaries: bool = False
|
||||
|
||||
|
||||
# These CSPs depend on mariner-selected depth thresholds. NavSea delivery PBF
|
||||
# must preserve their chart facts instead of freezing the current comparison
|
||||
# profile into tile properties. The fixed S52MarinerSettings path remains
|
||||
# available for OpenCPN/reference rendering.
|
||||
MARINER_DEPENDENT_CSPS = frozenset(
|
||||
{"DEPARE01", "DEPARE02", "DEPCNT02", "SOUNDG02", "OBSTRN04", "WRECKS02"}
|
||||
)
|
||||
|
||||
|
||||
def is_mariner_dependent_csp(csp_name: str) -> bool:
|
||||
return str(csp_name or "")[:8] in MARINER_DEPENDENT_CSPS
|
||||
|
||||
|
||||
def _float(attrs: dict[str, Any], key: str, default: float) -> float:
|
||||
try:
|
||||
return float(attrs.get(key, default))
|
||||
@@ -446,6 +463,131 @@ def _area_least_depth(
|
||||
return min(values) if values else None
|
||||
|
||||
|
||||
def _runtime_hazard_depth(
|
||||
csp_name: str,
|
||||
object_class: str,
|
||||
geometry_type: str,
|
||||
attrs: dict[str, Any],
|
||||
associated_depth_ranges: list[dict[str, Any]] | None,
|
||||
) -> float | None:
|
||||
valsou = _optional_float(attrs.get("VALSOU"))
|
||||
if valsou is not None:
|
||||
return valsou
|
||||
if str(geometry_type).upper() == "AREA":
|
||||
area_depth = _area_least_depth(associated_depth_ranges)
|
||||
if area_depth is not None:
|
||||
return area_depth
|
||||
|
||||
name = str(csp_name or "")[:8]
|
||||
watlev = _int_attr(attrs, "WATLEV", -9)
|
||||
if name == "WRECKS02":
|
||||
catwrk = _int_attr(attrs, "CATWRK", -9)
|
||||
if catwrk == 1:
|
||||
return 20.0
|
||||
if catwrk == 2:
|
||||
return 0.0
|
||||
if catwrk in {4, 5}:
|
||||
return -15.0
|
||||
if watlev == 3:
|
||||
return 0.01
|
||||
if watlev == 5:
|
||||
return 0.0
|
||||
return -15.0
|
||||
|
||||
expsou = _int_attr(attrs, "EXPSOU", 0)
|
||||
if expsou == 1:
|
||||
return None
|
||||
if _int_attr(attrs, "CATOBS", 0) == 6:
|
||||
return 0.01
|
||||
if watlev == 5:
|
||||
return 0.0
|
||||
if watlev == 3:
|
||||
return 0.01
|
||||
return -15.0
|
||||
|
||||
|
||||
def materialize_mariner_runtime_facts(
|
||||
csp_name: str,
|
||||
object_class: str,
|
||||
geometry_type: str,
|
||||
attrs: dict[str, Any],
|
||||
*,
|
||||
sounding_depth_m: float | None = None,
|
||||
associated_depth_ranges: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Emit mariner-neutral inputs needed by NavSea runtime presentation."""
|
||||
name = str(csp_name or "")[:8]
|
||||
if name not in MARINER_DEPENDENT_CSPS:
|
||||
return {}
|
||||
|
||||
out: dict[str, Any] = {
|
||||
"s52_mariner_dependent": 1,
|
||||
"s52_runtime_csp": name,
|
||||
}
|
||||
|
||||
if name in {"DEPARE01", "DEPARE02"}:
|
||||
depth_min = _optional_float(attrs.get("DRVAL1"))
|
||||
depth_max = _optional_float(attrs.get("DRVAL2"))
|
||||
if depth_min is not None:
|
||||
out["depth_min_m"] = depth_min
|
||||
if depth_max is not None:
|
||||
out["depth_max_m"] = depth_max
|
||||
|
||||
if name == "DEPCNT02":
|
||||
contour = _optional_float(attrs.get("VALDCO"))
|
||||
if contour is not None:
|
||||
out["contour_depth_m"] = contour
|
||||
|
||||
if name == "SOUNDG02" and sounding_depth_m is not None:
|
||||
out["s52_sounding_depth_m"] = float(sounding_depth_m)
|
||||
out["s52_sounding_swept"] = int(6 in _int_list(attrs.get("TECSOU")))
|
||||
out["s52_sounding_low_quality"] = int(
|
||||
bool(
|
||||
set(_int_list(attrs.get("QUASOU"))) & {3, 4, 5, 8, 9}
|
||||
or 18 in _int_list(attrs.get("STATUS"))
|
||||
or any(2 <= q < 10 for q in _int_list(attrs.get("QUAPOS")))
|
||||
)
|
||||
)
|
||||
|
||||
if name in {"OBSTRN04", "WRECKS02"}:
|
||||
hazard_depth = _runtime_hazard_depth(
|
||||
name, object_class, geometry_type, attrs, associated_depth_ranges
|
||||
)
|
||||
if hazard_depth is not None:
|
||||
out["s52_hazard_depth_m"] = float(hazard_depth)
|
||||
ranges = _hazard_depth_context(associated_depth_ranges)
|
||||
lows = [_optional_float(item.get("drval1")) for item in ranges]
|
||||
highs = [_optional_float(item.get("drval2")) for item in ranges]
|
||||
lows = [value for value in lows if value is not None]
|
||||
highs = [value for value in highs if value is not None]
|
||||
if lows:
|
||||
out["s52_surrounding_max_drval1_m"] = max(lows)
|
||||
if highs:
|
||||
out["s52_surrounding_min_drval2_m"] = min(highs)
|
||||
|
||||
scalar_codes = {
|
||||
"QUAPOS": "s52_quapos_code",
|
||||
"WATLEV": "s52_watlev_code",
|
||||
"EXPSOU": "s52_expsou_code",
|
||||
"CATOBS": "s52_catobs_code",
|
||||
"CATWRK": "s52_catwrk_code",
|
||||
}
|
||||
for source_key, target_key in scalar_codes.items():
|
||||
if attrs.get(source_key) not in {None, ""}:
|
||||
out[target_key] = _int_attr(attrs, source_key, 0)
|
||||
|
||||
list_codes = {
|
||||
"QUASOU": "s52_quasou_codes",
|
||||
"TECSOU": "s52_tecsou_codes",
|
||||
"STATUS": "s52_status_codes",
|
||||
}
|
||||
for source_key, target_key in list_codes.items():
|
||||
values = _int_list(attrs.get(source_key))
|
||||
if values:
|
||||
out[target_key] = values
|
||||
return out
|
||||
|
||||
|
||||
def resolve_obstrn04_instruction(
|
||||
object_class: str,
|
||||
geometry_type: str,
|
||||
@@ -815,6 +957,7 @@ def resolve_csp_hints(
|
||||
return {}
|
||||
|
||||
hints = static_rule_hints(instruction)
|
||||
hints.update(materialize_symbol_hints(instruction, attrs))
|
||||
if name == "LIGHTS05" and hints.get("s52_symbol_code"):
|
||||
extra["s52_light_symbol_code"] = hints["s52_symbol_code"]
|
||||
hints.update(extra)
|
||||
|
||||
@@ -23,6 +23,9 @@ DELIVERY_PROPERTY_ALLOWLIST = frozenset(
|
||||
"chart_text_color",
|
||||
"chart_text_style",
|
||||
"clearance_height_m",
|
||||
"contour_depth_m",
|
||||
"depth_max_m",
|
||||
"depth_min_m",
|
||||
"depth_value_m",
|
||||
"display_code",
|
||||
"least_depth_m",
|
||||
@@ -35,6 +38,8 @@ DELIVERY_PROPERTY_ALLOWLIST = frozenset(
|
||||
"s52_csp",
|
||||
"s52_csp_resolved",
|
||||
"s52_csp_rule_opcodes",
|
||||
"s52_mariner_dependent",
|
||||
"s52_runtime_csp",
|
||||
"s52_is_safety_contour",
|
||||
"s52_display_category",
|
||||
"s52_area_color",
|
||||
@@ -52,8 +57,20 @@ DELIVERY_PROPERTY_ALLOWLIST = frozenset(
|
||||
"s52_hazard_depth_m",
|
||||
"s52_hazard_procedure",
|
||||
"s52_isolated_danger",
|
||||
"s52_surrounding_max_drval1_m",
|
||||
"s52_surrounding_min_drval2_m",
|
||||
"s52_quapos_code",
|
||||
"s52_quasou_codes",
|
||||
"s52_tecsou_codes",
|
||||
"s52_status_codes",
|
||||
"s52_watlev_code",
|
||||
"s52_expsou_code",
|
||||
"s52_catobs_code",
|
||||
"s52_catwrk_code",
|
||||
"s52_line_color",
|
||||
"s52_line_complex_code",
|
||||
"s52_line_count",
|
||||
"s52_line_sequence",
|
||||
"s52_line_style",
|
||||
"s52_line_width",
|
||||
"s52_rule_opcodes",
|
||||
@@ -72,9 +89,14 @@ DELIVERY_PROPERTY_ALLOWLIST = frozenset(
|
||||
"s52_sector_start_deg",
|
||||
"s52_sector_sweep_deg",
|
||||
"s52_display_priority",
|
||||
"s52_priority_override",
|
||||
"s52_lookup_rcid",
|
||||
"s52_object_class",
|
||||
"s52_symbol_code",
|
||||
"s52_symbol_count",
|
||||
"s52_symbol_rotation_deg",
|
||||
"s52_symbol_rotation_source",
|
||||
"s52_symbol_rule_sequence",
|
||||
"s52_symbol_sequence",
|
||||
"s52_sounding_depth_m",
|
||||
"s52_sounding_is_shallow",
|
||||
|
||||
@@ -135,6 +135,38 @@ def materialize_text_hints(instruction: str, attrs: dict[str, Any]) -> dict[str,
|
||||
return out
|
||||
|
||||
|
||||
def materialize_symbol_hints(instruction: str, attrs: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Resolve generic SY orientation parameters without changing symbol choice."""
|
||||
symbols: list[dict[str, Any]] = []
|
||||
for rule in parse_instruction_string(instruction):
|
||||
if rule.opcode != "SY" or not rule.args:
|
||||
continue
|
||||
entry: dict[str, Any] = {
|
||||
"sequence": rule.sequence,
|
||||
"code": rule.args[0].strip().strip("'\"")[:8],
|
||||
}
|
||||
if len(rule.args) >= 2:
|
||||
source = rule.args[1].strip().strip("'\"")
|
||||
raw_value = attrs.get(source, source)
|
||||
try:
|
||||
entry["rotation_deg"] = float(raw_value) % 360.0
|
||||
entry["rotation_source"] = source
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
symbols.append(entry)
|
||||
|
||||
if not symbols:
|
||||
return {}
|
||||
out: dict[str, Any] = {}
|
||||
if len(symbols) > 1 or any("rotation_deg" in item for item in symbols):
|
||||
out["s52_symbol_rule_sequence"] = symbols
|
||||
first = symbols[0]
|
||||
if "rotation_deg" in first:
|
||||
out["s52_symbol_rotation_deg"] = first["rotation_deg"]
|
||||
out["s52_symbol_rotation_source"] = first.get("rotation_source")
|
||||
return out
|
||||
|
||||
|
||||
class PresentationParseError(ValueError):
|
||||
pass
|
||||
|
||||
@@ -324,6 +356,16 @@ def static_rule_hints(instruction: str) -> dict[str, Any]:
|
||||
name = args[0].strip().strip("'\"")[:8]
|
||||
existing = out.get("s52_csp")
|
||||
out["s52_csp"] = f"{existing},{name}" if existing else name
|
||||
line_rules = [
|
||||
rule for rule in rules if rule.opcode == "LS" and len(rule.args) >= 3
|
||||
]
|
||||
if len(line_rules) > 1:
|
||||
out["s52_line_sequence"] = "|".join(
|
||||
",".join(arg.strip().strip("'\"") for arg in rule.args[:3])
|
||||
for rule in line_rules
|
||||
)
|
||||
out["s52_line_count"] = len(line_rules)
|
||||
|
||||
symbol_codes = [
|
||||
rule.args[0].strip().strip("'\"")[:8]
|
||||
for rule in rules
|
||||
@@ -332,4 +374,6 @@ def static_rule_hints(instruction: str) -> dict[str, Any]:
|
||||
if symbol_codes:
|
||||
out.setdefault("s52_symbol_code", symbol_codes[0])
|
||||
out["s52_symbol_sequence"] = ",".join(symbol_codes)
|
||||
if len(symbol_codes) > 1:
|
||||
out["s52_symbol_count"] = len(symbol_codes)
|
||||
return out
|
||||
|
||||
@@ -2,8 +2,40 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any, Iterable
|
||||
|
||||
from .presentation import materialize_text_hints, static_rule_hints
|
||||
from .csp import S52MarinerSettings, resolve_csp_hints
|
||||
from .presentation import (
|
||||
materialize_symbol_hints,
|
||||
materialize_text_hints,
|
||||
static_rule_hints,
|
||||
)
|
||||
from .csp import (
|
||||
S52MarinerSettings,
|
||||
is_mariner_dependent_csp,
|
||||
materialize_mariner_runtime_facts,
|
||||
resolve_csp_hints,
|
||||
)
|
||||
|
||||
|
||||
S52_DISPLAY_PRIORITY_BY_CODE = {
|
||||
"0": "No data",
|
||||
"1": "Group 1",
|
||||
"2": "Area 1",
|
||||
"3": "Area 2",
|
||||
"4": "Point Symbol",
|
||||
"5": "Line Symbol",
|
||||
"6": "Area Symbol",
|
||||
"7": "Routing",
|
||||
"8": "Hazards",
|
||||
"9": "Mariners",
|
||||
}
|
||||
|
||||
|
||||
def _apply_presentation_side_effects(out: dict[str, Any]) -> None:
|
||||
override = str(out.get("s52_priority_override") or "")
|
||||
if override and override[0] in S52_DISPLAY_PRIORITY_BY_CODE:
|
||||
out["s52_display_priority"] = S52_DISPLAY_PRIORITY_BY_CODE[override[0]]
|
||||
if int(out.get("s52_display_base_promoted") or 0) == 1:
|
||||
out["s52_display_category"] = "Displaybase"
|
||||
out["s52_display_priority"] = "Hazards"
|
||||
|
||||
|
||||
OPENCPN_CM93_CLASS_SUBSTITUTIONS: dict[str, str] = {
|
||||
@@ -370,6 +402,7 @@ class S52LookupRegistry:
|
||||
path,
|
||||
point_table: str = "Paper",
|
||||
boundary_table: str = "Plain",
|
||||
mariner_mode: str = "runtime",
|
||||
):
|
||||
import json
|
||||
from pathlib import Path
|
||||
@@ -377,6 +410,9 @@ class S52LookupRegistry:
|
||||
self.path = Path(path)
|
||||
self.point_table = str(point_table)
|
||||
self.boundary_table = str(boundary_table)
|
||||
self.mariner_mode = str(mariner_mode)
|
||||
if self.mariner_mode not in {"runtime", "reference"}:
|
||||
raise ValueError("mariner_mode must be runtime or reference")
|
||||
self.settings = S52MarinerSettings(symbolized_boundaries=self.boundary_table == "Symbolized")
|
||||
rows = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
self.by_class: dict[str, list[dict[str, Any]]] = {}
|
||||
@@ -437,8 +473,10 @@ class S52LookupRegistry:
|
||||
"s52_display_priority": str(row.get("display_priority") or ""),
|
||||
"s52_table_name": str(row.get("table_name") or ""),
|
||||
}
|
||||
instruction = str(row.get("instruction") or "")
|
||||
out.update(dict(row.get("_static_hints") or {}))
|
||||
out.update(materialize_text_hints(str(row.get("instruction") or ""), normalized_attrs))
|
||||
out.update(materialize_text_hints(instruction, normalized_attrs))
|
||||
out.update(materialize_symbol_hints(instruction, normalized_attrs))
|
||||
if lookup_class != str(object_class):
|
||||
out["s52_object_class"] = lookup_class
|
||||
procedures = [
|
||||
@@ -448,6 +486,19 @@ class S52LookupRegistry:
|
||||
]
|
||||
unresolved: list[str] = []
|
||||
for procedure in procedures:
|
||||
if self.mariner_mode == "runtime" and is_mariner_dependent_csp(procedure):
|
||||
out.update(
|
||||
materialize_mariner_runtime_facts(
|
||||
procedure,
|
||||
lookup_class,
|
||||
str(geometry_type),
|
||||
normalized_attrs,
|
||||
sounding_depth_m=sounding_depth_m,
|
||||
associated_depth_ranges=associated_depth_ranges,
|
||||
)
|
||||
)
|
||||
unresolved.append(procedure)
|
||||
continue
|
||||
resolved = resolve_csp_hints(
|
||||
procedure,
|
||||
lookup_class,
|
||||
@@ -466,6 +517,7 @@ class S52LookupRegistry:
|
||||
out["s52_csp"] = ",".join(unresolved)
|
||||
else:
|
||||
out.pop("s52_csp", None)
|
||||
_apply_presentation_side_effects(out)
|
||||
return {key: value for key, value in out.items() if value != ""}
|
||||
|
||||
|
||||
|
||||
157
tools/cm95_s52_runtime_contract_check.py
Executable file
157
tools/cm95_s52_runtime_contract_check.py
Executable file
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from collections import Counter
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import mapbox_vector_tile
|
||||
|
||||
|
||||
DYNAMIC_FINAL_FIELDS = {
|
||||
"s52_is_safety_contour",
|
||||
"s52_sounding_prefix",
|
||||
"s52_sounding_is_shallow",
|
||||
"s52_isolated_danger",
|
||||
"s52_display_base_promoted",
|
||||
}
|
||||
|
||||
|
||||
def decode_features(path: Path) -> list[tuple[str, dict[str, Any]]]:
|
||||
decoded = mapbox_vector_tile.decode(path.read_bytes())
|
||||
features: list[tuple[str, dict[str, Any]]] = []
|
||||
for layer_name, layer in decoded.items():
|
||||
for feature in layer.get("features", []):
|
||||
features.append((layer_name, dict(feature.get("properties") or {})))
|
||||
return features
|
||||
|
||||
|
||||
def audit_runtime(features: list[tuple[str, dict[str, Any]]]) -> dict[str, Any]:
|
||||
runtime = [
|
||||
(layer, props)
|
||||
for layer, props in features
|
||||
if props.get("s52_mariner_dependent") == 1
|
||||
]
|
||||
failures: list[str] = []
|
||||
if not runtime:
|
||||
failures.append("没有发现 s52_mariner_dependent=1 Feature")
|
||||
|
||||
csp_counts = Counter(str(props.get("s52_runtime_csp") or "") for _, props in runtime)
|
||||
leaked = Counter()
|
||||
for _, props in runtime:
|
||||
csp = str(props.get("s52_runtime_csp") or "")
|
||||
if not csp:
|
||||
failures.append("runtime Feature 缺少 s52_runtime_csp")
|
||||
if csp and csp not in str(props.get("s52_csp") or "").split(","):
|
||||
failures.append(f"{csp} 未保留在 s52_csp")
|
||||
for key in DYNAMIC_FINAL_FIELDS:
|
||||
if key in props:
|
||||
leaked[key] += 1
|
||||
if csp in {"DEPARE01", "DEPARE02"}:
|
||||
if "depth_min_m" not in props:
|
||||
failures.append(f"{csp} 缺少 depth_min_m")
|
||||
if "s52_area_color" in props:
|
||||
leaked["DEPARE:s52_area_color"] += 1
|
||||
elif csp == "DEPCNT02" and "contour_depth_m" not in props:
|
||||
failures.append("DEPCNT02 缺少 contour_depth_m")
|
||||
elif csp == "SOUNDG02" and "s52_sounding_depth_m" not in props:
|
||||
failures.append("SOUNDG02 缺少 s52_sounding_depth_m")
|
||||
|
||||
if leaked:
|
||||
failures.append(f"发现 runtime 最终状态泄漏: {dict(leaked)}")
|
||||
|
||||
return {
|
||||
"feature_count": len(features),
|
||||
"mariner_dependent_count": len(runtime),
|
||||
"runtime_csp_counts": dict(csp_counts),
|
||||
"layer_counts": dict(Counter(layer for layer, _ in runtime)),
|
||||
"fact_counts": {
|
||||
key: sum(1 for _, props in features if key in props)
|
||||
for key in (
|
||||
"depth_min_m",
|
||||
"depth_max_m",
|
||||
"contour_depth_m",
|
||||
"s52_sounding_depth_m",
|
||||
"s52_hazard_depth_m",
|
||||
"s52_surrounding_max_drval1_m",
|
||||
"s52_surrounding_min_drval2_m",
|
||||
)
|
||||
},
|
||||
"dynamic_final_leaks": dict(leaked),
|
||||
"failures": failures,
|
||||
"status": "PASS" if not failures else "FAIL",
|
||||
}
|
||||
|
||||
|
||||
def audit_reference(features: list[tuple[str, dict[str, Any]]]) -> dict[str, Any]:
|
||||
failures: list[str] = []
|
||||
runtime_markers = sum(
|
||||
1 for _, props in features if props.get("s52_mariner_dependent") == 1
|
||||
)
|
||||
if runtime_markers:
|
||||
failures.append(f"reference PBF 出现 {runtime_markers} 个 runtime marker")
|
||||
return {
|
||||
"feature_count": len(features),
|
||||
"runtime_marker_count": runtime_markers,
|
||||
"fixed_result_counts": {
|
||||
"safety_contour": sum(
|
||||
1 for _, props in features if props.get("s52_is_safety_contour") == 1
|
||||
),
|
||||
"isolated_danger": sum(
|
||||
1 for _, props in features if props.get("s52_isolated_danger") == 1
|
||||
),
|
||||
"display_base_promoted": sum(
|
||||
1 for _, props in features if props.get("s52_display_base_promoted") == 1
|
||||
),
|
||||
"sounding_prefix": sum(
|
||||
1 for _, props in features if "s52_sounding_prefix" in props
|
||||
),
|
||||
},
|
||||
"presentation_sequence_counts": {
|
||||
key: sum(1 for _, props in features if key in props)
|
||||
for key in (
|
||||
"s52_symbol_count",
|
||||
"s52_line_count",
|
||||
"s52_symbol_rotation_deg",
|
||||
"s52_symbol_rule_sequence",
|
||||
"s52_priority_override",
|
||||
)
|
||||
},
|
||||
"failures": failures,
|
||||
"status": "PASS" if not failures else "FAIL",
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="检查 CM95 S-52 runtime/reference Delivery PBF 边界"
|
||||
)
|
||||
parser.add_argument("--runtime-pbf", type=Path, required=True)
|
||||
parser.add_argument("--reference-pbf", type=Path, required=True)
|
||||
parser.add_argument("--output", type=Path)
|
||||
args = parser.parse_args()
|
||||
|
||||
runtime = audit_runtime(decode_features(args.runtime_pbf))
|
||||
reference = audit_reference(decode_features(args.reference_pbf))
|
||||
result = {
|
||||
"schema": "cm95-s52-runtime-contract-check/v1",
|
||||
"runtime_pbf": str(args.runtime_pbf),
|
||||
"reference_pbf": str(args.reference_pbf),
|
||||
"runtime": runtime,
|
||||
"reference": reference,
|
||||
"status": "PASS"
|
||||
if runtime["status"] == reference["status"] == "PASS"
|
||||
else "FAIL",
|
||||
}
|
||||
text = json.dumps(result, ensure_ascii=False, indent=2)
|
||||
print(text)
|
||||
if args.output:
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(text + "\n", encoding="utf-8")
|
||||
return 0 if result["status"] == "PASS" else 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user