diff --git a/include/evalhyd/detail/probabilist/brier.hpp b/include/evalhyd/detail/probabilist/brier.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..70293fc9c354f2d7db11ae122d68f33b8b2e1fdc
--- /dev/null
+++ b/include/evalhyd/detail/probabilist/brier.hpp
@@ -0,0 +1,639 @@
+#ifndef EVALHYD_PROBABILIST_BRIER_HPP
+#define EVALHYD_PROBABILIST_BRIER_HPP
+
+#include <xtensor/xtensor.hpp>
+#include <xtensor/xview.hpp>
+#include <xtensor/xmasked_view.hpp>
+
+
+namespace evalhyd
+{
+    namespace probabilist
+    {
+        namespace elements
+        {
+            // Determine observed realisation of threshold(s) exceedance.
+            //
+            // \param q_obs
+            //     Streamflow observations.
+            //     shape: (time,)
+            // \param q_thr
+            //     Streamflow exceedance threshold(s).
+            //     shape: (thresholds,)
+            // \return
+            //     Event observed outcome.
+            //     shape: (thresholds, time)
+            template<class V1D2>
+            inline xt::xtensor<double, 2> calc_o_k(
+                    const V1D2& q_obs,
+                    const V1D2& q_thr
+            )
+            {
+                // determine observed realisation of threshold(s) exceedance
+                return q_obs >= xt::view(q_thr, xt::all(), xt::newaxis());
+            }
+
+            // Determine mean observed realisation of threshold(s) exceedance.
+            //
+            // \param o_k
+            //     Event observed outcome.
+            //     shape: (thresholds, time)
+            // \param t_msk
+            //     Temporal subsets of the whole record.
+            //     shape: (subsets, time)
+            // \param b_exp
+            //     Boostrap samples.
+            //     shape: (samples, time slice)
+            // \param n_thr
+            //     Number of thresholds.
+            // \param n_msk
+            //     Number of temporal subsets.
+            // \param n_exp
+            //     Number of bootstrap samples.
+            // \return
+            //     Mean event observed outcome.
+            //     shape: (subsets, samples, thresholds)
+            inline xt::xtensor<double, 3> calc_bar_o(
+                    const xt::xtensor<double, 2>& o_k,
+                    const xt::xtensor<bool, 2>& t_msk,
+                    const std::vector<xt::xkeep_slice<int>>& b_exp,
+                    std::size_t n_thr,
+                    std::size_t n_msk,
+                    std::size_t n_exp
+            )
+            {
+                // apply the mask
+                // (using NaN workaround until reducers work on masked_view)
+                auto o_k_masked = xt::where(
+                        xt::view(t_msk, xt::all(), xt::newaxis(), xt::all()),
+                        o_k, NAN
+                );
+
+                // compute variable one sample at a time
+                xt::xtensor<double, 3> bar_o = xt::zeros<double>({n_msk, n_exp, n_thr});
+
+                for (std::size_t e = 0; e < n_exp; e++)
+                {
+                    // apply the bootstrap sampling
+                    auto o_k_masked_sampled =
+                            xt::view(o_k_masked, xt::all(), xt::all(), b_exp[e]);
+
+                    // compute mean "climatology" relative frequency of the event
+                    // $\bar{o} = \frac{1}{n} \sum_{k=1}^{n} o_k$
+                    xt::view(bar_o, xt::all(), e, xt::all()) =
+                            xt::nanmean(o_k_masked_sampled, -1);
+                }
+
+                return bar_o;
+            }
+
+            // Determine forecast probability of threshold(s) exceedance to occur.
+            //
+            // \param q_prd
+            //     Streamflow predictions.
+            //     shape: (members, time)
+            // \param q_thr
+            //     Streamflow exceedance threshold(s).
+            //     shape: (thresholds,)
+            // \param n_mbr
+            //     Number of ensemble members.
+            // \return
+            //     Event probability forecast.
+            //     shape: (thresholds, time)
+            template<class V2D4, class V1D2>
+            inline xt::xtensor<double, 2> calc_y_k(
+                    const V2D4& q_prd,
+                    const V1D2& q_thr,
+                    std::size_t n_mbr
+            )
+            {
+                // determine if members have exceeded threshold(s)
+                auto e_frc =
+                        q_prd >= xt::view(q_thr, xt::all(),
+                                          xt::newaxis(), xt::newaxis());
+
+                // calculate how many members have exceeded threshold(s)
+                auto n_frc = xt::sum(e_frc, 1);
+
+                // determine probability of threshold(s) exceedance
+                // /!\ probability calculation dividing by n (the number of
+                //     members), not n+1 (the number of ranks) like in other metrics
+                return xt::cast<double>(n_frc) / n_mbr;
+            }
+        }
+
+        namespace intermediate
+        {
+            // Compute the Brier score for each time step.
+            //
+            // \param o_k
+            //     Observed event outcome.
+            //     shape: (thresholds, time)
+            // \param y_k
+            //     Event probability forecast.
+            //     shape: (thresholds, time)
+            // \return
+            //     Brier score for each threshold for each time step.
+            //     shape: (thresholds, time)
+            inline xt::xtensor<double, 2> calc_bs(
+                    const xt::xtensor<double, 2>& o_k,
+                    const xt::xtensor<double, 2>& y_k
+            )
+            {
+                // return computed Brier score(s)
+                // $bs = (o_k - y_k)^2$
+                return xt::square(o_k - y_k);
+            }
+        }
+
+        namespace metrics
+        {
+            // Compute the Brier score (BS).
+            //
+            // \param bs
+            //     Brier score for each threshold for each time step.
+            //     shape: (thresholds, time)
+            // \param q_thr
+            //     Streamflow exceedance threshold(s).
+            //     shape: (thresholds,)
+            // \param t_msk
+            //     Temporal subsets of the whole record.
+            //     shape: (subsets, time)
+            // \param b_exp
+            //     Boostrap samples.
+            //     shape: (samples, time slice)
+            // \param n_thr
+            //     Number of thresholds.
+            // \param n_msk
+            //     Number of temporal subsets.
+            // \param n_exp
+            //     Number of bootstrap samples.
+            // \return
+            //     Brier score for each subset and for each threshold.
+            //     shape: (subsets, samples, thresholds)
+            template <class V1D2>
+            inline xt::xtensor<double, 3> calc_BS(
+                    const xt::xtensor<double, 2>& bs,
+                    const V1D2& q_thr,
+                    const xt::xtensor<bool, 2>& t_msk,
+                    const std::vector<xt::xkeep_slice<int>>& b_exp,
+                    std::size_t n_thr,
+                    std::size_t n_msk,
+                    std::size_t n_exp
+            )
+            {
+                // initialise output variable
+                // shape: (subsets, thresholds)
+                xt::xtensor<double, 3> BS =
+                        xt::zeros<double>({n_msk, n_exp, n_thr});
+
+                // compute variable one mask at a time to minimise memory imprint
+                for (std::size_t m = 0; m < n_msk; m++)
+                {
+                    // apply the mask
+                    // (using NaN workaround until reducers work on masked_view)
+                    auto bs_masked = xt::where(xt::row(t_msk, m), bs, NAN);
+
+                    // compute variable one sample at a time
+                    for (std::size_t e = 0; e < n_exp; e++)
+                    {
+                        // apply the bootstrap sampling
+                        auto bs_masked_sampled =
+                                xt::view(bs_masked, xt::all(), b_exp[e]);
+
+                        // calculate the mean over the time steps
+                        // $BS = \frac{1}{n} \sum_{k=1}^{n} (o_k - y_k)^2$
+                        xt::view(BS, m, e, xt::all()) =
+                                xt::nanmean(bs_masked_sampled, -1);
+                    }
+                }
+
+                // assign NaN where thresholds were not provided (i.e. set as NaN)
+                xt::masked_view(
+                        BS,
+                        xt::isnan(xt::view(q_thr, xt::newaxis(),
+                                           xt::newaxis(), xt::all()))
+                ) = NAN;
+
+                return BS;
+            }
+
+            // Compute the calibration-refinement decomposition of the Brier score
+            // into reliability, resolution, and uncertainty.
+            //
+            // BS = reliability - resolution + uncertainty
+            //
+            // \param q_thr
+            //     Streamflow exceedance threshold(s).
+            //     shape: (thresholds,)
+            // \param o_k
+            //     Observed event outcome.
+            //     shape: (thresholds, time)
+            // \param y_k
+            //     Event probability forecast.
+            //     shape: (thresholds, time)
+            // \param bar_o
+            //     Mean event observed outcome.
+            //     shape: (subsets, samples, thresholds)
+            // \param t_msk
+            //     Temporal subsets of the whole record.
+            //     shape: (subsets, time)
+            // \param b_exp
+            //     Boostrap samples.
+            //     shape: (samples, time slice)
+            // \param n_thr
+            //     Number of thresholds.
+            // \param n_mbr
+            //     Number of ensemble members.
+            // \param n_msk
+            //     Number of temporal subsets.
+            // \param n_exp
+            //     Number of bootstrap samples.
+            // \return
+            //     Brier score components (reliability, resolution, uncertainty)
+            //     for each subset and for each threshold.
+            //     shape: (subsets, samples, thresholds, components)
+            template <class V1D2>
+            inline xt::xtensor<double, 4> calc_BS_CRD(
+                    const V1D2& q_thr,
+                    const xt::xtensor<double, 2>& o_k,
+                    const xt::xtensor<double, 2>& y_k,
+                    const xt::xtensor<double, 3>& bar_o,
+                    const xt::xtensor<bool, 2>& t_msk,
+                    const std::vector<xt::xkeep_slice<int>>& b_exp,
+                    std::size_t n_thr,
+                    std::size_t n_mbr,
+                    std::size_t n_msk,
+                    std::size_t n_exp
+            )
+            {
+                // declare internal variables
+                // shape: (bins, thresholds, time)
+                xt::xtensor<double, 3> msk_bins;
+                // shape: (bins, thresholds)
+                xt::xtensor<double, 2> N_i, bar_o_i;
+                // shape: (bins,)
+                xt::xtensor<double, 1> y_i;
+
+                // compute range of forecast values $y_i$
+                y_i = xt::arange<double>(double(n_mbr + 1)) / n_mbr;
+
+                // initialise output variable
+                // shape: (subsets, thresholds, components)
+                xt::xtensor<double, 4> BS_CRD =
+                        xt::zeros<double>({n_msk, n_exp, n_thr, std::size_t {3}});
+
+                // compute variable one mask at a time to minimise memory imprint
+                for (std::size_t m = 0; m < n_msk; m++)
+                {
+                    // apply the mask
+                    // (using NaN workaround until reducers work on masked_view)
+                    auto o_k_masked = xt::where(xt::row(t_msk, m), o_k, NAN);
+                    auto y_k_masked = xt::where(xt::row(t_msk, m), y_k, NAN);
+
+                    // compute variable one sample at a time
+                    for (std::size_t e = 0; e < n_exp; e++)
+                    {
+                        // apply the bootstrap sampling
+                        auto o_k_masked_sampled =
+                                xt::view(o_k_masked, xt::all(), b_exp[e]);
+                        auto y_k_masked_sampled =
+                                xt::view(y_k_masked, xt::all(), b_exp[e]);
+                        auto t_msk_sampled =
+                                xt::view(xt::row(t_msk, m), b_exp[e]);
+                        auto bar_o_sampled =
+                                xt::view(bar_o, xt::all(), e, xt::all());
+
+                        // calculate length of subset
+                        auto l = xt::sum(t_msk_sampled);
+
+                        // compute mask to subsample time steps belonging to same forecast bin
+                        // (where bins are defined as the range of forecast values)
+                        msk_bins = xt::equal(
+                                // force evaluation to avoid segfault
+                                xt::eval(y_k_masked_sampled),
+                                xt::view(y_i, xt::all(), xt::newaxis(), xt::newaxis())
+                        );
+
+                        // compute number of forecasts in each forecast bin $N_i$
+                        N_i = xt::nansum(msk_bins, -1);
+
+                        // compute subsample relative frequency
+                        // $\bar{o_i} = \frac{1}{N_i} \sum_{k \in N_i} o_k$
+                        bar_o_i = xt::where(
+                                N_i > 0,
+                                xt::nansum(
+                                        xt::where(
+                                                msk_bins,
+                                                xt::view(o_k_masked_sampled, xt::newaxis(),
+                                                         xt::all(), xt::all()),
+                                                0.
+                                        ),
+                                        -1
+                                ) / N_i,
+                                0.
+                        );
+
+                        // calculate reliability =
+                        // $\frac{1}{n} \sum_{i=1}^{I} N_i (y_i - \bar{o_i})^2$
+                        xt::view(BS_CRD, m, e, xt::all(), 0) =
+                                xt::nansum(
+                                        xt::square(
+                                                xt::view(y_i, xt::all(), xt::newaxis())
+                                                - bar_o_i
+                                        ) * N_i,
+                                        0
+                                ) / l;
+
+                        // calculate resolution =
+                        // $\frac{1}{n} \sum_{i=1}^{I} N_i (\bar{o_i} - \bar{o})^2$
+                        xt::view(BS_CRD, m, e, xt::all(), 1) =
+                                xt::nansum(
+                                        xt::square(
+                                                bar_o_i - xt::view(bar_o_sampled, m)
+                                        ) * N_i,
+                                        0
+                                ) / l;
+
+                        // calculate uncertainty = $\bar{o} (1 - \bar{o})$
+                        xt::view(BS_CRD, m, e, xt::all(), 2) =
+                                xt::view(bar_o_sampled, m)
+                                * (1 - xt::view(bar_o_sampled, m));
+                    }
+                }
+
+                // assign NaN where thresholds were not provided (i.e. set as NaN)
+                xt::masked_view(
+                        BS_CRD,
+                        xt::isnan(xt::view(q_thr, xt::newaxis(), xt::newaxis(),
+                                           xt::all(), xt::newaxis()))
+                ) = NAN;
+
+                return BS_CRD;
+            }
+
+            // Compute the likelihood-base rate decomposition of the Brier score
+            // into type 2 bias, discrimination, and sharpness (a.k.a. refinement).
+            //
+            // BS = type 2 bias - discrimination + sharpness
+            //
+            // \param q_thr
+            //     Streamflow exceedance threshold(s).
+            //     shape: (thresholds,)
+            // \param o_k
+            //     Observed event outcome.
+            //     shape: (thresholds, time)
+            // \param y_k
+            //     Event probability forecast.
+            //     shape: (thresholds, time)
+            // \param t_msk
+            //     Temporal subsets of the whole record.
+            //     shape: (subsets, time)
+            // \param b_exp
+            //     Boostrap samples.
+            //     shape: (samples, time slice)
+            // \param n_thr
+            //     Number of thresholds.
+            // \param n_msk
+            //     Number of temporal subsets.
+            // \param n_exp
+            //     Number of bootstrap samples.
+            // \return
+            //     Brier score components (type 2 bias, discrimination, sharpness)
+            //     for each subset and for each threshold.
+            //     shape: (subsets, samples, thresholds, components)
+            template <class V1D2>
+            inline xt::xtensor<double, 4> calc_BS_LBD(
+                    const V1D2& q_thr,
+                    const xt::xtensor<double, 2>& o_k,
+                    const xt::xtensor<double, 2>& y_k,
+                    const xt::xtensor<bool, 2>& t_msk,
+                    const std::vector<xt::xkeep_slice<int>>& b_exp,
+                    std::size_t n_thr,
+                    std::size_t n_msk,
+                    std::size_t n_exp
+            )
+            {
+                // declare internal variables
+                // shape: (bins, thresholds, time)
+                xt::xtensor<double, 3> msk_bins;
+                // shape: (thresholds,)
+                xt::xtensor<double, 1> bar_y;
+                // shape: (bins, thresholds)
+                xt::xtensor<double, 2> M_j, bar_y_j;
+                // shape: (bins,)
+                xt::xtensor<double, 1> o_j;
+
+                // set the range of observed values $o_j$
+                o_j = {0., 1.};
+
+                // declare and initialise output variable
+                xt::xtensor<double, 4> BS_LBD =
+                        xt::zeros<double>({n_msk, n_exp, n_thr, std::size_t {3}});
+
+                // compute variable one mask at a time to minimise memory imprint
+                for (std::size_t m = 0; m < n_msk; m++)
+                {
+                    // apply the mask
+                    // (using NaN workaround until reducers work on masked_view)
+                    auto o_k_masked = xt::where(xt::row(t_msk, m), o_k, NAN);
+                    auto y_k_masked = xt::where(xt::row(t_msk, m), y_k, NAN);
+
+                    // compute variable one sample at a time
+                    for (std::size_t e = 0; e < n_exp; e++)
+                    {
+                        // apply the bootstrap sampling
+                        auto o_k_masked_sampled =
+                                xt::view(o_k_masked, xt::all(), b_exp[e]);
+                        auto y_k_masked_sampled =
+                                xt::view(y_k_masked, xt::all(), b_exp[e]);
+                        auto t_msk_sampled =
+                                xt::view(t_msk, xt::all(), b_exp[e]);
+
+                        // calculate length of subset
+                        auto l = xt::sum(xt::row(t_msk_sampled, m));
+
+                        // compute mask to subsample time steps belonging to same observation bin
+                        // (where bins are defined as the range of forecast values)
+                        msk_bins = xt::equal(
+                                // force evaluation to avoid segfault
+                                xt::eval(o_k_masked_sampled),
+                                xt::view(o_j, xt::all(), xt::newaxis(), xt::newaxis())
+                        );
+
+                        // compute number of observations in each observation bin $M_j$
+                        M_j = xt::nansum(msk_bins, -1);
+
+                        // compute subsample relative frequency
+                        // $\bar{y_j} = \frac{1}{M_j} \sum_{k \in M_j} y_k$
+                        bar_y_j = xt::where(
+                                M_j > 0,
+                                xt::nansum(
+                                        xt::where(
+                                                msk_bins,
+                                                xt::view(
+                                                        y_k_masked_sampled,
+                                                        xt::newaxis(), xt::all(), xt::all()
+                                                ),
+                                                0.
+                                        ),
+                                        -1
+                                ) / M_j,
+                                0.
+                        );
+
+                        // compute mean "climatology" forecast probability
+                        // $\bar{y} = \frac{1}{n} \sum_{k=1}^{n} y_k$
+                        bar_y = xt::nanmean(y_k_masked_sampled, -1);
+
+                        // calculate type 2 bias =
+                        // $\frac{1}{n} \sum_{j=1}^{J} M_j (o_j - \bar{y_j})^2$
+                        xt::view(BS_LBD, m, e, xt::all(), 0) =
+                                xt::nansum(
+                                        xt::square(
+                                                xt::view(o_j, xt::all(), xt::newaxis())
+                                                - bar_y_j
+                                        ) * M_j,
+                                        0
+                                ) / l;
+
+                        // calculate discrimination =
+                        // $\frac{1}{n} \sum_{j=1}^{J} M_j (\bar{y_j} - \bar{y})^2$
+                        xt::view(BS_LBD, m, e, xt::all(), 1) =
+                                xt::nansum(
+                                        xt::square(
+                                                bar_y_j - bar_y
+                                        ) * M_j,
+                                        0
+                                ) / l;
+
+                        // calculate sharpness =
+                        // $\frac{1}{n} \sum_{k=1}^{n} (\bar{y_k} - \bar{y})^2$
+                        xt::view(BS_LBD, m, e, xt::all(), 2) =
+                                xt::nansum(
+                                        xt::square(
+                                                y_k_masked_sampled
+                                                - xt::view(bar_y, xt::all(), xt::newaxis())
+                                        ),
+                                        -1
+                                ) / l;
+                    }
+
+                }
+
+                // assign NaN where thresholds were not provided (i.e. set as NaN)
+                xt::masked_view(
+                        BS_LBD,
+                        xt::isnan(xt::view(q_thr, xt::newaxis(), xt::newaxis(),
+                                           xt::all(), xt::newaxis()))
+                ) = NAN;
+
+                return BS_LBD;
+            }
+
+            // Compute the Brier skill score (BSS).
+            //
+            // \param bs
+            //     Brier score for each threshold for each time step.
+            //     shape: (thresholds, time)
+            // \param q_thr
+            //     Streamflow exceedance threshold(s).
+            //     shape: (thresholds,)
+            // \param o_k
+            //     Observed event outcome.
+            //     shape: (thresholds, time)
+            // \param bar_o
+            //     Mean event observed outcome.
+            //     shape: (subsets, samples, thresholds)
+            // \param t_msk
+            //     Temporal subsets of the whole record.
+            //     shape: (subsets, time)
+            // \param b_exp
+            //     Boostrap samples.
+            //     shape: (samples, time slice)
+            // \param n_thr
+            //     Number of thresholds.
+            // \param n_msk
+            //     Number of temporal subsets.
+            // \param n_exp
+            //     Number of bootstrap samples.
+            // \return
+            //     Brier skill score for each subset and for each threshold.
+            //     shape: (subsets, samples, thresholds)
+            template <class V1D2>
+            inline xt::xtensor<double, 3> calc_BSS(
+                    const xt::xtensor<double, 2>& bs,
+                    const V1D2& q_thr,
+                    const xt::xtensor<double, 2>& o_k,
+                    const xt::xtensor<double, 3>& bar_o,
+                    const xt::xtensor<bool, 2>& t_msk,
+                    const std::vector<xt::xkeep_slice<int>>& b_exp,
+                    std::size_t n_thr,
+                    std::size_t n_msk,
+                    std::size_t n_exp
+            )
+            {
+                // declare and initialise output variable
+                xt::xtensor<double, 3> BSS =
+                        xt::zeros<double>({n_msk, n_exp, n_thr});
+
+                // compute variable one mask at a time to minimise memory imprint
+                for (std::size_t m = 0; m < n_msk; m++)
+                {
+                    // apply the mask
+                    // (using NaN workaround until reducers work on masked_view)
+                    auto o_k_masked = xt::where(xt::row(t_msk, m), o_k, NAN);
+                    auto bs_masked = xt::where(xt::row(t_msk, m), bs, NAN);
+
+                    // compute variable one sample at a time
+                    for (std::size_t e = 0; e < n_exp; e++)
+                    {
+                        // apply the bootstrap sampling
+                        auto o_k_masked_sampled =
+                                xt::view(o_k_masked, xt::all(), b_exp[e]);
+                        auto bs_masked_sampled =
+                                xt::view(bs_masked, xt::all(), b_exp[e]);
+                        auto bar_o_sampled =
+                                xt::view(bar_o, xt::all(), e, xt::all());
+
+                        // calculate reference Brier score(s)
+                        // $bs_{ref} = \frac{1}{n} \sum_{k=1}^{n} (o_k - \bar{o})^2$
+                        xt::xtensor<double, 2> bs_ref =
+                                xt::nanmean(
+                                        xt::square(
+                                                o_k_masked_sampled -
+                                                xt::view(
+                                                        xt::view(bar_o_sampled, m),
+                                                        xt::all(), xt::newaxis()
+                                                )
+                                        ),
+                                        -1, xt::keep_dims
+                                );
+
+                        // compute Brier skill score(s)
+                        // $BSS = \frac{1}{n} \sum_{k=1}^{n} 1 - \frac{bs}{bs_{ref}}
+                        xt::view(BSS, m, e, xt::all()) =
+                                xt::nanmean(
+                                        xt::where(
+                                                xt::equal(bs_ref, 0),
+                                                0, 1 - (bs_masked_sampled / bs_ref)
+                                        ),
+                                        -1
+                                );
+                    }
+                }
+
+                // assign NaN where thresholds were not provided (i.e. set as NaN)
+                xt::masked_view(
+                        BSS,
+                        xt::isnan(xt::view(q_thr, xt::newaxis(), xt::newaxis(),
+                                           xt::all()))
+                ) = NAN;
+
+                return BSS;
+            }
+        }
+    }
+}
+
+#endif //EVALHYD_PROBABILIST_BRIER_HPP
diff --git a/include/evalhyd/detail/probabilist/evaluator.hpp b/include/evalhyd/detail/probabilist/evaluator.hpp
index 1cce16193fffc677a526f394a77a9d9e86cdf943..7f0e2eb2bbf07da0ca9238c8a8b9192e1881890c 100644
--- a/include/evalhyd/detail/probabilist/evaluator.hpp
+++ b/include/evalhyd/detail/probabilist/evaluator.hpp
@@ -4,13 +4,12 @@
 #include <stdexcept>
 #include <vector>
 
+#include <xtl/xoptional.hpp>
 #include <xtensor/xtensor.hpp>
 #include <xtensor/xview.hpp>
-#include <xtensor/xmasked_view.hpp>
-#include <xtensor/xslice.hpp>
-#include <xtensor/xoperation.hpp>
-#include <xtensor/xmath.hpp>
-#include <xtensor/xsort.hpp>
+
+#include "brier.hpp"
+#include "quantiles.hpp"
 
 
 namespace evalhyd
@@ -64,10 +63,104 @@ namespace evalhyd
             std::size_t n_exp;
 
             // members for computational elements
-            xt::xtensor<double, 2> o_k;
-            xt::xtensor<double, 3> bar_o;
-            xt::xtensor<double, 2> y_k;
-            xt::xtensor<double, 2> q_qnt;
+            xtl::xoptional<xt::xtensor<double, 2>, bool> o_k;
+            xtl::xoptional<xt::xtensor<double, 3>, bool> bar_o;
+            xtl::xoptional<xt::xtensor<double, 2>, bool> y_k;
+            xtl::xoptional<xt::xtensor<double, 2>, bool> q_qnt;
+
+            // members for intermediate evaluation metrics
+            // (i.e. before the reduction along the temporal axis)
+            xtl::xoptional<xt::xtensor<double, 2>, bool> bs;
+            xtl::xoptional<xt::xtensor<double, 2>, bool> qs;
+            xtl::xoptional<xt::xtensor<double, 2>, bool> crps;
+
+            // members for evaluation metrics
+            xtl::xoptional<xt::xtensor<double, 3>, bool> BS;
+            xtl::xoptional<xt::xtensor<double, 4>, bool> BS_CRD;
+            xtl::xoptional<xt::xtensor<double, 4>, bool> BS_LBD;
+            xtl::xoptional<xt::xtensor<double, 3>, bool> BSS;
+            xtl::xoptional<xt::xtensor<double, 3>, bool> QS;
+            xtl::xoptional<xt::xtensor<double, 2>, bool> CRPS;
+
+            // methods to compute elements
+            xt::xtensor<double, 2> get_o_k()
+            {
+                if (!o_k.has_value())
+                {
+                    o_k = elements::calc_o_k<view1d_xtensor2d_double_type>(
+                            q_obs, q_thr
+                    );
+                }
+                return o_k.value();
+            };
+
+            xt::xtensor<double, 3> get_bar_o()
+            {
+                if (!bar_o.has_value())
+                {
+                    bar_o = elements::calc_bar_o(
+                            get_o_k(), t_msk, b_exp, n_thr, n_msk, n_exp
+                    );
+                }
+                return bar_o.value();
+            };
+
+            xt::xtensor<double, 2> get_y_k()
+            {
+                if (!y_k.has_value())
+                {
+                    y_k = elements::calc_y_k<view2d_xtensor4d_double_type,
+                                             view1d_xtensor2d_double_type>(
+                            q_prd, q_thr, n_mbr
+                    );
+                }
+                return y_k.value();
+            };
+
+            xt::xtensor<double, 2> get_q_qnt()
+            {
+                if (!q_qnt.has_value())
+                {
+                    q_qnt = elements::calc_q_qnt<view2d_xtensor4d_double_type>(
+                            q_prd
+                    );
+                }
+                return q_qnt.value();
+            };
+
+            // methods to compute intermediate metrics
+            xt::xtensor<double, 2> get_bs()
+            {
+                if (!bs.has_value())
+                {
+                    bs = intermediate::calc_bs(
+                            get_o_k(), get_y_k()
+                    );
+                }
+                return bs.value();
+            };
+
+            xt::xtensor<double, 2> get_qs()
+            {
+                if (!qs.has_value())
+                {
+                    qs = intermediate::calc_qs<view1d_xtensor2d_double_type>(
+                            q_obs, get_q_qnt(), n_mbr
+                    );
+                }
+                return qs.value();
+            };;
+
+            xt::xtensor<double, 2> get_crps()
+            {
+                if (!crps.has_value())
+                {
+                    crps = intermediate::calc_crps(
+                            get_qs(), n_mbr
+                    );
+                }
+                return crps.value();
+            };
 
         public:
             // constructor method
@@ -109,694 +202,76 @@ namespace evalhyd
                 xt::view(t_msk, xt::all(), xt::keep(msk_nan)) = false;
             };
 
-            // members for intermediate evaluation metrics
-            // (i.e. before the reduction along the temporal axis)
-            xt::xtensor<double, 2> bs;
-            xt::xtensor<double, 2> qs;
-            xt::xtensor<double, 2> crps;
-
-            // members for evaluation metrics
-            xt::xtensor<double, 3> BS;
-            xt::xtensor<double, 4> BS_CRD;
-            xt::xtensor<double, 4> BS_LBD;
-            xt::xtensor<double, 3> BSS;
-            xt::xtensor<double, 3> QS;
-            xt::xtensor<double, 2> CRPS;
-
-            // methods to compute derived data
-            void calc_q_qnt();
-
-            // methods to compute elements
-            void calc_o_k();
-            void calc_bar_o();
-            void calc_y_k();
-
-            // methods to compute intermediate metrics
-            void calc_bs();
-            void calc_qs();
-            void calc_crps();
-
             // methods to compute metrics
-            void calc_BS();
-            void calc_BS_CRD();
-            void calc_BS_LBD();
-            void calc_BSS();
-            void calc_QS();
-            void calc_CRPS();
-        };
-
-        // --------------------------------------------------------------------
-        // ELEMENTS
-        // --------------------------------------------------------------------
-
-        // Determine observed realisation of threshold(s) exceedance.
-        //
-        // \require q_obs:
-        //     Streamflow observations.
-        //     shape: (time,)
-        // \require q_thr:
-        //     Streamflow exceedance threshold(s).
-        //     shape: (thresholds,)
-        // \assign o_k:
-        //     Event observed outcome.
-        //     shape: (thresholds, time)
-        template <class D2, class D4, class B4>
-        void Evaluator<D2, D4, B4>::calc_o_k()
-        {
-            // determine observed realisation of threshold(s) exceedance
-            o_k = q_obs >= xt::view(q_thr, xt::all(), xt::newaxis());
-        }
-
-        // Determine mean observed realisation of threshold(s) exceedance.
-        //
-        // \require o_k:
-        //     Event observed outcome.
-        //     shape: (thresholds, time)
-        // \require t_msk:
-        //     Temporal subsets of the whole record.
-        //     shape: (subsets, time)
-        // \assign bar_o:
-        //     Mean event observed outcome.
-        //     shape: (subsets, samples, thresholds)
-        template <class D2, class D4, class B4>
-        void Evaluator<D2, D4, B4>::calc_bar_o()
-        {
-            // apply the mask
-            // (using NaN workaround until reducers work on masked_view)
-            auto o_k_masked = xt::where(
-                    xt::view(t_msk, xt::all(), xt::newaxis(), xt::all()),
-                    o_k, NAN
-            );
-
-            // compute variable one sample at a time
-            bar_o = xt::zeros<double>({n_msk, n_exp, n_thr});
-
-            for (std::size_t e = 0; e < n_exp; e++)
-            {
-                // apply the bootstrap sampling
-                auto o_k_masked_sampled =
-                        xt::view(o_k_masked, xt::all(), xt::all(), b_exp[e]);
-
-                // compute mean "climatology" relative frequency of the event
-                // $\bar{o} = \frac{1}{n} \sum_{k=1}^{n} o_k$
-                xt::view(bar_o, xt::all(), e, xt::all()) =
-                        xt::nanmean(o_k_masked_sampled, -1);
-            }
-        }
-
-        // Determine forecast probability of threshold(s) exceedance to occur.
-        //
-        // \require q_prd:
-        //     Streamflow predictions.
-        //     shape: (members, time)
-        // \require q_thr:
-        //     Streamflow exceedance threshold(s).
-        //     shape: (thresholds,)
-        // \assign y_k:
-        //     Event probability forecast.
-        //     shape: (thresholds, time)
-        template <class D2, class D4, class B4>
-        void Evaluator<D2, D4, B4>::calc_y_k()
-        {
-            // determine if members have exceeded threshold(s)
-            auto e_frc =
-                    q_prd
-                    >= xt::view(q_thr, xt::all(), xt::newaxis(), xt::newaxis());
-
-            // calculate how many members have exceeded threshold(s)
-            auto n_frc = xt::sum(e_frc, 1);
-
-            // determine probability of threshold(s) exceedance
-            // /!\ probability calculation dividing by n (the number of
-            //     members), not n+1 (the number of ranks) like in other metrics
-            y_k = xt::cast<double>(n_frc) / n_mbr;
-        }
-
-        // Compute the forecast quantiles from the ensemble members.
-        //
-        // \require q_prd:
-        //     Streamflow predictions.
-        //     shape: (members, time)
-        // \assign q_qnt:
-        //     Streamflow forecast quantiles.
-        //     shape: (quantiles, time)
-        template <class D2, class D4, class B4>
-        void Evaluator<D2, D4, B4>::calc_q_qnt()
-        {
-            q_qnt = xt::sort(q_prd, 0);
-        }
-
-        // --------------------------------------------------------------------
-        // BRIER
-        // --------------------------------------------------------------------
-
-        // Compute the Brier score for each time step.
-        //
-        // \require o_k:
-        //     Observed event outcome.
-        //     shape: (thresholds, time)
-        // \require y_k:
-        //     Event probability forecast.
-        //     shape: (thresholds, time)
-        // \assign bs:
-        //     Brier score for each threshold for each time step.
-        //     shape: (thresholds, time)
-        template <class D2, class D4, class B4>
-        void Evaluator<D2, D4, B4>::calc_bs()
-        {
-            // return computed Brier score(s)
-            // $bs = (o_k - y_k)^2$
-            bs = xt::square(o_k - y_k);
-        }
-
-        // Compute the Brier score (BS).
-        //
-        // \require t_msk:
-        //     Temporal subsets of the whole record.
-        //     shape: (subsets, time)
-        // \require bs:
-        //     Brier score for each threshold for each time step.
-        //     shape: (thresholds, time)
-        // \assign BS:
-        //     Brier score for each subset and for each threshold.
-        //     shape: (subsets, samples, thresholds)
-        template <class D2, class D4, class B4>
-        void Evaluator<D2, D4, B4>::calc_BS()
-        {
-            // initialise output variable
-            // shape: (subsets, thresholds)
-            BS = xt::zeros<double>({n_msk, n_exp, n_thr});
-
-            // compute variable one mask at a time to minimise memory imprint
-            for (std::size_t m = 0; m < n_msk; m++)
+            xt::xtensor<double, 3> get_BS()
             {
-                // apply the mask
-                // (using NaN workaround until reducers work on masked_view)
-                auto bs_masked = xt::where(xt::row(t_msk, m), bs, NAN);
-
-                // compute variable one sample at a time
-                for (std::size_t e = 0; e < n_exp; e++)
+                if (!BS.has_value())
                 {
-                    // apply the bootstrap sampling
-                    auto bs_masked_sampled =
-                            xt::view(bs_masked, xt::all(), b_exp[e]);
-
-                    // calculate the mean over the time steps
-                    // $BS = \frac{1}{n} \sum_{k=1}^{n} (o_k - y_k)^2$
-                    xt::view(BS, m, e, xt::all()) =
-                            xt::nanmean(bs_masked_sampled, -1);
+                    BS = metrics::calc_BS<view1d_xtensor2d_double_type>(
+                            get_bs(), q_thr, t_msk, b_exp, n_thr, n_msk, n_exp
+                    );
                 }
-            }
-
-            // assign NaN where thresholds were not provided (i.e. set as NaN)
-            xt::masked_view(
-                    BS,
-                    xt::isnan(xt::view(q_thr, xt::newaxis(), xt::newaxis(), xt::all()))
-            ) = NAN;
-        }
-
-        // Compute the calibration-refinement decomposition of the Brier score
-        // into reliability, resolution, and uncertainty.
-        //
-        // BS = reliability - resolution + uncertainty
-        //
-        // \require q_thr:
-        //     Streamflow exceedance threshold(s).
-        //     shape: (thresholds,)
-        // \require t_msk:
-        //     Temporal subsets of the whole record.
-        //     shape: (subsets, time)
-        // \require o_k:
-        //     Observed event outcome.
-        //     shape: (thresholds, time)
-        // \require y_k:
-        //     Event probability forecast.
-        //     shape: (thresholds, time)
-        // \require bar_o:
-        //     Mean event observed outcome.
-        //     shape: (subsets, thresholds)
-        // \assign BS_CRD:
-        //     Brier score components (reliability, resolution, uncertainty)
-        //     for each subset and for each threshold.
-        //     shape: (subsets, samples, thresholds, components)
-        template <class D2, class D4, class B4>
-        void Evaluator<D2, D4, B4>::calc_BS_CRD()
-        {
-            // declare internal variables
-            // shape: (bins, thresholds, time)
-            xt::xtensor<double, 3> msk_bins;
-            // shape: (bins, thresholds)
-            xt::xtensor<double, 2> N_i, bar_o_i;
-            // shape: (bins,)
-            xt::xtensor<double, 1> y_i;
-
-            // compute range of forecast values $y_i$
-            y_i = xt::arange<double>(double(n_mbr + 1)) / n_mbr;
-
-            // initialise output variable
-            // shape: (subsets, thresholds, components)
-            BS_CRD = xt::zeros<double>({n_msk, n_exp, n_thr, std::size_t {3}});
-
-            // compute variable one mask at a time to minimise memory imprint
-            for (std::size_t m = 0; m < n_msk; m++)
-            {
-                // apply the mask
-                // (using NaN workaround until reducers work on masked_view)
-                auto o_k_masked = xt::where(xt::row(t_msk, m), o_k, NAN);
-                auto y_k_masked = xt::where(xt::row(t_msk, m), y_k, NAN);
+                return BS.value();
+            };
 
-                // compute variable one sample at a time
-                for (std::size_t e = 0; e < n_exp; e++)
+            xt::xtensor<double, 4> get_BS_CRD()
+            {
+                if (!BS_CRD.has_value())
                 {
-                    // apply the bootstrap sampling
-                    auto o_k_masked_sampled =
-                            xt::view(o_k_masked, xt::all(), b_exp[e]);
-                    auto y_k_masked_sampled =
-                            xt::view(y_k_masked, xt::all(), b_exp[e]);
-                    auto t_msk_sampled =
-                            xt::view(xt::row(t_msk, m), b_exp[e]);
-                    auto bar_o_sampled =
-                            xt::view(bar_o, xt::all(), e, xt::all());
-
-                    // calculate length of subset
-                    auto l = xt::sum(t_msk_sampled);
-
-                    // compute mask to subsample time steps belonging to same forecast bin
-                    // (where bins are defined as the range of forecast values)
-                    msk_bins = xt::equal(
-                            // force evaluation to avoid segfault
-                            xt::eval(y_k_masked_sampled),
-                            xt::view(y_i, xt::all(), xt::newaxis(), xt::newaxis())
-                    );
-
-                    // compute number of forecasts in each forecast bin $N_i$
-                    N_i = xt::nansum(msk_bins, -1);
-
-                    // compute subsample relative frequency
-                    // $\bar{o_i} = \frac{1}{N_i} \sum_{k \in N_i} o_k$
-                    bar_o_i = xt::where(
-                            N_i > 0,
-                            xt::nansum(
-                                    xt::where(
-                                            msk_bins,
-                                            xt::view(o_k_masked_sampled, xt::newaxis(),
-                                                     xt::all(), xt::all()),
-                                            0.
-                                    ),
-                                    -1
-                            ) / N_i,
-                            0.
+                    BS_CRD = metrics::calc_BS_CRD<view1d_xtensor2d_double_type>(
+                            q_thr, get_o_k(), get_y_k(), get_bar_o(), t_msk,
+                            b_exp, n_thr, n_mbr, n_msk, n_exp
                     );
-
-                    // calculate reliability =
-                    // $\frac{1}{n} \sum_{i=1}^{I} N_i (y_i - \bar{o_i})^2$
-                    xt::view(BS_CRD, m, e, xt::all(), 0) =
-                            xt::nansum(
-                                    xt::square(
-                                            xt::view(y_i, xt::all(), xt::newaxis())
-                                            - bar_o_i
-                                    ) * N_i,
-                                    0
-                            ) / l;
-
-                    // calculate resolution =
-                    // $\frac{1}{n} \sum_{i=1}^{I} N_i (\bar{o_i} - \bar{o})^2$
-                    xt::view(BS_CRD, m, e, xt::all(), 1) =
-                            xt::nansum(
-                                    xt::square(
-                                            bar_o_i - xt::view(bar_o_sampled, m)
-                                    ) * N_i,
-                                    0
-                            ) / l;
-
-                    // calculate uncertainty = $\bar{o} (1 - \bar{o})$
-                    xt::view(BS_CRD, m, e, xt::all(), 2) =
-                            xt::view(bar_o_sampled, m)
-                            * (1 - xt::view(bar_o_sampled, m));
                 }
-            }
-
-            // assign NaN where thresholds were not provided (i.e. set as NaN)
-            xt::masked_view(
-                    BS_CRD,
-                    xt::isnan(xt::view(q_thr, xt::newaxis(), xt::newaxis(),
-                                       xt::all(), xt::newaxis()))
-            ) = NAN;
-        }
-
-        // Compute the likelihood-base rate decomposition of the Brier score
-        // into type 2 bias, discrimination, and sharpness (a.k.a. refinement).
-        //
-        // BS = type 2 bias - discrimination + sharpness
-        //
-        // \require t_msk:
-        //     Temporal subsets of the whole record.
-        //     shape: (subsets, time)
-        // \require o_k:
-        //     Observed event outcome.
-        //     shape: (thresholds, time)
-        // \require y_k:
-        //     Event probability forecast.
-        //     shape: (thresholds, time)
-        // \return BS_LBD:
-        //     Brier score components (type 2 bias, discrimination, sharpness)
-        //     for each subset and for each threshold.
-        //     shape: (subsets, samples, thresholds, components)
-        template <class D2, class D4, class B4>
-        void Evaluator<D2, D4, B4>::calc_BS_LBD()
-        {
-            // declare internal variables
-            // shape: (bins, thresholds, time)
-            xt::xtensor<double, 3> msk_bins;
-            // shape: (thresholds,)
-            xt::xtensor<double, 1> bar_y;
-            // shape: (bins, thresholds)
-            xt::xtensor<double, 2> M_j, bar_y_j;
-            // shape: (bins,)
-            xt::xtensor<double, 1> o_j;
-
-            // set the range of observed values $o_j$
-            o_j = {0., 1.};
-
-            // declare and initialise output variable
-            // shape: (subsets, thresholds, components)
-            BS_LBD = xt::zeros<double>({n_msk, n_exp, n_thr, std::size_t {3}});
-
-            // compute variable one mask at a time to minimise memory imprint
-            for (std::size_t m = 0; m < n_msk; m++)
-            {
-                // apply the mask
-                // (using NaN workaround until reducers work on masked_view)
-                auto o_k_masked = xt::where(xt::row(t_msk, m), o_k, NAN);
-                auto y_k_masked = xt::where(xt::row(t_msk, m), y_k, NAN);
+                return BS_CRD.value();
+            };
 
-                // compute variable one sample at a time
-                for (std::size_t e = 0; e < n_exp; e++)
+            xt::xtensor<double, 4> get_BS_LBD()
+            {
+                if (!BS_LBD.has_value())
                 {
-                    // apply the bootstrap sampling
-                    auto o_k_masked_sampled =
-                            xt::view(o_k_masked, xt::all(), b_exp[e]);
-                    auto y_k_masked_sampled =
-                            xt::view(y_k_masked, xt::all(), b_exp[e]);
-                    auto t_msk_sampled =
-                            xt::view(t_msk, xt::all(), b_exp[e]);
-
-                    // calculate length of subset
-                    auto l = xt::sum(xt::row(t_msk_sampled, m));
-
-                    // compute mask to subsample time steps belonging to same observation bin
-                    // (where bins are defined as the range of forecast values)
-                    msk_bins = xt::equal(
-                            // force evaluation to avoid segfault
-                            xt::eval(o_k_masked_sampled),
-                            xt::view(o_j, xt::all(), xt::newaxis(), xt::newaxis())
-                    );
-
-                    // compute number of observations in each observation bin $M_j$
-                    M_j = xt::nansum(msk_bins, -1);
-
-                    // compute subsample relative frequency
-                    // $\bar{y_j} = \frac{1}{M_j} \sum_{k \in M_j} y_k$
-                    bar_y_j = xt::where(
-                            M_j > 0,
-                            xt::nansum(
-                                    xt::where(
-                                            msk_bins,
-                                            xt::view(
-                                                    y_k_masked_sampled,
-                                                    xt::newaxis(), xt::all(), xt::all()
-                                            ),
-                                            0.
-                                    ),
-                                    -1
-                            ) / M_j,
-                            0.
+                    BS_LBD = metrics::calc_BS_LBD<view1d_xtensor2d_double_type>(
+                            q_thr, get_o_k(), get_y_k(), t_msk,
+                            b_exp, n_thr, n_msk, n_exp
                     );
-
-                    // compute mean "climatology" forecast probability
-                    // $\bar{y} = \frac{1}{n} \sum_{k=1}^{n} y_k$
-                    bar_y = xt::nanmean(y_k_masked_sampled, -1);
-
-                    // calculate type 2 bias =
-                    // $\frac{1}{n} \sum_{j=1}^{J} M_j (o_j - \bar{y_j})^2$
-                    xt::view(BS_LBD, m, e, xt::all(), 0) =
-                            xt::nansum(
-                                    xt::square(
-                                            xt::view(o_j, xt::all(), xt::newaxis())
-                                            - bar_y_j
-                                    ) * M_j,
-                                    0
-                            ) / l;
-
-                    // calculate discrimination =
-                    // $\frac{1}{n} \sum_{j=1}^{J} M_j (\bar{y_j} - \bar{y})^2$
-                    xt::view(BS_LBD, m, e, xt::all(), 1) =
-                            xt::nansum(
-                                    xt::square(
-                                            bar_y_j - bar_y
-                                    ) * M_j,
-                                    0
-                            ) / l;
-
-                    // calculate sharpness =
-                    // $\frac{1}{n} \sum_{k=1}^{n} (\bar{y_k} - \bar{y})^2$
-                    xt::view(BS_LBD, m, e, xt::all(), 2) =
-                            xt::nansum(
-                                    xt::square(
-                                            y_k_masked_sampled
-                                            - xt::view(bar_y, xt::all(), xt::newaxis())
-                                    ),
-                                    -1
-                            ) / l;
                 }
+                return BS_LBD.value();
+            };
 
-            }
-
-            // assign NaN where thresholds were not provided (i.e. set as NaN)
-            xt::masked_view(
-                    BS_LBD,
-                    xt::isnan(xt::view(q_thr, xt::newaxis(), xt::newaxis(),
-                                       xt::all(), xt::newaxis()))
-            ) = NAN;
-        }
-
-        // Compute the Brier skill score (BSS).
-        //
-        // \require t_msk:
-        //     Temporal subsets of the whole record.
-        //     shape: (subsets, time)
-        // \require o_k:
-        //     Observed event outcome.
-        //     shape: (thresholds, time)
-        // \require bar_o:
-        //     Mean event observed outcome.
-        //     shape: (subsets, thresholds)
-        // \require bs:
-        //     Brier scores for each time step.
-        //     shape: (thresholds, time)
-        // \assign BSS:
-        //     Brier skill score for each subset and for each threshold.
-        //     shape: (subsets, samples, thresholds)
-        template <class D2, class D4, class B4>
-        void Evaluator<D2, D4, B4>::calc_BSS()
-        {
-            // declare and initialise output variable
-            // shape: (subsets, thresholds)
-            BSS = xt::zeros<double>({n_msk, n_exp, n_thr});
-
-            // compute variable one mask at a time to minimise memory imprint
-            for (std::size_t m = 0; m < n_msk; m++)
+            xt::xtensor<double, 3> get_BSS()
             {
-                // apply the mask
-                // (using NaN workaround until reducers work on masked_view)
-                auto o_k_masked = xt::where(xt::row(t_msk, m), o_k, NAN);
-                auto bs_masked = xt::where(xt::row(t_msk, m), bs, NAN);
-
-                // compute variable one sample at a time
-                for (std::size_t e = 0; e < n_exp; e++)
+                if (!BSS.has_value())
                 {
-                    // apply the bootstrap sampling
-                    auto o_k_masked_sampled =
-                            xt::view(o_k_masked, xt::all(), b_exp[e]);
-                    auto bs_masked_sampled =
-                            xt::view(bs_masked, xt::all(), b_exp[e]);
-                    auto bar_o_sampled =
-                            xt::view(bar_o, xt::all(), e, xt::all());
-
-                    // calculate reference Brier score(s)
-                    // $bs_{ref} = \frac{1}{n} \sum_{k=1}^{n} (o_k - \bar{o})^2$
-                    xt::xtensor<double, 2> bs_ref =
-                            xt::nanmean(
-                                    xt::square(
-                                            o_k_masked_sampled -
-                                            xt::view(
-                                                    xt::view(bar_o_sampled, m),
-                                                    xt::all(), xt::newaxis()
-                                            )
-                                    ),
-                                    -1, xt::keep_dims
-                            );
-
-                    // compute Brier skill score(s)
-                    // $BSS = \frac{1}{n} \sum_{k=1}^{n} 1 - \frac{bs}{bs_{ref}}
-                    xt::view(BSS, m, e, xt::all()) =
-                            xt::nanmean(
-                                    xt::where(
-                                            xt::equal(bs_ref, 0),
-                                            0, 1 - (bs_masked_sampled / bs_ref)
-                                    ),
-                                    -1
-                            );
+                    BSS = metrics::calc_BSS<view1d_xtensor2d_double_type>(
+                            get_bs(), q_thr, get_o_k(), get_bar_o(), t_msk,
+                            b_exp, n_thr, n_msk, n_exp
+                    );
                 }
-            }
-
-            // assign NaN where thresholds were not provided (i.e. set as NaN)
-            xt::masked_view(
-                    BSS,
-                    xt::isnan(xt::view(q_thr, xt::newaxis(), xt::newaxis(),
-                                       xt::all()))
-            ) = NAN;
-        }
-
-        // --------------------------------------------------------------------
-        // QUANTILES
-        // --------------------------------------------------------------------
-
-        // Compute the quantile scores for each time step.
-        //
-        // \require q_obs:
-        //     Streamflow observations.
-        //     shape: (time,)
-        // \require q_qnt:
-        //     Streamflow quantiles.
-        //     shape: (quantiles, time)
-        // \assign qs:
-        //     Quantile scores for each time step.
-        //     shape: (quantiles, time)
-        template <class D2, class D4, class B4>
-        void Evaluator<D2, D4, B4>::calc_qs()
-        {
-            // compute the quantile order $alpha$
-            xt::xtensor<double, 1> alpha =
-                    xt::arange<double>(1., double(n_mbr + 1))
-                    / double(n_mbr + 1);
-
-            // calculate the difference
-            xt::xtensor<double, 2> diff = q_qnt - q_obs;
-
-            // calculate the quantile scores
-            qs = xt::where(
-                    diff > 0,
-                    2 * (1 - xt::view(alpha, xt::all(), xt::newaxis())) * diff,
-                    - 2 * xt::view(alpha, xt::all(), xt::newaxis()) * diff
-            );
-        }
-
-        // Compute the quantile score (QS).
-        //
-        // \require t_msk:
-        //     Temporal subsets of the whole record.
-        //     shape: (subsets, time)
-        // \require qs:
-        //     Quantile scores for each time step.
-        //     shape: (quantiles, time)
-        // \assign QS:
-        //     Quantile scores.
-        //     shape: (subsets, quantiles)
-        template <class D2, class D4, class B4>
-        void Evaluator<D2, D4, B4>::calc_QS()
-        {
-            // initialise output variable
-            // shape: (subsets, quantiles)
-            QS = xt::zeros<double>({n_msk, n_exp, n_mbr});
+                return BSS.value();
+            };
 
-            // compute variable one mask at a time to minimise memory imprint
-            for (std::size_t m = 0; m < n_msk; m++)
+            xt::xtensor<double, 3> get_QS()
             {
-                // apply the mask
-                // (using NaN workaround until reducers work on masked_view)
-                auto qs_masked = xt::where(xt::row(t_msk, m), qs, NAN);
-
-                // compute variable one sample at a time
-                for (std::size_t e = 0; e < n_exp; e++)
+                if (!QS.has_value())
                 {
-                    // apply the bootstrap sampling
-                    auto qs_masked_sampled =
-                            xt::view(qs_masked, xt::all(), b_exp[e]);
-
-                    // calculate the mean over the time steps
-                    // $QS = \frac{1}{n} \sum_{k=1}^{n} qs$
-                    xt::view(QS, m, e, xt::all()) =
-                            xt::nanmean(qs_masked_sampled, -1);
+                    QS = metrics::calc_QS(
+                            get_qs(), t_msk, b_exp, n_mbr, n_msk, n_exp
+                    );
                 }
-            }
-        }
-
-        // Compute the continuous rank probability score(s) based
-        // on quantile scores for each time step, and integrating using the
-        // trapezoidal rule.
-        //
-        // /!\ The number of quantiles must be sufficiently large so that the
-        //     cumulative distribution is smooth enough for the numerical
-        //     integration to be accurate.
-        //
-        // \require qs:
-        //     Quantile scores for each time step.
-        //     shape: (quantiles, time)
-        // \assign crps:
-        //     CRPS for each time step.
-        //     shape: (1, time)
-        template <class D2, class D4, class B4>
-        void Evaluator<D2, D4, B4>::calc_crps()
-        {
-            // integrate with trapezoidal rule
-            crps = xt::view(
-                    // xt::trapz(y, dx=1/(n+1), axis=0)
-                    xt::trapz(qs, 1./(double(n_mbr) + 1.), 0),
-                    xt::newaxis(), xt::all()
-            );
-        }
-
-        // Compute the continuous rank probability score (CRPS) based
-        // on quantile scores.
-        //
-        // \require t_msk:
-        //     Temporal subsets of the whole record.
-        //     shape: (subsets, time)
-        // \require crps:
-        //     CRPS for each time step.
-        //     shape: (1, time)
-        // \assign CRPS:
-        //     CRPS.
-        //     shape: (subsets,)
-        template <class D2, class D4, class B4>
-        void Evaluator<D2, D4, B4>::calc_CRPS()
-        {
-            // initialise output variable
-            // shape: (subsets,)
-            CRPS = xt::zeros<double>({n_msk, n_exp});
+                return QS.value();
+            };
 
-            // compute variable one mask at a time to minimise memory imprint
-            for (std::size_t m = 0; m < n_msk; m++)
+            xt::xtensor<double, 2> get_CRPS()
             {
-                // apply the mask
-                // (using NaN workaround until reducers work on masked_view)
-                auto crps_masked = xt::where(xt::row(t_msk, m), crps, NAN);
-
-                // compute variable one sample at a time
-                for (std::size_t e = 0; e < n_exp; e++)
+                if (!CRPS.has_value())
                 {
-                    // apply the bootstrap sampling
-                    auto crps_masked_sampled =
-                            xt::view(crps_masked, xt::all(), b_exp[e]);
-
-                    // calculate the mean over the time steps
-                    // $CRPS = \frac{1}{n} \sum_{k=1}^{n} crps$
-                    xt::view(CRPS, m, e) =
-                            xt::squeeze(xt::nanmean(crps_masked_sampled, -1));
+                    CRPS = metrics::calc_CRPS(
+                            get_crps(), t_msk, b_exp, n_msk, n_exp
+                    );
                 }
-            }
-        }
+                return CRPS.value();
+            };
+        };
     }
 }
 
diff --git a/include/evalhyd/detail/probabilist/quantiles.hpp b/include/evalhyd/detail/probabilist/quantiles.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..c05273d8ef8d408537484254ffa900e8a57fa353
--- /dev/null
+++ b/include/evalhyd/detail/probabilist/quantiles.hpp
@@ -0,0 +1,216 @@
+#ifndef EVALHYD_PROBABILIST_QUANTILES_HPP
+#define EVALHYD_PROBABILIST_QUANTILES_HPP
+
+#include <xtensor/xtensor.hpp>
+#include <xtensor/xview.hpp>
+#include <xtensor/xsort.hpp>
+
+
+namespace evalhyd
+{
+    namespace probabilist
+    {
+        namespace elements
+        {
+            // Compute the forecast quantiles from the ensemble members.
+            //
+            // \param q_prd
+            //     Streamflow predictions.
+            //     shape: (members, time)
+            // \return
+            //     Streamflow forecast quantiles.
+            //     shape: (quantiles, time)
+            template <class V2D4>
+            inline xt::xtensor<double, 2> calc_q_qnt(
+                    const V2D4& q_prd
+            )
+            {
+                return xt::sort(q_prd, 0);
+            }
+        }
+
+        namespace intermediate
+        {
+            // Compute the quantile scores for each time step.
+            //
+            // \param q_obs
+            //     Streamflow observations.
+            //     shape: (time,)
+            // \param q_qnt
+            //     Streamflow quantiles.
+            //     shape: (quantiles, time)
+            // \return
+            //     Quantile scores for each time step.
+            //     shape: (quantiles, time)
+            template <class V1D2>
+            inline xt::xtensor<double, 2> calc_qs(
+                    const V1D2 &q_obs,
+                    const xt::xtensor<double, 2>& q_qnt,
+                    std::size_t n_mbr
+            )
+            {
+                // compute the quantile order $alpha$
+                xt::xtensor<double, 1> alpha =
+                        xt::arange<double>(1., double(n_mbr + 1))
+                        / double(n_mbr + 1);
+
+                // calculate the difference
+                xt::xtensor<double, 2> diff = q_qnt - q_obs;
+
+                // calculate the quantile scores
+                xt::xtensor<double, 2> qs = xt::where(
+                        diff > 0,
+                        2 * (1 - xt::view(alpha, xt::all(), xt::newaxis())) * diff,
+                        - 2 * xt::view(alpha, xt::all(), xt::newaxis()) * diff
+                );
+
+                return qs;
+            }
+
+            // Compute the continuous rank probability score(s) based
+            // on quantile scores for each time step, and integrating using the
+            // trapezoidal rule.
+            //
+            // /!\ The number of quantiles must be sufficiently large so that the
+            //     cumulative distribution is smooth enough for the numerical
+            //     integration to be accurate.
+            //
+            // \param qs
+            //     Quantile scores for each time step.
+            //     shape: (quantiles, time)
+            // \return
+            //     CRPS for each time step.
+            //     shape: (1, time)
+            inline xt::xtensor<double, 2> calc_crps(
+                    const xt::xtensor<double, 2>& qs,
+                    std::size_t n_mbr
+            )
+            {
+                // integrate with trapezoidal rule
+                return xt::view(
+                        // xt::trapz(y, dx=1/(n+1), axis=0)
+                        xt::trapz(qs, 1./(double(n_mbr) + 1.), 0),
+                        xt::newaxis(), xt::all()
+                );
+            }
+        }
+
+        namespace metrics
+        {
+            // Compute the quantile score (QS).
+            //
+            // \param qs
+            //     Quantile scores for each time step.
+            //     shape: (quantiles, time)
+            // \param t_msk
+            //     Temporal subsets of the whole record.
+            //     shape: (subsets, time)
+            // \param b_exp
+            //     Boostrap samples.
+            //     shape: (samples, time slice)
+            // \param n_mbr
+            //     Number of ensemble members.
+            // \param n_msk
+            //     Number of temporal subsets.
+            // \param n_exp
+            //     Number of bootstrap samples.
+            // \return
+            //     Quantile scores.
+            //     shape: (subsets, samples, quantiles)
+            inline xt::xtensor<double, 3> calc_QS(
+                    const xt::xtensor<double, 2>& qs,
+                    const xt::xtensor<bool, 2>& t_msk,
+                    const std::vector<xt::xkeep_slice<int>>& b_exp,
+                    std::size_t n_mbr,
+                    std::size_t n_msk,
+                    std::size_t n_exp
+            )
+            {
+                // initialise output variable
+                // shape: (subsets, quantiles)
+                xt::xtensor<double, 3> QS =
+                        xt::zeros<double>({n_msk, n_exp, n_mbr});
+
+                // compute variable one mask at a time to minimise memory imprint
+                for (std::size_t m = 0; m < n_msk; m++)
+                {
+                    // apply the mask
+                    // (using NaN workaround until reducers work on masked_view)
+                    auto qs_masked = xt::where(xt::row(t_msk, m), qs, NAN);
+
+                    // compute variable one sample at a time
+                    for (std::size_t e = 0; e < n_exp; e++)
+                    {
+                        // apply the bootstrap sampling
+                        auto qs_masked_sampled =
+                                xt::view(qs_masked, xt::all(), b_exp[e]);
+
+                        // calculate the mean over the time steps
+                        // $QS = \frac{1}{n} \sum_{k=1}^{n} qs$
+                        xt::view(QS, m, e, xt::all()) =
+                                xt::nanmean(qs_masked_sampled, -1);
+                    }
+                }
+
+                return QS;
+            }
+
+            // Compute the continuous rank probability score (CRPS) based
+            // on quantile scores.
+            //
+            // \param crps
+            //     CRPS for each time step.
+            //     shape: (1, time)
+            // \param t_msk
+            //     Temporal subsets of the whole record.
+            //     shape: (subsets, time)
+            // \param b_exp
+            //     Boostrap samples.
+            //     shape: (samples, time slice)
+            // \param n_msk
+            //     Number of temporal subsets.
+            // \param n_exp
+            //     Number of bootstrap samples.
+            // \return
+            //     CRPS.
+            //     shape: (subsets, samples)
+            inline xt::xtensor<double, 2> calc_CRPS(
+                    const xt::xtensor<double, 2>& crps,
+                    const xt::xtensor<bool, 2>& t_msk,
+                    const std::vector<xt::xkeep_slice<int>>& b_exp,
+                    std::size_t n_msk,
+                    std::size_t n_exp
+            )
+            {
+                // initialise output variable
+                // shape: (subsets,)
+                xt::xtensor<double, 2> CRPS = xt::zeros<double>({n_msk, n_exp});
+
+                // compute variable one mask at a time to minimise memory imprint
+                for (std::size_t m = 0; m < n_msk; m++)
+                {
+                    // apply the mask
+                    // (using NaN workaround until reducers work on masked_view)
+                    auto crps_masked = xt::where(xt::row(t_msk, m), crps, NAN);
+
+                    // compute variable one sample at a time
+                    for (std::size_t e = 0; e < n_exp; e++)
+                    {
+                        // apply the bootstrap sampling
+                        auto crps_masked_sampled =
+                                xt::view(crps_masked, xt::all(), b_exp[e]);
+
+                        // calculate the mean over the time steps
+                        // $CRPS = \frac{1}{n} \sum_{k=1}^{n} crps$
+                        xt::view(CRPS, m, e) =
+                                xt::squeeze(xt::nanmean(crps_masked_sampled, -1));
+                    }
+                }
+
+                return CRPS;
+            }
+        }
+    }
+}
+
+#endif //EVALHYD_PROBABILIST_QUANTILES_HPP
diff --git a/include/evalhyd/evalp.hpp b/include/evalhyd/evalp.hpp
index c41277ae5d25ce7a2dc876a3eefe80a58bef5425..350b923f70fb59a47cf2056e411e351ad8c69a7c 100644
--- a/include/evalhyd/evalp.hpp
+++ b/include/evalhyd/evalp.hpp
@@ -228,7 +228,6 @@ namespace evalhyd
             }
         }
 
-
         // retrieve dimensions
         std::size_t n_sit = q_prd_.shape(0);
         std::size_t n_ltm = q_prd_.shape(1);
@@ -250,29 +249,6 @@ namespace evalhyd
         dim["QS"] = {n_sit, n_ltm, n_msk, n_exp, n_mbr};
         dim["CRPS"] = {n_sit, n_ltm, n_msk, n_exp};
 
-        // declare maps for memoisation purposes
-        std::unordered_map<std::string, std::vector<std::string>> elt;
-        std::unordered_map<std::string, std::vector<std::string>> dep;
-
-        // register potentially recurring computation elements across metrics
-        elt["bs"] = {"o_k", "y_k"};
-        elt["BSS"] = {"o_k", "bar_o"};
-        elt["BS_CRD"] = {"o_k", "bar_o", "y_k"};
-        elt["BS_LBD"] = {"o_k", "y_k"};
-        elt["qs"] = {"q_qnt"};
-
-        // register nested metrics (i.e. metric dependent on another metric)
-        dep["BS"] = {"bs"};
-        dep["BSS"] = {"bs"};
-        dep["QS"] = {"qs"};
-        dep["CRPS"] = {"qs", "crps"};
-
-        // determine required elt/dep to be pre-computed
-        std::vector<std::string> req_elt;
-        std::vector<std::string> req_dep;
-
-        utils::find_requirements(metrics, elt, dep, req_elt, req_dep);
-
         // generate masks from conditions if provided
         auto gen_msk = [&]()
         {
@@ -353,44 +329,6 @@ namespace evalhyd
                         q_obs_v, q_prd_v, q_thr_v, t_msk_v, b_exp
                 );
 
-                // pre-compute required elt
-                for (const auto& element : req_elt)
-                {
-                    if ( element == "o_k" )
-                    {
-                        evaluator.calc_o_k();
-                    }
-                    else if ( element == "bar_o" )
-                    {
-                        evaluator.calc_bar_o();
-                    }
-                    else if ( element == "y_k" )
-                    {
-                        evaluator.calc_y_k();
-                    }
-                    else if ( element == "q_qnt" )
-                    {
-                        evaluator.calc_q_qnt();
-                    }
-                }
-
-                // pre-compute required dep
-                for (const auto& dependency : req_dep)
-                {
-                    if ( dependency == "bs" )
-                    {
-                        evaluator.calc_bs();
-                    }
-                    else if ( dependency == "qs" )
-                    {
-                        evaluator.calc_qs();
-                    }
-                    else if ( dependency == "crps" )
-                    {
-                        evaluator.calc_crps();
-                    }
-                }
-
                 // retrieve or compute requested metrics
                 for (std::size_t m = 0; m < metrics.size(); m++)
                 {
@@ -398,76 +336,45 @@ namespace evalhyd
 
                     if ( metric == "BS" )
                     {
-                        if (std::find(req_dep.begin(), req_dep.end(), metric)
-                            == req_dep.end())
-                        {
-                            evaluator.calc_BS();
-                        }
                         // (sites, lead times, subsets, samples, thresholds)
                         xt::view(r[m], s, l, xt::all(), xt::all(), xt::all()) =
-                                uncertainty::summarise(evaluator.BS, summary);
+                                uncertainty::summarise(evaluator.get_BS(), summary);
                     }
                     else if ( metric == "BSS" )
                     {
-                        if (std::find(req_dep.begin(), req_dep.end(), metric)
-                            == req_dep.end())
-                        {
-                            evaluator.calc_BSS();
-                        }
                         // (sites, lead times, subsets, samples, thresholds)
                         xt::view(r[m], s, l, xt::all(), xt::all(), xt::all()) =
-                                uncertainty::summarise(evaluator.BSS, summary);
+                                uncertainty::summarise(evaluator.get_BSS(), summary);
                     }
                     else if ( metric == "BS_CRD" )
                     {
-                        if (std::find(req_dep.begin(), req_dep.end(), metric)
-                            == req_dep.end())
-                        {
-                            evaluator.calc_BS_CRD();
-                        }
                         // (sites, lead times, subsets, samples, thresholds, components)
                         xt::view(r[m], s, l, xt::all(), xt::all(), xt::all(), xt::all()) =
-                                uncertainty::summarise(evaluator.BS_CRD, summary);
+                                uncertainty::summarise(evaluator.get_BS_CRD(), summary);
                     }
                     else if ( metric == "BS_LBD" )
                     {
-                        if (std::find(req_dep.begin(), req_dep.end(), metric)
-                            == req_dep.end())
-                        {
-                            evaluator.calc_BS_LBD();
-                        }
-
                         // (sites, lead times, subsets, samples, thresholds, components)
                         xt::view(r[m], s, l, xt::all(), xt::all(), xt::all(), xt::all()) =
-                                uncertainty::summarise(evaluator.BS_LBD, summary);
+                                uncertainty::summarise(evaluator.get_BS_LBD(), summary);
                     }
                     else if ( metric == "QS" )
                     {
-                        if (std::find(req_dep.begin(), req_dep.end(), metric)
-                            == req_dep.end())
-                        {
-                            evaluator.calc_QS();
-                        }
                         // (sites, lead times, subsets, samples, quantiles)
                         xt::view(r[m], s, l, xt::all(), xt::all(), xt::all()) =
-                                uncertainty::summarise(evaluator.QS, summary);
+                                uncertainty::summarise(evaluator.get_QS(), summary);
                     }
                     else if ( metric == "CRPS" )
                     {
-                        if (std::find(req_dep.begin(), req_dep.end(), metric)
-                            == req_dep.end())
-                        {
-                            evaluator.calc_CRPS();
-                        }
                         // (sites, lead times, subsets, samples)
                         xt::view(r[m], s, l, xt::all(), xt::all()) =
-                                uncertainty::summarise(evaluator.CRPS, summary);
+                                uncertainty::summarise(evaluator.get_CRPS(), summary);
                     }
                 }
             }
         }
         return r;
-    };
+    }
 }
 
 #endif //EVALHYD_EVALP_HPP