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:
readUType | Populated | Value lives at |
|---|---|---|
"basicRead" | basicRead | value — a single total for the period |
"intervalRead" | intervalRead | aggregateValue, 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
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
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)
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" => %{}})
nilWhen 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")
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}]