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/calculation.py +105 -39
- flixopt/components.py +16 -0
- flixopt/config.py +120 -0
- flixopt/effects.py +28 -28
- flixopt/elements.py +58 -1
- flixopt/flow_system.py +141 -84
- flixopt/interface.py +23 -2
- flixopt/io.py +506 -4
- flixopt/results.py +52 -24
- flixopt/solvers.py +12 -4
- flixopt/structure.py +369 -49
- {flixopt-3.2.1.dist-info → flixopt-3.4.0.dist-info}/METADATA +3 -2
- flixopt-3.4.0.dist-info/RECORD +26 -0
- flixopt-3.2.1.dist-info/RECORD +0 -26
- {flixopt-3.2.1.dist-info → flixopt-3.4.0.dist-info}/WHEEL +0 -0
- {flixopt-3.2.1.dist-info → flixopt-3.4.0.dist-info}/licenses/LICENSE +0 -0
- {flixopt-3.2.1.dist-info → flixopt-3.4.0.dist-info}/top_level.txt +0 -0
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
|
-
|
|
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
|
-
|
|
191
|
-
|
|
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
|