mostlyright.weather.hazards
mostlyright.weather.hazards — standalone NWS/NHC hazard adapter.
Hazards are CAP alerts (api.weather.gov/alerts), NWS/NHC text bulletins
(TCP/TCM/TCD/TCV/PSH), NHC active tropical cyclones (CurrentStorms.json),
the NHC HURDAT2 best-track reanalysis, the NWPS river-gauge inventory and
detail routes, NWPS stage-and-flow series, NWPS stage-to-flow rating curves
(all three on api.water.noaa.gov/nwps/v1), USGS continuous water
observations (api.waterdata.usgs.gov), the interagency wildfire incident
services (services3.arcgis.com), the California incident list
(incidents.fire.ca.gov), and NASA satellite active-fire detections
(firms.modaps.eosdis.nasa.gov). Ten of those eleven are US federal
government works (17 U.S.C. §105, public domain); the California incident list
is a State of California record, in the public domain under that state’s own
conditions of use rather than under §105. All eleven are read over plain HTTPS
directly from the publishing agency, and only one route in the whole adapter
takes a credential — the satellite bounding-box route, which uses a free key of
the caller’s own in MOSTLYRIGHT_FIRMS_MAP_KEY. The source addresses resolve
through the keyed manifest catalog (MOSTLYRIGHT_API_KEY).
The satellite detections carry the one obligation the others do not: NASA asks
for an acknowledgment, and every hotspot row carries it in an attribution
column — a column rather than frame metadata, because a frame’s attribute bag is
dropped by the first concat or merge.
This module is deliberately standalone. A hazard row is its own product, never
an observation source: a hurricane advisory’s intensity_kt is not a
temperature, and a CAP alert has no place in the source-blind max/min
aggregation that produces the settlement label. Hazard rows therefore carry
their own schema.hazard.*.v1 ids rather than schema.observation.v1.
Four files are the parity firewall; no hazard source is registered in any of them:
packages/core/src/mostlyright/_internal/merge/observations.py(SOURCE_PRIORITY)packages/core/src/mostlyright/_internal/merge/climate.pypackages/weather/src/mostlyright/_live/_sources.py(the live-dispatch tableweather.liveresolves to)- the
observation_typeenum ofschema.observation.v1(packages/core/src/mostlyright/core/schemas/observation.py)
No wildfire source is registered in any of those four either — not
wfigs.current.live, wfigs.season.live, wfigs.history,
calfire.incidents.live, firms.bulk.live, or firms.area.live. The
reasoning is CWOP’s, applied to a different instrument: a satellite hotspot is a
DETECTION of heat, not a temperature reading, and a reported acreage is not a
temperature either. Both reach a model as their own frame that a caller joins,
never through the settlement aggregation.
Nothing under weather/hazards/ imports the merge path, the live-dispatch
table, or the parity cache module. Nothing under it imports a geometry package
either: this adapter is attributes-only, and a repository test scans the
wildfire modules for both an import and a route name that would pull one in.
Public verbs:
alerts()— CAP alerts at the (issuance x UGC zone) grain.source=Nonereads the live NWS window,source="iem"reads the Iowa State VTEC archive, and every row says which leg answered.text_products()— NWS/NHC bulletins (TCP/TCM/TCD/TCV/PSH) withraw_afoskept verbatim. An out-of-retention window auto-routes to the Iowa State AFOS archive unlesssource="nws"pins the live route.post_storm_reports()—text_products(product_code="PSH", …)plus the PSH header parse. Two amendments of one report are two rows.issuing_offices()— the offices that issue a product code, read from/products/types/{code}/locations, returned asIssuingOfficerecords.tropical_cyclones()— NHC active storms; a quiet season returns an empty frame, not an error.best_track()— HURDAT2 fixes for one storm, from one reanalysis vintage. An absent storm raises rather than returning nothing.best_track_revisions()— every published HURDAT2 revision for a basin, as file records rather than rows.gauges()— NWPS river gauges.bbox=enumerates the inventory inside a bounding box;gauge_id=returns the one detail row carrying the identifier crosswalk and the flood-category thresholds. Exactly one of the two is required.stage_and_flow()— a gauge’s observed and forecast stage/flow points, discriminated by theseriescolumn. A gauge with no live forecast returns an empty frame; an unknown gauge raises.rating_curve()— a gauge’s official stage-to-flow curve, whole and ascending bystage_ft.water_observations()— USGS continuous water values over a window, carryingapproval_statusand the upstream revision time.wildfires()— wildfire incident records (schema.hazard.wildfire.v1) at the (incident x capture) grain.source=Nonereads the national interagency feed throughscope=and tags rowswfigs.current.live/wfigs.season.live/wfigs.history;source="calfire"reads the California state list and tags themcalfire.incidents.live. A past window againstscope="current"raises rather than returning the fires that happen to still be listed.hotspots()— satellite active-fire detections (schema.hazard.hotspot.v1) at the (overpass x ground pixel) grain. The default route is KEYLESS and tags rowsfirms.bulk.live;source="firms.area"reads an arbitrary bounding box with a key of your own and tags themfirms.area.live.capture_history()— replay the local vintage store, selecting a capture withvintages=andas_of=.persist_rows()— write rows to that store directly.
Errors: NoHazardDataError when a named identity has no rows,
HazardRetentionError when a window predates the 7-day live retention
(pass source="iem" for the archive route), and
HazardCacheCorruptError when a persisted partition is unreadable. All
three come from mostlyright.core.exceptions and are re-exported here,
never redefined. None of the verbs returns []/None on failure.
An enumeration verb whose provider answered with a well-formed empty collection returns a valid empty frame; an identity verb handed a name that does not exist raises. That split is what keeps a quiet hurricane season distinguishable from a typo’d storm id.
Importing this module registers schema.hazard.alert.v1,
schema.hazard.text_product.v1, schema.hazard.tropical_cyclone.v1,
schema.hazard.best_track.v1, schema.hazard.gauge.v1,
schema.hazard.stage_and_flow.v1, schema.hazard.rating_curve.v1, and
schema.hazard.water_observation.v1 with the validator (lazy — a base
install that never touches hazards pays nothing).
It also registers schema.hazard.wildfire.v1 and
schema.hazard.hotspot.v1, for ten row contracts in all.
class mostlyright.weather.hazards.HazardAlertSchema
Section titled “class mostlyright.weather.hazards.HazardAlertSchema”Bases: Schema
One row per (CAP issuance x UGC zone).
That grain is what both legs can express without heuristics. The live CAP
feed returns one feature per issuance carrying geocode.UGC[]; the Iowa
State watchwarn archive returns one CSV row per UGC zone. Fanning the CAP
feature out across its zones is a mechanical transform; collapsing the
archive’s zone rows back into one issuance is not.
Supersession is the majority case, not the exception: over a 200-alert live
sample the message types split Alert 85 / Update 106 / Cancel 9, with 115
carrying a non-empty references[]. So alert_id is the primary key
(unique per issuance) and references points at the superseded issuance
— never a delete. A “latest wins per zone” dedup destroys the vintage
record.
schema_id: ClassVar[str]
Section titled “schema_id: ClassVar[str]”class mostlyright.weather.hazards.HazardBestTrackSchema
Section titled “class mostlyright.weather.hazards.HazardBestTrackSchema”Bases: Schema
One row per HURDAT2 best-track fix.
Best track is reanalysis, published months after the storm and revised
again in later seasons. hurdat2_revision is therefore the knowledge
time — the date on which these values became knowable — while
valid_at_utc is the event time. Conflating the two is exactly how a
model trained today “predicts” a 2022 storm using values NHC published in
2023.
Multiple revisions of the same season coexist upstream (the 2022 Atlantic
file has five versions). That multiplicity is the vintage record; the
(hurdat2_revision, hurdat2_filename) pair keeps each one addressable.
schema_id: ClassVar[str]
Section titled “schema_id: ClassVar[str]”exception mostlyright.weather.hazards.HazardCacheCorruptError(path, reason, , request_id=None, error_code=None)
Section titled “exception mostlyright.weather.hazards.HazardCacheCorruptError(path, reason, , request_id=None, error_code=None)”Bases: MostlyrightError
A persisted hazard cache partition exists but cannot be read.
Descends MostlyrightError DIRECTLY. A torn parquet partition is
neither a ContractError (the caller did nothing wrong) nor a
NoDataError (the rows exist — they are unreadable). Giving it
either parent would let a caller’s except NoDataError swallow a corrupt
on-disk vintage as “no data”, which is exactly the silent-corruption path
the hazards vintage store exists to prevent.
The message carries the absolute path and the literal recovery command so the user does not have to guess which file to move aside.
default_error_code: str
Section titled “default_error_code: str”Subclass override — the stable string enum surfaced via error_code.
error_code: str
Section titled “error_code: str”message: str
Section titled “message: str”reason: str
Section titled “reason: str”request_id: str | None
Section titled “request_id: str | None”source: str | None
Section titled “source: str | None”class mostlyright.weather.hazards.HazardGaugeSchema
Section titled “class mostlyright.weather.hazards.HazardGaugeSchema”Bases: Schema
One row per gauge, per capture, per route scope.
Gauge reference data has no upstream issuance stamp: the payload says what
is true now and never says when it became true. The honest knowledge time
is therefore captured_at — when this install read it — and the source
contract declares exactly that rather than inventing a publication time.
Flood thresholds are mostly ABSENT. In a gauge sample, 47% carried no
stage thresholds at all and only 10% carried all four stage AND all four
flow thresholds. thresholds_defined makes that a column instead of a
surprise, and every absent threshold is NULL rather than the -9999 the
upstream serves.
schema_id: ClassVar[str]
Section titled “schema_id: ClassVar[str]”class mostlyright.weather.hazards.HazardHotspotSchema
Section titled “class mostlyright.weather.hazards.HazardHotspotSchema”Bases: Schema
One row per thermal detection in one satellite overpass.
The column order is the role manifest applied to this product: a hotspot’s identity is the platform and instrument family that saw it plus the ground pixel it saw, so those five columns lead; the overpass instant follows; the measurements come next; provenance closes. Leading with a timestamp would read as if time were the key, and it is not — the same pixel is re-detected on every pass.
The knowledge time and the event time are deliberately different. An overpass happens hours before the detection is published, so the overpass instant is when the fire burned, not when this install could have known about it.
schema_id: ClassVar[str]
Section titled “schema_id: ClassVar[str]”class mostlyright.weather.hazards.HazardProductSpec(tier, schema_id, verbs, ts_verbs, source_tags, raw_columns, knowledge_time_column, event_time_column)
Section titled “class mostlyright.weather.hazards.HazardProductSpec(tier, schema_id, verbs, ts_verbs, source_tags, raw_columns, knowledge_time_column, event_time_column)”Bases: object
One hazard product: its cache tier, schema, verbs, tags, and time columns.
tier is the noun everything else derives from — the schema id is
schema.hazard.{tier}.v1 and the cache tier directory is {tier}.
verbs is a tuple because one product can be reached by more than one
verb (post-storm reports are text products filtered to a single product
code); ts_verbs is its positional camelCase twin.
- Parameters:
event_time_column: str
Section titled “event_time_column: str”knowledge_time_column: str
Section titled “knowledge_time_column: str”raw_columns: tuple[str, ...]
Section titled “raw_columns: tuple[str, ...]”schema_id: str
Section titled “schema_id: str”source_tags: frozenset[str]
Section titled “source_tags: frozenset[str]”ts_verbs: tuple[str, ...]
Section titled “ts_verbs: tuple[str, ...]”class mostlyright.weather.hazards.HazardRatingCurveSchema
Section titled “class mostlyright.weather.hazards.HazardRatingCurveSchema”Bases: Schema
One row per point of a gauge’s official stage-to-flow curve.
This is the upstream’s own curve, not a fit. It already serves cubic feet
per second, so there is no second flow column and no unit string beside the
number — the upstream unit field survives verbatim in raw_json like
every other upstream field.
schema_id: ClassVar[str]
Section titled “schema_id: ClassVar[str]”exception mostlyright.weather.hazards.HazardRetentionError(message=”, , field=None, expected=None, actual=None, location=None, doc_url=None, source=None, request_id=None, error_code=None)
Section titled “exception mostlyright.weather.hazards.HazardRetentionError(message=”, , field=None, expected=None, actual=None, location=None, doc_url=None, source=None, request_id=None, error_code=None)”Bases: ContractError
The requested window is older than the live route’s retention limit.
api.weather.gov/alerts and api.weather.gov/products both retain
exactly 7 days: a start seven days back returns rows, and eight days
back returns zero features. Asking the live route for an older window is a
caller contract violation caught BEFORE any I/O — it is not a
data-availability failure and it never degrades to an empty frame.
The fix the message carries is a source= pin, not a second verb name:
source="iem" routes the same call at the Iowa State VTEC/AFOS archive,
which has no 7-day cliff. Keeping the fix on a parameter rather than on a
separate archive verb is what lets this teaching error survive a verb
rename.
Takes the ContractError signature unchanged
(field/expected/actual) so the structured teaching payload is
the same one every other contract violation in the SDK emits.
- Parameters:
- Return type: None
actual: str | None
Section titled “actual: str | None”default_error_code: str
Section titled “default_error_code: str”Subclass override — the stable string enum surfaced via error_code.
doc_url: str | None
Section titled “doc_url: str | None”error_code: str
Section titled “error_code: str”expected: str | None
Section titled “expected: str | None”location: str | None
Section titled “location: str | None”message: str
Section titled “message: str”request_id: str | None
Section titled “request_id: str | None”source: str | None
Section titled “source: str | None”class mostlyright.weather.hazards.HazardStageAndFlowSchema
Section titled “class mostlyright.weather.hazards.HazardStageAndFlowSchema”Bases: Schema
One row per (gauge x series x valid time x issuance x capture).
The discriminator is spelled series, never product: product_code,
product_id, and product_version already exist in this schema family
naming an NWS text bulletin, and one word must not name two things.
Only ONE forecast issuance is ever served, and no route parameter exposes a
prior one. A forecast this install does not capture is permanently
unrecoverable, which is why captured_at is part of the dedup key here
and why the store is append-only.
schema_id: ClassVar[str]
Section titled “schema_id: ClassVar[str]”class mostlyright.weather.hazards.HazardTextProductSchema
Section titled “class mostlyright.weather.hazards.HazardTextProductSchema”Bases: Schema
One row per NWS/NHC text bulletin (TCP, TCM, TCD, TCV, PSH).
The parsed columns are best-effort. raw_afos holds the verbatim
bulletin and is non-nullable, so a parse miss degrades to
parse_status="raw_only" rather than raising or fabricating a value —
PSH station tables are free text and every forecast office formats them
slightly differently.
The full product_id, any amendment suffix (‘-AAA’) included, is the
vintage key. Deduping on (office, storm) would silently drop a correction.
schema_id: ClassVar[str]
Section titled “schema_id: ClassVar[str]”class mostlyright.weather.hazards.HazardTropicalCycloneSchema
Section titled “class mostlyright.weather.hazards.HazardTropicalCycloneSchema”Bases: Schema
One row per active tropical cyclone in an NHC CurrentStorms.json pull.
Off-season the document serves {"activeStorms": []} — an empty list,
not a 404 and not a missing key. That is a correct answer about the world,
so an enumeration over it returns a schema-valid zero-row frame rather than
raising.
NHC types several of these fields as strings (intensity, pressure,
latitude, longitude, advNum all arrive quoted). The numeric
columns here hold the coerced value and raw_json holds the verbatim
object, so the original spelling is never lost.
schema_id: ClassVar[str]
Section titled “schema_id: ClassVar[str]”class mostlyright.weather.hazards.HazardWaterObservationSchema
Section titled “class mostlyright.weather.hazards.HazardWaterObservationSchema”Bases: Schema
One row per (time series x instant x upstream revision).
This is the only flood product whose vintage key comes from UPSTREAM.
Every row carries its own last_modified_at_utc, so re-fetching an
unrevised row is idempotent — structurally the opposite of the
capture-or-lose forecast product beside it.
schema_id: ClassVar[str]
Section titled “schema_id: ClassVar[str]”class mostlyright.weather.hazards.HazardWildfireSchema
Section titled “class mostlyright.weather.hazards.HazardWildfireSchema”Bases: Schema
One row per (incident x capture), from either incident feed.
The two feeds populate disjoint subsets of the columns, which is why almost everything here is nullable. The interagency feed carries the fire-report identifier, the discovery and final acreages, the coordination centre, the dispatch centre, the personnel count, the cost to date, and the complex columns; the state feed carries the active/final flags, the administrative unit, the agency list, the incident page, and the coordinates. Coordinates are state-feed-only because the interagency route is queried with geometry off — that is this tier’s hard constraint, not an oversight.
captured_at is the knowledge time. The upstream modified_at_utc
stamp is not: 9,098 of the 14,587 out-fires in a year-to-date sample were
modified more than a day after being declared out with the acreage frozen,
so it records paperwork rather than when a number became knowable.
schema_id: ClassVar[str]
Section titled “schema_id: ClassVar[str]”class mostlyright.weather.hazards.IssuingOffice(code, name, product_code)
Section titled “class mostlyright.weather.hazards.IssuingOffice(code, name, product_code)”Bases: object
One office that issues a given text product, with the city it covers.
A record rather than a dict[str, str]: a caller reads office.name
instead of guessing which side of a mapping holds the city.
product_code: str
Section titled “product_code: str”exception mostlyright.weather.hazards.NoHazardDataError(identifier, reason, , source=‘hazards.cache’, request_id=None, error_code=None)
Section titled “exception mostlyright.weather.hazards.NoHazardDataError(identifier, reason, , source=‘hazards.cache’, request_id=None, error_code=None)”Bases: NoDataError
A named hazard identity produced no rows.
Raised by the mostlyright.weather.hazards identity verbs — the ones that
are handed a specific storm id, product id, or zone — instead of returning
[]/None. Carries the identifier that was asked for, the
human-readable reason, and the source tag of the route that answered.
Parented on NoDataError, NOT on NoLiveDataError: a valid
finite query that returns zero rows is a zero-row answer, not a
live-capture failure. The shape (positional identifier + reason,
keyword-only source, the _payload override) mirrors
NoCWOPDataError; only the parentage differs, deliberately.
Enumeration verbs do NOT raise this. hazards.tropical_cyclones() in a
quiet season returns a schema-valid EMPTY frame, because
CurrentStorms.json serving {"activeStorms": []} is a correct answer
about the world. A missing NAMED identity is not.
- Parameters:
- Return type: None
default_error_code: str
Section titled “default_error_code: str”Subclass override — the stable string enum surfaced via error_code.
error_code: str
Section titled “error_code: str”identifier: str
Section titled “identifier: str”message: str
Section titled “message: str”reason: str
Section titled “reason: str”request_id: str | None
Section titled “request_id: str | None”source: str | None
Section titled “source: str | None”mostlyright.weather.hazards.alerts(, source=None, zone=None, area=None, point=None, event=None, from_time=None, to_time=None, active=False, phenomena=None, significance=None, wfo=None, persist=False, client=None)
Section titled “mostlyright.weather.hazards.alerts(, source=None, zone=None, area=None, point=None, event=None, from_time=None, to_time=None, active=False, phenomena=None, significance=None, wfo=None, persist=False, client=None)”Read NWS CAP alerts at the (issuance x UGC zone) grain.
One verb, two providers. source=None reads the live NWS CAP window;
source="iem" reads the Iowa State VTEC archive, which has no retention
limit. Both return schema.hazard.alert.v1 with the same columns.
- Parameters:
- source (
Literal['nws','iem'] |None) –Noneserves the live NWS window,"nws"pins that same leg,"iem"pins the Iowa State VTEC archive. Any other value raises before a request is issued. - zone (
str|None) – One UGC zone or county code, e.g."FLC086". On the archive leg this filters the parsed rows onugc_zone. - area (
str|None) – One two-letter state or marine area, e.g."FL". - point (
tuple[float,float] |None) –(latitude, longitude)in degrees, e.g.(25.7617, -80.1918). Live leg only. - event (
str|None) – NWS event name, e.g."Hurricane Warning". Live leg only — the archive serves VTEC codes, so pinphenomena/significancethere instead. - from_time (
datetime|None) – Window start. Alerts are instant-grained, so the window isfrom_time/to_time; there is nofrom_date. - to_time (
datetime|None) – Window end. - active (
bool) – Read/alerts/active— the alerts in force right now. Cannot be combined with a window or withsource="iem". - phenomena (
str|None) – VTEC phenomena code, e.g."HU". On the archive leg this narrows the query itself; on the live leg the CAP route has no such parameter, so it filters the parsed rows on thephenomenacolumn the VTEC decomposition already produced. - significance (
str|None) – VTEC significance code, e.g."W". Same two legs asphenomena. - wfo (
str|None) – Forecast office, e.g."MLB". Same two legs asphenomena. - persist (
bool) – Write the returned rows to the local vintage store before returning them. Two calls append two vintages; nothing is overwritten. - client (
Client|None) – Anhttpx.Clientto reuse. A client passed in is never closed here.
- source (
- Returns:
alert_id, ugc_zone, event, message_type, severity, certainty, urgency, sent_at_utc, effective_at_utc, onset_at_utc, expires_at_utc, ends_at_utc, vtec_class, vtec_action, vtec_office, wfo, phenomena, significance, event_tracking_number, vtec_year, vtec_string, references, product_id, sender_name, headline, area_description, raw_cap, raw_csv, captured_at, source. A window with no alerts in it returns zero rows, not an error. - Return type:
A
schema.hazard.alert.v1DataFrame. Columns, in order - Raises:
- ContractError –
sourceis not a recognised provider; more than one ofzone/area/pointwas passed;pointis not a degree pair inside[-90, 90]/[-180, 180];active=Truewas combined with a window or withsource="iem"; the archive leg was asked forpointorevent, or was called without a window. - HazardRetentionError –
from_timepredates the 7-day live retention andsourceisNoneor"nws". The message namessource="iem"as the route that answers it. - ValueError –
from_timeis afterto_time. - httpx.HTTPStatusError – A provider answered non-2xx.
- ContractError –
Source:
: Accepted source= values are None, "nws", and "iem".
None routes to the live NWS CAP window. The per-row source
column and df.attrs["source"] always name the leg that actually
answered — "nws.alerts.live" or "iem.vtec.archive" — so a
caller can always tell live from archive. An unrecognised value raises
ContractError before any network
call; it never falls back to the other provider.
alerts.weather.gov — the host the hurricane-category contract rules
cite for their per-zone CAP feed — returns NXDOMAIN on two independent
public resolvers, measured August 2026. zone= on this verb is the
live equivalent of that per-zone CAP URL: it reads the same alerts for
the same UGC zone from api.weather.gov/alerts.
Examples
Section titled “Examples”Read the alerts in force for one Florida zone:
from mostlyright.weather import hazards
df = hazards.alerts(zone="FLC086", active=True)df[["alert_id", "event", "vtec_action", "sent_at_utc", "source"]]Read the same zone two years back, through the archive:
from datetime import UTC, datetime
df = hazards.alerts( source="iem", zone="FLC086", from_time=datetime(2024, 10, 1, tzinfo=UTC), to_time=datetime(2024, 10, 15, tzinfo=UTC), phenomena="HU", significance="W",)df.attrs["source"] # 'iem.vtec.archive'mostlyright.weather.hazards.best_track(storm_id, , revision=None, persist=False, client=None)
Section titled “mostlyright.weather.hazards.best_track(storm_id, , revision=None, persist=False, client=None)”Return the HURDAT2 best-track fixes for one storm, from one reanalysis.
Lists the published revisions for the storm’s basin, selects one, downloads
that file, and parses only that storm’s records. Every returned row carries
hurdat2_revision and hurdat2_filename, so the frame always states
which reanalysis answered.
- Parameters:
- storm_id (
str) – A HURDAT2 storm id, e.g."AL142024". Case-insensitive; upper-cased before use. A malformed id raises before any request. - revision (
date|None) – The reanalysis vintage to read, as its published date. Omit it to read the latest published revision. - persist (
bool) – Write the returned rows to the local vintage store. Re-running this call for the same revision does not duplicate rows: the revision is itself the vintage, so a second download is idempotent. - client (
Client|None) – Anhttpx.Clientto reuse. A client passed in is never closed here.
- storm_id (
- Return type:
DataFrame - Returns:
A
schema.hazard.best_track.v1DataFrame withattrs["source"] == "nhc.hurdat2", one row per synoptic fix.landfall_flag == "L"marks the fix at which the centre crossed the coastline — the settlement-relevant flag for “hurricane hits X” contracts. - Raises:
- ContractError –
storm_idis not a HURDAT2 storm id. - NoHazardDataError – the storm is absent from the selected revision, the
requested
revisionwas never published, or NHC publishes no revision for the storm’s basin. The message names the storm and the revision searched. - httpx.HTTPStatusError – NHC answered non-2xx.
- ContractError –
HURDAT2 is reanalysed annually, and several revisions of the same
season coexist upstream. A call without an explicit revision
returns the latest one, which is a value that did not exist while the
storm was happening. A best-track row whose hurdat2_revision
post-dates a research as_of is the warning sign of leakage: the
model is reading numbers NHC had not published yet.
Examples
Section titled “Examples”Read the latest reanalysis of one storm:
from mostlyright.weather import hazards
df = hazards.best_track("AL142024")df.loc[df["landfall_flag"] == "L", ["valid_at_utc", "max_wind_kt"]]Pin the reanalysis that was current at an earlier decision time:
from datetime import date
df = hazards.best_track("AL142024", revision=date(2025, 2, 27))mostlyright.weather.hazards.best_track_revisions(, basin=‘atl’, client=None)
Section titled “mostlyright.weather.hazards.best_track_revisions(, basin=‘atl’, client=None)”List every published HURDAT2 revision for basin, oldest first.
This is the catalog verb, and it deliberately returns
BestTrackRevision
objects rather than a DataFrame. Every other verb here returns a frame
because it returns observations — rows about the world. This one describes
files: a basin, a filename, a publication date, and a URL. Wrapping four
file attributes in a schema-validated frame would imply a row grain and a
knowledge time that a directory listing does not have.
Nothing is deduplicated. The 1851-2022 Atlantic archive has five coexisting revisions, and that multiplicity is the vintage record: the values NHC published for a 2022 storm in 2022 and in 2025 differ.
- Parameters:
- Return type:
tuple[BestTrackRevision,...] - Returns:
A tuple sorted ascending by
revision_date— the parsed date, never the filename string. - Raises:
- ContractError –
basinis not a published HURDAT2 basin. - ValueError – a listing entry named like a best-track file could not be parsed into a revision date.
- httpx.HTTPStatusError – NHC answered non-2xx.
- ContractError –
Examples
Section titled “Examples”See which reanalysis vintages exist before pinning one:
from mostlyright.weather import hazards
for revision in hazards.best_track_revisions(): print(revision.revision_date, revision.filename)mostlyright.weather.hazards.capture_history(product, from_time, to_time, , vintages=‘all’, as_of=None, identifier=None)
Section titled “mostlyright.weather.hazards.capture_history(product, from_time, to_time, , vintages=‘all’, as_of=None, identifier=None)”Return the persisted hazard captures for product in [from_time, to_time].
Reads the local vintage store, selects a vintage, and returns a validated
frame for the product’s schema tagged source="hazards.cache". The window
is inclusive on both ends; a bare date covers the whole calendar day in
UTC. Most products window on captured_at; best_track,
stage_and_flow, and water_observation window on valid_at_utc,
and hotspot windows on acquired_at_utc — because one download of
each carries a long span of instants, and for detections that instant is
the overpass rather than the download.
- Parameters:
-
product (
Literal['alert','text_product','tropical_cyclone','best_track','gauge','stage_and_flow','rating_curve','water_observation','wildfire','hotspot']) – one of"alert","text_product","tropical_cyclone","best_track","gauge","stage_and_flow","rating_curve","water_observation","wildfire","hotspot". -
from_time (
date|datetime) – window start (inclusive).dateordatetime. -
to_time (
date|datetime) – window end (inclusive).dateordatetime. -
vintages (
Literal['all','latest']) –"all"returns every capture in the window (the append-only record);"latest"returns the newest capture per identity —(alert_id, ugc_zone)for alerts,(product_id)for text products,(storm_id, last_update_at_utc)for tropical cyclones,(storm_id, valid_at_utc)for best track,(gauge_id, record_scope)for gauges,(gauge_id, series, valid_at_utc, forecast_issued_at_utc)for stage and flow,(gauge_id, stage_ft)for a rating curve,(gauge_id, parameter_code, time_series_id, valid_at_utc)for water observations,(incident_id)for wildfire incidents, and(acquired_at_utc, latitude, longitude, satellite)for detections — a detection carries no upstream id, so its identity is the overpass instant plus the ground pixel plus the platform.A REVISION of one thing is not a second thing.
hotspot.product_versionis part of the stored key, so the near-real-time reading and the calibrated standard-quality reading that replaces it months later both survive on disk — but it is NOT part of the identity above, sovintages="latest"returns ONE row for that overpass, the newest capture. Counting detections over a window that straddles the substitution would otherwise count every re-processed one twice.Named to match
economy.series(vintages=…), which selects a vintage mode the same way; the value sets differ because the semantics do. -
as_of (
datetime|None) – restrict to rows whose vintage is at or before this instant, applied before thevintagesreduction. This is the point-in-time replay: pass the decision time and the result is what was knowable then. Anas_ofin the future is accepted and filters nothing. -
filter on the product’s primary identity column —
alert_idfor alerts,product_idfor text products,storm_idfor both tropical products,gauge_idfor all four flood products, andincident_idforwildfire. Forwater_observationthat is the id the CALLER captured with, not the upstreamtime_series_id: the filter selected the series id beforegauge_idled that product’s identity, so passing the site number returned a silently empty frame.The value is CANONICALIZED with the same rule the product’s verb applies before it is compared, so every spelling that reaches the same rows through the verb reaches them here:
"abbg1"," ABBG1 ", and"ABBG1"are one query on the three NWPS products,"02215260"," 02215260 ", and"USGS-02215260"are one query onwater_observation, and a braced or lower-cased incident id is one query onwildfire. A value that cannot be an identifier of this product raisesContractErrorrather than returning an empty frame.NOT ACCEPTED on
"hotspot", which raises. A detection carries no upstream identifier — its identity is the whole(acquired_at_utc, latitude, longitude, satellite)tuple — so no string could ever match, and every value passed returned a silently empty frame. Read the window and filter the returned frame instead.
-
- Return type:
DataFrame - Returns:
A schema-valid DataFrame with
attrs["source"] == "hazards.cache". - Raises:
- ContractError –
productorvintagesis not a recognised value. - ValueError –
from_timeis afterto_time. - HazardCacheCorruptError – a partition in range exists but is unreadable.
- ContractError –
A window with nothing persisted returns a valid empty frame; it does not raise. This reads a local store, so an empty result is a fact about when this install started capturing — the left edge of the local record — not a fetch failure. The network verbs behave the other way and raise when a window has fallen out of upstream retention.
mostlyright.weather.hazards.gauges(, bbox=None, gauge_id=None, srid=‘EPSG_4326’, max_tile_degrees=10.0, persist=False, client=None)
Section titled “mostlyright.weather.hazards.gauges(, bbox=None, gauge_id=None, srid=‘EPSG_4326’, max_tile_degrees=10.0, persist=False, client=None)”Return NWPS river gauges — a bounding box of them, or one by identifier.
One verb, two scopes, one identifier word. bbox= enumerates the gauge
INVENTORY inside a box, tiling the request because the route serves no
pagination and its unfiltered answer was measured at 13 MB and a gateway
timeout. gauge_id= returns the ONE-row DETAIL record, which is the only
place the identifier crosswalk, the timezone, and the flood-category
thresholds are served. record_scope says which shape a row is.
- Parameters:
- bbox (
tuple[float,float,float,float] |None) –(xmin, ymin, xmax, ymax)— west, south, east, north — in the units ofsrid. Split into tiles no larger thanmax_tile_degreeson either axis. An inverted, zero-width, or off-the-globe box raises before any request. - gauge_id (
str|None) – An NWS Location ID ("ABBG1") or a bare USGS site number ("02215260"). The route accepts both on the same path and was measured answering identically, which is why there is one argument rather than two. Stripped and upper-cased once, and that canonical spelling is both what is requested and what the row’sgauge_idcolumn carries. - srid (
str) – Spatial reference of thebboxcorners. Always sent: the route answers a missing or unrecognised projection with HTTP 200 and an EMPTY gauge list, so there is no code path here that omits it. - max_tile_degrees (
float) – Largest tile a single request will cover. - persist (
bool) – Write the returned rows to the local vintage store before returning them. Gauge reference data carries no upstream publication stamp, socaptured_atIS the vintage and every refresh appends beside the last one rather than replacing it. - client (
Client|None) – Anhttpx.Clientto reuse. A client passed in is never closed here.
- bbox (
- Return type:
DataFrame - Returns:
A
schema.hazard.gauge.v1DataFrame withattrs["source"] == "nwps.gauge.live". On aninventoryrowusgs_site_id,reach_id,timezone, and the eight threshold columns are NULL because the LIST route omits them, not because the gauge lacks them — readrecord_scopeto tell the two apart. - Raises:
- ContractError – both selectors were passed, or neither; the box is
inverted, zero-width, or off the globe;
sridis not a documented projection; or the route answered with something that is not JSON. - NoHazardDataError –
gauge_idaddresses no gauge. An identity failure is never converted into an empty frame. - httpx.HTTPStatusError – A route answered non-2xx.
- ContractError – both selectors were passed, or neither; the box is
inverted, zero-width, or off the globe;
Flood thresholds are mostly ABSENT upstream. In a 60-gauge sample only
10% carried all four stage AND all four flow thresholds, and 47%
carried no stage thresholds at all. thresholds_defined makes that a
column, and every undefined threshold is NULL rather than the -9999
the upstream serves. Building a complete threshold table would cost
12,820 detail requests at roughly 47 kB each against a host with no
rate-limit headers and a measured HTTP 504, so no crawl verb ships:
thresholds are fetched one gauge at a time, on demand.
Examples
Section titled “Examples”Enumerate a basin:
from mostlyright.weather import hazards
df = hazards.gauges(bbox=(-84.0, 33.0, -83.0, 34.0))df[["gauge_id", "name", "observed_flood_category"]]Read one gauge’s thresholds and crosswalk:
detail = hazards.gauges(gauge_id="ABBG1")detail[["usgs_site_id", "action_stage_ft", "major_stage_ft"]]An empty tile is an answer; an unknown gauge is an error:
len(hazards.gauges(bbox=(-140.0, 5.0, -139.0, 6.0))) # 0hazards.gauges(gauge_id="ZZZZ9") # NoHazardDataErrormostlyright.weather.hazards.hotspots(, source=None, sensor=‘viirs_snpp’, region=‘usa’, from_time=None, to_time=None, bbox=None, persist=False, client=None)
Section titled “mostlyright.weather.hazards.hotspots(, source=None, sensor=‘viirs_snpp’, region=‘usa’, from_time=None, to_time=None, bbox=None, persist=False, client=None)”Return satellite active-fire detections — where an instrument saw heat.
hotspots() with no arguments downloads the published file for the
contiguous United States and Hawaii and returns its detections. It needs no
FIRMS registration and reads no MOSTLYRIGHT_FIRMS_MAP_KEY; like every
other source in this SDK its address is resolved through the manifest
catalog, which reads MOSTLYRIGHT_API_KEY.
- Parameters:
- source (
Literal['firms.bulk','firms.area'] |None) –Noneand"firms.bulk"take the keyless published files;"firms.area"takes the bounding-box route, which needs a key of your own inMOSTLYRIGHT_FIRMS_MAP_KEY(free, one form). Any other value raises before a request is issued. - sensor (
str) – The platform —"viirs_snpp"(the default),"viirs_noaa20","viirs_noaa21", or"modis". The two instrument families report confidence in incomparable units, which is why a row carries eitherconfidence_category(a word) orconfidence_percent(a number) andsensor_familysays which. - region (
str) – Which published file to read —"usa"(the default),"alaska","canada", or"global". Keyless route only: the bounding-box route is addressed by coordinates, and passing both raises rather than ignoring one. - from_time (
datetime|None) – Start of the window, timezone-aware. Given together withto_timeor not at all. - to_time (
datetime|None) – End of the window, timezone-aware. - bbox (
tuple[float,float,float,float] |None) –(west, south, east, north)in degrees. REQUIRED onsource="firms.area"and refused on the keyless route, which serves fixed regional files. - persist (
bool) – Write the returned rows to the local vintage store before returning them. It writes parquet vintages of the parsed rows; the downloaded CSV is a temporary intermediate and is never kept. - client (
Client|None) – Anhttpx.Clientto reuse. A client passed in is never closed here.
- source (
- Return type:
DataFrame - Returns:
A
schema.hazard.hotspot.v1DataFrame carryingattrs["source"]("firms.bulk.live"or"firms.area.live"),attrs["retrieved_at"],attrs["attribution"], and the same acknowledgment in theattributionCOLUMN of every row. On a keyless rowregionandwindowsay which published file the detection came from, spelled as the upstream file-name tokens ("USA_contiguous_and_Hawaii","24h") rather than as the shortregion=name; both are NULL on a bounding-box row. A pass that detected nothing returns a valid EMPTY frame — that is the healthy answer for a quiet region. - Raises:
- ContractError –
source,sensor,region,bbox, or a window bound is unusable;bbox=was passed to the keyless route or omitted from the bounding-box one;region=was passed to the bounding-box route; the window is wider than the route can serve; or no key is configured for the keyed route. Every one of these is refused before or instead of a request, and none of them falls back to the other route. - SourceUnavailableError – a route answered non-2xx, or served a body that is not an active-fire file.
- RateLimitError – the key’s transaction budget was spent, or the route answered HTTP 429 on every attempt.
- ApiKeyRequiredError – no API key is configured for catalog resolution.
This is the SDK’s own
MOSTLYRIGHT_API_KEY, not the FIRMS one, and it is raised on BOTH routes — the keyless tier is keyless of the FIRMS registration.
- ContractError –
THE PUBLISHED FILES ARE FIXED TRAILING WINDOWS of 24 hours, 48 hours,
and 7 days. This verb picks the smallest one that covers from_time
and then filters the parsed rows to the window you asked for, so a
narrower request costs the same download. A window reaching further back
than the widest file raises and names the bounding-box route, which
takes a past date directly (at most 5 days per call).
THE KEY IS A URL PATH SEGMENT, not a header. Calling the bounding-box route from a browser exposes the key to anyone who can read the request, so both routes are Node-only in the TypeScript twin and no proxy is offered. The documented budget is 5000 transactions / 10-minute interval, and a multi-day request may count as several.
THE SAME OVERPASS CAN YIELD DIFFERENT ROWS. Near-real-time detections
are replaced by the calibrated standard-quality product after roughly
three months, so product_version is part of a stored row’s WRITE key
and both readings survive in the vintage store. It is not part of the
read-time identity: capture_history("hotspot", …, vintages="latest") returns one row per detection, the newest capture,
because a re-processing revises that overpass rather than adding a
second one.
Examples
Section titled “Examples”What a satellite saw over the United States in the last day:
from mostlyright.weather import hazards
df = hazards.hotspots()df[["acquired_at_utc", "latitude", "longitude", "brightness_k", "source"]]A narrower window, still keyless:
from datetime import UTC, datetime, timedelta
now = datetime.now(UTC)df = hazards.hotspots(from_time=now - timedelta(hours=6), to_time=now)An arbitrary box, with a key of your own:
df = hazards.hotspots( source="firms.area", bbox=(-125.0, 32.0, -114.0, 42.0), from_time=now - timedelta(days=2), to_time=now,)mostlyright.weather.hazards.issuing_offices(, product_code=‘PSH’, client=None)
Section titled “mostlyright.weather.hazards.issuing_offices(, product_code=‘PSH’, client=None)”Return the offices that issue product_code, read from the endpoint.
A function, not a module constant. The office map is served by
/products/types/{code}/locations, and a hardcoded copy would silently
drop an office that started issuing after this module was written — for a
settlement product, that reads as “no report was filed”.
- Parameters:
- Return type:
list[IssuingOffice] - Returns:
One
IssuingOfficeper office, sorted bycode. Readoffice.codefor the id to pass asissuing_office=andoffice.namefor the city it covers. - Raises:
- ContractError –
product_codeis not a supported code. - SourceUnavailableError – the endpoint answered non-2xx or served no office mapping.
- ContractError –
Examples
Section titled “Examples”Find which office covers the city you are settling against:
from mostlyright.weather import hazards
for office in hazards.issuing_offices(): print(office.code, office.name)mostlyright.weather.hazards.persist_rows(product, rows)
Section titled “mostlyright.weather.hazards.persist_rows(product, rows)”Merge rows into their monthly partitions. Append-only and idempotent.
Groups rows by the (year, month) of the product’s partition time column,
then, per partition, takes one FileLock, re-reads the existing table under
it, merges, dedups on the vintage key, and writes atomically. A re-capture
never overwrites an earlier one: two rows differing only in captured_at
are two vintages and both stay on disk.
best_track is the exception in both respects — it partitions on
valid_at_utc and its key excludes captured_at, so downloading the same
HURDAT2 revision twice leaves one row.
There is no current-month write-skip. The current month is exactly what a live capture needs to retain, because the live CAP feed will not serve it again.
Returns the total row count of every partition touched, after the merge.
Returns 0 for an empty rows.
- Raises:
- TypeError –
productis not astr. - ValueError –
productis unregistered or not path-safe, or a row carries asourcethe product’s schema does not accept.
- TypeError –
- Return type:
int - Parameters:
mostlyright.weather.hazards.post_storm_reports(, source=None, issuing_office=None, from_time=None, to_time=None, product_id=None, persist=False, client=None)
Section titled “mostlyright.weather.hazards.post_storm_reports(, source=None, issuing_office=None, from_time=None, to_time=None, product_id=None, persist=False, client=None)”Read NWS post-storm reports (PSH) with their header block parsed.
Identical to text_products(product_code="PSH", ...) plus the PSH header
parse, which reads the WMO product identifier, the AWIPS identifier, the
storm name, and any amendment marker off the bulletin heading.
The full product_id, amendment suffix included, is the key, so two
amendments of one report appear as two rows. A PSH is the literal
settlement text for the city “hurricane hits X” contracts, and a correction
that silently replaced its original would erase the record of what was
settled against.
- Parameters:
- source (
Literal['nws','iem'] |None) – Same three values astext_products(). - issuing_office (
str|None) – One office, e.g."KLIX". Read the current list fromissuing_offices(). - from_time (
datetime|None) – Window start. - to_time (
datetime|None) – Window end. - product_id (
str|None) – Read one report by id. - persist (
bool) – Write the returned rows to the local vintage store. - client (
Client|None) – Anhttpx.Clientto reuse.
- source (
- Return type:
DataFrame - Returns:
A
schema.hazard.text_product.v1DataFrame withproduct_code == "PSH"on every row. - Raises:
- ContractError – as
text_products(). - HazardRetentionError – as
text_products(). - NoHazardDataError –
product_idnames a report that does not exist.
- ContractError – as
No per-station table is extracted. Every office formats its peak-wind,
storm-surge, and rainfall tables differently, and a column-position
parser written against one office reports a number from the wrong
column on the next. raw_afos holds the whole report, so that
extraction can be added later as a re-parse of rows already on disk.
Examples
Section titled “Examples”Read the reports one office filed for a storm:
from datetime import UTC, datetime
from mostlyright.weather import hazards
df = hazards.post_storm_reports( source="iem", issuing_office="KLIX", from_time=datetime(2026, 7, 30, tzinfo=UTC), to_time=datetime(2026, 7, 31, tzinfo=UTC),)df[["product_id", "amendment_suffix", "storm_name"]]mostlyright.weather.hazards.rating_curve(gauge_id, , persist=False, client=None)
Section titled “mostlyright.weather.hazards.rating_curve(gauge_id, , persist=False, client=None)”Return a gauge’s official stage-to-flow curve, whole and ascending.
- Parameters:
- gauge_id (
str) – An NWS Location ID or a bare USGS site number. Echoed onto every row. - persist (
bool) – Write the returned rows to the local vintage store. A curve carries no upstream publication stamp, socaptured_atis its vintage and every refresh appends beside the last one. - client (
Client|None) – Anhttpx.Clientto reuse. A client passed in is never closed here.
- gauge_id (
- Return type:
DataFrame - Returns:
A
schema.hazard.rating_curve.v1DataFrame withattrs["source"] == "nwps.rating_curve.live", sorted ASCENDING bystage_ft. This route serves cubic feet per second, which is what makesflow_cfshere directly comparable to a gauge’s*_flow_cfsflood threshold. - Raises:
- ContractError –
gauge_idis not addressable, or the payload reports flow in a unit the shared table does not convert. - NoHazardDataError –
gauge_idaddresses no gauge. - httpx.HTTPStatusError – The route answered non-2xx.
- ContractError –
There is deliberately no limit, sort, or only_tenths
argument. The measured curve is 303 rows and 16,169 bytes and is always
returned whole, so three interacting options over a small ordered table
buy a caller nothing that .head(10) does not already give them.
Examples
Section titled “Examples”Convert a forecast stage to a flow:
from mostlyright.weather import hazards
curve = hazards.rating_curve("ABBG1")curve.head(10)[["stage_ft", "flow_cfs"]]mostlyright.weather.hazards.stage_and_flow(gauge_id, , series=None, persist=False, client=None)
Section titled “mostlyright.weather.hazards.stage_and_flow(gauge_id, , series=None, persist=False, client=None)”Return a gauge’s observed and/or forecast stage-and-flow points.
series=None reads the combined document and returns BOTH products in
one frame, discriminated by the series column. series="observed" or
series="forecast" reads only that block.
- Parameters:
- gauge_id (
str) – An NWS Location ID or a bare USGS site number. Echoed onto every row. - series (
Literal['observed','forecast'] |None) –"observed","forecast", orNonefor both. The argument is calledseriesand notproductbecauseproduct_codealready names an NWS text bulletin in this schema family, and one word must not name two things. - persist (
bool) – Write the returned rows to the local vintage store. Two DIFFERENT issuances both survive; re-capturing ONE issuance at the same instant stores one copy. - client (
Client|None) – Anhttpx.Clientto reuse. A client passed in is never closed here.
- gauge_id (
- Return type:
DataFrame - Returns:
A
schema.hazard.stage_and_flow.v1DataFrame withattrs["source"] == "nwps.stage_and_flow.live", observed rows first.flow_kcfsis the verbatim upstream number andflow_cfsthe converted one — this route serves kilo-cubic-feet per second while the gauge threshold and rating routes serve cubic feet per second, so onlyflow_cfsis comparable to a*_flow_cfsthreshold. - Raises:
- ContractError –
seriesis not one of the two the route serves,gauge_idis not addressable, a block reports stage in a unit other than feet, or a block carries points but no issuance time. - NoHazardDataError –
gauge_idaddresses no gauge. - httpx.HTTPStatusError – The route answered non-2xx.
- ContractError –
CAPTURE-OR-LOSE. Exactly one forecast issuance is ever served and no
route parameter asks for a prior one, so an issuance this install did
not capture is permanently unrecoverable — which is what persist=
exists for. A gauge with no current forecast answers HTTP 200 with a
zero-valued block and yields ZERO rows; fewer than one gauge in four
carries a live forecast at any moment, so that is the ordinary answer.
Forecast rows carry TWO stamps. forecast_issued_at_utc is the River
Forecast Center issuance and is the vintage; generated_at_utc is
the per-point production stamp, measured five minutes and thirteen
seconds later. Observed rows populate latest_observation_at_utc
instead, because on that block the same upstream field is merely the
newest point’s timestamp rather than an issuance.
Examples
Section titled “Examples”Read both series at once:
from mostlyright.weather import hazards
df = hazards.stage_and_flow("ABBG1")df.loc[df["series"] == "forecast", ["valid_at_utc", "stage_ft"]]Capture each issuance as it is published:
hazards.stage_and_flow("ABBG1", series="forecast", persist=True)mostlyright.weather.hazards.text_products(, source=None, product_code=‘TCP’, issuing_office=None, from_time=None, to_time=None, product_id=None, persist=False, client=None)
Section titled “mostlyright.weather.hazards.text_products(, source=None, product_code=‘TCP’, issuing_office=None, from_time=None, to_time=None, product_id=None, persist=False, client=None)”Read NWS/NHC text bulletins as schema.hazard.text_product.v1 rows.
- Parameters:
- source (
Literal['nws','iem'] |None) –Nonereads the live NWS window and auto-routes an out-of-retention window to the Iowa State AFOS archive."nws"pins the live route and raises for such a window."iem"pins the archive, which accepts any age. - product_code (
str) – One ofTCP,TCM,TCD,TCV,PSH. - issuing_office (
str|None) – One office, e.g."KNHC". On the archive leg this is the four-letter communications-header office. - from_time (
datetime|None) – Window start. Bulletins are instant-grained, so the window isfrom_time/to_time; there is noon_date. To read one archive day, pass that day’s UTC bounds. - to_time (
datetime|None) – Window end. - product_id (
str|None) – Read one bulletin by id. An AFOS-form id (202410090000-KNHC-WTNT34-TCPAT4) addresses the archive; any other opaque id addresses the live route. Cannot be combined with a window. - persist (
bool) – Write the returned rows to the local vintage store before returning them. Two calls append two vintages. - client (
Client|None) – Anhttpx.Clientto reuse. A client passed in is never closed here.
- source (
- Returns:
product_id, product_code, issuing_office, wmo_id, awips_id, issued_at_utc, storm_id, storm_name, advisory_number, amendment_suffix, raw_afos, parse_status, captured_at, source. A window with no bulletins in it returns zero rows, not an error. - Return type:
A
schema.hazard.text_product.v1DataFrame. Columns, in order - Raises:
- ContractError –
sourceorproduct_codeis not recognised;product_idwas combined with a window; the archive leg was called without both window ends; or the archive window spans more thanMAX_ARCHIVE_WINDOW_DAYSdays. - HazardRetentionError –
source="nws"with a window older than the 7-day live retention. The message namessource="iem". - NoHazardDataError –
product_idnames a bulletin that does not exist. - ValueError –
from_timeis afterto_time.
- ContractError –
Source:
: Accepted source= values are None, "nws", and "iem". The
per-row source column and df.attrs["source"] always name the leg
that actually answered — "nws.text_products.live" or
"iem.afos.archive" — so an auto-route is visible in the result. An
unrecognised value raises before any network call.
Every row keeps raw_afos byte for byte, and parse_status is
"parsed" or "raw_only". A bulletin the free-text parse cannot
read still produces a row with its full text, so it can be re-parsed
later without a re-fetch.
Examples
Section titled “Examples”Read the last two days of public advisories from the hurricane center:
from datetime import UTC, datetime, timedelta
from mostlyright.weather import hazards
now = datetime.now(UTC)df = hazards.text_products( product_code="TCP", issuing_office="KNHC", from_time=now - timedelta(days=2), to_time=now,)df[["product_id", "issued_at_utc", "advisory_number", "source"]]Read one archived bulletin by id:
df = hazards.text_products(product_id="202410090000-KNHC-WTNT34-TCPAT4")df["raw_afos"].iloc[0]mostlyright.weather.hazards.tropical_cyclones(, source=None, persist=False, client=None)
Section titled “mostlyright.weather.hazards.tropical_cyclones(, source=None, persist=False, client=None)”Return one row per tropical cyclone NHC currently lists as active.
Reads CurrentStorms.json and returns
schema.hazard.tropical_cyclone.v1. Off-season that document serves
{"activeStorms": []} — an empty list, HTTP 200 — and this returns a
schema-valid zero-row frame for it. A quiet season is a fact, not a failure,
so nothing raises.
- Parameters:
- source (
Literal['nhc'] |None) –Noneand"nhc"both read NHC’s active-storm index; they are the same route today. The parameter exists so adding a second provider never requires a new keyword argument. Any other value raises before a request is issued. - persist (
bool) – Write the returned rows to the local vintage store before returning them. Two calls append two vintages — the storm state changes every advisory cycle, andcaptured_atdiffers. - client (
Client|None) – Anhttpx.Clientto reuse. A client passed in is never closed here.
- source (
- Return type:
DataFrame - Returns:
A
schema.hazard.tropical_cyclone.v1DataFrame withattrs["source"] == "nhc.tropical_cyclones.live".intensity_kt,pressure_mb,latitude,longitude,movement_direction_degrees, andmovement_speed_ktare coerced numerically;advisory_numberstays the zero-padded string NHC serves;raw_jsonholds the verbatim per-storm object, so every spelling upstream used survives the coercion. - Raises:
- ContractError –
sourceis not a recognised provider. - httpx.HTTPStatusError – NHC answered non-2xx.
- ContractError –
basin is derived from the storm-id prefix (al / ep / cp).
An id that does not match that shape yields basin=None rather than
raising — an upstream id-format change must cost one column, not the
whole verb. A storm object carrying no usable id at all is skipped
with a warning on this module’s logger, for the same reason: storm_id
is non-nullable, so keeping it would fail the frame and take every other
storm in the pull down with it.
Examples
Section titled “Examples”Read what is active right now:
from mostlyright.weather import hazards
df = hazards.tropical_cyclones()df[["storm_id", "storm_name", "classification", "intensity_kt"]]Off-season the same call returns zero rows and does not raise:
len(hazards.tropical_cyclones()) # 0mostlyright.weather.hazards.water_observations(gauge_id, from_time, to_time, , parameter_code=‘00065’, approval_status=None, persist=False, client=None)
Section titled “mostlyright.weather.hazards.water_observations(gauge_id, from_time, to_time, , parameter_code=‘00065’, approval_status=None, persist=False, client=None)”Return USGS continuous water values for one site over a window.
Resolves the site ONCE — one monitoring-locations request per call,
never one per page — to validate it and to read the standard-time
abbreviation and daylight-saving flag every returned row is stamped with,
then walks the continuous collection over the window.
- Parameters:
- gauge_id (
str) – A USGS site number in either spelling — the bare"02215260"the water-prediction service carries, or the"USGS-02215260"this API addresses. Both are accepted and both canonicalise to the BARE number, which is what every row’sgauge_idcarries, so a water frame and a gauge frame join on one column and one reading is never stored under two identities. Anything that is not 8 to 15 digits raises before any request. - from_time (
datetime) – Window start. Must be timezone-aware. - to_time (
datetime) – Window end. Must be timezone-aware and at or afterfrom_time. - parameter_code (
str) –"00065"(gage height, ft) or"00060"(discharge, ft³/s). - approval_status (
Literal['Provisional','Approved'] |None) – Keep only rows in this state. This is an ordinary FILTER over a column and is not a vintage selector — “provisional” is a property of the datum, not of when this install learned it. For point-in-time replay passcapture_history(..., as_of=), which selects onlast_modified_at_utc. - persist (
bool) – Write the returned rows to the local vintage store. - client (
Client|None) – Anhttpx.Clientto reuse. A client passed in is never closed here.
- gauge_id (
- Return type:
DataFrame - Returns:
A
schema.hazard.water_observation.v1DataFrame withattrs["source"] == "usgs.waterdata.live". A VALIDATED site that recorded nothing in the window returns a valid EMPTY frame. - Raises:
- ContractError –
approval_statusorparameter_codeis not a recognised value,gauge_idis not a USGS site number in one of the two accepted spellings, a bound is naive,from_timeis afterto_time, or a served pagination cursor leaves the resolved address. - NoHazardDataError –
gauge_idaddresses no monitoring location. An unknown site is an identity failure, unlike a quiet window. - httpx.HTTPStatusError – The route answered non-2xx.
- ContractError –
USGS marks recent values Provisional and flips them to Approved
roughly 60 to 90 days later. That flip can change the VALUE, not only
the label, and it republishes the same instant under a newer
last_modified_at_utc — so a re-fetch of an unrevised row is
idempotent while a revision APPENDS beside the earlier reading. This is
the only flood product whose vintage comes from upstream.
Examples
Section titled “Examples”Read a day of stage readings:
from datetime import UTC, datetime
from mostlyright.weather import hazards
df = hazards.water_observations( "02215260", datetime(2026, 8, 1, tzinfo=UTC), datetime(2026, 8, 2, tzinfo=UTC),)df[["valid_at_utc", "value", "approval_status"]]mostlyright.weather.hazards.wildfires(, source=None, scope=‘current’, state=None, incident_type=‘WF’, include_complex_children=False, min_acres=None, from_date=None, to_date=None, year=None, active_only=False, persist=False, client=None)
Section titled “mostlyright.weather.hazards.wildfires(, source=None, scope=‘current’, state=None, incident_type=‘WF’, include_complex_children=False, min_acres=None, from_date=None, to_date=None, year=None, active_only=False, persist=False, client=None)”Return wildfire incident records — acreage, containment, and dates.
One verb, two providers, one row contract. source=None reads the
national interagency feed and source="calfire" reads the California
state incident list; both return schema.hazard.wildfire.v1 and every row
says which leg answered.
- Parameters:
- source (
Literal['wfigs','calfire'] |None) –Noneand"wfigs"serve the national interagency feed throughscope=;"calfire"serves the California state feed throughyear=/active_only=. Any other value raises before a request is issued and never falls back to the other provider. - scope (
Literal['current','season','history']) – Which interagency window answers."current"is the fires burning now — and the one service that DROPS records, see the note below."season"is every discovery of the current year, with no such rule."history"is every year the interagency system has recorded. Interagency leg only. - state (
str|None) – A two-letter state code, e.g."CA". Matched against a closed pattern before it becomes part of the query expression. Interagency leg only — the California route serves that state and nothing else. - incident_type (
str|None) – The incident category, interpreted PER SOURCE. On the interagency leg it is one of"WF"(wildfire, the default),"RX"(prescribed burn), or"CX"(incident complex). On the California leg it is one of"Wildfire","Fire","Flood", or"Hazmat". The default"WF"is the one word both legs answer — a signature carries one default, and on the California leg it selects that feed’s"Wildfire"rows, which is what the returnedincident_typecolumn shows. Every other word of the wrong vocabulary raises and names the leg that accepts it.Nonekeeps every category. - include_complex_children (
bool) – Keep the individual fires that roll up into an incident complex.Falseby default, because a complex parent and its children both report acreage and counting both double-counts the same ground. Interagency leg only. - min_acres (
float|None) – Keep only fires at or above this reported size, in acres. Zero or more; a negative floor excludes nothing and is refused rather than sent as a filter that does not filter. Interagency leg only. - from_date (
date|None) – Earliest fire-discovery day, inclusive. Converted to an absolute epoch-millisecond boundary in Python. Interagency leg only. - to_date (
date|None) – Latest fire-discovery day, inclusive. Same conversion. Interagency leg only. - year (
int|None) – A California season from 2013 to the current year. The fetcher validates it: the route answers a year it cannot read with HTTP 200 and the CURRENT season. California leg only. - active_only (
bool) – Ask the California route for the incidents burning right now rather than the whole season. California leg only, and not combinable withyear=: a past season is a record of fires that are out, and the route answers that combination with zero records. - persist (
bool) – Write the returned rows to the local vintage store before returning them. Two calls append two vintages; nothing is overwritten, becausecaptured_atis part of the key. - client (
Client|None) – Anhttpx.Clientto reuse. A client passed in is never closed here.
- source (
- Return type:
DataFrame - Returns:
A
schema.hazard.wildfire.v1DataFrame carryingattrs["source"]and a non-nullsourcecolumn — one of"wfigs.current.live","wfigs.season.live","wfigs.history", or"calfire.incidents.live". A window with no matching incidents returns a valid EMPTY frame: a quiet fire season is a fact. - Raises:
- ContractError –
source,scope,incident_type,state,min_acres, or a date bound is unusable; an argument belonging to the other provider was passed (the message names the source that accepts it); or ascope="history"walk would cover more thanWFIGS_HISTORY_PREFLIGHT_LIMITrecords — measured on every history call, bounded or not, because a date bound is not a size. All of these are refused before any records request. - HazardRetentionError –
from_dateorto_datepredates the shortest fall-off horizon of the live incident service andscopeis"current". The message names the bound it read, carries the publisher’s own rules, and namesscope="season"andscope="history". - SourceUnavailableError – a service answered HTTP 200 with an error object instead of records, or the California route answered non-2xx.
- RateLimitError – a route answered HTTP 429 on every attempt.
- ApiKeyRequiredError – no API key is configured for catalog resolution.
- ContractError –
THE LIVE LEG DROPS RECORDS. scope="current" lists only fires that
have not been declared contained, controlled, or out, and it removes a
record after three, eight, or fourteen days without an update depending
on size class. A revision this install did not capture cannot be fetched
from that service again, which is what persist=True exists for.
THE SEASON AND HISTORY LEGS ARE OPERATIONAL, NOT CERTIFIED. The
publisher states that the data “represents a snapshot in time of what
was known for each incident during the operational period of the fire”
and is “not QA/QC’d after a fire is declared out”. The authoritative
certified record is a separate service,
InFORM_FireOccurrence_Public, which carries the certified acreage
and is cataloged in docs/hazards-adapter.md rather than shipped
here: it is post-hoc and stable, so it is not capture-or-lose.
THIS VERB OWNS THE QUERY EXPRESSION. There is no where= and no
out_fields= argument. The expression is assembled from validated,
enumerated components, and the requested attribute list is the set
verified present on all three interagency services.
Examples
Section titled “Examples”What is burning right now:
from mostlyright.weather import hazards
df = hazards.wildfires()df[["incident_name", "acres", "percent_contained", "state", "source"]]One state’s season, prescribed burns excluded:
df = hazards.wildfires(scope="season", state="CA", min_acres=100.0)The California state acreage of record:
df = hazards.wildfires(source="calfire", year=2025)df[["incident_name", "acres", "is_final", "source"]]