Skip to content

Commit 995e0e2

Browse files
committed
Add euclidian-distance converter engine with lazy-load
- Add ts/converters/ with generic EuclidianDistanceEngine (ported from model-cervical-fluid, no domain-specific references) - Add HDSModel-Converters with async lazy-loading of converter packs from {modelBaseUrl}/converters/{itemKey}/pack-latest.json - Expose convertMethodToEvent, convertEventToMethod, convertMethodToMethod - Add converter-aware shortText formatting with autoConvert setting support - Add modelUrl getter on HDSModel for URL derivation
1 parent 3ab66f6 commit 995e0e2

6 files changed

Lines changed: 673 additions & 2 deletions

File tree

ts/HDSModel/HDSModel-Converters.ts

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import { HDSModel } from './HDSModel.ts';
2+
import { EuclidianDistanceEngine } from '../converters/EuclidianDistanceEngine.ts';
3+
import type { ConverterPack, ObservationVector, ConversionResult, SourceBlock } from '../converters/types.ts';
4+
5+
/**
6+
* Converters — Extension of HDSModel
7+
*
8+
* Lazy-loads converter packs from the model's base URL:
9+
* {modelBaseUrl}/converters/{itemKey}/pack-latest.json
10+
*
11+
* The main pack.json only contains the converter index (item keys + versions).
12+
* Full converter data (dimensions + methods) is fetched on first use and cached.
13+
*
14+
* All conversion methods are async (first call fetches, subsequent calls are instant).
15+
*/
16+
export class HDSModelConverters {
17+
#model: HDSModel;
18+
#engines: Record<string, EuclidianDistanceEngine> = {};
19+
#pendingLoads: Record<string, Promise<EuclidianDistanceEngine>> = {};
20+
#itemKeyByEventType: Record<string, string> = {};
21+
22+
constructor (model: HDSModel) {
23+
this.#model = model;
24+
}
25+
26+
/** List available converter item keys (from the index, may not be loaded yet) */
27+
get availableItemKeys (): string[] {
28+
const converters = this.#model.modelData.converters;
29+
return converters ? Object.keys(converters) : [];
30+
}
31+
32+
/** List loaded converter item keys */
33+
get loadedItemKeys (): string[] {
34+
return Object.keys(this.#engines);
35+
}
36+
37+
/**
38+
* Load a converter pack manually.
39+
* Bridges and apps can call this to register packs without fetching from URL.
40+
*/
41+
loadPack (pack: ConverterPack): void {
42+
if (pack.engine !== 'euclidian-distance') {
43+
throw new Error(`Unknown converter engine: "${pack.engine}". Only "euclidian-distance" is supported.`);
44+
}
45+
this.#engines[pack.itemKey] = new EuclidianDistanceEngine(pack);
46+
this.#itemKeyByEventType[pack.eventType] = pack.itemKey;
47+
}
48+
49+
/** Get a loaded engine (returns undefined if not yet loaded) */
50+
getEngine (itemKey: string): EuclidianDistanceEngine | undefined {
51+
return this.#engines[itemKey];
52+
}
53+
54+
/**
55+
* Ensure a converter engine is loaded for the given item key.
56+
* Fetches pack-latest.json on first call, returns cached engine on subsequent calls.
57+
*/
58+
async ensureEngine (itemKey: string): Promise<EuclidianDistanceEngine> {
59+
if (this.#engines[itemKey]) return this.#engines[itemKey];
60+
if (this.#pendingLoads[itemKey]) return this.#pendingLoads[itemKey];
61+
62+
this.#pendingLoads[itemKey] = this.#fetchAndLoadPack(itemKey);
63+
try {
64+
const engine = await this.#pendingLoads[itemKey];
65+
return engine;
66+
} finally {
67+
delete this.#pendingLoads[itemKey];
68+
}
69+
}
70+
71+
/**
72+
* Convert a source method observation into a Pryv event structure.
73+
*
74+
* @param itemKey - converter item key (e.g. 'cervical-fluid', 'mood')
75+
* @param sourceMethod - source method id (e.g. 'mira', 'appleHealth')
76+
* @param dataFromSource - raw observation from the source method
77+
* @param modelVersion - version of the model definition used (default: engine's version)
78+
* @returns Pryv event-like object with type, streamIds, content
79+
*/
80+
async convertMethodToEvent (itemKey: string, sourceMethod: string, dataFromSource: any, modelVersion?: string): Promise<any> {
81+
const engine = await this.ensureEngine(itemKey);
82+
const itemDef = this.#getItemDef(itemKey);
83+
84+
const vector = engine.toVector(sourceMethod, dataFromSource);
85+
86+
const source: SourceBlock = {
87+
key: sourceMethod,
88+
sourceData: dataFromSource,
89+
engineVersion: engine.converterVersion,
90+
modelVersion: modelVersion ?? engine.converterVersion,
91+
};
92+
93+
return {
94+
type: engine.eventType,
95+
streamIds: [itemDef.streamId],
96+
content: {
97+
data: vector,
98+
source,
99+
},
100+
};
101+
}
102+
103+
/**
104+
* Convert a stored event to a target method observation.
105+
*
106+
* @param event - Pryv event with content.data (the N-D vector)
107+
* @param targetMethod - target method id
108+
* @returns { data, matchDistance }
109+
*/
110+
async convertEventToMethod (event: any, targetMethod: string): Promise<ConversionResult> {
111+
const itemKey = await this.#findItemKeyForEvent(event);
112+
const engine = await this.ensureEngine(itemKey);
113+
114+
const vector: ObservationVector = event.content?.data;
115+
if (!vector || typeof vector !== 'object') {
116+
throw new Error(`Event content.data is not a valid vector: ${JSON.stringify(event.content)}`);
117+
}
118+
119+
return engine.fromVector(targetMethod, vector);
120+
}
121+
122+
/**
123+
* Convert directly between two methods.
124+
*
125+
* @param itemKey - converter item key
126+
* @param sourceMethod - source method id
127+
* @param targetMethod - target method id
128+
* @param data - observation in the source method
129+
* @returns { data, matchDistance }
130+
*/
131+
async convertMethodToMethod (itemKey: string, sourceMethod: string, targetMethod: string, data: any): Promise<ConversionResult> {
132+
const engine = await this.ensureEngine(itemKey);
133+
return engine.convertMethodToMethod(sourceMethod, targetMethod, data);
134+
}
135+
136+
// ── Private helpers ─────────────────────────────────────────────────────
137+
138+
async #fetchAndLoadPack (itemKey: string): Promise<EuclidianDistanceEngine> {
139+
// Check the converter index exists
140+
const converters = this.#model.modelData.converters;
141+
if (!converters?.[itemKey]) {
142+
throw new Error(`Unknown converter item key: "${itemKey}". Available: [${this.availableItemKeys.join(', ')}]`);
143+
}
144+
145+
// Derive URL from model base URL
146+
const modelUrl = this.#model.modelUrl;
147+
const baseUrl = modelUrl.substring(0, modelUrl.lastIndexOf('/') + 1);
148+
const packUrl = `${baseUrl}converters/${itemKey}/pack-latest.json`;
149+
150+
const response = await fetch(packUrl);
151+
if (!response.ok) {
152+
throw new Error(`Failed to fetch converter pack: ${packUrl} (${response.status})`);
153+
}
154+
const pack: ConverterPack = await response.json();
155+
156+
if (pack.engine !== 'euclidian-distance') {
157+
throw new Error(`Unknown converter engine: "${pack.engine}" in pack for "${itemKey}"`);
158+
}
159+
160+
const engine = new EuclidianDistanceEngine(pack);
161+
this.#engines[itemKey] = engine;
162+
this.#itemKeyByEventType[engine.eventType] = itemKey;
163+
return engine;
164+
}
165+
166+
#getItemDef (itemKey: string): any {
167+
const items = this.#model.modelData.items;
168+
for (const [_key, item] of Object.entries(items) as [string, any][]) {
169+
const ce = item['converter-engine'];
170+
if (ce && ce.models === itemKey) {
171+
return item;
172+
}
173+
}
174+
throw new Error(`No itemDef found with converter-engine.models="${itemKey}"`);
175+
}
176+
177+
async #findItemKeyForEvent (event: any): Promise<string> {
178+
const eventType = event.type;
179+
180+
// Check already-loaded engines
181+
const cached = this.#itemKeyByEventType[eventType];
182+
if (cached) return cached;
183+
184+
// Check itemDefs for a matching eventType with converter-engine
185+
const items = this.#model.modelData.items;
186+
for (const [_key, item] of Object.entries(items) as [string, any][]) {
187+
if (item.eventType === eventType && item['converter-engine']) {
188+
const itemKey = item['converter-engine'].models;
189+
await this.ensureEngine(itemKey);
190+
return itemKey;
191+
}
192+
}
193+
194+
throw new Error(`No converter found for event type: "${eventType}"`);
195+
}
196+
}

ts/HDSModel/HDSModel.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { HDSModelItemsDefs } from './HDSModel-ItemsDefs.ts';
66
import { HDSModelEventTypes } from './HDSModel-EventTypes.ts';
77
import { HDSModelDatasources } from './HDSModel-Datasources.ts';
88
import { HDSModelConversions } from './HDSModel-Conversions.ts';
9+
import { HDSModelConverters } from './HDSModel-Converters.ts';
910

1011
export class HDSModel {
1112
/**
@@ -48,6 +49,11 @@ export class HDSModel {
4849
return !!this.#modelData;
4950
}
5051

52+
/** The URL the model was loaded from */
53+
get modelUrl (): string {
54+
return this.#modelUrl;
55+
}
56+
5157
/**
5258
* Load model definitions
5359
*/
@@ -120,6 +126,14 @@ export class HDSModel {
120126
}
121127
return this.laziliyLoadedMap.conversions;
122128
}
129+
130+
get converters (): HDSModelConverters {
131+
if (!this.isLoaded) throwNotLoadedError();
132+
if (!this.laziliyLoadedMap.converters) {
133+
this.laziliyLoadedMap.converters = new HDSModelConverters(this);
134+
}
135+
return this.laziliyLoadedMap.converters;
136+
}
123137
}
124138

125139
function throwNotLoadedError (): never {

ts/HDSModel/eventToShortText.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ export function eventToShortText (event: any): string | null {
6262
function formatWithItemDef (event: any, content: any, itemDef: any, model: any): string | null {
6363
const type = itemDef.data.type;
6464

65+
if (type === 'convertible') {
66+
return formatConvertible(event, content, itemDef, model);
67+
}
68+
6569
if (type === 'checkbox') {
6670
return event.type === 'activity/plain' ? 'Yes' : String(content);
6771
}
@@ -200,6 +204,58 @@ function formatObject (content: any): string | null {
200204
return `{${keys.length} fields}`;
201205
}
202206

207+
/**
208+
* Format a convertible event (euclidian-distance converter).
209+
* Shows source observation + source method name.
210+
* If an autoConvert setting exists, converts to that method instead.
211+
*/
212+
function formatConvertible (_event: any, content: any, itemDef: any, model: any): string | null {
213+
const ce = itemDef.data['converter-engine'];
214+
if (!ce) return formatObject(content);
215+
216+
const itemKey = ce.models;
217+
const source = content?.source;
218+
const data = content?.data;
219+
220+
// If source block exists, show the source observation + method name
221+
if (source?.sourceData != null && source?.key) {
222+
const sourceLabel = typeof source.sourceData === 'string'
223+
? source.sourceData
224+
: typeof source.sourceData === 'number'
225+
? String(source.sourceData)
226+
: JSON.stringify(source.sourceData);
227+
const truncated = sourceLabel.length > 40 ? sourceLabel.slice(0, 40) + '...' : sourceLabel;
228+
229+
// Check for autoConvert setting
230+
if (HDSSettings.isHooked && data) {
231+
const settingKey = `autoConvert-${itemDef.key}`;
232+
try {
233+
const targetMethod = HDSSettings.get(settingKey as any);
234+
if (targetMethod && typeof targetMethod === 'string') {
235+
const engine = model.converters?.getEngine(itemKey);
236+
if (engine) {
237+
const result = engine.fromVector(targetMethod, data);
238+
const resultLabel = typeof result.data === 'string' ? result.data : JSON.stringify(result.data);
239+
return `${resultLabel} (${targetMethod})`;
240+
}
241+
}
242+
} catch { /* setting not found, use default */ }
243+
}
244+
245+
return `${truncated} (${source.key})`;
246+
}
247+
248+
// No source — RAW vector input, show dimension summary
249+
if (data && typeof data === 'object') {
250+
const dims = Object.entries(data).filter(([_, v]) => typeof v === 'number' && v > 0);
251+
if (dims.length === 0) return 'empty';
252+
const top = dims.sort(([, a], [, b]) => (b as number) - (a as number)).slice(0, 3);
253+
return top.map(([k, v]) => `${k}:${(v as number).toFixed(1)}`).join(' ');
254+
}
255+
256+
return formatObject(content);
257+
}
258+
203259
function getSymbol (eventType: string, model: any): string | null {
204260
try {
205261
return model.eventTypes.getEventTypeSymbol(eventType);

0 commit comments

Comments
 (0)