plans.tools.analysis_spatial_normals#
analysis_spatial_normals – standalone PLANS tool.
Self-contained script: copy this single file anywhere and run it with
Python 3. It has no dependency on the plans.tools.core module –
all parameters are passed through a single JSON config file.
From a monthly raster time series (e.g., NDVI), this tool builds:
Annual aggregations (one or more statistics per year).
Climatological normals – annual and monthly, over a configurable horizon.
Anomalies – monthly and annual, absolute and relative/percent.
Assumes all input rasters share the same grid (shape, transform, CRS),
and that monthly raster filenames end with a fixed "_<YYYY>_<MM>"
suffix before the extension, month zero-padded, e.g. NDVI_2020_01.tif.
The only third-party dependencies are numpy and rasterio.
Usage#
python analysis_spatial_normals.py --config config.json
Config file#
The --config/-c flag points to a JSON file. Template, ready to
copy and paste:
{
"input": "data/ndvi_monthly",
"output": "output/run1",
"label": "ndvi_normals_v1",
"pattern": "*.tif",
"verbose": true,
"annual": {
"stats": ["mean", "sum", "max", "p90"],
"min_months": 12
},
"normals": {
"stats": ["mean", "std"],
"annual_base_stat": "mean",
"annual_horizon": [2000, 2020],
"monthly_horizon": null
},
"anomalies": {
"monthly": true,
"annual": true,
"relative": true
},
"styles": {
"annual": "styles/annual.qml",
"annual_normal": "styles/annual_normal.qml",
"monthly_normal": "styles/monthly_normal.qml",
"anomaly_absolute": "styles/anomaly_absolute.qml",
"anomaly_relative": "styles/anomaly_relative.qml"
}
}
Required fields#
input(str) – folder containing the monthly raster files.output(str) – output folder for run artifacts.label(str) – run label, used as the logger name.
Optional fields#
pattern(str, default"*.tif") – glob pattern for input files.verbose(bool, defaultfalse) – echo logs to console.annual(dict, default{}) – seeaggregate_annual(); keysstats(list[str], default["mean"]) andmin_months(int, default12).normals(dict, default{}) – seecompute_annual_normals()/compute_monthly_normals(); keysstats(list[str], default["mean"]),annual_base_stat(str, default"mean"),annual_horizonandmonthly_horizon([start_year, end_year]ornull, defaultnull).anomalies(dict, default{}) – seecompute_monthly_anomalies()/compute_annual_anomalies(); keysmonthlyandannual(bool, defaulttrue),relative(bool, defaulttrue).styles(dict, default{}) – seeapply_style(). Each key is optional; when present, its value is a path to a QGIS.qmlstyle template that gets copied as a same-named sidecar next to every raster in that output category, so QGIS auto-applies it on load. Keys:annual(the per-year aggregates),annual_normal,monthly_normal,anomaly_absolute(used for both monthly and annual absolute anomalies), andanomaly_relative(ditto, relative anomalies).
The REQUIRED/OPTIONAL dictionaries just below are the actual
validation source of truth for the top-level fields – keep them in sync
with this docstring whenever you add, rename or remove a field. Nested
sub-dict defaults (annual, normals, anomalies) are filled in
by process_data().
Functions
|
Aggregate monthly rasters into one raster per year, per requested stat. |
|
Copy a QGIS |
|
Compute per-year anomalies against the mean annual normal. |
|
Compute annual normals from a per-year annual series. |
|
Compute per-month anomalies against the calendar-month normal. |
|
Compute monthly (calendar-month) climatological normals. |
|
Write a JSON manifest listing every raster produced by the run. |
|
Parse the single |
|
Create a simple console + file logger. |
|
Load, validate and fill defaults for the JSON config file. |
|
Scan and validate the monthly raster time series described by |
|
Entry point: load the config and run load -> process -> export. |
|
Scan a folder for monthly rasters following the fixed naming convention. |
|
Run annual aggregation, climatological normals, and anomalies. |
|
Check that a set of rasters share the same grid. |
Classes
|
Reference to a single monthly raster file. |
- plans.tools.analysis_spatial_normals.load_config(file_config)[source]#
Load, validate and fill defaults for the JSON config file.
Checks that every key in
REQUIREDis present and of the right type, then fills in defaults for any missing key inOPTIONALand type-checks those that were provided.- Parameters:
file_config (str or pathlib.Path) – path to the JSON config file.
- Returns:
validated configuration dictionary, defaults included.
- Return type:
dict
- Raises:
ValueError – if a required field is missing, or any field has the wrong type.
- plans.tools.analysis_spatial_normals.get_logger(name, log_file, talk=True)[source]#
Create a simple console + file logger.
- Parameters:
name (str) – logger name, shown in every log line.
log_file (str or pathlib.Path) – path to the log file to write to.
talk (bool) – whether to also emit log records to the console.
- Returns:
configured logger instance.
- Return type:
logging.Logger
- class plans.tools.analysis_spatial_normals.MonthlyRaster(year: int, month: int, path: str)[source]#
Bases:
objectReference to a single monthly raster file.
- Parameters:
year (int) – Calendar year, e.g.
2020.month (int) – Calendar month,
1-12.path (str) – Path to the raster file on disk.
- year: int#
- month: int#
- path: str#
- __dataclass_fields__ = {'month': Field(name='month',type=<class 'int'>,default=<dataclasses._MISSING_TYPE object>,default_factory=<dataclasses._MISSING_TYPE object>,init=True,repr=True,hash=None,compare=True,metadata=mappingproxy({}),kw_only=False,_field_type=_FIELD), 'path': Field(name='path',type=<class 'str'>,default=<dataclasses._MISSING_TYPE object>,default_factory=<dataclasses._MISSING_TYPE object>,init=True,repr=True,hash=None,compare=True,metadata=mappingproxy({}),kw_only=False,_field_type=_FIELD), 'year': Field(name='year',type=<class 'int'>,default=<dataclasses._MISSING_TYPE object>,default_factory=<dataclasses._MISSING_TYPE object>,init=True,repr=True,hash=None,compare=True,metadata=mappingproxy({}),kw_only=False,_field_type=_FIELD)}#
- __dataclass_params__ = _DataclassParams(init=True,repr=True,eq=True,order=False,unsafe_hash=False,frozen=False)#
- __eq__(other)#
Return self==value.
- __hash__ = None#
- __init__(year: int, month: int, path: str) None#
- __match_args__ = ('year', 'month', 'path')#
- __repr__()#
Return repr(self).
- plans.tools.analysis_spatial_normals._resolve_stat_func(stat: str) Callable[source]#
Resolve a stat name to a NaN-aware
(array, axis) -> arrayreducer.- Parameters:
stat (str) – Statistic name. One of the keys in
_BASE_STAT_FUNCS("mean","sum","max","min","median","std"), or"pN"for the Nth percentile, e.g."p10","p90".- Returns:
A reducer function usable as
func(stack, axis=0).- Return type:
callable
- Raises:
ValueError – If
statis not a recognized name, or a percentile is outside the 0-100 range.
- plans.tools.analysis_spatial_normals.parse_monthly_rasters(folder, logger, pattern: str = '*.tif') list[MonthlyRaster][source]#
Scan a folder for monthly rasters following the fixed naming convention.
Files must end with a
"_<YYYY>_<MM>"suffix before the extension, month zero-padded, e.g.NDVI_2020_01.tif.- Parameters:
folder (str or pathlib.Path) – Directory to scan.
logger (logging.Logger) – Logger instance for warnings about unmatched filenames.
pattern (str) – Glob pattern used to list candidate files.
- Returns:
Parsed monthly rasters, sorted chronologically.
- Return type:
list[MonthlyRaster]
- plans.tools.analysis_spatial_normals.validate_grid_consistency(paths: list[str], logger) None[source]#
Check that a set of rasters share the same grid.
- Parameters:
paths (list[str]) – Raster file paths to compare.
logger (logging.Logger) – Logger instance for a confirmation message.
- Returns:
None
- Return type:
None
- Raises:
ValueError – If any raster’s width, height, transform, or CRS differs from the first raster’s.
- plans.tools.analysis_spatial_normals._read_as_float(path) tuple[numpy.ndarray, dict][source]#
Read band 1 of a raster as float64, converting nodata to NaN.
- Parameters:
path (str or pathlib.Path) – Path to the raster file.
- Returns:
A 2-tuple of the pixel array and the rasterio profile.
- Return type:
tuple[numpy.ndarray, dict]
- plans.tools.analysis_spatial_normals._write_raster(arr: numpy.ndarray, profile: dict, out_path) None[source]#
Write a single-band float32 raster with NaN nodata.
- Parameters:
arr (numpy.ndarray) – Pixel array to write.
profile (dict) – Rasterio profile to reuse (CRS, transform, etc.); its
dtype,count, andnodataare overridden.out_path (str or pathlib.Path) – Output file path; parent directories are created if needed.
- Returns:
None
- Return type:
None
- plans.tools.analysis_spatial_normals._apply_stat(stack: numpy.ndarray, func: Callable) numpy.ndarray[source]#
Apply a stat reducer across axis 0 of a stack.
Silences the expected “all-NaN slice” warning for pixels that are nodata in every layer of the stack (those pixels correctly come out as NaN in the result).
- Parameters:
stack (numpy.ndarray) – Array stacked along axis 0 (e.g. one layer per month or year).
func (callable) – Reducer called as
func(stack, axis=0).
- Returns:
The reduced array.
- Return type:
numpy.ndarray
- plans.tools.analysis_spatial_normals._read_stack(records: list[MonthlyRaster]) tuple[numpy.ndarray, dict][source]#
Read a list of rasters into a single stacked array.
- Parameters:
records (list[MonthlyRaster]) – Rasters to read, in the order they should be stacked.
- Returns:
A 2-tuple of the stacked array (axis 0 = record index) and the rasterio profile of the first record.
- Return type:
tuple[numpy.ndarray, dict]
- plans.tools.analysis_spatial_normals.aggregate_annual(monthly_rasters: list[MonthlyRaster], output_dir, logger, stats: list[str] = ('mean',), min_months: int = 12, file_prefix: str = 'annual') dict[str, dict[int, str]][source]#
Aggregate monthly rasters into one raster per year, per requested stat.
- Parameters:
monthly_rasters (list[MonthlyRaster]) – Parsed monthly rasters, as returned by
parse_monthly_rasters().output_dir (str or pathlib.Path) – Directory to write
<file_prefix>_<stat>_<year>.tiffiles into.logger (logging.Logger) – Logger instance for progress messages.
stats (list[str]) – Statistics to compute per year. See
_resolve_stat_func()for accepted names.min_months (int) – Minimum number of valid months required for a year to be aggregated; years with fewer are skipped (with a warning).
file_prefix (str) – Output filename prefix.
- Returns:
Mapping of
{stat: {year: output_path}}.- Return type:
dict[str, dict[int, str]]
- plans.tools.analysis_spatial_normals.compute_annual_normals(annual_paths_by_stat: dict[str, dict[int, str]], output_dir, logger, base_stat: str = 'mean', stats: list[str] = ('mean',), horizon: tuple[int, int] | None = None, file_prefix: str = 'annual_normal') dict[str, str][source]#
Compute annual normals from a per-year annual series.
For the annual series identified by
base_stat(e.g. the per-year"mean"rasters fromaggregate_annual()), compute each stat instatsacross the years withinhorizon.- Parameters:
annual_paths_by_stat (dict[str, dict[int, str]]) – Per-year annual rasters, as returned by
aggregate_annual().output_dir (str or pathlib.Path) – Directory to write
<file_prefix>_<stat>.tiffiles into.logger (logging.Logger) – Logger instance for progress messages.
base_stat (str) – Which annual series in
annual_paths_by_statto use as input.stats (list[str]) – Statistics to compute across years. See
_resolve_stat_func()for accepted names.horizon (tuple[int, int] or None) – Inclusive
(start_year, end_year)window;Noneuses every available year.file_prefix (str) – Output filename prefix.
- Returns:
Mapping of
{stat: output_path}.- Return type:
dict[str, str]
- Raises:
ValueError – If
base_statwas not computed inannual_paths_by_stat, or no years fall withinhorizon.
- plans.tools.analysis_spatial_normals.compute_monthly_normals(monthly_rasters: list[MonthlyRaster], output_dir, logger, stats: list[str] = ('mean',), horizon: tuple[int, int] | None = None, file_prefix: str = 'normal_month') dict[str, dict[int, str]][source]#
Compute monthly (calendar-month) climatological normals.
For each calendar month (1-12), compute each stat in
statsacross the years withinhorizon.horizonis independent of the horizon used for the annual normals.- Parameters:
monthly_rasters (list[MonthlyRaster]) – Parsed monthly rasters, as returned by
parse_monthly_rasters().output_dir (str or pathlib.Path) – Directory to write
<file_prefix>_<MM>_<stat>.tiffiles into.logger (logging.Logger) – Logger instance for progress messages.
stats (list[str]) – Statistics to compute per calendar month. See
_resolve_stat_func()for accepted names.horizon (tuple[int, int] or None) – Inclusive
(start_year, end_year)window;Noneuses every available year.file_prefix (str) – Output filename prefix.
- Returns:
Mapping of
{stat: {month: output_path}}.- Return type:
dict[str, dict[int, str]]
- plans.tools.analysis_spatial_normals.compute_monthly_anomalies(monthly_rasters: list[MonthlyRaster], monthly_normal_paths_by_stat: dict[str, dict[int, str]], output_dir, logger, relative: bool = True, normal_stat: str = 'mean') dict[str, dict[tuple[int, int], str]][source]#
Compute per-month anomalies against the calendar-month normal.
For every monthly raster, computes:
anomaly = value - normal[month] relative_anomaly = (value - normal[month]) / normal[month] * 100 # percent
where
normalis thenormal_statmonthly normal ("mean"by default – anomalies are conventionally defined relative to the mean). Pixels where the normal is exactly 0 getNaNfor the relative anomaly (percent deviation is undefined there) but still get a valid absolute anomaly – relevant for variables like precipitation with dry-season zeros.- Parameters:
monthly_rasters (list[MonthlyRaster]) – Parsed monthly rasters, as returned by
parse_monthly_rasters().monthly_normal_paths_by_stat (dict[str, dict[int, str]]) – Monthly normals, as returned by
compute_monthly_normals().output_dir (str or pathlib.Path) – Base directory;
absolute/andrelative/subdirectories are created under it.logger (logging.Logger) – Logger instance for progress messages.
relative (bool) – Whether to also compute the percent anomaly.
normal_stat (str) – Which monthly normal stat to use as the baseline.
- Returns:
{"absolute": {(year, month): path}, "relative": {(year, month): path}}.- Return type:
dict[str, dict[tuple[int, int], str]]
- Raises:
ValueError – If
normal_statwas not computed inmonthly_normal_paths_by_stat.
- plans.tools.analysis_spatial_normals.compute_annual_anomalies(annual_paths_by_stat: dict[str, dict[int, str]], annual_normal_paths_by_stat: dict[str, str], output_dir, logger, base_stat: str = 'mean', relative: bool = True) dict[str, dict[int, str]][source]#
Compute per-year anomalies against the mean annual normal.
For every year in the annual series identified by
base_stat(e.g. the per-year"sum"rasters fromaggregate_annual(), for a total like annual precipitation), computes:anomaly = annual_value[year] - annual_normal_mean relative_anomaly = (annual_value[year] - annual_normal_mean) / annual_normal_mean * 100 # percent
The baseline is always the
"mean"entry ofannual_normal_paths_by_stat, matching the convention used for the monthly anomalies.- Parameters:
annual_paths_by_stat (dict[str, dict[int, str]]) – Per-year annual rasters, as returned by
aggregate_annual().annual_normal_paths_by_stat (dict[str, str]) – Annual normals, as returned by
compute_annual_normals(); must include a"mean"entry.output_dir (str or pathlib.Path) – Base directory;
absolute/andrelative/subdirectories are created under it.logger (logging.Logger) – Logger instance for progress messages.
base_stat (str) – Which annual series in
annual_paths_by_statto compute anomalies for.relative (bool) – Whether to also compute the percent anomaly.
- Returns:
{"absolute": {year: path}, "relative": {year: path}}.- Return type:
dict[str, dict[int, str]]
- Raises:
ValueError – If
base_statwas not computed inannual_paths_by_stat, or the"mean"annual normal is missing.
- plans.tools.analysis_spatial_normals._iter_paths(obj)[source]#
Recursively yield every path string found in a nested dict/list structure.
Walks arbitrarily nested dicts and lists/tuples (matching the shapes returned by
aggregate_annual(),compute_annual_normals(),compute_monthly_normals(),compute_monthly_anomalies(), andcompute_annual_anomalies()) and yields every string value found.- Parameters:
obj (dict or list or tuple or str) – nested paths structure.
- Yields:
each path string found.
- Return type:
collections.abc.Iterator[str]
- plans.tools.analysis_spatial_normals.apply_style(paths_obj, template_path, logger) list[str][source]#
Copy a QGIS
.qmlstyle template as a same-named sidecar for each raster.For every raster path found in
paths_obj(any nesting of dict/list, as returned by the pipeline step functions), copiestemplate_pathto<raster>.qml– i.e. the raster’s filename with its extension replaced by.qml– so QGIS auto-applies the style when the raster is added to the map.- Parameters:
paths_obj (dict) – nested output-paths structure to style.
template_path (str or pathlib.Path) – path to the
.qmlstyle template to copy.logger (logging.Logger) – logger instance for progress messages.
- Returns:
paths of the created
.qmlsidecar files.- Return type:
list[str]
- Raises:
FileNotFoundError – if
template_pathdoes not exist.
- plans.tools.analysis_spatial_normals._maybe_apply_style(paths_obj, template_path, logger) None[source]#
Call
apply_style()only iftemplate_pathis truthy.Small convenience wrapper so callers can pass
styles_cfg.get(key)(which isNonefor an unconfigured style) without anifat every call site.- Parameters:
paths_obj (dict) – nested output-paths structure to style.
template_path (str or pathlib.Path or None) – path to the
.qmlstyle template, orNoneto skip styling.logger (logging.Logger) – logger instance for progress messages.
- Returns:
None
- Return type:
None
- plans.tools.analysis_spatial_normals.load_data(cfg, logger)[source]#
Scan and validate the monthly raster time series described by
cfg.- Parameters:
cfg (dict) – validated configuration dictionary from
load_config().logger (logging.Logger) – logger instance for progress messages.
- Returns:
parsed monthly rasters, sorted chronologically.
- Return type:
list[MonthlyRaster]
- Raises:
ValueError – if no monthly rasters are found in
cfg["input"].
- plans.tools.analysis_spatial_normals.process_data(loaded, cfg, logger)[source]#
Run annual aggregation, climatological normals, and anomalies.
- Parameters:
loaded (list[MonthlyRaster]) – monthly rasters, as returned by
load_data().cfg (dict) – validated configuration dictionary from
load_config().logger (logging.Logger) – logger instance for progress messages.
- Returns:
dict with keys
"annual","annual_normals","monthly_normals","annual_anomalies", and"monthly_anomalies", holding the structures returned byaggregate_annual(),compute_annual_normals(),compute_monthly_normals(),compute_annual_anomalies(), andcompute_monthly_anomalies()respectively. The two anomaly entries areNoneif disabled in the config. Ifcfg["styles"]has entries,apply_style()is also called for each configured category as a side effect (writing.qmlsidecars next to the relevant rasters).- Return type:
dict
- plans.tools.analysis_spatial_normals._manifest_safe(obj)[source]#
Recursively convert a
processedresults structure into a JSON-safe form.(year, month)tuple keys become"YYYY-MM"strings; everything else is passed through unchanged.- Parameters:
obj (object) – value to convert (dict, list/tuple, or scalar).
- Returns:
JSON-serializable equivalent.
- Return type:
object
- plans.tools.analysis_spatial_normals.export_data(processed, cfg, logger)[source]#
Write a JSON manifest listing every raster produced by the run.
- Parameters:
processed (dict) – data returned by
process_data().cfg (dict) – validated configuration dictionary from
load_config().logger (logging.Logger) – logger instance for progress messages.
- Returns:
None- Return type:
None