diff --git a/CHANGELOG.md b/CHANGELOG.md index e7705fb..2f6cc58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,22 @@ +## [Unreleased] +- metadata accessors can fall back to the properties a metric inherits from objects higher up in an asset hierarchy (under "inherited" in the metric metadata of the current convention), only as last resort after "value_dimensions_shared". This is the default for get_metric_info, get_singlets_metric_info, get_series_info and get_singlets_info. The functions for dedicated information (units, names, display names, short display names and measurements) only do so with keyword argument include_inherited=True, since an inherited property with the same label usually does not describe the metric itself +- get_metric_info and get_singlets_metric_info fall back to the value dimension "value" and to "value_dimensions_shared" if the metric itself does not provide the information +- fix: (short) display names of metadata following the current convention treat null as missing and look up each field on the value dimension, the metric and "value_dimensions_shared" before falling back to the next field. So e.g. a display name of the metric wins over the name of its value dimension +- fix: get_metric_info returns None entries instead of raising if the metadata contains no metric information +- the series accessors determine their metric like the SingleTSFrame accessors: the one given by "single_metric" in the dataset metadata or, if that is missing, the only metric present. The metric named "series" is only used as default for older metadata structures +- a "single_metric" in the dataset metadata which is no string counts as missing. Before, e.g. a list let all accessors of Series and SingleTSFrames raise an error +- fix: get_series_display_name no longer falls back to the short display name, as documented and like get_display_names and get_singlets_display_names +- fix: the SingleTSFrame accessors no longer use the only metric present if it is another one than the metric given by "single_metric" in the dataset metadata. The information counts as missing then +- metadata following the current convention is extracted in a single pass in plain Python instead of evaluating glom specs per level and field. The accessors are considerably faster for frames with many metrics, e.g. about 100 times for 1000 metrics when requesting field names or dotted paths. Other glom specs are still evaluated by glom, but only on the levels needed +- the accessors of Series and SingleTSFrames only resolve the information of their single metric. So they are considerably faster for a Series carrying the metadata of many metrics, e.g. taken from a MultiTSFrame, and a spec is not applied to the other metrics. Also get_series_unit and get_series_name no longer evaluate glom specs for metadata following the current convention. Both are about 90 times faster for the metadata of 1000 metrics +- the requested information is only looked up on the levels needed, so e.g. a spec transforming the information is no longer applied to null values of levels which are not used anyway +- entries of metadata following the current convention which do not adhere to the conventions are skipped: metrics which are no objects or whose field named by the metric key is no string, and value dimensions which are no objects or whose column is no string. "value_dimensions" and "value_dimensions_shared" which are no lists count as empty, a metric key which is no string as missing. Before, a single such entry could make all information missing or raise an error +- new functions get_default_timezones, get_series_default_timezone and get_singlets_default_timezone provide the default time zone of the metrics ("default_timezone" of the current metadata convention), falling back to the one of the dataset metadata. It is meant for computations, e.g. bucketing days when aggregating, not for displaying timestamps, which is what the time zone of the plot target settings is for. New section "Which time zone to compute in?" in the documentation +- documentation: the information requested from get_metric_info, get_singlets_metric_info, get_series_info and get_singlets_info is interpreted as a glom spec. New section "How to query nested information?" with examples for dotted paths into sub dictionaries, other glom specs and keys containing dots +- new class Recording and functions get_recordings, get_series_recording and get_singlets_recording provide how the values of the metrics are recorded: "recording_mode", "data_frequency" with "data_frequency_offset" and "data_frequency_timezone", and "recording_aggregation_method" of the current metadata convention. The frequency falls back to "ref_data_frequency", "ref_data_frequency_offset" and "ref_data_frequency_timezone" of the dataset metadata as a whole, i.e. offset and time zone only apply together with the frequency of the same level. The values are provided as given in the metadata, the documentation of Recording explains what they mean +- new functions get_default_aggregation_methods, get_series_default_aggregation_method and get_singlets_default_aggregation_method provide the default aggregation method of the metrics ("default_aggregation_method"), normalizing "AVG" to "MEAN". Without one, it is "LAST" for metrics whose value dimension "value" has the value data type "boolean" or "string", otherwise "MEAN" +- new function without_metadata provides a copy of a Series or DataFrame without its metadata (attrs) to compute on. pandas deep-copies the attrs to every object an operation creates, which makes e.g. loops over the metrics of a MultiTSFrame with the metadata of many metrics slow. The copy shares the data until it is changed + ## [0.0.9] - 2026-09-10 - singletsframe support diff --git a/docs/_sources/first_steps.rst.txt b/docs/_sources/first_steps.rst.txt index c2a396f..24dab44 100644 --- a/docs/_sources/first_steps.rst.txt +++ b/docs/_sources/first_steps.rst.txt @@ -5,50 +5,233 @@ First steps How to get metadata with hdhelpers? =================================== -Let's say we want to retrieve the metadata of a timeseries. +Let's say we want to retrieve the metadata of a timeseries, which follows the +`hetida designer metadata conventions`_. In hetida designer this series can be represented as json for *direct provisioning* .. code-block:: json { - "__hd_wrapped_data_object__":"SERIES", + "__hd_wrapped_data_object__": "SERIES", "__metadata__": { - "single_metric_dataset_metadata": { - "ref_interval_end_timestamp":"2020-01-01T08:20:00.000Z", - "ref_interval_start_timestamp": "2020-01-01T08:10:00.000Z" + "dataset_metadata": { + "metric_key": "external_id", + "single_metric": "water.north.level", + "ref_interval_start_timestamp": "2020-01-01T08:10:00+00:00", + "ref_interval_end_timestamp": "2020-01-01T08:20:00+00:00", + "ref_interval_type": "closed" }, - "single_metric_metadata": { - "structured_metadata": { - "metric": { - "short_display_name": "Water Level", - "unit": "cm" - } + "metrics": [ + { + "external_id": "water.north.level", + "name": "Water level north", + "short_display_name": "Water Level", + "value_dimensions": [{"column": "value", "unit": "cm"}], + "inherited": {"latitude": 51.43, "longitude": 7.58} } - } + ] }, "__data__": { "2020-01-01T08:10:00+00:00": 1, "2020-01-01T08:15:00+00:00": 2, "2020-01-01T08:16:00+00:00": 3, - "2020-01-01T08:17:00+00:00": 4, + "2020-01-01T08:17:00+00:00": 4 } } -We can retrieve the name and unit of the series with the following code +We can retrieve the name, unit and latitude of the series with the following code .. code-block:: python - from hdhelpers.metadata import get_series_name, get_series_unit + from hdhelpers.metadata import get_series_info, get_series_name, get_series_unit def main(*, series): # entrypoint function for this component # ***** DO NOT EDIT LINES ABOVE ***** - name = get_series_name(series) - unit = get_series_unit(series) + name = get_series_name(series) # "Water level north" + unit = get_series_unit(series) # "cm" + latitude = get_series_info(series, "latitude") # 51.43, inherited from the hierarchy ... +.. _hetida designer metadata conventions: https://hetida.github.io/hetida-designer/user_guide/attached_metadata/ + +Where does the information come from? +===================================== + +The metadata conventions allow to provide information on different levels. The functions +look for it in the following order and use the first information which is not null. Information +provided as null counts as missing. + +**Dedicated information** like the unit, name, display name, short display name and measurement +(e.g. ``get_units``, ``get_series_unit``, ``get_singlets_units``) is looked up per value dimension: + +1. in the value dimension itself, +2. only for the value dimension ``"value"``: in the metric, +3. in ``"value_dimensions_shared"``. + +For (short) display names, each field is looked up on all of these levels before falling back to +the next field: short display name, then display name, then name. So e.g. a display name provided +for the metric wins over the name of its value dimension. + +**Other information** is looked up by ``get_metric_info`` and ``get_singlets_metric_info`` + +1. in the metric itself, +2. in its value dimension ``"value"``, +3. in ``"value_dimensions_shared"``, +4. in the properties the metric inherits from objects higher up in an asset hierarchy (given under + ``"inherited"`` in the metric metadata), e.g. the latitude of the plant a sensor belongs to. + +``get_series_info`` and ``get_singlets_info`` use the order of the dedicated information, followed +by the inherited properties as last resort. + +The inherited properties can be switched off via the keyword argument ``include_inherited=False``. +In contrast, the functions for dedicated information do not use them by default, since an +inherited property with the same label usually does not describe the metric itself, e.g. a +property "unit" of a plant. Pass ``include_inherited=True`` to use them as last resort anyway. + +Entries which do not adhere to the conventions are skipped: metrics which are no objects or +whose field named by the metric key is no string, and value dimensions which are no objects or +whose column is no string. ``"value_dimensions"`` and ``"value_dimensions_shared"`` which are no +lists count as empty, a metric key which is no string as missing, i.e. ``"id"`` is used. + +How to query nested information? +================================ + +The information requested from ``get_metric_info``, ``get_singlets_metric_info``, +``get_series_info`` and ``get_singlets_info`` is interpreted as a `glom`_ spec. A string is a +path in which dots separate the keys, so it also reaches into sub dictionaries. Let's say the +metric of the series above additionally provides comments and information on its sensor (the +conventions allow additional fields at every level), and the label of one of its inherited +properties contains a dot: + +.. code-block:: json + + { + "external_id": "water.north.level", + "name": "Water level north", + "sensor": { + "manufacturer": "Acme", + "calibration": {"date": "2026-03-01", "offset": 0.02} + }, + "comments": [ + {"content": "Gauge maintenance"}, + {"content": "Sensor replaced"} + ], + "value_dimensions": [{"column": "value", "unit": "cm"}], + "inherited": {"latitude": 51.43, "operator.name": "Ruhrverband"} + } + +We can retrieve the nested information with the following code + +.. code-block:: python + + from glom import Path + from hdhelpers.metadata import get_series_info + + def main(*, series): + # entrypoint function for this component + # ***** DO NOT EDIT LINES ABOVE ***** + + manufacturer = get_series_info(series, "sensor.manufacturer") # "Acme" + calibration_date = get_series_info(series, "sensor.calibration.date") # "2026-03-01" + latitude = get_series_info(series, "inherited.latitude") # 51.43, never the metric's own + firmware = get_series_info(series, "sensor.firmware") # None, not provided + + # any other glom spec, e.g. the content of each comment + comments = get_series_info(series, ("comments", ["content"])) + # ["Gauge maintenance", "Sensor replaced"] + + # a key which itself contains dots requires a glom Path + operator = get_series_info(series, Path("operator.name")) # "Ruhrverband" + + ... + +For a MULTITSFRAME, ``get_metric_info(multitsframe, "sensor.manufacturer")`` provides the +manufacturer keyed by metric accordingly. + +The fallbacks described above apply to the whole path, e.g. ``"sensor.manufacturer"`` is looked +up in the metric first, then in its value dimension ``"value"`` and so on. A path which is not +present yields None instead of an error. Note that a plain string like ``"operator.name"`` is +always split at its dots, so it does not find the inherited property ``"operator.name"``. + +A spec may also transform the information, e.g. ``("name", str.upper)``. Since information +provided as null counts as missing, such a spec may receive null: on the metric itself and, if +the metric does not provide the information, on the levels it falls back to. Errors raised by +the spec are not caught. So either let it handle None, or rather transform the information +after retrieving it. + +.. _glom: https://glom.readthedocs.io/ + +Which time zone to compute in? +============================== + +Metadata may provide a ``default_timezone`` for each metric and for the dataset metadata: the +time zone the timeseries should be evaluated in by default. It belongs to the data, e.g. it is +the time zone of the plant a sensor belongs to, and it is meant for **computations**: +Components evaluating timeseries in calendar intervals should respect it. E.g. a component +aggregating daily values should bucket the days from midnight to midnight of this time zone, +so its results depend on it. + +It is **not** meant for displaying timestamps. That is what the time zone of the plot target +settings is for, which the hetida platform provides for the view, e.g. the time zone of a +dashboard or of the user looking at a plot. ``modify_timezone`` uses it if no ``to_timezone`` +is given. Both may differ: A report on a plant in Germany may aggregate days in +``"Europe/Berlin"`` and nevertheless show its timestamps to a viewer in New York in +``"America/New_York"``. + +``get_series_default_timezone``, ``get_singlets_default_timezone`` and +``get_default_timezones`` (keyed by metric) provide the default time zone of a metric. If the +metric does not provide one, they fall back to the one of the dataset metadata, which applies +to all metrics without their own. If neither provides one, they return None. The time zone is +an IANA time zone name like ``"Europe/Berlin"``, which respects daylight saving time, or a +fixed UTC offset like ``"+02:00"``. pandas accepts both. + +.. code-block:: python + + from hdhelpers.helpers import modify_timezone + from hdhelpers.metadata import get_series_default_timezone + + def main(*, series): + # entrypoint function for this component + # ***** DO NOT EDIT LINES ABOVE ***** + + # compute: each day starts at midnight of the default time zone of the data, + # e.g. at midnight in "Europe/Berlin", and has 23 or 25 hours when switching + # from or to daylight saving time + timezone = get_series_default_timezone(series) or "UTC" + daily_sums = modify_timezone(series, to_timezone=timezone).resample("1D").sum() + + # view: show the timestamps in the time zone of the plot target settings + daily_sums = modify_timezone(daily_sums) + + ... + +Note the explicit fallback ``or "UTC"`` in the example: Without it, ``modify_timezone`` would +use the time zone of the plot target settings if the metadata provide no default time zone, +so the results of the computation would depend on who looks at them. + +For a MULTITSFRAME, each metric may have its own default time zone: + +.. code-block:: python + + from hdhelpers.metadata import get_default_timezones + + def main(*, multitsframe): + # entrypoint function for this component + # ***** DO NOT EDIT LINES ABOVE ***** + + timezones = get_default_timezones(multitsframe) + daily_sums = { + metric: group.set_index("timestamp")["value"] + .tz_convert(timezones[metric] or "UTC") + .resample("1D") + .sum() + for metric, group in multitsframe.groupby("metric") + } + + ... How to use hdhelpers for plotting? (tbd) @@ -78,10 +261,10 @@ Our component code might look like this to plot the timeseries accordingly: fig = go.Figure([go.Scatter(x=series.index, y=series.values, marker={"color": resolve_color("ki.vision")})]) - start, end = get_queried_interval(series=series) + start, end = get_queried_interval(series) fig.update_xaxes(range=(start, end)) - full_title = get_series_name(series=series, default_title="Level") + full_title = get_series_name(series) or "Level" fig.update_layout(yaxis_title=full_title) set_dt_ticks(fig) diff --git a/docs/_sources/index.rst.txt b/docs/_sources/index.rst.txt index b6ca2c0..cf00ef9 100644 --- a/docs/_sources/index.rst.txt +++ b/docs/_sources/index.rst.txt @@ -14,12 +14,13 @@ hdhelpers is a package designed for and included in the standard installation of It contains functions that streamline plotting components, especially those that are used in the `hetida platform`_, by -* accessing series metadata that complies with the hetida platform metadata scheme, +* accessing metadata of series, singletsframes and multitsframes that complies with the `hetida designer metadata conventions`_, * aditional helper functions like adjusting the timezone of timestamps, series, and dataframes, * accessing metadata that the hetida platform writes into the hetida designer's *plot_target_settings* context variable (tbd), * providing toggleable standardized styling options and json serialization for plotly plots (tbd). .. _hetida designer: https://github.com/hetida/hetida-designer +.. _hetida designer metadata conventions: https://hetida.github.io/hetida-designer/user_guide/attached_metadata/ .. _hetida platform: https://hetida.io/ Further Information diff --git a/docs/first_steps.html b/docs/first_steps.html index 5796262..a6623f6 100644 --- a/docs/first_steps.html +++ b/docs/first_steps.html @@ -44,42 +44,203 @@

Navigation

First steps

How to get metadata with hdhelpers?

-

Let’s say we want to retrieve the metadata of a timeseries. +

Let’s say we want to retrieve the metadata of a timeseries, which follows the +hetida designer metadata conventions. In hetida designer this series can be represented as json for direct provisioning

{
-    "__hd_wrapped_data_object__":"SERIES",
+    "__hd_wrapped_data_object__": "SERIES",
     "__metadata__": {
-        "single_metric_dataset_metadata": {
-            "ref_interval_end_timestamp":"2020-01-01T08:20:00.000Z",
-            "ref_interval_start_timestamp": "2020-01-01T08:10:00.000Z"
+        "dataset_metadata": {
+            "metric_key": "external_id",
+            "single_metric": "water.north.level",
+            "ref_interval_start_timestamp": "2020-01-01T08:10:00+00:00",
+            "ref_interval_end_timestamp": "2020-01-01T08:20:00+00:00",
+            "ref_interval_type": "closed"
         },
-        "single_metric_metadata": {
-            "structured_metadata": {
-                "metric": {
-                    "short_display_name": "Water Level",
-                    "unit": "cm"
-                }
+        "metrics": [
+            {
+                "external_id": "water.north.level",
+                "name": "Water level north",
+                "short_display_name": "Water Level",
+                "value_dimensions": [{"column": "value", "unit": "cm"}],
+                "inherited": {"latitude": 51.43, "longitude": 7.58}
             }
-        }
+        ]
     },
     "__data__": {
         "2020-01-01T08:10:00+00:00": 1,
         "2020-01-01T08:15:00+00:00": 2,
         "2020-01-01T08:16:00+00:00": 3,
-        "2020-01-01T08:17:00+00:00": 4,
+        "2020-01-01T08:17:00+00:00": 4
     }
 }
 
-

We can retrieve the name and unit of the series with the following code

-
from hdhelpers.metadata import get_series_name, get_series_unit
+

We can retrieve the name, unit and latitude of the series with the following code

+
from hdhelpers.metadata import get_series_info, get_series_name, get_series_unit
 
 def main(*, series):
     # entrypoint function for this component
     # ***** DO NOT EDIT LINES ABOVE *****
 
-    name = get_series_name(series)
-    unit = get_series_unit(series)
+    name = get_series_name(series)  # "Water level north"
+    unit = get_series_unit(series)  # "cm"
+    latitude = get_series_info(series, "latitude")  # 51.43, inherited from the hierarchy
+
+    ...
+
+
+
+
+

Where does the information come from?

+

The metadata conventions allow to provide information on different levels. The functions +look for it in the following order and use the first information which is not null. Information +provided as null counts as missing.

+

Dedicated information like the unit, name, display name, short display name and measurement +(e.g. get_units, get_series_unit, get_singlets_units) is looked up per value dimension:

+
    +
  1. in the value dimension itself,

  2. +
  3. only for the value dimension "value": in the metric,

  4. +
  5. in "value_dimensions_shared".

  6. +
+

For (short) display names, each field is looked up on all of these levels before falling back to +the next field: short display name, then display name, then name. So e.g. a display name provided +for the metric wins over the name of its value dimension.

+

Other information is looked up by get_metric_info and get_singlets_metric_info

+
    +
  1. in the metric itself,

  2. +
  3. in its value dimension "value",

  4. +
  5. in "value_dimensions_shared",

  6. +
  7. in the properties the metric inherits from objects higher up in an asset hierarchy (given under +"inherited" in the metric metadata), e.g. the latitude of the plant a sensor belongs to.

  8. +
+

get_series_info and get_singlets_info use the order of the dedicated information, followed +by the inherited properties as last resort.

+

The inherited properties can be switched off via the keyword argument include_inherited=False. +In contrast, the functions for dedicated information do not use them by default, since an +inherited property with the same label usually does not describe the metric itself, e.g. a +property “unit” of a plant. Pass include_inherited=True to use them as last resort anyway.

+

Entries which do not adhere to the conventions are skipped: metrics which are no objects or +whose field named by the metric key is no string, and value dimensions which are no objects or +whose column is no string. "value_dimensions" and "value_dimensions_shared" which are no +lists count as empty, a metric key which is no string as missing, i.e. "id" is used.

+
+
+

How to query nested information?

+

The information requested from get_metric_info, get_singlets_metric_info, +get_series_info and get_singlets_info is interpreted as a glom spec. A string is a +path in which dots separate the keys, so it also reaches into sub dictionaries. Let’s say the +metric of the series above additionally provides comments and information on its sensor (the +conventions allow additional fields at every level), and the label of one of its inherited +properties contains a dot:

+
{
+    "external_id": "water.north.level",
+    "name": "Water level north",
+    "sensor": {
+        "manufacturer": "Acme",
+        "calibration": {"date": "2026-03-01", "offset": 0.02}
+    },
+    "comments": [
+        {"content": "Gauge maintenance"},
+        {"content": "Sensor replaced"}
+    ],
+    "value_dimensions": [{"column": "value", "unit": "cm"}],
+    "inherited": {"latitude": 51.43, "operator.name": "Ruhrverband"}
+}
+
+
+

We can retrieve the nested information with the following code

+
from glom import Path
+from hdhelpers.metadata import get_series_info
+
+def main(*, series):
+    # entrypoint function for this component
+    # ***** DO NOT EDIT LINES ABOVE *****
+
+    manufacturer = get_series_info(series, "sensor.manufacturer")  # "Acme"
+    calibration_date = get_series_info(series, "sensor.calibration.date")  # "2026-03-01"
+    latitude = get_series_info(series, "inherited.latitude")  # 51.43, never the metric's own
+    firmware = get_series_info(series, "sensor.firmware")  # None, not provided
+
+    # any other glom spec, e.g. the content of each comment
+    comments = get_series_info(series, ("comments", ["content"]))
+    # ["Gauge maintenance", "Sensor replaced"]
+
+    # a key which itself contains dots requires a glom Path
+    operator = get_series_info(series, Path("operator.name"))  # "Ruhrverband"
+
+    ...
+
+
+

For a MULTITSFRAME, get_metric_info(multitsframe, "sensor.manufacturer") provides the +manufacturer keyed by metric accordingly.

+

The fallbacks described above apply to the whole path, e.g. "sensor.manufacturer" is looked +up in the metric first, then in its value dimension "value" and so on. A path which is not +present yields None instead of an error. Note that a plain string like "operator.name" is +always split at its dots, so it does not find the inherited property "operator.name".

+

A spec may also transform the information, e.g. ("name", str.upper). Since information +provided as null counts as missing, such a spec may receive null: on the metric itself and, if +the metric does not provide the information, on the levels it falls back to. Errors raised by +the spec are not caught. So either let it handle None, or rather transform the information +after retrieving it.

+
+
+

Which time zone to compute in?

+

Metadata may provide a default_timezone for each metric and for the dataset metadata: the +time zone the timeseries should be evaluated in by default. It belongs to the data, e.g. it is +the time zone of the plant a sensor belongs to, and it is meant for computations: +Components evaluating timeseries in calendar intervals should respect it. E.g. a component +aggregating daily values should bucket the days from midnight to midnight of this time zone, +so its results depend on it.

+

It is not meant for displaying timestamps. That is what the time zone of the plot target +settings is for, which the hetida platform provides for the view, e.g. the time zone of a +dashboard or of the user looking at a plot. modify_timezone uses it if no to_timezone +is given. Both may differ: A report on a plant in Germany may aggregate days in +"Europe/Berlin" and nevertheless show its timestamps to a viewer in New York in +"America/New_York".

+

get_series_default_timezone, get_singlets_default_timezone and +get_default_timezones (keyed by metric) provide the default time zone of a metric. If the +metric does not provide one, they fall back to the one of the dataset metadata, which applies +to all metrics without their own. If neither provides one, they return None. The time zone is +an IANA time zone name like "Europe/Berlin", which respects daylight saving time, or a +fixed UTC offset like "+02:00". pandas accepts both.

+
from hdhelpers.helpers import modify_timezone
+from hdhelpers.metadata import get_series_default_timezone
+
+def main(*, series):
+    # entrypoint function for this component
+    # ***** DO NOT EDIT LINES ABOVE *****
+
+    # compute: each day starts at midnight of the default time zone of the data,
+    # e.g. at midnight in "Europe/Berlin", and has 23 or 25 hours when switching
+    # from or to daylight saving time
+    timezone = get_series_default_timezone(series) or "UTC"
+    daily_sums = modify_timezone(series, to_timezone=timezone).resample("1D").sum()
+
+    # view: show the timestamps in the time zone of the plot target settings
+    daily_sums = modify_timezone(daily_sums)
+
+    ...
+
+
+

Note the explicit fallback or "UTC" in the example: Without it, modify_timezone would +use the time zone of the plot target settings if the metadata provide no default time zone, +so the results of the computation would depend on who looks at them.

+

For a MULTITSFRAME, each metric may have its own default time zone:

+
from hdhelpers.metadata import get_default_timezones
+
+def main(*, multitsframe):
+    # entrypoint function for this component
+    # ***** DO NOT EDIT LINES ABOVE *****
+
+    timezones = get_default_timezones(multitsframe)
+    daily_sums = {
+        metric: group.set_index("timestamp")["value"]
+        .tz_convert(timezones[metric] or "UTC")
+        .resample("1D")
+        .sum()
+        for metric, group in multitsframe.groupby("metric")
+    }
 
     ...
 
@@ -107,10 +268,10 @@

How to use hdhelpers for plotting? (tbd)fig = go.Figure([go.Scatter(x=series.index, y=series.values, marker={"color": resolve_color("ki.vision")})]) - start, end = get_queried_interval(series=series) + start, end = get_queried_interval(series) fig.update_xaxes(range=(start, end)) - full_title = get_series_name(series=series, default_title="Level") + full_title = get_series_name(series) or "Level" fig.update_layout(yaxis_title=full_title) set_dt_ticks(fig) @@ -134,6 +295,9 @@

Table of Contents

  • First steps
  • diff --git a/docs/genindex.html b/docs/genindex.html index d69b792..ce8be7b 100644 --- a/docs/genindex.html +++ b/docs/genindex.html @@ -50,6 +50,8 @@

    Index

    G

      +
    • get_default_timezones() (in module hdhelpers.metadata) +
    • get_display_names() (in module hdhelpers.metadata)
    • get_locale() (in module hdhelpers.helpers) @@ -61,6 +63,8 @@

      G

    • get_names() (in module hdhelpers.metadata)
    • get_queried_interval() (in module hdhelpers.metadata) +
    • +
    • get_series_default_timezone() (in module hdhelpers.metadata)
    • get_series_display_name() (in module hdhelpers.metadata)
    • @@ -77,6 +81,8 @@

      G

    • get_series_unit() (in module hdhelpers.metadata)
    • get_short_display_names() (in module hdhelpers.metadata) +
    • +
    • get_singlets_default_timezone() (in module hdhelpers.metadata)
    • get_singlets_display_names() (in module hdhelpers.metadata)
    • diff --git a/docs/index.html b/docs/index.html index a8ec446..2b4a729 100644 --- a/docs/index.html +++ b/docs/index.html @@ -47,7 +47,7 @@

      Introductionhetida designer.

      It contains functions that streamline plotting components, especially those that are used in the hetida platform, by

        -
      • accessing series metadata that complies with the hetida platform metadata scheme,

      • +
      • accessing metadata of series, singletsframes and multitsframes that complies with the hetida designer metadata conventions,

      • aditional helper functions like adjusting the timezone of timestamps, series, and dataframes,

      • accessing metadata that the hetida platform writes into the hetida designer’s plot_target_settings context variable (tbd),

      • providing toggleable standardized styling options and json serialization for plotly plots (tbd).

      • @@ -59,6 +59,9 @@

        Further InformationFirst steps @@ -72,17 +75,71 @@

        Functions +
        +hdhelpers.metadata.get_default_timezones(multitsframe)
        +

        Gets the default time zone of each metric of a MultiTSFrame

        +

        The default time zone is meant for computations: It is the time zone the timeseries should +be evaluated in by default, e.g. for bucketing days when aggregating, so that each day +starts at midnight of this time zone. It belongs to the data, e.g. it is the time zone of +the plant a sensor belongs to. It is not meant for displaying timestamps - that is what the +time zone of the plot target settings is for, which e.g. hdhelpers.helpers.modify_timezone() +uses by default. See “Which time zone to compute in?” in the first steps.

        +

        The time zone is an IANA time zone name like “Europe/Berlin” or a fixed UTC offset like +“+02:00”, both accepted by pandas, e.g. by tz_convert.

        +

        The default time zone of a metric falls back to the one of the dataset metadata, which +applies to all metrics without their own. As specified by the metadata conventions, it does +not fall back to value dimensions or inherited properties.

        +
        +
        Parameters:
        +

        multitsframe (pd.DataFrame) – MTSF with metadata following the convention.

        +
        +
        Returns:
        +

        Dictionary, where keys are the entries of the metrics metadata specified via “metric_key” in “dataset_metadata” and values are their default time zones. Defaults to the default time zone of the dataset metadata, also for metrics without metadata, and to None if that is missing as well.

        +
        +
        Return type:
        +

        defaultdict[str, str | None]

        +
        +
        Raises:
        +

        TypeError – If multitsframe is not a DataFrame.

        +
        +
        +

        Code example:

        +
        >>> multitsframe.attrs = {
        +...     "dataset_metadata": {"metric_key": "id", "default_timezone": "Europe/Berlin"},
        +...     "metrics": [
        +...         {"id": "new_york_sensor", "default_timezone": "America/New_York"},
        +...         {"id": "berlin_sensor"},
        +...     ],
        +... }
        +>>> timezones = get_default_timezones(multitsframe)
        +>>> timezones["new_york_sensor"]
        +'America/New_York'
        +>>> timezones["berlin_sensor"]
        +'Europe/Berlin'
        +>>> timezones["metric_without_metadata"]
        +'Europe/Berlin'
        +
        +
        +
        +
        -hdhelpers.metadata.get_display_names(multitsframe)
        +hdhelpers.metadata.get_display_names(multitsframe, *, include_inherited=False)

        Gets display names of the MTSF metrics from the metadata

        Parameters:
        -

        multitsframe (pd.DataFrame) – MTSF with metadata following the convention.

        +
          +
        • multitsframe (pd.DataFrame) – MTSF with metadata following the convention.

        • +
        • include_inherited (bool) – Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under “inherited” in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor “value_dimensions_shared” provide the information. Defaults to False, since this information should be provided for the metric itself.

        • +
        Returns:

        Dictionary of metrics containing the display names. -If the display name of the metrics is not present it returns the result of hdhelpers.metadata.get_names().

        +If the display name of the metrics is not present it returns the result of hdhelpers.metadata.get_names(). +For metadata following the current convention, the display name is looked up on the value dimension, on +the metric (for the value dimension “value”) and in “value_dimensions_shared” before falling back to the +name. Fields provided as null count as missing.

        Return type:

        defaultdict[str, defaultdict[str, str | None]]

        @@ -119,11 +176,14 @@

        Functions
        -hdhelpers.metadata.get_measurements(multitsframe)
        +hdhelpers.metadata.get_measurements(multitsframe, *, include_inherited=False)

        Gets measurement (type) of the MTSF metrics from the metadata

        Parameters:
        -

        multitsframe (pd.DataFrame) – MTSF with metadata following the convention.

        +
          +
        • multitsframe (pd.DataFrame) – MTSF with metadata following the convention.

        • +
        • include_inherited (bool) – Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under “inherited” in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor “value_dimensions_shared” provide the information. Defaults to False, since this information should be provided for the metric itself.

        • +
        Returns:

        Dictionary of metrics containing the measurement (type) of the MTSF metrics. @@ -149,19 +209,27 @@

        Functions
        -hdhelpers.metadata.get_metric_info(multitsframe, metric_info)
        +hdhelpers.metadata.get_metric_info(multitsframe, metric_info, *, include_inherited=True)

        Gets a dictionary of metadata associated to metrics.

        In contrast to metadata associated to concrete value dimensions, this function abstracts access to metadata associated to the underlying metric.

        +

        The information is looked up in the metric itself, then in its value dimension “value”, +then in “value_dimensions_shared” and finally in the properties the metric inherits from +objects higher up in an asset hierarchy (given under “inherited” in the metric metadata), +e.g. the latitude of the plant a sensor belongs to. The latter can be switched off via +include_inherited. Information provided as null counts as missing. Note that in contrast +to this function, the functions for dedicated information like get_units() or +get_display_names() do not fall back to inherited properties by default.

        Parameters:
        • multitsframe (pd.DataFrame) – MTSF with metadata following the convention.

        • -
        • metric_info (str | Spec) – Name of information to retrieve. Note that metric_info is interpreted as a glom Spec.

        • +
        • metric_info (str | Spec) – Information to retrieve, interpreted as a glom Spec: e.g. a field name, a dotted path like “sensor.manufacturer” into sub dictionaries or any other glom Spec. See “How to query nested information?” in the first steps.

        • +
        • include_inherited (bool) – Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under “inherited” in the metric metadata) as last resort, i.e. if neither the metric, its value dimension “value” nor “value_dimensions_shared” provide the information. Defaults to True.

        Returns:
        -

        Dictionary, where keys are the entries of the metrics metadata specified via “metric_key” in “dataset_metadata” and values are the entries specified via “metric_info” in the metrics metadata

        +

        Dictionary, where keys are the entries of the metrics metadata specified via “metric_key” in “dataset_metadata” and values are the entries specified via “metric_info” in the metrics metadata. Defaults to None, also if there is no metric metadata at all.

        Return type:

        defaultdict[str, Any]

        @@ -178,6 +246,7 @@

        Functions... "external_id": "external_first", ... "unit": "m", ... "display_name": "first display name", +... "sensor": {"manufacturer": "Acme"}, ... "value_dimensions": [ ... { ... "column": "temp", @@ -208,15 +277,26 @@

        FunctionsTrue +

        A dotted path reaches into sub dictionaries:

        +
        >>> result = get_metric_info(multitsframe, "sensor.manufacturer")
        +>>> result["first"]
        +'Acme'
        +>>> result["second"] is None
        +True
        +
        +

        -hdhelpers.metadata.get_names(multitsframe)
        +hdhelpers.metadata.get_names(multitsframe, *, include_inherited=False)

        Gets names of the MTSF metrics from the metadata

        Parameters:
        -

        multitsframe (pd.DataFrame) – MTSF with metadata following the convention.

        +
          +
        • multitsframe (pd.DataFrame) – MTSF with metadata following the convention.

        • +
        • include_inherited (bool) – Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under “inherited” in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor “value_dimensions_shared” provide the information. Defaults to False, since this information should be provided for the metric itself.

        • +
        Returns:

        Dictionary of metrics containing the names. If the name is not present for a metric the corresponding value is None.

        @@ -292,13 +372,57 @@

        Functions +
        +hdhelpers.metadata.get_series_default_timezone(series)
        +

        Gets the default time zone of a Series

        +

        The default time zone is meant for computations: It is the time zone the timeseries should +be evaluated in by default, e.g. for bucketing days when aggregating, so that each day +starts at midnight of this time zone. It belongs to the data, e.g. it is the time zone of +the plant a sensor belongs to. It is not meant for displaying timestamps - that is what the +time zone of the plot target settings is for, which e.g. hdhelpers.helpers.modify_timezone() +uses by default. See “Which time zone to compute in?” in the first steps.

        +

        The time zone is an IANA time zone name like “Europe/Berlin” or a fixed UTC offset like +“+02:00”, both accepted by pandas, e.g. by tz_convert.

        +

        The default time zone of the metric falls back to the one of the dataset metadata.

        +
        +
        Parameters:
        +

        series (pd.Series) – Series with metadata following the convention.

        +
        +
        Returns:
        +

        The default time zone, or None if neither the metric nor the dataset metadata provide one.

        +
        +
        Return type:
        +

        str | None

        +
        +
        Raises:
        +

        TypeError – If series is not a Series.

        +
        +
        +

        Code example:

        +
        >>> series.attrs = {
        +...     "dataset_metadata": {"single_metric": "water.north.level", "metric_key": "external_id"},
        +...     "metrics": [{"external_id": "water.north.level", "default_timezone": "Europe/Berlin"}],
        +... }
        +>>> get_series_default_timezone(series)
        +'Europe/Berlin'
        +>>> series.attrs = {}
        +>>> get_series_default_timezone(series) is None
        +True
        +
        +
        +

        +
        -hdhelpers.metadata.get_series_display_name(series)
        +hdhelpers.metadata.get_series_display_name(series, *, include_inherited=False)

        Gets display name of the series from metadata

        Parameters:
        -

        series (pd.Series) – Series with metadata following the convention.

        +
          +
        • series (pd.Series) – Series with metadata following the convention.

        • +
        • include_inherited (bool) – Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under “inherited” in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor “value_dimensions_shared” provide the information. Defaults to False, since this information should be provided for the metric itself.

        • +
        Returns:

        Returns the display name of the value. @@ -323,7 +447,7 @@

        Functions
        -hdhelpers.metadata.get_series_info(series, value_dim_info)
        +hdhelpers.metadata.get_series_info(series, value_dim_info, *, include_inherited=True)

        Gets an arbitrary series info

        Since a series has only one value dimension named “value”, this information is equivalent to information on the metric.

        @@ -331,7 +455,8 @@

        FunctionsParameters:
        • series (pd.Series) – Series with metadata following the convention.

        • -
        • value_dim_info (str | Spec) – Name of information to retrieve. Note that value_dim_info is interpreted as a glom Spec.

        • +
        • value_dim_info (str | Spec) – Information to retrieve, interpreted as a glom Spec: e.g. a field name, a dotted path like “sensor.manufacturer” into sub dictionaries or any other glom Spec. See “How to query nested information?” in the first steps.

        • +
        • include_inherited (bool) – Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under “inherited” in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor “value_dimensions_shared” provide the information. Defaults to True.

        Returns:
        @@ -354,11 +479,14 @@

        Functions
        -hdhelpers.metadata.get_series_measurement(series)
        +hdhelpers.metadata.get_series_measurement(series, *, include_inherited=False)

        Gets measurement (type) of the Series from metadata

        Parameters:
        -

        series (pd.Series) – Series with metadata following the convention.

        +
          +
        • series (pd.Series) – Series with metadata following the convention.

        • +
        • include_inherited (bool) – Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under “inherited” in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor “value_dimensions_shared” provide the information. Defaults to False, since this information should be provided for the metric itself.

        • +
        Returns:

        Returns the measurement (type) of the value. @@ -382,11 +510,14 @@

        Functions
        -hdhelpers.metadata.get_series_name(series)
        +hdhelpers.metadata.get_series_name(series, *, include_inherited=False)

        Gets name of the series from metadata

        Parameters:
        -

        series (pd.Series) – Series with metadata following the convention.

        +
          +
        • series (pd.Series) – Series with metadata following the convention.

        • +
        • include_inherited (bool) – Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under “inherited” in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor “value_dimensions_shared” provide the information. Defaults to False, since this information should be provided for the metric itself.

        • +
        Returns:

        Returns the name of the value. @@ -426,11 +557,14 @@

        Functions
        -hdhelpers.metadata.get_series_short_display_name(series)
        +hdhelpers.metadata.get_series_short_display_name(series, *, include_inherited=False)

        Gets short display name of the Series from metadata

        Parameters:
        -

        series (pd.Series) – Series with metadata following the convention.

        +
          +
        • series (pd.Series) – Series with metadata following the convention.

        • +
        • include_inherited (bool) – Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under “inherited” in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor “value_dimensions_shared” provide the information. Defaults to False, since this information should be provided for the metric itself.

        • +
        Returns:

        Returns the short display name of the value. @@ -455,11 +589,14 @@

        Functions
        -hdhelpers.metadata.get_series_unit(series)
        +hdhelpers.metadata.get_series_unit(series, *, include_inherited=False)

        Gets name of the series from metadata

        Parameters:
        -

        series (pd.Series) – Series with metadata following the convention.

        +
          +
        • series (pd.Series) – Series with metadata following the convention.

        • +
        • include_inherited (bool) – Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under “inherited” in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor “value_dimensions_shared” provide the information. Defaults to False, since this information should be provided for the metric itself.

        • +
        Returns:

        Returns the unit of series. @@ -496,15 +633,21 @@

        Functions
        -hdhelpers.metadata.get_short_display_names(multitsframe)
        +hdhelpers.metadata.get_short_display_names(multitsframe, *, include_inherited=False)

        Gets short display names of the MTSF metrics from the metadata

        Parameters:
        -

        multitsframe (pd.DataFrame) – MTSF with metadata following the convention.

        +
          +
        • multitsframe (pd.DataFrame) – MTSF with metadata following the convention.

        • +
        • include_inherited (bool) – Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under “inherited” in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor “value_dimensions_shared” provide the information. Defaults to False, since this information should be provided for the metric itself.

        • +
        Returns:

        Dictionary of metrics containing the short display names. -If the short display name of the metrics is not present it returns the result of hdhelpers.metadata.get_display_names().

        +If the short display name of the metrics is not present it returns the result of hdhelpers.metadata.get_display_names(). +For metadata following the current convention, each of short display name, display name and name is +looked up on the value dimension, on the metric (for the value dimension “value”) and in +“value_dimensions_shared” before falling back to the next one. Fields provided as null count as missing.

        Return type:

        defaultdict[str, defaultdict[str, str | None]]

        @@ -542,14 +685,58 @@

        Functions +
        +hdhelpers.metadata.get_singlets_default_timezone(singletsframe)
        +

        Gets the default time zone of the single metric of a SingleTSFrame

        +

        The default time zone is meant for computations: It is the time zone the timeseries should +be evaluated in by default, e.g. for bucketing days when aggregating, so that each day +starts at midnight of this time zone. It belongs to the data, e.g. it is the time zone of +the plant a sensor belongs to. It is not meant for displaying timestamps - that is what the +time zone of the plot target settings is for, which e.g. hdhelpers.helpers.modify_timezone() +uses by default. See “Which time zone to compute in?” in the first steps.

        +

        The time zone is an IANA time zone name like “Europe/Berlin” or a fixed UTC offset like +“+02:00”, both accepted by pandas, e.g. by tz_convert.

        +

        The default time zone of the metric falls back to the one of the dataset metadata.

        +
        +
        Parameters:
        +

        singletsframe (pd.DataFrame) – STSF with metadata following the convention.

        +
        +
        Returns:
        +

        The default time zone, or None if neither the metric nor the dataset metadata provide one.

        +
        +
        Return type:
        +

        str | None

        +
        +
        Raises:
        +

        TypeError – If singletsframe is not a DataFrame.

        +
        +
        +

        Code example:

        +
        >>> dataframe.attrs = {
        +...     "dataset_metadata": {"single_metric": "abc.temp", "default_timezone": "+02:00"},
        +...     "metrics": [{"id": "abc.temp", "default_timezone": None}],
        +... }
        +>>> get_singlets_default_timezone(dataframe)
        +'+02:00'
        +
        +
        +

        +
        -hdhelpers.metadata.get_singlets_display_names(singletsframe)
        +hdhelpers.metadata.get_singlets_display_names(singletsframe, *, include_inherited=False)

        Gets display names of the value dimensions of a SingleTSFrame from metadata

        -

        Falls back to the name of the value dimension if no display name is given.

        +

        Falls back to the name of the value dimension if no display name is given. The display name +is looked up on the value dimension, on the metric (for the value dimension “value”) and in +“value_dimensions_shared” before falling back to the name. Fields provided as null count as +missing.

        Parameters:
        -

        singletsframe (pd.DataFrame) – STSF with metadata following the convention.

        +
          +
        • singletsframe (pd.DataFrame) – STSF with metadata following the convention.

        • +
        • include_inherited (bool) – Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under “inherited” in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor “value_dimensions_shared” provide the information. Defaults to False, since this information should be provided for the metric itself.

        • +
        Returns:

        Dictionary of value dimensions containing the display names. If neither display name nor name is present for a value dimension the corresponding value is None.

        @@ -585,7 +772,7 @@

        Functions
        -hdhelpers.metadata.get_singlets_info(singletsframe, value_dim_info)
        +hdhelpers.metadata.get_singlets_info(singletsframe, value_dim_info, *, include_inherited=True)

        Gets an arbitrary info of the value dimensions of a SingleTSFrame

        A SingleTSFrame (STSF) holds exactly one metric - like a Series - but arbitrarily many value dimensions - like a MultiTSFrame. Therefore, in contrast to the MTSF functions, @@ -597,7 +784,8 @@

        FunctionsParameters:
        • singletsframe (pd.DataFrame) – STSF with metadata following the convention.

        • -
        • value_dim_info (str | Spec) – Name of information to retrieve. Note that value_dim_info is interpreted as a glom Spec.

        • +
        • value_dim_info (str | Spec) – Information to retrieve, interpreted as a glom Spec: e.g. a field name, a dotted path like “sensor.manufacturer” into sub dictionaries or any other glom Spec. See “How to query nested information?” in the first steps.

        • +
        • include_inherited (bool) – Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under “inherited” in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor “value_dimensions_shared” provide the information. Defaults to True.

        Returns:
        @@ -634,11 +822,14 @@

        Functions
        -hdhelpers.metadata.get_singlets_measurements(singletsframe)
        +hdhelpers.metadata.get_singlets_measurements(singletsframe, *, include_inherited=False)

        Gets measurements (types) of the value dimensions of a SingleTSFrame from metadata

        Parameters:
        -

        singletsframe (pd.DataFrame) – STSF with metadata following the convention.

        +
          +
        • singletsframe (pd.DataFrame) – STSF with metadata following the convention.

        • +
        • include_inherited (bool) – Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under “inherited” in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor “value_dimensions_shared” provide the information. Defaults to False, since this information should be provided for the metric itself.

        • +
        Returns:

        Dictionary of value dimensions containing the measurements. If “measurement” is not given for a value dimension the corresponding value is None.

        @@ -670,15 +861,18 @@

        Functions
        -hdhelpers.metadata.get_singlets_metric_info(singletsframe, metric_info)
        +hdhelpers.metadata.get_singlets_metric_info(singletsframe, metric_info, *, include_inherited=True)

        Gets metadata associated to the single metric of a SingleTSFrame

        Counterpart of get_metric_info() for SingleTSFrames: instead of a dictionary keyed -by metric this directly returns the requested information for the one metric.

        +by metric this directly returns the requested information for the one metric. Like +get_metric_info(), it falls back to the value dimension “value”, to +“value_dimensions_shared” and finally to the properties the metric inherits.

        Parameters:
        • singletsframe (pd.DataFrame) – STSF with metadata following the convention.

        • -
        • metric_info (str | Spec) – Name of information to retrieve. Note that metric_info is interpreted as a glom Spec.

        • +
        • metric_info (str | Spec) – Information to retrieve, interpreted as a glom Spec: e.g. a field name, a dotted path like “sensor.manufacturer” into sub dictionaries or any other glom Spec. See “How to query nested information?” in the first steps.

        • +
        • include_inherited (bool) – Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under “inherited” in the metric metadata) as last resort, i.e. if neither the metric, its value dimension “value” nor “value_dimensions_shared” provide the information. Defaults to True.

        Returns:
        @@ -712,11 +906,14 @@

        Functions
        -hdhelpers.metadata.get_singlets_names(singletsframe)
        +hdhelpers.metadata.get_singlets_names(singletsframe, *, include_inherited=False)

        Gets names of the value dimensions of a SingleTSFrame from metadata

        Parameters:
        -

        singletsframe (pd.DataFrame) – STSF with metadata following the convention.

        +
          +
        • singletsframe (pd.DataFrame) – STSF with metadata following the convention.

        • +
        • include_inherited (bool) – Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under “inherited” in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor “value_dimensions_shared” provide the information. Defaults to False, since this information should be provided for the metric itself.

        • +
        Returns:

        Dictionary of value dimensions containing the names. If the name is not present for a value dimension the corresponding value is None.

        @@ -752,13 +949,18 @@

        Functions
        -hdhelpers.metadata.get_singlets_short_display_names(singletsframe)
        +hdhelpers.metadata.get_singlets_short_display_names(singletsframe, *, include_inherited=False)

        Gets short display names of the value dimensions of a SingleTSFrame from metadata

        Falls back to the display name and then to the name of the value dimension if no short -display name is given.

        +display name is given. Each of them is looked up on the value dimension, on the metric (for +the value dimension “value”) and in “value_dimensions_shared” before falling back to the next +one. Fields provided as null count as missing.

        Parameters:
        -

        singletsframe (pd.DataFrame) – STSF with metadata following the convention.

        +
          +
        • singletsframe (pd.DataFrame) – STSF with metadata following the convention.

        • +
        • include_inherited (bool) – Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under “inherited” in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor “value_dimensions_shared” provide the information. Defaults to False, since this information should be provided for the metric itself.

        • +
        Returns:

        Dictionary of value dimensions containing the short display names. If none of short display name, display name and name is present for a value dimension the corresponding value is None.

        @@ -794,11 +996,14 @@

        Functions
        -hdhelpers.metadata.get_singlets_units(singletsframe)
        +hdhelpers.metadata.get_singlets_units(singletsframe, *, include_inherited=False)

        Gets units of the value dimensions of a SingleTSFrame from metadata

        Parameters:
        -

        singletsframe (pd.DataFrame) – STSF with metadata following the convention.

        +
          +
        • singletsframe (pd.DataFrame) – STSF with metadata following the convention.

        • +
        • include_inherited (bool) – Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under “inherited” in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor “value_dimensions_shared” provide the information. Defaults to False, since this information should be provided for the metric itself.

        • +
        Returns:

        Dictionary of value dimensions containing the units. If the unit is not present for a value dimension the corresponding value is None.

        @@ -849,11 +1054,14 @@

        Functions
        -hdhelpers.metadata.get_units(multitsframe)
        +hdhelpers.metadata.get_units(multitsframe, *, include_inherited=False)

        Gets unit of value dimensions in MTSF metrics from Metadata

        Parameters:
        -

        multitsframe (pd.DataFrame) – MTSF with metadata following the convention.

        +
          +
        • multitsframe (pd.DataFrame) – MTSF with metadata following the convention.

        • +
        • include_inherited (bool) – Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under “inherited” in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor “value_dimensions_shared” provide the information. Defaults to False, since this information should be provided for the metric itself.

        • +
        Returns:

        Dictionary of metrics containing the names of the value dimensions. @@ -1112,11 +1320,13 @@

        Table of Contents

      • Further Information
      • Functions
        • metadata
            +
          • get_default_timezones()
          • get_display_names()
          • get_measurements()
          • get_metric_info()
          • get_names()
          • get_queried_interval()
          • +
          • get_series_default_timezone()
          • get_series_display_name()
          • get_series_info()
          • get_series_measurement()
          • @@ -1124,6 +1334,7 @@

            Table of Contents

          • get_series_short_display_name()
          • get_series_unit()
          • get_short_display_names()
          • +
          • get_singlets_default_timezone()
          • get_singlets_display_names()
          • get_singlets_info()
          • get_singlets_measurements()
          • diff --git a/docs/objects.inv b/docs/objects.inv index 4812edf..3fa5d63 100644 Binary files a/docs/objects.inv and b/docs/objects.inv differ diff --git a/docs/searchindex.js b/docs/searchindex.js index 48248e2..4bca6aa 100644 --- a/docs/searchindex.js +++ b/docs/searchindex.js @@ -1 +1 @@ -Search.setIndex({"alltitles":{"First steps":[[0,null]],"Functions":[[1,"functions"]],"Further Information":[[1,"further-information"]],"How to get metadata with hdhelpers?":[[0,"how-to-get-metadata-with-hdhelpers"]],"How to use hdhelpers for plotting? (tbd)":[[0,"how-to-use-hdhelpers-for-plotting-tbd"]],"Introduction":[[1,"introduction"]],"exceptions":[[1,"module-hdhelpers.exceptions"]],"hdhelpers":[[1,null]],"helpers":[[1,"module-hdhelpers.helpers"]],"metadata":[[1,"module-hdhelpers.metadata"]],"plotting":[[1,"module-hdhelpers.plotting"]]},"docnames":["first_steps","index"],"envversion":{"sphinx":66,"sphinx.domains.c":3,"sphinx.domains.changeset":1,"sphinx.domains.citation":1,"sphinx.domains.cpp":9,"sphinx.domains.index":1,"sphinx.domains.javascript":3,"sphinx.domains.math":2,"sphinx.domains.python":4,"sphinx.domains.rst":2,"sphinx.domains.std":2},"filenames":["first_steps.rst","index.rst"],"indexentries":{"get_display_names() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_display_names",false]],"get_locale() (in module hdhelpers.helpers)":[[1,"hdhelpers.helpers.get_locale",false]],"get_measurements() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_measurements",false]],"get_metric_info() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_metric_info",false]],"get_names() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_names",false]],"get_queried_interval() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_queried_interval",false]],"get_series_display_name() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_series_display_name",false]],"get_series_info() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_series_info",false]],"get_series_measurement() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_series_measurement",false]],"get_series_name() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_series_name",false]],"get_series_short_display_name() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_series_short_display_name",false]],"get_series_unit() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_series_unit",false]],"get_short_display_names() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_short_display_names",false]],"get_singlets_display_names() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_singlets_display_names",false]],"get_singlets_info() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_singlets_info",false]],"get_singlets_measurements() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_singlets_measurements",false]],"get_singlets_metric_info() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_singlets_metric_info",false]],"get_singlets_names() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_singlets_names",false]],"get_singlets_short_display_names() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_singlets_short_display_names",false]],"get_singlets_units() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_singlets_units",false]],"get_units() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_units",false]],"hdhelpers.exceptions":[[1,"module-hdhelpers.exceptions",false]],"hdhelpers.helpers":[[1,"module-hdhelpers.helpers",false]],"hdhelpers.metadata":[[1,"module-hdhelpers.metadata",false]],"hdhelpers.plotting":[[1,"module-hdhelpers.plotting",false]],"helperexception":[[1,"hdhelpers.exceptions.HelperException",false]],"insufficientplottingdata":[[1,"hdhelpers.exceptions.InsufficientPlottingData",false]],"modify_timezone() (in module hdhelpers.helpers)":[[1,"hdhelpers.helpers.modify_timezone",false]],"module":[[1,"module-hdhelpers.exceptions",false],[1,"module-hdhelpers.helpers",false],[1,"module-hdhelpers.metadata",false],[1,"module-hdhelpers.plotting",false]],"resolve_color() (in module hdhelpers.plotting)":[[1,"hdhelpers.plotting.resolve_color",false]],"set_agnostic_theme() (in module hdhelpers.plotting)":[[1,"hdhelpers.plotting.set_agnostic_theme",false]],"set_dt_ticks() (in module hdhelpers.plotting)":[[1,"hdhelpers.plotting.set_dt_ticks",false]]},"objects":{"hdhelpers":[[1,0,0,"-","exceptions"],[1,0,0,"-","helpers"],[1,0,0,"-","metadata"],[1,0,0,"-","plotting"]],"hdhelpers.exceptions":[[1,1,1,"","HelperException"],[1,1,1,"","InsufficientPlottingData"]],"hdhelpers.helpers":[[1,2,1,"","get_locale"],[1,2,1,"","modify_timezone"]],"hdhelpers.metadata":[[1,2,1,"","get_display_names"],[1,2,1,"","get_measurements"],[1,2,1,"","get_metric_info"],[1,2,1,"","get_names"],[1,2,1,"","get_queried_interval"],[1,2,1,"","get_series_display_name"],[1,2,1,"","get_series_info"],[1,2,1,"","get_series_measurement"],[1,2,1,"","get_series_name"],[1,2,1,"","get_series_short_display_name"],[1,2,1,"","get_series_unit"],[1,2,1,"","get_short_display_names"],[1,2,1,"","get_singlets_display_names"],[1,2,1,"","get_singlets_info"],[1,2,1,"","get_singlets_measurements"],[1,2,1,"","get_singlets_metric_info"],[1,2,1,"","get_singlets_names"],[1,2,1,"","get_singlets_short_display_names"],[1,2,1,"","get_singlets_units"],[1,2,1,"","get_units"]],"hdhelpers.plotting":[[1,2,1,"","resolve_color"],[1,2,1,"","set_agnostic_theme"],[1,2,1,"","set_dt_ticks"]]},"objnames":{"0":["py","module","Python module"],"1":["py","exception","Python exception"],"2":["py","function","Python function"]},"objtypes":{"0":"py:module","1":"py:exception","2":"py:function"},"terms":{"000z":0,"00z":1,"01t01":1,"01t08":0,"05t13":1,"06t13":1,"A":1,"DO":0,"For":[0,1],"How":1,"If":1,"In":[0,1],"It":1,"NOT":0,"Our":0,"The":1,"This":1,"To":1,"We":0,"__data__":0,"__hd_wrapped_data_object__":0,"__metadata__":0,"abc":1,"abov":0,"absent":1,"abstract":1,"access":1,"accord":0,"adequ":1,"adit":1,"adjust":1,"agnost":1,"all_timezon":1,"allow":1,"ambigu":1,"ani":1,"anoth":1,"applic":1,"arbitrari":1,"arbitrarili":1,"arg":1,"associ":1,"assum":1,"attr":1,"avail":1,"axe":1,"axi":0,"back":1,"background":1,"base":1,"behaviour":1,"berlin":1,"bool":1,"boolean":1,"by_metr":1,"c":1,"can":[0,1],"carri":1,"case":1,"certain":1,"class":1,"cm":0,"code":[0,1],"collect":1,"color":[0,1],"color_str":1,"column":1,"column_nam":1,"compli":1,"compon":[0,1],"concret":1,"contain":1,"context":1,"contrast":1,"control":1,"convent":1,"convert":1,"convert_index":1,"correspond":[0,1],"counterpart":1,"custom":1,"dark":1,"data":1,"datafram":1,"dataset_metadata":1,"date":1,"datetim":1,"de":1,"de_d":1,"def":0,"default":1,"default_titl":0,"defaultdict":1,"defin":[0,1],"design":[0,1],"determin":1,"dict":1,"dictionari":1,"dimens":1,"direct":[0,1],"display":1,"display_nam":1,"display_name_of_metric1":1,"display_name_of_seri":1,"e":1,"ea1255":1,"eas":1,"eb6962":1,"edit":0,"empti":1,"en":1,"enabl":1,"end":[0,1],"energi":1,"entri":1,"entrypoint":0,"equival":1,"error":1,"error_cod":1,"especi":1,"europ":1,"exact":1,"exampl":[0,1],"exclus":1,"execut":1,"expect":1,"explicit":1,"explicit_local":1,"external_first":1,"external_id":1,"external_second":1,"extra_inform":1,"extract":1,"fall":1,"fig":[0,1],"figur":[0,1],"first":1,"follow":[0,1],"format":1,"frontend":1,"full_titl":0,"function":0,"fuseki":1,"g":1,"generat":1,"german":1,"get":1,"get_display_nam":1,"get_local":[0,1],"get_measur":1,"get_metric_info":1,"get_nam":1,"get_queried_interv":[0,1],"get_series_display_nam":1,"get_series_info":1,"get_series_measur":1,"get_series_nam":[0,1],"get_series_short_display_nam":1,"get_series_unit":[0,1],"get_short_display_nam":1,"get_singlets_display_nam":1,"get_singlets_info":1,"get_singlets_measur":1,"get_singlets_metric_info":1,"get_singlets_nam":1,"get_singlets_short_display_nam":1,"get_singlets_unit":1,"get_unit":1,"get_value_dimension_info":1,"given":1,"global":1,"glom":1,"go":[0,1],"good":1,"graph_object":[0,1],"handl":1,"happen":1,"helper":0,"helperexcept":1,"hetida":[0,1],"hold":1,"id":1,"identifi":1,"import":[0,1],"includ":1,"index":[0,1],"infer":1,"info":1,"inspir":1,"instal":1,"instead":1,"insuffici":1,"insufficientplottingdata":1,"int":1,"integr":1,"interpret":1,"interv":1,"iso":1,"isoformat":1,"json":[0,1],"just":1,"key":1,"ki":[0,1],"kwarg":1,"label":[0,1],"languag":1,"let":[0,1],"level":[0,1],"light":1,"like":[0,1],"limit":0,"line":0,"list":1,"local":1,"look":0,"m":1,"main":0,"mani":1,"marker":0,"meaning":1,"measur":1,"messag":1,"method":1,"metric":[0,1],"metric1":1,"metric2":1,"metric3":1,"metric_info":1,"metric_key":1,"might":0,"miss":1,"modifi":1,"modified_timezon":1,"modify_timezon":[0,1],"mtsf":1,"multipl":1,"multitsfram":1,"name":[0,1],"name_of_metric1":1,"name_of_metric2":1,"name_of_series_1":1,"name_of_series_2":1,"neither":1,"non":1,"none":1,"note":1,"object":1,"object_to_convert":1,"one":1,"onli":1,"oper":1,"option":1,"orang":1,"output":1,"packag":1,"panda":1,"paramet":1,"pd":1,"place":1,"platform":1,"pleas":1,"plot_target_set":1,"possibl":1,"present":1,"print":1,"provid":1,"provis":0,"pytz":1,"queri":1,"rais":1,"rang":0,"re":1,"red":1,"ref_interval_end_timestamp":[0,1],"ref_interval_start_timestamp":[0,1],"repres":0,"request":1,"resolv":1,"resolve_color":[0,1],"respect":1,"result":1,"retriev":[0,1],"return":[0,1],"ruhr":1,"s":[0,1],"say":0,"scatter":[0,1],"scheme":1,"second":1,"see":1,"sensibl":1,"seri":[0,1],"serial":1,"set":[0,1],"set_agnostic_them":[0,1],"set_dt_tick":[0,1],"shade":1,"short":1,"short_display_nam":[0,1],"short_display_name_of_metric1":1,"short_display_name_of_seri":1,"show":1,"similar":1,"sinc":1,"singl":1,"single_metr":1,"single_metric_dataset_metadata":0,"single_metric_metadata":[0,1],"singletsfram":1,"spec":1,"specifi":1,"standard":[0,1],"start":[0,1],"state":1,"step":1,"str":1,"streamlin":1,"string":1,"structured_metadata":[0,1],"stsf":1,"style":1,"surpress":1,"t":1,"target":1,"tbd":1,"temp":1,"temperatur":1,"templat":1,"test":1,"theme":1,"therefor":1,"tick":1,"time":1,"timeseri":[0,1],"timestamp":1,"timezon":[0,1],"to_datetim":1,"to_timezon":1,"toggleabl":1,"total_second":1,"transform":1,"tri":1,"true":1,"tupl":1,"type":1,"typeerror":1,"tz_convert":1,"underlying":1,"unit":[0,1],"unknown":1,"update_layout":0,"update_xax":0,"us":1,"use":1,"utc":1,"utcoffset":1,"valu":[0,1],"value_dim_1":1,"value_dim_info":1,"value_dimens":1,"value_dimensions_shar":1,"value_name_of_seri":1,"valueerror":1,"vari":1,"variabl":1,"via":1,"vision":0,"want":0,"water":0,"way":1,"well":1,"whether":1,"whose":1,"will":1,"without":1,"write":1,"x":[0,1],"y":[0,1],"yaxis_titl":0,"yield":1,"zone":1,"zoom":1},"titles":["First steps","hdhelpers"],"titleterms":{"Further":1,"How":0,"except":1,"first":0,"function":1,"get":0,"hdhelper":[0,1],"helper":1,"inform":1,"introduct":1,"metadata":[0,1],"plot":[0,1],"step":0,"tbd":0,"use":0}}) \ No newline at end of file +Search.setIndex({"alltitles":{"First steps":[[0,null]],"Functions":[[1,"functions"]],"Further Information":[[1,"further-information"]],"How to get metadata with hdhelpers?":[[0,"how-to-get-metadata-with-hdhelpers"]],"How to query nested information?":[[0,"how-to-query-nested-information"]],"How to use hdhelpers for plotting? (tbd)":[[0,"how-to-use-hdhelpers-for-plotting-tbd"]],"Introduction":[[1,"introduction"]],"Where does the information come from?":[[0,"where-does-the-information-come-from"]],"Which time zone to compute in?":[[0,"which-time-zone-to-compute-in"]],"exceptions":[[1,"module-hdhelpers.exceptions"]],"hdhelpers":[[1,null]],"helpers":[[1,"module-hdhelpers.helpers"]],"metadata":[[1,"module-hdhelpers.metadata"]],"plotting":[[1,"module-hdhelpers.plotting"]]},"docnames":["first_steps","index"],"envversion":{"sphinx":66,"sphinx.domains.c":3,"sphinx.domains.changeset":1,"sphinx.domains.citation":1,"sphinx.domains.cpp":9,"sphinx.domains.index":1,"sphinx.domains.javascript":3,"sphinx.domains.math":2,"sphinx.domains.python":4,"sphinx.domains.rst":2,"sphinx.domains.std":2},"filenames":["first_steps.rst","index.rst"],"indexentries":{"get_default_timezones() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_default_timezones",false]],"get_display_names() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_display_names",false]],"get_locale() (in module hdhelpers.helpers)":[[1,"hdhelpers.helpers.get_locale",false]],"get_measurements() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_measurements",false]],"get_metric_info() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_metric_info",false]],"get_names() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_names",false]],"get_queried_interval() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_queried_interval",false]],"get_series_default_timezone() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_series_default_timezone",false]],"get_series_display_name() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_series_display_name",false]],"get_series_info() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_series_info",false]],"get_series_measurement() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_series_measurement",false]],"get_series_name() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_series_name",false]],"get_series_short_display_name() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_series_short_display_name",false]],"get_series_unit() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_series_unit",false]],"get_short_display_names() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_short_display_names",false]],"get_singlets_default_timezone() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_singlets_default_timezone",false]],"get_singlets_display_names() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_singlets_display_names",false]],"get_singlets_info() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_singlets_info",false]],"get_singlets_measurements() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_singlets_measurements",false]],"get_singlets_metric_info() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_singlets_metric_info",false]],"get_singlets_names() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_singlets_names",false]],"get_singlets_short_display_names() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_singlets_short_display_names",false]],"get_singlets_units() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_singlets_units",false]],"get_units() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_units",false]],"hdhelpers.exceptions":[[1,"module-hdhelpers.exceptions",false]],"hdhelpers.helpers":[[1,"module-hdhelpers.helpers",false]],"hdhelpers.metadata":[[1,"module-hdhelpers.metadata",false]],"hdhelpers.plotting":[[1,"module-hdhelpers.plotting",false]],"helperexception":[[1,"hdhelpers.exceptions.HelperException",false]],"insufficientplottingdata":[[1,"hdhelpers.exceptions.InsufficientPlottingData",false]],"modify_timezone() (in module hdhelpers.helpers)":[[1,"hdhelpers.helpers.modify_timezone",false]],"module":[[1,"module-hdhelpers.exceptions",false],[1,"module-hdhelpers.helpers",false],[1,"module-hdhelpers.metadata",false],[1,"module-hdhelpers.plotting",false]],"resolve_color() (in module hdhelpers.plotting)":[[1,"hdhelpers.plotting.resolve_color",false]],"set_agnostic_theme() (in module hdhelpers.plotting)":[[1,"hdhelpers.plotting.set_agnostic_theme",false]],"set_dt_ticks() (in module hdhelpers.plotting)":[[1,"hdhelpers.plotting.set_dt_ticks",false]]},"objects":{"hdhelpers":[[1,0,0,"-","exceptions"],[1,0,0,"-","helpers"],[1,0,0,"-","metadata"],[1,0,0,"-","plotting"]],"hdhelpers.exceptions":[[1,1,1,"","HelperException"],[1,1,1,"","InsufficientPlottingData"]],"hdhelpers.helpers":[[1,2,1,"","get_locale"],[1,2,1,"","modify_timezone"]],"hdhelpers.metadata":[[1,2,1,"","get_default_timezones"],[1,2,1,"","get_display_names"],[1,2,1,"","get_measurements"],[1,2,1,"","get_metric_info"],[1,2,1,"","get_names"],[1,2,1,"","get_queried_interval"],[1,2,1,"","get_series_default_timezone"],[1,2,1,"","get_series_display_name"],[1,2,1,"","get_series_info"],[1,2,1,"","get_series_measurement"],[1,2,1,"","get_series_name"],[1,2,1,"","get_series_short_display_name"],[1,2,1,"","get_series_unit"],[1,2,1,"","get_short_display_names"],[1,2,1,"","get_singlets_default_timezone"],[1,2,1,"","get_singlets_display_names"],[1,2,1,"","get_singlets_info"],[1,2,1,"","get_singlets_measurements"],[1,2,1,"","get_singlets_metric_info"],[1,2,1,"","get_singlets_names"],[1,2,1,"","get_singlets_short_display_names"],[1,2,1,"","get_singlets_units"],[1,2,1,"","get_units"]],"hdhelpers.plotting":[[1,2,1,"","resolve_color"],[1,2,1,"","set_agnostic_theme"],[1,2,1,"","set_dt_ticks"]]},"objnames":{"0":["py","module","Python module"],"1":["py","exception","Python exception"],"2":["py","function","Python function"]},"objtypes":{"0":"py:module","1":"py:exception","2":"py:function"},"terms":{"00z":1,"01t01":1,"01t08":0,"05t13":1,"06t13":1,"1d":0,"A":[0,1],"As":1,"Both":0,"DO":0,"Each":1,"For":[0,1],"How":1,"If":[0,1],"In":[0,1],"It":[0,1],"NOT":0,"Other":0,"Our":0,"So":0,"That":0,"The":[0,1],"This":1,"To":1,"We":0,"Where":1,"Which":1,"__data__":0,"__hd_wrapped_data_object__":0,"__metadata__":0,"abc":1,"abov":0,"absent":1,"abstract":1,"accept":[0,1],"access":1,"accord":0,"acm":[0,1],"addit":0,"adequ":1,"adher":0,"adit":1,"adjust":1,"aggreg":[0,1],"agnost":1,"all_timezon":1,"allow":[0,1],"also":[0,1],"alway":0,"ambigu":1,"america":[0,1],"ani":[0,1],"anoth":1,"anyway":0,"appli":[0,1],"applic":1,"arbitrari":1,"arbitrarili":1,"arg":1,"argument":0,"asset":[0,1],"associ":1,"assum":1,"attr":1,"avail":1,"axe":1,"axi":0,"back":[0,1],"background":1,"base":1,"befor":[0,1],"behaviour":1,"belong":[0,1],"berlin":[0,1],"berlin_sensor":1,"bool":1,"boolean":1,"bucket":[0,1],"by_metr":1,"c":1,"calendar":0,"calibr":0,"calibration_d":0,"can":[0,1],"carri":1,"case":1,"caught":0,"certain":1,"class":1,"close":0,"cm":0,"code":[0,1],"collect":1,"color":[0,1],"color_str":1,"column":[0,1],"column_nam":1,"come":1,"comment":0,"compli":1,"compon":[0,1],"comput":1,"concret":1,"contain":[0,1],"content":0,"context":1,"contrast":[0,1],"control":1,"convent":[0,1],"convert":1,"convert_index":1,"correspond":[0,1],"count":[0,1],"counterpart":1,"current":1,"custom":1,"daili":0,"daily_sum":0,"dark":1,"dashboard":0,"data":[0,1],"datafram":1,"dataset":[0,1],"dataset_metadata":[0,1],"date":[0,1],"datetim":1,"day":[0,1],"daylight":0,"de":1,"de_d":1,"dedic":[0,1],"def":0,"default":[0,1],"default_timezon":[0,1],"defaultdict":1,"defin":[0,1],"depend":0,"describ":0,"design":[0,1],"determin":1,"dict":1,"dictionari":[0,1],"differ":0,"dimens":[0,1],"direct":[0,1],"display":[0,1],"display_nam":1,"display_name_of_metric1":1,"display_name_of_seri":1,"doe":1,"dot":[0,1],"e":[0,1],"ea1255":1,"eas":1,"eb6962":1,"edit":0,"either":0,"empti":[0,1],"en":1,"enabl":1,"end":[0,1],"energi":1,"entri":[0,1],"entrypoint":0,"equival":1,"error":[0,1],"error_cod":1,"especi":1,"europ":[0,1],"evalu":[0,1],"everi":0,"exact":1,"exampl":[0,1],"exclus":1,"execut":1,"expect":1,"explicit":[0,1],"explicit_local":1,"external_first":1,"external_id":[0,1],"external_second":1,"extra_inform":1,"extract":1,"fall":[0,1],"fallback":0,"fals":[0,1],"field":[0,1],"fig":[0,1],"figur":[0,1],"final":1,"find":0,"firmwar":0,"first":1,"fix":[0,1],"follow":[0,1],"format":1,"frontend":1,"full_titl":0,"function":0,"fuseki":1,"g":[0,1],"gaug":0,"generat":1,"german":1,"germani":0,"get":1,"get_default_timezon":[0,1],"get_display_nam":1,"get_local":[0,1],"get_measur":1,"get_metric_info":[0,1],"get_nam":1,"get_queried_interv":[0,1],"get_series_default_timezon":[0,1],"get_series_display_nam":1,"get_series_info":[0,1],"get_series_measur":1,"get_series_nam":[0,1],"get_series_short_display_nam":1,"get_series_unit":[0,1],"get_short_display_nam":1,"get_singlets_default_timezon":[0,1],"get_singlets_display_nam":1,"get_singlets_info":[0,1],"get_singlets_measur":1,"get_singlets_metric_info":[0,1],"get_singlets_nam":1,"get_singlets_short_display_nam":1,"get_singlets_unit":[0,1],"get_unit":[0,1],"get_value_dimension_info":1,"given":[0,1],"global":1,"glom":[0,1],"go":[0,1],"good":1,"graph_object":[0,1],"group":0,"groupbi":0,"handl":[0,1],"happen":1,"helper":0,"helperexcept":1,"hetida":[0,1],"hierarchi":[0,1],"higher":[0,1],"hold":1,"hour":0,"iana":[0,1],"id":[0,1],"identifi":1,"import":[0,1],"includ":1,"include_inherit":[0,1],"index":[0,1],"infer":1,"info":1,"inherit":[0,1],"inspir":1,"instal":1,"instead":[0,1],"insuffici":1,"insufficientplottingdata":1,"int":1,"integr":1,"interpret":[0,1],"interv":[0,1],"iso":1,"isoformat":1,"json":[0,1],"just":1,"key":[0,1],"keyword":0,"ki":[0,1],"kwarg":1,"label":[0,1],"languag":1,"last":[0,1],"latitud":[0,1],"latter":1,"let":[0,1],"level":[0,1],"light":1,"like":[0,1],"limit":0,"line":0,"list":[0,1],"local":1,"longitud":0,"look":[0,1],"m":1,"main":0,"mainten":0,"mani":1,"manufactur":[0,1],"marker":0,"may":0,"meaning":1,"meant":[0,1],"measur":[0,1],"messag":1,"method":1,"metric":[0,1],"metric1":1,"metric2":1,"metric3":1,"metric_info":1,"metric_key":[0,1],"metric_without_metadata":1,"midnight":[0,1],"might":0,"miss":[0,1],"modifi":1,"modified_timezon":1,"modify_timezon":[0,1],"mtsf":1,"multipl":1,"multitsfram":[0,1],"name":[0,1],"name_of_metric1":1,"name_of_metric2":1,"name_of_series_1":1,"name_of_series_2":1,"neither":[0,1],"nest":1,"never":0,"nevertheless":0,"new":0,"new_york":[0,1],"new_york_sensor":1,"next":[0,1],"non":1,"none":[0,1],"north":[0,1],"note":[0,1],"null":[0,1],"object":[0,1],"object_to_convert":1,"offset":[0,1],"one":[0,1],"onli":[0,1],"oper":[0,1],"option":1,"orang":1,"order":0,"output":1,"packag":1,"panda":[0,1],"paramet":1,"pass":0,"path":[0,1],"pd":1,"per":0,"place":1,"plain":0,"plant":[0,1],"platform":[0,1],"pleas":1,"plot_target_set":1,"possibl":1,"present":[0,1],"print":1,"properti":[0,1],"provid":[0,1],"provis":0,"pytz":1,"queri":1,"rais":[0,1],"rang":0,"rather":0,"re":1,"reach":[0,1],"receiv":0,"red":1,"ref_interval_end_timestamp":[0,1],"ref_interval_start_timestamp":[0,1],"ref_interval_typ":0,"replac":0,"report":0,"repres":0,"request":[0,1],"requir":0,"resampl":0,"resolv":1,"resolve_color":[0,1],"resort":[0,1],"respect":[0,1],"result":[0,1],"retriev":[0,1],"return":[0,1],"ruhr":1,"ruhrverband":0,"s":[0,1],"save":0,"say":0,"scatter":[0,1],"second":1,"see":1,"sensibl":1,"sensor":[0,1],"separ":0,"seri":[0,1],"serial":1,"set":[0,1],"set_agnostic_them":[0,1],"set_dt_tick":[0,1],"set_index":0,"shade":1,"short":[0,1],"short_display_nam":[0,1],"short_display_name_of_metric1":1,"short_display_name_of_seri":1,"show":[0,1],"similar":1,"sinc":[0,1],"singl":1,"single_metr":[0,1],"single_metric_metadata":1,"singletsfram":1,"skip":0,"spec":[0,1],"specifi":1,"split":0,"standard":[0,1],"start":[0,1],"state":1,"step":1,"str":[0,1],"streamlin":1,"string":[0,1],"structured_metadata":1,"stsf":1,"style":1,"sub":[0,1],"sum":0,"surpress":1,"switch":[0,1],"t":1,"target":[0,1],"tbd":1,"temp":1,"temperatur":1,"templat":1,"test":1,"theme":1,"therefor":1,"tick":1,"time":1,"timeseri":[0,1],"timestamp":[0,1],"timezon":[0,1],"to_datetim":1,"to_timezon":[0,1],"toggleabl":1,"total_second":1,"transform":[0,1],"tri":1,"true":[0,1],"tupl":1,"type":1,"typeerror":1,"tz_convert":[0,1],"underlying":1,"unit":[0,1],"unknown":1,"update_layout":0,"update_xax":0,"upper":0,"us":1,"use":1,"user":0,"usual":0,"utc":[0,1],"utcoffset":1,"valu":[0,1],"value_dim_1":1,"value_dim_info":1,"value_dimens":[0,1],"value_dimensions_shar":[0,1],"value_name_of_seri":1,"valueerror":1,"vari":1,"variabl":1,"via":[0,1],"view":0,"viewer":0,"vision":0,"want":0,"water":[0,1],"way":1,"well":1,"whether":1,"whole":0,"whose":[0,1],"will":1,"win":0,"without":[0,1],"write":1,"x":[0,1],"y":[0,1],"yaxis_titl":0,"yield":[0,1],"york":0,"zone":1,"zoom":1},"titles":["First steps","hdhelpers"],"titleterms":{"Further":1,"How":0,"Where":0,"Which":0,"come":0,"comput":0,"doe":0,"except":1,"first":0,"function":1,"get":0,"hdhelper":[0,1],"helper":1,"inform":[0,1],"introduct":1,"metadata":[0,1],"nest":0,"plot":[0,1],"queri":0,"step":0,"tbd":0,"time":0,"use":0,"zone":0}}) \ No newline at end of file diff --git a/sphinx/source/first_steps.rst b/sphinx/source/first_steps.rst index c2a396f..24dab44 100644 --- a/sphinx/source/first_steps.rst +++ b/sphinx/source/first_steps.rst @@ -5,50 +5,233 @@ First steps How to get metadata with hdhelpers? =================================== -Let's say we want to retrieve the metadata of a timeseries. +Let's say we want to retrieve the metadata of a timeseries, which follows the +`hetida designer metadata conventions`_. In hetida designer this series can be represented as json for *direct provisioning* .. code-block:: json { - "__hd_wrapped_data_object__":"SERIES", + "__hd_wrapped_data_object__": "SERIES", "__metadata__": { - "single_metric_dataset_metadata": { - "ref_interval_end_timestamp":"2020-01-01T08:20:00.000Z", - "ref_interval_start_timestamp": "2020-01-01T08:10:00.000Z" + "dataset_metadata": { + "metric_key": "external_id", + "single_metric": "water.north.level", + "ref_interval_start_timestamp": "2020-01-01T08:10:00+00:00", + "ref_interval_end_timestamp": "2020-01-01T08:20:00+00:00", + "ref_interval_type": "closed" }, - "single_metric_metadata": { - "structured_metadata": { - "metric": { - "short_display_name": "Water Level", - "unit": "cm" - } + "metrics": [ + { + "external_id": "water.north.level", + "name": "Water level north", + "short_display_name": "Water Level", + "value_dimensions": [{"column": "value", "unit": "cm"}], + "inherited": {"latitude": 51.43, "longitude": 7.58} } - } + ] }, "__data__": { "2020-01-01T08:10:00+00:00": 1, "2020-01-01T08:15:00+00:00": 2, "2020-01-01T08:16:00+00:00": 3, - "2020-01-01T08:17:00+00:00": 4, + "2020-01-01T08:17:00+00:00": 4 } } -We can retrieve the name and unit of the series with the following code +We can retrieve the name, unit and latitude of the series with the following code .. code-block:: python - from hdhelpers.metadata import get_series_name, get_series_unit + from hdhelpers.metadata import get_series_info, get_series_name, get_series_unit def main(*, series): # entrypoint function for this component # ***** DO NOT EDIT LINES ABOVE ***** - name = get_series_name(series) - unit = get_series_unit(series) + name = get_series_name(series) # "Water level north" + unit = get_series_unit(series) # "cm" + latitude = get_series_info(series, "latitude") # 51.43, inherited from the hierarchy ... +.. _hetida designer metadata conventions: https://hetida.github.io/hetida-designer/user_guide/attached_metadata/ + +Where does the information come from? +===================================== + +The metadata conventions allow to provide information on different levels. The functions +look for it in the following order and use the first information which is not null. Information +provided as null counts as missing. + +**Dedicated information** like the unit, name, display name, short display name and measurement +(e.g. ``get_units``, ``get_series_unit``, ``get_singlets_units``) is looked up per value dimension: + +1. in the value dimension itself, +2. only for the value dimension ``"value"``: in the metric, +3. in ``"value_dimensions_shared"``. + +For (short) display names, each field is looked up on all of these levels before falling back to +the next field: short display name, then display name, then name. So e.g. a display name provided +for the metric wins over the name of its value dimension. + +**Other information** is looked up by ``get_metric_info`` and ``get_singlets_metric_info`` + +1. in the metric itself, +2. in its value dimension ``"value"``, +3. in ``"value_dimensions_shared"``, +4. in the properties the metric inherits from objects higher up in an asset hierarchy (given under + ``"inherited"`` in the metric metadata), e.g. the latitude of the plant a sensor belongs to. + +``get_series_info`` and ``get_singlets_info`` use the order of the dedicated information, followed +by the inherited properties as last resort. + +The inherited properties can be switched off via the keyword argument ``include_inherited=False``. +In contrast, the functions for dedicated information do not use them by default, since an +inherited property with the same label usually does not describe the metric itself, e.g. a +property "unit" of a plant. Pass ``include_inherited=True`` to use them as last resort anyway. + +Entries which do not adhere to the conventions are skipped: metrics which are no objects or +whose field named by the metric key is no string, and value dimensions which are no objects or +whose column is no string. ``"value_dimensions"`` and ``"value_dimensions_shared"`` which are no +lists count as empty, a metric key which is no string as missing, i.e. ``"id"`` is used. + +How to query nested information? +================================ + +The information requested from ``get_metric_info``, ``get_singlets_metric_info``, +``get_series_info`` and ``get_singlets_info`` is interpreted as a `glom`_ spec. A string is a +path in which dots separate the keys, so it also reaches into sub dictionaries. Let's say the +metric of the series above additionally provides comments and information on its sensor (the +conventions allow additional fields at every level), and the label of one of its inherited +properties contains a dot: + +.. code-block:: json + + { + "external_id": "water.north.level", + "name": "Water level north", + "sensor": { + "manufacturer": "Acme", + "calibration": {"date": "2026-03-01", "offset": 0.02} + }, + "comments": [ + {"content": "Gauge maintenance"}, + {"content": "Sensor replaced"} + ], + "value_dimensions": [{"column": "value", "unit": "cm"}], + "inherited": {"latitude": 51.43, "operator.name": "Ruhrverband"} + } + +We can retrieve the nested information with the following code + +.. code-block:: python + + from glom import Path + from hdhelpers.metadata import get_series_info + + def main(*, series): + # entrypoint function for this component + # ***** DO NOT EDIT LINES ABOVE ***** + + manufacturer = get_series_info(series, "sensor.manufacturer") # "Acme" + calibration_date = get_series_info(series, "sensor.calibration.date") # "2026-03-01" + latitude = get_series_info(series, "inherited.latitude") # 51.43, never the metric's own + firmware = get_series_info(series, "sensor.firmware") # None, not provided + + # any other glom spec, e.g. the content of each comment + comments = get_series_info(series, ("comments", ["content"])) + # ["Gauge maintenance", "Sensor replaced"] + + # a key which itself contains dots requires a glom Path + operator = get_series_info(series, Path("operator.name")) # "Ruhrverband" + + ... + +For a MULTITSFRAME, ``get_metric_info(multitsframe, "sensor.manufacturer")`` provides the +manufacturer keyed by metric accordingly. + +The fallbacks described above apply to the whole path, e.g. ``"sensor.manufacturer"`` is looked +up in the metric first, then in its value dimension ``"value"`` and so on. A path which is not +present yields None instead of an error. Note that a plain string like ``"operator.name"`` is +always split at its dots, so it does not find the inherited property ``"operator.name"``. + +A spec may also transform the information, e.g. ``("name", str.upper)``. Since information +provided as null counts as missing, such a spec may receive null: on the metric itself and, if +the metric does not provide the information, on the levels it falls back to. Errors raised by +the spec are not caught. So either let it handle None, or rather transform the information +after retrieving it. + +.. _glom: https://glom.readthedocs.io/ + +Which time zone to compute in? +============================== + +Metadata may provide a ``default_timezone`` for each metric and for the dataset metadata: the +time zone the timeseries should be evaluated in by default. It belongs to the data, e.g. it is +the time zone of the plant a sensor belongs to, and it is meant for **computations**: +Components evaluating timeseries in calendar intervals should respect it. E.g. a component +aggregating daily values should bucket the days from midnight to midnight of this time zone, +so its results depend on it. + +It is **not** meant for displaying timestamps. That is what the time zone of the plot target +settings is for, which the hetida platform provides for the view, e.g. the time zone of a +dashboard or of the user looking at a plot. ``modify_timezone`` uses it if no ``to_timezone`` +is given. Both may differ: A report on a plant in Germany may aggregate days in +``"Europe/Berlin"`` and nevertheless show its timestamps to a viewer in New York in +``"America/New_York"``. + +``get_series_default_timezone``, ``get_singlets_default_timezone`` and +``get_default_timezones`` (keyed by metric) provide the default time zone of a metric. If the +metric does not provide one, they fall back to the one of the dataset metadata, which applies +to all metrics without their own. If neither provides one, they return None. The time zone is +an IANA time zone name like ``"Europe/Berlin"``, which respects daylight saving time, or a +fixed UTC offset like ``"+02:00"``. pandas accepts both. + +.. code-block:: python + + from hdhelpers.helpers import modify_timezone + from hdhelpers.metadata import get_series_default_timezone + + def main(*, series): + # entrypoint function for this component + # ***** DO NOT EDIT LINES ABOVE ***** + + # compute: each day starts at midnight of the default time zone of the data, + # e.g. at midnight in "Europe/Berlin", and has 23 or 25 hours when switching + # from or to daylight saving time + timezone = get_series_default_timezone(series) or "UTC" + daily_sums = modify_timezone(series, to_timezone=timezone).resample("1D").sum() + + # view: show the timestamps in the time zone of the plot target settings + daily_sums = modify_timezone(daily_sums) + + ... + +Note the explicit fallback ``or "UTC"`` in the example: Without it, ``modify_timezone`` would +use the time zone of the plot target settings if the metadata provide no default time zone, +so the results of the computation would depend on who looks at them. + +For a MULTITSFRAME, each metric may have its own default time zone: + +.. code-block:: python + + from hdhelpers.metadata import get_default_timezones + + def main(*, multitsframe): + # entrypoint function for this component + # ***** DO NOT EDIT LINES ABOVE ***** + + timezones = get_default_timezones(multitsframe) + daily_sums = { + metric: group.set_index("timestamp")["value"] + .tz_convert(timezones[metric] or "UTC") + .resample("1D") + .sum() + for metric, group in multitsframe.groupby("metric") + } + + ... How to use hdhelpers for plotting? (tbd) @@ -78,10 +261,10 @@ Our component code might look like this to plot the timeseries accordingly: fig = go.Figure([go.Scatter(x=series.index, y=series.values, marker={"color": resolve_color("ki.vision")})]) - start, end = get_queried_interval(series=series) + start, end = get_queried_interval(series) fig.update_xaxes(range=(start, end)) - full_title = get_series_name(series=series, default_title="Level") + full_title = get_series_name(series) or "Level" fig.update_layout(yaxis_title=full_title) set_dt_ticks(fig) diff --git a/sphinx/source/index.rst b/sphinx/source/index.rst index b6ca2c0..cf00ef9 100644 --- a/sphinx/source/index.rst +++ b/sphinx/source/index.rst @@ -14,12 +14,13 @@ hdhelpers is a package designed for and included in the standard installation of It contains functions that streamline plotting components, especially those that are used in the `hetida platform`_, by -* accessing series metadata that complies with the hetida platform metadata scheme, +* accessing metadata of series, singletsframes and multitsframes that complies with the `hetida designer metadata conventions`_, * aditional helper functions like adjusting the timezone of timestamps, series, and dataframes, * accessing metadata that the hetida platform writes into the hetida designer's *plot_target_settings* context variable (tbd), * providing toggleable standardized styling options and json serialization for plotly plots (tbd). .. _hetida designer: https://github.com/hetida/hetida-designer +.. _hetida designer metadata conventions: https://hetida.github.io/hetida-designer/user_guide/attached_metadata/ .. _hetida platform: https://hetida.io/ Further Information diff --git a/src/hdhelpers/metadata/__init__.py b/src/hdhelpers/metadata/__init__.py index 3b0a1b4..969d034 100644 --- a/src/hdhelpers/metadata/__init__.py +++ b/src/hdhelpers/metadata/__init__.py @@ -3,11 +3,20 @@ extract the requested information.""" from hdhelpers.metadata.helpers import ( + Recording, + get_default_aggregation_methods, + get_recordings, + get_series_default_aggregation_method, + get_series_recording, + get_singlets_default_aggregation_method, + get_singlets_recording, + get_default_timezones, get_display_names, get_measurements, get_metric_info, get_names, get_queried_interval, + get_series_default_timezone, get_series_display_name, get_series_info, get_series_measurement, @@ -15,6 +24,7 @@ get_series_short_display_name, get_series_unit, get_short_display_names, + get_singlets_default_timezone, get_singlets_display_names, get_singlets_info, get_singlets_measurements, @@ -23,6 +33,7 @@ get_singlets_short_display_names, get_singlets_units, get_units, + without_metadata, ) __all__ = [ @@ -46,4 +57,15 @@ "get_singlets_measurements", "get_singlets_metric_info", "get_singlets_units", + "get_default_timezones", + "get_series_default_timezone", + "get_singlets_default_timezone", + "Recording", + "get_recordings", + "get_series_recording", + "get_singlets_recording", + "get_default_aggregation_methods", + "get_series_default_aggregation_method", + "get_singlets_default_aggregation_method", + "without_metadata", ] diff --git a/src/hdhelpers/metadata/_current_convention.py b/src/hdhelpers/metadata/_current_convention.py new file mode 100644 index 0000000..3f589b5 --- /dev/null +++ b/src/hdhelpers/metadata/_current_convention.py @@ -0,0 +1,223 @@ +"""Extraction of metadata following the current metadata convention + +The metadata is gathered in a single pass in plain Python: the object of each metric with its +value dimensions by column, and the value dimensions shared by all metrics +("value_dimensions_shared"). The fallback chains are resolved on these objects, applying the +spec of the requested information only to the objects of the levels actually needed. Field +names and dotted paths of them are looked up without glom, since evaluating glom specs costs a +lot of time per object, which adds up for frames with many metrics. + +Entries not adhering to the conventions are skipped: metrics which are no objects or whose +field named by the metric key is no string, and value dimensions which are no objects or whose +column is no string. "value_dimensions" and "value_dimensions_shared" which are no lists count +as empty, a metric key which is no string as missing, i.e. the default "id" is used. + +Older metadata structures are handled by the glom specs in _specs. +""" + +from collections import defaultdict +from collections.abc import Callable, Sequence +from dataclasses import dataclass, replace +from typing import Any + +from glom import Coalesce, Spec, glom + +from hdhelpers.metadata._specs import INHERITED + +Getter = Callable[[Any], Any] + +_MISSING = object() +_DEFAULT_COALESCE_SKIP = Coalesce("any").skip + + +@dataclass(frozen=True) +class GatheredMetric: + metric: dict + value_dimensions: dict[str, dict] # by column + + +@dataclass(frozen=True) +class GatheredMetadata: + metrics: dict[str, GatheredMetric] # by value of the field named by the metric key + value_dimensions_shared: dict[str, dict] # by column + + +def follows_current_convention(attrs: Any) -> bool: + """Whether metadata follows the current metadata convention, i.e. has a list of metrics""" + return isinstance(attrs, dict) and isinstance(attrs.get("metrics"), list) + + +def _metric_key(attrs: dict) -> str: + dataset_metadata = attrs.get("dataset_metadata") + metric_key = dataset_metadata.get("metric_key") if isinstance(dataset_metadata, dict) else None + return metric_key if isinstance(metric_key, str) else "id" + + +def _by_column(value_dimensions: Any) -> dict[str, dict]: + """Value dimension objects by column, the last one winning, skipping malformed ones""" + if not isinstance(value_dimensions, list): + return {} + return { + value_dimension["column"]: value_dimension + for value_dimension in value_dimensions + if isinstance(value_dimension, dict) and isinstance(value_dimension.get("column"), str) + } + + +def gather(attrs: Any) -> GatheredMetadata | None: + """Gather the objects of all levels, None if not following the current convention""" + if not follows_current_convention(attrs): + return None + + metric_key = _metric_key(attrs) + return GatheredMetadata( + metrics={ + metric[metric_key]: GatheredMetric(metric, _by_column(metric.get("value_dimensions"))) + for metric in attrs["metrics"] + if isinstance(metric, dict) and isinstance(metric.get(metric_key), str) + }, + value_dimensions_shared=_by_column(attrs.get("value_dimensions_shared")), + ) + + +def restricted_to(gathered: GatheredMetadata, metric: str) -> GatheredMetadata: + """The gathered metadata of one of the metrics only, so that only its information is resolved""" + return replace(gathered, metrics={metric: gathered.metrics[metric]}) + + +def _simple_path(spec: Any) -> tuple[str, ...] | None: + """The keys a spec looks up one after another, if it is just a path of dict keys + + glom splits string paths at dots, and only interprets stars besides. + """ + if isinstance(spec, str) and "*" not in spec and "\\" not in spec: + return tuple(spec.split(".")) + if ( + isinstance(spec, Coalesce) + and len(spec.subspecs) == 1 + and spec.default is None + and spec.skip is _DEFAULT_COALESCE_SKIP + ): + return _simple_path(spec.subspecs[0]) + return None + + +def getter(spec: str | Spec) -> Getter: + """Apply spec as Coalesce(spec, default=None), without glom for paths of dict keys""" + generic_spec = Coalesce(spec, default=None) + + def generic(obj: Any) -> Any: + return glom(obj, generic_spec) + + path = _simple_path(spec) + if path is None: + return generic + + def by_path(obj: Any) -> Any: + # glom accesses keys of dicts via item access only, so a missing key yields None. + # Other targets, e.g. lists accessed via indices, are left to glom. + target = obj + for key in path: + if type(target) is not dict: + return generic(obj) + if key not in target: + return None + target = target[key] + return target + + return by_path + + +def _inherited_info(metric: dict, get: Getter) -> Any: + # the spec is not applied if the metric does not provide inherited properties at all + return get(metric[INHERITED]) if INHERITED in metric else None + + +def value_dimension_info( + gathered: GatheredMetadata, specs: Sequence[str | Spec], include_inherited: bool +) -> defaultdict[str, defaultdict[str, Any]]: + """Information per value dimension per metric, falling back to further specs + + The value dimensions of a metric are its own ones, "value" and the shared ones. For each + of them, the first information which is not None is used: Each spec is applied on + + 1. the value dimension itself, + 2. only for the value dimension "value": the metric, + 3. "value_dimensions_shared" + + before the next spec is tried. If include_inherited, the properties the metric inherits + are used as last resort for the value dimension "value", again trying each spec in turn. + """ + getters = [getter(spec) for spec in specs] + shared_info_cache: list[dict[str, Any]] = [{} for _ in getters] + + def shared_info(index: int, column: str) -> Any: + cache = shared_info_cache[index] + if column not in cache: + shared = gathered.value_dimensions_shared.get(column) + cache[column] = None if shared is None else getters[index](shared) + return cache[column] + + result: defaultdict[str, defaultdict[str, Any]] = defaultdict(lambda: defaultdict(lambda: None)) + for metric, gathered_metric in gathered.metrics.items(): + info_by_column: defaultdict[str, Any] = defaultdict(lambda: None) + columns = dict.fromkeys( + [*gathered.value_dimensions_shared, "value", *gathered_metric.value_dimensions] + ) + for column in columns: + value_dimension = gathered_metric.value_dimensions.get(column) + info = None + for index, get in enumerate(getters): + info = get(value_dimension) if value_dimension is not None else None + if info is None and column == "value": + info = get(gathered_metric.metric) + if info is None: + info = shared_info(index, column) + if info is not None: + break + if info is None and include_inherited and column == "value": + for get in getters: + info = _inherited_info(gathered_metric.metric, get) + if info is not None: + break + info_by_column[column] = info + result[metric] = info_by_column + return result + + +def metric_info( + gathered: GatheredMetadata, spec: str | Spec, include_inherited: bool +) -> dict[str, Any]: + """Information per metric, the first one which is not None of + + 1. the metric itself, + 2. its value dimension "value", + 3. the value dimension "value" in "value_dimensions_shared", + 4. if include_inherited, the properties the metric inherits. + """ + get = getter(spec) + shared = gathered.value_dimensions_shared.get("value") + shared_info: Any = _MISSING + + result: dict[str, Any] = {} + for metric, gathered_metric in gathered.metrics.items(): + info = get(gathered_metric.metric) + if info is None: + value_dimension = gathered_metric.value_dimensions.get("value") + info = get(value_dimension) if value_dimension is not None else None + if info is None and shared is not None: + if shared_info is _MISSING: + shared_info = get(shared) + info = shared_info + if info is None and include_inherited: + info = _inherited_info(gathered_metric.metric, get) + result[metric] = info + return result + + +def own_metric_info(gathered: GatheredMetadata, key: str) -> dict[str, Any]: + """A field of each metric itself, without falling back to other levels""" + return { + metric: gathered_metric.metric.get(key) + for metric, gathered_metric in gathered.metrics.items() + } diff --git a/src/hdhelpers/metadata/_helpers.py b/src/hdhelpers/metadata/_helpers.py index 0120981..7af114d 100644 --- a/src/hdhelpers/metadata/_helpers.py +++ b/src/hdhelpers/metadata/_helpers.py @@ -1,9 +1,11 @@ from collections import defaultdict -from typing import Any +from collections.abc import Callable, Collection +from typing import Any, cast import pandas as pd -from glom import Check, Coalesce, GlomError, Spec, glom +from glom import Check, Coalesce, CoalesceError, Spec, glom +import hdhelpers.metadata._current_convention as _current_convention import hdhelpers.metadata._specs as _specs @@ -32,8 +34,20 @@ def extract_from_metadata(metadata: Any, key: str, default: str | None = None) - return glom(metadata, Coalesce(f"dataset_metadata.{key}", default=default)) +def _legacy_value_dimension_info( + attrs: Any, value_dim_info: str | Spec +) -> defaultdict[str, defaultdict[str, Any]]: + """get_value_dimension_info for older metadata structures""" + return defaultdict( + lambda: defaultdict(lambda: None), + glom(attrs, _specs.by_metric_key_by_val_dimension(value_dim_info)), + ) + + def get_value_dimension_info( - multitsframe: pd.DataFrame | pd.Series, value_dim_info: str | Spec + multitsframe: pd.DataFrame | pd.Series, + value_dim_info: str | Spec, + include_inherited: bool = False, ) -> defaultdict[str, defaultdict[str, Any]]: """Obtain metadata info associated to the value dimensions of the metrics @@ -51,40 +65,113 @@ def get_value_dimension_info( dimension in the value_dimensions list under the metric, the global "value_dimensions_shared" field of the attrs object is searched for corresponding information. + If include_inherited, the properties a metric inherits are used as last resort for the + "value" value dimension, i.e. after "value_dimensions_shared". + If no information is found, None is set as value and is the default value of the inner default dict. + For metadata following the current metadata convention, value_dim_info is only applied to + the levels needed and entries not adhering to the conventions are skipped, see + _current_convention. + For examples we refer to the corresponding unit tests (/tests/helpers/test_metadata.py). """ - spec = _specs.by_metric_key_by_val_dimension(value_dim_info) - value_dimension_info_by_metric_by_value_dimension = glom(multitsframe.attrs, spec) - return defaultdict( - lambda: defaultdict(lambda: None), value_dimension_info_by_metric_by_value_dimension - ) + gathered = _current_convention.gather(multitsframe.attrs) + if gathered is not None: + return _current_convention.value_dimension_info( + gathered, [value_dim_info], include_inherited + ) + return _legacy_value_dimension_info(multitsframe.attrs, value_dim_info) + + +def value_dimension_info_with_fallbacks( + data: pd.DataFrame | pd.Series, + fields: tuple[str | Spec, ...], + legacy_spec: Spec, + include_inherited: bool = False, +) -> defaultdict[str, defaultdict[str, Any]]: + """Obtain a field of the value dimensions, falling back to further fields + + For metadata following the current metadata convention, each field is looked up on all + levels described in get_value_dimension_info (the value dimension, the metric for the + value dimension "value" and "value_dimensions_shared") before the next field is tried. + So e.g. a display name of the metric wins over the name of its value dimension. Fields + provided as null are treated as missing. Inherited properties are only used if none of the + fields is provided by the metadata itself. + + Older metadata structures are handled via legacy_spec, keeping their established behaviour. + """ + gathered = _current_convention.gather(data.attrs) + if gathered is None: + return _legacy_value_dimension_info(data.attrs, legacy_spec) + return _current_convention.value_dimension_info(gathered, fields, include_inherited) _SINGLE_METRIC_KEY = "single_metric" +def _single_metric_name(attrs: Any) -> str | None: + """The metric named by _SINGLE_METRIC_KEY in "dataset_metadata", if it is a string""" + metric = extract_from_metadata(attrs, key=_SINGLE_METRIC_KEY) + return metric if isinstance(metric, str) else None + + +def _single_metric(attrs: Any, metrics: Collection[str]) -> str | None: + """The single metric of a SingleTSFrame or Series among the metrics present + + The single metric is identified via _SINGLE_METRIC_KEY in "dataset_metadata", values which + are no strings count as missing. If it is missing but exactly one metric is present, that + one is used, since a SingleTSFrame or Series cannot be ambiguous in this respect. Otherwise + None is returned - also if the identified metric is not present, since the only metric + present is another one then. + """ + metric = _single_metric_name(attrs) + if metric is not None: + return metric if metric in metrics else None + + if len(metrics) == 1: + return next(iter(metrics)) + + return None + + def _select_single_metric(info_by_metric: dict, attrs: Any, empty: Any) -> Any: - """Pick the entry of the one metric of a SingleTSFrame out of a by-metric mapping + """Pick the entry of the single metric (see _single_metric) out of a by-metric mapping - The single metric is identified via _SINGLE_METRIC_KEY in "dataset_metadata". If that is - missing but exactly one metric is present, that one is used, since a SingleTSFrame cannot - be ambiguous in this respect. Otherwise `empty` is returned. + `empty` is returned if the single metric cannot be determined. """ - metric = extract_from_metadata(attrs, key=_SINGLE_METRIC_KEY) - if metric is not None and metric in info_by_metric: - return info_by_metric[metric] + metric = _single_metric(attrs, info_by_metric) + return empty if metric is None else info_by_metric[metric] - if len(info_by_metric) == 1: - return next(iter(info_by_metric.values())) - return empty +def _single_metric_info( + attrs: Any, + current: Callable[[_current_convention.GatheredMetadata], dict[str, Any]], + legacy: Callable[[], dict[str, Any]], + empty: Any, +) -> Any: + """Information of the single metric of a SingleTSFrame or Series (see _single_metric) + + For metadata following the current metadata convention, `current` gets the gathered metadata + of the single metric only, so that no information of the other metrics is resolved - of + which there may be many, e.g. for a Series taken from a MultiTSFrame. For older metadata + structures, the entry of the single metric is picked out of the result of `legacy`. + + `empty` is returned if the single metric cannot be determined. + """ + gathered = _current_convention.gather(attrs) + if gathered is None: + return _select_single_metric(legacy(), attrs, empty) + + metric = _single_metric(attrs, gathered.metrics) + if metric is None: + return empty + return current(_current_convention.restricted_to(gathered, metric))[metric] def singlets_value_dimension_info( - singletsframe: pd.DataFrame, value_dim_info: str | Spec + singletsframe: pd.DataFrame, value_dim_info: str | Spec, include_inherited: bool = False ) -> defaultdict[str, Any]: """Metadata info of the value dimensions of the single metric of a SingleTSFrame @@ -99,14 +186,93 @@ def singlets_value_dimension_info( What is extracted per value dimension is exactly what get_value_dimension_info extracts - only the metric level is collapsed away. """ - return _select_single_metric( - get_value_dimension_info(singletsframe, value_dim_info), + return singlets_value_dimension_info_with_fallbacks( + singletsframe, (value_dim_info,), value_dim_info, include_inherited + ) + + +def singlets_value_dimension_info_with_fallbacks( + singletsframe: pd.DataFrame, + fields: tuple[str | Spec, ...], + legacy_spec: Spec, + include_inherited: bool = False, +) -> defaultdict[str, Any]: + """Counterpart of value_dimension_info_with_fallbacks for SingleTSFrames + + Like singlets_value_dimension_info, the result is keyed by value dimension only. + """ + return _single_metric_info( singletsframe.attrs, + lambda gathered: _current_convention.value_dimension_info( + gathered, fields, include_inherited + ), + lambda: _legacy_value_dimension_info(singletsframe.attrs, legacy_spec), empty=defaultdict(lambda: None), ) -def singlets_metric_info(singletsframe: pd.DataFrame, metric_info: str | Spec) -> Any: +# the metric of a Series in older metadata structures, used if "single_metric" is missing +_LEGACY_SERIES_METRIC = "series" + + +def series_value_info( + series: pd.Series, fields: tuple[str | Spec, ...], legacy_spec: Spec, include_inherited: bool +) -> Any: + """Info of the value dimension "value" of the single metric of a Series + + Each field is looked up as described in value_dimension_info_with_fallbacks. The single + metric is determined like for SingleTSFrames, see singlets_value_dimension_info. Only older + metadata structures name the metric of a Series "series", which is used if "single_metric" + is missing. + """ + + def legacy() -> dict[str, Any]: + info_by_metric = _legacy_value_dimension_info(series.attrs, legacy_spec) + if _single_metric_name(series.attrs) is None and _LEGACY_SERIES_METRIC in info_by_metric: + return {_LEGACY_SERIES_METRIC: info_by_metric[_LEGACY_SERIES_METRIC]} + return info_by_metric + + return _single_metric_info( + series.attrs, + lambda gathered: _current_convention.value_dimension_info( + gathered, fields, include_inherited + ), + legacy, + empty={}, + ).get("value") + + +def _legacy_metric_info(attrs: Any, metric_info: str | Spec) -> dict[str, Any]: + """metric_info_by_metric for older metadata structures""" + try: + return glom(attrs, _specs.spec_by_metric_key(metric_info)) # type: ignore[no-any-return] + except CoalesceError: # no metric metadata present at all + return {} + + +def metric_info_by_metric( + data: pd.DataFrame | pd.Series, metric_info: str | Spec, include_inherited: bool = True +) -> dict[str, Any]: + """Obtain metadata info associated to the metrics, keyed by metric + + For metadata following the current metadata convention, information the metric itself does + not provide is taken from its value dimension "value", then from "value_dimensions_shared" + and, if include_inherited, finally from the properties the metric inherits. Information + provided as null counts as missing. metric_info is only applied to the levels needed and + entries not adhering to the conventions are skipped, see _current_convention. + + Returns an empty dict if no metric metadata is present at all. Errors raised by applying + metric_info, e.g. a transformation failing on null, are not caught. + """ + gathered = _current_convention.gather(data.attrs) + if gathered is not None: + return _current_convention.metric_info(gathered, metric_info, include_inherited) + return _legacy_metric_info(data.attrs, metric_info) + + +def singlets_metric_info( + singletsframe: pd.DataFrame, metric_info: str | Spec, include_inherited: bool = True +) -> Any: """Metadata of the single metric of a SingleTSFrame Counterpart of get_metric_info for SingleTSFrames: instead of a mapping keyed by metric @@ -114,9 +280,64 @@ def singlets_metric_info(singletsframe: pd.DataFrame, metric_info: str | Spec) - be determined - which includes the case of absent metric metadata, since components should not require metadata (see the metadata conventions documentation). """ - try: - info_by_metric = glom(singletsframe.attrs, _specs.spec_by_metric_key(metric_info)) - except GlomError: # no metric metadata present at all - return None + return _single_metric_info( + singletsframe.attrs, + lambda gathered: _current_convention.metric_info(gathered, metric_info, include_inherited), + lambda: _legacy_metric_info(singletsframe.attrs, metric_info), + empty=None, + ) + - return _select_single_metric(info_by_metric, singletsframe.attrs, empty=None) +def _string_or_none(value: Any) -> str | None: + return value if isinstance(value, str) and value != "" else None + + +def _dataset_default_timezone(attrs: Any) -> str | None: + return _string_or_none(extract_from_metadata(attrs, key=_specs.DEFAULT_TIMEZONE)) + + +def _default_timezones( + gathered: _current_convention.GatheredMetadata | None, dataset_timezone: str | None +) -> defaultdict[str, str | None]: + own_timezones = ( + {} + if gathered is None + else _current_convention.own_metric_info(gathered, _specs.DEFAULT_TIMEZONE) + ) + return defaultdict( + lambda: dataset_timezone, + { + metric: _string_or_none(timezone) or dataset_timezone + for metric, timezone in own_timezones.items() + }, + ) + + +def default_timezone_by_metric(data: pd.DataFrame | pd.Series) -> defaultdict[str, str | None]: + """The default time zone of each metric, falling back to the one of the dataset metadata + + The default time zone of a metric only falls back to the one of the dataset metadata, not to + its value dimensions or inherited properties. + The default of the returned defaultdict is the one of the dataset metadata, since it applies + to all metrics without their own. Values which are no (non-empty) strings count as missing. + """ + return _default_timezones( + _current_convention.gather(data.attrs), _dataset_default_timezone(data.attrs) + ) + + +def single_metric_default_timezone(data: pd.DataFrame | pd.Series) -> str | None: + """The default time zone of the single metric of a Series or SingleTSFrame + + Falls back to the one of the dataset metadata, also if the single metric is not found. + """ + dataset_timezone = _dataset_default_timezone(data.attrs) + return cast( + str | None, + _single_metric_info( + data.attrs, + lambda gathered: _default_timezones(gathered, dataset_timezone), + dict, # older metadata structures provide no default time zones of the metrics + empty=dataset_timezone, + ), + ) diff --git a/src/hdhelpers/metadata/_recording.py b/src/hdhelpers/metadata/_recording.py new file mode 100644 index 0000000..5ae2dc3 --- /dev/null +++ b/src/hdhelpers/metadata/_recording.py @@ -0,0 +1,169 @@ +"""How the values of the metrics are recorded and how they should be aggregated by default + +The frequency of the recorded values of a metric falls back as a whole: If the metric has a +"data_frequency", it applies together with the metric's "data_frequency_offset" and +"data_frequency_timezone". Otherwise "ref_data_frequency" of the dataset metadata applies +together with "ref_data_frequency_offset" and "ref_data_frequency_timezone". +""" + +from collections import defaultdict +from dataclasses import dataclass +from typing import Any, cast + +import pandas as pd + +import hdhelpers.metadata._current_convention as _current_convention +from hdhelpers.metadata._helpers import _select_single_metric, _string_or_none + +_FREQUENCY_FIELDS = ("data_frequency", "data_frequency_offset", "data_frequency_timezone") +_DATASET_FREQUENCY_FIELDS = ( + "ref_data_frequency", + "ref_data_frequency_offset", + "ref_data_frequency_timezone", +) + +# default aggregation method of metrics without one by the data type of their values +_DEFAULT_AGGREGATION_METHOD_BY_VALUE_DATA_TYPE = {"boolean": "LAST", "string": "LAST"} +_DEFAULT_AGGREGATION_METHOD = "MEAN" +_AGGREGATION_METHOD_ALIASES = {"AVG": "MEAN"} + + +@dataclass(frozen=True) +class Recording: + """How the values of a metric are recorded, as given in its metadata + + The fields mean the following: + + Attributes: + recording_mode (str | None): "periodic": one value per interval of data_frequency, timestamped with the start of the interval. A missing value is a gap. "on_change": a value is recorded when it changes by more than the deadband of its value dimension and holds until the next one. Missing timestamps are no gaps. "irregular": values at arbitrary points in time. None if unknown. + data_frequency (str | None): Frequency as Pandas date offset alias or timedelta string, e.g. "15min", "1D" or "1MS". For "on_change", the interval after which the value is recorded again at the latest, also without a change (heartbeat). + data_frequency_offset (str | None): Timedelta string by which the intervals of data_frequency are shifted, e.g. "5min". None means 0. + data_frequency_timezone (str | None): Time zone the intervals of data_frequency are aligned to, e.g. "Europe/Berlin". None means UTC. + recording_aggregation_method (str | None): Only for "periodic": the method each value aggregates its interval with, e.g. "MEAN" or "MAX". None if the values are samples, e.g. the value holding at the timestamp. + + data_frequency_offset and data_frequency_timezone belong to data_frequency: They are None + if no data_frequency applies. + """ + + recording_mode: str | None = None + data_frequency: str | None = None + data_frequency_offset: str | None = None + data_frequency_timezone: str | None = None + recording_aggregation_method: str | None = None + + +_Frequency = tuple[str | None, str | None, str | None] +_NO_FREQUENCY: _Frequency = (None, None, None) + + +def _frequency(obj: Any, fields: tuple[str, str, str]) -> _Frequency: + """Frequency, offset and time zone of obj, all None if it has no frequency + + Values which are no (non-empty) strings count as missing. + """ + if not isinstance(obj, dict): + return _NO_FREQUENCY + frequency, offset, timezone = (_string_or_none(obj.get(field)) for field in fields) + if frequency is None: + return _NO_FREQUENCY + return frequency, offset, timezone + + +def _dataset_frequency(attrs: Any) -> _Frequency: + dataset_metadata = attrs.get("dataset_metadata") if isinstance(attrs, dict) else None + return _frequency(dataset_metadata, _DATASET_FREQUENCY_FIELDS) + + +def _recording(metric: dict, dataset_frequency: _Frequency) -> Recording: + own_frequency = _frequency(metric, _FREQUENCY_FIELDS) + frequency, offset, timezone = ( + own_frequency if own_frequency[0] is not None else dataset_frequency + ) + return Recording( + recording_mode=_string_or_none(metric.get("recording_mode")), + data_frequency=frequency, + data_frequency_offset=offset, + data_frequency_timezone=timezone, + recording_aggregation_method=_string_or_none(metric.get("recording_aggregation_method")), + ) + + +def recording_by_metric(data: pd.DataFrame | pd.Series) -> defaultdict[str, Recording]: + """The recording of each metric, falling back to the frequency of the dataset metadata + + The default of the returned defaultdict is the recording of a metric without metadata, i.e. + with the frequency of the dataset metadata. + """ + dataset_frequency = _dataset_frequency(data.attrs) + gathered = _current_convention.gather(data.attrs) + metrics = ( + {} + if gathered is None + else { + metric: gathered_metric.metric for metric, gathered_metric in gathered.metrics.items() + } + ) + return defaultdict( + lambda: Recording(None, *dataset_frequency, None), + {metric: _recording(obj, dataset_frequency) for metric, obj in metrics.items()}, + ) + + +def single_metric_recording(data: pd.DataFrame | pd.Series) -> Recording: + """The recording of the single metric of a Series or SingleTSFrame + + Falls back to the frequency of the dataset metadata, also if the single metric is not found. + """ + return cast( + Recording, + _select_single_metric( + recording_by_metric(data), + data.attrs, + empty=Recording(None, *_dataset_frequency(data.attrs), None), + ), + ) + + +def _default_aggregation_method(method: Any, value_data_type: Any) -> str: + own_method = _string_or_none(method) + if own_method is not None: + return _AGGREGATION_METHOD_ALIASES.get(own_method, own_method) + return _DEFAULT_AGGREGATION_METHOD_BY_VALUE_DATA_TYPE.get( + value_data_type, _DEFAULT_AGGREGATION_METHOD + ) + + +def default_aggregation_method_by_metric(data: pd.DataFrame | pd.Series) -> defaultdict[str, str]: + """The default aggregation method of each metric + + The method of the metric, with "AVG" normalized to its alias "MEAN". If the metric does not + provide one, "LAST" for values of the data type "boolean" or "string" (of the value + dimension "value", see value_dimension_info) and "MEAN" otherwise, which is also the default + of the returned defaultdict. + """ + gathered = _current_convention.gather(data.attrs) + if gathered is None: + return defaultdict(lambda: _DEFAULT_AGGREGATION_METHOD) + methods = _current_convention.own_metric_info(gathered, "default_aggregation_method") + value_data_types = _current_convention.value_dimension_info( + gathered, ["value_data_type"], include_inherited=False + ) + return defaultdict( + lambda: _DEFAULT_AGGREGATION_METHOD, + { + metric: _default_aggregation_method(method, value_data_types[metric]["value"]) + for metric, method in methods.items() + }, + ) + + +def single_metric_default_aggregation_method(data: pd.DataFrame | pd.Series) -> str: + """The default aggregation method of the single metric of a Series or SingleTSFrame""" + return cast( + str, + _select_single_metric( + default_aggregation_method_by_metric(data), + data.attrs, + empty=_DEFAULT_AGGREGATION_METHOD, + ), + ) diff --git a/src/hdhelpers/metadata/_specs.py b/src/hdhelpers/metadata/_specs.py index e5749bf..021a638 100644 --- a/src/hdhelpers/metadata/_specs.py +++ b/src/hdhelpers/metadata/_specs.py @@ -1,7 +1,4 @@ -from collections import defaultdict -from collections.abc import Callable - -from glom import A, Check, Coalesce, Iter, Merge, S, Spec, T +from glom import Check, Coalesce, Iter, Merge, Spec, T # The metadatum specs of the individual accessors, including their fallback chains. # Defined once here and shared by the MultiTSFrame and the SingleTSFrame accessors, which @@ -9,12 +6,30 @@ UNIT: Spec = "unit" MEASUREMENT: Spec = "measurement" NAME: Spec = Coalesce("name", default=None) -DISPLAY_NAME: Spec = Coalesce("display_name", "name", default=None) -SHORT_DISPLAY_NAME: Spec = Coalesce("short_display_name", "display_name", "name", default=None) + +# The fallback chains of the display names. For metadata following the current metadata +# convention, each field is looked up on all levels (value dimension, metric, shared value +# dimensions) before the next field is tried, see _current_convention.value_dimension_info. +# DISPLAY_NAME and SHORT_DISPLAY_NAME are the corresponding specs for older metadata structures. +DISPLAY_NAME_FIELDS = ("display_name", "name") +SHORT_DISPLAY_NAME_FIELDS = ("short_display_name", *DISPLAY_NAME_FIELDS) +DISPLAY_NAME: Spec = Coalesce(*DISPLAY_NAME_FIELDS, default=None) +SHORT_DISPLAY_NAME: Spec = Coalesce(*SHORT_DISPLAY_NAME_FIELDS, default=None) + +# Key of the dict in a metric object holding the properties the metric inherits from objects +# higher up in an asset hierarchy, e.g. the latitude of the plant a sensor belongs to. +INHERITED = "inherited" + +# Field of a metric and of the dataset metadata: the time zone the timeseries should be +# evaluated in by default, e.g. for bucketing days when aggregating. +DEFAULT_TIMEZONE = "default_timezone" def by_metric_key_by_val_dimension(metadatum_key: str | Spec) -> Spec: - """Providesglom spec that extracts a metadatum by metric by value dimension + """Provides glom spec that extracts a metadatum by metric by value dimension + + Only for older metadata structures, metadata following the current metadata convention is + handled by _current_convention. The generated glom spec returns a defaultdict of defaultdicts: {metric_key: {value_dimension_column_name: metadatum_value}} @@ -30,7 +45,6 @@ def by_metric_key_by_val_dimension(metadatum_key: str | Spec) -> Spec: metadatum_key can also be any glom spec. """ return Coalesce( - _spec_new_convention(metadatum_key), _spec_platform_convention_series_metric(metadatum_key), _spec_platform_convention_series_value_dim(metadatum_key), _spec_older_convention1_metric(metadatum_key), @@ -41,111 +55,6 @@ def by_metric_key_by_val_dimension(metadatum_key: str | Spec) -> Spec: ) -def _spec_metric_key() -> Spec: - """Which key of the metric objects identifies a metric - - Per the metadata conventions "metric_key" is optional and defaults to "id". Note that - it therefore cannot act as the discriminator between the current and the older metadata - conventions - that role belongs to "metrics" being a list, see - _spec_actual_per_metric_per_value_dimensions. - """ - return (Coalesce("dataset_metadata.metric_key", default="id"), A.globals.metric_key) - - -def _spec_defaults_by_value_dimension(metadatum_key: str | Spec) -> Spec: - return Coalesce( - ( - Coalesce("value_dimensions_shared", default=[]), - Check(instance_of=list), - _build_dict_from_iterable_from_key_and_subspec_and_then_proceed_on_result( - "column", Coalesce(metadatum_key, default=None) - ), - ), - default={}, - ) - - -def _spec_defaults_by_metric(metadatum_key: str | Spec) -> Spec: - return Coalesce( - ( - "metrics", - Check(instance_of=list), - _build_dict_from_iterable_from_key_and_subspec_and_then_proceed_on_result( - S.globals.metric_key, - Coalesce(metadatum_key, default=None), - key_as_value=True, - ), - ), - default={}, - ) - - -def _spec_actual_per_metric_per_value_dimensions(metadatum_key: str | Spec) -> Spec: - """Deliberately without a default: this is what discriminates the metadata conventions - - If "metrics" is absent or is not a list, this spec fails, which makes the enclosing - _spec_new_convention fail, so that by_metric_key_by_val_dimension falls through to the - older conventions. - """ - return ( - "metrics", - Check(instance_of=list), - _build_dict_from_iterable_from_key_and_subspec_and_then_proceed_on_result( - S.globals.metric_key, - ( - Coalesce("value_dimensions", default={}), - _build_dict_from_iterable_from_key_and_subspec_and_then_proceed_on_result( - "column", - Coalesce(metadatum_key, default=None), - add_keys_with_none_values=["value"], - ), - ), - key_as_value=True, - ), - ) - - -def _spec_new_convention(metadatum_key: str | Spec) -> Spec: - return ( - { # first gather information at different locations in metadata in a dict - "metric_key": _spec_metric_key(), - "defaults_by_value_dimension": _spec_defaults_by_value_dimension(metadatum_key), - "defaults_by_metric": _spec_defaults_by_metric(metadatum_key), - "actual_per_metric_per_value_dimensions": _spec_actual_per_metric_per_value_dimensions( - metadatum_key - ), - }, - lambda x: defaultdict( - lambda: defaultdict(lambda: None, {}), - { # combine dicts with gathered information / falling back to defaults - metric: defaultdict( - lambda: None, - { - value_dim: info - if info is not None # prio 1: use, if explicitely provided - else ( - x["defaults_by_metric"][metric] - if ( - value_dim == "value" - and x["defaults_by_metric"].get(metric) is not None - ) # prio 2: only for "value" value dim: possibly use from metric - else ( - x["defaults_by_value_dimension"].get( - value_dim, None - ) # prio 3: from global "value_dimensions_shared" - ) - ) - for value_dim, info in _update_dict_and_return_it( - x["defaults_by_value_dimension"].copy(), info_by_val_dim - ).items() - }, - ) - for metric, info_by_val_dim in x["actual_per_metric_per_value_dimensions"].items() - }, - ), - ) - - def _spec_older_convention1_value_dim(metadatum_key: str | Spec) -> Spec: return ( "by_metric", @@ -215,22 +124,12 @@ def _spec_older_convention4(metadatum_key: str | Spec) -> Spec: def spec_by_metric_key(metadatum_key: str | Spec) -> Spec: + """Provides glom spec that extracts a metadatum from the metric objects, keyed by metric + + Only for older metadata structures, metadata following the current metadata convention is + handled by _current_convention. + """ return Coalesce( - ( # current metdadata convention - { - "metric_key": _spec_metric_key(), - "by_metric": ( - "metrics", - Check(instance_of=list), - _build_dict_from_iterable_from_key_and_subspec_and_then_proceed_on_result( - S.globals.metric_key, - Coalesce(metadatum_key, default=None), - key_as_value=True, - ), - ), - }, - lambda x: defaultdict(lambda: None, x["by_metric"]), - ), ( # some older, simpler metadata structure "by_metric", _glom_dict_with_keys_of_current_dict_and_values_something_deeper_nested( @@ -246,64 +145,8 @@ def spec_by_metric_key(metadatum_key: str | Spec) -> Spec: ) -def _build_dict_from_iterable_from_key_and_subspec_and_then_proceed_on_result( - key_spec: Spec, - value_spec: Spec, - add_keys_with_none_values: list[str] | None = None, - default_dict_func: Callable | None = None, - continuation_spec: Spec | None = None, - key_as_value: bool = False, -) -> Spec: - """Build dict from an iterable - - Spec to convert an iterable of objects to a dict using one of their fields - (or something deeper) as keys and something else as values. - - The key something and the value something can be arbitrary specs that are applyable - on each item. - - The resulting glom spec first produces a dictionary which keys being extracted from - each element of the iterable using key_spec and values using value_spec. - - If given it then proceeds on the resulting object using the continuation_spec. - - add_keys_with_none_values allows to add keys even if they do not occur - with a default value of None. - - .. doctest:: - - >>> from hdhelpers.metadata._specs import _build_dict_from_iterable_from_key_and_subspec_and_then_proceed_on_result - >>> from glom import glom - >>> data = { - ... "some": [ - ... {"id": "42", "name": "some_name", "sub": {"unit": "l"}}, - ... {"id": "53", "name": "another", "sub": {"unit": "m"}},] - ... } - >>> result = glom(data, ("some", _build_dict_from_iterable_from_key_and_subspec_and_then_proceed_on_result("id", "sub.unit"))) - >>> result["42"] - 'l' - >>> result["53"] - 'm' - """ - - if add_keys_with_none_values is None: - add_keys_with_none_values = [] - - start_dict = dict.fromkeys(add_keys_with_none_values, None) - - if default_dict_func is not None: - start_dict = defaultdict(default_dict_func, start_dict) - - return ( - [{"key": key_spec if not key_as_value else T[key_spec], "value": value_spec}], - [lambda x: (x.get("key"), x.get("value"))], - dict, - lambda x: _update_dict_and_return_it(start_dict.copy(), x), - ) + ((continuation_spec,) if continuation_spec is not None else ()) - - def _glom_dict_with_keys_of_current_dict_and_values_something_deeper_nested( - deeper_glom_spec: Spec, add_keys_with_none_values: list[str] | None = None + deeper_glom_spec: Spec, ) -> Spec: """Create dicts with keys from current dict and values from deeper in their value objects @@ -312,9 +155,6 @@ def _glom_dict_with_keys_of_current_dict_and_values_something_deeper_nested( deeper_glom_spec is the spec to get to the deeper values in each value object. - add_keys_with_none_values allows to add keys even if they do not occur - with a default value of None. - .. doctest:: >>> from hdhelpers.metadata._specs import _glom_dict_with_keys_of_current_dict_and_values_something_deeper_nested @@ -353,23 +193,9 @@ def _glom_dict_with_keys_of_current_dict_and_values_something_deeper_nested( ... ) {'item1': 'kg', 'item2': 'meters', 'item3': 'liters'} """ - if add_keys_with_none_values is None: - add_keys_with_none_values = [] - - start_dict = dict.fromkeys(add_keys_with_none_values, None) - + # T is a stand-in for the target: everything done to T is recorded and executed by glom return ( T.items(), # treat it as list of (key, value) tuples Iter({T[0]: (T[1], deeper_glom_spec)}), Merge(), - lambda x: _update_dict_and_return_it(start_dict.copy(), x), ) - - -# info on T: Basically, think of T as your data’s stunt double. Everything that you do to T will be recorded and executed during the glom() call. -# info to S: On its surface, the glom scope is a dictionary of extra values that can be passed in to the top-level glom call. These values can then be addressed with the S object, which behaves similarly to the T object. -# info on A: Any keyword arguments to the S will have their values evaluated as a spec, with the result being saved to the keyword argument name in the scope. When only the target is being assigned, you can use the A as a shortcut -def _update_dict_and_return_it(start_dict: dict, updated_values_dict: dict) -> dict: - """Update a dict and return it""" - start_dict.update(updated_values_dict) - return start_dict diff --git a/src/hdhelpers/metadata/helpers.py b/src/hdhelpers/metadata/helpers.py index d0ff0c4..0d68e19 100644 --- a/src/hdhelpers/metadata/helpers.py +++ b/src/hdhelpers/metadata/helpers.py @@ -16,27 +16,40 @@ import pandas as pd from glom import Coalesce, Spec, glom +import hdhelpers.metadata._specs as _specs +from hdhelpers.metadata._recording import ( + Recording, + default_aggregation_method_by_metric, + recording_by_metric, + single_metric_default_aggregation_method, + single_metric_recording, +) from hdhelpers.metadata._helpers import ( check_dataframe, check_series, check_series_or_dataframe, + default_timezone_by_metric, extract_from_metadata, get_value_dimension_info, + metric_info_by_metric, + series_value_info, singlets_metric_info, singlets_value_dimension_info, + single_metric_default_timezone, + singlets_value_dimension_info_with_fallbacks, spec_not_none, + value_dimension_info_with_fallbacks, ) -import hdhelpers.metadata._specs as _specs -from hdhelpers.metadata._specs import spec_by_metric_key def get_units( - multitsframe: pd.DataFrame, + multitsframe: pd.DataFrame, *, include_inherited: bool = False ) -> defaultdict[str, defaultdict[str, str | None]]: """Gets unit of value dimensions in MTSF metrics from Metadata Args: multitsframe (pd.DataFrame): MTSF with metadata following the convention. + include_inherited (bool): Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under "inherited" in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor "value_dimensions_shared" provide the information. Defaults to False, since this information should be provided for the metric itself. Returns: dict[str, dict[str | None] | None]: Dictionary of metrics containing the names of the value dimensions. @@ -78,14 +91,17 @@ def get_units( """ check_dataframe(multitsframe) - return get_value_dimension_info(multitsframe, _specs.UNIT) + return get_value_dimension_info(multitsframe, _specs.UNIT, include_inherited) -def get_names(multitsframe: pd.DataFrame) -> defaultdict[str, defaultdict[str, str | None]]: +def get_names( + multitsframe: pd.DataFrame, *, include_inherited: bool = False +) -> defaultdict[str, defaultdict[str, str | None]]: """Gets names of the MTSF metrics from the metadata Args: multitsframe (pd.DataFrame): MTSF with metadata following the convention. + include_inherited (bool): Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under "inherited" in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor "value_dimensions_shared" provide the information. Defaults to False, since this information should be provided for the metric itself. Returns: dict[str, str | None]: Dictionary of metrics containing the names. If the name is not present for a metric the corresponding value is None. @@ -123,18 +139,24 @@ def get_names(multitsframe: pd.DataFrame) -> defaultdict[str, defaultdict[str, s """ check_dataframe(multitsframe) - return get_value_dimension_info(multitsframe, _specs.NAME) + return get_value_dimension_info(multitsframe, _specs.NAME, include_inherited) -def get_display_names(multitsframe: pd.DataFrame) -> defaultdict[str, defaultdict[str, str | None]]: +def get_display_names( + multitsframe: pd.DataFrame, *, include_inherited: bool = False +) -> defaultdict[str, defaultdict[str, str | None]]: """Gets display names of the MTSF metrics from the metadata Args: multitsframe (pd.DataFrame): MTSF with metadata following the convention. + include_inherited (bool): Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under "inherited" in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor "value_dimensions_shared" provide the information. Defaults to False, since this information should be provided for the metric itself. Returns: defaultdict[str, defaultdict[str, str | None]]: Dictionary of metrics containing the display names. If the display name of the metrics is not present it returns the result of :func:`hdhelpers.metadata.get_names`. + For metadata following the current convention, the display name is looked up on the value dimension, on + the metric (for the value dimension "value") and in "value_dimensions_shared" before falling back to the + name. Fields provided as null count as missing. Raises: TypeError: If `multitsframe` is not a DataFrame. @@ -169,20 +191,26 @@ def get_display_names(multitsframe: pd.DataFrame) -> defaultdict[str, defaultdic """ check_dataframe(multitsframe) - return get_value_dimension_info(multitsframe, _specs.DISPLAY_NAME) + return value_dimension_info_with_fallbacks( + multitsframe, _specs.DISPLAY_NAME_FIELDS, _specs.DISPLAY_NAME, include_inherited + ) def get_short_display_names( - multitsframe: pd.DataFrame, + multitsframe: pd.DataFrame, *, include_inherited: bool = False ) -> defaultdict[str, defaultdict[str, str | None]]: """Gets short display names of the MTSF metrics from the metadata Args: multitsframe (pd.DataFrame): MTSF with metadata following the convention. + include_inherited (bool): Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under "inherited" in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor "value_dimensions_shared" provide the information. Defaults to False, since this information should be provided for the metric itself. Returns: defaultdict[str, defaultdict[str, str | None]]: Dictionary of metrics containing the short display names. If the short display name of the metrics is not present it returns the result of :func:`hdhelpers.metadata.get_display_names`. + For metadata following the current convention, each of short display name, display name and name is + looked up on the value dimension, on the metric (for the value dimension "value") and in + "value_dimensions_shared" before falling back to the next one. Fields provided as null count as missing. Raises: TypeError: If `multitsframe` is not a DataFrame. @@ -220,14 +248,19 @@ def get_short_display_names( """ check_dataframe(multitsframe) - return get_value_dimension_info(multitsframe, _specs.SHORT_DISPLAY_NAME) + return value_dimension_info_with_fallbacks( + multitsframe, _specs.SHORT_DISPLAY_NAME_FIELDS, _specs.SHORT_DISPLAY_NAME, include_inherited + ) -def get_measurements(multitsframe: pd.DataFrame) -> defaultdict[str, defaultdict[str, str | None]]: +def get_measurements( + multitsframe: pd.DataFrame, *, include_inherited: bool = False +) -> defaultdict[str, defaultdict[str, str | None]]: """Gets measurement (type) of the MTSF metrics from the metadata Args: multitsframe (pd.DataFrame): MTSF with metadata following the convention. + include_inherited (bool): Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under "inherited" in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor "value_dimensions_shared" provide the information. Defaults to False, since this information should be provided for the metric itself. Returns: defaultdict[str, defaultdict[str, str | None]]: Dictionary of metrics containing the measurement (type) of the MTSF metrics. @@ -250,21 +283,32 @@ def get_measurements(multitsframe: pd.DataFrame) -> defaultdict[str, defaultdict """ check_dataframe(multitsframe) - return get_value_dimension_info(multitsframe, _specs.MEASUREMENT) + return get_value_dimension_info(multitsframe, _specs.MEASUREMENT, include_inherited) -def get_metric_info(multitsframe: pd.DataFrame, metric_info: str | Spec) -> defaultdict[str, Any]: +def get_metric_info( + multitsframe: pd.DataFrame, metric_info: str | Spec, *, include_inherited: bool = True +) -> defaultdict[str, Any]: """Gets a dictionary of metadata associated to metrics. In contrast to metadata associated to concrete value dimensions, this function abstracts access to metadata associated to the underlying metric. + The information is looked up in the metric itself, then in its value dimension "value", + then in "value_dimensions_shared" and finally in the properties the metric inherits from + objects higher up in an asset hierarchy (given under "inherited" in the metric metadata), + e.g. the latitude of the plant a sensor belongs to. The latter can be switched off via + `include_inherited`. Information provided as null counts as missing. Note that in contrast + to this function, the functions for dedicated information like :func:`get_units` or + :func:`get_display_names` do not fall back to inherited properties by default. + Args: multitsframe (pd.DataFrame): MTSF with metadata following the convention. - metric_info (str | Spec): Name of information to retrieve. Note that `metric_info` is interpreted as a glom Spec. + metric_info (str | Spec): Information to retrieve, interpreted as a glom Spec: e.g. a field name, a dotted path like "sensor.manufacturer" into sub dictionaries or any other glom Spec. See "How to query nested information?" in the first steps. + include_inherited (bool): Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under "inherited" in the metric metadata) as last resort, i.e. if neither the metric, its value dimension "value" nor "value_dimensions_shared" provide the information. Defaults to True. Returns: - defaultdict[str, Any]: Dictionary, where keys are the entries of the metrics metadata specified via "metric_key" in "dataset_metadata" and values are the entries specified via "metric_info" in the metrics metadata + defaultdict[str, Any]: Dictionary, where keys are the entries of the metrics metadata specified via "metric_key" in "dataset_metadata" and values are the entries specified via "metric_info" in the metrics metadata. Defaults to None, also if there is no metric metadata at all. Code example: @@ -280,6 +324,7 @@ def get_metric_info(multitsframe: pd.DataFrame, metric_info: str | Spec) -> defa ... "external_id": "external_first", ... "unit": "m", ... "display_name": "first display name", + ... "sensor": {"manufacturer": "Acme"}, ... "value_dimensions": [ ... { ... "column": "temp", @@ -308,14 +353,26 @@ def get_metric_info(multitsframe: pd.DataFrame, metric_info: str | Spec) -> defa 'external_second' >>> result["not-given"] is None True + + A dotted path reaches into sub dictionaries: + + .. doctest:: metadata.get_metric_info + + >>> result = get_metric_info(multitsframe, "sensor.manufacturer") + >>> result["first"] + 'Acme' + >>> result["second"] is None + True """ check_dataframe(multitsframe) - spec = spec_by_metric_key(metric_info) - metric_info = glom(multitsframe.attrs, spec) - return defaultdict(lambda: None, metric_info) + return defaultdict( + lambda: None, metric_info_by_metric(multitsframe, metric_info, include_inherited) + ) -def get_series_info(series: pd.Series, value_dim_info: str | Spec) -> Any: +def get_series_info( + series: pd.Series, value_dim_info: str | Spec, *, include_inherited: bool = True +) -> Any: """Gets an arbitrary series info Since a series has only one value dimension named "value", this information is @@ -323,7 +380,8 @@ def get_series_info(series: pd.Series, value_dim_info: str | Spec) -> Any: Args: series (pd.Series): Series with metadata following the convention. - value_dim_info (str | Spec): Name of information to retrieve. Note that `value_dim_info` is interpreted as a glom Spec. + value_dim_info (str | Spec): Information to retrieve, interpreted as a glom Spec: e.g. a field name, a dotted path like "sensor.manufacturer" into sub dictionaries or any other glom Spec. See "How to query nested information?" in the first steps. + include_inherited (bool): Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under "inherited" in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor "value_dimensions_shared" provide the information. Defaults to True. Returns: Any: Retrieved information defined by `value_dim_info` @@ -340,17 +398,24 @@ def get_series_info(series: pd.Series, value_dim_info: str | Spec) -> Any: True """ - # Since the fallback behaviour for this value dimension is to fall back to the metric - # metadata, we can reuse the code that extracts value_dimension metadata for - # this value dimension. check_series(series) - series_metric_key = extract_from_metadata(series.attrs, key="single_metric", default="series") - from_new_convention = get_value_dimension_info(series, value_dim_info)[series_metric_key].get( - "value" - ) + return _series_info(series, (value_dim_info,), value_dim_info, include_inherited) + - if from_new_convention is not None: - return from_new_convention +def _series_info( + series: pd.Series, fields: tuple[str | Spec, ...], legacy_spec: Spec, include_inherited: bool +) -> Any: + """Info of a Series, falling back to further fields + + Since a Series has only one value dimension named "value", it falls back to the metric + metadata. For metadata following the current metadata convention, each field is looked up + on the value dimension, the metric and "value_dimensions_shared" before the next field is + tried, see value_dimension_info_with_fallbacks. Older metadata structures are handled via + legacy_spec, keeping their established behaviour. + """ + from_metrics = series_value_info(series, fields, legacy_spec, include_inherited) + if from_metrics is not None: + return from_metrics # compatibility with some older format return glom( @@ -359,13 +424,13 @@ def get_series_info(series: pd.Series, value_dim_info: str | Spec) -> Any: spec_not_none( ( "single_metric_metadata.structured_metadata.value_dimensions.value", - value_dim_info, + legacy_spec, ) ), spec_not_none( ( "single_metric_metadata.structured_metadata.metric", - value_dim_info, + legacy_spec, ) ), default=None, @@ -373,11 +438,24 @@ def get_series_info(series: pd.Series, value_dim_info: str | Spec) -> Any: ) -def get_series_unit(series: pd.Series) -> str | None: +def _series_info_with_fallbacks( + series: pd.Series, fields: tuple[str | Spec, ...], include_inherited: bool +) -> Any: + """Info of a Series, falling back to further fields, which may be provided as null + + Older metadata structures keep their established behaviour of using the first of the + fields present on the first level providing any. + """ + legacy_spec = Coalesce(*(spec_not_none(field) for field in fields)) + return _series_info(series, fields, legacy_spec, include_inherited) + + +def get_series_unit(series: pd.Series, *, include_inherited: bool = False) -> str | None: """Gets name of the series from metadata Args: series (pd.Series): Series with metadata following the convention. + include_inherited (bool): Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under "inherited" in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor "value_dimensions_shared" provide the information. Defaults to False, since this information should be provided for the metric itself. Returns: str | None: @@ -415,14 +493,18 @@ def get_series_unit(series: pd.Series) -> str | None: """ check_series(series) - return cast(str | None, get_series_info(series, spec_not_none("unit"))) + return cast( + str | None, + _series_info_with_fallbacks(series, (_specs.UNIT,), include_inherited), + ) -def get_series_name(series: pd.Series) -> str | None: +def get_series_name(series: pd.Series, *, include_inherited: bool = False) -> str | None: """Gets name of the series from metadata Args: series (pd.Series): Series with metadata following the convention. + include_inherited (bool): Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under "inherited" in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor "value_dimensions_shared" provide the information. Defaults to False, since this information should be provided for the metric itself. Returns: str | None: @@ -462,14 +544,18 @@ def get_series_name(series: pd.Series) -> str | None: 'name_of_series_2' """ check_series(series) - return cast(str | None, get_series_info(series, spec_not_none("name"))) + return cast( + str | None, + _series_info_with_fallbacks(series, ("name",), include_inherited), + ) -def get_series_display_name(series: pd.Series) -> str | None: +def get_series_display_name(series: pd.Series, *, include_inherited: bool = False) -> str | None: """Gets display name of the series from metadata Args: series (pd.Series): Series with metadata following the convention. + include_inherited (bool): Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under "inherited" in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor "value_dimensions_shared" provide the information. Defaults to False, since this information should be provided for the metric itself. Returns: str | None: @@ -492,22 +578,18 @@ def get_series_display_name(series: pd.Series) -> str | None: check_series(series) return cast( str | None, - get_series_info( - series, - Coalesce( - spec_not_none("display_name"), - spec_not_none("name"), - spec_not_none("short_display_name"), - ), - ), + _series_info_with_fallbacks(series, _specs.DISPLAY_NAME_FIELDS, include_inherited), ) -def get_series_short_display_name(series: pd.Series) -> str | None: +def get_series_short_display_name( + series: pd.Series, *, include_inherited: bool = False +) -> str | None: """Gets short display name of the Series from metadata Args: series (pd.Series): Series with metadata following the convention. + include_inherited (bool): Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under "inherited" in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor "value_dimensions_shared" provide the information. Defaults to False, since this information should be provided for the metric itself. Returns: str | None: @@ -530,22 +612,16 @@ def get_series_short_display_name(series: pd.Series) -> str | None: check_series(series) return cast( str | None, - get_series_info( - series, - Coalesce( - spec_not_none("short_display_name"), - spec_not_none("display_name"), - spec_not_none("name"), - ), - ), + _series_info_with_fallbacks(series, _specs.SHORT_DISPLAY_NAME_FIELDS, include_inherited), ) -def get_series_measurement(series: pd.Series) -> str | None: +def get_series_measurement(series: pd.Series, *, include_inherited: bool = False) -> str | None: """Gets measurement (type) of the Series from metadata Args: series (pd.Series): Series with metadata following the convention. + include_inherited (bool): Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under "inherited" in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor "value_dimensions_shared" provide the information. Defaults to False, since this information should be provided for the metric itself. Returns: str | None: @@ -565,10 +641,14 @@ def get_series_measurement(series: pd.Series) -> str | None: 'temperature' """ check_series(series) - return cast(str | None, get_series_info(series, "measurement")) + return cast( + str | None, get_series_info(series, "measurement", include_inherited=include_inherited) + ) -def get_singlets_info(singletsframe: pd.DataFrame, value_dim_info: str | Spec) -> Any: +def get_singlets_info( + singletsframe: pd.DataFrame, value_dim_info: str | Spec, *, include_inherited: bool = True +) -> Any: """Gets an arbitrary info of the value dimensions of a SingleTSFrame A SingleTSFrame (STSF) holds exactly one metric - like a Series - but arbitrarily many @@ -581,7 +661,8 @@ def get_singlets_info(singletsframe: pd.DataFrame, value_dim_info: str | Spec) - Args: singletsframe (pd.DataFrame): STSF with metadata following the convention. - value_dim_info (str | Spec): Name of information to retrieve. Note that `value_dim_info` is interpreted as a glom Spec. + value_dim_info (str | Spec): Information to retrieve, interpreted as a glom Spec: e.g. a field name, a dotted path like "sensor.manufacturer" into sub dictionaries or any other glom Spec. See "How to query nested information?" in the first steps. + include_inherited (bool): Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under "inherited" in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor "value_dimensions_shared" provide the information. Defaults to True. Returns: defaultdict[str, Any]: Dictionary of value dimensions containing the retrieved information. If the information is not present for a value dimension the corresponding value is None. @@ -613,14 +694,17 @@ def get_singlets_info(singletsframe: pd.DataFrame, value_dim_info: str | Spec) - """ check_dataframe(singletsframe) - return singlets_value_dimension_info(singletsframe, value_dim_info) + return singlets_value_dimension_info(singletsframe, value_dim_info, include_inherited) -def get_singlets_units(singletsframe: pd.DataFrame) -> defaultdict[str, str | None]: +def get_singlets_units( + singletsframe: pd.DataFrame, *, include_inherited: bool = False +) -> defaultdict[str, str | None]: """Gets units of the value dimensions of a SingleTSFrame from metadata Args: singletsframe (pd.DataFrame): STSF with metadata following the convention. + include_inherited (bool): Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under "inherited" in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor "value_dimensions_shared" provide the information. Defaults to False, since this information should be provided for the metric itself. Returns: defaultdict[str, str | None]: Dictionary of value dimensions containing the units. If the unit is not present for a value dimension the corresponding value is None. @@ -669,14 +753,17 @@ def get_singlets_units(singletsframe: pd.DataFrame) -> defaultdict[str, str | No """ check_dataframe(singletsframe) - return singlets_value_dimension_info(singletsframe, _specs.UNIT) + return singlets_value_dimension_info(singletsframe, _specs.UNIT, include_inherited) -def get_singlets_names(singletsframe: pd.DataFrame) -> defaultdict[str, str | None]: +def get_singlets_names( + singletsframe: pd.DataFrame, *, include_inherited: bool = False +) -> defaultdict[str, str | None]: """Gets names of the value dimensions of a SingleTSFrame from metadata Args: singletsframe (pd.DataFrame): STSF with metadata following the convention. + include_inherited (bool): Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under "inherited" in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor "value_dimensions_shared" provide the information. Defaults to False, since this information should be provided for the metric itself. Returns: defaultdict[str, str | None]: Dictionary of value dimensions containing the names. If the name is not present for a value dimension the corresponding value is None. @@ -708,16 +795,22 @@ def get_singlets_names(singletsframe: pd.DataFrame) -> defaultdict[str, str | No """ check_dataframe(singletsframe) - return singlets_value_dimension_info(singletsframe, _specs.NAME) + return singlets_value_dimension_info(singletsframe, _specs.NAME, include_inherited) -def get_singlets_display_names(singletsframe: pd.DataFrame) -> defaultdict[str, str | None]: +def get_singlets_display_names( + singletsframe: pd.DataFrame, *, include_inherited: bool = False +) -> defaultdict[str, str | None]: """Gets display names of the value dimensions of a SingleTSFrame from metadata - Falls back to the name of the value dimension if no display name is given. + Falls back to the name of the value dimension if no display name is given. The display name + is looked up on the value dimension, on the metric (for the value dimension "value") and in + "value_dimensions_shared" before falling back to the name. Fields provided as null count as + missing. Args: singletsframe (pd.DataFrame): STSF with metadata following the convention. + include_inherited (bool): Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under "inherited" in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor "value_dimensions_shared" provide the information. Defaults to False, since this information should be provided for the metric itself. Returns: defaultdict[str, str | None]: Dictionary of value dimensions containing the display names. If neither display name nor name is present for a value dimension the corresponding value is None. @@ -749,17 +842,24 @@ def get_singlets_display_names(singletsframe: pd.DataFrame) -> defaultdict[str, """ check_dataframe(singletsframe) - return singlets_value_dimension_info(singletsframe, _specs.DISPLAY_NAME) + return singlets_value_dimension_info_with_fallbacks( + singletsframe, _specs.DISPLAY_NAME_FIELDS, _specs.DISPLAY_NAME, include_inherited + ) -def get_singlets_short_display_names(singletsframe: pd.DataFrame) -> defaultdict[str, str | None]: +def get_singlets_short_display_names( + singletsframe: pd.DataFrame, *, include_inherited: bool = False +) -> defaultdict[str, str | None]: """Gets short display names of the value dimensions of a SingleTSFrame from metadata Falls back to the display name and then to the name of the value dimension if no short - display name is given. + display name is given. Each of them is looked up on the value dimension, on the metric (for + the value dimension "value") and in "value_dimensions_shared" before falling back to the next + one. Fields provided as null count as missing. Args: singletsframe (pd.DataFrame): STSF with metadata following the convention. + include_inherited (bool): Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under "inherited" in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor "value_dimensions_shared" provide the information. Defaults to False, since this information should be provided for the metric itself. Returns: defaultdict[str, str | None]: Dictionary of value dimensions containing the short display names. If none of short display name, display name and name is present for a value dimension the corresponding value is None. @@ -791,14 +891,22 @@ def get_singlets_short_display_names(singletsframe: pd.DataFrame) -> defaultdict """ check_dataframe(singletsframe) - return singlets_value_dimension_info(singletsframe, _specs.SHORT_DISPLAY_NAME) + return singlets_value_dimension_info_with_fallbacks( + singletsframe, + _specs.SHORT_DISPLAY_NAME_FIELDS, + _specs.SHORT_DISPLAY_NAME, + include_inherited, + ) -def get_singlets_measurements(singletsframe: pd.DataFrame) -> defaultdict[str, str | None]: +def get_singlets_measurements( + singletsframe: pd.DataFrame, *, include_inherited: bool = False +) -> defaultdict[str, str | None]: """Gets measurements (types) of the value dimensions of a SingleTSFrame from metadata Args: singletsframe (pd.DataFrame): STSF with metadata following the convention. + include_inherited (bool): Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under "inherited" in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor "value_dimensions_shared" provide the information. Defaults to False, since this information should be provided for the metric itself. Returns: defaultdict[str, str | None]: Dictionary of value dimensions containing the measurements. If "measurement" is not given for a value dimension the corresponding value is None. @@ -826,18 +934,23 @@ def get_singlets_measurements(singletsframe: pd.DataFrame) -> defaultdict[str, s """ check_dataframe(singletsframe) - return singlets_value_dimension_info(singletsframe, _specs.MEASUREMENT) + return singlets_value_dimension_info(singletsframe, _specs.MEASUREMENT, include_inherited) -def get_singlets_metric_info(singletsframe: pd.DataFrame, metric_info: str | Spec) -> Any: +def get_singlets_metric_info( + singletsframe: pd.DataFrame, metric_info: str | Spec, *, include_inherited: bool = True +) -> Any: """Gets metadata associated to the single metric of a SingleTSFrame Counterpart of :func:`get_metric_info` for SingleTSFrames: instead of a dictionary keyed - by metric this directly returns the requested information for the one metric. + by metric this directly returns the requested information for the one metric. Like + :func:`get_metric_info`, it falls back to the value dimension "value", to + "value_dimensions_shared" and finally to the properties the metric inherits. Args: singletsframe (pd.DataFrame): STSF with metadata following the convention. - metric_info (str | Spec): Name of information to retrieve. Note that `metric_info` is interpreted as a glom Spec. + metric_info (str | Spec): Information to retrieve, interpreted as a glom Spec: e.g. a field name, a dotted path like "sensor.manufacturer" into sub dictionaries or any other glom Spec. See "How to query nested information?" in the first steps. + include_inherited (bool): Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under "inherited" in the metric metadata) as last resort, i.e. if neither the metric, its value dimension "value" nor "value_dimensions_shared" provide the information. Defaults to True. Returns: Any: Retrieved information defined by `metric_info`, or None if it cannot be determined - which includes the case of absent metric metadata. @@ -868,7 +981,7 @@ def get_singlets_metric_info(singletsframe: pd.DataFrame, metric_info: str | Spe """ check_dataframe(singletsframe) - return singlets_metric_info(singletsframe, metric_info) + return singlets_metric_info(singletsframe, metric_info, include_inherited) def get_queried_interval( @@ -911,3 +1024,401 @@ def get_queried_interval( formatted_end = datetime.datetime.fromisoformat(end) if end else None return formatted_start, formatted_end + + +def get_default_timezones(multitsframe: pd.DataFrame) -> defaultdict[str, str | None]: + """Gets the default time zone of each metric of a MultiTSFrame + + The default time zone is meant for computations: It is the time zone the timeseries should + be evaluated in by default, e.g. for bucketing days when aggregating, so that each day + starts at midnight of this time zone. It belongs to the data, e.g. it is the time zone of + the plant a sensor belongs to. It is not meant for displaying timestamps - that is what the + time zone of the plot target settings is for, which e.g. :func:`hdhelpers.helpers.modify_timezone` + uses by default. See "Which time zone to compute in?" in the first steps. + + The time zone is an IANA time zone name like "Europe/Berlin" or a fixed UTC offset like + "+02:00", both accepted by pandas, e.g. by ``tz_convert``. + + The default time zone of a metric falls back to the one of the dataset metadata, which + applies to all metrics without their own. It does not fall back to value dimensions or + inherited properties. + + Args: + multitsframe (pd.DataFrame): MTSF with metadata following the convention. + + Returns: + defaultdict[str, str | None]: Dictionary, where keys are the entries of the metrics metadata specified via "metric_key" in "dataset_metadata" and values are their default time zones. Defaults to the default time zone of the dataset metadata, also for metrics without metadata, and to None if that is missing as well. + + Raises: + TypeError: If `multitsframe` is not a DataFrame. + + Code example: + + .. doctest:: metadata.get_default_timezones + + >>> multitsframe.attrs = { + ... "dataset_metadata": {"metric_key": "id", "default_timezone": "Europe/Berlin"}, + ... "metrics": [ + ... {"id": "new_york_sensor", "default_timezone": "America/New_York"}, + ... {"id": "berlin_sensor"}, + ... ], + ... } + >>> timezones = get_default_timezones(multitsframe) + >>> timezones["new_york_sensor"] + 'America/New_York' + >>> timezones["berlin_sensor"] + 'Europe/Berlin' + >>> timezones["metric_without_metadata"] + 'Europe/Berlin' + """ + check_dataframe(multitsframe) + return default_timezone_by_metric(multitsframe) + + +def get_series_default_timezone(series: pd.Series) -> str | None: + """Gets the default time zone of a Series + + The default time zone is meant for computations: It is the time zone the timeseries should + be evaluated in by default, e.g. for bucketing days when aggregating, so that each day + starts at midnight of this time zone. It belongs to the data, e.g. it is the time zone of + the plant a sensor belongs to. It is not meant for displaying timestamps - that is what the + time zone of the plot target settings is for, which e.g. :func:`hdhelpers.helpers.modify_timezone` + uses by default. See "Which time zone to compute in?" in the first steps. + + The time zone is an IANA time zone name like "Europe/Berlin" or a fixed UTC offset like + "+02:00", both accepted by pandas, e.g. by ``tz_convert``. + + The default time zone of the metric falls back to the one of the dataset metadata. + + Args: + series (pd.Series): Series with metadata following the convention. + + Returns: + str | None: The default time zone, or None if neither the metric nor the dataset metadata provide one. + + Raises: + TypeError: If `series` is not a Series. + + Code example: + + .. doctest:: metadata.get_series_default_timezone + + >>> series.attrs = { + ... "dataset_metadata": {"single_metric": "water.north.level", "metric_key": "external_id"}, + ... "metrics": [{"external_id": "water.north.level", "default_timezone": "Europe/Berlin"}], + ... } + >>> get_series_default_timezone(series) + 'Europe/Berlin' + >>> series.attrs = {} + >>> get_series_default_timezone(series) is None + True + """ + check_series(series) + return single_metric_default_timezone(series) + + +def get_singlets_default_timezone(singletsframe: pd.DataFrame) -> str | None: + """Gets the default time zone of the single metric of a SingleTSFrame + + The default time zone is meant for computations: It is the time zone the timeseries should + be evaluated in by default, e.g. for bucketing days when aggregating, so that each day + starts at midnight of this time zone. It belongs to the data, e.g. it is the time zone of + the plant a sensor belongs to. It is not meant for displaying timestamps - that is what the + time zone of the plot target settings is for, which e.g. :func:`hdhelpers.helpers.modify_timezone` + uses by default. See "Which time zone to compute in?" in the first steps. + + The time zone is an IANA time zone name like "Europe/Berlin" or a fixed UTC offset like + "+02:00", both accepted by pandas, e.g. by ``tz_convert``. + + The default time zone of the metric falls back to the one of the dataset metadata. + + Args: + singletsframe (pd.DataFrame): STSF with metadata following the convention. + + Returns: + str | None: The default time zone, or None if neither the metric nor the dataset metadata provide one. + + Raises: + TypeError: If `singletsframe` is not a DataFrame. + + Code example: + + .. doctest:: metadata.get_singlets_default_timezone + + >>> dataframe.attrs = { + ... "dataset_metadata": {"single_metric": "abc.temp", "default_timezone": "+02:00"}, + ... "metrics": [{"id": "abc.temp", "default_timezone": None}], + ... } + >>> get_singlets_default_timezone(dataframe) + '+02:00' + """ + check_dataframe(singletsframe) + return single_metric_default_timezone(singletsframe) + + +def get_recordings(multitsframe: pd.DataFrame) -> defaultdict[str, Recording]: + """Gets how the values of each metric of a MultiTSFrame are recorded + + The recording consists of the "recording_mode" of the metric (e.g. "periodic" or + "on_change"), the frequency of its values with offset and time zone and its + "recording_aggregation_method", see :class:`Recording` for what they mean. + + The frequency falls back as a whole: If the metric has a "data_frequency", it applies + together with the metric's "data_frequency_offset" and "data_frequency_timezone". Otherwise + "ref_data_frequency" of the dataset metadata applies together with + "ref_data_frequency_offset" and "ref_data_frequency_timezone". The values are provided as + given in the metadata. + + Args: + multitsframe (pd.DataFrame): MTSF with metadata following the convention. + + Returns: + defaultdict[str, Recording]: Dictionary, where keys are the entries of the metrics metadata specified via "metric_key" in "dataset_metadata" and values are their recordings. Defaults to a recording with the frequency of the dataset metadata, also for metrics without metadata. + + Raises: + TypeError: If `multitsframe` is not a DataFrame. + + Code example: + + .. doctest:: metadata.get_recordings + + >>> multitsframe.attrs = { + ... "dataset_metadata": {"metric_key": "id", "ref_data_frequency": "1h"}, + ... "metrics": [ + ... { + ... "id": "average_level", + ... "recording_mode": "periodic", + ... "data_frequency": "15min", + ... "recording_aggregation_method": "MEAN", + ... }, + ... {"id": "pump_state", "recording_mode": "on_change"}, + ... ], + ... } + >>> recordings = get_recordings(multitsframe) + >>> recordings["average_level"].data_frequency + '15min' + >>> recordings["pump_state"].recording_mode + 'on_change' + >>> recordings["pump_state"].data_frequency + '1h' + """ + check_dataframe(multitsframe) + return recording_by_metric(multitsframe) + + +def get_series_recording(series: pd.Series) -> Recording: + """Gets how the values of a Series are recorded + + See :func:`get_recordings` for the recording and how its frequency falls back to the one of + the dataset metadata. + + Args: + series (pd.Series): Series with metadata following the convention. + + Returns: + Recording: The recording of the metric, with the frequency of the dataset metadata if the metric does not provide one. + + Raises: + TypeError: If `series` is not a Series. + + Code example: + + .. doctest:: metadata.get_series_recording + + >>> series.attrs = { + ... "dataset_metadata": {"single_metric": "water.north.level", "metric_key": "external_id"}, + ... "metrics": [ + ... { + ... "external_id": "water.north.level", + ... "recording_mode": "periodic", + ... "data_frequency": "2h", + ... "data_frequency_timezone": "Europe/Berlin", + ... } + ... ], + ... } + >>> recording = get_series_recording(series) + >>> recording.data_frequency, recording.data_frequency_offset, recording.data_frequency_timezone + ('2h', None, 'Europe/Berlin') + """ + check_series(series) + return single_metric_recording(series) + + +def get_singlets_recording(singletsframe: pd.DataFrame) -> Recording: + """Gets how the values of the single metric of a SingleTSFrame are recorded + + See :func:`get_recordings` for the recording and how its frequency falls back to the one of + the dataset metadata. + + Args: + singletsframe (pd.DataFrame): STSF with metadata following the convention. + + Returns: + Recording: The recording of the metric, with the frequency of the dataset metadata if the metric does not provide one. + + Raises: + TypeError: If `singletsframe` is not a DataFrame. + + Code example: + + .. doctest:: metadata.get_singlets_recording + + >>> dataframe.attrs = { + ... "dataset_metadata": { + ... "single_metric": "abc.temp", + ... "ref_data_frequency": "5min", + ... "ref_data_frequency_offset": "1min", + ... }, + ... "metrics": [{"id": "abc.temp", "recording_mode": "periodic"}], + ... } + >>> recording = get_singlets_recording(dataframe) + >>> recording.recording_mode, recording.data_frequency, recording.data_frequency_offset + ('periodic', '5min', '1min') + """ + check_dataframe(singletsframe) + return single_metric_recording(singletsframe) + + +def get_default_aggregation_methods(multitsframe: pd.DataFrame) -> defaultdict[str, str]: + """Gets the method each metric of a MultiTSFrame should be aggregated with by default + + E.g. for plotting or reporting it in coarser intervals. It is the "default_aggregation_method" + of the metric, with "AVG" normalized to its alias "MEAN". If the metric does not provide one, + it is "LAST" for values of the data type "boolean" or "string" (the "value_data_type" of the + value dimension "value"), i.e. the state holding at the end of the interval, and "MEAN" + otherwise. It is not taken from inherited properties. + + Args: + multitsframe (pd.DataFrame): MTSF with metadata following the convention. + + Returns: + defaultdict[str, str]: Dictionary, where keys are the entries of the metrics metadata specified via "metric_key" in "dataset_metadata" and values are their default aggregation methods, e.g. "MEAN", "SUM" or "LAST". Defaults to "MEAN" for metrics without metadata. + + Raises: + TypeError: If `multitsframe` is not a DataFrame. + + Code example: + + .. doctest:: metadata.get_default_aggregation_methods + + >>> multitsframe.attrs = { + ... "dataset_metadata": {"metric_key": "id"}, + ... "metrics": [ + ... {"id": "level", "default_aggregation_method": "AVG"}, + ... {"id": "rainfall", "default_aggregation_method": "SUM"}, + ... { + ... "id": "pump_running", + ... "value_dimensions": [{"column": "value", "value_data_type": "boolean"}], + ... }, + ... ], + ... } + >>> methods = get_default_aggregation_methods(multitsframe) + >>> methods["level"], methods["rainfall"], methods["pump_running"] + ('MEAN', 'SUM', 'LAST') + >>> methods["metric_without_metadata"] + 'MEAN' + """ + check_dataframe(multitsframe) + return default_aggregation_method_by_metric(multitsframe) + + +def get_series_default_aggregation_method(series: pd.Series) -> str: + """Gets the method a Series should be aggregated with by default + + See :func:`get_default_aggregation_methods` for the method and its default. + + Args: + series (pd.Series): Series with metadata following the convention. + + Returns: + str: The default aggregation method, e.g. "MEAN", "SUM" or "LAST". + + Raises: + TypeError: If `series` is not a Series. + + Code example: + + .. doctest:: metadata.get_series_default_aggregation_method + + >>> series.attrs = { + ... "dataset_metadata": {"single_metric": "rainfall"}, + ... "metrics": [{"id": "rainfall", "default_aggregation_method": "SUM"}], + ... } + >>> get_series_default_aggregation_method(series) + 'SUM' + >>> series.attrs = {} + >>> get_series_default_aggregation_method(series) + 'MEAN' + """ + check_series(series) + return single_metric_default_aggregation_method(series) + + +def get_singlets_default_aggregation_method(singletsframe: pd.DataFrame) -> str: + """Gets the method the single metric of a SingleTSFrame should be aggregated with by default + + See :func:`get_default_aggregation_methods` for the method and its default. + + Args: + singletsframe (pd.DataFrame): STSF with metadata following the convention. + + Returns: + str: The default aggregation method, e.g. "MEAN", "SUM" or "LAST". + + Raises: + TypeError: If `singletsframe` is not a DataFrame. + + Code example: + + .. doctest:: metadata.get_singlets_default_aggregation_method + + >>> dataframe.attrs = { + ... "dataset_metadata": {"single_metric": "valve"}, + ... "metrics": [ + ... { + ... "id": "valve", + ... "value_dimensions": [{"column": "value", "value_data_type": "string"}], + ... } + ... ], + ... } + >>> get_singlets_default_aggregation_method(dataframe) + 'LAST' + """ + check_dataframe(singletsframe) + return single_metric_default_aggregation_method(singletsframe) + + +def without_metadata[T: (pd.Series, pd.DataFrame)](data: T) -> T: + """Gets a copy of a Series or DataFrame without its metadata, to compute on + + pandas deep-copies the metadata (``attrs``) of a Series or DataFrame to every object an + operation creates from it, e.g. when filtering rows, selecting a column or grouping. For + metadata of many metrics, e.g. with comments, loops over the metrics then take much longer + than the computation itself. Compute on the copy without metadata instead and attach the + metadata of the result once at the end. + + The copy is cheap: It shares the data with ``data`` until one of them is changed (copy on + write), so changing the copy does not change ``data``. + + Args: + data (pd.Series | pd.DataFrame): Series or DataFrame, e.g. a MTSF with metadata. + + Returns: + pd.Series | pd.DataFrame: Copy of ``data`` with empty ``attrs``. + + Code example: + + .. doctest:: metadata.without_metadata + + >>> multitsframe.attrs = {"dataset_metadata": {"metric_key": "id"}, "metrics": []} + >>> data = without_metadata(multitsframe) + >>> data.attrs + {} + >>> multitsframe.attrs + {'dataset_metadata': {'metric_key': 'id'}, 'metrics': []} + """ + check_series_or_dataframe(data) + # The constructor does not copy attrs, in contrast to e.g. copy(deep=False) + copy = type(data)(data) + copy.attrs = {} + return copy diff --git a/tests/metadata/test_metadata.py b/tests/metadata/test_metadata.py index d09a127..4e9db3d 100644 --- a/tests/metadata/test_metadata.py +++ b/tests/metadata/test_metadata.py @@ -501,3 +501,125 @@ def test_get_singlets_without_metadata(): def test_get_singlets_requires_a_dataframe(func): with pytest.raises(TypeError): func(pd.Series()) + + +@pytest.fixture +def attrs_with_inherited() -> dict: + return { + "dataset_metadata": {"metric_key": "id", "single_metric": "sensor"}, + "value_dimensions_shared": [{"column": "value", "unit": "shared unit"}], + "metrics": [ + { + "id": "sensor", + "name": "sensor name", + "Latitude": None, + "Longitude": 7.58, + "inherited": { + "Latitude": 51.43, + "Longitude": 8.28, + "display_name": "plant display name", + "unit": "inherited unit", + }, + "value_dimensions": [{"column": "value"}, {"column": "state"}], + }, + {"id": "other", "value_dimensions": [{"column": "value"}]}, + ], + } + + +def test_get_metric_info_falls_back_to_inherited(attrs_with_inherited): + from hdhelpers.metadata import get_metric_info + + dataframe = pd.DataFrame() + dataframe.attrs = attrs_with_inherited + + assert get_metric_info(dataframe, "Latitude")["sensor"] == 51.43 # own None -> inherited + assert get_metric_info(dataframe, "Longitude")["sensor"] == 7.58 # own beats inherited + assert get_metric_info(dataframe, "Latitude")["other"] is None + assert get_metric_info(dataframe, "Latitude", include_inherited=False)["sensor"] is None + assert get_singlets_metric_info(dataframe, "Latitude") == 51.43 + assert get_singlets_metric_info(dataframe, "Latitude", include_inherited=False) is None + + +def test_get_value_dimension_info_falls_back_to_inherited_for_value_only( + attrs_with_inherited, +): + from hdhelpers.metadata import get_units + + dataframe = pd.DataFrame() + dataframe.attrs = attrs_with_inherited + + # like the metric metadata itself, inherited metadata only applies to the "value" column + assert get_singlets_info(dataframe, "Latitude")["value"] == 51.43 + assert get_singlets_info(dataframe, "Latitude")["state"] is None + assert get_singlets_info(dataframe, "Latitude", include_inherited=False)["value"] is None + + # inherited metadata is the last resort, after the dataset wide value_dimensions_shared + assert get_units(dataframe, include_inherited=True)["sensor"]["value"] == "shared unit" + assert get_units(dataframe, include_inherited=True)["other"]["value"] == "shared unit" + assert get_singlets_units(dataframe, include_inherited=True)["value"] == "shared unit" + + del dataframe.attrs["value_dimensions_shared"] + assert get_units(dataframe, include_inherited=True)["sensor"]["value"] == "inherited unit" + assert get_singlets_units(dataframe, include_inherited=True)["value"] == "inherited unit" + + # the functions for dedicated information do not fall back to inherited by default + assert get_units(dataframe)["sensor"]["value"] is None + assert get_singlets_units(dataframe)["value"] is None + + +def test_get_series_info_falls_back_to_inherited(attrs_with_inherited): + from hdhelpers.metadata import get_series_info + + series = pd.Series() + series.attrs = attrs_with_inherited + + assert get_series_info(series, "Latitude") == 51.43 + assert get_series_info(series, "Longitude") == 7.58 + assert get_series_info(series, "Latitude", include_inherited=False) is None + assert get_series_unit(series) == "shared unit" + assert get_series_unit(series, include_inherited=True) == "shared unit" + + del series.attrs["value_dimensions_shared"] + assert get_series_unit(series) is None + assert get_series_unit(series, include_inherited=True) == "inherited unit" + + +def test_own_metadata_beats_inherited_metadata_in_fallback_chains(attrs_with_inherited): + """The whole spec is applied to the metric itself before trying its inherited metadata""" + series = pd.Series() + series.attrs = attrs_with_inherited + + # own "name" beats inherited "display_name" + assert get_series_display_name(series, include_inherited=True) == "sensor name" + assert get_series_short_display_name(series, include_inherited=True) == "sensor name" + + del series.attrs["metrics"][0]["name"] + assert get_series_display_name(series, include_inherited=True) == "plant display name" + assert get_series_display_name(series) is None + + +def test_get_metric_info_prefers_value_dimension_and_shared_over_inherited( + attrs_with_inherited, +): + """Order of get_metric_info: metric, value dimension "value", shared, inherited""" + from hdhelpers.metadata import get_metric_info, get_units + + dataframe = pd.DataFrame() + dataframe.attrs = attrs_with_inherited + + assert get_metric_info(dataframe, "unit")["sensor"] == "shared unit" + + dataframe.attrs["metrics"][0]["value_dimensions"][0]["unit"] = "m" + assert get_metric_info(dataframe, "unit")["sensor"] == "m" + assert get_units(dataframe)["sensor"]["value"] == "m" + + dataframe.attrs["metrics"][0]["unit"] = "kg" + assert get_metric_info(dataframe, "unit")["sensor"] == "kg" + + del dataframe.attrs["value_dimensions_shared"] + del dataframe.attrs["metrics"][0]["unit"] + dataframe.attrs["metrics"][0]["value_dimensions"][0]["unit"] = None + assert get_metric_info(dataframe, "unit")["sensor"] == "inherited unit" + assert get_metric_info(dataframe, "unit", include_inherited=False)["sensor"] is None + assert get_singlets_metric_info(dataframe, "unit") == "inherited unit" diff --git a/tests/metadata/test_metadata_current_convention.py b/tests/metadata/test_metadata_current_convention.py new file mode 100644 index 0000000..f425d86 --- /dev/null +++ b/tests/metadata/test_metadata_current_convention.py @@ -0,0 +1,179 @@ +"""Extraction of metadata following the current metadata convention in a single pass""" + +import pandas as pd +import pytest +from glom import Coalesce, Path + +import hdhelpers.metadata._current_convention as _current_convention +from hdhelpers.metadata import ( + get_display_names, + get_metric_info, + get_series_info, + get_short_display_names, + get_singlets_info, + get_singlets_metric_info, + get_singlets_units, + get_units, +) + + +def frame(attrs): + df = pd.DataFrame() + df.attrs = attrs + return df + + +def many_metrics_attrs(number): + return { + "dataset_metadata": {"metric_key": "id"}, + "metrics": [ + { + "id": f"m{i}", + "name": f"Metric {i}", + "display_name": None, + "inherited": {"site": "north"}, + "value_dimensions": [{"column": "value", "name": f"Metric {i}", "unit": "m"}], + } + for i in range(number) + ], + } + + +def test_entries_not_adhering_to_the_conventions_are_skipped(): + attrs = { + "metrics": [ + "no object", + {"name": "without metric key"}, + {"id": None, "name": "null metric key"}, + {"id": 42, "name": "metric key no string"}, + { + "id": "a", + "value_dimensions": [ + {"column": "value", "unit": "m"}, + {"unit": "without column"}, + {"column": None, "unit": "null column"}, + {"column": ["no", "string"], "unit": "column no string"}, + "no object", + ], + }, + {"id": "b", "unit": "m³/h", "value_dimensions": None}, + ], + "value_dimensions_shared": [ + {"column": "state", "unit": "shared unit"}, + {"unit": "without column"}, + "no object", + ], + } + + units = get_units(frame(attrs)) + + assert set(units) == {"a", "b"} + assert units["a"] == {"state": "shared unit", "value": "m"} + assert units["b"] == {"state": "shared unit", "value": "m³/h"} + assert set(get_metric_info(frame(attrs), "unit")) == {"a", "b"} + + +@pytest.mark.parametrize("shared", [None, "no list", {"column": "value", "unit": "m"}]) +def test_value_dimensions_shared_which_is_no_list_counts_as_empty(shared): + attrs = {"metrics": [{"id": "a"}], "value_dimensions_shared": shared} + + assert get_units(frame(attrs)) == {"a": {"value": None}} + + +@pytest.mark.parametrize("metric_key", [None, 42]) +def test_metric_key_which_is_no_string_defaults_to_id(metric_key): + attrs = { + "dataset_metadata": {"metric_key": metric_key, "single_metric": "a"}, + "metrics": [{"id": "a", "external_id": "ext", "unit": "m"}], + } + + assert get_metric_info(frame(attrs), "external_id") == {"a": "ext"} + assert get_singlets_metric_info(frame(attrs), "unit") == "m" + assert get_singlets_units(frame(attrs))["value"] == "m" + + +@pytest.mark.parametrize( + ("spec", "expected"), + [ + ("name", "n"), + ("missing", None), + ("items", None), # glom does not fall back to attributes of dicts + (Coalesce("name", default=None), "n"), + (Coalesce("display_name", "name", default=None), None), # null display_name is present + ("sensor.manufacturer", "Acme"), + ("sensor.missing", None), + ("display_name.missing", None), # path through null, left to glom + ("comments.0.content", "c"), # list index, left to glom + ("operator.name", None), # string paths are split at dots + (Path("operator.name"), "Ruhrverband"), + (("comments", ["content"]), ["c"]), + ], +) +def test_getter_applies_spec_like_glom(spec, expected): + obj = { + "name": "n", + "display_name": None, + "sensor": {"manufacturer": "Acme"}, + "operator.name": "Ruhrverband", + "comments": [{"content": "c"}], + } + + assert _current_convention.getter(spec)(obj) == expected + + +def test_spec_is_only_applied_to_levels_needed(): + attrs = { + "metrics": [ + {"id": "a", "name": "Sensor", "value_dimensions": [{"column": "value", "name": None}]} + ], + "value_dimensions_shared": [{"column": "value", "name": None}], + } + + assert get_metric_info(frame(attrs), ("name", str.upper))["a"] == "SENSOR" + + +def test_unused_inherited_properties_are_not_evaluated(): + attrs = { + "dataset_metadata": {"single_metric": "a"}, + "metrics": [ + { + "id": "a", + "name": "Sensor", + "inherited": {"name": None}, + "value_dimensions": [{"column": "value", "name": "Value"}], + } + ], + } + series = pd.Series([1.0]) + series.attrs = attrs + + assert get_series_info(series, ("name", str.upper)) == "VALUE" + assert get_singlets_info(frame(attrs), ("name", str.upper))["value"] == "VALUE" + + +def test_no_glom_evaluation_per_metric_for_field_names(monkeypatch): + """glom costs a lot of time per call, which must not add up for frames with many metrics""" + calls = [] + original_glom = _current_convention.glom + monkeypatch.setattr( + _current_convention, + "glom", + lambda *args, **kwargs: calls.append(1) or original_glom(*args, **kwargs), + ) + multitsframe = frame(many_metrics_attrs(100)) + + get_units(multitsframe) + get_display_names(multitsframe, include_inherited=True) + get_short_display_names(multitsframe, include_inherited=True) + get_metric_info(multitsframe, "unit") + + assert calls == [] + + +def test_many_metrics(): + multitsframe = frame(many_metrics_attrs(1000)) + + assert get_short_display_names(multitsframe)["m999"]["value"] == "Metric 999" + assert get_units(multitsframe)["m0"]["value"] == "m" + assert get_metric_info(multitsframe, "unit")["m500"] == "m" + assert get_metric_info(multitsframe, "site")["m1"] == "north" diff --git a/tests/metadata/test_metadata_default_timezone.py b/tests/metadata/test_metadata_default_timezone.py new file mode 100644 index 0000000..dc3504c --- /dev/null +++ b/tests/metadata/test_metadata_default_timezone.py @@ -0,0 +1,169 @@ +"""Default time zone of the metrics, falling back to the one of the dataset metadata""" + +import pandas as pd +import pytest + +from hdhelpers.helpers import modify_timezone +from hdhelpers.metadata import ( + get_default_timezones, + get_series_default_timezone, + get_singlets_default_timezone, +) + + +def frame(attrs): + df = pd.DataFrame() + df.attrs = attrs + return df + + +def series(attrs, values=(1.0,), timestamps=("2026-03-29T12:00:00Z",)): + ser = pd.Series(list(values), index=pd.to_datetime(list(timestamps))) + ser.attrs = attrs + return ser + + +multitsframe_attrs = { + "dataset_metadata": {"metric_key": "external_id", "default_timezone": "Europe/Berlin"}, + "metrics": [ + {"external_id": "new_york", "default_timezone": "America/New_York"}, + {"external_id": "fixed_offset", "default_timezone": "+02:00"}, + {"external_id": "without"}, + {"external_id": "null", "default_timezone": None}, + {"external_id": "no_string", "default_timezone": 2}, + {"external_id": "empty", "default_timezone": ""}, + ], +} + + +def test_default_timezones_of_metrics_fall_back_to_dataset_metadata(): + timezones = get_default_timezones(frame(multitsframe_attrs)) + + assert timezones == { + "new_york": "America/New_York", + "fixed_offset": "+02:00", + "without": "Europe/Berlin", + "null": "Europe/Berlin", + "no_string": "Europe/Berlin", + "empty": "Europe/Berlin", + } + # the default time zone of the dataset metadata applies to all metrics without their own + assert timezones["metric without metadata"] == "Europe/Berlin" + + +def test_default_timezones_without_dataset_default_timezone(): + attrs = {"metrics": [{"id": "a", "default_timezone": "UTC"}, {"id": "b"}]} + + timezones = get_default_timezones(frame(attrs)) + + assert timezones == {"a": "UTC", "b": None} + assert timezones["metric without metadata"] is None + + +def test_default_timezone_does_not_fall_back_to_other_levels(): + """The conventions specify only the dataset metadata as fallback""" + attrs = { + "dataset_metadata": {"single_metric": "a"}, + "metrics": [ + { + "id": "a", + "inherited": {"default_timezone": "Asia/Tokyo"}, + "value_dimensions": [{"column": "value", "default_timezone": "Asia/Tokyo"}], + } + ], + "value_dimensions_shared": [{"column": "value", "default_timezone": "Asia/Tokyo"}], + } + + assert get_default_timezones(frame(attrs))["a"] is None + assert get_series_default_timezone(series(attrs)) is None + assert get_singlets_default_timezone(frame(attrs)) is None + + +@pytest.mark.parametrize( + ("attrs", "expected"), + [ + ( + { + "dataset_metadata": {"single_metric": "a", "default_timezone": "Europe/Berlin"}, + "metrics": [{"id": "a", "default_timezone": "America/New_York"}], + }, + "America/New_York", + ), + ( + { + "dataset_metadata": {"single_metric": "a", "default_timezone": "Europe/Berlin"}, + "metrics": [{"id": "a", "default_timezone": None}], + }, + "Europe/Berlin", + ), + # without single_metric the only metric is used + ({"metrics": [{"id": "a", "default_timezone": "UTC"}]}, "UTC"), + # but not if it is another one than the single metric + ( + { + "dataset_metadata": {"single_metric": "a", "default_timezone": "Europe/Berlin"}, + "metrics": [{"id": "b", "default_timezone": "America/New_York"}], + }, + "Europe/Berlin", + ), + ( + { + "dataset_metadata": {"single_metric": "a"}, + "metrics": [{"id": "b", "default_timezone": "America/New_York"}], + }, + None, + ), + # a single_metric which is no string counts as missing + ( + { + "dataset_metadata": {"single_metric": ["a"]}, + "metrics": [{"id": "a", "default_timezone": "UTC"}], + }, + "UTC", + ), + # without metric metadata the dataset metadata still applies + ({"dataset_metadata": {"default_timezone": "Europe/Berlin"}}, "Europe/Berlin"), + ( + {"dataset_metadata": {"default_timezone": "Europe/Berlin"}, "metrics": []}, + "Europe/Berlin", + ), + ({}, None), + # older metadata structures do not provide default time zones of the metrics + ({"by_metric": {"series": {"metric": {"default_timezone": "UTC"}}}}, None), + ], +) +def test_default_timezone_of_single_metric(attrs, expected): + assert get_series_default_timezone(series(attrs)) == expected + assert get_singlets_default_timezone(frame(attrs)) == expected + + +def test_wrong_types_raise(): + with pytest.raises(TypeError): + get_default_timezones(series({})) + with pytest.raises(TypeError): + get_series_default_timezone(frame({})) + with pytest.raises(TypeError): + get_singlets_default_timezone(series({})) + + +def test_aggregating_days_in_default_timezone(): + """As in the first steps: days start at midnight of the default time zone, which may + differ from UTC days and changes its UTC offset with daylight saving time""" + attrs = { + "dataset_metadata": {"single_metric": "a"}, + "metrics": [{"id": "a", "default_timezone": "Europe/Berlin"}], + } + # 2026-03-29 is the day daylight saving time starts in Germany, it has 23 hours + hourly = series( + attrs, + values=[1.0] * 26, + timestamps=pd.date_range("2026-03-28T23:00:00Z", periods=26, freq="1h"), + ) + + timezone = get_series_default_timezone(hourly) or "UTC" + daily_sums = modify_timezone(hourly, to_timezone=timezone).resample("1D").sum() + + assert daily_sums.to_dict() == { + pd.Timestamp("2026-03-29", tz="Europe/Berlin"): 23.0, + pd.Timestamp("2026-03-30", tz="Europe/Berlin"): 3.0, + } diff --git a/tests/metadata/test_metadata_fallbacks.py b/tests/metadata/test_metadata_fallbacks.py new file mode 100644 index 0000000..3455339 --- /dev/null +++ b/tests/metadata/test_metadata_fallbacks.py @@ -0,0 +1,341 @@ +"""Fallbacks for metadata following the current metadata convention + +Metadata providers like the hetida platform send fields without value explicitly as null. +These count as missing: The accessors fall back to the next level or field as if the field +was not provided at all. +""" + +from copy import deepcopy + +import pandas as pd +import pytest + +from hdhelpers.metadata import ( + get_display_names, + get_measurements, + get_metric_info, + get_names, + get_series_default_timezone, + get_series_display_name, + get_series_info, + get_series_measurement, + get_series_name, + get_series_short_display_name, + get_series_unit, + get_short_display_names, + get_singlets_display_names, + get_singlets_metric_info, + get_singlets_short_display_names, + get_singlets_units, + get_units, +) + +SIGNAL_ID = "7ab8c419-8745-4714-b588-57c0c772cc95" + + +def value_dimension(**fields): + """Value dimension "value" as the hetida platform sends it, with nulls for missing fields""" + return { + "column": "value", + "name": None, + "display_name": None, + "short_display_name": None, + "description": None, + "unit": None, + "measurement": None, + "value_data_type": "float", + } | fields + + +def metric(**fields): + """Metric as the hetida platform sends it, with nulls for missing fields""" + return { + "id": SIGNAL_ID, + "external_id": None, + "name": None, + "display_name": None, + "description": None, + "inherited": {}, + "comments": [], + "value_dimensions": [value_dimension()], + "default_aggregation_method": None, + } | fields + + +# what the hetida platform sends for a timeseries of a SERIES read +platform_series_attrs = { + "dataset_metadata": { + "metric_key": "id", + "single_metric": SIGNAL_ID, + "queried_metrics": [SIGNAL_ID], + "ref_interval_start_timestamp": "2026-10-18T00:00:00Z", + "ref_interval_end_timestamp": "2026-10-19T00:00:00Z", + "ref_interval_type": "closed", + }, + "metrics": [ + metric( + external_id="water.north.level", + name="Water-lvl-north", + value_dimensions=[ + value_dimension(name="Water-lvl-north", unit="m", measurement="level") + ], + default_aggregation_method="AVG", + ) + ], +} + +# what the hetida platform sends for a MULTITSFRAME read of a signal group: display_name of +# a metric is the display name configured for the member of the group +platform_multitsframe_attrs = { + "dataset_metadata": {"metric_key": "id", "queried_metrics": ["level", "flow"]}, + "metrics": [ + metric( + id="level", + name="Water-lvl-north", + value_dimensions=[value_dimension(name="Water-lvl-north", unit="m")], + ), + metric( + id="flow", + name="Pump-flow", + display_name="Flow pump 1", + value_dimensions=[value_dimension(name="Pump-flow")], + ), + ], +} + + +def series(attrs): + ser = pd.Series([1.0], index=pd.to_datetime(["2026-10-18T06:00:00Z"])) + ser.attrs = deepcopy(attrs) + return ser + + +def frame(attrs): + df = pd.DataFrame() + df.attrs = deepcopy(attrs) + return df + + +def test_series_from_platform(): + platform_series = series(platform_series_attrs) + + assert get_series_unit(platform_series) == "m" + assert get_series_measurement(platform_series) == "level" + assert get_series_name(platform_series) == "Water-lvl-north" + assert get_series_display_name(platform_series) == "Water-lvl-north" + assert get_series_short_display_name(platform_series) == "Water-lvl-north" + + +def test_multitsframe_from_platform(): + multitsframe = frame(platform_multitsframe_attrs) + + assert get_units(multitsframe)["level"]["value"] == "m" + assert get_units(multitsframe)["flow"]["value"] is None + assert get_names(multitsframe)["flow"]["value"] == "Pump-flow" + assert get_measurements(multitsframe)["level"]["value"] is None + # a null display name falls back to the name + assert get_display_names(multitsframe)["level"]["value"] == "Water-lvl-north" + assert get_short_display_names(multitsframe)["level"]["value"] == "Water-lvl-north" + # the display name of the metric wins over the name of its value dimension + assert get_display_names(multitsframe)["flow"]["value"] == "Flow pump 1" + assert get_short_display_names(multitsframe)["flow"]["value"] == "Flow pump 1" + + +def test_each_field_is_looked_up_on_all_levels_before_the_next_field(): + attrs = { + "metrics": [ + metric( + id="a", + name="metric name", + value_dimensions=[ + value_dimension(name="value name"), + value_dimension(column="state", name="state name"), + ], + ), + metric(id="b", name="metric name", display_name="metric display name"), + metric( + id="c", + display_name="metric display name", + value_dimensions=[value_dimension(display_name="value display name")], + ), + ], + "value_dimensions_shared": [{"column": "state", "display_name": "shared display name"}], + } + + display_names = get_display_names(frame(attrs)) + + assert display_names["a"]["value"] == "value name" + assert display_names["a"]["state"] == "shared display name" + assert display_names["b"]["value"] == "metric display name" + assert display_names["c"]["value"] == "value display name" + + +def test_singletsframe_display_names(): + attrs = { + "dataset_metadata": {"single_metric": "abc.temp", "metric_key": "external_id"}, + "metrics": [ + metric( + external_id="abc.temp", + name="ABC temperature", + display_name="ABC temp.", + value_dimensions=[ + value_dimension(name="temperature", unit="°C"), + value_dimension(column="state", name="measurement state"), + ], + ) + ], + } + singletsframe = frame(attrs) + + assert get_singlets_units(singletsframe)["value"] == "°C" + # only the value dimension "value" falls back to the metric + assert get_singlets_display_names(singletsframe)["value"] == "ABC temp." + assert get_singlets_display_names(singletsframe)["state"] == "measurement state" + assert get_singlets_short_display_names(singletsframe)["state"] == "measurement state" + + +def test_series_display_name_of_metric_wins_over_value_dimension_name(): + attrs = deepcopy(platform_series_attrs) + attrs["metrics"][0]["display_name"] = "Level north" + attrs["metrics"][0]["short_display_name"] = "north" + + assert get_series_display_name(series(attrs)) == "Level north" + assert get_series_short_display_name(series(attrs)) == "north" + + +def test_series_without_single_metric_uses_its_only_metric(): + attrs = deepcopy(platform_series_attrs) + del attrs["dataset_metadata"]["single_metric"] + + assert get_series_unit(series(attrs)) == "m" + assert get_series_display_name(series(attrs)) == "Water-lvl-north" + + +def test_single_metric_is_not_replaced_by_another_only_metric(): + """If the metadata lacks the single metric, the only metric present is another one""" + attrs = deepcopy(platform_series_attrs) + attrs["dataset_metadata"]["single_metric"] = "another signal" + + assert get_series_unit(series(attrs)) is None + assert get_series_display_name(series(attrs)) is None + assert get_singlets_units(frame(attrs))["value"] is None + assert get_singlets_display_names(frame(attrs))["value"] is None + assert get_singlets_metric_info(frame(attrs), "name") is None + + +@pytest.mark.parametrize("single_metric", [None, 1, [SIGNAL_ID]]) +def test_single_metric_which_is_no_string_counts_as_missing(single_metric): + attrs = deepcopy(platform_series_attrs) + attrs["dataset_metadata"]["single_metric"] = single_metric + + assert get_series_unit(series(attrs)) == "m" + assert get_singlets_units(frame(attrs))["value"] == "m" + assert get_singlets_metric_info(frame(attrs), "name") == "Water-lvl-north" + + +def test_series_with_metadata_of_several_metrics_has_no_metric(): + """E.g. a Series taken from a MultiTSFrame, whose metadata it carries + + A metric named "series" is only the metric of a Series in older metadata structures. + """ + attrs = { + "dataset_metadata": {"default_timezone": "UTC"}, + "metrics": [ + metric(id="series", name="Series", default_timezone="Asia/Tokyo"), + metric(id="other", name="Other"), + ], + } + + assert get_series_name(series(attrs)) is None + assert get_series_default_timezone(series(attrs)) == "UTC" + + +def test_only_the_single_metric_is_resolved(): + """The spec is not applied to other metrics, so failing on their null names does not matter""" + attrs = deepcopy(platform_series_attrs) + attrs["metrics"].append(metric(id="other")) + + assert get_series_info(series(attrs), ("name", str.upper)) == "WATER-LVL-NORTH" + assert get_singlets_metric_info(frame(attrs), ("name", str.upper)) == "WATER-LVL-NORTH" + + +def test_series_display_name_does_not_fall_back_to_short_display_name(): + """Like the display names of MultiTSFrames and SingleTSFrames""" + attrs = deepcopy(platform_series_attrs) + attrs["metrics"][0]["name"] = None + attrs["metrics"][0]["value_dimensions"][0]["name"] = None + attrs["metrics"][0]["short_display_name"] = "north" + + assert get_series_display_name(series(attrs)) is None + assert get_singlets_display_names(frame(attrs))["value"] is None + assert get_series_short_display_name(series(attrs)) == "north" + + +@pytest.mark.parametrize( + "attrs", + [{}, {"dataset_metadata": {"metric_key": "id"}}, {"metrics": None}], +) +def test_metric_info_without_metric_metadata(attrs): + """Components should not require metadata, so missing metric metadata must not raise""" + assert get_metric_info(frame(attrs), "name")["any metric"] is None + + +def test_metric_info_from_platform(): + multitsframe = frame(platform_multitsframe_attrs) + multitsframe.attrs["metrics"][0]["default_aggregation_method"] = "SUM" + + aggregation_methods = get_metric_info(multitsframe, "default_aggregation_method") + + assert aggregation_methods["level"] == "SUM" + assert aggregation_methods["flow"] is None + + +def test_metric_info_falls_back_only_for_metrics_lacking_it(): + """A spec transforming the information is not applied to fallback levels needlessly + + The null name of the value dimension of metric "a" would let str.upper fail. + """ + metric_b = metric(id="b", value_dimensions=[value_dimension(name="value name")]) + del metric_b["name"] + multitsframe = frame({"metrics": [metric(id="a", name="Sensor"), metric_b]}) + + names = get_metric_info(multitsframe, ("name", str.upper)) + + assert names["a"] == "SENSOR" + assert names["b"] == "VALUE NAME" + assert get_singlets_metric_info(frame(platform_series_attrs), ("name", str.upper)) == ( + "WATER-LVL-NORTH" + ) + + +def test_metric_info_raises_errors_of_the_spec(): + """Errors of the spec must not be mistaken for missing metric metadata""" + with pytest.raises(TypeError): + get_metric_info(frame(platform_multitsframe_attrs), ("display_name", str.upper)) + + +@pytest.mark.parametrize("include_inherited", [False, True]) +def test_short_display_names_of_metrics_resolved_by_different_fields(include_inherited): + """Each metric gets its information from the first field providing it, independently""" + attrs = { + "metrics": [ + metric( + id="a", + short_display_name="a short", + value_dimensions=[value_dimension(), value_dimension(column="state")], + ), + metric(id="b", display_name="b display", name="b name"), + metric(id="c", name="c name"), + metric(id="d", inherited={"display_name": "d inherited"}), + ], + "value_dimensions_shared": [{"column": "state", "name": "shared state"}], + } + + short_display_names = get_short_display_names(frame(attrs), include_inherited=include_inherited) + + assert short_display_names["a"]["value"] == "a short" + assert short_display_names["a"]["state"] == "shared state" + assert short_display_names["b"]["value"] == "b display" + assert short_display_names["c"]["value"] == "c name" + assert short_display_names["c"]["state"] == "shared state" + assert short_display_names["d"]["value"] == ("d inherited" if include_inherited else None) diff --git a/tests/metadata/test_metadata_recording.py b/tests/metadata/test_metadata_recording.py new file mode 100644 index 0000000..527bac6 --- /dev/null +++ b/tests/metadata/test_metadata_recording.py @@ -0,0 +1,183 @@ +"""Recording of the metrics and their default aggregation methods""" + +import pandas as pd +import pytest + +from hdhelpers.metadata import ( + Recording, + get_default_aggregation_methods, + get_recordings, + get_series_default_aggregation_method, + get_series_recording, + get_singlets_default_aggregation_method, + get_singlets_recording, +) + + +def frame(attrs): + df = pd.DataFrame() + df.attrs = attrs + return df + + +def series(attrs): + ser = pd.Series(dtype=float) + ser.attrs = attrs + return ser + + +def value_data_type(data_type): + return [{"column": "value", "unit": None, "measurement": None, "value_data_type": data_type}] + + +multitsframe_attrs = { + "dataset_metadata": { + "metric_key": "external_id", + "ref_data_frequency": "1h", + "ref_data_frequency_offset": "30min", + "ref_data_frequency_timezone": "Europe/Berlin", + }, + "metrics": [ + { + "external_id": "own_frequency", + "recording_mode": "periodic", + "data_frequency": "15min", + "recording_aggregation_method": "MEAN", + }, + # the offset and time zone of the metric only apply together with its frequency + { + "external_id": "without_frequency", + "recording_mode": "on_change", + "data_frequency_offset": "5min", + "data_frequency_timezone": "UTC", + }, + {"external_id": "null_frequency", "data_frequency": None}, + {"external_id": "no_string", "data_frequency": 15, "recording_mode": 1}, + ], +} + +dataset_frequency = { + "data_frequency": "1h", + "data_frequency_offset": "30min", + "data_frequency_timezone": "Europe/Berlin", +} + + +def test_recordings_fall_back_to_the_frequency_of_the_dataset_as_a_whole(): + recordings = get_recordings(frame(multitsframe_attrs)) + + assert recordings == { + "own_frequency": Recording( + recording_mode="periodic", + data_frequency="15min", + recording_aggregation_method="MEAN", + ), + "without_frequency": Recording(recording_mode="on_change", **dataset_frequency), + "null_frequency": Recording(**dataset_frequency), + "no_string": Recording(**dataset_frequency), + } + assert recordings["metric without metadata"] == Recording(**dataset_frequency) + + +@pytest.mark.parametrize( + "attrs", + [ + {}, + {"metrics": "no list"}, + # an older metadata structure, whose metrics are not considered + {"dataset_metadata": {}, "metrics": {"a": {"data_frequency": "1h"}}}, + ], +) +def test_recordings_without_metric_metadata(attrs): + recordings = get_recordings(frame(attrs)) + + assert recordings == {} + assert recordings["a"] == Recording() + + +def test_series_recording(): + attrs = { + "dataset_metadata": {"single_metric": "b", "ref_data_frequency": "1D"}, + "metrics": [{"id": "b", "recording_mode": "periodic", "data_frequency_offset": "1h"}], + } + + assert get_series_recording(series(attrs)) == Recording( + recording_mode="periodic", data_frequency="1D" + ) + + +def test_singlets_recording_falls_back_to_the_dataset_if_the_metric_is_not_found(): + attrs = { + "dataset_metadata": {"single_metric": "missing", "ref_data_frequency": "5min"}, + "metrics": [{"id": "a", "data_frequency": "1h"}, {"id": "b"}], + } + + assert get_singlets_recording(frame(attrs)) == Recording(data_frequency="5min") + + +def test_recording_functions_check_their_input(): + with pytest.raises(TypeError): + get_recordings(series({})) + with pytest.raises(TypeError): + get_series_recording(frame({})) + with pytest.raises(TypeError): + get_singlets_recording(series({})) + + +def test_default_aggregation_methods(): + attrs = { + "dataset_metadata": {"metric_key": "id"}, + "metrics": [ + {"id": "avg", "default_aggregation_method": "AVG"}, + {"id": "sum", "default_aggregation_method": "SUM"}, + {"id": "float", "value_dimensions": value_data_type("float")}, + {"id": "integer", "value_dimensions": value_data_type("integer")}, + {"id": "boolean", "value_dimensions": value_data_type("boolean")}, + {"id": "string", "value_dimensions": value_data_type("string")}, + # the method of the metric takes precedence over the default of its data type + { + "id": "boolean_max", + "default_aggregation_method": "MAX", + "value_dimensions": value_data_type("boolean"), + }, + {"id": "null", "default_aggregation_method": None}, + {"id": "shared_data_type", "value_dimensions": []}, + ], + "value_dimensions_shared": [{"column": "value", "value_data_type": "string"}], + } + + methods = get_default_aggregation_methods(frame(attrs)) + + assert methods == { + "avg": "MEAN", + "sum": "SUM", + "float": "MEAN", + "integer": "MEAN", + "boolean": "LAST", + "string": "LAST", + "boolean_max": "MAX", + # the value data type of the shared value dimension "value" applies + "null": "LAST", + "shared_data_type": "LAST", + } + assert methods["metric without metadata"] == "MEAN" + + +@pytest.mark.parametrize( + ("attrs", "method"), + [ + ({}, "MEAN"), + ({"metrics": {"a": {"default_aggregation_method": "SUM"}}}, "MEAN"), + ( + { + "dataset_metadata": {"single_metric": "a"}, + "metrics": [{"id": "a", "value_dimensions": value_data_type("boolean")}], + }, + "LAST", + ), + ({"metrics": [{"id": "a", "default_aggregation_method": "AVG"}]}, "MEAN"), + ], +) +def test_single_metric_default_aggregation_method(attrs, method): + assert get_series_default_aggregation_method(series(attrs)) == method + assert get_singlets_default_aggregation_method(frame(attrs)) == method diff --git a/tests/metadata/test_metadata_without_metadata.py b/tests/metadata/test_metadata_without_metadata.py new file mode 100644 index 0000000..3a2c344 --- /dev/null +++ b/tests/metadata/test_metadata_without_metadata.py @@ -0,0 +1,51 @@ +import pandas as pd +import pytest + +from hdhelpers.metadata import without_metadata + + +class NotCopyable: + """Metadata which fails if it is copied""" + + def __deepcopy__(self, memo): + raise AssertionError("The metadata was copied") + + +@pytest.mark.parametrize( + "data", + [ + pd.DataFrame({"metric": ["a", "b"], "value": [1.0, 2.0]}), + pd.Series([1.0, 2.0], index=pd.date_range("2026-01-01", periods=2, tz="UTC"), name="a"), + ], +) +def test_without_metadata(data): + metadata = {"metrics": [{"id": "a"}], "not copyable": NotCopyable()} + data.attrs = metadata + + copy = without_metadata(data) + + assert copy.attrs == {} + assert data.attrs == metadata + # comparing the data creates objects from it, which would copy its metadata + data.attrs = {} + pd.testing.assert_index_equal(copy.index, data.index) + if isinstance(data, pd.Series): + assert copy.name == data.name + pd.testing.assert_series_equal(copy, data, check_flags=False) + else: + pd.testing.assert_frame_equal(copy, data, check_flags=False) + + +def test_changing_the_copy_does_not_change_the_data(): + data = pd.DataFrame({"value": [1.0, 2.0]}) + data.attrs = {"metrics": []} + + copy = without_metadata(data) + copy.loc[0, "value"] = -1.0 + + assert data.loc[0, "value"] == 1.0 + + +def test_without_metadata_requires_series_or_dataframe(): + with pytest.raises(TypeError): + without_metadata([1.0, 2.0])