Skip to content
Open
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
1 change: 1 addition & 0 deletions documentation/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ New features
* The flex-context can now define multiple commodities, each specifying their own prices and grid capacities [see `PR #1946 <https://www.github.com/FlexMeasures/flexmeasures/pull/1946>`_, `PR #2172 <https://www.github.com/FlexMeasures/flexmeasures/pull/2172>`_, `PR #2235 <https://www.github.com/FlexMeasures/flexmeasures/pull/2235>`_ and `PR #2271 <https://www.github.com/FlexMeasures/flexmeasures/pull/2271>`_]
* CLI support for adding/editing account attributes [see `PR #2242 <https://www.github.com/FlexMeasures/flexmeasures/pull/2242>`_]
* Extended ``GET /api/v3_0/jobs/<uuid>`` with a ``result`` field containing ``unresolved`` and ``resolved`` soft state-of-charge constraint analysis (``soc-minima``/``soc-maxima`` violations or satisfied constraints, keyed by asset ID) for scheduling jobs; both arrays are empty when no SoC constraints were defined [see `PR #2072 <https://www.github.com/FlexMeasures/flexmeasures/pull/2072>`_]
* New ``FLEXMEASURES_LP_SOLVER_OPTIONS`` config setting to pass solver options to the scheduling solver, validated against the installed HiGHS build so that unknown or unsupported options raise instead of being silently ignored [see `PR #2283 <https://www.github.com/FlexMeasures/flexmeasures/pull/2283>`_]

Infrastructure / Support
----------------------
Expand Down
14 changes: 14 additions & 0 deletions documentation/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,20 @@ Note that you need to install the solver, read more at :ref:`installing-a-solver
Default: ``"appsi_highs"``


FLEXMEASURES_LP_SOLVER_OPTIONS
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

Solver options passed to the scheduling solver, overriding the defaults FlexMeasures sets itself. Use this to tune the solver without patching code, for example to trade optimality for speed::

FLEXMEASURES_LP_SOLVER_OPTIONS = {"mip_rel_gap": "1e-4"}

When the solver is HiGHS, FlexMeasures validates these against the installed HiGHS build and raises on an unknown option name, an invalid value, or a feature the build lacks. This matters because Pyomo's ``appsi_highs`` interface otherwise applies solver options without checking whether HiGHS accepted them, so a typo would be silently ignored.

.. note:: HiGHS initializes its thread scheduler once per process. Setting ``threads`` or ``parallel`` therefore only affects the first solve in a worker process; later solves fail with ``global scheduler has already been initialized`` and return no schedule. FlexMeasures logs a warning if you set either.

Default: ``{}``



FLEXMEASURES_HOSTS_AND_AUTH_START
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Expand Down
45 changes: 45 additions & 0 deletions flexmeasures/data/models/planning/linear_optimization.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,45 @@
infinity = float("inf")


def validate_highs_options(options: dict) -> None:
"""Raise if HiGHS would refuse any of these options.

Pyomo's appsi_highs interface applies solver options without checking HiGHS'
return status, so an unknown name, an invalid value, or a feature missing from
the installed HiGHS build is otherwise ignored without a word. That silently
turns a mis-typed option into a no-op, and a benchmark of it into a false
negative. Probing a throwaway Highs instance surfaces the rejection instead.
"""
try:
import highspy
except ImportError:
# Solver named "*highs*" but highspy absent: let the solver interface complain.
return

probe = highspy.Highs()
probe.setOptionValue("output_flag", False)
rejected = [
f"{name}={value!r}"
for name, value in options.items()
if probe.setOptionValue(name, value) != highspy.HighsStatus.kOk
]
if rejected:
raise ValueError(
f"HiGHS rejected these FLEXMEASURES_LP_SOLVER_OPTIONS: {', '.join(rejected)}."
" The option name may be unknown, the value invalid, or the feature absent"
" from this HiGHS build. For example, the HiPO solver (solver='hipo') needs"
" a HiGHS built against BLAS and METIS, which the pip-installed highspy is not."
)

if "threads" in options or "parallel" in options:
current_app.logger.warning(
"FLEXMEASURES_LP_SOLVER_OPTIONS sets 'threads' and/or 'parallel'. HiGHS"
" initializes its thread scheduler once per process, so inside a long-lived"
" worker only the first solve honours these; later solves fail with 'global"
" scheduler has already been initialized' and yield no schedule."
)


def device_scheduler( # noqa C901
device_constraints: list[pd.DataFrame],
ems_constraints: pd.DataFrame | list[pd.DataFrame],
Expand Down Expand Up @@ -819,6 +858,12 @@ def cost_function(m):
if current_app.config["LOGGING_LEVEL"] == "INFO":
profile["output_flag"] = "false"

# Apply operator-configured options last, so they override the defaults above.
configured_options = current_app.config.get("FLEXMEASURES_LP_SOLVER_OPTIONS") or {}
if configured_options and "highs" in solver_name.lower():
validate_highs_options(configured_options)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be better to do this once on startup rather than for each scheduling job.

profile.update(configured_options)

for option_name, option_value in profile.items():
solver.options[option_name] = option_value

Expand Down
62 changes: 62 additions & 0 deletions flexmeasures/data/models/planning/tests/test_solver_options.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
from __future__ import annotations

import pytest

from flexmeasures.data.models.planning.linear_optimization import validate_highs_options

highspy = pytest.importorskip("highspy")


def highs_accepts(name, value) -> bool:
probe = highspy.Highs()
probe.setOptionValue("output_flag", False)
return probe.setOptionValue(name, value) == highspy.HighsStatus.kOk


def test_validate_highs_options_accepts_the_defaults_we_ship():
"""The profile device_scheduler applies itself must stay acceptable to HiGHS."""
validate_highs_options(
{
"mip_rel_gap": "0",
"mip_abs_gap": "0",
"primal_feasibility_tolerance": "1e-9",
"dual_feasibility_tolerance": "1e-9",
"mip_feasibility_tolerance": "1e-9",
"output_flag": "false",
Comment on lines +20 to +25

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The settings tested here should be gotten from the operational codebase. Maybe via a shared import.

}
)


def test_validate_highs_options_rejects_unknown_option_name():
with pytest.raises(ValueError, match="no_such_option"):
validate_highs_options({"no_such_option": "1"})


def test_validate_highs_options_rejects_invalid_option_value():
with pytest.raises(ValueError, match="solver='banana'"):
validate_highs_options({"solver": "banana"})


def test_validate_highs_options_reports_every_rejected_option():
with pytest.raises(ValueError) as exc:
validate_highs_options({"no_such_option": "1", "solver": "banana"})
assert "no_such_option" in str(exc.value)
assert "solver='banana'" in str(exc.value)


def test_validate_highs_options_rejects_solver_missing_from_this_build():
"""HiPO needs a HiGHS built against BLAS and METIS; the pip-installed one is not.

Pyomo's appsi_highs would swallow this, so we must not.
"""
if highs_accepts("solver", "hipo"):
pytest.skip("this HiGHS build provides the HiPO solver")
with pytest.raises(ValueError, match="hipo"):
validate_highs_options({"solver": "hipo"})


def test_validate_highs_options_warns_about_the_global_thread_scheduler(app, caplog):
"""HiGHS initializes its thread scheduler once per process, so `threads` is a trap."""
with app.app_context():
validate_highs_options({"threads": 2})
assert "thread scheduler" in caplog.text
1 change: 1 addition & 0 deletions flexmeasures/utils/config_defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ class Config(object):
"EVSE": ["one-way_evse", "two-way_evse"],
} # how to group assets by asset types
FLEXMEASURES_LP_SOLVER: str = "appsi_highs"
FLEXMEASURES_LP_SOLVER_OPTIONS: dict[str, str | int | float] = {}
FLEXMEASURES_JOB_TTL: timedelta = timedelta(days=1)
FLEXMEASURES_PLANNING_HORIZON: timedelta = timedelta(days=2)
FLEXMEASURES_MAX_PLANNING_HORIZON: timedelta | int | None = (
Expand Down
Loading