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
+263
View File
@@ -78,6 +78,269 @@ def auto_region(cell_id: str) -> str:
if prefix in REGION_B_PREFIXES: return "B"
return "B"
# ── CSV → features (DIMAR / custom CSV charts) ────────────────────────────────
# Mapping from DIMAR CSV feat_type to canonical S-57 layer name.
_CSV_FEAT_TYPE_MAP = {
"BOYSPEC": "BOYSPP", # DIMAR uses BOYSPEC; IHO S-57 is BOYSPP
}
# Feature categories per layer
_CSV_CATEGORY_MAP = {
"BOYLAT": "buoy", "BOYCAR": "buoy", "BOYISD": "buoy",
"BOYSPP": "buoy", "BOYSAW": "buoy",
"BCNLAT": "beacon","BCNCAR": "beacon",
"LIGHTS": "light", "LNDMRK": "landmark",
}
def _csv_num(v):
if v is None or str(v).strip() == "":
return None
try:
return float(v)
except Exception:
return None
def _csv_int(v):
n = _csv_num(v)
return int(n) if n is not None else None
def _csv_fmt(v):
"""Format float: 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 _csv_light_desc(litchr, siggrp, colour, sigper, valnmr) -> str:
"""Build compact light description from CSV fields (e.g. 'Fl G 3s 3M')."""
parts = []
lc_int = _csv_int(litchr)
lc = LITCHR.get(lc_int, str(lc_int)) if lc_int is not None else ""
sg = _csv_int(siggrp)
if sg is not None:
lc = f"{lc}({sg})"
col_int = _csv_int(colour)
col_str = COLOUR.get(col_int, "") if col_int is not None else ""
if lc:
parts.append(f"{lc} {col_str}".strip())
sp = _csv_num(sigper)
if sp is not None:
parts.append(f"{_csv_fmt(sp)}s")
rng = _csv_num(valnmr)
if rng is not None:
parts.append(f"{_csv_fmt(rng)}M")
return " ".join(parts)
def _csv_infer_catcam(siggrp: str, name: str) -> str | None:
"""
Cardinal buoy quadrant from SIGGRP (reliable) or DIMAR naming convention.
Q(9)+LFl=W Q(6)+LFl=S Q(3)=E Q=N
DIMAR name suffixes: SS/VS→S SN/VN→N SE→E SO→W
"""
sg = _csv_int(siggrp)
if sg is not None:
if sg == 9: return "W"
if sg == 6: return "S"
if sg == 3: return "E"
return "N"
n = (name or "").upper()
if any(x in n for x in (" SS", "(SS)", "VS")): return "S"
if any(x in n for x in (" SE", "(SE)")): return "E"
if any(x in n for x in (" SO", "(SO)")): return "W"
return "N"
def _csv_infer_catlam(colour: str) -> int | None:
"""IALA B (Americas): green(4)=port=1, red(3)=stbd=2."""
c = _csv_int(colour)
if c == 4: return 1
if c == 3: return 2
return None
def _parse_csvs_to_features(csv_dir: Path) -> list[dict]:
"""
Read navigation-aid CSV files from csv_dir and return GeoJSON features.
Each CSV must have columns: OBJNAM, lon, lat, feat_type, LITCHR, SIGGRP,
COLOUR, SIGPER, VALNMR, HEIGHT, ORIENT, INFORM.
feat_type values: BOYLAT, BOYCAR, BOYISD, BOYSPEC/BOYSPP, BCNLAT, LIGHTS,
LNDMRK.
This function is the primary path for DIMAR/custom charts created with
QGISS57Converter; it preserves all light attributes (LITCHR, SIGPER, etc.)
that the GDAL S-57 driver may drop during the .000 round-trip.
"""
import csv as _csv_mod
features: list[dict] = []
for csv_file in sorted(csv_dir.glob("*.csv")):
with open(csv_file, newline="", encoding="utf-8") as fh:
reader = _csv_mod.DictReader(fh)
for row in reader:
feat_type = (row.get("feat_type") or csv_file.stem).strip()
layer = _CSV_FEAT_TYPE_MAP.get(feat_type, feat_type)
if not layer:
continue
lon = _csv_num(row.get("lon"))
lat = _csv_num(row.get("lat"))
if lon is None or lat is None:
continue
category = _CSV_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_r = row.get("ORIENT", "").strip()
name = row.get("OBJNAM", "").strip()
inform = row.get("INFORM", "").strip()
col_int = _csv_int(colour)
colours = [col_int] if col_int is not None else []
light_d = _csv_light_desc(litchr, siggrp, colour, sigper, valnmr)
props: dict = {
"layer": layer,
"category": category,
"name": name or None,
"info": inform or None,
"light_desc": light_d or None,
"range_nm": _csv_num(valnmr),
"height_m": _csv_num(height),
"colours": colours,
"colour_code": col_int,
}
if layer in ("BOYCAR", "BCNCAR"):
props["catcam"] = _csv_infer_catcam(siggrp, name)
if layer in ("BOYLAT", "BCNLAT"):
props["catlam"] = _csv_infer_catlam(colour)
orient_val = _csv_num(orient_r)
if orient_val is not None:
props["orient"] = orient_val
props["aid_type"] = classify(layer, props)
# Remove None values
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,
})
return features
def install_from_csv_dir(csv_dir: Path, cell_id: str) -> str:
"""
Create or update an AidsMonitoring chart cell from a directory of CSV files.
This is the preferred install pathway for custom (DIMAR) charts because it
preserves all S-57 attribute codes (LITCHR, SIGGRP, etc.) without loss.
csv_dir — directory containing *.csv files (BOYLAT.csv, BOYCAR.csv, etc.)
cell_id — chart cell identifier (e.g. 'BAHIA_DE_CARTAGENA')
"""
cell_id = cell_id.upper()
cell_dir = CHARTS_DIR / cell_id
cell_dir.mkdir(exist_ok=True)
features = _parse_csvs_to_features(csv_dir)
with open(cell_dir / "features.geojson", "w", encoding="utf-8") as f:
json.dump({"type": "FeatureCollection", "features": features}, f,
ensure_ascii=False)
log.info("CSV chart %s%d features", cell_id, len(features))
# Empty auxiliary caches so the frontend doesn't show stale data
for fname in ("depths.geojson", "land.geojson", "hazards.geojson", "zones.geojson"):
p = cell_dir / fname
if not p.exists():
with open(p, "w") as f:
json.dump({"type": "FeatureCollection", "features": []}, f)
# Update meta.json
bbox = None
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)]
meta = get_meta(cell_id)
meta["feature_count"] = len(features)
meta["bbox"] = bbox
meta.setdefault("region", auto_region(cell_id))
_meta_path(cell_dir).write_text(json.dumps(meta))
return cell_id
def install_from_csv_zip(zip_path: Path) -> list[str]:
"""
Install one or more CSV chart cells from a ZIP archive.
Expected ZIP layout (any of these is accepted):
Option A — single cell (cell_id inferred from folder name or ZIP name):
BOYLAT.csv
BOYCAR.csv
...
Option B — cell_id in subfolder:
BAHIA_DE_CARTAGENA/BOYLAT.csv
BAHIA_DE_CARTAGENA/BOYCAR.csv
Option C — meta.json declares cell_id:
meta.json → {"cell_id": "BAHIA_DE_CARTAGENA"}
BOYLAT.csv
...
"""
import csv as _csv_mod
installed: list[str] = []
with zipfile.ZipFile(zip_path) as z:
namelist = z.namelist()
# Collect all CSV files grouped by subfolder
csv_files = [n for n in namelist if n.lower().endswith(".csv")]
if not csv_files:
raise ValueError("No CSV files found in ZIP")
# Check for meta.json
meta_cell_id: str | None = None
if "meta.json" in namelist:
try:
meta_cell_id = json.loads(z.read("meta.json")).get("cell_id")
except Exception:
pass
# Group by directory prefix
import collections
groups: dict[str, list[str]] = collections.defaultdict(list)
for name in csv_files:
prefix = str(Path(name).parent)
groups[prefix].append(name)
with tempfile.TemporaryDirectory() as tmp_root:
tmp_root_p = Path(tmp_root)
for prefix, members in groups.items():
# Determine cell_id
if meta_cell_id:
cid = meta_cell_id
elif prefix and prefix != ".":
cid = Path(prefix).name
else:
cid = Path(zip_path).stem # e.g. BAHIA_DE_CARTAGENA
# Extract CSVs to temp dir
tmp_csv = tmp_root_p / cid
tmp_csv.mkdir(exist_ok=True)
for member in members:
data = z.read(member)
(tmp_csv / Path(member).name).write_bytes(data)
# Install
result_id = install_from_csv_dir(tmp_csv, cid)
installed.append(result_id)
return installed
def _meta_path(cell_dir: Path) -> Path:
return cell_dir / "meta.json"