@geotechcli/core 0.4.107 → 0.4.109

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.
@@ -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,7 @@ 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;
9
11
  const MAX_BIOT_TIME_STEP_GROWTH_RATIO = 8;
10
12
  function assertFinite(value, label) {
11
13
  if (!Number.isFinite(value))
@@ -178,52 +180,126 @@ function deviatoricNorm(values) {
178
180
  const mean = (values[0] + values[1] + values[2]) / 3;
179
181
  return Math.hypot(values[0] - mean, values[1] - mean, values[2] - mean);
180
182
  }
181
- function projectDruckerPragerStress(input) {
183
+ function principalTrace(values) {
184
+ return values[0] + values[1] + values[2];
185
+ }
186
+ function principalDeviator(values) {
187
+ const mean = principalTrace(values) / 3;
188
+ return [values[0] - mean, values[1] - mean, values[2] - mean];
189
+ }
190
+ function addPrincipal(a, b) {
191
+ return [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
192
+ }
193
+ function scalePrincipal(values, scale) {
194
+ return [values[0] * scale, values[1] * scale, values[2] * scale];
195
+ }
196
+ function initialDruckerPragerState() {
197
+ return {
198
+ strain: [0, 0, 0],
199
+ stressKpa: [0, 0, 0],
200
+ sigmaZKpa: 0,
201
+ equivalentPlasticStrain: 0,
202
+ plasticStrainPrincipal: [0, 0, 0],
203
+ volumetricPlasticStrain: 0,
204
+ };
205
+ }
206
+ function integrateDruckerPragerStress(input) {
182
207
  const { material, strain } = input;
183
208
  const params = druckerPragerParameters(material);
184
- const principal = principalCompressionFromPlaneStress(input.stress, input.sigmaZKpa);
209
+ const d = planeStrainD(material);
210
+ const strainIncrement = strain.map((value, index) => value - input.previous.strain[index]);
211
+ const stressIncrement = d.map((row) => row.reduce((sum, value, col) => sum + value * strainIncrement[col], 0));
212
+ const trialStress = [
213
+ input.previous.stressKpa[0] + stressIncrement[0],
214
+ input.previous.stressKpa[1] + stressIncrement[1],
215
+ input.previous.stressKpa[2] + stressIncrement[2],
216
+ ];
217
+ const trialSigmaZKpa = input.previous.sigmaZKpa + planeStrainSigmaZ(material, strainIncrement);
218
+ const principal = principalCompressionFromPlaneStress(trialStress, trialSigmaZKpa);
185
219
  const principalStress = principal.values;
186
- const trace = principalStress[0] + principalStress[1] + principalStress[2];
220
+ const trace = principalTrace(principalStress);
187
221
  const mean = trace / 3;
188
222
  const q = deviatoricNorm(principalStress);
189
- const intercept = params.compressionInterceptKpa;
223
+ const hardeningModulusKpa = material.hardeningModulusKpa ?? 0;
224
+ const intercept = params.compressionInterceptKpa +
225
+ hardeningModulusKpa * input.previous.equivalentPlasticStrain;
190
226
  const yieldValue = q - params.rho * trace - intercept;
191
227
  const yieldScale = Math.max(q, Math.abs(params.rho * trace), Math.abs(intercept), 1);
192
228
  if (yieldValue <= 0 || Math.abs(yieldValue) / yieldScale <= input.policy.residualTolerance) {
193
- return {
229
+ const nextState = {
194
230
  strain,
195
- stressKpa: input.stress,
196
- outOfPlaneStressKpa: input.sigmaZKpa,
197
- compressionPositivePrincipalStressKpa: principalStress,
198
- yieldValueKpa: yieldValue,
199
- yieldResidualRatio: Math.max(0, yieldValue) / yieldScale,
200
- plasticMultiplier: 0,
201
- equivalentPlasticStrain: 0,
202
- state: 'elastic',
231
+ stressKpa: trialStress,
232
+ sigmaZKpa: trialSigmaZKpa,
233
+ equivalentPlasticStrain: input.previous.equivalentPlasticStrain,
234
+ plasticStrainPrincipal: input.previous.plasticStrainPrincipal,
235
+ volumetricPlasticStrain: input.previous.volumetricPlasticStrain,
236
+ };
237
+ return {
238
+ nextState,
239
+ result: {
240
+ strain,
241
+ stressKpa: trialStress,
242
+ outOfPlaneStressKpa: trialSigmaZKpa,
243
+ compressionPositivePrincipalStressKpa: principalStress,
244
+ strainIncrement,
245
+ previousEquivalentPlasticStrain: input.previous.equivalentPlasticStrain,
246
+ equivalentPlasticStrainIncrement: 0,
247
+ plasticStrainPrincipal: nextState.plasticStrainPrincipal,
248
+ volumetricPlasticStrain: nextState.volumetricPlasticStrain,
249
+ yieldValueKpa: yieldValue,
250
+ yieldResidualRatio: Math.max(0, yieldValue) / yieldScale,
251
+ plasticMultiplier: 0,
252
+ equivalentPlasticStrain: nextState.equivalentPlasticStrain,
253
+ state: 'elastic',
254
+ },
203
255
  };
204
256
  }
205
- const targetQ = Math.max(0, params.rho * trace + intercept);
206
- const scale = q > 0 ? targetQ / q : 0;
207
- const correctedPrincipal = principalStress.map((value) => mean + (value - mean) * scale);
208
- const corrected = planeStressFromPrincipalCompression(correctedPrincipal, principal.angleRad);
209
- const correctedQ = deviatoricNorm(correctedPrincipal);
210
- const correctedYieldValue = correctedQ - params.rho * trace - intercept;
211
- const correctedScale = Math.max(correctedQ, Math.abs(params.rho * trace), Math.abs(intercept), 1);
212
257
  const moduli = elasticModuli(material);
213
258
  const denominator = 2 * moduli.shearModulusKpa +
214
259
  9 * moduli.bulkModulusKpa * params.rho * params.rhoBar +
215
- (material.hardeningModulusKpa ?? 0);
260
+ hardeningModulusKpa;
216
261
  const plasticMultiplier = Math.max(0, yieldValue / Math.max(denominator, 1e-12));
217
- return {
262
+ const updatedEquivalentPlasticStrain = input.previous.equivalentPlasticStrain + plasticMultiplier;
263
+ const updatedIntercept = params.compressionInterceptKpa +
264
+ hardeningModulusKpa * updatedEquivalentPlasticStrain;
265
+ const trialDeviator = principalDeviator(principalStress);
266
+ const flowDirection = q > 0 ? scalePrincipal(trialDeviator, 1 / q) : [0, 0, 0];
267
+ const plasticStrainIncrement = addPrincipal(scalePrincipal(flowDirection, plasticMultiplier), [-params.rhoBar * plasticMultiplier, -params.rhoBar * plasticMultiplier, -params.rhoBar * plasticMultiplier]);
268
+ const correctedDeviator = addPrincipal(trialDeviator, scalePrincipal(flowDirection, -2 * moduli.shearModulusKpa * plasticMultiplier));
269
+ const correctedTrace = trace + 9 * moduli.bulkModulusKpa * params.rhoBar * plasticMultiplier;
270
+ const correctedPrincipal = addPrincipal(correctedDeviator, [correctedTrace / 3, correctedTrace / 3, correctedTrace / 3]);
271
+ const corrected = planeStressFromPrincipalCompression(correctedPrincipal, principal.angleRad);
272
+ const correctedQ = deviatoricNorm(correctedPrincipal);
273
+ const correctedYieldValue = correctedQ - params.rho * correctedTrace - updatedIntercept;
274
+ const correctedScale = Math.max(correctedQ, Math.abs(params.rho * correctedTrace), Math.abs(updatedIntercept), 1);
275
+ const plasticStrainPrincipal = addPrincipal(input.previous.plasticStrainPrincipal, plasticStrainIncrement);
276
+ const volumetricPlasticStrain = input.previous.volumetricPlasticStrain + principalTrace(plasticStrainIncrement);
277
+ const nextState = {
218
278
  strain,
219
279
  stressKpa: corrected.stress,
220
- outOfPlaneStressKpa: corrected.sigmaZKpa,
221
- compressionPositivePrincipalStressKpa: correctedPrincipal,
222
- yieldValueKpa: correctedYieldValue,
223
- yieldResidualRatio: Math.abs(correctedYieldValue) / correctedScale,
224
- plasticMultiplier,
225
- equivalentPlasticStrain: plasticMultiplier,
226
- state: 'plastic',
280
+ sigmaZKpa: corrected.sigmaZKpa,
281
+ equivalentPlasticStrain: updatedEquivalentPlasticStrain,
282
+ plasticStrainPrincipal,
283
+ volumetricPlasticStrain,
284
+ };
285
+ return {
286
+ nextState,
287
+ result: {
288
+ strain,
289
+ stressKpa: corrected.stress,
290
+ outOfPlaneStressKpa: corrected.sigmaZKpa,
291
+ compressionPositivePrincipalStressKpa: correctedPrincipal,
292
+ strainIncrement,
293
+ previousEquivalentPlasticStrain: input.previous.equivalentPlasticStrain,
294
+ equivalentPlasticStrainIncrement: plasticMultiplier,
295
+ plasticStrainPrincipal,
296
+ volumetricPlasticStrain,
297
+ yieldValueKpa: correctedYieldValue,
298
+ yieldResidualRatio: Math.abs(correctedYieldValue) / correctedScale,
299
+ plasticMultiplier,
300
+ equivalentPlasticStrain: updatedEquivalentPlasticStrain,
301
+ state: 'plastic',
302
+ },
227
303
  };
228
304
  }
229
305
  function shapeDerivativesNatural(xi, eta) {
@@ -284,6 +360,9 @@ function matVec(matrix, vector) {
284
360
  function dot(a, b) {
285
361
  return a.reduce((sum, value, index) => sum + value * b[index], 0);
286
362
  }
363
+ function vectorNorm(values) {
364
+ return Math.sqrt(values.reduce((sum, value) => sum + value * value, 0));
365
+ }
287
366
  function elementMatrices(input) {
288
367
  const { nodes, material, thicknessM } = input;
289
368
  const d = planeStrainD(material);
@@ -1344,7 +1423,7 @@ export function runPlaneStrainQuad4Assembly(model) {
1344
1423
  policy,
1345
1424
  };
1346
1425
  }
1347
- function assemblePlaneStrainSystem(model) {
1426
+ function assemblePlaneStrainSystem(model, options = {}) {
1348
1427
  if (model.schemaVersion !== 'fem-plane-strain-model.v1') {
1349
1428
  throw new Error('Only fem-plane-strain-model.v1 is supported.');
1350
1429
  }
@@ -1370,10 +1449,17 @@ function assemblePlaneStrainSystem(model) {
1370
1449
  const nodeIndexById = new Map(model.nodes.map((node, index) => [node.id, index]));
1371
1450
  const materialById = new Map(model.materials.map((material) => [material.id, material]));
1372
1451
  const dofCount = model.nodes.length * 2;
1373
- if (dofCount > MAX_DENSE_DOF_COUNT) {
1452
+ const storage = options.storage ?? 'dense-and-triplets';
1453
+ if (storage === 'dense-and-triplets' && dofCount > MAX_DENSE_DOF_COUNT) {
1374
1454
  throw new Error(`Plane-strain dense assembly is capped at ${MAX_DENSE_DOF_COUNT} DOFs for benchmark-scale evidence runs.`);
1375
1455
  }
1376
- const stiffness = Array.from({ length: dofCount }, () => new Array(dofCount).fill(0));
1456
+ if (storage === 'triplets-only' && dofCount > MAX_SPARSE_EXPERIMENTAL_DOF_COUNT) {
1457
+ throw new Error(`Plane-strain sparse experimental assembly is capped at ${MAX_SPARSE_EXPERIMENTAL_DOF_COUNT} DOFs until production solver benchmarks are approved.`);
1458
+ }
1459
+ const stiffness = storage === 'dense-and-triplets'
1460
+ ? Array.from({ length: dofCount }, () => new Array(dofCount).fill(0))
1461
+ : undefined;
1462
+ const stiffnessTriplets = [];
1377
1463
  const loads = new Array(dofCount).fill(0);
1378
1464
  for (const node of model.nodes) {
1379
1465
  assertFinite(node.xM, `node ${node.id} xM`);
@@ -1422,7 +1508,12 @@ function assemblePlaneStrainSystem(model) {
1422
1508
  const globalDofs = nodeIndices.flatMap((index) => [dofIndex(index, 'ux'), dofIndex(index, 'uy')]);
1423
1509
  for (let localRow = 0; localRow < 8; localRow += 1) {
1424
1510
  for (let localCol = 0; localCol < 8; localCol += 1) {
1425
- stiffness[globalDofs[localRow]][globalDofs[localCol]] += elementData.stiffness[localRow][localCol];
1511
+ const row = globalDofs[localRow];
1512
+ const col = globalDofs[localCol];
1513
+ const value = elementData.stiffness[localRow][localCol];
1514
+ stiffnessTriplets.push({ row, col, value });
1515
+ if (stiffness)
1516
+ stiffness[row][col] += value;
1426
1517
  }
1427
1518
  }
1428
1519
  elementGaussCache.push({
@@ -1463,6 +1554,7 @@ function assemblePlaneStrainSystem(model) {
1463
1554
  policy,
1464
1555
  materialById,
1465
1556
  stiffness,
1557
+ stiffnessTriplets,
1466
1558
  loads,
1467
1559
  prescribed,
1468
1560
  freeDofs,
@@ -1471,27 +1563,30 @@ function assemblePlaneStrainSystem(model) {
1471
1563
  };
1472
1564
  }
1473
1565
  function evaluatePlaneStrainDruckerPragerState(input) {
1474
- const { system, displacement, loadFactor } = input;
1566
+ const { system, displacement, loadFactor, committedStates } = input;
1475
1567
  const internal = new Array(system.dofCount).fill(0);
1476
1568
  let maxYieldResidualRatio = 0;
1477
1569
  let maxEquivalentPlasticStrain = 0;
1570
+ let maxEquivalentPlasticStrainIncrement = 0;
1478
1571
  let plasticGaussPointCount = 0;
1479
- const elements = system.elementGaussCache.map((entry) => {
1572
+ const trialStates = [];
1573
+ const elements = system.elementGaussCache.map((entry, elementIndex) => {
1480
1574
  const material = system.materialById.get(entry.element.materialId);
1481
- const d = planeStrainD(material);
1482
1575
  const elementDisplacement = entry.globalDofs.map((index) => displacement[index]);
1483
1576
  const gaussPoints = entry.gauss.map((point, index) => {
1484
1577
  const strain = point.b.map((row) => row.reduce((sum, value, col) => sum + value * elementDisplacement[col], 0));
1485
- const elasticStress = d.map((row) => row.reduce((sum, value, col) => sum + value * strain[col], 0));
1486
- const projected = projectDruckerPragerStress({
1578
+ const integrated = integrateDruckerPragerStress({
1487
1579
  material,
1488
1580
  policy: system.policy,
1489
1581
  strain,
1490
- stress: elasticStress,
1491
- sigmaZKpa: planeStrainSigmaZ(material, strain),
1582
+ previous: committedStates[elementIndex]?.[index] ?? initialDruckerPragerState(),
1492
1583
  });
1584
+ const projected = integrated.result;
1585
+ trialStates[elementIndex] = trialStates[elementIndex] ?? [];
1586
+ trialStates[elementIndex][index] = integrated.nextState;
1493
1587
  maxYieldResidualRatio = Math.max(maxYieldResidualRatio, projected.yieldResidualRatio);
1494
1588
  maxEquivalentPlasticStrain = Math.max(maxEquivalentPlasticStrain, projected.equivalentPlasticStrain);
1589
+ maxEquivalentPlasticStrainIncrement = Math.max(maxEquivalentPlasticStrainIncrement, projected.equivalentPlasticStrainIncrement);
1495
1590
  if (projected.state === 'plastic')
1496
1591
  plasticGaussPointCount += 1;
1497
1592
  for (let localDof = 0; localDof < 8; localDof += 1) {
@@ -1520,6 +1615,19 @@ function evaluatePlaneStrainDruckerPragerState(input) {
1520
1615
  round(projected.compressionPositivePrincipalStressKpa[1], 8),
1521
1616
  round(projected.compressionPositivePrincipalStressKpa[2], 8),
1522
1617
  ],
1618
+ strainIncrement: [
1619
+ round(projected.strainIncrement[0], 12),
1620
+ round(projected.strainIncrement[1], 12),
1621
+ round(projected.strainIncrement[2], 12),
1622
+ ],
1623
+ previousEquivalentPlasticStrain: round(projected.previousEquivalentPlasticStrain, 12),
1624
+ equivalentPlasticStrainIncrement: round(projected.equivalentPlasticStrainIncrement, 12),
1625
+ plasticStrainPrincipal: [
1626
+ round(projected.plasticStrainPrincipal[0], 12),
1627
+ round(projected.plasticStrainPrincipal[1], 12),
1628
+ round(projected.plasticStrainPrincipal[2], 12),
1629
+ ],
1630
+ volumetricPlasticStrain: round(projected.volumetricPlasticStrain, 12),
1523
1631
  yieldValueKpa: round(projected.yieldValueKpa, 10),
1524
1632
  yieldResidualRatio: round(projected.yieldResidualRatio, 12),
1525
1633
  plasticMultiplier: round(projected.plasticMultiplier, 12),
@@ -1560,13 +1668,33 @@ function evaluatePlaneStrainDruckerPragerState(input) {
1560
1668
  reactionBalanceRatio,
1561
1669
  maxYieldResidualRatio,
1562
1670
  maxEquivalentPlasticStrain,
1671
+ maxEquivalentPlasticStrainIncrement,
1563
1672
  plasticGaussPointCount,
1673
+ trialStates,
1564
1674
  elements,
1565
1675
  };
1566
1676
  }
1567
- function normalizeLoadStepFractions(loadStepFractions) {
1568
- const fractions = loadStepFractions && loadStepFractions.length > 0
1569
- ? [...loadStepFractions]
1677
+ function normalizeLoadStepFractions(options) {
1678
+ if (options.loadStepFractions != null && options.loadHistoryFactors != null) {
1679
+ throw new Error('Specify either loadStepFractions or loadHistoryFactors, not both.');
1680
+ }
1681
+ if (options.loadHistoryFactors != null) {
1682
+ if (!Array.isArray(options.loadHistoryFactors) || options.loadHistoryFactors.length === 0) {
1683
+ throw new Error('loadHistoryFactors must contain at least one load factor when provided.');
1684
+ }
1685
+ const history = [...options.loadHistoryFactors];
1686
+ for (const [index, factor] of history.entries()) {
1687
+ if (!Number.isFinite(factor) || factor < 0 || factor > 1) {
1688
+ throw new Error(`loadHistoryFactors.${index} must be finite and between 0 and 1.`);
1689
+ }
1690
+ }
1691
+ if (history[history.length - 1] !== 1) {
1692
+ throw new Error('loadHistoryFactors must end at 1.');
1693
+ }
1694
+ return history;
1695
+ }
1696
+ const fractions = options.loadStepFractions && options.loadStepFractions.length > 0
1697
+ ? [...options.loadStepFractions]
1570
1698
  : [0.25, 0.5, 0.75, 1];
1571
1699
  let previous = 0;
1572
1700
  for (const [index, fraction] of fractions.entries()) {
@@ -1584,6 +1712,9 @@ function isDruckerPragerStepConverged(evaluation, policy) {
1584
1712
  return evaluation.residualNormRatio <= policy.forceBalanceTolerance &&
1585
1713
  evaluation.maxYieldResidualRatio <= policy.residualTolerance;
1586
1714
  }
1715
+ function createInitialDruckerPragerStateGrid(system) {
1716
+ return system.elementGaussCache.map((entry) => entry.gauss.map(() => initialDruckerPragerState()));
1717
+ }
1587
1718
  function druckerPragerResidualHistoryEntry(iteration, evaluation, policy) {
1588
1719
  return {
1589
1720
  iteration,
@@ -1596,48 +1727,184 @@ function druckerPragerResidualHistoryEntry(iteration, evaluation, policy) {
1596
1727
  converged: isDruckerPragerStepConverged(evaluation, policy),
1597
1728
  };
1598
1729
  }
1599
- function druckerPragerTerminationReason(evaluation, policy, converged, iterations) {
1730
+ function druckerPragerTerminationReason(evaluation, policy, converged, iterations, linearSolverFailure) {
1600
1731
  if (converged)
1601
1732
  return 'converged';
1733
+ if (linearSolverFailure)
1734
+ return 'linear_solver_nonconverged';
1602
1735
  if (iterations >= policy.maxIterations)
1603
1736
  return 'max_iterations';
1604
1737
  if (evaluation.residualNormRatio > policy.forceBalanceTolerance)
1605
1738
  return 'force_residual_exceeded';
1606
1739
  return 'yield_residual_exceeded';
1607
1740
  }
1741
+ function normalizeLinearSolverKind(solver) {
1742
+ const resolved = solver ?? 'dense-gaussian';
1743
+ if (resolved !== 'dense-gaussian' && resolved !== 'sparse-csr-cg') {
1744
+ throw new Error('linearSolver must be dense-gaussian or sparse-csr-cg.');
1745
+ }
1746
+ return resolved;
1747
+ }
1748
+ function denseNonzeroCount(matrix) {
1749
+ return matrix.reduce((count, row) => count + row.filter((value) => Math.abs(value) > 0).length, 0);
1750
+ }
1751
+ function buildReducedCsr(input) {
1752
+ const freeIndexByDof = new Map(input.freeDofs.map((dof, index) => [dof, index]));
1753
+ const reducedTriplets = input.triplets.flatMap((entry) => {
1754
+ const row = freeIndexByDof.get(entry.row);
1755
+ const col = freeIndexByDof.get(entry.col);
1756
+ return row != null && col != null ? [{ row, col, value: entry.value }] : [];
1757
+ });
1758
+ return buildCsrFromTriplets({
1759
+ rowCount: input.freeDofs.length,
1760
+ colCount: input.freeDofs.length,
1761
+ triplets: reducedTriplets,
1762
+ dropTolerance: 0,
1763
+ });
1764
+ }
1765
+ function solveDenseDruckerPragerCorrection(input) {
1766
+ const correction = solveDenseLinearSystem(input.reducedK, [...input.rhs]);
1767
+ const solvedRhs = matVec(input.reducedK, correction);
1768
+ const residual = solvedRhs.map((value, index) => value - input.rhs[index]);
1769
+ const rhsNorm = Math.max(vectorNorm(input.rhs), 1);
1770
+ const finalResidualNorm = vectorNorm(residual);
1771
+ const correctionNorm = vectorNorm(correction);
1772
+ return {
1773
+ correction,
1774
+ audit: {
1775
+ schemaVersion: 'fem-plane-strain-linear-solver-audit.v1',
1776
+ solver: 'dense-gaussian',
1777
+ matrixDofCount: input.rhs.length,
1778
+ nonzeroCount: denseNonzeroCount(input.reducedK),
1779
+ iterations: input.rhs.length,
1780
+ tolerance: input.tolerance,
1781
+ maxIterations: input.maxIterations,
1782
+ initialResidualNorm: vectorNorm(input.rhs),
1783
+ finalResidualNorm,
1784
+ residualNormRatio: finalResidualNorm / rhsNorm,
1785
+ correctionNormM: correctionNorm,
1786
+ correctionNormRatio: correctionNorm / Math.max(vectorNorm(input.currentFreeDisplacement), 1e-12),
1787
+ converged: true,
1788
+ },
1789
+ };
1790
+ }
1791
+ function solveSparseDruckerPragerCorrection(input) {
1792
+ const solved = solveCsrConjugateGradient(input.reducedK, input.rhs, {
1793
+ tolerance: input.tolerance,
1794
+ maxIterations: input.maxIterations,
1795
+ preconditioner: 'jacobi',
1796
+ });
1797
+ const correctionNorm = vectorNorm(solved.solution);
1798
+ return {
1799
+ correction: solved.solution,
1800
+ audit: {
1801
+ schemaVersion: 'fem-plane-strain-linear-solver-audit.v1',
1802
+ solver: 'sparse-csr-cg',
1803
+ matrixDofCount: input.rhs.length,
1804
+ nonzeroCount: input.reducedK.nonzeroCount,
1805
+ iterations: solved.iterations,
1806
+ tolerance: solved.tolerance,
1807
+ maxIterations: solved.maxIterations,
1808
+ initialResidualNorm: solved.initialResidualNorm,
1809
+ finalResidualNorm: solved.finalResidualNorm,
1810
+ residualNormRatio: solved.residualNormRatio,
1811
+ correctionNormM: correctionNorm,
1812
+ correctionNormRatio: correctionNorm / Math.max(vectorNorm(input.currentFreeDisplacement), 1e-12),
1813
+ converged: solved.converged,
1814
+ ...(solved.failureReason ? { failureReason: solved.failureReason } : {}),
1815
+ },
1816
+ };
1817
+ }
1608
1818
  export function runPlaneStrainDruckerPragerLoadSteps(model, options = {}) {
1609
- const system = assemblePlaneStrainSystem(model);
1610
- const loadStepFractions = normalizeLoadStepFractions(options.loadStepFractions);
1611
- const reducedK = system.freeDofs.map((row) => system.freeDofs.map((col) => system.stiffness[row][col]));
1819
+ const linearSolver = normalizeLinearSolverKind(options.linearSolver);
1820
+ const system = assemblePlaneStrainSystem(model, {
1821
+ storage: linearSolver === 'sparse-csr-cg' ? 'triplets-only' : 'dense-and-triplets',
1822
+ });
1823
+ const loadStepFractions = normalizeLoadStepFractions({
1824
+ loadStepFractions: options.loadStepFractions,
1825
+ loadHistoryFactors: options.loadHistoryFactors,
1826
+ });
1827
+ const linearSolverTolerance = options.linearSolverTolerance ?? Math.min(1e-10, system.policy.forceBalanceTolerance / 10);
1828
+ if (!Number.isFinite(linearSolverTolerance) || linearSolverTolerance <= 0) {
1829
+ throw new Error('linearSolverTolerance must be a finite positive number.');
1830
+ }
1831
+ const linearSolverMaxIterations = options.linearSolverMaxIterations ?? Math.max(100, system.freeDofs.length * 10);
1832
+ assertPositiveInteger(linearSolverMaxIterations, 'linearSolverMaxIterations');
1833
+ const reducedDenseK = linearSolver === 'dense-gaussian'
1834
+ ? system.freeDofs.map((row) => system.freeDofs.map((col) => system.stiffness[row][col]))
1835
+ : undefined;
1836
+ const reducedSparseK = linearSolver === 'sparse-csr-cg' && system.freeDofs.length > 0
1837
+ ? buildReducedCsr({
1838
+ freeDofs: system.freeDofs,
1839
+ triplets: system.stiffnessTriplets,
1840
+ })
1841
+ : undefined;
1612
1842
  const displacement = new Array(system.dofCount).fill(0);
1843
+ let committedStates = createInitialDruckerPragerStateGrid(system);
1613
1844
  let finalEvaluation;
1614
1845
  const loadSteps = [];
1615
1846
  for (const [stepIndex, loadFactor] of loadStepFractions.entries()) {
1616
1847
  for (const [index, value] of system.prescribed)
1617
1848
  displacement[index] = value * loadFactor;
1618
- let evaluation = evaluatePlaneStrainDruckerPragerState({ system, displacement, loadFactor });
1849
+ let evaluation = evaluatePlaneStrainDruckerPragerState({
1850
+ system,
1851
+ displacement,
1852
+ loadFactor,
1853
+ committedStates,
1854
+ });
1619
1855
  let iterations = 0;
1620
1856
  let converged = isDruckerPragerStepConverged(evaluation, system.policy);
1621
1857
  const residualHistory = [
1622
1858
  druckerPragerResidualHistoryEntry(iterations, evaluation, system.policy),
1623
1859
  ];
1860
+ const linearSolverAudits = [];
1861
+ let linearSolverFailure;
1624
1862
  while (!converged && iterations < system.policy.maxIterations) {
1625
1863
  iterations += 1;
1626
1864
  if (system.freeDofs.length === 0)
1627
1865
  break;
1628
1866
  const correctionRhs = system.freeDofs.map((index) => -evaluation.residual[index]);
1629
- const correction = solveDenseLinearSystem(reducedK, correctionRhs);
1867
+ const currentFreeDisplacement = system.freeDofs.map((index) => displacement[index]);
1868
+ const solved = linearSolver === 'sparse-csr-cg'
1869
+ ? solveSparseDruckerPragerCorrection({
1870
+ reducedK: reducedSparseK,
1871
+ rhs: correctionRhs,
1872
+ currentFreeDisplacement,
1873
+ tolerance: linearSolverTolerance,
1874
+ maxIterations: linearSolverMaxIterations,
1875
+ })
1876
+ : solveDenseDruckerPragerCorrection({
1877
+ reducedK: reducedDenseK,
1878
+ rhs: correctionRhs,
1879
+ currentFreeDisplacement,
1880
+ tolerance: linearSolverTolerance,
1881
+ maxIterations: linearSolverMaxIterations,
1882
+ });
1883
+ linearSolverAudits.push(solved.audit);
1884
+ if (!solved.audit.converged) {
1885
+ linearSolverFailure = solved.audit;
1886
+ break;
1887
+ }
1888
+ const correction = solved.correction;
1630
1889
  for (const [correctionIndex, dof] of system.freeDofs.entries()) {
1631
1890
  displacement[dof] += correction[correctionIndex];
1632
1891
  }
1633
1892
  for (const [index, value] of system.prescribed)
1634
1893
  displacement[index] = value * loadFactor;
1635
- evaluation = evaluatePlaneStrainDruckerPragerState({ system, displacement, loadFactor });
1894
+ evaluation = evaluatePlaneStrainDruckerPragerState({
1895
+ system,
1896
+ displacement,
1897
+ loadFactor,
1898
+ committedStates,
1899
+ });
1636
1900
  converged = isDruckerPragerStepConverged(evaluation, system.policy);
1637
1901
  residualHistory.push(druckerPragerResidualHistoryEntry(iterations, evaluation, system.policy));
1638
1902
  }
1639
1903
  finalEvaluation = evaluation;
1640
- const terminationReason = druckerPragerTerminationReason(evaluation, system.policy, converged, iterations);
1904
+ if (converged)
1905
+ committedStates = evaluation.trialStates;
1906
+ const terminationReason = druckerPragerTerminationReason(evaluation, system.policy, converged, iterations, linearSolverFailure);
1907
+ const lastLinearAudit = linearSolverAudits.at(-1);
1641
1908
  loadSteps.push({
1642
1909
  step: stepIndex + 1,
1643
1910
  loadFactor: round(loadFactor, 8),
@@ -1647,9 +1914,16 @@ export function runPlaneStrainDruckerPragerLoadSteps(model, options = {}) {
1647
1914
  reactionBalanceRatio: round(evaluation.reactionBalanceRatio, 12),
1648
1915
  maxYieldResidualRatio: round(evaluation.maxYieldResidualRatio, 12),
1649
1916
  maxEquivalentPlasticStrain: round(evaluation.maxEquivalentPlasticStrain, 12),
1917
+ maxEquivalentPlasticStrainIncrement: round(evaluation.maxEquivalentPlasticStrainIncrement, 12),
1650
1918
  plasticGaussPointCount: evaluation.plasticGaussPointCount,
1919
+ linearSolver,
1920
+ linearIterations: linearSolverAudits.reduce((sum, audit) => sum + audit.iterations, 0),
1921
+ linearResidualNormRatio: round(lastLinearAudit?.residualNormRatio ?? 0, 12),
1922
+ correctionNormRatio: round(lastLinearAudit?.correctionNormRatio ?? 0, 12),
1923
+ linearSolverAudits,
1651
1924
  converged,
1652
1925
  terminationReason,
1926
+ ...(linearSolverFailure?.failureReason ? { failureReason: linearSolverFailure.failureReason } : {}),
1653
1927
  residualHistory,
1654
1928
  });
1655
1929
  }
@@ -1672,12 +1946,18 @@ export function runPlaneStrainDruckerPragerLoadSteps(model, options = {}) {
1672
1946
  dofCount: system.dofCount,
1673
1947
  freeDofCount: system.freeDofs.length,
1674
1948
  constrainedDofCount: system.prescribed.size,
1949
+ linearSolver,
1950
+ nonlinearAlgorithm: 'modified-newton',
1951
+ globalTangent: 'elastic',
1952
+ materialIntegration: 'incremental-committed-drucker-prager-return-mapping',
1953
+ stateStorage: 'committed-gauss-point-history',
1675
1954
  loadSteps,
1676
1955
  maxFreeResidualKn: round(finalEvaluation.maxFreeResidualKn, 12),
1677
1956
  residualNormRatio: round(finalEvaluation.residualNormRatio, 12),
1678
1957
  reactionBalanceRatio: round(finalEvaluation.reactionBalanceRatio, 12),
1679
1958
  maxYieldResidualRatio: round(finalEvaluation.maxYieldResidualRatio, 12),
1680
1959
  maxEquivalentPlasticStrain: round(finalEvaluation.maxEquivalentPlasticStrain, 12),
1960
+ maxEquivalentPlasticStrainIncrement: round(finalEvaluation.maxEquivalentPlasticStrainIncrement, 12),
1681
1961
  plasticGaussPointCount: finalEvaluation.plasticGaussPointCount,
1682
1962
  converged: status === 'converged',
1683
1963
  status,
@@ -1695,7 +1975,9 @@ export function runPlaneStrainDruckerPragerLoadSteps(model, options = {}) {
1695
1975
  limitations: [
1696
1976
  'Benchmark-scale modified-Newton plane-strain plasticity evidence kernel only.',
1697
1977
  ...(failedStep ? ['Nonconverged load-step result is reported fail-closed and must not be treated as an accepted engineering solve.'] : []),
1698
- '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.',
1978
+ linearSolver === 'sparse-csr-cg'
1979
+ ? 'Uses an experimental CSR Conjugate Gradient linear solve audit, elastic global tangent, and committed Gauss-point Drucker-Prager return mapping; no production consistent tangent, hardening calibration, staged activation, pore-pressure DOF, or route-backed result manifest is provided.'
1980
+ : 'Uses elastic global tangent with committed Gauss-point Drucker-Prager return mapping; no production consistent tangent, production sparse solver, hardening calibration, staged activation, pore-pressure DOF, or route-backed result manifest is provided.',
1699
1981
  'Use for deterministic evidence and regression tests only until independent published/commercial benchmark comparison and licensed production approval gates are complete.',
1700
1982
  ],
1701
1983
  };