486 lines
16 KiB
Python
486 lines
16 KiB
Python
from __future__ import annotations
|
|
|
|
import dataclasses
|
|
import io
|
|
import itertools
|
|
import json
|
|
import re
|
|
import typing
|
|
import zipfile
|
|
from argparse import ArgumentParser
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import geopandas as gpd
|
|
import numpy as np
|
|
import numpy.typing as npt
|
|
import polars
|
|
import pyproj
|
|
import requests
|
|
from pyproj import CRS, Transformer
|
|
from pyproj.aoi import AreaOfInterest
|
|
from pyproj.database import query_utm_crs_info
|
|
from tqdm import tqdm
|
|
|
|
import rerun as rr
|
|
import rerun.blueprint as rrb
|
|
|
|
if typing.TYPE_CHECKING:
|
|
import shapely
|
|
|
|
DATA_DIR = Path(__file__).parent / "dataset"
|
|
MAP_DATA_DIR = DATA_DIR / "map_data"
|
|
if not DATA_DIR.exists():
|
|
DATA_DIR.mkdir()
|
|
|
|
INVOLI_DATASETS = {
|
|
"10min": "https://storage.googleapis.com/rerun-example-datasets/involi/involi_demo_set_1_10min.zip",
|
|
"2h": "https://storage.googleapis.com/rerun-example-datasets/involi/involi_demo_set_2_2h.zip",
|
|
}
|
|
|
|
|
|
def download_with_progress(url: str, what: str) -> io.BytesIO:
|
|
"""Download file with tqdm progress bar."""
|
|
chunk_size = 1024 * 1024
|
|
try:
|
|
resp = requests.get(url, stream=True, timeout=30)
|
|
resp.raise_for_status()
|
|
total_size = int(resp.headers.get("content-length", 0))
|
|
with tqdm(
|
|
desc=f"Downloading {what}…",
|
|
total=total_size,
|
|
unit="iB",
|
|
unit_scale=True,
|
|
unit_divisor=1024,
|
|
) as progress:
|
|
download_file = io.BytesIO()
|
|
for data in resp.iter_content(chunk_size):
|
|
download_file.write(data)
|
|
progress.update(len(data))
|
|
|
|
if download_file.tell() != total_size and total_size > 0:
|
|
raise ValueError("Download incomplete: size mismatch")
|
|
|
|
download_file.seek(0)
|
|
return download_file
|
|
except (requests.RequestException, ValueError) as e:
|
|
raise RuntimeError(f"Failed to download {what} from {url}: {e}") from e
|
|
|
|
|
|
def shapely_geom_to_numpy(geom: shapely.Geometry) -> list[npt.NDArray[np.float64]]:
|
|
"""Convert shapely objects to numpy array suitable for logging as line batches."""
|
|
|
|
if geom.geom_type == "Polygon":
|
|
return [np.array(geom.exterior.coords)] + [np.array(interior.coords) for interior in geom.interiors]
|
|
elif geom.geom_type == "MultiPolygon":
|
|
res = []
|
|
for poly in geom.geoms:
|
|
res.extend(shapely_geom_to_numpy(poly))
|
|
return res
|
|
else:
|
|
print(f"Warning: unknown Shapely object {geom}")
|
|
return []
|
|
|
|
|
|
def log_region_boundaries_for_country(
|
|
country_code: str,
|
|
level: int,
|
|
color: tuple[float, float, float],
|
|
crs: CRS,
|
|
) -> None:
|
|
"""Log some boundaries for the given country and level."""
|
|
|
|
def download_eu_map_data() -> None:
|
|
"""Download some basic EU map data."""
|
|
|
|
if MAP_DATA_DIR.exists():
|
|
return
|
|
|
|
EU_MAP_DATA_URL = "https://gisco-services.ec.europa.eu/distribution/v2/nuts/download/ref-nuts-2021-01m.json.zip"
|
|
zip_data = download_with_progress(EU_MAP_DATA_URL, "map data")
|
|
with zipfile.ZipFile(zip_data) as zip_ref:
|
|
zip_ref.extractall(MAP_DATA_DIR)
|
|
|
|
download_eu_map_data()
|
|
|
|
# cspell:disable-next-line
|
|
map_data = gpd.read_file(MAP_DATA_DIR / f"NUTS_RG_01M_2021_4326_LEVL_{level}.json").set_crs("epsg:4326").to_crs(crs)
|
|
|
|
for _i, row in map_data[map_data.CNTR_CODE == country_code].iterrows():
|
|
entity_path = f"region_boundaries/{country_code}/{level}/{row.NUTS_ID}"
|
|
lines = shapely_geom_to_numpy(row.geometry)
|
|
rr.log(entity_path + "/2D", rr.LineStrips2D(lines, colors=color), static=True)
|
|
rr.log(
|
|
entity_path + "/3D",
|
|
rr.LineStrips3D(
|
|
[np.hstack([line, np.zeros((len(line), 1))]) for line in lines],
|
|
colors=color,
|
|
),
|
|
static=True,
|
|
)
|
|
metadata = row.to_dict()
|
|
metadata.pop("geometry")
|
|
rr.log(entity_path, rr.AnyValues(**metadata), static=True)
|
|
|
|
|
|
@dataclasses.dataclass
|
|
class Measurement:
|
|
"""One measurement loaded from INVOLI data. Corresponds to an "aircraft" record."""
|
|
|
|
icao_id: str
|
|
latitude: float | None
|
|
longitude: float | None
|
|
barometric_altitude: float | None
|
|
wg84_altitude: float | None
|
|
course: float | None
|
|
ground_speed: float | None
|
|
vertical_speed: float | None
|
|
ground_status: str | None
|
|
timestamp: float
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: dict[str, Any]) -> Measurement:
|
|
return cls(
|
|
icao_id=data["ids"]["icao"],
|
|
latitude=data.get("latitude"),
|
|
longitude=data.get("longitude"),
|
|
barometric_altitude=data.get("barometric_altitude"),
|
|
wg84_altitude=data.get("wg84_altitude"),
|
|
course=data.get("course"),
|
|
ground_speed=data.get("ground_speed"),
|
|
vertical_speed=data.get("vertical_speed"),
|
|
ground_status=data.get("ground_status"),
|
|
timestamp=data["timestamp"][0] + data["timestamp"][1] / 1e9,
|
|
)
|
|
|
|
|
|
def find_best_utm_crs(measurements: list[Measurement]) -> CRS:
|
|
"""Returns the best UTM coordinates reference system given a list of measurements."""
|
|
|
|
def get_area_of_interest(measurements: list[Measurement]) -> AreaOfInterest:
|
|
"""Compute the span of coordinates for all provided measurements."""
|
|
|
|
print("Computing area of interest…")
|
|
all_long_lat = [
|
|
(a.longitude, a.latitude) for a in measurements if a.latitude is not None and a.longitude is not None
|
|
]
|
|
return AreaOfInterest(
|
|
west_lon_degree=min(x[0] for x in all_long_lat),
|
|
south_lat_degree=min(x[1] for x in all_long_lat),
|
|
east_lon_degree=max(x[0] for x in all_long_lat),
|
|
north_lat_degree=max(x[1] for x in all_long_lat),
|
|
)
|
|
|
|
area_of_interest = get_area_of_interest(measurements)
|
|
utm_crs_list = query_utm_crs_info(
|
|
datum_name="WGS 84",
|
|
area_of_interest=area_of_interest,
|
|
)
|
|
|
|
return CRS.from_epsg(utm_crs_list[0].code)
|
|
|
|
|
|
def load_measurements(paths: list[Path]) -> list[Measurement]:
|
|
"""Load measurements from a bunch of json files."""
|
|
all_measurements = []
|
|
for path in tqdm(paths, "Loading measurements"):
|
|
data = json.loads(path.read_text())
|
|
for data_rec in data:
|
|
for aircraft in data_rec["records"]:
|
|
all_measurements.append(Measurement.from_dict(aircraft["aircraft"]))
|
|
|
|
return all_measurements
|
|
|
|
|
|
def get_paths_for_directory(directory: Path) -> list[Path]:
|
|
"""
|
|
Get a sorted list of JSON file by recursively walking the provided directory.
|
|
|
|
Note: technically, we don't need the list to be sorted as Rerun accepts out of order data. However, it comes at a
|
|
(small) performance cost and any (cheap) sorting on the logging end is always better.
|
|
"""
|
|
|
|
def atoi(text: str) -> int | str:
|
|
return int(text) if text.isdigit() else text
|
|
|
|
def natural_keys(path: Path) -> list[int | str]:
|
|
"""
|
|
Human sort.
|
|
|
|
alist.sort(key=natural_keys) sorts in human order
|
|
https://nedbatchelder.com/blog/200712/human_sorting.html
|
|
(See Toothy's implementation in the comments)
|
|
"""
|
|
return [atoi(c) for c in re.split(r"(\d+)", str(path))]
|
|
|
|
return sorted(directory.rglob("*.json"), key=natural_keys)
|
|
|
|
|
|
class Logger(typing.Protocol):
|
|
def process_measurement(self, measurement: Measurement) -> None:
|
|
pass
|
|
|
|
def flush(self) -> None:
|
|
pass
|
|
|
|
|
|
# ================================================================================================
|
|
# Simple logger
|
|
|
|
|
|
class MeasurementLogger:
|
|
"""Logger class that uses regular `rr.log` calls."""
|
|
|
|
def __init__(self, proj: pyproj.Transformer, raw: bool) -> None:
|
|
self._proj = proj
|
|
self._raw = raw
|
|
|
|
self._ignored_fields = [
|
|
"icao_id", # already the entity's path
|
|
"timestamp", # already the clock's value
|
|
]
|
|
|
|
def process_measurement(self, measurement: Measurement) -> None:
|
|
rr.set_time("unix_time", timestamp=measurement.timestamp)
|
|
|
|
if self._raw:
|
|
metadata = dataclasses.asdict(measurement)
|
|
else:
|
|
metadata = dataclasses.asdict(
|
|
measurement,
|
|
dict_factory=lambda x: {k: v for (k, v) in x if k not in self._ignored_fields and v is not None},
|
|
)
|
|
|
|
entity_path = f"aircraft/{measurement.icao_id}"
|
|
color = rr.components.Color.from_string(entity_path)
|
|
|
|
if (
|
|
measurement.latitude is not None
|
|
and measurement.longitude is not None
|
|
and measurement.barometric_altitude is not None
|
|
):
|
|
rr.log(
|
|
entity_path,
|
|
rr.Points3D(
|
|
[
|
|
self._proj.transform(
|
|
measurement.longitude,
|
|
measurement.latitude,
|
|
measurement.barometric_altitude,
|
|
),
|
|
],
|
|
colors=color,
|
|
),
|
|
rr.GeoPoints(lat_lon=[measurement.latitude, measurement.longitude]),
|
|
)
|
|
|
|
if len(metadata) > 0:
|
|
rr.log(entity_path, rr.AnyValues(**metadata))
|
|
|
|
if measurement.barometric_altitude is not None:
|
|
rr.log(
|
|
entity_path + "/barometric_altitude",
|
|
rr.Scalars(measurement.barometric_altitude),
|
|
rr.SeriesLines(colors=color),
|
|
)
|
|
|
|
def flush(self) -> None:
|
|
pass
|
|
|
|
|
|
# ================================================================================================
|
|
# Batch logger
|
|
|
|
|
|
class MeasurementBatchLogger:
|
|
"""Logger class that batches measurements and uses `rr.send_columns` calls."""
|
|
|
|
def __init__(self, proj: pyproj.Transformer, batch_size: int = 8192) -> None:
|
|
self._proj = proj
|
|
self._batch_size = batch_size
|
|
self._measurements: list[Measurement] = []
|
|
self._position_indicators: set[str] = set()
|
|
|
|
def process_measurement(self, measurement: Measurement) -> None:
|
|
self._measurements.append(measurement)
|
|
|
|
if len(self._measurements) >= 8192:
|
|
self.flush()
|
|
|
|
def flush(self) -> None:
|
|
# !!! the raw data is not sorted by timestamp, so we sort it here
|
|
df = polars.DataFrame(self._measurements).sort("timestamp")
|
|
self._measurements = []
|
|
|
|
for (icao_id,), group in df.group_by("icao_id"):
|
|
icao_id = str(icao_id)
|
|
|
|
# Note: this splitting in 3 different functions is due to the pattern of nulls in the raw data.
|
|
self.log_position_and_altitude(group, icao_id)
|
|
self.log_ground_status(group, icao_id)
|
|
self.log_metadata(group, icao_id)
|
|
|
|
def log_position_and_altitude(self, df: polars.DataFrame, icao_id: str) -> None:
|
|
entity_path = f"aircraft/{icao_id}"
|
|
df = df["timestamp", "latitude", "longitude", "barometric_altitude"].drop_nulls()
|
|
|
|
if df.height == 0:
|
|
return
|
|
|
|
if icao_id not in self._position_indicators:
|
|
color = rr.components.Color.from_string(entity_path)
|
|
rr.log(
|
|
entity_path,
|
|
rr.Points3D.from_fields(colors=color),
|
|
# TODO(cmc): That would be UB right now (and doesn't matter as long as we are on the untagged index).
|
|
# rr.GeoPoints.from_fields(colors=color),
|
|
static=True,
|
|
)
|
|
rr.log(entity_path + "/barometric_altitude", rr.SeriesLines.from_fields(colors=color), static=True)
|
|
self._position_indicators.add(icao_id)
|
|
|
|
timestamps = rr.TimeColumn("unix_time", timestamp=df["timestamp"].to_numpy())
|
|
pos = self._proj.transform(df["longitude"], df["latitude"], df["barometric_altitude"])
|
|
|
|
raw_coordinates = rr.AnyValues.columns(
|
|
latitude=df["latitude"].to_numpy(),
|
|
longitude=df["longitude"].to_numpy(),
|
|
barometric_altitude=df["barometric_altitude"].to_numpy(),
|
|
)
|
|
|
|
rr.send_columns(
|
|
entity_path,
|
|
[timestamps],
|
|
[
|
|
*rr.Points3D.columns(positions=np.vstack(pos).T),
|
|
*rr.GeoPoints.columns(positions=np.vstack((df["latitude"], df["longitude"])).T),
|
|
*raw_coordinates,
|
|
],
|
|
)
|
|
|
|
rr.send_columns(
|
|
entity_path + "/barometric_altitude",
|
|
[timestamps],
|
|
rr.Scalars.columns(scalars=df["barometric_altitude"].to_numpy()),
|
|
)
|
|
|
|
def log_ground_status(self, df: polars.DataFrame, icao_id: str) -> None:
|
|
entity_path = f"aircraft/{icao_id}"
|
|
df = df["timestamp", "ground_status"].drop_nulls()
|
|
|
|
if df.height == 0:
|
|
return
|
|
|
|
timestamps = rr.TimeColumn("unix_time", timestamp=df["timestamp"].to_numpy())
|
|
columns = rr.AnyValues.columns(ground_status=df["ground_status"].to_numpy())
|
|
|
|
rr.send_columns(entity_path, [timestamps], columns)
|
|
|
|
def log_metadata(self, df: polars.DataFrame, icao_id: str) -> None:
|
|
entity_path = f"aircraft/{icao_id}"
|
|
df = df["timestamp", "course", "ground_speed", "vertical_speed"].drop_nulls()
|
|
|
|
if df.height == 0:
|
|
return
|
|
|
|
metadata = rr.AnyValues.columns(
|
|
course=df["course"].to_numpy(),
|
|
ground_speed=df["ground_speed"].to_numpy(),
|
|
vertical_speed=df["vertical_speed"].to_numpy(),
|
|
)
|
|
|
|
rr.send_columns(
|
|
entity_path,
|
|
[rr.TimeColumn("unix_time", timestamp=df["timestamp"].to_numpy())],
|
|
metadata,
|
|
)
|
|
|
|
|
|
# ================================================================================================
|
|
|
|
|
|
def log_everything(paths: list[Path], raw: bool, batch: bool, batch_size: int) -> None:
|
|
measurements = load_measurements(paths)
|
|
utm_crs = find_best_utm_crs(measurements)
|
|
|
|
proj = Transformer.from_crs("EPSG:4326", utm_crs, always_xy=True)
|
|
|
|
for country_code, (level, color) in itertools.product(["DE", "CH"], [(0, (1, 0.5, 0.5))]):
|
|
log_region_boundaries_for_country(country_code, level, color, utm_crs)
|
|
|
|
# Exaggerate altitudes
|
|
rr.log("aircraft", rr.Transform3D(scale=[1, 1, 10]), static=True)
|
|
|
|
if batch:
|
|
logger: Logger = MeasurementBatchLogger(proj, batch_size)
|
|
else:
|
|
logger = MeasurementLogger(proj, raw)
|
|
|
|
for measurement in tqdm(measurements, "Logging measurements"):
|
|
if measurement.icao_id is None:
|
|
continue
|
|
|
|
logger.process_measurement(measurement)
|
|
logger.flush()
|
|
|
|
|
|
def main() -> None:
|
|
parser = ArgumentParser(description="Visualize INVOLI data")
|
|
parser.add_argument(
|
|
"--dataset",
|
|
choices=INVOLI_DATASETS.keys(),
|
|
default="2h",
|
|
help="Which dataset to automatically download and visualize",
|
|
)
|
|
parser.add_argument(
|
|
"--raw",
|
|
action="store_true",
|
|
help="If true, logs the raw data with all its issues (useful to stress edge cases in the viewer)",
|
|
)
|
|
parser.add_argument(
|
|
"--batch",
|
|
action="store_true",
|
|
default=True,
|
|
help="If true, use the batch logger function (rerun 0.18 required)",
|
|
)
|
|
parser.add_argument(
|
|
"--batch-size",
|
|
type=int,
|
|
default=8192,
|
|
help="Batch size for the batch logger",
|
|
)
|
|
parser.add_argument(
|
|
"--dir",
|
|
type=Path,
|
|
help="Use this directory of data instead of downloading a dataset",
|
|
)
|
|
rr.script_add_args(parser)
|
|
args = parser.parse_args()
|
|
|
|
if args.dir:
|
|
dataset_directory = args.dir
|
|
else:
|
|
dataset = args.dataset
|
|
dataset_ulr = INVOLI_DATASETS[dataset]
|
|
dataset_directory = DATA_DIR / dataset
|
|
if not dataset_directory.exists():
|
|
dataset_directory.mkdir()
|
|
zip_data = download_with_progress(dataset_ulr, f"dataset {dataset}")
|
|
with zipfile.ZipFile(zip_data) as zip_ref:
|
|
zip_ref.extractall(dataset_directory)
|
|
|
|
# TODO(ab): this blueprint would be massively improved by setting the 3D view's orbit point to FRA's coordinates.
|
|
blueprint = rrb.Vertical(
|
|
rrb.Horizontal(rrb.Spatial3DView(origin="/"), rrb.MapView(origin="/")),
|
|
rrb.TimeSeriesView(origin="/aircraft"),
|
|
row_shares=[3, 1],
|
|
)
|
|
rr.script_setup(args, "rerun_example_air_traffic_data", default_blueprint=blueprint)
|
|
|
|
paths = get_paths_for_directory(dataset_directory)
|
|
log_everything(paths, args.raw, args.batch, args.batch_size)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|