@geotechcli/core 0.4.104 → 0.4.106

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.
@@ -19,6 +19,18 @@ 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
+ function expectedMeshCounts(mesh) {
23
+ if (mesh.elementType === 'quad4_plane_strain') {
24
+ return {
25
+ nodes: (mesh.divisionsX + 1) * (mesh.divisionsY + 1),
26
+ elements: mesh.divisionsX * mesh.divisionsY,
27
+ };
28
+ }
29
+ return {
30
+ nodes: (mesh.divisionsX + 1) * (mesh.divisionsY + 1) * (mesh.divisionsZ + 1),
31
+ elements: mesh.divisionsX * mesh.divisionsY * mesh.divisionsZ,
32
+ };
33
+ }
22
34
  function isFiniteNumber(value) {
23
35
  return typeof value === 'number' && Number.isFinite(value);
24
36
  }
@@ -53,7 +65,7 @@ function isFemAnalysisCaseShape(value) {
53
65
  const groundwater = value.groundwater;
54
66
  return (isRecord(geometry) &&
55
67
  isRecord(geometry.domain) &&
56
- (isRecord(geometry.raft) || isRecord(geometry.excavation) || isRecord(geometry.tunnel) || isRecord(geometry.consolidation)) &&
68
+ (isRecord(geometry.raft) || isRecord(geometry.excavation) || isRecord(geometry.tunnel) || isRecord(geometry.consolidation) || isRecord(geometry.biot)) &&
57
69
  isRecord(mesh) &&
58
70
  isRecord(groundwater) &&
59
71
  isRecord(value.units) &&
@@ -173,7 +185,7 @@ function validateOptionalResultMetadata(findings, manifest, nodeCount, outlineNo
173
185
  const validFieldLocations = new Set(['surface_nodes', 'outline_nodes', 'envelope']);
174
186
  const validFieldQuantities = new Set(['displacement', 'reaction', 'load', 'stage_count', 'pore_pressure', 'degree_of_consolidation', 'strength_ratio']);
175
187
  const validFieldComponents = new Set(['x', 'y', 'z', 'magnitude']);
176
- const validDatasetSources = new Set(['visualization.disp', 'visualization.frame', 'envelope']);
188
+ const validDatasetSources = new Set(['visualization.disp', 'visualization.frame', 'visualization.scalar-frame', 'envelope']);
177
189
  const fieldIds = new Set();
178
190
  const stepIds = new Set();
179
191
  const stepIndexes = new Set();
@@ -209,6 +221,17 @@ function validateOptionalResultMetadata(findings, manifest, nodeCount, outlineNo
209
221
  ['max_mobilized_strength_ratio', manifest.envelope.maxMobilizedStrengthRatio],
210
222
  ['drainage_path', manifest.envelope.drainagePathM],
211
223
  ['consolidation_duration', manifest.envelope.consolidationDurationYears],
224
+ ['time_step_count', manifest.envelope.timeStepCount],
225
+ ['min_pore_pressure', manifest.envelope.minPorePressureKpa],
226
+ ['max_pore_pressure', manifest.envelope.maxPorePressureKpa],
227
+ ['max_biot_coupling', manifest.envelope.maxBiotCouplingKpa],
228
+ ['pore_pressure_mass_balance_error_ratio', manifest.envelope.porePressureMassBalanceErrorRatio],
229
+ ['max_free_pore_pressure_residual', manifest.envelope.maxFreePorePressureResidualM3PerS],
230
+ ['free_pore_pressure_residual_l1', manifest.envelope.freePorePressureResidualL1M3PerS],
231
+ ['prescribed_pore_pressure_residual_l1', manifest.envelope.prescribedPorePressureResidualL1M3PerS],
232
+ ['coupled_unknown_count', manifest.envelope.coupledUnknownCount],
233
+ ['displacement_dof_count', manifest.envelope.displacementDofCount],
234
+ ['pore_pressure_dof_count', manifest.envelope.porePressureDofCount],
212
235
  ['tunnel_diameter', manifest.envelope.tunnelDiameterM],
213
236
  ['tunnel_axis_depth', manifest.envelope.tunnelAxisDepthM],
214
237
  ['volume_loss', manifest.envelope.volumeLossPercent],
@@ -235,8 +258,11 @@ function validateOptionalResultMetadata(findings, manifest, nodeCount, outlineNo
235
258
  if (fieldInfo.component != null && !validFieldComponents.has(String(fieldInfo.component))) {
236
259
  findings.push(finding('blocker', `result.fields.${index}.component.invalid`, `Unsupported result field component: ${String(fieldInfo.component)}.`));
237
260
  }
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.'));
261
+ if (fieldInfo.location !== 'envelope' && fieldInfo.quantity !== 'displacement' && fieldInfo.quantity !== 'pore_pressure') {
262
+ findings.push(finding('blocker', `result.fields.${index}.node-quantity-invalid`, 'Surface and outline node result fields must describe displacement or pore-pressure quantities.'));
263
+ }
264
+ if (fieldInfo.location === 'outline_nodes' && fieldInfo.quantity === 'pore_pressure') {
265
+ findings.push(finding('blocker', `result.fields.${index}.outline-pore-pressure-invalid`, 'Pore-pressure result fields must be surface-node scalar fields.'));
240
266
  }
241
267
  if (fieldInfo.location !== 'envelope' && fieldInfo.quantity === 'displacement' && fieldInfo.component == null) {
242
268
  findings.push(finding('blocker', `result.fields.${index}.component.missing`, 'Node displacement result fields must include a displacement component.'));
@@ -271,6 +297,7 @@ function validateOptionalResultMetadata(findings, manifest, nodeCount, outlineNo
271
297
  }
272
298
  }
273
299
  }
300
+ let previousTimeSeconds;
274
301
  if (Array.isArray(steps)) {
275
302
  for (const [index, step] of steps.entries()) {
276
303
  const id = pushUniqueStringFinding(findings, stepIds, step.id, `result.steps.${index}.id`, 'Result step id');
@@ -294,6 +321,26 @@ function validateOptionalResultMetadata(findings, manifest, nodeCount, outlineNo
294
321
  if (step.depthM != null && (!Number.isFinite(step.depthM) || step.depthM < 0)) {
295
322
  findings.push(finding('blocker', `result.steps.${index}.depth-invalid`, 'Result step depth must be finite and non-negative when present.'));
296
323
  }
324
+ if (step.timeSeconds != null && (!Number.isFinite(step.timeSeconds) || step.timeSeconds < 0)) {
325
+ findings.push(finding('blocker', `result.steps.${index}.time-invalid`, 'Result step timeSeconds must be finite and non-negative when present.'));
326
+ }
327
+ if (step.deltaTimeSeconds != null && (!Number.isFinite(step.deltaTimeSeconds) || step.deltaTimeSeconds <= 0)) {
328
+ findings.push(finding('blocker', `result.steps.${index}.delta-time-invalid`, 'Result step deltaTimeSeconds must be finite and positive when present.'));
329
+ }
330
+ if (manifest.analysisCase.objective === 'seepage_groundwater_coupling') {
331
+ if (!isFiniteNumber(step.timeSeconds) || step.timeSeconds <= 0) {
332
+ findings.push(finding('blocker', `result.steps.${index}.time-required`, 'Biot consolidation result steps must carry a positive timeSeconds value.'));
333
+ }
334
+ if (!isFiniteNumber(step.deltaTimeSeconds) || step.deltaTimeSeconds <= 0) {
335
+ findings.push(finding('blocker', `result.steps.${index}.delta-time-required`, 'Biot consolidation result steps must carry a positive deltaTimeSeconds value.'));
336
+ }
337
+ }
338
+ if (isFiniteNumber(step.timeSeconds)) {
339
+ if (previousTimeSeconds != null && step.timeSeconds <= previousTimeSeconds) {
340
+ findings.push(finding('blocker', `result.steps.${index}.time-order-invalid`, 'Result step timeSeconds values must increase monotonically.'));
341
+ }
342
+ previousTimeSeconds = step.timeSeconds;
343
+ }
297
344
  if (manifest.analysisCase.objective === 'excavation_deformation') {
298
345
  if (!step.analysisStageId) {
299
346
  findings.push(finding('blocker', `result.steps.${index}.stage-required`, 'Excavation result steps must reference an analysis stage.'));
@@ -367,15 +414,31 @@ function validateOptionalResultMetadata(findings, manifest, nodeCount, outlineNo
367
414
  findings.push(finding('blocker', `result.datasets.${index}.source.invalid`, `Unsupported result dataset source: ${String(dataset.source)}.`));
368
415
  }
369
416
  const location = fieldLocations.get(dataset.fieldId);
370
- if ((dataset.source === 'visualization.disp' || dataset.source === 'visualization.frame') && dataset.stepId == null) {
417
+ const fieldInfo = fieldInfoById.get(dataset.fieldId);
418
+ const isVisualizationDataset = dataset.source === 'visualization.disp' ||
419
+ dataset.source === 'visualization.frame' ||
420
+ dataset.source === 'visualization.scalar-frame';
421
+ if (isVisualizationDataset && dataset.stepId == null) {
371
422
  findings.push(finding('blocker', `result.datasets.${index}.step-required`, 'Visualization datasets must reference a result step.'));
372
423
  }
373
- if ((dataset.source === 'visualization.disp' || dataset.source === 'visualization.frame') && location === 'envelope') {
424
+ if (isVisualizationDataset && location === 'envelope') {
374
425
  findings.push(finding('blocker', `result.datasets.${index}.visualization-field-invalid`, 'Visualization datasets must reference surface or outline node result fields, not envelope fields.'));
375
426
  }
376
427
  if (dataset.source === 'envelope' && location !== 'envelope') {
377
428
  findings.push(finding('blocker', `result.datasets.${index}.envelope-field-invalid`, 'Envelope datasets must reference an envelope result field.'));
378
429
  }
430
+ if ((dataset.source === 'visualization.disp' || dataset.source === 'visualization.frame') && dataset.stride !== 3) {
431
+ findings.push(finding('blocker', `result.datasets.${index}.visualization-stride-invalid`, 'Displacement visualization datasets must use stride 3.'));
432
+ }
433
+ if ((dataset.source === 'visualization.disp' || dataset.source === 'visualization.frame') && fieldInfo?.quantity !== 'displacement') {
434
+ findings.push(finding('blocker', `result.datasets.${index}.visualization-quantity-invalid`, 'Displacement visualization datasets must reference displacement result fields.'));
435
+ }
436
+ if (dataset.source === 'visualization.scalar-frame' && dataset.stride !== 1) {
437
+ findings.push(finding('blocker', `result.datasets.${index}.scalar-stride-invalid`, 'Scalar-frame visualization datasets must use stride 1.'));
438
+ }
439
+ if (dataset.source === 'visualization.scalar-frame' && fieldInfo?.quantity !== 'pore_pressure') {
440
+ findings.push(finding('blocker', `result.datasets.${index}.scalar-quantity-invalid`, 'Scalar-frame visualization datasets must reference pore-pressure result fields.'));
441
+ }
379
442
  if (dataset.source === 'visualization.disp' && !arraysApproximatelyEqual(dataset.values, manifest.visualization.disp, 1e-12)) {
380
443
  findings.push(finding('blocker', `result.datasets.${index}.disp-values-mismatch`, 'Visualization displacement dataset values must match the manifest visualization displacement array.'));
381
444
  }
@@ -388,6 +451,18 @@ function validateOptionalResultMetadata(findings, manifest, nodeCount, outlineNo
388
451
  findings.push(finding('blocker', `result.datasets.${index}.frame-values-mismatch`, 'Visualization frame dataset values must match the referenced frame displacement array.'));
389
452
  }
390
453
  }
454
+ if (dataset.source === 'visualization.scalar-frame' && dataset.stepId != null) {
455
+ const frame = frameByKey.get(`${dataset.fieldId}:${dataset.stepId}`);
456
+ if (!frame) {
457
+ findings.push(finding('blocker', `result.datasets.${index}.scalar-frame-missing`, 'Scalar-frame dataset has no matching frame payload.'));
458
+ }
459
+ else if (!Array.isArray(frame.scalarValues)) {
460
+ findings.push(finding('blocker', `result.datasets.${index}.scalar-frame-values-missing`, 'Scalar-frame dataset must reference a frame with scalarValues.'));
461
+ }
462
+ else if (!arraysApproximatelyEqual(dataset.values, frame.scalarValues, 1e-12)) {
463
+ findings.push(finding('blocker', `result.datasets.${index}.scalar-frame-values-mismatch`, 'Scalar-frame dataset values must match the referenced frame scalarValues array.'));
464
+ }
465
+ }
391
466
  const valueCount = dataset.values.length / dataset.stride;
392
467
  if (location === 'surface_nodes' && valueCount !== nodeCount) {
393
468
  findings.push(finding('blocker', `result.datasets.${index}.surface-count-mismatch`, 'Surface-node dataset values must match visualization base node count.'));
@@ -480,7 +555,7 @@ export function validateFemAnalysisCase(caseFile) {
480
555
  finding('blocker', 'schema.shape-invalid', 'FEM analysis case is missing required geometry, mesh, groundwater, or array fields.'),
481
556
  ]);
482
557
  }
483
- const { domain, raft, excavation, tunnel, consolidation } = caseFile.geometry;
558
+ const { domain, raft, excavation, tunnel, consolidation, biot } = caseFile.geometry;
484
559
  if (caseFile.schemaVersion !== 'fem-analysis-case.v0') {
485
560
  findings.push(finding('blocker', 'schema.unsupported', 'Only fem-analysis-case.v0 is supported.'));
486
561
  }
@@ -499,7 +574,7 @@ export function validateFemAnalysisCase(caseFile) {
499
574
  if (!caseFile.experimental) {
500
575
  findings.push(finding('blocker', 'mode.experimental-required', 'FEM cases must be explicitly marked experimental.'));
501
576
  }
502
- if (!['foundation_settlement', 'excavation_deformation', 'tunnel_volume_loss_settlement', 'staged_settlement_consolidation'].includes(caseFile.objective)) {
577
+ if (!['foundation_settlement', 'excavation_deformation', 'tunnel_volume_loss_settlement', 'staged_settlement_consolidation', 'seepage_groundwater_coupling'].includes(caseFile.objective)) {
503
578
  findings.push(finding('blocker', 'objective.unsupported', `Unsupported FEM objective: ${caseFile.objective}.`));
504
579
  }
505
580
  if (caseFile.objective === 'foundation_settlement' && caseFile.analysisType !== 'static_3d_small_strain') {
@@ -514,6 +589,9 @@ export function validateFemAnalysisCase(caseFile) {
514
589
  if (caseFile.objective === 'staged_settlement_consolidation' && caseFile.analysisType !== 'time_dependent_1d_consolidation') {
515
590
  findings.push(finding('blocker', 'analysis.unsupported', `Unsupported analysis type: ${caseFile.analysisType}.`));
516
591
  }
592
+ if (caseFile.objective === 'seepage_groundwater_coupling' && caseFile.analysisType !== 'time_dependent_2d_biot_consolidation') {
593
+ findings.push(finding('blocker', 'analysis.unsupported', `Unsupported analysis type: ${caseFile.analysisType}.`));
594
+ }
517
595
  if (caseFile.geometry.domain.type !== 'box') {
518
596
  findings.push(finding('blocker', 'geometry.domain.type-invalid', `Unsupported domain type: ${String(caseFile.geometry.domain.type)}.`));
519
597
  }
@@ -691,6 +769,64 @@ export function validateFemAnalysisCase(caseFile) {
691
769
  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
770
  }
693
771
  }
772
+ if (caseFile.objective === 'seepage_groundwater_coupling') {
773
+ if (!biot) {
774
+ findings.push(finding('blocker', 'geometry.biot-missing', 'Seepage/groundwater coupling cases require plane-strain Biot geometry.'));
775
+ }
776
+ else {
777
+ if (biot.type !== 'plane_strain_biot_column') {
778
+ findings.push(finding('blocker', 'geometry.biot.type-invalid', `Unsupported Biot geometry type: ${String(biot.type)}.`));
779
+ }
780
+ pushPositiveNumberFindings(findings, [
781
+ [biot.widthM, 'geometry.biot.width', 'Biot column width'],
782
+ [biot.heightM, 'geometry.biot.height', 'Biot column height'],
783
+ [biot.thicknessM, 'geometry.biot.thickness', 'Biot column thickness'],
784
+ ]);
785
+ pushFiniteNumberFinding(findings, biot.initialPorePressureKpa, 'geometry.biot.initial-pore-pressure', 'Initial pore pressure', { nonNegative: true });
786
+ if (!isApproxEqual(domain.lengthM, biot.widthM, 1e-9)) {
787
+ findings.push(finding('blocker', 'geometry.biot.domain-width-mismatch', 'Biot column width must match the box-domain length.'));
788
+ }
789
+ if (!isApproxEqual(domain.depthM, biot.heightM, 1e-9)) {
790
+ findings.push(finding('blocker', 'geometry.biot.domain-height-mismatch', 'Biot column height must match the box-domain depth.'));
791
+ }
792
+ if (!isApproxEqual(domain.widthM, biot.thicknessM, 1e-9)) {
793
+ findings.push(finding('blocker', 'geometry.biot.domain-thickness-mismatch', 'Biot column thickness must match the box-domain width.'));
794
+ }
795
+ if (!Array.isArray(biot.timeStepsSeconds) || biot.timeStepsSeconds.length === 0) {
796
+ findings.push(finding('blocker', 'geometry.biot.time-steps-missing', 'Biot consolidation cases require at least one positive time step.'));
797
+ }
798
+ else {
799
+ let previousStepSeconds = 0;
800
+ for (const [index, timeSeconds] of biot.timeStepsSeconds.entries()) {
801
+ if (!Number.isFinite(timeSeconds) || timeSeconds <= previousStepSeconds) {
802
+ findings.push(finding('blocker', `geometry.biot.time-steps.${index}.invalid`, 'Biot time steps must be finite, positive, and strictly increasing.'));
803
+ break;
804
+ }
805
+ previousStepSeconds = timeSeconds;
806
+ }
807
+ }
808
+ if (!Array.isArray(biot.porePressureBoundaries) || biot.porePressureBoundaries.length === 0) {
809
+ findings.push(finding('blocker', 'geometry.biot.pressure-boundary-missing', 'Biot consolidation cases require at least one prescribed pore-pressure boundary.'));
810
+ }
811
+ else {
812
+ const boundaryIds = new Set();
813
+ const validPressureBoundaries = new Set(['top', 'bottom', 'left', 'right']);
814
+ for (const [index, boundary] of biot.porePressureBoundaries.entries()) {
815
+ const prefix = `geometry.biot.pressure-boundaries.${index}`;
816
+ if (!isRecord(boundary)) {
817
+ findings.push(finding('blocker', `${prefix}.shape-invalid`, 'Pore-pressure boundary must be an object.'));
818
+ continue;
819
+ }
820
+ pushUniqueStringFinding(findings, boundaryIds, boundary.id, `${prefix}.id`, 'Pore-pressure boundary id');
821
+ if (!validPressureBoundaries.has(String(boundary.boundary))) {
822
+ findings.push(finding('blocker', `${prefix}.boundary-invalid`, `Unsupported pore-pressure boundary: ${String(boundary.boundary)}.`));
823
+ }
824
+ pushFiniteNumberFinding(findings, boundary.porePressureKpa, `${prefix}.pore-pressure`, 'Prescribed pore pressure', { nonNegative: true });
825
+ }
826
+ }
827
+ 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.'));
828
+ }
829
+ }
694
830
  if (caseFile.materials.length === 0) {
695
831
  findings.push(finding('blocker', 'material.missing', 'At least one material is required.'));
696
832
  }
@@ -733,6 +869,28 @@ export function validateFemAnalysisCase(caseFile) {
733
869
  pushFiniteNumberFinding(findings, material.hydraulicConductivityMPerS, `${prefix}.hydraulic-conductivity`, 'Hydraulic conductivity', { positive: true });
734
870
  }
735
871
  }
872
+ if (caseFile.objective === 'seepage_groundwater_coupling') {
873
+ if (material.model !== 'linear_elastic') {
874
+ findings.push(finding('blocker', `${prefix}.biot-model-required`, 'Biot u-p preview requires a linear_elastic material with hydraulic coupling parameters.'));
875
+ }
876
+ const hasHydraulicX = material.hydraulicConductivityXMPerS != null || material.hydraulicConductivityMPerS != null;
877
+ if (!hasHydraulicX) {
878
+ 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.'));
879
+ }
880
+ else {
881
+ pushFiniteNumberFinding(findings, material.hydraulicConductivityXMPerS ?? material.hydraulicConductivityMPerS, `${prefix}.hydraulic-conductivity-x`, 'Hydraulic conductivity X', { positive: true });
882
+ }
883
+ if (material.hydraulicConductivityYMPerS != null) {
884
+ pushFiniteNumberFinding(findings, material.hydraulicConductivityYMPerS, `${prefix}.hydraulic-conductivity-y`, 'Hydraulic conductivity Y', { positive: true });
885
+ }
886
+ if (material.hydraulicConductivityMPerS != null) {
887
+ pushFiniteNumberFinding(findings, material.hydraulicConductivityMPerS, `${prefix}.hydraulic-conductivity`, 'Isotropic hydraulic conductivity', { positive: true });
888
+ }
889
+ if (!isFiniteNumber(material.biotCoefficient) || material.biotCoefficient < 0 || material.biotCoefficient > 1) {
890
+ findings.push(finding('blocker', `${prefix}.biot-coefficient-invalid`, 'Biot coefficient must be finite and between 0 and 1.'));
891
+ }
892
+ pushFiniteNumberFinding(findings, material.specificStorage1PerM, `${prefix}.specific-storage`, 'Specific storage', { positive: true });
893
+ }
736
894
  validateEvidenceRefs(findings, material.evidenceRefs, prefix);
737
895
  validateAssumptions(findings, material.assumptions, prefix);
738
896
  }
@@ -740,6 +898,9 @@ export function validateFemAnalysisCase(caseFile) {
740
898
  if (caseFile.objective === 'tunnel_volume_loss_settlement' && caseFile.loads.length > 0) {
741
899
  findings.push(finding('blocker', 'load.unsupported-for-objective', 'Tunnel volume-loss settlement previews use explicit volume loss and must not include pressure loads.'));
742
900
  }
901
+ if (caseFile.objective === 'seepage_groundwater_coupling' && caseFile.loads.length > 0) {
902
+ 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.'));
903
+ }
743
904
  if (caseFile.loads.length === 0) {
744
905
  if (caseFile.objective === 'foundation_settlement') {
745
906
  findings.push(finding('blocker', 'load.missing', 'A raft pressure load is required.'));
@@ -775,6 +936,9 @@ export function validateFemAnalysisCase(caseFile) {
775
936
  if (caseFile.objective === 'staged_settlement_consolidation' && load.target !== 'ground_surface') {
776
937
  findings.push(finding('blocker', `${prefix}.target-invalid`, 'Staged consolidation loads must target ground_surface.'));
777
938
  }
939
+ if (caseFile.objective === 'seepage_groundwater_coupling') {
940
+ findings.push(finding('blocker', `${prefix}.target-invalid`, 'Biot u-p seepage previews do not accept pressure loads.'));
941
+ }
778
942
  if (caseFile.objective === 'staged_settlement_consolidation' && consolidation) {
779
943
  if (caseFile.loads.length !== consolidation.stages.length) {
780
944
  findings.push(finding('blocker', 'load.stage-count-mismatch', 'Staged consolidation load count must match the consolidation stage count.'));
@@ -789,19 +953,30 @@ export function validateFemAnalysisCase(caseFile) {
789
953
  }
790
954
  }
791
955
  const { divisionsX, divisionsY, divisionsZ } = caseFile.mesh;
792
- if (caseFile.mesh.elementType !== 'hex8') {
956
+ if (caseFile.mesh.elementType !== 'hex8' && caseFile.mesh.elementType !== 'quad4_plane_strain') {
793
957
  findings.push(finding('blocker', 'mesh.element-type-invalid', `Unsupported mesh element type: ${String(caseFile.mesh.elementType)}.`));
794
958
  }
959
+ if (caseFile.objective === 'seepage_groundwater_coupling' && caseFile.mesh.elementType !== 'quad4_plane_strain') {
960
+ findings.push(finding('blocker', 'mesh.element-type-biot-required', 'Biot u-p seepage previews require quad4_plane_strain mesh elements.'));
961
+ }
962
+ if (caseFile.objective !== 'seepage_groundwater_coupling' && caseFile.mesh.elementType !== 'hex8') {
963
+ findings.push(finding('blocker', 'mesh.element-type-hex8-required', 'Non-Biot FEM preview cases require hex8 mesh elements.'));
964
+ }
795
965
  if (!Number.isInteger(divisionsX) ||
796
966
  !Number.isInteger(divisionsY) ||
797
967
  !Number.isInteger(divisionsZ)) {
798
968
  findings.push(finding('blocker', 'mesh.divisions-integer', 'Mesh divisions must be finite integers.'));
799
969
  }
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);
970
+ else {
971
+ if (caseFile.mesh.elementType === 'quad4_plane_strain') {
972
+ if (divisionsX < 1 || divisionsY < 1 || divisionsZ !== 1) {
973
+ 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.'));
974
+ }
975
+ }
976
+ else if (divisionsX < 2 || divisionsY < 2 || divisionsZ < 1) {
977
+ findings.push(finding('blocker', 'mesh.too-coarse', 'Mesh divisions must be at least 2 x 2 x 1.'));
978
+ }
979
+ const nodeCount = expectedMeshCounts(caseFile.mesh).nodes;
805
980
  if (!Number.isFinite(nodeCount) || nodeCount > FEM_MAX_PREVIEW_MESH_NODES) {
806
981
  findings.push(finding('blocker', 'mesh.preview-node-limit', `Experimental FEM/WebGL previews are capped at ${FEM_MAX_PREVIEW_MESH_NODES} nodes.`));
807
982
  }
@@ -960,6 +1135,7 @@ function validateResultEnvelopeSemantics(findings, manifest) {
960
1135
  ['excavation_deformation', ['builtin-staged-excavation-demo']],
961
1136
  ['tunnel_volume_loss_settlement', ['builtin-tunnel-volume-loss-demo']],
962
1137
  ['staged_settlement_consolidation', ['builtin-staged-consolidation-1d', 'builtin-nonlinear-column-v0']],
1138
+ ['seepage_groundwater_coupling', ['builtin-biot-up-plane-strain-v0']],
963
1139
  ]);
964
1140
  const expectedBackends = expectedBackendByObjective.get(analysisCase.objective);
965
1141
  if (expectedBackends && !expectedBackends.includes(manifest.backend.id)) {
@@ -1096,6 +1272,80 @@ function validateResultEnvelopeSemantics(findings, manifest) {
1096
1272
  }
1097
1273
  }
1098
1274
  }
1275
+ if (analysisCase.objective === 'seepage_groundwater_coupling') {
1276
+ const biot = analysisCase.geometry.biot;
1277
+ if (!biot)
1278
+ return;
1279
+ const boundaryPressureValues = biot.porePressureBoundaries.map((boundary) => boundary.porePressureKpa);
1280
+ const expectedMaxPressureKpa = Math.max(biot.initialPorePressureKpa, ...boundaryPressureValues);
1281
+ const minPorePressureOk = pushFiniteNumberFinding(findings, envelope.minPorePressureKpa, 'result.envelope.min-pore-pressure', 'Envelope minimum pore pressure', { nonNegative: true });
1282
+ const maxPorePressureOk = pushFiniteNumberFinding(findings, envelope.maxPorePressureKpa, 'result.envelope.max-pore-pressure', 'Envelope maximum pore pressure', { nonNegative: true });
1283
+ const maxExcessOk = pushFiniteNumberFinding(findings, envelope.maxExcessPorePressureKpa, 'result.envelope.max-excess-pore-pressure', 'Envelope maximum excess pore pressure', { nonNegative: true });
1284
+ pushFiniteNumberFinding(findings, envelope.maxBiotCouplingKpa, 'result.envelope.max-biot-coupling', 'Envelope maximum Biot coupling', { nonNegative: true });
1285
+ const massBalanceOk = pushFiniteNumberFinding(findings, envelope.porePressureMassBalanceErrorRatio, 'result.envelope.pore-pressure-mass-balance-error-ratio', 'Envelope pore-pressure mass-balance error ratio', { nonNegative: true });
1286
+ pushFiniteNumberFinding(findings, envelope.maxFreePorePressureResidualM3PerS, 'result.envelope.max-free-pore-pressure-residual', 'Envelope maximum free pore-pressure residual', { nonNegative: true });
1287
+ pushFiniteNumberFinding(findings, envelope.freePorePressureResidualL1M3PerS, 'result.envelope.free-pore-pressure-residual-l1', 'Envelope free pore-pressure residual L1 norm', { nonNegative: true });
1288
+ pushFiniteNumberFinding(findings, envelope.prescribedPorePressureResidualL1M3PerS, 'result.envelope.prescribed-pore-pressure-residual-l1', 'Envelope prescribed pore-pressure residual L1 norm', { nonNegative: true });
1289
+ const timeStepCountOk = pushFiniteNumberFinding(findings, envelope.timeStepCount, 'result.envelope.time-step-count', 'Envelope time-step count', { positive: true });
1290
+ const coupledUnknownsOk = pushFiniteNumberFinding(findings, envelope.coupledUnknownCount, 'result.envelope.coupled-unknown-count', 'Envelope coupled unknown count', { positive: true });
1291
+ const displacementDofsOk = pushFiniteNumberFinding(findings, envelope.displacementDofCount, 'result.envelope.displacement-dof-count', 'Envelope displacement DOF count', { positive: true });
1292
+ const porePressureDofsOk = pushFiniteNumberFinding(findings, envelope.porePressureDofCount, 'result.envelope.pore-pressure-dof-count', 'Envelope pore-pressure DOF count', { positive: true });
1293
+ if (minPorePressureOk && maxPorePressureOk && envelope.minPorePressureKpa > envelope.maxPorePressureKpa) {
1294
+ findings.push(finding('blocker', 'result.envelope.pore-pressure-range-invalid', 'Envelope minimum pore pressure cannot exceed maximum pore pressure.'));
1295
+ }
1296
+ if (maxPorePressureOk && envelope.maxPorePressureKpa > expectedMaxPressureKpa + 1e-6) {
1297
+ findings.push(finding('blocker', 'result.envelope.pore-pressure-upper-bound-invalid', 'Envelope maximum pore pressure cannot exceed the initial or prescribed boundary pressure envelope.'));
1298
+ }
1299
+ if (maxExcessOk && maxPorePressureOk) {
1300
+ pushApproximateMatchFinding(findings, envelope.maxExcessPorePressureKpa, envelope.maxPorePressureKpa, 'result.envelope.max-excess-pore-pressure-mismatch', 'Maximum excess pore pressure', 1e-6);
1301
+ }
1302
+ if (timeStepCountOk && (!Number.isInteger(envelope.timeStepCount) || envelope.timeStepCount !== biot.timeStepsSeconds.length)) {
1303
+ findings.push(finding('blocker', 'result.envelope.time-step-count-mismatch', 'Biot envelope time-step count must match the embedded time-step schedule.'));
1304
+ }
1305
+ if (coupledUnknownsOk && !Number.isInteger(envelope.coupledUnknownCount)) {
1306
+ findings.push(finding('blocker', 'result.envelope.coupled-unknown-count-integer', 'Coupled unknown count must be an integer.'));
1307
+ }
1308
+ if (displacementDofsOk && !Number.isInteger(envelope.displacementDofCount)) {
1309
+ findings.push(finding('blocker', 'result.envelope.displacement-dof-count-integer', 'Displacement DOF count must be an integer.'));
1310
+ }
1311
+ if (porePressureDofsOk && !Number.isInteger(envelope.porePressureDofCount)) {
1312
+ findings.push(finding('blocker', 'result.envelope.pore-pressure-dof-count-integer', 'Pore-pressure DOF count must be an integer.'));
1313
+ }
1314
+ if (coupledUnknownsOk && displacementDofsOk && porePressureDofsOk && envelope.coupledUnknownCount > envelope.displacementDofCount + envelope.porePressureDofCount) {
1315
+ findings.push(finding('blocker', 'result.envelope.coupled-unknown-count-mismatch', 'Coupled unknown count cannot exceed displacement plus pore-pressure DOF counts.'));
1316
+ }
1317
+ if (massBalanceOk && envelope.porePressureMassBalanceErrorRatio > 1e-3) {
1318
+ findings.push(finding('blocker', 'result.envelope.pore-pressure-mass-balance-too-large', 'Biot pore-pressure mass-balance error ratio exceeds the preview tolerance.'));
1319
+ }
1320
+ pushApproximateMatchFinding(findings, envelope.totalLoadKn, 0, 'result.envelope.biot-total-load-invalid', 'Biot preview total load', 0.001);
1321
+ pushApproximateMatchFinding(findings, envelope.reactionKn, 0, 'result.envelope.biot-reaction-invalid', 'Biot preview reaction', 0.001);
1322
+ if (maxSettlementOk && isFiniteNumber(envelope.finalSettlementMm)) {
1323
+ pushApproximateMatchFinding(findings, envelope.maxSettlementMm, envelope.finalSettlementMm, 'result.envelope.biot-max-settlement-mismatch', 'Biot maximum settlement', 0.001);
1324
+ }
1325
+ const pressureAudit = manifest.pressureAudit;
1326
+ if (!pressureAudit) {
1327
+ findings.push(finding('blocker', 'result.pressure-audit.missing', 'Biot result manifests must include a pressure audit.'));
1328
+ }
1329
+ else {
1330
+ const pressureAuditEntries = [
1331
+ ['freePorePressureResidualL1M3PerS', 'free-pore-pressure-residual-l1', 'Free pore-pressure residual L1 norm'],
1332
+ ['prescribedPorePressureResidualL1M3PerS', 'prescribed-pore-pressure-residual-l1', 'Prescribed pore-pressure residual L1 norm'],
1333
+ ['netPrescribedPressureBoundaryFlowM3PerS', 'net-prescribed-pressure-boundary-flow', 'Net prescribed pressure boundary flow'],
1334
+ ['appliedNodalFluxSumM3PerS', 'applied-nodal-flux-sum', 'Applied nodal flux sum'],
1335
+ ['storageRateSumM3PerS', 'storage-rate-sum', 'Storage-rate sum'],
1336
+ ['couplingRateSumM3PerS', 'coupling-rate-sum', 'Coupling-rate sum'],
1337
+ ['darcyFlowRateSumM3PerS', 'darcy-flow-rate-sum', 'Darcy flow-rate sum'],
1338
+ ];
1339
+ for (const [key, code, label] of pressureAuditEntries) {
1340
+ pushFiniteNumberFinding(findings, pressureAudit[key], `result.pressure-audit.${code}`, label);
1341
+ }
1342
+ pushApproximateMatchFinding(findings, pressureAudit.freePorePressureResidualL1M3PerS, envelope.freePorePressureResidualL1M3PerS, 'result.pressure-audit.free-residual-envelope-mismatch', 'Pressure-audit free residual', 1e-12);
1343
+ pushApproximateMatchFinding(findings, pressureAudit.prescribedPorePressureResidualL1M3PerS, envelope.prescribedPorePressureResidualL1M3PerS, 'result.pressure-audit.prescribed-residual-envelope-mismatch', 'Pressure-audit prescribed residual', 1e-12);
1344
+ }
1345
+ }
1346
+ else if (manifest.pressureAudit != null) {
1347
+ findings.push(finding('blocker', 'result.pressure-audit.unexpected', 'Pressure audit is only valid for Biot u-p seepage result manifests.'));
1348
+ }
1099
1349
  }
1100
1350
  export function validateFemResultManifest(manifest) {
1101
1351
  const findings = [];
@@ -1130,6 +1380,7 @@ export function validateFemResultManifest(manifest) {
1130
1380
  'builtin-tunnel-volume-loss-demo',
1131
1381
  'builtin-staged-consolidation-1d',
1132
1382
  'builtin-nonlinear-column-v0',
1383
+ 'builtin-biot-up-plane-strain-v0',
1133
1384
  ]);
1134
1385
  if (!validBackendIds.has(manifest.backend.id)) {
1135
1386
  findings.push(finding('blocker', 'result.backend.id-invalid', `Unsupported FEM result backend: ${String(manifest.backend.id)}.`));
@@ -1170,12 +1421,7 @@ export function validateFemResultManifest(manifest) {
1170
1421
  }
1171
1422
  pushIndexArrayFindings(findings, 'tri', visualization.tri, nodeCount, 3);
1172
1423
  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;
1424
+ const { nodes: expectedMeshNodes, elements: expectedMeshElements } = expectedMeshCounts(manifest.analysisCase.mesh);
1179
1425
  const meshDivisions = manifest.mesh.divisions;
1180
1426
  if (!Number.isInteger(manifest.mesh.nodes) || manifest.mesh.nodes !== expectedMeshNodes) {
1181
1427
  findings.push(finding('blocker', 'result.mesh.nodes-mismatch', 'Result mesh node count must match the embedded analysis case mesh divisions.'));
@@ -1232,6 +1478,12 @@ export function validateFemResultManifest(manifest) {
1232
1478
  if (frame.color.length / 3 !== nodeCount) {
1233
1479
  findings.push(finding('blocker', `result.frames.${index}.color.node-count-mismatch`, 'Frame color vectors must match base nodes.'));
1234
1480
  }
1481
+ if (frame.scalarValues != null) {
1482
+ pushFiniteArrayFindings(findings, `frames.${index}.scalarValues`, frame.scalarValues, 1);
1483
+ if (frame.scalarValues.length !== nodeCount) {
1484
+ findings.push(finding('blocker', `result.frames.${index}.scalar-values.node-count-mismatch`, 'Frame scalarValues must match base nodes.'));
1485
+ }
1486
+ }
1235
1487
  }
1236
1488
  }
1237
1489
  validateOptionalResultMetadata(findings, manifest, nodeCount, outlineNodeCount);