Skip to content
Back to skills

Docs

ASecurity

Build the wflow spatial model: staticmaps.nc containing all grid parameters (DEM, soil, vegetation, river network). This is the foundation — all other stages depend on correct staticmaps. Skipping this stage means no spatial parameters exist and the model cannot run.

  • 231 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added September 11, 2026
testingpythongogit

Works with

  • cli

Security analysis

A100/100

Pro scans all 14 files and shows the line behind each finding

Scanned September 11, 2026

npx -y skills add lzwei196/KISS---Knowledge-Infrastructure-for-Scientific-Simulation --skill docs --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Docs?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Docs
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/lzwei196-docs-ac5cab6a/badge)](https://www.skillsdirectory.com/skills/lzwei196-docs-ac5cab6a)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
# s1 — HydroMT Model Setup Skill Document

## Purpose

Build the wflow spatial model: staticmaps.nc containing all grid parameters (DEM, soil, vegetation, river network). This is the foundation — all other stages depend on correct staticmaps. Skipping this stage means no spatial parameters exist and the model cannot run.

## Prerequisites

- Stage s0 complete (wflow_config.yaml exists)
- Basin shapefile (.shp) or coordinates for delineation
- For HydroMT mode: hydromt_wflow Python package installed
- For manual mode: HydroCraft's HWSD soil and AVHRR vegetation data available

## Inputs

| Name | Type | Source | Description |
|------|------|--------|-------------|
| wflow_config.yaml | file | s0 | Configuration with basin, period, resolution |
| shapefile | file | user or delineation | Basin boundary polygon |
| data_catalog.yml | file | build_data_catalog.py | HydroMT data catalog (HydroMT mode) |

## Procedure

### Option A: HydroMT Mode (recommended for first use)

1. Run `build_data_catalog.py` to create catalog pointing to HydroCraft datasets
2. Run `hydromt build wflow` via `run_hydromt_build.py --use_hydromt`
3. Verify staticmaps.nc contains expected variables
4. Check river network looks reasonable (5-20% of cells should be river)

### Option B: Manual Mode (when HydroMT is unavailable)

`hydromt_wflow` is NOT installed in this environment (`import hydromt_wflow` →
ModuleNotFoundError), so manual mode is in practice the ONLY path.

1. Run `run_hydromt_build.py --shapefile /path/to/basin.shp`
2. This derives staticmaps.nc per cell. The land-surface block (RootingDepth,
   SoilThickness, InfiltCapSoil, Cmax, CanopyGapFraction, Kext, Sl, Swood, N,
   PathFrac, WaterFrac) comes from `s1_hydromt/derive_landsurface_params.py`
   (GLC_FCS30-2015 sub-grid class fractions + GLASS LAI + HWSD REF_DEPTH);
   KsatVer / theta_s / theta_r / c come from the HWSD Saxton–Rawls lookup.
3. **There is NO uniform-constant mode, and a uniform land-surface map is a
   BUILD FAILURE, not a starting point.** With a uniform SoilThickness 2000 mm /
   RootingDepth 750 mm the basin never water-stresses, AET pins to ~PET, and the
   water that should have become runoff is evaporated — the discharge is
   systematically under-predicted while the timing still looks right, so the run
   *appears* merely uncalibrated (dt_w015 / dt_w042). Rio Pelotas: AET
   1016 mm/yr = 0.97 × PET against the 758 mm/yr the observed balance implies,
   PBIAS −32%. Imhoff et al. 2020 identifies RootingDepth as one of the two most
   sensitive calibration-free discharge parameters, so leaving it uniform makes
   the most important lever inert. If a source does not resolve, the build must
   FAIL — never substitute a placeholder (dt_w040).
4. River network is thresholded on upstream area (`--river_threshold_km2`), not
   all cells = river.

### Post-Build Checks

5. Open staticmaps.nc and verify:
   - wflow_subcatch has non-zero values
   - wflow_dem has realistic elevation range
   - KsatVer is in range 10-10000 mm/day
   - SoilThickness is in range 500-5000 mm
   - theta_s > theta_r everywhere
   - **every land-surface field VARIES across the mask.** `nunique == 1` over a
     multi-cell basin means the lookup silently missed. The only two declared
     exceptions: PathFrac / WaterFrac may be a constant 0.0 (that class is
     genuinely absent from the basin, and both are literal class area
     fractions), and SoilThickness *together with* InfiltCapSoil may be uniform
     when the whole basin lies in a SINGLE HWSD mapping unit — REF_DEPTH is a
     per-mapping-unit attribute taking only 10/30/100 cm, and InfiltCapSoil **is
     KsatVer, unfloored** (KsatVer = Saxton–Rawls of that same unit's
     SHARE-weighted texture), so the two are uniform together or not at all
     (check `hwsd_mapping_units` in the build provenance). A constant 0.0 is
     never tolerated for either. There is deliberately no `max(KsatVer, 1)`
     floor: it turned a collapsed lookup into a legal constant 1.0 mm/day that
     satisfied the "constant > 0" precondition and was waved through as a
     warning. A non-positive or non-finite KsatVer now FAILS the build, on the
     derived path and on the caller-supplied path alike. On the caller-supplied
     path the three bad categories (NaN / ±inf / finite-and-≤ 0) are counted
     **disjointly** — `(~isfinite).sum() + (ks <= 0).sum()` counted a −inf cell
     twice — and the minimum is reported over the **finite** active cells and
     labelled as such, because `np.nanmin` skips NaN and would otherwise print a
     healthy minimum for an all-NaN failure. Swood is NOT an
     exception: it is class-area-weighted, so a constant-zero Swood means the
     weighting collapsed.
   - **land-cover coverage — THREE different faults, measured separately and
     all scoped to the ACTIVE mask.** Each one leaves the class fractions
     computed over a sub-area (the rest redistributed pro rata) and each FAILS
     the build, but they have different remedies so they must not share a
     message. rio_merge pads uncovered area with `LC_PAD_SENTINEL = 255` — a
     code in neither the GLC_FCS30-2015 legend nor `LC_NODATA = (0, 250)` —
     which keeps the three pixel populations disjoint. Whether pad means
     "unstaged" or "a tile really uses 255" is **decided from the staged tiles'
     own footprints**, not inferred from which tile names are missing:
     0. *sentinel collision* (grid-scoped, unconditional) — a pad pixel lying
        INSIDE a staged tile's footprint cannot be unstaged area, so it is real
        data wearing the sentinel. Change `LC_PAD_SENTINEL`. This is the one
        check that is not mask-scoped, because an ambiguous sentinel invalidates
        every pad/nodata count on the mosaic, active cells included.
     1. *staging gap* — an ACTIVE cell contains pad. Both the tile names and
        the remedy come from **the failing cells' own pad pixels**, never from
        the cell centre and never from the grid-wide missing-tile list. A cell
        straddling a 5° boundary takes its pad from the NEIGHBOURING tile while
        its centre resolves to the tile it mostly sits in — which is staged — so
        the centre named a present tile as the cause; and an unstaged tile lying
        entirely over INACTIVE cells cannot be the cause, yet it used to select
        the "stage them" wording. The message says plainly that if GLC_FCS30
        ships no tile with the named name (the product has none over open ocean)
        then those active cells are not land and the **mask** is wrong — do not
        hunt for a tile that does not exist. Pad over INACTIVE cells is benign
        (they are zeroed before the staticmaps are written) and is recorded,
        never raised on: a bbox that merely overhangs the coast must not fail a
        build.
     2. *mosaic-edge shortfall* — an ACTIVE cell has NO pad yet fewer staged
        pixels than a full cell holds. "The grid extends beyond the tile grid"
        is impossible here (that produces pad, i.e. fault 1); the real cause is
        rasterization — `px_per_cell` is a FLOAT and membership is decided by
        rounding pixel centres — which is also what the 1% tolerance is for.
        The message claims **only** what this branch establishes, namely that no
        ACTIVE cell carries pad. It does *not* claim every enumerated tile is
        staged — reaching here does not prove that, because an unstaged tile
        over inactive cells is benign — so both tile lists are printed as facts
        and neither is offered as the cause.
     3. *in-tile nodata* — the cell IS fully staged, but `classified` is below
        99% of that cell's own STAGED pixels: ocean or "Filled value" inside the
        tile. Measured against the cell's staged count, never against a full
        cell, so it cannot fire just because a tile is missing — and the
        operator is never told to stage a tile that is already staged.

     **Scoping is part of the contract.** Every figure a fault message quotes is
     active-cell scoped and comes from the FAILING cells (`*_active`,
     `*_in_an_active_cell`); grid-wide figures are reported separately as
     `*_grid` / `*_in_any_grid_cell` and never appear in a message. The verdict
     is raised inside `_class_fractions`, which now takes the mask — computing
     the statistics in one place and raising in another is what allowed a
     grid-scoped pixel count to be printed next to a mask-scoped cell count.

     **Provenance is a fixed key set, seeded at `_class_fractions` entry.**
     The six coverage keys (`lc_active_cells`,
     `lc_pad_pixels_inside_a_staged_footprint`, `lc_active_cells_unstaged`,
     `lc_active_cells_edge_short`, `lc_active_cells_nodata_dominated`,
     `lc_unstaged_tiles_under_failing_cells`) are written from
     `COVERAGE_PROVENANCE_SEED` as the **first statement** of that function, so
     a consumer reads `0` / `[]` instead of having to tell "the quantity was
     zero" apart from "the test never ran". Every later assignment is an
     **overwrite**, never the only write — seeding in the middle of the function
     left the claim false on five paths (the sentinel-collision, unknown-code,
     out-of-0-255, empty-mosaic and bad-mask raises all fire earlier) and left
     `lc_active_cells_edge_short` / `lc_active_cells_nodata_dominated` with a
     single assignment site inside their own fault block, so a FAULT 1 raise
     dropped both and a FAULT 2 raise dropped the second.
     **The scope is narrowed and stated:** the set is fixed *from
     `_class_fractions` entry onward*, i.e. for the coverage stage. A failure
     before that stage is entered never had a coverage stage and claims none of
     these keys. For the three `lc_active_cells_*` counters and
     `lc_unstaged_tiles_under_failing_cells` the seed `0` / `[]` is also a
     legitimate success value (no fault ⇒ no failing cells); for
     `lc_active_cells` it is not reachable on any success path, because an empty
     mask raises, so `lc_active_cells == 0` means the stage raised before it
     counted the mask.
     **The contract is observable.** `prov` is a local of
     `derive_landsurface_params`, so on a raise it never binds in the caller —
     seeded keys nothing can read would be decoration. A `DeriveError` from the
     coverage stage carries the dict as `e.provenance` (class attribute
     `DeriveError.provenance = None`), and the CLI prints it under
     `"provenance"` on the failure path; `None` means the failure was outside
     the coverage stage. The `*_grid` / `*_active` figures are *not* part of the
     seeded set — they are measured once, after the mask checks, and are
     absent on the earlier raises. The unscoped names of the
     earlier revision (`lc_nodata_pixels`, `lc_pad_pixels`,
     `lc_pixels_per_cell_median`, `lc_cells_unstaged`,
     `lc_cells_nodata_dominated`, `lc_max_pad_pixels_in_a_cell`,
     `lc_max_nodata_pixels_in_a_cell`) were **replaced, not aliased** — an
     unscoped alias would preserve exactly the ambiguity that let a grid-wide
     figure be quoted in a mask-scoped message. Nothing subscripts them:
     `run_hydromt_build.py` only `json.dumps()` the dict into the staticmaps
     attribute `landsurface_provenance` and copies it wholesale into
     `build_result.json` under `"landsurface"`.
   - **HWSD texture is judged per component ROW, not per column**: a row counts
     as soil only if `T_SAND > 0` **and** `T_CLAY > 0`; a 0/0 row is a non-soil
     component (water, glacier, rock) and is dropped from the SHARE weighting
     instead of diluting the mean toward zero. `T_OC` only has to be finite and
     `>= 0` — zero organic carbon is a real HWSD record, a zero sand or clay
     percentage is not. All three columns are averaged over the same surviving
     rows.
   - land-cover legend: the tiles under `KISSPATH_DATA/vegetation/GLCFCS30` are
     **GLC_FCS30-2015**, whose forest codes are 50/60/70/80/90 with open–closed
     splits only at 61/62, 71/72, 81/82. Codes 51/52/91/92 belong to the
     GLC_FCS30D / 2020-era legend and must never be mapped through the 2015
     table; the deriver raises if it sees one.

## Expected Outputs

| Output | Path | Verification |
|--------|------|-------------|
| staticmaps.nc | outputs/<run>/wflow_project/staticmaps.nc | ncdump -h shows expected variables |
| data_catalog.yml | outputs/<run>/data_catalog.yml | YAML loads, paths exist |

## Validation Checks

1. staticmaps.nc exists and is >1 MB
2. Grid dimensions match expected resolution
3. Active cells (wflow_subcatch > 0) > 0
4. No NaN values in soil parameters within basin mask
5. River network has proper connectivity (dt_w013)

## Common Pitfalls

- **dt_w013**: If wflow_river is all zeros, routing produces zero flow
- **dt_w014**: Boundary cells with invalid flow direction cause BoundsError
- **dt_w024**: HydroMT-wflow version must match Wflow.jl version
- **dt_w042**: A uniform land-surface field (`nunique == 1` over a multi-cell
  mask) is a BUILD FAILURE, not a caveat to disclose — see Option B item 3. The
  only declared exceptions are PathFrac/WaterFrac constant at exactly 0.0, and
  SoilThickness + InfiltCapSoil uniform (and > 0) inside a single HWSD mapping
  unit — those two are per-mapping-unit attributes and are uniform together or
  not at all. That "> 0" only means something because InfiltCapSoil carries no
  `max(..., 1)` floor: with the floor, a collapsed KsatVer lookup surfaced as a
  legal constant 1.0 mm/day and passed the exception.
- **Coverage provenance (fixed key set)** — no triplet id is cited here on
  purpose: `diagnostics/triplets.yaml` currently ends at `dt_w041` and a doc that
  names an id the file does not define is the same dangling reference `dt_w042`
  already is. The rule is proposed as a tier-3 addition, not asserted here.
  The coverage key set is seeded at
  `_class_fractions` entry, before every raise in it, and each fault block only
  *overwrites*. A key assigned solely inside its own fault block is absent
  whenever an EARLIER fault raises, which silently turns a declared "always
  present" contract into a `KeyError` on exactly the paths it was written for.
  The rule: if a key is declared always-present, seed it at function entry, and
  narrow the declared scope to the function that seeds it. And if the dict is a
  local of the caller, attach it to the exception — otherwise nothing can read
  it on the fault path and the seeding is decoration.
- **dt_w040 (land cover)**: a sentinel collision, a staging gap, a mosaic-edge
  shortfall and in-tile nodata are FOUR separate faults with separate messages
  — see the coverage item under Post-Build Checks. The mosaic pad sentinel (255)
  must stay outside both the legend and `LC_NODATA`, or they collapse into one
  undiagnosable shortfall. Three rules that are easy to get wrong: pad is
  attributed by TILE FOOTPRINT, never inferred from the missing-tile list (the
  inferred branch was unreachable and named a cause that could not have
  occurred); the failing cells' TILE NAMES and the choice of remedy come from
  those cells' own PAD PIXELS, not from the cell centre (a cell straddling a 5°
  boundary would otherwise name its staged neighbour) and not from whether any
  tile is missing anywhere in the bbox (a tile over inactive cells cannot be the
  cause); and a coverage fault is measured over the ACTIVE mask only, so a bbox
  overhanging the coast or the tile grid over inactive cells is recorded, not
  failed.

Files in this skill

  • REFERENCES.md1.6 KB
  • format_spec.yaml45.8 KB
  • papers.json5.4 KB
  • s0_configuration_skill.md2.2 KB
  • s10_reservoir_skill.md8.2 KB
  • s1_hydromt_setup_skill.md15.3 KB
  • s2_forcing_skill.md2.9 KB
  • s3_parameters_skill.md2.9 KB
  • s4_execution_skill.md2.8 KB
  • s5_output_skill.md2.5 KB
  • s6_s8_sediment_skill.md6.1 KB
  • s9_coupling_skill.md3.1 KB
  • validation_convention.yaml14.8 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

View all in testing →

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.