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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
- **Tooling Reliance:** Do not act as a syntax linter or formatter. Rely on ESLint, Stylelint, and Prettier (see Commands below) and CI to catch formatting/lint issues.
- **Never amend commits:** Always create a new commit instead of `git commit --amend`, even for a small immediate follow-up fix (e.g. a lint/format correction) to a commit made moments earlier. This holds regardless of whether the original commit has been pushed.
- **Run Prettier before committing:** Run `npx prettier --write` on changed files (or `npm run format`) before creating a commit, so CI's Prettier check doesn't fail on avoidable formatting issues.
- **Keep the commit title short:** The subject line must fit the conventional-commit standard of ~50 characters and never exceed 72 — GitHub truncates longer titles in the commits list and PR view, so the point of the change gets cut off. Recent history here has titles that are far too long; don't copy them. Write `<type>: <short imperative summary>` and put the detail in the commit body, not the title.
- **No AI co-author trailer:** Never add a `Co-Authored-By: Claude ...` (or similar AI-attribution) line to commit messages.
- **Branch from a local ref, not a remote-tracking ref:** Never run `git checkout -b <branch> origin/main` (or `origin/<anything>`) to create a feature branch. Passing a remote-tracking ref as the start-point makes git auto-set that branch's upstream to it (`branch.autoSetupMerge`), so a later push with no explicit destination silently targets `main`/that remote branch instead of creating `origin/<branch>` — this already caused a rejected push straight to `main`. Instead branch from local `main` (`git checkout -b <branch> main`, after `git fetch`/`git pull` if it needs to be current) so no upstream is auto-configured, and only set one explicitly via `git push -u origin <branch>` when actually pushing.

Expand Down
10 changes: 10 additions & 0 deletions src/app/analyzer/math-channel-modal/math-channel-modal.css
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,13 @@
font-size: 0.85em;
padding: 6px 4px;
}

.math-tag-note {
font-size: 0.8rem;
color: var(--text-secondary);
margin: 6px 0 0;
}

.math-tag-note strong {
color: var(--accent, var(--text-primary));
}
12 changes: 11 additions & 1 deletion src/app/analyzer/math-channel-modal/math-channel-modal.html
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,12 @@ <h2>Create Math Channel</h2>
<span class="math-signal-no-results">No signals match.</span>
}
</div>
} @else if (input.isConstant && input.options) {
@if (appliedTag(); as tag) {
<p class="math-tag-note">
Preselected {{ selectedSources().length }} signal(s) for the
<strong>{{ tag }}</strong> tag.
</p>
} } @else if (input.isConstant && input.options) {
<select
class="template-select"
[value]="inputValues()[inputIdx] ?? ''"
Expand Down Expand Up @@ -184,6 +189,11 @@ <h2>Create Math Channel</h2>
[disabled]="!isolateEnabled()"
(input)="autoEnableText.set($any($event.target).value)"
/>
@if (appliedTag(); as tag) {
<p class="math-tag-note">
Isolate list for the <strong>{{ tag }}</strong> tag.
</p>
}
</div>
} @if (errorMessage(); as error) {
<div class="math-error-msg">{{ error }}</div>
Expand Down
35 changes: 30 additions & 5 deletions src/app/analyzer/math-channel-modal/math-channel-modal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ export class MathChannelModal {
protected readonly smoothWindow = signal(5);
protected readonly isolateEnabled = signal(false);
protected readonly autoEnableText = signal('');
/** The file tag whose channel set drove the current preselection (e.g. '2.0gme'), or null when the tag-agnostic default was used -- shown next to the affected fields. */
protected readonly appliedTag = signal<string | null>(null);
protected readonly errorMessage = signal<string | null>(null);

protected readonly selectedDefinition = computed<MathDefinition | undefined>(
Expand Down Expand Up @@ -106,14 +108,20 @@ export class MathChannelModal {
this.channelName.set('');
this.isolateEnabled.set(false);
this.autoEnableText.set('');
this.appliedTag.set(null);
return;
}

const file = this.currentFile();

// Tag-aware: a '2.0gme' log isolates a different channel set than a
// 175tbi one (see MathChannelsService.resolveAutoEnableSignals).
const autoEnable = this.mathChannels.resolveAutoEnableSignals(def, file);
this.appliedTag.set(this.mathChannels.resolveAutoEnableTag(def, file));
this.channelName.set(def.isBatch ? '[Auto Generated]' : def.name);
this.isolateEnabled.set(!!def.autoEnableSignals?.length);
this.autoEnableText.set(def.autoEnableSignals?.join(', ') ?? '');
this.isolateEnabled.set(autoEnable.length > 0);
this.autoEnableText.set(autoEnable.join(', '));

const file = this.currentFile();
const values: Partial<Record<number, string>> = {};
def.inputs.forEach((input, idx) => {
if (input.isConstant) {
Expand Down Expand Up @@ -143,7 +151,9 @@ export class MathChannelModal {
this.inputValues.set(values);

if (def.preSelectAllSources && file) {
this.selectedSources.set([...file.availableSignals]);
this.selectedSources.set(
this.mathChannels.resolveBatchSources(def, file)
);
}
}

Expand Down Expand Up @@ -242,12 +252,26 @@ export class MathChannelModal {
refreshedFile.availableSignals,
false
);
// Matched case/whitespace-insensitively against the file's actual
// signal names, like AccelerationService.captureExtraCurves: the
// same channel comes through with slightly different spacing
// depending on the source dictionary, and an exact-name-only
// lookup would silently isolate nothing.
const normalize = (s: string) =>
s.trim().toLowerCase().replace(/\s+/g, ' ');
const byNormalizedName = new Map(
refreshedFile.availableSignals.map((sig) => [normalize(sig), sig])
);
this.autoEnableText()
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.forEach((sig) =>
this.appState.setSignalVisible(fileIndex, sig, true)
this.appState.setSignalVisible(
fileIndex,
byNormalizedName.get(normalize(sig)) ?? sig,
true
)
);
}
}
Expand All @@ -273,6 +297,7 @@ export class MathChannelModal {
this.smoothWindow.set(5);
this.isolateEnabled.set(false);
this.autoEnableText.set('');
this.appliedTag.set(null);
this.errorMessage.set(null);

// untracked: this runs from an effect keyed on isModalOpen() — reading
Expand Down
97 changes: 97 additions & 0 deletions src/app/core/math-channels.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from 'vitest';
import { AppStateService } from './app-state.service';
import { DbManagerService } from './db-manager.service';
import { EventBusService } from './event-bus.service';
import { MATH_DEFINITIONS } from './math-definitions';
import { MathChannelsService } from './math-channels.service';
import { EVENTS, LoadedFile, SignalPoint } from './models';
import { SignalRegistryService } from './signal-registry.service';
Expand Down Expand Up @@ -315,6 +316,102 @@ describe('MathChannelsService', () => {
});
});

describe('resolveAutoEnableSignals', () => {
const wotFilter = MATH_DEFINITIONS.find(
(d) => d.id === 'gas_pedal_filter_batch'
)!;

it('falls back to the tag-agnostic list for an untagged file', () => {
const resolved = service.resolveAutoEnableSignals(
wotFilter,
makeFile({})
);
expect(resolved).toBe(wotFilter.autoEnableSignals);
});

it("returns the 2.0gme channel set for a file tagged '2.0gme'", () => {
const resolved = service.resolveAutoEnableSignals(
wotFilter,
makeFile({}, { tags: ['2.0gme', 'Bluetooth'] })
);
expect(resolved).toBe(wotFilter.autoEnableSignalsByTag!['2.0gme']);
expect(resolved).toContain('Math: Filtered: Measured Boost Pressure');
expect(resolved).not.toContain('Math: Filtered: Throttle position');
});

it("returns the 175tbi channel set -- same as the default -- for a file tagged '175tbi'", () => {
const resolved = service.resolveAutoEnableSignals(
wotFilter,
makeFile({}, { tags: ['175tbi'] })
);
expect(resolved).toEqual(wotFilter.autoEnableSignals);
expect(resolved).toContain('Math: Filtered: Over Boost Measured');
});

it('returns an empty list for a definition with no auto-enable set, and tolerates a missing file', () => {
const plain = MATH_DEFINITIONS.find((d) => d.id === 'multiply_const')!;
expect(service.resolveAutoEnableSignals(plain, makeFile({}))).toEqual([]);
expect(service.resolveAutoEnableSignals(wotFilter, undefined)).toBe(
wotFilter.autoEnableSignals
);
});
});

describe('resolveBatchSources', () => {
const wotFilter = MATH_DEFINITIONS.find(
(d) => d.id === 'gas_pedal_filter_batch'
)!;

it('pre-selects every signal for an untagged file', () => {
const file = makeFile({ 'Engine Oil Level': [], 'Fuel Level': [] });
expect(service.resolveBatchSources(wotFilter, file)).toEqual(
file.availableSignals
);
});

it("narrows to the tagged channel set's own sources for a '2.0gme' file", () => {
const file = makeFile(
{
'Engine Oil Level': [],
'Fuel Level': [],
'Engine Speed': [],
// Irregular spacing, as the GME dictionary's newline-split
// descriptions come through -- still has to match.
'Gas Pedal Position': [],
'Measured Boost Pressure': [],
},
{ tags: ['2.0gme'] }
);
expect(service.resolveBatchSources(wotFilter, file)).toEqual([
'Measured Boost Pressure',
'Gas Pedal Position',
'Engine Speed',
]);
});

it("narrows a '175tbi' file to its own channel set too", () => {
const file = makeFile(
{
'Engine Oil Level': [],
'Over Boost Measured': [],
'Throttle position': [],
},
{ tags: ['175tbi'] }
);
expect(service.resolveBatchSources(wotFilter, file)).toEqual([
'Over Boost Measured',
'Throttle position',
]);
});

it('falls back to every signal when the tagged set matches nothing in the file', () => {
const file = makeFile({ 'Engine Oil Level': [] }, { tags: ['2.0gme'] });
expect(service.resolveBatchSources(wotFilter, file)).toEqual(
file.availableSignals
);
});
});

describe('executeAutoMath', () => {
it('auto-creates GPS distance/speed and trip cost channels when the raw signals exist', () => {
appState.addFile(
Expand Down
93 changes: 92 additions & 1 deletion src/app/core/math-channels.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import {
import { ActionLogEvent, EVENTS, LoadedFile, SignalPoint } from './models';
import { SignalRegistryService } from './signal-registry.service';

/** Name prefix `createBatchChannels` gives each generated channel (`Math: ` is added by finalizeChannel) -- also how `resolveBatchSources` maps an auto-enable entry back to its source signal. */
const BATCH_CHANNEL_PREFIX = 'Math: Filtered: ';

export interface CreateChannelOptions {
smooth?: boolean;
smoothWindow?: number;
Expand Down Expand Up @@ -113,6 +116,94 @@ export class MathChannelsService {
return MATH_DEFINITIONS.find((d) => d.id === id);
}

/**
* The signals a formula's "isolate on chart" step should re-enable for
* `file`, honouring `autoEnableSignalsByTag` (see MathDefinition): the WOT
* quick-filter isolates a different channel set on a '2.0gme' log than on a
* '175tbi' one, since the two ECUs name the same measurements differently.
* Falls back to the tag-agnostic `autoEnableSignals` when the file carries
* none of the mapped tags (or has no tags at all).
*/
resolveAutoEnableSignals(
definition: MathDefinition,
file: LoadedFile | undefined
): string[] {
return (
this.tagSpecificAutoEnableSignals(definition, file) ??
definition.autoEnableSignals ??
[]
);
}

/**
* Which source signals a `preSelectAllSources` batch formula should start
* with for `file`. When a tag-specific auto-enable list applies, only the
* sources behind that list are pre-selected: a 2.0gme log carries ~80
* channels, and filtering every one of them just to isolate nine afterwards
* is wasted work (and a wall of checkboxes to undo by hand). Everything
* else -- no tag-specific list, or none of its signals present in this file
* -- keeps the "select everything" default.
*/
resolveBatchSources(
definition: MathDefinition,
file: LoadedFile | undefined
): string[] {
const available = file?.availableSignals ?? [];
const tagged = this.tagSpecificAutoEnableSignals(definition, file);
if (!tagged) return [...available];

// Same case/whitespace-insensitive matching as AccelerationService's
// extra-curve lookup -- the same channel can come through with slightly
// different spacing depending on the source dictionary.
const normalize = (s: string) =>
s.trim().toLowerCase().replace(/\s+/g, ' ');
const byNormalizedName = new Map(
available.map((sig) => [normalize(sig), sig])
);

const sources: string[] = [];
for (const entry of tagged) {
const match = byNormalizedName.get(
normalize(
entry.startsWith(BATCH_CHANNEL_PREFIX)
? entry.slice(BATCH_CHANNEL_PREFIX.length)
: entry
)
);
if (match && !sources.includes(match)) sources.push(match);
}
return sources.length > 0 ? sources : [...available];
}

/** Which of `file`'s tags drove `resolveAutoEnableSignals`/`resolveBatchSources`, or null when neither used a tag-specific list -- surfaced in the modal so the preselection isn't unexplained. */
resolveAutoEnableTag(
definition: MathDefinition,
file: LoadedFile | undefined
): string | null {
return this.matchTagEntry(definition, file)?.[0] ?? null;
}

private tagSpecificAutoEnableSignals(
definition: MathDefinition,
file: LoadedFile | undefined
): string[] | null {
return this.matchTagEntry(definition, file)?.[1] ?? null;
}

/** The `autoEnableSignalsByTag` entry matching one of `file`'s tags (first match wins), or null when none applies. */
private matchTagEntry(
definition: MathDefinition,
file: LoadedFile | undefined
): [string, string[]] | null {
const tags = file?.tags ?? [];
for (const entry of Object.entries(
definition.autoEnableSignalsByTag ?? {}
)) {
if (tags.includes(entry[0])) return entry;
}
return null;
}

createChannel(
fileIndex: number,
formulaId: string,
Expand Down Expand Up @@ -188,7 +279,7 @@ export class MathChannelsService {

sources.forEach((src) => {
const singleInputs = [src, ...restInputs];
const name = `Filtered: ${src}`;
const name = `${BATCH_CHANNEL_PREFIX}${src}`;
const createdName = this.createChannel(
fileIndex,
targetId,
Expand Down
Loading
Loading