release: bump version to 0.43.0

This commit is contained in:
linkong
2026-04-28 16:10:17 +08:00
parent 1cd2dab0ee
commit ac69d5d354
69 changed files with 6954 additions and 1141 deletions

View File

@@ -4,7 +4,7 @@ Unified API for all visualization data sources.
Returns GeoJSON format compatible with Three.js, CesiumJS, and Unreal Cesium.
"""
from datetime import UTC, datetime
from datetime import UTC, datetime, timedelta
import math
import httpx
from fastapi import APIRouter, HTTPException, Depends, Query, Response
@@ -20,6 +20,7 @@ from app.db.session import get_db
from app.models.bgp_anomaly import BGPAnomaly
from app.models.bgp_incident import BGPIncident
from app.models.collected_data import CollectedData
from app.models.vessel import VesselPosition, VesselStatic
from app.services.bgp_collectors import build_bgp_collector_coverage
from app.services.cable_graph import build_graph_from_data, CableGraph, haversine_distance
from app.services.collectors.bgp_common import RIPE_RIS_COLLECTOR_COORDS
@@ -511,7 +512,7 @@ def _normalize_capacity_band(capacity_value: Optional[float], capacity_unit: str
if unit in {"pflop/s", "pflops", "pflop"}:
normalized_tflops = capacity_value * 1000
elif unit in {"gflop/s", "gflops", "gflop"}:
normalized_tflops = capacity_value / 1000
normalized_tflops = capacity_value
else:
normalized_tflops = capacity_value
@@ -609,6 +610,108 @@ def convert_compute_centers_to_geojson(records: List[CollectedData]) -> Dict[str
return {"type": "FeatureCollection", "features": features}
VESSEL_TYPE_FILTERS = {
"cargo": lambda props: str(props.get("vessel_type_name", "")).lower() == "cargo"
or 70 <= int(props.get("vessel_type") or -1) <= 79,
"tanker": lambda props: str(props.get("vessel_type_name", "")).lower() == "tanker"
or 80 <= int(props.get("vessel_type") or -1) <= 89,
"passenger": lambda props: str(props.get("vessel_type_name", "")).lower() == "passenger"
or 60 <= int(props.get("vessel_type") or -1) <= 69,
"fishing": lambda props: str(props.get("vessel_type_name", "")).lower() == "fishing"
or int(props.get("vessel_type") or -1) == 30,
"military": lambda props: str(props.get("vessel_type_name", "")).lower() == "military"
or int(props.get("vessel_type") or -1) == 35,
"other": lambda props: str(props.get("vessel_type_name", "")).lower()
not in {"cargo", "tanker", "passenger", "fishing", "military"},
}
def convert_vessels_to_geojson(rows: List[Any]) -> Dict[str, Any]:
features = []
for position, static in rows:
if position.lat is None or position.lon is None:
continue
props = {
"mmsi": position.mmsi,
"name": getattr(static, "name", None) or f"MMSI {position.mmsi}",
"callsign": getattr(static, "callsign", None),
"imo": getattr(static, "imo", None),
"vessel_type": getattr(static, "vessel_type", None),
"vessel_type_name": getattr(static, "vessel_type_name", None) or "Other",
"flag": getattr(static, "flag", None),
"length": getattr(static, "length", None),
"width": getattr(static, "width", None),
"draught": getattr(static, "draught", None),
"sog": position.sog,
"cog": position.cog,
"heading": position.heading,
"nav_status": position.nav_status,
"received_at": to_iso8601_utc(position.received_at),
"data_type": "vessel",
}
features.append(
{
"type": "Feature",
"id": position.mmsi,
"geometry": {
"type": "Point",
"coordinates": [position.lon, position.lat],
},
"properties": props,
}
)
return {"type": "FeatureCollection", "features": features}
def _parse_bbox(value: Optional[str]) -> tuple[float, float, float, float] | None:
if not value:
return None
parts = [part.strip() for part in value.split(",")]
if len(parts) != 4:
raise HTTPException(status_code=400, detail="bbox must be lon_min,lat_min,lon_max,lat_max")
try:
lon_min, lat_min, lon_max, lat_max = [float(part) for part in parts]
except ValueError as exc:
raise HTTPException(status_code=400, detail="bbox values must be numbers") from exc
if lat_min > lat_max:
lat_min, lat_max = lat_max, lat_min
if lon_min > lon_max:
lon_min, lon_max = lon_max, lon_min
return lon_min, lat_min, lon_max, lat_max
def _matches_vessel_type(props: dict[str, Any], requested_types: set[str]) -> bool:
if not requested_types:
return True
for requested_type in requested_types:
predicate = VESSEL_TYPE_FILTERS.get(requested_type)
if predicate and predicate(props):
return True
return False
def _build_vessel_stats(features: List[dict[str, Any]]) -> dict[str, Any]:
by_type: dict[str, int] = {}
underway = 0
anchored_or_moored = 0
for feature in features:
props = feature.get("properties", {})
vessel_type = str(props.get("vessel_type_name") or "Other")
by_type[vessel_type] = by_type.get(vessel_type, 0) + 1
nav_status = props.get("nav_status")
if nav_status in (1, 5):
anchored_or_moored += 1
else:
underway += 1
return {
"total": len(features),
"by_type": by_type,
"underway": underway,
"anchored_or_moored": anchored_or_moored,
}
def convert_bgp_anomalies_to_geojson(
records: List[BGPAnomaly],
geography_hints: Optional[Dict[str, Dict[str, Any]]] = None,
@@ -1298,6 +1401,137 @@ async def get_compute_centers_geojson(
}
@router.get("/geo/vessels")
async def get_vessels_geojson(
bbox: Optional[str] = Query(
None,
description="Viewport bbox as lon_min,lat_min,lon_max,lat_max",
),
type: Optional[str] = Query(
None,
description="Comma-separated vessel types: cargo,tanker,passenger,fishing,military,other",
),
limit: int = Query(5000, ge=1, le=50000),
db: AsyncSession = Depends(get_db),
):
"""Return latest vessel positions as GeoJSON points."""
latest_times = (
select(
VesselPosition.mmsi.label("mmsi"),
func.max(VesselPosition.received_at).label("received_at"),
)
.group_by(VesselPosition.mmsi)
.subquery()
)
stmt = (
select(VesselPosition, VesselStatic)
.join(
latest_times,
(VesselPosition.mmsi == latest_times.c.mmsi)
& (VesselPosition.received_at == latest_times.c.received_at),
)
.outerjoin(VesselStatic, VesselStatic.mmsi == VesselPosition.mmsi)
.order_by(VesselPosition.received_at.desc())
.limit(limit)
)
parsed_bbox = _parse_bbox(bbox)
if parsed_bbox is not None:
lon_min, lat_min, lon_max, lat_max = parsed_bbox
stmt = stmt.where(
VesselPosition.lon >= lon_min,
VesselPosition.lon <= lon_max,
VesselPosition.lat >= lat_min,
VesselPosition.lat <= lat_max,
)
result = await db.execute(stmt)
rows = list(result.all())
geojson = convert_vessels_to_geojson(rows)
requested_types = {
item.strip().lower()
for item in (type or "").split(",")
if item.strip()
}
if requested_types:
geojson["features"] = [
feature
for feature in geojson.get("features", [])
if _matches_vessel_type(feature.get("properties", {}), requested_types)
]
features = geojson.get("features", [])
return {
**geojson,
"count": len(features),
"stats": _build_vessel_stats(features),
}
@router.get("/vessels/{mmsi}")
async def get_vessel_detail(mmsi: int, db: AsyncSession = Depends(get_db)):
latest_position_stmt = (
select(VesselPosition)
.where(VesselPosition.mmsi == mmsi)
.order_by(VesselPosition.received_at.desc())
.limit(1)
)
static = await db.get(VesselStatic, mmsi)
result = await db.execute(latest_position_stmt)
position = result.scalar_one_or_none()
if position is None:
raise HTTPException(status_code=404, detail="Vessel not found")
geojson = convert_vessels_to_geojson([(position, static)])
return {
**(geojson["features"][0]["properties"]),
"latitude": position.lat,
"longitude": position.lon,
}
@router.get("/vessels/{mmsi}/track")
async def get_vessel_track(
mmsi: int,
hours: int = Query(6, ge=1, le=24),
db: AsyncSession = Depends(get_db),
):
cutoff = datetime.now(UTC) - timedelta(hours=hours)
result = await db.execute(
select(VesselPosition)
.where(VesselPosition.mmsi == mmsi)
.where(VesselPosition.received_at >= cutoff)
.order_by(VesselPosition.received_at.asc())
)
positions = list(result.scalars().all())
if not positions:
return {
"type": "FeatureCollection",
"features": [],
"count": 0,
}
return {
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": {
"type": "LineString",
"coordinates": [[position.lon, position.lat] for position in positions],
},
"properties": {
"mmsi": mmsi,
"hours": hours,
"point_count": len(positions),
"start_at": to_iso8601_utc(positions[0].received_at),
"end_at": to_iso8601_utc(positions[-1].received_at),
},
}
],
"count": 1,
}
@router.get("/geo/bgp-anomalies")
async def get_bgp_anomalies_geojson(
severity: Optional[str] = Query(None),
@@ -1394,6 +1628,10 @@ async def get_visualization_geo_summary(db: AsyncSession = Depends(get_db)):
db,
source_filter=("ris_live_bgp", "bgpstream_bgp"),
)
vessel_count_result = await db.execute(
select(func.count(func.distinct(VesselPosition.mmsi))),
)
vessel_count = int(vessel_count_result.scalar() or 0)
return {
"generated_at": to_iso8601_utc(datetime.now(UTC)),
@@ -1402,6 +1640,7 @@ async def get_visualization_geo_summary(db: AsyncSession = Depends(get_db)):
"landing_point_count": len(landing_points.get("features", [])),
"satellite_count": len(satellites.get("features", [])),
"compute_center_count": len(compute_features),
"vessel_count": vessel_count,
"supercomputer_count": sum(
1 for feature in compute_features
if feature.get("properties", {}).get("site_type") == "supercomputer"