Skip to content

Conversation

@FBumann
Copy link
Member

@FBumann FBumann commented Sep 23, 2025

Description

Make the InvestmentParameters work properly with multiple years

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

    • Multi-year investment modeling with year-aware variables and state tracking.
    • Flexible duration handling in on/off tracking using per-step durations.
    • FlowSystem now exposes year durations and lazy access from results after solve.
    • Broader input support for non-temporal effect shares.
  • Changes

    • Scenario-based weighting aligned across stochastic modeling.
    • Improved default weight handling when years are present.
  • Documentation

    • Changelog reworded for clarity; clarified FlowSystem/FlowResults terms; minor phrasing and typo fixes.
  • Tests

    • Added end-to-end tests for year-aware investments and annualized effects.

# Conflicts:
#	flixopt/flow_system.py
#	flixopt/interface.py
#	flixopt/structure.py
# Conflicts:
#	.github/ISSUE_TEMPLATE/bug_report.yml
#	.github/ISSUE_TEMPLATE/config.yml
#	.github/ISSUE_TEMPLATE/feature_request.yml
#	.github/pull_request_template.md
#	CHANGELOG.md
#	README.md
#	docs/SUMMARY.md
#	docs/contribute.md
#	flixopt/calculation.py
#	flixopt/effects.py
#	flixopt/elements.py
#	flixopt/features.py
#	flixopt/flow_system.py
#	flixopt/interface.py
#	flixopt/modeling.py
#	flixopt/structure.py
#	pyproject.toml
…th years. Further, remove investment_scenarios parameter
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Sep 23, 2025

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Walkthrough

Adds year-aware investment modeling and utilities, extends FlowSystem with years_per_year and related API, generalizes duration tracking primitives, propagates an optional name_prefix through transform_data across interfaces/components/effects/elements, widens effects non-temporal input types, updates weights behavior, and introduces comprehensive tests. CHANGELOG wording adjusted.

Changes

Cohort / File(s) Summary
Docs
CHANGELOG.md
Reworded Unreleased notes, renamed a subsection, clarified descriptions, and fixed typos; no functional code changes.
transform_data name_prefix propagation
flixopt/elements.py, flixopt/components.py, flixopt/effects.py, flixopt/interface.py, flixopt/structure.py
Added optional parameter name_prefix: str = '' and explicit -> None to multiple transform_data methods; aligns public API across elements, components, effects, interfaces; structure.Interface docstring updated.
Effects input widening
flixopt/effects.py
NonTemporalEffectsUser now accepts NonTemporalDataUser; Effect.transform_data updated signature; no temporal path change.
FlowSystem years handling
flixopt/flow_system.py
Extended FlowSystem.__init__ with years_of_last_year; added calculate_years_per_year; new attributes years_per_year, years_of_investment; simplified coord fitting (has_time_dim removed); optimized sel/isel.
Investment model (year-aware)
flixopt/features.py
Introduced year-aware variable/constraint creation paths; added properties for investment state binaries; integrated PiecewiseEffects; updated OnOffModel calls with duration params; added public imports and parameters: InvestParameters.
Modeling primitives refactor/additions
flixopt/modeling.py
consecutive_duration_tracking now uses duration_per_step and duration_dim; added continuous_transition_bounds and link_changes_to_level_with_binaries helpers.
InvestParameters and related interfaces
flixopt/interface.py
Replaced investment_scenarios with previous_size; transform_data now fits previous_size and effects/piecewise via model coords; standardized transform_data signatures for Piece*, Piecewise*, InvestParameters, OnOffParameters.
Weights behavior
flixopt/structure.py
Default weights now depend on presence of years: 1 if no years, else use years_per_year.
Tests (year-aware investments)
tests/test_models.py
Added utilities for annual payment/effects, fixture for FlowSystem, tests for InvestParameters and an end-to-end flow investment model/solve path.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant T as Test/Caller
  participant FS as FlowSystem
  participant IM as InvestmentModel
  participant MP as ModelingPrimitives
  participant S as Solver
  participant R as Results

  T->>FS: __init__(timesteps, years, years_of_last_year, weights)
  FS-->>FS: calculate_years_per_year()
  FS-->>T: FlowSystem with years_per_year, years_of_investment

  T->>IM: build(model, flow, InvestParameters)
  IM->>IM: transform_data(..., name_prefix)
  IM->>IM: create variables (year-aware if years present)
  IM->>MP: consecutive_duration_tracking(duration_per_step=FS.years_per_year, duration_dim='year')
  IM->>MP: link_changes_to_level_with_binaries(...)
  IM->>MP: continuous_transition_bounds(...)

  IM->>S: optimize()
  S-->>IM: solution
  IM-->>R: assemble results
  R-->>T: FlowResults
Loading
sequenceDiagram
  autonumber
  participant C as Component/Effect
  participant IF as Interface
  participant FS as FlowSystem

  C->>IF: transform_data(flow_system, name_prefix='')
  Note over C,IF: name_prefix propagated uniformly
  IF->>FS: fit_to_model_coords(dims/coords)
  FS-->>IF: aligned data
  IF-->>C: transformed attributes
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60–90 minutes

Possibly related PRs

  • V3.0.0/main #284 — Touches FlowSystem years handling, investment modeling, and transform_data signatures; overlaps with year-aware investment API changes.
  • Feature/v3/update #352 — Adjusts ModelingPrimitives APIs and adds transition/link helpers; mirrors duration handling/generalization introduced here.

Suggested reviewers

  • baumbude
  • PStange

Poem

A whisker twitches at the turning year,
I hop through models, prefixes clear.
Years-per-year drum a gentle beat,
Investments sprout with binary feet.
Constraints align, the solver sings—
Carrots of results and other good things. 🥕

Pre-merge checks and finishing touches

❌ Failed checks (3 warnings)
Check name Status Explanation Resolution
Title Check ⚠️ Warning The title “Feature/v3/invest” is a generic branch-style label rather than a concise summary of the primary change, so it does not clearly communicate the main update introduced by this pull request. Please rename the title to a clear, descriptive sentence that highlights the core change, for example “Support multi-year investment parameters in InvestmentModel.”
Description Check ⚠️ Warning The description follows the template structure but leaves the Related Issues section with a placeholder and does not indicate that changes have been tested or that existing tests pass, so it is missing required details in those sections. Please provide an actual issue number in the Related Issues section or remove the placeholder and update the Testing section by checking the boxes to confirm that you have tested your changes and that existing tests still pass.
Docstring Coverage ⚠️ Warning Docstring coverage is 48.44% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.

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.

@FBumann
Copy link
Member Author

FBumann commented Sep 24, 2025

@coderabbitai review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Sep 24, 2025

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

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: 4

Caution

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

⚠️ Outside diff range comments (5)
flixopt/components.py (1)

833-839: Discharge efficiency applied incorrectly in storage balance equation.

Docstring states discharge should divide by eta_discharge. Current code multiplies, which overstates stored energy and breaks energy conservation.

Apply this diff:

-            - discharge_rate * eff_discharge * hours_per_step,
+            - discharge_rate * hours_per_step / eff_discharge,

Consider validating eta_discharge > 0 during transform/plausibility checks to avoid division-by-zero.

flixopt/features.py (1)

320-335: Bug: self.hours_per_step attribute does not exist on OnOffModel.

Should reference the FlowSystemModel’s hours_per_step.

Apply this diff:

-                duration_per_step=self.hours_per_step,
+                duration_per_step=self._model.hours_per_step,
                 duration_dim='time',

Repeat the same change for consecutive_off_hours.

flixopt/modeling.py (2)

14-53: to_binary: handle scalars and avoid invalid len() usage.

Passing a scalar (e.g., 0) currently raises. Also the size==0 branch uses .item() unsafely.

Apply this diff:

     @staticmethod
     def to_binary(
         values: xr.DataArray,
         epsilon: float | None = None,
         dims: str | list[str] | None = None,
     ) -> xr.DataArray:
@@
-        if not isinstance(values, xr.DataArray):
-            values = xr.DataArray(values, dims=['time'], coords={'time': range(len(values))})
+        if not isinstance(values, xr.DataArray):
+            arr = np.asarray(values)
+            if arr.ndim == 0:
+                values = xr.DataArray(arr)  # 0-D scalar
+            else:
+                values = xr.DataArray(arr, dims=['time'], coords={'time': np.arange(arr.shape[0])})
@@
-        if values.size == 0:
-            return xr.DataArray(0) if values.item() < epsilon else xr.DataArray(1)
+        if values.size == 0:
+            return xr.DataArray(0)

81-89: count_consecutive_states: boolean check and numpy where usage.

  • Using .all() on a scalar bool is invalid.
  • Use underlying .values for np.where.

Apply this diff:

-        if np.isclose(binary_values.isel({dim: -1}).item(), 0, atol=epsilon).all():
+        if bool(np.isclose(binary_values.isel({dim: -1}).item(), 0, atol=epsilon)):
             return 0.0
@@
-        zero_indices = np.where(is_zero)[0]
+        zero_indices = np.where(is_zero.values)[0]
flixopt/interface.py (1)

879-916: Fix _plausibility_checks call signature and previous_size dims

  • Call self._plausibility_checks() (drop the flow_system argument)
  • Use dims=['scenario'] for previous_size to avoid year conflicts
-        self._plausibility_checks(flow_system)
+        self._plausibility_checks()-        self.previous_size = flow_system.fit_to_model_coords(
-            f'{name_prefix}|previous_size', self.previous_size, dims=['year', 'scenario']
-        )
+        self.previous_size = flow_system.fit_to_model_coords(
+            f'{name_prefix}|previous_size', self.previous_size, dims=['scenario']
+        )
🧹 Nitpick comments (13)
CHANGELOG.md (2)

38-55: Fix Markdown heading level (MD001) under Unreleased section.

Headings jump from H2 to H4. Use H3 for section headers to satisfy markdownlint and keep structure consistent.

Apply this diff:

-#### Multi-year-investments
+### Multi-year-investments
@@
-#### Stochastic modeling
+### Stochastic modeling
@@
-#### Improved Data handling: IO, resampling and more through xarray
+### Improved Data handling: IO, resampling and more through xarray

72-80: Tighten wording and fix minor typos in Unreleased text.

Purely editorial; improves clarity and polish.

Apply this diff:

-* FlowSystem Restoring: The used FlowSystem is now acessible directly form the results without manual restoring (lazily). All Parameters can be safely accessed anytime after the solve.
+* FlowSystem restoring: The used FlowSystem is now accessible directly from the results without manual restoring (lazily). All parameters can be safely accessed anytime after the solve.
@@
-* Improved filter methods in `resulty.py`
+* Improved filter methods in `results.py`
@@
-* IO for single Interfaces/Elemenets to Datasets might not work properly if the Interface/Element is not part of a fully transformed and connected FlowSystem. This arrises from Numeric Data not being stored as xr.DataArray by the user. To avoid this, always use the `to_dataset()` on Elements inside a FlowSystem thats connected and transformed.
+* IO for single Interfaces/Elements to Datasets might not work properly if the Interface/Element is not part of a fully transformed and connected FlowSystem. This arises from numeric data not being stored as xr.DataArray by the user. To avoid this, always use `to_dataset()` on Elements inside a FlowSystem that's connected and transformed.
@@
-* Added new module `.modeling`that contains Modelling primitives and utilities
+* Added new module `.modeling` that contains modeling primitives and utilities
@@
-* Improved access to the Submodels and their variables, constraints and submodels
+* Improved access to Submodels and their variables, constraints and submodels

Also applies to: 107-109, 111-117

flixopt/structure.py (1)

174-178: Default weights: guard dims and make broadcast robust.

If year or scenario dims are absent, passing dims=['year','scenario'] may misalign or error depending on fit_to_model_coords. Build dims dynamically from available coords.

Apply this diff:

-            weights = self.flow_system.fit_to_model_coords(
-                'weights',
-                1 if self.flow_system.years is None else self.flow_system.years_per_year,
-                dims=['year', 'scenario'],
-            )
+            base = 1 if self.flow_system.years is None else self.flow_system.years_per_year
+            dims = [d for d in ('year', 'scenario') if d in self.flow_system.coords]
+            weights = self.flow_system.fit_to_model_coords('weights', base, dims=dims)

Please also confirm the intended normalization semantics (global sum to 1 vs per-year normalization) are correct for multi-year/multi-scenario objectives.

flixopt/components.py (4)

207-214: Propagate name_prefix through transform_data.

Currently ignored. Forward it to parent and compose prefixes for nested transforms for consistent naming.

Apply this diff:

-    def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None:
-        super().transform_data(flow_system)
+    def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None:
+        super().transform_data(flow_system, name_prefix)
         if self.conversion_factors:
             self.conversion_factors = self._transform_conversion_factors(flow_system)
         if self.piecewise_conversion:
             self.piecewise_conversion.has_time_dim = True
-            self.piecewise_conversion.transform_data(flow_system, f'{self.label_full}|PiecewiseConversion')
+            prefix = f'{name_prefix}|{self.label_full}' if name_prefix else self.label_full
+            self.piecewise_conversion.transform_data(flow_system, f'{prefix}|PiecewiseConversion')

215-227: Conversion factors: explicitly align to model dims.

To avoid accidental broadcasting with new year/scenario dims, pass dims when fitting.

Apply this diff:

-                ts = flow_system.fit_to_model_coords(f'{self.flows[flow].label_full}|conversion_factor{idx}', values)
+                ts = flow_system.fit_to_model_coords(
+                    f'{self.flows[flow].label_full}|conversion_factor{idx}',
+                    values,
+                    dims=['time', 'year', 'scenario'],
+                )

425-466: Storage.transform_data should forward name_prefix and use it when delegating.

Keeps naming consistent across nested structures.

Apply this diff:

-    def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None:
-        super().transform_data(flow_system)
+    def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None:
+        super().transform_data(flow_system, name_prefix)
@@
-        if isinstance(self.capacity_in_flow_hours, InvestParameters):
-            self.capacity_in_flow_hours.transform_data(flow_system, f'{self.label_full}|InvestParameters')
+        if isinstance(self.capacity_in_flow_hours, InvestParameters):
+            prefix = f'{name_prefix}|{self.label_full}' if name_prefix else self.label_full
+            self.capacity_in_flow_hours.transform_data(flow_system, f'{prefix}|InvestParameters')

696-703: Transmission.transform_data should propagate name_prefix.

Forward the prefix and use it for nested names.

Apply this diff:

-    def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None:
-        super().transform_data(flow_system)
+    def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None:
+        super().transform_data(flow_system, name_prefix)
         self.relative_losses = flow_system.fit_to_model_coords(
             f'{self.label_full}|relative_losses', self.relative_losses
         )
         self.absolute_losses = flow_system.fit_to_model_coords(
             f'{self.label_full}|absolute_losses', self.absolute_losses
         )
flixopt/effects.py (1)

177-216: Propagate name_prefix for effect variable naming consistency.

Currently ignored; compose it with label_full so nested calls can prefix names coherently.

Apply this diff:

-    def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None:
-        self.minimum_operation_per_hour = flow_system.fit_to_model_coords(
-            f'{self.label_full}|minimum_operation_per_hour', self.minimum_operation_per_hour
+    def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None:
+        prefix = f'{name_prefix}|{self.label_full}' if name_prefix else self.label_full
+        self.minimum_operation_per_hour = flow_system.fit_to_model_coords(
+            f'{prefix}|minimum_operation_per_hour', self.minimum_operation_per_hour
         )
@@
-        self.maximum_operation_per_hour = flow_system.fit_to_model_coords(
-            f'{self.label_full}|maximum_operation_per_hour', self.maximum_operation_per_hour
+        self.maximum_operation_per_hour = flow_system.fit_to_model_coords(
+            f'{prefix}|maximum_operation_per_hour', self.maximum_operation_per_hour
         )
@@
-        self.specific_share_to_other_effects_operation = flow_system.fit_effects_to_model_coords(
-            f'{self.label_full}|operation->', self.specific_share_to_other_effects_operation, 'operation'
+        self.specific_share_to_other_effects_operation = flow_system.fit_effects_to_model_coords(
+            f'{prefix}|operation->', self.specific_share_to_other_effects_operation, 'operation'
         )
@@
-        self.minimum_operation = flow_system.fit_to_model_coords(
-            f'{self.label_full}|minimum_operation', self.minimum_operation, dims=['year', 'scenario']
+        self.minimum_operation = flow_system.fit_to_model_coords(
+            f'{prefix}|minimum_operation', self.minimum_operation, dims=['year', 'scenario']
         )
-        self.maximum_operation = flow_system.fit_to_model_coords(
-            f'{self.label_full}|maximum_operation', self.maximum_operation, dims=['year', 'scenario']
+        self.maximum_operation = flow_system.fit_to_model_coords(
+            f'{prefix}|maximum_operation', self.maximum_operation, dims=['year', 'scenario']
         )
-        self.minimum_invest = flow_system.fit_to_model_coords(
-            f'{self.label_full}|minimum_invest', self.minimum_invest, dims=['year', 'scenario']
+        self.minimum_invest = flow_system.fit_to_model_coords(
+            f'{prefix}|minimum_invest', self.minimum_invest, dims=['year', 'scenario']
         )
-        self.maximum_invest = flow_system.fit_to_model_coords(
-            f'{self.label_full}|maximum_invest', self.maximum_invest, dims=['year', 'scenario']
+        self.maximum_invest = flow_system.fit_to_model_coords(
+            f'{prefix}|maximum_invest', self.maximum_invest, dims=['year', 'scenario']
         )
-        self.minimum_total = flow_system.fit_to_model_coords(
-            f'{self.label_full}|minimum_total',
+        self.minimum_total = flow_system.fit_to_model_coords(
+            f'{prefix}|minimum_total',
             self.minimum_total,
             dims=['year', 'scenario'],
         )
-        self.maximum_total = flow_system.fit_to_model_coords(
-            f'{self.label_full}|maximum_total', self.maximum_total, dims=['year', 'scenario']
+        self.maximum_total = flow_system.fit_to_model_coords(
+            f'{prefix}|maximum_total', self.maximum_total, dims=['year', 'scenario']
         )
-        self.specific_share_to_other_effects_invest = flow_system.fit_effects_to_model_coords(
-            f'{self.label_full}|invest->',
+        self.specific_share_to_other_effects_invest = flow_system.fit_effects_to_model_coords(
+            f'{prefix}|invest->',
             self.specific_share_to_other_effects_invest,
             'invest',
             dims=['year', 'scenario'],
         )
flixopt/elements.py (3)

100-106: Component.transform_data should pass name_prefix onward.

Forward prefix to parent and flows for consistent nested naming.

Apply this diff:

-    def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None:
-        if self.on_off_parameters is not None:
-            self.on_off_parameters.transform_data(flow_system, self.label_full)
-
-        for flow in self.inputs + self.outputs:
-            flow.transform_data(flow_system)
+    def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None:
+        if self.on_off_parameters is not None:
+            prefix = f'{name_prefix}|{self.label_full}' if name_prefix else self.label_full
+            self.on_off_parameters.transform_data(flow_system, prefix)
+
+        for flow in self.inputs + self.outputs:
+            flow.transform_data(flow_system, name_prefix)

420-452: Flow.transform_data: incorporate name_prefix in delegated transforms.

Use the prefix when passing to OnOffParameters/InvestParameters and keep forwarding to fit_* names.

Apply this diff:

-    def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None:
+    def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None:
         self.relative_minimum = flow_system.fit_to_model_coords(
             f'{self.label_full}|relative_minimum', self.relative_minimum
         )
@@
-        if self.on_off_parameters is not None:
-            self.on_off_parameters.transform_data(flow_system, self.label_full)
+        if self.on_off_parameters is not None:
+            prefix = f'{name_prefix}|{self.label_full}' if name_prefix else self.label_full
+            self.on_off_parameters.transform_data(flow_system, prefix)
         if isinstance(self.size, InvestParameters):
-            self.size.transform_data(flow_system, self.label_full)
+            prefix = f'{name_prefix}|{self.label_full}' if name_prefix else self.label_full
+            self.size.transform_data(flow_system, prefix)

203-205: Minor typos in warning.

“FLow” and capitalization.

Apply this diff:

-                        f'Using a FLow with a fixed size ({flow.label_full}) AND a piecewise_conversion '
-                        f'(in {self.label_full}) and variable size is uncommon. Please check if this is intended!'
+                        f'Using a Flow with a fixed size ({flow.label_full}) AND a piecewise_conversion '
+                        f'(in {self.label_full}) and variable size is uncommon. Please check if this is intended.'
tests/test_models.py (2)

45-91: Annualized effects generator: OK.

Logic is clear; could be vectorized later for performance on large year sets.


186-232: Solver dependency in test: consider graceful skip if Gurobi unavailable.

This integration test hard-depends on Gurobi. Mark or guard to avoid CI breaks.

Apply this diff:

 @pytest.fixture
 def flow_system() -> fx.FlowSystem:
@@
 class TestYearAwareInvestmentModelDirect:
@@
-    def test_flow_invest_new(self, flow_system):
+    def test_flow_invest_new(self, flow_system):
+        if not hasattr(fx.solvers, "GurobiSolver"):
+            pytest.skip("Gurobi not available in this environment")
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between af4a297 and 638d531.

📒 Files selected for processing (10)
  • CHANGELOG.md (2 hunks)
  • flixopt/components.py (4 hunks)
  • flixopt/effects.py (3 hunks)
  • flixopt/elements.py (4 hunks)
  • flixopt/features.py (8 hunks)
  • flixopt/flow_system.py (8 hunks)
  • flixopt/interface.py (10 hunks)
  • flixopt/modeling.py (4 hunks)
  • flixopt/structure.py (2 hunks)
  • tests/test_models.py (1 hunks)
🧰 Additional context used
🪛 markdownlint-cli2 (0.18.1)
CHANGELOG.md

38-38: Heading levels should only increment by one level at a time
Expected: h3; Actual: h4

(MD001, heading-increment)

🔇 Additional comments (26)
flixopt/effects.py (2)

19-19: Type widening is appropriate.

Switching NonTemporalEffectsUser to accept NonTemporalDataUser aligns invest-side shares with year/scenario-aware modeling.


278-279: Alias update acknowledged.

The new NonTemporalEffectsUser alias remains backward-compatible for scalar inputs and supports year/scenario-aware values.

flixopt/elements.py (2)

192-195: LGTM on Bus.transform_data.

The signature is standardized and coordinates fit uses label_full. No issues.


690-693: Lower bound logic for non-optional investment looks correct.

The InvestParameters guard avoids unintended LB=0 with required size; matches year-aware modeling.

flixopt/structure.py (1)

225-231: All transform_data implementations include the new name_prefix parameter No subclasses missing the updated signature.

flixopt/features.py (5)

14-16: Public imports look good.

Exposing CONFIG and ModelingUtilitiesAbstract here is appropriate for downstream usage.


26-38: Docstring + public attribute annotation: OK.

Clearer API surface for InvestmentModel.parameters.


59-81: Verify get_coords gracefully ignores missing dims.

This path is taken when years is None, yet coords request includes 'year'. Ensure Submodel.get_coords filters out unavailable dims to avoid shape/key errors.


194-206: Piecewise effects integration: OK.

Clean encapsulation via PiecewiseEffectsModel and sensible zero_point wiring to is_invested.


219-238: Accessor properties: OK.

Names match variable short_names introduced above.

flixopt/flow_system.py (5)

52-63: Docstring extension: OK.

years_of_last_year is documented clearly.


85-91: Year-aware attributes initialization: OK.

Storing years_per_year and a renamed years_of_investment improves clarity.


383-387: Simplified coord fitting looks good.

Restricting coords by dims improves consistency; relies on self.coords to include only active axes.


779-795: Minor efficiency improvement: OK.

Using a local ds reduces repeated to_dataset() calls.


816-832: Minor efficiency improvement: OK.

Same for isel path.

flixopt/interface.py (7)

11-12: xr import is fine.

Keeps typing options open; ensure linters tolerate potential unused import.


78-82: Piece.transform_data: OK.

dims gating on has_time_dim is correct; name_prefix propagation is consistent.


224-227: Piecewise.transform_data: OK.

Indexing/prefixing per piece is clean.


446-449: PiecewiseConversion.transform_data: OK.

Per-flow name scoping prevents collisions.


658-662: PiecewiseEffects.transform_data: OK.

Origin/effects prefixed consistently; time-dim propagated via has_time_dim.


857-878: InvestParameters: previous_size wiring is appropriate.

Public parameter matches multi-period modeling intent.


1132-1160: OnOffParameters.transform_data: OK.

Signature alignment and labeling are consistent with fit_effects_to_model_coords changes.

flixopt/modeling.py (1)

259-296: Consecutive duration constraints: OK post‑validation.

Indexing with duration_dim is correct; initial condition uses provided previous_duration.

tests/test_models.py (3)

24-43: Annuity helper looks correct.

Edge cases handled for remaining_years==1; non-positive years yield zero via loop guard.


93-113: Fixture: minimal system wiring is fine.

Randomized profiles + buses/effects setup provides a workable sandbox.


115-184: API names: verify YearAwareInvestParameters symbols exist.

Tests reference fx.YearAwareInvestParameters; ensure the project exposes these (vs InvestParameters/InvestTimingParameters). Otherwise imports will fail.

Comment on lines 82 to 163
def _create_variables_and_constraints_with_years(self):
size_min, size_max = self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size
self.add_variables(
short_name='size',
lower=0,
upper=size_max,
coords=self._model.get_coords(['year', 'scenario']),
)

self.add_variables(
binary=True,
coords=self._model.get_coords(['year', 'scenario']),
short_name='is_invested',
)

BoundingPatterns.bounds_with_state(
self,
variable=self.size,
variable_state=self.is_invested,
bounds=(size_min, size_max),
)
########################################################################
previous_size = self.parameters.previous_size if self.parameters.previous_size is not None else 0
self.add_variables(
binary=True,
coords=self._model.get_coords(['year', 'scenario']),
short_name='size|investment_occurs',
)
self.add_variables(
binary=True,
coords=self._model.get_coords(['year', 'scenario']),
short_name='size|decommissioning_occurs',
)
BoundingPatterns.state_transition_bounds(
self,
state_variable=self.is_invested,
switch_on=self.investment_occurs,
switch_off=self.decommissioning_occurs,
name=self.is_invested.name,
previous_state=ModelingUtilitiesAbstract.to_binary(values=previous_size, epsilon=CONFIG.modeling.EPSILON),
coord='year',
)
if self.parameters.optional:
self.add_constraints(
self.investment_occurs.sum('year') <= 1,
short_name='investment_occurs|once',
)
else:
self.add_constraints(
self.investment_occurs.sum('year') == 1,
short_name='investment_occurs|once',
)

self.add_constraints(
self.decommissioning_occurs.sum('year') <= 1,
short_name='decommissioning_occurs|once',
)
########################################################################
self.add_variables(
coords=self._model.get_coords(['year', 'scenario']),
short_name='size|increase',
lower=0,
upper=size_max,
)
self.add_variables(
coords=self._model.get_coords(['year', 'scenario']),
short_name='size|decrease',
lower=0,
upper=size_max,
)
BoundingPatterns.link_changes_to_level_with_binaries(
self,
level_variable=self.size,
increase_variable=self.size_increase,
decrease_variable=self.size_decrease,
increase_binary=self.investment_occurs,
decrease_binary=self.decommissioning_occurs,
name=f'{self.label_of_element}|size|changes',
max_change=size_max,
initial_level=previous_size,
coord='year',
)
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

Fix previous_state dimensionality and scalar handling in state transition logic.

Two issues:

  • previous_state passed to state_transition_bounds currently keeps a 'year' dim, causing a misalignment at the initial condition (LHS is indexed at year=0; RHS keeps full 'year' dim).
  • ModelingUtilitiesAbstract.to_binary doesn’t robustly handle scalars; passing 0 (int) can raise due to len() usage upstream.

Action:

  • Collapse previous_size to scenario-only before binary casting.
  • Use the collapsed previous state consistently in both state_transition_bounds and link_changes_to_level_with_binaries initial level.

Apply this diff:

-        previous_size = self.parameters.previous_size if self.parameters.previous_size is not None else 0
+        previous_size = self.parameters.previous_size if self.parameters.previous_size is not None else 0
+        previous_invested = ModelingUtilitiesAbstract.to_binary(
+            values=previous_size,
+            epsilon=CONFIG.modeling.EPSILON,
+            dims='scenario',  # keep only scenario; drop year to match initial constraint broadcasting
+        )
...
-        BoundingPatterns.state_transition_bounds(
+        BoundingPatterns.state_transition_bounds(
             self,
             state_variable=self.is_invested,
             switch_on=self.investment_occurs,
             switch_off=self.decommissioning_occurs,
             name=self.is_invested.name,
-            previous_state=ModelingUtilitiesAbstract.to_binary(values=previous_size, epsilon=CONFIG.modeling.EPSILON),
+            previous_state=previous_invested,
             coord='year',
         )
-        if self.parameters.optional:
-            self.add_constraints(
-                self.investment_occurs.sum('year') <= 1,
-                short_name='investment_occurs|once',
-            )
-        else:
-            self.add_constraints(
-                self.investment_occurs.sum('year') == 1,
-                short_name='investment_occurs|once',
-            )
+        if self.parameters.optional:
+            self.add_constraints(self.investment_occurs.sum('year') <= 1, short_name='investment_occurs|once')
+        else:
+            # If already invested before start, no new investment is required; otherwise exactly one invest is required.
+            required_invests = 1 - previous_invested
+            self.add_constraints(self.investment_occurs.sum('year') == required_invests, short_name='investment_occurs|once')
...
-        BoundingPatterns.link_changes_to_level_with_binaries(
+        BoundingPatterns.link_changes_to_level_with_binaries(
             self,
             level_variable=self.size,
             increase_variable=self.size_increase,
             decrease_variable=self.size_decrease,
             increase_binary=self.investment_occurs,
             decrease_binary=self.decommissioning_occurs,
             name=f'{self.label_of_element}|size|changes',
             max_change=size_max,
-            initial_level=previous_size,
+            initial_level=previous_size if getattr(previous_size, 'dims', None) != ('year', 'scenario') else previous_size.isel(year=0),
             coord='year',
         )
📝 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 _create_variables_and_constraints_with_years(self):
size_min, size_max = self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size
self.add_variables(
short_name='size',
lower=0,
upper=size_max,
coords=self._model.get_coords(['year', 'scenario']),
)
self.add_variables(
binary=True,
coords=self._model.get_coords(['year', 'scenario']),
short_name='is_invested',
)
BoundingPatterns.bounds_with_state(
self,
variable=self.size,
variable_state=self.is_invested,
bounds=(size_min, size_max),
)
########################################################################
previous_size = self.parameters.previous_size if self.parameters.previous_size is not None else 0
self.add_variables(
binary=True,
coords=self._model.get_coords(['year', 'scenario']),
short_name='size|investment_occurs',
)
self.add_variables(
binary=True,
coords=self._model.get_coords(['year', 'scenario']),
short_name='size|decommissioning_occurs',
)
BoundingPatterns.state_transition_bounds(
self,
state_variable=self.is_invested,
switch_on=self.investment_occurs,
switch_off=self.decommissioning_occurs,
name=self.is_invested.name,
previous_state=ModelingUtilitiesAbstract.to_binary(values=previous_size, epsilon=CONFIG.modeling.EPSILON),
coord='year',
)
if self.parameters.optional:
self.add_constraints(
self.investment_occurs.sum('year') <= 1,
short_name='investment_occurs|once',
)
else:
self.add_constraints(
self.investment_occurs.sum('year') == 1,
short_name='investment_occurs|once',
)
self.add_constraints(
self.decommissioning_occurs.sum('year') <= 1,
short_name='decommissioning_occurs|once',
)
########################################################################
self.add_variables(
coords=self._model.get_coords(['year', 'scenario']),
short_name='size|increase',
lower=0,
upper=size_max,
)
self.add_variables(
coords=self._model.get_coords(['year', 'scenario']),
short_name='size|decrease',
lower=0,
upper=size_max,
)
BoundingPatterns.link_changes_to_level_with_binaries(
self,
level_variable=self.size,
increase_variable=self.size_increase,
decrease_variable=self.size_decrease,
increase_binary=self.investment_occurs,
decrease_binary=self.decommissioning_occurs,
name=f'{self.label_of_element}|size|changes',
max_change=size_max,
initial_level=previous_size,
coord='year',
)
def _create_variables_and_constraints_with_years(self):
size_min, size_max = self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size
self.add_variables(
short_name='size',
lower=0,
upper=size_max,
coords=self._model.get_coords(['year', 'scenario']),
)
self.add_variables(
binary=True,
coords=self._model.get_coords(['year', 'scenario']),
short_name='is_invested',
)
BoundingPatterns.bounds_with_state(
self,
variable=self.size,
variable_state=self.is_invested,
bounds=(size_min, size_max),
)
########################################################################
previous_size = self.parameters.previous_size if self.parameters.previous_size is not None else 0
previous_invested = ModelingUtilitiesAbstract.to_binary(
values=previous_size,
epsilon=CONFIG.modeling.EPSILON,
dims='scenario', # keep only scenario; drop year to match initial constraint broadcasting
)
self.add_variables(
binary=True,
coords=self._model.get_coords(['year', 'scenario']),
short_name='size|investment_occurs',
)
self.add_variables(
binary=True,
coords=self._model.get_coords(['year', 'scenario']),
short_name='size|decommissioning_occurs',
)
BoundingPatterns.state_transition_bounds(
self,
state_variable=self.is_invested,
switch_on=self.investment_occurs,
switch_off=self.decommissioning_occurs,
name=self.is_invested.name,
previous_state=previous_invested,
coord='year',
)
if self.parameters.optional:
self.add_constraints(self.investment_occurs.sum('year') <= 1, short_name='investment_occurs|once')
else:
# If already invested before start, no new investment is required; otherwise exactly one invest is required.
required_invests = 1 - previous_invested
self.add_constraints(self.investment_occurs.sum('year') == required_invests, short_name='investment_occurs|once')
self.add_constraints(
self.decommissioning_occurs.sum('year') <= 1,
short_name='decommissioning_occurs|once',
)
########################################################################
self.add_variables(
coords=self._model.get_coords(['year', 'scenario']),
short_name='size|increase',
lower=0,
upper=size_max,
)
self.add_variables(
coords=self._model.get_coords(['year', 'scenario']),
short_name='size|decrease',
lower=0,
upper=size_max,
)
BoundingPatterns.link_changes_to_level_with_binaries(
self,
level_variable=self.size,
increase_variable=self.size_increase,
decrease_variable=self.size_decrease,
increase_binary=self.investment_occurs,
decrease_binary=self.decommissioning_occurs,
name=f'{self.label_of_element}|size|changes',
max_change=size_max,
initial_level=previous_size if getattr(previous_size, 'dims', None) != ('year', 'scenario') else previous_size.isel(year=0),
coord='year',
)
🤖 Prompt for AI Agents
In flixopt/features.py around lines 82-163, previous_size retains a 'year'
dimension and raw ints can be passed into to_binary; collapse previous_size to
scenario-only (e.g., pick the initial year or reduce the 'year' axis so the
result has only 'scenario' coords) and normalize scalar ints into the same shape
before calling ModelingUtilitiesAbstract.to_binary; then pass that
collapsed/normalized previous_state to BoundingPatterns.state_transition_bounds
and also use the same collapsed value as initial_level for
link_changes_to_level_with_binaries so both APIs receive a scenario-only
previous state without a lingering 'year' dimension.

Comment on lines 184 to 194
@staticmethod
def calculate_years_per_year(years: pd.Index, years_of_last_year: int | None = None) -> xr.DataArray:
"""Calculate duration of each timestep as a 1D DataArray."""
years_per_year = np.diff(years)
return xr.DataArray(
np.append(years_per_year, years_of_last_year or years_per_year[-1]),
coords={'year': years},
dims='year',
name='years_per_year',
)

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

Handle single-year indexes in calculate_years_per_year.

np.diff(years) is empty for a single-year index, making years_per_year[-1] invalid.

Apply this diff:

 @staticmethod
 def calculate_years_per_year(years: pd.Index, years_of_last_year: int | None = None) -> xr.DataArray:
-    """Calculate duration of each timestep as a 1D DataArray."""
-    years_per_year = np.diff(years)
-    return xr.DataArray(
-        np.append(years_per_year, years_of_last_year or years_per_year[-1]),
-        coords={'year': years},
-        dims='year',
-        name='years_per_year',
-    )
+    """Calculate the represented duration for each year as a 1D DataArray."""
+    if len(years) == 1:
+        last = years_of_last_year if years_of_last_year is not None else 1
+        data = np.array([last])
+    else:
+        diffs = np.diff(years)
+        data = np.append(diffs, years_of_last_year if years_of_last_year is not None else diffs[-1])
+    return xr.DataArray(data, coords={'year': years}, dims='year', name='years_per_year')
📝 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
@staticmethod
def calculate_years_per_year(years: pd.Index, years_of_last_year: int | None = None) -> xr.DataArray:
"""Calculate duration of each timestep as a 1D DataArray."""
years_per_year = np.diff(years)
return xr.DataArray(
np.append(years_per_year, years_of_last_year or years_per_year[-1]),
coords={'year': years},
dims='year',
name='years_per_year',
)
@staticmethod
def calculate_years_per_year(years: pd.Index, years_of_last_year: int | None = None) -> xr.DataArray:
"""Calculate the represented duration for each year as a 1D DataArray."""
if len(years) == 1:
last = years_of_last_year if years_of_last_year is not None else 1
data = np.array([last])
else:
diffs = np.diff(years)
data = np.append(diffs, years_of_last_year if years_of_last_year is not None else diffs[-1])
return xr.DataArray(data, coords={'year': years}, dims='year', name='years_per_year')
🤖 Prompt for AI Agents
In flixopt/flow_system.py around lines 184 to 194, calculate_years_per_year
fails for a single-year pd.Index because np.diff(years) is empty and
years_per_year[-1] is invalid; update the function to detect when len(years) ==
1 and return a DataArray of length 1 using years_of_last_year if provided or a
sensible default (e.g. 1.0), otherwise compute diffs as before and append the
last interval (use years_of_last_year if given); ensure resulting array has the
same dtype and coords/dims as before.

Comment on lines 229 to 231
duration_dim: str = 'time',
duration_per_step: int | float | TemporalData = None,
previous_duration: TemporalData = 0,
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

Make duration_per_step mandatory and validate early.

When None, the Big‑M and constraints fail. Add an explicit check.

Apply this diff:

 def consecutive_duration_tracking(
@@
-        duration_dim: str = 'time',
-        duration_per_step: int | float | TemporalData = None,
+        duration_dim: str = 'time',
+        duration_per_step: int | float | TemporalData = None,
@@
-        if not isinstance(model, Submodel):
+        if not isinstance(model, Submodel):
             raise ValueError('ModelingPrimitives.sum_up_variable() can only be used with a Submodel')
+        if duration_per_step is None:
+            raise ValueError("duration_per_step must be provided for consecutive_duration_tracking()")
📝 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
duration_dim: str = 'time',
duration_per_step: int | float | TemporalData = None,
previous_duration: TemporalData = 0,
duration_dim: str = 'time',
duration_per_step: int | float | TemporalData = None,
previous_duration: TemporalData = 0,
if not isinstance(model, Submodel):
raise ValueError('ModelingPrimitives.sum_up_variable() can only be used with a Submodel')
if duration_per_step is None:
raise ValueError(
"duration_per_step must be provided for consecutive_duration_tracking()"
)
🤖 Prompt for AI Agents
In flixopt/modeling.py around lines 229-231, the duration_per_step parameter is
currently optional (default None) which breaks Big‑M and constraint setup; make
duration_per_step mandatory by removing the "= None" default from the signature
(and update any callers) and add an early validation at the start of the
function that raises a clear ValueError if duration_per_step is None (and
optionally if it's non-positive or an invalid type), so the function fails fast
with an explicit message instead of producing invalid constraints.

Comment on lines 594 to 659
@staticmethod
def continuous_transition_bounds(
model: Submodel,
continuous_variable: linopy.Variable,
switch_on: linopy.Variable,
switch_off: linopy.Variable,
name: str,
max_change: float | xr.DataArray,
previous_value: float | xr.DataArray = 0.0,
coord: str = 'time',
) -> tuple[linopy.Constraint, linopy.Constraint, linopy.Constraint, linopy.Constraint]:
"""
Constrains a continuous variable to only change when switch variables are active.
Mathematical formulation:
-max_change * (switch_on[t] + switch_off[t]) <= continuous[t] - continuous[t-1] <= max_change * (switch_on[t] + switch_off[t]) ∀t > 0
-max_change * (switch_on[0] + switch_off[0]) <= continuous[0] - previous_value <= max_change * (switch_on[0] + switch_off[0])
switch_on[t], switch_off[t] ∈ {0, 1}
This ensures the continuous variable can only change when switch_on or switch_off is 1.
When both switches are 0, the variable must stay exactly constant.
Args:
model: The submodel to add constraints to
continuous_variable: The continuous variable to constrain
switch_on: Binary variable indicating when changes are allowed (typically transitions to active state)
switch_off: Binary variable indicating when changes are allowed (typically transitions to inactive state)
name: Base name for the constraints
max_change: Maximum possible change in the continuous variable (Big-M value)
previous_value: Initial value of the continuous variable before first period
coord: Coordinate name for time dimension
Returns:
Tuple of constraints: (transition_upper, transition_lower, initial_upper, initial_lower)
"""
if not isinstance(model, Submodel):
raise ValueError('BoundingPatterns.continuous_transition_bounds() can only be used with a Submodel')

# Transition constraints for t > 0: continuous variable can only change when switches are active
transition_upper = model.add_constraints(
continuous_variable.isel({coord: slice(1, None)}) - continuous_variable.isel({coord: slice(None, -1)})
<= max_change * (switch_on.isel({coord: slice(1, None)}) + switch_off.isel({coord: slice(1, None)})),
name=f'{name}|transition_ub',
)

transition_lower = model.add_constraints(
continuous_variable.isel({coord: slice(None, -1)}) - continuous_variable.isel({coord: slice(1, None)})
<= max_change * (switch_on.isel({coord: slice(1, None)}) + switch_off.isel({coord: slice(1, None)})),
name=f'{name}|transition_lb',
)

# Initial constraints for t = 0
initial_upper = model.add_constraints(
continuous_variable.isel({coord: 0}) - previous_value
<= max_change * (switch_on.isel({coord: 0}) + switch_off.isel({coord: 0})),
name=f'{name}|initial_ub',
)

initial_lower = model.add_constraints(
-continuous_variable.isel({coord: 0}) + previous_value
<= max_change * (switch_on.isel({coord: 0}) + switch_off.isel({coord: 0})),
name=f'{name}|initial_lb',
)

return transition_upper, transition_lower, initial_upper, initial_lower

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: error message references wrong class.

Message says BoundingPatterns.* but the method belongs to ModelingPrimitives.

Apply this diff:

-            raise ValueError('BoundingPatterns.continuous_transition_bounds() can only be used with a Submodel')
+            raise ValueError('ModelingPrimitives.continuous_transition_bounds() can only be used with a Submodel')
📝 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
@staticmethod
def continuous_transition_bounds(
model: Submodel,
continuous_variable: linopy.Variable,
switch_on: linopy.Variable,
switch_off: linopy.Variable,
name: str,
max_change: float | xr.DataArray,
previous_value: float | xr.DataArray = 0.0,
coord: str = 'time',
) -> tuple[linopy.Constraint, linopy.Constraint, linopy.Constraint, linopy.Constraint]:
"""
Constrains a continuous variable to only change when switch variables are active.
Mathematical formulation:
-max_change * (switch_on[t] + switch_off[t]) <= continuous[t] - continuous[t-1] <= max_change * (switch_on[t] + switch_off[t]) ∀t > 0
-max_change * (switch_on[0] + switch_off[0]) <= continuous[0] - previous_value <= max_change * (switch_on[0] + switch_off[0])
switch_on[t], switch_off[t] ∈ {0, 1}
This ensures the continuous variable can only change when switch_on or switch_off is 1.
When both switches are 0, the variable must stay exactly constant.
Args:
model: The submodel to add constraints to
continuous_variable: The continuous variable to constrain
switch_on: Binary variable indicating when changes are allowed (typically transitions to active state)
switch_off: Binary variable indicating when changes are allowed (typically transitions to inactive state)
name: Base name for the constraints
max_change: Maximum possible change in the continuous variable (Big-M value)
previous_value: Initial value of the continuous variable before first period
coord: Coordinate name for time dimension
Returns:
Tuple of constraints: (transition_upper, transition_lower, initial_upper, initial_lower)
"""
if not isinstance(model, Submodel):
raise ValueError('BoundingPatterns.continuous_transition_bounds() can only be used with a Submodel')
# Transition constraints for t > 0: continuous variable can only change when switches are active
transition_upper = model.add_constraints(
continuous_variable.isel({coord: slice(1, None)}) - continuous_variable.isel({coord: slice(None, -1)})
<= max_change * (switch_on.isel({coord: slice(1, None)}) + switch_off.isel({coord: slice(1, None)})),
name=f'{name}|transition_ub',
)
transition_lower = model.add_constraints(
continuous_variable.isel({coord: slice(None, -1)}) - continuous_variable.isel({coord: slice(1, None)})
<= max_change * (switch_on.isel({coord: slice(1, None)}) + switch_off.isel({coord: slice(1, None)})),
name=f'{name}|transition_lb',
)
# Initial constraints for t = 0
initial_upper = model.add_constraints(
continuous_variable.isel({coord: 0}) - previous_value
<= max_change * (switch_on.isel({coord: 0}) + switch_off.isel({coord: 0})),
name=f'{name}|initial_ub',
)
initial_lower = model.add_constraints(
-continuous_variable.isel({coord: 0}) + previous_value
<= max_change * (switch_on.isel({coord: 0}) + switch_off.isel({coord: 0})),
name=f'{name}|initial_lb',
)
return transition_upper, transition_lower, initial_upper, initial_lower
if not isinstance(model, Submodel):
raise ValueError('ModelingPrimitives.continuous_transition_bounds() can only be used with a Submodel')
🤖 Prompt for AI Agents
flixopt/modeling.py lines 594-659: The ValueError raised when model is not a
Submodel references BoundingPatterns but this static method belongs to
ModelingPrimitives; update the exception message to reference ModelingPrimitives
(e.g. "ModelingPrimitives.continuous_transition_bounds() can only be used with a
Submodel") so the class name in the error matches the actual owner of the
method.

@FBumann
Copy link
Member Author

FBumann commented Sep 25, 2025

Renaming InvestParameters

Current vs. Proposed Naming

Current Proposed Rationale
fix effects effects of investment More explicit about what these effects represent
specific effects effects of investment per size Clearly indicates these scale with investment size
divest effects effects of retirement Aligns with retired variable naming and is more descriptive

Benefits of Proposed Changes

Clarity: Each parameter name explicitly states what it represents without requiring domain knowledge of “fix” vs “specific” terminology.

Consistency: Uses parallel structure with “effects of [action]” pattern throughout.

Accessibility: More intuitive for users who aren’t familiar with optimization modeling conventions.

Alignment: effects of retirement matches the retired binary variable naming we discussed.

Example Usage

investment = InvestParameters(
    effects_of_investment={...},      # one-time effects when investing
    effects_of_investment_per_size={...},  # effects that scale with size
    effects_of_retirement={...}       # effects when retiring the asset
)

@FBumann FBumann closed this Oct 13, 2025
@FBumann FBumann deleted the feature/v3/invest branch October 13, 2025 21:36
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