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 aDTSTART
line in UTC (ending withZ
). RRULEs are bounded (viaCOUNT
orUNTIL
).duration_hrs
(number): The event window length in hours for each occurrence generated by the RRULE.is_sunrise_sunset
(boolean): Iftrue
, 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
andends_at
—the overall validity window. Schedule windows must be intersected with this validity.
Client-Side Usage Pattern
Parse the RRULE to get start datetimes.
Compute end = start + duration_hrs.
Intersect each
[start, end)
with:The NOTAM validity
[starts_at, ends_at)
, andAny 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 bothDTSTART
andRRULE
.Use
rule.between(start, end, inc=True)
whenever you can bound expansion.
JavaScript/TypeScript (rrule)
IMPORTANT: Use
rrulestr
from therrule
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
?”
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