diff --git a/e2e-tests/data/external-dataset.json b/e2e-tests/data/external-dataset.json index b0b1bf49e8..2a4003cbb2 100644 --- a/e2e-tests/data/external-dataset.json +++ b/e2e-tests/data/external-dataset.json @@ -37,6 +37,9 @@ "rate": -0.5 } }, + { + "duration": 40000000 + }, { "duration": 30000000, "dynamics": { @@ -48,4 +51,4 @@ "type": "real" } } -} \ No newline at end of file +} diff --git a/src/types/simulation.ts b/src/types/simulation.ts index d559032bff..6f6b8b85e9 100644 --- a/src/types/simulation.ts +++ b/src/types/simulation.ts @@ -27,9 +27,18 @@ export type Profile = { }; }; +// Dynamics for real profiles (linear interpolation) +export type RealDynamics = { + initial: number; + rate: number; +}; + +// Dynamics can be: simple value (discrete), RealDynamics (real), or null/missing (gap) +export type ProfileDynamics = RealDynamics | string | number | boolean | null; + export type ProfileSegment = { dataset_id: number; - dynamics: any; + dynamics?: ProfileDynamics; is_gap: boolean; profile_id: number; start_offset: string; diff --git a/src/utilities/resources.test.ts b/src/utilities/resources.test.ts index 1b63d22590..6c1ad77ba9 100644 --- a/src/utilities/resources.test.ts +++ b/src/utilities/resources.test.ts @@ -110,4 +110,82 @@ describe('sampleProfiles', () => { expect(resources).toEqual(expectedResources); }); + + test('Emit a real profile segment with missing/null dynamics as a gap instead of crashing', () => { + const profiles: Profile[] = [ + { + dataset_id: 1, + duration: '00:00:03', + id: 1, + name: '/real/with_gap', + profile_segments: [ + { + dataset_id: 1, + dynamics: { initial: 10, rate: -0.5 }, + is_gap: false, + profile_id: 1, + start_offset: '00:00:00', + }, + // Segment missing dynamics (e.g. a `{ duration }`-only external dataset segment) — previously crashed. + { dataset_id: 1, dynamics: null, is_gap: false, profile_id: 1, start_offset: '00:00:01' }, + { dataset_id: 1, dynamics: { initial: 5, rate: 0 }, is_gap: false, profile_id: 1, start_offset: '00:00:02' }, + ], + type: { + schema: { items: { initial: { type: 'real' }, rate: { type: 'real' } }, type: 'struct' }, + type: 'real', + }, + }, + ]; + + const resources = sampleProfiles(profiles, '2022-09-01T00:00:00+00:00'); + + expect(resources).toEqual([ + { + name: '/real/with_gap', + schema: { items: { initial: { type: 'real' }, rate: { type: 'real' } }, type: 'struct' }, + values: [ + { is_gap: false, x: 1661990400000, y: 10 }, + { is_gap: false, x: 1661990401000, y: 9.5 }, + { is_gap: true, x: 1661990401000, y: null }, + { is_gap: true, x: 1661990402000, y: null }, + { is_gap: false, x: 1661990402000, y: 5 }, + { is_gap: false, x: 1661990403000, y: 5 }, + ], + }, + ]); + }); + + test('Skip a real profile segment whose dynamics is missing initial/rate', () => { + const profiles: Profile[] = [ + { + dataset_id: 1, + duration: '00:00:01', + id: 1, + name: '/real/malformed', + profile_segments: [ + { + dataset_id: 1, + dynamics: {} as Profile['profile_segments'][number]['dynamics'], + is_gap: false, + profile_id: 1, + start_offset: '00:00:00', + }, + ], + type: { + schema: { items: { initial: { type: 'real' }, rate: { type: 'real' } }, type: 'struct' }, + type: 'real', + }, + }, + ]; + + const resources = sampleProfiles(profiles, '2022-09-01T00:00:00+00:00'); + + expect(resources).toEqual([ + { + name: '/real/malformed', + schema: { items: { initial: { type: 'real' }, rate: { type: 'real' } }, type: 'struct' }, + values: [], + }, + ]); + }); }); diff --git a/src/utilities/resources.ts b/src/utilities/resources.ts index 103c3e23c8..d0f9f68a27 100644 --- a/src/utilities/resources.ts +++ b/src/utilities/resources.ts @@ -1,6 +1,20 @@ -import type { Profile, Resource, ResourceValue } from '../types/simulation'; +import type { Profile, RealDynamics, Resource, ResourceValue } from '../types/simulation'; import { getIntervalInMs } from './time'; +/** + * Type guard to check if dynamics represents a real profile (linear interpolation). + */ +function isRealDynamics(dynamics: unknown): dynamics is RealDynamics { + return ( + typeof dynamics === 'object' && + dynamics !== null && + 'initial' in dynamics && + 'rate' in dynamics && + typeof (dynamics as RealDynamics).initial === 'number' && + typeof (dynamics as RealDynamics).rate === 'number' + ); +} + /** * Samples a list of profiles at their change points. Converts the sampled profiles to Resources. */ @@ -30,29 +44,33 @@ export function sampleProfiles( const { dynamics, is_gap } = segment; - if (type === 'discrete') { - values.push({ - is_gap, - x: start + segmentOffset, - y: dynamics, - }); - values.push({ - is_gap, - x: start + nextSegmentOffset, - y: dynamics, - }); - } else if (type === 'real') { - values.push({ - is_gap, - x: start + segmentOffset, - y: dynamics.initial, - }); - values.push({ - is_gap, - x: start + nextSegmentOffset, - y: dynamics.initial + dynamics.rate * ((nextSegmentOffset - segmentOffset) / 1000), - }); + // Compute y values based on segment type + let startY: ResourceValue['y']; + let endY: ResourceValue['y']; + let segmentIsGap: boolean; + + if (is_gap || dynamics == null) { + segmentIsGap = true; + startY = null; + endY = null; + } else if (type === 'real' && isRealDynamics(dynamics)) { + segmentIsGap = false; + startY = dynamics.initial; + endY = dynamics.initial + dynamics.rate * ((nextSegmentOffset - segmentOffset) / 1000); + } else if (type === 'discrete') { + // Discrete - dynamics is the value itself + segmentIsGap = false; + startY = dynamics as ResourceValue['y']; + endY = dynamics as ResourceValue['y']; + } else { + // Type is not supported + continue; } + + values.push( + { is_gap: segmentIsGap, x: start + segmentOffset, y: startY }, + { is_gap: segmentIsGap, x: start + nextSegmentOffset, y: endY }, + ); } resources.push({ name, schema, values });