Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +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. 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

Expand Down
50 changes: 50 additions & 0 deletions docs/user-guide/optimization/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 40 additions & 7 deletions flixopt/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ def format(self, record):
'log_to_console': True,
'log_main_results': True,
'compute_infeasibilities': True,
'capture_solver_log': False,
}
),
}
Expand Down Expand Up @@ -529,13 +530,36 @@ 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. 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
# 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')

# 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')

# Native solver console only (no Python logger capture)
CONFIG.Solving.capture_solver_log = False
CONFIG.Solving.log_to_console = True
```
"""

Expand All @@ -544,6 +568,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.
Expand Down Expand Up @@ -668,6 +693,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,
Expand Down Expand Up @@ -698,13 +724,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
Expand All @@ -714,15 +742,17 @@ 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

@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:
Expand All @@ -734,8 +764,9 @@ 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()
return cls

Expand All @@ -761,6 +792,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
Expand Down Expand Up @@ -865,6 +897,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

Expand Down
28 changes: 22 additions & 6 deletions flixopt/flow_system.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, progress: bool = True) -> FlowSystem:
"""
Solve the optimization model and populate the solution.

Expand All @@ -1423,6 +1423,11 @@ 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.
progress: Whether to show a tqdm progress bar during solving.

Returns:
Self, for method chaining.
Expand All @@ -1439,11 +1444,22 @@ 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,
)
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(log_fn=log_path) as captured_path:
self.model.solve(
log_fn=captured_path,
solver_name=solver.name,
progress=progress,
**solver.options,
)
else:
self.model.solve(
**({'log_fn': log_path} if log_path is not None else {}),
solver_name=solver.name,
progress=progress,
**solver.options,
)

if self.model.termination_condition in ('infeasible', 'infeasible_or_unbounded'):
if CONFIG.Solving.compute_infeasibilities:
Expand Down
92 changes: 92 additions & 0 deletions flixopt/io.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1507,6 +1510,95 @@ def suppress_output():
pass # FD already closed or invalid


@contextmanager
def stream_solver_log(log_fn: pathlib.Path | None = None):
"""Stream solver log file contents to the ``flixopt.solver`` Python logger.

Tails a solver log file in a background thread, forwarding each line to
``logging.getLogger('flixopt.solver')`` at INFO level.

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 their console-log option
takes effect. This is a solver/linopy limitation.

Args:
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:
Path to the log file. Pass it as ``log_fn`` to
``linopy.Model.solve``.

Warning:
Not thread-safe. Use only with sequential execution.
"""
solver_logger = logging.getLogger('flixopt.solver')

# 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)
# Truncate existing file so the tail thread only streams new output
if log_path.exists():
log_path.write_text('')

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
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
# ============================================================================
Expand Down
22 changes: 16 additions & 6 deletions flixopt/optimization.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(log_fn=log_fn) as log_path:
self.model.solve(
log_fn=log_path,
solver_name=solver.name,
progress=False,
**solver.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}')
Expand Down
Loading
Loading