@geotechcli/core 0.4.105 → 0.4.107

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/fem/demo.d.ts +2 -0
  2. package/dist/fem/demo.d.ts.map +1 -1
  3. package/dist/fem/demo.js +414 -0
  4. package/dist/fem/demo.js.map +1 -1
  5. package/dist/fem/engineering-evidence.d.ts.map +1 -1
  6. package/dist/fem/engineering-evidence.js +9 -7
  7. package/dist/fem/engineering-evidence.js.map +1 -1
  8. package/dist/fem/index.d.ts +2 -2
  9. package/dist/fem/index.d.ts.map +1 -1
  10. package/dist/fem/index.js +1 -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 +35 -0
  16. package/dist/fem/plane-strain-assembly.d.ts.map +1 -1
  17. package/dist/fem/plane-strain-assembly.js +110 -14
  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 +7 -6
  21. package/dist/fem/production-readiness.js.map +1 -1
  22. package/dist/fem/routing.d.ts +15 -0
  23. package/dist/fem/routing.d.ts.map +1 -1
  24. package/dist/fem/routing.js +147 -14
  25. package/dist/fem/routing.js.map +1 -1
  26. package/dist/fem/types.d.ts +98 -6
  27. package/dist/fem/types.d.ts.map +1 -1
  28. package/dist/fem/validation.d.ts.map +1 -1
  29. package/dist/fem/validation.js +438 -22
  30. package/dist/fem/validation.js.map +1 -1
  31. package/dist/ingest/job-store.js +1 -1
  32. package/dist/ingest/job-store.js.map +1 -1
  33. package/dist/meta/metadata.json +1 -1
  34. package/dist/verifier/findings.js +6 -7
  35. package/dist/verifier/findings.js.map +1 -1
  36. package/package.json +1 -1
@@ -19,6 +19,20 @@ function isNonEmptyString(value) {
19
19
  }
20
20
  const FEM_WEBGL_UINT16_INDEX_LIMIT = 65_535;
21
21
  const FEM_MAX_PREVIEW_MESH_NODES = FEM_WEBGL_UINT16_INDEX_LIMIT + 1;
22
+ const FEM_MIN_BIOT_TRANSIENT_STEPS = 3;
23
+ const FEM_MAX_BIOT_TIME_STEP_GROWTH_RATIO = 8;
24
+ function expectedMeshCounts(mesh) {
25
+ if (mesh.elementType === 'quad4_plane_strain') {
26
+ return {
27
+ nodes: (mesh.divisionsX + 1) * (mesh.divisionsY + 1),
28
+ elements: mesh.divisionsX * mesh.divisionsY,
29
+ };
30
+ }
31
+ return {
32
+ nodes: (mesh.divisionsX + 1) * (mesh.divisionsY + 1) * (mesh.divisionsZ + 1),
33
+ elements: mesh.divisionsX * mesh.divisionsY * mesh.divisionsZ,
34
+ };
35
+ }
22
36
  function isFiniteNumber(value) {
23
37
  return typeof value === 'number' && Number.isFinite(value);
24
38
  }
@@ -53,7 +67,7 @@ function isFemAnalysisCaseShape(value) {
53
67
  const groundwater = value.groundwater;
54
68
  return (isRecord(geometry) &&
55
69
  isRecord(geometry.domain) &&
56
- (isRecord(geometry.raft) || isRecord(geometry.excavation) || isRecord(geometry.tunnel) || isRecord(geometry.consolidation)) &&
70
+ (isRecord(geometry.raft) || isRecord(geometry.excavation) || isRecord(geometry.tunnel) || isRecord(geometry.consolidation) || isRecord(geometry.biot)) &&
57
71
  isRecord(mesh) &&
58
72
  isRecord(groundwater) &&
59
73
  isRecord(value.units) &&
@@ -173,7 +187,7 @@ function validateOptionalResultMetadata(findings, manifest, nodeCount, outlineNo
173
187
  const validFieldLocations = new Set(['surface_nodes', 'outline_nodes', 'envelope']);
174
188
  const validFieldQuantities = new Set(['displacement', 'reaction', 'load', 'stage_count', 'pore_pressure', 'degree_of_consolidation', 'strength_ratio']);
175
189
  const validFieldComponents = new Set(['x', 'y', 'z', 'magnitude']);
176
- const validDatasetSources = new Set(['visualization.disp', 'visualization.frame', 'envelope']);
190
+ const validDatasetSources = new Set(['visualization.disp', 'visualization.frame', 'visualization.scalar-frame', 'envelope']);
177
191
  const fieldIds = new Set();
178
192
  const stepIds = new Set();
179
193
  const stepIndexes = new Set();
@@ -209,6 +223,21 @@ function validateOptionalResultMetadata(findings, manifest, nodeCount, outlineNo
209
223
  ['max_mobilized_strength_ratio', manifest.envelope.maxMobilizedStrengthRatio],
210
224
  ['drainage_path', manifest.envelope.drainagePathM],
211
225
  ['consolidation_duration', manifest.envelope.consolidationDurationYears],
226
+ ['time_step_count', manifest.envelope.timeStepCount],
227
+ ['min_pore_pressure', manifest.envelope.minPorePressureKpa],
228
+ ['max_pore_pressure', manifest.envelope.maxPorePressureKpa],
229
+ ['max_biot_coupling', manifest.envelope.maxBiotCouplingKpa],
230
+ ['pore_pressure_mass_balance_error_ratio', manifest.envelope.porePressureMassBalanceErrorRatio],
231
+ ['max_free_pore_pressure_residual', manifest.envelope.maxFreePorePressureResidualM3PerS],
232
+ ['free_pore_pressure_residual_l1', manifest.envelope.freePorePressureResidualL1M3PerS],
233
+ ['prescribed_pore_pressure_residual_l1', manifest.envelope.prescribedPorePressureResidualL1M3PerS],
234
+ ['average_pore_pressure', manifest.envelope.averagePorePressureKpa],
235
+ ['average_free_pore_pressure', manifest.envelope.averageFreePorePressureKpa],
236
+ ['pore_pressure_dissipation_ratio', manifest.envelope.porePressureDissipationRatio],
237
+ ['max_pore_pressure_change_rate', manifest.envelope.maxPorePressureChangeRateKpaPerS],
238
+ ['coupled_unknown_count', manifest.envelope.coupledUnknownCount],
239
+ ['displacement_dof_count', manifest.envelope.displacementDofCount],
240
+ ['pore_pressure_dof_count', manifest.envelope.porePressureDofCount],
212
241
  ['tunnel_diameter', manifest.envelope.tunnelDiameterM],
213
242
  ['tunnel_axis_depth', manifest.envelope.tunnelAxisDepthM],
214
243
  ['volume_loss', manifest.envelope.volumeLossPercent],
@@ -235,8 +264,11 @@ function validateOptionalResultMetadata(findings, manifest, nodeCount, outlineNo
235
264
  if (fieldInfo.component != null && !validFieldComponents.has(String(fieldInfo.component))) {
236
265
  findings.push(finding('blocker', `result.fields.${index}.component.invalid`, `Unsupported result field component: ${String(fieldInfo.component)}.`));
237
266
  }
238
- if (fieldInfo.location !== 'envelope' && fieldInfo.quantity !== 'displacement') {
239
- findings.push(finding('blocker', `result.fields.${index}.node-quantity-invalid`, 'Surface and outline node result fields must describe displacement quantities.'));
267
+ if (fieldInfo.location !== 'envelope' && fieldInfo.quantity !== 'displacement' && fieldInfo.quantity !== 'pore_pressure') {
268
+ findings.push(finding('blocker', `result.fields.${index}.node-quantity-invalid`, 'Surface and outline node result fields must describe displacement or pore-pressure quantities.'));
269
+ }
270
+ if (fieldInfo.location === 'outline_nodes' && fieldInfo.quantity === 'pore_pressure') {
271
+ findings.push(finding('blocker', `result.fields.${index}.outline-pore-pressure-invalid`, 'Pore-pressure result fields must be surface-node scalar fields.'));
240
272
  }
241
273
  if (fieldInfo.location !== 'envelope' && fieldInfo.quantity === 'displacement' && fieldInfo.component == null) {
242
274
  findings.push(finding('blocker', `result.fields.${index}.component.missing`, 'Node displacement result fields must include a displacement component.'));
@@ -271,6 +303,7 @@ function validateOptionalResultMetadata(findings, manifest, nodeCount, outlineNo
271
303
  }
272
304
  }
273
305
  }
306
+ let previousTimeSeconds;
274
307
  if (Array.isArray(steps)) {
275
308
  for (const [index, step] of steps.entries()) {
276
309
  const id = pushUniqueStringFinding(findings, stepIds, step.id, `result.steps.${index}.id`, 'Result step id');
@@ -294,6 +327,26 @@ function validateOptionalResultMetadata(findings, manifest, nodeCount, outlineNo
294
327
  if (step.depthM != null && (!Number.isFinite(step.depthM) || step.depthM < 0)) {
295
328
  findings.push(finding('blocker', `result.steps.${index}.depth-invalid`, 'Result step depth must be finite and non-negative when present.'));
296
329
  }
330
+ if (step.timeSeconds != null && (!Number.isFinite(step.timeSeconds) || step.timeSeconds < 0)) {
331
+ findings.push(finding('blocker', `result.steps.${index}.time-invalid`, 'Result step timeSeconds must be finite and non-negative when present.'));
332
+ }
333
+ if (step.deltaTimeSeconds != null && (!Number.isFinite(step.deltaTimeSeconds) || step.deltaTimeSeconds <= 0)) {
334
+ findings.push(finding('blocker', `result.steps.${index}.delta-time-invalid`, 'Result step deltaTimeSeconds must be finite and positive when present.'));
335
+ }
336
+ if (manifest.analysisCase.objective === 'seepage_groundwater_coupling') {
337
+ if (!isFiniteNumber(step.timeSeconds) || step.timeSeconds <= 0) {
338
+ findings.push(finding('blocker', `result.steps.${index}.time-required`, 'Biot consolidation result steps must carry a positive timeSeconds value.'));
339
+ }
340
+ if (!isFiniteNumber(step.deltaTimeSeconds) || step.deltaTimeSeconds <= 0) {
341
+ findings.push(finding('blocker', `result.steps.${index}.delta-time-required`, 'Biot consolidation result steps must carry a positive deltaTimeSeconds value.'));
342
+ }
343
+ }
344
+ if (isFiniteNumber(step.timeSeconds)) {
345
+ if (previousTimeSeconds != null && step.timeSeconds <= previousTimeSeconds) {
346
+ findings.push(finding('blocker', `result.steps.${index}.time-order-invalid`, 'Result step timeSeconds values must increase monotonically.'));
347
+ }
348
+ previousTimeSeconds = step.timeSeconds;
349
+ }
297
350
  if (manifest.analysisCase.objective === 'excavation_deformation') {
298
351
  if (!step.analysisStageId) {
299
352
  findings.push(finding('blocker', `result.steps.${index}.stage-required`, 'Excavation result steps must reference an analysis stage.'));
@@ -367,15 +420,31 @@ function validateOptionalResultMetadata(findings, manifest, nodeCount, outlineNo
367
420
  findings.push(finding('blocker', `result.datasets.${index}.source.invalid`, `Unsupported result dataset source: ${String(dataset.source)}.`));
368
421
  }
369
422
  const location = fieldLocations.get(dataset.fieldId);
370
- if ((dataset.source === 'visualization.disp' || dataset.source === 'visualization.frame') && dataset.stepId == null) {
423
+ const fieldInfo = fieldInfoById.get(dataset.fieldId);
424
+ const isVisualizationDataset = dataset.source === 'visualization.disp' ||
425
+ dataset.source === 'visualization.frame' ||
426
+ dataset.source === 'visualization.scalar-frame';
427
+ if (isVisualizationDataset && dataset.stepId == null) {
371
428
  findings.push(finding('blocker', `result.datasets.${index}.step-required`, 'Visualization datasets must reference a result step.'));
372
429
  }
373
- if ((dataset.source === 'visualization.disp' || dataset.source === 'visualization.frame') && location === 'envelope') {
430
+ if (isVisualizationDataset && location === 'envelope') {
374
431
  findings.push(finding('blocker', `result.datasets.${index}.visualization-field-invalid`, 'Visualization datasets must reference surface or outline node result fields, not envelope fields.'));
375
432
  }
376
433
  if (dataset.source === 'envelope' && location !== 'envelope') {
377
434
  findings.push(finding('blocker', `result.datasets.${index}.envelope-field-invalid`, 'Envelope datasets must reference an envelope result field.'));
378
435
  }
436
+ if ((dataset.source === 'visualization.disp' || dataset.source === 'visualization.frame') && dataset.stride !== 3) {
437
+ findings.push(finding('blocker', `result.datasets.${index}.visualization-stride-invalid`, 'Displacement visualization datasets must use stride 3.'));
438
+ }
439
+ if ((dataset.source === 'visualization.disp' || dataset.source === 'visualization.frame') && fieldInfo?.quantity !== 'displacement') {
440
+ findings.push(finding('blocker', `result.datasets.${index}.visualization-quantity-invalid`, 'Displacement visualization datasets must reference displacement result fields.'));
441
+ }
442
+ if (dataset.source === 'visualization.scalar-frame' && dataset.stride !== 1) {
443
+ findings.push(finding('blocker', `result.datasets.${index}.scalar-stride-invalid`, 'Scalar-frame visualization datasets must use stride 1.'));
444
+ }
445
+ if (dataset.source === 'visualization.scalar-frame' && fieldInfo?.quantity !== 'pore_pressure') {
446
+ findings.push(finding('blocker', `result.datasets.${index}.scalar-quantity-invalid`, 'Scalar-frame visualization datasets must reference pore-pressure result fields.'));
447
+ }
379
448
  if (dataset.source === 'visualization.disp' && !arraysApproximatelyEqual(dataset.values, manifest.visualization.disp, 1e-12)) {
380
449
  findings.push(finding('blocker', `result.datasets.${index}.disp-values-mismatch`, 'Visualization displacement dataset values must match the manifest visualization displacement array.'));
381
450
  }
@@ -388,6 +457,18 @@ function validateOptionalResultMetadata(findings, manifest, nodeCount, outlineNo
388
457
  findings.push(finding('blocker', `result.datasets.${index}.frame-values-mismatch`, 'Visualization frame dataset values must match the referenced frame displacement array.'));
389
458
  }
390
459
  }
460
+ if (dataset.source === 'visualization.scalar-frame' && dataset.stepId != null) {
461
+ const frame = frameByKey.get(`${dataset.fieldId}:${dataset.stepId}`);
462
+ if (!frame) {
463
+ findings.push(finding('blocker', `result.datasets.${index}.scalar-frame-missing`, 'Scalar-frame dataset has no matching frame payload.'));
464
+ }
465
+ else if (!Array.isArray(frame.scalarValues)) {
466
+ findings.push(finding('blocker', `result.datasets.${index}.scalar-frame-values-missing`, 'Scalar-frame dataset must reference a frame with scalarValues.'));
467
+ }
468
+ else if (!arraysApproximatelyEqual(dataset.values, frame.scalarValues, 1e-12)) {
469
+ findings.push(finding('blocker', `result.datasets.${index}.scalar-frame-values-mismatch`, 'Scalar-frame dataset values must match the referenced frame scalarValues array.'));
470
+ }
471
+ }
391
472
  const valueCount = dataset.values.length / dataset.stride;
392
473
  if (location === 'surface_nodes' && valueCount !== nodeCount) {
393
474
  findings.push(finding('blocker', `result.datasets.${index}.surface-count-mismatch`, 'Surface-node dataset values must match visualization base node count.'));
@@ -480,7 +561,7 @@ export function validateFemAnalysisCase(caseFile) {
480
561
  finding('blocker', 'schema.shape-invalid', 'FEM analysis case is missing required geometry, mesh, groundwater, or array fields.'),
481
562
  ]);
482
563
  }
483
- const { domain, raft, excavation, tunnel, consolidation } = caseFile.geometry;
564
+ const { domain, raft, excavation, tunnel, consolidation, biot } = caseFile.geometry;
484
565
  if (caseFile.schemaVersion !== 'fem-analysis-case.v0') {
485
566
  findings.push(finding('blocker', 'schema.unsupported', 'Only fem-analysis-case.v0 is supported.'));
486
567
  }
@@ -499,7 +580,7 @@ export function validateFemAnalysisCase(caseFile) {
499
580
  if (!caseFile.experimental) {
500
581
  findings.push(finding('blocker', 'mode.experimental-required', 'FEM cases must be explicitly marked experimental.'));
501
582
  }
502
- if (!['foundation_settlement', 'excavation_deformation', 'tunnel_volume_loss_settlement', 'staged_settlement_consolidation'].includes(caseFile.objective)) {
583
+ if (!['foundation_settlement', 'excavation_deformation', 'tunnel_volume_loss_settlement', 'staged_settlement_consolidation', 'seepage_groundwater_coupling'].includes(caseFile.objective)) {
503
584
  findings.push(finding('blocker', 'objective.unsupported', `Unsupported FEM objective: ${caseFile.objective}.`));
504
585
  }
505
586
  if (caseFile.objective === 'foundation_settlement' && caseFile.analysisType !== 'static_3d_small_strain') {
@@ -514,6 +595,9 @@ export function validateFemAnalysisCase(caseFile) {
514
595
  if (caseFile.objective === 'staged_settlement_consolidation' && caseFile.analysisType !== 'time_dependent_1d_consolidation') {
515
596
  findings.push(finding('blocker', 'analysis.unsupported', `Unsupported analysis type: ${caseFile.analysisType}.`));
516
597
  }
598
+ if (caseFile.objective === 'seepage_groundwater_coupling' && caseFile.analysisType !== 'time_dependent_2d_biot_consolidation') {
599
+ findings.push(finding('blocker', 'analysis.unsupported', `Unsupported analysis type: ${caseFile.analysisType}.`));
600
+ }
517
601
  if (caseFile.geometry.domain.type !== 'box') {
518
602
  findings.push(finding('blocker', 'geometry.domain.type-invalid', `Unsupported domain type: ${String(caseFile.geometry.domain.type)}.`));
519
603
  }
@@ -691,6 +775,75 @@ export function validateFemAnalysisCase(caseFile) {
691
775
  findings.push(finding('review', 'consolidation.1d-preview', 'Staged consolidation preview uses a 1D Terzaghi column and Mohr-Coulomb material-point review gate; it is not a full 2D/3D coupled FEM consolidation solver.'));
692
776
  }
693
777
  }
778
+ if (caseFile.objective === 'seepage_groundwater_coupling') {
779
+ if (!biot) {
780
+ findings.push(finding('blocker', 'geometry.biot-missing', 'Seepage/groundwater coupling cases require plane-strain Biot geometry.'));
781
+ }
782
+ else {
783
+ if (biot.type !== 'plane_strain_biot_column') {
784
+ findings.push(finding('blocker', 'geometry.biot.type-invalid', `Unsupported Biot geometry type: ${String(biot.type)}.`));
785
+ }
786
+ pushPositiveNumberFindings(findings, [
787
+ [biot.widthM, 'geometry.biot.width', 'Biot column width'],
788
+ [biot.heightM, 'geometry.biot.height', 'Biot column height'],
789
+ [biot.thicknessM, 'geometry.biot.thickness', 'Biot column thickness'],
790
+ ]);
791
+ pushFiniteNumberFinding(findings, biot.initialPorePressureKpa, 'geometry.biot.initial-pore-pressure', 'Initial pore pressure', { nonNegative: true });
792
+ if (!isApproxEqual(domain.lengthM, biot.widthM, 1e-9)) {
793
+ findings.push(finding('blocker', 'geometry.biot.domain-width-mismatch', 'Biot column width must match the box-domain length.'));
794
+ }
795
+ if (!isApproxEqual(domain.depthM, biot.heightM, 1e-9)) {
796
+ findings.push(finding('blocker', 'geometry.biot.domain-height-mismatch', 'Biot column height must match the box-domain depth.'));
797
+ }
798
+ if (!isApproxEqual(domain.widthM, biot.thicknessM, 1e-9)) {
799
+ findings.push(finding('blocker', 'geometry.biot.domain-thickness-mismatch', 'Biot column thickness must match the box-domain width.'));
800
+ }
801
+ if (!Array.isArray(biot.timeStepsSeconds) || biot.timeStepsSeconds.length === 0) {
802
+ findings.push(finding('blocker', 'geometry.biot.time-steps-missing', 'Biot consolidation cases require at least one positive time step.'));
803
+ }
804
+ else {
805
+ if (biot.timeStepsSeconds.length < FEM_MIN_BIOT_TRANSIENT_STEPS) {
806
+ findings.push(finding('blocker', 'geometry.biot.time-steps.too-few', `Biot consolidation cases require at least ${FEM_MIN_BIOT_TRANSIENT_STEPS} accepted transient steps for preview diagnostics.`));
807
+ }
808
+ let previousStepSeconds = 0;
809
+ let previousDeltaSeconds;
810
+ for (const [index, timeSeconds] of biot.timeStepsSeconds.entries()) {
811
+ if (!Number.isFinite(timeSeconds) || timeSeconds <= previousStepSeconds) {
812
+ findings.push(finding('blocker', `geometry.biot.time-steps.${index}.invalid`, 'Biot time steps must be finite, positive, and strictly increasing.'));
813
+ break;
814
+ }
815
+ const deltaSeconds = timeSeconds - previousStepSeconds;
816
+ if (previousDeltaSeconds != null &&
817
+ deltaSeconds / previousDeltaSeconds > FEM_MAX_BIOT_TIME_STEP_GROWTH_RATIO) {
818
+ findings.push(finding('blocker', `geometry.biot.time-steps.${index}.growth-ratio`, `Biot time-step growth ratio must not exceed ${FEM_MAX_BIOT_TIME_STEP_GROWTH_RATIO} between accepted steps.`));
819
+ break;
820
+ }
821
+ previousStepSeconds = timeSeconds;
822
+ previousDeltaSeconds = deltaSeconds;
823
+ }
824
+ }
825
+ if (!Array.isArray(biot.porePressureBoundaries) || biot.porePressureBoundaries.length === 0) {
826
+ findings.push(finding('blocker', 'geometry.biot.pressure-boundary-missing', 'Biot consolidation cases require at least one prescribed pore-pressure boundary.'));
827
+ }
828
+ else {
829
+ const boundaryIds = new Set();
830
+ const validPressureBoundaries = new Set(['top', 'bottom', 'left', 'right']);
831
+ for (const [index, boundary] of biot.porePressureBoundaries.entries()) {
832
+ const prefix = `geometry.biot.pressure-boundaries.${index}`;
833
+ if (!isRecord(boundary)) {
834
+ findings.push(finding('blocker', `${prefix}.shape-invalid`, 'Pore-pressure boundary must be an object.'));
835
+ continue;
836
+ }
837
+ pushUniqueStringFinding(findings, boundaryIds, boundary.id, `${prefix}.id`, 'Pore-pressure boundary id');
838
+ if (!validPressureBoundaries.has(String(boundary.boundary))) {
839
+ findings.push(finding('blocker', `${prefix}.boundary-invalid`, `Unsupported pore-pressure boundary: ${String(boundary.boundary)}.`));
840
+ }
841
+ pushFiniteNumberFinding(findings, boundary.porePressureKpa, `${prefix}.pore-pressure`, 'Prescribed pore pressure', { nonNegative: true });
842
+ }
843
+ }
844
+ findings.push(finding('review', 'biot.up-preview-only', 'Seepage/groundwater coupling uses an experimental deterministic 2D Biot u-p preview and is not production design evidence.'));
845
+ }
846
+ }
694
847
  if (caseFile.materials.length === 0) {
695
848
  findings.push(finding('blocker', 'material.missing', 'At least one material is required.'));
696
849
  }
@@ -733,6 +886,28 @@ export function validateFemAnalysisCase(caseFile) {
733
886
  pushFiniteNumberFinding(findings, material.hydraulicConductivityMPerS, `${prefix}.hydraulic-conductivity`, 'Hydraulic conductivity', { positive: true });
734
887
  }
735
888
  }
889
+ if (caseFile.objective === 'seepage_groundwater_coupling') {
890
+ if (material.model !== 'linear_elastic') {
891
+ findings.push(finding('blocker', `${prefix}.biot-model-required`, 'Biot u-p preview requires a linear_elastic material with hydraulic coupling parameters.'));
892
+ }
893
+ const hasHydraulicX = material.hydraulicConductivityXMPerS != null || material.hydraulicConductivityMPerS != null;
894
+ if (!hasHydraulicX) {
895
+ findings.push(finding('blocker', `${prefix}.hydraulic-conductivity-x-missing`, 'Biot u-p preview requires hydraulic conductivity in the x direction or an isotropic hydraulic conductivity.'));
896
+ }
897
+ else {
898
+ pushFiniteNumberFinding(findings, material.hydraulicConductivityXMPerS ?? material.hydraulicConductivityMPerS, `${prefix}.hydraulic-conductivity-x`, 'Hydraulic conductivity X', { positive: true });
899
+ }
900
+ if (material.hydraulicConductivityYMPerS != null) {
901
+ pushFiniteNumberFinding(findings, material.hydraulicConductivityYMPerS, `${prefix}.hydraulic-conductivity-y`, 'Hydraulic conductivity Y', { positive: true });
902
+ }
903
+ if (material.hydraulicConductivityMPerS != null) {
904
+ pushFiniteNumberFinding(findings, material.hydraulicConductivityMPerS, `${prefix}.hydraulic-conductivity`, 'Isotropic hydraulic conductivity', { positive: true });
905
+ }
906
+ if (!isFiniteNumber(material.biotCoefficient) || material.biotCoefficient < 0 || material.biotCoefficient > 1) {
907
+ findings.push(finding('blocker', `${prefix}.biot-coefficient-invalid`, 'Biot coefficient must be finite and between 0 and 1.'));
908
+ }
909
+ pushFiniteNumberFinding(findings, material.specificStorage1PerM, `${prefix}.specific-storage`, 'Specific storage', { positive: true });
910
+ }
736
911
  validateEvidenceRefs(findings, material.evidenceRefs, prefix);
737
912
  validateAssumptions(findings, material.assumptions, prefix);
738
913
  }
@@ -740,6 +915,9 @@ export function validateFemAnalysisCase(caseFile) {
740
915
  if (caseFile.objective === 'tunnel_volume_loss_settlement' && caseFile.loads.length > 0) {
741
916
  findings.push(finding('blocker', 'load.unsupported-for-objective', 'Tunnel volume-loss settlement previews use explicit volume loss and must not include pressure loads.'));
742
917
  }
918
+ if (caseFile.objective === 'seepage_groundwater_coupling' && caseFile.loads.length > 0) {
919
+ findings.push(finding('blocker', 'load.unsupported-for-objective', 'Biot u-p seepage previews currently use prescribed pore-pressure boundaries and must not include pressure loads.'));
920
+ }
743
921
  if (caseFile.loads.length === 0) {
744
922
  if (caseFile.objective === 'foundation_settlement') {
745
923
  findings.push(finding('blocker', 'load.missing', 'A raft pressure load is required.'));
@@ -775,6 +953,9 @@ export function validateFemAnalysisCase(caseFile) {
775
953
  if (caseFile.objective === 'staged_settlement_consolidation' && load.target !== 'ground_surface') {
776
954
  findings.push(finding('blocker', `${prefix}.target-invalid`, 'Staged consolidation loads must target ground_surface.'));
777
955
  }
956
+ if (caseFile.objective === 'seepage_groundwater_coupling') {
957
+ findings.push(finding('blocker', `${prefix}.target-invalid`, 'Biot u-p seepage previews do not accept pressure loads.'));
958
+ }
778
959
  if (caseFile.objective === 'staged_settlement_consolidation' && consolidation) {
779
960
  if (caseFile.loads.length !== consolidation.stages.length) {
780
961
  findings.push(finding('blocker', 'load.stage-count-mismatch', 'Staged consolidation load count must match the consolidation stage count.'));
@@ -789,19 +970,30 @@ export function validateFemAnalysisCase(caseFile) {
789
970
  }
790
971
  }
791
972
  const { divisionsX, divisionsY, divisionsZ } = caseFile.mesh;
792
- if (caseFile.mesh.elementType !== 'hex8') {
973
+ if (caseFile.mesh.elementType !== 'hex8' && caseFile.mesh.elementType !== 'quad4_plane_strain') {
793
974
  findings.push(finding('blocker', 'mesh.element-type-invalid', `Unsupported mesh element type: ${String(caseFile.mesh.elementType)}.`));
794
975
  }
976
+ if (caseFile.objective === 'seepage_groundwater_coupling' && caseFile.mesh.elementType !== 'quad4_plane_strain') {
977
+ findings.push(finding('blocker', 'mesh.element-type-biot-required', 'Biot u-p seepage previews require quad4_plane_strain mesh elements.'));
978
+ }
979
+ if (caseFile.objective !== 'seepage_groundwater_coupling' && caseFile.mesh.elementType !== 'hex8') {
980
+ findings.push(finding('blocker', 'mesh.element-type-hex8-required', 'Non-Biot FEM preview cases require hex8 mesh elements.'));
981
+ }
795
982
  if (!Number.isInteger(divisionsX) ||
796
983
  !Number.isInteger(divisionsY) ||
797
984
  !Number.isInteger(divisionsZ)) {
798
985
  findings.push(finding('blocker', 'mesh.divisions-integer', 'Mesh divisions must be finite integers.'));
799
986
  }
800
- if (divisionsX < 2 || divisionsY < 2 || divisionsZ < 1) {
801
- findings.push(finding('blocker', 'mesh.too-coarse', 'Mesh divisions must be at least 2 x 2 x 1.'));
802
- }
803
- if (Number.isInteger(divisionsX) && Number.isInteger(divisionsY) && Number.isInteger(divisionsZ)) {
804
- const nodeCount = (divisionsX + 1) * (divisionsY + 1) * (divisionsZ + 1);
987
+ else {
988
+ if (caseFile.mesh.elementType === 'quad4_plane_strain') {
989
+ if (divisionsX < 1 || divisionsY < 1 || divisionsZ !== 1) {
990
+ findings.push(finding('blocker', 'mesh.quad4-plane-strain-invalid', 'Quad4 plane-strain meshes require divisionsX and divisionsY of at least 1 and divisionsZ exactly 1.'));
991
+ }
992
+ }
993
+ else if (divisionsX < 2 || divisionsY < 2 || divisionsZ < 1) {
994
+ findings.push(finding('blocker', 'mesh.too-coarse', 'Mesh divisions must be at least 2 x 2 x 1.'));
995
+ }
996
+ const nodeCount = expectedMeshCounts(caseFile.mesh).nodes;
805
997
  if (!Number.isFinite(nodeCount) || nodeCount > FEM_MAX_PREVIEW_MESH_NODES) {
806
998
  findings.push(finding('blocker', 'mesh.preview-node-limit', `Experimental FEM/WebGL previews are capped at ${FEM_MAX_PREVIEW_MESH_NODES} nodes.`));
807
999
  }
@@ -945,6 +1137,135 @@ function pushApproximateMatchFinding(findings, actual, expected, code, label, to
945
1137
  findings.push(finding('blocker', code, `${label} must match the embedded FEM analysis case within ${tolerance}.`));
946
1138
  }
947
1139
  }
1140
+ function validateNonlinearSolverConvergenceReport(findings, manifest, expectedLoadSteps) {
1141
+ const fallback = { forceBalanceTolerance: 1e-3, residualTolerance: 1e-6 };
1142
+ const report = manifest.solverConvergence;
1143
+ if (!isRecord(report)) {
1144
+ findings.push(finding('blocker', 'result.solver-convergence.missing', 'Nonlinear column solver manifests must include explicit convergence policy and load-step residual history.'));
1145
+ return fallback;
1146
+ }
1147
+ if (report.schemaVersion !== 'fem-solver-convergence-report.v1') {
1148
+ findings.push(finding('blocker', 'result.solver-convergence.schema-invalid', 'Unsupported solver convergence report schema.'));
1149
+ }
1150
+ if (report.status !== 'converged' && report.status !== 'nonconverged') {
1151
+ findings.push(finding('blocker', 'result.solver-convergence.status-invalid', 'Solver convergence status must be converged or nonconverged.'));
1152
+ }
1153
+ if (report.status === 'nonconverged') {
1154
+ findings.push(finding('blocker', 'result.solver-convergence.nonconverged', 'Nonlinear column solver did not satisfy its configured convergence policy.'));
1155
+ }
1156
+ const policy = report.policy;
1157
+ let forceBalanceTolerance = fallback.forceBalanceTolerance;
1158
+ let residualTolerance = fallback.residualTolerance;
1159
+ if (!isRecord(policy)) {
1160
+ findings.push(finding('blocker', 'result.solver-convergence.policy.missing', 'Solver convergence report must include a policy object.'));
1161
+ }
1162
+ else {
1163
+ if (policy.schemaVersion !== 'fem-convergence-policy.v1') {
1164
+ findings.push(finding('blocker', 'result.solver-convergence.policy.schema-invalid', 'Solver convergence policy schema is unsupported.'));
1165
+ }
1166
+ if (pushFiniteNumberFinding(findings, policy.forceBalanceTolerance, 'result.solver-convergence.policy.force-balance-tolerance', 'Solver force-balance tolerance', { positive: true })) {
1167
+ forceBalanceTolerance = policy.forceBalanceTolerance;
1168
+ }
1169
+ if (pushFiniteNumberFinding(findings, policy.residualTolerance, 'result.solver-convergence.policy.residual-tolerance', 'Solver residual tolerance', { positive: true })) {
1170
+ residualTolerance = policy.residualTolerance;
1171
+ }
1172
+ pushFiniteNumberFinding(findings, policy.porePressureMassBalanceTolerance, 'result.solver-convergence.policy.pore-pressure-mass-balance-tolerance', 'Solver pore-pressure mass-balance tolerance', { positive: true });
1173
+ if (!Number.isInteger(policy.maxIterations) || policy.maxIterations < 1) {
1174
+ findings.push(finding('blocker', 'result.solver-convergence.policy.max-iterations-invalid', 'Solver maxIterations must be a positive integer.'));
1175
+ }
1176
+ if (!Number.isInteger(policy.minAcceptedSteps) || policy.minAcceptedSteps < 1) {
1177
+ findings.push(finding('blocker', 'result.solver-convergence.policy.min-accepted-steps-invalid', 'Solver minAcceptedSteps must be a positive integer.'));
1178
+ }
1179
+ }
1180
+ if (!Array.isArray(report.loadSteps)) {
1181
+ findings.push(finding('blocker', 'result.solver-convergence.load-steps.invalid', 'Solver convergence loadSteps must be an array.'));
1182
+ return { forceBalanceTolerance, residualTolerance };
1183
+ }
1184
+ if (report.loadSteps.length !== expectedLoadSteps) {
1185
+ findings.push(finding('blocker', 'result.solver-convergence.load-steps.count-mismatch', 'Solver convergence load steps must match consolidation stages.'));
1186
+ }
1187
+ const validTerminationReasons = new Set([
1188
+ 'converged',
1189
+ 'max_iterations',
1190
+ 'force_residual_exceeded',
1191
+ 'yield_residual_exceeded',
1192
+ 'material_nonconvergence',
1193
+ 'consolidation_nonconvergence',
1194
+ ]);
1195
+ for (const [index, step] of report.loadSteps.entries()) {
1196
+ const prefix = `result.solver-convergence.loadSteps.${index}`;
1197
+ if (!isRecord(step)) {
1198
+ findings.push(finding('blocker', `${prefix}.shape-invalid`, 'Solver convergence load step must be an object.'));
1199
+ continue;
1200
+ }
1201
+ const stepOk = pushFiniteNumberFinding(findings, step.step, `${prefix}.step`, 'Solver convergence load-step number', { positive: true });
1202
+ if (stepOk && (!Number.isInteger(step.step) || step.step !== index + 1)) {
1203
+ findings.push(finding('blocker', `${prefix}.step.sequence-invalid`, 'Solver convergence load-step numbers must be sequential.'));
1204
+ }
1205
+ if (step.stageId != null && !isNonEmptyString(step.stageId)) {
1206
+ findings.push(finding('blocker', `${prefix}.stage-id.invalid`, 'Solver convergence stageId must be a non-empty string when present.'));
1207
+ }
1208
+ if (!Number.isInteger(step.iterations) || step.iterations < 0) {
1209
+ findings.push(finding('blocker', `${prefix}.iterations.invalid`, 'Solver convergence iterations must be a non-negative integer.'));
1210
+ }
1211
+ const residualOk = pushFiniteNumberFinding(findings, step.residualRatio, `${prefix}.residual-ratio`, 'Solver convergence residual ratio', { nonNegative: true });
1212
+ const forceToleranceOk = pushFiniteNumberFinding(findings, step.forceBalanceTolerance, `${prefix}.force-balance-tolerance`, 'Solver convergence force-balance tolerance', { positive: true });
1213
+ const stepForceTolerance = forceToleranceOk ? step.forceBalanceTolerance : forceBalanceTolerance;
1214
+ if (step.yieldResidualRatio != null) {
1215
+ const yieldResidualOk = pushFiniteNumberFinding(findings, step.yieldResidualRatio, `${prefix}.yield-residual-ratio`, 'Solver convergence yield residual ratio', { nonNegative: true });
1216
+ const stepResidualTolerance = isFiniteNumber(step.residualTolerance) && step.residualTolerance > 0
1217
+ ? step.residualTolerance
1218
+ : residualTolerance;
1219
+ if (yieldResidualOk && step.yieldResidualRatio > stepResidualTolerance) {
1220
+ findings.push(finding('blocker', `${prefix}.yield-residual-too-large`, 'Solver convergence yield residual exceeds the reported tolerance.'));
1221
+ }
1222
+ }
1223
+ if (step.residualTolerance != null) {
1224
+ pushFiniteNumberFinding(findings, step.residualTolerance, `${prefix}.residual-tolerance`, 'Solver convergence residual tolerance', { positive: true });
1225
+ }
1226
+ if (typeof step.converged !== 'boolean') {
1227
+ findings.push(finding('blocker', `${prefix}.converged.invalid`, 'Solver convergence load-step converged must be boolean.'));
1228
+ }
1229
+ else if (!step.converged) {
1230
+ findings.push(finding('blocker', `${prefix}.nonconverged`, 'Solver convergence load step did not satisfy the reported policy.'));
1231
+ }
1232
+ if (typeof step.terminationReason !== 'string' || !validTerminationReasons.has(step.terminationReason)) {
1233
+ findings.push(finding('blocker', `${prefix}.termination-reason.invalid`, 'Solver convergence termination reason is unsupported.'));
1234
+ }
1235
+ if (residualOk && step.residualRatio > stepForceTolerance) {
1236
+ findings.push(finding('blocker', `${prefix}.force-residual-too-large`, 'Solver convergence force residual exceeds the reported tolerance.'));
1237
+ }
1238
+ if (!Array.isArray(step.residualHistory) || step.residualHistory.length === 0) {
1239
+ findings.push(finding('blocker', `${prefix}.residual-history.missing`, 'Solver convergence load step must include non-empty residual history.'));
1240
+ continue;
1241
+ }
1242
+ for (const [historyIndex, history] of step.residualHistory.entries()) {
1243
+ const historyPrefix = `${prefix}.residualHistory.${historyIndex}`;
1244
+ if (!isRecord(history)) {
1245
+ findings.push(finding('blocker', `${historyPrefix}.shape-invalid`, 'Solver convergence residual history entry must be an object.'));
1246
+ continue;
1247
+ }
1248
+ if (!Number.isInteger(history.iteration) || history.iteration < 0) {
1249
+ findings.push(finding('blocker', `${historyPrefix}.iteration.invalid`, 'Solver convergence residual history iteration must be a non-negative integer.'));
1250
+ }
1251
+ pushFiniteNumberFinding(findings, history.residualRatio, `${historyPrefix}.residual-ratio`, 'Solver convergence residual history ratio', { nonNegative: true });
1252
+ pushFiniteNumberFinding(findings, history.forceBalanceTolerance, `${historyPrefix}.force-balance-tolerance`, 'Solver convergence residual history force-balance tolerance', { positive: true });
1253
+ if (history.yieldResidualRatio != null) {
1254
+ pushFiniteNumberFinding(findings, history.yieldResidualRatio, `${historyPrefix}.yield-residual-ratio`, 'Solver convergence residual history yield residual ratio', { nonNegative: true });
1255
+ }
1256
+ if (history.residualTolerance != null) {
1257
+ pushFiniteNumberFinding(findings, history.residualTolerance, `${historyPrefix}.residual-tolerance`, 'Solver convergence residual history residual tolerance', { positive: true });
1258
+ }
1259
+ if (typeof history.converged !== 'boolean') {
1260
+ findings.push(finding('blocker', `${historyPrefix}.converged.invalid`, 'Solver convergence residual history converged must be boolean.'));
1261
+ }
1262
+ }
1263
+ }
1264
+ if (report.status === 'nonconverged' && !isRecord(report.failure)) {
1265
+ findings.push(finding('blocker', 'result.solver-convergence.failure.missing', 'Nonconverged solver reports must include failure details.'));
1266
+ }
1267
+ return { forceBalanceTolerance, residualTolerance };
1268
+ }
948
1269
  function validateResultEnvelopeSemantics(findings, manifest) {
949
1270
  const { envelope, analysisCase } = manifest;
950
1271
  const maxSettlementOk = pushFiniteNumberFinding(findings, envelope.maxSettlementMm, 'result.envelope.max-settlement', 'Envelope max settlement', { nonNegative: true });
@@ -960,6 +1281,7 @@ function validateResultEnvelopeSemantics(findings, manifest) {
960
1281
  ['excavation_deformation', ['builtin-staged-excavation-demo']],
961
1282
  ['tunnel_volume_loss_settlement', ['builtin-tunnel-volume-loss-demo']],
962
1283
  ['staged_settlement_consolidation', ['builtin-staged-consolidation-1d', 'builtin-nonlinear-column-v0']],
1284
+ ['seepage_groundwater_coupling', ['builtin-biot-up-plane-strain-v0']],
963
1285
  ]);
964
1286
  const expectedBackends = expectedBackendByObjective.get(analysisCase.objective);
965
1287
  if (expectedBackends && !expectedBackends.includes(manifest.backend.id)) {
@@ -1077,6 +1399,7 @@ function validateResultEnvelopeSemantics(findings, manifest) {
1077
1399
  pushApproximateMatchFinding(findings, envelope.drainagePathM, expectedDrainagePathM, 'result.envelope.consolidation-drainage-path-mismatch', 'Consolidation drainage path', 0.001);
1078
1400
  pushApproximateMatchFinding(findings, envelope.consolidationDurationYears, expectedDurationYears, 'result.envelope.consolidation-duration-mismatch', 'Consolidation duration', 0.001);
1079
1401
  if (manifest.backend.id === 'builtin-nonlinear-column-v0') {
1402
+ const solverTolerances = validateNonlinearSolverConvergenceReport(findings, manifest, consolidation.stages.length);
1080
1403
  const loadStepsOk = pushFiniteNumberFinding(findings, envelope.solverLoadSteps, 'result.envelope.solver-load-steps', 'Envelope solver load steps', { positive: true });
1081
1404
  const iterationsOk = pushFiniteNumberFinding(findings, envelope.solverIterations, 'result.envelope.solver-iterations', 'Envelope solver iterations', { positive: true });
1082
1405
  const solverResidualOk = pushFiniteNumberFinding(findings, envelope.maxSolverResidualRatio, 'result.envelope.max-solver-residual-ratio', 'Envelope max solver residual ratio', { nonNegative: true });
@@ -1088,14 +1411,105 @@ function validateResultEnvelopeSemantics(findings, manifest) {
1088
1411
  if (iterationsOk && !Number.isInteger(envelope.solverIterations)) {
1089
1412
  findings.push(finding('blocker', 'result.envelope.solver-iterations-integer', 'Nonlinear column solver iterations must be an integer.'));
1090
1413
  }
1091
- if (solverResidualOk && envelope.maxSolverResidualRatio > 1e-3) {
1414
+ if (solverResidualOk && envelope.maxSolverResidualRatio > solverTolerances.forceBalanceTolerance) {
1092
1415
  findings.push(finding('blocker', 'result.envelope.solver-residual-too-large', 'Nonlinear column solver residual exceeds the force-balance tolerance.'));
1093
1416
  }
1094
- if (yieldResidualOk && envelope.maxYieldResidualRatio > 1e-6) {
1417
+ if (yieldResidualOk && envelope.maxYieldResidualRatio > solverTolerances.residualTolerance) {
1095
1418
  findings.push(finding('blocker', 'result.envelope.yield-residual-too-large', 'Nonlinear column solver yield residual exceeds the material return-map tolerance.'));
1096
1419
  }
1097
1420
  }
1098
1421
  }
1422
+ if (analysisCase.objective === 'seepage_groundwater_coupling') {
1423
+ const biot = analysisCase.geometry.biot;
1424
+ if (!biot)
1425
+ return;
1426
+ const boundaryPressureValues = biot.porePressureBoundaries.map((boundary) => boundary.porePressureKpa);
1427
+ const expectedMaxPressureKpa = Math.max(biot.initialPorePressureKpa, ...boundaryPressureValues);
1428
+ const minPorePressureOk = pushFiniteNumberFinding(findings, envelope.minPorePressureKpa, 'result.envelope.min-pore-pressure', 'Envelope minimum pore pressure', { nonNegative: true });
1429
+ const maxPorePressureOk = pushFiniteNumberFinding(findings, envelope.maxPorePressureKpa, 'result.envelope.max-pore-pressure', 'Envelope maximum pore pressure', { nonNegative: true });
1430
+ const maxExcessOk = pushFiniteNumberFinding(findings, envelope.maxExcessPorePressureKpa, 'result.envelope.max-excess-pore-pressure', 'Envelope maximum excess pore pressure', { nonNegative: true });
1431
+ pushFiniteNumberFinding(findings, envelope.maxBiotCouplingKpa, 'result.envelope.max-biot-coupling', 'Envelope maximum Biot coupling', { nonNegative: true });
1432
+ const massBalanceOk = pushFiniteNumberFinding(findings, envelope.porePressureMassBalanceErrorRatio, 'result.envelope.pore-pressure-mass-balance-error-ratio', 'Envelope pore-pressure mass-balance error ratio', { nonNegative: true });
1433
+ pushFiniteNumberFinding(findings, envelope.maxFreePorePressureResidualM3PerS, 'result.envelope.max-free-pore-pressure-residual', 'Envelope maximum free pore-pressure residual', { nonNegative: true });
1434
+ pushFiniteNumberFinding(findings, envelope.freePorePressureResidualL1M3PerS, 'result.envelope.free-pore-pressure-residual-l1', 'Envelope free pore-pressure residual L1 norm', { nonNegative: true });
1435
+ pushFiniteNumberFinding(findings, envelope.prescribedPorePressureResidualL1M3PerS, 'result.envelope.prescribed-pore-pressure-residual-l1', 'Envelope prescribed pore-pressure residual L1 norm', { nonNegative: true });
1436
+ const averagePorePressureOk = pushFiniteNumberFinding(findings, envelope.averagePorePressureKpa, 'result.envelope.average-pore-pressure', 'Envelope average pore pressure', { nonNegative: true });
1437
+ const averageFreePorePressureOk = pushFiniteNumberFinding(findings, envelope.averageFreePorePressureKpa, 'result.envelope.average-free-pore-pressure', 'Envelope average free pore pressure', { nonNegative: true });
1438
+ const dissipationRatioOk = pushFiniteNumberFinding(findings, envelope.porePressureDissipationRatio, 'result.envelope.pore-pressure-dissipation-ratio', 'Envelope pore-pressure dissipation ratio', { nonNegative: true });
1439
+ pushFiniteNumberFinding(findings, envelope.maxPorePressureChangeRateKpaPerS, 'result.envelope.max-pore-pressure-change-rate', 'Envelope maximum pore-pressure change rate', { nonNegative: true });
1440
+ const timeStepCountOk = pushFiniteNumberFinding(findings, envelope.timeStepCount, 'result.envelope.time-step-count', 'Envelope time-step count', { positive: true });
1441
+ const coupledUnknownsOk = pushFiniteNumberFinding(findings, envelope.coupledUnknownCount, 'result.envelope.coupled-unknown-count', 'Envelope coupled unknown count', { positive: true });
1442
+ const displacementDofsOk = pushFiniteNumberFinding(findings, envelope.displacementDofCount, 'result.envelope.displacement-dof-count', 'Envelope displacement DOF count', { positive: true });
1443
+ const porePressureDofsOk = pushFiniteNumberFinding(findings, envelope.porePressureDofCount, 'result.envelope.pore-pressure-dof-count', 'Envelope pore-pressure DOF count', { positive: true });
1444
+ if (minPorePressureOk && maxPorePressureOk && envelope.minPorePressureKpa > envelope.maxPorePressureKpa) {
1445
+ findings.push(finding('blocker', 'result.envelope.pore-pressure-range-invalid', 'Envelope minimum pore pressure cannot exceed maximum pore pressure.'));
1446
+ }
1447
+ if (maxPorePressureOk && envelope.maxPorePressureKpa > expectedMaxPressureKpa + 1e-6) {
1448
+ findings.push(finding('blocker', 'result.envelope.pore-pressure-upper-bound-invalid', 'Envelope maximum pore pressure cannot exceed the initial or prescribed boundary pressure envelope.'));
1449
+ }
1450
+ if (averagePorePressureOk && minPorePressureOk && maxPorePressureOk && (envelope.averagePorePressureKpa < envelope.minPorePressureKpa - 1e-6 ||
1451
+ envelope.averagePorePressureKpa > envelope.maxPorePressureKpa + 1e-6)) {
1452
+ findings.push(finding('blocker', 'result.envelope.average-pore-pressure-range-invalid', 'Envelope average pore pressure must stay within the reported pore-pressure range.'));
1453
+ }
1454
+ if (averageFreePorePressureOk && maxPorePressureOk && envelope.averageFreePorePressureKpa > envelope.maxPorePressureKpa + 1e-6) {
1455
+ findings.push(finding('blocker', 'result.envelope.average-free-pore-pressure-range-invalid', 'Envelope average free pore pressure cannot exceed the reported maximum pore pressure.'));
1456
+ }
1457
+ if (dissipationRatioOk && envelope.porePressureDissipationRatio > 1) {
1458
+ findings.push(finding('blocker', 'result.envelope.pore-pressure-dissipation-ratio-invalid', 'Envelope pore-pressure dissipation ratio must be between 0 and 1.'));
1459
+ }
1460
+ if (maxExcessOk && maxPorePressureOk) {
1461
+ pushApproximateMatchFinding(findings, envelope.maxExcessPorePressureKpa, envelope.maxPorePressureKpa, 'result.envelope.max-excess-pore-pressure-mismatch', 'Maximum excess pore pressure', 1e-6);
1462
+ }
1463
+ if (timeStepCountOk && (!Number.isInteger(envelope.timeStepCount) || envelope.timeStepCount !== biot.timeStepsSeconds.length)) {
1464
+ findings.push(finding('blocker', 'result.envelope.time-step-count-mismatch', 'Biot envelope time-step count must match the embedded time-step schedule.'));
1465
+ }
1466
+ if (timeStepCountOk && envelope.timeStepCount < FEM_MIN_BIOT_TRANSIENT_STEPS) {
1467
+ findings.push(finding('blocker', 'result.envelope.time-step-count-too-small', 'Biot envelope time-step count is below the preview transient-step policy.'));
1468
+ }
1469
+ if (coupledUnknownsOk && !Number.isInteger(envelope.coupledUnknownCount)) {
1470
+ findings.push(finding('blocker', 'result.envelope.coupled-unknown-count-integer', 'Coupled unknown count must be an integer.'));
1471
+ }
1472
+ if (displacementDofsOk && !Number.isInteger(envelope.displacementDofCount)) {
1473
+ findings.push(finding('blocker', 'result.envelope.displacement-dof-count-integer', 'Displacement DOF count must be an integer.'));
1474
+ }
1475
+ if (porePressureDofsOk && !Number.isInteger(envelope.porePressureDofCount)) {
1476
+ findings.push(finding('blocker', 'result.envelope.pore-pressure-dof-count-integer', 'Pore-pressure DOF count must be an integer.'));
1477
+ }
1478
+ if (coupledUnknownsOk && displacementDofsOk && porePressureDofsOk && envelope.coupledUnknownCount > envelope.displacementDofCount + envelope.porePressureDofCount) {
1479
+ findings.push(finding('blocker', 'result.envelope.coupled-unknown-count-mismatch', 'Coupled unknown count cannot exceed displacement plus pore-pressure DOF counts.'));
1480
+ }
1481
+ if (massBalanceOk && envelope.porePressureMassBalanceErrorRatio > 1e-3) {
1482
+ findings.push(finding('blocker', 'result.envelope.pore-pressure-mass-balance-too-large', 'Biot pore-pressure mass-balance error ratio exceeds the preview tolerance.'));
1483
+ }
1484
+ pushApproximateMatchFinding(findings, envelope.totalLoadKn, 0, 'result.envelope.biot-total-load-invalid', 'Biot preview total load', 0.001);
1485
+ pushApproximateMatchFinding(findings, envelope.reactionKn, 0, 'result.envelope.biot-reaction-invalid', 'Biot preview reaction', 0.001);
1486
+ if (maxSettlementOk && isFiniteNumber(envelope.finalSettlementMm)) {
1487
+ pushApproximateMatchFinding(findings, envelope.maxSettlementMm, envelope.finalSettlementMm, 'result.envelope.biot-max-settlement-mismatch', 'Biot maximum settlement', 0.001);
1488
+ }
1489
+ const pressureAudit = manifest.pressureAudit;
1490
+ if (!pressureAudit) {
1491
+ findings.push(finding('blocker', 'result.pressure-audit.missing', 'Biot result manifests must include a pressure audit.'));
1492
+ }
1493
+ else {
1494
+ const pressureAuditEntries = [
1495
+ ['freePorePressureResidualL1M3PerS', 'free-pore-pressure-residual-l1', 'Free pore-pressure residual L1 norm'],
1496
+ ['prescribedPorePressureResidualL1M3PerS', 'prescribed-pore-pressure-residual-l1', 'Prescribed pore-pressure residual L1 norm'],
1497
+ ['netPrescribedPressureBoundaryFlowM3PerS', 'net-prescribed-pressure-boundary-flow', 'Net prescribed pressure boundary flow'],
1498
+ ['appliedNodalFluxSumM3PerS', 'applied-nodal-flux-sum', 'Applied nodal flux sum'],
1499
+ ['storageRateSumM3PerS', 'storage-rate-sum', 'Storage-rate sum'],
1500
+ ['couplingRateSumM3PerS', 'coupling-rate-sum', 'Coupling-rate sum'],
1501
+ ['darcyFlowRateSumM3PerS', 'darcy-flow-rate-sum', 'Darcy flow-rate sum'],
1502
+ ];
1503
+ for (const [key, code, label] of pressureAuditEntries) {
1504
+ pushFiniteNumberFinding(findings, pressureAudit[key], `result.pressure-audit.${code}`, label);
1505
+ }
1506
+ pushApproximateMatchFinding(findings, pressureAudit.freePorePressureResidualL1M3PerS, envelope.freePorePressureResidualL1M3PerS, 'result.pressure-audit.free-residual-envelope-mismatch', 'Pressure-audit free residual', 1e-12);
1507
+ pushApproximateMatchFinding(findings, pressureAudit.prescribedPorePressureResidualL1M3PerS, envelope.prescribedPorePressureResidualL1M3PerS, 'result.pressure-audit.prescribed-residual-envelope-mismatch', 'Pressure-audit prescribed residual', 1e-12);
1508
+ }
1509
+ }
1510
+ else if (manifest.pressureAudit != null) {
1511
+ findings.push(finding('blocker', 'result.pressure-audit.unexpected', 'Pressure audit is only valid for Biot u-p seepage result manifests.'));
1512
+ }
1099
1513
  }
1100
1514
  export function validateFemResultManifest(manifest) {
1101
1515
  const findings = [];
@@ -1130,6 +1544,7 @@ export function validateFemResultManifest(manifest) {
1130
1544
  'builtin-tunnel-volume-loss-demo',
1131
1545
  'builtin-staged-consolidation-1d',
1132
1546
  'builtin-nonlinear-column-v0',
1547
+ 'builtin-biot-up-plane-strain-v0',
1133
1548
  ]);
1134
1549
  if (!validBackendIds.has(manifest.backend.id)) {
1135
1550
  findings.push(finding('blocker', 'result.backend.id-invalid', `Unsupported FEM result backend: ${String(manifest.backend.id)}.`));
@@ -1170,12 +1585,7 @@ export function validateFemResultManifest(manifest) {
1170
1585
  }
1171
1586
  pushIndexArrayFindings(findings, 'tri', visualization.tri, nodeCount, 3);
1172
1587
  pushIndexArrayFindings(findings, 'edge', visualization.edge, nodeCount, 2);
1173
- const expectedMeshNodes = (manifest.analysisCase.mesh.divisionsX + 1) *
1174
- (manifest.analysisCase.mesh.divisionsY + 1) *
1175
- (manifest.analysisCase.mesh.divisionsZ + 1);
1176
- const expectedMeshElements = manifest.analysisCase.mesh.divisionsX *
1177
- manifest.analysisCase.mesh.divisionsY *
1178
- manifest.analysisCase.mesh.divisionsZ;
1588
+ const { nodes: expectedMeshNodes, elements: expectedMeshElements } = expectedMeshCounts(manifest.analysisCase.mesh);
1179
1589
  const meshDivisions = manifest.mesh.divisions;
1180
1590
  if (!Number.isInteger(manifest.mesh.nodes) || manifest.mesh.nodes !== expectedMeshNodes) {
1181
1591
  findings.push(finding('blocker', 'result.mesh.nodes-mismatch', 'Result mesh node count must match the embedded analysis case mesh divisions.'));
@@ -1232,6 +1642,12 @@ export function validateFemResultManifest(manifest) {
1232
1642
  if (frame.color.length / 3 !== nodeCount) {
1233
1643
  findings.push(finding('blocker', `result.frames.${index}.color.node-count-mismatch`, 'Frame color vectors must match base nodes.'));
1234
1644
  }
1645
+ if (frame.scalarValues != null) {
1646
+ pushFiniteArrayFindings(findings, `frames.${index}.scalarValues`, frame.scalarValues, 1);
1647
+ if (frame.scalarValues.length !== nodeCount) {
1648
+ findings.push(finding('blocker', `result.frames.${index}.scalar-values.node-count-mismatch`, 'Frame scalarValues must match base nodes.'));
1649
+ }
1650
+ }
1235
1651
  }
1236
1652
  }
1237
1653
  validateOptionalResultMetadata(findings, manifest, nodeCount, outlineNodeCount);