Skip to content

Commit b7dd6d8

Browse files
committed
Cost chip: follow the scrub + draw the ring for multi-tariff
The cost chip now reads the cost at the hovered instant while scrubbing (like the other chips) instead of staying on the live value; and its around-house day curve now samples HA's cost statistics per slot, so it draws for a multi-tariff grid, not only a fixed price. Rolls 2026.9.0-a5.
1 parent 6aaf87f commit b7dd6d8

5 files changed

Lines changed: 55 additions & 30 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ and the project follows a date-based versioning scheme (`YEAR.MONTH.PATCH`).
1111

1212
The weather release, "Your real sky": the scene now reflects the weather over
1313
your home, and the outdoor temperature and humidity join the card. Currently in
14-
alpha (`2026.9.0-a4`).
14+
alpha (`2026.9.0-a5`).
1515

1616
### Added: a cost chip
1717

@@ -32,7 +32,9 @@ statistic still works, computed as energy times price. **Multi-tariff grids
3232
price per tariff and never an explicit cost sensor: Helios finds Home Assistant's
3333
own per-tariff cost statistics and sums them. And the chip is **fully in the
3434
editor** now, show or hide it, and pick its colour and icon, like every other
35-
chip.
35+
chip. The chip **follows the timeline** as you scrub (the cost at the hovered
36+
moment, like the other chips), and its **day curve around the house** now draws
37+
for multi-tariff grids too, not just a fixed price.
3638

3739
### Added: the real weather, painted over the scene
3840

dist/helios.js

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "helios",
3-
"version": "2026.9.0-a4",
3+
"version": "2026.9.0-a5",
44
"description": "HELIOS - real-time solar exposure, cloud cover and PV production card for Home Assistant",
55
"license": "GPL-3.0-or-later",
66
"type": "module",

src/data/period-totals/day-profile.ts

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import { TIMELINE_MODES } from '../../timeline/timeline-modes';
2424
import { buildPeriodData, binSlotSum, type PeriodHost, type PeriodLayer } from './period-totals';
2525
import { changeSeriesToWatts } from '../sources/energy-stats';
2626
import { groupDevices } from '../sources/device-consumption';
27-
import { staticPrice } from '../sources/cost';
27+
import { staticPrice, costRateAt } from '../sources/cost';
2828
import type { PeriodWindow } from './slot-walk';
2929

3030
//One drawn line of the day, unit-agnostic. `colour` is a descriptor the card resolves; everything else is the data
@@ -467,22 +467,37 @@ export function buildDayProfile(host: PeriodHost, target: ChartTarget, dayMs: nu
467467

468468
if (target === 'cost')
469469
{
470-
const store = host._unifiedStore;
471-
if (!store) { return []; }
472-
//Static price only for now (cost = energy x constant price, exact across the whole history). The ring shows
473-
//the SPENDING magnitude per slot (currency/hour, floored at 0); earning hours read as no ring, since the
474-
//around-house ring is a positive-magnitude shape. store powers are watts, hence /1000.
475-
const impP = staticPrice(host._energyDefaults.gridImportPriceNumbers);
476-
if (impP === null) { return []; }
477-
const expP = staticPrice(host._energyDefaults.gridExportPriceNumbers) ?? 0;
478-
const imp = binSlotAvg(store, store.gridImport, slots, win);
479-
const exp = binSlotAvg(store, store.gridExport, slots, win);
480-
const values = imp.map((iv, i) =>
470+
//The ring shows the SPENDING magnitude per slot (currency/hour, floored at 0); earning hours read as no
471+
//ring, since the around-house ring is a positive-magnitude shape. Preferred source: HA's cost statistics
472+
//(any tariff), sampled at each slot's midpoint. Fallback: a single static price x grid energy (store
473+
//powers are watts, hence /1000).
474+
const hasStats = !!(host._costImportSeries || host._costExportSeries);
475+
let values: (number | null)[];
476+
if (hasStats)
481477
{
482-
const ev = exp[i];
483-
if ((iv === null || !isFinite(iv)) && (ev === null || !isFinite(ev))) { return null; }
484-
return Math.max(0, ((iv ?? 0) * impP - (ev ?? 0) * expP) / 1000);
485-
});
478+
const slotLen = DAY_MS / slots;
479+
values = Array.from({ length: slots }, (_v, s) =>
480+
{
481+
const r = costRateAt(host, win.fromMs + (s + 0.5) * slotLen);
482+
return r === null ? null : Math.max(0, r);
483+
});
484+
}
485+
else
486+
{
487+
const store = host._unifiedStore;
488+
if (!store) { return []; }
489+
const impP = staticPrice(host._energyDefaults.gridImportPriceNumbers);
490+
if (impP === null) { return []; }
491+
const expP = staticPrice(host._energyDefaults.gridExportPriceNumbers) ?? 0;
492+
const imp = binSlotAvg(store, store.gridImport, slots, win);
493+
const exp = binSlotAvg(store, store.gridExport, slots, win);
494+
values = imp.map((iv, i) =>
495+
{
496+
const ev = exp[i];
497+
if ((iv === null || !isFinite(iv)) && (ev === null || !isFinite(ev))) { return null; }
498+
return Math.max(0, ((iv ?? 0) * impP - (ev ?? 0) * expP) / 1000);
499+
});
500+
}
486501
dropShortRuns(values);
487502
const peak = peakOf(values);
488503
if (peak <= 0) { return []; }

src/data/sources/cost.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ export interface CostHost
2929
//Rolling past-window days + recorder period, for the cost `change` fetch (same span as the store).
3030
readonly _periodPastDays: number;
3131
readonly _storeFetchPeriod: StatPeriod;
32+
//Scrub state, so the chip reads the cost at the hovered time (like the other chips) rather than only live.
33+
readonly _selectedTime: Date | null;
34+
readonly _isLiveMode: boolean;
3235
requestUpdate(): void;
3336
//Recorder `change` series for the cost (`stat_cost`) + compensation (`stat_compensation`) statistics, summed
3437
//across flows. Δ per bucket is money, so a rate is Δ / bucketHours. Null until first fetch (or none configured).
@@ -133,13 +136,18 @@ export function refreshCostLive(host: CostHost): void
133136
{
134137
host._currency = String(host.hass?.config?.currency ?? '€');
135138

136-
//1) Universal path: HA's own cost statistics (correct for any tariff, incl. Tempo + a total-cost sensor). Live
137-
// is the most recent committed bucket's net rate. Preferred whenever cost stats are configured.
138-
const fromStats = latestCostRate(host);
139-
if (fromStats !== null)
139+
//1) Universal path: HA's own cost statistics (correct for any tariff, incl. Tempo + a total-cost sensor).
140+
// Scrub-aware like every other chip: the rate at the hovered instant while scrubbing, else the most recent
141+
// committed bucket when live.
142+
if (host._costImportSeries || host._costExportSeries)
140143
{
141-
if (host._costRate !== fromStats) { host._costRate = fromStats; }
142-
return;
144+
const scrubMs = (host._selectedTime && !host._isLiveMode) ? host._selectedTime.getTime() : null;
145+
const fromStats = scrubMs !== null ? costRateAt(host, scrubMs) : latestCostRate(host);
146+
if (fromStats !== null)
147+
{
148+
if (host._costRate !== fromStats) { host._costRate = fromStats; }
149+
return;
150+
}
143151
}
144152

145153
//2) Fallback: a single configured PRICE x live power (no cost statistic, e.g. only a static/current price set).

0 commit comments

Comments
 (0)