187 lines
7.6 KiB
Python
187 lines
7.6 KiB
Python
"""Vessel AIS models for live maritime tracking."""
|
|
|
|
from sqlalchemy import BigInteger, Column, DateTime, Float, Index, Integer, JSON, SmallInteger, String
|
|
from sqlalchemy.sql import func
|
|
|
|
from app.core.time import to_iso8601_utc
|
|
from app.db.session import Base
|
|
|
|
|
|
class VesselStatic(Base):
|
|
"""Slow-changing vessel identity and dimensions."""
|
|
|
|
__tablename__ = "vessel_static"
|
|
|
|
mmsi = Column(BigInteger, primary_key=True)
|
|
name = Column(String(128), nullable=True)
|
|
callsign = Column(String(16), nullable=True)
|
|
vessel_type = Column(SmallInteger, nullable=True, index=True)
|
|
vessel_type_name = Column(String(64), nullable=True, index=True)
|
|
flag = Column(String(4), nullable=True, index=True)
|
|
length = Column(Float, nullable=True)
|
|
width = Column(Float, nullable=True)
|
|
draught = Column(Float, nullable=True)
|
|
imo = Column(BigInteger, nullable=True)
|
|
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"mmsi": self.mmsi,
|
|
"name": self.name,
|
|
"callsign": self.callsign,
|
|
"vessel_type": self.vessel_type,
|
|
"vessel_type_name": self.vessel_type_name,
|
|
"flag": self.flag,
|
|
"length": self.length,
|
|
"width": self.width,
|
|
"draught": self.draught,
|
|
"imo": self.imo,
|
|
"updated_at": to_iso8601_utc(self.updated_at),
|
|
}
|
|
|
|
|
|
class VesselPosition(Base):
|
|
"""Append-only AIS positions retained for short history windows."""
|
|
|
|
__tablename__ = "vessel_position"
|
|
|
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
mmsi = Column(BigInteger, nullable=False, index=True)
|
|
lat = Column(Float, nullable=False)
|
|
lon = Column(Float, nullable=False)
|
|
sog = Column(Float, nullable=True)
|
|
cog = Column(Float, nullable=True)
|
|
heading = Column(SmallInteger, nullable=True)
|
|
nav_status = Column(SmallInteger, nullable=True, index=True)
|
|
received_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now(), index=True)
|
|
|
|
__table_args__ = (
|
|
Index("idx_vessel_pos_mmsi_time", "mmsi", "received_at"),
|
|
Index("idx_vessel_pos_time", "received_at"),
|
|
Index("idx_vessel_pos_lat_lon", "lat", "lon"),
|
|
)
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"id": self.id,
|
|
"mmsi": self.mmsi,
|
|
"lat": self.lat,
|
|
"lon": self.lon,
|
|
"sog": self.sog,
|
|
"cog": self.cog,
|
|
"heading": self.heading,
|
|
"nav_status": self.nav_status,
|
|
"received_at": to_iso8601_utc(self.received_at),
|
|
}
|
|
|
|
|
|
class AISRawObservation(Base):
|
|
"""Source-level AIS fact before aggregation and conflict resolution."""
|
|
|
|
__tablename__ = "ais_raw_observations"
|
|
|
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
target_schema = Column(String(64), nullable=False, default="vessel_ais", index=True)
|
|
source = Column(String(100), nullable=False, index=True)
|
|
entity_key = Column(String(64), nullable=False, index=True)
|
|
delivery_mode = Column(String(32), nullable=False, index=True)
|
|
transport = Column(String(32), nullable=False, index=True)
|
|
message_type = Column(String(64), nullable=True, index=True)
|
|
source_message_id = Column(String(128), nullable=True, index=True)
|
|
observation_hash = Column(String(64), nullable=False, unique=True, index=True)
|
|
observed_at = Column(DateTime(timezone=True), nullable=False, index=True)
|
|
collected_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now(), index=True)
|
|
normalized_payload = Column(JSON, default=dict)
|
|
raw_payload = Column(JSON, default=dict)
|
|
quality_flags = Column(JSON, default=list)
|
|
|
|
__table_args__ = (
|
|
Index("idx_ais_raw_entity_observed", "target_schema", "entity_key", "observed_at"),
|
|
Index("idx_ais_raw_schema_observed_entity", "target_schema", "observed_at", "entity_key"),
|
|
Index("idx_ais_raw_source_entity", "source", "entity_key"),
|
|
)
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"id": self.id,
|
|
"target_schema": self.target_schema,
|
|
"source": self.source,
|
|
"entity_key": self.entity_key,
|
|
"delivery_mode": self.delivery_mode,
|
|
"transport": self.transport,
|
|
"message_type": self.message_type,
|
|
"source_message_id": self.source_message_id,
|
|
"observation_hash": self.observation_hash,
|
|
"observed_at": to_iso8601_utc(self.observed_at),
|
|
"collected_at": to_iso8601_utc(self.collected_at),
|
|
"normalized_payload": self.normalized_payload or {},
|
|
"raw_payload": self.raw_payload or {},
|
|
"quality_flags": self.quality_flags or [],
|
|
}
|
|
|
|
|
|
class AISConflictRecord(Base):
|
|
"""Recorded field-level disagreement between AIS sources."""
|
|
|
|
__tablename__ = "ais_conflict_records"
|
|
|
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
target_schema = Column(String(64), nullable=False, default="vessel_ais", index=True)
|
|
entity_key = Column(String(64), nullable=False, index=True)
|
|
field = Column(String(64), nullable=False, index=True)
|
|
candidates = Column(JSON, default=dict)
|
|
selected_source = Column(String(100), nullable=True, index=True)
|
|
selected_value = Column(JSON, nullable=True)
|
|
selected_reason = Column(String(64), nullable=True, index=True)
|
|
resolved_by = Column(String(32), nullable=False, default="system", index=True)
|
|
status = Column(String(32), nullable=False, default="open", index=True)
|
|
created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now(), index=True)
|
|
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
|
|
|
__table_args__ = (
|
|
Index("idx_ais_conflict_entity_field", "target_schema", "entity_key", "field"),
|
|
)
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"id": self.id,
|
|
"target_schema": self.target_schema,
|
|
"entity_key": self.entity_key,
|
|
"field": self.field,
|
|
"candidates": self.candidates or {},
|
|
"selected_source": self.selected_source,
|
|
"selected_value": self.selected_value,
|
|
"selected_reason": self.selected_reason,
|
|
"resolved_by": self.resolved_by,
|
|
"status": self.status,
|
|
"created_at": to_iso8601_utc(self.created_at),
|
|
"updated_at": to_iso8601_utc(self.updated_at),
|
|
}
|
|
|
|
|
|
class AISSourceHealth(Base):
|
|
"""Runtime health signal for an AIS collector source."""
|
|
|
|
__tablename__ = "ais_source_health"
|
|
|
|
source = Column(String(100), primary_key=True)
|
|
connection_state = Column(String(32), nullable=False, default="disconnected", index=True)
|
|
last_seen_at = Column(DateTime(timezone=True), nullable=True, index=True)
|
|
last_success_at = Column(DateTime(timezone=True), nullable=True, index=True)
|
|
last_error = Column(String(500), nullable=True)
|
|
message_rate = Column(Float, nullable=True)
|
|
lag_seconds = Column(Float, nullable=True)
|
|
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now(), index=True)
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"source": self.source,
|
|
"connection_state": self.connection_state,
|
|
"last_seen_at": to_iso8601_utc(self.last_seen_at),
|
|
"last_success_at": to_iso8601_utc(self.last_success_at),
|
|
"last_error": self.last_error,
|
|
"message_rate": self.message_rate,
|
|
"lag_seconds": self.lag_seconds,
|
|
"updated_at": to_iso8601_utc(self.updated_at),
|
|
}
|