Skip to content

Commit eb43bc8

Browse files
authored
Feat/step function auto period (#102)
* Support for step functions for the periods of statistics entities * Lower limit of auto period is inclusive
1 parent 6d3ac85 commit eb43bc8

File tree

8 files changed

+179
-33
lines changed

8 files changed

+179
-33
lines changed

dist/plotly-graph-card.js

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

readme.md

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -180,11 +180,24 @@ entities:
180180

181181
Fetch and plot long-term statistics of an entity
182182

183+
#### for entities with state_class=measurement (normal sensors, like temperature)
184+
185+
```yaml
186+
type: custom:plotly-graph
187+
entities:
188+
- entity: sensor.temperature
189+
statistic: max # `min`, `mean` of `max`
190+
period: 5minute # `5minute`, `hour`, `day`, `week`, `month`, `auto` # `auto` varies the period depending on the zoom level
191+
```
192+
193+
The option `auto` makes the period relative to the currently visible time range. It picks the longest period, such that there are at least 100 datapoints in screen.
194+
195+
#### for entities with state_class=measurement (normal sensors, like temperature)
196+
183197
```yaml
184198
type: custom:plotly-graph
185199
entities:
186200
- entity: sensor.temperature
187-
# for entities with state_class=measurement (normal sensors, like temperature):
188201
statistic: max # `min`, `mean` of `max`
189202
# for entities with state_class=total (such as utility meters):
190203
statistic: state # `state` or `sum`
@@ -193,9 +206,22 @@ entities:
193206

194207
```
195208

196-
Note that `5minute` period statistics are limited in time as normal recorder history is, contrary to other periods which keep data for years.
209+
#### step function for auto period
197210

198-
The option `auto` makes the period relative to the currently visible time range. It picks the longest period, such that there are at least 500 datapoints in screen.
211+
```yaml
212+
type: custom:plotly-graph
213+
entities:
214+
- entity: sensor.temperature
215+
statistic: mean
216+
period:
217+
0: 5minute
218+
24h: hour # when the visible range is ≥ 1 day, use the `hour` period
219+
7d: day # from 7 days on, use `day`
220+
# 6M: week # not yet supported in HA
221+
1y: month # from 1 year on, use `month
222+
```
223+
224+
Note that `5minute` period statistics are limited in time as normal recorder history is, contrary to other periods which keep data for years.
199225

200226
## Extra entity attributes:
201227

src/duration/duration.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { parseTimeDuration } from "./duration";
2+
3+
describe("data-ranges", () => {
4+
const ms = 1;
5+
const s = ms * 1000;
6+
const m = s * 60;
7+
const h = m * 60;
8+
const d = h * 24;
9+
const w = d * 7;
10+
const M = d * 30;
11+
const y = d * 365;
12+
it("Should parse all units", () => {
13+
expect(parseTimeDuration("1ms")).toBe(1 * ms);
14+
expect(parseTimeDuration("1s")).toBe(1 * s);
15+
expect(parseTimeDuration("1m")).toBe(1 * m);
16+
expect(parseTimeDuration("1h")).toBe(1 * h);
17+
expect(parseTimeDuration("1d")).toBe(1 * d);
18+
expect(parseTimeDuration("1w")).toBe(1 * w);
19+
expect(parseTimeDuration("1M")).toBe(1 * M);
20+
expect(parseTimeDuration("1y")).toBe(1 * y);
21+
});
22+
it("Should parse all signs", () => {
23+
expect(parseTimeDuration("1ms")).toBe(1 * ms);
24+
expect(parseTimeDuration("+1ms")).toBe(1 * ms);
25+
expect(parseTimeDuration("-1ms")).toBe(-1 * ms);
26+
});
27+
it("Should parse all numbers", () => {
28+
expect(parseTimeDuration("1s")).toBe(1 * s);
29+
expect(parseTimeDuration("1.5s")).toBe(1.5 * s);
30+
});
31+
it("Should parse undefined", () => {
32+
expect(parseTimeDuration(undefined)).toBe(0);
33+
});
34+
it("Should throw when it can't parse", () => {
35+
// @ts-expect-error
36+
expect(() => parseTimeDuration("1")).toThrow();
37+
// @ts-expect-error
38+
expect(() => parseTimeDuration("s")).toThrow();
39+
// @ts-expect-error
40+
expect(() => parseTimeDuration("--1s")).toThrow();
41+
// @ts-expect-error
42+
expect(() => parseTimeDuration("-1.1.1s")).toThrow();
43+
});
44+
});

src/duration/duration.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
const timeUnits = {
2+
ms: 1,
3+
s: 1000,
4+
m: 1000 * 60,
5+
h: 1000 * 60 * 60,
6+
d: 1000 * 60 * 60 * 24,
7+
w: 1000 * 60 * 60 * 24 * 7,
8+
M: 1000 * 60 * 60 * 24 * 30,
9+
y: 1000 * 60 * 60 * 24 * 365,
10+
};
11+
type TimeUnit = keyof typeof timeUnits;
12+
export type TimeDurationStr = `${number}${TimeUnit}` | `0`;
13+
14+
/**
15+
*
16+
* @param str 1.5s, -2m, 1h, 1d, 1w, 1M, 1.5y
17+
* @returns duration in milliseconds
18+
*/
19+
export const parseTimeDuration = (str: TimeDurationStr | undefined): number => {
20+
if (!str) return 0;
21+
if (str === "0") return 0;
22+
if (!str.match) return 0;
23+
const match = str.match(
24+
/^(?<sign>[+-])?(?<number>\d*(\.\d)?)(?<unit>(ms|s|m|h|d|w|M|y))$/
25+
);
26+
if (!match || !match.groups)
27+
throw new Error(`Cannot parse "${str}" as a duration`);
28+
const g = match.groups;
29+
const sign = g.sign === "-" ? -1 : 1;
30+
const number = parseFloat(g.number);
31+
if (Number.isNaN(number))
32+
throw new Error(`Cannot parse "${str}" as a duration`);
33+
const unit = timeUnits[g.unit as TimeUnit];
34+
if (unit === undefined)
35+
throw new Error(`Cannot parse "${str}" as a duration`);
36+
37+
return sign * number * unit;
38+
};

src/plotly-graph-card.ts

Lines changed: 32 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@ import {
2424
STATISTIC_PERIODS,
2525
STATISTIC_TYPES,
2626
StatisticPeriod,
27+
isAutoPeriodConfig as getIsAutoPeriodConfig,
2728
} from "./recorder-types";
29+
import { parseTimeDuration } from "./duration/duration";
2830

2931
const componentName = isProduction ? "plotly-graph" : "plotly-graph-dev";
3032

@@ -237,10 +239,23 @@ export class PlotlyGraph extends HTMLElement {
237239
if ("statistic" in entity || "period" in entity) {
238240
const validStatistic = STATISTIC_TYPES.includes(entity.statistic!);
239241
if (!validStatistic) entity.statistic = "mean";
240-
const validPeriod = STATISTIC_PERIODS.includes(entity.period);
242+
console.log(entity.period);
243+
// @TODO: cleanup how this is done
241244
if (entity.period === "auto") {
242-
entity.autoPeriod = true;
245+
entity.autoPeriod = {
246+
"0": "5minute",
247+
"1d": "hour",
248+
"7d": "day",
249+
// "28d": "week",
250+
"12M": "month",
251+
};
252+
}
253+
const isAutoPeriodConfig = getIsAutoPeriodConfig(entity.period);
254+
255+
if (isAutoPeriodConfig) {
256+
entity.autoPeriod = entity.period;
243257
}
258+
const validPeriod = STATISTIC_PERIODS.includes(entity.period);
244259
if (!validPeriod) entity.period = "hour";
245260
}
246261
const [oldAPI_entity, oldAPI_attribute] = entity.entity.split("::");
@@ -307,23 +322,23 @@ export class PlotlyGraph extends HTMLElement {
307322
for (const entity of this.config.entities) {
308323
if ((entity as any).autoPeriod) {
309324
if (isEntityIdStatisticsConfig(entity) && entity.autoPeriod) {
310-
const spanInMinutes = (range[1] - range[0]) / 1000 / 60;
311-
const MIN_POINTS_PER_RANGE = 100;
312-
let period: StatisticPeriod = "5minute";
313-
const period2minutes: [StatisticPeriod, number][] = [
314-
// needs to be sorted in ascending order
315-
["hour", 60],
316-
["day", 60 * 24],
317-
// ["week", 60 * 24 * 7], not supported yet in HA
318-
["month", 60 * 24 * 30],
319-
];
320-
for (const [aPeriod, minutesPerPoint] of period2minutes) {
321-
const pointsInSpan = spanInMinutes / minutesPerPoint;
322-
if (pointsInSpan > MIN_POINTS_PER_RANGE) period = aPeriod;
325+
entity.period = "5minute";
326+
const timeSpan = range[1] - range[0];
327+
const mapping = Object.entries(entity.autoPeriod)
328+
.map(
329+
([duration, period]) =>
330+
[parseTimeDuration(duration as any), period] as [
331+
number,
332+
StatisticPeriod
333+
]
334+
)
335+
.sort(([durationA], [durationB]) => durationA - durationB);
336+
337+
for (const [fromMS, aPeriod] of mapping) {
338+
if (timeSpan >= fromMS) entity.period = aPeriod;
323339
}
324-
entity.period = period;
325340
this.config.layout = merge(this.config.layout, {
326-
xaxis: { title: `Period: ${period}` },
341+
xaxis: { title: `Period: ${entity.period}` },
327342
});
328343
}
329344
}

src/recorder-types.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
// https://github.com/home-assistant/frontend/blob/dev/src/data/recorder.ts
2+
import { keys } from "lodash";
3+
import { parseTimeDuration, TimeDurationStr } from "./duration/duration";
4+
25
export interface StatisticValue {
36
statistic_id: string;
47
start: string;
@@ -25,3 +28,21 @@ export const STATISTIC_PERIODS = [
2528
"month",
2629
] as const;
2730
export type StatisticPeriod = typeof STATISTIC_PERIODS[number];
31+
export type AutoPeriodConfig = Record<TimeDurationStr, StatisticPeriod>;
32+
33+
export function isAutoPeriodConfig(val: any): val is AutoPeriodConfig {
34+
const isObject =
35+
typeof val === "object" && val !== null && !Array.isArray(val);
36+
if (!isObject) return false;
37+
const entries = Object.entries(val);
38+
if (entries.length === 0) return false;
39+
return entries.every(([duration, period]) => {
40+
if (!STATISTIC_PERIODS.includes(period as any)) return false;
41+
try {
42+
parseTimeDuration(duration as any);
43+
} catch (e) {
44+
return false;
45+
}
46+
return true;
47+
});
48+
}

src/types.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Datum } from "plotly.js";
22
import { ColorSchemeArray, ColorSchemeNames } from "./color-schemes";
33
import {
4+
AutoPeriodConfig,
45
StatisticPeriod,
56
StatisticType,
67
StatisticValue,
@@ -16,7 +17,7 @@ export type InputConfig = {
1617
entity: string;
1718
attribute?: string;
1819
statistic?: StatisticType;
19-
period?: StatisticPeriod | "auto";
20+
period?: StatisticPeriod | "auto" | AutoPeriodConfig;
2021
unit_of_measurement?: string;
2122
lambda?: string;
2223
show_value?:
@@ -74,7 +75,7 @@ export type EntityIdStatisticsConfig = {
7475
entity: string;
7576
statistic: StatisticType;
7677
period: StatisticPeriod;
77-
autoPeriod: boolean;
78+
autoPeriod: AutoPeriodConfig;
7879
};
7980
export type EntityIdConfig =
8081
| EntityIdStateConfig

src/utils.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1-
export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
1+
export const sleep = (ms: number) =>
2+
new Promise((resolve) => setTimeout(resolve, ms));

0 commit comments

Comments
 (0)