From 440535bdd42717252b13a1b86ae0f524308f7977 Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Thu, 15 Oct 2026 12:33:07 -0500 Subject: [PATCH 1/3] DAC waveforms default to the 3.3 V output, not 5 V generate and replay (SDK, MCP tools, OpenHTF helpers) used dac_path="5v" when the caller left the output out, so one call could put 5 V on a 3.3 V DUT pin. They now default to 3v3 (DEFAULT_DAC_PATH). With route=False nothing is switched and the path only scales the volts, so the default follows the output this connection last routed (cal1 = 5v, cal2 = 12v), else 5v as before, which keeps the cal1 loopbacks correct. replay_waveform and Waveforms.preview fall back to 3v3; dac_replay_phase now uses the waveform's own path. MCP generate reports the output used, and the instructions tell agents to pick the output the pin tolerates. Co-Authored-By: Claude Opus 5.5 --- packages/embeddedci-mcp/CHANGELOG.md | 8 ++ packages/embeddedci-mcp/README.md | 4 +- .../src/embeddedci_mcp/guide.py | 2 + .../src/embeddedci_mcp/server.py | 25 ++++-- packages/embeddedci-mcp/tests/test_tools.py | 15 ++-- .../embeddedci-mcp/tests/tools_surface.json | 50 +++++++---- packages/embeddedci-openhtf/CHANGELOG.md | 6 ++ .../examples/analog_loopback.py | 2 +- .../src/embeddedci_openhtf/analog.py | 11 +-- .../embeddedci-openhtf/tests/test_analog.py | 11 +-- packages/embeddedci/CHANGELOG.md | 5 ++ packages/embeddedci/README.md | 11 ++- .../src/embeddedci/benchpod/client.py | 54 ++++++++++-- .../src/embeddedci/benchpod/constants.py | 3 + .../embeddedci/src/embeddedci/benchpod/dsp.py | 2 +- .../src/embeddedci/benchpod/replay.py | 2 +- .../src/embeddedci/benchpod/waveforms.py | 2 +- packages/embeddedci/tests/api_surface.json | 6 +- packages/embeddedci/tests/e2e/test_e2e_pod.py | 2 +- .../embeddedci/tests/test_dac_path_default.py | 83 +++++++++++++++++++ 20 files changed, 245 insertions(+), 59 deletions(-) create mode 100644 packages/embeddedci/tests/test_dac_path_default.py diff --git a/packages/embeddedci-mcp/CHANGELOG.md b/packages/embeddedci-mcp/CHANGELOG.md index 144adf2..8d132c9 100644 --- a/packages/embeddedci-mcp/CHANGELOG.md +++ b/packages/embeddedci-mcp/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog — `embeddedci-mcp` +## Unreleased + +- `generate` and `replay` default to `dac_path` 3v3 instead of 5v, so an agent that leaves the + output out can no longer put 5 V on a 3.3 V pin. With `route=false` they scale for the output + last routed (`analog_path` cal1 = 5v, cal2 = 12v), else 5v. `replay_waveform` falls back to 3v3 + for a waveform stored without a path. The instructions tell agents to pick the output the DUT + pin tolerates. `generate` reports the output it used. + ## 2.13.0 - `generate` plays the calibrated 16-bit waveform on firmware 3.9.1+ (through the SDK's diff --git a/packages/embeddedci-mcp/README.md b/packages/embeddedci-mcp/README.md index dfde187..e98697c 100644 --- a/packages/embeddedci-mcp/README.md +++ b/packages/embeddedci-mcp/README.md @@ -300,9 +300,9 @@ the pod's second UART, so `uart_open` keeps the console. Needs gateware v48 (`gp | Tool | Purpose | Parameters | | --- | --- | --- | -| `generate` | Drive a sine, square or sawtooth on a DAC output (volts, or amps on `current_out`). | **`waveform`**, **`freq_hz`**, **`amplitude`**, `offset`, `dac_path=5v`, `duration`, `sample_rate_hz`, `on_capture=false`, `route=true` | +| `generate` | Drive a sine, square or sawtooth on a DAC output (volts, or amps on `current_out`). | **`waveform`**, **`freq_hz`**, **`amplitude`**, `offset`, `dac_path=3v3`, `duration`, `sample_rate_hz`, `on_capture=false`, `route=true` | | `dac_stop` | Stop any DAC output: generator, replay or control loop. | none | -| `replay` | Loop your own samples, or the last ADC capture, out of the DAC. | `volts`, `from_last_capture=false`, `dac_path=5v`, `mapping=faithful`, `sample_rate_hz`, `fault`, `on_capture=false`, `route=true`, `switch_image=true` | +| `replay` | Loop your own samples, or the last ADC capture, out of the DAC. | `volts`, `from_last_capture=false`, `dac_path=3v3`, `mapping=faithful`, `sample_rate_hz`, `fault`, `on_capture=false`, `route=true`, `switch_image=true` | | `list_waveforms` | The organization's cloud waveform library. | none | | `replay_waveform` | Loop a cloud-library waveform out of the DAC. | **`waveform_id`**, `dac_path`, `mapping=faithful`, `sample_rate_hz`, `window_start=0`, `window_len=0`, `target_samples=0`, `fault`, `on_capture=false`, `switch_image=true` | | `save_capture_as_recording` | Save the last ADC capture to the cloud waveform library. | **`name`**, `full_scale_v` | diff --git a/packages/embeddedci-mcp/src/embeddedci_mcp/guide.py b/packages/embeddedci-mcp/src/embeddedci_mcp/guide.py index 170300d..bb599a1 100644 --- a/packages/embeddedci-mcp/src/embeddedci_mcp/guide.py +++ b/packages/embeddedci-mcp/src/embeddedci_mcp/guide.py @@ -64,6 +64,8 @@ Analog: DAC outputs '3v3', '5v', '12v' (bipolar +/-12 V) — dac_output, generate, replay + generate and replay default to 3v3: pass the output the DUT pin tolerates (5v and 12v + can damage a 3.3 V pin; ask the user which SMA is wired if unsure). ADC sources 'ext' front SMA (true volts, the /12 divider is applied), 'cal1' (5 V DAC loopback), 'cal2' (12 V DAC loopback), 'current_in' (4-20 mA input, J8) — adc_read, capture_adc 4-20 mA output J9 (current_out): loop powered, pin 1 plus to an external floating supply (8 V plus diff --git a/packages/embeddedci-mcp/src/embeddedci_mcp/server.py b/packages/embeddedci-mcp/src/embeddedci_mcp/server.py index 0443aba..cd798f0 100644 --- a/packages/embeddedci-mcp/src/embeddedci_mcp/server.py +++ b/packages/embeddedci-mcp/src/embeddedci_mcp/server.py @@ -1535,6 +1535,15 @@ def op() -> m.LaTimingResult: # -- DAC ------------------------------------------------------------------------------ +def _dac_path_arg(dac_path: Optional[str], route: bool) -> Dict[str, Any]: + """The dac_path keyword for generate/replay. Left out and routed: 3v3, the lowest voltage + output, passed explicitly so an older SDK (whose default is 5v) plays there too. Left out with + route false: omitted, so the SDK scales for the output already routed (cal1 = 5v).""" + if dac_path is not None: + return {"dac_path": dac_path} + return {"dac_path": "3v3"} if route else {} + + @mcp.tool(annotations=_ann("Generate a waveform", destructive=True)) async def generate( waveform: Waveshape, @@ -1542,7 +1551,9 @@ async def generate( amplitude: Annotated[float, Field(gt=0, description="Peak volts. On dac_path current_out: peak AMPS (0.006 = 6 mA).")], offset: Annotated[Optional[float], Field(description=( "Centre volts; default mid-range of dac_path. On current_out: centre AMPS (0.012 = 12 mA)."))] = None, - dac_path: DacPath = "5v", + dac_path: Annotated[Optional[DacPath], Field(description=( + "DAC output. Omitted: 3v3, the lowest voltage output (with route false: the output last routed, " + "cal1 = 5v). Pick the one the DUT pin tolerates: 5v and 12v can damage a 3.3 V pin."))] = None, duration: Annotated[Optional[float], Field(description="Seconds; omit to run until dac_stop.")] = None, sample_rate_hz: RateHz = None, on_capture: Annotated[bool, Field(description="Start on the next capture's hardware t0 (phase-locked).")] = False, @@ -1561,10 +1572,10 @@ async def generate( 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, + waveform, freq_hz=freq_hz, amplitude=amplitude, offset=offset, **_dac_path_arg(dac_path, route), duration=duration, sample_rate_hz=sample_rate_hz, on_capture=on_capture, route=route)) 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, + return m.GenerateResult(waveform=waveform, freq_hz=freq_hz, dac_path=handle.dac_path, cotrig=handle.cotrig, bits=bits) @@ -1597,7 +1608,9 @@ async def replay( volts: Annotated[Optional[List[float]], Field(description=( "Waveform in volts, one value per sample. On dac_path current_out the values are AMPS (0.004 to 0.020)."))] = None, from_last_capture: Annotated[bool, Field(description="Replay the last capture_adc instead of volts.")] = False, - dac_path: DacPath = "5v", + dac_path: Annotated[Optional[DacPath], Field(description=( + "DAC output. Omitted: 3v3, the lowest voltage output (with route false: the output last routed, " + "cal1 = 5v). Pick the one the DUT pin tolerates: 5v and 12v can damage a 3.3 V pin."))] = None, mapping: Annotated[ReplayMapping, Field(description="faithful = reproduce volts (clip); fit = auto-scale.")] = "faithful", sample_rate_hz: Annotated[Optional[float], Field(description="Replay rate; defaults to the capture's own rate.")] = None, fault: Optional[m.FaultSpec] = None, @@ -1623,7 +1636,7 @@ def op() -> m.ReplayResult: if SESSION.last_adc is None: raise SessionStateError("no ADC capture yet — run capture_adc first") source = SESSION.last_adc - return _replay_result(pod.replay(source, dac_path=dac_path, mapping=mapping, + return _replay_result(pod.replay(source, **_dac_path_arg(dac_path, route), mapping=mapping, sample_rate_hz=sample_rate_hz, fault=_fault(fault), on_capture=on_capture, route=route, switch_image=switch_image)) @@ -1677,7 +1690,7 @@ def op() -> m.CloudDeviceList: @mcp.tool(annotations=_ann("Replay a library waveform", destructive=True, cloud=True)) async def replay_waveform( waveform_id: str, - dac_path: Annotated[Optional[DacPath], Field(description="Output path; default the waveform's own (else 5v).")] = None, + dac_path: Annotated[Optional[DacPath], Field(description="Output path; default the waveform's own, else 3v3.")] = None, mapping: ReplayMapping = "faithful", sample_rate_hz: RateHz = None, window_start: Annotated[int, Field(ge=0, description="First recording sample to replay.")] = 0, diff --git a/packages/embeddedci-mcp/tests/test_tools.py b/packages/embeddedci-mcp/tests/test_tools.py index 328e060..be33300 100644 --- a/packages/embeddedci-mcp/tests/test_tools.py +++ b/packages/embeddedci-mcp/tests/test_tools.py @@ -526,14 +526,19 @@ 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, "bits": 8} - assert connected.requests[-2] == {"cmd": "dac_out", "path": "5v"} - assert connected.requests[-1]["amplitude"] == 51 + # No dac_path: the 3.3 V output, the lowest voltage one. + assert result == {"waveform": "sine", "freq_hz": 1000.0, "dac_path": "3v3", "cotrig": True, "bits": 8} + assert connected.requests[-2] == {"cmd": "dac_out", "path": "3v3"} + assert connected.requests[-1]["amplitude"] == 77 assert call("dac_stop") == {"stopped": True} - # route=false keeps a loopback set up with analog_path: no dac_out before the generator. + # route=false keeps a loopback set up with analog_path: no dac_out before the generator, and + # the volts scale for the routed DAC (cal1 is the 5 V one). call("analog_path", path="cal1") - call("generate", waveform="square", freq_hz=100, amplitude=1.0, route=False) + res = call("generate", waveform="square", freq_hz=100, amplitude=1.0, route=False) assert [r["cmd"] for r in connected.requests[-2:]] == ["analog_path", "generate"] + assert res["dac_path"] == "5v" and connected.requests[-1]["amplitude"] == 51 + res = call("generate", waveform="sine", freq_hz=100, amplitude=1.0, dac_path="12v") + assert res["dac_path"] == "12v" and connected.requests[-2] == {"cmd": "dac_out", "path": "12v"} def test_replay_sources(connected): diff --git a/packages/embeddedci-mcp/tests/tools_surface.json b/packages/embeddedci-mcp/tests/tools_surface.json index 885da67..dee8f05 100644 --- a/packages/embeddedci-mcp/tests/tools_surface.json +++ b/packages/embeddedci-mcp/tests/tools_surface.json @@ -1910,15 +1910,23 @@ "type": "number" }, "dac_path": { - "default": "5v", - "enum": [ - "3v3", - "5v", - "12v", - "current_out" + "anyOf": [ + { + "enum": [ + "3v3", + "5v", + "12v", + "current_out" + ], + "type": "string" + }, + { + "type": "null" + } ], - "title": "Dac Path", - "type": "string" + "default": null, + "description": "DAC output. Omitted: 3v3, the lowest voltage output (with route false: the output last routed, cal1 = 5v). Pick the one the DUT pin tolerates: 5v and 12v can damage a 3.3 V pin.", + "title": "Dac Path" }, "duration": { "anyOf": [ @@ -3085,15 +3093,23 @@ }, "properties": { "dac_path": { - "default": "5v", - "enum": [ - "3v3", - "5v", - "12v", - "current_out" + "anyOf": [ + { + "enum": [ + "3v3", + "5v", + "12v", + "current_out" + ], + "type": "string" + }, + { + "type": "null" + } ], - "title": "Dac Path", - "type": "string" + "default": null, + "description": "DAC output. Omitted: 3v3, the lowest voltage output (with route false: the output last routed, cal1 = 5v). Pick the one the DUT pin tolerates: 5v and 12v can damage a 3.3 V pin.", + "title": "Dac Path" }, "fault": { "anyOf": [ @@ -3249,7 +3265,7 @@ } ], "default": null, - "description": "Output path; default the waveform's own (else 5v).", + "description": "Output path; default the waveform's own, else 3v3.", "title": "Dac Path" }, "fault": { diff --git a/packages/embeddedci-openhtf/CHANGELOG.md b/packages/embeddedci-openhtf/CHANGELOG.md index e7960cd..c0b120b 100644 --- a/packages/embeddedci-openhtf/CHANGELOG.md +++ b/packages/embeddedci-openhtf/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog — `embeddedci-openhtf` +## Unreleased + +- `signal_generate`, `signal_generate_phase` and `loopback_measure_phase` default to + `dac_path="3v3"` instead of `"5v"`; `dac_replay_phase` now defaults to the path stored with the + waveform, else `"3v3"`. Pass `dac_path="5v"` to keep the old output. + ## 2.3.0 - Needs `embeddedci` 2.12.0 or later (was 2.4), the SDK whose `usb` connection reaches the USB diff --git a/packages/embeddedci-openhtf/examples/analog_loopback.py b/packages/embeddedci-openhtf/examples/analog_loopback.py index f3acb5b..e2cbcad 100644 --- a/packages/embeddedci-openhtf/examples/analog_loopback.py +++ b/packages/embeddedci-openhtf/examples/analog_loopback.py @@ -52,7 +52,7 @@ def dac_stop(test, bench): ), # 2) free-run a square wave for 2 s, then snapshot the ADC on its own. signal_generate_phase(bench, waveform="square", freq_hz=100, amplitude=1.0, - duration=2.0), + dac_path="5v", duration=2.0), adc_capture_phase(bench, samples=4096, sample_rate_hz=ADC_RATE_HZ, source="ext", pp_range=(1.8, 2.2)), ], diff --git a/packages/embeddedci-openhtf/src/embeddedci_openhtf/analog.py b/packages/embeddedci-openhtf/src/embeddedci_openhtf/analog.py index 1027974..d157b5a 100644 --- a/packages/embeddedci-openhtf/src/embeddedci_openhtf/analog.py +++ b/packages/embeddedci-openhtf/src/embeddedci_openhtf/analog.py @@ -92,7 +92,7 @@ def stim(test, bench): # -- low-level helpers (operate on a BenchPod or BenchPodPlug) --------------- def signal_generate(bench: Any, *, waveform: Waveshape, freq_hz: float, amplitude: float, - offset: Optional[float] = None, dac_path: DacPath = "5v", + offset: Optional[float] = None, dac_path: DacPath = "3v3", duration: Optional[float] = None, sample_rate_hz: Optional[float] = None) -> DacHandle: """Start a parametric DAC waveform (``sine``/``square``/``sawtooth``) on ``dac_path``. @@ -223,7 +223,7 @@ def _record_volts(test: Any, cap: Capture, *, prefix: str, attachment: Optional[ # -- phase factories --------------------------------------------------------- def signal_generate_phase(plug: type, *, waveform: Waveshape, freq_hz: float, amplitude: float, - offset: Optional[float] = None, dac_path: DacPath = "5v", + offset: Optional[float] = None, dac_path: DacPath = "3v3", duration: Optional[float] = None, sample_rate_hz: Optional[float] = None, name: str = "signal_generate") -> object: @@ -362,7 +362,7 @@ def _cap(test, bench): def loopback_measure_phase(plug: type, *, waveform: Waveshape = "sine", freq_hz: float, amplitude: float, offset: Optional[float] = None, - dac_path: DacPath = "5v", samples: int = 4096, + dac_path: DacPath = "3v3", samples: int = 4096, sample_rate_hz: Optional[float] = None, source: Optional[AdcSource] = "ext", settle: float = 0.1, prefix: str = "adc", mean_range: _Range = None, @@ -454,12 +454,13 @@ def _loop(test, bench): return _loop -def dac_replay_phase(plug: type, *, waveform_id: str, dac_path: DacPath = "5v", +def dac_replay_phase(plug: type, *, waveform_id: str, dac_path: Optional[DacPath] = None, mapping: ReplayMapping = "faithful", target_samples: int = 4096, stop_after: bool = False, switch_image: bool = True, name: str = "dac_replay") -> object: """A phase that loads a cloud-stored waveform and replays it (looping) on the DAC. + ``dac_path`` left out is the path stored with the waveform, else ``3v3``. Needs server access (``BENCHPOD_API_KEY``). By default the replay keeps looping after the phase (so a later capture phase can observe it); set ``stop_after=True`` to stop it at the end of this phase. A replay that needs the deep-replay gateware image switches the pod to it first @@ -474,7 +475,7 @@ def _replay(test, bench): if handle.switched_image is not None: test.logger.warning("switched the FPGA to the deep-replay gateware image") test.logger.info("replaying waveform %s on %s (%d samples)", - waveform_id, dac_path, handle.samples) + waveform_id, handle.dac_path, handle.samples) if stop_after: handle.stop() diff --git a/packages/embeddedci-openhtf/tests/test_analog.py b/packages/embeddedci-openhtf/tests/test_analog.py index 2bc89c7..e0c9dc7 100644 --- a/packages/embeddedci-openhtf/tests/test_analog.py +++ b/packages/embeddedci-openhtf/tests/test_analog.py @@ -65,11 +65,11 @@ def test_signal_generate_phase_converts_volts_through_generate(): freq_hz=1000, amplitude=1.0, duration=0.5)) assert rec.outcome == tr.Outcome.PASS i = _cmds(tx).index("generate") - # generate routes the DAC path first, then sends 8-bit generator codes: - # 1.0 V / (5 V / 255) = 51, offset defaults to mid-range 2.5 V -> 128. - assert tx.commands[i - 1] == {"cmd": "dac_out", "path": "5v"} + # generate routes the DAC path first (3v3 when none is given), then sends 8-bit generator + # codes: 1.0 V / (3.3 V / 255) = 77, offset defaults to mid-range 1.65 V -> 127. + assert tx.commands[i - 1] == {"cmd": "dac_out", "path": "3v3"} assert tx.commands[i] == {"cmd": "generate", "waveform": "sine", "freq": 1000.0, - "amplitude": 51, "offset": 128, "duration_ms": 500} + "amplitude": 77, "offset": 127, "duration_ms": 500} def test_signal_generate_helper_path_offset_and_rate(): @@ -169,8 +169,9 @@ def test_loopback_measure_phase_generates_captures_then_stops(): assert rec.outcome == tr.Outcome.PASS # DAC first (its path re-applies and would open a loopback relay), then route the ADC. assert _cmds(tx) == ["dac_out", "generate", "analog_path", "capture", "dac_stop"] + assert tx.commands[0] == {"cmd": "dac_out", "path": "3v3"} assert tx.commands[1] == {"cmd": "generate", "waveform": "sine", "freq": 100.0, - "amplitude": 51, "offset": 128} + "amplitude": 77, "offset": 127} assert tx.commands[2] == {"cmd": "analog_path", "path": "adc_ext"} assert tx.commands[3] == {"cmd": "capture", "samples": 8, "sample_rate_mhz": 0.05} p = _phase(rec, "loopback_measure") diff --git a/packages/embeddedci/CHANGELOG.md b/packages/embeddedci/CHANGELOG.md index 2c77695..0640af5 100644 --- a/packages/embeddedci/CHANGELOG.md +++ b/packages/embeddedci/CHANGELOG.md @@ -12,6 +12,11 @@ 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. +- `generate`, `replay`, `Waveforms.preview` and `replay_waveform` (for a waveform stored without + a path) default to `dac_path="3v3"` instead of `"5v"`, so a call that leaves the output out can + no longer put 5 V on a 3.3 V pin. Pass `dac_path="5v"` to keep the old output. With + `route=False` (a loopback set up with `analog_path`), `generate` and `replay` scale for the + output this connection last routed (`cal1` = 5 V, `cal2` = 12 V), else `5v` as before. - `@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 68bf1d5..111ce5c 100644 --- a/packages/embeddedci/README.md +++ b/packages/embeddedci/README.md @@ -1138,7 +1138,12 @@ v35+ (`Capabilities.capture_trigger`). Every DAC output keeps running until stopped. The returned `DacHandle` / `ReplayHandle` is a context manager that stops it on exit; `bp.dac_stop()` stops any output (generator, replay or -control loop). DAC paths are `"3v3"`, `"5v"` and `"12v"`. +control loop). DAC paths are `"3v3"`, `"5v"` and `"12v"`. `generate`, `replay` and +`replay_waveform` (for a waveform stored without a path) default to `"3v3"`, the lowest voltage +output: pass the output your DUT pin tolerates, since `"5v"` and `"12v"` can drive more than a +3.3 V pin takes. With `route=False` nothing is switched and the path only scales the volts, so the +default is the output this connection last routed (`analog_path("cal1")` is the 5 V DAC, +`"cal2"` the 12 V one), else `"5v"`. ```python from embeddedci.benchpod import Fault @@ -1165,11 +1170,11 @@ with bp.replay(ramp, dac_path="3v3", sample_rate_hz=10_000, print(r.samples, r.sample_rate_hz, r.deep) ``` -* `generate(waveform, *, freq_hz, amplitude, offset=None, dac_path="5v", duration=None, +* `generate(waveform, *, freq_hz, amplitude, offset=None, dac_path=None, duration=None, sample_rate_hz=None, on_capture=False)` — `sine`, `square` or `sawtooth`. `amplitude` is the peak and `offset` the centre (default: mid-range of the path). The firmware builds the waveform from 8-bit levels, so volts are quantised to full-scale / 255. -* `replay(source, *, dac_path="5v", mapping="faithful", sample_rate_hz=None, deep=None, fault=None, +* `replay(source, *, dac_path=None, mapping="faithful", sample_rate_hz=None, deep=None, fault=None, are_codes=False, on_capture=False)` — `source` is a `Capture` (its volts, at its own sample rate by default), a sequence of volts, or raw DAC codes with `are_codes=True`. `mapping="faithful"` reproduces the voltage (clipping outside the path's range), `"fit"` auto-scales. Above 2048 samples diff --git a/packages/embeddedci/src/embeddedci/benchpod/client.py b/packages/embeddedci/src/embeddedci/benchpod/client.py index 97c0d64..b3fc96a 100644 --- a/packages/embeddedci/src/embeddedci/benchpod/client.py +++ b/packages/embeddedci/src/embeddedci/benchpod/client.py @@ -55,6 +55,7 @@ DAC_OUTPUT_PATHS, DAC_PATHS, DECODE_PROTOCOLS, + DEFAULT_DAC_PATH, GPIO_MODES, LA_STEP_MAX_DELAY_US, LA_STEP_MAX_STEPS, @@ -131,6 +132,9 @@ _GENERATOR_MAX_CODE = 255 #: The 4-20 mA output (J9) as a waveform output path. Levels on it are amps. _CURRENT_OUT = "current_out" +# The DAC output each named analog path drives (cal1/cal2 loop the 5 V / 12 V DAC back to the ADC). +_ANALOG_PATH_DAC = {"dac_3v3": "3v3", "dac_5v": "5v", "dac_12v": "12v", "cal1": "5v", "cal2": "12v", + "current_out": _CURRENT_OUT} #: The sense resistor on the 4-20 mA input (J8): ``current_in`` reads the voltage across it. _CURRENT_IN_SENSE_OHMS = 249.0 #: The live zero a stopped current waveform returns to (microamps). @@ -252,6 +256,9 @@ def __init__( self._wiring: Optional[Wiring] = Wiring.coerce(wiring) if wiring is not None else None self.timeout = timeout self._lease: Optional[DeviceLease] = None + # The DAC output this connection last routed (dac_output, analog_path, a routed waveform), + # so a waveform played with route=False and no dac_path scales for it. None = unknown. + self._routed_dac_path: Optional[str] = None self._api_base = api_base or os.environ.get(API_BASE_ENV) self._api_key = api_key or os.environ.get(API_KEY_ENV) self._cloud_user_token = cloud_user_token @@ -1062,7 +1069,12 @@ def analog_path(self, path: AnalogPath) -> AnalogPathState: for a waveform played as a current (:meth:`current_out` does it for a held level). """ check_choice(path, ANALOG_PATHS, "path") - return AnalogPathState.from_reply(self.command({"cmd": "analog_path", "path": path})) + state = AnalogPathState.from_reply(self.command({"cmd": "analog_path", "path": path})) + if path in _ANALOG_PATH_DAC: + self._routed_dac_path = _ANALOG_PATH_DAC[path] + elif path == "off": + self._routed_dac_path = None # adc_ext / current_in only move the ADC side + return state def dac_output(self, path: DacOutputPath, *, volts: Optional[float] = None) -> DacOutput: """Route a DAC output path and, with ``volts``, drive that CALIBRATED DC voltage. @@ -1076,7 +1088,9 @@ def dac_output(self, path: DacOutputPath, *, volts: Optional[float] = None) -> D if path == "off": raise ValueError("volts cannot be set on the 'off' path") req["volts"] = float(volts) - return DacOutput.from_reply(self.command(req)) + out = DacOutput.from_reply(self.command(req)) + self._routed_dac_path = None if path == "off" else path + return out # -- the 4-20 mA output (J9) ------------------------------------------------ # An XTR116 two-wire transmitter. The pod owns the conversion (firmware `current_out`); the @@ -1585,6 +1599,19 @@ def _route_output(self, dac_path: str) -> None: self.command({"cmd": "analog_path", "path": _CURRENT_OUT}) else: self.command({"cmd": "dac_out", "path": dac_path}) + self._routed_dac_path = dac_path + + def _default_dac_path(self, route: bool) -> str: + """The output a waveform plays on when ``dac_path`` is left out. + + Routed (the default): ``3v3``, the lowest voltage output, so a call that leaves the output + out cannot put 5 V on a 3.3 V pin. With ``route=False`` nothing is switched and the path + only sets the volts scaling, so it follows what this connection last routed (``cal1`` is + the 5 V DAC, ``cal2`` the 12 V one), else ``5v``, the cal1 loopback's scaling. + """ + if route: + return DEFAULT_DAC_PATH + return self._routed_dac_path or "5v" def _stop_current_out(self) -> None: """Stop a waveform on the 4-20 mA output and return the loop to its live zero. ``dac_stop`` @@ -1598,7 +1625,7 @@ def _stop_for(self, dac_path: str) -> Callable[[], None]: # -- DAC generate / stop ---------------------------------------------------- def generate(self, waveform: Waveshape = "sine", *, freq_hz: float, amplitude: float, - offset: Optional[float] = None, dac_path: DacPath = "5v", + offset: Optional[float] = None, dac_path: Optional[DacPath] = None, duration: Optional[float] = None, sample_rate_hz: Optional[float] = None, on_capture: bool = False, route: bool = True) -> DacHandle: """Generate a parametric waveform (``sine``/``square``/``sawtooth``) on a DAC output path. @@ -1622,8 +1649,14 @@ def generate(self, waveform: Waveshape = "sine", *, freq_hz: float, amplitude: f By default the output path is routed first. ``route=False`` keeps the current analog switching — e.g. after ``analog_path("cal1")`` for a DAC→ADC loopback, which routing the output would undo — and ``dac_path`` then only sets the volts scaling. + + ``dac_path`` left out is ``3v3``, the lowest voltage output: pass the output your DUT pin + tolerates. With ``route=False`` it is the output this connection last routed instead + (``cal1`` → ``5v``, ``cal2`` → ``12v``), else ``5v``. """ check_choice(waveform, WAVESHAPES, "waveform") + if dac_path is None: + dac_path = self._default_dac_path(route) # type: ignore[assignment] check_choice(dac_path, DAC_PATHS, "dac_path") if freq_hz <= 0: raise ValueError(f"freq_hz must be > 0, got {freq_hz!r}") @@ -1702,6 +1735,8 @@ def _generate16(self, waveform: str, *, freq_hz: float, amplitude: float, offset if not route: req["route"] = False d = _dict(self.command(req)) + if route: + self._routed_dac_path = dac_path # the pod routed it return DacHandle(stop=self._stop_for(dac_path), dac_path=dac_path, cotrig=bool(d.get("cotrig", False)), data=d) @@ -1942,14 +1977,15 @@ def _arm_replay(self, code_bytes: bytes, *, bits: int, dac_path: str, switched_image=switched) def replay(self, source: Union[Capture, Sequence[float], Sequence[int]], *, - dac_path: DacPath = "5v", mapping: ReplayMapping = "faithful", + dac_path: Optional[DacPath] = None, mapping: ReplayMapping = "faithful", sample_rate_hz: Optional[float] = None, deep: Optional[bool] = None, fault: Union[Fault, Dict[str, Any], None] = None, are_codes: bool = False, on_capture: bool = False, route: bool = True, switch_image: bool = True) -> ReplayHandle: """Replay a waveform on the DAC, streaming it to the device over the transport. - ``route=False`` keeps the current analog switching (see :meth:`generate`). + ``route=False`` keeps the current analog switching (see :meth:`generate`). ``dac_path`` + left out is ``3v3``, or with ``route=False`` the output last routed (see :meth:`generate`). ``source`` is a :class:`Capture` (its volts are replayed), a sequence of volts, or — with ``are_codes=True`` — raw DAC codes. Volts map to codes for ``dac_path`` (``faithful`` @@ -1960,7 +1996,7 @@ def replay(self, source: Union[Capture, Sequence[float], Sequence[int]], *, The replay LOOPS until the returned :class:`ReplayHandle` is stopped, so it can run concurrently with a capture (gateware v18):: - with bp.replay(cap, dac_path="5v"): + with bp.replay(cap, dac_path="3v3"): la = bp.capture_la(8192, sample_rate_hz=1e6) ``on_capture=True`` arms the DAC to start on the NEXT capture's hardware t0 (gateware @@ -1973,6 +2009,8 @@ def replay(self, source: Union[Capture, Sequence[float], Sequence[int]], *, over the output's range. Stopping the handle returns the loop to 4 mA. See :meth:`current_out` for the wiring. """ + if dac_path is None: + dac_path = self._default_dac_path(route) # type: ignore[assignment] check_choice(dac_path, DAC_PATHS, "dac_path") check_choice(mapping, REPLAY_MAPPINGS, "mapping") bits = self._replay_bits() @@ -2010,7 +2048,7 @@ def replay_waveform(self, waveform: Union[str, "Waveform"], *, """Load a **cloud-stored** waveform from the library and replay it on the DAC. ``waveform`` is a waveform id or a :class:`~embeddedci.benchpod.waveforms.Waveform`. - ``dac_path`` defaults to the path stored with the waveform (else ``5v``). + ``dac_path`` defaults to the path stored with the waveform (else ``3v3``). ``window_start``/``window_len`` select a slice of a recording; ``target_samples`` downsamples a shallow replay. By default this uses the server's replay DSP when the device is cloud-connected; otherwise it fetches the recording and replays it over the device @@ -2033,7 +2071,7 @@ def replay_waveform(self, waveform: Union[str, "Waveform"], *, lib = self.waveforms wf = lib.get(wid) # type: ignore[arg-type] if dac_path is None: - dac_path = wf.dac_path if wf.dac_path in DAC_PATHS else "5v" # type: ignore[assignment] + dac_path = wf.dac_path if wf.dac_path in DAC_PATHS else DEFAULT_DAC_PATH # type: ignore[assignment] check_choice(dac_path, DAC_PATHS, "dac_path") # type: ignore[arg-type] path_unit = "mA" if dac_path == _CURRENT_OUT else "V" wf_unit = getattr(wf, "unit", "V") or "V" diff --git a/packages/embeddedci/src/embeddedci/benchpod/constants.py b/packages/embeddedci/src/embeddedci/benchpod/constants.py index 2b0ea2b..6aa8b7d 100644 --- a/packages/embeddedci/src/embeddedci/benchpod/constants.py +++ b/packages/embeddedci/src/embeddedci/benchpod/constants.py @@ -132,6 +132,9 @@ class Pin(IntEnum): TriggerEdge = Literal["rising", "falling", "high", "low"] DAC_PATHS: Tuple[str, ...] = get_args(DacPath) +#: The DAC output a waveform plays on when ``dac_path`` is left out: the lowest voltage output, +#: so a call that leaves it out cannot put 5 V on a 3.3 V pin. +DEFAULT_DAC_PATH = "3v3" DAC_OUTPUT_PATHS: Tuple[str, ...] = get_args(DacOutputPath) ANALOG_PATHS: Tuple[str, ...] = get_args(AnalogPath) ADC_SOURCES: Tuple[str, ...] = get_args(AdcSource) diff --git a/packages/embeddedci/src/embeddedci/benchpod/dsp.py b/packages/embeddedci/src/embeddedci/benchpod/dsp.py index f5fb3ce..d3824a7 100644 --- a/packages/embeddedci/src/embeddedci/benchpod/dsp.py +++ b/packages/embeddedci/src/embeddedci/benchpod/dsp.py @@ -231,7 +231,7 @@ def recording_to_replay_codes( raw: bytes, *, src_full_scale_v: float, - dac_path: str = "5v", + dac_path: str = "3v3", mapping: str = FAITHFUL, window_start: int = 0, window_len: int = 0, diff --git a/packages/embeddedci/src/embeddedci/benchpod/replay.py b/packages/embeddedci/src/embeddedci/benchpod/replay.py index 3385be8..d782bf0 100644 --- a/packages/embeddedci/src/embeddedci/benchpod/replay.py +++ b/packages/embeddedci/src/embeddedci/benchpod/replay.py @@ -5,7 +5,7 @@ the same time**. :class:`DacHandle` / :class:`ReplayHandle` model a running output so a test can express "drive this while I capture" as a context manager:: - with bp.replay(recording, dac_path="5v"): + with bp.replay(recording, dac_path="3v3"): la = bp.capture_la(8192, sample_rate_hz=1e6) # runs concurrently # DAC stopped on exit diff --git a/packages/embeddedci/src/embeddedci/benchpod/waveforms.py b/packages/embeddedci/src/embeddedci/benchpod/waveforms.py index 9614e12..9d11bc4 100644 --- a/packages/embeddedci/src/embeddedci/benchpod/waveforms.py +++ b/packages/embeddedci/src/embeddedci/benchpod/waveforms.py @@ -101,7 +101,7 @@ def download_recording(self, waveform_id: str) -> bytes: ) return raw if isinstance(raw, (bytes, bytearray)) else bytes(raw) - def preview(self, waveform_id: str, *, mapping: str = "faithful", dac_path: str = "5v", + def preview(self, waveform_id: str, *, mapping: str = "faithful", dac_path: str = "3v3", points: int = 4096) -> Dict[str, Any]: """Server-downsampled, output-mapped preview of a recording (what the DAC will emit).""" _, data = self._api.request( diff --git a/packages/embeddedci/tests/api_surface.json b/packages/embeddedci/tests/api_surface.json index 2d07acd..f3efea4 100644 --- a/packages/embeddedci/tests/api_surface.json +++ b/packages/embeddedci/tests/api_surface.json @@ -85,7 +85,7 @@ "enabled_pulls": "(self) -> 'List[int]'", "flash": "(self, *, swclk: 'Union[Pin, int, str, None]' = None, swdio: 'Union[Pin, int, str, None]' = None, nreset: 'Optional[bool]' = None, target: 'str' = '', file: 'str' = '', load_address: 'str' = '', target_power: 'Optional[Union[Efuse, int]]' = None, verify: 'bool' = True, reset: 'bool' = True, connect_under_reset: 'Optional[bool]' = None, clear_reset_events: 'bool' = True, openocd_bin: 'Optional[str]' = None, extra_configs: 'Sequence[str]' = (), extra_args: 'Sequence[str]' = (), timeout: 'float' = 300.0, connect_attempts: 'int' = 5, check: 'bool' = True, build: 'str' = '', repo: 'str' = '', ref: 'str' = '', artifact: 'str' = '', server_side: 'Optional[bool]' = None) -> 'FlashResult'", "fpga_image": "(self, image: 'Union[FpgaImage, int]') -> 'FpgaImageInfo'", - "generate": "(self, waveform: 'Waveshape' = 'sine', *, freq_hz: 'float', amplitude: 'float', offset: 'Optional[float]' = None, dac_path: 'DacPath' = '5v', duration: 'Optional[float]' = None, sample_rate_hz: 'Optional[float]' = None, on_capture: 'bool' = False, route: 'bool' = True) -> 'DacHandle'", + "generate": "(self, waveform: 'Waveshape' = 'sine', *, freq_hz: 'float', amplitude: 'float', offset: 'Optional[float]' = None, dac_path: 'Optional[DacPath]' = None, duration: 'Optional[float]' = None, sample_rate_hz: 'Optional[float]' = None, on_capture: 'bool' = False, route: 'bool' = True) -> 'DacHandle'", "get_la_voltage": "(self) -> 'LaVoltage'", "gpio": "(self, la: 'Union[Pin, int, str]', mode: 'GpioMode' = 'output', *, level: 'Optional[int]' = None) -> 'GpioPin'", "gpio_pins": "(self, las: 'Sequence[Union[Pin, int, str]]', mode: 'GpioMode' = 'output', *, level: 'Optional[int]' = None) -> 'List[GpioPin]'", @@ -115,7 +115,7 @@ "read_gpio": "(self, la: 'Union[Pin, int, str]') -> 'int'", "refresh_capabilities": "(self) -> 'Capabilities'", "release_gpio": "(self, *las: 'Union[Pin, int, str]') -> 'None'", - "replay": "(self, source: 'Union[Capture, Sequence[float], Sequence[int]]', *, dac_path: 'DacPath' = '5v', mapping: 'ReplayMapping' = 'faithful', sample_rate_hz: 'Optional[float]' = None, deep: 'Optional[bool]' = None, fault: 'Union[Fault, Dict[str, Any], None]' = None, are_codes: 'bool' = False, on_capture: 'bool' = False, route: 'bool' = True, switch_image: 'bool' = True) -> 'ReplayHandle'", + "replay": "(self, source: 'Union[Capture, Sequence[float], Sequence[int]]', *, dac_path: 'Optional[DacPath]' = None, mapping: 'ReplayMapping' = 'faithful', sample_rate_hz: 'Optional[float]' = None, deep: 'Optional[bool]' = None, fault: 'Union[Fault, Dict[str, Any], None]' = None, are_codes: 'bool' = False, on_capture: 'bool' = False, route: 'bool' = True, switch_image: 'bool' = True) -> 'ReplayHandle'", "replay_waveform": "(self, waveform: \"Union[str, 'Waveform']\", *, dac_path: 'Optional[DacPath]' = None, mapping: 'ReplayMapping' = 'faithful', sample_rate_hz: 'Optional[float]' = None, window_start: 'int' = 0, window_len: 'int' = 0, target_samples: 'int' = 0, fault: 'Union[Fault, Dict[str, Any], None]' = None, deep: 'Optional[bool]' = None, server_side: 'Optional[bool]' = None, on_capture: 'bool' = False, switch_image: 'bool' = True) -> 'ReplayHandle'", "reset_state": "(self) -> 'ResetState'", "reset_target": "(self, *, pulse: 'float' = 0.1) -> 'ResetState'", @@ -905,7 +905,7 @@ "find": "(self, name: 'str') -> 'Optional[Waveform]'", "get": "(self, waveform_id: 'str') -> 'Waveform'", "list": "(self) -> 'List[Waveform]'", - "preview": "(self, waveform_id: 'str', *, mapping: 'str' = 'faithful', dac_path: 'str' = '5v', points: 'int' = 4096) -> 'Dict[str, Any]'", + "preview": "(self, waveform_id: 'str', *, mapping: 'str' = 'faithful', dac_path: 'str' = '3v3', points: 'int' = 4096) -> 'Dict[str, Any]'", "rename": "(self, waveform_id: 'str', name: 'str') -> 'Waveform'", "samples_b64": "(self, waveform_id: 'str') -> 'str'", "save_recording": "(self, name: 'str', samples16: 'bytes', *, sample_rate_hz: 'float', full_scale_v: 'float', unit: 'str' = 'V') -> 'Waveform'", diff --git a/packages/embeddedci/tests/e2e/test_e2e_pod.py b/packages/embeddedci/tests/e2e/test_e2e_pod.py index 3caca77..ec72ebd 100644 --- a/packages/embeddedci/tests/e2e/test_e2e_pod.py +++ b/packages/embeddedci/tests/e2e/test_e2e_pod.py @@ -442,7 +442,7 @@ def test_route_false_keeps_a_loopback_and_route_true_reroutes(pod): cap = pod.capture_adc(8192, sample_rate_hz=20_000) assert abs(cap.dominant_frequency() - 200) < 5 and p2p(cap) > 800, (cap.dominant_frequency(), p2p(cap)) pod.analog_path("cal1") - with pod.generate("sine", freq_hz=200, amplitude=0.8, offset=1.5): + with pod.generate("sine", freq_hz=200, amplitude=0.8, offset=1.5, dac_path="5v"): assert pod.lowlevel.cal_switch_status()["cal1"] == 0 # dac_out re-routed the output diff --git a/packages/embeddedci/tests/test_dac_path_default.py b/packages/embeddedci/tests/test_dac_path_default.py new file mode 100644 index 0000000..9252f56 --- /dev/null +++ b/packages/embeddedci/tests/test_dac_path_default.py @@ -0,0 +1,83 @@ +"""A waveform played without ``dac_path`` goes to the 3.3 V output, never a fixed 5 V. + +The DUT is wired to one DAC SMA, and most bench MCUs take 3.3 V at most: a call that leaves the +output out must not drive 5 V into one. With ``route=False`` nothing is switched and the path only +scales the volts, so it follows the output this connection last routed (``cal1`` is the 5 V DAC). +""" + +from __future__ import annotations + +import pytest + +from embeddedci.benchpod.client import BenchPod +from embeddedci.benchpod.constants import DEFAULT_DAC_PATH + + +class _Fake: + def __init__(self): + self.commands = [] + + def status(self): + return {"board": "stm32h563", "adc_bits": 16} + + def command(self, req): + self.commands.append(req) + return {} + + def load_replay(self, *, data, replay, psram=False): + self.replay = replay + return {} + + def close(self): + pass + + +@pytest.fixture +def pod(monkeypatch): + monkeypatch.delenv("BENCHPOD_LA_VOLTAGE", raising=False) + t = _Fake() + return BenchPod(transport=t, lease=False), t + + +def test_default_is_3v3(): + assert DEFAULT_DAC_PATH == "3v3" + + +def test_generate_without_a_path_routes_3v3(pod): + bp, t = pod + h = bp.generate("sine", freq_hz=100, amplitude=1.0) + assert h.dac_path == "3v3" + assert t.commands[-2] == {"cmd": "dac_out", "path": "3v3"} + # 1 V over 3.3 V / 255 levels; offset at mid-range 1.65 V. + assert t.commands[-1]["amplitude"] == 77 and t.commands[-1]["offset"] == 127 + + +def test_replay_without_a_path_routes_3v3(pod): + bp, t = pod + h = bp.replay([0.0, 1.0, 3.3]) + assert h.dac_path == "3v3" + assert {"cmd": "dac_out", "path": "3v3"} in t.commands + + +def test_an_explicit_path_is_kept(pod): + bp, t = pod + assert bp.generate("sine", freq_hz=100, amplitude=1.0, dac_path="5v").dac_path == "5v" + assert t.commands[-2] == {"cmd": "dac_out", "path": "5v"} + + +@pytest.mark.parametrize(("setup", "expected"), [ + (lambda bp: bp.analog_path("cal1"), "5v"), + (lambda bp: bp.analog_path("cal2"), "12v"), + (lambda bp: bp.dac_output("3v3"), "3v3"), + (lambda bp: bp.generate("sine", freq_hz=10, amplitude=1.0, dac_path="12v"), "12v"), + (lambda bp: (bp.analog_path("cal1"), bp.analog_path("adc_ext")), "5v"), # ADC side only + (lambda bp: (bp.dac_output("3v3"), bp.analog_path("off")), "5v"), # parked: unknown again + (lambda bp: None, "5v"), # nothing routed: the cal1 loopback's scaling, as before +]) +def test_route_false_scales_for_the_routed_output(pod, setup, expected): + bp, t = pod + setup(bp) + n = len(t.commands) + h = bp.generate("square", freq_hz=10, amplitude=1.0, route=False) + assert h.dac_path == expected + assert [c["cmd"] for c in t.commands[n:]] == ["generate"] # nothing switched From 0589c01d81deb20ea9c4ff25604345766c3b6350 Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Thu, 15 Oct 2026 13:20:29 -0500 Subject: [PATCH 2/3] American spelling: centre -> center in user-facing text MCP generate's offset description, the generate() and OpenHTF docstrings, results.py, the READMEs and the loopback example. Internal names (decode.centre_of, test names) are unchanged. Co-Authored-By: Claude Opus 5.5 --- packages/embeddedci-mcp/src/embeddedci_mcp/server.py | 2 +- packages/embeddedci-mcp/tests/tools_surface.json | 2 +- packages/embeddedci-openhtf/README.md | 2 +- packages/embeddedci-openhtf/examples/analog_loopback.py | 2 +- packages/embeddedci-openhtf/src/embeddedci_openhtf/analog.py | 4 ++-- packages/embeddedci/README.md | 2 +- packages/embeddedci/src/embeddedci/benchpod/client.py | 2 +- packages/embeddedci/src/embeddedci/benchpod/results.py | 2 +- 8 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/embeddedci-mcp/src/embeddedci_mcp/server.py b/packages/embeddedci-mcp/src/embeddedci_mcp/server.py index cd798f0..b7a98b5 100644 --- a/packages/embeddedci-mcp/src/embeddedci_mcp/server.py +++ b/packages/embeddedci-mcp/src/embeddedci_mcp/server.py @@ -1550,7 +1550,7 @@ async def generate( freq_hz: Annotated[float, Field(gt=0)], amplitude: Annotated[float, Field(gt=0, description="Peak volts. On dac_path current_out: peak AMPS (0.006 = 6 mA).")], offset: Annotated[Optional[float], Field(description=( - "Centre volts; default mid-range of dac_path. On current_out: centre AMPS (0.012 = 12 mA)."))] = None, + "Center volts; default mid-range of dac_path. On current_out: center AMPS (0.012 = 12 mA)."))] = None, dac_path: Annotated[Optional[DacPath], Field(description=( "DAC output. Omitted: 3v3, the lowest voltage output (with route false: the output last routed, " "cal1 = 5v). Pick the one the DUT pin tolerates: 5v and 12v can damage a 3.3 V pin."))] = None, diff --git a/packages/embeddedci-mcp/tests/tools_surface.json b/packages/embeddedci-mcp/tests/tools_surface.json index dee8f05..498e990 100644 --- a/packages/embeddedci-mcp/tests/tools_surface.json +++ b/packages/embeddedci-mcp/tests/tools_surface.json @@ -1956,7 +1956,7 @@ } ], "default": null, - "description": "Centre volts; default mid-range of dac_path. On current_out: centre AMPS (0.012 = 12 mA).", + "description": "Center volts; default mid-range of dac_path. On current_out: center AMPS (0.012 = 12 mA).", "title": "Offset" }, "on_capture": { diff --git a/packages/embeddedci-openhtf/README.md b/packages/embeddedci-openhtf/README.md index 86a43b6..d81f210 100644 --- a/packages/embeddedci-openhtf/README.md +++ b/packages/embeddedci-openhtf/README.md @@ -191,7 +191,7 @@ from embeddedci_openhtf import ( bench = benchpod_plug("192.168.1.50:8080") test = htf.Test( - # drive a 1 V-peak sine centred on 2.5 V (5V DAC path), capture the front ADC SMA + # drive a 1 V-peak sine centered on 2.5 V (5V DAC path), capture the front ADC SMA # while it runs, stop the DAC, and assert the round trip in volts loopback_measure_phase(bench, waveform="sine", freq_hz=100, amplitude=1.0, samples=4096, sample_rate_hz=50_000, diff --git a/packages/embeddedci-openhtf/examples/analog_loopback.py b/packages/embeddedci-openhtf/examples/analog_loopback.py index e2cbcad..e8be07c 100644 --- a/packages/embeddedci-openhtf/examples/analog_loopback.py +++ b/packages/embeddedci-openhtf/examples/analog_loopback.py @@ -43,7 +43,7 @@ def dac_stop(test, bench): return htf.Test( htf.PhaseGroup( main=[ - # 1) loopback: a 100 Hz, 1 V-peak sine centred on 2.5 V (5V path), + # 1) loopback: a 100 Hz, 1 V-peak sine centered on 2.5 V (5V path), # captured while it runs; expect ~2 V peak-to-peak around 2.5 V. loopback_measure_phase( bench, waveform="sine", freq_hz=100, amplitude=1.0, offset=2.5, diff --git a/packages/embeddedci-openhtf/src/embeddedci_openhtf/analog.py b/packages/embeddedci-openhtf/src/embeddedci_openhtf/analog.py index d157b5a..1c768a4 100644 --- a/packages/embeddedci-openhtf/src/embeddedci_openhtf/analog.py +++ b/packages/embeddedci-openhtf/src/embeddedci_openhtf/analog.py @@ -97,7 +97,7 @@ def signal_generate(bench: Any, *, waveform: Waveshape, freq_hz: float, amplitud sample_rate_hz: Optional[float] = None) -> DacHandle: """Start a parametric DAC waveform (``sine``/``square``/``sawtooth``) on ``dac_path``. - ``amplitude`` is the peak and ``offset`` the centre, both in **volts** (``offset`` defaults to + ``amplitude`` is the peak and ``offset`` the center, both in **volts** (``offset`` defaults to the middle of the path's range). ``duration`` (seconds) stops it by itself; omitted, it runs until :func:`signal_stop` or the returned handle's ``stop()``. See :meth:`BenchPod.generate `. @@ -380,7 +380,7 @@ def loopback_measure_phase(plug: type, *, waveform: Waveshape = "sine", freq_hz: ``_rms_v`` / ``_min_v`` / ``_max_v``. Wire the DAC output SMA to the front ADC SMA (directly, or through the DUT whose response you - are checking) and keep ``source="ext"``. E.g. a 1 V-peak sine centred on 2.5 V straight back + are checking) and keep ``source="ext"``. E.g. a 1 V-peak sine centered on 2.5 V straight back in: ``pp_range=(1.8, 2.2), mean_range=(2.4, 2.6)``. """ _check_source(source) diff --git a/packages/embeddedci/README.md b/packages/embeddedci/README.md index 111ce5c..e7c44e1 100644 --- a/packages/embeddedci/README.md +++ b/packages/embeddedci/README.md @@ -1172,7 +1172,7 @@ with bp.replay(ramp, dac_path="3v3", sample_rate_hz=10_000, * `generate(waveform, *, freq_hz, amplitude, offset=None, dac_path=None, duration=None, sample_rate_hz=None, on_capture=False)` — `sine`, `square` or `sawtooth`. `amplitude` is the peak - and `offset` the centre (default: mid-range of the path). The firmware builds the waveform from + and `offset` the center (default: mid-range of the path). The firmware builds the waveform from 8-bit levels, so volts are quantised to full-scale / 255. * `replay(source, *, dac_path=None, mapping="faithful", sample_rate_hz=None, deep=None, fault=None, are_codes=False, on_capture=False)` — `source` is a `Capture` (its volts, at its own sample rate diff --git a/packages/embeddedci/src/embeddedci/benchpod/client.py b/packages/embeddedci/src/embeddedci/benchpod/client.py index b3fc96a..cca7286 100644 --- a/packages/embeddedci/src/embeddedci/benchpod/client.py +++ b/packages/embeddedci/src/embeddedci/benchpod/client.py @@ -1630,7 +1630,7 @@ def generate(self, waveform: Waveshape = "sine", *, freq_hz: float, amplitude: f on_capture: bool = False, route: bool = True) -> DacHandle: """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 + ``amplitude`` is the peak in volts and ``offset`` the center in volts (default: the middle 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 diff --git a/packages/embeddedci/src/embeddedci/benchpod/results.py b/packages/embeddedci/src/embeddedci/benchpod/results.py index 4b0341a..7ef1cd8 100644 --- a/packages/embeddedci/src/embeddedci/benchpod/results.py +++ b/packages/embeddedci/src/embeddedci/benchpod/results.py @@ -113,7 +113,7 @@ def rms(self) -> float: return math.sqrt(sum(v * v for v in self.volts) / len(self.volts)) def rms_ac(self) -> float: - """RMS about the mean (the AC component) — a clean amplitude for a centred waveform.""" + """RMS about the mean (the AC component) — a clean amplitude for a centered waveform.""" if not self.volts: return 0.0 m = self.mean() From 579e12fa110921ec864fa69aa8f99857a52041f9 Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Thu, 15 Oct 2026 14:52:12 -0500 Subject: [PATCH 3/3] Changelog: the DAC default entry is unreleased, not 2.13.0 Co-Authored-By: Claude Opus 5.5 --- packages/embeddedci/CHANGELOG.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/embeddedci/CHANGELOG.md b/packages/embeddedci/CHANGELOG.md index 0640af5..b53df66 100644 --- a/packages/embeddedci/CHANGELOG.md +++ b/packages/embeddedci/CHANGELOG.md @@ -4,6 +4,11 @@ - `Capabilities.scope` is False on the digital-only board. Current firmware lists `signal` on every board; it only stands for the scope on firmware without the `analog` flag. +- `generate`, `replay`, `Waveforms.preview` and `replay_waveform` (for a waveform stored without + a path) default to `dac_path="3v3"` instead of `"5v"`, so a call that leaves the output out can + no longer put 5 V on a 3.3 V pin. Pass `dac_path="5v"` to keep the old output. With + `route=False` (a loopback set up with `analog_path`), `generate` and `replay` scale for the + output this connection last routed (`cal1` = 5 V, `cal2` = 12 V), else `5v` as before. ## 2.13.0 @@ -12,11 +17,6 @@ 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. -- `generate`, `replay`, `Waveforms.preview` and `replay_waveform` (for a waveform stored without - a path) default to `dac_path="3v3"` instead of `"5v"`, so a call that leaves the output out can - no longer put 5 V on a 3.3 V pin. Pass `dac_path="5v"` to keep the old output. With - `route=False` (a loopback set up with `analog_path`), `generate` and `replay` scale for the - output this connection last routed (`cal1` = 5 V, `cal2` = 12 V), else `5v` as before. - `@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).