Skip to content

Commit

Permalink
[uss_qualifier] Add KML visualization to sequence view artifact (#462)
Browse files Browse the repository at this point in the history
* Factor out Volume4D KML operations from make_flight_intent_kml

* Generate KML visualizations for scenarios in sequence view artifact

* Fix general_flight_auth CI

* Improve Python compatibility

* `make format`

* Add resiliency to render failure

* Address comments
  • Loading branch information
BenjaminPelletier authored Jan 24, 2024
1 parent e97d874 commit a12e2b0
Show file tree
Hide file tree
Showing 11 changed files with 766 additions and 339 deletions.
9 changes: 9 additions & 0 deletions monitoring/monitorlib/geo.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,15 @@ def match(self, other: LatLngPoint) -> bool:
and abs(self.lng - other.lng) < COORD_TOLERANCE_DEG
)

def offset(self, east_meters: float, north_meters: float) -> LatLngPoint:
dlat = 360 * north_meters / EARTH_CIRCUMFERENCE_M
dlng = (
360
* east_meters
/ (EARTH_CIRCUMFERENCE_M * math.cos(math.radians(self.lat)))
)
return LatLngPoint(lat=self.lat + dlat, lng=self.lng + dlng)


class Radius(ImplicitDict):
value: float
Expand Down
249 changes: 248 additions & 1 deletion monitoring/monitorlib/kml.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,33 @@
from pykml import parser
import math
import re
from typing import List, Optional, Union

import s2sphere
from pykml import parser
from pykml.factory import KML_ElementMaker as kml

from monitoring.monitorlib.geo import (
Altitude,
AltitudeDatum,
DistanceUnits,
egm96_geoid_offset,
Radius,
EARTH_CIRCUMFERENCE_M,
LatLngPoint,
)
from monitoring.monitorlib.geotemporal import Volume4D

KML_NAMESPACE = {"kml": "http://www.opengis.net/kml/2.2"}
METERS_PER_FOOT = 0.3048

# Hexadecimal colors
GREEN = "ff00c000"
YELLOW = "ff00ffff"
RED = "ff0000ff"
CYAN = "ffc0c000"
TRANSLUCENT_GRAY = "80808080"
TRANSLUCENT_GREEN = "8000ff00"
TRANSLUCENT_LIGHT_CYAN = "80ffffaa"


def get_kml_root(kml_obj, from_string=False):
Expand Down Expand Up @@ -111,3 +136,225 @@ def get_kml_content(kml_file, from_string=False):
if folder_details:
kml_content.update(folder_details)
return kml_content


def _altitude_mode_of(altitude: Altitude) -> str:
if altitude.reference == AltitudeDatum.W84:
return "absolute"
elif altitude.reference == AltitudeDatum.SFC:
return "relativeToGround"
else:
raise NotImplementedError(
f"Altitude reference {altitude.reference} not yet supported"
)


def _distance_value_of(distance: Union[Altitude, Radius]) -> float:
if distance.units == DistanceUnits.M:
return distance.value
elif distance.units == DistanceUnits.FT:
return distance.value * METERS_PER_FOOT
else:
raise NotImplementedError(f"Distance units {distance.units} not yet supported")


def make_placemark_from_volume(
v4: Volume4D,
name: Optional[str] = None,
style_url: Optional[str] = None,
description: Optional[str] = None,
) -> kml.Placemark:
if "outline_polygon" in v4.volume and v4.volume.outline_polygon:
vertices = v4.volume.outline_polygon.vertices
elif "outline_circle" in v4.volume and v4.volume.outline_circle:
center = v4.volume.outline_circle.center
r = _distance_value_of(v4.volume.outline_circle.radius)
N_VERTICES = 32
vertices = [
center.offset(
r * math.sin(2 * math.pi * theta / N_VERTICES),
r * math.cos(2 * math.pi * theta / N_VERTICES),
)
for theta in range(0, N_VERTICES)
]
else:
raise NotImplementedError("Volume footprint type not supported")

# Create placemark
args = []
if name is not None:
args.append(kml.name(name))
if style_url is not None:
args.append(kml.styleUrl(style_url))
placemark = kml.Placemark(*args)
if description:
placemark.append(kml.description(description))

# Set time range
timespan = None
if "time_start" in v4 and v4.time_start:
timespan = kml.TimeSpan(kml.begin(v4.time_start.datetime.isoformat()))
if "time_end" in v4 and v4.time_end:
if timespan is None:
timespan = kml.TimeSpan()
timespan.append(kml.end(v4.time_end.datetime.isoformat()))
if timespan is not None:
placemark.append(timespan)

# Create top and bottom of the volume
avg = s2sphere.LatLng.from_degrees(
lat=sum(v.lat for v in vertices) / len(vertices),
lng=sum(v.lng for v in vertices) / len(vertices),
)
geoid_offset = egm96_geoid_offset(avg)
lower_coords = []
upper_coords = []
alt_lo = (
_distance_value_of(v4.volume.altitude_lower) - geoid_offset
if "altitude_lower" in v4.volume
else 0
)
alt_hi = (
_distance_value_of(v4.volume.altitude_upper) - geoid_offset
if "altitude_upper" in v4.volume
else 0
)
for vertex in vertices:
lower_coords.append((vertex.lng, vertex.lat, alt_lo))
upper_coords.append((vertex.lng, vertex.lat, alt_hi))
geo = kml.MultiGeometry()
make_sides = True
if "altitude_lower" in v4.volume:
geo.append(
kml.Polygon(
kml.altitudeMode(
_altitude_mode_of(
v4.volume.altitude_lower
if "altitude_lower" in v4.volume
else AltitudeDatum.SFC
)
),
kml.outerBoundaryIs(
kml.LinearRing(
kml.coordinates(
" ".join(",".join(str(v) for v in c) for c in lower_coords)
)
)
),
)
)
else:
make_sides = False
if "altitude_upper" in v4.volume:
geo.append(
kml.Polygon(
kml.altitudeMode(
_altitude_mode_of(
v4.volume.altitude_upper
if "altitude_upper" in v4.volume
else AltitudeDatum.SFC
)
),
kml.outerBoundaryIs(
kml.LinearRing(
kml.coordinates(
" ".join(",".join(str(v) for v in c) for c in upper_coords)
)
)
),
)
)
else:
make_sides = False

# We can only create the sides of the volume if the altitude references are the same
if (
make_sides
and v4.volume.altitude_lower.reference == v4.volume.altitude_upper.reference
):
indices = list(range(len(vertices)))
for i1, i2 in zip(indices, indices[1:] + [0]):
coords = [
(vertices[i1].lng, vertices[i1].lat, alt_lo),
(vertices[i1].lng, vertices[i1].lat, alt_hi),
(vertices[i2].lng, vertices[i2].lat, alt_hi),
(vertices[i2].lng, vertices[i2].lat, alt_lo),
]
geo.append(
kml.Polygon(
kml.altitudeMode(_altitude_mode_of(v4.volume.altitude_lower)),
kml.outerBoundaryIs(
kml.LinearRing(
kml.coordinates(
" ".join(",".join(str(v) for v in c) for c in coords)
)
)
),
)
)

placemark.append(geo)
return placemark


def flight_planning_styles() -> List[kml.Style]:
"""Provides KML styles with names in the form {FlightPlanState}_{AirspaceUsageState}."""
return [
kml.Style(
kml.LineStyle(kml.color(GREEN), kml.width(3)),
kml.PolyStyle(kml.color(TRANSLUCENT_GRAY)),
id="Planned_Nominal",
),
kml.Style(
kml.LineStyle(kml.color(GREEN), kml.width(3)),
kml.PolyStyle(kml.color(TRANSLUCENT_GREEN)),
id="InUse_Nominal",
),
kml.Style(
kml.LineStyle(kml.color(YELLOW), kml.width(5)),
kml.PolyStyle(kml.color(TRANSLUCENT_GREEN)),
id="InUse_OffNominal",
),
kml.Style(
kml.LineStyle(kml.color(RED), kml.width(5)),
kml.PolyStyle(kml.color(TRANSLUCENT_GREEN)),
id="InUse_Contingent",
),
]


def query_styles() -> List[kml.Style]:
"""Provides KML styles for query areas."""
return [
kml.Style(
kml.LineStyle(kml.color(CYAN), kml.width(3)),
kml.PolyStyle(kml.color(TRANSLUCENT_LIGHT_CYAN)),
id="QueryArea",
),
]


def f3548v21_styles() -> List[kml.Style]:
"""Provides KML styles according to F3548-21 operational intent states."""
return [
kml.Style(
kml.LineStyle(kml.color(GREEN), kml.width(3)),
kml.PolyStyle(kml.color(TRANSLUCENT_GRAY)),
id="F3548v21Accepted",
),
kml.Style(
kml.LineStyle(kml.color(GREEN), kml.width(3)),
kml.PolyStyle(kml.color(TRANSLUCENT_GREEN)),
id="F3548v21Activated",
),
kml.Style(
kml.LineStyle(kml.color(YELLOW), kml.width(5)),
kml.PolyStyle(kml.color(TRANSLUCENT_GREEN)),
id="F3548v21Nonconforming",
),
kml.Style(
kml.LineStyle(kml.color(RED), kml.width(5)),
kml.PolyStyle(kml.color(TRANSLUCENT_GREEN)),
id="F3548v21Contingent",
),
]
3 changes: 3 additions & 0 deletions monitoring/uss_qualifier/configurations/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,9 @@ class SequenceViewConfiguration(ImplicitDict):
redact_access_tokens: bool = True
"""When True, look for instances of "Authorization" keys in the report with values starting "Bearer " and redact the signature from those access tokens"""

render_kml: bool = True
"""When True, visualize geographic data for each scenario as a KML file."""


class ReportHTMLConfiguration(ImplicitDict):
redact_access_tokens: bool = True
Expand Down
4 changes: 3 additions & 1 deletion monitoring/uss_qualifier/reports/artifacts.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
from monitoring.uss_qualifier.configurations.configuration import ArtifactsConfiguration
from monitoring.uss_qualifier.reports.documents import make_report_html
from monitoring.uss_qualifier.reports.report import TestRunReport, redact_access_tokens
from monitoring.uss_qualifier.reports.sequence_view import generate_sequence_view
from monitoring.uss_qualifier.reports.sequence_view.generate import (
generate_sequence_view,
)
from monitoring.uss_qualifier.reports.templates import render_templates
from monitoring.uss_qualifier.reports.tested_requirements import (
generate_tested_requirements,
Expand Down
Empty file.
Loading

0 comments on commit a12e2b0

Please sign in to comment.