Files
CM95/src/cm95pbf/csp.py
2026-09-21 05:54:36 +08:00

980 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
from dataclasses import dataclass
import math
from typing import Any
from .presentation import (
materialize_symbol_hints,
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
symbolized_boundaries: bool = False
# These CSPs depend on mariner-selected depth thresholds. NavSea delivery PBF
# must preserve their chart facts instead of freezing the current comparison
# profile into tile properties. The fixed S52MarinerSettings path remains
# available for OpenCPN/reference rendering.
MARINER_DEPENDENT_CSPS = frozenset(
{"DEPARE01", "DEPARE02", "DEPCNT02", "SOUNDG02", "OBSTRN04", "WRECKS02"}
)
def is_mariner_dependent_csp(csp_name: str) -> bool:
return str(csp_name or "")[:8] in MARINER_DEPENDENT_CSPS
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 _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], *, sector: bool = False) -> str:
if len(colours) == 1:
if 3 in colours:
return "LITRD"
if 4 in colours:
return "LITGN"
# 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:
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]) -> 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 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")))
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 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),
}
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}.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:
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}.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 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)):
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)
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_soundg03_instruction(
attrs: dict[str, Any],
depth_value_in: float,
settings: S52MarinerSettings,
) -> tuple[str, dict[str, Any]]:
depth_value = float(depth_value_in)
if depth_value > 40000.0:
depth_value = 99999.0
if depth_value < -1000.0:
depth_value = 0.0
depth_value += 0.01 if depth_value > 0.0 else -0.01
prefix = "SOUNDS" if depth_value <= settings.safety_depth_m else "SOUNDG"
codes: list[str] = []
swept = 6 in _int_list(attrs.get("TECSOU"))
low_quality = bool(
set(_int_list(attrs.get("QUASOU"))) & {3, 4, 5, 8, 9}
or 18 in _int_list(attrs.get("STATUS"))
or any(2 <= q < 10 for q in _int_list(attrs.get("QUAPOS")))
)
if swept:
codes.append(f"{prefix}B1")
if low_quality:
codes.append(f"{prefix}C2")
leading = int(abs(depth_value))
absolute = abs(depth_value)
if absolute < 10.0:
fraction = int(abs((absolute - leading) * 10))
codes.append(f"{prefix}1{leading}")
if fraction > 0:
codes.append(f"{prefix}5{fraction}")
if depth_value < 0.0:
codes.append(f"{prefix}A1")
elif absolute < 31.0 and (absolute - math.floor(leading)) != 0.0:
fraction = int(abs(absolute - math.floor(leading)) * 10)
if leading >= 10:
codes.append(f"{prefix}2{leading // 10}")
first_digit = math.floor(leading / 10)
second_digit = int(math.floor(leading - first_digit * 10))
codes.append(f"{prefix}1{second_digit}")
if fraction > 0:
codes.append(f"{prefix}5{fraction}")
if depth_value < 0.0:
codes.append(f"{prefix}A1")
elif absolute < 100.0:
first_digit = int(math.floor(leading / 10))
second_digit = int(math.floor(leading - first_digit * 10))
if depth_value < 0.0:
codes.extend([f"{prefix}2{first_digit}", f"{prefix}1{second_digit}", f"{prefix}A1"])
else:
codes.extend([f"{prefix}1{first_digit}", f"{prefix}0{second_digit}"])
elif depth_value < 1000.0:
first = int(math.floor(leading / 100))
second = int(math.floor((leading - first * 100) / 10))
third = int(math.floor(leading - first * 100 - second * 10))
codes.extend([f"{prefix}2{first}", f"{prefix}1{second}", f"{prefix}0{third}"])
elif depth_value < 10000.0:
first = int(math.floor(leading / 1000))
second = int(math.floor((leading - first * 1000) / 100))
third = int(math.floor((leading - first * 1000 - second * 100) / 10))
last = int(math.floor(leading - first * 1000 - second * 100 - third * 10))
codes.extend([f"{prefix}2{first}", f"{prefix}1{second}", f"{prefix}0{third}", f"{prefix}4{last}"])
else:
first = int(math.floor(leading / 10000))
second = int(math.floor((leading - first * 10000) / 1000))
third = int(math.floor((leading - first * 10000 - second * 1000) / 100))
fourth = int(math.floor((leading - first * 10000 - second * 1000 - third * 100) / 10))
last = int(math.floor(leading - first * 10000 - second * 1000 - third * 100 - fourth * 10))
codes.extend([f"{prefix}3{first}", f"{prefix}2{second}", f"{prefix}1{third}", f"{prefix}0{fourth}", f"{prefix}4{last}"])
instruction = ";".join(f"SY({code})" for code in codes)
extra = {
"s52_soundg_point_csp": "SOUNDG03",
"s52_sounding_depth_m": float(depth_value_in),
"s52_sounding_prefix": prefix,
"s52_sounding_symbol_sequence": ",".join(codes),
"s52_sounding_swept": int(swept),
"s52_sounding_low_quality": int(low_quality),
"s52_sounding_is_shallow": int(depth_value <= settings.safety_depth_m),
}
return instruction, extra
def _int_attr(attrs: dict[str, Any], key: str, default: int = -9) -> int:
try:
return int(attrs.get(key))
except (TypeError, ValueError):
return default
def _hazard_depth_context(
associated_depth_ranges: list[dict[str, Any]] | None,
) -> list[dict[str, Any]]:
return list(associated_depth_ranges or [])
def resolve_udwhaz03_instruction(
attrs: dict[str, Any],
depth_value: float | None,
settings: S52MarinerSettings,
*,
associated_depth_ranges: list[dict[str, Any]] | None = None,
) -> tuple[str, dict[str, Any]]:
danger = False
expsou = _int_attr(attrs, "EXPSOU", 0)
if depth_value is None and expsou != 1:
danger = True
if not danger and (
expsou == 1
or (depth_value is not None and depth_value <= settings.safety_contour_m)
):
for item in _hazard_depth_context(associated_depth_ranges):
primitive = str(item.get("geometry_type") or "AREA").upper()
try:
drval1 = float(item.get("drval1") or 0.0)
except (TypeError, ValueError):
drval1 = 0.0
try:
drval2 = float(item.get("drval2") or 0.0)
except (TypeError, ValueError):
drval2 = 0.0
if primitive == "LINE":
if drval2 < settings.safety_contour_m:
danger = True
break
elif drval1 >= settings.safety_contour_m and expsou != 1:
danger = True
break
extra = {
"s52_isolated_danger": int(danger),
"s52_display_base_promoted": int(danger),
}
if depth_value is not None:
extra["s52_hazard_depth_m"] = float(depth_value)
if not danger:
return "", extra
watlev = _int_attr(attrs, "WATLEV", 0)
if watlev in {1, 2}:
return "", extra
return "SY(ISODGR51)", extra
def _area_least_depth(
associated_depth_ranges: list[dict[str, Any]] | None,
) -> float | None:
values: list[float] = []
for item in _hazard_depth_context(associated_depth_ranges):
try:
values.append(float(item.get("drval1")))
except (TypeError, ValueError):
pass
return min(values) if values else None
def _runtime_hazard_depth(
csp_name: str,
object_class: str,
geometry_type: str,
attrs: dict[str, Any],
associated_depth_ranges: list[dict[str, Any]] | None,
) -> float | None:
valsou = _optional_float(attrs.get("VALSOU"))
if valsou is not None:
return valsou
if str(geometry_type).upper() == "AREA":
area_depth = _area_least_depth(associated_depth_ranges)
if area_depth is not None:
return area_depth
name = str(csp_name or "")[:8]
watlev = _int_attr(attrs, "WATLEV", -9)
if name == "WRECKS02":
catwrk = _int_attr(attrs, "CATWRK", -9)
if catwrk == 1:
return 20.0
if catwrk == 2:
return 0.0
if catwrk in {4, 5}:
return -15.0
if watlev == 3:
return 0.01
if watlev == 5:
return 0.0
return -15.0
expsou = _int_attr(attrs, "EXPSOU", 0)
if expsou == 1:
return None
if _int_attr(attrs, "CATOBS", 0) == 6:
return 0.01
if watlev == 5:
return 0.0
if watlev == 3:
return 0.01
return -15.0
def materialize_mariner_runtime_facts(
csp_name: str,
object_class: str,
geometry_type: str,
attrs: dict[str, Any],
*,
sounding_depth_m: float | None = None,
associated_depth_ranges: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Emit mariner-neutral inputs needed by NavSea runtime presentation."""
name = str(csp_name or "")[:8]
if name not in MARINER_DEPENDENT_CSPS:
return {}
out: dict[str, Any] = {
"s52_mariner_dependent": 1,
"s52_runtime_csp": name,
}
if name in {"DEPARE01", "DEPARE02"}:
depth_min = _optional_float(attrs.get("DRVAL1"))
depth_max = _optional_float(attrs.get("DRVAL2"))
if depth_min is not None:
out["depth_min_m"] = depth_min
if depth_max is not None:
out["depth_max_m"] = depth_max
if name == "DEPCNT02":
contour = _optional_float(attrs.get("VALDCO"))
if contour is not None:
out["contour_depth_m"] = contour
if name == "SOUNDG02" and sounding_depth_m is not None:
out["s52_sounding_depth_m"] = float(sounding_depth_m)
out["s52_sounding_swept"] = int(6 in _int_list(attrs.get("TECSOU")))
out["s52_sounding_low_quality"] = int(
bool(
set(_int_list(attrs.get("QUASOU"))) & {3, 4, 5, 8, 9}
or 18 in _int_list(attrs.get("STATUS"))
or any(2 <= q < 10 for q in _int_list(attrs.get("QUAPOS")))
)
)
if name in {"OBSTRN04", "WRECKS02"}:
hazard_depth = _runtime_hazard_depth(
name, object_class, geometry_type, attrs, associated_depth_ranges
)
if hazard_depth is not None:
out["s52_hazard_depth_m"] = float(hazard_depth)
ranges = _hazard_depth_context(associated_depth_ranges)
lows = [_optional_float(item.get("drval1")) for item in ranges]
highs = [_optional_float(item.get("drval2")) for item in ranges]
lows = [value for value in lows if value is not None]
highs = [value for value in highs if value is not None]
if lows:
out["s52_surrounding_max_drval1_m"] = max(lows)
if highs:
out["s52_surrounding_min_drval2_m"] = min(highs)
scalar_codes = {
"QUAPOS": "s52_quapos_code",
"WATLEV": "s52_watlev_code",
"EXPSOU": "s52_expsou_code",
"CATOBS": "s52_catobs_code",
"CATWRK": "s52_catwrk_code",
}
for source_key, target_key in scalar_codes.items():
if attrs.get(source_key) not in {None, ""}:
out[target_key] = _int_attr(attrs, source_key, 0)
list_codes = {
"QUASOU": "s52_quasou_codes",
"TECSOU": "s52_tecsou_codes",
"STATUS": "s52_status_codes",
}
for source_key, target_key in list_codes.items():
values = _int_list(attrs.get(source_key))
if values:
out[target_key] = values
return out
def resolve_obstrn04_instruction(
object_class: str,
geometry_type: str,
attrs: dict[str, Any],
settings: S52MarinerSettings,
*,
associated_depth_ranges: list[dict[str, Any]] | None = None,
) -> tuple[str, dict[str, Any]]:
valsou = _optional_float(attrs.get("VALSOU"))
depth_value = valsou
if depth_value is None and str(geometry_type).upper() == "AREA":
depth_value = _area_least_depth(associated_depth_ranges)
if depth_value is None:
catobs = _int_attr(attrs, "CATOBS", 0)
watlev = _int_attr(attrs, "WATLEV", 0)
expsou = _int_attr(attrs, "EXPSOU", 0)
if expsou != 1:
if catobs == 6:
depth_value = 0.01
elif watlev == 5:
depth_value = 0.0
elif watlev == 3:
depth_value = 0.01
else:
depth_value = -15.0
udw, extra = resolve_udwhaz03_instruction(
attrs,
depth_value,
settings,
associated_depth_ranges=associated_depth_ranges,
)
extra["s52_hazard_procedure"] = "OBSTRN04"
geom = str(geometry_type).upper()
if geom == "POINT":
if udw:
return udw, extra
sounding = False
watlev = _int_attr(attrs, "WATLEV", -9)
if valsou is not None:
if valsou <= 20.0:
if object_class == "UWTROC":
if watlev in {4, 5}:
instruction = "SY(UWTROC04)"
else:
instruction = "SY(DANGER51)"
sounding = True
else:
if watlev == -9:
instruction = "SY(DANGER01)"
sounding = True
elif watlev in {1, 2}:
instruction = "SY(LNDARE01)"
elif watlev == 3:
instruction = "SY(DANGER52)"
sounding = True
elif watlev in {4, 5}:
instruction = "SY(DANGER53)"
sounding = True
else:
instruction = "SY(DANGER51)"
sounding = True
else:
instruction = "SY(DANGER52)"
sounding = True
else:
if object_class == "UWTROC":
instruction = (
"SY(LNDARE01)" if watlev == 2 else
"SY(UWTROC03)" if watlev == 3 else
"SY(UWTROC04)"
)
else:
instruction = (
"SY(OBSTRN11)" if watlev in {1, 2} else
"SY(OBSTRN03)" if watlev in {4, 5} else
"SY(OBSTRN01)"
)
if sounding and valsou is not None:
snd, snd_extra = resolve_soundg03_instruction(attrs, valsou, settings)
instruction += ";" + snd if snd else ""
extra.update(snd_extra)
return instruction, extra
if geom == "LINE":
quapos = _int_attr(attrs, "QUAPOS", 0)
if 2 <= quapos < 10:
return ("LC(LOWACC41)" if udw else "LC(LOWACC31)"), extra
if udw:
return "LS(DOTT,2,CHBLK)", extra
if valsou is None or valsou <= 20.0:
return "LS(DOTT,2,CHBLK)", extra
return "LS(DASH,2,CHBLK)", extra
# AREA
if udw:
return "AC(DEPVS);AP(FOULAR01);LS(DOTT,2,CHBLK);" + udw, extra
watlev = _int_attr(attrs, "WATLEV", -9)
if valsou is not None:
line = "LS(DOTT,2,CHBLK)" if valsou <= 20.0 else "LS(DASH,2,CHBLK)"
return line, extra
if watlev in {1, 2}:
return "AC(CHBRN);LS(SOLD,2,CSTLN)", extra
if watlev == 4:
return "AC(DEPIT);LS(DASH,2,CSTLN)", extra
if _int_attr(attrs, "CATOBS", -9) == 6 and watlev in {3, 5}:
return "AC(DEPVS);AP(FOULAR01);LS(DOTT,2,CHBLK)", extra
return "AC(DEPVS);LS(DOTT,2,CHBLK)", extra
def resolve_wrecks02_instruction(
geometry_type: str,
attrs: dict[str, Any],
settings: S52MarinerSettings,
*,
associated_depth_ranges: list[dict[str, Any]] | None = None,
) -> tuple[str, dict[str, Any]]:
valsou = _optional_float(attrs.get("VALSOU"))
watlev = _int_attr(attrs, "WATLEV", -9)
catwrk = _int_attr(attrs, "CATWRK", -9)
depth_value = valsou
if depth_value is None and str(geometry_type).upper() == "AREA":
depth_value = _area_least_depth(associated_depth_ranges)
if depth_value is None:
if catwrk == 1:
depth_value = 20.0
elif catwrk == 2:
depth_value = 0.0
elif catwrk in {4, 5}:
depth_value = -15.0
elif watlev == 3:
depth_value = 0.01
elif watlev == 5:
depth_value = 0.0
else:
depth_value = -15.0
quasou7 = 7 in _int_list(attrs.get("QUASOU"))
if quasou7:
udw, udw_extra = "", {"s52_isolated_danger": 0, "s52_display_base_promoted": 0}
else:
udw, udw_extra = resolve_udwhaz03_instruction(
attrs, depth_value, settings,
associated_depth_ranges=associated_depth_ranges,
)
extra = dict(udw_extra)
extra.update({"s52_hazard_procedure": "WRECKS02", "s52_hazard_depth_m": float(depth_value)})
geom = str(geometry_type).upper()
if geom == "POINT":
if udw:
return udw, extra
if valsou is not None:
symbol = "DANGER51" if valsou < settings.safety_contour_m else "DANGER52"
instruction = f"SY({symbol});TX('Wk',3,1,2,'15110',2,0,CHBLK,21)"
if quasou7:
instruction += ";SY(WRECKS07)"
snd, snd_extra = resolve_soundg03_instruction(attrs, valsou, settings)
if snd:
instruction += ";" + snd
extra.update(snd_extra)
return instruction, extra
if catwrk == 1 and watlev == 3:
return "SY(WRECKS04)", extra
if catwrk == 2 and watlev == 3:
return "SY(WRECKS05)", extra
if catwrk in {4, 5} or watlev in {1, 2, 4, 5}:
return "SY(WRECKS01)", extra
return "SY(WRECKS05)", extra
quapos = _int_attr(attrs, "QUAPOS", 0)
if 2 <= quapos < 10:
line = "LC(LOWACC41)"
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 ""
snd, snd_extra = resolve_soundg03_instruction(attrs, valsou, settings)
if snd:
instruction += ";" + snd
extra.update(snd_extra)
elif valsou is None:
color = "CHBRN" if watlev in {1, 2} else "DEPIT" if watlev == 4 else "DEPVS"
instruction += f";AC({color})"
if udw:
instruction += ";" + udw
return instruction, extra
def resolve_quapnt01_instruction(attrs: dict[str, Any]) -> str:
quapos = _int_attr(attrs, "QUAPOS", 10)
if not (2 <= quapos < 10):
return ""
if quapos == 4:
return "SY(QUAPOS01)"
if quapos == 5:
return "SY(QUAPOS02)"
if quapos in {7, 8}:
return "SY(QUAPOS03)"
return "SY(LOWACC03)"
def resolve_qualin01_instruction(
object_class: str,
attrs: dict[str, Any],
) -> str:
quapos_present = attrs.get("QUAPOS") is not None
quapos = _int_attr(attrs, "QUAPOS", 0)
if quapos_present:
if 2 <= quapos < 10:
return "LC(LOWACC21)"
return ""
if object_class == "COALNE":
conrad = _int_attr(attrs, "CONRAD", 0)
if conrad == 1:
return "LS(SOLD,3,CHMGF);LS(SOLD,1,CSTLN)"
return "LS(SOLD,1,CSTLN)"
def resolve_quapos01_instruction(
object_class: str,
geometry_type: str,
attrs: dict[str, Any],
) -> str:
if str(geometry_type).upper() == "LINE":
return resolve_qualin01_instruction(object_class, attrs)
return resolve_quapnt01_instruction(attrs)
def resolve_slcons03_instruction(
geometry_type: str,
attrs: dict[str, Any],
) -> str:
geom = str(geometry_type).upper()
quapos_present = attrs.get("QUAPOS") is not None
quapos = _int_attr(attrs, "QUAPOS", 0)
if geom == "POINT":
if quapos_present and 2 <= quapos < 10:
return "SY(LOWACC01)"
return ""
instructions: list[str] = []
if geom == "AREA":
instructions.append("AP(CROSSX01)")
if quapos_present:
if 2 <= quapos < 10:
instructions.append("LC(LOWACC01)")
return ";".join(instructions)
condtn = _int_attr(attrs, "CONDTN", 0)
catslc = _int_attr(attrs, "CATSLC", 0)
watlev = _int_attr(attrs, "WATLEV", 0)
if condtn in {1, 2}:
instructions.append("LS(DASH,1,CSTLN)")
elif catslc in {6, 15, 16}:
instructions.append("LS(SOLD,4,CSTLN)")
elif watlev == 2:
instructions.append("LS(SOLD,2,CSTLN)")
elif watlev in {3, 4}:
instructions.append("LS(DASH,2,CSTLN)")
else:
instructions.append("LS(SOLD,2,CSTLN)")
return ";".join(instructions)
def resolve_restrn01_instruction(attrs: dict[str, Any]) -> str:
restrn = set(_int_list(attrs.get("RESTRN")))
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} else "ENTRES51"
elif restrn & {1, 2}:
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} else "FSHRES51"
else:
symbol = "INFARE51" if restrn & {9, 10, 11, 12, 13} else "RSRDEF51"
return f"SY({symbol})"
def resolve_resare02_instruction(
attrs: dict[str, Any], settings: S52MarinerSettings
) -> str:
restrn = set(_int_list(attrs.get("RESTRN")))
catrea = set(_int_list(attrs.get("CATREA")))
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" 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} 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} 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} or catrea & group_b) else "FSHRES51"
line_code = "FSHRES51"; priority = True
else:
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"
line_code = "CTYARE51"
line = f"LC({line_code})" if settings.symbolized_boundaries else "LS(DASH,2,CHMGD)"
parts = [line, f"SY({symbol})"]
if priority:
parts.insert(0, "OP(6---)")
return ";".join(parts)
def resolve_csp_hints(
csp_name: str,
object_class: str,
geometry_type: str,
attrs: dict[str, Any],
settings: S52MarinerSettings,
*,
next_safe_contour_m: float | None = None,
sounding_depth_m: float | None = None,
associated_depth_ranges: list[dict[str, Any]] | 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)
elif name == "LIGHTS05":
instruction, light_extra = resolve_lights05_instruction(attrs)
extra.update(light_extra)
elif name == "SOUNDG02":
if sounding_depth_m is None:
return {}
instruction, sounding_extra = resolve_soundg03_instruction(
attrs, sounding_depth_m, settings
)
extra.update(sounding_extra)
elif name == "OBSTRN04":
instruction, hazard_extra = resolve_obstrn04_instruction(
object_class, geometry_type=geometry_type,
attrs=attrs, settings=settings,
associated_depth_ranges=associated_depth_ranges,
)
extra.update(hazard_extra)
elif name == "WRECKS02":
instruction, hazard_extra = resolve_wrecks02_instruction(
geometry_type, attrs, settings, associated_depth_ranges=associated_depth_ranges
)
extra.update(hazard_extra)
elif name == "QUAPOS01":
instruction = resolve_quapos01_instruction(object_class, geometry_type, attrs)
elif name == "SLCONS03":
instruction = resolve_slcons03_instruction(geometry_type, attrs)
elif name == "RESTRN01":
instruction = resolve_restrn01_instruction(attrs)
elif name == "RESARE02":
instruction = resolve_resare02_instruction(attrs, settings)
else:
return {}
hints = static_rule_hints(instruction)
hints.update(materialize_symbol_hints(instruction, attrs))
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(
rule.opcode for rule in parse_instruction_string(instruction)
)
return hints