Skip to content

CLI Parameter Naming Conventions

This document establishes the standard naming conventions for CLI parameters in lsdtt3. If you are a developer and make a pull request failing to follow these instructions the lead developer will wag his finger at you and say "bad!".


Overview

Every lsdtt3 CLI declares its parameters through lsdtt_param_parser. The naming standard below ensures that:

  • A parameter's name tells the developer what it does and what units it uses.
  • Renaming a parameter is non-breaking for existing .param files through the deprecated-alias mechanism.
  • New contributors follow the existing patterns rather than inventing new ones.

The standard applies to all parameters. A CTest guard checks every help-registered parameter in every CLI, and fails on any violation (see Conformance testing).


The standard

Concept Canonical form Notes
Channel initiation threshold_contributing_pixels (int, pixels, 1000) Matches lsdtopotools2 for cross-version continuity
Genuine areas <what>_area_threshold_m2 (float) Never spelled the same as the pixel form
Input already filled, fill is mandatory raster_is_filled (bool, false) Concept A — a compute-saving assertion about the input
Fill is optional; false routes into sinks fill_before_routing (bool, true) Concept B — an analytical choice
Fill minimum slope fill_min_slope (float, m/m, 1e-4) Stage prefix only where a CLI fills more than one surface
Raster output format write_output_format One per CLI
Vector output format write_vector_format One per CLI; per-output overrides only where a container genuinely differs
Boolean raster output write_<name>_raster Plural _rasters where a set is written
Input file <what>_fname Never _file, _filename, _csv; format is a separate <what>_input_format
Fitting / moving window <stage>_<quantity>_radius_metres Radius, always; polyfit_lambda_metres reserved for half-wavelength
Snap radius <what>_snap_max_search_radius_pixels (+ _min_) No tol; metric tolerances _tolerance_metres
Units _metres, _pixels, _degrees spelled out Never _m, _px, _deg
Stage switch run_<stage> Not do_, use_, apply_, compute_
Output switch write_<output> Never print_ for something that writes a file
Bounds min_ / max_ prefix Not minimum_, not threshold_<x>
Subject-qualified pixel-count filters <subject>_minimum_contributing_pixels Deliberate exception to the row above. The subject prefix (basin_, crossing_, side_tributary_) names the object being filtered and reads as plain English. Do not shorten to min_

Two concepts: filling a raster

The two fill-related parameters below ask different questions and must never be merged. Each help text must state which question it is asking.

Concept A: raster_is_filled — input assertion

The question: Has the input DEM already been filled (sinks removed)?

The purpose: A compute-saving assertion, not an analytical choice. The operation requires a filled surface. If you set this to true but supply an unfilled DEM, you get bad routing — divergences, loops, or sinks that should have been breached. This is silent data corruption, not just a different answer.

Default: false (do the work, be safe)

Implementation pattern:

if (!bf["raster_is_filled"])
{
    dem = lsdtt::fill_pits(dem);
}

Legacy alias: flow_dem_is_filled in feature-finder maps to raster_is_filled with a deprecation warning.

Concept B: fill_before_routing — analytical choice

The question: Should flow routing terminate at sinks, or should the surface be filled first?

The purpose: A genuine analysis choice. Setting to false means flow is routed on the unfilled surface and terminates at real sinks. This is intentional, not an error.

Default: true (fill before routing)

Implementation pattern:

dem = bf.at("fill_before_routing") ? lsdtt::fill_pits(dem_raw) : dem_raw;

Legacy alias: fill_dem_for_flow_routing in fluvial-surfaces maps to fill_before_routing with a deprecation warning.

Note: fill_initial_surface

The parameter fill_initial_surface in lsdtt-lem is neither concept. It applies to the synthetic initial surface, not to routing. It stays as it is.


Shared parameter declaration layer

The shared declaration layer in src/core/lsdtt_common_parameters.hpp eliminates duplication of common parameter names and help text.

The layer exposes functions that populate a CLI's default maps and help registry in one call. This ensures there is exactly one place each shared name is spelled:

lsdtt::declare_routing_core_parameters(int_map, string_map, help_map);
    // Declares: boundary_conditions, n_threads

lsdtt::declare_raster_is_filled_parameter(bool_map, help_map);
lsdtt::declare_fill_before_routing_parameter(bool_map, help_map);
lsdtt::declare_fill_min_slope_parameter(float_map, help_map);
lsdtt::declare_channel_initiation_parameters(int_map, help_map);
    // Each declaration is independent; use only the concepts the CLI supports.
    // Every function accepts an optional prefix for multi-stage CLIs.

lsdtt::declare_raster_output_format_parameter(string_defaults, help);
lsdtt::declare_vector_output_format_parameter(string_defaults, help);
lsdtt::declare_spatial_index_parameter(bool_defaults, help);
    // Declares: write_output_format, write_vector_format,
    //           calculate_spatial_index

lsdtt::declare_basin_outlet_parameters(...);
    // Declares: basin_outlet_fname, basin_outlet_snapping_method,
    //           basin_outlet_snap_min_search_radius_pixels,
    //           basin_outlet_snap_max_search_radius_pixels,
    //           basin_outlet_contributing_pixels_threshold

lsdtt::declare_surface_fitting_parameters(...);
    // Declares: polyfit_lambda_metres, polyfit_metrics, compute_backend

lsdtt::declare_synthetic_domain_parameters(...);
    // Declares: n_rows, n_cols, cell_size_metres, random_seed,
    //           synthetic_crs_wkt

lsdtt::declare_synthetic_noise_parameters(...);
    // Declares: noise_type, noise_target_stddev_metres, noise_relief_metres,
    //           diamond_square_roughness, diamond_square_initial_scale

// Synthetic concepts are also independently selectable:
lsdtt::declare_synthetic_dimensions_parameters(int_map, help_map);
lsdtt::declare_synthetic_cell_size_parameter(float_map, help_map);
lsdtt::declare_synthetic_random_seed_parameter(int_map, help_map);
lsdtt::declare_synthetic_crs_wkt_parameter(string_map, help_map);
lsdtt::declare_synthetic_noise_type_parameter(string_map, help_map);
lsdtt::declare_synthetic_noise_target_stddev_parameter(float_map, help_map);
lsdtt::declare_synthetic_noise_relief_parameter(float_map, help_map);
lsdtt::declare_diamond_square_parameters(float_map, help_map);

CLIs use these declarations for the shared concepts they expose.

The flow and synthetic declarations are intentionally granular. raster_is_filled and fill_before_routing are different questions, and many CLIs support only one. Similarly, a CLI may consume a pre-filled surface without owning a fill-slope parameter, or may define channels without exposing routing controls. Do not add unsupported keys merely to use a larger declaration group. In particular, do not add synthetic_crs_wkt to a CLI with an EPSG/centre-coordinate CRS model or noise_type to a harness whose independent write switches can run together.


Deprecated-alias adoption pattern

When a parameter is renamed or retired, the old name must be handled gracefully so existing .param files do not silently break. The lsdtt_param_parser provides five mechanisms, and the choice depends on what the parameter did.

Most important rule: The old key keeps working with a warning naming the new one. Every rename is non-breaking for users. The cost of a rename is one line in an alias map. There is no flag day.

However, the alias map only rescues a key that is read by the CLI. A key string hard-coded in a script, notebook, or the QGIS plugin is not covered. A rename still needs a tree sweep for those cases.

Five maps: the decision rule

set_deprecated_aliases() — the parameter still exists under a new name

Use this when renaming a parameter. The value is carried across automatically, and the old name is erased so the parser does not report it as unknown.

When to use it: The parameter exists; only the name changed. The user's .param file continues to work, and the warning tells them the new name.

Example: source_pixels_threshold renamed to threshold_contributing_pixels. Both names work; if both are supplied, the new one wins and the conflict is reported.

pp.set_deprecated_aliases({
    {"source_pixels_threshold", "threshold_contributing_pixels"},
    {"old_fill_param", "fill_before_routing"},
});

Warning message:

WARNING: '<old_key>' is deprecated; use '<new_key>'. Its value has been applied
to '<new_key>' for this run.

If both old and new are present:

WARNING: '<old_key>' is deprecated and '<new_key>' was also supplied. Using
'<new_key>'; the deprecated key is ignored.

The warning contract names both keys; it does not name the executable. The parser owns the parameter filename but is not given CLI identity, and adding tool identity solely to decorate this warning would couple otherwise generic key migration to presentation.

set_deprecated_aliases() replaces the parser's complete alias map. A CLI with more than one migration must therefore pass all mappings in one call. Do not call it repeatedly: a later call would discard the earlier aliases.

set_deprecated_numeric_aliases() — the name and numeric meaning changed

Use this narrow typed facility when a rename also requires an affine numeric conversion. It is intended for unit or geometric convention migrations, such as an old window diameter becoming a canonical radius. It is not a general callback mechanism.

pp.set_deprecated_numeric_aliases({
    {"slope_window_diameter_metres",
     {"slope_window_radius_metres", 0.5, 0.0, true}},
});

The policy fields are new_key, scale, offset, and preserve_zero; the rewrite is new = old * scale + offset, except that an old zero remains exactly zero when preserve_zero is true. The old value must be a complete finite number. Malformed values, nan, inf, a non-finite result, and invalid policies stop with a fatal diagnostic before typed parsing and analysis.

The canonical key wins when both are supplied, as for an ordinary alias. The old value is still validated, then erased so it is not reported as unknown. Warnings name both keys and print the transformation. Rewritten values use classic-locale double max_digits10 formatting, preserving sufficient text precision for subsequent float or double parsing.

set_deprecated_numeric_aliases() replaces the complete transformed-alias map, independently of set_deprecated_aliases(): multiple transformed entries and the ordinary alias map coexist. Transformed aliases are applied before ordinary aliases. Registering the same old key in both maps is a fatal configuration error; do not rely on ordering to choose a policy.

set_deprecated_compatibility_aliases() — values the parser cannot convert

Use this in two cases:

  1. Consolidated selectors. Several old per-output selectors are consolidated into one canonical selector and old files may set those selectors differently. Each old key maps to a distinct hidden storage key plus the public replacement named in the warning. The old value remains attached to its original output for that run without exposing fake public overrides. These storage keys default to same_as_write_vector_format or same_as_write_output_format and are resolved after parsing with resolve_output_format_override().
  2. Conversions the parser cannot make. The old value can only be turned into the new one with information the parser does not have (the DEM cell size, for a window in cells becoming a radius in metres) or with a function that is not affine (a tangent, for an angle becoming a gradient), or it changes type (a bool becoming a *_algorithm_choice). The CLI declares the storage key as an empty string, converts the stored text after parsing when the new key was not supplied, and calls pp.record_resolved_parameter(new_key, value) so _ingested_params.param carries the converted value and a rerun from it reproduces the run. The conversions in lsdtt_common_parameters.hpp (gradient_from_legacy_degrees, relief_threshold_choice_from_legacy_bool) throw with the old key named when the stored text is not a valid old value.

The policy's optional third field, note, replaces the default warning tail (which is written for case 1) with a sentence saying what happened to the value, for example "Converted with gradient = tan(angle).".

Storage keys are implementation details and must not be added to help or user documentation. Do not reach for case 2 when an affine numeric alias would do: that facility stays deliberately narrow.

set_removed_keys() — the parameter is gone; fatal

Use this only for parameters whose whole purpose was to turn something on. Silently ignoring such a key leaves the user believing an analysis ran when it did not.

When to use it: The parameter controlled an analysis mode or enabled a feature. Removing it without the user noticing is a silent failure. Stop the run with a clear migration message instead.

Example: If a parameter like run_feature_analysis: true is removed, a user with an old .param file would expect that analysis to run. Silently skipping it is wrong. Make it fatal so they fix the file.

pp.set_removed_keys({
    {"run_obsolete_analysis",
     "The 'run_obsolete_analysis' parameter has been removed. "
     "Use 'run_new_analysis: true' instead."},
});

Behavior: The run stops (exit 1) and prints all removed-key messages together, so a user migrating an old file gets the whole list in one attempt rather than one key per run.

set_ignored_keys() — the parameter is gone; accepted and ignored

Use this only for parameters that merely tuned or disabled a mechanism that no longer exists. Ignoring such a key cannot change what the user believes happened.

When to use it: The parameter optimised or disabled something, and the mechanism no longer exists. The analysis still runs, and runs better. Ignoring the key is fine.

Example: If a parameter like legacy_optimization_level is removed, ignoring it does not hide a missing analysis — it just means a tuning knob is gone. The analysis still produces the same output.

pp.set_ignored_keys({
    {"legacy_optimization_level",
     "'legacy_optimization_level' is no longer used; the code now auto-optimizes. "
     "This parameter is ignored and may be removed from your .param file."},
});

Behavior: The key is accepted, ignored, and the run continues. The message is printed so the user knows the key was not lost in a typo.

set_report_unknown_keys() — default on

This is on by default and is separate from the three maps above. Any key in the .param file that is not declared in any default map and is not reserved (read_path, read_fname, write_path, write_fname, boundary_conditions) is reported as unknown. This catches typos.

A key that is a near miss of a declared or reserved key stops the run with "Did you mean ''?": the same key in another case, or, for keys of four or more characters, one within min(2, length/4) edits. Running on would silently use the default for the parameter the user meant. The length rule keeps short unknown keys (m) from being "corrected" to one-letter keys (K, D). Any other unknown key is a warning and the run continues — a key may be intended for a plugin or workflow wrapper. The check runs after the alias rewrite, so a deprecated name gets its own message, never a near-miss stop.

Value checks

parse_all_parameters also checks every supplied value against its declared type before any typed parse, and stops the run (all problems reported at once, FATAL: lines, exit 1) on:

  • a boolean other than true/True/TRUE/t/T/1 or false/False/FALSE/f/F/0;
  • an integer that is not a whole number in range (1e3, 1.5, 100m);
  • a float or double that is not a finite number (100m, nan), or a float outside float range;
  • an empty value for an integer, float, double or boolean key (an empty string key is fine);
  • a quoted value (lsdtt3 keeps quote characters).

A default that is not finite (nan as "not set") is written to _ingested_params.param as a comment, since the parser would reject it as a value. Float and double defaults are written with the shortest text that reads back to the same value, so the ingested file reproduces the run; the smoke tests re-run one test per CLI from it (LSDTT_RERUN_FROM_INGESTED_TESTS in tests/CMakeLists.txt).

Implementation in a CLI

Every CLI gains a deprecated-aliases block in its main(), immediately after the default maps are filled but before parse_all_parameters() is called:

// --- Parameter defaults and help ---
int_default_map["threshold_contributing_pixels"] = 1000;
help_map["threshold_contributing_pixels"] = {"int","1000",
    "Channel-initiation threshold (upstream pixels).",
    "...", "Flow routing", "param", ""};

// ... other parameters ...

// --- Deprecated aliases, removed keys, ignored keys ---
pp.set_deprecated_aliases({
    {"source_pixels_threshold", "threshold_contributing_pixels"},
    {"old_fill_param", "fill_before_routing"},
});
pp.set_removed_keys({
    {"run_obsolete_analysis",
     "The 'run_obsolete_analysis' parameter has been removed. "
     "Use 'run_new_analysis: true' instead."},
});
pp.set_ignored_keys({
    {"legacy_optimization_level",
     "'legacy_optimization_level' is no longer used; the code now auto-optimizes."},
});

// --- Parse all parameters ---
pp.parse_all_parameters(float_default_map, int_default_map,
                        bool_default_map, string_default_map);

For reference implementations, see: - cli/lsdtt-flat-surface-finder.cpp - cli/lsdtt-hillslope-channel-coupling.cpp - cli/lsdtt-internal-drainage.cpp - cli/lsdtt-valley-centreline.cpp

Every future rename adds one line to the alias map. That is the cost and the benefit.


Conformance testing

The CTest case unit_cli_parameter_conventions mechanically enforces the checkable subset of these rules. It runs tests/scripts/check_cli_parameter_conventions.py, which runs every built lsdtt-* binary with -h json and checks each declared parameter:

Rule id Checks
unit_suffix no name ends in _m, _px or _deg (_area_threshold_m2 is allowed)
print_writes_file no print_* parameter whose help says it writes a file
bool_raster_suffix a bool switch that writes a raster is write_<name>_raster / _rasters
duplicate_format at most one raster-format and one vector-format parameter per CLI
minimum_prefix bounds use min_ / max_, except <subject>_minimum_contributing_pixels
tol_abbreviation no tol token; use _tolerance_metres or _snap_max_search_radius_pixels
help_json_unparseable the CLI's -h json output could be parsed

The rules are heuristics over parameter names and help text. For example, bool_raster_suffix only fires when the help text mentions a raster and no vector or table output.

The test fails on every violation. The baseline allow-list (tests/data/cli_parameter_conventions_baseline.json) was used while the remediation phases were in progress, and is now empty. Do not add entries to it. Fix the name instead. The only sanctioned exceptions are recorded in the script itself:

  • REVIEWED_FORMAT_OVERRIDES: per-CLI format selectors that govern a genuinely different output domain or stage. Examples are wiener_diagnostics_write_output_format in lsdtt-channel-extraction and diagnostics_write_output_format in lsdtt-advection-diffusion, for visualisation frames. Add a new one only after review, with a comment saying why.
  • REVIEWED_INPUT_FORMATS: legacy input selectors that predate the _input_format suffix.

The test also refuses to run if the build tree does not match cli/: a CLI source with no binary, or a binary older than its source, is a fatal error, because checking stale binaries would measure parameters that no longer exist. If it fails this way, rebuild everything (cmake --build build -j, re-running cmake first if a new CLI was added) and run it again.

A companion test, unit_cli_reference_is_current, checks that the generated parameter tables in docs/cli_reference/ match the binaries. After adding or renaming a parameter, regenerate them with python3 tools/generate_cli_reference.py --update --bin-dir build.


The exception: <subject>_minimum_contributing_pixels

Three parameters form a deliberate exception to the min_ / max_ prefix rule:

  • basin_minimum_contributing_pixels
  • crossing_minimum_contributing_pixels
  • side_tributary_minimum_contributing_pixels

The subject prefix (basin_, crossing_, side_tributary_) says what is being filtered and the full word minimum reads as plain English: "the minimum contributing pixels for a [subject]". Do not shorten these to min_ — the readability of the English matters more than the prefix consistency in this case.

This is an explicit exception, not a conflict with the row above it in the standard table.


See also

  • Architecture document: lsdtt3 Architecture Document — parameter declaration in CLIs
  • Developer onboarding: lsdtt3 Developer Onboarding — checklist for adding a parameter
  • Parameter parser: src/core/lsdtt_param_parser.hpp — the engine that enforces these rules and applies aliases