Skip to content

Analysis

Regime assessment, catchment routing, hydrological-year detection, wet events, and dynamic hydrological state. See Which route did my catchment take? and Dynamic Hydrological State for narrative context.

Catchment Routing (start here)

hydroseason._catchment

Regime-routed catchment analysis: one entry point, no operator decisions.

analyze_catchment assesses the regime first, then dispatches to the analysis that regime actually supports, and records which route it took and why. Nothing prompts and nothing raises on a difficult record: a catchment with no detectable annual cycle returns event descriptors and an empty hydrological-year table rather than an exception or -- worse -- a full set of confidently-labelled boundaries fitted to noise.

CatchmentAnalysis dataclass

Everything the record supports, plus how that was decided.

summary_row
summary_row(*, name: str) -> dict

Flat one-row-per-catchment record for a cross-catchment table.

analyze_catchment

analyze_catchment(extent, *, value_col: str = 'extent_pct', date_col: str | None = None, min_months_per_year: int = 9, max_invalid_pct: float = 20.0, quality_policy: QualityPolicy = 'flag', measurement_tolerance_pct: float = 0.0, phase_scheme: PhaseScheme | UnsetPhaseScheme = PHASE_SCHEME_UNSET, phase_model: LegacyPhaseModel | None = None, n_bootstrap: int = 200, random_state: int = 0) -> CatchmentAnalysis

Assess regime, then run the analysis that regime supports.

Fully automatic: the recommended action for the detected regime is taken, not offered. Callers wanting a different route should call the underlying detectors directly, which makes the override explicit in their own code rather than hidden in a flag here.

Regime Assessment

Annual timing evidence

WaterRegimeAssessment and CatchmentAnalysis.summary_row() expose the following peak and trough timing fields. They are calculated from one peak and one trough month in each qualifying year (at least min_months_per_year, default 9, usable months). n_timing_years is a count of years, not months.

Field Units / range None or zero when Meaning
amplitude_snr Unitless, >=0 (finite float) 0.0 for insufficient records Climatological amplitude divided by mean within-month interannual SD. Descriptive only; does not set the regime.
peak_timing_concentration, trough_timing_concentration Unitless mean resultant length, 0–1 None for insufficient records Concentration of annual peak/trough months.
*_timing_concentration_ci_low, *_ci_high Unitless 0–1 None for insufficient records Percentile 95% bootstrap bounds for the corresponding R.
peak_timing_uniformity_p, trough_timing_uniformity_p p-value 0–1 None for insufficient records Deterministic Monte Carlo Kuiper p-value for the discrete 12-month uniform null.
peak_phase_iqr_months, trough_phase_iqr_months Months, 0–12 approximately None when fewer than four timings or insufficient Circular IQR; descriptive only and never a regime decision.
n_timing_years Integer >=0 years 0 for insufficient records Number of qualifying annual timing observations. Equals n_peak_timing_years; kept as a separate published field from the conservative min() used in the route gate.
n_peak_timing_years, n_trough_timing_years Integer >=0 years -- Count of calendar years whose peak/trough extremum is independently identifiable under the calibrated timing-identifiability thresholds.
n_zero_months Integer >=0 months -- Total usable months with exact-zero observed extent. Descriptive only; never a route or timing predicate.
zero_month_fraction Unitless, 0–1 -- Fraction of usable months that are exact zero.
n_whole_zero_years Integer >=0 years -- Count of years whose usable months are all exact zero. Contributes to dry-duration/event summaries but no timing observation.
pixel_support_status "available" | "unavailable" -- Whether pixel counts (n_water/n_valid/n_invalid/n_aoi) are present. Percentage-only inputs report "unavailable", and the calibrated min_peak_water_pixels threshold is not consulted for them.
timing_evidence "supported" | "insufficient" | "unsupported" -- insufficient when min(n_peak_timing_years, n_trough_timing_years) < min_informative_years; unsupported when established seasonality/uniformity evidence rejects an annual cycle; otherwise supported.
mean_monthly_peak_month, mean_monthly_trough_month Calendar month 1–12 None for aseasonal/insufficient records Extrema of mean monthly extent when the record supports reporting them.

Public regime assessment and routing emit the runtime decision_policy value hydroseason_0_2_0 while using exact empirical monthly extrema, circular timing statistics, and calibrated timing-identifiability thresholds. Extent is observed surface-water availability, not rainfall, discharge, storage volume, a climate variable, or natural-condition hydrology. A record's route is per_year_detection only when its regime is seasonal and its timing evidence is supported; a seasonal record with insufficient identifiable timing keeps its regime label but routes to event_characterisation. See the Methods Reference for the method specification.

hydroseason._regime

Surface-water regime assessment: what kind of signal is this, and what may be asked of it?

This module answers a question that must be settled before hydrological-year detection runs: does the catchment's observed surface-water record contain a reproducible annual cycle at all? Detectors downstream will return an answer whether or not one exists, so the gate belongs here.

Scope note, deliberately narrow: extent_pct measures observed surface water, which is water availability as seen from above. It is not a climate variable and must not be read as one. Regulation, diversion, extraction, farm-dam storage and land-use change all move surface-water extent independently of rainfall, so a flat or shifted signal is evidence about water availability, never directly about climate.

Regime module-attribute

Regime = Literal['seasonal', 'aseasonal', 'insufficient_record']

WaterRegimeAssessment dataclass

What the record supports, and what it does not.

supports_per_year_boundaries property
supports_per_year_boundaries: bool

Whether public hydrological years may be published for this record.

attempts_per_year_detection property
attempts_per_year_detection: bool

Whether the detector runs at all, as an internal diagnostic.

supports_fixed_window property
supports_fixed_window: bool

Whether one fixed climatological wet/dry window is defensible.

assess_water_regime

assess_water_regime(extent, *, value_col: str = 'extent_pct', date_col: str | None = None, min_months_per_year: int = _DEFAULT_MIN_MONTHS_PER_YEAR, max_invalid_pct: float = 20.0, quality_policy: QualityPolicy = 'flag', measurement_tolerance_pct: float = 0.0, n_bootstrap: int = 200, random_state: int = 0) -> WaterRegimeAssessment

Assess what the observed surface-water record supports.

Wet Events and Low Spells

hydroseason._events

Event-based characterisation of a surface-water record.

The annual-cycle view (peak, trough, hydrological year) presumes a reproducible yearly rhythm. Where a catchment has none -- episodic dryland rivers fill on rainfall events rather than on a calendar -- that view has nothing to attach to, and forcing it produces boundaries that describe noise.

This module supplies the alternative vocabulary such records do satisfy: discrete wet episodes, the dry spells between them, and how often filling recurs. These are the terms in which an intermittent river is normally described, and unlike a hydrological year they make no stationarity or periodicity assumption.

Episode detection uses hysteresis: an event opens when extent rises above a high threshold and stays open until it falls below a lower one. A single entry threshold would split one flood into several whenever the recession wobbles across it, inflating the event count precisely on the noisy records this module exists to serve.

WaterEventResult dataclass

Wet episodes, dry spells, and record-level summaries.

extract_water_events

extract_water_events(extent, *, value_col: str = 'extent_pct', date_col: str | None = None, threshold_mode: str = 'noise', enter_k: float = _DEFAULT_ENTER_K, exit_k: float = _DEFAULT_EXIT_K, low_k: float = _DEFAULT_LOW_K, start_quantile: float = _DEFAULT_START_QUANTILE, end_quantile: float = _DEFAULT_END_QUANTILE, low_quantile: float = _DEFAULT_LOW_QUANTILE, min_event_months: int = 1, min_separation_months: int = 1, min_low_months: int = 2, max_invalid_pct: float = 20.0, quality_policy: QualityPolicy = 'flag') -> WaterEventResult

Extract wet episodes and dry spells from a monthly extent record.

A wet event is a departure above the record's baseline exceeding enter_k noise scales, persisting until it falls back below exit_k noise scales (hysteresis), lasting at least min_event_months, and separated from the next episode by at least min_separation_months.

A low-extent spell is a run of at least min_low_months below the baseline minus low_k noise scales. It is defined independently of events: a catchment that never floods still has low-extent spells, and defining a spell as the gap between two events reports none for exactly the records that stay low.

The name is deliberate. This measures below this record's own typical extent, not dryness in the cease-to-flow sense -- roughly a third of all months qualify on a normal catchment, and calling those "dry" implies an absence of water that the data does not show. months_below_low_pct is reported alongside so the base rate is always visible: a long spell on a catchment where 38% of months sit below baseline describes a sustained below-average period, not a river without water.

threshold_mode selects how those thresholds resolve:

"noise" (default) Baseline median plus/minus multiples of the record's own AR(1)-corrected month-to-month noise. Means the same thing across catchments -- "larger than this record's own wobble" -- and presupposes nothing about what fraction of months are wet. "quantile" Fixed quantiles of the usable distribution. Robust but arbitrary: p75 places a quarter of all months above the entry threshold even on a record containing no events whatsoever.

Where the noise estimate is degenerate -- a synthetic or heavily-quantised record with no month-to-month variation -- noise mode cannot produce a scale, so thresholds fall back to quantiles and the resolved mode is reported as "quantile_fallback" rather than silently claiming "noise".

Months screened out by the quality policy break an episode rather than bridging it: an unobserved month is not evidence that water persisted.

Hydrological-Year Detection Core

hydroseason.hydro_year

Source-agnostic hydrological-year detection from monthly water extent.

Ported from WaterMask-TSFill commit 90983c1559e7c08951096bbf196c0daedead6b4f. Raster masks, WOfS, and extent CSVs converge on this module's monthly extent_pct input.

HydroYearConfig dataclass

Wet then dry search windows, at any phase of the calendar year.

Windows are cyclic month ranges, not calendar-anchored spans: each is read by walking forward from its start month to its end month, wrapping through December where needed. The cycle for the record labelled Y is anchored so the wet window ends in year Y; the wet window's start and the whole dry window then fall wherever that anchoring puts them, which may be the previous or the following calendar year.

The shipped default is unchanged and describes a tropical monsoon: wet Nov(Y-1)..Apr(Y), then dry Jul(Y)..Dec(Y).

Because the geometry is cyclic, phases the earlier fixed geometry could not express are now valid -- notably southern-Australian winter-rainfall catchments, e.g. wet_start_month=6, wet_end_month=9, dry_start_month=11, dry_end_month=2, whose dry window ends in Y+1. The only ordering rule left is that the dry window must begin after the wet window ends.

cycle_span_months property
cycle_span_months: int

Total months from wet-window start to dry-window end.

windows_for_year
windows_for_year(year: int) -> tuple[pd.Timestamp, pd.Timestamp, pd.Timestamp, pd.Timestamp]

Absolute (wet_start, wet_end, dry_start, dry_end) for record year.

Anchored on the wet window ending in year, then walked outward by the cyclic spans, so every other bound lands in whichever calendar year the phase implies without the caller specifying offsets.

detect_hydrological_years

detect_hydrological_years(extent: Series | DataFrame, *, value_col: str = 'extent_pct', date_col: str | None = None, config: HydroYearConfig | None = None, duplicate_month_policy: DuplicateMonthPolicy = 'raise', missing_month_policy: MissingMonthPolicy = 'raise', max_invalid_pct: float = 20.0, quality_policy: QualityPolicy = 'flag') -> pd.DataFrame

Detect hydrological years from a complete, quality-screened monthly series.

invalid_pct is honoured when supplied in a DataFrame. The conservative default rejects months with more than 20% invalid coverage. Set quality_policy="flag" to retain those observations and continue while leaving quality interpretation to the caller.

label_hydrological_months

label_hydrological_months(dates: Index | Series | DatetimeIndex, hy_df: DataFrame) -> pd.DataFrame

Assign Wet/Dry and hydrological-year labels from detected boundaries.

monthly_water_extent

monthly_water_extent(water_mask: 'xr.DataArray', *, water_value: int = 1, dry_value: int = 0, outside_value: int = -2, invalid_value: int = -1, spatial_dims: tuple[str, str] = ('y', 'x'), time_block: int = 1, wet_aoi=None, read_workers: int | None = None) -> pd.DataFrame

Summarise monthly canonical masks without treating invalid pixels as dry.

n_valid counts only pixels explicitly equal to water_value or dry_value; any other code (unknown values, NaN, out-of-domain codes that bypassed a classifier) counts as invalid rather than silently inflating the valid denominator. Raster dependencies are imported only at this computation boundary. The four scalar summaries are computed in streamed blocks of time_block steps along time rather than in one all-at-once dask.compute call, so peak memory stays bounded by time_block (times the spatial chunk footprint) instead of scaling with the full length of time. Raising time_block trades scheduler overhead (more, smaller dask.compute calls) for locality (fewer calls, more spatial chunks held concurrently); lower it to bound memory more tightly, raise it to reduce per-call scheduling overhead.

wet_aoi, if given, is a polygon or GeoDataFrame (in any CRS) describing the historical wet-AOI extent. It is rasterised against water_mask's spatial grid exactly once (the grid is time-invariant across the whole cube), then used to compute n_wet_aoi -- the per-month count of pixels inside the wet AOI that are also not outside_value -- and the derived wet_fill_pct = 100 * n_water / n_wet_aoi drought-signal ratio (NaN when n_wet_aoi is 0). When wet_aoi is None (the default), no rasterisation happens at all and n_wet_aoi is set equal to n_valid, so existing callers adding no wet AOI see no change to any pre-existing column, and get a well-defined wet_fill_pct computed with the same 100 * n_water / n_wet_aoi formula used in the wet_aoi-given case (this keeps wet_fill_pct an exact sum-then-percentage tiled aggregation of n_water/n_wet_aoi, matching how extent_pct and invalid_pct already aggregate). wet_fill_pct therefore equals extent_pct exactly and unconditionally when wet_aoi is None, regardless of whether invalid pixels are present, because both ratios reduce to the same 100 * n_water / n_valid formula in that case (they can legitimately differ only when a real wet_aoi is supplied).

read_workers, if given (and > 0), overrides dask's threaded-scheduler worker count for the dask.compute reductions below, where the lazy STAC/COG graph is actually materialised. Profiling on real WOfS data found this workload is decode/warp-CPU-bound rather than I/O-latency-bound as might be assumed for remote reads: dask's own default worker count outperformed every explicit override tried (4 through 64), and forcing a higher count made it monotonically worse. Leave this at None (the default, which leaves dask's configuration untouched) unless you have profiled your own workload and confirmed a specific value helps -- see hydroseason._io_extent_cache.load_wofs_monthly_extent's read_workers docstring for the measurements. The override, if used, is scoped via dask.config.set and restored on exit.

suggest_hydro_year_config

suggest_hydro_year_config(extent: Series | DataFrame, *, value_col: str = 'extent_pct', date_col: str | None = None, **overrides) -> HydroYearConfig

Propose a HydroYearConfig from a monthly-mean climatology of extent.

Averages extent by calendar month, then takes the contiguous above-mean run around the climatological peak as the wet window and the below-mean run around the trough as the dry window. Because HydroYearConfig windows are cyclic, the suggestion keeps whatever phase the climatology shows rather than reshaping it toward a cross-year wet season -- a winter-rainfall catchment gets a mid-year wet window.

This remains a first guess for review, not a substitute for it: bimodal, flat, or noisy climatologies can produce a split that doesn't match physical wet/dry seasons. Screen the record with assess_water_regime first; a catchment it calls aseasonal has no phase worth suggesting. Pass explicit HydroYearConfig fields as overrides (e.g. min_wet_months=3) to keep the suggested months but override other settings.

Dynamic Hydrological State

hydroseason.hydrological_state

Dynamic hydrological state: data-driven years, phases, and conditions.

The public face of the dynamic route. Where :mod:hydroseason.hydro_year fixes a calendar-anchored hydrological year, this module derives each cycle's boundaries from the observed extent record itself, then labels months by cycle-relative phase and classifies each year's surface-water condition against the record's own history.

:func:analyze_hydrological_state is the one call that runs the whole chain (seasonality classification, dynamic year detection, phase assignment, condition classification); the individual steps are re-exported for callers that need only one of them. See the Dynamic Hydrological State guide <https://tayerthiaggo.github.io/hydroseason/hydrological-state/>_.

DynamicHydroYearConfig dataclass

HydrologicalStateResult dataclass

SeasonalPatternResult dataclass

analyze_hydrological_state

analyze_hydrological_state(extent, *, config: DynamicHydroYearConfig | None = None, reference_start=None, reference_end=None, reference: str = 'full_record', rolling_window_cycles: int = 10, rolling_min_cycles: int = 5, n_bootstrap: int = 200, random_state: int = 0, quality_policy: QualityPolicy = 'flag') -> HydrologicalStateResult

detect_dynamic_hydrological_years

detect_dynamic_hydrological_years(extent, *, config: DynamicHydroYearConfig, value_col: str = 'extent_pct', date_col: str | None = None, pattern: SeasonalPatternResult | None = None) -> pd.DataFrame

suggest_dynamic_hydro_year_config

suggest_dynamic_hydro_year_config(extent, *, pattern: SeasonalPatternResult | None = None, **overrides) -> DynamicHydroYearConfig

classify_seasonal_pattern

classify_seasonal_pattern(extent, *, resolution_floor_pp: float | None = None, mode_min_frequency: float | None = None, mode_min_separation_months: int | None = None, n_bootstrap: int = 200, n_null: int = _DEFAULT_N_NULL, random_state: int = 0, measurement_tolerance_pct: float = 1.0, quality_policy: Literal['exclude', 'flag'] = 'flag') -> SeasonalPatternResult

Classify annual-cycle shape from weighted, partial-year-tolerant evidence.

Every month passing the observation policy contributes, weighted by its observed fraction. Complete calendar years remain a compatibility metric, but evaluable partial years now gate and inform the fit.

classify_annual_surface_water_condition

classify_annual_surface_water_condition(annual: DataFrame, *, reference: str = 'full_record', reference_start: str | Timestamp | None = None, reference_end: str | Timestamp | None = None, rolling_window_cycles: int = 10, rolling_min_cycles: int = 5, min_baseline_cycles: int = 5, low_percentile: float = 20.0, high_percentile: float = 80.0, low_variability: bool = False, allow_low_variability_labels: bool = False, noise_pp: float | None = None, timing_amplitude_k: float = 2.0) -> pd.DataFrame

compute_monthly_surface_water_condition

compute_monthly_surface_water_condition(extent, *, reference_start=None, reference_end=None, value_col: str = 'extent_pct', date_col: str | None = None, max_invalid_pct: float = 20.0, allow_unknown_quality: bool = False, quality_policy: QualityPolicy = 'flag') -> pd.DataFrame

aggregate_basin_monthly_extent

aggregate_basin_monthly_extent(monthly: DataFrame, *, date_col: str = 'date', aoi_col: str = 'aoi_id', area_weight_col: str | None = None) -> pd.DataFrame