Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/app/analyzer/sidebar/sidebar.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { Component, inject, signal } from '@angular/core';
import { AppStateService } from '../../core/app-state.service';
import { EventBusService } from '../../core/event-bus.service';
import { EVENTS } from '../../core/models';
import {
PreferencesService,
SIDEBAR_STATE_KEY,
Expand All @@ -26,6 +28,16 @@ export class Sidebar {
protected readonly preferences = inject(PreferencesService);
protected readonly uiState = inject(UiStateService);

constructor() {
// A threshold math-filter (e.g. the WOT quick-filter) re-runs the anomaly
// scan with the same condition (AnalysisService.applyThresholdFilter), so
// reveal the section -- its fresh results are the point of the run and
// would otherwise land inside a collapsed panel.
inject(EventBusService)
.on(EVENTS.MATH_FILTER_APPLIED)
.subscribe(() => this.expandSection('anomalyScanner'));
}

/**
* Port of legacy/src/ui.js's `initSidebarSectionsCollapse` — every
* `.control-group` with a clickable header can be collapsed
Expand Down Expand Up @@ -62,4 +74,9 @@ export class Sidebar {
return next;
});
}

private expandSection(id: string): void {
if (!this.collapsedSections().has(id)) return;
this.toggleSection(id);
}
}
38 changes: 37 additions & 1 deletion src/app/core/analysis.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { AnalysisService } from './analysis.service';
import { AppStateService } from './app-state.service';
import { DbManagerService } from './db-manager.service';
import { EventBusService } from './event-bus.service';
import { LoadedFile, RawDataPoint } from './models';
import { EVENTS, LoadedFile, RawDataPoint } from './models';
import { SignalRegistryService } from './signal-registry.service';

function makeFile(
Expand Down Expand Up @@ -34,6 +34,42 @@ describe('AnalysisService', () => {
service = new AnalysisService(appState, new SignalRegistryService(), bus);
});

it('mirrors a threshold math-filter into the rows and scans (MATH_FILTER_APPLIED)', () => {
const bus = new EventBusService();
const state = new AppStateService(bus, new DbManagerService());
const svc = new AnalysisService(state, new SignalRegistryService(), bus);
state.addFile(
makeFile([
{ timestamp: 0, signal: 'Pedal', value: 10 },
{ timestamp: 1000, signal: 'Pedal', value: 80 },
{ timestamp: 2000, signal: 'Pedal', value: 90 },
{ timestamp: 3000, signal: 'Pedal', value: 5 },
])
);
svc.addFilterRow();

bus.emit(EVENTS.MATH_FILTER_APPLIED, {
fileIndex: 0,
conditionSignal: 'Pedal',
operator: '>',
threshold: 60,
label: 'Gas Pedal Filter (> 60%)',
});

expect(svc.filters()).toEqual([
expect.objectContaining({
fileIdx: 0,
signal: 'Pedal',
operator: '>',
value: '60',
}),
]);
expect(svc.results()).toEqual([
expect.objectContaining({ start: 1000, end: 3000, fileIdx: 0 }),
]);
expect(svc.scanMessage()).toBe('1 events found (Gas Pedal Filter (> 60%))');
});

it('starts with a single empty filter row', () => {
expect(service.filters()).toHaveLength(1);
expect(service.filters()[0]).toEqual(
Expand Down
39 changes: 36 additions & 3 deletions src/app/core/analysis.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import { Injectable, signal } from '@angular/core';
import ANOMALY_TEMPLATES from './analysis-templates.json';
import { AppStateService } from './app-state.service';
import { EventBusService } from './event-bus.service';
import { EVENTS, FileRemovedEvent, LoadedFile } from './models';
import {
EVENTS,
FileRemovedEvent,
LoadedFile,
MathFilterAppliedEvent,
} from './models';
import { SignalRegistryService } from './signal-registry.service';

export type FilterOperator = '>' | '<';
Expand Down Expand Up @@ -65,6 +70,30 @@ export class AnalysisService {
this.results.set([]);
this.scanMessage.set('');
});

bus
.on<MathFilterAppliedEvent>(EVENTS.MATH_FILTER_APPLIED)
.subscribe((event) => this.applyThresholdFilter(event));
}

/**
* Rebuilds the filter rows from a threshold math-filter (e.g. the WOT
* quick-filter) and scans immediately, so the results list holds exactly
* the ranges the filtered channels are non-zero over on the chart. The
* criterion replaces whatever was in the rows: showing the WOT ranges
* next to leftover rows from an earlier scan would just be confusing, and
* the previous rows are one template-pick away.
*/
applyThresholdFilter(event: MathFilterAppliedEvent): void {
this.filters.set([
this.emptyRow({
fileIdx: event.fileIndex,
signal: event.conditionSignal,
operator: event.operator,
value: String(event.threshold),
}),
]);
this.runScan(event.label);
}

addFilterRow(): void {
Expand Down Expand Up @@ -103,7 +132,7 @@ export class AnalysisService {
this.runScan();
}

runScan(): void {
runScan(label?: string): void {
const criteria: ScanCriterion[] = this.filters()
.map((r) => ({
fileIdx: r.fileIdx,
Expand All @@ -130,7 +159,11 @@ export class AnalysisService {
});

this.results.set(aggregated);
this.scanMessage.set(`${aggregated.length} events found`);
this.scanMessage.set(
label
? `${aggregated.length} events found (${label})`
: `${aggregated.length} events found`
);
}

/**
Expand Down
59 changes: 59 additions & 0 deletions src/app/core/math-channels.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,65 @@ describe('MathChannelsService', () => {

expect(events).toHaveLength(1);
});

it('emits MATH_FILTER_APPLIED with the filter condition', () => {
appState.addFile(
makeFile({ A: [{ x: 1, y: 100 }], Cond: [{ x: 1, y: 50 }] })
);
const bus = new EventBusService();
const svc = new MathChannelsService(
appState,
bus,
new SignalRegistryService()
);
const events: unknown[] = [];
bus.on(EVENTS.MATH_FILTER_APPLIED).subscribe((e) => events.push(e));

const definition = svc.getDefinition('filtered_batch')!;
svc.createBatchChannels(definition, ['A'], ['Cond', '10', '0', '0'], 0);

expect(events).toEqual([
expect.objectContaining({
fileIndex: 0,
conditionSignal: 'Cond',
operator: '<',
threshold: 10,
}),
]);
});

it('does not emit MATH_FILTER_APPLIED on replay or for range filters', () => {
appState.addFile(
makeFile({
A: [{ x: 1, y: 100 }],
Cond: [{ x: 1, y: 50 }],
})
);
const bus = new EventBusService();
const svc = new MathChannelsService(
appState,
bus,
new SignalRegistryService()
);
const events: unknown[] = [];
bus.on(EVENTS.MATH_FILTER_APPLIED).subscribe((e) => events.push(e));

svc.createBatchChannels(
svc.getDefinition('filtered_batch')!,
['A'],
['Cond', '10', '1', '0'],
0,
{ isReplay: true }
);
svc.createBatchChannels(
svc.getDefinition('filter_range_batch')!,
['A'],
['Cond', '10', '90', '1', '0'],
0
);

expect(events).toEqual([]);
});
});

describe('createSingleChannel', () => {
Expand Down
62 changes: 61 additions & 1 deletion src/app/core/math-channels.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@ import {
MathDefinition,
MathInputDef,
} from './math-definitions';
import { ActionLogEvent, EVENTS, LoadedFile, SignalPoint } from './models';
import {
ActionLogEvent,
EVENTS,
LoadedFile,
MathFilterAppliedEvent,
SignalPoint,
} from './models';
import { SignalRegistryService } from './signal-registry.service';
import { VehicleTag, fileHasVehicleTag } from './vehicle-tags';

Expand Down Expand Up @@ -291,9 +297,63 @@ export class MathChannelsService {
this.logAction(targetId, singleInputs, name, fileIndex, options);
});

if (!options.isReplay) {
this.announceThresholdFilter(definition, restInputs, fileIndex);
}

return createdNames;
}

/**
* Mirrors a threshold-filter batch (the WOT quick-filter, or the generic
* "Filtered (Multi-Signal)") into MATH_FILTER_APPLIED so the anomaly
* scanner can list the ranges the filter actually passes — see
* MathFilterAppliedEvent. Only batches shaped like `cond`/`thresh`/`mode`
* qualify; range filters (`filter_range_batch`) and anything else are
* left alone.
*/
private announceThresholdFilter(
definition: MathDefinition,
restInputs: Array<string | number>,
fileIndex: number
): void {
const file = this.appState.files()[fileIndex];
if (!file) return;

const inputName = (input: MathInputDef): string =>
Array.isArray(input.name) ? (input.name[0] ?? '') : (input.name ?? '');

const condIdx = definition.inputs.findIndex(
(input) => !input.isConstant && !input.isMulti
);
const threshIdx = definition.inputs.findIndex(
(input) => inputName(input) === 'thresh'
);
const modeIdx = definition.inputs.findIndex(
(input) => inputName(input) === 'mode'
);
if (condIdx < 1 || threshIdx < 1 || modeIdx < 1) return;

const threshold = Number(restInputs[threshIdx - 1]);
if (!isFinite(threshold)) return;

const conditionSignal = this.resolveSignalName(
file,
definition.inputs[condIdx],
String(restInputs[condIdx - 1])
);
if (!file.signals[conditionSignal]) return;

this.bus.emit<MathFilterAppliedEvent>(EVENTS.MATH_FILTER_APPLIED, {
fileIndex,
conditionSignal,
// Same convention as `filtered_single`'s formula: mode 1 = high pass.
operator: Number(restInputs[modeIdx - 1]) === 1 ? '>' : '<',
threshold,
label: definition.name,
});
}

/** Auto-creates the always-on derived signals (GPS distance/speed, trip cost) after each batch load. */
executeAutoMath(): void {
this.appState.files().forEach((_, fileIdx) => {
Expand Down
19 changes: 19 additions & 0 deletions src/app/core/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const EVENTS = {
DRIVE_TAG_ADDED: 'drive:tag-added',
FILE_TAG_REMOVED: 'file:tag-removed',
DRIVE_TAG_REMOVED: 'drive:tag-removed',
MATH_FILTER_APPLIED: 'mathchannels:filter-applied',
} as const;

export interface SignalPoint {
Expand Down Expand Up @@ -116,6 +117,24 @@ export interface MapSelectedEvent {
fileIndex: number;
}

/**
* Emitted by MathChannelsService when a threshold-filter batch (the WOT
* quick-filter's `gas_pedal_filter_batch`, or the generic
* `filtered_batch`) is created, so AnalysisService can mirror the very same
* condition into the anomaly scanner and list the matching time ranges — the
* scanner's results then line up with what the filtered channels show on the
* chart instead of being an unrelated scan.
*/
export interface MathFilterAppliedEvent {
fileIndex: number;
/** The condition channel the filter gates on (e.g. the gas pedal signal), as resolved against the file's actual signal names. */
conditionSignal: string;
operator: '>' | '<';
threshold: number;
/** The formula's display name, used for the scanner's status line. */
label: string;
}

export interface ActionLogEvent {
type: string;
description: string;
Expand Down
Loading