Harmonizing Flight Hours, Cycles, and Landings Across Sources

Utilization counters arrive from OEM downloads, operator technical logs, and CAMO exports in units that rarely agree, and a component life limit expressed in flight cycles is meaningless if one feed reports landings while another reports pressurization cycles. This page reconciles inconsistent time-in-service figures — flight hours, flight cycles, landings, and APU hours — from multiple feeds into a single monotonic utilization timeline. It is a focused problem inside Data Normalization Across OEM Formats, and its output underpins the continuing-airworthiness record required by EASA Part-M M.A.305.

Prerequisites & inputs

Every utilization sample is a snapshot taken at a moment in time: airframe flight hours, airframe flight cycles, and optionally landings and APU hours, each tagged with the source that reported it and an ISO 8601 timestamp. The core difficulty is that these feeds are neither synchronized nor internally consistent. An operator’s daily technical log may lag an OEM download by hours; a maintenance-check export may round flight hours to whole numbers where the flight-data feed carries tenths; and different sources disagree on whether a touch-and-go increments a cycle. A monotonic timeline must therefore order samples by time, reject any that regress a counter, and preserve provenance for each accepted point.

The scenario in this page follows US-registered N512JB under ATA 49-61 (APU control), reconciling three feeds: an avionics download reading 18422.6 FH / 9377 FC, an operator log reading 18422 FH / 9377 FC recorded ninety minutes later, and an out-of-order maintenance export reading 18410.0 FH. The harmonized timeline these feeds produce is the same series a resolver would key part removals against — the identity work covered in Mapping Airbus vs Boeing Part-Number Schemes in Python — so the two normalizers must agree on time.

Utilization unit harmonization

Utilization unit harmonizationMixedunitsUnitresolverMonotoniccheckCanonicalTIS

Mixed-unit samples pass through a resolver that fixes units and timezone, then a monotonic check that discards any sample regressing a counter, leaving a canonical time-in-service series fit for life-limit tracking.

Step-by-step implementation

  1. Model each sample strictly. Enforce non-negative counters and coerce every timestamp to UTC at ingestion; a naive local timestamp is the most common cause of mis-ordering.
  2. Order by observation time. Sort samples on as_of so the timeline reflects when the aircraft actually accrued the utilization, not when the feed happened to publish.
  3. Reject regressions. A later sample whose flight hours or cycles fall below the running maximum is either a stale export or a data error; log and drop it rather than letting a counter move backward.
  4. Emit canonical points. Each accepted sample becomes a CanonicalTIS entry retaining its source, so the airworthiness record can trace every figure to a feed.
from __future__ import annotations

import logging
from datetime import datetime, timezone
from typing import Any, Optional

from pydantic import BaseModel, ConfigDict, Field, field_validator

logging.basicConfig(level=logging.INFO, format="%(asctime)s | %(levelname)s | %(message)s")
logger = logging.getLogger("mro.utilization")


class UtilizationSample(BaseModel):
    """One time-in-service snapshot from a single operator or OEM feed."""

    model_config = ConfigDict(strict=True, extra="forbid")

    source: str = Field(min_length=2, max_length=48)
    as_of: datetime
    airframe_fh: float = Field(ge=0.0)   # flight hours
    airframe_fc: int = Field(ge=0)       # flight cycles
    landings: Optional[int] = Field(default=None, ge=0)
    apu_hours: Optional[float] = Field(default=None, ge=0.0)

    @field_validator("as_of", mode="before")
    @classmethod
    def enforce_utc(cls, v: Any) -> datetime:
        # EASA Part-M M.A.305 requires the continuing-airworthiness record to
        # reflect actual accrued time; a consistent UTC basis makes the
        # cross-feed timeline reconstructable.
        if isinstance(v, str):
            return datetime.fromisoformat(v.replace("Z", "+00:00")).astimezone(timezone.utc)
        if isinstance(v, datetime):
            return v.astimezone(timezone.utc)
        raise ValueError("as_of must be an ISO 8601 timestamp")


class CanonicalTIS(BaseModel):
    model_config = ConfigDict(extra="forbid")

    as_of: datetime
    fh: float
    fc: int
    apu_hours: Optional[float]
    source: str


class UtilizationHarmonizer:
    """Fold mixed-unit samples into one monotonic time-in-service timeline."""

    def __init__(self) -> None:
        self._timeline: list[CanonicalTIS] = []

    def ingest(self, samples: list[UtilizationSample]) -> list[CanonicalTIS]:
        ordered = sorted(samples, key=lambda s: s.as_of)
        last_fh, last_fc = 0.0, 0
        timeline: list[CanonicalTIS] = []
        for s in ordered:
            if s.airframe_fh < last_fh or s.airframe_fc < last_fc:
                logger.error(
                    "non-monotonic sample dropped source=%s as_of=%s fh=%.1f fc=%d",
                    s.source, s.as_of.isoformat(), s.airframe_fh, s.airframe_fc,
                )
                continue
            timeline.append(
                CanonicalTIS(
                    as_of=s.as_of,
                    fh=s.airframe_fh,
                    fc=s.airframe_fc,
                    apu_hours=s.apu_hours,
                    source=s.source,
                )
            )
            last_fh, last_fc = s.airframe_fh, s.airframe_fc
        self._timeline = timeline
        logger.info("harmonized %d of %d samples", len(timeline), len(samples))
        return timeline

Worked example

Running the three N512JB feeds through the harmonizer:

samples = [
    UtilizationSample(source="avionics_dl", as_of="2026-07-14T09:12:00Z", airframe_fh=18422.6, airframe_fc=9377, apu_hours=6321.4),
    UtilizationSample(source="tech_log",    as_of="2026-07-14T10:42:00Z", airframe_fh=18422.0, airframe_fc=9377),
    UtilizationSample(source="mx_export",   as_of="2026-07-14T08:00:00Z", airframe_fh=18410.0, airframe_fc=9375),
]
timeline = UtilizationHarmonizer().ingest(samples)

After sorting on as_of, the earliest point is the 08:00Z maintenance export at 18410.0 FH, accepted as the baseline. The 09:12Z avionics download at 18422.6 FH is monotonic and accepted. The 10:42Z technical-log sample reports 18422.0 FH — lower than the running maximum of 18422.6 because that feed rounds down — so it is dropped with a logged non-monotonic sample dropped source=tech_log and does not corrupt the series. The resulting timeline holds two canonical points, each traceable to its feed, and the APU-hours figure survives only where a source actually reported it rather than being fabricated for the log-only sample.

Edge cases & compliance gotchas

  • Cycles are not always landings. For many transport types one flight cycle equals one pressurization cycle, but touch-and-go training and go-arounds break the one-to-one landing relationship; never substitute a landings count for a cycles count when a life limit is expressed in cycles.
  • Rounding drift between feeds. A feed that truncates flight hours to whole numbers will periodically read lower than a tenths-precision feed. Treat sub-unit regressions as rounding, not accrual, and prefer the higher-precision source rather than dropping the point outright in production.
  • Timezone-naive technical logs. Operator logs recorded in local time without an offset will sort incorrectly against UTC OEM feeds; reject naive timestamps at ingestion rather than guessing an offset.
  • APU hours accrue independently. APU hours track ground and in-flight auxiliary-power use and are decoupled from airframe hours; harmonize them on their own monotonic axis, never by scaling from flight hours.
  • Interrupted feeds and back-fill. When a feed goes silent for a period and then back-fills historical figures, those late-arriving samples carry an earlier as_of than points already committed; because the harmonizer sorts on observation time, re-running the full sample set — rather than appending incrementally — is the only way to reconstruct a correct series without leaving a gap in the record.

A monotonic, provenance-preserving timeline is the foundation every downstream life-limit and airworthiness-review calculation stands on. By coercing units and timezones at ingestion, ordering strictly by observation time, and refusing to let any counter move backward, the pipeline turns a stream of disagreeing feeds into a single utilization history a CAMO can defend line-by-line. That defensible timeline is exactly what the broader EASA Part-M Compliance Mapping relies on when it ties each life-limited component and airworthiness review back to accrued time in service under M.A.305.