Skip to content

Forecasting

A saved location's coordinates produce a three-day photography forecast in Europe/London: today and the next two calendar days. Sun, terrain, and the other figures are for that place anchor, not for each viewpoint. Adding or moving a viewpoint does not refresh or duplicate the forecast. The split between the place and a viewpoint is in catalogue.md. Open-Meteo, Sunsethue and PVGIS stay independent. Tempest scores the weather itself. A location with the Mist & Fog subcategory also gets a specialist block from the UK Met Office UKV model. After the cards are on screen, Groq writes a short note from that finished forecast and does not call the other providers.

flowchart TD
  location[Saved coordinates]
  page[Forecast page]
  device[IndexedDB copy]
  forecastGet["GET /api/forecast"]
  meteoCache[Open-Meteo cache, 1 hour]
  meteo[Open-Meteo]
  periods[Sunrise, daytime and sunset windows]
  score[Weather suitability]
  hueCache[Sunsethue cache, 6 hours]
  hue[Sunsethue colour]
  horizonStore[HorizonProfile, kept]
  pvgis[PVGIS horizon and elevation]
  sunCalc[Astral sun position]
  fogGate{mist-fog on the location}
  ukvCache["UKV cache, 1 hour"]
  ukv[UK Met Office UKV]
  fogRead[Fog potential]
  payload[Forecast JSON]
  snap[Forecast snapshot]
  cards[Day cards and Horizon graph]
  summaryGet["GET summary for that snapshot"]
  facts[Facts from that snapshot]
  groqCache[Summary cache, keyed by those facts]
  groq[Groq]
  note[Two-sentence AI summary]

  location --> page
  page --> device
  device -->|older than 1 hour, moved pin, no snapshot, mist-fog mismatch, or Refresh| forecastGet
  device -->|still fresh| cards
  forecastGet --> meteoCache
  meteoCache -->|missing, stale, or Refresh| meteo
  meteoCache --> periods
  meteo --> periods
  periods --> score
  score --> hueCache
  hueCache -->|upcoming sunrise or sunset missing or stale| hue
  hueCache --> payload
  hue --> payload
  score --> horizonStore
  horizonStore -->|no profile for this pin| pvgis
  horizonStore --> sunCalc
  pvgis --> sunCalc
  sunCalc --> fogGate
  fogGate -->|yes| ukvCache
  ukvCache -->|missing, stale, or Refresh| ukv
  ukvCache --> fogRead
  ukv --> fogRead
  fogRead --> payload
  fogGate -->|no| payload
  payload --> snap
  snap --> cards
  cards --> summaryGet
  summaryGet --> facts
  facts --> groqCache
  groqCache -->|digest not stored| groq
  groqCache --> note
  groq --> note

The browser shows the last forecast saved on the device, then replaces it when a newer one arrives. Opening the page also fetches once when the location's Mist & Fog subcategory and the saved block disagree. The summary request waits until that forecast exists. A failure in Sunsethue, PVGIS, the Mist & Fog block or Groq leaves the Open-Meteo days in place.

Coordinates must already be synced to the server. The forecast uses the saved pin, not the phone's current position. The location has to belong to the signed-in account.

Open-Meteo

The three-day forecast is one Open-Meteo request, model best_match. It also supplies the sunrise and sunset times the rest of the page hangs on. Mist & Fog is a second request, and it runs only when that subcategory is selected.

forecast/openmeteo.py requests https://api.open-meteo.com/v1/forecast for three days, model best_match, timezone Europe/London, Unix timestamps. Units are Celsius, mph and millimetres. Hourly fields:

  • cloud cover, and the low, mid and high layers
  • precipitation probability and amount
  • wind speed, gusts and direction
  • visibility
  • temperature, relative humidity and dew point

Daily fields are sunrise and sunset. Open-Meteo does not send a model-run time, so the page shows when Tempest fetched the payload.

forecast/service.py turns that payload into three local days. When both sunrise and sunset exist, each day has three windows:

  • Sunrise: one hour either side of the published sunrise
  • Daytime: from one hour after sunrise until one hour before sunset
  • Sunset: one hour either side of the published sunset

Comparisons use the UTC instant. Europe/London is only the civil date and the clock label. A spring day has 23 samples and an autumn day has 25, including the repeated hour as two instants. Adding a local hour with a timedelta is not used, because that skips the repeated hour.

Each Open-Meteo field has its own time span:

  • Precipitation is the preceding hour's total. A session takes amount × overlap / 1 hour. Rain is treated as uniform inside that hour.
  • Precipitation probability and gusts are preceding-hour maxima. An hour counts when it overlaps the session. The probability is the highest of those hours. It is not the chance of rain somewhere in the whole session. These values are not prorated.
  • Cloud, wind speed, wind direction, visibility, temperature, humidity and dew point are the sample at that timestamp. A sample counts when the timestamp is inside the session. Nothing is interpolated between hours.

A sunrise at 05:46 therefore ignores the 05:00 precipitation hour (that rain fell from 04:00 to 05:00) and includes the part of the 08:00 hour that falls before 07:46. If one of those hours is absent, the session is coverage: partial and is not scored. The missing local hour on a spring clock change is not a gap in the data.

Weather suitability

forecast/score.py scores a two-hour session from 100. The policy id is suitability-1. The same rule applies to every location. The number is dryness, wind and visibility. It is not a measure of light. Cloud, temperature, humidity and wind direction are shown beside it. Dew point is stored on the hour and is not shown. None of these change the number.

Sunrise and sunset are already two hours, one hour either side of the published time. Daytime keeps that whole span for the hourly table and for a worst-conditions line. The number on the daytime card is the best two-hour session that has not yet ended, stepped every 30 minutes. The same weather over any two such sessions scores the same, so a long day is not penalised for its length.

Rain chance and rain amount describe the same rain, so only the larger deduction is kept. Sustained wind and gusts are treated the same way. The other figure stays in the measurements.

Condition Points removed
Rain the larger of round(max hourly probability × 0.4) and min(30, round(session mm × 15))
Wind the larger of min(25, round(2 × (max mph − 20))) and min(15, round(max gust mph − 20))
Visibility below 10 km min(20, round(2 × (10 − minimum km))), worded as distant views limited

The result is clamped to 0–100 and carries policy. Every subtraction is listed under the number. A dry session with wind at or under 20 mph and visibility over 10 km scores 100. Missing rain or missing wind produces no score. Missing visibility still scores, and the reasons say it was not supplied. Partial coverage produces no score.

Score Rating
80–100 Good
60–79 Fair
40–59 Mixed
0–39 Poor

Cache and failure

The raw Open-Meteo JSON is stored in ForecastCache with provider Open-Meteo. The key rounds latitude and longitude to three decimal places, about 100 m, so nearby pins share one request. A stored payload younger than one hour is reused. Refresh ignores that hour and fetches again.

If Open-Meteo cannot be reached and a payload is already stored, the page keeps that payload and marks it stale. If nothing is stored, the forecast endpoint returns 503 and the browser keeps the device copy when it has one.

prune() deletes ForecastCache rows older than 24 hours and forecast snapshots older than 48 hours. The daily maintenance job runs that prune. It does not delete horizon profiles.

Sunsethue

Sunsethue is the sunrise and sunset colour forecast. It describes colour along the sun's path. The stored caption says it does not account for hills. The forecast card shows that sentence only when a colour percentage exists and the terrain line says the land blocks the sun. A clear window, a missing horizon, and a colour result with no percentage leave the sentence out.

forecast/sunsethue.py sends one GET per event to https://api.sunsethue.com/event: latitude, longitude, local date, type of sunrise or sunset, and forecast=true. The key travels only in the x-api-key header. Up to six events run in parallel.

Tempest keeps, when the body supplies them:

  • colour quality as a percentage and a short quality label
  • cloud cover percentage
  • sun direction in degrees
  • golden hour and blue hour, as London times

A sunrise or sunset whose window has already ended is marked skipped and is not requested. Daytime has no colour block. An empty key marks upcoming events unavailable. A quota, authentication, rate-limit or network failure does the same for that event. Weather suitability is unchanged. Colour can stay high when the sun is behind the land: sky colour and direct sunlight are different signals.

Colour is cached for six hours in ForecastCache under provider Sunsethue, on the same 100 m key as the weather. The cache stores events by date and type, so a later request only asks for events that are not already stored. Refresh fetches the upcoming events again. A failed live call falls back to the stored event for that date when one exists.

PVGIS

PVGIS supplies terrain, not weather. forecast/pvgis.py calls the v6 API with no key:

  • GET /topography/horizon-profile with horizon_profile=PVGIS and angle_output_units=Degrees
  • GET /topography/elevation with the same degree setting

The horizon is the elevation angle of the land around the pin, typically 48 bearings at 7.5° steps, clockwise from north. The elevation call can fail on its own; the horizon is still kept, with spot height left empty. Both calls ask for degrees because the elevation endpoint otherwise echoes coordinates in radians.

The profile is stored in HorizonProfile, keyed to five decimal places, about 1 m. A moved pin is a new profile. The row is not refreshed with the weather and prune() does not delete it. Refresh does not call PVGIS again once a profile exists.

Sun against the land

PVGIS solar position rejects dates after 2025, so forecast/sun.py places the sun with astral. Azimuth is degrees clockwise from north, the same convention as the horizon.

The sun's centre is astral's geometric elevation. Refraction is added once, using astral's refraction at the land angle, together with astral's apparent solar radius (0.2667°). The upper limb clears the land when that sum meets the horizon angle. Refraction is not added a second time. On a flat horizon this matches astral's own sunrise to within a minute. The horizon angle is interpolated between samples and wraps through north. The rule id is apparent-limb-1.

Tempest steps one UTC minute from local midnight to the next local midnight, so a 23-hour or 25-hour day is complete. Every interval where the limb is above the land is kept. For the two-hour sunrise or sunset shooting window:

  • clear, when the limb is above the land within 2 minutes of the published time
  • rises later, or sets earlier, when the crossing is still inside that window
  • blocked for this window, when the window itself has no clearance but the limb clears the land at another time today
  • no clearance today, when the limb never clears the land

The wording is terrain clearance. It does not say clear sky or promise direct sunlight. The line is attached to sunrise and sunset only. It does not replace the Open-Meteo clock time, and it does not change weather suitability or the colour percentage.

The Horizon button draws the stored profile. The sun path uses the same clearance rule, sampled every five minutes while the geometric centre is above −6°. Gold is above the land. A dashed stroke is behind it. The y-axis stays close to the ridge, so the middle of the day can leave the top of the graph. The readout still uses the full sample and says "above the land" or "behind the land". The profile is an elevation model: trees and buildings are absent.

Specialist landscape readings

Eight Landscape subcategories have a specialist reading: Aerial & Drone, Hills, Lakes, Mist & Fog, Mountain, Moving Water, Seascape and Woodland. Astro and Macro do not. A reading is built only when that slug is on the saved location. kept_subcategories() drops a slug when Landscape is off, so there is no extra switch.

forecast/specialist.py is the registry. MODULES maps a subcategory slug to a module, in label order, and enabled() returns the slugs on the location that the registry knows. An empty list does not call UKV, Marine or a river gauge. build() passes those slugs in, plus the location's linked gauge. A failure inside one module becomes status: unavailable for that subject. The Open-Meteo days, and the other subjects, stay as they were.

A module is a class with KEY, LABEL, POLICY and attach(context). Mist & Fog lives in forecast/specialist.py. The other seven live in forecast/subjects/. attach receives the standard-forecast hours, the hours from the previous three days, the assembled day cards, the model elevation, the horizon and the linked gauge. It returns one JSON block and must not raise for a provider failure.

Mist & Fog runs first. Lakes, Hills and Woodland read its dawn level from context["results"] and do not repeat that sentence. When Mist & Fog is not selected, they may add one clause from the standard-model hours, labelled as the standard model. That path does not call UKV.

The page shows one line per selected subject on each day card, after the sunrise and sunset line. When that day has an AI summary, the summary comes first. The label order is fixed. A Low reading, and a reading with no usable fields, is muted. A caution is a short second sentence on the same line. Trip chips do not show these lines. Subjects the device has since removed disappear at once, including offline. Subjects missing from a saved forecast, unavailable subjects, and one stale warning sit in a single notice above the days. Module notes join the bullet list at the bottom. Credits that belong to a subject join the footer, and each URL is shown once.

Each new block has days[]. A day has a level (High, Moderate, Low, or null), a summary, a window, supporting and limiting points, data (complete, partial or insufficient), a caution, and passed when every period on that day had ended at build time. A favourable run is at least two hours. A missing input caps the level at Moderate. It is never stored as zero. The general suitability score is not an input and is not changed.

The shared weather request

forecast/openmeteo.py asks for three forecast days and three past days in the one best_match request. past_hours is not used: combined with forecast_hours it stretches the horizon. Hours before the first of the three forecast days are past_hours. A stored payload from before this change still parses: the new series are missing, so those fields are null, and past_hours is empty.

The extra hourly fields are weather code, wind at 120 m, shortwave radiation, diffuse radiation, sunshine duration, freezing-level height, snowfall and snow depth. Freezing level is often null. A null stays null. Period hours saved on the day card stay on the original keys, so the stored forecast does not grow by those series.

cached_point() stores any specialist provider body in ForecastCache. The advisory lock key includes the provider, so UKV, Marine and a river search do not block each other. load_ukv() is that helper with the UKV accept rule. It still raises ukv.ProviderError when nothing stored can be served.

Mist & Fog

Mist & Fog is the specialist block for mist-fog. The page shows its morning sentence, and "Mist & Fog hours" still opens the dawn window. A saved forecast from before days[] existed falls back to dawns, so the line remains until the next refresh.

UKV model

forecast/ukv.py calls the same Open-Meteo forecast URL with models=ukmo_uk_deterministic_2km, forecast_days=3, timezone Europe/London and Unix timestamps. Units match the standard request: Celsius, mph and millimetres. The hourly variables are:

  • cloud_cover_2m, the near-surface fog cloud
  • visibility
  • relative humidity at 2 m, dew point at 2 m and temperature at 2 m
  • wind speed at 10 m
  • low cloud
  • weather code
  • precipitation

A null stays null. It is never stored as zero. The model runs for roughly two to two and a half days, and a three-day request is null after that. ukv_until is the last hour whose near-surface cloud the model supplied. Hours at or before that instant use the UKV hour whole. Later hours, and an hour the model left entirely empty, use the standard hour already in hand. Those hours are labelled best_match. They do not receive a near-surface cloud or a weather code. One hour is never a mix of the two models.

Coverage comes from the API. HTTP 400 with "No data is available for this location" is outside the grid. A timeout, any other non-200 response, or a body without hourly times is a provider error. The model-run time comes from https://api.open-meteo.com/data/ukmo_uk_deterministic_2km/static/meta.json and is stored on the block. When that call fails, the run time is left empty. The page does not print it. The model updates hourly, and the open UK Met Office data arrives about four hours late.

The fog-1 reading

forecast/fog.py turns the merged hours into a level. The policy id is fog-1. The weights live in THRESHOLDS. This is a reading of the hourly fields, not a calibrated probability, and the points are not shown as a percentage. The dew-point spread is temperature minus dew point, rounded to 0.1 °C before it is judged, so the threshold sees the figure the page shows. Visibility is judged in metres. Precipitation on an hour is the preceding hour, the same convention as the rest of the forecast.

Sign Reading Points
Near-surface cloud 50% or more +4, and this is a strong sign
Near-surface cloud 20% or more, and under 50% +2
Visibility under 1 km +4, and this is a strong sign
Visibility 1 km to 5 km +2
Weather code 45 or 48 fog, or fog depositing rime +3, and this is a strong sign
Humidity 97% or more +2
Humidity 93% or more, and under 97% +1
Dew-point spread 1.0 °C or less +2
Dew-point spread above 1.0 °C, and 2.5 °C or less +1
Wind 5 mph or less +1
Wind 15 mph or more −2
Low cloud 90% or more +1
Precipitation 0.5 mm or more −2

High needs 7 points or more and at least one strong sign. Moderate needs 4 points or more. Anything else is Low. Humidity and the dew-point spread on their own cannot reach High. When precipitation took points off and visibility is also down, the explanation says the reduced visibility may be rain.

An hour is insufficient when near-surface cloud, visibility, humidity and the spread are all missing. It then has no level. Confidence is model when the UKV near-surface cloud is present, including zero. It is reduced when that field is missing and the hour fell back to the standard model.

Each dawn window runs from three hours before the standard forecast's sunrise to two hours after it. The page uses that window for one morning, so a longer run is not copied onto the previous day. The hour at sunrise is the closest sample, and a tie keeps the earlier hour. Three or more consecutive hours of near-surface cloud at 50% or more are sustained fog.

Each dawn has a summary. When the window contains Moderate or High hours, that sentence describes the highest-ranked run inside it: High before Moderate, then the number of High hours, then the total points, then the earliest start. It cites the values that contributed, such as the highest near-surface cloud, the lowest visibility, humidity, spread and wind, and it ends "A model indication, not a guarantee." A window with only Low hours says "Low fog potential around sunrise." A window with no usable fields says "Insufficient data around this sunrise."

The best window is still the contiguous run of Moderate or High hours that has not ended, ranked in the same way and then by overlap with a dawn window. The page does not show that single run. It shows each morning's summary.

Cache, freshness and the page

The UKV body is stored in ForecastCache under provider Open-Meteo UKV, on the same three-decimal-place key as the weather, about 100 m. FOG_CACHE_SECONDS defaults to 3600, in tempest/settings/base.py and .env.example. An outside-coverage answer is stored too, so a pin the model does not cover is not asked again within the hour. A cache miss takes a PostgreSQL advisory lock on the point, checks again, then fetches, so two requests for the same point share one call. Refresh fetches again. A provider failure with a stored row keeps that row and marks it stale. cached_only does not fetch. prune() deletes the row with the other ForecastCache rows after 24 hours. Removing the subcategory stops the request. The stored row then expires through that prune.

The Mist & Fog line renders only when the location on the device currently includes mist-fog. Removing the subcategory hides it at once, including offline. If the location has the subcategory and the saved forecast has no block yet, the notice above the days says so and the page fetches once. An unavailable block names the reason there too. A stale specialist warning is shown once above the days. The UKV horizon note is a bullet with the other forecast notes at the bottom.

On the day, a Moderate or High morning is the summary sentence. A Low morning and a morning with no usable fields are the short muted lines. "Mist & Fog hours" opens the dawn window only: time, potential, visibility, near-surface cloud, humidity, spread, wind, and whether the hour is UKV or the standard model. A missing value is a dash.

Open-Meteo asks for a "Weather data by Open-Meteo.com" link, which is the link already at the bottom of the forecast. When a UKV hour was used, UK Met Office data (CC BY-SA 4.0) is credited beside it. The standard best_match forecast keeps its own CC BY 4.0 credit. The reading does not change weather suitability, Sunsethue or the terrain line. The specialist block is part of the forecast snapshot digest, so a change in any reading mints a new snapshot id. While that morning's dawn window has not ended, the AI summary receives the same sentence. A change in it asks Groq again. The fog block's attribution stays a string. The other modules use a list of {text, url}.

The other seven readings

Each policy id is the version of that reading. The points and thresholds are not shown as a percentage. Photography suitability is not a statement about personal safety. A caution is added only for serious adverse weather, and the line never calls a place or an activity safe.

Aerial & Drone, drone-1

forecast/subjects/drone.py. Wind at 120 m is preferred. When it is missing, wind at 10 m is used and the level is capped at Moderate. No wind at all is insufficient. A dry hour has under 0.1 mm of rain and a chance under 40%. Visibility of at least 5 km, wind at or under DRONE_CALM_MPH (12) and gusts at or under DRONE_GUST_MPH (16) is a good hour. Up to DRONE_LIMIT_MPH (24) is marginal. Above that, or wet, or visibility under 5 km, is poor. Two or more good hours is High. A missing gust or visibility on the chosen run caps High at Moderate.

24 mph is 10.7 m/s, the figure DJI publishes for the Mini 3 and Mini 4 Pro. The Mini 5 Pro publishes 12 m/s, about 27 mph. Raise DRONE_LIMIT_MPH for that aircraft. The note on the forecast says these are photographic stability thresholds, not a clearance to fly. The line does not mention permission. A thunderstorm code (95, 96 or 99) adds a caution. A run that overlaps sunrise or sunset is preferred.

Hills, hills-1

forecast/subjects/hills.py reads the period already on the card: visibility, low cloud, the terrain state and the colour percentage. It does not claim a temperature inversion. Visibility of at least 20 km and a clear terrain line, without low cloud at 80% or more, can be High. Visibility or low cloud missing caps the level at Moderate. Both missing, with no terrain state, is insufficient. A blocked sun is described as shade on the slope.

Lakes, lakes-1

forecast/subjects/lakes.py. Calm is wind at or under 5 mph, gusts at or under 10 mph, and rain under 0.1 mm. Two or more calm hours in the sunrise or sunset window is High and says the wind favours a reflection. It does not say the water will be smooth or mirror-like. Visibility under 10 km caps that High at Moderate. No wind is insufficient. A missing gust caps High at Moderate.

Mountain, mountain-1

forecast/subjects/mountain.py. Visibility of at least 20 km, low cloud under 70%, and a clear or unknown terrain line is High, provided visibility and low cloud were both present. Freezing level and elevation are optional and do not block High. The elevation is the PVGIS height when the horizon status is ok, otherwise the Open-Meteo model elevation. A gap of more than 100 m is a note, because the model ground height is not the shooting height. Gusts of 40 mph or more add a caution about exposed ground. That caution is not a safety assessment. Snow is mentioned when snow depth is at least 1 cm or snowfall is at least 0.2 cm. A freezing level at or below the pin is supporting context, not a hazard claim.

Woodland, woodland-1

forecast/subjects/woodland.py. Soft light is cloud at 70% or more, or sunshine under 600 seconds with cloud missing or at least 50%. Bright is cloud under 40% and sunshine missing or at least 1800 seconds. Calm is wind at or under 10 mph and gusts at or under 18 mph. Two or more soft, calm, dry hours, with fewer bright hours and no more windy hours, is High. The line does not claim how light falls through a canopy. Missing cloud and sunshine caps the level at Moderate. Rain of at least 2 mm in the 24 hours before sunrise can add that the ground may be damp.

Moving Water, water-1

forecast/subjects/water.py. The anchor is sunrise, or local noon when sunrise is missing. Rain at a timestamp is the preceding hour. Under 1 mm in the previous 72 hours and under 2 mm in the next 24 is Low and may mean reduced flow. At least 5 mm in 24 hours, 12 mm in 72 hours, or 5 mm in the next 24 hours is useful. Useful rain with daytime cloud at 70% or more, or mean sunshine under 900 seconds, is High and may favour a waterfall photograph. At least 25 mm in 24 hours or 40 mm in 72 hours is capped at Moderate and carries a caution. The line does not state the actual flow. With no past days in an old cached payload, the 72-hour total is missing and the level stays at most Moderate.

A gauge is optional. The location edit form shows it only while Moving Water is selected, and only while online. Search is GET /api/river-gauges?lat=&lon=, 15 km around the subject position, or the shooting position when the subject has none. The nearest gauge is often a different river, and the help text says so. The choice is stored on Location.river_gauge as {provider, station, label, river} and syncs with the location. Unticking Moving Water clears it. The only accepted provider is ea. The station id is letters, digits, _ and -, at most 40 characters.

forecast/rivers.py reads the Environment Agency flood-monitoring API. Search uses parameter=level. A reading prefers stage in metres. It is cached for RIVER_CACHE_SECONDS (15 minutes) under provider EA:<station>. A reading older than RIVER_STALE_HOURS (24) is unverified. The figure is an observation, shown on today's line only, and it is not a forecast of flow. No gauge means the line says nothing about a river level. A linked gauge that fails says the level is unverified. Above the station's typical range adds a caution and caps the level at Moderate. The required credit is "this uses Environment Agency flood and river level data from the real-time data API (Beta)", and it is shown only when a reading was used.

Seascape, seascape-1

forecast/marine.py calls https://marine-api.open-meteo.com/v1/marine with cell_selection=sea, models=best_match and forecast_days=3. The fields are wave height, period and direction, and the swell and wind-wave split. Sea level is not requested. It is not a tide table. Tide notes stay in docs/future-features.md.

forecast/subjects/seascape.py uses that body only for a selected Seascape. All-null wave fields are inland. A sea cell further than MARINE_MAX_KM (15) is too far. Both answers are cached for MARINE_CACHE_SECONDS, so an inland pin is not asked again within the hour. Inland, too far, or a failed fetch with nothing stored keeps the line and uses the weather and the light only, capped at Moderate, with a note that says why. There is no marine credit in that case.

Wave height under 0.5 m is calm, under 1.25 m slight, under 2.5 m moderate, under 4 m rough, and otherwise very rough. A swell period of at least 10 seconds is long-period. A larger wave is not a better photograph. Calm, slight or moderate seas with favourable light can be High. Rough is Moderate with a caution. Very rough is Low with a caution. Gusts of 40 mph or more add a caution. When the coverage is sea, the footer credits "Marine data by Open-Meteo.com (CC BY 4.0), including Copernicus Marine Service information".

AI summary

The summary is a separate request, made after the day cards render. build() stores the assembled forecast as a ForecastSnapshot for that location and returns snapshot_id. The same location and the same digest reuse the id. The digest covers the suitability policy, the sun rule, which windows were still open, the assembled days, and the specialist block when one was built, so a colour-only change, a window ending, or a change in a specialist reading produces a new id. The summary view then keeps only the blocks whose slugs are still selected, so a subject removed after the snapshot is not sent. A change of linked gauge asks for a new forecast, because the Moving Water block records the station it used.

GET /api/forecast/<location id>/summary?snapshot=<id> reads that row. It checks that the location belongs to the signed-in account. It returns 409 snapshot_expired when the pin has moved or any window that was open in the snapshot has since ended, and it does not call a provider. Otherwise it summarises the stored days. The response echoes snapshot_id. A request without a snapshot id returns 409 snapshot_required.

services/ai_summary.py asks Groq through the OpenAI-compatible API at GROQ_API_URL. The default model is openai/gpt-oss-20b. The key stays in server settings. An empty key removes the summary slot. The model is named in caption(); llama-3.1-8b-instant is no longer the default because Groq shut it down for free accounts.

What the model is given

A day whose sunrise, daytime and sunset have all ended is skipped and the page shows no summary for it. For every window that has not ended, the prompt receives:

  • the window name and its London start and end, plus the chosen two-hour session when that was scored separately
  • the weather suitability, rating and the reason lines
  • mean cloud, and the low, mid and high means
  • Sunsethue quality percentage and label, only when that colour status is ok
  • the terrain state and its sentence, for clear, rises_later, sets_earlier, blocked_window or blocked_day
  • each selected specialist subject whose window has not ended: its label, level, summary, supporting and limiting points, data flag, and the London start and end of its window

Unavailable colour, a skipped event, a missing horizon, and a subject window that has already ended are left out of the subject list. An unavailable subject is named, with its reason, and is not given a level. The hourly fog table is not sent. The model is told to use only those facts, to invent no numbers, and not to recompute or change a level. It should lead with the best opportunity and name a significant limit. It does not have to mention every subject, and it should not copy the subject sentences. Suitability, colour, terrain clearance and the specialist readings are separate signals. Suitability is not light quality. Low visibility, including fog, is not a reason to call the day not worth a visit. A high colour percentage can still be worth using when the sun is behind the land. If the terrain sentence says the sun is behind the land or does not clear it, the reply must not call the horizon clear. Mist and fog is a model indication, not a guarantee, and the reply must not invent it when the fact is absent. River figures are past observations, not a flow forecast. Waves are a model forecast. Tides are not mentioned. The reply never calls a place or an activity safe. A caution, when one was supplied, is mentioned briefly.

The instruction asks for at most two sentences in UK English: which remaining window to prioritise, or that the day is not worth a visit, and what is good about a day that is worth going. The required JSON is {"priority": "sunrise|daytime|sunset|none", "summary": "..."}.

What is kept from the reply

Temperature is 0.2, max_tokens is 2000, and the response format is a JSON object. The larger token budget is there because this model writes reasoning before the JSON, and a day with several specialist readings needs room for that reasoning as well as the note. A 400 from Groq, or JSON that fails the checks below, is tried up to twice more.

A reply is kept only when:

  • priority is none, or it is one of the windows that were actually sent
  • summary is non-empty after trimming to two sentences and 300 characters

Anything else is unavailable for that day. The forecast, the score, the colour and the terrain line are left as they were. The page then shows "AI summary unavailable." A transport failure does the same. Each day is independent, so one bad day does not drop the others.

When Groq is called again

A successful note is stored in ForecastCache under provider groq, on the same 100 m coordinate key as the weather. The lookup key is a hash of the model name, the prompt version (PROMPT_VERSION, currently 5) and the fact JSON. The same facts are not sent again. A changed suitability result, colour result, terrain sentence, specialist reading, model or prompt version is a new hash and a new call. The reply is still returned for the snapshot that was requested. Failed replies are not stored, so the next summary request tries them again.

That cache row's fetched_at is updated when a new note is saved. The 24-hour forecast prune deletes it with the other ForecastCache rows. Horizon profiles stay.

On the device, the summary is saved beside the forecast in IndexedDB and reused only when snapshot_id is the one on screen. A newer snapshot asks for a summary again. A snapshot_expired response refetches the forecast once and summarises the new id. An older reply is discarded. A saved forecast that has no snapshot id is fetched again. The browser allows 30 seconds for that request. Groq's own timeout is 8 seconds per call, with one retry, and the days are asked one after another.

Where a change lands

Change Place
Weather fields or the Open-Meteo request forecast/openmeteo.py
UKV request, nulls and outside coverage forecast/ukv.py
Fog thresholds, levels and the dawn windows forecast/fog.py
Which specialist modules run, the shared provider cache and the hour merge forecast/specialist.py
How long a UKV response is reused FOG_CACHE_SECONDS in tempest/settings/base.py
Aerial, hills, lakes, mountain, water, seascape and woodland readings forecast/subjects/
Marine request, inland and too-far coverage forecast/marine.py
River-gauge search and the latest stage reading forecast/rivers.py
Drone wind figures, marine distance and river freshness DRONE_*, MARINE_* and RIVER_* in tempest/settings/base.py
The optional gauge on a location, and its sync Location.river_gauge, syncapi/schema.py
Suitability penalties, limits, ratings and the policy id forecast/score.py
Hour intervals, rain proration and daylight-saving instants forecast/openmeteo.py
Forecast snapshots ForecastSnapshot in forecast/models.py
Window length around sunrise and sunset WINDOW in forecast/service.py
Colour request, parsing and error types forecast/sunsethue.py
Horizon request forecast/pvgis.py
Sun-versus-land rule and the graph samples forecast/sun.py
Cache times and the order of the three sources forecast/service.py
Forecast and summary HTTP endpoints forecast/views.py
Summary prompt, fact selection and reply checks services/ai_summary.py
Cards, Horizon graph, the specialist lines and the summary slot static/tempest/js/ui/forecast.js
Device cache, the specialist mismatch and the trip-page chips static/tempest/js/forecastdata.js
River-gauge picker on the location form static/tempest/js/ui/edit.js

Trip days reuse this forecast for dates inside the three-day window. The chip shows the score, the first reason, cloud, colour and the terrain sentence. It does not request its own weather.