324 lines
17 KiB
Python
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}
|