GET /weather/forecast/exceedance
Answers one question: what share of the ensemble members crosses my threshold
on that day? For one point - GPS coordinates or a city name
(city=Manila) - it returns one row per UTC calendar day of the GEFS ensemble
tail, roughly two to five weeks out, each row carrying the number of members
that crossed the threshold, the number of members counted, and the fraction of
the two.
It is the threshold companion to GET /weather/forecast
and GET /weather/forecast/daily, which
serve the ensemble mean in that same range. Averaging the members is exactly
what destroys a threshold signal - see
why this endpoint exists.
Here the members are counted one by one, before any averaging.
Use it when an agent has to commit to a rain-or-shine decision weeks ahead: a
parametric payout trigger, a shipping or expedition window, a harvest slot, a
concrete pour. The whole series is settled as one x402 payment for the call.
See the live /catalog for the authoritative endpoint
listing and price.
Not a calibrated probability. This page repeats what the response itself
says in data.method, in the same words:
Raw ensemble exceedance fraction: the share of GEFS members whose daily statistic crosses the threshold, counted member by member. This is not a calibrated probability — no bias correction or statistical post-processing is applied; ‘members_total’ is served on every day so the fraction can be audited.
A 0.29 means 9 of 31 members crossed the threshold. No more than that. It
is not a 29 % chance of rain, and nothing on this route claims it is: no model
output statistics, no bias correction against observations, no reliability
calibration. members_total is served on every single day precisely so the
fraction can be divided back out and audited.
x402 golden rule: the agent pays for the answer to its question. A
well-formed request that returns at least one counted day is a successful answer
-> 200, even when the first or last day is clipped by the window edge: that
is reported through coverage, never hidden. Requests the service cannot answer
- an unknown variable, a threshold with no unit, a window that ends before the ensemble tail begins - leave the 200 range.
Why a separate endpoint: the mean cannot answer this
Past the deterministic window (about 384 h, or J+16), /weather/forecast and
/weather/forecast/daily serve the average of ~31 ensemble members. An
extreme produced by only part of the members survives that average as a fraction
of itself. That is what an ensemble mean is - not a defect - but it makes the
mean unusable as a threshold trigger.
Measured in production on 2026-07-25 for Paris, on
/weather/forecast/daily?lat=48.85&lon=2.35&days=35:
| Segment | Register | Daily precipitation | Daily max temperature |
|---|---|---|---|
| J+0 → J+15 (16 days) | deterministic GFS | 9 days at 0.0 mm, peak 13.12 mm | 21.2 → 40.5 °C, a 19.3 °C spread |
| J+16 → J+34 (19 days) | GEFS ensemble mean | no day below 0.40 mm, none above 1.82 mm | 25.3 → 27.9 °C, a 2.6 °C spread |
A rule like “more than 30 mm in a day” placed on that tail will essentially never fire, whatever weather actually occurs. This endpoint fills exactly that hole: it reads the member grids rather than their mean, so the tail of the distribution survives the count. The trade is explicit - you get a counted fraction over the tail, not a deterministic value, and not a calibrated probability.
For lead times inside the deterministic window, this route is the wrong tool
and says so: a window entirely short of the tail returns
400 OUT_OF_RANGE pointing back at
GET /weather/forecast and
GET /weather/forecast/daily.
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
variable | string | yes | Quantity the threshold is tested on: precipitation (mm), wind (m/s) or temperature (°C) |
threshold | number | yes | Threshold value, in the unit of that variable - echoed back as threshold_unit |
comparison | string | no | at_least (default, daily value ≥ threshold) or at_most (daily value ≤ threshold); bounds inclusive on both sides |
lat | number | coordinate mode | Latitude in decimal degrees, from -90 to 90 |
lon | number | coordinate mode | Longitude in decimal degrees, from -180 to 180 |
city | string | place-name mode | Place name instead of coordinates, for example city=Manila |
location | string | no | Strict alias of city |
country | string | no | ISO-3166 alpha-2 code narrowing an ambiguous place name, for example country=PH |
from | string | no | First UTC calendar day of the window, YYYY-MM-DD; defaults to the first day of the tail |
days | number | no | Length of the window in calendar days, integer 1 to 20; see the default window |
variable and threshold are both required, and required together: a
threshold without a quantity has no unit. The unit actually applied is never
implicit - it is fixed by variable and echoed back as data.threshold_unit.
Locate the point either with lat/lon or with a place name via city
(alias location) - never both. Place names resolve against an embedded
GeoNames cities500 gazetteer (exact match, case- and accent-insensitive, no
fuzzy matching; ties broken by population, narrowed by country=), and the
resolved place is echoed back in data.location - see
GET /weather/forecast for the full
place-name contract.
comparison covers both directions of a real trigger: at_least for “at least
30 mm of rain”, “wind at or above 15 m/s”; at_most for “a dry day”, “a day at
or below 0 °C”. Both bounds are inclusive, so a member sitting exactly on
the threshold is counted.
GET /weather/forecast/exceedance?city=Manila&variable=precipitation&threshold=30&days=3
GET /weather/forecast/exceedance?lat=48.8566&lon=2.3522&variable=temperature&threshold=0&comparison=at_most&days=3
GET /weather/forecast/exceedance?lat=48.8566&lon=2.3522&variable=wind&threshold=15&days=2
The window: the ensemble tail, up to 20 days
days is capped at 20 - the physical span of the ensemble tail the members
cover (steps f390 to f840). from sets the first day and defaults to the
first day of the tail. The real bounds are not a constant: they are served on
every response as data.lead.window_start / window_end, so an agent knows the
window without a second call.
Days that fall outside the covered window are simply omitted from the
response - never a 400. Only a window that ends entirely before the tail (or
starts entirely after it) is a 400 OUT_OF_RANGE.
The default window is capped by a read budget
Omit days and the service serves as much of the tail as fits a read budget,
then flags it: data.window_capped is true and data.lead.window_end shows
where the tail really ends. Measured in production on 2026-08-09, the
default window was 11 days for precipitation and 6 days for wind
(which has to read two component variables). Ask days= explicitly for the
rest, up to 20 - an explicit days is served in full, the budget applies only
to the default.
{
"variable": "precipitation",
"threshold": 30.0,
"threshold_unit": "mm",
"comparison": "at_least",
"from": "2026-08-24",
"to": "2026-09-03",
"window_capped": true
}
200 response - UnifiedResponse
{
"data": { ... },
"provenance": {
"source": "NOAA GEFS ensemble members (0.5°, up to 31 members)",
"dataset": "gefs",
"fetched_at": "2026-08-09T16:00:42Z",
"freshness": { "kind": "cached", "age_secs": 144042 }
}
}
provenance.source: names the ensemble members, never the ensemble mean - that mean is precisely what this route does not serve. The wording is a human label and is not part of the contract.provenance.dataset:gefs. Branch on this, not onsource.freshness.kind:cachedwithage_secs, the age of the member cycle actually read. The member layer is structurally one cycle behind the deterministic forecast - its 35-day extension publishes the following day - so a healthy response is normallycached, notlive.- GEFS data comes from NOAA and is public-domain U.S. government data.
Fields of data
| Field | Type | Description |
|---|---|---|
lat | number | Latitude in effect: echoed, or resolved from the place name |
lon | number | Longitude in effect: echoed, or resolved from the place name |
location | object | Resolved place echo; present only when the request used city/location |
variable | string | Quantity tested, echoed |
threshold | number | Threshold value, echoed |
threshold_unit | string | Unit the threshold was interpreted in: mm, m/s or °C |
comparison | string | Direction tested: at_least or at_most |
from | string | First day of the window, YYYY-MM-DD |
to | string | Last day of the window, YYYY-MM-DD |
window_capped | boolean | Present and true only when days was omitted and the default window was shortened by the read budget; absent otherwise |
days | object[] | One entry per covered UTC calendar day, in ascending date order |
lead | object | Member cycle read and the real bounds of the ensemble tail |
grid | object | Effective grid cell used for the answer |
method | object | How the number was obtained - served on every response |
coverage | object | Whole-series completeness marker |
days[] - one counted day
| Field | Type | Description |
|---|---|---|
date | string | The UTC calendar day, YYYY-MM-DD |
statistic | string | Daily statistic each member was reduced to before the comparison - see below |
members_total | integer | Members actually present in the store for that day - counted, never assumed equal to the nominal 31 |
members_exceeding | integer | Members whose daily statistic crosses the threshold in the requested direction (bounds inclusive) |
probability | number | null | members_exceeding / members_total, between 0 and 1; null when no member carried a value for that day |
steps_used | integer | Distinct forecast steps that fed this day, all members taken together (the tail is 6-hourly, so a full day is 4) |
coverage | object | Per-day completeness (complete, reason) |
probability is null, not 0, on an empty sample: a day with no member has no
fraction, and none is invented.
statistic - what each member was reduced to
Each member is reduced to one value per day before the threshold test, and the response names which reduction was applied:
variable | comparison | statistic | Reduction |
|---|---|---|---|
precipitation | either | daily_sum | Sum over the day, rebuilt per member from the model’s disjoint accumulation buckets - never a naive sum of overlapping buckets |
wind | either | daily_max | Largest 10 m scalar speed sqrt(u² + v²) computed per member - never a vector mean of components, which would understate gusty members |
temperature | at_least | daily_max | Warmest value of the day |
temperature | at_most | daily_min | Coldest value of the day - a frost threshold tested against the daily maximum would never fire |
lead
| Field | Type | Description |
|---|---|---|
cycle | string | Analysis cycle of the members actually read, RFC 3339 UTC |
window_start | string | First instant of the ensemble tail this endpoint can count over |
window_end | string | Last instant of that tail |
These bounds are those of the member grids, never those of the ensemble-mean
series served by GET /weather/forecast.
grid, method and coverage
grid (lat, lon, distance_km) is the representative grid cell used, as in
GET /weather/forecast - the member grids are
a ~0.5 degree mesh, a grid cell and not a weather station.
method carries kind: "counted_member_fraction", calibrated: false and the
note quoted at the top of this page. It is served on every response, not
merely documented here, so an agent that never reads this page still receives the
caveat with the number.
Top-level coverage is complete: true only when every served day is itself
complete; otherwise reason says how many served days are partial, and the
per-day coverage says why.
Example - three days over Manila, 30 mm of rain
Captured in production on 2026-08-09 on
?city=Manila&variable=precipitation&threshold=30&days=3. The first day is
clipped by the start of the ensemble window and marked as such; the two full days
are complete.
{
"data": {
"lat": 14.6042,
"lon": 120.9822,
"location": {
"name": "Manila",
"country": "PH",
"admin1": "NCR",
"lat": 14.6042,
"lon": 120.9822,
"source": "GeoNames"
},
"variable": "precipitation",
"threshold": 30.0,
"threshold_unit": "mm",
"comparison": "at_least",
"from": "2026-08-24",
"to": "2026-08-26",
"days": [
{
"date": "2026-08-24",
"statistic": "daily_sum",
"members_total": 31,
"members_exceeding": 7,
"probability": 0.2258,
"steps_used": 4,
"coverage": {
"complete": false,
"reason": "day starts before the ensemble window (covered from 2026-08-24T06:00:00+00:00)"
}
},
{
"date": "2026-08-25",
"statistic": "daily_sum",
"members_total": 31,
"members_exceeding": 9,
"probability": 0.2903,
"steps_used": 4,
"coverage": { "complete": true }
},
{
"date": "2026-08-26",
"statistic": "daily_sum",
"members_total": 31,
"members_exceeding": 11,
"probability": 0.3548,
"steps_used": 4,
"coverage": { "complete": true }
}
],
"lead": {
"cycle": "2026-08-08T00:00:00+00:00",
"window_start": "2026-08-24T06:00:00+00:00",
"window_end": "2026-09-12T06:00:00+00:00"
},
"grid": { "lat": 14.5, "lon": 121.0, "distance_km": 11.74 },
"method": {
"kind": "counted_member_fraction",
"calibrated": false,
"note": "Raw ensemble exceedance fraction: the share of GEFS members whose daily statistic crosses the threshold, counted member by member. This is not a calibrated probability — no bias correction or statistical post-processing is applied; 'members_total' is served on every day so the fraction can be audited."
},
"coverage": {
"complete": false,
"reason": "1 served day(s) have partial coverage"
}
},
"provenance": {
"source": "NOAA GEFS ensemble members (0.5°, up to 31 members)",
"dataset": "gefs",
"fetched_at": "2026-08-09T16:00:42.586091066Z",
"freshness": { "kind": "cached", "age_secs": 144042 }
}
}
Read the middle day as “9 of the 31 members put at least 30 mm of rain on 2026-08-25”. Not “a 29 % chance of rain”.
Example - a frost trigger (at_most on temperature)
Captured the same day on
?lat=48.8566&lon=2.3522&variable=temperature&threshold=0&comparison=at_most&days=3.
The direction flips the reduction: statistic is daily_min, because a frost
threshold tested against the daily maximum would never fire. Late August over
Paris, no member goes below zero - a 0.0 here is a counted zero on 31
members, which is exactly the honest answer.
{
"variable": "temperature",
"threshold": 0.0,
"threshold_unit": "°C",
"comparison": "at_most",
"days": [
{
"date": "2026-08-25",
"statistic": "daily_min",
"members_total": 31,
"members_exceeding": 0,
"probability": 0.0,
"steps_used": 4,
"coverage": { "complete": true }
}
],
"grid": { "lat": 49.0, "lon": 2.5, "distance_km": 19.26 }
}
Coverage honesty: how a day degrades
A degraded day is served and marked, never dropped and never smoothed over.
Four reasons can appear in days[].coverage.reason, combined with ; when more
than one applies:
- Clipped by the start of the window -
day starts before the ensemble window (covered from …). The first day of the tail usually begins mid-day. - Clipped by the end of the window -
day ends after the ensemble window (covered until …). - Below the member floor -
counted on N of 31 ensemble members (below the 21-member floor). Under two thirds of the nominal 31 members, the day is still served with its realmembers_total; the fraction is simply counted on a smaller sample and the reason cites it. Nothing is extrapolated to 31. - Members with missing steps -
N counted member(s) have missing forecast steps. Such a member is counted, not dropped: dropping it would silently shrink the ensemble and inflate the fraction of those that remain.
Asking for the whole tail (days=20) on 2026-08-09 shows the window-end case -
the last requested day falls past window_end, so it carries no member at all
and its probability is null:
{
"date": "2026-09-12",
"statistic": "daily_sum",
"members_total": 0,
"members_exceeding": 0,
"probability": null,
"steps_used": 0,
"coverage": {
"complete": false,
"reason": "day ends after the ensemble window (covered until 2026-09-12T06:00:00+00:00); no ensemble member available at this grid cell"
}
}
Always read members_total before acting on probability: a 0.5 counted on 4
members and a 0.5 counted on 31 are not the same statement, and the response
gives you both numbers to tell them apart.
When the deployment carries no member grids
If the store holds no GEFS member grids at all, no fraction can be counted.
The service answers 200 with an empty days, coverage.complete: false and
the reason no GEFS ensemble member grids in this deployment's store: no exceedance fraction can be counted (shorter lead times are served in deterministic detail by GET /weather/forecast) - and flags the answer as
carrying no usable result, which the gateway takes as a signal not to
settle the payment. It is neither a 5xx of staleness nor a 4xx: the agent asked
nothing wrong, and it is not charged for an empty answer.
Settlement
The gateway settles the whole series as one x402 payment for the call, at a
flat price, whatever the number of days counted. The authoritative price is
published in the live /catalog; this page never hardcodes
it.
Errors
Only requests the service cannot answer leave the 200 range, and a 4xx is never charged.
| Status | code | Case |
|---|---|---|
| 400 | INVALID_VARIABLE | variable missing, or outside precipitation / wind / temperature |
| 400 | INVALID_THRESHOLD | threshold missing, non-numeric or infinite |
| 400 | INVALID_COMPARISON | comparison outside at_least / at_most |
| 400 | INVALID_PERIOD | days non-numeric or outside 1..=20 |
| 400 | INVALID_COORDS | lat/lon out of bounds, or a point-mode conflict (coordinates and a place name together, or no point at all) |
| 400 | UNKNOWN_LOCATION | city/location names a place the gazetteer cannot resolve |
| 400 | OUT_OF_RANGE | The requested window lies entirely outside the ensemble tail |
| 400 | UNKNOWN_PARAMETER | An unrecognised query parameter |
Error messages name the unit and guide the retry:
{ "error": "threshold is required, in mm for variable 'precipitation'. Accepted parameters: lat+lon, or city (alias 'location', with optional 'country' ISO-3166 alpha-2); variable (precipitation, wind, temperature) and threshold, expressed in that variable's unit (precipitation in mm, wind in m/s, temperature in °C); optional comparison (at_least = value ≥ threshold, default; at_most = value ≤ threshold), from (YYYY-MM-DD, default: first day of the ensemble tail) and days (integer 1..=20, default: as much of the covered ensemble tail as fits the read budget, flagged window_capped when it is shorter). Example: /weather/forecast/exceedance?city=Zurich&variable=precipitation&threshold=30", "code": "INVALID_THRESHOLD" }
{ "error": "unknown comparison 'above' (expected: at_least = daily value ≥ threshold, or at_most = daily value ≤ threshold; default at_least)", "code": "INVALID_COMPARISON" }
The out-of-range message is the one that matters most, because it is how an agent that aimed at the wrong horizon finds the right route:
{ "error": "requested days end before the ensemble tail, which covers 2026-08-24T06:00:00+00:00..2026-09-12T06:00:00+00:00; shorter lead times are served in deterministic detail by GET /weather/forecast (one lead time) and GET /weather/forecast/daily (daily series)", "code": "OUT_OF_RANGE" }
See also
GET /weather/forecast- one lead time or calendar date at one point, deterministic within 16 days.GET /weather/forecast/daily- the daily series for one point, over the same range.GET /climate/point- historical ERA5 reanalysis for dated climate evidence.- For agents - discovery surfaces, the live
/catalogand how settlement works.