OpenFeed.Energy (OpenFeed v0.1.0)

Copy Markdown View Source

Helpers for reading OpenFeed's energy usage payloads.

Why this exists

A usage reading is the most awkward shape in the sharing API, and getting it wrong fails quietly rather than loudly. One day's usage looks like this:

%{
  "meterId" => "…",
  "intervalDate" => "2026-03-11",
  "reads" => [
    %{
      "readUType" => "intervalRead",
      "intervalRead" => %{
        "readIntervalLength" => 30,
        "aggregateValue" => 12.5,
        "intervalReads" => [0.2, 0.3, ...]
      }
    }
  ]
}

The trap is readUType. It is a discriminator, and it selects which of two differently-shaped siblings is populated:

readUTypePopulatedValue lives at
"basicRead"basicReadvalue — a single total for the period
"intervalRead"intervalReadaggregateValue, and intervalReads

Which one you get depends on the meter: EnergyMeter.detectedReadType is "daily" for meters that report a total (basicRead) and "interval" for meters that report fixed-length intervals, typically 15 or 30 minutes (intervalRead).

Code that only reads intervalRead.aggregateValue therefore works perfectly against an interval meter and silently reports zero for every daily meter. That is not hypothetical: it is the bug this module was extracted to fix.

Signs

Positive is consumption, negative is export — a meter with solar generation reports negative values when exporting. net_usage/1 sums them as-is, so the result is net usage. If you need consumption and export separately, walk reads/1 yourself.

Summary

Functions

Whether a reading actually carried any reads.

The net usage for one reading, summed across all its reads.

The value of a single read, whichever variant it is.

The reads list for a reading, always a list.

Functions

has_reads?(reading)

@spec has_reads?(map()) :: boolean()

Whether a reading actually carried any reads.

Lets you tell "no data returned" apart from "zero usage recorded", which net_usage/1 deliberately collapses.

Examples

iex> OpenFeed.Energy.has_reads?(%{"reads" => [%{"readUType" => "basicRead"}]})
true

iex> OpenFeed.Energy.has_reads?(%{"reads" => []})
false

iex> OpenFeed.Energy.has_reads?(%{})
false

net_usage(reading)

@spec net_usage(map()) :: Decimal.t()

The net usage for one reading, summed across all its reads.

Handles both readUType variants, and prefers intervalRead.aggregateValue over re-summing intervalReads when both are present, since the aggregate is what the data holder computed.

Returns Decimal.new(0) when there are no reads, which keeps sums and averages free of nil guards. That does mean "the provider returned no reads" and "the meter recorded zero" look identical here — use has_reads?/1 when the difference matters, for instance to leave a gap in a chart rather than plotting a misleading zero.

Examples

An interval meter:

iex> OpenFeed.Energy.net_usage(%{
...>   "reads" => [
...>     %{"readUType" => "intervalRead", "intervalRead" => %{"aggregateValue" => 12.5}}
...>   ]
...> })
Decimal.new("12.5")

A daily meter — the case that silently returned zero before this existed:

iex> OpenFeed.Energy.net_usage(%{
...>   "reads" => [
...>     %{"readUType" => "basicRead", "basicRead" => %{"value" => 8.25}}
...>   ]
...> })
Decimal.new("8.25")

Multiple registers on one meter sum together:

iex> OpenFeed.Energy.net_usage(%{
...>   "reads" => [
...>     %{"readUType" => "basicRead", "basicRead" => %{"value" => 3}},
...>     %{"readUType" => "basicRead", "basicRead" => %{"value" => 4}}
...>   ]
...> })
Decimal.new(7)

Export is negative, so net usage can be too:

iex> OpenFeed.Energy.net_usage(%{
...>   "reads" => [
...>     %{"readUType" => "intervalRead", "intervalRead" => %{"aggregateValue" => -4.5}}
...>   ]
...> })
Decimal.new("-4.5")

No reads gives zero:

iex> OpenFeed.Energy.net_usage(%{"reads" => []})
Decimal.new(0)

iex> OpenFeed.Energy.net_usage(%{})
Decimal.new(0)

read_value(read)

@spec read_value(map()) :: Decimal.t() | nil

The value of a single read, whichever variant it is.

Returns nil when the read carries no usable value, so an unrecognised or future readUType is skipped rather than counted as zero.

Examples

iex> OpenFeed.Energy.read_value(%{"readUType" => "basicRead", "basicRead" => %{"value" => 1.5}})
Decimal.new("1.5")

iex> OpenFeed.Energy.read_value(%{"readUType" => "somethingNew", "somethingNew" => %{}})
nil

When an interval read has no aggregate, the individual intervals are summed:

iex> OpenFeed.Energy.read_value(%{
...>   "readUType" => "intervalRead",
...>   "intervalRead" => %{"intervalReads" => [1.0, 2.0, 0.5]}
...> })
Decimal.new("3.5")

reads(reading)

@spec reads(map()) :: [map()]

The reads list for a reading, always a list.

Examples

iex> OpenFeed.Energy.has_reads?(%{"reads" => nil})
false

iex> OpenFeed.Energy.reads(%{"reads" => [%{"a" => 1}]})
[%{"a" => 1}]