feat: add S-52 presentation rule engine
This commit is contained in:
50
docs/报告/CM95_S52_Presentation引擎阶段报告_v1.md
Normal file
50
docs/报告/CM95_S52_Presentation引擎阶段报告_v1.md
Normal 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。
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
134
src/cm95pbf/csp.py
Normal 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
|
||||||
@@ -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
186
src/cm95pbf/presentation.py
Normal 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
|
||||||
@@ -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 != ""}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
172
tools/cm95_s52_presentation_audit.py
Normal file
172
tools/cm95_s52_presentation_audit.py
Normal 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())
|
||||||
Reference in New Issue
Block a user