From 8fad0f8730c16416836029f80c0354420b65b0ad Mon Sep 17 00:00:00 2001 From: Kostiantyn Dvornik Date: Tue, 16 Jun 2026 18:29:14 +0300 Subject: [PATCH] fix: reconstruct convergence series from diff-only scopeTrack Accumulate global scope across scopeTrack items so convergence charts work with per-repetition deltas, and add regression tests for diff and legacy data. --- dist/js/Subworkflow.d.ts | 6 +- dist/js/Subworkflow.js | 25 +++-- src/js/Subworkflow.ts | 24 +++-- .../js/Subworkflow.convergenceSeries.test.ts | 100 ++++++++++++++++++ 4 files changed, 133 insertions(+), 22 deletions(-) create mode 100644 tests/js/Subworkflow.convergenceSeries.test.ts diff --git a/dist/js/Subworkflow.d.ts b/dist/js/Subworkflow.d.ts index 3c145062..375955d0 100644 --- a/dist/js/Subworkflow.d.ts +++ b/dist/js/Subworkflow.d.ts @@ -736,14 +736,14 @@ declare class Subworkflow extends InMemoryEntity implements SubworkflowSchema { reason: string; } | undefined; findUnitKeyById(id: string): string; - private findUnitWithTag; + private findAssignmentUnitWithTag; get hasConvergence(): boolean; get convergenceParam(): string | undefined; get convergenceResult(): string | undefined; convergenceSeries(scopeTrack: JobSchema["scopeTrack"]): { x: number; - param: any; - y: any; + param: unknown; + y: unknown; }[]; updateMethodData(materials: Material[], metaProperties: MetaPropertyHolder[]): void; addConvergence({ parameter, parameterInitial, parameterIncrement, result, resultInitial, condition, operator, tolerance, maxOccurrences, externalContext, }: ConvergenceConfig): void; diff --git a/dist/js/Subworkflow.js b/dist/js/Subworkflow.js index 6d2f7d39..3aa92731 100644 --- a/dist/js/Subworkflow.js +++ b/dist/js/Subworkflow.js @@ -186,7 +186,7 @@ class Subworkflow extends entity_1.InMemoryEntity { const index = this.units.findIndex((u) => u.flowchartId === id); return `units.${index}`; } - findUnitWithTag(tag) { + findAssignmentUnitWithTag(tag) { return this.units .filter((unit) => unit.type === enums_1.UnitType.assignment) .find((unit) => { var _a; return (_a = unit.tags) === null || _a === void 0 ? void 0 : _a.includes(tag); }); @@ -196,27 +196,32 @@ class Subworkflow extends entity_1.InMemoryEntity { } get convergenceParam() { var _a; - return (_a = this.findUnitWithTag(enums_1.UnitTag.hasConvergenceParam)) === null || _a === void 0 ? void 0 : _a.operand; + return (_a = this.findAssignmentUnitWithTag(enums_1.UnitTag.hasConvergenceParam)) === null || _a === void 0 ? void 0 : _a.operand; } get convergenceResult() { var _a; - return (_a = this.findUnitWithTag(enums_1.UnitTag.hasConvergenceResult)) === null || _a === void 0 ? void 0 : _a.operand; + return (_a = this.findAssignmentUnitWithTag(enums_1.UnitTag.hasConvergenceResult)) === null || _a === void 0 ? void 0 : _a.operand; } convergenceSeries(scopeTrack) { - if (!this.hasConvergence || !(scopeTrack === null || scopeTrack === void 0 ? void 0 : scopeTrack.length)) { + const { convergenceParam, convergenceResult } = this; + if (!convergenceParam || !convergenceResult || !(scopeTrack === null || scopeTrack === void 0 ? void 0 : scopeTrack.length)) { return []; } let prevResult; + // `scopeTrack` stores per-repetition diffs: each item only carries the global/local keys + // that were added or changed in that repetition (see UnitEndpoint.saveUnitStatus). Accumulate + // the global scope across items so each iteration reads the full scope, not just its delta. + // This also stays correct for legacy full-snapshot scopeTrack data, since re-applying a full + // snapshot is idempotent. + const accumulatedGlobal = {}; return scopeTrack .map((scopeItem, i) => { - var _a, _b; + var _a; + Object.assign(accumulatedGlobal, (_a = scopeItem.scope) === null || _a === void 0 ? void 0 : _a.global); return { x: i, - // TODO: fix types - // @ts-ignore - param: (_a = scopeItem.scope) === null || _a === void 0 ? void 0 : _a.global[this.convergenceParam], - // @ts-ignore - y: (_b = scopeItem.scope) === null || _b === void 0 ? void 0 : _b.global[this.convergenceResult], + param: accumulatedGlobal[convergenceParam], + y: accumulatedGlobal[convergenceResult], }; }) .filter(({ y }) => { diff --git a/src/js/Subworkflow.ts b/src/js/Subworkflow.ts index e6d25b4a..f91cebca 100644 --- a/src/js/Subworkflow.ts +++ b/src/js/Subworkflow.ts @@ -279,7 +279,7 @@ class Subworkflow extends InMemoryEntity implements SubworkflowSchema { return `units.${index}`; } - private findUnitWithTag(tag: UnitTag) { + private findAssignmentUnitWithTag(tag: UnitTag) { return this.units .filter((unit) => unit.type === UnitType.assignment) .find((unit) => unit.tags?.includes(tag)); @@ -290,29 +290,35 @@ class Subworkflow extends InMemoryEntity implements SubworkflowSchema { } get convergenceParam() { - return this.findUnitWithTag(UnitTag.hasConvergenceParam)?.operand; + return this.findAssignmentUnitWithTag(UnitTag.hasConvergenceParam)?.operand; } get convergenceResult() { - return this.findUnitWithTag(UnitTag.hasConvergenceResult)?.operand; + return this.findAssignmentUnitWithTag(UnitTag.hasConvergenceResult)?.operand; } convergenceSeries(scopeTrack: JobSchema["scopeTrack"]) { - if (!this.hasConvergence || !scopeTrack?.length) { + const { convergenceParam, convergenceResult } = this; + + if (!convergenceParam || !convergenceResult || !scopeTrack?.length) { return []; } let prevResult: unknown; + // `scopeTrack` stores per-repetition diffs: each item only carries the global/local keys + // that were added or changed in that repetition (see UnitEndpoint.saveUnitStatus). Accumulate + // the global scope across items so each iteration reads the full scope, not just its delta. + // This also stays correct for legacy full-snapshot scopeTrack data, since re-applying a full + // snapshot is idempotent. + const accumulatedGlobal: Record = {}; return scopeTrack .map((scopeItem, i) => { + Object.assign(accumulatedGlobal, scopeItem.scope?.global); return { x: i, - // TODO: fix types - // @ts-ignore - param: scopeItem.scope?.global[this.convergenceParam], - // @ts-ignore - y: scopeItem.scope?.global[this.convergenceResult], + param: accumulatedGlobal[convergenceParam], + y: accumulatedGlobal[convergenceResult], }; }) .filter(({ y }) => { diff --git a/tests/js/Subworkflow.convergenceSeries.test.ts b/tests/js/Subworkflow.convergenceSeries.test.ts new file mode 100644 index 00000000..1ee898ca --- /dev/null +++ b/tests/js/Subworkflow.convergenceSeries.test.ts @@ -0,0 +1,100 @@ +import JSONSchemasInterface from "@mat3ra/esse/dist/js/esse/JSONSchemasInterface"; +import esseSchemas from "@mat3ra/esse/dist/js/schemas.json"; +import type { JobSchema } from "@mat3ra/esse/dist/js/types"; +import { ApplicationRegistry } from "@mat3ra/standata"; +import StandataDriver from "@mat3ra/standata/dist/js/StandataDriver"; +import { expect } from "chai"; +import type { JSONSchema7 } from "json-schema"; + +import { AssignmentUnit, Subworkflow } from "../../src/js"; +import { UnitTag } from "../../src/js/enums"; + +const CONVERGENCE_PARAM = "N_k"; +const CONVERGENCE_RESULT = "total_energy"; + +function buildConvergenceSubworkflow({ withConvergence = true } = {}) { + const units = withConvergence + ? [ + new AssignmentUnit({ + name: "set_kpoints", + flowchartId: "set-kpoints", + operand: CONVERGENCE_PARAM, + value: "12", + tags: [UnitTag.hasConvergenceParam], + }).toJSON(), + new AssignmentUnit({ + name: "save_energy", + flowchartId: "save-energy", + operand: CONVERGENCE_RESULT, + value: "total_energy", + tags: [UnitTag.hasConvergenceResult], + }).toJSON(), + ] + : []; + return new Subworkflow({ ...Subworkflow.defaultConfig, units }); +} + +function makeScopeItem( + global: Record, +): NonNullable[number] { + return { scope: { global, local: {} } }; +} + +describe("Subworkflow.convergenceSeries", () => { + before(() => { + JSONSchemasInterface.setSchemas(esseSchemas as JSONSchema7[]); + ApplicationRegistry.setDriver(new StandataDriver()); + }); + + it("returns [] when the subworkflow has no convergence units", () => { + const subworkflow = buildConvergenceSubworkflow({ withConvergence: false }); + const scopeTrack = [makeScopeItem({ [CONVERGENCE_PARAM]: 12, [CONVERGENCE_RESULT]: -100 })]; + + expect(subworkflow.convergenceSeries(scopeTrack)).to.deep.equal([]); + }); + + it("returns [] for empty/undefined scopeTrack", () => { + const subworkflow = buildConvergenceSubworkflow(); + + expect(subworkflow.convergenceSeries([])).to.deep.equal([]); + expect(subworkflow.convergenceSeries(undefined)).to.deep.equal([]); + }); + + it("reconstructs the full scope from per-repetition diffs (param carried over from earlier diff)", () => { + const subworkflow = buildConvergenceSubworkflow(); + + // Diff-only scopeTrack: each item carries only keys added/changed in that repetition. + // The last item omits `total_energy` (unchanged) so the param must come from accumulation. + const scopeTrack = [ + makeScopeItem({ [CONVERGENCE_PARAM]: 12, [CONVERGENCE_RESULT]: -100 }), + makeScopeItem({ [CONVERGENCE_PARAM]: 24, [CONVERGENCE_RESULT]: -98 }), + makeScopeItem({ [CONVERGENCE_PARAM]: 36 }), + ]; + + const series = subworkflow.convergenceSeries(scopeTrack); + + // Last repetition is dropped because the (accumulated) result did not change. + expect(series).to.deep.equal([ + { x: 1, param: 12, y: -100 }, + { x: 2, param: 24, y: -98 }, + ]); + }); + + it("matches the diff-based output for legacy full-snapshot scopeTrack (back-compat)", () => { + const subworkflow = buildConvergenceSubworkflow(); + + // Legacy format: every item stores the full cumulative scope. + const scopeTrack = [ + makeScopeItem({ [CONVERGENCE_PARAM]: 12, [CONVERGENCE_RESULT]: -100 }), + makeScopeItem({ [CONVERGENCE_PARAM]: 24, [CONVERGENCE_RESULT]: -98 }), + makeScopeItem({ [CONVERGENCE_PARAM]: 36, [CONVERGENCE_RESULT]: -98 }), + ]; + + const series = subworkflow.convergenceSeries(scopeTrack); + + expect(series).to.deep.equal([ + { x: 1, param: 12, y: -100 }, + { x: 2, param: 24, y: -98 }, + ]); + }); +});