@geotechcli/core 0.4.106 → 0.4.107

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -19,6 +19,8 @@ function isNonEmptyString(value) {
19
19
  }
20
20
  const FEM_WEBGL_UINT16_INDEX_LIMIT = 65_535;
21
21
  const FEM_MAX_PREVIEW_MESH_NODES = FEM_WEBGL_UINT16_INDEX_LIMIT + 1;
22
+ const FEM_MIN_BIOT_TRANSIENT_STEPS = 3;
23
+ const FEM_MAX_BIOT_TIME_STEP_GROWTH_RATIO = 8;
22
24
  function expectedMeshCounts(mesh) {
23
25
  if (mesh.elementType === 'quad4_plane_strain') {
24
26
  return {
@@ -229,6 +231,10 @@ function validateOptionalResultMetadata(findings, manifest, nodeCount, outlineNo
229
231
  ['max_free_pore_pressure_residual', manifest.envelope.maxFreePorePressureResidualM3PerS],
230
232
  ['free_pore_pressure_residual_l1', manifest.envelope.freePorePressureResidualL1M3PerS],
231
233
  ['prescribed_pore_pressure_residual_l1', manifest.envelope.prescribedPorePressureResidualL1M3PerS],
234
+ ['average_pore_pressure', manifest.envelope.averagePorePressureKpa],
235
+ ['average_free_pore_pressure', manifest.envelope.averageFreePorePressureKpa],
236
+ ['pore_pressure_dissipation_ratio', manifest.envelope.porePressureDissipationRatio],
237
+ ['max_pore_pressure_change_rate', manifest.envelope.maxPorePressureChangeRateKpaPerS],
232
238
  ['coupled_unknown_count', manifest.envelope.coupledUnknownCount],
233
239
  ['displacement_dof_count', manifest.envelope.displacementDofCount],
234
240
  ['pore_pressure_dof_count', manifest.envelope.porePressureDofCount],
@@ -796,13 +802,24 @@ export function validateFemAnalysisCase(caseFile) {
796
802
  findings.push(finding('blocker', 'geometry.biot.time-steps-missing', 'Biot consolidation cases require at least one positive time step.'));
797
803
  }
798
804
  else {
805
+ if (biot.timeStepsSeconds.length < FEM_MIN_BIOT_TRANSIENT_STEPS) {
806
+ findings.push(finding('blocker', 'geometry.biot.time-steps.too-few', `Biot consolidation cases require at least ${FEM_MIN_BIOT_TRANSIENT_STEPS} accepted transient steps for preview diagnostics.`));
807
+ }
799
808
  let previousStepSeconds = 0;
809
+ let previousDeltaSeconds;
800
810
  for (const [index, timeSeconds] of biot.timeStepsSeconds.entries()) {
801
811
  if (!Number.isFinite(timeSeconds) || timeSeconds <= previousStepSeconds) {
802
812
  findings.push(finding('blocker', `geometry.biot.time-steps.${index}.invalid`, 'Biot time steps must be finite, positive, and strictly increasing.'));
803
813
  break;
804
814
  }
815
+ const deltaSeconds = timeSeconds - previousStepSeconds;
816
+ if (previousDeltaSeconds != null &&
817
+ deltaSeconds / previousDeltaSeconds > FEM_MAX_BIOT_TIME_STEP_GROWTH_RATIO) {
818
+ findings.push(finding('blocker', `geometry.biot.time-steps.${index}.growth-ratio`, `Biot time-step growth ratio must not exceed ${FEM_MAX_BIOT_TIME_STEP_GROWTH_RATIO} between accepted steps.`));
819
+ break;
820
+ }
805
821
  previousStepSeconds = timeSeconds;
822
+ previousDeltaSeconds = deltaSeconds;
806
823
  }
807
824
  }
808
825
  if (!Array.isArray(biot.porePressureBoundaries) || biot.porePressureBoundaries.length === 0) {
@@ -1120,6 +1137,135 @@ function pushApproximateMatchFinding(findings, actual, expected, code, label, to
1120
1137
  findings.push(finding('blocker', code, `${label} must match the embedded FEM analysis case within ${tolerance}.`));
1121
1138
  }
1122
1139
  }
1140
+ function validateNonlinearSolverConvergenceReport(findings, manifest, expectedLoadSteps) {
1141
+ const fallback = { forceBalanceTolerance: 1e-3, residualTolerance: 1e-6 };
1142
+ const report = manifest.solverConvergence;
1143
+ if (!isRecord(report)) {
1144
+ findings.push(finding('blocker', 'result.solver-convergence.missing', 'Nonlinear column solver manifests must include explicit convergence policy and load-step residual history.'));
1145
+ return fallback;
1146
+ }
1147
+ if (report.schemaVersion !== 'fem-solver-convergence-report.v1') {
1148
+ findings.push(finding('blocker', 'result.solver-convergence.schema-invalid', 'Unsupported solver convergence report schema.'));
1149
+ }
1150
+ if (report.status !== 'converged' && report.status !== 'nonconverged') {
1151
+ findings.push(finding('blocker', 'result.solver-convergence.status-invalid', 'Solver convergence status must be converged or nonconverged.'));
1152
+ }
1153
+ if (report.status === 'nonconverged') {
1154
+ findings.push(finding('blocker', 'result.solver-convergence.nonconverged', 'Nonlinear column solver did not satisfy its configured convergence policy.'));
1155
+ }
1156
+ const policy = report.policy;
1157
+ let forceBalanceTolerance = fallback.forceBalanceTolerance;
1158
+ let residualTolerance = fallback.residualTolerance;
1159
+ if (!isRecord(policy)) {
1160
+ findings.push(finding('blocker', 'result.solver-convergence.policy.missing', 'Solver convergence report must include a policy object.'));
1161
+ }
1162
+ else {
1163
+ if (policy.schemaVersion !== 'fem-convergence-policy.v1') {
1164
+ findings.push(finding('blocker', 'result.solver-convergence.policy.schema-invalid', 'Solver convergence policy schema is unsupported.'));
1165
+ }
1166
+ if (pushFiniteNumberFinding(findings, policy.forceBalanceTolerance, 'result.solver-convergence.policy.force-balance-tolerance', 'Solver force-balance tolerance', { positive: true })) {
1167
+ forceBalanceTolerance = policy.forceBalanceTolerance;
1168
+ }
1169
+ if (pushFiniteNumberFinding(findings, policy.residualTolerance, 'result.solver-convergence.policy.residual-tolerance', 'Solver residual tolerance', { positive: true })) {
1170
+ residualTolerance = policy.residualTolerance;
1171
+ }
1172
+ pushFiniteNumberFinding(findings, policy.porePressureMassBalanceTolerance, 'result.solver-convergence.policy.pore-pressure-mass-balance-tolerance', 'Solver pore-pressure mass-balance tolerance', { positive: true });
1173
+ if (!Number.isInteger(policy.maxIterations) || policy.maxIterations < 1) {
1174
+ findings.push(finding('blocker', 'result.solver-convergence.policy.max-iterations-invalid', 'Solver maxIterations must be a positive integer.'));
1175
+ }
1176
+ if (!Number.isInteger(policy.minAcceptedSteps) || policy.minAcceptedSteps < 1) {
1177
+ findings.push(finding('blocker', 'result.solver-convergence.policy.min-accepted-steps-invalid', 'Solver minAcceptedSteps must be a positive integer.'));
1178
+ }
1179
+ }
1180
+ if (!Array.isArray(report.loadSteps)) {
1181
+ findings.push(finding('blocker', 'result.solver-convergence.load-steps.invalid', 'Solver convergence loadSteps must be an array.'));
1182
+ return { forceBalanceTolerance, residualTolerance };
1183
+ }
1184
+ if (report.loadSteps.length !== expectedLoadSteps) {
1185
+ findings.push(finding('blocker', 'result.solver-convergence.load-steps.count-mismatch', 'Solver convergence load steps must match consolidation stages.'));
1186
+ }
1187
+ const validTerminationReasons = new Set([
1188
+ 'converged',
1189
+ 'max_iterations',
1190
+ 'force_residual_exceeded',
1191
+ 'yield_residual_exceeded',
1192
+ 'material_nonconvergence',
1193
+ 'consolidation_nonconvergence',
1194
+ ]);
1195
+ for (const [index, step] of report.loadSteps.entries()) {
1196
+ const prefix = `result.solver-convergence.loadSteps.${index}`;
1197
+ if (!isRecord(step)) {
1198
+ findings.push(finding('blocker', `${prefix}.shape-invalid`, 'Solver convergence load step must be an object.'));
1199
+ continue;
1200
+ }
1201
+ const stepOk = pushFiniteNumberFinding(findings, step.step, `${prefix}.step`, 'Solver convergence load-step number', { positive: true });
1202
+ if (stepOk && (!Number.isInteger(step.step) || step.step !== index + 1)) {
1203
+ findings.push(finding('blocker', `${prefix}.step.sequence-invalid`, 'Solver convergence load-step numbers must be sequential.'));
1204
+ }
1205
+ if (step.stageId != null && !isNonEmptyString(step.stageId)) {
1206
+ findings.push(finding('blocker', `${prefix}.stage-id.invalid`, 'Solver convergence stageId must be a non-empty string when present.'));
1207
+ }
1208
+ if (!Number.isInteger(step.iterations) || step.iterations < 0) {
1209
+ findings.push(finding('blocker', `${prefix}.iterations.invalid`, 'Solver convergence iterations must be a non-negative integer.'));
1210
+ }
1211
+ const residualOk = pushFiniteNumberFinding(findings, step.residualRatio, `${prefix}.residual-ratio`, 'Solver convergence residual ratio', { nonNegative: true });
1212
+ const forceToleranceOk = pushFiniteNumberFinding(findings, step.forceBalanceTolerance, `${prefix}.force-balance-tolerance`, 'Solver convergence force-balance tolerance', { positive: true });
1213
+ const stepForceTolerance = forceToleranceOk ? step.forceBalanceTolerance : forceBalanceTolerance;
1214
+ if (step.yieldResidualRatio != null) {
1215
+ const yieldResidualOk = pushFiniteNumberFinding(findings, step.yieldResidualRatio, `${prefix}.yield-residual-ratio`, 'Solver convergence yield residual ratio', { nonNegative: true });
1216
+ const stepResidualTolerance = isFiniteNumber(step.residualTolerance) && step.residualTolerance > 0
1217
+ ? step.residualTolerance
1218
+ : residualTolerance;
1219
+ if (yieldResidualOk && step.yieldResidualRatio > stepResidualTolerance) {
1220
+ findings.push(finding('blocker', `${prefix}.yield-residual-too-large`, 'Solver convergence yield residual exceeds the reported tolerance.'));
1221
+ }
1222
+ }
1223
+ if (step.residualTolerance != null) {
1224
+ pushFiniteNumberFinding(findings, step.residualTolerance, `${prefix}.residual-tolerance`, 'Solver convergence residual tolerance', { positive: true });
1225
+ }
1226
+ if (typeof step.converged !== 'boolean') {
1227
+ findings.push(finding('blocker', `${prefix}.converged.invalid`, 'Solver convergence load-step converged must be boolean.'));
1228
+ }
1229
+ else if (!step.converged) {
1230
+ findings.push(finding('blocker', `${prefix}.nonconverged`, 'Solver convergence load step did not satisfy the reported policy.'));
1231
+ }
1232
+ if (typeof step.terminationReason !== 'string' || !validTerminationReasons.has(step.terminationReason)) {
1233
+ findings.push(finding('blocker', `${prefix}.termination-reason.invalid`, 'Solver convergence termination reason is unsupported.'));
1234
+ }
1235
+ if (residualOk && step.residualRatio > stepForceTolerance) {
1236
+ findings.push(finding('blocker', `${prefix}.force-residual-too-large`, 'Solver convergence force residual exceeds the reported tolerance.'));
1237
+ }
1238
+ if (!Array.isArray(step.residualHistory) || step.residualHistory.length === 0) {
1239
+ findings.push(finding('blocker', `${prefix}.residual-history.missing`, 'Solver convergence load step must include non-empty residual history.'));
1240
+ continue;
1241
+ }
1242
+ for (const [historyIndex, history] of step.residualHistory.entries()) {
1243
+ const historyPrefix = `${prefix}.residualHistory.${historyIndex}`;
1244
+ if (!isRecord(history)) {
1245
+ findings.push(finding('blocker', `${historyPrefix}.shape-invalid`, 'Solver convergence residual history entry must be an object.'));
1246
+ continue;
1247
+ }
1248
+ if (!Number.isInteger(history.iteration) || history.iteration < 0) {
1249
+ findings.push(finding('blocker', `${historyPrefix}.iteration.invalid`, 'Solver convergence residual history iteration must be a non-negative integer.'));
1250
+ }
1251
+ pushFiniteNumberFinding(findings, history.residualRatio, `${historyPrefix}.residual-ratio`, 'Solver convergence residual history ratio', { nonNegative: true });
1252
+ pushFiniteNumberFinding(findings, history.forceBalanceTolerance, `${historyPrefix}.force-balance-tolerance`, 'Solver convergence residual history force-balance tolerance', { positive: true });
1253
+ if (history.yieldResidualRatio != null) {
1254
+ pushFiniteNumberFinding(findings, history.yieldResidualRatio, `${historyPrefix}.yield-residual-ratio`, 'Solver convergence residual history yield residual ratio', { nonNegative: true });
1255
+ }
1256
+ if (history.residualTolerance != null) {
1257
+ pushFiniteNumberFinding(findings, history.residualTolerance, `${historyPrefix}.residual-tolerance`, 'Solver convergence residual history residual tolerance', { positive: true });
1258
+ }
1259
+ if (typeof history.converged !== 'boolean') {
1260
+ findings.push(finding('blocker', `${historyPrefix}.converged.invalid`, 'Solver convergence residual history converged must be boolean.'));
1261
+ }
1262
+ }
1263
+ }
1264
+ if (report.status === 'nonconverged' && !isRecord(report.failure)) {
1265
+ findings.push(finding('blocker', 'result.solver-convergence.failure.missing', 'Nonconverged solver reports must include failure details.'));
1266
+ }
1267
+ return { forceBalanceTolerance, residualTolerance };
1268
+ }
1123
1269
  function validateResultEnvelopeSemantics(findings, manifest) {
1124
1270
  const { envelope, analysisCase } = manifest;
1125
1271
  const maxSettlementOk = pushFiniteNumberFinding(findings, envelope.maxSettlementMm, 'result.envelope.max-settlement', 'Envelope max settlement', { nonNegative: true });
@@ -1253,6 +1399,7 @@ function validateResultEnvelopeSemantics(findings, manifest) {
1253
1399
  pushApproximateMatchFinding(findings, envelope.drainagePathM, expectedDrainagePathM, 'result.envelope.consolidation-drainage-path-mismatch', 'Consolidation drainage path', 0.001);
1254
1400
  pushApproximateMatchFinding(findings, envelope.consolidationDurationYears, expectedDurationYears, 'result.envelope.consolidation-duration-mismatch', 'Consolidation duration', 0.001);
1255
1401
  if (manifest.backend.id === 'builtin-nonlinear-column-v0') {
1402
+ const solverTolerances = validateNonlinearSolverConvergenceReport(findings, manifest, consolidation.stages.length);
1256
1403
  const loadStepsOk = pushFiniteNumberFinding(findings, envelope.solverLoadSteps, 'result.envelope.solver-load-steps', 'Envelope solver load steps', { positive: true });
1257
1404
  const iterationsOk = pushFiniteNumberFinding(findings, envelope.solverIterations, 'result.envelope.solver-iterations', 'Envelope solver iterations', { positive: true });
1258
1405
  const solverResidualOk = pushFiniteNumberFinding(findings, envelope.maxSolverResidualRatio, 'result.envelope.max-solver-residual-ratio', 'Envelope max solver residual ratio', { nonNegative: true });
@@ -1264,10 +1411,10 @@ function validateResultEnvelopeSemantics(findings, manifest) {
1264
1411
  if (iterationsOk && !Number.isInteger(envelope.solverIterations)) {
1265
1412
  findings.push(finding('blocker', 'result.envelope.solver-iterations-integer', 'Nonlinear column solver iterations must be an integer.'));
1266
1413
  }
1267
- if (solverResidualOk && envelope.maxSolverResidualRatio > 1e-3) {
1414
+ if (solverResidualOk && envelope.maxSolverResidualRatio > solverTolerances.forceBalanceTolerance) {
1268
1415
  findings.push(finding('blocker', 'result.envelope.solver-residual-too-large', 'Nonlinear column solver residual exceeds the force-balance tolerance.'));
1269
1416
  }
1270
- if (yieldResidualOk && envelope.maxYieldResidualRatio > 1e-6) {
1417
+ if (yieldResidualOk && envelope.maxYieldResidualRatio > solverTolerances.residualTolerance) {
1271
1418
  findings.push(finding('blocker', 'result.envelope.yield-residual-too-large', 'Nonlinear column solver yield residual exceeds the material return-map tolerance.'));
1272
1419
  }
1273
1420
  }
@@ -1286,6 +1433,10 @@ function validateResultEnvelopeSemantics(findings, manifest) {
1286
1433
  pushFiniteNumberFinding(findings, envelope.maxFreePorePressureResidualM3PerS, 'result.envelope.max-free-pore-pressure-residual', 'Envelope maximum free pore-pressure residual', { nonNegative: true });
1287
1434
  pushFiniteNumberFinding(findings, envelope.freePorePressureResidualL1M3PerS, 'result.envelope.free-pore-pressure-residual-l1', 'Envelope free pore-pressure residual L1 norm', { nonNegative: true });
1288
1435
  pushFiniteNumberFinding(findings, envelope.prescribedPorePressureResidualL1M3PerS, 'result.envelope.prescribed-pore-pressure-residual-l1', 'Envelope prescribed pore-pressure residual L1 norm', { nonNegative: true });
1436
+ const averagePorePressureOk = pushFiniteNumberFinding(findings, envelope.averagePorePressureKpa, 'result.envelope.average-pore-pressure', 'Envelope average pore pressure', { nonNegative: true });
1437
+ const averageFreePorePressureOk = pushFiniteNumberFinding(findings, envelope.averageFreePorePressureKpa, 'result.envelope.average-free-pore-pressure', 'Envelope average free pore pressure', { nonNegative: true });
1438
+ const dissipationRatioOk = pushFiniteNumberFinding(findings, envelope.porePressureDissipationRatio, 'result.envelope.pore-pressure-dissipation-ratio', 'Envelope pore-pressure dissipation ratio', { nonNegative: true });
1439
+ pushFiniteNumberFinding(findings, envelope.maxPorePressureChangeRateKpaPerS, 'result.envelope.max-pore-pressure-change-rate', 'Envelope maximum pore-pressure change rate', { nonNegative: true });
1289
1440
  const timeStepCountOk = pushFiniteNumberFinding(findings, envelope.timeStepCount, 'result.envelope.time-step-count', 'Envelope time-step count', { positive: true });
1290
1441
  const coupledUnknownsOk = pushFiniteNumberFinding(findings, envelope.coupledUnknownCount, 'result.envelope.coupled-unknown-count', 'Envelope coupled unknown count', { positive: true });
1291
1442
  const displacementDofsOk = pushFiniteNumberFinding(findings, envelope.displacementDofCount, 'result.envelope.displacement-dof-count', 'Envelope displacement DOF count', { positive: true });
@@ -1296,12 +1447,25 @@ function validateResultEnvelopeSemantics(findings, manifest) {
1296
1447
  if (maxPorePressureOk && envelope.maxPorePressureKpa > expectedMaxPressureKpa + 1e-6) {
1297
1448
  findings.push(finding('blocker', 'result.envelope.pore-pressure-upper-bound-invalid', 'Envelope maximum pore pressure cannot exceed the initial or prescribed boundary pressure envelope.'));
1298
1449
  }
1450
+ if (averagePorePressureOk && minPorePressureOk && maxPorePressureOk && (envelope.averagePorePressureKpa < envelope.minPorePressureKpa - 1e-6 ||
1451
+ envelope.averagePorePressureKpa > envelope.maxPorePressureKpa + 1e-6)) {
1452
+ findings.push(finding('blocker', 'result.envelope.average-pore-pressure-range-invalid', 'Envelope average pore pressure must stay within the reported pore-pressure range.'));
1453
+ }
1454
+ if (averageFreePorePressureOk && maxPorePressureOk && envelope.averageFreePorePressureKpa > envelope.maxPorePressureKpa + 1e-6) {
1455
+ findings.push(finding('blocker', 'result.envelope.average-free-pore-pressure-range-invalid', 'Envelope average free pore pressure cannot exceed the reported maximum pore pressure.'));
1456
+ }
1457
+ if (dissipationRatioOk && envelope.porePressureDissipationRatio > 1) {
1458
+ findings.push(finding('blocker', 'result.envelope.pore-pressure-dissipation-ratio-invalid', 'Envelope pore-pressure dissipation ratio must be between 0 and 1.'));
1459
+ }
1299
1460
  if (maxExcessOk && maxPorePressureOk) {
1300
1461
  pushApproximateMatchFinding(findings, envelope.maxExcessPorePressureKpa, envelope.maxPorePressureKpa, 'result.envelope.max-excess-pore-pressure-mismatch', 'Maximum excess pore pressure', 1e-6);
1301
1462
  }
1302
1463
  if (timeStepCountOk && (!Number.isInteger(envelope.timeStepCount) || envelope.timeStepCount !== biot.timeStepsSeconds.length)) {
1303
1464
  findings.push(finding('blocker', 'result.envelope.time-step-count-mismatch', 'Biot envelope time-step count must match the embedded time-step schedule.'));
1304
1465
  }
1466
+ if (timeStepCountOk && envelope.timeStepCount < FEM_MIN_BIOT_TRANSIENT_STEPS) {
1467
+ findings.push(finding('blocker', 'result.envelope.time-step-count-too-small', 'Biot envelope time-step count is below the preview transient-step policy.'));
1468
+ }
1305
1469
  if (coupledUnknownsOk && !Number.isInteger(envelope.coupledUnknownCount)) {
1306
1470
  findings.push(finding('blocker', 'result.envelope.coupled-unknown-count-integer', 'Coupled unknown count must be an integer.'));
1307
1471
  }