diff --git a/README.md b/README.md
index a301c27..0ffec25 100644
--- a/README.md
+++ b/README.md
@@ -99,7 +99,7 @@ Additional dependencies can be installed using
# To add scipy-related dependencies
pip install continuous-timeseries[scipy]
- # To add all optionatl dependencies
+ # To add all optional dependencies
pip install continuous-timeseries[full]
```
diff --git a/changelog/18.docs.md b/changelog/18.docs.md
new file mode 100644
index 0000000..4f85fb1
--- /dev/null
+++ b/changelog/18.docs.md
@@ -0,0 +1 @@
+Added a tutorial into our support for creating emissions pathways that are compatible with a given budget (see [Budget-compatible emissions pathways](../tutorials/budget_compatible_pathways)).
diff --git a/changelog/18.feature.md b/changelog/18.feature.md
new file mode 100644
index 0000000..ec7cec3
--- /dev/null
+++ b/changelog/18.feature.md
@@ -0,0 +1 @@
+Added [`budget_compatible_pathways`][continuous_timeseries.budget_compatible_pathways] to support the creation of pathways compatible with a given budget.
diff --git a/changelog/18.trivial.md b/changelog/18.trivial.md
new file mode 100644
index 0000000..c37861c
--- /dev/null
+++ b/changelog/18.trivial.md
@@ -0,0 +1 @@
+Added support for rending maths with latex in the docs.
diff --git a/docs/NAVIGATION.md b/docs/NAVIGATION.md
index a663dea..6e9e638 100644
--- a/docs/NAVIGATION.md
+++ b/docs/NAVIGATION.md
@@ -10,6 +10,7 @@ See https://oprypin.github.io/mkdocs-literate-nav/
- [Tutorials](tutorials/index.md)
- [Timeseries tutorial](tutorials/timeseries_tutorial.py)
- [Higher-order interpolation](tutorials/higher_order_interpolation.py)
+ - [Budget-compatible emissions pathways](tutorials/budget_compatible_pathways.py)
- [Continuous timeseries tutorial](tutorials/continuous_timeseries_tutorial.py)
- [Discrete timeseries tutorial](tutorials/discrete_timeseries_tutorial.py)
- [Further background](further-background/index.md)
diff --git a/docs/javascripts/katex.js b/docs/javascripts/katex.js
new file mode 100644
index 0000000..f7fd704
--- /dev/null
+++ b/docs/javascripts/katex.js
@@ -0,0 +1,10 @@
+document$.subscribe(({ body }) => {
+ renderMathInElement(body, {
+ delimiters: [
+ { left: "$$", right: "$$", display: true },
+ { left: "$", right: "$", display: false },
+ { left: "\\(", right: "\\)", display: false },
+ { left: "\\[", right: "\\]", display: true }
+ ],
+ })
+})
diff --git a/docs/tutorials/budget_compatible_pathways.py b/docs/tutorials/budget_compatible_pathways.py
new file mode 100644
index 0000000..1e660f4
--- /dev/null
+++ b/docs/tutorials/budget_compatible_pathways.py
@@ -0,0 +1,396 @@
+# ---
+# jupyter:
+# jupytext:
+# text_representation:
+# extension: .py
+# format_name: percent
+# format_version: '1.3'
+# jupytext_version: 1.16.6
+# kernelspec:
+# display_name: Python 3 (ipykernel)
+# language: python
+# name: python3
+# ---
+
+# %% [markdown]
+# # Budget-compatible pathways
+#
+# Here we discuss our module
+# for creating emissions pathways compatible with a given budget.
+
+# %% [markdown]
+# ## Imports
+
+# %%
+import matplotlib.pyplot as plt
+import numpy as np
+import openscm_units
+import pint
+import scipy.interpolate
+
+import continuous_timeseries as ct
+import continuous_timeseries.budget_compatible_pathways as ct_bcp
+from continuous_timeseries.timeseries_continuous import ContinuousFunctionScipyPPoly
+
+# %% [markdown]
+# ## Set up pint
+
+# %%
+pint.set_application_registry(openscm_units.unit_registry)
+
+# %% [markdown]
+# ## Handy pint aliases
+
+# %%
+UR = pint.get_application_registry()
+Q = UR.Quantity
+
+# %% [markdown]
+# ## Set up matplotlib to work with pint
+#
+# For details, see the pint docs
+# ([stable docs](https://pint.readthedocs.io/en/stable/user/plotting.html),
+# [last version that we checked at the time of writing](https://pint.readthedocs.io/en/0.24.4/user/plotting.html))
+# [or our docs on unit-aware plotting](../discrete_timeseries_tutorial#unit-aware-plotting).
+
+# %%
+UR.setup_matplotlib(enable=True)
+
+# %% [markdown]
+# ## Calculating a budget-compatible pathway
+
+# %% [markdown]
+# Let's imagine we start with some emissions budget from some point in time.
+
+# %%
+budget = Q(500, "GtCO2")
+budget_start_time = Q(2020.0, "yr")
+
+# %% [markdown]
+# ### Linear pathway to zero
+#
+# Calculating a linear pathway to zero emissions
+# that is compatible with this budget is trivial.
+# The only other piece of information you need
+# is the emissions you want to start from.
+# Normally, that will be emissions at the time
+# from which the budget is/was available.
+
+# %%
+emissions_start = Q(10.4, "GtC / yr").to("GtCO2 / yr")
+
+# %%
+linear_in_line_with_budget = ct_bcp.derive_linear_path(
+ budget=budget,
+ budget_start_time=budget_start_time,
+ emissions_start=emissions_start,
+)
+linear_in_line_with_budget
+
+# %%
+fig, axes = plt.subplots(nrows=2, sharex=True)
+
+linear_in_line_with_budget.plot(ax=axes[0])
+linear_in_line_with_budget.integrate(Q(0, "GtC"), name_res="Cumulative emissions").plot(
+ ax=axes[1]
+)
+
+for ax in axes:
+ ax.legend()
+ ax.grid()
+
+fig.tight_layout()
+
+# %% [markdown]
+# There is one thing to notice on this plot.
+# The pathway is extended slightly beyond the net-zero year with zero emissions.
+# This ensures that there is at least one full year with zero emissions
+# and you get sensible results if you extrapolate after creating the pathway.
+
+# %%
+extrapolation_times = np.arange(
+ np.ceil(budget_start_time).to("yr").m, 2100.0 + 1.0, 1.0
+) * UR.Unit("yr")
+
+linear_extrapolated = linear_in_line_with_budget.interpolate(
+ extrapolation_times, allow_extrapolation=True
+)
+linear_extrapolated.timeseries_continuous.name = "Extrapolated"
+
+fig, axes = plt.subplots(nrows=2, sharex=True)
+
+
+linear_extrapolated.plot(ax=axes[0])
+linear_extrapolated.integrate(
+ Q(0, "GtC"), name_res="Extrapolated cumulative emissions"
+).plot(ax=axes[1])
+
+for ax in axes:
+ ax.legend()
+ ax.grid()
+
+fig.tight_layout()
+
+# %% [markdown]
+# #### Annual steps
+#
+# In general, countries don't report emissions instantaneously.
+# Instead, they report emissions as the average over each year.
+# With Continuous Timeseries, it is trivial to transform our pathway
+# into something which can be directly compared with country emissions.
+
+# %%
+linear_annual_steps_in_line_with_budget = ct_bcp.convert_to_annual_constant_emissions(
+ linear_in_line_with_budget, name_res="Annualised"
+)
+
+# %%
+continuous_plot_kwargs = dict(linewidth=2.0, alpha=0.7)
+pathways = [linear_in_line_with_budget, linear_annual_steps_in_line_with_budget]
+
+fig, axes = plt.subplots(nrows=2, sharex=True, figsize=(10, 5))
+
+for pw in pathways:
+ pw.plot(ax=axes[0], continuous_plot_kwargs=continuous_plot_kwargs)
+ pw.integrate(Q(0, "GtC"), name_res=f"Cumulative emissions {pw.name}").plot(
+ ax=axes[1], continuous_plot_kwargs=continuous_plot_kwargs
+ )
+
+for ax in axes:
+ ax.legend()
+ ax.grid()
+
+fig.tight_layout()
+
+# %% [markdown]
+# The difference between the two clearer if we take a more extreme example.
+
+# %%
+budget_small = Q(0.425, "GtCO2")
+budget_small_start_time = Q(2020.0, "yr")
+emissions_small_start = Q(300, "MtCO2 / yr")
+
+# %%
+linear_in_line_with_budget_small = ct_bcp.derive_linear_path(
+ budget=budget_small,
+ budget_start_time=budget_small_start_time,
+ emissions_start=emissions_small_start,
+)
+linear_annual_steps_in_line_with_budget_small = (
+ ct_bcp.convert_to_annual_constant_emissions(
+ linear_in_line_with_budget_small, name_res="Annualised"
+ )
+)
+
+# %%
+continuous_plot_kwargs = dict(linewidth=2.0, alpha=0.7)
+pathways = [
+ linear_in_line_with_budget_small,
+ linear_annual_steps_in_line_with_budget_small,
+]
+
+fig, axes = plt.subplots(nrows=2, sharex=True, figsize=(10, 5))
+
+for pw in pathways:
+ pw.plot(ax=axes[0], continuous_plot_kwargs=continuous_plot_kwargs)
+ pw.integrate(Q(0, "GtC"), name_res=f"Cumulative emissions {pw.name}").plot(
+ ax=axes[1], continuous_plot_kwargs=continuous_plot_kwargs
+ )
+
+for ax in axes:
+ ax.legend()
+ ax.grid()
+
+fig.tight_layout()
+
+# %% [markdown]
+# This example also shows why annualising emissions can be tricky.
+# The drop from one year to the next is the same for all steps,
+# except the year in which (instantaneous) net zero is reached,
+# which has a smaller drop to the zero emissions.
+# For example, in the annualised pathway above,
+# the drop in emissions from 2021 to 2022 is around 100 MtCO2 / yr
+# while the drop in emissions from 2022 to 2023 is only around 40 MtCO2 / yr.
+# (There are ways to do such calculations in their discrete form,
+# Continuous Timeseries just removes those headaches.)
+
+# %% [markdown]
+# ### Curved pathway to zero
+#
+# A linear pathway is one way to get to zero.
+# Other options are possible, for example a smoother pathway.
+#
+# #### Quadratic
+#
+# First we show a symmetric quadratic.
+# This generally produces nice results.
+# Its major downside is that its gradient is zero today,
+# which may not be desirable in all cases.
+
+# %%
+quadratic_in_line_with_budget = ct_bcp.derive_symmetric_quadratic_path(
+ budget=budget,
+ budget_start_time=budget_start_time,
+ emissions_start=emissions_start,
+)
+
+# %%
+continuous_plot_kwargs = dict(linewidth=2.0, alpha=0.7)
+pathways = [linear_in_line_with_budget, quadratic_in_line_with_budget]
+
+fig, axes = plt.subplots(nrows=2, sharex=True, figsize=(10, 5))
+
+for pw in pathways:
+ pw.plot(ax=axes[0], continuous_plot_kwargs=continuous_plot_kwargs)
+ pw.integrate(Q(0, "GtC"), name_res=f"Cumulative emissions {pw.name}").plot(
+ ax=axes[1], continuous_plot_kwargs=continuous_plot_kwargs
+ )
+
+for ax in axes:
+ ax.legend()
+ ax.grid()
+
+fig.tight_layout()
+
+# %%
+quadratic_annual_steps_in_line_with_budget = (
+ ct_bcp.convert_to_annual_constant_emissions(
+ quadratic_in_line_with_budget, name_res="Annualised quadratic"
+ )
+)
+
+# %%
+continuous_plot_kwargs = dict(linewidth=2.0, alpha=0.7)
+pathways = [quadratic_in_line_with_budget, quadratic_annual_steps_in_line_with_budget]
+
+fig, axes = plt.subplots(nrows=2, sharex=True, figsize=(10, 5))
+
+for pw in pathways:
+ pw.plot(ax=axes[0], continuous_plot_kwargs=continuous_plot_kwargs)
+ pw.integrate(Q(0, "GtC"), name_res=f"Cumulative emissions {pw.name}").plot(
+ ax=axes[1], continuous_plot_kwargs=continuous_plot_kwargs
+ )
+
+for ax in axes:
+ ax.legend()
+ ax.grid()
+
+fig.tight_layout()
+
+
+# %% [markdown]
+# #### Custom
+#
+# Here we show how to make a custom fit,
+# in case you want to explore further yourself.
+# Here we do the fit such that the emissions and gradient today
+# are specified, but we still stay within the budget.
+# We do this by fitting a cubic pathway.
+
+
+# %%
+def get_cubic_pathway( # noqa: D103, PLR0913
+ budget,
+ budget_start_time,
+ emissions_start,
+ emissions_gradient_start,
+ name_res,
+ max_iter: int = 100,
+):
+ time_units = budget_start_time.u
+ values_units = emissions_start.u
+
+ E_0 = emissions_start
+ m_0 = emissions_gradient_start
+
+ # A first guess, doesn't really matter what it is.
+ # (Would probably break in some cases, but not the concern here).
+ nz_yr = budget_start_time + Q(1.0, "yr")
+ time_bounds = np.hstack([budget_start_time, nz_yr])
+ # Figure out scale factor to match the budget
+ for _ in range(max_iter):
+ # Handy constant
+ nzd = nz_yr - budget_start_time
+
+ b = -(2 * m_0 * nzd + 3 * E_0) / (nzd**2)
+ a = (m_0 * nzd + 2 * E_0) / (nzd**3)
+
+ c_non_zero = np.array(
+ [
+ [a.to(values_units / time_units**3).m],
+ [b.to(values_units / time_units**2).m],
+ [m_0.to(values_units / time_units).m],
+ [E_0.to(values_units).m],
+ ]
+ )
+
+ ppoly_raw = scipy.interpolate.PPoly(x=time_bounds.m, c=c_non_zero)
+ budget_raw = ppoly_raw.integrate(
+ budget_start_time.to(time_units).m, nz_yr.to(time_units).m
+ )
+ scale_factor = budget.to(values_units * time_units).m / budget_raw
+ if np.isclose(scale_factor, 1.0, rtol=1e-4):
+ break
+
+ nz_yr = budget_start_time + nzd * scale_factor
+ time_bounds = np.hstack([budget_start_time, nz_yr])
+
+ else:
+ msg = "Did not converge"
+ raise AssertionError(msg)
+
+ # Add on the zero component
+ c = np.hstack([c_non_zero, np.zeros((c_non_zero.shape[0], 1))])
+ last_ts_time = np.floor(nz_yr) + 2.0 * nz_yr.to("yr").u
+ x_bounds = np.hstack([budget_start_time, nz_yr, last_ts_time])
+ x = x_bounds.to(time_units).m
+
+ ppoly = scipy.interpolate.PPoly(x=x, c=c)
+
+ tsc = ct.TimeseriesContinuous(
+ name=name_res,
+ time_units=time_units,
+ values_units=values_units,
+ function=ContinuousFunctionScipyPPoly(ppoly),
+ domain=(budget_start_time, last_ts_time),
+ )
+ ts = ct.Timeseries(
+ time_axis=ct.TimeAxis(x_bounds),
+ timeseries_continuous=tsc,
+ )
+
+ return ts
+
+
+# %%
+emissions_gradient_start = Q(-1.0, "GtCO2 / yr / yr")
+times_plot = (
+ np.arange(budget_start_time.to("yr").m, 2081, 1.0) * budget_start_time.to("yr").u
+)
+
+fig, axes = plt.subplots(nrows=3, sharex=True, figsize=(10, 6))
+for emissions_gradient_start in Q([-2.0, 0.0, 5.0], "GtCO2 / yr / yr"):
+ cubic = get_cubic_pathway(
+ budget=budget,
+ budget_start_time=budget_start_time,
+ emissions_start=emissions_start,
+ emissions_gradient_start=emissions_gradient_start,
+ name_res=f"Initial gradient: {emissions_gradient_start:.2f}",
+ ).interpolate(times_plot, allow_extrapolation=True)
+
+ annualised = ct_bcp.convert_to_annual_constant_emissions(
+ cubic, name_res=f"Annualised {cubic.name}"
+ )
+
+ cubic.plot(ax=axes[0], continuous_plot_kwargs=dict(linewidth=2.0))
+ annualised.plot(
+ ax=axes[0], continuous_plot_kwargs=dict(label="", zorder=1.0, alpha=0.6)
+ )
+ cubic.differentiate().plot(ax=axes[1])
+ cubic.integrate(Q(0, "GtC")).plot(ax=axes[2])
+
+axes[0].legend()
+for ax in axes:
+ ax.grid()
+
+fig.tight_layout()
diff --git a/docs/tutorials/index.md b/docs/tutorials/index.md
index a96f22c..e2386bd 100644
--- a/docs/tutorials/index.md
+++ b/docs/tutorials/index.md
@@ -29,6 +29,12 @@ See [higher-order interpolation](./higher_order_interpolation)
for a tutorial on how to do higher-order interpolation with Continuous Timeseries
and some tips on how to go beyond the default options available.
+## Budget-compatible emissions pathways
+
+See [budget-compatible emissions pathways](./budget_compatible_pathways)
+for a tutorial on how to calculate emissions pathways
+compatible with a given budget.
+
## Continuous timeseries handling
See [Continuous timeseries](./continuous_timeseries_tutorial)
diff --git a/mkdocs.yml b/mkdocs.yml
index f9973ff..eaa590a 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -110,6 +110,9 @@ plugins:
markdown_extensions:
# https://squidfunk.github.io/mkdocs-material/setup/extensions/python-markdown/#attribute-lists
- attr_list
+ # https://squidfunk.github.io/mkdocs-material/reference/math/#katex-mkdocsyml
+ - pymdownx.arithmatex:
+ generic: true
# Allow admonitions, useful for deprecation warnings
# https://facelessuser.github.io/pymdown-extensions/extensions/blocks/plugins/admonition/
- pymdownx.blocks.admonition
@@ -138,6 +141,14 @@ markdown_extensions:
- toc:
permalink: "#"
+extra_javascript:
+ - javascripts/katex.js
+ - https://unpkg.com/katex@0/dist/katex.min.js
+ - https://unpkg.com/katex@0/dist/contrib/auto-render.min.js
+
+extra_css:
+ - https://unpkg.com/katex@0/dist/katex.min.css
+
watch:
- README.md
# Auto-generate if `src` changes (because this changes API docs)
diff --git a/pdm.lock b/pdm.lock
index 3f2105a..954d374 100644
--- a/pdm.lock
+++ b/pdm.lock
@@ -5,14 +5,14 @@
groups = ["default", "all-dev", "dev", "docs", "full", "plots", "scipy", "tests"]
strategy = ["inherit_metadata"]
lock_version = "4.5.0"
-content_hash = "sha256:68f13428514726753e6dd3e970ee62c0dfd4cbc731f52a04e93e2f1e1fd4306f"
+content_hash = "sha256:d588a6199c537a80be2873d5863598ca348c864dc25da7316cb96892c8df50dc"
[[metadata.targets]]
requires_python = ">=3.9"
[[package]]
name = "anyio"
-version = "4.7.0"
+version = "4.8.0"
requires_python = ">=3.9"
summary = "High level compatibility layer for multiple asynchronous event loop implementations"
groups = ["all-dev", "docs"]
@@ -23,8 +23,8 @@ dependencies = [
"typing-extensions>=4.5; python_version < \"3.13\"",
]
files = [
- {file = "anyio-4.7.0-py3-none-any.whl", hash = "sha256:ea60c3723ab42ba6fff7e8ccb0488c898ec538ff4df1f1d5e642c3601d07e352"},
- {file = "anyio-4.7.0.tar.gz", hash = "sha256:2f834749c602966b7d456a7567cafcb309f96482b5081d14ac93ccd457f9dd48"},
+ {file = "anyio-4.8.0-py3-none-any.whl", hash = "sha256:b5011f270ab5eb0abf13385f851315585cc37ef330dd88e27ec3d34d651fd47a"},
+ {file = "anyio-4.8.0.tar.gz", hash = "sha256:1d9fe889df5212298c0c0723fa20479d1b94883a2df44bd3897aa91083316f7a"},
]
[[package]]
@@ -726,7 +726,7 @@ name = "flexcache"
version = "0.3"
requires_python = ">=3.9"
summary = "Saves and loads to the cache a transformed versions of a source object."
-groups = ["default", "all-dev", "docs"]
+groups = ["default", "all-dev", "docs", "tests"]
dependencies = [
"typing-extensions",
]
@@ -740,7 +740,7 @@ name = "flexparser"
version = "0.4"
requires_python = ">=3.9"
summary = "Parsing made fun ... using typing."
-groups = ["default", "all-dev", "docs"]
+groups = ["default", "all-dev", "docs", "tests"]
dependencies = [
"typing-extensions",
]
@@ -831,7 +831,7 @@ files = [
name = "globalwarmingpotentials"
version = "0.11.1"
summary = "Global warming potentials of greenhouse gases from various IPCC reports"
-groups = ["all-dev", "docs"]
+groups = ["all-dev", "docs", "tests"]
files = [
{file = "globalwarmingpotentials-0.11.1-py2.py3-none-any.whl", hash = "sha256:0f208afdaea1e464b1d2f760f06ab09e3870876c48a322252536809a5087ed11"},
{file = "globalwarmingpotentials-0.11.1.tar.gz", hash = "sha256:c0d98d6fe7fb7d21bc2c17d7b41dfdf5a08b3a80778943e1f0d2f7f29372038b"},
@@ -2108,7 +2108,7 @@ name = "openscm-units"
version = "0.6.3"
requires_python = ">=3.9"
summary = "Handling of units related to simple climate modelling."
-groups = ["all-dev", "docs"]
+groups = ["all-dev", "docs", "tests"]
dependencies = [
"globalwarmingpotentials",
"numpy",
@@ -2175,7 +2175,7 @@ name = "pandas"
version = "2.2.3"
requires_python = ">=3.9"
summary = "Powerful data structures for data analysis, time series, and statistics"
-groups = ["all-dev", "docs"]
+groups = ["all-dev", "docs", "tests"]
dependencies = [
"numpy>=1.22.4; python_version < \"3.11\"",
"numpy>=1.23.2; python_version == \"3.11\"",
@@ -2361,7 +2361,7 @@ name = "pint"
version = "0.24.4"
requires_python = ">=3.9"
summary = "Physical quantities module"
-groups = ["default", "all-dev", "docs"]
+groups = ["default", "all-dev", "docs", "tests"]
dependencies = [
"flexcache>=0.3",
"flexparser>=0.4",
@@ -2389,7 +2389,7 @@ name = "platformdirs"
version = "4.3.6"
requires_python = ">=3.8"
summary = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
-groups = ["default", "all-dev", "dev", "docs"]
+groups = ["default", "all-dev", "dev", "docs", "tests"]
files = [
{file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"},
{file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"},
@@ -2500,13 +2500,13 @@ files = [
[[package]]
name = "pygments"
-version = "2.18.0"
+version = "2.19.0"
requires_python = ">=3.8"
summary = "Pygments is a syntax highlighting package written in Python."
groups = ["all-dev", "dev", "docs", "tests"]
files = [
- {file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"},
- {file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"},
+ {file = "pygments-2.19.0-py3-none-any.whl", hash = "sha256:4755e6e64d22161d5b61432c0600c923c5927214e7c956e31c23923c89251a9b"},
+ {file = "pygments-2.19.0.tar.gz", hash = "sha256:afc4146269910d4bdfabcd27c24923137a74d562a23a320a41a55ad303e19783"},
]
[[package]]
@@ -2604,7 +2604,7 @@ name = "python-dateutil"
version = "2.9.0.post0"
requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
summary = "Extensions to the standard Python datetime module"
-groups = ["all-dev", "docs", "full", "plots"]
+groups = ["all-dev", "docs", "full", "plots", "tests"]
dependencies = [
"six>=1.5",
]
@@ -2631,7 +2631,7 @@ files = [
name = "pytz"
version = "2024.2"
summary = "World timezone definitions, modern and historical"
-groups = ["all-dev", "docs"]
+groups = ["all-dev", "docs", "tests"]
files = [
{file = "pytz-2024.2-py2.py3-none-any.whl", hash = "sha256:31c7c1817eb7fae7ca4b8c7ee50c72f93aa2dd863de768e1ef4245d426aa0725"},
{file = "pytz-2024.2.tar.gz", hash = "sha256:2aa355083c50a0f93fa581709deac0c9ad65cca8a9e9beac660adcbd493c798a"},
@@ -3210,7 +3210,7 @@ name = "six"
version = "1.17.0"
requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
summary = "Python 2 and 3 compatibility utilities"
-groups = ["all-dev", "docs", "full", "plots"]
+groups = ["all-dev", "docs", "full", "plots", "tests"]
files = [
{file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"},
{file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"},
@@ -3412,7 +3412,7 @@ name = "tzdata"
version = "2024.2"
requires_python = ">=2"
summary = "Provider of IANA time zone data"
-groups = ["all-dev", "docs"]
+groups = ["all-dev", "docs", "tests"]
files = [
{file = "tzdata-2024.2-py2.py3-none-any.whl", hash = "sha256:a48093786cdcde33cad18c2555e8532f34422074448fbc874186f0abd79565cd"},
{file = "tzdata-2024.2.tar.gz", hash = "sha256:7d85cc416e9382e69095b7bdf4afd9e3880418a2413feec7069d533d6b4e31cc"},
diff --git a/pyproject.toml b/pyproject.toml
index 897d9fd..065546f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -68,6 +68,7 @@ docs = [
tests = [
"pytest>=8.3.3",
"coverage>=7.6.0",
+ "openscm-units>=0.6.3",
"pytest-cov>=5.0.0",
"pytest-regressions>=2.6.0",
"ipython>=8.18.1",
@@ -185,6 +186,9 @@ ignore = [
"docs/how-to-guides/how-to-make-a-step-forcing.py" = [
"E501",
]
+"docs/tutorials/budget_compatible_pathways.py" = [
+ "E501",
+]
"docs/tutorials/continuous_timeseries_tutorial.py" = [
"E501",
]
diff --git a/src/continuous_timeseries/budget_compatible_pathways.py b/src/continuous_timeseries/budget_compatible_pathways.py
new file mode 100644
index 0000000..76d6578
--- /dev/null
+++ b/src/continuous_timeseries/budget_compatible_pathways.py
@@ -0,0 +1,501 @@
+"""
+Creation of emissions pathways compatible with a given budget
+"""
+
+from __future__ import annotations
+
+import numpy as np
+
+from continuous_timeseries.discrete_to_continuous import InterpolationOption
+from continuous_timeseries.exceptions import MissingOptionalDependencyError
+from continuous_timeseries.time_axis import TimeAxis
+from continuous_timeseries.timeseries import Timeseries
+from continuous_timeseries.timeseries_continuous import (
+ ContinuousFunctionScipyPPoly,
+ TimeseriesContinuous,
+)
+from continuous_timeseries.typing import PINT_NUMPY_ARRAY, PINT_SCALAR
+
+
+def calculate_linear_net_zero_time(
+ budget: PINT_SCALAR,
+ budget_start_time: PINT_SCALAR,
+ emissions_start: PINT_SCALAR,
+) -> PINT_SCALAR:
+ """
+ Calculate the net-zero time, assuming a linear path to net-zero
+
+ Parameters
+ ----------
+ budget
+ Budget to match
+
+ budget_start_time
+ Time from which the budget is available.
+
+ E.g., if the budget is available from 1 Jan 2025,
+ supply `Q(2025.0, "yr")` (assuming you're working with pint quantities).
+
+ emissions_start
+ Emissions from which the linear path should start
+
+ Returns
+ -------
+ :
+ Net-zero time
+ """
+ net_zero_time: PINT_SCALAR = budget_start_time + 2 * budget / emissions_start
+
+ return net_zero_time
+
+
+def derive_linear_path(
+ budget: PINT_SCALAR,
+ budget_start_time: PINT_SCALAR,
+ emissions_start: PINT_SCALAR,
+ name_res: str | None = None,
+) -> Timeseries:
+ r"""
+ Derive a linear pathway that stays within a given budget
+
+ Parameters
+ ----------
+ budget
+ Budget to match
+
+ budget_start_time
+ Time from which the budget is available.
+
+ E.g., if the budget is available from 1 Jan 2025,
+ supply `Q(2025.0, "yr")` (assuming you're working with pint quantities).
+
+ emissions_start
+ Emissions from which the linear path should start
+
+ name_res
+ Name to use for the result.
+
+ If not supplied, we use a verbose but clear default.
+
+ Returns
+ -------
+ :
+ Linear pathway to net-zero in line with the budget
+
+ Notes
+ -----
+ We're solving for emissions, $y$, as a function of time, $x$.
+ We pick a linear emissions pathway
+
+ $$
+ y(x) = e_0 * (1 - \frac{x - x_0}{x_{nz} - x_0})
+ $$
+
+ Simplifying slightly, we have
+
+ $$
+ y(x) = e_0 * \frac{x_{nz} - x}{x_{nz} - x_0}
+ $$
+
+ where $e_0$ is emissions at the known time (normally today), $x_0$,
+ and $x_{nz}$ is the net-zero time.
+
+ By geometry, the integral of this curve between $x_0$ and $x_nz$ is:
+
+ $$
+ \frac{1}{2} (x_0 - x_{nz}) * e_0
+ $$
+
+ You can also do this with calculus:
+
+ $$
+ \begin{equation}
+ \begin{split}
+ \int_{x_0}^{x_{nz}} y(x) \, dx &= \int_{x_0}^{x_{nz}} e_0 * (x_{nz} - x) / (x_{nz} - x_0) \, dx \\
+ &= [-e_0 (x_{nz} - x)^2 / (2 * (x_{nz} - x_0))]_{x_0}^{x_{nz}} \\
+ &= -e_0 (x_{nz} - x_0)^2 / (2 * (x_{nz} - x_0))) - e_0 (x_{nz} - x_{nz})^2 / (2 * (x_{nz} - x_0))) \\
+ &= e_0 (x_0 - x_{nz}) / 2
+ \end{split}
+ \end{equation}
+ $$
+
+ This integral should be equal to the allowed buget:
+
+ $$
+ \frac{1}{2} (x_0 - x_{nz}) * e_0 = \text{budget}
+ $$
+
+ therefore
+
+ $$
+ x_{nz} = x_0 + \frac{2 * \text{budget}}{e_0}
+ $$
+ """ # noqa: E501
+ if name_res is None:
+ name_res = (
+ "Linear emissions\n"
+ f"compatible with budget of {budget:.2f}\n"
+ f"from {budget_start_time:.2f}"
+ )
+
+ net_zero_time = calculate_linear_net_zero_time(
+ budget=budget,
+ budget_start_time=budget_start_time,
+ emissions_start=emissions_start,
+ )
+
+ last_ts_time = np.floor(net_zero_time) + 2.0 * net_zero_time.to("yr").u
+
+ time_axis_bounds: PINT_NUMPY_ARRAY = np.hstack( # type: ignore # mypy confused by pint
+ [budget_start_time, net_zero_time, last_ts_time]
+ )
+ values_at_bounds: PINT_NUMPY_ARRAY = np.hstack( # type: ignore # mypy confused by pint
+ [emissions_start, 0.0 * emissions_start, 0.0 * emissions_start]
+ )
+
+ emms_linear_pathway = Timeseries.from_arrays(
+ time_axis_bounds=time_axis_bounds,
+ values_at_bounds=values_at_bounds,
+ interpolation=InterpolationOption.Linear,
+ name=name_res,
+ )
+
+ return emms_linear_pathway
+
+
+def derive_symmetric_quadratic_path(
+ budget: PINT_SCALAR,
+ budget_start_time: PINT_SCALAR,
+ emissions_start: PINT_SCALAR,
+ name_res: str | None = None,
+) -> Timeseries:
+ r"""
+ Derive a quadratic pathway that stays within a given budget
+
+ The major downside of this approach is
+ that the gradient of the output path is zero at `budget_start_time`.
+
+ Parameters
+ ----------
+ budget
+ Budget to match
+
+ budget_start_time
+ Time from which the budget is available.
+
+ E.g., if the budget is available from 1 Jan 2025,
+ supply `Q(2025.0, "yr")` (assuming you're working with pint quantities).
+
+ emissions_start
+ Emissions from which the quadratic path should start.
+
+ name_res
+ Name to use for the result.
+
+ If not supplied, we use a verbose but clear default.
+
+ Returns
+ -------
+ :
+ Symmetric quadratic pathway to net-zero in line with the budget
+
+ Notes
+ -----
+ We're solving for emissions, $y$, as a function of time, $x$.
+ We pick a quadratic emissions pathway comprised of two pieces.
+ The two pieces are equal in size and split the time period
+ from the known time (normally today), $x_0$,
+ to the net zero time, $x_{nz}$, in two.
+ For convenience, we also define the halfway to net-zero point,
+ $x_{nzh} = \frac{x_0 + x_{nz}}{2}$.
+
+ $$
+ y(x) = \begin{cases}
+ a_1 (x - x_0)^2 + b_1 (x - x_0) + c_1 &\text{if } x \leq x_{nzh} \\
+ a_2 (x - x_{nzh})^2 + b_2 (x - x_{nzh}) + c_2 &\text{otherwise}
+ \end{cases}
+ $$
+
+ Therefore
+
+ $$
+ \frac{dy}{dx}(x) = \begin{cases}
+ 2 a_1 (x - x_0) + b_1 &\text{if } x \leq x_{nzh} \\
+ 2 a_2 (x - x_{nzh}) + b_2 &\text{otherwise}
+ \end{cases}
+ $$
+
+ To set the constants, we need some boundary conditions.
+
+ At $x = x_0$, emissions should be equal to the starting emissions, $e_0$.
+ This immediately sets $c_1 = e_0$.
+
+ We also choose to set the quadratics such that
+ at the time point which is halfway to net zero,
+ emissions are half of their original value.
+ Thus, at $x = x_{nzh}$, emissions should be $e_0 / 2$.
+ This immediately sets $c_2 = e_0 / 2$.
+
+ This condition also means that the decrease in emissions should be the same
+ between $x_0$ and $x_{nzh}$ as it is between $x_{nzh}$ and $x_{nz}$.
+ We also want the gradient to be continuous
+ at the boundary between the two quadratics.
+
+ As a result, we can see that the two quadratics are simply
+ a translation and a reflection of each other
+ (they have the same change between their defining points
+ and the same gradient at the boundary between the two intervals,
+ so there are no more degrees of freedom with which we could
+ introduce different shapes),
+ i.e. they are symmetric (about a carefully chosen axis).
+
+ By the symmetry argument, we have that $a_1 = -a_2$
+ and the gradient at $x=x_0$ must equal the gradient at $x=x_{nx}$.
+ The gradient at $x=x_{nx}$ is zero, therefore
+
+ $$
+ \frac{dy}{dx}(x_0) = 2 a_1 (x_0 - x_0) + b_1 = 0.0 \Rightarrow b_1 = 0.0
+ $$
+
+ We can now use the fact that $y(x_{nzh}) = e_0 / 2$ to solve for $a_1$,
+ and therefore also $a_2$.
+
+ $$
+ \begin{equation}
+ \begin{split}
+ y(x_{nzh}) &= e_0 / 2 \\
+ a_1 (x_{nzh} - x_0)^2 + e_0 &= e_0 / 2 \\
+ a_1 &= -\frac{e_0}{2 (x_{nzh} - x_0)^2}
+ \end{split}
+ \end{equation}
+ $$
+
+ The last constant to solve for is $b_2$.
+ We solve for this using the first-order continuity constraint at the boundary.
+
+ $$
+ \frac{dy}{dx}(x_{nzh}) = 2 a_1 (x_{nzh} - x_0) = 2 a_2 (x - x_{nzh}) + b_2 \Rightarrow b_2 = 2 a_1 (x_{nzh} - x_0)
+ $$
+
+ In summary, our constants are
+
+ $$
+ \begin{equation}
+ \begin{split}
+ a_1 &= -\frac{e_0}{2 (x_{nzh} - x_0)^2} \\
+ a_2 &= -a_1 \\
+ b_1 &= 0.0 \\
+ b_2 &= 2 a_1 (x_{nzh} - x_0) \\
+ c_1 &= e_0 \\
+ c_2 &= e_0 / 2
+ \end{split}
+ \end{equation}
+ $$
+
+ The last question is, where does the net-zero year come from?
+ To answer this, first consider the integral of our function
+ from $x_0$ to $x_{nz}$
+
+ $$
+ \begin{equation}
+ \begin{split}
+ \int_{x_0}^{x_{nz}} y(x) \, dx &= \int_{x_0}^{x_{nzh}} a_1 (x - x_0)^2 + c_1 \, dx \\
+ & \quad + \int_{x_{nzh}}^{x_{nz}} a_2 (x - x_{nzh})^2 + b_2 (x - x_{nzh}) + c_2 \, dx \\
+ &= [ a_1 (x - x_0)^3 / 3 + c_1 x ]_{x_0}^{x_{nzh}} \\
+ & + [ a_2 (x - x_{nzh})^3 / 3 + b_2 (x - x_{nzh})^2 / 2 + c_2 x ]_{x_{nzh}}^{x_{nz}} \\
+ &= a_1 (x_{nzh} - x_0)^3 / 3 + c_1 (x_{nzh} - x_0) \\
+ & + a_2 (x_{nz} - x_{nzh})^3 / 3 + b_2 (x_{nz} - x_{nzh})^2 / 2 + c_2 (x_{nz} - x_{nzh})
+ \end{split}
+ \end{equation}
+ $$
+
+ We next note that
+
+ $$
+ x_{nzh} - x_0 = \frac{x_0 + x_{nz}}{2} - x_0 = \frac{x_{nz} - x_0}{2} = \frac{2 x_{nz} - (x_{nz} + x_0)}{2} = x_{nz} - x_{nzh}
+ $$
+
+ Hence the cubic terms cancel because $a_1 = -a_2$ and we are left with
+
+ $$
+ \begin{equation}
+ \begin{split}
+ \int_{x_0}^{x_{nz}} y(x) \, dx &= c_1 (x_{nzh} - x_0) + b_2 (x_{nz} - x_{nzh})^2 / 2 + c_2 (x_{nzh} - x_0) \\
+ &= e_0 (x_{nzh} - x_0) + 2 a_1 (x_{nzh} - x_0) (x_{nz} - x_{nzh})^2 / 2 + e_0 / 2 (x_{nzh} - x_0) \\
+ &= \frac{3}{2} e_0 (x_{nzh} - x_0) - 2 \frac{e_0}{2 (x_{nzh} - x_0)^2} (x_{nzh} - x_0) (x_{nz} - x_{nzh})^2 / 2 \\
+ &= \frac{3}{2} e_0 (x_{nzh} - x_0) - \frac{e_0}{2 (x_{nzh} - x_0)} (x_{nz} - x_{nzh})^2 \\
+ &= \frac{3}{2} e_0 (x_{nzh} - x_0) - \frac{e_0}{2 (x_{nzh} - x_0)} (x_{nzh} - x_0)^2 \\
+ &= \frac{3}{2} e_0 (x_{nzh} - x_0) - \frac{e_0}{2} (x_{nzh} - x_0) \\
+ &= \frac{3}{2} e_0 (x_{nzh} - x_0) - \frac{e_0}{2} (x_{nzh} - x_0) \\
+ &= e_0 (x_{nzh} - x_0) \\
+ &= e_0 (\frac{x_{nz} - x_0}{2}) \\
+ &= \frac{1}{2} e_0 (x_{nz} - x_0)
+ \end{split}
+ \end{equation}
+ $$
+
+ Put more simply,
+ the integral is simply equal to the integral of a straight-line to net-zero.
+ Hence, we can simply use the net-zero year of a linear pathway to net-zero
+ in line with the budget,
+ and our quadratic pathway will have the same cumulative emissions
+ (i.e. will also match the budget).
+
+ As a result, our recipe is:
+
+ 1. Calculate the net-zero year of a straight-line pathway to net-zero year.
+ 1. Use that net-zero year to calculate our constants.
+ """ # noqa: E501
+ try:
+ import scipy.interpolate
+ except ImportError as exc:
+ raise MissingOptionalDependencyError(
+ "derive_symmetric_quadratic_path", requirement="scipy"
+ ) from exc
+
+ if name_res is None:
+ name_res = (
+ "Symmetric quadratic emissions\n"
+ f"compatible with budget of {budget:.2f}\n"
+ f"from {budget_start_time:.2f}"
+ )
+
+ time_units = budget_start_time.u
+ values_units = emissions_start.u
+
+ net_zero_time = calculate_linear_net_zero_time(
+ budget=budget,
+ budget_start_time=budget_start_time,
+ emissions_start=emissions_start,
+ )
+
+ e_0 = emissions_start
+ x_0 = budget_start_time
+ x_nz = net_zero_time
+ x_nzh = (x_0 + x_nz) / 2.0
+
+ a_1 = -e_0 / (2.0 * (x_nzh - x_0) ** 2)
+ a_2 = -a_1
+ b_1 = 0.0 * emissions_start.u / budget_start_time.u
+ b_2 = 2.0 * a_1 * (x_nzh - x_0)
+ c_1 = e_0
+ c_2 = e_0 / 2.0
+
+ a_coeffs: PINT_NUMPY_ARRAY = np.hstack([a_1, a_2]) # type: ignore # mypy confused ay pint
+ b_coeffs: PINT_NUMPY_ARRAY = np.hstack([b_1, b_2]) # type: ignore # mypy confused by pint
+ const_terms: PINT_NUMPY_ARRAY = np.hstack([c_1, c_2]) # type: ignore # mypy confused by pint
+
+ c_non_zero: PINT_NUMPY_ARRAY = np.vstack( # type: ignore # mypy confused by pint
+ [
+ a_coeffs.to(values_units / time_units**2).m,
+ b_coeffs.to(values_units / time_units).m,
+ const_terms.to(values_units).m,
+ ]
+ )
+
+ # Ensure we have a zero tail
+ last_ts_time = np.floor(net_zero_time) + 2.0 * net_zero_time.to("yr").u
+ time_axis_bounds: PINT_NUMPY_ARRAY = np.hstack( # type: ignore # mypy confused by pint
+ [x_0, x_nzh, x_nz, last_ts_time]
+ )
+
+ x = time_axis_bounds.to(net_zero_time.u).m
+ c = np.hstack([c_non_zero, np.zeros((c_non_zero.shape[0], 1))])
+
+ ppoly = scipy.interpolate.PPoly(c=c, x=x)
+ tsc = TimeseriesContinuous(
+ name=name_res,
+ time_units=time_units,
+ values_units=values_units,
+ function=ContinuousFunctionScipyPPoly(ppoly),
+ domain=(time_axis_bounds.min(), time_axis_bounds.max()),
+ )
+ emms_quadratic_pathway = Timeseries(
+ time_axis=TimeAxis(time_axis_bounds),
+ timeseries_continuous=tsc,
+ )
+
+ return emms_quadratic_pathway
+
+
+def convert_to_annual_time_axis(ts: Timeseries) -> Timeseries:
+ """
+ Convert a timeseries to an annual time axis
+
+ This is just a convenience method.
+ It has minimal checks, so may not always produce sensible results.
+
+ Parameters
+ ----------
+ ts
+ Timeseries to convert to an annual time axis.
+
+
+ Returns
+ -------
+ :
+ `ts`, with its time axis updated so that it has annual steps.
+
+ If `ts`'s first time point is not an integer,
+ it is also included in the output time axis
+ to ensure that `ts`'s cumulative emissions are conserved.
+ """
+ annual_time_axis = (
+ np.union1d(
+ ts.time_axis.bounds.min().to("yr").m,
+ np.arange(
+ np.ceil(ts.time_axis.bounds.min()).to("yr").m,
+ np.ceil(ts.time_axis.bounds.max()).to("yr").m + 1,
+ 1.0,
+ ),
+ )
+ * ts.time_axis.bounds[0].to("yr").u
+ )
+
+ res = ts.interpolate(annual_time_axis, allow_extrapolation=True)
+
+ return res
+
+
+def convert_to_annual_constant_emissions(
+ ts: Timeseries, name_res: str | None = None
+) -> Timeseries:
+ """
+ Convert a timeseries to annual constant emissions
+
+ In other words, to annual-average emissions
+ (or annual-total, depending on how you think about emissions),
+ like what countries report.
+
+ If the time axis of `ts` starts with an integer year,
+ then you can simply sum the output emissions and you will get
+ the same cumulative emissions as `ts`.
+ If the time axis of `ts` does not start with an integer year,
+ then it is more complicated because your first time step
+ will not be a full year.
+
+ Parameters
+ ----------
+ ts
+ Timeseries to convert to an annual time axis.
+
+ name_res
+ Name to use for the result.
+
+ If not supplied, we use `f"{ts.name}_annualised"`.
+
+ Returns
+ -------
+ :
+ `ts`, with its time axis updated so that it has annual steps
+ and is piecewise constant.
+ """
+ if name_res is None:
+ name_res = f"{ts.name}_annualised"
+
+ annual_interp = convert_to_annual_time_axis(ts)
+ res = annual_interp.update_interpolation_integral_preserving(
+ InterpolationOption.PiecewiseConstantNextLeftClosed, name_res=name_res
+ )
+
+ return res
diff --git a/tests/integration/test_budget_compatible_pathways_integration.py b/tests/integration/test_budget_compatible_pathways_integration.py
new file mode 100644
index 0000000..c861d56
--- /dev/null
+++ b/tests/integration/test_budget_compatible_pathways_integration.py
@@ -0,0 +1,357 @@
+"""
+Integration tests of `continuous_timeseries.budget_compatible_pathways`
+"""
+
+from __future__ import annotations
+
+import sys
+from contextlib import nullcontext as does_not_raise
+from unittest.mock import patch
+
+import numpy as np
+import openscm_units
+import pint.testing
+import pytest
+
+from continuous_timeseries import InterpolationOption, Timeseries
+from continuous_timeseries import budget_compatible_pathways as ct_bcp
+from continuous_timeseries.exceptions import MissingOptionalDependencyError
+
+UR = openscm_units.unit_registry
+Q = UR.Quantity
+
+
+budget_cases = pytest.mark.parametrize(
+ "budget, budget_start_time, emissions_start",
+ (
+ pytest.param(
+ Q(500, "GtC"),
+ Q(2020, "yr"),
+ Q(10.2, "GtC / yr"),
+ id="ar6_global_budget_like",
+ ),
+ pytest.param(
+ Q(1.5, "GtCO2"),
+ Q(2025, "yr"),
+ Q(453.2, "MtCO2 / yr"),
+ id="country_small_budget_like",
+ ),
+ pytest.param(
+ Q(15.0, "GtCO2"),
+ Q(2025, "yr"),
+ Q(745.0, "MtCO2 / yr"),
+ id="country_bigger_budget_like",
+ ),
+ ),
+)
+
+
+@budget_cases
+@pytest.mark.parametrize(
+ "name_res",
+ (
+ pytest.param(None, id="default_name_res"),
+ pytest.param("overwritten", id="name_res_supplied"),
+ ),
+)
+def test_budget_compatible_linear_pathway_derivation(
+ name_res,
+ budget,
+ budget_start_time,
+ emissions_start,
+):
+ kwargs = {}
+ if name_res is not None:
+ kwargs["name_res"] = name_res
+
+ res = ct_bcp.derive_linear_path(
+ budget=budget,
+ budget_start_time=budget_start_time,
+ emissions_start=emissions_start,
+ **kwargs,
+ )
+
+ if name_res is None:
+ assert res.name == (
+ "Linear emissions\n"
+ f"compatible with budget of {budget:.2f}\n"
+ f"from {budget_start_time:.2f}"
+ )
+
+ else:
+ assert res.name == name_res
+
+ # First value should be input emissions
+ discrete_values = res.discrete.values_at_bounds.values
+ pint.testing.assert_equal(discrete_values[0], emissions_start)
+
+ # Last two values should be zero
+ pint.testing.assert_equal(discrete_values[-2:], 0.0 * emissions_start)
+
+ # Derivative should be constant (i.e. function is linear),
+ # except for the last two timesteps,
+ # which should both have a gradient of zero.
+ derivative_values = (
+ ct_bcp.convert_to_annual_time_axis(res)
+ .differentiate()
+ .discrete.values_at_bounds.values
+ )
+ pint.testing.assert_equal(derivative_values[:-2], derivative_values[0])
+ pint.testing.assert_equal(derivative_values[-2:], derivative_values[0] * 0.0)
+
+ # Ensure that we budget is respected
+ pint.testing.assert_allclose(
+ res.integrate(
+ Q(0, emissions_start.u * budget_start_time.u)
+ ).discrete.values_at_bounds.values[-1],
+ budget,
+ )
+ # Ensure that extrapolating post the net-zero results in zeros
+ net_zero_year = res.time_axis.bounds[1]
+ res_extrap = res.interpolate(
+ np.hstack(
+ [net_zero_year, net_zero_year + 3 * (net_zero_year - budget_start_time)]
+ ),
+ allow_extrapolation=True,
+ )
+ pint.testing.assert_equal(
+ res_extrap.discrete.values_at_bounds.values, 0.0 * emissions_start
+ )
+
+
+@budget_cases
+@pytest.mark.parametrize(
+ "name_res",
+ (
+ pytest.param(None, id="default_name_res"),
+ pytest.param("overwritten", id="name_res_supplied"),
+ ),
+)
+def test_budget_compatible_symmetric_quadratic_pathway_derivation(
+ name_res,
+ budget,
+ budget_start_time,
+ emissions_start,
+):
+ kwargs = {}
+ if name_res is not None:
+ kwargs["name_res"] = name_res
+
+ res = ct_bcp.derive_symmetric_quadratic_path(
+ budget=budget,
+ budget_start_time=budget_start_time,
+ emissions_start=emissions_start,
+ **kwargs,
+ )
+
+ if name_res is None:
+ assert res.name == (
+ "Symmetric quadratic emissions\n"
+ f"compatible with budget of {budget:.2f}\n"
+ f"from {budget_start_time:.2f}"
+ )
+
+ else:
+ assert res.name == name_res
+
+ # First value should be input emissions
+ discrete_values = res.discrete.values_at_bounds.values
+ pint.testing.assert_equal(discrete_values[0], emissions_start)
+
+ # Last two values should be zero
+ pint.testing.assert_equal(discrete_values[-2:], 0.0 * emissions_start)
+
+ # Derivative should start at zero,
+ # then climb to its peak,
+ # then be back at zero by the net-zero year (two quadratics).
+ net_zero_year = res.time_axis.bounds[-2]
+ derivative_times = np.hstack(
+ [
+ budget_start_time,
+ (budget_start_time + net_zero_year) / 2.0,
+ net_zero_year,
+ ]
+ )
+ derivative_values = (
+ res.differentiate()
+ .interpolate(derivative_times)
+ .discrete.values_at_bounds.values
+ )
+ # Zero at start and end
+ pint.testing.assert_equal(0.0 * derivative_values[0], derivative_values[[0, -1]])
+ # Peak gradient
+ nzd = (net_zero_year - budget_start_time) / 2.0
+ exp_peak_gradient = -emissions_start / nzd
+ pint.testing.assert_allclose(derivative_values.min(), exp_peak_gradient)
+
+ # Two symmetric quadratics.
+ exp_a = emissions_start / (2.0 * nzd**2)
+ exp_second_derivative_abs = 2.0 * exp_a
+ second_derivative_times = (
+ np.union1d(
+ derivative_times.to("yr").m,
+ (
+ np.linspace(
+ budget_start_time.to("yr").m,
+ net_zero_year.to("yr").m,
+ 100,
+ )
+ ),
+ )
+ * budget_start_time.to("yr").u
+ )
+ second_derivative_values = (
+ res.differentiate()
+ .differentiate()
+ .interpolate(second_derivative_times)
+ .discrete.values_at_bounds.values
+ )
+ pre_mid_point = second_derivative_times < (budget_start_time + net_zero_year) / 2.0
+ pint.testing.assert_allclose(
+ second_derivative_values[pre_mid_point], -exp_second_derivative_abs
+ )
+ pint.testing.assert_allclose(
+ second_derivative_values[~pre_mid_point][:-1], exp_second_derivative_abs
+ )
+ # At net-zero year, second derivative is zero
+ pint.testing.assert_allclose(
+ second_derivative_values[-1], 0.0 * exp_second_derivative_abs
+ )
+
+ # Ensure that we budget is respected
+ pint.testing.assert_allclose(
+ res.integrate(
+ Q(0, emissions_start.u * budget_start_time.u)
+ ).discrete.values_at_bounds.values[-1],
+ budget,
+ )
+ # Ensure that extrapolating post the net-zero results in zeros
+ res_extrap = res.interpolate(
+ np.hstack(
+ [net_zero_year, net_zero_year + 3 * (net_zero_year - budget_start_time)]
+ ),
+ allow_extrapolation=True,
+ )
+ pint.testing.assert_equal(
+ res_extrap.discrete.values_at_bounds.values, 0.0 * emissions_start
+ )
+
+
+# ensure convert_to_annual_constant_emissions works
+# for integer and non-integer start year
+# (integer start, just get steps)
+# ()
+@pytest.mark.parametrize(
+ "start_time_axis, exp_out_time_axis",
+ (
+ pytest.param(
+ Q([2010.0, 2029.5, 2030.0], "yr"),
+ Q(np.arange(2010, 2030 + 1), "yr"),
+ id="integer_start_year_integer_end_year",
+ ),
+ pytest.param(
+ Q([2010.5, 2050.3, 2052.0], "yr"),
+ Q(
+ np.hstack([2010.5, np.arange(2011.0, 2052 + 0.1)]),
+ "yr",
+ ),
+ id="non_integer_start_year_integer_end_year",
+ ),
+ pytest.param(
+ Q([2010.0, 2050.3, 2052.5], "yr"),
+ Q(np.arange(2010, 2053 + 1), "yr"),
+ id="integer_start_year_non_integer_end_year",
+ ),
+ pytest.param(
+ Q([2010.5, 2050.0, 2050.5], "yr"),
+ Q(
+ np.hstack([2010.5, np.arange(2011.0, 2051 + 0.1)]),
+ "yr",
+ ),
+ id="non_integer_start_year_non_integer_end_year",
+ ),
+ ),
+)
+@pytest.mark.parametrize(
+ "name_res",
+ (
+ pytest.param(None, id="default_name_res"),
+ pytest.param("overwritten", id="name_res_supplied"),
+ ),
+)
+def test_convert_to_annual_constant_emissions(
+ name_res, start_time_axis, exp_out_time_axis
+):
+ name_start = "name_start"
+
+ kwargs = {}
+ if name_res is not None:
+ kwargs["name_res"] = name_res
+
+ start = Timeseries.from_arrays(
+ time_axis_bounds=start_time_axis,
+ values_at_bounds=Q([1.0, 0.0, 0.0], "MtCO2 / yr"),
+ interpolation=InterpolationOption.Linear,
+ name=name_start,
+ )
+
+ res = ct_bcp.convert_to_annual_constant_emissions(start, **kwargs)
+
+ if name_res is not None:
+ assert res.name == name_res
+ else:
+ assert res.name == f"{start.name}_annualised"
+
+ pint.testing.assert_equal(res.time_axis.bounds, exp_out_time_axis)
+
+ # Result should be piecewise constant, hence
+ pint.testing.assert_allclose(
+ res.differentiate().discrete.values_at_bounds.values, Q(0.0, "MtCO2 / yr / yr")
+ )
+
+ # Check integral preserved
+ exp_integral = start.integrate(Q(0.0, "MtCO2")).discrete.values_at_bounds.values[-1]
+ pint.testing.assert_allclose(
+ exp_integral,
+ res.integrate(Q(0.0, "MtCO2")).discrete.values_at_bounds.values[-1],
+ )
+ if res.time_axis.bounds.m[0] == int(res.time_axis.bounds.m[0]):
+ # Make sure that simply adding up values gives back our budget
+ np.testing.assert_allclose(
+ exp_integral,
+ np.cumsum(res.discrete.values_at_bounds.values)
+ .to(exp_integral.u / res.time_axis.bounds.u)
+ .m[-1],
+ rtol=1e-3,
+ )
+
+ # else:
+ # # In this case, the awkward half year means that simple addition doesn't work.
+ # # There is nothing we can do about that.
+
+
+@pytest.mark.parametrize(
+ "sys_modules_patch, expectation",
+ (
+ pytest.param({}, does_not_raise(), id="scipy_available"),
+ pytest.param(
+ {"scipy": None},
+ pytest.raises(
+ MissingOptionalDependencyError,
+ match=(
+ "`derive_symmetric_quadratic_path` "
+ "requires scipy to be installed"
+ ),
+ ),
+ id="scipy_not_available",
+ ),
+ ),
+)
+def test_no_scipy_quadratic(sys_modules_patch, expectation):
+ with patch.dict(sys.modules, sys_modules_patch):
+ with expectation:
+ ct_bcp.derive_symmetric_quadratic_path(
+ budget=Q(450.0, "GtC"),
+ budget_start_time=Q(2010.0, "yr"),
+ emissions_start=Q(2.3, "GtC / yr"),
+ )
diff --git a/tests/integration/test_timeseries_integration.py b/tests/integration/test_timeseries_integration.py
index 03df0ea..ef149db 100644
--- a/tests/integration/test_timeseries_integration.py
+++ b/tests/integration/test_timeseries_integration.py
@@ -513,7 +513,13 @@ def test_discrete(operations_test_case):
@operations_test_cases
-@pytest.mark.parametrize("name_res", (None, "overwritten"))
+@pytest.mark.parametrize(
+ "name_res",
+ (
+ pytest.param(None, id="default_name_res"),
+ pytest.param("overwritten", id="name_res_supplied"),
+ ),
+)
def test_differentiate(operations_test_case, name_res):
kwargs = {}
if name_res is not None:
@@ -544,7 +550,13 @@ def test_differentiate(operations_test_case, name_res):
@operations_test_cases
-@pytest.mark.parametrize("name_res", (None, "overwritten"))
+@pytest.mark.parametrize(
+ "name_res",
+ (
+ pytest.param(None, id="default_name_res"),
+ pytest.param("overwritten", id="name_res_supplied"),
+ ),
+)
def test_integrate(operations_test_case, name_res):
kwargs = {}
if name_res is not None:
@@ -840,7 +852,13 @@ def get_test_update_interpolation_piecewise_constant_to_higher_order_cases() ->
),
),
)
-@pytest.mark.parametrize("name_res", (None, "overwritten"))
+@pytest.mark.parametrize(
+ "name_res",
+ (
+ pytest.param(None, id="default_name_res"),
+ pytest.param("overwritten", id="name_res_supplied"),
+ ),
+)
def test_update_interpolation( # noqa: PLR0913
name_res,
start_interp,
@@ -955,7 +973,13 @@ def get_test_update_interpolation_integral_preserving_cases() -> tuple[pytest.pa
"start_interp, end_interp, exp_successful, kwargs",
get_test_update_interpolation_integral_preserving_cases(),
)
-@pytest.mark.parametrize("name_res", (None, "overwritten"))
+@pytest.mark.parametrize(
+ "name_res",
+ (
+ pytest.param(None, id="default_name_res"),
+ pytest.param("overwritten", id="name_res_supplied"),
+ ),
+)
def test_update_interpolation_integral_preserving(
name_res, start_interp, end_interp, exp_successful, kwargs
):
diff --git a/uv.lock b/uv.lock
index b558697..cc5011d 100644
--- a/uv.lock
+++ b/uv.lock
@@ -336,7 +336,7 @@ wheels = [
[[package]]
name = "continuous-timeseries"
-version = "0.2.0"
+version = "0.2.1a1"
source = { editable = "." }
dependencies = [
{ name = "attrs" },
@@ -427,6 +427,7 @@ docs = [
tests = [
{ name = "coverage" },
{ name = "ipython" },
+ { name = "openscm-units" },
{ name = "pytest" },
{ name = "pytest-cov" },
{ name = "pytest-regressions" },
@@ -511,6 +512,7 @@ docs = [
tests = [
{ name = "coverage", specifier = ">=7.6.0" },
{ name = "ipython", specifier = ">=8.18.1" },
+ { name = "openscm-units", specifier = ">=0.6.3" },
{ name = "pytest", specifier = ">=8.3.3" },
{ name = "pytest-cov", specifier = ">=5.0.0" },
{ name = "pytest-regressions", specifier = ">=2.6.0" },