Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions atak/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""ATAK bridge helpers."""

from __future__ import annotations
22 changes: 22 additions & 0 deletions atak/bridge.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"""ATAK bridge entry points."""

from __future__ import annotations

import json
from pathlib import Path

from atak.cot import PlanMapping, write_plan_kml


def export_plan_kml(plan: PlanMapping, output_path: str | Path) -> Path:
"""Export a plan response to a KML file for manual ATAK import."""
return write_plan_kml(plan, output_path)


def export_plan_json_kml(plan_json: str, output_path: str | Path) -> Path:
"""Export a JSON-encoded plan response to KML."""
decoded = json.loads(plan_json)
if not isinstance(decoded, dict):
msg = "plan JSON must decode to an object"
raise ValueError(msg)
return export_plan_kml(decoded, output_path)
147 changes: 147 additions & 0 deletions atak/cot.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
"""KML and CoT-adjacent helpers for ATAK route export."""

from __future__ import annotations

from collections.abc import Mapping, Sequence
from pathlib import Path
from xml.etree import ElementTree

KML_NS = "http://www.opengis.net/kml/2.2"
ElementTree.register_namespace("", KML_NS)

JsonScalar = str | int | float | bool | None
JsonValue = JsonScalar | Mapping[str, "JsonValue"] | Sequence["JsonValue"]
PlanMapping = Mapping[str, JsonValue]


class PlanExportError(ValueError):
"""Raised when a plan response cannot be exported for ATAK."""


def plan_to_kml(plan: PlanMapping, *, document_name: str = "TERA route") -> str:
"""Serialize a PlanResponse-like mapping to a KML document."""
coordinates = _extract_linestring_coordinates(plan)
waypoints = _extract_waypoints(plan)

kml = ElementTree.Element(_kml_tag("kml"))
document = ElementTree.SubElement(kml, _kml_tag("Document"))
ElementTree.SubElement(document, _kml_tag("name")).text = document_name

route_placemark = ElementTree.SubElement(document, _kml_tag("Placemark"))
ElementTree.SubElement(route_placemark, _kml_tag("name")).text = "TERA route"
rationale = plan.get("rationale")
if isinstance(rationale, str):
ElementTree.SubElement(route_placemark, _kml_tag("description")).text = rationale

line = ElementTree.SubElement(route_placemark, _kml_tag("LineString"))
ElementTree.SubElement(line, _kml_tag("tessellate")).text = "1"
ElementTree.SubElement(line, _kml_tag("coordinates")).text = " ".join(
_format_kml_coordinate(lon, lat, alt) for lon, lat, alt in coordinates
)

for index, waypoint in enumerate(waypoints, start=1):
placemark = ElementTree.SubElement(document, _kml_tag("Placemark"))
ElementTree.SubElement(placemark, _kml_tag("name")).text = (
waypoint.label or f"Waypoint {index}"
)
point = ElementTree.SubElement(placemark, _kml_tag("Point"))
ElementTree.SubElement(point, _kml_tag("coordinates")).text = _format_kml_coordinate(
waypoint.lon,
waypoint.lat,
None,
)

ElementTree.indent(kml, space=" ")
xml = ElementTree.tostring(kml, encoding="utf-8", xml_declaration=True)
return xml.decode("utf-8")


def write_plan_kml(
plan: PlanMapping,
output_path: str | Path,
*,
document_name: str = "TERA route",
) -> Path:
"""Write a PlanResponse-like mapping as a KML file and return the path."""
path = Path(output_path)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(plan_to_kml(plan, document_name=document_name), encoding="utf-8")
return path


class _Waypoint:
def __init__(self, *, lat: float, lon: float, label: str | None) -> None:
self.lat = lat
self.lon = lon
self.label = label


def _kml_tag(name: str) -> str:
return f"{{{KML_NS}}}{name}"


def _extract_linestring_coordinates(plan: PlanMapping) -> list[tuple[float, float, float | None]]:
route = _mapping_field(plan, "route")
geometry = _mapping_field(route, "geometry")
geometry_type = geometry.get("type")
if geometry_type != "LineString":
raise PlanExportError("plan route geometry must be a GeoJSON LineString")

raw_coordinates = geometry.get("coordinates")
if not isinstance(raw_coordinates, Sequence) or isinstance(raw_coordinates, str):
raise PlanExportError("plan route geometry coordinates must be a sequence")

coordinates: list[tuple[float, float, float | None]] = []
for raw_coord in raw_coordinates:
if not isinstance(raw_coord, Sequence) or isinstance(raw_coord, str):
raise PlanExportError("each route coordinate must be a sequence")
if len(raw_coord) not in {2, 3}:
raise PlanExportError(
"each route coordinate must contain lon, lat, and optional altitude"
)

lon = _number(raw_coord[0], "route longitude")
lat = _number(raw_coord[1], "route latitude")
alt = _number(raw_coord[2], "route altitude") if len(raw_coord) == 3 else None
coordinates.append((lon, lat, alt))

if len(coordinates) < 2:
raise PlanExportError("route must contain at least two coordinates")

return coordinates


def _extract_waypoints(plan: PlanMapping) -> list[_Waypoint]:
raw_waypoints = plan.get("waypoints", [])
if not isinstance(raw_waypoints, Sequence) or isinstance(raw_waypoints, str):
raise PlanExportError("plan waypoints must be a sequence")

waypoints: list[_Waypoint] = []
for raw_waypoint in raw_waypoints:
if not isinstance(raw_waypoint, Mapping):
raise PlanExportError("each waypoint must be an object")
lat = _number(raw_waypoint.get("lat"), "waypoint latitude")
lon = _number(raw_waypoint.get("lon"), "waypoint longitude")
raw_label = raw_waypoint.get("label")
label = raw_label if isinstance(raw_label, str) else None
waypoints.append(_Waypoint(lat=lat, lon=lon, label=label))

return waypoints


def _mapping_field(source: Mapping[str, JsonValue], name: str) -> Mapping[str, JsonValue]:
value = source.get(name)
if not isinstance(value, Mapping):
raise PlanExportError(f"plan field {name!r} must be an object")
return value


def _number(value: JsonValue, label: str) -> float:
if isinstance(value, bool) or not isinstance(value, int | float):
raise PlanExportError(f"{label} must be numeric")
return float(value)


def _format_kml_coordinate(lon: float, lat: float, alt: float | None) -> str:
altitude = 0.0 if alt is None else alt
return f"{lon:.7f},{lat:.7f},{altitude:.2f}"
49 changes: 49 additions & 0 deletions data/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Data Pipeline

The Phase 1/2 data build produces four local artifacts:

- `data/extracts/sf.osm.pbf`
- `data/extracts/austere.osm.pbf`
- `data/dem/sf.tif`
- `data/dem/austere.tif`

Install lane tools:

```bash
brew install osmium-tool gdal
```

Fetch and verify the full Phase 1 data bundle:

```bash
data/scripts/fetch_all.sh
```

This downloads a small San Francisco PBF from BBBike, queries Overpass for the
austere AO, downloads public Copernicus GLO-30 DEM source tiles, crops both AOIs,
then writes and verifies `data/manifest.sha256`.

Clip OSM extracts from a larger source PBF:

```bash
data/scripts/clip_osm.sh /path/to/california-latest.osm.pbf
```

Crop DEM GeoTIFFs from one or more source DEM tiles:

```bash
data/scripts/build_dem.sh /path/to/source-dem-1.tif /path/to/source-dem-2.tif
```

Write and verify the artifact manifest:

```bash
data/scripts/write_manifest.sh
data/scripts/verify_manifest.sh
```

`data/aois.yml` is the source of truth for AOI bounding boxes and output paths.

Root-level `make data-fetch` / `make data-verify` are documented lane entry
points, but the root `Makefile` is owned by P2. Until P2 wires those targets,
use `data/scripts/fetch_all.sh` and `data/scripts/verify_manifest.sh` directly.
30 changes: 30 additions & 0 deletions data/aois.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
aois:
- name: sf
label: San Francisco demo AO
bbox:
west: -122.535
south: 37.690
east: -122.340
north: 37.835
route_profiles:
- foot
- foot_covered
osm_extract: data/extracts/sf.osm.pbf
dem: data/dem/sf.tif
cesium_cache: data/cache/cesium/sf
notes: Ferry Building to local freshwater fallback demo.
- name: austere
label: MWTC Bridgeport austere demo AO
bbox:
west: -119.720
south: 38.160
east: -119.360
north: 38.460
route_profiles:
- foot
- foot_covered
- vehicle_mrap
osm_extract: data/extracts/austere.osm.pbf
dem: data/dem/austere.tif
cesium_cache: data/cache/cesium/austere
notes: Mountain Warfare Training Center / Pickel Meadows proxy near Bridgeport.
1 change: 1 addition & 0 deletions data/manifest.sha256
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Placeholder. Regenerate with data/scripts/write_manifest.sh after OSM/DEM artifacts are built.
70 changes: 70 additions & 0 deletions data/scripts/build_dem.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
#!/usr/bin/env bash
set -euo pipefail

ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
AOIS_FILE="${AOIS_FILE:-$ROOT_DIR/data/aois.yml}"
WORK_DIR="${WORK_DIR:-$ROOT_DIR/data/runtime/dem-build}"

if [[ "$#" -lt 1 ]]; then
echo "usage: data/scripts/build_dem.sh <source-dem.tif> [source-dem-2.tif ...]" >&2
exit 2
fi

if ! command -v gdalbuildvrt >/dev/null 2>&1; then
echo "error: gdalbuildvrt is required. macOS: brew install gdal" >&2
exit 127
fi

if ! command -v gdalwarp >/dev/null 2>&1; then
echo "error: gdalwarp is required. macOS: brew install gdal" >&2
exit 127
fi

mkdir -p "$WORK_DIR"
VRT="$WORK_DIR/source-dem.vrt"

gdalbuildvrt -overwrite "$VRT" "$@"

"$ROOT_DIR/.venv/bin/python" - "$ROOT_DIR" "$AOIS_FILE" "$VRT" <<'PY'
from __future__ import annotations

import subprocess
import sys
from pathlib import Path

import yaml

root = Path(sys.argv[1])
aois_file = Path(sys.argv[2])
vrt = Path(sys.argv[3])
config = yaml.safe_load(aois_file.read_text(encoding="utf-8"))

for aoi in config["aois"]:
bbox = aoi["bbox"]
output = root / aoi["dem"]
output.parent.mkdir(parents=True, exist_ok=True)
command = [
"gdalwarp",
"-overwrite",
"-of",
"GTiff",
"-t_srs",
"EPSG:4326",
"-te",
str(bbox["west"]),
str(bbox["south"]),
str(bbox["east"]),
str(bbox["north"]),
"-r",
"bilinear",
"-co",
"COMPRESS=DEFLATE",
"-co",
"PREDICTOR=2",
str(vrt),
str(output),
]
print(f"[data] building DEM {aoi['name']} -> {output}")
subprocess.run(command, check=True)
PY

56 changes: 56 additions & 0 deletions data/scripts/clip_osm.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
set -euo pipefail

ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
AOIS_FILE="${AOIS_FILE:-$ROOT_DIR/data/aois.yml}"
SOURCE_PBF="${1:-}"

if [[ -z "$SOURCE_PBF" ]]; then
echo "usage: data/scripts/clip_osm.sh <source.osm.pbf>" >&2
exit 2
fi

if ! command -v osmium >/dev/null 2>&1; then
echo "error: osmium is required. macOS: brew install osmium-tool" >&2
exit 127
fi

if [[ ! -f "$SOURCE_PBF" ]]; then
echo "error: source PBF not found: $SOURCE_PBF" >&2
exit 1
fi

"$ROOT_DIR/.venv/bin/python" - "$ROOT_DIR" "$AOIS_FILE" "$SOURCE_PBF" <<'PY'
from __future__ import annotations

import subprocess
import sys
from pathlib import Path

import yaml

root = Path(sys.argv[1])
aois_file = Path(sys.argv[2])
source_pbf = Path(sys.argv[3])
config = yaml.safe_load(aois_file.read_text(encoding="utf-8"))

for aoi in config["aois"]:
bbox = aoi["bbox"]
output = root / aoi["osm_extract"]
output.parent.mkdir(parents=True, exist_ok=True)
bbox_arg = f"{bbox['west']},{bbox['south']},{bbox['east']},{bbox['north']}"
command = [
"osmium",
"extract",
"--bbox",
bbox_arg,
"--strategy",
"complete_ways",
"--overwrite",
"--output",
str(output),
str(source_pbf),
]
print(f"[data] clipping {aoi['name']} -> {output}")
subprocess.run(command, check=True)
PY
9 changes: 9 additions & 0 deletions data/scripts/fetch_all.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
set -euo pipefail

ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"

"$ROOT_DIR/data/scripts/fetch_osm.sh"
"$ROOT_DIR/data/scripts/fetch_dem.sh"
"$ROOT_DIR/data/scripts/write_manifest.sh"
"$ROOT_DIR/data/scripts/verify_manifest.sh"
Loading
Loading