Scheduling Around Lightning and High-Wind Exclusion Windows
A single APPROVED/DEFERRED verdict for “right now” is not the same problem as finding every safe hour inside a six-hour candidate window three days out, and treating them as the same problem is how crews end up climbing into the front edge of a storm that arrived on schedule. A dispatcher who only has a point-in-time verdict has to poll it hour by hour and hope nobody forgets to re-check before the crew clips in; what operations actually need is the full shape of a work day up front — every safe sub-window computed once, sealed, and handed to routing before a truck ever leaves the yard. This page covers that second problem: given an hourly forecast series and a candidate climb window, convert every hour that breaches a lightning-proximity or wind threshold into an exclusion interval, merge the overlapping ones, and subtract the result from the candidate window so what remains is provably safe. It is one layer of the Weather Window Optimization approach — the layer that turns raw forecast telemetry into the hard constraint intervals that the rest of scheduling treats as immovable.
Prerequisites & Context
You need Python 3.10 or newer for the dataclass defaults and modern type hints; everything on this page is standard library — no requests, no third-party interval-math package. Interval math over integers (hour offsets) is exact, so there is no floating-point drift to worry about between the forecast series and the candidate window boundaries.
Three things should already be settled before you wire this into dispatch:
- A single-point climb verdict. This page assumes you already know how to turn one forecast reading into one decision — that is the subject of Integrating NOAA Weather APIs for Safe Tower Climb Scheduling. What follows here is the next step up: applying that same threshold logic across an entire hourly series to find every safe sub-window, not just answer whether right now is safe.
- A threshold set with a version tag. The exact wind and lightning limits used below are illustrative defaults, not universal constants. Deriving, testing, and versioning threshold sets per structural class and municipal code is the job of Frequency Logic & Threshold Tuning; this page consumes a
threshold_versionstring, it does not decide what that version means. - A regulatory floor for sustained wind. OSHA 29 CFR Part 1926 sets the general climbing and fall-protection framework for tower work; it does not hard-code a single sustained-wind number, so operators derive a site- and structure-class-specific limit that sits at or below whatever their crew safety program and equipment manufacturer specify, and treat gust speed as a separate, usually stricter, ceiling. The examples below use 25 mph sustained and 35 mph gust as representative defaults — replace them with your own governed values.
All of this sits under the broader Intelligent Inspection Scheduling & Technician Routing pipeline, where a safe-window set produced here becomes one input alongside crew availability and lease-mandated quiet hours.
Step-by-Step Implementation
Step 1 — Define the exclusion thresholds. Three independent conditions can each unilaterally exclude an hour: sustained wind over the climb limit, gusts over a separate (typically higher) ceiling, and a lightning strike inside the hold-down radius. Keep them in one versioned object so a threshold change is auditable rather than a silent edit buried in comparison logic.
@dataclass
class ExclusionThresholds:
wind_sustained_limit_mph: float = 25.0
wind_gust_limit_mph: float = 35.0
lightning_hold_km: float = 8.0
threshold_version: str = "v1.4.0"
Step 2 — Build exclusion intervals from the hourly forecast series. An hourly forecast is a list of per-hour readings, not a single snapshot, and each reading typically carries its own wind, gust, and lightning-proximity fields independently of the others — a calm-wind hour can still be excluded on lightning alone, and vice versa. Walk the series and turn every hour that breaches any threshold into a half-open interval (hour, hour + 1) — half-open so adjacent hours merge cleanly in the next step with no off-by-one gap or double-count at the boundary. Representing exclusions as intervals rather than a per-hour boolean flag is what makes the later subtraction step a matter of ordinary interval arithmetic instead of a hand-rolled hour-by-hour scan.
def build_exclusion_intervals(hourly, thresholds):
intervals = []
for entry in hourly:
breach = (entry["wind_sustained_mph"] > thresholds.wind_sustained_limit_mph
or entry["wind_gust_mph"] > thresholds.wind_gust_limit_mph
or entry["lightning_km"] < thresholds.lightning_hold_km)
if breach:
intervals.append((entry["hour"], entry["hour"] + 1))
return intervals
Step 3 — Merge overlapping exclusions. A three-hour storm cell produces three separate one-hour intervals from Step 2; left unmerged, downstream logic would have to reason about three tiny holds instead of one contiguous one, and a boundary bug there is exactly how a crew gets cleared to climb into the middle hour of a storm. Sort the intervals and fold each one into the previous when it starts at or before the running interval’s end.
def merge_intervals(intervals):
if not intervals:
return []
ordered = sorted(intervals)
merged = [ordered[0]]
for start, end in ordered[1:]:
last_start, last_end = merged[-1]
if start <= last_end:
merged[-1] = (last_start, max(last_end, end))
else:
merged.append((start, end))
return merged
Step 4 — Interval-subtract exclusions from the candidate window. A candidate window is itself just an interval — the technician’s assigned shift block, say (6, 18) for a 06:00–18:00 day. For every merged exclusion that overlaps it, split the candidate (or whatever remains of it after prior splits) into the piece before the exclusion and the piece after, discarding the excluded slice entirely. Feed the exclusions in left-to-right and the running safe set only ever shrinks — it can be split into more pieces by a later exclusion, but no split step ever re-adds hours a previous one removed. This is why exclusion order does not need to match chronological order: subtracting (11, 12) before (8, 10) produces the identical three-window result, because each subtraction only ever operates on what is currently marked safe.
def subtract_intervals(candidate, exclusions):
start, end = candidate
safe = [(start, end)]
for ex_start, ex_end in exclusions:
next_safe = []
for s, e in safe:
if ex_end <= s or ex_start >= e:
next_safe.append((s, e))
continue
if ex_start > s:
next_safe.append((s, ex_start))
if ex_end < e:
next_safe.append((ex_end, e))
safe = next_safe
return safe
Run a (6, 18) candidate window for site TWR-8842 against a forecast with a wind breach from 08:00–10:00 and a lightning breach from 11:00–12:00 and the subtraction leaves three disjoint safe sub-windows — the shape traced below.
Figure: candidate window minus exclusion bands equals safe sub-windows.
Step 5 — Seal the safe-window set with hashlib.sha256. The safe sub-windows are the artifact dispatch actually consumes, so they need the same audit fingerprint the single-point verdict gets: a digest over the site, the original candidate window, the resulting safe windows, and the threshold version, so a later question of “why was TWR-8842 only offered three sub-windows that day” resolves to an exact, reproducible input set. If the subtraction leaves nothing at all — the entire candidate window falls inside merged exclusions — that is not a degenerate case to paper over with an empty list; it is raised as UnsafeClimbWindowError so the scheduler is forced to look for another day rather than silently offer a zero-length window downstream.
def seal_safe_windows(site_id, candidate, safe_windows, thresholds):
payload = json.dumps({"site_id": site_id, "candidate": candidate,
"safe_windows": safe_windows,
"threshold_version": thresholds.threshold_version}, sort_keys=True)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
Complete Runnable Example
The block below is self-contained, needs no installs and no network, and carries the three mandatory pieces for this codebase: structured logging, a custom exception (UnsafeClimbWindowError), and a hashlib.sha256 audit hash. It runs the exact TWR-8842 scenario traced in the figure above — a wind breach from 08:00–10:00 and a lightning breach from 11:00–12:00 inside a 06:00–18:00 candidate window.
import hashlib
import json
import logging
from dataclasses import dataclass
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
log = logging.getLogger("exclusion_windows")
class UnsafeClimbWindowError(Exception):
"""Raised when a candidate window has zero safe hours after exclusions."""
@dataclass
class ExclusionThresholds:
wind_sustained_limit_mph: float = 25.0
wind_gust_limit_mph: float = 35.0
lightning_hold_km: float = 8.0
threshold_version: str = "v1.4.0"
def build_exclusion_intervals(hourly, thresholds):
intervals = []
for entry in hourly:
breach = (entry["wind_sustained_mph"] > thresholds.wind_sustained_limit_mph
or entry["wind_gust_mph"] > thresholds.wind_gust_limit_mph
or entry["lightning_km"] < thresholds.lightning_hold_km)
if breach:
intervals.append((entry["hour"], entry["hour"] + 1))
return intervals
def merge_intervals(intervals):
if not intervals:
return []
ordered = sorted(intervals)
merged = [ordered[0]]
for start, end in ordered[1:]:
last_start, last_end = merged[-1]
if start <= last_end:
merged[-1] = (last_start, max(last_end, end))
else:
merged.append((start, end))
return merged
def subtract_intervals(candidate, exclusions):
start, end = candidate
safe = [(start, end)]
for ex_start, ex_end in exclusions:
next_safe = []
for s, e in safe:
if ex_end <= s or ex_start >= e:
next_safe.append((s, e))
continue
if ex_start > s:
next_safe.append((s, ex_start))
if ex_end < e:
next_safe.append((ex_end, e))
safe = next_safe
return safe
def seal_safe_windows(site_id, candidate, safe_windows, thresholds):
payload = json.dumps({"site_id": site_id, "candidate": candidate,
"safe_windows": safe_windows,
"threshold_version": thresholds.threshold_version}, sort_keys=True)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
def compute_safe_windows(site_id, candidate, hourly, thresholds=ExclusionThresholds()):
exclusions = merge_intervals(build_exclusion_intervals(hourly, thresholds))
safe = subtract_intervals(candidate, exclusions)
if sum(e - s for s, e in safe) == 0:
raise UnsafeClimbWindowError(f"{site_id} has zero safe hours in {candidate}")
digest = seal_safe_windows(site_id, candidate, safe, thresholds)
log.info("%s exclusions=%s safe=%s hash=%s", site_id, exclusions, safe, digest)
return safe, digest
if __name__ == "__main__":
hourly = [
{"hour": 6, "wind_sustained_mph": 12, "wind_gust_mph": 18, "lightning_km": 40},
{"hour": 7, "wind_sustained_mph": 14, "wind_gust_mph": 20, "lightning_km": 35},
{"hour": 8, "wind_sustained_mph": 28, "wind_gust_mph": 38, "lightning_km": 30},
{"hour": 9, "wind_sustained_mph": 30, "wind_gust_mph": 40, "lightning_km": 25},
{"hour": 10, "wind_sustained_mph": 16, "wind_gust_mph": 22, "lightning_km": 12},
{"hour": 11, "wind_sustained_mph": 15, "wind_gust_mph": 21, "lightning_km": 6},
{"hour": 12, "wind_sustained_mph": 14, "wind_gust_mph": 19, "lightning_km": 9},
{"hour": 13, "wind_sustained_mph": 13, "wind_gust_mph": 18, "lightning_km": 45},
{"hour": 14, "wind_sustained_mph": 12, "wind_gust_mph": 17, "lightning_km": 50},
{"hour": 15, "wind_sustained_mph": 11, "wind_gust_mph": 16, "lightning_km": 55},
{"hour": 16, "wind_sustained_mph": 10, "wind_gust_mph": 15, "lightning_km": 60},
{"hour": 17, "wind_sustained_mph": 10, "wind_gust_mph": 14, "lightning_km": 60},
]
safe, digest = compute_safe_windows("TWR-8842", (6, 18), hourly)
print(f"Safe sub-windows: {safe}")
Verification & Expected Output
Save the block as exclusion_windows.py and run python3 exclusion_windows.py. You should see exactly:
INFO TWR-8842 exclusions=[(8, 10), (11, 12)] safe=[(6, 8), (10, 11), (12, 18)] hash=b11444a3259700b1
Safe sub-windows: [(6, 8), (10, 11), (12, 18)]
Trace it against the figure: hours 8 and 9 both breach on wind (28 and 30 mph, over the 25 mph sustained limit) and merge into one (8, 10) exclusion; hour 11’s lightning reading of 6 km is inside the 8 km hold and becomes its own (11, 12) exclusion — hour 12’s 9 km reading clears the hold by a single kilometer and stays out of the exclusion set entirely, which is exactly the kind of boundary a threshold-version bump should be tested against before it ships. Subtracting both exclusions from the (6, 18) candidate leaves three safe sub-windows totaling nine hours out of twelve. The hash= value is deterministic for this exact candidate window, exclusion set, and threshold_version="v1.4.0"; change any reading or bump the version and the digest changes with it. To see the fail-closed path, call compute_safe_windows with every hour breaching (for example, 32 mph sustained wind and a 3 km strike across all twelve hours) — every hour becomes part of one merged (6, 18) exclusion, subtraction leaves an empty safe list, and the function raises UnsafeClimbWindowError: TWR-8842 has zero safe hours in (6, 18) instead of returning a hash for a window nobody should ever be dispatched into.
Gotchas & Edge Cases
- Hourly granularity hides sub-hour lightning strikes. Turning each hour into a single boolean breach collapses a strike that occurred at minute 55 of an otherwise-clear hour into “hour excluded,” which is the conservative and correct direction — but it also means a strike at minute 5 of the next hour won’t retroactively re-exclude the hour you already called safe. If your lightning feed reports at finer resolution than your candidate window’s granularity, resample to the coarser grain before calling
build_exclusion_intervals, never the reverse. - Adjacent exclusions must merge, not just overlap.
merge_intervalsfolds an interval in wheneverstart <= last_end, which correctly joins(8, 10)and(10, 12)into(8, 12)even though they only touch at a single point rather than truly overlapping. Using<instead of<=there is a one-line bug that leaves a zero-width gap at the boundary hour —subtract_intervalswould then hand back a technically “safe” sub-window that is zero hours wide, which a downstream scheduler might treat as a real slot. - A gust breach with sustained wind well under limit is still an exclusion. The
orchain inbuild_exclusion_intervalstreats all three conditions as independent triggers; sustained wind at 14 mph with gusts to 40 mph is excluded on gust alone. Reporting onlywind_sustained_mphin an incident review after a near-miss hides the actual cause — always log which condition fired, not just whether the hour was safe. - A candidate window that spans a threshold-version change mid-day is a stale-config bug waiting to happen. If
threshold_versiongets bumped by Frequency Logic & Threshold Tuning between when a safe-window set was sealed and when a crew is actually dispatched against it, the audit hash on file no longer matches what the current thresholds would produce for the same forecast. Treat a stalethreshold_versionon a safe-window record as a reason to recompute before dispatch, not as a cosmetic mismatch to ignore.
FAQ
Why subtract intervals instead of just checking each hour independently at dispatch time?
Checking each hour independently at dispatch time answers “is this specific hour safe,” which is fine for a single climb but doesn’t tell a scheduler where the safe sub-windows inside a longer candidate block actually start and end. Interval subtraction produces that shape directly — a list of contiguous safe ranges — which is what a routing or assignment step needs to place a multi-hour inspection without stepping into an excluded hour partway through.
What happens if the forecast series has gaps in the hourly data?
A missing hour is neither excluded nor safe by default in the logic above — it simply never appears in the loop, so it is treated as if no breach occurred there. That is the wrong failure mode for a safety gate: gaps should be caught before build_exclusion_intervals runs, by validating that the hourly series covers every hour of the candidate window and raising rather than silently proceeding on a partial series.
Should the lightning and wind thresholds share one exclusion interval or be tracked separately?
Track them separately upstream, then merge for the final safe-window calculation shown here. Keeping the triggering condition on each interval (as the runnable example’s logging line does, via the exclusions list order) lets an audit trail show why an hour was excluded, while the merged, condition-agnostic set is what subtract_intervals actually needs to produce a clean safe-window shape.
Related
- Parent topic: Weather Window Optimization
- Sibling guide: Integrating NOAA Weather APIs for Safe Tower Climb Scheduling
- Background: Frequency Logic & Threshold Tuning
- Section overview: Intelligent Inspection Scheduling & Technician Routing