How to warm the weather cache for an offline run¶
Some environments cannot reach the internet when they run: a hardened CI runner, an air-gapped cluster, a container whose egress is closed by policy. idfkit is built to work in them, but weather files are the one thing it cannot invent, so they have to be put on disk while a network is still available.
This guide covers the build-time warm-up: what to pre-download, where to put it, and the two settings that decide whether the isolated run is silent or spends five minutes discovering it is offline.
Everything here is Python. There is no equivalent procedure in JavaScript because there is nothing there to warm: that library keeps no cache directory, so its offline story is decided at install time rather than at build time.
Python only, permanently
Caching retrieved weather files on disk belongs to Python alone. JavaScript is not waiting on a port and will not gain a counterpart: this is a permanent boundary, so no issue tracks it, and moving the capability out of that state takes a constitutional amendment rather than a ledger edit.
FR-031, stated as a decision rather than a backlog item: the on-disk cache for retrieved
weather and design-day files is Python's, and JavaScript MUST NOT grow one. Moving this
entry out of never needs a constitutional amendment, not an edit here.
What Python has. WeatherDownloader owns a cache directory, per platform by default and moved with cache_dir or IDFKIT_CACHE_DIR. download() looks in it first and reaches the network only on a miss or past max_age, so the second call for a station costs nothing; clear_cache empties it. Everything downstream follows: the record it returns is paths rather than text, zip_path addresses the archive it kept, and selective extraction through only= is meaningful because a suffix skipped this time may already be sitting there from an earlier call.
Why JavaScript will not have one. @idfkit/weather targets a browser, a worker, and an edge runtime as well as Node. Two of those have no directory it could own, and picking one in Node would make the package behave differently depending on where it runs, which is the one thing a portable package must not do. So it fetches every time and returns text the caller keeps. saveWeatherFiles in @idfkit/weather/node writes a retrieved bundle where the caller says, and that is a caller-directed write rather than a cache: it consults nothing on the way in and remembers nothing on the way out.
This is not a gap in JavaScript, and it is why several one-sided names exist on each
side. The station index is shipped, not cached, in both languages (FR-043, FR-075), so
nothing here changes how either library finds a station: that is
weather-index. Retrieval itself is present
in both languages: that is
weather-download.
The full entry, including the vocabulary this capability owns, is on the capability parity page.
What needs warming, and what does not¶
Only the weather files need warming. The station index does not.
| Asset | Ships with the package | Needs the network |
|---|---|---|
| Station index (about 70,000 stations) | yes, as stations.json.gz |
no |
| EPW and DDY files | no | yes, once per station |
IP geolocation (ipgeo.json) |
no | yes, and do not warm it: see below |
| Schemas, validation, introspection, documentation URLs | yes | no |
StationIndex.load() reads the bundled index straight off disk. Searching it,
resolving a station, and reading its metadata are all local. Only
WeatherDownloader.download() reaches for anything.
StationIndex.refresh() is optional
refresh() re-downloads the upstream KML indexes and rebuilds the cached
index. It is a freshness update, not a required fetch. A run that never
calls it still gets a full station index, just the one that shipped with
the installed version of idfkit. Do not put refresh() in a warm-up script
unless you specifically want a newer index than the release carries.
1. Choose a cache location the isolated run can read¶
Set IDFKIT_CACHE_DIR
to a path you control, in both the warming environment and the isolated one.
Without it, idfkit uses a platform location that a build container and its
runtime almost never share.
An explicit IDFKIT_CACHE_DIR is never silently relocated. If the path turns
out to be unwritable, the write fails and names the path you chose, rather than
quietly moving your downloads somewhere the later run will not look.
2. Pre-download the stations the run will need¶
from idfkit.weather import StationIndex, WeatherDownloader
# Every station the isolated run will later ask for.
QUERIES = ("chicago ohare", "denver intl ap", "singapore changi")
index = StationIndex.load()
downloader = WeatherDownloader()
for query in QUERIES:
results = index.search(query)
if not results:
# Fail here, where there is still a network to fix the problem with.
msg = f"no weather station matched {query!r}"
raise LookupError(msg)
station = results[0].station
files = downloader.download(station, only={".epw", ".ddy"})
print(f"warmed {station.display_name}: {files.epw}")
only={".epw", ".ddy"} skips extracting the STAT, CLM, WEA, PVSYST, and RAIN
members of the upstream bundle, none of which a simulation reads. It saves
disk, not bandwidth: the archive is downloaded whole either way and kept, so
the transfer is identical. For Chicago O'Hare the full extraction occupies
2.9 MB against 2.1 MB for the EPW and DDY alone, the 384 KB archive included in
both. Worth having across a few hundred stations, not worth contorting a build
for.
Fail the build on a station that does not resolve. A warm-up that quietly skips a miss produces a cache that looks complete and fails hours later, in the one environment that cannot fix it.
3. Turn off the freshness nudge in the isolated run¶
This is the step that is easy to miss, and warming the cache alone does not cover it.
StationIndex.load() fires a throttled freshness check: at most once every 24
hours it sends a HEAD request for each of the 10 upstream index files, warns if
the loaded index is behind, and records the check under the cache directory.
Offline, every one of those requests fails and is swallowed, so the call still
returns a complete index and nothing breaks. What it costs is time. Each
request carries a 30 second timeout, so on a network that drops packets rather
than refusing them, the first StationIndex.load() of the day can block for up
to five minutes before returning a result it already had on disk.
Measured with every socket call failing the way an offline machine fails, so the counts are attempts made rather than requests that happened to succeed:
| Configuration | StationIndex.load() network attempts |
|---|---|
| Cold writable cache, nudge on | 10 |
Cold writable cache, IDFKIT_NO_WEATHER_UPDATE_CHECK=1 |
0 |
| Warm cache checked within the last 24 hours | 0 |
| Read-only cache | 0 |
The nudge is a convenience for interactive use, where a stale index is worth a warning. In an environment that cannot act on the warning, it buys nothing.
4. Mount the cache read-only, if you like¶
A warm cache mounted read-only is a supported configuration and needs no extra
setting. Reads do not require write permission, and the freshness nudge stops
on its own: it cannot write its throttle timestamp, so it gives up before
reaching for the network. That is why the read-only row above reads 0 without
IDFKIT_NO_WEATHER_UPDATE_CHECK being set at all.
Set IDFKIT_NO_WEATHER_UPDATE_CHECK anyway. It makes the intent explicit
rather than relying on a permission bit to suppress a network call.
5. Run offline¶
from idfkit.weather import StationIndex, WeatherDownloader
index = StationIndex.load() # the bundled index, read from disk
station = index.search("chicago ohare")[0].station
files = WeatherDownloader().download(station) # served from the warm cache
print(files.epw)
A worked example: warming an image at build time¶
The whole procedure in one container. The build has a network, the run does not.
FROM python:3.12-slim
ENV IDFKIT_CACHE_DIR=/opt/idfkit-cache \
IDFKIT_NO_WEATHER_UPDATE_CHECK=1
RUN pip install --no-cache-dir idfkit
# Warm the cache while the build still has a network.
COPY warm_up.py /tmp/
RUN python /tmp/warm_up.py
COPY run.py /tmp/
CMD ["python", "/tmp/run.py"]
Set both variables in the image rather than at docker run time. They have to
apply to the build stage that warms the cache and to the run stage that reads
it, and a cache warmed at one path and read from another is the single most
common way this goes wrong.
The cache does not need to be writable at run time. Running the same image as a
user who cannot write to /opt/idfkit-cache works unchanged, which is what
makes a read-only mount or a non-root runtime a supported configuration rather
than a thing to work around.
Verify the warm-up before you rely on it¶
Run this while the network is still available. It exercises the same calls the
isolated run will make, and a warm cache turns every download() into a disk
read.
from pathlib import Path
from idfkit.weather import StationIndex, WeatherDownloader
from idfkit.weather.index import default_cache_dir
QUERIES = ("chicago ohare", "denver intl ap", "singapore changi")
cache: Path = default_cache_dir()
index = StationIndex.load()
downloader = WeatherDownloader()
missing: list[str] = []
for query in QUERIES:
station = index.search(query)[0].station
# download() returns from disk when the bundle is already cached, so a warm
# cache makes this a no-op. Run it while the network is still available and
# a miss is repairable, never as the isolated run's first act.
files = downloader.download(station, only={".epw", ".ddy"})
if files.epw is None or files.ddy is None:
missing.append(station.display_name)
if missing:
msg = f"cache at {cache} is missing EPW or DDY for: {', '.join(missing)}"
raise SystemExit(msg)
print(f"cache at {cache} covers all {len(QUERIES)} stations")
To prove the offline claim rather than assume it, block the network inside the process instead of switching off an interface. Raising from the socket layer shows that no request was even attempted, which is a stronger result than a run that merely happened to succeed:
import socket
def refuse(*args: object, **kwargs: object) -> object:
raise socket.gaierror(8, "offline")
socket.socket.connect = refuse # type: ignore[method-assign]
socket.create_connection = refuse # type: ignore[assignment]
socket.getaddrinfo = refuse # type: ignore[assignment]
# Everything below this line must still work.
What still needs the network¶
These are the only calls that retrieve, and they fail loudly offline rather than returning something empty:
| Call | Offline behaviour |
|---|---|
WeatherDownloader.download() for a station not in the cache |
raises RuntimeError naming the URL it could not reach |
StationIndex.refresh() |
raises RuntimeError naming the index file it could not fetch |
StationIndex.check_for_updates() |
returns False, by design, because a freshness check that cannot reach upstream has learned nothing |
geocode() and detect_location() |
raise GeocodingError |
Geocoding is worth calling out separately. geocode() has no cache, so an
isolated run has to resolve its addresses to coordinates before it loses the
network, or work from coordinates directly.
Do not warm detect_location()
detect_location() does keep a disk cache, ipgeo.json, alongside the
weather files, with a one hour default expiry. Warming it is worse than
leaving it cold: it records the build machine's approximate location
from its public IP, so a warmed entry would hand the isolated run the
coordinates of your CI runner and be believed. If a run needs to know where
it is, pass the coordinates in.
See also¶
- Environment variables for the full
contract of
IDFKIT_CACHE_DIRandIDFKIT_NO_WEATHER_UPDATE_CHECK - How to download weather files for the downloader's full API
- Weather data pipeline for why the index ships and the files do not