Repository: calliope-project/calliope (language: python)

Issue:
Move time series aggregation to an external module
## Problem description

To reduce complexity of Calliope's core code, we only want a hook for time series aggregation and resampling, rather than actually doing it ourselves.

The external module could be:

* Our own current code moved out of the Calliope core
* [tsam](https://github.com/FZJ-IEK3-VSA/tsam)

TODO:
- [ ] Remove all complex clustering algorithms from core (inc. masking).
- [ ] Move time resampling to `model.resample_time`.
- [ ] Make it possible to cluster the timeseries using a user-defined set of cluster IDs (the functionality already exists, we just need to move the definition to `model.cluster_time`. 
- [ ] Keep a config to switch enable inter-cluster storage when using clustering (e.g. `model.include_inter_cluster_storage`, default is True).
- [ ] Update docs to tell people to prepare cluster IDs themselves using e.g. [tsam](https://github.com/FZJ-IEK3-VSA/tsam).
- [ ] Make hardcoded sum/mean of data on resampling explicit for every input parameter.
- [ ] Move hardcoded sum/mean of data on resampling (calliope/time/funcs.py:294 ea89a660742a04c80486fe34a4d8c49e230805ac) to a model_data variable attribute (ideally, this would be encoded in the typedconfig rules). 
- [ ] Document justification for sum/mean of input parameters on resampling.


Discussion / hints:

Fix patch:
diff --git a/changelog.rst b/changelog.rst
index 3b9cf9926..e92d7ec4a 100644
--- a/changelog.rst
+++ b/changelog.rst
@@ -16,6 +16,8 @@ User-facing changes
 
 |new| Files containing user-defined mathematical formulations can be referenced in the model configuration. User-defined mathematical formulations must follow the new Calliope YAML math syntax (see "Internal changes" below).
 
+|changed| |backwards-incompatible| Time masking and clustering capabilities have been severely reduced. Time resampling and clustering are now accessible by top-level configuration keys: e.g., `config.init.time_resample: 2H`, `config.init.time_cluster: cluster_file.csv`. Clustering is simplified to only matching model dates to representative days, with those representative days being in the clustered timeseries. Masking/clustering data should now be undertaken by the user prior to initialising a Calliope model.
+
 |changed| |backwards-incompatible| `Locations` (abbreviated to `locs`) are now referred to as `nodes` (no abbreviation). For users, this requires updating the top-level YAML key "locations" to "nodes" and accessing data in `model.inputs` and `model.results` on the set "nodes" rather than "locs".
 
 |changed| |backwards-incompatible| The `loc::tech` and `loc::tech::carrier` sets have been removed. Model components are now indexed separately over `node`, `tech`, and `carrier` (where applicable). Although primarily an internal change, this affects the xarray dataset structure and hence how users access data in `model.inputs` and `model.results`. For example, `model.inputs.energy_cap_max.loc[{"loc_techs": "X:pv"}]` in v0.6 needs to be changed to `model.inputs.energy_cap_max.loc[{"nodes": "X", "techs": "pv"}]` in v0.7. This is functionally equivalent to first calling `model.get_formatted_array("energy_cap_max")` in v0.6, which is no longer necessary in v0.7.
@@ -343,14 +345,14 @@ Internal changes
 0.6.0 (2018-04-20)
 ------------------
 
-Version 0.6.0 is an almost complete rewrite of most of Calliope's internals. See :doc:`user/ref_05_to_06` for a more detailed description of the many changes.
+Version 0.6.0 is an almost complete rewrite of most of Calliope's internals. See https://calliope.readthedocs.io/en/v0.6.0/user/whatsnew.html for a more detailed description of the many changes.
 
 Major changes
 ~~~~~~~~~~~~~
 
 |changed| |backwards-incompatible| Substantial changes to model configuration format, including more verbose names for most settings, and removal of run configuration files.
 
-|new| |backwards-incompatible| Complete rewrite of Pyomo backend, including new various new and improved functionality to interact with a built model (see :doc:`user/ref_05_to_06`).
+|new| |backwards-incompatible| Complete rewrite of Pyomo backend, including new various new and improved functionality to interact with a built model (see https://calliope.readthedocs.io/en/v0.6.0/user/whatsnew.html).
 
 |new| Addition of a ``calliope convert`` CLI tool to convert 0.5.x models to 0.6.0.
 
diff --git a/doc/_static/custom_math/annual_energy_balance.yaml b/doc/_static/custom_math/annual_energy_balance.yaml
index b26b07cdc..e1641f87f 100644
--- a/doc/_static/custom_math/annual_energy_balance.yaml
+++ b/doc/_static/custom_math/annual_energy_balance.yaml
@@ -8,7 +8,7 @@
 # annual_flow_max (if summing over technologies and/or nodes)
 # flow_max_group (if summing over technologies and/or nodes)
 
-# helper functions used
+# helper functions used:
 # inheritance (where)
 # sum (expression)
 
diff --git a/doc/_static/custom_math/chp_htp.yaml b/doc/_static/custom_math/chp_htp.yaml
index e9edbcc80..ef64b7a71 100644
--- a/doc/_static/custom_math/chp_htp.yaml
+++ b/doc/_static/custom_math/chp_htp.yaml
@@ -65,7 +65,7 @@
 # power_to_heat_ratio
 # boiler_eff
 
-# helper functions used
+# helper functions used:
 # reduce_carrier_dim (expression)
 
 constraints:
diff --git a/doc/_static/custom_math/demand_share_per_timestep_decision.yaml b/doc/_static/custom_math/demand_share_per_timestep_decision.yaml
index cbd95f27a..57fee2a3a 100644
--- a/doc/_static/custom_math/demand_share_per_timestep_decision.yaml
+++ b/doc/_static/custom_math/demand_share_per_timestep_decision.yaml
@@ -12,7 +12,7 @@
 # demand_share_limit.
 # decide_demand_share <- Link supply technologies to the demand technology they are going to be a share of.
 
-# helper functions used
+# helper functions used:
 # sum (expression)
 # select_from_lookup_arrays (expression)
 
diff --git a/doc/_static/custom_math/fuel_dist.yaml b/doc/_static/custom_math/fuel_dist.yaml
index e9f5b31f3..786e275ed 100644
--- a/doc/_static/custom_math/fuel_dist.yaml
+++ b/doc/_static/custom_math/fuel_dist.yaml
@@ -10,7 +10,7 @@
 # fuel_distributor_costs
 # allow_fuel_distribution <- lookup array with a value of `True` for each carrier where you want to track its distribution
 
-# helper functions used
+# helper functions used:
 # any (where)
 # sum (expression)
 
diff --git a/doc/_static/custom_math/max_time_varying.yaml b/doc/_static/custom_math/max_time_varying.yaml
index 2aaeb0462..f39650249 100644
--- a/doc/_static/custom_math/max_time_varying.yaml
+++ b/doc/_static/custom_math/max_time_varying.yaml
@@ -7,7 +7,7 @@
 # flow_cap_max_relative_per_ts
 # storage_max_relative_per_ts
 
-# helper functions used
+# helper functions used:
 # reduce_carrier_dim (expression)
 
 constraints:
diff --git a/doc/_static/custom_math/net_import_share.yaml b/doc/_static/custom_math/net_import_share.yaml
index 66aacc775..8c103ee42 100644
--- a/doc/_static/custom_math/net_import_share.yaml
+++ b/doc/_static/custom_math/net_import_share.yaml
@@ -5,7 +5,7 @@
 # New top-level parameters:
 # net_import_share
 
-# helper functions used
+# helper functions used:
 # defined (where)
 # sum (expression)
 # get_transmission_techs (expression)
diff --git a/doc/_static/custom_math/piecewise_linear_efficiency.yaml b/doc/_static/custom_math/piecewise_linear_efficiency.yaml
index 147a46353..3d2403100 100644
--- a/doc/_static/custom_math/piecewise_linear_efficiency.yaml
+++ b/doc/_static/custom_math/piecewise_linear_efficiency.yaml
@@ -6,7 +6,7 @@
 # flow_eff_piecewise_slopes (defining the new parameter `pieces`)
 # flow_eff_piecewise_intercept (defining the new parameter `pieces`)
 
-# helper functions used
+# helper functions used:
 # reduce_carrier_dim (expression)
 
 variables:
diff --git a/doc/_static/custom_math/share_all_timesteps.yaml b/doc/_static/custom_math/share_all_timesteps.yaml
index 1b1fbe908..a56e9c314 100644
--- a/doc/_static/custom_math/share_all_timesteps.yaml
+++ b/doc/_static/custom_math/share_all_timesteps.yaml
@@ -7,7 +7,7 @@
 # demand_share_equals
 # supply_share_equals
 
-# helper functions used
+# helper functions used:
 # sum (expression)
 # reduce_carrier_dim (expression)
 
diff --git a/doc/_static/custom_math/share_per_timestep.yaml b/doc/_static/custom_math/share_per_timestep.yaml
index bc19df8ba..85766798a 100644
--- a/doc/_static/custom_math/share_per_timestep.yaml
+++ b/doc/_static/custom_math/share_per_timestep.yaml
@@ -7,7 +7,7 @@
 # demand_share_per_timestep_equals
 # supply_share_per_timestep_equals
 
-# helper functions used
+# helper functions used:
 # sum (expression)
 # reduce_carrier_dim (expression)
 
diff --git a/doc/_static/custom_math/uptime_downtime_limits.yaml b/doc/_static/custom_math/uptime_downtime_limits.yaml
index ecaf9f2fa..a3f2f6189 100644
--- a/doc/_static/custom_math/uptime_downtime_limits.yaml
+++ b/doc/_static/custom_math/uptime_downtime_limits.yaml
@@ -12,7 +12,7 @@
 # downtime_periods (from CSV as a timeseries)
 # uptime_limit
 
-# helper functions used
+# helper functions used:
 # sum (expression)
 # reduce_carrier_dim (expression)
 
diff --git a/doc/_static/notebooks/calliope_model_object.ipynb b/doc/_static/notebooks/calliope_model_object.ipynb
index 01911fb1b..21e8316fb 100644
--- a/doc/_static/notebooks/calliope_model_object.ipynb
+++ b/doc/_static/notebooks/calliope_model_object.ipynb
@@ -1841,11 +1841,11 @@
        "group_resource_area_equals:\n",
        "</dd><dt><span>allow_operate_mode :</span></dt><dd>1</dd><dt><span>model_config :</span></dt><dd>calliope_version: 0.6.10\n",
        "name: Urban-scale example model\n",
-       "subset_time:\n",
+       "time_subset:\n",
        "- &#x27;2005-07-01&#x27;\n",
        "- &#x27;2005-07-02&#x27;\n",
-       "timeseries_data_path: /Users/brynmorp/Repos/calliope-project/calliope/calliope/example_models/urban_scale/timeseries_data\n",
-       "timeseries_dateformat: &#x27;%Y-%m-%d %H:%M:%S&#x27;\n",
+       "time_data_path: /Users/brynmorp/Repos/calliope-project/calliope/calliope/example_models/urban_scale/timeseries_data\n",
+       "time_format: &#x27;%Y-%m-%d %H:%M:%S&#x27;\n",
        "</dd><dt><span>run_config :</span></dt><dd>backend: pyomo\n",
        "bigM: 1000000.0\n",
        "cyclic_storage: true\n",
@@ -4595,11 +4595,11 @@
        "group_resource_area_equals:\n",
        "</dd><dt><span>allow_operate_mode :</span></dt><dd>1</dd><dt><span>model_config :</span></dt><dd>calliope_version: 0.6.10\n",
        "name: Urban-scale example model\n",
-       "subset_time:\n",
+       "time_subset:\n",
        "- &#x27;2005-07-01&#x27;\n",
        "- &#x27;2005-07-02&#x27;\n",
-       "timeseries_data_path: /Users/brynmorp/Repos/calliope-project/calliope/calliope/example_models/urban_scale/timeseries_data\n",
-       "timeseries_dateformat: &#x27;%Y-%m-%d %H:%M:%S&#x27;\n",
+       "time_data_path: /Users/brynmorp/Repos/calliope-project/calliope/calliope/example_models/urban_scale/timeseries_data\n",
+       "time_format: &#x27;%Y-%m-%d %H:%M:%S&#x27;\n",
        "</dd><dt><span>run_config :</span></dt><dd>backend: pyomo\n",
        "bigM: 1000000.0\n",
        "cyclic_storage: true\n",
diff --git a/doc/_static/notebooks/milp.ipynb b/doc/_static/notebooks/milp.ipynb
index dfe257483..970df4f07 100644
--- a/doc/_static/notebooks/milp.ipynb
+++ b/doc/_static/notebooks/milp.ipynb
@@ -1953,11 +1953,11 @@
        "group_resource_area_equals:\n",
        "</dd><dt><span>allow_operate_mode :</span></dt><dd>1</dd><dt><span>model_config :</span></dt><dd>calliope_version: 0.6.10\n",
        "name: Urban-scale example model with MILP\n",
-       "subset_time:\n",
+       "time_subset:\n",
        "- &#x27;2005-07-01&#x27;\n",
        "- &#x27;2005-07-02&#x27;\n",
-       "timeseries_data_path: /Users/brynmorp/Repos/calliope-project/calliope/calliope/example_models/urban_scale/timeseries_data\n",
-       "timeseries_dateformat: &#x27;%Y-%m-%d %H:%M:%S&#x27;\n",
+       "time_data_path: /Users/brynmorp/Repos/calliope-project/calliope/calliope/example_models/urban_scale/timeseries_data\n",
+       "time_format: &#x27;%Y-%m-%d %H:%M:%S&#x27;\n",
        "</dd><dt><span>run_config :</span></dt><dd>backend: pyomo\n",
        "bigM: 1000000.0\n",
        "cyclic_storage: true\n",
diff --git a/doc/_static/notebooks/national_scale.ipynb b/doc/_static/notebooks/national_scale.ipynb
index 903ba059c..57297e42c 100644
--- a/doc/_static/notebooks/national_scale.ipynb
+++ b/doc/_static/notebooks/national_scale.ipynb
@@ -2228,11 +2228,11 @@
        "group_resource_area_equals:\n",
        "</dd><dt><span>allow_operate_mode :</span></dt><dd>1</dd><dt><span>model_config :</span></dt><dd>calliope_version: 0.6.10\n",
        "name: National-scale example model\n",
-       "subset_time:\n",
+       "time_subset:\n",
        "- &#x27;2005-01-01&#x27;\n",
        "- &#x27;2005-01-05&#x27;\n",
-       "timeseries_data_path: /Users/brynmorp/Repos/calliope-project/calliope/calliope/example_models/national_scale/timeseries_data\n",
-       "timeseries_dateformat: &#x27;%Y-%m-%d %H:%M:%S&#x27;\n",
+       "time_data_path: /Users/brynmorp/Repos/calliope-project/calliope/calliope/example_models/national_scale/timeseries_data\n",
+       "time_format: &#x27;%Y-%m-%d %H:%M:%S&#x27;\n",
        "</dd><dt><span>run_config :</span></dt><dd>backend: pyomo\n",
        "bigM: 1000000.0\n",
        "cyclic_storage: true\n",
diff --git a/doc/_static/notebooks/urban_scale.ipynb b/doc/_static/notebooks/urban_scale.ipynb
index 4a0c9b5af..1d9208316 100644
--- a/doc/_static/notebooks/urban_scale.ipynb
+++ b/doc/_static/notebooks/urban_scale.ipynb
@@ -2490,11 +2490,11 @@
        "group_resource_area_equals:\n",
        "</dd><dt><span>allow_operate_mode :</span></dt><dd>1</dd><dt><span>model_config :</span></dt><dd>calliope_version: 0.6.10\n",
        "name: Urban-scale example model\n",
-       "subset_time:\n",
+       "time_subset:\n",
        "- &#x27;2005-07-01&#x27;\n",
        "- &#x27;2005-07-02&#x27;\n",
-       "timeseries_data_path: /Users/brynmorp/Repos/calliope-project/calliope/calliope/example_models/urban_scale/timeseries_data\n",
-       "timeseries_dateformat: &#x27;%Y-%m-%d %H:%M:%S&#x27;\n",
+       "time_data_path: /Users/brynmorp/Repos/calliope-project/calliope/calliope/example_models/urban_scale/timeseries_data\n",
+       "time_format: &#x27;%Y-%m-%d %H:%M:%S&#x27;\n",
        "</dd><dt><span>run_config :</span></dt><dd>backend: pyomo\n",
        "bigM: 1000000.0\n",
        "cyclic_storage: true\n",
diff --git a/doc/api/api.rst b/doc/api/api.rst
index 85b47d7b2..ee5a2aece 100644
--- a/doc/api/api.rst
+++ b/doc/api/api.rst
@@ -11,20 +11,6 @@ Model class
 .. autoclass:: calliope.Model
     :members:
 
-.. _api_time_masks:
-
-Time series
-===========
-
-.. automodule:: calliope.time.clustering
-    :members: get_clusters
-
-.. automodule:: calliope.time.masks
-    :members: extreme, extreme_diff
-
-.. automodule:: calliope.time.funcs
-    :members: resample
-
 .. _api_backend_interface:
 
 Methods to interface with the optimisation problem (a.k.a., `backend`)
diff --git a/doc/user/advanced_features.rst b/doc/user/advanced_features.rst
index 1846d1c43..692b80a36 100644
--- a/doc/user/advanced_features.rst
+++ b/doc/user/advanced_features.rst
@@ -4,7 +4,7 @@ Advanced features
 
 Once you're comfortable with :doc:`building <building>`, :doc:`running <running>`, and :doc:`analysing <analysing>` one of the built-in example models, you may want to explore Calliope's advanced functionality. With these features, you will be able to build and run complex models in no time.
 
-.. _time_clustering:
+.. _time_resolution_adjust:
 
 Time resolution adjustment
 --------------------------
@@ -15,66 +15,45 @@ Models have a default timestep length (defined implicitly by the timesteps of th
 
     config:
         init:
-            time:
-                function: resample
-                function_options: {'resolution': '6H'}
+            time_resample: 6H
 
 In the above example, this would resample all time series data to 6-hourly timesteps.
+Any `pandas-compatible rule describing the target resolution <https://pandas.pydata.org/pandas-docs/stable/generated/pandas.DataFrame.resample.html>`_ can be used.
 
-Calliope's time resolution adjustment functionality allows running a function that can perform arbitrary adjustments to the time series data in the model.
-
-The available options include:
-
-1. Uniform time resolution reduction through the ``resample`` function, which takes a `pandas-compatible rule describing the target resolution <https://pandas.pydata.org/pandas-docs/stable/generated/pandas.DataFrame.resample.html>`_ (see above example).
-
-2. Deriving representative days from the input time series, by applying the clustering method implemented in :mod:`calliope.time.clustering`, for example:
-
-.. code-block:: yaml
-
-    config:
-        init:
-            time:
-                function: apply_clustering
-                function_options:
-                    clustering_func: kmeans
-                    how: mean
-                    k: 20
-
-When using representative days, a number of additional constraints are added, based on the study undertaken by `Kotzur et al <https://doi.org/10.1016/j.apenergy.2018.01.023>`_. These constraints require a new decision variable ``storage_inter_cluster``, which tracks storage between all the dates of the original timeseries. This particular functionality can be disabled by including :yaml:`storage_inter_cluster: false` in the `function_options` given above.
-
-.. note::
+.. _time_clustering:
 
-    It is also possible to load user-defined representative days, by pointing to a file in `clustering_func` in the same format as pointing to timeseries files in constraints, e.g. :yaml:`clustering_func: file=clusters.csv:column_name`. Clusters are unique per datestep, so the clustering file is most readable if the index is at datestep resolution. But, the clustering file index can be in timesteps (e.g. if sharing the same file as a constraint timeseries), with the cluster number repeated per timestep in a day. Cluster values should be integer, starting at zero.
+Timeseries clustering
+---------------------
 
-3. Heuristic selection of time steps, that is, the application of one or more of the masks defined in :mod:`calliope.time.masks`, which will mark areas of the time series to retain at maximum resolution (unmasked) and areas where resolution can be lowered (masked). Options can be passed to the masking functions by specifying ``options``. A ``time.function`` can still be specified and will be applied to the masked areas (i.e. those areas of the time series not selected to remain at the maximum resolution), as in this example, which looks for the week of minimum and maximum potential wind generation (assuming a ``wind`` technology was specified), then reduces the rest of the input time series to 6-hourly resolution:
+By supplying a file linking dates in your model timeseries with representative days, it is possible to cluster your timeseries:
 
 .. code-block:: yaml
 
     config:
         init:
-            time:
-                masks:
-                    - {function: extreme, options: {padding: 'calendar_week', tech: 'wind', how: 'max'}}
-                    - {function: extreme, options: {padding: 'calendar_week', tech: 'wind', how: 'min'}}
-                function: resample
-                function_options: {'resolution': '6H'}
+            time_cluster: cluster_days.csv
 
-.. Warning::
+When using representative days, a number of additional constraints are added, based on the study undertaken by `Kotzur et al <https://doi.org/10.1016/j.apenergy.2018.01.023>`_.
+These constraints require a new decision variable ``storage_inter_cluster``, which tracks storage between all the dates of the original timeseries.
+This particular functionality can be enabled by including :yaml:`storage_inter_cluster` in your list of custom math.
 
-  When using time clustering or time masking, the resulting timesteps will be assigned different weights depending on how long a period of time they represent. Weights are used for example to give appropriate weight to the operational costs of aggregated typical days in comparison to individual extreme days, if both exist in the same processed time series. The weighting is accessible in the model data, e.g. through :python:`model.inputs.timestep_weights`. The interpretation of results when weights are not 1 for all timesteps requires caution. Production values are not scaled according to weights, but costs are multiplied by weight, in order to weight different timesteps appropriately in the objective function. This means that costs and production values are not consistent without manually post-processing them by either multipyling production by weight (production would then be inconsistent with capacity) or dividing costs by weight. The computation of levelised costs and of capacity factors takes weighting into account, so these values are consisten and can be used as usual.
+We no longer provide the functionality to infer representative days from your timeseries.
+Instead, we recommend you use other timeseries processing tools applied to your input CSV data or your build model dataset (`model.inputs`).
 
-.. seealso::
-
-  See the implementation of constraints in :mod:`calliope.backend.pyomo.constraints` for more detail on timestep weights and how they affect model constraints.
-
-Setting a random seed
----------------------
+.. note::
 
-By specifying :yaml:`model.random_seed` in the model configuration, any alphanumeric string can be used to initialise the random number generator at the very start of model processing.
+    Resampling and clustering can be applied together.
+    Resampling of your timeseries will take place _before_ clustering.
 
-This is useful for full reproducibility of model results where time series clustering is used, as clustering methods such as k-means depend on randomly generated initial conditions.
+.. warning::
 
-Note that this affects only the random number generator used in Calliope's model preprocessing and not in any way the solver used to solve the model (any solver-specific options need to be set specifically for that solver; see :ref:`solver_options`).
+   When using time clustering, the resulting timesteps will be assigned different weights depending on how long a period of time they represent.
+   Weights are used for example to give appropriate weight to the operational costs of aggregated typical days in comparison to individual extreme days, if both exist in the same processed time series.
+   The weighting is accessible in the model data, e.g. through :python:`model.inputs.timestep_weights`.
+   The interpretation of results when weights are not 1 for all timesteps requires caution.
+   Production values are not scaled according to weights, but costs are multiplied by weight, in order to weight different timesteps appropriately in the objective function.
+   This means that costs and production values are not consistent without manually post-processing them by either multiplying production by weight (production would then be inconsistent with capacity) or dividing costs by weight.
+   The computation of levelised costs and of capacity factors takes weighting into account, so these values are consistent and can be used as usual.
 
 .. _tech_groups:
 
diff --git a/doc/user/building.rst b/doc/user/building.rst
index 19a6261c6..ae11dcddc 100644
--- a/doc/user/building.rst
+++ b/doc/user/building.rst
@@ -78,8 +78,8 @@ We will discuss each of these in turn, starting with ``config``:
     config:
         init:
             name: 'My energy model'
-            timeseries_data_path: 'timeseries_data'
-            subset_time: ['2005-01-01', '2005-01-05']
+            time_data_path: 'timeseries_data'
+            time_subset: ['2005-01-01', '2005-01-05']
         build:
             mode: plan
         solve:
@@ -94,15 +94,15 @@ At each of these stages you can override what you have put in your YAML file (or
 .. code-block:: python
 
     # Overriding `config.init` items in `calliope.Model`
-    model = calliope.Model("path/to/model.yaml", subset_time=["2005-01", "2005-02"])
+    model = calliope.Model("path/to/model.yaml", time_subset=["2005-01", "2005-02"])
     # Overriding `config.build` items in `calliope.Model.build`
     model.build(ensure_feasibility=True)
     # Overriding `config.solve` items in `calliope.Model.solve`
     model.solve(save_logs="path/to/logs/dir")
 
-None of the configuration options are _required_ as there is a default value for them all, but you will likely want to set `init.name`, `init.calliope_version`, `init.timeseries_data_path`, `build.mode`, and `solve.solver`.
+None of the configuration options are _required_ as there is a default value for them all, but you will likely want to set `init.name`, `init.calliope_version`, `init.time_data_path`, `build.mode`, and `solve.solver`.
 
-To test your model pipeline, `config.init.subset_time` is a good way to limit your model size by slicing the time dimension to a smaller range.
+To test your model pipeline, `config.init.time_subset` is a good way to limit your model size by slicing the time dimension to a smaller range.
 
 `config.build.mode`
 ^^^^^^^^^^^^^^^^^^^
@@ -273,7 +273,7 @@ Reading in CSV files is possible from both the command-line tool as well running
 
 Reading in CSV files
 ^^^^^^^^^^^^^^^^^^^^
-To read in CSV files, specify e.g., :yaml:`source: file=filename.csv` to pick the desired CSV file from within the configured timeseries data path (``model.timeseries_data_path``).
+To read in CSV files, specify e.g., :yaml:`source: file=filename.csv` to pick the desired CSV file from within the configured timeseries data path (``model.time_data_path``).
 
 By default, Calliope looks for a column in the CSV file with the same name as the location. It is also possible to specify a column to use when setting ``source`` per location, by giving the column name with a colon following the filename: :yaml:`source: file=filename.csv:column`
 
@@ -291,7 +291,7 @@ For example, a simple photovoltaic (PV) tech using a time series of hour-by-hour
             source: file=pv_resource.csv
             flow_cap_max: 10000  # kW
 
-By default, Calliope expects time series data in a model to be indexed by ISO 8601 compatible time stamps in the format ``YYYY-MM-DD hh:mm:ss``, e.g. ``2005-01-01 00:00:00``. This can be changed by setting :yaml:`model.timeseries_dateformat` based on ``strftime` directives <https://strftime.org/>`_, which defaults to ``'%Y-%m-%d %H:%M:%S'``.
+By default, Calliope expects time series data in a model to be indexed by ISO 8601 compatible time stamps in the format ``YYYY-MM-DD hh:mm:ss``, e.g. ``2005-01-01 00:00:00``. This can be changed by setting :yaml:`model.time_format` based on ``strftime` directives <https://strftime.org/>`_, which defaults to ``"ISO8601"``.
 
 For example, the first few lines of a CSV file, called ``pv_resource.csv`` giving a source potential for two locations might look like this, with the first column in the file always being read as the date-time index:
 
@@ -417,9 +417,9 @@ To make it easier to run a given model multiple times with slightly changed sett
         high_cost:
             techs.onshore_wind.costs.monetary.flow_cap: 2000
         year2005:
-            model.subset_time: ['2005-01-01', '2005-12-31']
+            model.time_subset: ['2005-01-01', '2005-12-31']
         year2006:
-            model.subset_time: ['2006-01-01', '2006-12-31']
+            model.time_subset: ['2006-01-01', '2006-12-31']
 
     config:
         ...
diff --git a/doc/user/develop.rst b/doc/user/develop.rst
index f78be1bd6..eba21e659 100644
--- a/doc/user/develop.rst
+++ b/doc/user/develop.rst
@@ -45,26 +45,6 @@ We use the code formatter `black <https://github.com/psf/black/>`_ and before yo
 
    $ pre-commit install
 
----------------------------
-Creating modular extensions
----------------------------
-
-As of version 0.6.0, dynamic loading of custom constraint generator extensions has been removed due it not not being used by users of Calliope. The ability to dynamically load custom functions to adjust time resolution remains (see below).
-
-Time functions and masks
-------------------------
-
-Custom functions that adjust time resolution can be loaded dynamically during model initialisation. By default, Calliope first checks whether the name of a function or time mask refers to a function from the :mod:`calliope.core.time.masks` or :mod:`calliope.core.time.funcs` module, and if not, attempts to load the function from an importable module:
-
-.. code-block:: yaml
-
-   time:
-      masks:
-          - {function: week, options: {day_func: 'extreme', tech: 'wind', how: 'min'}}
-          - {function: my_custom_module.my_custom_mask, options: {...}}
-      function: my_custom_module.my_custom_function
-      function_options: {...}
-
 ----------------------------------------------
 Understanding Calliope internal implementation
 ----------------------------------------------
diff --git a/doc/user/ref_05_to_06.rst b/doc/user/ref_05_to_06.rst
deleted file mode 100644
index 86d36ed7a..000000000
--- a/doc/user/ref_05_to_06.rst
+++ /dev/null
@@ -1,505 +0,0 @@
-=============================
-Migrating from v0.5 to v0.6.0
-=============================
-
-.. Note:: This page summarises changes in version 0.6.0 compared to previous versions. For changes made since version 0.6.0, consult the :doc:`release history <../history>`.
-
-Version `0.6` is backwards incompatible with version `0.5`. If you are familiar with how Calliope functions then this page will act as a reference for moving to version `0.6`.
-
----------------------
-Removed functionality
----------------------
-
-If you require any of the removed functionality, we recommend you `open an issue on GitHub <https://github.com/calliope-project/calliope/issues>`_ for it to be built into a later revision of `0.6`.
-
-Technology constraints
-======================
-
-* `s_time` (providing a minimum/maximum/exact time of stored energy available for discharge) no longer exists. This constraint was relatively unpredictable in its effects when providing any combination of `s_cap`, `e_cap`, `c_rate` and time clustering.
-
-* The variable `r2` (providing a secondary resource that could be used by a supply/supply_plus technology), along with all its constraints, have been removed. To utilise multiple resource inputs, conversion_plus can be used instead.
-
-* `r_scale_to_peak` (allowing a user to provide a value for the peak resource to which the entire time series would be scaled accordingly) has been removed. `resource_scale` (previously `r_scale`) can still be used for scaling resource values by the given scale factor.
-
-* `weight` (giving a technology a disproportionate weight in the objective function calculation) has been removed.
-
-Custom objectives
-=================
-
-The ability to load additional constraints or objectives has been removed. It is still possible to define a custom objective, but to load it, a modeller needs to use a development installation of Calliope and load the function manually.
-
-.. seealso:: :doc:`develop`
-
----------------------
-Updated functionality
----------------------
-
-Verbosity
-=========
-
-Almost all sets, constraints, costs, and variables have been updated to be more verbose, making models more readable. The primary updates are:
-
-Sets
-----
-
-- `y` -> `techs`
-- `x` -> `nodes`
-- `c` -> `carriers`
-- `k` -> `costs`
-
-Constraints/Costs
------------------
-
-- `e` -> `energy`, e.g. `e_cap` -> `energy_cap`
-- `r` -> `resource`, e.g. `r_cap` -> `resource_cap`
-- `s` -> `storage`, e.g. `s_cap` -> `storage_cap`
-- `c_rate` -> `charge_rate`
-- `p_eff` -> `parasitic_eff`
-
-Variables
----------
-
-- `r` -> `resource_con`: an output from the model giving how much of a resource was consumed
-- `r` -> `resource`: the available resource as an input parameter to the model
-- `c_prod`/`c_con` -> `carrier_prod`/`carrier_con`: The produced/consumed carrier energy in each time storage_cap
-
-Model and run configuration
-===========================
-
-`run.yaml` no longer exists. Instead, all information needed to run a model is now stored in `model.yaml` under the headings `model` and `run`.
-
-`run` *only* contains information about the solver: which one to use and any specific solver options to apply.
-
-`model` contains all other information: time subsetting, model mode, output format, parallel runs, and time clustering.
-
-To solve a model, point to the `model.yaml` file, e.g.: ``calliope run path/to/model.yaml``.
-
-.. _0.6_overrides:
-
-Overrides
-=========
-
-Overrides are no longer applied within `run.yaml` (or even `model.yaml`). Instead, overrides are grouped and placed into a separate YAML file, called for example `overrides.yaml` (as of version 0.6.3, they are in a top-level ``overrides`` key in the model configuration, so the informatiom below no longer applies to v0.6.3 and later).
-
-Each group defines any number of overrides to the technology, location, link, model, or run definitions. One or several such groups can then be applied when solving a model, e.g.:
-
-`overrides.yaml`:
-
-.. code-block:: yaml
-
-    higher_costs:
-        techs.ccgt.costs.monetary.energy_cap: 10
-        locations.region2.techs.csp.costs.monetary.energy_cap: 100
-    winter:
-        model.subset_time: ['2005-01-01', '2005-02-28']
-
-Running in the command line:
-
-.. code-block:: shell
-
-    calliope run model.yaml --override_file=overrides.yaml:higher_costs
-
-    calliope run model.yaml --override_file=overrides.yaml:higher_costs,winter
-
-Running interactively:
-
-.. code-block:: python
-
-    # only apply the 'higher_costs' override group
-    model = calliope.Model(
-        'model.yaml',
-        override_file='overrides.yaml:higher_costs'
-    )
-
-    # apply both the 'higher_costs' and 'winter' overrides
-    model2 = calliope.Model(
-        'model.yaml',
-        override_file='overrides.yaml:higher_costs,winter'
-    )
-
-As in version `0.5`, overrides can be applied when creating a `Model` object, via the argument `override_dict`. A dictionary can then be given:
-
-.. code-block:: python
-
-    higher_costs = {
-        'techs.ccgt.costs.monetary.energy_cap': 10,
-        'locations.region2.techs.csp.costs.monetary.energy_cap': 100
-    }
-
-    model = calliope.Model('model.yaml', override_dict=higher_costs)
-
-Parallel runs
-=============
-
-Building on the simplified way to define overrides (see above) and on lessons learnt during the development of Calliope so far, the functionality to generate multiple runs to run either on a single machine or in parallel on a high-performance cluster has been greatly simplified and improved.
-
-.. seealso:: :ref:`generating_scripts`
-
-Location and technology subsets
-===============================
-
-In model configuration, `subset_x` and `subset_y` (subsetting the used locations and technologies, respectively) no longer exist. `subset_t`, now called `subset_time`, does still exist.
-
-To remove specific technologies or locations from a model, the new and much more powerful ``exists`` option can be used.
-
-.. seealso:: :ref:`removing_techs_locations`
-
-Technology definition
-=====================
-
-A technology is now defined in three parts: `essentials`, `constraints`, and `costs`. All top-level definitions (`parent`, `carrier_out`, etc.) are now given under `essentials` and cannot be defined per-location -- they are defined only once for a given technology and apply model-wide. Both `constraints` and `costs` remain the same as in `0.5`, but with more verbose naming:
-
-Old:
-
-.. code-block:: yaml
-
-    supply_grid_power:
-        name: 'National grid import'
-        parent: supply
-        carrier: power
-        constraints:
-            r: inf
-            e_cap.max: 2000
-        costs:
-            monetary:
-                e_cap: 15
-                om_fuel: 0.1
-
-New:
-
-.. code-block:: yaml
-
-    supply_grid_power:
-        essentials:
-            name: 'National grid import'
-            parent: supply
-            carrier: electricity
-        constraints:
-            resource: inf
-            energy_cap_max: 2000
-            lifetime: 25
-        costs:
-            monetary:
-                interest_rate: 0.10
-                energy_cap: 15
-                om_con: 0.1
-
-Carrier ratios and export carriers have also been moved from essentials into constraints:
-
-Old:
-
-.. code-block:: yaml
-
-    chp:
-        name: 'Combined heat and power'
-        stack_weight: 100
-        parent: conversion_plus
-        export: true
-        primary_carrier_out: power
-        carrier_in: gas
-        carrier_out: power
-        carrier_out_2:
-            heat: 0.8
-        constraints:
-            e_cap.max: 1500
-            e_eff: 0.405
-        costs:
-            monetary:
-                e_cap: 750
-                om_var: 0.004
-                export: file=export_power.csv
-
-New:
-
-.. code-block:: yaml
-
-    chp:
-        essentials:
-            name: 'Combined heat and power'
-            parent: conversion_plus
-            primary_carrier_out: electricity
-            carrier_in: gas
-            carrier_out: electricity
-            carrier_out_2: heat
-        constraints:
-            export_carrier: electricity
-            energy_cap_max: 1500
-            energy_eff: 0.405
-            carrier_ratios.carrier_out_2.heat: 0.8
-            lifetime: 25
-        costs:
-            monetary:
-                interest_rate: 0.10
-                energy_cap: 750
-                om_prod: 0.004
-                export: file=export_power.csv
-
-Per distance constraints and costs have now been incorporated under the constraints and costs keys, with a '_per_distance' suffix:
-
-Old:
-
-.. code-block:: yaml
-
-    heat_pipes:
-        name: 'District heat distribution'
-        parent: transmission
-        carrier: heat
-        constraints:
-            e_cap.max: 2000
-        constraints_per_distance:
-            e_loss: 0.025
-        costs_per_distance:
-            monetary:
-                e_cap: 0.3
-
-New:
-
-.. code-block:: yaml
-
-    heat_pipes:
-        essentials:
-            name: 'District heat distribution'
-            parent: transmission
-            carrier: heat
-        constraints:
-            energy_cap_max: 2000
-            energy_eff_per_distance: 0.975
-            lifetime: 25
-        costs:
-            monetary:
-                interest_rate: 0.10
-                energy_cap_per_distance: 0.3
-
-Interest rates and life times
-=============================
-
-As seen in the above examples, technology lifetime and interest rate must now be defined for each technology, under `costs`. In version `0.5`, technologies not defining these would silently use implicit default values of 0.10 for interest rate and 25 years for life time. Setting these explicitly for any technology which has investment costs (i.e. those which are not `om_`... or `export`) is now mandatory; no default values exist any more.
-
-Location definition
-===================
-
-In version `0.5`, location definitions included a list of technologies to permit at that location(s). An additional `overrides` key permitted per-location changes to model-wide technology definitions.
-
-In `0.6`, "overriding" refers only to model-wide overrides applied :ref:`as described above <0.6_overrides>`. At each location, `techs` simply lists all allowed technologies and any possible changes to model-wide configuration values to apply at this location only, as shown below:
-
-Old:
-
-.. code-block:: yaml
-
-    locations:
-        region1:
-            techs: [ccgt, csp]
-            overrides:
-                ccgt:
-                    constraints:
-                        energy_cap: 100
-
-New:
-
-.. code-block:: yaml
-
-    locations:
-        region1:
-            techs:
-                ccgt:
-                    constraints:
-                        energy_cap: 100
-                # Note that csp must be listed to be permitted here,
-                # even though it has no location-specific configuration.
-                csp:
-
-Loading time series data from CSV files
-=======================================
-
-`x_map` (mapping a technology name to a column in a CSV file) has been removed. Instead, a user can define the time series file column when defining the file name, separated from the file name by a `:`. If no column name is provided, Calliope will look for a column with the location name.
-
-Old:
-
-.. code-block:: yaml
-
-    # will look for the column `demand` in the file `demand_heat_r.csv`
-    locations:
-        region1:
-            techs: [demand_power]
-                overrides:
-                    demand_power:
-                        x_map: demand
-                        constraints:
-                            r: file
-
-New:
-
-.. code-block:: yaml
-
-    # will look for the column `demand` in the file `demand_heat_r.csv`
-    locations:
-        region1:
-            techs:
-                demand_power:
-                    constraints:
-                        resource: file=demand_heat.csv:demand
-
-Link definition
-===============
-
-Links have remained much the same as before. However, there is a slightly different structure in defining technologies, bringing the definition of link technologies more in line with the rest of the model configuration format.
-
-Old:
-
-.. code-block:: yaml
-
-    links:
-        region1,region2:
-            ac_transmission:
-                constraints:
-                    e_cap: 1000
-
-New:
-
-.. code-block:: yaml
-
-    links:
-        region1,region2:
-            techs:
-                ac_transmission:
-                    constraints:
-                        energy_cap: 1000
-
-Location metadata
-=================
-
-Location coordinates, previously given under the `metadata` key, are now given directly per location:
-
-Old:
-
-.. code-block:: yaml
-
-    metadata:
-        # metadata given in cartesian coordinates, not lat, lon.
-        map_boundary:
-            lower_left:
-                x: 0
-                y: 0
-            upper_right:
-                x: 1
-                y: 1
-        location_coordinates:
-            region1: {x: 2, y: 7}
-            region2: {x: 8, y: 7}
-
-New:
-
-.. code-block:: yaml
-
-    locations:
-        region1:
-            techs:
-                ccgt:
-                csp:
-            coordinates: {x: 2, y: 7}
-        region2:
-            techs:
-                demand_power:
-            coordinates: {x: 8, y: 7}
-
-
-``group_share`` constraint
-==========================
-
-The ``group_fraction`` constraint is now called ``group_share`` and has a different formulation more in line with the rest of the tech-specific constraints::
-
-    group_share:
-        csp,ccgt:
-            energy_cap_min: 0.5
-            energy_cap_max: 0.9
-            carrier_prod_min:
-                power: 0.5
-
-In the process of making these updates, the ``demand_power_peak`` and (undocumented) ``ignored_techs`` options were removed from ``group_share``.
-
-``charge_rate``
-===============
-
-When first introduced, charge rate was used to hard-link `energy_cap` and `storage_cap` for a storage/supply_plus technology. This meant that on defining ``energy_cap_max`` and ``charge_rate``, a user was implicitly defining ``storage_cap_max``. This hard-link has now been removed, replaced with only one constraint concerning charge rate: :math:`storage_{cap}(loc::tech) \geq energy_{cap}(loc:tech) \times charge\_rate(loc:tech)`.
-
-Pre-processed data
-==================
-
-Version `0.5` kept pre-processed data in either a dictionary (static data), pandas dataframe (location data) or an `xarray Dataset <https://docs.xarray.dev/en/v0.10.1/generated/xarray.Dataset.html>`_ (timeseries data). To view a value that would be used in optimisation, the user would call `model.get_option()`. Similarly, to edit a value before running the model, a user could use `model.set_option()`.
-
-Now, all pre-processed data is held in a single unified `xarray Dataset <https://docs.xarray.dev/en/v0.10.1/generated/xarray.Dataset.html>`_: `model.inputs`.
-
-To view and edit this data before it is sent to the solver, a user need only use standard xarray functionality (see their `documentation <https://docs.xarray.dev/en/v0.10.1/>`_ for more information).
-
-Plotting data
-=============
-
-.. Note::
-    Advanced plotting is still under construction. In case our current functionality is insufficient, input and output data can be plotted by the user using their preferred Python plotting tools, or any other language that can access either NetCDF or CSV data.
-
-Plotting functions can now be called directly on the model and now use `Plotly <https://plotly.com/python/>`_ instead of `0.5`'s matplotlib.
-
-Changes are:
-
-* ``calliope.analysis.plot_capacity(model.solution)`` to ``model.plot.capacity()``
-
-* ``calliope.analysis.plot_transmission(model.solution, carrier='power', tech='ac_transmission')`` to ``model.plot.transmission()``
-
-* ``calliope.analysis.plot_carrier_production(model.solution, carrier='power')`` to ``model.plot.timeseries()``
-
-All available data is plotted, with dropdown menus available for a user to move between plots. A summary of all plotting can also be produced using ``model.plot.summary()``, a function that is also available via the command line interface.
-
-.. seealso:: :ref:`api_model`
-
-Operational mode
-================
-
-In `0.6`, running in operational mode changes capacities from decision variables to parameters, preventing various issues that plagued operational mode in prior versions. Additional sense checks were added to ensure that functionality incompatible with operational mode, such as time clustering, is not accidentally used together with it.
-
-.. seealso:: :ref:`operational_mode`
-
------------------
-New functionality
------------------
-
-Debugging & checks
-==================
-
-A user can now output a data structure of all model input data (the `model_run` dictionary) after Calliope's internal pre-processing, into a YAML file, for debugging. This debug file includes comments as to where constraint/cost values have originated (e.g. having been set by a location-specific configuration, or from a model-wide override group).
-
-Similarly, sense checks are undertaken at several points during pre-processing to ensure the model being built is robust. This includes checks for missing data, possibly misspelled constraints, incompatible inputs, and much more.
-
-This functionality will not find all possible user input errors, as this is an impossible task. However, it flags common mistakes, and the format of implementation allows for further checks to be applied in the future.
-
-Pre-processed model
-===================
-
-Having the pre-processed model available in one `xarray Dataset <https://docs.xarray.dev/en/v0.10.1/generated/xarray.Dataset.html>`_ allows a model to be saved to file *before* being run. Although pre-processing is quick, this allows a user to avoid pre-processing the same file multiple times. Instead, they can read in a previously saved NetCDF file which fully describes the model.
-
-Multiple backends
-=================
-
-Our primary solver backend is `Pyomo <https://www.pyomo.org/>`_. However, we have now extracted all pre-processing stages from the backend, with all data for a model run being stored in a single `xarray Dataset <https://docs.xarray.dev/en/v0.10.1/generated/xarray.Dataset.html>`_. This permits the implementation of additional backends.
-
-One such backend currently in an experimental state is based on `JuMP <https://github.com/jump-dev/JuMP.jl>`_ in the Julia programming language. Linking Calliope to Julia is a long-term project, for which we welcome any contributions.
-
-Pyomo warmstart
-===============
-
-Warmstart functionality can be used in solvers other than GLPK. They allow a previously constructed model to be changed slightly without having to be fully rebuilt. This can speed up re-running a model when you have just a few input parameters you would like to change (the cost of a technology, for instance).
-
-Although the use of warmstart existed in operational mode in version `0.5`, now it extends to all possible parameters in all models. This functionality is currently undocumented in Calliope, but the Pyomo documentation provides some information and the Pyomo model built by Calliope can be accessed by `model._backend_model`.
-
-Backend interface
-=================
-
-Once the backend model has been built, it can be accessed by a user, via Calliope. Parameters can be checked and changed, constraints can be activated/deactivated and a model can be re run, all without having to build the backend again. User who are familiar with building large models with Pyomo will be aware of the time penalty associated with processing the model in Pyomo. This additional functionality helps mitigate this, as changing a few parameters need not require complete model rebuild.
-
-.. seealso:: :ref:`api_backend_interface`
-
-Logging
-=======
-
-In an interactive Python session (e.g. using Jupyter notebook), output from Calliope can be triggered at different levels of verbosity. By default on building the model (``calliope.Model()``) and running it (``model.run()``), there is no logging displayed unless it is at least a `WARNING`. For helpful information on where the model is in its pre-processing and running in the solver, verbosity can be increased using ``calliope.set_log_level()``.
-
-.. seealso:: :ref:`api_utility_classes`
diff --git a/doc/user/reference.rst b/doc/user/reference.rst
index 7df8fd4b7..60a3d18e1 100644
--- a/doc/user/reference.rst
+++ b/doc/user/reference.rst
@@ -10,5 +10,4 @@ We suggest you read the :doc:`building`, :doc:`running` and :doc:`analysing` sec
    :maxdepth: 2
 
    ref_example_models
-   ref_config
-   ref_05_to_06
\ No newline at end of file
+   ref_config
\ No newline at end of file
diff --git a/doc/user/running.rst b/doc/user/running.rst
index c17b87873..f48097c6d 100644
--- a/doc/user/running.rst
+++ b/doc/user/running.rst
@@ -63,7 +63,7 @@ It is also possible to use the `--override_dict` option to pass a YAML string th
 
   .. code-block:: fishshell
 
-    $ calliope run testmodel/model.yaml --override_dict="{'model.subset_time': ['2005-01-01', '2005-01-31']}" --save_netcdf=results.nc
+    $ calliope run testmodel/model.yaml --override_dict="{'model.time_subset': ['2005-01-01', '2005-01-31']}" --save_netcdf=results.nc
 
 .. seealso::
 
diff --git a/doc/user/troubleshooting.rst b/doc/user/troubleshooting.rst
index f63986470..329474716 100644
--- a/doc/user/troubleshooting.rst
+++ b/doc/user/troubleshooting.rst
@@ -5,7 +5,7 @@ Troubleshooting
 General strategies
 ------------------
 
-* **Building a smaller model**: :yaml:`model.subset_time` allows specifying a subset of timesteps to be used. This can be useful for debugging purposes as it can dramatically speed up model solution times. The timestep subset can be specified as :yaml:`[startdate, enddate]`, e.g. :yaml:`['2005-01-01', '2005-01-31']`, or as a single time period, such as :yaml:`2005-01` to select January only. The subsets are processed before building the model and applying time resolution adjustments, so time resolution reduction functions will only see the reduced set of data.
+* **Building a smaller model**: :yaml:`model.time_subset` allows specifying a subset of timesteps to be used. This can be useful for debugging purposes as it can dramatically speed up model solution times. The timestep subset can be specified as :yaml:`[startdate, enddate]`, e.g. :yaml:`['2005-01-01', '2005-01-31']`, or as a single time period, such as :yaml:`2005-01` to select January only. The subsets are processed before building the model and applying time resolution adjustments, so time resolution reduction functions will only see the reduced set of data.
 
 * **Retaining logs and temporary files**: The setting :yaml:`config.solve.save_logs`, disabled by default, sets the directory into which to save logs and temporary files from the backend, to inspect solver logs and solver-generated model files. This also turns on symbolic solver labels in the Pyomo backend, so that all model components in the backend model are named according to the corresponding Calliope model components (by default, Pyomo uses short random names for all generated model components).
 
diff --git a/requirements/base.txt b/requirements/base.txt
index b047524aa..a0ce7d5c0 100644
--- a/requirements/base.txt
+++ b/requirements/base.txt
@@ -10,5 +10,4 @@ pandas >= 2, < 2.1
 pyomo >= 6.5, < 7
 pyparsing >= 3.0, < 3.1
 ruamel.yaml >= 0.17, < 0.18
-scikit-learn >= 1.2, < 1.3
 xarray >= 2023.9, < 2024.2
diff --git a/src/calliope/config/defaults.yaml b/src/calliope/config/defaults.yaml
index 4ca530c2e..393fb66da 100644
--- a/src/calliope/config/defaults.yaml
+++ b/src/calliope/config/defaults.yaml
@@ -12,11 +12,11 @@ config:
   init:
     name: null # Model name
     calliope_version: null # Calliope framework version this model is intended for
-    random_seed: null # Seed for random number generator used during clustering
-    subset_time: null # Subset of timesteps as a two-element list giving the range, e.g. ['2005-01-01', '2005-01-05'], or a single string, e.g. '2005-01'
-    time: null # Optional settings to adjust time resolution, see :ref:`time_clustering` for the available options
-    timeseries_data_path: null # Path to time series data
-    timeseries_dateformat: "ISO8601" # Timestamp format of all time series data when read from file. "ISO8601" means "YYYY-mm-dd HH:MM:SS".
+    time_subset: null # (Optional) Subset of timesteps as a two-element list giving the range, e.g. ['2005-01-01', '2005-01-05'], or a single string, e.g. '2005-01'
+    time_resample: null # (Optional) setting to adjust time resolution, e.g. "2H" for 2-hourly
+    time_cluster: null # (Optional) setting to cluster the timeseries, must be a path to a file where each date is linked to a representative date that also exists in the timeseries.
+    time_data_path: "." # Path to time series data. If relative, it will be relative to the base model definition file.
+    time_format: "ISO8601" # Timestamp format of all time series data when read from file. "ISO8601" means "YYYY-mm-dd HH:MM:SS".
     custom_math: [] # List of references to files which contain custom mathematical formulations. If referring to an in-built Calliope custom math file (see documentation for available files), do not append the reference with ".yaml". If referring to your own custom math file, ensure the file type is given as a suffix (".yaml" or ".yml"). Relative paths will be assumed to be relative to the `config` file given when creating a calliope Model (`calliope.Model(config)`)
   build:
     backend: pyomo  # Module with which to build the optimisation problem
diff --git a/src/calliope/core/attrdict.py b/src/calliope/core/attrdict.py
index 653ced493..17883d5a8 100644
--- a/src/calliope/core/attrdict.py
+++ b/src/calliope/core/attrdict.py
@@ -137,10 +137,7 @@ def _resolve_imports(cls, loaded, resolve_imports, base_path=None):
             raise ValueError("`import` must be a list.")
 
         for k in imports:
-            if base_path:
-                path = relative_path(base_path, k)
-            else:
-                path = k
+            path = relative_path(base_path, k)
             imported = cls.from_yaml(path)
             # loaded is added to imported (i.e. it takes precedence)
             imported.union(loaded_dict)
@@ -156,7 +153,7 @@ def _resolve_imports(cls, loaded, resolve_imports, base_path=None):
         return loaded
 
     @classmethod
-    def from_yaml(cls, f, resolve_imports=True):
+    def from_yaml(cls, filename, resolve_imports=True):
         """
         Returns an AttrDict initialized from the given path or
         file object ``f``, which must point to a YAML file. The path can
@@ -165,7 +162,7 @@ def from_yaml(cls, f, resolve_imports=True):
         Parameters
         ----------
 
-        f : str or pathlib.Path
+        filename : str or pathlib.Path
         resolve_imports : bool or str, optional
             If ``resolve_imports`` is True, top-level ``import:`` statements
             are resolved recursively.
@@ -177,16 +174,9 @@ def from_yaml(cls, f, resolve_imports=True):
             overrides definitions in the imported file.
 
         """
-        if isinstance(f, (str, Path)):
-            with open(
-                f,
-                "r",
-                encoding="utf-8",
-            ) as src:
-                loaded = cls(_yaml_load(src))
-        else:
-            loaded = cls(_yaml_load(f))
-        loaded = cls._resolve_imports(loaded, resolve_imports, base_path=f)
+        filename = Path(filename)
+        loaded = cls(_yaml_load(filename.read_text(encoding="utf-8")))
+        loaded = cls._resolve_imports(loaded, resolve_imports, filename)
         return loaded
 
     @classmethod
diff --git a/src/calliope/core/model.py b/src/calliope/core/model.py
index be360fee9..f693e9ff1 100644
--- a/src/calliope/core/model.py
+++ b/src/calliope/core/model.py
@@ -98,14 +98,14 @@ def __init__(
         self.defaults: AttrDict
         self.config: AttrDict
         self.math: AttrDict
-        self._model_def_path: Optional[str]
+        self._model_def_path: Optional[Path]
         self.math_documentation = MathDocumentation()
 
         # try to set logging output format assuming python interactive. Will
         # use CLI logging format if model called from CLI
         log_time(LOGGER, self._timings, "model_creation", comment="Model: initialising")
         if isinstance(model_definition, (str, Path)):
-            self._model_def_path = Path(model_definition).as_posix()
+            self._model_def_path = Path(model_definition)
             model_run, debug_data = model_run_from_yaml(
                 model_definition,
                 scenario,
@@ -305,7 +305,7 @@ def _add_math(self, custom_math: list) -> AttrDict:
             if not f"{filename}".endswith((".yaml", ".yml")):
                 yaml_filepath = math_dir / f"{filename}.yaml"
             else:
-                yaml_filepath = Path(relative_path(self._model_def_path, filename))
+                yaml_filepath = relative_path(self._model_def_path, filename)
 
             if not yaml_filepath.is_file():
                 file_errors.append(filename)
diff --git a/src/calliope/core/util/tools.py b/src/calliope/core/util/tools.py
index f5d5bbf41..608f83042 100644
--- a/src/calliope/core/util/tools.py
+++ b/src/calliope/core/util/tools.py
@@ -2,10 +2,6 @@
 # Licensed under the Apache 2.0 License (see LICENSE file).
 
 
-import functools
-import importlib
-import os
-import sys
 from pathlib import Path
 from typing import TypeVar
 
@@ -18,91 +14,23 @@
 T = TypeVar("T")
 
 
-memoize = functools.lru_cache(maxsize=2048)
-
-
-class memoize_instancemethod(object):
-    """
-    Cache the return value of a method on a per-instance basis
-    (as opposed to a per-class basis like functools.lru_cache does)
-
-    Source: http://code.activestate.com/recipes/577452/
-
-    This class is meant to be used as a decorator of methods. The return
-    value from a given method invocation will be cached on the instance
-    whose method was invoked. All arguments passed to a method decorated
-    with memoize must be hashable.
-
-    If a memoized method is invoked directly on its class the result
-    will not be cached. Instead the method will be invoked like a
-    static method.
-
-    """
-
-    def __init__(self, func):
-        self.func = func
-
-    def __get__(self, obj, objtype=None):
-        if obj is None:
-            return self.func
-        return functools.partial(self, obj)
-
-    def __call__(self, *args, **kw):
-        obj = args[0]
-        try:
-            cache = obj.__cache
-        except AttributeError:
-            cache = obj.__cache = {}
-        key = (self.func, args[1:], frozenset(list(kw.items())))
-        try:
-            res = cache[key]
-        except KeyError:
-            res = cache[key] = self.func(*args, **kw)
-        return res
-
-
-def relative_path(base_path_file, path):
+def relative_path(base_path_file, path) -> Path:
     """
     If ``path`` is not absolute, it is interpreted as relative to the
     path of the given ``base_path_file``.
 
     """
     # Check if base_path_file is a string because it might be an AttrDict
-    if not os.path.isabs(path) and isinstance(base_path_file, (str, Path)):
-        path = os.path.join(os.path.dirname(base_path_file), path)
+    path = Path(path)
+    if base_path_file is not None:
+        base_path_file = Path(base_path_file)
+        if base_path_file.is_file():
+            base_path_file = base_path_file.parent
+        if not path.is_absolute():
+            path = base_path_file.absolute() / path
     return path
 
 
-def load_function(source):
-    """
-    Returns a function from a module, given a source string of the form:
-
-        'module.submodule.subsubmodule.function_name'
-
-    """
-    module_string, function_string = source.rsplit(".", 1)
-    modules = sys.modules.keys()
-    # Check if module already loaded, if so, don't re-import it
-    if module_string in modules:
-        module = sys.modules[module_string]
-    # Else load the module
-    else:
-        module = importlib.import_module(module_string)
-    return getattr(module, function_string)
-
-
-def plugin_load(name, builtin_module):
-    try:  # First try importing as a third-party module
-        func = load_function(name)
-    except ValueError:
-        # ValueError raised if we got a string without '.',
-        # which implies a builtin function,
-        # so we attempt to load from the given module
-        func_string = builtin_module + "." + name
-        func = load_function(func_string)
-    return func
-
-
 def validate_dict(to_validate: dict, schema: dict, dict_descriptor: str) -> None:
     """
     Validate a dictionary under a given schema.
diff --git a/src/calliope/example_models/national_scale/model.yaml b/src/calliope/example_models/national_scale/model.yaml
index 1f40e98dc..cb05b6a29 100644
--- a/src/calliope/example_models/national_scale/model.yaml
+++ b/src/calliope/example_models/national_scale/model.yaml
@@ -10,8 +10,8 @@ config:
     # What version of Calliope this model is intended for
     calliope_version: 0.7.0
     # Time series data path - can either be a path relative to this file, or an absolute path
-    timeseries_data_path: "timeseries_data"
-    subset_time: ["2005-01-01", "2005-01-05"] # Subset of timesteps
+    time_data_path: "timeseries_data"
+    time_subset: ["2005-01-01", "2005-01-05"] # Subset of timesteps
 
   build:
     ensure_feasibility: true # Switches on the "unmet demand" constraint
diff --git a/src/calliope/example_models/national_scale/scenarios.yaml b/src/calliope/example_models/national_scale/scenarios.yaml
index 41ada7b76..aa2c8455a 100644
--- a/src/calliope/example_models/national_scale/scenarios.yaml
+++ b/src/calliope/example_models/national_scale/scenarios.yaml
@@ -12,27 +12,22 @@ overrides:
   profiling:
     config:
       init.name: "National-scale example model (profiling run)"
-      init.subset_time: ["2005-01-01", "2005-01-15"]
+      init.time_subset: ["2005-01-01", "2005-01-15"]
       solve.solver: cbc
 
   time_resampling:
     config:
       init:
         name: "National-scale example model with time resampling"
-        subset_time: ["2005-01", "2005-01"]
-        # Resample time resolution to 6-hourly
-        time: { function: resample, function_options: { "resolution": "6H" } }
+        time_subset: ["2005-01", "2005-01"]
+        time_resample: "6H"
 
   time_clustering:
     config:
       init:
-        random_seed: 23
         name: "National-scale example model with time clustering"
-        subset_time: null # No time subsetting
-        # Cluster timesteps using k-means
-        time:
-          function: apply_clustering
-          function_options: { clustering_func: "kmeans", how: "closest", k: 10 }
+        time_subset: null # No time subsetting
+        time_cluster: cluster_days.csv
 
   spores:
     config:
@@ -65,7 +60,7 @@ overrides:
 
   operate:
     config:
-      init.subset_time: ["2005-01-01", "2005-01-10"]
+      init.time_subset: ["2005-01-01", "2005-01-10"]
       build.mode: operate
       solve:
         operate_window: 12
@@ -100,7 +95,7 @@ overrides:
         ensure_feasibility: False
         objective: "check_feasibility"
       init:
-        subset_time: ["2005-01-04", "2005-01-04"]
+        time_subset: ["2005-01-04", "2005-01-04"]
 
   reserve_margin:
     parameters:
@@ -118,14 +113,14 @@ overrides:
   ##
 
   run1:
-    config.init.subset_time: ["2005-01-01", "2005-01-31"]
+    config.init.time_subset: ["2005-01-01", "2005-01-31"]
   run2:
-    config.init.subset_time: ["2005-02-01", "2005-02-31"]
+    config.init.time_subset: ["2005-02-01", "2005-02-31"]
   run3:
-    config.init.subset_time: ["2005-01-01", "2005-01-31"]
+    config.init.time_subset: ["2005-01-01", "2005-01-31"]
     nodes.region1.techs.ccgt.constraints.flow_cap_max: 0 # Disallow CCGT
   run4:
-    subset_time: ["2005-02-01", "2005-02-31"]
+    time_subset: ["2005-02-01", "2005-02-31"]
     nodes.region1.techs.ccgt.constraints.flow_cap_max: 0 # Disallow CCGT
 
   ##
diff --git a/src/calliope/example_models/national_scale/timeseries_data/cluster_days.csv b/src/calliope/example_models/national_scale/timeseries_data/cluster_days.csv
new file mode 100644
index 000000000..4cd9e78da
--- /dev/null
+++ b/src/calliope/example_models/national_scale/timeseries_data/cluster_days.csv
@@ -0,0 +1,366 @@
+datesteps,cluster
+2005-01-01,2005-12-09
+2005-01-02,2005-12-08
+2005-01-03,2005-12-08
+2005-01-04,2005-12-09
+2005-01-05,2005-12-09
+2005-01-06,2005-12-06
+2005-01-07,2005-12-06
+2005-01-08,2005-12-06
+2005-01-09,2005-12-06
+2005-01-10,2005-12-06
+2005-01-11,2005-12-06
+2005-01-12,2005-12-06
+2005-01-13,2005-12-06
+2005-01-14,2005-12-09
+2005-01-15,2005-12-09
+2005-01-16,2005-12-08
+2005-01-17,2005-12-08
+2005-01-18,2005-12-06
+2005-01-19,2005-12-06
+2005-01-20,2005-12-06
+2005-01-21,2005-12-08
+2005-01-22,2005-12-08
+2005-01-23,2005-12-08
+2005-01-24,2005-12-06
+2005-01-25,2005-12-08
+2005-01-26,2005-12-06
+2005-01-27,2005-12-06
+2005-01-28,2005-12-06
+2005-01-29,2005-12-06
+2005-01-30,2005-12-08
+2005-01-31,2005-02-17
+2005-02-01,2005-12-08
+2005-02-02,2005-12-06
+2005-02-03,2005-02-17
+2005-02-04,2005-12-06
+2005-02-05,2005-12-09
+2005-02-06,2005-12-09
+2005-02-07,2005-02-17
+2005-02-08,2005-12-06
+2005-02-09,2005-12-06
+2005-02-10,2005-12-06
+2005-02-11,2005-12-06
+2005-02-12,2005-12-06
+2005-02-13,2005-12-06
+2005-02-14,2005-12-08
+2005-02-15,2005-02-17
+2005-02-16,2005-12-08
+2005-02-17,2005-12-06
+2005-02-18,2005-12-08
+2005-02-19,2005-12-06
+2005-02-20,2005-12-06
+2005-02-21,2005-02-17
+2005-02-22,2005-12-06
+2005-02-23,2005-12-09
+2005-02-24,2005-02-17
+2005-02-25,2005-09-13
+2005-02-26,2005-04-13
+2005-02-27,2005-12-08
+2005-02-28,2005-12-08
+2005-03-01,2005-12-08
+2005-03-02,2005-12-09
+2005-03-03,2005-02-17
+2005-03-04,2005-09-13
+2005-03-05,2005-12-08
+2005-03-06,2005-02-17
+2005-03-07,2005-04-13
+2005-03-08,2005-04-13
+2005-03-09,2005-04-13
+2005-03-10,2005-04-13
+2005-03-11,2005-04-13
+2005-03-12,2005-04-27
+2005-03-13,2005-04-27
+2005-03-14,2005-12-06
+2005-03-15,2005-09-13
+2005-03-16,2005-09-13
+2005-03-17,2005-09-13
+2005-03-18,2005-09-13
+2005-03-19,2005-04-27
+2005-03-20,2005-04-13
+2005-03-21,2005-04-27
+2005-03-22,2005-04-13
+2005-03-23,2005-04-27
+2005-03-24,2005-09-13
+2005-03-25,2005-09-13
+2005-03-26,2005-04-13
+2005-03-27,2005-04-13
+2005-03-28,2005-12-06
+2005-03-29,2005-04-13
+2005-03-30,2005-04-13
+2005-03-31,2005-04-13
+2005-04-01,2005-04-13
+2005-04-02,2005-12-09
+2005-04-03,2005-12-08
+2005-04-04,2005-09-13
+2005-04-05,2005-09-13
+2005-04-06,2005-09-13
+2005-04-07,2005-04-13
+2005-04-08,2005-12-09
+2005-04-09,2005-08-09
+2005-04-10,2005-08-09
+2005-04-11,2005-08-09
+2005-04-12,2005-08-09
+2005-04-13,2005-08-09
+2005-04-14,2005-04-13
+2005-04-15,2005-04-13
+2005-04-16,2005-09-13
+2005-04-17,2005-09-09
+2005-04-18,2005-05-25
+2005-04-19,2005-08-09
+2005-04-20,2005-08-09
+2005-04-21,2005-09-09
+2005-04-22,2005-09-09
+2005-04-23,2005-09-09
+2005-04-24,2005-09-13
+2005-04-25,2005-08-09
+2005-04-26,2005-08-09
+2005-04-27,2005-08-09
+2005-04-28,2005-08-09
+2005-04-29,2005-09-09
+2005-04-30,2005-09-09
+2005-05-01,2005-09-09
+2005-05-02,2005-09-09
+2005-05-03,2005-09-09
+2005-05-04,2005-08-09
+2005-05-05,2005-08-09
+2005-05-06,2005-09-09
+2005-05-07,2005-08-09
+2005-05-08,2005-09-09
+2005-05-09,2005-09-13
+2005-05-10,2005-08-09
+2005-05-11,2005-04-27
+2005-05-12,2005-04-27
+2005-05-13,2005-04-27
+2005-05-14,2005-09-09
+2005-05-15,2005-08-09
+2005-05-16,2005-04-27
+2005-05-17,2005-08-09
+2005-05-18,2005-08-09
+2005-05-19,2005-08-09
+2005-05-20,2005-09-09
+2005-05-21,2005-05-25
+2005-05-22,2005-08-09
+2005-05-23,2005-09-09
+2005-05-24,2005-08-09
+2005-05-25,2005-09-09
+2005-05-26,2005-09-09
+2005-05-27,2005-09-09
+2005-05-28,2005-09-09
+2005-05-29,2005-04-27
+2005-05-30,2005-04-27
+2005-05-31,2005-12-09
+2005-06-01,2005-09-09
+2005-06-02,2005-09-09
+2005-06-03,2005-08-09
+2005-06-04,2005-08-09
+2005-06-05,2005-08-09
+2005-06-06,2005-08-09
+2005-06-07,2005-08-09
+2005-06-08,2005-08-09
+2005-06-09,2005-08-09
+2005-06-10,2005-08-09
+2005-06-11,2005-09-09
+2005-06-12,2005-08-09
+2005-06-13,2005-09-13
+2005-06-14,2005-08-09
+2005-06-15,2005-05-25
+2005-06-16,2005-08-09
+2005-06-17,2005-08-09
+2005-06-18,2005-08-09
+2005-06-19,2005-08-09
+2005-06-20,2005-08-09
+2005-06-21,2005-08-09
+2005-06-22,2005-09-13
+2005-06-23,2005-04-27
+2005-06-24,2005-08-09
+2005-06-25,2005-08-09
+2005-06-26,2005-08-09
+2005-06-27,2005-04-27
+2005-06-28,2005-08-09
+2005-06-29,2005-09-09
+2005-06-30,2005-08-09
+2005-07-01,2005-08-09
+2005-07-02,2005-08-09
+2005-07-03,2005-08-09
+2005-07-04,2005-09-09
+2005-07-05,2005-09-09
+2005-07-06,2005-08-09
+2005-07-07,2005-08-09
+2005-07-08,2005-08-09
+2005-07-09,2005-08-09
+2005-07-10,2005-08-09
+2005-07-11,2005-08-09
+2005-07-12,2005-08-09
+2005-07-13,2005-08-09
+2005-07-14,2005-08-09
+2005-07-15,2005-09-09
+2005-07-16,2005-08-09
+2005-07-17,2005-08-09
+2005-07-18,2005-08-09
+2005-07-19,2005-08-09
+2005-07-20,2005-08-09
+2005-07-21,2005-08-09
+2005-07-22,2005-08-09
+2005-07-23,2005-08-09
+2005-07-24,2005-08-09
+2005-07-25,2005-08-09
+2005-07-26,2005-08-09
+2005-07-27,2005-09-13
+2005-07-28,2005-09-13
+2005-07-29,2005-08-09
+2005-07-30,2005-08-09
+2005-07-31,2005-08-09
+2005-08-01,2005-08-09
+2005-08-02,2005-09-09
+2005-08-03,2005-08-09
+2005-08-04,2005-08-09
+2005-08-05,2005-09-09
+2005-08-06,2005-09-09
+2005-08-07,2005-09-09
+2005-08-08,2005-09-13
+2005-08-09,2005-09-13
+2005-08-10,2005-04-27
+2005-08-11,2005-05-25
+2005-08-12,2005-08-09
+2005-08-13,2005-08-09
+2005-08-14,2005-08-09
+2005-08-15,2005-08-09
+2005-08-16,2005-09-13
+2005-08-17,2005-08-09
+2005-08-18,2005-08-09
+2005-08-19,2005-04-27
+2005-08-20,2005-08-09
+2005-08-21,2005-08-09
+2005-08-22,2005-08-09
+2005-08-23,2005-08-09
+2005-08-24,2005-08-09
+2005-08-25,2005-09-13
+2005-08-26,2005-08-09
+2005-08-27,2005-08-09
+2005-08-28,2005-05-25
+2005-08-29,2005-09-13
+2005-08-30,2005-08-09
+2005-08-31,2005-09-13
+2005-09-01,2005-08-09
+2005-09-02,2005-08-09
+2005-09-03,2005-05-25
+2005-09-04,2005-09-13
+2005-09-05,2005-09-13
+2005-09-06,2005-09-13
+2005-09-07,2005-04-27
+2005-09-08,2005-09-13
+2005-09-09,2005-05-25
+2005-09-10,2005-08-09
+2005-09-11,2005-09-13
+2005-09-12,2005-09-13
+2005-09-13,2005-09-13
+2005-09-14,2005-08-09
+2005-09-15,2005-09-13
+2005-09-16,2005-04-13
+2005-09-17,2005-04-27
+2005-09-18,2005-09-13
+2005-09-19,2005-09-13
+2005-09-20,2005-09-13
+2005-09-21,2005-09-13
+2005-09-22,2005-09-13
+2005-09-23,2005-12-09
+2005-09-24,2005-09-09
+2005-09-25,2005-09-13
+2005-09-26,2005-05-25
+2005-09-27,2005-05-25
+2005-09-28,2005-05-25
+2005-09-29,2005-09-13
+2005-09-30,2005-05-25
+2005-10-01,2005-05-25
+2005-10-02,2005-04-13
+2005-10-03,2005-09-13
+2005-10-04,2005-09-13
+2005-10-05,2005-09-13
+2005-10-06,2005-09-13
+2005-10-07,2005-09-13
+2005-10-08,2005-04-27
+2005-10-09,2005-04-27
+2005-10-10,2005-12-09
+2005-10-11,2005-12-09
+2005-10-12,2005-04-27
+2005-10-13,2005-04-27
+2005-10-14,2005-12-06
+2005-10-15,2005-09-13
+2005-10-16,2005-12-08
+2005-10-17,2005-02-17
+2005-10-18,2005-04-27
+2005-10-19,2005-09-13
+2005-10-20,2005-04-27
+2005-10-21,2005-05-25
+2005-10-22,2005-04-27
+2005-10-23,2005-09-13
+2005-10-24,2005-09-13
+2005-10-25,2005-09-13
+2005-10-26,2005-09-13
+2005-10-27,2005-09-13
+2005-10-28,2005-04-27
+2005-10-29,2005-09-13
+2005-10-30,2005-12-06
+2005-10-31,2005-09-13
+2005-11-01,2005-04-27
+2005-11-02,2005-12-08
+2005-11-03,2005-12-09
+2005-11-04,2005-12-08
+2005-11-05,2005-12-08
+2005-11-06,2005-12-08
+2005-11-07,2005-12-06
+2005-11-08,2005-09-13
+2005-11-09,2005-09-13
+2005-11-10,2005-04-27
+2005-11-11,2005-04-27
+2005-11-12,2005-04-27
+2005-11-13,2005-12-09
+2005-11-14,2005-04-27
+2005-11-15,2005-04-27
+2005-11-16,2005-12-06
+2005-11-17,2005-12-09
+2005-11-18,2005-12-09
+2005-11-19,2005-12-09
+2005-11-20,2005-12-09
+2005-11-21,2005-12-09
+2005-11-22,2005-12-06
+2005-11-23,2005-12-08
+2005-11-24,2005-12-08
+2005-11-25,2005-02-17
+2005-11-26,2005-12-08
+2005-11-27,2005-12-08
+2005-11-28,2005-12-06
+2005-11-29,2005-12-09
+2005-11-30,2005-12-06
+2005-12-01,2005-12-06
+2005-12-02,2005-12-09
+2005-12-03,2005-12-06
+2005-12-04,2005-12-06
+2005-12-05,2005-12-06
+2005-12-06,2005-12-06
+2005-12-07,2005-12-06
+2005-12-08,2005-12-06
+2005-12-09,2005-12-06
+2005-12-10,2005-12-06
+2005-12-11,2005-12-06
+2005-12-12,2005-12-06
+2005-12-13,2005-12-06
+2005-12-14,2005-12-06
+2005-12-15,2005-12-08
+2005-12-16,2005-12-08
+2005-12-17,2005-12-06
+2005-12-18,2005-12-08
+2005-12-19,2005-12-09
+2005-12-20,2005-12-09
+2005-12-21,2005-12-06
+2005-12-22,2005-12-06
+2005-12-23,2005-12-09
+2005-12-24,2005-12-09
+2005-12-25,2005-12-09
+2005-12-26,2005-12-09
+2005-12-27,2005-12-09
+2005-12-28,2005-12-09
+2005-12-29,2005-12-06
+2005-12-30,2005-12-09
+2005-12-31,2005-12-09
diff --git a/src/calliope/example_models/urban_scale/model.yaml b/src/calliope/example_models/urban_scale/model.yaml
index 58fe595e7..985542785 100644
--- a/src/calliope/example_models/urban_scale/model.yaml
+++ b/src/calliope/example_models/urban_scale/model.yaml
@@ -9,8 +9,8 @@ config:
     # What version of Calliope this model is intended for
     calliope_version: 0.7.0
     # Time series data path - can either be a path relative to this file, or an absolute path
-    timeseries_data_path: "timeseries_data"
-    subset_time: ["2005-07-01", "2005-07-02"] # Subset of timesteps
+    time_data_path: "timeseries_data"
+    time_subset: ["2005-07-01", "2005-07-02"] # Subset of timesteps
 
   build:
     mode: plan # Choices: plan, operate
diff --git a/src/calliope/example_models/urban_scale/scenarios.yaml b/src/calliope/example_models/urban_scale/scenarios.yaml
index 21af5777e..2f970f0ff 100644
--- a/src/calliope/example_models/urban_scale/scenarios.yaml
+++ b/src/calliope/example_models/urban_scale/scenarios.yaml
@@ -49,7 +49,7 @@ overrides:
 
   operate:
     config:
-      init.subset_time: ["2005-07-01", "2005-07-10"]
+      init.time_subset: ["2005-07-01", "2005-07-10"]
       build.mode: operate
       solve:
         operate_window: 24
@@ -81,21 +81,3 @@ overrides:
       X1,N1.techs.heat_pipes.constraints.flow_cap_max: 300
       N1,X2.techs.heat_pipes.constraints.flow_cap_max: 250
       N1,X3.techs.heat_pipes.constraints.flow_cap_max: 320
-
-  time_masking:
-    config:
-      init:
-      name: "Urban-scale example model with time masking"
-      subset_time: ["2005-01", "2005-01"]
-      # Resample time resolution to 6-hourly
-      time:
-        masks:
-          - function: extreme_diff
-            options:
-              tech0: demand_heat
-              tech1: demand_electricity
-              how: max
-              n: 2
-              var: sink_equals
-        function: resample
-        function_options: { resolution: 6H }
diff --git a/src/calliope/examples.py b/src/calliope/examples.py
index ada4a4d4e..c7955437e 100644
--- a/src/calliope/examples.py
+++ b/src/calliope/examples.py
@@ -52,8 +52,3 @@ def milp(*args, **kwargs):
 def operate(*args, **kwargs):
     """Returns the built-in urban-scale example model in operate mode."""
     return urban_scale(scenario="operate", *args, **kwargs)
-
-
-def time_masking(*args, **kwargs):
-    """Returns the built-in urban-scale example model with time masking."""
-    return urban_scale(scenario="time_masking", *args, **kwargs)
diff --git a/src/calliope/preprocess/model_data.py b/src/calliope/preprocess/model_data.py
index b75e4e051..d9f61c4c9 100644
--- a/src/calliope/preprocess/model_data.py
+++ b/src/calliope/preprocess/model_data.py
@@ -9,16 +9,16 @@
 time-varying param_dict.
 
 """
+import importlib
 import logging
-import os
 import re
+from pathlib import Path
 from typing import Optional
 
 import numpy as np
 import pandas as pd
 import xarray as xr
 
-import calliope
 from calliope import exceptions
 from calliope._version import __version__
 from calliope.core.attrdict import AttrDict
@@ -64,19 +64,20 @@ def __init__(self, model_run_dict):
         """
         self.node_dict: dict = model_run_dict.nodes.as_dict_flat()
         self.tech_dict: dict = model_run_dict.techs.as_dict_flat()
-        self.timeseries_vars: pd.DataFrame = model_run_dict.timeseries_vars
-        self.timeseries_data: set[str] = model_run_dict.timeseries_data
-        self.time_config: Optional[AttrDict] = model_run_dict.config.init.time
-        self.random_seed: Optional[int] = model_run_dict.config.init.random_seed
+        self.timeseries_vars: set[str] = model_run_dict.timeseries_vars
+        self.timeseries_data: pd.DataFrame = model_run_dict.timeseries_data
+        self.time_data_path: Path = model_run_dict.config.init.time_data_path
+        self.time_resample: Optional[str] = model_run_dict.config.init.time_resample
+        self.time_cluster: Optional[str] = model_run_dict.config.init.time_cluster
+        self.time_format: str = model_run_dict.config.init.time_format
         self.params: AttrDict = model_run_dict.parameters
 
         self.model_data = xr.Dataset(coords={"timesteps": self.timeseries_data.index})
         self._add_attributes(model_run_dict)
         self.template_config = AttrDict.from_yaml(
-            os.path.join(
-                os.path.dirname(calliope.__file__), "config", "model_data_lookup.yaml"
-            )
+            importlib.resources.files("calliope") / "config" / "model_data_lookup.yaml"
         )
+
         self._strip_unwanted_keys()
         self._add_node_tech_sets()
 
@@ -191,14 +192,12 @@ def _add_time_dimension(self):
         )
         self._update_dtypes()
 
-        # TODO: this is a legacy function, so should be updated to numpy best-practice
-        np.random.seed(seed=self.random_seed)
-
         self.model_data_pre_clustering = self.model_data.copy(deep=True)
-        if self.time_config is not None:
-            self.model_data = time.apply_time_clustering(
-                self.model_data, self.time_config, self.timeseries_data
-            )
+        if self.time_resample is not None:
+            self.model_data = time.resample(self.model_data, self.time_resample)
+        if self.time_cluster is not None:
+            cluster_data = self.timeseries_data.loc[:, self.time_cluster].squeeze()
+            self.model_data = time.cluster(self.model_data, cluster_data)
 
         self.model_data["annualisation_weight"] = (
             self.model_data.timestep_resolution * self.model_data.timestep_weights
diff --git a/src/calliope/preprocess/model_run.py b/src/calliope/preprocess/model_run.py
index 09a125e23..bc65dd4d9 100644
--- a/src/calliope/preprocess/model_run.py
+++ b/src/calliope/preprocess/model_run.py
@@ -65,7 +65,7 @@ def model_run_from_yaml(
 
     """
     config = AttrDict.from_yaml(model_file)
-    config._model_def_path = Path(model_file).as_posix()
+    config._model_def_path = Path(model_file)
 
     config_with_overrides, debug_comments, overrides, scenario = apply_overrides(
         config, scenario=scenario, override_dict=override_dict
@@ -162,16 +162,14 @@ def apply_overrides(model_dict, scenario=None, override_dict=None):
         os.path.join(os.path.dirname(calliope.__file__), "config", "defaults.yaml")
     )
 
-    # Interpret timeseries_data_path as relative
-    if "timeseries_data_path" in model_dict.get_key("config.init", default={}):
-        model_dict.config.init.timeseries_data_path = relative_path(
-            model_dict._model_def_path, model_dict.config.init.timeseries_data_path
-        )
-
     # The input files are allowed to override other model defaults
     default_model_dict.union(model_dict, allow_override=True)
     config_model = default_model_dict.copy()
 
+    config_model.config.init.time_data_path = relative_path(
+        model_dict._model_def_path, model_dict.config.init.time_data_path
+    )
+
     # First pass of applying override dict before applying scenarios,
     # so that can override scenario definitions by override_dict
     if override_dict:
@@ -436,8 +434,8 @@ def process_tech_groups(config_model, techs):
     return tech_groups
 
 
-def load_timeseries_from_file(timeseries_data_path, tskey):
-    file_path = os.path.join(timeseries_data_path, tskey)
+def load_timeseries_from_file(time_data_path, tskey):
+    file_path = os.path.join(time_data_path, tskey)
     df = pd.read_csv(file_path, index_col=0)
     df.columns = pd.MultiIndex.from_product(
         [[tskey], df.columns], names=["source", "column"]
@@ -513,16 +511,13 @@ def _get_names(config):
 def process_timeseries_data(
     model_run: AttrDict,
     timeseries_dfs: Optional[pd.DataFrame],
-    timeseries_data_path: Optional[str],
-    time_config: Optional[AttrDict],
+    time_data_path: Optional[str],
+    time_cluster: Optional[str],
     subset_time_config: Optional[list[str]],
     datetime_format: str,
 ):
     # Generate set of all files and dataframes we want to load
     constraint_tsnames, constraint_tsvars = _get_names(model_run.nodes.as_dict_flat())
-    cluster_tsnames = set()
-    if time_config is not None:
-        cluster_tsnames, _ = _get_names(time_config.as_dict_flat())
 
     # Check if timeseries_dataframes is in the correct format (dict of
     # pandas DataFrames)
@@ -539,11 +534,11 @@ def process_timeseries_data(
     # Load each timeseries into timeseries data. tskey is either a filename
     # (called by file=...) or a key in timeseries_dataframes (called by df=...)
     timeseries_data = pd.DataFrame(dtype=float)
-    for tskey in constraint_tsnames | cluster_tsnames:
+    for tskey in constraint_tsnames:
         # If tskey is a CSV path, load the CSV, else load the dataframe
 
         if tskey[0] == "file":
-            df = load_timeseries_from_file(timeseries_data_path, tskey[1])
+            df = load_timeseries_from_file(time_data_path, tskey[1])
         elif tskey[0] == "df":
             df = load_timeseries_from_dataframe(timeseries_dfs, tskey[1])
         else:
@@ -566,11 +561,22 @@ def process_timeseries_data(
             )
         timeseries_data = pd.concat([timeseries_data, df], axis=1)
 
+    if time_cluster is not None:
+        df = load_timeseries_from_file(time_data_path, time_cluster)
+        try:
+            df.index = _parser(df.index, datetime_format)
+        except ValueError as e:
+            raise exceptions.ModelError(
+                "Error in parsing dates in timeseries data from {}, "
+                "using datetime format `{}`: {}".format(tskey[1], datetime_format, e)
+            )
+        timeseries_data = pd.concat([timeseries_data, df], axis=1)
+
     # Apply time subsetting, if supplied in model_run
     if subset_time_config is not None:
         # Test parsing dates first, to make sure they fit our required subset format
         try:
-            subset_time = _parser(subset_time_config, "ISO8601")
+            time_subset = _parser(subset_time_config, "ISO8601")
         except ValueError as e:
             raise exceptions.ModelError(
                 "Timeseries subset must be in ISO format (anything up to the  "
@@ -582,8 +588,8 @@ def process_timeseries_data(
 
             # Don't allow slicing outside the range of input data
             if (
-                pd.Timestamp(subset_time[0].date()) < timeseries_data.index[0]
-                or pd.Timestamp(subset_time[1].date()) > timeseries_data.index[-1]
+                pd.Timestamp(time_subset[0].date()) < timeseries_data.index[0]
+                or pd.Timestamp(time_subset[1].date()) > timeseries_data.index[-1]
             ):
                 raise exceptions.ModelError(
                     "subset time range {} is outside the input data time range "
@@ -595,7 +601,7 @@ def process_timeseries_data(
                 )
         else:
             raise exceptions.ModelError(
-                "subset_time must be a list of two datetime strings, not: {}".format(
+                "time_subset must be a list of two datetime strings, not: {}".format(
                     subset_time_config
                 )
             )
@@ -614,20 +620,6 @@ def process_timeseries_data(
                 timeseries_data.columns[timeseries_data.isna().any()].values
             )
         )
-    if (
-        cluster_tsnames
-        and timeseries_data[[i[1] for i in cluster_tsnames]]
-        .resample("1D")
-        .mean()
-        .isna()
-        .any()
-        .any()
-    ):
-        raise exceptions.ModelError(
-            "Missing data for the timeseries array(s) {}.".format(
-                timeseries_data.columns[timeseries_data.isna().any()].values
-            )
-        )
 
     return timeseries_data.rename_axis(index="timesteps"), constraint_tsvars
 
@@ -681,10 +673,10 @@ def generate_model_run(
     ) = process_timeseries_data(
         model_run,
         timeseries_dataframes,
-        config.config.init.timeseries_data_path,
-        config.config.init.time,
-        config.config.init.subset_time,
-        config.config.init.timeseries_dateformat,
+        config.config.init.time_data_path,
+        config.config.init.time_cluster,
+        config.config.init.time_subset,
+        config.config.init.time_format,
     )
 
     # 6) Grab additional relevant bits from run and model config
diff --git a/src/calliope/preprocess/time.py b/src/calliope/preprocess/time.py
index 771e77962..0b9b976a0 100644
--- a/src/calliope/preprocess/time.py
+++ b/src/calliope/preprocess/time.py
@@ -8,93 +8,15 @@
 Functionality to add and process time varying parameters
 
 """
+import logging
+
 import numpy as np
 import pandas as pd
 import xarray as xr
 
 from calliope import exceptions
-from calliope.core.attrdict import AttrDict
-from calliope.core.util.tools import plugin_load
-
-
-def apply_time_clustering(
-    model_data: xr.Dataset, time_config: AttrDict, input_timeseries_dfs: pd.DataFrame
-):
-    """
-    Take a Calliope model_data post time dimension addition, prior to any time
-    clustering, and apply relevant time clustering/masking techniques.
-    See doi: 10.1016/j.apenergy.2017.03.051 for applications.
-
-    Techniques include:
-    - Clustering timeseries into a selected number of 'representative' days.
-        Days with similar profiles and daily magnitude are grouped together and
-        represented by one 'representative' day with a greater weight per time
-        step.
-    - Masking timeseries, leading to variable timestep length
-        Only certain parts of the input are shown at full resolution, with other
-        periods being clustered together into a single timestep.
-        E.g. Keep high resolution in the week with greatest wind power variability,
-        smooth all other timesteps to 12H
-    - Timestep resampling
-        Used to reduce problem size by reducing resolution of all timeseries data.
-        E.g. resample from 1H to 6H timesteps
-
-
-    Parameters
-    ----------
-    model_data : xarray Dataset
-        Preprocessed Calliope model_data, as produced using
-        `calliope.preprocess.build_model_data`
-        and found in model._model_data_original
-    model_run : bool
-        preprocessed model_run dictionary, as produced by
-        Calliope.preprocess_model
-
-    Returns
-    -------
-    data : xarray Dataset
-        Dataset with optimisation parameters as variables, optimisation sets as
-        coordinates, and other information in attributes. Time dimension has
-        been updated as per user-defined clustering techniques (from model_run)
-
-    """
 
-    data = model_data.copy(deep=True)
-
-    ##
-    # Process masking and get list of timesteps to keep at high res
-    ##
-    if "masks" in time_config:
-        masks = {}
-        # time.masks is a list of {'function': .., 'options': ..} dicts
-        for entry in time_config.masks:
-            entry = AttrDict(entry)
-            mask_func = plugin_load(
-                entry.function, builtin_module="calliope.time.masks"
-            )
-            mask_kwargs = entry.get_key("options", default=AttrDict()).as_dict()
-            masks[entry.to_yaml()] = mask_func(data, **mask_kwargs)
-        data.attrs["masks"] = masks
-        # Concatenate the DatetimeIndexes by using dummy Series
-        chosen_timesteps = pd.concat(
-            [pd.Series(0, index=m) for m in masks.values()]
-        ).index
-        # timesteps: a list of timesteps NOT picked by masks
-        timesteps = pd.Index(data.timesteps.values).difference(chosen_timesteps)
-    else:
-        timesteps = None
-
-    ##
-    # Process function, apply resolution adjustments
-    ##
-    if "function" in time_config:
-        func = plugin_load(time_config.function, builtin_module="calliope.time.funcs")
-        func_kwargs = time_config.get("function_options", AttrDict()).as_dict()
-        if "file=" in func_kwargs.get("clustering_func", ""):
-            func_kwargs.update({"clustering_timeseries": input_timeseries_dfs})
-        data = func(data=data, timesteps=timesteps, **func_kwargs)
-
-    return data
+LOGGER = logging.getLogger(__name__)
 
 
 def add_time_dimension(
@@ -220,3 +142,124 @@ def update_dtypes(model_data):
                     except ValueError:
                         None
     return model_data
+
+
+def resample(data: xr.Dataset, resolution: str):
+    """
+    Function to resample timeseries data from the input resolution (e.g. 1H), to
+    the given resolution (e.g. 2H)
+
+    Parameters
+    ----------
+    data : xarray.Dataset
+        calliope model data, containing only timeseries data variables
+    timesteps : str or list; optional
+        If given, apply resampling to a subset of the timeseries data
+    resolution : str
+        time resolution of the output data, given in Pandas time frequency format.
+        E.g. 1H = 1 hour, 1W = 1 week, 1M = 1 month, 1T = 1 minute. Multiples allowed.
+
+    """
+    resample_kwargs = {"indexer": {"timesteps": resolution}, "skipna": True}
+    data_non_ts = data.drop_dims("timesteps")
+    data_ts = data.drop(data_non_ts.data_vars)
+    data_ts_resampled = data_ts.resample(**resample_kwargs).first(keep_attrs=True)
+
+    for var_name, var_data in data_ts.data_vars.items():
+        resampler = var_data.resample(**resample_kwargs)
+        if var_name in [
+            "timestep_resolution",
+            "source_min",
+            "sink_min",
+            "source_max",
+            "sink_max",
+            "source_equals",
+            "sink_equals",
+        ]:
+            method = "sum"
+        elif var_data.dtype.kind in ["f", "i"]:
+            method = "mean"
+        else:
+            method = "first"
+        data_ts_resampled[var_name] = getattr(resampler, method)(keep_attrs=True)
+        LOGGER.debug(
+            f"Time Resampling | {var_name} | resampling function used: {method}"
+        )
+
+    data_new = xr.merge([data_non_ts, data_ts_resampled])
+
+    # Resampling still permits operational mode
+    data_new.attrs["allow_operate_mode"] = 1
+
+    return data_new
+
+
+def cluster(
+    data: xr.Dataset,
+    clustering_timeseries: pd.Series,
+):
+    """
+    Apply the given clustering time series to the given data.
+
+    Parameters
+    ----------
+    data : xarray.Dataset
+    clustering_timeseries
+
+    Returns
+    -------
+    data_new_scaled : xarray.Dataset
+
+    """
+    representative_days = pd.to_datetime(clustering_timeseries.dropna()).dt.date
+    grouper = representative_days.to_frame("clusters").groupby("clusters")
+    data_new = data.sel(
+        timesteps=data.timesteps.dt.date.isin(representative_days.values)
+    )
+    date_series = data_new.timesteps.dt.date.to_series()
+    data_new["timestep_cluster"] = xr.DataArray(
+        grouper.ngroup().reindex(date_series).values, dims="timesteps"
+    )
+    data_new["timestep_weights"] = xr.DataArray(
+        grouper.value_counts().reindex(date_series).values, dims="timesteps"
+    )
+    data_new.coords["datesteps"] = representative_days.index.rename("datesteps")
+    data_new.coords["clusters"] = np.unique(data_new["timestep_cluster"].values)
+    # Clustering no longer permits operational mode
+    data_new.attrs["allow_operate_mode"] = 0
+    _lookup_clusters(data_new, grouper)
+
+    return data_new
+
+
+def _lookup_clusters(dataset: xr.Dataset, grouper: pd.Series) -> xr.Dataset:
+    """
+    For any given timestep in a time clustered model, get:
+    1. the first and last timestep of the cluster,
+    2. the last timestep of the cluster corresponding to a date in the original timeseries
+    """
+
+    dataset["lookup_cluster_first_timestep"] = dataset.timesteps.isin(
+        dataset.timesteps.groupby("timesteps.date").first()
+    )
+    dataset["lookup_cluster_last_timestep"] = dataset.timesteps.isin(
+        dataset.timesteps.groupby("timesteps.date").last()
+    )
+
+    dataset["lookup_datestep_cluster"] = xr.DataArray(
+        grouper.ngroup(), dims="datesteps"
+    )
+
+    last_timesteps = (
+        dataset.timestep_cluster.to_series()
+        .reset_index()
+        .groupby("timestep_cluster")
+        .last()
+    )
+    dataset["lookup_datestep_last_cluster_timestep"] = (
+        dataset.lookup_datestep_cluster.to_series()
+        .map(last_timesteps.timesteps)
+        .to_xarray()
+    )
+
+    return dataset
diff --git a/src/calliope/time/__init__.py b/src/calliope/time/__init__.py
deleted file mode 100644
index 6a9c11e6b..000000000
--- a/src/calliope/time/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-# flake8: noqa
-
-from calliope.time import clustering, funcs, masks
diff --git a/src/calliope/time/clustering.py b/src/calliope/time/clustering.py
deleted file mode 100644
index 0320a0c52..000000000
--- a/src/calliope/time/clustering.py
+++ /dev/null
@@ -1,467 +0,0 @@
-# Copyright (C) since 2013 Calliope contributors listed in AUTHORS.
-# Licensed under the Apache 2.0 License (see LICENSE file).
-
-"""
-clustering.py
-~~~~~~~~~~~~~
-
-Functions to cluster data along the time dimension.
-
-"""
-import numpy as np
-import pandas as pd
-import xarray as xr
-from sklearn import cluster as sk_cluster
-from sklearn.metrics import mean_squared_error
-
-from calliope import exceptions
-
-
-def _stack_data(data, dates, times):
-    """
-    Stack all non-time dimensions of an xarray DataArray
-    """
-    data_to_stack = data.assign_coords(
-        xr.Coordinates.from_pandas_multiindex(
-            pd.MultiIndex.from_product([dates, times], names=["dates", "times"]),
-            "timesteps",
-        )
-    ).unstack("timesteps")
-    non_date_dims = list(set(data_to_stack.dims).difference(["dates", "times"])) + [
-        "times"
-    ]
-    if len(non_date_dims) >= 2:
-        stacked_var = data_to_stack.stack(stacked=non_date_dims)
-    else:
-        raise exceptions.ModelError(
-            "Cannot conduct time clustering with variable {} as it has no "
-            "non-time dimensions.".format(data.name)
-        )
-    return stacked_var
-
-
-def reshape_for_clustering(data, loc_techs=None, variables=None):
-    """
-    Create an array of timeseries values, where each day has a row of all
-    hourly timeseries data from all relevant variables
-
-    Parameters
-    ----------
-    data : xarray Dataset
-        Dataset with all non-time dependent variables removed
-    loc_techs : string or list-like, default = None
-        If clustering over a subset of loc_techs, they are listed here
-    variables : string or list-like, default = None
-        If clustering over a subset of timeseries variables, they are listed here
-
-    Returns
-    -------
-    X : numpy 2darray
-        Array with all rows as days and all other data in columns, with NaNs
-        converted to zeros
-    """
-    # to create an array with days as rows, we need to get timesteps per day
-    timesteps = data.timesteps.to_index()
-    if timesteps.dtype.kind == "M":  # 'M' = datetime format
-        dates = np.unique(timesteps.date)
-        times = np.unique(timesteps.time)
-    else:  # mean_data from get_closest_days_from_clusters is in the format cluster-timestep
-        dates = np.unique([i.split("-")[0] for i in timesteps])
-        times = np.unique([i.split("-")[1] for i in timesteps])
-
-    reshaped_data = np.array([[] for i in dates])
-    # if 'variables' is given then we will loop over that, otherwise we loop over
-    # all timeseries variables
-    relevent_vars = variables if variables else data.data_vars
-
-    # loop over variables to get our arrays
-    for var in relevent_vars:
-        temp_data = data[var].copy()
-        # if there is a loc_tech subset, index over that
-        if loc_techs:
-            loc_tech_dim = [i for i in data[var].dims if "loc_techs" in i][0]
-            relevent_loc_techs = list(
-                set(temp_data[loc_tech_dim].values).intersection(loc_techs)
-            )
-            temp_data = temp_data.loc[{loc_tech_dim: relevent_loc_techs}]
-
-        # stack all non-time dimensions to get one row of data for each timestep
-        stacked_var = _stack_data(temp_data, dates, times)
-        # reshape the array to split days and timesteps within days, now we have
-        # one row of data per day
-
-        reshaped_data = np.concatenate([reshaped_data, stacked_var.values], axis=1)
-    # put all the columns of data together, keeping rows as days. Also convert
-    # all nans to zeros
-
-    X = np.nan_to_num(reshaped_data, nan=0.0, posinf=0.0, neginf=0.0)
-
-    return X
-
-
-def get_mean_from_clusters(data, clusters, timesteps_per_day):
-    """
-    Clusters are days which are considered similar to each other. Here we find
-    the mean value (in each tech dimension) for each cluster and return it
-
-    Parameters
-    ----------
-    data : xarray Dataset
-        Dataset with all non-time dependent variables removed
-    clusters : pandas DataFrame
-        index as days, columns as cluster group to which that day is associated
-    timesteps_per_day : int
-        Number of timesteps in each day
-
-    Returns
-    -------
-    ds : xarray Dataset
-        Dataset of timeseries DataArrays. Time dimension is only equal to the
-        number of clusters * timesteps_per_day.
-    """
-
-    cluster_map = clusters.groupby(clusters).groups
-
-    ds = {}
-    t_coords = [
-        "{}-{}".format(cid, t) for cid in cluster_map for t in range(timesteps_per_day)
-    ]
-    for var in data.data_vars:
-        clustered_array = (
-            data[var].loc[{"timesteps": data.timesteps[: len(t_coords)]}].copy()
-        )
-        clustered_array["timesteps"] = t_coords
-        for cluster_id, cluster_members in cluster_map.items():
-            current_cluster = [
-                i for i in t_coords if i.startswith("{}-".format(cluster_id))
-            ]
-            clustered_array.loc[{"timesteps": current_cluster}] = (
-                data[var]
-                .loc[{"timesteps": cluster_members}]
-                .groupby("timesteps.time")
-                .mean(dim="timesteps")
-                .values
-            )
-        ds[var] = clustered_array
-    ds = xr.Dataset(ds)
-    return ds
-
-
-def find_nearest_vector_index(array, value, metric="rmse"):
-    """
-    compares the data for one cluster to every day in the timeseries, to find the
-    day which most closely matches.
-
-    Parameters
-    ----------
-    array : np.ndarray
-        full timeseries array,
-        shape = (num_dates, num_loc_techs * num_vars * num_timesteps_per_day)
-        The shape is acheived by running a dataset through clustering.reshape_for_clustering
-    value : np.ndarray
-        one cluster of data,
-        shape = (1, num_loc_techs * num_vars * num_timesteps_per_day)
-        The shape is acheived by running the mean clustered dataset,
-        subset over one cluster, through clustering.reshape_for_clustering
-    metric : str, default = 'rmse'
-        Error metric with which to compare the array and the values.
-        If 'rmse', will compare the root mean square error between `value` and
-        each date in `array`.
-        If 'abs' will compare the absolute difference between `value` and
-        each date in `array`.
-    """
-    if metric == "rmse":
-        error = np.array([mean_squared_error(i, value[0]) for i in array])
-    elif metric == "abs":
-        error = np.array([np.linalg.norm(sum(y)) for y in array - value])
-    else:
-        raise ValueError(
-            "Error metric can only be `rmse` or `abs`, {} given".format(metric)
-        )
-
-    return error.argmin()
-
-
-def get_closest_days_from_clusters(data, mean_data, clusters, daily_timesteps):
-    """
-    Given a set of mean cluster timeseries profiles, find the day in the full
-    timeseries that matches each cluster profile most closely.
-
-    Parameters
-    ----------
-    data : xarray Dataset
-        Input model data, with all non-timeseries data vars dropped
-    mean_data : xarray Dataset
-        Mean per cluster of input model data, resulting timeseries is of the form
-        X-Y where X is the cluster number and Y is the timestep (both integers)
-    clusters : pd.Series
-        Cluster to which each date is aligned.
-        Index = dates of the full timeseries, values = cluster number
-    daily_timesteps : list
-        hours assigned to each timestep in a day (e.g. an entry of 0.25 = 15 minutes).
-        We expect uniform timesteps between days, which is checked prior to
-        reaching this function.
-
-    Returns
-        new_data : xarray Dataset
-            data, indexed over only the timesteps within the days that most
-            closely match the cluster means.
-            Number of timesteps = timesteps_per_day * num_clusters. May be lower
-            than this if many cluster means match with the same day
-        chosen_day_timestepas : dict
-            The day assigned to each cluster. key = cluster number, value = date.
-    """
-    dtindex = data["timesteps"].to_index()
-    timesteps_per_day = len(daily_timesteps)
-    chosen_days = {}
-
-    for cluster in sorted(clusters.unique()):
-        subset_t = [
-            t
-            for t in mean_data.timesteps.values
-            if t.startswith("{}-".format(int(cluster)))
-        ]
-
-        target = reshape_for_clustering(mean_data.loc[dict(timesteps=subset_t)])
-        lookup_array = reshape_for_clustering(data)
-
-        chosen_days[cluster] = find_nearest_vector_index(lookup_array, target)
-
-    days_list = sorted(list(set(chosen_days.values())))
-    new_t_coord = _timesteps_from_daily_index(
-        dtindex[::timesteps_per_day][days_list], daily_timesteps
-    )
-
-    chosen_day_timestamps = {
-        k: dtindex[::timesteps_per_day][v] for k, v in chosen_days.items()
-    }
-
-    new_data = data.loc[dict(timesteps=new_t_coord)]
-
-    return new_data, chosen_day_timestamps
-
-
-def _timesteps_from_daily_index(idx, daily_timesteps):
-    new_idx = pd.Index(
-        [
-            date + pd.DateOffset(hours=sum(daily_timesteps[:timestep]))
-            for date in idx
-            for timestep in range(len(daily_timesteps))
-        ]
-    )
-    return new_idx
-
-
-def map_clusters_to_data(
-    data, clusters, how, daily_timesteps, storage_inter_cluster=True
-):
-    """
-    Returns a copy of data that has been clustered.
-
-    Parameters
-    ----------
-    how : str
-        How to select data from clusters. Can be mean (centroid) or closest real
-        day to the mean (by root mean square error).
-    storage_inter_cluster : bool, default=True
-        If True, add `datesteps` to model_data, for use in the backend to build
-        inter_cluster storage decision variables and constraints
-    """
-    # FIXME hardcoded time intervals ('1H', '1D')
-
-    # Get all timesteps, not just the first per day
-    timesteps_per_day = len(daily_timesteps)
-    idx = clusters.index
-    new_idx = _timesteps_from_daily_index(idx, daily_timesteps)
-    clusters_timeseries = clusters.reindex(new_idx).ffill().astype(int)
-
-    new_data = get_mean_from_clusters(data, clusters_timeseries, timesteps_per_day)
-    new_data.attrs = data.attrs
-
-    if how == "mean":
-        # Add timestep names by taking the median timestamp from daily clusters...
-        # (a random way of doing it, but we want some label to apply)
-        timestamps = clusters.groupby(clusters).apply(
-            lambda x: x.index[int(len(x.index) / 2)]
-        )
-        new_data.coords["timesteps"] = _timesteps_from_daily_index(
-            pd.Index(timestamps.values), daily_timesteps
-        )
-        # Generate weights
-        # weight of each timestep = number of timesteps in this timestep's cluster
-        # divided by timesteps per day (since we're grouping days together and
-        # a cluster consisting of 1 day = 24 hours should have weight of 1)
-        value_counts = clusters_timeseries.value_counts() / timesteps_per_day
-        # And turn the index into dates (days)
-        value_counts = pd.DataFrame(
-            {"dates": timestamps, "counts": value_counts}
-        ).set_index("dates")["counts"]
-
-    elif how == "closest":
-        new_data, chosen_ts = get_closest_days_from_clusters(
-            data, new_data, clusters, daily_timesteps
-        )
-        # Deal with the case where more than one cluster has the same closest day
-        # An easy way is to rename the original clusters with the chosen days
-        # So at this point, clusterdays_timeseries maps all timesteps to the day
-        # of year of the cluster the timestep belongs to
-        clusterdays_timeseries = clusters_timeseries.map(lambda x: chosen_ts[x])
-        value_counts = clusterdays_timeseries.value_counts() / timesteps_per_day
-        timestamps = pd.DataFrame.from_dict(chosen_ts, orient="index")[0]
-        cluster_diff = len(clusters.unique()) - len(timestamps.unique())
-        if cluster_diff > 0:
-            exceptions.warn(
-                "Creating {} fewer clusters as some clusters share the same "
-                "closest day".format(cluster_diff)
-            )
-            timestamps = timestamps.drop_duplicates()
-            clusters = pd.Series(index=clusterdays_timeseries.index, dtype=int)
-            for cluster, date in timestamps.items():
-                clusters.loc[clusterdays_timeseries == date] = cluster
-            clusters = clusters.resample("1D").mean()
-
-    _clusters = xr.DataArray(
-        data=np.full(len(new_data.timesteps.values), np.nan),
-        dims="timesteps",
-        coords={"timesteps": new_data.timesteps.values},
-    )
-
-    for cluster, date in timestamps.items():
-        _clusters.loc[date.strftime("%Y-%m-%d")] = cluster
-
-    new_data["timestep_cluster"] = _clusters.astype(int)
-    weights = value_counts.reindex(
-        _timesteps_from_daily_index(value_counts.index, daily_timesteps)
-    ).ffill()
-    new_data["timestep_weights"] = xr.DataArray(weights, dims=["timesteps"])
-    days = np.unique(new_data.timesteps.to_index().date)
-    new_data["timestep_resolution"] = xr.DataArray(
-        np.tile(daily_timesteps, len(days)),
-        dims=["timesteps"],
-        coords={"timesteps": new_data["timesteps"]},
-    )
-
-    if storage_inter_cluster:
-        clusters.index.name = "datesteps"
-        new_data["lookup_datestep_cluster"] = xr.DataArray.from_series(clusters)
-    timestamps.index.name = "clusters"
-    new_data.coords["clusters"] = timestamps.index
-
-    return new_data
-
-
-def get_clusters(
-    data,
-    func,
-    timesteps_per_day,
-    tech=None,
-    timesteps=None,
-    k=None,
-    variables=None,
-    **kwargs,
-):
-    """
-    Run a clustering algorithm on the timeseries data supplied. All timeseries
-    data is reshaped into one row per day before clustering into similar days.
-
-    Parameters
-    ----------
-    data : xarray.Dataset
-        Should be normalized
-    func : str
-        'kmeans' or 'hierarchical' for KMeans or Agglomerative clustering, respectively
-    timesteps_per_day : int
-        Total number of timesteps in a day
-    tech : list, optional
-        list of strings referring to technologies by which clustering is undertaken.
-        If none (default), all technologies within timeseries variables will be used.
-    timesteps : list or str, optional
-        Subset of the time domain within which to apply clustering.
-    k : int, optional
-        Number of clusters to create. If none (default), will use Hartigan's rule
-        to infer a reasonable number of clusters.
-    variables : list, optional
-        data variables (e.g. `source_max`, `flow_eff`) by whose values the data
-        will be clustered. If none (default), all timeseries variables will be used.
-    kwargs : dict
-        Additional keyword arguments available depend on the `func`.
-        For available KMeans kwargs see:
-        :class:`sklearn.cluster.KMeans`
-        For available hierarchical kwargs see:
-        :class:`sklearn.cluster.AgglomerativeClustering`
-    Returns
-    -------
-    clusters : pandas.DataFrame
-        Indexed by timesteps and with nodes as columns, giving cluster
-        membership for first timestep of each day.
-    clustered_data : sklearn.cluster object
-        Result of clustering using :python:`sklearn.KMeans(k).fit(X)` or
-        :python:`sklearn.KMeans(k).AgglomerativeClustering(X)`. Allows user to access
-        specific attributes, for detailed statistical analysis.
-
-    """
-
-    if timesteps is not None:
-        data = data.loc[{"timesteps": timesteps}]
-    else:
-        timesteps = data.timesteps.values
-
-    X = reshape_for_clustering(data, tech, variables)
-
-    if func == "kmeans":
-        if k is None:
-            k = hartigan_n_clusters(X)
-            exceptions.warn(
-                "Used Hartigan's rule to determine that"
-                "a good number of clusters is {}.".format(k)
-            )
-        clustered_data = sk_cluster.KMeans(k, n_init=10).fit(X)
-
-    elif func == "hierarchical":
-        if k is None:
-            raise exceptions.ModelError(
-                "Cannot undertake hierarchical clustering without a predefined "
-                "number of clusters (k)"
-            )
-        clustered_data = sk_cluster.AgglomerativeClustering(k).fit(X)
-
-    # Determine the cluster membership of each day
-    day_clusters = clustered_data.labels_
-
-    # Create mapping of timesteps to clusters
-    clusters = pd.Series(day_clusters, index=timesteps[::timesteps_per_day])
-
-    return clusters, clustered_data
-
-
-def hartigan_n_clusters(X, threshold=10):
-    """
-    Try clustering using sklearn.cluster.kmeans, for several cluster sizes.
-    Using Hartigan's rule, we will return the number of clusters after which
-    the benefit of clustering is low.
-    """
-
-    def _H_rule(inertia, inertia_plus_one, n_clusters, len_input):
-        # see http://www.dcs.bbk.ac.uk/~mirkin/papers/00357_07-216RR_mirkin.pdf
-        return ((inertia / inertia_plus_one) - 1) * (len_input - n_clusters - 1)
-
-    len_input = len(X)
-    n_clusters = 1
-    HK = threshold + 1
-
-    while n_clusters <= len_input and HK > threshold:
-        kmeans = sk_cluster.KMeans(n_clusters=n_clusters).fit(X)
-        kmeans_plus_one = sk_cluster.KMeans(n_clusters=n_clusters + 1).fit(X)
-
-        inertia = kmeans.inertia_
-        inertia_plus_one = kmeans_plus_one.inertia_
-
-        HK = _H_rule(inertia, inertia_plus_one, n_clusters, len_input)
-
-        n_clusters += 1
-
-    if HK > threshold:  # i.e. we went to the limit where n_clusters = len_input
-        exceptions.warn("Based on threshold, number of clusters = number of dates")
-        return len_input
-    else:
-        return n_clusters - 1
diff --git a/src/calliope/time/funcs.py b/src/calliope/time/funcs.py
deleted file mode 100644
index 30055f743..000000000
--- a/src/calliope/time/funcs.py
+++ /dev/null
@@ -1,410 +0,0 @@
-# Copyright (C) since 2013 Calliope contributors listed in AUTHORS.
-# Licensed under the Apache 2.0 License (see LICENSE file).
-
-"""
-funcs.py
-~~~~~~~~
-
-Functions to process time series data.
-
-"""
-
-import datetime
-import logging
-
-import numpy as np
-import pandas as pd
-import xarray as xr
-
-from calliope import exceptions
-from calliope.time import clustering
-
-logger = logging.getLogger(__name__)
-
-
-def get_daily_timesteps(data, check_uniformity=False):
-    daily_timesteps = [
-        data.timestep_resolution.loc[i].values
-        for i in np.unique(data.timesteps.to_index().strftime("%Y-%m-%d"))
-    ]
-
-    if check_uniformity:
-        if not np.all(daily_timesteps == daily_timesteps[0]):
-            raise exceptions.ModelError(
-                "For clustering, timestep resolution must be uniform."
-            )
-
-    return daily_timesteps[0]
-
-
-def normalized_copy(data):
-    """
-    Normalize timeseries data, using the maximum across all regions and timesteps.
-
-    Parameters
-    ----------
-    data : xarray Dataset
-        Dataset with all non-time dependent variables removed
-
-    Returns
-    -------
-    ds : xarray Dataset
-        Copy of `data`, with the absolute taken and normalized to 0-1
-
-    """
-    ds = data.copy(deep=True)  # Work off a copy
-
-    for var in ds.data_vars:
-        ds[var] = abs(ds[var] / abs(ds[var]).groupby("techs").max(..., skipna=True))
-    return ds
-
-
-def _copy_non_t_vars(data0, data1):
-    """Copies non-t-indexed variables from data0 into data1, then
-    returns data1"""
-    non_t_vars = [
-        varname
-        for varname, vardata in data0.data_vars.items()
-        if "timesteps" not in vardata.dims
-    ]
-    # Manually copy over variables not in `timesteps`. If we don't do this,
-    # these vars get polluted with a superfluous `timesteps` dimension
-    for v in non_t_vars:
-        data1[v] = data0[v]
-    return data1
-
-
-def _combine_datasets(data0, data1):
-    """Concatenates data0 and data1 along the time dimension"""
-    data_new = xr.concat([data0, data1], dim="timesteps")
-    # Ensure time dimension is ordered
-    data_new = data_new.loc[{"timesteps": data_new.timesteps.to_index().sort_values()}]
-
-    return data_new
-
-
-def _drop_timestep_vars(data, timesteps):
-    timeseries_data = data.copy(deep=True)
-    # Save all coordinates, to ensure they can be added back in after clustering
-    data_coords = data.copy().coords
-    del data_coords["timesteps"]
-
-    if timesteps is not None:
-        timeseries_data = timeseries_data.loc[{"timesteps": timesteps}]
-
-    timeseries_data = timeseries_data.drop_vars(
-        [
-            varname
-            for varname, vardata in data.data_vars.items()
-            if "timesteps" not in vardata.dims
-        ]
-    )
-
-    return timeseries_data, data_coords
-
-
-def apply_clustering(
-    data,
-    timesteps,
-    clustering_func,
-    how,
-    normalize=True,
-    scale_clusters="mean",
-    storage_inter_cluster=True,
-    clustering_timeseries=None,
-    **kwargs,
-):
-    """
-    Apply the given clustering function to the given data.
-
-    Parameters
-    ----------
-    data : xarray.Dataset
-    timesteps : pandas.DatetimeIndex or list of timesteps or None
-    clustering_func : str
-        Name of clustering function. Can be `file=....csv:column_name`
-        if loading custom clustering. Custom clustering index = timeseries days.
-        If no column_name, the CSV file must have only one column of data.
-    how : str
-        How to map clusters to data. 'mean' or 'closest'.
-    normalize : bool, optional
-        If True (default), data is normalized before clustering is applied,
-        using :func:`~calliope.time.funcs.normalized_copy`.
-    scale_clusters : str or None, default = 'mean'
-        Scale the results of clustering such that the clusters match the metric
-        given by scale_clusters. For example, 'mean' scales along each loc_tech
-        and variable to match inputs and outputs. Other options for matching
-        include 'sum', 'max', and 'min'. If None, no scaling occurs.
-    **kwargs : optional
-        Arguments passed to clustering_func.
-
-    Returns
-    -------
-    data_new_scaled : xarray.Dataset
-
-    """
-
-    assert how in ["mean", "closest"]
-
-    daily_timesteps = get_daily_timesteps(data, check_uniformity=True)
-    timesteps_per_day = len(daily_timesteps)
-
-    # get a copy of the dataset with only timeseries variables,
-    # and get all coordinates of the original dataset, to reinstate later
-    data_to_cluster, data_coords = _drop_timestep_vars(data, timesteps)
-
-    data_to_cluster = data_to_cluster.drop_vars(
-        ["timestep_weights", "timestep_resolution"]
-    )
-
-    for dim in data_to_cluster.dims:
-        data_to_cluster[dim] = data[dim]
-
-    data_to_cluster = data_to_cluster.where(~np.isinf(data_to_cluster))
-    if normalize:
-        data_normalized = normalized_copy(data_to_cluster)
-    else:
-        data_normalized = data_to_cluster
-
-    if "file=" in clustering_func:
-        file = clustering_func.split("=")[1]
-        if ":" in file:
-            file, column = file.rsplit(":", 1)
-        else:
-            column = None
-
-        df = clustering_timeseries[file]
-        if isinstance(df, pd.Series) and column is not None:
-            exceptions.warn(
-                "{} given as time clustering column, but only one column to "
-                "choose from in {}.".format(column, file)
-            )
-            clusters = df.resample("1D").mean()
-        elif isinstance(df, pd.DataFrame) and column is None:
-            raise exceptions.ModelError(
-                "No time clustering column given, but multiple columns found in "
-                "{0}. Choose one column and add it to {1} as {1}:name_of_column.".format(
-                    file, clustering_func
-                )
-            )
-        elif isinstance(df, pd.DataFrame) and column not in df.columns:
-            raise KeyError(
-                "time clustering column {} not found in {}.".format(column, file)
-            )
-        elif isinstance(df, pd.DataFrame):
-            clusters = (
-                df.loc[:, column].dropna().groupby(pd.Grouper(freq="1D")).unique()
-            )
-
-        # Check there weren't instances of more than one cluster assigned to a day
-        # or days with no information assigned
-        if any([len(i) == 0 for i in clusters.values]):
-            raise exceptions.ModelError(
-                "Missing cluster days in `{}:{}`.".format(file, column)
-            )
-        elif any([len(i) > 1 for i in clusters.values]):
-            raise exceptions.ModelError(
-                "More than one cluster value assigned to a day in `{}:{}`. "
-                "Unique clusters per day: {}".format(file, column, clusters)
-            )
-        else:
-            clusters.loc[:] = [i[0] for i in clusters.values]
-
-    else:
-        result = clustering.get_clusters(
-            data_normalized,
-            clustering_func,
-            timesteps_per_day=timesteps_per_day,
-            **kwargs,
-        )
-        clusters = result[0]  # Ignore other stuff returned
-
-    data_new = clustering.map_clusters_to_data(
-        data_to_cluster,
-        clusters,
-        how=how,
-        daily_timesteps=daily_timesteps,
-        storage_inter_cluster=storage_inter_cluster,
-    )
-
-    # It's now safe to add the original coordinates back in (preserving all the
-    # loc_tech sets that aren't used to index a variable in the DataArray)
-    data_new.update(data_coords)
-
-    data_new = _copy_non_t_vars(data, data_new)
-
-    if timesteps is not None:
-        data_new = _copy_non_t_vars(data, data_new)
-        data_new = _combine_datasets(data.drop_sel(timesteps=timesteps), data_new)
-        data_new = _copy_non_t_vars(data, data_new)
-
-    # Scale the new/combined data so that the mean for each (loc_tech, variable)
-    # combination matches that from the original data
-    data_new_scaled = data_new.copy(deep=True)
-    if scale_clusters:
-        data_vars_in_t = [
-            v
-            for v in data_new.data_vars
-            if "timesteps" in data_new[v].dims
-            and "timestep_" not in v
-            and v != "clusters"
-        ]
-        for var in data_vars_in_t:
-            scale = getattr(data[var], scale_clusters)(dim="timesteps") / getattr(
-                data_new[var], scale_clusters
-            )(dim="timesteps")
-            data_new_scaled[var] = data_new[var] * scale.fillna(0)
-
-    lookup_clusters(data_new_scaled)
-
-    return data_new_scaled
-
-
-def resample(data, timesteps, resolution):
-    """
-    Function to resample timeseries data from the input resolution (e.g. 1H), to
-    the given resolution (e.g. 2H)
-
-    Parameters
-    ----------
-    data : xarray.Dataset
-        calliope model data, containing only timeseries data variables
-    timesteps : str or list; optional
-        If given, apply resampling to a subset of the timeseries data
-    resolution : str
-        time resolution of the output data, given in Pandas time frequency format.
-        E.g. 1H = 1 hour, 1W = 1 week, 1M = 1 month, 1T = 1 minute. Multiples allowed.
-
-    """
-
-    def _resample(var, how):
-        return getattr(var.resample(timesteps=resolution), how)(
-            "timesteps", keep_attrs=True
-        )
-
-    # get a copy of the dataset with only timeseries variables,
-    # and get all coordinates of the original dataset, to reinstate later
-    data_new, data_coords = _drop_timestep_vars(data, timesteps)
-
-    # First create a new resampled dataset of the correct size by
-    # using first-resample, which should be a quick way to achieve this
-
-    data_rs = _resample(data_new, how="first")
-
-    for var in data_rs.data_vars:
-        if var in [
-            "timestep_resolution",
-            "source_min",
-            "sink_min",
-            "source_max",
-            "sink_max",
-            "source_equals",
-            "sink_equals",
-        ]:
-            data_rs[var] = _resample(data_new[var], how="sum")
-        else:
-            try:
-                data_rs[var] = _resample(data_new[var], how="mean")
-            except TypeError:
-                # If the var has a datatype of strings, it can't be resampled
-                logger.error(
-                    "Dropping {} because it has a {} data type when integer or "
-                    "float is expected for timeseries resampling.".format(
-                        var, data_rs[var].dtype
-                    )
-                )
-                data_rs = data_rs.drop_vars(var)
-
-    # Get rid of the filled-in NaN timestamps
-    data_rs = data_rs.dropna(dim="timesteps", how="all")
-
-    data_rs.attrs["allow_operate_mode"] = 1  # Resampling still permits operational mode
-
-    # It's now safe to add the original coordinates back in (preserving all the
-    # loc_tech sets that aren't used to index a variable in the DataArray)
-    data_rs.update(data_coords)
-
-    data_rs = _copy_non_t_vars(data, data_rs)  # add back in non timeseries data
-
-    if timesteps is not None:
-        # Combine leftover parts of passed in data with new data
-        data_rs = _combine_datasets(data.drop_sel(timesteps=timesteps), data_rs)
-        data_rs = _copy_non_t_vars(data, data_rs)
-        # Having timesteps with different lengths does not permit operational mode
-        data_rs.attrs["allow_operate_mode"] = 0
-
-    return data_rs
-
-
-def drop(data, timesteps):
-    """
-    Drop timesteps from data, adjusting the timestep weight of remaining
-    timesteps accordingly. Returns updated dataset.
-
-    Parameters
-    ----------
-    data : xarray.Dataset
-        Calliope model data.
-    timesteps : str or list or other iterable
-        Pandas-compatible timestep strings.
-
-    """
-    # Turn timesteps into a pandas datetime index for subsetting, which also
-    # checks whether they are actually valid
-    try:
-        timesteps_pd = pd.to_datetime(timesteps)
-    except Exception:
-        raise exceptions.ModelError("Invalid timesteps: {}".format(timesteps))
-
-    # 'Distribute weight' of the dropped timesteps onto the remaining ones
-    dropped_weight = data.timestep_weights.loc[{"timesteps": timesteps_pd}].sum()
-
-    data = data.drop_sel(timesteps=timesteps_pd)
-
-    data["timestep_weights"] = data["timestep_weights"] + (
-        dropped_weight / len(data["timestep_weights"])
-    )
-
-    return data
-
-
-def lookup_clusters(dataset):
-    """
-    For any given timestep in a time clustered model, get:
-    1. the first and last timestep of the cluster,
-    2. the last timestep of the cluster corresponding to a date in the original timeseries
-    """
-
-    data_dict_first = dict(dims=["timesteps"], data=[])
-    data_dict_last = dict(dims=["timesteps"], data=[])
-    for timestep in dataset.timesteps:
-        t = pd.to_datetime(timestep.item()).date().strftime("%Y-%m-%d")
-        timestep_first = dataset.timesteps.loc[t][0]
-        timestep_last = dataset.timesteps.loc[t][-1]
-        if timestep == timestep_first:
-            data_dict_first["data"].append(1)
-            data_dict_last["data"].append(timestep_last.values)
-        else:
-            data_dict_first["data"].append(0)
-            data_dict_last["data"].append(None)
-    dataset["lookup_cluster_first_timestep"] = xr.DataArray.from_dict(data_dict_first)
-    dataset["lookup_cluster_last_timestep"] = xr.DataArray.from_dict(data_dict_last)
-
-    if "datesteps" in dataset.dims:
-        last_timesteps = dict(dims=["datesteps"], data=[])
-        cluster_date = dataset.timestep_cluster.to_pandas().resample("1D").mean()
-        for datestep in dataset.datesteps.to_index():
-            cluster = dataset.lookup_datestep_cluster.loc[
-                datestep.strftime("%Y-%m-%d")
-            ].item()
-            last_timesteps["data"].append(
-                datetime.datetime.combine(
-                    cluster_date[cluster_date == cluster].index[0].date(),
-                    dataset.timesteps.to_index().time[-1],
-                )
-            )
-        dataset["lookup_datestep_last_cluster_timestep"] = xr.DataArray.from_dict(
-            last_timesteps
-        )
-
-    return dataset
diff --git a/src/calliope/time/masks.py b/src/calliope/time/masks.py
deleted file mode 100644
index 39b0df1b9..000000000
--- a/src/calliope/time/masks.py
+++ /dev/null
@@ -1,258 +0,0 @@
-# Copyright (C) since 2013 Calliope contributors listed in AUTHORS.
-# Licensed under the Apache 2.0 License (see LICENSE file).
-
-"""
-masks.py
-~~~~~~~~
-
-Functions to pick timesteps from data given certain criteria.
-
-"""
-
-import pandas as pd
-
-from calliope import exceptions
-from calliope.time import funcs
-
-
-def _get_array(data, var, tech, **kwargs):
-    subset = {"techs": tech}
-    if kwargs is not None:
-        subset.update({k: v for k, v in kwargs.items()})
-
-    unusable_dims = (
-        set(subset.keys()).difference(["techs", "nodes"]).difference(data[var].dims)
-    )
-    if unusable_dims:
-        raise exceptions.ModelError(
-            "Attempting to mask time based on  technology {}, "
-            "but dimension(s) {} do not exist for parameter {}".format(
-                tech, unusable_dims, var.name
-            )
-        )
-
-    arr = data[var].loc[subset].groupby("timesteps").mean(...).to_pandas()
-    return arr
-
-
-def zero(data, tech, var="source_max", **kwargs):
-    """
-    Returns timesteps where ``var`` for the technology ``tech`` is zero.
-
-    kwargs are additional dimensions to subset on, for example,
-    ``nodes=['node1', 'node2]``
-
-    """
-    s = _get_array(data, var, tech, **kwargs)
-
-    return s[s == 0].index
-
-
-def _concat_indices(indices):
-    return pd.concat([i.to_series() for i in indices]).sort_index().index
-
-
-def _get_minmax_timestamps(series, length, n, how="max", padding=None):
-    # Get the max/min timestamps
-    group = series.groupby(pd.Grouper(freq=length)).mean()
-    timesteps = []
-    for _ in range(n):
-        if how == "max":
-            ts = group.idxmax()
-        elif how == "min":
-            ts = group.idxmin()
-        timesteps.append(ts)
-        group = group.drop(ts)
-
-    # Get range of timestamps including padding
-    full_timesteps = []
-    for ts in timesteps:
-        ts_end = ts + pd.Timedelta(length)
-        if padding is not None:
-            ts -= pd.Timedelta(padding)
-            ts_end += pd.Timedelta(padding)
-        ts_range = series[ts:ts_end].index[:-1]
-        full_timesteps.append(ts_range)
-
-    ts_index = _concat_indices(full_timesteps)
-
-    return ts_index
-
-
-def extreme(
-    data,
-    tech,
-    var="source_max",
-    how="max",
-    length="1D",
-    n=1,
-    groupby_length=None,
-    padding=None,
-    normalize=True,
-    **kwargs,
-):
-    """
-    Returns timesteps for period of ``length`` where ``var`` for the technology
-    ``tech`` across the given list of ``nodes`` is either minimal
-    or maximal.
-
-    Parameters
-    ----------
-    data : xarray.Dataset
-    tech : str
-        Technology whose `var` to find extreme for.
-    var : str, optional
-        default 'source_max'
-    how : str, optional
-        'max' (default) or 'min'.
-    length : str, optional
-        Defaults to '1D'.
-    n : int, optional
-        Number of periods of `length` to look for, default is 1.
-    groupby_length : str, optional
-        Group time series and return `n` periods of `length`
-        for each group.
-    padding : str, optional
-        Either Pandas frequency (e.g. '1D') or 'calendar_week'.
-        If Pandas frequency, symmetric padding is undertaken, either side of `length`
-        If 'calendar_week', padding is fit to the calendar week in which the
-        extreme day(s) are found.
-    normalize : bool, optional
-        If True (default), data is normalized
-        using :func:`~calliope.time.funcs.normalized_copy`.
-    kwargs : dict, optional
-        Dimensions of the selected var over which to index. Any remaining
-        dimensions will be flattened by mean
-
-    """
-    if normalize:
-        # Only normalise the desired var as rest of data may contain
-        # non-numeric variables!
-        data_n = funcs.normalized_copy(data[var].to_dataset(name=var))
-    else:
-        data_n = data
-    arr = _get_array(data_n, var, tech, **kwargs)
-    return _extreme_with_padding(arr, how, length, n, groupby_length, padding)
-
-
-def extreme_diff(
-    data,
-    tech0,
-    tech1,
-    var="source_max",
-    how="max",
-    length="1D",
-    n=1,
-    groupby_length=None,
-    padding=None,
-    normalize=True,
-    **kwargs,
-):
-    """
-    Returns timesteps for period of ``length`` where the diffence in extreme
-    value for ``var`` between technologies ``tech0`` and ``tech1`` is either a
-    minimum or a maximum.
-
-    Parameters
-    ----------
-    data : xarray.Dataset
-    tech0 : str
-        First technology for which we find the extreme of `var`
-    tech1 : str
-        Second technology for which we find the extreme of `var`
-    var : str, optional
-        default 'source_max'
-    how : str, optional
-        'max' (default) or 'min'.
-    length : str, optional
-        Defaults to '1D'.
-    n : int, optional
-        Number of periods of `length` to look for, default is 1.
-    groupby_length : str, optional
-        Group time series and return `n` periods of `length`
-        for each group.
-    padding : str, optional
-        Either Pandas frequency (e.g. '1D') or 'calendar_week'.
-        If Pandas frequency, symmetric padding is undertaken, either side of `length`
-        If 'calendar_week', padding is fit to the calendar week in which the
-        extreme day(s) are found.
-    normalize : bool, optional
-        If True (default), data is normalized
-        using :func:`~calliope.time.funcs.normalized_copy`.
-    kwargs : dict, optional
-        Dimensions of the selected var over which to index. Any remaining
-        dimensions will be flattened by mean
-
-    """
-
-    if normalize:
-        # Only normalise the desired var as rest of data may contain
-        # non-numeric variables!
-        data_n = funcs.normalized_copy(data[var].to_dataset(name=var))
-    else:
-        data_n = data
-    arr0 = _get_array(data_n, var, tech0, **kwargs)
-    arr1 = _get_array(data_n, var, tech1, **kwargs)
-    arr = arr0 - arr1
-
-    return _extreme_with_padding(arr, how, length, n, groupby_length, padding)
-
-
-def _extreme(arr, how="max", length="1D", n=1, groupby_length=None, padding=None):
-    if groupby_length:
-        groupby = pd.Grouper(freq=groupby_length)
-        group_indices = []
-        grouping = arr.groupby(groupby)
-        for k in grouping.groups.keys():
-            s = grouping.get_group(k)
-            group_indices.append(_get_minmax_timestamps(s, length, n, how, padding))
-        ts_index = _concat_indices(group_indices)
-    else:
-        ts_index = _get_minmax_timestamps(arr, length, n, how, padding)
-
-    return ts_index
-
-
-def _extreme_with_padding(arr, how, length, n, groupby_length, padding):
-    if padding == "calendar_week":
-        if n != 1 or length != "1D":
-            raise ValueError(
-                "calendar_week padding only supports n=1 and length=1D for now."
-            )
-        result = _extreme(arr, how, length, n, groupby_length, padding=None)
-        # get week padding for each day in result
-        days = list(result.groupby(result.dayofyear).values())
-        weeks = pd.DatetimeIndex(days[0])
-        for d in days:
-            weeks = weeks.union(_calendar_week_padding(d, arr))
-        # concatenate the weeks into one index and drop possible duplicates
-        return pd.DatetimeIndex(weeks).drop_duplicates()
-    else:
-        return _extreme(arr, how, length, n, groupby_length, padding)
-
-
-def _calendar_week_padding(day, arr):
-    """
-    Given a day, returns the whole calendar week which contains that day
-
-    """
-    days = len(day.day.unique())
-    if not days == 1:
-        raise ValueError(
-            "Only a single day at a time may be used for calendar_week padding, "
-            "but {} days were passed.".format(days)
-        )
-
-    # Using day of week, figure out how many days before and after to get
-    # a complete week
-    days_before = 6 - day[0].dayofweek
-    days_after = 6 - days_before
-
-    # Turn it into a week
-    start_time = day[0] - pd.Timedelta("{}D".format(days_before))
-    end_time = day[-1] + pd.Timedelta("{}D".format(days_after))
-    before = arr[start_time : day[0]].index[:-1]
-    after = arr[day[-1] : end_time].index[1:]
-    result_week = before.append(day).append(after)
-
-    return result_week


Test patch:
diff --git a/tests/common/test_model/energy_cap_per_storage_cap.yaml b/tests/common/test_model/energy_cap_per_storage_cap.yaml
index f543e62b9..e8b56c850 100644
--- a/tests/common/test_model/energy_cap_per_storage_cap.yaml
+++ b/tests/common/test_model/energy_cap_per_storage_cap.yaml
@@ -1,8 +1,8 @@
 config:
   init:
     name: Test model for energy capacity per storage capacity
-    timeseries_data_path: "timeseries_data"
-    subset_time: ["2005-01-01 00:00", "2005-01-01 01:00"]
+    time_data_path: "timeseries_data"
+    time_subset: ["2005-01-01 00:00", "2005-01-01 01:00"]
 
   build:
     mode: plan
diff --git a/tests/common/test_model/model.yaml b/tests/common/test_model/model.yaml
index da7ccdad9..b74c8d3a6 100644
--- a/tests/common/test_model/model.yaml
+++ b/tests/common/test_model/model.yaml
@@ -14,7 +14,7 @@ import:
 config:
   init:
     name: Test model
-    timeseries_data_path: "timeseries_data"
+    time_data_path: "timeseries_data"
   build:
     mode: plan
   solve:
diff --git a/tests/common/test_model/model_minimal.yaml b/tests/common/test_model/model_minimal.yaml
index 78f1dc383..211f900cd 100644
--- a/tests/common/test_model/model_minimal.yaml
+++ b/tests/common/test_model/model_minimal.yaml
@@ -4,7 +4,7 @@ import:
 config:
   init:
     name: Minimal test model
-    timeseries_data_path: "timeseries_data"
+    time_data_path: "timeseries_data"
   build:
     mode: plan
   solve:
diff --git a/tests/common/test_model/scenarios.yaml b/tests/common/test_model/scenarios.yaml
index 9e70df290..37ac6eb46 100644
--- a/tests/common/test_model/scenarios.yaml
+++ b/tests/common/test_model/scenarios.yaml
@@ -293,14 +293,6 @@ overrides:
 
     links.a,b.active: false
 
-  clustering:
-    config.init.time:
-      function: apply_clustering
-      function_options:
-        clustering_func: file=cluster_days.csv:a
-        how: "mean"
-        storage_inter_cluster: True
-
   spores:
     config:
       build.mode: spores
@@ -316,22 +308,20 @@ overrides:
     techs.test_supply_elec.constraints.lifetime: 1
 
   one_day:
-    config.init.subset_time: ["2005-01-01", "2005-01-01"]
+    config.init.time_subset: ["2005-01-01", "2005-01-01"]
 
   two_hours:
-    config.init.subset_time: ["2005-01-01 00:00:00", "2005-01-01 01:00:00"]
+    config.init.time_subset: ["2005-01-01 00:00:00", "2005-01-01 01:00:00"]
 
   resample_two_days:
     config:
       init:
-        subset_time: ["2005-01-01", "2005-01-02"]
-        time:
-          function: resample
-          function_options.resolution: 24h
+        time_subset: ["2005-01-01", "2005-01-02"]
+        time_resample: 24h
 
   operate:
     config.build.mode: operate
-    config.init.subset_time: ["2005-01-01", "2005-01-04"]
+    config.init.time_subset: ["2005-01-01", "2005-01-04"]
     config.solve.operate_window: 12
     config.solve.operate_horizon: 24
 
diff --git a/tests/common/test_model/timeseries_data/cluster_days.csv b/tests/common/test_model/timeseries_data/cluster_days.csv
index 2cf3d5cfa..3f3e0e298 100644
--- a/tests/common/test_model/timeseries_data/cluster_days.csv
+++ b/tests/common/test_model/timeseries_data/cluster_days.csv
@@ -1,6 +1,6 @@
-,a,b
-2005-01-01,0,2
-2005-01-02,1,1
-2005-01-03,2,0
-2005-01-04,1,1
-2005-01-06,0,0
\ No newline at end of file
+,clusters
+2005-01-01,2005-01-01
+2005-01-02,2005-01-03
+2005-01-03,2005-01-03
+2005-01-04,2005-01-01
+2005-01-05,2005-01-01
\ No newline at end of file
diff --git a/tests/common/test_model/timeseries_data/clusters.csv b/tests/common/test_model/timeseries_data/clusters.csv
deleted file mode 100644
index 4214e53bb..000000000
--- a/tests/common/test_model/timeseries_data/clusters.csv
+++ /dev/null
@@ -1,97 +0,0 @@
-,a,b
-2005-01-01 00:00:00,0,0
-2005-01-01 01:00:00,0,0
-2005-01-01 02:00:00,0,0
-2005-01-01 03:00:00,0,0
-2005-01-01 04:00:00,0,0
-2005-01-01 05:00:00,0,0
-2005-01-01 06:00:00,0,0
-2005-01-01 07:00:00,0,0
-2005-01-01 08:00:00,0,0
-2005-01-01 09:00:00,0,0
-2005-01-01 10:00:00,0,0
-2005-01-01 11:00:00,0,0
-2005-01-01 12:00:00,0,0
-2005-01-01 13:00:00,0,0
-2005-01-01 14:00:00,0,0
-2005-01-01 15:00:00,0,0
-2005-01-01 16:00:00,0,0
-2005-01-01 17:00:00,0,0
-2005-01-01 18:00:00,0,0
-2005-01-01 19:00:00,0,0
-2005-01-01 20:00:00,0,0
-2005-01-01 21:00:00,0,0
-2005-01-01 22:00:00,0,0
-2005-01-01 23:00:00,0,0
-2005-01-02 00:00:00,1,0
-2005-01-02 01:00:00,1,0
-2005-01-02 02:00:00,1,0
-2005-01-02 03:00:00,1,0
-2005-01-02 04:00:00,1,0
-2005-01-02 05:00:00,1,0
-2005-01-02 06:00:00,1,0
-2005-01-02 07:00:00,1,0
-2005-01-02 08:00:00,1,0
-2005-01-02 09:00:00,1,0
-2005-01-02 10:00:00,1,0
-2005-01-02 11:00:00,1,0
-2005-01-02 12:00:00,1,0
-2005-01-02 13:00:00,1,0
-2005-01-02 14:00:00,1,0
-2005-01-02 15:00:00,1,0
-2005-01-02 16:00:00,1,0
-2005-01-02 17:00:00,1,0
-2005-01-02 18:00:00,1,0
-2005-01-02 19:00:00,1,1
-2005-01-02 20:00:00,1,1
-2005-01-02 21:00:00,1,1
-2005-01-02 22:00:00,1,1
-2005-01-02 23:00:00,1,1
-2005-01-03 00:00:00,2,2
-2005-01-03 01:00:00,2,2
-2005-01-03 02:00:00,2,2
-2005-01-03 03:00:00,2,2
-2005-01-03 04:00:00,2,2
-2005-01-03 05:00:00,2,2
-2005-01-03 06:00:00,2,2
-2005-01-03 07:00:00,2,2
-2005-01-03 08:00:00,2,2
-2005-01-03 09:00:00,2,2
-2005-01-03 10:00:00,2,2
-2005-01-03 11:00:00,2,2
-2005-01-03 12:00:00,2,2
-2005-01-03 13:00:00,2,2
-2005-01-03 14:00:00,2,2
-2005-01-03 15:00:00,2,2
-2005-01-03 16:00:00,2,2
-2005-01-03 17:00:00,2,2
-2005-01-03 18:00:00,2,2
-2005-01-03 19:00:00,2,2
-2005-01-03 20:00:00,2,2
-2005-01-03 21:00:00,2,2
-2005-01-03 22:00:00,2,2
-2005-01-03 23:00:00,2,2
-2005-01-04 00:00:00,1,1
-2005-01-04 01:00:00,1,1
-2005-01-04 02:00:00,1,1
-2005-01-04 03:00:00,1,1
-2005-01-04 04:00:00,1,1
-2005-01-04 05:00:00,1,1
-2005-01-04 06:00:00,1,1
-2005-01-04 07:00:00,1,1
-2005-01-04 08:00:00,1,1
-2005-01-04 09:00:00,1,1
-2005-01-04 10:00:00,1,1
-2005-01-04 11:00:00,1,1
-2005-01-04 12:00:00,1,1
-2005-01-04 13:00:00,1,1
-2005-01-04 14:00:00,1,1
-2005-01-04 15:00:00,1,1
-2005-01-04 16:00:00,1,1
-2005-01-04 17:00:00,1,1
-2005-01-04 18:00:00,1,1
-2005-01-04 19:00:00,1,1
-2005-01-04 20:00:00,1,1
-2005-01-04 21:00:00,1,1
-2005-01-04 22:00:00,1,1
-2005-01-04 23:00:00,1,1
\ No newline at end of file
diff --git a/tests/common/test_model/weighted_obj_func.yaml b/tests/common/test_model/weighted_obj_func.yaml
index 052af4762..70746ae42 100644
--- a/tests/common/test_model/weighted_obj_func.yaml
+++ b/tests/common/test_model/weighted_obj_func.yaml
@@ -1,8 +1,8 @@
 config:
   init:
     name: Cost capacity constraint test model
-    timeseries_data_path: "timeseries_data"
-    subset_time: ["2005-01-01", "2005-01-01"]
+    time_data_path: "timeseries_data"
+    time_subset: ["2005-01-01", "2005-01-01"]
   build:
     mode: plan
   solve:
diff --git a/tests/common/util.py b/tests/common/util.py
index 275080271..cad7d32ce 100644
--- a/tests/common/util.py
+++ b/tests/common/util.py
@@ -32,12 +32,14 @@ def build_test_model(
     scenario=None,
     model_file="model.yaml",
     timeseries_dataframes=None,
+    **init_kwargs,
 ):
     return calliope.Model(
         os.path.join(os.path.dirname(__file__), "test_model", model_file),
         override_dict=override_dict,
         scenario=scenario,
         timeseries_dataframes=timeseries_dataframes,
+        **init_kwargs,
     )
 
 
diff --git a/tests/test_backend_pyomo.py b/tests/test_backend_pyomo.py
index 9320b658f..ceb933807 100755
--- a/tests/test_backend_pyomo.py
+++ b/tests/test_backend_pyomo.py
@@ -1626,14 +1626,8 @@ def cluster_model(
         storage_initial=False,
     ):
         override = {
-            "config.init.subset_time": ["2005-01-01", "2005-01-04"],
-            "config.init.time": {
-                "function": "apply_clustering",
-                "function_options": {
-                    "clustering_func": "file=cluster_days.csv:a",
-                    "how": how,
-                },
-            },
+            "config.init.time_subset": ["2005-01-01", "2005-01-04"],
+            "config.init.time_cluster": "cluster_days.csv",
             "config.init.custom_math": ["storage_inter_cluster"]
             if storage_inter_cluster
             else [],
diff --git a/tests/test_backend_pyomo_objective.py b/tests/test_backend_pyomo_objective.py
index 80e658777..152109e3f 100644
--- a/tests/test_backend_pyomo_objective.py
+++ b/tests/test_backend_pyomo_objective.py
@@ -11,7 +11,7 @@ class TestCostMinimisationObjective:
     def test_nationalscale_minimize_emissions(self):
         model = calliope.examples.national_scale(
             scenario="minimize_emissions_costs",
-            override_dict={"config.init.subset_time": ["2005-01-01", "2005-01-01"]},
+            override_dict={"config.init.time_subset": ["2005-01-01", "2005-01-01"]},
         )
         model.build()
         model.solve()
@@ -34,7 +34,7 @@ def test_nationalscale_minimize_emissions(self):
     def test_nationalscale_maximize_utility(self):
         model = calliope.examples.national_scale(
             scenario="maximize_utility_costs",
-            override_dict={"config.init.subset_time": ["2005-01-01", "2005-01-01"]},
+            override_dict={"config.init.time_subset": ["2005-01-01", "2005-01-01"]},
         )
         model.build()
         model.solve()
diff --git a/tests/test_constraint_results.py b/tests/test_constraint_results.py
index b96213087..283b942d7 100644
--- a/tests/test_constraint_results.py
+++ b/tests/test_constraint_results.py
@@ -111,7 +111,7 @@ def _run_model(feasibility, cap_val):
                 "nodes.a.techs": {"test_supply_elec": {}, "test_demand_elec": {}},
                 "links.a,b.active": False,
                 # pick a time subset where demand is uniformally -10 throughout
-                "config.init.subset_time": [
+                "config.init.time_subset": [
                     "2005-01-01 06:00:00",
                     "2005-01-01 08:00:00",
                 ],
@@ -267,7 +267,7 @@ def test_operate_mode(self, model_file):
     def test_operate_mode_horizon_window(self, model_file, horizon_window):
         horizon, window = horizon_window
         override_dict = {
-            "config.init.subset_time": ["2005-01-01", "2005-01-05"],
+            "config.init.time_subset": ["2005-01-01", "2005-01-05"],
         }
         model = build_model(
             model_file=model_file,
diff --git a/tests/test_core_attrdict.py b/tests/test_core_attrdict.py
index c2029e90b..1a414000e 100644
--- a/tests/test_core_attrdict.py
+++ b/tests/test_core_attrdict.py
@@ -1,6 +1,6 @@
 import os
 import tempfile
-from io import StringIO
+from pathlib import Path
 
 import numpy as np
 import pytest
@@ -38,8 +38,9 @@ def regular_dict(self):
     """
 
     @pytest.fixture
-    def yaml_file(self):
-        return StringIO(self.setup_string)
+    def yaml_filepath(self):
+        this_path = Path(__file__).parent
+        return this_path / "common" / "yaml_file.yaml"
 
     @pytest.fixture
     def yaml_string(self):
@@ -69,15 +70,8 @@ def test_init_from_dict_with_nested_keys(self):
         d = AttrDict({"foo.bar.baz": 1})
         assert d.foo.bar.baz == 1
 
-    def test_from_yaml_fobj(self, yaml_file):
-        d = AttrDict.from_yaml(yaml_file)
-        assert d.a == 1
-        assert d.c.z.II == 2
-
-    def test_from_yaml_path(self):
-        this_path = os.path.dirname(__file__)
-        yaml_path = os.path.join(this_path, "common", "yaml_file.yaml")
-        d = AttrDict.from_yaml(yaml_path)
+    def test_from_yaml_path(self, yaml_filepath):
+        d = AttrDict.from_yaml(yaml_filepath)
         assert d.a == 1
         assert d.c.z.II == 2
 
@@ -290,8 +284,8 @@ def test_del_key_nested(self, attr_dict):
         attr_dict.del_key("c.z.I")
         assert "I" not in attr_dict.c.z
 
-    def test_to_yaml(self, yaml_file):
-        d = AttrDict.from_yaml(yaml_file)
+    def test_to_yaml(self, yaml_filepath):
+        d = AttrDict.from_yaml(yaml_filepath)
         d.set_key("numpy.some_int", np.int32(10))
         d.set_key("numpy.some_float", np.float64(0.5))
         d.a_list = [0, 1, 2]
@@ -306,8 +300,8 @@ def test_to_yaml(self, yaml_file):
             assert "some_float: 0.5" in result
             assert "a_list:\n- 0\n- 1\n- 2" in result
 
-    def test_to_yaml_string(self, yaml_file):
-        d = AttrDict.from_yaml(yaml_file)
+    def test_to_yaml_string(self, yaml_filepath):
+        d = AttrDict.from_yaml(yaml_filepath)
         result = d.to_yaml()
         assert "a: 1" in result
 
@@ -328,7 +322,7 @@ def test_do_not_resolve_imports(self):
         # imports not to be resolved
         assert d["import"] == ["somefile.yaml"]
 
-    def test_nested_import(self, yaml_file):
+    def test_nested_import(self):
         with tempfile.TemporaryDirectory() as tempdir:
             imported_file = os.path.join(tempdir, "test_import.yaml")
             imported_yaml = """
diff --git a/tests/test_core_model.py b/tests/test_core_model.py
index 775a0e564..4be86a4b3 100644
--- a/tests/test_core_model.py
+++ b/tests/test_core_model.py
@@ -15,7 +15,7 @@ class TestModel:
     @pytest.fixture(scope="module")
     def national_scale_example(self):
         model = calliope.examples.national_scale(
-            override_dict={"config.init.subset_time": ["2005-01-01", "2005-01-01"]}
+            override_dict={"config.init.time_subset": ["2005-01-01", "2005-01-01"]}
         )
         return model
 
diff --git a/tests/test_core_preprocess.py b/tests/test_core_preprocess.py
index 99de85134..338585797 100644
--- a/tests/test_core_preprocess.py
+++ b/tests/test_core_preprocess.py
@@ -29,11 +29,11 @@ def test_model_from_dict(self):
             }
         )
         model_dict.union(node_dict)
-        model_dict.config.init["timeseries_data_path"] = os.path.join(
+        model_dict.config.init["time_data_path"] = os.path.join(
             this_path,
             "common",
             "test_model",
-            model_dict.config.init["timeseries_data_path"],
+            model_dict.config.init["time_data_path"],
         )
         # test as AttrDict
         calliope.Model(model_dict)
@@ -235,14 +235,14 @@ def test_conversion_plus_primary_carriers(self):
 
     def test_incorrect_subset_time(self):
         """
-        If subset_time is a list, it must have two entries (start_time, end_time)
-        If subset_time is not a list, it should successfully subset on the given
+        If time_subset is a list, it must have two entries (start_time, end_time)
+        If time_subset is not a list, it should successfully subset on the given
         string/integer
         """
 
         def override(param):
             return AttrDict.from_yaml_string(
-                "config.init.subset_time: {}".format(param)
+                "config.init.time_subset: {}".format(param)
             )
 
         # should fail: one string in list
@@ -298,7 +298,7 @@ def test_incorrect_date_format(self):
 
         # should pass: changing datetime format from default
         override1 = {
-            "config.init.timeseries_dateformat": "%d/%m/%Y %H:%M:%S",
+            "config.init.time_format": "%d/%m/%Y %H:%M:%S",
             "techs.test_demand_heat.constraints.sink_equals": "file=demand_heat_diff_dateformat.csv",
             "techs.test_demand_elec.constraints.sink_equals": "file=demand_heat_diff_dateformat.csv",
         }
@@ -317,7 +317,7 @@ def test_incorrect_date_format(self):
             build_model(override_dict=override2, scenario="simple_conversion")
 
         # should fail: wrong dateformat input for all files
-        override3 = {"config.init.timeseries_dateformat": "%d/%m/%Y %H:%M:%S"}
+        override3 = {"config.init.time_format": "%d/%m/%Y %H:%M:%S"}
 
         with pytest.raises(exceptions.ModelError):
             build_model(override_dict=override3, scenario="simple_supply")
@@ -352,7 +352,7 @@ def test_single_timestep(self):
         be inferred to be 1 hour
         """
         override1 = {
-            "config.init.subset_time": ["2005-01-01 00:00:00", "2005-01-01 00:00:00"]
+            "config.init.time_subset": ["2005-01-01 00:00:00", "2005-01-01 00:00:00"]
         }
         # check in output error that it points to: 07/01/2005 10:00:00
         with pytest.warns(exceptions.ModelWarning) as warn_info:
@@ -1117,7 +1117,7 @@ def test_one_way(self):
             "links.N1,X3.techs.heat_pipes.switches.one_way": True,
         }
         m = calliope.examples.urban_scale(
-            override_dict=override, subset_time=["2005-01-01", "2005-01-01"]
+            override_dict=override, time_subset=["2005-01-01", "2005-01-01"]
         )
         m.build()
         removed_prod_links = [
@@ -1266,15 +1266,8 @@ def test_clustering_and_cyclic_storage(self):
         """
 
         override = {
-            "config.init.subset_time": ["2005-01-01", "2005-01-04"],
-            "config.init.time": {
-                "function": "apply_clustering",
-                "function_options": {
-                    "clustering_func": "file=cluster_days.csv:a",
-                    "how": "mean",
-                    "storage_inter_cluster": False,
-                },
-            },
+            "config.init.time_subset": ["2005-01-01", "2005-01-04"],
+            "config.init.time_cluster": "cluster_days.csv",
             "config.build.cyclic_storage": True,
         }
 
@@ -1439,7 +1432,7 @@ def test_storage_inter_cluster_vs_storage_discharge_depth(self):
         Check that the storage_inter_cluster is not used together with storage_discharge_depth
         """
         with pytest.raises(exceptions.ModelError) as error:
-            override = {"config.init.subset_time": ["2005-01-01", "2005-01-04"]}
+            override = {"config.init.time_subset": ["2005-01-01", "2005-01-04"]}
             build_model(override, "clustering,simple_storage,storage_discharge_depth")
 
         assert check_error_or_warning(
@@ -1479,20 +1472,20 @@ def model_national(self, load_timeseries_from_dataframes):
         """
         if load_timeseries_from_dataframes:
             # Create dictionary with dataframes
-            timeseries_data_path = (
+            time_data_path = (
                 calliope.examples.EXAMPLE_MODEL_DIR
                 / "national_scale"
                 / "timeseries_data"
             )
             timeseries_dataframes = {}
             timeseries_dataframes["csp_resource"] = pd.read_csv(
-                timeseries_data_path / "csp_resource.csv", index_col=0
+                time_data_path / "csp_resource.csv", index_col=0
             )
             timeseries_dataframes["demand_1"] = pd.read_csv(
-                timeseries_data_path / "demand-1.csv", index_col=0
+                time_data_path / "demand-1.csv", index_col=0
             )
             timeseries_dataframes["demand_2"] = pd.read_csv(
-                timeseries_data_path / "demand-2.csv", index_col=0
+                time_data_path / "demand-2.csv", index_col=0
             )
             # Create override dict telling calliope to load timeseries from df
             override_dict = {
@@ -1509,7 +1502,7 @@ def model_national(self, load_timeseries_from_dataframes):
     @pytest.fixture
     def model_urban(self):
         return calliope.examples.urban_scale(
-            override_dict={"config.init.subset_time": ["2005-01-01", "2005-01-10"]}
+            override_dict={"config.init.time_subset": ["2005-01-01", "2005-01-10"]}
         )
 
     @pytest.mark.parametrize("load_timeseries_from_dataframes", [False, True])
diff --git a/tests/test_core_time.py b/tests/test_core_time.py
deleted file mode 100644
index d8085d86f..000000000
--- a/tests/test_core_time.py
+++ /dev/null
@@ -1,1072 +0,0 @@
-import calliope
-import numpy as np
-import pandas as pd
-import pytest  # noqa: F401
-from calliope import exceptions
-from calliope.time import funcs, masks
-
-from .common.util import build_test_model, check_error_or_warning
-
-
-class TestClustering:
-    @pytest.fixture
-    def model_national(self, scope="module"):
-        return calliope.examples.national_scale(
-            override_dict={
-                "config.init.random_seed": 23,
-                "config.init.subset_time": ["2005-01-01", "2005-03-31"],
-            }
-        )
-
-    def test_kmeans_mean(self, model_national):
-        data = model_national._model_data
-
-        data_clustered = funcs.apply_clustering(
-            data,
-            timesteps=None,
-            clustering_func="kmeans",
-            how="mean",
-            normalize=True,
-            k=5,
-        )
-
-        assert len(data_clustered.clusters.to_pandas().unique()) == 5
-
-    def test_kmeans_closest(self, model_national):
-        data = model_national._model_data
-
-        funcs.apply_clustering(
-            data,
-            timesteps=None,
-            clustering_func="kmeans",
-            how="closest",
-            normalize=True,
-            k=5,
-        )
-
-    def test_hierarchical_mean(self, model_national):
-        data = model_national._model_data
-
-        data_clustered = funcs.apply_clustering(
-            data,
-            timesteps=None,
-            clustering_func="hierarchical",
-            how="mean",
-            normalize=True,
-            k=5,
-        )
-
-        assert len(data_clustered.clusters.to_pandas().unique()) == 5
-
-    def test_hierarchical_closest(self, model_national):
-        data = model_national._model_data
-
-        funcs.apply_clustering(
-            data,
-            timesteps=None,
-            clustering_func="hierarchical",
-            how="closest",
-            normalize=True,
-            k=5,
-        )
-
-        # FIXME
-
-    @pytest.mark.time_intensive
-    def test_hartigans_rule(self, model_national):
-        data = model_national._model_data
-
-        with pytest.warns(exceptions.ModelWarning) as excinfo:
-            funcs.apply_clustering(
-                data,
-                timesteps=None,
-                clustering_func="kmeans",
-                how="mean",
-                normalize=True,
-            )
-
-        assert check_error_or_warning(excinfo, "a good number of clusters is 5")
-
-    def test_hierarchical_no_hartigans_rule(self, model_national):
-        data = model_national._model_data
-
-        with pytest.raises(exceptions.ModelError) as excinfo:
-            funcs.apply_clustering(
-                data,
-                timesteps=None,
-                clustering_func="hierarchical",
-                how="mean",
-                normalize=True,
-            )
-
-        assert check_error_or_warning(
-            excinfo, "Cannot undertake hierarchical clustering"
-        )
-
-    def test_15min_clustering(self):
-        # The data is identical for '2005-01-01' and '2005-01-03' timesteps,
-        # it is only different for '2005-01-02'
-        override = {
-            "techs.test_demand_elec.constraints.sink_equals": "file=demand_elec_15mins.csv",
-            "config.init.subset_time": None,
-        }
-
-        model = build_test_model(override, scenario="simple_supply,one_day")
-        data = model._model_data
-
-        data_clustered_kmeans = funcs.apply_clustering(
-            data,
-            timesteps=None,
-            clustering_func="kmeans",
-            how="mean",
-            normalize=True,
-            k=2,
-        )
-
-        data_clustered_hierarchical = funcs.apply_clustering(
-            data,
-            timesteps=None,
-            clustering_func="hierarchical",
-            how="mean",
-            normalize=True,
-            k=2,
-        )
-        assert len(data_clustered_kmeans.clusters.to_pandas().unique()) == 2
-        assert len(data_clustered_hierarchical.clusters.to_pandas().unique()) == 2
-
-        days = np.unique(
-            data_clustered_kmeans.timesteps.to_index().strftime("%Y-%m-%d")
-        )
-        # not sure which of '2005-01-01' and '2005-01-03' it will choose to
-        # label the cluster of those two days
-        assert "2005-01-02" in days and ("2005-01-01" in days or "2005-01-03" in days)
-
-        assert np.array_equal(
-            data_clustered_kmeans.timestep_resolution.values,
-            [0.25 for i in range(24 * 4 * 2)],
-        )
-
-    def test_15min_to_2h_clustering(self):
-        # The data is identical for '2005-01-01' and '2005-01-03' timesteps,
-        # it is only different for '2005-01-02'
-        override = {
-            "techs.test_demand_elec.constraints.sink_equals": "file=demand_elec_15T_to_2h.csv",
-            "config.init.subset_time": None,
-        }
-
-        model = build_test_model(override, scenario="simple_supply,one_day")
-        data = model._model_data
-
-        data_clustered_kmeans = funcs.apply_clustering(
-            data,
-            timesteps=None,
-            clustering_func="kmeans",
-            how="mean",
-            normalize=True,
-            k=2,
-        )
-
-        data_clustered_hierarchical = funcs.apply_clustering(
-            data,
-            timesteps=None,
-            clustering_func="hierarchical",
-            how="mean",
-            normalize=True,
-            k=2,
-        )
-        assert len(data_clustered_kmeans.clusters.to_pandas().unique()) == 2
-        assert len(data_clustered_hierarchical.clusters.to_pandas().unique()) == 2
-
-        days = np.unique(
-            data_clustered_kmeans.timesteps.to_index().strftime("%Y-%m-%d")
-        )
-        # not sure which of '2005-01-01' and '2005-01-03' it will choose to
-        # label the cluster of those two days
-        assert "2005-01-02" in days and ("2005-01-01" in days or "2005-01-03" in days)
-
-        assert np.array_equal(
-            data_clustered_kmeans.timestep_resolution.values,
-            [
-                0.25,
-                0.25,
-                0.25,
-                0.25,
-                0.5,
-                0.5,
-                0.5,
-                0.5,
-                1,
-                1,
-                1,
-                2,
-                2,
-                2,
-                2,
-                2,
-                1,
-                1,
-                2,
-                2,
-                2,
-                0.25,
-                0.25,
-                0.25,
-                0.25,
-                0.5,
-                0.5,
-                0.5,
-                0.5,
-                1,
-                1,
-                1,
-                2,
-                2,
-                2,
-                2,
-                2,
-                1,
-                1,
-                2,
-                2,
-                2,
-            ],
-        )
-
-    @pytest.fixture
-    def predefined_cluster_override(self):
-        return {
-            "config.init.subset_time": ["2005-01-01", "2005-01-04"],
-            "config.init.time": {
-                "function": "apply_clustering",
-                "function_options": {
-                    "clustering_func": "file=clusters.csv:a",
-                    "how": "mean",
-                },
-            },
-        }
-
-    def test_predefined_clusters(self, predefined_cluster_override):
-        model = build_test_model(predefined_cluster_override, scenario="simple_supply")
-
-        assert np.array_equal(
-            model._model_data.clusters.to_pandas().unique(), [0, 1, 2]
-        )
-
-        override2 = {
-            **predefined_cluster_override,
-            **{
-                "config.init.time.function_options.clustering_func": "file=cluster_days.csv:b"
-            },
-        }
-
-        model = build_test_model(override2, scenario="simple_supply")
-
-        assert np.array_equal(
-            model._model_data.clusters.to_pandas().unique(), [0, 1, 2]
-        )
-
-    @pytest.mark.filterwarnings(
-        "ignore:(?s).*Creating 2 fewer clusters:calliope.exceptions.ModelWarning"
-    )
-    def test_predefined_clusters_closest(self, predefined_cluster_override):
-        override3 = {
-            **predefined_cluster_override,
-            **{
-                "config.init.time.function_options.clustering_func": "file=cluster_days.csv:b",
-                "config.init.time.function_options.how": "closest",
-            },
-        }
-
-        model = build_test_model(override3, scenario="simple_supply")
-
-        assert np.array_equal(model._model_data.clusters.to_pandas().unique(), [0])
-
-    def test_predefined_clusters_fail(self):
-        override = {
-            "config.init.subset_time": ["2005-01-01", "2005-01-04"],
-            "config.init.time": {
-                "function": "apply_clustering",
-                "function_options": {
-                    "clustering_func": "file=clusters.csv:a",
-                    "how": "mean",
-                },
-            },
-        }
-        # should fail - no CSV data column defined
-        override1 = {
-            **override,
-            **{
-                "config.init.time.function_options.clustering_func": "file=clusters.csv"
-            },
-        }
-        with pytest.raises(exceptions.ModelError) as error:
-            build_test_model(override1, scenario="simple_supply")
-
-        assert check_error_or_warning(error, "No time clustering column given")
-
-        # should fail - unknown CSV data column defined
-        override2 = {
-            **override,
-            **{
-                "config.init.time.function_options.clustering_func": "file=clusters.csv:1"
-            },
-        }
-
-        with pytest.raises(KeyError) as error:
-            build_test_model(override2, scenario="simple_supply")
-
-        assert check_error_or_warning(error, "time clustering column 1 not found")
-
-        # should fail - more than one cluster given to any one day
-        override3 = {
-            **override,
-            **{
-                "config.init.time.function_options.clustering_func": "file=clusters.csv:b"
-            },
-        }
-
-        with pytest.raises(exceptions.ModelError) as error:
-            build_test_model(override3, scenario="simple_supply")
-
-        assert check_error_or_warning(
-            error, "More than one cluster value assigned to a day in `clusters.csv:b`"
-        )
-
-        # should fail - not enough data in clusters.csv to cover subset_time
-        override4 = {
-            **override,
-            **{
-                "config.init.subset_time": ["2005-01-01", "2005-01-06"],
-                "config.init.time.function_options.clustering_func": "file=cluster_days.csv:b",
-            },
-        }
-
-        with pytest.raises(exceptions.ModelError) as error:
-            build_test_model(override4, scenario="simple_supply")
-
-        assert check_error_or_warning(
-            error,
-            "Missing data for the timeseries array(s) [('cluster_days.csv', 'a') ('cluster_days.csv', 'b')]",
-        )
-
-
-class TestMasks:
-    @pytest.fixture
-    def model_national(self, scope="module"):
-        return calliope.examples.national_scale(
-            override_dict={"config.init.subset_time": ["2005-01-01", "2005-01-31"]}
-        )
-
-    @pytest.fixture
-    def model_urban(self, scope="module"):
-        return calliope.examples.urban_scale(
-            override_dict={"config.init.subset_time": ["2005-01-01", "2005-01-31"]}
-        )
-
-    def test_zero(self, model_national):
-        data = model_national._model_data_pre_clustering.copy()
-        mask = masks.zero(data, "csp", var="source_max")
-
-        dtindex = pd.DatetimeIndex(
-            [
-                "2005-01-01 00:00:00",
-                "2005-01-01 01:00:00",
-                "2005-01-01 02:00:00",
-                "2005-01-01 03:00:00",
-                "2005-01-01 04:00:00",
-                "2005-01-01 05:00:00",
-                "2005-01-01 06:00:00",
-                "2005-01-01 16:00:00",
-                "2005-01-01 17:00:00",
-                "2005-01-01 18:00:00",
-                "2005-01-01 19:00:00",
-                "2005-01-01 20:00:00",
-                "2005-01-01 21:00:00",
-                "2005-01-01 22:00:00",
-                "2005-01-01 23:00:00",
-                "2005-01-02 00:00:00",
-                "2005-01-02 01:00:00",
-                "2005-01-02 02:00:00",
-                "2005-01-02 03:00:00",
-                "2005-01-02 04:00:00",
-                "2005-01-02 05:00:00",
-                "2005-01-02 06:00:00",
-                "2005-01-02 16:00:00",
-                "2005-01-02 17:00:00",
-                "2005-01-02 18:00:00",
-                "2005-01-02 19:00:00",
-                "2005-01-02 20:00:00",
-                "2005-01-02 21:00:00",
-                "2005-01-02 22:00:00",
-                "2005-01-02 23:00:00",
-                "2005-01-03 00:00:00",
-                "2005-01-03 01:00:00",
-                "2005-01-03 02:00:00",
-                "2005-01-03 03:00:00",
-                "2005-01-03 04:00:00",
-                "2005-01-03 05:00:00",
-                "2005-01-03 06:00:00",
-                "2005-01-03 16:00:00",
-                "2005-01-03 17:00:00",
-                "2005-01-03 18:00:00",
-                "2005-01-03 19:00:00",
-                "2005-01-03 20:00:00",
-                "2005-01-03 21:00:00",
-                "2005-01-03 22:00:00",
-                "2005-01-03 23:00:00",
-                "2005-01-04 00:00:00",
-                "2005-01-04 01:00:00",
-                "2005-01-04 02:00:00",
-                "2005-01-04 03:00:00",
-                "2005-01-04 04:00:00",
-                "2005-01-04 05:00:00",
-                "2005-01-04 06:00:00",
-                "2005-01-04 16:00:00",
-                "2005-01-04 17:00:00",
-                "2005-01-04 18:00:00",
-                "2005-01-04 19:00:00",
-                "2005-01-04 20:00:00",
-                "2005-01-04 21:00:00",
-                "2005-01-04 22:00:00",
-                "2005-01-04 23:00:00",
-                "2005-01-05 00:00:00",
-                "2005-01-05 01:00:00",
-                "2005-01-05 02:00:00",
-                "2005-01-05 03:00:00",
-                "2005-01-05 04:00:00",
-                "2005-01-05 05:00:00",
-                "2005-01-05 06:00:00",
-                "2005-01-05 16:00:00",
-                "2005-01-05 17:00:00",
-                "2005-01-05 18:00:00",
-                "2005-01-05 19:00:00",
-                "2005-01-05 20:00:00",
-                "2005-01-05 21:00:00",
-                "2005-01-05 22:00:00",
-                "2005-01-05 23:00:00",
-            ]
-        )
-
-        assert dtindex.equals(mask[0:75])
-
-    def test_extreme(self, model_national):
-        data = model_national._model_data_pre_clustering.copy()
-        mask = masks.extreme(
-            data, "csp", var="source_max", how="max", length="2D", n=1, padding="2H"
-        )
-
-        dtindex = pd.DatetimeIndex(
-            [
-                "2005-01-18 22:00:00",
-                "2005-01-18 23:00:00",
-                "2005-01-19 00:00:00",
-                "2005-01-19 01:00:00",
-                "2005-01-19 02:00:00",
-                "2005-01-19 03:00:00",
-                "2005-01-19 04:00:00",
-                "2005-01-19 05:00:00",
-                "2005-01-19 06:00:00",
-                "2005-01-19 07:00:00",
-                "2005-01-19 08:00:00",
-                "2005-01-19 09:00:00",
-                "2005-01-19 10:00:00",
-                "2005-01-19 11:00:00",
-                "2005-01-19 12:00:00",
-                "2005-01-19 13:00:00",
-                "2005-01-19 14:00:00",
-                "2005-01-19 15:00:00",
-                "2005-01-19 16:00:00",
-                "2005-01-19 17:00:00",
-                "2005-01-19 18:00:00",
-                "2005-01-19 19:00:00",
-                "2005-01-19 20:00:00",
-                "2005-01-19 21:00:00",
-                "2005-01-19 22:00:00",
-                "2005-01-19 23:00:00",
-                "2005-01-20 00:00:00",
-                "2005-01-20 01:00:00",
-                "2005-01-20 02:00:00",
-                "2005-01-20 03:00:00",
-                "2005-01-20 04:00:00",
-                "2005-01-20 05:00:00",
-                "2005-01-20 06:00:00",
-                "2005-01-20 07:00:00",
-                "2005-01-20 08:00:00",
-                "2005-01-20 09:00:00",
-                "2005-01-20 10:00:00",
-                "2005-01-20 11:00:00",
-                "2005-01-20 12:00:00",
-                "2005-01-20 13:00:00",
-                "2005-01-20 14:00:00",
-                "2005-01-20 15:00:00",
-                "2005-01-20 16:00:00",
-                "2005-01-20 17:00:00",
-                "2005-01-20 18:00:00",
-                "2005-01-20 19:00:00",
-                "2005-01-20 20:00:00",
-                "2005-01-20 21:00:00",
-                "2005-01-20 22:00:00",
-                "2005-01-20 23:00:00",
-                "2005-01-21 00:00:00",
-                "2005-01-21 01:00:00",
-            ]
-        )
-
-        assert dtindex.equals(mask)
-
-    def test_extreme_diff_and_normalize(self, model_urban):
-        data = model_urban._model_data_pre_clustering.copy()
-        mask = masks.extreme_diff(
-            data,
-            "demand_heat",
-            "demand_electricity",
-            var="sink_equals",
-            how="min",
-            length="1D",
-            n=2,
-            normalize=True,
-        )
-
-        dtindex = pd.DatetimeIndex(
-            [
-                "2005-01-13 00:00:00",
-                "2005-01-13 01:00:00",
-                "2005-01-13 02:00:00",
-                "2005-01-13 03:00:00",
-                "2005-01-13 04:00:00",
-                "2005-01-13 05:00:00",
-                "2005-01-13 06:00:00",
-                "2005-01-13 07:00:00",
-                "2005-01-13 08:00:00",
-                "2005-01-13 09:00:00",
-                "2005-01-13 10:00:00",
-                "2005-01-13 11:00:00",
-                "2005-01-13 12:00:00",
-                "2005-01-13 13:00:00",
-                "2005-01-13 14:00:00",
-                "2005-01-13 15:00:00",
-                "2005-01-13 16:00:00",
-                "2005-01-13 17:00:00",
-                "2005-01-13 18:00:00",
-                "2005-01-13 19:00:00",
-                "2005-01-13 20:00:00",
-                "2005-01-13 21:00:00",
-                "2005-01-13 22:00:00",
-                "2005-01-13 23:00:00",
-                "2005-01-19 00:00:00",
-                "2005-01-19 01:00:00",
-                "2005-01-19 02:00:00",
-                "2005-01-19 03:00:00",
-                "2005-01-19 04:00:00",
-                "2005-01-19 05:00:00",
-                "2005-01-19 06:00:00",
-                "2005-01-19 07:00:00",
-                "2005-01-19 08:00:00",
-                "2005-01-19 09:00:00",
-                "2005-01-19 10:00:00",
-                "2005-01-19 11:00:00",
-                "2005-01-19 12:00:00",
-                "2005-01-19 13:00:00",
-                "2005-01-19 14:00:00",
-                "2005-01-19 15:00:00",
-                "2005-01-19 16:00:00",
-                "2005-01-19 17:00:00",
-                "2005-01-19 18:00:00",
-                "2005-01-19 19:00:00",
-                "2005-01-19 20:00:00",
-                "2005-01-19 21:00:00",
-                "2005-01-19 22:00:00",
-                "2005-01-19 23:00:00",
-            ]
-        )
-
-        assert dtindex.equals(mask)
-
-    def test_extreme_week_1d(self, model_national):
-        data = model_national._model_data_pre_clustering.copy()
-        mask = masks.extreme(
-            data,
-            "csp",
-            var="source_max",
-            how="max",
-            length="1D",
-            n=1,
-            padding="calendar_week",
-        )
-
-        found_days = list(mask.dayofyear.unique())
-        days = [18, 19, 20, 21, 22, 23, 24]
-
-        assert days == found_days
-
-    def test_extreme_week_2d(self, model_national):
-        data = model_national._model_data_pre_clustering.copy()
-        with pytest.raises(ValueError):
-            masks.extreme(
-                data,
-                "csp",
-                var="source_max",
-                how="max",
-                length="2D",
-                n=1,
-                padding="calendar_week",
-            )
-
-    def test_15min_masking_1D(self):
-        # The data is identical for '2005-01-01' and '2005-01-03' timesteps,
-        # it is only different for '2005-01-02'
-        override = {
-            "techs.test_demand_elec.constraints.sink_equals": "file=demand_elec_15mins.csv",
-            "config.init.subset_time": None,
-        }
-
-        model = build_test_model(override, scenario="simple_supply,one_day")
-        data = model._model_data
-
-        mask = masks.extreme(
-            data, "test_demand_elec", var="sink_equals", how="max", length="1D"
-        )
-
-        dtindex = pd.DatetimeIndex(
-            [
-                "2005-01-02 00:00:00",
-                "2005-01-02 00:15:00",
-                "2005-01-02 00:30:00",
-                "2005-01-02 00:45:00",
-                "2005-01-02 01:00:00",
-                "2005-01-02 01:15:00",
-                "2005-01-02 01:30:00",
-                "2005-01-02 01:45:00",
-                "2005-01-02 02:00:00",
-                "2005-01-02 02:15:00",
-                "2005-01-02 02:30:00",
-                "2005-01-02 02:45:00",
-                "2005-01-02 03:00:00",
-                "2005-01-02 03:15:00",
-                "2005-01-02 03:30:00",
-                "2005-01-02 03:45:00",
-                "2005-01-02 04:00:00",
-                "2005-01-02 04:15:00",
-                "2005-01-02 04:30:00",
-                "2005-01-02 04:45:00",
-                "2005-01-02 05:00:00",
-                "2005-01-02 05:15:00",
-                "2005-01-02 05:30:00",
-                "2005-01-02 05:45:00",
-                "2005-01-02 06:00:00",
-                "2005-01-02 06:15:00",
-                "2005-01-02 06:30:00",
-                "2005-01-02 06:45:00",
-                "2005-01-02 07:00:00",
-                "2005-01-02 07:15:00",
-                "2005-01-02 07:30:00",
-                "2005-01-02 07:45:00",
-                "2005-01-02 08:00:00",
-                "2005-01-02 08:15:00",
-                "2005-01-02 08:30:00",
-                "2005-01-02 08:45:00",
-                "2005-01-02 09:00:00",
-                "2005-01-02 09:15:00",
-                "2005-01-02 09:30:00",
-                "2005-01-02 09:45:00",
-                "2005-01-02 10:00:00",
-                "2005-01-02 10:15:00",
-                "2005-01-02 10:30:00",
-                "2005-01-02 10:45:00",
-                "2005-01-02 11:00:00",
-                "2005-01-02 11:15:00",
-                "2005-01-02 11:30:00",
-                "2005-01-02 11:45:00",
-                "2005-01-02 12:00:00",
-                "2005-01-02 12:15:00",
-                "2005-01-02 12:30:00",
-                "2005-01-02 12:45:00",
-                "2005-01-02 13:00:00",
-                "2005-01-02 13:15:00",
-                "2005-01-02 13:30:00",
-                "2005-01-02 13:45:00",
-                "2005-01-02 14:00:00",
-                "2005-01-02 14:15:00",
-                "2005-01-02 14:30:00",
-                "2005-01-02 14:45:00",
-                "2005-01-02 15:00:00",
-                "2005-01-02 15:15:00",
-                "2005-01-02 15:30:00",
-                "2005-01-02 15:45:00",
-                "2005-01-02 16:00:00",
-                "2005-01-02 16:15:00",
-                "2005-01-02 16:30:00",
-                "2005-01-02 16:45:00",
-                "2005-01-02 17:00:00",
-                "2005-01-02 17:15:00",
-                "2005-01-02 17:30:00",
-                "2005-01-02 17:45:00",
-                "2005-01-02 18:00:00",
-                "2005-01-02 18:15:00",
-                "2005-01-02 18:30:00",
-                "2005-01-02 18:45:00",
-                "2005-01-02 19:00:00",
-                "2005-01-02 19:15:00",
-                "2005-01-02 19:30:00",
-                "2005-01-02 19:45:00",
-                "2005-01-02 20:00:00",
-                "2005-01-02 20:15:00",
-                "2005-01-02 20:30:00",
-                "2005-01-02 20:45:00",
-                "2005-01-02 21:00:00",
-                "2005-01-02 21:15:00",
-                "2005-01-02 21:30:00",
-                "2005-01-02 21:45:00",
-                "2005-01-02 22:00:00",
-                "2005-01-02 22:15:00",
-                "2005-01-02 22:30:00",
-                "2005-01-02 22:45:00",
-                "2005-01-02 23:00:00",
-                "2005-01-02 23:15:00",
-                "2005-01-02 23:30:00",
-                "2005-01-02 23:45:00",
-            ]
-        )
-
-        assert dtindex.equals(mask)
-
-    def test_15min_to_2h_masking_1D(self):
-        # The data is identical for '2005-01-01' and '2005-01-03' timesteps,
-        # it is only different for '2005-01-02'
-        override = {
-            "techs.test_demand_elec.constraints.sink_equals": "file=demand_elec_15T_to_2h.csv",
-            "config.init.subset_time": None,
-        }
-
-        model = build_test_model(override, scenario="simple_supply,one_day")
-        data = model._model_data
-
-        mask = masks.extreme(
-            data, "test_demand_elec", var="sink_equals", how="max", length="1D"
-        )
-
-        dtindex = pd.DatetimeIndex(
-            [
-                "2005-01-02 00:00:00",
-                "2005-01-02 00:15:00",
-                "2005-01-02 00:30:00",
-                "2005-01-02 00:45:00",
-                "2005-01-02 01:00:00",
-                "2005-01-02 01:30:00",
-                "2005-01-02 02:00:00",
-                "2005-01-02 02:30:00",
-                "2005-01-02 03:00:00",
-                "2005-01-02 04:00:00",
-                "2005-01-02 05:00:00",
-                "2005-01-02 06:00:00",
-                "2005-01-02 08:00:00",
-                "2005-01-02 10:00:00",
-                "2005-01-02 12:00:00",
-                "2005-01-02 14:00:00",
-                "2005-01-02 16:00:00",
-                "2005-01-02 17:00:00",
-                "2005-01-02 18:00:00",
-                "2005-01-02 20:00:00",
-                "2005-01-02 22:00:00",
-            ]
-        )
-
-        assert dtindex.equals(mask)
-
-
-class TestResampling:
-    def test_15min_masking_1D_resampling_to_2h(self):
-        # The data is identical for '2005-01-01' and '2005-01-03' timesteps,
-        # it is only different for '2005-01-02'
-        override = {
-            "techs.test_demand_elec.constraints.sink_equals": "file=demand_elec_15mins.csv",
-            "config.init.subset_time": None,
-            "config.init.time": {
-                "masks": [
-                    {
-                        "function": "extreme",
-                        "options": {
-                            "tech": "test_demand_elec",
-                            "how": "max",
-                            "var": "sink_equals",
-                        },
-                    }
-                ],
-                "function": "resample",
-                "function_options": {"resolution": "2H"},
-            },
-        }
-
-        model = build_test_model(override, scenario="simple_supply,one_day")
-        data = model._model_data
-
-        dtindex = pd.DatetimeIndex(
-            [
-                "2005-01-01 00:00:00",
-                "2005-01-01 02:00:00",
-                "2005-01-01 04:00:00",
-                "2005-01-01 06:00:00",
-                "2005-01-01 08:00:00",
-                "2005-01-01 10:00:00",
-                "2005-01-01 12:00:00",
-                "2005-01-01 14:00:00",
-                "2005-01-01 16:00:00",
-                "2005-01-01 18:00:00",
-                "2005-01-01 20:00:00",
-                "2005-01-01 22:00:00",
-                "2005-01-02 00:00:00",
-                "2005-01-02 00:15:00",
-                "2005-01-02 00:30:00",
-                "2005-01-02 00:45:00",
-                "2005-01-02 01:00:00",
-                "2005-01-02 01:15:00",
-                "2005-01-02 01:30:00",
-                "2005-01-02 01:45:00",
-                "2005-01-02 02:00:00",
-                "2005-01-02 02:15:00",
-                "2005-01-02 02:30:00",
-                "2005-01-02 02:45:00",
-                "2005-01-02 03:00:00",
-                "2005-01-02 03:15:00",
-                "2005-01-02 03:30:00",
-                "2005-01-02 03:45:00",
-                "2005-01-02 04:00:00",
-                "2005-01-02 04:15:00",
-                "2005-01-02 04:30:00",
-                "2005-01-02 04:45:00",
-                "2005-01-02 05:00:00",
-                "2005-01-02 05:15:00",
-                "2005-01-02 05:30:00",
-                "2005-01-02 05:45:00",
-                "2005-01-02 06:00:00",
-                "2005-01-02 06:15:00",
-                "2005-01-02 06:30:00",
-                "2005-01-02 06:45:00",
-                "2005-01-02 07:00:00",
-                "2005-01-02 07:15:00",
-                "2005-01-02 07:30:00",
-                "2005-01-02 07:45:00",
-                "2005-01-02 08:00:00",
-                "2005-01-02 08:15:00",
-                "2005-01-02 08:30:00",
-                "2005-01-02 08:45:00",
-                "2005-01-02 09:00:00",
-                "2005-01-02 09:15:00",
-                "2005-01-02 09:30:00",
-                "2005-01-02 09:45:00",
-                "2005-01-02 10:00:00",
-                "2005-01-02 10:15:00",
-                "2005-01-02 10:30:00",
-                "2005-01-02 10:45:00",
-                "2005-01-02 11:00:00",
-                "2005-01-02 11:15:00",
-                "2005-01-02 11:30:00",
-                "2005-01-02 11:45:00",
-                "2005-01-02 12:00:00",
-                "2005-01-02 12:15:00",
-                "2005-01-02 12:30:00",
-                "2005-01-02 12:45:00",
-                "2005-01-02 13:00:00",
-                "2005-01-02 13:15:00",
-                "2005-01-02 13:30:00",
-                "2005-01-02 13:45:00",
-                "2005-01-02 14:00:00",
-                "2005-01-02 14:15:00",
-                "2005-01-02 14:30:00",
-                "2005-01-02 14:45:00",
-                "2005-01-02 15:00:00",
-                "2005-01-02 15:15:00",
-                "2005-01-02 15:30:00",
-                "2005-01-02 15:45:00",
-                "2005-01-02 16:00:00",
-                "2005-01-02 16:15:00",
-                "2005-01-02 16:30:00",
-                "2005-01-02 16:45:00",
-                "2005-01-02 17:00:00",
-                "2005-01-02 17:15:00",
-                "2005-01-02 17:30:00",
-                "2005-01-02 17:45:00",
-                "2005-01-02 18:00:00",
-                "2005-01-02 18:15:00",
-                "2005-01-02 18:30:00",
-                "2005-01-02 18:45:00",
-                "2005-01-02 19:00:00",
-                "2005-01-02 19:15:00",
-                "2005-01-02 19:30:00",
-                "2005-01-02 19:45:00",
-                "2005-01-02 20:00:00",
-                "2005-01-02 20:15:00",
-                "2005-01-02 20:30:00",
-                "2005-01-02 20:45:00",
-                "2005-01-02 21:00:00",
-                "2005-01-02 21:15:00",
-                "2005-01-02 21:30:00",
-                "2005-01-02 21:45:00",
-                "2005-01-02 22:00:00",
-                "2005-01-02 22:15:00",
-                "2005-01-02 22:30:00",
-                "2005-01-02 22:45:00",
-                "2005-01-02 23:00:00",
-                "2005-01-02 23:15:00",
-                "2005-01-02 23:30:00",
-                "2005-01-02 23:45:00",
-                "2005-01-03 00:00:00",
-                "2005-01-03 02:00:00",
-                "2005-01-03 04:00:00",
-                "2005-01-03 06:00:00",
-                "2005-01-03 08:00:00",
-                "2005-01-03 10:00:00",
-                "2005-01-03 12:00:00",
-                "2005-01-03 14:00:00",
-                "2005-01-03 16:00:00",
-                "2005-01-03 18:00:00",
-                "2005-01-03 20:00:00",
-                "2005-01-03 22:00:00",
-            ]
-        )
-
-        assert dtindex.equals(data.timesteps.to_index())
-
-    def test_15min_resampling_to_6h(self):
-        # The data is identical for '2005-01-01' and '2005-01-03' timesteps,
-        # it is only different for '2005-01-02'
-        override = {
-            "techs.test_demand_elec.constraints.sink_equals": "file=demand_elec_15mins.csv",
-            "config.init.subset_time": None,
-            "config.init.time": {
-                "function": "resample",
-                "function_options": {"resolution": "6H"},
-            },
-        }
-
-        model = build_test_model(override, scenario="simple_supply,one_day")
-        data = model._model_data
-
-        dtindex = pd.DatetimeIndex(
-            [
-                "2005-01-01 00:00:00",
-                "2005-01-01 06:00:00",
-                "2005-01-01 12:00:00",
-                "2005-01-01 18:00:00",
-                "2005-01-02 00:00:00",
-                "2005-01-02 06:00:00",
-                "2005-01-02 12:00:00",
-                "2005-01-02 18:00:00",
-                "2005-01-03 00:00:00",
-                "2005-01-03 06:00:00",
-                "2005-01-03 12:00:00",
-                "2005-01-03 18:00:00",
-            ]
-        )
-
-        assert dtindex.equals(data.timesteps.to_index())
-
-    def test_15min_to_2h_resampling_to_2h(self):
-        """
-        CSV has daily timeseries varying from 15min to 2h resolution, resample all to 2h
-        """
-        override = {
-            "techs.test_demand_elec.constraints.sink_equals": "file=demand_elec_15T_to_2h.csv",
-            "config.init.subset_time": None,
-            "config.init.time": {
-                "function": "resample",
-                "function_options": {"resolution": "2H"},
-            },
-        }
-
-        model = build_test_model(override, scenario="simple_supply,one_day")
-        data = model._model_data
-
-        dtindex = pd.DatetimeIndex(
-            [
-                "2005-01-01 00:00:00",
-                "2005-01-01 02:00:00",
-                "2005-01-01 04:00:00",
-                "2005-01-01 06:00:00",
-                "2005-01-01 08:00:00",
-                "2005-01-01 10:00:00",
-                "2005-01-01 12:00:00",
-                "2005-01-01 14:00:00",
-                "2005-01-01 16:00:00",
-                "2005-01-01 18:00:00",
-                "2005-01-01 20:00:00",
-                "2005-01-01 22:00:00",
-                "2005-01-02 00:00:00",
-                "2005-01-02 02:00:00",
-                "2005-01-02 04:00:00",
-                "2005-01-02 06:00:00",
-                "2005-01-02 08:00:00",
-                "2005-01-02 10:00:00",
-                "2005-01-02 12:00:00",
-                "2005-01-02 14:00:00",
-                "2005-01-02 16:00:00",
-                "2005-01-02 18:00:00",
-                "2005-01-02 20:00:00",
-                "2005-01-02 22:00:00",
-                "2005-01-03 00:00:00",
-                "2005-01-03 02:00:00",
-                "2005-01-03 04:00:00",
-                "2005-01-03 06:00:00",
-                "2005-01-03 08:00:00",
-                "2005-01-03 10:00:00",
-                "2005-01-03 12:00:00",
-                "2005-01-03 14:00:00",
-                "2005-01-03 16:00:00",
-                "2005-01-03 18:00:00",
-                "2005-01-03 20:00:00",
-                "2005-01-03 22:00:00",
-            ]
-        )
-
-        assert dtindex.equals(data.timesteps.to_index())
-
-
-class TestFuncs:
-    @pytest.fixture
-    def model_national(self, scope="module"):
-        return calliope.examples.national_scale(
-            override_dict={"config.init.subset_time": ["2005-01", "2005-01"]}
-        )
-
-    def test_drop_invalid_timesteps(self, model_national):
-        data = model_national._model_data_pre_clustering.copy()
-        timesteps = ["XXX2005-01-01 23:00"]
-
-        with pytest.raises(exceptions.ModelError):
-            funcs.drop(data, timesteps)
-
-    def test_drop(self, model_national):
-        data = model_national._model_data_pre_clustering.copy()
-        timesteps = ["2005-01-01 23:00", "2005-01-01 22:00"]
-
-        data_dropped = funcs.drop(data, timesteps)
-
-        assert len(data_dropped.timesteps) == 742
-
-        result_timesteps = list(data_dropped.coords["timesteps"].values)
-
-        assert "2005-01-01 21:00" not in result_timesteps
-        assert "2005-01-01 22:00" not in result_timesteps
-
-
-class TestLoadTimeseries:
-    def test_invalid_csv_columns(self):
-        override = {
-            "nodes": {
-                "c.techs": {"test_supply_elec": None, "test_demand_elec": None},
-                "d.techs": {"test_supply_elec": None, "test_demand_elec": None},
-            },
-            "links": {
-                "a,b": {"active": False},
-                "c,d.techs": {"test_transmission_elec": None},
-            },
-        }
-        with pytest.raises(exceptions.ModelError) as excinfo:
-            build_test_model(override_dict=override, scenario="one_day")
-
-        assert check_error_or_warning(
-            excinfo,
-            [
-                "file:column combinations `[('demand_elec.csv', 'c') ('demand_elec.csv', 'd')]` not found, but are requested by parameter `sink_equals`."
-            ],
-        )
diff --git a/tests/test_core_util.py b/tests/test_core_util.py
index b68f3edf1..73a4171e6 100644
--- a/tests/test_core_util.py
+++ b/tests/test_core_util.py
@@ -9,7 +9,7 @@
 import pytest
 from calliope.core.util.generate_runs import generate_runs
 from calliope.core.util.logging import log_time
-from calliope.core.util.tools import memoize, memoize_instancemethod, validate_dict
+from calliope.core.util.tools import validate_dict
 
 from .common.util import check_error_or_warning, python36_or_higher
 
@@ -18,32 +18,6 @@
 _MODEL_URBAN = (_EXAMPLES_DIR / "urban_scale" / "model.yaml").as_posix()
 
 
-class TestMemoization:
-    @memoize_instancemethod
-    def instance_method(self, a, b):
-        return a + b
-
-    def test_memoize_one_arg(self):
-        @memoize
-        def test(a):
-            return a + 1
-
-        assert test(1) == 2
-        assert test(1) == 2
-
-    def test_memoize_two_args(self):
-        @memoize
-        def test(a, b):
-            return a + b
-
-        assert test(1, 2) == 3
-        assert test(1, 2) == 3
-
-    def test_memoize_instancemethod(self):
-        assert self.instance_method(1, 2) == 3
-        assert self.instance_method(1, 2) == 3
-
-
 class TestLogging:
     @pytest.mark.parametrize(
         ["level", "include_solver_output", "expected_level", "expected_solver_level"],
@@ -118,7 +92,7 @@ def test_generate_runs_scenarios_none_with_overrides(self):
             _MODEL_URBAN,
             scenarios=None,
         )
-        assert len(runs) == 4
+        assert len(runs) == 3
         assert runs[0].endswith("--scenario milp --save_netcdf out_1_milp.nc")
 
 
diff --git a/tests/test_example_models.py b/tests/test_example_models.py
index fd17bc0c8..c83a70c87 100755
--- a/tests/test_example_models.py
+++ b/tests/test_example_models.py
@@ -32,14 +32,11 @@ def test_preprocess_milp(self):
     def test_preprocess_operate(self):
         calliope.examples.operate()
 
-    def test_preprocess_time_masking(self):
-        calliope.examples.time_masking()
-
 
 class TestNationalScaleExampleModelSenseChecks:
     def example_tester(self, solver="cbc", solver_io=None):
         model = calliope.examples.national_scale(
-            override_dict={"config.init.subset_time": ["2005-01-01", "2005-01-01"]}
+            override_dict={"config.init.time_subset": ["2005-01-01", "2005-01-01"]}
         )
         solve_kwargs = {"solver": solver}
         if solver_io:
@@ -143,7 +140,7 @@ class TestNationalScaleExampleModelOperate:
     def example_tester(self):
         with pytest.warns(calliope.exceptions.ModelWarning) as excinfo:
             model = calliope.examples.national_scale(
-                override_dict={"config.init.subset_time": ["2005-01-01", "2005-01-03"]},
+                override_dict={"config.init.time_subset": ["2005-01-01", "2005-01-03"]},
                 scenario="operate",
             )
             model.build()
@@ -168,7 +165,7 @@ def test_nationalscale_example_results_cbc(self):
 class TestNationalScaleExampleModelSpores:
     def example_tester(self, solver="cbc", solver_io=None):
         model = calliope.examples.national_scale(
-            override_dict={"config.init.subset_time": ["2005-01-01", "2005-01-03"]},
+            override_dict={"config.init.time_subset": ["2005-01-01", "2005-01-03"]},
             scenario="spores",
         )
         solve_kwargs = {"solver": solver}
@@ -236,7 +233,7 @@ def test_nationalscale_example_results_gurobi(self):
     @pytest.fixture
     def base_model_data(self):
         model = calliope.examples.national_scale(
-            override_dict={"config.init.subset_time": ["2005-01-01", "2005-01-03"]},
+            override_dict={"config.init.time_subset": ["2005-01-01", "2005-01-03"]},
             scenario="spores",
         )
 
@@ -348,12 +345,13 @@ def test_ignore_forced_flow_cap_spores_no_double_counting(
 
 class TestNationalScaleResampledExampleModelSenseChecks:
     def example_tester(self, solver="cbc", solver_io=None):
-        override = {"config.init.subset_time": ["2005-01-01", "2005-01-01"]}
         solve_kwargs = {"solver": solver}
         if solver_io:
             solve_kwargs["solver_io"] = solver_io
 
-        model = calliope.examples.time_resampling(override_dict=override)
+        model = calliope.examples.time_resampling(
+            time_subset=["2005-01-01", "2005-01-01"]
+        )
         model.build()
         model.solve(**solve_kwargs)
 
@@ -397,117 +395,6 @@ def test_nationalscale_resampled_example_results_glpk(self):
             pytest.skip("GLPK not installed")
 
 
-@pytest.mark.time_intensive
-class TestNationalScaleClusteredExampleModelSenseChecks:
-    def model_runner(
-        self,
-        expected_total_cost=None,
-        expected_levelised_cost=None,
-        expected_capacity_factor=None,
-        solver="cbc",
-        solver_io=None,
-        how="closest",
-        storage_inter_cluster=False,
-        cyclic=False,
-        storage=True,
-        backend_runner="run",
-    ):
-        override = {
-            "config.init.time.function_options": {
-                "how": how,
-                "storage_inter_cluster": storage_inter_cluster,
-            },
-        }
-        if storage is False:
-            override.update({"techs.battery.active": False, "techs.csp.active": False})
-        if storage_inter_cluster and backend_runner == "solve":
-            override["config.init.custom_math"] = ["storage_inter_cluster"]
-
-        solve_kwargs = {"solver": solver}
-        if solver_io:
-            solve_kwargs["solver_io"] = solver_io
-
-        model = calliope.examples.time_clustering(override_dict=override)
-        timesteps = model._model_data.timesteps.copy(deep=True)
-
-        model.build(cyclic_storage=cyclic)
-        model.solve(**solve_kwargs)
-
-        # make sure the dimension items have not been accidentally reordered
-        assert timesteps.equals(model._model_data.timesteps)
-
-        if expected_total_cost is not None:
-            # Full 1-hourly model run: 22389323.5 with cyclic storage, 22389455.6 without
-            assert float(model.results.cost.sum()) == approx(expected_total_cost)
-
-        if expected_levelised_cost is not None:
-            # Full 1-hourly model run: 0.316745 with cyclic storage, 0.316745 without
-            assert float(
-                model.results.systemwide_levelised_cost.loc[
-                    {"carriers": "power", "techs": "battery"}
-                ].item()
-            ) == approx(expected_levelised_cost, abs=0.000001)
-
-        if expected_capacity_factor is not None:
-            # Full 1-hourly model run: 0.067998 with cycling storage, 0.067998 without
-            assert float(
-                model.results.systemwide_capacity_factor.loc[
-                    {"carriers": "power", "techs": "battery"}
-                ].item()
-            ) == approx(expected_capacity_factor, abs=0.000001)
-
-        return None
-
-    def example_tester_closest(self, backend_runner, solver="cbc", solver_io=None):
-        self.model_runner(
-            solver=solver,
-            expected_total_cost=51711873.177,  # was 49670627.15297682 when clustering with sklearn < v0.24
-            expected_levelised_cost=0.111456,  # was 0.137105 when clustering with sklearn < v0.24
-            expected_capacity_factor=0.074809,  # was 0.064501 when clustering with sklearn < v0.24
-            solver_io=solver_io,
-            how="closest",
-            backend_runner=backend_runner,
-        )
-
-    def example_tester_mean(self, backend_runner, solver="cbc", solver_io=None):
-        self.model_runner(
-            solver=solver,
-            expected_total_cost=45110416.434,  # was 22172253.328 when clustering with sklearn < v0.24
-            expected_levelised_cost=0.126098,  # was 0.127783 when clustering with sklearn < v0.24
-            expected_capacity_factor=0.047596,  # was 0.044458 when clustering with sklearn < v0.24
-            solver_io=solver_io,
-            how="mean",
-            backend_runner=backend_runner,
-        )
-
-    @pytest.mark.parametrize("backend_runner", ["run", "solve"])
-    def test_nationalscale_clustered_example_closest_results_cbc(self, backend_runner):
-        self.example_tester_closest(backend_runner)
-
-    @pytest.mark.parametrize("backend_runner", ["run", "solve"])
-    def test_nationalscale_clustered_example_mean_results_cbc(self, backend_runner):
-        self.example_tester_mean(backend_runner)
-
-    def test_nationalscale_clustered_example_storage_inter_cluster(self):
-        self.model_runner(
-            expected_total_cost=33353390.626,  # was 21825515.304 when clustering with sklearn < v0.24
-            expected_levelised_cost=0.115866,  # was 0.100760 when clustering with sklearn < v0.24
-            expected_capacity_factor=0.074167,  # was 0.091036 when clustering with sklearn < v0.24
-            storage_inter_cluster=True,
-            backend_runner="solve",
-        )
-
-    def test_storage_inter_cluster_cyclic(self):
-        self.model_runner(
-            expected_total_cost=18838244.197,  # was 18904055.722 when clustering with sklearn < v0.24
-            expected_levelised_cost=0.133110,  # was 0.122564 when clustering with sklearn < v0.24
-            expected_capacity_factor=0.071411,  # was 0.075145 when clustering with sklearn < v0.24
-            storage_inter_cluster=True,
-            cyclic=True,
-            backend_runner="solve",
-        )
-
-
 class TestUrbanScaleExampleModelSenseChecks:
     def example_tester(self, source_unit, solver="cbc", solver_io=None):
         unit_override = {
@@ -518,7 +405,7 @@ def example_tester(self, source_unit, solver="cbc", solver_io=None):
         }
 
         model = calliope.examples.urban_scale(
-            override_dict=unit_override, subset_time=["2005-07-01", "2005-07-01"]
+            override_dict=unit_override, time_subset=["2005-07-01", "2005-07-01"]
         )
 
         solve_kwargs = {"solver": solver}
@@ -569,7 +456,7 @@ def test_urban_example_results_cap_gurobi(self):
     def test_milp_example_results(self):
         model = calliope.examples.milp(
             override_dict={
-                "config.init.subset_time": ["2005-01-01", "2005-01-01"],
+                "config.init.time_subset": ["2005-01-01", "2005-01-01"],
                 "config.solve.solver_options": {"mipgap": 0.001},
             }
         )
@@ -596,7 +483,7 @@ def test_milp_example_results(self):
     @pytest.mark.xfail(reason="Not expecting operate mode to work at the moment")
     def test_operate_example_results(self):
         model = calliope.examples.operate(
-            override_dict={"config.init.subset_time": ["2005-07-01", "2005-07-04"]}
+            override_dict={"config.init.time_subset": ["2005-07-01", "2005-07-04"]}
         )
         with pytest.warns(calliope.exceptions.ModelWarning) as excinfo:
             model.build()
diff --git a/tests/test_preprocess_time.py b/tests/test_preprocess_time.py
new file mode 100644
index 000000000..f3dc47624
--- /dev/null
+++ b/tests/test_preprocess_time.py
@@ -0,0 +1,175 @@
+import pandas as pd
+import pytest  # noqa: F401
+from calliope import exceptions
+
+from .common.util import build_test_model, check_error_or_warning
+
+
+class TestClustering:
+    @pytest.fixture
+    def clustered_model(self):
+        cluster_init = {
+            "time_subset": ["2005-01-01", "2005-01-04"],
+            "time_cluster": "cluster_days.csv",
+        }
+        return build_test_model(scenario="simple_supply", **cluster_init)
+
+    def test_no_operate_mode_allowed(self, clustered_model):
+        assert clustered_model._model_data.attrs["allow_operate_mode"] == 0
+
+    def test_cluster_numbers(self, clustered_model):
+        assert (
+            clustered_model._model_data.clusters.to_index()
+            .symmetric_difference([0, 1])
+            .empty
+        )
+
+    def test_cluster_timesteps(self, clustered_model):
+        expected = pd.Series(
+            {
+                f"2005-01-0{day} {hour}:00:00": 0 if day == 1 else 1
+                for day in [1, 3]
+                for hour in range(0, 24)
+            }
+        )
+        expected.index = pd.to_datetime(expected.index)
+
+        pd.testing.assert_series_equal(
+            clustered_model._model_data.timestep_cluster.to_series(),
+            expected,
+            check_names=False,
+        )
+
+    def test_cluster_datesteps(self, clustered_model):
+        expected = pd.DatetimeIndex(
+            ["2005-01-01", "2005-01-02", "2005-01-03", "2005-01-04"],
+            name="datesteps",
+        )
+        pd.testing.assert_index_equal(
+            clustered_model._model_data.datesteps.to_index(), expected
+        )
+
+    @pytest.mark.parametrize(
+        "var",
+        [
+            "lookup_cluster_first_timestep",
+            "lookup_cluster_last_timestep",
+            "lookup_datestep_cluster",
+            "lookup_datestep_last_cluster_timestep",
+        ],
+    )
+    def test_cluster_has_lookup_arrays(self, clustered_model, var):
+        assert var in clustered_model._model_data.data_vars
+
+
+class TestResamplingAndCluster:
+    def test_resampling_to_6h_then_clustering(self):
+        model = build_test_model(
+            scenario="simple_supply",
+            time_subset=["2005-01-01", "2005-01-04"],
+            time_resample="6H",
+            time_cluster="cluster_days.csv",
+        )
+
+        dtindex = pd.DatetimeIndex(
+            [
+                "2005-01-01 00:00:00",
+                "2005-01-01 06:00:00",
+                "2005-01-01 12:00:00",
+                "2005-01-01 18:00:00",
+                "2005-01-03 00:00:00",
+                "2005-01-03 06:00:00",
+                "2005-01-03 12:00:00",
+                "2005-01-03 18:00:00",
+            ]
+        )
+
+        assert dtindex.equals(model._model_data.timesteps.to_index())
+        assert model._model_data.attrs["allow_operate_mode"] == 0
+
+
+class TestResampling:
+    def test_15min_resampling_to_6h(self):
+        # The data is identical for '2005-01-01' and '2005-01-03' timesteps,
+        # it is only different for '2005-01-02'
+        override = {
+            "techs.test_demand_elec.constraints.sink_equals": "file=demand_elec_15mins.csv",
+        }
+
+        model = build_test_model(override, scenario="simple_supply", time_resample="6H")
+        data = model._model_data
+
+        dtindex = pd.DatetimeIndex(
+            [
+                "2005-01-01 00:00:00",
+                "2005-01-01 06:00:00",
+                "2005-01-01 12:00:00",
+                "2005-01-01 18:00:00",
+                "2005-01-02 00:00:00",
+                "2005-01-02 06:00:00",
+                "2005-01-02 12:00:00",
+                "2005-01-02 18:00:00",
+                "2005-01-03 00:00:00",
+                "2005-01-03 06:00:00",
+                "2005-01-03 12:00:00",
+                "2005-01-03 18:00:00",
+            ]
+        )
+
+        assert dtindex.equals(data.timesteps.to_index())
+        assert data.attrs["allow_operate_mode"] == 1
+
+    def test_15min_to_2h_resampling_to_2h(self):
+        """
+        CSV has daily timeseries varying from 15min to 2h resolution, resample all to 2h
+        """
+        override = {
+            "techs.test_demand_elec.constraints.sink_equals": "file=demand_elec_15T_to_2h.csv"
+        }
+
+        model = build_test_model(
+            override, scenario="simple_supply,one_day", time_resample="2H"
+        )
+        data = model._model_data
+
+        dtindex = pd.DatetimeIndex(
+            [
+                "2005-01-01 00:00:00",
+                "2005-01-01 02:00:00",
+                "2005-01-01 04:00:00",
+                "2005-01-01 06:00:00",
+                "2005-01-01 08:00:00",
+                "2005-01-01 10:00:00",
+                "2005-01-01 12:00:00",
+                "2005-01-01 14:00:00",
+                "2005-01-01 16:00:00",
+                "2005-01-01 18:00:00",
+                "2005-01-01 20:00:00",
+                "2005-01-01 22:00:00",
+            ]
+        )
+
+        assert dtindex.equals(data.timesteps.to_index())
+
+
+class TestLoadTimeseries:
+    def test_invalid_csv_columns(self):
+        override = {
+            "nodes": {
+                "c.techs": {"test_supply_elec": None, "test_demand_elec": None},
+                "d.techs": {"test_supply_elec": None, "test_demand_elec": None},
+            },
+            "links": {
+                "a,b": {"active": False},
+                "c,d.techs": {"test_transmission_elec": None},
+            },
+        }
+        with pytest.raises(exceptions.ModelError) as excinfo:
+            build_test_model(override_dict=override, scenario="one_day")
+
+        assert check_error_or_warning(
+            excinfo,
+            [
+                "file:column combinations `[('demand_elec.csv', 'c') ('demand_elec.csv', 'd')]` not found, but are requested by parameter `sink_equals`."
+            ],
+        )