From f3047a3f06d2eac3f603dcb54646a60fdead2354 Mon Sep 17 00:00:00 2001 From: OpenAI Codex Date: Mon, 21 Sep 2026 05:54:36 +0800 Subject: [PATCH] fix: align local S-52 CSP decisions with OpenCPN --- src/cm95pbf/csp.py | 76 +++++++++++++++++++++++++--------------------- 1 file changed, 42 insertions(+), 34 deletions(-) diff --git a/src/cm95pbf/csp.py b/src/cm95pbf/csp.py index c5b33c4..be7cedd 100644 --- a/src/cm95pbf/csp.py +++ b/src/cm95pbf/csp.py @@ -145,13 +145,14 @@ def _optional_float(value: Any) -> float | None: return None -def _light_color_ref(colours: list[int]) -> str: +def _light_color_ref(colours: list[int], *, sector: bool = False) -> 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)): + # OpenCPN uses orange (11) for sectors, amber (9) for flares/circles. + if any(code in colours for code in (1, 6, 11 if sector else 9)): return "LITYW" return "CHMGD" if len(colours) == 2: @@ -193,7 +194,7 @@ _LIGHT_CHARACTER = { } -def _light_description(attrs: dict[str, Any], *, has_sector: bool) -> str: +def _light_description(attrs: dict[str, Any]) -> str: try: litchr = int(attrs.get("LITCHR")) except (TypeError, ValueError): @@ -201,18 +202,18 @@ def _light_description(attrs: dict[str, Any], *, has_sector: bool) -> str: 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 two_groups: + groups = [part for part in siggrp.replace("(", ")").split(")") if part] + first = groups[0][0] if groups else "?" + left, right = text.split(" + ", 1) + text = f"{left}({first}) + {right}" + if len(groups) > 1 and groups[1] != "1": + text += f"({groups[1]})" + elif siggrp and siggrp != "(1)": + text += siggrp + # Description suppression depends on SECTR1 presence, not sector validity. + has_sector = "SECTR1" in attrs 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"))) @@ -243,7 +244,7 @@ def resolve_lights05_instruction(attrs: dict[str, Any]) -> tuple[str, dict[str, sectr1 = _optional_float(attrs.get("SECTR1")) sectr2 = _optional_float(attrs.get("SECTR2")) - has_sector = sectr1 is not None and sectr2 is not None + has_sector = sectr1 not in (None, -9.0) and sectr2 not in (None, -9.0) colours = _int_list(attrs.get("COLOUR")) or [12] extra: dict[str, Any] = { "s52_light_nominal_range_nm": float(valnmr), @@ -266,7 +267,7 @@ def resolve_lights05_instruction(attrs: dict[str, Any]) -> tuple[str, dict[str, 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) + radius_adjust = {"LITRD": 1.0, "LITGN": 0.0, "LITYW": 2.0}.get(color, 3.0 if colours == [12] else 5.0) instruction = f"CA(OUTLW,4,{color},2,0,360,{radius + radius_adjust:.1f},0)" extra["s52_light_mode"] = "all_round" else: @@ -275,12 +276,12 @@ def resolve_lights05_instruction(attrs: dict[str, Any]) -> tuple[str, dict[str, 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) + radius_adjust = {"LITRD": 1.0, "LITGN": 0.0, "LITYW": 2.0}.get(color, 3.0 if colours == [12] else 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) + arc_radius = 10.0 if 0 < valnmr < 15.0 else 15.0 if 15.0 <= valnmr < 30.0 else 20.0 + color = _light_color_ref(colours, sector=True) 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)): @@ -298,7 +299,7 @@ def resolve_lights05_instruction(attrs: dict[str, Any]) -> tuple[str, dict[str, "s52_sector_sweep_deg": float(sweep), }) - description = _light_description(attrs, has_sector=has_sector) + description = _light_description(attrs) if description: safe = description.replace("'", "’") instruction += f";TX('{safe}',3,2,3,'15110',2,0,CHBLK,23)" @@ -760,10 +761,16 @@ def resolve_wrecks02_instruction( quapos = _int_attr(attrs, "QUAPOS", 0) if 2 <= quapos < 10: line = "LC(LOWACC41)" - elif udw or valsou is None or valsou <= 20.0: - line = "LS(DOTT,2,CHBLK)" if udw or valsou is not None else "LS(DOTT,2,CSTLN)" - else: + elif udw or (valsou is not None and valsou <= 20.0): + line = "LS(DOTT,2,CHBLK)" + elif valsou is not None: line = "LS(DASH,2,CHBLK)" + elif watlev in {1, 2}: + line = "LS(SOLD,2,CSTLN)" + elif watlev == 4: + line = "LS(DASH,2,CSTLN)" + else: + line = "LS(DOTT,2,CSTLN)" instruction = line if valsou is not None and valsou <= 20.0: instruction += ";" + udw if udw else "" @@ -836,8 +843,9 @@ def resolve_slcons03_instruction( if geom == "AREA": instructions.append("AP(CROSSX01)") - if quapos_present and 2 <= quapos < 10: - instructions.append("LC(LOWACC21)") + if quapos_present: + if 2 <= quapos < 10: + instructions.append("LC(LOWACC01)") return ";".join(instructions) condtn = _int_attr(attrs, "CONDTN", 0) @@ -860,13 +868,13 @@ def resolve_restrn01_instruction(attrs: dict[str, Any]) -> str: if not restrn: return "" if restrn & {7, 8, 14}: - symbol = "ENTRES61" if restrn & {1, 2, 3, 4, 5, 6} else "ENTRES71" if restrn & {9, 10, 11, 12, 13, 14, 15} else "ENTRES51" + symbol = "ENTRES61" if restrn & {1, 2, 3, 4, 5, 6} else "ENTRES71" if restrn & {9, 10, 11, 12, 13} else "ENTRES51" elif restrn & {1, 2}: - symbol = "ACHRES61" if restrn & {3, 4, 5, 6} else "ACHRES71" if restrn & {9, 10, 11, 12, 13, 14, 15} else "ACHRES51" + symbol = "ACHRES61" if restrn & {3, 4, 5, 6} else "ACHRES71" if restrn & {9, 10, 11, 12, 13} else "ACHRES51" elif restrn & {3, 4, 5, 6}: - symbol = "FSHRES71" if restrn & {9, 10, 11, 12, 13, 14, 15} else "FSHRES51" + symbol = "FSHRES71" if restrn & {9, 10, 11, 12, 13} else "FSHRES51" else: - symbol = "INFARE51" if restrn & {9, 10, 11, 12, 13, 14, 15} else "RSRDEF51" + symbol = "INFARE51" if restrn & {9, 10, 11, 12, 13} else "RSRDEF51" return f"SY({symbol})" @@ -878,18 +886,18 @@ def resolve_resare02_instruction( group_a = {1, 8, 9, 12, 14, 19, 21, 25} group_b = {4, 5, 6, 7, 10, 18, 20, 22, 23, 24} priority = False - if restrn: + if "RESTRN" in attrs: if restrn & {7, 8, 14}: - symbol = "ENTRES61" if (restrn & {1,2,3,4,5,6} or catrea & group_a) else "ENTRES71" if (restrn & {9,10,11,12,13,14,15} or catrea & group_b) else "ENTRES51" + symbol = "ENTRES61" if (restrn & {1,2,3,4,5,6} or catrea & group_a) else "ENTRES71" if (restrn & {9,10,11,12,13} or catrea & group_b) else "ENTRES51" line_code = "RESARE51"; priority = True elif restrn & {1, 2}: - symbol = "ACHRES61" if (restrn & {3,4,5,6} or catrea & group_a) else "ACHRES71" if (restrn & {9,10,11,12,13,14,15} or catrea & group_b) else "RESTRN51" + symbol = "ACHRES61" if (restrn & {3,4,5,6} or catrea & group_a) else "ACHRES71" if (restrn & {9,10,11,12,13} or catrea & group_b) else "RESTRN51" line_code = "RESARE51"; priority = True elif restrn & {3,4,5,6}: - symbol = "FSHRES51" if catrea & group_a else "FSHRES71" if (restrn & {9,10,11,12,13,14,15} or catrea & group_b) else "FSHRES51" + symbol = "FSHRES51" if catrea & group_a else "FSHRES71" if (restrn & {9,10,11,12,13} or catrea & group_b) else "FSHRES51" line_code = "FSHRES51"; priority = True else: - symbol = "INFARE51" if restrn & {9,10,11,12,13,14,15} else "RSRDEF51" + symbol = "INFARE51" if restrn & {9,10,11,12,13} else "RSRDEF51" line_code = "CTYARE51" else: symbol = "CTYARE71" if (catrea & group_a and catrea & group_b) else "CTYARE51" if catrea & group_a else "INFARE51" if catrea & group_b else "RSRDEF51"