Skip to content
Open
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
5 changes: 4 additions & 1 deletion e2e-tests/data/external-dataset.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@
"rate": -0.5
}
},
{
"duration": 40000000
},
{
"duration": 30000000,
"dynamics": {
Expand All @@ -48,4 +51,4 @@
"type": "real"
}
}
}
}
11 changes: 10 additions & 1 deletion src/types/simulation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
78 changes: 78 additions & 0 deletions src/utilities/resources.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
},
]);
});
});
64 changes: 41 additions & 23 deletions src/utilities/resources.ts
Original file line number Diff line number Diff line change
@@ -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.
*/
Expand Down Expand Up @@ -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 });
Expand Down
Loading