Initial commit — multi-tenant filtering, port constraints, chart bbox

This commit is contained in:
2026-05-04 22:41:09 -04:00
parent c3b07be67e
commit fcf1d2787a
1102 changed files with 7353 additions and 1166 deletions
+265
View File
@@ -0,0 +1,265 @@
#!/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")