266 lines
10 KiB
Python
266 lines
10 KiB
Python
#!/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")
|