Skip to content

Commit

Permalink
feat(entities): add rolling average behavior
Browse files Browse the repository at this point in the history
Closes #285
  • Loading branch information
mKeRix committed Oct 5, 2020
1 parent 471620b commit 3acacf5
Show file tree
Hide file tree
Showing 6 changed files with 237 additions and 7 deletions.
9 changes: 9 additions & 0 deletions config/test.yml
Expand Up @@ -8,6 +8,15 @@ entities:
debounce:
wait: 0.75
maxWait: 2
rolling_average_entity:
rollingAverage:
window: 60
chained_entity:
debounce:
wait: 0.75
maxWait: 2
rollingAverage:
window: 60
gpio:
binarySensors:
- name: PIR Sensor
Expand Down
22 changes: 18 additions & 4 deletions docs/guide/entities.md
Expand Up @@ -14,9 +14,10 @@ Each entity that room-assistant exposes is managed by a central registry and has

Behaviors may be set per entity ID, with the ID being the key and an object with some of the properties below as value in the configuration map.

| Name | Type | Default | Description |
| ---------- | --------------------- | ------- | -------------------------------------------------- |
| `debounce` | [Debounce](#debounce) | | Allows you to debounce state updates for entities. |
| Name | Type | Default | Description |
| ---------------- | ----------------------------------- | ------- | ------------------------------------------------------------ |
| `debounce` | [Debounce](#debounce) | | Allows you to debounce state updates for entities. |
| `rollingAverage` | [Rolling Average](#rolling-average) | | Makes sensors output the average value based on a sliding window. |

#### Debounce

Expand All @@ -38,4 +39,17 @@ entities:
maxWait: 2
```

:::
:::

#### Rolling Average

This behavior is useful for when you have a sensor that on average has the correct value, but sometimes changes to wrong states. It will make the sensor output the average value that it has seen over the window period that you configured. Depending on the state type the average calculation behaves differently:

- For numeric states, the weighted average of all values seen in the window period will be calculated.
- For other states, the state that the original sensor spent the longest time in over the last `window` seconds will be chosen as the output.

The state itself is updated every second.

| Name | Type | Default | Description |
| -------- | ------ | ------- | ------------------------------------------------------------ |
| `window` | Number | | Number of seconds to look back for when calculating the average state. |
5 changes: 5 additions & 0 deletions src/entities/entities.config.ts
Expand Up @@ -4,9 +4,14 @@ export class EntitiesConfig {

export class EntityBehavior {
debounce?: DebounceOptions;
rollingAverage?: RollingAverageOptions;
}

export class DebounceOptions {
wait?: number;
maxWait?: number;
}

export class RollingAverageOptions {
window?: number;
}
104 changes: 104 additions & 0 deletions src/entities/entities.service.spec.ts
Expand Up @@ -159,6 +159,110 @@ describe('EntitiesService', () => {
);
});

it('should calculate rolling average for non-number states if configured', () => {
jest.useFakeTimers('modern');
const spy = jest.spyOn(emitter, 'emit');

const entityProxy = service.add(
new Sensor('rolling_average_entity', 'Rolling Test')
);
spy.mockClear();

entityProxy.state = 'test1';
expect(entityProxy.state).toBe('test1');
expect(spy).toHaveBeenCalledWith(
'stateUpdate',
'rolling_average_entity',
'test1',
false
);

jest.setSystemTime(Date.now() + 10 * 1000);
entityProxy.state = 'test2';
expect(entityProxy.state).toBe('test1');

jest.advanceTimersByTime(11 * 1000);
expect(entityProxy.state).toBe('test2');
expect(spy).toHaveBeenCalledWith(
'stateUpdate',
'rolling_average_entity',
'test2',
false
);
expect(spy).toHaveBeenCalledTimes(2);

jest.advanceTimersByTime(50 * 1000);
expect(entityProxy.state).toBe('test2');
});

it('should calculate rolling average for number states if configured', () => {
jest.useFakeTimers('modern');
const spy = jest.spyOn(emitter, 'emit');

const entityProxy = service.add(
new Sensor('rolling_average_entity', 'Rolling Test')
);
spy.mockClear();

entityProxy.state = 10;
jest.advanceTimersByTime(1000);
expect(entityProxy.state).toBe(10);
expect(spy).toHaveBeenCalledWith(
'stateUpdate',
'rolling_average_entity',
10,
false
);

jest.advanceTimersByTime(9 * 1000);
entityProxy.state = 20;
expect(entityProxy.state).toBe(10);

jest.advanceTimersByTime(6 * 1000);
expect(entityProxy.state).toBe(13.75);
expect(spy).toHaveBeenCalledWith(
'stateUpdate',
'rolling_average_entity',
13.75,
false
);

jest.advanceTimersByTime(55 * 1000);
expect(entityProxy.state).toBe(20);
expect(spy).toHaveBeenCalledWith(
'stateUpdate',
'rolling_average_entity',
20,
false
);
});

it('should chain entity behaviors together', () => {
jest.useFakeTimers('modern');
const spy = jest.spyOn(emitter, 'emit');

const entityProxy = service.add(
new Sensor('chained_entity', 'Chaining Test')
);
spy.mockClear();

entityProxy.state = 'test1';
jest.advanceTimersByTime(500);
expect(entityProxy.state).toBeUndefined();

entityProxy.state = 'test2';
jest.advanceTimersByTime(1000);
expect(entityProxy.state).toBe('test2');

jest.advanceTimersByTime(5000);
entityProxy.state = 'test3';
jest.advanceTimersByTime(1000);
expect(entityProxy.state).toBe('test2');

jest.advanceTimersByTime(7000);
expect(entityProxy.state).toBe('test3');
});

it('should send attribute updates to publishers', () => {
const entity = new Sensor('attributes_sensor', 'Sensor with attributes');
const spy = jest.spyOn(emitter, 'emit');
Expand Down
15 changes: 12 additions & 3 deletions src/entities/entities.service.ts
Expand Up @@ -8,6 +8,7 @@ import { ClusterService } from '../cluster/cluster.service';
import { ConfigService } from '../config/config.service';
import { EntitiesConfig } from './entities.config';
import { DebounceProxyHandler } from './debounce.proxy';
import { RollingAverageProxyHandler } from './rolling-average.proxy';

@Injectable()
export class EntitiesService implements OnApplicationBootstrap {
Expand Down Expand Up @@ -98,12 +99,20 @@ export class EntitiesService implements OnApplicationBootstrap {
* @param entity - Entity to customize
*/
private applyEntityBehaviors(entity: Entity): Entity {
const behaviorConfig = this.config.behaviors[entity.id];
let proxy = entity;

if (this.config.behaviors[entity.id]?.debounce?.wait) {
if (behaviorConfig?.rollingAverage?.window) {
proxy = new Proxy<Entity>(
entity,
new DebounceProxyHandler(this.config.behaviors[entity.id].debounce)
proxy,
new RollingAverageProxyHandler(behaviorConfig.rollingAverage)
);
}

if (behaviorConfig?.debounce?.wait) {
proxy = new Proxy<Entity>(
proxy,
new DebounceProxyHandler(behaviorConfig.debounce)
);
}

Expand Down
89 changes: 89 additions & 0 deletions src/entities/rolling-average.proxy.ts
@@ -0,0 +1,89 @@
import { Entity } from './entity.dto';
import _ from 'lodash';
import { RollingAverageOptions } from './entities.config';
import Timeout = NodeJS.Timeout;

export class RollingAverageProxyHandler implements ProxyHandler<Entity> {
private values: Array<TimedValue<any>> = [];
private updateInterval: Timeout;

constructor(private readonly config: RollingAverageOptions) {}

set(target: Entity, p: PropertyKey, value: any, receiver: any): boolean {
if (p == 'state') {
this.values.push(new TimedValue<any>(value));
this.updateTargetState(target);

if (this.updateInterval === undefined) {
setInterval(() => this.updateTargetState(target), 1000);
}
} else {
target[p] = value;
}

return true;
}

private updateTargetState(target: Entity) {
this.values = this.values.filter((value, index, array) => {
return (
value.msAgo <= this.config.window * 1000 ||
array[index + 1]?.msAgo <= this.config.window * 1000
);
});

if (this.values.length > 0) {
const weights = this.values.reduce<Map<any, number>>(
(map, value, index, array) => {
const weight =
_.clamp(value.msAgo, this.config.window * 1000) -
(array[index + 1]?.msAgo || 0);
if (map.has(value.value)) {
map.set(value.value, map.get(value.value) + weight);
} else {
map.set(value.value, weight);
}
return map;
},
new Map()
);

if (Array.from(weights.keys()).every((x) => typeof x === 'number')) {
// actual weighted average
const [weightedValueSum, weightSum] = Array.from(
weights.entries()
).reduce<[number, number]>(
(previous, value) => {
return [previous[0] + value[0] * value[1], previous[1] + value[1]];
},
[0, 0]
);
target.state = weightedValueSum / weightSum;
} else {
// state with max weight wins
const winner = Array.from(weights.entries()).reduce<[any, number]>(
(previous, value) => {
return previous[0] == undefined || value[1] > previous[1]
? value
: previous;
},
[undefined, 0]
);
target.state = winner[0];
}
}
}
}

class TimedValue<T> {
value: T;
readonly createdAt: Date = new Date();

constructor(value: T) {
this.value = value;
}

get msAgo(): number {
return Date.now() - this.createdAt.getTime();
}
}

0 comments on commit 3acacf5

Please sign in to comment.