diff --git a/src/app/analyzer/sidebar/sidebar.ts b/src/app/analyzer/sidebar/sidebar.ts index d919932..f29655b 100644 --- a/src/app/analyzer/sidebar/sidebar.ts +++ b/src/app/analyzer/sidebar/sidebar.ts @@ -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, @@ -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 @@ -62,4 +74,9 @@ export class Sidebar { return next; }); } + + private expandSection(id: string): void { + if (!this.collapsedSections().has(id)) return; + this.toggleSection(id); + } } diff --git a/src/app/core/analysis.service.spec.ts b/src/app/core/analysis.service.spec.ts index a7a9be5..45afb18 100644 --- a/src/app/core/analysis.service.spec.ts +++ b/src/app/core/analysis.service.spec.ts @@ -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( @@ -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( diff --git a/src/app/core/analysis.service.ts b/src/app/core/analysis.service.ts index abe0348..de58c19 100644 --- a/src/app/core/analysis.service.ts +++ b/src/app/core/analysis.service.ts @@ -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 = '>' | '<'; @@ -65,6 +70,30 @@ export class AnalysisService { this.results.set([]); this.scanMessage.set(''); }); + + bus + .on(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 { @@ -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, @@ -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` + ); } /** diff --git a/src/app/core/math-channels.service.spec.ts b/src/app/core/math-channels.service.spec.ts index 3dd95d2..c62a5b8 100644 --- a/src/app/core/math-channels.service.spec.ts +++ b/src/app/core/math-channels.service.spec.ts @@ -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', () => { diff --git a/src/app/core/math-channels.service.ts b/src/app/core/math-channels.service.ts index 9af6fb8..4826afe 100644 --- a/src/app/core/math-channels.service.ts +++ b/src/app/core/math-channels.service.ts @@ -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'; @@ -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, + 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(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) => { diff --git a/src/app/core/models.ts b/src/app/core/models.ts index d3a7e47..0644f50 100644 --- a/src/app/core/models.ts +++ b/src/app/core/models.ts @@ -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 { @@ -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;