Detecting drift between filed and as-built tower records

Two records claim to describe the same structure — the FCC Antenna Structure Registration filing for TWR-8842 under ASR 1290123, and the as-built survey a field crew produced last quarter — and they disagree on height by 34 feet. Neither record is fabricated: the filing is simply the last thing anyone told the regulator, and the survey is what a technician with a laser rangefinder actually measured on site. Regulatory Filing Synchronization establishes the sync loop that pulls a filing and checks it against the internal ledger on a schedule; this page is the deep dive on the comparison at the center of that loop — the field-by-field diff, the per-field tolerance that separates real drift from survey noise, the severity score that decides whether a discrepancy deserves a human’s attention, and the routing that puts material drift in front of a compliance analyst instead of letting it sit unread in a log.

Prerequisites & Context

You need Python 3.10 or later for the pattern-matching walrus expressions used below, plus two aligned inputs: a normalized filed record — the shape the parent subsystem produces from an FCC ASR pull — and an as-built record from a physical or photogrammetric survey, both keyed by the same fcc_asr_number and site_id. This page assumes those two records already exist in canonical form; the retrieval and normalization half of that pipeline is covered in full by the sibling walkthrough Syncing FCC ASR filings with an internal compliance ledger, which produces exactly the filed side of the comparison built here.

One regulatory detail shapes the severity model directly: FCC rules under 47 CFR Part 17 tie obstruction marking and lighting obligations to a 200-foot above-ground-level threshold, and the Zoning Rule Engine Design evaluates municipal height variances against the same filed figure this page checks for drift. A height discrepancy is not just a number that moved — if it crosses that 200-foot line in either direction, it changes whether marking and lighting are legally required at all, independent of how large the raw delta is. That is why the drift scorer below treats a threshold crossing as an automatic escalation rather than folding it into a simple magnitude check.

Step-by-Step Implementation

The scorer runs as five ordered stages, each closing off a specific way a silent discrepancy could otherwise reach a filing untouched.

Step 1 — Align filed and as-built records by identifier. Before any field is compared, confirm both records describe the same structure: fcc_asr_number must match, and site_id must match. A comparison run against two records for different towers is not drift, it is a pairing bug, and it must fail loudly with a custom RecordDriftError rather than produce a diff that looks plausible but means nothing.

Step 2 — Diff each canonical field against a per-field tolerance. Exact equality is the wrong test for a physical measurement: a filed tower_height_ft of 150.0 and an as-built 150.4 is rounding, not relocation. Height gets a ±1 ft tolerance, GPS coordinates get a ±30 m tolerance computed from a flat-earth approximation over latitude and longitude, and categorical fields — owner_id, marking_lighting — use exact match after normalization, because there is no meaningful notion of “close enough” for who owns a structure or what lighting scheme is filed for it.

Step 3 — Classify drift severity as INFO, MATERIAL, or CRITICAL. Every field starts from an implicit INFO baseline — within tolerance, nothing to report. A breach escalates: a height or coordinate delta past tolerance is MATERIAL, and it escalates further to CRITICAL if the height delta exceeds 10 ft, the coordinate delta exceeds 100 m, the height crosses the 200 ft marking threshold, or the field is marking_lighting itself, since an unfiled lighting-scheme change is a live aviation-safety exposure rather than a paperwork lag. Severity only ever escalates — it never downgrades a finding once a worse condition is met.

Step 4 — Route material drift to a remediation queue. The report’s overall severity is the highest severity found across all fields. Anything above the INFO baseline — MATERIAL or CRITICAL — is not just logged, it is routed to a remediation queue where a compliance analyst picks it up for a refiling or a follow-up survey. An all-clear report still gets sealed and archived; it simply never reaches that queue.

Step 5 — Seal the drift report with hashlib.sha256. Once severity is resolved, the entire report — identifiers, the diff list, and the overall verdict — is serialized canonically and hashed. That seal is what lets a compliance team later prove to an auditor that a specific comparison ran against a specific pair of records and produced exactly the verdict on file, not a verdict reconstructed from memory after the fact.

Filed vs as-built drift pipeline: align, diff, classify severity, route, seal A filed record and an as-built record are aligned by fcc_asr_number and site_id; a mismatch is rejected as an alignment failure and never reaches the diff. Aligned records pass through a per-field diff against a fixed tolerance per field, then a severity classification step scores every breach on an escalate-only ladder from an INFO baseline to MATERIAL to CRITICAL. A result above the INFO baseline is routed to a remediation queue for analyst follow-up; an all-clear result is archived without routing. Both the remediation path and the archive path converge on a single SHA-256 sealed drift report. mismatch aligned INFO · no breach MATERIAL / CRITICAL Filed record ASR 1290123 As-built record TWR-8842 survey Align by ASR + site_id RecordDriftError no alignment Per-field diff height, coords, owner, marking Severity classification Archived report no remediation routed Remediation queue analyst follow-up Seal drift report SHA-256 over the full verdict

Figure: filed and as-built records are aligned, diffed per field against tolerance, scored for severity, routed to remediation if material, and sealed either way.

Complete Runnable Example

The module below is self-contained. It aligns a filed and an as-built record for TWR-8842 / ASR 1290123, diffs the canonical fields, scores severity, and seals the report — then demonstrates the alignment failure path with a mismatched site_id.

python
import hashlib
import json
import logging
import math
from enum import Enum
from typing import Any, Dict, Optional, Tuple

logging.basicConfig(level=logging.INFO, format="%(asctime)s | %(levelname)s | %(message)s")
logger = logging.getLogger("telecom.filing.drift_scorer")

REQUIRED_FIELDS = {"tower_height_ft", "owner_id", "gps_coords", "marking_lighting"}
HEIGHT_TOLERANCE_FT, COORD_TOLERANCE_M, MARKING_THRESHOLD_FT = 1.0, 30.0, 200.0


class RecordDriftError(Exception):
    """Raised when filed and as-built records cannot be aligned for a diff."""

    def __init__(self, message: str, fcc_asr_number: str):
        super().__init__(message)
        self.fcc_asr_number = fcc_asr_number


class DriftSeverity(Enum):
    INFO = 1        # baseline: no field breached tolerance
    MATERIAL = 2    # escalate: real disagreement, needs review
    CRITICAL = 3    # escalate: safety or regulatory-status impact


def _meters(a: Tuple[float, float], b: Tuple[float, float]) -> float:
    lat_m = (b[0] - a[0]) * 111_320
    lon_m = (b[1] - a[1]) * 111_320 * math.cos(math.radians(a[0]))
    return math.hypot(lat_m, lon_m)


def _align(filed: Dict[str, Any], as_built: Dict[str, Any]) -> None:
    asr = filed.get("fcc_asr_number")
    if asr != as_built.get("fcc_asr_number") or filed.get("site_id") != as_built.get("site_id"):
        raise RecordDriftError("fcc_asr_number/site_id do not match", str(asr))
    if REQUIRED_FIELDS - filed.keys() or REQUIRED_FIELDS - as_built.keys():
        raise RecordDriftError("missing canonical field on filed or as-built side", str(asr))


def diff_field(name: str, filed_v: Any, built_v: Any) -> Optional[Dict[str, Any]]:
    if name == "tower_height_ft":
        delta = abs(filed_v - built_v)
        if delta <= HEIGHT_TOLERANCE_FT:
            return None
        crosses = (filed_v < MARKING_THRESHOLD_FT) != (built_v < MARKING_THRESHOLD_FT)
        sev = DriftSeverity.CRITICAL if (crosses or delta > 10) else DriftSeverity.MATERIAL
        return {"field": name, "filed": filed_v, "as_built": built_v, "delta": round(delta, 1), "severity": sev}
    if name == "gps_coords":
        delta_m = _meters(filed_v, built_v)
        if delta_m <= COORD_TOLERANCE_M:
            return None
        sev = DriftSeverity.CRITICAL if delta_m > 100 else DriftSeverity.MATERIAL
        return {"field": name, "filed": filed_v, "as_built": built_v, "delta": round(delta_m, 1), "severity": sev}
    if filed_v == built_v:
        return None
    sev = DriftSeverity.CRITICAL if name == "marking_lighting" else DriftSeverity.MATERIAL
    return {"field": name, "filed": filed_v, "as_built": built_v, "delta": None, "severity": sev}


def build_drift_report(filed: Dict[str, Any], as_built: Dict[str, Any]) -> Dict[str, Any]:
    _align(filed, as_built)
    diffs = [d for name in ("tower_height_ft", "owner_id", "gps_coords", "marking_lighting")
             if (d := diff_field(name, filed.get(name), as_built.get(name)))]
    overall = max((d["severity"] for d in diffs), default=DriftSeverity.INFO, key=lambda s: s.value)
    report = {"fcc_asr_number": filed["fcc_asr_number"], "site_id": filed["site_id"],
              "diffs": [{**d, "severity": d["severity"].name} for d in diffs],
              "overall_severity": overall.name, "remediation_required": overall != DriftSeverity.INFO}
    canonical = json.dumps(report, sort_keys=True, separators=(",", ":"))
    report["report_hash"] = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
    return report


def route_to_remediation(report: Dict[str, Any]) -> None:
    if report["remediation_required"]:
        logger.warning("REMEDIATION | %s | %s | severity=%s", report["site_id"],
                        report["fcc_asr_number"], report["overall_severity"])
    else:
        logger.info("NO_DRIFT | %s | within tolerance", report["site_id"])


if __name__ == "__main__":
    filed = {"fcc_asr_number": "1290123", "site_id": "TWR-8842", "tower_height_ft": 150.0,
             "owner_id": "OWN-4471", "gps_coords": (30.2672, -97.7431), "marking_lighting": "L-864 red strobe"}
    as_built = {"fcc_asr_number": "1290123", "site_id": "TWR-8842", "tower_height_ft": 184.0,
                "owner_id": "OWN-4471", "gps_coords": (30.2672, -97.7431), "marking_lighting": "L-864 red strobe"}

    try:
        build_drift_report(filed, {**as_built, "site_id": "TWR-9910"})
    except RecordDriftError as exc:
        logger.error("ALIGNMENT_FAILED | %s | %s", exc.fcc_asr_number, exc)

    report = build_drift_report(filed, as_built)
    route_to_remediation(report)
    for d in report["diffs"]:
        print(f"{d['field']}: {d['filed']} -> {d['as_built']} | severity={d['severity']}")
    print(f"overall={report['overall_severity']} hash={report['report_hash'][:12]}")

Verification & Expected Output

Run python drift_scorer.py. The demo first exercises the alignment guard by rejecting a mismatched site_id before any diff runs, then builds the real report: the height field is the only one that breaches tolerance — 34 ft against a ±1 ft window, past the 10 ft escalation cutoff — so the report scores CRITICAL and routes to remediation:

text
2026-06-11 08:42:03 | ERROR | ALIGNMENT_FAILED | 1290123 | fcc_asr_number/site_id do not match
2026-06-11 08:42:03 | WARNING | REMEDIATION | TWR-8842 | 1290123 | severity=CRITICAL
tower_height_ft: 150.0 -> 184.0 | severity=CRITICAL
overall=CRITICAL hash=3f9a8b2c1d4e

A healthy report shows exactly one REMEDIATION line per record with a non-INFO verdict, followed by one diff line per breached field and a final overall= line whose hash prefix is deterministic for identical input. The failure signature to watch for is a NO_DRIFT log line for a pair of records you know disagree on a canonical field — that almost always means a tolerance constant was set too loose for the field in question, not that the records genuinely agree. A REMEDIATION line with an empty diffs list in the underlying report, on the other hand, means the routing check and the severity classification have drifted out of sync with each other, which is its own bug worth catching before it reaches the remediation queue.

Gotchas & Edge Cases

A tolerance sized for one field silently swallows another. Coordinate tolerance in meters and height tolerance in feet look like small numbers, but a ±30 m coordinate window is generous enough to absorb a genuine relocation to an adjacent rooftop corner, while a ±1 ft height window is tight enough that a survey team’s rounding convention (nearest half-foot versus nearest foot) can manufacture spurious MATERIAL findings on every site. Size each tolerance against the actual measurement precision of its source, not a single company-wide default.

A height crossing the marking threshold outranks a much larger drift that doesn’t. A filed height of 195 ft against an as-built 205 ft is only a 10 ft delta, smaller than plenty of MATERIAL-only cases, but it crosses the 200 ft obstruction-marking line and must classify as CRITICAL regardless of magnitude — the delta size is not what makes it dangerous, the change in regulatory obligation is. Any severity model that classifies purely on delta magnitude will under-rank exactly the drift most likely to trigger an FAA compliance finding.

Sealing the report before routing hides tampering after the fact, not before it. The report_hash proves a report was not altered after it was produced; it says nothing about whether the inputs feeding build_drift_report were themselves current. A stale as-built survey run through this scorer produces a perfectly sealed, perfectly wrong verdict — sealing integrity and input freshness are two different guarantees, and only the freshness gate upstream in the synchronization loop covers the second one.

FAQ

Why does a height drift that crosses 200 feet escalate to CRITICAL even when the raw delta is small?

Because 47 CFR Part 17 ties FAA obstruction marking and lighting requirements to the 200-foot above-ground-level threshold, a height change that moves a structure from one side of that line to the other changes whether marking and lighting are legally required at all — not just the number on file. A 10-foot delta that stays entirely below or entirely above 200 ft is a measurement disagreement; the same 10-foot delta straddling the line is a change in regulatory status, which is why the classifier treats a threshold crossing as an automatic escalation independent of delta size.

Why does a marking_lighting mismatch escalate straight to CRITICAL instead of MATERIAL?

Height and coordinate fields have a natural tolerance because they are physical measurements with instrument noise; a lighting or marking scheme is a categorical, filed value with no equivalent “close enough.” Any mismatch there means the structure is operating under an obstruction-lighting configuration that does not match what was filed with the FCC, which is a live aviation-safety exposure rather than a paperwork lag, so it is classified at the same severity as the most dangerous height or coordinate breach.

What happens to a drift report that scores INFO — does it get thrown away?

No. Every report is sealed with hashlib.sha256 and archived regardless of its severity, because an INFO verdict is itself an auditable claim — “this pair of records agreed within tolerance on this date” — that a regulator or an internal audit may need to verify later. The only thing severity controls is whether the report is also pushed into the remediation queue for analyst follow-up; sealing and archiving happen on every pass, drifted or not.

Related pages