import abc
from importlib import import_module
import numpy as np
from os import path, sep
import cf
from cfunits import Units
from numbers import Number
from copy import deepcopy
import yaml

from ._utils.state import (
    State,
    create_states_dump,
    update_states_dump,
    load_states_dump,
)
from ._utils.record import (
    StateRecord,
    OutwardRecord,
    OutputRecord,
    RecordStream,
)
from .time import TimeDomain
from . import space
from .space import SpaceDomain, Grid
from .data import (
    DataSet,
    Variable,
    StaticVariable,
    ClimatologicVariable,
    DynamicVariable,
)
from .settings import dtype_float, array_order


class MetaComponent(abc.ABCMeta):
    """MetaComponent is a metaclass for `Component`."""

    # intrinsic attributes
    _category = None

    _inwards_info = None
    _outwards_info = None

    _inwards = None
    _outwards = None

    # definition attributes
    _inputs_info = None
    _parameters_info = None
    _constants_info = None
    _states_info = None
    _outputs_info = None

    _solver_history = None

    _requires_land_sea_mask = None
    _requires_flow_direction = None
    _requires_cell_area = None

    @property
    def category(cls):
        return cls._category

    @property
    def inwards_info(cls):
        return {
            k: deepcopy(v) for k, v in cls._inwards_info.items() if k in cls._inwards
        }

    @property
    def outwards_info(cls):
        return {
            k: deepcopy(v) for k, v in cls._outwards_info.items() if k in cls._outwards
        }

    @property
    def inwards_metadata(cls):
        """Return details on the component inward transfers as a `str`."""
        if cls.inwards_info:
            return yaml.dump(cls.inwards_info)

    @property
    def outwards_metadata(cls):
        """Return details on the component outward transfers as a `str`."""
        if cls.outwards_info:
            return yaml.dump(cls.outwards_info)

    @property
    def inputs_metadata(cls):
        """Return details on the component input data as a `str`."""
        if cls._inputs_info:
            return yaml.dump(cls._inputs_info)

    @property
    def parameters_metadata(cls):
        """Return details on the component parameters as a `str`."""
        if cls._parameters_info:
            return yaml.dump(cls._parameters_info)

    @property
    def constants_metadata(cls):
        """Return details on the component constants as a `str`."""
        if cls._constants_info:
            return yaml.dump(cls._constants_info)

    @property
    def states_metadata(cls):
        """Return details on the component states as a `str`."""
        if cls._states_info:
            return yaml.dump(cls._states_info)

    @property
    def outputs_metadata(cls):
        """Return details on the component outputs as a `str`."""
        if cls._outputs_info:
            return yaml.dump(cls._outputs_info)

    @property
    def solver_history(cls):
        return cls._solver_history

    def requires_flow_direction(cls):
        """Return `True` if flow direction must be available in the
        component spacedomain, otherwise return `False`."""
        return cls._requires_flow_direction

    def requires_land_sea_mask(cls):
        """Return `True` if land sea mask must be available in the
        component spacedomain, otherwise return `False`."""
        return cls._requires_land_sea_mask

    def requires_cell_area(cls):
        """Return `True` if cell area must be available in the
        component spacedomain, otherwise return `False`."""
        return cls._requires_cell_area

    def __str__(cls):
        """**Examples:**

        >>> from tests.components.surfacelayer import Dummy
        >>> print(Dummy)
        Dummy(
            category: surfacelayer
            inwards metadata:
                transfer_k [1]
                transfer_l [1]
                transfer_n [1]
                transfer_h [1]
            inputs metadata:
                driving_a [1]
                driving_b [1]
                driving_c [1]
                ancillary_c [1]
            requires land sea mask: True
            requires flow direction: True
            requires cell area: False
            states metadata:
                state_a [1]
                state_b [1]
            outwards metadata:
                transfer_i [1]
                transfer_j [1]
            outputs metadata:
                output_x [1]
                output_y [1]
        )
        """
        info_a = [
            "\n".join(
                (
                    [f"    {t.replace('_info', ' metadata').replace('_', '')}:"]
                    + [
                        f"        {n} [{info['units']}]"
                        for n, info in getattr(cls, t).items()
                    ]
                )
            )
            for t in ["inwards_info", "_inputs_info"]
            if getattr(cls, t)
        ]

        info_b = [
            "\n".join(
                (
                    [f"    {t.replace('_info', ' metadata').replace('_', '')}:"]
                    + [
                        f"        {n} [{info['units']}]"
                        for n, info in getattr(cls, t).items()
                    ]
                )
            )
            for t in [
                "_parameters_info",
                "_constants_info",
                "_states_info",
                "outwards_info",
                "_outputs_info",
            ]
            if getattr(cls, t)
        ]

        return "\n".join(
            [f"{cls.__name__}("]
            + [f"    category: {getattr(cls, '_category')}"]
            + info_a
            + [
                f"    requires land sea mask: "
                f"{getattr(cls, '_requires_land_sea_mask')}"
            ]
            + [
                f"    requires flow direction: "
                f"{getattr(cls, '_requires_flow_direction')}"
            ]
            + [f"    requires cell area: " f"{getattr(cls, '_requires_cell_area')}"]
            + info_b
            + [")"]
        )


class Component(metaclass=MetaComponent):
    # intrinsic attributes (set to default)
    _category = ""

    _inwards_info = {}
    _outwards_info = {}

    _inwards = {}
    _outwards = {}

    # definition attributes (set to default)
    _inputs_info = {}
    _parameters_info = {}
    _constants_info = {}
    _states_info = {}
    _outputs_info = {}

    _solver_history = 1

    _requires_land_sea_mask = False
    _requires_flow_direction = False
    _requires_cell_area = False

    def __init__(
        self,
        saving_directory,
        timedomain,
        spacedomain,
        dataset=None,
        parameters=None,
        constants=None,
        records=None,
        io_slice=None,
    ):
        """**Instantiation**

        :Parameters:

            saving_directory: `str`
                The path to the directory where to save the component
                dump and record files.

            timedomain: `TimeDomain` object
                The temporal dimension of the `Component`.

            spacedomain: `SpaceDomain` object
                The spatial dimension of the `Component`.

            dataset: `DataSet` object, optional
                The collection of input data required by the `Component`
                (i.e. 'dynamic' and/or 'static' and/or 'climatologic').
                The input data must always be compatible in space with
                *spacedomain*, and compatible in time with *timedomain*
                for the 'dynamic' kind, and with the 'frequency' for
                the 'climatologic' kind (see :ref:`Tab. 2<tab_frequencies>`
                for details).

            parameters: `dict`, optional
                The parameter values for the `Component`. Each key must
                correspond to a parameter name, and each value can be
                a `cf.Field` (with units), a `cf.Data` (with units),
                or a sequence of 2 items data and units (in this order).

                If it is a `cf.Field`, it must contains data for all
                spatial elements in the region covered by the component's
                spacedomain. If it is a `cf.Data` or sequence of 2 items,
                underlying data can be a scalar or an array (of the same
                shape as the component's spacedomain).

                *Parameter example:* ::

                    parameters={
                        'parameter_a': (120, 'm s-1')
                    }

                *Parameter example:* ::

                    parameters={
                        'parameter_a': cf.Data(120, 'm s-1')
                    }

                *Parameter example:* ::

                    parameters={
                        'parameter_a': ([130, 110, 120], 'm s-1')
                    }

                *Parameter example:* ::

                    parameters={
                        'parameter_a': (numpy.array([130, 110, 120]), 'm s-1')
                    }

                *Parameter example:* ::

                    parameters={
                        'parameter_a': cf.Data([130, 110, 120], 'm s-1')
                    }

            constants: `dict`, optional
                The constant values for the `Component` for which
                adjustment is desired. Each key must correspond to a
                constant name, and each value must either be a `cf.Data`
                (with units, where data must be a scalar) or a sequence
                of 2 items data and units (in this order, where data
                must be a scalar).

                *Parameter example:* ::

                    constants={
                        'constant_a': (1000, 'kg m-3')
                    }

                *Parameter example:* ::

                    constants={
                        'constant_a': cf.Data(1000, 'kg m-3')
                    }

            records: `dict`, optional
                The desired records from the `Component`. Each key
                must be a valid variable recordable for the component
                chosen (i.e. outwards, outputs, and states), each value
                is a `dict` of recording temporal resolutions as
                `datetime.timedelta` for keys and aggregation methods
                as a sequence of `str` for values.

                *Parameter example:* ::

                    records={
                        'output_a': {
                            timedelta(days=1): ['sum'],
                            timedelta(weeks=1): ['min', 'max']
                        },
                        'output_b': {
                            timedelta(days=1): ['mean']
                        }
                    }

                The aggregation methods supported are listed in the
                table below.

                ===============  =======================================
                method           description
                ===============  =======================================
                ``'point'``      The instantaneous value at the given
                                 elapsed timedelta.

                ``'sum'``        The accumulation of the values during
                                 the given elapsed timedelta.

                ``'mean'``       The average of the values during the
                                 given elapsed timedelta.

                ``'min'``        The minimum amongst the values during
                                 the elapsed timedelta.

                ``'max'``        The maximum amongst the values during
                                 the elapsed timedelta.
                ===============  =======================================

                The recording temporal resolutions must be multiples
                of the component temporal resolution, and they must be
                divisors of the component simulation period.

            io_slice: `int`, optional
                The length of the time slice to use for input/output
                operations. This corresponds to the number of component
                timesteps to read/write at once. If not set, its default
                value is 100 (arbitrary).

        """
        # check class definition attributes
        self._check_definition()

        # space attributes
        self.spacedomain = spacedomain

        # data attributes
        # # dataset to keep whole data period pristine
        self.dataset = dataset
        # # dataset to subset whole data for given period
        self.datasubset = DataSet()

        # time attribute
        self._io_slice = 100 if io_slice is None else int(io_slice)
        self._timedelta_in_seconds = None
        self._current_datetime = None
        self._datetime_array = None
        self.timedomain = timedomain

        # parameters attribute
        self._pristine_parameters = None
        self.parameters = parameters

        # constants attribute
        self.constants = constants

        # records attributes
        self._record_objects = None
        self._record_streams = None
        self.records = records

        # states attribute
        self.states = {}

        # identifier
        self.identifier = None

        # directories and files
        self.saving_directory = saving_directory
        self.dump_file = None

        # special attribute to store information that can be communicated
        # between component methods (typically between `initialise` and `run`)
        self.shelf = {}

        # flag to check whether states / streams have been initialised
        self._initialised_states = False
        self._revived_streams = False

    @property
    def timedomain(self):
        """Return the temporal configuration of the Component as a
        `TimeDomain` object."""
        return self._timedomain

    @timedomain.setter
    def timedomain(self, timedomain):
        self._check_timedomain(timedomain)
        self._check_dataset_time(timedomain)
        self._timedomain = timedomain
        self._timedelta_in_seconds = timedomain.timedelta.total_seconds()
        self._current_datetime = timedomain.time.datetime_array[0]
        self._datetime_array = timedomain.time.datetime_array[:]

    @property
    def timedelta_in_seconds(self):
        """Return the number of seconds separating two consecutive
        timestamps in the temporal configuration of the Component
        as a `float`."""
        return self._timedelta_in_seconds

    @property
    def current_datetime(self):
        """Return the current datetime at any stage during the
        simulation as a datetime object."""
        return self._current_datetime

    @property
    def spacedomain(self):
        """Return the spatial configuration of the Component as a
        `SpaceDomain` object."""
        return self._spacedomain

    @spacedomain.setter
    def spacedomain(self, spacedomain):
        self._check_spacedomain(spacedomain)
        self._spaceshape = spacedomain.shape
        self._spacedomain = spacedomain

    @property
    def spaceshape(self):
        """Return the length of each dimension in the spatial
        configuration of the Component as a `tuple` of `int`."""
        return self._spaceshape

    @property
    def dataset(self):
        """Return the collection of variables forming the dataset for
        the Component as a `DataSet` object."""
        return self._dataset

    @dataset.setter
    def dataset(self, dataset):
        dataset = DataSet() if dataset is None else dataset
        self._check_dataset(dataset)
        self._check_dataset_space(dataset, self.spacedomain)
        self._dataset = dataset

    @property
    def parameters(self):
        """Return the collection of parameter values for the Component
        as a `dict`."""
        return self._parameters

    @parameters.setter
    def parameters(self, parameters):
        parameters = {} if parameters is None else parameters
        self._pristine_parameters = parameters
        self._parameters = self._check_parameters(parameters, self.spacedomain)

    @property
    def constants(self):
        """Return the collection of adjusted constant values for the
        Component as a `dict`. Potentially returning an empty dictionary
        if no default constant value is adjusted."""
        return self._constants

    @constants.setter
    def constants(self, constants):
        constants = {} if constants is None else constants
        self._constants = self._check_constants(constants)
        self._use_constants_to_replace_state_divisions()

    @property
    def records(self):
        """Return the collection of desired records to be saved for the
        Component as a `dict`. Potentially returning an empty dictionary
        if no record are desired."""
        return self._records

    @records.setter
    def records(self, records):
        records = {} if records is None else records
        records_ = {}
        for name, frequencies in records.items():
            # check type and eliminate duplicates in methods
            records_[name] = {}
            for delta, methods in frequencies.items():
                if isinstance(methods, (list, tuple, set)):
                    records_[name][delta] = set(methods)
                else:
                    raise TypeError(
                        f"recording methods for {name} at {delta} "
                        f"must be a sequence of strings"
                    )

        self._records = records_

        # create record objects, record stream objects
        self._record_objects = {}
        self._record_streams = {}

        for name, frequencies in self.records.items():
            # create instance of appropriate Record subclass
            if name in self._outputs_info:
                self._record_objects[name] = OutputRecord(
                    name, **self._outputs_info[name]
                )
            elif name in self._outwards_info:
                self._record_objects[name] = OutwardRecord(
                    name, **self._outwards_info[name]
                )
            elif name in self._states_info:
                self._record_objects[name] = StateRecord(
                    name, **self._states_info[name]
                )
            else:
                raise ValueError(f"{name} not available for {self._category} component")

            for delta, methods in frequencies.items():
                # instantiate RecordStream if none for given timedelta yet
                if delta not in self._record_streams:
                    self._record_streams[delta] = RecordStream(
                        delta, writing_slice=self._io_slice
                    )
                # hold reference to record object in stream
                self._record_streams[delta].add_record(
                    self._record_objects[name], methods
                )

    @property
    def initialised_states(self):
        """Return whether initial conditions for component states have
        already been set as a `bool`."""
        return self._initialised_states

    def _check_definition(self):
        # check inwards/outwards are relevant for the component category
        for lead in ["inwards", "outwards"]:
            info = {}
            def_attr = getattr(self, f"_{lead}")
            cls_attr = getattr(self, f"_{lead}_info")
            for name in def_attr:
                if name in cls_attr:
                    info[name] = cls_attr[name]
                else:
                    raise RuntimeError(
                        f"{lead[:-1]} {name} in component definition is "
                        f"not compatible with component category"
                    )
            setattr(self, f"_{lead}_info", info)

        # check for units
        for lead in ["inputs", "parameters", "constants", "outputs", "states"]:
            attr = getattr(self, f"_{lead}_info")
            if attr:
                for name, info in attr.items():
                    if "units" not in info:
                        raise RuntimeError(
                            f"units missing for {name} in {self._category} "
                            f"component definition"
                        )

        # check for presence of input kind, if not assume 'dynamic'
        if self._inputs_info:
            for name, info in self._inputs_info.items():
                if "kind" not in info:
                    # assume it is a 'standard' input, i.e. dynamic
                    # (this is also useful for a `DataComponent` which
                    #  will not have a 'kind' since inputs are inherited
                    #  from outwards of the component being substituted)
                    info["kind"] = "dynamic"
                else:
                    if info["kind"] not in [
                        "dynamic",
                        "static",
                        "climatologic",
                    ]:
                        raise ValueError(
                            f"invalid type for {name} in {self._category} "
                            f"component definition"
                        )
                    if info["kind"] == "climatologic":
                        if "frequency" not in info:
                            raise RuntimeError(
                                f"frequency missing for {name} in "
                                f"{self._category} component definition"
                            )
                        freq = info["frequency"]
                        if not isinstance(freq, int):
                            if isinstance(freq, str) and freq not in [
                                "seasonal",
                                "monthly",
                                "daily",
                            ]:
                                raise TypeError(
                                    f"invalid frequency for {name} in "
                                    f"{self._category} component definition"
                                )

        # check for presence of constant default_value
        if self._constants_info:
            for name, info in self._constants_info.items():
                if "default_value" not in info:
                    raise RuntimeError(
                        f"default_value missing for constant {name} in "
                        f"{self._category} component definition"
                    )

        # check for presence of state divisions, if not assume scalar
        if self._states_info:
            for name, info in self._states_info.items():
                d = info.get("divisions", None)

                if d is None:
                    # assign default value (i.e. state is a scalar)
                    d = [1]
                else:
                    if isinstance(d, (Number, str)):
                        # convert to list of one item
                        d = [d]
                    elif isinstance(d, (list, tuple)):
                        # make sure it is a mutable sequence
                        d = [*d]
                    else:
                        raise TypeError(
                            f"invalid divisions for {name} in "
                            f"{self._category} component definition"
                        )

                for v in d:
                    # check it has a valid type
                    if not isinstance(v, (Number, str)):
                        raise TypeError(
                            f"invalid divisions for {name} in "
                            f"{self._category} component definition"
                        )
                    # check that, if string used, constant exists
                    if isinstance(v, str):
                        if v not in self._constants_info:
                            raise ValueError(
                                f"no constant {v} to use for divisions of "
                                f"state {name} in {self._category} component "
                                f"definition"
                            )

                info["divisions"] = d

    def _check_timedomain(self, timedomain):
        """The purpose of this method is to check that the timedomain is
        of the right type.
        """
        if not isinstance(timedomain, TimeDomain):
            raise TypeError(
                f"not an instance of {TimeDomain.__name__} for {self._category}"
            )

    def _check_spacedomain(self, spacedomain):
        """The purpose of this method is to check that the spacedomain
        is of the right type and that special properties land_sea_mask,
        flow_direction, and cell_area are set if required by component.
        """
        if not isinstance(spacedomain, SpaceDomain):
            raise TypeError(
                f"not an instance of {SpaceDomain.__name__} for {self._category}"
            )

        if not isinstance(spacedomain, Grid):
            raise NotImplementedError(
                f"only {Grid.__name__} currently supported by framework "
                f"for spacedomain"
            )

        for extra in ["land_sea_mask", "flow_direction", "cell_area"]:
            if getattr(self, "_requires_{}".format(extra)):
                if getattr(spacedomain, extra) is None:
                    raise RuntimeError(
                        f"'{extra}' must be set in {SpaceDomain.__name__} of "
                        f"{self._category} component '{self.__class__.__name__}'"
                    )

    def _check_dataset(self, dataset):
        # checks for input data

        # check that the dataset is an instance of DataSet
        if not isinstance(dataset, DataSet):
            raise TypeError(
                f"object given for dataset argument of {self._category} "
                f"component '{self.__class__.__name__}' not of type "
                f"{DataSet.__name__}"
            )

        # check data units compatibility with component
        for data_name, data_info in self._inputs_info.items():
            # check that all input data are available in DataSet
            if data_name not in dataset:
                raise KeyError(
                    f"no data '{data_name}' available in {DataSet.__name__} "
                    f"for {self._category} component '{self.__class__.__name__}'"
                )
            # check that input data units are compliant with component units
            if hasattr(dataset[data_name].field, "units"):
                if not Units(data_info["units"]).equals(
                    Units(dataset[data_name].field.units)
                ):
                    raise ValueError(
                        f"units of variable '{data_name}' in {DataSet.__name__} "
                        f"not equal to units required by {self._category} "
                        f"component '{self.__class__.__name__}': "
                        f"{data_info['units']} required"
                    )
            else:
                raise AttributeError(
                    f"variable '{data_name}' in {DataSet.__name__} for "
                    f"{self._category} component missing 'units' attribute"
                )

    def _check_dataset_space(self, dataset, spacedomain):
        # check space compatibility for input data
        for data_name, data_unit in self._inputs_info.items():
            try:
                dataset[data_name] = Variable(
                    spacedomain.subset_and_compare(dataset[data_name].field),
                    dataset[data_name].filenames,
                )
            except RuntimeError:
                raise ValueError(
                    f"spacedomain of data '{data_name}' not compatible with "
                    f"spacedomain of {self._category} component "
                    f"'{self.__class__.__name__}'"
                )

    def _check_dataset_time(self, timedomain):
        # check time compatibility for 'dynamic' input data
        for data_name in self._inputs_info:
            error = ValueError(
                f"timedomain of data '{data_name}' not compatible with "
                f"timedomain of {self._category} component "
                f"'{self.__class__.__name__}'"
            )

            self.datasubset[data_name] = self._check_time(
                self.dataset[data_name],
                timedomain,
                self._inputs_info[data_name]["kind"],
                error,
                self._io_slice,
                frequency=self._inputs_info[data_name].get("frequency"),
            )

    @staticmethod
    def _check_time(variable, timedomain, kind, error, reading_slice, frequency=None):
        field = variable.field
        filenames = variable.filenames

        if kind == "dynamic":
            try:
                variable_subset = DynamicVariable(
                    timedomain.subset_and_compare(field),
                    filenames,
                    reading_slice,
                )
            except RuntimeError:
                raise error

        elif kind == "climatologic":
            lengths = {
                "seasonal": 4,  # DJF-MAM-JJA-SON
                "monthly": 12,  # January to December
                "day_of_year": 366,  # Jan 1st to Dec 31st (with Feb 29th)
            }
            if isinstance(frequency, str):
                length = lengths[frequency]
            else:  # isinstance(freq, int):
                length = int(frequency)

            # check that time dimension is of expected length
            if field.construct("time").size != length:
                raise error

            # copy reference for climatologic input data
            variable_subset = ClimatologicVariable(field, filenames)

        else:  # kind == 'static':
            # copy reference for static input data
            if field.has_construct("time"):
                if field.construct("time").size == 1:
                    variable_subset = StaticVariable(field.squeeze("time"), filenames)
                else:
                    raise error
            else:
                variable_subset = StaticVariable(field, filenames)

        return variable_subset

    def _check_parameters(self, parameters, spacedomain):
        """Check that parameter values are (convertible to) `cf.Data`
        with right units. Return dictionary with values in place of
        `cf.Data`.
        """
        parameters_ = {}

        if self._parameters_info:
            for name, info in self._parameters_info.items():
                # check presence of value for parameter
                if name not in parameters:
                    raise RuntimeError(f"value missing for parameter {name}")
                parameter = parameters[name]

                # check parameter type
                if isinstance(parameter, cf.Data):
                    pass
                elif isinstance(parameter, cf.Field):
                    try:
                        parameter = spacedomain.subset_and_compare(parameter)
                    except RuntimeError:
                        raise RuntimeError(f"parameter {name} spatially incompatible")
                    parameter = parameter.data
                elif isinstance(parameter, (tuple, list)) and len(parameter) == 2:
                    try:
                        parameter = cf.Data(*parameter)
                    except ValueError:
                        raise ValueError(f"parameter {name} not convertible to cf.Data")
                else:
                    raise TypeError(f"invalid type for parameter {name}")

                # check parameter units
                if not parameter.get_units(False):
                    raise ValueError(f"missing units for parameter {name}")
                if not parameter.Units.equals(
                    Units(self._parameters_info[name]["units"])
                ):
                    raise ValueError(f"invalid units for parameter {name}")

                # check parameter shape (and reshape if scalar-like)
                if parameter.shape == spacedomain.shape:
                    parameter = parameter.array
                else:
                    # check if parameter evaluate as a scalar
                    try:
                        p = parameter.array.item()
                    except ValueError:
                        raise ValueError(f"incompatible shape for parameter {name}")
                    # assign scalar to newly created array of spacedomain shape
                    parameter = np.zeros(spacedomain.shape, dtype_float())
                    parameter[:] = p

                # assign parameter value in place of cf.Data
                parameters_[name] = parameter

        return parameters_

    def _check_constants(self, constants):
        """If given, check that constant values are (convertible to)
        `cf.Data` with right units. If not given, use constant default
        value from component definition. Return dictionary with values
        in place of `cf.Data`.
        """
        constants_ = {}

        # check if constant value provided, otherwise use default_value
        if self._constants_info:
            for name, info in self._constants_info.items():
                if name not in constants:
                    constants_[name] = info["default_value"]
                else:
                    constant = constants[name]

                    # check parameter type
                    if isinstance(constant, cf.Data):
                        pass
                    elif isinstance(constant, (tuple, list)) and len(constant) == 2:
                        try:
                            constant = cf.Data(*constant)
                        except ValueError:
                            raise ValueError(
                                f"constant {name} not convertible to cf.Data"
                            )
                    else:
                        raise TypeError(f"invalid type for constant {name}")

                    # check parameter units
                    if not constant.get_units(False):
                        raise ValueError(f"missing units for constant {name}")
                    if not constant.Units.equals(
                        Units(self._constants_info[name]["units"])
                    ):
                        raise ValueError(f"invalid units for constant {name}")

                    # assign parameter value in place of cf.Data
                    try:
                        constants_[name] = constant.array.item()
                    except ValueError:
                        raise ValueError(f"constant {name} not a scalar")

        return constants_

    def _use_constants_to_replace_state_divisions(self):
        """Replace component state divisions if specified as a string
        by an integer using component constants.
        """
        for s in self._states_info:
            d = self._states_info[s]["divisions"]

            new_d = []
            for v in d:
                if v and isinstance(v, str):
                    # replace string with constant value, cast to integer
                    new_v = int(self.constants[v])
                else:
                    # keep existing value, cast to integer
                    new_v = int(v)

                if new_v <= 0:
                    # cannot have negative dimension for array, also rule out 0
                    raise ValueError(
                        f"{s} divisions dimension must be greater than zero"
                    )
                elif new_v > 1:
                    # do not add dimension if it is 1
                    new_d.append(new_v)

            self._states_info[s]["divisions"] = tuple(new_d)

    @property
    def category(self):
        """Return the part of the water/nutrients cycle the `Component` represents."""
        return self._category

    @property
    def inwards_info(self):
        """Return the incoming information expected by the `Component`."""
        return deepcopy(self._inwards_info)

    @property
    def outwards_info(self):
        """Return the outgoing information provided by the `Component`."""
        return deepcopy(self._outwards_info)

    @classmethod
    def from_config(cls, cfg):
        # get relevant spacedomain subclass
        spacedomain = getattr(space, cfg["spacedomain"]["class"])

        # convert parameters to cf.Field or cf.Data
        parameters = {}
        if cfg.get("parameters"):
            for name, info in cfg["parameters"].items():
                error = ValueError(f"invalid information in YAML for parameter {name}")
                if isinstance(info, (tuple, list)):
                    parameters[name] = cf.Data(*info)
                elif isinstance(info, dict):
                    if info.get("files") and info.get("select"):
                        parameters[name] = cf.read(info["files"]).select_field(
                            info["select"]
                        )
                    else:
                        raise error
                else:
                    raise error

        return cls(
            saving_directory=cfg["saving_directory"],
            timedomain=TimeDomain.from_config(cfg["timedomain"]),
            spacedomain=spacedomain.from_config(cfg["spacedomain"]),
            dataset=DataSet.from_config(cfg.get("dataset")),
            parameters=parameters,
            constants=cfg.get("constants"),
            records=cfg.get("records"),
            io_slice=cfg.get("io_slice", None),
        )

    def to_config(self):
        # get parameters and constants as tuple (value, unit)
        parameters = {}
        if self.parameters:
            for name, value in self.parameters.items():
                original = self._pristine_parameters[name]
                if isinstance(original, cf.Field):
                    if original.data.get_filenames():
                        # comes from a file, point to it
                        parameters[name] = {
                            "files": original.data.get_filenames(),
                            "select": original.identity(),
                        }
                    else:
                        # create a new file for it
                        filename = sep.join(
                            [
                                self.saving_directory,
                                "_".join([self.identifier, self._category, name])
                                + ".nc",
                            ]
                        )
                        cf.write(original, filename)
                        # point to it
                        parameters[name] = {
                            "files": [filename],
                            "select": original.identity(),
                        }
                else:
                    if np.amin(value) == np.amax(value):
                        # can be stored as a scalar
                        parameters[name] = (
                            np.mean(value).item(),
                            self._parameters_info[name]["units"],
                        )
                    else:
                        # must be stored as a cf.Field in new file
                        field = self.spacedomain.to_field()
                        field.data[:] = original
                        field.long_name = name
                        # create a new file for it
                        filename = sep.join(
                            [
                                self.saving_directory,
                                "_".join([self.identifier, self._category, name])
                                + ".nc",
                            ]
                        )
                        cf.write(original, filename)
                        # point to it
                        parameters[name] = {
                            "files": [filename],
                            "select": field.identity(),
                        }

        constants = {}
        if self.constants:
            for name, value in self.constants.items():
                constants[name] = (value, self._constants_info[name]["units"])

        cfg = {
            "module": self.__module__,
            "class": self.__class__.__name__,
            "saving_directory": self.saving_directory,
            "timedomain": self.timedomain.to_config(),
            "spacedomain": self.spacedomain.to_config(),
            "dataset": self.dataset.to_config(),
            "parameters": parameters if parameters else None,
            "constants": constants if constants else None,
            "records": self.records if self.records else None,
            "io_slice": self._io_slice,
        }
        return cfg

    def get_spin_up_timedomain(self, start, end):
        if ((end - start) % self.timedomain.timedelta).total_seconds() != 0:
            raise RuntimeError(
                f"spin up start-end incompatible with {self._category} "
                f"component timedelta"
            )

        timedomain = TimeDomain.from_start_end_step(
            start,
            end,
            self.timedomain.timedelta,
            self.timedomain.units,
            self.timedomain.calendar,
        )

        return timedomain

    def __str__(self):
        shape = ", ".join(
            [f"{ax}: {ln}" for ax, ln in zip(self.spacedomain.axes, self.spaceshape)]
        )

        records = (
            [
                f"        {o}: {d} {m}"
                for o, f in self.records.items()
                for d, m in f.items()
            ]
            if self.records
            else []
        )

        return "\n".join(
            [f"{self.__class__.__name__}("]
            + [f"    category: {self._category}"]
            + [f"    saving directory: {self.saving_directory}"]
            + [f"    timedomain: period: {self.timedomain.period}"]
            + [f"    spacedomain: shape: ({shape})"]
            + (["    records:"] if records else [])
            + records
            + [")"]
        )

    def initialise_(self, tag, overwrite):
        # if states not already initialised, instantiate them
        if not self._initialised_states:
            self._instantiate_states()

        # reset time for data slices
        # (because component may have already be run, the slices in
        #  the data would not point to the beginning of the array, and
        #  they need to for this new run)
        for d in self._inputs_info:
            self.datasubset[d].reset_time()

        # collect inputs for first time step (i.e. time index 0)
        inputs = {d: self.datasubset[d][0] for d in self._inputs_info}

        # reset time for data slices
        # (because of the input collection just above, the slices
        #  iterator in the data has been triggered, so it needs to be
        #  reset for the actual run to follow)
        for d in self._inputs_info:
            self.datasubset[d].reset_time()

        # initialise component
        self.initialise(**inputs, **self.parameters, **self.constants, **self.states)
        self._initialised_states = True

        # create dump file for given run
        self._initialise_states_dump(tag, overwrite)

        if self.records:
            if not self._revived_streams:
                self._initialise_record_streams()
            # need to reset flag to False because Component may be
            # re-used for another spin cycle / simulation run and
            # it needs for its streams to be properly re-initialised
            # (its trackers in particular)
            self._revived_streams = False
            # optionally create files and dump files
            self._create_stream_files_and_dumps(tag, overwrite)

    def run_(self, timeindex, exchanger):
        data = {}
        # collect required input data from dataset
        for d in self._inputs_info:
            data[d] = self.datasubset[d][timeindex]

        # determine current datetime in simulation
        self._current_datetime = self._datetime_array[timeindex]

        # collect required transfers from exchanger
        for d in self._inwards_info:
            data[d] = exchanger.get_transfer(d, self._category)

        # run simulation for the component
        to_exchanger, outputs = self.run(
            **self.parameters, **self.constants, **self.states, **data
        )

        # store variables to record
        for name in self._records:
            self._record_objects[name](self.states, to_exchanger, outputs)

        # increment the component's states by one timestep
        self.increment_states()

        return to_exchanger

    def finalise_(self):
        timestamp = self.timedomain.bounds.array[-1, -1]
        update_states_dump(
            sep.join([self.saving_directory, self.dump_file]),
            self.states,
            timestamp,
            self._solver_history,
        )
        self.finalise(**self.parameters, **self.constants, **self.states)

    def _instantiate_states(self):
        # get a State object for each state and initialise to zero
        for s in self._states_info:
            d = self._states_info[s].get("divisions")
            o = self._states_info[s].get("order", array_order())
            self.states[s] = State(
                np.zeros(
                    (self._solver_history + 1, *self.spaceshape, *d),
                    dtype_float(),
                    order=o,
                ),
                order=o,
            )

    def _initialise_states_dump(self, tag, overwrite):
        self.dump_file = "_".join(
            [self.identifier, self._category, tag, "dump_states.nc"]
        )
        if overwrite or not path.exists(
            sep.join([self.saving_directory, self.dump_file])
        ):
            create_states_dump(
                sep.join([self.saving_directory, self.dump_file]),
                self._states_info,
                self._solver_history,
                self.timedomain,
                self.spacedomain,
            )

    def initialise_states_from_dump(self, dump_file, at=None):
        """Initialise the states of the Component from a dump file.

        :Parameters:

            dump_file: `str`
                A string providing the path to the netCDF dump file
                containing values to be used as initial conditions for
                the states of the Component.

            at: datetime object, optional
                The snapshot in time to be used for the initial
                conditions. Must be any datetime type (except
                `numpy.datetime64`). Must be contained in the 'time'
                variable in *dump_file*. If not provided, the last index
                in the 'time' variable in *dump_file* will be used.

                Note: if a datetime is provided, there is no enforcement
                of the fact that this datetime must correspond to the
                initial datetime in the simulation period for the
                `Component`, and it is only used as a means to select
                a specific snapshot in time amongst the ones available
                in the dump file.

        :Returns:

            datetime object
                The snapshot in time that was used for the initial
                conditions.

        """
        states, at = load_states_dump(dump_file, at, self._states_info)
        for s in self._states_info:
            if s in states:
                o = self._states_info[s].get("order", array_order())
                self.states[s] = State(states[s], order=o)
            else:
                raise KeyError(
                    f"initial conditions for {self._category} "
                    f"component state '{s}' not in dump"
                )
        self._initialised_states = True

        return at

    def increment_states(self):
        for s in self.states:
            self.states[s].increment()

    def dump_states(self, timeindex):
        timestamp = self.timedomain.bounds.array[timeindex, 0]
        update_states_dump(
            sep.join([self.saving_directory, self.dump_file]),
            self.states,
            timestamp,
            self._solver_history,
        )

    def _initialise_record_streams(self):
        for delta, stream in self._record_streams.items():
            # (re)initialise record stream time attributes
            stream.initialise(self.timedomain, self.spacedomain)

    def _create_stream_files_and_dumps(self, tag, overwrite):
        for delta, stream in self._record_streams.items():
            # initialise record files
            filename = "_".join(
                [
                    self.identifier,
                    self._category,
                    tag,
                    "records",
                    stream.frequency_tag,
                ]
            )
            file_ = sep.join([self.saving_directory, filename + ".nc"])

            if overwrite or not path.exists(file_):
                stream.create_record_stream_file(file_)
            else:
                stream.file = file_

            # initialise stream dumps
            filename = "_".join(
                [
                    self.identifier,
                    self._category,
                    tag,
                    "dump_record_stream",
                    stream.frequency_tag,
                ]
            )
            file_ = sep.join([self.saving_directory, filename + ".nc"])

            if overwrite or not path.exists(file_):
                stream.create_record_stream_dump(file_)
            else:
                stream.dump_file = file_

    def revive_record_streams_from_dump(
        self, dump_file_pattern, timedomain=None, at=None
    ):
        """Revive the record streams of the Component from a dump file.

        :Parameters:

            dump_filepath_pattern: `str`
                A string providing the path to the netCDF dump file
                containing values to be used as initial conditions for
                the record streams of the Component. Note, curly
                brackets {} should be used where the record stream delta
                should be used.

                *Parameter example:* ::

                    dump_file_pattern='out/dummy_surfacelayer_run_'
                                      'dump_record_stream_{}.nc'

            timedomain: `TimeDomain`, optional
                This is required if the run to be revived is a spin-up
                one. Indeed, until the `spin_up` method is called, the
                `TimeDomain` of the `Component` is the one of its main
                run.

            at: datetime object, optional
                The snapshot in time to be used for the initial
                conditions. Must be any datetime type (except
                `numpy.datetime64`). Must be contained in the 'time'
                variable in dump file. If not provided, the last index
                in the 'time' variable in dump file will be used.

                Note: if a datetime is provided, there is no enforcement
                of the fact that this datetime must correspond to the
                initial datetime in the simulation period for the
                `Component`, and it is only used as a means to select
                a specific snapshot in time amongst the ones available
                in the dump file.

        :Returns:

            datetime object
                The snapshot in time that was used for reviving the
                record streams.

        """
        ats = []

        if self.records:
            for delta, stream in self._record_streams.items():
                file_ = dump_file_pattern.format(stream.frequency_tag)
                ats.append(
                    stream.load_record_stream_dump(
                        file_,
                        at,
                        timedomain or self.timedomain,
                        self.spacedomain,
                    )
                )
        self._revived_streams = True

        return ats

    def dump_record_streams(self, timeindex):
        timestamp = self.timedomain.bounds.array[timeindex, 0]
        if self.records:
            for delta, stream in self._record_streams.items():
                stream.update_record_stream_dump(timestamp)

    @abc.abstractmethod
    def initialise(self, **kwargs):
        raise NotImplementedError(
            f"{self._category} class '{self.__class__.__name__}' "
            f"missing an 'initialise' method"
        )

    @abc.abstractmethod
    def run(self, **kwargs):
        raise NotImplementedError(
            f"{self._category} class '{self.__class__.__name__}' "
            f"missing a 'run' method"
        )

    @abc.abstractmethod
    def finalise(self, **kwargs):
        raise NotImplementedError(
            f"{self._category} class '{self.__class__.__name__}' "
            f"missing a 'finalise' method"
        )


class SurfaceLayerComponent(Component, metaclass=abc.ABCMeta):
    """The SurfaceLayerComponent is simulating the hydrological
    processes in the surface layer compartment of the hydrological
    cycle.
    """

    _category = "surfacelayer"
    _inwards_info = {
        "soil_water_stress_for_transpiration": {
            "units": "1",
            "from": "subsurface",
            "method": "mean",
        },
        "soil_water_stress_for_direct_soil_evaporation": {
            "units": "1",
            "from": "subsurface",
            "method": "mean",
        },
        "standing_water_area_fraction": {
            "units": "1",
            "from": "subsurface",
            "method": "mean",
        },
        "total_water_area_fraction": {
            "units": "1",
            "from": "subsurface",
            "method": "mean",
        },
    }
    _outwards_info = {
        "canopy_liquid_throughfall_and_snow_melt_flux": {
            "units": "kg m-2 s-1",
            "to": ["subsurface"],
            "method": "mean",
        },
        "transpiration_flux_from_root_uptake": {
            "units": "kg m-2 s-1",
            "to": ["subsurface"],
            "method": "mean",
        },
        "direct_water_evaporation_flux_from_soil": {
            "units": "kg m-2 s-1",
            "to": ["subsurface"],
            "method": "mean",
        },
        "water_evaporation_flux_from_standing_water": {
            "units": "kg m-2 s-1",
            "to": ["subsurface"],
            "method": "mean",
        },
        "water_evaporation_flux_from_open_water": {
            "units": "kg m-2 s-1",
            "to": ["openwater"],
            "method": "mean",
        },
        "direct_throughfall_flux": {
            "units": "kg m-2 s-1",
            "to": ["openwater"],
            "method": "mean",
        },
    }
    # if not specified, assume all inwards are required
    _inwards = tuple(_inwards_info)
    # if not specified, assume all outwards are produced
    _outwards = tuple(_outwards_info)


class SubSurfaceComponent(Component, metaclass=abc.ABCMeta):
    """The SubSurfaceComponent is simulating the hydrological processes
    in the subsurface compartment of the hydrological cycle.
    """

    _category = "subsurface"
    _inwards_info = {
        "canopy_liquid_throughfall_and_snow_melt_flux": {
            "units": "kg m-2 s-1",
            "from": "surfacelayer",
            "method": "mean",
        },
        "transpiration_flux_from_root_uptake": {
            "units": "kg m-2 s-1",
            "from": "surfacelayer",
            "method": "mean",
        },
        "direct_water_evaporation_flux_from_soil": {
            "units": "kg m-2 s-1",
            "from": "surfacelayer",
            "method": "mean",
        },
        "water_evaporation_flux_from_standing_water": {
            "units": "kg m-2 s-1",
            "from": "surfacelayer",
            "method": "mean",
        },
        "open_water_area_fraction": {
            "units": "1",
            "from": "openwater",
            "method": "mean",
        },
        "open_water_surface_height": {
            "units": "m",
            "from": "openwater",
            "method": "mean",
        },
    }
    _outwards_info = {
        "soil_water_stress_for_transpiration": {
            "units": "1",
            "to": ["surfacelayer"],
            "method": "mean",
        },
        "soil_water_stress_for_direct_soil_evaporation": {
            "units": "1",
            "to": ["surfacelayer"],
            "method": "mean",
        },
        "standing_water_area_fraction": {
            "units": "1",
            "to": ["surfacelayer"],
            "method": "mean",
        },
        "total_water_area_fraction": {
            "units": "1",
            "to": ["surfacelayer"],
            "method": "mean",
        },
        "surface_runoff_flux_delivered_to_rivers": {
            "units": "kg m-2 s-1",
            "to": ["openwater"],
            "method": "mean",
        },
        "net_groundwater_flux_to_rivers": {
            "units": "kg m-2 s-1",
            "to": ["openwater"],
            "method": "mean",
        },
    }
    # if not specified, assume all inwards are required
    _inwards = tuple(_inwards_info)
    # if not specified, assume all outwards are produced
    _outwards = tuple(_outwards_info)


class OpenWaterComponent(Component, metaclass=abc.ABCMeta):
    """The OpenWaterComponent is simulating the hydrological processes
    in the open water compartment of the hydrological cycle.
    """

    _category = "openwater"
    _inwards_info = {
        "water_evaporation_flux_from_open_water": {
            "units": "kg m-2 s-1",
            "from": "surfacelayer",
            "method": "mean",
        },
        "direct_throughfall_flux": {
            "units": "kg m-2 s-1",
            "from": "surfacelayer",
            "method": "mean",
        },
        "surface_runoff_flux_delivered_to_rivers": {
            "units": "kg m-2 s-1",
            "from": "subsurface",
            "method": "mean",
        },
        "net_groundwater_flux_to_rivers": {
            "units": "kg m-2 s-1",
            "from": "subsurface",
            "method": "mean",
        },
    }
    _outwards_info = {
        "open_water_area_fraction": {
            "units": "1",
            "to": ["subsurface"],
            "method": "mean",
        },
        "open_water_surface_height": {
            "units": "m",
            "to": ["subsurface"],
            "method": "mean",
        },
        "water_volume_in_surface_routing_channels": {
            "units": "m3",
            "to": ["nutrientopenwater"],
            "method": "mean",
        },
        "water_volume_in_subsurface_routing_channels": {
            "units": "m3",
            "to": ["nutrientopenwater"],
            "method": "mean",
        },
        "outgoing_water_volume_transport_out_of_surface_cell": {
            "units": "m3 s-1",
            "to": ["nutrientopenwater"],
            "method": "mean",
        },
        "outgoing_water_volume_transport_out_of_subsurface_cell": {
            "units": "m3 s-1",
            "to": ["nutrientopenwater"],
            "method": "mean",
        },
        "upward_volume_transport_of_liquid_water_between_subsurface_and_surface": {
            "units": "m3 s-1",
            "to": ["nutrientopenwater"],
            "method": "mean",
        },   
        "return_flow_fraction": {
            "units": "1",
            "to": ["nutrientopenwater"],
            "method": "mean",
        },           
    }
    # if not specified, assume all inwards are required
    _inwards = tuple(_inwards_info)
    # if not specified, assume all outwards are produced
    _outwards = tuple(_outwards_info)


class NutrientSurfaceLayerComponent(Component, metaclass=abc.ABCMeta):
    """The NutrientSurfaceLayerComponent simulates surface nutrient processes"""

    _category = "nutrientsurfacelayer"
    _inwards_info = {}  # None
    _outwards_info = {
        "mass_flux_of_nitrogen_as_ammonium_from_atmosphere_to_surface_due_to_deposition": {
            "units": "kg m-2 s-1",
            "to": ["nutrientopenwater"],
            "method": "mean",
        },
        "mass_flux_of_nitrogen_as_nitrate_from_atmosphere_to_surface_due_to_deposition": {
            "units": "kg m-2 s-1",
            "to": ["nutrientopenwater"],
            "method": "mean",
        },
        "mass_flux_of_sulfur_as_sulfate_from_atmosphere_to_surface_due_to_deposition": {
            "units": "kg m-2 s-1",
            "to": ["nutrientopenwater"],
            "method": "mean",
        },
        "mass_concentration_of_carbon_dioxide_in_air": {
            "units": "kg m-3",
            "to": ["nutrientopenwater"],
            "method": "mean",
        },
    }

    # if not specified, assume all inwards are required
    _inwards = tuple(_inwards_info)
    # if not specified, assume all outwards are produced
    _outwards = tuple(_outwards_info)


class NutrientSubSurfaceComponent(Component, metaclass=abc.ABCMeta):
    """The NutrientSubSurfaceComponent simulates nutrient processes occuring
    in the subsurface
    """

    _category = "nutrientsubsurface"
    _inwards_info = {}  # None
    _outwards_info = {
        "mass_flux_of_dissolved_inorganic_carbon_from_soil_in_surface_runoff": {
            "units": "kg m-2 s-1",
            "to": ["nutrientopenwater"],
            "method": "mean",
        },
        "mass_flux_of_dissolved_organic_carbon_from_soil_in_surface_runoff": {
            "units": "kg m-2 s-1",
            "to": ["nutrientopenwater"],
            "method": "mean",
        },
        "mass_flux_of_dissolved_nitrogen_as_ammonium_from_soil_in_surface_runoff": {
            "units": "kg m-2 s-1",
            "to": ["nutrientopenwater"],
            "method": "mean",
        },
        "mass_flux_of_dissolved_nitrogen_as_nitrate_from_soil_in_surface_runoff": {
            "units": "kg m-2 s-1",
            "to": ["nutrientopenwater"],
            "method": "mean",
        },
        "mass_flux_of_dissolved_organic_nitrogen_from_soil_in_surface_runoff": {
            "units": "kg m-2 s-1",
            "to": ["nutrientopenwater"],
            "method": "mean",
        },
        "mass_flux_of_dissolved_phosphorus_from_soil_in_surface_runoff": {
            "units": "kg m-2 s-1",
            "to": ["nutrientopenwater"],
            "method": "mean",
        },
        "mass_flux_of_dissolved_calcium_from_soil_in_surface_runoff": {
            "units": "kg m-2 s-1",
            "to": ["nutrientopenwater"],
            "method": "mean",
        },
        "mass_flux_of_dissolved_sulfur_as_sulfate_from_soil_in_surface_runoff": {
            "units": "kg m-2 s-1",
            "to": ["nutrientopenwater"],
            "method": "mean",
        },
        "mass_flux_of_dissolved_silicon_from_soil_in_surface_runoff": {
            "units": "kg m-2 s-1",
            "to": ["nutrientopenwater"],
            "method": "mean",
        },
        "mass_flux_of_dissolved_inorganic_carbon_from_soil_in_subsurface_runoff": {
            "units": "kg m-2 s-1",
            "to": ["nutrientopenwater"],
            "method": "mean",
        },
        "mass_flux_of_dissolved_organic_carbon_from_soil_in_subsurface_runoff": {
            "units": "kg m-2 s-1",
            "to": ["nutrientopenwater"],
            "method": "mean",
        },
        "mass_flux_of_dissolved_nitrogen_as_ammonium_from_soil_in_subsurface_runoff": {
            "units": "kg m-2 s-1",
            "to": ["nutrientopenwater"],
            "method": "mean",
        },
        "mass_flux_of_dissolved_nitrogen_as_nitrate_from_soil_in_subsurface_runoff": {
            "units": "kg m-2 s-1",
            "to": ["nutrientopenwater"],
            "method": "mean",
        },
        "mass_flux_of_dissolved_organic_nitrogen_from_soil_in_subsurface_runoff": {
            "units": "kg m-2 s-1",
            "to": ["nutrientopenwater"],
            "method": "mean",
        },
        "mass_flux_of_dissolved_phosphorus_from_soil_in_subsurface_runoff": {
            "units": "kg m-2 s-1",
            "to": ["nutrientopenwater"],
            "method": "mean",
        },
        "mass_flux_of_dissolved_calcium_from_soil_in_subsurface_runoff": {
            "units": "kg m-2 s-1",
            "to": ["nutrientopenwater"],
            "method": "mean",
        },
        "mass_flux_of_dissolved_sulfur_as_sulfate_from_soil_in_subsurface_runoff": {
            "units": "kg m-2 s-1",
            "to": ["nutrientopenwater"],
            "method": "mean",
        },
        "mass_flux_of_dissolved_silicon_from_soil_in_subsurface_runoff": {
            "units": "kg m-2 s-1",
            "to": ["nutrientopenwater"],
            "method": "mean",
        },
    }

    # if not specified, assume all inwards are required
    _inwards = tuple(_inwards_info)
    # if not specified, assume all outwards are produced
    _outwards = tuple(_outwards_info)


class NutrientOpenWaterComponent(Component, metaclass=abc.ABCMeta):
    """The NutrientOpenWaterComponent simulates nutrient process occuring in
    open-water such as rivers.
    """

    _category = "nutrientopenwater"
    _inwards_info = {
        "mass_flux_of_dissolved_inorganic_carbon_from_soil_in_surface_runoff": {
            "units": "kg m-2 s-1",
            "from": "nutrientsubsurface",
            "method": "mean",
        },
        "mass_flux_of_dissolved_organic_carbon_from_soil_in_surface_runoff": {
            "units": "kg m-2 s-1",
            "from": "nutrientsubsurface",
            "method": "mean",
        },
        "mass_flux_of_dissolved_nitrogen_as_ammonium_from_soil_in_surface_runoff": {
            "units": "kg m-2 s-1",
            "from": "nutrientsubsurface",
            "method": "mean",
        },
        "mass_flux_of_dissolved_nitrogen_as_nitrate_from_soil_in_surface_runoff": {
            "units": "kg m-2 s-1",
            "from": "nutrientsubsurface",
            "method": "mean",
        },
        "mass_flux_of_dissolved_organic_nitrogen_from_soil_in_surface_runoff": {
            "units": "kg m-2 s-1",
            "from": "nutrientsubsurface",
            "method": "mean",
        },
        "mass_flux_of_dissolved_phosphorus_from_soil_in_surface_runoff": {
            "units": "kg m-2 s-1",
            "from": "nutrientsubsurface",
            "method": "mean",
        },
        "mass_flux_of_dissolved_calcium_from_soil_in_surface_runoff": {
            "units": "kg m-2 s-1",
            "from": "nutrientsubsurface",
            "method": "mean",
        },
        "mass_flux_of_dissolved_sulfur_as_sulfate_from_soil_in_surface_runoff": {
            "units": "kg m-2 s-1",
            "from": "nutrientsubsurface",
            "method": "mean",
        },
        "mass_flux_of_dissolved_silicon_from_soil_in_surface_runoff": {
            "units": "kg m-2 s-1",
            "from": "nutrientsubsurface",
            "method": "mean",
        },
        "mass_flux_of_dissolved_inorganic_carbon_from_soil_in_subsurface_runoff": {
            "units": "kg m-2 s-1",
            "from": "nutrientsubsurface",
            "method": "mean",
        },
        "mass_flux_of_dissolved_organic_carbon_from_soil_in_subsurface_runoff": {
            "units": "kg m-2 s-1",
            "from": "nutrientsubsurface",
            "method": "mean",
        },
        "mass_flux_of_dissolved_nitrogen_as_ammonium_from_soil_in_subsurface_runoff": {
            "units": "kg m-2 s-1",
            "from": "nutrientsubsurface",
            "method": "mean",
        },
        "mass_flux_of_dissolved_nitrogen_as_nitrate_from_soil_in_subsurface_runoff": {
            "units": "kg m-2 s-1",
            "from": "nutrientsubsurface",
            "method": "mean",
        },
        "mass_flux_of_dissolved_organic_nitrogen_from_soil_in_subsurface_runoff": {
            "units": "kg m-2 s-1",
            "from": "nutrientsubsurface",
            "method": "mean",
        },
        "mass_flux_of_dissolved_phosphorus_from_soil_in_subsurface_runoff": {
            "units": "kg m-2 s-1",
            "from": "nutrientsubsurface",
            "method": "mean",
        },
        "mass_flux_of_dissolved_calcium_from_soil_in_subsurface_runoff": {
            "units": "kg m-2 s-1",
            "from": "nutrientsubsurface",
            "method": "mean",
        },
        "mass_flux_of_dissolved_sulfur_as_sulfate_from_soil_in_subsurface_runoff": {
            "units": "kg m-2 s-1",
            "from": "nutrientsubsurface",
            "method": "mean",
        },
        "mass_flux_of_dissolved_silicon_from_soil_in_subsurface_runoff": {
            "units": "kg m-2 s-1",
            "from": "nutrientsubsurface",
            "method": "mean",
        },
        "mass_flux_of_nitrogen_as_ammonium_from_atmosphere_to_surface_due_to_deposition": {
            "units": "kg m-2 s-1",
            "from": "nutrientsurfacelayer",
            "method": "mean",
        },
        "mass_flux_of_nitrogen_as_nitrate_from_atmosphere_to_surface_due_to_deposition": {
            "units": "kg m-2 s-1",
            "from": "nutrientsurfacelayer",
            "method": "mean",
        },
        "mass_flux_of_sulfur_as_sulfate_from_atmosphere_to_surface_due_to_deposition": {
            "units": "kg m-2 s-1",
            "from": "nutrientsurfacelayer",
            "method": "mean",
        },
        "mass_concentration_of_carbon_dioxide_in_air": {
            "units": "kg m-3",
            "from": "nutrientsurfacelayer",
            "method": "mean",
        },
        "water_volume_in_surface_routing_channels": {
            "units": "m3",
            "from": "nutrientopenwater",
            "method": "mean",
        },
        "water_volume_in_subsurface_routing_channels": {
            "units": "m3",
            "from": "nutrientopenwater",
            "method": "mean",
        },
        "outgoing_water_volume_transport_out_of_surface_cell": {
            "units": "m3 s-1",
            "from": "nutrientopenwater",
            "method": "mean",
        },
        "outgoing_water_volume_transport_out_of_subsurface_cell": {
            "units": "m3 s-1",
            "from": "nutrientopenwater",
            "method": "mean",
        },      
        "upward_volume_transport_of_liquid_water_between_subsurface_and_surface": {
            "units": "m3 s-1",
            "from": "nutrientopenwater",
            "method": "mean",
        },   
        "return_flow_fraction": {
            "units": "1",
            "from": "nutrientopenwater",
            "method": "mean",
        },                   
    }
    _outwards_info = {}  # None

    # if not specified, assume all inwards are required
    _inwards = tuple(_inwards_info)
    # if not specified, assume all outwards are produced
    _outwards = tuple(_outwards_info)


class DataComponent(Component):
    """The DataComponent is a `Component` substituting simulations with
    data.

    Its intended use is to replace a compartment of the hydrological
    cycle with measurements or with previous simulation runs.
    """

    _category = "data"
    _inwards_info = {}
    _outwards_info = {}

    # definition attributes
    _solver_history = 0

    def __init__(
        self,
        timedomain,
        spacedomain,
        dataset,
        substituting_class,
        io_slice=None,
    ):
        """**Instantiation**

        :Parameters:

            timedomain: `TimeDomain` object
                The temporal dimension of the `Component`.

            spacedomain: `SpaceDomain` object
                The spatial dimension of the `Component`.

            dataset: `DataSet` object
                The dataset containing the substitute data substituting
                the `Component`\'s simulated time series. The data in
                dataset must be compatible in time with *timedomain* and
                compatible in space with *spacedomain*.

            substituting_class: `Component` object
                The subclass of `Component` that the DataComponent is
                substituting its simulated time series with the ones in
                *dataset*.

            io_slice: `int`, optional
                The length of the time slice to use for input/output
                operations. This corresponds to the number of component
                timesteps to read/write at once. If not set, its default
                value is 100 (arbitrary).

        """
        # store class being substituted for config
        self._substituting_class = substituting_class

        # override category to the one of substituting component
        self._category = substituting_class.category

        # override outwards with the ones of component being substituted
        self._outwards = set(substituting_class.outwards_info.keys())
        self._outwards_info = substituting_class.outwards_info

        # override inputs info with the outwards of component being
        # substituted (so that the dataset is checked for time and space
        # compatibility as a 'standard' dataset would be)
        self._inputs_info = substituting_class.outwards_info

        # initialise as a Component
        super(DataComponent, self).__init__(
            None, timedomain, spacedomain, dataset, io_slice=io_slice
        )

    def __str__(self):
        shape = ", ".join(
            [f"{ax}: {ln}" for ax, ln in zip(self.spacedomain.axes, self.spaceshape)]
        )
        return "\n".join(
            [f"{self.__class__.__name__}("]
            + [f"    category: {self._category}"]
            + [f"    timedomain: period: {self.timedomain.period}"]
            + [f"    spacedomain: shape: ({shape})"]
            + [f"    dataset: {len(self.dataset)} variable(s)"]
            + [")"]
        )

    @classmethod
    def from_config(cls, cfg):
        spacedomain = getattr(space, cfg["spacedomain"]["class"])
        substituting_class = getattr(
            import_module(cfg["substituting"]["module"]),
            cfg["substituting"]["class"],
        )
        return cls(
            timedomain=TimeDomain.from_config(cfg["timedomain"]),
            spacedomain=spacedomain.from_config(cfg["spacedomain"]),
            dataset=DataSet.from_config(cfg.get("dataset")),
            substituting_class=substituting_class,
            io_slice=cfg.get("io_slice", None),
        )

    def to_config(self):
        cfg = {
            "module": self.__module__,
            "class": self.__class__.__name__,
            "timedomain": self.timedomain.to_config(),
            "spacedomain": self.spacedomain.to_config(),
            "dataset": self.dataset.to_config(),
            "substituting": {
                "module": self._substituting_class.__module__,
                "class": self._substituting_class.__name__,
            },
            "io_slice": self._io_slice,
        }
        return cfg

    def initialise_(self, *args, **kwargs):
        # reset time for data slices
        for d in self._inputs_info:
            self.datasubset[d].reset_time()

    def dump_states(self, *args, **kwargs):
        pass

    def finalise_(self, *args, **kwargs):
        pass

    def initialise(self, *args, **kwargs):
        return {}

    def run(self, *args, **kwargs):
        return {n: kwargs[n] for n in self._outwards_info}, {}

    def finalise(self, *args, **kwargs):
        pass


class NullComponent(Component):
    """The NullComponent mimics a `Component` and returns time series of
    zeros.

    Its intended use is to ignore a compartment of the hydrological
    cycle.
    """

    _category = "null"
    _inwards_info = {}
    _outwards_info = {}

    # definition attributes
    _solver_history = 0

    def __init__(self, timedomain, spacedomain, substituting_class):
        """**Instantiation**

        :Parameters:

            timedomain: `TimeDomain` object
                The temporal dimension of the `Component`.

            spacedomain: `SpaceDomain` object
                The spatial dimension of the `Component`.

            substituting_class: `Component` object
                The subclass of `Component` that the NullComponent is
                substituting its simulated time series with time series
                of zeros.

        """
        # store class being substituted for config
        self._substituting_class = substituting_class

        # override category with the one of component being substituted
        self._category = substituting_class.category

        # override inwards and outwards with the ones of component
        # being substituted
        self._outwards = set(substituting_class.outwards_info.keys())
        self._outwards_info = substituting_class.outwards_info

        # initialise as a Component
        super(NullComponent, self).__init__(None, timedomain, spacedomain)

    def __str__(self):
        shape = ", ".join(
            [f"{ax}: {ln}" for ax, ln in zip(self.spacedomain.axes, self.spaceshape)]
        )
        return "\n".join(
            [f"{self.__class__.__name__}("]
            + [f"    category: {self._category}"]
            + [f"    timedomain: period: {self.timedomain.period}"]
            + [f"    spacedomain: shape: ({shape})"]
            + [")"]
        )

    @classmethod
    def from_config(cls, cfg):
        spacedomain = getattr(space, cfg["spacedomain"]["class"])
        substituting_class = getattr(
            import_module(cfg["substituting"]["module"]),
            cfg["substituting"]["class"],
        )
        return cls(
            timedomain=TimeDomain.from_config(cfg["timedomain"]),
            spacedomain=spacedomain.from_config(cfg["spacedomain"]),
            substituting_class=substituting_class,
        )

    def to_config(self):
        cfg = {
            "module": self.__module__,
            "class": self.__class__.__name__,
            "timedomain": self.timedomain.to_config(),
            "spacedomain": self.spacedomain.to_config(),
            "substituting": {
                "module": self._substituting_class.__module__,
                "class": self._substituting_class.__name__,
            },
        }
        return cfg

    def initialise_(self, *args, **kwargs):
        pass

    def dump_states(self, *args, **kwargs):
        pass

    def finalise_(self, *args, **kwargs):
        pass

    def initialise(self, *args, **kwargs):
        return {}

    def run(self, *args, **kwargs):
        null_array = np.zeros(self.spaceshape, np.float32)
        return {n: null_array for n in self._outwards_info}, {}

    def finalise(self, *args, **kwargs):
        pass
