feat: separate S-52 mariner runtime presentation

This commit is contained in:
OpenAI Codex
2026-09-18 06:21:54 +08:00
parent f238f1c471
commit a7c9812bd8
12 changed files with 939 additions and 6 deletions

View File

@@ -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:

View 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 输入必须得到确定性相同输出。

View 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。

View 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 重建。

View File

@@ -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"

View File

@@ -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")

View File

@@ -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))

View File

@@ -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)

View File

@@ -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",

View File

@@ -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

View File

@@ -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 != ""}

View 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())