From 362cb17dab360b016adf3971663199e0e5203097 Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Mon, 19 Oct 2026 17:05:37 -0500 Subject: [PATCH 1/4] DMM-guided calibration: calibrate_dac, calibrate_adc_gain, partial clear SDK: BenchPod.calibrate_dac(path, [(code16, volts), ...]) stores this pod's own DAC line from two DMM readings, calibrate_adc_gain(source) measures an ADC gain through a loopback, clear_calibration(part) clears one part. Calibration gains dac (DacLine), adc_gain_ppm, dmm, measured; Capabilities.cal_dmm. The cloud transport treats the new write forms as writes (never repeated). MCP: tools calibrate_dac, calibrate_adc_gain; calibrate clear_part. Docs and changelogs (2.13.2). Co-Authored-By: Claude Opus 5.5 --- packages/embeddedci-mcp/CHANGELOG.md | 3 + packages/embeddedci-mcp/README.md | 8 +- .../src/embeddedci_mcp/models.py | 15 +++ .../src/embeddedci_mcp/server.py | 55 +++++++++- packages/embeddedci-mcp/tests/conftest.py | 33 +++++- packages/embeddedci-mcp/tests/test_tools.py | 20 +++- .../embeddedci-mcp/tests/tools_surface.json | 102 ++++++++++++++++++ packages/embeddedci/CHANGELOG.md | 7 ++ packages/embeddedci/README.md | 48 +++++++-- .../src/embeddedci/benchpod/__init__.py | 8 ++ .../src/embeddedci/benchpod/capabilities.py | 7 +- .../src/embeddedci/benchpod/client.py | 68 +++++++++++- .../src/embeddedci/benchpod/constants.py | 10 ++ .../src/embeddedci/benchpod/state.py | 51 ++++++++- .../embeddedci/benchpod/transport/cloud.py | 3 +- packages/embeddedci/tests/api_surface.json | 25 ++++- packages/embeddedci/tests/test_client_v2.py | 60 +++++++++++ 17 files changed, 495 insertions(+), 28 deletions(-) diff --git a/packages/embeddedci-mcp/CHANGELOG.md b/packages/embeddedci-mcp/CHANGELOG.md index e67b8d4..539ed57 100644 --- a/packages/embeddedci-mcp/CHANGELOG.md +++ b/packages/embeddedci-mcp/CHANGELOG.md @@ -2,6 +2,9 @@ ## 2.13.2 +- New tools `calibrate_dac` and `calibrate_adc_gain`: the DMM-guided calibration (firmware 3.9.3+). + `calibrate` takes `clear_part` (offset, dac or adc); `calibration` reports the DAC lines and + ADC gains. - `calibrate` and `calibration` describe the ADC offset calibration: from firmware 3.9.3 it corrects every ADC source, up to 150 mV. - Requires embeddedci 2.13.2. diff --git a/packages/embeddedci-mcp/README.md b/packages/embeddedci-mcp/README.md index a0aba07..c179036 100644 --- a/packages/embeddedci-mcp/README.md +++ b/packages/embeddedci-mcp/README.md @@ -290,8 +290,10 @@ the pod's second UART, so `uart_open` keeps the console. Needs gateware v48 (`gp | `dac_output` | Route a DAC output and drive a calibrated DC voltage on it. | **`path`**, `volts` | | `current_out` | Hold a current on the 4-20 mA output (J9), in amps, or read its range. | `current` | | `adc_read` | One calibrated reading: front SMA, DAC loopbacks or the 4-20 mA input (J8). | `source=ext` | -| `calibration` | The stored calibration of the 4-20 mA input and the fit it uses. | none | -| `calibrate` | Measure and store the 4-20 mA input's offset, or clear it. | `source=current_in`, `clear=false` | +| `calibration` | The stored calibration: the ADC offset (measured on the 4-20 mA input) and the fit it uses, plus the DMM lines and gains (firmware 3.9.3+). | none | +| `calibrate` | Measure and store the ADC offset on the 4-20 mA input (J8 open), or clear the calibration (all of it, or one part). | `source=current_in`, `clear=false`, `clear_part` | +| `calibrate_dac` | Store this pod's line for a DAC output from two DMM readings of set points driven with `dac_output` (firmware 3.9.3+). | **`path`**, **`code1`**, **`volts1`**, **`code2`**, **`volts2`** | +| `calibrate_adc_gain` | Measure an ADC source's gain through a loopback against a DMM-calibrated output (firmware 3.9.3+). | **`source`** | ### Capture and decode @@ -372,7 +374,7 @@ resistors and analog paths) and `benchpod://help` (the server instructions). `disable_i2c_sensor` and `disable_gps`; a pull conflict to `set_pull(las=[7], enabled=false)`. - **Capabilities.** `connect` and `status` report what the pod can do as flags, so an agent can check before it calls a tool: for example `la_pins`, `capture_trigger`, `power_profile`, - `nrst_pin`, `can`, `calibrate`, `current_out`, `pod_current` and `analog` (false on the + `nrst_pin`, `can`, `calibrate`, `cal_dmm`, `current_out`, `pod_current` and `analog` (false on the digital-only board), plus the pod's policy and cloud settings (`dac_limits`, `flash_kb`, `ota_sig`, `sig_policy`, `lan_policy`, `tunnel_max_tier`, `lease_state`, `cloud_ca`, `cloud_proxy`). diff --git a/packages/embeddedci-mcp/src/embeddedci_mcp/models.py b/packages/embeddedci-mcp/src/embeddedci_mcp/models.py index ecb88f6..a615736 100644 --- a/packages/embeddedci-mcp/src/embeddedci_mcp/models.py +++ b/packages/embeddedci-mcp/src/embeddedci_mcp/models.py @@ -418,6 +418,13 @@ class AdcReadResult(BaseModel): "Loop current in amps on the 4-20 mA input (current_in only): 0.004 to 0.020 for a live loop.")) +class DacLineResult(BaseModel): + a: float = Field(description="Volts at 16-bit code 0 (volts = a + b * code16).") + b: float = Field(description="Volts per 16-bit code.") + offset: float = Field(description="Offset against the built-in line in volts (at 0 V on 12v, at code 0 on the others).") + gain_ppm: int = Field(description="Gain against the built-in line, ppm.") + + class CalibrationResult(BaseModel): source: str calibrated: bool = Field(description="True when the pod has its own calibration stored.") @@ -427,6 +434,14 @@ class CalibrationResult(BaseModel): count: Optional[int] = Field(None, description="Mean raw count of the calibration measurement (only right after calibrating).") span: Optional[int] = Field(None, description="Its peak-to-peak spread in counts.") samples: Optional[int] = Field(None, description="Samples averaged.") + dac: Dict[str, Optional[DacLineResult]] = Field(default_factory=dict, description=( + "This pod's DMM line per DAC output (3v3, 5v, 12v); null = the built-in line. Empty before firmware 3.9.3.")) + adc_gain_ppm: Dict[str, Optional[int]] = Field(default_factory=dict, description=( + "ADC gain correction per source (cal1, cal2, ext) in ppm; null = the built-in gain.")) + dmm: bool = Field(False, description="True when any DMM-guided part is stored.") + measured: Optional[Dict[str, float]] = Field(None, description=( + "After calibrate_adc_gain: v1, v2 (what the DAC put out), r1, r2 (what the ADC read before the gain) " + "and residual (offset left after it), in volts.")) class AdcCaptureResult(BaseModel): diff --git a/packages/embeddedci-mcp/src/embeddedci_mcp/server.py b/packages/embeddedci-mcp/src/embeddedci_mcp/server.py index 78a457b..e9fb63a 100644 --- a/packages/embeddedci-mcp/src/embeddedci_mcp/server.py +++ b/packages/embeddedci-mcp/src/embeddedci_mcp/server.py @@ -29,7 +29,10 @@ from embeddedci.benchpod import ( AdcSource, AnalogPath, + AdcGainSource, CalibrateSource, + CalibrationPart, + DacCalPath, CanMode, DacOutputPath, DacPath, @@ -1346,15 +1349,19 @@ async def adc_read(source: AdcSource = "ext") -> m.AdcReadResult: def _calibration_result(c: Any) -> m.CalibrationResult: + dac = {k: (None if v is None else m.DacLineResult(a=v.a, b=v.b, offset=v.offset, gain_ppm=v.gain_ppm)) + for k, v in (c.dac or {}).items()} return m.CalibrationResult(source=c.source, calibrated=c.calibrated, offset=c.offset, a=c.a, b=c.b, - count=c.count, span=c.span, samples=c.samples) + count=c.count, span=c.span, samples=c.samples, dac=dac, + adc_gain_ppm=dict(c.adc_gain_ppm or {}), dmm=c.dmm, measured=c.measured) @mcp.tool(annotations=_ann("Read the pod's calibration", read_only=True)) async def calibration() -> m.CalibrationResult: - """The ADC offset calibration the pod has stored (measured on current_in, terminal J8), and the fit it uses. + """The calibration the pod has stored: the ADC offset (measured on current_in, terminal J8) and the fit it uses, + and on firmware 3.9.3+ the DMM-guided parts (dac: a line per DAC output, adc_gain_ppm: a gain per ADC source). - calibrated=false means the pod was never calibrated: analog readings can be off by up to about 50 mV. + calibrated=false means the pod has no ADC offset: analog readings can be off by up to about 50 mV. """ return _calibration_result(await _call(lambda: SESSION.require().calibration())) @@ -1363,6 +1370,8 @@ async def calibration() -> m.CalibrationResult: async def calibrate( source: Annotated[CalibrateSource, Field(description="What to calibrate. Only current_in, the 4-20 mA input.")] = "current_in", clear: Annotated[bool, Field(description="True removes the stored calibration instead of measuring one.")] = False, + clear_part: Annotated[Optional[CalibrationPart], Field(description=( + "With clear=true, remove only this part: offset, dac (the DMM lines) or adc (the ADC gains). Firmware 3.9.3+."))] = None, ) -> m.CalibrationResult: """Calibrate the pod's ADC offset on its 4-20 mA input (current_in, terminal J8) and store it on the pod. @@ -1371,13 +1380,49 @@ async def calibrate( has it taken out, and on firmware 3.9.3+ every other ADC source and capture too (they share the front end). Once per pod is enough: it survives reboots and firmware updates. The pod refuses, and keeps what it had, when something is driving J8 (more than 150 mV; 50 mV before 3.9.3). - clear=true removes the stored calibration. + clear=true removes the stored calibration (all of it, or clear_part only). """ if clear: - return _calibration_result(await _call(lambda: SESSION.require().clear_calibration())) + return _calibration_result(await _call(lambda: SESSION.require().clear_calibration(clear_part))) return _calibration_result(await _call(lambda: SESSION.require().calibrate(source))) +@mcp.tool(annotations=_ann("Store a DAC output's DMM calibration", destructive=True, idempotent=True)) +async def calibrate_dac( + path: Annotated[DacCalPath, Field(description="The DAC output: 3v3, 5v or 12v.")], + code1: Annotated[int, Field(ge=0, le=65535, description="The 16-bit code of the first set point: code16 from dac_output.")], + volts1: Annotated[float, Field(description="What the DMM read at the first set point, in volts.")], + code2: Annotated[int, Field(ge=0, le=65535, description="The 16-bit code of the second set point.")], + volts2: Annotated[float, Field(description="What the DMM read at the second set point, in volts.")], +) -> m.CalibrationResult: + """Store this pod's own line for a DAC output from two DMM readings (firmware 3.9.3+, cap cal_dmm). + + The flow, with the user holding a bench DMM: ask them to disconnect the DUT from the output and put the DMM + (DC volts) on that output's SMA. Drive each set point with dac_output(path, volts) and keep its code16 + (3v3: 0.5 and 3.0 V, 5v: 1.0 and 4.0 V, 12v: -5 and +5 V), ask the user what the DMM reads, then call this + with both points and switch the output off (dac_output path off). From then on dac_output, generate and + the DAC output limits use this line. The pod refuses, and keeps what it had, when the codes are too close, + a reading is far from what the output should give (DMM on the wrong SMA) or the gain is more than 2 % off. + """ + return _calibration_result(await _call( + lambda: SESSION.require().calibrate_dac(path, [(code1, volts1), (code2, volts2)]))) + + +@mcp.tool(annotations=_ann("Measure an ADC gain through a loopback", destructive=True, idempotent=True)) +async def calibrate_adc_gain( + source: Annotated[AdcGainSource, Field(description=( + "cal1 (and current_in) through the 5 V output, cal2 through the ±12 V output, ext (the front SMA, " + "captures and the control loop) through an SMA cable from the ±12 V output to the ADC input."))], +) -> m.CalibrationResult: + """Measure and store an ADC source's gain against a DMM-calibrated DAC output (firmware 3.9.3+). + + Needs the ADC offset (calibrate) and the driving output's DMM line (calibrate_dac on 5v for cal1, 12v for + cal2 and ext). For ext, ask the user to connect the SMA cable first. The pod drives two set points itself + and switches the outputs off afterwards; measured says what it saw. Nothing to read on a DMM. + """ + return _calibration_result(await _call(lambda: SESSION.require().calibrate_adc_gain(source))) + + @mcp.tool(annotations=_ann("Capture an ADC waveform")) async def capture_adc( samples: Samples = 4096, diff --git a/packages/embeddedci-mcp/tests/conftest.py b/packages/embeddedci-mcp/tests/conftest.py index b05f0ab..cd020ca 100644 --- a/packages/embeddedci-mcp/tests/conftest.py +++ b/packages/embeddedci-mcp/tests/conftest.py @@ -68,10 +68,13 @@ def __init__(self) -> None: #: Capabilities the pod reports; drop entries to test a pod without a feature. self.caps = ["signal", "la", "uart", "dac", "dac_replay", "dac_cotrig", "la_pins", "gpio_read", "capture_trigger", "power_profile", "calibrate", - "current_out"] + "cal_dmm", "current_out"] #: The pod's stored `current_in` calibration (the `calibrate` command). self.current_in_calibrated = False self.current_in_offset_uv = 0 + #: DMM parts: the DAC lines by path and the ADC gains by source (ppm). + self.dac_lines: Dict[str, Any] = {} + self.adc_gains: Dict[str, int] = {} # -- LA pin ownership (the firmware's table): function, gpio mode and commanded level. self.function: Dict[int, str] = {la: "none" for la in range(1, 15)} self.mode: Dict[int, Any] = {la: None for la in range(1, 15)} @@ -285,9 +288,27 @@ def _cmd_adc_read(self, req): return reply def _cmd_calibrate(self, req): - if req.get("clear"): + measured = None + clear = req.get("clear") + if clear in ("dac", "adc", "offset"): + if clear == "dac": + self.dac_lines = {} + elif clear == "adc": + self.adc_gains = {} + else: + self.current_in_offset_uv, self.current_in_calibrated = 0, False + elif clear: self.current_in_offset_uv = 0 self.current_in_calibrated = False + self.dac_lines, self.adc_gains = {}, {} + elif "dac" in req: + if self.error: + raise FirmwareError(self.error, cmd="calibrate") + self.dac_lines[req["dac"]] = {"a_uv": -609, "b_pv": 76249841, "offset_uv": -3127, "gain_ppm": -996, + "_req": dict(req)} + elif "adc" in req: + self.adc_gains[req["adc"]] = 1850 + measured = {"v1_uv": 995900, "v2_uv": 3992900, "r1_uv": 994100, "r2_uv": 3985500, "residual_uv": 1200} elif "source" in req: if self.error: raise FirmwareError(self.error, cmd="calibrate") @@ -295,9 +316,15 @@ def _cmd_calibrate(self, req): self.current_in_calibrated = True reply = {"source": "current_in", "calibrated": self.current_in_calibrated, "offset_mv": round(self.current_in_offset_uv / 1000), "offset_uv": self.current_in_offset_uv, - "a_uv": 65832397 - self.current_in_offset_uv, "b_nv": -1004471} + "a_uv": 65832397 - self.current_in_offset_uv, "b_nv": -1004471, + "dac": {p: ({k: v for k, v in self.dac_lines[p].items() if k != "_req"} if p in self.dac_lines else None) + for p in ("3v3", "5v", "12v")}, + "adc_gain_ppm": {s: self.adc_gains.get(s) for s in ("cal1", "cal2", "ext")}, + "dmm": bool(self.dac_lines or self.adc_gains)} if "source" in req: reply.update(count=65535, span=6, samples=512) + if measured: + reply["measured"] = measured return reply def _cmd_generate(self, req): diff --git a/packages/embeddedci-mcp/tests/test_tools.py b/packages/embeddedci-mcp/tests/test_tools.py index 3a4f214..07784e0 100644 --- a/packages/embeddedci-mcp/tests/test_tools.py +++ b/packages/embeddedci-mcp/tests/test_tools.py @@ -25,7 +25,7 @@ "i2c_sensor_regs", "i2c_sensor_capture", "i2c_sensor_types", "enable_gps", "set_gps", "disable_gps", "gps_status", "set_pull", "pull_status", "usb_network", "lan_tls", - "analog_path", "dac_output", "current_out", "adc_read", "calibration", "calibrate", + "analog_path", "dac_output", "current_out", "adc_read", "calibration", "calibrate", "calibrate_dac", "calibrate_adc_gain", "capture_adc", "capture_la", "capture_correlated", "decode_la", "la_timing", "generate", "dac_stop", "replay", "list_waveforms", "replay_waveform", "save_capture_as_recording", @@ -478,6 +478,24 @@ def test_calibrate_tools(connected): assert cleared["calibrated"] is False and cleared["offset"] == 0.0 +def test_dmm_calibration_tools(connected): + done = call("calibrate_dac", path="5v", code1=13069, volts1=0.9959, code2=52374, volts2=3.9929) + assert connected.dac_lines["5v"]["_req"] == {"cmd": "calibrate", "dac": "5v", "code1": 13069, "uv1": 995900, + "code2": 52374, "uv2": 3992900} + assert done["dac"]["5v"]["gain_ppm"] == -996 and done["dac"]["3v3"] is None and done["dmm"] is True + assert done["dac"]["5v"]["offset"] == pytest.approx(-3.127e-3) + gain = call("calibrate_adc_gain", source="cal1") + assert gain["adc_gain_ppm"]["cal1"] == 1850 and gain["measured"]["residual"] == pytest.approx(0.0012) + cleared = call("calibrate", clear=True, clear_part="dac") + assert cleared["dac"]["5v"] is None and cleared["adc_gain_ppm"]["cal1"] == 1850 + + +def test_dmm_calibration_needs_newer_firmware(connected): + without_caps(connected, "cal_dmm") + with pytest.raises(Exception, match="cal_dmm"): + call("calibrate_adc_gain", source="cal1") + + def test_calibrate_refusal_is_a_tool_error(connected): connected.error = "calibrate: current_in reads 996 mV, it must be within +/-50 mV of zero. Something is driving J8. Disconnect it and try again" with pytest.raises(Exception, match="996 mV"): diff --git a/packages/embeddedci-mcp/tests/tools_surface.json b/packages/embeddedci-mcp/tests/tools_surface.json index ea3bc28..83e52aa 100644 --- a/packages/embeddedci-mcp/tests/tools_surface.json +++ b/packages/embeddedci-mcp/tests/tools_surface.json @@ -73,6 +73,24 @@ "title": "Clear", "type": "boolean" }, + "clear_part": { + "anyOf": [ + { + "enum": [ + "offset", + "dac", + "adc" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "With clear=true, remove only this part: offset, dac (the DMM lines) or adc (the ADC gains). Firmware 3.9.3+.", + "title": "Clear Part" + }, "source": { "const": "current_in", "default": "current_in", @@ -85,6 +103,90 @@ "type": "object" } }, + "calibrate_adc_gain": { + "annotations": { + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Measure an ADC gain through a loopback" + }, + "input": { + "properties": { + "source": { + "description": "cal1 (and current_in) through the 5 V output, cal2 through the \u00b112 V output, ext (the front SMA, captures and the control loop) through an SMA cable from the \u00b112 V output to the ADC input.", + "enum": [ + "cal1", + "cal2", + "ext" + ], + "title": "Source", + "type": "string" + } + }, + "required": [ + "source" + ], + "title": "calibrate_adc_gainArguments", + "type": "object" + } + }, + "calibrate_dac": { + "annotations": { + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Store a DAC output's DMM calibration" + }, + "input": { + "properties": { + "code1": { + "description": "The 16-bit code of the first set point: code16 from dac_output.", + "maximum": 65535, + "minimum": 0, + "title": "Code1", + "type": "integer" + }, + "code2": { + "description": "The 16-bit code of the second set point.", + "maximum": 65535, + "minimum": 0, + "title": "Code2", + "type": "integer" + }, + "path": { + "description": "The DAC output: 3v3, 5v or 12v.", + "enum": [ + "3v3", + "5v", + "12v" + ], + "title": "Path", + "type": "string" + }, + "volts1": { + "description": "What the DMM read at the first set point, in volts.", + "title": "Volts1", + "type": "number" + }, + "volts2": { + "description": "What the DMM read at the second set point, in volts.", + "title": "Volts2", + "type": "number" + } + }, + "required": [ + "path", + "code1", + "volts1", + "code2", + "volts2" + ], + "title": "calibrate_dacArguments", + "type": "object" + } + }, "calibration": { "annotations": { "idempotentHint": false, diff --git a/packages/embeddedci/CHANGELOG.md b/packages/embeddedci/CHANGELOG.md index ff16eff..33038b4 100644 --- a/packages/embeddedci/CHANGELOG.md +++ b/packages/embeddedci/CHANGELOG.md @@ -2,6 +2,13 @@ ## 2.13.2 +- The DMM-guided calibration (pod firmware 3.9.3+, `Capabilities.cal_dmm`): + `BenchPod.calibrate_dac(path, points)` stores this pod's own line for a DAC output from two + `(code16, volts)` DMM readings, `calibrate_adc_gain(source)` measures an ADC gain (`cal1`, + `cal2`, `ext`) through a loopback, and `clear_calibration(part)` clears one part (`offset`, + `dac`, `adc`). `Calibration` gains `dac` (`DacLine`), `adc_gain_ppm`, `dmm` and `measured`; + `BenchPod.DMM_SET_POINTS` has the set points. New types `DacCalPath`, `AdcGainSource`, + `CalibrationPart`. - Docs: `calibrate()` corrects every ADC source from firmware 3.9.3 (they share the front end) and the pod accepts an offset up to 150 mV. On the LAN, `capture_adc` volts use the fit the pod now reports in `status`, so they include the pod's calibrated offset. diff --git a/packages/embeddedci/README.md b/packages/embeddedci/README.md index c61730d..d93fd5b 100644 --- a/packages/embeddedci/README.md +++ b/packages/embeddedci/README.md @@ -1008,8 +1008,43 @@ and keeps what it had. `a` and `b` are the fit the pod now uses for `current_in` Every ADC source shares the same front end after its input switch, so from firmware 3.9.3 the pod takes the offset out of all of them: `adc_read` on `ext`, `cal1` and `cal2`, and `capture_adc`, whose `volts` use the fit the pod reports (on the LAN in `status`, through embeddedci.com in its -capabilities). Older firmware corrects `current_in` only. Only the offset is calibrated; the gain -uses the built-in fits. Needs firmware 3.4.0 (capability `calibrate`, `Capabilities.calibrate`). +capabilities). Older firmware corrects `current_in` only. Needs firmware 3.4.0 (capability +`calibrate`, `Capabilities.calibrate`). The web app has it in the pod's Settings tab, under +Calibration. + +#### Calibrating with a DMM + +With a bench DMM the pod can also take out what the offset leaves (firmware 3.9.3+, capability +`cal_dmm`, `Capabilities.cal_dmm`): its own DAC line per output, then the ADC gain per source. +Every step is optional and stored on its own; an output or source without one keeps the built-in +fit. Disconnect the DUT from the DAC outputs first: this drives up to ±5 V on them. + +```python +points = [] +for volts in bp.DMM_SET_POINTS["5v"]: # 1.0 and 4.0 V; 3v3: 0.5 / 3.0, 12v: -5 / +5 + out = bp.dac_output("5v", volts=volts) + points.append((out.code16, read_dmm())) # the DMM on the 5 V output SMA, DC volts +cal = bp.calibrate_dac("5v", points) # this pod's line for the 5 V output +bp.dac_output("off") +print(cal.dac["5v"]) # DacLine(a, b, offset=-0.0031, gain_ppm=-996) + +bp.calibrate_adc_gain("cal1") # 5 V loopback, against the line just stored +bp.calibrate_adc_gain("ext") # needs the 12v line and an SMA cable ±12 V -> ADC in +bp.clear_calibration("dac") # one part: "offset", "dac" or "adc"; no part = all +``` + +`calibrate_dac(path, points)` takes two `(code16, volts)` pairs: the 16-bit code `dac_output` +drove and the DMM reading. From then on `dac_output`, the calibrated `generate` and the DAC output +limits convert with this pod's line (`DacLine`: `a`, `b` per 16-bit code, `offset`, `gain_ppm` +against the built-in line). `calibrate_adc_gain(source)` measures `cal1` through the 5 V output, +`cal2` through the ±12 V output and `ext` (the front SMA, which `capture_adc` and the control loop +use) through an SMA cable from the ±12 V output to the ADC input; `current_in` follows `cal1`. It +needs the ADC offset and the driving output's DMM line, and `Calibration.measured` says what it +saw. The pod refuses (`BenchPodError`) and keeps what it had when a reading cannot be right: two +codes too close, a reading far from what the output should give (the DMM on another SMA), a gain +more than 2 % off. It also refuses while DAC output limits are set. `Calibration.dac`, +`Calibration.adc_gain_ppm` and `Calibration.dmm` show what is stored. Going back to firmware +older than 3.9.3 drops the whole calibration. ### The 4-20 mA output @@ -1593,7 +1628,7 @@ lease_wait=600.0, lease_ttl=120, wiring=None)`. A context manager; `close()` is | GPIO and pins | `gpio(la, mode="output", *, level=None)`, `gpio_pins(las, mode="output", *, level=None)`, `configure_gpio(las, mode="output", *, level=None)`, `set_gpio(la, level)`, `read_gpio(la)`, `wait_for_level(la, level, *, timeout, poll=0.005)` (`False` when `timeout` passes first), `pin_levels()`, `la_pins()`, `release_gpio(*las)`, `la_step(la, *, steps, delay, dir_la=None, direction=0)` | | I2C sensor | `enable_i2c_sensor(sensor=Sensor.BMP280, *, sda=None, scl=None, address=None, **values)`, `set_i2c_sensor(**values)`, `disable_i2c_sensor()`, `i2c_sensor_status()`, `i2c_sensor_types()`, `i2c_sensor_regs()`, `i2c_sensor_capture(...)` | | GPS receiver | `enable_gps(tx, *, baud=9600, rate_hz=1, sentences=None, utc=None, **fix)`, `set_gps(*, utc=None, **fix)`, `disable_gps()`, `gps_status()` | -| Analog | `analog_path(path)`, `adc_read(source="ext")`, `dac_output(path, *, volts=None)`, `current_out(current)`, `current_out_range()`, `calibrate(source="current_in")`, `calibration()`, `clear_calibration()` | +| Analog | `analog_path(path)`, `adc_read(source="ext")`, `dac_output(path, *, volts=None)`, `current_out(current)`, `current_out_range()`, `calibrate(source="current_in")`, `calibration()`, `clear_calibration(part=None)`, `calibrate_dac(path, points)`, `calibrate_adc_gain(source)`, `DMM_SET_POINTS` | | Captures | `capture_adc(...)`, `capture_la(...)`, `capture_correlated(...)`, `decode(source, protocol="i2c", ...)` | | DAC | `generate(...)`, `replay(...)`, `replay_waveform(...)`, `dac_stop()`, `control_loop(...)`, `loop_input(...)`, `loop_probe()` | | CAN | `open_can(*, bitrate=500000, mode="normal", term=False, fd=False)`, `can_config(...)`, `can_disable()`, `can_status()`, `can_term(on)`, `can_write(...)`, `can_read(...)`, `can_respond(...)`, `can_respond_clear()` | @@ -1638,7 +1673,8 @@ few built from other shapes); `to_dict()` goes the other way where a type is sen | `AdcReading` | `adc_read` | `source`, `voltage`, `count`, `span`, `offset`, `current` | | `DacOutput` | `dac_output` | `path`, `voltage`, `code` | | `CurrentOutput` | `current_out`, `current_out_range` | `current`, `code`, `min_current`, `max_current` | -| `Calibration` | `calibrate`, `calibration`, `clear_calibration` | `source`, `calibrated`, `offset`, `a`, `b`, `count`, `span`, `samples` | +| `Calibration` | `calibrate`, `calibration`, `clear_calibration`, `calibrate_dac`, `calibrate_adc_gain` | `source`, `calibrated`, `offset`, `a`, `b`, `count`, `span`, `samples`, `dac`, `adc_gain_ppm`, `dmm`, `measured` | +| `DacLine` | `Calibration.dac` | `a`, `b`, `offset`, `gain_ppm` | | `Capture` | `capture_adc` | `counts`, `volts`, `currents`, `sample_rate_hz`, `source`, `trigger`, `duration`, `times()`, `mean()`, `min()`, `max()`, `rms()`, `rms_ac()`, `peak_to_peak()`, `first_crossing()`, `crossing_times()`, `fft()`, `dominant_frequency()` | | `LaCapture` | `capture_la` | `words`, `channels`, `sample_rate_hz`, `trigger`, `duration`, `channel(la)`, `level_at()`, `edges()`, `edge_times()`, `first_edge()`, `delay()`, `frequency()`, `duty_cycle()`, `pulse_widths()`, `decode()` | | `CorrelatedCapture` | `capture_correlated` | `adc` (a `Capture`), `la` (a `LaCapture`), sharing one hardware trigger | @@ -1682,7 +1718,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+), `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) | +| 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`, `cal_dmm` (firmware 3.9.3+: `calibrate_dac`, `calibrate_adc_gain`), `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`) | | Source | `raw` (the map it was parsed from) | @@ -1723,7 +1759,7 @@ timeout=60.0, lease_id=None)`. Errors raise `ServerApiError` (`status`), and an | `curve_output_at(curve, input_code)`, `input_percent_to_code(percent)` | evaluate a curve the way the gateware does; an input percentage as a code | | `encode_curve_b64url(codes)` | 16-bit DAC codes as little-endian base64url, the wire form of a curve | | `INTERNAL`, `EXTERNAL`, `PIN1` … `PIN14`, `BMP280_ADDR_PRIMARY`, `BMP280_ADDR_SECONDARY` | named constants (see [API conventions](#api-conventions-and-stability)) | -| `DacPath`, `DacOutputPath`, `AnalogPath`, `AdcSource`, `CalibrateSource` (`"current_in"`), `LoopSource`, `Waveshape`, `ReplayMapping`, `DecodeProtocol`, `CanMode`, `FaultType`, `GpioMode`, `Edge`, `TriggerEdge` | the `Literal` string types | +| `DacPath`, `DacOutputPath`, `AnalogPath`, `AdcSource`, `CalibrateSource` (`"current_in"`), `DacCalPath` (`"3v3"`, `"5v"`, `"12v"`), `AdcGainSource` (`"cal1"`, `"cal2"`, `"ext"`), `CalibrationPart` (`"offset"`, `"dac"`, `"adc"`), `LoopSource`, `Waveshape`, `ReplayMapping`, `DecodeProtocol`, `CanMode`, `FaultType`, `GpioMode`, `Edge`, `TriggerEdge` | the `Literal` string types | | `decode`, `i2c`, `can`, `control_loop`, `motor_emulator`, `dsp` | submodules: off-device decoders, I2C helpers, CAN types, curve helpers, the emulator's registers, and `dsp`, a pure-Python mirror of the server's record-to-replay processing | ## Examples diff --git a/packages/embeddedci/src/embeddedci/benchpod/__init__.py b/packages/embeddedci/src/embeddedci/benchpod/__init__.py index 8e70991..b0b0267 100644 --- a/packages/embeddedci/src/embeddedci/benchpod/__init__.py +++ b/packages/embeddedci/src/embeddedci/benchpod/__init__.py @@ -50,6 +50,9 @@ def test_boots(benchpod_target, firmware): # fixtures from the pytest plugin PIN14, AdcSource, CalibrateSource, + CalibrationPart, + DacCalPath, + AdcGainSource, AnalogPath, CanMode, DacOutputPath, @@ -117,6 +120,7 @@ def test_boots(benchpod_target, firmware): # fixtures from the pytest plugin AdcReading, AnalogPathState, Calibration, + DacLine, CurrentOutput, DacOutput, EfuseState, @@ -161,6 +165,7 @@ def test_boots(benchpod_target, firmware): # fixtures from the pytest plugin "DacOutput", "AdcReading", "Calibration", + "DacLine", "CurrentOutput", "FpgaImageInfo", "LoopState", @@ -254,6 +259,9 @@ def test_boots(benchpod_target, firmware): # fixtures from the pytest plugin "AnalogPath", "AdcSource", "CalibrateSource", + "CalibrationPart", + "DacCalPath", + "AdcGainSource", "Waveshape", "ReplayMapping", "LoopSource", diff --git a/packages/embeddedci/src/embeddedci/benchpod/capabilities.py b/packages/embeddedci/src/embeddedci/benchpod/capabilities.py index 236ca9c..56ca45a 100644 --- a/packages/embeddedci/src/embeddedci/benchpod/capabilities.py +++ b/packages/embeddedci/src/embeddedci/benchpod/capabilities.py @@ -176,6 +176,10 @@ class Capabilities: spi_stream: bool = False #: The pod can calibrate its own ADC and keeps the result: :meth:`BenchPod.calibrate`. calibrate: bool = False + #: ``calibrate`` also takes the DMM-guided forms (firmware 3.9.3+): this pod's own DAC line + #: per output and ADC gain per source, :meth:`BenchPod.calibrate_dac`, + #: :meth:`BenchPod.calibrate_adc_gain`. + cal_dmm: bool = False #: The pod can hold a current on its 4-20 mA output (J9): :meth:`BenchPod.current_out`. current_out: bool = False #: Classic CAN on the pod's CAN+/CAN- terminals: :meth:`BenchPod.open_can`. Firmware older @@ -319,6 +323,7 @@ def from_status(cls, status: Mapping[str, Any]) -> "Capabilities": ("spi_master", "spi_master"), ("spi_stream", "spi_stream"), ("calibrate", "calibrate"), + ("cal_dmm", "cal_dmm"), ("current_out", "current_out"), ("can", "can"), ("pod_current", "pod_current"), @@ -379,7 +384,7 @@ def from_parameters(cls, params: Mapping[str, Any]) -> "Capabilities": ("capture_b64", "cap.capture_b64"), ("nrst_pin", "cap.nrst_pin"), ("usb_cc", "cap.usb_cc"), ("spi_master", "cap.spi_master"), ("spi_stream", "cap.spi_stream"), - ("calibrate", "cap.calibrate"), ("current_out", "cap.current_out"), + ("calibrate", "cap.calibrate"), ("cal_dmm", "cap.cal_dmm"), ("current_out", "cap.current_out"), ("can", "cap.can"), ("pod_current", "cap.pod_current"), ("sensor_types", "cap.sensor_types"), ("gps", "cap.gps"), ("dac_limits", "cap.dac_limits"), diff --git a/packages/embeddedci/src/embeddedci/benchpod/client.py b/packages/embeddedci/src/embeddedci/benchpod/client.py index 24b477b..4962cf9 100644 --- a/packages/embeddedci/src/embeddedci/benchpod/client.py +++ b/packages/embeddedci/src/embeddedci/benchpod/client.py @@ -48,9 +48,12 @@ from .capabilities import ADC_WRAP_SPAN, ADC_WRAP_THRESHOLD, Capabilities from .constants import ( ADC_SOURCE_PATHS, + ADC_GAIN_SOURCES, ADC_SOURCES, ANALOG_PATHS, CALIBRATE_SOURCES, + CALIBRATION_PARTS, + DAC_CAL_PATHS, CAN_MODES, DAC_OUTPUT_PATHS, DAC_PATHS, @@ -65,9 +68,12 @@ PULLUP_CHANNELS, REPLAY_MAPPINGS, WAVESHAPES, + AdcGainSource, AdcSource, AnalogPath, CalibrateSource, + CalibrationPart, + DacCalPath, CanMode, DacOutputPath, DacPath, @@ -1255,10 +1261,66 @@ def calibration(self) -> Calibration: self._require_capability("calibrate", "calibration") return Calibration.from_reply(self.command({"cmd": "calibrate"})) - def clear_calibration(self) -> Calibration: - """Remove the pod's stored calibration. It goes back to the built-in fit.""" + def clear_calibration(self, part: Optional[CalibrationPart] = None) -> Calibration: + """Remove the pod's stored calibration. It goes back to the built-in fits. + + ``part`` removes one part only (firmware 3.9.3+): ``"offset"`` (the ADC offset), + ``"dac"`` (the DMM lines of every DAC output) or ``"adc"`` (the ADC gains). + """ self._require_capability("calibrate", "calibration") - return Calibration.from_reply(self.command({"cmd": "calibrate", "clear": True})) + if part is None: + return Calibration.from_reply(self.command({"cmd": "calibrate", "clear": True})) + check_choice(part, CALIBRATION_PARTS, "part") + self._require_capability("cal_dmm", "partial calibration clears") + return Calibration.from_reply(self.command({"cmd": "calibrate", "clear": part})) + + #: The set points a DMM calibration drives on each output, in volts: well inside the range, + #: away from the output floor near 0 V. The pod's ADC gain step uses the same ones. + DMM_SET_POINTS: Dict[str, Tuple[float, float]] = {"3v3": (0.5, 3.0), "5v": (1.0, 4.0), "12v": (-5.0, 5.0)} + + def calibrate_dac(self, path: DacCalPath, + points: Sequence[Tuple[int, float]]) -> Calibration: + """Store this pod's own line for a DAC output from two DMM readings (firmware 3.9.3+). + + ``points`` is two ``(code16, volts)`` pairs: the 16-bit code the pod drove (the + ``code16`` of :meth:`dac_output`) and what a DMM read on that output's SMA. Disconnect + the DUT from the output first. A typical run:: + + for v in pod.DMM_SET_POINTS["5v"]: + out = pod.dac_output("5v", volts=v) + points.append((out.code16, dmm.read_volts())) + pod.calibrate_dac("5v", points) + pod.dac_output("off") + + From then on :meth:`dac_output`, the calibrated :meth:`generate` and the DAC output + limits convert with this line. Raises :class:`BenchPodError` when the pod refuses: the + codes are too close, a reading is far from what the output should give (the DMM is on + another SMA), or the gain is more than 2 % off. The stored line is then unchanged. + """ + check_choice(path, DAC_CAL_PATHS, "path") + pts = list(points) + if len(pts) != 2: + raise ValueError("calibrate_dac needs exactly two (code16, volts) points") + (c1, v1), (c2, v2) = pts + self._require_capability("cal_dmm", "DMM calibration") + return Calibration.from_reply(self.command({ + "cmd": "calibrate", "dac": path, "code1": int(c1), "uv1": int(round(float(v1) * 1e6)), + "code2": int(c2), "uv2": int(round(float(v2) * 1e6))})) + + def calibrate_adc_gain(self, source: AdcGainSource) -> Calibration: + """Measure and store an ADC source's gain through a loopback (firmware 3.9.3+). + + The pod drives two set points on a DAC output that has its DMM line + (:meth:`calibrate_dac`) and compares what the ADC reads with what the output puts out: + ``cal1`` through the 5 V output, ``cal2`` through the ±12 V output, and ``ext`` (the front + SMA, which captures and the control loop use) through an SMA cable from the ±12 V output + to the ADC input. ``current_in`` follows ``cal1``. Needs the ADC offset first + (:meth:`calibrate`). ``Calibration.measured`` says what was measured. Takes about 0.7 s + and switches the outputs off afterwards. + """ + check_choice(source, ADC_GAIN_SOURCES, "source") + self._require_capability("cal_dmm", "DMM calibration") + return Calibration.from_reply(self.command({"cmd": "calibrate", "adc": source})) # -- CAN (FDCAN1 / TCAN1044 transceiver) ----------------------------------- # Single-node board: use a loopback ``mode`` to self-test on one pod. :meth:`open_can` and diff --git a/packages/embeddedci/src/embeddedci/benchpod/constants.py b/packages/embeddedci/src/embeddedci/benchpod/constants.py index 6aa8b7d..99df70e 100644 --- a/packages/embeddedci/src/embeddedci/benchpod/constants.py +++ b/packages/embeddedci/src/embeddedci/benchpod/constants.py @@ -112,6 +112,13 @@ class Pin(IntEnum): AdcSource = Literal["ext", "cal1", "cal2", "current_in"] #: ADC sources a pod can calibrate on its own (:meth:`BenchPod.calibrate`): the 4-20 mA input. CalibrateSource = Literal["current_in"] +#: DAC outputs a DMM line can be stored for (:meth:`BenchPod.calibrate_dac`). +DacCalPath = Literal["3v3", "5v", "12v"] +#: ADC sources whose gain the pod measures through a loopback (:meth:`BenchPod.calibrate_adc_gain`); +#: ``current_in`` follows ``cal1``. +AdcGainSource = Literal["cal1", "cal2", "ext"] +#: One part of the pod's calibration (:meth:`BenchPod.clear_calibration`). +CalibrationPart = Literal["offset", "dac", "adc"] #: Parametric DAC waveforms the firmware generator produces. Waveshape = Literal["sine", "square", "sawtooth"] #: How replayed volts map onto DAC codes: reproduce them (``faithful``) or auto-scale (``fit``). @@ -139,6 +146,9 @@ class Pin(IntEnum): ANALOG_PATHS: Tuple[str, ...] = get_args(AnalogPath) ADC_SOURCES: Tuple[str, ...] = get_args(AdcSource) CALIBRATE_SOURCES: Tuple[str, ...] = get_args(CalibrateSource) +DAC_CAL_PATHS: Tuple[str, ...] = get_args(DacCalPath) +ADC_GAIN_SOURCES: Tuple[str, ...] = get_args(AdcGainSource) +CALIBRATION_PARTS: Tuple[str, ...] = get_args(CalibrationPart) WAVESHAPES: Tuple[str, ...] = get_args(Waveshape) REPLAY_MAPPINGS: Tuple[str, ...] = get_args(ReplayMapping) LOOP_SOURCES: Tuple[str, ...] = get_args(LoopSource) diff --git a/packages/embeddedci/src/embeddedci/benchpod/state.py b/packages/embeddedci/src/embeddedci/benchpod/state.py index a4c8f12..92ae809 100644 --- a/packages/embeddedci/src/embeddedci/benchpod/state.py +++ b/packages/embeddedci/src/embeddedci/benchpod/state.py @@ -314,13 +314,35 @@ def from_reply(cls, reply: Any) -> "AdcReading": current=None if ua is None else ua / 1e6, raw=d) +@dataclass(frozen=True) +class DacLine: + """This pod's own line for a DAC output, from two DMM readings (``BenchPod.calibrate_dac``).""" + + #: ``volts = a + b * code16``: volts at code 0 and volts per 16-bit code. + a: float + b: float + #: Against the built-in line: the offset in volts (at 0 V on ``12v``, at code 0 on the + #: others) and the gain difference in ppm. + offset: float + gain_ppm: int + + @classmethod + def from_reply(cls, d: Any) -> Optional["DacLine"]: + if not isinstance(d, dict): + return None + return cls(a=(_opt_int(d, "a_uv") or 0) / 1e6, b=(_opt_int(d, "b_pv") or 0) / 1e12, + offset=(_opt_int(d, "offset_uv") or 0) / 1e6, gain_ppm=_opt_int(d, "gain_ppm") or 0) + + @dataclass(frozen=True) class Calibration: - """The pod's own ADC calibration for one source (``BenchPod.calibrate`` / ``calibration``). + """The pod's own calibration (``BenchPod.calibrate`` / ``calibration``). Every ADC source is scaled with a fit, ``volts = a + b * count``, that is built into the - firmware and the same on every pod. A pod that calibrated itself keeps its own value on top - of that, in flash. Today that is the offset of ``current_in``, the 4-20 mA input (J8). + firmware and the same on every pod. A pod that calibrated itself keeps its own values on top + of that, in flash: the ADC offset, measured on ``current_in`` (the 4-20 mA input, J8), and from + firmware 3.9.3 the DMM-guided parts, a DAC line per output (``dac``) and an ADC gain per + source (``adc_gain_ppm``). """ source: str @@ -338,16 +360,37 @@ class Calibration: count: Optional[int] = None span: Optional[int] = None samples: Optional[int] = None + #: This pod's DMM line per DAC output (``3v3``, ``5v``, ``12v``); ``None`` = the built-in + #: line. Empty on firmware before 3.9.3. + dac: Dict[str, Optional[DacLine]] = field(default_factory=dict) + #: The ADC gain correction per source (``cal1``, ``cal2``, ``ext``) in ppm; ``None`` = the + #: built-in gain. Empty on firmware before 3.9.3. + adc_gain_ppm: Dict[str, Optional[int]] = field(default_factory=dict) + #: ``True`` when any DMM part is stored. + dmm: bool = False + #: After :meth:`BenchPod.calibrate_adc_gain`: what the DAC put out at the two set points + #: (``v1``, ``v2``), what the ADC read before the gain (``r1``, ``r2``) and the mean offset left + #: after it (``residual``), all in volts. ``None`` otherwise. + measured: Optional[Dict[str, float]] = None raw: Dict[str, Any] = _raw() @classmethod def from_reply(cls, reply: Any) -> "Calibration": d = _d(reply) + dac = d.get("dac") if isinstance(d.get("dac"), dict) else {} + gains = d.get("adc_gain_ppm") if isinstance(d.get("adc_gain_ppm"), dict) else {} + meas = d.get("measured") if isinstance(d.get("measured"), dict) else None return cls(source=str(d.get("source", "")), calibrated=d.get("calibrated") is True, offset=(_opt_int(d, "offset_uv") or 0) / 1e6, a=(_opt_int(d, "a_uv") or 0) / 1e6, b=(_opt_int(d, "b_nv") or 0) / 1e9, count=_opt_int(d, "count"), span=_opt_int(d, "span"), - samples=_opt_int(d, "samples"), raw=d) + samples=_opt_int(d, "samples"), + dac={str(k): DacLine.from_reply(v) for k, v in dac.items()}, + adc_gain_ppm={str(k): _opt_int(gains, k) for k in gains}, + dmm=d.get("dmm") is True, + measured=None if meas is None else { + k[:-3]: (_opt_int(meas, k) or 0) / 1e6 for k in meas if k.endswith("_uv")}, + raw=d) @dataclass(frozen=True) diff --git a/packages/embeddedci/src/embeddedci/benchpod/transport/cloud.py b/packages/embeddedci/src/embeddedci/benchpod/transport/cloud.py index fe2fc93..d3a404e 100644 --- a/packages/embeddedci/src/embeddedci/benchpod/transport/cloud.py +++ b/packages/embeddedci/src/embeddedci/benchpod/transport/cloud.py @@ -157,7 +157,8 @@ def _read_form(req: dict) -> bool: if cmd == "dac_limits": return "path" not in req and req.get("enabled") is not False if cmd == "calibrate": - return "source" not in req and req.get("clear") is not True + return (not any(k in req for k in ("source", "dac", "adc")) + and req.get("clear") in (None, False)) if cmd in ("sig_policy", "lan_policy"): return "set" not in req if cmd in ("cloud_ca", "cloud_proxy"): diff --git a/packages/embeddedci/tests/api_surface.json b/packages/embeddedci/tests/api_surface.json index 5ae2fc2..836e977 100644 --- a/packages/embeddedci/tests/api_surface.json +++ b/packages/embeddedci/tests/api_surface.json @@ -1,4 +1,5 @@ { + "AdcGainSource": "Literal['cal1', 'cal2', 'ext']", "AdcReading": { "__fields__": [ "source", @@ -42,6 +43,8 @@ "adc_read": "(self, source: 'AdcSource' = 'ext') -> 'AdcReading'", "analog_path": "(self, path: 'AnalogPath') -> 'AnalogPathState'", "calibrate": "(self, source: 'CalibrateSource' = 'current_in') -> 'Calibration'", + "calibrate_adc_gain": "(self, source: 'AdcGainSource') -> 'Calibration'", + "calibrate_dac": "(self, path: 'DacCalPath', points: 'Sequence[Tuple[int, float]]') -> 'Calibration'", "calibration": "(self) -> 'Calibration'", "can_analog": "property", "can_calibrate": "property", @@ -64,7 +67,7 @@ "capture_correlated": "(self, *, adc_samples: 'int' = 4096, adc_sample_rate_hz: 'Optional[float]' = None, la_samples: 'int' = 4096, la_sample_rate_hz: 'Optional[float]' = None, stop_dac_after: 'Optional[float]' = None, trigger: 'Optional[Trigger]' = None, trigger_timeout: 'float' = 10.0, source: 'Optional[AdcSource]' = None) -> 'CorrelatedCapture'", "capture_la": "(self, samples: 'int' = 4096, *, sample_rate_hz: 'Optional[float]' = None, stop_dac_after: 'Optional[float]' = None, trigger: 'Optional[Trigger]' = None, trigger_timeout: 'float' = 10.0) -> 'LaCapture'", "capture_uart": "(self, *, rx: 'Union[Pin, int, str, None]' = None, tx: 'Union[Pin, int, str, None]' = None, baud: 'Optional[int]' = None, duration: 'float', until: 'Optional[_uart.Until]' = None) -> '_uart.UartCapture'", - "clear_calibration": "(self) -> 'Calibration'", + "clear_calibration": "(self, part: 'Optional[CalibrationPart]' = None) -> 'Calibration'", "close": "(self) -> 'None'", "command": "(self, req: 'Dict[str, Any]') -> 'Any'", "configure_gpio": "(self, las: 'Sequence[Union[Pin, int, str]]', mode: 'GpioMode' = 'output', *, level: 'Optional[int]' = None) -> 'List[LaPinState]'", @@ -168,10 +171,15 @@ "count", "span", "samples", + "dac", + "adc_gain_ppm", + "dmm", + "measured", "raw" ], "from_reply": "(cls, reply: 'Any') -> \"'Calibration'\"" }, + "CalibrationPart": "Literal['offset', 'dac', 'adc']", "CanBus": { "__init__": "(self, bp: \"'BenchPod'\", *, bitrate: 'int', mode: 'str', term: 'bool' = False, fd: 'bool' = False) -> 'None'", "add_responder": "(self, match_id: 'int', reply_id: 'int', reply_data: 'Union[bytes, Sequence[int], None]' = None, *, match_ext: 'bool' = False, reply_ext: 'bool' = False) -> 'dict'", @@ -249,6 +257,7 @@ "spi_master", "spi_stream", "calibrate", + "cal_dmm", "current_out", "can", "pod_current", @@ -342,10 +351,20 @@ ], "from_reply": "(cls, reply: 'Any') -> \"'CurrentOutput'\"" }, + "DacCalPath": "Literal['3v3', '5v', '12v']", "DacHandle": { "__init__": "(self, *, stop: 'Callable[[], Any]', dac_path: 'str' = '', cotrig: 'bool' = False, data: 'Optional[dict]' = None) -> 'None'", "stop": "(self) -> 'None'" }, + "DacLine": { + "__fields__": [ + "a", + "b", + "offset", + "gain_ppm" + ], + "from_reply": "(cls, d: 'Any') -> \"Optional['DacLine']\"" + }, "DacOutput": { "__fields__": [ "path", @@ -974,6 +993,7 @@ "with_changes": "(self, **changes: 'Any') -> \"'Wiring'\"" }, "__all__": [ + "AdcGainSource", "AdcReading", "AdcSource", "AnalogPath", @@ -986,6 +1006,7 @@ "BuildReporter", "CalibrateSource", "Calibration", + "CalibrationPart", "CanBus", "CanFrame", "CanMode", @@ -1000,7 +1021,9 @@ "ControlLoopHandle", "CorrelatedCapture", "CurrentOutput", + "DacCalPath", "DacHandle", + "DacLine", "DacOutput", "DacOutputPath", "DacPath", diff --git a/packages/embeddedci/tests/test_client_v2.py b/packages/embeddedci/tests/test_client_v2.py index 19775c0..0a18283 100644 --- a/packages/embeddedci/tests/test_client_v2.py +++ b/packages/embeddedci/tests/test_client_v2.py @@ -13,6 +13,7 @@ AdcReading, AnalogPathState, Calibration, + DacLine, CurrentOutput, DacOutput, LaVoltage, @@ -373,6 +374,65 @@ def test_calibrate_needs_the_capability(): assert BenchPod(transport=CalPod(), lease=False).capabilities.calibrate +class DmmCalPod(FakeTransport): + """A pod that takes the DMM-guided calibration (firmware 3.9.3+).""" + + def status(self) -> Any: + return {**super().status(), "version": "3.9.3", "caps": ["calibrate", "cal_dmm"]} + + +_DMM = {**_CAL, "dmm": True, + "dac": {"3v3": None, "5v": {"a_uv": -609, "b_pv": 76249841, "offset_uv": -3127, "gain_ppm": -996}, + "12v": None}, + "adc_gain_ppm": {"cal1": 1850, "cal2": None, "ext": None}} + + +def test_calibrate_dac_sends_both_points_in_microvolts(): + t = DmmCalPod({"calibrate": lambda req: _DMM}) + bp = BenchPod(transport=t, lease=False) + assert bp.capabilities.cal_dmm + c = bp.calibrate_dac("5v", [(13069, 0.9959), (52374, 3.9929)]) + assert t.commands[-1] == {"cmd": "calibrate", "dac": "5v", "code1": 13069, "uv1": 995900, + "code2": 52374, "uv2": 3992900} + line = c.dac["5v"] + assert isinstance(line, DacLine) and c.dac["3v3"] is None and c.dmm + assert line.a == pytest.approx(-609e-6) and line.b == pytest.approx(76.249841e-6) + assert line.offset == pytest.approx(-3.127e-3) and line.gain_ppm == -996 + assert c.adc_gain_ppm == {"cal1": 1850, "cal2": None, "ext": None} + with pytest.raises(ValueError): + bp.calibrate_dac("5v", [(13069, 0.9959)]) + with pytest.raises(ValueError): + bp.calibrate_dac("off", [(1, 0.0), (2, 1.0)]) # type: ignore[arg-type] + assert set(bp.DMM_SET_POINTS) == {"3v3", "5v", "12v"} + + +def test_calibrate_adc_gain_and_partial_clear(): + meas = {"v1_uv": 995900, "v2_uv": 3992900, "r1_uv": 994100, "r2_uv": 3985500, "residual_uv": 1200} + t = DmmCalPod({"calibrate": lambda req: {**_DMM, "measured": meas} if "adc" in req else _DMM}) + bp = BenchPod(transport=t, lease=False) + c = bp.calibrate_adc_gain("cal1") + assert t.commands[-1] == {"cmd": "calibrate", "adc": "cal1"} + assert c.measured == pytest.approx({"v1": 0.9959, "v2": 3.9929, "r1": 0.9941, "r2": 3.9855, "residual": 0.0012}) + with pytest.raises(ValueError): + bp.calibrate_adc_gain("current_in") # type: ignore[arg-type] + bp.clear_calibration("dac") + assert t.commands[-1] == {"cmd": "calibrate", "clear": "dac"} + with pytest.raises(ValueError): + bp.clear_calibration("everything") # type: ignore[arg-type] + + +def test_dmm_calibration_needs_cal_dmm(): + t = CalPod({"calibrate": lambda req: _CAL}) # 3.4.0 to 3.9.2: offset only + bp = BenchPod(transport=t, lease=False) + for call in (lambda: bp.calibrate_dac("5v", [(1, 1.0), (40000, 3.0)]), lambda: bp.calibrate_adc_gain("cal1"), + lambda: bp.clear_calibration("adc")): + with pytest.raises(BenchPodError, match="cal_dmm"): + call() + assert t.commands == [] + old = bp.calibration() # an old reply has no DMM parts + assert old.dac == {} and old.adc_gain_ppm == {} and not old.dmm and old.measured is None + + def test_capture_adc_routes_the_source_first(): bp, t = _bp({"analog_path": {"path": "cal1"}}) cap = bp.capture_adc(2, source="cal1", sample_rate_hz=100_000) From 20dd3688ff42c8e904a2a43b0156add13884354c Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Mon, 19 Oct 2026 17:16:10 -0500 Subject: [PATCH 2/4] DMM calibration is pod firmware 3.9.4 and SDK/MCP 2.13.3 (2.13.2 ships without it) Co-Authored-By: Claude Opus 5.5 --- packages/embeddedci-mcp/CHANGELOG.md | 7 +++++-- packages/embeddedci-mcp/README.md | 6 +++--- packages/embeddedci-mcp/src/embeddedci_mcp/models.py | 2 +- packages/embeddedci-mcp/src/embeddedci_mcp/server.py | 8 ++++---- packages/embeddedci-mcp/tests/tools_surface.json | 2 +- packages/embeddedci/CHANGELOG.md | 7 +++++-- packages/embeddedci/README.md | 4 ++-- .../embeddedci/src/embeddedci/benchpod/capabilities.py | 2 +- packages/embeddedci/src/embeddedci/benchpod/client.py | 6 +++--- packages/embeddedci/src/embeddedci/benchpod/state.py | 2 +- packages/embeddedci/tests/test_client_v2.py | 4 ++-- 11 files changed, 28 insertions(+), 22 deletions(-) diff --git a/packages/embeddedci-mcp/CHANGELOG.md b/packages/embeddedci-mcp/CHANGELOG.md index 539ed57..21bb168 100644 --- a/packages/embeddedci-mcp/CHANGELOG.md +++ b/packages/embeddedci-mcp/CHANGELOG.md @@ -1,10 +1,13 @@ # Changelog — `embeddedci-mcp` -## 2.13.2 +## 2.13.3 (unreleased) -- New tools `calibrate_dac` and `calibrate_adc_gain`: the DMM-guided calibration (firmware 3.9.3+). +- New tools `calibrate_dac` and `calibrate_adc_gain`: the DMM-guided calibration (firmware 3.9.4+). `calibrate` takes `clear_part` (offset, dac or adc); `calibration` reports the DAC lines and ADC gains. + +## 2.13.2 + - `calibrate` and `calibration` describe the ADC offset calibration: from firmware 3.9.3 it corrects every ADC source, up to 150 mV. - Requires embeddedci 2.13.2. diff --git a/packages/embeddedci-mcp/README.md b/packages/embeddedci-mcp/README.md index c179036..4bea70e 100644 --- a/packages/embeddedci-mcp/README.md +++ b/packages/embeddedci-mcp/README.md @@ -290,10 +290,10 @@ the pod's second UART, so `uart_open` keeps the console. Needs gateware v48 (`gp | `dac_output` | Route a DAC output and drive a calibrated DC voltage on it. | **`path`**, `volts` | | `current_out` | Hold a current on the 4-20 mA output (J9), in amps, or read its range. | `current` | | `adc_read` | One calibrated reading: front SMA, DAC loopbacks or the 4-20 mA input (J8). | `source=ext` | -| `calibration` | The stored calibration: the ADC offset (measured on the 4-20 mA input) and the fit it uses, plus the DMM lines and gains (firmware 3.9.3+). | none | +| `calibration` | The stored calibration: the ADC offset (measured on the 4-20 mA input) and the fit it uses, plus the DMM lines and gains (firmware 3.9.4+). | none | | `calibrate` | Measure and store the ADC offset on the 4-20 mA input (J8 open), or clear the calibration (all of it, or one part). | `source=current_in`, `clear=false`, `clear_part` | -| `calibrate_dac` | Store this pod's line for a DAC output from two DMM readings of set points driven with `dac_output` (firmware 3.9.3+). | **`path`**, **`code1`**, **`volts1`**, **`code2`**, **`volts2`** | -| `calibrate_adc_gain` | Measure an ADC source's gain through a loopback against a DMM-calibrated output (firmware 3.9.3+). | **`source`** | +| `calibrate_dac` | Store this pod's line for a DAC output from two DMM readings of set points driven with `dac_output` (firmware 3.9.4+). | **`path`**, **`code1`**, **`volts1`**, **`code2`**, **`volts2`** | +| `calibrate_adc_gain` | Measure an ADC source's gain through a loopback against a DMM-calibrated output (firmware 3.9.4+). | **`source`** | ### Capture and decode diff --git a/packages/embeddedci-mcp/src/embeddedci_mcp/models.py b/packages/embeddedci-mcp/src/embeddedci_mcp/models.py index a615736..ed10d8d 100644 --- a/packages/embeddedci-mcp/src/embeddedci_mcp/models.py +++ b/packages/embeddedci-mcp/src/embeddedci_mcp/models.py @@ -435,7 +435,7 @@ class CalibrationResult(BaseModel): span: Optional[int] = Field(None, description="Its peak-to-peak spread in counts.") samples: Optional[int] = Field(None, description="Samples averaged.") dac: Dict[str, Optional[DacLineResult]] = Field(default_factory=dict, description=( - "This pod's DMM line per DAC output (3v3, 5v, 12v); null = the built-in line. Empty before firmware 3.9.3.")) + "This pod's DMM line per DAC output (3v3, 5v, 12v); null = the built-in line. Empty before firmware 3.9.4.")) adc_gain_ppm: Dict[str, Optional[int]] = Field(default_factory=dict, description=( "ADC gain correction per source (cal1, cal2, ext) in ppm; null = the built-in gain.")) dmm: bool = Field(False, description="True when any DMM-guided part is stored.") diff --git a/packages/embeddedci-mcp/src/embeddedci_mcp/server.py b/packages/embeddedci-mcp/src/embeddedci_mcp/server.py index e9fb63a..97f2334 100644 --- a/packages/embeddedci-mcp/src/embeddedci_mcp/server.py +++ b/packages/embeddedci-mcp/src/embeddedci_mcp/server.py @@ -1359,7 +1359,7 @@ def _calibration_result(c: Any) -> m.CalibrationResult: @mcp.tool(annotations=_ann("Read the pod's calibration", read_only=True)) async def calibration() -> m.CalibrationResult: """The calibration the pod has stored: the ADC offset (measured on current_in, terminal J8) and the fit it uses, - and on firmware 3.9.3+ the DMM-guided parts (dac: a line per DAC output, adc_gain_ppm: a gain per ADC source). + and on firmware 3.9.4+ the DMM-guided parts (dac: a line per DAC output, adc_gain_ppm: a gain per ADC source). calibrated=false means the pod has no ADC offset: analog readings can be off by up to about 50 mV. """ @@ -1371,7 +1371,7 @@ async def calibrate( source: Annotated[CalibrateSource, Field(description="What to calibrate. Only current_in, the 4-20 mA input.")] = "current_in", clear: Annotated[bool, Field(description="True removes the stored calibration instead of measuring one.")] = False, clear_part: Annotated[Optional[CalibrationPart], Field(description=( - "With clear=true, remove only this part: offset, dac (the DMM lines) or adc (the ADC gains). Firmware 3.9.3+."))] = None, + "With clear=true, remove only this part: offset, dac (the DMM lines) or adc (the ADC gains). Firmware 3.9.4+."))] = None, ) -> m.CalibrationResult: """Calibrate the pod's ADC offset on its 4-20 mA input (current_in, terminal J8) and store it on the pod. @@ -1395,7 +1395,7 @@ async def calibrate_dac( code2: Annotated[int, Field(ge=0, le=65535, description="The 16-bit code of the second set point.")], volts2: Annotated[float, Field(description="What the DMM read at the second set point, in volts.")], ) -> m.CalibrationResult: - """Store this pod's own line for a DAC output from two DMM readings (firmware 3.9.3+, cap cal_dmm). + """Store this pod's own line for a DAC output from two DMM readings (firmware 3.9.4+, cap cal_dmm). The flow, with the user holding a bench DMM: ask them to disconnect the DUT from the output and put the DMM (DC volts) on that output's SMA. Drive each set point with dac_output(path, volts) and keep its code16 @@ -1414,7 +1414,7 @@ async def calibrate_adc_gain( "cal1 (and current_in) through the 5 V output, cal2 through the ±12 V output, ext (the front SMA, " "captures and the control loop) through an SMA cable from the ±12 V output to the ADC input."))], ) -> m.CalibrationResult: - """Measure and store an ADC source's gain against a DMM-calibrated DAC output (firmware 3.9.3+). + """Measure and store an ADC source's gain against a DMM-calibrated DAC output (firmware 3.9.4+). Needs the ADC offset (calibrate) and the driving output's DMM line (calibrate_dac on 5v for cal1, 12v for cal2 and ext). For ext, ask the user to connect the SMA cable first. The pod drives two set points itself diff --git a/packages/embeddedci-mcp/tests/tools_surface.json b/packages/embeddedci-mcp/tests/tools_surface.json index 83e52aa..8d86f46 100644 --- a/packages/embeddedci-mcp/tests/tools_surface.json +++ b/packages/embeddedci-mcp/tests/tools_surface.json @@ -88,7 +88,7 @@ } ], "default": null, - "description": "With clear=true, remove only this part: offset, dac (the DMM lines) or adc (the ADC gains). Firmware 3.9.3+.", + "description": "With clear=true, remove only this part: offset, dac (the DMM lines) or adc (the ADC gains). Firmware 3.9.4+.", "title": "Clear Part" }, "source": { diff --git a/packages/embeddedci/CHANGELOG.md b/packages/embeddedci/CHANGELOG.md index 33038b4..ae62166 100644 --- a/packages/embeddedci/CHANGELOG.md +++ b/packages/embeddedci/CHANGELOG.md @@ -1,14 +1,17 @@ # Changelog — `embeddedci` -## 2.13.2 +## 2.13.3 (unreleased) -- The DMM-guided calibration (pod firmware 3.9.3+, `Capabilities.cal_dmm`): +- The DMM-guided calibration (pod firmware 3.9.4+, `Capabilities.cal_dmm`): `BenchPod.calibrate_dac(path, points)` stores this pod's own line for a DAC output from two `(code16, volts)` DMM readings, `calibrate_adc_gain(source)` measures an ADC gain (`cal1`, `cal2`, `ext`) through a loopback, and `clear_calibration(part)` clears one part (`offset`, `dac`, `adc`). `Calibration` gains `dac` (`DacLine`), `adc_gain_ppm`, `dmm` and `measured`; `BenchPod.DMM_SET_POINTS` has the set points. New types `DacCalPath`, `AdcGainSource`, `CalibrationPart`. + +## 2.13.2 + - Docs: `calibrate()` corrects every ADC source from firmware 3.9.3 (they share the front end) and the pod accepts an offset up to 150 mV. On the LAN, `capture_adc` volts use the fit the pod now reports in `status`, so they include the pod's calibrated offset. diff --git a/packages/embeddedci/README.md b/packages/embeddedci/README.md index d93fd5b..c7d69ae 100644 --- a/packages/embeddedci/README.md +++ b/packages/embeddedci/README.md @@ -1014,7 +1014,7 @@ Calibration. #### Calibrating with a DMM -With a bench DMM the pod can also take out what the offset leaves (firmware 3.9.3+, capability +With a bench DMM the pod can also take out what the offset leaves (firmware 3.9.4+, capability `cal_dmm`, `Capabilities.cal_dmm`): its own DAC line per output, then the ADC gain per source. Every step is optional and stored on its own; an output or source without one keeps the built-in fit. Disconnect the DUT from the DAC outputs first: this drives up to ±5 V on them. @@ -1718,7 +1718,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+), `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`, `cal_dmm` (firmware 3.9.3+: `calibrate_dac`, `calibrate_adc_gain`), `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) | +| 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`, `cal_dmm` (firmware 3.9.4+: `calibrate_dac`, `calibrate_adc_gain`), `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`) | | Source | `raw` (the map it was parsed from) | diff --git a/packages/embeddedci/src/embeddedci/benchpod/capabilities.py b/packages/embeddedci/src/embeddedci/benchpod/capabilities.py index 56ca45a..b3a3b6f 100644 --- a/packages/embeddedci/src/embeddedci/benchpod/capabilities.py +++ b/packages/embeddedci/src/embeddedci/benchpod/capabilities.py @@ -176,7 +176,7 @@ class Capabilities: spi_stream: bool = False #: The pod can calibrate its own ADC and keeps the result: :meth:`BenchPod.calibrate`. calibrate: bool = False - #: ``calibrate`` also takes the DMM-guided forms (firmware 3.9.3+): this pod's own DAC line + #: ``calibrate`` also takes the DMM-guided forms (firmware 3.9.4+): this pod's own DAC line #: per output and ADC gain per source, :meth:`BenchPod.calibrate_dac`, #: :meth:`BenchPod.calibrate_adc_gain`. cal_dmm: bool = False diff --git a/packages/embeddedci/src/embeddedci/benchpod/client.py b/packages/embeddedci/src/embeddedci/benchpod/client.py index 4962cf9..67bf93b 100644 --- a/packages/embeddedci/src/embeddedci/benchpod/client.py +++ b/packages/embeddedci/src/embeddedci/benchpod/client.py @@ -1264,7 +1264,7 @@ def calibration(self) -> Calibration: def clear_calibration(self, part: Optional[CalibrationPart] = None) -> Calibration: """Remove the pod's stored calibration. It goes back to the built-in fits. - ``part`` removes one part only (firmware 3.9.3+): ``"offset"`` (the ADC offset), + ``part`` removes one part only (firmware 3.9.4+): ``"offset"`` (the ADC offset), ``"dac"`` (the DMM lines of every DAC output) or ``"adc"`` (the ADC gains). """ self._require_capability("calibrate", "calibration") @@ -1280,7 +1280,7 @@ def clear_calibration(self, part: Optional[CalibrationPart] = None) -> Calibrati def calibrate_dac(self, path: DacCalPath, points: Sequence[Tuple[int, float]]) -> Calibration: - """Store this pod's own line for a DAC output from two DMM readings (firmware 3.9.3+). + """Store this pod's own line for a DAC output from two DMM readings (firmware 3.9.4+). ``points`` is two ``(code16, volts)`` pairs: the 16-bit code the pod drove (the ``code16`` of :meth:`dac_output`) and what a DMM read on that output's SMA. Disconnect @@ -1308,7 +1308,7 @@ def calibrate_dac(self, path: DacCalPath, "code2": int(c2), "uv2": int(round(float(v2) * 1e6))})) def calibrate_adc_gain(self, source: AdcGainSource) -> Calibration: - """Measure and store an ADC source's gain through a loopback (firmware 3.9.3+). + """Measure and store an ADC source's gain through a loopback (firmware 3.9.4+). The pod drives two set points on a DAC output that has its DMM line (:meth:`calibrate_dac`) and compares what the ADC reads with what the output puts out: diff --git a/packages/embeddedci/src/embeddedci/benchpod/state.py b/packages/embeddedci/src/embeddedci/benchpod/state.py index 92ae809..3fb4d02 100644 --- a/packages/embeddedci/src/embeddedci/benchpod/state.py +++ b/packages/embeddedci/src/embeddedci/benchpod/state.py @@ -341,7 +341,7 @@ class Calibration: Every ADC source is scaled with a fit, ``volts = a + b * count``, that is built into the firmware and the same on every pod. A pod that calibrated itself keeps its own values on top of that, in flash: the ADC offset, measured on ``current_in`` (the 4-20 mA input, J8), and from - firmware 3.9.3 the DMM-guided parts, a DAC line per output (``dac``) and an ADC gain per + firmware 3.9.4 the DMM-guided parts, a DAC line per output (``dac``) and an ADC gain per source (``adc_gain_ppm``). """ diff --git a/packages/embeddedci/tests/test_client_v2.py b/packages/embeddedci/tests/test_client_v2.py index 0a18283..370ab19 100644 --- a/packages/embeddedci/tests/test_client_v2.py +++ b/packages/embeddedci/tests/test_client_v2.py @@ -375,10 +375,10 @@ def test_calibrate_needs_the_capability(): class DmmCalPod(FakeTransport): - """A pod that takes the DMM-guided calibration (firmware 3.9.3+).""" + """A pod that takes the DMM-guided calibration (firmware 3.9.4+).""" def status(self) -> Any: - return {**super().status(), "version": "3.9.3", "caps": ["calibrate", "cal_dmm"]} + return {**super().status(), "version": "3.9.4", "caps": ["calibrate", "cal_dmm"]} _DMM = {**_CAL, "dmm": True, From 02bee4559d5beafcc5e768edb1d62c27ba0f4fa5 Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Mon, 19 Oct 2026 19:21:17 -0500 Subject: [PATCH 3/4] Calibration.zero_count: the raw zero the pod derives its offset from (fw#61 stores raw measurements) Co-Authored-By: Claude Opus 5.5 --- packages/embeddedci-mcp/src/embeddedci_mcp/models.py | 2 ++ packages/embeddedci-mcp/src/embeddedci_mcp/server.py | 3 ++- packages/embeddedci/CHANGELOG.md | 2 +- packages/embeddedci/README.md | 8 +++++--- packages/embeddedci/src/embeddedci/benchpod/state.py | 4 ++++ packages/embeddedci/tests/api_surface.json | 1 + packages/embeddedci/tests/test_client_v2.py | 4 +++- 7 files changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/embeddedci-mcp/src/embeddedci_mcp/models.py b/packages/embeddedci-mcp/src/embeddedci_mcp/models.py index ed10d8d..a5dd8ef 100644 --- a/packages/embeddedci-mcp/src/embeddedci_mcp/models.py +++ b/packages/embeddedci-mcp/src/embeddedci_mcp/models.py @@ -439,6 +439,8 @@ class CalibrationResult(BaseModel): adc_gain_ppm: Dict[str, Optional[int]] = Field(default_factory=dict, description=( "ADC gain correction per source (cal1, cal2, ext) in ppm; null = the built-in gain.")) dmm: bool = Field(False, description="True when any DMM-guided part is stored.") + zero_count: Optional[float] = Field(None, description=( + "The raw zero the offset is derived from: the mean ADC count with J8 open, unwrapped (firmware 3.9.4+).")) measured: Optional[Dict[str, float]] = Field(None, description=( "After calibrate_adc_gain: v1, v2 (what the DAC put out), r1, r2 (what the ADC read before the gain) " "and residual (offset left after it), in volts.")) diff --git a/packages/embeddedci-mcp/src/embeddedci_mcp/server.py b/packages/embeddedci-mcp/src/embeddedci_mcp/server.py index 97f2334..f9f762e 100644 --- a/packages/embeddedci-mcp/src/embeddedci_mcp/server.py +++ b/packages/embeddedci-mcp/src/embeddedci_mcp/server.py @@ -1353,7 +1353,8 @@ def _calibration_result(c: Any) -> m.CalibrationResult: for k, v in (c.dac or {}).items()} return m.CalibrationResult(source=c.source, calibrated=c.calibrated, offset=c.offset, a=c.a, b=c.b, count=c.count, span=c.span, samples=c.samples, dac=dac, - adc_gain_ppm=dict(c.adc_gain_ppm or {}), dmm=c.dmm, measured=c.measured) + adc_gain_ppm=dict(c.adc_gain_ppm or {}), dmm=c.dmm, measured=c.measured, + zero_count=c.zero_count) @mcp.tool(annotations=_ann("Read the pod's calibration", read_only=True)) diff --git a/packages/embeddedci/CHANGELOG.md b/packages/embeddedci/CHANGELOG.md index ae62166..7ec1fd4 100644 --- a/packages/embeddedci/CHANGELOG.md +++ b/packages/embeddedci/CHANGELOG.md @@ -8,7 +8,7 @@ `cal2`, `ext`) through a loopback, and `clear_calibration(part)` clears one part (`offset`, `dac`, `adc`). `Calibration` gains `dac` (`DacLine`), `adc_gain_ppm`, `dmm` and `measured`; `BenchPod.DMM_SET_POINTS` has the set points. New types `DacCalPath`, `AdcGainSource`, - `CalibrationPart`. + `CalibrationPart`. `Calibration.zero_count` is the raw J8-open zero the pod derives its offset from. ## 2.13.2 diff --git a/packages/embeddedci/README.md b/packages/embeddedci/README.md index c7d69ae..14f7f93 100644 --- a/packages/embeddedci/README.md +++ b/packages/embeddedci/README.md @@ -1043,8 +1043,10 @@ needs the ADC offset and the driving output's DMM line, and `Calibration.measure saw. The pod refuses (`BenchPodError`) and keeps what it had when a reading cannot be right: two codes too close, a reading far from what the output should give (the DMM on another SMA), a gain more than 2 % off. It also refuses while DAC output limits are set. `Calibration.dac`, -`Calibration.adc_gain_ppm` and `Calibration.dmm` show what is stored. Going back to firmware -older than 3.9.3 drops the whole calibration. +`Calibration.adc_gain_ppm` and `Calibration.dmm` show what is stored. The pod stores raw +measurements (the J8-open zero as `Calibration.zero_count`, the DMM points) and derives every fit +from them, so a later firmware with re-measured built-in fits keeps them right. Going back to +firmware 3.4.0 to 3.9.3 keeps the offset; the DMM parts are not used there. ### The 4-20 mA output @@ -1673,7 +1675,7 @@ few built from other shapes); `to_dict()` goes the other way where a type is sen | `AdcReading` | `adc_read` | `source`, `voltage`, `count`, `span`, `offset`, `current` | | `DacOutput` | `dac_output` | `path`, `voltage`, `code` | | `CurrentOutput` | `current_out`, `current_out_range` | `current`, `code`, `min_current`, `max_current` | -| `Calibration` | `calibrate`, `calibration`, `clear_calibration`, `calibrate_dac`, `calibrate_adc_gain` | `source`, `calibrated`, `offset`, `a`, `b`, `count`, `span`, `samples`, `dac`, `adc_gain_ppm`, `dmm`, `measured` | +| `Calibration` | `calibrate`, `calibration`, `clear_calibration`, `calibrate_dac`, `calibrate_adc_gain` | `source`, `calibrated`, `offset`, `a`, `b`, `count`, `span`, `samples`, `dac`, `adc_gain_ppm`, `dmm`, `measured`, `zero_count` | | `DacLine` | `Calibration.dac` | `a`, `b`, `offset`, `gain_ppm` | | `Capture` | `capture_adc` | `counts`, `volts`, `currents`, `sample_rate_hz`, `source`, `trigger`, `duration`, `times()`, `mean()`, `min()`, `max()`, `rms()`, `rms_ac()`, `peak_to_peak()`, `first_crossing()`, `crossing_times()`, `fft()`, `dominant_frequency()` | | `LaCapture` | `capture_la` | `words`, `channels`, `sample_rate_hz`, `trigger`, `duration`, `channel(la)`, `level_at()`, `edges()`, `edge_times()`, `first_edge()`, `delay()`, `frequency()`, `duty_cycle()`, `pulse_widths()`, `decode()` | diff --git a/packages/embeddedci/src/embeddedci/benchpod/state.py b/packages/embeddedci/src/embeddedci/benchpod/state.py index 3fb4d02..7793007 100644 --- a/packages/embeddedci/src/embeddedci/benchpod/state.py +++ b/packages/embeddedci/src/embeddedci/benchpod/state.py @@ -368,6 +368,9 @@ class Calibration: adc_gain_ppm: Dict[str, Optional[int]] = field(default_factory=dict) #: ``True`` when any DMM part is stored. dmm: bool = False + #: The raw zero the offset is derived from: the mean count ``current_in`` read with J8 open, + #: unwrapped. ``None`` when not calibrated or on firmware before 3.9.4. + zero_count: Optional[float] = None #: After :meth:`BenchPod.calibrate_adc_gain`: what the DAC put out at the two set points #: (``v1``, ``v2``), what the ADC read before the gain (``r1``, ``r2``) and the mean offset left #: after it (``residual``), all in volts. ``None`` otherwise. @@ -388,6 +391,7 @@ def from_reply(cls, reply: Any) -> "Calibration": dac={str(k): DacLine.from_reply(v) for k, v in dac.items()}, adc_gain_ppm={str(k): _opt_int(gains, k) for k in gains}, dmm=d.get("dmm") is True, + zero_count=None if _opt_int(d, "zero_count_mc") is None else _opt_int(d, "zero_count_mc") / 1000.0, measured=None if meas is None else { k[:-3]: (_opt_int(meas, k) or 0) / 1e6 for k in meas if k.endswith("_uv")}, raw=d) diff --git a/packages/embeddedci/tests/api_surface.json b/packages/embeddedci/tests/api_surface.json index 836e977..02e6de2 100644 --- a/packages/embeddedci/tests/api_surface.json +++ b/packages/embeddedci/tests/api_surface.json @@ -174,6 +174,7 @@ "dac", "adc_gain_ppm", "dmm", + "zero_count", "measured", "raw" ], diff --git a/packages/embeddedci/tests/test_client_v2.py b/packages/embeddedci/tests/test_client_v2.py index 370ab19..af8894b 100644 --- a/packages/embeddedci/tests/test_client_v2.py +++ b/packages/embeddedci/tests/test_client_v2.py @@ -381,7 +381,7 @@ def status(self) -> Any: return {**super().status(), "version": "3.9.4", "caps": ["calibrate", "cal_dmm"]} -_DMM = {**_CAL, "dmm": True, +_DMM = {**_CAL, "dmm": True, "zero_count_mc": 65585123, "dac": {"3v3": None, "5v": {"a_uv": -609, "b_pv": 76249841, "offset_uv": -3127, "gain_ppm": -996}, "12v": None}, "adc_gain_ppm": {"cal1": 1850, "cal2": None, "ext": None}} @@ -399,6 +399,7 @@ def test_calibrate_dac_sends_both_points_in_microvolts(): assert line.a == pytest.approx(-609e-6) and line.b == pytest.approx(76.249841e-6) assert line.offset == pytest.approx(-3.127e-3) and line.gain_ppm == -996 assert c.adc_gain_ppm == {"cal1": 1850, "cal2": None, "ext": None} + assert c.zero_count == pytest.approx(65585.123) with pytest.raises(ValueError): bp.calibrate_dac("5v", [(13069, 0.9959)]) with pytest.raises(ValueError): @@ -431,6 +432,7 @@ def test_dmm_calibration_needs_cal_dmm(): assert t.commands == [] old = bp.calibration() # an old reply has no DMM parts assert old.dac == {} and old.adc_gain_ppm == {} and not old.dmm and old.measured is None + assert old.zero_count is None def test_capture_adc_routes_the_source_first(): From ea038f23dd99d90aad02748b0aa7500737de432c Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Mon, 19 Oct 2026 19:28:46 -0500 Subject: [PATCH 4/4] embeddedci 2.13.3, embeddedci-mcp 2.13.3: the MCP tools need the SDK's DMM calls Co-Authored-By: Claude Opus 5.5 --- packages/embeddedci-mcp/CHANGELOG.md | 3 ++- packages/embeddedci-mcp/pyproject.toml | 4 ++-- packages/embeddedci-mcp/server.json | 4 ++-- packages/embeddedci/CHANGELOG.md | 2 +- packages/embeddedci/pyproject.toml | 2 +- 5 files changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/embeddedci-mcp/CHANGELOG.md b/packages/embeddedci-mcp/CHANGELOG.md index 21bb168..a099aec 100644 --- a/packages/embeddedci-mcp/CHANGELOG.md +++ b/packages/embeddedci-mcp/CHANGELOG.md @@ -1,10 +1,11 @@ # Changelog — `embeddedci-mcp` -## 2.13.3 (unreleased) +## 2.13.3 - New tools `calibrate_dac` and `calibrate_adc_gain`: the DMM-guided calibration (firmware 3.9.4+). `calibrate` takes `clear_part` (offset, dac or adc); `calibration` reports the DAC lines and ADC gains. +- Requires embeddedci 2.13.3. ## 2.13.2 diff --git a/packages/embeddedci-mcp/pyproject.toml b/packages/embeddedci-mcp/pyproject.toml index 909caf9..edabf83 100644 --- a/packages/embeddedci-mcp/pyproject.toml +++ b/packages/embeddedci-mcp/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "embeddedci-mcp" -version = "2.13.2" +version = "2.13.3" description = "MCP server that lets AI agents drive an EmbeddedCI BenchPod hardware-in-the-loop bench" readme = "README.md" requires-python = ">=3.10" @@ -26,7 +26,7 @@ classifiers = [ dependencies = [ # cloud: the embeddedci: destination; discovery: connect("discover") over mDNS; # analysis: dominant frequency in capture summaries. - "embeddedci[cloud,discovery,analysis]>=2.13.2,<3", + "embeddedci[cloud,discovery,analysis]>=2.13.3,<3", "mcp>=1.12,<2", "anyio>=4", "pydantic>=2.7", diff --git a/packages/embeddedci-mcp/server.json b/packages/embeddedci-mcp/server.json index 67ee396..858ab44 100644 --- a/packages/embeddedci-mcp/server.json +++ b/packages/embeddedci-mcp/server.json @@ -3,7 +3,7 @@ "name": "io.github.embeddedci-com/embeddedci-mcp", "title": "EmbeddedCI BenchPod", "description": "Drive a BenchPod hardware-in-the-loop bench: power, flash, UART, I2C, CAN, analog and logic.", - "version": "2.13.2", + "version": "2.13.3", "websiteUrl": "https://www.embeddedci.com/docs/benchpod-mcp", "repository": { "url": "https://github.com/embeddedci-com/embeddedci-python", @@ -15,7 +15,7 @@ "registryType": "pypi", "registryBaseUrl": "https://pypi.org", "identifier": "embeddedci-mcp", - "version": "2.13.2", + "version": "2.13.3", "runtimeHint": "uvx", "transport": { "type": "stdio" diff --git a/packages/embeddedci/CHANGELOG.md b/packages/embeddedci/CHANGELOG.md index 7ec1fd4..1475584 100644 --- a/packages/embeddedci/CHANGELOG.md +++ b/packages/embeddedci/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog — `embeddedci` -## 2.13.3 (unreleased) +## 2.13.3 - The DMM-guided calibration (pod firmware 3.9.4+, `Capabilities.cal_dmm`): `BenchPod.calibrate_dac(path, points)` stores this pod's own line for a DAC output from two diff --git a/packages/embeddedci/pyproject.toml b/packages/embeddedci/pyproject.toml index 0f23264..7f0e021 100644 --- a/packages/embeddedci/pyproject.toml +++ b/packages/embeddedci/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "embeddedci" -version = "2.13.2" +version = "2.13.3" description = "BenchPod SDK and pytest plugin for EmbeddedCI hardware-in-the-loop testing" readme = "README.md" requires-python = ">=3.10"