diff --git a/frontend/src/framework/EnsembleParameters.ts b/frontend/src/framework/EnsembleParameters.ts index 87f6688eb..2dfed3cee 100644 --- a/frontend/src/framework/EnsembleParameters.ts +++ b/frontend/src/framework/EnsembleParameters.ts @@ -1,14 +1,78 @@ -export type Parameter = { +import { MinMax } from "@lib/utils/MinMax"; + +export enum ParameterType { + CONTINUOUS, + DISCRETE, +} + +export type ContinuousParameter = { + readonly type: ParameterType.CONTINUOUS; readonly name: string; - readonly isLogarithmic: boolean; // Only applicable for numerical/float arrays? - readonly isNumerical: boolean; + readonly groupName: string | null; + readonly description?: string; readonly isConstant: boolean; - readonly groupName?: string; - readonly descriptiveName?: string; - readonly realizations: number[]; // The two arrays, realizations and values, must always be same length - readonly values: number[] | string[]; // Array items can be string, int or float. Should probably be Float32Array, Int32Array or string[] instead. + readonly isLogarithmic: boolean; + readonly realizations: number[]; // The two arrays, realizations and values, must always be same length + readonly values: number[]; // Array items will be floating point. }; +export type DiscreteParameter = { + readonly type: ParameterType.DISCRETE; + readonly name: string; + readonly groupName: string | null; + readonly description?: string; + readonly isConstant: boolean; + readonly realizations: number[]; // The two arrays, realizations and values, must always be same length + readonly values: number[] | string[]; // Array items can be string or int. Should maybe utilize Int32Array or string[] instead? +}; + +export type Parameter = ContinuousParameter | DiscreteParameter; + +export class ParameterIdent { + readonly name: string; + readonly groupName: string | null; + + constructor(name: string, groupName: string | null) { + this.name = name; + this.groupName = groupName; + } + + static fromNameAndGroup(name: string, groupName: string | null): ParameterIdent { + return new ParameterIdent(name, groupName); + } + + static fromString(paramIdentString: string): ParameterIdent { + const parts = paramIdentString.split("~@@~"); + if (parts.length === 1) { + return new ParameterIdent(parts[0], null); + } + if (parts.length === 2) { + return new ParameterIdent(parts[0], parts[1]); + } + + throw new Error(`Invalid parameter ident string: ${paramIdentString}`); + } + + toString(): string { + if (this.groupName) { + return `${this.name}~@@~${this.groupName}`; + } else { + return this.name; + } + } + + equals(otherIdent: ParameterIdent | null): boolean { + if (!otherIdent) { + return false; + } + if (otherIdent === this) { + return true; + } + + return this.name === otherIdent.name && this.groupName === otherIdent.groupName; + } +} + export class EnsembleParameters { private _parameterArr: Parameter[]; @@ -16,8 +80,46 @@ export class EnsembleParameters { this._parameterArr = parameterArr; } - getParameterNames(): string[] { - return this._parameterArr.map((par) => par.name); + getParameterIdents(requiredParamType: ParameterType | null): ParameterIdent[] { + const identArr: ParameterIdent[] = []; + for (const par of this._parameterArr) { + if (requiredParamType == null || par.type === requiredParamType) { + identArr.push(new ParameterIdent(par.name, par.groupName)); + } + } + + return identArr; + } + + hasParameter(paramIdent: ParameterIdent): boolean { + return this.findParameter(paramIdent) !== null; + } + + getParameter(paramIdent: ParameterIdent): Parameter { + const par = this.findParameter(paramIdent); + if (!par) { + throw new Error(`Parameter ${paramIdent.name} (group=${paramIdent.groupName}) not found`); + } + return par; + } + + getContinuousParameterMinMax(paramIdent: ParameterIdent): MinMax { + const par = this.getParameter(paramIdent); + if (par.type !== ParameterType.CONTINUOUS) { + throw new Error(`Parameter ${paramIdent.name} (group=${paramIdent.groupName}) is not of type continuous`); + } + + return MinMax.fromNumericValues(par.values); + } + + findParameter(paramIdent: ParameterIdent): Parameter | null { + for (const par of this._parameterArr) { + if (par.name === paramIdent.name && par.groupName === paramIdent.groupName) { + return par; + } + } + + return null; } getParameterArr(): readonly Parameter[] { diff --git a/frontend/src/framework/internal/EnsembleSetLoader.ts b/frontend/src/framework/internal/EnsembleSetLoader.ts index 4fbffdd6b..a9d4ef14c 100644 --- a/frontend/src/framework/internal/EnsembleSetLoader.ts +++ b/frontend/src/framework/internal/EnsembleSetLoader.ts @@ -4,7 +4,7 @@ import { QueryClient } from "@tanstack/react-query"; import { Ensemble } from "../Ensemble"; import { EnsembleIdent } from "../EnsembleIdent"; -import { Parameter } from "../EnsembleParameters"; +import { Parameter, ParameterType, ContinuousParameter, DiscreteParameter } from "../EnsembleParameters"; import { Sensitivity, SensitivityCase } from "../EnsembleSensitivities"; import { EnsembleSet } from "../EnsembleSet"; @@ -129,16 +129,31 @@ function buildParameterArrFromApiResponse(apiParameterArr: EnsembleParameter_api const retParameterArr: Parameter[] = []; for (const apiPar of apiParameterArr) { - retParameterArr.push({ - name: apiPar.name, - isLogarithmic: apiPar.is_logarithmic, - isNumerical: apiPar.is_numerical, - isConstant: apiPar.is_constant, - groupName: apiPar.group_name, - descriptiveName: apiPar.descriptive_name, - realizations: apiPar.realizations, - values: apiPar.values, - }); + if (apiPar.is_numerical) { + const retPar: ContinuousParameter = { + type: ParameterType.CONTINUOUS, + name: apiPar.name, + groupName: apiPar.group_name ?? null, + description: apiPar.descriptive_name, + isConstant: apiPar.is_constant, + isLogarithmic: apiPar.is_logarithmic, + realizations: apiPar.realizations, + values: apiPar.values as number[], + }; + retParameterArr.push(retPar); + } + else { + const retPar: DiscreteParameter = { + type: ParameterType.DISCRETE, + name: apiPar.name, + groupName: apiPar.group_name ?? null, + description: apiPar.descriptive_name, + isConstant: apiPar.is_constant, + realizations: apiPar.realizations, + values: apiPar.values, + }; + retParameterArr.push(retPar); + } } return retParameterArr; diff --git a/frontend/src/lib/utils/MinMax.ts b/frontend/src/lib/utils/MinMax.ts new file mode 100644 index 000000000..c671a48a2 --- /dev/null +++ b/frontend/src/lib/utils/MinMax.ts @@ -0,0 +1,64 @@ +/** + * Immutable class for storing a min/max scalar range + */ + +export class MinMax { + readonly min: number; + readonly max: number; + + constructor(min: number, max: number) { + this.min = min; + this.max = max; + } + + static createInvalid(): MinMax { + return new MinMax(Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY); + } + + static fromNumericValues(values: Iterable): MinMax { + let min = Number.POSITIVE_INFINITY; + let max = Number.NEGATIVE_INFINITY; + for (const v of values) { + if (v < min) { + min = v; + } + if (v > max) { + max = v; + } + } + + if (min <= max) { + return new MinMax(min, max); + } else { + return MinMax.createInvalid(); + } + } + + /** + * Returns a new MinMax object that is extend to include values from other min/max range + * Will handle invalid ranges (min > max) in both this and the other object. + */ + extendedBy(otherMinMax: MinMax): MinMax { + if (!otherMinMax.isValid()) { + return new MinMax(this.min, this.max); + } + if (!this.isValid()) { + return new MinMax(otherMinMax.min, otherMinMax.max); + } + + const newMin = Math.min(this.min, otherMinMax.min); + const newMax = Math.max(this.max, otherMinMax.max); + return new MinMax(newMin, newMax); + } + + /** + * Returns true if the range is valid, i.e. if minimum <= maximum + */ + isValid(): boolean { + if (this.min <= this.max) { + return true; + } else { + return false; + } + } +} diff --git a/frontend/tests/unit-tests/EnsembleParameters.test.ts b/frontend/tests/unit-tests/EnsembleParameters.test.ts new file mode 100644 index 000000000..a9f22d2fe --- /dev/null +++ b/frontend/tests/unit-tests/EnsembleParameters.test.ts @@ -0,0 +1,127 @@ +import { EnsembleParameters, Parameter, ParameterIdent, ParameterType } from "@framework/EnsembleParameters"; +import { MinMax } from "@lib/utils/MinMax"; + +// prettier-ignore +const PARAM_ARR: Parameter[] = [ + {type: ParameterType.CONTINUOUS, name: "cparam_10", groupName: null, description: "desc10", isConstant: false, isLogarithmic: false, realizations: [1,2,3], values: [11, 12, 19]}, + {type: ParameterType.CONTINUOUS, name: "cparam_20", groupName: null, description: "desc20", isConstant: false, isLogarithmic: false, realizations: [1,2,3], values: [21, 22, 29]}, + {type: ParameterType.CONTINUOUS, name: "cparam_50", groupName: "grp1", description: "desc50g1", isConstant: false, isLogarithmic: false, realizations: [1,2,3], values: [51, 52, 54]}, + {type: ParameterType.CONTINUOUS, name: "cparam_50", groupName: "grp2", description: "desc50g2", isConstant: false, isLogarithmic: false, realizations: [1,2,3], values: [55, 56, 59]}, + + {type: ParameterType.DISCRETE, name: "dparam_A", groupName: null, description: "descA", isConstant: false, realizations: [1,2,3], values: [1, 2, 3]}, + {type: ParameterType.DISCRETE, name: "dparam_B", groupName: null, description: "descB", isConstant: false, realizations: [1,2,3], values: ["A", "B", "C"]}, +]; + + +describe("EnsembleParameters tests", () => { + test("Get list of parameter idents", () => { + const ensParams = new EnsembleParameters(PARAM_ARR); + { + const allIdents = ensParams.getParameterIdents(null); + expect(allIdents.length).toEqual(6); + expect(allIdents[0]).toEqual(ParameterIdent.fromNameAndGroup("cparam_10", null)); + expect(allIdents[1]).toEqual(ParameterIdent.fromNameAndGroup("cparam_20", null)); + expect(allIdents[2]).toEqual(ParameterIdent.fromNameAndGroup("cparam_50", "grp1")); + expect(allIdents[3]).toEqual(ParameterIdent.fromNameAndGroup("cparam_50", "grp2")); + expect(allIdents[4]).toEqual(ParameterIdent.fromNameAndGroup("dparam_A", null)); + expect(allIdents[5]).toEqual(ParameterIdent.fromNameAndGroup("dparam_B", null)); + } + { + const contIdents = ensParams.getParameterIdents(ParameterType.CONTINUOUS); + expect(contIdents.length).toEqual(4); + expect(contIdents[0]).toEqual(ParameterIdent.fromNameAndGroup("cparam_10", null)); + expect(contIdents[1]).toEqual(ParameterIdent.fromNameAndGroup("cparam_20", null)); + expect(contIdents[2]).toEqual(ParameterIdent.fromNameAndGroup("cparam_50", "grp1")); + expect(contIdents[3]).toEqual(ParameterIdent.fromNameAndGroup("cparam_50", "grp2")); + } + { + const discIdents = ensParams.getParameterIdents(ParameterType.DISCRETE); + expect(discIdents.length).toEqual(2); + expect(discIdents[0]).toEqual(ParameterIdent.fromNameAndGroup("dparam_A", null)); + expect(discIdents[1]).toEqual(ParameterIdent.fromNameAndGroup("dparam_B", null)); + } + }); + + test("Check for parameter existence", () => { + const ensParams = new EnsembleParameters(PARAM_ARR); + + expect(ensParams.hasParameter(ParameterIdent.fromNameAndGroup("cparam_10", null))).toBe(true); + expect(ensParams.hasParameter(ParameterIdent.fromNameAndGroup("cparam_50", "grp1"))).toBe(true); + expect(ensParams.hasParameter(ParameterIdent.fromNameAndGroup("cparam_50", "grp2"))).toBe(true); + + expect(ensParams.hasParameter(ParameterIdent.fromNameAndGroup("aName", "aGroup"))).toBe(false); + expect(ensParams.hasParameter(ParameterIdent.fromNameAndGroup("", ""))).toBe(false); + expect(ensParams.hasParameter(ParameterIdent.fromNameAndGroup("cparam_10", ""))).toBe(false); + expect(ensParams.hasParameter(ParameterIdent.fromNameAndGroup("cparam_50", null))).toBe(false); + }); + + test("Get parameters", () => { + const ensParams = new EnsembleParameters(PARAM_ARR); + { + const par = ensParams.getParameter(ParameterIdent.fromNameAndGroup("cparam_10", null)); + expect(par.type).toEqual(ParameterType.CONTINUOUS); + expect(par.name).toEqual("cparam_10"); + expect(par.groupName).toEqual(null); + expect(par.values).toEqual([11, 12, 19]); + } + { + const par = ensParams.getParameter(ParameterIdent.fromNameAndGroup("cparam_50", "grp2")); + expect(par.type).toEqual(ParameterType.CONTINUOUS); + expect(par.name).toEqual("cparam_50"); + expect(par.groupName).toEqual("grp2"); + expect(par.values).toEqual([55, 56, 59]); + } + { + const par = ensParams.getParameter(ParameterIdent.fromNameAndGroup("dparam_B", null)); + expect(par.type).toEqual(ParameterType.DISCRETE); + expect(par.name).toEqual("dparam_B"); + expect(par.groupName).toEqual(null); + expect(par.values).toEqual(["A", "B", "C"]); + } + }); + + test("Check that getting non-existing parameter throws", () => { + const ensParams = new EnsembleParameters(PARAM_ARR); + expect(() => ensParams.getParameter(ParameterIdent.fromNameAndGroup("someBogusName", null))).toThrow(); + }); + + test("Test getting min/max values for continuous parameter", () => { + const ensParams = new EnsembleParameters(PARAM_ARR); + { + const minMax = ensParams.getContinuousParameterMinMax(ParameterIdent.fromNameAndGroup("cparam_10", null)); + expect(minMax).toEqual(new MinMax(11, 19)); + } + { + const minMax = ensParams.getContinuousParameterMinMax(ParameterIdent.fromNameAndGroup("cparam_50", "grp1")); + expect(minMax).toEqual(new MinMax(51, 54)); + } + }); +}); + + +describe("ParameterIdent tests", () => { + test("Conversion to/from string", () => { + { + const identStr = ParameterIdent.fromNameAndGroup("aName", "aGroup").toString(); + const ident = ParameterIdent.fromString(identStr); + expect(ident.name).toEqual("aName"); + expect(ident.groupName).toEqual("aGroup"); + } + { + const identStr = ParameterIdent.fromNameAndGroup("aName", null).toString(); + const ident = ParameterIdent.fromString(identStr); + expect(ident.name).toEqual("aName"); + expect(ident.groupName).toEqual(null); + } + }); + + test("Check for equality", () => { + const identA = new ParameterIdent("aName", "aGroup"); + const identB = new ParameterIdent("aName", "aGroup"); + const identC = new ParameterIdent("anotherName", "anotherGroup"); + expect(identA.equals(identA)).toBe(true); + expect(identA.equals(identB)).toBe(true); + expect(identA.equals(identC)).toBe(false); + expect(identA.equals(null)).toBe(false); + }); +}); diff --git a/frontend/tests/unit-tests/MinMax.test.ts b/frontend/tests/unit-tests/MinMax.test.ts new file mode 100644 index 000000000..710e5733a --- /dev/null +++ b/frontend/tests/unit-tests/MinMax.test.ts @@ -0,0 +1,42 @@ +import { MinMax } from "@lib/utils/MinMax"; + +describe("MinMax tests", () => { + test("Check validity of MinMax instances", () => { + expect(new MinMax(0, 1).isValid()).toBe(true); + expect(new MinMax(-1, -1).isValid()).toBe(true); + expect(new MinMax(0, Number.POSITIVE_INFINITY).isValid()).toBe(true); + expect(new MinMax(Number.NEGATIVE_INFINITY, 0).isValid()).toBe(true); + + expect(MinMax.createInvalid().isValid()).toBe(false); + expect(new MinMax(1, 0).isValid()).toBe(false); + expect(new MinMax(0, Number.NaN).isValid()).toBe(false); + expect(new MinMax(Number.NaN, 0).isValid()).toBe(false); + expect(new MinMax(Number.NaN, Number.NaN).isValid()).toBe(false); + }); + + test("Check construction from numeric values", () => { + expect(MinMax.fromNumericValues([]).isValid()).toBe(false); + expect(MinMax.fromNumericValues([1])).toEqual(new MinMax(1, 1)); + expect(MinMax.fromNumericValues([0, 1, 2, 3, 4])).toEqual(new MinMax(0, 4)); + + expect(MinMax.fromNumericValues(new Float32Array([0, 1, 2, 3, 4]))).toEqual(new MinMax(0, 4)); + expect(MinMax.fromNumericValues(new Set([0, 1, 2, 3, 4]))).toEqual(new MinMax(0, 4)); + + const bogusArray = [1, undefined, 2, Number.NaN, 3, 4]; + expect(MinMax.fromNumericValues(bogusArray as number[])).toEqual(new MinMax(1, 4)); + }); + + test("Check extending by another MinMax object", () => { + const validMinMaxA = new MinMax(0, 1); + const validMinMaxB = new MinMax(10, 11); + const invalidMinMax = MinMax.createInvalid(); + + expect(validMinMaxA.extendedBy(validMinMaxA)).toEqual(validMinMaxA); + expect(invalidMinMax.extendedBy(validMinMaxA)).toEqual(validMinMaxA); + expect(validMinMaxA.extendedBy(invalidMinMax)).toEqual(validMinMaxA); + + expect(validMinMaxA.extendedBy(validMinMaxB)).toEqual(new MinMax(0, 11)); + + expect(invalidMinMax.extendedBy(invalidMinMax).isValid()).toBe(false); + }); +});