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

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

Conversation

brynpickering
Copy link
Member

@brynpickering brynpickering commented Nov 6, 2024

Fixes #626
Partially fixes #619

Thought I'd already upload this so you can contribute to the attempt @irm-codebase. Tests haven't been cleaned up so I expect many will fail.

I'm quite liking pydantic @sjpfenninger. I know we questioned using it some time ago, but now that I've spent more time with it I do wonder whether it might make other parts of the code and input validation cleaner...

Summary of changes in this pull request

  • Config is a pydantic model, replacing the config schema (we can dump a yaml schema at any time, though!)
  • Config repr hides operate/spores options data if those modes aren't activated
  • For debugging, pydantic methods can be used to hide defaults (model.config.model_dump(exclude_defaults=True))
  • build and solve steps have isolated configs that account for ad-hoc kwargs, which are stored in the config class as applied_keyword_overrides (might want something snappier)
  • operate_[...] and spores_[...] config options have returned to being sub-dicts (build.operate.[...] and build.spores.[...]) so the options can be easily isolated and passed around as necessary
  • config options are "frozen" unless using the update method, which returns an updated config object, but keeps the base config object unchanged except for content in applied_keyword_overrides. So you can't change config options accidentally (e.g. model.config.init.name = "new_name" won't work).
  • Intellisense picks up the config option docstrings which is useful when doing development and probably also for users writing scripts!

Reviewer checklist

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

@irm-codebase
Copy link
Contributor

@brynpickering this sounds really nice. I'll give a thorough look at it.

One thing, based on your description of the additions (and before I check the code): perhaps we should alter things so that the mode extends the configuration, rather than hiding it? This would avoid extra work on our end down the line, and move the code of these modes towards a 'plug-in' approach.

I'll share more thoughts or suggestions in a while.

Copy link
Contributor

@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.

This is a comment, for now, since the code is not 100% ready.

Generally, I like this proposal.

Some of the positives I see.

  • The capacity in inherit validation models could do wonders to streamline our code. In particular, it could enable us to make non-standard modes 'plug-ins' that people can choose to use.
  • With a bit of context, pydantic makes the configuration quite easy to follow. It giving intellisense suggestions is very nice too.

Some concerns, though:

  • I see this approach as a duplicate of yaml schemas. We should only use one. Keeping both only makes the code harder to understand, imo.
  • I do not think this really solves Invalid configuration showing for un-activated modes due to schema defaults #626. The configuration is still 'tangled'. But it does provide a way to solve it.
  • More of an open question than a concern: would pydantic help in our efforts to make parameters and dimensions part of the input yaml files?

src/calliope/backend/__init__.py Show resolved Hide resolved
src/calliope/backend/backend_model.py Outdated Show resolved Hide resolved
src/calliope/backend/where_parser.py Outdated Show resolved Hide resolved
Comment on lines 346 to 351
@model_validator(mode="before")
@classmethod
def update_solve_mode(cls, data):
"""Solve mode should match build mode."""
data["solve"]["mode"] = data["build"]["mode"]
return data
Copy link
Contributor

Choose a reason for hiding this comment

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

This seems to do nothing?

Copy link
Member Author

Choose a reason for hiding this comment

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

if you do model.config.update({"build": {"mode": "operate"}}) it will update the solve "mode" too. The solve "mode" is really just a tracker of the build mode, and this ensures they are always in sync.

Copy link
Contributor

Choose a reason for hiding this comment

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

I meant it as 'this is not used anywhere in our code'. But it seems like its an update to pydantic behaviour (fancy!).

Having mode in two different places is an issue, though. Even if pydantic ensures they are equal, it introduces unnecessary ambiguity.

Model.solve() seems to update solve.mode, and then immediately use it, and then it's used nowhere else after.

        kwargs["mode"] = self.config.build.applied_keyword_overrides.get(
            "mode", self.config.build.mode
        )

        this_solve_config = self.config.update({"solve": kwargs}).solve
        self._model_data.attrs["timestamp_solve_start"] = log_time(
            LOGGER,
            self._timings,
            "solve_start",
            comment=f"Optimisation model | starting model in {this_solve_config.mode} mode.",
        )

        shadow_prices = this_solve_config.shadow_prices
        self.backend.shadow_prices.track_constraints(shadow_prices)

        if this_solve_config.mode == "operate":
            results = self._solve_operate(**this_solve_config.model_dump())
        else:
            results = self.backend._solve(
                warmstart=warmstart, **this_solve_config.model_dump()
            )

If we just used the value build.mode within Model.solve(), all this code is unnecessary.
Perhaps ensuring that Model._is_built is automatically set to False if the mode is ever updated is a better approach?

src/calliope/config.py Outdated Show resolved Hide resolved
src/calliope/model.py Outdated Show resolved Hide resolved
src/calliope/model.py Outdated Show resolved Hide resolved
src/calliope/model.py Outdated Show resolved Hide resolved
src/calliope/model.py Show resolved Hide resolved
src/calliope/preprocess/data_tables.py Outdated Show resolved Hide resolved
* Removed mode redundancy to simplify the configuration
* Simplify config schema extraction
@sjpfenninger
Copy link
Member

I think moving to pydantic is perfectly fine if there are clear benefits - it seems like two of these are better maintainability, and making it easier to handle optional additional configuration. Can we limit additional changes to the schema (e.g. nesting) though?

@irm-codebase
Copy link
Contributor

irm-codebase commented Dec 10, 2024

@brynpickering @sjpfenninger @FLomb

Before continuing this PR, we need to decide how to handle updates to the model configuration via kwargs in Model.build and Model.solve.

Our current approach is to handle them as "ephemeral", meaning that we essentially forget them afterwards.

This introduces several issues:

  1. A lot of seemingly unnecessary complexity on our side, since "ephemeral" values may need to be passed between Model.build and Model.solve.
  2. It is quite bug prone, since there are two places that 'could' be relevant for the configuration. If we miss which one should be used, it would cause an error that might be invisible both to us and users.
  3. It may mislead users after reloading files, since model.config will not relate to the results.
  4. It is unintuitive, since in object oriented programming you'd expect changes to 'stick'.

More arguments can be found here.

My proposal is to always treat model.config as the 'up to date' configuration, modifying it via kwargs if necessary. We can save the initial configuration somewhere, and reload it if requested.

image
(the red arrow is just an example of a detectable error, since we always know which state we are in thanks to _built and _solved. Y is True, N is False)

@brynpickering
Copy link
Member Author

Can we limit additional changes to the schema (e.g. nesting) though?

That could be difficult to achieve without lots of extra work, but sure.

Our current approach is to handle them as "ephemeral", meaning that we essentially forget them afterwards.

I disagree with this description of the current method. It is containerising the config for a specific build, so kwargs applied on build/solve are stored separately and then wiped on re-build/re-solve.

A lot of seemingly unnecessary complexity on our side, since "ephemeral" values may need to be passed between Model.build and Model.solve.

agree

It is quite bug prone, since there are two places that 'could' be relevant for the configuration. If we miss which one we should be used, it would cause an error that might be invisible both to us and users.

This is easy enough to solve with methods that force application of the containerised config in certain circumstance.

It may mislead users after reloading files, since model.config will not relate to the results.

Less so than an out-of-sync config with no way of even seeing what existed in the config before calling "reset".

It is unintuitive, since in object oriented programming you'd expect changes to 'stick'.

Disagree. Different libraries take different approaches to this, but many pass back a copy of the input with the changes, rather than making changes in-place on update. The current approach returns a copy of the config with the changes applied, this is the containerised, temporary config that is used. The only addition is that we store these changes so that a user can easily see the diff between the original and the containerised config.

The "reset" method is fine as an alternative except that I would not just say "and now we save with the wrong config but the user will understand that because they'll get a warning that it's out-of-sync". Instead, even if the user has reset the config, if they save the model with the current set of results then the config associated with those results is the one that is saved. Being able to access results with an out-of-sync config just doesn't work in practice.

@irm-codebase
Copy link
Contributor

irm-codebase commented Dec 10, 2024

Thanks for the feedback @brynpickering !
Some responses below

It is quite bug prone, since there are two places that 'could' be relevant for the configuration. If we miss which one we should be used, it would cause an error that might be invisible both to us and users.

This is easy enough to solve with methods that force application of the containerised config in certain circumstance.

I think we are saying the same thing here, kind of...

The problem is that 'in a certain circumstance' implies that the programmer must know which of the two (or more) methods should be called in one case. This ambiguity is what I am calling bug-prone, and does not exist if you have everything in one place with just one access method.

It is unintuitive, since in object oriented programming you'd expect changes to 'stick'.

Disagree. Different libraries take different approaches to this, but many pass back a copy of the input with the changes, rather than making changes in-place on update. The current approach returns a copy of the config with the changes applied, this is the containerised, temporary config that is used. The only addition is that we store these changes so that a user can easily see the diff between the original and the containerised config.

I was not referring to how the new pydantic schema works, but to how Model.build and Model.solve work.

model.build(backend='pyomo')

They do not follow the behaviour you describe (they return None), and returning a copy of the model would not work here due to their size.

If someone unfamiliar with calliope reads the code above, I think they would assume the object is being modified. That's my worry.

Less so than an out-of-sync config with no way of even seeing what existed in the config before calling "reset".

The "reset" method is fine as an alternative except that I would not just say "and now we save with the wrong config but the user will understand that because they'll get a warning that it's out-of-sync". Instead, even if the user has reset the config, if they save the model with the current set of results then the config associated with those results is the one that is saved. Being able to access results with an out-of-sync config just doesn't work in practice.

I agree on this. We should disallow results in saved files if this mismatch exists, then?
Should be easy in any case.

@irm-codebase
Copy link
Contributor

One last clarification, @brynpickering.

The current approach returns a copy of the config with the changes applied, this is the containerised, temporary config that is used. The only addition is that we store these changes so that a user can easily see the diff between the original and the containerised config.

I definitely think this is the way to go when it comes to updates to the config.

All the above is more about how many config-relevant objects we should have, and where. Not necessarily about how said object is updated (we agree there), or if we should have a reset method (we could do away with it entirely).

@sjpfenninger
Copy link
Member

Can we limit additional changes to the schema (e.g. nesting) though?

That could be difficult to achieve without lots of extra work, but sure.

I'm asking this because I think it's not ideal if implementation details keep affecting how the config looks. Can we ex-ante look at all changes would and decide then, considering the extra work as a factor? And obviously settling onto something that will stay stable for the foreseeable future then.

@sjpfenninger
Copy link
Member

model.build(backend='pyomo')

They do not follow the behaviour you describe (they return None), and returning a copy of the model would not work here due to their size.

If someone unfamiliar with calliope reads the code above, I think they would assume the object is being modified. That's my worry.

I'm not sure that's the case. I would actually expect a kwarg passed to a function like this to NOT modify an underlying configuration. If the function signature were something like: model.build(update_config="backend: pyomo") that would be different.

So is maybe the implicit use of kwargs in a possibly confusing way one of the issues here?

Though, is this "containerised/ephemeral" config updating important enough to warrant the complexity of dealing with it? Would it be better (less code to maintain) to have only ever have a single configuration and document (or even warn) that kwargs update that configuration?

@irm-codebase
Copy link
Contributor

Can we limit additional changes to the schema (e.g. nesting) though?

That could be difficult to achieve without lots of extra work, but sure.

I'm asking this because I think it's not ideal if implementation details keep affecting how the config looks. Can we ex-ante look at all changes would and decide then, considering the extra work as a factor? And obviously settling onto something that will stay stable for the foreseeable future then.

I will attempt to create a summarized proposal of how I see pydantic affecting our YAML in another issue.
That way we keep that discussion there in a clear way.

@irm-codebase
Copy link
Contributor

irm-codebase commented Dec 10, 2024

@brynpickering @sjpfenninger

Here are diagrams for all proposed alternatives as I see them. I tried to be fair in the pros/cons I see.
This is pseudo-code, function and variable names do not necessarily match the code in this PR.

Overall:

  • In all cases, it is clear that we need flags for both build and solve stages to avoid saving results with out-of-sync configurations.
  • Beyond some code nuances, "containerised/ephemeral" and "reset" are more similar than what the diagrams imply. Main difference is the style of user interaction: one 'auto-resets', and the other makes users do it.

All three cases are fine, IMHO. However, my only request is that if the containerised case is chosen, no other 'extra' functionality should be in the pydantic schema (e.g., no automatic path modifications). Otherwise, we risk bloating it.

containerised/ephemeral

This is the closest to what @brynpickering initially proposed.

  • The 'current' configuration is still 'split', meaning de-syncs might still happen if we are not careful.
  • Probably the most 'code heavy', since it requires adding extra functionality to the pydantic schema: cnf.applied_overrides() to merge the kwargs, and a special initialisation when loading from netCDF (otherwise, the kwargs are lost).

As long as we always call applied_overrides when referencing the config, it should be OK.

image

reset

This is the closest to what I initially proposed.

  • The 'current' configuration is in one place, with no special method needed to access it.
  • It requires a new, user facing (!) function to reset the configuration. It will likely lead to less code than the containerised/ephemeral case, though: all you do is copy/paste one dictionary.

This one is only fine if we are willing to pass the responsibility of resetting to the users.

image

simple

This is the closest to what @sjpfenninger suggested above.

  • Very simple. Less code, less bugs, less complexity.
  • It's on users to save / re-load the config if they wish. The initial config is lost otherwise!

This puts the most trust on the user and slims our code. The most KISS / YAGNI of all cases.

image

@brynpickering
Copy link
Member Author

@irm-codebase My perspective on it (some overlap, some places where I disagree with your interpretation). In each, I give two examples of experiments, demonstrating the approach in action: 1. pyomo backend with gurobi solver (default) -> gurobi backend -> pyomo backend with CBC solver. 2. add constraint to math -> update constraint -> no contraint.

containerised

  • within build func: temp build config is a copy of model.config.build and **kwargs, which is used for all build steps. model.config.build is unchanged.
  • within solve func: temp solve config is a copy of model.config.solve and **kwargs, which is used for all solve steps. model.config.solve is unchanged, but access to the as-built config requires an extra step to access the model.config.build | build_kwargs variant (this is where it is not ideal and prone to error).
  • resetting: the config is effectively reset automatically after build and solve are run. The temporary changes are stored so that build_kwargs and solve_kwargs can be viewed until the next time build/solve is called. The pydantic model need not be updated with extra functions, I was just attaching them to the pydantic model for convenience. They could all be attached to the calliope Model object.
  • example:
Example 1

m = calliope.Model(...)
m.build()
m.solve()
m.to_netcdf(...)

m.build(backend="gurobi", force=True)
m.solve()
m.to_netcdf(...)

m.build(force=True)  # backend=pyomo is the default that it has reset to 
m.solve(solver="cbc")
m.to_netcdf(...)

Example 2

m = calliope.Model(...)
m.build(add_math={"constraints": {"foo": {<whole-constraint>}})
m.solve()
m.to_netcdf(...)

m.build(add_math={"constraints": {"foo": {<whole-constraint-with-update>}}, force=True)
m.solve()
m.to_netcdf(...)

m.build(force=True)  # math is automatically reset to having no added constraint
m.solve()
m.to_netcdf(...)

reset

  • within build func: model.config is recreated with a copy in which model.config.build has been updated with **kwargs. This is then used for all build steps.
  • within solve func: model.config is recreated with a copy in which model.config.solve has been updated with **kwargs. This is then used for all solve steps and access to the as-built config is also available in the previously updated model.config.build.
  • resetting: A manual call to model.config.reset() or model.reset(). If model.config.reset(), one should expect model.backend and model.results to still be there, but the config to have been reset to the pre-kwarg version. If model.reset(), the safest would be to wipe model.backend and model.results. This would ensure there's no de-sync between config and results as the results are lost at the same time as the config.
Example 1

No actual need for reset.

m = calliope.Model(...)
m.build()
m.solve()
m.to_netcdf(...)

m.build(backend="gurobi", force=True)
m.solve()
m.to_netcdf(...)

m.build(backend="pyomo", force=True)
m.solve(solver="cbc")
m.to_netcdf(...)

Example 2

m = calliope.Model(...)
m.build(add_math={"constraints": {"foo": {<whole-constraint>}})
m.solve()
m.to_netcdf(...)

m.build(add_math={"constraints": {"foo": {<partial-constraint-with-updates>}}, force=True)
m.solve()
m.to_netcdf(...)

m.reset()
m.build()
m.solve()
m.to_netcdf(...)

override

  • within build func: model.config is recreated with a copy in which model.config.build has been updated with **kwargs. This is then used for all build steps.
  • within solve func: model.config is recreated with a copy in which model.config.solve has been updated with **kwargs. This is then used for all solve steps and access to the as-built config is also available in the previously updated model.config.build.
  • resetting: can only be achieved with a re-instatiation of the model. I assume force remains for the build and solve methods.
Example 1

Can override without needing to re-init.

m = calliope.Model(...)
m.build()
m.solve()
m.to_netcdf(...)

m.build(backend="gurobi", force=True)
m.solve()
m.to_netcdf(...)

m.build(backend="pyomo", force=True)
m.solve(solver="cbc")
m.to_netcdf(...)

Example 2

m = calliope.Model(...)
m.build(add_math={"constraints": {"foo": {<whole-constraint>}})
m.solve()
m.to_netcdf(...)

m.build(add_math={"constraints": {"foo": {<partial-constraint-with-updates>}}, force=True)
m.solve()
m.to_netcdf(...)

# Can't undo the addition of a constraint to the math, so we have to re-init.
m = calliope.Model(...)
m.build()
m.solve()
m.to_netcdf(...)

@sjpfenninger
Copy link
Member

Using "simplicity in implementation" and "avoiding hidden magic" as the main two criteria, and looking at Bryn's detailed examples, option 3 seems preferable - unless we can think of a strong reason why initial configuration needs to be recoverable or we want to preserve the current behavior?

What we could add is to raise a warning when kwargs are passed to model.build() or model.solve(): warning the user that supplied kwargs are applied to the respective parts of the config, overriding anything that was there (n case of option 2, maybe pointing out the reset possibility).

@irm-codebase
Copy link
Contributor

I agree with @sjpfenninger in that the most simple approach might be preferable here.
If this turns out to be a bad decision, we can add this functionality back (in either containerised or reset form) with relative ease after pydantic and other changes remove large swaths of complexity.

The one case were this might cause pain is not being able to remove temporary math. In #726 I made a suggestion to 'mix and match' math files. Hopefully it lessens this issue.

@brynpickering which would be your preference? Is the pure override case ok for you?

@irm-codebase
Copy link
Contributor

@brynpickering while debugging failing test cases I've discovered a problem in the 'containerised' case.

It happens in the test_backend_build_mode test-case.

model = build_model({}, "simple_supply,operate,var_costs,investment_costs")  # build.mode is 'operate'
model.build(mode="plan")
model.solve()  # Fail!
model.build(
    force=True,
    mode="operate",
    operate={"use_cap_results": True, "window":request.param[0], "horizon":request.param[1]}
)

The main issue is that config.update(kwargs) erases the previous applied_overrides within Model.solve(), leading to logic errors in later portions of the function:

# within Model.solve
solve_config = self.config.update({"solve": kwargs}).solve  # previous build kwargs are lost!
# this will only see {"solve": kwargs} , mode='plan' is lost !
mode = self.config.update(self.config.applied_keyword_overrides).build.mode
# mode is 'operate' :(

This has important implications:

  • The order of reads / updates matters, which can lead to bugs
  • Since applied_overrides are lost with each update, we cannot guarantee that the saved configuration will match the results. This will happen even if we reverse the order in model.solve

The alternative is making applied_overrides 'sticky', meaning every update will remain after it is applied (similar to AttrDict.union). This introduces the need to 'reset' applied_overrides, either via user command or perhaps at the start of Model.build...

So, both the 'reset' and the 'containerised' case are actually quite similar, in the end. It's just a matter of where / how we save the kwarg overrides.

- Model 'build' and 'solve' no longer use a 'temporary' configuration
- Simplified the pydantic configuration schema, and made it fully 'frozen' and non extensible
- Moved data table loading into the Data generation portion
- Fixed most tests
@irm-codebase irm-codebase marked this pull request as ready for review December 15, 2024 18:49
@irm-codebase
Copy link
Contributor

@brynpickering, @sjpfenninger
I am done with the upgrades. All tests pass, although for some reason CodeConv and ubuntu / windows / mac tests are not triggering on GitHub.

Here is a summary of the changes:

  • Implemented the 'simple' approach to kwargs in model.build, model.solve.
  • We no longer modify the configuration 'behind the scenes', and there are no 'invisible' settings in it. This ensures clear separation between user inputs (data and configuration) and internal settings we need.
  • Ensured the whole configuration schema is frozen. Directly tampering with it will always trigger an error.
  • Ensured the configuration schema matches the types that the YAML can give (dicts, str, int, etc). Using other types, such as datetime, was leading to unexpected behaviour and time mismatches.

Did not implement:

  • Unfortunately, it difficult to protect saved netCDFs from containing results from old configurations. Setting model._is_solved to False at the end of build breaks the operate mode. The only solution I can think of is bringing this mode 'outside' of the Model class to make the finate-state machine of init / build / solve unambiguous, but it's too big of a change for this PR. I expect spores to have the same problem as long as it is contained 'within' build or solve.

Future improvements:

  • Since we no longer put hidden settings in the configuration, I had to bring model._def_path back. I expect it to go away once we implement Use pydantic for the whole model definition #726, as the idea is to 'load' all the math files users desire during _init_from_model_definition, making invalid paths impossible beyond it, since all the desired math would be saved in the file.

table_name (str): name of the data table.
data_table (DataTableDict): Data table definition dictionary.
data_table_dfs (dict[str, pd.DataFrame] | None, optional):
If given, a dictionary mapping table names in `data_table` to in-memory pandas DataFrames.
Defaults to None.
model_definition_path (Path | None, optional):
model_definition_path (Path, optional):
Copy link
Member Author

Choose a reason for hiding this comment

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

Should this be str | Path | None, optional?

Copy link
Contributor

Choose a reason for hiding this comment

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

I remember removing some of these due to mypy complaining about incorrect typings, but I am unsure about this one. If running mypy check does not result in complaints, either is fine.

@brynpickering
Copy link
Member Author

@irm-codebase : one thing that has now happened is that it doesn't fix #626 (all mode configs are shown in the config repr)!

What is the plan to mitigate this, if not in this PR?

Copy link

codecov bot commented Dec 18, 2024

Codecov Report

Attention: Patch coverage is 95.28302% with 10 lines in your changes missing coverage. Please review.

Project coverage is 96.22%. Comparing base (9f33ecb) to head (45ade7d).

Files with missing lines Patch % Lines
src/calliope/config.py 95.86% 4 Missing and 1 partial ⚠️
src/calliope/cli.py 40.00% 2 Missing and 1 partial ⚠️
src/calliope/model.py 94.87% 0 Missing and 2 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #704      +/-   ##
==========================================
+ Coverage   96.09%   96.22%   +0.13%     
==========================================
  Files          29       30       +1     
  Lines        4067     4181     +114     
  Branches      584      591       +7     
==========================================
+ Hits         3908     4023     +115     
  Misses         68       68              
+ Partials       91       90       -1     
Files with missing lines Coverage Δ
src/calliope/attrdict.py 97.63% <ø> (ø)
src/calliope/backend/__init__.py 100.00% <100.00%> (ø)
src/calliope/backend/backend_model.py 97.98% <100.00%> (ø)
src/calliope/backend/gurobi_backend_model.py 95.29% <100.00%> (-0.38%) ⬇️
src/calliope/backend/latex_backend_model.py 96.89% <100.00%> (+0.02%) ⬆️
src/calliope/backend/parsing.py 96.99% <ø> (ø)
src/calliope/backend/pyomo_backend_model.py 98.11% <100.00%> (+0.27%) ⬆️
src/calliope/backend/where_parser.py 98.20% <100.00%> (+0.02%) ⬆️
src/calliope/postprocess/math_documentation.py 90.32% <ø> (ø)
src/calliope/postprocess/postprocess.py 98.18% <ø> (-0.04%) ⬇️
... and 8 more

@irm-codebase
Copy link
Contributor

irm-codebase commented Dec 18, 2024

@irm-codebase : one thing that has now happened is that it doesn't fix #626 (all mode configs are shown in the config repr)!

What is the plan to mitigate this, if not in this PR?

Not in this PR for sure... We'll have to keep that one there for now.
Given that these modes are now a subdict, it's slightly less of a problem (it provides some isolation), but the confusion is still there. Ideally, we'd like these modes to use some kind of extensible plug-in approach, with a flexible part in the configuration schema were these additional properties are added.

As long as these modes are 'within' the Model class, we'll have to accept that they'll be there even if the modes are not used. I do not think that adding code to hide them is worth it (it'd just introduce more complexity).

class Solve(ConfigBaseModel):
"""Base configuration options used when solving a Calliope optimisation problem (`calliope.Model.solve`)."""

model_config = {"title": "Model Solve Configuration"}
Copy link
Contributor

Choose a reason for hiding this comment

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

@brynpickering one of the reasons I removed these model_config=... was that they override the settings given in ConfigBaseModel. If we do want these titles, we have to copy-paste the following in all cases. The titles did not seem worth the extra lines, and that's why I took them out.

    model_config = {
        "extra": "forbid",
        "frozen": True,
        "revalidate_instances": "always",
        "use_attribute_docstrings": True,
    }

Copy link
Member Author

Choose a reason for hiding this comment

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

Oddly enough, they don't override the ConfigBaseModel config. You can check it out for yourself (or maybe we should add a test to ensure that remains the case)!

Copy link
Contributor

Choose a reason for hiding this comment

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

Huh. Good to know!
I looked into the older version of the code, and it seems like the cause was that one of the subclasses omitted "frozen". I assumed that meant you had to specify it every time.

@sjpfenninger
Copy link
Member

The basic setup looks sound to me. I think it's good that the docs explicitly explain that overrides are executed after imports: but before templates: - and if somebody complains about the ability to have imports in templates that could always be brought back in later; I don't think we strictly need it. A complete review will have to wait till 2025!

@irm-codebase
Copy link
Contributor

@brynpickering I've added a few tests to satisfy the codecov check. Mostly testing new error messages (and some old ones that had no checks).

Should be enough to satisfy everything needed for this PR.

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