@geotechcli/core 0.4.76 → 0.4.78
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.
- package/dist/agents/data-tools.js +38 -1
- package/dist/agents/data-tools.js.map +1 -1
- package/dist/agents/fem-artifact-guards.d.ts +14 -0
- package/dist/agents/fem-artifact-guards.d.ts.map +1 -0
- package/dist/agents/fem-artifact-guards.js +53 -0
- package/dist/agents/fem-artifact-guards.js.map +1 -0
- package/dist/agents/fem-tools.js +86 -1
- package/dist/agents/fem-tools.js.map +1 -1
- package/dist/agents/filesystem-tools.js +13 -0
- package/dist/agents/filesystem-tools.js.map +1 -1
- package/dist/agents/provider-operating-contract.d.ts +3 -3
- package/dist/agents/provider-operating-contract.d.ts.map +1 -1
- package/dist/agents/provider-operating-contract.js +10 -46
- package/dist/agents/provider-operating-contract.js.map +1 -1
- package/dist/agents/runtime-bootstrap.d.ts +1 -0
- package/dist/agents/runtime-bootstrap.d.ts.map +1 -1
- package/dist/agents/runtime-bootstrap.js +1 -0
- package/dist/agents/runtime-bootstrap.js.map +1 -1
- package/dist/agents/safety.d.ts.map +1 -1
- package/dist/agents/safety.js +33 -0
- package/dist/agents/safety.js.map +1 -1
- package/dist/agents/signal-tools.d.ts +2 -0
- package/dist/agents/signal-tools.d.ts.map +1 -0
- package/dist/agents/signal-tools.js +96 -0
- package/dist/agents/signal-tools.js.map +1 -0
- package/dist/agents/swarm-planner.js +1 -1
- package/dist/agents/swarm-planner.js.map +1 -1
- package/dist/agents/swarm.d.ts +1 -0
- package/dist/agents/swarm.d.ts.map +1 -1
- package/dist/agents/swarm.js +202 -31
- package/dist/agents/swarm.js.map +1 -1
- package/dist/fem/ground-model-draft.d.ts +14 -0
- package/dist/fem/ground-model-draft.d.ts.map +1 -1
- package/dist/fem/ground-model-draft.js +86 -21
- package/dist/fem/ground-model-draft.js.map +1 -1
- package/dist/fem/index.d.ts +1 -1
- package/dist/fem/index.d.ts.map +1 -1
- package/dist/fem/index.js +1 -1
- package/dist/fem/index.js.map +1 -1
- package/dist/fem/routing.d.ts +15 -1
- package/dist/fem/routing.d.ts.map +1 -1
- package/dist/fem/routing.js +192 -14
- package/dist/fem/routing.js.map +1 -1
- package/dist/fem/validation.d.ts.map +1 -1
- package/dist/fem/validation.js +715 -30
- package/dist/fem/validation.js.map +1 -1
- package/dist/fem/webgl.d.ts.map +1 -1
- package/dist/fem/webgl.js +24 -8
- package/dist/fem/webgl.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/ingest/document-evidence-packet.d.ts +603 -45
- package/dist/ingest/document-evidence-packet.d.ts.map +1 -1
- package/dist/ingest/document-evidence-packet.js +145 -5
- package/dist/ingest/document-evidence-packet.js.map +1 -1
- package/dist/ingest/geotech-benchmark-corpus.d.ts +108 -0
- package/dist/ingest/geotech-benchmark-corpus.d.ts.map +1 -0
- package/dist/ingest/geotech-benchmark-corpus.js +423 -0
- package/dist/ingest/geotech-benchmark-corpus.js.map +1 -0
- package/dist/ingest/geotech-document-benchmark.d.ts +133 -0
- package/dist/ingest/geotech-document-benchmark.d.ts.map +1 -1
- package/dist/ingest/geotech-document-benchmark.js +370 -2
- package/dist/ingest/geotech-document-benchmark.js.map +1 -1
- package/dist/ingest/geotech-document.d.ts +3 -0
- package/dist/ingest/geotech-document.d.ts.map +1 -1
- package/dist/ingest/geotech-document.js +7 -0
- package/dist/ingest/geotech-document.js.map +1 -1
- package/dist/ingest/index.d.ts +2 -1
- package/dist/ingest/index.d.ts.map +1 -1
- package/dist/ingest/index.js +1 -0
- package/dist/ingest/index.js.map +1 -1
- package/dist/ingest/job-store.d.ts.map +1 -1
- package/dist/ingest/job-store.js +193 -0
- package/dist/ingest/job-store.js.map +1 -1
- package/dist/ingest/job-worker.d.ts.map +1 -1
- package/dist/ingest/job-worker.js +5 -0
- package/dist/ingest/job-worker.js.map +1 -1
- package/dist/ingest/page-evidence-cache.d.ts +6 -2
- package/dist/ingest/page-evidence-cache.d.ts.map +1 -1
- package/dist/ingest/page-evidence-cache.js +226 -4
- package/dist/ingest/page-evidence-cache.js.map +1 -1
- package/dist/ingest/pdf.d.ts.map +1 -1
- package/dist/ingest/pdf.js +2 -2
- package/dist/ingest/pdf.js.map +1 -1
- package/dist/ingest/review-store.d.ts +3 -0
- package/dist/ingest/review-store.d.ts.map +1 -1
- package/dist/ingest/review-store.js +28 -0
- package/dist/ingest/review-store.js.map +1 -1
- package/dist/llm/capabilities.d.ts +6 -1
- package/dist/llm/capabilities.d.ts.map +1 -1
- package/dist/llm/capabilities.js +66 -0
- package/dist/llm/capabilities.js.map +1 -1
- package/dist/llm/index.d.ts +2 -2
- package/dist/llm/index.d.ts.map +1 -1
- package/dist/llm/index.js +1 -1
- package/dist/llm/index.js.map +1 -1
- package/dist/llm/types.d.ts +20 -0
- package/dist/llm/types.d.ts.map +1 -1
- package/dist/llm/types.js.map +1 -1
- package/dist/meta/metadata.json +1 -1
- package/dist/report/ingest-dossier.d.ts.map +1 -1
- package/dist/report/ingest-dossier.js +13 -1
- package/dist/report/ingest-dossier.js.map +1 -1
- package/dist/report/project-workflow.js +3 -3
- package/dist/report/project-workflow.js.map +1 -1
- package/dist/signal/index.d.ts +95 -0
- package/dist/signal/index.d.ts.map +1 -0
- package/dist/signal/index.js +375 -0
- package/dist/signal/index.js.map +1 -0
- package/dist/verifier/findings.d.ts +1 -1
- package/dist/verifier/findings.d.ts.map +1 -1
- package/dist/verifier/findings.js +329 -0
- package/dist/verifier/findings.js.map +1 -1
- package/dist/vision/ocr.d.ts +2 -0
- package/dist/vision/ocr.d.ts.map +1 -1
- package/dist/vision/ocr.js +78 -2
- package/dist/vision/ocr.js.map +1 -1
- package/dist/vision/preprocess.d.ts +65 -0
- package/dist/vision/preprocess.d.ts.map +1 -1
- package/dist/vision/preprocess.js +620 -7
- package/dist/vision/preprocess.js.map +1 -1
- package/dist/workspace/project-workflow-executor.d.ts +1 -1
- package/dist/workspace/project-workflow-executor.d.ts.map +1 -1
- package/dist/workspace/project-workflow-executor.js +275 -5
- package/dist/workspace/project-workflow-executor.js.map +1 -1
- package/dist/workspace/project-workflow-router.d.ts.map +1 -1
- package/dist/workspace/project-workflow-router.js +63 -1
- package/dist/workspace/project-workflow-router.js.map +1 -1
- package/package.json +1 -1
package/dist/fem/validation.js
CHANGED
|
@@ -17,6 +17,34 @@ function isRecord(value) {
|
|
|
17
17
|
function isNonEmptyString(value) {
|
|
18
18
|
return typeof value === 'string' && value.trim().length > 0;
|
|
19
19
|
}
|
|
20
|
+
const FEM_WEBGL_UINT16_INDEX_LIMIT = 65_535;
|
|
21
|
+
const FEM_MAX_PREVIEW_MESH_NODES = FEM_WEBGL_UINT16_INDEX_LIMIT + 1;
|
|
22
|
+
function isFiniteNumber(value) {
|
|
23
|
+
return typeof value === 'number' && Number.isFinite(value);
|
|
24
|
+
}
|
|
25
|
+
function pushFiniteNumberFinding(findings, value, code, label, options = {}) {
|
|
26
|
+
if (!isFiniteNumber(value)) {
|
|
27
|
+
findings.push(finding('blocker', `${code}.non-finite`, `${label} must be a finite number.`));
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
if (options.positive && value <= 0) {
|
|
31
|
+
findings.push(finding('blocker', `${code}.positive`, `${label} must be positive.`));
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
if (options.nonNegative && value < 0) {
|
|
35
|
+
findings.push(finding('blocker', `${code}.non-negative`, `${label} must be non-negative.`));
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
function pushPositiveNumberFindings(findings, entries) {
|
|
41
|
+
for (const [value, code, label] of entries) {
|
|
42
|
+
pushFiniteNumberFinding(findings, value, code, label, { positive: true });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function arraysApproximatelyEqual(left, right, tolerance) {
|
|
46
|
+
return left.length === right.length && left.every((value, index) => Math.abs(value - right[index]) <= tolerance);
|
|
47
|
+
}
|
|
20
48
|
function isFemAnalysisCaseShape(value) {
|
|
21
49
|
if (!isRecord(value))
|
|
22
50
|
return false;
|
|
@@ -28,8 +56,10 @@ function isFemAnalysisCaseShape(value) {
|
|
|
28
56
|
(isRecord(geometry.raft) || isRecord(geometry.excavation) || isRecord(geometry.tunnel)) &&
|
|
29
57
|
isRecord(mesh) &&
|
|
30
58
|
isRecord(groundwater) &&
|
|
59
|
+
isRecord(value.units) &&
|
|
31
60
|
Array.isArray(value.materials) &&
|
|
32
61
|
Array.isArray(value.loads) &&
|
|
62
|
+
Array.isArray(value.boundaryConditions) &&
|
|
33
63
|
Array.isArray(value.assumptions) &&
|
|
34
64
|
Array.isArray(value.limitations) &&
|
|
35
65
|
Array.isArray(value.evidenceRefs));
|
|
@@ -45,6 +75,84 @@ function pushUniqueStringFinding(findings, seen, value, code, label) {
|
|
|
45
75
|
seen.add(value);
|
|
46
76
|
return value;
|
|
47
77
|
}
|
|
78
|
+
function validateEvidenceRefs(findings, evidenceRefs, prefix) {
|
|
79
|
+
if (!Array.isArray(evidenceRefs)) {
|
|
80
|
+
findings.push(finding('blocker', `${prefix}.evidence-refs-invalid`, 'Evidence refs must be an array.'));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const evidenceIds = new Set();
|
|
84
|
+
for (const [index, evidence] of evidenceRefs.entries()) {
|
|
85
|
+
const itemPrefix = `${prefix}.evidenceRefs.${index}`;
|
|
86
|
+
if (!isRecord(evidence)) {
|
|
87
|
+
findings.push(finding('blocker', `${itemPrefix}.shape-invalid`, 'Evidence ref must be an object.'));
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
pushUniqueStringFinding(findings, evidenceIds, evidence.id, `${itemPrefix}.id`, 'Evidence ref id');
|
|
91
|
+
if (evidence.source != null && !isNonEmptyString(evidence.source)) {
|
|
92
|
+
findings.push(finding('blocker', `${itemPrefix}.source.invalid`, 'Evidence ref source must be a non-empty string when present.'));
|
|
93
|
+
}
|
|
94
|
+
if (evidence.page != null && (typeof evidence.page !== 'number' || !Number.isInteger(evidence.page) || evidence.page < 1)) {
|
|
95
|
+
findings.push(finding('blocker', `${itemPrefix}.page.invalid`, 'Evidence ref page must be an integer greater than or equal to 1 when present.'));
|
|
96
|
+
}
|
|
97
|
+
if (evidence.note != null && !isNonEmptyString(evidence.note)) {
|
|
98
|
+
findings.push(finding('blocker', `${itemPrefix}.note.invalid`, 'Evidence ref note must be a non-empty string when present.'));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
function validateAssumptions(findings, assumptions, prefix, options = {}) {
|
|
103
|
+
if (!Array.isArray(assumptions)) {
|
|
104
|
+
findings.push(finding('blocker', `${prefix}.assumptions-invalid`, 'Assumptions must be an array.'));
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const validConfidence = new Set(['measured', 'inferred', 'review']);
|
|
108
|
+
const assumptionIds = new Set();
|
|
109
|
+
for (const [index, assumption] of assumptions.entries()) {
|
|
110
|
+
const itemPrefix = `${prefix}.assumptions.${index}`;
|
|
111
|
+
if (!isRecord(assumption)) {
|
|
112
|
+
findings.push(finding('blocker', `${itemPrefix}.shape-invalid`, 'Assumption must be an object.'));
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const id = pushUniqueStringFinding(findings, assumptionIds, assumption.id, `${itemPrefix}.id`, 'Assumption id');
|
|
116
|
+
const parameter = isNonEmptyString(assumption.parameter) ? assumption.parameter : undefined;
|
|
117
|
+
if (!parameter) {
|
|
118
|
+
findings.push(finding('blocker', `${itemPrefix}.parameter.missing`, 'Assumption parameter must be a non-empty string.'));
|
|
119
|
+
}
|
|
120
|
+
if (!(typeof assumption.value === 'number' && Number.isFinite(assumption.value)) &&
|
|
121
|
+
!isNonEmptyString(assumption.value)) {
|
|
122
|
+
findings.push(finding('blocker', `${itemPrefix}.value.invalid`, 'Assumption value must be a finite number or non-empty string.'));
|
|
123
|
+
}
|
|
124
|
+
if (assumption.unit != null && !isNonEmptyString(assumption.unit)) {
|
|
125
|
+
findings.push(finding('blocker', `${itemPrefix}.unit.invalid`, 'Assumption unit must be a non-empty string when present.'));
|
|
126
|
+
}
|
|
127
|
+
if (!isNonEmptyString(assumption.basis)) {
|
|
128
|
+
findings.push(finding('blocker', `${itemPrefix}.basis.missing`, 'Assumption basis must be a non-empty string.'));
|
|
129
|
+
}
|
|
130
|
+
if (!validConfidence.has(String(assumption.confidence))) {
|
|
131
|
+
findings.push(finding('blocker', `${itemPrefix}.confidence.invalid`, `Unsupported assumption confidence: ${String(assumption.confidence)}.`));
|
|
132
|
+
}
|
|
133
|
+
if (typeof assumption.reviewRequired !== 'boolean') {
|
|
134
|
+
findings.push(finding('blocker', `${itemPrefix}.review-required.invalid`, 'Assumption reviewRequired must be boolean.'));
|
|
135
|
+
}
|
|
136
|
+
if (options.emitReviewFindings && id && parameter && (assumption.reviewRequired === true || assumption.confidence === 'review')) {
|
|
137
|
+
findings.push(finding('review', `assumption.${id}`, `${parameter} is an engineering assumption requiring review.`));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function validateLimitations(findings, limitations, prefix) {
|
|
142
|
+
if (!Array.isArray(limitations)) {
|
|
143
|
+
findings.push(finding('blocker', `${prefix}.limitations-invalid`, 'Limitations must be an array.'));
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (limitations.length === 0) {
|
|
147
|
+
findings.push(finding('blocker', `${prefix}.limitations-missing`, 'Experimental FEM artifacts must include at least one limitation.'));
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
for (const [index, limitation] of limitations.entries()) {
|
|
151
|
+
if (!isNonEmptyString(limitation)) {
|
|
152
|
+
findings.push(finding('blocker', `${prefix}.limitations.${index}.missing`, 'Limitation entries must be non-empty strings.'));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
48
156
|
function validateOptionalResultMetadata(findings, manifest, nodeCount, outlineNodeCount) {
|
|
49
157
|
const { resultFields, steps, datasets } = manifest;
|
|
50
158
|
if (resultFields != null && !Array.isArray(resultFields)) {
|
|
@@ -59,15 +167,49 @@ function validateOptionalResultMetadata(findings, manifest, nodeCount, outlineNo
|
|
|
59
167
|
if (!Array.isArray(resultFields) && !Array.isArray(steps) && !Array.isArray(datasets)) {
|
|
60
168
|
return;
|
|
61
169
|
}
|
|
170
|
+
if (!Array.isArray(resultFields) || !Array.isArray(steps) || !Array.isArray(datasets)) {
|
|
171
|
+
findings.push(finding('blocker', 'result.metadata.incomplete', 'Result metadata must include resultFields, steps, and datasets together, or omit all three for legacy manifests.'));
|
|
172
|
+
}
|
|
62
173
|
const validFieldLocations = new Set(['surface_nodes', 'outline_nodes', 'envelope']);
|
|
63
174
|
const validFieldQuantities = new Set(['displacement', 'reaction', 'load', 'stage_count']);
|
|
175
|
+
const validFieldComponents = new Set(['x', 'y', 'z', 'magnitude']);
|
|
64
176
|
const validDatasetSources = new Set(['visualization.disp', 'visualization.frame', 'envelope']);
|
|
65
177
|
const fieldIds = new Set();
|
|
66
178
|
const stepIds = new Set();
|
|
179
|
+
const stepIndexes = new Set();
|
|
67
180
|
const datasetIds = new Set();
|
|
68
|
-
const
|
|
181
|
+
const excavationStages = manifest.analysisCase.geometry.excavation?.stages ?? [];
|
|
182
|
+
const excavationStageIds = new Set(excavationStages.map((stage) => stage.id));
|
|
183
|
+
const excavationStageById = new Map(excavationStages.map((stage, index) => [stage.id, { stage, index }]));
|
|
69
184
|
const stepIdByIndex = new Map();
|
|
185
|
+
const stepLabelByIndex = new Map();
|
|
70
186
|
const fieldLocations = new Map();
|
|
187
|
+
const fieldInfoById = new Map();
|
|
188
|
+
const frameByKey = new Map();
|
|
189
|
+
const referencedFieldIds = new Set();
|
|
190
|
+
const referencedStepIds = new Set();
|
|
191
|
+
const envelopeValueByFieldId = new Map([
|
|
192
|
+
['max_settlement', manifest.envelope.maxSettlementMm],
|
|
193
|
+
['min_settlement', manifest.envelope.minSettlementMm],
|
|
194
|
+
['total_load', manifest.envelope.totalLoadKn],
|
|
195
|
+
['reaction', manifest.envelope.reactionKn],
|
|
196
|
+
['reaction_balance_ratio', manifest.envelope.reactionBalanceRatio],
|
|
197
|
+
['max_surface_settlement', manifest.envelope.maxSurfaceSettlementMm],
|
|
198
|
+
['max_horizontal_displacement', manifest.envelope.maxHorizontalDisplacementMm],
|
|
199
|
+
['max_wall_deflection', manifest.envelope.maxWallDeflectionMm],
|
|
200
|
+
['max_basal_heave', manifest.envelope.maxBasalHeaveMm],
|
|
201
|
+
['total_excavated_weight', manifest.envelope.totalExcavatedWeightKn],
|
|
202
|
+
['support_reaction', manifest.envelope.supportReactionKn],
|
|
203
|
+
['boundary_reaction', manifest.envelope.boundaryReactionKn],
|
|
204
|
+
['stage_count', manifest.envelope.stageCount],
|
|
205
|
+
['tunnel_diameter', manifest.envelope.tunnelDiameterM],
|
|
206
|
+
['tunnel_axis_depth', manifest.envelope.tunnelAxisDepthM],
|
|
207
|
+
['volume_loss', manifest.envelope.volumeLossPercent],
|
|
208
|
+
['trough_width', manifest.envelope.troughWidthM],
|
|
209
|
+
['influence_width', manifest.envelope.influenceWidthM],
|
|
210
|
+
['settlement_volume', manifest.envelope.settlementVolumeM3],
|
|
211
|
+
['settlement_volume_per_m', manifest.envelope.settlementVolumePerM],
|
|
212
|
+
].filter((entry) => isFiniteNumber(entry[1])));
|
|
71
213
|
if (Array.isArray(resultFields)) {
|
|
72
214
|
for (const [index, fieldInfo] of resultFields.entries()) {
|
|
73
215
|
const id = pushUniqueStringFinding(findings, fieldIds, fieldInfo.id, `result.fields.${index}.id`, 'Result field id');
|
|
@@ -83,8 +225,37 @@ function validateOptionalResultMetadata(findings, manifest, nodeCount, outlineNo
|
|
|
83
225
|
if (!validFieldQuantities.has(fieldInfo.quantity)) {
|
|
84
226
|
findings.push(finding('blocker', `result.fields.${index}.quantity.invalid`, `Unsupported result field quantity: ${String(fieldInfo.quantity)}.`));
|
|
85
227
|
}
|
|
86
|
-
if (
|
|
228
|
+
if (fieldInfo.component != null && !validFieldComponents.has(String(fieldInfo.component))) {
|
|
229
|
+
findings.push(finding('blocker', `result.fields.${index}.component.invalid`, `Unsupported result field component: ${String(fieldInfo.component)}.`));
|
|
230
|
+
}
|
|
231
|
+
if (fieldInfo.location !== 'envelope' && fieldInfo.quantity !== 'displacement') {
|
|
232
|
+
findings.push(finding('blocker', `result.fields.${index}.node-quantity-invalid`, 'Surface and outline node result fields must describe displacement quantities.'));
|
|
233
|
+
}
|
|
234
|
+
if (fieldInfo.location !== 'envelope' && fieldInfo.quantity === 'displacement' && fieldInfo.component == null) {
|
|
235
|
+
findings.push(finding('blocker', `result.fields.${index}.component.missing`, 'Node displacement result fields must include a displacement component.'));
|
|
236
|
+
}
|
|
237
|
+
if (fieldInfo.quantity !== 'displacement' && fieldInfo.component != null) {
|
|
238
|
+
findings.push(finding('blocker', `result.fields.${index}.component-unexpected`, 'Only displacement result fields may include a component.'));
|
|
239
|
+
}
|
|
240
|
+
if (fieldInfo.quantity === 'displacement' && fieldInfo.unit !== 'mm') {
|
|
241
|
+
findings.push(finding('blocker', `result.fields.${index}.unit.displacement-invalid`, 'Displacement result fields must use mm units.'));
|
|
242
|
+
}
|
|
243
|
+
if ((fieldInfo.quantity === 'reaction' || fieldInfo.quantity === 'load') && fieldInfo.unit !== 'kN') {
|
|
244
|
+
findings.push(finding('blocker', `result.fields.${index}.unit.force-invalid`, 'Reaction and load result fields must use kN units.'));
|
|
245
|
+
}
|
|
246
|
+
if (fieldInfo.quantity === 'stage_count' && fieldInfo.unit !== 'count') {
|
|
247
|
+
findings.push(finding('blocker', `result.fields.${index}.unit.stage-count-invalid`, 'Stage count result fields must use count units.'));
|
|
248
|
+
}
|
|
249
|
+
if (fieldInfo.signConvention != null && !isNonEmptyString(fieldInfo.signConvention)) {
|
|
250
|
+
findings.push(finding('blocker', `result.fields.${index}.sign-convention.invalid`, 'Result field sign convention must be a non-empty string when present.'));
|
|
251
|
+
}
|
|
252
|
+
if (id && fieldInfo.location === 'envelope' && !envelopeValueByFieldId.has(id)) {
|
|
253
|
+
findings.push(finding('blocker', `result.fields.${index}.envelope-field-unmapped`, `Envelope result field ${id} must map to a known result envelope value.`));
|
|
254
|
+
}
|
|
255
|
+
if (id) {
|
|
87
256
|
fieldLocations.set(id, fieldInfo.location);
|
|
257
|
+
fieldInfoById.set(id, fieldInfo);
|
|
258
|
+
}
|
|
88
259
|
}
|
|
89
260
|
}
|
|
90
261
|
if (Array.isArray(steps)) {
|
|
@@ -97,7 +268,12 @@ function validateOptionalResultMetadata(findings, manifest, nodeCount, outlineNo
|
|
|
97
268
|
findings.push(finding('blocker', `result.steps.${index}.index.invalid`, 'Result step index must be an integer greater than or equal to zero.'));
|
|
98
269
|
}
|
|
99
270
|
else if (id) {
|
|
271
|
+
if (stepIndexes.has(step.index)) {
|
|
272
|
+
findings.push(finding('blocker', `result.steps.${index}.index.duplicate`, `Result step index ${step.index} is duplicated.`));
|
|
273
|
+
}
|
|
274
|
+
stepIndexes.add(step.index);
|
|
100
275
|
stepIdByIndex.set(step.index, id);
|
|
276
|
+
stepLabelByIndex.set(step.index, step.label);
|
|
101
277
|
}
|
|
102
278
|
if (step.analysisStageId && !excavationStageIds.has(step.analysisStageId)) {
|
|
103
279
|
findings.push(finding('blocker', `result.steps.${index}.stage-unknown`, `Result step references unknown excavation stage ${step.analysisStageId}.`));
|
|
@@ -105,6 +281,44 @@ function validateOptionalResultMetadata(findings, manifest, nodeCount, outlineNo
|
|
|
105
281
|
if (step.depthM != null && (!Number.isFinite(step.depthM) || step.depthM < 0)) {
|
|
106
282
|
findings.push(finding('blocker', `result.steps.${index}.depth-invalid`, 'Result step depth must be finite and non-negative when present.'));
|
|
107
283
|
}
|
|
284
|
+
if (manifest.analysisCase.objective === 'excavation_deformation') {
|
|
285
|
+
if (!step.analysisStageId) {
|
|
286
|
+
findings.push(finding('blocker', `result.steps.${index}.stage-required`, 'Excavation result steps must reference an analysis stage.'));
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
const stageInfo = excavationStageById.get(step.analysisStageId);
|
|
290
|
+
if (stageInfo) {
|
|
291
|
+
if (step.label !== stageInfo.stage.label) {
|
|
292
|
+
findings.push(finding('blocker', `result.steps.${index}.stage-label-mismatch`, 'Excavation result step label must match the embedded analysis stage label.'));
|
|
293
|
+
}
|
|
294
|
+
if (step.depthM == null) {
|
|
295
|
+
findings.push(finding('blocker', `result.steps.${index}.stage-depth-required`, 'Excavation result steps must carry the analysis stage depth.'));
|
|
296
|
+
}
|
|
297
|
+
else if (Number.isFinite(step.depthM) && !isApproxEqual(step.depthM, stageInfo.stage.depthM, 1e-6)) {
|
|
298
|
+
findings.push(finding('blocker', `result.steps.${index}.stage-depth-mismatch`, 'Excavation result step depth must match the embedded analysis stage depth.'));
|
|
299
|
+
}
|
|
300
|
+
if (Number.isInteger(step.index) && step.index !== stageInfo.index) {
|
|
301
|
+
findings.push(finding('blocker', `result.steps.${index}.stage-index-mismatch`, 'Excavation result step index must match the embedded analysis stage order.'));
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
else {
|
|
307
|
+
if (step.analysisStageId != null) {
|
|
308
|
+
findings.push(finding('blocker', `result.steps.${index}.stage-unexpected`, 'Non-staged FEM result steps must not reference analysis stages.'));
|
|
309
|
+
}
|
|
310
|
+
if (step.depthM != null) {
|
|
311
|
+
findings.push(finding('blocker', `result.steps.${index}.depth-unexpected`, 'Non-staged FEM result steps must not carry staged excavation depths.'));
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
if (Array.isArray(manifest.visualization.frames)) {
|
|
317
|
+
for (const frame of manifest.visualization.frames) {
|
|
318
|
+
const stepId = stepIdByIndex.get(frame.stageIndex ?? 0);
|
|
319
|
+
if (isNonEmptyString(frame.field) && stepId) {
|
|
320
|
+
frameByKey.set(`${frame.field}:${stepId}`, frame);
|
|
321
|
+
}
|
|
108
322
|
}
|
|
109
323
|
}
|
|
110
324
|
if (Array.isArray(datasets)) {
|
|
@@ -113,9 +327,15 @@ function validateOptionalResultMetadata(findings, manifest, nodeCount, outlineNo
|
|
|
113
327
|
if (!fieldIds.has(dataset.fieldId)) {
|
|
114
328
|
findings.push(finding('blocker', `result.datasets.${index}.field-unknown`, `Result dataset references unknown field ${String(dataset.fieldId)}.`));
|
|
115
329
|
}
|
|
330
|
+
else {
|
|
331
|
+
referencedFieldIds.add(dataset.fieldId);
|
|
332
|
+
}
|
|
116
333
|
if (dataset.stepId != null && !stepIds.has(dataset.stepId)) {
|
|
117
334
|
findings.push(finding('blocker', `result.datasets.${index}.step-unknown`, `Result dataset references unknown step ${String(dataset.stepId)}.`));
|
|
118
335
|
}
|
|
336
|
+
else if (dataset.stepId != null) {
|
|
337
|
+
referencedStepIds.add(dataset.stepId);
|
|
338
|
+
}
|
|
119
339
|
if (!Array.isArray(dataset.values) || dataset.values.length === 0) {
|
|
120
340
|
findings.push(finding('blocker', `result.datasets.${index}.values.empty`, 'Result dataset values must be a non-empty array.'));
|
|
121
341
|
continue;
|
|
@@ -134,6 +354,27 @@ function validateOptionalResultMetadata(findings, manifest, nodeCount, outlineNo
|
|
|
134
354
|
findings.push(finding('blocker', `result.datasets.${index}.source.invalid`, `Unsupported result dataset source: ${String(dataset.source)}.`));
|
|
135
355
|
}
|
|
136
356
|
const location = fieldLocations.get(dataset.fieldId);
|
|
357
|
+
if ((dataset.source === 'visualization.disp' || dataset.source === 'visualization.frame') && dataset.stepId == null) {
|
|
358
|
+
findings.push(finding('blocker', `result.datasets.${index}.step-required`, 'Visualization datasets must reference a result step.'));
|
|
359
|
+
}
|
|
360
|
+
if ((dataset.source === 'visualization.disp' || dataset.source === 'visualization.frame') && location === 'envelope') {
|
|
361
|
+
findings.push(finding('blocker', `result.datasets.${index}.visualization-field-invalid`, 'Visualization datasets must reference surface or outline node result fields, not envelope fields.'));
|
|
362
|
+
}
|
|
363
|
+
if (dataset.source === 'envelope' && location !== 'envelope') {
|
|
364
|
+
findings.push(finding('blocker', `result.datasets.${index}.envelope-field-invalid`, 'Envelope datasets must reference an envelope result field.'));
|
|
365
|
+
}
|
|
366
|
+
if (dataset.source === 'visualization.disp' && !arraysApproximatelyEqual(dataset.values, manifest.visualization.disp, 1e-12)) {
|
|
367
|
+
findings.push(finding('blocker', `result.datasets.${index}.disp-values-mismatch`, 'Visualization displacement dataset values must match the manifest visualization displacement array.'));
|
|
368
|
+
}
|
|
369
|
+
if (dataset.source === 'visualization.frame' && dataset.stepId != null) {
|
|
370
|
+
const frame = frameByKey.get(`${dataset.fieldId}:${dataset.stepId}`);
|
|
371
|
+
if (!frame) {
|
|
372
|
+
findings.push(finding('blocker', `result.datasets.${index}.frame-missing`, 'Visualization frame dataset has no matching frame payload.'));
|
|
373
|
+
}
|
|
374
|
+
else if (!arraysApproximatelyEqual(dataset.values, frame.disp, 1e-12)) {
|
|
375
|
+
findings.push(finding('blocker', `result.datasets.${index}.frame-values-mismatch`, 'Visualization frame dataset values must match the referenced frame displacement array.'));
|
|
376
|
+
}
|
|
377
|
+
}
|
|
137
378
|
const valueCount = dataset.values.length / dataset.stride;
|
|
138
379
|
if (location === 'surface_nodes' && valueCount !== nodeCount) {
|
|
139
380
|
findings.push(finding('blocker', `result.datasets.${index}.surface-count-mismatch`, 'Surface-node dataset values must match visualization base node count.'));
|
|
@@ -144,18 +385,53 @@ function validateOptionalResultMetadata(findings, manifest, nodeCount, outlineNo
|
|
|
144
385
|
if (dataset.source === 'envelope' && dataset.values.length !== 1) {
|
|
145
386
|
findings.push(finding('blocker', `result.datasets.${index}.envelope-count-invalid`, 'Envelope datasets must contain exactly one value.'));
|
|
146
387
|
}
|
|
388
|
+
if (dataset.source === 'envelope' && dataset.values.length === 1) {
|
|
389
|
+
const expectedEnvelopeValue = envelopeValueByFieldId.get(dataset.fieldId);
|
|
390
|
+
const fieldInfo = fieldInfoById.get(dataset.fieldId);
|
|
391
|
+
if (!isFiniteNumber(expectedEnvelopeValue)) {
|
|
392
|
+
findings.push(finding('blocker', `result.datasets.${index}.envelope-field-unmapped`, `Envelope dataset field ${dataset.fieldId} must map to a known result envelope value.`));
|
|
393
|
+
}
|
|
394
|
+
else if (!isApproxEqual(dataset.values[0], expectedEnvelopeValue, 1e-6)) {
|
|
395
|
+
findings.push(finding('blocker', `result.datasets.${index}.envelope-values-mismatch`, `Envelope dataset ${fieldInfo?.label ?? dataset.fieldId} must match the result envelope value.`));
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
if (Array.isArray(resultFields) && Array.isArray(datasets)) {
|
|
401
|
+
for (const [index, fieldInfo] of resultFields.entries()) {
|
|
402
|
+
if (isNonEmptyString(fieldInfo.id) && !referencedFieldIds.has(fieldInfo.id)) {
|
|
403
|
+
findings.push(finding('blocker', `result.fields.${index}.dataset-missing`, `Result field ${fieldInfo.id} has no backing dataset metadata.`));
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
if (Array.isArray(steps) && Array.isArray(datasets)) {
|
|
408
|
+
for (const [index, step] of steps.entries()) {
|
|
409
|
+
if (isNonEmptyString(step.id) && !referencedStepIds.has(step.id)) {
|
|
410
|
+
findings.push(finding('blocker', `result.steps.${index}.dataset-missing`, `Result step ${step.id} has no backing dataset metadata.`));
|
|
411
|
+
}
|
|
147
412
|
}
|
|
148
413
|
}
|
|
149
414
|
if (Array.isArray(resultFields) && Array.isArray(steps) && Array.isArray(datasets) && Array.isArray(manifest.visualization.frames)) {
|
|
150
415
|
const datasetKeys = new Set(datasets.map((dataset) => `${dataset.fieldId}:${dataset.stepId ?? ''}`));
|
|
151
416
|
for (const [index, frame] of manifest.visualization.frames.entries()) {
|
|
152
|
-
|
|
417
|
+
const fieldInfo = fieldInfoById.get(frame.field);
|
|
418
|
+
if (!fieldInfo) {
|
|
153
419
|
findings.push(finding('blocker', `result.frames.${index}.field-metadata-missing`, `Frame field ${frame.field} has no resultFields metadata.`));
|
|
154
420
|
}
|
|
421
|
+
else if (isNonEmptyString(frame.fieldLabel) && frame.fieldLabel !== fieldInfo.label) {
|
|
422
|
+
findings.push(finding('blocker', `result.frames.${index}.field-label-mismatch`, `Frame field label must match result field metadata for ${frame.field}.`));
|
|
423
|
+
}
|
|
155
424
|
const stepId = stepIdByIndex.get(frame.stageIndex ?? 0);
|
|
156
|
-
if (
|
|
425
|
+
if (!stepId) {
|
|
426
|
+
findings.push(finding('blocker', `result.frames.${index}.stage-unknown`, 'Frame stage index must reference a known result step.'));
|
|
427
|
+
}
|
|
428
|
+
else if (!datasetKeys.has(`${frame.field}:${stepId}`)) {
|
|
157
429
|
findings.push(finding('blocker', `result.frames.${index}.dataset-missing`, `Frame ${frame.field}/${stepId} has no matching dataset metadata.`));
|
|
158
430
|
}
|
|
431
|
+
const stepLabel = stepLabelByIndex.get(frame.stageIndex ?? 0);
|
|
432
|
+
if (stepLabel && isNonEmptyString(frame.stageLabel) && frame.stageLabel !== stepLabel) {
|
|
433
|
+
findings.push(finding('blocker', `result.frames.${index}.stage-label-mismatch`, 'Frame stage label must match result step metadata.'));
|
|
434
|
+
}
|
|
159
435
|
}
|
|
160
436
|
}
|
|
161
437
|
}
|
|
@@ -165,7 +441,11 @@ function isFemResultManifestShape(value) {
|
|
|
165
441
|
const visualization = value.visualization;
|
|
166
442
|
const envelope = value.envelope;
|
|
167
443
|
const mesh = value.mesh;
|
|
444
|
+
const backend = value.backend;
|
|
445
|
+
const validation = value.validation;
|
|
168
446
|
return (isFemAnalysisCaseShape(value.analysisCase) &&
|
|
447
|
+
isRecord(backend) &&
|
|
448
|
+
isRecord(validation) &&
|
|
169
449
|
isRecord(envelope) &&
|
|
170
450
|
isRecord(mesh) &&
|
|
171
451
|
isRecord(visualization) &&
|
|
@@ -188,11 +468,21 @@ export function validateFemAnalysisCase(caseFile) {
|
|
|
188
468
|
]);
|
|
189
469
|
}
|
|
190
470
|
const { domain, raft, excavation, tunnel } = caseFile.geometry;
|
|
191
|
-
const material = caseFile.materials[0];
|
|
192
|
-
const load = caseFile.loads[0];
|
|
193
471
|
if (caseFile.schemaVersion !== 'fem-analysis-case.v0') {
|
|
194
472
|
findings.push(finding('blocker', 'schema.unsupported', 'Only fem-analysis-case.v0 is supported.'));
|
|
195
473
|
}
|
|
474
|
+
if (!isNonEmptyString(caseFile.caseId)) {
|
|
475
|
+
findings.push(finding('blocker', 'case-id.missing', 'FEM analysis case id must be a non-empty string.'));
|
|
476
|
+
}
|
|
477
|
+
if (!isNonEmptyString(caseFile.title)) {
|
|
478
|
+
findings.push(finding('blocker', 'title.missing', 'FEM analysis case title must be a non-empty string.'));
|
|
479
|
+
}
|
|
480
|
+
if (!isNonEmptyString(caseFile.createdBy)) {
|
|
481
|
+
findings.push(finding('blocker', 'created-by.missing', 'FEM analysis case creator must be a non-empty string.'));
|
|
482
|
+
}
|
|
483
|
+
if (!isNonEmptyString(caseFile.createdAt) || Number.isNaN(Date.parse(caseFile.createdAt))) {
|
|
484
|
+
findings.push(finding('blocker', 'created-at.invalid', 'FEM analysis case createdAt must be an ISO-compatible timestamp.'));
|
|
485
|
+
}
|
|
196
486
|
if (!caseFile.experimental) {
|
|
197
487
|
findings.push(finding('blocker', 'mode.experimental-required', 'FEM cases must be explicitly marked experimental.'));
|
|
198
488
|
}
|
|
@@ -208,6 +498,21 @@ export function validateFemAnalysisCase(caseFile) {
|
|
|
208
498
|
if (caseFile.objective === 'tunnel_volume_loss_settlement' && caseFile.analysisType !== 'empirical_3d_settlement_surface') {
|
|
209
499
|
findings.push(finding('blocker', 'analysis.unsupported', `Unsupported analysis type: ${caseFile.analysisType}.`));
|
|
210
500
|
}
|
|
501
|
+
if (caseFile.geometry.domain.type !== 'box') {
|
|
502
|
+
findings.push(finding('blocker', 'geometry.domain.type-invalid', `Unsupported domain type: ${String(caseFile.geometry.domain.type)}.`));
|
|
503
|
+
}
|
|
504
|
+
if (caseFile.units.length !== 'm' ||
|
|
505
|
+
caseFile.units.force !== 'kN' ||
|
|
506
|
+
caseFile.units.stress !== 'kPa' ||
|
|
507
|
+
caseFile.units.density !== 'kN/m3' ||
|
|
508
|
+
caseFile.units.displacement !== 'mm') {
|
|
509
|
+
findings.push(finding('blocker', 'units.unsupported', 'FEM cases must use m, kN, kPa, kN/m3, and mm units. Values must be converted before preview execution.'));
|
|
510
|
+
}
|
|
511
|
+
pushPositiveNumberFindings(findings, [
|
|
512
|
+
[domain.lengthM, 'geometry.domain.length', 'Domain length'],
|
|
513
|
+
[domain.widthM, 'geometry.domain.width', 'Domain width'],
|
|
514
|
+
[domain.depthM, 'geometry.domain.depth', 'Domain depth'],
|
|
515
|
+
]);
|
|
211
516
|
if (domain.lengthM <= 0 || domain.widthM <= 0 || domain.depthM <= 0) {
|
|
212
517
|
findings.push(finding('blocker', 'geometry.domain-invalid', 'Domain dimensions must be positive.'));
|
|
213
518
|
}
|
|
@@ -216,6 +521,16 @@ export function validateFemAnalysisCase(caseFile) {
|
|
|
216
521
|
findings.push(finding('blocker', 'geometry.raft-missing', 'Foundation-settlement cases require raft geometry.'));
|
|
217
522
|
}
|
|
218
523
|
else {
|
|
524
|
+
if (raft.type !== 'raft') {
|
|
525
|
+
findings.push(finding('blocker', 'geometry.raft.type-invalid', `Unsupported raft geometry type: ${String(raft.type)}.`));
|
|
526
|
+
}
|
|
527
|
+
pushPositiveNumberFindings(findings, [
|
|
528
|
+
[raft.lengthM, 'geometry.raft.length', 'Raft length'],
|
|
529
|
+
[raft.widthM, 'geometry.raft.width', 'Raft width'],
|
|
530
|
+
[raft.thicknessM, 'geometry.raft.thickness', 'Raft thickness'],
|
|
531
|
+
]);
|
|
532
|
+
pushFiniteNumberFinding(findings, raft.centerXM, 'geometry.raft.center-x', 'Raft center X');
|
|
533
|
+
pushFiniteNumberFinding(findings, raft.centerYM, 'geometry.raft.center-y', 'Raft center Y');
|
|
219
534
|
if (raft.lengthM <= 0 || raft.widthM <= 0 || raft.thicknessM <= 0) {
|
|
220
535
|
findings.push(finding('blocker', 'geometry.raft-invalid', 'Raft dimensions must be positive.'));
|
|
221
536
|
}
|
|
@@ -232,6 +547,20 @@ export function validateFemAnalysisCase(caseFile) {
|
|
|
232
547
|
findings.push(finding('blocker', 'geometry.excavation-missing', 'Excavation-deformation cases require excavation geometry.'));
|
|
233
548
|
}
|
|
234
549
|
else {
|
|
550
|
+
if (excavation.type !== 'braced_excavation') {
|
|
551
|
+
findings.push(finding('blocker', 'geometry.excavation.type-invalid', `Unsupported excavation geometry type: ${String(excavation.type)}.`));
|
|
552
|
+
}
|
|
553
|
+
pushPositiveNumberFindings(findings, [
|
|
554
|
+
[excavation.lengthM, 'geometry.excavation.length', 'Excavation length'],
|
|
555
|
+
[excavation.widthM, 'geometry.excavation.width', 'Excavation width'],
|
|
556
|
+
[excavation.finalDepthM, 'geometry.excavation.final-depth', 'Excavation final depth'],
|
|
557
|
+
[excavation.wallToeDepthM, 'geometry.excavation.wall-toe-depth', 'Excavation wall toe depth'],
|
|
558
|
+
]);
|
|
559
|
+
pushFiniteNumberFinding(findings, excavation.centerXM, 'geometry.excavation.center-x', 'Excavation center X');
|
|
560
|
+
pushFiniteNumberFinding(findings, excavation.centerYM, 'geometry.excavation.center-y', 'Excavation center Y');
|
|
561
|
+
if (!['diaphragm_wall', 'secant_pile_wall', 'soldier_pile_lagging', 'unsupported_screening'].includes(excavation.wallType)) {
|
|
562
|
+
findings.push(finding('blocker', 'geometry.excavation.wall-type-invalid', `Unsupported excavation wall type: ${String(excavation.wallType)}.`));
|
|
563
|
+
}
|
|
235
564
|
if (excavation.lengthM <= 0 ||
|
|
236
565
|
excavation.widthM <= 0 ||
|
|
237
566
|
excavation.finalDepthM <= 0 ||
|
|
@@ -248,7 +577,12 @@ export function validateFemAnalysisCase(caseFile) {
|
|
|
248
577
|
findings.push(finding('blocker', 'stages.missing', 'Excavation cases require at least one construction stage.'));
|
|
249
578
|
}
|
|
250
579
|
let previousDepth = 0;
|
|
251
|
-
|
|
580
|
+
const stageIds = new Set();
|
|
581
|
+
for (const [index, stage] of excavation.stages.entries()) {
|
|
582
|
+
pushUniqueStringFinding(findings, stageIds, stage.id, `stages.${index}.id`, 'Excavation stage id');
|
|
583
|
+
if (!isNonEmptyString(stage.label)) {
|
|
584
|
+
findings.push(finding('blocker', `stages.${index}.label.missing`, 'Excavation stage label must be a non-empty string.'));
|
|
585
|
+
}
|
|
252
586
|
if (!Number.isFinite(stage.depthM) || stage.depthM <= previousDepth || stage.depthM > excavation.finalDepthM) {
|
|
253
587
|
findings.push(finding('blocker', 'stages.depth-invalid', 'Excavation stage depths must increase and stay within the final excavation depth.'));
|
|
254
588
|
break;
|
|
@@ -270,6 +604,18 @@ export function validateFemAnalysisCase(caseFile) {
|
|
|
270
604
|
findings.push(finding('blocker', 'geometry.tunnel-missing', 'Tunnel volume-loss settlement cases require tunnel geometry.'));
|
|
271
605
|
}
|
|
272
606
|
else {
|
|
607
|
+
if (tunnel.type !== 'tunnel') {
|
|
608
|
+
findings.push(finding('blocker', 'geometry.tunnel.type-invalid', `Unsupported tunnel geometry type: ${String(tunnel.type)}.`));
|
|
609
|
+
}
|
|
610
|
+
pushPositiveNumberFindings(findings, [
|
|
611
|
+
[tunnel.diameterM, 'geometry.tunnel.diameter', 'Tunnel diameter'],
|
|
612
|
+
[tunnel.axisDepthM, 'geometry.tunnel.axis-depth', 'Tunnel axis depth'],
|
|
613
|
+
[tunnel.lengthM, 'geometry.tunnel.length', 'Tunnel length'],
|
|
614
|
+
[tunnel.volumeLossPercent, 'geometry.tunnel.volume-loss', 'Tunnel volume loss'],
|
|
615
|
+
[tunnel.troughWidthParameterK, 'geometry.tunnel.trough-width-factor', 'Tunnel trough-width factor'],
|
|
616
|
+
]);
|
|
617
|
+
pushFiniteNumberFinding(findings, tunnel.centerXM, 'geometry.tunnel.center-x', 'Tunnel center X');
|
|
618
|
+
pushFiniteNumberFinding(findings, tunnel.centerYM, 'geometry.tunnel.center-y', 'Tunnel center Y');
|
|
273
619
|
if (tunnel.diameterM <= 0 ||
|
|
274
620
|
tunnel.axisDepthM <= 0 ||
|
|
275
621
|
tunnel.lengthM <= 0 ||
|
|
@@ -296,46 +642,153 @@ export function validateFemAnalysisCase(caseFile) {
|
|
|
296
642
|
findings.push(finding('review', 'tunnel.empirical-preview', 'Tunnel preview uses an empirical Gaussian settlement surface from prescribed volume loss; it is not a tunnel lining, face-stability, or coupled FEM solver.'));
|
|
297
643
|
}
|
|
298
644
|
}
|
|
299
|
-
if (
|
|
645
|
+
if (caseFile.materials.length === 0) {
|
|
300
646
|
findings.push(finding('blocker', 'material.missing', 'At least one material is required.'));
|
|
301
647
|
}
|
|
302
648
|
else {
|
|
303
|
-
|
|
304
|
-
|
|
649
|
+
const materialIds = new Set();
|
|
650
|
+
for (const [index, material] of caseFile.materials.entries()) {
|
|
651
|
+
const prefix = index === 0 ? 'material' : `material.${index}`;
|
|
652
|
+
if (!isRecord(material)) {
|
|
653
|
+
findings.push(finding('blocker', `${prefix}.shape-invalid`, 'Material must be an object.'));
|
|
654
|
+
continue;
|
|
655
|
+
}
|
|
656
|
+
pushUniqueStringFinding(findings, materialIds, material.id, `${prefix}.id`, 'Material id');
|
|
657
|
+
if (!isNonEmptyString(material.name)) {
|
|
658
|
+
findings.push(finding('blocker', `${prefix}.name.missing`, 'Material name must be a non-empty string.'));
|
|
659
|
+
}
|
|
660
|
+
if (material.model !== 'linear_elastic') {
|
|
661
|
+
findings.push(finding('blocker', `${prefix}.unsupported`, `Unsupported material model: ${String(material.model)}.`));
|
|
662
|
+
}
|
|
663
|
+
if (!Number.isFinite(material.elasticModulusKpa) || material.elasticModulusKpa <= 0) {
|
|
664
|
+
findings.push(finding('blocker', `${prefix}.elastic-modulus-invalid`, 'Elastic modulus must be positive.'));
|
|
665
|
+
}
|
|
666
|
+
if (!Number.isFinite(material.poissonRatio) || material.poissonRatio <= 0 || material.poissonRatio >= 0.5) {
|
|
667
|
+
findings.push(finding('blocker', `${prefix}.poisson-invalid`, 'Poisson ratio must be between 0 and 0.5.'));
|
|
668
|
+
}
|
|
669
|
+
if (!Number.isFinite(material.unitWeightKnM3) || material.unitWeightKnM3 <= 0) {
|
|
670
|
+
findings.push(finding('blocker', `${prefix}.unit-weight-invalid`, 'Unit weight must be positive.'));
|
|
671
|
+
}
|
|
672
|
+
validateEvidenceRefs(findings, material.evidenceRefs, prefix);
|
|
673
|
+
validateAssumptions(findings, material.assumptions, prefix);
|
|
305
674
|
}
|
|
306
|
-
|
|
307
|
-
|
|
675
|
+
}
|
|
676
|
+
if (caseFile.objective === 'tunnel_volume_loss_settlement' && caseFile.loads.length > 0) {
|
|
677
|
+
findings.push(finding('blocker', 'load.unsupported-for-objective', 'Tunnel volume-loss settlement previews use explicit volume loss and must not include pressure loads.'));
|
|
678
|
+
}
|
|
679
|
+
if (caseFile.loads.length === 0) {
|
|
680
|
+
if (caseFile.objective === 'foundation_settlement') {
|
|
681
|
+
findings.push(finding('blocker', 'load.missing', 'A raft pressure load is required.'));
|
|
308
682
|
}
|
|
309
|
-
if (
|
|
310
|
-
findings.push(finding('blocker', '
|
|
683
|
+
else if (caseFile.objective === 'excavation_deformation') {
|
|
684
|
+
findings.push(finding('blocker', 'load.missing', 'An excavation surcharge/load assumption is required.'));
|
|
311
685
|
}
|
|
312
686
|
}
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
687
|
+
else {
|
|
688
|
+
const loadIds = new Set();
|
|
689
|
+
for (const [index, load] of caseFile.loads.entries()) {
|
|
690
|
+
const prefix = index === 0 ? 'load' : `load.${index}`;
|
|
691
|
+
if (!isRecord(load)) {
|
|
692
|
+
findings.push(finding('blocker', `${prefix}.shape-invalid`, 'Load must be an object.'));
|
|
693
|
+
continue;
|
|
694
|
+
}
|
|
695
|
+
pushUniqueStringFinding(findings, loadIds, load.id, `${prefix}.id`, 'Load id');
|
|
696
|
+
if (load.type !== 'uniform_pressure') {
|
|
697
|
+
findings.push(finding('blocker', `${prefix}.type-invalid`, `Unsupported load type: ${String(load.type)}.`));
|
|
698
|
+
}
|
|
699
|
+
if (!Number.isFinite(load.pressureKpa) || load.pressureKpa <= 0) {
|
|
700
|
+
findings.push(finding('blocker', `${prefix}.pressure-invalid`, 'Uniform pressure must be positive.'));
|
|
701
|
+
}
|
|
702
|
+
if (caseFile.objective === 'foundation_settlement' && load.target !== 'raft') {
|
|
703
|
+
findings.push(finding('blocker', `${prefix}.target-invalid`, 'Foundation-settlement load must target the raft.'));
|
|
704
|
+
}
|
|
705
|
+
if (caseFile.objective === 'excavation_deformation' && load.target !== 'excavation_surcharge') {
|
|
706
|
+
findings.push(finding('blocker', `${prefix}.target-invalid`, 'Excavation-deformation load must target excavation_surcharge.'));
|
|
707
|
+
}
|
|
708
|
+
validateEvidenceRefs(findings, load.evidenceRefs, prefix);
|
|
709
|
+
validateAssumptions(findings, load.assumptions, prefix);
|
|
316
710
|
}
|
|
317
711
|
}
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
else if (caseFile.objective === 'foundation_settlement' && load.target !== 'raft') {
|
|
322
|
-
findings.push(finding('blocker', 'load.target-invalid', 'Foundation-settlement load must target the raft.'));
|
|
712
|
+
const { divisionsX, divisionsY, divisionsZ } = caseFile.mesh;
|
|
713
|
+
if (caseFile.mesh.elementType !== 'hex8') {
|
|
714
|
+
findings.push(finding('blocker', 'mesh.element-type-invalid', `Unsupported mesh element type: ${String(caseFile.mesh.elementType)}.`));
|
|
323
715
|
}
|
|
324
|
-
|
|
325
|
-
|
|
716
|
+
if (!Number.isInteger(divisionsX) ||
|
|
717
|
+
!Number.isInteger(divisionsY) ||
|
|
718
|
+
!Number.isInteger(divisionsZ)) {
|
|
719
|
+
findings.push(finding('blocker', 'mesh.divisions-integer', 'Mesh divisions must be finite integers.'));
|
|
326
720
|
}
|
|
327
|
-
const { divisionsX, divisionsY, divisionsZ } = caseFile.mesh;
|
|
328
721
|
if (divisionsX < 2 || divisionsY < 2 || divisionsZ < 1) {
|
|
329
722
|
findings.push(finding('blocker', 'mesh.too-coarse', 'Mesh divisions must be at least 2 x 2 x 1.'));
|
|
330
723
|
}
|
|
331
|
-
if (
|
|
332
|
-
|
|
724
|
+
if (Number.isInteger(divisionsX) && Number.isInteger(divisionsY) && Number.isInteger(divisionsZ)) {
|
|
725
|
+
const nodeCount = (divisionsX + 1) * (divisionsY + 1) * (divisionsZ + 1);
|
|
726
|
+
if (!Number.isFinite(nodeCount) || nodeCount > FEM_MAX_PREVIEW_MESH_NODES) {
|
|
727
|
+
findings.push(finding('blocker', 'mesh.preview-node-limit', `Experimental FEM/WebGL previews are capped at ${FEM_MAX_PREVIEW_MESH_NODES} nodes.`));
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
if (caseFile.boundaryConditions.length === 0) {
|
|
731
|
+
findings.push(finding('blocker', 'boundary.missing', 'At least one boundary condition is required.'));
|
|
333
732
|
}
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
733
|
+
else {
|
|
734
|
+
const boundaryIds = new Set();
|
|
735
|
+
let hasFixedBase = false;
|
|
736
|
+
let hasSideRollers = false;
|
|
737
|
+
for (const [index, boundary] of caseFile.boundaryConditions.entries()) {
|
|
738
|
+
const prefix = index === 0 ? 'boundary' : `boundary.${index}`;
|
|
739
|
+
if (!isRecord(boundary)) {
|
|
740
|
+
findings.push(finding('blocker', `${prefix}.shape-invalid`, 'Boundary condition must be an object.'));
|
|
741
|
+
continue;
|
|
742
|
+
}
|
|
743
|
+
pushUniqueStringFinding(findings, boundaryIds, boundary.id, `${prefix}.id`, 'Boundary condition id');
|
|
744
|
+
if (boundary.type === 'fixed_base')
|
|
745
|
+
hasFixedBase = true;
|
|
746
|
+
if (boundary.type === 'side_rollers')
|
|
747
|
+
hasSideRollers = true;
|
|
748
|
+
if (boundary.type !== 'fixed_base' && boundary.type !== 'side_rollers') {
|
|
749
|
+
findings.push(finding('blocker', `${prefix}.type-invalid`, `Unsupported boundary condition type: ${String(boundary.type)}.`));
|
|
750
|
+
}
|
|
751
|
+
if (!isNonEmptyString(boundary.description)) {
|
|
752
|
+
findings.push(finding('blocker', `${prefix}.description.missing`, 'Boundary condition description must be a non-empty string.'));
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
if (!hasFixedBase) {
|
|
756
|
+
findings.push(finding('blocker', 'boundary.fixed-base-missing', 'FEM preview cases require a fixed-base boundary condition.'));
|
|
337
757
|
}
|
|
758
|
+
if (!hasSideRollers) {
|
|
759
|
+
findings.push(finding('blocker', 'boundary.side-rollers-missing', 'FEM preview cases require side-roller boundary conditions.'));
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
if (!['not_modelled', 'below_domain', 'specified'].includes(caseFile.groundwater.condition)) {
|
|
763
|
+
findings.push(finding('blocker', 'groundwater.condition-invalid', `Unsupported groundwater condition: ${String(caseFile.groundwater.condition)}.`));
|
|
764
|
+
}
|
|
765
|
+
if (!isNonEmptyString(caseFile.groundwater.note)) {
|
|
766
|
+
findings.push(finding('blocker', 'groundwater.note.missing', 'Groundwater note must be a non-empty string.'));
|
|
767
|
+
}
|
|
768
|
+
if (typeof caseFile.groundwater.reviewRequired !== 'boolean') {
|
|
769
|
+
findings.push(finding('blocker', 'groundwater.review-required.invalid', 'Groundwater reviewRequired must be boolean.'));
|
|
770
|
+
}
|
|
771
|
+
if (caseFile.groundwater.depthM != null) {
|
|
772
|
+
pushFiniteNumberFinding(findings, caseFile.groundwater.depthM, 'groundwater.depth', 'Groundwater depth', { nonNegative: true });
|
|
773
|
+
}
|
|
774
|
+
if (caseFile.groundwater.condition === 'specified' && caseFile.groundwater.depthM == null) {
|
|
775
|
+
findings.push(finding('blocker', 'groundwater.depth-required', 'Specified groundwater conditions require a groundwater depth.'));
|
|
776
|
+
}
|
|
777
|
+
if (caseFile.groundwater.condition === 'specified' && caseFile.groundwater.depthM != null && caseFile.groundwater.depthM > domain.depthM) {
|
|
778
|
+
findings.push(finding('blocker', 'groundwater.depth-outside-domain', 'Specified groundwater depth must fall within the model domain. Use below_domain for deeper groundwater.'));
|
|
779
|
+
}
|
|
780
|
+
if (caseFile.groundwater.condition === 'below_domain' && caseFile.groundwater.depthM != null && caseFile.groundwater.depthM <= domain.depthM) {
|
|
781
|
+
findings.push(finding('blocker', 'groundwater.below-domain-depth-invalid', 'Below-domain groundwater depth must be deeper than the model domain when supplied.'));
|
|
782
|
+
}
|
|
783
|
+
if (caseFile.groundwater.condition === 'not_modelled' && caseFile.groundwater.depthM != null) {
|
|
784
|
+
findings.push(finding('blocker', 'groundwater.depth-unused', 'Do not provide groundwater depth when groundwater is not modelled.'));
|
|
338
785
|
}
|
|
786
|
+
if (caseFile.groundwater.reviewRequired) {
|
|
787
|
+
findings.push(finding('review', 'groundwater.review-required', caseFile.groundwater.note));
|
|
788
|
+
}
|
|
789
|
+
validateAssumptions(findings, caseFile.assumptions, 'case', { emitReviewFindings: true });
|
|
790
|
+
validateEvidenceRefs(findings, caseFile.evidenceRefs, 'case');
|
|
791
|
+
validateLimitations(findings, caseFile.limitations, 'case');
|
|
339
792
|
return summary(findings);
|
|
340
793
|
}
|
|
341
794
|
function pushFiniteArrayFindings(findings, name, values, multipleOf) {
|
|
@@ -363,6 +816,155 @@ function pushIndexArrayFindings(findings, name, values, nodeCount, multipleOf) {
|
|
|
363
816
|
findings.push(finding('blocker', `result.${name}.index-invalid`, `${name} references node index ${value}, outside the 0-${Math.max(0, nodeCount - 1)} range.`));
|
|
364
817
|
return;
|
|
365
818
|
}
|
|
819
|
+
if (value > FEM_WEBGL_UINT16_INDEX_LIMIT) {
|
|
820
|
+
findings.push(finding('blocker', `result.${name}.webgl-index-limit`, `${name} references node index ${value}, above the WebGL 1 unsigned-short index limit.`));
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
function validateEmbeddedValidationSummary(findings, manifestValidation, caseValidation) {
|
|
826
|
+
if (!isRecord(manifestValidation)) {
|
|
827
|
+
findings.push(finding('blocker', 'result.validation.shape-invalid', 'Embedded validation summary must be an object.'));
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
if (!['ready', 'review', 'blocked'].includes(manifestValidation.status)) {
|
|
831
|
+
findings.push(finding('blocker', 'result.validation.status-invalid', `Unsupported embedded validation status: ${String(manifestValidation.status)}.`));
|
|
832
|
+
}
|
|
833
|
+
if (!Number.isInteger(manifestValidation.blockers) || manifestValidation.blockers < 0) {
|
|
834
|
+
findings.push(finding('blocker', 'result.validation.blockers-invalid', 'Embedded validation blocker count must be a non-negative integer.'));
|
|
835
|
+
}
|
|
836
|
+
if (!Number.isInteger(manifestValidation.reviewItems) || manifestValidation.reviewItems < 0) {
|
|
837
|
+
findings.push(finding('blocker', 'result.validation.review-items-invalid', 'Embedded validation review item count must be a non-negative integer.'));
|
|
838
|
+
}
|
|
839
|
+
if (!Array.isArray(manifestValidation.findings)) {
|
|
840
|
+
findings.push(finding('blocker', 'result.validation.findings-invalid', 'Embedded validation findings must be an array.'));
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
if (manifestValidation.status !== caseValidation.status) {
|
|
844
|
+
findings.push(finding('blocker', 'result.validation.status-mismatch', 'Embedded validation status must match the embedded analysis-case validation.'));
|
|
845
|
+
}
|
|
846
|
+
if (manifestValidation.blockers !== caseValidation.blockers) {
|
|
847
|
+
findings.push(finding('blocker', 'result.validation.blockers-mismatch', 'Embedded validation blocker count must match the embedded analysis-case validation.'));
|
|
848
|
+
}
|
|
849
|
+
if (manifestValidation.reviewItems !== caseValidation.reviewItems) {
|
|
850
|
+
findings.push(finding('blocker', 'result.validation.review-items-mismatch', 'Embedded validation review item count must match the embedded analysis-case validation.'));
|
|
851
|
+
}
|
|
852
|
+
const manifestCodes = manifestValidation.findings.map((item) => isRecord(item) ? String(item.code) : '');
|
|
853
|
+
const caseCodes = caseValidation.findings.map((item) => item.code);
|
|
854
|
+
if (manifestCodes.length !== caseCodes.length ||
|
|
855
|
+
manifestCodes.some((code, index) => code !== caseCodes[index])) {
|
|
856
|
+
findings.push(finding('blocker', 'result.validation.findings-mismatch', 'Embedded validation finding codes must match the embedded analysis-case validation.'));
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
function isApproxEqual(actual, expected, tolerance) {
|
|
860
|
+
return Math.abs(actual - expected) <= tolerance;
|
|
861
|
+
}
|
|
862
|
+
function pushApproximateMatchFinding(findings, actual, expected, code, label, tolerance) {
|
|
863
|
+
if (!isFiniteNumber(actual) || !Number.isFinite(expected))
|
|
864
|
+
return;
|
|
865
|
+
if (!isApproxEqual(actual, expected, tolerance)) {
|
|
866
|
+
findings.push(finding('blocker', code, `${label} must match the embedded FEM analysis case within ${tolerance}.`));
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
function validateResultEnvelopeSemantics(findings, manifest) {
|
|
870
|
+
const { envelope, analysisCase } = manifest;
|
|
871
|
+
const maxSettlementOk = pushFiniteNumberFinding(findings, envelope.maxSettlementMm, 'result.envelope.max-settlement', 'Envelope max settlement', { nonNegative: true });
|
|
872
|
+
const minSettlementOk = pushFiniteNumberFinding(findings, envelope.minSettlementMm, 'result.envelope.min-settlement', 'Envelope min settlement', { nonNegative: true });
|
|
873
|
+
pushFiniteNumberFinding(findings, envelope.totalLoadKn, 'result.envelope.total-load', 'Envelope total load', { nonNegative: true });
|
|
874
|
+
pushFiniteNumberFinding(findings, envelope.reactionKn, 'result.envelope.reaction', 'Envelope reaction', { nonNegative: true });
|
|
875
|
+
pushFiniteNumberFinding(findings, envelope.reactionBalanceRatio, 'result.envelope.reaction-balance-ratio', 'Envelope reaction balance ratio', { positive: true });
|
|
876
|
+
if (maxSettlementOk && minSettlementOk && envelope.minSettlementMm > envelope.maxSettlementMm) {
|
|
877
|
+
findings.push(finding('blocker', 'result.envelope.settlement-range-invalid', 'Envelope minimum settlement cannot exceed maximum settlement.'));
|
|
878
|
+
}
|
|
879
|
+
const expectedBackendByObjective = new Map([
|
|
880
|
+
['foundation_settlement', 'builtin-elastic3d-demo'],
|
|
881
|
+
['excavation_deformation', 'builtin-staged-excavation-demo'],
|
|
882
|
+
['tunnel_volume_loss_settlement', 'builtin-tunnel-volume-loss-demo'],
|
|
883
|
+
]);
|
|
884
|
+
const expectedBackend = expectedBackendByObjective.get(analysisCase.objective);
|
|
885
|
+
if (expectedBackend && manifest.backend.id !== expectedBackend) {
|
|
886
|
+
findings.push(finding('blocker', 'result.backend.objective-mismatch', 'Result backend must match the embedded FEM objective.'));
|
|
887
|
+
}
|
|
888
|
+
if (analysisCase.objective === 'foundation_settlement') {
|
|
889
|
+
const raft = analysisCase.geometry.raft;
|
|
890
|
+
if (!raft)
|
|
891
|
+
return;
|
|
892
|
+
const totalLoadKn = analysisCase.loads
|
|
893
|
+
.filter((load) => load.target === 'raft')
|
|
894
|
+
.reduce((total, load) => total + load.pressureKpa * raft.lengthM * raft.widthM, 0);
|
|
895
|
+
const tolerance = Math.max(0.01, Math.abs(totalLoadKn) * 0.0001);
|
|
896
|
+
pushApproximateMatchFinding(findings, envelope.totalLoadKn, totalLoadKn, 'result.envelope.foundation-total-load-mismatch', 'Foundation total load', tolerance);
|
|
897
|
+
pushApproximateMatchFinding(findings, envelope.reactionKn, totalLoadKn, 'result.envelope.foundation-reaction-mismatch', 'Foundation reaction', tolerance);
|
|
898
|
+
if (envelope.maxSurfaceSettlementMm != null) {
|
|
899
|
+
findings.push(finding('blocker', 'result.envelope.foundation-extra-surface-settlement', 'Foundation result envelopes must not expose excavation/tunnel surface-settlement fields.'));
|
|
900
|
+
}
|
|
901
|
+
if (envelope.stageCount != null) {
|
|
902
|
+
findings.push(finding('blocker', 'result.envelope.foundation-extra-stage-count', 'Foundation result envelopes must not expose staged-excavation fields.'));
|
|
903
|
+
}
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
if (analysisCase.objective === 'excavation_deformation') {
|
|
907
|
+
const excavation = analysisCase.geometry.excavation;
|
|
908
|
+
const upperMaterial = analysisCase.materials[0];
|
|
909
|
+
if (!excavation || !upperMaterial)
|
|
910
|
+
return;
|
|
911
|
+
const expectedWeightKn = excavation.lengthM * excavation.widthM * excavation.finalDepthM * upperMaterial.unitWeightKnM3;
|
|
912
|
+
const expectedWeightTolerance = Math.max(0.01, Math.abs(expectedWeightKn) * 0.0001);
|
|
913
|
+
const maxSurfaceSettlementMm = envelope.maxSurfaceSettlementMm;
|
|
914
|
+
const maxSurfaceOk = pushFiniteNumberFinding(findings, maxSurfaceSettlementMm, 'result.envelope.max-surface-settlement', 'Envelope max surface settlement', { nonNegative: true });
|
|
915
|
+
pushFiniteNumberFinding(findings, envelope.maxHorizontalDisplacementMm, 'result.envelope.max-horizontal-displacement', 'Envelope max horizontal displacement', { nonNegative: true });
|
|
916
|
+
pushFiniteNumberFinding(findings, envelope.maxWallDeflectionMm, 'result.envelope.max-wall-deflection', 'Envelope max wall deflection', { nonNegative: true });
|
|
917
|
+
pushFiniteNumberFinding(findings, envelope.maxBasalHeaveMm, 'result.envelope.max-basal-heave', 'Envelope max basal heave', { nonNegative: true });
|
|
918
|
+
pushFiniteNumberFinding(findings, envelope.totalExcavatedWeightKn, 'result.envelope.total-excavated-weight', 'Envelope total excavated weight', { nonNegative: true });
|
|
919
|
+
pushFiniteNumberFinding(findings, envelope.supportReactionKn, 'result.envelope.support-reaction', 'Envelope support reaction', { nonNegative: true });
|
|
920
|
+
pushFiniteNumberFinding(findings, envelope.boundaryReactionKn, 'result.envelope.boundary-reaction', 'Envelope boundary reaction', { nonNegative: true });
|
|
921
|
+
const stageCountOk = pushFiniteNumberFinding(findings, envelope.stageCount, 'result.envelope.stage-count', 'Envelope stage count', { positive: true });
|
|
922
|
+
if (stageCountOk && !Number.isInteger(envelope.stageCount)) {
|
|
923
|
+
findings.push(finding('blocker', 'result.envelope.excavation-stage-count-integer', 'Excavation stage count must be an integer.'));
|
|
924
|
+
}
|
|
925
|
+
if (stageCountOk && envelope.stageCount !== excavation.stages.length) {
|
|
926
|
+
findings.push(finding('blocker', 'result.envelope.excavation-stage-count-mismatch', 'Excavation envelope stage count must match the embedded construction stages.'));
|
|
927
|
+
}
|
|
928
|
+
pushApproximateMatchFinding(findings, envelope.totalExcavatedWeightKn, expectedWeightKn, 'result.envelope.excavation-weight-mismatch', 'Excavation total excavated weight', expectedWeightTolerance);
|
|
929
|
+
pushApproximateMatchFinding(findings, envelope.totalLoadKn, expectedWeightKn, 'result.envelope.excavation-total-load-mismatch', 'Excavation total load', expectedWeightTolerance);
|
|
930
|
+
pushApproximateMatchFinding(findings, envelope.reactionKn, expectedWeightKn, 'result.envelope.excavation-reaction-mismatch', 'Excavation reaction', expectedWeightTolerance);
|
|
931
|
+
if (isFiniteNumber(envelope.supportReactionKn) && isFiniteNumber(envelope.boundaryReactionKn)) {
|
|
932
|
+
pushApproximateMatchFinding(findings, envelope.supportReactionKn + envelope.boundaryReactionKn, expectedWeightKn, 'result.envelope.excavation-reaction-components-mismatch', 'Excavation support plus boundary reactions', expectedWeightTolerance);
|
|
933
|
+
}
|
|
934
|
+
if (maxSettlementOk && maxSurfaceOk) {
|
|
935
|
+
pushApproximateMatchFinding(findings, envelope.maxSettlementMm, maxSurfaceSettlementMm, 'result.envelope.excavation-max-settlement-mismatch', 'Excavation max settlement', 0.001);
|
|
936
|
+
}
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
if (analysisCase.objective === 'tunnel_volume_loss_settlement') {
|
|
940
|
+
const tunnel = analysisCase.geometry.tunnel;
|
|
941
|
+
if (!tunnel)
|
|
942
|
+
return;
|
|
943
|
+
const troughWidthM = tunnel.axisDepthM * tunnel.troughWidthParameterK;
|
|
944
|
+
const tunnelAreaM2 = Math.PI * (tunnel.diameterM / 2) ** 2;
|
|
945
|
+
const settlementVolumePerM = (tunnel.volumeLossPercent / 100) * tunnelAreaM2;
|
|
946
|
+
const settlementVolumeM3 = settlementVolumePerM * tunnel.lengthM;
|
|
947
|
+
const maxSurfaceSettlementMm = envelope.maxSurfaceSettlementMm;
|
|
948
|
+
const maxSurfaceOk = pushFiniteNumberFinding(findings, maxSurfaceSettlementMm, 'result.envelope.max-surface-settlement', 'Envelope max surface settlement', { nonNegative: true });
|
|
949
|
+
pushFiniteNumberFinding(findings, envelope.tunnelDiameterM, 'result.envelope.tunnel-diameter', 'Envelope tunnel diameter', { positive: true });
|
|
950
|
+
pushFiniteNumberFinding(findings, envelope.tunnelAxisDepthM, 'result.envelope.tunnel-axis-depth', 'Envelope tunnel axis depth', { positive: true });
|
|
951
|
+
pushFiniteNumberFinding(findings, envelope.volumeLossPercent, 'result.envelope.volume-loss', 'Envelope volume loss', { positive: true });
|
|
952
|
+
pushFiniteNumberFinding(findings, envelope.troughWidthM, 'result.envelope.trough-width', 'Envelope trough width', { positive: true });
|
|
953
|
+
pushFiniteNumberFinding(findings, envelope.influenceWidthM, 'result.envelope.influence-width', 'Envelope influence width', { positive: true });
|
|
954
|
+
pushFiniteNumberFinding(findings, envelope.settlementVolumeM3, 'result.envelope.settlement-volume', 'Envelope settlement volume', { nonNegative: true });
|
|
955
|
+
pushFiniteNumberFinding(findings, envelope.settlementVolumePerM, 'result.envelope.settlement-volume-per-m', 'Envelope settlement volume per metre', { nonNegative: true });
|
|
956
|
+
pushApproximateMatchFinding(findings, envelope.totalLoadKn, 0, 'result.envelope.tunnel-total-load-invalid', 'Tunnel empirical total load', 0.001);
|
|
957
|
+
pushApproximateMatchFinding(findings, envelope.reactionKn, 0, 'result.envelope.tunnel-reaction-invalid', 'Tunnel empirical reaction', 0.001);
|
|
958
|
+
pushApproximateMatchFinding(findings, envelope.tunnelDiameterM, tunnel.diameterM, 'result.envelope.tunnel-diameter-mismatch', 'Tunnel diameter envelope value', 0.001);
|
|
959
|
+
pushApproximateMatchFinding(findings, envelope.tunnelAxisDepthM, tunnel.axisDepthM, 'result.envelope.tunnel-axis-depth-mismatch', 'Tunnel axis depth envelope value', 0.001);
|
|
960
|
+
pushApproximateMatchFinding(findings, envelope.volumeLossPercent, tunnel.volumeLossPercent, 'result.envelope.tunnel-volume-loss-mismatch', 'Tunnel volume loss envelope value', 0.001);
|
|
961
|
+
pushApproximateMatchFinding(findings, envelope.troughWidthM, troughWidthM, 'result.envelope.tunnel-trough-width-mismatch', 'Tunnel trough width envelope value', 0.001);
|
|
962
|
+
pushApproximateMatchFinding(findings, envelope.influenceWidthM, troughWidthM * 6, 'result.envelope.tunnel-influence-width-mismatch', 'Tunnel influence width envelope value', 0.001);
|
|
963
|
+
pushApproximateMatchFinding(findings, envelope.settlementVolumePerM, settlementVolumePerM, 'result.envelope.tunnel-settlement-volume-per-m-mismatch', 'Tunnel settlement volume per metre', 0.0001);
|
|
964
|
+
pushApproximateMatchFinding(findings, envelope.settlementVolumeM3, settlementVolumeM3, 'result.envelope.tunnel-settlement-volume-mismatch', 'Tunnel settlement volume', Math.max(0.001, Math.abs(settlementVolumeM3) * 0.0001));
|
|
965
|
+
if (maxSettlementOk && maxSurfaceOk) {
|
|
966
|
+
pushApproximateMatchFinding(findings, envelope.maxSettlementMm, maxSurfaceSettlementMm, 'result.envelope.tunnel-max-settlement-mismatch', 'Tunnel max settlement', 0.001);
|
|
967
|
+
}
|
|
366
968
|
}
|
|
367
969
|
}
|
|
368
970
|
export function validateFemResultManifest(manifest) {
|
|
@@ -375,9 +977,40 @@ export function validateFemResultManifest(manifest) {
|
|
|
375
977
|
if (manifest.schemaVersion !== 'fem-result-manifest.v0') {
|
|
376
978
|
findings.push(finding('blocker', 'result.schema.unsupported', 'Only fem-result-manifest.v0 is supported.'));
|
|
377
979
|
}
|
|
980
|
+
if (!isNonEmptyString(manifest.caseId)) {
|
|
981
|
+
findings.push(finding('blocker', 'result.case-id.missing', 'Result manifest caseId must be a non-empty string.'));
|
|
982
|
+
}
|
|
983
|
+
else if (manifest.caseId !== manifest.analysisCase.caseId) {
|
|
984
|
+
findings.push(finding('blocker', 'result.case-id.mismatch', 'Result manifest caseId must match the embedded analysis case.'));
|
|
985
|
+
}
|
|
986
|
+
if (!isNonEmptyString(manifest.title)) {
|
|
987
|
+
findings.push(finding('blocker', 'result.title.missing', 'Result manifest title must be a non-empty string.'));
|
|
988
|
+
}
|
|
989
|
+
if (!isNonEmptyString(manifest.generatedAt) || Number.isNaN(Date.parse(manifest.generatedAt))) {
|
|
990
|
+
findings.push(finding('blocker', 'result.generated-at.invalid', 'Result manifest generatedAt must be an ISO-compatible timestamp.'));
|
|
991
|
+
}
|
|
992
|
+
validateAssumptions(findings, manifest.assumptions, 'result');
|
|
993
|
+
validateLimitations(findings, manifest.limitations, 'result');
|
|
378
994
|
if (!manifest.analysisCase.experimental) {
|
|
379
995
|
findings.push(finding('blocker', 'result.experimental-required', 'FEM result manifests must be tied to an experimental case.'));
|
|
380
996
|
}
|
|
997
|
+
const validBackendIds = new Set([
|
|
998
|
+
'builtin-elastic3d-demo',
|
|
999
|
+
'builtin-staged-excavation-demo',
|
|
1000
|
+
'builtin-tunnel-volume-loss-demo',
|
|
1001
|
+
]);
|
|
1002
|
+
if (!validBackendIds.has(manifest.backend.id)) {
|
|
1003
|
+
findings.push(finding('blocker', 'result.backend.id-invalid', `Unsupported FEM result backend: ${String(manifest.backend.id)}.`));
|
|
1004
|
+
}
|
|
1005
|
+
if (!isNonEmptyString(manifest.backend.label)) {
|
|
1006
|
+
findings.push(finding('blocker', 'result.backend.label.missing', 'Result backend label must be a non-empty string.'));
|
|
1007
|
+
}
|
|
1008
|
+
if (manifest.backend.deterministic !== true) {
|
|
1009
|
+
findings.push(finding('blocker', 'result.backend.deterministic-required', 'FEM result backends must be deterministic for strong-beta preview artifacts.'));
|
|
1010
|
+
}
|
|
1011
|
+
if (!isNonEmptyString(manifest.backend.version)) {
|
|
1012
|
+
findings.push(finding('blocker', 'result.backend.version.missing', 'Result backend version must be a non-empty string.'));
|
|
1013
|
+
}
|
|
381
1014
|
for (const [key, value] of Object.entries(manifest.envelope)) {
|
|
382
1015
|
if (!Number.isFinite(value)) {
|
|
383
1016
|
findings.push(finding('blocker', `result.envelope.${key}.non-finite`, `Envelope value ${key} must be finite.`));
|
|
@@ -386,6 +1019,7 @@ export function validateFemResultManifest(manifest) {
|
|
|
386
1019
|
if (manifest.envelope.reactionBalanceRatio < 0.95 || manifest.envelope.reactionBalanceRatio > 1.05) {
|
|
387
1020
|
findings.push(finding('review', 'result.envelope.balance-review', 'Reaction balance is outside the 0.95-1.05 review band.'));
|
|
388
1021
|
}
|
|
1022
|
+
validateResultEnvelopeSemantics(findings, manifest);
|
|
389
1023
|
const { visualization } = manifest;
|
|
390
1024
|
pushFiniteArrayFindings(findings, 'base', visualization.base, 3);
|
|
391
1025
|
pushFiniteArrayFindings(findings, 'disp', visualization.disp, 3);
|
|
@@ -393,6 +1027,9 @@ export function validateFemResultManifest(manifest) {
|
|
|
393
1027
|
pushFiniteArrayFindings(findings, 'outlineBase', visualization.outlineBase, 3);
|
|
394
1028
|
pushFiniteArrayFindings(findings, 'outlineDisp', visualization.outlineDisp, 3);
|
|
395
1029
|
const nodeCount = visualization.base.length / 3;
|
|
1030
|
+
if (nodeCount > FEM_MAX_PREVIEW_MESH_NODES) {
|
|
1031
|
+
findings.push(finding('blocker', 'result.visualization.webgl-node-limit', `FEM/WebGL preview visualization is capped at ${FEM_MAX_PREVIEW_MESH_NODES} nodes.`));
|
|
1032
|
+
}
|
|
396
1033
|
if (visualization.disp.length / 3 !== nodeCount) {
|
|
397
1034
|
findings.push(finding('blocker', 'result.disp.node-count-mismatch', 'Displacement vectors must match base nodes.'));
|
|
398
1035
|
}
|
|
@@ -401,13 +1038,60 @@ export function validateFemResultManifest(manifest) {
|
|
|
401
1038
|
}
|
|
402
1039
|
pushIndexArrayFindings(findings, 'tri', visualization.tri, nodeCount, 3);
|
|
403
1040
|
pushIndexArrayFindings(findings, 'edge', visualization.edge, nodeCount, 2);
|
|
1041
|
+
const expectedMeshNodes = (manifest.analysisCase.mesh.divisionsX + 1) *
|
|
1042
|
+
(manifest.analysisCase.mesh.divisionsY + 1) *
|
|
1043
|
+
(manifest.analysisCase.mesh.divisionsZ + 1);
|
|
1044
|
+
const expectedMeshElements = manifest.analysisCase.mesh.divisionsX *
|
|
1045
|
+
manifest.analysisCase.mesh.divisionsY *
|
|
1046
|
+
manifest.analysisCase.mesh.divisionsZ;
|
|
1047
|
+
const meshDivisions = manifest.mesh.divisions;
|
|
1048
|
+
if (!Number.isInteger(manifest.mesh.nodes) || manifest.mesh.nodes !== expectedMeshNodes) {
|
|
1049
|
+
findings.push(finding('blocker', 'result.mesh.nodes-mismatch', 'Result mesh node count must match the embedded analysis case mesh divisions.'));
|
|
1050
|
+
}
|
|
1051
|
+
if (!Number.isInteger(manifest.mesh.elements) || manifest.mesh.elements !== expectedMeshElements) {
|
|
1052
|
+
findings.push(finding('blocker', 'result.mesh.elements-mismatch', 'Result mesh element count must match the embedded analysis case mesh divisions.'));
|
|
1053
|
+
}
|
|
1054
|
+
if (manifest.mesh.elementType !== manifest.analysisCase.mesh.elementType) {
|
|
1055
|
+
findings.push(finding('blocker', 'result.mesh.element-type-mismatch', 'Result mesh element type must match the embedded analysis case.'));
|
|
1056
|
+
}
|
|
1057
|
+
if (!Array.isArray(meshDivisions) ||
|
|
1058
|
+
meshDivisions.length !== 3 ||
|
|
1059
|
+
meshDivisions[0] !== manifest.analysisCase.mesh.divisionsX ||
|
|
1060
|
+
meshDivisions[1] !== manifest.analysisCase.mesh.divisionsY ||
|
|
1061
|
+
meshDivisions[2] !== manifest.analysisCase.mesh.divisionsZ) {
|
|
1062
|
+
findings.push(finding('blocker', 'result.mesh.divisions-mismatch', 'Result mesh divisions must match the embedded analysis case.'));
|
|
1063
|
+
}
|
|
1064
|
+
if (!Number.isInteger(manifest.mesh.visualizationNodes) || manifest.mesh.visualizationNodes !== nodeCount) {
|
|
1065
|
+
findings.push(finding('blocker', 'result.mesh.visualization-nodes-mismatch', 'Result visualization node count must match the visualization base array.'));
|
|
1066
|
+
}
|
|
1067
|
+
if (!Number.isInteger(manifest.mesh.visualizationTriangles) || manifest.mesh.visualizationTriangles !== visualization.tri.length / 3) {
|
|
1068
|
+
findings.push(finding('blocker', 'result.mesh.visualization-triangles-mismatch', 'Result visualization triangle count must match the visualization tri array.'));
|
|
1069
|
+
}
|
|
1070
|
+
if (!Number.isInteger(manifest.mesh.visualizationEdges) || manifest.mesh.visualizationEdges !== visualization.edge.length / 2) {
|
|
1071
|
+
findings.push(finding('blocker', 'result.mesh.visualization-edges-mismatch', 'Result visualization edge count must match the visualization edge array.'));
|
|
1072
|
+
}
|
|
404
1073
|
const outlineNodeCount = visualization.outlineBase.length / 3;
|
|
1074
|
+
if (outlineNodeCount > FEM_MAX_PREVIEW_MESH_NODES) {
|
|
1075
|
+
findings.push(finding('blocker', 'result.outline.webgl-node-limit', `FEM/WebGL outline visualization is capped at ${FEM_MAX_PREVIEW_MESH_NODES} nodes.`));
|
|
1076
|
+
}
|
|
405
1077
|
if (visualization.outlineDisp.length / 3 !== outlineNodeCount) {
|
|
406
1078
|
findings.push(finding('blocker', 'result.outline.node-count-mismatch', 'Outline displacement vectors must match outline nodes.'));
|
|
407
1079
|
}
|
|
408
1080
|
pushIndexArrayFindings(findings, 'outlineIdx', visualization.outlineIdx, outlineNodeCount, 2);
|
|
409
1081
|
if (Array.isArray(visualization.frames)) {
|
|
410
1082
|
for (const [index, frame] of visualization.frames.entries()) {
|
|
1083
|
+
if (!isNonEmptyString(frame.field)) {
|
|
1084
|
+
findings.push(finding('blocker', `result.frames.${index}.field.missing`, 'Frame field must be a non-empty string.'));
|
|
1085
|
+
}
|
|
1086
|
+
if (!isNonEmptyString(frame.fieldLabel)) {
|
|
1087
|
+
findings.push(finding('blocker', `result.frames.${index}.field-label.missing`, 'Frame field label must be a non-empty string.'));
|
|
1088
|
+
}
|
|
1089
|
+
if (frame.stageIndex != null && (!Number.isInteger(frame.stageIndex) || frame.stageIndex < 0)) {
|
|
1090
|
+
findings.push(finding('blocker', `result.frames.${index}.stage-index.invalid`, 'Frame stage index must be an integer greater than or equal to zero when present.'));
|
|
1091
|
+
}
|
|
1092
|
+
if (frame.stageLabel != null && !isNonEmptyString(frame.stageLabel)) {
|
|
1093
|
+
findings.push(finding('blocker', `result.frames.${index}.stage-label.missing`, 'Frame stage label must be a non-empty string when present.'));
|
|
1094
|
+
}
|
|
411
1095
|
pushFiniteArrayFindings(findings, `frames.${index}.disp`, frame.disp, 3);
|
|
412
1096
|
pushFiniteArrayFindings(findings, `frames.${index}.color`, frame.color, 3);
|
|
413
1097
|
if (frame.disp.length / 3 !== nodeCount) {
|
|
@@ -420,6 +1104,7 @@ export function validateFemResultManifest(manifest) {
|
|
|
420
1104
|
}
|
|
421
1105
|
validateOptionalResultMetadata(findings, manifest, nodeCount, outlineNodeCount);
|
|
422
1106
|
const caseValidation = validateFemAnalysisCase(manifest.analysisCase);
|
|
1107
|
+
validateEmbeddedValidationSummary(findings, manifest.validation, caseValidation);
|
|
423
1108
|
findings.push(...caseValidation.findings);
|
|
424
1109
|
return summary(findings);
|
|
425
1110
|
}
|