release: bump version to 0.70.0
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
release / images (push) Has been cancelled
ci / delivery (push) Has been cancelled

This commit is contained in:
linkong
2026-06-04 17:16:23 +08:00
parent acbbfdf9e2
commit 8c204717cd
78 changed files with 1762 additions and 703 deletions

View File

@@ -9,7 +9,12 @@ from sqlalchemy import select
from sqlalchemy import Float
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.vessel import AISConflictRecord, AISRawObservation, AISSourceHealth
from app.models.vessel import (
AISConflictRecord,
AISRawObservation,
AISSourceHealth,
VesselCurrentState,
)
from app.services.vessel_aggregation_strategy import (
DEFAULT_STRATEGY,
load_strategy,
@@ -44,6 +49,17 @@ CONFLICT_FIELDS = (
"width",
"draught",
)
CURRENT_STATE_STATIC_FIELDS = (
"name",
"callsign",
"vessel_type",
"vessel_type_name",
"flag",
"length",
"width",
"draught",
"imo",
)
def _json_default(value: Any) -> Any:
@@ -489,9 +505,115 @@ async def record_vessel_ais_observation(
quality_flags=quality_flags or [],
)
db.add(observation)
await upsert_vessel_current_state(
db,
source=source,
normalized_payload=normalized_json,
observed_at=observed_at,
quality_flags=quality_flags or [],
)
return observation
async def upsert_vessel_current_state(
db: AsyncSession,
*,
source: str,
normalized_payload: dict[str, Any],
observed_at: datetime,
quality_flags: list[str] | None = None,
) -> VesselCurrentState | None:
"""Keep one latest renderable row per MMSI while preserving useful static fields."""
if not _has_valid_position(normalized_payload):
return None
mmsi = int(normalized_payload["mmsi"])
current = await db.get(VesselCurrentState, mmsi)
if current is not None and current.observed_at is not None:
current_observed_at = _coerce_datetime(current.observed_at)
if current_observed_at is not None and observed_at < current_observed_at:
return current
if current is None:
current = VesselCurrentState(mmsi=mmsi)
db.add(current)
current.lat = float(normalized_payload["lat"])
current.lon = float(normalized_payload["lon"])
current.source = source
current.observed_at = observed_at
current.updated_at = datetime.now(UTC)
updated_fields: set[str] = {"lat", "lon"}
for field in DYNAMIC_FIELDS:
if field in {"lat", "lon"}:
continue
value = _payload_value(normalized_payload, field)
if value is not None:
setattr(current, field, value)
updated_fields.add(field)
field_sources = dict(current.field_sources or {})
for field in CURRENT_STATE_STATIC_FIELDS:
value = _payload_value(normalized_payload, field)
if value is None:
continue
existing_source = field_sources.get(field)
existing_value = getattr(current, field, None)
if (
existing_value in (None, "")
or _strategy_source_rank(source, DEFAULT_STRATEGY)
>= _strategy_source_rank(str(existing_source or ""), DEFAULT_STRATEGY)
):
setattr(current, field, value)
updated_fields.add(field)
current.vessel_type_name = current.vessel_type_name or normalize_vessel_type_name(
current.vessel_type
)
selected_reasons = dict(current.selected_reasons or {})
for field in updated_fields:
field_sources[field] = source
selected_reasons[field] = (
"newest_observation" if field in DYNAMIC_FIELDS else "source_priority"
)
current.field_sources = field_sources
current.selected_reasons = selected_reasons
current.source_summary = {
**dict(current.source_summary or {}),
source: {
"latest_observed_at": observed_at.isoformat(),
},
}
current.quality_flags = sorted(set((current.quality_flags or []) + (quality_flags or [])))
return current
async def get_current_vessels_snapshot(
db: AsyncSession,
*,
bbox: tuple[float, float, float, float],
limit: int = 1000,
observed_since: datetime,
) -> list[dict[str, Any]]:
"""Read the bounded latest-state table used by Earth rendering."""
safe_limit = min(max(int(limit or 1000), 1), MAX_SNAPSHOT_LIMIT)
lon_min, lat_min, lon_max, lat_max = bbox
stmt = (
select(VesselCurrentState)
.where(VesselCurrentState.observed_at >= observed_since)
.where(VesselCurrentState.lon >= lon_min)
.where(VesselCurrentState.lon <= lon_max)
.where(VesselCurrentState.lat >= lat_min)
.where(VesselCurrentState.lat <= lat_max)
.order_by(VesselCurrentState.observed_at.desc(), VesselCurrentState.mmsi.asc())
.limit(safe_limit)
)
result = await db.execute(stmt)
if not hasattr(result, "scalars"):
return []
return [item.to_dict() for item in result.scalars().all()]
async def aggregate_vessel_observations(
db: AsyncSession,
observations: Iterable[AISRawObservation],