From a7c9812bd81b74aa274632c5bc82e496053b82f0 Mon Sep 17 00:00:00 2001 From: OpenAI Codex Date: Fri, 18 Sep 2026 06:21:54 +0800 Subject: [PATCH] feat: separate S-52 mariner runtime presentation --- config/mappings/cm95_to_navsea_v1.yaml | 2 + docs/CM95_S52_NavSea运行时呈现契约_v1.md | 228 ++++++++++++++++++ docs/CM95_S52_静态物化与运行时呈现边界_v1.md | 140 +++++++++++ ...2_静态运行时边界与四项物化修复阶段报告_v1.md | 128 ++++++++++ src/cm95pbf/__init__.py | 4 +- src/cm95pbf/builder.py | 3 + src/cm95pbf/cli.py | 14 ++ src/cm95pbf/csp.py | 145 ++++++++++- src/cm95pbf/mapping.py | 22 ++ src/cm95pbf/presentation.py | 44 ++++ src/cm95pbf/s52.py | 58 ++++- tools/cm95_s52_runtime_contract_check.py | 157 ++++++++++++ 12 files changed, 939 insertions(+), 6 deletions(-) create mode 100644 docs/CM95_S52_NavSea运行时呈现契约_v1.md create mode 100644 docs/CM95_S52_静态物化与运行时呈现边界_v1.md create mode 100644 docs/报告/CM95_S52_静态运行时边界与四项物化修复阶段报告_v1.md create mode 100755 tools/cm95_s52_runtime_contract_check.py diff --git a/config/mappings/cm95_to_navsea_v1.yaml b/config/mappings/cm95_to_navsea_v1.yaml index f25f220..f7f7319 100644 --- a/config/mappings/cm95_to_navsea_v1.yaml +++ b/config/mappings/cm95_to_navsea_v1.yaml @@ -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: diff --git a/docs/CM95_S52_NavSea运行时呈现契约_v1.md b/docs/CM95_S52_NavSea运行时呈现契约_v1.md new file mode 100644 index 0000000..f897a39 --- /dev/null +++ b/docs/CM95_S52_NavSea运行时呈现契约_v1.md @@ -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 输入必须得到确定性相同输出。 diff --git a/docs/CM95_S52_静态物化与运行时呈现边界_v1.md b/docs/CM95_S52_静态物化与运行时呈现边界_v1.md new file mode 100644 index 0000000..69e5cb5 --- /dev/null +++ b/docs/CM95_S52_静态物化与运行时呈现边界_v1.md @@ -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 = ` +- 未解析的 `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。 diff --git a/docs/报告/CM95_S52_静态运行时边界与四项物化修复阶段报告_v1.md b/docs/报告/CM95_S52_静态运行时边界与四项物化修复阶段报告_v1.md new file mode 100644 index 0000000..2be18f8 --- /dev/null +++ b/docs/报告/CM95_S52_静态运行时边界与四项物化修复阶段报告_v1.md @@ -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 重建。 diff --git a/src/cm95pbf/__init__.py b/src/cm95pbf/__init__.py index d67eda0..7379af6 100644 --- a/src/cm95pbf/__init__.py +++ b/src/cm95pbf/__init__.py @@ -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" diff --git a/src/cm95pbf/builder.py b/src/cm95pbf/builder.py index e97116c..adbd7a4 100644 --- a/src/cm95pbf/builder.py +++ b/src/cm95pbf/builder.py @@ -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") diff --git a/src/cm95pbf/cli.py b/src/cm95pbf/cli.py index d3cb016..f787e1f 100644 --- a/src/cm95pbf/cli.py +++ b/src/cm95pbf/cli.py @@ -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)) diff --git a/src/cm95pbf/csp.py b/src/cm95pbf/csp.py index 67d2620..b3359e9 100644 --- a/src/cm95pbf/csp.py +++ b/src/cm95pbf/csp.py @@ -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) diff --git a/src/cm95pbf/mapping.py b/src/cm95pbf/mapping.py index 78c0ed3..8799f8b 100644 --- a/src/cm95pbf/mapping.py +++ b/src/cm95pbf/mapping.py @@ -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", diff --git a/src/cm95pbf/presentation.py b/src/cm95pbf/presentation.py index fed81a4..7b4e122 100644 --- a/src/cm95pbf/presentation.py +++ b/src/cm95pbf/presentation.py @@ -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 diff --git a/src/cm95pbf/s52.py b/src/cm95pbf/s52.py index 1a286ab..3315965 100644 --- a/src/cm95pbf/s52.py +++ b/src/cm95pbf/s52.py @@ -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 != ""} diff --git a/tools/cm95_s52_runtime_contract_check.py b/tools/cm95_s52_runtime_contract_check.py new file mode 100755 index 0000000..3a58783 --- /dev/null +++ b/tools/cm95_s52_runtime_contract_check.py @@ -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())