STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
plate_dxf.py
Go to the documentation of this file.
1"""Read the injector plate's revolved half-section from a CAD DXF.
2
3The sketch is the plate's cross-section on one side of its axis: one closed profile of LINEs and
4ARCs, radius on one sketch axis and axial position on the other (the revolve axis is the profile
5edge at radius 0, or the profile sits entirely at radius >= 0). Units come from $INSUNITS.
6
7From it: the plate thickness, the recesses in the back face (deep ones are the manifold channels;
8shallow ones are seal grooves, reported and ignored), the recess in the chamber face (the groove
9the orifices exit), a hole on the axis if there is one, and gland recesses on the rim.
10
11Only geometry the engine design determines is used; seal features are the designer's and are
12listed, never interpreted. Orifices and feed ports are not revolved features and are not here.
13"""
14from __future__ import annotations
15
16import math
17from typing import Any, Dict, List, Tuple
18
19_UNIT_M = {0: None, 1: 0.0254, 2: 0.3048, 4: 0.001, 5: 0.01, 6: 1.0}
20#: A back-face recess deeper than this fraction of the plate thickness is a manifold channel;
21#: shallower ones (seal grooves) are listed and not modelled.
22CHANNEL_MIN_DEPTH_FRAC = 0.25
23
24
25def _arc_points(cx, cy, r, a0, a1, step_deg=5.0):
26 a0, a1 = math.radians(a0), math.radians(a1)
27 if a1 <= a0:
28 a1 += 2 * math.pi
29 n = max(2, int(math.ceil((a1 - a0) / math.radians(step_deg))) + 1)
30 return [(cx + r * math.cos(a0 + (a1 - a0) * i / (n - 1)),
31 cy + r * math.sin(a0 + (a1 - a0) * i / (n - 1))) for i in range(n)]
32
33
34def _segments(path: str) -> Tuple[List[List[Tuple[float, float]]], float]:
35 import ezdxf
36 doc = ezdxf.readfile(path)
37 unit = _UNIT_M.get(int(doc.header.get("$INSUNITS", 0) or 0))
38 if unit is None:
39 raise ValueError(f"{path}: $INSUNITS is not set; export with units (inches or mm)")
40 segs = []
41 for e in doc.modelspace():
42 t = e.dxftype()
43 if t == "LINE":
44 segs.append([tuple(e.dxf.start)[:2], tuple(e.dxf.end)[:2]])
45 elif t == "ARC":
46 c = e.dxf.center
47 segs.append(_arc_points(c[0], c[1], e.dxf.radius, e.dxf.start_angle, e.dxf.end_angle))
48 elif t == "LWPOLYLINE":
49 pts = [tuple(p[:2]) for p in e.get_points()]
50 if e.closed:
51 pts.append(pts[0])
52 segs.append(pts)
53 return segs, unit
54
55
56def _chain(segs, tol):
57 """Join segments end to end into one closed loop."""
58 segs = [list(s) for s in segs]
59 loop = segs.pop(0)
60 close = lambda a, b: abs(a[0] - b[0]) < tol and abs(a[1] - b[1]) < tol
61 while segs:
62 for i, s in enumerate(segs):
63 if close(s[0], loop[-1]):
64 loop += s[1:]
65 elif close(s[-1], loop[-1]):
66 loop += s[::-1][1:]
67 else:
68 continue
69 segs.pop(i)
70 break
71 else:
72 raise ValueError("the profile is not one closed loop (a gap or a second loop)")
73 if not close(loop[0], loop[-1]):
74 raise ValueError("the profile does not close")
75 return loop[:-1]
76
77
78def _area(poly):
79 return 0.5 * abs(sum(poly[i][0] * poly[(i + 1) % len(poly)][1] - poly[(i + 1) % len(poly)][0] * poly[i][1]
80 for i in range(len(poly))))
81
82
83def _recesses(loop, on_line, depth_of, full):
84 """Runs of the loop that leave a boundary line and come back: (vertices, r-range, depth, area).
85 The run that wraps round the rest of the profile (depth reaching ``full``) is not a recess."""
86 n = len(loop)
87 idx = [i for i in range(n) if on_line(loop[i])]
88 out = []
89 for a, b in zip(idx, idx[1:] + [idx[0] + n]):
90 if b - a <= 1:
91 continue
92 run = [loop[k % n] for k in range(a, b + 1)]
93 inner = run[1:-1]
94 if not inner or all(on_line(p) for p in inner):
95 continue
96 depth = max(depth_of(p) for p in inner)
97 if depth <= 1e-9 or depth >= full * (1.0 - 1e-6):
98 continue
99 out.append({"pts": run, "depth": depth, "area": _area(run),
100 "r_lo": min(p[0] for p in run), "r_hi": max(p[0] for p in run)})
101 return out
102
103
104def read_plate_profile(path: str) -> Dict[str, Any]:
105 """The plate's revolved half-section, in metres. See the module docstring."""
106 segs, unit = _segments(path)
107 if not segs:
108 raise ValueError(f"{path}: no LINE/ARC/LWPOLYLINE entities")
109 pts = [p for s in segs for p in s]
110 span = max(max(abs(p[0]) for p in pts), max(abs(p[1]) for p in pts))
111 loop = _chain(segs, tol=1e-6 * max(span, 1.0))
112 # Radius is the sketch axis on which the profile starts at 0 and never goes negative.
113 xs, ys = [p[0] for p in loop], [p[1] for p in loop]
114 if min(xs) > -1e-9 and (min(ys) < -1e-9 or max(xs) - min(xs) > max(ys) - min(ys)):
115 rz = [(x * unit, y * unit) for x, y in loop]
116 else:
117 rz = [(y * unit, x * unit) for x, y in loop]
118 tol = 1e-6
119 zs = [z for _, z in rz]
120 z0, z1 = min(zs), max(zs)
121 R = max(r for r, _ in rz)
122 t = z1 - z0
123 face_recs = _recesses(rz, lambda p: abs(p[1] - z0) < tol, lambda p: p[1] - z0, t)
124 back_recs = _recesses(rz, lambda p: abs(p[1] - z1) < tol, lambda p: z1 - p[1], t)
125 # Which side is the back: the one with the deep recesses (the channels).
126 deep = lambda recs: [r for r in recs if r["depth"] > CHANNEL_MIN_DEPTH_FRAC * t]
127 if deep(face_recs) and not deep(back_recs):
128 face_recs, back_recs = back_recs, face_recs
129 flip = True
130 else:
131 flip = False
132 channels = sorted(deep(back_recs), key=lambda r: r["r_lo"])
133 seals = [r for r in back_recs if r["depth"] <= CHANNEL_MIN_DEPTH_FRAC * t]
134 rim = _recesses(rz, lambda p: abs(p[0] - R) < tol, lambda p: R - p[0], R)
135 axis_touch = any(abs(r) < tol for r, _ in rz)
136 face_line = z1 if flip else z0
137
138 out_channels = []
139 for ch in channels:
140 # Metal left under the channel: thickness minus the deepest floor point.
141 out_channels.append({
142 "r_lo": ch["r_lo"], "r_hi": ch["r_hi"], "r_center": 0.5 * (ch["r_lo"] + ch["r_hi"]),
143 "width": ch["r_hi"] - ch["r_lo"], "depth": ch["depth"], "flow_area": ch["area"],
144 "ligament_min": t - ch["depth"],
145 "hydraulic_diameter": 4.0 * ch["area"] / _perimeter_wetted(ch["pts"], closed_by=ch["r_hi"] - ch["r_lo"]),
146 "floor": [(p[0], abs((z1 if not flip else z0) - p[1])) for p in ch["pts"]],
147 # Radius of the section's centroid: the ring the flow goes round.
148 "r_centroid": _centroid_r(ch["pts"]),
149 "_pts": ch["pts"],
150 })
151 face_groove = None
152 if face_recs:
153 g = max(face_recs, key=lambda r: r["area"])
154 pts = g["pts"]
155 flanks = []
156 for (ra, za), (rb, zb) in zip(pts, pts[1:]):
157 dr, dz = rb - ra, zb - za
158 if abs(dr) > 1e-9 and abs(dz) > 1e-9:
159 mid = (0.5 * (ra + rb), abs(0.5 * (za + zb) - face_line))
160 flanks.append({"r_mid": mid[0], "depth_mid": mid[1],
161 "angle_from_face_deg": math.degrees(math.atan2(abs(dz), abs(dr))),
162 "length": math.hypot(dr, dz)})
163 face_groove = {"r_lo": g["r_lo"], "r_hi": g["r_hi"], "depth": g["depth"], "flanks": flanks}
164 return {
165 "source": path, "units_m": unit, "plate_radius": R, "thickness": t,
166 "axis_hole": not axis_touch, "channels": out_channels, "face_groove": face_groove,
167 "seal_grooves_back": [{"r_lo": s["r_lo"], "r_hi": s["r_hi"], "depth": s["depth"]} for s in seals],
168 "rim_recesses": [{"depth": s["depth"], "z_lo": min(p[1] for p in s["pts"]),
169 "z_hi": max(p[1] for p in s["pts"])} for s in rim],
170 "loop": rz,
171 "_face_line": face_line,
172 "_face_groove_pts": (max(face_recs, key=lambda r: r["area"])["pts"] if face_recs else None),
173 }
174
175
176def _centroid_r(poly):
177 a = cx = 0.0
178 for i in range(len(poly)):
179 (x0, y0), (x1, y1) = poly[i], poly[(i + 1) % len(poly)]
180 cr = x0 * y1 - x1 * y0
181 a += cr
182 cx += (x0 + x1) * cr
183 return cx / (3.0 * a) if abs(a) > 1e-30 else 0.5 * (min(p[0] for p in poly) + max(p[0] for p in poly))
184
185
186def _perimeter_wetted(pts, closed_by):
187 """Wetted perimeter of a channel closed by the cover across its mouth."""
188 per = sum(math.hypot(b[0] - a[0], b[1] - a[1]) for a, b in zip(pts, pts[1:]))
189 return per + closed_by
190
191
192def ring_channel(profile: Dict[str, Any], r_hole_entry: float) -> Dict[str, Any]:
193 """The channel a ring's holes break into: the one whose radial span holds the entry radius,
194 else the nearest."""
195 chs = profile["channels"]
196 if not chs:
197 raise ValueError("the plate profile has no back-face channels")
198 inside = [c for c in chs if c["r_lo"] - 1e-9 <= r_hole_entry <= c["r_hi"] + 1e-9]
199 return inside[0] if inside else min(chs, key=lambda c: abs(c["r_center"] - r_hole_entry))
200
201
202def _sloped(pts, face_line):
203 """The sloped straight segments of a run, as (A, B, angle from the face plane [deg]) in
204 (r, depth-from-face) coordinates."""
205 out = []
206 for (ra, za), (rb, zb) in zip(pts, pts[1:]):
207 dr, dz = rb - ra, zb - za
208 if abs(dr) > 1e-9 and abs(dz) > 1e-9:
209 A, B = (ra, abs(za - face_line)), (rb, abs(zb - face_line))
210 out.append((A, B, math.degrees(math.atan2(abs(dz), abs(dr)))))
211 return out
212
213
214def drilled_passages(profile: Dict[str, Any], angles: Dict[str, float], *, tol_deg: float = 1.0
215 ) -> Dict[str, Dict[str, Any]]:
216 """Where each ring's orifice runs, from the drawing alone.
217
218 A drilled passage is not a revolved feature, but its two ends are: the channel facet it is
219 drilled square into and the face-groove flank it leaves through, both inclined at the jet
220 angle. The passage is the facet's normal through the facet's midpoint -- where a drill spotted
221 square on that facet goes -- until it meets the flank of the same angle. ``angles``:
222 ``{"O": deg, "F": deg}`` from the chamber axis. A side with no facet/flank pair at its angle
223 is left out (that drawing does not locate its passage)."""
224 face_line = profile["_face_line"]
225 flanks = _sloped(profile["_face_groove_pts"], face_line) if profile.get("_face_groove_pts") else []
226 out: Dict[str, Dict[str, Any]] = {}
227 for k, th in angles.items():
228 th = float(th)
229 s, c = math.sin(math.radians(th)), math.cos(math.radians(th))
230 for ci, ch in enumerate(profile["channels"]):
231 for (A, B, ang) in _sloped(ch["_pts"], face_line):
232 if abs(ang - th) > tol_deg:
233 continue
234 P = (0.5 * (A[0] + B[0]), 0.5 * (A[1] + B[1]))
235 for (F0, F1, fang) in flanks:
236 if abs(fang - th) > tol_deg:
237 continue
238 for sign in (1.0, -1.0):
239 u = (sign * s, -c) # toward the face
240 e = (F1[0] - F0[0], F1[1] - F0[1])
241 den = u[0] * (-e[1]) - u[1] * (-e[0])
242 if abs(den) < 1e-15:
243 continue
244 w = (F0[0] - P[0], F0[1] - P[1])
245 t = (w[0] * (-e[1]) - w[1] * (-e[0])) / den
246 v = (u[0] * w[1] - u[1] * w[0]) / den
247 if t <= 0 or not (-1e-6 <= v <= 1 + 1e-6):
248 continue
249 E = (P[0] + t * u[0], P[1] + t * u[1])
250 out[k] = {
251 "exit_r": E[0], "exit_depth": E[1], "entry_r": P[0], "entry_depth": P[1],
252 "length": t, "channel": ci, "facet_deg": ang, "flank_deg": fang,
253 "entry_off_square_deg": abs(ang - th),
254 "flank": (F0, F1), "facet": (A, B),
255 # Slant from the exit centre to each edge of the flank it leaves by.
256 "flank_slant": (math.hypot(E[0] - F0[0], E[1] - F0[1]),
257 math.hypot(E[0] - F1[0], E[1] - F1[1])),
258 "inward": sign < 0,
259 }
260 return out
261
262
263_CACHE: Dict[Tuple[str, float], Dict[str, Any]] = {}
264
265
266def resolve_path(path: str) -> str:
267 """A drawing path as written in a config: absolute, else relative to the working directory,
268 else to the EngineDesign root."""
269 import os
270 if os.path.isabs(path) or os.path.exists(path):
271 return os.path.abspath(path)
272 root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
273 return os.path.join(root, path)
274
275
276def load_plate_profile(path: str) -> Dict[str, Any]:
277 """``read_plate_profile``, cached on the file's path and modification time."""
278 import os
279 p = resolve_path(path)
280 key = (p, os.path.getmtime(p))
281 if key not in _CACHE:
282 _CACHE.clear()
283 _CACHE[key] = read_plate_profile(p)
284 return _CACHE[key]
285
286
287def section_thickness(profile: Dict[str, Any], r: Any) -> Any:
288 """Metal on the axial line at each radius r [m]: the length of that line inside the profile
289 (the whole plate, less the face groove, a channel, a seal groove, whatever is cut there)."""
290 return loop_section(profile["loop"], r)
291
292
293def loop_section(loop: List[Tuple[float, float]], r: Any) -> Any:
294 """``section_thickness`` of a closed (r, z) loop."""
295 import numpy as np
296 rr = np.atleast_1d(np.asarray(r, dtype=float))
297 out = np.zeros_like(rr)
298 n = len(loop)
299 for i, x in enumerate(rr):
300 zs = []
301 for j in range(n):
302 (r0, z0), (r1, z1) = loop[j], loop[(j + 1) % n]
303 if (r0 <= x < r1) or (r1 <= x < r0):
304 zs.append(z0 + (z1 - z0) * (x - r0) / (r1 - r0))
305 zs.sort()
306 out[i] = sum(zs[k + 1] - zs[k] for k in range(0, len(zs) - 1, 2))
307 return out if np.ndim(r) else float(out[0])
308
309
310def loop_deviation(a: List[Tuple[float, float]], b: List[Tuple[float, float]], *, step: float = 5e-5
311 ) -> Dict[str, Any]:
312 """How far apart two closed outlines are: for points every ``step`` along each, the distance
313 to the other outline. ``max`` is the Hausdorff distance; ``where_a`` / ``where_b`` the points
314 on each that sit farthest from the other."""
315 import numpy as np
316
317 def samples(L):
318 P = np.asarray(L + L[:1], dtype=float)
319 out = [P[0:1]]
320 for i in range(len(P) - 1):
321 seg = P[i + 1] - P[i]
322 n = max(1, int(math.ceil(np.hypot(*seg) / step)))
323 out.append(P[i] + np.outer(np.arange(1, n + 1) / n, seg))
324 return np.vstack(out)
325
326 def dist(Q, L):
327 P = np.asarray(L + L[:1], dtype=float)
328 A, B = P[:-1], P[1:]
329 AB = B - A
330 L2 = np.maximum((AB ** 2).sum(1), 1e-30)
331 best = np.full(len(Q), np.inf)
332 for j in range(len(A)):
333 tt = np.clip(((Q - A[j]) @ AB[j]) / L2[j], 0.0, 1.0)
334 d = np.hypot(*(Q - (A[j] + np.outer(tt, AB[j]))).T)
335 best = np.minimum(best, d)
336 return best
337
338 Sa, Sb = samples(list(a)), samples(list(b))
339 da, db = dist(Sa, list(b)), dist(Sb, list(a))
340 ia, ib = int(np.argmax(da)), int(np.argmax(db))
341 return {"max": float(max(da[ia], db[ib])), "a_to_b": float(da[ia]), "b_to_a": float(db[ib]),
342 "where_a": tuple(float(v) for v in Sa[ia]), "where_b": tuple(float(v) for v in Sb[ib])}
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
Dict[str, Any] loop_deviation(List[Tuple[float, float]] a, List[Tuple[float, float]] b, *, float step=5e-5)
Definition plate_dxf.py:311
Dict[str, Dict[str, Any]] drilled_passages(Dict[str, Any] profile, Dict[str, float] angles, *, float tol_deg=1.0)
Definition plate_dxf.py:215
_perimeter_wetted(pts, closed_by)
Definition plate_dxf.py:186
_recesses(loop, on_line, depth_of, full)
Definition plate_dxf.py:83
Dict[str, Any] ring_channel(Dict[str, Any] profile, float r_hole_entry)
Definition plate_dxf.py:192
Dict[str, Any] read_plate_profile(str path)
Definition plate_dxf.py:104
Dict[str, Any] load_plate_profile(str path)
Definition plate_dxf.py:276
_arc_points(cx, cy, r, a0, a1, step_deg=5.0)
Definition plate_dxf.py:25
Any loop_section(List[Tuple[float, float]] loop, Any r)
Definition plate_dxf.py:293
Tuple[List[List[Tuple[float, float]]], float] _segments(str path)
Definition plate_dxf.py:34
Any section_thickness(Dict[str, Any] profile, Any r)
Definition plate_dxf.py:287