From b8b68514129f4493f761b24468637e7622bc688b Mon Sep 17 00:00:00 2001 From: OpenAI Codex Date: Thu, 17 Sep 2026 07:54:13 +0800 Subject: [PATCH] feat: implement S-52 LIGHTS05 presentation --- docs/报告/CM95_S52_LIGHTS05阶段报告_v1.md | 40 +++++ src/cm95pbf/csp.py | 191 ++++++++++++++++++++++ src/cm95pbf/mapping.py | 10 ++ src/cm95pbf/presentation.py | 13 ++ src/cm95pbf/s52.py | 3 +- 5 files changed, 256 insertions(+), 1 deletion(-) create mode 100644 docs/报告/CM95_S52_LIGHTS05阶段报告_v1.md diff --git a/docs/报告/CM95_S52_LIGHTS05阶段报告_v1.md b/docs/报告/CM95_S52_LIGHTS05阶段报告_v1.md new file mode 100644 index 0000000..837542c --- /dev/null +++ b/docs/报告/CM95_S52_LIGHTS05阶段报告_v1.md @@ -0,0 +1,40 @@ +# CM95 S-52 LIGHTS05 阶段报告 v1 + +日期:2026-09-17 + +## 结论 + +LIGHTS05 已从“诊断字段”升级为统一 Presentation Rule IR 的正式 CSP 实现。 + +实现链路: + +`CS(LIGHTS05) → LIGHTS06 等价规则 → SY/CA/TX → Presentation Rule IR → PBF 标量属性 → MapLibre runtime renderer` + +OpenCPN 当前实现被作为行为参考;未直接复制其渲染器代码。 + +## 实现内容 + +- 支持短程 flare:`LIGHTS11 / LIGHTS12 / LIGHTS13 / LITDEF11`。 +- 支持全周灯 `CA(...)` 圆环。 +- 支持 sector `CA(...)` 参数、遮蔽 sector 和径向边界参数。 +- 支持灯质文字组合:LITCHR、SIGGRP、COLOUR、SIGPER、HEIGHT、VALNMR。 + +## 福冈湾 z12 实测 + +Tile:`12/3531/1641` + +- LIGHTS:62 +- `s52_csp_resolved=LIGHTS05`:62 +- flare:60 +- all-round CA:2 +- 未解析 LIGHTS05:0 +- flare symbol:`LIGHTS11=24 / LIGHTS12=24 / LIGHTS13=9 / LITDEF11=3` +- 灯质文字:59 + +浏览器使用 Chromium/Puppeteer 实际渲染通过;基础海图、深度色、等深线、底质、主体航标、灯光 flare 和 CA 圆环同时可见。 + +## 运行时边界 + +`CA` 半径继续按屏幕毫米解释,Web 测试 renderer 通过 `project/unproject` 在移动、缩放后重建几何;不得固化为地理米数。 + +本 tile 没有 `SECTR1/SECTR2` sector light,因此 sector leg/sector arc 的真实数据视觉验收仍需使用包含 sector light 的相邻 tile。 diff --git a/src/cm95pbf/csp.py b/src/cm95pbf/csp.py index 4c5a773..85d011e 100644 --- a/src/cm95pbf/csp.py +++ b/src/cm95pbf/csp.py @@ -100,6 +100,192 @@ def resolve_depcnt02_instruction( return ("LS(SOLD,2,DEPSC)" if safe else "LS(SOLD,1,DEPCN)"), safe + +def _int_list(value: Any) -> list[int]: + if value is None or value == "": + return [] + if isinstance(value, (list, tuple)): + raw = list(value) + elif isinstance(value, str): + raw = [part.strip() for part in value.strip().strip("[]").split(",") if part.strip()] + else: + raw = [value] + out: list[int] = [] + for item in raw: + try: + out.append(int(item)) + except (TypeError, ValueError): + continue + return out + + +def _optional_float(value: Any) -> float | None: + try: + return float(value) + except (TypeError, ValueError): + return None + + +def _light_color_ref(colours: list[int]) -> str: + if len(colours) == 1: + if 3 in colours: + return "LITRD" + if 4 in colours: + return "LITGN" + if any(code in colours for code in (1, 6, 11)): + return "LITYW" + return "CHMGD" + if len(colours) == 2: + if 1 in colours and 3 in colours: + return "LITRD" + if 1 in colours and 4 in colours: + return "LITGN" + return "CHMGD" + + +def _light_flare_symbol(colours: list[int]) -> str: + ref = _light_color_ref(colours) + return { + "LITRD": "LIGHTS11", + "LITGN": "LIGHTS12", + "LITYW": "LIGHTS13", + }.get(ref, "LITDEF11") + + +def _light_radius_mm(valnmr: float) -> float: + if valnmr > 0: + if valnmr < 7.0: + return 3.0 + if valnmr < 15.0: + return 10.0 + if valnmr < 30.0: + return 15.0 + return 20.0 + return 3.0 + + +_LIGHT_CHARACTER = { + 1: "F", 2: "Fl", 3: "LFl", 4: "Q", 5: "VQ", 6: "UQ", + 7: "Iso", 8: "Occ", 9: "IQ", 10: "IVQ", 11: "IUQ", 12: "Mo", + 13: "F + Fl", 14: "Fl + LFl", 15: "Occ + Fl", 16: "F + LFl", + 17: "Al Occ", 18: "Al LFl", 19: "Al Fl", 20: "Al Grp", + 21: "F", 22: "F", 23: "F", 24: "F", 25: "Q + LFl", + 26: "VQ + LFl", 27: "UQ + LFl", 28: "Alt", 29: "F + Alt", +} + + +def _light_description(attrs: dict[str, Any], *, has_sector: bool) -> str: + try: + litchr = int(attrs.get("LITCHR")) + except (TypeError, ValueError): + litchr = -9 + text = _LIGHT_CHARACTER.get(litchr, "") + two_groups = litchr in {13, 14, 15, 16, 25, 26, 27, 29} + siggrp = str(attrs.get("SIGGRP") or "").strip() + if siggrp: + groups = [part for part in siggrp.replace(")(", ",").strip("()").split(",") if part] + if two_groups and groups: + first = groups[0] + if " + " in text: + left, right = text.split(" + ", 1) + text = f"{left}({first}) + {right}" + if len(groups) > 1 and groups[1] != "1": + text += f"({groups[1]})" + elif siggrp != "(1)": + text += siggrp + + if not has_sector: + colour_codes = {1: "W", 3: "R", 4: "G", 6: "Y"} + colours = "".join(colour_codes.get(code, "") for code in _int_list(attrs.get("COLOUR"))) + if colours: + text += f" {colours}" + + sigper = _optional_float(attrs.get("SIGPER")) + if sigper is not None: + text += f" {sigper:.1f}s" if abs(round(sigper) - sigper) > 0.01 else f" {sigper:.0f}s" + height = _optional_float(attrs.get("HEIGHT")) + if height is not None: + text += f" {height:.0f}m" + valnmr = _optional_float(attrs.get("VALNMR")) + if valnmr is not None and not has_sector: + text += f" {valnmr:.0f}Nm" + if litchr in {21, 23}: + text += " (vert)" + elif litchr in {22, 24}: + text += " (horz)" + return text.strip() + + +def resolve_lights05_instruction(attrs: dict[str, Any]) -> tuple[str, dict[str, Any]]: + catlit = _int_list(attrs.get("CATLIT")) + valnmr = _optional_float(attrs.get("VALNMR")) + if valnmr is None: + valnmr = 9.0 + + sectr1 = _optional_float(attrs.get("SECTR1")) + sectr2 = _optional_float(attrs.get("SECTR2")) + has_sector = sectr1 is not None and sectr2 is not None + colours = _int_list(attrs.get("COLOUR")) or [12] + extra: dict[str, Any] = { + "s52_light_nominal_range_nm": float(valnmr), + } + + if 8 in catlit or 11 in catlit: + instruction = "SY(LIGHTS82)" + extra["s52_light_mode"] = "special" + elif 9 in catlit: + instruction = "SY(LIGHTS81)" + extra["s52_light_mode"] = "special" + elif not has_sector: + if any(code in catlit for code in (1, 14)): + instruction = "SY(QUESMRK1)" + extra["s52_light_mode"] = "directional_unknown" + elif valnmr < 10.0: + instruction = f"SY({_light_flare_symbol(colours)},135)" + extra["s52_light_mode"] = "flare" + extra["s52_light_rotation_deg"] = 135.0 + else: + radius = _light_radius_mm(valnmr) + color = _light_color_ref(colours) + radius_adjust = {"LITRD": 1.0, "LITGN": 0.0, "LITYW": 2.0, "CHMGD": 3.0}.get(color, 5.0) + instruction = f"CA(OUTLW,4,{color},2,0,360,{radius + radius_adjust:.1f},0)" + extra["s52_light_mode"] = "all_round" + else: + assert sectr1 is not None and sectr2 is not None + sweep = (sectr2 - sectr1) % 360.0 + if sweep < 1.0: + radius = _light_radius_mm(valnmr) + color = _light_color_ref(colours) + radius_adjust = {"LITRD": 1.0, "LITGN": 0.0, "LITYW": 2.0, "CHMGD": 3.0}.get(color, 5.0) + instruction = f"CA(OUTLW,4,{color},2,0,360,{radius + radius_adjust:.1f},0)" + extra["s52_light_mode"] = "all_round" + else: + arc_radius = 10.0 if 0 < valnmr < 15.0 else 15.0 if valnmr < 30.0 else 20.0 + color = _light_color_ref(colours) + outline_color, outline_width, arc_width = "OUTLW", 4, 2 + litvis = _int_list(attrs.get("LITVIS")) + if any(code in litvis for code in (3, 7, 8)): + outline_color, outline_width, color, arc_width = "CHBLK", 4, "CHBRN", 1 + extra["s52_light_obscured"] = 1 + start = (sectr1 + 180.0) % 360.0 + end = (sectr2 + 180.0) % 360.0 + if end <= start: + end += 360.0 + instruction = f"CA({outline_color},{outline_width},{color},{arc_width},{start:.1f},{end:.1f},{arc_radius:.1f},25.0)" + extra.update({ + "s52_light_mode": "sector", + "s52_sector_start_deg": float(sectr1), + "s52_sector_end_deg": float(sectr2), + "s52_sector_sweep_deg": float(sweep), + }) + + description = _light_description(attrs, has_sector=has_sector) + if description: + safe = description.replace("'", "’") + instruction += f";TX('{safe}',3,2,3,'15110',2,0,CHBLK,23)" + extra["s52_light_label_text"] = description + return instruction, extra + def resolve_csp_hints( csp_name: str, object_class: str, @@ -122,10 +308,15 @@ def resolve_csp_hints( next_safe_contour_m=next_safe_contour_m, ) extra["s52_is_safety_contour"] = int(safe) + elif name == "LIGHTS05": + instruction, light_extra = resolve_lights05_instruction(attrs) + extra.update(light_extra) else: return {} hints = static_rule_hints(instruction) + if name == "LIGHTS05" and hints.get("s52_symbol_code"): + extra["s52_light_symbol_code"] = hints["s52_symbol_code"] hints.update(extra) hints["s52_csp_resolved"] = name hints["s52_csp_rule_opcodes"] = ",".join( diff --git a/src/cm95pbf/mapping.py b/src/cm95pbf/mapping.py index e7317a6..d86b565 100644 --- a/src/cm95pbf/mapping.py +++ b/src/cm95pbf/mapping.py @@ -39,6 +39,14 @@ DELIVERY_PROPERTY_ALLOWLIST = frozenset( "s52_display_category", "s52_area_color", "s52_area_pattern_code", + "s52_arc_color", + "s52_arc_end_deg", + "s52_arc_outline_color", + "s52_arc_outline_width", + "s52_arc_radius_mm", + "s52_arc_sector_radius_mm", + "s52_arc_start_deg", + "s52_arc_width", "s52_has_text", "s52_line_color", "s52_line_complex_code", @@ -50,6 +58,7 @@ DELIVERY_PROPERTY_ALLOWLIST = frozenset( "s52_light_mode", "s52_light_nominal_range_nm", "s52_light_obscured", + "s52_light_label_text", "s52_light_rotation_deg", "s52_light_sector_radius_mm", "s52_light_symbol_code", @@ -62,6 +71,7 @@ DELIVERY_PROPERTY_ALLOWLIST = frozenset( "s52_lookup_rcid", "s52_symbol_code", "s52_table_name", + "s52_text_literal", "shape_class_code", } ) diff --git a/src/cm95pbf/presentation.py b/src/cm95pbf/presentation.py index b5599ff..150a46f 100644 --- a/src/cm95pbf/presentation.py +++ b/src/cm95pbf/presentation.py @@ -177,8 +177,21 @@ def static_rule_hints(instruction: str) -> dict[str, Any]: 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 == "CA" and len(args) >= 8: + out["s52_arc_outline_color"] = args[0].strip().strip("\'\"") + out["s52_arc_outline_width"] = args[1].strip().strip("\'\"") + out["s52_arc_color"] = args[2].strip().strip("\'\"") + out["s52_arc_width"] = args[3].strip().strip("\'\"") + out["s52_arc_start_deg"] = args[4].strip().strip("\'\"") + out["s52_arc_end_deg"] = args[5].strip().strip("\'\"") + out["s52_arc_radius_mm"] = args[6].strip().strip("\'\"") + out["s52_arc_sector_radius_mm"] = args[7].strip().strip("\'\"") elif rule.opcode in {"TX", "TE"}: out["s52_has_text"] = 1 + if rule.opcode == "TX" and args: + literal = args[0].strip() + if len(literal) >= 2 and literal[0] in {"\'", '"'} and literal[-1] == literal[0]: + out.setdefault("s52_text_literal", literal[1:-1]) elif rule.opcode == "CS" and args: name = args[0].strip().strip("'\"")[:8] existing = out.get("s52_csp") diff --git a/src/cm95pbf/s52.py b/src/cm95pbf/s52.py index 6d244a3..3584aba 100644 --- a/src/cm95pbf/s52.py +++ b/src/cm95pbf/s52.py @@ -356,12 +356,13 @@ class S52LookupRegistry: for token in str(out.get("s52_csp") or "").split(",") if token.strip() ] + normalized_attrs = normalize_cm93_attributes_for_s52(attrs) unresolved: list[str] = [] for procedure in procedures: resolved = resolve_csp_hints( procedure, str(object_class), - attrs, + normalized_attrs, self.settings, next_safe_contour_m=next_safe_contour_m, )