Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
c8f0a4e
Add dispatch method using cost-minimising supply
tsmbland May 1, 2026
2575ce8
Tidy-ups to dispatch module
tsmbland May 5, 2026
ce6c22d
Move core code to dispatch module
tsmbland May 5, 2026
1fe7e54
Remove internal helper functions
tsmbland May 5, 2026
2b43af8
Update tests
tsmbland May 5, 2026
6142139
Fix issue with prices data structure
tsmbland May 5, 2026
116727f
Add docstring and test
tsmbland May 5, 2026
cab8f70
Merge branch 'main' into cost_minimising_supply
tsmbland May 5, 2026
2a13a63
Change default and update results files
tsmbland May 5, 2026
a2cf081
Update tutorial models
tsmbland May 5, 2026
3fd29c8
Fix for models with multiple regions
tsmbland May 5, 2026
de88eeb
Update tutorial model
tsmbland May 5, 2026
7cf9d37
Update documentation
tsmbland May 5, 2026
5fc2a15
Add new method to documentation
tsmbland May 5, 2026
cd84aff
Copilot suggestions
tsmbland May 5, 2026
b5ea292
Revert dispatch method for some example model sectors
tsmbland May 6, 2026
08d7ba2
Add tests, fix errors with multi-region models
tsmbland May 6, 2026
fc62b8b
Use different mask
tsmbland May 6, 2026
99889e7
Fix documentation formatting
tsmbland May 6, 2026
e021235
Normalise some data inputs
tsmbland May 6, 2026
2a070d7
Typo
tsmbland May 6, 2026
d3970d1
Create dedicated marginal_cost function
tsmbland May 6, 2026
b80d991
Simplify marginal cost calculation
tsmbland May 6, 2026
3b7fc0e
Update results files
tsmbland May 6, 2026
4dda6f5
Fix error with dispatch ordering
tsmbland May 6, 2026
5f161db
Update results AGAIN
tsmbland May 6, 2026
b83550a
Different selection method (testing)
tsmbland May 6, 2026
e4395fe
Revert "Different selection method (testing)"
tsmbland May 6, 2026
1d78508
Simplify dispatch_by_merit_order
tsmbland May 6, 2026
e9decf2
Add tests
tsmbland May 6, 2026
ccbcd72
Ban implicit broadcasting over region dimension
tsmbland May 6, 2026
1f2f4f0
Fix some tests
tsmbland May 6, 2026
31348ac
Fix carbon budget
tsmbland May 6, 2026
62cd3a4
Fix another test
tsmbland May 6, 2026
e681068
Ban implicit broadcasting over year dimension
tsmbland May 7, 2026
981c2ef
Fix some test fixtures
tsmbland May 7, 2026
2f0a4f1
Fix dimension check
tsmbland May 7, 2026
1152c57
Fix more tests
tsmbland May 7, 2026
88df659
Fix example models
tsmbland May 7, 2026
8bdf36f
Merge branch 'main' into broadcast_regions
tsmbland May 7, 2026
8b5fdb5
Merge branch 'broadcast_regions' into broadcast_years
tsmbland May 7, 2026
4f176df
Fix remaining tests
tsmbland May 7, 2026
dabefe6
Merge branch 'main' into broadcast_years
tsmbland May 7, 2026
97a733a
Fix final tests
tsmbland May 7, 2026
e083ac5
Copilot suggestions
tsmbland May 7, 2026
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
9 changes: 9 additions & 0 deletions src/muse/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,15 @@ def patched_broadcast_compat_data(self, other):
"`broadcast_regions` (see `muse.utilities`)."
)

if (isinstance(other, Variable)) and ("year" in self.dims) != (
"year" in getattr(other, "dims", [])
):
raise ValueError(
"Broadcasting along the 'year' dimension is required, but automatic "
"broadcasting is disabled. Please handle it explicitly using "
"`broadcast_years` (see `muse.utilities`)."
)

# The rest of the function is copied directly from
# xarray.core.variable._broadcast_compat_data
if all(hasattr(other, attr) for attr in ["dims", "data", "shape", "encoding"]):
Expand Down
5 changes: 4 additions & 1 deletion src/muse/agents/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import xarray as xr

from muse.timeslices import drop_timeslice
from muse.utilities import broadcast_years


class AbstractAgent(ABC):
Expand Down Expand Up @@ -494,7 +495,9 @@ def retirement_profile(
investments = investments.reindex_like(profile, method="ffill")

# Apply the retirement profile to the investments
new_assets = (investments * profile).rename(replacement="asset")
new_assets = (broadcast_years(investments, profile) * profile).rename(
replacement="asset"
)
new_assets["installed"] = "asset", [investment_year] * len(new_assets.asset)

# The new assets have picked up quite a few coordinates along the way.
Expand Down
5 changes: 3 additions & 2 deletions src/muse/agents/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

from muse.agents.agent import Agent, InvestingAgent
from muse.errors import AgentShareNotDefined, TechnologyNotDefined
from muse.utilities import broadcast_years


def create_standard_agent(
Expand Down Expand Up @@ -252,8 +253,8 @@ def _shared_capacity(
techs = (existing > 0) & (shares > 0)
techs = techs.any([u for u in techs.dims if u != "asset"])
if not any(techs):
return (capacity * shares).copy()
return (capacity * shares).sel(asset=techs.values).copy()
return (capacity * broadcast_years(shares, capacity)).copy()
return (capacity * broadcast_years(shares, capacity)).sel(asset=techs.values).copy()


def _standardize_inputs(
Expand Down
6 changes: 4 additions & 2 deletions src/muse/costs.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
from muse.commodities import is_enduse, is_fuel, is_material, is_pollutant
from muse.quantities import production_amplitude
from muse.timeslices import broadcast_timeslice, distribute_timeslice, get_level
from muse.utilities import broadcast_years


def cost(func):
Expand Down Expand Up @@ -625,10 +626,11 @@ def annual_to_lifetime(costs: xr.DataArray, technologies: xr.Dataset):
rates = discount_factor(
years=years,
interest_rate=technologies.interest_rate,
mask=years <= life,
mask=years <= broadcast_years(life, years),
)
if "timeslice" in costs.dims:
rates = broadcast_timeslice(rates, level=get_level(costs))
costs = broadcast_years(costs, years)
return (costs * rates).sum("year")


Expand All @@ -648,7 +650,7 @@ def discount_factor(
assert "year" not in interest_rate.dims

# Calculate discount factor over the years
df = 1 / (1 + interest_rate) ** years
df = 1 / (1 + broadcast_years(interest_rate, years)) ** years

# Apply mask
if mask is not None:
Expand Down
91 changes: 43 additions & 48 deletions src/muse/dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ def production(
import xarray as xr

from muse.registration import registrator
from muse.utilities import check_dimensions


class PRODUCTION_SIGNATURE(Protocol):
Expand Down Expand Up @@ -141,8 +142,11 @@ def share_based_production(
from muse.quantities import emission, maximum_production, minimum_production
from muse.utilities import broadcast_over_assets

assert "asset" not in demand.dims
assert "asset" in capacity.dims
check_dimensions(demand, ["timeslice", "commodity"], optional=["region"])
check_dimensions(capacity, ["asset"], optional=["dst_region"])
check_dimensions(
technologies, ["asset", "commodity"], optional=["timeslice", "dst_region"]
)

# Maximum and minimum production for each asset
maxprod = maximum_production(
Expand Down Expand Up @@ -234,9 +238,15 @@ def merit_order_production(
)
from muse.utilities import broadcast_over_assets

assert "asset" not in demand.dims
assert "asset" in capacity.dims
assert "asset" not in prices.dims
if "dst_region" in technologies.dims:
raise ValueError(
"Merit-order dispatch is not currently compatible with trade models."
)

check_dimensions(demand, ["timeslice", "commodity"], optional=["region"])
check_dimensions(capacity, ["asset"])
check_dimensions(technologies, ["asset", "commodity"], optional=["timeslice"])
check_dimensions(prices, ["timeslice", "commodity"], optional=["region"])

# Normalise demand/prices dataarrays to ensure they have a region dimension
# Multi-region models will already have a region dimension
Expand Down Expand Up @@ -283,51 +293,36 @@ def merit_order_production(
# Initialise result with zeros
result = xr.zeros_like(maxprod)

for y in maxprod.year.values:
prices_y = prices.sel(year=y)
maxprod_y = maxprod.sel(year=y)
minprod_y = minprod.sel(year=y)
maxcons_y = maxcons.sel(year=y)

for region in demand.region.values:
maxprod_region = maxprod_y.sel(
asset=maxprod_y.asset[maxprod_y.region == region]
)
techs_region = technologies.sel(
asset=technologies.asset[technologies.region == region]
)
minprod_region = minprod_y.sel(
asset=minprod_y.asset[minprod_y.region == region]
)
maxcons_region = maxcons_y.sel(
asset=maxcons_y.asset[maxcons_y.region == region]
)
for region in demand.region.values:
# Select data for this region
maxprod_region = maxprod.sel(asset=maxprod.asset[maxprod.region == region])
techs_region = technologies.sel(
asset=technologies.asset[technologies.region == region]
)
minprod_region = minprod.sel(asset=minprod.asset[minprod.region == region])
maxcons_region = maxcons.sel(asset=maxcons.asset[maxcons.region == region])

# Calculate timeslice-level costs for each asset assuming full
# dispatch. We use LCOE excluding capital costs.
technology_costs = marginal_cost(
techs_region,
prices.sel(region=region),
production=maxprod_region,
consumption=maxcons_region,
)

# Calculate timeslice-level costs for each asset in this year assuming full
# dispatch. We use LCOE excluding capital costs.
technology_costs = marginal_cost(
techs_region,
prices_y.sel(region=region),
production=maxprod_region,
consumption=maxcons_region,
# Calculate production by dispatching assets in order of
# increasing cost until demand is met
for ts in maxprod.timeslice.values:
dispatch = dispatch_by_merit_order(
demand=demand.sel(timeslice=ts, region=region),
minprod=minprod_region.sel(timeslice=ts),
maxprod=maxprod_region.sel(timeslice=ts),
technology_costs=technology_costs.sel(timeslice=ts),
)

# Calculate production for this year by dispatching assets in order of
# increasing cost until demand is met
for ts in maxprod_y.timeslice.values:
dispatch = dispatch_by_merit_order(
demand=demand.sel(year=y, timeslice=ts, region=region),
minprod=minprod_region.sel(timeslice=ts),
maxprod=maxprod_region.sel(timeslice=ts),
technology_costs=technology_costs.sel(timeslice=ts),
)
result.loc[
dict(
year=y,
timeslice=ts,
asset=result.asset[result.region == region],
)
] = dispatch
result.loc[
dict(timeslice=ts, asset=result.asset[result.region == region])
] = dispatch

# Add production of environmental pollutants
env = is_pollutant(technologies.comm_usage)
Expand Down
2 changes: 2 additions & 0 deletions src/muse/examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
from muse.mca import MCA
from muse.sectors import AbstractSector
from muse.timeslices import drop_timeslice
from muse.utilities import broadcast_years

__all__ = ["model", "technodata"]

Expand Down Expand Up @@ -271,6 +272,7 @@ def matching_market(sector: str, model: str = "default") -> xr.Dataset:

market = xr.Dataset()
techs = broadcast_over_assets(loaded_sector.technologies, assets.capacity)
techs = broadcast_years(techs, assets.capacity)
production = maximum_production(techs, assets.capacity)
market["supply"] = production.sum("asset")
if "dst_region" in market.dims:
Expand Down
3 changes: 2 additions & 1 deletion src/muse/investments.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ def investment(
from muse.outputs.cache import cache_quantity
from muse.registration import registrator
from muse.timeslices import timeslice_max
from muse.utilities import broadcast_years

INVESTMENT_SIGNATURE = Callable[
[xr.DataArray, xr.DataArray, xr.Dataset, list[Constraint], KwArg(Any)],
Expand Down Expand Up @@ -203,7 +204,7 @@ def cliff_retirement_profile(
dims="year",
coords={"year": range(investment_year, max_year + 1)},
)
profile = allyears < (investment_year + technical_life) # type: ignore
profile = allyears < broadcast_years(investment_year + technical_life, allyears) # type: ignore

# Minimize the number of years needed to represent the profile fully
# This is done by removing the central year of any three repeating years, ensuring
Expand Down
5 changes: 4 additions & 1 deletion src/muse/readers/toml.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import xarray as xr

from muse.defaults import DATA_DIRECTORY
from muse.utilities import broadcast_years

DEFAULT_SETTINGS_PATH = DATA_DIRECTORY / "default_settings.toml"
"""Default settings path."""
Expand Down Expand Up @@ -721,7 +722,9 @@ def read_correlation_consumption(sector_conf: Any) -> xr.Dataset:
# Split by timeslice
if sector_conf.timeslice_shares_path is not None:
shares = read_timeslice_shares(sector_conf.timeslice_shares_path)
consumption = broadcast_timeslice(consumption) * shares
consumption = broadcast_timeslice(consumption) * broadcast_years(
shares, consumption.year
)
else:
consumption = distribute_timeslice(consumption)

Expand Down
33 changes: 23 additions & 10 deletions src/muse/regressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@

from xarray import DataArray, Dataset

from muse.utilities import broadcast_years

__all__ = [
"Exponential",
"ExponentialAdj",
Expand Down Expand Up @@ -356,8 +358,9 @@ def Exponential(
) -> DataArray:
from numpy import exp

factor = 1e6 * self.coeffs.a * population
return factor * exp(self.coeffs.b * population / gdp)
coeffs = broadcast_years(self.coeffs, gdp.year)
factor = 1e6 * coeffs.a * population
return factor * exp(coeffs.b * population / gdp)


@register_regression
Expand All @@ -377,10 +380,11 @@ def ExponentialAdj(
if year is None:
year = self.base_year

factor = 1e6 * self.coeffs.a * population
unadjusted = factor * exp(self.coeffs.b * population / gdp)
coeffs = broadcast_years(self.coeffs, gdp.year)
factor = 1e6 * coeffs.a * population
unadjusted = factor * exp(coeffs.b * population / gdp)
p = power(year + forecast - self.base_year, n)
return unadjusted * (1 + self.coeffs.w * p) / (1 + p)
return unadjusted * (1 + coeffs.w * p) / (1 + p)


@register_regression
Expand All @@ -394,7 +398,8 @@ def Logistic(
"""
from numpy import exp, power

a, b, c, w = self.coeffs.a, self.coeffs.b, self.coeffs.c, self.coeffs.w
coeffs = broadcast_years(self.coeffs, gdp.year)
a, b, c, w = coeffs.a, coeffs.b, coeffs.c, coeffs.w
p = power(forecast, n)
factor = 1e6 * a * population * (1 + w * p) / (1 + p)
return factor / (1 + b * exp(gdp * c / population))
Expand All @@ -406,8 +411,9 @@ def Loglog(self, gdp: DataArray, population: DataArray, *args, **kwargs) -> Data
"""1e6 * e^a * population * (gpd/population)^b."""
from numpy import exp, power

factor = 1e6 * exp(self.coeffs.a) * population
return factor * power(gdp / population, self.coeffs.b)
coeffs = broadcast_years(self.coeffs, gdp.year)
factor = 1e6 * exp(coeffs.a) * population
return factor * power(gdp / population, coeffs.b)


@register_regression
Expand All @@ -424,6 +430,7 @@ def LogisticSigmoid(
) -> DataArray:
"""0.001 * (constant * pop + gdp * c / sqrt(1 + (gdp * scale / pop)^2)."""
from numpy import power
from xarray import ones_like

constant = self.coeffs.a
c = self.coeffs.c
Expand All @@ -442,8 +449,11 @@ def LogisticSigmoid(
# fmt: enable
scale = self.coeffs.b0.where(years < 2015, self.coeffs.b1)
else:
scale = 1
scale = ones_like(self.coeffs.b0)

Comment thread
tsmbland marked this conversation as resolved.
scale = broadcast_years(scale, gdp.year)
constant = broadcast_years(constant, gdp.year)
c = broadcast_years(c, gdp.year)
p = power(1 + power(gdp * scale / population, 2), 0.5)
return 0.001 * (constant * population + gdp * c / p)

Expand Down Expand Up @@ -498,7 +508,10 @@ def __call__(

if year is not None and "year" in data.dims:
data = data.interp(year=year, method=self.interpolation)
return coeffs.a * data.population + scale * (
a = broadcast_years(coeffs.a, data.year)
scale = broadcast_years(scale, data.year)
gdpcap_offset = broadcast_years(gdpcap_offset, data.year)
return a * data.population + scale * (
data.gdp - gdpcap_offset * data.population
)

Expand Down
Loading
Loading