Skip to content

Configuration in depth

A snowdb has one entry point: the root config. Hand snowtool a single path and it reaches everything else — the datasets, the pourpoint catalog, the generation sources — by following that config, never by assuming where things live. This page is the full knob surface: every field of the root config and of a dataset config, how the two fit together with the data on disk, and how a config reaches the running CLI and API. For the environment-variable basics of pointing snowtool at a snowdb, see Configuration; for how these files sit in the directory tree they describe, see A snowdb on disk.

Every persisted config is a pydantic model in snowdb/config.py and carries an opaque, versioned resource tag as its first field (snowtool.snowdb/v1, snowtool.dataset/v1). A schema change is a new resource type, not an in-place reinterpretation; there is no global snowdb version. That tagging, and the atomic writes these files are saved with, are covered in A snowdb on disk.

The root config

RootConfig (resource: "snowtool.snowdb/v1") is written to snowdb_conf.json and is the source of truth for what datasets exist and where the pourpoint catalog lives.

created_at is the UTC timestamp stamped when snowtool init created the root; it is informational.

datasets maps a dataset name to its link — the registration of one dataset. The map is empty on a fresh snowdb; dataset create/register register into it and dataset activate/deactivate toggle entries. This map, plus each link's active flag, is the whole answer to "what datasets does this snowdb have, and which does it serve." Links are detailed below.

pourpoint_index and pourpoint_records are the locations of the pourpoint manifest and the record directory, defaulting to pourpoints/index.geojson and pourpoints/records. They are plain paths, resolved relative to the root config's directory (or absolute); overriding them is rarely needed but supported for an unusual layout.

sources maps a zone-layer provider name (terrain, landcover) to a path that overrides where that provider reads its input during dataset generate-zones. A provider absent from this map uses its network default (USGS 3DEP for terrain, the MRLC Annual NLCD bundle for land cover). Paths are absolute or relative to the root config. This is the place to pin an offline or pre-staged input for the whole snowdb rather than passing --source on every generation run. Generation itself is zone generation.

A link is a discriminated union on type. A path link (type: "path") references a dataset config file elsewhere on the filesystem:

{ "type": "path", "path": "data/snodas/dataset.json", "active": true }

The path is relative to the root config's directory (a relocatable tree) or absolute (a dataset staged elsewhere). The dataset's data lives beside its config wherever the path points. This is what dataset create writes: a dataset.json under data/<name>/ and a relative link to it.

An inline link (type: "inline") embeds the whole dataset config in the root config instead of pointing at a file:

{ "type": "inline", "active": true, "dataset": { "resource": "snowtool.dataset/v1", "...": "..." } }

An inline dataset has no dataset.json on disk; its data lives at the conventional data/<name>/ under the root. Inline definitions let a whole snowdb be built in memory with no dataset files at all, which is how the test suite constructs one. Prefer a path link for a normal on-disk dataset (its config lives with its data and stays independently editable); reach for inline for a small, self-contained, or programmatically built snowdb.

The active flag gates visibility to readers, not existence. An inactive dataset is still registered — resolved by name for management (ingest, zone generation, health checks) — but the query CLI and the API skip it. dataset create/register register a dataset inactive so it can be fully staged and populated before anything serves it, and dataset activate flips the flag. A bare hand-written link omits active and defaults to True, so a config authored by hand just works. The register/activate split, and why it exists, is walked through in the walkthrough; the read-versus-manage split it drives is architecture.

The dataset config

DatasetConfig (resource: "snowtool.dataset/v1") is everything a dataset is, independent of where it lives — the same model whether it sits in a dataset.json or inline in the root config. It carries no name: the name comes from the key it is registered under.

grid is a GridParams (snowdb/grid.py), the definition of the dataset's north-up tiled grid. It carries the pixel-space origin (origin_x, origin_y), the pixel size in CRS units (px_size), the grid size in pixels (cols, rows), the tile_size in pixels per tile edge, and the crs as an EPSG integer or WKT string (default 4326). A geographic CRS means per-cell area varies by latitude (the AOI raster burns geodesic area); a projected CRS means constant cell area. The grid is the one type used both as the live definition and its persisted form.

variables maps a variable key to a DatasetVariable (snowdb/variables.py). The map key is the variable's key and is injected on load, so it is not repeated inside the value. Each variable declares glob (the filename glob that finds its COG within a cogs/<YYYYMMDD>/ directory), dtype (the numpy read dtype, e.g. int16), nodata (a finite fill sentinel — NaN is rejected, because the stats reader masks fill with a != compare that can never exclude NaN), reducer (mean for an area-weighted intensive average or total for an area-weighted basin total), and unit (an inline {name, scale_factor} that reports the value). A key may not contain __, which is the COG filename delimiter between provenance and variable.

ingester is the registry name of the code that turns a source artifact into this dataset's per-date COGs — one of the keys in INGESTERS (snowtool.snowdb.datasets): snodas, swann, instarr. None means a read-only/derived dataset with no ingest. The name is the dataset kind (swann), distinct from a dataset name (swann-800m). Ingest is ingest.

zones maps a zone-layer provider to its layers to each layer's default query params: {provider: {layer: ZoneLayerParams | None}}. A provider's presence enables it for the dataset (its layers are generated and served); its absence means the dataset has no such zone layer. ZoneLayerParams is a union of four single-field, extra='forbid' models — BandStepParams (band_step_ft), BucketParams (buckets), ThresholdParams (threshold_pct), EntropyThresholdParams (entropy_threshold) — so a block parses to exactly one member by its field name, and a mistyped or unknown param fails at config load rather than being silently ignored. A layer enabled with no params (a categorical axis, e.g. terrain.aspect) is stored as null, not {}; a param attached to a layer whose scheme doesn't take it (e.g. buckets for elevation) parses fine here but raises ZoneParamsError at query time, when ZoneScheme.configured folds it into the layer's actual scheme. The zoning framework and what each knob does is zones.

footprint is an optional GeoJSON geometry, in the grid's CRS, giving the region the dataset actually serves — for example a MODIS block minus a never-ingested tile. Omitted, the dataset serves its whole grid extent. It is used for AOI coverage classification, so a basin over a permanently empty hole is not reported as fully covered. Set it only when the served region is genuinely smaller than the grid rectangle.

data_dir overrides where the dataset's data lives — the directory holding cogs/, aoi-rasters/, and the zone subdirectories. Absolute points anywhere (decoupled from the config's location); relative resolves against the config's own directory; omitted uses the convention (beside a referenced config, or data/<name>/ for an inline one). Leave it unset unless a dataset's data must sit apart from its config.

Worked examples

A root config with one path-linked dataset and one inline dataset, pinning terrain generation to a local DEM:

{
  "resource": "snowtool.snowdb/v1",
  "created_at": "2026-07-05T17:32:00Z",
  "datasets": {
    "snodas": {
      "type": "path",
      "path": "data/snodas/dataset.json",
      "active": true
    },
    "swann": {
      "type": "inline",
      "active": true,
      "dataset": {
        "resource": "snowtool.dataset/v1",
        "grid": {
          "origin_x": -125.0208,
          "origin_y": 49.9375,
          "px_size": 0.008333325394357,
          "cols": 7025,
          "rows": 3105,
          "tile_size": 256,
          "crs": 4269
        },
        "variables": {
          "swe": {
            "unit": {"name": "mm", "scale_factor": 1.0},
            "reducer": "mean",
            "dtype": "int16",
            "nodata": -999.0,
            "glob": "*__swe.tif"
          }
        },
        "ingester": "swann"
      }
    }
  },
  "pourpoint_index": "pourpoints/index.geojson",
  "pourpoint_records": "pourpoints/records",
  "sources": {
    "terrain": "/data/dem.tif"
  }
}

The referenced data/snodas/dataset.json, trimmed from the built-in swann-800m template (DATASET_TEMPLATES in snowtool.snowdb.datasets, which derives every template from a real built-in spec):

{
  "resource": "snowtool.dataset/v1",
  "grid": {
    "origin_x": -125.0208,
    "origin_y": 49.9375,
    "px_size": 0.008333325394357,
    "cols": 7025,
    "rows": 3105,
    "tile_size": 256,
    "crs": 4269
  },
  "variables": {
    "swe": {
      "unit": {"name": "mm", "scale_factor": 1.0},
      "reducer": "mean",
      "dtype": "int16",
      "nodata": -999.0,
      "glob": "*__swe.tif"
    },
    "depth": {
      "unit": {"name": "mm", "scale_factor": 1.0},
      "reducer": "mean",
      "dtype": "int16",
      "nodata": -999.0,
      "glob": "*__depth.tif"
    }
  },
  "ingester": "swann",
  "zones": {
    "terrain": {"elevation": {"band_step_ft": 1000}},
    "landcover": {"forest_cover": {"threshold_pct": 50.0}}
  }
}

Rather than authoring one of these by hand, dataset create --template <kind> stamps the built-in template for a dataset kind; hand-editing is for tuning zones knobs, a footprint, or a data_dir afterward.

How a config reaches runtime

Both the CLI and the API take a filesystem path to the snowdb: either the snowdb_conf.json file itself or the directory containing it (SnowDb.open appends the fixed filename when handed a directory). On the CLI the path comes from --config/-C or the SNOWTOOL_SNOWDB_CONFIG environment variable, resolved onto the per-invocation CliContext (cli/_context.py), which lazily opens a single SnowDb the first time a command needs it — so commands that never touch the database (--version, api serve) require no config at all.

The API layer adds a small pydantic-settings model, Settings (api/settings.py), with env prefix snowtool_. It exposes exactly three knobs: snowdb_config (the SNOWTOOL_SNOWDB_CONFIG path, file or directory), tiff_cache_size (the maximum number of open async-tiff handles kept in the read-path LRU cache), and max_zone_cells (a cap on a crossed zonal-stats query's product size — the number of output rows — rejected before any raster is read, so several fine-grained zone axes crossed together cannot blow up a query). There is no CORS or other web knob here. Dotenv loading is deliberately disabled on this model: a .env file is never parsed, so these variables must be set in the actual environment (source them from a shell). The CLI depends on no Settings at all — that is purely an API concern.