Skip to content

Commit f766121

Browse files
authored
Add color parameter to components and bulk color assignment via topology accessor (#585)
* Add color attribute to remaining classes * 1. Added color parameter to all component classes (flixopt/components.py, flixopt/linear_converters.py): - LinearConverter, Storage, Transmission, Source, Sink - Boiler, Power2Heat, HeatPump, CoolingTower, CHP, HeatPumpWithSource 2. Added bulk color assignment methods to TopologyAccessor (flixopt/topology_accessor.py): # Single component flow_system.topology.set_component_color('Boiler', '#D35400') # Multiple components - direct assignment flow_system.topology.set_component_colors({ 'Boiler': '#D35400', 'CHP': 'darkred', }) # Multiple components - colorscale for groups flow_system.topology.set_component_colors({ 'Oranges': ['Solar1', 'Solar2'], 'Blues': ['Wind1', 'Wind2'], }) # All components with colorscale flow_system.topology.set_component_colors('turbo') # Carrier color (affects bus colors) flow_system.topology.set_carrier_color('electricity', '#FECB52') 3. Cache invalidation - _invalidate_color_caches() resets cached color dicts when colors change. 4. Updated documentation (docs/user-guide/results-plotting.md) - Fixed the incorrect flow_system.colors references to use flow_system.topology. Read-only accessors still available: flow_system.topology.component_colors # dict[str, str] flow_system.topology.carrier_colors # dict[str, str] flow_system.topology.bus_colors # dict[str, str] * The overwrite=False parameter works correctly. Summary: # Set colors only for components that don't have one yet flow_system.topology.set_component_colors('turbo', overwrite=False) # With dict - skips components that already have colors flow_system.topology.set_component_colors({ 'Boiler': 'red', # Skipped if Boiler already has a color 'CHP': 'blue', # Skipped if CHP already has a color }, overwrite=False) Returns only the colors that were actually assigned, so you can see what changed. * ⏺ Done. Reduced from ~50 lines to ~30 lines while keeping the same functionality: def set_component_colors( self, colors: dict[str, str | list[str]] | str, overwrite: bool = True, ) -> dict[str, str]: components = self._fs.components # Normalize to {label: color} mapping if isinstance(colors, str): color_map = process_colors(colors, list(components.keys())) else: color_map = {} for key, value in colors.items(): if isinstance(value, list): missing = [c for c in value if c not in components] if missing: raise KeyError(f"Components not found: {missing}") color_map.update(process_colors(key, value)) else: if key not in components: raise KeyError(f"Component '{key}' not found") color_map[key] = value # Apply colors (respecting overwrite flag) result = {} for label, color in color_map.items(): if overwrite or components[label].color is None: components[label].color = color result[label] = color self._invalidate_color_caches() return result Key improvements: - Normalize all inputs to a {label: color} dict first - Single pass to apply colors - Cleaner error messages - Shorter docstring with inline examples * Update CHANGELOG.md * Add colors to snakey/flows plots * Add missing color parameter
1 parent ca5f54a commit f766121

6 files changed

Lines changed: 172 additions & 21 deletions

File tree

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,21 @@ comp.diff('baseline') # vs named case
243243
- Mirrors all `StatisticsPlotAccessor` methods (`balance`, `carrier_balance`, `flows`, `sizes`, `duration_curve`, `effects`, `charge_states`, `heatmap`, `storage`)
244244
- Existing plotting infrastructure automatically handles faceting by `'case'`
245245

246+
#### Component Color Parameter (#585)
247+
248+
All component classes now accept a `color` parameter for visualization customization:
249+
250+
```python
251+
# Set color at instantiation
252+
boiler = fx.Boiler('Boiler', ..., color='#D35400')
253+
storage = fx.Storage('Battery', ..., color='green')
254+
255+
# Bulk assignment via topology accessor
256+
flow_system.topology.set_component_colors({'Boiler': 'red', 'CHP': 'blue'})
257+
flow_system.topology.set_component_colors({'Oranges': ['Solar1', 'Solar2']}) # Colorscale
258+
flow_system.topology.set_component_colors('turbo', overwrite=False) # Only unset colors
259+
```
260+
246261
### 💥 Breaking Changes
247262

248263
#### tsam v3 Migration

docs/user-guide/results-plotting.md

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -394,24 +394,38 @@ flow_system.carriers # CarrierContainer with locally registered carriers
394394
flow_system.get_carrier('biogas') # Returns Carrier object
395395
```
396396

397-
### Color Accessor
397+
### Setting Colors via Topology Accessor
398398

399-
The `flow_system.colors` accessor provides centralized color configuration:
399+
The `flow_system.topology` accessor provides methods for bulk color assignment:
400400

401401
```python
402402
# Configure colors for components
403-
flow_system.colors.setup({
403+
flow_system.topology.set_component_colors({
404404
'Boiler': '#D35400',
405405
'CHP': '#8E44AD',
406406
'HeatPump': '#27AE60',
407407
})
408408

409409
# Or set individual colors
410-
flow_system.colors.set_component_color('Boiler', '#D35400')
411-
flow_system.colors.set_carrier_color('biogas', '#228B22')
410+
flow_system.topology.set_component_color('Boiler', '#D35400')
411+
flow_system.topology.set_carrier_color('electricity', '#FECB52')
412+
413+
# Apply a colorscale to groups of components
414+
flow_system.topology.set_component_colors({
415+
'Oranges': ['Solar1', 'Solar2', 'Solar3'], # Colorscale for solar
416+
'Blues': ['Wind1', 'Wind2'], # Colorscale for wind
417+
'Battery': 'green', # Direct color assignment
418+
})
419+
420+
# Apply a colorscale to all components
421+
flow_system.topology.set_component_colors('turbo')
422+
```
412423

413-
# Load from file
414-
flow_system.colors.setup('colors.json') # or .yaml
424+
You can also set colors at component instantiation:
425+
426+
```python
427+
boiler = fx.Boiler('Boiler', ..., color='#D35400')
428+
storage = fx.Storage('Battery', ..., color='green')
415429
```
416430

417431
### Context-Aware Coloring
@@ -435,22 +449,21 @@ flow_system.statistics.plot.balance('CHP')
435449
Colors are resolved in this order:
436450

437451
1. **Explicit colors** passed to plot methods (always override)
438-
2. **Component/bus colors** set via `flow_system.colors.setup()`
439-
3. **Element `meta_data['color']`** if present
440-
4. **Carrier colors** from FlowSystem or CONFIG.Carriers
441-
5. **Default colorscale** (controlled by `CONFIG.Plotting.default_qualitative_colorscale`)
452+
2. **Component colors** set via `flow_system.topology.set_component_colors()` or at instantiation
453+
3. **Carrier colors** from FlowSystem or CONFIG.Carriers (for buses)
454+
4. **Default colorscale** (controlled by `CONFIG.Plotting.default_qualitative_colorscale`)
442455

443456
### Persistence
444457

445458
Color configurations are automatically saved with the FlowSystem:
446459

447460
```python
448-
# Colors are persisted
461+
# Colors are persisted (component.color attributes)
449462
flow_system.to_netcdf('my_system.nc')
450463

451464
# And restored
452465
loaded = fx.FlowSystem.from_netcdf('my_system.nc')
453-
loaded.colors # Configuration restored
466+
loaded.topology.component_colors # Colors are restored
454467
```
455468

456469
### Display Control

flixopt/components.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,8 +172,9 @@ def __init__(
172172
conversion_factors: list[dict[str, Numeric_TPS]] | None = None,
173173
piecewise_conversion: PiecewiseConversion | None = None,
174174
meta_data: dict | None = None,
175+
color: str | None = None,
175176
):
176-
super().__init__(label, inputs, outputs, status_parameters, meta_data=meta_data)
177+
super().__init__(label, inputs, outputs, status_parameters, meta_data=meta_data, color=color)
177178
self.conversion_factors = conversion_factors or []
178179
self.piecewise_conversion = piecewise_conversion
179180

@@ -417,6 +418,7 @@ def __init__(
417418
balanced: bool = False,
418419
cluster_mode: Literal['independent', 'cyclic', 'intercluster', 'intercluster_cyclic'] = 'intercluster_cyclic',
419420
meta_data: dict | None = None,
421+
color: str | None = None,
420422
):
421423
# TODO: fixed_relative_chargeState implementieren
422424
super().__init__(
@@ -425,6 +427,7 @@ def __init__(
425427
outputs=[discharging],
426428
prevent_simultaneous_flows=[charging, discharging] if prevent_simultaneous_charge_and_discharge else None,
427429
meta_data=meta_data,
430+
color=color,
428431
)
429432

430433
self.charging = charging
@@ -744,6 +747,7 @@ def __init__(
744747
prevent_simultaneous_flows_in_both_directions: bool = True,
745748
balanced: bool = False,
746749
meta_data: dict | None = None,
750+
color: str | None = None,
747751
):
748752
super().__init__(
749753
label,
@@ -754,6 +758,7 @@ def __init__(
754758
if in2 is None or prevent_simultaneous_flows_in_both_directions is False
755759
else [in1, in2],
756760
meta_data=meta_data,
761+
color=color,
757762
)
758763
self.in1 = in1
759764
self.out1 = out1
@@ -1702,13 +1707,15 @@ def __init__(
17021707
outputs: list[Flow] | None = None,
17031708
prevent_simultaneous_flow_rates: bool = True,
17041709
meta_data: dict | None = None,
1710+
color: str | None = None,
17051711
):
17061712
super().__init__(
17071713
label,
17081714
inputs=inputs,
17091715
outputs=outputs,
17101716
prevent_simultaneous_flows=(inputs or []) + (outputs or []) if prevent_simultaneous_flow_rates else None,
17111717
meta_data=meta_data,
1718+
color=color,
17121719
)
17131720
self.prevent_simultaneous_flow_rates = prevent_simultaneous_flow_rates
17141721

@@ -1795,13 +1802,15 @@ def __init__(
17951802
outputs: list[Flow] | None = None,
17961803
meta_data: dict | None = None,
17971804
prevent_simultaneous_flow_rates: bool = False,
1805+
color: str | None = None,
17981806
):
17991807
self.prevent_simultaneous_flow_rates = prevent_simultaneous_flow_rates
18001808
super().__init__(
18011809
label,
18021810
outputs=outputs,
18031811
meta_data=meta_data,
18041812
prevent_simultaneous_flows=outputs if prevent_simultaneous_flow_rates else None,
1813+
color=color,
18051814
)
18061815

18071816

@@ -1888,6 +1897,7 @@ def __init__(
18881897
inputs: list[Flow] | None = None,
18891898
meta_data: dict | None = None,
18901899
prevent_simultaneous_flow_rates: bool = False,
1900+
color: str | None = None,
18911901
):
18921902
"""Initialize a Sink (consumes flow from the system).
18931903
@@ -1897,6 +1907,7 @@ def __init__(
18971907
meta_data: Arbitrary metadata attached to the element.
18981908
prevent_simultaneous_flow_rates: If True, prevents simultaneous nonzero flow rates
18991909
across the element's inputs by wiring that restriction into the base Component setup.
1910+
color: Optional color for visualizations.
19001911
"""
19011912

19021913
self.prevent_simultaneous_flow_rates = prevent_simultaneous_flow_rates
@@ -1905,4 +1916,5 @@ def __init__(
19051916
inputs=inputs,
19061917
meta_data=meta_data,
19071918
prevent_simultaneous_flows=inputs if prevent_simultaneous_flow_rates else None,
1919+
color=color,
19081920
)

flixopt/linear_converters.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ def __init__(
8181
thermal_flow: Flow | None = None,
8282
status_parameters: StatusParameters | None = None,
8383
meta_data: dict | None = None,
84+
color: str | None = None,
8485
):
8586
# Validate required parameters
8687
if fuel_flow is None:
@@ -96,6 +97,7 @@ def __init__(
9697
outputs=[thermal_flow],
9798
status_parameters=status_parameters,
9899
meta_data=meta_data,
100+
color=color,
99101
)
100102
self.fuel_flow = fuel_flow
101103
self.thermal_flow = thermal_flow
@@ -176,6 +178,7 @@ def __init__(
176178
thermal_flow: Flow | None = None,
177179
status_parameters: StatusParameters | None = None,
178180
meta_data: dict | None = None,
181+
color: str | None = None,
179182
):
180183
# Validate required parameters
181184
if electrical_flow is None:
@@ -191,6 +194,7 @@ def __init__(
191194
outputs=[thermal_flow],
192195
status_parameters=status_parameters,
193196
meta_data=meta_data,
197+
color=color,
194198
)
195199

196200
self.electrical_flow = electrical_flow
@@ -271,6 +275,7 @@ def __init__(
271275
thermal_flow: Flow | None = None,
272276
status_parameters: StatusParameters | None = None,
273277
meta_data: dict | None = None,
278+
color: str | None = None,
274279
):
275280
# Validate required parameters
276281
if electrical_flow is None:
@@ -287,6 +292,7 @@ def __init__(
287292
conversion_factors=[],
288293
status_parameters=status_parameters,
289294
meta_data=meta_data,
295+
color=color,
290296
)
291297
self.electrical_flow = electrical_flow
292298
self.thermal_flow = thermal_flow
@@ -368,6 +374,7 @@ def __init__(
368374
thermal_flow: Flow | None = None,
369375
status_parameters: StatusParameters | None = None,
370376
meta_data: dict | None = None,
377+
color: str | None = None,
371378
):
372379
# Validate required parameters
373380
if electrical_flow is None:
@@ -381,6 +388,7 @@ def __init__(
381388
outputs=[],
382389
status_parameters=status_parameters,
383390
meta_data=meta_data,
391+
color=color,
384392
)
385393

386394
self.electrical_flow = electrical_flow
@@ -472,6 +480,7 @@ def __init__(
472480
thermal_flow: Flow | None = None,
473481
status_parameters: StatusParameters | None = None,
474482
meta_data: dict | None = None,
483+
color: str | None = None,
475484
):
476485
# Validate required parameters
477486
if fuel_flow is None:
@@ -492,6 +501,7 @@ def __init__(
492501
conversion_factors=[{}, {}],
493502
status_parameters=status_parameters,
494503
meta_data=meta_data,
504+
color=color,
495505
)
496506

497507
self.fuel_flow = fuel_flow
@@ -602,6 +612,7 @@ def __init__(
602612
thermal_flow: Flow | None = None,
603613
status_parameters: StatusParameters | None = None,
604614
meta_data: dict | None = None,
615+
color: str | None = None,
605616
):
606617
# Validate required parameters
607618
if electrical_flow is None:
@@ -619,6 +630,7 @@ def __init__(
619630
outputs=[thermal_flow],
620631
status_parameters=status_parameters,
621632
meta_data=meta_data,
633+
color=color,
622634
)
623635
self.electrical_flow = electrical_flow
624636
self.heat_source_flow = heat_source_flow

flixopt/statistics_accessor.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1051,20 +1051,22 @@ def _create_figure(
10511051
return fig
10521052

10531053
def _get_node_colors(self, node_list: list[str], colors: ColorType | None) -> list[str]:
1054-
"""Get colors for nodes: buses use cached bus_colors, components use process_colors."""
1055-
# Get fallback colors from process_colors
1056-
fallback_colors = process_colors(colors, node_list)
1057-
1058-
# Use cached bus colors for efficiency
1054+
"""Get colors for nodes: buses use bus_colors, components use component_colors."""
1055+
# Get cached colors
10591056
bus_colors = self._stats.bus_colors
1057+
component_colors = self._stats.component_colors
1058+
1059+
# Get fallback colors for nodes without explicit colors
1060+
uncolored = [n for n in node_list if n not in bus_colors and n not in component_colors]
1061+
fallback_colors = process_colors(colors, uncolored) if uncolored else {}
10601062

10611063
node_colors = []
10621064
for node in node_list:
1063-
# Check if node is a bus with a cached color
10641065
if node in bus_colors:
10651066
node_colors.append(bus_colors[node])
1067+
elif node in component_colors:
1068+
node_colors.append(component_colors[node])
10661069
else:
1067-
# Fall back to process_colors
10681070
node_colors.append(fallback_colors[node])
10691071

10701072
return node_colors

0 commit comments

Comments
 (0)