qtmodel 2.4.2__tar.gz → 2.5.0__tar.gz

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 (56) hide show
  1. {qtmodel-2.4.2/src/qtmodel.egg-info → qtmodel-2.5.0}/PKG-INFO +29 -3
  2. qtmodel-2.4.2/PKG-INFO → qtmodel-2.5.0/README.md +27 -11
  3. {qtmodel-2.4.2 → qtmodel-2.5.0}/pyproject.toml +6 -2
  4. {qtmodel-2.4.2 → qtmodel-2.5.0}/src/qtmodel/__init__.py +9 -0
  5. {qtmodel-2.4.2 → qtmodel-2.5.0}/src/qtmodel/core/__init__.py +9 -0
  6. {qtmodel-2.4.2 → qtmodel-2.5.0}/src/qtmodel/core/qt_server.py +1 -1
  7. qtmodel-2.5.0/src/qtmodel/core/section_property_solver.py +539 -0
  8. {qtmodel-2.4.2 → qtmodel-2.5.0}/src/qtmodel/mdb/mdb_section.py +66 -0
  9. qtmodel-2.4.2/README.md → qtmodel-2.5.0/src/qtmodel.egg-info/PKG-INFO +36 -2
  10. {qtmodel-2.4.2 → qtmodel-2.5.0}/src/qtmodel.egg-info/SOURCES.txt +3 -0
  11. qtmodel-2.5.0/src/qtmodel.egg-info/requires.txt +2 -0
  12. qtmodel-2.5.0/tests/test_section_property_solver.py +145 -0
  13. {qtmodel-2.4.2 → qtmodel-2.5.0}/setup.cfg +0 -0
  14. {qtmodel-2.4.2 → qtmodel-2.5.0}/src/qtmodel/bridge.py +0 -0
  15. {qtmodel-2.4.2 → qtmodel-2.5.0}/src/qtmodel/cdb/__init__.py +0 -0
  16. {qtmodel-2.4.2 → qtmodel-2.5.0}/src/qtmodel/cdb/cdb_anti_overturn_check.py +0 -0
  17. {qtmodel-2.4.2 → qtmodel-2.5.0}/src/qtmodel/cdb/cdb_concrete_check.py +0 -0
  18. {qtmodel-2.4.2 → qtmodel-2.5.0}/src/qtmodel/cdb/cdb_steel_box_check.py +0 -0
  19. {qtmodel-2.4.2 → qtmodel-2.5.0}/src/qtmodel/cdb/cdb_steel_truss_check.py +0 -0
  20. {qtmodel-2.4.2 → qtmodel-2.5.0}/src/qtmodel/core/concrete_check_db.py +0 -0
  21. {qtmodel-2.4.2 → qtmodel-2.5.0}/src/qtmodel/core/data_helper.py +0 -0
  22. {qtmodel-2.4.2 → qtmodel-2.5.0}/src/qtmodel/core/model_db.py +0 -0
  23. {qtmodel-2.4.2 → qtmodel-2.5.0}/src/qtmodel/core/result_db.py +0 -0
  24. {qtmodel-2.4.2 → qtmodel-2.5.0}/src/qtmodel/core/section_db.py +0 -0
  25. {qtmodel-2.4.2 → qtmodel-2.5.0}/src/qtmodel/core/section_geometry.py +0 -0
  26. {qtmodel-2.4.2 → qtmodel-2.5.0}/src/qtmodel/core/section_shape.py +0 -0
  27. {qtmodel-2.4.2 → qtmodel-2.5.0}/src/qtmodel/mdb/__init__.py +0 -0
  28. {qtmodel-2.4.2 → qtmodel-2.5.0}/src/qtmodel/mdb/mdb_analysis_setting.py +0 -0
  29. {qtmodel-2.4.2 → qtmodel-2.5.0}/src/qtmodel/mdb/mdb_assistant.py +0 -0
  30. {qtmodel-2.4.2 → qtmodel-2.5.0}/src/qtmodel/mdb/mdb_boundary.py +0 -0
  31. {qtmodel-2.4.2 → qtmodel-2.5.0}/src/qtmodel/mdb/mdb_construction_stage.py +0 -0
  32. {qtmodel-2.4.2 → qtmodel-2.5.0}/src/qtmodel/mdb/mdb_dynamic_load.py +0 -0
  33. {qtmodel-2.4.2 → qtmodel-2.5.0}/src/qtmodel/mdb/mdb_live_load.py +0 -0
  34. {qtmodel-2.4.2 → qtmodel-2.5.0}/src/qtmodel/mdb/mdb_load.py +0 -0
  35. {qtmodel-2.4.2 → qtmodel-2.5.0}/src/qtmodel/mdb/mdb_project.py +0 -0
  36. {qtmodel-2.4.2 → qtmodel-2.5.0}/src/qtmodel/mdb/mdb_property.py +0 -0
  37. {qtmodel-2.4.2 → qtmodel-2.5.0}/src/qtmodel/mdb/mdb_sink_load.py +0 -0
  38. {qtmodel-2.4.2 → qtmodel-2.5.0}/src/qtmodel/mdb/mdb_static_load.py +0 -0
  39. {qtmodel-2.4.2 → qtmodel-2.5.0}/src/qtmodel/mdb/mdb_structure.py +0 -0
  40. {qtmodel-2.4.2 → qtmodel-2.5.0}/src/qtmodel/mdb/mdb_temperature_load.py +0 -0
  41. {qtmodel-2.4.2 → qtmodel-2.5.0}/src/qtmodel/mdb/mdb_tendon.py +0 -0
  42. {qtmodel-2.4.2 → qtmodel-2.5.0}/src/qtmodel/odb/__init__.py +0 -0
  43. {qtmodel-2.4.2 → qtmodel-2.5.0}/src/qtmodel/odb/odb_model_boundary.py +0 -0
  44. {qtmodel-2.4.2 → qtmodel-2.5.0}/src/qtmodel/odb/odb_model_load.py +0 -0
  45. {qtmodel-2.4.2 → qtmodel-2.5.0}/src/qtmodel/odb/odb_model_material.py +0 -0
  46. {qtmodel-2.4.2 → qtmodel-2.5.0}/src/qtmodel/odb/odb_model_section.py +0 -0
  47. {qtmodel-2.4.2 → qtmodel-2.5.0}/src/qtmodel/odb/odb_model_stage.py +0 -0
  48. {qtmodel-2.4.2 → qtmodel-2.5.0}/src/qtmodel/odb/odb_model_structure.py +0 -0
  49. {qtmodel-2.4.2 → qtmodel-2.5.0}/src/qtmodel/odb/odb_result_data.py +0 -0
  50. {qtmodel-2.4.2 → qtmodel-2.5.0}/src/qtmodel/odb/odb_result_plot.py +0 -0
  51. {qtmodel-2.4.2 → qtmodel-2.5.0}/src/qtmodel/odb/odb_view.py +0 -0
  52. {qtmodel-2.4.2 → qtmodel-2.5.0}/src/qtmodel.egg-info/dependency_links.txt +0 -0
  53. {qtmodel-2.4.2 → qtmodel-2.5.0}/src/qtmodel.egg-info/top_level.txt +0 -0
  54. {qtmodel-2.4.2 → qtmodel-2.5.0}/tests/test_section_db.py +0 -0
  55. {qtmodel-2.4.2 → qtmodel-2.5.0}/tests/test_section_geometry.py +0 -0
  56. {qtmodel-2.4.2 → qtmodel-2.5.0}/tests/test_section_shape.py +0 -0
@@ -1,14 +1,40 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: qtmodel
3
- Version: 2.4.2
3
+ Version: 2.5.0
4
4
  Summary: qtmodel package
5
5
  Author: Your Name
6
6
  Requires-Python: >=3.9
7
7
  Description-Content-Type: text/markdown
8
+ Requires-Dist: requests>=2.28
9
+ Requires-Dist: psutil>=5.9
8
10
 
9
- > 最新qtmodel版本 2.4.2 - 2026-07-17
11
+ > 最新qtmodel版本 2.5.0 - 2026-07-18
10
12
  > pip install --upgrade qtmodel -i https://pypi.org/simple
11
- - 增加检算荷载API
13
+ - 增加纯 Python 本地截面特性求解:面积、形心、Iy/Iz/Iyz、内外周长、
14
+ 包围框、四个控制点、组合截面换算特性和线宽截面特性。
15
+
16
+ ## 本地截面特性计算
17
+
18
+ 本地求解不要求打开桥通,不发送 HTTP 请求。所有坐标和厚度单位为 m,
19
+ 面积单位为 m²,惯性矩单位为 m⁴。
20
+
21
+ ```Python
22
+ from qtmodel import mdb
23
+
24
+ result = mdb.get_parameter_section_property_local(
25
+ sec_type="矩形",
26
+ sec_info={"W": 2.0, "H": 1.0},
27
+ )
28
+ print(result.property.Ax, result.property.Iy, result.bounding_box.to_dict())
29
+
30
+ line_result = mdb.get_section_property_local(
31
+ sec_type="自定义线宽截面",
32
+ sec_lines=[[0, 0, 2, 0, 0.02]],
33
+ )
34
+ ```
35
+
36
+ 当前本地求解器仅填写可由二维几何严格积分的字段。``Ix``、``Ay``、``Az``、
37
+ 剪心和静矩保持为零,完整截面求解仍使用 ``odb.get_section_property``。
12
38
  # 建模操作
13
39
  ## 节点操作
14
40
  ### generate_curve_node_data
@@ -1,14 +1,30 @@
1
- Metadata-Version: 2.4
2
- Name: qtmodel
3
- Version: 2.4.2
4
- Summary: qtmodel package
5
- Author: Your Name
6
- Requires-Python: >=3.9
7
- Description-Content-Type: text/markdown
8
-
9
- > 最新qtmodel版本 2.4.2 - 2026-07-17
10
- > pip install --upgrade qtmodel -i https://pypi.org/simple
11
- - 增加检算荷载API
1
+ > 最新qtmodel版本 2.5.0 - 2026-07-18
2
+ > pip install --upgrade qtmodel -i https://pypi.org/simple
3
+ - 增加纯 Python 本地截面特性求解:面积、形心、Iy/Iz/Iyz、内外周长、
4
+ 包围框、四个控制点、组合截面换算特性和线宽截面特性。
5
+
6
+ ## 本地截面特性计算
7
+
8
+ 本地求解不要求打开桥通,不发送 HTTP 请求。所有坐标和厚度单位为 m,
9
+ 面积单位为 m²,惯性矩单位为 m⁴。
10
+
11
+ ```Python
12
+ from qtmodel import mdb
13
+
14
+ result = mdb.get_parameter_section_property_local(
15
+ sec_type="矩形",
16
+ sec_info={"W": 2.0, "H": 1.0},
17
+ )
18
+ print(result.property.Ax, result.property.Iy, result.bounding_box.to_dict())
19
+
20
+ line_result = mdb.get_section_property_local(
21
+ sec_type="自定义线宽截面",
22
+ sec_lines=[[0, 0, 2, 0, 0.02]],
23
+ )
24
+ ```
25
+
26
+ 当前本地求解器仅填写可由二维几何严格积分的字段。``Ix``、``Ay``、``Az``、
27
+ 剪心和静矩保持为零,完整截面求解仍使用 ``odb.get_section_property``。
12
28
  # 建模操作
13
29
  ## 节点操作
14
30
  ### generate_curve_node_data
@@ -4,11 +4,15 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "qtmodel"
7
- version = "2.4.2"
7
+ version = "2.5.0"
8
8
  description = "qtmodel package"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
11
- authors = [{name = "Your Name"}]
11
+ authors = [{name = "Your Name"}]
12
+ dependencies = [
13
+ "requests>=2.28",
14
+ "psutil>=5.9",
15
+ ]
12
16
 
13
17
  [tool.setuptools]
14
18
  package-dir = {"" = "src"}
@@ -3,6 +3,13 @@ from .cdb import cdb, Cdb
3
3
  from .core.qt_server import QtServer
4
4
  from .core.section_db import SectionDB, SectionParameter, SectionProperty, SectionSchema
5
5
  from .core.section_geometry import SectionGeometryDB
6
+ from .core.section_property_solver import (
7
+ SectionBoundingBox,
8
+ SectionControlPoint,
9
+ SectionPartProperty,
10
+ SectionPropertyResult,
11
+ SectionPropertySolver,
12
+ )
6
13
  from .core.section_shape import (
7
14
  SectionLine,
8
15
  SectionLoopSegment,
@@ -19,6 +26,8 @@ __all__ = [
19
26
  "Bridge", "bridge", "Cdb", "cdb", "Mdb", "mdb", "Odb", "odb",
20
27
  "SectionDB", "SectionParameter", "SectionProperty", "SectionSchema",
21
28
  "SectionGeometryDB",
29
+ "SectionBoundingBox", "SectionControlPoint", "SectionPartProperty",
30
+ "SectionPropertyResult", "SectionPropertySolver",
22
31
  "SectionPoint", "SectionLoopSegment", "SectionLine", "SectionShapePart",
23
32
  "SectionShape",
24
33
  ]
@@ -1,5 +1,12 @@
1
1
  from .section_db import SectionDB, SectionParameter, SectionProperty, SectionSchema
2
2
  from .section_geometry import SectionGeometryDB
3
+ from .section_property_solver import (
4
+ SectionBoundingBox,
5
+ SectionControlPoint,
6
+ SectionPartProperty,
7
+ SectionPropertyResult,
8
+ SectionPropertySolver,
9
+ )
3
10
  from .section_shape import (
4
11
  SectionLine,
5
12
  SectionLoopSegment,
@@ -11,6 +18,8 @@ from .section_shape import (
11
18
  __all__ = [
12
19
  "SectionDB", "SectionParameter", "SectionProperty", "SectionSchema",
13
20
  "SectionGeometryDB",
21
+ "SectionBoundingBox", "SectionControlPoint", "SectionPartProperty",
22
+ "SectionPropertyResult", "SectionPropertySolver",
14
23
  "SectionPoint", "SectionLoopSegment", "SectionLine", "SectionShapePart",
15
24
  "SectionShape",
16
25
  ]
@@ -7,7 +7,7 @@ import requests
7
7
 
8
8
  class QtServer:
9
9
  URL: str = "http://localhost:55125/pythonForQt/"
10
- QT_VERSION: str = "2.4.2"
10
+ QT_VERSION: str = "2.5.0"
11
11
  _session = requests.Session()
12
12
  _lock = threading.Lock()
13
13
 
@@ -0,0 +1,539 @@
1
+ # -*- coding: utf-8 -*-
2
+ """纯 Python 截面几何特性求解器。
3
+
4
+ 本模块只计算能够由二维几何直接、确定地积分得到的截面特性,不连接桥通,
5
+ 也不调用 ``SECPROCAL.exe``。坐标约定与 :mod:`qtmodel.core.section_shape`
6
+ 一致:形状中的 ``x`` 对应截面局部 ``Y``,``y`` 对应截面局部 ``Z``。
7
+
8
+ 当前严格支持:
9
+
10
+ * 实体线圈(包括孔洞)的面积、形心、Iy、Iz、Iyz 和周长;
11
+ * 带厚度中心线的薄壁矩形条带面积、形心和惯性矩;
12
+ * 组合截面的等效面积、形心和惯性矩;
13
+ * 包围框以及左上、右上、右下、左下四个控制点。
14
+
15
+ ``Ix``、``Ay``、``Az``、剪心、静矩等需要截面求解内核或额外工程假设的
16
+ 字段保持为零,调用方可据此区分本地几何结果与桥通完整求解结果。
17
+ """
18
+
19
+ from dataclasses import dataclass
20
+ from math import hypot, isfinite
21
+ from typing import Any, Dict, Iterable, Mapping, Optional, Sequence, Tuple, Union
22
+
23
+ from .section_db import SectionProperty
24
+ from .section_shape import (
25
+ SectionLine,
26
+ SectionLoopSegment,
27
+ SectionPoint,
28
+ SectionShape,
29
+ SectionShapePart,
30
+ )
31
+
32
+
33
+ _EPSILON = 1.0e-14
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class SectionBoundingBox:
38
+ """截面轴对齐包围框,坐标单位为 m。"""
39
+
40
+ min_y: float
41
+ min_z: float
42
+ max_y: float
43
+ max_z: float
44
+
45
+ @property
46
+ def width(self) -> float:
47
+ return self.max_y - self.min_y
48
+
49
+ @property
50
+ def height(self) -> float:
51
+ return self.max_z - self.min_z
52
+
53
+ def to_dict(self) -> Dict[str, float]:
54
+ return {
55
+ "min_y": self.min_y,
56
+ "min_z": self.min_z,
57
+ "max_y": self.max_y,
58
+ "max_z": self.max_z,
59
+ "width": self.width,
60
+ "height": self.height,
61
+ }
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class SectionControlPoint:
66
+ """相对换算截面形心的控制点坐标,单位为 m。"""
67
+
68
+ name: str
69
+ y: float
70
+ z: float
71
+
72
+ def to_dict(self) -> Dict[str, Union[str, float]]:
73
+ return {"name": self.name, "y": self.y, "z": self.z}
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class SectionPartProperty:
78
+ """组合截面中一个几何部件的未换算特性。"""
79
+
80
+ role: str
81
+ index: int
82
+ material_factor: float
83
+ property: SectionProperty
84
+ bounding_box: SectionBoundingBox
85
+
86
+ def to_dict(self) -> Dict[str, Any]:
87
+ return {
88
+ "role": self.role,
89
+ "index": self.index,
90
+ "material_factor": self.material_factor,
91
+ "property": self.property.to_dict(),
92
+ "bounding_box": self.bounding_box.to_dict(),
93
+ }
94
+
95
+
96
+ @dataclass(frozen=True)
97
+ class SectionPropertyResult:
98
+ """本地截面特性及其可核验的几何上下文。"""
99
+
100
+ property: SectionProperty
101
+ bounding_box: SectionBoundingBox
102
+ control_points: Tuple[SectionControlPoint, ...]
103
+ parts: Tuple[SectionPartProperty, ...]
104
+ transformed: bool = False
105
+ reference_role: Optional[str] = None
106
+ elastic_modulus_ratio: Optional[float] = None
107
+
108
+ def to_dict(self) -> Dict[str, Any]:
109
+ return {
110
+ "property": self.property.to_dict(),
111
+ "bounding_box": self.bounding_box.to_dict(),
112
+ "control_points": [point.to_dict() for point in self.control_points],
113
+ "parts": [part.to_dict() for part in self.parts],
114
+ "transformed": self.transformed,
115
+ "reference_role": self.reference_role,
116
+ "elastic_modulus_ratio": self.elastic_modulus_ratio,
117
+ "units": {
118
+ "length": "m",
119
+ "area": "m²",
120
+ "inertia": "m⁴",
121
+ },
122
+ "calculated_fields": [
123
+ "Ax", "Iy", "Iz", "Iyz", "Cyp", "Cym", "Czp", "Czm",
124
+ "PeriO", "PeriI", "CentY", "CentZ", "Y1", "Z1", "Y2",
125
+ "Z2", "Y3", "Z3", "Y4", "Z4",
126
+ ],
127
+ "not_calculated_fields": [
128
+ "Ay", "Az", "Ix", "ShearCenterY", "ShearCenterZ",
129
+ "Hys", "Hzs", "Sys", "Szs",
130
+ ],
131
+ "assumptions": [
132
+ "各实体线圈分段互不重叠,孔洞位于所属外轮廓内部",
133
+ "圆弧特性基于 SectionShape 中已经离散后的多边形积分",
134
+ "线宽按中心线矩形条带叠加,交点重叠面积不做布尔扣除",
135
+ "Iyz 采用面积乘积惯性矩积分 ∫Y·Z·dA 的符号约定",
136
+ ],
137
+ }
138
+
139
+
140
+ @dataclass(frozen=True)
141
+ class _Moments:
142
+ area: float
143
+ first_y: float
144
+ first_z: float
145
+ iy_origin: float
146
+ iz_origin: float
147
+ iyz_origin: float
148
+ perimeter_outer: float
149
+ perimeter_inner: float
150
+ points: Tuple[Tuple[float, float], ...]
151
+
152
+ def scaled(self, factor: float) -> "_Moments":
153
+ return _Moments(
154
+ area=self.area * factor,
155
+ first_y=self.first_y * factor,
156
+ first_z=self.first_z * factor,
157
+ iy_origin=self.iy_origin * factor,
158
+ iz_origin=self.iz_origin * factor,
159
+ iyz_origin=self.iyz_origin * factor,
160
+ perimeter_outer=self.perimeter_outer,
161
+ perimeter_inner=self.perimeter_inner,
162
+ points=self.points,
163
+ )
164
+
165
+
166
+ def _clean(value: float) -> float:
167
+ return 0.0 if abs(value) <= _EPSILON else value
168
+
169
+
170
+ def _ensure_finite(values: Iterable[float], description: str) -> None:
171
+ if not all(isfinite(value) for value in values):
172
+ raise ValueError(f"{description} 包含 NaN 或无穷大")
173
+
174
+
175
+ def _perimeter(points: Sequence[Tuple[float, float]]) -> float:
176
+ return sum(
177
+ hypot(
178
+ points[(index + 1) % len(points)][0] - point[0],
179
+ points[(index + 1) % len(points)][1] - point[1],
180
+ )
181
+ for index, point in enumerate(points)
182
+ )
183
+
184
+
185
+ def _polygon_moments(
186
+ raw_points: Sequence[SectionPoint],
187
+ sign: float,
188
+ description: str,
189
+ ) -> _Moments:
190
+ points = tuple((float(point.x), float(point.y)) for point in raw_points)
191
+ if len(points) > 1 and points[0] == points[-1]:
192
+ points = points[:-1]
193
+ if len(points) < 3:
194
+ raise ValueError(f"{description} 至少需要3个不同的点")
195
+ _ensure_finite((value for point in points for value in point), description)
196
+
197
+ twice_area = 0.0
198
+ first_y_numerator = 0.0
199
+ first_z_numerator = 0.0
200
+ iy_origin_numerator = 0.0
201
+ iz_origin_numerator = 0.0
202
+ iyz_origin_numerator = 0.0
203
+ for index, (y1, z1) in enumerate(points):
204
+ y2, z2 = points[(index + 1) % len(points)]
205
+ cross = y1 * z2 - y2 * z1
206
+ twice_area += cross
207
+ first_y_numerator += (y1 + y2) * cross
208
+ first_z_numerator += (z1 + z2) * cross
209
+ iy_origin_numerator += (z1 * z1 + z1 * z2 + z2 * z2) * cross
210
+ iz_origin_numerator += (y1 * y1 + y1 * y2 + y2 * y2) * cross
211
+ iyz_origin_numerator += (
212
+ 2.0 * y1 * z1 + y1 * z2 + y2 * z1 + 2.0 * y2 * z2
213
+ ) * cross
214
+
215
+ signed_area = twice_area / 2.0
216
+ if abs(signed_area) <= _EPSILON:
217
+ raise ValueError(f"{description} 面积为零或点全部共线")
218
+
219
+ # 输入轮廓的顺逆时针方向不参与实体/孔洞语义。main_loop 永远为正,
220
+ # sub_loops 永远为负,从而兼容历史几何生成器中不同的轮廓方向。
221
+ orientation = 1.0 if signed_area > 0 else -1.0
222
+ multiplier = sign * orientation
223
+ return _Moments(
224
+ area=signed_area * multiplier,
225
+ first_y=(first_y_numerator / 6.0) * multiplier,
226
+ first_z=(first_z_numerator / 6.0) * multiplier,
227
+ iy_origin=(iy_origin_numerator / 12.0) * multiplier,
228
+ iz_origin=(iz_origin_numerator / 12.0) * multiplier,
229
+ iyz_origin=(iyz_origin_numerator / 24.0) * multiplier,
230
+ perimeter_outer=_perimeter(points) if sign > 0 else 0.0,
231
+ perimeter_inner=_perimeter(points) if sign < 0 else 0.0,
232
+ points=points if sign > 0 else (),
233
+ )
234
+
235
+
236
+ def _add_moments(items: Iterable[_Moments]) -> _Moments:
237
+ values = tuple(items)
238
+ if not values:
239
+ raise ValueError("截面中没有可计算的实体线圈或线宽")
240
+ return _Moments(
241
+ area=sum(item.area for item in values),
242
+ first_y=sum(item.first_y for item in values),
243
+ first_z=sum(item.first_z for item in values),
244
+ iy_origin=sum(item.iy_origin for item in values),
245
+ iz_origin=sum(item.iz_origin for item in values),
246
+ iyz_origin=sum(item.iyz_origin for item in values),
247
+ perimeter_outer=sum(item.perimeter_outer for item in values),
248
+ perimeter_inner=sum(item.perimeter_inner for item in values),
249
+ points=tuple(point for item in values for point in item.points),
250
+ )
251
+
252
+
253
+ def _loop_segment_moments(segment: SectionLoopSegment, index: int) -> _Moments:
254
+ values = [
255
+ _polygon_moments(segment.main_loop, 1.0, f"第{index + 1}个实体外轮廓")
256
+ ]
257
+ values.extend(
258
+ _polygon_moments(loop, -1.0, f"第{index + 1}个实体的第{hole_index + 1}个孔洞")
259
+ for hole_index, loop in enumerate(segment.sub_loops)
260
+ )
261
+ result = _add_moments(values)
262
+ if result.area <= _EPSILON:
263
+ raise ValueError(f"第{index + 1}个实体扣除孔洞后的面积必须大于0")
264
+ return result
265
+
266
+
267
+ def _line_moments(line: SectionLine, index: int) -> _Moments:
268
+ y1, z1, y2, z2, thickness = (
269
+ float(line.x1), float(line.y1), float(line.x2), float(line.y2),
270
+ float(line.thickness),
271
+ )
272
+ _ensure_finite((y1, z1, y2, z2, thickness), f"第{index + 1}条线宽")
273
+ length = hypot(y2 - y1, z2 - z1)
274
+ if length <= _EPSILON:
275
+ raise ValueError(f"第{index + 1}条线宽的长度必须大于0")
276
+ if thickness <= 0.0:
277
+ raise ValueError(f"第{index + 1}条线宽的厚度必须大于0")
278
+
279
+ uy, uz = (y2 - y1) / length, (z2 - z1) / length
280
+ normal_y, normal_z = -uz, uy
281
+ half_t = thickness / 2.0
282
+ corners = (
283
+ (y1 + normal_y * half_t, z1 + normal_z * half_t),
284
+ (y2 + normal_y * half_t, z2 + normal_z * half_t),
285
+ (y2 - normal_y * half_t, z2 - normal_z * half_t),
286
+ (y1 - normal_y * half_t, z1 - normal_z * half_t),
287
+ )
288
+ area = length * thickness
289
+ cent_y, cent_z = (y1 + y2) / 2.0, (z1 + z2) / 2.0
290
+
291
+ along = thickness * length ** 3 / 12.0
292
+ across = length * thickness ** 3 / 12.0
293
+ iy_centroid = along * uz ** 2 + across * uy ** 2
294
+ iz_centroid = along * uy ** 2 + across * uz ** 2
295
+ iyz_centroid = (along - across) * uy * uz
296
+ return _Moments(
297
+ area=area,
298
+ first_y=area * cent_y,
299
+ first_z=area * cent_z,
300
+ iy_origin=iy_centroid + area * cent_z ** 2,
301
+ iz_origin=iz_centroid + area * cent_y ** 2,
302
+ iyz_origin=iyz_centroid + area * cent_y * cent_z,
303
+ # 薄壁中心线按条带叠加,交点不做布尔合并;因此不报告可能误导的周长。
304
+ perimeter_outer=0.0,
305
+ perimeter_inner=0.0,
306
+ points=corners,
307
+ )
308
+
309
+
310
+ def _part_moments(part: SectionShapePart) -> _Moments:
311
+ if part.shape_type == "loops" or part.loop_segments:
312
+ return _add_moments(
313
+ _loop_segment_moments(segment, index)
314
+ for index, segment in enumerate(part.loop_segments)
315
+ )
316
+ if part.shape_type == "lines" or part.lines:
317
+ return _add_moments(
318
+ _line_moments(line, index) for index, line in enumerate(part.lines)
319
+ )
320
+ raise ValueError(f"部件 {part.role!r} 没有可计算的线圈或线宽")
321
+
322
+
323
+ def _bounding_box(points: Sequence[Tuple[float, float]]) -> SectionBoundingBox:
324
+ if not points:
325
+ raise ValueError("无法从空几何计算包围框")
326
+ ys = [point[0] for point in points]
327
+ zs = [point[1] for point in points]
328
+ return SectionBoundingBox(min(ys), min(zs), max(ys), max(zs))
329
+
330
+
331
+ def _quadrant_point(
332
+ points: Sequence[Tuple[float, float]],
333
+ cent_y: float,
334
+ cent_z: float,
335
+ quadrant: str,
336
+ ) -> Tuple[float, float]:
337
+ if quadrant == "left_top":
338
+ candidates = [point for point in points if point[0] < cent_y and point[1] > cent_z]
339
+ fallback = [point for point in points if point[0] <= cent_y and point[1] >= cent_z]
340
+ key = lambda point: (point[1], -point[0])
341
+ elif quadrant == "right_top":
342
+ candidates = [point for point in points if point[0] > cent_y and point[1] > cent_z]
343
+ fallback = [point for point in points if point[0] >= cent_y and point[1] >= cent_z]
344
+ key = lambda point: (point[1], point[0])
345
+ elif quadrant == "right_bottom":
346
+ candidates = [point for point in points if point[0] > cent_y and point[1] < cent_z]
347
+ fallback = [point for point in points if point[0] >= cent_y and point[1] <= cent_z]
348
+ key = lambda point: (-point[1], point[0])
349
+ else:
350
+ candidates = [point for point in points if point[0] < cent_y and point[1] < cent_z]
351
+ fallback = [point for point in points if point[0] <= cent_y and point[1] <= cent_z]
352
+ key = lambda point: (-point[1], -point[0])
353
+ return max(candidates or fallback or list(points), key=key)
354
+
355
+
356
+ def _control_points(
357
+ points: Sequence[Tuple[float, float]], cent_y: float, cent_z: float,
358
+ ) -> Tuple[SectionControlPoint, ...]:
359
+ definitions = (
360
+ ("左上", "left_top"),
361
+ ("右上", "right_top"),
362
+ ("右下", "right_bottom"),
363
+ ("左下", "left_bottom"),
364
+ )
365
+ return tuple(
366
+ SectionControlPoint(name, point[0] - cent_y, point[1] - cent_z)
367
+ for name, quadrant in definitions
368
+ for point in (_quadrant_point(points, cent_y, cent_z, quadrant),)
369
+ )
370
+
371
+
372
+ def _property_from_moments(
373
+ moments: _Moments,
374
+ points: Sequence[Tuple[float, float]],
375
+ ) -> Tuple[SectionProperty, SectionBoundingBox, Tuple[SectionControlPoint, ...]]:
376
+ if moments.area <= _EPSILON:
377
+ raise ValueError("截面换算面积必须大于0")
378
+ cent_y = moments.first_y / moments.area
379
+ cent_z = moments.first_z / moments.area
380
+ iy = moments.iy_origin - moments.area * cent_z ** 2
381
+ iz = moments.iz_origin - moments.area * cent_y ** 2
382
+ iyz = moments.iyz_origin - moments.area * cent_y * cent_z
383
+ bounds = _bounding_box(points)
384
+ controls = _control_points(points, cent_y, cent_z)
385
+ prop = SectionProperty(
386
+ Ax=_clean(moments.area),
387
+ Iy=_clean(iy),
388
+ Iz=_clean(iz),
389
+ Iyz=_clean(iyz),
390
+ Cyp=_clean(bounds.max_y - cent_y),
391
+ Cym=_clean(cent_y - bounds.min_y),
392
+ Czp=_clean(bounds.max_z - cent_z),
393
+ Czm=_clean(cent_z - bounds.min_z),
394
+ PeriO=_clean(moments.perimeter_outer),
395
+ PeriI=_clean(moments.perimeter_inner),
396
+ CentY=_clean(cent_y),
397
+ CentZ=_clean(cent_z),
398
+ Y1=_clean(controls[0].y),
399
+ Z1=_clean(controls[0].z),
400
+ Y2=_clean(controls[1].y),
401
+ Z2=_clean(controls[1].z),
402
+ Y3=_clean(controls[2].y),
403
+ Z3=_clean(controls[2].z),
404
+ Y4=_clean(controls[3].y),
405
+ Z4=_clean(controls[3].z),
406
+ )
407
+ return prop, bounds, controls
408
+
409
+
410
+ class SectionPropertySolver:
411
+ """对 :class:`SectionShape` 执行无外部依赖的几何积分。"""
412
+
413
+ @staticmethod
414
+ def calculate(
415
+ shape: Union[SectionShape, Mapping[str, Any]],
416
+ elastic_modulus_ratio: Optional[float] = None,
417
+ reference_role: str = "main",
418
+ material_factors: Optional[Mapping[str, float]] = None,
419
+ ) -> SectionProperty:
420
+ """返回与桥通字段兼容的 ``SectionProperty``。
421
+
422
+ ``elastic_modulus_ratio`` 表示主材/辅材弹性模量比。以主材为换算基准时
423
+ 辅材系数为 ``1 / ratio``;以辅材为基准时主材系数为 ``ratio``。
424
+ ``material_factors`` 可为任意 role 显式指定换算系数,优先级更高。
425
+ """
426
+ return SectionPropertySolver.calculate_detailed(
427
+ shape,
428
+ elastic_modulus_ratio=elastic_modulus_ratio,
429
+ reference_role=reference_role,
430
+ material_factors=material_factors,
431
+ ).property
432
+
433
+ @staticmethod
434
+ def calculate_detailed(
435
+ shape: Union[SectionShape, Mapping[str, Any]],
436
+ elastic_modulus_ratio: Optional[float] = None,
437
+ reference_role: str = "main",
438
+ material_factors: Optional[Mapping[str, float]] = None,
439
+ ) -> SectionPropertyResult:
440
+ if isinstance(shape, Mapping) and not isinstance(shape, SectionShape):
441
+ shape = SectionShape.from_dict(shape)
442
+ if not isinstance(shape, SectionShape):
443
+ raise TypeError("shape 必须是 SectionShape 或可转换为 SectionShape 的字典")
444
+ if shape.is_tapered:
445
+ if not shape.begin_parts or not shape.end_parts:
446
+ raise ValueError("变截面必须同时包含 begin_parts 和 end_parts")
447
+ begin_shape = SectionShape(
448
+ index=shape.index,
449
+ name=shape.name,
450
+ sec_type=shape.sec_type,
451
+ shape_type="composite" if len(shape.begin_parts) > 1 else shape.begin_parts[0].shape_type,
452
+ parts=shape.begin_parts,
453
+ )
454
+ end_shape = SectionShape(
455
+ index=shape.index,
456
+ name=shape.name,
457
+ sec_type=shape.sec_type,
458
+ shape_type="composite" if len(shape.end_parts) > 1 else shape.end_parts[0].shape_type,
459
+ parts=shape.end_parts,
460
+ )
461
+ begin = SectionPropertySolver.calculate_detailed(
462
+ begin_shape, elastic_modulus_ratio, reference_role, material_factors
463
+ )
464
+ end = SectionPropertySolver.calculate_detailed(
465
+ end_shape, elastic_modulus_ratio, reference_role, material_factors
466
+ )
467
+ all_points = tuple(
468
+ (point.property.CentY + control.y, point.property.CentZ + control.z)
469
+ for point in (begin, end)
470
+ for control in point.control_points
471
+ )
472
+ return SectionPropertyResult(
473
+ property=SectionProperty(sec_begin=begin.property, sec_end=end.property),
474
+ bounding_box=_bounding_box(all_points),
475
+ control_points=begin.control_points,
476
+ parts=begin.parts + end.parts,
477
+ transformed=begin.transformed or end.transformed,
478
+ reference_role=reference_role,
479
+ elastic_modulus_ratio=elastic_modulus_ratio,
480
+ )
481
+ if not shape.parts:
482
+ raise ValueError("特性截面没有几何数据,无法进行本地几何积分")
483
+ if elastic_modulus_ratio is not None and elastic_modulus_ratio <= 0.0:
484
+ raise ValueError("elastic_modulus_ratio 必须大于0")
485
+ if reference_role not in ("main", "secondary"):
486
+ raise ValueError("reference_role 必须为 'main' 或 'secondary'")
487
+
488
+ factors: Dict[str, float] = {}
489
+ if material_factors is not None:
490
+ factors = {str(key): float(value) for key, value in material_factors.items()}
491
+ if any(value <= 0.0 or not isfinite(value) for value in factors.values()):
492
+ raise ValueError("material_factors 中的换算系数必须是大于0的有限数")
493
+ elif elastic_modulus_ratio is not None:
494
+ if reference_role == "main":
495
+ factors = {"main": 1.0, "secondary": 1.0 / elastic_modulus_ratio}
496
+ else:
497
+ factors = {"main": elastic_modulus_ratio, "secondary": 1.0}
498
+
499
+ raw_parts = tuple((part, _part_moments(part)) for part in shape.parts)
500
+ weighted_parts = tuple(
501
+ moments.scaled(factors.get(part.role, 1.0))
502
+ for part, moments in raw_parts
503
+ )
504
+ total = _add_moments(weighted_parts)
505
+ all_points = tuple(point for _part, moments in raw_parts for point in moments.points)
506
+ prop, bounds, controls = _property_from_moments(total, all_points)
507
+
508
+ part_results = []
509
+ for part, moments in raw_parts:
510
+ part_property, part_bounds, _part_controls = _property_from_moments(
511
+ moments, moments.points
512
+ )
513
+ part_results.append(SectionPartProperty(
514
+ role=part.role,
515
+ index=part.index,
516
+ material_factor=factors.get(part.role, 1.0),
517
+ property=part_property,
518
+ bounding_box=part_bounds,
519
+ ))
520
+
521
+ transformed = any(abs(factors.get(part.role, 1.0) - 1.0) > _EPSILON for part, _ in raw_parts)
522
+ return SectionPropertyResult(
523
+ property=prop,
524
+ bounding_box=bounds,
525
+ control_points=controls,
526
+ parts=tuple(part_results),
527
+ transformed=transformed,
528
+ reference_role=reference_role if transformed else None,
529
+ elastic_modulus_ratio=elastic_modulus_ratio if transformed else None,
530
+ )
531
+
532
+
533
+ __all__ = [
534
+ "SectionBoundingBox",
535
+ "SectionControlPoint",
536
+ "SectionPartProperty",
537
+ "SectionPropertyResult",
538
+ "SectionPropertySolver",
539
+ ]
@@ -4,6 +4,7 @@ from qtmodel.core.data_helper import QtDataHelper
4
4
  from qtmodel.core.qt_server import QtServer
5
5
  from qtmodel.core.section_db import SectionDB
6
6
  from qtmodel.core.section_geometry import SectionGeometryDB
7
+ from qtmodel.core.section_property_solver import SectionPropertyResult, SectionPropertySolver
7
8
  from qtmodel.core.section_shape import SectionShape
8
9
 
9
10
 
@@ -75,6 +76,71 @@ class MdbSection:
75
76
  box_height=section_kwargs.get("box_height", 2),
76
77
  )
77
78
 
79
+ @staticmethod
80
+ def get_parameter_section_property_local(
81
+ sec_type: str,
82
+ sec_info: Mapping[str, Any],
83
+ mat_combine: list[float] = None,
84
+ reference_role: str = "main",
85
+ name: str = "",
86
+ ) -> SectionPropertyResult:
87
+ """在本地计算参数化截面的可严格验证几何特性。
88
+
89
+ 本方法不会连接桥通或修改当前模型。当前计算面积、形心、Iy、Iz、
90
+ Iyz、内外轮廓周长、包围框和四个控制点;组合截面按
91
+ ``mat_combine[0]``(主材/辅材弹性模量比)进行换算。
92
+
93
+ Args:
94
+ sec_type: 参数化截面类型。
95
+ sec_info: 有含义的参数字典,坐标和厚度单位为 m。
96
+ mat_combine: 组合材料参数,可选;第一项为主材/辅材弹模比。
97
+ reference_role: 换算基准,``main`` 或 ``secondary``。
98
+ name: 临时截面名称。
99
+
100
+ Returns:
101
+ 包含 ``SectionProperty``、包围框、控制点及各部件特性的详细结果。
102
+ """
103
+ if mat_combine is not None and len(mat_combine) != 5:
104
+ raise ValueError("mat_combine 必须包含5个组合材料参数")
105
+ shape = MdbSection.get_parameter_section_shape(
106
+ sec_type=sec_type,
107
+ sec_info=sec_info,
108
+ mat_combine=mat_combine,
109
+ name=name,
110
+ )
111
+ return SectionPropertySolver.calculate_detailed(
112
+ shape,
113
+ elastic_modulus_ratio=(float(mat_combine[0]) if mat_combine else None),
114
+ reference_role=reference_role,
115
+ )
116
+
117
+ @staticmethod
118
+ def get_section_property_local(
119
+ sec_type: str,
120
+ sec_info: Union[list[float], Mapping[str, Any]] = None,
121
+ mat_combine: list[float] = None,
122
+ reference_role: str = "main",
123
+ **shape_options: Any,
124
+ ) -> SectionPropertyResult:
125
+ """按 ``get_section_shape`` 的参数本地计算截面几何特性。
126
+
127
+ 自定义线圈可在 ``shape_options`` 中传 ``loop_segments``,自定义线宽
128
+ 可传 ``sec_lines``。多条线宽按矩形条带叠加,交点不进行布尔去重。
129
+ """
130
+ if mat_combine is not None and len(mat_combine) != 5:
131
+ raise ValueError("mat_combine 必须包含5个组合材料参数")
132
+ shape = MdbSection.get_section_shape(
133
+ sec_type=sec_type,
134
+ sec_info=sec_info,
135
+ mat_combine=mat_combine,
136
+ **shape_options,
137
+ )
138
+ return SectionPropertySolver.calculate_detailed(
139
+ shape,
140
+ elastic_modulus_ratio=(float(mat_combine[0]) if mat_combine else None),
141
+ reference_role=reference_role,
142
+ )
143
+
78
144
  @staticmethod
79
145
  def get_section_shape(
80
146
  sec_type: str,
@@ -1,6 +1,40 @@
1
- > 最新qtmodel版本 2.4.2 - 2026-07-17
1
+ Metadata-Version: 2.4
2
+ Name: qtmodel
3
+ Version: 2.5.0
4
+ Summary: qtmodel package
5
+ Author: Your Name
6
+ Requires-Python: >=3.9
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: requests>=2.28
9
+ Requires-Dist: psutil>=5.9
10
+
11
+ > 最新qtmodel版本 2.5.0 - 2026-07-18
2
12
  > pip install --upgrade qtmodel -i https://pypi.org/simple
3
- - 增加检算荷载API
13
+ - 增加纯 Python 本地截面特性求解:面积、形心、Iy/Iz/Iyz、内外周长、
14
+ 包围框、四个控制点、组合截面换算特性和线宽截面特性。
15
+
16
+ ## 本地截面特性计算
17
+
18
+ 本地求解不要求打开桥通,不发送 HTTP 请求。所有坐标和厚度单位为 m,
19
+ 面积单位为 m²,惯性矩单位为 m⁴。
20
+
21
+ ```Python
22
+ from qtmodel import mdb
23
+
24
+ result = mdb.get_parameter_section_property_local(
25
+ sec_type="矩形",
26
+ sec_info={"W": 2.0, "H": 1.0},
27
+ )
28
+ print(result.property.Ax, result.property.Iy, result.bounding_box.to_dict())
29
+
30
+ line_result = mdb.get_section_property_local(
31
+ sec_type="自定义线宽截面",
32
+ sec_lines=[[0, 0, 2, 0, 0.02]],
33
+ )
34
+ ```
35
+
36
+ 当前本地求解器仅填写可由二维几何严格积分的字段。``Ix``、``Ay``、``Az``、
37
+ 剪心和静矩保持为零,完整截面求解仍使用 ``odb.get_section_property``。
4
38
  # 建模操作
5
39
  ## 节点操作
6
40
  ### generate_curve_node_data
@@ -5,6 +5,7 @@ src/qtmodel/bridge.py
5
5
  src/qtmodel.egg-info/PKG-INFO
6
6
  src/qtmodel.egg-info/SOURCES.txt
7
7
  src/qtmodel.egg-info/dependency_links.txt
8
+ src/qtmodel.egg-info/requires.txt
8
9
  src/qtmodel.egg-info/top_level.txt
9
10
  src/qtmodel/cdb/__init__.py
10
11
  src/qtmodel/cdb/cdb_anti_overturn_check.py
@@ -19,6 +20,7 @@ src/qtmodel/core/qt_server.py
19
20
  src/qtmodel/core/result_db.py
20
21
  src/qtmodel/core/section_db.py
21
22
  src/qtmodel/core/section_geometry.py
23
+ src/qtmodel/core/section_property_solver.py
22
24
  src/qtmodel/core/section_shape.py
23
25
  src/qtmodel/mdb/__init__.py
24
26
  src/qtmodel/mdb/mdb_analysis_setting.py
@@ -48,4 +50,5 @@ src/qtmodel/odb/odb_result_plot.py
48
50
  src/qtmodel/odb/odb_view.py
49
51
  tests/test_section_db.py
50
52
  tests/test_section_geometry.py
53
+ tests/test_section_property_solver.py
51
54
  tests/test_section_shape.py
@@ -0,0 +1,2 @@
1
+ requests>=2.28
2
+ psutil>=5.9
@@ -0,0 +1,145 @@
1
+ # -*- coding: utf-8 -*-
2
+ import importlib
3
+ from pathlib import Path
4
+ import sys
5
+ import types
6
+ import unittest
7
+
8
+
9
+ PACKAGE_ROOT = Path(__file__).resolve().parents[1] / "src" / "qtmodel"
10
+ if "qtmodel" not in sys.modules:
11
+ package = types.ModuleType("qtmodel")
12
+ package.__path__ = [str(PACKAGE_ROOT)]
13
+ sys.modules["qtmodel"] = package
14
+
15
+ CORE = importlib.import_module("qtmodel.core")
16
+ SectionGeometryDB = CORE.SectionGeometryDB
17
+ SectionPropertySolver = CORE.SectionPropertySolver
18
+
19
+
20
+ class SectionPropertySolverTests(unittest.TestCase):
21
+ def assertAlmostRelative(self, actual, expected, places=12):
22
+ self.assertAlmostEqual(actual, expected, places=places)
23
+
24
+ def test_rectangle_has_exact_analytical_properties(self):
25
+ shape = SectionGeometryDB.generate("矩形", [2.0, 1.0])
26
+ result = SectionPropertySolver.calculate_detailed(shape)
27
+ prop = result.property
28
+
29
+ self.assertAlmostRelative(prop.Ax, 2.0)
30
+ self.assertAlmostRelative(prop.CentY, 1.0)
31
+ self.assertAlmostRelative(prop.CentZ, 0.5)
32
+ self.assertAlmostRelative(prop.Iy, 2.0 / 12.0)
33
+ self.assertAlmostRelative(prop.Iz, 8.0 / 12.0)
34
+ self.assertAlmostRelative(prop.Iyz, 0.0)
35
+ self.assertAlmostRelative(prop.PeriO, 6.0)
36
+ self.assertAlmostRelative(prop.PeriI, 0.0)
37
+ self.assertEqual(
38
+ [(point.y, point.z) for point in result.control_points],
39
+ [(-1.0, 0.5), (1.0, 0.5), (1.0, -0.5), (-1.0, -0.5)],
40
+ )
41
+ self.assertEqual(result.bounding_box.to_dict(), {
42
+ "min_y": 0.0,
43
+ "min_z": 0.0,
44
+ "max_y": 2.0,
45
+ "max_z": 1.0,
46
+ "width": 2.0,
47
+ "height": 1.0,
48
+ })
49
+
50
+ def test_hole_orientation_does_not_change_subtraction(self):
51
+ shape = SectionGeometryDB.generate(
52
+ "自定义线圈截面",
53
+ loop_segments=[{
54
+ "main": [[0, 0], [4, 0], [4, 2], [0, 2]],
55
+ # 故意与外圈同为逆时针,求解器仍必须把它识别为孔洞。
56
+ "sub1": [[1, 0.5], [3, 0.5], [3, 1.5], [1, 1.5]],
57
+ }],
58
+ )
59
+ prop = SectionPropertySolver.calculate(shape)
60
+
61
+ self.assertAlmostRelative(prop.Ax, 6.0)
62
+ self.assertAlmostRelative(prop.CentY, 2.0)
63
+ self.assertAlmostRelative(prop.CentZ, 1.0)
64
+ self.assertAlmostRelative(prop.Iy, 2.5)
65
+ self.assertAlmostRelative(prop.Iz, 10.0)
66
+ self.assertAlmostRelative(prop.PeriO, 12.0)
67
+ self.assertAlmostRelative(prop.PeriI, 6.0)
68
+
69
+ def test_product_of_inertia_is_translation_invariant_about_centroid(self):
70
+ base = SectionGeometryDB.generate(
71
+ "自定义线圈截面",
72
+ loop_segments=[{"main": [[0, 0], [2, 0], [0, 1]]}],
73
+ )
74
+ moved = SectionGeometryDB.generate(
75
+ "自定义线圈截面",
76
+ loop_segments=[{"main": [[10, -7], [12, -7], [10, -6]]}],
77
+ )
78
+ first = SectionPropertySolver.calculate(base)
79
+ second = SectionPropertySolver.calculate(moved)
80
+ self.assertAlmostRelative(first.Iy, second.Iy)
81
+ self.assertAlmostRelative(first.Iz, second.Iz)
82
+ self.assertAlmostRelative(first.Iyz, second.Iyz)
83
+
84
+ def test_composite_section_uses_modular_ratio(self):
85
+ shape = SectionGeometryDB.generate(
86
+ "自定义组合截面",
87
+ loop_segments=[{"main": [[0, 0], [2, 0], [2, 1], [0, 1]]}],
88
+ secondary_loop_segments=[{
89
+ "main": [[0, 1], [2, 1], [2, 2], [0, 2]],
90
+ }],
91
+ )
92
+ result = SectionPropertySolver.calculate_detailed(
93
+ shape,
94
+ elastic_modulus_ratio=2.0,
95
+ reference_role="main",
96
+ )
97
+ prop = result.property
98
+
99
+ self.assertTrue(result.transformed)
100
+ self.assertAlmostRelative(prop.Ax, 3.0)
101
+ self.assertAlmostRelative(prop.CentY, 1.0)
102
+ self.assertAlmostRelative(prop.CentZ, 5.0 / 6.0)
103
+ self.assertAlmostRelative(prop.Iy, 11.0 / 12.0)
104
+ self.assertAlmostRelative(prop.Iz, 1.0)
105
+ self.assertEqual(
106
+ [part.material_factor for part in result.parts], [1.0, 0.5]
107
+ )
108
+
109
+ def test_line_width_uses_exact_oriented_rectangle_formula(self):
110
+ shape = SectionGeometryDB.generate(
111
+ "自定义线宽截面",
112
+ sec_lines=[[0, 0, 2, 0, 0.02]],
113
+ )
114
+ result = SectionPropertySolver.calculate_detailed(shape)
115
+ prop = result.property
116
+
117
+ self.assertAlmostRelative(prop.Ax, 0.04)
118
+ self.assertAlmostRelative(prop.CentY, 1.0)
119
+ self.assertAlmostRelative(prop.CentZ, 0.0)
120
+ self.assertAlmostRelative(prop.Iy, 2.0 * 0.02 ** 3 / 12.0)
121
+ self.assertAlmostRelative(prop.Iz, 0.02 * 2.0 ** 3 / 12.0)
122
+ self.assertAlmostRelative(prop.Iyz, 0.0)
123
+ self.assertAlmostRelative(result.bounding_box.min_z, -0.01)
124
+ self.assertAlmostRelative(result.bounding_box.max_z, 0.01)
125
+ self.assertEqual(prop.PeriO, 0.0)
126
+ self.assertEqual(prop.PeriI, 0.0)
127
+
128
+ def test_invalid_geometry_is_rejected(self):
129
+ shape = SectionGeometryDB.generate(
130
+ "自定义线宽截面",
131
+ sec_lines=[[0, 0, 0, 0, 0.02]],
132
+ )
133
+ with self.assertRaisesRegex(ValueError, "长度必须大于0"):
134
+ SectionPropertySolver.calculate(shape)
135
+
136
+ def test_detailed_result_is_json_serializable_data(self):
137
+ shape = SectionGeometryDB.generate("矩形", [2.0, 1.0])
138
+ data = SectionPropertySolver.calculate_detailed(shape).to_dict()
139
+ self.assertEqual(data["units"]["area"], "m²")
140
+ self.assertIn("Iy", data["calculated_fields"])
141
+ self.assertIn("Ix", data["not_calculated_fields"])
142
+
143
+
144
+ if __name__ == "__main__":
145
+ unittest.main()
File without changes
File without changes