truss-analysis 2.0.9__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.
@@ -0,0 +1,19 @@
1
+ __version__ = "2.0.9"
2
+
3
+ from .assembly import assemble_global_matrices
4
+ from .model import Element, Node
5
+ from .postprocess import calculate_element_forces
6
+ from .solver import check_energy, solve
7
+
8
+ solve_truss = solve
9
+
10
+ __all__ = [
11
+ "__version__",
12
+ "Element",
13
+ "Node",
14
+ "solve",
15
+ "solve_truss",
16
+ "check_energy",
17
+ "assemble_global_matrices",
18
+ "calculate_element_forces",
19
+ ]
@@ -0,0 +1,93 @@
1
+ """Assembly: global stiffness matrix and force vectors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+
7
+ from .exceptions import AssemblyError
8
+ from .model import Element, Node
9
+
10
+
11
+ def assemble_global_matrices(
12
+ nodes: list[Node],
13
+ elements: list[Element],
14
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray, list[int]]:
15
+ """Assemble global stiffness matrix and force vectors.
16
+
17
+ Returns:
18
+ K: Global stiffness matrix
19
+ F_ext: External force vector (mechanical + thermal)
20
+ F_mechanical: Mechanical force vector only
21
+ fixed_dofs: List of fixed DOF indices
22
+ """
23
+ n = len(nodes)
24
+ K = np.zeros((2 * n, 2 * n))
25
+ F_ext = np.zeros(2 * n)
26
+ F_mechanical = np.zeros(2 * n)
27
+ fixed_dofs = []
28
+
29
+ # Build node index map
30
+ node_map = {node.id: i for i, node in enumerate(nodes)}
31
+
32
+ # Assemble element contributions
33
+ for elem in elements:
34
+ if elem.node_i not in node_map or elem.node_j not in node_map:
35
+ raise AssemblyError(f"Element {elem.id} references non-existent nodes")
36
+
37
+ i = node_map[elem.node_i]
38
+ j = node_map[elem.node_j]
39
+
40
+ # Element geometry
41
+ dx = nodes[j].x - nodes[i].x
42
+ dy = nodes[j].y - nodes[i].y
43
+ L = np.sqrt(dx**2 + dy**2)
44
+
45
+ if L < 1e-12:
46
+ raise AssemblyError(f"Element {elem.id} has zero or negative length")
47
+
48
+ c = dx / L
49
+ s = dy / L
50
+
51
+ # Element stiffness matrix (local to global transformation)
52
+ k = elem.E * elem.A / L
53
+ ke = k * np.array(
54
+ [
55
+ [c**2, c * s, -(c**2), -c * s],
56
+ [c * s, s**2, -c * s, -(s**2)],
57
+ [-(c**2), -c * s, c**2, c * s],
58
+ [-c * s, -(s**2), c * s, s**2],
59
+ ]
60
+ )
61
+
62
+ # DOF indices
63
+ dofs = [2 * i, 2 * i + 1, 2 * j, 2 * j + 1]
64
+
65
+ # Assemble into global matrix
66
+ for ii in range(4):
67
+ for jj in range(4):
68
+ K[dofs[ii], dofs[jj]] += ke[ii, jj]
69
+
70
+ # Thermal/fabrication forces
71
+ if elem.alpha != 0 or elem.delta_L_free != 0:
72
+ delta_L_thermal = elem.alpha * elem.delta_T * L
73
+ delta_L_prestress = delta_L_thermal + elem.delta_L_free
74
+
75
+ # Equivalent nodal forces (in global coordinates)
76
+ F_thermal = k * delta_L_prestress
77
+ F_ext[2 * i] -= F_thermal * c
78
+ F_ext[2 * i + 1] -= F_thermal * s
79
+ F_ext[2 * j] += F_thermal * c
80
+ F_ext[2 * j + 1] += F_thermal * s
81
+
82
+ # F_mechanical does NOT include thermal forces
83
+ # (they are internal, not external)
84
+
85
+ # Apply boundary conditions
86
+ for i, node in enumerate(nodes):
87
+ if node.is_support:
88
+ if node.support_dx:
89
+ fixed_dofs.append(2 * i)
90
+ if node.support_dy:
91
+ fixed_dofs.append(2 * i + 1)
92
+
93
+ return K, F_ext, F_mechanical, fixed_dofs
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class TrussError(Exception):
5
+ pass
6
+
7
+
8
+ class AssemblyError(TrussError):
9
+ pass
10
+
11
+
12
+ class InputValidationError(TrussError):
13
+ pass
14
+
15
+
16
+ class EnergyValidationError(TrussError):
17
+ pass
18
+
19
+
20
+ class SingularMatrixError(TrussError):
21
+ pass
22
+
23
+
24
+ class UnitConversionError(TrussError):
25
+ pass
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+
6
+ from .exceptions import InputValidationError
7
+
8
+
9
+ def load_json(filepath):
10
+ if not os.path.exists(filepath):
11
+ raise FileNotFoundError(f"File not found: {filepath}")
12
+ size = os.path.getsize(filepath)
13
+ if size > 10 * 1024 * 1024:
14
+ raise InputValidationError(f"File too large: {size} bytes.")
15
+ try:
16
+ with open(filepath, "r", encoding="utf-8") as f:
17
+ data = json.load(f)
18
+ except json.JSONDecodeError:
19
+ raise InputValidationError("Invalid JSON format.")
20
+
21
+ for key in ["nodes", "elements"]:
22
+ if key not in data:
23
+ raise InputValidationError(f"Missing key: '{key}'")
24
+ if not isinstance(data[key], list):
25
+ raise InputValidationError(f"'{key}' must be a list.")
26
+
27
+ node_ids = {n.get("id") for n in data["nodes"]}
28
+ for elem in data["elements"]:
29
+ if elem.get("node_i") not in node_ids or elem.get("node_j") not in node_ids:
30
+ raise InputValidationError(
31
+ f"Element {elem.get('id')} references non-existent nodes."
32
+ )
33
+
34
+ if "loads" in data:
35
+ loads = data["loads"]
36
+ if not isinstance(loads, list):
37
+ raise InputValidationError("'loads' must be a list of force objects.")
38
+ for lf in loads:
39
+ if not isinstance(lf, dict):
40
+ raise InputValidationError("Each load must be a dictionary.")
41
+ if "node_id" not in lf and "id" not in lf:
42
+ raise InputValidationError("Load missing 'node_id' or 'id'.")
43
+ if "Fx" not in lf and "Fy" not in lf:
44
+ raise InputValidationError("Load missing 'Fx' or 'Fy'.")
45
+ return data
truss_analysis/i18n.py ADDED
@@ -0,0 +1,30 @@
1
+ """ماژول مدیریت چندزبانه (i18n) با پشتیبانی از Graceful Degradation"""
2
+
3
+ import logging
4
+
5
+ logger = logging.getLogger(__name__)
6
+ TRANSLATIONS = {
7
+ "fa": {
8
+ "force": "نیرو",
9
+ "displacement": "جابجایی",
10
+ "node": "گره",
11
+ "element": "عضو",
12
+ "status": "وضعیت",
13
+ },
14
+ "en": {
15
+ "force": "Force",
16
+ "displacement": "Displacement",
17
+ "node": "Node",
18
+ "element": "Element",
19
+ "status": "Status",
20
+ },
21
+ }
22
+ CURRENT_LANG = "fa"
23
+
24
+
25
+ def get_text(key: str, lang: str = CURRENT_LANG) -> str:
26
+ lang_dict = TRANSLATIONS.get(lang, TRANSLATIONS.get("fa", {}))
27
+ if key in lang_dict:
28
+ return lang_dict[key]
29
+ logger.warning(f"Translation key not found: '{key}' in language '{lang}'")
30
+ return f"!!{key}!!"
@@ -0,0 +1,12 @@
1
+ import logging
2
+ import sys
3
+
4
+
5
+ def setup_logger():
6
+ logger = logging.getLogger("truss_analysis")
7
+ if not logger.handlers:
8
+ handler = logging.StreamHandler(sys.stdout)
9
+ handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s"))
10
+ logger.addHandler(handler)
11
+ logger.setLevel(logging.INFO)
12
+ return logger
truss_analysis/main.py ADDED
@@ -0,0 +1,271 @@
1
+ """Main entry point for truss analysis with advanced CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from dataclasses import dataclass, field
8
+
9
+ from .assembly import assemble_global_matrices
10
+ from .fileio import load_json
11
+ from .model import Element, Node, validate_inputs
12
+ from .plotter import plot_axial_force, plot_truss, setup_persian_font
13
+ from .postprocess import (
14
+ calculate_element_forces,
15
+ calculate_reactions,
16
+ check_equilibrium,
17
+ )
18
+ from .report import to_csv, to_json, to_markdown
19
+ from .solver import check_energy, solve
20
+ from .units import to_si
21
+
22
+
23
+ @dataclass
24
+ class AnalysisResult:
25
+ """Complete analysis results."""
26
+
27
+ displacements: dict[str, tuple[float, float]]
28
+ forces: list[dict]
29
+ reactions: dict[str, dict[str, float]]
30
+ strain_energy: float
31
+ prestress_work: float
32
+ equilibrium_errors: dict[str, float]
33
+ nodes: list[Node] = field(default_factory=list)
34
+ elements: list[Element] = field(default_factory=list)
35
+
36
+
37
+ def run(filepath: str, unit_sys: str = "SI") -> AnalysisResult:
38
+ """Run complete truss analysis."""
39
+ data = load_json(filepath)
40
+ nodes = [
41
+ Node(
42
+ id=str(n["id"]),
43
+ x=to_si(n["x"], unit_sys, "L"),
44
+ y=to_si(n["y"], unit_sys, "L"),
45
+ is_support=n.get("is_support", False),
46
+ support_dx=n.get("support_dx", False),
47
+ support_dy=n.get("support_dy", False),
48
+ )
49
+ for n in data["nodes"]
50
+ ]
51
+
52
+ elements = [
53
+ Element(
54
+ id=str(e["id"]),
55
+ node_i=str(e["node_i"]),
56
+ node_j=str(e["node_j"]),
57
+ E=to_si(e["E"], unit_sys, "E"),
58
+ A=to_si(e["A"], unit_sys, "A"),
59
+ I_sec=to_si(e.get("I_sec", e.get("I", 0.0)), unit_sys, "I_sec"),
60
+ alpha=to_si(e.get("alpha", 0.0), unit_sys, "alpha"),
61
+ delta_T=to_si(e.get("delta_T", 0.0), unit_sys, "delta_T"),
62
+ delta_L_free=to_si(e.get("delta_L_free", 0.0), unit_sys, "L"),
63
+ density=to_si(e.get("density", 0.0), unit_sys, "density"),
64
+ effective_length_factor=e.get("effective_length_factor", 1.0),
65
+ )
66
+ for e in data["elements"]
67
+ ]
68
+
69
+ validate_inputs(nodes, elements)
70
+ K, F_ext, F_mechanical, fixed_dofs = assemble_global_matrices(nodes, elements)
71
+
72
+ # Add self-weight if density is provided
73
+ g = 9.81 # m/s²
74
+ for elem in elements:
75
+ if elem.density > 0:
76
+ i = next(j for j, n in enumerate(nodes) if n.id == elem.node_i)
77
+ j = next(j for j, n in enumerate(nodes) if n.id == elem.node_j)
78
+ dx = nodes[j].x - nodes[i].x
79
+ dy = nodes[j].y - nodes[i].y
80
+ L = (dx**2 + dy**2) ** 0.5
81
+ weight = elem.density * elem.A * L * g
82
+ F_ext[2 * i + 1] -= weight / 2
83
+ F_ext[2 * j + 1] -= weight / 2
84
+ F_mechanical[2 * i + 1] -= weight / 2
85
+ F_mechanical[2 * j + 1] -= weight / 2
86
+
87
+ loads = data.get("loads", [])
88
+ if isinstance(loads, dict):
89
+ loads = loads.get("node_forces", [])
90
+
91
+ node_map = {node.id: i for i, node in enumerate(nodes)}
92
+ for lf in loads:
93
+ nid = str(lf.get("node_id", lf.get("id")))
94
+ if nid in node_map:
95
+ idx = node_map[nid]
96
+ Fx = to_si(lf.get("Fx", 0.0), unit_sys, "F")
97
+ Fy = to_si(lf.get("Fy", 0.0), unit_sys, "F")
98
+ F_ext[idx * 2] += Fx
99
+ F_ext[idx * 2 + 1] += Fy
100
+ F_mechanical[idx * 2] += Fx
101
+ F_mechanical[idx * 2 + 1] += Fy
102
+
103
+ U = solve(K, F_ext, fixed_dofs)
104
+ results, strain_energy, prestress_work = calculate_element_forces(
105
+ nodes, elements, U
106
+ )
107
+ check_energy(U, F_mechanical, strain_energy, prestress_work)
108
+
109
+ reactions = calculate_reactions(nodes, elements, U, F_ext)
110
+ equilibrium_errors = check_equilibrium(nodes, reactions, F_ext)
111
+ displacements = {node.id: (U[i * 2], U[i * 2 + 1]) for i, node in enumerate(nodes)}
112
+
113
+ return AnalysisResult(
114
+ displacements=displacements,
115
+ forces=results,
116
+ reactions=reactions,
117
+ strain_energy=strain_energy,
118
+ prestress_work=prestress_work,
119
+ equilibrium_errors=equilibrium_errors,
120
+ nodes=nodes,
121
+ elements=elements,
122
+ )
123
+
124
+
125
+ def main() -> int:
126
+ # Set UTF-8 encoding for stdout/stderr (Windows compatibility)
127
+ import io
128
+
129
+ try:
130
+ sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
131
+ sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8")
132
+ except (AttributeError, Exception):
133
+ pass
134
+
135
+ parser = argparse.ArgumentParser(
136
+ description="2D Truss Analysis Tool with Advanced Features",
137
+ formatter_class=argparse.RawDescriptionHelpFormatter,
138
+ epilog="""
139
+ Examples:
140
+ python main.py example.json
141
+ python main.py example.json --plot
142
+ python main.py example.json --output results --format all
143
+ python main.py example.json --save-plots --scale 50
144
+ python main.py example.json --check-buckling
145
+ """,
146
+ )
147
+ parser.add_argument("filepath", help="Path to the JSON input file")
148
+ parser.add_argument(
149
+ "unit_sys",
150
+ nargs="?",
151
+ default="SI",
152
+ help="Unit system (SI or Imperial)",
153
+ )
154
+ parser.add_argument(
155
+ "--plot",
156
+ action="store_true",
157
+ help="Show interactive plots of the truss",
158
+ )
159
+ parser.add_argument(
160
+ "--save-plots",
161
+ action="store_true",
162
+ help="Save plots as PNG files",
163
+ )
164
+ parser.add_argument(
165
+ "--output",
166
+ type=str,
167
+ help="Output file prefix (default: results)",
168
+ )
169
+ parser.add_argument(
170
+ "--format",
171
+ choices=["json", "csv", "markdown", "all"],
172
+ default="all",
173
+ help="Output format(s) to generate",
174
+ )
175
+ parser.add_argument(
176
+ "--check-buckling",
177
+ action="store_true",
178
+ help="Check Euler buckling for compression members",
179
+ )
180
+ parser.add_argument(
181
+ "--scale",
182
+ type=float,
183
+ default=10.0,
184
+ help="Deformation scale factor for plots (default: 10)",
185
+ )
186
+
187
+ args = parser.parse_args()
188
+
189
+ try:
190
+ result = run(args.filepath, args.unit_sys)
191
+
192
+ # Console output
193
+ print("✅ تحلیل با موفقیت انجام شد.")
194
+ print(f"انرژی کرنشی: {result.strain_energy:.4f} J")
195
+ print(f"کار پیش‌تنیدگی: {result.prestress_work:.4f} J")
196
+ print("\n📊 نیروهای اعضا:")
197
+ for r in result.forces:
198
+ status = "📈 کشش" if r["force"] > 0 else "📉 فشار"
199
+ print(f" المان {r['element']}: {r['force']:.2f} N ({status})")
200
+ if r.get("buckling_warning"):
201
+ print(f" ⚠️ {r['buckling_warning']}")
202
+
203
+ print("\n🔧 عکس‌العمل‌های تکیه‌گاهی:")
204
+ for node_id, react in result.reactions.items():
205
+ print(f" گره {node_id}: Rx={react['Rx']:.2f} N, Ry={react['Ry']:.2f} N")
206
+
207
+ print("\n✅ بررسی تعادل استاتیکی:")
208
+ print(f" خطای ΣFx: {result.equilibrium_errors['delta_Fx']:.6e}")
209
+ print(f" خطای ΣFy: {result.equilibrium_errors['delta_Fy']:.6e}")
210
+ print(f" خطای ΣM: {result.equilibrium_errors['delta_M']:.6e}")
211
+
212
+ # Generate plots if requested
213
+ if args.plot or args.save_plots:
214
+ font_prop = setup_persian_font()
215
+ prefix = args.output or "results"
216
+
217
+ if args.save_plots:
218
+ plot_truss(
219
+ result.nodes,
220
+ result.elements,
221
+ result.displacements,
222
+ show=args.plot,
223
+ filename=f"{prefix}_truss.png",
224
+ scale=args.scale,
225
+ font_prop=font_prop,
226
+ )
227
+ plot_axial_force(
228
+ result.nodes,
229
+ result.elements,
230
+ result.forces,
231
+ filename=f"{prefix}_forces.png",
232
+ font_prop=font_prop,
233
+ )
234
+ elif args.plot:
235
+ plot_truss(
236
+ result.nodes,
237
+ result.elements,
238
+ result.displacements,
239
+ show=True,
240
+ scale=args.scale,
241
+ font_prop=font_prop,
242
+ )
243
+ plot_axial_force(
244
+ result.nodes,
245
+ result.elements,
246
+ result.forces,
247
+ font_prop=font_prop,
248
+ )
249
+
250
+ # Generate structured outputs
251
+ if args.output:
252
+ prefix = args.output
253
+ if args.format in ["json", "all"]:
254
+ to_json(result, f"{prefix}.json")
255
+ if args.format in ["csv", "all"]:
256
+ to_csv(result, prefix)
257
+ if args.format in ["markdown", "all"]:
258
+ to_markdown(result, f"{prefix}.md")
259
+
260
+ return 0
261
+
262
+ except Exception as e:
263
+ print(f"❌ خطا در تحلیل: {e}", file=sys.stderr)
264
+ import traceback
265
+
266
+ traceback.print_exc()
267
+ return 2
268
+
269
+
270
+ if __name__ == "__main__":
271
+ sys.exit(main())
@@ -0,0 +1,94 @@
1
+ """Pure DTOs for Truss Analysis."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from .exceptions import InputValidationError
8
+
9
+
10
+ @dataclass
11
+ class Node:
12
+ """A node in the truss structure."""
13
+
14
+ id: str
15
+ x: float
16
+ y: float
17
+ is_support: bool = False
18
+ support_dx: bool = False
19
+ support_dy: bool = False
20
+
21
+ def __post_init__(self):
22
+ if not isinstance(self.id, str):
23
+ raise InputValidationError(f"Node ID must be string, got {type(self.id)}")
24
+ if not all(isinstance(v, (int, float)) for v in [self.x, self.y]):
25
+ raise InputValidationError("Node coordinates must be numeric")
26
+
27
+
28
+ @dataclass
29
+ class Element:
30
+ """A truss element (bar) connecting two nodes."""
31
+
32
+ id: str
33
+ node_i: str
34
+ node_j: str
35
+ E: float # Young's modulus
36
+ A: float # Cross-sectional area
37
+ I_sec: float = 0.0 # Second moment of area
38
+ alpha: float = 0.0 # Thermal expansion coefficient
39
+ delta_T: float = 0.0 # Temperature change
40
+ delta_L_free: float = 0.0 # Free length change (fabrication error)
41
+ density: float = 0.0 # Material density (for self-weight)
42
+ effective_length_factor: float = 1.0 # For buckling calculation
43
+
44
+ def __post_init__(self):
45
+ if not isinstance(self.id, str):
46
+ raise InputValidationError(
47
+ f"Element ID must be string, got {type(self.id)}"
48
+ )
49
+ if self.E <= 0:
50
+ raise InputValidationError(
51
+ f"Element {self.id}: E must be positive, got {self.E}"
52
+ )
53
+ if self.A <= 0:
54
+ raise InputValidationError(
55
+ f"Element {self.id}: A must be positive, got {self.A}"
56
+ )
57
+ if self.node_i == self.node_j:
58
+ raise InputValidationError(
59
+ f"Element {self.id}: node_i and node_j cannot be the same"
60
+ )
61
+
62
+
63
+ def validate_inputs(nodes: list[Node], elements: list[Element]) -> None:
64
+ """Validate input data for consistency and correctness."""
65
+
66
+ # Check unique node IDs
67
+ node_ids = {n.id for n in nodes}
68
+ if len(node_ids) != len(nodes):
69
+ raise InputValidationError("Duplicate node IDs found")
70
+
71
+ # Check unique element IDs
72
+ elem_ids = {e.id for e in elements}
73
+ if len(elem_ids) != len(elements):
74
+ raise InputValidationError("Duplicate element IDs found")
75
+
76
+ # Check that all element node references exist
77
+ for elem in elements:
78
+ if elem.node_i not in node_ids:
79
+ raise InputValidationError(
80
+ f"Element {elem.id} references non-existent node {elem.node_i}"
81
+ )
82
+ if elem.node_j not in node_ids:
83
+ raise InputValidationError(
84
+ f"Element {elem.id} references non-existent node {elem.node_j}"
85
+ )
86
+
87
+ # Check kinematic stability (at least 3 constraints total)
88
+ total_constraints = sum(
89
+ (n.support_dx + n.support_dy) for n in nodes if n.is_support
90
+ )
91
+ if total_constraints < 3:
92
+ raise InputValidationError(
93
+ f"Insufficient constraints for stability: {total_constraints} < 3"
94
+ )