Skip to content

Conversation

@FBumann
Copy link
Member

@FBumann FBumann commented Jan 7, 2026

Description

Brief description of the changes in this PR.

Type of Change

  • Bug fix
  • New feature
  • Documentation update
  • Code refactoring

Related Issues

Closes #(issue number)

Testing

  • I have tested my changes
  • Existing tests still pass

Checklist

  • My code follows the project style
  • I have updated documentation if needed
  • I have added tests for new functionality (if applicable)

Summary by CodeRabbit

  • New Features

    • Added inter-cluster storage linking for clustered optimizations
    • Introduced cluster-aware storage modes (independent, cyclic, intercluster variants)
    • Expanded component configuration with property-based APIs for flexible control
  • Documentation

    • Added comprehensive cluster architecture design document
  • Refactor

    • Improved internal state management with property-based access patterns
    • Enhanced time dimension handling with cluster-aware temporal operations
    • Replaced hours_per_step with timestep_duration for clearer semantics

✏️ Tip: You can customize this high-level summary in your review settings.

FBumann and others added 19 commits January 5, 2026 18:16
Complete rework of the clustering/aggregation system:
- New clustering module with inter-cluster linking and storage modes
- Unified cluster/expand API via transform_accessor
- Support for multi-period and multi-scenario clustering
- Improved cluster weights and statistics calculations
- Storage model support for clustered operation (initial=equal, cyclic, etc.)
- External tsam support with data aggregation
- Cluster IO and serialization

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
Add new dataset plotting accessor for enhanced visualization:
- fxplot accessor for xarray datasets
- Improved plotting defaults and styling
- Animation frame handling improvements

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
Add comparison functionality for analyzing multiple FlowSystem solutions:
- Compare results across different scenarios
- Visualization support for comparison data

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
- Tests for cluster_reduce and expand functionality
- Tests for clustering base and integration
- Tests for clustering IO
- Tests for single selection (sel/isel)
- Updated existing tests to work with new API

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
- Add new clustering notebooks (08c, 08c2, 08d, 08e)
- Add cluster architecture design documentation
- Add tutorial data helper module for notebooks
- Update existing notebooks to use new API
- Improve user guide documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
Document all breaking changes and new features for the major release.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
Improve CI workflow for faster notebook execution in documentation builds.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
  | Case             | Layout                                                                  |
  |------------------|-------------------------------------------------------------------------|
  | Simple (no dims) | Single heatmap                                                          |
  | Periods only     | Vertical stack (1 column)                                               |
  | Scenarios only   | Horizontal layout                                                       |
  | Both             | Grid (periods as rows, scenarios as columns via facet_cols=n_scenarios) |

  | File                                            | Issue                                                     | Fix                                                 |
  |-------------------------------------------------|-----------------------------------------------------------|-----------------------------------------------------|
  | docs/notebooks/03-investment-optimization.ipynb | Division by zero when solar_size = 0                      | Added guard: if solar_size > 0 else float('nan')    |
  | flixopt/clustering/base.py:181-235              | ClusterStructure.plot() crashes for multi-period/scenario | Added NotImplementedError with helpful message      |
  | flixopt/components.py:1256-1265                 | Obsolete linopy LP writer bug workaround                  | Removed + 0.0 workaround (fixed in linopy >= 0.5.1) |
  | flixopt/dataset_plot_accessor.py:742-780        | to_duration_curve crashes on variables without time dim   | Added guard to skip non-time variables              |
  | flixopt/features.py:234-236                     | Critical: Startup count ignores cluster weighting         | Now multiplies by cluster_weight before summing     |
  | flixopt/structure.py:268-281                    | scenario_weights docstring misleading                     | Updated docstring to accurately describe behavior   |

  Nitpick Fixes

  | File                                               | Fix                                                                                       |
  |----------------------------------------------------|-------------------------------------------------------------------------------------------|
  | docs/notebooks/data/generate_example_systems.py    | Fixed type hint pd.DataFrame → pd.Series for _elec_prices, added timezone guard           |
  | flixopt/statistics_accessor.py:2103-2132           | Added detailed comment explaining secondary y-axis offset strategy                        |
  | tests/test_cluster_reduce_expand.py                | Moved import xarray as xr to top of file                                                  |
  | docs/notebooks/data/generate_realistic_profiles.py | Clarified warnings.resetwarnings() comment                                                |
  | flixopt/transform_accessor.py                      | Removed unused cluster_coords and time_coords params from _combine_slices_to_dataarray_2d |
  | flixopt/comparison.py                              | Added error handling to _concat_property for FlowSystems lacking optimization data        |
  1. Added _invalidate() helper method to the Interface base class in structure.py
  2. Made Element.label immutable - attempting to set label now raises AttributeError
  3. Converted parameters to properties across all Element/Component classes:
    - Flow: size, relative_minimum/maximum, fixed_relative_profile, effects_per_flow_hour, status_parameters, flow_hours constraints, load_factor bounds
    - Bus: carrier, imbalance_penalty_per_flow_hour
    - Component: inputs, outputs, status_parameters, prevent_simultaneous_flows
    - Storage: capacity_in_flow_hours, eta_charge/discharge, initial_charge_state, all charging bounds
    - LinearConverter: conversion_factors, piecewise_conversion
    - Transmission, Source, Sink: all relevant parameters
    - Linear converter subclasses: Boiler, CHP, HeatPump, etc. (including efficiency properties)
    - InvestParameters, StatusParameters: all parameters
    - Effect: unit, description, is_standard, is_objective, period_weights, share_from_*, all bounds
  4. Created tests/test_property_guards.py with 19 tests verifying:
    - Label immutability (4 tests)
    - Modification before linking works (3 tests)
    - Modification after linking invalidates FlowSystem (3 tests)
    - Nested object modification invalidates (2 tests)
    - List/dict properties return copies (2 tests)
    - Full workflow: modify → re-optimize (1 test)
    - Storage properties invalidate (3 tests)
    - HeatPump COP invalidates (1 test)
  5. All 1561 tests pass including the new property guard tests

  Key Design Decisions

  - Labels are immutable (raise AttributeError)
  - All other params are mutable but trigger _invalidate() after linking
  - List/dict properties return copies to prevent in-place mutation
  - Internal code uses backing fields (self._size) to avoid triggering invalidation during transform_data()
Resolve 24 merge conflicts, bringing improvements from main:
- tsam clustering with storage inter-cluster linking
- fxplot accessor and comparison module
- Improved error handling, logging, and docstrings
- CI speedups and notebook improvements

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jan 7, 2026

📝 Walkthrough

Walkthrough

This pull request introduces a comprehensive refactoring across nine core modules, systematically replacing public attributes with private backing fields and exposing them through property accessors with invalidation semantics. The changes support a cluster-aware architecture and prepare the codebase for inter-cluster storage linking, documented in a new design guide.

Changes

Cohort / File(s) Summary
Design Documentation
docs/design/cluster_architecture.md
New comprehensive design document outlining cluster architecture with true (cluster, time) dimensions. Details chosen approach (Option B), impact across StatusModel, StorageModel, InterclusterStorageModel, and plotting; includes data shape changes, examples, phased implementation plan, and testing strategy.
Component Layer Refactoring
flixopt/components.py
Introduced backing fields and property accessors for LinearConverter (conversion_factors, piecewise_conversion), Transmission (in1/out1/in2/out2, relative_losses, absolute_losses, balanced), and Source/Sink/SourceAndSink (prevent_simultaneous_flow_rates). Expanded Storage with cluster_mode, charging/discharging, capacity/charge-state properties, and new InterclusterStorageModel class for inter-cluster storage linking.
Effect & Element Layer
flixopt/effects.py, flixopt/elements.py
Refactored Effect class to use private backing fields (_unit, _description, _is_standard, etc.) with property accessors and invalidation. Similarly refactored Component, Flow, and Bus classes in elements.py with extensive private backing fields (_inputs, _outputs, _status_parameters, etc.) exposed via public properties with invalidation semantics.
Interface & Investment Parameters
flixopt/interface.py
Replaced public attributes in InvestParameters and StatusParameters with private backing fields. Added comprehensive property-based API (effects_of_investment, linked_periods, effects_per_startup, min_uptime, etc.), each triggering invalidation on setter. Updated transform_data to operate on backing fields to avoid unnecessary invalidation during initialization.
Linear Converters
flixopt/linear_converters.py
Refactored to use private _conversion_factors backing field instead of direct public access. Setter paths consistently update backing field and call _invalidate(); reads use .get() accessors. Applied across HeatPump, Power2Heat, CHP, and related converter classes.
Flow System & Structure
flixopt/flow_system.py, flixopt/structure.py
In flow_system.py, internal methods now use backing fields (_inputs, _outputs, _color) to avoid triggering invalidation. In structure.py, removed normalize_weights parameter, replaced hours_per_step with timestep_duration, introduced new public accessors (dims, indexes, weights, temporal_dims, temporal_weight, sum_temporal). Refactored Element to use backing fields (_label, _meta_data, _color) with property accessors.
Features & Time-Awareness
flixopt/features.py
Updated StatusModel startup counting constraint to use cluster-weighted startup sum, multiplying startup variable by cluster weights (defaulting to 1.0) and aggregating over existing startup_temporal_dims.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

  • Feature/simplify structure #464: Overlapping refactors to transform_data, FlowSystem propagation, and shared module changes (LinearConverter, Storage, Transmission, elements, interface, structure).
  • V3.0.0/main #284: Related temporal/coordinate/submodel refactoring foundation; main PR extends the backing-field and property patterns introduced there.
  • Feature/v3/update #352: Related cluster-aware and duration-handling changes affecting the same core FlowSystem/modeling primitives (timestep_duration, temporal dims, FlowSystemModel).

Poem

🐰 With backing fields tucked safe and sound,
Properties spring forth all around,
Invalidation sweeps through with care,
Clusters now linked beyond compare,
The codebase breathes—encapsulation's dare!

🚥 Pre-merge checks | ❌ 3
❌ Failed checks (3 warnings)
Check name Status Explanation Resolution
Title check ⚠️ Warning The title 'Feature/properties' is vague and does not clearly describe the main changes; it uses a generic branch name rather than a meaningful summary of what the PR accomplishes. Replace with a more specific title that describes the main change, such as 'Introduce property-based backing fields and internal state management across components' or similar.
Description check ⚠️ Warning The PR description is incomplete; it only contains the template with unchecked sections and no substantive information about what changes are included or why. Fill in the Description section with a summary of the key changes, select the appropriate Type of Change, add Related Issues if applicable, and confirm Testing status.
Docstring Coverage ⚠️ Warning Docstring coverage is 61.90% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

📜 Recent review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between cf68233 and b1cbab6.

📒 Files selected for processing (3)
  • flixopt/features.py
  • flixopt/flow_system.py
  • flixopt/interface.py
✅ Files skipped from review due to trivial changes (1)
  • flixopt/flow_system.py
🧰 Additional context used
🧬 Code graph analysis (1)
flixopt/interface.py (3)
flixopt/structure.py (7)
  • _invalidate (438-446)
  • prefix (391-393)
  • _fit_effect_coords (484-502)
  • dims (229-231)
  • transform_data (375-388)
  • _fit_coords (469-482)
  • flow_system (449-467)
flixopt/effects.py (1)
  • transform_data (402-445)
flixopt/components.py (3)
  • transform_data (243-250)
  • transform_data (671-710)
  • transform_data (1054-1058)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (5)
  • GitHub Check: test (3.14)
  • GitHub Check: test (3.11)
  • GitHub Check: test (3.12)
  • GitHub Check: test (3.13)
  • GitHub Check: Build documentation
🔇 Additional comments (7)
flixopt/interface.py (6)

1158-1169: LGTM! Backing field initialization follows the new design pattern.

The initialization correctly sets up private backing fields that will be exposed through properties. The None-to-empty-dict conversions for effect parameters maintain consistency with the previous API.


1170-1273: Well-structured property layer with invalidation semantics.

The property accessors provide a clean public API with automatic invalidation when values change. The defensive copying for dict types (lines 1175-1177, etc.) prevents external mutation of the internal state, which is a good safety measure.

Note: Dict property getters return copies, so direct mutation like obj.effects_of_investment['key'] = value won't affect the underlying object. Users should use the setter instead: obj.effects_of_investment = {**obj.effects_of_investment, 'key': value} or reassign the entire dict.


1281-1346: Correct usage of backing fields in transform_data.

The transform_data method correctly operates on backing fields (e.g., self._effects_of_investment) throughout, avoiding unnecessary invalidation calls during the transformation phase. This aligns with the pattern shown in the relevant code snippets and prevents invalidation loops.

The validation at line 1283 and the linked_periods tuple handling (lines 1319-1340) also correctly use backing fields.


1591-1602: LGTM! StatusParameters follows the same backing field pattern.

Consistent with InvestParameters, all parameters are initialized as private backing fields with appropriate None-to-empty-dict conversions for effect parameters.


1603-1722: Property layer correctly implemented with invalidation.

The StatusParameters properties follow the same pattern as InvestParameters: defensive dict copying in getters, invalidation in setters, and consistent public API. The property organization with region markers improves code readability.


1724-1748: Transform operations correctly use backing fields.

All data transformation operations consistently use backing fields (e.g., self._effects_per_startup, self._min_uptime) to avoid triggering invalidation during the transformation phase. This matches the established pattern from the codebase.

flixopt/features.py (1)

237-242: The cluster-weighted startup counting logic is correct and safe. Verification confirms:

  • self._model.weights is properly defined as a dictionary property in FlowSystemModel, delegating to flow_system.weights
  • The weights['cluster'] key is conditionally present only when self.clusters is not None
  • The pattern weights.get('cluster', 1.0) safely defaults to 1.0 in non-clustered systems
  • The cluster weight broadcasts correctly with the self.startup variable (created with full coordinates)
  • The startup_temporal_dims correctly identifies dimensions to sum over (excludes 'period' and 'scenario')
  • This pattern is consistently used throughout the codebase (lines 641, 720) with no issues reported

No changes needed.


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 and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
docs/notebooks/08a-aggregation.ipynb (1)

111-117: Minor comment inconsistency.

The comment on line 113 says "Resample from 15-min to 4h resolution", but based on the print statement on line 68 the system appears to be at hourly resolution. Consider updating the comment for accuracy.

-    "# Resample from 15-min to 4h resolution\n",
+    "# Resample from 1h to 4h resolution\n",
flixopt/flow_system.py (1)

2065-2105: Use .item() to safely extract boolean from xarray operation

The code if np.isclose(norm, 0.0).any(): has a truth value ambiguity issue. Since norm is an xarray.DataArray, np.isclose(norm, 0.0) returns a DataArray, and .any() on a DataArray returns a 0-dimensional DataArray (not a Python bool). Using it directly in an if statement can raise a truth value error.

The simplest fix is to convert the result to a Python bool using .item():

-        if np.isclose(norm, 0.0).any():
+        if np.isclose(norm, 0.0).any().item():

This preserves all existing logic including the detailed error diagnostics for multi-dimensional weights.

🤖 Fix all issues with AI agents
In @docs/notebooks/data/generate_example_systems.py:
- Around line 543-547: The comment mismatches the generated timesteps and the
described year: update the comment around the pd.date_range(...) and
_get_weather() usage to clearly state that timesteps are generated for a
non‑leap reference year (2019) to match TMY's 8760 hours and that the
temperature and ghi arrays are from the TMY-like weather returned by
_get_weather(), e.g., clarify that we "map TMY (8760-hour) weather from
_get_weather() onto a 2019 non‑leap hourly index (pd.date_range(...))" so
variables timesteps, temp, and ghi are unambiguous.

In @docs/notebooks/data/generate_realistic_profiles.py:
- Around line 185-235: The PV output scaling in generate_pv_profile is incorrect
because system_efficiency is applied on top of capacity_kw, causing peak power
to be ~0.1275× capacity_kw; remove the hardcoded system_efficiency multiplier
(or add a new parameter performance_ratio with default=1.0) and compute
pv_output as poa['poa_global'] * capacity_kw / 1000 (optionally multiply by
performance_ratio if you want to model losses). Update references to pv_output
and the final clipping so values are in kW and clipped to [0, capacity_kw], and
document the new performance_ratio parameter if added.

In @flixopt/clustering/base.py:
- Around line 331-337: ClusterResult._create_reference_structure currently
mutates self.n_representatives when it's an xr.DataArray; instead create a local
renamed copy and register that in arrays without assigning back to self: if
isinstance(self.n_representatives, xr.DataArray) then compute n_rep_name, make a
renamed copy (e.g., n_representatives_da =
self.n_representatives.rename(n_rep_name)), store arrays[n_rep_name] =
n_representatives_da and set ref['n_representatives'] = f':::{n_rep_name}';
otherwise keep ref['n_representatives'] = int(self.n_representatives). Ensure
you do not reassign to self.n_representatives anywhere in
_create_reference_structure.
- Around line 110-133: The method _create_reference_structure mutates
self.n_clusters by assigning a renamed DataArray back to it; avoid this by
creating a local renamed copy instead of reassigning to self.n_clusters. Inside
the isinstance(self.n_clusters, xr.DataArray) branch, compute n_clusters_name =
self.n_clusters.name or 'n_clusters', set renamed =
self.n_clusters.rename(n_clusters_name) (do not assign back to self.n_clusters),
add arrays[n_clusters_name] = renamed and ref['n_clusters'] =
f':::{n_clusters_name}' so you preserve the original attribute and still
serialize the renamed copy; keep the rest of the method (cluster_order,
cluster_occurrences, timesteps_per_cluster) unchanged.

In @flixopt/clustering/intercluster_helpers.py:
- Around line 49-53: The module-level constant DEFAULT_UNBOUNDED_CAPACITY is set
to 1e6 but the docstring later refers to 1e9; make them consistent by updating
the docstring text that mentions the default upper bound (the paragraph
referencing InvestParameters.maximum_size) to state 1e6 instead of 1e9 so the
documentation matches DEFAULT_UNBOUNDED_CAPACITY.

In @flixopt/transform_accessor.py:
- Around line 1293-1334: The boolean check on the xarray DataArray loss_value
inside _apply_soc_decay is ambiguous; replace the current if not (loss_value >
0).any(): with a concrete scalar check by converting the DataArray to a
numpy/scalar before testing (e.g., use loss_value.values.any() or
bool((loss_value > 0).values.any()) or loss_value.any().item()), so the early
return when self-discharge is zero works correctly and avoids the ValueError
raised by evaluating a DataArray in a boolean context.

In
@tests/deprecated/examples/03_Optimization_modes/example_optimization_modes.py:
- Around line 192-217: The wrapper currently uses a placeholder duration; change
the optimize call to capture its return value (e.g., optimization_result =
clustered_fs.optimize(...)), update the ClusteredResult class signature to
accept the optimization object (def __init__(self, name, fs, optimization):) and
set self.durations = optimization.durations, and then instantiate with
ClusteredResult('Clustered', clustered_fs, optimization_result) so the actual
solve time is preserved.
🧹 Nitpick comments (16)
.github/workflows/docs.yaml (1)

124-144: DRY opportunity: Consider extracting notebook execution to a composite action.

The notebook caching and execution logic is duplicated between build and deploy jobs. If this pattern will be maintained long-term, consider extracting it into a reusable composite action or a shared workflow to reduce maintenance burden.

That said, this duplication is acceptable for now given the workflow's purpose.

docs/design/cluster_architecture.md (1)

37-40: Add language specifier to fenced code block.

The code block starting at line 37 is missing a language specifier, which helps with syntax highlighting and accessibility.

📝 Suggested fix
-```
+```text
 time: (n_clusters × timesteps_per_cluster,)  # Flat, e.g., (864,) for 9 clusters × 96 timesteps
</details>

</blockquote></details>
<details>
<summary>flixopt/effects.py (1)</summary><blockquote>

`240-285`: **Consider whether `unit` and `description` setters should trigger invalidation.**

Setting `unit` or `description` typically doesn't affect the mathematical model - these are metadata fields. Calling `_invalidate()` on these setters may cause unnecessary model rebuilds.


If these are purely informational (as the docstring suggests for `unit`), consider removing the `_invalidate()` call from their setters:

<details>
<summary>♻️ Optional: Skip invalidation for metadata-only fields</summary>

```diff
 @unit.setter
 def unit(self, value: str) -> None:
     self._unit = value
-    self._invalidate()

 @description.setter
 def description(self, value: str) -> None:
     self._description = value
-    self._invalidate()
flixopt/clustering/base.py (1)

1154-1172: Potential inefficiency in create_cluster_structure_from_mapping.

The loop at lines 1171-1172 uses a list comprehension inside a loop to count occurrences, resulting in O(n²) complexity. For large cluster counts, this could be slow.

♻️ More efficient occurrence counting
     # Count occurrences of each cluster
     unique_clusters = np.unique(cluster_order)
     n_clusters = int(unique_clusters.max()) + 1 if len(unique_clusters) > 0 else 0
-    occurrences = {}
-    for c in unique_clusters:
-        occurrences[int(c)] = sum(1 for x in cluster_order if x == c)
+    # Use numpy's bincount for O(n) counting
+    occurrences = dict(enumerate(np.bincount(cluster_order, minlength=n_clusters)))
flixopt/statistics_accessor.py (2)

129-170: Consider clarifying the slot calculation logic.

The slot calculation on line 166 uses boolean addition, which is compact but potentially unclear:

slots = (facet_col == 'auto') + (animation_frame == 'auto')

While functionally correct, this could be more explicit for maintainability.

♻️ More explicit slot calculation
-        slots = (facet_col == 'auto') + (animation_frame == 'auto')
+        # Count available slots for extra dimensions (facet_col and animation_frame)
+        available_slots = sum([facet_col == 'auto', animation_frame == 'auto'])
+        slots = available_slots

Or even simpler:

-        slots = (facet_col == 'auto') + (animation_frame == 'auto')
+        slots = sum([facet_col == 'auto', animation_frame == 'auto'])

2093-2136: The axis naming and +100 offset strategy are correct; consider adding validation for trace-to-axis mapping.

The implementation correctly leverages plotly's 'yaxis', 'yaxis2', etc. naming scheme for faceted subplots, and the +100 offset is a reasonable collision-avoidance strategy. However, the code assumes that line_fig.data traces are ordered to match the sorted primary axes without explicit validation.

While plotly.express typically generates faceted traces in consistent order, this mapping relies on undocumented behavior. If plotly's faceting logic changes, traces could be assigned to incorrect secondary axes without detection.

Add a simple assertion after creating the secondary axes:

assert len(line_fig.data) == len(primary_yaxes), \
    f"Trace count ({len(line_fig.data)}) != axis count ({len(primary_yaxes)})"

This catches mismatches early and makes the coupling to plotly's internal behavior explicit.

flixopt/optimize_accessor.py (1)

88-99: Consider moving imports to module level for consistency.

The deprecation logic is correct, but importing warnings and DEPRECATION_REMOVAL_VERSION inside the conditional block is inconsistent with the rest of the module's import style. While functional, moving them to the top would improve consistency.

♻️ Optional refactor

Move the imports to the module level (after line 17):

 from .config import CONFIG
+from .config import DEPRECATION_REMOVAL_VERSION
 from .io import suppress_output
+import warnings

Then simplify the conditional block:

 if normalize_weights is not None:
-    import warnings
-
-    from .config import DEPRECATION_REMOVAL_VERSION
-
     warnings.warn(
docs/notebooks/data/raw/README.md (1)

1-31: LGTM! Clear documentation of bundled data sources.

The README provides comprehensive information about the datasets including source, license, and column descriptions. The attribution and licensing information is properly documented, which is important for data provenance.

Optional: Format URLs as markdown links

For better markdown formatting, consider converting bare URLs to links:

-**URL:** https://re.jrc.ec.europa.eu/pvg_tools/en/
+**URL:** [PVGIS Tools](https://re.jrc.ec.europa.eu/pvg_tools/en/)
-**URL:** https://data.open-power-system-data.org/time_series/
+**URL:** [Open Power System Data - Time Series](https://data.open-power-system-data.org/time_series/)
-**Attribution required:** "Data from Open Power System Data. https://open-power-system-data.org"
+**Attribution required:** "Data from Open Power System Data. [https://open-power-system-data.org](https://open-power-system-data.org)"
tests/deprecated/examples/03_Optimization_modes/example_optimization_modes.py (1)

178-178: Type annotation doesn't include ClusteredResult (acceptable for deprecated code).

The type annotation list[fx.Optimization | fx.SegmentedOptimization] doesn't include ClusteredResult, but the code appends a ClusteredResult instance (line 217). This is acceptable because:

  1. This is in the deprecated/examples directory
  2. get_solutions() uses structural typing (duck typing) and only requires .name and .flow_system attributes
  3. Adding a formal type would require a Protocol or base class, which is overkill for deprecated code

Consider adding a comment explaining this if the type checker complains.

docs/notebooks/data/generate_example_systems.py (2)

20-43: Consider documenting the import handling strategy.

The try/except import pattern handles multiple execution contexts (direct run, package import, mkdocs-jupyter), which is good. However, this complexity could benefit from a brief comment explaining why both import strategies are needed and when each applies.

📝 Suggested documentation
-# Handle imports in different contexts (direct run, package import, mkdocs-jupyter)
+# Handle imports in different contexts:
+# 1. Relative import: when run as part of flixopt package (docs build, tests)
+# 2. Absolute import: when run directly or in mkdocs-jupyter (sys.path manipulation)
 try:
     from .generate_realistic_profiles import (

56-87: Global state in notebook/script context is acceptable; refactor only if this becomes library code.

The lazy-loading globals work well for single-threaded notebook execution where data is loaded once but reused across multiple example system generation functions. While pytest-xdist is available in the project, this file is not used in test suites. If this code is later refactored into reusable library functions or imported in parallel contexts, consider migrating to functools.lru_cache for thread safety.

docs/notebooks/data/tutorial_data.py (1)

48-49: Consider using distinct seeds across functions.

The seed 42 is reused in get_heat_system_data() (line 48), get_investment_data() (line 75), and get_scenarios_data() (line 227). While this ensures reproducibility within each function, the identical seeds mean these functions will produce the same random sequence, which could lead to unintended correlations in the generated noise patterns.

If this is intentional for consistency across tutorials, consider adding a brief comment explaining the choice. Otherwise, consider using unique seeds per function.

Also applies to: 75-76, 227-228

flixopt/optimization.py (1)

80-124: Missing deprecation warning for normalize_weights parameter.

The normalize_weights parameter is marked as deprecated in the docstring (line 98) and is now always set to True (line 110), but there's no deprecation warning emitted when a caller explicitly passes a value. The FlowSystem.create_model() method (from relevant snippets) does emit such a warning. Consider adding a warning here for consistency.

♻️ Suggested improvement
 def _initialize_optimization_common(
     obj: Any,
     name: str,
     flow_system: FlowSystem,
     folder: pathlib.Path | None = None,
     normalize_weights: bool | None = None,
 ) -> None:
     ...
     obj.name = name
 
     if flow_system.used_in_calculation:
         logger.warning(
             f'This FlowSystem is already used in an optimization:\n{flow_system}\n'
             f'Creating a copy of the FlowSystem for Optimization "{obj.name}".'
         )
         flow_system = flow_system.copy()
 
+    if normalize_weights is not None:
+        warnings.warn(
+            f'normalize_weights parameter is deprecated and will be removed in {DEPRECATION_REMOVAL_VERSION}. '
+            'Scenario weights are now always normalized when set on FlowSystem.',
+            DeprecationWarning,
+            stacklevel=3,
+        )
+
     # normalize_weights is deprecated but kept for backwards compatibility
     obj.normalize_weights = True  # Always True now
docs/notebooks/data/generate_realistic_profiles.py (1)

28-31: Avoid resetting global warnings filters at module import

Calling warnings.resetwarnings() on import can undo project-level warning configuration (e.g. in tests or user scripts). Prefer scoping any suppression to specific calls (via warnings.catch_warnings) or filtering only the exact warnings you want to silence from dependencies.

flixopt/structure.py (1)

438-447: Be more selective about invalidation for cosmetic Element properties

Interface._invalidate() is used by Element.meta_data and Element.color setters to call FlowSystem._invalidate_model() whenever these properties change. That has two side effects:

  • It tears down the model and resets connected_and_transformed, even though meta_data/color don’t affect optimization.
  • It can run on a locked FlowSystem (solution present), leading to a state where is_locked is still True but model has been cleared.

Consider one of these options:

  • Don’t invalidate on purely cosmetic fields (color, meta_data), and reserve _invalidate() for modeling‑relevant parameters only.
  • Or, have _invalidate() delegate to flow_system.invalidate() so modifications on a locked system raise consistently instead of silently half-resetting internals.

This keeps invalidation semantics aligned with the existing reset()/invalidate() contract while still providing safety for real parameter changes.

Also applies to: 1151-1182

flixopt/dataset_plot_accessor.py (1)

970-1018: DataArray heatmap duplicates logic from Dataset heatmap.

The heatmap method in DataArrayPlotAccessor duplicates most of the logic from DatasetPlotAccessor.heatmap. Consider refactoring to reduce duplication.

♻️ Consider extracting shared heatmap logic

The core heatmap logic (slot resolution, facet handling, dimension squeezing) is duplicated between DatasetPlotAccessor.heatmap and DataArrayPlotAccessor.heatmap. Consider extracting a shared helper function:

def _build_heatmap_figure(
    da: xr.DataArray,
    ds_for_slots: xr.Dataset,
    *,
    colors: str | list[str] | None,
    title: str,
    facet_col: str | Literal['auto'] | None,
    animation_frame: str | Literal['auto'] | None,
    facet_cols: int | None,
    **imshow_kwargs: Any,
) -> go.Figure:
    """Shared heatmap building logic for Dataset and DataArray accessors."""
    # ... shared implementation

This would allow both accessors to delegate to the same implementation, reducing maintenance burden.

📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f122288 and cf68233.

⛔ Files ignored due to path filters (2)
  • docs/notebooks/data/raw/electricity_prices_de_2020.csv is excluded by !**/*.csv
  • docs/notebooks/data/raw/tmy_dresden.csv is excluded by !**/*.csv
📒 Files selected for processing (79)
  • .github/workflows/docs.yaml
  • .pre-commit-config.yaml
  • CHANGELOG.md
  • CITATION.cff
  • docs/design/cluster_architecture.md
  • docs/notebooks/02-heat-system.ipynb
  • docs/notebooks/03-investment-optimization.ipynb
  • docs/notebooks/04-operational-constraints.ipynb
  • docs/notebooks/05-multi-carrier-system.ipynb
  • docs/notebooks/06a-time-varying-parameters.ipynb
  • docs/notebooks/07-scenarios-and-periods.ipynb
  • docs/notebooks/08a-aggregation.ipynb
  • docs/notebooks/08b-rolling-horizon.ipynb
  • docs/notebooks/08c-clustering.ipynb
  • docs/notebooks/08c2-clustering-storage-modes.ipynb
  • docs/notebooks/08d-clustering-multiperiod.ipynb
  • docs/notebooks/08e-clustering-internals.ipynb
  • docs/notebooks/09-plotting-and-data-access.ipynb
  • docs/notebooks/data/__init__.py
  • docs/notebooks/data/generate_example_systems.py
  • docs/notebooks/data/generate_realistic_profiles.py
  • docs/notebooks/data/raw/README.md
  • docs/notebooks/data/tutorial_data.py
  • docs/notebooks/fxplot_accessor_demo.ipynb
  • docs/user-guide/optimization/clustering.md
  • docs/user-guide/optimization/index.md
  • docs/user-guide/recipes/plotting-custom-data.md
  • docs/user-guide/results-plotting.md
  • docs/user-guide/results/index.md
  • flixopt/__init__.py
  • flixopt/clustering.py
  • flixopt/clustering/__init__.py
  • flixopt/clustering/base.py
  • flixopt/clustering/intercluster_helpers.py
  • flixopt/comparison.py
  • flixopt/components.py
  • flixopt/config.py
  • flixopt/core.py
  • flixopt/dataset_plot_accessor.py
  • flixopt/effects.py
  • flixopt/elements.py
  • flixopt/features.py
  • flixopt/flow_system.py
  • flixopt/interface.py
  • flixopt/io.py
  • flixopt/linear_converters.py
  • flixopt/optimization.py
  • flixopt/optimize_accessor.py
  • flixopt/plot_result.py
  • flixopt/results.py
  • flixopt/statistics_accessor.py
  • flixopt/structure.py
  • flixopt/transform_accessor.py
  • mkdocs.yml
  • pyproject.toml
  • tests/deprecated/examples/03_Optimization_modes/example_optimization_modes.py
  • tests/deprecated/test_bus.py
  • tests/deprecated/test_effect.py
  • tests/deprecated/test_flow.py
  • tests/deprecated/test_flow_system_resample.py
  • tests/deprecated/test_integration.py
  • tests/deprecated/test_linear_converter.py
  • tests/deprecated/test_scenarios.py
  • tests/deprecated/test_storage.py
  • tests/test_bus.py
  • tests/test_cluster_reduce_expand.py
  • tests/test_clustering/__init__.py
  • tests/test_clustering/test_base.py
  • tests/test_clustering/test_integration.py
  • tests/test_clustering_io.py
  • tests/test_effect.py
  • tests/test_flow.py
  • tests/test_flow_system_resample.py
  • tests/test_io_conversion.py
  • tests/test_linear_converter.py
  • tests/test_property_guards.py
  • tests/test_scenarios.py
  • tests/test_sel_isel_single_selection.py
  • tests/test_storage.py
💤 Files with no reviewable changes (1)
  • flixopt/clustering.py
🧰 Additional context used
🧬 Code graph analysis (21)
flixopt/optimize_accessor.py (1)
flixopt/flow_system.py (2)
  • FlowSystem (55-2512)
  • build_model (1355-1391)
flixopt/clustering/__init__.py (1)
flixopt/clustering/base.py (4)
  • Clustering (980-1135)
  • ClusterResult (248-544)
  • ClusterStructure (35-244)
  • create_cluster_structure_from_mapping (1138-1185)
tests/test_bus.py (1)
flixopt/structure.py (2)
  • timestep_duration (220-222)
  • timestep_duration (1793-1794)
tests/deprecated/test_flow_system_resample.py (1)
flixopt/structure.py (2)
  • timestep_duration (220-222)
  • timestep_duration (1793-1794)
flixopt/clustering/intercluster_helpers.py (1)
flixopt/interface.py (1)
  • InvestParameters (956-1385)
tests/deprecated/test_integration.py (4)
tests/conftest.py (1)
  • assert_almost_equal_numeric (696-725)
tests/deprecated/conftest.py (1)
  • assert_almost_equal_numeric (695-724)
flixopt/flow_system.py (2)
  • solution (1449-1466)
  • solution (1469-1472)
flixopt/structure.py (2)
  • solution (169-217)
  • solution (1196-1210)
flixopt/core.py (1)
flixopt/flow_system.py (1)
  • coords (2003-2021)
tests/deprecated/test_storage.py (1)
flixopt/structure.py (2)
  • timestep_duration (220-222)
  • timestep_duration (1793-1794)
tests/deprecated/test_bus.py (2)
flixopt/structure.py (3)
  • variables (1761-1767)
  • timestep_duration (220-222)
  • timestep_duration (1793-1794)
flixopt/results.py (2)
  • variables (335-339)
  • variables (1260-1268)
flixopt/features.py (2)
flixopt/flow_system.py (6)
  • temporal_weight (1992-2000)
  • temporal_dims (1982-1989)
  • sum_temporal (2139-2156)
  • dims (1938-1958)
  • weights (2117-2137)
  • coords (2003-2021)
flixopt/structure.py (11)
  • temporal_weight (255-257)
  • temporal_dims (247-252)
  • sum_temporal (259-265)
  • dims (229-231)
  • weights (239-244)
  • get (1700-1705)
  • get (1865-1867)
  • timestep_duration (220-222)
  • timestep_duration (1793-1794)
  • get_coords (294-329)
  • get_coords (1707-1712)
tests/deprecated/test_linear_converter.py (3)
flixopt/structure.py (4)
  • variables (1761-1767)
  • timestep_duration (220-222)
  • timestep_duration (1793-1794)
  • constraints (1752-1758)
tests/conftest.py (1)
  • assert_conequal (755-771)
tests/deprecated/conftest.py (1)
  • assert_conequal (754-770)
flixopt/linear_converters.py (1)
flixopt/structure.py (1)
  • _invalidate (438-446)
tests/deprecated/test_effect.py (1)
flixopt/structure.py (2)
  • timestep_duration (220-222)
  • timestep_duration (1793-1794)
tests/deprecated/test_scenarios.py (2)
flixopt/flow_system.py (2)
  • scenario_weights (2055-2062)
  • scenario_weights (2065-2104)
flixopt/structure.py (4)
  • scenario_weights (268-282)
  • flow_system (449-467)
  • values (1571-1576)
  • values (1858-1859)
docs/notebooks/data/generate_realistic_profiles.py (2)
flixopt/structure.py (2)
  • values (1571-1576)
  • values (1858-1859)
flixopt/transform_accessor.py (1)
  • resample (165-222)
flixopt/effects.py (2)
flixopt/structure.py (5)
  • _invalidate (438-446)
  • _fit_coords (469-482)
  • prefix (391-393)
  • _fit_effect_coords (484-502)
  • dims (229-231)
flixopt/flow_system.py (1)
  • dims (1938-1958)
flixopt/interface.py (2)
flixopt/structure.py (7)
  • _invalidate (438-446)
  • prefix (391-393)
  • _fit_effect_coords (484-502)
  • dims (229-231)
  • transform_data (375-388)
  • _fit_coords (469-482)
  • flow_system (449-467)
flixopt/effects.py (1)
  • transform_data (402-445)
flixopt/structure.py (2)
flixopt/flow_system.py (14)
  • FlowSystem (55-2512)
  • solution (1449-1466)
  • solution (1469-1472)
  • coords (2003-2021)
  • indexes (1961-1979)
  • dims (1938-1958)
  • weights (2117-2137)
  • temporal_dims (1982-1989)
  • temporal_weight (1992-2000)
  • sum_temporal (2139-2156)
  • scenario_weights (2055-2062)
  • scenario_weights (2065-2104)
  • _unit_weight (2106-2114)
  • _invalidate_model (1482-1503)
flixopt/carrier.py (1)
  • label (107-109)
flixopt/optimization.py (1)
flixopt/flow_system.py (2)
  • FlowSystem (55-2512)
  • create_model (1333-1353)
flixopt/__init__.py (1)
flixopt/comparison.py (1)
  • Comparison (27-191)
flixopt/dataset_plot_accessor.py (2)
flixopt/color_processing.py (1)
  • process_colors (165-233)
flixopt/config.py (2)
  • CONFIG (184-912)
  • Plotting (550-592)
🪛 LanguageTool
docs/design/cluster_architecture.md

[grammar] ~512-~512: Use a hyphen to join words.
Context: ...Goal: Produce true (cluster, time) shaped data. Tasks: 1. Update `clus...

(QB_NEW_EN_HYPHEN)

docs/user-guide/results/index.md

[style] ~333-~333: Using many exclamation marks might seem excessive (in this case: 9 exclamation marks for a text that’s 4730 characters long)
Context: ...Works! cluster_boundary is ignored ``` !!! note "Component Differences" System...

(EN_EXCESSIVE_EXCLAMATION)

🪛 markdownlint-cli2 (0.18.1)
docs/design/cluster_architecture.md

37-37: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

CHANGELOG.md

74-74: Code block style
Expected: indented; Actual: fenced

(MD046, code-block-style)


121-121: Code block style
Expected: indented; Actual: fenced

(MD046, code-block-style)


149-149: Code block style
Expected: indented; Actual: fenced

(MD046, code-block-style)


189-189: Code block style
Expected: indented; Actual: fenced

(MD046, code-block-style)

docs/notebooks/data/raw/README.md

10-10: Bare URL used

(MD034, no-bare-urls)


26-26: Bare URL used

(MD034, no-bare-urls)


28-28: Bare URL used

(MD034, no-bare-urls)

Comment on lines 293 to 415
def create_district_heating_system() -> fx.FlowSystem:
"""Create a district heating system with BDEW profiles.
Uses realistic German data:
- One month (January 2020), hourly resolution
- BDEW industrial heat profile
- BDEW commercial electricity profile
- OPSD electricity prices
- Seasonal gas prices
- CHP, boiler, storage, and grid connections
- Investment optimization for sizing
Used by: 08a-aggregation, 08c-clustering, 08e-clustering-internals notebooks
"""
# One month, hourly
timesteps = pd.date_range('2020-01-01', '2020-01-31 23:00:00', freq='h')
temp = _get_weather()['temperature_C'].reindex(timesteps, method='ffill').values

# BDEW profiles (MW scale for district heating)
thermal_gen = ThermalLoadGenerator()
heat_demand = thermal_gen.generate(timesteps, temp, 'industrial', annual_demand_kwh=15_000_000) / 1000 # MW

elec_gen = ElectricityLoadGenerator()
electricity_demand = elec_gen.generate(timesteps, 'commercial', annual_demand_kwh=5_000_000) / 1000 # MW

# Prices
electricity_price = _get_elec_prices().reindex(timesteps, method='ffill').values # EUR/MWh
gas_gen = GasPriceGenerator()
gas_price = gas_gen.generate(timesteps) # EUR/MWh

fs = fx.FlowSystem(timesteps)
fs.add_elements(
# Buses
fx.Bus('Electricity'),
fx.Bus('Heat'),
fx.Bus('Gas'),
fx.Bus('Coal'),
# Effects
fx.Effect('costs', '€', 'Total Costs', is_standard=True, is_objective=True),
fx.Effect('CO2', 'kg', 'CO2 Emissions'),
# CHP unit with investment
fx.linear_converters.CHP(
'CHP',
thermal_efficiency=0.58,
electrical_efficiency=0.22,
electrical_flow=fx.Flow('P_el', bus='Electricity', size=200),
thermal_flow=fx.Flow(
'Q_th',
bus='Heat',
size=fx.InvestParameters(
minimum_size=100,
maximum_size=300,
effects_of_investment_per_size={'costs': 10},
),
relative_minimum=0.3,
status_parameters=fx.StatusParameters(),
),
fuel_flow=fx.Flow('Q_fu', bus='Coal'),
),
# Gas Boiler with investment
fx.linear_converters.Boiler(
'Boiler',
thermal_efficiency=0.85,
thermal_flow=fx.Flow(
'Q_th',
bus='Heat',
size=fx.InvestParameters(
minimum_size=0,
maximum_size=150,
effects_of_investment_per_size={'costs': 5},
),
relative_minimum=0.1,
status_parameters=fx.StatusParameters(),
),
fuel_flow=fx.Flow('Q_fu', bus='Gas'),
),
# Thermal Storage with investment
fx.Storage(
'Storage',
capacity_in_flow_hours=fx.InvestParameters(
minimum_size=0,
maximum_size=1000,
effects_of_investment_per_size={'costs': 0.5},
),
initial_charge_state=0,
eta_charge=1,
eta_discharge=1,
relative_loss_per_hour=0.001,
charging=fx.Flow('Charge', size=137, bus='Heat'),
discharging=fx.Flow('Discharge', size=158, bus='Heat'),
),
# Fuel sources
fx.Source(
'GasGrid',
outputs=[fx.Flow('Q_Gas', bus='Gas', size=1000, effects_per_flow_hour={'costs': gas_price, 'CO2': 0.3})],
),
fx.Source(
'CoalSupply',
outputs=[fx.Flow('Q_Coal', bus='Coal', size=1000, effects_per_flow_hour={'costs': 4.6, 'CO2': 0.3})],
),
# Electricity grid
fx.Source(
'GridBuy',
outputs=[
fx.Flow(
'P_el',
bus='Electricity',
size=1000,
effects_per_flow_hour={'costs': electricity_price + 0.5, 'CO2': 0.3},
)
],
),
fx.Sink(
'GridSell',
inputs=[fx.Flow('P_el', bus='Electricity', size=1000, effects_per_flow_hour=-(electricity_price - 0.5))],
),
# Demands
fx.Sink('HeatDemand', inputs=[fx.Flow('Q_th', bus='Heat', size=1, fixed_relative_profile=heat_demand)]),
fx.Sink(
'ElecDemand', inputs=[fx.Flow('P_el', bus='Electricity', size=1, fixed_relative_profile=electricity_demand)]
),
)
return fs
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# First, find the file and check its structure
fd "generate_example_systems" --type f

# Search for investment parameters across the codebase
rg "effects_of_investment_per_size" --type=py -A2 -B2

Repository: flixOpt/flixopt

Length of output: 33452


🏁 Script executed:

#!/bin/bash
# Get the district heating system context with more lines
sed -n '293,415p' docs/notebooks/data/generate_example_systems.py | head -70

# Also check if there are comments or documentation for these cost values
sed -n '340,385p' docs/notebooks/data/generate_example_systems.py

Repository: flixOpt/flixopt

Length of output: 4359


🏁 Script executed:

#!/bin/bash
# Look for other heating systems or comparison examples
grep -n "CHP\|Boiler\|create_.*system" docs/notebooks/data/generate_example_systems.py | head -20

# Check context around lines with high investment costs to see the pattern
grep -B5 -A2 "effects_of_investment_per_size.*15000\|effects_of_investment_per_size.*20000" docs/notebooks/data/generate_example_systems.py

Repository: flixOpt/flixopt

Length of output: 1582


Add documentation or reconsider investment cost values for consistency with other MW-scale systems.

The district heating system's investment parameters (CHP: 10, Boiler: 5, Storage: 0.5 €/unit) lack explanatory comments and are 1500–40000× lower than comparable MW-scale renewable systems in the same file (Solar: 15000 €/MW, Wind: 20000 €/MW, both documented as "annualized"). Either clarify the cost units and assumptions for these values to match the pattern used elsewhere, or verify the values themselves are intentionally lower and document why.

Comment on lines 543 to 547
# Full year, hourly (use non-leap year to match TMY data which has 8760 hours)
timesteps = pd.date_range('2019-01-01', periods=8760, freq='h')
# Map to 2020 weather data (TMY has 8760 hours, no Feb 29)
temp = _get_weather()['temperature_C'].values
ghi = _get_weather()['ghi_W_m2'].values
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Clarify comment about weather data year mapping.

The comment at line 546 states "Map to 2020 weather data" but the timesteps are generated for 2019 (line 544). This appears intentional (to match TMY data's 8760 hours), but the comment could be clearer:

📝 Suggested clarification
-    # Map to 2020 weather data (TMY has 8760 hours, no Feb 29)
+    # Use 2020 weather data (TMY typical year with 8760 hours, no Feb 29)
+    # Note: timesteps use 2019 to ensure 8760 hours (2020 is a leap year with 8784 hours)
     temp = _get_weather()['temperature_C'].values
🤖 Prompt for AI Agents
In @docs/notebooks/data/generate_example_systems.py around lines 543 - 547, The
comment mismatches the generated timesteps and the described year: update the
comment around the pd.date_range(...) and _get_weather() usage to clearly state
that timesteps are generated for a non‑leap reference year (2019) to match TMY's
8760 hours and that the temperature and ghi arrays are from the TMY-like weather
returned by _get_weather(), e.g., clarify that we "map TMY (8760-hour) weather
from _get_weather() onto a 2019 non‑leap hourly index (pd.date_range(...))" so
variables timesteps, temp, and ghi are unambiguous.

Comment on lines 185 to 235
def generate_pv_profile(
self,
timesteps: pd.DatetimeIndex,
weather: pd.DataFrame,
surface_tilt: float = 35,
surface_azimuth: float = 180, # South-facing
capacity_kw: float = 1.0,
) -> np.ndarray:
"""Generate PV power output profile.
Parameters
----------
timesteps
Time index for the profile
weather
Weather data with 'ghi_W_m2', 'dni_W_m2', 'dhi_W_m2', 'temperature_C'
surface_tilt
Panel tilt angle in degrees (0=horizontal, 90=vertical)
surface_azimuth
Panel azimuth in degrees (180=south, 90=east, 270=west)
capacity_kw
Installed PV capacity in kW
Returns
-------
np.ndarray
PV power output in kW
"""
# Ensure weather is aligned with timesteps
weather = weather.reindex(timesteps, method='ffill')

# Get solar position
solar_position = self.location.get_solarposition(timesteps)

# Calculate plane-of-array irradiance
poa = pvlib.irradiance.get_total_irradiance(
surface_tilt=surface_tilt,
surface_azimuth=surface_azimuth,
solar_zenith=solar_position['apparent_zenith'],
solar_azimuth=solar_position['azimuth'],
dni=weather['dni_W_m2'],
ghi=weather['ghi_W_m2'],
dhi=weather['dhi_W_m2'],
)

# Simple efficiency model: ~15% module efficiency, ~85% system efficiency
system_efficiency = 0.15 * 0.85
pv_output = poa['poa_global'] * system_efficiency * capacity_kw / 1000

return np.clip(pv_output.fillna(0).values, 0, capacity_kw)

Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

PV capacity scaling is off by a large factor

pv_output = poa['poa_global'] * system_efficiency * capacity_kw / 1000 gives a peak output of only ~0.1275 × capacity_kw at 1000 W/m², even before clipping. For capacity_kw=1, the maximum is ~0.13 kW, not 1 kW.

If capacity_kw is intended as installed DC capacity (kWp), the usual relation is:

[
P_\text{out} [\text{kW}] \approx \text{POA}[W/m^2] \times \frac{\text{capacity_kw}}{1000}
]

i.e. capacity already encodes module+system efficiency. The extra system_efficiency factor should either be removed or folded into how capacity_kw is defined.

A minimal fix that treats capacity_kw as kWp:

-        system_efficiency = 0.15 * 0.85
-        pv_output = poa['poa_global'] * system_efficiency * capacity_kw / 1000
+        # Treat `capacity_kw` as installed DC capacity (kWp).
+        # Scale POA irradiance (W/m²) to kW using 1000 W/m² reference.
+        pv_output = poa['poa_global'] * capacity_kw / 1000

Optionally add a separate performance-ratio parameter if you want to model additional system losses explicitly.

📝 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
def generate_pv_profile(
self,
timesteps: pd.DatetimeIndex,
weather: pd.DataFrame,
surface_tilt: float = 35,
surface_azimuth: float = 180, # South-facing
capacity_kw: float = 1.0,
) -> np.ndarray:
"""Generate PV power output profile.
Parameters
----------
timesteps
Time index for the profile
weather
Weather data with 'ghi_W_m2', 'dni_W_m2', 'dhi_W_m2', 'temperature_C'
surface_tilt
Panel tilt angle in degrees (0=horizontal, 90=vertical)
surface_azimuth
Panel azimuth in degrees (180=south, 90=east, 270=west)
capacity_kw
Installed PV capacity in kW
Returns
-------
np.ndarray
PV power output in kW
"""
# Ensure weather is aligned with timesteps
weather = weather.reindex(timesteps, method='ffill')
# Get solar position
solar_position = self.location.get_solarposition(timesteps)
# Calculate plane-of-array irradiance
poa = pvlib.irradiance.get_total_irradiance(
surface_tilt=surface_tilt,
surface_azimuth=surface_azimuth,
solar_zenith=solar_position['apparent_zenith'],
solar_azimuth=solar_position['azimuth'],
dni=weather['dni_W_m2'],
ghi=weather['ghi_W_m2'],
dhi=weather['dhi_W_m2'],
)
# Simple efficiency model: ~15% module efficiency, ~85% system efficiency
system_efficiency = 0.15 * 0.85
pv_output = poa['poa_global'] * system_efficiency * capacity_kw / 1000
return np.clip(pv_output.fillna(0).values, 0, capacity_kw)
def generate_pv_profile(
self,
timesteps: pd.DatetimeIndex,
weather: pd.DataFrame,
surface_tilt: float = 35,
surface_azimuth: float = 180, # South-facing
capacity_kw: float = 1.0,
) -> np.ndarray:
"""Generate PV power output profile.
Parameters
----------
timesteps
Time index for the profile
weather
Weather data with 'ghi_W_m2', 'dni_W_m2', 'dhi_W_m2', 'temperature_C'
surface_tilt
Panel tilt angle in degrees (0=horizontal, 90=vertical)
surface_azimuth
Panel azimuth in degrees (180=south, 90=east, 270=west)
capacity_kw
Installed PV capacity in kW
Returns
-------
np.ndarray
PV power output in kW
"""
# Ensure weather is aligned with timesteps
weather = weather.reindex(timesteps, method='ffill')
# Get solar position
solar_position = self.location.get_solarposition(timesteps)
# Calculate plane-of-array irradiance
poa = pvlib.irradiance.get_total_irradiance(
surface_tilt=surface_tilt,
surface_azimuth=surface_azimuth,
solar_zenith=solar_position['apparent_zenith'],
solar_azimuth=solar_position['azimuth'],
dni=weather['dni_W_m2'],
ghi=weather['ghi_W_m2'],
dhi=weather['dhi_W_m2'],
)
# Treat `capacity_kw` as installed DC capacity (kWp).
# Scale POA irradiance (W/m²) to kW using 1000 W/m² reference.
pv_output = poa['poa_global'] * capacity_kw / 1000
return np.clip(pv_output.fillna(0).values, 0, capacity_kw)
🤖 Prompt for AI Agents
In @docs/notebooks/data/generate_realistic_profiles.py around lines 185 - 235,
The PV output scaling in generate_pv_profile is incorrect because
system_efficiency is applied on top of capacity_kw, causing peak power to be
~0.1275× capacity_kw; remove the hardcoded system_efficiency multiplier (or add
a new parameter performance_ratio with default=1.0) and compute pv_output as
poa['poa_global'] * capacity_kw / 1000 (optionally multiply by performance_ratio
if you want to model losses). Update references to pv_output and the final
clipping so values are in kW and clipped to [0, capacity_kw], and document the
new performance_ratio parameter if added.

Comment on lines 110 to 133
def _create_reference_structure(self) -> tuple[dict, dict[str, xr.DataArray]]:
"""Create reference structure for serialization."""
ref = {'__class__': self.__class__.__name__}
arrays = {}

# Store DataArrays with references
arrays[str(self.cluster_order.name)] = self.cluster_order
ref['cluster_order'] = f':::{self.cluster_order.name}'

arrays[str(self.cluster_occurrences.name)] = self.cluster_occurrences
ref['cluster_occurrences'] = f':::{self.cluster_occurrences.name}'

# Store scalar values
if isinstance(self.n_clusters, xr.DataArray):
n_clusters_name = self.n_clusters.name or 'n_clusters'
self.n_clusters = self.n_clusters.rename(n_clusters_name)
arrays[n_clusters_name] = self.n_clusters
ref['n_clusters'] = f':::{n_clusters_name}'
else:
ref['n_clusters'] = int(self.n_clusters)

ref['timesteps_per_cluster'] = self.timesteps_per_cluster

return ref, arrays
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Potential mutation of self.n_clusters during serialization.

Line 125 mutates self.n_clusters by renaming it. Since this is a dataclass with n_clusters: int | xr.DataArray, mutating it during _create_reference_structure could have side effects if the method is called multiple times.

🐛 Suggested fix: Avoid mutating self
         if isinstance(self.n_clusters, xr.DataArray):
             n_clusters_name = self.n_clusters.name or 'n_clusters'
-            self.n_clusters = self.n_clusters.rename(n_clusters_name)
-            arrays[n_clusters_name] = self.n_clusters
+            n_clusters_da = self.n_clusters.rename(n_clusters_name)
+            arrays[n_clusters_name] = n_clusters_da
             ref['n_clusters'] = f':::{n_clusters_name}'
🤖 Prompt for AI Agents
In @flixopt/clustering/base.py around lines 110 - 133, The method
_create_reference_structure mutates self.n_clusters by assigning a renamed
DataArray back to it; avoid this by creating a local renamed copy instead of
reassigning to self.n_clusters. Inside the isinstance(self.n_clusters,
xr.DataArray) branch, compute n_clusters_name = self.n_clusters.name or
'n_clusters', set renamed = self.n_clusters.rename(n_clusters_name) (do not
assign back to self.n_clusters), add arrays[n_clusters_name] = renamed and
ref['n_clusters'] = f':::{n_clusters_name}' so you preserve the original
attribute and still serialize the renamed copy; keep the rest of the method
(cluster_order, cluster_occurrences, timesteps_per_cluster) unchanged.

Comment on lines 331 to 337
if isinstance(self.n_representatives, xr.DataArray):
n_rep_name = self.n_representatives.name or 'n_representatives'
self.n_representatives = self.n_representatives.rename(n_rep_name)
arrays[n_rep_name] = self.n_representatives
ref['n_representatives'] = f':::{n_rep_name}'
else:
ref['n_representatives'] = int(self.n_representatives)
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Same mutation issue in ClusterResult._create_reference_structure.

Similar to ClusterStructure, line 333 mutates self.n_representatives during serialization.

🐛 Suggested fix
         if isinstance(self.n_representatives, xr.DataArray):
             n_rep_name = self.n_representatives.name or 'n_representatives'
-            self.n_representatives = self.n_representatives.rename(n_rep_name)
-            arrays[n_rep_name] = self.n_representatives
+            n_rep_da = self.n_representatives.rename(n_rep_name)
+            arrays[n_rep_name] = n_rep_da
             ref['n_representatives'] = f':::{n_rep_name}'
🤖 Prompt for AI Agents
In @flixopt/clustering/base.py around lines 331 - 337,
ClusterResult._create_reference_structure currently mutates
self.n_representatives when it's an xr.DataArray; instead create a local renamed
copy and register that in arrays without assigning back to self: if
isinstance(self.n_representatives, xr.DataArray) then compute n_rep_name, make a
renamed copy (e.g., n_representatives_da =
self.n_representatives.rename(n_rep_name)), store arrays[n_rep_name] =
n_representatives_da and set ref['n_representatives'] = f':::{n_rep_name}';
otherwise keep ref['n_representatives'] = int(self.n_representatives). Ensure
you do not reassign to self.n_representatives anywhere in
_create_reference_structure.

Comment on lines 49 to 53
# Default upper bound for unbounded storage capacity.
# Used when no explicit capacity or InvestParameters.maximum_size is provided.
# Set to 1e6 to avoid numerical issues with very large bounds while still
# being effectively unbounded for most practical applications.
DEFAULT_UNBOUNDED_CAPACITY = 1e6
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Documentation inconsistency: default bound value.

The docstring on line 87 mentions 1e9 as the default upper bound, but the actual constant DEFAULT_UNBOUNDED_CAPACITY on line 53 is set to 1e6.

📝 Suggested fix
-    - **None/Unbounded**: Upper bound is set to a large value (1e9).
+    - **None/Unbounded**: Upper bound is set to a large value (1e6).

Also applies to: 87-88

🤖 Prompt for AI Agents
In @flixopt/clustering/intercluster_helpers.py around lines 49 - 53, The
module-level constant DEFAULT_UNBOUNDED_CAPACITY is set to 1e6 but the docstring
later refers to 1e9; make them consistent by updating the docstring text that
mentions the default upper bound (the paragraph referencing
InvestParameters.maximum_size) to state 1e6 instead of 1e9 so the documentation
matches DEFAULT_UNBOUNDED_CAPACITY.

Comment on lines 1293 to 1334
self,
soc_boundary_per_timestep: xr.DataArray,
storage_name: str,
cluster_structure,
original_timesteps_extra: pd.DatetimeIndex,
original_cluster_indices: np.ndarray,
timesteps_per_cluster: int,
) -> xr.DataArray:
"""Apply self-discharge decay to SOC_boundary values.
Args:
soc_boundary_per_timestep: SOC boundary values mapped to each timestep.
storage_name: Name of the storage component.
cluster_structure: ClusterStructure with cluster order info.
original_timesteps_extra: Original timesteps including final extra timestep.
original_cluster_indices: Mapping of timesteps to original cluster indices.
timesteps_per_cluster: Number of timesteps per cluster.
Returns:
SOC boundary values with decay applied.
"""
storage = self._fs.storages.get(storage_name)
if storage is None:
return soc_boundary_per_timestep

n_timesteps = len(original_timesteps_extra)

# Time within period for each timestep (0, 1, 2, ..., T-1, 0, 1, ...)
time_within_period = np.arange(n_timesteps) % timesteps_per_cluster
time_within_period[-1] = timesteps_per_cluster # Extra timestep gets full decay
time_within_period_da = xr.DataArray(
time_within_period, dims=['time'], coords={'time': original_timesteps_extra}
)

# Decay factor: (1 - loss)^t
loss_value = storage.relative_loss_per_hour.mean('time')
if not (loss_value > 0).any():
return soc_boundary_per_timestep

decay_da = (1 - loss_value) ** time_within_period_da

# Handle cluster dimension if present
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, let's locate and read the specific method to see the exact code
sed -n '1293,1334p' flixopt/transform_accessor.py

Repository: flixOpt/flixopt

Length of output: 1815


🏁 Script executed:

# Also check the context around this method to understand loss_value's type
sed -n '1280,1340p' flixopt/transform_accessor.py

Repository: flixOpt/flixopt

Length of output: 2769


🏁 Script executed:

# Let's verify the structure of storage.relative_loss_per_hour to understand its type
rg "relative_loss_per_hour" -A 2 -B 2

Repository: flixOpt/flixopt

Length of output: 23171


🏁 Script executed:

# Check what type storage objects have and where they're defined
rg "class.*[Ss]torage" -A 5

Repository: flixOpt/flixopt

Length of output: 8215


🏁 Script executed:

python3 << 'EOF'
import xarray as xr
import numpy as np

# Simulate what happens in the code
time_coords = ['t1', 't2', 't3']
loss_data = np.array([0.01, 0.01, 0.01])  # Example loss values
loss_da = xr.DataArray(loss_data, dims=['time'], coords={'time': time_coords})

# What the code does
loss_value = loss_da.mean('time')
print(f"loss_value type: {type(loss_value)}")
print(f"loss_value: {loss_value}")

# Try the problematic operation
try:
    result = (loss_value > 0).any()
    print(f"result of (loss_value > 0).any(): {result}")
    print(f"result type: {type(result)}")
    
    # Now try using it in an if statement
    if not result:
        print("In if statement: result is falsy")
    else:
        print("In if statement: result is truthy")
except ValueError as e:
    print(f"Error during comparison: {e}")

# Test with zero loss (the case mentioned in review)
loss_da_zero = xr.DataArray([0.0, 0.0, 0.0], dims=['time'], coords={'time': time_coords})
loss_value_zero = loss_da_zero.mean('time')
try:
    result_zero = (loss_value_zero > 0).any()
    print(f"\nZero loss case - result: {result_zero}, type: {type(result_zero)}")
    if not result_zero:
        print("Zero loss: would return early (correct behavior)")
except ValueError as e:
    print(f"Error with zero loss: {e}")
EOF

Repository: flixOpt/flixopt

Length of output: 177


🏁 Script executed:

# Check if there are tests that exercise _apply_soc_decay
rg "_apply_soc_decay|expand_solution" tests/ -l

Repository: flixOpt/flixopt

Length of output: 122


🏁 Script executed:

# Check the exact line with the issue to verify the context
sed -n '1320,1325p' flixopt/transform_accessor.py

Repository: flixOpt/flixopt

Length of output: 446


🏁 Script executed:

# Check if the test suite actually calls _apply_soc_decay via expand_solution
rg "expand_solution" tests/test_cluster_reduce_expand.py -B 2 -A 5

Repository: flixOpt/flixopt

Length of output: 7937


🏁 Script executed:

# Look at one of the tests more carefully to see if it exercises soc_decay
sed -n '1,100p' tests/test_cluster_reduce_expand.py | head -60

Repository: flixOpt/flixopt

Length of output: 2056


🏁 Script executed:

# Check if there's a test with relative_loss_per_hour that calls expand
rg "relative_loss_per_hour.*0\." tests/test_cluster_reduce_expand.py -B 5 -A 10

Repository: flixOpt/flixopt

Length of output: 1781


🏁 Script executed:

# Find where expand_solution calls _apply_soc_decay
rg "def expand_solution" flixopt/transform_accessor.py -A 100 | head -120

Repository: flixOpt/flixopt

Length of output: 4958


🏁 Script executed:

# Check if _apply_soc_decay is actually called from expand_solution
sed -n '1400,1500p' flixopt/transform_accessor.py

Repository: flixOpt/flixopt

Length of output: 4636


🏁 Script executed:

# Look for where _apply_soc_decay is invoked to see the code flow
rg "_apply_soc_decay" flixopt/transform_accessor.py -B 3 -A 3

Repository: flixOpt/flixopt

Length of output: 601


🏁 Script executed:

# Let's look at the actual test that exercises _apply_soc_decay with self-discharge
sed -n '/test_expand_solution_soc_decay/,/^    def /p' tests/test_cluster_reduce_expand.py | head -50

Repository: flixOpt/flixopt

Length of output: 41


🏁 Script executed:

# Check if there's a test with loss_rate = 0.0 (the zero loss case)
rg "relative_loss_per_hour.*=.*0" tests/test_cluster_reduce_expand.py -B 2 -A 8

Repository: flixOpt/flixopt

Length of output: 954


🏁 Script executed:

# Look at the actual behavior when xarray comparison result is used in if statement
# by checking xarray documentation or source to understand the error
python3 << 'EOF'
# Simulate the xarray behavior without importing xarray
# When you call .any() on a DataArray comparison result, it returns a DataArray
# Using DataArray in boolean context raises ValueError

# Here's what would happen:
import numpy as np

class FakeDataArray:
    def __init__(self, value):
        self.value = value
    
    def __bool__(self):
        raise ValueError("The truth value of a DataArray is ambiguous. Use a.any() or a.all().")
    
    def __nonzero__(self):
        raise ValueError("The truth value of a DataArray is ambiguous. Use a.any() or a.all().")

# This simulates what xarray does
loss_da = FakeDataArray(0.0)
try:
    if not loss_da:
        print("This won't be reached")
except ValueError as e:
    print(f"Error raised as expected: {e}")

# Show what the fix does
print("\nThe fix uses .values to extract numpy array:")
class FixedDataArray:
    def __init__(self, value):
        self._value = value
    
    @property
    def values(self):
        return np.array([self._value])

fixed_da = FixedDataArray(0.0)
loss_value_values = fixed_da.values
if not np.any((loss_value_values > 0)):
    print("With .values fix: correctly returns early for zero loss")
EOF

Repository: flixOpt/flixopt

Length of output: 176


🏁 Script executed:

# Let's find the exact test that exercises SOC decay expansion
sed -n '/def test_expand_solution_soc_decay_with_loss/,/^    def test_/p' tests/test_cluster_reduce_expand.py | head -80

Repository: flixOpt/flixopt

Length of output: 41


🏁 Script executed:

# Verify that this test would call _apply_soc_decay by checking the storage config
sed -n '1,50p' tests/test_cluster_reduce_expand.py | grep -A 20 "class.*Storage"

Repository: flixOpt/flixopt

Length of output: 41


🏁 Script executed:

# Check if there's actually a test case with cluster_mode='intercluster_cyclic' that exercises _apply_soc_decay
rg "cluster_mode.*intercluster" tests/test_cluster_reduce_expand.py -B 2 -A 15 | head -80

Repository: flixOpt/flixopt

Length of output: 4136


🏁 Script executed:

# Let's see the full test that should exercise the code
sed -n '/def test_expand_solution_combines_soc_boundary_with_charge_state/,/^    def test_/p' tests/test_cluster_reduce_expand.py | head -100

Repository: flixOpt/flixopt

Length of output: 1128


🏁 Script executed:

# Also check the test with loss (0.01)
sed -n '/def test_expand_solution_soc_decay_with_loss/,/^    def test_/p' tests/test_cluster_reduce_expand.py | head -100

Repository: flixOpt/flixopt

Length of output: 41


🏁 Script executed:

# Let's trace through the code - when expand_solution is called, does it call _combine_intercluster_charge_states?
sed -n '1440,1470p' flixopt/transform_accessor.py

Repository: flixOpt/flixopt

Length of output: 1624


🏁 Script executed:

# Check _combine_intercluster_charge_states to confirm it calls _apply_soc_decay
sed -n '1350,1400p' flixopt/transform_accessor.py

Repository: flixOpt/flixopt

Length of output: 2479


🏁 Script executed:

# Final check: look at the exact lines where _apply_soc_decay is called
sed -n '1370,1385p' flixopt/transform_accessor.py

Repository: flixOpt/flixopt

Length of output: 685


🏁 Script executed:

# Search for the actual call to _apply_soc_decay in _combine_intercluster_charge_states
sed -n '1320,1360p' flixopt/transform_accessor.py

Repository: flixOpt/flixopt

Length of output: 2037


🏁 Script executed:

# Get full _combine_intercluster_charge_states method
sed -n '/def _combine_intercluster_charge_states/,/def _apply_soc_decay/p' flixopt/transform_accessor.py | head -100

Repository: flixOpt/flixopt

Length of output: 3375


🏁 Script executed:

# Check what happens when you use a DataArray in boolean context
# by looking at what xarray.DataArray.__bool__ does
rg "def __bool__|def __nonzero__" --type py -A 3

Repository: flixOpt/flixopt

Length of output: 41


🏁 Script executed:

# Verify the test uses intercluster storage which calls _apply_soc_decay
grep -A 15 "test_expand_solution_combines_soc_boundary_with_charge_state" tests/test_cluster_reduce_expand.py | head -20

Repository: flixOpt/flixopt

Length of output: 1032


🏁 Script executed:

# Confirm the storage is created with cluster_mode='intercluster_cyclic'
grep -B 5 "test_expand_solution_combines_soc_boundary_with_charge_state" tests/test_cluster_reduce_expand.py | grep "cluster_mode"

Repository: flixOpt/flixopt

Length of output: 41


🏁 Script executed:

# Let me check the xarray version and understand the exact error behavior
cat flixopt/requirements.txt | grep -i xarray

Repository: flixOpt/flixopt

Length of output: 115


🏁 Script executed:

# Also check if there's already handling for this in the codebase
rg "loss_value.*\.values|loss_value.*\.item()" flixopt/ -B 2 -A 2

Repository: flixOpt/flixopt

Length of output: 41


🏁 Script executed:

# Check similar patterns in the codebase to see how other boolean checks on DataArrays are handled
rg "if.*\.any\(\)" flixopt/ -B 2 -A 2 | head -50

Repository: flixOpt/flixopt

Length of output: 3608


🏁 Script executed:

# Check the default value used by create_system_with_storage in the test
rg "def create_system_with_storage" tests/test_cluster_reduce_expand.py -A 10 | head -15

Repository: flixOpt/flixopt

Length of output: 546


Fix boolean check on xarray DataArray in _apply_soc_decay

loss_value is an xarray.DataArray; (loss_value > 0).any() also returns a DataArray. Using it directly in if not (loss_value > 0).any(): raises:

ValueError: The truth value of a DataArray is ambiguous. Use a.any() or a.all().

This triggers whenever _apply_soc_decay is called during expansion of intercluster storage solutions, preventing the intended early return when self-discharge is zero.

Convert to numpy array before calling .any():

        loss_value = storage.relative_loss_per_hour.mean('time')
-       if not (loss_value > 0).any():
+       if not np.any(loss_value.values > 0):
            return soc_boundary_per_timestep
🤖 Prompt for AI Agents
In @flixopt/transform_accessor.py around lines 1293 - 1334, The boolean check on
the xarray DataArray loss_value inside _apply_soc_decay is ambiguous; replace
the current if not (loss_value > 0).any(): with a concrete scalar check by
converting the DataArray to a numpy/scalar before testing (e.g., use
loss_value.values.any() or bool((loss_value > 0).values.any()) or
loss_value.any().item()), so the early return when self-discharge is zero works
correctly and avoids the ValueError raised by evaluating a DataArray in a
boolean context.

Comment on lines 192 to 217
# Use the new transform.cluster() API
# Note: time_series_for_high_peaks/low_peaks expect string labels matching dataset variables
time_series_for_high_peaks = ['Wärmelast(Q_th_Last)|fixed_relative_profile'] if keep_extreme_periods else None
time_series_for_low_peaks = (
['Stromlast(P_el_Last)|fixed_relative_profile', 'Wärmelast(Q_th_Last)|fixed_relative_profile']
if keep_extreme_periods
else None
)

clustered_fs = flow_system.copy().transform.cluster(
n_clusters=n_clusters,
cluster_duration=cluster_duration,
time_series_for_high_peaks=time_series_for_high_peaks,
time_series_for_low_peaks=time_series_for_low_peaks,
)
clustered_fs.optimize(fx.solvers.HighsSolver(0.01 / 100, 60))

# Wrap in a simple object for compatibility with comparison code
class ClusteredResult:
def __init__(self, name, fs):
self.name = name
self.flow_system = fs
self.durations = {'total': 0} # Placeholder

optimization = ClusteredResult('Clustered', clustered_fs)
optimizations.append(optimization)
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for fixed_relative_profile usage
rg -n "fixed_relative_profile" --type=py -A2 -B2

Repository: flixOpt/flixopt

Length of output: 48067


🏁 Script executed:

#!/bin/bash
# Search for ClusteredResult or similar wrapper classes
rg -n "class.*Result|class.*Clustered" --type=py -A5

Repository: flixOpt/flixopt

Length of output: 7547


🏁 Script executed:

#!/bin/bash
# Look for time_series labels or dataset variable definitions
rg -n "time_series_for_high_peaks|time_series_for_low_peaks" --type=py -B2 -A2

Repository: flixOpt/flixopt

Length of output: 7719


🏁 Script executed:

#!/bin/bash
# Search for the transform.cluster API definition
rg -n "def cluster" --type=py -A10

Repository: flixOpt/flixopt

Length of output: 5257


🏁 Script executed:

#!/bin/bash
# Look for solve duration capture in optimization code
rg -n "solve.*duration|duration.*solve|elapsed.*time" --type=py -i

Repository: flixOpt/flixopt

Length of output: 608


Time-series labels are correct; capture actual optimize() duration in the wrapper.

The time-series label format 'Wärmelast(Q_th_Last)|fixed_relative_profile' correctly matches the dataset variable names and follows the ComponentLabel(FlowLabel)|fixed_relative_profile pattern used throughout the codebase.

However, the ClusteredResult wrapper should capture the actual solve time instead of using a placeholder:

Replace the wrapper with:
# Wrap in a simple object for compatibility with comparison code
class ClusteredResult:
    def __init__(self, name, fs, optimization):
        self.name = name
        self.flow_system = fs
        self.durations = optimization.durations  # Capture actual solve time

Then pass the optimization object: ClusteredResult('Clustered', clustered_fs, optimization_result)

Otherwise the speed comparison plot will incorrectly show 0 seconds for the clustered approach.

🤖 Prompt for AI Agents
In
@tests/deprecated/examples/03_Optimization_modes/example_optimization_modes.py
around lines 192 - 217, The wrapper currently uses a placeholder duration;
change the optimize call to capture its return value (e.g., optimization_result
= clustered_fs.optimize(...)), update the ClusteredResult class signature to
accept the optimization object (def __init__(self, name, fs, optimization):) and
set self.durations = optimization.durations, and then instantiate with
ClusteredResult('Clustered', clustered_fs, optimization_result) so the actual
solve time is preserved.

Merged main branch updates while preserving property-based architecture:
- Kept private attributes with property getters/setters for Storage, OnOffParameters
- Added cluster_mode property to OnOffParameters
- Took main's improvements: _select_dims helper, join='outer' for xr.concat
- Updated CITATION.cff version to 6.0.0rc2
- Added inputs property to Comparison class
- Updated dim_priority with 'case' dimension

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
@FBumann FBumann closed this Jan 7, 2026
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.

2 participants