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",
|
||||
s52_lookups_path: Path | None = None,
|
||||
s52_point_table: str = "Paper",
|
||||
s52_boundary_table: str = "Plain",
|
||||
):
|
||||
self.db = db
|
||||
self.dataset_id = int(dataset_id)
|
||||
@@ -282,11 +283,17 @@ class Cm95TileBuilder:
|
||||
self.report_root = Path(report_root)
|
||||
self.profile = str(profile)
|
||||
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
|
||||
else None
|
||||
)
|
||||
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"}:
|
||||
raise ValueError("profile must be delivery or engineering")
|
||||
|
||||
@@ -432,6 +439,7 @@ class Cm95TileBuilder:
|
||||
|
||||
consumed: set[int] = set()
|
||||
s52_context = self._build_s52_context(selected)
|
||||
self._s52_context = s52_context
|
||||
nav_outputs = self._compose_navigation_aids(
|
||||
selected,
|
||||
manifest,
|
||||
@@ -1696,10 +1704,21 @@ class Cm95TileBuilder:
|
||||
"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_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:
|
||||
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":
|
||||
continue
|
||||
if not (row.object_class.startswith("BOY") or row.object_class in {"LITFLT", "LITVES"}):
|
||||
@@ -1713,10 +1732,11 @@ class Cm95TileBuilder:
|
||||
return {
|
||||
"floating_relations": floating_relations,
|
||||
"floating_points": floating_points,
|
||||
"next_safe_contour_m": min(contour_values) if contour_values else None,
|
||||
}
|
||||
|
||||
@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")
|
||||
if relation is not None and (row.cell_id, str(relation)) in context["floating_relations"]:
|
||||
return True
|
||||
@@ -1732,6 +1752,7 @@ class Cm95TileBuilder:
|
||||
row.object_class,
|
||||
row.geometry_type,
|
||||
row.attrs,
|
||||
next_safe_contour_m=self._s52_context.get("next_safe_contour_m"),
|
||||
)
|
||||
|
||||
def _append_s52_fallback_output(
|
||||
@@ -1740,11 +1761,9 @@ class Cm95TileBuilder:
|
||||
manifest,
|
||||
row: SourceFeature,
|
||||
trace_rows,
|
||||
s52_context: dict[str, set[Any]],
|
||||
s52_context: dict[str, Any],
|
||||
) -> bool:
|
||||
if self.s52 is None or row.geometry_type != "POINT":
|
||||
return False
|
||||
if row.object_class not in {"BOYLAT", "TOPMAR"}:
|
||||
if self.s52 is None or row.geometry_type not in {"POINT", "LINE", "AREA"}:
|
||||
return False
|
||||
|
||||
s52_props = self._s52_properties(row)
|
||||
@@ -1757,12 +1776,16 @@ class Cm95TileBuilder:
|
||||
s52_props["s52_csp_resolved"] = "TOPMAR01"
|
||||
s52_props.pop("s52_csp", None)
|
||||
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
|
||||
|
||||
source_id = self.source_id(row)
|
||||
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 = {
|
||||
"canonical_object_type": f"s52_{row.object_class.lower()}",
|
||||
**s52_props,
|
||||
|
||||
@@ -63,6 +63,11 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
choices=["Simplified", "Paper"],
|
||||
default="Paper",
|
||||
)
|
||||
tile.add_argument(
|
||||
"--s52-boundary-table",
|
||||
choices=["Plain", "Symbolized"],
|
||||
default="Plain",
|
||||
)
|
||||
|
||||
sample = sub.add_parser(
|
||||
"build-sample-aoi",
|
||||
@@ -87,6 +92,11 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
choices=["Simplified", "Paper"],
|
||||
default="Paper",
|
||||
)
|
||||
sample.add_argument(
|
||||
"--s52-boundary-table",
|
||||
choices=["Plain", "Symbolized"],
|
||||
default="Plain",
|
||||
)
|
||||
|
||||
|
||||
prod_plan = sub.add_parser(
|
||||
@@ -310,6 +320,7 @@ def main() -> None:
|
||||
profile=args.profile,
|
||||
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,
|
||||
)
|
||||
result = builder.build_tile(
|
||||
TileJob(args.z, args.x, args.y)
|
||||
@@ -344,6 +355,7 @@ def main() -> None:
|
||||
profile=profile,
|
||||
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,
|
||||
)
|
||||
for z, x, y in jobs:
|
||||
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",
|
||||
"s52_csp",
|
||||
"s52_csp_resolved",
|
||||
"s52_csp_rule_opcodes",
|
||||
"s52_is_safety_contour",
|
||||
"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_radius_mm",
|
||||
"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 .presentation import static_rule_hints
|
||||
from .csp import S52MarinerSettings, resolve_csp_hints
|
||||
|
||||
|
||||
CM93_COLMAR_TO_S57_COLOUR: dict[int, str] = {
|
||||
1: "4", # green
|
||||
@@ -284,15 +287,26 @@ def resolve_lights05(attrs: dict[str, Any]) -> dict[str, Any]:
|
||||
class S52LookupRegistry:
|
||||
"""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
|
||||
from pathlib import Path
|
||||
|
||||
self.path = Path(path)
|
||||
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"))
|
||||
self.by_class: dict[str, list[dict[str, Any]]] = {}
|
||||
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 "")
|
||||
self.by_class.setdefault(object_class, []).append(row)
|
||||
|
||||
@@ -303,13 +317,19 @@ class S52LookupRegistry:
|
||||
attrs: dict[str, Any],
|
||||
) -> dict[str, Any] | None:
|
||||
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
|
||||
table_name, lookup_geometry = table_geometry
|
||||
return find_best_lookup(
|
||||
self.by_class.get(str(object_class), []),
|
||||
attrs,
|
||||
table_name=self.point_table,
|
||||
geometry_type="Point",
|
||||
table_name=table_name,
|
||||
geometry_type=lookup_geometry,
|
||||
)
|
||||
|
||||
def properties(
|
||||
@@ -317,6 +337,8 @@ class S52LookupRegistry:
|
||||
object_class: str,
|
||||
geometry_type: str,
|
||||
attrs: dict[str, Any],
|
||||
*,
|
||||
next_safe_contour_m: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
row = self.resolve(object_class, geometry_type, attrs)
|
||||
if row is None:
|
||||
@@ -328,12 +350,29 @@ class S52LookupRegistry:
|
||||
"s52_display_priority": str(row.get("display_priority") or ""),
|
||||
"s52_table_name": str(row.get("table_name") or ""),
|
||||
}
|
||||
symbol = primary_symbol_code(row)
|
||||
if symbol:
|
||||
out["s52_symbol_code"] = symbol
|
||||
procedures = conditional_procedures(row)
|
||||
if procedures:
|
||||
out["s52_csp"] = ",".join(procedures)
|
||||
out.update(dict(row.get("_static_hints") or {}))
|
||||
procedures = [
|
||||
token.strip()
|
||||
for token in str(out.get("s52_csp") or "").split(",")
|
||||
if token.strip()
|
||||
]
|
||||
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 != ""}
|
||||
|
||||
|
||||
|
||||
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