Slant Columns#
A column instrument that does not look straight up measures air along a
tilted path: a ground-based solar-tracking spectrometer (EM27/SUN, TCCON)
looks at the sun, and an off-nadir satellite sounding looks toward the
satellite. PYSTILT represents that path as a
MultiPointReceptor whose points step up the line of sight,
runs all of them in one simulation, and weights the particles afterwards.
This page walks through the geometry conventions, an EM27/SUN example, and
the satellite variant, including slant samples on the retrieval’s own
pressure levels.
Geometry#
slant_points() turns a location, a list of
altitudes, and two angles into (longitude, latitude, altitude) points.
Each altitude sits at
metres from the location along the azimuth bearing, where \(\theta\) is
the zenith angle and \(z_\text{anchor}\) is the altitude at which the
path passes through the location (the first altitude unless you pass
anchor=). The conventions to get right:
zenithDegrees from the local vertical. For a solar tracker this is the solar zenith angle at the time of the measurement.
azimuthDegrees clockwise from north, giving the bearing from the ground point toward the instrument or the sun. For a solar tracker this is the solar azimuth angle. Higher points of the receptor are displaced in this direction. Satellite products usually report the bearing to the satellite under a name like
sensor_azimuth_angleorviewing_azimuth_angle; confirm the product’s definition, since a few report the reverse bearing.- Altitudes
Use
altitude_ref="msl"for a slant. The path is a straight line in geometric height, and terrain-following (AGL) altitudes would bend it. Start at the station or surface altitude and end at the top of the air you want to resolve, no higher than the meteorology’s top. Air above the last point is not sampled and belongs to the background (see Weighting below).
The construction uses a flat tangent plane. At a 3 km column and an 80° zenith angle the path reaches 17 km horizontally, where the curvature error is still under a few percent, so this is fine for the release heights STILT uses.
EM27/SUN example#
An EM27 retrieval gives one column value per spectrum with the solar
zenith and azimuth angles at that time. GGG (through EGI) writes a .oof
per instrument-day, which read_ggg_oof() reads
into the same table the other readers produce (Reading Retrieval Products). The sun
moves about 15° per hour, so build one receptor per averaging window rather
than one per day. The points come from slant_points()
and the receptor from stilt.Receptor.from_points().
import numpy as np
import stilt
from stilt.observations import read_ggg_oof, slant_points
df = read_ggg_oof("ha20230715.vav.ada.aia.oof", "xch4")
df = df[df.good]
windows = (
df.set_index("time")[["longitude", "latitude", "surface_altitude", "zenith", "azimuth"]]
.resample("10min").mean().dropna()
)
receptors = [
stilt.Receptor.from_points(
t,
slant_points(
w.longitude, w.latitude,
np.linspace(w.surface_altitude, w.surface_altitude + 3000.0, 20),
zenith=w.zenith, azimuth=w.azimuth,
),
altitude_ref="msl",
)
for t, w in windows.iterrows()
]
model.register(receptors=receptors)
model.run()
Twenty points over 3 km is a typical spacing; HYSPLIT distributes the
particles across the points, so raise numpar with the point count. Check
the geometry before running by plotting receptor.longitudes and
receptor.latitudes against receptor.altitudes for a morning and an
afternoon window: the path should lean east in the morning and west in the
afternoon.
Weighting#
A slant receptor’s particles start uniformly in height. A column instrument averages over air mass and applies its own averaging kernel, so weight the particles before they become a footprint (Particle Weighting (Transforms)):
footprints:
column:
grid: slv
transforms:
- kind: pressure_weighting
pressure_weighting is derived from the particles and needs nothing
supplied. The weights sum to the fraction of the atmosphere’s mass the
receptor covers, so the footprint is complete for the surface fluxes; the
column above the receptor top is background.
The averaging kernel is different. An EM27 kernel changes with the solar
zenith angle, so each window has its own. A .oof does not carry it: take
it from the run’s *.private.nc with
read_ggg_netcdf(), which expands GGG’s kernel
table per spectrum, or from a site table keyed by solar zenith angle
(PROFFAST tabulates one that way). Write the kernels into the project as a
table keyed by receptor, with
averaging_kernel_table(), and let the footprint’s
averaging_kernel transform look each one up:
from stilt.observations import read_ggg_netcdf
from stilt.transforms import averaging_kernel_table
ak = read_ggg_netcdf("ha20230715_20230715.private.nc", "xch4").set_index("time")
# one kernel per window: the spectrum nearest the window's midpoint
nearest = ak.index.get_indexer(windows.index + pd.Timedelta("5min"), method="nearest")
kernels = [ak.ak.iloc[i] for i in nearest]
table = averaging_kernel_table(receptors, levels=ak.ak_pressure.iloc[0], values=kernels)
table.to_parquet(model.project.directory / "kernels.parquet")
footprints:
column:
grid: slv
transforms:
- kind: averaging_kernel
table: kernels.parquet
coordinate: pres
- kind: pressure_weighting
The kernel’s levels are heights above ground by default; the GGG kernel
sits on pressures, hence coordinate: pres. If one kernel is a good
enough approximation for a campaign, give it inline as levels and
values instead of a table.
Satellite soundings#
For a satellite product you have many soundings per overpass, each with its own location, surface altitude, viewing angles, and averaging kernel. The same two lines apply per row of the product table, anchored at each sounding’s surface altitude (MSL):
import numpy as np
import stilt
from stilt.observations import slant_points
receptors = [
stilt.Receptor.from_points(
row.time,
slant_points(
row.longitude, row.latitude,
np.linspace(row.surface_altitude, row.surface_altitude + 3000, 20),
zenith=row.vza, azimuth=row.vaa,
),
altitude_ref="msl",
)
for row in df.itertuples()
]
model.register(receptors=receptors)
Altitudes from the retrieval’s pressure levels#
Retrievals define their vertical grid in pressure: OCO-2 reports
pressure_levels per sounding, and TROPOMI’s layers are
surface_pressure minus multiples of pressure_interval. To put the
slant samples on those layers instead of an even spacing in height,
pressure_altitudes() converts the levels to MSL
altitudes with the hypsometric equation, anchored at the sounding’s surface
pressure and surface altitude:
from stilt.observations import pressure_altitudes, slant_points
levels = pressure_altitudes(
row.pressure_levels, # hPa, in the product's order
surface_pressure=row.surface_pressure, # hPa
surface_altitude=row.surface_altitude, # m MSL
top=row.surface_altitude + 3000.0, # keep the levels you want to resolve
)
points = slant_points(
row.longitude, row.latitude, levels, zenith=row.vza, azimuth=row.vaa
)
The result is sorted from the surface upward, so the first altitude anchors
the path at the sounding’s location whatever order the product lists its
levels in; levels below the surface are dropped, and top drops the
levels above it (the meteorology’s top is a sensible cap). Without a
temperature the conversion uses the standard-atmosphere lapse rate from the
surface, which reproduces the U.S. Standard Atmosphere below 11 km and is
within a few percent of a real profile. Pass temperature= as one
temperature per level (K) when the retrieval or its prior gives one, or as a
single temperature for an isothermal scale height. Points on pressure levels
are still points in height to HYSPLIT; the pressure weighting and averaging
kernel below apply unchanged.
Satellite And Column Observations walks through selecting the soundings first
and writing their kernels to the project. Many satellite workflows
neglect the slant and use a ColumnReceptor instead; at a 20°
viewing angle the top of a 3 km column is only 1 km off the nadir point,
which is within a footprint grid cell for coarse grids.
How the release heights come back#
HYSPLIT does not record which point a particle started from, and vertical weighting needs each particle’s release height. PYSTILT recovers it from the first row HYSPLIT writes for the particle by matching on height, which holds to about 20 m for a slant because the points differ in altitude. See Release heights for multipoint and slant receptors in Receptors, including how to run a HYSPLIT build that writes exact release-time rows.