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:

SegmentRegisterDaily precipitationDaily max temperature
J+0 → J+15 (16 days)deterministic GFS9 days at 0.0 mm, peak 13.12 mm21.2 → 40.5 °C, a 19.3 °C spread
J+16 → J+34 (19 days)GEFS ensemble meanno day below 0.40 mm, none above 1.82 mm25.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

ParameterTypeRequiredDescription
variablestringyesQuantity the threshold is tested on: precipitation (mm), wind (m/s) or temperature (°C)
thresholdnumberyesThreshold value, in the unit of that variable - echoed back as threshold_unit
comparisonstringnoat_least (default, daily value ≥ threshold) or at_most (daily value ≤ threshold); bounds inclusive on both sides
latnumbercoordinate modeLatitude in decimal degrees, from -90 to 90
lonnumbercoordinate modeLongitude in decimal degrees, from -180 to 180
citystringplace-name modePlace name instead of coordinates, for example city=Manila
locationstringnoStrict alias of city
countrystringnoISO-3166 alpha-2 code narrowing an ambiguous place name, for example country=PH
fromstringnoFirst UTC calendar day of the window, YYYY-MM-DD; defaults to the first day of the tail
daysnumbernoLength 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 on source.
  • freshness.kind: cached with age_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 normally cached, not live.
  • GEFS data comes from NOAA and is public-domain U.S. government data.

Fields of data

FieldTypeDescription
latnumberLatitude in effect: echoed, or resolved from the place name
lonnumberLongitude in effect: echoed, or resolved from the place name
locationobjectResolved place echo; present only when the request used city/location
variablestringQuantity tested, echoed
thresholdnumberThreshold value, echoed
threshold_unitstringUnit the threshold was interpreted in: mm, m/s or °C
comparisonstringDirection tested: at_least or at_most
fromstringFirst day of the window, YYYY-MM-DD
tostringLast day of the window, YYYY-MM-DD
window_cappedbooleanPresent and true only when days was omitted and the default window was shortened by the read budget; absent otherwise
daysobject[]One entry per covered UTC calendar day, in ascending date order
leadobjectMember cycle read and the real bounds of the ensemble tail
gridobjectEffective grid cell used for the answer
methodobjectHow the number was obtained - served on every response
coverageobjectWhole-series completeness marker

days[] - one counted day

FieldTypeDescription
datestringThe UTC calendar day, YYYY-MM-DD
statisticstringDaily statistic each member was reduced to before the comparison - see below
members_totalintegerMembers actually present in the store for that day - counted, never assumed equal to the nominal 31
members_exceedingintegerMembers whose daily statistic crosses the threshold in the requested direction (bounds inclusive)
probabilitynumber | nullmembers_exceeding / members_total, between 0 and 1; null when no member carried a value for that day
steps_usedintegerDistinct forecast steps that fed this day, all members taken together (the tail is 6-hourly, so a full day is 4)
coverageobjectPer-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:

variablecomparisonstatisticReduction
precipitationeitherdaily_sumSum over the day, rebuilt per member from the model’s disjoint accumulation buckets - never a naive sum of overlapping buckets
windeitherdaily_maxLargest 10 m scalar speed sqrt(u² + v²) computed per member - never a vector mean of components, which would understate gusty members
temperatureat_leastdaily_maxWarmest value of the day
temperatureat_mostdaily_minColdest value of the day - a frost threshold tested against the daily maximum would never fire

lead

FieldTypeDescription
cyclestringAnalysis cycle of the members actually read, RFC 3339 UTC
window_startstringFirst instant of the ensemble tail this endpoint can count over
window_endstringLast 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 real members_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.

StatuscodeCase
400INVALID_VARIABLEvariable missing, or outside precipitation / wind / temperature
400INVALID_THRESHOLDthreshold missing, non-numeric or infinite
400INVALID_COMPARISONcomparison outside at_least / at_most
400INVALID_PERIODdays non-numeric or outside 1..=20
400INVALID_COORDSlat/lon out of bounds, or a point-mode conflict (coordinates and a place name together, or no point at all)
400UNKNOWN_LOCATIONcity/location names a place the gazetteer cannot resolve
400OUT_OF_RANGEThe requested window lies entirely outside the ensemble tail
400UNKNOWN_PARAMETERAn 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