From 7f80297ff7d7a0534a83fcb782c41195e9893160 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 11 Feb 2026 14:04:46 +0100 Subject: [PATCH 01/14] Capture the solver log in an extra sub logger, which propagates by default to the regular logger. --- flixopt/config.py | 26 +++++++++- flixopt/flow_system.py | 19 +++++-- flixopt/io.py | 107 ++++++++++++++++++++++++++++++++++++++++ flixopt/optimization.py | 22 ++++++--- 4 files changed, 161 insertions(+), 13 deletions(-) diff --git a/flixopt/config.py b/flixopt/config.py index 32724b4ae..738d5607f 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -173,6 +173,7 @@ def format(self, record): 'log_to_console': True, 'log_main_results': True, 'compute_infeasibilities': True, + 'capture_solver_log': False, } ), } @@ -529,6 +530,13 @@ class Solving: log_to_console: Whether solver should output to console. log_main_results: Whether to log main results after solving. compute_infeasibilities: Whether to compute infeasibility analysis when the model is infeasible. + capture_solver_log: Whether to route solver output through the + ``flixopt.solver`` Python logger instead of printing directly + to the console. When enabled, the solver's native console + output is disabled and each log line is forwarded at INFO level + to ``logging.getLogger('flixopt.solver')``. This allows + capturing solver output in Python log handlers (console, file, + or both) without double-logging. Examples: ```python @@ -536,6 +544,11 @@ class Solving: CONFIG.Solving.mip_gap = 0.001 CONFIG.Solving.time_limit_seconds = 600 CONFIG.Solving.log_to_console = False + + # Route solver output through Python logging + CONFIG.Solving.capture_solver_log = True + CONFIG.Logging.enable_console('INFO') + # Solver output now appears via the 'flixopt.solver' logger ``` """ @@ -544,6 +557,7 @@ class Solving: log_to_console: bool = _DEFAULTS['solving']['log_to_console'] log_main_results: bool = _DEFAULTS['solving']['log_main_results'] compute_infeasibilities: bool = _DEFAULTS['solving']['compute_infeasibilities'] + capture_solver_log: bool = _DEFAULTS['solving']['capture_solver_log'] class Plotting: """Plotting configuration. @@ -668,6 +682,7 @@ def to_dict(cls) -> dict: 'log_to_console': cls.Solving.log_to_console, 'log_main_results': cls.Solving.log_main_results, 'compute_infeasibilities': cls.Solving.compute_infeasibilities, + 'capture_solver_log': cls.Solving.capture_solver_log, }, 'plotting': { 'default_show': cls.Plotting.default_show, @@ -698,13 +713,15 @@ def silent(cls) -> type[CONFIG]: cls.Plotting.default_show = False cls.Solving.log_to_console = False cls.Solving.log_main_results = False + cls.Solving.capture_solver_log = False return cls @classmethod def debug(cls) -> type[CONFIG]: """Configure for debug mode with verbose output. - Enables console logging at DEBUG level and all solver output for troubleshooting. + Enables console logging at DEBUG level and routes solver output through + the ``flixopt.solver`` Python logger for full capture. Examples: ```python @@ -716,13 +733,15 @@ def debug(cls) -> type[CONFIG]: cls.Logging.enable_console('DEBUG') cls.Solving.log_to_console = True cls.Solving.log_main_results = True + cls.Solving.capture_solver_log = True return cls @classmethod def exploring(cls) -> type[CONFIG]: """Configure for exploring flixopt. - Enables console logging at INFO level and all solver output. + Enables console logging at INFO level and routes solver output through + the ``flixopt.solver`` Python logger. Also enables browser plotting for plotly with showing plots per default. Examples: @@ -736,6 +755,7 @@ def exploring(cls) -> type[CONFIG]: cls.Logging.enable_console('INFO') cls.Solving.log_to_console = True cls.Solving.log_main_results = True + cls.Solving.capture_solver_log = True cls.browser_plotting() return cls @@ -761,6 +781,7 @@ def production(cls, log_file: str | Path = 'flixopt.log') -> type[CONFIG]: cls.Plotting.default_show = False cls.Solving.log_to_console = False cls.Solving.log_main_results = False + cls.Solving.capture_solver_log = True return cls @classmethod @@ -865,6 +886,7 @@ def notebook(cls) -> type[CONFIG]: # Disable verbose solver output for cleaner notebook cells cls.Solving.log_to_console = False cls.Solving.log_main_results = False + cls.Solving.capture_solver_log = True return cls diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index c61a15b70..56994b8cd 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -1439,11 +1439,20 @@ def solve(self, solver: _Solver) -> FlowSystem: if self.model is None: raise RuntimeError('Model has not been built. Call build_model() first.') - self.model.solve( - solver_name=solver.name, - progress=CONFIG.Solving.log_to_console, - **solver.options, - ) + if CONFIG.Solving.capture_solver_log: + with fx_io.stream_solver_log(solver.options) as (log_path, options): + self.model.solve( + log_fn=log_path, + solver_name=solver.name, + progress=CONFIG.Solving.log_to_console, + **options, + ) + else: + self.model.solve( + solver_name=solver.name, + progress=CONFIG.Solving.log_to_console, + **solver.options, + ) if self.model.termination_condition in ('infeasible', 'infeasible_or_unbounded'): if CONFIG.Solving.compute_infeasibilities: diff --git a/flixopt/io.py b/flixopt/io.py index fb20a4d5c..38760c929 100644 --- a/flixopt/io.py +++ b/flixopt/io.py @@ -7,6 +7,9 @@ import pathlib import re import sys +import tempfile +import threading +import time import warnings from collections import defaultdict from contextlib import contextmanager @@ -1507,6 +1510,110 @@ def suppress_output(): pass # FD already closed or invalid +def _disable_solver_console(solver_options: dict[str, Any]) -> dict[str, Any]: + """Return a copy of solver options with console logging disabled. + + Handles solver-specific option names (HiGHS: ``log_to_console``, + Gurobi: ``LogToConsole``). + """ + result = dict(solver_options) + if 'log_to_console' in result: + result['log_to_console'] = False + if 'LogToConsole' in result: + result['LogToConsole'] = 0 + return result + + +@contextmanager +def stream_solver_log( + solver_options: dict[str, Any], + log_fn: pathlib.Path | None = None, +): + """Stream solver log file contents to the ``flixopt.solver`` Python logger. + + Redirects solver output from console to a log file, then tails that file in + a background thread, forwarding each line to + ``logging.getLogger('flixopt.solver')`` at INFO level. + + This avoids double-logging: the solver's own console output is disabled, and + the Python logger controls all output routing (console, file, both, neither). + + Args: + solver_options: Solver options dict (from ``solver.options``). A copy is + made with console logging disabled. + log_fn: Path to the solver log file. If *None*, a temporary file is + created and deleted after the context exits. If a path is provided, + the file is kept (useful when the caller wants a persistent solver + log alongside the Python logger stream). + + Yields: + A ``(log_path, modified_options)`` tuple. Pass ``log_path`` as + ``log_fn`` and unpack ``modified_options`` as ``**kwargs`` to + ``linopy.Model.solve``. + + Warning: + Not thread-safe. Use only with sequential execution. + """ + solver_logger = logging.getLogger('flixopt.solver') + + # Disable solver console output — the logger handles routing + modified_options = _disable_solver_console(solver_options) + + # Resolve log file path + cleanup = log_fn is None + if cleanup: + fd, tmp_path = tempfile.mkstemp(suffix='.log', prefix='flixopt_solver_') + os.close(fd) + log_path = pathlib.Path(tmp_path) + else: + log_path = pathlib.Path(log_fn) + log_path.parent.mkdir(parents=True, exist_ok=True) + + stop_event = threading.Event() + + def _tail() -> None: + """Read lines from the log file and forward to the solver logger.""" + # Wait for the file to appear (linopy creates it) + while not log_path.exists() and not stop_event.is_set(): + time.sleep(0.01) + + if not log_path.exists(): + return + + with open(log_path) as f: + while not stop_event.is_set(): + line = f.readline() + if line: + stripped = line.rstrip('\n\r') + if stripped: + solver_logger.info(stripped) + else: + time.sleep(0.05) + + # Drain remaining lines after solve completes + for line in f: + stripped = line.rstrip('\n\r') + if stripped: + solver_logger.info(stripped) + + thread = threading.Thread(target=_tail, daemon=True) + thread.start() + + try: + yield log_path, modified_options + finally: + # Give the tail thread a moment to catch the last writes + time.sleep(0.1) + stop_event.set() + thread.join(timeout=5) + + if cleanup: + try: + log_path.unlink(missing_ok=True) + except OSError: + pass + + # ============================================================================ # FlowSystem Dataset I/O # ============================================================================ diff --git a/flixopt/optimization.py b/flixopt/optimization.py index 21a4ebd87..9cbe80cc6 100644 --- a/flixopt/optimization.py +++ b/flixopt/optimization.py @@ -243,12 +243,22 @@ def solve( t_start = timeit.default_timer() - self.model.solve( - log_fn=pathlib.Path(log_file) if log_file is not None else self.folder / f'{self.name}.log', - solver_name=solver.name, - progress=CONFIG.Solving.log_to_console, - **solver.options, - ) + log_fn = pathlib.Path(log_file) if log_file is not None else self.folder / f'{self.name}.log' + if CONFIG.Solving.capture_solver_log: + with fx_io.stream_solver_log(solver.options, log_fn=log_fn) as (log_path, options): + self.model.solve( + log_fn=log_path, + solver_name=solver.name, + progress=CONFIG.Solving.log_to_console, + **options, + ) + else: + self.model.solve( + log_fn=log_fn, + solver_name=solver.name, + progress=CONFIG.Solving.log_to_console, + **solver.options, + ) self.durations['solving'] = round(timeit.default_timer() - t_start, 2) logger.log(SUCCESS_LEVEL, f'Model solved with {solver.name} in {self.durations["solving"]:.2f} seconds.') logger.info(f'Model status after solve: {self.model.status}') From 5832cefff3b56f585bd19e4ef220ed4e449a42a2 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 11 Feb 2026 14:14:44 +0100 Subject: [PATCH 02/14] What's implemented MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - stream_solver_log() in io.py: tails a log file in a background thread, forwards lines to logging.getLogger('flixopt.solver') at INFO level - _disable_solver_console(): overrides solver console options (log_to_console/LogToConsole) - capture_solver_log config setting (default False, enabled by presets) - progress=False when capturing — eliminates tqdm progress bar noise Known limitation (documented in docstring) Gurobi may print a few lines (license banner, LP reading, parameter setting) directly to stdout before LogToConsole=0 takes effect. This is a Gurobi/linopy limitation. HiGHS handles it cleanly. --- flixopt/flow_system.py | 2 +- flixopt/io.py | 10 ++++++++-- flixopt/optimization.py | 2 +- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 56994b8cd..cac689dbc 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -1444,7 +1444,7 @@ def solve(self, solver: _Solver) -> FlowSystem: self.model.solve( log_fn=log_path, solver_name=solver.name, - progress=CONFIG.Solving.log_to_console, + progress=False, **options, ) else: diff --git a/flixopt/io.py b/flixopt/io.py index 38760c929..07002e427 100644 --- a/flixopt/io.py +++ b/flixopt/io.py @@ -1535,8 +1535,14 @@ def stream_solver_log( a background thread, forwarding each line to ``logging.getLogger('flixopt.solver')`` at INFO level. - This avoids double-logging: the solver's own console output is disabled, and - the Python logger controls all output routing (console, file, both, neither). + The solver's native console output is disabled via solver options, and the + Python logger controls all output routing (console, file, both, neither). + + Note: + Some solvers (e.g. Gurobi) may print a small amount of output (license + banner, LP reading) directly to stdout before the ``LogToConsole`` + option takes effect. This is a solver/linopy limitation and does not + go through the Python logger. Args: solver_options: Solver options dict (from ``solver.options``). A copy is diff --git a/flixopt/optimization.py b/flixopt/optimization.py index 9cbe80cc6..4b659a665 100644 --- a/flixopt/optimization.py +++ b/flixopt/optimization.py @@ -249,7 +249,7 @@ def solve( self.model.solve( log_fn=log_path, solver_name=solver.name, - progress=CONFIG.Solving.log_to_console, + progress=False, **options, ) else: From eb643fe4a9f05e3a610d40996def19a205e15565 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 11 Feb 2026 16:17:28 +0100 Subject: [PATCH 03/14] Improve setting log to console False --- flixopt/flow_system.py | 5 +++-- flixopt/io.py | 42 +++++++++-------------------------------- flixopt/optimization.py | 5 +++-- 3 files changed, 15 insertions(+), 37 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index cac689dbc..e3a22cb74 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -1440,12 +1440,13 @@ def solve(self, solver: _Solver) -> FlowSystem: raise RuntimeError('Model has not been built. Call build_model() first.') if CONFIG.Solving.capture_solver_log: - with fx_io.stream_solver_log(solver.options) as (log_path, options): + solver.log_to_console = False + with fx_io.stream_solver_log() as log_path: self.model.solve( log_fn=log_path, solver_name=solver.name, progress=False, - **options, + **solver.options, ) else: self.model.solve( diff --git a/flixopt/io.py b/flixopt/io.py index 07002e427..3abcb7f8d 100644 --- a/flixopt/io.py +++ b/flixopt/io.py @@ -1510,51 +1510,30 @@ def suppress_output(): pass # FD already closed or invalid -def _disable_solver_console(solver_options: dict[str, Any]) -> dict[str, Any]: - """Return a copy of solver options with console logging disabled. - - Handles solver-specific option names (HiGHS: ``log_to_console``, - Gurobi: ``LogToConsole``). - """ - result = dict(solver_options) - if 'log_to_console' in result: - result['log_to_console'] = False - if 'LogToConsole' in result: - result['LogToConsole'] = 0 - return result - - @contextmanager -def stream_solver_log( - solver_options: dict[str, Any], - log_fn: pathlib.Path | None = None, -): +def stream_solver_log(log_fn: pathlib.Path | None = None): """Stream solver log file contents to the ``flixopt.solver`` Python logger. - Redirects solver output from console to a log file, then tails that file in - a background thread, forwarding each line to + Tails a solver log file in a background thread, forwarding each line to ``logging.getLogger('flixopt.solver')`` at INFO level. - The solver's native console output is disabled via solver options, and the - Python logger controls all output routing (console, file, both, neither). + Use together with ``solver.options_for_log_capture`` to disable the + solver's native console output and route everything through the Python + logger instead. Note: Some solvers (e.g. Gurobi) may print a small amount of output (license - banner, LP reading) directly to stdout before the ``LogToConsole`` - option takes effect. This is a solver/linopy limitation and does not - go through the Python logger. + banner, LP reading) directly to stdout before their console-log option + takes effect. This is a solver/linopy limitation. Args: - solver_options: Solver options dict (from ``solver.options``). A copy is - made with console logging disabled. log_fn: Path to the solver log file. If *None*, a temporary file is created and deleted after the context exits. If a path is provided, the file is kept (useful when the caller wants a persistent solver log alongside the Python logger stream). Yields: - A ``(log_path, modified_options)`` tuple. Pass ``log_path`` as - ``log_fn`` and unpack ``modified_options`` as ``**kwargs`` to + Path to the log file. Pass it as ``log_fn`` to ``linopy.Model.solve``. Warning: @@ -1562,9 +1541,6 @@ def stream_solver_log( """ solver_logger = logging.getLogger('flixopt.solver') - # Disable solver console output — the logger handles routing - modified_options = _disable_solver_console(solver_options) - # Resolve log file path cleanup = log_fn is None if cleanup: @@ -1606,7 +1582,7 @@ def _tail() -> None: thread.start() try: - yield log_path, modified_options + yield log_path finally: # Give the tail thread a moment to catch the last writes time.sleep(0.1) diff --git a/flixopt/optimization.py b/flixopt/optimization.py index 4b659a665..779cc8421 100644 --- a/flixopt/optimization.py +++ b/flixopt/optimization.py @@ -245,12 +245,13 @@ def solve( log_fn = pathlib.Path(log_file) if log_file is not None else self.folder / f'{self.name}.log' if CONFIG.Solving.capture_solver_log: - with fx_io.stream_solver_log(solver.options, log_fn=log_fn) as (log_path, options): + solver.log_to_console = False + with fx_io.stream_solver_log(log_fn=log_fn) as log_path: self.model.solve( log_fn=log_path, solver_name=solver.name, progress=False, - **options, + **solver.options, ) else: self.model.solve( From ac00afd50585f4ef587d91aa148b3cbb7049bc98 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 11 Feb 2026 16:24:59 +0100 Subject: [PATCH 04/14] Dont mutate when capturing, instead do depending on config.py --- flixopt/config.py | 4 ++-- flixopt/flow_system.py | 1 - flixopt/optimization.py | 1 - 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/flixopt/config.py b/flixopt/config.py index 738d5607f..68ce40357 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -731,7 +731,7 @@ def debug(cls) -> type[CONFIG]: ``` """ cls.Logging.enable_console('DEBUG') - cls.Solving.log_to_console = True + cls.Solving.log_to_console = False cls.Solving.log_main_results = True cls.Solving.capture_solver_log = True return cls @@ -753,7 +753,7 @@ def exploring(cls) -> type[CONFIG]: ``` """ cls.Logging.enable_console('INFO') - cls.Solving.log_to_console = True + cls.Solving.log_to_console = False cls.Solving.log_main_results = True cls.Solving.capture_solver_log = True cls.browser_plotting() diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index e3a22cb74..2eb43557d 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -1440,7 +1440,6 @@ def solve(self, solver: _Solver) -> FlowSystem: raise RuntimeError('Model has not been built. Call build_model() first.') if CONFIG.Solving.capture_solver_log: - solver.log_to_console = False with fx_io.stream_solver_log() as log_path: self.model.solve( log_fn=log_path, diff --git a/flixopt/optimization.py b/flixopt/optimization.py index 779cc8421..4f5da92fa 100644 --- a/flixopt/optimization.py +++ b/flixopt/optimization.py @@ -245,7 +245,6 @@ def solve( log_fn = pathlib.Path(log_file) if log_file is not None else self.folder / f'{self.name}.log' if CONFIG.Solving.capture_solver_log: - solver.log_to_console = False with fx_io.stream_solver_log(log_fn=log_fn) as log_path: self.model.solve( log_fn=log_path, From 00b10bee7914ef4ef2018d1893afad22bf80627f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 11 Feb 2026 16:25:34 +0100 Subject: [PATCH 05/14] Update CHANGELOG.md --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 02cea1cb8..dfbbeae4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,10 @@ Until here --> **Summary**: Bugfix release fixing `cluster_weight` loss during NetCDF roundtrip for manually constructed clustered FlowSystems. +### ✨ Added + +- **Solver log capture**: New `CONFIG.Solving.capture_solver_log` option routes solver output (HiGHS, Gurobi, etc.) through the `flixopt.solver` Python logger at INFO level instead of printing directly to the console. This allows capturing solver output in any Python log handler (console, file, or both) and filtering it independently from flixopt application logs. Enabled automatically by `CONFIG.debug()`, `CONFIG.exploring()`, `CONFIG.production()`, and `CONFIG.notebook()` presets. ([#606](https://github.com/flixOpt/flixopt/pull/606)) + ### 🐛 Fixed - **Clustering IO**: `cluster_weight` is now preserved during NetCDF roundtrip for manually constructed clustered FlowSystems (i.e. `FlowSystem(..., clusters=..., cluster_weight=...)`). Previously, `cluster_weight` was silently dropped to `None` during `save->reload->solve`, causing incorrect objective values. Systems created via `.transform.cluster()` were not affected. From 624a06eb5f3ed63f6b51673f9d880f6f96875ff3 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 11 Feb 2026 16:29:27 +0100 Subject: [PATCH 06/14] Revert test changes --- flixopt/config.py | 4 ++-- tests/utilities/test_config.py | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/flixopt/config.py b/flixopt/config.py index 68ce40357..738d5607f 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -731,7 +731,7 @@ def debug(cls) -> type[CONFIG]: ``` """ cls.Logging.enable_console('DEBUG') - cls.Solving.log_to_console = False + cls.Solving.log_to_console = True cls.Solving.log_main_results = True cls.Solving.capture_solver_log = True return cls @@ -753,7 +753,7 @@ def exploring(cls) -> type[CONFIG]: ``` """ cls.Logging.enable_console('INFO') - cls.Solving.log_to_console = False + cls.Solving.log_to_console = True cls.Solving.log_main_results = True cls.Solving.capture_solver_log = True cls.browser_plotting() diff --git a/tests/utilities/test_config.py b/tests/utilities/test_config.py index 94d626af2..e486ccd15 100644 --- a/tests/utilities/test_config.py +++ b/tests/utilities/test_config.py @@ -164,6 +164,7 @@ def test_preset_exploring(self, capfd): logger.info('exploring') assert 'exploring' in capfd.readouterr().out assert CONFIG.Solving.log_to_console is True + assert CONFIG.Solving.capture_solver_log is True def test_preset_debug(self, capfd): """Test debug preset.""" From b514c2217340ed7d2526b784b2667958483339c7 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 11 Feb 2026 16:33:20 +0100 Subject: [PATCH 07/14] Updated the docstring for capture_solver_log in config.py to: - Explain the setting is independent of log_to_console - Document the double-logging scenario clearly (capture + native console + console logger = double output) - Provide three example configurations showing how to avoid it: - File capture only: capture=True, log_to_console=False, enable_file() - Logger to console: capture=True, log_to_console=False, enable_console() - Native console only: capture=False, log_to_console=True MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The user is in full control — if they enable both, they get both. The docs make it obvious how to avoid double logging. --- flixopt/config.py | 35 +++++++++++++++++++++++------------ 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/flixopt/config.py b/flixopt/config.py index 738d5607f..290308703 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -531,24 +531,35 @@ class Solving: log_main_results: Whether to log main results after solving. compute_infeasibilities: Whether to compute infeasibility analysis when the model is infeasible. capture_solver_log: Whether to route solver output through the - ``flixopt.solver`` Python logger instead of printing directly - to the console. When enabled, the solver's native console - output is disabled and each log line is forwarded at INFO level - to ``logging.getLogger('flixopt.solver')``. This allows - capturing solver output in Python log handlers (console, file, - or both) without double-logging. + ``flixopt.solver`` Python logger. When enabled, each solver + log line is forwarded at INFO level to + ``logging.getLogger('flixopt.solver')``. This setting is + independent of ``log_to_console`` — both can be active at the + same time. + + .. note:: + If ``capture_solver_log`` is ``True`` **and** + ``log_to_console`` is ``True`` **and** the ``flixopt`` + logger has a console handler, solver output will appear + on the console twice (once natively, once via the logger). + To avoid this, set ``log_to_console = False`` when + capturing to a console logger. Examples: ```python - # Set tighter convergence and longer timeout - CONFIG.Solving.mip_gap = 0.001 - CONFIG.Solving.time_limit_seconds = 600 - CONFIG.Solving.log_to_console = False + # Capture solver output to file only (no double console logging) + CONFIG.Solving.capture_solver_log = True + CONFIG.Solving.log_to_console = False # avoid double console output + CONFIG.Logging.enable_file('INFO', 'flixopt.log') - # Route solver output through Python logging + # Capture through logger to console (disable native solver console) CONFIG.Solving.capture_solver_log = True + CONFIG.Solving.log_to_console = False CONFIG.Logging.enable_console('INFO') - # Solver output now appears via the 'flixopt.solver' logger + + # Native solver console only (no Python logger capture) + CONFIG.Solving.capture_solver_log = False + CONFIG.Solving.log_to_console = True ``` """ From b417157bebf37bcf37cf5f124665a370271a3dd9 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 11 Feb 2026 16:43:36 +0100 Subject: [PATCH 08/14] =?UTF-8?q?=20Added=20a=20truncation=20at=20line=201?= =?UTF-8?q?553=20=E2=80=94=20when=20the=20user=20provides=20a=20log=5Ffn?= =?UTF-8?q?=20that=20already=20exists,=20it's=20emptied=20before=20the=20t?= =?UTF-8?q?ail=20thread=20starts.=20The=20cleanup=3DTrue=20(temp=20file)?= =?UTF-8?q?=20path=20is=20=20=20unchanged=20since=20mkstemp=20already=20cr?= =?UTF-8?q?eates=20a=20fresh=20empty=20file.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- flixopt/io.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/flixopt/io.py b/flixopt/io.py index 3abcb7f8d..d17a9147a 100644 --- a/flixopt/io.py +++ b/flixopt/io.py @@ -1550,6 +1550,9 @@ def stream_solver_log(log_fn: pathlib.Path | None = None): else: log_path = pathlib.Path(log_fn) log_path.parent.mkdir(parents=True, exist_ok=True) + # Truncate existing file so the tail thread only streams new output + if log_path.exists(): + log_path.write_text('') stop_event = threading.Event() From 01c38a2a44714035998f5a9ab72112c8d2903c1e Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 11 Feb 2026 16:45:42 +0100 Subject: [PATCH 09/14] Imrpove defaults in configs --- flixopt/config.py | 4 ++-- tests/utilities/test_config.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/flixopt/config.py b/flixopt/config.py index 290308703..7c7d0acd5 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -742,7 +742,7 @@ def debug(cls) -> type[CONFIG]: ``` """ cls.Logging.enable_console('DEBUG') - cls.Solving.log_to_console = True + cls.Solving.log_to_console = False cls.Solving.log_main_results = True cls.Solving.capture_solver_log = True return cls @@ -764,7 +764,7 @@ def exploring(cls) -> type[CONFIG]: ``` """ cls.Logging.enable_console('INFO') - cls.Solving.log_to_console = True + cls.Solving.log_to_console = False cls.Solving.log_main_results = True cls.Solving.capture_solver_log = True cls.browser_plotting() diff --git a/tests/utilities/test_config.py b/tests/utilities/test_config.py index e486ccd15..a55a38d2f 100644 --- a/tests/utilities/test_config.py +++ b/tests/utilities/test_config.py @@ -163,7 +163,7 @@ def test_preset_exploring(self, capfd): CONFIG.exploring() logger.info('exploring') assert 'exploring' in capfd.readouterr().out - assert CONFIG.Solving.log_to_console is True + assert CONFIG.Solving.log_to_console is False assert CONFIG.Solving.capture_solver_log is True def test_preset_debug(self, capfd): From c11ff7c909e97394dc3d38b42222310ce9382cba Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 11 Feb 2026 16:47:27 +0100 Subject: [PATCH 10/14] =?UTF-8?q?-=20New=20log=5Ffn=20parameter=20(pathlib?= =?UTF-8?q?.Path=20|=20str=20|=20None,=20default=20None)=20=E2=80=94=20let?= =?UTF-8?q?s=20users=20persist=20the=20solver=20log=20to=20a=20specific=20?= =?UTF-8?q?file?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- flixopt/flow_system.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 2eb43557d..1df5c6f23 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -1412,7 +1412,7 @@ def build_model(self, normalize_weights: bool | None = None) -> FlowSystem: return self - def solve(self, solver: _Solver) -> FlowSystem: + def solve(self, solver: _Solver, log_fn: pathlib.Path | str | None = None) -> FlowSystem: """ Solve the optimization model and populate the solution. @@ -1423,6 +1423,10 @@ def solve(self, solver: _Solver) -> FlowSystem: Args: solver: The solver to use (e.g., HighsSolver, GurobiSolver). + log_fn: Path to write the solver log file. If *None* and + ``capture_solver_log`` is enabled, a temporary file is used + (deleted after streaming). If a path is provided, the solver + log is persisted there regardless of capture settings. Returns: Self, for method chaining. @@ -1439,16 +1443,18 @@ def solve(self, solver: _Solver) -> FlowSystem: if self.model is None: raise RuntimeError('Model has not been built. Call build_model() first.') + log_path = pathlib.Path(log_fn) if log_fn is not None else None if CONFIG.Solving.capture_solver_log: - with fx_io.stream_solver_log() as log_path: + with fx_io.stream_solver_log(log_fn=log_path) as captured_path: self.model.solve( - log_fn=log_path, + log_fn=captured_path, solver_name=solver.name, progress=False, **solver.options, ) else: self.model.solve( + **({'log_fn': log_path} if log_path is not None else {}), solver_name=solver.name, progress=CONFIG.Solving.log_to_console, **solver.options, From 81068ba802b6b03b1c39a9175857ef6892bc6375 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 11 Feb 2026 16:52:15 +0100 Subject: [PATCH 11/14] Fix test --- tests/deprecated/test_config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/deprecated/test_config.py b/tests/deprecated/test_config.py index 94d626af2..04ed04e25 100644 --- a/tests/deprecated/test_config.py +++ b/tests/deprecated/test_config.py @@ -163,7 +163,7 @@ def test_preset_exploring(self, capfd): CONFIG.exploring() logger.info('exploring') assert 'exploring' in capfd.readouterr().out - assert CONFIG.Solving.log_to_console is True + assert CONFIG.Solving.log_to_console is False def test_preset_debug(self, capfd): """Test debug preset.""" From 1f083e498b9d3351bc2eee77098c0f039f33cb33 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 11 Feb 2026 19:33:52 +0100 Subject: [PATCH 12/14] Improve docs --- docs/user-guide/optimization/index.md | 50 +++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/docs/user-guide/optimization/index.md b/docs/user-guide/optimization/index.md index 5c8b0e3f1..12571ad67 100644 --- a/docs/user-guide/optimization/index.md +++ b/docs/user-guide/optimization/index.md @@ -282,6 +282,56 @@ Common solver parameters: - `mip_gap` - Acceptable optimality gap (0.01 = 1%) - `log_to_console` - Show solver output +## Logging & Solver Output + +By default, solvers print directly to the console. You can route this output +through Python's logging system using `capture_solver_log`, which forwards each +line to the `flixopt.solver` logger at INFO level. + +### Quick Setup with Presets + +```python +from flixopt import CONFIG + +CONFIG.exploring() # Console logging + solver capture (recommended for interactive use) +CONFIG.debug() # Verbose DEBUG logging + solver capture +CONFIG.production('flixopt.log') # File logging + solver capture, no console +``` + +### Manual Configuration + +`capture_solver_log` and `log_to_console` are independent settings: + +```python +# Route solver output through logger to console +CONFIG.Solving.capture_solver_log = True +CONFIG.Solving.log_to_console = False +CONFIG.Logging.enable_console('INFO') + +# Route solver output through logger to file +CONFIG.Solving.capture_solver_log = True +CONFIG.Solving.log_to_console = False +CONFIG.Logging.enable_file('INFO', 'flixopt.log') + +# Native solver console only (no Python logger) +CONFIG.Solving.capture_solver_log = False +CONFIG.Solving.log_to_console = True +``` + +!!! warning "Avoiding double console output" + If `capture_solver_log` and `log_to_console` are both `True` **and** the + `flixopt` logger has a console handler, solver output appears twice. Set + `log_to_console = False` when capturing to a console logger. + +### Persistent Solver Log File + +Pass `log_fn` to `solve()` to keep the raw solver log on disk: + +```python +flow_system.build_model() +flow_system.solve(fx.solvers.HighsSolver(), log_fn='solver.log') +``` + ## Performance Tips ### Model Size Reduction From 10538f42c14f1ded0995bfb7ac96949144d08579 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 11 Feb 2026 21:42:25 +0100 Subject: [PATCH 13/14] Add progress parameter to solve/optimize for linopy pass through --- flixopt/flow_system.py | 7 ++++--- flixopt/optimize_accessor.py | 8 +++++--- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 1df5c6f23..e838c5480 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -1412,7 +1412,7 @@ def build_model(self, normalize_weights: bool | None = None) -> FlowSystem: return self - def solve(self, solver: _Solver, log_fn: pathlib.Path | str | None = None) -> FlowSystem: + def solve(self, solver: _Solver, log_fn: pathlib.Path | str | None = None, progress: bool = True) -> FlowSystem: """ Solve the optimization model and populate the solution. @@ -1427,6 +1427,7 @@ def solve(self, solver: _Solver, log_fn: pathlib.Path | str | None = None) -> Fl ``capture_solver_log`` is enabled, a temporary file is used (deleted after streaming). If a path is provided, the solver log is persisted there regardless of capture settings. + progress: Whether to show a tqdm progress bar during solving. Returns: Self, for method chaining. @@ -1449,14 +1450,14 @@ def solve(self, solver: _Solver, log_fn: pathlib.Path | str | None = None) -> Fl self.model.solve( log_fn=captured_path, solver_name=solver.name, - progress=False, + progress=progress, **solver.options, ) else: self.model.solve( **({'log_fn': log_path} if log_path is not None else {}), solver_name=solver.name, - progress=CONFIG.Solving.log_to_console, + progress=progress, **solver.options, ) diff --git a/flixopt/optimize_accessor.py b/flixopt/optimize_accessor.py index d223da6ad..ecddd955b 100644 --- a/flixopt/optimize_accessor.py +++ b/flixopt/optimize_accessor.py @@ -59,6 +59,7 @@ def __call__( self, solver: _Solver, before_solve: Callable[[FlowSystem], None] | None = None, + progress: bool = True, normalize_weights: bool | None = None, ) -> FlowSystem: """ @@ -73,6 +74,7 @@ def __call__( before_solve: Optional callback function that receives the FlowSystem after building the model and before solving. Use this to add custom constraints via `flow_system.model.add_constraints()`. + progress: Whether to show a tqdm progress bar during solving. normalize_weights: Deprecated. Scenario weights are now always normalized in FlowSystem. Returns: @@ -122,7 +124,7 @@ def __call__( self._fs.build_model() if before_solve is not None: before_solve(self._fs) - self._fs.solve(solver) + self._fs.solve(solver, progress=progress) return self._fs def rolling_horizon( @@ -260,7 +262,7 @@ def rolling_horizon( self._check_no_investments(segment_fs) if before_solve is not None: before_solve(segment_fs) - segment_fs.solve(solver) + segment_fs.solve(solver, progress=False) finally: logger.setLevel(original_level) else: @@ -277,7 +279,7 @@ def rolling_horizon( self._check_no_investments(segment_fs) if before_solve is not None: before_solve(segment_fs) - segment_fs.solve(solver) + segment_fs.solve(solver, progress=False) segment_flow_systems.append(segment_fs) From 580ea904e169e76841ad79dc0b2345f842b24aea Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 11 Feb 2026 21:47:54 +0100 Subject: [PATCH 14/14] Add progress and make CONFIG.Solving.log_to_console purely about the solver log itself --- CHANGELOG.md | 13 ++++-- flixopt/optimize_accessor.py | 86 ++++++++++++++++++++++-------------- 2 files changed, 63 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dfbbeae4f..8a840fbba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,13 +52,20 @@ If upgrading from v2.x, see the [v3.0.0 release notes](https://github.com/flixOp Until here --> -## [6.0.3] - Upcoming +## [6.1.0] - Upcoming -**Summary**: Bugfix release fixing `cluster_weight` loss during NetCDF roundtrip for manually constructed clustered FlowSystems. +**Summary**: Adds solver log capture through the Python logging system, exposes `progress` and `log_fn` parameters on solve/optimize, and fixes `cluster_weight` loss during NetCDF roundtrip. ### ✨ Added -- **Solver log capture**: New `CONFIG.Solving.capture_solver_log` option routes solver output (HiGHS, Gurobi, etc.) through the `flixopt.solver` Python logger at INFO level instead of printing directly to the console. This allows capturing solver output in any Python log handler (console, file, or both) and filtering it independently from flixopt application logs. Enabled automatically by `CONFIG.debug()`, `CONFIG.exploring()`, `CONFIG.production()`, and `CONFIG.notebook()` presets. ([#606](https://github.com/flixOpt/flixopt/pull/606)) +- **Solver log capture**: New `CONFIG.Solving.capture_solver_log` option routes solver output (HiGHS, Gurobi, etc.) through the `flixopt.solver` Python logger at INFO level. This allows capturing solver output in any Python log handler (console, file, or both) and filtering it independently from flixopt application logs. Enabled automatically by `CONFIG.debug()`, `CONFIG.exploring()`, `CONFIG.production()`, and `CONFIG.notebook()` presets. ([#606](https://github.com/flixOpt/flixopt/pull/606)) +- **`progress` parameter**: `solve()`, `optimize()`, and `rolling_horizon()` now accept a `progress` parameter (default `True`) to control the tqdm progress bar independently of CONFIG settings. +- **`log_fn` parameter**: `solve()` now accepts a `log_fn` parameter to persist the solver log to a file. + +### ♻️ Changed + +- **Presets**: `CONFIG.debug()` and `CONFIG.exploring()` now set `log_to_console=False` (solver output is routed through the Python logger instead of native console output). +- **`CONFIG.Solving.log_to_console`** now exclusively controls the solver's native console output. It no longer affects the tqdm progress bar (use the `progress` parameter instead). ### 🐛 Fixed diff --git a/flixopt/optimize_accessor.py b/flixopt/optimize_accessor.py index ecddd955b..c87f1e713 100644 --- a/flixopt/optimize_accessor.py +++ b/flixopt/optimize_accessor.py @@ -14,7 +14,6 @@ import xarray as xr from tqdm import tqdm -from .config import CONFIG from .io import suppress_output if TYPE_CHECKING: @@ -134,6 +133,7 @@ def rolling_horizon( overlap: int = 0, nr_of_previous_values: int = 1, before_solve: Callable[[FlowSystem], None] | None = None, + progress: bool = True, ) -> list[FlowSystem]: """ Solve the optimization using a rolling horizon approach. @@ -161,6 +161,7 @@ def rolling_horizon( before_solve: Optional callback function that receives each segment's FlowSystem after building the model and before solving. Use this to add custom constraints to each segment. + progress: Whether to show a tqdm progress bar for segment solving. Returns: List of segment FlowSystems, each with their individual solution. @@ -235,51 +236,42 @@ def rolling_horizon( desc='Solving segments', unit='segment', file=sys.stdout, - disable=not CONFIG.Solving.log_to_console, + disable=not progress, ) try: for i, (start_idx, end_idx) in progress_bar: progress_bar.set_description(f'Segment {i + 1}/{n_segments} (timesteps {start_idx}-{end_idx})') - # Suppress output when progress bar is shown (including logger and solver) - if CONFIG.Solving.log_to_console: - # Temporarily raise logger level to suppress INFO messages + # Suppress per-segment output when progress bar is shown + if progress: original_level = logger.level logger.setLevel(logging.WARNING) try: with suppress_output(): - segment_fs = self._fs.transform.isel(time=slice(start_idx, end_idx)) - if i > 0 and nr_of_previous_values > 0: - self._transfer_state( - source_fs=segment_flow_systems[i - 1], - target_fs=segment_fs, - horizon=horizon, - nr_of_previous_values=nr_of_previous_values, - ) - segment_fs.build_model() - if i == 0: - self._check_no_investments(segment_fs) - if before_solve is not None: - before_solve(segment_fs) - segment_fs.solve(solver, progress=False) + segment_fs = self._solve_segment( + solver, + start_idx, + end_idx, + i, + segment_flow_systems, + horizon, + nr_of_previous_values, + before_solve, + ) finally: logger.setLevel(original_level) else: - segment_fs = self._fs.transform.isel(time=slice(start_idx, end_idx)) - if i > 0 and nr_of_previous_values > 0: - self._transfer_state( - source_fs=segment_flow_systems[i - 1], - target_fs=segment_fs, - horizon=horizon, - nr_of_previous_values=nr_of_previous_values, - ) - segment_fs.build_model() - if i == 0: - self._check_no_investments(segment_fs) - if before_solve is not None: - before_solve(segment_fs) - segment_fs.solve(solver, progress=False) + segment_fs = self._solve_segment( + solver, + start_idx, + end_idx, + i, + segment_flow_systems, + horizon, + nr_of_previous_values, + before_solve, + ) segment_flow_systems.append(segment_fs) @@ -294,6 +286,34 @@ def rolling_horizon( return segment_flow_systems + def _solve_segment( + self, + solver: _Solver, + start_idx: int, + end_idx: int, + i: int, + previous_segments: list[FlowSystem], + horizon: int, + nr_of_previous_values: int, + before_solve: Callable[[FlowSystem], None] | None, + ) -> FlowSystem: + """Build and solve a single rolling-horizon segment.""" + segment_fs = self._fs.transform.isel(time=slice(start_idx, end_idx)) + if i > 0 and nr_of_previous_values > 0: + self._transfer_state( + source_fs=previous_segments[i - 1], + target_fs=segment_fs, + horizon=horizon, + nr_of_previous_values=nr_of_previous_values, + ) + segment_fs.build_model() + if i == 0: + self._check_no_investments(segment_fs) + if before_solve is not None: + before_solve(segment_fs) + segment_fs.solve(solver, progress=False) + return segment_fs + def _calculate_segment_indices(self, total_timesteps: int, horizon: int, overlap: int) -> list[tuple[int, int]]: """Calculate start and end indices for each segment.""" segments = []