Reading and writing street-network cases¶
A street-network case bundles a network with the meteorology, emissions and background
concentrations that drive it hour by hour. read_case(path) -> StreetCase reads one from
disk: a directory holding a munich.cfg is read as a MUNICH case; a .dat file is read as
a SIRANE master file (Donnees_*.dat), with the deck it names; anything else raises
ValueError naming both expectations.
from noodl.apps.street_aq import read_case
case = read_case("tests/data/street/munich_paris_excerpt")
case.street_ids # ['1', '3', '8', '11'], the emissions/background street axis
case.species # ['NO2']
case.meteo["u_star"].shape # (3, 4): 3 hours, 4 streets
A SIRANE master file names a whole deck (the network Shapefile, the site, meteo, emission, background and species files) relative to its own directory:
from noodl.apps.street_aq import read_case
case = read_case("tests/data/street/sirane_south_kensington/Donnees_SouthKensington.dat")
case.source # 'sirane'
len(case.street_ids) # 46
case.species # ['NO2', 'NO', 'O3']
case.native["one_sided"] # ['3', '14', '42']: streets with buildings on one side only
The SIRANE network mapping. Street i is the network Shapefile's record i, named
str(i) -- the record order, not the DBF's own ID column, is the street's id in every
SIRANE output. Each street's width is WG + WD (the half-widths either side of the
centreline) and its height mean(HG, HD); a street with HG = 0 or HD = 0 (buildings on
one side only) is listed in native["one_sided"], and its height is still that mean, i.e.
half the one side's. z0_b is Z0D_BAT, SIRANE's building-surface roughness. A street's
two ends are the DBF's NDDEB -> NDFIN node ids, which are also the junction names and
case.junction_ids.
native["physics"] carries the master-file switches that change SIRANE's own results --
background_on, plume_on (the street-plume model above the roofs), retro_on (the
retrotrajectory model), dispersion_model, chemistry_on -- recorded rather than acted on:
noodl physics has no above-roof dispersion model. native["options"] holds every master-file
setting by its SIRANE keyword, native["site_disp"]/native["site_meteo"] the two site
files, native["streets"] each record's SIRANE-consumed DBF fields, and
native["meteo_raw"] the precipitation and cloud-cover columns read_case itself does not
use.
read_case refuses, by name, a SIRANE deck that exercises anything the South Kensington
deck does not: a network file that is not a PolyLine Shapefile, meteorology that is not one
measured station, user-supplied street velocities (B_STREET_U_SIGMA_W = 1), NO emitted as
NO2-equivalent, a per-hour meteo override file, a non-empty source-groups file, a non-empty
surface-emission file, and a point source with non-zero emission (noodl physics' street
model has no point source) -- see read_case's docstring for the complete list.
wind_dir_from_deg is degrees clockwise from north, the direction the wind blows FROM, in
both formats. MUNICH's own WindDirection is radians clockwise from north, the direction
the wind blows TOWARD (MUNICH's preprocessing/meteo.py, compute_wdir); read_case and
write_case convert at the file boundary, and drivers_at converts degrees-FROM into noodl
physics' theta_w (radians counter-clockwise from east, TOWARD). SIRANE's own meteo file is
already in the FROM convention, so no direction conversion happens at that boundary -- only
the unit conversions (g/s, micrograms/m3) do. Every mass in StreetCase is SI (kg/s, kg/m3).
StreetCase¶
| Field | Holds |
|---|---|
source |
Which reader produced the case: "munich", "sirane", or "synthetic" for one built with StreetCase.synthetic. |
network |
The case's StreetNetwork, in metres. |
times |
(n_hours,), seconds since start. |
street_ids |
The streets' names, in network.streets order -- the axis emissions and background use. |
junction_ids |
The source model's own node ids for network.junctions, in that order (MUNICH: intersection.dat's ids; SIRANE: the network Shapefile's NDDEB/NDFIN node ids). |
species |
The case's species names, the last axis of emissions and background. |
meteo |
One (n_hours, n_streets) array per key: wind_dir_from_deg and wind_speed always; h_abl, u_star, lmo, temperature and sigma_theta (the direction spread, radians) wherever the source provides them (a case read from SIRANE has neither h_abl, u_star nor lmo -- SIRANE derives them itself; see StreetCase.model_options()). |
meteo_junction |
The same keys, (n_hours, n_junctions), in network.junctions order -- may be empty or partial when the source has no genuine per-junction meteorology (always empty for SIRANE: one meteorological station for the whole network). |
emissions |
(n_hours, n_streets, n_species), kg/s per street. |
background |
(n_hours, n_streets, n_species), kg/m3 per street. |
native |
The source's own options, as read (MUNICH: one dict per munich.cfg section, plus the lon/lat projection the reader used; SIRANE: the master file's options by SIRANE keyword, the two site files, each street's network fields, the one-sided streets and the raw meteo columns; the refusals are listed above). model_options translates these into build_model keywords, and write_case writes them back when the format matches. |
start |
The absolute date and time of times[0], or None for a synthetic case built without one (writing such a case then raises). |
read_results / StreetResults¶
read_results(path, *, case=None, hours="case") -> StreetResults reads one street model's
own results, format-neutral: a SIRANE result directory (holding RUES_PAR_HEURE/ and
METEO/Resul_Meteo.dat) or a MUNICH results/ directory of <species>.bin files -- a
MUNICH directory always needs case (its binaries carry no street order, species or
absolute time of their own); a path with neither RUES_PAR_HEURE/ nor a case is refused,
naming both expectations.
from noodl.apps.street_aq import read_case, read_results
case = read_case("tests/data/street/sirane_south_kensington/Donnees_SouthKensington.dat")
results = read_results(
"tests/data/street/sirane_south_kensington/RESULT_SOUTHKENSINGTON", case=case,
)
results.source # 'sirane'
sorted(results.meteo) # ['h_abl', 'lmo', 'sigma_theta', 'temperature', 'u_star', ...]
results.u_canyon.shape # (2, 46): SIRANE's own U_moy, m/s
| Field | Holds |
|---|---|
source |
"sirane" or "munich". |
times |
The absolute time of each output hour. |
street_ids |
What every per-street field's street axis indexes (SIRANE: its own record-order ids; MUNICH: case.street_ids). |
species |
c_in/c_above's dict keys, in file order. |
c_in |
One (n_hours, n_streets) array per species, kg/m3: the in-canyon concentration (SIRANE Cint; MUNICH's own street concentration). |
c_above |
The same, kg/m3, for the concentration just above the canyon (SIRANE Cext) -- {} for MUNICH, which has no such output. |
u_canyon |
(n_hours, n_streets), m/s, the mean in-canyon velocity (SIRANE U_moy) -- None for MUNICH. |
sigma_w_roof |
(n_hours, n_streets), m/s, the vertical-velocity fluctuation at roof height (SIRANE Sigma_wH) -- None for MUNICH. |
u_exchange |
(n_hours, n_streets), m/s, the roof-level exchange velocity, read AS PRINTED (SIRANE u_d), not recomputed -- None for MUNICH. |
meteo |
u_star, sigma_theta, h_abl, lmo, wind_speed, wind_dir_from_deg, temperature (K), each (n_hours, n_streets), network-wide (SIRANE's Resul_Meteo.dat, one meteorological station for the whole case) -- {} for MUNICH, whose own meteorology is the case's meteo, not a result. |
hours="case" (the default) keeps only the hours inside case's own period; a SIRANE
result directory can otherwise mix hours from more than one run, so this is the safer
default -- a case hour missing from the directory raises ValueError naming it. hours="all"
returns every hour the directory holds, in time order, with or without a case.
A case read from SIRANE carries none of h_abl, u_star or lmo in its own meteo (see
StreetCase.model_options()): a SIRANE case is driven with these
results' own meteorology instead, through the u_star driver directly -- never through
noodl physics' log law from the measured wind speed.
drivers_at¶
drivers_at(case, model, k, *, species=None) -> dict is the driver mapping at time index k
for model, built from case. It follows the model's own shape: meteo="uniform" reduces
every meteorology array to one network-wide value (circular mean for direction, through the
reciprocal for the Obukhov length, a plain mean otherwise); meteo="per_street" keeps every
driver's trailing street axis and adds the "<key>_junction" drivers junction routing needs,
from case.meteo_junction when the source has it, otherwise the same street-to-junction
reduction. u_star is supplied whenever case.meteo has it, and drives the friction
velocity directly rather than through noodl physics' log law. background follows the
model's own boundary count the same way: one "<layer>.x_boundary" row per street, or one
network-wide mean. Species are matched by name: species defaults to the model's own
species_names, so a case ordered NO, NO2 correctly drives a model ordered NO2, NO (or a
model of a subset of the case's species), for emissions and background alike. An explicit
species must equal the model's species in the model's order, otherwise drivers_at raises.
The sources driver is the case's unscaled emissions: each street's Street.emission_scale is applied inside the model (build_model), once, by street name, and write_case applies it to the emissions it writes into a MUNICH or SIRANE deck, so a noodl run and its deck agree. It is not applied in drivers_at; the background is never scaled. The model's own street order
(street_index(model)) must equal case.street_ids -- build the model on case.network
itself.
drivers_at supplies sigma_theta (radians) when the case's meteo carries it and the model
takes its direction spread from the driver (direction_spread="driver"): one network-wide
mean under meteo="uniform", one value per junction under meteo="per_street" (from
meteo_junction["sigma_theta"] when the case has it, else the mean over the streets meeting
there). A case read from a SIRANE deck has no spread of its own -- SIRANE derives it hour by
hour (results.meteo["sigma_theta"], from read_results) -- so it is added as a driver, see
the SIRANE worked example.
StreetCase.model_options()¶
case.model_options() reads the closure options case's own source model implies: a
preset plus the options the source's own files set, all as build_model keywords.
For source="munich", that is preset="munich" (whose Monin-Obukhov turbulence,
turbulence-intensity direction spread and hard-coded u_d_min=0.001 every MUNICH case
implies) plus munich.cfg's [street] section, translated:
[street] key |
Value | build_model keyword |
|---|---|---|
Mean_wind_speed_parameterization |
Exponential / Sirane |
canyon_wind="exponential_profile" / "bessel_profile" |
Transfer_parameterization |
Schulte / Sirane |
roof_exchange="aspect_ratio_scaled" / "turbulent_velocity" |
Building_height_wind_speed_parameterization |
Sirane / Macdonald |
roof_wind="bessel_canyon_mean" / "canopy_log_law" |
With_horizontal_fluctuation |
yes / no |
direction_averaging="rectangle_rule" / "none" |
Zref |
m | z_ref |
Minimum_Street_Wind_Speed |
m/s | canyon_wind_min |
A missing Minimum_Street_Wind_Speed defaults to MUNICH's own 0.1 m/s
(canyon_wind_min=0.1); Zref's absence still raises, and a value with no counterpart (e.g.
the Wang transfer) raises NotImplementedError.
For source="sirane", the master file does not switch SIRANE's closures, so model_options
returns preset="sirane": the Bessel canyon wind, u_d = sigma_w / (sqrt(2) pi), the
non-crossing-streamline junction routing, the exact Gaussian average of the junction routing
over the direction spread (Soulhac et al. 2011, Eq. 7) with the spread from the sigma_theta
driver, and the three-branch stable/neutral/unstable sigma_w (stability="monin_obukhov").
The deck's turbulence floors SIGMA_W_MIN and SIGMA_V_MIN become sigma_w_min and
sigma_v_min when the master file sets them; otherwise the preset's 0.30 and 0.5 m/s,
SIRANE's own defaults, apply. z_ref is left at build_model's default: noodl
physics has no SIRANE meteorological preprocessor (SIRANE derives u*, the boundary-layer
height, the Obukhov length and the direction spread from the meteo site's wind, temperature
and cloud cover, over that site's own roughness). A SIRANE case is therefore driven with
SIRANE's own u* -- e.g. from read_results(...).meteo["u_star"] -- through the u_star
driver, never through noodl physics' log law from the measured wind speed. Likewise the
dispersion site's Z0D and ZDISPL are recorded in native["site_disp"] but not used:
noodl physics' canopy takes d = 2 h_mean / 3 and z0 = h_mean / 10 from the network.
A synthetic case raises NotImplementedError -- pass build_model's keywords directly
instead.
StreetCase.synthetic¶
StreetCase.synthetic(network, *, species, times, meteo, emissions, background,
meteo_junction=None, start=None) builds a case in Python -- an idealised network to drive
directly, or to write out with write_case. meteo needs wind_dir_from_deg and
wind_speed; the rest are optional. meteo_junction takes any subset of the same keys, or
none at all. Each value is a scalar, an (n_hours,) series, or the full (n_hours, n_streets)
(n_junctions for meteo_junction) array. emissions and background broadcast the same
way, with an optional trailing species axis. Writing the case out with
write_case(format="munich") additionally needs meteo to carry h_abl, u_star and
lmo.
write_case¶
write_case(out_dir, case, *, format="munich", options=None) -> Path writes case under
out_dir and returns out_dir; format is "munich" or "sirane", and case.start must
be set: both formats date every input.
format="munich": case.meteo must have h_abl, u_star and lmo -- MUNICH needs
their PBLH, UST, LMO fields whenever transport is on, which this writer always turns
on. A case read from MUNICH files writes its own [street] section and projection back
(read_case then write_case round-trips those two); [options] itself always turns
chemistry, photolysis, deposition and scavenging off, and the six [meteo] fields MUNICH
always requires (Rain, SolarRadiation, SpecificHumidity, SurfacePressure,
SurfaceTemperature, Attenuation) come from the case where it has one (only
SurfaceTemperature, from meteo["temperature"]), else a constant default -- either way,
options overrides them. options are the format's own overrides -- for MUNICH, any
[street] closure key, any of those six [meteo] fields, or lat0_deg/lon0_deg (the
lon/lat a synthetic network's (0, 0) is anchored to). Missing per-junction meteorology is
derived from the streets meeting at each junction (circular mean for direction, through the
reciprocal for the Obukhov length, a plain mean otherwise).
format="sirane": a complete SIRANE v2.1 deck, master file out_dir / "Donnees.dat" in
French labels. times must be whole consecutive hours; meteorology, background and the
streets' z0_b must each be one value for the network (SIRANE has one meteo station, one
background and one building roughness); the wind speed a multiple of 0.1 m/s and the
direction whole degrees (the meteo file's format); the species must be SIRANE species (a
passive tracer is written as an existing one, e.g. NO2, with chemistry and deposition off).
Masses are written in SIRANE's units (g/s, micrograms/m3). The deck's input folder is
out_dir relative to its parent (SIRANE's working directory) and its results folder
<out_dir>/RESULT (created in advance, with SIRANE's own result subfolders). Every numeric
setting is checked against SIRANE's own range and refused by name outside it. A case read
from a SIRANE deck writes its own physics and numerical settings, site files, street fields
and species flags back.
options for format="sirane": chapman (0/1, SIRANE's Chapman NO-NO2-O3 chemistry;
default 0), plume (0/1, SIRANE's street-plume model above the roofs; default 1),
deposition (0/1; default 0), latitude (deg; default 51.5), measurement_height (m, the
height of the wind speed; default 10), input_dir/result_dir (SIRANE's two folders,
relative to its working directory), and SIRANE's own keyword for any physics or numerical
setting it writes back (e.g. U_MIN).
import tempfile
from pathlib import Path
from noodl.apps.street_aq import read_case, write_case
case = read_case("tests/data/street/sirane_south_kensington/Donnees_SouthKensington.dat")
with tempfile.TemporaryDirectory() as tmp:
out_dir = Path(tmp) / "deck"
write_case(out_dir, case, format="sirane")
(out_dir / "Donnees.dat").is_file() # True: the master file write_case writes
back = read_case(out_dir / "Donnees.dat") # round trip
back.source, len(back.street_ids) # ('sirane', 46)
write_sweep¶
write_sweep(out_dir, base_case, *, directions_deg, speeds, sources="unit_impulse",
species="NO2", variants=None, chapman=0) -> Path writes a sweep of SIRANE decks under
out_dir -- one per (direction, speed, source street, variant) -- plus a manifest of each
deck's parameters.
Each deck covers two hours -- the first a warm-up -- with the wind from direction
at speed, zero background, and a unit emission (1 g/s) of species on the source street
only. sources="unit_impulse" (the default) takes every street in turn; a sequence of
street ids takes those. variants names write_case option overrides for each variant
(default {"plume_on": {"plume": 1}, "plume_off": {"plume": 0}}); chapman applies to
every variant that does not set its own.
import tempfile
from pathlib import Path
from noodl.apps.street_aq import read_case, write_sweep
case = read_case("tests/data/street/sirane_south_kensington/Donnees_SouthKensington.dat")
with tempfile.TemporaryDirectory() as tmp:
out_dir = Path(tmp) / "sweep"
write_sweep(
out_dir, case, directions_deg=[0.0, 90.0], speeds=[3.0], sources=["0", "4"],
species="NO2",
)
sorted(p.name for p in out_dir.iterdir())
# ['decks', 'runs.csv']
Layout: decks/<run-id>/ (each deck, with its results folder decks/<run-id>/RESULT/)
and runs.csv (every deck's run id, direction, speed, source street, variant and options).
A deck reads back with read_case("decks/<run-id>/Donnees.dat"), and SIRANE output in its
results folder with read_results.
Worked example¶
from noodl.apps.street_aq import build_model, drivers_at, read_case
case = read_case("tests/data/street/munich_paris_excerpt")
model, state, _ = build_model(
case.network, species=case.species, meteo="per_street", background="per_street",
**case.model_options(),
)
for k in range(len(case.times)):
drivers = drivers_at(case, model, k)
state = model.steady(state, drivers)
print(state["street.x"]) # kg/m3 per street, at the case's last hour
(munich_paris_excerpt is a four-street excerpt of MUNICH's own published test case -- see
tests/data/street/munich_paris_excerpt/NOTICE.md.)
SIRANE worked example¶
A SIRANE case's own meteo has no friction velocity, boundary-layer height or Obukhov
length: model_options() leaves z_ref at build_model's
default because noodl physics has no SIRANE meteorological preprocessor. Driving the case
therefore takes these, and the hourly direction spread, from read_results:
from dataclasses import replace
import numpy as np
import torch
from noodl.apps.street_aq import build_model, drivers_at, read_case, read_results
FIXTURE = "tests/data/street/sirane_south_kensington"
case = read_case(f"{FIXTURE}/Donnees_SouthKensington.dat")
results = read_results(f"{FIXTURE}/RESULT_SOUTHKENSINGTON", case=case)
# Drive with the RESULTS' own meteorology (u*, h_abl, lmo, direction), not the deck's:
# read_case's own case.meteo has none of h_abl, u_star or lmo.
driven = replace(
case, meteo={k: v for k, v in results.meteo.items() if k != "sigma_theta"},
meteo_junction={},
)
# The deck zeroes SIRANE's turbulence floors (model_options() gives sigma_w_min=0.0); the
# archived results use SIRANE's default sigma_w floor of 0.30 m/s, so that is set here.
model, state, _ = build_model(
driven.network, species=driven.species, meteo="per_street", background="per_street",
**dict(driven.model_options(), sigma_w_min=0.30),
)
drivers = drivers_at(driven, model, 0, species=driven.species)
# SIRANE's own direction spread (one meteorological station for the whole network),
# broadcast onto every junction -- the exact Gaussian direction average takes it as a
# driver, not a constructor value, because it varies hour by hour.
spread = np.unique(results.meteo["sigma_theta"][0])
drivers["sigma_theta"] = torch.full(
(len(driven.network.junctions),), float(spread[0]), dtype=torch.float64,
)
state = model.steady(state, drivers)
print(state["street.x"]) # kg/m3 per street, at hour 0
(sirane_south_kensington is SIRANE's own South Kensington deck with part of its archived
results -- see tests/data/street/sirane_south_kensington/NOTICE.md.)