Usage#

A map of the valley#

SaltLake makes a cartopy map of the valley; each add_* method adds a layer and returns the map, so calls chain.

from slv.basemap import SaltLake

m = (
    SaltLake(tiles="terrain")  # Stadia stamen_terrain; needs $STADIA_API_KEY
    .add_population()  # block-group density with a colorbar panel
    .add_trax(lines="RG")  # red and green lines
    .add_sites(["wbb", "ldf", "hdp"], labels={"wbb": "UOU"})
    .add_mesowest()
    .add_legend()
    .add_inset()
    .add_north_arrow()
)
m.fig.savefig("slv.png", dpi=300)

tiles=None draws without tiles (no network). Pass ax= to draw on an existing cartopy axes, and your own data to any add_* method in place of the defaults.

Measurements#

from slv.measurements import aggregate_obs, load_concentrations

obs = load_concentrations(
    "CH4", sites=["wbb", "hdp"], time_range=("2024-01-01", "2024-02-01")
)
hourly = aggregate_obs(obs, freq="1h")

A methane inversion#

An InversionConfig describes the run; build_problem() builds the fips problem from it (obs, background, prior, Jacobian from the PYSTILT footprints, covariances), and run() builds it, solves it and prints a summary. Components are cached under cache keyed on the settings they depend on, so a second run that changes only, say, prior_base_std reuses the obs and the Jacobian. The keys also see the input files: the CT-STILT CSV (background_kwargs) and mobile_obs by their size and modification time, and the STILT projects by how many of the selected receptors have a footprint and which settings folder they are read from. A CSV rewritten in place, or simulations run since the last run, rebuild what depends on them; a cache keyed by path alone could not see either. An obs the model cannot simulate is dropped before the solve, and the run prints how many: one with no background, and one whose receptor has no footprint (its STILT simulation was not run, or failed). A receptor whose footprint is empty, because its particles left the domain, keeps its obs: it saw no emissions.

from slv.inversion import InversionConfig, run

config = InversionConfig(
    tstart="2016-01-01",
    tend="2024-01-01",
    flux_freq="MS",
    sites=["wbb"],
    aggregate_obs="1D",
    background="rolling",
    prior="epa",
    variant="hrrr",
    cache="./cache",
    num_processes=30,
)
post = run(config)  # a fips Posterior

post.estimate["flux"].to_series()  # the posterior flux by time and cell
post.problem.prior["flux"]  # the prior, a fips Vector
post.cov(W)  # the posterior covariance of aggregates W (an Operator), such as totals

The flux intervals are whole flux_freq periods from tstart to tend, so both must be on its boundaries: January 1 for "YS", the first of a quarter for "QS", the first of a month for "MS", Sundays two weeks apart for "2W". A config that is off them raises ValueError rather than leave the obs of the days outside whole periods with no flux to fit.

Building the Jacobian reads every footprint in the period: run it through SLURM, not on a login node.

Sweeping hyperparameters#

Sweep runs one inversion per combination of the listed values, sharing the cache, and collects the fit metrics.

from slv.inversion import Sweep

sweep = Sweep(
    cache="./cache",
    base_config=config,
    prior_base_std=[0.01, 0.02],
    gamma=[1.0, 3.0],
)
results = sweep.run(results_dir="./sweep", n_jobs=4)
results.best()  # reduced chi^2 within 0.1 of 1

For a SLURM job array, write the grid with sweep.run(results_dir, n_jobs=0) and call run_sweep_job() in each array task; it runs the config at $SLURM_ARRAY_TASK_ID.

TRAX receptors#

The TRAX light-rail record becomes STILT receptors: one multi-point receptor per pass over a 2-km track segment, and one point receptor per hour a train sits parked.

from slv.measurements.mobile import (
    build_dwell_receptors,
    build_trax_receptors,
    find_dwells,
    find_segment_crossings,
    load_trax_fixes,
    load_trax_network_points,
)

points = load_trax_network_points(meters=True)  # 50-m points, 2-km segments
fixes = load_trax_fixes(location="outdoor")

dwells = find_dwells(fixes)
crossings = find_segment_crossings(fixes, points)  # drop the dwell fixes first
receptors = build_trax_receptors(crossings, points, hours=range(12, 17))
dwell_receptors = build_dwell_receptors(fixes, dwells, hours=range(12, 17))

The observation for each receptor comes from trax_receptor_observations(), and the inversion reads them through InversionConfig(mobile_obs=...).