tiny-bclibc-wasm 0.0.1__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,765 @@
1
+ """tiny_bclibc -- micropython-bclibc's tiny_bclibc API for CPython, PyPy and Pythonista.
2
+
3
+ The same module a MicroPython board gets as a native .mpy (micropython-bclibc's natmod/usermod),
4
+ here backed by tiny_bclibc compiled to WebAssembly and run in whatever WebAssembly host is
5
+ available: JavaScriptCore's JSContext in Pythonista, `wasmtime` or Node on a desktop (see
6
+ `_runner.py`). A script written against the natmod runs here unchanged:
7
+
8
+ import tiny_bclibc as bc
9
+
10
+ shot = bc.Shot(bc=0.310, weight_grain=168.0, diameter_inch=0.308, length_inch=1.2,
11
+ muzzle_velocity_fps=2750.0, sight_height_ft=0.125, twist_inch=11.0)
12
+ bc.zero(shot, 300 * 3.28084)
13
+ rows, reason = bc.fire(shot, bc.Request(range_limit_ft=3000.0, range_step_ft=300.0))
14
+ for r in rows:
15
+ print(r[bc.T_DISTANCE], r[bc.T_HEIGHT])
16
+
17
+ Differences from the natmod, all at the storage level (results use the same C code):
18
+ - Shot/Wind/Config/Request keep their fields as Python floats, not float32 bytearrays, so a
19
+ double-precision module sees full-precision inputs. They are still `(buf, s)`-shaped
20
+ named tuples with the fields on `.s` (`shot.s.props.barrel_elevation_rad`, `w.s.velocity_fps`,
21
+ `cfg.s.max_iterations`, ...); `buf` is None.
22
+ - bench() measures the WebAssembly *host's* f32/f64 speed (the same loops, compiled to wasm),
23
+ not the CPU directly.
24
+
25
+ Typing: fully annotated (Python 3.10 syntax, which Pythonista runs); the public surface is also
26
+ described by __init__.pyi, checked against this module with mypy's stubtest.
27
+
28
+ Configuration (environment variables, read on first use):
29
+ TINY_BCLIBC_PRECISION double (default) | single -- which .wasm to load first; see
30
+ set_precision() to switch from code
31
+ TINY_BCLIBC_WASM explicit path to a .wasm (overrides the one next to this file)
32
+ TINY_BCLIBC_HOST jscontext | wasmtime | wasm3 | gi-jsc | node (default: first available,
33
+ see _runner.default_runner); set_host() does the same from code
34
+ """
35
+
36
+ import atexit
37
+ import os
38
+ import struct as _struct
39
+ import time
40
+ from collections.abc import Callable, Iterable, Sequence
41
+ from dataclasses import dataclass
42
+ from typing import Final, NamedTuple, TypeAlias
43
+
44
+ from . import _drag_tables
45
+ from ._runner import HOSTS, TbwError, WasmRunner, default_runner
46
+
47
+ __all__ = [
48
+ "DRAG_CUSTOM",
49
+ "DRAG_G1",
50
+ "DRAG_G7",
51
+ "INTERP_MACH",
52
+ "INTERP_POS_X",
53
+ "INTERP_POS_Y",
54
+ "INTERP_POS_Z",
55
+ "INTERP_TIME",
56
+ "INTERP_VEL_X",
57
+ "INTERP_VEL_Y",
58
+ "INTERP_VEL_Z",
59
+ "TRAJ_FLAG_ALL",
60
+ "TRAJ_FLAG_APEX",
61
+ "TRAJ_FLAG_MACH",
62
+ "TRAJ_FLAG_MRT",
63
+ "TRAJ_FLAG_NONE",
64
+ "TRAJ_FLAG_RANGE",
65
+ "TRAJ_FLAG_ZERO",
66
+ "TRAJ_FLAG_ZERO_DOWN",
67
+ "TRAJ_FLAG_ZERO_UP",
68
+ "T_ANGLE",
69
+ "T_DENSITY_RATIO",
70
+ "T_DISTANCE",
71
+ "T_DRAG",
72
+ "T_DROP_ANGLE",
73
+ "T_ENERGY",
74
+ "T_FLAG",
75
+ "T_HEIGHT",
76
+ "T_MACH",
77
+ "T_OGW",
78
+ "T_SLANT_DISTANCE",
79
+ "T_SLANT_HEIGHT",
80
+ "T_TIME",
81
+ "T_VELOCITY",
82
+ "T_WINDAGE",
83
+ "T_WINDAGE_ANGLE",
84
+ "Config",
85
+ "MultiBC",
86
+ "Request",
87
+ "Shot",
88
+ "Wind",
89
+ "aim",
90
+ "bench",
91
+ "build_multibc",
92
+ "find_apex",
93
+ "find_max_range",
94
+ "find_zero_angle",
95
+ "fire",
96
+ "host",
97
+ "integrate",
98
+ "integrate_at",
99
+ "integrate_ex",
100
+ "integrate_stream",
101
+ "precision",
102
+ "set_host",
103
+ "set_precision",
104
+ "version",
105
+ "zero",
106
+ "zero_point",
107
+ ]
108
+
109
+ # ── Constants (same values as natmod's _tiny_bclibc) ──────────────────────────
110
+ DRAG_G1: Final = 0
111
+ DRAG_G7: Final = 1
112
+ DRAG_CUSTOM: Final = 2
113
+
114
+ TRAJ_FLAG_NONE: Final = 0
115
+ TRAJ_FLAG_ZERO_UP: Final = 1
116
+ TRAJ_FLAG_ZERO_DOWN: Final = 2
117
+ TRAJ_FLAG_ZERO: Final = 3
118
+ TRAJ_FLAG_MACH: Final = 4
119
+ TRAJ_FLAG_RANGE: Final = 8
120
+ TRAJ_FLAG_APEX: Final = 16
121
+ TRAJ_FLAG_MRT: Final = 32
122
+ TRAJ_FLAG_ALL: Final = 31
123
+
124
+ T_TIME: Final = 0
125
+ T_DISTANCE: Final = 1
126
+ T_VELOCITY: Final = 2
127
+ T_MACH: Final = 3
128
+ T_HEIGHT: Final = 4
129
+ T_SLANT_HEIGHT: Final = 5
130
+ T_DROP_ANGLE: Final = 6
131
+ T_WINDAGE: Final = 7
132
+ T_WINDAGE_ANGLE: Final = 8
133
+ T_SLANT_DISTANCE: Final = 9
134
+ T_ANGLE: Final = 10
135
+ T_DENSITY_RATIO: Final = 11
136
+ T_DRAG: Final = 12
137
+ T_ENERGY: Final = 13
138
+ T_OGW: Final = 14
139
+ T_FLAG: Final = 15
140
+
141
+ INTERP_TIME: Final = 0
142
+ INTERP_MACH: Final = 1
143
+ INTERP_POS_X: Final = 2
144
+ INTERP_POS_Y: Final = 3
145
+ INTERP_POS_Z: Final = 4
146
+ INTERP_VEL_X: Final = 5
147
+ INTERP_VEL_Y: Final = 6
148
+ INTERP_VEL_Z: Final = 7
149
+
150
+ _NaN: Final = float("nan")
151
+ _INF: Final = 1e8 # TINY_BCLIBC_MAX_WIND_DIST_FT
152
+ _MAX_WINDS: Final = 16
153
+ _MAX_DRAG_PTS: Final = 200
154
+ _TERM_HANDLER_STOP: Final = 5
155
+
156
+ # tiny_bclibc_wasm.c output layout
157
+ _ROW: Final = 16
158
+ _INTEGRATE_HEADER: Final = 12
159
+ _AT_HEADER: Final = 9
160
+
161
+ # ── Types ─────────────────────────────────────────────────────────────────────
162
+
163
+ # One TrajectoryData row, indexed by the T_* constants: 15 floats, then the int TRAJ_FLAG_* flag.
164
+ Row: TypeAlias = tuple[
165
+ float, float, float, float, float, float, float, float, float, float, float, float, float, float, float, int
166
+ ]
167
+ # BaseTrajData: (time, px, py, pz, vx, vy, vz, mach).
168
+ RawState: TypeAlias = tuple[float, float, float, float, float, float, float, float]
169
+ # A custom drag column: a packed float32 buffer (what MultiBC() returns) or a plain sequence.
170
+ DragColumn: TypeAlias = Sequence[float] | bytes | bytearray | memoryview
171
+
172
+
173
+ # ── Module loading ────────────────────────────────────────────────────────────
174
+ _HERE: Final = os.path.dirname(os.path.abspath(__file__))
175
+ _PRECISIONS: Final = ("double", "single")
176
+ # One loaded host per precision (named _actives so it can't shadow the _runner submodule).
177
+ _actives: dict[str, WasmRunner] = {}
178
+ _host_choice: str | WasmRunner | None = None
179
+
180
+ # Release the hosts while the interpreter is still intact: wasmtime's store/engine destructors
181
+ # otherwise run during shutdown, after their own module has been torn down, and print tracebacks.
182
+ atexit.register(_actives.clear)
183
+ _precision: str = "single" if os.environ.get("TINY_BCLIBC_PRECISION", "double").lower().startswith("s") else "double"
184
+
185
+
186
+ def _get_runner() -> WasmRunner:
187
+ runner = _actives.get(_precision)
188
+ if runner is None:
189
+ path = os.environ.get("TINY_BCLIBC_WASM") or os.path.join(
190
+ _HERE, "tiny_bclibc_sp.wasm" if _precision == "single" else "tiny_bclibc_dp.wasm"
191
+ )
192
+ if not os.path.isfile(path):
193
+ raise FileNotFoundError(
194
+ f"tiny_bclibc wasm module not found at '{path}'. Build it with `uv sync` or "
195
+ "`python build_wasm.py` in the tiny-bclibc-wasm-py repo, or set "
196
+ "TINY_BCLIBC_WASM."
197
+ )
198
+ runner = _pick_host()
199
+ runner.load_file(path)
200
+ _actives[_precision] = runner
201
+ return runner
202
+
203
+
204
+ def _call(what: str, export: str, inputs: Sequence[float], *args: float) -> list[float]:
205
+ try:
206
+ return _get_runner().call(export, inputs, *args)
207
+ except TbwError as exc:
208
+ raise ValueError(f"{what} rc={exc.status}: {exc.message}") from None
209
+
210
+
211
+ def _pick_host() -> WasmRunner:
212
+ if _host_choice is None:
213
+ return default_runner()
214
+ if isinstance(_host_choice, WasmRunner):
215
+ return _host_choice
216
+ return HOSTS[_host_choice]()
217
+
218
+
219
+ def set_host(host: str | WasmRunner | None) -> None:
220
+ """Choose the WebAssembly host before (or instead of) the automatic pick.
221
+
222
+ ``host`` is a name (see _runner.HOSTS), a ready WasmRunner instance, or
223
+ None to go back to automatic selection. Takes effect on the next call (the module is reloaded).
224
+ """
225
+ global _host_choice
226
+ if host is not None and not isinstance(host, WasmRunner) and host not in HOSTS:
227
+ raise ValueError("unknown host {!r}: expected one of {}".format(host, ", ".join(HOSTS)))
228
+ _host_choice = host
229
+ _actives.clear()
230
+
231
+
232
+ def set_precision(precision: str) -> None:
233
+ """Switch between the double- and single-precision modules ("double" | "single").
234
+
235
+ Process-wide, and cheap to flip back and forth: each module is loaded once, on first use.
236
+ """
237
+ global _precision
238
+ if precision not in _PRECISIONS:
239
+ raise ValueError(f"unknown precision {precision!r}: expected one of {', '.join(_PRECISIONS)}")
240
+ _precision = precision
241
+
242
+
243
+ def precision() -> str:
244
+ """The precision in use: "double" or "single"."""
245
+ return _precision
246
+
247
+
248
+ def version() -> str:
249
+ """tiny_bclibc version of the loaded module, e.g. "2.0.0-rc.1-dp" (natmod: "<ver>-sp")."""
250
+ return _get_runner().version
251
+
252
+
253
+ def host() -> str:
254
+ """Name of the WebAssembly host in use: jscontext, wasmtime, wasm3, gi-jsc or node."""
255
+ return _get_runner().name
256
+
257
+
258
+ # ── Value objects ─────────────────────────────────────────────────────────────
259
+ # The `.s` side of natmod's `(buf, s)` pairs: there a uctypes struct view over a float32 buffer,
260
+ # here a plain typed record with the same field names.
261
+
262
+
263
+ @dataclass(slots=True)
264
+ class WindFields:
265
+ velocity_fps: float
266
+ direction_from_rad: float
267
+ until_distance_ft: float
268
+ max_distance_ft: float
269
+
270
+
271
+ @dataclass(slots=True)
272
+ class ConfigFields:
273
+ step_multiplier: float
274
+ zero_finding_accuracy: float
275
+ minimum_velocity: float
276
+ maximum_drop: float
277
+ max_iterations: int
278
+ gravity_constant: float
279
+ minimum_altitude: float
280
+
281
+
282
+ @dataclass(slots=True)
283
+ class ShotProps:
284
+ bc: float
285
+ weight_grain: float
286
+ diameter_inch: float
287
+ length_inch: float
288
+ muzzle_velocity_fps: float
289
+ sight_height_ft: float
290
+ twist_inch: float
291
+ temp_c: float
292
+ pressure_hpa: float
293
+ altitude_ft: float
294
+ humidity: float
295
+ look_angle_rad: float
296
+ barrel_elevation_rad: float
297
+ barrel_azimuth_rad: float
298
+ cant_angle_rad: float
299
+ latitude_deg: float
300
+ azimuth_deg: float
301
+
302
+
303
+ @dataclass(slots=True)
304
+ class ShotFields:
305
+ props: ShotProps
306
+ cfg: ConfigFields
307
+ drag_type: int
308
+ winds: list[WindFields]
309
+ drag_mach: list[float] | None # custom drag table (DRAG_CUSTOM), else None
310
+ drag_cd: list[float] | None
311
+
312
+ @property
313
+ def wind_count(self) -> int:
314
+ return len(self.winds)
315
+
316
+ @property
317
+ def drag_count(self) -> int:
318
+ return len(self.drag_mach) if self.drag_mach else 0
319
+
320
+
321
+ @dataclass(slots=True)
322
+ class RequestFields:
323
+ range_limit_ft: float
324
+ range_step_ft: float
325
+ time_step: float
326
+ filter_flags: int
327
+
328
+
329
+ class WindData(NamedTuple):
330
+ """What Wind() returns (natmod's `Wind` namedtuple)."""
331
+
332
+ buf: None
333
+ s: WindFields
334
+
335
+
336
+ class ConfigData(NamedTuple):
337
+ """What Config() returns (natmod's `Config` namedtuple)."""
338
+
339
+ buf: None
340
+ s: ConfigFields
341
+
342
+
343
+ class ShotData(NamedTuple):
344
+ """What Shot() returns (natmod's `Shot` namedtuple)."""
345
+
346
+ buf: None
347
+ s: ShotFields
348
+ holder: None
349
+
350
+
351
+ class RequestData(NamedTuple):
352
+ """What Request() returns (natmod's `Request` namedtuple)."""
353
+
354
+ buf: None
355
+ s: RequestFields
356
+ traj: None
357
+
358
+
359
+ def Wind(
360
+ velocity_fps: float = 0.0,
361
+ direction_from_rad: float = 0.0,
362
+ until_distance_ft: float = _INF,
363
+ max_distance_ft: float = _INF,
364
+ ) -> WindData:
365
+ return WindData(
366
+ None,
367
+ WindFields(
368
+ velocity_fps=float(velocity_fps),
369
+ direction_from_rad=float(direction_from_rad),
370
+ until_distance_ft=float(until_distance_ft),
371
+ max_distance_ft=float(max_distance_ft),
372
+ ),
373
+ )
374
+
375
+
376
+ def Config(
377
+ step_multiplier: float = 0.5,
378
+ zero_finding_accuracy: float = 0.001,
379
+ minimum_velocity: float = 50.0,
380
+ maximum_drop: float = -15000.0,
381
+ max_iterations: int = 50,
382
+ gravity_constant: float = -32.17405,
383
+ minimum_altitude: float = -1500.0,
384
+ ) -> ConfigData:
385
+ return ConfigData(
386
+ None,
387
+ ConfigFields(
388
+ step_multiplier=float(step_multiplier),
389
+ zero_finding_accuracy=float(zero_finding_accuracy),
390
+ minimum_velocity=float(minimum_velocity),
391
+ maximum_drop=float(maximum_drop),
392
+ max_iterations=int(max_iterations),
393
+ gravity_constant=float(gravity_constant),
394
+ minimum_altitude=float(minimum_altitude),
395
+ ),
396
+ )
397
+
398
+
399
+ def _floats(column: DragColumn, count: int) -> list[float]:
400
+ if isinstance(column, (bytes, bytearray, memoryview)):
401
+ return list(_struct.unpack_from(f"<{count}f", column))
402
+ return [float(column[i]) for i in range(count)]
403
+
404
+
405
+ def Shot(
406
+ bc: float = 0.0,
407
+ weight_grain: float = 0.0,
408
+ diameter_inch: float = 0.0,
409
+ length_inch: float = 0.0,
410
+ muzzle_velocity_fps: float = 0.0,
411
+ sight_height_ft: float = 0.0,
412
+ twist_inch: float = 0.0,
413
+ temp_c: float = 15.0,
414
+ pressure_hpa: float = 1013.25,
415
+ altitude_ft: float = 0.0,
416
+ humidity: float = 0.5,
417
+ look_angle_rad: float = 0.0,
418
+ barrel_elevation_rad: float = 0.0,
419
+ barrel_azimuth_rad: float = 0.0,
420
+ cant_angle_rad: float = 0.0,
421
+ latitude_deg: float = _NaN,
422
+ azimuth_deg: float = _NaN,
423
+ drag_type: int = DRAG_G7,
424
+ drag_mach: DragColumn | None = None,
425
+ drag_cd: DragColumn | None = None,
426
+ drag_count: int | None = None,
427
+ winds: Iterable[WindData] | None = None,
428
+ config: ConfigData | None = None,
429
+ ) -> ShotData:
430
+ cfg = config if config is not None else Config()
431
+ wind_list = list(winds or [])[:_MAX_WINDS]
432
+ mach: list[float] | None = None
433
+ cd: list[float] | None = None
434
+ if drag_type == DRAG_CUSTOM and drag_mach and drag_cd:
435
+ if drag_count is not None:
436
+ dc = drag_count
437
+ elif isinstance(drag_mach, (bytes, bytearray, memoryview)):
438
+ dc = min(len(drag_mach) // 4, len(drag_cd) // 4)
439
+ else:
440
+ dc = min(len(drag_mach), len(drag_cd))
441
+ dc = min(dc, _MAX_DRAG_PTS)
442
+ mach, cd = _floats(drag_mach, dc), _floats(drag_cd, dc)
443
+ props = ShotProps(
444
+ bc=float(bc),
445
+ weight_grain=float(weight_grain),
446
+ diameter_inch=float(diameter_inch),
447
+ length_inch=float(length_inch),
448
+ muzzle_velocity_fps=float(muzzle_velocity_fps),
449
+ sight_height_ft=float(sight_height_ft),
450
+ twist_inch=float(twist_inch),
451
+ temp_c=float(temp_c),
452
+ pressure_hpa=float(pressure_hpa),
453
+ altitude_ft=float(altitude_ft),
454
+ humidity=float(humidity),
455
+ look_angle_rad=float(look_angle_rad),
456
+ barrel_elevation_rad=float(barrel_elevation_rad),
457
+ barrel_azimuth_rad=float(barrel_azimuth_rad),
458
+ cant_angle_rad=float(cant_angle_rad),
459
+ latitude_deg=float(latitude_deg),
460
+ azimuth_deg=float(azimuth_deg),
461
+ )
462
+ s = ShotFields(
463
+ props=props,
464
+ cfg=cfg.s,
465
+ drag_type=drag_type,
466
+ winds=[w.s for w in wind_list],
467
+ drag_mach=mach,
468
+ drag_cd=cd,
469
+ )
470
+ return ShotData(None, s, None)
471
+
472
+
473
+ def Request(
474
+ range_limit_ft: float = 3000.0,
475
+ range_step_ft: float = 100.0,
476
+ time_step: float = 0.0,
477
+ filter_flags: int = TRAJ_FLAG_RANGE,
478
+ ) -> RequestData:
479
+ return RequestData(
480
+ None,
481
+ RequestFields(
482
+ range_limit_ft=float(range_limit_ft),
483
+ range_step_ft=float(range_step_ft),
484
+ time_step=float(time_step),
485
+ filter_flags=int(filter_flags),
486
+ ),
487
+ None,
488
+ )
489
+
490
+
491
+ def _serialize(shot: ShotData) -> list[float]:
492
+ """Flatten a Shot into tiny_bclibc_wasm.c's input layout."""
493
+ s = shot.s
494
+ p = s.props
495
+ c = s.cfg
496
+ mach: Sequence[float]
497
+ cd: Sequence[float]
498
+ if s.drag_type == DRAG_CUSTOM and s.drag_mach and s.drag_cd:
499
+ mach, cd = s.drag_mach, s.drag_cd
500
+ elif s.drag_type == DRAG_G1:
501
+ mach, cd = _drag_tables.G1_MACH, _drag_tables.G1_CD
502
+ else:
503
+ mach, cd = _drag_tables.G7_MACH, _drag_tables.G7_CD
504
+ values = [
505
+ p.bc,
506
+ p.weight_grain,
507
+ p.diameter_inch,
508
+ p.length_inch,
509
+ p.muzzle_velocity_fps,
510
+ p.sight_height_ft,
511
+ p.twist_inch,
512
+ p.temp_c,
513
+ p.pressure_hpa,
514
+ p.altitude_ft,
515
+ p.humidity,
516
+ p.look_angle_rad,
517
+ p.barrel_elevation_rad,
518
+ p.barrel_azimuth_rad,
519
+ p.cant_angle_rad,
520
+ p.latitude_deg,
521
+ p.azimuth_deg,
522
+ c.step_multiplier,
523
+ c.zero_finding_accuracy,
524
+ c.minimum_velocity,
525
+ c.maximum_drop,
526
+ float(c.max_iterations),
527
+ c.gravity_constant,
528
+ c.minimum_altitude,
529
+ float(len(mach)),
530
+ float(len(s.winds)),
531
+ ]
532
+ values.extend(mach)
533
+ values.extend(cd)
534
+ for w in s.winds:
535
+ values.extend((w.velocity_fps, w.direction_from_rad, w.until_distance_ft, w.max_distance_ft))
536
+ return values
537
+
538
+
539
+ def _row(v: Sequence[float], i: int) -> Row:
540
+ """One TrajectoryData row as natmod's 16-tuple (15 floats + int flag)."""
541
+ return (
542
+ v[i],
543
+ v[i + 1],
544
+ v[i + 2],
545
+ v[i + 3],
546
+ v[i + 4],
547
+ v[i + 5],
548
+ v[i + 6],
549
+ v[i + 7],
550
+ v[i + 8],
551
+ v[i + 9],
552
+ v[i + 10],
553
+ v[i + 11],
554
+ v[i + 12],
555
+ v[i + 13],
556
+ v[i + 14],
557
+ int(v[i + 15]),
558
+ )
559
+
560
+
561
+ # ── API ───────────────────────────────────────────────────────────────────────
562
+
563
+
564
+ class Trajectory(NamedTuple):
565
+ """Everything one integration returns (see integrate_ex)."""
566
+
567
+ rows: list[Row]
568
+ reason: int # TINY_BCLIBC_TerminationReason
569
+ total: int # rows the solver emitted (== len(rows) unless the output was cut short)
570
+ final: RawState # the exact terminal state, whether or not a row was emitted for it
571
+
572
+
573
+ def integrate(shot: ShotData, req: RequestData) -> tuple[list[Row], int]:
574
+ """Return ``(rows, stop_reason)``; each row is a 16-tuple indexed by the ``T_*`` constants."""
575
+ traj = integrate_ex(shot, req)
576
+ return traj.rows, traj.reason
577
+
578
+
579
+ def integrate_ex(shot: ShotData, req: RequestData) -> Trajectory:
580
+ """Like integrate(), plus the row total and the terminal raw state (not part of the natmod API).
581
+
582
+ The terminal state lets a caller close a trajectory that ended other than by reaching the
583
+ requested range with its exact last point.
584
+ """
585
+ r = req.s
586
+ out = _call(
587
+ "integrate",
588
+ "tbw_integrate",
589
+ _serialize(shot),
590
+ r.range_limit_ft,
591
+ r.range_step_ft,
592
+ r.time_step,
593
+ r.filter_flags,
594
+ )
595
+ n = int(out[3])
596
+ final: RawState = (out[4], out[5], out[6], out[7], out[8], out[9], out[10], out[11])
597
+ rows = [_row(out, _INTEGRATE_HEADER + k * _ROW) for k in range(n)]
598
+ return Trajectory(rows, int(out[1]), int(out[2]), final)
599
+
600
+
601
+ def integrate_stream(shot: ShotData, req: RequestData, cb: Callable[[Row], object]) -> tuple[int, int]:
602
+ """Call ``cb(row)`` per row; a truthy return stops early. Returns ``(count, stop_reason)``.
603
+
604
+ The module computes the whole trajectory in one call and the callbacks run afterwards (one
605
+ host round trip instead of one per row); a stop therefore reports reason 5 (handler stop)
606
+ exactly like the natmod, but does not save the remaining integration work.
607
+ """
608
+ rows, reason = integrate(shot, req)
609
+ for i, row in enumerate(rows):
610
+ if cb(row):
611
+ return i + 1, _TERM_HANDLER_STOP
612
+ return len(rows), reason
613
+
614
+
615
+ def integrate_at(shot: ShotData, interp: int, val: float) -> tuple[RawState, Row]:
616
+ """Return ``(raw, row)`` where the ``INTERP_*`` quantity equals val.
617
+
618
+ ``raw`` is (time, px, py, pz, vx, vy, vz, mach); ``row`` a 16-tuple.
619
+ """
620
+ o = _call("integrate_at", "tbw_integrate_at", _serialize(shot), int(interp), float(val))
621
+ raw: RawState = (o[1], o[2], o[3], o[4], o[5], o[6], o[7], o[8])
622
+ return raw, _row(o, _AT_HEADER)
623
+
624
+
625
+ def find_zero_angle(shot: ShotData, dist_ft: float) -> float:
626
+ """Barrel elevation (rad) that zeroes the shot at dist_ft."""
627
+ return _call("find_zero_angle", "tbw_find_zero_angle", _serialize(shot), float(dist_ft))[1]
628
+
629
+
630
+ def zero_point(shot: ShotData, dist_ft: float) -> tuple[float, Row]:
631
+ """Return the solver's ``(zero_angle_rad, terminal_row)`` without re-integration."""
632
+ out = _call("zero_point", "tbw_find_zero_point", _serialize(shot), float(dist_ft))
633
+ return out[1], _row(out, 2)
634
+
635
+
636
+ def zero(shot: ShotData, dist_ft: float) -> float:
637
+ """Set ``shot``'s barrel elevation for dist_ft and return it in radians."""
638
+ angle, _point = zero_point(shot, dist_ft)
639
+ shot.s.props.barrel_elevation_rad = angle
640
+ return angle
641
+
642
+
643
+ def aim(shot: ShotData, dist_ft: float) -> tuple[float, float, Row]:
644
+ """Return ``(vertical_hold_rad, windage_rad, point)`` for a target distance.
645
+
646
+ The hold is relative to the barrel elevation currently stored in ``shot`` (normally set by
647
+ :func:`zero`).
648
+ """
649
+ angle, point = zero_point(shot, dist_ft)
650
+ return angle - shot.s.props.barrel_elevation_rad, point[T_WINDAGE_ANGLE], point
651
+
652
+
653
+ def fire(shot: ShotData, req: RequestData) -> tuple[list[Row], int]:
654
+ """Calculate and return ``(trajectory_rows, stop_reason)``."""
655
+ return integrate(shot, req)
656
+
657
+
658
+ def find_apex(shot: ShotData) -> Row:
659
+ """The apex (vertical velocity = 0) as a 16-tuple row."""
660
+ return _row(_call("find_apex", "tbw_find_apex", _serialize(shot)), 1)
661
+
662
+
663
+ def find_max_range(shot: ShotData, lo: float, hi: float) -> tuple[float, float]:
664
+ """Return ``(max_range_ft, angle_rad)`` searched between lo and hi degrees."""
665
+ out = _call("find_max_range", "tbw_find_max_range", _serialize(shot), float(lo), float(hi))
666
+ return out[1], out[2]
667
+
668
+
669
+ # ── MultiBC ───────────────────────────────────────────────────────────────────
670
+ # Same algorithm as natmod's build_multibc (src/tiny_bclibc_mp.c) and ffimod's pure-Python port.
671
+
672
+
673
+ def _interp_bc(bc_mach: Sequence[float], bc_val: Sequence[float], mach: float) -> float:
674
+ n = len(bc_mach)
675
+ if mach <= bc_mach[0]:
676
+ return bc_val[0]
677
+ if mach >= bc_mach[n - 1]:
678
+ return bc_val[n - 1]
679
+ lo, hi = 0, n - 1
680
+ while hi - lo > 1:
681
+ mid = (lo + hi) // 2
682
+ if bc_mach[mid] <= mach:
683
+ lo = mid
684
+ else:
685
+ hi = mid
686
+ t = (mach - bc_mach[lo]) / (bc_mach[hi] - bc_mach[lo])
687
+ return bc_val[lo] + t * (bc_val[hi] - bc_val[lo])
688
+
689
+
690
+ def build_multibc(
691
+ drag_type: int,
692
+ bc_points_buf: bytes | bytearray | memoryview,
693
+ out_mach_buf: bytearray | memoryview,
694
+ out_cd_buf: bytearray | memoryview,
695
+ ) -> int:
696
+ """Low-level primitive with natmod's signature: packed "<ff" (mach, bc) points in, float32 out."""
697
+ n_pts = len(bc_points_buf) // 8
698
+ pts: list[tuple[float, float]] = sorted(
699
+ (_struct.unpack_from("<ff", bc_points_buf, i * 8) for i in range(n_pts)),
700
+ key=lambda p: p[0],
701
+ )
702
+ bc_mach = [p[0] for p in pts]
703
+ bc_val = [p[1] for p in pts]
704
+ # natmod: 0 = G1, anything else = G7
705
+ if drag_type == DRAG_G1:
706
+ ref_mach, ref_cd = _drag_tables.G1_MACH, _drag_tables.G1_CD
707
+ else:
708
+ ref_mach, ref_cd = _drag_tables.G7_MACH, _drag_tables.G7_CD
709
+ n = len(ref_mach)
710
+ for i in range(n):
711
+ bc_at = _interp_bc(bc_mach, bc_val, ref_mach[i])
712
+ _struct.pack_into("<f", out_mach_buf, i * 4, ref_mach[i])
713
+ _struct.pack_into("<f", out_cd_buf, i * 4, ref_cd[i] / bc_at)
714
+ return n
715
+
716
+
717
+ def MultiBC(bc_points: Iterable[tuple[float, float]], drag_type: int = DRAG_G7) -> tuple[bytearray, bytearray, int]:
718
+ """Fold (mach, bc) points into one custom drag curve: returns ``(mach_buf, cd_buf, count)``.
719
+
720
+ Feed it to ``Shot(bc=1.0, drag_type=DRAG_CUSTOM, drag_mach=mach_buf, drag_cd=cd_buf,
721
+ drag_count=count)`` -- same contract as the natmod's MultiBC().
722
+ """
723
+ pts = list(bc_points)
724
+ pts_buf = bytearray(len(pts) * 8)
725
+ for i, (mach, bc_val) in enumerate(pts):
726
+ _struct.pack_into("<ff", pts_buf, i * 8, mach, bc_val)
727
+ mach_buf = bytearray(_MAX_DRAG_PTS * 4)
728
+ cd_buf = bytearray(_MAX_DRAG_PTS * 4)
729
+ count = build_multibc(drag_type, pts_buf, mach_buf, cd_buf)
730
+ return mach_buf, cd_buf, count
731
+
732
+
733
+ # ── bench: FPU micro-benchmark ────────────────────────────────────────────────
734
+ # The natmod's bench() runs native FPU loops; these are the same loops compiled into the module
735
+ # (tbw_bench_*), so what is measured is how fast the WebAssembly host runs f32/f64 arithmetic --
736
+ # useful for comparing hosts (wasmtime vs wasm3 vs JavaScriptCore without its JIT, ...).
737
+
738
+ _BENCH_N_LAT: Final = 500_000 # x4 ops/iter
739
+ _BENCH_N_THR: Final = 100_000 # x16 ops/iter
740
+
741
+
742
+ def _bench_run(label: str, export: str, n: int, ops: int) -> None:
743
+ runner = _get_runner()
744
+ runner.call_scalar(export, n // 10) # warmup
745
+ t0 = time.perf_counter()
746
+ runner.call_scalar(export, n)
747
+ dt = time.perf_counter() - t0
748
+ print(f" {label:8s}: {n * ops / dt / 1e6:9.2f} MFLOPS dt={dt:.3f}s")
749
+
750
+
751
+ def bench() -> None:
752
+ """Print an FPU latency/throughput/peak micro-benchmark (MFLOPS) of the WebAssembly host."""
753
+ print("=" * 52)
754
+ print(f"tiny_bclibc FPU FLOPS Benchmark (wasm on {host()})")
755
+ print("=" * 52)
756
+ print("\nLatency-bound (volatile, sequential chain):")
757
+ _bench_run("DP", "tbw_bench_lat_dp", _BENCH_N_LAT, 4)
758
+ _bench_run("SP", "tbw_bench_lat_sp", _BENCH_N_LAT, 4)
759
+ print("\nThroughput (8 independent accumulators, volatile operands):")
760
+ _bench_run("DP", "tbw_bench_thr_dp", _BENCH_N_THR, 16)
761
+ _bench_run("SP", "tbw_bench_thr_sp", _BENCH_N_THR, 16)
762
+ print("\nPeak (8 independent accumulators, register-resident, add-only):")
763
+ _bench_run("DP", "tbw_bench_peak_dp", _BENCH_N_THR, 8)
764
+ _bench_run("SP", "tbw_bench_peak_sp", _BENCH_N_THR, 8)
765
+ print("=" * 52)