CLI and command registry#

GeoPrior-v3 exposes a staged command-line interface for pipeline execution, artifact materialization, and figure rendering. The refactored layout uses geoprior.scripts as the authoritative home for reproducibility commands, while the top-level scripts package remains a backward-compatible launcher.

The practical rule is simple:

  • geoprior and its family entry points are the modern public interface.

  • geoprior.scripts owns the real script registry and artifact-path policy.

  • scripts mirrors that registry for legacy invocations such as python -m scripts ....

Note

The root dispatcher no longer depends on the legacy scripts package as its source of truth. Instead, both the modern CLI and the compatibility launcher read from geoprior.scripts.registry.

Architecture overview#

The command stack is split into three layers.

  1. Public entry points

    • geoprior

    • geoprior-run

    • geoprior-build

    • geoprior-plot

    • geoprior-init

  2. Internal implementation

    • geoprior.__main__

    • geoprior.cli

    • geoprior.cli._dispatch

    • geoprior.scripts.registry

    • geoprior.scripts.config

  3. Compatibility layer

    • scripts.__main__

    • scripts.registry

    • python -m scripts <command>

A compact conceptual map is shown below.

geoprior/
├── __main__.py
├── cli/
│   ├── __init__.py
│   ├── __main__.py
│   ├── _dispatch.py
│   ├── _presets.py
│   ├── config.py
│   ├── init_config.py
│   ├── run_sensitivity.py
│   ├── run_sm3_suite.py
│   ├── stage1.py
│   ├── stage2.py
│   ├── stage3.py
│   ├── stage4.py
│   ├── stage5.py
│   └── build_*.py / sm3_*.py / ...
├── scripts/
│   ├── registry.py
│   ├── config.py
│   └── plot_*.py / build_*.py / ...
└── ...

scripts/
├── __init__.py
├── __main__.py
└── registry.py

Entry points and usage model#

The modern CLI supports two complementary styles.

Root entry point#

The root command uses explicit families:

geoprior run <command> [args]
geoprior build <command> [args]
geoprior plot <command> [args]

Examples:

geoprior run stage1-preprocess
geoprior build exposure --help
geoprior plot physics-fields --help

Family-specific entry points#

Dedicated entry points remove the family prefix:

geoprior-run <command> [args]
geoprior-build <command> [args]
geoprior-plot <command> [args]
geoprior-init [args]

Examples:

geoprior-run stage4-infer --help
geoprior-build model-metrics
geoprior-plot transfer-impact
geoprior-init --yes

Backward compatibility launcher#

The legacy launcher remains available:

python -m scripts <command> [args]

Examples:

python -m scripts plot-physics-fields --help
python -m scripts make-exposure --help

This compatibility surface is intentionally thin. It re-exports the registry and dispatches to modules stored in geoprior.scripts.

Command families#

GeoPrior-v3 groups commands into three public families.

Run family#

The run family executes staged workflows and related experiment drivers.

Typical commands include:

  • init-config

  • stage1-preprocess

  • stage2-train

  • stage3-tune

  • stage4-infer

  • stage5-transfer

  • sensitivity

  • sm3-identifiability

  • sm3-offset-diagnostics

  • sm3-suite

Build family#

The build family materializes derived artifacts, helper products, and summary tables.

Typical commands include:

  • full-inputs-npz

  • physics-payload-npz

  • external-validation-fullcity

  • sm3-collect-summaries

  • assign-boreholes

  • add-zsurf-from-coords

  • external-validation-metrics

  • brier-exceedance

  • hotspots

  • hotspots-summary

  • extend-forecast

  • update-ablation-records

  • model-metrics

  • ablation-table

  • boundary

  • exposure

  • district-grid

  • clusters-with-zones

Plot family#

The plot family renders publication-facing figures, appendix panels, maps, and transfer-analysis visuals.

Typical commands include:

  • driver-response

  • core-ablation

  • litho-parity

  • uncertainty

  • spatial-forecasts

  • physics-sanity

  • physics-maps

  • physics-fields

  • physics-profiles

  • uncertainty-extras

  • ablations-sensitivity

  • physics-sensitivity

  • sm3-identifiability

  • sm3-bounds-ridge-summary

  • sm3-log-offsets

  • xfer-transferability

  • xfer-impact

  • transfer

  • transfer-impact

  • geo-cumulative

  • hotspot-analytics

  • external-validation

Note

The exact command list is defined in the registry. The examples above reflect the current public surface exposed by the dispatcher and the script registry.

Registry ownership#

The central design change in the refactor is the ownership model for reproducibility commands.

Before the refactor, the modern CLI treated scripts as a registry bridge. After the refactor, the authoritative registry lives in geoprior.scripts.registry.

That yields a cleaner dependency direction:

  • geoprior.cligeoprior.scripts

  • scriptsgeoprior.scripts

and avoids making the main package depend on a legacy shim.

Registry structure#

The registry stores command metadata such as:

  • module name

  • callable name

  • human-readable description

  • family

  • aliases

  • public command name

This information is consumed by the dispatcher in geoprior.cli.__main__ and mirrored by the compatibility launcher.

Dispatch model#

The modern CLI resolves commands through the registry and then imports the target module lazily.

This keeps the root entry point compact and makes each script module independently testable.

A simplified execution path is:

user command
   ↓
geoprior / geoprior-run / geoprior-build / geoprior-plot
   ↓
geoprior.cli.__main__
   ↓
registry lookup
   ↓
import target module
   ↓
call exported main function

Artifact-root policy#

The CLI also standardizes artifact-path resolution through shared environment-variable and config helpers.

In practice this means that figures, tables, and helper artifacts can be redirected consistently without forcing script-specific path rewrites.

Typical environment variables include:

  • GEOPRIOR_ARTIFACT_ROOT

  • GEOPRIOR_FIG_DIR

  • GEOPRIOR_OUT_DIR

The conceptual layout is:

<artifact root>/
├── results/
├── fig/
└── out/

This keeps compatibility with the historical folder layout while allowing explicit relocation of generated outputs.

Examples:

export GEOPRIOR_ARTIFACT_ROOT=/tmp/geoprior_artifacts
export GEOPRIOR_FIG_DIR=/tmp/geoprior_figures
export GEOPRIOR_OUT_DIR=/tmp/geoprior_tables

On Windows PowerShell:

$env:GEOPRIOR_ARTIFACT_ROOT = "D:\\geoprior\\artifacts"
$env:GEOPRIOR_FIG_DIR = "D:\\geoprior\\figures"
$env:GEOPRIOR_OUT_DIR = "D:\\geoprior\\tables"

Why geoprior.__main__ matters#

Adding geoprior.__main__ allows direct execution with:

python -m geoprior --help

This complements installed console scripts and gives one more stable execution path for development, testing, and package verification.

API reference strategy#

Top-level entry points#

__main__

Module entry point for running GeoPrior as a package.

cli

Command-line interface for GeoPrior-v3.

__main__

Command-line entry point for GeoPrior.

__main__

registry

Compatibility registry for legacy python -m scripts runs.

Dispatcher and shared CLI helpers#

_dispatch

Low-level dispatch helpers for GeoPrior command-line entry points.

_presets

Reusable CLI preset definitions.

config

Shared CLI configuration helpers.

Run wrappers and workflow drivers#

init_config

CLI for initializing GeoPrior configuration files.

stage1

Stage-1: Zhongshan/Nansha preprocessing & sequence export for GeoPriorSubsNet

stage2

CLI wrapper for Stage-2 training.

stage3

CLI wrapper for Stage-3 hyperparameter tuning.

stage4

CLI wrapper for Stage-4 inference.

stage5

CLI wrapper for Stage-5 cross-city transfer evaluation.

run_sensitivity

run_sensitivity.py

run_sm3_suite

Preset-driven SM3 suite runner.

sensitivity_lib

Drop-in run_one() for nat.com/sensitivity_lib.py.

sm3_collect_summaries

Collect SM3 per-regime summaries into one combined table.

sm3_log_offsets_diagnostics

CLI wrapper for SM3 log-offset diagnostics.

sm3_synthetic_identifiability

CLI wrapper for SM3 synthetic identifiability.

Build wrappers under geoprior.cli#

build_add_zsurf_from_coords

Build harmonized datasets enriched with surface elevation.

build_assign_boreholes

Assign boreholes to the nearest city point cloud.

build_external_validation_fullcity

CLI for building full-city external validation artifacts.

build_external_validation_metrics

Build external validation metrics from Stage-1 inputs and a physics payload.

build_full_inputs_npz

CLI for building merged full-input NPZ artifacts.

build_physics_payload_npz

CLI for building physics payload NPZ artifacts.

build_sm3_collect_summaries

Build a combined SM3 summary table from a suite directory.

Private workflow backends excluded from autosummary#

The modules below are intentionally not imported by the API reference because they depend on workflow state such as manifests or runtime environment setup.

  • geoprior.cli._sensitivity

  • geoprior.cli._stage2

  • geoprior.cli._stage3

  • geoprior.cli._stage4

  • geoprior.cli._stage5

Reproducibility registry and shared helpers#

config

Shared configuration helpers for GeoPrior scripts.

registry

Registry helpers used by GeoPrior scripts.

utils

Shared utility helpers for GeoPrior scripts.

extend_utils

Utility helpers for forecast extension scripts.

Build and compute scripts under geoprior.scripts#

build_ablation_table

Build tidy ablation/sensitivity tables from ablation_records.

build_model_metrics

Build a unified "model metrics" table from GeoPrior runs.

compute_brier_exceedance

Compute Brier scores for subsidence exceedance events.

compute_hotspots

Script helpers for computing spatial hotspot summaries.

extend_forecast

Script helpers for extending forecast outputs in time.

make_boundary

Script helpers for building study-area boundary artifacts.

make_district_grid

Script helpers for building district grid artifacts.

make_exposure

Script helpers for building exposure-layer artifacts.

rebuild_confusion_tables

Script helpers for rebuilding confusion tables.

summarize_hotspots

Summarise hotspot point clouds.

tag_clusters_with_zones

Script helpers for tagging clusters with spatial zones.

update_ablation_record_posthoc

Update ablation record using post-hoc calibrated metrics.

update_ablation_records

Update ablation_record using post-hoc calibrated metrics.

Plot scripts under geoprior.scripts#

plot_ablations_sensitivity

Plot extended ablations & sensitivities.

plot_core_ablation

Script helpers for plotting core ablation results.

plot_driver_response

Script helpers for plotting driver response analyses.

plot_external_validation

Plot point-support external validation of inferred effective fields.

plot_geo_cumulative

Plot cumulative subsidence maps on satellite basemap + optional hotspots.

plot_hotspot_analytics

Script helpers for plotting hotspot analytics.

plot_litho_parity

Plot lithology parity across cities.

plot_physics_fields

Plot spatial physics fields and "physics tension".

plot_physics_maps

Plot spatial physics fields.

plot_physics_profiles

Plot 1D physics sensitivity profiles.

plot_physics_sanity

Script helpers for plotting physics sanity diagnostics.

plot_physics_sensitivity

Plot physics sensitivity (\(epsilon\_prior, \epsilon_cons\)).

plot_sm3_bounds_ridge_summary

Plot bounds vs ridge summary.

plot_sm3_identifiability

Plot synthetic identifiability (SM3).

plot_sm3_log_offsets

Script helpers for plotting SM3 log-offset diagnostics.

plot_spatial_forecasts

Script helpers for plotting spatial forecast outputs.

plot_transfer

Plot cross-city transferability of GeoPriorSubsNet.

plot_uncertainty

Script helpers for plotting uncertainty diagnostics.

plot_uncertainty_extras

Extra plotting helpers for uncertainty diagnostics.

plot_xfer_impact

Plot impact transferability.

plot_xfer_transferability

Plot cross-city transferability +.

Registry ownership#

The authoritative command registry lives under geoprior.scripts.registry. The modern CLI and the compatibility launcher both read from that registry rather than maintaining separate command maps.

The dependency direction is therefore:

  • geoprior.cligeoprior.scripts

  • scriptsgeoprior.scripts

This keeps the compatibility layer thin and avoids making the main package depend on legacy launch code.

Dispatch model#

The modern CLI resolves a command through the registry, imports the target module lazily, and calls the exported main function.

user command
   ↓
geoprior / geoprior-run / geoprior-build / geoprior-plot
   ↓
geoprior.cli.__main__
   ↓
registry lookup
   ↓
import target module
   ↓
call exported main function

Artifact-root policy#

The CLI standardizes artifact-path resolution through shared configuration and environment-variable helpers. In practice, this allows figures, tables, and helper artifacts to be redirected without rewriting each script individually.

Typical environment variables include:

  • GEOPRIOR_ARTIFACT_ROOT

  • GEOPRIOR_FIG_DIR

  • GEOPRIOR_OUT_DIR

Conceptually:

<artifact root>/
├── results/
├── fig/
└── out/

Examples:

export GEOPRIOR_ARTIFACT_ROOT=/tmp/geoprior_artifacts
export GEOPRIOR_FIG_DIR=/tmp/geoprior_figures
export GEOPRIOR_OUT_DIR=/tmp/geoprior_tables

On Windows PowerShell:

$env:GEOPRIOR_ARTIFACT_ROOT = "D:\\geoprior\\artifacts"
$env:GEOPRIOR_FIG_DIR = "D:\\geoprior\\figures"
$env:GEOPRIOR_OUT_DIR = "D:\\geoprior\\tables"

Source listings#

The most important source files for the CLI stack are listed below. These listings are useful when the API reference is read together with the developer notes.

Package entry point#

geoprior/__main__.py#
# SPDX-License-Identifier: Apache-2.0
# GeoPrior-v3 — https://github.com/earthai-tech/geoprior-v3
# Copyright (c) 2026-present
# Author: LKouadio <https://lkouadio.com>
r"""Module entry point for running GeoPrior as a package."""

from __future__ import annotations

from .cli import main

if __name__ == "__main__":
    main()

Modern CLI package#

geoprior/cli/__init__.py#
# SPDX-License-Identifier: Apache-2.0
# GeoPrior-v3 — https://github.com/earthai-tech/geoprior-v3
# Copyright (c) 2026-present
# Author: LKouadio <https://lkouadio.com>

"""Command-line interface for GeoPrior-v3.

This subpackage exposes one versatile root entry point and
its public wrapper modules.

Examples
--------
Use the root dispatcher with an explicit family::

    geoprior run stage1-preprocess
    geoprior build full-inputs-npz --stage1-dir results/foo_stage1

Use the family-specific console scripts directly::

    geoprior-run stage4-infer --help
    geoprior-build full-inputs
    geoprior-plot <command>
    geoprior-init --yes

Notes
-----
The package lazily exposes public CLI wrapper modules so
``geoprior.cli.<name>`` works with Sphinx autosummary
without importing private execution backends such as
``geoprior.cli._stage2`` at package import time.
"""

from __future__ import annotations

from importlib import import_module
from types import ModuleType
from typing import Final

_MAIN_EXPORTS: Final = {
    "main": (".__main__", "main"),
    "run_main": (".__main__", "run_main"),
    "build_main": (".__main__", "build_main"),
    "plot_main": (".__main__", "plot_main"),
}

_PUBLIC_MODULES: Final = {
    "config",
    "utils",
    "init_config",
    "stage1",
    "stage2",
    "stage3",
    "stage4",
    "stage5",
    "run_sensitivity",
    "run_sm3_suite",
    "sensitivity_lib",
    "sm3_collect_summaries",
    "sm3_log_offsets_diagnostics",
    "sm3_synthetic_identifiability",
    "build_add_zsurf_from_coords",
    "build_spatial_sampling",
    "build_batch_spatial_sampling",
    "build_forecast_ready_sample",
    "build_spatial_roi",
    "build_spatial_clusters",
    "build_extract_zones",
    "build_assign_boreholes",
    "build_external_validation_fullcity",
    "build_external_validation_metrics",
    "build_full_inputs_npz",
    "build_physics_payload_npz",
    "build_sm3_collect_summaries",
}

__all__ = [
    "main",
    "run_main",
    "build_main",
    "plot_main",
    "config",
    "utils",
    "init_config",
    "stage1",
    "stage2",
    "stage3",
    "stage4",
    "stage5",
    "run_sensitivity",
    "run_sm3_suite",
    "sensitivity_lib",
    "sm3_collect_summaries",
    "sm3_log_offsets_diagnostics",
    "sm3_synthetic_identifiability",
    "build_add_zsurf_from_coords",
    "build_spatial_sampling",
    "build_batch_spatial_sampling",
    "build_spatial_roi",
    "build_spatial_clusters",
    "build_extract_zones",
    "build_assign_boreholes",
    "build_external_validation_fullcity",
    "build_external_validation_metrics",
    "build_full_inputs_npz",
    "build_physics_payload_npz",
    "build_sm3_collect_summaries",
]


def _load_module(name: str) -> ModuleType:
    module = import_module(f".{name}", __name__)
    globals()[name] = module
    return module


def _load_export(name: str):
    mod_name, attr_name = _MAIN_EXPORTS[name]
    module = import_module(mod_name, __name__)
    value = getattr(module, attr_name)
    globals()[name] = value
    return value


def __getattr__(name: str):
    if name in _MAIN_EXPORTS:
        return _load_export(name)
    if name in _PUBLIC_MODULES:
        return _load_module(name)
    raise AttributeError(
        f"module {__name__!r} has no attribute {name!r}"
    )


def __dir__() -> list[str]:
    return sorted(set(globals()) | set(__all__))
geoprior/cli/__main__.py#
# SPDX-License-Identifier: Apache-2.0
# GeoPrior-v3 — https://github.com/earthai-tech/geoprior-v3
# Copyright (c) 2026-present
# Author: LKouadio <https://lkouadio.com>
r"""Command-line entry point for GeoPrior."""

from __future__ import annotations

import sys

from ..scripts.registry import SCRIPT_COMMANDS
from ._dispatch import (
    CommandSpec,
    alias_map,
    call_entry,
    load_callable,
    print_help_table,
    run_module,
)


def _scripts_as_cli() -> dict[str, CommandSpec]:
    items: dict[str, CommandSpec] = {}

    for spec in SCRIPT_COMMANDS.values():
        public = spec.public_name
        if public is None:
            continue

        if public in items:
            raise ValueError(
                f"Duplicate script public name: {public}"
            )

        items[public] = CommandSpec(
            package="geoprior.scripts",
            mod=spec.mod,
            fn=spec.fn,
            desc=spec.desc,
            mode=spec.mode,
            family=spec.family,
            public_name=public,
            aliases=spec.aliases,
            legacy_names=spec.legacy_names,
        )

    return items


_CMD: dict[str, CommandSpec] = {
    "init-config": CommandSpec(
        package="geoprior.cli",
        mod="init_config",
        fn="main",
        desc="Create nat.com/config.py interactively.",
        mode="argv",
        family="run",
        public_name="init-config",
        aliases=("init", "bootstrap"),
    ),
    "stage1-preprocess": CommandSpec(
        package="geoprior.cli",
        mod="stage1",
        fn="stage1_main",
        desc="Stage-1 preprocessing and export.",
        mode="argv",
        family="run",
        public_name="stage1-preprocess",
        aliases=(
            "stage1",
            "s1",
            "preprocess",
            "prepare",
        ),
    ),
    "stage2-train": CommandSpec(
        package="geoprior.cli",
        mod="stage2",
        fn="stage2_main",
        desc="Stage-2 training.",
        mode="argv",
        family="run",
        public_name="stage2-train",
        aliases=(
            "stage2",
            "s2",
            "train",
            "fit",
        ),
    ),
    "stage3-tune": CommandSpec(
        package="geoprior.cli",
        mod="stage3",
        fn="stage3_main",
        desc="Stage-3 hyperparameter tuning.",
        mode="argv",
        family="run",
        public_name="stage3-tune",
        aliases=(
            "stage3",
            "s3",
            "tune",
            "tuning",
            "search",
        ),
    ),
    "stage4-infer": CommandSpec(
        package="geoprior.cli",
        mod="stage4",
        fn="stage4_main",
        desc="Stage-4 inference.",
        mode="argv",
        family="run",
        public_name="stage4-infer",
        aliases=(
            "stage4",
            "s4",
            "infer",
            "inference",
            "predict",
            "forecast",
        ),
    ),
    "stage5-transfer": CommandSpec(
        package="geoprior.cli",
        mod="stage5",
        fn="stage5_main",
        desc="Stage-5 transfer evaluation.",
        mode="argv",
        family="run",
        public_name="stage5-transfer",
        aliases=(
            "stage5",
            "s5",
            "transfer",
            "xfer",
        ),
    ),
    "sensitivity": CommandSpec(
        package="geoprior.cli",
        mod="run_sensitivity",
        fn="sensitivity_main",
        desc="Physics sensitivity grid driver.",
        mode="argv",
        family="run",
        public_name="sensitivity",
        aliases=(
            "sens",
            "lambda-sensitivity",
            "run-sensitivity",
        ),
    ),
    "identifiability": CommandSpec(
        package="geoprior.cli",
        mod="sm3_synthetic_identifiability",
        fn="sm3_identifiability_main",
        desc="SM3 synthetic identifiability.",
        mode="argv",
        family="run",
        public_name="identifiability",
        aliases=(
            "identifiability",
            "ident",
            "indetifiability",
            "sm3-ident",
        ),
    ),
    "sm3-offset-diagnostics": CommandSpec(
        package="geoprior.cli",
        mod="sm3_log_offsets_diagnostics",
        fn="sm3_offsets_main",
        desc="SM3 log-offset diagnostics.",
        mode="argv",
        family="run",
        public_name="sm3-offset-diagnostics",
        aliases=(
            "offset-diagnostics",
            "offsets",
            "sm3-offsets",
        ),
    ),
    "sm3-suite": CommandSpec(
        package="geoprior.cli",
        mod="run_sm3_suite",
        fn="sm3_suite_main",
        desc="Preset-driven SM3 multi-regime suite runner.",
        mode="argv",
        family="run",
        public_name="sm3-suite",
        aliases=(
            "sm3-regimes",
            "sm3-preset",
            "sm3-batch",
        ),
    ),
    "full-inputs-npz": CommandSpec(
        package="geoprior.cli",
        mod="build_full_inputs_npz",
        fn="build_full_inputs_main",
        desc="Build merged full_inputs.npz from splits.",
        mode="argv",
        family="build",
        public_name="full-inputs-npz",
        aliases=(
            "build-full-inputs",
            "make-full-inputs",
            "full-inputs",
            "merge-inputs",
        ),
    ),
    "physics-payload-npz": CommandSpec(
        package="geoprior.cli",
        mod="build_physics_payload_npz",
        fn="build_physics_payload_main",
        desc="Build a physics payload NPZ.",
        mode="argv",
        family="build",
        public_name="physics-payload-npz",
        aliases=(
            "physics-payload",
            "payload-npz",
            "full-city-payload",
            "fullcity-payload",
            "export-physics-payload",
        ),
    ),
    "external-validation-fullcity": CommandSpec(
        package="geoprior.cli",
        mod="build_external_validation_fullcity",
        fn="build_external_validation_fullcity_main",
        desc="Build full-city validation artifacts.",
        mode="argv",
        family="build",
        public_name="external-validation-fullcity",
        aliases=(
            "external-validation",
            "fullcity-validation",
            "validate-fullcity",
            "ext-validation",
        ),
    ),
    "sm3-collect-summaries": CommandSpec(
        package="geoprior.cli",
        mod="build_sm3_collect_summaries",
        fn="build_sm3_collect_main",
        desc="Build one combined SM3 summary table.",
        mode="argv",
        family="build",
        public_name="sm3-collect-summaries",
        aliases=(
            "sm3-summaries",
            "collect-summaries",
            "collect-sm3",
            "combined-sm3-summary",
        ),
    ),
    "assign-boreholes": CommandSpec(
        package="geoprior.cli",
        mod="build_assign_boreholes",
        fn="build_assign_boreholes_main",
        desc="Build nearest-city borehole tables.",
        mode="argv",
        family="build",
        public_name="assign-boreholes",
        aliases=(
            "classify-boreholes",
            "borehole-city-assignment",
            "boreholes-by-city",
            "split-boreholes",
        ),
    ),
    "add-zsurf-from-coords": CommandSpec(
        package="geoprior.cli",
        mod="build_add_zsurf_from_coords",
        fn="build_add_zsurf_main",
        desc="Build z_surf-enriched datasets.",
        mode="argv",
        family="build",
        public_name="add-zsurf-from-coords",
        aliases=(
            "add-zsurf",
            "merge-zsurf",
            "zsurf-from-coords",
            "harmonized-zsurf",
        ),
    ),
    "batch-spatial-sampling": CommandSpec(
        package="geoprior.cli",
        mod="build_batch_spatial_sampling",
        fn="build_batch_spatial_sampling_main",
        desc="Build non-overlapping spatial sample batches.",
        mode="argv",
        family="build",
        public_name="batch-spatial-sampling",
        aliases=(
            "batch-sampling",
            "batch-spatial",
            "spatial-batches",
        ),
    ),
    "spatial-sampling": CommandSpec(
        package="geoprior.cli",
        mod="build_spatial_sampling",
        fn="build_spatial_sampling_main",
        desc="Build a stratified spatial sample table.",
        mode="argv",
        family="build",
        public_name="spatial-sampling",
        aliases=(
            "sample-spatial",
            "spatial-sample",
            "sampling",
        ),
    ),
    "spatial-roi": CommandSpec(
        package="geoprior.cli",
        mod="build_spatial_roi",
        fn="build_spatial_roi_main",
        desc="Build a spatial region-of-interest table.",
        mode="argv",
        family="build",
        public_name="spatial-roi",
        aliases=(
            "roi",
            "extract-roi",
            "roi-table",
        ),
    ),
    "spatial-clusters": CommandSpec(
        package="geoprior.cli",
        mod="build_spatial_clusters",
        fn="build_spatial_clusters_main",
        desc="Build a table with spatial cluster labels.",
        mode="argv",
        family="build",
        public_name="spatial-clusters",
        aliases=(
            "cluster-spatial",
            "cluster-regions",
            "clusters",
        ),
    ),
    "forecast-ready-sample": CommandSpec(
        package="geoprior.cli",
        mod="build_forecast_ready_sample",
        fn="build_forecast_ready_sample_main",
        desc="Build a compact forecast-ready panel sample.",
        mode="argv",
        family="build",
        public_name="forecast-ready-sample",
        aliases=(
            "forecast-sample",
            "panel-sample",
            "demo-panel",
            "ready-sample",
        ),
    ),
    "extract-zones": CommandSpec(
        package="geoprior.cli",
        mod="build_extract_zones",
        fn="build_extract_zones_main",
        desc="Build a threshold-based zone extraction table.",
        mode="argv",
        family="build",
        public_name="extract-zones",
        aliases=(
            "zones",
            "zones-from",
            "zone-extract",
        ),
    ),
    "external-validation-metrics": CommandSpec(
        package="geoprior.cli",
        mod="build_external_validation_metrics",
        fn="build_external_validation_metrics_main",
        desc="Build external validation metrics.",
        mode="argv",
        family="build",
        public_name="external-validation-metrics",
        aliases=(
            "borehole-validation",
            "compute-external-validation",
        ),
    ),
}

for _name, _spec in _scripts_as_cli().items():
    if _name in _CMD:
        raise ValueError(f"Duplicate public command: {_name}")
    _CMD[_name] = _spec


_FAMILY_ALIASES = {
    "run": "run",
    "build": "build",
    "make": "build",
    "plot": "plot",
}


_GROUPS = (
    (
        "Pipeline",
        (
            "init-config",
            "stage1-preprocess",
            "stage2-train",
            "stage3-tune",
            "stage4-infer",
            "stage5-transfer",
            "sensitivity",
        ),
    ),
    (
        "Supplementary diagnostics",
        (
            "sm3-identifiability",
            "sm3-offset-diagnostics",
            "sm3-suite",
        ),
    ),
    (
        "Build commands",
        (
            "full-inputs-npz",
            "physics-payload-npz",
            "external-validation-fullcity",
            "sm3-collect-summaries",
            "assign-boreholes",
            "add-zsurf-from-coords",
            "external-validation-metrics",
            "forecast-ready-sample",
            "batch-spatial-sampling",
            "spatial-sampling",
            "spatial-roi",
            "spatial-clusters",
            "extract-zones",
            "brier-exceedance",
            "hotspots",
            "hotspots-summary",
            "extend-forecast",
            "update-ablation-records",
            "model-metrics",
            "ablation-table",
            "boundary",
            "exposure",
            "district-grid",
            "clusters-with-zones",
        ),
    ),
    (
        "Plot commands",
        (
            "driver-response",
            "core-ablation",
            "litho-parity",
            "uncertainty",
            "spatial-forecasts",
            "physics-sanity",
            "physics-maps",
            "physics-fields",
            "physics-profiles",
            "uncertainty-extras",
            "ablations-sensitivity",
            "physics-sensitivity",
            "sm3-identifiability",
            "sm3-bounds-ridge-summary",
            "sm3-log-offsets",
            "xfer-transferability",
            "xfer-impact",
            "transfer",
            "transfer-impact",
            "geo-cumulative",
            "hotspot-analytics",
            "external-validation",
        ),
    ),
)


def _auto_prog_name() -> str:
    argv0 = (sys.argv[0] or "").strip()
    if argv0.endswith("__main__.py"):
        return "python -m geoprior.cli"
    return argv0 or "geoprior"


def _entry_prog(family: str | None) -> str:
    if family == "run":
        return "geoprior-run"
    if family == "build":
        return "geoprior-build"
    if family == "plot":
        return "geoprior-plot"
    return "geoprior"


def _display_cmd(
    prog: str,
    family: str,
    cmd: str,
    *,
    fixed_family: str | None,
) -> str:
    if fixed_family is None:
        return f"{prog} {family} {cmd}"
    return f"{prog} {cmd}"


def _family_items(
    family: str,
) -> list[tuple[str, CommandSpec]]:
    items: list[tuple[str, CommandSpec]] = []

    for name, spec in _CMD.items():
        if spec.public_name != name:
            continue
        if spec.family != family:
            continue
        items.append((name, spec))

    items.sort(key=lambda x: x[0])
    return items


def _print_help(
    *,
    fixed_family: str | None = None,
    prog: str | None = None,
) -> None:
    prog_name = prog or _auto_prog_name()

    if fixed_family is None:
        print("Usage:")
        print(f"  {prog_name} run <command> [args]")
        print(f"  {prog_name} build <command> [args]")
        print(f"  {prog_name} plot <command> [args]")
        print("")
        print("Families:")
        print("  run   Execute model workflows.")
        print("  build Materialize artifacts.")
        print("  make  Alias of build.")
        print("  plot  Render figures and maps.")
        print("")

        for title, names in _GROUPS:
            items = [
                (name, _CMD[name])
                for name in names
                if name in _CMD
            ]
            print_help_table(title, items)

        amap = alias_map(_CMD)
        if amap:
            print("Aliases:")
            for src in sorted(amap):
                print(f"  {src} -> {amap[src]}")
            print("")

        print("Examples:")
        print(f"  {prog_name} plot physics-fields --help")
        print(f"  {prog_name} build exposure --help")
        print(f"  {prog_name} run stage1-preprocess")
        return

    print("Usage:")
    print(f"  {prog_name} <command> [args]")
    print("")
    print(f"{fixed_family.title()} commands:")
    print("")

    items = _family_items(fixed_family)
    print_help_table("Commands", items)

    amap = alias_map(_CMD, family=fixed_family)
    if amap:
        print("Aliases:")
        for src in sorted(amap):
            print(f"  {src} -> {amap[src]}")
        print("")

    print("Examples:")
    if fixed_family == "plot":
        print(f"  {prog_name} physics-fields --help")
    elif fixed_family == "build":
        print(f"  {prog_name} exposure --help")
    else:
        print(f"  {prog_name} stage1-preprocess")


def _resolve(
    args: list[str],
    *,
    fixed_family: str | None,
    prog: str,
) -> tuple[str, list[str], str]:
    amap = alias_map(_CMD)

    if fixed_family is None:
        family = _FAMILY_ALIASES.get(args[0])
        if family is None:
            _print_help(
                fixed_family=None,
                prog=prog,
            )
            raise SystemExit(2)

        if len(args) == 1:
            _print_help(
                fixed_family=family,
                prog=f"{prog} {args[0]}",
            )
            raise SystemExit(0)

        if args[1] in {"-h", "--help", "help"}:
            _print_help(
                fixed_family=family,
                prog=f"{prog} {args[0]}",
            )
            raise SystemExit(0)

        cmd = amap.get(args[1], args[1])
        return cmd, args[2:], family

    repeated = _FAMILY_ALIASES.get(args[0])
    if repeated is not None:
        print(
            f"[ERR] {prog!r} already implies "
            f"the {fixed_family!r} family."
        )
        raise SystemExit(2)

    cmd = amap.get(args[0], args[0])
    return cmd, args[1:], fixed_family


def _dispatch(
    argv: list[str] | None = None,
    *,
    fixed_family: str | None = None,
    prog: str | None = None,
) -> None:
    args = list(argv) if argv is not None else sys.argv[1:]
    prog_name = prog or _auto_prog_name()

    if not args or args[0] in {"-h", "--help", "help"}:
        _print_help(
            fixed_family=fixed_family,
            prog=prog_name,
        )
        return

    cmd, rest, family = _resolve(
        args,
        fixed_family=fixed_family,
        prog=prog_name,
    )
    spec = _CMD.get(cmd)

    if spec is None:
        print(f"[ERR] Unknown command: {cmd}")
        print("")
        _print_help(
            fixed_family=family,
            prog=prog_name,
        )
        raise SystemExit(2)

    if spec.family != family:
        print(
            f"[ERR] Command {cmd!r} belongs to "
            f"{spec.family!r}, not {family!r}."
        )
        print(f"[HINT] Use {_entry_prog(spec.family)} {cmd}")
        raise SystemExit(2)

    display_cmd = _display_cmd(
        prog_name,
        family,
        cmd,
        fixed_family=fixed_family,
    )

    if spec.mode == "module":
        run_module(
            spec,
            display_cmd=display_cmd,
            argv=rest,
        )
        return

    fn = load_callable(spec)
    call_entry(
        fn,
        argv=rest,
        display_cmd=display_cmd,
    )


def main(argv: list[str] | None = None) -> None:
    _dispatch(argv, fixed_family=None)


def run_main(argv: list[str] | None = None) -> None:
    _dispatch(argv, fixed_family="run")


def build_main(argv: list[str] | None = None) -> None:
    _dispatch(argv, fixed_family="build")


def plot_main(argv: list[str] | None = None) -> None:
    _dispatch(argv, fixed_family="plot")


main.__doc__ = r"""
Run the root GeoPrior command dispatcher.

This is the top-level CLI entry point behind the generic
``geoprior`` command. It routes user input to one of the three
public command families exposed by the project:

- ``run`` for staged model workflows,
- ``build`` for artifact materialization and table generation,
- ``plot`` for figure and map rendering.

The function itself is intentionally thin. It delegates all parsing,
family resolution, alias expansion, help rendering, and command
execution to the internal dispatcher while keeping a stable public
entry point for console scripts and programmatic invocation.

Conceptually, the root command supports calls of the form

.. code-block:: bash

   geoprior run <command> [args]
   geoprior build <command> [args]
   geoprior plot <command> [args]

which mirrors the stage-wise and artifact-aware workflow adopted by
the GeoPrior project for forecasting, diagnostics, and uncertainty
analysis :cite:p:`Kouadio2025XTFT,Limetal2021`.

Parameters
----------
argv : list of str or None, default=None
    Optional command-line tokens excluding the program name.
    When ``None``, the function reads arguments from
    :data:`sys.argv`.

Returns
-------
None
    This function is executed for its side effects. It prints help,
    dispatches a command, or raises :class:`SystemExit` on invalid
    user input.

Raises
------
SystemExit
    Raised when command resolution fails, when help is requested, or
    when a delegated subcommand exits.

Notes
-----
This function is the most user-facing CLI entry point in the module.
Use it when you want the full family-aware dispatcher behavior.

For family-specific wrappers that do not require the explicit
``run``, ``build``, or ``plot`` prefix, see :func:`run_main`,
:func:`build_main`, and :func:`plot_main`.

Examples
--------
Call from Python with an explicit token list:

>>> from geoprior.cli.__main__ import main
>>> main(["run", "stage1-preprocess"])  # doctest: +SKIP

Request top-level help:

>>> main(["--help"])  # doctest: +SKIP

Dispatch a plotting command:

>>> main(["plot", "physics-fields", "--help"])  # doctest: +SKIP

See Also
--------
run_main :
    Run-family wrapper used by the ``geoprior-run`` entry point.
build_main :
    Build-family wrapper used by the ``geoprior-build`` entry point.
plot_main :
    Plot-family wrapper used by the ``geoprior-plot`` entry point.
"""


run_main.__doc__ = r"""
Run the GeoPrior dispatcher in fixed ``run`` mode.

This wrapper exposes the workflow-oriented command family behind the
``geoprior-run`` console script. Unlike :func:`main`, it does not
expect a family token as the first positional argument. Instead, it
assumes that every command belongs to the ``run`` family and rejects
attempts to repeat the family prefix.

Typical commands dispatched through this entry point include stage
execution and synthetic or sensitivity workflows, such as
preprocessing, training, tuning, inference, transfer evaluation, and
selected supplementary diagnostics.

Supported usage therefore follows the shorter form

.. code-block:: bash

   geoprior-run <command> [args]

rather than

.. code-block:: bash

   geoprior run <command> [args]

Parameters
----------
argv : list of str or None, default=None
    Optional command-line tokens excluding the program name.
    When ``None``, the function reads arguments from
    :data:`sys.argv`.

Returns
-------
None
    This function dispatches a run-family command for its side
    effects.

Raises
------
SystemExit
    Raised when the requested command is unknown, belongs to a
    different family, or explicitly requests help.

Notes
-----
This wrapper is mainly intended for console-script integration and
family-specific convenience. It is especially useful in automation,
examples, and shell documentation where repeated family prefixes
would add noise.

Examples
--------
Run stage 1 preprocessing:

>>> from geoprior.cli.__main__ import run_main
>>> run_main(["stage1-preprocess"])  # doctest: +SKIP

Inspect the help of a training command:

>>> run_main(["stage2-train", "--help"])  # doctest: +SKIP

Request family-scoped help:

>>> run_main(["--help"])  # doctest: +SKIP

See Also
--------
main :
    Root family-aware dispatcher.
build_main :
    Build-family wrapper.
plot_main :
    Plot-family wrapper.
"""


build_main.__doc__ = r"""
Run the GeoPrior dispatcher in fixed ``build`` mode.

This wrapper exposes the artifact-building command family behind the
``geoprior-build`` console script. It assumes that the requested
subcommand already belongs to the ``build`` family and therefore uses
the compact invocation form

.. code-block:: bash

   geoprior-build <command> [args]

Typical commands in this family generate or transform reproducible
artifacts needed by downstream training, validation, interpretation,
or documentation workflows. Examples include merged NPZ payloads,
external-validation tables, spatial sampling products, hotspot
summaries, and other materialized intermediate datasets.

Parameters
----------
argv : list of str or None, default=None
    Optional command-line tokens excluding the program name.
    When ``None``, the function reads arguments from
    :data:`sys.argv`.

Returns
-------
None
    This function dispatches a build-family command for its side
    effects.

Raises
------
SystemExit
    Raised when the command is unknown, belongs to another family, or
    when help is requested.

Notes
-----
Use this wrapper when you want a stable programmatic entry point for
artifact generation without exposing the full root dispatcher. This
is the recommended choice for shell examples, gallery preparation,
and reproducible data-materialization scripts built around GeoPrior.

Examples
--------
Build a full input archive:

>>> from geoprior.cli.__main__ import build_main
>>> build_main(["full-inputs-npz", "--help"])  # doctest: +SKIP

Build a compact forecast-ready panel:

>>> build_main(["forecast-ready-sample"])  # doctest: +SKIP

Show the build-family help page:

>>> build_main(["--help"])  # doctest: +SKIP

See Also
--------
main :
    Root family-aware dispatcher.
run_main :
    Run-family wrapper.
plot_main :
    Plot-family wrapper.
"""


plot_main.__doc__ = r"""
Run the GeoPrior dispatcher in fixed ``plot`` mode.

This wrapper exposes the visualization command family behind the
``geoprior-plot`` console script. It dispatches plotting commands
without requiring the explicit ``plot`` family prefix and therefore
supports the compact form

.. code-block:: bash

   geoprior-plot <command> [args]

The plot family is used to render publication-style figures,
diagnostic graphics, uncertainty summaries, spatial forecast maps,
transfer panels, and other visual outputs derived from GeoPrior
artifacts. In practice, this family is central to the project's
interpretability and reporting workflow, where forecast accuracy,
uncertainty behavior, and physically informed diagnostics must be
read together :cite:p:`Kouadio2025XTFT`.

Parameters
----------
argv : list of str or None, default=None
    Optional command-line tokens excluding the program name.
    When ``None``, the function reads arguments from
    :data:`sys.argv`.

Returns
-------
None
    This function dispatches a plot-family command for its side
    effects.

Raises
------
SystemExit
    Raised when the command is unknown, belongs to another family, or
    when help is requested.

Notes
-----
This wrapper is useful when the calling context is already
visualization-specific, such as gallery scripts, reproducible figure
pipelines, or publication packaging code.

Examples
--------
Inspect help for a physics-field plot:

>>> from geoprior.cli.__main__ import plot_main
>>> plot_main(["physics-fields", "--help"])  # doctest: +SKIP

Render a transferability figure:

>>> plot_main(["xfer-transferability"])  # doctest: +SKIP

Show the plot-family help page:

>>> plot_main(["--help"])  # doctest: +SKIP

See Also
--------
main :
    Root family-aware dispatcher.
run_main :
    Run-family wrapper.
build_main :
    Build-family wrapper.
"""

if __name__ == "__main__":
    main()
geoprior/cli/_dispatch.py#
# SPDX-License-Identifier: Apache-2.0
r"""
Low-level dispatch helpers for GeoPrior command-line entry points.

This module provides the internal building blocks used by the GeoPrior
CLI frontends to resolve commands, import their implementation modules,
and execute the appropriate entry callable with a uniform interface.

It is intentionally small and generic. The higher-level command
registry, family routing, and user-facing help pages live elsewhere,
while this module focuses on the reusable mechanics required by all
CLI entry points.

Overview
--------
The dispatcher infrastructure revolves around a small command
description object, :class:`CommandSpec`, and a set of helper
functions that perform four main tasks:

1. Import a command module from a registry entry.
2. Load the entry callable exposed by that module.
3. Execute the callable or module with the correct delegated
   ``argv`` and program name.
4. Build compact command/alias listings for help output.

Design goals
------------
This module is designed to keep CLI execution:

- **registry-driven**
  so commands can be described declaratively rather than hard-coded,
- **lightweight**
  so frontends such as ``geoprior``, ``geoprior-run``,
  ``geoprior-build``, and ``geoprior-plot`` can share the same
  dispatch machinery,
- **backward-compatible**
  so legacy command names and aliases can still resolve cleanly,
- **implementation-agnostic**
  so a command may expose either a callable entrypoint or a
  module-style ``__main__`` execution path.

Execution model
---------------
A command is described by :class:`CommandSpec`, which records the
target package, module, callable name, public command name, family,
and accepted aliases.

Given such a specification, the helpers in this module support two
execution styles:

``call_entry``
    Load a Python callable and invoke it using the most compatible
    calling convention discovered from its signature.

``run_module``
    Execute a module as ``__main__`` after temporarily patching
    :data:`sys.argv`, which is useful for module-oriented CLI code.

The module also provides small helpers such as :func:`alias_map`,
:func:`public_items`, and :func:`print_help_table` to support
consistent help rendering across command families.

Notes
-----
This module is primarily internal and is not intended to contain
domain-specific workflow logic. It should remain focused on generic
dispatch behavior only.

See Also
--------
geoprior.cli.__main__
    Family-aware public CLI frontends built on top of this module.
geoprior.scripts.registry
    Registry definitions consumed by the dispatch layer.
"""

from __future__ import annotations

import importlib
import inspect
import runpy
import sys
from collections.abc import Callable, Iterable
from dataclasses import dataclass


@dataclass(frozen=True)
class CommandSpec:
    package: str
    mod: str
    fn: str
    desc: str
    mode: str = "argv"  # argv | sysargv | module
    family: str | None = None
    public_name: str | None = None
    aliases: tuple[str, ...] = ()
    legacy_names: tuple[str, ...] = ()


CommandSpec.__doc__ = r"""
Immutable command description used by the CLI dispatch layer.

A :class:`CommandSpec` stores the minimal metadata needed to resolve
and execute a command from the registry-driven GeoPrior CLI system.

Each instance identifies

- the Python package containing the command,
- the module to import,
- the callable name to execute,
- a short human-readable description,
- the execution mode,
- the command family and public-facing names.

Attributes
----------
package : str
    Package path used for relative imports. If empty, ``mod`` is
    imported as an absolute module path.
mod : str
    Module name containing the command implementation.
fn : str
    Name of the callable entrypoint expected inside ``mod`` when the
    execution mode is callable-based.
desc : str
    Short help text shown in command listings.
mode : str, default="argv"
    Execution strategy used by the dispatcher.

    Supported values are typically:

    - ``"argv"`` for callable entrypoints that accept delegated
      argument lists,
    - ``"sysargv"`` for callables relying on patched
      :data:`sys.argv`,
    - ``"module"`` for modules executed through :mod:`runpy`.
family : str or None, default=None
    Optional command family such as ``"run"``, ``"build"``, or
    ``"plot"``.
public_name : str or None, default=None
    Canonical public command name exposed to users.
aliases : tuple of str, default=()
    Alternative spellings that resolve to ``public_name``.
legacy_names : tuple of str, default=()
    Backward-compatible historical command names.
"""


def load_module(spec: CommandSpec):
    """Import a module for a command spec."""
    if spec.package:
        return importlib.import_module(
            f".{spec.mod}",
            package=spec.package,
        )
    return importlib.import_module(spec.mod)


def load_callable(spec: CommandSpec) -> Callable[..., None]:
    """Load the entry callable from a command module."""
    mod = load_module(spec)
    fn = getattr(mod, spec.fn, None)
    if fn is None:
        raise AttributeError(
            f"Missing {spec.fn!r} in {spec.package}.{spec.mod}"
        )
    return fn


def run_module(
    spec: CommandSpec,
    *,
    display_cmd: str,
    argv: list[str] | None,
) -> None:
    """Execute a module as __main__ with delegated argv."""
    old = list(sys.argv)
    sys.argv = [display_cmd]
    if argv:
        sys.argv += list(argv)

    mod_name = (
        f"{spec.package}.{spec.mod}"
        if spec.package
        else spec.mod
    )
    try:
        runpy.run_module(mod_name, run_name="__main__")
    finally:
        sys.argv = old


def call_entry(
    fn: Callable[..., None],
    *,
    argv: list[str] | None,
    display_cmd: str,
) -> None:
    """
    Call a command entrypoint.

    Preference order:
    1) fn(argv, prog=...)
    2) fn(argv)
    3) fn() with patched sys.argv
    """
    try:
        sig = inspect.signature(fn)
    except (TypeError, ValueError):
        sig = None

    if sig is not None:
        params = sig.parameters
        if "prog" in params:
            fn(argv, prog=display_cmd)
            return

        positional = [
            p
            for p in params.values()
            if p.kind
            in {
                inspect.Parameter.POSITIONAL_ONLY,
                inspect.Parameter.POSITIONAL_OR_KEYWORD,
            }
        ]
        if positional:
            fn(argv)
            return

    old = list(sys.argv)
    sys.argv = [display_cmd]
    if argv:
        sys.argv += list(argv)
    try:
        fn()
    finally:
        sys.argv = old


def public_items(
    registry: dict[str, CommandSpec],
    *,
    family: str | None = None,
) -> list[tuple[str, CommandSpec]]:
    """Return public registry items filtered by family."""
    items = []
    for name, spec in registry.items():
        if spec.public_name is None:
            continue
        if family is not None and spec.family != family:
            continue
        items.append((spec.public_name, spec))
    items.sort(key=lambda x: x[0])
    return items


def alias_map(
    registry: dict[str, CommandSpec],
    *,
    family: str | None = None,
) -> dict[str, str]:
    """Build alias -> public-name mapping."""
    amap: dict[str, str] = {}
    for spec in registry.values():
        public_name = spec.public_name
        if public_name is None:
            continue
        if family is not None and spec.family != family:
            continue

        for alias in spec.aliases:
            amap[alias] = public_name
        for legacy in spec.legacy_names:
            amap[legacy] = public_name
    return amap


def print_help_table(
    title: str,
    items: Iterable[tuple[str, CommandSpec]],
    *,
    width: int = 30,
) -> None:
    """Print a compact help table."""
    shown = list(items)
    if not shown:
        return

    print(f"{title}:")
    for name, spec in shown:
        left = ("  " + name).ljust(width)
        print(f"{left}{spec.desc}")
    print("")

Script registry and shared config#

geoprior/scripts/registry.py#
# SPDX-License-Identifier: Apache-2.0
# GeoPrior-v3 — https://github.com/earthai-tech/geoprior-v3
# Copyright (c) 2026-present
# Author: LKouadio <https://lkouadio.com>
r"""Registry helpers used by GeoPrior scripts."""

from __future__ import annotations

from dataclasses import dataclass


@dataclass(frozen=True)
class ScriptSpec:
    mod: str
    fn: str
    desc: str
    mode: str = "argv"
    family: str = "build"
    public_name: str | None = None
    aliases: tuple[str, ...] = ()
    legacy_names: tuple[str, ...] = ()


def _drop_known_prefix(name: str) -> tuple[str, str]:
    if name.startswith("plot-"):
        return "plot", name[len("plot-") :]
    if name.startswith("build-"):
        return "build", name[len("build-") :]
    if name.startswith("make-"):
        return "build", name[len("make-") :]
    return "build", name


def _spec(
    legacy_name: str,
    mod: str,
    fn: str,
    desc: str,
    *,
    mode: str = "argv",
    family: str | None = None,
    public_name: str | None = None,
    aliases: tuple[str, ...] = (),
) -> ScriptSpec:
    auto_family, auto_public = _drop_known_prefix(legacy_name)
    return ScriptSpec(
        mod=mod,
        fn=fn,
        desc=desc,
        mode=mode,
        family=family or auto_family,
        public_name=public_name or auto_public,
        aliases=aliases,
        legacy_names=(legacy_name,),
    )


SCRIPT_COMMANDS: dict[str, ScriptSpec] = {
    "plot-driver-response": _spec(
        "plot-driver-response",
        "plot_driver_response",
        "plot_driver_response_main",
        "Driver-response figure.",
    ),
    "plot-core-ablation": _spec(
        "plot-core-ablation",
        "plot_core_ablation",
        "plot_fig3_core_ablation_main",
        "Core ablation figure.",
    ),
    "plot-litho-parity": _spec(
        "plot-litho-parity",
        "plot_litho_parity",
        "figS1_lithology_parity_main",
        "Lithology parity figure.",
    ),
    "plot-uncertainty": _spec(
        "plot-uncertainty",
        "plot_uncertainty",
        "plot_fig5_uncertainty_main",
        "Forecast uncertainty figure.",
    ),
    "plot-spatial-forecasts": _spec(
        "plot-spatial-forecasts",
        "plot_spatial_forecasts",
        "plot_fig6_spatial_forecasts_main",
        "Spatial forecast maps.",
    ),
    "plot-physics-sanity": _spec(
        "plot-physics-sanity",
        "plot_physics_sanity",
        "plot_physics_sanity_main",
        "Physics sanity plots.",
    ),
    "plot-physics-maps": _spec(
        "plot-physics-maps",
        "plot_physics_maps",
        "plot_physics_maps_main",
        "Physics maps plots.",
    ),
    "plot-physics-fields": _spec(
        "plot-physics-fields",
        "plot_physics_fields",
        "plot_physics_fields_main",
        "Physics fields plots.",
    ),
    "plot-physics-profiles": _spec(
        "plot-physics-profiles",
        "plot_physics_profiles",
        "figA1_phys_profiles_main",
        "Physics profiles (Appendix).",
    ),
    "plot-uncertainty-extras": _spec(
        "plot-uncertainty-extras",
        "plot_uncertainty_extras",
        "supp_figS5_uncertainty_extras_main",
        "Extra uncertainty panels.",
        aliases=("plot_uncertainty_extras",),
    ),
    "plot-ablations-sensitivity": _spec(
        "plot-ablations-sensitivity",
        "plot_ablations_sensitivity",
        "plot_ablations_sensivity_main",
        "Ablations sensitivity.",
    ),
    "plot-physics-sensitivity": _spec(
        "plot-physics-sensitivity",
        "plot_physics_sensitivity",
        "plot_physics_sensitivity_main",
        "Physics sensitivity.",
    ),
    "plot-sm3-identifiability": _spec(
        "plot-sm3-identifiability",
        "plot_sm3_identifiability",
        "plot_sm3_identifiability_main",
        "SM3 identifiability figure.",
    ),
    "plot-sm3-bounds-ridge-summary": _spec(
        "plot-sm3-bounds-ridge-summary",
        "plot_sm3_bounds_ridge_summary",
        "plot_sm3_bounds_ridge_summary_main",
        "SM3 bounds vs ridge summary.",
        aliases=("plot-sm3-bounds-ridge",),
    ),
    "plot-sm3-log-offsets": _spec(
        "plot-sm3-log-offsets",
        "plot_sm3_log_offsets",
        "plot_sm3_log_offsets_main",
        "SM3 log-offset diagnostics.",
    ),
    "plot-xfer-transferability": _spec(
        "plot-xfer-transferability",
        "plot_xfer_transferability",
        "figSx_xfer_transferability_main",
        "Cross-city transferability.",
    ),
    "plot-xfer-impact": _spec(
        "plot-xfer-impact",
        "plot_xfer_impact",
        "figSx_xfer_impact_main",
        "Transfer impact (retention + risk).",
    ),
    "plot-transfer": _spec(
        "plot-transfer",
        "plot_xfer_transferability",
        "figSx_xfer_transferability_main",
        "Alias of transferability plot.",
        public_name="transfer",
    ),
    "plot-transfer-impact": _spec(
        "plot-transfer-impact",
        "plot_xfer_impact",
        "figSx_xfer_impact_main",
        "Alias of transfer impact plot.",
        public_name="transfer-impact",
    ),
    "plot-geo-cumulative": _spec(
        "plot-geo-cumulative",
        "plot_geo_cumulative",
        "plot_geo_cumulative_main",
        "Cumulative geo curves.",
    ),
    "plot-hotspot-analytics": _spec(
        "plot-hotspot-analytics",
        "plot_hotspot_analytics",
        "plot_hotspot_analytics_main",
        "Hotspot analytics (maps + timeline).",
    ),
    "plot-external-validation": _spec(
        "plot-external-validation",
        "plot_external_validation",
        "plot_external_validation_main",
        "External point-support validation figure.",
    ),
    "compute-brier-exceedance": _spec(
        "compute-brier-exceedance",
        "compute_brier_exceedance",
        "brier_exceedance_main",
        "Compute exceedance Brier table.",
        family="build",
        public_name="brier-exceedance",
    ),
    "summarize-hotspots": _spec(
        "summarize-hotspots",
        "summarize_hotspots",
        "summarize_hotspots_main",
        "Summarize hotspot outputs.",
        family="build",
        public_name="hotspots-summary",
    ),
    "compute-hotspots": _spec(
        "compute-hotspots",
        "compute_hotspots",
        "compute_hotspots_main",
        "Compute hotspot outputs.",
        family="build",
        public_name="hotspots",
    ),
    "extend-forecast": _spec(
        "extend-forecast",
        "extend_forecast",
        "extend_forecast_main",
        "Extend future forecast CSV by extrapolation.",
        family="build",
    ),
    "update-ablation-records": _spec(
        "update-ablation-records",
        "update_ablation_records",
        "update_ablation_records_main",
        "Patch ablation record JSONL with metrics.",
        family="build",
    ),
    "build-model-metrics": _spec(
        "build-model-metrics",
        "build_model_metrics",
        "build_model_metrics_main",
        "Build unified metrics tables (CSV/JSON).",
    ),
    "build-ablation-table": _spec(
        "build-ablation-table",
        "build_ablation_table",
        "build_ablation_table_main",
        "Build ablation table from record JSONL.",
    ),
    "make-boundary": _spec(
        "make-boundary",
        "make_boundary",
        "make_boundary_main",
        "Create boundary polygon from points.",
    ),
    "make-exposure": _spec(
        "make-exposure",
        "make_exposure",
        "make_exposure_main",
        "Create exposure.csv (proxy) from points.",
    ),
    "make-district-grid": _spec(
        "make-district-grid",
        "make_district_grid",
        "make_district_grid_main",
        "Create grid-based district layer.",
    ),
    "tag-clusters-with-zones": _spec(
        "tag-clusters-with-zones",
        "tag_clusters_with_zones",
        "tag_clusters_with_zones_main",
        "Assign hotspot clusters to Zone IDs.",
        family="build",
        public_name="clusters-with-zones",
    ),
}


SCRIPT_GROUPS = (
    (
        "Figures",
        (
            "driver-response",
            "core-ablation",
            "litho-parity",
            "uncertainty",
            "spatial-forecasts",
            "transfer-impact",
            "hotspot-analytics",
        ),
    ),
    (
        "Supplementary",
        (
            "physics-sanity",
            "physics-fields",
            "physics-maps",
            "physics-profiles",
            "uncertainty-extras",
            "ablations-sensitivity",
            "physics-sensitivity",
            "sm3-identifiability",
            "sm3-bounds-ridge-summary",
            "sm3-log-offsets",
            "xfer-transferability",
            "xfer-impact",
            "transfer",
            "geo-cumulative",
        ),
    ),
    (
        "Tables & summaries",
        (
            "brier-exceedance",
            "hotspots",
            "hotspots-summary",
            "update-ablation-records",
            "ablation-table",
            "model-metrics",
            "extend-forecast",
            "boundary",
            "exposure",
            "district-grid",
            "clusters-with-zones",
        ),
    ),
)
geoprior/scripts/config.py#
# SPDX-License-Identifier: Apache-2.0
# GeoPrior-v3 — https://github.com/earthai-tech/
# geoprior-v3
# Copyright (c) 2026-present
# Author: LKouadio <https://lkouadio.com>
r"""Shared configuration helpers for GeoPrior scripts."""

from __future__ import annotations

import os
from pathlib import Path

# ---------------------------------------------------------
# Artifact export environment
# ---------------------------------------------------------
FIG_DIR_ENV = "GEOPRIOR_FIG_DIR"
OUT_DIR_ENV = "GEOPRIOR_OUT_DIR"
ROOT_ENV = "GEOPRIOR_ARTIFACT_ROOT"

# ---------------------------------------------------------
# Filesystem helpers
# ---------------------------------------------------------


def _as_path(value: str | os.PathLike[str]) -> Path:
    """Return an expanded absolute path."""
    return Path(value).expanduser().resolve()


def _find_project_root(start: Path | None = None) -> Path:
    """Return the nearest project root."""

    here = (start or Path.cwd()).resolve()

    for root in (here, *here.parents):
        if (root / "pyproject.toml").exists():
            return root
        if (root / ".git").exists():
            return root

    return here


def scripts_root(*, create: bool = True) -> Path:
    """Return the user-facing artifact root."""

    root_env = os.environ.get(ROOT_ENV)
    if root_env:
        root = _as_path(root_env)
    else:
        root = _find_project_root() / "scripts"

    if create:
        root.mkdir(parents=True, exist_ok=True)
    return root


def fig_dir(*, create: bool = True) -> Path:
    """Return the figure export directory."""

    value = os.environ.get(FIG_DIR_ENV)
    path = (
        _as_path(value)
        if value
        else scripts_root(create=create) / "figs"
    )

    if create:
        path.mkdir(parents=True, exist_ok=True)
    return path


def out_dir(*, create: bool = True) -> Path:
    """Return the tabular export directory."""

    value = os.environ.get(OUT_DIR_ENV)
    path = (
        _as_path(value)
        if value
        else scripts_root(create=create) / "out"
    )

    if create:
        path.mkdir(parents=True, exist_ok=True)
    return path


def _has_explicit_parent(path: Path) -> bool:
    """Return True when path includes a parent part."""

    return path.parent != Path(".")


def resolve_user_artifact_path(
    value: str | os.PathLike[str],
    *,
    kind: str,
    create_parent: bool = False,
) -> Path:
    """
    Resolve a user-provided export path.

    Rules
    -----
    - absolute path: use as given
    - relative path with parent: respect that folder
    - bare filename/stem: place under scripts/figs or
      scripts/out
    """

    path = Path(value).expanduser()
    kind_norm = kind.strip().lower()

    if path.is_absolute():
        out = path
    elif _has_explicit_parent(path):
        out = path.resolve()
    elif kind_norm in {"fig", "figs", "figure", "figures"}:
        out = fig_dir(create=False) / path
    elif kind_norm == "out":
        out = out_dir(create=False) / path
    else:
        raise ValueError(
            "kind must be 'fig', 'figs', or 'out'"
        )

    if create_parent:
        out.parent.mkdir(parents=True, exist_ok=True)

    return out


def resolve_export_path(
    kind: str,
    *parts: str | os.PathLike[str],
    create_parent: bool = True,
) -> Path:
    """Resolve a path under fig/ or out/."""

    path = resolve_user_artifact_path(
        Path(*map(str, parts)),
        kind=kind,
        create_parent=create_parent,
    )
    return path


def export_env_summary() -> dict[str, str]:
    """Return resolved export environment values."""

    return {
        ROOT_ENV: str(scripts_root(create=False)),
        FIG_DIR_ENV: str(fig_dir(create=False)),
        OUT_DIR_ENV: str(out_dir(create=False)),
    }


# ---------------------------------------------------------
# Filesystem layout (v3.2)
# ---------------------------------------------------------
MODULE_DIR = Path(__file__).resolve().parent
SCRIPTS_DIR = MODULE_DIR
ARTIFACT_ROOT = scripts_root()
FIG_DIR = fig_dir()
FIGS_DIR = FIG_DIR
OUT_DIR = out_dir()

# ---------------------------------------------------------
# Common discovery patterns
# (src may be a directory or a file)
# ---------------------------------------------------------
# NOTE:
# - We search recursively under src when src is a
#   directory.
# - For GeoPrior eval JSON we prefer the
#   interpretable one when available.
# - It may carry human units such as "mm".
PATTERNS = {
    # GeoPrior evaluation JSON
    "phys_json": (
        "geoprior_eval_phys_*_interpretable.json",
        "geoprior_eval_phys_*.json",
    ),
    # Eval diagnostics fallback JSON
    "eval_diag_json": (
        "*GeoPriorSubsNet_eval_diagnostics*_calibrated.json",
        "*GeoPriorSubsNet_eval_diagnostics*.json",
        "eval_diagnostics*.json",
        "diagnostics*.json",
        "*eval*diagnostic*.json",
    ),
    # --- TestSet: EVAL
    # (has subsidence_actual) ---
    "forecast_test_csv": (
        "*forecast*TestSet*eval_calibrated*.csv",
        "*forecast_TestSet*_eval_calibrated.csv",
        "*_forecast_TestSet_*_eval_calibrated.csv",
        "*TestSet*eval_calibrated*.csv",
    ),
    # --- TestSet: FUTURE
    # (no subsidence_actual) ---
    "forecast_test_future_csv": (
        "*forecast*TestSet*future*.csv",
        "*forecast_TestSet*_future.csv",
        "*_forecast_TestSet_*_future.csv",
        "*TestSet*future*.csv",
    ),
    # Typical calibrated validation forecasts
    "forecast_val_csv": (
        "*forecast*Validation*calibrated*.csv",
        "*forecast*Validation*Fallback*calibrated*.csv",
        "*Validation*calibrated*.csv",
        "*_calibrated.csv",
    ),
    # Typical future forecasts
    "forecast_future_csv": (
        "*future*.csv",
        "*H*_future*.csv",
        "*forecast*future*.csv",
    ),
    # Physics payloads
    "physics_payload": (
        "*physics_payload*.npz",
        "*phys_payload*.npz",
        "*payload*.npz",
    ),
    # Optional coords file
    # (used by some physics maps)
    "coords_npz": (
        "*coords*.npz",
        "*xy*.npz",
        "*lonlat*.npz",
        "*val_inputs*.npz",
        "*test_inputs*.npz",
        "*train_inputs*.npz",
        "*oos_time_inputs*.npz",
    ),
    # Ablation records (Supplement S6/S7)
    "ablation_record_jsonl": (
        "ablation_records/ablation_record.updated*.jsonl",
        "ablation_records/ablation_record*.jsonl",
        "ablation_record*.jsonl",
        "ablation_record.updated*.jsonl",
    ),
    "boundary_shp": (
        "*boundary*.shp",
        "*coast*.shp",
        "*admin*.shp",
        "*outline*.shp",
        "*border*.shp",
    ),
}

# ---------------------------------------------------------
# Plot metric metadata
# (titles / labels / format)
# Used by multi-panel figures to avoid duplicated
# strings.
# ---------------------------------------------------------
PLOT_METRIC_META = {
    "r2": {
        "title": r"$R^2$ (↑)",
        "ylabel": r"$R^2$",
        "fmt": "{:.2f}",
    },
    "mae": {
        "title": "MAE (↓, {unit})",
        "ylabel": "MAE ({unit})",
        "fmt": "{:.2f}",
        "unit": "mm",
    },
    "rmse": {
        "title": "RMSE (↓, {unit})",
        "ylabel": "RMSE ({unit})",
        "fmt": "{:.2f}",
        "unit": "mm",
    },
    "mse": {
        "title": "MSE (↓, {unit})",
        "ylabel": "MSE ({unit})",
        "fmt": "{:.2f}",
        "unit": "mm²",
    },
    "coverage80": {
        "title": "Coverage (80% PI; target 0.80)",
        "ylabel": "Coverage",
        "fmt": "{:.3f}",
    },
    "sharpness80": {
        "title": "Sharpness (80% PI; ↓, {unit})",
        "ylabel": "Sharpness ({unit})",
        "fmt": "{:.3f}",
        "unit": "mm",
    },
}

# ---------------------------------------------------------
# Matplotlib defaults (paper-friendly)
# ---------------------------------------------------------
PAPER_DPI = 600
PAPER_FONT = 8

# ---------------------------------------------------------
# City styling
# (keep consistent across all scripts)
# (Used repeatedly across figures)
# ---------------------------------------------------------
CITY_CANON = {
    "nansha": "Nansha",
    "zhongshan": "Zhongshan",
    "ns": "Nansha",
    "zh": "Zhongshan",
}

CITY_COLORS = {
    "Nansha": "#1F78B4",
    "Zhongshan": "#E31A1C",
}

# ---------------------------------------------------------
# Units: payload + drivers + targets
# ---------------------------------------------------------
# Keep *units* separate from *labels* so plots can
# do:
#   label = LABELS[key]
#   unit = UNITS.get(key)
#   ax.set_xlabel(
#       f"{label} ({unit})" if unit else label
#   )
UNITS = {
    # Drivers / coordinates
    "GWL": "m",
    "rainfall_mm": "mm/yr",
    "soil_thickness": "m",
    "normalized_urban_load_proxy": "1",
    "longitude": "deg",
    "latitude": "deg",
    "year": "year",
    # Targets
    "subsidence": "mm/yr",
    "subsidence_cum": "mm",
    # Forecast CSV canonical columns
    "forecast_step": "step",
    "sample_idx": "id",
}

LABELS = {
    "GWL": "Groundwater level",
    "rainfall_mm": "Rainfall",
    "soil_thickness": "Soil thickness",
    "normalized_urban_load_proxy": "Urban load",
    "longitude": "Longitude",
    "latitude": "Latitude",
    "year": "Year",
    "subsidence": "Subsidence",
    "subsidence_cum": "Cumulative subsidence",
    "lithology_class": "Lithology class",
    "city": "City",
    "forecast_step": "Forecast step",
    "sample_idx": "Sample id",
}

UNITS.update(
    {
        "GWL_depth_bgs_m": "m",
        "GWL_depth_bgs": "m",
        "GWL_depth_bgs_raw": "m",
        "soil_thickness_eff": "m",
        "soil_thickness_imputed": "m",
        "urban_load_global": "1",
        "head_m": "m",
        "z_surf_m": "m",
        "z_surf": "m",
    }
)

LABELS.update(
    {
        "GWL_depth_bgs_m": "Depth to water table",
        "GWL_depth_bgs": "Depth to water table",
        "GWL_depth_bgs_raw": "Depth to water table",
        "soil_thickness_eff": "Effective soil thickness",
        "soil_thickness_imputed": "Imputed soil thickness",
        "urban_load_global": "Urban load",
        "head_m": "Hydraulic head",
        "z_surf_m": "Surface elevation",
        "z_surf": "Surface elevation",
    }
)

COLUMN_ALIASES = {
    # Drivers
    "GWL_depth_bgs_m": (
        "GWL_depth_bgs_m",
        "GWL_depth_bgs",
        "GWL",
        "GWL_depth_bgs_raw",
    ),
    "rainfall_mm": (
        "rainfall_mm",
        "rainfall",
        "rainfall_m",
    ),
    "soil_thickness_eff": (
        "soil_thickness_eff",
        "soil_thickness_imputed",
        "soil_thickness",
    ),
    "urban_load_global": (
        "urban_load_global",
        "normalized_urban_load_proxy",
    ),
    # Responses
    "subsidence_cum": ("subsidence_cum",),
    "subsidence": ("subsidence",),
    "head_m": ("head_m",),
}

DRIVER_RESPONSE_DEFAULT_DRIVERS = (
    "GWL_depth_bgs_m",
    "rainfall_mm",
    "soil_thickness_eff",
    "urban_load_global",
)

DRIVER_RESPONSE_DEFAULT_RESPONSE = "subsidence_cum"

# ---------------------------------------------------------
# Units: GeoPrior physics fields / diagnostics
# ---------------------------------------------------------
# Used for physics maps and sanity plots.
PHYS_UNITS = {
    "K": "m/s",
    "Ss": "1/m",
    "Hd": "m",
    "H": "m",
    "tau": "s",
    "tau_prior": "s",
    "log10_K": "log10(m/s)",
    "log10_Ss": "log10(1/m)",
    "log10_tau": "log10(s)",
    # Diagnostics
    "epsilon_prior": "1",
    "epsilon_cons": "1",
    "epsilon_gw": "1",
    "epsilon_cons_raw": "varies",
    "epsilon_gw_raw": "varies",
}

PHYS_LABELS = {
    "K": "Hydraulic conductivity",
    "Ss": "Specific storage",
    "Hd": "Drainage thickness",
    "H": "Effective thickness",
    "tau": "Consolidation time scale",
    "tau_prior": "Prior time scale",
    "log10_K": "log10 K",
    "log10_Ss": "log10 Ss",
    "log10_tau": "log10 τ",
    "epsilon_prior": r"$\epsilon_{\mathrm{prior}}$",
    "epsilon_cons": r"$\epsilon_{\mathrm{cons}}$",
    "epsilon_gw": r"$\epsilon_{\mathrm{gw}}$",
    "epsilon_cons_raw": (r"$\epsilon_{\mathrm{cons,raw}}$"),
    "epsilon_gw_raw": r"$\epsilon_{\mathrm{gw,raw}}$",
}

# ---------------------------------------------------------
# Time + scaling
# (used to harmonize JSON variants)
# ---------------------------------------------------------
SECONDS_PER_YEAR = 31556952.0

# ---------------------------------------------------------
# Closure strings for captions / legends
# (math "single source")
# ---------------------------------------------------------
CLOSURES = {
    "tau_prior": (
        r"$\tau_{\mathrm{prior}}"
        r"\approx H_d^2\,S_s"
        r"/(\pi^2\,\kappa_b\,K)$"
    ),
    "s_eq": r"$s_{\mathrm{eq}}\approx S_s\,\Delta h\,H$",
    "R_cons": (
        r"$R_{\mathrm{cons}}="
        r"\partial_t \bar{s}"
        r"-(s_{\mathrm{eq}}(\bar{h})-\bar{s})/\tau$"
    ),
    "R_gw": (
        r"$R_{\mathrm{gw}}="
        r"S_s\,\partial_t \bar{h}"
        r"-\nabla\cdot(K\nabla\bar{h})$"
    ),
}

# ---------------------------------------------------------
# Column canonicalization
# ---------------------------------------------------------
_BASE_ALIASES = {
    "sample_idx": ("sample_idx", "sample_id", "sample"),
    "forecast_step": ("forecast_step", "forecast_s", "h"),
    "coord_t": ("coord_t", "year", "t"),
    "coord_x": ("coord_x", "lon", "longitude", "x"),
    "coord_y": ("coord_y", "lat", "latitude", "y"),
    "subsidence_actual": ("subsidence_actual", "actual"),
    "subsidence_q50": ("subsidence_q50", "q50", "p50"),
    "subsidence_unit": ("subsidence_unit", "unit"),
}

_CALIB_REQUIRED = [
    "sample_idx",
    "forecast_step",
    "coord_t",
    "coord_x",
    "coord_y",
    "subsidence_actual",
    "subsidence_q50",
]

_FUT_REQUIRED = [
    "sample_idx",
    "forecast_step",
    "coord_t",
    "coord_x",
    "coord_y",
    "subsidence_q50",
]

_CAL_ORDER = ("none", "source", "target")

_CAL_LABEL = {
    "none": "None",
    "source": "Source",
    "target": "Target",
}

_CAL_MARKER = {
    "none": "o",
    "source": "^",
    "target": "s",
}

_STRAT_DEFAULT = ("baseline", "xfer", "warm")

_STRAT_LABEL = {
    "baseline": "Baseline (target)",
    "xfer": "Transfer",
    "warm": "Warm-start",
}

_STRAT_HATCH = {
    "baseline": "//",
    "xfer": "",
    "warm": "xx",
}

_STRAT_EDGE = {
    "baseline": "#111111",
    "xfer": "#111111",
    "warm": "#111111",
}

_STRAT_LINESTYLE = {
    "baseline": ":",
    "xfer": "--",
    "warm": "-",
}

_STRAT_MARKER = {
    "baseline": "o",
    "xfer": "^",
    "warm": "s",
}

_BASELINE_MAP = {
    "A_to_B": "B_to_B",
    "B_to_A": "A_to_A",
}

_METRIC_DEF = {
    "mae": ("overall_mae", "MAE (mm)", "min"),
    "mse": ("overall_mse", "MSE (mm^2)", "min"),
    "rmse": ("overall_rmse", "RMSE (mm)", "min"),
    "r2": ("overall_r2", r"$R^2$", "max"),
}

Compatibility package#

scripts/__init__.py#
"""Compatibility launcher for legacy paper scripts.

Run::

  python -m scripts <command> [args]
"""

from .__main__ import main

__all__ = ["main"]
scripts/__main__.py#
# SPDX-License-Identifier: Apache-2.0
# GeoPrior-v3 — https://github.com/earthai-tech/geoprior-v3
# Copyright (c) 2026-present
# Author: LKouadio <https://lkouadio.com>

from __future__ import annotations

import sys

from geoprior.cli._dispatch import (
    CommandSpec,
    alias_map,
    call_entry,
    load_callable,
    print_help_table,
    run_module,
)

from .registry import SCRIPT_COMMANDS, SCRIPT_GROUPS


def _legacy_registry() -> dict[str, CommandSpec]:
    items: dict[str, CommandSpec] = {}
    for legacy_name, spec in SCRIPT_COMMANDS.items():
        items[legacy_name] = CommandSpec(
            package="geoprior.scripts",
            mod=spec.mod,
            fn=spec.fn,
            desc=spec.desc,
            mode=spec.mode,
            family=spec.family,
            public_name=legacy_name,
            aliases=spec.aliases,
            legacy_names=(),
        )
    return items


_CMD = _legacy_registry()


def _print_help() -> None:
    print("Usage:")
    print("  python -m scripts <command> [args]")
    print("")

    for title, public_names in SCRIPT_GROUPS:
        items = [
            (
                next(
                    legacy
                    for legacy, spec in SCRIPT_COMMANDS.items()
                    if spec.public_name == name
                ),
                _CMD[
                    next(
                        legacy
                        for legacy, spec in SCRIPT_COMMANDS.items()
                        if spec.public_name == name
                    )
                ],
            )
            for name in public_names
            if any(
                spec.public_name == name
                for spec in SCRIPT_COMMANDS.values()
            )
        ]
        print_help_table(title, items)

    amap = alias_map(_CMD)
    if amap:
        print("Aliases:")
        for src in sorted(amap):
            print(f"  {src} -> {amap[src]}")
        print("")

    print("Tip:")
    print("  python -m scripts plot-physics-fields -h")
    print("")
    print("Modern entry points:")
    print("  geoprior plot physics-fields -h")
    print("  geoprior-build exposure -h")


def main(argv: list[str] | None = None) -> None:
    args = list(argv) if argv is not None else sys.argv[1:]

    if not args or args[0] in {"-h", "--help", "help"}:
        _print_help()
        return

    amap = alias_map(_CMD)
    cmd = amap.get(args[0], args[0])
    rest = args[1:]
    spec = _CMD.get(cmd)

    if spec is None:
        print(f"[ERR] Unknown command: {cmd}")
        print("")
        _print_help()
        raise SystemExit(2)

    display_cmd = f"python -m scripts {cmd}"

    if spec.mode == "module":
        run_module(
            spec,
            display_cmd=display_cmd,
            argv=rest,
        )
        return

    fn = load_callable(spec)
    call_entry(
        fn,
        argv=rest,
        display_cmd=display_cmd,
    )


if __name__ == "__main__":
    main()
scripts/registry.py#
# SPDX-License-Identifier: Apache-2.0
# GeoPrior-v3 — https://github.com/earthai-tech/geoprior-v3
# Copyright (c) 2026-present
# Author: LKouadio <https://lkouadio.com>

"""Compatibility registry for legacy ``python -m scripts`` runs.

The authoritative reproducibility registry now lives under
``geoprior.scripts``. This module re-exports the public objects so
legacy dispatch continues to work without duplicating the registry.
"""

from __future__ import annotations

from geoprior.scripts.registry import (
    SCRIPT_COMMANDS,
    SCRIPT_GROUPS,
    ScriptSpec,
)

__all__ = [
    "ScriptSpec",
    "SCRIPT_COMMANDS",
    "SCRIPT_GROUPS",
]

Design notes#

A few design choices are intentional.

Single source of truth#

The real registry now lives under geoprior.scripts. This avoids circular dependency pressure and makes the main package self-contained.

Thin compatibility layer#

The top-level scripts package should stay small. Its role is to preserve historical commands, not to own the actual implementation.

Separated artifact policy#

Artifact-path logic belongs in configuration rather than in individual plotting or build scripts. That keeps export behavior consistent and easier to override.

Shared dispatcher helpers#

Both the modern CLI and the legacy launcher reuse the same helpers from geoprior.cli._dispatch. This keeps help formatting, callable loading, alias handling, and module execution consistent across entry points.

See also#