""" REST API for S-57 ENC chart management. """ import asyncio import io import logging import shutil import tempfile from pathlib import Path import httpx from fastapi import APIRouter, HTTPException, UploadFile, File from fastapi.responses import JSONResponse from services.chart_manager import ( install_from_zip, install_from_enc, list_cells, delete_cell, get_all_features, get_all_depths, get_all_land, get_all_hazards, get_all_zones, CHARTS_DIR, set_meta, get_region, install_from_csv_zip, install_from_csv_dir, ) router = APIRouter(prefix="/charts", tags=["charts"]) log = logging.getLogger(__name__) # Florida/Miami NOAA ENC cells — name → description NOAA_FLORIDA_CELLS = { "US5FL17M": "Miami Harbor", "US5FL16M": "Biscayne Bay", "US4FL15M": "Florida Keys — East", "US4FL16M": "Florida Keys — West", "US5FL18M": "Port Everglades (Fort Lauderdale)", "US5FL19M": "Lake Worth / Palm Beach", "US4FL14M": "Florida Bay", "US5FL13M": "Card Sound / Barnes Sound", "US4FL13M": "Straits of Florida", "US5FL12M": "Hawk Channel East", "US3FL04M": "Florida Straits Overview", } NOAA_BASE = "https://charts.noaa.gov/ENCs/{cell}.zip" # Catálogo local NOAA: cell_id → (west, south, east, north, scale, description) # Fuente: NOAA Office of Coast Survey ENC catalog NOAA_CATALOG: list[dict] = [ # ── FLORIDA / MIAMI ─────────────────────────────────────────────────────── {"id":"US5FL17M","name":"Miami Harbor", "scale":10000, "w":-80.20,"s":25.73,"e":-80.10,"n":25.82}, {"id":"US5FL16M","name":"Biscayne Bay", "scale":40000, "w":-80.32,"s":25.42,"e":-80.08,"n":25.78}, {"id":"US5FL18M","name":"Port Everglades", "scale":10000, "w":-80.15,"s":26.05,"e":-80.06,"n":26.13}, {"id":"US5FL19M","name":"Lake Worth — Palm Beach", "scale":10000, "w":-80.10,"s":26.67,"e":-80.01,"n":26.76}, {"id":"US4FL15M","name":"Florida Keys — East", "scale":80000, "w":-80.60,"s":24.40,"e":-79.80,"n":25.50}, {"id":"US4FL16M","name":"Florida Keys — West", "scale":80000, "w":-82.00,"s":24.40,"e":-80.60,"n":25.20}, {"id":"US4FL14M","name":"Florida Bay", "scale":80000, "w":-81.50,"s":24.80,"e":-80.30,"n":25.70}, {"id":"US5FL13M","name":"Card Sound / Barnes Sound", "scale":20000, "w":-80.50,"s":25.10,"e":-80.25,"n":25.45}, {"id":"US4FL13M","name":"Straits of Florida", "scale":150000, "w":-81.00,"s":24.00,"e":-79.50,"n":26.00}, {"id":"US3FL04M","name":"Florida Straits Overview", "scale":600000, "w":-85.00,"s":22.00,"e":-78.00,"n":28.00}, {"id":"US5FL20M","name":"Miami Beach / Government Cut", "scale":5000, "w":-80.14,"s":25.75,"e":-80.10,"n":25.78}, {"id":"US5FL21M","name":"Fort Lauderdale — New River", "scale":10000, "w":-80.20,"s":26.07,"e":-80.09,"n":26.15}, {"id":"US5FL14M","name":"Key West Harbor", "scale":15000, "w":-81.86,"s":24.52,"e":-81.72,"n":24.60}, {"id":"US5FL15M","name":"Key West Approaches", "scale":40000, "w":-82.20,"s":24.30,"e":-81.50,"n":24.80}, {"id":"US4FL17M","name":"Tampa Bay", "scale":80000, "w":-82.80,"s":27.40,"e":-82.20,"n":28.10}, {"id":"US5FL01M","name":"Tampa Bay — Port Tampa", "scale":15000, "w":-82.60,"s":27.83,"e":-82.40,"n":27.95}, {"id":"US5FL04M","name":"Jacksonville", "scale":15000, "w":-81.70,"s":30.27,"e":-81.55,"n":30.42}, # ── NEW YORK / NEW JERSEY ──────────────────────────────────────────────── {"id":"US5NY01M","name":"New York Harbor", "scale":10000, "w":-74.15,"s":40.57,"e":-73.95,"n":40.72}, {"id":"US5NY02M","name":"Upper New York Bay", "scale":10000, "w":-74.10,"s":40.63,"e":-73.95,"n":40.75}, {"id":"US5NY13M","name":"New York — Lower Bay", "scale":20000, "w":-74.20,"s":40.48,"e":-73.90,"n":40.68}, {"id":"US4NY10M","name":"New York Approaches", "scale":80000, "w":-74.50,"s":40.20,"e":-73.50,"n":41.00}, {"id":"US5NY05M","name":"East River", "scale":10000, "w":-74.02,"s":40.65,"e":-73.90,"n":40.80}, {"id":"US5NY06M","name":"Hudson River — New York", "scale":10000, "w":-74.05,"s":40.68,"e":-73.95,"n":40.85}, {"id":"US5NJ01M","name":"Port Newark / Elizabeth", "scale":10000, "w":-74.18,"s":40.62,"e":-74.08,"n":40.72}, {"id":"US5NJ02M","name":"Newark Bay", "scale":10000, "w":-74.18,"s":40.64,"e":-74.05,"n":40.73}, {"id":"US3NY01M","name":"New York — Long Island Sound Overview","scale":400000,"w":-74.50,"s":40.00,"e":-71.50,"n":41.50}, # ── CHESAPEAKE / MID-ATLANTIC ──────────────────────────────────────────── {"id":"US5MD01M","name":"Baltimore Harbor", "scale":10000, "w":-76.65,"s":39.20,"e":-76.50,"n":39.32}, {"id":"US4MD01M","name":"Chesapeake Bay — Northern", "scale":80000, "w":-76.70,"s":38.80,"e":-75.80,"n":39.70}, {"id":"US4VA01M","name":"Chesapeake Bay — Southern", "scale":80000, "w":-76.70,"s":36.80,"e":-75.80,"n":38.00}, {"id":"US5VA01M","name":"Norfolk — Hampton Roads", "scale":15000, "w":-76.40,"s":36.82,"e":-76.20,"n":37.00}, {"id":"US5PA01M","name":"Philadelphia", "scale":10000, "w":-75.20,"s":39.88,"e":-75.06,"n":39.98}, # ── NEW ENGLAND ────────────────────────────────────────────────────────── {"id":"US5MA01M","name":"Boston Harbor", "scale":10000, "w":-71.10,"s":42.28,"e":-70.95,"n":42.40}, {"id":"US4MA01M","name":"Cape Cod — Approaches", "scale":80000, "w":-71.20,"s":41.40,"e":-69.80,"n":42.50}, {"id":"US5RI01M","name":"Providence — Narragansett Bay","scale":15000, "w":-71.48,"s":41.60,"e":-71.35,"n":41.85}, # ── GULF COAST ─────────────────────────────────────────────────────────── {"id":"US5LA01M","name":"New Orleans — Lower Mississippi","scale":10000,"w":-89.85,"s":29.85,"e":-89.70,"n":30.05}, {"id":"US5TX01M","name":"Houston Ship Channel", "scale":10000, "w":-95.10,"s":29.70,"e":-94.90,"n":29.85}, {"id":"US5TX02M","name":"Galveston Bay", "scale":10000, "w":-94.95,"s":29.25,"e":-94.65,"n":29.60}, {"id":"US4TX01M","name":"Texas — Gulf Coast", "scale":80000, "w":-97.50,"s":25.50,"e":-93.50,"n":30.00}, {"id":"US5MS01M","name":"Pascagoula", "scale":10000, "w":-88.65,"s":30.28,"e":-88.48,"n":30.38}, {"id":"US5AL01M","name":"Mobile Bay", "scale":20000, "w":-88.15,"s":30.50,"e":-87.90,"n":30.75}, # ── WEST COAST ─────────────────────────────────────────────────────────── {"id":"US5CA01M","name":"Los Angeles — Long Beach", "scale":15000, "w":-118.30,"s":33.70,"e":-118.10,"n":33.82}, {"id":"US5CA02M","name":"San Diego Bay", "scale":10000, "w":-117.22,"s":32.60,"e":-117.10,"n":32.75}, {"id":"US5CA03M","name":"San Francisco Bay", "scale":20000, "w":-122.55,"s":37.60,"e":-122.15,"n":37.90}, {"id":"US5CA04M","name":"San Francisco — Oakland", "scale":10000, "w":-122.42,"s":37.75,"e":-122.22,"n":37.86}, {"id":"US5OR01M","name":"Columbia River — Astoria", "scale":15000, "w":-124.00,"s":46.10,"e":-123.75,"n":46.30}, {"id":"US5WA01M","name":"Seattle — Elliott Bay", "scale":10000, "w":-122.45,"s":47.55,"e":-122.30,"n":47.66}, {"id":"US5WA02M","name":"Puget Sound — Tacoma", "scale":15000, "w":-122.55,"s":47.15,"e":-122.35,"n":47.35}, {"id":"US4WA01M","name":"Puget Sound Overview", "scale":80000, "w":-123.30,"s":46.80,"e":-122.00,"n":48.60}, # ── ALASKA ─────────────────────────────────────────────────────────────── {"id":"US5AK01M","name":"Anchorage", "scale":15000, "w":-150.10,"s":61.15,"e":-149.75,"n":61.32}, {"id":"US4AK01M","name":"Cook Inlet — Overview", "scale":80000, "w":-153.00,"s":59.00,"e":-148.00,"n":63.00}, # ── GREAT LAKES ────────────────────────────────────────────────────────── {"id":"US5MI01M","name":"Detroit River", "scale":10000, "w":-83.15,"s":42.22,"e":-82.95,"n":42.40}, {"id":"US5IL01M","name":"Chicago Harbor", "scale":10000, "w":-87.68,"s":41.82,"e":-87.55,"n":41.92}, {"id":"US5OH01M","name":"Cleveland Harbor", "scale":10000, "w":-81.75,"s":41.48,"e":-81.60,"n":41.56}, # ── CARIBBEAN / PUERTO RICO ────────────────────────────────────────────── {"id":"US5PR01M","name":"San Juan Harbor", "scale":10000, "w":-66.15,"s":18.43,"e":-66.05,"n":18.50}, {"id":"US4PR01M","name":"Puerto Rico — North Coast", "scale":80000, "w":-67.50,"s":17.80,"e":-65.00,"n":18.80}, {"id":"US5VI01M","name":"Charlotte Amalie — St. Thomas","scale":10000, "w":-64.97,"s":18.30,"e":-64.88,"n":18.37}, # ── HAWAII ─────────────────────────────────────────────────────────────── {"id":"US5HI01M","name":"Honolulu Harbor", "scale":10000, "w":-157.90,"s":21.27,"e":-157.83,"n":21.34}, {"id":"US4HI01M","name":"Oahu — Overview", "scale":80000, "w":-158.50,"s":21.00,"e":-157.50,"n":21.80}, ] @router.get("/cells") def get_cells(): return list_cells() @router.get("/florida-catalog") def florida_catalog(): installed = {c["id"] for c in list_cells()} return [ {"id": k, "description": v, "installed": k in installed} for k, v in NOAA_FLORIDA_CELLS.items() ] @router.get("/noaa-catalog") def noaa_catalog(): installed = {c["id"] for c in list_cells()} return [ {"id": c["id"], "description": c["name"], "scale": c["scale"], "installed": c["id"] in installed} for c in NOAA_CATALOG ] @router.get("/noaa-nearby") def noaa_nearby(west: float, south: float, east: float, north: float): """ Return NOAA ENC cells that intersect the given bbox. Checks both the hardcoded catalog AND installed cells' actual bounding boxes. """ cells_info = list_cells() installed = {c["id"] for c in cells_info} # Build actual-bbox map for installed cells actual_bbox = {c["id"]: c["bbox"] for c in cells_info if c["bbox"]} seen = set() matches = [] # 1. Installed cells with real bounding boxes for cell_id, bbox in actual_bbox.items(): w, s, e, n = bbox if e < west or w > east or n < south or s > north: continue # Look up catalog name if available name = next((c["name"] for c in NOAA_CATALOG if c["id"] == cell_id), cell_id) scale = next((c["scale"] for c in NOAA_CATALOG if c["id"] == cell_id), 0) matches.append({"id": cell_id, "name": name, "scale": scale, "installed": True}) seen.add(cell_id) # 2. Catalog cells not yet installed for cell in NOAA_CATALOG: if cell["id"] in seen: continue if cell["e"] < west or cell["w"] > east: continue if cell["n"] < south or cell["s"] > north: continue matches.append({ "id": cell["id"], "name": cell["name"], "scale": cell["scale"], "installed": cell["id"] in installed, }) matches.sort(key=lambda c: c["scale"]) return matches @router.post("/download-noaa/{cell_id}") async def download_noaa(cell_id: str): cell_id = cell_id.upper() url = NOAA_BASE.format(cell=cell_id) log.info("Downloading NOAA ENC %s from %s", cell_id, url) try: async with httpx.AsyncClient(timeout=120) as client: r = await client.get(url, follow_redirects=True) if r.status_code == 404: raise HTTPException(404, f"Cell {cell_id} not found on NOAA server") if r.status_code != 200: raise HTTPException(502, f"NOAA returned {r.status_code}") except httpx.TimeoutException: raise HTTPException(504, "Timeout downloading from NOAA") with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tmp: tmp.write(r.content) tmp_path = Path(tmp.name) try: installed = await asyncio.get_event_loop().run_in_executor( None, install_from_zip, tmp_path) except Exception as e: raise HTTPException(500, str(e)) finally: tmp_path.unlink(missing_ok=True) return {"installed": installed} def _zip_contains_csvs(zip_path: Path) -> bool: """Return True if the ZIP has *.csv files but no *.000 ENC files.""" import zipfile as _zf with _zf.ZipFile(zip_path) as z: names = z.namelist() has_csv = any(n.lower().endswith(".csv") for n in names) has_enc = any(n.upper().endswith(".000") for n in names) return has_csv and not has_enc @router.post("/upload") async def upload_chart(file: UploadFile = File(...)): """ Universal chart upload. Accepts: • .000 — single S-57 ENC cell • .zip — either a NOAA ENC zip (contains .000) OR a CSV-based custom chart zip (contains *.csv, no .000). The ZIP auto-detection determines which parser is used. """ suffix = Path(file.filename or "").suffix.lower() if suffix not in (".zip", ".000"): raise HTTPException(400, "Only .zip or .000 files accepted") data = await file.read() with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp: tmp.write(data) tmp_path = Path(tmp.name) try: if suffix == ".zip": # Auto-detect: CSV zip vs ENC zip if _zip_contains_csvs(tmp_path): installed = await asyncio.get_event_loop().run_in_executor( None, install_from_csv_zip, tmp_path) else: installed = await asyncio.get_event_loop().run_in_executor( None, install_from_zip, tmp_path) else: orig_name = Path(file.filename).stem.upper() if file.filename else None cell_id = await asyncio.get_event_loop().run_in_executor( None, install_from_enc, tmp_path, orig_name) installed = [cell_id] except Exception as e: raise HTTPException(500, str(e)) finally: tmp_path.unlink(missing_ok=True) return {"installed": installed} @router.post("/upload-csv") async def upload_csv_chart(file: UploadFile = File(...), cell_id: str | None = None): """ Upload a ZIP archive containing CSV navigation-aid files to create a custom chart cell. Use this when your source data is in DIMAR / custom CSV format rather than S-57 .000. The cell_id query parameter overrides the inferred name from the ZIP. Workflow: 1. Edit BOYLAT.csv, BOYCAR.csv, BOYSPEC.csv, etc. in your local capas_ctg/ folder. 2. Zip the entire folder. 3. POST the zip here (optionally with ?cell_id=BAHIA_DE_CARTAGENA). 4. AidsMonitoring reads the CSVs directly, preserving all light attributes (LITCHR, SIGPER, VALNMR …) without GDAL round-trip loss. """ if not (file.filename or "").lower().endswith(".zip"): raise HTTPException(400, "Only .zip files accepted for CSV upload") data = await file.read() with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tmp: tmp.write(data) tmp_path = Path(tmp.name) try: if cell_id: # Extract to temp dir then install with explicit cell_id import zipfile as _zf import tempfile as _tf with _tf.TemporaryDirectory() as td: td_p = Path(td) with _zf.ZipFile(tmp_path) as z: for member in z.namelist(): if member.lower().endswith(".csv"): data_bytes = z.read(member) (td_p / Path(member).name).write_bytes(data_bytes) installed_id = await asyncio.get_event_loop().run_in_executor( None, install_from_csv_dir, td_p, cell_id) installed = [installed_id] else: installed = await asyncio.get_event_loop().run_in_executor( None, install_from_csv_zip, tmp_path) except Exception as e: raise HTTPException(500, str(e)) finally: tmp_path.unlink(missing_ok=True) return {"installed": installed} @router.delete("/cells/{cell_id}") def remove_cell(cell_id: str): delete_cell(cell_id) return {"deleted": cell_id.upper()} @router.patch("/cells/{cell_id}/region") def set_cell_region(cell_id: str, region: str): region = (region or "").upper().strip() if region not in ("A", "B"): raise HTTPException(400, "region must be 'A' or 'B'") try: set_meta(cell_id, region=region) except FileNotFoundError: raise HTTPException(404, f"Cell {cell_id} not installed") return {"id": cell_id.upper(), "region": region} @router.get("/features") def chart_features(): return JSONResponse(get_all_features()) @router.get("/depths") def chart_depths(w: float | None = None, s: float | None = None, e: float | None = None, n: float | None = None): bbox = (w, s, e, n) if None not in (w, s, e, n) else None return JSONResponse(get_all_depths(bbox)) @router.get("/land") def chart_land(w: float | None = None, s: float | None = None, e: float | None = None, n: float | None = None): bbox = (w, s, e, n) if None not in (w, s, e, n) else None return JSONResponse(get_all_land(bbox)) @router.get("/hazards") def chart_hazards(): return JSONResponse(get_all_hazards()) @router.get("/zones") def chart_zones(w: float | None = None, s: float | None = None, e: float | None = None, n: float | None = None): bbox = (w, s, e, n) if None not in (w, s, e, n) else None return JSONResponse(get_all_zones(bbox)) @router.post("/rebuild-cache") async def rebuild_cache(): """Re-parse ALL installed ENC files and regenerate feature cache.""" from services.chart_manager import CHARTS_DIR, _build_cache import asyncio rebuilt = [] for cell_dir in CHARTS_DIR.iterdir(): if not cell_dir.is_dir(): continue enc_files = list(cell_dir.glob("*.000")) if not enc_files: continue await asyncio.get_event_loop().run_in_executor( None, _build_cache, cell_dir.name, enc_files[0]) rebuilt.append(cell_dir.name) return {"rebuilt": rebuilt} @router.post("/cells/{cell_id}/rebuild-from-csv") async def rebuild_cell_from_csv(cell_id: str, file: UploadFile = File(...)): """ Update an existing cell's features.geojson by re-uploading its CSV zip. Equivalent to DELETE + upload-csv but preserves meta.json settings (e.g. region override). """ if not (file.filename or "").lower().endswith(".zip"): raise HTTPException(400, "Only .zip files accepted") data = await file.read() with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tmp: tmp.write(data) tmp_path = Path(tmp.name) try: import zipfile as _zf, tempfile as _tf with _tf.TemporaryDirectory() as td: td_p = Path(td) with _zf.ZipFile(tmp_path) as z: for member in z.namelist(): if member.lower().endswith(".csv"): (td_p / Path(member).name).write_bytes(z.read(member)) installed_id = await asyncio.get_event_loop().run_in_executor( None, install_from_csv_dir, td_p, cell_id) except Exception as e: raise HTTPException(500, str(e)) finally: tmp_path.unlink(missing_ok=True) return {"rebuilt": installed_id} @router.post("/cells/{cell_id}/rebuild") async def rebuild_cell(cell_id: str): """Re-parse a single ENC cell and regenerate its feature cache.""" from services.chart_manager import CHARTS_DIR, _build_cache import asyncio cell_dir = CHARTS_DIR / cell_id if not cell_dir.is_dir(): from fastapi import HTTPException raise HTTPException(status_code=404, detail=f"Cell '{cell_id}' not found") enc_files = list(cell_dir.glob("*.000")) if not enc_files: from fastapi import HTTPException raise HTTPException(status_code=404, detail=f"No .000 file in cell '{cell_id}'") await asyncio.get_event_loop().run_in_executor( None, _build_cache, cell_id, enc_files[0]) return {"rebuilt": cell_id}