engforge 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. engforge/__init__.py +26 -0
  2. engforge/_testing_components.py +864 -0
  3. engforge/analysis.py +257 -0
  4. engforge/attr_dynamics.py +222 -0
  5. engforge/attr_plotting.py +811 -0
  6. engforge/attr_signals.py +116 -0
  7. engforge/attr_slots.py +211 -0
  8. engforge/attr_solver.py +443 -0
  9. engforge/attributes.py +326 -0
  10. engforge/common.py +76 -0
  11. engforge/component_collections.py +276 -0
  12. engforge/components.py +55 -0
  13. engforge/configuration.py +673 -0
  14. engforge/dataframe.py +185 -0
  15. engforge/datastores/__init__.py +116 -0
  16. engforge/datastores/data.py +519 -0
  17. engforge/datastores/gdocs.py +2650 -0
  18. engforge/datastores/reporting.py +935 -0
  19. engforge/datastores/secrets.py +549 -0
  20. engforge/dynamics.py +662 -0
  21. engforge/eng/__init__.py +0 -0
  22. engforge/eng/costs.py +1459 -0
  23. engforge/eng/fluid_material.py +279 -0
  24. engforge/eng/geometry.py +1060 -0
  25. engforge/eng/pipes.py +1211 -0
  26. engforge/eng/prediction.py +366 -0
  27. engforge/eng/solid_materials.py +445 -0
  28. engforge/eng/structure.py +2228 -0
  29. engforge/eng/structure_beams.py +821 -0
  30. engforge/eng/thermodynamics.py +271 -0
  31. engforge/engforge_attributes.py +380 -0
  32. engforge/env_var.py +214 -0
  33. engforge/locations.py +18 -0
  34. engforge/logging.py +290 -0
  35. engforge/patterns.py +449 -0
  36. engforge/problem_context.py +2251 -0
  37. engforge/properties.py +501 -0
  38. engforge/reporting.py +595 -0
  39. engforge/solveable.py +964 -0
  40. engforge/solver.py +316 -0
  41. engforge/solver_utils.py +726 -0
  42. engforge/system.py +143 -0
  43. engforge/system_reference.py +299 -0
  44. engforge/tabulation.py +288 -0
  45. engforge/test/__init__.py +2 -0
  46. engforge/test/_pre_test_structures.py +221 -0
  47. engforge/test/report_testing.py +158 -0
  48. engforge/test/test_airfilter.py +91 -0
  49. engforge/test/test_analysis.py +3 -0
  50. engforge/test/test_comp_iter.py +196 -0
  51. engforge/test/test_composition.py +139 -0
  52. engforge/test/test_costs.py +338 -0
  53. engforge/test/test_dynamics.py +109 -0
  54. engforge/test/test_dynamics_spaces.py +39 -0
  55. engforge/test/test_four_bar.py +97 -0
  56. engforge/test/test_modules.py +72 -0
  57. engforge/test/test_performance.py +93 -0
  58. engforge/test/test_pipes.py +49 -0
  59. engforge/test/test_problem.py +411 -0
  60. engforge/test/test_problem_deepscoping.py +68 -0
  61. engforge/test/test_slider_crank.py +51 -0
  62. engforge/test/test_solver.py +343 -0
  63. engforge/test/test_tabulation.py +233 -0
  64. engforge/typing.py +92 -0
  65. engforge-0.3.0.dist-info/METADATA +392 -0
  66. engforge-0.3.0.dist-info/RECORD +69 -0
  67. engforge-0.3.0.dist-info/WHEEL +5 -0
  68. engforge-0.3.0.dist-info/licenses/LICENSE +7 -0
  69. engforge-0.3.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1060 @@
1
+ """These exist as in interface to sectionproperties from Pynite"""
2
+
3
+ from engforge.configuration import Configuration, forge, LoggingMixin
4
+ from engforge.properties import (
5
+ cached_system_property,
6
+ system_property,
7
+ instance_cached,
8
+ )
9
+ from engforge.typing import Options
10
+ from engforge.eng.prediction import PredictionMixin
11
+ from engforge.env_var import EnvVariable
12
+ import numpy
13
+ import attr, attrs
14
+
15
+ from scipy import optimize as skopt
16
+
17
+ from sklearn import svm
18
+
19
+ from sectionproperties.pre.geometry import Geometry
20
+ from sectionproperties.pre.pre import Material as sec_material
21
+ from sectionproperties.analysis.section import Section
22
+ import sectionproperties.pre.library.primitive_sections as sections
23
+ import numpy as np
24
+ import shapely
25
+ import attr, attrs
26
+ import functools
27
+ import itertools
28
+ import multiprocessing as mp
29
+ import threading
30
+ import random
31
+ import pandas
32
+ import pickle
33
+ import hashlib
34
+ import tempfile, os
35
+ import json
36
+
37
+ # generic cross sections from
38
+ # https://mechanicalbase.com/area-moment-of-inertia-calculator-of-certain-cross-sectional-shapes/
39
+ _temp = os.path.join(os.path.expanduser("~"), ".forge_tmp")
40
+ temp_path = os.path.join(_temp, "shapely_sections")
41
+
42
+ section_cache = EnvVariable(
43
+ "FORGE_CACHE",
44
+ default=temp_path,
45
+ desc="directory to cache section properties",
46
+ )
47
+ if "FORGE_CACHE" not in os.environ and not os.path.exists(temp_path):
48
+ os.makedirs(temp_path, 0o741, exist_ok=True)
49
+
50
+ section_cache.info(f"loading section from {section_cache.secret}")
51
+
52
+
53
+ class GeometryLog(LoggingMixin):
54
+ pass
55
+
56
+
57
+ log = GeometryLog()
58
+
59
+
60
+ def conver_np(inpt):
61
+ if isinstance(inpt, np.ndarray):
62
+ return inpt
63
+ elif isinstance(inpt, (list, tuple)):
64
+ return np.array(inpt)
65
+ elif isinstance(inpt, (int, float)):
66
+ return np.array([inpt])
67
+ else:
68
+ raise ValueError(f"got non numpy array/float: {inpt}")
69
+
70
+
71
+ @attrs.define(slots=False)
72
+ class ParametricSpline:
73
+ """a multivariate spline defined by uniform length vector input, with points P1,P2 and their slopes P1ds,P2ds"""
74
+
75
+ P1 = attrs.field(converter=conver_np)
76
+ P2 = attrs.field(converter=conver_np)
77
+ P1ds = attrs.field(converter=conver_np)
78
+ P2ds = attrs.field(converter=conver_np)
79
+
80
+ def __attrs_post_init__(self):
81
+ assert len(self.P1) == len(self.P2)
82
+ assert len(self.P1ds) == len(self.P2ds)
83
+ assert len(self.P1) == len(self.P1ds)
84
+
85
+ @functools.cached_property
86
+ def a0(self):
87
+ return self.P1
88
+
89
+ @functools.cached_property
90
+ def a1(self):
91
+ return self.P1ds
92
+
93
+ @functools.cached_property
94
+ def a2(self):
95
+ return 3 * (self.P2 - self.P1) - 2 * self.P1ds - self.P2ds
96
+
97
+ @functools.cached_property
98
+ def a3(self):
99
+ return (self.P2ds - self.P1ds - 2 * self.a2) / 3.0
100
+
101
+ def coords(self, s: float):
102
+ if isinstance(s, (float, int)):
103
+ assert s <= 1
104
+ assert 0 <= s
105
+ return self._coords(s)
106
+ elif isinstance(s, (list, tuple, numpy.ndarray)):
107
+ assert max(s) <= 1
108
+ assert min(s) >= 0
109
+ ol = [self._coords(si) for si in s]
110
+ return numpy.array(ol)
111
+ else:
112
+ raise ValueError(f"non array/float input {s}")
113
+
114
+ def _coords(self, s: float):
115
+ return self.a0 + self.a1 * s + self.a2 * s**2 + self.a3 * s**3
116
+
117
+
118
+ # TODO: cache SectionProperty sections and develop auto-mesh refinement system.
119
+
120
+
121
+ @forge
122
+ class Profile2D(Configuration, PredictionMixin):
123
+ name: str = attr.ib(default="generic cross section")
124
+
125
+ # provide relative interface over
126
+ y_bounds: tuple = None
127
+ x_bounds: tuple = None
128
+
129
+ @property
130
+ def A(self):
131
+ return 0
132
+
133
+ @property
134
+ def Ao(self):
135
+ """outside area, over ride for hallow sections"""
136
+ return self.A
137
+
138
+ @property
139
+ def Ixx(self):
140
+ return 0
141
+
142
+ @property
143
+ def Iyy(self):
144
+ return 0
145
+
146
+ @property
147
+ def J(self):
148
+ return 0
149
+
150
+ def display_results(self):
151
+ self.info("mock section, no results to display")
152
+
153
+ def plot_mesh(self):
154
+ self.info("mock section, no mesh to plot")
155
+
156
+ def calculate_stress(self, N, Vx, Vy, Mxx, Myy, M11, M22, Mzz):
157
+ # TODO: Implement stress object, make fake mesh, and mock?
158
+ # sigma_n = N / self.A
159
+ # sigma_bx = self.Myy * self.max_y / self.Ixx
160
+ # sigma_by = self.Mxx * self.max_x / self.Iyy
161
+ self.warning(f"calculating stress in simple profile!")
162
+ return np.nan
163
+
164
+ def estimate_stress(self, N, Vx, Vy, Mxx, Myy, M11, M22, Mzz):
165
+ # TODO: Implement stress object, make fake mesh, and mock?
166
+ # sigma_n = N / self.A
167
+ # sigma_bx = self.Myy * self.max_y / self.Ixx
168
+ # sigma_by = self.Mxx * self.max_x / self.Iyy
169
+ self.warning(f"estimating stress in simple profile!")
170
+ return np.nan
171
+
172
+
173
+ @forge
174
+ class Rectangle(Profile2D):
175
+ """models rectangle with base b, and height h"""
176
+
177
+ b: float = attr.ib()
178
+ h: float = attr.ib()
179
+ name: str = attr.ib(default="rectangular section")
180
+
181
+ def __on_init__(self):
182
+ self.y_bounds = (self.h / 2, -self.h / 2)
183
+ self.x_bounds = (self.b / 2, -self.b / 2)
184
+
185
+ @property
186
+ def A(self):
187
+ return self.h * self.b
188
+
189
+ @property
190
+ def Ixx(self):
191
+ return self.b * self.h**3.0 / 12.0
192
+
193
+ @property
194
+ def Iyy(self):
195
+ return self.h * self.b**3.0 / 12.0
196
+
197
+ @property
198
+ def J(self):
199
+ return (self.h * self.b) * (self.b**2.0 + self.h**2.0) / 12
200
+
201
+
202
+ @forge
203
+ class Triangle(Profile2D):
204
+ """models a triangle with base, b and height h"""
205
+
206
+ b: float = attr.ib()
207
+ h: float = attr.ib()
208
+ name: str = attr.ib(default="rectangular section")
209
+
210
+ def __on_init__(self):
211
+ self.y_bounds = (self.h / 2, -self.h / 2)
212
+ self.x_bounds = (self.b / 2, -self.b / 2)
213
+
214
+ @property
215
+ def A(self):
216
+ return self.h * self.b / 2.0
217
+
218
+ @property
219
+ def Ixx(self):
220
+ return self.b * self.h**3.0 / 36.0
221
+
222
+ @property
223
+ def Iyy(self):
224
+ return self.h * self.b**3.0 / 36.0
225
+
226
+ @property
227
+ def J(self):
228
+ return (self.h * self.b) * (self.b**2.0 + self.h**2.0) / 12
229
+
230
+
231
+ @forge
232
+ class Circle(Profile2D):
233
+ """models a solid circle with diameter d"""
234
+
235
+ d: float = attr.ib()
236
+ name: str = attr.ib(default="rectangular section")
237
+
238
+ def __on_init__(self):
239
+ self.x_bounds = self.y_bounds = (self.d / 2, -self.d / 2)
240
+
241
+ @property
242
+ def A(self):
243
+ return (self.d / 2.0) ** 2.0 * numpy.pi
244
+
245
+ @property
246
+ def Ixx(self):
247
+ return numpy.pi * (self.d**4.0 / 64.0)
248
+
249
+ @property
250
+ def Iyy(self):
251
+ return numpy.pi * (self.d**4.0 / 64.0)
252
+
253
+ @property
254
+ def J(self):
255
+ return numpy.pi * (self.d**4.0 / 32.0)
256
+
257
+
258
+ @forge
259
+ class HollowCircle(Profile2D):
260
+ """models a hollow circle with diameter d and thickness t"""
261
+
262
+ d: float = attr.ib()
263
+ t: float = attr.ib()
264
+ name: str = attr.ib(default="rectangular section")
265
+
266
+ def __on_init__(self):
267
+ self.y_bounds = (self.d / 2, -self.d / 2)
268
+ self.x_bounds = (self.d / 2, -self.d / 2)
269
+
270
+ @property
271
+ def di(self):
272
+ return self.d - self.t * 2
273
+
274
+ @property
275
+ def Ao(self):
276
+ """outside area, over ride for hallow sections"""
277
+ return (self.d**2.0) / 4.0 * numpy.pi
278
+
279
+ @property
280
+ def A(self):
281
+ return (self.d**2.0 - self.di**2.0) / 4.0 * numpy.pi
282
+
283
+ @property
284
+ def Ixx(self):
285
+ return numpy.pi * ((self.d**4.0 - self.di**4.0) / 64.0)
286
+
287
+ @property
288
+ def Iyy(self):
289
+ return numpy.pi * ((self.d**4.0 - self.di**4.0) / 64.0)
290
+
291
+ @property
292
+ def J(self):
293
+ return numpy.pi * ((self.d**4.0 - self.di**4.0) / 32.0)
294
+
295
+
296
+ # ADVANCED CUSTOM SECTIONS
297
+ def get_mesh_size(inst):
298
+ if isinstance(inst.shape, Geometry):
299
+ shape = inst.shape.geom
300
+ else:
301
+ shape = inst.shape
302
+
303
+ dec = inst.mesh_extent_decimation
304
+ x, y = shape.exterior.coords.xy
305
+ dx = abs(max(x) - min(x))
306
+ dy = abs(max(y) - min(y))
307
+ ddx = np.abs(np.diff(x))
308
+ ddx = ddx[ddx > inst.min_mesh_size].tolist()
309
+ ddy = np.abs(np.diff(y))
310
+ ddy = ddy[ddy > inst.min_mesh_size].tolist()
311
+
312
+ A = shape.area
313
+ dAmin = A * 0.95 / (inst.goal_elements)
314
+
315
+ cans = [dx / dec, dy / dec]
316
+ if ddx:
317
+ cans.append(min(ddx))
318
+ if ddy:
319
+ cans.append(min(ddy))
320
+
321
+ ms = max(min(cans), inst.min_mesh_size)
322
+ return max(ms**2.0, dAmin) # length to area conversion
323
+
324
+
325
+ def calculate_stress(
326
+ section,
327
+ n=0,
328
+ vx=0,
329
+ vy=0,
330
+ mxx=0,
331
+ myy=0,
332
+ mzz=0,
333
+ raw=False,
334
+ row=False,
335
+ record=True,
336
+ value=True,
337
+ ) -> float:
338
+ """returns the maximum vonmises stress in the section and returns the ratio of the allowable stress, also known as the failure fracion
339
+ :param raw: if raw is true, the stress object is returned, otherwise the failure fraction is returned
340
+ """
341
+ inp = dict(n=n, vx=vx, vy=vy, mxx=mxx, myy=myy, mzz=mzz)
342
+
343
+ stress = section._sec.calculate_stress(**inp).get_stress()[0]
344
+ fail_stress = section.determine_failure_stress(stress)
345
+ inp["fail_stress"] = fail_stress
346
+ inp["fail_frac"] = ff = fail_stress / section.material.allowable_stress
347
+ inp["fails"] = int(ff >= 1)
348
+
349
+ if record:
350
+ section.record_stress(inp)
351
+
352
+ if value:
353
+ return fail_stress
354
+
355
+ if raw:
356
+ return stress
357
+
358
+ if row:
359
+ return inp
360
+
361
+ return ff
362
+
363
+
364
+ @forge(hash=False)
365
+ class ShapelySection(Profile2D):
366
+ """a 2D profile that takes a shapely section to calculate section properties, use a sectionproperties section with hidden variable `_geo` to bypass shape calculation"""
367
+
368
+ name: str = attrs.field(default="shapely section")
369
+ shape: shapely.Polygon = attrs.field()
370
+
371
+ # Mesh sizing
372
+ coarse: bool = attrs.field(default=False)
373
+ mesh_extent_decimation = attrs.field(default=100)
374
+ min_mesh_angle: float = attrs.field(default=20) # below 20.7 garunteed to work
375
+ min_mesh_size: float = attrs.field(default=1e-5) # multiply by min
376
+ goal_elements: float = attrs.field(default=1000) # multiply by min
377
+ _mesh_size: float = attrs.field(default=attrs.Factory(get_mesh_size, True))
378
+
379
+ material: sec_material = attrs.field(default=None)
380
+ failure_mode = Options("von_mises", "max_norm", "maximum_strain")
381
+
382
+ # Stress classification & prediction
383
+ prediction: bool = attr.field(default=False)
384
+ prediction_goal_error: float = attrs.field(default=0.025)
385
+ max_records: list = attr.field(default=10000)
386
+ prediction_records: list
387
+ near_margin = 0.1
388
+ max_margin = 1.5
389
+ save_threshold = 0.90
390
+ max_rec_parm = "fail_frac"
391
+
392
+ _use_symmetric: bool = attrs.field(default=True)
393
+ _prediction_parms = ["n", "vx", "vy", "mxx", "myy", "mzz"]
394
+ _do_print: bool = attrs.field(default=False)
395
+ _sec: Section = None
396
+ _geo: Geometry = None
397
+ _A: float
398
+ _symmetric: bool
399
+ _subclass_init: bool = False
400
+
401
+ def __on_init__(self):
402
+ self.init_with_material(self.material)
403
+
404
+ if self.prediction:
405
+ self.add_prediction_record(
406
+ {
407
+ "n": 0,
408
+ "vx": 0,
409
+ "vy": 0,
410
+ "mxx": 0,
411
+ "myy": 0,
412
+ "mzz": 0,
413
+ "fails": 0,
414
+ "fail_frac": 0,
415
+ }
416
+ )
417
+ self._symmetric = self.check_symmetric()
418
+ if self._symmetric and self._use_symmetric:
419
+ self._prediction_models = {
420
+ "fails": {
421
+ "mod": svm.SVC(C=2000, gamma=0.1, probability=True),
422
+ "N": 0,
423
+ },
424
+ "fail_frac": {"mod": svm.SVR(C=5, gamma=0.5), "N": 0},
425
+ }
426
+ else:
427
+ self._prediction_models = {
428
+ "fails": {
429
+ "mod": svm.SVC(C=5000, gamma=5, probability=True),
430
+ "N": 0,
431
+ },
432
+ "fail_frac": {"mod": svm.SVR(C=10, gamma=0.5), "N": 0},
433
+ }
434
+ # self.determine_failure_front(pareto_front=False)
435
+ # self.basis_expand(expand_values=[0.75,0.5,0.1],Nparm=3,est=True)
436
+
437
+ def training_callback(self, models):
438
+ """when training is complete save the model to a pickle with `ShapelySection_<hash>.pkl`"""
439
+ score = models["fail_frac"]["train_score"]
440
+ Nscore = models["fail_frac"]["N"]
441
+ test_score = score * Nscore # compare to meta file
442
+
443
+ # Opt out of saving if score is below threshold
444
+ if score < self.save_threshold:
445
+ return
446
+ # Opt out of saving if score is worse than previous
447
+ if os.path.exists(self.meta_path):
448
+ with open(self.meta_path, "r") as f:
449
+ meta = json.load(f)
450
+ if test_score < meta["test_score"]:
451
+ self.info(
452
+ f'new model score {test_score} is worse than {meta["test_score"]}, not saving'
453
+ )
454
+ return
455
+
456
+ new_meta = {"test_score": test_score, "N": Nscore, "train_score": score}
457
+ # add a meta json file to show info regarding data (num points better ect)
458
+ with open(self.meta_path, "w") as f:
459
+ json.dump(new_meta, f)
460
+
461
+ # Finally save the geometry
462
+ self.info(f"saving model with score: {Nscore}x{score}=>{test_score}")
463
+ fil = self.cache_path
464
+ with open(fil, "wb") as f:
465
+ pickle.dump(self, f)
466
+ self.info(f"saved section to {fil}")
467
+
468
+ @property
469
+ def section_cache(self) -> str:
470
+ return section_cache.secret
471
+
472
+ @property
473
+ def cache_name(self) -> str:
474
+ return f"ShapelySection_{self.hash_id()}.pkl"
475
+
476
+ @property
477
+ def meta_name(self) -> str:
478
+ return f"ShapelySection_meta_{self.hash_id()}.json"
479
+
480
+ @property
481
+ def cache_path(self):
482
+ return os.path.join(self.section_cache, self.cache_name)
483
+
484
+ @property
485
+ def meta_path(self):
486
+ return os.path.join(self.section_cache, self.meta_name)
487
+
488
+ @classmethod
489
+ def from_cache(cls, hash_id):
490
+ cchc = section_cache.secret
491
+ log.info(f"loading section {hash_id} from cache {cchc}")
492
+ fil = os.path.join(cchc, f"ShapelySection_{hash_id}.pkl")
493
+ with open(fil, "rb") as f:
494
+ model = pickle.load(f)
495
+ return model
496
+
497
+ def prediction_weights(self, df, window, initial_weight=10):
498
+ weights = numpy.ones(min(len(df), window))
499
+ weights[0] = initial_weight**2 # zero value is important!
500
+ weights[: getattr(self, "N_base", 100)] = initial_weight # then base values
501
+ if hasattr(self, "N_pareto"):
502
+ weights[: getattr(self, "N_pareto")] = (
503
+ initial_weight**0.5
504
+ ) # then pareto values
505
+ # Dont emphasise fit above max margin
506
+ dm = (df.fail_frac - self.max_margin).to_numpy()
507
+ penalize_inx = dm > 0
508
+ weights[penalize_inx] = np.maximum(1.0 / ((1.0 + dm[penalize_inx])), 0.1)
509
+ return weights
510
+
511
+ def _subsample_data(self, X, y, window, weights):
512
+ """subsamples the data to the window size"""
513
+ inx = getattr(self, "N_pareto", getattr(self, "N_base", window))
514
+ x1 = X.iloc[:inx]
515
+ y1 = y.iloc[:inx]
516
+ w1 = weights[:inx]
517
+ if inx != window:
518
+ x2 = X.iloc[inx:].sample(frac=0.5)
519
+ y2 = y.iloc[x2.index]
520
+ w2 = weights[x2.index]
521
+ return (
522
+ pandas.concat((x1, x2)),
523
+ pandas.concat((y1, y2)),
524
+ numpy.concatenate((w1, w2)),
525
+ )
526
+ return X.iloc[:window], y.iloc[:window], weights[:window]
527
+
528
+ def reset_prediction(self):
529
+ self._fitted = False
530
+ self._basis = None
531
+ self._training_history = None
532
+ self._running_error = None
533
+ self.add_prediction_record(
534
+ {
535
+ "n": 0,
536
+ "vx": 0,
537
+ "vy": 0,
538
+ "mxx": 0,
539
+ "myy": 0,
540
+ "mzz": 0,
541
+ "fails": 0,
542
+ "fail_frac": 0,
543
+ }
544
+ )
545
+ # self._symmetric = self.check_symmetric()
546
+
547
+ @property
548
+ def mesh_size(self):
549
+ return self._mesh_size
550
+
551
+ @property
552
+ def _prediction_record(self):
553
+ """not a property of state, just return an empty dict, we add record manually"""
554
+ return {}
555
+
556
+ @mesh_size.setter
557
+ def mesh_size(self, value):
558
+ # print(f'setting mesh size to {value}')
559
+ # BUG: something going on with setattrs on mesh_size, setting to None, hacky fix to set __dict__ directly
560
+ _mesh_size = max(value, self.min_mesh_area)
561
+ self.__dict__["_mesh_size"] = _mesh_size
562
+ self.mesh_section()
563
+
564
+ def init_with_material(self, material=None):
565
+ if self._sec is not None:
566
+ raise Exception(f"already initalized!")
567
+
568
+ if isinstance(self.shape, Geometry):
569
+ self._geo = self.shape
570
+ if self._geo.material and self.material:
571
+ self.warning(
572
+ f"overriding material {self._geo.material} with {self.material}"
573
+ )
574
+ self._geo.material = self.material
575
+ elif self._geo.material:
576
+ self.info(f"setting beam material from section")
577
+ self.material = self._geo.material
578
+ elif self.material:
579
+ self._geo.material = self.material
580
+ elif isinstance(self.shape, shapely.Polygon):
581
+ self._geo = Geometry(self.shape, self.material)
582
+ else:
583
+ raise ValueException(f"got invalid shape: {self.shape}")
584
+
585
+ self.calculate_mesh_size()
586
+ self.mesh_section()
587
+
588
+ def calculate_mesh_size(self):
589
+ self.mesh_size = get_mesh_size(self)
590
+
591
+ @property
592
+ def min_mesh_area(self):
593
+ if isinstance(self.shape, Geometry):
594
+ shape = self.shape.geom
595
+ else:
596
+ shape = self.shape
597
+ A = shape.area
598
+ dAmin = A * 0.95 / (self.goal_elements)
599
+ return dAmin
600
+
601
+ def mesh_section(self):
602
+ """caches section properties and mesh"""
603
+ self._cross_section = None # reset cross section
604
+ self._mesh = self._geo.create_mesh(
605
+ mesh_sizes=self.mesh_size,
606
+ coarse=self.coarse,
607
+ min_angle=self.min_mesh_angle,
608
+ )
609
+ self._sec = Section(self._geo)
610
+ self._sec.calculate_geometric_properties()
611
+ self._sec.calculate_warping_properties()
612
+ self._sec.calculate_frame_properties()
613
+
614
+ self._A = self._sec.get_area()
615
+ if self.material:
616
+ self._Ixx, self._Iyy, self._Ixy = self._sec.get_eic(e_ref=self.material)
617
+ self._J = self._sec.get_ej()
618
+ else:
619
+ self._Ixx, self._Iyy, self._Ixy = self._sec.get_ic()
620
+ self._J = self._sec.get_j()
621
+
622
+ self.calculate_bounds()
623
+
624
+ def calculate_bounds(self):
625
+ self.debug(f"calculating shape bounds!")
626
+ xcg, ycg = self._geo.calculate_centroid()
627
+ minx, maxx, miny, maxy = self._geo.calculate_extents()
628
+ self.y_bounds = (miny - ycg, maxy - ycg)
629
+ self.x_bounds = (minx - xcg, maxx - xcg)
630
+
631
+ @property
632
+ def A(self):
633
+ return self._A
634
+
635
+ @property
636
+ def Ao(self):
637
+ """outside area, over ride for hallow sections"""
638
+ return self.A
639
+
640
+ @property
641
+ def Ixx(self):
642
+ return self._Ixx
643
+
644
+ @property
645
+ def Iyy(self):
646
+ return self._Iyy
647
+
648
+ @property
649
+ def J(self):
650
+ return self._J
651
+
652
+ @property
653
+ def Ixy(self):
654
+ return self._Ixy
655
+
656
+ def display_results(self):
657
+ self.info("mock section, no results to display")
658
+
659
+ def plot_mesh(self):
660
+ self._sec.display_mesh_info()
661
+
662
+ def plot_mesh(self):
663
+ return self._sec.plot_centroids()
664
+
665
+ def calculate_stress(self, n=0, vx=0, vy=0, mxx=0, myy=0, mzz=0, **kw) -> float:
666
+ return calculate_stress(
667
+ self, n=n, vx=vx, vy=vy, mxx=mxx, myy=myy, mzz=mzz, **kw
668
+ )
669
+
670
+ def estimate_stress(
671
+ self,
672
+ n=0,
673
+ vx=0,
674
+ vy=0,
675
+ mxx=0,
676
+ myy=0,
677
+ mzz=0,
678
+ value=False,
679
+ calc_margin=2,
680
+ min_est_records=100,
681
+ calc_every=25,
682
+ pre_train_margin=2,
683
+ force_calc=False,
684
+ ) -> float:
685
+ """uses a support vector machine to estimate stresses and returns the ratio of the allowable stress, also known as the failure fracion if prediction is set to True, otherwise calculates stress"""
686
+
687
+ Nrec = 0
688
+ under_size = True
689
+ if self.prediction:
690
+ pr = self.prediction_records
691
+ Nrec = len(pr) if pr else 0
692
+ under_size = Nrec <= min_est_records
693
+
694
+ do_calc = not self.prediction or not self._fitted or under_size
695
+ if do_calc or force_calc:
696
+ if self._do_print and self.prediction:
697
+ print(f"calc till {len(self.prediction_records)} <= {min_est_records}")
698
+ stress = calculate_stress(
699
+ self, n=n, vx=vx, vy=vy, mxx=mxx, myy=myy, mzz=mzz, value=value
700
+ )
701
+ return stress
702
+ else:
703
+ parms = self._prediction_parms
704
+ data = dict(n=n, vx=vx, vy=vy, mxx=mxx, myy=myy, mzz=mzz)
705
+
706
+ # Format data for prediction
707
+ if self._symmetric and self._use_symmetric:
708
+ inp = {k: abs(data[k] / v) for k, v in zip(parms, self._basis)}
709
+ else:
710
+ inp = {k: data[k] / v for k, v in zip(parms, self._basis)}
711
+ X = pandas.DataFrame([inp])
712
+ val = self._prediction_models["fail_frac"]["mod"].predict(X)[0]
713
+
714
+ # Provide margin of error and use estimate where possible
715
+ if not self.trained:
716
+ # calc when in doubt
717
+ calc_margin = calc_margin * pre_train_margin
718
+
719
+ # calculate stress if close to failure within saftey margin
720
+ err = 1 - val
721
+ mrg = self.fail_frac_criteria(calc_margin=calc_margin)
722
+ do_calc = abs(err) <= mrg or all([calc_every, (Nrec % calc_every) == 0])
723
+ oob = val <= self.max_margin and self.check_out_of_domain(data, 0.1)
724
+ if self._do_print:
725
+ self.info(
726
+ f'{"calc" if do_calc or oob else "est"} stress {abs(err):5.3f}>{mrg*calc_margin:5.3f}| {calc_margin} | oob {oob}'
727
+ )
728
+
729
+ if do_calc:
730
+ return self.calculate_stress(
731
+ n=n, vx=vx, vy=vy, mxx=mxx, myy=myy, mzz=mzz, value=value
732
+ )
733
+
734
+ elif oob:
735
+ # if self._do_print:
736
+ # self.info(f'out of domain {val:5.3f}<={self.max_margin:5.3f}')
737
+ return self.calculate_stress(
738
+ n=n, vx=vx, vy=vy, mxx=mxx, myy=myy, mzz=mzz, value=value
739
+ )
740
+
741
+ # otherwise prediction is value
742
+ if value:
743
+ return val * self.material.allowable_stress
744
+
745
+ return val
746
+
747
+ def fail_frac_criteria(self, calc_margin=2, min_rec=1000):
748
+ MargRec = max(min_rec / len(self._prediction_records), 1)
749
+
750
+ if self._training_history:
751
+ score = self._training_history[-1]["fail_frac"]["scr"]
752
+ else:
753
+ score = self._prediction_models["fail_frac"]["train_score"]
754
+
755
+ mrg = max(abs((1 - score)), self.prediction_goal_error)
756
+ return mrg * calc_margin * MargRec
757
+
758
+ def estimate_failure(self, n=0, vx=0, vy=0, mxx=0, myy=0, mzz=0) -> float:
759
+ """uses a support vector machine to estimate stresses and returns a number zero or one to indicate failure, if prediction is set to True, otherwise calculates stress"""
760
+ if not self.prediction or not self._fitted:
761
+ ff = calculate_stress(
762
+ self,
763
+ n=n,
764
+ vx=vx,
765
+ vy=vy,
766
+ mxx=mxx,
767
+ myy=myy,
768
+ mzz=mzz,
769
+ row=True,
770
+ value=False,
771
+ )
772
+ return ff["fails"]
773
+ else:
774
+ # TODO: add logic to determine if calculation should be done close to failure
775
+
776
+ parms = self._prediction_parms
777
+ data = dict(n=n, vx=vx, vy=vy, mxx=mxx, myy=myy, mzz=mzz)
778
+ if self._symmetric and self._use_symmetric:
779
+ inp = {k: abs(data[k] / v) for k, v in zip(parms, self._basis)}
780
+ else:
781
+ inp = {k: data[k] / v for k, v in zip(parms, self._basis)}
782
+ X = pandas.DataFrame([inp])
783
+ return self._prediction_models["fails"]["mod"].predict(X)[0]
784
+
785
+ def record_stress(self, stress_dict):
786
+ """determines if stress record should be added to prediction_records"""
787
+ if not self.prediction:
788
+ return
789
+ if len(self.prediction_records) > self.max_records:
790
+ return
791
+ # Add the data to the stress records
792
+ # TODO: add logic to determine if stress record should be added to prediction_records
793
+ ff = stress_dict["fail_frac"]
794
+ if ff == 0:
795
+ # null included
796
+ return
797
+
798
+ # Convert to positive domain
799
+ if self._symmetric and self._use_symmetric:
800
+ stress_dict = {k: abs(v) for k, v in stress_dict.items()}
801
+
802
+ if not getattr(self, "N_base", None):
803
+ max_margin = None
804
+ else:
805
+ max_margin = self.max_margin
806
+
807
+ if self.near_margin and abs(ff - 1) < self.near_margin:
808
+ # near failure always add to map resolution
809
+ self.add_prediction_record(stress_dict)
810
+ elif max_margin and ff < max_margin * 2:
811
+ # under max marging
812
+ self.add_prediction_record(stress_dict, False, 0.1)
813
+ elif not self.near_margin and not max_margin:
814
+ # no checks
815
+ self.add_prediction_record(stress_dict, False)
816
+
817
+ def determine_failure_stress(self, stress_obj):
818
+ """uses the failure mode to compare to allowable stress"""
819
+ if self.failure_mode == "von_mises":
820
+ return stress_obj["sig_vm"].max()
821
+ elif self.failure_mode == "max_norm":
822
+ return np.nan # TODO: make this work
823
+ elif self.failure_mode == "maximum_strain":
824
+ return np.nan # TODO: make this work
825
+ else:
826
+ raise ValueError(f"invalid failure mode: {self.failure_mode}")
827
+
828
+ def fail_learning(self, X, parm, base_kw, mult=1):
829
+ """optimizes stress until failure, given base_kw arguments for extra parameters"""
830
+ base_kw = base_kw.copy()
831
+ inpt = {parm: mult * X}
832
+ base_kw.update(inpt)
833
+
834
+ base_kw["value"] = False # override value for calcstress to fail frac
835
+ ff = self.calculate_stress(**base_kw).item()
836
+ return 1 - ff
837
+
838
+ def solve_fail(self, fail_parm, base_kw, guess=None, tol=1e-4, mult=1):
839
+ if guess is None:
840
+ guess = 1000 * random.random()
841
+ kw = {}
842
+ # if self._basis is not None:
843
+ # fpinx = self._prediction_parms.index(fail_parm)
844
+ # bracket = (0,self._basis[fpinx]*1.25)
845
+ # kw['bracket'] = bracket
846
+
847
+ ans = skopt.root_scalar(
848
+ self.fail_learning,
849
+ x0=guess * 0.1,
850
+ x1=guess,
851
+ xtol=tol,
852
+ args=(fail_parm, base_kw, mult),
853
+ ) # ,**kw)
854
+ if self._do_print:
855
+ self.info(
856
+ f"{mult}x{fail_parm:<6}| success: {ans.converged} , ans:{ans.root}, base: {base_kw}"
857
+ )
858
+ else:
859
+ self.debug(
860
+ f"{mult}x{fail_parm:<6}| success: {ans.converged} , ans:{ans.root}, base: {base_kw}"
861
+ )
862
+ if ans.converged:
863
+ return ans.root
864
+ return 1e6
865
+
866
+ # Determine Outer Bound Of Failures
867
+ def determine_failure_front(self, pareto_inx=[0.5, 0.1], pareto_front=False):
868
+ self.info(
869
+ f"determining failure front for cross section, with pareto inx: {pareto_inx}"
870
+ )
871
+ null_kw = {}
872
+ if self._symmetric:
873
+ mvec = [1]
874
+ else:
875
+ mvec = [-1, 1]
876
+
877
+ if self._basis is None:
878
+ self.info(f"determining normalization basis")
879
+ res = {}
880
+ for mult in mvec:
881
+ res[mult] = {}
882
+ for p in self._prediction_parms:
883
+ res[mult][p] = self.solve_fail(p, null_kw, mult)
884
+
885
+ self._basis = np.array(
886
+ [
887
+ max([abs(v.get(p, 1e6)) for k, v in res.items()])
888
+ for p in self._prediction_parms
889
+ ]
890
+ )
891
+
892
+ self.N_base = len(self.prediction_records)
893
+
894
+ # Second Pareto Value Calc
895
+ if pareto_front:
896
+ for mult in mvec:
897
+ for aux_frac in pareto_inx:
898
+ # TODO: expand parato combos past 2
899
+ for fail_parm, aux_parm in itertools.combinations(
900
+ self._prediction_parms, 2
901
+ ):
902
+ ainx = self._prediction_parms.index(aux_parm)
903
+ aux_val = self._basis[ainx] * aux_frac
904
+ base_kw = {aux_parm: aux_val}
905
+ finx = self._prediction_parms.index(fail_parm)
906
+ guesstimate = self._basis[finx] * (1 - aux_frac)
907
+ self.solve_fail(
908
+ fail_parm, base_kw, guess=guesstimate, mult=mult
909
+ )
910
+ if not self._symmetric:
911
+ self.solve_fail(
912
+ fail_parm,
913
+ base_kw,
914
+ guess=guesstimate,
915
+ mult=-1 * mult,
916
+ ) # alternato
917
+
918
+ self.N_pareto = len(self.prediction_records)
919
+
920
+ def basis_expand(
921
+ self,
922
+ expand_values=[0.9, 0.75, 0.5, 0.1, 0.01],
923
+ Nparm=4,
924
+ est=True,
925
+ normalize=True,
926
+ ):
927
+ """run combinations of parameters and permutations of weights against the basis values to populate the stress records, by default using estimation logic to speed up the process"""
928
+ i = 0
929
+ Nparm = min(min(Nparm, len(self._prediction_parms)), len(expand_values))
930
+ for parms in itertools.combinations(self._prediction_parms, Nparm):
931
+ for weight in itertools.permutations(expand_values, Nparm):
932
+ wt = sum(weight)
933
+ q = max(wt, 1) if normalize else 1
934
+ inxs = [self._prediction_parms.index(p) for p in parms]
935
+ base_kw = {
936
+ p: w * self._basis[i] / q for p, w, i in zip(parms, weight, inxs)
937
+ }
938
+
939
+ # print(base_kw)
940
+ if est:
941
+ self.estimate_stress(**base_kw)
942
+ else:
943
+ self.calculate_stress(**base_kw)
944
+ i += 1
945
+ if i % 100 == 0:
946
+ self.info(f"basis expansion... {i}")
947
+
948
+ def random_force_input(self, exp_mg=12, exp_min=6, min_ex=1, max_ex=3):
949
+ porp = np.random.random(size=6) ** np.random.randint(
950
+ numpy.random.randint(min_ex, max_ex),
951
+ numpy.random.randint(exp_min, exp_mg),
952
+ size=(6,),
953
+ )
954
+ stress = porp * self._basis
955
+ return {k: v for k, v in zip(self._prediction_parms, stress)}
956
+
957
+ def train_until_valid(self, print_interval=50, max_iter=1000, est=False):
958
+ """trains the prediction models until the error is below the goal error"""
959
+ i = 0
960
+ goal = self.prediction_goal_error
961
+ while not self.trained:
962
+ inp = self.random_force_input()
963
+ if est:
964
+ self.estimate_stress(**inp)
965
+ else:
966
+ self.calculate_stress(**inp)
967
+
968
+ if i % print_interval == 0:
969
+ self.info(f"training... current error: {self._training_history[-1]}")
970
+ i += 1
971
+ if i >= max_iter:
972
+ self.info(f"training... max iterations reached")
973
+ return
974
+
975
+ # Geometric Prediction Solutions
976
+ def check_symmetric(self, precision=3, Nincr=180):
977
+ """checks if the section is symmetric about the x and y axis, by finding the intersection of"""
978
+ if isinstance(self.shape, Geometry):
979
+ shape = self.shape.geom
980
+ else:
981
+ shape = self.shape
982
+ x, y = (np.array(v) for v in shape.exterior.coords.xy)
983
+ xc = shape.centroid.x
984
+ yc = shape.centroid.y
985
+ dx = x - xc
986
+ dy = y - yc
987
+
988
+ dth = np.arctan2(dx, dy)
989
+ rth = (dx**2 + dy**2) ** 0.5
990
+ inx = np.cumsum(np.ones(dth.size)) - 1
991
+
992
+ Nincr = 1000
993
+ Imax = inx.max()
994
+ inx2 = np.linspace(0, Imax, Nincr)
995
+ oppo = lambda i: (i + Imax / 2) % Imax
996
+
997
+ R = np.interp(inx2, inx, rth)
998
+ T = np.interp(inx2, inx, dth)
999
+ X = np.cos(T) * R
1000
+ Y = np.sin(T) * R
1001
+
1002
+ # finds set of radius at a given angle
1003
+ def find_radii(targetth):
1004
+ """targetth must be positive from 0->pi"""
1005
+ r = dth - targetth
1006
+ # print(r)
1007
+ if np.all(r < 0):
1008
+ r = r + np.pi
1009
+ elif np.all(r > 0):
1010
+ r = r - np.pi
1011
+ sgn = np.concatenate([r[1:] * r[:-1], [r[0] * r[-1]]])
1012
+ possibles = np.where(sgn < 0)[0]
1013
+ # print(f'\n{targetth}')
1014
+ out = set()
1015
+ for poss in possibles:
1016
+ poss2 = int((poss + 1) % Imax)
1017
+ x_ = np.array([r[poss], r[poss2]])
1018
+ y_ = np.array([inx[poss], inx[poss2]])
1019
+ itarget = (0 - x_[0]) * (y_[1] - y_[0]) / (x_[1] - x_[0]) + y_[0]
1020
+ r_ = np.interp(itarget, inx2, R)
1021
+ out.add(round(r_, precision))
1022
+ # print(itarget,r_)
1023
+ return out
1024
+
1025
+ # check symmetry from 0-180 deg
1026
+ syms = []
1027
+ for targetth in np.linspace(0, np.pi, Nincr):
1028
+ o1 = find_radii(targetth)
1029
+ o2 = find_radii(targetth - np.pi)
1030
+ sym_point = len(o1.intersection(o2)) >= 1
1031
+ syms.append(sym_point)
1032
+
1033
+ return np.all(syms)
1034
+
1035
+ def __hash__(self):
1036
+ """uniqueness based on geometry and material"""
1037
+ # print('hash shape...')
1038
+ if isinstance(self.shape, Geometry):
1039
+ shape = self.shape.geom
1040
+ else:
1041
+ shape = self.shape
1042
+ vals = (
1043
+ shape.wkb_hex,
1044
+ str(hash(self.material)),
1045
+ str(self.goal_elements),
1046
+ str(self.min_mesh_angle),
1047
+ )
1048
+ h = hashlib.md5()
1049
+ for hv in vals:
1050
+ h.update(hv.encode())
1051
+ return int(h.hexdigest(), 16)
1052
+
1053
+ def hash_id(self) -> str:
1054
+ """string for saving to persisting"""
1055
+ return str(hash(self))
1056
+
1057
+
1058
+ ALL_CROSSSECTIONS = [
1059
+ cs for cs in locals() if type(cs) is type and issubclass(cs, Profile2D)
1060
+ ]