FERS 0.1.0
The Flexible Extensible Radar Simulator
Loading...
Searching...
No Matches
assetTemplates.ts
Go to the documentation of this file.
1// SPDX-License-Identifier: GPL-2.0-only
2// Copyright (c) 2026-present FERS Contributors (see AUTHORS.md).
3
4import { z } from 'zod';
5import {
6 AntennaSchema,
7 PlatformSchema,
8 TimingSchema,
9 WaveformSchema,
10} from './scenarioSchema';
11import { generateSimId } from './scenarioStore/idUtils';
12import {
13 collectScenarioNames,
14 createUniqueName,
15 createUniqueScenarioCopyName,
16} from './scenarioStore/nameUtils';
17import type {
18 Antenna,
19 Platform,
20 PlatformComponent,
21 ScenarioData,
22 Timing,
23 Waveform,
24} from './scenarioStore/types';
25
26export const ASSET_LIBRARY_SCHEMA_VERSION = 1 as const;
27
28const TemplateBaseSchema = z.object({
29 schemaVersion: z.literal(ASSET_LIBRARY_SCHEMA_VERSION),
30 id: z.string().min(1),
31 name: z.string().min(1),
32 createdAt: z.string().min(1),
33 updatedAt: z.string().min(1),
34 source: z
35 .object({
36 scenarioName: z.string().optional(),
37 itemId: z.string().optional(),
38 })
39 .optional(),
40});
41
42const PlatformDependenciesSchema = z.object({
43 waveforms: z.array(WaveformSchema),
44 timings: z.array(TimingSchema),
45 antennas: z.array(AntennaSchema),
46});
47
48const WaveformTemplateSchema = TemplateBaseSchema.extend({
49 kind: z.literal('waveform'),
50 payload: WaveformSchema,
51});
52
53const TimingTemplateSchema = TemplateBaseSchema.extend({
54 kind: z.literal('timing'),
55 payload: TimingSchema,
56});
57
58const AntennaTemplateSchema = TemplateBaseSchema.extend({
59 kind: z.literal('antenna'),
60 payload: AntennaSchema,
61});
62
63const PlatformTemplateSchema = TemplateBaseSchema.extend({
64 kind: z.literal('platform'),
65 payload: PlatformSchema,
66 dependencies: PlatformDependenciesSchema,
67});
68
69export const AssetLibraryTemplateSchema = z.discriminatedUnion('kind', [
70 WaveformTemplateSchema,
71 TimingTemplateSchema,
72 AntennaTemplateSchema,
73 PlatformTemplateSchema,
74]);
75
76export const AssetLibraryFileSchema = z.object({
77 schemaVersion: z.literal(ASSET_LIBRARY_SCHEMA_VERSION),
78 templates: z.array(AssetLibraryTemplateSchema),
79});
80
81export type AssetTemplateKind = z.infer<
82 typeof AssetLibraryTemplateSchema
83>['kind'];
84export type AssetLibraryTemplate = z.infer<typeof AssetLibraryTemplateSchema>;
85export type AssetLibraryFile = z.infer<typeof AssetLibraryFileSchema>;
86
87export interface AssetTemplateInsertionResult {
88 insertedItemId: string | null;
89 insertedName: string | null;
90 warnings: string[];
91}
92
93type CreateTemplateOptions = {
94 id?: string;
95 timestamp?: string;
96};
97
98let fallbackTemplateCounter = 1;
99
100export function createAssetTemplateId(): string {
101 if (globalThis.crypto?.randomUUID) {
102 return globalThis.crypto.randomUUID();
103 }
104
105 const id = `template-${Date.now()}-${fallbackTemplateCounter}`;
106 fallbackTemplateCounter += 1;
107 return id;
108}
109
110function deepClone<T>(value: T): T {
111 return JSON.parse(JSON.stringify(value)) as T;
112}
113
114function createBaseTemplate(
115 item: Waveform | Timing | Antenna | Platform,
116 scenarioData: ScenarioData,
117 options: CreateTemplateOptions
118) {
119 const timestamp = options.timestamp ?? new Date().toISOString();
120 return {
121 schemaVersion: ASSET_LIBRARY_SCHEMA_VERSION,
122 id: options.id ?? createAssetTemplateId(),
123 name: item.name,
124 createdAt: timestamp,
125 updatedAt: timestamp,
126 source: {
127 scenarioName: scenarioData.globalParameters.simulation_name,
128 itemId: item.id,
129 },
130 };
131}
132
133function sanitizePlatform(platform: Platform): Platform {
134 const cloned = deepClone(platform);
135 delete cloned.pathPoints;
136 delete cloned.rotationPathPoints;
137 return cloned;
138}
139
140function uniqueById<T extends { id: string }>(items: T[]): T[] {
141 const seen = new Set<string>();
142 return items.filter((item) => {
143 if (seen.has(item.id)) {
144 return false;
145 }
146 seen.add(item.id);
147 return true;
148 });
149}
150
151function collectPlatformDependencies(
152 platform: Platform,
153 scenarioData: ScenarioData
154) {
155 const antennaIds = new Set<string>();
156 const waveformIds = new Set<string>();
157 const timingIds = new Set<string>();
158
159 for (const component of platform.components) {
160 if ('antennaId' in component && component.antennaId) {
161 antennaIds.add(component.antennaId);
162 }
163 if ('waveformId' in component && component.waveformId) {
164 waveformIds.add(component.waveformId);
165 }
166 if ('timingId' in component && component.timingId) {
167 timingIds.add(component.timingId);
168 }
169 }
170
171 return {
172 waveforms: uniqueById(
173 scenarioData.waveforms.filter((waveform) =>
174 waveformIds.has(waveform.id)
175 )
176 ).map(deepClone),
177 timings: uniqueById(
178 scenarioData.timings.filter((timing) => timingIds.has(timing.id))
179 ).map(deepClone),
180 antennas: uniqueById(
181 scenarioData.antennas.filter((antenna) =>
182 antennaIds.has(antenna.id)
183 )
184 ).map(deepClone),
185 };
186}
187
188export function createTemplateFromScenarioItem(
189 scenarioData: ScenarioData,
190 itemId: string,
191 options: CreateTemplateOptions = {}
192): AssetLibraryTemplate | null {
193 const waveform = scenarioData.waveforms.find((item) => item.id === itemId);
194 if (waveform) {
195 return {
196 ...createBaseTemplate(waveform, scenarioData, options),
197 kind: 'waveform',
198 payload: deepClone(waveform),
199 };
200 }
201
202 const timing = scenarioData.timings.find((item) => item.id === itemId);
203 if (timing) {
204 return {
205 ...createBaseTemplate(timing, scenarioData, options),
206 kind: 'timing',
207 payload: deepClone(timing),
208 };
209 }
210
211 const antenna = scenarioData.antennas.find((item) => item.id === itemId);
212 if (antenna) {
213 return {
214 ...createBaseTemplate(antenna, scenarioData, options),
215 kind: 'antenna',
216 payload: deepClone(antenna),
217 };
218 }
219
220 const platform = scenarioData.platforms.find((item) => item.id === itemId);
221 if (platform) {
222 return {
223 ...createBaseTemplate(platform, scenarioData, options),
224 kind: 'platform',
225 payload: sanitizePlatform(platform),
226 dependencies: collectPlatformDependencies(platform, scenarioData),
227 };
228 }
229
230 return null;
231}
232
233function cloneWaveform(
234 waveform: Waveform,
235 scenarioData: ScenarioData,
236 baseName = waveform.name
237): Waveform {
238 return {
239 ...deepClone(waveform),
240 id: generateSimId('Waveform'),
241 name: createUniqueScenarioCopyName(scenarioData, baseName),
242 };
243}
244
245function cloneTiming(
246 timing: Timing,
247 scenarioData: ScenarioData,
248 baseName = timing.name
249): Timing {
250 return {
251 ...deepClone(timing),
252 id: generateSimId('Timing'),
253 name: createUniqueScenarioCopyName(scenarioData, baseName),
254 noiseEntries: timing.noiseEntries.map((entry) => ({
255 ...deepClone(entry),
256 id: generateSimId('Timing'),
257 })),
258 };
259}
260
261function cloneAntenna(
262 antenna: Antenna,
263 scenarioData: ScenarioData,
264 baseName = antenna.name
265): Antenna {
266 return {
267 ...deepClone(antenna),
268 id: generateSimId('Antenna'),
269 name: createUniqueScenarioCopyName(scenarioData, baseName),
270 };
271}
272
273function clonePlatformWaypointIds(platform: Platform): Platform {
274 const cloned = sanitizePlatform(platform);
275 cloned.id = generateSimId('Platform');
276 cloned.motionPath.waypoints = cloned.motionPath.waypoints.map(
277 (waypoint) => ({
278 ...waypoint,
279 id: generateSimId('Platform'),
280 })
281 );
282
283 if (cloned.rotation.type === 'path') {
284 cloned.rotation.waypoints = cloned.rotation.waypoints.map(
285 (waypoint) => ({
286 ...waypoint,
287 id: generateSimId('Platform'),
288 })
289 );
290 }
291
292 return cloned;
293}
294
295function remapReference(
296 originalId: string | null,
297 idMap: Map<string, string>,
298 warnings: string[],
299 label: string
300): string | null {
301 if (!originalId) {
302 return null;
303 }
304
305 const remapped = idMap.get(originalId);
306 if (remapped) {
307 return remapped;
308 }
309
310 warnings.push(
311 `Missing ${label} dependency ${originalId}; reference cleared.`
312 );
313 return null;
314}
315
316function cloneComponent(
317 component: PlatformComponent,
318 antennaIdMap: Map<string, string>,
319 timingIdMap: Map<string, string>,
320 waveformIdMap: Map<string, string>,
321 warnings: string[],
322 existingNames: Set<string>
323): PlatformComponent {
324 const name = createUniqueName(component.name, existingNames, {
325 copy: true,
326 });
327 existingNames.add(name);
328
329 switch (component.type) {
330 case 'monostatic': {
331 const txId = generateSimId('Transmitter');
332 return {
333 ...deepClone(component),
334 id: txId,
335 txId,
336 rxId: generateSimId('Receiver'),
337 name,
338 antennaId: remapReference(
339 component.antennaId,
340 antennaIdMap,
341 warnings,
342 'antenna'
343 ),
344 timingId: remapReference(
345 component.timingId,
346 timingIdMap,
347 warnings,
348 'timing'
349 ),
350 waveformId: remapReference(
351 component.waveformId,
352 waveformIdMap,
353 warnings,
354 'waveform'
355 ),
356 };
357 }
358 case 'transmitter':
359 return {
360 ...deepClone(component),
361 id: generateSimId('Transmitter'),
362 name,
363 antennaId: remapReference(
364 component.antennaId,
365 antennaIdMap,
366 warnings,
367 'antenna'
368 ),
369 timingId: remapReference(
370 component.timingId,
371 timingIdMap,
372 warnings,
373 'timing'
374 ),
375 waveformId: remapReference(
376 component.waveformId,
377 waveformIdMap,
378 warnings,
379 'waveform'
380 ),
381 };
382 case 'receiver':
383 return {
384 ...deepClone(component),
385 id: generateSimId('Receiver'),
386 name,
387 antennaId: remapReference(
388 component.antennaId,
389 antennaIdMap,
390 warnings,
391 'antenna'
392 ),
393 timingId: remapReference(
394 component.timingId,
395 timingIdMap,
396 warnings,
397 'timing'
398 ),
399 };
400 case 'target':
401 return {
402 ...deepClone(component),
403 id: generateSimId('Target'),
404 name,
405 };
406 }
407}
408
409export function cloneTemplateIntoScenarioData(
410 scenarioData: ScenarioData,
411 template: AssetLibraryTemplate
412): { scenarioData: ScenarioData; result: AssetTemplateInsertionResult } {
413 const nextScenarioData: ScenarioData = {
414 globalParameters: deepClone(scenarioData.globalParameters),
415 waveforms: scenarioData.waveforms.map(deepClone),
416 timings: scenarioData.timings.map(deepClone),
417 antennas: scenarioData.antennas.map(deepClone),
418 platforms: scenarioData.platforms.map(sanitizePlatform),
419 };
420 const warnings: string[] = [];
421
422 switch (template.kind) {
423 case 'waveform': {
424 const waveform = cloneWaveform(
425 template.payload,
426 nextScenarioData,
427 template.name
428 );
429 nextScenarioData.waveforms.push(waveform);
430 return {
431 scenarioData: nextScenarioData,
432 result: {
433 insertedItemId: waveform.id,
434 insertedName: waveform.name,
435 warnings,
436 },
437 };
438 }
439 case 'timing': {
440 const timing = cloneTiming(
441 template.payload,
442 nextScenarioData,
443 template.name
444 );
445 nextScenarioData.timings.push(timing);
446 return {
447 scenarioData: nextScenarioData,
448 result: {
449 insertedItemId: timing.id,
450 insertedName: timing.name,
451 warnings,
452 },
453 };
454 }
455 case 'antenna': {
456 const antenna = cloneAntenna(
457 template.payload,
458 nextScenarioData,
459 template.name
460 );
461 nextScenarioData.antennas.push(antenna);
462 return {
463 scenarioData: nextScenarioData,
464 result: {
465 insertedItemId: antenna.id,
466 insertedName: antenna.name,
467 warnings,
468 },
469 };
470 }
471 case 'platform': {
472 const dependencies = template.dependencies ?? {
473 waveforms: [],
474 timings: [],
475 antennas: [],
476 };
477 const waveformIdMap = new Map<string, string>();
478 const timingIdMap = new Map<string, string>();
479 const antennaIdMap = new Map<string, string>();
480
481 for (const waveformTemplate of dependencies.waveforms) {
482 const waveform = cloneWaveform(
483 waveformTemplate,
484 nextScenarioData
485 );
486 waveformIdMap.set(waveformTemplate.id, waveform.id);
487 nextScenarioData.waveforms.push(waveform);
488 }
489
490 for (const timingTemplate of dependencies.timings) {
491 const timing = cloneTiming(timingTemplate, nextScenarioData);
492 timingIdMap.set(timingTemplate.id, timing.id);
493 nextScenarioData.timings.push(timing);
494 }
495
496 for (const antennaTemplate of dependencies.antennas) {
497 const antenna = cloneAntenna(antennaTemplate, nextScenarioData);
498 antennaIdMap.set(antennaTemplate.id, antenna.id);
499 nextScenarioData.antennas.push(antenna);
500 }
501
502 const platform = clonePlatformWaypointIds(template.payload);
503 platform.name = createUniqueScenarioCopyName(
504 nextScenarioData,
505 template.name
506 );
507 const existingNames = collectScenarioNames(nextScenarioData);
508 existingNames.add(platform.name);
509 platform.components = template.payload.components.map((component) =>
510 cloneComponent(
511 component,
512 antennaIdMap,
513 timingIdMap,
514 waveformIdMap,
515 warnings,
516 existingNames
517 )
518 );
519 nextScenarioData.platforms.push(platform);
520
521 return {
522 scenarioData: nextScenarioData,
523 result: {
524 insertedItemId: platform.id,
525 insertedName: platform.name,
526 warnings,
527 },
528 };
529 }
530 }
531}
532
533export function createAssetLibraryFile(
534 templates: AssetLibraryTemplate[]
535): AssetLibraryFile {
536 return {
537 schemaVersion: ASSET_LIBRARY_SCHEMA_VERSION,
538 templates,
539 };
540}
541
542export function parseAssetTemplates(data: unknown): AssetLibraryTemplate[] {
543 const fileResult = AssetLibraryFileSchema.safeParse(data);
544 if (fileResult.success) {
545 return fileResult.data.templates;
546 }
547
548 const templateResult = AssetLibraryTemplateSchema.safeParse(data);
549 if (templateResult.success) {
550 return [templateResult.data];
551 }
552
553 throw new Error(
554 'Asset library JSON does not match the v1 template schema.'
555 );
556}
557
558export function prepareTemplatesForCatalog(
559 templates: AssetLibraryTemplate[],
560 timestamp = new Date().toISOString()
561): AssetLibraryTemplate[] {
562 return templates.map((template) => ({
563 ...deepClone(template),
564 id: createAssetTemplateId(),
565 updatedAt: timestamp,
566 }));
567}