mcft 0.1.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.
mcft/__init__.py ADDED
File without changes
mcft/io.py ADDED
@@ -0,0 +1,583 @@
1
+ """XML (.rsp) parser for Response-2000 files.
2
+
3
+ Reads the suite_3g XML format produced by Response-2000 v1.9.6+.
4
+ Values are stored exactly as read — no unit conversion is performed.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import re
11
+ import shlex
12
+ import xml.etree.ElementTree as ET
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # Data model
20
+ # ---------------------------------------------------------------------------
21
+
22
+
23
+ @dataclass
24
+ class Concrete:
25
+ name: str
26
+ fcp: float
27
+ maxagg: float = 20.0
28
+ c_mod: int = 2
29
+ c_soft: int = 1 # 0=None, 1=Collins-Bentz (default), 2=VC1982
30
+ tsfactor: float = 1.0
31
+ ft: float | None = None
32
+ e0: float | None = None
33
+ Ec: float | None = None
34
+ curve: list[tuple[float, float]] | None = None
35
+
36
+
37
+ @dataclass
38
+ class Rebar:
39
+ name: str
40
+ fy: float
41
+ E: float = 200_000.0
42
+ fu: float | None = None
43
+ esh: float = 10.0
44
+ eu: float = 100.0
45
+
46
+
47
+ @dataclass
48
+ class Prestress:
49
+ name: str
50
+ fu: float
51
+ E: float = 190_000.0
52
+ A: float = 0.030
53
+ B: float = 121.0
54
+ C: float = 6.0
55
+ eu: float = 40.0
56
+
57
+
58
+ @dataclass
59
+ class SectionPoint:
60
+ depth: float
61
+ width: float
62
+ concrete_name: str | None = None
63
+
64
+
65
+ @dataclass
66
+ class LongReinf:
67
+ z: float
68
+ type: str
69
+ A: float
70
+ name: str = ""
71
+ num: int = 1
72
+ Ai: float | None = None
73
+ db: float | None = None
74
+ space: float = 0.0
75
+ dep: float = 0.0
76
+ drape: float = 0.0
77
+ bartitle: str = ""
78
+
79
+
80
+ @dataclass
81
+ class TransReinf:
82
+ A: float
83
+ type: str
84
+ pattern: int
85
+ space: float
86
+ disttop: float = 0.0
87
+ distbot: float = 0.0
88
+ name: str = ""
89
+ Ai: float | None = None
90
+ db: float | None = None
91
+ dep: float = 0.0
92
+ bartitle: str = ""
93
+
94
+
95
+ @dataclass
96
+ class ElemInfo:
97
+ L: float
98
+ mido2: float = 0.0
99
+ lplate: float = 0.0
100
+ rplate: float = 0.0
101
+
102
+
103
+ @dataclass
104
+ class SectLoading:
105
+ MM: float = 0.0
106
+ MV: float = 0.0
107
+ BN: float = 0.0
108
+ BM: float = 0.0
109
+
110
+ @property
111
+ def av(self) -> float:
112
+ """Shear span M/V in analysis length units (mm).
113
+
114
+ Derived from the load-path ratios MM and MV.
115
+ In .rsp files MM is in kN·m and MV in kN, so MM/MV
116
+ is in m; the factor of 1000 converts to mm.
117
+ Returns inf for pure bending (MV == 0).
118
+ """
119
+ if abs(self.MV) < 1e-15:
120
+ return float("inf")
121
+ return self.MM / self.MV * 1000.0
122
+
123
+
124
+ @dataclass
125
+ class ShrinkTherm:
126
+ profile: list[tuple[float, float]] = field(default_factory=list)
127
+
128
+
129
+ @dataclass
130
+ class BeamSection:
131
+ name: str
132
+ doneby: str = ""
133
+ date: str = ""
134
+ concretes: dict[str, Concrete] = field(default_factory=dict)
135
+ rebars: dict[str, Rebar] = field(default_factory=dict)
136
+ prestress: dict[str, Prestress] = field(default_factory=dict)
137
+ section_points: list[SectionPoint] = field(default_factory=list)
138
+ long_reinf: list[LongReinf] = field(default_factory=list)
139
+ trans_reinf: list[TransReinf] = field(default_factory=list)
140
+ elem_info: ElemInfo | None = None
141
+ sect_loading: SectLoading | None = None
142
+ shrink_therm: ShrinkTherm | None = None
143
+
144
+
145
+ @dataclass
146
+ class BMDPoint:
147
+ position: float
148
+ moment: float
149
+ flag: int
150
+
151
+
152
+ @dataclass
153
+ class BMD:
154
+ points: list[BMDPoint] = field(default_factory=list)
155
+ isconstant: bool = False
156
+ type: str = ""
157
+ axial_load: float = 0.0
158
+
159
+
160
+ @dataclass
161
+ class MatZone:
162
+ position: float
163
+ section_name: str
164
+
165
+
166
+ @dataclass
167
+ class Support:
168
+ which: str
169
+ platelocate: float
170
+ platesize: float = 0.0
171
+ support: str = ""
172
+
173
+
174
+ @dataclass
175
+ class MemberAnalysis:
176
+ length: float
177
+ bmds: list[BMD] = field(default_factory=list)
178
+ mat_zones: list[MatZone] = field(default_factory=list)
179
+ supports: list[Support] = field(default_factory=list)
180
+ topplatesize: float = 0.0
181
+ displocate: float = 0.0
182
+ leftanchor: str = ""
183
+ rightanchor: str = ""
184
+
185
+
186
+ @dataclass
187
+ class R2KModel:
188
+ sections: list[BeamSection] = field(default_factory=list)
189
+ member_analysis: MemberAnalysis | None = None
190
+
191
+
192
+ # ---------------------------------------------------------------------------
193
+ # XML preprocessing
194
+ # ---------------------------------------------------------------------------
195
+
196
+ _DOCTYPE_RE = re.compile(r"<!DOCTYPE[^>]*>")
197
+ _TAG_RE = re.compile(r"<[^!?/][^>]*>", re.DOTALL)
198
+ _ATTR_RE = re.compile(r"""(\w+)\s*=\s*"([^"]*)"\s*""")
199
+
200
+
201
+ def _strip_doctype(xml_text: str) -> str:
202
+ return _DOCTYPE_RE.sub("", xml_text)
203
+
204
+
205
+ def _dedup_attrs(xml_text: str) -> str:
206
+ """Remove duplicate attributes within each XML tag, keeping last occurrence."""
207
+
208
+ def _dedup_tag(match: re.Match) -> str:
209
+ tag = match.group(0)
210
+ attrs = _ATTR_RE.findall(tag)
211
+ if not attrs:
212
+ return tag
213
+ seen: dict[str, str] = {}
214
+ for name, value in attrs:
215
+ if name in seen:
216
+ logger.debug("Duplicate attribute %r in tag, keeping last value", name)
217
+ seen[name] = value
218
+ if len(seen) == len(attrs):
219
+ return tag
220
+ # Rebuild: take tag name portion, then unique attrs
221
+ # Find where the tag name ends
222
+ stripped = tag.lstrip("<")
223
+ tag_name = stripped.split()[0].rstrip(">").rstrip("/")
224
+ is_self_closing = tag.rstrip().endswith("/>")
225
+ rebuilt = f"<{tag_name}"
226
+ for aname, aval in seen.items():
227
+ rebuilt += f' {aname}="{aval}"'
228
+ rebuilt += " />" if is_self_closing else ">"
229
+ return rebuilt
230
+
231
+ return _TAG_RE.sub(_dedup_tag, xml_text)
232
+
233
+
234
+ def _preprocess(xml_text: str) -> str:
235
+ return _dedup_attrs(_strip_doctype(xml_text))
236
+
237
+
238
+ # ---------------------------------------------------------------------------
239
+ # Text content parsers
240
+ # ---------------------------------------------------------------------------
241
+
242
+
243
+ def _parse_text_lines(text: str | None) -> list[list[str]]:
244
+ """Split element text into tokenised lines using shlex for quoted strings."""
245
+ if not text:
246
+ return []
247
+ lines: list[list[str]] = []
248
+ for raw in text.strip().splitlines():
249
+ raw = raw.strip()
250
+ if not raw:
251
+ continue
252
+ try:
253
+ tokens = shlex.split(raw)
254
+ except ValueError:
255
+ tokens = raw.split()
256
+ lines.append(tokens)
257
+ return lines
258
+
259
+
260
+ def _parse_section_points(text: str | None) -> list[SectionPoint]:
261
+ points: list[SectionPoint] = []
262
+ for tokens in _parse_text_lines(text):
263
+ if len(tokens) < 2:
264
+ continue
265
+ depth = float(tokens[0])
266
+ width = float(tokens[1])
267
+ concrete_name = tokens[2] if len(tokens) >= 3 else None
268
+ points.append(SectionPoint(depth=depth, width=width, concrete_name=concrete_name))
269
+ return points
270
+
271
+
272
+ def _parse_curve(text: str | None) -> list[tuple[float, float]] | None:
273
+ lines = _parse_text_lines(text)
274
+ if not lines:
275
+ return None
276
+ curve: list[tuple[float, float]] = []
277
+ for tokens in lines:
278
+ if len(tokens) >= 2:
279
+ curve.append((float(tokens[0]), float(tokens[1])))
280
+ return curve or None
281
+
282
+
283
+ def _parse_bmd_points(text: str | None) -> list[BMDPoint]:
284
+ points: list[BMDPoint] = []
285
+ for tokens in _parse_text_lines(text):
286
+ if len(tokens) >= 3:
287
+ points.append(
288
+ BMDPoint(
289
+ position=float(tokens[0]),
290
+ moment=float(tokens[1]),
291
+ flag=int(tokens[2]),
292
+ )
293
+ )
294
+ return points
295
+
296
+
297
+ def _parse_mat_zones(text: str | None) -> list[MatZone]:
298
+ zones: list[MatZone] = []
299
+ for tokens in _parse_text_lines(text):
300
+ if len(tokens) >= 2:
301
+ zones.append(MatZone(position=float(tokens[0]), section_name=tokens[1]))
302
+ return zones
303
+
304
+
305
+ def _parse_shrink_therm(text: str | None) -> list[tuple[float, float]]:
306
+ profile: list[tuple[float, float]] = []
307
+ for tokens in _parse_text_lines(text):
308
+ if len(tokens) >= 2:
309
+ profile.append((float(tokens[0]), float(tokens[1])))
310
+ return profile
311
+
312
+
313
+ # ---------------------------------------------------------------------------
314
+ # Attribute helpers
315
+ # ---------------------------------------------------------------------------
316
+
317
+
318
+ def _float(el: ET.Element, attr: str, default: float | None = None) -> float | None:
319
+ val = el.get(attr)
320
+ if val is None:
321
+ return default
322
+ return float(val)
323
+
324
+
325
+ def _float_req(el: ET.Element, attr: str) -> float:
326
+ val = el.get(attr)
327
+ if val is None:
328
+ raise ValueError(f"Required attribute {attr!r} missing on <{el.tag}>")
329
+ return float(val)
330
+
331
+
332
+ def _int(el: ET.Element, attr: str, default: int = 0) -> int:
333
+ val = el.get(attr)
334
+ if val is None:
335
+ return default
336
+ return int(val)
337
+
338
+
339
+ def _str(el: ET.Element, attr: str, default: str = "") -> str:
340
+ val = el.get(attr)
341
+ return val if val is not None else default
342
+
343
+
344
+ # ---------------------------------------------------------------------------
345
+ # Element parsers
346
+ # ---------------------------------------------------------------------------
347
+
348
+ _concrete_counter: int = 0
349
+
350
+
351
+ def _map_c_soft(r2k_code: int) -> int:
352
+ """Map R2K c_soft code (1-based) to internal code.
353
+
354
+ Internal: 0=None (β=1), 1=Collins-Bentz (default), 2=Vecchio-Collins 1982.
355
+ R2K: 1=None, 2=VC1982, 18=Collins-Bentz 2011 (default). Others → Collins-Bentz.
356
+ """
357
+ if r2k_code == 1:
358
+ return 0 # None
359
+ if r2k_code == 2:
360
+ return 2 # Vecchio-Collins 1982
361
+ return 1 # Collins-Bentz for everything else (including default 18)
362
+
363
+
364
+ def _parse_concrete(el: ET.Element) -> Concrete:
365
+ global _concrete_counter
366
+ _concrete_counter += 1
367
+ name = _str(el, "name", f"Concrete {_concrete_counter}")
368
+ c_mod_r2k = _int(el, "c_mod", 2)
369
+ # R2K codes: 1=Linear, 2=Parabolic(Popovics), 3=Popovics/Thorenfeldt, 4=Elasto-Plastic, 5=User-Defined
370
+ # Internal codes (materials.py): 0=Linear, 1=Parabolic, 2=Popovics
371
+ # Empirically: R2K default c_mod=2 ("Parabolic") matches our Popovics (2) — no offset for code 2+.
372
+ # Only special case: R2K c_mod=1 (Linear) must map to our 0 (Linear).
373
+ c_mod = 0 if c_mod_r2k == 1 else c_mod_r2k
374
+ curve = _parse_curve(el.text) if c_mod_r2k == 4 else None # c_mod=4 = User-Defined in R2K
375
+ return Concrete(
376
+ name=name,
377
+ fcp=_float_req(el, "fcp"),
378
+ maxagg=_float(el, "maxagg", 20.0), # type: ignore[arg-type]
379
+ c_mod=c_mod,
380
+ c_soft=_map_c_soft(_int(el, "c_soft", 18)),
381
+ tsfactor=_float(el, "tsfactor", 1.0), # type: ignore[arg-type]
382
+ ft=_float(el, "ft"),
383
+ e0=_float(el, "e0"),
384
+ Ec=_float(el, "E"),
385
+ curve=curve,
386
+ )
387
+
388
+
389
+ def _parse_rebar(el: ET.Element) -> Rebar:
390
+ return Rebar(
391
+ name=_str(el, "name", "Steel"),
392
+ fy=_float_req(el, "fy"),
393
+ E=_float(el, "E", 200_000.0), # type: ignore[arg-type]
394
+ fu=_float(el, "fu"),
395
+ esh=_float(el, "esh", 10.0), # type: ignore[arg-type]
396
+ eu=_float(el, "eu", 100.0), # type: ignore[arg-type]
397
+ )
398
+
399
+
400
+ def _parse_prestress(el: ET.Element) -> Prestress:
401
+ return Prestress(
402
+ name=_str(el, "name", "P-Steel"),
403
+ fu=_float_req(el, "fu"),
404
+ E=_float(el, "E", 190_000.0), # type: ignore[arg-type]
405
+ A=_float(el, "A", 0.030), # type: ignore[arg-type]
406
+ B=_float(el, "B", 121.0), # type: ignore[arg-type]
407
+ C=_float(el, "C", 6.0), # type: ignore[arg-type]
408
+ eu=_float(el, "eu", 40.0), # type: ignore[arg-type]
409
+ )
410
+
411
+
412
+ def _parse_long_reinf(el: ET.Element) -> LongReinf:
413
+ return LongReinf(
414
+ z=_float_req(el, "z"),
415
+ type=_str(el, "type", "Steel"),
416
+ A=_float_req(el, "A"),
417
+ name=_str(el, "name"),
418
+ num=_int(el, "num", 1),
419
+ Ai=_float(el, "Ai"),
420
+ db=_float(el, "db"),
421
+ space=_float(el, "space", 0.0), # type: ignore[arg-type]
422
+ dep=_float(el, "dep", 0.0), # type: ignore[arg-type]
423
+ drape=_float(el, "drape", 0.0), # type: ignore[arg-type]
424
+ bartitle=_str(el, "bartitle"),
425
+ )
426
+
427
+
428
+ def _parse_trans_reinf(el: ET.Element) -> TransReinf:
429
+ pattern = _int(el, "pattern", 0)
430
+ if pattern == 7:
431
+ logger.warning(
432
+ "Unknown transverse reinforcement pattern=7 on %r, treating as pattern=1 (open stirrup)",
433
+ _str(el, "name", "<unnamed>"),
434
+ )
435
+ return TransReinf(
436
+ A=_float_req(el, "A"),
437
+ type=_str(el, "type", "Steel"),
438
+ pattern=pattern,
439
+ space=_float_req(el, "space"),
440
+ disttop=_float(el, "disttop", 0.0), # type: ignore[arg-type]
441
+ distbot=_float(el, "distbot", 0.0), # type: ignore[arg-type]
442
+ name=_str(el, "name"),
443
+ Ai=_float(el, "Ai"),
444
+ db=_float(el, "db"),
445
+ dep=_float(el, "dep", 0.0), # type: ignore[arg-type]
446
+ bartitle=_str(el, "bartitle"),
447
+ )
448
+
449
+
450
+ def _parse_elem_info(el: ET.Element) -> ElemInfo:
451
+ return ElemInfo(
452
+ L=_float_req(el, "L"),
453
+ mido2=_float(el, "mido2", 0.0), # type: ignore[arg-type]
454
+ lplate=_float(el, "lplate", 0.0), # type: ignore[arg-type]
455
+ rplate=_float(el, "rplate", 0.0), # type: ignore[arg-type]
456
+ )
457
+
458
+
459
+ def _parse_sect_loading(el: ET.Element) -> SectLoading:
460
+ return SectLoading(
461
+ MM=_float(el, "MM", 0.0), # type: ignore[arg-type]
462
+ MV=_float(el, "MV", 0.0), # type: ignore[arg-type]
463
+ BN=_float(el, "BN", 0.0), # type: ignore[arg-type]
464
+ BM=_float(el, "BM", 0.0), # type: ignore[arg-type]
465
+ )
466
+
467
+
468
+ # ---------------------------------------------------------------------------
469
+ # Top-level parsers
470
+ # ---------------------------------------------------------------------------
471
+
472
+
473
+ def _parse_beam_section(el: ET.Element) -> BeamSection:
474
+ global _concrete_counter
475
+ _concrete_counter = 0
476
+
477
+ section = BeamSection(
478
+ name=_str(el, "name", "Unnamed"),
479
+ doneby=_str(el, "doneby"),
480
+ date=_str(el, "date"),
481
+ )
482
+
483
+ for child in el:
484
+ tag = child.tag
485
+ if tag == "concrete":
486
+ c = _parse_concrete(child)
487
+ section.concretes[c.name] = c
488
+ elif tag == "rebar":
489
+ r = _parse_rebar(child)
490
+ section.rebars[r.name] = r
491
+ elif tag == "prestress":
492
+ p = _parse_prestress(child)
493
+ section.prestress[p.name] = p
494
+ elif tag == "sectionSOLID":
495
+ section.section_points = _parse_section_points(child.text)
496
+ elif tag == "shapesection":
497
+ pass # display metadata only
498
+ elif tag == "longreinfx":
499
+ section.long_reinf.append(_parse_long_reinf(child))
500
+ elif tag == "transreinfz":
501
+ section.trans_reinf.append(_parse_trans_reinf(child))
502
+ elif tag == "eleminfo":
503
+ section.elem_info = _parse_elem_info(child)
504
+ elif tag == "sectloading":
505
+ section.sect_loading = _parse_sect_loading(child)
506
+ elif tag == "shrinktherm":
507
+ section.shrink_therm = ShrinkTherm(profile=_parse_shrink_therm(child.text))
508
+ else:
509
+ logger.debug("Ignoring unknown element <%s> in r3g_beam %r", tag, section.name)
510
+
511
+ return section
512
+
513
+
514
+ def _parse_support(el: ET.Element) -> Support:
515
+ return Support(
516
+ which=_str(el, "which", ""),
517
+ platelocate=_float(el, "platelocate", 0.0), # type: ignore[arg-type]
518
+ platesize=_float(el, "platesize", 0.0), # type: ignore[arg-type]
519
+ support=_str(el, "support"),
520
+ )
521
+
522
+
523
+ def _parse_bmd(el: ET.Element) -> BMD:
524
+ return BMD(
525
+ points=_parse_bmd_points(el.text),
526
+ isconstant=_str(el, "isconstant") == "1",
527
+ type=_str(el, "type"),
528
+ axial_load=_float(el, "AxialLoad", 0.0), # type: ignore[arg-type]
529
+ )
530
+
531
+
532
+ def _parse_member_analysis(el: ET.Element) -> MemberAnalysis:
533
+ ma = MemberAnalysis(
534
+ length=_float_req(el, "length"),
535
+ topplatesize=_float(el, "topplatesize", 0.0), # type: ignore[arg-type]
536
+ displocate=_float(el, "displocate", 0.0), # type: ignore[arg-type]
537
+ leftanchor=_str(el, "leftanchor"),
538
+ rightanchor=_str(el, "rightanchor"),
539
+ )
540
+
541
+ for child in el:
542
+ tag = child.tag
543
+ if tag == "BMD":
544
+ ma.bmds.append(_parse_bmd(child))
545
+ elif tag == "mattypes":
546
+ ma.mat_zones = _parse_mat_zones(child.text)
547
+ elif tag == "supports":
548
+ ma.supports.append(_parse_support(child))
549
+ else:
550
+ logger.debug("Ignoring unknown element <%s> in R2010_Analysis", tag)
551
+
552
+ return ma
553
+
554
+
555
+ def _parse_document(root: ET.Element) -> R2KModel:
556
+ model = R2KModel()
557
+ for child in root:
558
+ if child.tag == "r3g_beam":
559
+ model.sections.append(_parse_beam_section(child))
560
+ elif child.tag == "R2010_Analysis":
561
+ model.member_analysis = _parse_member_analysis(child)
562
+ else:
563
+ logger.debug("Ignoring unknown top-level element <%s>", child.tag)
564
+ return model
565
+
566
+
567
+ # ---------------------------------------------------------------------------
568
+ # Public API
569
+ # ---------------------------------------------------------------------------
570
+
571
+
572
+ def read_rsp(path: str | Path) -> R2KModel:
573
+ """Parse an .rsp XML file and return the complete model."""
574
+ path = Path(path)
575
+ xml_text = path.read_text(encoding="utf-8")
576
+ return read_rsp_string(xml_text)
577
+
578
+
579
+ def read_rsp_string(xml_text: str) -> R2KModel:
580
+ """Parse an .rsp XML string and return the complete model."""
581
+ cleaned = _preprocess(xml_text)
582
+ root = ET.fromstring(cleaned)
583
+ return _parse_document(root)