Draft
Conversation
- Created BusesModel(TypeModel) class that handles ALL buses in one instance
- Creates batched virtual_supply and virtual_demand variables for buses with imbalance penalty
- Creates bus balance constraints: sum(inputs) == sum(outputs) (with virtual supply/demand adjustment for imbalance)
- Created BusModelProxy for lightweight proxy in type-level mode
Effect Shares Refactoring
The effect shares pattern was refactored for cleaner architecture:
Before: TypeModels directly modified effect constraints
After: TypeModels declare specs → Effects system applies them
1. FlowsModel now has:
- collect_effect_share_specs() - returns dict of effect specs
- create_effect_shares() - delegates to EffectCollectionModel
2. BusesModel now has:
- collect_penalty_share_specs() - returns list of penalty expressions
- create_effect_shares() - delegates to EffectCollectionModel
3. EffectCollectionModel now has:
- apply_batched_flow_effect_shares() - applies flow effect specs in bulk
- apply_batched_penalty_shares() - applies penalty specs in bulk
Architecture
TypeModels declare specs → Effects applies them in bulk
1. FlowsModel.collect_effect_share_specs() - Returns dict of effect specs
2. BusesModel.collect_penalty_share_specs() - Returns list of penalty specs
3. EffectCollectionModel.apply_batched_flow_effect_shares() - Creates batched share variables
4. EffectCollectionModel.apply_batched_penalty_shares() - Creates penalty share variables
Per-Element Contribution Visibility
The share variables now preserve per-element information:
flow_effects->costs(temporal)
dims: ('element', 'time')
element coords: ['Grid(elec)', 'HP(elec_in)']
You can query individual contributions:
# Get Grid's contribution to costs
grid_costs = results['flow_effects->costs(temporal)'].sel(element='Grid(elec)')
# Get HP's contribution
hp_costs = results['flow_effects->costs(temporal)'].sel(element='HP(elec_in)')
Performance
Still maintains 8.8-14.2x speedup because:
- ONE batched variable per effect (not one per element)
- ONE vectorized constraint per effect
- Element dimension enables per-element queries without N separate variables
Architecture - StoragesModel - handles ALL basic (non-intercluster) storages in one instance - StorageModelProxy - lightweight proxy for individual storages in type-level mode - InterclusterStorageModel - still uses traditional approach (too complex to batch) Variables (batched with element dimension) - storage|charge_state: (element, time+1, ...) - with extra timestep for energy balance - storage|netto_discharge: (element, time, ...) Constraints (per-element due to varying parameters) - netto_discharge: discharge - charge - charge_state: Energy balance constraint - initial_charge_state: Initial SOC constraint - final_charge_max/min: Final SOC bounds - cluster_cyclic: For cyclic cluster mode Performance Type-level approach now has: - 8.9-12.3x speedup for 50-200 converters with 100 timesteps - 4.2x speedup for 100 converters with 500 timesteps (constraint creation becomes bottleneck) Implemented Type-Level Models 1. FlowsModel - all flows 2. BusesModel - all buses 3. StoragesModel - basic (non-intercluster) storages
I've added investment categorization to StoragesModel batched constraints:
Changes Made
1. components.py - create_investment_constraints() method (lines 1946-1998)
- Added a new method that creates scaled bounds constraints for storages with investment
- Must be called AFTER component models are created (since it needs investment.size variables)
- Uses per-element constraint creation because each storage has its own investment size variable
- Handles both variable bounds (lb and ub) and fixed bounds (when rel_lower == rel_upper)
2. components.py - StorageModelProxy._do_modeling() (lines 2088-2104)
- Removed the inline BoundingPatterns.scaled_bounds() call
- Added comment explaining that scaled bounds are now created by StoragesModel.create_investment_constraints()
3. structure.py - do_modeling_type_level() (lines 873-877)
- Added call to _storages_model.create_investment_constraints() after component models are created
- Added timing tracking for storages_investment step
Architecture Note
The investment constraints are created per-element (not batched) because each storage has its own investment.size variable. True batching would require a InvestmentsModel with a shared size variable having an element dimension. This is documented in the method docstring and is a pragmatic choice that:
- Works correctly
- Maintains the benefit of batched variables (charge_state, netto_discharge)
- Keeps the architecture simple
A type-level model that handles ALL elements with investment at once with batched variables: Variables created: - investment|size - Batched size variable with element dimension - investment|invested - Batched binary variable with element dimension (non-mandatory only) Constraints created: - investment|size|lb / investment|size|ub - State-controlled bounds for non-mandatory - Per-element linked_periods constraints when applicable Effect shares: - Fixed effects (effects_of_investment) - Per-size effects (effects_of_investment_per_size) - Retirement effects (effects_of_retirement) Updated: StoragesModel (components.py) - Added _investments_model attribute - New method create_investment_model() - Creates batched InvestmentsModel - Updated create_investment_constraints() - Uses batched size variable for truly vectorized scaled bounds Updated: StorageModelProxy (components.py) - Removed per-element InvestmentModel creation - investment property now returns _InvestmentProxy that accesses batched variables New Class: _InvestmentProxy (components.py:31-50) Proxy class providing access to batched investment variables for a specific element: storage.submodel.investment.size # Returns slice: investment|size[element_id] storage.submodel.investment.invested # Returns slice: investment|invested[element_id] Updated: do_modeling_type_level() (structure.py) Order of operations: 1. StoragesModel.create_variables() - charge_state, netto_discharge 2. StoragesModel.create_constraints() - energy balance 3. StoragesModel.create_investment_model() - batched size/invested 4. StoragesModel.create_investment_constraints() - batched scaled bounds 5. Component models (StorageModelProxy skips InvestmentModel) Benefits - Single investment|size variable with element dimension vs N per-element variables - Vectorized constraint creation for scaled bounds - Consistent architecture with FlowsModel/BusesModel
… a summary of the changes:
Changes Made:
1. features.py - Added InvestmentProxy class (lines 157-176)
- Provides same interface as InvestmentModel (.size, .invested)
- Returns slices from batched InvestmentsModel variables
- Shared between FlowModelProxy and StorageModelProxy
2. elements.py - Updated FlowModelProxy
- Added import for InvestmentProxy (line 18)
- Updated investment property (lines 788-800) to return InvestmentProxy instead of None
3. structure.py - Added call to FlowsModel.create_investment_model() (lines 825-828)
- Creates batched investment variables, constraints, and effect shares for flows
4. components.py - Cleaned up
- Removed local _InvestmentProxy class (moved to features.py)
- Import InvestmentProxy from features.py
Test Results:
- All 88 flow tests pass (including all investment-related tests)
- All 48 storage tests pass
- All 26 functional tests pass
The batched InvestmentsModel now handles both Storage and Flow investments with:
- Batched size and invested variables with element dimension
- Vectorized constraint creation
- Batched effect shares for investment costs
New Classes Added (features.py):
1. StatusProxy (lines 529-563) - Provides per-element access to batched StatusesModel variables:
- active_hours, startup, shutdown, inactive, startup_count properties
2. StatusesModel (lines 566-964) - Type-level model for batched status features:
- Categorization by feature flags:
- All status elements get active_hours
- Elements with use_startup_tracking get startup, shutdown
- Elements with use_downtime_tracking get inactive
- Elements with startup_limit get startup_count
- Batched variables with element dimension
- Batched constraints:
- active_hours tracking
- inactive complementary (status + inactive == 1)
- State transitions (startup/shutdown)
- Startup count limits
- Uptime/downtime tracking (consecutive duration)
- Cluster cyclic constraints
- Effect shares for effects_per_active_hour and effects_per_startup
Updated Files:
1. elements.py:
- Added _statuses_model = None to FlowsModel
- Added create_status_model() method to FlowsModel
- Updated FlowModelProxy to use StatusProxy instead of per-element StatusModel
2. structure.py:
- Added call to self._flows_model.create_status_model() in type-level modeling
The architecture now has one StatusesModel handling ALL flows with status, instead of creating individual StatusModel instances per element.
StatusesModel Implementation
Created a batched StatusesModel class in features.py that handles ALL elements with status in a single instance:
New Classes:
- StatusProxy - Per-element access to batched StatusesModel variables (active_hours, startup, shutdown, inactive, startup_count)
- StatusesModel - Type-level model with:
- Categorization by feature flags (startup tracking, downtime tracking, uptime tracking, startup_limit)
- Batched variables with element dimension
- Batched constraints (active_hours tracking, state transitions, consecutive duration, etc.)
- Batched effect shares
Updates:
- FlowsModel - Added _statuses_model attribute and create_status_model() method
- FlowModelProxy - Updated status property to return StatusProxy
- structure.py - Added call to create_status_model() in type-level modeling path
Bug Fixes
1. _ensure_coords - Fixed to handle None values (bounds not specified)
2. FlowSystemModel.add_variables - Fixed to properly handle binary variables (cannot have bounds in linopy)
3. Removed unused stacked_status variable in StatusesModel
Test Results
- All 114 tests pass (88 flow tests + 26 functional tests)
- Type-level modeling path working correctly
broadcasted = xr.broadcast(*arrays_to_stack) stacked = xr.concat(broadcasted, dim='element') This is the correct approach because: 1. xr.broadcast() expands all arrays to have the same dimensions (adds missing dims like 'period') 2. Scalar values get broadcast to all coordinate values 3. After broadcasting, all arrays have identical shape and coordinates 4. xr.concat() then works without any compatibility issues
…r.concat when arrays have different dimensions (some have period/scenario, some don't) 2. Fixed investment name collision - Added name_prefix parameter to InvestmentsModel to differentiate flow_investment|size from storage_investment|size 3. Fixed StatusesModel consecutive duration tracking - Replaced ModelingPrimitives.consecutive_duration_tracking() (which requires Submodel) with a direct implementation in _add_consecutive_duration_tracking() 4. Kept traditional as default - The type-level mode works for model building but the solution structure differs (batched variables vs per-element names). This requires further work to make the solution API compatible. What's needed for type-level mode to be default: - Post-process the solution to unpack batched variables into per-element named variables for backward compatibility - Update tests that check internal variable names to handle both naming schemes The type-level mode is still available via CONFIG.Modeling.mode = 'type_level' for users who want the performance benefits and can adapt to the new solution structure.
┌─────────────────┬────────┬───────────┐ │ Test │ Status │ Objective │ ├─────────────────┼────────┼───────────┤ │ 01 (Basic) │ ✓ OK │ 150.00 │ ├─────────────────┼────────┼───────────┤ │ 02 (Storage) │ ✓ OK │ 558.66 │ ├─────────────────┼────────┼───────────┤ │ 03 (Investment) │ ✓ OK │ — │ ├─────────────────┼────────┼───────────┤ │ 04 (Scenarios) │ ✓ OK │ 33.31 │ └─────────────────┴────────┴───────────┘ Fixes implemented during testing: 1. InvestmentsModel._stack_bounds() - Handles xr.concat when arrays have different dimensions (some with 'period', some without) 2. Investment name prefix - Added name_prefix parameter to avoid collisions between flow_investment|size and storage_investment|size 3. StatusesModel._add_consecutive_duration_tracking() - Direct implementation that doesn't require Submodel 4. dims=None for all dimensions - Fixed flow_rate missing 'period' dimension by using dims=None to include ALL model dimensions (time, period, scenario) Current state: - Default mode remains 'traditional' in config.py:156 - Type-level mode is fully functional but produces batched variable names in solutions (e.g., flow|flow_rate instead of per-element names) - All 1547 tests pass with traditional mode To make type_level the default, the solution would need post-processing to unpack batched variables into per-element named variables for backward compatibility.
New Class: EffectsModel (effects.py) - Creates batched variables using effect dimension instead of per-effect models - Variables: effect|periodic, effect|temporal, effect|per_timestep, effect|total - Uses mask-based share accumulation to modify specific effect slices Updated EffectCollectionModel (effects.py) - In type_level mode: creates single EffectsModel with batched variables - In traditional mode: creates per-effect EffectModel instances (unchanged) - Share methods route to appropriate mode Key Changes: 1. _merge_coords() helper for safe coordinate handling when periods/scenarios are missing 2. Mask-based constraint modification: expression * effect_mask to update specific effect slice 3. FlowSystemModel.objective_weights now handles type_level mode without submodel 4. Solution retrieval skips elements without submodels in type_level mode Variable Structure (type_level mode): effect|periodic: dims=(effect,) # effect=['costs','Penalty'] effect|temporal: dims=(effect,) effect|per_timestep: dims=(effect, time) effect|total: dims=(effect,) flow|flow_rate: dims=(element, time) # element=['HeatDemand(heat_in)',...] flow_investment|size: dims=(element,) flow_effects->costs(temporal): dims=(element, time) The model correctly solves with objective=1062.0 (investment + operation costs).
New structure in EffectsModel: - effect_share|temporal: dims=(element, effect, time, ...) - effect_share|periodic: dims=(element, effect, ...) How it works: 1. add_share_temporal() and add_share_periodic() track contributions as (element_id, effect_id, expression) tuples 2. apply_batched_flow_effect_shares() tracks per-element contributions for type_level mode 3. create_share_variables() creates the unified variables and constraints after all shares are collected 4. Elements that don't contribute to an effect have NaN (unconstrained) in that slice Benefits: - Single variable to retrieve all element→effect contributions - Easy to query "how much does element X contribute to effect Y" - NaN indicates no contribution (vs 0 which means constrained to zero) - Both temporal and periodic shares tracked uniformly
All 5 core notebooks pass with type_level mode: - ✓ 01-quickstart.ipynb - ✓ 02-heat-system.ipynb - ✓ 03-investment-optimization.ipynb - ✓ 04-operational-constraints.ipynb - ✓ 05-multi-carrier-system.ipynb Bug Fix Applied Fixed ValueError: 'period' not present in all datasets in xr.concat calls by adding coords='minimal' to handle dimension mismatches when stacking bounds from flows/storages that have different dimensions (some have 'period', some don't). Files modified: - flixopt/elements.py:1858-1912 - Fixed 6 xr.concat calls in FlowsModel bounds creation - flixopt/components.py:1764,1867,2008-2009 - Fixed 4 xr.concat calls in StoragesModel - flixopt/features.py:296 - Fixed 1 xr.concat call in InvestmentsModel._stack_bounds Remaining Issue The 07-scenarios-and-periods notebook has a cell that uses flow_system.solution['effect_share|temporal'] which works, but a later cell tries to access flow_system.statistics.sizes['CHP(P_el)'] which returns empty. This is because: - In type_level mode, the variable category is SIZE (not FLOW_SIZE) - Variables are stored with an element dimension as 'flow_investment|size' rather than individual variables like 'CHP(P_el)|size' This is a statistics accessor API compatibility issue that would require updating the accessor to handle both traditional and type_level mode variable formats, or updating the notebook to use the new API.
Variable Naming - FlowsModel: element → flow dimension, flow_rate → rate, total_flow_hours → hours - BusesModel: element → bus dimension for virtual_supply/virtual_demand - StoragesModel: element → storage dimension, charge_state → charge, netto_discharge → netto - InvestmentsModel: Now uses context-aware dimension (flow or storage) - StatusesModel: Now uses configurable dimension name (flow for flow status) - EffectsModel: effect_share|temporal → share|temporal, effect_share|periodic → share|periodic Constraint Naming (StoragesModel) - storage|netto_discharge → storage|netto_eq - storage|charge_state → storage|balance - storage|charge_state|investment|* → storage|charge|investment|* Notebooks Updated Removed internal variable access cells from notebooks 05 and 07 that referenced type_level-specific variable names (flow|rate, effect|temporal) which are not stable across modeling modes.
1. Created ComponentStatusesModel (elements.py)
- Batched component|status binary variable with component dimension
- Constraints linking component status to flow statuses:
- Single-flow: status == flow_status
- Multi-flow: status >= sum(flow_statuses)/N and status <= sum(flow_statuses)
- Integrates with StatusesModel for status features (startup, shutdown, active_hours)
2. Created PreventSimultaneousFlowsModel (elements.py)
- Batched mutual exclusivity constraints: sum(flow_statuses) <= 1
- Handles components where flows cannot be active simultaneously
3. Updated do_modeling_type_level (structure.py)
- Added ComponentStatusesModel creation and initialization
- Added PreventSimultaneousFlowsModel constraint creation
- Updated ComponentModel to skip status creation in type_level mode
4. Updated StatusesModel (features.py)
- Added name_prefix parameter for customizable variable naming
- Flow status uses status| prefix
- Component status uses component| prefix
Variable Naming Scheme (Consistent)
┌───────────┬────────────┐
│ Type │ Variables │
├───────────┼────────────┤
│ Flow │ `flow │
├───────────┼────────────┤
│ Status │ `status │
├───────────┼────────────┤
│ Component │ `component │
├───────────┼────────────┤
│ Effect │ `effect │
└───────────┴────────────┘
Testing
- Component status with startup costs works correctly (objective = 40€)
- prevent_simultaneous_flows constraints work correctly (no simultaneous buy/sell)
- Notebook 04 (operational constraints) passes with type_level mode
Fixed issue: TypeError: The elements in the input list need to be either all 'Dataset's or all 'DataArray's when calling linopy.merge on expressions with inconsistent underlying data structures.
Changes in flixopt/effects.py:
1. Added _stack_expressions helper function (lines 42-71):
- Handles stacking LinearExpressions with inconsistent backing data types
- Converts all expression data to Datasets for consistency
- Uses xr.concat with coords='minimal' and compat='override' to handle dimension mismatches
2. Updated share constraint creation (lines 730-740, 790-800):
- Ensured expressions are LinearExpressions (convert Variables with 1 * expr)
- Replaced linopy.merge with _stack_expressions for robust handling
Results:
- Benchmark passes: 3.5-3.8x faster build, up to 17x faster LP write
- Type-level mode: 7 variables, 8 constraints (vs 208+ variables, 108+ constraints in traditional)
- Both modes produce identical optimization results
- Scenarios notebook passes
…a summary of what was fixed:
Issue: ValueError: dictionary update sequence element #0 has length 6; 2 is required
Root Cause: In SharesModel.create_variables_and_constraints(), the code was passing DataArray objects to xr.Coordinates() when it expects raw array values.
Fix: Changed from:
var_coords = {k: v for k, v in total.coords.items() if k != '_term'}
to:
var_coords = {k: v.values for k, v in total.coords.items() if k != '_term'}
The type-level mode is now working with 6.9x to 9.5x faster build times and 3.5x to 20.5x faster LP file writing compared to the traditional mode.
Remaining tasks for future work:
- Update StatusesModel to register shares (for running hours effects)
- Update InvestmentsModel to register shares (for investment effects)
These will follow the same pattern as FlowsModel: build factor arrays with (contributor, effect) dimensions and register them with the SharesModel.
Changes Made
1. Fixed coordinate handling in SharesModel (effects.py:116-153)
- Changed {k: v for k, v in total.coords.items()} to {k: v.values for k, v in total.coords.items()}
- This extracts raw array values from DataArray coordinates for xr.Coordinates()
2. Updated StatusesModel (features.py)
- Added batched_status_var parameter to accept the full batched status variable
- Implemented _create_effect_shares_batched() for SharesModel registration
- Builds factor arrays with (element, effect) dimensions for effects_per_active_hour and effects_per_startup
- Falls back to legacy per-element approach when batched variable not available
3. Updated InvestmentsModel (features.py)
- Implemented _create_effect_shares_batched() for SharesModel registration
- Builds factor arrays for effects_of_investment_per_size and effects_of_investment (non-mandatory)
- Handles retirement effects with negative factors
- Falls back to legacy approach for mandatory fixed effects (constants)
4. Updated FlowsModel (elements.py)
- Passes batched status variable to StatusesModel: batched_status_var=self._variables.get('status')
…w established:
Clean SharesModel Registration Pattern
1. Centralized Factor Building
# SharesModel.build_factors() handles:
# - Sparse effects (elements without effect get 0)
# - Building (contributor, effect) shaped DataArray
factors, contributor_ids = shares.build_factors(
elements=elements_with_effects,
effects_getter=lambda e: e.effects_per_flow_hour, # or lambda e: params_getter(e).effects_per_x
contributor_dim='flow', # 'flow', 'component', 'storage', etc.
)
2. Registration
# Get batched variable and select subset with effects
variable_subset = batched_var.sel({dim: contributor_ids})
# Optional: transform (e.g., multiply by timestep_duration)
variable_hours = variable_subset * model.timestep_duration
# Register
shares.register_temporal(variable_hours, factors, dim) # or register_periodic
3. Complete Example (from StatusesModel)
def _create_effect_shares_batched(self, effects_model, xr):
shares = effects_model.shares
dim = self.dim_name
# 1. Filter elements with this effect type
elements_with_effects = [e for e in self.elements
if self._parameters_getter(e).effects_per_active_hour]
# 2. Build factors using centralized helper
factors, ids = shares.build_factors(
elements=elements_with_effects,
effects_getter=lambda e: self._parameters_getter(e).effects_per_active_hour,
contributor_dim=dim,
)
# 3. Get variable, select subset, transform if needed
status_subset = self._batched_status_var.sel({dim: ids})
status_hours = status_subset * self.model.timestep_duration
# 4. Register
shares.register_temporal(status_hours, factors, dim)
Key Benefits
- DRY: Factor building logic is centralized in SharesModel.build_factors()
- Consistent: All type-level models follow same pattern
- Simple: 3-4 lines per effect type registration
- Flexible: Custom transformations (× timestep_duration, negative factors) applied before registration
Pattern
┌─────────────────────────────────────────────────────────────────────┐
│ Type-Level Models (FlowsModel, StatusesModel, InvestmentsModel) │
│ ───────────────────────────────────────────────────────────────── │
│ Expose factor properties: │
│ - get_effect_factors_temporal(effect_ids) → (contributor, effect) │
│ - elements_with_effects_ids → list[str] │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ EffectsModel.finalize_shares() │
│ ───────────────────────────────────────────────────────────────── │
│ Collects factors from ALL models: │
│ │
│ factors = flows_model.get_effect_factors_temporal(effect_ids) │
│ rate_subset = flows_model.rate.sel(flow=flows_model.flows_with_effects_ids) │
│ expr = (rate_subset * factors * timestep_duration).sum('flow') │
│ shares._temporal_exprs.append(expr) │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ SharesModel │
│ ───────────────────────────────────────────────────────────────── │
│ Creates ONE variable + constraint per share type: │
│ - share|temporal: (effect, time) │
│ - share|periodic: (effect, period) │
└─────────────────────────────────────────────────────────────────────┘
Benefits
1. Centralized: All share registration in EffectsModel.finalize_shares()
2. Simple properties: Type-level models just expose factors + IDs
3. Sparse: Only elements with effects are included
4. Clean multiplication: variable.sel(ids) * factors * duration
Benchmark Results
┌──────────────────┬───────────────┬────────────┬─────────────┐
│ Config │ Build Speedup │ Variables │ Constraints │
├──────────────────┼───────────────┼────────────┼─────────────┤
│ 50 conv, 100 ts │ 7.1x │ 7 (vs 208) │ 8 (vs 108) │
├──────────────────┼───────────────┼────────────┼─────────────┤
│ 100 conv, 200 ts │ 9.0x │ 7 (vs 408) │ 8 (vs 208) │
├──────────────────┼───────────────┼────────────┼─────────────┤
│ 200 conv, 100 ts │ 9.8x │ 7 (vs 808) │ 8 (vs 408) │
└──────────────────┴───────────────┴────────────┴─────────────┘
Pattern Established
All effect contributions now follow a clean property-based pattern:
┌──────────────────┬────────────────────────────────┬──────────┬──────────┬───────────────────────┐
│ Model │ Property │ Variable │ Type │ Formula │
├──────────────────┼────────────────────────────────┼──────────┼──────────┼───────────────────────┤
│ FlowsModel │ effect_factors_per_flow_hour │ rate │ temporal │ rate × factors × dt │
├──────────────────┼────────────────────────────────┼──────────┼──────────┼───────────────────────┤
│ StatusesModel │ effect_factors_per_active_hour │ status │ temporal │ status × factors × dt │
├──────────────────┼────────────────────────────────┼──────────┼──────────┼───────────────────────┤
│ StatusesModel │ effect_factors_per_startup │ startup │ temporal │ startup × factors │
├──────────────────┼────────────────────────────────┼──────────┼──────────┼───────────────────────┤
│ InvestmentsModel │ effect_factors_per_size │ size │ periodic │ size × factors │
├──────────────────┼────────────────────────────────┼──────────┼──────────┼───────────────────────┤
│ InvestmentsModel │ effect_factors_fix │ invested │ periodic │ invested × factors │
├──────────────────┼────────────────────────────────┼──────────┼──────────┼───────────────────────┤
│ InvestmentsModel │ effect_factors_retirement │ invested │ periodic │ -invested × factors │
└──────────────────┴────────────────────────────────┴──────────┴──────────┴───────────────────────┘
Key Changes
1. InvestmentsModel (features.py:520-594): Converted method-based to property-based
- effect_factors_per_size, effect_factors_fix, effect_factors_retirement
- _build_factors now gets effect_ids internally
2. FlowsModel (elements.py:2016-2064): Fixed time-varying factors
- Properly handles multi-dimensional factors (time, period, scenario)
- Uses xr.concat to preserve dimensionality
3. EffectsModel (effects.py:1000-1065): Updated collection methods
- _collect_status_shares uses property-based factors
- _collect_investment_shares uses property-based factors
- Both extract element IDs from factor coords (implicit mask)
Performance Results
Build speedup: 6.1x to 8.8x faster
Variables: 7 vs 208-808 (massively reduced)
Constraints: 8 vs 108-408 (massively reduced)
The model builds and solves correctly with the new architecture.
Final Data Flow
LAYER 1: Individual Elements
─────────────────────────────
Flow.effects_per_flow_hour: dict → e.g., {'costs': 0.04, 'CO2': 0.3}
StatusParams.effects_per_active_hour: dict
StatusParams.effects_per_startup: dict
InvestParams.effects_of_investment_per_size: dict
InvestParams.effects_of_investment: dict
InvestParams.effects_of_retirement: dict
│
▼
LAYER 2: Type Models (aggregation via xr.concat)
─────────────────────────────────────────────────
FlowsModel.effects_per_flow_hour: DataArray(flow, effect)
StatusesModel.effects_per_active_hour: DataArray(element, effect)
StatusesModel.effects_per_startup: DataArray(element, effect)
InvestmentsModel.effects_of_investment_per_size: DataArray(element, effect)
InvestmentsModel.effects_of_investment: DataArray(element, effect)
InvestmentsModel.effects_of_retirement: DataArray(element, effect)
※ Missing (element, effect) = NaN → .fillna(0) for computation
※ Property names match attribute names
│
▼
LAYER 3: EffectsModel (expression building)
───────────────────────────────────────────
expr = (variable * factors.fillna(0) * duration).sum(dim)
Key Design Decisions
1. Property names match attribute names - effects_per_flow_hour not effect_factors_per_flow_hour
2. NaN for missing effects - Distinguishes "not defined" from "zero"
- factors.fillna(0) for computation
- factors.notnull() as mask if needed
3. xr.concat pattern - Clean list comprehension + concat:
flow_factors = [
xr.concat([xr.DataArray(flow.effects.get(eff, np.nan)) for eff in effect_ids], dim='effect')
.assign_coords(effect=effect_ids)
for flow in flows_with_effects
]
return xr.concat(flow_factors, dim='flow').assign_coords(flow=flow_ids)
4. Consistent structure across all models - Same _build_factors helper in both StatusesModel and InvestmentsModel
Performance
Build speedup: 6.8x to 8.3x faster
Variables: 7 vs 208-808
Merged
3 tasks
…ffect share constraints) (#595) * fix: memory issues due to dense large coeficients 1. flixopt/features.py — Added sparse_multiply_sum() function that takes a sparse dict of (group_id, sum_id) -> coefficient instead of a dense DataArray. This avoids ever allocating the massive dense array. 2. flixopt/elements.py — Replaced _coefficients (dense DataArray) and _flow_sign (dense DataArray) with a single _signed_coefficients cached property that returns dict[tuple[str, str], float | xr.DataArray] containing only non-zero signed coefficients. Updated create_linear_constraints to use sparse_multiply_sum instead of sparse_weighted_sum. The dense allocation at line 2385 (np.zeros(n_conv, max_eq, n_flows, *time) ~14.5 GB) is completely eliminated. Memory usage is now proportional to the number of non-zero entries (typically 2-3 flows per converter) rather than the full cartesian product. * fix(effects): avoid massive memory allocation in share variable creation Replace linopy.align(join='outer') with per-contributor accumulation and linopy.merge(dim='contributor'). The old approach reindexed ALL dimensions via xr.where(), allocating ~12.7 GB of dense arrays. Now contributions are split by contributor at registration time and accumulated via linopy addition (cheap for same-shape expressions), then merged along the disjoint contributor dimension. * Switch to per contributor constraints to solve memmory issues * fix(effects): avoid massive memory allocation in share variable creation Replace linopy.align(join='outer') with per-contributor accumulation and individual constraints. The old approach reindexed ALL dimensions via xr.where(), allocating ~12.7 GB of dense arrays. Now contributions are split by contributor at registration time and accumulated via linopy addition (cheap for same-shape expressions). Each contributor gets its own constraint, avoiding any cross-contributor alignment. Reduces effects expression memory from 1.2 GB to 5 MB. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Switch to per contributor constraints to solve memmory issues * perf: improve bus balance to be more memmory efficient * Switch to per effect shares * Firs succesfull drop to 10 GB * Make more readable * Go back to one variable for all shares * ⏺ Instead of adding zero-constraints for uncovered combos, we should just set lower=0, upper=0 on those entries (fix the bounds), or better yet — use a mask on the per-effect constraints and set the variable bounds to 0 for uncovered combos. The simplest fix: create the variable with lower=0, upper=0 by default, then only the covered entries need constraints. * Only create variables needed * _create_share_var went from 1,674ms → 116ms — a 14x speedup! The reindex + + approach is much faster than per-contributor sel + merge * Revert * Revert * 1. effects.py: add_temporal_contribution and add_periodic_contribution now raise ValueError if a DataArray has no effect dimension and no effect= argument is provided. 2. statistics_accessor.py: Early return with empty xr.Dataset() when no contributors are detected, preventing xr.concat from failing on an empty list. --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This reverts commit 9e3c164.
…flow_hours, flow_sizes, storage_sizes, sizes, charge_states, effect_share_factors, temporal_effects, periodic_effects, total_effects) from manual _field is None caching to @cached_property. The whole accessor is invalidated (_statistics = None) on re-solve, so this is safe. 2. Sankey _build_flow_links: Replaced per-flow topo_flows.sel(flow=label) with the _flow_carriers cached dict lookup from StatisticsPlotAccessor.
…perty (removed manual _field is None pattern) 2. Removed _flow_carriers dict — no intermediate dict caching, topology.flows is the single source of truth 3. _get_unit_label — now reads unit coord directly from topology.flows instead of carrier→carrier_units two-step lookup 4. _build_flow_links (sankey) — extracts all topology coords as plain dicts upfront (topo_bus, topo_comp, topo_carrier, topo_is_input), then iterates without any .sel() calls. Also iterates topo.coords['flow'] instead of self._fs.flows.values() 5. _get_smart_color_defaults — same pattern: extracts topo_carriers and topo_components dicts upfront, no per-label .sel() calls
Bug #2 & #3: Investment effects not registered Files: elements.py:1314-1337 and components.py:926-948 Problem: The effects_of_investment code was inside the if inv.effects_per_size is not None: block, so optional investments without effects_of_investment_per_size never had their fixed costs registered. Fix: Moved the investment/retirement effects code outside the effects_per_size conditional. Bug #4 & #5: min_downtime/max_downtime not enforced Files: batched.py:232-256 and features.py:616-621 Problems: 1. previous_downtime was only computed when min_downtime was set, not when max_downtime was set 2. minimum_duration was accepted but never used to create a constraint Fixes: 1. Updated _build_previous_durations to check for both min and max constraints 2. Added minimum duration constraint: duration[t] >= min * (state[t] - state[t+1]) Bug #1: share_from_periodic not working Root cause: Was a consequence of Bug #2 & #3 - once investment effects are properly registered, the periodic values are available for sharing.
…classes # Conflicts: # flixopt/io.py
Member
Author
Bug Found: Component-level status effects not registeredIssueThe failing CI tests ( Component-level Example:
|
…element-data-classes # Conflicts: # CHANGELOG.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Major refactoring of the model building pipeline to use batched/vectorized operations instead of per-element loops. This brings significant performance improvements, especially for large models.
Key Changes
Batched Type-Level Models: New
FlowsModel,StoragesModel,BusesModelclasses that handle ALL elements of a type in single batched operations instead of individualFlowModel,StorageModelinstances.FlowsData/StoragesData Classes: Pre-compute and cache element data as xarray DataArrays with element dimensions, enabling vectorized constraint creation.
Mask-based Variable Creation: Variables use linopy's
mask=parameter to handle heterogeneous elements (e.g., only some flows have status variables) while keeping consistent coordinates.Fast NumPy Helpers: Replace slow xarray methods with numpy equivalents:
fast_notnull()/fast_isnull()- ~55x faster than xarray's.notnull()/.isnull()Unified Coordinate Handling: All variables use consistent coordinate order via
.reindex()to prevent alignment errors.Performance Results
XL System (2000h, 300 converters, 50 storages)
42f593e7302413c47dd56ddef38f828f2a94130f805bcc5682e699899c2d3d3b8277d5d3c67a6a7e52a581fe8c8eb5c9Complex System (72h, piecewise)
42f593e7302413c47dd56ddef38f828f2a94130f805bcc5682e699899c2d3d3b8277d5d3c67a6a7e52a581fe8c8eb5c9LP file size: 528.28 MB (XL, branch) vs 503.88 MB (XL, main), 0.21 MB (Complex) — unchanged.
Key Takeaways
XL system: 67.6x build speedup — from 113.4s down to 1.7s. LP write improved 5.1x (44.8s → 8.9s). The bulk of the gain came from the initial refactoring (
302413c4, 14.7x), with sparse groupby and weighted sum optimizations adding further large improvements.Complex system: 2.62x build speedup — from 1,003ms down to 383ms. LP write improved 4.2x (417ms → 100ms). Gains are more modest since this system is small (72 timesteps, 14 flows) and dominated by per-operation linopy/xarray overhead.
Model Size Reduction
The batched approach creates fewer, larger variables instead of many small ones:
How to Run Benchmarks
Type of Change
Testing