diff --git a/packages/client/hmi-client/src/components/widgets/VegaChart.vue b/packages/client/hmi-client/src/components/widgets/VegaChart.vue index a8a5cb4b55..a6f1ca04a7 100644 --- a/packages/client/hmi-client/src/components/widgets/VegaChart.vue +++ b/packages/client/hmi-client/src/components/widgets/VegaChart.vue @@ -31,7 +31,7 @@ import embed, { Config, Result, VisualizationSpec } from 'vega-embed'; import Button from 'primevue/button'; import Dialog from 'primevue/dialog'; import { countDigits, fixPrecisionError } from '@/utils/number'; -import { ref, watch, toRaw, isRef, isReactive, isProxy, computed, h, render } from 'vue'; +import { ref, watch, toRaw, isRef, isReactive, isProxy, computed, h, render, onUnmounted } from 'vue'; const NUMBER_FORMAT = '.3~s'; @@ -119,6 +119,7 @@ const onExpand = async () => { if (typeof props.expandable === 'function') { spec = props.expandable(spec); } + vegaVisualizationExpanded.value?.finalize(); // dispose previous visualization before creating a new one vegaVisualizationExpanded.value = await createVegaVisualization(vegaContainerLg.value, spec, props.config, { actions: props.areEmbedActionsVisible, expandable: false @@ -219,8 +220,8 @@ async function createVegaVisualization( watch( [vegaContainer, () => props.visualizationSpec], async ([, newSpec], [, oldSpec]) => { + if (_.isEmpty(newSpec)) return; const isEqual = _.isEqual(newSpec, oldSpec); - if (isEqual && vegaVisualization.value !== undefined) return; const spec = deepToRaw(props.visualizationSpec); @@ -246,6 +247,7 @@ watch( } else { // console.log('render interactive'); if (!vegaContainer.value) return; + vegaVisualization.value?.finalize(); // dispose previous visualization before creating a new one vegaVisualization.value = await createVegaVisualization(vegaContainer.value, spec, props.config, { actions: props.areEmbedActionsVisible, expandable: !!props.expandable @@ -256,6 +258,11 @@ watch( { immediate: true } ); +onUnmounted(() => { + vegaVisualization.value?.finalize(); + vegaVisualizationExpanded.value?.finalize(); +}); + defineExpose({ view, expandedView diff --git a/packages/client/hmi-client/src/components/workflow/ops/calibrate-ensemble-ciemss/calibrate-ensemble-ciemss-operation.ts b/packages/client/hmi-client/src/components/workflow/ops/calibrate-ensemble-ciemss/calibrate-ensemble-ciemss-operation.ts index da6cdeb2c7..7609dcdb64 100644 --- a/packages/client/hmi-client/src/components/workflow/ops/calibrate-ensemble-ciemss/calibrate-ensemble-ciemss-operation.ts +++ b/packages/client/hmi-client/src/components/workflow/ops/calibrate-ensemble-ciemss/calibrate-ensemble-ciemss-operation.ts @@ -40,8 +40,15 @@ export interface CalibrateEnsembleWeights { [key: string]: number; } -export interface CalibrateEnsembleCiemssOperationState extends BaseState { +export interface CalibrateEnsembleCiemssOperationOutputSettingsState { + showLossChart: boolean; chartSettings: ChartSetting[] | null; + showModelWeightsCharts: boolean; +} + +export interface CalibrateEnsembleCiemssOperationState + extends BaseState, + CalibrateEnsembleCiemssOperationOutputSettingsState { ensembleMapping: CalibrateEnsembleMappingRow[]; configurationWeights: CalibrateEnsembleWeights; timestampColName: string; @@ -73,6 +80,8 @@ export const CalibrateEnsembleCiemssOperation: Operation = { initState: () => { const init: CalibrateEnsembleCiemssOperationState = { chartSettings: null, + showLossChart: true, + showModelWeightsCharts: true, ensembleMapping: [], configurationWeights: {}, timestampColName: '', diff --git a/packages/client/hmi-client/src/components/workflow/ops/calibrate-ensemble-ciemss/tera-calibrate-ensemble-ciemss-drilldown.vue b/packages/client/hmi-client/src/components/workflow/ops/calibrate-ensemble-ciemss/tera-calibrate-ensemble-ciemss-drilldown.vue index 57b95127ef..449b49e984 100644 --- a/packages/client/hmi-client/src/components/workflow/ops/calibrate-ensemble-ciemss/tera-calibrate-ensemble-ciemss-drilldown.vue +++ b/packages/client/hmi-client/src/components/workflow/ops/calibrate-ensemble-ciemss/tera-calibrate-ensemble-ciemss-drilldown.vue @@ -190,12 +190,12 @@ @@ -297,6 +321,7 @@ import TeraPyciemssCancelButton from '@/components/pyciemss/tera-pyciemss-cancel import TeraSliderPanel from '@/components/widgets/tera-slider-panel.vue'; import TeraChartSettings from '@/components/widgets/tera-chart-settings.vue'; import TeraChartSettingsPanel from '@/components/widgets/tera-chart-settings-panel.vue'; +import TeraCheckbox from '@/components/widgets/tera-checkbox.vue'; import TeraInputText from '@/components/widgets/tera-input-text.vue'; import TeraSignalBars from '@/components/widgets/tera-signal-bars.vue'; import TeraTimestepCalendar from '@/components/widgets/tera-timestep-calendar.vue'; @@ -554,18 +579,20 @@ const { updateEnsembleVariableSettingOption } = useChartSettings(props, emit); -const { generateAnnotation, getChartAnnotationsByChartId, useEnsembleVariableCharts } = useCharts( - props.node.id, - null, - allModelConfigurations, - computed(() => buildChartData(outputData.value, selectedOutputMapping.value)), - chartSize, - null, - selectedOutputMapping -); +const { generateAnnotation, getChartAnnotationsByChartId, useEnsembleVariableCharts, useWeightsDistributionCharts } = + useCharts( + props.node.id, + null, + allModelConfigurations, + computed(() => buildChartData(outputData.value, selectedOutputMapping.value)), + chartSize, + null, + selectedOutputMapping + ); const ensembleVariables = computed(() => getSelectedOutputEnsembleMapping(props.node, false).map((d) => d.newName)); const ensembleVariableCharts = useEnsembleVariableCharts(selectedEnsembleVariableSettings, groundTruthData); +const weightsDistributionCharts = useWeightsDistributionCharts(); // -------------------------------------------------------- watch( diff --git a/packages/client/hmi-client/src/composables/useCharts.ts b/packages/client/hmi-client/src/composables/useCharts.ts index b12b11d54b..ca13dfeafd 100644 --- a/packages/client/hmi-client/src/composables/useCharts.ts +++ b/packages/client/hmi-client/src/composables/useCharts.ts @@ -13,7 +13,7 @@ import { ForecastChartOptions } from '@/services/charts'; import { flattenInterventionData } from '@/services/intervention-policy'; -import { DataArray, extractModelConfigIds } from '@/services/models/simulation-service'; +import { DataArray, extractModelConfigIdsInOrder, extractModelConfigIds } from '@/services/models/simulation-service'; import { ChartSetting, ChartSettingEnsembleVariable, ChartSettingType } from '@/types/common'; import { Intervention, Model, ModelConfiguration } from '@/types/Types'; import { displayNumber } from '@/utils/number'; @@ -41,20 +41,14 @@ type VariableMappings = CalibrateMap[] | EnsembleVariableMappings; const BASE_GREY = '#AAB3C6'; const PRIMARY_COLOR = CATEGORICAL_SCHEME[0]; -// Get the model configuration id to variable name mappings for the given ensemble variable -const getModelConfigMappings = (mapping: EnsembleVariableMappings, ensembleVariableName: string) => { - const modelConfigMappings = mapping.find((d) => d.newName === ensembleVariableName)?.modelConfigurationMappings; - return modelConfigMappings ?? {}; -}; - // Get the model variable name for the corresponding model configuration and the ensemble variable name from the mapping const getModelConfigVariable = ( mapping: EnsembleVariableMappings, ensembleVariableName: string, modelConfigId: string -) => getModelConfigMappings(mapping, ensembleVariableName)[modelConfigId] ?? ''; +) => mapping.find((d) => d.newName === ensembleVariableName)?.modelConfigurationMappings[modelConfigId] ?? ''; -const getModelConfigIdPrefix = (modelId: string) => (modelId ? `${modelId}/` : ''); +const getModelConfigIdPrefix = (configId: string) => (configId ? `${configId}/` : ''); /** * Converts a model variable name to a dataset variable name based on the provided mapping. @@ -94,6 +88,9 @@ const addModelConfigNameToTranslationMap = ( return newMap; }; +// Consider provided reference object is ready if it is set to null explicitly or if it's value is available +const isRefReady = (ref: Ref | null) => ref === null || Boolean(ref.value); + /** * Composable to manage the creation and configuration of various types of charts used in operator nodes and drilldown. * @@ -115,6 +112,10 @@ export function useCharts( interventions: Ref | null, mapping: Ref | null ) { + // Check if references of the core dependencies are ready to build the chart to prevent multiple re-renders especially + // on initial page load where data are fetched asynchronously and assigned to the references in different times. + const isChartReadyToBuild = computed(() => [model, modelConfig, chartData].every(isRefReady)); + // Setup annotations const { getChartAnnotationsByChartId, generateAndSaveForecastChartAnnotation } = useChartAnnotations(nodeId); @@ -160,7 +161,7 @@ export function useCharts( variables.push( modelConfigId ? // model variable - getModelConfigIdPrefix(modelConfigId ?? '') + + getModelConfigIdPrefix(modelConfigId) + getModelConfigVariable(mapping?.value ?? [], ensembleVarName, modelConfigId) : // ensemble variable (modelVarToDatasetVar(mapping?.value ?? [], ensembleVarName) as string) @@ -239,8 +240,8 @@ export function useCharts( const useInterventionCharts = (chartSettings: ComputedRef, showSamples = false) => { const interventionCharts = computed(() => { const charts: Record = {}; - if (!chartData.value) return charts; - const { resultSummary, result } = chartData.value; + if (!isChartReadyToBuild.value) return charts; + const { resultSummary, result } = chartData.value as ChartData; // intervention chart spec chartSettings.value.forEach((setting) => { const variable = setting.selectedVariables[0]; @@ -279,8 +280,8 @@ export function useCharts( ) => { const variableCharts = computed(() => { const charts: Record = {}; - if (!chartData.value) return charts; - const { result, resultSummary } = chartData.value; + if (!isChartReadyToBuild.value || !isRefReady(groundTruthData)) return charts; + const { result, resultSummary } = chartData.value as ChartData; chartSettings.value.forEach((settings) => { const variable = settings.selectedVariables[0]; @@ -325,8 +326,8 @@ export function useCharts( const useComparisonCharts = (chartSettings: ComputedRef) => { const comparisonCharts = computed(() => { const charts: Record = {}; - if (!chartData.value) return charts; - const { result, resultSummary } = chartData.value; + if (!isChartReadyToBuild.value) return charts; + const { result, resultSummary } = chartData.value as ChartData; chartSettings.value.forEach((setting) => { const selectedVars = setting.selectedVariables; const { statLayerVariables, sampleLayerVariables, options } = createForecastChartOptions(setting); @@ -370,16 +371,13 @@ export function useCharts( ) => { const ensembleVariableCharts = computed(() => { const charts: Record = {}; - if (!chartData.value) return charts; - const { result, resultSummary } = chartData.value; + if (!isChartReadyToBuild.value || !isRefReady(groundTruthData)) return chartData; + const { result, resultSummary } = chartData.value as ChartData; + const modelConfigIds = extractModelConfigIdsInOrder(chartData.value?.pyciemssMap ?? {}); chartSettings.value.forEach((setting) => { const annotations = getChartAnnotationsByChartId(setting.id); const datasetVar = modelVarToDatasetVar(mapping?.value || [], setting.selectedVariables[0]); if (setting.showIndividualModels) { - // Build small multiples charts for each model configuration variable - const modelConfigIds = Object.keys( - getModelConfigMappings(mapping?.value || [], setting.selectedVariables[0]) - ); const smallMultiplesCharts = ['', ...modelConfigIds].map((modelConfigId, index) => { const { sampleLayerVariables, statLayerVariables, options } = createEnsembleVariableChartOptions( setting, @@ -503,13 +501,13 @@ export function useCharts( // Create parameter distribution charts based on chart settings const useParameterDistributionCharts = (chartSettings: ComputedRef) => { const parameterDistributionCharts = computed(() => { - if (!chartData.value) return {}; - const { result, pyciemssMap } = chartData.value; + const charts = {}; + if (!isChartReadyToBuild.value) return charts; + const { result, pyciemssMap } = chartData.value as ChartData; // Note that we want to show the parameter distribution at the first timepoint only const data = result.filter((d) => d.timepoint_id === 0); const labelBefore = 'Before calibration'; const labelAfter = 'After calibration'; - const charts = {}; chartSettings.value.forEach((setting) => { const param = setting.selectedVariables[0]; const fieldName = pyciemssMap[param]; @@ -541,6 +539,52 @@ export function useCharts( return parameterDistributionCharts; }; + const useWeightsDistributionCharts = () => { + const WEIGHT_PARAM_NAME = 'weight_param'; + const weightsCharts = computed(() => { + const charts: VisualizationSpec[] = []; + if (!isChartReadyToBuild.value) return charts; + + // Model configs are used to get the model config metadata. This order of model configs in arrays are not guaranteed to be the same as the order of model configs in the pyciemss results + const modelConfigs = modelConfig?.value ?? []; + // extractModelConfigIdsInOrder ensures that the order of model config IDs are matched with the order of corresponding model index in the pyciemss results + const modelConfigIds = extractModelConfigIdsInOrder(chartData.value?.pyciemssMap ?? {}); + + const data = chartData.value?.result.filter((d) => d.timepoint_id === 0) ?? []; + const labelBefore = 'Before calibration'; + const labelAfter = 'After calibration'; + + const colors = CATEGORICAL_SCHEME.slice(1); // exclude the first color which is for ensemble variable + + modelConfigIds.forEach((configId, index) => { + const modelConfigName = getModelConfigName(modelConfigs, configId); + const chartWidth = chartSize.value.width / modelConfigs.length; + + const fieldName = chartData.value?.pyciemssMap[`${getModelConfigIdPrefix(configId)}${WEIGHT_PARAM_NAME}`] ?? ''; + const beforeFieldName = `${fieldName}:pre`; + + const maxBins = 10; + const barWidth = Math.min((chartWidth - 40) / maxBins, 54); + const spec = createHistogramChart(data, { + title: modelConfigName, + width: chartWidth, + height: chartSize.value.height, + xAxisTitle: `Weights`, + yAxisTitle: 'Count', + maxBins, + variables: [ + { field: beforeFieldName, label: labelBefore, width: barWidth, color: BASE_GREY }, + { field: fieldName, label: labelAfter, width: barWidth / 2, color: colors[index % colors.length] } + ], + legendProperties: { direction: 'vertical', columns: 1, labelLimit: chartWidth } + }); + charts.push(spec); + }); + return charts; + }); + return weightsCharts; + }; + return { generateAnnotation, getChartAnnotationsByChartId, @@ -549,6 +593,7 @@ export function useCharts( useComparisonCharts, useEnsembleVariableCharts, useErrorChart, - useParameterDistributionCharts + useParameterDistributionCharts, + useWeightsDistributionCharts }; } diff --git a/packages/client/hmi-client/src/services/charts.ts b/packages/client/hmi-client/src/services/charts.ts index a7144508ef..e0da825cfa 100644 --- a/packages/client/hmi-client/src/services/charts.ts +++ b/packages/client/hmi-client/src/services/charts.ts @@ -51,6 +51,7 @@ export interface ForecastChartLayer { export interface HistogramChartOptions extends BaseChartOptions { maxBins?: number; variables: { field: string; label?: string; width: number; color: string }[]; + legendProperties?: Record; } export interface ErrorChartOptions extends Omit { @@ -291,7 +292,8 @@ export function createHistogramChart(dataset: Record[], options: Hi symbolStrokeWidth: 4, symbolSize: 200, labelFontSize: 12, - labelOffset: 4 + labelOffset: 4, + ...options.legendProperties }; const spec: VisualizationSpec = { diff --git a/packages/client/hmi-client/src/services/models/simulation-service.ts b/packages/client/hmi-client/src/services/models/simulation-service.ts index 07eaffe28e..f977e281e1 100644 --- a/packages/client/hmi-client/src/services/models/simulation-service.ts +++ b/packages/client/hmi-client/src/services/models/simulation-service.ts @@ -385,6 +385,8 @@ export async function getEnsembleResultModelConfigMap(runId: string) { return resultMap; } +// ========== Ensemble pyciemss map operations ========== + /** * Build pyCiemss map for the ensemble simulation results. * @@ -454,3 +456,32 @@ export function extractModelConfigIds(ensemblePyciemssMap: Record): string[] { + const result: string[] = []; + const modelNumConfigIdMap = extractModelConfigIds(ensemblePyciemssMap); + Object.keys(modelNumConfigIdMap) + // Sort by model index #, e.g. model_0, model_1, model_2 + .sort((a, b) => Number(a.split('_')[1]) - Number(b.split('_')[1])) + .forEach((key) => { + result.push(modelNumConfigIdMap[key]); + }); + return result; +} diff --git a/packages/server/src/main/java/software/uncharted/terarium/hmiserver/service/data/NotebookSessionService.java b/packages/server/src/main/java/software/uncharted/terarium/hmiserver/service/data/NotebookSessionService.java index b5ca2d73ea..f7806ae7a0 100644 --- a/packages/server/src/main/java/software/uncharted/terarium/hmiserver/service/data/NotebookSessionService.java +++ b/packages/server/src/main/java/software/uncharted/terarium/hmiserver/service/data/NotebookSessionService.java @@ -52,7 +52,6 @@ public void copyAssetFiles( throw new UnsupportedOperationException("Unimplemented"); } - @Override public Integer uploadFile(final UUID uuid, final String filename, final ContentType contentType, final byte[] data) throws IOException {