Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add config object to keep config in sync at all times #704

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
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
5 changes: 4 additions & 1 deletion docs/hooks/generate_readable_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,15 @@
import jsonschema2md
from mkdocs.structure.files import File

from calliope import AttrDict, config
from calliope.util import schema

TEMPDIR = tempfile.TemporaryDirectory()

SCHEMAS = {
"config_schema": schema.CONFIG_SCHEMA,
"config_schema": AttrDict.from_yaml_string(
config.CalliopeConfig().model_yaml_schema()
),
"model_schema": schema.MODEL_SCHEMA,
"math_schema": schema.MATH_SCHEMA,
"data_table_schema": schema.DATA_TABLE_SCHEMA,
Expand Down
3 changes: 2 additions & 1 deletion requirements/base.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ geographiclib >= 2, < 3
ipdb >= 0.13, < 0.14
ipykernel < 7
jinja2 >= 3, < 4
jsonref >= 1.1, < 2
jsonschema >= 4, < 5
natsort >= 8, < 9
netcdf4 >= 1.2, < 1.7
Expand All @@ -13,4 +14,4 @@ pyomo >= 6.5, < 6.7.2
pyparsing >= 3.0, < 3.1
ruamel.yaml >= 0.18, < 0.19
typing-extensions >= 4, < 5
xarray >= 2024.1, < 2024.4
xarray >= 2024.1, < 2024.4
4 changes: 4 additions & 0 deletions src/calliope/attrdict.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import numpy as np
import ruamel.yaml as ruamel_yaml
from ruamel.yaml.scalarstring import walk_tree
from typing_extensions import Self

from calliope.util.tools import relative_path
Expand Down Expand Up @@ -355,6 +356,9 @@ def to_yaml(self, path=None):

result = result.as_dict()

# handle multi-line strings.
walk_tree(result)

if path is not None:
with open(path, "w") as f:
yaml_.dump(result, f)
Expand Down
14 changes: 7 additions & 7 deletions src/calliope/backend/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,30 +15,30 @@
from calliope.preprocess import CalliopeMath

if TYPE_CHECKING:
from calliope import config
from calliope.backend.backend_model import BackendModel


def get_model_backend(
name: str, data: xr.Dataset, math: CalliopeMath, **kwargs
build_config: "config.Build", data: xr.Dataset, math: CalliopeMath
) -> "BackendModel":
"""Assign a backend using the given configuration.

Args:
name (str): name of the backend to use.
build_config: Build configuration options.
data (Dataset): model data for the backend.
math (CalliopeMath): Calliope math.
**kwargs: backend keyword arguments corresponding to model.config.build.

Raises:
exceptions.BackendError: If invalid backend was requested.

Returns:
BackendModel: Initialized backend object.
"""
match name:
match build_config.backend:
case "pyomo":
return PyomoBackendModel(data, math, **kwargs)
return PyomoBackendModel(data, math, build_config)
case "gurobi":
return GurobiBackendModel(data, math, **kwargs)
return GurobiBackendModel(data, math, build_config)
case _:
raise BackendError(f"Incorrect backend '{name}' requested.")
raise BackendError(f"Incorrect backend '{build_config.backend}' requested.")
Comment on lines +38 to +44
Copy link
Contributor

Choose a reason for hiding this comment

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

This is an area where the old approach and the new pydantic may be at odds.
Case _ is spurious, since pydantic should catch wrong settings beforehand, no?

Copy link
Member Author

Choose a reason for hiding this comment

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

yes, you're right! Actually it was always kinda spurious as we always call this with info from the config and the config was being validated against the schema which only had two backend options.

33 changes: 17 additions & 16 deletions src/calliope/backend/backend_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,17 +26,13 @@
import numpy as np
import xarray as xr

from calliope import exceptions
from calliope import config, exceptions
from calliope.attrdict import AttrDict
from calliope.backend import helper_functions, parsing
from calliope.exceptions import warn as model_warn
from calliope.io import load_config
from calliope.preprocess.model_math import ORDERED_COMPONENTS_T, CalliopeMath
from calliope.util.schema import (
MODEL_SCHEMA,
extract_from_schema,
update_then_validate_config,
)
from calliope.util.schema import MODEL_SCHEMA, extract_from_schema
Comment on lines +29 to +35
Copy link
Contributor

Choose a reason for hiding this comment

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

An issue here is that config.py and config/**.yaml files are at odds, since both provide similar functionality. Do we expect pydantic to replace our approach completely?

Copy link
Member Author

Choose a reason for hiding this comment

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

I think yes. The switch to parameters in math would drastically reduce the need for a massive model definition schema. Then the rest might be best blended with our TypedDicts that we use (which pydantic can translate directly to a model to validate against, reducing duplication of schema/type definitions in our code!)


if TYPE_CHECKING:
from calliope.backend.parsing import T as Tp
Expand Down Expand Up @@ -69,20 +65,20 @@ class BackendModelGenerator(ABC):
_PARAM_UNITS = extract_from_schema(MODEL_SCHEMA, "x-unit")
_PARAM_TYPE = extract_from_schema(MODEL_SCHEMA, "x-type")

def __init__(self, inputs: xr.Dataset, math: CalliopeMath, **kwargs):
def __init__(
self, inputs: xr.Dataset, math: CalliopeMath, build_config: config.Build
):
"""Abstract base class to build a representation of the optimisation problem.

Args:
inputs (xr.Dataset): Calliope model data.
math (CalliopeMath): Calliope math.
**kwargs (Any): build configuration overrides.
build_config: Build configuration options.
"""
self._dataset = xr.Dataset()
self.inputs = inputs.copy()
self.inputs.attrs = deepcopy(inputs.attrs)
self.inputs.attrs["config"]["build"] = update_then_validate_config(
"build", self.inputs.attrs["config"], **kwargs
)
self.config = build_config
self.math: CalliopeMath = deepcopy(math)
self._solve_logger = logging.getLogger(__name__ + ".<solve>")

Expand Down Expand Up @@ -200,6 +196,7 @@ def _check_inputs(self):
"equation_name": "",
"backend_interface": self,
"input_data": self.inputs,
"build_config": self.config,
"helper_functions": helper_functions._registry["where"],
"apply_where": True,
"references": set(),
Expand Down Expand Up @@ -246,7 +243,7 @@ def add_optimisation_components(self) -> None:
# The order of adding components matters!
# 1. Variables, 2. Global Expressions, 3. Constraints, 4. Objectives
self._add_all_inputs_as_parameters()
if self.inputs.attrs["config"]["build"]["pre_validate_math_strings"]:
if self.config.pre_validate_math_strings:
self._validate_math_string_parsing()
for components in typing.get_args(ORDERED_COMPONENTS_T):
component = components.removesuffix("s")
Expand Down Expand Up @@ -399,7 +396,7 @@ def _add_all_inputs_as_parameters(self) -> None:
if param_name in self.parameters.keys():
continue
elif (
self.inputs.attrs["config"]["build"]["mode"] != "operate"
self.config.mode != "operate"
and param_name
in extract_from_schema(MODEL_SCHEMA, "x-operate-param").keys()
):
Expand Down Expand Up @@ -606,17 +603,21 @@ class BackendModel(BackendModelGenerator, Generic[T]):
"""Calliope's backend model functionality."""

def __init__(
self, inputs: xr.Dataset, math: CalliopeMath, instance: T, **kwargs
self,
inputs: xr.Dataset,
math: CalliopeMath,
instance: T,
build_config: config.Build,
) -> None:
"""Abstract base class to build backend models that interface with solvers.

Args:
inputs (xr.Dataset): Calliope model data.
math (CalliopeMath): Calliope math.
instance (T): Interface model instance.
**kwargs: build configuration overrides.
build_config: Build configuration options.
"""
super().__init__(inputs, math, **kwargs)
super().__init__(inputs, math, build_config)
self._instance = instance
self.shadow_prices: ShadowPrices
self._has_verbose_strings: bool = False
Expand Down
11 changes: 7 additions & 4 deletions src/calliope/backend/gurobi_backend_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import pandas as pd
import xarray as xr

from calliope import config
from calliope.backend import backend_model, parsing
from calliope.exceptions import BackendError, BackendWarning
from calliope.exceptions import warn as model_warn
Expand Down Expand Up @@ -41,19 +42,21 @@
class GurobiBackendModel(backend_model.BackendModel):
"""gurobipy-specific backend functionality."""

def __init__(self, inputs: xr.Dataset, math: CalliopeMath, **kwargs) -> None:
def __init__(
self, inputs: xr.Dataset, math: CalliopeMath, build_config: config.Build
) -> None:
"""Gurobi solver interface class.

Args:
inputs (xr.Dataset): Calliope model data.
math (CalliopeMath): Calliope math.
**kwargs: passed directly to the solver.
build_config: Build configuration options.
"""
if importlib.util.find_spec("gurobipy") is None:
raise ImportError(
"Install the `gurobipy` package to build the optimisation problem with the Gurobi backend."
)
super().__init__(inputs, math, gurobipy.Model(), **kwargs)
super().__init__(inputs, math, gurobipy.Model(), build_config)
self._instance: gurobipy.Model
self.shadow_prices = GurobiShadowPrices(self)

Expand Down Expand Up @@ -144,7 +147,7 @@ def _objective_setter(
) -> xr.DataArray:
expr = element.evaluate_expression(self, references=references)

if name == self.inputs.attrs["config"].build.objective:
if name == self.config.objective:
self._instance.setObjective(expr.item(), sense=sense)

self.log("objectives", name, "Objective activated.")
Expand Down
1 change: 1 addition & 0 deletions src/calliope/backend/parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,7 @@ def evaluate_where(
helper_functions=helper_functions._registry["where"],
input_data=backend_interface.inputs,
backend_interface=backend_interface,
build_config=backend_interface.config,
references=references if references is not None else set(),
apply_where=True,
)
Expand Down
11 changes: 7 additions & 4 deletions src/calliope/backend/pyomo_backend_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
from pyomo.opt import SolverFactory # type: ignore
from pyomo.util.model_size import build_model_size_report # type: ignore

from calliope import config
from calliope.exceptions import BackendError, BackendWarning
from calliope.exceptions import warn as model_warn
from calliope.preprocess import CalliopeMath
Expand Down Expand Up @@ -58,15 +59,17 @@
class PyomoBackendModel(backend_model.BackendModel):
"""Pyomo-specific backend functionality."""

def __init__(self, inputs: xr.Dataset, math: CalliopeMath, **kwargs) -> None:
def __init__(
self, inputs: xr.Dataset, math: CalliopeMath, build_config: config.Build
) -> None:
"""Pyomo solver interface class.

Args:
inputs (xr.Dataset): Calliope model data.
math (CalliopeMath): Calliope math.
**kwargs: passed directly to the solver.
build_config: Build configuration options.
"""
super().__init__(inputs, math, pmo.block(), **kwargs)
super().__init__(inputs, math, pmo.block(), build_config)

self._instance.parameters = pmo.parameter_dict()
self._instance.variables = pmo.variable_dict()
Expand Down Expand Up @@ -185,7 +188,7 @@ def _objective_setter(
) -> xr.DataArray:
expr = element.evaluate_expression(self, references=references)
objective = pmo.objective(expr.item(), sense=sense)
if name == self.inputs.attrs["config"].build.objective:
if name == self.config.objective:
text = "activated"
objective.activate()
else:
Expand Down
6 changes: 3 additions & 3 deletions src/calliope/backend/where_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from calliope.exceptions import BackendError

if TYPE_CHECKING:
from calliope import config
from calliope.backend.backend_model import BackendModel
Comment on lines 19 to 21
Copy link
Contributor

Choose a reason for hiding this comment

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

The need for TYPE_CHECKING may indicate that config.py is not being placed sensibly (a possible cyclic import?). Would it make sense to move this into src/calliope/config/?). The dependencies of config.py do not seem to conflict with anything else, and this would make things easier to maintain.

Copy link
Member Author

Choose a reason for hiding this comment

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

I only put it in there because its use in the parser is purely as a type hint. It's a way to show that it has no other purpose as well as to avoid conflicts (in this case, it doesn't create a conflict)

Copy link
Contributor

Choose a reason for hiding this comment

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

I'd prefer if it was moved to a regular import. A conflict is a code smell that indicates that the software could be arranged better, and this may hide those cases.
They are a useful annoyance.



Expand All @@ -34,6 +35,7 @@ class EvalAttrs(TypedDict):
helper_functions: dict[str, Callable]
apply_where: NotRequired[bool]
references: NotRequired[set]
build_config: config.Build


class EvalWhere(expression_parser.EvalToArrayStr):
Expand Down Expand Up @@ -118,9 +120,7 @@ def as_math_string(self) -> str: # noqa: D102, override
return rf"\text{{config.{self.config_option}}}"

def as_array(self) -> xr.DataArray: # noqa: D102, override
config_val = (
self.eval_attrs["input_data"].attrs["config"].build[self.config_option]
)
config_val = getattr(self.eval_attrs["build_config"], self.config_option)

if not isinstance(config_val, int | float | str | bool | np.bool_):
raise BackendError(
Expand Down
11 changes: 5 additions & 6 deletions src/calliope/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -278,9 +278,9 @@ def run(
# Else run the model, then save outputs
else:
click.secho("Starting model run...")

kwargs = {}
if save_logs:
model.config.set_key("solve.save_logs", save_logs)
kwargs["solve.save_logs"] = save_logs

if save_csv is None and save_netcdf is None:
click.secho(
Expand All @@ -292,14 +292,13 @@ def run(
# If save_netcdf is used, override the 'save_per_spore_path' to point to a
# directory of the same name as the planned netcdf

if save_netcdf and model.config.solve.spores_save_per_spore:
model.config.set_key(
"solve.spores_save_per_spore_path",
if save_netcdf and model.config.solve.spores.save_per_spore:
kwargs["solve.spores_save_per_spore_path"] = (
save_netcdf.replace(".nc", "/spore_{}.nc"),
)

model.build()
model.solve()
model.solve(**kwargs)
termination = model._model_data.attrs.get(
"termination_condition", "unknown"
)
Expand Down
Loading
Loading