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

Rework math handling #639

Open
wants to merge 19 commits into
base: main
Choose a base branch
from
Open

Rework math handling #639

wants to merge 19 commits into from

Conversation

irm-codebase
Copy link
Contributor

@irm-codebase irm-codebase commented Jul 16, 2024

Partially fixes #608 #619

Summary of changes in this pull request

This PR aims to improve the way in which math is handled across a model init->build->solve->postprocess pipeline.

  • MathDocumentation is now in postprocess. Model no longer includes it (instead, it's an input to it).
  • model._model_def_path -> model._def_path: the processing of this was made clearer to remove unnecessary code during __init__. As a result, load.py is now scenarios.py, which is what it was really doing all along.
  • ModelMath introduced to handle math better. This preprocess class contains the following:
    • math file additions (either user math or pre-defined math).
    • math file history checks, in order of addition
    • forbidding adding the same file twice (easy to remove, if this would cause trouble)
    • math validation against the schema and against external math dictionaries (before adding to the model).
    • methods to easily save / read math from netCDF attributes.
  • _model_data.attrs["applied_additional_math"] was eliminated in favor of math.history, which is also saved/read from netCDFs.
  • model._model_data.attrs no longer contains "math".
  • base.yaml -> plan.yaml. This is to avoid extra logic when checking math modes)... plus its clearer, imo.

Additional discussion

Stuff that I could add to this PR very easily, with permission.

Removal of double math objects

Removing model.math is very possible now, because all the logic needed to process files is contained in ModelMath. Users can still reach it if we declare a @property that returns model.backend.math.data, or a warning if the backend has not been built / initialized.

I have not done this because it needs discussing (e.g., should we partially initialise the backend during init?).
This resulted in some additional attributes to the ModelBackendGenerator, which can be easily removed.

Enabling no math ('clean') models

This PR also allows us to fulfill #606 very easily. I believe that changing config.init.add_math::default::[] to config.init.added_math::default::['plan'] is the most transparent way and the easiest to maintain.

However, this might lead to some users accidentally omitting plan math if they add their own math... but this could hint at a documentation problem on our side.

Another option is to add config.init.default_math::default:true, but it's not my favorite because it seems intransparent...

Reviewer checklist

  • Test(s) added to cover contribution
  • Documentation updated
  • Changelog updated
  • Coverage maintained or improved

@irm-codebase irm-codebase marked this pull request as draft July 16, 2024 10:16
@irm-codebase irm-codebase changed the title Rework math Rework math handling Jul 16, 2024
@irm-codebase
Copy link
Contributor Author

irm-codebase commented Jul 18, 2024

Tried to improve model_math.py by redefining it as a @dataclass. Unfortunately, adding AttrDict as a parameter to a dataclass results in funky behavior. dataclasses.asdict fails, for example.

Ultimately I decided it was not worth the hassle, but this leads to unnecessary code (__eq__, __repr__, etc). Maybe we could improve AttrDict in the future to enable this usage.

@irm-codebase
Copy link
Contributor Author

irm-codebase commented Jul 18, 2024

@brynpickering requesting a preeliminary review of this, with some open questions:

@irm-codebase irm-codebase self-assigned this Jul 18, 2024
@irm-codebase irm-codebase marked this pull request as ready for review July 18, 2024 20:27
@irm-codebase irm-codebase marked this pull request as draft July 18, 2024 20:27
@irm-codebase irm-codebase changed the base branch from rework-model-data-handling to main July 18, 2024 20:28
@irm-codebase irm-codebase marked this pull request as ready for review July 18, 2024 20:28
Copy link
Contributor Author

@irm-codebase irm-codebase left a comment

Choose a reason for hiding this comment

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

Few comments to help with the review. Let me know what you think!

docs/hooks/generate_math_docs.py Outdated Show resolved Hide resolved
src/calliope/backend/__init__.py Show resolved Hide resolved
src/calliope/backend/backend_model.py Show resolved Hide resolved
src/calliope/backend/backend_model.py Outdated Show resolved Hide resolved
src/calliope/backend/latex_backend_model.py Show resolved Hide resolved
src/calliope/preprocess/model_math.py Show resolved Hide resolved
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Removed chunks of code thanks to the init improvements in Model.

tests/conftest.py Outdated Show resolved Hide resolved
tests/test_postprocess_math_documentation.py Outdated Show resolved Hide resolved
expected_math.union(user_math, allow_override=True)
flat = expected_math.as_dict_flat()
assert all(
model_math_w_mode_user.data.get_key(i) == flat[i] for i in flat.keys()
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Checks every single math combination!

Copy link
Member

@brynpickering brynpickering left a comment

Choose a reason for hiding this comment

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

Nice cleanup. Just a few comments:

  1. math documentation building isn't really a postprocessing step, it's a parallel process to building/solving the optimisation problem. Not sure where it should go, tbh.
  2. This is getting us one step closer to removing math from calliope.Model entirely, and I think I'm in favour of that. It just requires moving the validating_math_strings method to somewhere more suitable.
  3. I like being able to add a math dictionary and not just a reference to a math YAML. It's effectively equivalent to adding scenarios vs override_dict at calliope.Model instatiation. Ideally we'd add this to calliope.Model.build and allow that dict to completely replace the math dict or to be added as another override. If the approach matches scenarios/override_dict then add_math list would be applied first, followed by the math_dict. It just then needs a flag for ignoring math/base.yaml(/math/plan.yaml).
  4. Shall we use CalliopeMath as the class name? Many libraries seem to do this to avoid name clashes when using their lib as a dependency.

docs/hooks/generate_math_docs.py Outdated Show resolved Hide resolved
src/calliope/backend/__init__.py Outdated Show resolved Hide resolved
src/calliope/backend/backend_model.py Outdated Show resolved Hide resolved
src/calliope/backend/backend_model.py Outdated Show resolved Hide resolved
src/calliope/backend/backend_model.py Outdated Show resolved Hide resolved
Comment on lines +86 to +87
model_def_with_overrides["nodes"] = model_def_with_overrides["locations"]
del model_def_with_overrides["locations"]
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
model_def_with_overrides["nodes"] = model_def_with_overrides["locations"]
del model_def_with_overrides["locations"]
model_def_with_overrides["nodes"] = model_def_with_overrides.pop("locations")

def _combine_overrides(overrides: AttrDict, scenario_overrides: list):
combined_override_dict = AttrDict()
for override in scenario_overrides:
try:
Copy link
Member

Choose a reason for hiding this comment

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

I tend to find try/except to be less readable. if override not in overrides is much more explicit

tests/conftest.py Outdated Show resolved Hide resolved
tests/test_preprocess_model_math.py Outdated Show resolved Hide resolved
@@ -561,8 +526,8 @@ def validate_math_strings(self, math_dict: dict) -> None:
"""
Copy link
Member

Choose a reason for hiding this comment

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

I guess it should move to the parsing module? It just needs a list of possible parameters passed to it so that it can use that in the parsing.

Copy link

codecov bot commented Jul 19, 2024

Codecov Report

Attention: Patch coverage is 96.08696% with 9 lines in your changes missing coverage. Please review.

Project coverage is 95.98%. Comparing base (4fc6b84) to head (3284a55).
Report is 4 commits behind head on main.

Files Patch % Lines
src/calliope/preprocess/scenarios.py 86.66% 4 Missing ⚠️
src/calliope/util/tools.py 66.66% 1 Missing and 1 partial ⚠️
src/calliope/backend/latex_backend_model.py 80.00% 0 Missing and 1 partial ⚠️
src/calliope/model.py 96.77% 0 Missing and 1 partial ⚠️
src/calliope/postprocess/math_documentation.py 96.29% 1 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##             main     #639   +/-   ##
=======================================
  Coverage   95.97%   95.98%           
=======================================
  Files          26       29    +3     
  Lines        3980     4014   +34     
  Branches      836      771   -65     
=======================================
+ Hits         3820     3853   +33     
- Misses         70       72    +2     
+ Partials       90       89    -1     
Files Coverage Δ
src/calliope/attrdict.py 96.48% <100.00%> (ø)
src/calliope/backend/__init__.py 100.00% <100.00%> (ø)
src/calliope/backend/backend_model.py 97.97% <100.00%> (+0.01%) ⬆️
src/calliope/backend/expression_parser.py 93.75% <100.00%> (+0.01%) ⬆️
src/calliope/backend/gurobi_backend_model.py 95.66% <100.00%> (ø)
src/calliope/backend/parsing.py 96.99% <ø> (ø)
src/calliope/backend/pyomo_backend_model.py 98.11% <100.00%> (ø)
src/calliope/io.py 96.80% <100.00%> (-0.04%) ⬇️
src/calliope/preprocess/__init__.py 100.00% <100.00%> (ø)
src/calliope/preprocess/model_math.py 100.00% <100.00%> (ø)
... and 6 more

@brynpickering
Copy link
Member

@irm-codebase I've made a bunch of changes, partly following offline discussions between us.

  1. I moved math to only be introduced at the calliope.Model.build step. I think this generally works well.
  2. I updated calliope.Model.math to calliope.Model.applied_math so it's clear that the math has already been applied (since the optimisation problem must have been built!)
  3. I've added math dict application as a build arg and enabled ignoring the mode math with a config.build option (default=false) ignore_mode_math. This could also be flipped to something like include_mode_math (default=true).
  4. I've moved math dict parsing validation to the calliope.Model.build step. It duplicated dict parsing that is done when adding each optimisation component, but does it much quicker (so you can catch math errors quickly for a large model). I've also added a config.build option to activate it (default=true): pre_validate_math_strings.

@irm-codebase
Copy link
Contributor Author

@brynpickering fantastic! It's a lot of changes, so I'll review them once I'm back this Monday.

Copy link
Contributor Author

@irm-codebase irm-codebase left a comment

Choose a reason for hiding this comment

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

Went through the changes. I'm quite happy with them!

I've added some suggestions to avoid bugs catched by mypy, and to improve logic in some parts.

"objectives",
]:
self._add_all_inputs_as_parameters()
if self.inputs.attrs["config"]["build"]["pre_validate_math_strings"]:
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Nice idea!

component = components.removesuffix("s")
for name in self.math.data[components]:
for name, dict_ in self.math.data[components].items():
Copy link
Contributor Author

Choose a reason for hiding this comment

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

General note: it'd be better if we used more descriptive names than dict_.
It makes it a bit harder to know what is being fetched and why.

@@ -280,7 +279,7 @@ def _add_component(
this name must be available in the input math provided on initialising the class.
component_dict (Tp): unparsed YAML dictionary configuration.
component_setter (Callable): function to combine evaluated xarray DataArrays into backend component objects.
component_type (Literal["variables", "global_expressions", "constraints", "objectives"]):
component_type (Literal["variables", "global_expressions", "constraints", "piecewise_constraints", "objectives"]):
Copy link
Contributor Author

Choose a reason for hiding this comment

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

You could change Literal... to ORDERED_COMPONENTS_T to make this smaller.
Users would see it though, so a more descriptive name (MATH_COMPONENTS_T?) might be a good idea.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

See proposal to move this to a TypedDict in CalliopeMath below.

src/calliope/backend/backend_model.py Show resolved Hide resolved
@@ -491,7 +483,7 @@ def generate_math_doc(
]
if getattr(self, objtype).data_vars
}
if not components["parameters"]:
if "parameters" in components and not components["parameters"]:
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Potential bug:
This will also eliminate parameters with False, 0 and similar values.
We may want this due to sparsity, but I am not sure. If you only want to target None, do if not None instead.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@brynpickering do you still consider this not a postprocessing feature after going through the changes?
should we change something here?

end_math_list = [] if add_math_dict is None else [add_math_dict]
full_math_list = init_math_list + backend_config["add_math"] + end_math_list
LOGGER.debug(f"Math preprocessing | Loading math: {full_math_list}")
model_math = preprocess.CalliopeMath(full_math_list, self._def_path)
Copy link
Contributor Author

@irm-codebase irm-codebase Aug 17, 2024

Choose a reason for hiding this comment

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

This "math mode salad" is quite hard to follow because of its "mixed value" nature.

  • init_math_list will have either [] or [str]
  • the middle (backend_config["add_math"]) will have [] [str]
  • end_math_list (add_math_dict) will have either None or [dict]

How about:

math_list = [backend_config.get("mode"), backend_config.get("add_math"), add_math_dict]
math_list = list(filter(bool, math_list))  # Removes False equivalents None, "", [], {}
model_math = preprocess.CalliopeMath(math_list, self._def_path)

self._init_from_dict(math_to_add)
self.data: AttrDict = AttrDict(
{
"variables": {},
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Should be TypedDict above?
The backend also refers to through ORDERED_COMPONENTS_T, so maybe the order should be defined here so that we have it in one tidy place.


ATTRS_TO_SAVE = ("history", "data")
ATTRS_TO_LOAD = ("history",)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Why are we not loading back the math data?
I was doing it because the file structure might have changed, meaning the "rebuild" would fail since the reference directories would be different...

Comment on lines +62 to +70
def __repr__(self) -> str:
"""Custom string representation of class."""
return f"""Calliope math definition dictionary with:
{len(self.data["variables"])} decision variable(s)
{len(self.data["global_expressions"])} global expression(s)
{len(self.data["constraints"])} constraint(s)
{len(self.data["piecewise_constraints"])} piecewise constraint(s)
{len(self.data["objectives"])} objective(s)
"""
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Incorrect tabs trigger a mypy error.

Suggested change
def __repr__(self) -> str:
"""Custom string representation of class."""
return f"""Calliope math definition dictionary with:
{len(self.data["variables"])} decision variable(s)
{len(self.data["global_expressions"])} global expression(s)
{len(self.data["constraints"])} constraint(s)
{len(self.data["piecewise_constraints"])} piecewise constraint(s)
{len(self.data["objectives"])} objective(s)
"""
def __repr__(self) -> str:
"""Custom string representation of class."""
return f"""
Calliope math definition dictionary with:
{len(self.data["variables"])} decision variable(s)
{len(self.data["global_expressions"])} global expression(s)
{len(self.data["constraints"])} constraint(s)
{len(self.data["piecewise_constraints"])} piecewise constraint(s)
{len(self.data["objectives"])} objective(s)
"""

@@ -788,7 +789,7 @@ def as_array(self) -> xr.DataArray: # noqa: D102, override
evaluated = backend_interface._dataset[self.name]
except KeyError:
evaluated = xr.DataArray(self.name, attrs={"obj_type": "string"})
if "default" in evaluated.attrs:
if "default" in evaluated.attrs and pd.notnull(evaluated.attrs["default"]):
Copy link
Contributor Author

Choose a reason for hiding this comment

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

pd.notnull here should change.

@irm-codebase
Copy link
Contributor Author

@brynpickering what's next for this feature?
Will we wait until the approach to parameters is set, or should we merge this beforehand?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Potential desync in model configuration at the model.py level
2 participants