cycloidgen 7.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.
Files changed (74) hide show
  1. cycloidgen/__init__.py +11 -0
  2. cycloidgen/__main__.py +344 -0
  3. cycloidgen/analysis/__init__.py +490 -0
  4. cycloidgen/analysis/bearings.py +719 -0
  5. cycloidgen/analysis/compliance.py +256 -0
  6. cycloidgen/analysis/efficiency.py +177 -0
  7. cycloidgen/analysis/fatigue.py +311 -0
  8. cycloidgen/analysis/lubrication.py +415 -0
  9. cycloidgen/analysis/mass.py +226 -0
  10. cycloidgen/analysis/mechanics.py +159 -0
  11. cycloidgen/analysis/stiffness.py +779 -0
  12. cycloidgen/analysis/thermal.py +245 -0
  13. cycloidgen/analysis/tolerance.py +108 -0
  14. cycloidgen/core/__init__.py +1 -0
  15. cycloidgen/core/designfile.py +88 -0
  16. cycloidgen/core/explain.py +616 -0
  17. cycloidgen/core/guide.py +471 -0
  18. cycloidgen/core/kinematics.py +237 -0
  19. cycloidgen/core/profile.py +290 -0
  20. cycloidgen/core/spec.py +910 -0
  21. cycloidgen/core/validate.py +297 -0
  22. cycloidgen/design/__init__.py +26 -0
  23. cycloidgen/design/batch.py +386 -0
  24. cycloidgen/design/optimize.py +621 -0
  25. cycloidgen/design/sweep.py +187 -0
  26. cycloidgen/export/__init__.py +92 -0
  27. cycloidgen/export/animation.py +358 -0
  28. cycloidgen/export/bom.py +183 -0
  29. cycloidgen/export/dxf.py +261 -0
  30. cycloidgen/export/manifest.py +199 -0
  31. cycloidgen/export/solid.py +397 -0
  32. cycloidgen/export/svg.py +75 -0
  33. cycloidgen/report/__init__.py +1 -0
  34. cycloidgen/report/build.py +737 -0
  35. cycloidgen/report/plots.py +732 -0
  36. cycloidgen/ui/__init__.py +1 -0
  37. cycloidgen/ui/app.py +54 -0
  38. cycloidgen/ui/assets/chevron-down-dark.png +0 -0
  39. cycloidgen/ui/assets/chevron-down-light.png +0 -0
  40. cycloidgen/ui/assets/chevron-up-dark.png +0 -0
  41. cycloidgen/ui/assets/chevron-up-light.png +0 -0
  42. cycloidgen/ui/assets/cycloidgen.ico +0 -0
  43. cycloidgen/ui/assets/mark-black.png +0 -0
  44. cycloidgen/ui/assets/mark-blue.png +0 -0
  45. cycloidgen/ui/assets/mark-white.png +0 -0
  46. cycloidgen/ui/assets/tick.png +0 -0
  47. cycloidgen/ui/assets/wordmark-black.png +0 -0
  48. cycloidgen/ui/assets/wordmark-blue.png +0 -0
  49. cycloidgen/ui/assets/wordmark-white.png +0 -0
  50. cycloidgen/ui/branding.py +637 -0
  51. cycloidgen/ui/fields.py +282 -0
  52. cycloidgen/ui/history.py +65 -0
  53. cycloidgen/ui/logpanel.py +315 -0
  54. cycloidgen/ui/main_window.py +2356 -0
  55. cycloidgen/ui/optimise_dialog.py +430 -0
  56. cycloidgen/ui/outputs.py +279 -0
  57. cycloidgen/ui/plotbar.py +100 -0
  58. cycloidgen/ui/settings.py +33 -0
  59. cycloidgen/ui/tables.py +56 -0
  60. cycloidgen/ui/tradestudy.py +278 -0
  61. cycloidgen/ui/view3d.py +600 -0
  62. cycloidgen/ui/view3d_qtgl.py +412 -0
  63. cycloidgen/ui/view3d_vtk.py +625 -0
  64. cycloidgen/units.py +70 -0
  65. cycloidgen/viz/__init__.py +14 -0
  66. cycloidgen/viz/mesh.py +697 -0
  67. cycloidgen/viz/scene.py +233 -0
  68. cycloidgen/viz/vtkbridge.py +235 -0
  69. cycloidgen-7.1.0.dist-info/METADATA +943 -0
  70. cycloidgen-7.1.0.dist-info/RECORD +74 -0
  71. cycloidgen-7.1.0.dist-info/WHEEL +5 -0
  72. cycloidgen-7.1.0.dist-info/entry_points.txt +5 -0
  73. cycloidgen-7.1.0.dist-info/licenses/LICENSE +202 -0
  74. cycloidgen-7.1.0.dist-info/top_level.txt +1 -0
cycloidgen/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ """Parametric cycloidal drive generator."""
2
+
3
+ #: The one place the version is written.
4
+ #:
5
+ #: `pyproject.toml` reads it from here (`dynamic = ["version"]`), the
6
+ #: PyInstaller spec stamps it into the executable, and `packaging/cycloidgen.nsi`
7
+ #: parses this exact line with `!searchparse`. Keep it a plain string literal
8
+ #: assignment on one line: setuptools reads it statically, without importing the
9
+ #: package, and NSIS reads it with a text match. Anything cleverer - a computed
10
+ #: string, a tuple, an import - breaks both.
11
+ __version__ = "7.1.0"
cycloidgen/__main__.py ADDED
@@ -0,0 +1,344 @@
1
+ """``python -m cycloidgen`` - launch the desktop app, or work from the CLI.
2
+
3
+ python -m cycloidgen # GUI
4
+ python -m cycloidgen --ratio 29 --out ./x # headless export
5
+ python -m cycloidgen --ratio 29 --list-outputs # what an export would write
6
+ python -m cycloidgen --optimise --ratio 29 --torque 20 --max-od 120 --out ./x
7
+ python -m cycloidgen --ratio 21 --vary disc_count=1 --vary disc_count=2 \
8
+ --vary output_pin_count=8:16:5 --csv study.csv # parameter study
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import sys
14
+ from pathlib import Path
15
+
16
+
17
+ def _spec_from_args(args):
18
+ """The design a headless run works on: a saved file if given, else a preset."""
19
+ import json
20
+
21
+ from .core.designfile import numbers_may_have_moved, provenance, spec_from_dict, written_by
22
+ from .core.spec import preset
23
+ if args.design:
24
+ data = json.loads(args.design.read_text(encoding="utf-8"))
25
+ spec = spec_from_dict(data)
26
+ # No dialog to put it in out here, and a headless run is the one most
27
+ # likely to end up in a script whose output nobody reads twice.
28
+ written = written_by(data)
29
+ if numbers_may_have_moved(written):
30
+ print(provenance(written), file=sys.stderr)
31
+ else:
32
+ spec = preset(args.ratio or 15)
33
+ return _apply_omissions(spec, args)
34
+
35
+
36
+ def _apply_omissions(spec, args):
37
+ """Take out the bearings the caller says this drive does not have.
38
+
39
+ Only ever subtractive - the flags are ``--no-...`` - so applying them over a
40
+ saved design can remove a bearing it had and never put one back that it did
41
+ not, which is the only reading of a command line that is not a surprise.
42
+ """
43
+ if args.no_cam_bearing:
44
+ spec.cam_bearing_fitted = False
45
+ if args.no_shaft_bearings:
46
+ spec.shaft_bearings_fitted = False
47
+ if args.no_output_bearing:
48
+ spec.output_bearing_fitted = False
49
+ return spec
50
+
51
+
52
+ def _list_outputs(spec, groups: set[str]) -> int:
53
+ """Print the bundle without producing it.
54
+
55
+ Straight off the manifest, which is also what ``write_bundle`` walks, so
56
+ this is a promise the exporter keeps rather than a table someone wrote once.
57
+ """
58
+ from .export.manifest import GROUPS, outputs_for
59
+
60
+ print(f"An export of this {spec.ratio}:1 design writes:\n")
61
+ total = 0
62
+ for group in GROUPS:
63
+ mark = "x" if group.key in groups else " "
64
+ print(f"[{mark}] {group.title} - {group.note}")
65
+ for out in outputs_for({group.key}):
66
+ names = out.files(spec)
67
+ print(f" {out.fmt:<5} {out.where:<16} {out.title}")
68
+ if out.is_folder:
69
+ for name in names:
70
+ print(f" {name}")
71
+ total += len(names) if group.key in groups else 0
72
+ print()
73
+ print(f"{total} file(s) with the current selection.")
74
+ return 0
75
+
76
+
77
+ def _batch(args, spec) -> int:
78
+ """Run the parameter study and print it, or write it, and stop there.
79
+
80
+ A study is numbers rather than parts: four hundred designs are four hundred
81
+ answers and one folder of STEP files nobody asked for, so this deliberately
82
+ does not export. ``--out`` with ``--vary`` is refused rather than ignored,
83
+ because ignoring it would look like it had worked.
84
+ """
85
+ import sys as _sys
86
+
87
+ from .design.batch import METRICS, as_text, merge_axes, parse_axis, run_batch, write_csv
88
+
89
+ if args.out:
90
+ print("--out writes one design; --vary evaluates many. Use --csv for a "
91
+ "study, or drop --vary to export this design.", file=_sys.stderr)
92
+ return 2
93
+
94
+ try:
95
+ axes = merge_axes([parse_axis(*_split_vary(text)) for text in args.vary])
96
+ except ValueError as exc:
97
+ print(exc, file=_sys.stderr)
98
+ return 2
99
+
100
+ total = 1
101
+ for axis in axes:
102
+ total *= len(axis)
103
+ plan = " x ".join(f"{len(a)} {a.field}" for a in axes)
104
+ print(f"{total} design(s): {plan}", file=_sys.stderr)
105
+
106
+ def tick(done: int, of: int) -> None:
107
+ if of > 20 and (done % max(1, of // 20) == 0 or done == of):
108
+ print(f"\r {done}/{of}", end="", file=_sys.stderr, flush=True)
109
+
110
+ points = run_batch(spec, axes, progress=tick)
111
+ if total > 20:
112
+ print(file=_sys.stderr)
113
+
114
+ if args.csv:
115
+ path = write_csv(points, axes, args.csv)
116
+ built = sum(1 for p in points if p.ok)
117
+ print(f"wrote {len(points)} row(s) to {path} ({built} built, "
118
+ f"{len(points) - built} blocked)")
119
+ return 0
120
+
121
+ # No file asked for, so this goes on the terminal - and the whole table is
122
+ # too wide for one. The five shown are the five the calibration plan
123
+ # measures on real hardware, which is why they are the first five in
124
+ # METRICS rather than a second list kept here.
125
+ shown = METRICS[:5]
126
+ header = [a.field for a in axes] + ["ok"] + [m.name for m in shown]
127
+ rows = [[as_text(p.values[a.field]) for a in axes]
128
+ + ["yes" if p.ok else "no"]
129
+ + [f"{p.metrics.get(m.name, float('nan')):.4g}" for m in shown]
130
+ for p in points]
131
+
132
+ # Sized to what is in them. A lubricant name is 24 characters and a fixed
133
+ # width either truncates it into two rows that read as the same design or
134
+ # pushes every column out to fit the longest thing in the app.
135
+ widths = [max(len(header[i]), *(len(r[i]) for r in rows)) if rows
136
+ else len(header[i]) for i in range(len(header))]
137
+ line = " ".join(h.rjust(w) for h, w in zip(header, widths, strict=True))
138
+ print(line)
139
+ print("-" * len(line))
140
+ for row in rows:
141
+ print(" ".join(cell.rjust(w) for cell, w in zip(row, widths, strict=True)))
142
+ print(f"\n{len(METRICS)} metrics per design; --csv writes all of them.")
143
+ return 0
144
+
145
+
146
+ def _split_vary(text: str) -> tuple[str, str]:
147
+ """``field=value`` into its two halves, splitting on the *first* ``=``.
148
+
149
+ Values can contain one - a bearing designation or a lubricant name is
150
+ somebody else's naming scheme, not ours to constrain.
151
+ """
152
+ name, sep, value = text.partition("=")
153
+ if not sep:
154
+ raise ValueError(f"--vary wants field=value, not {text!r}")
155
+ return name.strip(), value
156
+
157
+
158
+ def _search(args) -> tuple[int, object | None]:
159
+ """Run the requirements search and print the shortlist.
160
+
161
+ Returns ``(exit_code, spec)``; the spec is the winner, ready to export.
162
+ """
163
+ from .core.spec import MATERIALS, Process
164
+ from .design import Objective, Requirements, optimise
165
+
166
+ if args.disc_material not in MATERIALS:
167
+ print(f"unknown material {args.disc_material!r}; choose from "
168
+ + ", ".join(MATERIALS), file=sys.stderr)
169
+ return 2, None
170
+
171
+ req = Requirements(
172
+ ratio=args.ratio or 29,
173
+ output_torque_Nm=args.torque,
174
+ input_rpm=args.rpm,
175
+ max_outer_diameter_mm=args.max_od,
176
+ max_length_mm=args.max_length,
177
+ process=Process(args.process),
178
+ disc_material=args.disc_material,
179
+ pin_material=args.pin_material,
180
+ housing_material=args.housing_material,
181
+ ring_pins_are_rollers=args.rollers,
182
+ output_pins_are_rollers=args.rollers,
183
+ cam_bearing_fitted=not args.no_cam_bearing,
184
+ shaft_bearings_fitted=not args.no_shaft_bearings,
185
+ output_bearing_fitted=not args.no_output_bearing,
186
+ min_safety_factor=args.min_safety,
187
+ objective=Objective(args.objective),
188
+ disc_count=args.discs,
189
+ )
190
+ print(f"searching for a {req.ratio}:1 drive, {req.output_torque_Nm:g} Nm out, "
191
+ f"under {req.max_outer_diameter_mm:g} mm across, "
192
+ f"optimising for {req.objective.value}...\n")
193
+ result = optimise(req, effort=args.effort)
194
+
195
+ if not result.ok:
196
+ print(f"nothing met those requirements after {result.evaluations} "
197
+ f"candidates.\nwhat stopped them: {result.tally.explain()}",
198
+ file=sys.stderr)
199
+ return 3, None
200
+
201
+ header = (f"{'#':>2} {'OD':>6} {'len':>6} {'capacity':>9} {'margin':>7} "
202
+ f"{'eff':>6} {'mass':>7} {'backlash':>9} {'temp':>6}")
203
+ print(header)
204
+ print("-" * len(header))
205
+ for i, c in enumerate(result.best, 1):
206
+ print(f"{i:>2} {c.outer_diameter_mm:6.1f} {c.length_mm:6.1f} "
207
+ f"{c.capacity_Nm:8.2f}N {c.margin:6.2f}x {100 * c.efficiency:5.1f}% "
208
+ f"{c.mass_g:6.0f}g {c.lost_motion_arcmin:8.1f}' "
209
+ f"{c.temperature_C:5.0f}C")
210
+ best = result.best[0].spec
211
+ print(f"\ntaking #1: R={best.pin_circle_radius:.2f} Rr={best.pin_radius:.2f} "
212
+ f"E={best.eccentricity:.3f} K1={best.K1:.3f}, "
213
+ f"{best.disc_count} x {best.disc_thickness:.1f} mm disc(s), "
214
+ f"{best.output_pin_count} x {best.output_pin_diameter:.1f} mm output pins")
215
+ return 0, best
216
+
217
+
218
+ def main(argv: list[str] | None = None) -> int:
219
+ from . import __version__
220
+
221
+ parser = argparse.ArgumentParser(prog="cycloidgen",
222
+ description="Cycloidal drive generator")
223
+ parser.add_argument("--version", action="version",
224
+ version=f"cycloidgen {__version__}")
225
+ parser.add_argument("--ratio", type=int, help="generate a preset and exit")
226
+ parser.add_argument("--design", type=Path, help="load a saved design JSON")
227
+ parser.add_argument("--out", type=Path, help="output folder for a headless run")
228
+ parser.add_argument("--no-solids", action="store_true",
229
+ help="skip STEP/STL, write drawings and report only")
230
+ parser.add_argument("--only", metavar="GROUPS",
231
+ help="write only these output groups, comma separated: "
232
+ "drawings, solids, data")
233
+ parser.add_argument("--list-outputs", action="store_true",
234
+ help="print every file an export would write, and exit")
235
+
236
+ study = parser.add_argument_group(
237
+ "parameter study", "evaluate a grid of designs and get a table out "
238
+ "instead of a folder of parts")
239
+ study.add_argument("--vary", action="append", default=[], metavar="FIELD=VALUE",
240
+ help="a parameter and a value to put the design through. "
241
+ "Repeat it for more values of the same field, or for "
242
+ "another field - every combination is evaluated. "
243
+ "Numeric fields also take a lo:hi:steps range")
244
+ study.add_argument("--csv", type=Path, metavar="PATH",
245
+ help="write the full table here instead of a summary to "
246
+ "the terminal")
247
+
248
+ built = parser.add_argument_group(
249
+ "bearings fitted", "three of the five load paths can be built without a "
250
+ "bearing of their own; these leave them out of the "
251
+ "design, not just out of the picture")
252
+ built.add_argument("--no-cam-bearing", action="store_true",
253
+ help="the disc bore runs straight on the cam")
254
+ built.add_argument("--no-shaft-bearings", action="store_true",
255
+ help="the drive hangs on the driving motor's bearings")
256
+ built.add_argument("--no-output-bearing", action="store_true",
257
+ help="the driven machine locates the output flange")
258
+
259
+ search = parser.add_argument_group(
260
+ "design search", "state what the drive has to do and let the app find "
261
+ "the geometry, instead of giving it one")
262
+ search.add_argument("--optimise", "--optimize", action="store_true",
263
+ dest="optimise", help="search for a design")
264
+ search.add_argument("--torque", type=float, default=5.0,
265
+ help="required output torque, Nm (default 5)")
266
+ search.add_argument("--rpm", type=float, default=1000.0,
267
+ help="input speed (default 1000)")
268
+ search.add_argument("--max-od", type=float, default=120.0,
269
+ help="outer diameter limit, mm (default 120)")
270
+ search.add_argument("--max-length", type=float, default=60.0,
271
+ help="axial length limit, mm (default 60)")
272
+ search.add_argument("--process", default="FDM 3D print",
273
+ help="manufacturing process (default 'FDM 3D print')")
274
+ search.add_argument("--disc-material", default="PLA")
275
+ search.add_argument("--pin-material", default="Steel 1045")
276
+ search.add_argument("--housing-material", default="PLA")
277
+ search.add_argument("--rollers", action="store_true",
278
+ help="ring and output pins carry rolling elements")
279
+ search.add_argument("--discs", type=int, default=0, choices=(0, 1, 2, 3),
280
+ help="0 lets the search choose (default)")
281
+ search.add_argument("--min-safety", type=float, default=1.5,
282
+ help="required margin on contact stress (default 1.5)")
283
+ search.add_argument("--objective", default="balanced",
284
+ choices=["balanced", "torque capacity", "efficiency",
285
+ "small and light", "stiffness and low backlash"])
286
+ search.add_argument("--effort", default="normal",
287
+ choices=["quick", "normal", "thorough"])
288
+ args = parser.parse_args(argv)
289
+
290
+ headless = (args.out or args.ratio or args.design or args.optimise
291
+ or args.list_outputs or args.vary)
292
+ if headless:
293
+ import matplotlib
294
+ matplotlib.use("Agg")
295
+ from .analysis import analyse
296
+ from .export import write_bundle
297
+ from .export.manifest import resolve_groups
298
+
299
+ try:
300
+ groups = resolve_groups(
301
+ not args.no_solids,
302
+ [g.strip() for g in args.only.split(",")] if args.only else None)
303
+ except ValueError as exc:
304
+ print(exc, file=sys.stderr)
305
+ return 2
306
+
307
+ if args.optimise:
308
+ code, spec = _search(args)
309
+ if code:
310
+ return code
311
+ print()
312
+ else:
313
+ spec = _spec_from_args(args)
314
+
315
+ if args.list_outputs:
316
+ return _list_outputs(spec, groups)
317
+
318
+ if args.vary:
319
+ # After the search rather than instead of it: varying around a
320
+ # design the app found is a more useful study than varying around
321
+ # a preset, and it costs nothing to allow.
322
+ return _batch(args, spec)
323
+
324
+ report = analyse(spec).report
325
+ print(report)
326
+ if not args.out and args.optimise:
327
+ return 0 # searched only, nothing to write
328
+ if not report.ok:
329
+ print("\nblocked: fix the errors above", file=sys.stderr)
330
+ return 2
331
+
332
+ out = args.out or Path.cwd() / f"cycloidal_{spec.ratio}to1"
333
+ files = write_bundle(spec, out, groups=groups)
334
+ print(f"\nwrote {len(files)} files to {out}")
335
+ for f in files:
336
+ print(f" {f.relative_to(out)}")
337
+ return 0
338
+
339
+ from .ui.app import main as gui_main
340
+ return gui_main()
341
+
342
+
343
+ if __name__ == "__main__":
344
+ raise SystemExit(main())