Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,34 @@ jobs:
- name: Install
run: uv sync --locked

# The Getting Started page runs a real simulation on the met files the
# tests cache: the same cache and key as tests.yml.
- name: Restore HRRR met cache
id: met-cache
uses: actions/cache/restore@v6
with:
path: tests/met_cache
key: met-hrrr-2021-v1

- name: Prewarm HRRR met cache
if: steps.met-cache.outputs.cache-hit != 'true'
env:
STILT_TEST_FETCH_MET: "1"
run: |
uv run pytest \
tests/transport/hysplit/test_release_assignment.py::test_hysplit_multipoint_release_points_follow_control_order \
--setup-only -q

- name: Save HRRR met cache
if: steps.met-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@v6
with:
path: tests/met_cache
key: ${{ steps.met-cache.outputs.cache-primary-key }}

- name: Build
env:
STILT_TEST_MET_DIR: ${{ github.workspace }}/tests/met_cache
run: uv run just build-docs

- uses: actions/upload-artifact@v7
Expand Down
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -579,6 +579,13 @@ when one breaks.
- **Data:** the docs build on GitHub Actions, so an example uses synthetic data,
a small file in the repository, or a public download, never a machine-specific
path. Seed random numbers so the figures don't change from build to build.
- **The Getting Started page runs a real simulation** on the met files the tests
cache (`tests/met_cache`, 14 and 15 January 2021 around Salt Lake), in a
temporary folder. `just build-docs` needs them: set `STILT_TEST_MET_DIR` to
that folder, or fetch them once with
`STILT_TEST_FETCH_MET=1 uv run pytest tests/transport/hysplit/test_release_assignment.py::test_hysplit_multipoint_release_points_follow_control_order --setup-only -q`.
A receptor in the page must stay inside that time window. `docs.yml` restores
the same cache the tests use.
- **Optional dependencies:** the `dev` group installs `pystilt[complete]`, so the
plotting extra (`visualization`) is there in CI. If an example needs another
extra, add `pystilt[extra]` the same way.
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,19 @@ This project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- The Getting Started page runs when the docs are built, on the met files for
14 and 15 January 2021 that the tests use, so its output and figures are
what the code makes. Its receptor moved from 15 July 2023 to 15 January
2021 to fit that met.

### Fixed

- `receptor.plot.map()`, and so `sim.plot.map()`, puts its legend in the upper
right corner. It searched for the emptiest spot, which took over a second
with a simulation's particles on the map, and matplotlib warned.

## [0.1.0a24] - 2026-10-09

### Added
Expand Down
94 changes: 70 additions & 24 deletions docs/getting_started/quickstart.rst
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ fails, naming the hours.

If you already have ARL files, for example in a group archive, tell PYSTILT
the folder they are in, a pattern for their names, and how many hours each
file covers. For files named like ``20230715_18``, each holding six hours:
file covers. For files named like ``20210115_06``, each holding six hours:

.. code-block:: python

Expand Down Expand Up @@ -59,26 +59,43 @@ Step 1: Describe your measurement
---------------------------------

A receptor is where and when you measured. This one is a sensor 10 m above
the ground on the University of Utah campus, at 18:00 UTC on 15 July 2023:
the ground on the University of Utah campus, at 08:00 UTC on 15 January 2021:

.. code-block:: python
.. ipython:: python

import stilt

receptor = stilt.PointReceptor(
time="2023-07-15 18:00", # UTC
time="2021-01-15 08:00", # UTC
longitude=-111.848,
latitude=40.766,
altitude=10, # meters above ground level
)

.. This page runs when the docs are built. It uses the met files the tests
cache, which cover 14 and 15 January 2021 around Salt Lake, and works in a
temporary folder so the project it makes is not left in the repository.

.. ipython:: python
:suppress:

import os
import tempfile
from pathlib import Path

docs_cwd = os.getcwd()
met_dir = Path(os.environ.get("STILT_TEST_MET_DIR", "tests/met_cache")).resolve()
assert met_dir.is_dir(), f"{met_dir} is missing: see 'Examples in the docs' in AGENTS.md"
met = {"directory": str(met_dir), "file_format": "%Y%m%d_%H", "file_tres": "6h"}
os.chdir(tempfile.mkdtemp())

Step 2: Follow the particles
----------------------------

:func:`stilt.run_trajectories` releases particles at the receptor and
follows them back in time through the meteorology:

.. code-block:: python
.. ipython:: python

particles = stilt.run_trajectories(
receptor,
Expand All @@ -100,18 +117,32 @@ This runs HYSPLIT, the transport model, and usually takes a minute or two.
The particles are a :class:`pandas.DataFrame`, one row per particle per
time step:

.. code-block:: python
.. ipython:: python

particles.head()
particles.stilt.plot.map()

@savefig quickstart_particles.png width=6in
particles.stilt.plot.map();

The particles start at the receptor and spread out. The met files here cover
only a small region around Salt Lake, and the particles leave it before the 24
hours are up. A particle that leaves the met stops there. This is how many hours
they went (``age`` is in minutes):

.. ipython:: python

print(f"{particles['age'].abs().max() / 60:.1f} hours")

If that is short of ``n_hours``, the footprint covers only those hours.
:doc:`../guides/checking` says when that matters.

Step 3: Calculate the footprint
-------------------------------

The footprint is calculated on a grid. Make it big enough to include the
areas upwind of your site. Here the cells are 0.01°, about 1 km:

.. code-block:: python
.. ipython:: python

grid = stilt.Grid(
xmin=-113.0, xmax=-110.5, # longitude range
Expand All @@ -120,19 +151,22 @@ areas upwind of your site. Here the cells are 0.01°, about 1 km:
)
foot = stilt.calc_footprint(particles, receptor, grid)

foot.stilt.plot.map()
@savefig quickstart_footprint.png width=6in
foot.stilt.plot.map();

You should see the most influence near the receptor, trailing off in the
direction the air came from. The map shows the footprint summed over all 24
hours, on a log scale. Cells with more color influenced the measurement more.
The footprint starts at the receptor and stretches in the direction the air
came from, here to the north-northwest. The map shows the footprint summed over
time, on a log scale. Cells with more color influenced the measurement more.

The footprint is an :class:`xarray.DataArray` with dimensions
``(time, lat, lon)``, one map per hour, in units of ppm per
(µmol m⁻² s⁻¹). Sum it over time with xarray:
``(time, lat, lon)``, in units of ppm per (µmol m⁻² s⁻¹). It has one map for
each hour the particles were still inside the met files. Sum it over time with
xarray:

.. code-block:: python
.. ipython:: python

foot.sum("time")
foot.sizes
foot.sum("time").sizes

PYSTILT's own methods on the particles and the footprint are under
``.stilt``.
Expand All @@ -146,7 +180,7 @@ runs every receptor, and keeps the results. :meth:`Project.init
<stilt.Project.init>` creates the folder and writes both to it. The
settings are the ones above:

.. code-block:: python
.. ipython:: python

project = stilt.Project.init(
"./my_first_project",
Expand All @@ -157,7 +191,7 @@ settings are the ones above:
numpar=200,
grid=grid,
)
project.run()
project.run();

``run()`` runs the particles and the footprint of every receptor, and
returns when they are done.
Expand All @@ -167,12 +201,19 @@ there is one variant, named ``hrrr`` after the met. One receptor under
one variant is a :term:`simulation`. Look yours up by the receptor's id and
the variant name:

.. code-block:: python
.. ipython:: python

sim = project.simulation(receptor.id, "hrrr")
sim.footprint.stilt.plot.map()
sim.particles.head()

@savefig quickstart_sim_footprint.png width=6in
sim.footprint.stilt.plot.map();

This footprint looks a little different from the one in Step 3, although the
settings are the same. Each run draws its own random numbers for the
turbulence, and 200 particles are few. To repeat a run exactly, set ``krand: 2``
and a ``seed``.

What PYSTILT wrote
------------------

Expand All @@ -185,9 +226,9 @@ folder.
config.yaml # your settings
receptors.csv # your receptors
output/
particles/settings=hrrr-a3f9c2/date=2023-07-15/202307151800_-111.848_40.766_10.parquet
footprints/settings=hrrr-93278c/date=2023-07-15/202307151800_-111.848_40.766_10.parquet
logs/settings=hrrr-a3f9c2/date=2023-07-15/202307151800_-111.848_40.766_10.log
particles/settings=hrrr-a3f9c2/date=2021-01-15/202101150800_-111.848_40.766_10.parquet
footprints/settings=hrrr-93278c/date=2021-01-15/202101150800_-111.848_40.766_10.parquet
logs/settings=hrrr-a3f9c2/date=2021-01-15/202101150800_-111.848_40.766_10.log

Each folder under ``particles``, ``footprints``, and ``logs`` is a
:term:`settings folder <settings folder>`: the variant's name, ``hrrr``,
Expand Down Expand Up @@ -251,7 +292,7 @@ line, so the file reads:
.. code-block:: text

time,longitude,latitude,altitude
2023-07-15 18:00:00,-111.848,40.766,10
2021-01-15 08:00:00,-111.848,40.766,10

Then run it and check the result:

Expand All @@ -260,6 +301,11 @@ Then run it and check the result:
stilt run ./my_first_project
stilt status ./my_first_project

.. ipython:: python
:suppress:

os.chdir(docs_cwd)

Next steps
----------

Expand Down
4 changes: 2 additions & 2 deletions src/stilt/visualization.py
Original file line number Diff line number Diff line change
Expand Up @@ -465,7 +465,7 @@ def map(
if met_bounds is not None:
_draw_met_box(ax, met_bounds)

ax.legend(fontsize=8)
ax.legend(fontsize=8, loc="upper right")
if standalone:
ax.set(xlabel="Longitude", ylabel="Latitude", title="Receptor")
return ax
Expand Down Expand Up @@ -565,7 +565,7 @@ def map(

if met_bounds is not None:
_draw_met_box(ax, met_bounds)
ax.legend(fontsize=8)
ax.legend(fontsize=8, loc="upper right")

ax.set_title(f"Simulation {sim}")
return ax
Expand Down
19 changes: 19 additions & 0 deletions tests/test_visualization.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import datetime as dt

import matplotlib
import matplotlib.legend
import numpy as np
import pandas as pd
import pytest
Expand Down Expand Up @@ -174,6 +175,24 @@ def test_receptor_map_multipoint_returns_axes(multi_receptor):
assert ax is not None


def test_receptor_map_legend_has_a_fixed_place(receptor, monkeypatch):
"""
The legend sits in a corner instead of searching for the emptiest spot.

With loc="best", matplotlib tries every candidate against every point on the
axes, and warns when that takes over a second, as it does with the particles
of a simulation.
"""

def search(self, *args, **kwargs):
raise AssertionError("the legend searched for its best position")

monkeypatch.setattr(matplotlib.legend.Legend, "_find_best_position", search)
fig, ax = plt.subplots()
receptor.plot.map(ax=ax)
fig.canvas.draw() # the legend is placed at draw time


def test_receptor_map_with_domain(receptor, grid):
ax = receptor.plot.map(domain=grid)
assert ax is not None
Expand Down
Loading