From 516dc05feeb2ce90fdbb10ef3f014c999c9ae690 Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Thu, 15 Oct 2026 12:29:39 -0500 Subject: [PATCH] generate(): calibrated 16-bit form on firmware 3.9.1+ Firmware 3.9.1 (caps generate16 / cap.generate_bits 16) takes generate in volts on a DAC path, or microamps on the 4-20 mA output, calibrates it and plays it at the DAC's full 16 bits. generate() now sends the values when the pod has it (and lets the pod route, or "route": false), instead of 8-bit levels: about 50 uV a step on 3v3 instead of 13 mV, 0.25 uA instead of 63 uA. Peaks past the path raise ValueError. Older firmware keeps the levels. - Capabilities.generate16 (status caps, cap.generate_bits) - MCP generate: result reports bits (16 or 8); description updated - e2e: the invalid-argument case uses amplitude 0 (0.1 mV is valid at 16 bits) - HW: SDK e2e 116/0, MCP 10/0, OpenHTF 2/0 on baea06 with the 3.9.1 dev build (benchpod-firmware#52) Co-Authored-By: Claude Opus 5.5 --- packages/embeddedci-mcp/CHANGELOG.md | 5 ++ .../src/embeddedci_mcp/models.py | 2 + .../src/embeddedci_mcp/server.py | 11 ++- packages/embeddedci-mcp/tests/test_tools.py | 2 +- packages/embeddedci/CHANGELOG.md | 5 ++ packages/embeddedci/README.md | 13 ++- .../src/embeddedci/benchpod/capabilities.py | 6 ++ .../src/embeddedci/benchpod/client.py | 53 +++++++++++- packages/embeddedci/tests/api_surface.json | 1 + packages/embeddedci/tests/e2e/test_e2e_pod.py | 4 +- packages/embeddedci/tests/test_generate16.py | 86 +++++++++++++++++++ 11 files changed, 177 insertions(+), 11 deletions(-) create mode 100644 packages/embeddedci/tests/test_generate16.py diff --git a/packages/embeddedci-mcp/CHANGELOG.md b/packages/embeddedci-mcp/CHANGELOG.md index a4e8517..e5f98d7 100644 --- a/packages/embeddedci-mcp/CHANGELOG.md +++ b/packages/embeddedci-mcp/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog — `embeddedci-mcp` +## Unreleased + +- `generate` plays the calibrated 16-bit waveform on firmware 3.9.1+ (through the SDK's + `generate16`), and its result says which form ran: `bits` 16 or 8. + ## 2.12.0 - New tool `usb_network`: read the pod's USB network setting, or turn the adapter off over the USB diff --git a/packages/embeddedci-mcp/src/embeddedci_mcp/models.py b/packages/embeddedci-mcp/src/embeddedci_mcp/models.py index e94c103..f462791 100644 --- a/packages/embeddedci-mcp/src/embeddedci_mcp/models.py +++ b/packages/embeddedci-mcp/src/embeddedci_mcp/models.py @@ -515,6 +515,8 @@ class GenerateResult(BaseModel): freq_hz: float dac_path: str cotrig: bool + bits: int = Field(default=8, description=( + "16 when the pod played the calibrated 16-bit waveform (firmware 3.9.1+), 8 for 8-bit levels.")) class StopResult(BaseModel): diff --git a/packages/embeddedci-mcp/src/embeddedci_mcp/server.py b/packages/embeddedci-mcp/src/embeddedci_mcp/server.py index 60d702c..0443aba 100644 --- a/packages/embeddedci-mcp/src/embeddedci_mcp/server.py +++ b/packages/embeddedci-mcp/src/embeddedci_mcp/server.py @@ -1550,17 +1550,22 @@ async def generate( "Route dac_path first. false = keep the current analog path (e.g. after analog_path('cal1') " "for a DAC-to-ADC loopback); dac_path then only sets the volts scaling."))] = True, ) -> m.GenerateResult: - """Drive a sine, square or sawtooth on a DAC output (built from 8-bit levels). + """Drive a sine, square or sawtooth on a DAC output. + + On firmware 3.9.1+ the pod takes the volts, calibrates them for dac_path and plays the waveform + at the DAC's full 16 bits (bits=16 in the result); both peaks must then be on the path. Older firmware builds it from 8-bit levels (bits=8): 256 steps over the path. dac_path current_out plays it as a current on the 4-20 mA output (J9), with amplitude and offset - in amps and steps of about 63 uA. See current_out for the wiring it needs. The DAC voltage + in amps and steps of about 0.25 uA (63 uA on older firmware). See current_out for the wiring it needs. The DAC voltage outputs are switched off for it. dac_stop then leaves the loop where the waveform stopped: call current_out(0.004) to go back to 4 mA. """ handle = await _call(lambda: SESSION.require().generate( waveform, freq_hz=freq_hz, amplitude=amplitude, offset=offset, dac_path=dac_path, duration=duration, sample_rate_hz=sample_rate_hz, on_capture=on_capture, route=route)) - return m.GenerateResult(waveform=waveform, freq_hz=freq_hz, dac_path=dac_path, cotrig=handle.cotrig) + bits = 16 if (handle.data or {}).get("bits") == 16 else 8 + return m.GenerateResult(waveform=waveform, freq_hz=freq_hz, dac_path=dac_path, cotrig=handle.cotrig, + bits=bits) @mcp.tool(annotations=_ann("Stop the DAC", idempotent=True)) diff --git a/packages/embeddedci-mcp/tests/test_tools.py b/packages/embeddedci-mcp/tests/test_tools.py index 565e1f2..328e060 100644 --- a/packages/embeddedci-mcp/tests/test_tools.py +++ b/packages/embeddedci-mcp/tests/test_tools.py @@ -526,7 +526,7 @@ def test_capture_correlated(connected): def test_generate_and_stop(connected): result = call("generate", waveform="sine", freq_hz=1000, amplitude=1.0, on_capture=True) - assert result == {"waveform": "sine", "freq_hz": 1000.0, "dac_path": "5v", "cotrig": True} + assert result == {"waveform": "sine", "freq_hz": 1000.0, "dac_path": "5v", "cotrig": True, "bits": 8} assert connected.requests[-2] == {"cmd": "dac_out", "path": "5v"} assert connected.requests[-1]["amplitude"] == 51 assert call("dac_stop") == {"stopped": True} diff --git a/packages/embeddedci/CHANGELOG.md b/packages/embeddedci/CHANGELOG.md index 3fb145f..bab292f 100644 --- a/packages/embeddedci/CHANGELOG.md +++ b/packages/embeddedci/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +- `generate()` on firmware 3.9.1+ (`Capabilities.generate16`) sends the volts (or microamps on + `current_out`) and the pod plays the waveform calibrated at the DAC's full 16 bits, instead of + 8-bit levels (256 steps: about 13 mV on `3v3`, 63 µA on the 4-20 mA output). Both peaks must be + on the path. Older firmware keeps the 8-bit levels. `route=False` still keeps the analog + switching. - `@pytest.mark.benchpod_capability("analog")` skips a test on the digital-only board and runs it on firmware that does not announce the flag, as `BenchPod.can_analog` does (it used to skip there too). diff --git a/packages/embeddedci/README.md b/packages/embeddedci/README.md index 78e4d68..68bf1d5 100644 --- a/packages/embeddedci/README.md +++ b/packages/embeddedci/README.md @@ -1017,7 +1017,8 @@ print(max(loop.currents)) # a capture of J8 carries the loop cu bp.replay(loop, dac_path="current_out") # play the captured loop back on J9 ``` -- `generate` builds 8-bit levels: steps of about 63 µA. `replay` is 16-bit. +- `generate` steps are about 0.25 µA on firmware 3.9.1+ (`capabilities.generate16`, 16-bit) and + 63 µA on older firmware (8-bit levels). `replay` is 16-bit. - Stopping the returned handle returns the loop to 4 mA. A bare `dac_stop()` does not. - A capture or recording of a voltage has no current to reproduce: on `current_out` it needs `mapping="fit"`, which stretches its shape over the output's range. The same holds for a @@ -1144,7 +1145,15 @@ from embeddedci.benchpod import Fault # A parametric waveform, in volts: 0-3.3 V square wave that stops by itself after 5 s. bp.generate("square", freq_hz=10, amplitude=1.65, offset=1.65, dac_path="3v3", duration=5.0) +``` + +On firmware 3.9.1+ (`capabilities.generate16`) the pod takes the volts, applies its own per-path +calibration and plays the waveform at the DAC's full 16 bits (about 50 µV a step on `3v3`); both +peaks must be on the path (0 to 3.3 V works: the pod clamps a peak just past an end it cannot quite +reach). Older firmware builds it from 8-bit levels: 256 steps +over the path (about 13 mV on `3v3`). +```python golden = bp.capture_adc(16_384, sample_rate_hz=100_000, source="ext") with bp.replay(golden, dac_path="5v"): # loops until the block exits @@ -1627,7 +1636,7 @@ reported keeps its default (`False`, `0` or `""`), so a feature check is just |---|---| | Identity | `board`, `firmware_version`, `board_rev` (`"v2"`, `"v3"`, `"unknown"`, or `""`), `la_vccio_mv` (the LA bank voltage the pod reports) | | ADC | `adc_bits`, `adc_fullscale_mv`, `adc_channels`, `adc_offset_counts`, `adc_affine` (the front-end fit), `adc_max_count`, `counts_to_volts(count)` | -| DAC and replay | `dac`, `dac_dc`, `dac_replay`, `dac_deep_replay`, `dac_control_loop`, `dac_loop_sources` (gateware v29+), `dac_loop_input_map` (v30+), `dac_cotrig` (v27+), `dac_bits`, `dac_replay_bits`, `dac_replay_max_samples`, `dac_fullscale_mv`, `dac_channels`, `dac_limits` (output limits on the DAC paths) | +| DAC and replay | `dac`, `dac_dc`, `dac_replay`, `dac_deep_replay`, `dac_control_loop`, `dac_loop_sources` (gateware v29+), `dac_loop_input_map` (v30+), `dac_cotrig` (v27+), `generate16` (firmware 3.9.1+: `generate` in calibrated 16-bit volts), `dac_bits`, `dac_replay_bits`, `dac_replay_max_samples`, `dac_fullscale_mv`, `dac_channels`, `dac_limits` (output limits on the DAC paths) | | Features | `scope`, `analyzer`, `serial`, `tunnel`, `command`, `ota`, `la_pins`, `gpio_read` (direct pin reads, v35+), `capture_trigger` (v35+), `power_profile`, `capture_b64` (faster ADC read-back, used automatically), `nrst_pin` (the dedicated reset pin, rev3), `usb_cc` (rev3), `spi_master` (v45+), `spi_stream`, `calibrate`, `current_out`, `can`, `pod_current` (`PowerStatus.pod`), `sensor_types` (the BME280/SHT4x/MPU-6050 models), `gps` (the GPS receiver, gateware v48+), `analog` (`False` on the digital-only board, `None` when not announced) | | Firmware updates and policies | `flash_kb` (MCU flash in KiB, 0 when not reported), `blob_slots`, `ota_sig`, `sig_policy` (`"audit"`, `"required"`, ...), `sig_keys`, `sig_policy_cmd`, `lan_policy` (`"open"`, `"locked"`, `"off"`), `lan_policy_cmd`, `tunnel_max_tier`, `lease_state` (the pod refuses LAN writes while a cloud job holds it), `cloud_ca`, `cloud_proxy`, `ws_auth_v2` (server only) | | Boot health | `safe_mode`, `safe_reason`, `last_crash`, `reset_cause`, `boot_warning()` (a one-line warning after safe mode or a crash, else `None`) | diff --git a/packages/embeddedci/src/embeddedci/benchpod/capabilities.py b/packages/embeddedci/src/embeddedci/benchpod/capabilities.py index a0a2621..a65b30c 100644 --- a/packages/embeddedci/src/embeddedci/benchpod/capabilities.py +++ b/packages/embeddedci/src/embeddedci/benchpod/capabilities.py @@ -135,6 +135,10 @@ class Capabilities: #: DAC↔capture co-trigger: a replay can be armed to fire on the next capture's hardware t0 #: (phase-locked, gateware >= v27). See ``replay_waveform(on_capture=True)``. dac_cotrig: bool = False + #: ``generate`` takes the waveform in volts (or amps on the 4-20 mA output) and plays it at + #: the DAC's full 16 bits, calibrated (firmware 3.9.1+). Without it the generator works in + #: 8-bit levels: 256 steps over the output's range. + generate16: bool = False dac_bits: int = 8 dac_replay_bits: int = 8 dac_replay_max_samples: int = 4096 @@ -295,6 +299,7 @@ def from_status(cls, status: Mapping[str, Any]) -> "Capabilities": ("dac_deep_replay", "dac_deep_replay"), ("dac_control_loop", "dac_control_loop"), ("dac_cotrig", "dac_cotrig"), + ("generate16", "generate16"), ("dac_loop_sources", "dac_loop_sources"), ("dac_loop_input_map", "dac_loop_input_map"), ("ota", "ota"), @@ -355,6 +360,7 @@ def from_parameters(cls, params: Mapping[str, Any]) -> "Capabilities": c.dac_fullscale_mv = v if (v := _as_int(params, "cap.dac_channels")) is not None and v > 0: c.dac_channels = v + c.generate16 = _as_int(params, "cap.generate_bits") == 16 for attr, key in ( ("dac", "cap.dac"), ("dac_dc", "cap.dac_dc"), ("dac_replay", "cap.dac_replay"), ("dac_deep_replay", "cap.dac_deep_replay"), diff --git a/packages/embeddedci/src/embeddedci/benchpod/client.py b/packages/embeddedci/src/embeddedci/benchpod/client.py index ec83563..97c0d64 100644 --- a/packages/embeddedci/src/embeddedci/benchpod/client.py +++ b/packages/embeddedci/src/embeddedci/benchpod/client.py @@ -1604,13 +1604,15 @@ def generate(self, waveform: Waveshape = "sine", *, freq_hz: float, amplitude: f """Generate a parametric waveform (``sine``/``square``/``sawtooth``) on a DAC output path. ``amplitude`` is the peak in volts and ``offset`` the centre in volts (default: the middle - of ``dac_path``'s range — 0 V on the bipolar ±12 V ``12v`` path). The firmware builds the - waveform from 8-bit levels, so volts are + of ``dac_path``'s range — 0 V on the bipolar ±12 V ``12v`` path). On firmware 3.9.1+ + (:attr:`Capabilities.generate16`) the pod takes the volts, converts them with its per-path + calibration and plays the waveform at the DAC's full 16 bits; both peaks must then be on + the path (a peak within 5% of an end the pod cannot quite reach is clamped to it). Older firmware builds it from 8-bit levels, so volts are quantised to ``full_scale / 255`` using the same volts→code mapping as :meth:`replay`. ``dac_path="current_out"`` plays it as a current on the 4-20 mA output (J9): ``amplitude`` and ``offset`` are then AMPS (``amplitude=0.006, offset=0.012`` swings 6 to - 18 mA, in steps of about 63 µA). The DAC voltage outputs are switched off, and stopping + 18 mA, in steps of about 0.25 µA at 16 bits, 63 µA at 8 bits). The DAC voltage outputs are switched off, and stopping the handle returns the loop to 4 mA. See :meth:`current_out` for the wiring. ``duration`` (seconds) stops it by itself; omitted, it runs until :meth:`dac_stop` or the returned handle's ``stop()``. ``on_capture=True`` defers the start to the next capture's @@ -1625,6 +1627,10 @@ def generate(self, waveform: Waveshape = "sine", *, freq_hz: float, amplitude: f check_choice(dac_path, DAC_PATHS, "dac_path") if freq_hz <= 0: raise ValueError(f"freq_hz must be > 0, got {freq_hz!r}") + if self.capabilities.generate16: + return self._generate16(waveform, freq_hz=freq_hz, amplitude=amplitude, offset=offset, + dac_path=dac_path, duration=duration, + sample_rate_hz=sample_rate_hz, on_capture=on_capture, route=route) vmin, vmax = self._output_range(dac_path, generator=True) unit = "A" if dac_path == _CURRENT_OUT else "V" span = vmax - vmin @@ -1658,6 +1664,47 @@ def generate(self, waveform: Waveshape = "sine", *, freq_hz: float, amplitude: f return DacHandle(stop=self._stop_for(dac_path), dac_path=dac_path, cotrig=bool(d.get("cotrig", False)), data=d) + def _generate16(self, waveform: str, *, freq_hz: float, amplitude: float, offset: Optional[float], + dac_path: str, duration: Optional[float], sample_rate_hz: Optional[float], + on_capture: bool, route: bool) -> DacHandle: + """:meth:`generate` on firmware 3.9.1+: the calibrated 16-bit form. The pod routes the + output itself (unless ``route=False``) and refuses peaks it cannot reach.""" + vmin, vmax = self._output_range(dac_path) + current = dac_path == _CURRENT_OUT + unit = "A" if current else "V" + amp = float(amplitude) + step = (vmax - vmin) / 65535.0 + if not amp >= step: + raise ValueError(f"amplitude must be at least {step:.3g} {unit} (one 16-bit step) on the " + f"{dac_path} path, got {amplitude!r}") + off = (vmin + vmax) / 2.0 if offset is None else float(offset) + # A little slack for the float edges of the range; the pod checks against its calibration. + slack = (vmax - vmin) * 1e-6 + if off - amp < vmin - slack or off + amp > vmax + slack: + raise ValueError(f"the peaks {off - amp:.4g} to {off + amp:.4g} {unit} must stay within " + f"{vmin:.4g}..{vmax:.4g} {unit} on the {dac_path} path") + req: Dict[str, Any] = {"cmd": "generate", "waveform": waveform, "freq": float(freq_hz)} + if current: + req["amplitude_ua"] = round(amp * 1e6) + req["offset_ua"] = round(off * 1e6) + else: + req.update({"path": dac_path, "amplitude_v": amp, "offset_v": off}) + if duration is not None: + ms = _seconds_to_ms(duration, "duration") + if ms <= 0: + raise ValueError("duration must be at least 1 ms; omit it to run until stopped") + req["duration_ms"] = ms + mhz = _capture.rate_mhz(sample_rate_hz) + if mhz is not None: + req["sample_rate_mhz"] = mhz + if on_capture: + req["on_capture"] = True + if not route: + req["route"] = False + d = _dict(self.command(req)) + return DacHandle(stop=self._stop_for(dac_path), dac_path=dac_path, + cotrig=bool(d.get("cotrig", False)), data=d) + def dac_stop(self) -> None: """Stop any DAC output: generator, replay or control loop. Idempotent.""" self.command({"cmd": "dac_stop"}) diff --git a/packages/embeddedci/tests/api_surface.json b/packages/embeddedci/tests/api_surface.json index de70078..2d07acd 100644 --- a/packages/embeddedci/tests/api_surface.json +++ b/packages/embeddedci/tests/api_surface.json @@ -223,6 +223,7 @@ "dac_loop_sources", "dac_loop_input_map", "dac_cotrig", + "generate16", "dac_bits", "dac_replay_bits", "dac_replay_max_samples", diff --git a/packages/embeddedci/tests/e2e/test_e2e_pod.py b/packages/embeddedci/tests/e2e/test_e2e_pod.py index f2f9194..3caca77 100644 --- a/packages/embeddedci/tests/e2e/test_e2e_pod.py +++ b/packages/embeddedci/tests/e2e/test_e2e_pod.py @@ -96,7 +96,7 @@ def test_reset_line_and_usb_cc(pod, rev3): lambda p: p.enable_pullup(7), lambda p: p.enable_pulldown(1), lambda p: p.set_la_voltage(5), - lambda p: p.generate("sine", freq_hz=100, amplitude=0.0001), + lambda p: p.generate("sine", freq_hz=100, amplitude=0.0), lambda p: p.capture_adc(0), lambda p: p.capture_la(64, sample_rate_hz=0), lambda p: p.replay([1.0], dac_path="24v"), @@ -106,7 +106,7 @@ def test_reset_line_and_usb_cc(pod, rev3): lambda p: p.decode([0, 1], "can"), lambda p: p.control_loop(vmin=50000, vmax=1000), ], ids=["analog_path alias", "volts on off", "adc source", "pullup on LA7", "pulldown on LA1", - "la voltage", "tiny amplitude", "zero samples", "zero rate", "dac path", "fpga image", + "la voltage", "zero amplitude", "zero samples", "zero rate", "dac path", "fpga image", "zero steps", "9 CAN bytes", "protocol", "inverted clamp"]) def test_invalid_arguments_raise_before_touching_the_pod(pod, bad_call): with pytest.raises(ValueError): diff --git a/packages/embeddedci/tests/test_generate16.py b/packages/embeddedci/tests/test_generate16.py new file mode 100644 index 0000000..591a40e --- /dev/null +++ b/packages/embeddedci/tests/test_generate16.py @@ -0,0 +1,86 @@ +"""generate() on firmware 3.9.1+ (Capabilities.generate16): the calibrated 16-bit form. + +The pod gets volts (or microamps on the 4-20 mA output) and converts them with its own per-path +calibration, so the SDK sends the values, not 8-bit levels, and lets the pod route the output. +""" + +from __future__ import annotations + +import pytest + +from embeddedci.benchpod.capabilities import Capabilities +from embeddedci.benchpod.client import BenchPod + + +class _Fake: + def __init__(self, caps): + self.caps = caps + self.commands = [] + + def status(self): + return {"board": "stm32h563", "adc_bits": 16, "caps": self.caps} + + def command(self, req): + self.commands.append(req) + if req.get("cmd") == "current_out": + return {"min_ua": 4055, "max_ua": 20094} + if req.get("cmd") == "generate": + return {"bits": 16} if "generate16" in self.caps else None + return {} + + def close(self): + pass + + +def _pod(monkeypatch, caps): + monkeypatch.delenv("BENCHPOD_LA_VOLTAGE", raising=False) + t = _Fake(caps) + return BenchPod(transport=t, lease=False), t + + +def test_volts_are_sent_and_the_pod_routes(monkeypatch): + bp, t = _pod(monkeypatch, ["analog", "dac", "generate16"]) + assert bp.capabilities.generate16 + h = bp.generate("sine", freq_hz=1000, amplitude=0.004, offset=1.65, dac_path="3v3") + assert t.commands == [{"cmd": "generate", "waveform": "sine", "freq": 1000.0, "path": "3v3", + "amplitude_v": 0.004, "offset_v": 1.65}] + assert h.data == {"bits": 16} + + +def test_route_false_and_on_capture(monkeypatch): + bp, t = _pod(monkeypatch, ["generate16"]) + bp.generate("sawtooth", freq_hz=100, amplitude=0.05, offset=2.5, dac_path="5v", route=False, + on_capture=True, duration=0.5) + assert t.commands[-1] == {"cmd": "generate", "waveform": "sawtooth", "freq": 100.0, "path": "5v", + "amplitude_v": 0.05, "offset_v": 2.5, "duration_ms": 500, + "on_capture": True, "route": False} + + +def test_current_out_in_microamps(monkeypatch): + bp, t = _pod(monkeypatch, ["generate16", "current_out"]) + bp.generate("square", freq_hz=10, amplitude=0.004, offset=0.012, dac_path="current_out") + gen = [c for c in t.commands if c["cmd"] == "generate"][-1] + assert gen["amplitude_ua"] == 4000 and gen["offset_ua"] == 12000 and "path" not in gen + + +def test_peaks_past_the_path_are_refused_not_clipped(monkeypatch): + bp, t = _pod(monkeypatch, ["generate16"]) + with pytest.raises(ValueError, match="must stay within 0..3.3 V"): + bp.generate("sine", freq_hz=10, amplitude=1.0, offset=3.0, dac_path="3v3") + with pytest.raises(ValueError, match="one 16-bit step"): + bp.generate("sine", freq_hz=10, amplitude=0, offset=1.0, dac_path="3v3") + assert not [c for c in t.commands if c["cmd"] == "generate"] + + +def test_older_firmware_keeps_the_8bit_levels(monkeypatch): + bp, t = _pod(monkeypatch, ["analog", "dac"]) + assert not bp.capabilities.generate16 + bp.generate("sine", freq_hz=10, amplitude=1.0, offset=2.5, dac_path="5v") + gen = t.commands[-1] + assert gen["amplitude"] == 51 and gen["offset"] == 128 and "amplitude_v" not in gen + + +def test_cloud_parameters(): + assert Capabilities.from_parameters({"cap.generate_bits": "16"}).generate16 + assert not Capabilities.from_parameters({"cap.generate_bits": "8"}).generate16 + assert not Capabilities.from_parameters({}).generate16