Skip to content

fix: explicit adjacent-timestep alignment for linopy v1 compat#684

Open
FBumann wants to merge 2 commits into
mainfrom
fix/linopy-v1-positional-alignment
Open

fix: explicit adjacent-timestep alignment for linopy v1 compat#684
FBumann wants to merge 2 commits into
mainfrom
fix/linopy-v1-positional-alignment

Conversation

@FBumann
Copy link
Copy Markdown
Member

@FBumann FBumann commented May 27, 2026

Summary

  • Relabel adjacent-step slices with .assign_coords(...) at the handful of sites where flixopt combines [1:] and [:-1] of the same variable to build difference/transition constraints. Under linopy's legacy semantics these aligned silently by position; the upcoming v1 convention (PyPSA/linopy#717) requires shared-dim labels to match exactly.
  • Pass drop=True to scalar .isel(time=0) / .isel(time=-1) patterns so leftover aux coords don't conflict on combination.
  • In consecutive_duration_tracking, switch to a lag-label convention throughout so the lb constraint references duration at the on-state moment (the off-transition timestep has duration=0).

Sites

  • flixopt/components.py:_build_energy_balance_lhs — storage energy balance.
  • flixopt/components.py:_add_cluster_cyclic_constraint, _add_initial_final_constraints — scalar .isel calls.
  • flixopt/modeling.py:ModelingPrimitives.consecutive_duration_tracking — forward / backward / initial / lb.
  • flixopt/modeling.py:BoundingPatterns.state_transition_bounds — transition + initial.
  • flixopt/features.py:InvestmentModel linked-periods constraint.
  • flixopt/features.py:StatusModel._add_cluster_cyclic_constraint.

Validation

With linopy.options.set_value(semantics='v1'):

  • tests/test_math/: 383 pass / 3 fail (was 236/150). The 3 remaining failures are pre-existing on main (TestClusteringExact::test_storage_cyclic_charge_discharge_pattern), unrelated to this change.
  • Broader tests/ (excluding test_math/): no v1-specific regressions — same 68 pre-existing failures under both legacy and v1.

Test plan

  • pytest tests/test_math/ passes under legacy semantics (no behavior change).
  • pytest tests/test_math/ passes under v1 (linopy.options.set_value(semantics='v1')) with only the 3 pre-existing failures.
  • Solve a representative model with both modes and confirm equal objective and SOC trajectories.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Fixed constraint construction and time-coordinate alignment for cyclic operation and energy-balance calculations to ensure consistent behavior at period boundaries.
    • Resolved incorrect timestep handling in state-transition and duration-tracking constraints so constraints align correctly across consecutive timesteps.
  • Refactor

    • Reworked constraint slicing and coordinate-assignment logic for clearer, more robust timestep alignment.

Review Change Stack

flixopt builds adjacent-step constraints (storage energy balance, state
transitions, consecutive-duration tracking, multi-period linking) by
slicing the same variable at `[1:]` and `[:-1]` and combining them. The
two slices carry different time/period labels by construction, and
linopy's legacy semantics silently aligned them positionally. Under
the new opt-in v1 semantics (`linopy.options['semantics']='v1'`),
shared-dim labels must match exactly, so every such site raised.

Relabel the lead slice onto the lag axis (or vice versa) via
`.assign_coords(...)` so positional intent is explicit. For scalar
`.isel(time=0)` / `.isel(time=-1)` patterns, pass `drop=True` to avoid
leftover aux-coord conflicts.

In `consecutive_duration_tracking`, the `lb` constraint must reference
duration at the on-state moment, not the off-transition that follows —
so the helper uses lag-label convention throughout.

Verified with `linopy.options.set_value(semantics='v1')`: 383/386
`tests/test_math/` pass (the remaining 3 are pre-existing failures on
main, unrelated to this change). No regression under legacy semantics.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 27, 2026

📝 Walkthrough

Walkthrough

This PR refactors constraint construction across StorageModel, InvestmentModel, StatusModel, and duration/state-transition tracking functions to use explicit coordinate slicing with drop=True and assign_coords alignment, ensuring consistent dimensional handling when comparing sliced variables across timesteps and periods.

Changes

Coordinate alignment and dimensionality consistency

Layer / File(s) Summary
StorageModel cyclic and energy-balance constraint alignment
flixopt/components.py
Cyclic constraint applies drop=True when slicing charge_state across the extra timestep; energy-balance LHS explicitly defines charge_state_t and charge_state_tp1 with reassigned time coordinates so the difference term aligns over regular timesteps.
StorageModel initial and final charge-state constraints
flixopt/components.py
All clustered mode initial/final charge-state constraints—including equality and inequality paths—consistently use isel(..., drop=True) for dimensional consistency.
InvestmentModel and StatusModel period and time alignment
flixopt/features.py
InvestmentModel's linked_periods constraint for size reassigns shifted period coordinates on the left-hand side before comparison; StatusModel's cyclic cluster constraint applies drop=True to time endpoint indices during equality comparison.
Duration tracking constraint refactoring
flixopt/modeling.py
consecutive_duration_tracking() replaces slice-based indexing with explicit lag and lead coordinate slices and assign_coords alignment; forward, backward, and minimum-duration constraints now explicitly map duration[t], duration[t+1], and duration_per_step[t] onto shared dimensions, and initial/minimum-duration constraints use drop=True scalar selectors.
State transition constraint refactoring
flixopt/modeling.py
state_transition_bounds() builds t > 0 transition constraints using explicit lead and lag slices with assign_coords so that activate[t] - deactivate[t] aligns with state[t] - state[t-1]; t=0 initial constraint uses drop=True scalars for both activation/deactivation and state operands.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • flixOpt/flixopt#380: Both PRs modify flixopt/features.py's InvestmentModel linked_periods constraint to enforce consistent multi-period investment size, with this PR additionally fixing coordinate alignment when equating consecutive periods.
  • flixOpt/flixopt#372: Overlaps with refactors in flixopt/modeling.py around consecutive-duration and state-transition logic that this PR updates to use lead/lag and assign_coords.

Suggested labels

v3.0.0

Poem

🐰 Coordinates align, dimensions drop with care,
Lag and lead dance through constraints fair,
StorageModel, Investment alike renewed,
Time slices match, where data once flew askew!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main technical change: explicit adjacent-timestep alignment for linopy v1 compatibility.
Description check ✅ Passed The description provides comprehensive context, multiple edit sites, validation results, and a test plan, though some checklist items are marked incomplete.
Docstring Coverage ✅ Passed Docstring coverage is 88.89% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/linopy-v1-positional-alignment

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

❤️ Share

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

Copy link
Copy Markdown
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: 2

Caution

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

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

867-874: ⚠️ Potential issue | 🟠 Major | 💤 Low value

Missing linopy v1 label-alignment in link_changes_to_level_with_binaries transition constraint

link_changes_to_level_with_binaries builds level[t] = level[t-1] + increase[t] - decrease[t] with lead/lag isel slices but never relabels the lag slice onto the lead axis via assign_coords (see flixopt/modeling.py:868-873). This is inconsistent with nearby helpers that explicitly note linopy v1 requires matching labels on shared dims and fix it via state.isel(lag).assign_coords(lead_coord) (and the same adjacent-slice pattern also exists in continuous_transition_bounds).

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

In `@flixopt/modeling.py` around lines 867 - 874, The transition constraint in
link_changes_to_level_with_binaries constructs level[t] == level[t-1] +
increase[t] - decrease[t] using isel lead/lag slices but does not align the lag
slice labels to the lead axis; update the constraint to reassign coordinates on
the lag slices (e.g., use level_variable.isel({coord: slice(None,
-1)}).assign_coords({coord: level_variable.isel({coord: slice(1,
None)}).coords[coord]}) or the equivalent pattern used elsewhere before adding
the constraint so that level_variable, increase_variable, and decrease_variable
share the same coord labels (follow the assign_coords(lag->lead) pattern used in
other helpers like continuous_transition_bounds) to satisfy linopy v1 label
alignment requirements.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@flixopt/modeling.py`:
- Around line 729-732: The constraint call creating transition via
model.add_constraints is misformatted; reformat the call to match the forward
constraint style by placing the entire expression as the first argument on its
own line and the name= keyword on a separate line (i.e., break after
model.add_constraints(, put the expression involving activate.isel(lead) -
deactivate.isel(lead) == state.isel(lead) -
state.isel(lag).assign_coords(lead_coord) on its own line, then the
name=f'{name}|transition' on the next line) so the parentheses and arguments
align consistently with the other add_constraints usages (symbols: transition,
model.add_constraints, activate, deactivate, state, isel, assign_coords, name).
- Around line 415-418: The constraint expression assigned to
constraints['forward'] is split across too many lines and fails ruff formatting;
reformat the call to model.add_constraints so the whole boolean expression and
its arguments fit on fewer lines (e.g., place the left-hand expression, the
comparison and the name argument on the same or fewer lines) while keeping the
same semantics: update the code around constraints['forward'] where
model.add_constraints is called with
duration.isel(lead).assign_coords(lag_coord) <= duration.isel(lag) +
duration_per_step.isel(lag) and name=f'{duration.name}|forward' so it conforms
to ruff/PEP8 line-length/formatting rules.

---

Outside diff comments:
In `@flixopt/modeling.py`:
- Around line 867-874: The transition constraint in
link_changes_to_level_with_binaries constructs level[t] == level[t-1] +
increase[t] - decrease[t] using isel lead/lag slices but does not align the lag
slice labels to the lead axis; update the constraint to reassign coordinates on
the lag slices (e.g., use level_variable.isel({coord: slice(None,
-1)}).assign_coords({coord: level_variable.isel({coord: slice(1,
None)}).coords[coord]}) or the equivalent pattern used elsewhere before adding
the constraint so that level_variable, increase_variable, and decrease_variable
share the same coord labels (follow the assign_coords(lag->lead) pattern used in
other helpers like continuous_transition_bounds) to satisfy linopy v1 label
alignment requirements.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c4b37b0b-0c03-4311-a1f4-a25fab04ecdc

📥 Commits

Reviewing files that changed from the base of the PR and between 6756fb7 and 212b3c8.

📒 Files selected for processing (3)
  • flixopt/components.py
  • flixopt/features.py
  • flixopt/modeling.py

Comment thread flixopt/modeling.py
Comment thread flixopt/modeling.py
Apply the same lag→lead `.assign_coords(...)` and `drop=True` pattern
to two more helpers that build adjacent-step constraints with
positional `.isel()` — currently unused by flixopt's tests but defined
in the public modeling primitives, so they would raise under linopy v1
when called. Also collapses ruff-formatted lines.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@FBumann FBumann changed the title Make adjacent-timestep alignment explicit (linopy v1 compat) fix: explicit adjacent-timestep alignment for linopy v1 compat May 27, 2026
Copy link
Copy Markdown
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.

Caution

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

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

389-390: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Fix scalar handling for duration_per_step (runtime breakage) + check max_change initial bound indexing

consecutive_duration_tracking(..., duration_per_step: int | float | xr.DataArray) declares scalars are allowed, but flixopt/modeling.py unconditionally calls duration_per_step.sum(...) and duration_per_step.isel(...) (including duration_per_step.isel({duration_dim: 0}, drop=True)), so passing a plain int/float will raise at runtime.

Also re-check BoundingPatterns.continuous_transition_bounds: while activate/deactivate for the initial step are sliced to t=0, initial_bound = max_change * (activate.isel({coord: 0}, drop=True) + deactivate.isel({coord: 0}, drop=True)) leaves max_change unsliced. If max_change is time-indexed xr.DataArray (not scalar), this can broadcast it across time and overconstrain/mismatch the initial-step constraint.

💡 Minimal fix to honor the declared scalar/DataArray contract
-        mega = duration_per_step.sum(duration_dim) + (previous_duration if previous_duration is not None else 0)
+        mega = _scalar_safe_reduce(duration_per_step, duration_dim, method='sum') + (
+            previous_duration if previous_duration is not None else 0
+        )
@@
+        duration_step_lag = _scalar_safe_isel(duration_per_step, lag)
         constraints['forward'] = model.add_constraints(
-            duration.isel(lead).assign_coords(lag_coord) <= duration.isel(lag) + duration_per_step.isel(lag),
+            duration.isel(lead).assign_coords(lag_coord) <= duration.isel(lag) + duration_step_lag,
             name=f'{duration.name}|forward',
         )
@@
-                == (duration_per_step.isel({duration_dim: 0}, drop=True) + previous_duration)
+                == (_scalar_safe_isel_drop(duration_per_step, duration_dim, 0) + previous_duration)
                 * state.isel({duration_dim: 0}, drop=True),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@flixopt/modeling.py` around lines 389 - 390, The code assumes
duration_per_step is always an xr.DataArray but the signature allows int/float;
update consecutive_duration_tracking to handle scalar vs DataArray: if
duration_per_step is a plain int/float compute mega = float(duration_per_step) *
number_of_steps + (previous_duration or 0) or convert scalar to an xr.DataArray
matching the expected dims before calling .sum(...) and .isel(...); only call
.sum and .isel when duration_per_step is an xr.DataArray. Also fix
BoundingPatterns.continuous_transition_bounds: when computing initial_bound =
max_change * (activate.isel({coord: 0}, drop=True) + deactivate.isel({coord: 0},
drop=True)), ensure max_change is sliced the same way (e.g., use
max_change.isel({coord: 0}, drop=True) when max_change is a DataArray, otherwise
use the scalar directly) so a time-indexed max_change is not left unsliced and
broadcasting/mismatches are avoided.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@flixopt/modeling.py`:
- Around line 389-390: The code assumes duration_per_step is always an
xr.DataArray but the signature allows int/float; update
consecutive_duration_tracking to handle scalar vs DataArray: if
duration_per_step is a plain int/float compute mega = float(duration_per_step) *
number_of_steps + (previous_duration or 0) or convert scalar to an xr.DataArray
matching the expected dims before calling .sum(...) and .isel(...); only call
.sum and .isel when duration_per_step is an xr.DataArray. Also fix
BoundingPatterns.continuous_transition_bounds: when computing initial_bound =
max_change * (activate.isel({coord: 0}, drop=True) + deactivate.isel({coord: 0},
drop=True)), ensure max_change is sliced the same way (e.g., use
max_change.isel({coord: 0}, drop=True) when max_change is a DataArray, otherwise
use the scalar directly) so a time-indexed max_change is not left unsliced and
broadcasting/mismatches are avoided.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6f89ea7c-3ebd-4391-ba7d-aa05d57bb619

📥 Commits

Reviewing files that changed from the base of the PR and between 212b3c8 and f29297d.

📒 Files selected for processing (1)
  • flixopt/modeling.py

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant