A log contains a fixed observation followed by ground. A simple state-machine checker reports an invalid transition. Then somebody notices that the sequence number jumped from 17 to 24. The software has diagnosed the path between two observations without having that path in its input.
This is a common failure in telemetry tooling: a correct transition table is applied to the wrong pair of records. Missing observations, repeated delivery, restarts and mixed producers can all change what adjacency means. Adding more states does not repair that mistake. The observer needs a separate model of continuity before it can evaluate a state change.
This tutorial builds a small offline Python observer for that problem. It uses a fixed-wing VTOL aircraft as its application context, defines a deliberately explicit CSV contract, and produces findings that retain the distinction between unexplained transitions and missing information. You can adapt the pattern to log importers, maintenance dashboards or integration test tools without connecting it to aircraft control.
Why the UG25 is a useful context
The current UG25 fixed-wing VTOL product page describes vertical takeoff and transition into fixed-wing cruise. That combination gives a developer a concrete reason to distinguish vertical-lift operation, transition and fixed-wing operation in a diagnostic view.
The listing does not establish the telemetry contract used below. The field names, phase labels and transition graph are tutorial definitions for a downstream adapter. Obtain the supported interface documentation and sample logs for an actual delivered configuration before mapping its reported values. A model name or product photograph cannot identify the installed controller, firmware revision or available logging fields.
Check continuity before checking a transition
There are two different questions. First, are these records consecutive observations from the same defined stream? Second, if they are, does their label change belong to the selected analysis graph? The first question determines whether the second is meaningful.
In this example, continuity requires the same vehicle, producer and boot identifier; consecutive sequence numbers; and a strictly increasing source time whose interval does not exceed a configured threshold. These are application rules, not assumptions about every autopilot log. An adapter must document how it produces them.
A sequence gap creates a boundary even when the timestamps are close together. A time gap creates a boundary even when the sequence numbers are consecutive. Both matter: a process can pause while preserving its counter, and a network path can lose records without introducing a long delay between the records that survive.
Consider fixed at sequence 17 and ground at sequence 24. The observer records a gap and starts its next comparison from the later observation. It does not fabricate intermediate labels, and it does not add an unexplained edge for that pair. By contrast, the same label change between consecutive observations within the allowed interval becomes a finding about the selected data contract.
Neither result identifies the cause of an aircraft event. An unexplained edge could come from an adapter mapping error, a changed firmware vocabulary, an incomplete graph or a real sequence requiring investigation. The report points to the records that need review while leaving those explanations open.
Define the stream rather than borrowing a transport counter
The CSV has six columns in a fixed order: vehicle, producer, boot, seq, t_us and phase. Its sequence number belongs to this normalized observation stream. It is not an arbitrary packet counter copied from a multiplexed transport.
That distinction prevents a subtle false-positive factory. A transport may increment its counter for many message types. Filtering that traffic to phase observations can produce apparent gaps during normal delivery. The adapter must either expose a sequence with the appropriate scope or provide separate continuity information. Renaming a transport field does not change what it counts.
The boot identifier separates source clock epochs. A producer identifier separates independent emitters, including an original decoder and a replay process. A vehicle identifier separates aircraft. The implementation reviews each combination independently, so equal sequence values from different streams do not collide.
Source time is an unsigned integer in microseconds inside that boot epoch. This observer does not require UTC and does not join clocks across aircraft or restarts. If a system also needs an incident timeline, preserve wall time and synchronization quality alongside this contract. Do not replace the source clock with a cloud arrival timestamp and assume the ordering properties remain unchanged.
Make the parser reject ambiguity at the boundary
CSV parsing deserves a precise contract even when the analysis is small. The Python CSV documentation describes how readers handle tabular fields and quoting. This example uses the standard parser, then performs its own header, width, identity, numeric and label checks.
The fixed header rejects duplicated or reordered columns. Row-width validation catches missing and extra fields. Identity values use a small ASCII alphabet rather than accepting embedded whitespace or control characters. Numeric values use decimal digits with an explicit range, so a floating-point string, a negative counter or a scientific-notation value cannot pass through accidental coercion.
An unknown source value and an explicitly unavailable observation are different. The supported label unknown tells the observer that classification is unavailable. A label outside the mapping vocabulary is rejected because its meaning has not been established. Silently converting every unexpected token into a normal phase would hide an integration defect.
Save the following as phase_observer.py. It runs with Python 3.9 or later, uses only the standard library, and performs no network or aircraft operations. Its embedded records are synthetic examples, not a report of a UG25 flight.
"""Offline tutorial contract; no aircraft protocol or control commands.""" import csv import io from dataclasses import dataclass HEADER = ("vehicle", "producer", "boot", "seq", "t_us", "phase") PHASES = {"ground", "vertical", "to_fixed", "fixed", "to_vertical", "unknown"} EDGES = { ("ground", "vertical"), ("vertical", "ground"), ("vertical", "to_fixed"), ("to_fixed", "fixed"), ("to_fixed", "vertical"), ("fixed", "to_vertical"), ("to_vertical", "vertical"), ("to_vertical", "fixed"), } @dataclass(frozen=True) class Event: vehicle: str producer: str boot: str seq: int t_us: int phase: str def unsigned(token): if not token or len(token) > 19 or not token.isascii() or not token.isdecimal(): raise ValueError("Expected an ASCII unsigned integer") value = int(token) if value >= 2**63: raise ValueError("Integer outside this example's range") return value def read_events(raw): if not isinstance(raw, bytes) or len(raw) > 1_000_000: raise ValueError("Expected at most one million UTF-8 bytes") rows = csv.reader(io.StringIO(raw.decode("utf-8"), newline=""), strict=True) try: if tuple(next(rows, ())) != HEADER: raise ValueError("Unexpected CSV header or column order") result = [] for row in rows: if len(result) >= 10_000 or len(row) != len(HEADER): raise ValueError("Row count or width outside contract") vehicle, producer, boot, seq, t_us, phase = row if any(not x or len(x) > 64 or not all( c.isascii() and (c.isalnum() or c in "-_.:") for c in x) for x in (vehicle, producer, boot)): raise ValueError("Invalid stream identity") if phase not in PHASES: raise ValueError("Unmapped phase label") result.append(Event(vehicle, producer, boot, unsigned(seq), unsigned(t_us), phase)) return result except csv.Error as exc: raise ValueError("Malformed CSV") from exc def review(events, max_gap_us=2_000_000): if type(max_gap_us) is not int or max_gap_us <= 0: raise ValueError("max_gap_us must be a positive integer") last, seen = {}, {} report = dict(observations=0, duplicates=0, checked_links=0, gap_links=0, unknown_links=0, unexplained_edges=[]) for event in events: # Events must come from read_events. stream = (event.vehicle, event.producer, event.boot) identity = (stream, event.seq) content = (event.t_us, event.phase) if identity in seen: if seen[identity] != content: raise ValueError("Conflicting duplicate event identity") report["duplicates"] += 1 continue previous = last.get(stream) if previous is not None: if event.seq <= previous.seq or event.t_us <= previous.t_us: raise ValueError("Sequence or clock moved backward") gap = event.seq != previous.seq + 1 or event.t_us - previous.t_us > max_gap_us if gap: report["gap_links"] += 1 elif "unknown" in (previous.phase, event.phase): report["unknown_links"] += 1 else: report["checked_links"] += 1 if event.phase != previous.phase and (previous.phase, event.phase) not in EDGES: report["unexplained_edges"].append(dict( stream=list(stream), from_seq=previous.seq, to_seq=event.seq, before=previous.phase, after=event.phase)) seen[identity] = content last[stream] = event report["observations"] += 1 report["streams"] = len(last) return report if __name__ == "__main__": import json synthetic = b"""vehicle,producer,boot,seq,t_us,phase demo-ug25,adapter-v1,boot-A,0,0,ground demo-ug25,adapter-v1,boot-A,1,100000,vertical demo-ug25,adapter-v1,boot-A,2,200000,to_fixed demo-ug25,adapter-v1,boot-A,3,300000,fixed demo-ug25,adapter-v1,boot-A,5,500000,vertical demo-ug25,adapter-v1,boot-A,5,500000,vertical demo-ug25,adapter-v1,boot-B,0,0,fixed demo-ug25,adapter-v1,boot-B,1,100000,ground """ print(json.dumps(review(read_events(synthetic)), indent=2)) Read the report as several dimensions of evidence
The synthetic example produces seven unique observations, one identical duplicate, four checked links, one gap link and two streams. Its unexplained edge is the consecutive fixed to ground pair in boot-B. The earlier jump in boot-A remains a gap, which is a different outcome.
Do not collapse that output into a single green indicator. A file with no unexplained edges might contain almost no checked links. An empty file with a valid header produces zero observations, not evidence that a mission followed the graph. Show the observation count, continuity coverage and findings together.
The report also keeps a stable locator for each finding: the stream identity and the two sequence numbers. A reviewer can retrieve the surrounding original records and compare the adapter output with the raw source. A line number alone is less useful after filtering, concatenation or a different export order.
The observer deliberately does not calculate phase duration. Summing the time between matching endpoint labels would introduce another assumption: that the label remained unchanged between samples. A duration estimator needs an explicit sampling model and treatment of boundaries, rather than quietly inheriting a state checker's output.
Handle repeated delivery without rewriting the past
An event identity is the stream plus its sequence number. The implementation stores the corresponding time and phase. An identical repeated identity increments the duplicate count and leaves the current comparison state untouched. This also works when an old, identical event arrives after later records have already been processed.
A repeated identity with different content raises an error. Choosing whichever version arrived last would rewrite the observation history and could change the transition findings. Choosing the first version without surfacing the conflict would conceal a producer defect. Preserve both conflicting source records outside the accepted stream for investigation.
An unseen lower sequence number is different again. This batch implementation rejects that ordering instead of sorting the file automatically. Sorting could hide a delivery problem or incorrectly reconstruct a source sequence. If reordering is part of the actual contract, implement a separate bounded reorder stage and retain its original receive order and decisions.
The seen dictionary retains identities for the bounded input batch. That design is suitable for the stated size limits, not an indefinite service. A streaming version needs a durable deduplication strategy or a justified retention horizon. After an identity falls outside that horizon, the service must not claim it can still detect every conflicting historical duplicate.
Keep restarts and unknown observations visible
Starting a new boot identifier opens a separate stream. The first record in that stream establishes an observation baseline. It is not compared with the final record of a previous boot, and it is not required to begin at ground. The file may start halfway through an operation or contain only a selected interval.
A counter reset alone is insufficient evidence of a reboot. Some counters wrap; some exports restart their numbering; some adapters introduce a new sequence during conversion. Obtain a supported source for the epoch identity or mark the boundary unresolved. Do not invent boot identifiers solely to make a reversed sequence pass validation.
An explicit unknown phase also prevents transition inference across that link. If fixed, unknown and ground occur consecutively, the observer records two unknown links and no direct fixed to ground edge. It never bridges over the unknown record as if the missing label were a harmless formatting detail.
These boundaries carry information. A dashboard can display them as intervals needing source review. A data export can preserve them as separate quality fields. An analytics job can exclude them from computations that require continuous classification while still retaining the underlying observations for other uses.
Test the distinctions that change the conclusion
The tests that matter are pairs of scenarios with similar labels but different continuity. A direct, consecutive fixed to ground change should produce a finding. The same labels separated by a sequence gap or long time interval should not produce that finding. Testing only the normal chain would miss the central behavior.
The following short checks exercise those differences. Run them beside the saved module. They supplement the embedded synthetic example rather than pretending that a successful example covers a complete integration.
from phase_observer import read_events, review def run(second_sequence=1, second_time=100): text = ( "vehicle,producer,boot,seq,t_us,phase\n" "demo,adapter,A,0,0,fixed\n" f"demo,adapter,A,{second_sequence},{second_time},ground\n" ) return review(read_events(text.encode("utf-8"))) assert len(run()["unexplained_edges"]) == 1 assert run(second_sequence=2)["gap_links"] == 1 assert run(second_sequence=2)["unexplained_edges"] == [] assert run(second_time=2_000_001)["gap_links"] == 1 assert run(second_time=2_000_001)["unexplained_edges"] == [] print("Continuity and transition checks passed") Additional tests should cover a conflicting duplicate, an identical late retry, a clock reversal, an epoch change and two producers interleaved in one file. Exercise malformed headers, unmapped labels and resource limits as well. These cases verify different failure boundaries; they are not interchangeable variations of one parser test.
Choose a threshold with an observable meaning
The default two-second threshold is a tutorial parameter. It is not a recommended UG25 logging rate, controller timeout or operational limit. Determine a threshold from the actual producer's documented cadence, buffering behavior and the resolution needed by the analysis.
A large threshold can join observations across a meaningful interruption. A small threshold can turn normal scheduling variation into gaps. Inspect interval distributions from representative source logs and explain the chosen rule. Keep that choice versioned with the decoder and graph so that two reports can be compared on the same basis.
Strictly increasing microsecond timestamps are another explicit rule. A source with lower timestamp resolution may legitimately emit distinct observations with equal time values. That source needs a different reviewed ordering contract; it should not be squeezed into this example by adding fictional microseconds.
For a report that spans multiple files, preserve their stream identities and source ordering semantics. Concatenating two exports can duplicate records or reverse portions of a stream. The resulting findings should identify an assembly problem instead of being presented as an aircraft phase anomaly.
Put the observer after a documented adapter
An implementation pipeline should preserve raw input, decode it under a named interface revision, normalize the relevant fields, and then run this kind of observation review. Keep the decoder version, mapping profile, continuity threshold and graph revision with the report. Reprocessing the same raw file under another mapping can legitimately produce different findings, and that change needs to remain explainable.
The example graph includes paths for abandoning a transition and returning to the previous regime. It illustrates why a graph should reflect the analysis contract rather than an idealized straight-line mission. For a delivered system, review supported labels and paths with the source documentation; remove or add edges only through a documented profile change.
Do not weaken a graph merely because one log generates many findings. Inspect whether the source label was mapped correctly, whether observations were filtered, whether a producer restarted and whether the graph covers the configuration being analyzed. A frequent unexplained edge can reveal a recurring adapter error just as readily as a missing graph rule.
The UG25 product listing provides the aircraft context for requesting that integration material. Ask for supported logging fields, timestamp semantics, counter scope, restart indication, mode vocabulary and representative files for the actual supplied configuration. A product's VTOL architecture does not answer those software questions by itself.
A useful log observer preserves the difference between what was observed, what can be compared and what remains unexplained. With those distinctions in the output, a state machine becomes a reproducible review tool instead of a mechanism for inventing the missing parts of a flight history.
Disclosure: UNITED UAV sells the aircraft used as this tutorial's product context. The CSV schema, phase graph and records are illustrative software definitions, not an OEM protocol or a company flight-test result. This article was prepared with AI assistance; the executable examples and rejection tests were run locally.




