Files
AidsMonitoring/backend/routers/charts.py
T

324 lines
17 KiB
Python

"""
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,
)
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}
@router.post("/upload")
async def upload_chart(file: UploadFile = File(...)):
suffix = Path(file.filename).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":
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.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")
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}