Skip to content

Commit

Permalink
feat(#158): Convert units to CO2 or $ (#159)
Browse files Browse the repository at this point in the history
* Add a space between the number and the unit

* Support conversion of energy to CO2

* Support conversion of energy to MJ

* Support conversion of energy to $

* Display as "$5" instead of "5 USD"

* Revert "Add a space between the number and the unit"

This reverts commit 1882244.

* Fix tests

* Bugfix: tooltip (title) was still showing the word "monetary"

* Make gas_co2_intensity configurable

per EIA 2022, natural gas emits 55.0 gCO2/ft3 when burned, but per "Life
Cycle GHG Perspective on U.S. Natural Gas Delivery Pathways"
(Littlefield, Rai & Skone), supply chain adds an average of 12.2
gCO2e/MJ (11.6 gCO2e/ft3) to the lifecycle embodied carbon, with the
actual amount varying by region.

* Move defaults to normalizeConfig()

* Set default gas_co2_intensity based on locale
  • Loading branch information
madsciencetist authored Jan 28, 2024
1 parent 2f55d78 commit 668f3d6
Show file tree
Hide file tree
Showing 10 changed files with 223 additions and 29 deletions.
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ This card is intended to display connections between entities with numeric state
| min_state | number | **Optional** | >0 | Any entity below this value will not be displayed. Only positive numbers above 0 are allowed. The default is to show everything above 0.
| throttle | number | **Optional** | | Minimum time in ms between updates/rerenders
| static_scale | number | **Optional** | | State value corresponding to the maximum height size of the card. For example, if this is set to 1000, then a box with state 500 will take up half of its section. If some section exceeds the value of `static_scale`, the card will dynamically rescale overriding this option. See (#153)
| convert_units_to | string | **Optional** | | If entities are electricity (kWh) or gas (ft³) usage, convert them to energy (MJ), cost (monetary) or carbon (gCO2). For cost, you must also specify `electricity_price` and/or `gas_price`, as well as the `monetary_unit` of the price(s). For gCO2, all kWh values will be multiplied by the varying grid CO2 intensity, as with the Energy Dashboard.
| co2_intensity_entity |string | **Optional** | sensor.co2_signal_co2_intensity | Entity providing carbon intensity of electricity (gCO2eq/kWh). If you have solar or storage, you may wish to create a template sensor to convert grid CO2 intensity to consumption CO2 intensity.
| gas_co2_intensity | number | **Optional** | 66.6 g/ft³ or 2352 g/m³ | Carbon intensity of gas, e.g. in gCO2eq/ft³. Default value depends on locale; units must match those of gas entities.
| electricity_price | number | **Optional** | | Unit price of electricity, e.g. in USD/kWh. Automatic conversion does not support varying electricity prices like the Energy Dashboard does.
| gas_price | number | **Optional** | | Unit price of gas, e.g. in USD/ft³.
| monetary_unit | string | **Optional** | | Currency of the gas or electricity price, e.g. 'USD'.

### Sections object

Expand Down
2 changes: 1 addition & 1 deletion __tests__/basic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ describe('SankeyChart', () => {
{ entities: ['ent2', 'ent3'] },
],
};
sankeyChart.setConfig(config);
sankeyChart.setConfig(config, true);
document.body.appendChild(sankeyChart);
await sankeyChart.updateComplete;

Expand Down
21 changes: 18 additions & 3 deletions src/chart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ export class Chart extends LitElement {
const entityConf =
typeof entityConfOrStr === 'string' ? { entity_id: entityConfOrStr, children: [] } : entityConfOrStr;
const entity = this._getEntityState(entityConf);
const unit_of_measurement = entityConf.unit_of_measurement || entity.attributes.unit_of_measurement;
const unit_of_measurement = this._getUnitOfMeasurement(entityConf.unit_of_measurement || entity.attributes.unit_of_measurement);
const normalized = normalizeStateValue(this.config.unit_prefix, Number(entity.state), unit_of_measurement);

if (entityConf.type === 'passthrough') {
Expand Down Expand Up @@ -419,6 +419,21 @@ export class Chart extends LitElement {
});
}

private _getUnitOfMeasurement(reported_unit_of_measurement: string): string {
// If converting to money, don't actually display the word "monetary"
if (this.config.convert_units_to == 'monetary') {
return '';
}

// If converting from kWh to gCO2, attributes.unit_of_measurement remains kWh even though the number is gCO2, so we
// override the unit to gCO2, unless normalizeStateValue() has already converted it to kgCO2.
if (this.config.convert_units_to && !reported_unit_of_measurement.endsWith(this.config.convert_units_to)) {
return this.config.convert_units_to;
}

return reported_unit_of_measurement;
}

private _getEntityState(entityConf: EntityConfigInternal) {
if (entityConf.type === 'remaining_parent_state') {
const connections = this.connectionsByChild.get(entityConf);
Expand All @@ -431,7 +446,7 @@ export class Chart extends LitElement {
const { unit_of_measurement } = normalizeStateValue(
this.config.unit_prefix,
0,
parentEntity.attributes.unit_of_measurement,
this._getUnitOfMeasurement(parentEntity.attributes.unit_of_measurement),
);
return { ...parentEntity, state, attributes: { ...parentEntity.attributes, unit_of_measurement } };
}
Expand All @@ -446,7 +461,7 @@ export class Chart extends LitElement {
const { unit_of_measurement } = normalizeStateValue(
this.config.unit_prefix,
0,
childEntity.attributes.unit_of_measurement,
this._getUnitOfMeasurement(childEntity.attributes.unit_of_measurement),
);
return { ...childEntity, state, attributes: { ...childEntity.attributes, unit_of_measurement } };
}
Expand Down
4 changes: 3 additions & 1 deletion src/const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,6 @@ export const MIN_LABEL_HEIGHT = 15;

export const DEFAULT_ENTITY_CONF: Omit<EntityConfig, 'entity_id'> = {
type: 'entity',
};
};

export const FT3_PER_M3 = 35.31;
3 changes: 2 additions & 1 deletion src/editor/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,8 @@ export class SankeyChartEditor extends LitElement implements LovelaceCardEditor
return html``;
}

const config = normalizeConfig(this._config || ({} as SankeyChartConfig));
const isMetric = this.hass.config.unit_system.length == "km";
const config = normalizeConfig(this._config || ({} as SankeyChartConfig), isMetric);
const { autoconfig } = config;
const sections: SectionConfig[] = config.sections || [];

Expand Down
169 changes: 155 additions & 14 deletions src/energy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { HomeAssistant } from "custom-card-helpers";
import { Collection } from "home-assistant-js-websocket";
import { differenceInDays } from 'date-fns';
import { FT3_PER_M3 } from './const';

export const ENERGY_SOURCE_TYPES = ['grid', 'solar', 'battery'];

Expand Down Expand Up @@ -38,6 +39,14 @@ export interface StatisticValue {
state?: number | null;
}

export interface Conversions {
convert_units_to: string;
co2_intensity_entity: string;
gas_co2_intensity: number;
gas_price?: number | null;
electricity_price?: number | null;
}

const statisticTypes = [
"change",
"last_reset",
Expand Down Expand Up @@ -117,6 +126,31 @@ const fetchStatistics = (
types,
});

export interface FossilEnergyConsumption {
[date: string]: number;
}

const fetchFossilEnergyConsumption = (
hass: HomeAssistant,
startTime: Date,
energy_statistic_ids: string[],
co2_statistic_id: string,
endTime?: Date,
period: "5minute" | "hour" | "day" | "month" = "hour"
) =>
hass.callWS<FossilEnergyConsumption>({
type: "energy/fossil_energy_consumption",
start_time: startTime.toISOString(),
end_time: endTime?.toISOString(),
energy_statistic_ids,
co2_statistic_id,
period,
});

const sumOverTime = (values: FossilEnergyConsumption): number => {
return Object.values(values).reduce((a, b) => a + b, 0);
};

const calculateStatisticSumGrowth = (
values: StatisticValue[]
): number | null => {
Expand All @@ -140,27 +174,134 @@ const calculateStatisticSumGrowth = (
return growth;
};

export async function getStatistics(hass: HomeAssistant, energyData: EnergyData, devices: string[]): Promise<Record<string, number>> {
export async function getStatistics(hass: HomeAssistant, energyData: EnergyData, devices: string[], conversions: Conversions): Promise<Record<string, number>> {
const dayDifference = differenceInDays(
energyData.end || new Date(),
energyData.start
);
const period = dayDifference > 35 ? "month" : dayDifference > 2 ? "day" : "hour";

const data = await fetchStatistics(
hass,
energyData.start,
energyData.end,
devices,
period,
// units,
["change"]
);
let time_invariant_devices: string[] = [];
const time_variant_data = {};
if (conversions.convert_units_to == 'gCO2' || conversions.convert_units_to == "gCO2eq") {
for (const id of devices) {
if (hass.states[id].attributes.unit_of_measurement == "kWh") {
// If converting from kWh to CO2, we need to use a different API call to account for time-varying CO2 intensity
time_variant_data[id] = fetchFossilEnergyConsumption(
hass,
energyData.start,
[id],
conversions.co2_intensity_entity,
energyData.end,
period
);
}
else {
// Otherwise, we can get all the data we need from fetchStatistics below
time_invariant_devices.push(id);
}
}
}
else {
time_invariant_devices = devices;
}

let time_invariant_data = {};
if (time_invariant_devices.length > 0) {
time_invariant_data = await fetchStatistics(
hass,
energyData.start,
energyData.end,
time_invariant_devices,
period,
// units,
["change"]
);
}

const result = {};

for (const id in time_variant_data) {
const scale = 100; // API assumes co2_statistic_id is fossil fuel percentage [0-100], so it divides by 100, which we must undo
result[id] = sumOverTime(await time_variant_data[id]) * scale;
}

for (const id of time_invariant_devices) {
result[id] = calculateStatisticSumGrowth(time_invariant_data[id])

return devices.reduce((states, id) => ({
...states,
[id]: calculateStatisticSumGrowth(data[id]),
}), {})
if (conversions.convert_units_to && result[id]) {
let scale = 1.0;
if (conversions.convert_units_to == 'gCO2' || conversions.convert_units_to == "gCO2eq") {
switch (hass.states[id].attributes.unit_of_measurement) {
case 'gCO2':
case 'gCO2eq':
scale = 1;
break;
case "ft³":
case "ft3":
scale = conversions.gas_co2_intensity;
break;
case "CCF":
case "ccf":
scale = conversions.gas_co2_intensity * 100;
break;
case "m³":
case "m3":
scale = conversions.gas_co2_intensity * FT3_PER_M3;
break;
default:
console.warn("Can't convert from", hass.states[id].attributes.unit_of_measurement, "to", conversions.convert_units_to);
}
}
else if (conversions.convert_units_to == 'MJ') {
switch (hass.states[id].attributes.unit_of_measurement) {
case 'MJ':
scale = 1;
break;
case "kWh":
scale = 3.6;
break;
case "ft³":
case "ft3":
scale = 1.0551;
break;
case "m³":
case "m3":
scale = 1.0551 * FT3_PER_M3;
break;
default:
console.warn("Can't convert from", hass.states[id].attributes.unit_of_measurement, "to", conversions.convert_units_to);
}
}
else if (conversions.convert_units_to == 'monetary') {
switch (hass.states[id].attributes.unit_of_measurement) {
case "kWh":
scale = conversions.electricity_price ? conversions.electricity_price : 0;
break;
case "ft³":
case "ft3":
case "CCF":
case "ccf":
case "m³":
case "m3":
scale = conversions.gas_price ? conversions.gas_price : 0;
break;
default:
if (hass.states[id].attributes.device_class == 'monetary')
scale = 1;
else
console.warn("Can't convert from", hass.states[id].attributes.unit_of_measurement, "to", conversions.convert_units_to);
}
}
else {
console.warn("Can't convert to", conversions.convert_units_to);
}

result[id] *= scale;
}
}

return result;
}

export function getEnergySourceColor(type: string) {
Expand Down
14 changes: 11 additions & 3 deletions src/ha-sankey-chart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { SubscribeMixin } from './subscribe-mixin';
import './chart';
import { HassEntities } from 'home-assistant-js-websocket';
import {
Conversions,
EnergyCollection,
ENERGY_SOURCE_TYPES,
getEnergyDataCollection,
Expand Down Expand Up @@ -111,7 +112,14 @@ class SankeyChart extends SubscribeMixin(LitElement) {
}
}
if (this.entityIds.length) {
const stats = await getStatistics(this.hass, data, this.entityIds);
const conversions: Conversions = {
convert_units_to: this.config.convert_units_to!,
co2_intensity_entity: this.config.co2_intensity_entity!,
gas_co2_intensity: this.config.gas_co2_intensity!,
electricity_price: this.config.electricity_price,
gas_price: this.config.gas_price,
};
const stats = await getStatistics(this.hass, data, this.entityIds, conversions);
const states: HassEntities = {};
Object.keys(stats).forEach(id => {
if (this.hass.states[id]) {
Expand All @@ -127,12 +135,12 @@ class SankeyChart extends SubscribeMixin(LitElement) {
}

// https://lit.dev/docs/components/properties/#accessors-custom
public setConfig(config: SankeyChartConfig): void {
public setConfig(config: SankeyChartConfig, isMetric: boolean): void {
if (typeof config !== 'object') {
throw new Error(localize('common.invalid_configuration'));
}

this.setNormalizedConfig(normalizeConfig(config));
this.setNormalizedConfig(normalizeConfig(config, isMetric));
this.resetSubscriptions();
}

Expand Down
2 changes: 1 addition & 1 deletion src/section.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ export function renderSection(props: {
: null}
${boxes.map((box, i) => {
const { entity, extraSpacers } = box;
const formattedState = formatState(box.state, props.config.round, props.locale);
const formattedState = formatState(box.state, props.config.round, props.locale, props.config.monetary_unit);
const isNotPassthrough = box.config.type !== 'passthrough';
const name = box.config.name || entity.attributes.friendly_name || '';
const icon = box.config.icon || stateIcon(entity as HassEntity);
Expand Down
8 changes: 8 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,13 +96,21 @@ export interface SectionConfig {
min_width?: string;
}

export type CONVERSION_UNITS = 'MJ' | 'gCO2' | 'monetary';

export interface SankeyChartConfig extends LovelaceCardConfig {
type: string;
autoconfig?: {
print_yaml?: boolean;
};
title?: string;
sections?: SectionConfig[];
convert_units_to?: '' | CONVERSION_UNITS;
co2_intensity_entity?: string;
gas_co2_intensity?: number;
monetary_unit?: string;
electricity_price?: number;
gas_price?: number;
unit_prefix?: '' | keyof typeof UNIT_PREFIXES;
round?: number;
height?: number;
Expand Down
Loading

0 comments on commit 668f3d6

Please sign in to comment.