From dee2b23a0f7f87a94694ddab2cd8abdcb46f67b3 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 5 Oct 2025 13:42:27 +0200 Subject: [PATCH 01/23] Fix `charge_state` Constraint in `Storage` leading to incorrect losses in discharge and therefore incorrect charge states and discharge values (#347) * Fix equation in Storage * Fix test for equation in Storage * Update CHANGELOG.md * Improve Changelog Message --- CHANGELOG.md | 18 ++++++++++++++++++ flixopt/components.py | 2 +- tests/test_storage.py | 2 +- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ebb2562a1..e9d7edaf6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -90,6 +90,24 @@ Until here --> --- +## [Unreleased] - ????-??-?? + +### Added + +### Changed + +### Deprecated + +### Removed + +### Fixed +- Fix `charge_state` Constraint in `Storage` leading to incorrect losses in discharge and therefore incorrect charge states and discharge values. + +### Known issues + +### *Development* + + ## [2.1.8] - 2025-09-22 **Summary:** Code quality improvements, enhanced documentation, and bug fixes for heat pump components and visualization features. diff --git a/flixopt/components.py b/flixopt/components.py index 9dd0fc52b..2ad8d90e8 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -791,7 +791,7 @@ def do_modeling(self): charge_state.isel(time=slice(1, None)) == charge_state.isel(time=slice(None, -1)) * ((1 - rel_loss) ** hours_per_step) + charge_rate * eff_charge * hours_per_step - - discharge_rate * eff_discharge * hours_per_step, + - discharge_rate * hours_per_step / eff_discharge, name=f'{self.label_full}|charge_state', ), 'charge_state', diff --git a/tests/test_storage.py b/tests/test_storage.py index e8a95a2a1..5971c2f5c 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -158,7 +158,7 @@ def test_lossy_storage(self, basic_flow_system_linopy): charge_state.isel(time=slice(1, None)) == charge_state.isel(time=slice(None, -1)) * (1 - rel_loss) ** hours_per_step + charge_rate * eff_charge * hours_per_step - - discharge_rate * eff_discharge * hours_per_step, + - discharge_rate / eff_discharge * hours_per_step, ) # Check initial charge state constraint From 8791012783b00eed19ea02905965bff8985ac01c Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 5 Oct 2025 13:48:19 +0200 Subject: [PATCH 02/23] Fix CHANGELOG.md --- CHANGELOG.md | 33 ++++++++++++++------------------- 1 file changed, 14 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9d7edaf6..8a948cba9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,7 +57,6 @@ Please keep the format of the changelog consistent with the other releases, so t ### 🔒 Security ### 📦 Dependencies -- Updated `renovate.config` to treat CalVer packages (xarray and dask) with more care ### 📝 Docs @@ -68,6 +67,20 @@ Please keep the format of the changelog consistent with the other releases, so t Until here --> --- +## [v2.1.11] - 2025-10-05 +Important bugfix in `Storage` leading to wrong results due to incorrect discharge losses. + +### ♻️ Changed +- Using `h5netcdf` instead of `netCDF4` for dataset I/O operations. This follows the update in `xarray==2025.09.01` + +### 🐛 Fixed +- Fix `charge_state` Constraint in `Storage` leading to incorrect losses in discharge and therefore incorrect charge states and discharge values. + +### 📦 Dependencies +- Updated `renovate.config` to treat CalVer packages (xarray and dask) with more care + +--- + ## [2.1.10] - 2025-09-29 **Summary:** This release is a Documentation and Development release. @@ -90,24 +103,6 @@ Until here --> --- -## [Unreleased] - ????-??-?? - -### Added - -### Changed - -### Deprecated - -### Removed - -### Fixed -- Fix `charge_state` Constraint in `Storage` leading to incorrect losses in discharge and therefore incorrect charge states and discharge values. - -### Known issues - -### *Development* - - ## [2.1.8] - 2025-09-22 **Summary:** Code quality improvements, enhanced documentation, and bug fixes for heat pump components and visualization features. From eb6bab063b74f40f33f42581cd3d050f7feab955 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 5 Oct 2025 13:52:44 +0200 Subject: [PATCH 03/23] Simplify changes from next release --- flixopt/calculation.py | 2 +- flixopt/flow_system.py | 2 +- flixopt/io.py | 10 +++++----- flixopt/results.py | 4 ++-- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/flixopt/calculation.py b/flixopt/calculation.py index a695b285b..c912b083b 100644 --- a/flixopt/calculation.py +++ b/flixopt/calculation.py @@ -164,7 +164,7 @@ def solve(self, solver: _Solver, log_file: pathlib.Path | None = None, log_main_ from .io import document_linopy_model document_linopy_model(self.model, paths.model_documentation) - self.flow_system.to_netcdf(paths.flow_system, engine='h5netcdf') + self.flow_system.to_netcdf(paths.flow_system) raise RuntimeError( f'Model was infeasible. Please check {paths.model_documentation=} and {paths.flow_system=} for more information.' ) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index a07e3a38f..604b1ca1e 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -221,7 +221,7 @@ def to_netcdf(self, path: str | pathlib.Path, compression: int = 0, constants_in constants_in_dataset: If True, constants are included as Dataset variables. """ ds = self.as_dataset(constants_in_dataset=constants_in_dataset) - fx_io.save_dataset_to_netcdf(ds, path, compression=compression, engine='h5netcdf') + fx_io.save_dataset_to_netcdf(ds, path, compression=compression) logger.info(f'Saved FlowSystem to {path}') def plot_network( diff --git a/flixopt/io.py b/flixopt/io.py index 191df8a11..314f693db 100644 --- a/flixopt/io.py +++ b/flixopt/io.py @@ -208,7 +208,7 @@ def save_dataset_to_netcdf( ds: xr.Dataset, path: str | pathlib.Path, compression: int = 0, - engine: str = 'h5netcdf', + engine: Literal['netcdf4', 'scipy', 'h5netcdf'] = 'h5netcdf', ) -> None: """ Save a dataset to a netcdf file. Store the attrs as a json string in the 'attrs' attribute. @@ -227,12 +227,12 @@ def save_dataset_to_netcdf( apply_encoding = False if compression != 0: - if importlib.util.find_spec('h5netcdf') is not None: + if importlib.util.find_spec(engine) is not None: apply_encoding = True else: logger.warning( - 'Dataset was exported without compression due to missing dependency "h5netcdf".' - 'Install h5netcdf via `pip install h5netcdf`.' + f'Dataset was exported without compression due to missing dependency "{engine}".' + f'Install {engine} via `pip install {engine}`.' ) ds = ds.copy(deep=True) ds.attrs = {'attrs': json.dumps(ds.attrs)} @@ -241,7 +241,7 @@ def save_dataset_to_netcdf( encoding=None if not apply_encoding else {data_var: {'zlib': True, 'complevel': compression} for data_var in ds.data_vars}, - engine='h5netcdf', + engine=engine, ) diff --git a/flixopt/results.py b/flixopt/results.py index a34875381..d93285d2c 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -328,8 +328,8 @@ def to_file( paths = fx_io.CalculationResultsPaths(folder, name) - fx_io.save_dataset_to_netcdf(self.solution, paths.solution, compression=compression, engine='h5netcdf') - fx_io.save_dataset_to_netcdf(self.flow_system, paths.flow_system, compression=compression, engine='h5netcdf') + fx_io.save_dataset_to_netcdf(self.solution, paths.solution, compression=compression) + fx_io.save_dataset_to_netcdf(self.flow_system, paths.flow_system, compression=compression) with open(paths.summary, 'w', encoding='utf-8') as f: yaml.dump(self.summary, f, allow_unicode=True, sort_keys=False, indent=4, width=1000) From 1281d8273879136f2c8bc5c7823fbd94c8f19d2c Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 5 Oct 2025 13:54:30 +0200 Subject: [PATCH 04/23] Update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a948cba9..846a365e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,7 +68,7 @@ Until here --> --- ## [v2.1.11] - 2025-10-05 -Important bugfix in `Storage` leading to wrong results due to incorrect discharge losses. +**Summary:** Important bugfix in `Storage` leading to wrong results due to incorrect discharge losses. ### ♻️ Changed - Using `h5netcdf` instead of `netCDF4` for dataset I/O operations. This follows the update in `xarray==2025.09.01` From f8c196e673dc2396a734d7f0dd79997fc5f68848 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 5 Oct 2025 14:07:42 +0200 Subject: [PATCH 05/23] Fix CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 846a365e4..baf95cbda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,7 +67,7 @@ Please keep the format of the changelog consistent with the other releases, so t Until here --> --- -## [v2.1.11] - 2025-10-05 +## [2.1.11] - 2025-10-05 **Summary:** Important bugfix in `Storage` leading to wrong results due to incorrect discharge losses. ### ♻️ Changed From f61a978bc0e9d75ca2e102eda61dacd631c3c573 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 5 Oct 2025 13:44:42 +0000 Subject: [PATCH 06/23] chore(deps): update dependency mkdocs-material to v9.6.20 (#369) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 1a3ece7ca..f0f4822d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -95,7 +95,7 @@ dev = [ # Documentation building docs = [ - "mkdocs-material==9.6.19", + "mkdocs-material==9.6.20", "mkdocstrings-python==1.18.2", "mkdocs-table-reader-plugin==3.1.0", "mkdocs-gen-files==0.5.0", From 2cc5af76479643cdba6eff3c2055f323a4699808 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 6 Oct 2025 15:04:28 +0200 Subject: [PATCH 07/23] Improve renovate.json to automerge ruff despite 0.x version --- renovate.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/renovate.json b/renovate.json index 16b85fe71..ded1fbf17 100644 --- a/renovate.json +++ b/renovate.json @@ -39,6 +39,13 @@ "schedule": ["* * * * *"], "labels": ["calver", "breaking-change-risk", "dependencies"], "prPriority": 10 + }, + { + "description": "Automerge ruff patches despite 0.x version", + "matchPackageNames": ["ruff"], + "matchUpdateTypes": ["patch"], + "automerge": true, + "automergeType": "pr" } ] } From bd1ef9cea1f09376c496a66f7206483aab3731fa Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 6 Oct 2025 16:46:59 +0000 Subject: [PATCH 08/23] chore(deps): update dependency tsam to v2.3.9 (#379) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f0f4822d6..b3e923975 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -83,7 +83,7 @@ dev = [ "ruff==0.13.0", "pre-commit==4.3.0", "pyvis==0.3.2", - "tsam==2.3.1", + "tsam==2.3.9", "scipy==1.15.1", "gurobipy==12.0.3", "dash==3.0.0", From 5e66d5085d50bd3d6def9baa49f5ccfe2672c579 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 7 Oct 2025 09:11:11 +0000 Subject: [PATCH 09/23] chore(deps): update dependency ruff to v0.13.2 (#378) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index b3e923975..6a523bbb4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,7 +80,7 @@ dev = [ "pytest==8.4.2", "pytest-xdist==3.8.0", "nbformat==5.10.4", - "ruff==0.13.0", + "ruff==0.13.2", "pre-commit==4.3.0", "pyvis==0.3.2", "tsam==2.3.9", From 493ca97604fba6af31562d6e5c646828522d7462 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 9 Oct 2025 11:30:32 +0200 Subject: [PATCH 10/23] Feature/Improve Configuration options and handling (#385) * Refactor configuration management: remove dataclass-based schema and simplify CONFIG structure. * Refactor configuration loading: switch from `os` to `pathlib`, streamline YAML loading logic. * Refactor logging setup: split handler creation into dedicated functions, simplify configuration logic. * Improve logging configurability and safety - Add support for `RotatingFileHandler` to prevent large log files. - Introduce `console` flag for optional console logging. - Default to `NullHandler` when no handlers are configured for better library behavior. * Temp * Temp * Temp * Temp * Temp * Temp * Refactor configuration and logging: remove unused `merge_configs` function, streamline logging setup, and encapsulate `_setup_logging` as an internal function. * Remove unused `change_logging_level` import and export. * Add tests for config.py * Expand `config.py` test coverage: add tests for custom config loading, logging setup, dict roundtrip, and attribute modification. * Expand `test_config.py` coverage: add modeling config persistence test, refine logging reset, and improve partial config load assertions. * Expand `test_config.py` coverage: add teardown for state cleanup and reset modeling config in setup. * Add `CONFIG.reset()` method and expand test coverage to verify default restoration * Refactor `CONFIG` to centralize defaults in `_DEFAULTS` and ensure `reset()` aligns with them; add test to verify consistency. * Refactor `_DEFAULTS` to use `MappingProxyType` for immutability, restructure config hierarchy, and simplify `reset()` implementation for maintainability; update tests accordingly. * Mark `TestConfigModule` tests to run in a single worker with `@pytest.mark.xdist_group` to prevent global config interference. * Add default log file * Update CHANGELOG.md * Readd change_logging_level() for backwards compatability * Add more options to config.py * Add a docstring to config.y * Add a docstring to config.y * rename parameter message_format * Improve color config * Improve color config * Update CHANGELOG.md * Improve color handling * Improve color handling * Remove console Logging explicityl from examples * Make log to console the default * Make log to console the default * Add individual level parameters for console and file * Add extra Handler section * Use dedicated levels for both handlers * Switch back to not use Handlers * Revert "Switch back to not use Handlers" This reverts commit 05bbccb5d3d750e1b972799ae004d433bb798406. * Revert "Use dedicated levels for both handlers" This reverts commit ed0542bcb0db2d36e5aaec9f1f1e38aa5bc0c6b2. * Revert "Add extra Handler section" This reverts commit a133cc87c3567f0d4e40b43f60943afe7f6b9aaa. * Revert "Add individual level parameters for console and file" This reverts commit 19f81c9e05065de7dcf74bf4750f653d51a2ecbe. * Fix CHANGELOG.md --- CHANGELOG.md | 13 +- flixopt/__init__.py | 2 - flixopt/calculation.py | 4 +- flixopt/config.py | 679 +++++++++++++++++++++++++++++------------ flixopt/config.yaml | 10 - flixopt/elements.py | 10 +- flixopt/features.py | 12 +- flixopt/interface.py | 8 +- tests/test_config.py | 480 +++++++++++++++++++++++++++++ 9 files changed, 995 insertions(+), 223 deletions(-) delete mode 100644 flixopt/config.yaml create mode 100644 tests/test_config.py diff --git a/CHANGELOG.md b/CHANGELOG.md index baf95cbda..70660c6dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,15 +42,23 @@ Please keep the format of the changelog consistent with the other releases, so t ## [Unreleased] - ????-??-?? ### ✨ Added +- Added `CONFIG.reset()` method to restore configuration to default values +- Added configurable log file rotation settings: `CONFIG.Logging.max_file_size` and `CONFIG.Logging.backup_count` +- Added configurable log format settings: `CONFIG.Logging.date_format` and `CONFIG.Logging.format` +- Added configurable console settings: `CONFIG.Logging.console_width` and `CONFIG.Logging.show_path` +- Added `CONFIG.Logging.Colors` nested class for customizable log level colors using ANSI escape codes (works with both standard and Rich handlers) ### 💥 Breaking Changes ### ♻️ Changed -- Using `h5netcdf` instead of `netCDF4` for dataset I/O operations. This follows the update in `xarray==2025.09.01` +- Logging and Configuration management changed ### 🗑️ Deprecated +- `change_logging_level()` function is now deprecated in favor of `CONFIG.Logging.level` and `CONFIG.apply()`. Will be removed in version 3.0.0. ### 🔥 Removed +- Removed unused `config.merge_configs` function from configuration module + ### 🐛 Fixed @@ -61,6 +69,8 @@ Please keep the format of the changelog consistent with the other releases, so t ### 📝 Docs ### 👷 Development +- Greatly expanded test coverage for `config.py` module +- Added `@pytest.mark.xdist_group` to `TestConfigModule` tests to prevent global config interference ### 🚧 Known Issues @@ -78,6 +88,7 @@ Until here --> ### 📦 Dependencies - Updated `renovate.config` to treat CalVer packages (xarray and dask) with more care +- Updated packaging configuration --- diff --git a/flixopt/__init__.py b/flixopt/__init__.py index 34306ae32..d8ad05f19 100644 --- a/flixopt/__init__.py +++ b/flixopt/__init__.py @@ -35,5 +35,3 @@ results, solvers, ) - -CONFIG.load_config() diff --git a/flixopt/calculation.py b/flixopt/calculation.py index c912b083b..4dc13889c 100644 --- a/flixopt/calculation.py +++ b/flixopt/calculation.py @@ -91,13 +91,13 @@ def main_results(self) -> dict[str, Scalar | dict]: model.label_of_element: float(model.size.solution) for component in self.flow_system.components.values() for model in component.model.all_sub_models - if isinstance(model, InvestmentModel) and float(model.size.solution) >= CONFIG.modeling.EPSILON + if isinstance(model, InvestmentModel) and float(model.size.solution) >= CONFIG.Modeling.epsilon }, 'Not invested': { model.label_of_element: float(model.size.solution) for component in self.flow_system.components.values() for model in component.model.all_sub_models - if isinstance(model, InvestmentModel) and float(model.size.solution) < CONFIG.modeling.EPSILON + if isinstance(model, InvestmentModel) and float(model.size.solution) < CONFIG.Modeling.epsilon }, }, 'Buses with excess': [ diff --git a/flixopt/config.py b/flixopt/config.py index 74e33e3ee..2ec5bf88c 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -1,168 +1,345 @@ from __future__ import annotations import logging -import os -import types -from dataclasses import dataclass, fields, is_dataclass -from typing import Annotated, Literal, get_type_hints +import warnings +from logging.handlers import RotatingFileHandler +from pathlib import Path +from types import MappingProxyType +from typing import Literal import yaml from rich.console import Console from rich.logging import RichHandler +from rich.style import Style +from rich.theme import Theme -logger = logging.getLogger('flixopt') - - -def merge_configs(defaults: dict, overrides: dict) -> dict: - """ - Merge the default configuration with user-provided overrides. - Args: - defaults: Default configuration dictionary. - overrides: User configuration dictionary. - Returns: - Merged configuration dictionary. - """ - for key, value in overrides.items(): - if isinstance(value, dict) and key in defaults and isinstance(defaults[key], dict): - # Recursively merge nested dictionaries - defaults[key] = merge_configs(defaults[key], value) - else: - # Override the default value - defaults[key] = value - return defaults - - -def dataclass_from_dict_with_validation(cls, data: dict): - """ - Recursively initialize a dataclass from a dictionary. - """ - if not is_dataclass(cls): - raise TypeError(f'{cls} must be a dataclass') - - # Get resolved type hints to handle postponed evaluation - type_hints = get_type_hints(cls) - - # Build kwargs for the dataclass constructor - kwargs = {} - for field in fields(cls): - field_name = field.name - # Use resolved type from get_type_hints instead of field.type - field_type = type_hints.get(field_name, field.type) - field_value = data.get(field_name) - - # If the field type is a dataclass and the value is a dict, recursively initialize - if is_dataclass(field_type) and isinstance(field_value, dict): - kwargs[field_name] = dataclass_from_dict_with_validation(field_type, field_value) - else: - kwargs[field_name] = field_value # Pass as-is if no special handling is needed - - return cls(**kwargs) +__all__ = ['CONFIG', 'change_logging_level'] +logger = logging.getLogger('flixopt') -@dataclass() -class ValidatedConfig: - def __setattr__(self, name, value): - if field := self.__dataclass_fields__.get(name): - # Get resolved type hints to handle postponed evaluation - type_hints = get_type_hints(self.__class__, include_extras=True) - field_type = type_hints.get(name, field.type) - if metadata := getattr(field_type, '__metadata__', None): - assert metadata[0](value), f'Invalid value passed to {name!r}: {value=}' - super().__setattr__(name, value) +# SINGLE SOURCE OF TRUTH - immutable to prevent accidental modification +_DEFAULTS = MappingProxyType( + { + 'config_name': 'flixopt', + 'logging': MappingProxyType( + { + 'level': 'INFO', + 'file': 'flixopt.log', + 'rich': False, + 'console': True, + 'max_file_size': 10_485_760, # 10MB + 'backup_count': 5, + 'date_format': '%Y-%m-%d %H:%M:%S', + 'format': '%(message)s', + 'console_width': 120, + 'show_path': False, + 'colors': MappingProxyType( + { + 'DEBUG': '\033[32m', # Green + 'INFO': '\033[34m', # Blue + 'WARNING': '\033[33m', # Yellow + 'ERROR': '\033[31m', # Red + 'CRITICAL': '\033[1m\033[31m', # Bold Red + } + ), + } + ), + 'modeling': MappingProxyType( + { + 'big': 10_000_000, + 'epsilon': 1e-5, + 'big_binary_bound': 100_000, + } + ), + } +) -@dataclass -class LoggingConfig(ValidatedConfig): - level: Annotated[ - Literal['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'], - lambda level: level in ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'], - ] - file: Annotated[str, lambda file: isinstance(file, str)] - rich: Annotated[bool, lambda rich: isinstance(rich, bool)] +class CONFIG: + """Configuration for flixopt library. + + The CONFIG class provides centralized configuration for logging and modeling parameters. + All changes require calling ``CONFIG.apply()`` to take effect. + + By default, logging outputs to both console and file ('flixopt.log'). + + Attributes: + Logging: Nested class containing all logging configuration options. + Colors: Nested subclass under Logging containing ANSI color codes for log levels. + Modeling: Nested class containing optimization modeling parameters. + config_name (str): Name of the configuration (default: 'flixopt'). + + Logging Attributes: + level (str): Logging level: 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'. + Default: 'INFO' + file (str | None): Log file path. Default: 'flixopt.log'. + Set to None to disable file logging. + console (bool): Enable console (stdout) logging. Default: True + rich (bool): Use Rich library for enhanced console output. Default: False + max_file_size (int): Maximum log file size in bytes before rotation. + Default: 10485760 (10MB) + backup_count (int): Number of backup log files to keep. Default: 5 + date_format (str): Date/time format for log messages. + Default: '%Y-%m-%d %H:%M:%S' + format (str): Log message format string. Default: '%(message)s' + console_width (int): Console width for Rich handler. Default: 120 + show_path (bool): Show file paths in log messages. Default: False + + Colors Attributes: + DEBUG (str): ANSI color code for DEBUG level. Default: '\\033[32m' (green) + INFO (str): ANSI color code for INFO level. Default: '\\033[34m' (blue) + WARNING (str): ANSI color code for WARNING level. Default: '\\033[33m' (yellow) + ERROR (str): ANSI color code for ERROR level. Default: '\\033[31m' (red) + CRITICAL (str): ANSI color code for CRITICAL level. Default: '\\033[1m\\033[31m' (bold red) + + Works with both Rich and standard console handlers. + Rich automatically converts ANSI codes using Style.from_ansi(). + + Common ANSI codes: + + - '\\033[30m' - Black + - '\\033[31m' - Red + - '\\033[32m' - Green + - '\\033[33m' - Yellow + - '\\033[34m' - Blue + - '\\033[35m' - Magenta + - '\\033[36m' - Cyan + - '\\033[37m' - White + - '\\033[1m\\033[3Xm' - Bold color (replace X with color code 0-7) + - '\\033[2m\\033[3Xm' - Dim color (replace X with color code 0-7) + + Examples: + + - Magenta: '\\033[35m' + - Bold cyan: '\\033[1m\\033[36m' + - Dim green: '\\033[2m\\033[32m' + + Modeling Attributes: + big (int): Large number for optimization constraints. Default: 10000000 + epsilon (float): Small tolerance value. Default: 1e-5 + big_binary_bound (int): Upper bound for binary variable constraints. + Default: 100000 + + Examples: + Basic configuration:: + + from flixopt import CONFIG + + CONFIG.Logging.console = True + CONFIG.Logging.level = 'DEBUG' + CONFIG.apply() + + Configure log file rotation:: + + CONFIG.Logging.file = 'myapp.log' + CONFIG.Logging.max_file_size = 5_242_880 # 5 MB + CONFIG.Logging.backup_count = 3 + CONFIG.apply() + + Customize log colors:: + + CONFIG.Logging.Colors.INFO = '\\033[35m' # Magenta + CONFIG.Logging.Colors.DEBUG = '\\033[36m' # Cyan + CONFIG.Logging.Colors.ERROR = '\\033[1m\\033[31m' # Bold red + CONFIG.apply() + + Use Rich handler with custom colors:: + + CONFIG.Logging.console = True + CONFIG.Logging.rich = True + CONFIG.Logging.console_width = 100 + CONFIG.Logging.show_path = True + CONFIG.Logging.Colors.INFO = '\\033[36m' # Cyan + CONFIG.apply() + + Load from YAML file:: + + CONFIG.load_from_file('config.yaml') + + Example YAML config file: + + .. code-block:: yaml + + logging: + level: DEBUG + console: true + file: app.log + rich: true + max_file_size: 5242880 # 5MB + backup_count: 3 + date_format: '%H:%M:%S' + console_width: 100 + show_path: true + colors: + DEBUG: "\\033[36m" # Cyan + INFO: "\\033[32m" # Green + WARNING: "\\033[33m" # Yellow + ERROR: "\\033[31m" # Red + CRITICAL: "\\033[1m\\033[31m" # Bold red + + modeling: + big: 20000000 + epsilon: 1e-6 + big_binary_bound: 200000 + + Reset to defaults:: -@dataclass -class ModelingConfig(ValidatedConfig): - BIG: Annotated[int, lambda x: isinstance(x, int)] - EPSILON: Annotated[float, lambda x: isinstance(x, float)] - BIG_BINARY_BOUND: Annotated[int, lambda x: isinstance(x, int)] + CONFIG.reset() + + Export current configuration:: + config_dict = CONFIG.to_dict() + import yaml -@dataclass -class ConfigSchema(ValidatedConfig): - config_name: Annotated[str, lambda x: isinstance(x, str)] - logging: LoggingConfig - modeling: ModelingConfig + with open('my_config.yaml', 'w') as f: + yaml.dump(config_dict, f) + """ + class Logging: + level: str = _DEFAULTS['logging']['level'] + file: str | None = _DEFAULTS['logging']['file'] + rich: bool = _DEFAULTS['logging']['rich'] + console: bool = _DEFAULTS['logging']['console'] + max_file_size: int = _DEFAULTS['logging']['max_file_size'] + backup_count: int = _DEFAULTS['logging']['backup_count'] + date_format: str = _DEFAULTS['logging']['date_format'] + format: str = _DEFAULTS['logging']['format'] + console_width: int = _DEFAULTS['logging']['console_width'] + show_path: bool = _DEFAULTS['logging']['show_path'] + + class Colors: + DEBUG: str = _DEFAULTS['logging']['colors']['DEBUG'] + INFO: str = _DEFAULTS['logging']['colors']['INFO'] + WARNING: str = _DEFAULTS['logging']['colors']['WARNING'] + ERROR: str = _DEFAULTS['logging']['colors']['ERROR'] + CRITICAL: str = _DEFAULTS['logging']['colors']['CRITICAL'] + + class Modeling: + big: int = _DEFAULTS['modeling']['big'] + epsilon: float = _DEFAULTS['modeling']['epsilon'] + big_binary_bound: int = _DEFAULTS['modeling']['big_binary_bound'] + + config_name: str = _DEFAULTS['config_name'] -class CONFIG: - """ - A configuration class that stores global configuration values as class attributes. - """ + @classmethod + def reset(cls): + """Reset all configuration values to defaults.""" + for key, value in _DEFAULTS['logging'].items(): + if key == 'colors': + # Reset nested Colors class + for color_key, color_value in value.items(): + setattr(cls.Logging.Colors, color_key, color_value) + else: + setattr(cls.Logging, key, value) + + for key, value in _DEFAULTS['modeling'].items(): + setattr(cls.Modeling, key, value) + + cls.config_name = _DEFAULTS['config_name'] + cls.apply() - config_name: str = None - modeling: ModelingConfig = None - logging: LoggingConfig = None + @classmethod + def apply(cls): + """Apply current configuration to logging system.""" + # Convert Colors class attributes to dict + colors_dict = { + 'DEBUG': cls.Logging.Colors.DEBUG, + 'INFO': cls.Logging.Colors.INFO, + 'WARNING': cls.Logging.Colors.WARNING, + 'ERROR': cls.Logging.Colors.ERROR, + 'CRITICAL': cls.Logging.Colors.CRITICAL, + } + + _setup_logging( + default_level=cls.Logging.level, + log_file=cls.Logging.file, + use_rich_handler=cls.Logging.rich, + console=cls.Logging.console, + max_file_size=cls.Logging.max_file_size, + backup_count=cls.Logging.backup_count, + date_format=cls.Logging.date_format, + format=cls.Logging.format, + console_width=cls.Logging.console_width, + show_path=cls.Logging.show_path, + colors=colors_dict, + ) @classmethod - def load_config(cls, user_config_file: str | None = None): - """ - Initialize configuration using defaults or user-specified file. - """ - # Default config file - default_config_path = os.path.join(os.path.dirname(__file__), 'config.yaml') - - if user_config_file is None: - with open(default_config_path) as file: - new_config = yaml.safe_load(file) - elif not os.path.exists(user_config_file): - raise FileNotFoundError(f'Config file not found: {user_config_file}') - else: - with open(user_config_file) as user_file: - new_config = yaml.safe_load(user_file) + def load_from_file(cls, config_file: str | Path): + """Load configuration from YAML file and apply it.""" + config_path = Path(config_file) + if not config_path.exists(): + raise FileNotFoundError(f'Config file not found: {config_file}') - # Convert the merged config to ConfigSchema - config_data = dataclass_from_dict_with_validation(ConfigSchema, new_config) + with config_path.open() as file: + config_dict = yaml.safe_load(file) + cls._apply_config_dict(config_dict) - # Store the configuration in the class as class attributes - cls.logging = config_data.logging - cls.modeling = config_data.modeling - cls.config_name = config_data.config_name + cls.apply() - setup_logging(default_level=cls.logging.level, log_file=cls.logging.file, use_rich_handler=cls.logging.rich) + @classmethod + def _apply_config_dict(cls, config_dict: dict): + """Apply configuration dictionary to class attributes.""" + for key, value in config_dict.items(): + if key == 'logging' and isinstance(value, dict): + for nested_key, nested_value in value.items(): + if nested_key == 'colors' and isinstance(nested_value, dict): + # Handle nested colors under logging + for color_key, color_value in nested_value.items(): + setattr(cls.Logging.Colors, color_key, color_value) + else: + setattr(cls.Logging, nested_key, nested_value) + elif key == 'modeling' and isinstance(value, dict): + for nested_key, nested_value in value.items(): + setattr(cls.Modeling, nested_key, nested_value) + elif hasattr(cls, key): + setattr(cls, key, value) @classmethod def to_dict(cls): - """ - Convert the configuration class into a dictionary for JSON serialization. - Handles dataclasses and simple types like str, int, etc. - """ - config_dict = {} - for attribute, value in cls.__dict__.items(): - # Only consider attributes (not methods, etc.) - if ( - not attribute.startswith('_') - and not isinstance(value, (types.FunctionType, types.MethodType)) - and not isinstance(value, classmethod) - ): - if is_dataclass(value): - config_dict[attribute] = value.__dict__ - else: # Assuming only basic types here! - config_dict[attribute] = value - - return config_dict + """Convert the configuration class into a dictionary for JSON serialization.""" + return { + 'config_name': cls.config_name, + 'logging': { + 'level': cls.Logging.level, + 'file': cls.Logging.file, + 'rich': cls.Logging.rich, + 'console': cls.Logging.console, + 'max_file_size': cls.Logging.max_file_size, + 'backup_count': cls.Logging.backup_count, + 'date_format': cls.Logging.date_format, + 'format': cls.Logging.format, + 'console_width': cls.Logging.console_width, + 'show_path': cls.Logging.show_path, + 'colors': { + 'DEBUG': cls.Logging.Colors.DEBUG, + 'INFO': cls.Logging.Colors.INFO, + 'WARNING': cls.Logging.Colors.WARNING, + 'ERROR': cls.Logging.Colors.ERROR, + 'CRITICAL': cls.Logging.Colors.CRITICAL, + }, + }, + 'modeling': { + 'big': cls.Modeling.big, + 'epsilon': cls.Modeling.epsilon, + 'big_binary_bound': cls.Modeling.big_binary_bound, + }, + } class MultilineFormater(logging.Formatter): + """Formatter that handles multi-line messages with consistent prefixes.""" + + def __init__(self, fmt=None, datefmt=None): + super().__init__(fmt=fmt, datefmt=datefmt) + def format(self, record): message_lines = record.getMessage().split('\n') - - # Prepare the log prefix (timestamp + log level) timestamp = self.formatTime(record, self.datefmt) - log_level = record.levelname.ljust(8) # Align log levels for consistency + log_level = record.levelname.ljust(8) log_prefix = f'{timestamp} | {log_level} |' - # Format all lines first_line = [f'{log_prefix} {message_lines[0]}'] if len(message_lines) > 1: lines = first_line + [f'{log_prefix} {line}' for line in message_lines[1:]] @@ -173,96 +350,212 @@ def format(self, record): class ColoredMultilineFormater(MultilineFormater): - # ANSI escape codes for colors - COLORS = { - 'DEBUG': '\033[32m', # Green - 'INFO': '\033[34m', # Blue - 'WARNING': '\033[33m', # Yellow - 'ERROR': '\033[31m', # Red - 'CRITICAL': '\033[1m\033[31m', # Bold Red - } + """Formatter that adds ANSI colors to multi-line log messages.""" + RESET = '\033[0m' + def __init__(self, fmt=None, datefmt=None, colors=None): + super().__init__(fmt=fmt, datefmt=datefmt) + self.COLORS = ( + colors + if colors is not None + else { + 'DEBUG': '\033[32m', + 'INFO': '\033[34m', + 'WARNING': '\033[33m', + 'ERROR': '\033[31m', + 'CRITICAL': '\033[1m\033[31m', + } + ) + def format(self, record): lines = super().format(record).splitlines() log_color = self.COLORS.get(record.levelname, self.RESET) + formatted_lines = [f'{log_color}{line}{self.RESET}' for line in lines] + return '\n'.join(formatted_lines) - # Create a formatted message for each line separately - formatted_lines = [] - for line in lines: - formatted_lines.append(f'{log_color}{line}{self.RESET}') - return '\n'.join(formatted_lines) +def _create_console_handler( + use_rich: bool = False, + console_width: int = 120, + show_path: bool = False, + date_format: str = '%Y-%m-%d %H:%M:%S', + format: str = '%(message)s', + colors: dict[str, str] | None = None, +) -> logging.Handler: + """Create a console (stdout) logging handler. + Args: + use_rich: If True, use RichHandler with color support. + console_width: Width of the console for Rich handler. + show_path: Show file paths in log messages (Rich only). + date_format: Date/time format string. + format: Log message format string. + colors: Dictionary of ANSI color codes for each log level. -def _get_logging_handler(log_file: str | None = None, use_rich_handler: bool = False) -> logging.Handler: - """Returns a logging handler for the given log file.""" - if use_rich_handler and log_file is None: - # RichHandler for console output - console = Console(width=120) - rich_handler = RichHandler( + Returns: + Configured logging handler (RichHandler or StreamHandler). + """ + if use_rich: + # Convert ANSI codes to Rich theme + if colors: + theme_dict = {} + for level, ansi_code in colors.items(): + # Rich can parse ANSI codes directly! + try: + style = Style.from_ansi(ansi_code) + theme_dict[f'logging.level.{level.lower()}'] = style + except Exception: + # Fallback to default if parsing fails + pass + + theme = Theme(theme_dict) if theme_dict else None + else: + theme = None + + console = Console(width=console_width, theme=theme) + handler = RichHandler( console=console, rich_tracebacks=True, omit_repeated_times=True, - show_path=False, - log_time_format='%Y-%m-%d %H:%M:%S', - ) - rich_handler.setFormatter(logging.Formatter('%(message)s')) # Simplified formatting - - return rich_handler - elif log_file is None: - # Regular Logger with custom formating enabled - file_handler = logging.StreamHandler() - file_handler.setFormatter( - ColoredMultilineFormater( - fmt='%(message)s', - datefmt='%Y-%m-%d %H:%M:%S', - ) + show_path=show_path, + log_time_format=date_format, ) - return file_handler + handler.setFormatter(logging.Formatter(format)) else: - # FileHandler for file output - file_handler = logging.FileHandler(log_file) - file_handler.setFormatter( - MultilineFormater( - fmt='%(message)s', - datefmt='%Y-%m-%d %H:%M:%S', - ) - ) - return file_handler + handler = logging.StreamHandler() + handler.setFormatter(ColoredMultilineFormater(fmt=format, datefmt=date_format, colors=colors)) + + return handler + + +def _create_file_handler( + log_file: str, + max_file_size: int = 10_485_760, + backup_count: int = 5, + date_format: str = '%Y-%m-%d %H:%M:%S', + format: str = '%(message)s', +) -> RotatingFileHandler: + """Create a rotating file handler to prevent huge log files. + Args: + log_file: Path to the log file. + max_file_size: Maximum size in bytes before rotation. + backup_count: Number of backup files to keep. + date_format: Date/time format string. + format: Log message format string. + + Returns: + Configured RotatingFileHandler (without colors). + """ + handler = RotatingFileHandler( + log_file, + maxBytes=max_file_size, + backupCount=backup_count, + encoding='utf-8', + ) + handler.setFormatter(MultilineFormater(fmt=format, datefmt=date_format)) + return handler -def setup_logging( + +def _setup_logging( default_level: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] = 'INFO', - log_file: str | None = 'flixopt.log', + log_file: str | None = None, use_rich_handler: bool = False, + console: bool = False, + max_file_size: int = 10_485_760, + backup_count: int = 5, + date_format: str = '%Y-%m-%d %H:%M:%S', + format: str = '%(message)s', + console_width: int = 120, + show_path: bool = False, + colors: dict[str, str] | None = None, ): - """Setup logging configuration""" - logger = logging.getLogger('flixopt') # Use a specific logger name for your package - logger.setLevel(get_logging_level_by_name(default_level)) - # Clear existing handlers - if logger.hasHandlers(): - logger.handlers.clear() + """Internal function to setup logging - use CONFIG.apply() instead. - logger.addHandler(_get_logging_handler(use_rich_handler=use_rich_handler)) - if log_file is not None: - logger.addHandler(_get_logging_handler(log_file, use_rich_handler=False)) + Configures the flixopt logger with console and/or file handlers. + If no handlers are configured, adds NullHandler (library best practice). - return logger + Args: + default_level: Logging level for the logger. + log_file: Path to log file (None to disable file logging). + use_rich_handler: Use Rich for enhanced console output. + console: Enable console logging. + max_file_size: Maximum log file size before rotation. + backup_count: Number of backup log files to keep. + date_format: Date/time format for log messages. + format: Log message format string. + console_width: Console width for Rich handler. + show_path: Show file paths in log messages (Rich only). + colors: ANSI color codes for each log level. + """ + logger = logging.getLogger('flixopt') + logger.setLevel(getattr(logging, default_level.upper())) + logger.propagate = False # Prevent duplicate logs + logger.handlers.clear() + + if console: + logger.addHandler( + _create_console_handler( + use_rich=use_rich_handler, + console_width=console_width, + show_path=show_path, + date_format=date_format, + format=format, + colors=colors, + ) + ) + + if log_file: + logger.addHandler( + _create_file_handler( + log_file=log_file, + max_file_size=max_file_size, + backup_count=backup_count, + date_format=date_format, + format=format, + ) + ) + # Library best practice: NullHandler if no handlers configured + if not logger.handlers: + logger.addHandler(logging.NullHandler()) -def get_logging_level_by_name(level_name: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']) -> int: - possible_logging_levels = ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] - if level_name.upper() not in possible_logging_levels: - raise ValueError(f'Invalid logging level {level_name}') - else: - logging_level = getattr(logging, level_name.upper(), logging.WARNING) - return logging_level + return logger def change_logging_level(level_name: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']): + """ + Change the logging level for the flixopt logger and all its handlers. + + .. deprecated:: 2.1.11 + Use ``CONFIG.Logging.level = level_name`` and ``CONFIG.apply()`` instead. + This function will be removed in version 3.0.0. + + Parameters + ---------- + level_name : {'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'} + The logging level to set. + + Examples + -------- + >>> change_logging_level('DEBUG') # deprecated + >>> # Use this instead: + >>> CONFIG.Logging.level = 'DEBUG' + >>> CONFIG.apply() + """ + warnings.warn( + 'change_logging_level is deprecated and will be removed in version 3.0.0. ' + 'Use CONFIG.Logging.level = level_name and CONFIG.apply() instead.', + DeprecationWarning, + stacklevel=2, + ) logger = logging.getLogger('flixopt') - logging_level = get_logging_level_by_name(level_name) + logging_level = getattr(logging, level_name.upper()) logger.setLevel(logging_level) for handler in logger.handlers: handler.setLevel(logging_level) + + +# Initialize default config +CONFIG.apply() diff --git a/flixopt/config.yaml b/flixopt/config.yaml deleted file mode 100644 index e5336eeef..000000000 --- a/flixopt/config.yaml +++ /dev/null @@ -1,10 +0,0 @@ -# Default configuration of flixopt -config_name: flixopt # Name of the config file. This has no effect on the configuration itself. -logging: - level: INFO - file: flixopt.log - rich: false # logging output is formatted using rich. This is only advisable when using a proper terminal -modeling: - BIG: 10000000 # 1e notation not possible in yaml - EPSILON: 0.00001 - BIG_BINARY_BOUND: 100000 diff --git a/flixopt/elements.py b/flixopt/elements.py index 22256b636..21783808c 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -248,7 +248,7 @@ class Flow(Element): size: Flow capacity or nominal rating. Can be: - Scalar value for fixed capacity - InvestParameters for investment-based sizing decisions - - None to use large default value (CONFIG.modeling.BIG) + - None to use large default value (CONFIG.Modeling.big) relative_minimum: Minimum flow rate as fraction of size. Example: 0.2 means flow cannot go below 20% of rated capacity. relative_maximum: Maximum flow rate as fraction of size (typically 1.0). @@ -356,7 +356,7 @@ class Flow(Element): `relative_maximum` for upper bounds on optimization variables. Notes: - - Default size (CONFIG.modeling.BIG) is used when size=None + - Default size (CONFIG.Modeling.big) is used when size=None - list inputs for previous_flow_rate are converted to NumPy arrays - Flow direction is determined by component input/output designation @@ -383,7 +383,7 @@ def __init__( meta_data: dict | None = None, ): super().__init__(label, meta_data=meta_data) - self.size = CONFIG.modeling.BIG if size is None else size + self.size = CONFIG.Modeling.big if size is None else size self.relative_minimum = relative_minimum self.relative_maximum = relative_maximum self.fixed_relative_profile = fixed_relative_profile @@ -455,11 +455,11 @@ def _plausibility_checks(self) -> None: raise PlausibilityError(self.label_full + ': Take care, that relative_minimum <= relative_maximum!') if ( - self.size == CONFIG.modeling.BIG and self.fixed_relative_profile is not None + self.size == CONFIG.Modeling.big and self.fixed_relative_profile is not None ): # Default Size --> Most likely by accident logger.warning( f'Flow "{self.label}" has no size assigned, but a "fixed_relative_profile". ' - f'The default size is {CONFIG.modeling.BIG}. As "flow_rate = size * fixed_relative_profile", ' + f'The default size is {CONFIG.Modeling.big}. As "flow_rate = size * fixed_relative_profile", ' f'the resulting flow_rate will be very high. To fix this, assign a size to the Flow {self}.' ) diff --git a/flixopt/features.py b/flixopt/features.py index 5528917e0..7aafe242d 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -143,7 +143,7 @@ def _create_bounds_for_optional_investment(self): # eq2: P_invest >= isInvested * max(epsilon, investSize_min) self.add( self._model.add_constraints( - self.size >= self.is_invested * np.maximum(CONFIG.modeling.EPSILON, self.parameters.minimum_size), + self.size >= self.is_invested * np.maximum(CONFIG.Modeling.epsilon, self.parameters.minimum_size), name=f'{self.label_full}|is_invested_lb', ), 'is_invested_lb', @@ -304,7 +304,7 @@ def _add_defining_constraints(self): # Constraint: on * lower_bound <= def_var self.add( self._model.add_constraints( - self.on * np.maximum(CONFIG.modeling.EPSILON, lb) <= def_var, name=f'{self.label_full}|on_con1' + self.on * np.maximum(CONFIG.Modeling.epsilon, lb) <= def_var, name=f'{self.label_full}|on_con1' ), 'on_con1', ) @@ -314,7 +314,7 @@ def _add_defining_constraints(self): else: # Case for multiple defining variables ub = sum(bound[1] for bound in self._defining_bounds) / nr_of_def_vars - lb = CONFIG.modeling.EPSILON # TODO: Can this be a bigger value? (maybe the smallest bound?) + lb = CONFIG.Modeling.epsilon # TODO: Can this be a bigger value? (maybe the smallest bound?) # Constraint: on * epsilon <= sum(all_defining_variables) self.add( @@ -337,7 +337,7 @@ def _add_defining_constraints(self): @property def previous_states(self) -> np.ndarray: """Computes the previous states {0, 1} of defining variables as a binary array from their previous values.""" - return StateModel.compute_previous_states(self._previous_values, epsilon=CONFIG.modeling.EPSILON) + return StateModel.compute_previous_states(self._previous_values, epsilon=CONFIG.Modeling.epsilon) @property def previous_on_states(self) -> np.ndarray: @@ -603,14 +603,14 @@ def compute_consecutive_hours_in_state( elif np.isscalar(binary_values) and not np.isscalar(hours_per_timestep): return binary_values * hours_per_timestep[-1] - if np.isclose(binary_values[-1], 0, atol=CONFIG.modeling.EPSILON): + if np.isclose(binary_values[-1], 0, atol=CONFIG.Modeling.epsilon): return 0 if np.isscalar(hours_per_timestep): hours_per_timestep = np.ones(len(binary_values)) * hours_per_timestep hours_per_timestep: np.ndarray - indexes_with_zero_values = np.where(np.isclose(binary_values, 0, atol=CONFIG.modeling.EPSILON))[0] + indexes_with_zero_values = np.where(np.isclose(binary_values, 0, atol=CONFIG.Modeling.epsilon))[0] if len(indexes_with_zero_values) == 0: nr_of_indexes_with_consecutive_ones = len(binary_values) else: diff --git a/flixopt/interface.py b/flixopt/interface.py index e72e28b90..72737cc45 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -650,10 +650,10 @@ class InvestParameters(Interface): fixed_size: When specified, creates a binary investment decision at exactly this size. When None, allows continuous sizing between minimum and maximum bounds. minimum_size: Lower bound for continuous sizing decisions. Defaults to a small - positive value (CONFIG.modeling.EPSILON) to avoid numerical issues. + positive value (CONFIG.Modeling.epsilon) to avoid numerical issues. Ignored when fixed_size is specified. maximum_size: Upper bound for continuous sizing decisions. Defaults to a large - value (CONFIG.modeling.BIG) representing unlimited capacity. + value (CONFIG.Modeling.big) representing unlimited capacity. Ignored when fixed_size is specified. optional: Controls whether investment is required. When True (default), optimization can choose not to invest. When False, forces investment @@ -833,8 +833,8 @@ def __init__( self.optional = optional self.specific_effects: EffectValuesUserScalar = specific_effects or {} self.piecewise_effects = piecewise_effects - self._minimum_size = minimum_size if minimum_size is not None else CONFIG.modeling.EPSILON - self._maximum_size = maximum_size if maximum_size is not None else CONFIG.modeling.BIG # default maximum + self._minimum_size = minimum_size if minimum_size is not None else CONFIG.Modeling.epsilon + self._maximum_size = maximum_size if maximum_size is not None else CONFIG.Modeling.big # default maximum def transform_data(self, flow_system: FlowSystem): self.fix_effects = flow_system.effects.create_effect_values_dict(self.fix_effects) diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 000000000..c486d22c6 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,480 @@ +"""Tests for the config module.""" + +import logging +import sys +from pathlib import Path + +import pytest + +from flixopt.config import _DEFAULTS, CONFIG, _setup_logging + + +# All tests in this class will run in the same worker to prevent issues with global config altering +@pytest.mark.xdist_group(name='config_tests') +class TestConfigModule: + """Test the CONFIG class and logging setup.""" + + def setup_method(self): + """Reset CONFIG to defaults before each test.""" + CONFIG.reset() + + def teardown_method(self): + """Clean up after each test to prevent state leakage.""" + CONFIG.reset() + + def test_config_defaults(self): + """Test that CONFIG has correct default values.""" + assert CONFIG.Logging.level == 'INFO' + assert CONFIG.Logging.file == 'flixopt.log' + assert CONFIG.Logging.rich is False + assert CONFIG.Logging.console is True + assert CONFIG.Modeling.big == 10_000_000 + assert CONFIG.Modeling.epsilon == 1e-5 + assert CONFIG.Modeling.big_binary_bound == 100_000 + assert CONFIG.config_name == 'flixopt' + + def test_module_initialization(self): + """Test that logging is initialized on module import.""" + # Apply config to ensure handlers are initialized + CONFIG.apply() + logger = logging.getLogger('flixopt') + # Should have at least one handler (file handler by default) + assert len(logger.handlers) >= 1 + # Should have a file handler with default settings + assert any(isinstance(h, logging.handlers.RotatingFileHandler) for h in logger.handlers) + + def test_config_apply_console(self): + """Test applying config with console logging enabled.""" + CONFIG.Logging.console = True + CONFIG.Logging.level = 'DEBUG' + CONFIG.apply() + + logger = logging.getLogger('flixopt') + assert logger.level == logging.DEBUG + # Should have a StreamHandler for console output + assert any(isinstance(h, logging.StreamHandler) for h in logger.handlers) + # Should not have NullHandler when console is enabled + assert not any(isinstance(h, logging.NullHandler) for h in logger.handlers) + + def test_config_apply_file(self, tmp_path): + """Test applying config with file logging enabled.""" + log_file = tmp_path / 'test.log' + CONFIG.Logging.file = str(log_file) + CONFIG.Logging.level = 'WARNING' + CONFIG.apply() + + logger = logging.getLogger('flixopt') + assert logger.level == logging.WARNING + # Should have a RotatingFileHandler for file output + from logging.handlers import RotatingFileHandler + + assert any(isinstance(h, RotatingFileHandler) for h in logger.handlers) + + def test_config_apply_rich(self): + """Test applying config with rich logging enabled.""" + CONFIG.Logging.console = True + CONFIG.Logging.rich = True + CONFIG.apply() + + logger = logging.getLogger('flixopt') + # Should have a RichHandler + from rich.logging import RichHandler + + assert any(isinstance(h, RichHandler) for h in logger.handlers) + + def test_config_apply_multiple_changes(self): + """Test applying multiple config changes at once.""" + CONFIG.Logging.console = True + CONFIG.Logging.level = 'ERROR' + CONFIG.apply() + + logger = logging.getLogger('flixopt') + assert logger.level == logging.ERROR + assert any(isinstance(h, logging.StreamHandler) for h in logger.handlers) + + def test_config_to_dict(self): + """Test converting CONFIG to dictionary.""" + CONFIG.Logging.level = 'DEBUG' + CONFIG.Logging.console = True + + config_dict = CONFIG.to_dict() + + assert config_dict['config_name'] == 'flixopt' + assert config_dict['logging']['level'] == 'DEBUG' + assert config_dict['logging']['console'] is True + assert config_dict['logging']['file'] == 'flixopt.log' + assert config_dict['logging']['rich'] is False + assert 'modeling' in config_dict + assert config_dict['modeling']['big'] == 10_000_000 + + def test_config_load_from_file(self, tmp_path): + """Test loading configuration from YAML file.""" + config_file = tmp_path / 'config.yaml' + config_content = """ +config_name: test_config +logging: + level: DEBUG + console: true + rich: false +modeling: + big: 20000000 + epsilon: 1e-6 +""" + config_file.write_text(config_content) + + CONFIG.load_from_file(config_file) + + assert CONFIG.config_name == 'test_config' + assert CONFIG.Logging.level == 'DEBUG' + assert CONFIG.Logging.console is True + assert CONFIG.Modeling.big == 20000000 + # YAML may load epsilon as string, so convert for comparison + assert float(CONFIG.Modeling.epsilon) == 1e-6 + + def test_config_load_from_file_not_found(self): + """Test that loading from non-existent file raises error.""" + with pytest.raises(FileNotFoundError): + CONFIG.load_from_file('nonexistent_config.yaml') + + def test_config_load_from_file_partial(self, tmp_path): + """Test loading partial configuration (should keep unspecified settings).""" + config_file = tmp_path / 'partial_config.yaml' + config_content = """ +logging: + level: ERROR +""" + config_file.write_text(config_content) + + # Set a non-default value first + CONFIG.Logging.console = True + CONFIG.apply() + + CONFIG.load_from_file(config_file) + + # Should update level but keep other settings + assert CONFIG.Logging.level == 'ERROR' + # Verify console setting is preserved (not in YAML) + assert CONFIG.Logging.console is True + + def test_setup_logging_silent_default(self): + """Test that _setup_logging creates silent logger by default.""" + _setup_logging() + + logger = logging.getLogger('flixopt') + # Should have NullHandler when console=False and log_file=None + assert any(isinstance(h, logging.NullHandler) for h in logger.handlers) + assert not logger.propagate + + def test_setup_logging_with_console(self): + """Test _setup_logging with console output.""" + _setup_logging(console=True, default_level='DEBUG') + + logger = logging.getLogger('flixopt') + assert logger.level == logging.DEBUG + assert any(isinstance(h, logging.StreamHandler) for h in logger.handlers) + + def test_setup_logging_clears_handlers(self): + """Test that _setup_logging clears existing handlers.""" + logger = logging.getLogger('flixopt') + + # Add a dummy handler + dummy_handler = logging.NullHandler() + logger.addHandler(dummy_handler) + _ = len(logger.handlers) + + _setup_logging(console=True) + + # Should have cleared old handlers and added new one + assert dummy_handler not in logger.handlers + + def test_change_logging_level_removed(self): + """Test that change_logging_level function is deprecated but still exists.""" + # This function is deprecated - users should use CONFIG.apply() instead + import flixopt + + # Function should still exist but be deprecated + assert hasattr(flixopt, 'change_logging_level') + + # Should emit deprecation warning when called + with pytest.warns(DeprecationWarning, match='change_logging_level is deprecated'): + flixopt.change_logging_level('DEBUG') + + def test_public_api(self): + """Test that CONFIG and change_logging_level are exported from config module.""" + from flixopt import config + + # CONFIG should be accessible + assert hasattr(config, 'CONFIG') + + # change_logging_level should be accessible (but deprecated) + assert hasattr(config, 'change_logging_level') + + # _setup_logging should exist but be marked as private + assert hasattr(config, '_setup_logging') + + # merge_configs should not exist (was removed) + assert not hasattr(config, 'merge_configs') + + def test_logging_levels(self): + """Test all valid logging levels.""" + levels = ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] + + for level in levels: + CONFIG.Logging.level = level + CONFIG.Logging.console = True + CONFIG.apply() + + logger = logging.getLogger('flixopt') + assert logger.level == getattr(logging, level) + + def test_logger_propagate_disabled(self): + """Test that logger propagation is disabled.""" + CONFIG.apply() + logger = logging.getLogger('flixopt') + assert not logger.propagate + + def test_file_handler_rotation(self, tmp_path): + """Test that file handler uses rotation.""" + log_file = tmp_path / 'rotating.log' + CONFIG.Logging.file = str(log_file) + CONFIG.apply() + + logger = logging.getLogger('flixopt') + from logging.handlers import RotatingFileHandler + + file_handlers = [h for h in logger.handlers if isinstance(h, RotatingFileHandler)] + assert len(file_handlers) == 1 + + handler = file_handlers[0] + # Check rotation settings + assert handler.maxBytes == 10_485_760 # 10MB + assert handler.backupCount == 5 + + def test_custom_config_yaml_complete(self, tmp_path): + """Test loading a complete custom configuration.""" + config_file = tmp_path / 'custom_config.yaml' + config_content = """ +config_name: my_custom_config +logging: + level: CRITICAL + console: true + rich: true + file: /tmp/custom.log +modeling: + big: 50000000 + epsilon: 1e-4 + big_binary_bound: 200000 +""" + config_file.write_text(config_content) + + CONFIG.load_from_file(config_file) + + # Check all settings were applied + assert CONFIG.config_name == 'my_custom_config' + assert CONFIG.Logging.level == 'CRITICAL' + assert CONFIG.Logging.console is True + assert CONFIG.Logging.rich is True + assert CONFIG.Logging.file == '/tmp/custom.log' + assert CONFIG.Modeling.big == 50000000 + assert float(CONFIG.Modeling.epsilon) == 1e-4 + assert CONFIG.Modeling.big_binary_bound == 200000 + + # Verify logging was applied + logger = logging.getLogger('flixopt') + assert logger.level == logging.CRITICAL + + def test_config_file_with_console_and_file(self, tmp_path): + """Test configuration with both console and file logging enabled.""" + log_file = tmp_path / 'test.log' + config_file = tmp_path / 'config.yaml' + config_content = f""" +logging: + level: INFO + console: true + rich: false + file: {log_file} +""" + config_file.write_text(config_content) + + CONFIG.load_from_file(config_file) + + logger = logging.getLogger('flixopt') + # Should have both StreamHandler and RotatingFileHandler + from logging.handlers import RotatingFileHandler + + assert any(isinstance(h, logging.StreamHandler) for h in logger.handlers) + assert any(isinstance(h, RotatingFileHandler) for h in logger.handlers) + # Should NOT have NullHandler when console/file are enabled + assert not any(isinstance(h, logging.NullHandler) for h in logger.handlers) + + def test_config_to_dict_roundtrip(self, tmp_path): + """Test that config can be saved to dict, modified, and restored.""" + # Set custom values + CONFIG.Logging.level = 'WARNING' + CONFIG.Logging.console = True + CONFIG.Modeling.big = 99999999 + + # Save to dict + config_dict = CONFIG.to_dict() + + # Verify dict structure + assert config_dict['logging']['level'] == 'WARNING' + assert config_dict['logging']['console'] is True + assert config_dict['modeling']['big'] == 99999999 + + # Could be written to YAML and loaded back + yaml_file = tmp_path / 'saved_config.yaml' + import yaml + + with open(yaml_file, 'w') as f: + yaml.dump(config_dict, f) + + # Reset config + CONFIG.Logging.level = 'INFO' + CONFIG.Logging.console = False + CONFIG.Modeling.big = 10_000_000 + + # Load back from file + CONFIG.load_from_file(yaml_file) + + # Should match original values + assert CONFIG.Logging.level == 'WARNING' + assert CONFIG.Logging.console is True + assert CONFIG.Modeling.big == 99999999 + + def test_config_file_with_only_modeling(self, tmp_path): + """Test config file that only sets modeling parameters.""" + config_file = tmp_path / 'modeling_only.yaml' + config_content = """ +modeling: + big: 999999 + epsilon: 0.001 +""" + config_file.write_text(config_content) + + # Set logging config before loading + original_level = CONFIG.Logging.level + CONFIG.load_from_file(config_file) + + # Modeling should be updated + assert CONFIG.Modeling.big == 999999 + assert float(CONFIG.Modeling.epsilon) == 0.001 + + # Logging should keep default/previous values + assert CONFIG.Logging.level == original_level + + def test_config_attribute_modification(self): + """Test that config attributes can be modified directly.""" + # Store original values + original_big = CONFIG.Modeling.big + original_level = CONFIG.Logging.level + + # Modify attributes + CONFIG.Modeling.big = 12345678 + CONFIG.Modeling.epsilon = 1e-8 + CONFIG.Logging.level = 'DEBUG' + CONFIG.Logging.console = True + + # Verify modifications + assert CONFIG.Modeling.big == 12345678 + assert CONFIG.Modeling.epsilon == 1e-8 + assert CONFIG.Logging.level == 'DEBUG' + assert CONFIG.Logging.console is True + + # Reset + CONFIG.Modeling.big = original_big + CONFIG.Logging.level = original_level + CONFIG.Logging.console = False + + def test_logger_actually_logs(self, tmp_path): + """Test that the logger actually writes log messages.""" + log_file = tmp_path / 'actual_test.log' + CONFIG.Logging.file = str(log_file) + CONFIG.Logging.level = 'DEBUG' + CONFIG.apply() + + logger = logging.getLogger('flixopt') + test_message = 'Test log message from config test' + logger.debug(test_message) + + # Check that file was created and contains the message + assert log_file.exists() + log_content = log_file.read_text() + assert test_message in log_content + + def test_modeling_config_persistence(self): + """Test that Modeling config is independent of Logging config.""" + # Set custom modeling values + CONFIG.Modeling.big = 99999999 + CONFIG.Modeling.epsilon = 1e-8 + + # Change and apply logging config + CONFIG.Logging.console = True + CONFIG.apply() + + # Modeling values should be unchanged + assert CONFIG.Modeling.big == 99999999 + assert CONFIG.Modeling.epsilon == 1e-8 + + def test_config_reset(self): + """Test that CONFIG.reset() restores all defaults.""" + # Modify all config values + CONFIG.Logging.level = 'DEBUG' + CONFIG.Logging.console = False + CONFIG.Logging.rich = True + CONFIG.Logging.file = '/tmp/test.log' + CONFIG.Modeling.big = 99999999 + CONFIG.Modeling.epsilon = 1e-8 + CONFIG.Modeling.big_binary_bound = 500000 + CONFIG.config_name = 'test_config' + + # Reset should restore all defaults + CONFIG.reset() + + # Verify all values are back to defaults + assert CONFIG.Logging.level == 'INFO' + assert CONFIG.Logging.console is True + assert CONFIG.Logging.rich is False + assert CONFIG.Logging.file == 'flixopt.log' + assert CONFIG.Modeling.big == 10_000_000 + assert CONFIG.Modeling.epsilon == 1e-5 + assert CONFIG.Modeling.big_binary_bound == 100_000 + assert CONFIG.config_name == 'flixopt' + + # Verify logging was also reset + logger = logging.getLogger('flixopt') + assert logger.level == logging.INFO + assert any(isinstance(h, logging.handlers.RotatingFileHandler) for h in logger.handlers) + + def test_reset_matches_class_defaults(self): + """Test that reset() values match the _DEFAULTS constants. + + This ensures the reset() method and class attribute defaults + stay synchronized by using the same source of truth (_DEFAULTS). + """ + # Modify all values to something different + CONFIG.Logging.level = 'CRITICAL' + CONFIG.Logging.file = '/tmp/test.log' + CONFIG.Logging.rich = True + CONFIG.Logging.console = True + CONFIG.Modeling.big = 999999 + CONFIG.Modeling.epsilon = 1e-10 + CONFIG.Modeling.big_binary_bound = 999999 + CONFIG.config_name = 'modified' + + # Verify values are actually different from defaults + assert CONFIG.Logging.level != _DEFAULTS['logging']['level'] + assert CONFIG.Modeling.big != _DEFAULTS['modeling']['big'] + + # Now reset + CONFIG.reset() + + # Verify reset() restored exactly the _DEFAULTS values + assert CONFIG.Logging.level == _DEFAULTS['logging']['level'] + assert CONFIG.Logging.file == _DEFAULTS['logging']['file'] + assert CONFIG.Logging.rich == _DEFAULTS['logging']['rich'] + assert CONFIG.Logging.console == _DEFAULTS['logging']['console'] + assert CONFIG.Modeling.big == _DEFAULTS['modeling']['big'] + assert CONFIG.Modeling.epsilon == _DEFAULTS['modeling']['epsilon'] + assert CONFIG.Modeling.big_binary_bound == _DEFAULTS['modeling']['big_binary_bound'] + assert CONFIG.config_name == _DEFAULTS['config_name'] From 906fe9968f986ab1deee51c1e19f6a8df879bf30 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 9 Oct 2025 11:39:04 +0200 Subject: [PATCH 11/23] Update CHANGELOG.md --- CHANGELOG.md | 37 ++++++++++++++++++++++++++----------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70660c6dd..23ecb066b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm Formatting is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) & [Gitmoji](https://gitmoji.dev). For more details regarding the individual PRs and contributors, please refer to our [GitHub releases](https://github.com/flixOpt/flixopt/releases). +--- + + +## [v2.2.0] - 2025-10-11 +**Summary:** This release is a Configuration and Logging management release. + +### ✨ Added +- Added `CONFIG.reset()` method to restore configuration to default values +- Added configurable log file rotation settings: `CONFIG.Logging.max_file_size` and `CONFIG.Logging.backup_count` +- Added configurable log format settings: `CONFIG.Logging.date_format` and `CONFIG.Logging.format` +- Added configurable console settings: `CONFIG.Logging.console_width` and `CONFIG.Logging.show_path` +- Added `CONFIG.Logging.Colors` nested class for customizable log level colors using ANSI escape codes (works with both standard and Rich handlers) + +### ♻️ Changed +- Logging and Configuration management changed + +### 🗑️ Deprecated +- `change_logging_level()` function is now deprecated in favor of `CONFIG.Logging.level` and `CONFIG.apply()`. Will be removed in version 3.0.0. + +### 🔥 Removed +- Removed unused `config.merge_configs` function from configuration module + +### 👷 Development +- Greatly expanded test coverage for `config.py` module +- Added `@pytest.mark.xdist_group` to `TestConfigModule` tests to prevent global config interference + --- ## [2.1.11] - 2025-10-05 From f957ee83ed51a4c68cd0ea636ac8bcfbaea12a6b Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 9 Oct 2025 11:43:11 +0200 Subject: [PATCH 12/23] Fix CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23ecb066b..4ce346940 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,7 +67,7 @@ Please keep the format of the changelog consistent with the other releases, so t Until here --> -## [v2.2.0] - 2025-10-11 +## [2.2.0] - 2025-10-11 **Summary:** This release is a Configuration and Logging management release. ### ✨ Added From f3e765c39736385f96810668f9af73ea4b85dbc5 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 9 Oct 2025 13:21:54 +0200 Subject: [PATCH 13/23] Allow blank issues --- .github/ISSUE_TEMPLATE/config.yml | 2 +- .github/ISSUE_TEMPLATE/general_issue.yml | 40 ------------------------ CHANGELOG.md | 1 + 3 files changed, 2 insertions(+), 41 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/general_issue.yml diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index bc07496e8..94d96c479 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,4 +1,4 @@ -blank_issues_enabled: false +blank_issues_enabled: true contact_links: - name: 🤔 Modeling Questions url: https://github.com/flixOpt/flixopt/discussions/categories/q-a diff --git a/.github/ISSUE_TEMPLATE/general_issue.yml b/.github/ISSUE_TEMPLATE/general_issue.yml deleted file mode 100644 index f2578b9ce..000000000 --- a/.github/ISSUE_TEMPLATE/general_issue.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: 📝 General Issue -description: For issues that don't fit the specific templates below -title: "" -body: - - type: markdown - attributes: - value: | - **For specific issue types, please use the dedicated templates:** - - 🐛 **Bug Report** - Something is broken or not working as expected - - ✨ **Feature Request** - Suggest new functionality - - **For other topics, consider using Discussions instead:** - - 🤔 [Modeling Questions](https://github.com/flixOpt/flixopt/discussions/categories/q-a) - How to model specific energy systems - - ⚡ [Performance Help](https://github.com/flixOpt/flixopt/discussions/categories/performance) - Optimization speed and memory issues - - - type: textarea - id: issue-description - attributes: - label: Issue Description - description: Describe your issue, question, or concern - placeholder: | - Please describe: - - What you're trying to accomplish - - What's not working as expected - - Any relevant context or background - validations: - required: true - - - type: textarea - id: context - attributes: - label: Additional Context - description: Code examples, environment details, error messages, etc. - placeholder: | - Optional: Add any relevant details like: - - Code snippets - - Error messages - - Environment info - - Screenshots - render: markdown diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ce346940..df33609d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,7 @@ Please keep the format of the changelog consistent with the other releases, so t ### 📝 Docs ### 👷 Development +- Enable blank issues ### 🚧 Known Issues From 6de4503b697a8e2e85b4dc8451115be699379440 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 10 Oct 2025 13:05:23 +0200 Subject: [PATCH 14/23] Change default logging behaviour to other colors and no file logging --- examples/00_Minmal/minimal_example.py | 3 ++ examples/01_Simple/simple_example.py | 3 ++ examples/02_Complex/complex_example.py | 3 ++ .../02_Complex/complex_example_results.py | 3 ++ .../example_calculation_types.py | 3 ++ flixopt/config.py | 32 +++++++++---------- 6 files changed, 31 insertions(+), 16 deletions(-) diff --git a/examples/00_Minmal/minimal_example.py b/examples/00_Minmal/minimal_example.py index e9ef241ff..a99769b4c 100644 --- a/examples/00_Minmal/minimal_example.py +++ b/examples/00_Minmal/minimal_example.py @@ -9,6 +9,9 @@ import flixopt as fx if __name__ == '__main__': + # Enable console logging + fx.CONFIG.Logging.console = True + fx.CONFIG.apply() # --- Define the Flow System, that will hold all elements, and the time steps you want to model --- timesteps = pd.date_range('2020-01-01', periods=3, freq='h') flow_system = fx.FlowSystem(timesteps) diff --git a/examples/01_Simple/simple_example.py b/examples/01_Simple/simple_example.py index 8239f805a..a386c61ae 100644 --- a/examples/01_Simple/simple_example.py +++ b/examples/01_Simple/simple_example.py @@ -8,6 +8,9 @@ import flixopt as fx if __name__ == '__main__': + # Enable console logging + fx.CONFIG.Logging.console = True + fx.CONFIG.apply() # --- Create Time Series Data --- # Heat demand profile (e.g., kW) over time and corresponding power prices heat_demand_per_h = np.array([30, 0, 90, 110, 110, 20, 20, 20, 20]) diff --git a/examples/02_Complex/complex_example.py b/examples/02_Complex/complex_example.py index 175211c26..f3caad93c 100644 --- a/examples/02_Complex/complex_example.py +++ b/examples/02_Complex/complex_example.py @@ -9,6 +9,9 @@ import flixopt as fx if __name__ == '__main__': + # Enable console logging + fx.CONFIG.Logging.console = True + fx.CONFIG.apply() # --- Experiment Options --- # Configure options for testing various parameters and behaviors check_penalty = False diff --git a/examples/02_Complex/complex_example_results.py b/examples/02_Complex/complex_example_results.py index 3be201ae8..5020f71fe 100644 --- a/examples/02_Complex/complex_example_results.py +++ b/examples/02_Complex/complex_example_results.py @@ -5,6 +5,9 @@ import flixopt as fx if __name__ == '__main__': + # Enable console logging + fx.CONFIG.Logging.console = True + fx.CONFIG.apply() # --- Load Results --- try: results = fx.results.CalculationResults.from_file('results', 'complex example') diff --git a/examples/03_Calculation_types/example_calculation_types.py b/examples/03_Calculation_types/example_calculation_types.py index a92a20163..18cca32cb 100644 --- a/examples/03_Calculation_types/example_calculation_types.py +++ b/examples/03_Calculation_types/example_calculation_types.py @@ -11,6 +11,9 @@ import flixopt as fx if __name__ == '__main__': + # Enable console logging + fx.CONFIG.Logging.console = True + fx.CONFIG.apply() # Calculation Types full, segmented, aggregated = True, True, True diff --git a/flixopt/config.py b/flixopt/config.py index 2ec5bf88c..57c29c2e7 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -25,9 +25,9 @@ 'logging': MappingProxyType( { 'level': 'INFO', - 'file': 'flixopt.log', + 'file': None, 'rich': False, - 'console': True, + 'console': False, 'max_file_size': 10_485_760, # 10MB 'backup_count': 5, 'date_format': '%Y-%m-%d %H:%M:%S', @@ -36,8 +36,8 @@ 'show_path': False, 'colors': MappingProxyType( { - 'DEBUG': '\033[32m', # Green - 'INFO': '\033[34m', # Blue + 'DEBUG': '\033[90m', # Bright Black/Gray + 'INFO': '\033[0m', # Default/Reset (no color) 'WARNING': '\033[33m', # Yellow 'ERROR': '\033[31m', # Red 'CRITICAL': '\033[1m\033[31m', # Bold Red @@ -62,7 +62,7 @@ class CONFIG: The CONFIG class provides centralized configuration for logging and modeling parameters. All changes require calling ``CONFIG.apply()`` to take effect. - By default, logging outputs to both console and file ('flixopt.log'). + By default, logging is disabled (no console or file output). Enable logging in your scripts as needed. Attributes: Logging: Nested class containing all logging configuration options. @@ -73,9 +73,9 @@ class CONFIG: Logging Attributes: level (str): Logging level: 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'. Default: 'INFO' - file (str | None): Log file path. Default: 'flixopt.log'. - Set to None to disable file logging. - console (bool): Enable console (stdout) logging. Default: True + file (str | None): Log file path. Default: None (file logging disabled). + Set to a file path to enable file logging. + console (bool): Enable console (stdout) logging. Default: False rich (bool): Use Rich library for enhanced console output. Default: False max_file_size (int): Maximum log file size in bytes before rotation. Default: 10485760 (10MB) @@ -87,8 +87,8 @@ class CONFIG: show_path (bool): Show file paths in log messages. Default: False Colors Attributes: - DEBUG (str): ANSI color code for DEBUG level. Default: '\\033[32m' (green) - INFO (str): ANSI color code for INFO level. Default: '\\033[34m' (blue) + DEBUG (str): ANSI color code for DEBUG level. Default: '\\033[90m' (bright black/gray) + INFO (str): ANSI color code for INFO level. Default: '\\033[0m' (default/no color) WARNING (str): ANSI color code for WARNING level. Default: '\\033[33m' (yellow) ERROR (str): ANSI color code for ERROR level. Default: '\\033[31m' (red) CRITICAL (str): ANSI color code for CRITICAL level. Default: '\\033[1m\\033[31m' (bold red) @@ -139,7 +139,7 @@ class CONFIG: Customize log colors:: - CONFIG.Logging.Colors.INFO = '\\033[35m' # Magenta + CONFIG.Logging.Colors.INFO = '\\033[32m' # Green CONFIG.Logging.Colors.DEBUG = '\\033[36m' # Cyan CONFIG.Logging.Colors.ERROR = '\\033[1m\\033[31m' # Bold red CONFIG.apply() @@ -150,7 +150,7 @@ class CONFIG: CONFIG.Logging.rich = True CONFIG.Logging.console_width = 100 CONFIG.Logging.show_path = True - CONFIG.Logging.Colors.INFO = '\\033[36m' # Cyan + CONFIG.Logging.Colors.INFO = '\\033[32m' # Green CONFIG.apply() Load from YAML file:: @@ -172,8 +172,8 @@ class CONFIG: console_width: 100 show_path: true colors: - DEBUG: "\\033[36m" # Cyan - INFO: "\\033[32m" # Green + DEBUG: "\\033[90m" # Bright Black/Gray + INFO: "\\033[0m" # Default/No color WARNING: "\\033[33m" # Yellow ERROR: "\\033[31m" # Red CRITICAL: "\\033[1m\\033[31m" # Bold red @@ -360,8 +360,8 @@ def __init__(self, fmt=None, datefmt=None, colors=None): colors if colors is not None else { - 'DEBUG': '\033[32m', - 'INFO': '\033[34m', + 'DEBUG': '\033[90m', + 'INFO': '\033[0m', 'WARNING': '\033[33m', 'ERROR': '\033[31m', 'CRITICAL': '\033[1m\033[31m', From daa5840a22edda1abcf1d758f870618f73389350 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 10 Oct 2025 13:07:36 +0200 Subject: [PATCH 15/23] Use white for INFO --- CHANGELOG.md | 4 ++++ flixopt/config.py | 8 ++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df33609d8..9b4d09a1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,9 +77,13 @@ Until here --> - Added configurable log format settings: `CONFIG.Logging.date_format` and `CONFIG.Logging.format` - Added configurable console settings: `CONFIG.Logging.console_width` and `CONFIG.Logging.show_path` - Added `CONFIG.Logging.Colors` nested class for customizable log level colors using ANSI escape codes (works with both standard and Rich handlers) +- All examples now enable console logging to demonstrate proper logging usage ### ♻️ Changed - Logging and Configuration management changed +- **Breaking**: Console logging is now disabled by default (`CONFIG.Logging.console = False`). Enable it explicitly in your scripts with `CONFIG.Logging.console = True` and `CONFIG.apply()` +- **Breaking**: File logging is now disabled by default (`CONFIG.Logging.file = None`). Set a file path to enable file logging +- Improved default logging colors: DEBUG is now gray (`\033[90m`), INFO is now white (`\033[37m`) for better readability ### 🗑️ Deprecated - `change_logging_level()` function is now deprecated in favor of `CONFIG.Logging.level` and `CONFIG.apply()`. Will be removed in version 3.0.0. diff --git a/flixopt/config.py b/flixopt/config.py index 57c29c2e7..8f74d9a20 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -37,7 +37,7 @@ 'colors': MappingProxyType( { 'DEBUG': '\033[90m', # Bright Black/Gray - 'INFO': '\033[0m', # Default/Reset (no color) + 'INFO': '\033[37m', # White 'WARNING': '\033[33m', # Yellow 'ERROR': '\033[31m', # Red 'CRITICAL': '\033[1m\033[31m', # Bold Red @@ -88,7 +88,7 @@ class CONFIG: Colors Attributes: DEBUG (str): ANSI color code for DEBUG level. Default: '\\033[90m' (bright black/gray) - INFO (str): ANSI color code for INFO level. Default: '\\033[0m' (default/no color) + INFO (str): ANSI color code for INFO level. Default: '\\033[37m' (white) WARNING (str): ANSI color code for WARNING level. Default: '\\033[33m' (yellow) ERROR (str): ANSI color code for ERROR level. Default: '\\033[31m' (red) CRITICAL (str): ANSI color code for CRITICAL level. Default: '\\033[1m\\033[31m' (bold red) @@ -173,7 +173,7 @@ class CONFIG: show_path: true colors: DEBUG: "\\033[90m" # Bright Black/Gray - INFO: "\\033[0m" # Default/No color + INFO: "\\033[37m" # White WARNING: "\\033[33m" # Yellow ERROR: "\\033[31m" # Red CRITICAL: "\\033[1m\\033[31m" # Bold red @@ -361,7 +361,7 @@ def __init__(self, fmt=None, datefmt=None, colors=None): if colors is not None else { 'DEBUG': '\033[90m', - 'INFO': '\033[0m', + 'INFO': '\033[37m', 'WARNING': '\033[33m', 'ERROR': '\033[31m', 'CRITICAL': '\033[1m\033[31m', From d0f24c8bc3d4e1c1bbee8e881c20f900de799fea Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 10 Oct 2025 13:18:07 +0200 Subject: [PATCH 16/23] Use terminal default for INFO --- flixopt/config.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flixopt/config.py b/flixopt/config.py index 8f74d9a20..66e229876 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -37,7 +37,7 @@ 'colors': MappingProxyType( { 'DEBUG': '\033[90m', # Bright Black/Gray - 'INFO': '\033[37m', # White + 'INFO': '\033[0m', # White 'WARNING': '\033[33m', # Yellow 'ERROR': '\033[31m', # Red 'CRITICAL': '\033[1m\033[31m', # Bold Red @@ -88,7 +88,7 @@ class CONFIG: Colors Attributes: DEBUG (str): ANSI color code for DEBUG level. Default: '\\033[90m' (bright black/gray) - INFO (str): ANSI color code for INFO level. Default: '\\033[37m' (white) + INFO (str): ANSI color code for INFO level. Default: '\\033[m' (terminal default) WARNING (str): ANSI color code for WARNING level. Default: '\\033[33m' (yellow) ERROR (str): ANSI color code for ERROR level. Default: '\\033[31m' (red) CRITICAL (str): ANSI color code for CRITICAL level. Default: '\\033[1m\\033[31m' (bold red) @@ -361,7 +361,7 @@ def __init__(self, fmt=None, datefmt=None, colors=None): if colors is not None else { 'DEBUG': '\033[90m', - 'INFO': '\033[37m', + 'INFO': '\033[0m', 'WARNING': '\033[33m', 'ERROR': '\033[31m', 'CRITICAL': '\033[1m\033[31m', From 5175f6781b615c62a453a829166937f2794760ce Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 10 Oct 2025 13:35:05 +0200 Subject: [PATCH 17/23] Explicitly use stdout for StreamHandler --- flixopt/config.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/flixopt/config.py b/flixopt/config.py index 66e229876..487575624 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -423,7 +423,10 @@ def _create_console_handler( ) handler.setFormatter(logging.Formatter(format)) else: - handler = logging.StreamHandler() + import sys + + # Explicitly use sys.stdout instead of default sys.stderr + handler = logging.StreamHandler(stream=sys.stdout) handler.setFormatter(ColoredMultilineFormater(fmt=format, datefmt=date_format, colors=colors)) return handler From f4d039ba97b67a737a75863e9140b6b356da6c0b Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 10 Oct 2025 13:35:23 +0200 Subject: [PATCH 18/23] Use terminal default for Logging color --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b4d09a1d..cdbb3a73f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -83,7 +83,7 @@ Until here --> - Logging and Configuration management changed - **Breaking**: Console logging is now disabled by default (`CONFIG.Logging.console = False`). Enable it explicitly in your scripts with `CONFIG.Logging.console = True` and `CONFIG.apply()` - **Breaking**: File logging is now disabled by default (`CONFIG.Logging.file = None`). Set a file path to enable file logging -- Improved default logging colors: DEBUG is now gray (`\033[90m`), INFO is now white (`\033[37m`) for better readability +- Improved default logging colors: DEBUG is now gray (`\033[90m`), INFO is now white (`\033[0m`) for better readability ### 🗑️ Deprecated - `change_logging_level()` function is now deprecated in favor of `CONFIG.Logging.level` and `CONFIG.apply()`. Will be removed in version 3.0.0. From 27ae8efb0dfe12af3dc6761bb250bb2daef0477f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 10 Oct 2025 13:40:29 +0200 Subject: [PATCH 19/23] Add option for loggger name --- flixopt/config.py | 69 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 55 insertions(+), 14 deletions(-) diff --git a/flixopt/config.py b/flixopt/config.py index 487575624..663638145 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +import sys import warnings from logging.handlers import RotatingFileHandler from pathlib import Path @@ -34,10 +35,11 @@ 'format': '%(message)s', 'console_width': 120, 'show_path': False, + 'show_logger_name': False, 'colors': MappingProxyType( { 'DEBUG': '\033[90m', # Bright Black/Gray - 'INFO': '\033[0m', # White + 'INFO': '\033[0m', # Default/White 'WARNING': '\033[33m', # Yellow 'ERROR': '\033[31m', # Red 'CRITICAL': '\033[1m\033[31m', # Bold Red @@ -85,10 +87,11 @@ class CONFIG: format (str): Log message format string. Default: '%(message)s' console_width (int): Console width for Rich handler. Default: 120 show_path (bool): Show file paths in log messages. Default: False + show_logger_name (bool): Show logger name in log messages. Default: False Colors Attributes: DEBUG (str): ANSI color code for DEBUG level. Default: '\\033[90m' (bright black/gray) - INFO (str): ANSI color code for INFO level. Default: '\\033[m' (terminal default) + INFO (str): ANSI color code for INFO level. Default: '\\033[0m' (terminal default) WARNING (str): ANSI color code for WARNING level. Default: '\\033[33m' (yellow) ERROR (str): ANSI color code for ERROR level. Default: '\\033[31m' (red) CRITICAL (str): ANSI color code for CRITICAL level. Default: '\\033[1m\\033[31m' (bold red) @@ -106,6 +109,8 @@ class CONFIG: - '\\033[35m' - Magenta - '\\033[36m' - Cyan - '\\033[37m' - White + - '\\033[90m' - Bright Black/Gray + - '\\033[0m' - Reset to default - '\\033[1m\\033[3Xm' - Bold color (replace X with color code 0-7) - '\\033[2m\\033[3Xm' - Dim color (replace X with color code 0-7) @@ -130,6 +135,12 @@ class CONFIG: CONFIG.Logging.level = 'DEBUG' CONFIG.apply() + Show logger names to see which module is logging:: + + CONFIG.Logging.console = True + CONFIG.Logging.show_logger_name = True + CONFIG.apply() + Configure log file rotation:: CONFIG.Logging.file = 'myapp.log' @@ -165,15 +176,16 @@ class CONFIG: level: DEBUG console: true file: app.log - rich: true + rich: false + show_logger_name: true max_file_size: 5242880 # 5MB backup_count: 3 date_format: '%H:%M:%S' console_width: 100 - show_path: true + show_path: false colors: DEBUG: "\\033[90m" # Bright Black/Gray - INFO: "\\033[37m" # White + INFO: "\\033[0m" # Default WARNING: "\\033[33m" # Yellow ERROR: "\\033[31m" # Red CRITICAL: "\\033[1m\\033[31m" # Bold red @@ -207,6 +219,7 @@ class Logging: format: str = _DEFAULTS['logging']['format'] console_width: int = _DEFAULTS['logging']['console_width'] show_path: bool = _DEFAULTS['logging']['show_path'] + show_logger_name: bool = _DEFAULTS['logging']['show_logger_name'] class Colors: DEBUG: str = _DEFAULTS['logging']['colors']['DEBUG'] @@ -262,6 +275,7 @@ def apply(cls): format=cls.Logging.format, console_width=cls.Logging.console_width, show_path=cls.Logging.show_path, + show_logger_name=cls.Logging.show_logger_name, colors=colors_dict, ) @@ -312,6 +326,7 @@ def to_dict(cls): 'format': cls.Logging.format, 'console_width': cls.Logging.console_width, 'show_path': cls.Logging.show_path, + 'show_logger_name': cls.Logging.show_logger_name, 'colors': { 'DEBUG': cls.Logging.Colors.DEBUG, 'INFO': cls.Logging.Colors.INFO, @@ -331,14 +346,21 @@ def to_dict(cls): class MultilineFormater(logging.Formatter): """Formatter that handles multi-line messages with consistent prefixes.""" - def __init__(self, fmt=None, datefmt=None): - super().__init__(fmt=fmt, datefmt=datefmt) + def __init__(self, fmt=None, datefmt=None, show_logger_name=False): + super().__init__(datefmt=datefmt) + self.show_logger_name = show_logger_name def format(self, record): message_lines = record.getMessage().split('\n') timestamp = self.formatTime(record, self.datefmt) log_level = record.levelname.ljust(8) - log_prefix = f'{timestamp} | {log_level} |' + + if self.show_logger_name: + # Truncate long logger names for readability + logger_name = record.name if len(record.name) <= 20 else f'...{record.name[-17:]}' + log_prefix = f'{timestamp} | {log_level} | {logger_name.ljust(20)} |' + else: + log_prefix = f'{timestamp} | {log_level} |' first_line = [f'{log_prefix} {message_lines[0]}'] if len(message_lines) > 1: @@ -354,8 +376,8 @@ class ColoredMultilineFormater(MultilineFormater): RESET = '\033[0m' - def __init__(self, fmt=None, datefmt=None, colors=None): - super().__init__(fmt=fmt, datefmt=datefmt) + def __init__(self, fmt=None, datefmt=None, colors=None, show_logger_name=False): + super().__init__(datefmt=datefmt, show_logger_name=show_logger_name) self.COLORS = ( colors if colors is not None @@ -379,6 +401,7 @@ def _create_console_handler( use_rich: bool = False, console_width: int = 120, show_path: bool = False, + show_logger_name: bool = False, date_format: str = '%Y-%m-%d %H:%M:%S', format: str = '%(message)s', colors: dict[str, str] | None = None, @@ -389,6 +412,7 @@ def _create_console_handler( use_rich: If True, use RichHandler with color support. console_width: Width of the console for Rich handler. show_path: Show file paths in log messages (Rich only). + show_logger_name: Show logger name in log messages. date_format: Date/time format string. format: Log message format string. colors: Dictionary of ANSI color codes for each log level. @@ -423,11 +447,16 @@ def _create_console_handler( ) handler.setFormatter(logging.Formatter(format)) else: - import sys - # Explicitly use sys.stdout instead of default sys.stderr handler = logging.StreamHandler(stream=sys.stdout) - handler.setFormatter(ColoredMultilineFormater(fmt=format, datefmt=date_format, colors=colors)) + handler.setFormatter( + ColoredMultilineFormater( + fmt=format, + datefmt=date_format, + colors=colors, + show_logger_name=show_logger_name, + ) + ) return handler @@ -436,6 +465,7 @@ def _create_file_handler( log_file: str, max_file_size: int = 10_485_760, backup_count: int = 5, + show_logger_name: bool = False, date_format: str = '%Y-%m-%d %H:%M:%S', format: str = '%(message)s', ) -> RotatingFileHandler: @@ -445,6 +475,7 @@ def _create_file_handler( log_file: Path to the log file. max_file_size: Maximum size in bytes before rotation. backup_count: Number of backup files to keep. + show_logger_name: Show logger name in log messages. date_format: Date/time format string. format: Log message format string. @@ -457,7 +488,13 @@ def _create_file_handler( backupCount=backup_count, encoding='utf-8', ) - handler.setFormatter(MultilineFormater(fmt=format, datefmt=date_format)) + handler.setFormatter( + MultilineFormater( + fmt=format, + datefmt=date_format, + show_logger_name=show_logger_name, + ) + ) return handler @@ -472,6 +509,7 @@ def _setup_logging( format: str = '%(message)s', console_width: int = 120, show_path: bool = False, + show_logger_name: bool = False, colors: dict[str, str] | None = None, ): """Internal function to setup logging - use CONFIG.apply() instead. @@ -490,6 +528,7 @@ def _setup_logging( format: Log message format string. console_width: Console width for Rich handler. show_path: Show file paths in log messages (Rich only). + show_logger_name: Show logger name in log messages. colors: ANSI color codes for each log level. """ logger = logging.getLogger('flixopt') @@ -503,6 +542,7 @@ def _setup_logging( use_rich=use_rich_handler, console_width=console_width, show_path=show_path, + show_logger_name=show_logger_name, date_format=date_format, format=format, colors=colors, @@ -515,6 +555,7 @@ def _setup_logging( log_file=log_file, max_file_size=max_file_size, backup_count=backup_count, + show_logger_name=show_logger_name, date_format=date_format, format=format, ) From 3d0c0304737a3da30b5e1689cb261bdb4b973bb2 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 10 Oct 2025 13:42:00 +0200 Subject: [PATCH 20/23] Update CHANGELOG.md --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cdbb3a73f..b933bbfee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,12 +78,13 @@ Until here --> - Added configurable console settings: `CONFIG.Logging.console_width` and `CONFIG.Logging.show_path` - Added `CONFIG.Logging.Colors` nested class for customizable log level colors using ANSI escape codes (works with both standard and Rich handlers) - All examples now enable console logging to demonstrate proper logging usage +- Console logging now outputs to `sys.stdout` instead of `sys.stderr` for better compatibility with output redirection ### ♻️ Changed - Logging and Configuration management changed - **Breaking**: Console logging is now disabled by default (`CONFIG.Logging.console = False`). Enable it explicitly in your scripts with `CONFIG.Logging.console = True` and `CONFIG.apply()` - **Breaking**: File logging is now disabled by default (`CONFIG.Logging.file = None`). Set a file path to enable file logging -- Improved default logging colors: DEBUG is now gray (`\033[90m`), INFO is now white (`\033[0m`) for better readability +- Improved default logging colors: DEBUG is now gray (`\033[90m`) for de-emphasized messages, INFO uses terminal default color (`\033[0m`) for clean output ### 🗑️ Deprecated - `change_logging_level()` function is now deprecated in favor of `CONFIG.Logging.level` and `CONFIG.apply()`. Will be removed in version 3.0.0. From a2b5d4117095abba953dc8e8b17250cb6e40be36 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 10 Oct 2025 19:57:28 +0200 Subject: [PATCH 21/23] Ensure custom formats are being applied --- flixopt/config.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/flixopt/config.py b/flixopt/config.py index 663638145..3d51cb33d 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -346,12 +346,13 @@ def to_dict(cls): class MultilineFormater(logging.Formatter): """Formatter that handles multi-line messages with consistent prefixes.""" - def __init__(self, fmt=None, datefmt=None, show_logger_name=False): - super().__init__(datefmt=datefmt) + def __init__(self, fmt: str = '%(message)s', datefmt=None, show_logger_name=False): + super().__init__(fmt=fmt, datefmt=datefmt) self.show_logger_name = show_logger_name def format(self, record): - message_lines = record.getMessage().split('\n') + record.message = record.getMessage() + message_lines = self._style.format(record).split('\n') timestamp = self.formatTime(record, self.datefmt) log_level = record.levelname.ljust(8) @@ -377,7 +378,7 @@ class ColoredMultilineFormater(MultilineFormater): RESET = '\033[0m' def __init__(self, fmt=None, datefmt=None, colors=None, show_logger_name=False): - super().__init__(datefmt=datefmt, show_logger_name=show_logger_name) + super().__init__(fmt=fmt, datefmt=datefmt, show_logger_name=show_logger_name) self.COLORS = ( colors if colors is not None From 9656066803f58519fc5853143e53302f5f8674ce Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 10 Oct 2025 19:57:58 +0200 Subject: [PATCH 22/23] Catch empty config files --- flixopt/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixopt/config.py b/flixopt/config.py index 3d51cb33d..1ea0fb691 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -287,7 +287,7 @@ def load_from_file(cls, config_file: str | Path): raise FileNotFoundError(f'Config file not found: {config_file}') with config_path.open() as file: - config_dict = yaml.safe_load(file) + config_dict = yaml.safe_load(file) or {} cls._apply_config_dict(config_dict) cls.apply() From a072d07395c0648964423a874b8be9d1a5e030c2 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 10 Oct 2025 20:38:43 +0200 Subject: [PATCH 23/23] Update test to match new defaults --- tests/test_config.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/test_config.py b/tests/test_config.py index c486d22c6..60ed80555 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -25,9 +25,9 @@ def teardown_method(self): def test_config_defaults(self): """Test that CONFIG has correct default values.""" assert CONFIG.Logging.level == 'INFO' - assert CONFIG.Logging.file == 'flixopt.log' + assert CONFIG.Logging.file is None assert CONFIG.Logging.rich is False - assert CONFIG.Logging.console is True + assert CONFIG.Logging.console is False assert CONFIG.Modeling.big == 10_000_000 assert CONFIG.Modeling.epsilon == 1e-5 assert CONFIG.Modeling.big_binary_bound == 100_000 @@ -39,9 +39,9 @@ def test_module_initialization(self): CONFIG.apply() logger = logging.getLogger('flixopt') # Should have at least one handler (file handler by default) - assert len(logger.handlers) >= 1 + assert len(logger.handlers) == 1 # Should have a file handler with default settings - assert any(isinstance(h, logging.handlers.RotatingFileHandler) for h in logger.handlers) + assert isinstance(logger.handlers[0], logging.NullHandler) def test_config_apply_console(self): """Test applying config with console logging enabled.""" @@ -102,7 +102,7 @@ def test_config_to_dict(self): assert config_dict['config_name'] == 'flixopt' assert config_dict['logging']['level'] == 'DEBUG' assert config_dict['logging']['console'] is True - assert config_dict['logging']['file'] == 'flixopt.log' + assert config_dict['logging']['file'] is None assert config_dict['logging']['rich'] is False assert 'modeling' in config_dict assert config_dict['modeling']['big'] == 10_000_000 @@ -433,9 +433,9 @@ def test_config_reset(self): # Verify all values are back to defaults assert CONFIG.Logging.level == 'INFO' - assert CONFIG.Logging.console is True + assert CONFIG.Logging.console is False assert CONFIG.Logging.rich is False - assert CONFIG.Logging.file == 'flixopt.log' + assert CONFIG.Logging.file is None assert CONFIG.Modeling.big == 10_000_000 assert CONFIG.Modeling.epsilon == 1e-5 assert CONFIG.Modeling.big_binary_bound == 100_000 @@ -444,7 +444,7 @@ def test_config_reset(self): # Verify logging was also reset logger = logging.getLogger('flixopt') assert logger.level == logging.INFO - assert any(isinstance(h, logging.handlers.RotatingFileHandler) for h in logger.handlers) + assert isinstance(logger.handlers[0], logging.NullHandler) def test_reset_matches_class_defaults(self): """Test that reset() values match the _DEFAULTS constants.