Skip to content

Commit b64316e

Browse files
committed
feat(#264): Option to auto set the unit_prefix based on the value
When unit_prefix:'auto' is used, the appropriate prefix is chosen automatically for each value based on its magnitude (m for values <1, k for values >=1000, etc.
1 parent 17bfea8 commit b64316e

File tree

6 files changed

+241
-21
lines changed

6 files changed

+241
-21
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ Install through [HACS](https://hacs.xyz/)
2929
| layout | string | auto | Valid options are: 'horizontal' - flow left to right, 'vertical' - flow top to bottom & 'auto' - determine based on available space (based on the section->`min_witdh` option, which defaults to 150)
3030
| energy_date_selection | boolean | false | Integrate with the Energy Dashboard. Filters data based on the [energy-date-selection](https://www.home-assistant.io/dashboards/energy/) card. Use this only for accumulated data sensors (energy/water/gas) and with a `type:energy-date-selection` card. You still need to specify all your entities as HA doesn't know exactly how to connect them but you can use the general kWh entities that you have in the energy dashboard. In the future we may use areas to auto configure the chart. Not compatible with `time_period`
3131
| title | string | | Optional header title for the card
32-
| unit_prefix | string | | Metric prefix for the unit of measurment. See <https://en.wikipedia.org/wiki/Unit_prefix> . Supported values are m, k, M, G, T
32+
| unit_prefix | string | | Metric prefix for the unit of measurment. See <https://en.wikipedia.org/wiki/Unit_prefix> . Supported values are m, k, M, G, T, and 'auto'. When 'auto' is used, the appropriate prefix is chosen automatically for each value based on its magnitude (m for values <1, k for values >=1000, etc.)
3333
| round | number | 0 | Round the value to at most N decimal places. May not apply to near zero values, see issue [#29](https://github.com/MindFreeze/ha-sankey-chart/issues/29)
3434
| height | number | 200 | The height of the card in pixels. Only matters while in horizontal layout. Vertical layout height is dynamic based on content
3535
| wide | boolean | false | Set this to true if you see extra empty space on the right side of the card. This will expand it horizontally to cover all the available space. Only relevant in horizontal mode.

__tests__/normalizeStateValue.test.ts

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import { normalizeStateValue } from '../src/utils';
2+
3+
describe('normalizeStateValue', () => {
4+
// Test with no unit of measurement
5+
test('returns original state when no unit of measurement is provided', () => {
6+
const result = normalizeStateValue('', 123, undefined);
7+
expect(result).toEqual({ state: 123, unit_of_measurement: undefined });
8+
});
9+
10+
// Test with monetary unit
11+
test('returns original state when unit is monetary', () => {
12+
const result = normalizeStateValue('', 123, 'monetary');
13+
expect(result).toEqual({ state: 123, unit_of_measurement: 'monetary' });
14+
});
15+
16+
// Test with negative values
17+
test('handles negative values by converting to zero', () => {
18+
const result = normalizeStateValue('', -123, 'W');
19+
expect(result).toEqual({ state: 0, unit_of_measurement: 'W' });
20+
});
21+
22+
// Test with NaN
23+
test('handles NaN values by converting to zero', () => {
24+
const result = normalizeStateValue('', NaN, 'W');
25+
expect(result).toEqual({ state: 0, unit_of_measurement: 'W' });
26+
});
27+
28+
// Test with explicit unit prefixes
29+
test('converts values with explicit unit prefixes', () => {
30+
// No conversion needed (same prefix)
31+
let result = normalizeStateValue('k', 5, 'kW');
32+
expect(result).toEqual({ state: 5, unit_of_measurement: 'kW' });
33+
34+
// Convert from kW to W
35+
result = normalizeStateValue('', 5, 'kW');
36+
expect(result).toEqual({ state: 5000, unit_of_measurement: 'W' });
37+
38+
// Convert from W to kW
39+
result = normalizeStateValue('k', 5000, 'W');
40+
expect(result).toEqual({ state: 5, unit_of_measurement: 'kW' });
41+
42+
// Convert from MW to kW
43+
result = normalizeStateValue('k', 5, 'MW');
44+
expect(result).toEqual({ state: 5000, unit_of_measurement: 'kW' });
45+
});
46+
47+
// Test adding a prefix to a unit without one
48+
test('adds prefix to unit without one', () => {
49+
const result = normalizeStateValue('k', 5000, 'W');
50+
expect(result).toEqual({ state: 5, unit_of_measurement: 'kW' });
51+
});
52+
53+
// Test auto unit prefix selection when enableAutoPrefix is true
54+
describe('auto unit prefix selection (enableAutoPrefix=true)', () => {
55+
// Test values less than 1 (should use milli)
56+
test('selects milli prefix for values < 1', () => {
57+
const result = normalizeStateValue('auto', 0.123, 'W', true);
58+
expect(result.unit_of_measurement).toBe('mW');
59+
expect(result.state).toBeCloseTo(123);
60+
});
61+
62+
// Test values 1-999 (should use no prefix)
63+
test('selects no prefix for values between 1 and 999', () => {
64+
const result = normalizeStateValue('auto', 123, 'W', true);
65+
expect(result.unit_of_measurement).toBe('W');
66+
expect(result.state).toBe(123);
67+
});
68+
69+
// Test values 1000-999999 (should use kilo)
70+
test('selects kilo prefix for values between 1000 and 999999', () => {
71+
const result = normalizeStateValue('auto', 123000, 'W', true);
72+
expect(result.unit_of_measurement).toBe('kW');
73+
expect(result.state).toBe(123);
74+
});
75+
76+
// Test values 1000000-999999999 (should use mega)
77+
test('selects mega prefix for values between 1000000 and 999999999', () => {
78+
const result = normalizeStateValue('auto', 123000000, 'W', true);
79+
expect(result.unit_of_measurement).toBe('MW');
80+
expect(result.state).toBe(123);
81+
});
82+
83+
// Test values 1000000000-999999999999 (should use giga)
84+
test('selects giga prefix for values between 1000000000 and 999999999999', () => {
85+
const result = normalizeStateValue('auto', 123000000000, 'W', true);
86+
expect(result.unit_of_measurement).toBe('GW');
87+
expect(result.state).toBe(123);
88+
});
89+
90+
// Test values >= 1000000000000 (should use tera)
91+
test('selects tera prefix for values >= 1000000000000', () => {
92+
const result = normalizeStateValue('auto', 123000000000000, 'W', true);
93+
expect(result.unit_of_measurement).toBe('TW');
94+
expect(result.state).toBe(123);
95+
});
96+
97+
// Test with input that already has a prefix
98+
test('handles input with existing prefix', () => {
99+
// 5 kW (5000 W) should be displayed as 5 kW
100+
let result = normalizeStateValue('auto', 5, 'kW', true);
101+
expect(result.unit_of_measurement).toBe('kW');
102+
expect(result.state).toBe(5);
103+
104+
// 5000 kW (5000000 W) should be displayed as 5 MW
105+
result = normalizeStateValue('auto', 5000, 'kW', true);
106+
expect(result.unit_of_measurement).toBe('MW');
107+
expect(result.state).toBe(5);
108+
109+
// 0.001 kW (1 W) should be displayed as 1 W
110+
result = normalizeStateValue('auto', 0.001, 'kW', true);
111+
expect(result.unit_of_measurement).toBe('W');
112+
expect(result.state).toBe(1);
113+
114+
// 0.0005 kW (0.5 W) should be displayed as 500 mW
115+
result = normalizeStateValue('auto', 0.0005, 'kW', true);
116+
// In the current implementation, the function replaces 'k' with 'm'
117+
expect(result.unit_of_measurement).toBe('mW');
118+
expect(result.state).toBe(500);
119+
});
120+
});
121+
122+
// Test auto unit prefix option when enableAutoPrefix is false (default)
123+
describe('auto unit prefix selection (enableAutoPrefix=false)', () => {
124+
test('uses empty prefix when auto is set but enableAutoPrefix is false', () => {
125+
// With no existing prefix
126+
let result = normalizeStateValue('auto', 123000, 'W', false);
127+
expect(result.unit_of_measurement).toBe('W');
128+
expect(result.state).toBe(123000);
129+
130+
// With existing prefix (should be preserved)
131+
result = normalizeStateValue('auto', 5, 'kW', false);
132+
expect(result.unit_of_measurement).toBe('W');
133+
expect(result.state).toBe(5000);
134+
});
135+
136+
test('uses empty prefix when auto is set but enableAutoPrefix is omitted', () => {
137+
// With no existing prefix
138+
let result = normalizeStateValue('auto', 123000, 'W');
139+
expect(result.unit_of_measurement).toBe('W');
140+
expect(result.state).toBe(123000);
141+
142+
// With existing prefix (should be preserved)
143+
result = normalizeStateValue('auto', 5, 'kW');
144+
expect(result.unit_of_measurement).toBe('W');
145+
expect(result.state).toBe(5000);
146+
});
147+
});
148+
});

src/editor/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,11 @@ export class SankeyChartEditor extends LitElement implements LovelaceCardEditor
212212
selector: {
213213
select: {
214214
mode: 'dropdown',
215-
options: [{ value: '' }, ...Object.keys(UNIT_PREFIXES).map(key => ({ value: key, label: key }))],
215+
options: [
216+
{ value: '' },
217+
{ value: 'auto', label: localize('editor.layout.auto') },
218+
...Object.keys(UNIT_PREFIXES).map(key => ({ value: key, label: key })),
219+
],
216220
},
217221
},
218222
},

src/section.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { html, svg, SVGTemplateResult } from 'lit';
22
// eslint-disable-next-line @typescript-eslint/no-unused-vars
33
import { styleMap } from 'lit/directives/style-map';
44
import { Box, Config, ConnectionState, EntityConfigInternal, SectionState } from './types';
5-
import { formatState, getChildConnections, getEntityId } from './utils';
5+
import { formatState, getChildConnections, getEntityId, normalizeStateValue } from './utils';
66
import { FrontendLocaleData, stateIcon } from 'custom-card-helpers';
77
import { HassEntity } from 'home-assistant-js-websocket';
88
import { renderLabel } from './label';
@@ -105,6 +105,9 @@ export function renderSection(props: {
105105
: null}
106106
${boxes.map(box => {
107107
const { entity, extraSpacers } = box;
108+
if (props.config.unit_prefix === 'auto') {
109+
box = { ...box, ...normalizeStateValue(props.config.unit_prefix, box.state, box.unit_of_measurement, true) };
110+
}
108111
const formattedState = formatState(box.state, props.config.round, props.locale, props.config.monetary_unit);
109112
const isNotPassthrough = box.config.type !== 'passthrough';
110113
const name = box.config.name || entity.attributes.friendly_name || '';

src/types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export interface SankeyChartConfig extends LovelaceCardConfig {
2222
monetary_unit?: string;
2323
electricity_price?: number;
2424
gas_price?: number;
25-
unit_prefix?: '' | keyof typeof UNIT_PREFIXES;
25+
unit_prefix?: '' | 'auto' | keyof typeof UNIT_PREFIXES;
2626
round?: number;
2727
height?: number;
2828
wide?: boolean;
@@ -140,7 +140,7 @@ export interface Section {
140140

141141
export interface Config extends SankeyChartConfig {
142142
layout: 'auto' | 'vertical' | 'horizontal';
143-
unit_prefix: '' | keyof typeof UNIT_PREFIXES;
143+
unit_prefix: '' | 'auto' | keyof typeof UNIT_PREFIXES;
144144
round: number;
145145
height: number;
146146
min_box_size: number;

src/utils.ts

Lines changed: 81 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,19 @@ import {
2020
Section,
2121
SectionConfig,
2222
} from './types';
23-
import { addSeconds, addMinutes, addHours, addDays, addWeeks, addMonths, addYears, startOfDay, startOfWeek, startOfMonth, startOfYear } from 'date-fns';
23+
import {
24+
addSeconds,
25+
addMinutes,
26+
addHours,
27+
addDays,
28+
addWeeks,
29+
addMonths,
30+
addYears,
31+
startOfDay,
32+
startOfWeek,
33+
startOfMonth,
34+
startOfYear,
35+
} from 'date-fns';
2436

2537
export function cloneObj<T extends Record<string, unknown>>(obj: T): T {
2638
return JSON.parse(JSON.stringify(obj));
@@ -50,18 +62,44 @@ export function formatState(state: number, round: number, locale: FrontendLocale
5062
}
5163

5264
export function normalizeStateValue(
53-
unit_prefix: '' | keyof typeof UNIT_PREFIXES,
65+
unit_prefix: '' | 'auto' | keyof typeof UNIT_PREFIXES,
5466
state: number,
5567
unit_of_measurement?: string,
68+
enableAutoPrefix = false,
5669
): { state: number; unit_of_measurement?: string } {
5770
const validState = Math.max(0, state) || 0; // the 0 check is for NaN
5871
if (!unit_of_measurement || unit_of_measurement == 'monetary') {
5972
return { state: validState, unit_of_measurement };
6073
}
61-
const cleanUnit = unit_of_measurement.replace('²', '').replace('³', '');
62-
const prefix =
63-
(cleanUnit.length > 1 && Object.keys(UNIT_PREFIXES).find(p => unit_of_measurement!.indexOf(p) === 0)) || '';
74+
const prefix = getUOMPrefix(unit_of_measurement);
6475
const currentFactor = UNIT_PREFIXES[prefix] || 1;
76+
77+
if (unit_prefix === 'auto') {
78+
if (enableAutoPrefix) {
79+
// Find the most appropriate prefix based on the state value
80+
const magnitude = Math.abs(state * currentFactor);
81+
82+
// Choose prefix based on the magnitude
83+
if (magnitude < 1) {
84+
unit_prefix = 'm';
85+
} else if (magnitude >= 1000 && magnitude < 1000000) {
86+
unit_prefix = 'k';
87+
} else if (magnitude >= 1000000 && magnitude < 1000000000) {
88+
unit_prefix = 'M';
89+
} else if (magnitude >= 1000000000 && magnitude < 1000000000000) {
90+
unit_prefix = 'G';
91+
} else if (magnitude >= 1000000000000) {
92+
unit_prefix = 'T';
93+
} else {
94+
// For values between 1-999, use no prefix
95+
unit_prefix = '';
96+
}
97+
} else {
98+
// ignore auto prefix for now. calculate it at render
99+
unit_prefix = '';
100+
}
101+
}
102+
65103
const targetFactor = UNIT_PREFIXES[unit_prefix] || 1;
66104
if (currentFactor === targetFactor) {
67105
return { state: validState, unit_of_measurement };
@@ -72,6 +110,11 @@ export function normalizeStateValue(
72110
};
73111
}
74112

113+
function getUOMPrefix(unit_of_measurement: string): string {
114+
const cleanUnit = unit_of_measurement.replace('²', '').replace('³', '');
115+
return (cleanUnit.length > 1 && Object.keys(UNIT_PREFIXES).find(p => unit_of_measurement!.indexOf(p) === 0)) || '';
116+
}
117+
75118
export function getEntityId(entity: EntityConfigOrStr | ChildConfigOrStr): string {
76119
return typeof entity === 'string' ? entity : entity.entity_id;
77120
}
@@ -264,22 +307,44 @@ export function calculateTimePeriod(from: string, to = 'now'): { start: Date; en
264307
if (amount && unit) {
265308
const numAmount = parseInt(amount, 10) * (sign === '-' ? -1 : 1);
266309
switch (unit) {
267-
case 's': date = addSeconds(date, numAmount); break;
268-
case 'm': date = addMinutes(date, numAmount); break;
269-
case 'h': date = addHours(date, numAmount); break;
270-
case 'd': date = addDays(date, numAmount); break;
271-
case 'w': date = addWeeks(date, numAmount); break;
272-
case 'M': date = addMonths(date, numAmount); break;
273-
case 'y': date = addYears(date, numAmount); break;
310+
case 's':
311+
date = addSeconds(date, numAmount);
312+
break;
313+
case 'm':
314+
date = addMinutes(date, numAmount);
315+
break;
316+
case 'h':
317+
date = addHours(date, numAmount);
318+
break;
319+
case 'd':
320+
date = addDays(date, numAmount);
321+
break;
322+
case 'w':
323+
date = addWeeks(date, numAmount);
324+
break;
325+
case 'M':
326+
date = addMonths(date, numAmount);
327+
break;
328+
case 'y':
329+
date = addYears(date, numAmount);
330+
break;
274331
}
275332
}
276333

277334
if (roundTo) {
278335
switch (roundTo) {
279-
case 'd': date = startOfDay(date); break;
280-
case 'w': date = startOfWeek(date); break;
281-
case 'M': date = startOfMonth(date); break;
282-
case 'y': date = startOfYear(date); break;
336+
case 'd':
337+
date = startOfDay(date);
338+
break;
339+
case 'w':
340+
date = startOfWeek(date);
341+
break;
342+
case 'M':
343+
date = startOfMonth(date);
344+
break;
345+
case 'y':
346+
date = startOfYear(date);
347+
break;
283348
}
284349
}
285350

0 commit comments

Comments
 (0)