From e638fe87d6ab2234b2b9cbbae3595d09c9e68b80 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 06:54:16 +0000 Subject: [PATCH 1/3] Bump implicitdict from 4.1.0 to 5.0.0 Bumps [implicitdict](https://github.com/interuss/implicitdict) from 4.1.0 to 5.0.0. - [Release notes](https://github.com/interuss/implicitdict/releases) - [Commits](https://github.com/interuss/implicitdict/compare/v4.1.0...v5.0.0) --- updated-dependencies: - dependency-name: implicitdict dependency-version: 5.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- uv.lock | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/uv.lock b/uv.lock index 0c0d6bed68..e02fa00db3 100644 --- a/uv.lock +++ b/uv.lock @@ -985,16 +985,16 @@ wheels = [ [[package]] name = "implicitdict" -version = "4.1.0" +version = "5.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "arrow" }, { name = "jsonschema" }, - { name = "pytimeparse" }, + { name = "pytimeparse2" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/09/42/657eac38a6f71090790a8399decb4c6a8243136cebc7929c18c8c0df0613/implicitdict-4.1.0.tar.gz", hash = "sha256:afa39b97180993c0c8a75bccb72fa05f14a8db7550ffc18d92a3a2717396701b", size = 53878, upload-time = "2026-01-08T04:33:24.524Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/ca/07ec9f8d136d202b047d481b95319c5697f097c9fed37807d6e2b99011ac/implicitdict-5.0.0.tar.gz", hash = "sha256:7d6cd0dcad8742df15d8f29b355bd2f854ccf6a6276cf68bc70a8d5139885a05", size = 54851, upload-time = "2026-09-26T01:49:52.398Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/da/0e/9382537211f4009a99de7b6c005d52b55ee789c245b9e9a77015edd36958/implicitdict-4.1.0-py3-none-any.whl", hash = "sha256:e550f55aafe933e3132ec0dc9fe68e686dc2060d5ba676bf54f748cfd5fedfd0", size = 13737, upload-time = "2026-01-08T04:33:23.541Z" }, + { url = "https://files.pythonhosted.org/packages/92/7d/cd6169a6f2e16634df8a9d05d00685d5e426c9b27a52d076ea3bafa14e3d/implicitdict-5.0.0-py3-none-any.whl", hash = "sha256:8371d3896b3a52b6de793efa99e5f8c97505076627692d325e2b6e701dbaa65a", size = 13782, upload-time = "2026-09-26T01:49:51.045Z" }, ] [[package]] @@ -2298,12 +2298,12 @@ client = [ ] [[package]] -name = "pytimeparse" -version = "1.1.8" +name = "pytimeparse2" +version = "1.7.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/37/5d/231f5f33c81e09682708fb323f9e4041408d8223e2f0fb9742843328778f/pytimeparse-1.1.8.tar.gz", hash = "sha256:e86136477be924d7e670646a98561957e8ca7308d44841e21f5ddea757556a0a", size = 9403, upload-time = "2018-05-18T17:40:42.76Z" } +sdist = { url = "https://files.pythonhosted.org/packages/19/10/cc63fecd69905eb4d300fe71bd580e4a631483e9f53fdcb8c0ad345ce832/pytimeparse2-1.7.1.tar.gz", hash = "sha256:98668cdcba4890e1789e432e8ea0059ccf72402f13f5d52be15bdfaeb3a8b253", size = 10431, upload-time = "2023-05-11T21:40:55.774Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/b4/afd75551a3b910abd1d922dbd45e49e5deeb4d47dc50209ce489ba9844dd/pytimeparse-1.1.8-py2.py3-none-any.whl", hash = "sha256:04b7be6cc8bd9f5647a6325444926c3ac34ee6bc7e69da4367ba282f076036bd", size = 9969, upload-time = "2018-05-18T17:40:41.28Z" }, + { url = "https://files.pythonhosted.org/packages/17/9e/85abf91ef5df452f56498927affdb7128194d15644084f6c6722477c305b/pytimeparse2-1.7.1-py3-none-any.whl", hash = "sha256:a162ea6a7707fd0bb82dd99556efb783935f51885c8bdced0fce3fffe85ab002", size = 6136, upload-time = "2023-05-11T21:40:46.051Z" }, ] [[package]] From 4434827fee30db2b6b907fad3bf65968c118c551 Mon Sep 17 00:00:00 2001 From: Benjamin Pelletier Date: Wed, 7 Oct 2026 11:09:23 +0900 Subject: [PATCH 2/3] Fix revealed errors --- .basedpyright/baseline.json | 72 ---------- AGENTS.md | 3 +- .../documentation/definitions.py | 6 + .../documentation/definitions_test.py | 36 +++++ .../astm/netrid/common/dss/isa_simple.py | 12 +- .../astm/netrid/common/misbehavior.py | 10 +- .../astm/netrid/display_data_evaluator.py | 64 +++++---- .../netrid/display_data_evaluator_test.py | 99 ++++++++++++- .../utm/dss/fragments/sub/crud/__init__.py | 20 ++- .../astm/utm/dss/oir_implicit_sub_handling.py | 12 +- .../astm/utm/dss/subscription_validation.py | 28 +++- .../utm/dss/subscription_validation_test.py | 131 ++++++++++++++++++ 12 files changed, 371 insertions(+), 122 deletions(-) create mode 100644 monitoring/uss_qualifier/action_generators/documentation/definitions_test.py create mode 100644 monitoring/uss_qualifier/scenarios/astm/utm/dss/subscription_validation_test.py diff --git a/.basedpyright/baseline.json b/.basedpyright/baseline.json index 2e0b048228..ade0f90dc5 100644 --- a/.basedpyright/baseline.json +++ b/.basedpyright/baseline.json @@ -11971,14 +11971,6 @@ } ], "./monitoring/uss_qualifier/scenarios/astm/netrid/display_data_evaluator.py": [ - { - "code": "reportAttributeAccessIssue", - "range": { - "startColumn": 30, - "endColumn": 33, - "lineCount": 1 - } - }, { "code": "reportOptionalSubscript", "range": { @@ -12131,14 +12123,6 @@ "lineCount": 1 } }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 16, - "endColumn": 39, - "lineCount": 1 - } - }, { "code": "reportOperatorIssue", "range": { @@ -12349,14 +12333,6 @@ "lineCount": 1 } }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 12, - "endColumn": 13, - "lineCount": 1 - } - }, { "code": "reportArgumentType", "range": { @@ -12365,14 +12341,6 @@ "lineCount": 1 } }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 28, - "endColumn": 29, - "lineCount": 1 - } - }, { "code": "reportAttributeAccessIssue", "range": { @@ -13773,30 +13741,6 @@ "lineCount": 1 } }, - { - "code": "reportAttributeAccessIssue", - "range": { - "startColumn": 92, - "endColumn": 101, - "lineCount": 1 - } - }, - { - "code": "reportReturnType", - "range": { - "startColumn": 19, - "endColumn": 23, - "lineCount": 1 - } - }, - { - "code": "reportReturnType", - "range": { - "startColumn": 15, - "endColumn": 52, - "lineCount": 1 - } - }, { "code": "reportInvalidTypeVarUse", "range": { @@ -15610,22 +15554,6 @@ "endColumn": 82, "lineCount": 1 } - }, - { - "code": "reportOptionalMemberAccess", - "range": { - "startColumn": 40, - "endColumn": 48, - "lineCount": 1 - } - }, - { - "code": "reportOptionalMemberAccess", - "range": { - "startColumn": 74, - "endColumn": 81, - "lineCount": 1 - } } ], "./monitoring/uss_qualifier/scenarios/astm/utm/dss/synchronization/constraint_ref_synchronization.py": [ diff --git a/AGENTS.md b/AGENTS.md index e0b747fedc..81e8dfe64a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,8 @@ This document contains key context, nuances, and troubleshooting tips specifical ## 2. Navigating Data Schemas - **Implicit Types**: Many schema objects inherit from `ImplicitDict`. This means that reading their raw Python class definitions may not reveal all their expected structure. Rely on their `__annotations__` or their OpenAPI documentation. -- **Optional `ImplicitDict` Fields**: `ImplicitDict` fields annotated with `| None` (or `Optional[...]`) indicate optional dictionary keys. When constructing an `ImplicitDict` instance, omit optional fields that do not have a value rather than explicitly passing `None` (which would insert a `null` value into the serialized dictionary). +- **Optional `ImplicitDict` Fields**: `ImplicitDict` fields annotated with `| None` (or `Optional[...]`) indicate optional dictionary keys. In implicitdict 5.0.0, accessing a missing optional attribute returns `None` without inserting a key; use `"field" in obj` to distinguish absence from an explicitly stored null. Constructor keyword arguments set to `None` normally omit optional values, but class defaults may still populate the key; assigning `obj.field = None` or `obj["field"] = None` stores an explicit null. Use dictionary membership rather than `hasattr` when key presence matters. +- **implicitdict 5.0.0 Typing**: Its unannotated `__getattribute__` introduces `None` into the inferred type of undeclared attributes (`Unknown | Any | None` in basedpyright 1.40.1), while declared fields and properties retain their types. New optional-access diagnostics may expose a nonexistent member or the wrong schema class rather than a missing optional value. Check the receiver's declaration before adding a None guard or suppressing the diagnostic. An explicit `-> Any` return annotation on the accessor restores the previous permissive fallback without changing runtime behavior, but also hides these pre-existing member-access mistakes. - **Hidden Schema Nuances**: Certain F3548 properties intuitively behave differently than standard logic might predict (e.g., F3548's `OperationalIntentReference` does *not* represent 4D `altitude` extents, whereas `Volume4D` details do). Always verify property existence programmatically instead of relying on assumptions. - **Datetime Types**: Fields annotated as `StringBasedDateTime` (or generic `Time` structures wrapping it) inherently expose a `.datetime` property mapping directly to Python's built-in `datetime.datetime` type. Always rely on this `.datetime` attribute for date-math instead of manually parsing string strings. - **DSS Base URL Suffixes**: When configuring DSS instances, the base URL for ASTM F3411-22a (NetRID v2) requires the `/rid/v2` suffix path (e.g. `http://dss1.uss1.localutm/rid/v2`), whereas F3411-19 and SCD (F3548) typically use the host base URL directly without that suffix. diff --git a/monitoring/uss_qualifier/action_generators/documentation/definitions.py b/monitoring/uss_qualifier/action_generators/documentation/definitions.py index d170d6a867..3ebf59f525 100644 --- a/monitoring/uss_qualifier/action_generators/documentation/definitions.py +++ b/monitoring/uss_qualifier/action_generators/documentation/definitions.py @@ -33,3 +33,9 @@ class PotentialGeneratedAction(ImplicitDict): test_scenario: Optional[PotentialTestScenarioAction] test_suite: Optional[PotentialTestSuiteAction] action_generator: Optional[PotentialActionGeneratorAction] + + @property + def invalid_type_error(self) -> ValueError: + return ValueError( + "Invalid PotentialGeneratedAction: test_scenario, test_suite or action_generator must be specified" + ) diff --git a/monitoring/uss_qualifier/action_generators/documentation/definitions_test.py b/monitoring/uss_qualifier/action_generators/documentation/definitions_test.py new file mode 100644 index 0000000000..a26f744ea6 --- /dev/null +++ b/monitoring/uss_qualifier/action_generators/documentation/definitions_test.py @@ -0,0 +1,36 @@ +import pytest + +from monitoring.uss_qualifier.action_generators.documentation.definitions import ( + PotentialGeneratedAction, +) +from monitoring.uss_qualifier.reports.tested_requirements.breakdown import ( + _populate_breakdown_with_action_declaration, +) +from monitoring.uss_qualifier.reports.tested_requirements.data_types import ( + TestedBreakdown as Breakdown, +) +from monitoring.uss_qualifier.suites.documentation.documentation import ( + TestSuiteRenderContext as RenderContext, +) +from monitoring.uss_qualifier.suites.documentation.documentation import ( + _collect_requirements_from_action, + _render_action, +) + + +@pytest.mark.parametrize("consumer", ["render", "requirements", "breakdown"]) +# This test is AI-generated and has not been closely inspected by a human. +def test_invalid_generated_action_reports_value_error(consumer: str): + action = PotentialGeneratedAction() + with pytest.raises(ValueError, match="Invalid PotentialGeneratedAction"): + if consumer == "render": + _render_action( + action, + RenderContext("suite.yaml", "suite.md", ".", 0, 0, {}), + ) + elif consumer == "requirements": + _collect_requirements_from_action(action) + else: + _populate_breakdown_with_action_declaration( + Breakdown(packages=[]), action, [], None + ) diff --git a/monitoring/uss_qualifier/scenarios/astm/netrid/common/dss/isa_simple.py b/monitoring/uss_qualifier/scenarios/astm/netrid/common/dss/isa_simple.py index 960c3b1719..397b2f140b 100644 --- a/monitoring/uss_qualifier/scenarios/astm/netrid/common/dss/isa_simple.py +++ b/monitoring/uss_qualifier/scenarios/astm/netrid/common/dss/isa_simple.py @@ -233,7 +233,7 @@ def _search_earliest_incl_step(): check.record_failed( f"ISAs search did not return expected ISA {self._isa_id}", details=f"Search in area {self._isa_area} from time {earliest} returned ISAs {isas.isas.keys()}", - query_timestamps=[isas.dss_query.query.request.timestamp], + query_timestamps=[isas.query.request.timestamp], ) else: isa_validator = ISAValidator( @@ -272,7 +272,7 @@ def _search_earliest_excl_step(): check.record_failed( f"ISAs search returned unexpected ISA {self._isa_id}", details=f"Search in area {self._isa_area} from time {earliest} returned ISAs {isas.isas.keys()}", - query_timestamps=[isas.dss_query.query.request.timestamp], + query_timestamps=[isas.query.request.timestamp], ) self.end_test_step() @@ -299,7 +299,7 @@ def _search_latest_incl_step(): check.record_failed( f"ISAs search did not return expected ISA {self._isa_id}", details=f"Search in area {self._isa_area} to time {latest} returned ISAs {isas.isas.keys()}", - query_timestamps=[isas.dss_query.query.request.timestamp], + query_timestamps=[isas.query.request.timestamp], ) else: isa_validator = ISAValidator( @@ -338,7 +338,7 @@ def _search_latest_excl_step(): check.record_failed( f"ISAs search returned unexpected ISA {self._isa_id}", details=f"Search in area {self._isa_area} to time {latest} returned ISAs {isas.isas.keys()}", - query_timestamps=[isas.dss_query.query.request.timestamp], + query_timestamps=[isas.query.request.timestamp], ) self.end_test_step() @@ -363,7 +363,7 @@ def _search_area_only_step(): check.record_failed( f"ISAs search did not return expected ISA {self._isa_id}", details=f"Search in area {self._isa_area} returned ISAs {isas.isas.keys()}", - query_timestamps=[isas.dss_query.query.request.timestamp], + query_timestamps=[isas.query.request.timestamp], ) else: isa_validator = ISAValidator( @@ -530,7 +530,7 @@ def _search_isa_step(): check.record_failed( f"ISAs search returned deleted ISA {self._isa_id}", details=f"Search in area {self._isa_area} returned ISAs {isas.isas.keys()}", - query_timestamps=[isas.dss_query.query.request.timestamp], + query_timestamps=[isas.query.request.timestamp], ) self.end_test_step() diff --git a/monitoring/uss_qualifier/scenarios/astm/netrid/common/misbehavior.py b/monitoring/uss_qualifier/scenarios/astm/netrid/common/misbehavior.py index e5f64f744a..f8084aa140 100644 --- a/monitoring/uss_qualifier/scenarios/astm/netrid/common/misbehavior.py +++ b/monitoring/uss_qualifier/scenarios/astm/netrid/common/misbehavior.py @@ -23,6 +23,7 @@ injection, ) from monitoring.uss_qualifier.scenarios.astm.netrid.display_data_evaluator import ( + DPObservedFlight, FetchedToInjectedCache, TelemetryMapping, ) @@ -167,7 +168,9 @@ def poll_func(rect: LatLngRect) -> bool: def _is_area_too_large(self, rect: s2sphere.LatLngRect) -> bool: return geo.get_latlngrect_diagonal_km(rect) > self._rid_version.max_diagonal_km - def _fetch_flights_from_dss(self, rect: LatLngRect) -> dict[str, TelemetryMapping]: + def _fetch_flights_from_dss( + self, rect: LatLngRect + ) -> dict[str, TelemetryMapping[DPObservedFlight]]: # We grab all flights from the SPs (which we know how to reach by first querying the DSS). # This is authenticated and is expected to succeed sp_observation = rid.all_flights( @@ -215,7 +218,10 @@ def _evaluate_and_test_too_large_area_requests( return set(mapping_by_injection_id.keys()) def _evaluate_too_large_area( - self, rect: LatLngRect, injection_id: str, mapping: TelemetryMapping + self, + rect: LatLngRect, + injection_id: str, + mapping: TelemetryMapping[DPObservedFlight], ): participant_id = mapping.injected_flight.uss_participant_id flights_url = mapping.observed_flight.query.flights_url diff --git a/monitoring/uss_qualifier/scenarios/astm/netrid/display_data_evaluator.py b/monitoring/uss_qualifier/scenarios/astm/netrid/display_data_evaluator.py index e864a0ed44..21dd7edd76 100644 --- a/monitoring/uss_qualifier/scenarios/astm/netrid/display_data_evaluator.py +++ b/monitoring/uss_qualifier/scenarios/astm/netrid/display_data_evaluator.py @@ -1,5 +1,6 @@ import datetime import math +from collections.abc import Sequence from dataclasses import dataclass, field import arrow @@ -22,6 +23,7 @@ Position, all_flights, ) +from monitoring.monitorlib.fetch.rid import Flight as SPFlight from monitoring.monitorlib.rid import RIDVersion from monitoring.monitorlib.temporal import Time from monitoring.uss_qualifier.configurations.configuration import ParticipantID @@ -74,7 +76,7 @@ def most_recent_position(self) -> Position | None: return self.query.flights[self.flight_index].most_recent_position @property - def flight(self) -> fetch.rid.Flight: + def flight(self) -> SPFlight: return self.query.flights[self.flight_index] # TODO we may rather want to expose the whole flight object (self.query.flights[self.flight]) @@ -96,10 +98,10 @@ def flights_url(self) -> str: @dataclass -class TelemetryMapping: +class TelemetryMapping[ObservationT: ObservationType]: injected_flight: InjectedFlight telemetry_index: int - observed_flight: ObservationType + observed_flight: ObservationT @dataclass @@ -113,10 +115,10 @@ class FetchedToInjectedCache: """Unmapped is the list of flights or flight details we didn't attributed yet""" -def map_observations_to_injected_flights( +def map_observations_to_injected_flights[ObservationT: ObservationType]( injected_flights: list[InjectedFlight], - observed_flights: list[ObservationType], -) -> dict[str, TelemetryMapping]: + observed_flights: Sequence[ObservationT], +) -> dict[str, TelemetryMapping[ObservationT]]: """Identify which of the observed flights (if any) matches to each of the injected flights This function assumes there is no valid situation in which a particular observed flight could be one of multiple @@ -129,7 +131,7 @@ def map_observations_to_injected_flights( Returns: Mapping between InjectedFlight and observed Flight, indexed by injection_id. """ - mapping: dict[str, TelemetryMapping] = {} + mapping: dict[str, TelemetryMapping[ObservationT]] = {} for injected_flight in injected_flights: smallest_distance = 1e9 best_match = None @@ -194,7 +196,7 @@ def check_fetched_flights( summary="Could not query ISAs from DSS", details=f"Query to {dss_participant_id}'s DSS at failed: {', '.join(fetched_flights.dss_isa_query.errors)}", query_timestamps=[ - fetched_flights.dss_isa_query.request.initiated_at.datetime + fetched_flights.dss_isa_query.query.request.timestamp ], ) @@ -235,7 +237,7 @@ def map_fetched_to_injected_flights( fetched_flights: list[FetchedUSSFlights], fetched_flights_details: list[FetchedUSSFlightDetails], query_cache: FetchedToInjectedCache, -) -> dict[str, TelemetryMapping]: +) -> dict[str, TelemetryMapping[DPObservedFlight]]: """Identify which of the fetched flights (if any) matches to each of the injected flights. See `map_observations_to_injected_flights`. @@ -518,7 +520,7 @@ def _evaluate_normal_observation( details=( f"{mapping.injected_flight.uss_participant_id}'s flight with injection ID " f"{mapping.injected_flight.flight.injection_id} in test {mapping.injected_flight.test_id} had telemetry index {mapping.telemetry_index} at {injected_telemetry.timestamp} " - f"with extrapolated position state, but Service Provider reported non-extrapolated telemetry at {mapping.observed_flight.query.query.request.initiated_at}. " + f"without extrapolated position state, but the observation reported extrapolated telemetry at {query.request.timestamp}. " f"Extrapolation State: Injected={injected_telemetry_extrapolated}, Observed={observed_telemetry_extrapolated}" ), ) @@ -816,7 +818,7 @@ def _evaluate_sp_observation( self, requested_area: s2sphere.LatLngRect, sp_observation: FetchedFlights, - mappings: dict[str, TelemetryMapping], + mappings: dict[str, TelemetryMapping[DPObservedFlight]], ) -> None: # Note: This step currently uses the DSS endpoint to perform a one-time query for ISAs, but this # endpoint is not strictly required. The PUT Subscription endpoint, followed immediately by the @@ -842,7 +844,7 @@ def _evaluate_normal_sp_observation( self, requested_area: s2sphere.LatLngRect, sp_observation: FetchedFlights, - mappings: dict[str, TelemetryMapping], + mappings: dict[str, TelemetryMapping[DPObservedFlight]], ) -> None: _evaluate_flight_presence( "uss_qualifier, acting as Display Provider", @@ -973,7 +975,7 @@ def _evaluate_normal_sp_observation( def _evaluate_area_too_large_sp_observation( self, - mappings: dict[str, TelemetryMapping], + mappings: dict[str, TelemetryMapping[DPObservedFlight]], rect: s2sphere.LatLngRect, diagonal: float, ) -> None: @@ -992,7 +994,10 @@ def _evaluate_area_too_large_sp_observation( ) def _evaluate_sp_flight_recent_positions_times( - self, f: Flight, query_time: datetime.datetime, participant: ParticipantID + self, + f: SPFlight, + query_time: datetime.datetime, + participant: ParticipantID, ): with self._test_scenario.check( "Recent positions timestamps", participant @@ -1008,7 +1013,10 @@ def _evaluate_sp_flight_recent_positions_times( ) def _evaluate_sp_flight_recent_positions_crossing_area_boundary( - self, requested_area: s2sphere.LatLngRect, f: Flight, participant: ParticipantID + self, + requested_area: s2sphere.LatLngRect, + f: SPFlight, + participant: ParticipantID, ): with self._test_scenario.check( "Recent positions for aircraft crossing the requested area boundary show only one position before or after crossing", @@ -1154,7 +1162,7 @@ def evaluate_disconnected_flights( def _evaluate_sp_observation( self, sp_observation: FetchedFlights, - mappings: dict[str, TelemetryMapping], + mappings: dict[str, TelemetryMapping[DPObservedFlight]], ) -> None: # Note: This step currently uses the DSS endpoint to perform a one-time query for ISAs, but this # endpoint is not strictly required. The PUT Subscription endpoint, followed immediately by the @@ -1167,7 +1175,7 @@ def _evaluate_sp_observation( def _evaluate_sp_observation_of_disconnected_flights( self, sp_observation: FetchedFlights, - mappings: dict[str, TelemetryMapping], + mappings: dict[str, TelemetryMapping[DPObservedFlight]], ) -> None: # TODO we will want to reuse the code in RIDObservationEvaluator rather than copy-pasting it _evaluate_flight_presence( @@ -1214,11 +1222,17 @@ def _evaluate_sp_observation_of_disconnected_flights( continue if expected_flight.flight.injection_id in mappings: - observed_telemetry_timestamp = Time( - mappings[ - expected_flight.flight.injection_id - ].observed_flight.flight.timestamp.datetime - ) + observed_timestamp = mappings[ + expected_flight.flight.injection_id + ].observed_flight.flight.timestamp + if observed_timestamp is None: + check.record_failed( + summary="Observed flight has no telemetry timestamp", + details="The Service Provider returned the disconnected flight without a current-state timestamp.", + query_timestamps=query_timestamps, + ) + continue + observed_telemetry_timestamp = Time(observed_timestamp.datetime) # We don't expect the SP's systems to update the injected timestamp value, # but accept that it might do some reasonable roundings. @@ -1313,7 +1327,7 @@ def _retrieve_notifications(self): ) -def _chronological_positions(f: Flight) -> list[s2sphere.LatLng]: +def _chronological_positions(f: SPFlight) -> list[s2sphere.LatLng]: """ Returns the recent positions of the flight, ordered by time with the oldest first, and the most recent last. """ @@ -1330,11 +1344,11 @@ def _sliding_triples(points: list[s2sphere.LatLng]) -> list[list[s2sphere.LatLng return [[points[i], points[i + 1], points[i + 2]] for i in range(len(points) - 2)] -def _evaluate_flight_presence( +def _evaluate_flight_presence[ObservationT: ObservationType]( observer_participant_id: str, observation_queries: list[Query], observer_participant_is_relevant: bool, - mapping_by_injection_id: dict[str, TelemetryMapping], + mapping_by_injection_id: dict[str, TelemetryMapping[ObservationT]], verified_sps: set[str], test_scenario: TestScenario, injected_flights: list[InjectedFlight], diff --git a/monitoring/uss_qualifier/scenarios/astm/netrid/display_data_evaluator_test.py b/monitoring/uss_qualifier/scenarios/astm/netrid/display_data_evaluator_test.py index 2e92a25436..4a66164fb6 100644 --- a/monitoring/uss_qualifier/scenarios/astm/netrid/display_data_evaluator_test.py +++ b/monitoring/uss_qualifier/scenarios/astm/netrid/display_data_evaluator_test.py @@ -1,8 +1,12 @@ from datetime import UTC, datetime +from unittest.mock import MagicMock import s2sphere +from implicitdict import StringBasedDateTime +from uas_standards.interuss.automated_testing.rid.v1 import injection, observation -from monitoring.monitorlib.fetch.rid import Flight +from monitoring.monitorlib.fetch import Query, RequestDescription, ResponseDescription +from monitoring.monitorlib.fetch.rid import FetchedFlights, FetchedISAs, Flight from monitoring.monitorlib.rid import RIDVersion from monitoring.uss_qualifier.resources.netrid.evaluation import EvaluationConfiguration from monitoring.uss_qualifier.scenarios.astm.netrid.common_dictionary_evaluator_test import ( @@ -11,8 +15,101 @@ ) from monitoring.uss_qualifier.scenarios.astm.netrid.display_data_evaluator import ( RIDObservationEvaluator, + check_fetched_flights, ) +from monitoring.uss_qualifier.scenarios.astm.netrid.injection import InjectedFlight from monitoring.uss_qualifier.scenarios.interuss.unit_test import UnitTestScenario +from monitoring.uss_qualifier.scenarios.scenario import GenericTestScenario + + +# This test is AI-generated and has not been closely inspected by a human. +def test_failed_dss_search_records_query_timestamp(): + now = datetime(2026, 10, 7, tzinfo=UTC) + query = Query( + request=RequestDescription( + method="GET", + url="https://dss.example/isas", + initiated_at=StringBasedDateTime(now), + ), + response=ResponseDescription( + code=500, reported=StringBasedDateTime(now), elapsed_s=0 + ), + ) + fetched = FetchedFlights( + dss_isa_query=FetchedISAs(v22a_query=query), + uss_flight_queries={}, + uss_flight_details_queries={}, + ) + scenario = MagicMock(spec=GenericTestScenario) + + check_fetched_flights(fetched, scenario, "dss", False) + + check = scenario.check.return_value.__enter__.return_value + check.record_failed.assert_called_once() + assert check.record_failed.call_args.kwargs["query_timestamps"] == [now] + + +# This test is AI-generated and has not been closely inspected by a human. +def test_dp_extrapolation_failure_uses_observation_query(): + now = datetime(2026, 10, 7, tzinfo=UTC) + injected = InjectedFlight( + uss_participant_id="sp", + test_id="test-id", + query_timestamp=now, + query_duration_s=0, + flight=injection.TestFlight( + injection_id="injection-id", + telemetry=[ + injection.RIDAircraftState( + timestamp=StringBasedDateTime(now), + position=injection.RIDAircraftPosition(lat=1, lng=1, alt=100), + ) + ], + details_responses=[], + ), + ) + observed_position = observation.Position(lat=1, lng=1, alt=100) + # Constructors discard undeclared fields; explicitly add unexpected response data + # to exercise the extrapolation failure reporting branch. + observed_position["extrapolated"] = True + observed = observation.Flight( + id="observed-id", most_recent_position=observed_position + ) + query = Query( + request=RequestDescription( + method="GET", + url="https://observer.example/display_data", + initiated_at=StringBasedDateTime(now), + ), + response=ResponseDescription( + code=200, reported=StringBasedDateTime(now), elapsed_s=0 + ), + ) + scenario = MagicMock(spec=UnitTestScenario) + observer = MagicMock(participant_id="dp", base_url="https://observer.example") + evaluator = RIDObservationEvaluator( + config=EvaluationConfiguration(), + test_scenario=scenario, + rid_version=RIDVersion.f3411_22a, + injected_flights=[injected], + ) + evaluator._common_dictionary_evaluator = MagicMock() + evaluator._retrieved_flight_details[observer.base_url] = {observed.id} + + evaluator._evaluate_normal_observation( + observer, observation.GetDisplayDataResponse(flights=[observed]), query, {"sp"} + ) + + check = scenario.check.return_value.__enter__.return_value + failure = next( + call + for call in check.record_failed.call_args_list + if call.args + and call.args[0] + == "Position Data is using extrapolation when Telemetry is available." + ) + assert str(now) in failure.kwargs["details"] + assert "observation reported extrapolated telemetry" in failure.kwargs["details"] def _assert_evaluate_sp_flight_recent_positions( diff --git a/monitoring/uss_qualifier/scenarios/astm/utm/dss/fragments/sub/crud/__init__.py b/monitoring/uss_qualifier/scenarios/astm/utm/dss/fragments/sub/crud/__init__.py index f9a0c262aa..fa418826e0 100644 --- a/monitoring/uss_qualifier/scenarios/astm/utm/dss/fragments/sub/crud/__init__.py +++ b/monitoring/uss_qualifier/scenarios/astm/utm/dss/fragments/sub/crud/__init__.py @@ -1,10 +1,13 @@ from uas_standards.astm.f3548.v21.api import OperationalIntentReference, Subscription -from monitoring.monitorlib.fetch.rid import FetchedSubscription +from monitoring.monitorlib.fetch.scd import FetchedSubscription from monitoring.monitorlib.mutate.scd import MutatedSubscription from monitoring.monitorlib.subscription_params import SubscriptionParams from monitoring.uss_qualifier.resources.astm.f3548.v21.dss import DSSInstance -from monitoring.uss_qualifier.scenarios.scenario import TestScenarioType +from monitoring.uss_qualifier.scenarios.scenario import ( + ScenarioCannotContinueError, + TestScenarioType, +) # TODO: add functions implementing checks documented in this package @@ -40,15 +43,18 @@ def sub_get_query( with scenario.check("Get Subscription by ID", dss.participant_id) as check: fetched_sub = dss.get_subscription(sub_id) scenario.record_query(fetched_sub) - if not fetched_sub.success: + subscription = fetched_sub.subscription + if not fetched_sub.success or subscription is None: check.record_failed( summary="Subscription query failed", - details=f"Failed to query subscription {sub_id} referenced by oid {scenario._oir_a_id} with code {fetched_sub.response.status_code}. Message: {fetched_sub.error_message}", - query_timestamps=fetched_sub.query_timestamps, + details=f"Failed to query subscription {sub_id} with code {fetched_sub.status_code}: {', '.join(fetched_sub.errors)}", + query_timestamps=[fetched_sub.request.timestamp], + ) + raise ScenarioCannotContinueError( + "Cannot continue without the requested subscription" ) - return None - return fetched_sub.subscription, fetched_sub + return subscription, fetched_sub def sub_delete_query( diff --git a/monitoring/uss_qualifier/scenarios/astm/utm/dss/oir_implicit_sub_handling.py b/monitoring/uss_qualifier/scenarios/astm/utm/dss/oir_implicit_sub_handling.py index 2792db62df..54d83ea026 100644 --- a/monitoring/uss_qualifier/scenarios/astm/utm/dss/oir_implicit_sub_handling.py +++ b/monitoring/uss_qualifier/scenarios/astm/utm/dss/oir_implicit_sub_handling.py @@ -1064,7 +1064,9 @@ def check_sub_changed(oir, mutate_q): check_sub_changed(oir_queried, q) # Now fetch the subscription and check it is implicit - fetched_sub, _ = sub_get_query(self, self._dss, oir_queried.subscription_id) + fetched_sub, sub_query = sub_get_query( + self, self._dss, oir_queried.subscription_id + ) with self.check( "OIR is now attached to an implicit subscription", self._pid @@ -1073,7 +1075,7 @@ def check_sub_changed(oir, mutate_q): check.record_failed( summary="OIR is not attached to an implicit subscription", details=f"Subscription {self._sub_id} referenced by OIR {self._oir_a_id} is not an implicit subscription.", - query_timestamps=[fetched_sub.request.timestamp], + query_timestamps=[sub_query.request.timestamp], ) self.end_test_step() @@ -1139,7 +1141,9 @@ def check_sub_not_none(oir): check_sub_not_none(oir_queried) # Now fetch the subscription and check it is implicit - fetched_sub, _ = sub_get_query(self, self._dss, oir_queried.subscription_id) + fetched_sub, sub_query = sub_get_query( + self, self._dss, oir_queried.subscription_id + ) with self.check( "OIR is now attached to an implicit subscription", self._pid @@ -1148,7 +1152,7 @@ def check_sub_not_none(oir): check.record_failed( summary="OIR is not attached to an implicit subscription", details=f"Subscription {self._sub_id} referenced by OIR {self._oir_a_id} is not an implicit subscription.", - query_timestamps=[fetched_sub.request.timestamp], + query_timestamps=[sub_query.request.timestamp], ) self.end_test_step() diff --git a/monitoring/uss_qualifier/scenarios/astm/utm/dss/subscription_validation.py b/monitoring/uss_qualifier/scenarios/astm/utm/dss/subscription_validation.py index ead8c75b55..f2aa0d74ca 100644 --- a/monitoring/uss_qualifier/scenarios/astm/utm/dss/subscription_validation.py +++ b/monitoring/uss_qualifier/scenarios/astm/utm/dss/subscription_validation.py @@ -193,9 +193,29 @@ def _create_too_long_subscription(self): def _check_properly_truncated( self, check: PendingCheck, changed: MutatedSubscription ): - duration = changed.subscription.duration + subscription = changed.subscription + if subscription is None: + check.record_failed( + summary="DSS failed to reject or truncate subscription that exceeded 24 hours", + details=f"DSS returned status {changed.status_code} without a valid subscription: {changed.errors}", + query_timestamps=[changed.request.timestamp], + ) + return + + duration = ( + subscription.time_end.value.datetime + - subscription.time_start.value.datetime + if "time_start" in subscription + and subscription.time_start is not None + and "time_end" in subscription + and subscription.time_end is not None + else None + ) # In case of success, we obtained the effectively created subscription: - if _24H_MIN_TOLERANCE_S < duration.total_seconds() < _24H_MAX_TOLERANCE_S: + if ( + duration is not None + and _24H_MIN_TOLERANCE_S < duration.total_seconds() < _24H_MAX_TOLERANCE_S + ): # All is good pass else: @@ -204,12 +224,12 @@ def _check_properly_truncated( details=f"{self._dss.participant_id} DSS instance has returned a non-properly truncated subscription " f"(duration: {duration}) " f"when the expectation was either to fail or to truncate at 24 hours.", - query_timestamps=[changed.query.request.timestamp], + query_timestamps=[changed.request.timestamp], ) # If a subscription was created, we want to delete it before continuing: self.record_query( self._dss.delete_subscription( - sub_id=self._sub_id, sub_version=changed.subscription.version + sub_id=self._sub_id, sub_version=subscription.version ) ) diff --git a/monitoring/uss_qualifier/scenarios/astm/utm/dss/subscription_validation_test.py b/monitoring/uss_qualifier/scenarios/astm/utm/dss/subscription_validation_test.py new file mode 100644 index 0000000000..dfed65805e --- /dev/null +++ b/monitoring/uss_qualifier/scenarios/astm/utm/dss/subscription_validation_test.py @@ -0,0 +1,131 @@ +from datetime import UTC, datetime, timedelta +from unittest.mock import MagicMock + +import pytest +from implicitdict import StringBasedDateTime + +from monitoring.monitorlib.fetch import RequestDescription, ResponseDescription +from monitoring.monitorlib.fetch.scd import FetchedSubscription +from monitoring.monitorlib.mutate.scd import MutatedSubscription +from monitoring.uss_qualifier.scenarios.astm.utm.dss.fragments.sub.crud import ( + sub_get_query, +) +from monitoring.uss_qualifier.scenarios.astm.utm.dss.subscription_validation import ( + SubscriptionValidation, +) +from monitoring.uss_qualifier.scenarios.scenario import ( + PendingCheck, + ScenarioCannotContinueError, +) + + +@pytest.mark.parametrize( + "duration,has_subscription,should_fail,should_delete", + [ + (timedelta(hours=24), True, False, False), + (timedelta(hours=24, minutes=10), True, True, True), + (timedelta(hours=23), True, True, True), + (None, True, True, True), + (None, False, True, False), + ], +) +# This test is AI-generated and has not been closely inspected by a human. +def test_truncated_subscription_response( + duration: timedelta | None, + has_subscription: bool, + should_fail: bool, + should_delete: bool, +): + now = datetime(2026, 10, 7, tzinfo=UTC) + response_json = {} + if has_subscription: + subscription = { + "id": "subscription-id", + "version": "version-1", + "notification_index": 0, + "uss_base_url": "https://uss.example", + } + if duration is not None: + subscription["time_start"] = { + "format": "RFC3339", + "value": StringBasedDateTime(now), + } + subscription["time_end"] = { + "format": "RFC3339", + "value": StringBasedDateTime(now + duration), + } + response_json["subscription"] = subscription + changed = MutatedSubscription( + request=RequestDescription( + method="PUT", + url="https://dss.example/subscriptions/subscription-id", + initiated_at=StringBasedDateTime(now), + ), + response=ResponseDescription( + code=200, + json=response_json, + reported=StringBasedDateTime(now), + elapsed_s=0, + ), + ) + scenario = MagicMock(spec=SubscriptionValidation) + scenario._sub_id = "subscription-id" + scenario._dss = MagicMock(participant_id="dss") + check = MagicMock(spec=PendingCheck) + + SubscriptionValidation._check_properly_truncated(scenario, check, changed) + + assert check.record_failed.called == should_fail + if should_fail: + assert check.record_failed.call_args.kwargs["query_timestamps"] == [now] + assert scenario._dss.delete_subscription.called == should_delete + if should_delete: + scenario._dss.delete_subscription.assert_called_once_with( + sub_id="subscription-id", sub_version="version-1" + ) + + +@pytest.mark.parametrize( + "status_code,valid_subscription", [(200, True), (200, False), (404, False)] +) +# This test is AI-generated and has not been closely inspected by a human. +def test_get_subscription_preserves_query_or_stops( + status_code: int, valid_subscription: bool +): + now = datetime(2026, 10, 7, tzinfo=UTC) + response_json = {} + if valid_subscription: + response_json["subscription"] = { + "id": "subscription-id", + "version": "version-1", + "notification_index": 0, + "uss_base_url": "https://uss.example", + } + fetched = FetchedSubscription( + request=RequestDescription( + method="GET", + url="https://dss.example/subscriptions/subscription-id", + initiated_at=StringBasedDateTime(now), + ), + response=ResponseDescription( + code=status_code, + json=response_json, + reported=StringBasedDateTime(now), + elapsed_s=0, + ), + ) + scenario = MagicMock() + dss = MagicMock(participant_id="dss") + dss.get_subscription.return_value = fetched + + if valid_subscription: + subscription, query = sub_get_query(scenario, dss, "subscription-id") + assert subscription.id == "subscription-id" + assert query is fetched + else: + with pytest.raises(ScenarioCannotContinueError): + sub_get_query(scenario, dss, "subscription-id") + check = scenario.check.return_value.__enter__.return_value + assert check.record_failed.call_args.kwargs["query_timestamps"] == [now] + + scenario.record_query.assert_called_once_with(fetched) From c2c79c552856e812f6b14a56d60716640cd9a40f Mon Sep 17 00:00:00 2001 From: Benjamin Pelletier Date: Thu, 8 Oct 2026 06:29:46 +0900 Subject: [PATCH 3/3] Remove now-unnecessary field presence checks --- .../scenarios/astm/utm/dss/subscription_validation.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/monitoring/uss_qualifier/scenarios/astm/utm/dss/subscription_validation.py b/monitoring/uss_qualifier/scenarios/astm/utm/dss/subscription_validation.py index f2aa0d74ca..1c3b2ce5b5 100644 --- a/monitoring/uss_qualifier/scenarios/astm/utm/dss/subscription_validation.py +++ b/monitoring/uss_qualifier/scenarios/astm/utm/dss/subscription_validation.py @@ -205,10 +205,7 @@ def _check_properly_truncated( duration = ( subscription.time_end.value.datetime - subscription.time_start.value.datetime - if "time_start" in subscription - and subscription.time_start is not None - and "time_end" in subscription - and subscription.time_end is not None + if subscription.time_start is not None and subscription.time_end is not None else None ) # In case of success, we obtained the effectively created subscription: