#!/usr/bin/env python3 """ Regenerate features.geojson for Bahia de Cartagena directly from the source CSVs, bypassing the GDAL S-57 round-trip that loses LITCHR/SIGPER/ VALNMR for BOYLAT and CATCAM for BOYCAR. """ import csv import json from pathlib import Path CSV_DIR = Path(r"D:\Proyectos Software\QGISS57Converter\capas_ctg") OUT_FILE = Path(r"C:\AidsMonitoring\charts\BAHÍA_DE_CARTAGENA\features.geojson") META_FILE= Path(r"C:\AidsMonitoring\charts\BAHÍA_DE_CARTAGENA\meta.json") # ── S-57 attribute lookup tables ────────────────────────────────────────────── LITCHR_MAP = { 1:"F", 2:"Fl", 3:"LFl", 4:"Q", 5:"VQ", 6:"UQ", 7:"Iso", 8:"Oc", 9:"IQ", 10:"IVQ", 11:"IUQ", 12:"Mo", 13:"FFl", 14:"FlLFl", 15:"OcFl", 16:"FLFl", 25:"Al.Oc", 26:"Al.LFl", 27:"Al.Fl", 28:"Al.Grp", } COLOUR_MAP = { 1:"W", 2:"K", 3:"R", 4:"G", 5:"B", 6:"Y", 7:"Gy", 8:"Br", 9:"Amb", 10:"Vi", 11:"Or", 12:"Mg", } # CSV feat_type → canonical S-57 layer name FEAT_TYPE_TO_LAYER = { "BOYSPEC": "BOYSPP", # DIMAR CSV uses BOYSPEC; IHO S-57 is BOYSPP } # Layer → feature category CATEGORY_MAP = { "BOYLAT": "buoy", "BOYCAR": "buoy", "BOYISD": "buoy", "BOYSPP": "buoy", "BOYSAW": "buoy", "BCNLAT": "beacon", "BCNCAR": "beacon", "LIGHTS": "light", "LNDMRK": "landmark", } def _num(v): if v is None or str(v).strip() == "": return None try: return float(v) except Exception: return None def _int(v): n = _num(v) return int(n) if n is not None else None def _fmt(v): """Format a number: drop trailing .0 for whole numbers.""" try: f = float(v) return str(int(f)) if f == int(f) else str(f) except Exception: return str(v) def make_light_desc(litchr, siggrp, colour, sigper, valnmr): """Build the compact light description string (e.g. 'Fl G 3s 3M').""" parts = [] import re as _re2 lc_int = _int(litchr) lc = LITCHR_MAP.get(lc_int, str(lc_int)) if lc_int is not None else "" sg_raw = str(siggrp or "").strip() sg_m = _re2.search(r'\d+', sg_raw) sg = int(sg_m.group()) if sg_m else None suffix = "+" if "+" in sg_raw else "" if sg is not None: lc = f"{lc}({sg}){suffix}" col_int = _int(colour) col_str = COLOUR_MAP.get(col_int, "") if col_int is not None else "" if lc: parts.append(f"{lc} {col_str}".strip()) sp = _num(sigper) if sp is not None: parts.append(f"{_fmt(sp)}s") rng = _num(valnmr) if rng is not None: parts.append(f"{_fmt(rng)}M") return " ".join(parts) def infer_catcam(siggrp, name): """ Cardinal buoy direction from SIGGRP (most reliable). Q(9)+LFl = West, Q(6)+LFl = South, Q(3) = East, Q = North DIMAR name convention (fallback): SS/VS → S, SN/VN → N, SE → E, SO → W """ # Extract first integer from SIGGRP even if it looks like "(6)+" or "(9)+LFl" import re as _re sg_str = str(siggrp or "") m = _re.search(r'\d+', sg_str) sg = int(m.group()) if m else None if sg is not None: if sg == 9: return "W" if sg == 6: return "S" if sg == 3: return "E" return "N" # Q without group or Q(1) = North cardinal n = (name or "").upper() if any(x in n for x in ("SUR", " SS", "(SS)", " VS", "VS ")): return "S" if any(x in n for x in ("ESTE", " SE", "(SE)")): return "E" if any(x in n for x in ("OESTE", " SO", "(SO)", " SW", "(SW)")): return "W" if any(x in n for x in ("NORTE", " SN", "(SN)", " VN", "VN ")): return "N" return "N" def infer_catlam(colour): """IALA B (Americas): green=port=1, red=stbd=2.""" c = _int(colour) if c == 4: return 1 # green → port if c == 3: return 2 # red → starboard return None def classify(layer, props): catlam = props.get("catlam") catcam = props.get("catcam") if layer in ("BOYLAT", "BCNLAT"): if catlam == 1: return "LATERAL_PORT" if catlam == 2: return "LATERAL_STBD" if catlam == 3: return "LATERAL_PREF_STBD" if catlam == 4: return "LATERAL_PREF_PORT" return "LATERAL_UNKNOWN" if layer in ("BOYCAR", "BCNCAR"): if catcam == "N": return "CARDINAL_N" if catcam == "E": return "CARDINAL_E" if catcam == "S": return "CARDINAL_S" if catcam == "W": return "CARDINAL_W" return "CARDINAL_UNKNOWN" if layer in ("BOYISD", "BCNISD"): return "ISOLATED_DANGER" if layer == "BOYSAW": return "SAFE_WATER" if layer in ("BOYSPP", "BOYSPEC"): return "SPECIAL" if layer == "LIGHTS": return "LIGHT_POINT" if layer == "LNDMRK": return "LANDMARK" if layer.startswith("BCN"): return "BEACON_GENERIC" if layer.startswith("BOY"): return "BUOY_GENERIC" return "UNKNOWN" # ── Main loop ───────────────────────────────────────────────────────────────── features = [] csv_files = sorted(CSV_DIR.glob("*.csv")) print(f"Processing {len(csv_files)} CSV files from {CSV_DIR}") for csv_file in csv_files: row_count = 0 with open(csv_file, newline="", encoding="utf-8") as f: reader = csv.DictReader(f) for row in reader: feat_type = (row.get("feat_type") or csv_file.stem).strip() layer = FEAT_TYPE_TO_LAYER.get(feat_type, feat_type) if not layer: continue lon = _num(row.get("lon")) lat = _num(row.get("lat")) if lon is None or lat is None: continue category = CATEGORY_MAP.get(layer, "buoy") litchr = row.get("LITCHR", "").strip() siggrp = row.get("SIGGRP", "").strip() colour = row.get("COLOUR", "").strip() sigper = row.get("SIGPER", "").strip() valnmr = row.get("VALNMR", "").strip() height = row.get("HEIGHT", "").strip() orient_raw = row.get("ORIENT", "").strip() name = row.get("OBJNAM", "").strip() inform = row.get("INFORM", "").strip() colour_int = _int(colour) colours = [colour_int] if colour_int is not None else [] light_desc = make_light_desc(litchr, siggrp, colour, sigper, valnmr) props = { "layer": layer, "category": category, "name": name or None, "info": inform or None, "light_desc": light_desc or None, "range_nm": _num(valnmr), "height_m": _num(height), "colours": colours, "colour_code": colour_int, } # Layer-specific S-57 attributes if layer in ("BOYCAR", "BCNCAR"): # Prefer explicit CATCAM column (1=N,2=E,3=S,4=W); fall back to inference catcam_raw = row.get("CATCAM", "").strip() catcam_int = _int(catcam_raw) _CATCAM_INT = {1: "N", 2: "E", 3: "S", 4: "W"} if catcam_int in _CATCAM_INT: props["catcam"] = _CATCAM_INT[catcam_int] else: props["catcam"] = infer_catcam(siggrp, name) if layer in ("BOYLAT", "BCNLAT"): props["catlam"] = infer_catlam(colour) orient_num = _num(orient_raw) if orient_num is not None: props["orient"] = orient_num props["aid_type"] = classify(layer, props) # Strip None values (but keep empty lists like colours=[]) props = {k: v for k, v in props.items() if v is not None} features.append({ "type": "Feature", "geometry": {"type": "Point", "coordinates": [lon, lat]}, "properties": props, }) row_count += 1 print(f" {csv_file.name}: {row_count} features") # ── Write features.geojson ──────────────────────────────────────────────────── fc = {"type": "FeatureCollection", "features": features} OUT_FILE.parent.mkdir(parents=True, exist_ok=True) with open(OUT_FILE, "w", encoding="utf-8") as f: json.dump(fc, f, ensure_ascii=False, indent=2) print(f"\nWrote {len(features)} features to {OUT_FILE}") # ── Update meta.json ────────────────────────────────────────────────────────── if features: lons = [ft["geometry"]["coordinates"][0] for ft in features] lats = [ft["geometry"]["coordinates"][1] for ft in features] bbox = [min(lons), min(lats), max(lons), max(lats)] try: meta = json.loads(META_FILE.read_text(encoding="utf-8")) except Exception: meta = {} meta["feature_count"] = len(features) meta["bbox"] = bbox META_FILE.write_text(json.dumps(meta), encoding="utf-8") print(f"Updated meta.json: {len(features)} features, bbox={bbox}") # ── Quick QA: show light_desc for first 5 BOYLAT and all BOYCAR ────────────── print("\n── BOYLAT sample ──") for ft in [f for f in features if f["properties"].get("layer") == "BOYLAT"][:5]: p = ft["properties"] print(f" {p['name']:30s} {p.get('light_desc','(none)'):20s} " f"aid_type={p['aid_type']}") print("\n── BOYCAR (all) ──") for ft in [f for f in features if f["properties"].get("layer") == "BOYCAR"]: p = ft["properties"] print(f" {p['name']:35s} {p.get('light_desc','(none)'):20s} " f"catcam={p.get('catcam','?')} aid_type={p['aid_type']}") print("\n── LNDMRK (all) ──") for ft in [f for f in features if f["properties"].get("layer") == "LNDMRK"]: p = ft["properties"] print(f" {p['name']:35s} {p.get('light_desc','(none)'):22s} " f"height={p.get('height_m','?')}m range={p.get('range_nm','?')}NM")