Detecting Overlapping Duty Segments in Python

The exact task this guide solves is finding duty segments that overlap in time for the same crew member — a double-booked leg, a positioning segment that runs into a flight, a duplicated event from two feeds — before those overlaps silently corrupt every downstream duration. Overlapping segments are the most common data-quality failure in an ingested roster, and they are insidious: a rule engine that sums segment durations will double-count the overlap, inflating flight time and shrinking rest, so a pairing that is actually illegal can pass and one that is legal can be rejected. This page detects overlaps with a linear sweep-line pass and with PostgreSQL range operators, and reports the offending pair. It hardens the flight time calculation algorithms against bad input and complements the validation done at flight data ingestion.

Prerequisites

Why overlap detection comes before any duration math

An overlap means two segments claim the same slice of a crew member’s time, which is physically impossible and always a data error. If the sweep that sums flight or duty minutes runs before overlaps are removed, the overlapping minutes are counted twice, so the very first correctness property a flight-time engine needs is that no two segments for one crew member overlap. Detecting overlaps is therefore a gate, not a report: a roster with overlaps is rejected or quarantined, not evaluated.

Step 1 — Detect overlaps with a sweep line

Sorting the segments by start instant lets a single pass find every overlap: two segments overlap exactly when the next one starts before the current maximum end. Tracking the running maximum end and the segment that set it makes the reported pair actionable.

from dataclasses import dataclass
from datetime import datetime


@dataclass(frozen=True, slots=True)
class Segment:
    segment_id: str
    start_utc: datetime
    end_utc: datetime


def find_overlaps(segments: list[Segment]) -> list[tuple[str, str]]:
    """Return (earlier_id, later_id) pairs whose [start, end) intervals overlap."""
    ordered = sorted(segments, key=lambda s: s.start_utc)
    overlaps = []
    active = ordered[0] if ordered else None
    for seg in ordered[1:]:
        if seg.start_utc < active.end_utc:          # strictly before → overlap
            overlaps.append((active.segment_id, seg.segment_id))
            # Keep whichever reaches furthest, so a chain of overlaps is all caught.
            if seg.end_utc > active.end_utc:
                active = seg
        else:
            active = seg
    return overlaps

Verify: for segments A [08:00,10:00), B [09:30,11:00), C [11:00,12:00), find_overlaps returns [("A", "B")] — A and B overlap by 30 minutes, while C starts exactly when B ends and, under the half-open convention, does not overlap.

Step 2 — Distinguish an overlap from an exact duplicate

Two feeds delivering the same segment produce an overlap that is really a duplicate, and the remedy differs: a duplicate is deduplicated, a genuine overlap is escalated to a scheduler. Comparing the intervals for equality separates the two.

def classify_overlap(a: Segment, b: Segment) -> str:
    if a.start_utc == b.start_utc and a.end_utc == b.end_utc:
        return "duplicate"          # same interval from two sources → dedupe
    return "conflict"               # genuine double-booking → escalate

Verify: two segments with identical start and end classify as duplicate; a segment that merely straddles another classifies as conflict, so the pipeline dedupes the first and quarantines the second.

Step 3 — Detect overlaps in the database

When the roster already lives in PostgreSQL, a self-join on tstzrange overlap finds every conflicting pair in one statement, and a GiST index on the range keeps it fast for large crews.

SELECT a.segment_id AS earlier, b.segment_id AS later
FROM duty_segment AS a
JOIN duty_segment AS b
  ON a.crew_id = b.crew_id
 AND a.segment_id < b.segment_id
 AND tstzrange(a.start_utc, a.end_utc, '[)') && tstzrange(b.start_utc, b.end_utc, '[)');

The && operator is the range overlap test, and '[)' makes the ranges half-open so touching endpoints do not register. The a.segment_id < b.segment_id clause reports each pair once rather than twice.

Sweep-line overlap detection over three segments Three duty segments on a time axis. Segment A runs 08:00 to 10:00 and segment B runs 09:30 to 11:00; they overlap between 09:30 and 10:00, which is flagged. Segment C runs 11:00 to 12:00, starting exactly when B ends, and under the half-open interval convention does not overlap B. 08:00 10:00 11:00 12:00 A [08:00,10:00) B [09:30,11:00) C [11:00,12:00) overlap 09:30–10:00 touch — no overlap
The sweep line flags A and B, which share the 09:30–10:00 slice; C starts exactly when B ends and, under the half-open convention, is a clean hand-off rather than an overlap.

Failure modes and troubleshooting

Frequently Asked Questions

Why are half-open intervals important here?

Because back-to-back segments are normal and legal — one duty ends exactly as the next begins. Half-open intervals [start, end) treat a shared endpoint as a clean hand-off rather than an overlap, so the detector does not flag every legitimate connection.

What is the difference between an overlap and a duplicate?

A duplicate is the same interval delivered by two sources and is resolved by deduplication. An overlap with different endpoints is a genuine double-booking that a human must resolve. Comparing the intervals for equality separates them.

Why detect overlaps before computing flight time?

Because overlapping minutes are counted twice by any duration sum, inflating flight time and shrinking rest. Detection is a gate that must run first; a roster with unresolved conflicts is quarantined, not evaluated.

How does the sweep line catch a chain of overlaps?

By advancing the active segment to whichever reaches furthest in time. A segment contained within a longer one does not reset the active end, so a later segment overlapping the long one is still caught.

Back to Flight Time Calculation Algorithms.