Files
Oleksandr ShyshatskyiandClaude Opus 5 28eb20f3da Package numpy, because klippy does not start without it
extras/stepper_resonance_tester.py opens with a bare `import numpy as np`;
klippy.py:122 walks EVERY config section through a load_object whose
importlib.import_module is not wrapped in except ImportError; printer.base.cfg
includes FlashForge's printer.vibration.cfg, which declares
[stepper_resonance_tester]. Since klippy moved to our own CPython 3.13 that has
not been a printer missing resonance testing. It has been a klippy that never
reaches ready.

So this is not a scientific extra. anvil-klipper depends on anvil-python-numpy
the way it depends on cffi, except that the others mean "klippy imports this"
and this one means "klippy starts". It also gives back SHAPER_CALIBRATE and
TEST_RESONANCES, dead since the same switch, and the VFA calibration behind
STEPPER_RESONANCE_FACTORY_CALIBRATE that docs/notes/44-vfa-calibration.md
traces end to end.

WHAT WAS NOT OBVIOUS, in the order it bit.

numpy 2.x builds with meson-python, and meson has never heard of
_PYTHON_SYSCONFIGDATA_NAME -- the trick every other python-* recipe here leans
on. pkg_mesoncross writes a real cross file instead, naming the $PKG_XW
wrappers rather than the raw toolchain: the wrappers carry -EL -mnan=2008, and
a cross file that reaches past them builds a tree that passes every gate which
only reads symbols and is refused at exec() on the printer. pkg_pywheel grew a
PKG_PY_PIP_ARGS branch to pass the file as --config-settings, which is the one
thing PEP 517 does standardise.

The cross file's `python =` is the BUILD python, and that is not a mistake:
meson introspects an interpreter by running it and asking sysconfig, and
pkg_pytarget has already made that sysconfig answer for mipsel. The old trick
does reach meson, just not by its own mechanism.

numpy's meson.build hard-errors below gcc 8.4 and ours is 7.2. The gate is the
SciPy Toolchain Roadmap's support statement, not a requirement of the code:
this toolchain compiles if constexpr, structured bindings, std::optional,
std::string_view, and pocketfft_hdronly.h -- 3635 lines of the heaviest
template C++ in the tree, and the half np.fft.rfft needs. Probed before the
floor was lowered, and lowered rather than removed, behind a guard that fails
loudly if upstream moves the check. 19 extension modules, every one at e_flags
0x70001405/0x70001407, 14MB shipped after pruning tests/, f2py/, stubs and
static libs.

meson-python is self-hosting, so pkg_buildpython runs meson and ninja just to
install the backend -- hence ninja-build in the image and PYPKG_HOST_LIST in
dependency order rather than alphabetical. And pip puts meson and cython in
work/.py-host/bin, which nothing had ever looked in: without that on PATH the
backend fails to install with `meson executable "meson" not found` while pip
list shows it right there.

THE GATE THAT WOULD HAVE CAUGHT ALL OF IT is qa/replica/test_klippy_extras_import.py:
walk the real /usr/data/config include graph, map every section to its extra,
import each on the printer's own interpreter. It needs no MCU, because config
load happens before klippy:mcu_identify. Nothing asked that question before,
which is how two comments in this repo came to assert the module guarded its
own import. test_numpy.py asks for numeric answers rather than a module object
-- linalg.lstsq specifically, because -Dallow-noblas selects the bundled
lapack_lite and _r2_sinusoidal_fit calls it every calibration round. And
test_install.py's include check read only ff-*.cfg, so FlashForge's four --
printer.vibration.cfg among them -- were never asked about at all.

ff-toolchange.cfg wraps the two search commands in _FF_SHAPER_PREP, as
SHAPER_CALIBRATE already was. STEPPER_RESONANCE_FACTORY_CALIBRATE is
deliberately NOT wrapped: it is a gcode_macro, not a registered command, and
same-named sections merge under RawConfigParser(strict=False) -- a wrapper
would not wrap it, it would BE it, with rename_existing pointing at itself. It
needs none, since its body is two calls to a command that is wrapped. No
SAVE_CONFIG in any macro either; it restarts klippy and stays the operator's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EJr2x7aDaY889qwSxbdLt8
2026-08-31 13:42:57 +03:00

136 lines
5.2 KiB
Python

"""numpy on the printer's interpreter: does it import, and do the four things klippy asks of it work.
WHY A SMOKE TEST AND NOT JUST AN IMPORT
anvil-python-numpy is the one package in this feed built with meson-python
rather than setuptools, against a cross file this repo writes itself
(pkg_mesoncross), with `-Dallow-noblas=true` and `-Ddisable-optimization=true`.
Each of those is a place where a build can succeed, install cleanly, import
fine, and be wrong underneath:
* allow-noblas selects numpy's bundled `lapack_lite` instead of a real
LAPACK. `np.linalg.lstsq` is the call that goes through it, and it is not
hypothetical -- `_r2_sinusoidal_fit` in extras/stepper_resonance_tester.py
makes it on every VFA calibration round.
* a cross build that silently produced host objects would fail at import,
which test_klippy_extras_import.py already covers -- but one that lost an
accelerated path while keeping the module would not.
So this asks for numeric answers, not for a module object. The four calls
below are exactly the ones the calibration path makes: rfft and hanning in
`harmonic_extract_using_fft`, polyfit in `parabolic_interpolation`, lstsq in
`_r2_sinusoidal_fit`.
WHY NOT THE ABI
test_abi.py already sweeps every ELF object on the filesystem, so numpy's
extensions are covered there the moment they ship, and duplicating it here
would only make that sweep's failures arrive twice. This is the other half:
the ABI test proves the kernel will load them, this proves they compute.
"""
import pytest
pytestmark = pytest.mark.replica
MODDIR = "/usr/data/anvil"
# Ours, not FlashForge's 3.8.2. FlashForge's rootfs HAD a numpy; that one is
# built for 3.8 and is not importable here, which is the whole reason this
# package exists.
PY = MODDIR + "/bin/python3.13"
ENV = MODDIR + "/anvil-env.sh"
@pytest.fixture(scope="module")
def box(printer):
"""The installed machine, with the interpreter this asks about."""
if not printer.file(PY).exists:
pytest.fail(
"there is no %s, so there is no interpreter to ask -- "
"`make build` first." % PY)
return printer
def _py(box, body, timeout=300):
return box.sh(". %s\nexec %s - <<'PYEOF'\n%s\nPYEOF\n" % (ENV, PY, body),
timeout=timeout)
def test_numpy_imports_at_all(box):
"""The dependency anvil-klipper declares, present on the machine.
Separate from the maths below so a missing package reads as a missing
package rather than as four failed calculations.
"""
res = _py(box, "import numpy; print(numpy.__version__)")
assert res.ok, (
"numpy does not import on %s, so klippy cannot start: klippy.py:122 "
"loads [stepper_resonance_tester] and klippy.py:103 does not catch "
"ImportError.\nexit=%s\n%s\n%s"
% (PY, res.code, res.out[-2000:], res.err[-2000:]))
assert res.out.strip(), "numpy imported but reported no version"
def test_the_four_calls_the_calibration_makes(box):
"""rfft, hanning, polyfit, lstsq -- with answers checked, not just no traceback.
The values are the ones a correct numpy must produce, computed against
closed forms rather than against a recorded run of this same code, so a
subtly wrong build cannot agree with its own output.
"""
body = r'''
import numpy as np
fails = []
def check(name, got, want, tol=1e-6):
if abs(got - want) > tol:
fails.append("%s: got %r, want %r" % (name, got, want))
# rfft on a pure tone: a 16-sample signal at exactly bin 3 must put all its
# energy in that bin and (near) none elsewhere.
n = 16
sig = np.cos(2 * np.pi * 3 * np.arange(n) / n)
spec = np.abs(np.fft.rfft(sig))
check("rfft peak bin", float(np.argmax(spec)), 3.0)
check("rfft peak amplitude", float(spec[3]), n / 2.0, 1e-9)
# hanning: a symmetric window, zero at both ends, 1.0 in the middle for odd N.
w = np.hanning(9)
check("hanning[0]", float(w[0]), 0.0, 1e-12)
check("hanning[-1]", float(w[-1]), 0.0, 1e-12)
check("hanning mid", float(w[4]), 1.0, 1e-12)
# polyfit deg 2 on points taken FROM a known parabola: 2x^2 - 3x + 1.
x = np.array([-2.0, -1.0, 0.0, 1.0, 2.0])
y = 2 * x ** 2 - 3 * x + 1
c = np.polyfit(x, y, 2)
check("polyfit a", float(c[0]), 2.0, 1e-6)
check("polyfit b", float(c[1]), -3.0, 1e-6)
check("polyfit c", float(c[2]), 1.0, 1e-6)
# lstsq: THE lapack_lite CALL. Overdetermined exact-fit system, y = 2a + 3b.
A = np.array([[1.0, 0.0], [0.0, 1.0], [1.0, 1.0]])
rhs = np.array([2.0, 3.0, 5.0])
sol, _res, _rank, _sv = np.linalg.lstsq(A, rhs, rcond=None)
check("lstsq x0", float(sol[0]), 2.0, 1e-6)
check("lstsq x1", float(sol[1]), 3.0, 1e-6)
if fails:
print("FAILURES")
for f in fails:
print(" " + f)
else:
print("ALL OK")
'''
res = _py(box, body)
assert res.ok, (
"the numpy smoke test did not run to completion on %s.\nexit=%s\n%s\n%s"
% (PY, res.code, res.out[-3000:], res.err[-3000:]))
assert "ALL OK" in res.out, (
"numpy is installed and imports, but computes wrong answers -- so the "
"cross build produced something that loads and does not work. lstsq "
"failing on its own points at -Dallow-noblas and the bundled "
"lapack_lite; the rest points at the cross file.\n%s"
% res.out[-3000:])