Notamify NOTAM Schedules & RRULE Parsing

This article expands on how Notamify represents and parses NOTAM schedules and how you can use them for client-side time filtering. It includes fully working Python and JavaScript examples.

TL;DR

  • Notamify exposes schedule recurrences using a standards-compliant RFC 5545 RRULE string plus a separate duration_hrs.

  • Duration is not encoded in the RRULE. We keep duration in duration_hrs to avoid non-standard RRULEs and to properly handle windows that cross midnight (e.g., 20:00–04:00).

  • Each schedule RRULE produces a list of start datetimes; you derive the end by start + duration_hrs.

  • Always intersect schedule windows with the NOTAM’s overall validity [starts_at, ends_at].

Data Model

Within each NOTAM, schedules live at notam.interpretation.schedules[]:

{
  "description": "On April 4-6, 11-13, 18-20, 25-27 from 20:00 to 04:00.",
  "duration_hrs": 8,
  "is_sunrise_sunset": false,
  "rrule": "DTSTART:20250404T200000Z\nRRULE:FREQ=DAILY;UNTIL=20250627T040000Z;BYDAY=TH,FR,SA,SU,MO,TU,WE;COUNT=15",
  "source": "APR 04-06 11-13 18-20 25-27 2000-0400"
}

Contract & invariants

  • rrule (string): An iCalendar RRULE string that includes a DTSTART line in UTC (ending with Z). RRULEs are bounded (via COUNT or UNTIL).

  • duration_hrs (number): The event window length in hours for each occurrence generated by the RRULE.

  • is_sunrise_sunset (boolean): If true, duration/instants derive from astronomical times (out of scope here).

  • description/source: Human-readable; do not parse from these.

Note: The NOTAM object also carries starts_at and ends_at—the overall validity window. Schedule windows must be intersected with this validity.

Client-Side Usage Pattern

  1. Parse the RRULE to get start datetimes.

  2. Compute end = start + duration_hrs.

  3. Intersect each [start, end) with:

    • The NOTAM validity [starts_at, ends_at), and

    • Any query window you care about (e.g., “show what’s active on 2025-05-10”).

When possible, prefer windowed enumeration (between(...)) to avoid expanding very long recurrences.


Python (dateutil)

from datetime import datetime, timedelta, timezone
from dateutil.rrule import rrulestr

def ranges_overlap(a_start, a_end, b_start, b_end):
    return a_start < b_end and b_start < a_end

def any_schedule_overlaps_window(schedules, window_start, window_end) -> bool:
    """DTSTART/UTC inside RRULE; length provided via duration_hrs."""
    for s in schedules:
        rrule_str = s.get("rrule")
        rule = rrulestr(rrule_str)  # parses DTSTART + RRULE
        for occ_start in rule.between(window_start, window_end, inc=True):
            occ_end = occ_start + timedelta(hours=float(s.get("duration_hrs", 0) or 0))
            if ranges_overlap(occ_start, occ_end, window_start, window_end):
                return True
    return False

# --- example ---
schedules = [
    {
        "rrule": "DTSTART:20250502T200000Z\nRRULE:FREQ=DAILY;UNTIL=20250530T040000Z;COUNT=12",
        "duration_hrs": 8,  # 20:00 → 04:00 next day
    }
]

window_start = datetime(2025, 5, 10, 0, 0, tzinfo=timezone.utc)
window_end   = datetime(2025, 5, 11, 0, 0, tzinfo=timezone.utc)

print(any_schedule_overlaps_window(schedules, window_start, window_end))  # True/False

Key callouts

  • rule = rrulestr(rrule_str) is the only dateutil-specific line you need to parse both DTSTART and RRULE.

  • Use rule.between(start, end, inc=True) whenever you can bound expansion.


JavaScript/TypeScript (rrule)

IMPORTANT: Use rrulestr from the rrule package. Other RRULE string parsing mechanism from the package might not work as expected.

// npm i rrule
import { rrulestr } from 'rrule'

const addHours = (d: Date, h: number) => new Date(d.getTime() + h * 3600 * 1000)
const rangesOverlap = (aStart: Date, aEnd: Date, bStart: Date, bEnd: Date) =>
  aStart < bEnd && bStart < aEnd

export function anyScheduleOverlapsWindow(
  schedules: Array<{ rrule: string; duration_hrs: number }>,
  windowStart: Date,
  windowEnd: Date
): boolean {
  for (const s of schedules) {
    if (!s.rrule) continue
    const rule = rrulestr(s.rrule)               // parses DTSTART + RRULE
    const occurrences = rule.between(windowStart, windowEnd, true)
    for (const occStart of occurrences) {
      const occEnd = addHours(occStart, Number(s.duration_hrs || 0))
      if (rangesOverlap(occStart, occEnd, windowStart, windowEnd)) return true
    }
  }
  return false
}

// --- example ---
const schedules = [
  {
    rrule: 'DTSTART:20250502T200000Z\nRRULE:FREQ=DAILY;UNTIL=20250530T040000Z;COUNT=12',
    duration_hrs: 8, // 20:00 → 04:00
  },
]

const windowStart = new Date('2025-05-10T00:00:00Z')
const windowEnd   = new Date('2025-05-11T00:00:00Z')

console.log(anyScheduleOverlapsWindow(schedules, windowStart, windowEnd)) // true/false

Quick Recipe: “Is this NOTAM active at T?”

Python

def notam_active_at(notam: dict, t: datetime) -> bool:
    t = t.astimezone(timezone.utc)
    windows = schedule_windows_for_notam(notam, (t - timedelta(days=1), t + timedelta(days=1)))
    return any(w[0] <= t < w[1] for w in windows)

JavaScript/TS

export function notamActiveAt(notam: any, t: Date): boolean {
  const tUtc = new Date(t.toISOString()) // normalize
  const oneDayBefore = new Date(tUtc.getTime() - 24 * 3600 * 1000)
  const oneDayAfter  = new Date(tUtc.getTime() + 24 * 3600 * 1000)
  const windows = scheduleWindowsForNotam(notam, oneDayBefore, oneDayAfter)
  return windows.some(w => w.start <= tUtc && tUtc < w.end)
}

Summary

  • RRULE gives you when each occurrence starts; duration_hrs tells you how long it runs.

  • Expand, add duration, and intersect with the NOTAM’s validity (and your query).

  • Use dateutil (rrulestr) in Python and rrule (rrulestr) in JavaScript for robust, standards-compliant parsing.

Last updated