flixopt 3.2.1__py3-none-any.whl → 3.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of flixopt might be problematic. Click here for more details.

flixopt/io.py CHANGED
@@ -1,13 +1,18 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import inspect
3
4
  import json
4
5
  import logging
6
+ import os
5
7
  import pathlib
6
8
  import re
9
+ import sys
10
+ from contextlib import contextmanager
7
11
  from dataclasses import dataclass
8
12
  from typing import TYPE_CHECKING, Any
9
13
 
10
14
  import numpy as np
15
+ import pandas as pd
11
16
  import xarray as xr
12
17
  import yaml
13
18
 
@@ -165,6 +170,35 @@ def _load_yaml_unsafe(path: str | pathlib.Path) -> dict | list:
165
170
  return yaml.unsafe_load(f) or {}
166
171
 
167
172
 
173
+ def _create_compact_dumper():
174
+ """
175
+ Create a YAML dumper class with custom representer for compact numeric lists.
176
+
177
+ Returns:
178
+ A yaml.SafeDumper subclass configured to format numeric lists inline.
179
+ """
180
+
181
+ def represent_list(dumper, data):
182
+ """
183
+ Custom representer for lists to format them inline (flow style)
184
+ but only if they contain only numbers or nested numeric lists.
185
+ """
186
+ if data and all(
187
+ isinstance(item, (int, float, np.integer, np.floating))
188
+ or (isinstance(item, list) and all(isinstance(x, (int, float, np.integer, np.floating)) for x in item))
189
+ for item in data
190
+ ):
191
+ return dumper.represent_sequence('tag:yaml.org,2002:seq', data, flow_style=True)
192
+ return dumper.represent_sequence('tag:yaml.org,2002:seq', data, flow_style=False)
193
+
194
+ # Create custom dumper with the representer
195
+ class CompactDumper(yaml.SafeDumper):
196
+ pass
197
+
198
+ CompactDumper.add_representer(list, represent_list)
199
+ return CompactDumper
200
+
201
+
168
202
  def save_yaml(
169
203
  data: dict | list,
170
204
  path: str | pathlib.Path,
@@ -172,6 +206,7 @@ def save_yaml(
172
206
  width: int = 1000,
173
207
  allow_unicode: bool = True,
174
208
  sort_keys: bool = False,
209
+ compact_numeric_lists: bool = False,
175
210
  **kwargs,
176
211
  ) -> None:
177
212
  """
@@ -184,13 +219,79 @@ def save_yaml(
184
219
  width: Maximum line width (default: 1000).
185
220
  allow_unicode: If True, allow Unicode characters (default: True).
186
221
  sort_keys: If True, sort dictionary keys (default: False).
187
- **kwargs: Additional arguments to pass to yaml.safe_dump().
222
+ compact_numeric_lists: If True, format numeric lists inline for better readability (default: False).
223
+ **kwargs: Additional arguments to pass to yaml.dump().
188
224
  """
189
225
  path = pathlib.Path(path)
190
- with open(path, 'w', encoding='utf-8') as f:
191
- yaml.safe_dump(
226
+
227
+ if compact_numeric_lists:
228
+ with open(path, 'w', encoding='utf-8') as f:
229
+ yaml.dump(
230
+ data,
231
+ f,
232
+ Dumper=_create_compact_dumper(),
233
+ indent=indent,
234
+ width=width,
235
+ allow_unicode=allow_unicode,
236
+ sort_keys=sort_keys,
237
+ default_flow_style=False,
238
+ **kwargs,
239
+ )
240
+ else:
241
+ with open(path, 'w', encoding='utf-8') as f:
242
+ yaml.safe_dump(
243
+ data,
244
+ f,
245
+ indent=indent,
246
+ width=width,
247
+ allow_unicode=allow_unicode,
248
+ sort_keys=sort_keys,
249
+ default_flow_style=False,
250
+ **kwargs,
251
+ )
252
+
253
+
254
+ def format_yaml_string(
255
+ data: dict | list,
256
+ indent: int = 4,
257
+ width: int = 1000,
258
+ allow_unicode: bool = True,
259
+ sort_keys: bool = False,
260
+ compact_numeric_lists: bool = False,
261
+ **kwargs,
262
+ ) -> str:
263
+ """
264
+ Format data as a YAML string with consistent formatting.
265
+
266
+ This function provides the same formatting as save_yaml() but returns a string
267
+ instead of writing to a file. Useful for logging or displaying YAML data.
268
+
269
+ Args:
270
+ data: Data to format (dict or list).
271
+ indent: Number of spaces for indentation (default: 4).
272
+ width: Maximum line width (default: 1000).
273
+ allow_unicode: If True, allow Unicode characters (default: True).
274
+ sort_keys: If True, sort dictionary keys (default: False).
275
+ compact_numeric_lists: If True, format numeric lists inline for better readability (default: False).
276
+ **kwargs: Additional arguments to pass to yaml.dump().
277
+
278
+ Returns:
279
+ Formatted YAML string.
280
+ """
281
+ if compact_numeric_lists:
282
+ return yaml.dump(
283
+ data,
284
+ Dumper=_create_compact_dumper(),
285
+ indent=indent,
286
+ width=width,
287
+ allow_unicode=allow_unicode,
288
+ sort_keys=sort_keys,
289
+ default_flow_style=False,
290
+ **kwargs,
291
+ )
292
+ else:
293
+ return yaml.safe_dump(
192
294
  data,
193
- f,
194
295
  indent=indent,
195
296
  width=width,
196
297
  allow_unicode=allow_unicode,
@@ -547,3 +648,404 @@ class CalculationResultsPaths:
547
648
  raise FileNotFoundError(f'Folder {new_folder} does not exist or is not a directory.')
548
649
  self.folder = new_folder
549
650
  self._update_paths()
651
+
652
+
653
+ def numeric_to_str_for_repr(
654
+ value: int | float | np.integer | np.floating | np.ndarray | pd.Series | pd.DataFrame | xr.DataArray,
655
+ precision: int = 1,
656
+ atol: float = 1e-10,
657
+ ) -> str:
658
+ """Format value for display in repr methods.
659
+
660
+ For single values or uniform arrays, returns the formatted value.
661
+ For arrays with variation, returns a range showing min-max.
662
+
663
+ Args:
664
+ value: Numeric value or container (DataArray, array, Series, DataFrame)
665
+ precision: Number of decimal places (default: 1)
666
+ atol: Absolute tolerance for considering values equal (default: 1e-10)
667
+
668
+ Returns:
669
+ Formatted string representation:
670
+ - Single/uniform values: "100.0"
671
+ - Nearly uniform values: "~100.0" (values differ slightly but display similarly)
672
+ - Varying values: "50.0-150.0" (shows range from min to max)
673
+
674
+ Raises:
675
+ TypeError: If value cannot be converted to numeric format
676
+ """
677
+ # Handle simple scalar types
678
+ if isinstance(value, (int, float, np.integer, np.floating)):
679
+ return f'{float(value):.{precision}f}'
680
+
681
+ # Extract array data for variation checking
682
+ arr = None
683
+ if isinstance(value, xr.DataArray):
684
+ arr = value.values.flatten()
685
+ elif isinstance(value, (np.ndarray, pd.Series)):
686
+ arr = np.asarray(value).flatten()
687
+ elif isinstance(value, pd.DataFrame):
688
+ arr = value.values.flatten()
689
+ else:
690
+ # Fallback for unknown types
691
+ try:
692
+ return f'{float(value):.{precision}f}'
693
+ except (TypeError, ValueError) as e:
694
+ raise TypeError(f'Cannot format value of type {type(value).__name__} for repr') from e
695
+
696
+ # Normalize dtype and handle empties
697
+ arr = arr.astype(float, copy=False)
698
+ if arr.size == 0:
699
+ return '?'
700
+
701
+ # Filter non-finite values
702
+ finite = arr[np.isfinite(arr)]
703
+ if finite.size == 0:
704
+ return 'nan'
705
+
706
+ # Check for single value
707
+ if finite.size == 1:
708
+ return f'{float(finite[0]):.{precision}f}'
709
+
710
+ # Check if all values are the same or very close
711
+ min_val = float(np.nanmin(finite))
712
+ max_val = float(np.nanmax(finite))
713
+
714
+ # First check: values are essentially identical
715
+ if np.allclose(min_val, max_val, atol=atol):
716
+ return f'{float(np.mean(finite)):.{precision}f}'
717
+
718
+ # Second check: display values are the same but actual values differ slightly
719
+ min_str = f'{min_val:.{precision}f}'
720
+ max_str = f'{max_val:.{precision}f}'
721
+ if min_str == max_str:
722
+ return f'~{min_str}'
723
+
724
+ # Values vary significantly - show range
725
+ return f'{min_str}-{max_str}'
726
+
727
+
728
+ def _format_value_for_repr(value) -> str:
729
+ """Format a single value for display in repr.
730
+
731
+ Args:
732
+ value: The value to format
733
+
734
+ Returns:
735
+ Formatted string representation of the value
736
+ """
737
+ # Format numeric types using specialized formatter
738
+ if isinstance(value, (int, float, np.integer, np.floating, np.ndarray, pd.Series, pd.DataFrame, xr.DataArray)):
739
+ try:
740
+ return numeric_to_str_for_repr(value)
741
+ except Exception:
742
+ value_repr = repr(value)
743
+ if len(value_repr) > 50:
744
+ value_repr = value_repr[:47] + '...'
745
+ return value_repr
746
+
747
+ # Format dicts with numeric/array values nicely
748
+ elif isinstance(value, dict):
749
+ try:
750
+ formatted_items = []
751
+ for k, v in value.items():
752
+ if isinstance(
753
+ v, (int, float, np.integer, np.floating, np.ndarray, pd.Series, pd.DataFrame, xr.DataArray)
754
+ ):
755
+ v_str = numeric_to_str_for_repr(v)
756
+ else:
757
+ v_str = repr(v)
758
+ if len(v_str) > 30:
759
+ v_str = v_str[:27] + '...'
760
+ formatted_items.append(f'{repr(k)}: {v_str}')
761
+ value_repr = '{' + ', '.join(formatted_items) + '}'
762
+ if len(value_repr) > 50:
763
+ value_repr = value_repr[:47] + '...'
764
+ return value_repr
765
+ except Exception:
766
+ value_repr = repr(value)
767
+ if len(value_repr) > 50:
768
+ value_repr = value_repr[:47] + '...'
769
+ return value_repr
770
+
771
+ # Default repr with truncation
772
+ else:
773
+ value_repr = repr(value)
774
+ if len(value_repr) > 50:
775
+ value_repr = value_repr[:47] + '...'
776
+ return value_repr
777
+
778
+
779
+ def build_repr_from_init(
780
+ obj: object,
781
+ excluded_params: set[str] | None = None,
782
+ label_as_positional: bool = True,
783
+ skip_default_size: bool = False,
784
+ ) -> str:
785
+ """Build a repr string from __init__ signature, showing non-default parameter values.
786
+
787
+ This utility function extracts common repr logic used across flixopt classes.
788
+ It introspects the __init__ method to build a constructor-style repr showing
789
+ only parameters that differ from their defaults.
790
+
791
+ Args:
792
+ obj: The object to create repr for
793
+ excluded_params: Set of parameter names to exclude (e.g., {'self', 'inputs', 'outputs'})
794
+ Default excludes 'self', 'label', and 'kwargs'
795
+ label_as_positional: If True and 'label' param exists, show it as first positional arg
796
+ skip_default_size: If True, skip 'size' parameter when it equals CONFIG.Modeling.big
797
+
798
+ Returns:
799
+ Formatted repr string like: ClassName("label", param=value)
800
+ """
801
+ if excluded_params is None:
802
+ excluded_params = {'self', 'label', 'kwargs'}
803
+ else:
804
+ # Always exclude 'self'
805
+ excluded_params = excluded_params | {'self'}
806
+
807
+ try:
808
+ # Get the constructor arguments and their current values
809
+ init_signature = inspect.signature(obj.__init__)
810
+ init_params = init_signature.parameters
811
+
812
+ # Check if this has a 'label' parameter - if so, show it first as positional
813
+ has_label = 'label' in init_params and label_as_positional
814
+
815
+ # Build kwargs for non-default parameters
816
+ kwargs_parts = []
817
+ label_value = None
818
+
819
+ for param_name, param in init_params.items():
820
+ # Skip *args and **kwargs
821
+ if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
822
+ continue
823
+
824
+ # Handle label separately if showing as positional (check BEFORE excluded_params)
825
+ if param_name == 'label' and has_label:
826
+ label_value = getattr(obj, param_name, None)
827
+ continue
828
+
829
+ # Now check if parameter should be excluded
830
+ if param_name in excluded_params:
831
+ continue
832
+
833
+ # Get current value
834
+ value = getattr(obj, param_name, None)
835
+
836
+ # Skip if value matches default
837
+ if param.default != inspect.Parameter.empty:
838
+ # Special handling for empty containers (even if default was None)
839
+ if isinstance(value, (dict, list, tuple, set)) and len(value) == 0:
840
+ if param.default is None or (
841
+ isinstance(param.default, (dict, list, tuple, set)) and len(param.default) == 0
842
+ ):
843
+ continue
844
+
845
+ # Handle array comparisons (xarray, numpy)
846
+ elif isinstance(value, (xr.DataArray, np.ndarray)):
847
+ try:
848
+ if isinstance(param.default, (xr.DataArray, np.ndarray)):
849
+ # Compare arrays element-wise
850
+ if isinstance(value, xr.DataArray) and isinstance(param.default, xr.DataArray):
851
+ if value.equals(param.default):
852
+ continue
853
+ elif np.array_equal(value, param.default):
854
+ continue
855
+ elif isinstance(param.default, (int, float, np.integer, np.floating)):
856
+ # Compare array to scalar (e.g., after transform_data converts scalar to DataArray)
857
+ if isinstance(value, xr.DataArray):
858
+ if np.all(value.values == float(param.default)):
859
+ continue
860
+ elif isinstance(value, np.ndarray):
861
+ if np.all(value == float(param.default)):
862
+ continue
863
+ except Exception:
864
+ pass # If comparison fails, include in repr
865
+
866
+ # Handle numeric comparisons (deals with 0 vs 0.0, int vs float)
867
+ elif isinstance(value, (int, float, np.integer, np.floating)) and isinstance(
868
+ param.default, (int, float, np.integer, np.floating)
869
+ ):
870
+ try:
871
+ if float(value) == float(param.default):
872
+ continue
873
+ except (ValueError, TypeError):
874
+ pass
875
+
876
+ elif value == param.default:
877
+ continue
878
+
879
+ # Skip None values if default is None
880
+ if value is None and param.default is None:
881
+ continue
882
+
883
+ # Special case: hide CONFIG.Modeling.big for size parameter
884
+ if skip_default_size and param_name == 'size':
885
+ from .config import CONFIG
886
+
887
+ try:
888
+ if isinstance(value, (int, float, np.integer, np.floating)):
889
+ if float(value) == CONFIG.Modeling.big:
890
+ continue
891
+ except Exception:
892
+ pass
893
+
894
+ # Format value using helper function
895
+ value_repr = _format_value_for_repr(value)
896
+ kwargs_parts.append(f'{param_name}={value_repr}')
897
+
898
+ # Build args string with label first as positional if present
899
+ if has_label and label_value is not None:
900
+ # Use label_full if available, otherwise label
901
+ if hasattr(obj, 'label_full'):
902
+ label_repr = repr(obj.label_full)
903
+ else:
904
+ label_repr = repr(label_value)
905
+
906
+ if len(label_repr) > 50:
907
+ label_repr = label_repr[:47] + '...'
908
+ args_str = label_repr
909
+ if kwargs_parts:
910
+ args_str += ', ' + ', '.join(kwargs_parts)
911
+ else:
912
+ args_str = ', '.join(kwargs_parts)
913
+
914
+ # Build final repr
915
+ class_name = obj.__class__.__name__
916
+
917
+ return f'{class_name}({args_str})'
918
+
919
+ except Exception:
920
+ # Fallback if introspection fails
921
+ return f'{obj.__class__.__name__}(<repr_failed>)'
922
+
923
+
924
+ def format_flow_details(obj, has_inputs: bool = True, has_outputs: bool = True) -> str:
925
+ """Format inputs and outputs as indented bullet list.
926
+
927
+ Args:
928
+ obj: Object with 'inputs' and/or 'outputs' attributes
929
+ has_inputs: Whether to check for inputs
930
+ has_outputs: Whether to check for outputs
931
+
932
+ Returns:
933
+ Formatted string with flow details (including leading newline), or empty string if no flows
934
+ """
935
+ flow_lines = []
936
+
937
+ if has_inputs and hasattr(obj, 'inputs') and obj.inputs:
938
+ flow_lines.append(' inputs:')
939
+ for flow in obj.inputs:
940
+ flow_lines.append(f' * {repr(flow)}')
941
+
942
+ if has_outputs and hasattr(obj, 'outputs') and obj.outputs:
943
+ flow_lines.append(' outputs:')
944
+ for flow in obj.outputs:
945
+ flow_lines.append(f' * {repr(flow)}')
946
+
947
+ return '\n' + '\n'.join(flow_lines) if flow_lines else ''
948
+
949
+
950
+ def format_title_with_underline(title: str, underline_char: str = '-') -> str:
951
+ """Format a title with underline of matching length.
952
+
953
+ Args:
954
+ title: The title text
955
+ underline_char: Character to use for underline (default: '-')
956
+
957
+ Returns:
958
+ Formatted string: "Title\\n-----\\n"
959
+ """
960
+ return f'{title}\n{underline_char * len(title)}\n'
961
+
962
+
963
+ def format_sections_with_headers(sections: dict[str, str], underline_char: str = '-') -> list[str]:
964
+ """Format sections with underlined headers.
965
+
966
+ Args:
967
+ sections: Dict mapping section headers to content
968
+ underline_char: Character for underlining headers
969
+
970
+ Returns:
971
+ List of formatted section strings
972
+ """
973
+ formatted_sections = []
974
+ for section_header, section_content in sections.items():
975
+ underline = underline_char * len(section_header)
976
+ formatted_sections.append(f'{section_header}\n{underline}\n{section_content}')
977
+ return formatted_sections
978
+
979
+
980
+ def build_metadata_info(parts: list[str], prefix: str = ' | ') -> str:
981
+ """Build metadata info string from parts.
982
+
983
+ Args:
984
+ parts: List of metadata strings (empty strings are filtered out)
985
+ prefix: Prefix to add if parts is non-empty
986
+
987
+ Returns:
988
+ Formatted info string or empty string
989
+ """
990
+ # Filter out empty strings
991
+ parts = [p for p in parts if p]
992
+ if not parts:
993
+ return ''
994
+ info = ' | '.join(parts)
995
+ return prefix + info if prefix else info
996
+
997
+
998
+ @contextmanager
999
+ def suppress_output():
1000
+ """
1001
+ Suppress all console output including C-level output from solvers.
1002
+
1003
+ WARNING: Not thread-safe. Modifies global file descriptors.
1004
+ Use only with sequential execution or multiprocessing.
1005
+ """
1006
+ # Save original file descriptors
1007
+ old_stdout_fd = os.dup(1)
1008
+ old_stderr_fd = os.dup(2)
1009
+ devnull_fd = None
1010
+
1011
+ try:
1012
+ # Open devnull
1013
+ devnull_fd = os.open(os.devnull, os.O_WRONLY)
1014
+
1015
+ # Flush Python buffers before redirecting
1016
+ sys.stdout.flush()
1017
+ sys.stderr.flush()
1018
+
1019
+ # Redirect file descriptors to devnull
1020
+ os.dup2(devnull_fd, 1)
1021
+ os.dup2(devnull_fd, 2)
1022
+
1023
+ yield
1024
+
1025
+ finally:
1026
+ # Restore original file descriptors with nested try blocks
1027
+ # to ensure all cleanup happens even if one step fails
1028
+ try:
1029
+ # Flush any buffered output in the redirected streams
1030
+ sys.stdout.flush()
1031
+ sys.stderr.flush()
1032
+ except (OSError, ValueError):
1033
+ pass # Stream might be closed or invalid
1034
+
1035
+ try:
1036
+ os.dup2(old_stdout_fd, 1)
1037
+ except OSError:
1038
+ pass # Failed to restore stdout, continue cleanup
1039
+
1040
+ try:
1041
+ os.dup2(old_stderr_fd, 2)
1042
+ except OSError:
1043
+ pass # Failed to restore stderr, continue cleanup
1044
+
1045
+ # Close all file descriptors
1046
+ for fd in [devnull_fd, old_stdout_fd, old_stderr_fd]:
1047
+ if fd is not None:
1048
+ try:
1049
+ os.close(fd)
1050
+ except OSError:
1051
+ pass # FD already closed or invalid