Extending MUSE¶
One key feature of the generalized sector’s implementation is that it should be easy to extend. As such, MUSE can be made to run custom python functions, as long as these inputs and output of the function follow a standard specific to each step. We will look at a few here.
Below is a list of possible hooks, referenced by their implementation in the MUSE model:
register_interaction_net
inmuse.interactions
: a list of lists of agents that interact together.register_agent_interaction
inmuse.interactions
: Given a list of interacting agents, perform the interaction.register_production
inmuse.production
: A method to compute the production from a sector, given the demand and the capacity.register_initial_asset_transform
inmuse.hooks
: Allows any kind of transformation to be applied to the assets of an agent, prior to investing.register_final_asset_transform
inmuse.hooks
: After computing the investment, this sets the assets that will be owned by the agents.register_demand_share
inmuse.demand_share
: During agent investment, this is the share of the demand that an agent will try and satisfy.register_filter
inmuse.filters
: A filter to remove technologies from consideration, during agent investment.register_objective
inmuse.objectives
: A quantity which allows an agent to compare technologies during investment.register_decision
inmuse.decisions
: A transformation applied to aggregate multiple objectives into a single objective during agent investment, e.g. via a weighted sum.register_investment
inmuse.investment
: During agent investment, matches the demand for future investment using the decision metric above.register_output_quantity
inmuse.output.sector
: A sectorial quantity to output for postmortem analysis.register_output_sink
inmuse.outputs
: A place to store an output quantity, e.g. a file with a given format, a database on premise or on the cloud, etc…register_carbon_budget_fitter
inmuse.carbon_budget
register_carbon_budget_method
inmuse.carbon_budget
register_sector
: Registers a function that can create a sector from a muse configuration object.
Extending outputs¶
MUSE can be used to save custom quantities as well as data for analysis. There are two steps to this process:
Computing the quantity of interest
Store the quantity of interest in a sink
In practice, this means that we can compute any quantity, such as capacity or consumption of an energy source and save it to a csv file, or a netcdf file.
Output extension¶
To demonstrate this, we will compute a new edited quantity of consumption, then save it as a text file.
The current implementation of the quantity of consumption found in muse.outputs.sector
filters out values of 0. In this example, we would like to maintain the values of 0, but do not want to edit the source code of MUSE.
This is rather simple to do using MUSE’s hooks.
First we create a new function called consumption_zero
as follows:
[1]:
from muse.outputs.sector import register_output_quantity
from muse.outputs.sector import market_quantity
from xarray import Dataset, DataArray
from typing import Optional, List, Text
@register_output_quantity
def consumption_zero(
market: Dataset,
capacity: DataArray,
technologies: Dataset,
):
"""Current consumption."""
result = (
market_quantity(market.consumption, sum_over="timeslice", drop=None)
.rename("consumption")
.to_dataframe()
.round(4)
)
return result
---------------------------------------------------------------------------
ModuleNotFoundError Traceback (most recent call last)
/tmp/ipykernel_204/4248002360.py in <module>
----> 1 from muse.outputs.sector import register_output_quantity
2 from muse.outputs.sector import market_quantity
3 from xarray import Dataset, DataArray
4 from typing import Optional, List, Text
5
ModuleNotFoundError: No module named 'muse'
The function we created takes three arguments. These arguments (market
, capacity
and technology
) are mandatory for the @register_output_quantity
hook. Other hooks require different arguments.
Whilst this function is very similar to the consumption
function in muse.outputs.sector
, we have modified it slightly by allowing for values of 0
.
The important part of this function is the @register_output_quantity
decorator. This decorator ensures that this new quantity is addressable in the TOML file. Notice that we did not need to edit the source code to create our new function.
Next, we can create a sink to save the output quantity previously registered. For this example, this sink will simply dump the quantity it is given to a file, with the “Hello world!” message:
[2]:
from typing import Any, Text
from muse.outputs.sinks import register_output_sink, sink_to_file
@register_output_sink(name="txt")
@sink_to_file(".txt")
def text_dump(data: Any, filename: Text) -> None:
from pathlib import Path
Path(filename).write_text(f"Hello world!\n\n{data}")
---------------------------------------------------------------------------
ModuleNotFoundError Traceback (most recent call last)
/tmp/ipykernel_204/31753769.py in <module>
1 from typing import Any, Text
----> 2 from muse.outputs.sinks import register_output_sink, sink_to_file
3
4 @register_output_sink(name="txt")
5 @sink_to_file(".txt")
ModuleNotFoundError: No module named 'muse'
The code above makes use of two dectorators: @register_output_sink
and @sink_to_file
.
@register_output_sink
registers the function with MUSE, so that the sink is addressable from a TOML file. The second one, @sink_to_file
, is optional. This adds some nice-to-have features to sinks that are files. For example, a way to specify filenames and check that files cannot be overwritten, unless explicitly allowed to.
Next, we want to modify the TOML file to actually use this output type. To do this, we add a section to the output table:
[[sectors.residential.outputs]]
quantity = "consumption_zero"
sink = "txt"
filename = "{cwd}/{default_output_dir}/{Sector}{Quantity}{year}{suffix}"
The last line above allows us to specify the name of the file. We could also use sector
above or quantity
.
There can be as many sections of this kind as we like in the TOML file, which allow for multiple outputs.
Next, we first copy the default model provided with muse to a local subfolder called “model”. Then we read the settings.toml
file and modify it using python. You may prefer to modify the settings.toml
file using your favorite text editor. However, modifying the file programmatically allows us to routinely run this notebook as part of MUSE’s test suite and check that the tutorial it is still up to date.
[3]:
from pathlib import Path
from toml import load, dump
from muse import examples
model_path = examples.copy_model(overwrite=True)
settings = load(model_path / "settings.toml")
new_output = {
"quantity": "consumption_zero",
"sink": "txt",
"overwrite": True,
"filename": "{cwd}/{default_output_dir}/{Sector}{Quantity}{year}{suffix}",
}
settings["sectors"]["residential"]["outputs"].append(new_output)
dump(settings, (model_path / "modified_settings.toml").open("w"))
settings
---------------------------------------------------------------------------
ModuleNotFoundError Traceback (most recent call last)
/tmp/ipykernel_204/267986346.py in <module>
1 from pathlib import Path
----> 2 from toml import load, dump
3 from muse import examples
4
5 model_path = examples.copy_model(overwrite=True)
ModuleNotFoundError: No module named 'toml'
We can now run the simulation. There are two ways to do this. From the command-line, where we can do:
python3 -m muse data/commercial/modified_settings.toml
(note that slashes may be the other way on Windows). Or directly from the notebook:
[4]:
import logging
from muse.mca import MCA
logging.getLogger("muse").setLevel(0)
mca = MCA.factory(model_path / "modified_settings.toml")
mca.run();
---------------------------------------------------------------------------
ModuleNotFoundError Traceback (most recent call last)
/tmp/ipykernel_204/1115410891.py in <module>
1 import logging
----> 2 from muse.mca import MCA
3 logging.getLogger("muse").setLevel(0)
4 mca = MCA.factory(model_path / "modified_settings.toml")
5 mca.run();
ModuleNotFoundError: No module named 'muse'
We can now check that the simulation has created the files that we expect. We also check that our “Hello, world!” message has printed:
[5]:
all_txt_files = sorted((Path() / "Results").glob("Residential*.txt"))
assert "Hello world!" in all_txt_files[0].read_text()
all_txt_files
---------------------------------------------------------------------------
AssertionError Traceback (most recent call last)
/tmp/ipykernel_204/1678409945.py in <module>
1 all_txt_files = sorted((Path() / "Results").glob("Residential*.txt"))
----> 2 assert "Hello world!" in all_txt_files[0].read_text()
3 all_txt_files
AssertionError:
Our model output the files we were expecting and passed the assert
statement, meaning that it could find the “Hello world!” messages in the outputs.
Adding TOML parameters to the outputs¶
It would be useful if we could pass parameters from the TOML file to our new functions consumption_zero
and text_dump
. For example, in our previous iteration the consumption output was aggregating the data by "timeslice"
, by hardcoding the variable. We can pass a parameter which could do this by setting the sum_over
parameter to be True
. In addition, we could change the message output by a new text_dump
function.
Not all hooks are this flexible (for historical reasons, rather than any intrinsic difficulty). However, for outputs, we can do this as follows:
[6]:
@register_output_quantity(overwrite=True)
def consumption_zero(
market: Dataset,
capacity: DataArray,
technologies: Dataset,
sum_over: Optional[List[Text]] = None,
drop: Optional[List[Text]] = None,
rounding: int = 4,
):
"""Current consumption."""
result = (
market_quantity(market.consumption, sum_over=sum_over, drop=drop)
.rename("consumption")
.to_dataframe()
.round(rounding)
)
return result
@register_output_sink(name="txt", overwrite=True)
@sink_to_file(".txt")
def text_dump(
data: Any,
filename: Text,
msg : Optional[Text] = "Hello, world!"
) -> None:
from pathlib import Path
Path(filename).write_text(f"{msg}\n\n{data}")
---------------------------------------------------------------------------
NameError Traceback (most recent call last)
/tmp/ipykernel_204/717001874.py in <module>
----> 1 @register_output_quantity(overwrite=True)
2 def consumption_zero(
3 market: Dataset,
4 capacity: DataArray,
5 technologies: Dataset,
NameError: name 'register_output_quantity' is not defined
We simply added parameters as arguments to both of our functions: consumption_zero
and text_dump
.
Note: The overwrite argument allows us to overwrite previously defined registered functions. This is useful in a notebook such as this. But it should not be used in general. If overwrite were false, then the code would issue a warning and it would leave the TOML to refer to the original functions at the beginning of the notebook. This is useful when using custom modules.
Now we can modify the output section to take additional arguments:
[[sectors.commercial.outputs]]
quantity.name = "consumption_zero"
quantity.sum_over = "timeslice"
sink.name = "txt"
sink.filename = "{cwd}/{default_output_dir}/{Sector}{Quantity}{year}{suffix}"
sink.msg = "Hello, you!"
sink.overwrite = True
Here, we still want to use the consumption_zero
function and the txt
sink. But we would like to change the message from “Hello world!” to “Hello you!” within the TOML
file.
Now, both sink and quantity are dictionaries which can take any number of arguments. Previously, we were using a shorthand for convenience. Again, we create a new settings file, and run this with our new parameters, which interface with our new functions.
[7]:
from pathlib import Path
from toml import load, dump
from muse import examples
model_path = examples.copy_model(overwrite=True)
settings = load(model_path / "settings.toml")
settings["sectors"]["residential"]["outputs"] = [
{
"quantity":{
"name": "consumption_zero",
"sum_over": "timeslice"
},
"sink":{
"name": "txt",
"filename": "{cwd}/{default_output_dir}/{Sector}{Quantity}{year}{suffix}",
"msg": "Hello, you!",
"overwrite": True,
}
}
]
dump(settings, (model_path / "modified_settings_2.toml").open("w"))
settings
---------------------------------------------------------------------------
ModuleNotFoundError Traceback (most recent call last)
/tmp/ipykernel_204/4294915811.py in <module>
1 from pathlib import Path
----> 2 from toml import load, dump
3 from muse import examples
4
5 model_path = examples.copy_model(overwrite=True)
ModuleNotFoundError: No module named 'toml'
We then run the simulation again:
[8]:
mca = MCA.factory(model_path / "modified_settings_2.toml")
mca.run();
---------------------------------------------------------------------------
NameError Traceback (most recent call last)
/tmp/ipykernel_204/267455335.py in <module>
----> 1 mca = MCA.factory(model_path / "modified_settings_2.toml")
2 mca.run();
NameError: name 'MCA' is not defined
And we can check the parameters were used accordingly:
[9]:
all_txt_files = sorted((Path() / "Results").glob("Residential*.txt"))
assert len(all_txt_files) == 7
assert "Hello, you!" in all_txt_files[0].read_text()
all_txt_files
[9]:
[PosixPath('Results/ResidentialConsumption_Zero2020.txt'),
PosixPath('Results/ResidentialConsumption_Zero2025.txt'),
PosixPath('Results/ResidentialConsumption_Zero2030.txt'),
PosixPath('Results/ResidentialConsumption_Zero2035.txt'),
PosixPath('Results/ResidentialConsumption_Zero2040.txt'),
PosixPath('Results/ResidentialConsumption_Zero2045.txt'),
PosixPath('Results/ResidentialConsumption_Zero2050.txt')]
Again, we can see that the number of output files generated were as we expected and that our new message “Hello, you!” was found within these files. This means that our output and sink functions worked as expected.
Where to store new functionality¶
As previously demonstrated, we can easily add new functionality to MUSE. However, running a jupyter notebook is not always the best approach. It is also possible to store functions in an arbitrary pthon file, such as the following:
[10]:
%%writefile mynewfunctions.py
from typing import Any, Text
from muse.outputs.sinks import register_output_sink, sink_to_file
@register_output_sink(name="txt")
@sink_to_file(".txt")
def text_dump(data: Any, filename: Text) -> None:
from pathlib import Path
Path(filename).write_text(f"Hello world!\n\n{data}")
Overwriting mynewfunctions.py
We can then tell the TOML file where to find it:
plugins = "{cwd}/mynewfunctions.py"
[[sectors.commercial.outputs]]
quantity = "capacity"
sink = "dummy"
overwrite = true
Alternatively, plugin
can also be given a list of paths rather than just a single one, as done below.
[11]:
settings = load(model_path / "settings.toml")
settings["plugins"] = ["{cwd}/mynewfunctions.py"]
settings["sectors"]["residential"]["outputs"] = [
{
"quantity": "capacity",
"sink": "dummy",
"overwrite": "true"
}
]
dump(settings, (model_path / "modified_settings.toml").open("w"))
settings
---------------------------------------------------------------------------
NameError Traceback (most recent call last)
/tmp/ipykernel_204/3840354473.py in <module>
----> 1 settings = load(model_path / "settings.toml")
2 settings["plugins"] = ["{cwd}/mynewfunctions.py"]
3 settings["sectors"]["residential"]["outputs"] = [
4 {
5 "quantity": "capacity",
NameError: name 'load' is not defined
Next steps¶
In the next section we will output a technology filter, to stop agents from investing in a certain technology, and a new metric to combine multiple objectives.