# simsat — Python binding
Physically-based simulated visible/IR satellite imagery from WRF output, as numpy arrays
you can plot with matplotlib/cartopy. A thin PyO3 wrapper over the Rust `simsat` engine.
## Build / install (Linux or WSL)
```
pip install maturin
cd crates/simsat_py
maturin build --release # produces a `simsat-*.whl` in target/wheels/
pip install target/wheels/simsat-*.whl
# or, for local development:
maturin develop --release
```
The wheel is `abi3` (built against the CPython stable ABI), so ONE wheel works on any
CPython >= 3.8 — no per-version rebuild. `numpy` is the only runtime dependency.
## The eleven canonical render functions
Each takes a wrfout file OR a cached `run.json` as `input` (a wrfout is ingested to a
cached brick on first use, and the cache re-ingests automatically if the wrfout is
re-written over the same path) and returns `(array(s), georef)`:
| function | returns | what it is |
|---|---|---|
| `render_visible_rgb(input, ...)` | `H x W x 3` uint8 | the finished true-color visible image (the shipped display look) |
| `render_geocolor(input, ...)` | `H x W x 3` uint8 | GeoColor Style / SimSat Day-Night Color: broad-RGB visible by day, colored band-13 IR by night, crossfaded across the terminator. It is not yet sensor-derived ABI GeoColor |
| `render_sandwich(input, ...)` | `H x W x 3` uint8 | Sandwich severe-convection composite: visible base + color-enhanced band-13 IR overlaid on the cold cloud tops (a daytime product) |
| `render_rgb_reflectance(input, ...)` | `H x W x 3` float32 `[0,1]` | RAW broad-RGB reflectance (pre-tonemap) for custom RGB / reflectance math; not discrete ABI sensor bands |
| `render_ir(input, ...)` | `H x W` float32 KELVIN | RAW band-13 brightness temperature; `sensor='fast-gray'` is the unchanged default, while `sensor='goes-r-abi-band13-fm4'` applies NOAA's official FM4/GOES-19 SRF; opt-in `instrument_footprint='goes-r-abi-band13-mtf-prototype'` requires exact GOES-R navigation and the ABI 2-km global lattice; `enhancement=` adds a colored `H x W x 3` uint8 (`(bt, rgb, geo)`) |
| `render_water_vapor(input, band='6.2'\|'6.9'\|'7.3', ...)` | `H x W` float32 KELVIN | RAW water-vapor band 8/9/10 BT (upper/mid/lower-level moisture); `enhancement=` as `render_ir` |
| `render_precipitable_water(input, ...)` | `H x W` float32 mm | RAW precipitable water (column-integrated vapor); `colormap=True` adds a basic RGB |
| `render_cloud_top_temp(input, ...)` | `H x W` float32 KELVIN | RAW cloud-top temperature at the visible tau~1 level (`NaN` = clear); `colormap=True` as above |
| `render_cloud_optical_depth(input, ...)` | `H x W` float32 | RAW total-column visible cloud optical depth (clear = 0); `colormap=True` as above |
| `render_cloud_layer(input, ...)` | `(H x W x 4 uint8, H x W float32, geo)` | the WEB-MAP cloud layer pair: cloud-only RGBA (straight alpha; `premultiplied=True` for the additive form) + the ground cloud-shadow MULTIPLY layer, on a Web-Mercator grid with `geo.mercator_corners` for a Mapbox ImageSource (top-down by definition; no ground is rendered — the host map is the ground) |
| `render_perspective(input, eye=(lat,lon,alt_m), look=(lat,lon,alt_m), fov=40, size=(1280,720), ...)` | `H x W x 3` uint8 | a FREE-PERSPECTIVE frame: an eye/look/fov pinhole camera through the same marches — the angled-3D flyover product (full composite over the Blue Marble ground; sky rays composite the atmosphere limb). `cloud_layer_only=True` returns `H x W x 4` uint8 (the cloud field alone, premultiplied alpha) for a host 3-D map with a matching camera. `geo.camera_pose` always carries the camera; a FLYOVER is N calls along your own eye/look path |
`render_visible_bands(...)` is a deprecated compatibility alias for
`render_rgb_reflectance(...)`; it remains available and returns exactly the same array.
`render_visible_rgb(..., backend="gpu-preview")` explicitly uses the same synchronous
wgpu cloud pass as Studio (`backend="cpu"` is the default). It preserves `view="geo"`
or `view="topdown"`, raises one `UserWarning` for every temporary compatibility
substitution, and never silently falls back to CPU. Free perspective, a rotated-lat/lon
source, or a missing GPU adapter is an error.
Visible-family functions also accept `intent="display"` (the unchanged default)
or `intent="sensor-fast-gray"`. The latter selects the explicitly limited
`simsat-fast-gray-v1` operator: unscaled cloud extinction, neutral display
shaping, full modeled path airlight, and no edge feather/granulation/stratiform
reconstruction/synthetic green. Model fractional clouds are retained. Exact
substitutions and limitations ride on `geo.intent`,
`geo.observation_operator`, `geo.intent_adjustments`, and
`geo.intent_limitations` and are also raised as `UserWarning`s. It is not yet an
instrument-SRF-integrated ABI/AHI channel; use `render_rgb_reflectance` for the
pre-tonemap broad-RGB reflectance output. Sensor Fast Gray currently requires
`backend="cpu"` because GPU preview cannot preserve this strict contract.
Common keyword args (all optional): `sat` (`goes-east`/`goes-west`/`himawari`), `view`
(`topdown` default / `geo`), `timestep=0`, `resolution` (`native` default: one output
pixel per source-model grid cell, not necessarily the highest output resolution;
`abi1km` / `abi2km` request 1 km / 2 km sampling and may upsample a coarse model or
downsample a fine WRF grid), `margin=0.0`
(zoom-out fraction added on each side — real surrounding earth, clear sky, frames the
domain), `cache=
`, `threads=`. Finished/display visible functions and cloud
layers additionally take `exposure=1.5`; raw RGB reflectance deliberately does not.
Visible-family functions also take `multiscatter=True`, `cloud_multiscatter=None`,
`beer_powder=False`, `granulation=False`,
`steps` (`offline`/`interactive`), `clouds=True`, `fractional_clouds=True`,
`fractional_cloud_mode="deterministic-2"` for finished RGB/GeoColor/Sandwich
(`"effective-od"` remains the raw-reflectance/cloud-layer/perspective and explicit
sensor-compatible default),
`feather_exposed_domain_edges=True`,
`sun_elev`/`sun_az` (what-if sun override), `bluemarble=` (single-file ground),
`bluemarble_month`, `bluemarble_download=True`. Thermal functions (`render_ir`,
`render_water_vapor`) take `enhancement=`
(`natural`/`cimss`/`bd`/`avn`/`funktop`/`rainbow`/`gray`). For Band 13,
`cimss` is the recommended Band-13 display and intentionally emphasizes temperature
bands; `natural` remains NOAA's continuous heritage bi-linear grayscale. For water
vapor, `cimss` remains the classic moisture palette;
the derived-field functions take `colormap=`.
The visible-family functions (including raw RGB reflectance, cloud layer, and perspective)
also expose the atmosphere/cloud QA controls directly:
| keyword | default | effect |
|---|---:|---|
| `aerosol_optical_depth` | `0.05` | aerosol AOD only; Rayleigh remains present at zero |
| `rh_aerosol_swelling` | `False` | apply the documented 1.5x aerosol-extinction multiplier |
| `atmosphere_correction` | `True` | product-facing daytime aerial-veil correction; `False` retains full modeled path airlight (other display transforms remain) |
| `terrain_atmosphere` | `True` | shorten atmosphere columns to the WRF terrain elevation |
| `fractional_clouds` | `True` | use model cloud fraction/subcolumns when available; `False` restores legacy horizontally-full cloudy cells |
| `fractional_cloud_mode` | `"deterministic-2"`* | reviewed finished-display closure; explicit fast/sensor-compatible `"effective-od"`, fixed-stratified CPU references `"deterministic-2"`, `"deterministic-4"`, `"deterministic-8"`, or `"deterministic-16"`, or `"off"`; the legacy boolean remains the master switch. *Raw reflectance, cloud-layer, and perspective bindings retain `"effective-od"`. |
| `cloud_optical_depth_scale` | `0.15` | owner-selected cross-file visible calibration, applied consistently in view/sun/ambient/shadow paths (`0.0..=4.0`; `1.0` = unscaled model extinction) |
| `cloud_multiscatter` | `None` | explicit transport override: `"legacy-octaves"`, `"single-scatter"`, or experimental CPU `"delta-flux-v1"` / `"delta-flux-v2b"` / `"delta-flux-v3-memory"`; omitted preserves the exact historical `multiscatter` boolean contract |
| `beer_powder` | `False` | optional Schneider shaping of the direct cloud-sun term; does not change transmittance |
| `granulation` | `False` | display-only sub-grid cloud-edge erosion; quantitative bands/thermal/derived products remain unmodified |
| `feather_exposed_domain_edges` | `True` | owner-selected v0.1.5 presentation control: when the camera exposes a finite WRF-domain boundary, fade finished visible/cloud-layer clouds over the fixed 4% boundary band; `False` restores the exact prior margin-gated behavior; raw RGB reflectance, thermal, and derived products remain unmodified |
| `topdown_stratiform_regularization` | `False` | experimental top-down-only 5x5 low/liquid stratiform column reconstruction; conserves selected-area optical depth, protects high/convective cores, and is ignored by geostationary and raw-band products |
`topdown_stratiform_regularization` is an opt-in observation-operator approximation,
not a literal satellite point-spread function or a correction to the model's
microphysics. It cannot invent sub-grid cloud/clear structure, remains off by default,
and currently routes a matching Studio GPU-cloud preview through the CPU so the requested
field is never silently ignored.
Finished visible display products (`render_visible_rgb`, `render_geocolor`,
`render_sandwich`, and full-composite `render_perspective`) also accept these
optional display-calibration overrides. Omitting them preserves the shipped engine
constants; they are intentionally absent from `render_rgb_reflectance` so raw reflectance
cannot be changed by a tonemap choice. The land controls are irrelevant to
`render_cloud_layer` and `cloud_layer_only=True`, which render no ground.
| keyword | omitted behavior | effect |
|---|---:|---|
| `exposure` | shipped `1.5` | whole finished-visible display gain before the ABI stretch (`1.0` is the exact neutral override; intentionally absent from raw RGB reflectance) |
| `ground_gain` | shipped `1.10` | sun-gated daytime surface-radiance lift (`1.0` is neutral; accepted but irrelevant for ground-free cloud layers) |
| `cloud_softclip` | shipped `0.65` | highlight shoulder knee (`1.0` disables the shoulder/hard-clamps) |
| `cloud_highlight_max` | shipped `1.25` | physical reflectance factor mapped to display white; raising it retains structure in brighter cloud tops |
| `land_sza_normalization` | `True` | owner-selected bounded land-only solar-zenith display correction; exact identity through twilight and at/above a 60-degree sun; set false with `land_dark_toe=False` for the legacy identity |
| `land_sza_max_gain` | `4.0` | owner-selected upper bound for the SZA correction (`1.0` is identity) |
| `land_dark_toe` | `True` | owner-selected bounded lift of dark positive land reflectance; black, bright terrain, ocean, clouds, and twilight remain unchanged; set false with `land_sza_normalization=False` for the legacy identity |
| `land_dark_toe_knee` | `0.08` | linear-reflectance identity knee for the dark-land toe |
| `land_dark_toe_gamma` | `0.65` | toe exponent (`1.0` is identity; lower values lift dark land) |
| `land_dark_toe_max_gain` | `1.5` | upper bound for the dark-land toe (`1.0` is identity) |
| `surface_postlight_toe` | `False` | opt-in terrain-only toe over the lit, view-attenuated surface contribution before atmospheric airlight/cloud compositing; ocean/glint, clouds, raw bands, and Sensor Fast Gray are unchanged |
| `surface_postlight_toe_knee` | `0.18` | linear reflectance-factor luminance knee for the post-lighting terrain experiment (`1e-6..=1`) |
| `surface_postlight_toe_gamma` | `0.80` | post-lighting toe exponent (`0.05..=1`; `1.0` is identity) |
| `surface_postlight_toe_max_gain` | `1.35` | bounded terrain-signal lift (`1..=4`; `1.0` is identity) |
| `twilight_surface_recovery` | `True` | shipped land-only recovery gated from civil twilight through low sun; exact identity at/below -6 and at/above +12 degrees; independently switchable off |
| `twilight_surface_recovery_knee` | `0.30` | owner-selected D linear reflectance-factor knee (`1e-6..=1`) |
| `twilight_surface_recovery_gamma` | `0.50` | owner-selected D toe exponent (`0.05..=1`) |
| `twilight_surface_recovery_max_gain` | `4.0` | owner-selected D bounded lift (`1..=4`) |
`cloud_optical_depth_scale` is a labeled calibration/sensitivity control: the shipped
`0.15` is an owner-selected visual calibration after broad cross-file review, not a
claimed physical optimum. It supersedes the earlier tied `0.20`/`0.30` midpoint candidate.
`1.0` preserves the model-derived extinction unchanged, and
`0.0` makes its visible optical effects transparent. It does not alter
`render_cloud_optical_depth`, which intentionally returns the unscaled physical input.
The two land operators are likewise finished-visible display controls: raw RGB
reflectance, IR/WV, derived products, cloud-only layers, water/glint, and cloud radiance do
not consume them. Both remain independently switchable despite being shipped on.
The two post-light controls remain independent on CPU and GPU. The older broad
`surface_postlight_toe` stays off; the owner-selected tight `twilight_surface_recovery`
is shipped on for finished visible/GeoColor/Sandwich/Perspective displays. If both are
enabled their gains combine by maximum rather than multiplying. Raw RGB reflectance,
IR/WV, derived fields, cloud-only layers, and Sensor Fast Gray keep both at identity;
Sensor Fast Gray reports the substitutions rather than silently ignoring them.
`fractional_clouds=True` consumes model cloud fraction when the input supplies it
and safely falls back to full-cell coverage otherwise; set it false for the legacy A/B.
`fractional_cloud_mode="deterministic-4"`, `"deterministic-8"`, or
`"deterministic-16"` renders the selected number of fixed-stratified shared-u
maximum-overlap subcolumns with independent but internally identical
view/sun/ambient/shadow state, averages unclamped linear radiance, then applies one
tonemap. These are deterministic ICA-style convergence references, not full
max-random/Sobol McICA and not the shipped default. Cost grows with member count. GPU
preview reports an explicit fallback; cloud-layer and perspective products currently
reject these modes rather than silently substituting another closure.
`clouds=False` remains the explicit feature bypass, while `multiscatter=False`
disables the higher cloud-scattering octaves without changing cloud transmittance.
Set `cloud_multiscatter="delta-flux-v1"` to opt into the experimental Stage-2
isotropic source, `"delta-flux-v2b"` for its upward-mean-normalized P1 reconstruction,
or `"delta-flux-v3-memory"` for the bounded successive-order angular-memory candidate.
Leaving it omitted is byte-compatible with the established
`multiscatter=True`/`False` paths.
`beer_powder` and `granulation` are explicit opt-in appearance controls and remain off
unless requested.
`feather_exposed_domain_edges=True` is the owner-selected v0.1.5 default. With it off,
positive `margin` retains the pre-v0.1.5 4% edge feather and `margin=0` remains an exact
identity. With it on,
the same fixed band also activates when the camera raster exposes a WRF boundary; it is
available on finished RGB, cloud-layer, and perspective products but never raw bands.
Layer-only products accept the shared keywords for call-site consistency, but
`atmosphere_correction` and `terrain_atmosphere` have no surface atmosphere to modify there.
### `threads=` (per-process render-thread cap)
`threads=N` caps the render worker threads via rayon. HONEST SEMANTICS: rayon's pool is
GLOBAL and built ONCE per process — the FIRST render call in a process fixes the count
(from `threads=`, or else the `RAYON_NUM_THREADS` environment variable); a different
`threads=` on a later call in the same process has NO effect. Under a
`ProcessPoolExecutor(max_workers=16)` pass `threads=1` (each worker process gets its own
pool) so 16 concurrent renders do not each grab every core.
## Logging
The binding is **silent by default**: the engine's diagnostic stderr lines (e.g.
`simsat ingest: run=... dims=... wall=...` progress, MAP_PROJ / moving-nest warnings)
are suppressed when the module is imported, so batch runs are not spammed. To see them:
- `simsat.set_verbose(True)` — enable at runtime (`set_verbose(False)` silences again), or
- set the environment variable `SIMSAT_LOG=1` (or `true`) before `import simsat`.
The messages go to **STDERR** and their text is unchanged from the CLI's, so existing
log-parsing scripts work when enabled. This switch gates diagnostic chatter only —
render-honesty reporting (`georef.time_is_fallback` / `ground_source` / `ground_status`
and their `UserWarning`s) is data, not logs, and is always active.
## The `georef` object
| attribute | value |
|---|---|
| `geo.view` | `'topdown'` or `'geo'` |
| `geo.extent` | `(x0, x1, y0, y1)` for `imshow` (row 0 = north; use `origin='upper'`) |
| `geo.extent_kind` | `'projection_meters'` (topdown), `'lonlat_degrees'` (geo), or `'webmercator_meters'` (cloud layer) |
| `geo.proj4` | a PROJ.4 string EXACTLY consistent with the extent's CRS |
| `geo.proj_kind` | `'lcc'` / `'stere'` / `'merc'` / `'latlon'` |
| `geo.crs_params` | dict of the PROJ keys + the raw WRF attributes |
| `geo.lat`, `geo.lon` | `float32` `H x W` geodetic mesh (for `pcolormesh`) |
| `geo.time_is_fallback` | `True` when the source had no parseable valid time and the render used the fabricated fallback date 2004-06-21 12:00 UT (the sun position / ground season are then NOT the run's real conditions) |
| `geo.ground_source` | `'2km'` / `'8km-fallback'` / `'flat-albedo'` / `'single-file'` / `'none'` — where the visible ground pixels came from (`'none'` for thermal / derived products) |
| `geo.ground_status` | list of ground-resolution status lines (downloads / fallbacks) |
| `geo.mercator_corners` | `render_cloud_layer` only: the four `(lon, lat)` image corners in the Mapbox ImageSource `coordinates` order (NW, NE, SE, SW); `None` for other products |
| `geo.camera_pose` | `render_perspective` only: the camera dict (`eye_lat`/`eye_lon`/`eye_alt_m`/`look_lat`/`look_lon`/`look_alt_m`/`fov_deg`/`width`/`height`); `geo.view` reads `'perspective'`. `None` for other products |
Honesty behavior: a fabricated-date or downgraded-ground render also raises a Python
`UserWarning`; and a `bluemarble=` that fails to load is a hard `RuntimeError`
(you named the file — silently rendering something else would be wrong output). An
out-of-range `timestep=` is a `RuntimeError` naming the file's valid range.
## Quick start
```python
import simsat
import matplotlib.pyplot as plt
import cartopy.crs as ccrs
import pyproj
wrfout = "/path/to/wrfout_d01_2020-06-21_18:00:00"
# 1) Finished true-color RGB (H x W x 3 uint8), top-down and map-registered by default.
rgb, geo = simsat.render_visible_rgb(
wrfout, sat="goes-east", view="topdown", backend="cpu"
)
crs = ccrs.Projection(pyproj.CRS.from_proj4(geo.proj4))
ax = plt.axes(projection=crs)
ax.imshow(rgb, extent=geo.extent, transform=crs, origin="upper")
# 2) RAW brightness temperature in KELVIN (H x W float32) for your own colormap.
bt, geo = simsat.render_ir(wrfout)
ax.pcolormesh(geo.lon, geo.lat, bt, transform=ccrs.PlateCarree())
# or let SimSat color it:
bt, ir_rgb, geo = simsat.render_ir(wrfout, enhancement="cimss")
# Experimental complete-radiance Band-13 footprint on the exact global ABI lattice.
bt_mtf, geo_mtf = simsat.render_ir(
wrfout,
view="geo",
resolution="abi2km",
geo_navigation="goes-r-abi",
sensor="goes-r-abi-band13-fm4",
instrument_footprint="goes-r-abi-band13-mtf-prototype",
)
print(geo_mtf.instrument_footprint_metadata)
print(geo_mtf.abi_fixed_grid_crop)
# 3) Day/night-safe composite + a derived moisture field, capped to one worker thread.
gc, geo = simsat.render_geocolor(wrfout, threads=1)
pw, geo = simsat.render_precipitable_water(wrfout, threads=1)
```
Pass `view="geo"` for the from-space geostationary view. See the
[repository Python-binding overview](../../README.md#python-binding) for the
supported products and build instructions.
## Compositing the cloud layer over a Mapbox GL map
`render_cloud_layer` produces exactly what a Mapbox GL (or MapLibre GL) `image` source
needs: a north-up Web-Mercator-aligned RGBA image + its four corner lon/lats. Export the
arrays to PNGs (straight alpha — the default) and wire them up like this.
> UNTESTED-BY-US: the snippet below is written from Mapbox GL JS API knowledge, not from
> a run we performed against a live Mapbox map — validate in your own map before relying
> on it. The in-process composite proof (`web_layer::composite_over_basemap` / the
> `render_frame product=cloud-layer composite-out=` PNG) is what we verified.
```python
import imageio.v3 as iio
rgba, shadow, geo = simsat.render_cloud_layer(wrfout, sat="goes-east")
iio.imwrite("clouds.png", rgba) # straight-alpha RGBA
iio.imwrite("clouds_shadow.png", (shadow * 255).astype("uint8")) # 255 = no shadow
print(geo.mercator_corners) # [(lon,lat) NW, NE, SE, SW] -> paste/serve to the map
```
```js
// Mapbox GL JS (or MapLibre): the shadow layer multiplies the basemap, then the cloud
// layer composites over it. `coordinates` is the ImageSource order: NW, NE, SE, SW.
const corners = [[lonNW, latNW], [lonNE, latNE], [lonSE, latSE], [lonSW, latSW]];
map.addSource('simsat-shadow', { type: 'image', url: 'clouds_shadow.png', coordinates: corners });
map.addLayer({
id: 'simsat-shadow', type: 'raster', source: 'simsat-shadow',
paint: { 'raster-opacity': 1.0 },
});
map.addSource('simsat-clouds', { type: 'image', url: 'clouds.png', coordinates: corners });
map.addLayer({
id: 'simsat-clouds', type: 'raster', source: 'simsat-clouds',
paint: { 'raster-opacity': 1.0, 'raster-fade-duration': 0 },
});
```
NOTE on the multiply blend: core Mapbox GL raster layers composite source-over only —
a grayscale shadow PNG drawn source-over will WASH the basemap toward gray, not darken
it. Hosts that support blend modes (MapLibre >= 3 style `raster-...` extensions, deck.gl
`BitmapLayer` with `parameters: {blend: true, blendFunc: [GL.DST_COLOR, GL.ZERO]}`, or
any custom-layer/canvas pipeline) should use a true MULTIPLY for `clouds_shadow.png`.
Where only source-over is available, an acceptable stand-in is an INVERTED shadow
(`1 - shadow`) as a black image with alpha = darkness: `rgba_shadow = [0, 0, 0,
(1 - shadow) * 255]` drawn source-over — that is mathematically the same darkening as
the multiply for a black overlay. The animation loop is N timesteps -> N image pairs ->
`source.updateImage({url, coordinates})` per frame.
## Perspective flyovers
A flyover is N independent `render_perspective` calls along YOUR camera path — there is
deliberately no path-scripting DSL (each frame is one render; script the path in Python):
```python
import numpy as np
for i, t in enumerate(np.linspace(0.0, 1.0, 120)):
eye = (36.5 + 1.5 * t, -98.5 + 1.0 * t, 150_000 - 50_000 * t) # your own path
rgb, geo = simsat.render_perspective(
wrfout, eye=eye, look=(39.0, -97.5, 0.0), fov=45, size=(1280, 720)
)
iio.imwrite(f"flyover_{i:04d}.png", rgb)
```
Parallax of high cloud against the ground is physical (true 3-D rays) — that is the 3D
look. `geo.camera_pose` records the camera on every frame.