Skip to content

Commit da224ca

Browse files
committed
Added cython-specific tests to py_ballisticcalc.exts/tests
1 parent 3824fdd commit da224ca

12 files changed

Lines changed: 224 additions & 95 deletions

docs/Cython.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,9 @@ It explains naming, error handling, Global Interpreter Lock (GIL) usage, and why
106106
- If the function is purely a utility that both Cython modules and Python code will call and it neither needs `nogil` nor special exception mapping, `cpdef` is acceptable.
107107
- If the function is a hot numeric path, manipulates raw buffers/pointers, or needs careful error/status handling, implement a `cdef` nogil core and a `def` wrapper.
108108

109+
## 11. Exception annotation on nogil
109110

110-
---
111-
Generated: August 19, 2025
111+
`.pxd` declarations for `nogil` functions the module-level functions should have explicit exception values. Cython warns that cimporters calling them without the GIL will require exception checks. If you intend for these functions to never raise Cython exceptions, consider:
112+
113+
- Declaring them `noexcept` in the `.pxd`, or
114+
- Specify an explicit exception value (e.g., `except NULL` or `except False`) where appropriate to avoid implicit exception checks.

py_ballisticcalc.exts/__init__.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,7 @@
1-
from .py_ballisticcalc_exts import *
1+
try:
2+
# Normal package import (when used as a subpackage)
3+
from .py_ballisticcalc_exts import *
4+
except Exception:
5+
# pytest may import this package as a top-level module during collection;
6+
# fall back to absolute import of the nested extension package if needed.
7+
from py_ballisticcalc_exts import *

py_ballisticcalc.exts/py_ballisticcalc_exts/base_engine.pxd

Lines changed: 3 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,8 @@
11
# pxd for py_ballisticcalc_exts.base_engine
22

3-
# noinspection PyUnresolvedReferences
4-
from cython cimport final
5-
# noinspection PyUnresolvedReferences
6-
from libc.math cimport fabs, sin, cos, tan, atan, atan2
7-
8-
# noinspection PyUnresolvedReferences
9-
from py_ballisticcalc_exts.trajectory_data cimport TrajFlag_t, BaseTrajDataT, TrajectoryData
10-
# noinspection PyUnresolvedReferences
11-
from py_ballisticcalc_exts.cy_bindings cimport (
12-
Config_t,
13-
Wind_t,
14-
ShotData_t,
15-
)
16-
# noinspection PyUnresolvedReferences
17-
from py_ballisticcalc_exts.v3d cimport (
18-
V3dT
19-
)
3+
from py_ballisticcalc_exts.trajectory_data cimport TrajFlag_t, BaseTrajDataT
4+
from py_ballisticcalc_exts.cy_bindings cimport Config_t, Wind_t, ShotData_t
5+
from py_ballisticcalc_exts.v3d cimport V3dT
206

217
__all__ = (
228
'CythonizedBaseIntegrationEngine',

py_ballisticcalc.exts/py_ballisticcalc_exts/base_engine.pyx

Lines changed: 0 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -250,37 +250,6 @@ cdef BaseTrajDataT TrajDataFilter_t_should_record(TrajDataFilter_t * tdf,
250250
else:
251251
return <BaseTrajDataT>None
252252

253-
cdef void _check_next_time(TrajDataFilter_t * tdf, double time):
254-
if time > tdf.time_of_last_record + tdf.time_step:
255-
tdf.current_flag |= TrajFlag_t.RANGE
256-
tdf.time_of_last_record = time
257-
258-
cdef void _check_mach_crossing(TrajDataFilter_t * tdf, double velocity, double mach):
259-
cdef double current_v_mach = velocity / mach
260-
if tdf.previous_v_mach > 1 >= current_v_mach:
261-
tdf.current_flag |= TrajFlag_t.MACH
262-
tdf.previous_v_mach = current_v_mach
263-
264-
cdef void _check_zero_crossing(TrajDataFilter_t * tdf, const V3dT *range_vector_ptr):
265-
# Deprecated by new ZERO detection in TrajDataFilter_t_should_record; kept for compatibility if used elsewhere
266-
cdef double ca = cos(tdf.look_angle)
267-
cdef double sa = sin(tdf.look_angle)
268-
cdef double s_prev = tdf.previous_position.y * ca - tdf.previous_position.x * sa
269-
cdef double s_curr = range_vector_ptr[0].y * ca - range_vector_ptr[0].x * sa
270-
if not (tdf.seen_zero & TrajFlag_t.ZERO_UP):
271-
if s_prev < 0.0 and s_curr >= 0.0:
272-
tdf.current_flag |= TrajFlag_t.ZERO_UP
273-
tdf.seen_zero |= TrajFlag_t.ZERO_UP
274-
elif not (tdf.seen_zero & TrajFlag_t.ZERO_DOWN):
275-
if s_prev >= 0.0 and s_curr < 0.0:
276-
tdf.current_flag |= TrajFlag_t.ZERO_DOWN
277-
tdf.seen_zero |= TrajFlag_t.ZERO_DOWN
278-
279-
cdef void _check_apex(TrajDataFilter_t * tdf, const V3dT *velocity_vector_ptr):
280-
if velocity_vector_ptr[0].y <= 0 and tdf.previous_velocity.y > 0:
281-
# We have crossed the apex
282-
tdf.current_flag |= TrajFlag_t.APEX
283-
284253

285254
cdef WindSock_t * WindSock_t_create(object winds_py_list):
286255
"""

py_ballisticcalc.exts/py_ballisticcalc_exts/base_traj_seq.pxd

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,25 @@
11
"""
22
Header file for base_traj_seq.pyx - C Buffer Trajectory Sequence
33
4-
This declares the CBaseTrajSeq class for use by other Cython modules.
4+
Notes and conventions (keep in sync with base_traj_seq.pyx):
5+
- This .pxd is authoritative for C-level declarations shared across Cython
6+
modules. Avoid redeclaring enums, structs or function prototypes in the
7+
corresponding .pyx files; duplicating declarations can cause "redeclared"
8+
errors during editable installs or when Cython regenerates sources.
9+
- Nogil helpers are declared here with explicit exception sentinels so that
10+
callers know the sentinel value used when the function is invoked without
11+
the GIL. Conventions used in this module:
12+
* pointer-returning functions: use `except NULL nogil` (return NULL on error)
13+
* bint-returning try-style helpers: use `except False nogil` (return False on error)
14+
* append-style functions that cannot fail at the C level are marked `noexcept nogil`
15+
- The quadratic Lagrange interpolation used by the nogil core is currently
16+
inlined in the implementation to avoid cross-module nogil call-site
17+
diagnostics; a future refactor could move this math into a shared C
18+
header and declare a pure-C helper here.
519
"""
620

7-
from libc.stdlib cimport malloc, realloc, free
8-
921
# cimport BaseTrajDataT and interpolation helper
10-
from py_ballisticcalc_exts.trajectory_data cimport BaseTrajDataT, lagrange_quadratic
22+
from py_ballisticcalc_exts.trajectory_data cimport BaseTrajDataT
1123

1224
# Include BaseTrajC struct definition from header file
1325
cdef extern from "include/basetraj_seq.h" nogil:
@@ -33,16 +45,14 @@ cdef enum InterpKey:
3345
KEY_VEL_Y
3446
KEY_VEL_Z
3547

36-
# Nogil core: returns a malloc'ed BaseTrajC* on success or NULL on failure.
37-
3848
# Module-level nogil function that operates directly on raw buffer pointers.
3949
# It returns a malloc'ed BaseTrajC* or NULL on error.
40-
cdef BaseTrajC* _interpolate_nogil_raw(BaseTrajC* buffer, size_t length, Py_ssize_t idx, int key_kind, double key_value) nogil
50+
cdef BaseTrajC* _interpolate_nogil_raw(BaseTrajC* buffer, size_t length, Py_ssize_t idx, int key_kind, double key_value) except NULL nogil
4151
# Nogil-safe raw capacity/append helpers that operate on C pointers.
4252
# They mutate the buffer pointer and lengths via provided C pointers.
43-
cdef bint ensure_capacity_try_nogil_raw(BaseTrajC** buf_p, size_t* capacity_p, size_t min_capacity) nogil
53+
cdef bint ensure_capacity_try_nogil_raw(BaseTrajC** buf_p, size_t* capacity_p, size_t min_capacity) except False nogil
4454
cdef void append_nogil_raw(BaseTrajC** buf_p, size_t* length_p, double time, double px, double py, double pz,
45-
double vx, double vy, double vz, double mach) nogil
55+
double vx, double vy, double vz, double mach) noexcept nogil
4656

4757
cdef class CBaseTrajSeq:
4858
"""
@@ -58,15 +68,9 @@ cdef class CBaseTrajSeq:
5868
# with thin Python `def` wrappers in the .pyx. This keeps C-level calls
5969
# zero-overhead while preserving Python testability.
6070
cdef void _append_c(self, double time, double px, double py, double pz,
61-
double vx, double vy, double vz, double mach)
71+
double vx, double vy, double vz, double mach)
6272
# (nogil helpers are implemented as module-level functions that operate on raw
6373
# C pointers and lengths to avoid accessing 'self' without the GIL.)
6474
# Note: nogil try/grow helpers removed in this patch for compilation stability.
6575
cdef BaseTrajC* c_getitem(self, Py_ssize_t idx)
6676
cdef BaseTrajDataT _interpolate_at_c(self, Py_ssize_t idx, str key_attribute, double key_value)
67-
# Use plain int for the key_kind in the pxd to avoid enum cross-import issues.
68-
# Note: class does not expose nogil methods that access 'self'. Instead a
69-
# module-level nogil function is provided below to operate on raw buffers.
70-
71-
# Nogil interpolation core declared below as module-level enum and function.
72-

py_ballisticcalc.exts/py_ballisticcalc_exts/base_traj_seq.pyx

Lines changed: 57 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ cdef extern from "include/basetraj_seq.h" nogil:
3535

3636
__all__ = ['CBaseTrajSeq']
3737

38+
3839
cdef class CBaseTrajSeq:
3940
"""
4041
C Buffer Trajectory Sequence
@@ -59,7 +60,6 @@ cdef class CBaseTrajSeq:
5960
traj_seq.append(time, px, py, pz, vx, vy, vz, mach)
6061
trajectory_data = traj_seq[0] # Lazily converted to BaseTrajData
6162
"""
62-
6363
# Attributes declared in the matching .pxd; do not redeclare here.
6464

6565
def __cinit__(self):
@@ -120,7 +120,7 @@ cdef class CBaseTrajSeq:
120120
self._capacity = new_capacity
121121

122122
cdef void _append_c(self, double time, double px, double py, double pz,
123-
double vx, double vy, double vz, double mach):
123+
double vx, double vy, double vz, double mach):
124124
"""
125125
Append a new BaseTrajC entry to the buffer.
126126
@@ -129,10 +129,11 @@ cdef class CBaseTrajSeq:
129129
px, py, pz: Position coordinates
130130
vx, vy, vz: Velocity coordinates
131131
mach: Mach number
132+
133+
Note: This is the C-level `append` implementation and requires the GIL (because it references `self`).
134+
Callers who want a nogil fast-path should use the module-level raw helpers `ensure_capacity_try_nogil_raw`
135+
and `append_nogil_raw` which operate on raw C pointers and can be used inside with nogil: blocks.
132136
"""
133-
# Note: this is the raw C implementation used on hot paths. It is declared
134-
# nogil so callers that hold the GIL may release it for faster operation.
135-
# Python-level callers should use the thin Python wrapper `append` below.
136137
self._ensure_capacity(self._length + 1)
137138

138139
# Calculate pointer to new entry using byte arithmetic (self._buffer + self._length)
@@ -148,11 +149,9 @@ cdef class CBaseTrajSeq:
148149

149150
# increment length (must be done while still holding the GIL in typical callers)
150151
self._length += 1
151-
# Nogil try/grow and append helpers are implemented as module-level raw
152-
# helpers to avoid touching 'self' without holding the GIL.
153152

154153
def append(self, double time, double px, double py, double pz,
155-
double vx, double vy, double vz, double mach):
154+
double vx, double vy, double vz, double mach):
156155
"""Python wrapper around the cdef hot-path `_append_c`.
157156
158157
Attempt a nogil fast-path that grows and appends using the raw
@@ -163,12 +162,14 @@ cdef class CBaseTrajSeq:
163162
cdef BaseTrajC* local_buf = self._buffer
164163
cdef size_t local_cap = self._capacity
165164
cdef size_t local_len = self._length
166-
cdef bint ok = False
165+
# Explicitly initialize as bint to avoid mypy/cython literal-to-bool diagnostics
166+
cdef bint ok = <bint>0
167167

168168
# Try the nogil fast-path: grow (if needed) and append while holding no GIL.
169169
# We use local copies and then write them back into `self` on success.
170170
with nogil:
171-
ok = ensure_capacity_try_nogil_raw(&local_buf, &local_cap, <size_t>(local_len + 1))
171+
# Cast the C-return value to bint explicitly to satisfy static typing
172+
ok = <bint> ensure_capacity_try_nogil_raw(&local_buf, &local_cap, <size_t>(local_len + 1))
172173
if ok:
173174
append_nogil_raw(&local_buf, &local_len, time, px, py, pz, vx, vy, vz, mach)
174175

@@ -183,6 +184,16 @@ cdef class CBaseTrajSeq:
183184
# which will raise MemoryError on failure.
184185
self._ensure_capacity(self._length + 1)
185186
self._append_c(time, px, py, pz, vx, vy, vz, mach)
187+
188+
def reserve(self, Py_ssize_t min_capacity):
189+
"""Public helper to pre-allocate buffer capacity from Python tests/benchmarks.
190+
191+
This calls the underlying cdef `_ensure_capacity` which performs the
192+
realloc logic. It's small and safe to expose for testing/benching.
193+
"""
194+
if min_capacity < 0:
195+
raise ValueError("min_capacity must be non-negative")
196+
self._ensure_capacity(<size_t>min_capacity)
186197

187198
cdef BaseTrajC* c_getitem(self, Py_ssize_t idx):
188199
"""
@@ -282,7 +293,7 @@ cdef class CBaseTrajSeq:
282293
with nogil:
283294
outp = _interpolate_nogil_raw(_buf, _len, idx, key_kind, key_value)
284295

285-
if outp == NULL:
296+
if outp == <BaseTrajC*>NULL:
286297
raise IndexError("interpolate_at requires idx with valid neighbors (idx-1, idx, idx+1)")
287298

288299
pos.x = outp.px; pos.y = outp.py; pos.z = outp.pz
@@ -299,7 +310,7 @@ cdef class CBaseTrajSeq:
299310
return res
300311

301312
# Module-level nogil implementation that operates on raw buffers.
302-
cdef BaseTrajC* _interpolate_nogil_raw(BaseTrajC* buffer, size_t length, Py_ssize_t idx, int key_kind, double key_value) nogil:
313+
cdef BaseTrajC* _interpolate_nogil_raw(BaseTrajC* buffer, size_t length, Py_ssize_t idx, int key_kind, double key_value) except NULL nogil:
303314
cdef Py_ssize_t plength = <Py_ssize_t> length
304315
cdef BaseTrajC *p0
305316
cdef BaseTrajC *p1
@@ -337,24 +348,42 @@ cdef BaseTrajC* _interpolate_nogil_raw(BaseTrajC* buffer, size_t length, Py_ssiz
337348
else:
338349
return <BaseTrajC*>NULL
339350

351+
# Inline Lagrange quadratic interpolation to keep this function fully nogil-safe.
352+
cdef double L0, L1, L2, denom0, denom1, denom2, x
353+
x = key_value
354+
355+
# Compute denominators and check for degenerate points
356+
denom0 = (x0 - x1) * (x0 - x2)
357+
denom1 = (x1 - x0) * (x1 - x2)
358+
denom2 = (x2 - x0) * (x2 - x1)
359+
if denom0 == 0.0 or denom1 == 0.0 or denom2 == 0.0:
360+
# Degenerate points - cannot interpolate safely
361+
return <BaseTrajC*>NULL
362+
363+
L0 = ((x - x1) * (x - x2)) / denom0
364+
L1 = ((x - x0) * (x - x2)) / denom1
365+
L2 = ((x - x0) * (x - x1)) / denom2
366+
340367
if key_kind != 0:
341-
time = lagrange_quadratic(key_value, x0, p0.time, x1, p1.time, x2, p2.time)
368+
time = p0.time * L0 + p1.time * L1 + p2.time * L2
342369
else:
343-
time = key_value
370+
time = x
344371

345-
px = lagrange_quadratic(key_value, x0, p0.px, x1, p1.px, x2, p2.px)
346-
py = lagrange_quadratic(key_value, x0, p0.py, x1, p1.py, x2, p2.py)
347-
pz = lagrange_quadratic(key_value, x0, p0.pz, x1, p1.pz, x2, p2.pz)
348-
vx = lagrange_quadratic(key_value, x0, p0.vx, x1, p1.vx, x2, p2.vx)
349-
vy = lagrange_quadratic(key_value, x0, p0.vy, x1, p1.vy, x2, p2.vy)
350-
vz = lagrange_quadratic(key_value, x0, p0.vz, x1, p1.vz, x2, p2.vz)
372+
px = p0.px * L0 + p1.px * L1 + p2.px * L2
373+
py = p0.py * L0 + p1.py * L1 + p2.py * L2
374+
pz = p0.pz * L0 + p1.pz * L1 + p2.pz * L2
375+
vx = p0.vx * L0 + p1.vx * L1 + p2.vx * L2
376+
vy = p0.vy * L0 + p1.vy * L1 + p2.vy * L2
377+
vz = p0.vz * L0 + p1.vz * L1 + p2.vz * L2
351378

352379
if key_kind != 1:
353-
mach = lagrange_quadratic(key_value, x0, p0.mach, x1, p1.mach, x2, p2.mach)
380+
mach = p0.mach * L0 + p1.mach * L1 + p2.mach * L2
354381
else:
355-
mach = key_value
382+
mach = x
356383

357-
outp = <BaseTrajC*> malloc(<size_t>(sizeof(BaseTrajC)))
384+
# Cast sizeof(...) to size_t to match malloc's size parameter and avoid
385+
# implicit-int-to-size_t warnings on some compilers/platforms.
386+
outp = <BaseTrajC*> malloc(<size_t>sizeof(BaseTrajC))
358387
if outp == NULL:
359388
return <BaseTrajC*>NULL
360389

@@ -368,14 +397,14 @@ cdef BaseTrajC* _interpolate_nogil_raw(BaseTrajC* buffer, size_t length, Py_ssiz
368397

369398
# Nogil-safe raw capacity/append helpers implemented to match .pxd declarations.
370399
# These operate purely on C pointers and primitive types so they can run without the GIL.
371-
cdef bint ensure_capacity_try_nogil_raw(BaseTrajC** buf_p, size_t* capacity_p, size_t min_capacity) nogil:
400+
cdef bint ensure_capacity_try_nogil_raw(BaseTrajC** buf_p, size_t* capacity_p, size_t min_capacity) except False nogil:
372401
cdef size_t cur = capacity_p[0]
373402
cdef size_t new_capacity
374403
cdef BaseTrajC* new_buf
375404
cdef size_t doubled
376405

377406
if min_capacity <= cur:
378-
return True
407+
return <bint>1
379408

380409
if cur > 0:
381410
doubled = <size_t>(cur * 2)
@@ -388,15 +417,15 @@ cdef bint ensure_capacity_try_nogil_raw(BaseTrajC** buf_p, size_t* capacity_p, s
388417

389418
new_buf = <BaseTrajC*>realloc(<void*>buf_p[0], new_capacity * sizeof(BaseTrajC))
390419
if new_buf == NULL:
391-
return False
420+
return <bint>0
392421

393422
buf_p[0] = new_buf
394423
capacity_p[0] = new_capacity
395-
return True
424+
return <bint>1
396425

397426

398427
cdef void append_nogil_raw(BaseTrajC** buf_p, size_t* length_p, double time, double px, double py, double pz,
399-
double vx, double vy, double vz, double mach) nogil:
428+
double vx, double vy, double vz, double mach) noexcept nogil:
400429
cdef BaseTrajC* entry_ptr = <BaseTrajC*>((<char*>buf_p[0]) + (<size_t>length_p[0]) * sizeof(BaseTrajC))
401430
entry_ptr.time = time
402431
entry_ptr.px = px; entry_ptr.py = py; entry_ptr.pz = pz
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
Cython-specific tests and benchmarks for py-ballisticcalc extensions
2+
3+
- Run the CI helper from PowerShell to build, test, and microbench:
4+
5+
```powershell
6+
./ci_run_cython_checks.ps1
7+
```
8+
9+
- Unit tests live in `test_cbase_traj_seq_cython.py`.
10+
- Smoke and bench scripts: `smoke_cbase_traj_seq.py`, `bench_append_speed.py`, `microbench.py`.
11+
12+
These are intentionally segregated so repo-root workflows aren't used during quick cython iteration.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import time
2+
3+
from py_ballisticcalc_exts.base_traj_seq import CBaseTrajSeq
4+
5+
6+
def bench(n=100000):
7+
seq = CBaseTrajSeq()
8+
t0 = time.time()
9+
for i in range(n):
10+
seq.append(float(i), float(i*0.1), 0.0, 0.0, 0.0, 0.0, 0.0, 0.5)
11+
t1 = time.time()
12+
print(f"append {n}: {t1-t0:.4f}s")
13+
14+
15+
if __name__ == '__main__':
16+
bench(200000)
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# CI helper: run cython-focused checks locally.
2+
# - Builds extensions in-place
3+
# - Runs the cython-specific pytest folder
4+
# - Runs microbench
5+
6+
python -u setup.py build_ext --inplace ;
7+
pytest -q py_ballisticcalc.exts/tests ;
8+
python py_ballisticcalc.exts/tests/microbench.py

0 commit comments

Comments
 (0)