@geotechcli/core 0.4.97 → 0.4.99

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.
@@ -0,0 +1,834 @@
1
+ import { DEFAULT_FEM_CONVERGENCE_POLICY } from './engineering-evidence.js';
2
+ const GAUSS_POINTS = [
3
+ [-1 / Math.sqrt(3), -1 / Math.sqrt(3), 1],
4
+ [1 / Math.sqrt(3), -1 / Math.sqrt(3), 1],
5
+ [1 / Math.sqrt(3), 1 / Math.sqrt(3), 1],
6
+ [-1 / Math.sqrt(3), 1 / Math.sqrt(3), 1],
7
+ ];
8
+ const MAX_DENSE_DOF_COUNT = 800;
9
+ function assertFinite(value, label) {
10
+ if (!Number.isFinite(value))
11
+ throw new Error(`${label} must be finite.`);
12
+ }
13
+ function assertFinitePositive(value, label) {
14
+ if (!Number.isFinite(value) || value <= 0)
15
+ throw new Error(`${label} must be a finite positive number.`);
16
+ }
17
+ function assertFiniteNonNegative(value, label) {
18
+ if (!Number.isFinite(value) || value < 0)
19
+ throw new Error(`${label} must be a finite non-negative number.`);
20
+ }
21
+ function assertPositiveInteger(value, label) {
22
+ if (!Number.isFinite(value) || !Number.isInteger(value) || value < 1) {
23
+ throw new Error(`${label} must be a finite positive integer.`);
24
+ }
25
+ return value;
26
+ }
27
+ function assertArray(value, label) {
28
+ if (!Array.isArray(value))
29
+ throw new Error(`${label} must be an array.`);
30
+ }
31
+ function assertNonEmptyId(value, label) {
32
+ if (typeof value !== 'string' || value.trim().length === 0) {
33
+ throw new Error(`${label} id must be a non-empty string.`);
34
+ }
35
+ }
36
+ function assertUniqueIds(items, label) {
37
+ const seen = new Set();
38
+ for (const item of items) {
39
+ assertNonEmptyId(item.id, label);
40
+ if (seen.has(item.id))
41
+ throw new Error(`Duplicate ${label} id: ${item.id}.`);
42
+ seen.add(item.id);
43
+ }
44
+ }
45
+ function validateConvergencePolicy(policy) {
46
+ if (policy.schemaVersion !== 'fem-convergence-policy.v1') {
47
+ throw new Error('policy.schemaVersion must be fem-convergence-policy.v1.');
48
+ }
49
+ assertFinitePositive(policy.residualTolerance, 'policy.residualTolerance');
50
+ assertFinitePositive(policy.forceBalanceTolerance, 'policy.forceBalanceTolerance');
51
+ assertFinitePositive(policy.porePressureMassBalanceTolerance, 'policy.porePressureMassBalanceTolerance');
52
+ assertPositiveInteger(policy.maxIterations, 'policy.maxIterations');
53
+ assertPositiveInteger(policy.minAcceptedSteps, 'policy.minAcceptedSteps');
54
+ if (policy.minAcceptedSteps > policy.maxIterations) {
55
+ throw new Error('policy.minAcceptedSteps must be less than or equal to policy.maxIterations.');
56
+ }
57
+ }
58
+ function round(value, digits = 10) {
59
+ const factor = 10 ** digits;
60
+ return Math.round(value * factor) / factor;
61
+ }
62
+ function dofIndex(nodeIndex, dof) {
63
+ return nodeIndex * 2 + (dof === 'ux' ? 0 : 1);
64
+ }
65
+ function planeStrainD(material) {
66
+ assertFinitePositive(material.elasticModulusKpa, `material ${material.id} elasticModulusKpa`);
67
+ if (!Number.isFinite(material.poissonRatio) || material.poissonRatio < 0 || material.poissonRatio >= 0.5) {
68
+ throw new Error(`material ${material.id} poissonRatio must be finite and between 0 and 0.5.`);
69
+ }
70
+ const e = material.elasticModulusKpa;
71
+ const nu = material.poissonRatio;
72
+ const factor = e / ((1 + nu) * (1 - 2 * nu));
73
+ return [
74
+ [factor * (1 - nu), factor * nu, 0],
75
+ [factor * nu, factor * (1 - nu), 0],
76
+ [0, 0, factor * (1 - 2 * nu) / 2],
77
+ ];
78
+ }
79
+ function planeStrainSigmaZ(material, strain) {
80
+ const e = material.elasticModulusKpa;
81
+ const nu = material.poissonRatio;
82
+ const factor = e / ((1 + nu) * (1 - 2 * nu));
83
+ return factor * nu * (strain[0] + strain[1]);
84
+ }
85
+ function elasticModuli(material) {
86
+ return {
87
+ bulkModulusKpa: material.elasticModulusKpa / (3 * (1 - 2 * material.poissonRatio)),
88
+ shearModulusKpa: material.elasticModulusKpa / (2 * (1 + material.poissonRatio)),
89
+ };
90
+ }
91
+ function degToRad(degrees) {
92
+ return (degrees * Math.PI) / 180;
93
+ }
94
+ function druckerPragerRhoFromAngle(angleDeg) {
95
+ if (!Number.isFinite(angleDeg) || angleDeg < 0 || angleDeg >= 50) {
96
+ throw new Error('friction and dilation angles must be finite and between 0 and 50 degrees.');
97
+ }
98
+ const angle = degToRad(angleDeg);
99
+ const sinAngle = Math.sin(angle);
100
+ if (sinAngle === 0)
101
+ return 0;
102
+ return (2 * Math.SQRT2 * sinAngle) / (Math.sqrt(3) * (3 - sinAngle));
103
+ }
104
+ function druckerPragerParameters(material) {
105
+ const frictionAngleDeg = material.frictionAngleDeg ?? 30;
106
+ const cohesionKpa = material.cohesionKpa ?? 0;
107
+ const dilationAngleDeg = material.dilationAngleDeg ?? frictionAngleDeg;
108
+ if (!Number.isFinite(frictionAngleDeg) || frictionAngleDeg <= 0 || frictionAngleDeg >= 50) {
109
+ throw new Error(`material ${material.id} frictionAngleDeg must be finite and between 0 and 50 degrees.`);
110
+ }
111
+ assertFiniteNonNegative(cohesionKpa, `material ${material.id} cohesionKpa`);
112
+ assertFiniteNonNegative(material.hardeningModulusKpa ?? 0, `material ${material.id} hardeningModulusKpa`);
113
+ const rho = druckerPragerRhoFromAngle(frictionAngleDeg);
114
+ const rhoBar = Math.min(rho, druckerPragerRhoFromAngle(dilationAngleDeg));
115
+ const phi = degToRad(frictionAngleDeg);
116
+ const sinPhi = Math.sin(phi);
117
+ const cohesionContributionToQ = (2 * cohesionKpa * Math.cos(phi)) / (1 - sinPhi);
118
+ const deviatoricNormFactor = Math.sqrt(2 / 3);
119
+ return {
120
+ rho,
121
+ rhoBar,
122
+ compressionInterceptKpa: cohesionContributionToQ * (deviatoricNormFactor - rho),
123
+ };
124
+ }
125
+ function principalCompressionFromPlaneStress(stress, sigmaZKpa) {
126
+ const a = -stress[0];
127
+ const c = -stress[1];
128
+ const b = -stress[2];
129
+ const mean = (a + c) / 2;
130
+ const radius = Math.hypot((a - c) / 2, b);
131
+ const angleRad = 0.5 * Math.atan2(2 * b, a - c);
132
+ return {
133
+ values: [mean + radius, mean - radius, -sigmaZKpa],
134
+ angleRad,
135
+ };
136
+ }
137
+ function planeStressFromPrincipalCompression(values, angleRad) {
138
+ const [p1, p2, p3] = values;
139
+ const c = Math.cos(angleRad);
140
+ const s = Math.sin(angleRad);
141
+ const compressionX = p1 * c * c + p2 * s * s;
142
+ const compressionY = p1 * s * s + p2 * c * c;
143
+ const compressionXY = (p1 - p2) * s * c;
144
+ return {
145
+ stress: [-compressionX, -compressionY, -compressionXY],
146
+ sigmaZKpa: -p3,
147
+ };
148
+ }
149
+ function deviatoricNorm(values) {
150
+ const mean = (values[0] + values[1] + values[2]) / 3;
151
+ return Math.hypot(values[0] - mean, values[1] - mean, values[2] - mean);
152
+ }
153
+ function projectDruckerPragerStress(input) {
154
+ const { material, strain } = input;
155
+ const params = druckerPragerParameters(material);
156
+ const principal = principalCompressionFromPlaneStress(input.stress, input.sigmaZKpa);
157
+ const principalStress = principal.values;
158
+ const trace = principalStress[0] + principalStress[1] + principalStress[2];
159
+ const mean = trace / 3;
160
+ const q = deviatoricNorm(principalStress);
161
+ const intercept = params.compressionInterceptKpa;
162
+ const yieldValue = q - params.rho * trace - intercept;
163
+ const yieldScale = Math.max(q, Math.abs(params.rho * trace), Math.abs(intercept), 1);
164
+ if (yieldValue <= 0 || Math.abs(yieldValue) / yieldScale <= input.policy.residualTolerance) {
165
+ return {
166
+ strain,
167
+ stressKpa: input.stress,
168
+ outOfPlaneStressKpa: input.sigmaZKpa,
169
+ compressionPositivePrincipalStressKpa: principalStress,
170
+ yieldValueKpa: yieldValue,
171
+ yieldResidualRatio: Math.max(0, yieldValue) / yieldScale,
172
+ plasticMultiplier: 0,
173
+ equivalentPlasticStrain: 0,
174
+ state: 'elastic',
175
+ };
176
+ }
177
+ const targetQ = Math.max(0, params.rho * trace + intercept);
178
+ const scale = q > 0 ? targetQ / q : 0;
179
+ const correctedPrincipal = principalStress.map((value) => mean + (value - mean) * scale);
180
+ const corrected = planeStressFromPrincipalCompression(correctedPrincipal, principal.angleRad);
181
+ const correctedQ = deviatoricNorm(correctedPrincipal);
182
+ const correctedYieldValue = correctedQ - params.rho * trace - intercept;
183
+ const correctedScale = Math.max(correctedQ, Math.abs(params.rho * trace), Math.abs(intercept), 1);
184
+ const moduli = elasticModuli(material);
185
+ const denominator = 2 * moduli.shearModulusKpa +
186
+ 9 * moduli.bulkModulusKpa * params.rho * params.rhoBar +
187
+ (material.hardeningModulusKpa ?? 0);
188
+ const plasticMultiplier = Math.max(0, yieldValue / Math.max(denominator, 1e-12));
189
+ return {
190
+ strain,
191
+ stressKpa: corrected.stress,
192
+ outOfPlaneStressKpa: corrected.sigmaZKpa,
193
+ compressionPositivePrincipalStressKpa: correctedPrincipal,
194
+ yieldValueKpa: correctedYieldValue,
195
+ yieldResidualRatio: Math.abs(correctedYieldValue) / correctedScale,
196
+ plasticMultiplier,
197
+ equivalentPlasticStrain: plasticMultiplier,
198
+ state: 'plastic',
199
+ };
200
+ }
201
+ function shapeDerivativesNatural(xi, eta) {
202
+ return [
203
+ [-(1 - eta) / 4, -(1 - xi) / 4],
204
+ [(1 - eta) / 4, -(1 + xi) / 4],
205
+ [(1 + eta) / 4, (1 + xi) / 4],
206
+ [-(1 + eta) / 4, (1 - xi) / 4],
207
+ ];
208
+ }
209
+ function solveDenseLinearSystem(matrix, rhs) {
210
+ const n = rhs.length;
211
+ const a = matrix.map((row, index) => [...row, rhs[index]]);
212
+ for (let pivot = 0; pivot < n; pivot += 1) {
213
+ let pivotRow = pivot;
214
+ let pivotAbs = Math.abs(a[pivot][pivot]);
215
+ for (let row = pivot + 1; row < n; row += 1) {
216
+ const candidate = Math.abs(a[row][pivot]);
217
+ if (candidate > pivotAbs) {
218
+ pivotRow = row;
219
+ pivotAbs = candidate;
220
+ }
221
+ }
222
+ if (pivotAbs <= 1e-14)
223
+ throw new Error('Plane-strain global stiffness matrix is singular after boundary conditions.');
224
+ if (pivotRow !== pivot) {
225
+ const tmp = a[pivot];
226
+ a[pivot] = a[pivotRow];
227
+ a[pivotRow] = tmp;
228
+ }
229
+ const divisor = a[pivot][pivot];
230
+ for (let col = pivot; col <= n; col += 1)
231
+ a[pivot][col] /= divisor;
232
+ for (let row = 0; row < n; row += 1) {
233
+ if (row === pivot)
234
+ continue;
235
+ const factor = a[row][pivot];
236
+ if (factor === 0)
237
+ continue;
238
+ for (let col = pivot; col <= n; col += 1) {
239
+ a[row][col] -= factor * a[pivot][col];
240
+ }
241
+ }
242
+ }
243
+ return a.map((row) => row[n]);
244
+ }
245
+ function matVec(matrix, vector) {
246
+ return matrix.map((row) => row.reduce((sum, value, index) => sum + value * vector[index], 0));
247
+ }
248
+ function dot(a, b) {
249
+ return a.reduce((sum, value, index) => sum + value * b[index], 0);
250
+ }
251
+ function elementMatrices(input) {
252
+ const { nodes, material, thicknessM } = input;
253
+ const d = planeStrainD(material);
254
+ const k = Array.from({ length: 8 }, () => new Array(8).fill(0));
255
+ const gauss = [];
256
+ let areaM2 = 0;
257
+ for (const [xi, eta, weight] of GAUSS_POINTS) {
258
+ const derivatives = shapeDerivativesNatural(xi, eta);
259
+ let j11 = 0;
260
+ let j12 = 0;
261
+ let j21 = 0;
262
+ let j22 = 0;
263
+ for (let i = 0; i < 4; i += 1) {
264
+ j11 += derivatives[i][0] * nodes[i].xM;
265
+ j12 += derivatives[i][0] * nodes[i].yM;
266
+ j21 += derivatives[i][1] * nodes[i].xM;
267
+ j22 += derivatives[i][1] * nodes[i].yM;
268
+ }
269
+ const detJ = j11 * j22 - j12 * j21;
270
+ if (!Number.isFinite(detJ) || detJ <= 0) {
271
+ throw new Error('Plane-strain quad4 element has non-positive Jacobian; check node order and geometry.');
272
+ }
273
+ const invJ = [
274
+ [j22 / detJ, -j12 / detJ],
275
+ [-j21 / detJ, j11 / detJ],
276
+ ];
277
+ const b = Array.from({ length: 3 }, () => new Array(8).fill(0));
278
+ for (let i = 0; i < 4; i += 1) {
279
+ const dNdx = invJ[0][0] * derivatives[i][0] + invJ[0][1] * derivatives[i][1];
280
+ const dNdy = invJ[1][0] * derivatives[i][0] + invJ[1][1] * derivatives[i][1];
281
+ b[0][2 * i] = dNdx;
282
+ b[1][2 * i + 1] = dNdy;
283
+ b[2][2 * i] = dNdy;
284
+ b[2][2 * i + 1] = dNdx;
285
+ }
286
+ const dB = d.map((row) => b[0].map((_, col) => row.reduce((sum, value, r) => sum + value * b[r][col], 0)));
287
+ for (let row = 0; row < 8; row += 1) {
288
+ for (let col = 0; col < 8; col += 1) {
289
+ const value = b.reduce((sum, bRow, r) => sum + bRow[row] * dB[r][col], 0);
290
+ k[row][col] += value * detJ * weight * thicknessM;
291
+ }
292
+ }
293
+ areaM2 += detJ * weight;
294
+ gauss.push({ xi, eta, detJ, b });
295
+ }
296
+ return { stiffness: k, areaM2, gauss };
297
+ }
298
+ export function buildPlaneStrainRectangularMesh(input) {
299
+ assertFinitePositive(input.widthM, 'widthM');
300
+ assertFinitePositive(input.heightM, 'heightM');
301
+ const divisionsX = assertPositiveInteger(input.divisionsX, 'divisionsX');
302
+ const divisionsY = assertPositiveInteger(input.divisionsY, 'divisionsY');
303
+ const materialId = input.materialId ?? 'soil';
304
+ const nodes = [];
305
+ const elements = [];
306
+ const nodeId = (ix, iy) => `n-${ix}-${iy}`;
307
+ for (let iy = 0; iy <= divisionsY; iy += 1) {
308
+ for (let ix = 0; ix <= divisionsX; ix += 1) {
309
+ nodes.push({
310
+ id: nodeId(ix, iy),
311
+ xM: (input.widthM * ix) / divisionsX,
312
+ yM: (input.heightM * iy) / divisionsY,
313
+ });
314
+ }
315
+ }
316
+ for (let iy = 0; iy < divisionsY; iy += 1) {
317
+ for (let ix = 0; ix < divisionsX; ix += 1) {
318
+ elements.push({
319
+ id: `e-${ix}-${iy}`,
320
+ materialId,
321
+ nodeIds: [
322
+ nodeId(ix, iy),
323
+ nodeId(ix + 1, iy),
324
+ nodeId(ix + 1, iy + 1),
325
+ nodeId(ix, iy + 1),
326
+ ],
327
+ });
328
+ }
329
+ }
330
+ return { nodes, elements };
331
+ }
332
+ export function runPlaneStrainQuad4Assembly(model) {
333
+ if (model.schemaVersion !== 'fem-plane-strain-model.v1') {
334
+ throw new Error('Only fem-plane-strain-model.v1 is supported.');
335
+ }
336
+ assertArray(model.nodes, 'nodes');
337
+ assertArray(model.elements, 'elements');
338
+ assertArray(model.materials, 'materials');
339
+ assertArray(model.boundaryConditions, 'boundaryConditions');
340
+ if (model.nodes.length < 4)
341
+ throw new Error('Plane-strain model requires at least four nodes.');
342
+ if (model.elements.length < 1)
343
+ throw new Error('Plane-strain model requires at least one element.');
344
+ if (model.materials.length < 1)
345
+ throw new Error('Plane-strain model requires at least one material.');
346
+ if (model.defaultThicknessM != null)
347
+ assertFinitePositive(model.defaultThicknessM, 'defaultThicknessM');
348
+ if (model.nodalLoads != null)
349
+ assertArray(model.nodalLoads, 'nodalLoads');
350
+ assertUniqueIds(model.nodes, 'node');
351
+ assertUniqueIds(model.materials, 'material');
352
+ assertUniqueIds(model.elements, 'element');
353
+ const policy = model.policy ?? DEFAULT_FEM_CONVERGENCE_POLICY;
354
+ validateConvergencePolicy(policy);
355
+ const nodeIndexById = new Map(model.nodes.map((node, index) => [node.id, index]));
356
+ const materialById = new Map(model.materials.map((material) => [material.id, material]));
357
+ const dofCount = model.nodes.length * 2;
358
+ if (dofCount > MAX_DENSE_DOF_COUNT) {
359
+ throw new Error(`Plane-strain dense assembly is capped at ${MAX_DENSE_DOF_COUNT} DOFs for benchmark-scale evidence runs.`);
360
+ }
361
+ const stiffness = Array.from({ length: dofCount }, () => new Array(dofCount).fill(0));
362
+ const loads = new Array(dofCount).fill(0);
363
+ const elementResults = [];
364
+ for (const node of model.nodes) {
365
+ assertFinite(node.xM, `node ${node.id} xM`);
366
+ assertFinite(node.yM, `node ${node.id} yM`);
367
+ }
368
+ for (const material of model.materials) {
369
+ planeStrainD(material);
370
+ if (material.unitWeightKnM3 != null) {
371
+ assertFiniteNonNegative(material.unitWeightKnM3, `material ${material.id} unitWeightKnM3`);
372
+ }
373
+ }
374
+ for (const load of model.nodalLoads ?? []) {
375
+ const nodeIndex = nodeIndexById.get(load.nodeId);
376
+ if (nodeIndex == null)
377
+ throw new Error(`Unknown nodal load node: ${load.nodeId}.`);
378
+ const fxKn = load.fxKn ?? 0;
379
+ const fyKn = load.fyKn ?? 0;
380
+ assertFinite(fxKn, `nodal load ${load.nodeId}.fxKn`);
381
+ assertFinite(fyKn, `nodal load ${load.nodeId}.fyKn`);
382
+ loads[dofIndex(nodeIndex, 'ux')] += fxKn;
383
+ loads[dofIndex(nodeIndex, 'uy')] += fyKn;
384
+ }
385
+ const elementGaussCache = [];
386
+ for (const element of model.elements) {
387
+ if (!Array.isArray(element.nodeIds) || element.nodeIds.length !== 4) {
388
+ throw new Error(`Plane-strain element ${element.id} must reference exactly four nodes.`);
389
+ }
390
+ if (new Set(element.nodeIds).size !== 4) {
391
+ throw new Error(`Plane-strain element ${element.id} has duplicate node references.`);
392
+ }
393
+ assertNonEmptyId(element.materialId, `element ${element.id} material`);
394
+ const nodeIndices = element.nodeIds.map((id) => {
395
+ const index = nodeIndexById.get(id);
396
+ if (index == null)
397
+ throw new Error(`Unknown element node: ${id}.`);
398
+ return index;
399
+ });
400
+ const material = materialById.get(element.materialId);
401
+ if (!material)
402
+ throw new Error(`Unknown element material: ${element.materialId}.`);
403
+ const thicknessM = element.thicknessM ?? model.defaultThicknessM ?? 1;
404
+ assertFinitePositive(thicknessM, `element ${element.id} thicknessM`);
405
+ const nodes = nodeIndices.map((index) => model.nodes[index]);
406
+ const elementData = elementMatrices({ nodes, material, thicknessM });
407
+ const globalDofs = nodeIndices.flatMap((index) => [dofIndex(index, 'ux'), dofIndex(index, 'uy')]);
408
+ for (let localRow = 0; localRow < 8; localRow += 1) {
409
+ for (let localCol = 0; localCol < 8; localCol += 1) {
410
+ stiffness[globalDofs[localRow]][globalDofs[localCol]] += elementData.stiffness[localRow][localCol];
411
+ }
412
+ }
413
+ elementGaussCache.push({
414
+ element,
415
+ globalDofs,
416
+ gauss: elementData.gauss,
417
+ areaM2: elementData.areaM2,
418
+ thicknessM,
419
+ });
420
+ }
421
+ const prescribed = new Map();
422
+ for (const bc of model.boundaryConditions) {
423
+ const nodeIndex = nodeIndexById.get(bc.nodeId);
424
+ if (nodeIndex == null)
425
+ throw new Error(`Unknown boundary-condition node: ${bc.nodeId}.`);
426
+ if (bc.dof !== 'ux' && bc.dof !== 'uy') {
427
+ throw new Error(`Boundary condition ${bc.nodeId} dof must be ux or uy.`);
428
+ }
429
+ const index = dofIndex(nodeIndex, bc.dof);
430
+ const value = bc.valueM ?? 0;
431
+ assertFinite(value, `boundary condition ${bc.nodeId}.${bc.dof}`);
432
+ const existing = prescribed.get(index);
433
+ if (existing != null && Math.abs(existing - value) > 1e-12) {
434
+ throw new Error(`Conflicting boundary condition for ${bc.nodeId}.${bc.dof}.`);
435
+ }
436
+ prescribed.set(index, value);
437
+ }
438
+ if (prescribed.size === 0)
439
+ throw new Error('Plane-strain model requires at least one displacement boundary condition.');
440
+ const constrainedNodeIds = new Set(model.boundaryConditions.map((bc) => bc.nodeId));
441
+ const hasUxConstraint = model.boundaryConditions.some((bc) => bc.dof === 'ux');
442
+ const hasUyConstraint = model.boundaryConditions.some((bc) => bc.dof === 'uy');
443
+ if (prescribed.size < 3 || constrainedNodeIds.size < 2 || !hasUxConstraint || !hasUyConstraint) {
444
+ throw new Error('Plane-strain model has insufficient displacement constraints to restrain rigid-body modes.');
445
+ }
446
+ const freeDofs = Array.from({ length: dofCount }, (_, index) => index).filter((index) => !prescribed.has(index));
447
+ const displacement = new Array(dofCount).fill(0);
448
+ for (const [index, value] of prescribed)
449
+ displacement[index] = value;
450
+ if (freeDofs.length > 0) {
451
+ const reducedK = freeDofs.map((row) => freeDofs.map((col) => stiffness[row][col]));
452
+ const reducedF = freeDofs.map((row) => loads[row] - [...prescribed.entries()]
453
+ .reduce((sum, [col, value]) => sum + stiffness[row][col] * value, 0));
454
+ const solved = solveDenseLinearSystem(reducedK, reducedF);
455
+ for (const [index, dof] of freeDofs.entries())
456
+ displacement[dof] = solved[index];
457
+ }
458
+ const internal = matVec(stiffness, displacement);
459
+ const residual = internal.map((value, index) => value - loads[index]);
460
+ const maxFreeResidualKn = freeDofs.length > 0
461
+ ? Math.max(...freeDofs.map((index) => Math.abs(residual[index])))
462
+ : 0;
463
+ const loadNorm = Math.max(Math.hypot(...loads), Math.hypot(...internal), Math.hypot(...Array.from(prescribed.keys()).map((index) => residual[index])), 1);
464
+ const residualNormRatio = maxFreeResidualKn / loadNorm;
465
+ const externalLoadSumX = loads.filter((_, index) => index % 2 === 0).reduce((sum, value) => sum + value, 0);
466
+ const externalLoadSumY = loads.filter((_, index) => index % 2 === 1).reduce((sum, value) => sum + value, 0);
467
+ const reactionSumX = Array.from(prescribed.keys())
468
+ .filter((index) => index % 2 === 0)
469
+ .reduce((sum, index) => sum + residual[index], 0);
470
+ const reactionSumY = Array.from(prescribed.keys())
471
+ .filter((index) => index % 2 === 1)
472
+ .reduce((sum, index) => sum + residual[index], 0);
473
+ const balanceError = Math.hypot(reactionSumX + externalLoadSumX, reactionSumY + externalLoadSumY);
474
+ const balanceScale = Math.max(Math.hypot(reactionSumX, reactionSumY), Math.hypot(externalLoadSumX, externalLoadSumY), 1);
475
+ const reactionBalanceRatio = 1 - balanceError / balanceScale;
476
+ const elementOutputs = elementGaussCache.map((entry) => {
477
+ const material = materialById.get(entry.element.materialId);
478
+ const d = planeStrainD(material);
479
+ const elementDisplacement = entry.globalDofs.map((index) => displacement[index]);
480
+ const gaussPoints = entry.gauss.map((point, index) => {
481
+ const strain = point.b.map((row) => row.reduce((sum, value, col) => sum + value * elementDisplacement[col], 0));
482
+ const stress = d.map((row) => row.reduce((sum, value, col) => sum + value * strain[col], 0));
483
+ return {
484
+ elementId: entry.element.id,
485
+ gaussPoint: index + 1,
486
+ xi: round(point.xi, 10),
487
+ eta: round(point.eta, 10),
488
+ detJ: round(point.detJ, 10),
489
+ strain: [round(strain[0], 12), round(strain[1], 12), round(strain[2], 12)],
490
+ stressKpa: [round(stress[0], 8), round(stress[1], 8), round(stress[2], 8)],
491
+ };
492
+ });
493
+ return {
494
+ id: entry.element.id,
495
+ areaM2: round(entry.areaM2, 10),
496
+ thicknessM: round(entry.thicknessM, 10),
497
+ gaussPoints,
498
+ };
499
+ });
500
+ return {
501
+ schemaVersion: 'fem-plane-strain-assembly-result.v1',
502
+ method: 'quad4-plane-strain-linear-elastic-global-assembly',
503
+ nodes: model.nodes.map((node, index) => ({
504
+ ...node,
505
+ uxM: round(displacement[dofIndex(index, 'ux')], 12),
506
+ uyM: round(displacement[dofIndex(index, 'uy')], 12),
507
+ rxnXKn: round(residual[dofIndex(index, 'ux')], 8),
508
+ rxnYKn: round(residual[dofIndex(index, 'uy')], 8),
509
+ })),
510
+ elements: elementOutputs,
511
+ dofCount,
512
+ freeDofCount: freeDofs.length,
513
+ constrainedDofCount: prescribed.size,
514
+ maxFreeResidualKn: round(maxFreeResidualKn, 12),
515
+ residualNormRatio: round(residualNormRatio, 12),
516
+ reactionBalanceRatio: round(reactionBalanceRatio, 12),
517
+ strainEnergyKnM: round(0.5 * dot(displacement, internal), 12),
518
+ converged: residualNormRatio <= policy.forceBalanceTolerance,
519
+ policy,
520
+ };
521
+ }
522
+ function assemblePlaneStrainSystem(model) {
523
+ if (model.schemaVersion !== 'fem-plane-strain-model.v1') {
524
+ throw new Error('Only fem-plane-strain-model.v1 is supported.');
525
+ }
526
+ assertArray(model.nodes, 'nodes');
527
+ assertArray(model.elements, 'elements');
528
+ assertArray(model.materials, 'materials');
529
+ assertArray(model.boundaryConditions, 'boundaryConditions');
530
+ if (model.nodes.length < 4)
531
+ throw new Error('Plane-strain model requires at least four nodes.');
532
+ if (model.elements.length < 1)
533
+ throw new Error('Plane-strain model requires at least one element.');
534
+ if (model.materials.length < 1)
535
+ throw new Error('Plane-strain model requires at least one material.');
536
+ if (model.defaultThicknessM != null)
537
+ assertFinitePositive(model.defaultThicknessM, 'defaultThicknessM');
538
+ if (model.nodalLoads != null)
539
+ assertArray(model.nodalLoads, 'nodalLoads');
540
+ assertUniqueIds(model.nodes, 'node');
541
+ assertUniqueIds(model.materials, 'material');
542
+ assertUniqueIds(model.elements, 'element');
543
+ const policy = model.policy ?? DEFAULT_FEM_CONVERGENCE_POLICY;
544
+ validateConvergencePolicy(policy);
545
+ const nodeIndexById = new Map(model.nodes.map((node, index) => [node.id, index]));
546
+ const materialById = new Map(model.materials.map((material) => [material.id, material]));
547
+ const dofCount = model.nodes.length * 2;
548
+ if (dofCount > MAX_DENSE_DOF_COUNT) {
549
+ throw new Error(`Plane-strain dense assembly is capped at ${MAX_DENSE_DOF_COUNT} DOFs for benchmark-scale evidence runs.`);
550
+ }
551
+ const stiffness = Array.from({ length: dofCount }, () => new Array(dofCount).fill(0));
552
+ const loads = new Array(dofCount).fill(0);
553
+ for (const node of model.nodes) {
554
+ assertFinite(node.xM, `node ${node.id} xM`);
555
+ assertFinite(node.yM, `node ${node.id} yM`);
556
+ }
557
+ for (const material of model.materials) {
558
+ planeStrainD(material);
559
+ druckerPragerParameters(material);
560
+ if (material.unitWeightKnM3 != null) {
561
+ assertFiniteNonNegative(material.unitWeightKnM3, `material ${material.id} unitWeightKnM3`);
562
+ }
563
+ }
564
+ for (const load of model.nodalLoads ?? []) {
565
+ const nodeIndex = nodeIndexById.get(load.nodeId);
566
+ if (nodeIndex == null)
567
+ throw new Error(`Unknown nodal load node: ${load.nodeId}.`);
568
+ const fxKn = load.fxKn ?? 0;
569
+ const fyKn = load.fyKn ?? 0;
570
+ assertFinite(fxKn, `nodal load ${load.nodeId}.fxKn`);
571
+ assertFinite(fyKn, `nodal load ${load.nodeId}.fyKn`);
572
+ loads[dofIndex(nodeIndex, 'ux')] += fxKn;
573
+ loads[dofIndex(nodeIndex, 'uy')] += fyKn;
574
+ }
575
+ const elementGaussCache = [];
576
+ for (const element of model.elements) {
577
+ if (!Array.isArray(element.nodeIds) || element.nodeIds.length !== 4) {
578
+ throw new Error(`Plane-strain element ${element.id} must reference exactly four nodes.`);
579
+ }
580
+ if (new Set(element.nodeIds).size !== 4) {
581
+ throw new Error(`Plane-strain element ${element.id} has duplicate node references.`);
582
+ }
583
+ assertNonEmptyId(element.materialId, `element ${element.id} material`);
584
+ const nodeIndices = element.nodeIds.map((id) => {
585
+ const index = nodeIndexById.get(id);
586
+ if (index == null)
587
+ throw new Error(`Unknown element node: ${id}.`);
588
+ return index;
589
+ });
590
+ const material = materialById.get(element.materialId);
591
+ if (!material)
592
+ throw new Error(`Unknown element material: ${element.materialId}.`);
593
+ const thicknessM = element.thicknessM ?? model.defaultThicknessM ?? 1;
594
+ assertFinitePositive(thicknessM, `element ${element.id} thicknessM`);
595
+ const nodes = nodeIndices.map((index) => model.nodes[index]);
596
+ const elementData = elementMatrices({ nodes, material, thicknessM });
597
+ const globalDofs = nodeIndices.flatMap((index) => [dofIndex(index, 'ux'), dofIndex(index, 'uy')]);
598
+ for (let localRow = 0; localRow < 8; localRow += 1) {
599
+ for (let localCol = 0; localCol < 8; localCol += 1) {
600
+ stiffness[globalDofs[localRow]][globalDofs[localCol]] += elementData.stiffness[localRow][localCol];
601
+ }
602
+ }
603
+ elementGaussCache.push({
604
+ element,
605
+ globalDofs,
606
+ gauss: elementData.gauss,
607
+ areaM2: elementData.areaM2,
608
+ thicknessM,
609
+ });
610
+ }
611
+ const prescribed = new Map();
612
+ for (const bc of model.boundaryConditions) {
613
+ const nodeIndex = nodeIndexById.get(bc.nodeId);
614
+ if (nodeIndex == null)
615
+ throw new Error(`Unknown boundary-condition node: ${bc.nodeId}.`);
616
+ if (bc.dof !== 'ux' && bc.dof !== 'uy') {
617
+ throw new Error(`Boundary condition ${bc.nodeId} dof must be ux or uy.`);
618
+ }
619
+ const index = dofIndex(nodeIndex, bc.dof);
620
+ const value = bc.valueM ?? 0;
621
+ assertFinite(value, `boundary condition ${bc.nodeId}.${bc.dof}`);
622
+ const existing = prescribed.get(index);
623
+ if (existing != null && Math.abs(existing - value) > 1e-12) {
624
+ throw new Error(`Conflicting boundary condition for ${bc.nodeId}.${bc.dof}.`);
625
+ }
626
+ prescribed.set(index, value);
627
+ }
628
+ if (prescribed.size === 0)
629
+ throw new Error('Plane-strain model requires at least one displacement boundary condition.');
630
+ const constrainedNodeIds = new Set(model.boundaryConditions.map((bc) => bc.nodeId));
631
+ const hasUxConstraint = model.boundaryConditions.some((bc) => bc.dof === 'ux');
632
+ const hasUyConstraint = model.boundaryConditions.some((bc) => bc.dof === 'uy');
633
+ if (prescribed.size < 3 || constrainedNodeIds.size < 2 || !hasUxConstraint || !hasUyConstraint) {
634
+ throw new Error('Plane-strain model has insufficient displacement constraints to restrain rigid-body modes.');
635
+ }
636
+ const freeDofs = Array.from({ length: dofCount }, (_, index) => index).filter((index) => !prescribed.has(index));
637
+ return {
638
+ policy,
639
+ materialById,
640
+ stiffness,
641
+ loads,
642
+ prescribed,
643
+ freeDofs,
644
+ dofCount,
645
+ elementGaussCache,
646
+ };
647
+ }
648
+ function evaluatePlaneStrainDruckerPragerState(input) {
649
+ const { system, displacement, loadFactor } = input;
650
+ const internal = new Array(system.dofCount).fill(0);
651
+ let maxYieldResidualRatio = 0;
652
+ let maxEquivalentPlasticStrain = 0;
653
+ let plasticGaussPointCount = 0;
654
+ const elements = system.elementGaussCache.map((entry) => {
655
+ const material = system.materialById.get(entry.element.materialId);
656
+ const d = planeStrainD(material);
657
+ const elementDisplacement = entry.globalDofs.map((index) => displacement[index]);
658
+ const gaussPoints = entry.gauss.map((point, index) => {
659
+ const strain = point.b.map((row) => row.reduce((sum, value, col) => sum + value * elementDisplacement[col], 0));
660
+ const elasticStress = d.map((row) => row.reduce((sum, value, col) => sum + value * strain[col], 0));
661
+ const projected = projectDruckerPragerStress({
662
+ material,
663
+ policy: system.policy,
664
+ strain,
665
+ stress: elasticStress,
666
+ sigmaZKpa: planeStrainSigmaZ(material, strain),
667
+ });
668
+ maxYieldResidualRatio = Math.max(maxYieldResidualRatio, projected.yieldResidualRatio);
669
+ maxEquivalentPlasticStrain = Math.max(maxEquivalentPlasticStrain, projected.equivalentPlasticStrain);
670
+ if (projected.state === 'plastic')
671
+ plasticGaussPointCount += 1;
672
+ for (let localDof = 0; localDof < 8; localDof += 1) {
673
+ const force = point.b.reduce((sum, bRow, component) => sum + bRow[localDof] * projected.stressKpa[component], 0) * point.detJ * entry.thicknessM;
674
+ internal[entry.globalDofs[localDof]] += force;
675
+ }
676
+ return {
677
+ elementId: entry.element.id,
678
+ gaussPoint: index + 1,
679
+ xi: round(point.xi, 10),
680
+ eta: round(point.eta, 10),
681
+ detJ: round(point.detJ, 10),
682
+ strain: [
683
+ round(projected.strain[0], 12),
684
+ round(projected.strain[1], 12),
685
+ round(projected.strain[2], 12),
686
+ ],
687
+ stressKpa: [
688
+ round(projected.stressKpa[0], 8),
689
+ round(projected.stressKpa[1], 8),
690
+ round(projected.stressKpa[2], 8),
691
+ ],
692
+ outOfPlaneStressKpa: round(projected.outOfPlaneStressKpa, 8),
693
+ compressionPositivePrincipalStressKpa: [
694
+ round(projected.compressionPositivePrincipalStressKpa[0], 8),
695
+ round(projected.compressionPositivePrincipalStressKpa[1], 8),
696
+ round(projected.compressionPositivePrincipalStressKpa[2], 8),
697
+ ],
698
+ yieldValueKpa: round(projected.yieldValueKpa, 10),
699
+ yieldResidualRatio: round(projected.yieldResidualRatio, 12),
700
+ plasticMultiplier: round(projected.plasticMultiplier, 12),
701
+ equivalentPlasticStrain: round(projected.equivalentPlasticStrain, 12),
702
+ state: projected.state,
703
+ };
704
+ });
705
+ return {
706
+ id: entry.element.id,
707
+ areaM2: round(entry.areaM2, 10),
708
+ thicknessM: round(entry.thicknessM, 10),
709
+ gaussPoints,
710
+ };
711
+ });
712
+ const loadsAtStep = system.loads.map((load) => load * loadFactor);
713
+ const residual = internal.map((value, index) => value - loadsAtStep[index]);
714
+ const maxFreeResidualKn = system.freeDofs.length > 0
715
+ ? Math.max(...system.freeDofs.map((index) => Math.abs(residual[index])))
716
+ : 0;
717
+ const loadNorm = Math.max(Math.hypot(...loadsAtStep), Math.hypot(...internal), Math.hypot(...Array.from(system.prescribed.keys()).map((index) => residual[index])), 1);
718
+ const residualNormRatio = maxFreeResidualKn / loadNorm;
719
+ const externalLoadSumX = loadsAtStep.filter((_, index) => index % 2 === 0).reduce((sum, value) => sum + value, 0);
720
+ const externalLoadSumY = loadsAtStep.filter((_, index) => index % 2 === 1).reduce((sum, value) => sum + value, 0);
721
+ const reactionSumX = Array.from(system.prescribed.keys())
722
+ .filter((index) => index % 2 === 0)
723
+ .reduce((sum, index) => sum + residual[index], 0);
724
+ const reactionSumY = Array.from(system.prescribed.keys())
725
+ .filter((index) => index % 2 === 1)
726
+ .reduce((sum, index) => sum + residual[index], 0);
727
+ const balanceError = Math.hypot(reactionSumX + externalLoadSumX, reactionSumY + externalLoadSumY);
728
+ const balanceScale = Math.max(Math.hypot(reactionSumX, reactionSumY), Math.hypot(externalLoadSumX, externalLoadSumY), 1);
729
+ const reactionBalanceRatio = 1 - balanceError / balanceScale;
730
+ return {
731
+ internal,
732
+ residual,
733
+ maxFreeResidualKn,
734
+ residualNormRatio,
735
+ reactionBalanceRatio,
736
+ maxYieldResidualRatio,
737
+ maxEquivalentPlasticStrain,
738
+ plasticGaussPointCount,
739
+ elements,
740
+ };
741
+ }
742
+ function normalizeLoadStepFractions(loadStepFractions) {
743
+ const fractions = loadStepFractions && loadStepFractions.length > 0
744
+ ? [...loadStepFractions]
745
+ : [0.25, 0.5, 0.75, 1];
746
+ let previous = 0;
747
+ for (const [index, fraction] of fractions.entries()) {
748
+ if (!Number.isFinite(fraction) || fraction <= previous || fraction > 1) {
749
+ throw new Error(`loadStepFractions.${index} must be finite, increasing, and between 0 and 1.`);
750
+ }
751
+ previous = fraction;
752
+ }
753
+ if (fractions[fractions.length - 1] !== 1) {
754
+ throw new Error('loadStepFractions must end at 1.');
755
+ }
756
+ return fractions;
757
+ }
758
+ export function runPlaneStrainDruckerPragerLoadSteps(model, options = {}) {
759
+ const system = assemblePlaneStrainSystem(model);
760
+ const loadStepFractions = normalizeLoadStepFractions(options.loadStepFractions);
761
+ const reducedK = system.freeDofs.map((row) => system.freeDofs.map((col) => system.stiffness[row][col]));
762
+ const displacement = new Array(system.dofCount).fill(0);
763
+ let finalEvaluation;
764
+ const loadSteps = [];
765
+ for (const [stepIndex, loadFactor] of loadStepFractions.entries()) {
766
+ for (const [index, value] of system.prescribed)
767
+ displacement[index] = value * loadFactor;
768
+ let evaluation = evaluatePlaneStrainDruckerPragerState({ system, displacement, loadFactor });
769
+ let iterations = 0;
770
+ let converged = evaluation.residualNormRatio <= system.policy.forceBalanceTolerance &&
771
+ evaluation.maxYieldResidualRatio <= system.policy.residualTolerance;
772
+ while (!converged && iterations < system.policy.maxIterations) {
773
+ iterations += 1;
774
+ if (system.freeDofs.length === 0)
775
+ break;
776
+ const correctionRhs = system.freeDofs.map((index) => -evaluation.residual[index]);
777
+ const correction = solveDenseLinearSystem(reducedK, correctionRhs);
778
+ for (const [correctionIndex, dof] of system.freeDofs.entries()) {
779
+ displacement[dof] += correction[correctionIndex];
780
+ }
781
+ for (const [index, value] of system.prescribed)
782
+ displacement[index] = value * loadFactor;
783
+ evaluation = evaluatePlaneStrainDruckerPragerState({ system, displacement, loadFactor });
784
+ converged = evaluation.residualNormRatio <= system.policy.forceBalanceTolerance &&
785
+ evaluation.maxYieldResidualRatio <= system.policy.residualTolerance;
786
+ }
787
+ finalEvaluation = evaluation;
788
+ loadSteps.push({
789
+ step: stepIndex + 1,
790
+ loadFactor: round(loadFactor, 8),
791
+ iterations,
792
+ maxFreeResidualKn: round(evaluation.maxFreeResidualKn, 12),
793
+ residualNormRatio: round(evaluation.residualNormRatio, 12),
794
+ reactionBalanceRatio: round(evaluation.reactionBalanceRatio, 12),
795
+ maxYieldResidualRatio: round(evaluation.maxYieldResidualRatio, 12),
796
+ maxEquivalentPlasticStrain: round(evaluation.maxEquivalentPlasticStrain, 12),
797
+ plasticGaussPointCount: evaluation.plasticGaussPointCount,
798
+ converged,
799
+ });
800
+ }
801
+ if (!finalEvaluation) {
802
+ throw new Error('Plane-strain nonlinear load-step solver requires at least one load step.');
803
+ }
804
+ return {
805
+ schemaVersion: 'fem-plane-strain-drucker-prager-result.v1',
806
+ method: 'quad4-plane-strain-drucker-prager-modified-newton',
807
+ nodes: model.nodes.map((node, index) => ({
808
+ ...node,
809
+ uxM: round(displacement[dofIndex(index, 'ux')], 12),
810
+ uyM: round(displacement[dofIndex(index, 'uy')], 12),
811
+ rxnXKn: round(finalEvaluation.residual[dofIndex(index, 'ux')], 8),
812
+ rxnYKn: round(finalEvaluation.residual[dofIndex(index, 'uy')], 8),
813
+ })),
814
+ elements: finalEvaluation.elements,
815
+ dofCount: system.dofCount,
816
+ freeDofCount: system.freeDofs.length,
817
+ constrainedDofCount: system.prescribed.size,
818
+ loadSteps,
819
+ maxFreeResidualKn: round(finalEvaluation.maxFreeResidualKn, 12),
820
+ residualNormRatio: round(finalEvaluation.residualNormRatio, 12),
821
+ reactionBalanceRatio: round(finalEvaluation.reactionBalanceRatio, 12),
822
+ maxYieldResidualRatio: round(finalEvaluation.maxYieldResidualRatio, 12),
823
+ maxEquivalentPlasticStrain: round(finalEvaluation.maxEquivalentPlasticStrain, 12),
824
+ plasticGaussPointCount: finalEvaluation.plasticGaussPointCount,
825
+ converged: loadSteps.every((step) => step.converged),
826
+ policy: system.policy,
827
+ limitations: [
828
+ 'Benchmark-scale modified-Newton plane-strain plasticity evidence kernel only.',
829
+ '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.',
830
+ 'Use for deterministic evidence and regression tests only until independent published/commercial benchmark comparison and licensed production approval gates are complete.',
831
+ ],
832
+ };
833
+ }
834
+ //# sourceMappingURL=plane-strain-assembly.js.map