feat: add S-52 presentation rule engine

This commit is contained in:
OpenAI Codex
2026-09-17 07:43:22 +08:00
parent ef36ec3599
commit bbe006b3d6
8 changed files with 645 additions and 19 deletions

View File

@@ -0,0 +1,50 @@
# CM95 S-52 Presentation 引擎阶段报告 v1
日期:2026-09-17
## 本阶段完成
- 建立 `S-52 INST → Presentation Rule IR` 通用解释器。
- 按 OpenCPN `StringToRules/SCANFWRD` 容错语义解析历史 Presentation Library 数据。
- 支持 `SY/LS/LC/AC/AP/TX/TE/CS`,并预留 CSP 生成的 `CA/MP`。
- OpenCPN 当前 `chartsymbols.xml` 3057 条 Lookup 全量解析:3057/3057,失败 0。
- S52 Lookup 从 Point 扩展到 Point/Line/Area:Paper/Lines/Plain 或 Symbolized。
- 新增通用诊断 fallback layer:`s52_points/s52_lines/s52_areas`。
## 已落地 CSP
### DEPARE01 / DEPARE02
按 OpenCPN 行为生成区域显示规则;福冈湾 z12/3531/1641 实测:
- DEPARE/DRGARE:172 个。
- DEPVS:47。
- DEPMS:68。
- DEPMD:33。
- DEPDW:24。
- DRGARE 4 个同时得到 `AP(DRGARE01)` 与虚线边界。
### DEPCNT02
配置默认 safety contour=3m;数据不存在精确 3m contour 时,自动选择下一个更深的实际 contour。
福冈湾实测:
- DEPCNT:188 条。
- 5m 被选择为实际 safety contour。
- DEPSC(安全等深线,宽 2):91 条。
- DEPCN(普通等深线,宽 1):97 条。
## 福冈湾完整 S-52 覆盖
同一 z12 tile 共 3293 个输出 Feature,其中 1264 个已经取得 S-52 Lookup/Presentation 属性。
新增通用 fallback 中 `s52_points=42`、`s52_areas=4`;原有业务 layer 继续保留,不重复制造已有对象。
浏览器验证使用同一 PBF,已确认四级深度色、安全等深线、测深数字、底质缩写、地名和更多 S-52 航标实际渲染。
## 尚未完成
- LIGHTS05/LIGHTS06 的 `CA` 灯弧最终 renderer。
- SOUNDG02/SOUNDG03 的完整 S-52 sounding symbol 组合。
- SBDARE 底质长文本/组合格式。
- OBSTRN/WRECKS/UDWHAZ、RESARE/RESTRN、SLCONS/QUAPOS 等 CSP。
- 正式 `style.json` compiler;当前 Web 页面仍是诊断 Style。

View File

@@ -272,6 +272,7 @@ class Cm95TileBuilder:
profile: str = "delivery", profile: str = "delivery",
s52_lookups_path: Path | None = None, s52_lookups_path: Path | None = None,
s52_point_table: str = "Paper", s52_point_table: str = "Paper",
s52_boundary_table: str = "Plain",
): ):
self.db = db self.db = db
self.dataset_id = int(dataset_id) self.dataset_id = int(dataset_id)
@@ -282,11 +283,17 @@ class Cm95TileBuilder:
self.report_root = Path(report_root) self.report_root = Path(report_root)
self.profile = str(profile) self.profile = str(profile)
self.s52 = ( self.s52 = (
S52LookupRegistry(Path(s52_lookups_path), point_table=s52_point_table) S52LookupRegistry(
Path(s52_lookups_path),
point_table=s52_point_table,
boundary_table=s52_boundary_table,
)
if s52_lookups_path is not None if s52_lookups_path is not None
else None else None
) )
self.s52_point_table = str(s52_point_table) self.s52_point_table = str(s52_point_table)
self.s52_boundary_table = str(s52_boundary_table)
self._s52_context: dict[str, Any] = {}
if self.profile not in {"delivery", "engineering"}: if self.profile not in {"delivery", "engineering"}:
raise ValueError("profile must be delivery or engineering") raise ValueError("profile must be delivery or engineering")
@@ -432,6 +439,7 @@ class Cm95TileBuilder:
consumed: set[int] = set() consumed: set[int] = set()
s52_context = self._build_s52_context(selected) s52_context = self._build_s52_context(selected)
self._s52_context = s52_context
nav_outputs = self._compose_navigation_aids( nav_outputs = self._compose_navigation_aids(
selected, selected,
manifest, manifest,
@@ -1696,10 +1704,21 @@ class Cm95TileBuilder:
"conflict": name_conflict, "conflict": name_conflict,
} }
def _build_s52_context(self, rows: list[SourceFeature]) -> dict[str, set[Any]]: def _build_s52_context(self, rows: list[SourceFeature]) -> dict[str, Any]:
floating_relations: set[Any] = set() floating_relations: set[Any] = set()
floating_points: set[Any] = set() floating_points: set[Any] = set()
contour_values: list[float] = []
safety_contour = (
self.s52.settings.safety_contour_m if self.s52 is not None else None
)
for row in rows: for row in rows:
if row.object_class == "DEPCNT" and safety_contour is not None:
try:
value = float(row.attrs.get("VALDCO"))
except (TypeError, ValueError):
value = -1.0
if value >= safety_contour:
contour_values.append(value)
if row.geometry_type != "POINT": if row.geometry_type != "POINT":
continue continue
if not (row.object_class.startswith("BOY") or row.object_class in {"LITFLT", "LITVES"}): if not (row.object_class.startswith("BOY") or row.object_class in {"LITFLT", "LITVES"}):
@@ -1713,10 +1732,11 @@ class Cm95TileBuilder:
return { return {
"floating_relations": floating_relations, "floating_relations": floating_relations,
"floating_points": floating_points, "floating_points": floating_points,
"next_safe_contour_m": min(contour_values) if contour_values else None,
} }
@staticmethod @staticmethod
def _topmar_is_floating(row: SourceFeature, context: dict[str, set[Any]]) -> bool: def _topmar_is_floating(row: SourceFeature, context: dict[str, Any]) -> bool:
relation = row.attrs.get("_cm93_relation20_raw") relation = row.attrs.get("_cm93_relation20_raw")
if relation is not None and (row.cell_id, str(relation)) in context["floating_relations"]: if relation is not None and (row.cell_id, str(relation)) in context["floating_relations"]:
return True return True
@@ -1732,6 +1752,7 @@ class Cm95TileBuilder:
row.object_class, row.object_class,
row.geometry_type, row.geometry_type,
row.attrs, row.attrs,
next_safe_contour_m=self._s52_context.get("next_safe_contour_m"),
) )
def _append_s52_fallback_output( def _append_s52_fallback_output(
@@ -1740,11 +1761,9 @@ class Cm95TileBuilder:
manifest, manifest,
row: SourceFeature, row: SourceFeature,
trace_rows, trace_rows,
s52_context: dict[str, set[Any]], s52_context: dict[str, Any],
) -> bool: ) -> bool:
if self.s52 is None or row.geometry_type != "POINT": if self.s52 is None or row.geometry_type not in {"POINT", "LINE", "AREA"}:
return False
if row.object_class not in {"BOYLAT", "TOPMAR"}:
return False return False
s52_props = self._s52_properties(row) s52_props = self._s52_properties(row)
@@ -1757,12 +1776,16 @@ class Cm95TileBuilder:
s52_props["s52_csp_resolved"] = "TOPMAR01" s52_props["s52_csp_resolved"] = "TOPMAR01"
s52_props.pop("s52_csp", None) s52_props.pop("s52_csp", None)
resolved_csp = "TOPMAR01" resolved_csp = "TOPMAR01"
if not s52_props.get("s52_symbol_code") and not s52_props.get("s52_csp"): if not s52_props.get("s52_lookup_rcid"):
return False return False
source_id = self.source_id(row) source_id = self.source_id(row)
fid = manifest.mvt_id(source_id, ROLE_PRIMARY) fid = manifest.mvt_id(source_id, ROLE_PRIMARY)
target = "navigation_marks" target = {
"POINT": "s52_points",
"LINE": "s52_lines",
"AREA": "s52_areas",
}[row.geometry_type]
props = { props = {
"canonical_object_type": f"s52_{row.object_class.lower()}", "canonical_object_type": f"s52_{row.object_class.lower()}",
**s52_props, **s52_props,

View File

@@ -63,6 +63,11 @@ def build_parser() -> argparse.ArgumentParser:
choices=["Simplified", "Paper"], choices=["Simplified", "Paper"],
default="Paper", default="Paper",
) )
tile.add_argument(
"--s52-boundary-table",
choices=["Plain", "Symbolized"],
default="Plain",
)
sample = sub.add_parser( sample = sub.add_parser(
"build-sample-aoi", "build-sample-aoi",
@@ -87,6 +92,11 @@ def build_parser() -> argparse.ArgumentParser:
choices=["Simplified", "Paper"], choices=["Simplified", "Paper"],
default="Paper", default="Paper",
) )
sample.add_argument(
"--s52-boundary-table",
choices=["Plain", "Symbolized"],
default="Plain",
)
prod_plan = sub.add_parser( prod_plan = sub.add_parser(
@@ -310,6 +320,7 @@ def main() -> None:
profile=args.profile, profile=args.profile,
s52_lookups_path=(Path(args.s52_lookups) if args.s52_lookups else None), s52_lookups_path=(Path(args.s52_lookups) if args.s52_lookups else None),
s52_point_table=args.s52_point_table, s52_point_table=args.s52_point_table,
s52_boundary_table=args.s52_boundary_table,
) )
result = builder.build_tile( result = builder.build_tile(
TileJob(args.z, args.x, args.y) TileJob(args.z, args.x, args.y)
@@ -344,6 +355,7 @@ def main() -> None:
profile=profile, profile=profile,
s52_lookups_path=(Path(args.s52_lookups) if args.s52_lookups else None), s52_lookups_path=(Path(args.s52_lookups) if args.s52_lookups else None),
s52_point_table=args.s52_point_table, s52_point_table=args.s52_point_table,
s52_boundary_table=args.s52_boundary_table,
) )
for z, x, y in jobs: for z, x, y in jobs:
result = builder.build_tile(TileJob(z, x, y)) result = builder.build_tile(TileJob(z, x, y))

134
src/cm95pbf/csp.py Normal file
View File

@@ -0,0 +1,134 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from .presentation import parse_instruction_string, static_rule_hints
@dataclass(frozen=True)
class S52MarinerSettings:
shallow_contour_m: float = 2.0
safety_contour_m: float = 3.0
safety_depth_m: float = 3.0
deep_contour_m: float = 6.0
two_shades: bool = False
def _float(attrs: dict[str, Any], key: str, default: float) -> float:
try:
return float(attrs.get(key, default))
except (TypeError, ValueError):
return default
def resolve_depare01_instruction(
object_class: str,
attrs: dict[str, Any],
settings: S52MarinerSettings,
) -> str:
drval1_found = attrs.get("DRVAL1") not in {None, ""}
drval1 = _float(attrs, "DRVAL1", -1.0)
drval2 = _float(attrs, "DRVAL2", drval1 + 0.01)
if drval2 <= drval1:
drval2 = drval1 + 0.01
color = "DEPIT"
if drval1 >= 0.0 and drval2 > 0.0:
color = "DEPVS"
if settings.two_shades:
if (
drval1 >= settings.safety_contour_m
and drval2 > settings.safety_contour_m
):
color = "DEPDW"
else:
if (
drval1 >= settings.shallow_contour_m
and drval2 > settings.shallow_contour_m
):
color = "DEPMS"
if (
drval1 >= settings.safety_contour_m
and drval2 > settings.safety_contour_m
):
color = "DEPMD"
if (
drval1 >= settings.deep_contour_m
and drval2 > settings.deep_contour_m
):
color = "DEPDW"
if object_class == "DRGARE" and not drval1_found:
color = "DEPMD"
instruction = f"AC({color})"
if object_class == "DRGARE":
instruction += ";AP(DRGARE01);LS(DASH,1,CHGRF)"
return instruction
def resolve_depcnt02_instruction(
object_class: str,
attrs: dict[str, Any],
settings: S52MarinerSettings,
*,
next_safe_contour_m: float | None = None,
) -> tuple[str, bool]:
safe = False
if object_class == "DEPARE":
drval1 = _float(attrs, "DRVAL1", 0.0)
drval2 = _float(attrs, "DRVAL2", drval1)
if drval1 <= settings.safety_contour_m <= drval2:
safe = True
elif next_safe_contour_m is not None:
safe = abs(drval1 - next_safe_contour_m) < 1e-4
else:
valdco = _float(attrs, "VALDCO", 0.0)
if abs(valdco - settings.safety_contour_m) < 1e-4:
safe = True
elif next_safe_contour_m is not None:
safe = abs(valdco - next_safe_contour_m) < 1e-4
try:
quapos = int(attrs.get("QUAPOS") or 0)
except (TypeError, ValueError):
quapos = 0
if 2 <= quapos < 10:
return ("LS(DASH,2,DEPSC)" if safe else "LS(DASH,1,DEPCN)"), safe
return ("LS(SOLD,2,DEPSC)" if safe else "LS(SOLD,1,DEPCN)"), safe
def resolve_csp_hints(
csp_name: str,
object_class: str,
attrs: dict[str, Any],
settings: S52MarinerSettings,
*,
next_safe_contour_m: float | None = None,
) -> dict[str, Any]:
name = str(csp_name or "")[:8]
instruction: str | None = None
extra: dict[str, Any] = {}
if name in {"DEPARE01", "DEPARE02"}:
instruction = resolve_depare01_instruction(object_class, attrs, settings)
elif name == "DEPCNT02":
instruction, safe = resolve_depcnt02_instruction(
object_class,
attrs,
settings,
next_safe_contour_m=next_safe_contour_m,
)
extra["s52_is_safety_contour"] = int(safe)
else:
return {}
hints = static_rule_hints(instruction)
hints.update(extra)
hints["s52_csp_resolved"] = name
hints["s52_csp_rule_opcodes"] = ",".join(
rule.opcode for rule in parse_instruction_string(instruction)
)
return hints

View File

@@ -34,7 +34,17 @@ DELIVERY_PROPERTY_ALLOWLIST = frozenset(
"place_name_en", "place_name_en",
"s52_csp", "s52_csp",
"s52_csp_resolved", "s52_csp_resolved",
"s52_csp_rule_opcodes",
"s52_is_safety_contour",
"s52_display_category", "s52_display_category",
"s52_area_color",
"s52_area_pattern_code",
"s52_has_text",
"s52_line_color",
"s52_line_complex_code",
"s52_line_style",
"s52_line_width",
"s52_rule_opcodes",
"s52_light_arc_color", "s52_light_arc_color",
"s52_light_arc_radius_mm", "s52_light_arc_radius_mm",
"s52_light_mode", "s52_light_mode",

186
src/cm95pbf/presentation.py Normal file
View File

@@ -0,0 +1,186 @@
from __future__ import annotations
from dataclasses import asdict, dataclass
from typing import Any, Iterable
SUPPORTED_OPCODES = frozenset(
{"SY", "LS", "LC", "AC", "AP", "TX", "TE", "CS", "CA", "MP"}
)
class PresentationParseError(ValueError):
pass
@dataclass(frozen=True)
class PresentationRule:
opcode: str
args_raw: str
args: tuple[str, ...]
raw: str
sequence: int
def to_dict(self) -> dict[str, Any]:
data = asdict(self)
data["args"] = list(self.args)
return data
def _split_args(text: str) -> tuple[str, ...]:
if not text.strip():
return ()
out: list[str] = []
buf: list[str] = []
quote: str | None = None
depth = 0
i = 0
while i < len(text):
ch = text[i]
if quote is not None:
buf.append(ch)
if ch == quote:
quote = None
elif ch == "\\" and i + 1 < len(text):
i += 1
buf.append(text[i])
elif ch in {"'", '"'}:
quote = ch
buf.append(ch)
elif ch == "(":
depth += 1
buf.append(ch)
elif ch == ")":
depth = max(0, depth - 1)
buf.append(ch)
elif ch == "," and depth == 0:
out.append("".join(buf).strip())
buf.clear()
else:
buf.append(ch)
i += 1
out.append("".join(buf).strip())
return tuple(out)
def parse_instruction_string(instruction: str) -> list[PresentationRule]:
"""Parse S-52 INST using OpenCPN StringToRules/SCANFWRD semantics.
OpenCPN intentionally scans each command to the next semicolon/record end
instead of balancing parentheses. Some production Presentation Library
records rely on that tolerance, so this parser does the same.
"""
text = str(instruction or "").replace("\x1f", ";").strip()
rules: list[PresentationRule] = []
i = 0
while i < len(text):
while i < len(text) and (text[i].isspace() or text[i] == ";"):
i += 1
if i >= len(text):
break
if i + 2 >= len(text):
raise PresentationParseError(f"truncated opcode at {i}: {text!r}")
opcode = text[i : i + 2]
if opcode not in SUPPORTED_OPCODES:
raise PresentationParseError(
f"unsupported opcode {opcode!r} at {i}: {text!r}"
)
start = i
i += 2
while i < len(text) and text[i].isspace():
i += 1
if i >= len(text) or text[i] != "(":
raise PresentationParseError(
f"missing '(' after {opcode} at {i}: {text!r}"
)
i += 1
args_start = i
while i < len(text) and text[i] != ";":
i += 1
payload = text[args_start:i].strip()
if payload.endswith(")"):
payload = payload[:-1].rstrip()
raw = text[start:i].strip()
rules.append(
PresentationRule(
opcode=opcode,
args_raw=payload,
args=_split_args(payload),
raw=raw,
sequence=len(rules),
)
)
return rules
def compile_lookup_ir(row: dict[str, Any]) -> dict[str, Any]:
rules = parse_instruction_string(str(row.get("instruction") or ""))
return {
"id": row.get("id"),
"rcid": row.get("rcid"),
"object_class": row.get("object_class"),
"geometry_type": row.get("geometry_type"),
"table_name": row.get("table_name"),
"display_priority": row.get("display_priority"),
"display_category": row.get("display_category"),
"radar_priority": row.get("radar_priority"),
"attribute_conditions_raw": list(row.get("attribute_conditions_raw") or []),
"instruction": row.get("instruction") or "",
"rules": [rule.to_dict() for rule in rules],
}
def referenced_resource(rule: PresentationRule) -> tuple[str, str] | None:
if not rule.args:
return None
kind_by_opcode = {
"SY": "symbol",
"LC": "line_style",
"AP": "pattern",
"CS": "conditional",
"AC": "color",
}
kind = kind_by_opcode.get(rule.opcode)
if kind is None:
return None
value = rule.args[0].strip().strip("'\"")
if rule.opcode in {"SY", "LC", "AP", "CS"}:
value = value[:8]
return (kind, value) if value else None
def flatten_rules(rows: Iterable[dict[str, Any]]) -> list[PresentationRule]:
out: list[PresentationRule] = []
for row in rows:
out.extend(parse_instruction_string(str(row.get("instruction") or "")))
return out
def static_rule_hints(instruction: str) -> dict[str, Any]:
"""Materialize compact scalar hints; full rule IR remains lookup-side."""
rules = parse_instruction_string(instruction)
out: dict[str, Any] = {}
opcodes = [rule.opcode for rule in rules]
if opcodes:
out["s52_rule_opcodes"] = ",".join(opcodes)
for rule in rules:
args = rule.args
if rule.opcode == "SY" and args and "s52_symbol_code" not in out:
out["s52_symbol_code"] = args[0].strip().strip("'\"")[:8]
elif rule.opcode == "AC" and args and "s52_area_color" not in out:
out["s52_area_color"] = args[0].strip().strip("'\"")
elif rule.opcode == "AP" and args and "s52_area_pattern_code" not in out:
out["s52_area_pattern_code"] = args[0].strip().strip("'\"")[:8]
elif rule.opcode == "LC" and args and "s52_line_complex_code" not in out:
out["s52_line_complex_code"] = args[0].strip().strip("'\"")[:8]
elif rule.opcode == "LS" and len(args) >= 3 and "s52_line_style" not in out:
out["s52_line_style"] = args[0].strip().strip("'\"")
out["s52_line_width"] = args[1].strip().strip("'\"")
out["s52_line_color"] = args[2].strip().strip("'\"")
elif rule.opcode in {"TX", "TE"}:
out["s52_has_text"] = 1
elif rule.opcode == "CS" and args:
name = args[0].strip().strip("'\"")[:8]
existing = out.get("s52_csp")
out["s52_csp"] = f"{existing},{name}" if existing else name
return out

View File

@@ -2,6 +2,9 @@ from __future__ import annotations
from typing import Any, Iterable from typing import Any, Iterable
from .presentation import static_rule_hints
from .csp import S52MarinerSettings, resolve_csp_hints
CM93_COLMAR_TO_S57_COLOUR: dict[int, str] = { CM93_COLMAR_TO_S57_COLOUR: dict[int, str] = {
1: "4", # green 1: "4", # green
@@ -284,15 +287,26 @@ def resolve_lights05(attrs: dict[str, Any]) -> dict[str, Any]:
class S52LookupRegistry: class S52LookupRegistry:
"""Read extracted S-52 lookups once and expose PBF-ready properties.""" """Read extracted S-52 lookups once and expose PBF-ready properties."""
def __init__(self, path, point_table: str = "Paper"): def __init__(
self,
path,
point_table: str = "Paper",
boundary_table: str = "Plain",
):
import json import json
from pathlib import Path from pathlib import Path
self.path = Path(path) self.path = Path(path)
self.point_table = str(point_table) self.point_table = str(point_table)
self.boundary_table = str(boundary_table)
self.settings = S52MarinerSettings()
rows = json.loads(self.path.read_text(encoding="utf-8")) rows = json.loads(self.path.read_text(encoding="utf-8"))
self.by_class: dict[str, list[dict[str, Any]]] = {} self.by_class: dict[str, list[dict[str, Any]]] = {}
for row in rows: for row in rows:
row = dict(row)
row["_static_hints"] = static_rule_hints(
str(row.get("instruction") or "")
)
object_class = str(row.get("object_class") or "") object_class = str(row.get("object_class") or "")
self.by_class.setdefault(object_class, []).append(row) self.by_class.setdefault(object_class, []).append(row)
@@ -303,13 +317,19 @@ class S52LookupRegistry:
attrs: dict[str, Any], attrs: dict[str, Any],
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
geometry = str(geometry_type).upper() geometry = str(geometry_type).upper()
if geometry != "POINT": table_geometry = {
"POINT": (self.point_table, "Point"),
"LINE": ("Lines", "Line"),
"AREA": (self.boundary_table, "Area"),
}.get(geometry)
if table_geometry is None:
return None return None
table_name, lookup_geometry = table_geometry
return find_best_lookup( return find_best_lookup(
self.by_class.get(str(object_class), []), self.by_class.get(str(object_class), []),
attrs, attrs,
table_name=self.point_table, table_name=table_name,
geometry_type="Point", geometry_type=lookup_geometry,
) )
def properties( def properties(
@@ -317,6 +337,8 @@ class S52LookupRegistry:
object_class: str, object_class: str,
geometry_type: str, geometry_type: str,
attrs: dict[str, Any], attrs: dict[str, Any],
*,
next_safe_contour_m: float | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
row = self.resolve(object_class, geometry_type, attrs) row = self.resolve(object_class, geometry_type, attrs)
if row is None: if row is None:
@@ -328,12 +350,29 @@ class S52LookupRegistry:
"s52_display_priority": str(row.get("display_priority") or ""), "s52_display_priority": str(row.get("display_priority") or ""),
"s52_table_name": str(row.get("table_name") or ""), "s52_table_name": str(row.get("table_name") or ""),
} }
symbol = primary_symbol_code(row) out.update(dict(row.get("_static_hints") or {}))
if symbol: procedures = [
out["s52_symbol_code"] = symbol token.strip()
procedures = conditional_procedures(row) for token in str(out.get("s52_csp") or "").split(",")
if procedures: if token.strip()
out["s52_csp"] = ",".join(procedures) ]
unresolved: list[str] = []
for procedure in procedures:
resolved = resolve_csp_hints(
procedure,
str(object_class),
attrs,
self.settings,
next_safe_contour_m=next_safe_contour_m,
)
if resolved:
out.update(resolved)
else:
unresolved.append(procedure)
if unresolved:
out["s52_csp"] = ",".join(unresolved)
else:
out.pop("s52_csp", None)
return {key: value for key, value in out.items() if value != ""} return {key: value for key, value in out.items() if value != ""}

View File

@@ -0,0 +1,172 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
from collections import Counter, defaultdict
import json
from pathlib import Path
import re
import xml.etree.ElementTree as ET
from cm95pbf.presentation import (
compile_lookup_ir,
parse_instruction_string,
referenced_resource,
)
def _text(node: ET.Element, tag: str) -> str:
child = node.find(tag)
return (child.text or "").strip() if child is not None else ""
def _lookup_rows(root: ET.Element) -> list[dict]:
out = []
parent = root.find("lookups")
if parent is None:
return out
for node in parent.findall("lookup"):
out.append({
"id": node.attrib.get("id"),
"rcid": node.attrib.get("RCID"),
"object_class": node.attrib.get("name", ""),
"geometry_type": _text(node, "type"),
"display_priority": _text(node, "disp-prio"),
"radar_priority": _text(node, "radar-prio"),
"table_name": _text(node, "table-name"),
"display_category": _text(node, "display-cat"),
"attribute_conditions_raw": [
(x.text or "").strip()
for x in node.findall("attrib-code")
if (x.text or "").strip()
],
"instruction": _text(node, "instruction"),
})
return out
def _resource_names(root: ET.Element) -> dict[str, set[str]]:
symbols = {
_text(node, "name")
for node in root.find("symbols").findall("symbol")
if _text(node, "name")
} if root.find("symbols") is not None else set()
lines = {
_text(node, "name")
for node in root.find("line-styles").findall("line-style")
if _text(node, "name")
} if root.find("line-styles") is not None else set()
patterns = {
_text(node, "name")
for node in root.find("patterns").findall("pattern")
if _text(node, "name")
} if root.find("patterns") is not None else set()
colors: set[str] = set()
color_tables = root.find("color-tables")
if color_tables is not None:
for table in color_tables.findall("color-table"):
for node in table.findall("color"):
name = node.attrib.get("name", "").strip()
if name:
colors.add(name)
return {
"symbol": symbols,
"line_style": lines,
"pattern": patterns,
"color": colors,
}
def _opencpn_csp_names(path: Path | None) -> set[str]:
if path is None or not path.is_file():
return set()
text = path.read_text(encoding="utf-8", errors="replace")
block_match = re.search(
r"Cond\s+condTable\[\]\s*=\s*\{(?P<body>.*?)\{\"########\"",
text,
re.S,
)
if not block_match:
return set()
return set(re.findall(r'\{\"([A-Z0-9#]{8})\"\s*,', block_match.group("body")))
def main() -> int:
parser = argparse.ArgumentParser(description="Audit S-52 lookup -> Presentation Rule IR coverage.")
parser.add_argument("--chartsymbols", required=True, type=Path)
parser.add_argument("--output", required=True, type=Path)
parser.add_argument("--opencpn-csp-source", type=Path)
args = parser.parse_args()
root = ET.parse(args.chartsymbols).getroot()
rows = _lookup_rows(root)
resources = _resource_names(root)
csp_names = _opencpn_csp_names(args.opencpn_csp_source)
compiled: list[dict] = []
failures: list[dict] = []
opcode_counts: Counter[str] = Counter()
references: dict[str, Counter[str]] = defaultdict(Counter)
for row in rows:
try:
ir = compile_lookup_ir(row)
compiled.append(ir)
for rule in parse_instruction_string(row["instruction"]):
opcode_counts[rule.opcode] += 1
ref = referenced_resource(rule)
if ref:
references[ref[0]][ref[1]] += 1
except Exception as exc:
failures.append({
"object_class": row.get("object_class"),
"rcid": row.get("rcid"),
"instruction": row.get("instruction"),
"error": f"{type(exc).__name__}: {exc}",
})
missing: dict[str, list[str]] = {}
for kind in ("symbol", "line_style", "pattern", "color"):
missing[kind] = sorted(set(references[kind]) - resources[kind])
if csp_names:
missing["conditional"] = sorted(set(references["conditional"]) - csp_names)
else:
missing["conditional"] = []
generated_examples = {
"CA": ";CA(OUTLW,4,LITRD,2,180.0,220.0,10.0,25.0)",
"MP": "MP()",
}
generated_parse = {
name: [rule.to_dict() for rule in parse_instruction_string(text)]
for name, text in generated_examples.items()
}
report = {
"schema": "navsea.cm95.s52-presentation-audit/v1",
"lookup_count": len(rows),
"compiled_lookup_count": len(compiled),
"parse_failure_count": len(failures),
"opcode_counts": dict(sorted(opcode_counts.items())),
"resource_reference_counts": {
kind: len(counter) for kind, counter in sorted(references.items())
},
"missing_resources": missing,
"opencpn_csp_registry_count": len(csp_names),
"generated_opcode_parse": generated_parse,
}
args.output.mkdir(parents=True, exist_ok=True)
(args.output / "lookup_rule_ir.json").write_text(
json.dumps(compiled, ensure_ascii=False, indent=2), encoding="utf-8"
)
(args.output / "audit.json").write_text(
json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8"
)
(args.output / "parse_failures.json").write_text(
json.dumps(failures, ensure_ascii=False, indent=2), encoding="utf-8"
)
print(json.dumps(report, ensure_ascii=False, indent=2))
return 0 if not failures else 2
if __name__ == "__main__":
raise SystemExit(main())