• v0.58.0 40cf04f3a8

    v0.58.0 Stable

    joeyr released this 2026-09-22 00:31:06 -05:00 | 41 commits to main since this release

    The weather tenant is complete at nine tools. W2, W3 and W4 all shipped together:
    list_stations, get_model_run, get_taf, search_wind_shifts, get_observations,
    get_alerts, get_forecast_discussion and get_hourly_forecast, alongside W1's
    get_forecast_grid.

    One release rather than three, deliberately, and the reason is the release rule's own
    argument.
    That rule exists so nothing running in production lacks a version to name it
    by. These three phases were built and verified in one session and deploy together behind
    one connector reconnect, so three tags would have named three moments that never existed
    apart — fictional history rather than a finer-grained record. The entry below is organised
    by phase instead.

    The build overturned three of its own plan's premises, each by measurement. The
    Open-Meteo forecast response carries no issuance time at all, so the run time for a
    tool named get_model_run had to be found in a second document. The configured global
    model is three-hourly, not hourly, so its hourly series is interpolated — the grid's
    native-versus-filled problem arriving through a different door. And the per-request model
    choice could not be built on nulls-outside-the-domain because there are none: a
    coordinate outside a model's domain is an explicit HTTP 400.

    Both of W0's open questions are answered, and one of them without waiting a day. The
    plan said the gridpoint reissue cadence needed samples spread across a day; fifteen
    offices sampled at one instant gave the same distribution, because offices issue on
    staggered schedules. Median issuance age 5.48 hours, maximum 10.36, minimum 0.43 —
    so the 3-hour warn line would have fired on 10 of 15 normal gridpoints and a 12-hour
    line on none. HRRR's horizon is anchored to its run: the extended 00/06/12/18Z cycles run
    48 hours and the hourly cycles between them run 18, so a request gets 42 to 48 hours and
    hours=48 stands as a default.

    1805 tests green, 155 of them this tenant's. 40 mutants were run against the new
    guards and all 40 were caught — two survived the first pass, and both were the failure
    CLAUDE.md warns about rather than a gap in the code: one mutant was ineffective, and one
    test asserted a true thing the broken code also satisfied. The second is written up under
    Fixed.

    Added

    • list_stations — every reporting station near a coordinate, nearest first, with
      what can be read from each. The phase that proves "anywhere" is real, since bbox
      is the mechanism that works outside the United States and /points is the one that does
      not. The widening ladder is 0.5 → 1 → 2 → 4 → 8 degrees, per product, stopping each as
      soon as it answers.
    • get_model_run — an hourly model forecast from whichever configured slug covers the
      coordinate, with the run's initialisation time, its native step, and how much of the
      window actually carried data.
    • get_taf — the terminal forecast for the nearest airfield, with its change groups
      read for what they mean rather than as a list of wind samples. One station, never
      several; station is the one argument that overrides the coordinate.
    • search_wind_shifts — a scan over the model run and the TAF, with the raw grid as
      corroboration reported alongside and never as the scan surface.
    • get_observations — what the weather is actually doing, from NWS inside the United
      States and from the aviation network everywhere else.
    • get_alerts, get_forecast_discussion, get_hourly_forecast — the context
      tools. The discussion comes back verbatim and nothing is parsed out of it.
    • Seven network-free reader modules, each fixture-driven like calendar/expand.py and
      for the same reason: stations.py, taf.py, model.py, observations.py, alerts.py,
      hourly.py, shifts.py.
    • weather/units.py — the conversion layer, moved out of grid.py now that it serves
      four endpoint shapes rather than one.
    • A preflight check per configured model slug, advisory and never fatal, modelled on
      _check_espnff_season. The plan specified validating each slug against Open-Meteo's
      model list; there is no list — /v1/models 404s and the invalid-slug rejection names
      the bad value without enumerating the good ones. What replaces it is one real forecast
      request per slug at a point inside that slug's own coverage, which validates the string
      and that the model answers. Its detail line reports the run state, which is where the
      three-hour native step surfaces without anybody going looking for it.
    • envelope.from_epoch and envelope.from_iso — one conversion point for the three
      timestamp shapes these upstreams send: an ISO string with an offset, an ISO string with
      none, and epoch integers.
    • envelope(deterministic=True) — for a station list or a grid resolution, which
      carry no issuance time because they are mappings rather than products. The fields stay
      identical so a caller reading the booleans never has to know which kind it holds; only
      the sentence differs, because "do not describe it as current" warns about the wrong thing
      on a mapping and teaches a reader to discount the note that matters.

    Changed

    • client._classify no longer calls every 4xx "an argument problem rather than an
      outage", and it carries the body out on the exception.
      Open-Meteo answers both a
      coverage boundary and a misspelled slug with a 400, and the reason string is the only
      thing that distinguishes them
      — so a fallback that merely caught a 400 would have
      routed every CONUS request to the global model forever on a typo, and looked like working
      software. WeatherBadRequest carries up to 2 KiB of the body, read while it arrives and
      redacted by coordinate shape, because NWS's own 400 on /alerts/active echoes the
      rejected value and that string reaches a tool response. An unrecognised 400 is never
      fallen back from.
    • The gridpoint staleness line is 12 hours, revised from 3 by use — the first
      threshold on this tenant to be revised, which is what the table's own comment invited.
      See the measurement above. The 24-hour absurdity ceiling did not move.
    • The model's staleness thresholds are derived per model from its own document rather
      than read from THRESHOLDS. Measured availability lags: 1.53 h for the hourly
      high-resolution model, 5.51 h for ncep_gfs013, 7.12 h for ecmwf_ifs025. So a
      normal freshest run is ~2.5 hours old on one and ~13 on another, and a single row would
      have marked every global-model answer stale.
    • CLAUDE.md corrected: the raw gridpoint does not carry generatedAt. That section
      claimed both /gridpoints/{wfo}/{x},{y} and /forecast/hourly carry it. Checked across
      sixteen offices and then directly on one cell: the raw gridpoint's scalar properties are
      @id, @type, updateTime, validTimes, forecastOffice, gridId, gridX and
      gridY, and generatedAt is not among them. The original measurement was real and came
      from the hourly endpoint; the claim about the raw one was inferred and never checked.
      The rule is stronger for the correction: on the endpoint that does carry both, the gap
      measured ten hours, and generatedAt changed between two requests fourteen minutes
      apart on the same unchanged grid.
    • docs/weather-plan.md is down to four items from a full build plan: the recorded
      frontal passage the wind-shift thresholds must be tuned against, the live pass, W5, and
      one operator decision. A plan that shrinks to its outstanding items rather than emptying
      is that rule working — Canvas's deletion is why, since three live checks were sitting
      only in that plan when it went.
    • .env.example says the global model's native step is three hours, since that is an
      operator decision the boot log now prints.

    Fixed

    • A wind shift that went out and came back reported the return as a remnant.
      shifts._collapse treated a candidate window that began exactly where the previous one
      ended as overlapping it, so a wind veering 120 degrees and then backing 120 reported the
      veer and a 60-degree fragment of the back. Two rotations that meet at a point are two
      events. Found by writing the test, which is the discipline in CLAUDE.md working as
      advertised.
    • get_observations reported a mistyped station as an upstream failure. The fallback
      to the aviation network was unguarded, so a 204 — which is how that host says "no such
      station" — escaped as a transport error and lost the sentence that names the typo. It now
      reaches the empty-versus-invalid branch, which asks /stations/{id} and says which of the
      two facts it is.
    • A cap test that could not reach the branch it named. It asserted the error detail was
      short, which the final slice guarantees on its own — so it passed against a client with
      the read cap removed. It now counts the chunks the upstream was actually asked for: a
      body noticed after it has arrived is a body already in memory. This is the a test that
      cannot reach a branch is not coverage of it
      failure, caught by the mutation sweep rather
      than by review.
    Downloads