Skip to content

Conversation

@FBumann
Copy link
Member

@FBumann FBumann commented Aug 13, 2025

Description

Adding InvestmentTiming to flixOpt.
This is not yet finished and needs some work. Including proper testing.
One identified major issue is:

  • If an Investment occurs in a certain year which is representing multiple years, the investment costs are counted multiple times. This is wanted behavior for annual costs, but the actual investment costs/efffects are not are not happening in both years. This needs to be addressed before this is usable!!

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
    • Introduces time-based investment modeling with InvestTimingParameters and InvestmentTimingModel, including per-year effects, lifetimes, and decommissioning controls.
    • Adds year-aware modeling with a year_of_investment dimension and per-year durations; data-fitting and selection updated accordingly.
    • transform_data now supports an optional name_prefix across elements and interfaces.
  • Improvements
    • Duration-aware on/off tracking and new utilities for bounding continuous changes and linking level changes to binaries.
    • Package now exposes InvestTimingParameters at the top level; investment reporting includes timing models.
    • Default weights respect per-year durations when available.
  • Tests
    • Comprehensive tests for year-aware investments and annualized effects.

@FBumann FBumann requested review from PStange and baumbude August 13, 2025 18:03
@FBumann FBumann self-assigned this Aug 13, 2025
@FBumann FBumann added the New functionality Add entirely new functionality label Aug 13, 2025
* Change fit_to_model_coords to work with a Collection of dims

* Improve fit_to_model_coords
@FBumann
Copy link
Member Author

FBumann commented Aug 13, 2025

  • If an Investment occurs in a certain year which is representing multiple years, the investment costs are counted multiple times. This is wanted behavior for annual costs, but the actual investment costs/efffects are not are not happening in both years. This needs to be addressed before this is usable!!

This will only be an issue if the used effect is part of the objective. And it might be confusing when analyzing the effect results and using ffill on the years.

Unfortunately I cannot see this resolved without changing the effects structure.
The current implementation (variables: effect_name(operation)|total, effect_name(invest)|total) might need to be changed.
instead of operation and invest, it might be changed to "temporal", "annual", "invest". But im not sure.

@FBumann
Copy link
Member Author

FBumann commented Sep 2, 2025

@PStange @baumbude
Any feedback on this?

# Conflicts:
#	flixopt/flow_system.py
#	flixopt/interface.py
#	flixopt/structure.py
@baumbude
Copy link
Collaborator

baumbude commented Sep 2, 2025

Ich weiß nicht, ob ich es ganz verstehe, was du meinst, aber in diesem Zuge müsste man ja entsprechend Investitionszeitpunkt dann auch den Restwert am Ende des Betrachtungszeitraums mitdenken in der Zielfunktion. Lass uns das gerne bei Gelegenheit nochmal vertiefen.

@FBumann
Copy link
Member Author

FBumann commented Sep 2, 2025

Ich schreib das Konzept mal zusammen die nächsten Tage. Auch für die Leute von @oemof.
Dann diskutieren wir mal ob das praktikable ist

@FBumann
Copy link
Member Author

FBumann commented Sep 11, 2025

Solves #272
Solves #237
Somewhat adresses #196
Solves #222

# 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
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Sep 13, 2025

Walkthrough

Adds a year-aware investment feature: new InvestTimingParameters and InvestmentTimingModel, year_of_investment dimension, per-year durations, and duration-aware primitives. Broad updates wire timing-based investments through Flow, FlowSystem, and features. Multiple transform_data signatures gain an optional name_prefix. Tests added for investment timing and annualized effects.

Changes

Cohort / File(s) Summary
Public exports
flixopt/__init__.py, flixopt/commons.py
Re-export InvestTimingParameters from commons and include it in all; flixopt.InvestTimingParameters becomes public.
Investment timing core
flixopt/features.py, flixopt/interface.py
Introduces InvestmentTimingModel and InvestTimingParameters with timing controls, year-based effects, lifetime bounds, and plausibility checks; adds type aliases for year-of-investment data.
Flow integration and type handling
flixopt/elements.py, flixopt/calculation.py, flixopt/effects.py
Flow supports size as Scalar/InvestParameters/InvestTimingParameters; selects InvestmentModel vs InvestmentTimingModel; adjusts bounds and checks; extends filters to recognize InvestmentTimingModel; updates NonTemporalEffectsUser typing.
FlowSystem dims and durations
flixopt/flow_system.py, flixopt/modeling.py
Adds years_per_year and years_of_investment; fit_to_model_coords/fit_effects_to_model_coords accept with_year_of_investment; selection updated; ModelingPrimitives gains duration_dim and duration_per_step plus new transition/linking helpers.
Transform API surface
flixopt/structure.py, flixopt/components.py
Interface.transform_data and component transform_data methods accept optional name_prefix (default ''), returning None; behavior unchanged.
Tests
tests/test_models.py
Adds tests and helpers for year-aware invest parameters/model, annualized effects, and an integration path using FullCalculation.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor U as User
  participant F as Flow
  participant FS as FlowSystem
  participant FM as FlowModel
  participant ITM as InvestmentTimingModel
  participant MP as ModelingPrimitives
  participant SLV as Solver

  U->>F: new Flow(size=InvestTimingParameters)
  F->>FS: transform_data(flow_system, name_prefix)
  FS-->>F: fitted data (incl. year_of_investment, years_per_year)
  F->>FM: _create_investment_model(...)
  FM->>ITM: construct with parameters
  ITM->>MP: consecutive_duration_tracking(duration_dim='time', duration_per_step)
  ITM->>MP: link_changes_to_level_with_binaries(coord='year')
  ITM-->>FM: variables + constraints
  FM-->>SLV: model assembled
  SLV-->>U: results (size, investment_occurs, effects)
  note over ITM,MP: New/changed duration- and year-aware interactions
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • V3.0.0/main #284 — Appears to target the same investment-timing additions (InvestTimingParameters, InvestmentTimingModel, year_of_investment, and transform_data changes) across overlapping modules.

Poem

I hopped through years on a calendar line,
Planting watts where timings align.
With prefixes neat and durations per step,
I nibbled constraints with a careful rep.
Now investments bloom in seasons clear—
A carrot of code for each bright year! 🥕✨

Pre-merge checks and finishing touches

❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 58.14% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
Title Check ❓ Inconclusive The current title "V3.0.0/invest" is a branch-style label that is vague and not a short descriptive sentence summarizing the primary change; while "invest" relates to the changeset, the title lacks clarity for reviewers scanning PR history. Replace the branch-style title with a concise, descriptive sentence such as "Add year-aware investment timing (InvestTimingParameters & InvestmentTimingModel)" or "Add InvestmentTiming: InvestTimingParameters and InvestmentTimingModel"; avoid version/branch prefixes and, if the work is still in progress, prefix with "WIP:" or mark the PR as Draft to set expectations.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed The PR description follows the repository template, clearly states the new InvestmentTiming feature, marks it as a new feature, and calls out a major known issue and that work is incomplete, so it is mostly complete per the template. However the "Related Issues" line contains a placeholder instead of concrete issue numbers and the Testing section lacks explicit instructions about which tests were added, how to run them, and current CI/test status.
✨ Finishing touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch v3.0.0/invest

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

@FBumann
Copy link
Member Author

FBumann commented Sep 13, 2025

@coderabbitai review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Sep 13, 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: 7

Caution

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

⚠️ Outside diff range comments (13)
flixopt/effects.py (2)

195-203: Fix type aliases: Non-temporal invest shares are no longer scalars.

NonTemporalEffectsUser correctly switched to NonTemporalDataUser, but NonTemporalEffects and the return type of create_effect_values_dict still assume scalars. This is inconsistent and risks wrong typing/usages downstream.

Apply:

- from .core import NonTemporalDataUser, Scalar, TemporalData, TemporalDataUser
+ from .core import NonTemporalData, NonTemporalDataUser, Scalar, TemporalData, TemporalDataUser
-NonTemporalEffects = Dict[str, Scalar]
-""" This datatype is used internally to handle scalar shares to an effect. """
+NonTemporalEffects = Dict[str, NonTemporalData]
+""" This datatype is used internally to handle non-temporal (year/scenario) shares to an effect. """
-    ) -> Optional[Dict[str, Union[Scalar, TemporalDataUser]]]:
+    ) -> Optional[Dict[str, Union[NonTemporalDataUser, TemporalDataUser]]]:

Also applies to: 16-16


428-430: Objective double-counts investments when years aggregate multiple modeled years.

Multiplying total = operation + invest by year-weights inflates one-off invest effects across represented years — exactly the major issue called out in the PR.

Separate weighting: use per-(year,scenario) weights for operation, and scenario-only weights for invest:

-        self._model.add_objective(
-            (self.effects.objective_effect.submodel.total * self._model.weights).sum() + self.penalty.total.sum()
-        )
+        op_term = (self.effects.objective_effect.submodel.operation.total * self._model.weights).sum()
+        inv_weights = self._model.weights.sum('year')  # scenario-only weighting
+        inv_term = (self.effects.objective_effect.submodel.invest.total * inv_weights).sum()
+        self._model.add_objective(op_term + inv_term + self.penalty.total.sum())

If you prefer centralized accessors, add weights_operation and weights_invest on FlowSystemModel and use them here. I can wire that up.

flixopt/flow_system.py (1)

66-81: Persist and restore years_of_last_year.

years_of_last_year influences years_per_year, but it’s neither stored on the instance nor restored from datasets.

Apply:

@@
-        years_of_last_year: Optional[int] = None,
+        years_of_last_year: Optional[int] = None,
@@
-        if years is None:
+        self.years_of_last_year = years_of_last_year
+        if years is None:
             self.years, self.years_per_year, self.years_of_investment = None, None, None
         else:
             self.years = self._validate_years(years)
-            self.years_per_year = self.calculate_years_per_year(self.years, years_of_last_year)
+            self.years_per_year = self.calculate_years_per_year(self.years, self.years_of_last_year)
             self.years_of_investment = self.years.rename('year_of_investment')
@@ def from_dataset(cls, ds: xr.Dataset) -> 'FlowSystem':
-        flow_system = cls(
+        flow_system = cls(
             timesteps=ds.indexes['time'],
             years=ds.indexes.get('year'),
             scenarios=ds.indexes.get('scenario'),
+            years_of_last_year=reference_structure.get('years_of_last_year'),
             weights=cls._resolve_dataarray_reference(reference_structure['weights'], arrays_dict)
             if 'weights' in reference_structure
             else None,

Also applies to: 88-94, 282-291

flixopt/structure.py (1)

232-243: Interface.transform_data signature mismatch — fix Effects.transform_data

flixopt/effects.py:94 defines def transform_data(self, flow_system) — change to def transform_data(self, flow_system: 'FlowSystem', name_prefix: str = '') -> None and ensure the method handles/propagates name_prefix; otherwise callers that pass a prefix will raise TypeError.

flixopt/__init__.py (1)

9-38: Add alias YearAwareInvestParameters to match tests and PR wording

Tests in this PR use YearAwareInvestParameters. Provide a stable alias to avoid breakage and reduce churn.

Apply this diff:

 from .commons import (
@@
-    InvestParameters,
-    InvestTimingParameters,
+    InvestParameters,
+    InvestTimingParameters,
@@
 )
 
 CONFIG.load_config()
+
+# Backward-compat/test-friendly alias
+YearAwareInvestParameters = InvestTimingParameters
flixopt/calculation.py (1)

486-491: SegmentedCalculation: warn for both investment models

The warning currently checks only InvestmentModel. Include InvestmentTimingModel to avoid silent misuse.

Apply this diff:

-                    if isinstance(model, InvestmentModel)
+                    if isinstance(model, (InvestmentModel, InvestmentTimingModel))
flixopt/features.py (1)

1-10: Forward ref in type hints: prevent runtime NameError

InvestmentModel annotates piecewise_effects: Optional[PiecewiseEffectsModel] before the class is defined. Without postponed evaluation, this can fail at import time on older Python versions.

Apply this diff at file top:

+from __future__ import annotations

Alternatively, quote the annotation: Optional["PiecewiseEffectsModel"].

flixopt/modeling.py (1)

225-235: consecutive_duration_tracking: require duration_per_step and support scalars

duration_per_step may be None or scalar; current .sum() call would error. Enforce presence and coerce scalars to a DataArray aligned to state_variable.

Apply this diff:

     def consecutive_duration_tracking(
@@
-        duration_dim: str = 'time',
-        duration_per_step: Union[Scalar, xr.DataArray] = None,
+        duration_dim: str = 'time',
+        duration_per_step: Union[Scalar, xr.DataArray] = None,
@@
-        mega = duration_per_step.sum(duration_dim) + previous_duration  # Big-M value
+        if duration_per_step is None:
+            raise ValueError("duration_per_step must be provided")
+        if not isinstance(duration_per_step, xr.DataArray):
+            duration_per_step = xr.full_like(state_variable, fill_value=float(duration_per_step))
+        mega = duration_per_step.sum(duration_dim) + previous_duration  # Big-M value
flixopt/elements.py (5)

73-79: name_prefix is accepted but not used; propagate it and pass to child flows.
Currently ignored, which defeats the purpose of hierarchical labeling.

Apply:

-    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:
+        label_base = f'{name_prefix}|{self.label_full}' if name_prefix else self.label_full
+        if self.on_off_parameters is not None:
+            self.on_off_parameters.transform_data(flow_system, label_base)
+        for flow in self.inputs + self.outputs:
+            flow.transform_data(flow_system, name_prefix=label_base)

121-125: name_prefix is accepted but not used in Bus.transform_data.

Apply:

-    def transform_data(self, flow_system: 'FlowSystem', name_prefix: str = '') -> None:
-        self.excess_penalty_per_flow_hour = flow_system.fit_to_model_coords(
-            f'{self.label_full}|excess_penalty_per_flow_hour', self.excess_penalty_per_flow_hour
-        )
+    def transform_data(self, flow_system: 'FlowSystem', name_prefix: str = '') -> None:
+        prefix_label = f'{name_prefix}|{self.label_full}' if name_prefix else self.label_full
+        self.excess_penalty_per_flow_hour = flow_system.fit_to_model_coords(
+            f'{prefix_label}|excess_penalty_per_flow_hour', self.excess_penalty_per_flow_hour
+        )

241-266: name_prefix is unused in Flow.transform_data; compute and use a combined prefix.
Without this, upstream prefixes have no effect.

Apply:

-    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
-        )
+    def transform_data(self, flow_system: 'FlowSystem', name_prefix: str = '') -> None:
+        prefix_label = f'{name_prefix}|{self.label_full}' if name_prefix else self.label_full
+        self.relative_minimum = flow_system.fit_to_model_coords(
+            f'{prefix_label}|relative_minimum', self.relative_minimum
+        )
         self.relative_maximum = flow_system.fit_to_model_coords(
-            f'{self.label_full}|relative_maximum', self.relative_maximum
+            f'{prefix_label}|relative_maximum', self.relative_maximum
         )
         self.fixed_relative_profile = flow_system.fit_to_model_coords(
-            f'{self.label_full}|fixed_relative_profile', self.fixed_relative_profile
+            f'{prefix_label}|fixed_relative_profile', self.fixed_relative_profile
         )
         self.effects_per_flow_hour = flow_system.fit_effects_to_model_coords(
-            self.label_full, self.effects_per_flow_hour, 'per_flow_hour'
+            prefix_label, self.effects_per_flow_hour, 'per_flow_hour'
         )
         self.flow_hours_total_max = flow_system.fit_to_model_coords(
-            f'{self.label_full}|flow_hours_total_max', self.flow_hours_total_max, dims=['year', 'scenario']
+            f'{prefix_label}|flow_hours_total_max', self.flow_hours_total_max, dims=['year', 'scenario']
         )
         self.flow_hours_total_min = flow_system.fit_to_model_coords(
-            f'{self.label_full}|flow_hours_total_min', self.flow_hours_total_min, dims=['year', 'scenario']
+            f'{prefix_label}|flow_hours_total_min', self.flow_hours_total_min, dims=['year', 'scenario']
         )
         self.load_factor_max = flow_system.fit_to_model_coords(
-            f'{self.label_full}|load_factor_max', self.load_factor_max, dims=['year', 'scenario']
+            f'{prefix_label}|load_factor_max', self.load_factor_max, dims=['year', 'scenario']
         )
         self.load_factor_min = flow_system.fit_to_model_coords(
-            f'{self.label_full}|load_factor_min', self.load_factor_min, dims=['year', 'scenario']
+            f'{prefix_label}|load_factor_min', self.load_factor_min, dims=['year', 'scenario']
         )

267-273: Propagate prefix to nested transforms and scalar size.

Apply:

-        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:
+            self.on_off_parameters.transform_data(flow_system, prefix_label)
         if isinstance(self.size, (InvestParameters, InvestTimingParameters)):
-            self.size.transform_data(flow_system, self.label_full)
+            self.size.transform_data(flow_system, prefix_label)
         else:
-            self.size = flow_system.fit_to_model_coords(f'{self.label_full}|size', self.size, dims=['year', 'scenario'])
+            self.size = flow_system.fit_to_model_coords(
+                f'{prefix_label}|size', self.size, dims=['year', 'scenario']
+            )

318-326: size_is_fixed and invest_is_optional ignore InvestTimingParameters.
This will return misleading values with timing-based investments.

Apply:

     def size_is_fixed(self) -> bool:
-        # Wenn kein InvestParameters existiert --> True; Wenn Investparameter, den Wert davon nehmen
-        return False if (isinstance(self.size, InvestParameters) and self.size.fixed_size is None) else True
+        # True for scalar sizes. For investments, True only if a fixed_size is provided.
+        if isinstance(self.size, (InvestParameters, InvestTimingParameters)):
+            return getattr(self.size, 'fixed_size', None) is not None
+        return True

     def invest_is_optional(self) -> bool:
-        # Wenn kein InvestParameters existiert: # Investment ist nicht optional -> Keine Variable --> False
-        return False if (isinstance(self.size, InvestParameters) and not self.size.optional) else True
+        # No investment object => not optional.
+        if isinstance(self.size, (InvestParameters, InvestTimingParameters)):
+            return bool(getattr(self.size, 'optional', False))
+        return False
♻️ Duplicate comments (4)
tests/test_models.py (4)

133-141: Same rename here

-        params = fx.YearAwareInvestParameters(fixed_size=50)
+        params = fx.InvestTimingParameters(fixed_size=50)

142-154: Same rename here

-        params = fx.YearAwareInvestParameters(
+        params = fx.InvestTimingParameters(
             fixed_year_of_investment=2,
             minimum_duration=3,
             maximum_duration=5,
             earliest_year_of_decommissioning=4,
         )

155-170: Same rename here

-        params = fx.YearAwareInvestParameters(
+        params = fx.InvestTimingParameters(
             effects_of_investment={'costs': 1000},
             effects_of_investment_per_size={'costs': 100},
             allow_divestment=True,
             effects_of_divestment={'costs': 500},
             effects_of_divestment_per_size={'costs': 50},
         )

171-184: Same rename here

-        params_fixed = fx.YearAwareInvestParameters(fixed_size=50)
+        params_fixed = fx.InvestTimingParameters(fixed_size=50)
@@
-        params_range = fx.YearAwareInvestParameters(minimum_size=10, maximum_size=100)
+        params_range = fx.InvestTimingParameters(minimum_size=10, maximum_size=100)
🧹 Nitpick comments (22)
flixopt/effects.py (2)

450-519: Path explosion risk in BFS conversion accumulation.

Current BFS accumulates all indirect paths and sums factors; dense graphs can grow exponentially. If shares are meant to be unique per target, consider shortest-path (or first valid path) or detect/limit multiple paths per (origin,target).


94-133: Effect.transform_data signature not aligned with Interface.

Base Interface.transform_data now accepts name_prefix; Effect.transform_data ignores it. Unify signatures to avoid surprises for future callers.

-    def transform_data(self, flow_system: 'FlowSystem'):
+    def transform_data(self, flow_system: 'FlowSystem', name_prefix: str = '') -> None:
flixopt/flow_system.py (2)

187-197: Docstring nit: years, not timesteps.

“Calculate duration of each timestep” → “each year.” No functional change.


366-381: with_year_of_investment: clarify and guard.

Good addition. Consider guarding against accidental use when self.years is None to raise a clear error instead of broadcasting an empty coord.

+        if with_year_of_investment and 'year' not in coords:
+            raise ConversionError('with_year_of_investment=True requires "year" dimension in coords.')

Also applies to: 388-395

flixopt/components.py (3)

97-104: Forward name_prefix consistently.

LinearConverter.transform_data accepts name_prefix but calls super().transform_data(flow_system) without forwarding. Align for consistency (even if unused today).

-        super().transform_data(flow_system)
+        super().transform_data(flow_system, name_prefix)

210-251: Same: pass name_prefix in Storage.transform_data.

-        super().transform_data(flow_system)
+        super().transform_data(flow_system, name_prefix)

396-404: Same: pass name_prefix in Transmission.transform_data.

-        super().transform_data(flow_system)
+        super().transform_data(flow_system, name_prefix)
flixopt/interface.py (2)

410-489: No-broadcast guard for investment-year effects is good; error message could hint flow_system.years_of_investment.

Optional string tweak; logic is fine.


568-596: Consistent label argument names.

Use explicit keywords in fit_effects_to_model_coords calls (label_prefix, label_suffix) for readability and future-proofing; no behavior change.

flixopt/commons.py (1)

59-60: Add YearAwareInvestParameters alias to all

Keeps public API aligned with tests and PR terminology.

Apply this diff:

 __all__ = [
@@
-    'InvestTimingParameters',
+    'InvestTimingParameters',
+    'YearAwareInvestParameters',
 ]
+
+# Alias for compatibility with tests/terminology
+YearAwareInvestParameters = InvestTimingParameters
tests/test_models.py (2)

199-214: Test setup nit: drop no-op specific_effects and prefer OSS solver for CI

  • Multiplying by -0 is unnecessary; pass 0 or omit specific_effects.
  • Gurobi may be unavailable in CI. Prefer an OSS solver (e.g., HiGHS) if supported by flixopt.solvers.
-                specific_effects=xr.DataArray(
-                    [25, 30, 35, 40, 45, 50, 55, 60],
-                    coords=(flow_system.years,),
-                )
-                * -0,
+                # No specific effects here
+                # specific_effects=0,
@@
-        calculation.solve(fx.solvers.GurobiSolver(0, 60))
+        # Prefer OSS solver in tests if available
+        # calculation.solve(fx.solvers.HighsSolver())
+        calculation.solve(fx.solvers.GurobiSolver(0, 60))

If HiGHS/CBC are available in flixopt.solvers, switch to them to avoid vendor lock-in for tests.


220-231: Replace prints with minimal assertions

Ensure the test fails meaningfully instead of relying on prints.

Example assertions you can add after solve:

  • assert ds['is_invested'].sum('year') <= 1
  • assert (calculation.results.solution['costs(invest)|total'] <= 0).all()
    Do you want me to propose concrete assertions based on expected values?
flixopt/features.py (3)

14-18: Interface attribute naming: confirm parameter field names

InvestmentTimingModel uses parameters.fix_effects and parameters.specific_effects. Tests and PR text mention effects_of_investment, effects_of_investment_per_size, etc. Ensure InvestTimingParameters exposes both (aliases), or adapt here.

I can add a thin compatibility layer in InvestTimingParameters (properties mapping new→old names) if desired.


212-234: Lifetime computation: check minimum-lifetime handling at horizon end

Current lb constraint doesn’t enforce minimum lifetime if the asset is still invested in the last modeled year. If that’s intentional, add a comment; else enforce a lower bound when decommissioning occurs or investment persists to horizon end.

I can propose a conditional bound using last-period state if you want it enforced in both cases.


343-357: Docstrings: size_increase/size_decrease aren’t binary

These are continuous nonnegative variables. Update docstrings to avoid confusion.

-    def size_decrease(self) -> linopy.Variable:
-        """Binary decrease decision variable"""
+    def size_decrease(self) -> linopy.Variable:
+        """Nonnegative decrease variable"""
@@
-    def size_increase(self) -> linopy.Variable:
-        """Binary increase decision variable"""
+    def size_increase(self) -> linopy.Variable:
+        """Nonnegative increase variable"""
flixopt/modeling.py (1)

597-606: Typo in error message class name

Function lives under ModelingPrimitives; message mentions BoundingPatterns.

-            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')
flixopt/elements.py (6)

161-166: Update docstring to reflect InvestTimingParameters.
The argument docs still only mention InvestParameters.

Apply:

-            size: size of the flow. If InvestmentParameters is used, size is optimized.
+            size: size of the flow. If InvestParameters or InvestTimingParameters are used, size is optimized.

379-402: Investment model selection: message improvement and note on effects once-per-investment.

  • Error message mentions “InvestParameters” only.
  • Ensure InvestmentTimingModel registers investment effects once per actual investment year, not per represented years (addresses the PR’s double-counting risk).

Apply:

-        else:
-            raise ValueError(f'Invalid InvestParameters type: {type(self.element.size)}')
+        else:
+            raise ValueError(f'Unsupported size type for investment: {type(self.element.size)}')

434-441: Use non-deprecated alias for scaling_variable.

Apply:

-                scaling_variable=self._investment.size,
+                scaling_variable=self.investment.size,

175-186: Docstring typos and clarity.
Minor fixes improve readability and accuracy.

Apply:

-            label: The label of the FLow. Used to identify it in the FlowSystem. Its `full_label` consists of the label of the Component and the label of the Flow.
-            bus: blabel of the bus the flow is connected to.
-            size: size of the flow. If InvestmentParameters is used, size is optimized.
+            label: The label of the Flow. Used to identify it in the FlowSystem. Its `full_label` consists of the label of the Component and the label of the Flow.
+            bus: label of the bus the flow is connected to.
+            size: size of the flow. If InvestParameters or InvestTimingParameters are used, size is optimized.

309-311: Fix typo in error message.

Apply:

-                    f'Different values in different years or scenarios are not yetsupported.'
+                    f'Different values in different years or scenarios are not yet supported.'

548-553: Return type and docstring for investment property.
It may be InvestmentModel or InvestmentTimingModel; docstring is misleading.

Apply:

-    def investment(self) -> Optional[InvestmentModel]:
-        """OnOff feature"""
+    def investment(self) -> Optional[Union[InvestmentModel, InvestmentTimingModel]]:
+        """Investment feature"""
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 7467d2b and 299f629.

📒 Files selected for processing (12)
  • flixopt/__init__.py (1 hunks)
  • flixopt/calculation.py (2 hunks)
  • flixopt/commons.py (2 hunks)
  • flixopt/components.py (3 hunks)
  • flixopt/effects.py (2 hunks)
  • flixopt/elements.py (10 hunks)
  • flixopt/features.py (7 hunks)
  • flixopt/flow_system.py (8 hunks)
  • flixopt/interface.py (8 hunks)
  • flixopt/modeling.py (4 hunks)
  • flixopt/structure.py (2 hunks)
  • tests/test_models.py (1 hunks)
⏰ 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). (4)
  • GitHub Check: test (3.12)
  • GitHub Check: test (3.13)
  • GitHub Check: test (3.10)
  • GitHub Check: test (3.11)
🔇 Additional comments (18)
flixopt/effects.py (1)

432-448: Broadcast safety for invest shares.

factor may be a non-temporal DataArray over ['year','scenario']; explicitly align/reindex it to origin_effect.submodel.invest.total coords (e.g., xarray.align / reindex_like or explicit broadcast) before calling add_share to avoid silent broadcasting mismatches.

File: flixopt/effects.py lines 432–448

Automated repo search returned no results — manual verification of other call-sites or that add_share enforces coord alignment is required.

flixopt/components.py (1)

439-447: Equation form is correct; comment matches.

The rearranged constraint with lhs += absolute_losses still matches the documented equation.

flixopt/interface.py (1)

74-77: Piece/Piecewise/PiecewiseConversion: transform_data propagation looks correct.

Signatures and prefixing are consistent.

Also applies to: 107-110, 138-142

flixopt/__init__.py (1)

19-20: Public API: OK to expose InvestTimingParameters

Re-export looks good and matches the new feature.

flixopt/commons.py (1)

21-29: Expose InvestTimingParameters: LGTM

The import block and exposure are consistent.

flixopt/calculation.py (2)

31-33: Include InvestmentTimingModel: LGTM

Type inclusion in imports is correct.


116-125: Main results: correctly include timing-based investments

Extending isinstance filter to InvestmentTimingModel is correct.

flixopt/features.py (5)

78-106: InvestmentModel effects logic: OK

Effects hookup is correct and consistent with existing API.


133-141: Great: single invest/decommission window

Modeling matches the intended contiguous on-period between invest and decommission.


161-181: Bounds with state for size: OK

Size controlled by is_invested with [min,max] is correct.


195-211: At most one invest and one decommission: OK

Sum ≤ 1 constraints are correct for a single asset cycle.


235-260: size/Δsize linkage: correct pattern choice

Linking increases/decreases to binaries with exclusivity is solid.

flixopt/modeling.py (2)

664-675: Nice addition: link_changes_to_level_with_binaries

API is useful and well-composed.


458-466: OnOff: duration tracking: LGTM

Passing duration_per_step and duration_dim is correct and future-proof.

Also applies to: 476-479

flixopt/elements.py (4)

16-18: Imports look correct and consistent with new timing model.


279-287: Good exclusion of InvestTimingParameters from the default-size warning.


451-452: with_investment correctly recognizes both investment parameter types.


524-530: Resolved — InvestTimingParameters (year-aware invest class) exposes minimum_or_fixed_size and maximum_or_fixed_size. Properties are implemented in flixopt/interface.py and covered by tests; no runtime error expected.

Comment on lines +281 to 306
if self.parameters.fixed_effects_by_investment_year:
# Effects depending on when the investment is made
remapped_variable = self.investment_occurs.rename({'year': 'year_of_investment'})

self._model.effects.add_share_to_effects(
name=self.label_of_element,
expressions={
effect: -self.is_invested * factor + factor
for effect, factor in self.parameters.divest_effects.items()
effect: (remapped_variable * factor).sum('year_of_investment')
for effect, factor in self.parameters.fixed_effects_by_investment_year.items()
},
target='invest',
)

if self.parameters.specific_effects:
if self.parameters.specific_effects_by_investment_year:
# Annual effects proportional to investment size
remapped_variable = self.size_increase.rename({'year': 'year_of_investment'})

self._model.effects.add_share_to_effects(
name=self.label_of_element,
expressions={effect: self.size * factor for effect, factor in self.parameters.specific_effects.items()},
expressions={
effect: (remapped_variable * factor).sum('year_of_investment')
for effect, factor in self.parameters.specific_effects_by_investment_year.items()
},
target='invest',
)

Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Avoid double counting one-off effects when representative years have weights

For fixed_effects_by_investment_year, summing only over 'year_of_investment' leaves a 'year' dim if the factor carries it, which can trigger replication when totals get weighted by years_per_year. Sum over both dims present in the factor to ensure one-shot effects remain one-shot.

Apply this diff:

-            self._model.effects.add_share_to_effects(
-                name=self.label_of_element,
-                expressions={
-                    effect: (remapped_variable * factor).sum('year_of_investment')
-                    for effect, factor in self.parameters.fixed_effects_by_investment_year.items()
-                },
-                target='invest',
-            )
+            exprs = {}
+            for effect, factor in self.parameters.fixed_effects_by_investment_year.items():
+                expr = remapped_variable * factor
+                # Sum across any investment-year dim and drop plain 'year' if present to avoid replication
+                if hasattr(factor, "dims"):
+                    for dim in ('year_of_investment', 'year'):
+                        if dim in getattr(factor, "dims", ()):
+                            expr = expr.sum(dim)
+                else:
+                    expr = expr.sum('year_of_investment')
+                exprs[effect] = expr
+            self._model.effects.add_share_to_effects(
+                name=self.label_of_element, expressions=exprs, target='invest'
+            )

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
flixopt/features.py around lines 281 to 306: the current
.sum('year_of_investment') can leave a remaining 'year' dimension when the
factor carries it, causing double-counting with representative-year weights;
change the summation to collapse both dims present by summing over a tuple of
dims that exist (e.g. create dims_to_sum = tuple(d for d in
('year_of_investment','year') if d in factor.dims) and call .sum(dims_to_sum)
for each expression) so one-off effects are summed out correctly and not
replicated by year weights.

Comment on lines +379 to +382
def duration(self) -> linopy.Variable:
"""Investment duration variable"""
return self._variables['duration']

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

Property ‘duration’ references a non-existent variable

No variable named 'duration' is created; property will KeyError. Remove it or create and populate 'duration'.

Quick fix: remove the property until implemented.

-    @property
-    def duration(self) -> linopy.Variable:
-        """Investment duration variable"""
-        return self._variables['duration']

If you want me to implement a duration variable tied to lifetime/years, I can propose it.

📝 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 duration(self) -> linopy.Variable:
"""Investment duration variable"""
return self._variables['duration']
🤖 Prompt for AI Agents
In flixopt/features.py around lines 379-382, the duration property returns
self._variables['duration'] but no 'duration' key is ever created, causing a
KeyError; either remove this property until a duration variable is implemented,
or add code where self._variables is populated to create and assign a
linopy.Variable named 'duration' (and ensure it is kept in sync with the model's
lifetime/years semantics), i.e., create the variable with the correct shape/type
and insert it into self._variables['duration'] where other variables are
initialized.

Comment on lines +767 to 791
ds = self.to_dataset()

# Build indexers dict from non-None parameters
indexers = {}
if time is not None:
indexers['time'] = time
if year is not None:
indexers['year'] = year
if 'year_of_investment' in ds.dims:
indexers['year_of_investment'] = year
if scenario is not None:
indexers['scenario'] = scenario

if not indexers:
return self.copy() # Return a copy when no selection

selected_dataset = self.to_dataset().sel(**indexers)
selected_dataset = ds.sel(**indexers)
if 'year_of_investment' in selected_dataset.coords and selected_dataset.coords['year_of_investment'].size == 1:
logger.critical(
'Selected a single year while using InvestmentTiming. This is not supported and will lead to Errors '
'when trying to create a Calculation from this FlowSystem. Please select multiple years instead, '
'or remove the InvestmentTimingParameters.'
)
return self.__class__.from_dataset(selected_dataset)

Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Selecting a single year under InvestmentTiming: escalate to exception.

A logger.critical without raising still returns a FlowSystem that will fail later. Raise early to simplify debugging.

-        if 'year_of_investment' in selected_dataset.coords and selected_dataset.coords['year_of_investment'].size == 1:
-            logger.critical(
+        if 'year_of_investment' in selected_dataset.coords and selected_dataset.coords['year_of_investment'].size == 1:
+            raise ValueError(
                 'Selected a single year while using InvestmentTiming. This is not supported and will lead to Errors '
                 'when trying to create a Calculation from this FlowSystem. Please select multiple years instead, '
                 'or remove the InvestmentTimingParameters.'
             )

Also applies to: 812-835

🤖 Prompt for AI Agents
In flixopt/flow_system.py around lines 767-791 and 812-835, the code currently
logs a critical error when a selection results in a single 'year_of_investment'
but continues and returns a FlowSystem that will fail later; change this to
raise an exception immediately (e.g., raise ValueError with the same descriptive
message) instead of only logging, or log then raise, so the caller fails fast
and debugging is easier.

Comment on lines +338 to +369
def _plausibility_checks(self, flow_system):
"""Validate parameter consistency."""
if flow_system.years is None:
raise ValueError("InvestTimingParameters requires the flow_system to have a 'years' dimension.")

if (self.force_investment.sum('year') > 1).any():
raise ValueError('force_investment can only be True for a single year.')
if (self.force_decommissioning.sum('year') > 1).any():
raise ValueError('force_decommissioning can only be True for a single year.')

if (self.force_investment.sum('year') == 1).any() and (self.force_decommissioning.sum('year') == 1).any():
year_of_forced_investment = (
self.force_investment.where(self.force_investment) * self.force_investment.year
).sum('year')
year_of_forced_decommissioning = (
self.force_decommissioning.where(self.force_decommissioning) * self.force_decommissioning.year
).sum('year')
if not (year_of_forced_investment < year_of_forced_decommissioning).all():
raise ValueError(
f'force_investment needs to be before force_decommissioning. Got:\n'
f'{self.force_investment}\nand\n{self.force_decommissioning}'
)

if self.previous_lifetime is not None:
if self.fixed_size is None:
# TODO: Might be only a warning
raise ValueError('previous_lifetime can only be used if fixed_size is defined.')
if self.force_investment is False:
# TODO: Might be only a warning
raise ValueError('previous_lifetime can only be used if force_investment is True.')

if self.minimum_or_fixed_lifetime is not None and self.maximum_or_fixed_lifetime is not None:
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

Incorrect checks for forced investment/decommissioning.

  • self.force_investment is False compares identity to a DataArray; always false.
  • “exactly one forced year” logic uses > 1 where == 1 (or .any()) is intended.

Apply:

-        if (self.force_investment.sum('year') > 1).any():
+        if (self.force_investment.sum('year') > 1).any():
             raise ValueError('force_investment can only be True for a single year.')
-        if (self.force_decommissioning.sum('year') > 1).any():
+        if (self.force_decommissioning.sum('year') > 1).any():
             raise ValueError('force_decommissioning can only be True for a single year.')
@@
-        if self.previous_lifetime is not None:
+        if self.previous_lifetime is not None:
             if self.fixed_size is None:
                 # TODO: Might be only a warning
                 raise ValueError('previous_lifetime can only be used if fixed_size is defined.')
-            if self.force_investment is False:
+            if not bool(self.force_investment.any()):
                 # TODO: Might be only a warning
                 raise ValueError('previous_lifetime can only be used if force_investment is True.')
@@
-        specify_timing = (
-            (self.fixed_lifetime is not None)
-            + bool((self.force_investment.sum('year') > 1).any())
-            + bool((self.force_decommissioning.sum('year') > 1).any())
-        )
+        specify_timing = (
+            (self.fixed_lifetime is not None)
+            + bool(self.force_investment.any())
+            + bool(self.force_decommissioning.any())
+        )
📝 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 _plausibility_checks(self, flow_system):
"""Validate parameter consistency."""
if flow_system.years is None:
raise ValueError("InvestTimingParameters requires the flow_system to have a 'years' dimension.")
if (self.force_investment.sum('year') > 1).any():
raise ValueError('force_investment can only be True for a single year.')
if (self.force_decommissioning.sum('year') > 1).any():
raise ValueError('force_decommissioning can only be True for a single year.')
if (self.force_investment.sum('year') == 1).any() and (self.force_decommissioning.sum('year') == 1).any():
year_of_forced_investment = (
self.force_investment.where(self.force_investment) * self.force_investment.year
).sum('year')
year_of_forced_decommissioning = (
self.force_decommissioning.where(self.force_decommissioning) * self.force_decommissioning.year
).sum('year')
if not (year_of_forced_investment < year_of_forced_decommissioning).all():
raise ValueError(
f'force_investment needs to be before force_decommissioning. Got:\n'
f'{self.force_investment}\nand\n{self.force_decommissioning}'
)
if self.previous_lifetime is not None:
if self.fixed_size is None:
# TODO: Might be only a warning
raise ValueError('previous_lifetime can only be used if fixed_size is defined.')
if self.force_investment is False:
# TODO: Might be only a warning
raise ValueError('previous_lifetime can only be used if force_investment is True.')
if self.minimum_or_fixed_lifetime is not None and self.maximum_or_fixed_lifetime is not None:
def _plausibility_checks(self, flow_system):
"""Validate parameter consistency."""
if flow_system.years is None:
raise ValueError("InvestTimingParameters requires the flow_system to have a 'years' dimension.")
if (self.force_investment.sum('year') > 1).any():
raise ValueError('force_investment can only be True for a single year.')
if (self.force_decommissioning.sum('year') > 1).any():
raise ValueError('force_decommissioning can only be True for a single year.')
if (self.force_investment.sum('year') == 1).any() and (self.force_decommissioning.sum('year') == 1).any():
year_of_forced_investment = (
self.force_investment.where(self.force_investment) * self.force_investment.year
).sum('year')
year_of_forced_decommissioning = (
self.force_decommissioning.where(self.force_decommissioning) * self.force_decommissioning.year
).sum('year')
if not (year_of_forced_investment < year_of_forced_decommissioning).all():
raise ValueError(
f'force_investment needs to be before force_decommissioning. Got:\n'
f'{self.force_investment}\nand\n{self.force_decommissioning}'
)
if self.previous_lifetime is not None:
if self.fixed_size is None:
# TODO: Might be only a warning
raise ValueError('previous_lifetime can only be used if fixed_size is defined.')
if not bool(self.force_investment.any()):
# TODO: Might be only a warning
raise ValueError('previous_lifetime can only be used if force_investment is True.')
if self.minimum_or_fixed_lifetime is not None and self.maximum_or_fixed_lifetime is not None:
specify_timing = (
(self.fixed_lifetime is not None)
bool(self.force_investment.any())
bool(self.force_decommissioning.any())
)
🤖 Prompt for AI Agents
In flixopt/interface.py around lines 338-369, fix two checks: replace the
identity comparison "if self.force_investment is False" with a proper
DataArray-based test (e.g. check whether there is any True year: use
self.force_investment.any(...) or verify no forced year via
(self.force_investment.sum('year') == 0).any()), and ensure the "exactly one
forced year" logic uses equality checks where intended (use
(self.force_investment.sum('year') == 1).any() and similarly for
force_decommissioning) rather than identity or incorrect comparisons; update the
conditional expressions accordingly so all branches correctly test DataArray
contents across the 'year' dimension.

Comment on lines +181 to 186
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'],
)

Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Split weights: operation vs investment.

Using years_per_year for all effects causes invest double-counting. Keep current property for operation; add a scenario-only weights_invest and use it in effects objective.

Proposed additions:

     @property
     def weights(self) -> Union[int, xr.DataArray]:
         """Returns the scenario weights of the FlowSystem. If None, return weights that are normalized to 1 (one)"""
         if self.flow_system.weights is None:
             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'],
             )
             return weights / weights.sum()
         return self.flow_system.weights
+
+    @property
+    def weights_invest(self) -> xr.DataArray:
+        """Scenario-only weights (no year scaling) for investment terms."""
+        if self.flow_system.weights is None:
+            w = self.flow_system.fit_to_model_coords('weights_invest', 1, dims=['year', 'scenario'])
+            # normalize over scenarios only
+            scen_sum = w.sum('year')
+            scen_sum = scen_sum / scen_sum.sum()
+            return xr.DataArray(
+                np.broadcast_to(scen_sum.values, w.shape),
+                dims=w.dims,
+                coords=w.coords,
+                name='weights_invest',
+            )
+        # user provided weights: drop year scaling by summing over years
+        return (self.flow_system.weights / self.flow_system.weights.sum()).sum('year')

Then use weights_invest in the effects objective (see effects.py comment).

🤖 Prompt for AI Agents
In flixopt/structure.py around lines 181-186, the current weights calculation
uses years_per_year for all effects which double-counts investments; keep the
existing weights property for operational effects (using years_per_year) but add
a new scenario-only weights_invest property that does not multiply by years.
Create self.weights_invest = self.flow_system.fit_to_model_coords('weights', 1,
dims=['scenario']) (i.e. scalar 1 per scenario) and ensure the effects objective
(effects.py) is changed to use self.weights_invest for investment-related terms
while leaving operational terms using the existing self.weights.

Comment on lines +24 to +43
def calculate_annual_payment(total_cost: float, remaining_years: int, discount_rate: float) -> float:
"""Calculate annualized payment for given remaining years.
Args:
total_cost: Total cost to be annualized.
remaining_years: Number of remaining years.
discount_rate: Discount rate for annualization.
Returns:
Annual payment amount.
"""
if remaining_years == 1:
return total_cost

return (
total_cost
* (discount_rate * (1 + discount_rate) ** remaining_years)
/ ((1 + discount_rate) ** remaining_years - 1)
)

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 zero discount rate in annuity helper

Avoid division by zero when discount_rate == 0.

Apply this diff:

 def calculate_annual_payment(total_cost: float, remaining_years: int, discount_rate: float) -> float:
@@
-    if remaining_years == 1:
+    if remaining_years == 1:
         return total_cost
-
-    return (
+    if discount_rate == 0:
+        return total_cost / remaining_years
+    return (
         total_cost
         * (discount_rate * (1 + discount_rate) ** remaining_years)
         / ((1 + discount_rate) ** remaining_years - 1)
     )
📝 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 calculate_annual_payment(total_cost: float, remaining_years: int, discount_rate: float) -> float:
"""Calculate annualized payment for given remaining years.
Args:
total_cost: Total cost to be annualized.
remaining_years: Number of remaining years.
discount_rate: Discount rate for annualization.
Returns:
Annual payment amount.
"""
if remaining_years == 1:
return total_cost
return (
total_cost
* (discount_rate * (1 + discount_rate) ** remaining_years)
/ ((1 + discount_rate) ** remaining_years - 1)
)
def calculate_annual_payment(total_cost: float, remaining_years: int, discount_rate: float) -> float:
"""Calculate annualized payment for given remaining years.
Args:
total_cost: Total cost to be annualized.
remaining_years: Number of remaining years.
discount_rate: Discount rate for annualization.
Returns:
Annual payment amount.
"""
if remaining_years == 1:
return total_cost
if discount_rate == 0:
return total_cost / remaining_years
return (
total_cost
* (discount_rate * (1 + discount_rate) ** remaining_years)
/ ((1 + discount_rate) ** remaining_years - 1)
)
🤖 Prompt for AI Agents
In tests/test_models.py around lines 24 to 43, the annuity helper divides by
((1+discount_rate)**remaining_years - 1) which will raise a ZeroDivisionError
when discount_rate == 0; add an early branch handling discount_rate == 0 (after
the existing remaining_years == 1 check) that returns total_cost /
remaining_years, otherwise keep the existing formula.

Comment on lines +118 to +132
def test_basic_initialization(self):
"""Test basic parameter initialization."""
params = fx.YearAwareInvestParameters(
minimum_size=10,
maximum_size=100,
)

assert params.minimum_size == 10
assert params.maximum_size == 100
assert params.fixed_size is None
assert not params.allow_divestment
assert params.fixed_year_of_investment is None
assert params.fixed_year_of_decommissioning is None
assert params.fixed_duration is None

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

Class name mismatch: use InvestTimingParameters (or add alias)

The codebase exposes InvestTimingParameters; YearAwareInvestParameters isn’t exported. Either rely on the alias added in init.py/commons.py or switch tests to InvestTimingParameters.

Apply this diff if you prefer to update tests:

-        params = fx.YearAwareInvestParameters(
+        params = fx.InvestTimingParameters(
             minimum_size=10,
             maximum_size=100,
         )

Repeat the same rename for all occurrences in this file.

📝 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 test_basic_initialization(self):
"""Test basic parameter initialization."""
params = fx.YearAwareInvestParameters(
minimum_size=10,
maximum_size=100,
)
assert params.minimum_size == 10
assert params.maximum_size == 100
assert params.fixed_size is None
assert not params.allow_divestment
assert params.fixed_year_of_investment is None
assert params.fixed_year_of_decommissioning is None
assert params.fixed_duration is None
def test_basic_initialization(self):
"""Test basic parameter initialization."""
params = fx.InvestTimingParameters(
minimum_size=10,
maximum_size=100,
)
assert params.minimum_size == 10
assert params.maximum_size == 100
assert params.fixed_size is None
assert not params.allow_divestment
assert params.fixed_year_of_investment is None
assert params.fixed_year_of_decommissioning is None
assert params.fixed_duration is None
🤖 Prompt for AI Agents
In tests/test_models.py around lines 118 to 132, the test uses
YearAwareInvestParameters which is not exported; replace all occurrences of
YearAwareInvestParameters in this file with the exported class name
InvestTimingParameters (or add an explicit alias import if you prefer), and
update any imports at the top of the file accordingly so the tests reference the
exported InvestTimingParameters class consistently.

@FBumann FBumann added the Not in Focus This seems reasonable, but we are not working on it at the moment. label Sep 23, 2025
@FBumann
Copy link
Member Author

FBumann commented Oct 13, 2025

Closed by #348

@FBumann FBumann closed this Oct 13, 2025
@FBumann FBumann deleted the v3.0.0/invest branch October 13, 2025 21:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

New functionality Add entirely new functionality Not in Focus This seems reasonable, but we are not working on it at the moment.

Projects

None yet

3 participants