Mass balance parameter perturbation experiments with OGGM#

By now, we assume that you have read and run the mass balance calibration tutorial. We know that it is a lot of new information to take in!

In this notebook, we will:

  • re-iterate and discuss the important role that mass balance calibration plays in the projections

  • give you some tools to run parameter perturbation experiments (this will also illustrate useful aspects of the OGGM internals)

  • provide some keys about how to address the calibration process in your use case

Set-up#

import matplotlib.pyplot as plt
import matplotlib
import pandas as pd
import xarray as xr
import numpy as np
import os

import oggm
from oggm import cfg, utils, workflow, tasks, graphics
from oggm.core import massbalance
from oggm.core.massbalance import mb_calibration_from_scalar_mb, mb_calibration_from_geodetic_mb, mb_calibration_from_wgms_mb
cfg.initialize(logging_level='WARNING')
cfg.PATHS['working_dir'] = utils.gettempdir(dirname='OGGM-calib-perturbation', reset=True)
cfg.PARAMS['border'] = 80
2026-07-20 13:21:36: oggm.cfg: Reading default parameters from the OGGM `params.cfg` configuration file.
2026-07-20 13:21:36: oggm.cfg: Multiprocessing switched OFF according to the parameter file.
2026-07-20 13:21:36: oggm.cfg: Multiprocessing: using all available processors (N=4)

We start from our two well known glaciers in the Austrian Alps, Kesselwandferner and Hintereisferner. But you can also choose any other glacier, e.g. from this list.

# we start from preprocessing level 5
base_url = 'https://cluster.klima.uni-bremen.de/~oggm/gdirs/oggm_v1.6/L3-L5_files/2025.6/elev_bands/W5E5/per_glacier/'

gdirs = workflow.init_glacier_directories(['RGI60-11.00787',  'RGI60-11.00897'], from_prepro_level=5, prepro_base_url=base_url)
2026-07-20 13:21:37: oggm.workflow: init_glacier_directories from prepro level 5 on 2 glaciers.
2026-07-20 13:21:37: oggm.workflow: Execute entity tasks [gdir_from_prepro] on 2 glaciers

Changing the mass balance parameters for the mass balance model#

We just downloaded the data. Let’s have a look at the calibrated parameters for these glaciers:

# Pick each glacier
gdir_kwf = gdirs[0]
gdir_hef = gdirs[1]
gdir_hef.read_json('mb_calib')
{'rgi_id': 'RGI60-11.00897',
 'bias': 0,
 'melt_f': 5.0,
 'prcp_fac': 3.570255710475343,
 'temp_bias': 1.705035916562294,
 'reference_mb': -1100.3,
 'reference_mb_err': 171.8,
 'reference_period': '2000-01-01_2020-01-01',
 'mb_global_params': {'temp_default_gradient': -0.0065,
  'temp_all_solid': 0.0,
  'temp_all_liq': 2.0,
  'temp_melt': -1.0},
 'baseline_climate_source': 'GSWP3_W5E5'}
gdir_kwf.read_json('mb_calib')
{'rgi_id': 'RGI60-11.00787',
 'bias': 0,
 'melt_f': 5.0,
 'prcp_fac': 2.922352594344337,
 'temp_bias': 1.705035916562294,
 'reference_mb': -514.8000000000001,
 'reference_mb_err': 136.3,
 'reference_period': '2000-01-01_2020-01-01',
 'mb_global_params': {'temp_default_gradient': -0.0065,
  'temp_all_solid': 0.0,
  'temp_all_liq': 2.0,
  'temp_melt': -1.0},
 'baseline_climate_source': 'GSWP3_W5E5'}

These parameters are stored in a file called mb_calib.json in the glacier directory. This file is then read by the mass balance model when created:

mbmod = massbalance.MonthlyTIModel(gdir_hef)
mbmod.calib_params
filesuffix: None
data: {'parent_filesuffix': 'cfg.PARAMS'}

Therefore, if you want to mess around with these parameters, “all you have to do” is to overwrite this file somehow. Or you create a new one and ask the mass balance model to read it instead of the default one. Let’s do that:

params = gdir_hef.read_json('mb_calib')
params['melt_f'] = 7  # a new value
gdir_hef.write_json(params, 'mb_calib', filesuffix='_perturbed')  # write a new file, with perturbed parameters

We can read it in with:

mbmod_perturbed = massbalance.MonthlyTIModel(gdir_hef, mb_params_filesuffix='_perturbed')
mbmod_perturbed.calib_params
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[9], line 1
----> 1 mbmod_perturbed = massbalance.MonthlyTIModel(gdir_hef, mb_params_filesuffix='_perturbed')
      2 mbmod_perturbed.calib_params

TypeError: MonthlyTIModel.__init__() got an unexpected keyword argument 'mb_params_filesuffix'

Just for fun, check what this means for the mass balance:

h = np.linspace(2000, 3800, 80)
mb_default = mbmod.get_annual_mb(h, year=1980) * cfg.SEC_IN_YEAR * cfg.PARAMS['ice_density']
mb_perturbed = mbmod_perturbed.get_annual_mb(h, year=1980) * cfg.SEC_IN_YEAR * cfg.PARAMS['ice_density']
plt.plot(mb_default, h);
plt.plot(mb_perturbed, h);
plt.xlabel('Mass balance (mm w.e)'); plt.ylabel('Elevation');
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Cell In[10], line 3
      1 h = np.linspace(2000, 3800, 80)
      2 mb_default = mbmod.get_annual_mb(h, year=1980) * cfg.SEC_IN_YEAR * cfg.PARAMS['ice_density']
----> 3 mb_perturbed = mbmod_perturbed.get_annual_mb(h, year=1980) * cfg.SEC_IN_YEAR * cfg.PARAMS['ice_density']
      4 plt.plot(mb_default, h);
      5 plt.plot(mb_perturbed, h);
      6 plt.xlabel('Mass balance (mm w.e)'); plt.ylabel('Elevation');

NameError: name 'mbmod_perturbed' is not defined

So far so good. But how to feed this into the heavy OGGM pipeline? Many OGGM users will be more familiar with the run_* entity tasks. These tasks “hide” the process of creating the mass balance model and therefore make it look like we cant’s change anything internally.

We could have added a mechanism to pass the mb_params_filesuffix from, for example, run_random_climate, to the underlying mass balance model (similar to the “climate_input_filesuffix” mechanism). We may add this one day, but for now I’d like to use this opportunity to demonstrate another possible mechanism:

# So far so good: default run with the default mass balance
tasks.run_random_climate(gdir_hef, y0=2000, nyears=100, seed=1, output_filesuffix='_default');
# Let's create another "mass balance model" which is like the default one but with another default parameter
from functools import partial
PerturbedMassBalance = partial(massbalance.MonthlyTIModel, mb_params_filesuffix='_perturbed')

# Pass it to the run task
tasks.run_random_climate(gdir_hef, y0=2000, nyears=100, seed=1, mb_model_class=PerturbedMassBalance, output_filesuffix='_perturbed');
2026-07-20 13:21:38: oggm.core.flowline: TypeError occurred during task run_random_climate_perturbed on RGI60-11.00897: MonthlyTIModel.__init__() got an unexpected keyword argument 'mb_params_filesuffix'
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[12], line 6
      2 from functools import partial
      3 PerturbedMassBalance = partial(massbalance.MonthlyTIModel, mb_params_filesuffix='_perturbed')
      4 
      5 # Pass it to the run task
----> 6 tasks.run_random_climate(gdir_hef, y0=2000, nyears=100, seed=1, mb_model_class=PerturbedMassBalance, output_filesuffix='_perturbed');

File /usr/local/pyenv/versions/3.13.13/lib/python3.13/site-packages/oggm/utils/_workflow.py:522, in entity_task.__call__.<locals>._entity_task(gdir, reset, print_log, return_value, continue_on_error, add_to_log_file, **kwargs)
    520     signal.alarm(gdir.settings['task_timeout'])
    521 ex_t = time.time()
--> 522 out = task_func(gdir, **kwargs)
    523 ex_t = time.time() - ex_t
    524 if gdir.settings['task_timeout'] > 0:

File /usr/local/pyenv/versions/3.13.13/lib/python3.13/site-packages/oggm/core/flowline.py:4158, in run_random_climate(gdir, settings_filesuffix, nyears, y0, halfsize, ys, ye, bias, seed, temperature_bias, precipitation_factor, store_monthly_step, store_model_geometry, store_fl_diagnostics, mb_model_class, climate_filename, climate_input_filesuffix, flowlines_filesuffix, output_filesuffix, init_model_fls, init_model_filesuffix, init_model_yr, zero_initial_glacier, unique_samples, **kwargs)
   4058 @entity_task(log)
   4059 def run_random_climate(gdir, settings_filesuffix='',
   4060                        nyears=1000, y0=None, halfsize=15,
   (...)   4074                        zero_initial_glacier=False,
   4075                        unique_samples=False, **kwargs):
   4076     """Runs the random mass balance model for a given number of years.
   4077 
   4078     This will initialize a
   (...)   4155         kwargs to pass to the flowline_model_run task
   4156     """
-> 4158     mb_model = MultipleFlowlineMassBalance(gdir,
   4159                                            mb_model_class=partial(
   4160                                                RandomMassBalance,
   4161                                                mb_model_class=mb_model_class),
   4162                                            y0=y0, halfsize=halfsize,
   4163                                            bias=bias, seed=seed,
   4164                                            filename=climate_filename,
   4165                                            input_filesuffix=climate_input_filesuffix,
   4166                                            flowlines_filesuffix=flowlines_filesuffix,
   4167                                            unique_samples=unique_samples,
   4168                                            settings_filesuffix=settings_filesuffix)
   4170     if temperature_bias is not None:
   4171         mb_model.temp_bias += temperature_bias

File /usr/local/pyenv/versions/3.13.13/lib/python3.13/site-packages/oggm/core/massbalance.py:3494, in MultipleFlowlineMassBalance.__init__(self, gdir, settings_filesuffix, fls, mb_model_class, use_inversion_flowlines, flowlines_filesuffix, input_filesuffix, **kwargs)
   3490     if rgi_filesuffix is not None:
   3491         kwargs['input_filesuffix'] = rgi_filesuffix
   3493     self.flowline_mb_models.append(
-> 3494         mb_model_class(
   3495             gdir=gdir,
   3496             settings_filesuffix=settings_filesuffix,
   3497             **kwargs,
   3498         )
   3499     )
   3501 self.valid_bounds = self.flowline_mb_models[-1].valid_bounds
   3502 self.hemisphere = gdir.hemisphere

File /usr/local/pyenv/versions/3.13.13/lib/python3.13/site-packages/oggm/core/massbalance.py:3197, in RandomMassBalance.__init__(self, gdir, mb_model_class, y0, halfsize, seed, all_years, unique_samples, prescribe_years, **kwargs)
   3195 super().__init__()
   3196 self.valid_bounds = [-1e4, 2e4]  # in m
-> 3197 self.mbmod = mb_model_class(gdir=gdir, **kwargs)
   3199 # Climate period
   3200 self.prescribe_years = prescribe_years

TypeError: MonthlyTIModel.__init__() got an unexpected keyword argument 'mb_params_filesuffix'

The partial function allows to create a function that is created by fixing a certain number of arguments of another function. Here we create a new “class” which is the same as the default original one, but by setting one parameter to another value. This proves very useful here, since we are just tricking OGGM into using the new one!

Let’s check the outcome:

with xr.open_dataset(gdir_hef.get_filepath('model_diagnostics', filesuffix='_default')) as ds:
    ds_default = ds.load()
with xr.open_dataset(gdir_hef.get_filepath('model_diagnostics', filesuffix='_perturbed')) as ds:
    ds_perturbed = ds.load()
---------------------------------------------------------------------------
KeyError                                  Traceback (most recent call last)
File /usr/local/pyenv/versions/3.13.13/lib/python3.13/site-packages/xarray/backends/file_manager.py:219, in CachingFileManager._acquire_with_cache_info(self, needs_lock)
    218 try:
--> 219     file = self._cache[self._key]
    220 except KeyError:

File /usr/local/pyenv/versions/3.13.13/lib/python3.13/site-packages/xarray/backends/lru_cache.py:56, in LRUCache.__getitem__(self, key)
     55 with self._lock:
---> 56     value = self._cache[key]
     57     self._cache.move_to_end(key)

KeyError: [<class 'netCDF4._netCDF4.Dataset'>, ('/tmp/OGGM/OGGM-calib-perturbation/per_glacier/RGI60-11/RGI60-11.00/RGI60-11.00897/model_diagnostics_perturbed.nc',), 'r', (('clobber', True), ('diskless', False), ('format', 'NETCDF4'), ('persist', False)), 'a720d81d-6542-4013-b3b9-30a147955f70']

During handling of the above exception, another exception occurred:

FileNotFoundError                         Traceback (most recent call last)
Cell In[13], line 3
      1 with xr.open_dataset(gdir_hef.get_filepath('model_diagnostics', filesuffix='_default')) as ds:
      2     ds_default = ds.load()
----> 3 with xr.open_dataset(gdir_hef.get_filepath('model_diagnostics', filesuffix='_perturbed')) as ds:
      4     ds_perturbed = ds.load()

File /usr/local/pyenv/versions/3.13.13/lib/python3.13/site-packages/xarray/backends/api.py:607, in open_dataset(filename_or_obj, engine, chunks, cache, decode_cf, mask_and_scale, decode_times, decode_timedelta, use_cftime, concat_characters, decode_coords, drop_variables, create_default_indexes, inline_array, chunked_array_type, from_array_kwargs, backend_kwargs, **kwargs)
    595 decoders = _resolve_decoders_kwargs(
    596     decode_cf,
    597     open_backend_dataset_parameters=backend.open_dataset_parameters,
   (...)    603     decode_coords=decode_coords,
    604 )
    606 overwrite_encoded_chunks = kwargs.pop("overwrite_encoded_chunks", None)
--> 607 backend_ds = backend.open_dataset(
    608     filename_or_obj,
    609     drop_variables=drop_variables,
    610     **decoders,
    611     **kwargs,
    612 )
    613 ds = _dataset_from_backend_dataset(
    614     backend_ds,
    615     filename_or_obj,
   (...)    626     **kwargs,
    627 )
    628 return ds

File /usr/local/pyenv/versions/3.13.13/lib/python3.13/site-packages/xarray/backends/netCDF4_.py:771, in NetCDF4BackendEntrypoint.open_dataset(self, filename_or_obj, mask_and_scale, decode_times, concat_characters, decode_coords, drop_variables, use_cftime, decode_timedelta, group, mode, format, clobber, diskless, persist, auto_complex, lock, autoclose)
    749 def open_dataset(
    750     self,
    751     filename_or_obj: T_PathFileOrDataStore,
   (...)    768     autoclose=False,
    769 ) -> Dataset:
    770     filename_or_obj = _normalize_path(filename_or_obj)
--> 771     store = NetCDF4DataStore.open(
    772         filename_or_obj,
    773         mode=mode,
    774         format=format,
    775         group=group,
    776         clobber=clobber,
    777         diskless=diskless,
    778         persist=persist,
    779         auto_complex=auto_complex,
    780         lock=lock,
    781         autoclose=autoclose,
    782     )
    784     store_entrypoint = StoreBackendEntrypoint()
    785     with close_on_error(store):

File /usr/local/pyenv/versions/3.13.13/lib/python3.13/site-packages/xarray/backends/netCDF4_.py:529, in NetCDF4DataStore.open(cls, filename, mode, format, group, clobber, diskless, persist, auto_complex, lock, lock_maker, autoclose)
    525 else:
    526     manager = CachingFileManager(
    527         netCDF4.Dataset, filename, mode=mode, kwargs=kwargs, lock=lock
    528     )
--> 529 return cls(manager, group=group, mode=mode, lock=lock, autoclose=autoclose)

File /usr/local/pyenv/versions/3.13.13/lib/python3.13/site-packages/xarray/backends/netCDF4_.py:429, in NetCDF4DataStore.__init__(self, manager, group, mode, lock, autoclose)
    427 self._group = group
    428 self._mode = mode
--> 429 self.format = self.ds.data_model
    430 self._filename = self.ds.filepath()
    431 self.is_remote = is_remote_uri(self._filename)

File /usr/local/pyenv/versions/3.13.13/lib/python3.13/site-packages/xarray/backends/netCDF4_.py:538, in NetCDF4DataStore.ds(self)
    536 @property
    537 def ds(self):
--> 538     return self._acquire()

File /usr/local/pyenv/versions/3.13.13/lib/python3.13/site-packages/xarray/backends/netCDF4_.py:532, in NetCDF4DataStore._acquire(self, needs_lock)
    531 def _acquire(self, needs_lock=True):
--> 532     with self._manager.acquire_context(needs_lock) as root:
    533         ds = _nc4_require_group(root, self._group, self._mode)
    534     return ds

File /usr/local/pyenv/versions/3.13.13/lib/python3.13/contextlib.py:141, in _GeneratorContextManager.__enter__(self)
    139 del self.args, self.kwds, self.func
    140 try:
--> 141     return next(self.gen)
    142 except StopIteration:
    143     raise RuntimeError("generator didn't yield") from None

File /usr/local/pyenv/versions/3.13.13/lib/python3.13/site-packages/xarray/backends/file_manager.py:207, in CachingFileManager.acquire_context(self, needs_lock)
    204 @contextmanager
    205 def acquire_context(self, needs_lock: bool = True) -> Iterator[T_File]:
    206     """Context manager for acquiring a file."""
--> 207     file, cached = self._acquire_with_cache_info(needs_lock)
    208     try:
    209         yield file

File /usr/local/pyenv/versions/3.13.13/lib/python3.13/site-packages/xarray/backends/file_manager.py:225, in CachingFileManager._acquire_with_cache_info(self, needs_lock)
    223     kwargs = kwargs.copy()
    224     kwargs["mode"] = self._mode
--> 225 file = self._opener(*self._args, **kwargs)
    226 if self._mode == "w":
    227     # ensure file doesn't get overridden when opened again
    228     self._mode = "a"

File src/netCDF4/_netCDF4.pyx:2521, in netCDF4._netCDF4.Dataset.__init__()
-> 2521 'Could not get source, probably due dynamically evaluated source code.'

File src/netCDF4/_netCDF4.pyx:2158, in netCDF4._netCDF4._ensure_nc_success()
-> 2158 'Could not get source, probably due dynamically evaluated source code.'

FileNotFoundError: [Errno 2] No such file or directory: '/tmp/OGGM/OGGM-calib-perturbation/per_glacier/RGI60-11/RGI60-11.00/RGI60-11.00897/model_diagnostics_perturbed.nc'
ds_default.volume_m3.plot(label='Default')
ds_perturbed.volume_m3.plot(label='Perturbed')
plt.legend();
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Cell In[14], line 2
      1 ds_default.volume_m3.plot(label='Default')
----> 2 ds_perturbed.volume_m3.plot(label='Perturbed')
      3 plt.legend();

NameError: name 'ds_perturbed' is not defined
../../_images/4bb98c78fe11e1f412e198a6002bceadfaff2e1672e0cebf043260986222d1ce.png

Quite a big difference for “just” 2 units of melt factor more, from 5 to 7!

More structured parameters perturbation experiments#

OK, so let’s say we want to do this “at scale”. We actually had such an assignment recently for the PROTECT SLR project. We were asked to do a number of perturbed simulations with parameters diverging from their default values, for example +1 temp_bias everywhere. But how to do this, knowing that each glacier has a different temp_bias? We can’t simply set the bias to 1 everywhere (we need +=1).

For this I wrote a “task”, originally outside of OGGM but that is now (v1.6.3) part of the main codebase. Let’s have a look at it:


@entity_task(log, writes=['mb_calib'])
def perturbate_mb_params(gdir, perturbation=None, reset_default=False, filesuffix=''):
    """Replaces pre-calibrated MB params with perturbed ones for this glacier.

    It simply replaces the existing `mb_calib.json` file with an
    updated one with perturbed parameters. The original ones
    are stored in the file for re-use after perturbation.

    Users can change the following 4 parameters:
    - 'melt_f': unit [kg m-2 day-1 K-1], the melt factor
    - 'prcp_fac': unit [-], the precipitation factor
    - 'temp_bias': unit [K], the temperature correction applied to the timeseries
    - 'bias': unit [mm we yr-1], *substracted* from the computed MB. Rarely used.

    All parameter perturbations are additive, i.e. the value
    provided by the user is added to the *precalibrated* value.
    For example, `temp_bias=1` means that the temp_bias used by the
    model will be the precalibrated one, plus 1 Kelvin.

    The only exception is prpc_fac, which is multiplicative.
    For example prcp_fac=1 will leave the precalibrated prcp_fac unchanged,
    while 2 will double it.

    Parameters
    ----------
    perturbation : dict
        the parameters to change and the associated value (see doc above)
    reset_default : bool
        reset the parameters to their original value. This might be
        unnecessary if using the filesuffix mechanism.
    filesuffix : str
        write the modified parameters in a separate mb_calib.json file
        with the filesuffix appended. This can then be read by the
        MassBalanceModel for example instead of the default one.
        Note that it's always the default, precalibrated params
        file which is read to start with.
    """
    df = gdir.read_json('mb_calib')

    # Save original params if not there
    if 'bias_orig' not in df:
        for k in ['bias', 'melt_f', 'prcp_fac', 'temp_bias']:
            df[k + '_orig'] = df[k]

    if reset_default:
        for k in ['bias', 'melt_f', 'prcp_fac', 'temp_bias']:
            df[k] = df[k + '_orig']
        gdir.write_json(df, 'mb_calib', filesuffix=filesuffix)
        return df

    for k, v in perturbation.items():
        if k == 'prcp_fac':
            df[k] = df[k + '_orig'] * v
        elif k in ['bias', 'melt_f', 'temp_bias']:
            df[k] = df[k + '_orig'] + v
        else:
            raise InvalidParamsError(f'Perturbation not valid: {k}')

    gdir.write_json(df, 'mb_calib', filesuffix=filesuffix)
    return df

It’s a fairly easy piece of code isn’t it? Let’s apply it in a latin hypercube parameter set where we change all parameters in a structured way:

from scipy.stats import qmc
sampler = qmc.LatinHypercube(d=3)
sample = sampler.random(n=30)

def log_scale_value(value, low, high):
    """This is to sample multiplicative factors in log space to avoid asymetry (detail, but important)."""
    return 2**((np.log2(high) - np.log2(low))*value + np.log2(low))

sample[:,0] = 4*sample[:,0] - 2  # DDF factor (melt_f): apply change [-2, +2] mm/(°C day)
sample[:,1] = 2*sample[:,1] - 1  # temperature bias (temp_bias): apply change [-1, +1] °C
sample[:,2] = log_scale_value(sample[:,2], 0.5, 2)  # precipitation scaling factor (prcp_fac): apply scaling [0.5, 2] on log2

params_df = pd.DataFrame(sample, columns=['melt_f', 'temp_bias', 'prcp_fac'])
params_df.plot.scatter(x='temp_bias', y='prcp_fac');
../../_images/9e34688e33c3003d26d89c4f6d2bece7662530510ecf9ef96e5fe03d4c5341b5.png
cfg.set_logging_config('CRITICAL')  # shut down log output (bad!)
cfg.PARAMS['continue_on_error'] = True

for exp in range(len(params_df)):
    params = params_df.loc[exp]
    # clim_params = {k: params[k] for k in ('temp_bias', 'prcp_fac', 'melt_f')}
    exp = f'{exp:02d}'
    workflow.execute_entity_task(tasks.perturbate_mb_params, gdirs, perturbation=params, filesuffix=f'_{exp}')

    PerturbedMassBalance = partial(massbalance.MonthlyTIModel, mb_params_filesuffix=f'_{exp}')
    
    workflow.execute_entity_task(tasks.run_random_climate, gdirs,          
                                 y0=2000,
                                 nyears=100,
                                 seed=1,
                                 mb_model_class=PerturbedMassBalance,
                                 output_filesuffix=f'_{exp}',  # recognize the run for later
                                 );
out_df = pd.DataFrame()
for exp in range(len(params_df)):
    try:
        ds = utils.compile_run_output(gdirs, input_filesuffix=f'_{exp:02d}')
        if np.any(ds.volume.isnull()):
            continue
        out_df[f'{exp:02d}'] = ds.volume.sum(dim='rgi_id').to_series()
    except RuntimeError:
        pass
out_df.plot(legend=False, color='k', alpha=0.5);
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[18], line 1
----> 1 out_df.plot(legend=False, color='k', alpha=0.5);

File /usr/local/pyenv/versions/3.13.13/lib/python3.13/site-packages/pandas/plotting/_core.py:1185, in PlotAccessor.__call__(self, *args, **kwargs)
   1182             label_name = label_kw or data.columns
   1183             data.columns = label_name
-> 1185 return plot_backend.plot(data, kind=kind, **kwargs)

File /usr/local/pyenv/versions/3.13.13/lib/python3.13/site-packages/pandas/plotting/_matplotlib/__init__.py:71, in plot(data, kind, **kwargs)
     69         kwargs["ax"] = getattr(ax, "left_ax", ax)
     70 plot_obj = PLOT_CLASSES[kind](data, **kwargs)
---> 71 plot_obj.generate()
     72 plt.draw_if_interactive()
     73 return plot_obj.result

File /usr/local/pyenv/versions/3.13.13/lib/python3.13/site-packages/pandas/plotting/_matplotlib/core.py:516, in MPLPlot.generate(self)
    514 @final
    515 def generate(self) -> None:
--> 516     self._compute_plot_data()
    517     fig = self.fig
    518     self._make_plot(fig)

File /usr/local/pyenv/versions/3.13.13/lib/python3.13/site-packages/pandas/plotting/_matplotlib/core.py:716, in MPLPlot._compute_plot_data(self)
    714 # no non-numeric frames or series allowed
    715 if is_empty:
--> 716     raise TypeError("no numeric data to plot")
    718 self.data = numeric_data.apply(type(self)._convert_to_ndarray)

TypeError: no numeric data to plot

Parameters perturbation experiments which match observations#

The section above is nice, but works only for a problem setting where we don’t ask the mass balance model to match observations. If we were to match observations, things would be quite different!

To do this, we could define a new task very much like the above, but this time realizing a calibration step before writing its solution down.

This exercise is left to the reader ;-)

What’s next?#