Initial commit — multi-tenant filtering, port constraints, chart bbox
This commit is contained in:
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user