@geotechcli/core 0.4.107 → 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.
@@ -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))
@@ -284,6 +286,9 @@ function matVec(matrix, vector) {
284
286
  function dot(a, b) {
285
287
  return a.reduce((sum, value, index) => sum + value * b[index], 0);
286
288
  }
289
+ function vectorNorm(values) {
290
+ return Math.sqrt(values.reduce((sum, value) => sum + value * value, 0));
291
+ }
287
292
  function elementMatrices(input) {
288
293
  const { nodes, material, thicknessM } = input;
289
294
  const d = planeStrainD(material);
@@ -1344,7 +1349,7 @@ export function runPlaneStrainQuad4Assembly(model) {
1344
1349
  policy,
1345
1350
  };
1346
1351
  }
1347
- function assemblePlaneStrainSystem(model) {
1352
+ function assemblePlaneStrainSystem(model, options = {}) {
1348
1353
  if (model.schemaVersion !== 'fem-plane-strain-model.v1') {
1349
1354
  throw new Error('Only fem-plane-strain-model.v1 is supported.');
1350
1355
  }
@@ -1370,10 +1375,17 @@ function assemblePlaneStrainSystem(model) {
1370
1375
  const nodeIndexById = new Map(model.nodes.map((node, index) => [node.id, index]));
1371
1376
  const materialById = new Map(model.materials.map((material) => [material.id, material]));
1372
1377
  const dofCount = model.nodes.length * 2;
1373
- 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) {
1374
1380
  throw new Error(`Plane-strain dense assembly is capped at ${MAX_DENSE_DOF_COUNT} DOFs for benchmark-scale evidence runs.`);
1375
1381
  }
1376
- 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 = [];
1377
1389
  const loads = new Array(dofCount).fill(0);
1378
1390
  for (const node of model.nodes) {
1379
1391
  assertFinite(node.xM, `node ${node.id} xM`);
@@ -1422,7 +1434,12 @@ function assemblePlaneStrainSystem(model) {
1422
1434
  const globalDofs = nodeIndices.flatMap((index) => [dofIndex(index, 'ux'), dofIndex(index, 'uy')]);
1423
1435
  for (let localRow = 0; localRow < 8; localRow += 1) {
1424
1436
  for (let localCol = 0; localCol < 8; localCol += 1) {
1425
- 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;
1426
1443
  }
1427
1444
  }
1428
1445
  elementGaussCache.push({
@@ -1463,6 +1480,7 @@ function assemblePlaneStrainSystem(model) {
1463
1480
  policy,
1464
1481
  materialById,
1465
1482
  stiffness,
1483
+ stiffnessTriplets,
1466
1484
  loads,
1467
1485
  prescribed,
1468
1486
  freeDofs,
@@ -1596,19 +1614,115 @@ function druckerPragerResidualHistoryEntry(iteration, evaluation, policy) {
1596
1614
  converged: isDruckerPragerStepConverged(evaluation, policy),
1597
1615
  };
1598
1616
  }
1599
- function druckerPragerTerminationReason(evaluation, policy, converged, iterations) {
1617
+ function druckerPragerTerminationReason(evaluation, policy, converged, iterations, linearSolverFailure) {
1600
1618
  if (converged)
1601
1619
  return 'converged';
1620
+ if (linearSolverFailure)
1621
+ return 'linear_solver_nonconverged';
1602
1622
  if (iterations >= policy.maxIterations)
1603
1623
  return 'max_iterations';
1604
1624
  if (evaluation.residualNormRatio > policy.forceBalanceTolerance)
1605
1625
  return 'force_residual_exceeded';
1606
1626
  return 'yield_residual_exceeded';
1607
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
+ }
1608
1705
  export function runPlaneStrainDruckerPragerLoadSteps(model, options = {}) {
1609
- 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
+ });
1610
1710
  const loadStepFractions = normalizeLoadStepFractions(options.loadStepFractions);
1611
- 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;
1612
1726
  const displacement = new Array(system.dofCount).fill(0);
1613
1727
  let finalEvaluation;
1614
1728
  const loadSteps = [];
@@ -1621,12 +1735,35 @@ export function runPlaneStrainDruckerPragerLoadSteps(model, options = {}) {
1621
1735
  const residualHistory = [
1622
1736
  druckerPragerResidualHistoryEntry(iterations, evaluation, system.policy),
1623
1737
  ];
1738
+ const linearSolverAudits = [];
1739
+ let linearSolverFailure;
1624
1740
  while (!converged && iterations < system.policy.maxIterations) {
1625
1741
  iterations += 1;
1626
1742
  if (system.freeDofs.length === 0)
1627
1743
  break;
1628
1744
  const correctionRhs = system.freeDofs.map((index) => -evaluation.residual[index]);
1629
- 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;
1630
1767
  for (const [correctionIndex, dof] of system.freeDofs.entries()) {
1631
1768
  displacement[dof] += correction[correctionIndex];
1632
1769
  }
@@ -1637,7 +1774,8 @@ export function runPlaneStrainDruckerPragerLoadSteps(model, options = {}) {
1637
1774
  residualHistory.push(druckerPragerResidualHistoryEntry(iterations, evaluation, system.policy));
1638
1775
  }
1639
1776
  finalEvaluation = evaluation;
1640
- const terminationReason = druckerPragerTerminationReason(evaluation, system.policy, converged, iterations);
1777
+ const terminationReason = druckerPragerTerminationReason(evaluation, system.policy, converged, iterations, linearSolverFailure);
1778
+ const lastLinearAudit = linearSolverAudits.at(-1);
1641
1779
  loadSteps.push({
1642
1780
  step: stepIndex + 1,
1643
1781
  loadFactor: round(loadFactor, 8),
@@ -1648,8 +1786,14 @@ export function runPlaneStrainDruckerPragerLoadSteps(model, options = {}) {
1648
1786
  maxYieldResidualRatio: round(evaluation.maxYieldResidualRatio, 12),
1649
1787
  maxEquivalentPlasticStrain: round(evaluation.maxEquivalentPlasticStrain, 12),
1650
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,
1651
1794
  converged,
1652
1795
  terminationReason,
1796
+ ...(linearSolverFailure?.failureReason ? { failureReason: linearSolverFailure.failureReason } : {}),
1653
1797
  residualHistory,
1654
1798
  });
1655
1799
  }
@@ -1672,6 +1816,10 @@ export function runPlaneStrainDruckerPragerLoadSteps(model, options = {}) {
1672
1816
  dofCount: system.dofCount,
1673
1817
  freeDofCount: system.freeDofs.length,
1674
1818
  constrainedDofCount: system.prescribed.size,
1819
+ linearSolver,
1820
+ nonlinearAlgorithm: 'modified-newton',
1821
+ globalTangent: 'elastic',
1822
+ materialIntegration: 'total-strain-drucker-prager-projection',
1675
1823
  loadSteps,
1676
1824
  maxFreeResidualKn: round(finalEvaluation.maxFreeResidualKn, 12),
1677
1825
  residualNormRatio: round(finalEvaluation.residualNormRatio, 12),
@@ -1695,7 +1843,9 @@ export function runPlaneStrainDruckerPragerLoadSteps(model, options = {}) {
1695
1843
  limitations: [
1696
1844
  'Benchmark-scale modified-Newton plane-strain plasticity evidence kernel only.',
1697
1845
  ...(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.',
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.',
1699
1849
  'Use for deterministic evidence and regression tests only until independent published/commercial benchmark comparison and licensed production approval gates are complete.',
1700
1850
  ],
1701
1851
  };