Skip to content

feat: add cluster_on to select which coordinates drive clustering#89

Merged
FBumann merged 2 commits into
mainfrom
feat/cluster-on
Jul 9, 2026
Merged

feat: add cluster_on to select which coordinates drive clustering#89
FBumann merged 2 commits into
mainfrom
feat/cluster-on

Conversation

@FBumann

@FBumann FBumann commented Jul 9, 2026

Copy link
Copy Markdown
Owner

What

Adds a cluster_on parameter to aggregate() that restricts which coordinates along cluster_dim drive the clustering. Coordinates left out are still aggregated and reconstructed from the resulting clusters, but have no influence on how the clusters are formed.

# single cluster_dim — list or bare string
aggregate(da, ..., cluster_on=["solar", "wind"])
aggregate(da, ..., cluster_on="solar")

# multiple cluster_dim — dict keyed by dim; omitted dims unrestricted
aggregate(da, ..., cluster_dim=["variable", "region"],
          cluster_on={"variable": ["solar", "wind"]})   # carries "demand" across all regions

Why

Common ask: carry a price/demand series onto the representative grid without letting it drive period selection.

The obvious alternative — weights={"price": 0} — is a footgun: tsam silently clamps a 0 weight to a "minimal tolerable weighting", so the variable still weakly influences the clustering. Encoding a hard structural decision (which columns enter the distance metric) as a magic value of the soft weights knob also creates a 0.0-vs-1e-9 discontinuity.

cluster_on keeps weights purely about relative importance and expresses exclusion explicitly.

How

Internally clusters on the active subset via tsam.aggregate(), then applies that clustering back to the full frame (ClusteringResult.apply()) so passive columns get representatives. Composes with weights (which apply to the active subset). Mirrors the weights API shapes.

Validation covers: unknown dims/coords, list-form with multiple cluster_dim, and an empty selection ("nothing to cluster on").

Tests

New parametrized TestClusterOn (fixtures + parametrize) proving a carried variable yields identical cluster assignments to clustering the subset alone, plus error-path cases. Full suite: 517 passed, mypy + ruff clean.

Docs

New "Selecting what to cluster on" section in docs/examples/multi-dim.ipynb; executes end-to-end.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added cluster_on to aggregate() to control which coordinates influence clustering, while still reconstructing and returning all coordinates/variables.
    • Supports per-dimension and multi-dimension selection, with validation and consistent behavior with weights.
  • Documentation
    • Updated the multi-dim example notebook with a new section explaining how to choose what to cluster on, including a runnable code example.
  • Bug Fixes
    • Improved handling of representative conversion when using MultiIndex indexes.
  • Tests
    • Added coverage for valid cluster_on use, interaction with weights, differing outcomes vs full-variable clustering, and error cases.

`cluster_on` restricts the clustering to a subset of coordinates along
`cluster_dim`. Coordinates left out are still aggregated and reconstructed
from the resulting clusters, but have no influence on how the clusters are
formed — useful for carrying prices/demand onto the representative grid
without letting them drive period selection.

Mirrors the `weights` formats: a list/bare string for a single cluster_dim,
or a dict keyed by dimension for multiple. Internally clusters on the active
subset then applies the clustering back to the full frame, so `weights`
stays purely about relative importance (no magic zero-weight discontinuity).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds cluster_on to aggregate() so clustering can be limited to selected coordinates while other variables are still carried through reconstruction. It also adds validation, active/passive column handling, a notebook example, and tests.

Changes

cluster_on feature

Layer / File(s) Summary
ClusterOn type and aggregate() signature
src/tsam_xarray/_core.py
Adds the ClusterOn alias, extends aggregate() with cluster_on, and documents the accepted forms.
cluster_on normalization and validation
src/tsam_xarray/_core.py
Adds shared mapping validation and normalizes cluster_on into per-dimension active coordinate sets.
cluster_on threading and aggregation
src/tsam_xarray/_core.py
Threads active_coords through aggregation paths, guards MultiIndex handling, and clusters active columns before reapplying clustering to passive columns.
Notebook example and tests
docs/examples/multi-dim.ipynb, test/test_aggregate.py
Adds a notebook section showing cluster_on and tests covering subset matching, changed assignments, weights composition, and invalid inputs.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant aggregate
  participant _normalize_cluster_on
  participant _aggregate_single
  participant tsam

  aggregate->>_normalize_cluster_on: cluster_on
  _normalize_cluster_on-->>aggregate: active_coords
  aggregate->>_aggregate_single: active_coords
  alt passive columns exist
    _aggregate_single->>tsam: aggregate(active subset)
    tsam-->>_aggregate_single: clustering
    _aggregate_single->>tsam: clustering.apply(full DataFrame)
    tsam-->>_aggregate_single: reconstructed result
  else all columns active
    _aggregate_single->>tsam: aggregate(full DataFrame)
    tsam-->>_aggregate_single: reconstructed result
  end
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding cluster_on to control which coordinates drive clustering.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/cluster-on

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/tsam_xarray/_core.py`:
- Around line 445-448: Treat dict-form cluster_on string values as a single
coordinate instead of iterating over characters. Update the normalization logic
in the cluster_on handling branch of _core.py so the dict path matches the
list-form behavior: when a per-dimension value is a string, wrap it as one item
before converting to strings. Keep the existing validation flow in the
cluster_on processing code, but ensure {"variable": "solar"} becomes ["solar"],
not a character list.
- Around line 585-601: The passive-column path in the TSAM aggregation flow is
passing aggregate-only options into ClusteringResult.apply(), which only accepts
the data frame in tsam 3.4.0. Update the logic around tsam.aggregate() and
ClusteringResult.apply() in _core.py so that apply() is called without
temporal_resolution, round_decimals, numerical_tolerance, or any other
aggregate-only kwargs; either remove apply_kwargs entirely or filter it strictly
to parameters supported by apply(). Ensure the active-column branch still uses
the clustering.apply() result correctly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: aafe8147-4644-4d34-96de-1fdd7da59b36

📥 Commits

Reviewing files that changed from the base of the PR and between 77dd0c2 and 630188b.

📒 Files selected for processing (3)
  • docs/examples/multi-dim.ipynb
  • src/tsam_xarray/_core.py
  • test/test_aggregate.py

Comment thread src/tsam_xarray/_core.py
Comment on lines +445 to +448
if isinstance(cluster_on, dict):
per_dim: dict[str, list[str]] = {
k: [str(c) for c in v] for k, v in cluster_on.items()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the target file and inspect the relevant region.
wc -l src/tsam_xarray/_core.py
ast-grep outline src/tsam_xarray/_core.py --view expanded | sed -n '1,220p'
sed -n '420,490p' src/tsam_xarray/_core.py

Repository: FBumann/tsam_xarray

Length of output: 3571


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from collections.abc import Sequence

v = "solar"
print("isinstance(v, Sequence):", isinstance(v, Sequence))
print("[str(c) for c in v]:", [str(c) for c in v])

cluster_on = {"variable": "solar"}
per_dim = {k: [str(c) for c in v] for k, v in cluster_on.items()}
print("per_dim:", per_dim)
PY

Repository: FBumann/tsam_xarray

Length of output: 283


Treat dict-form cluster_on string values as one coord
{"variable": "solar"} is expanded into ['s', 'o', 'l', 'a', 'r'], which then fails validation with an unknown-coords error. Normalize per-dim string values the same way the list-form branch already does.

Proposed fix
     if isinstance(cluster_on, dict):
         per_dim: dict[str, list[str]] = {
-            k: [str(c) for c in v] for k, v in cluster_on.items()
+            k: [v] if isinstance(v, str) else [str(c) for c in v]
+            for k, v in cluster_on.items()
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if isinstance(cluster_on, dict):
per_dim: dict[str, list[str]] = {
k: [str(c) for c in v] for k, v in cluster_on.items()
}
if isinstance(cluster_on, dict):
per_dim: dict[str, list[str]] = {
k: [v] if isinstance(v, str) else [str(c) for c in v]
for k, v in cluster_on.items()
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/tsam_xarray/_core.py` around lines 445 - 448, Treat dict-form cluster_on
string values as a single coordinate instead of iterating over characters.
Update the normalization logic in the cluster_on handling branch of _core.py so
the dict path matches the list-form behavior: when a per-dimension value is a
string, wrap it as one item before converting to strings. Keep the existing
validation flow in the cluster_on processing code, but ensure {"variable":
"solar"} becomes ["solar"], not a character list.

Comment thread src/tsam_xarray/_core.py
Clean up the cluster_on implementation without changing the public API
or any test:

- Factor the duplicated dim/coord validation shared by weights and
  cluster_on into _validate_dim_coord_mapping().
- Collapse _col_is_active + _partition_by_cluster_on into a single
  _active_columns() pass; "has passive columns" is len(active) != total.
- Move the empty-selection ("nothing to cluster on") check up-front into
  _normalize_cluster_on, so bad input fails before any aggregation runs
  instead of deep in the per-slice loop.

517 passed, mypy + ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/tsam_xarray/_core.py`:
- Around line 590-591: The passive-column path is incorrectly forwarding
filtered kwargs into ClusteringResult.apply(), but that method only accepts the
DataFrame and will raise a TypeError. Update the apply logic in the clustering
flow around clustering.apply so it is called with df only, and either stop
constructing apply_kwargs here or keep _APPLY_KWARGS empty so
temporal_resolution, round_decimals, and numerical_tolerance are not passed
through.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: cdbe90ca-dc20-48bf-932a-9b48f91148a8

📥 Commits

Reviewing files that changed from the base of the PR and between 630188b and f923ccf.

📒 Files selected for processing (1)
  • src/tsam_xarray/_core.py

Comment thread src/tsam_xarray/_core.py
Comment on lines +590 to +591
apply_kwargs = {k: v for k, v in tsam_kwargs.items() if k in _APPLY_KWARGS}
tsam_result = clustering.apply(df, **apply_kwargs)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
sed -n '505,516p' src/tsam_xarray/_core.py
rg -n "_APPLY_KWARGS" src/tsam_xarray/_core.py

Repository: FBumann/tsam_xarray

Length of output: 702


🌐 Web query:

tsam 3.4.0 ClusteringResult.apply signature temporal_resolution round_decimals numerical_tolerance source

💡 Result:

In tsam 3.4.0, the ClusteringResult.apply() method is used to transfer a previously computed clustering configuration to new input data [1][2][3]. The signature of this method is: ClusteringResult.apply(data: pandas.DataFrame) -> AggregationResult The method takes a single argument, data, which is the pandas DataFrame to which the existing clustering configuration will be applied [4][3]. Regarding the parameters temporal_resolution, round_decimals, and numerical_tolerance: These parameters are generally associated with the initial tsam.aggregate() function (used to create the clustering in the first place) rather than the ClusteringResult.apply() method [5][6]. When performing the initial aggregation, they serve the following purposes [5][6]: - temporal_resolution: Defines the time resolution of the input data. It accepts a float (representing hours) or a pandas Timedelta string (e.g., '1h', '15min'). If not provided, it is inferred from the datetime index. - round_decimals: An integer specifying the number of decimal places to which the results should be rounded. - numerical_tolerance: A float (default is 1e-13) used for floating-point comparisons during the aggregation process. Because ClusteringResult.apply() uses a pre-calculated clustering (such as predefined cluster centers or indices), these tuning parameters are typically set during the initial aggregate() call and are encapsulated within the ClusteringResult object itself [4][3]. Applying the result to new data simply maps the new data to the existing cluster representatives [4][2].

Citations:


Don't forward kwargs into ClusteringResult.apply()
ClusteringResult.apply() only accepts the DataFrame, so passing temporal_resolution, round_decimals, or numerical_tolerance here will raise TypeError on the passive-column path. Call clustering.apply(df) directly, or leave _APPLY_KWARGS empty.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/tsam_xarray/_core.py` around lines 590 - 591, The passive-column path is
incorrectly forwarding filtered kwargs into ClusteringResult.apply(), but that
method only accepts the DataFrame and will raise a TypeError. Update the apply
logic in the clustering flow around clustering.apply so it is called with df
only, and either stop constructing apply_kwargs here or keep _APPLY_KWARGS empty
so temporal_resolution, round_decimals, and numerical_tolerance are not passed
through.

@FBumann
FBumann merged commit ddff73b into main Jul 9, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant