@holoscript/plugin-civil-engineering 2.0.1
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/CHANGELOG.md +14 -0
- package/LICENSE +21 -0
- package/package.json +13 -0
- package/src/__tests__/frame2d.test.ts +338 -0
- package/src/__tests__/runtime-integration.test.ts +197 -0
- package/src/frame2d.ts +638 -0
- package/src/index.ts +28 -0
- package/src/runtime.ts +207 -0
- package/src/traits/LoadBearingTrait.ts +25 -0
- package/src/traits/MaterialFatigueTrait.ts +28 -0
- package/src/traits/StructuralAnalysisTrait.ts +28 -0
- package/src/traits/types.ts +4 -0
- package/tsconfig.json +1 -0
- package/vitest.config.ts +22 -0
package/src/frame2d.ts
ADDED
|
@@ -0,0 +1,638 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 2D Structural Frame Solver — Direct Stiffness Method (DSM)
|
|
3
|
+
*
|
|
4
|
+
* Solves planar frame structures using the Euler-Bernoulli beam element
|
|
5
|
+
* with axial, shear, and bending DOF (3 DOF per node: ux, uy, θz).
|
|
6
|
+
*
|
|
7
|
+
* Capabilities:
|
|
8
|
+
* - Arbitrary geometry (inclined members via coordinate transformation)
|
|
9
|
+
* - Concentrated nodal loads (Fx, Fy, Mz)
|
|
10
|
+
* - Uniform distributed loads (UDL) converted to equivalent nodal forces
|
|
11
|
+
* - Pinned, roller, and fixed supports (combination of DOF restraints)
|
|
12
|
+
* - Internal force recovery (axial N, shear V, moment M at member ends)
|
|
13
|
+
* - Utilisation check against AISC/Eurocode compact-section limit
|
|
14
|
+
* - CAEL-ready receipt builder
|
|
15
|
+
*
|
|
16
|
+
* Reference: McGuire, Gallagher, Ziemian, "Matrix Structural Analysis", 2nd ed.
|
|
17
|
+
*
|
|
18
|
+
* @version 1.0.0
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
DOMAIN_SIMULATION_RECEIPT_SCHEMA,
|
|
23
|
+
buildDomainSimulationReceipt,
|
|
24
|
+
type DomainSimulationReceipt,
|
|
25
|
+
} from '@holoscript/core';
|
|
26
|
+
|
|
27
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
export interface Node2D {
|
|
30
|
+
id: string;
|
|
31
|
+
/** X coordinate (m) */
|
|
32
|
+
x: number;
|
|
33
|
+
/** Y coordinate (m) */
|
|
34
|
+
y: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface BeamElement {
|
|
38
|
+
id: string;
|
|
39
|
+
fromNodeId: string;
|
|
40
|
+
toNodeId: string;
|
|
41
|
+
/** Elastic modulus (GPa) */
|
|
42
|
+
elasticModulusGPa: number;
|
|
43
|
+
/** Moment of inertia about strong axis (m⁴) */
|
|
44
|
+
momentOfInertiaM4: number;
|
|
45
|
+
/** Cross-sectional area (m²) */
|
|
46
|
+
areaM2: number;
|
|
47
|
+
/** Plastic section modulus (m³) — used for utilisation check */
|
|
48
|
+
plasticModulusM3?: number;
|
|
49
|
+
/** Yield strength (MPa) — used for utilisation check */
|
|
50
|
+
yieldStrengthMPa?: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Nodal support: true = restrained, false/undefined = free */
|
|
54
|
+
export interface Support {
|
|
55
|
+
nodeId: string;
|
|
56
|
+
/** Restrain horizontal translation */
|
|
57
|
+
ux?: boolean;
|
|
58
|
+
/** Restrain vertical translation */
|
|
59
|
+
uy?: boolean;
|
|
60
|
+
/** Restrain rotation */
|
|
61
|
+
theta?: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface NodalLoad {
|
|
65
|
+
nodeId: string;
|
|
66
|
+
/** Force in X direction (kN) */
|
|
67
|
+
Fx?: number;
|
|
68
|
+
/** Force in Y direction (kN) */
|
|
69
|
+
Fy?: number;
|
|
70
|
+
/** Moment about Z axis (kN·m, positive counter-clockwise) */
|
|
71
|
+
Mz?: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface DistributedLoad {
|
|
75
|
+
elementId: string;
|
|
76
|
+
/** Uniform load intensity in local y direction (kN/m, positive pointing "down" in local frame) */
|
|
77
|
+
w: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface Frame2DModel {
|
|
81
|
+
id: string;
|
|
82
|
+
nodes: Node2D[];
|
|
83
|
+
elements: BeamElement[];
|
|
84
|
+
supports: Support[];
|
|
85
|
+
nodalLoads?: NodalLoad[];
|
|
86
|
+
distributedLoads?: DistributedLoad[];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface NodeDisplacement {
|
|
90
|
+
nodeId: string;
|
|
91
|
+
/** Horizontal displacement (m) */
|
|
92
|
+
ux: number;
|
|
93
|
+
/** Vertical displacement (m) */
|
|
94
|
+
uy: number;
|
|
95
|
+
/** Rotation (rad) */
|
|
96
|
+
theta: number;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface SupportReaction {
|
|
100
|
+
nodeId: string;
|
|
101
|
+
/** Horizontal reaction (kN) */
|
|
102
|
+
Rx: number;
|
|
103
|
+
/** Vertical reaction (kN) */
|
|
104
|
+
Ry: number;
|
|
105
|
+
/** Moment reaction (kN·m) */
|
|
106
|
+
Mz: number;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface ElementForces {
|
|
110
|
+
elementId: string;
|
|
111
|
+
/** Axial force at start node (kN, positive = tension) */
|
|
112
|
+
N_start: number;
|
|
113
|
+
/** Shear force at start node (kN) */
|
|
114
|
+
V_start: number;
|
|
115
|
+
/** Bending moment at start node (kN·m) */
|
|
116
|
+
M_start: number;
|
|
117
|
+
/** Axial force at end node (kN, positive = tension) */
|
|
118
|
+
N_end: number;
|
|
119
|
+
/** Shear force at end node (kN) */
|
|
120
|
+
V_end: number;
|
|
121
|
+
/** Bending moment at end node (kN·m) */
|
|
122
|
+
M_end: number;
|
|
123
|
+
/** Utilisation ratio (0–1+, 1.0 = section capacity) */
|
|
124
|
+
utilisationRatio: number;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface Frame2DValidation {
|
|
128
|
+
valid: boolean;
|
|
129
|
+
errors: string[];
|
|
130
|
+
warnings: string[];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface Frame2DResult {
|
|
134
|
+
solverType: 'dsm-2d-frame';
|
|
135
|
+
converged: boolean;
|
|
136
|
+
nodeDisplacements: NodeDisplacement[];
|
|
137
|
+
reactions: SupportReaction[];
|
|
138
|
+
elementForces: ElementForces[];
|
|
139
|
+
/** Maximum absolute nodal displacement (m) */
|
|
140
|
+
maxDisplacementM: number;
|
|
141
|
+
/** Maximum element utilisation ratio */
|
|
142
|
+
maxUtilisationRatio: number;
|
|
143
|
+
/** True if all utilisation ratios < 1.0 */
|
|
144
|
+
structurallyAdequate: boolean;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface Frame2DReceiptOptions {
|
|
148
|
+
runId?: string;
|
|
149
|
+
createdAt?: string;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export interface Frame2DReceipt {
|
|
153
|
+
schema: DomainSimulationReceipt['schema'];
|
|
154
|
+
plugin: DomainSimulationReceipt['plugin'];
|
|
155
|
+
pluginVersion: DomainSimulationReceipt['pluginVersion'];
|
|
156
|
+
runId: DomainSimulationReceipt['runId'];
|
|
157
|
+
createdAt: DomainSimulationReceipt['createdAt'];
|
|
158
|
+
modelId: NonNullable<DomainSimulationReceipt['modelId']>;
|
|
159
|
+
solverConfig: {
|
|
160
|
+
solverType: 'dsm-2d-frame';
|
|
161
|
+
nodeCount: number;
|
|
162
|
+
elementCount: number;
|
|
163
|
+
dofCount: number;
|
|
164
|
+
};
|
|
165
|
+
resultSummary: {
|
|
166
|
+
converged: boolean;
|
|
167
|
+
structurallyAdequate: boolean;
|
|
168
|
+
maxDisplacementMm: number;
|
|
169
|
+
maxUtilisationRatio: number;
|
|
170
|
+
};
|
|
171
|
+
cael: {
|
|
172
|
+
version: 'cael.v1';
|
|
173
|
+
event: 'civil_engineering.frame_analysis';
|
|
174
|
+
solverType: 'civil-engineering.dsm-2d-frame';
|
|
175
|
+
};
|
|
176
|
+
acceptance: DomainSimulationReceipt['acceptance'];
|
|
177
|
+
payloadHash: DomainSimulationReceipt['payloadHash'];
|
|
178
|
+
hashAlgorithm: DomainSimulationReceipt['hashAlgorithm'];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ─── Model validation ─────────────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
export function validateFrame2DModel(model: Frame2DModel): Frame2DValidation {
|
|
184
|
+
const errors: string[] = [];
|
|
185
|
+
const warnings: string[] = [];
|
|
186
|
+
|
|
187
|
+
const nodeIds = new Set(model.nodes.map((n) => n.id));
|
|
188
|
+
const elementIds = new Set(model.elements.map((e) => e.id));
|
|
189
|
+
|
|
190
|
+
for (const node of model.nodes) {
|
|
191
|
+
if (!Number.isFinite(node.x) || !Number.isFinite(node.y)) {
|
|
192
|
+
errors.push(`node ${node.id}: coordinates must be finite`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
for (const elem of model.elements) {
|
|
197
|
+
if (!nodeIds.has(elem.fromNodeId)) {
|
|
198
|
+
errors.push(`element ${elem.id}: fromNodeId '${elem.fromNodeId}' not found`);
|
|
199
|
+
}
|
|
200
|
+
if (!nodeIds.has(elem.toNodeId)) {
|
|
201
|
+
errors.push(`element ${elem.id}: toNodeId '${elem.toNodeId}' not found`);
|
|
202
|
+
}
|
|
203
|
+
if (elem.fromNodeId === elem.toNodeId) {
|
|
204
|
+
errors.push(`element ${elem.id}: fromNodeId and toNodeId must differ`);
|
|
205
|
+
}
|
|
206
|
+
if (!Number.isFinite(elem.elasticModulusGPa) || elem.elasticModulusGPa <= 0) {
|
|
207
|
+
errors.push(`element ${elem.id}: elasticModulusGPa must be positive`);
|
|
208
|
+
}
|
|
209
|
+
if (!Number.isFinite(elem.momentOfInertiaM4) || elem.momentOfInertiaM4 <= 0) {
|
|
210
|
+
errors.push(`element ${elem.id}: momentOfInertiaM4 must be positive`);
|
|
211
|
+
}
|
|
212
|
+
if (!Number.isFinite(elem.areaM2) || elem.areaM2 <= 0) {
|
|
213
|
+
errors.push(`element ${elem.id}: areaM2 must be positive`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
for (const support of model.supports) {
|
|
218
|
+
if (!nodeIds.has(support.nodeId)) {
|
|
219
|
+
errors.push(`support at '${support.nodeId}': node not found`);
|
|
220
|
+
}
|
|
221
|
+
if (!support.ux && !support.uy && !support.theta) {
|
|
222
|
+
warnings.push(`support at '${support.nodeId}': no DOF restrained — has no effect`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
for (const load of model.nodalLoads ?? []) {
|
|
227
|
+
if (!nodeIds.has(load.nodeId)) {
|
|
228
|
+
errors.push(`nodal load at '${load.nodeId}': node not found`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
for (const dl of model.distributedLoads ?? []) {
|
|
233
|
+
if (!elementIds.has(dl.elementId)) {
|
|
234
|
+
errors.push(`distributed load on '${dl.elementId}': element not found`);
|
|
235
|
+
}
|
|
236
|
+
if (!Number.isFinite(dl.w)) {
|
|
237
|
+
errors.push(`distributed load on '${dl.elementId}': w must be finite`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Minimum stability check: need enough supports to prevent rigid body motion
|
|
242
|
+
const totalRestrainedDOF = model.supports.reduce(
|
|
243
|
+
(sum, s) => sum + (s.ux ? 1 : 0) + (s.uy ? 1 : 0) + (s.theta ? 1 : 0),
|
|
244
|
+
0,
|
|
245
|
+
);
|
|
246
|
+
if (totalRestrainedDOF < 3) {
|
|
247
|
+
errors.push('insufficient supports: need at least 3 restrained DOF to prevent rigid body motion');
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return { valid: errors.length === 0, errors, warnings };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ─── Matrix utilities ─────────────────────────────────────────────────────────
|
|
254
|
+
|
|
255
|
+
type Matrix = number[][];
|
|
256
|
+
|
|
257
|
+
function zeros(rows: number, cols: number): Matrix {
|
|
258
|
+
return Array.from({ length: rows }, () => new Array<number>(cols).fill(0));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function matMul(A: Matrix, B: Matrix): Matrix {
|
|
262
|
+
const m = A.length;
|
|
263
|
+
const n = B[0].length;
|
|
264
|
+
const k = B.length;
|
|
265
|
+
const C = zeros(m, n);
|
|
266
|
+
for (let i = 0; i < m; i++)
|
|
267
|
+
for (let j = 0; j < n; j++)
|
|
268
|
+
for (let l = 0; l < k; l++)
|
|
269
|
+
C[i][j] += A[i][l] * B[l][j];
|
|
270
|
+
return C;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function transpose(A: Matrix): Matrix {
|
|
274
|
+
const m = A.length;
|
|
275
|
+
const n = A[0].length;
|
|
276
|
+
const T = zeros(n, m);
|
|
277
|
+
for (let i = 0; i < m; i++)
|
|
278
|
+
for (let j = 0; j < n; j++)
|
|
279
|
+
T[j][i] = A[i][j];
|
|
280
|
+
return T;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Gauss elimination with partial pivoting — solves Ax = b in place */
|
|
284
|
+
function gaussSolve(A: Matrix, b: number[]): number[] {
|
|
285
|
+
const n = b.length;
|
|
286
|
+
const a = A.map((row) => [...row]);
|
|
287
|
+
const rhs = [...b];
|
|
288
|
+
|
|
289
|
+
for (let pivot = 0; pivot < n; pivot++) {
|
|
290
|
+
// Partial pivot
|
|
291
|
+
let bestRow = pivot;
|
|
292
|
+
let bestAbs = Math.abs(a[pivot][pivot]);
|
|
293
|
+
for (let row = pivot + 1; row < n; row++) {
|
|
294
|
+
const v = Math.abs(a[row][pivot]);
|
|
295
|
+
if (v > bestAbs) { bestAbs = v; bestRow = row; }
|
|
296
|
+
}
|
|
297
|
+
if (bestAbs < 1e-14) throw new Error('[frame2d] singular stiffness matrix — check boundary conditions');
|
|
298
|
+
if (bestRow !== pivot) {
|
|
299
|
+
[a[pivot], a[bestRow]] = [a[bestRow], a[pivot]];
|
|
300
|
+
[rhs[pivot], rhs[bestRow]] = [rhs[bestRow], rhs[pivot]];
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const pv = a[pivot][pivot];
|
|
304
|
+
for (let col = pivot; col < n; col++) a[pivot][col] /= pv;
|
|
305
|
+
rhs[pivot] /= pv;
|
|
306
|
+
|
|
307
|
+
for (let row = 0; row < n; row++) {
|
|
308
|
+
if (row === pivot) continue;
|
|
309
|
+
const f = a[row][pivot];
|
|
310
|
+
if (f === 0) continue;
|
|
311
|
+
for (let col = pivot; col < n; col++) a[row][col] -= f * a[pivot][col];
|
|
312
|
+
rhs[row] -= f * rhs[pivot];
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return rhs;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ─── Element stiffness ────────────────────────────────────────────────────────
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* 6×6 local element stiffness matrix for a 2D Euler-Bernoulli beam element.
|
|
322
|
+
* DOF order: [u1, v1, θ1, u2, v2, θ2]
|
|
323
|
+
*
|
|
324
|
+
* @param E Elastic modulus (kN/m²)
|
|
325
|
+
* @param I Moment of inertia (m⁴)
|
|
326
|
+
* @param A Cross-sectional area (m²)
|
|
327
|
+
* @param L Element length (m)
|
|
328
|
+
*/
|
|
329
|
+
function localStiffness(E: number, I: number, A: number, L: number): Matrix {
|
|
330
|
+
const EA = E * A;
|
|
331
|
+
const EI = E * I;
|
|
332
|
+
const L2 = L * L;
|
|
333
|
+
const L3 = L * L * L;
|
|
334
|
+
|
|
335
|
+
return [
|
|
336
|
+
[ EA/L, 0, 0, -EA/L, 0, 0 ],
|
|
337
|
+
[ 0, 12*EI/L3, 6*EI/L2, 0, -12*EI/L3, 6*EI/L2 ],
|
|
338
|
+
[ 0, 6*EI/L2, 4*EI/L, 0, -6*EI/L2, 2*EI/L ],
|
|
339
|
+
[-EA/L, 0, 0, EA/L, 0, 0 ],
|
|
340
|
+
[ 0, -12*EI/L3, -6*EI/L2, 0, 12*EI/L3, -6*EI/L2 ],
|
|
341
|
+
[ 0, 6*EI/L2, 2*EI/L, 0, -6*EI/L2, 4*EI/L ],
|
|
342
|
+
];
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* 6×6 transformation matrix T mapping local DOF to global DOF.
|
|
347
|
+
* @param c cos(α) where α = angle of element axis w.r.t. global X
|
|
348
|
+
* @param s sin(α)
|
|
349
|
+
*/
|
|
350
|
+
function transformMatrix(c: number, s: number): Matrix {
|
|
351
|
+
return [
|
|
352
|
+
[ c, s, 0, 0, 0, 0 ],
|
|
353
|
+
[-s, c, 0, 0, 0, 0 ],
|
|
354
|
+
[ 0, 0, 1, 0, 0, 0 ],
|
|
355
|
+
[ 0, 0, 0, c, s, 0 ],
|
|
356
|
+
[ 0, 0, 0, -s, c, 0 ],
|
|
357
|
+
[ 0, 0, 0, 0, 0, 1 ],
|
|
358
|
+
];
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Global element stiffness: K_global = T^T * k_local * T */
|
|
362
|
+
function globalElementStiffness(elem: BeamElement, fromNode: Node2D, toNode: Node2D): Matrix {
|
|
363
|
+
const dx = toNode.x - fromNode.x;
|
|
364
|
+
const dy = toNode.y - fromNode.y;
|
|
365
|
+
const L = Math.sqrt(dx * dx + dy * dy);
|
|
366
|
+
if (L < 1e-10) throw new Error(`[frame2d] element ${elem.id} has zero length`);
|
|
367
|
+
|
|
368
|
+
const c = dx / L;
|
|
369
|
+
const s = dy / L;
|
|
370
|
+
const E = elem.elasticModulusGPa * 1e6; // GPa → kN/m²
|
|
371
|
+
const I = elem.momentOfInertiaM4;
|
|
372
|
+
const A = elem.areaM2;
|
|
373
|
+
|
|
374
|
+
const k_local = localStiffness(E, I, A, L);
|
|
375
|
+
const T = transformMatrix(c, s);
|
|
376
|
+
const Tt = transpose(T);
|
|
377
|
+
return matMul(Tt, matMul(k_local, T));
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ─── Equivalent nodal forces for distributed loads ────────────────────────────
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Converts a UDL (w kN/m in local y) to equivalent global nodal forces.
|
|
384
|
+
* Fixed-end forces for a fixed-fixed beam under UDL:
|
|
385
|
+
* Vy_A = Vy_B = wL/2 (in local y)
|
|
386
|
+
* Mz_A = +wL²/12, Mz_B = −wL²/12
|
|
387
|
+
*/
|
|
388
|
+
function equivalentNodalForces(
|
|
389
|
+
dl: DistributedLoad,
|
|
390
|
+
elem: BeamElement,
|
|
391
|
+
fromNode: Node2D,
|
|
392
|
+
toNode: Node2D,
|
|
393
|
+
): number[] /* 6-element global force vector */ {
|
|
394
|
+
const dx = toNode.x - fromNode.x;
|
|
395
|
+
const dy = toNode.y - fromNode.y;
|
|
396
|
+
const L = Math.sqrt(dx * dx + dy * dy);
|
|
397
|
+
const c = dx / L;
|
|
398
|
+
const s = dy / L;
|
|
399
|
+
const w = dl.w;
|
|
400
|
+
|
|
401
|
+
// Fixed-end forces (local frame) for a fixed-fixed beam under downward UDL w:
|
|
402
|
+
// [0, wL/2, wL²/12, 0, wL/2, -wL²/12] — these are the clamping REACTIONS.
|
|
403
|
+
// The equivalent APPLIED nodal loads are the negatives (DSM sign convention):
|
|
404
|
+
// f_eq = −f_fef = [0, −wL/2, −wL²/12, 0, −wL/2, +wL²/12]
|
|
405
|
+
const f_fef = [0, (w * L) / 2, (w * L * L) / 12, 0, (w * L) / 2, -(w * L * L) / 12];
|
|
406
|
+
|
|
407
|
+
// Transform to global: f_eq_global = T^T * f_eq_local = −T^T * f_fef
|
|
408
|
+
const T = transformMatrix(c, s);
|
|
409
|
+
const Tt = transpose(T);
|
|
410
|
+
return f_fef.map((_, i) => -Tt[i].reduce((sum, tij, j) => sum + tij * f_fef[j], 0));
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ─── Solver ───────────────────────────────────────────────────────────────────
|
|
414
|
+
|
|
415
|
+
export function solveFrame2D(model: Frame2DModel): Frame2DResult {
|
|
416
|
+
const validation = validateFrame2DModel(model);
|
|
417
|
+
if (!validation.valid) {
|
|
418
|
+
throw new Error(`[frame2d] invalid model: ${validation.errors.join('; ')}`);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const nodeById = new Map(model.nodes.map((n) => [n.id, n]));
|
|
422
|
+
const elemById = new Map(model.elements.map((e) => [e.id, e]));
|
|
423
|
+
|
|
424
|
+
// DOF numbering: node i → [3i, 3i+1, 3i+2] = [ux, uy, θ]
|
|
425
|
+
const nodeIndex = new Map(model.nodes.map((n, i) => [n.id, i]));
|
|
426
|
+
const nDOF = model.nodes.length * 3;
|
|
427
|
+
|
|
428
|
+
// Assemble global stiffness matrix
|
|
429
|
+
const K = zeros(nDOF, nDOF);
|
|
430
|
+
for (const elem of model.elements) {
|
|
431
|
+
const fromNode = nodeById.get(elem.fromNodeId)!;
|
|
432
|
+
const toNode = nodeById.get(elem.toNodeId)!;
|
|
433
|
+
const Ke = globalElementStiffness(elem, fromNode, toNode);
|
|
434
|
+
const i0 = nodeIndex.get(elem.fromNodeId)! * 3;
|
|
435
|
+
const i1 = nodeIndex.get(elem.toNodeId)! * 3;
|
|
436
|
+
const dofs = [i0, i0+1, i0+2, i1, i1+1, i1+2];
|
|
437
|
+
for (let r = 0; r < 6; r++)
|
|
438
|
+
for (let c_ = 0; c_ < 6; c_++)
|
|
439
|
+
K[dofs[r]][dofs[c_]] += Ke[r][c_];
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Assemble global force vector
|
|
443
|
+
const F = new Array<number>(nDOF).fill(0);
|
|
444
|
+
|
|
445
|
+
for (const load of model.nodalLoads ?? []) {
|
|
446
|
+
const idx = nodeIndex.get(load.nodeId)! * 3;
|
|
447
|
+
F[idx] += load.Fx ?? 0;
|
|
448
|
+
F[idx + 1] += load.Fy ?? 0;
|
|
449
|
+
F[idx + 2] += load.Mz ?? 0;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
for (const dl of model.distributedLoads ?? []) {
|
|
453
|
+
const elem = elemById.get(dl.elementId)!;
|
|
454
|
+
const fromNode = nodeById.get(elem.fromNodeId)!;
|
|
455
|
+
const toNode = nodeById.get(elem.toNodeId)!;
|
|
456
|
+
const f_global = equivalentNodalForces(dl, elem, fromNode, toNode);
|
|
457
|
+
const i0 = nodeIndex.get(elem.fromNodeId)! * 3;
|
|
458
|
+
const i1 = nodeIndex.get(elem.toNodeId)! * 3;
|
|
459
|
+
const dofs = [i0, i0+1, i0+2, i1, i1+1, i1+2];
|
|
460
|
+
for (let r = 0; r < 6; r++) F[dofs[r]] += f_global[r];
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Apply boundary conditions via penalty method
|
|
464
|
+
// Large penalty number makes constrained DOF effectively zero-displacement
|
|
465
|
+
const PENALTY = 1e14;
|
|
466
|
+
const restraints: boolean[] = new Array<boolean>(nDOF).fill(false);
|
|
467
|
+
for (const support of model.supports) {
|
|
468
|
+
const idx = nodeIndex.get(support.nodeId)! * 3;
|
|
469
|
+
if (support.ux) { K[idx][idx] += PENALTY; restraints[idx] = true; }
|
|
470
|
+
if (support.uy) { K[idx+1][idx+1] += PENALTY; restraints[idx+1] = true; }
|
|
471
|
+
if (support.theta) { K[idx+2][idx+2] += PENALTY; restraints[idx+2] = true; }
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Solve K * u = F
|
|
475
|
+
const u = gaussSolve(K, F);
|
|
476
|
+
|
|
477
|
+
// Extract nodal displacements
|
|
478
|
+
const nodeDisplacements: NodeDisplacement[] = model.nodes.map((node) => {
|
|
479
|
+
const idx = nodeIndex.get(node.id)! * 3;
|
|
480
|
+
return {
|
|
481
|
+
nodeId: node.id,
|
|
482
|
+
ux: u[idx],
|
|
483
|
+
uy: u[idx + 1],
|
|
484
|
+
theta: u[idx + 2],
|
|
485
|
+
};
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
// Recover support reactions: R = K_orig * u − F (at restrained DOFs)
|
|
489
|
+
// We rebuild K without penalty for clean reaction recovery
|
|
490
|
+
const K_clean = zeros(nDOF, nDOF);
|
|
491
|
+
for (const elem of model.elements) {
|
|
492
|
+
const fromNode = nodeById.get(elem.fromNodeId)!;
|
|
493
|
+
const toNode = nodeById.get(elem.toNodeId)!;
|
|
494
|
+
const Ke = globalElementStiffness(elem, fromNode, toNode);
|
|
495
|
+
const i0 = nodeIndex.get(elem.fromNodeId)! * 3;
|
|
496
|
+
const i1 = nodeIndex.get(elem.toNodeId)! * 3;
|
|
497
|
+
const dofs = [i0, i0+1, i0+2, i1, i1+1, i1+2];
|
|
498
|
+
for (let r = 0; r < 6; r++)
|
|
499
|
+
for (let c_ = 0; c_ < 6; c_++)
|
|
500
|
+
K_clean[dofs[r]][dofs[c_]] += Ke[r][c_];
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const reactions: SupportReaction[] = model.supports.map((support) => {
|
|
504
|
+
const idx = nodeIndex.get(support.nodeId)! * 3;
|
|
505
|
+
const Ku_row = (row: number) => K_clean[row].reduce((s, kij, j) => s + kij * u[j], 0);
|
|
506
|
+
return {
|
|
507
|
+
nodeId: support.nodeId,
|
|
508
|
+
Rx: support.ux ? Ku_row(idx) - F[idx] : 0,
|
|
509
|
+
Ry: support.uy ? Ku_row(idx + 1) - F[idx + 1] : 0,
|
|
510
|
+
Mz: support.theta ? Ku_row(idx + 2) - F[idx + 2] : 0,
|
|
511
|
+
};
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
// Recover element internal forces
|
|
515
|
+
const elementForces: ElementForces[] = model.elements.map((elem) => {
|
|
516
|
+
const fromNode = nodeById.get(elem.fromNodeId)!;
|
|
517
|
+
const toNode = nodeById.get(elem.toNodeId)!;
|
|
518
|
+
const dx = toNode.x - fromNode.x;
|
|
519
|
+
const dy = toNode.y - fromNode.y;
|
|
520
|
+
const L = Math.sqrt(dx * dx + dy * dy);
|
|
521
|
+
const c = dx / L;
|
|
522
|
+
const s = dy / L;
|
|
523
|
+
const E = elem.elasticModulusGPa * 1e6;
|
|
524
|
+
const I = elem.momentOfInertiaM4;
|
|
525
|
+
const A = elem.areaM2;
|
|
526
|
+
|
|
527
|
+
const i0 = nodeIndex.get(elem.fromNodeId)! * 3;
|
|
528
|
+
const i1 = nodeIndex.get(elem.toNodeId)! * 3;
|
|
529
|
+
const u_global = [u[i0], u[i0+1], u[i0+2], u[i1], u[i1+1], u[i1+2]];
|
|
530
|
+
|
|
531
|
+
// Transform to local: u_local = T * u_global
|
|
532
|
+
const T = transformMatrix(c, s);
|
|
533
|
+
const u_local = u_global.map((_, i) => T[i].reduce((sum, tij, j) => sum + tij * u_global[j], 0));
|
|
534
|
+
|
|
535
|
+
const k_local = localStiffness(E, I, A, L);
|
|
536
|
+
// f_local = k_local * u_local (internal forces in local frame)
|
|
537
|
+
const f_local = k_local.map((row) => row.reduce((sum, kij, j) => sum + kij * u_local[j], 0));
|
|
538
|
+
|
|
539
|
+
// Recover true element internal forces under UDL:
|
|
540
|
+
// f_int = k * u_local + f_fef
|
|
541
|
+
// Because we applied f_eq = −f_fef to the global load vector, the displacement
|
|
542
|
+
// solution encodes only the "flexibility" part. Adding back the fixed-end forces
|
|
543
|
+
// (f_fef) reconstructs the physically correct shear/moment diagram.
|
|
544
|
+
const dl = (model.distributedLoads ?? []).find((d) => d.elementId === elem.id);
|
|
545
|
+
let f_local_adj = [...f_local];
|
|
546
|
+
if (dl) {
|
|
547
|
+
const f_fef_local = [0, (dl.w * L) / 2, (dl.w * L * L) / 12, 0, (dl.w * L) / 2, -(dl.w * L * L) / 12];
|
|
548
|
+
f_local_adj = f_local.map((v, i) => v + f_fef_local[i]);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Utilisation ratio: max(|M|) / Mp (plastic moment capacity)
|
|
552
|
+
const Mmax = Math.max(Math.abs(f_local_adj[2]), Math.abs(f_local_adj[5]));
|
|
553
|
+
let utilisationRatio = 0;
|
|
554
|
+
if (elem.plasticModulusM3 && elem.yieldStrengthMPa) {
|
|
555
|
+
const Mp = elem.plasticModulusM3 * elem.yieldStrengthMPa * 1000; // kN·m
|
|
556
|
+
utilisationRatio = Mp > 0 ? Mmax / Mp : 0;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
return {
|
|
560
|
+
elementId: elem.id,
|
|
561
|
+
N_start: -f_local_adj[0], // sign: tension positive convention
|
|
562
|
+
V_start: -f_local_adj[1],
|
|
563
|
+
M_start: f_local_adj[2],
|
|
564
|
+
N_end: f_local_adj[3],
|
|
565
|
+
V_end: f_local_adj[4],
|
|
566
|
+
M_end: f_local_adj[5],
|
|
567
|
+
utilisationRatio,
|
|
568
|
+
};
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
const maxDisplacementM = Math.max(
|
|
572
|
+
...nodeDisplacements.map((d) => Math.sqrt(d.ux ** 2 + d.uy ** 2)),
|
|
573
|
+
);
|
|
574
|
+
const maxUtilisationRatio = Math.max(0, ...elementForces.map((ef) => ef.utilisationRatio));
|
|
575
|
+
|
|
576
|
+
return {
|
|
577
|
+
solverType: 'dsm-2d-frame',
|
|
578
|
+
converged: true,
|
|
579
|
+
nodeDisplacements,
|
|
580
|
+
reactions,
|
|
581
|
+
elementForces,
|
|
582
|
+
maxDisplacementM,
|
|
583
|
+
maxUtilisationRatio,
|
|
584
|
+
structurallyAdequate: maxUtilisationRatio < 1.0 || maxUtilisationRatio === 0,
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// ─── Receipt ──────────────────────────────────────────────────────────────────
|
|
589
|
+
|
|
590
|
+
export function buildFrame2DReceipt(
|
|
591
|
+
model: Frame2DModel,
|
|
592
|
+
result: Frame2DResult,
|
|
593
|
+
options: Frame2DReceiptOptions = {},
|
|
594
|
+
): Frame2DReceipt {
|
|
595
|
+
const violations: Array<{ criterion: string; message: string }> = [];
|
|
596
|
+
|
|
597
|
+
if (!result.converged) {
|
|
598
|
+
violations.push({ criterion: 'convergence', message: 'frame solver did not converge' });
|
|
599
|
+
}
|
|
600
|
+
if (!result.structurallyAdequate) {
|
|
601
|
+
violations.push({
|
|
602
|
+
criterion: 'utilisation',
|
|
603
|
+
message: `max utilisation ratio ${result.maxUtilisationRatio.toFixed(3)} ≥ 1.0 (section overstressed)`,
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const receipt = buildDomainSimulationReceipt({
|
|
608
|
+
plugin: 'civil-engineering' as const,
|
|
609
|
+
pluginVersion: '1.0.0',
|
|
610
|
+
runId: options.runId ?? `frame2d-${Date.now().toString(36)}`,
|
|
611
|
+
createdAt: options.createdAt,
|
|
612
|
+
modelId: model.id,
|
|
613
|
+
solverConfig: {
|
|
614
|
+
solverType: 'dsm-2d-frame',
|
|
615
|
+
scale: 'building',
|
|
616
|
+
nodeCount: model.nodes.length,
|
|
617
|
+
elementCount: model.elements.length,
|
|
618
|
+
dofCount: model.nodes.length * 3,
|
|
619
|
+
},
|
|
620
|
+
resultSummary: {
|
|
621
|
+
converged: result.converged,
|
|
622
|
+
structurallyAdequate: result.structurallyAdequate,
|
|
623
|
+
maxDisplacementMm: result.maxDisplacementM * 1000,
|
|
624
|
+
maxUtilisationRatio: result.maxUtilisationRatio,
|
|
625
|
+
},
|
|
626
|
+
cael: {
|
|
627
|
+
version: 'cael.v1',
|
|
628
|
+
event: 'civil_engineering.frame_analysis',
|
|
629
|
+
solverType: 'civil-engineering.dsm-2d-frame',
|
|
630
|
+
},
|
|
631
|
+
acceptance: { accepted: violations.length === 0, violations },
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
return receipt as unknown as Frame2DReceipt;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
export const CIVIL_ENGINEERING_PLUGIN_VERSION = '1.0.0';
|
|
638
|
+
export const CIVIL_DOMAIN_SIMULATION_RECEIPT_SCHEMA = DOMAIN_SIMULATION_RECEIPT_SCHEMA;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export * from './frame2d';
|
|
2
|
+
export { createStructuralAnalysisHandler, type StructuralAnalysisConfig, type StructureType, type MaterialType } from './traits/StructuralAnalysisTrait';
|
|
3
|
+
export { createLoadBearingHandler, type LoadBearingConfig, type LoadCase, type LoadType } from './traits/LoadBearingTrait';
|
|
4
|
+
export { createMaterialFatigueHandler, type MaterialFatigueConfig } from './traits/MaterialFatigueTrait';
|
|
5
|
+
export * from './traits/types';
|
|
6
|
+
|
|
7
|
+
import { createStructuralAnalysisHandler } from './traits/StructuralAnalysisTrait';
|
|
8
|
+
import { createLoadBearingHandler } from './traits/LoadBearingTrait';
|
|
9
|
+
import { createMaterialFatigueHandler } from './traits/MaterialFatigueTrait';
|
|
10
|
+
|
|
11
|
+
export * from './frame2d';
|
|
12
|
+
|
|
13
|
+
export const pluginMeta = { name: '@holoscript/plugin-civil-engineering', version: '1.0.0', traits: ['structural_analysis', 'load_bearing', 'material_fatigue', 'dsm_frame_2d'] };
|
|
14
|
+
export const traitHandlers = [createStructuralAnalysisHandler(), createLoadBearingHandler(), createMaterialFatigueHandler()];
|
|
15
|
+
|
|
16
|
+
// Runtime integration — behavioral trait handler + registrar that wire the
|
|
17
|
+
// deterministic Direct-Stiffness-Method 2D frame solver into HoloScriptRuntime's
|
|
18
|
+
// dispatch. Closes the built-but-dead-wired gap for `dsm_frame_2d`, mirroring
|
|
19
|
+
// government-civic's `civic_decision` reference integration.
|
|
20
|
+
export {
|
|
21
|
+
CIVIL_ENGINEERING_PLUGIN_ID,
|
|
22
|
+
dsmFrame2dHandler,
|
|
23
|
+
registerCivilEngineeringTraitHandlers,
|
|
24
|
+
type DsmFrame2dTraitConfig,
|
|
25
|
+
type DsmFrame2dSolvedEvent,
|
|
26
|
+
type RuntimeTraitHandler,
|
|
27
|
+
type TraitRegistrar,
|
|
28
|
+
} from './runtime';
|