1"""Read the injector plate's revolved half-section from a CAD DXF.
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.
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.
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.
14from __future__
import annotations
17from typing
import Any, Dict, List, Tuple
19_UNIT_M = {0:
None, 1: 0.0254, 2: 0.3048, 4: 0.001, 5: 0.01, 6: 1.0}
22CHANNEL_MIN_DEPTH_FRAC = 0.25
26 a0, a1 = math.radians(a0), math.radians(a1)
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)]
34def _segments(path: str) -> Tuple[List[List[Tuple[float, float]]], float]:
36 doc = ezdxf.readfile(path)
37 unit = _UNIT_M.get(int(doc.header.get(
"$INSUNITS", 0)
or 0))
39 raise ValueError(f
"{path}: $INSUNITS is not set; export with units (inches or mm)")
41 for e
in doc.modelspace():
44 segs.append([tuple(e.dxf.start)[:2], tuple(e.dxf.end)[:2]])
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()]
57 """Join segments end to end into one closed loop."""
58 segs = [list(s)
for s
in segs]
60 close =
lambda a, b: abs(a[0] - b[0]) < tol
and abs(a[1] - b[1]) < tol
62 for i, s
in enumerate(segs):
63 if close(s[0], loop[-1]):
65 elif close(s[-1], loop[-1]):
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")
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))))
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."""
87 idx = [i
for i
in range(n)
if on_line(loop[i])]
89 for a, b
in zip(idx, idx[1:] + [idx[0] + n]):
92 run = [loop[k % n]
for k
in range(a, b + 1)]
94 if not inner
or all(on_line(p)
for p
in inner):
96 depth =
max(depth_of(p)
for p
in inner)
97 if depth <= 1e-9
or depth >= full * (1.0 - 1e-6):
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)})
105 """The plate's revolved half-section, in metres. See the module docstring."""
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))
113 xs, ys = [p[0]
for p
in loop], [p[1]
for p
in loop]
115 rz = [(x * unit, y * unit)
for x, y
in loop]
117 rz = [(y * unit, x * unit)
for x, y
in loop]
119 zs = [z
for _, z
in rz]
121 R =
max(r
for r, _
in rz)
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)
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
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
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"]],
153 g =
max(face_recs, key=
lambda r: r[
"area"])
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}
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],
171 "_face_line": face_line,
172 "_face_groove_pts": (
max(face_recs, key=
lambda r: r[
"area"])[
"pts"]
if face_recs
else None),
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
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))
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
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,
195 chs = profile[
"channels"]
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))
203 """The sloped straight segments of a run, as (A, B, angle from the face plane [deg]) in
204 (r, depth-from-face) coordinates."""
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)))))
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.
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():
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:
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:
238 for sign
in (1.0, -1.0):
240 e = (F1[0] - F0[0], F1[1] - F0[1])
241 den = u[0] * (-e[1]) - u[1] * (-e[0])
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):
249 E = (P[0] + t * u[0], P[1] + t * u[1])
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),
256 "flank_slant": (math.hypot(E[0] - F0[0], E[1] - F0[1]),
257 math.hypot(E[0] - F1[0], E[1] - F1[1])),
263_CACHE: Dict[Tuple[str, float], Dict[str, Any]] = {}
267 """A drawing path as written in a config: absolute, else relative to the working directory,
268 else to the EngineDesign root."""
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)
277 """``read_plate_profile``, cached on the file's path and modification time."""
280 key = (p, os.path.getmtime(p))
281 if key
not in _CACHE:
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)."""
294 """``section_thickness`` of a closed (r, z) loop."""
296 rr = np.atleast_1d(np.asarray(r, dtype=float))
297 out = np.zeros_like(rr)
299 for i, x
in enumerate(rr):
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))
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])
310def loop_deviation(a: List[Tuple[float, float]], b: List[Tuple[float, float]], *, step: float = 5e-5
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."""
318 P = np.asarray(L + L[:1], dtype=float)
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)
327 P = np.asarray(L + L[:1], dtype=float)
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)
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])}
Dict[str, Any] loop_deviation(List[Tuple[float, float]] a, List[Tuple[float, float]] b, *, float step=5e-5)
Dict[str, Dict[str, Any]] drilled_passages(Dict[str, Any] profile, Dict[str, float] angles, *, float tol_deg=1.0)
_perimeter_wetted(pts, closed_by)
_recesses(loop, on_line, depth_of, full)
Dict[str, Any] ring_channel(Dict[str, Any] profile, float r_hole_entry)
Dict[str, Any] read_plate_profile(str path)
Dict[str, Any] load_plate_profile(str path)
_arc_points(cx, cy, r, a0, a1, step_deg=5.0)
str resolve_path(str path)
Any loop_section(List[Tuple[float, float]] loop, Any r)
Tuple[List[List[Tuple[float, float]]], float] _segments(str path)
Any section_thickness(Dict[str, Any] profile, Any r)