diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 0f60d44d..9b328919 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -4,7 +4,7 @@ Changelog master ------ - +- (`#87 `_) Added aggregated read of parameter - (`#86 `_) Made top level of region explicit, rather than allowing access via ``()`` and made requests robust to string inputs - (`#92 `_) Updated installation to remove notebook dependencies from minimum requirements as discussed in `#90 `_ - (`#85 `_) Split out submodule for ScmDataFrameBase ``openscm.scmdataframebase`` to avoid circular imports diff --git a/openscm/errors.py b/openscm/errors.py index 01e1467a..6fd7668e 100644 --- a/openscm/errors.py +++ b/openscm/errors.py @@ -13,6 +13,12 @@ class ParameterReadonlyError(ParameterError): """ +class ParameterEmptyError(ParameterError): + """ + Exception raised when trying to read when a parameter's value hasn't been set + """ + + class ParameterTypeError(ParameterError): """ Exception raised when a parameter is of a different type than diff --git a/openscm/parameter_views.py b/openscm/parameter_views.py index d1a16bc7..fb534a82 100644 --- a/openscm/parameter_views.py +++ b/openscm/parameter_views.py @@ -1,7 +1,10 @@ from typing import Sequence + + from .parameters import _Parameter from .timeframes import Timeframe, TimeframeConverter from .units import UnitConverter +from .errors import ParameterEmptyError class ParameterView: @@ -36,6 +39,9 @@ class ScalarView(ParameterView): Read-only view of a scalar parameter. """ + _child_data_views: Sequence["ScalarView"] + """List of views to the child parameters for aggregated reads""" + _unit_converter: UnitConverter """Unit converter""" @@ -50,13 +56,45 @@ def __init__(self, parameter: _Parameter, unit: str): unit Unit for the values in the view """ + + def get_data_views_for_children_or_parameter( + parameter: _Parameter + ) -> Sequence["ScalarView"]: + if parameter._children: + return sum( + ( + get_data_views_for_children_or_parameter(p) + for p in parameter._children.values() + ), + [], + ) + return [ScalarView(parameter, self._unit_converter._target)] + super().__init__(parameter) self._unit_converter = UnitConverter(parameter._info._unit, unit) + if self._parameter._children: + self._child_data_views = get_data_views_for_children_or_parameter( + self._parameter + ) def get(self) -> float: """ Get current value of scalar parameter. + + If the parameter has child parameters (i.e. ``_children`` is not empty), + the returned value will be the sum of the values of all of the child + parameters. + + Raises + ------ + ParameterEmptyError + Parameter is empty, i.e. has not yet been written to """ + if self._parameter._children: + return sum(v.get() for v in self._child_data_views) + elif self.is_empty: + raise ParameterEmptyError + return self._unit_converter.convert_from(self._parameter._data) @@ -82,6 +120,9 @@ class TimeseriesView(ParameterView): Read-only :class:`ParameterView` of a timeseries. """ + _child_data_views: Sequence["TimeseriesView"] + """List of views to the child parameters for aggregated reads""" + _timeframe_converter: TimeframeConverter """Timeframe converter""" @@ -101,16 +142,54 @@ def __init__(self, parameter: _Parameter, unit: str, timeframe: Timeframe): timeframe Timeframe """ + + def get_data_views_for_children_or_parameter( + parameter: _Parameter + ) -> Sequence["TimeseriesView"]: + if parameter._children: + return sum( + ( + get_data_views_for_children_or_parameter(p) + for p in parameter._children.values() + ), + [], + ) + return [ + TimeseriesView( + parameter, + self._unit_converter._target, + self._timeframe_converter._target, + ) + ] + super().__init__(parameter) self._unit_converter = UnitConverter(parameter._info._unit, unit) self._timeframe_converter = TimeframeConverter( parameter._info._timeframe, timeframe ) + if self._parameter._children: + self._child_data_views = get_data_views_for_children_or_parameter( + self._parameter + ) def get_series(self) -> Sequence[float]: """ Get values of the full timeseries. + + If the parameter has child parameters (i.e. ``_children`` is not empty), + the returned value will be the sum of the values of all of the child + parameters. + + Raises + ------ + ParameterEmptyError + Parameter is empty, i.e. has not yet been written to """ + if self._parameter._children: + return sum(v.get_series() for v in self._child_data_views) + elif self.is_empty: + raise ParameterEmptyError + return self._timeframe_converter.convert_from( self._unit_converter.convert_from(self._parameter._data) ) @@ -119,6 +198,8 @@ def get(self, index: int) -> float: """ Get value at a particular time. + TODO implement + Parameters ---------- index @@ -161,6 +242,8 @@ def set(self, value: float, index: int) -> None: """ Set value for a particular time in the time series. + TODO implement. + Parameters ---------- value diff --git a/openscm/parameters.py b/openscm/parameters.py index 97c49a33..1f0c814f 100644 --- a/openscm/parameters.py +++ b/openscm/parameters.py @@ -12,7 +12,8 @@ ) from .timeframes import Timeframe from .utils import ensure_input_is_tuple -from . import regions # needed for type annotations +from . import regions # needed for type annotations + class ParameterType(Enum): """ diff --git a/openscm/units.py b/openscm/units.py index 63c1e825..48b4e0c4 100644 --- a/openscm/units.py +++ b/openscm/units.py @@ -274,6 +274,12 @@ class UnitConverter: """ + _source: str + """Source unit""" + + _target: str + """Target unit""" + _offset: float """Offset for units (e.g. for temperature units)""" @@ -298,12 +304,18 @@ def __init__(self, source: str, target: str): UndefinedUnitError Unit undefined. """ + self._source = source + self._target = target + source_unit = unit_registry.Unit(source) target_unit = unit_registry.Unit(target) + s1 = unit_registry.Quantity(1, source_unit) s2 = unit_registry.Quantity(-1, source_unit) + t1 = s1.to(target_unit) t2 = s2.to(target_unit) + self._scaling = float(t2.m - t1.m) / float(s2.m - s1.m) self._offset = t1.m - self._scaling * s1.m diff --git a/openscm/utils.py b/openscm/utils.py index 886e625c..965c5742 100644 --- a/openscm/utils.py +++ b/openscm/utils.py @@ -6,7 +6,7 @@ def ensure_input_is_tuple(inp): if isinstance(inp, str): - if getattr(ensure_input_is_tuple, 'calls', 0) == 0: + if getattr(ensure_input_is_tuple, "calls", 0) == 0: ensure_input_is_tuple.calls = 1 warnings.warn("Converting input {} from string to tuple".format(inp)) return (inp,) diff --git a/tests/unit/test_core.py b/tests/unit/test_core.py index f3a39371..fb8b8c80 100644 --- a/tests/unit/test_core.py +++ b/tests/unit/test_core.py @@ -9,6 +9,7 @@ ParameterReadError, ParameterReadonlyError, ParameterTypeError, + ParameterEmptyError, ParameterWrittenError, RegionAggregatedError, ) @@ -164,7 +165,8 @@ def test_parameterset_named_initialisation(): def test_scalar_parameter_view(core): parameterset = core.parameters cs = parameterset.get_scalar_view(("Climate Sensitivity"), ("World",), "degC") - assert isnan(cs.get()) + with pytest.raises(ParameterEmptyError): + cs.get() assert cs.is_empty cs_writable = parameterset.get_writable_scalar_view( ("Climate Sensitivity"), "World", "degF" @@ -174,11 +176,54 @@ def test_scalar_parameter_view(core): assert not cs.is_empty np.testing.assert_allclose(cs.get(), 20) with pytest.raises(ParameterTypeError): - parameterset.get_timeseries_view(("Climate Sensitivity"), ("World",), "degC", 0, 1) + parameterset.get_timeseries_view( + ("Climate Sensitivity"), ("World",), "degC", 0, 1 + ) with pytest.raises(DimensionalityError): parameterset.get_scalar_view(("Climate Sensitivity"), ("World",), "kg") +def test_scalar_parameter_view_aggregation(core, start_time): + ta_1 = 0.6 + ta_2 = 0.3 + tb = 0.1 + + parameterset = core.parameters + + a_1_writable = parameterset.get_writable_scalar_view( + ("Top", "a", "1"), ("World"), "dimensionless" + ) + a_1_writable.set(ta_1) + + a_2_writable = parameterset.get_writable_scalar_view( + ("Top", "a", "2"), ("World"), "dimensionless" + ) + a_2_writable.set(ta_2) + + b_writable = parameterset.get_writable_scalar_view( + ("Top", "b"), ("World"), "dimensionless" + ) + b_writable.set(tb) + + a_1 = parameterset.get_scalar_view(("Top", "a", "1"), ("World"), "dimensionless") + np.testing.assert_allclose(a_1.get(), ta_1) + + a_2 = parameterset.get_scalar_view(("Top", "a", "2"), ("World"), "dimensionless") + np.testing.assert_allclose(a_2.get(), ta_2) + + a = parameterset.get_scalar_view(("Top", "a"), ("World"), "dimensionless") + np.testing.assert_allclose(a.get(), ta_1 + ta_2) + + b = parameterset.get_scalar_view(("Top", "b"), ("World"), "dimensionless") + np.testing.assert_allclose(b.get(), tb) + + with pytest.raises(ParameterReadonlyError): + parameterset.get_writable_scalar_view(("Top", "a"), ("World"), "dimensionless") + + total = parameterset.get_scalar_view(("Top"), ("World"), "dimensionless") + np.testing.assert_allclose(total.get(), ta_1 + ta_2 + tb) + + @pytest.fixture( params=[ (range(5 * 365), [0.24373829, 0.7325541, 1.22136991, 1.71018572, 2.19900153]), @@ -194,6 +239,10 @@ def test_timeseries_parameter_view(core, start_time, series): carbon = parameterset.get_timeseries_view( ("Emissions", "CO2"), ("World"), "GtCO2/a", start_time, 365 * 24 * 3600 ) + assert carbon.is_empty + with pytest.raises(ParameterEmptyError): + carbon.get_series() + carbon_writable = parameterset.get_writable_timeseries_view( ("Emissions", "CO2"), ("World"), "ktC/d", start_time, 24 * 3600 ) @@ -208,3 +257,87 @@ def test_timeseries_parameter_view(core, start_time, series): parameterset.get_scalar_view(("Emissions", "CO2"), ("World",), "GtCO2/a") with pytest.raises(DimensionalityError): parameterset.get_timeseries_view(("Emissions", "CO2"), ("World",), "kg", 0, 1) + + +def test_timeseries_parameter_view_aggregation(core, start_time): + fossil_industry_emms = np.array([0, 1, 2]) + fossil_energy_emms = np.array([2, 1, 4]) + land_emms = np.array([0.05, 0.1, 0.2]) + + parameterset = core.parameters + + fossil_industry_writable = parameterset.get_writable_timeseries_view( + ("Emissions", "CO2", "Fossil", "Industry"), + ("World"), + "GtC/yr", + start_time, + 24 * 3600, + ) + fossil_industry_writable.set_series(fossil_industry_emms) + + fossil_energy_writable = parameterset.get_writable_timeseries_view( + ("Emissions", "CO2", "Fossil", "Energy"), + ("World"), + "GtC/yr", + start_time, + 24 * 3600, + ) + fossil_energy_writable.set_series(fossil_energy_emms) + + land_writable = parameterset.get_writable_timeseries_view( + ("Emissions", "CO2", "Land"), ("World"), "MtC/yr", start_time, 24 * 3600 + ) + land_writable.set_series(land_emms * 1000) + + fossil_industry = parameterset.get_timeseries_view( + ("Emissions", "CO2", "Fossil", "Industry"), + ("World"), + "GtC/yr", + start_time, + 24 * 3600, + ) + np.testing.assert_allclose(fossil_industry.get_series(), fossil_industry_emms) + + fossil_energy = parameterset.get_timeseries_view( + ("Emissions", "CO2", "Fossil", "Energy"), + ("World"), + "GtC/yr", + start_time, + 24 * 3600, + ) + np.testing.assert_allclose(fossil_energy.get_series(), fossil_energy_emms) + + fossil = parameterset.get_timeseries_view( + ("Emissions", "CO2", "Fossil"), ("World"), "GtC/yr", start_time, 24 * 3600 + ) + np.testing.assert_allclose( + fossil.get_series(), fossil_industry_emms + fossil_energy_emms + ) + + # ensure that you can't write extra children once you've got a parent view, this + # avoids ever having the child views become out of date + with pytest.raises(ParameterReadError): + parameterset.get_writable_timeseries_view( + ("Emissions", "CO2", "Fossil", "Transport"), + ("World"), + "GtC/yr", + start_time, + 24 * 3600, + ) + + land = parameterset.get_timeseries_view( + ("Emissions", "CO2", "Land"), ("World"), "GtC/yr", start_time, 24 * 3600 + ) + np.testing.assert_allclose(land.get_series(), land_emms) + + with pytest.raises(ParameterReadonlyError): + parameterset.get_writable_timeseries_view( + ("Emissions", "CO2"), ("World"), "GtC/yr", start_time, 24 * 3600 + ) + + total = parameterset.get_timeseries_view( + ("Emissions", "CO2"), ("World"), "GtC/yr", start_time, 24 * 3600 + ) + np.testing.assert_allclose( + total.get_series(), land_emms + fossil_energy_emms + fossil_industry_emms + ) diff --git a/tests/unit/test_units.py b/tests/unit/test_units.py index ef9f95db..08fd24fb 100644 --- a/tests/unit/test_units.py +++ b/tests/unit/test_units.py @@ -93,6 +93,8 @@ def test_a(): def test_conversion_without_offset(): uc = UnitConverter("kg", "t") + assert uc._source == "kg" + assert uc._target == "t" np.testing.assert_allclose(uc.convert_from(1000), 1) np.testing.assert_allclose(uc.convert_to(1), 1000)