@geotechcli/core 0.4.106 → 0.4.108

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.
Files changed (34) hide show
  1. package/dist/fem/demo.d.ts.map +1 -1
  2. package/dist/fem/demo.js +4 -0
  3. package/dist/fem/demo.js.map +1 -1
  4. package/dist/fem/engineering-evidence.d.ts +65 -0
  5. package/dist/fem/engineering-evidence.d.ts.map +1 -1
  6. package/dist/fem/engineering-evidence.js +295 -15
  7. package/dist/fem/engineering-evidence.js.map +1 -1
  8. package/dist/fem/index.d.ts +3 -2
  9. package/dist/fem/index.d.ts.map +1 -1
  10. package/dist/fem/index.js +2 -1
  11. package/dist/fem/index.js.map +1 -1
  12. package/dist/fem/nonlinear-column-solver.d.ts.map +1 -1
  13. package/dist/fem/nonlinear-column-solver.js +137 -6
  14. package/dist/fem/nonlinear-column-solver.js.map +1 -1
  15. package/dist/fem/plane-strain-assembly.d.ts +69 -3
  16. package/dist/fem/plane-strain-assembly.d.ts.map +1 -1
  17. package/dist/fem/plane-strain-assembly.js +267 -21
  18. package/dist/fem/plane-strain-assembly.js.map +1 -1
  19. package/dist/fem/production-readiness.d.ts.map +1 -1
  20. package/dist/fem/production-readiness.js +8 -2
  21. package/dist/fem/production-readiness.js.map +1 -1
  22. package/dist/fem/routing.js +4 -4
  23. package/dist/fem/routing.js.map +1 -1
  24. package/dist/fem/sparse-linear-algebra.d.ts +47 -0
  25. package/dist/fem/sparse-linear-algebra.d.ts.map +1 -0
  26. package/dist/fem/sparse-linear-algebra.js +290 -0
  27. package/dist/fem/sparse-linear-algebra.js.map +1 -0
  28. package/dist/fem/types.d.ts +49 -0
  29. package/dist/fem/types.d.ts.map +1 -1
  30. package/dist/fem/validation.d.ts.map +1 -1
  31. package/dist/fem/validation.js +166 -2
  32. package/dist/fem/validation.js.map +1 -1
  33. package/dist/meta/metadata.json +1 -1
  34. package/package.json +1 -1
@@ -1,4 +1,5 @@
1
1
  import { DEFAULT_FEM_CONVERGENCE_POLICY } from './engineering-evidence.js';
2
+ import { buildCsrFromTriplets, solveCsrConjugateGradient, } from './sparse-linear-algebra.js';
2
3
  const GAUSS_POINTS = [
3
4
  [-1 / Math.sqrt(3), -1 / Math.sqrt(3), 1],
4
5
  [1 / Math.sqrt(3), -1 / Math.sqrt(3), 1],
@@ -6,6 +7,8 @@ const GAUSS_POINTS = [
6
7
  [-1 / Math.sqrt(3), 1 / Math.sqrt(3), 1],
7
8
  ];
8
9
  const MAX_DENSE_DOF_COUNT = 800;
10
+ const MAX_SPARSE_EXPERIMENTAL_DOF_COUNT = 5_000;
11
+ const MAX_BIOT_TIME_STEP_GROWTH_RATIO = 8;
9
12
  function assertFinite(value, label) {
10
13
  if (!Number.isFinite(value))
11
14
  throw new Error(`${label} must be finite.`);
@@ -62,6 +65,26 @@ function validateConvergencePolicy(policy) {
62
65
  throw new Error('policy.minAcceptedSteps must be less than or equal to policy.maxIterations.');
63
66
  }
64
67
  }
68
+ function validateBiotTransientStepPolicy(timeStepsSeconds, policy) {
69
+ if (timeStepsSeconds.length < policy.minAcceptedSteps) {
70
+ throw new Error(`timeStepsSeconds must include at least ${policy.minAcceptedSteps} accepted transient steps for the Biot consolidation preview.`);
71
+ }
72
+ let previousTimeSeconds = 0;
73
+ let previousDeltaTimeSeconds;
74
+ for (const [index, timeSeconds] of timeStepsSeconds.entries()) {
75
+ assertFinitePositive(timeSeconds, `timeStepsSeconds.${index}`);
76
+ if (timeSeconds <= previousTimeSeconds) {
77
+ throw new Error(`timeStepsSeconds.${index} must be strictly increasing.`);
78
+ }
79
+ const deltaTimeSeconds = timeSeconds - previousTimeSeconds;
80
+ if (previousDeltaTimeSeconds != null &&
81
+ deltaTimeSeconds / previousDeltaTimeSeconds > MAX_BIOT_TIME_STEP_GROWTH_RATIO) {
82
+ throw new Error(`timeStepsSeconds.${index} step growth ratio must not exceed ${MAX_BIOT_TIME_STEP_GROWTH_RATIO} for the Biot consolidation preview.`);
83
+ }
84
+ previousTimeSeconds = timeSeconds;
85
+ previousDeltaTimeSeconds = deltaTimeSeconds;
86
+ }
87
+ }
65
88
  function round(value, digits = 10) {
66
89
  const factor = 10 ** digits;
67
90
  return Math.round(value * factor) / factor;
@@ -263,6 +286,9 @@ function matVec(matrix, vector) {
263
286
  function dot(a, b) {
264
287
  return a.reduce((sum, value, index) => sum + value * b[index], 0);
265
288
  }
289
+ function vectorNorm(values) {
290
+ return Math.sqrt(values.reduce((sum, value) => sum + value * value, 0));
291
+ }
266
292
  function elementMatrices(input) {
267
293
  const { nodes, material, thicknessM } = input;
268
294
  const d = planeStrainD(material);
@@ -654,6 +680,7 @@ export function runPlaneStrainBiotConsolidation(model) {
654
680
  assertUniqueIds(model.elements, 'element');
655
681
  const policy = model.policy ?? DEFAULT_FEM_CONVERGENCE_POLICY;
656
682
  validateConvergencePolicy(policy);
683
+ validateBiotTransientStepPolicy(model.timeStepsSeconds, policy);
657
684
  const nodeIndexById = new Map(model.nodes.map((node, index) => [node.id, index]));
658
685
  const materialById = new Map(model.materials.map((material) => [material.id, material]));
659
686
  const displacementDofCount = model.nodes.length * 2;
@@ -661,14 +688,6 @@ export function runPlaneStrainBiotConsolidation(model) {
661
688
  if (displacementDofCount + porePressureDofCount > MAX_DENSE_DOF_COUNT) {
662
689
  throw new Error(`Plane-strain Biot consolidation dense assembly is capped at ${MAX_DENSE_DOF_COUNT} coupled DOFs for benchmark-scale evidence runs.`);
663
690
  }
664
- let previousTimeSeconds = 0;
665
- for (const [index, timeSeconds] of model.timeStepsSeconds.entries()) {
666
- assertFinitePositive(timeSeconds, `timeStepsSeconds.${index}`);
667
- if (timeSeconds <= previousTimeSeconds) {
668
- throw new Error(`timeStepsSeconds.${index} must be strictly increasing.`);
669
- }
670
- previousTimeSeconds = timeSeconds;
671
- }
672
691
  for (const node of model.nodes) {
673
692
  assertFinite(node.xM, `node ${node.id} xM`);
674
693
  assertFinite(node.yM, `node ${node.id} yM`);
@@ -703,6 +722,9 @@ export function runPlaneStrainBiotConsolidation(model) {
703
722
  throw new Error(`Unknown Biot nodal flux node: ${flux.nodeId}.`);
704
723
  const flowM3PerS = flux.flowM3PerS ?? 0;
705
724
  assertFinite(flowM3PerS, `nodal flux ${flux.nodeId}.flowM3PerS`);
725
+ if (flowM3PerS < 0) {
726
+ throw new Error(`nodal flux ${flux.nodeId}.flowM3PerS must be non-negative; extraction-driven suction is unsupported by this saturated excess-pressure Biot evidence kernel.`);
727
+ }
706
728
  fluxes[nodeIndex] += flowM3PerS;
707
729
  }
708
730
  const elementGaussCache = [];
@@ -828,6 +850,11 @@ export function runPlaneStrainBiotConsolidation(model) {
828
850
  displacement[index] = value;
829
851
  for (const [index, value] of prescribedPressures)
830
852
  porePressure[index] = value;
853
+ const pressureUpperBoundKpa = Math.max(initialPorePressureKpa, ...Array.from(prescribedPressures.values()));
854
+ const averagePressure = (dofs) => dofs.length > 0
855
+ ? dofs.reduce((sum, index) => sum + porePressure[index], 0) / dofs.length
856
+ : 0;
857
+ const initialAverageFreePorePressureKpa = averagePressure(freePressureDofs);
831
858
  let previousDisplacement = [...displacement];
832
859
  let previousPorePressure = [...porePressure];
833
860
  let lastMechanicalResidual = new Array(displacementDofCount).fill(0);
@@ -846,6 +873,14 @@ export function runPlaneStrainBiotConsolidation(model) {
846
873
  couplingRateSumM3PerS: 0,
847
874
  darcyFlowRateSumM3PerS: 0,
848
875
  };
876
+ let lastPressureDiagnostics = {
877
+ averagePorePressureKpa: round(porePressure.reduce((sum, value) => sum + value, 0) / porePressure.length, 8),
878
+ averageFreePorePressureKpa: round(initialAverageFreePorePressureKpa, 8),
879
+ porePressureDissipationRatio: 0,
880
+ maxPorePressureChangeKpa: 0,
881
+ maxPorePressureChangeRateKpaPerS: 0,
882
+ pressureOvershootKpa: 0,
883
+ };
849
884
  let lastMinPorePressureKpa = Math.min(...porePressure);
850
885
  let lastMaxPorePressureKpa = Math.max(...porePressure);
851
886
  const timeSteps = [];
@@ -966,6 +1001,23 @@ export function runPlaneStrainBiotConsolidation(model) {
966
1001
  const massBalanceErrorRatio = freePressureResidualSum / pressureScale;
967
1002
  const minPorePressureKpa = Math.min(...porePressure);
968
1003
  const maxPorePressureKpa = Math.max(...porePressure);
1004
+ const pressureOvershootKpa = Math.max(0, maxPorePressureKpa - pressureUpperBoundKpa);
1005
+ if (pressureOvershootKpa > 1e-6) {
1006
+ throw new Error(`Plane-strain Biot step ${stepIndex + 1} pore pressure exceeded the initial/prescribed pressure envelope by ${pressureOvershootKpa} kPa.`);
1007
+ }
1008
+ const averagePorePressureKpa = porePressure.reduce((sum, value) => sum + value, 0) / porePressure.length;
1009
+ const averageFreePorePressureKpa = averagePressure(freePressureDofs);
1010
+ const dissipationReferenceKpa = Math.max(initialAverageFreePorePressureKpa, 1e-12);
1011
+ const porePressureDissipationRatio = Math.min(1, Math.max(0, (initialAverageFreePorePressureKpa - averageFreePorePressureKpa) / dissipationReferenceKpa));
1012
+ const maxPorePressureChangeKpa = Math.max(...porePressure.map((value, index) => Math.abs(value - previousPorePressure[index])));
1013
+ const pressureDiagnostics = {
1014
+ averagePorePressureKpa: round(averagePorePressureKpa, 8),
1015
+ averageFreePorePressureKpa: round(averageFreePorePressureKpa, 8),
1016
+ porePressureDissipationRatio: round(porePressureDissipationRatio, 12),
1017
+ maxPorePressureChangeKpa: round(maxPorePressureChangeKpa, 8),
1018
+ maxPorePressureChangeRateKpaPerS: Number((maxPorePressureChangeKpa / deltaTimeSeconds).toExponential(12)),
1019
+ pressureOvershootKpa: round(pressureOvershootKpa, 8),
1020
+ };
969
1021
  const maxVerticalSettlementM = Math.max(0, -Math.min(...model.nodes.map((_, index) => displacement[dofIndex(index, 'uy')])));
970
1022
  const converged = residualNormRatio <= policy.forceBalanceTolerance &&
971
1023
  massBalanceErrorRatio <= policy.porePressureMassBalanceTolerance;
@@ -979,6 +1031,7 @@ export function runPlaneStrainBiotConsolidation(model) {
979
1031
  freePorePressureResidualL1M3PerS: Number(freePressureResidualSum.toExponential(12)),
980
1032
  massBalanceErrorRatio: round(massBalanceErrorRatio, 12),
981
1033
  pressureAudit,
1034
+ pressureDiagnostics,
982
1035
  minPorePressureKpa: round(minPorePressureKpa, 8),
983
1036
  maxPorePressureKpa: round(maxPorePressureKpa, 8),
984
1037
  maxVerticalSettlementM: round(maxVerticalSettlementM, 12),
@@ -994,6 +1047,7 @@ export function runPlaneStrainBiotConsolidation(model) {
994
1047
  lastMaxFreePorePressureResidualM3PerS = maxFreePorePressureResidualM3PerS;
995
1048
  lastFreePorePressureResidualL1M3PerS = freePressureResidualSum;
996
1049
  lastPressureAudit = pressureAudit;
1050
+ lastPressureDiagnostics = pressureDiagnostics;
997
1051
  lastMinPorePressureKpa = minPorePressureKpa;
998
1052
  lastMaxPorePressureKpa = maxPorePressureKpa;
999
1053
  }
@@ -1063,6 +1117,8 @@ export function runPlaneStrainBiotConsolidation(model) {
1063
1117
  totalStressRelation: 'sigma_total_xx_yy = sigma_effective_xx_yy - alpha_B * p; shear unchanged',
1064
1118
  darcyFluxRelation: 'q = -k/gamma_water * grad(p)',
1065
1119
  storageConvention: 'specificStorage1PerM is head-based; pressure storage uses Ss / gamma_water',
1120
+ transientStepPolicy: 'fixed backward-Euler grid requires minAcceptedSteps and bounded step-growth ratio',
1121
+ maxTimeStepGrowthRatio: MAX_BIOT_TIME_STEP_GROWTH_RATIO,
1066
1122
  gammaWaterKpaPerM: round(gammaWaterKpaPerM, 8),
1067
1123
  },
1068
1124
  nodes: model.nodes.map((node, index) => ({
@@ -1089,6 +1145,7 @@ export function runPlaneStrainBiotConsolidation(model) {
1089
1145
  maxFreePorePressureResidualM3PerS: Number(lastMaxFreePorePressureResidualM3PerS.toExponential(12)),
1090
1146
  freePorePressureResidualL1M3PerS: Number(lastFreePorePressureResidualL1M3PerS.toExponential(12)),
1091
1147
  pressureAudit: lastPressureAudit,
1148
+ pressureDiagnostics: lastPressureDiagnostics,
1092
1149
  massBalanceErrorRatio: round(lastMassBalanceErrorRatio, 12),
1093
1150
  minPorePressureKpa: round(lastMinPorePressureKpa, 8),
1094
1151
  maxPorePressureKpa: round(lastMaxPorePressureKpa, 8),
@@ -1292,7 +1349,7 @@ export function runPlaneStrainQuad4Assembly(model) {
1292
1349
  policy,
1293
1350
  };
1294
1351
  }
1295
- function assemblePlaneStrainSystem(model) {
1352
+ function assemblePlaneStrainSystem(model, options = {}) {
1296
1353
  if (model.schemaVersion !== 'fem-plane-strain-model.v1') {
1297
1354
  throw new Error('Only fem-plane-strain-model.v1 is supported.');
1298
1355
  }
@@ -1318,10 +1375,17 @@ function assemblePlaneStrainSystem(model) {
1318
1375
  const nodeIndexById = new Map(model.nodes.map((node, index) => [node.id, index]));
1319
1376
  const materialById = new Map(model.materials.map((material) => [material.id, material]));
1320
1377
  const dofCount = model.nodes.length * 2;
1321
- if (dofCount > MAX_DENSE_DOF_COUNT) {
1378
+ const storage = options.storage ?? 'dense-and-triplets';
1379
+ if (storage === 'dense-and-triplets' && dofCount > MAX_DENSE_DOF_COUNT) {
1322
1380
  throw new Error(`Plane-strain dense assembly is capped at ${MAX_DENSE_DOF_COUNT} DOFs for benchmark-scale evidence runs.`);
1323
1381
  }
1324
- const stiffness = Array.from({ length: dofCount }, () => new Array(dofCount).fill(0));
1382
+ if (storage === 'triplets-only' && dofCount > MAX_SPARSE_EXPERIMENTAL_DOF_COUNT) {
1383
+ throw new Error(`Plane-strain sparse experimental assembly is capped at ${MAX_SPARSE_EXPERIMENTAL_DOF_COUNT} DOFs until production solver benchmarks are approved.`);
1384
+ }
1385
+ const stiffness = storage === 'dense-and-triplets'
1386
+ ? Array.from({ length: dofCount }, () => new Array(dofCount).fill(0))
1387
+ : undefined;
1388
+ const stiffnessTriplets = [];
1325
1389
  const loads = new Array(dofCount).fill(0);
1326
1390
  for (const node of model.nodes) {
1327
1391
  assertFinite(node.xM, `node ${node.id} xM`);
@@ -1370,7 +1434,12 @@ function assemblePlaneStrainSystem(model) {
1370
1434
  const globalDofs = nodeIndices.flatMap((index) => [dofIndex(index, 'ux'), dofIndex(index, 'uy')]);
1371
1435
  for (let localRow = 0; localRow < 8; localRow += 1) {
1372
1436
  for (let localCol = 0; localCol < 8; localCol += 1) {
1373
- stiffness[globalDofs[localRow]][globalDofs[localCol]] += elementData.stiffness[localRow][localCol];
1437
+ const row = globalDofs[localRow];
1438
+ const col = globalDofs[localCol];
1439
+ const value = elementData.stiffness[localRow][localCol];
1440
+ stiffnessTriplets.push({ row, col, value });
1441
+ if (stiffness)
1442
+ stiffness[row][col] += value;
1374
1443
  }
1375
1444
  }
1376
1445
  elementGaussCache.push({
@@ -1411,6 +1480,7 @@ function assemblePlaneStrainSystem(model) {
1411
1480
  policy,
1412
1481
  materialById,
1413
1482
  stiffness,
1483
+ stiffnessTriplets,
1414
1484
  loads,
1415
1485
  prescribed,
1416
1486
  freeDofs,
@@ -1528,10 +1598,131 @@ function normalizeLoadStepFractions(loadStepFractions) {
1528
1598
  }
1529
1599
  return fractions;
1530
1600
  }
1601
+ function isDruckerPragerStepConverged(evaluation, policy) {
1602
+ return evaluation.residualNormRatio <= policy.forceBalanceTolerance &&
1603
+ evaluation.maxYieldResidualRatio <= policy.residualTolerance;
1604
+ }
1605
+ function druckerPragerResidualHistoryEntry(iteration, evaluation, policy) {
1606
+ return {
1607
+ iteration,
1608
+ maxFreeResidualKn: round(evaluation.maxFreeResidualKn, 12),
1609
+ residualNormRatio: round(evaluation.residualNormRatio, 12),
1610
+ forceBalanceTolerance: policy.forceBalanceTolerance,
1611
+ reactionBalanceRatio: round(evaluation.reactionBalanceRatio, 12),
1612
+ maxYieldResidualRatio: round(evaluation.maxYieldResidualRatio, 12),
1613
+ yieldResidualTolerance: policy.residualTolerance,
1614
+ converged: isDruckerPragerStepConverged(evaluation, policy),
1615
+ };
1616
+ }
1617
+ function druckerPragerTerminationReason(evaluation, policy, converged, iterations, linearSolverFailure) {
1618
+ if (converged)
1619
+ return 'converged';
1620
+ if (linearSolverFailure)
1621
+ return 'linear_solver_nonconverged';
1622
+ if (iterations >= policy.maxIterations)
1623
+ return 'max_iterations';
1624
+ if (evaluation.residualNormRatio > policy.forceBalanceTolerance)
1625
+ return 'force_residual_exceeded';
1626
+ return 'yield_residual_exceeded';
1627
+ }
1628
+ function normalizeLinearSolverKind(solver) {
1629
+ const resolved = solver ?? 'dense-gaussian';
1630
+ if (resolved !== 'dense-gaussian' && resolved !== 'sparse-csr-cg') {
1631
+ throw new Error('linearSolver must be dense-gaussian or sparse-csr-cg.');
1632
+ }
1633
+ return resolved;
1634
+ }
1635
+ function denseNonzeroCount(matrix) {
1636
+ return matrix.reduce((count, row) => count + row.filter((value) => Math.abs(value) > 0).length, 0);
1637
+ }
1638
+ function buildReducedCsr(input) {
1639
+ const freeIndexByDof = new Map(input.freeDofs.map((dof, index) => [dof, index]));
1640
+ const reducedTriplets = input.triplets.flatMap((entry) => {
1641
+ const row = freeIndexByDof.get(entry.row);
1642
+ const col = freeIndexByDof.get(entry.col);
1643
+ return row != null && col != null ? [{ row, col, value: entry.value }] : [];
1644
+ });
1645
+ return buildCsrFromTriplets({
1646
+ rowCount: input.freeDofs.length,
1647
+ colCount: input.freeDofs.length,
1648
+ triplets: reducedTriplets,
1649
+ dropTolerance: 0,
1650
+ });
1651
+ }
1652
+ function solveDenseDruckerPragerCorrection(input) {
1653
+ const correction = solveDenseLinearSystem(input.reducedK, [...input.rhs]);
1654
+ const solvedRhs = matVec(input.reducedK, correction);
1655
+ const residual = solvedRhs.map((value, index) => value - input.rhs[index]);
1656
+ const rhsNorm = Math.max(vectorNorm(input.rhs), 1);
1657
+ const finalResidualNorm = vectorNorm(residual);
1658
+ const correctionNorm = vectorNorm(correction);
1659
+ return {
1660
+ correction,
1661
+ audit: {
1662
+ schemaVersion: 'fem-plane-strain-linear-solver-audit.v1',
1663
+ solver: 'dense-gaussian',
1664
+ matrixDofCount: input.rhs.length,
1665
+ nonzeroCount: denseNonzeroCount(input.reducedK),
1666
+ iterations: input.rhs.length,
1667
+ tolerance: input.tolerance,
1668
+ maxIterations: input.maxIterations,
1669
+ initialResidualNorm: vectorNorm(input.rhs),
1670
+ finalResidualNorm,
1671
+ residualNormRatio: finalResidualNorm / rhsNorm,
1672
+ correctionNormM: correctionNorm,
1673
+ correctionNormRatio: correctionNorm / Math.max(vectorNorm(input.currentFreeDisplacement), 1e-12),
1674
+ converged: true,
1675
+ },
1676
+ };
1677
+ }
1678
+ function solveSparseDruckerPragerCorrection(input) {
1679
+ const solved = solveCsrConjugateGradient(input.reducedK, input.rhs, {
1680
+ tolerance: input.tolerance,
1681
+ maxIterations: input.maxIterations,
1682
+ preconditioner: 'jacobi',
1683
+ });
1684
+ const correctionNorm = vectorNorm(solved.solution);
1685
+ return {
1686
+ correction: solved.solution,
1687
+ audit: {
1688
+ schemaVersion: 'fem-plane-strain-linear-solver-audit.v1',
1689
+ solver: 'sparse-csr-cg',
1690
+ matrixDofCount: input.rhs.length,
1691
+ nonzeroCount: input.reducedK.nonzeroCount,
1692
+ iterations: solved.iterations,
1693
+ tolerance: solved.tolerance,
1694
+ maxIterations: solved.maxIterations,
1695
+ initialResidualNorm: solved.initialResidualNorm,
1696
+ finalResidualNorm: solved.finalResidualNorm,
1697
+ residualNormRatio: solved.residualNormRatio,
1698
+ correctionNormM: correctionNorm,
1699
+ correctionNormRatio: correctionNorm / Math.max(vectorNorm(input.currentFreeDisplacement), 1e-12),
1700
+ converged: solved.converged,
1701
+ ...(solved.failureReason ? { failureReason: solved.failureReason } : {}),
1702
+ },
1703
+ };
1704
+ }
1531
1705
  export function runPlaneStrainDruckerPragerLoadSteps(model, options = {}) {
1532
- const system = assemblePlaneStrainSystem(model);
1706
+ const linearSolver = normalizeLinearSolverKind(options.linearSolver);
1707
+ const system = assemblePlaneStrainSystem(model, {
1708
+ storage: linearSolver === 'sparse-csr-cg' ? 'triplets-only' : 'dense-and-triplets',
1709
+ });
1533
1710
  const loadStepFractions = normalizeLoadStepFractions(options.loadStepFractions);
1534
- const reducedK = system.freeDofs.map((row) => system.freeDofs.map((col) => system.stiffness[row][col]));
1711
+ const linearSolverTolerance = options.linearSolverTolerance ?? Math.min(1e-10, system.policy.forceBalanceTolerance / 10);
1712
+ if (!Number.isFinite(linearSolverTolerance) || linearSolverTolerance <= 0) {
1713
+ throw new Error('linearSolverTolerance must be a finite positive number.');
1714
+ }
1715
+ const linearSolverMaxIterations = options.linearSolverMaxIterations ?? Math.max(100, system.freeDofs.length * 10);
1716
+ assertPositiveInteger(linearSolverMaxIterations, 'linearSolverMaxIterations');
1717
+ const reducedDenseK = linearSolver === 'dense-gaussian'
1718
+ ? system.freeDofs.map((row) => system.freeDofs.map((col) => system.stiffness[row][col]))
1719
+ : undefined;
1720
+ const reducedSparseK = linearSolver === 'sparse-csr-cg' && system.freeDofs.length > 0
1721
+ ? buildReducedCsr({
1722
+ freeDofs: system.freeDofs,
1723
+ triplets: system.stiffnessTriplets,
1724
+ })
1725
+ : undefined;
1535
1726
  const displacement = new Array(system.dofCount).fill(0);
1536
1727
  let finalEvaluation;
1537
1728
  const loadSteps = [];
@@ -1540,24 +1731,51 @@ export function runPlaneStrainDruckerPragerLoadSteps(model, options = {}) {
1540
1731
  displacement[index] = value * loadFactor;
1541
1732
  let evaluation = evaluatePlaneStrainDruckerPragerState({ system, displacement, loadFactor });
1542
1733
  let iterations = 0;
1543
- let converged = evaluation.residualNormRatio <= system.policy.forceBalanceTolerance &&
1544
- evaluation.maxYieldResidualRatio <= system.policy.residualTolerance;
1734
+ let converged = isDruckerPragerStepConverged(evaluation, system.policy);
1735
+ const residualHistory = [
1736
+ druckerPragerResidualHistoryEntry(iterations, evaluation, system.policy),
1737
+ ];
1738
+ const linearSolverAudits = [];
1739
+ let linearSolverFailure;
1545
1740
  while (!converged && iterations < system.policy.maxIterations) {
1546
1741
  iterations += 1;
1547
1742
  if (system.freeDofs.length === 0)
1548
1743
  break;
1549
1744
  const correctionRhs = system.freeDofs.map((index) => -evaluation.residual[index]);
1550
- const correction = solveDenseLinearSystem(reducedK, correctionRhs);
1745
+ const currentFreeDisplacement = system.freeDofs.map((index) => displacement[index]);
1746
+ const solved = linearSolver === 'sparse-csr-cg'
1747
+ ? solveSparseDruckerPragerCorrection({
1748
+ reducedK: reducedSparseK,
1749
+ rhs: correctionRhs,
1750
+ currentFreeDisplacement,
1751
+ tolerance: linearSolverTolerance,
1752
+ maxIterations: linearSolverMaxIterations,
1753
+ })
1754
+ : solveDenseDruckerPragerCorrection({
1755
+ reducedK: reducedDenseK,
1756
+ rhs: correctionRhs,
1757
+ currentFreeDisplacement,
1758
+ tolerance: linearSolverTolerance,
1759
+ maxIterations: linearSolverMaxIterations,
1760
+ });
1761
+ linearSolverAudits.push(solved.audit);
1762
+ if (!solved.audit.converged) {
1763
+ linearSolverFailure = solved.audit;
1764
+ break;
1765
+ }
1766
+ const correction = solved.correction;
1551
1767
  for (const [correctionIndex, dof] of system.freeDofs.entries()) {
1552
1768
  displacement[dof] += correction[correctionIndex];
1553
1769
  }
1554
1770
  for (const [index, value] of system.prescribed)
1555
1771
  displacement[index] = value * loadFactor;
1556
1772
  evaluation = evaluatePlaneStrainDruckerPragerState({ system, displacement, loadFactor });
1557
- converged = evaluation.residualNormRatio <= system.policy.forceBalanceTolerance &&
1558
- evaluation.maxYieldResidualRatio <= system.policy.residualTolerance;
1773
+ converged = isDruckerPragerStepConverged(evaluation, system.policy);
1774
+ residualHistory.push(druckerPragerResidualHistoryEntry(iterations, evaluation, system.policy));
1559
1775
  }
1560
1776
  finalEvaluation = evaluation;
1777
+ const terminationReason = druckerPragerTerminationReason(evaluation, system.policy, converged, iterations, linearSolverFailure);
1778
+ const lastLinearAudit = linearSolverAudits.at(-1);
1561
1779
  loadSteps.push({
1562
1780
  step: stepIndex + 1,
1563
1781
  loadFactor: round(loadFactor, 8),
@@ -1568,12 +1786,22 @@ export function runPlaneStrainDruckerPragerLoadSteps(model, options = {}) {
1568
1786
  maxYieldResidualRatio: round(evaluation.maxYieldResidualRatio, 12),
1569
1787
  maxEquivalentPlasticStrain: round(evaluation.maxEquivalentPlasticStrain, 12),
1570
1788
  plasticGaussPointCount: evaluation.plasticGaussPointCount,
1789
+ linearSolver,
1790
+ linearIterations: linearSolverAudits.reduce((sum, audit) => sum + audit.iterations, 0),
1791
+ linearResidualNormRatio: round(lastLinearAudit?.residualNormRatio ?? 0, 12),
1792
+ correctionNormRatio: round(lastLinearAudit?.correctionNormRatio ?? 0, 12),
1793
+ linearSolverAudits,
1571
1794
  converged,
1795
+ terminationReason,
1796
+ ...(linearSolverFailure?.failureReason ? { failureReason: linearSolverFailure.failureReason } : {}),
1797
+ residualHistory,
1572
1798
  });
1573
1799
  }
1574
1800
  if (!finalEvaluation) {
1575
1801
  throw new Error('Plane-strain nonlinear load-step solver requires at least one load step.');
1576
1802
  }
1803
+ const failedStep = loadSteps.find((step) => !step.converged);
1804
+ const status = failedStep ? 'nonconverged' : 'converged';
1577
1805
  return {
1578
1806
  schemaVersion: 'fem-plane-strain-drucker-prager-result.v1',
1579
1807
  method: 'quad4-plane-strain-drucker-prager-modified-newton',
@@ -1588,6 +1816,10 @@ export function runPlaneStrainDruckerPragerLoadSteps(model, options = {}) {
1588
1816
  dofCount: system.dofCount,
1589
1817
  freeDofCount: system.freeDofs.length,
1590
1818
  constrainedDofCount: system.prescribed.size,
1819
+ linearSolver,
1820
+ nonlinearAlgorithm: 'modified-newton',
1821
+ globalTangent: 'elastic',
1822
+ materialIntegration: 'total-strain-drucker-prager-projection',
1591
1823
  loadSteps,
1592
1824
  maxFreeResidualKn: round(finalEvaluation.maxFreeResidualKn, 12),
1593
1825
  residualNormRatio: round(finalEvaluation.residualNormRatio, 12),
@@ -1595,11 +1827,25 @@ export function runPlaneStrainDruckerPragerLoadSteps(model, options = {}) {
1595
1827
  maxYieldResidualRatio: round(finalEvaluation.maxYieldResidualRatio, 12),
1596
1828
  maxEquivalentPlasticStrain: round(finalEvaluation.maxEquivalentPlasticStrain, 12),
1597
1829
  plasticGaussPointCount: finalEvaluation.plasticGaussPointCount,
1598
- converged: loadSteps.every((step) => step.converged),
1830
+ converged: status === 'converged',
1831
+ status,
1832
+ ...(failedStep ? {
1833
+ failure: {
1834
+ step: failedStep.step,
1835
+ loadFactor: failedStep.loadFactor,
1836
+ terminationReason: failedStep.terminationReason,
1837
+ residualNormRatio: failedStep.residualNormRatio,
1838
+ maxYieldResidualRatio: failedStep.maxYieldResidualRatio,
1839
+ message: `Plane-strain Drucker-Prager load step ${failedStep.step} did not satisfy the configured convergence policy.`,
1840
+ },
1841
+ } : {}),
1599
1842
  policy: system.policy,
1600
1843
  limitations: [
1601
1844
  'Benchmark-scale modified-Newton plane-strain plasticity evidence kernel only.',
1602
- 'Uses elastic global tangent with Gauss-point Drucker-Prager stress projection; no production consistent tangent, sparse solver, hardening calibration, staged activation, pore-pressure DOF, or route-backed result manifest is provided.',
1845
+ ...(failedStep ? ['Nonconverged load-step result is reported fail-closed and must not be treated as an accepted engineering solve.'] : []),
1846
+ linearSolver === 'sparse-csr-cg'
1847
+ ? 'Uses an experimental CSR Conjugate Gradient linear solve audit, elastic global tangent, and Gauss-point Drucker-Prager stress projection; no production consistent tangent, hardening calibration, staged activation, pore-pressure DOF, or route-backed result manifest is provided.'
1848
+ : 'Uses elastic global tangent with Gauss-point Drucker-Prager stress projection; no production consistent tangent, production sparse solver, hardening calibration, staged activation, pore-pressure DOF, or route-backed result manifest is provided.',
1603
1849
  'Use for deterministic evidence and regression tests only until independent published/commercial benchmark comparison and licensed production approval gates are complete.',
1604
1850
  ],
1605
1851
  };