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.
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