From 3a44f2a774c5a5727b7c941601c86f08dce2ebc9 Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Wed, 28 Oct 2026 22:42:40 -0500 Subject: [PATCH] ADC input range (analog module v3r4): SDK 2.13.4, MCP 2.13.4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SDK: BenchPod.adc_range(range=None) reads or switches the ADC input range (normal ±32.8 V, low ±5.46 V), adc_range_test() self-tests it, and analog_path / adc_read / capture_adc take range=. calibrate_adc_gain("cal1", range="low") measures the low range's gain. Captures scale with the fit of the range in use: the SDK takes it from the adc_range reply and re-reads it whenever a reply says the pod changed range (off and cal2 put it back to normal). AdcReading gains range/fullscale/overrange, Capture range/overrange/ clipped_samples, Calibration adc_low_gain_ppm, Capabilities adc_low_range/ adc_range/adc_range_fullscale. capture_adc(source="current_in") reads the pod's fit after routing, so it is the fit of the range in use. MCP: new adc_range tool (read, switch, test=true); range on analog_path, adc_read, capture_adc and calibrate_adc_gain; results report the range, full scale, overrange and clipped samples. Co-Authored-By: Claude Opus 5.5 --- packages/embeddedci-mcp/CHANGELOG.md | 9 + packages/embeddedci-mcp/README.md | 9 +- packages/embeddedci-mcp/pyproject.toml | 4 +- packages/embeddedci-mcp/server.json | 4 +- .../src/embeddedci_mcp/models.py | 35 +++ .../src/embeddedci_mcp/server.py | 62 ++++- .../src/embeddedci_mcp/summaries.py | 2 +- packages/embeddedci-mcp/tests/conftest.py | 20 ++ packages/embeddedci-mcp/tests/test_tools.py | 22 +- .../embeddedci-mcp/tests/tools_surface.json | 99 ++++++++ packages/embeddedci/CHANGELOG.md | 14 ++ packages/embeddedci/README.md | 53 ++++- packages/embeddedci/pyproject.toml | 2 +- .../src/embeddedci/benchpod/__init__.py | 8 + .../src/embeddedci/benchpod/capabilities.py | 21 ++ .../src/embeddedci/benchpod/capture.py | 5 +- .../src/embeddedci/benchpod/client.py | 128 ++++++++-- .../src/embeddedci/benchpod/constants.py | 4 + .../src/embeddedci/benchpod/results.py | 17 ++ .../src/embeddedci/benchpod/state.py | 85 ++++++- packages/embeddedci/tests/api_surface.json | 57 ++++- packages/embeddedci/tests/e2e/test_e2e_pod.py | 29 +++ packages/embeddedci/tests/test_adc_range.py | 218 ++++++++++++++++++ .../tests/test_current_out_waveforms.py | 6 +- 24 files changed, 855 insertions(+), 58 deletions(-) create mode 100644 packages/embeddedci/tests/test_adc_range.py diff --git a/packages/embeddedci-mcp/CHANGELOG.md b/packages/embeddedci-mcp/CHANGELOG.md index a099aec..beeaeb3 100644 --- a/packages/embeddedci-mcp/CHANGELOG.md +++ b/packages/embeddedci-mcp/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog — `embeddedci-mcp` +## 2.13.4 + +- New tool `adc_range`: read or switch the ADC input range (normal ±32.8 V, low ±5.46 V at 6x the + resolution; analog module v3r4+, firmware 3.9.5+), or run its self-test (`test=true`). + `analog_path`, `adc_read` and `capture_adc` take `range`; `calibrate_adc_gain` takes `range=low` + for the low range's gain. `adc_read` reports `range`, `fullscale` and `overrange`, `capture_adc` + `range` and `clipped_samples`, `calibration` `adc_low_gain_ppm`. +- Requires embeddedci 2.13.4. + ## 2.13.3 - New tools `calibrate_dac` and `calibrate_adc_gain`: the DMM-guided calibration (firmware 3.9.4+). diff --git a/packages/embeddedci-mcp/README.md b/packages/embeddedci-mcp/README.md index 4bea70e..8cd6e29 100644 --- a/packages/embeddedci-mcp/README.md +++ b/packages/embeddedci-mcp/README.md @@ -286,20 +286,21 @@ the pod's second UART, so `uart_open` keeps the console. Needs gateware v48 (`gp | Tool | Purpose | Parameters | | --- | --- | --- | -| `analog_path` | Set every analog mux and relay for a named path in one step. | **`path`** | +| `analog_path` | Set every analog mux and relay for a named path in one step; `range` sets the ADC input range too. | **`path`**, `range` | | `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` | +| `adc_read` | One calibrated reading: front SMA, DAC loopbacks or the 4-20 mA input (J8); reports the ADC range, its full scale and overrange. | `source=ext`, `range` | +| `adc_range` | Read or switch the ADC input range: normal ±32.8 V or low ±5.46 V at 6x the resolution (analog module v3r4+, firmware 3.9.5+; 1x leads, the low range's input is 1.82 MΩ), or self-test it through the cal1 loopback. | `range`, `test=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.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.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`** | +| `calibrate_adc_gain` | Measure an ADC source's gain through a loopback against a DMM-calibrated output (firmware 3.9.4+); `range=low` the ADC low range's gain through cal1 (firmware 3.9.5+). | **`source`**, `range=normal` | ### Capture and decode | Tool | Purpose | Parameters | | --- | --- | --- | -| `capture_adc` | Capture the ADC: calibrated statistics, dominant frequency and a min/max envelope. | `samples=4096`, `sample_rate_hz`, `source`, `points=200`, `trigger_la`, `trigger_edge=rising`, `trigger_timeout=10` | +| `capture_adc` | Capture the ADC: calibrated statistics, dominant frequency and a min/max envelope; `clipped_samples` counts samples past the range's full scale. | `samples=4096`, `sample_rate_hz`, `source`, `points=200`, `trigger_la`, `trigger_edge=rising`, `trigger_timeout=10`, `range` | | `capture_la` | Capture all 14 LA channels: levels, edge count, first edge and frequency per channel. | `samples=4096`, `sample_rate_hz`, `stop_dac_after`, `trigger_la`, `trigger_edge=rising`, `trigger_timeout=10` | | `capture_correlated` | ADC and LA from one hardware trigger, on one timebase. | `adc_samples=4096`, `adc_sample_rate_hz`, `la_samples=4096`, `la_sample_rate_hz`, `stop_dac_after`, `points=200`, `trigger_la`, `trigger_edge=rising`, `trigger_timeout=10`, `source` | | `decode_la` | Decode I2C, UART or SPI from the last LA capture (or a new one). | **`protocol`**, `sda`, `scl`, `rx`, `baud`, `sclk`, `mosi`, `miso`, `cs`, `mode=0`, `capture=last`, `samples=16384`, `sample_rate_hz=1000000`, `max_items=200` | diff --git a/packages/embeddedci-mcp/pyproject.toml b/packages/embeddedci-mcp/pyproject.toml index edabf83..fe5439a 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.3" +version = "2.13.4" 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.3,<3", + "embeddedci[cloud,discovery,analysis]>=2.13.4,<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 858ab44..1c34344 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.3", + "version": "2.13.4", "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.3", + "version": "2.13.4", "runtimeHint": "uvx", "transport": { "type": "stdio" diff --git a/packages/embeddedci-mcp/src/embeddedci_mcp/models.py b/packages/embeddedci-mcp/src/embeddedci_mcp/models.py index a5dd8ef..4991ea7 100644 --- a/packages/embeddedci-mcp/src/embeddedci_mcp/models.py +++ b/packages/embeddedci-mcp/src/embeddedci_mcp/models.py @@ -389,6 +389,29 @@ class PullStatusResult(BaseModel): class AnalogPathResult(BaseModel): path: str + range: Optional[str] = Field(None, description=( + "The ADC input range in use afterwards: normal or low (firmware 3.9.5+; null on older firmware).")) + + +class AdcRangeTestPointResult(BaseModel): + set_voltage: float = Field(description="What the 5 V output put into the cal1 loopback, volts.") + normal: float = Field(description="What the normal range read, volts.") + low: float = Field(description="What the low range read, volts.") + ok: bool + + +class AdcRangeTestResult(BaseModel): + passed: bool = Field(description="Both ranges agree with the set points within 5 % + 30 mV.") + points: List[AdcRangeTestPointResult] + + +class AdcRangeResult(BaseModel): + range: str = Field(description="The ADC input range in use: normal (±32.8 V) or low (±5.46 V).") + supported: bool = Field(description="The board has the low range (analog module v3r4 and later).") + fullscale: float = Field(description="Full scale of the range in use, volts (±).") + normal_fullscale: float = Field(description="Full scale of the normal range, volts (±).") + low_fullscale: Optional[float] = Field(None, description="Full scale of the low range, volts (±); null without it.") + test: Optional[AdcRangeTestResult] = Field(None, description="The self-test's result (test=true only).") class DacOutputResult(BaseModel): @@ -416,6 +439,12 @@ class AdcReadResult(BaseModel): "The pod's own calibration offset in volts, already taken out of voltage (current_in only).")) current: Optional[float] = Field(None, description=( "Loop current in amps on the 4-20 mA input (current_in only): 0.004 to 0.020 for a live loop.")) + range: Optional[str] = Field(None, description=( + "The ADC input range the reading was taken in: normal or low (firmware 3.9.5+).")) + fullscale: Optional[float] = Field(None, description="That range's full scale, volts (±).") + overrange: bool = Field(False, description=( + "A sample sat on an ADC rail: the input is past fullscale and voltage is only a bound. " + "Read again in the normal range.")) class DacLineResult(BaseModel): @@ -439,6 +468,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.") + adc_low_gain_ppm: Optional[int] = Field(None, description=( + "The ADC low range's gain correction in ppm (analog module v3r4+); null = the nominal divider ratio.")) 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=( @@ -467,6 +498,10 @@ class AdcCaptureResult(BaseModel): trigger: Optional[str] = Field(None, description=( "The trigger that started the capture, e.g. 'LA9 rising'; null for a free-running capture. " "t = 0 is the trigger moment.")) + range: Optional[str] = Field(None, description=( + "The ADC input range the capture was scaled for: normal or low (firmware 3.9.5+).")) + clipped_samples: int = Field(0, description=( + "Samples on an ADC rail: the input went past the range's full scale. Capture again in the normal range.")) class LaChannelSummary(BaseModel): diff --git a/packages/embeddedci-mcp/src/embeddedci_mcp/server.py b/packages/embeddedci-mcp/src/embeddedci_mcp/server.py index f9f762e..a3cfc60 100644 --- a/packages/embeddedci-mcp/src/embeddedci_mcp/server.py +++ b/packages/embeddedci-mcp/src/embeddedci_mcp/server.py @@ -30,6 +30,7 @@ AdcSource, AnalogPath, AdcGainSource, + AdcRange, CalibrateSource, CalibrationPart, DacCalPath, @@ -1286,16 +1287,49 @@ def op() -> m.GpioReleaseResult: # -- analog ------------------------------------------------------------------------------ +AdcRangeArg = Annotated[Optional[AdcRange], Field(description=( + "ADC input range: normal (±32.8 V, ~1 mV/count) or low (±5.46 V, ~167 µV/count, 1.82 MΩ input: 1x leads only; " + "analog module v3r4+). Omit to keep the range the pod is in."))] + + @mcp.tool(annotations=_ann("Apply an analog path", idempotent=True)) -async def analog_path(path: AnalogPath) -> m.AnalogPathResult: +async def analog_path(path: AnalogPath, range: AdcRangeArg = None) -> m.AnalogPathResult: """Set every analog mux and relay for a named path in one step. dac_3v3/dac_5v/dac_12v route the DAC to an output; adc_ext connects the ADC to the front SMA; cal1/cal2 loop the 5 V/12 V DAC output back into the ADC; current_in reads the 4-20 mA input (J8); current_out switches the DAC voltage outputs off for the 4-20 mA output (J9); off parks everything. + range also sets the ADC input range (see adc_range); off and cal2 put it back to normal. + """ + state = await _call(lambda: SESSION.require().analog_path(path, range=range)) + return m.AnalogPathResult(path=state.path, range=state.range or None) + + +@mcp.tool(annotations=_ann("Read or switch the ADC input range", idempotent=True)) +async def adc_range( + range: AdcRangeArg = None, + test: Annotated[bool, Field(description=( + "Run the self-test instead: the 5 V output drives the cal1 loopback at 1 V and 3.5 V and both ranges read it. " + "Nothing may be connected to the 5 V output."))] = False, +) -> m.AdcRangeResult: + """Read or switch the ADC input range (analog module v3r4 and later, firmware 3.9.5+). + + normal is the ÷12 divider: ±32.8 V, about 1 mV per count. low is ÷2: ±5.46 V, about 167 µV per count, + 6x the resolution for signals up to ±5 V. The low range's input is 1.82 MΩ: tell the user to use 1x + leads, not a 10x scope probe. It applies to ext, cal1 and current_in (not cal2), and stays until changed, + analog_path off or cal2, or a pod restart. capture_adc and adc_read scale with the range in use. + A board without the low range refuses low. Use low for small signals; switch back to normal when a + reading or capture reports overrange. """ - state = await _call(lambda: SESSION.require().analog_path(path)) - return m.AnalogPathResult(path=state.path) + result = None + if test: + res = await _call(lambda: SESSION.require().adc_range_test()) + result = m.AdcRangeTestResult(passed=res.passed, points=[ + m.AdcRangeTestPointResult(set_voltage=p.set_voltage, normal=p.normal, low=p.low, ok=p.ok) + for p in res.points]) + st = await _call(lambda: SESSION.require().adc_range(None if test else range)) + return m.AdcRangeResult(range=st.range, supported=st.supported, fullscale=st.fullscale, + normal_fullscale=st.normal_fullscale, low_fullscale=st.low_fullscale, test=result) @mcp.tool(annotations=_ann("Set a DC output voltage", destructive=True, idempotent=True)) @@ -1336,16 +1370,18 @@ async def current_out( @mcp.tool(annotations=_ann("Read a voltage", read_only=True)) -async def adc_read(source: AdcSource = "ext") -> m.AdcReadResult: +async def adc_read(source: AdcSource = "ext", range: AdcRangeArg = None) -> m.AdcReadResult: """One calibrated voltage: ext = front SMA (true volts), cal1/cal2 = DAC loopbacks, current_in = 4-20 mA input (J8). - current_in also returns current, the loop current in amps. + current_in also returns current, the loop current in amps. range low reads small signals (up to ±5.46 V) + at 6x the resolution on analog module v3r4+; overrange says the input clipped. Refused while the input is still moving (e.g. a DAC output left running). """ - r = await _call(lambda: SESSION.require().adc_read(source)) + r = await _call(lambda: SESSION.require().adc_read(source, range=range)) return m.AdcReadResult(source=r.source, voltage=r.voltage, count=r.count, span=r.span, - offset=r.offset, current=r.current) + offset=r.offset, current=r.current, range=r.range or None, + fullscale=r.fullscale, overrange=r.overrange) def _calibration_result(c: Any) -> m.CalibrationResult: @@ -1354,6 +1390,7 @@ def _calibration_result(c: Any) -> m.CalibrationResult: 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_low_gain_ppm=c.adc_low_gain_ppm, zero_count=c.zero_count) @@ -1414,6 +1451,9 @@ 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."))], + range: Annotated[AdcRange, Field(description=( + "low measures the ADC low range's gain instead (analog module v3r4+, firmware 3.9.5+): source must be " + "cal1, and the one gain serves every source in the low range."))] = "normal", ) -> m.CalibrationResult: """Measure and store an ADC source's gain against a DMM-calibrated DAC output (firmware 3.9.4+). @@ -1421,7 +1461,7 @@ async def calibrate_adc_gain( 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))) + return _calibration_result(await _call(lambda: SESSION.require().calibrate_adc_gain(source, range=range))) @mcp.tool(annotations=_ann("Capture an ADC waveform")) @@ -1433,6 +1473,7 @@ async def capture_adc( trigger_la: TriggerLa = None, trigger_edge: Annotated[TriggerEdge, Field(description="What starts the capture on trigger_la.")] = "rising", trigger_timeout: TriggerTimeoutS = 10.0, + range: AdcRangeArg = None, ctx: Context = None, # type: ignore[assignment] ) -> m.AdcCaptureResult: """Capture the ADC and summarise it: calibrated stats, dominant frequency and a min/max envelope. @@ -1442,12 +1483,13 @@ async def capture_adc( Above 32768 samples the capture streams from PSRAM (multi-second captures work). With trigger_la the capture waits for that edge or level, so t = 0 is the trigger moment. The - capture is kept for replay and save_capture_as_recording. + capture is kept for replay and save_capture_as_recording. range low captures small signals + (up to ±5.46 V) at 6x the resolution (see adc_range); clipped_samples > 0 means it clipped. """ def op() -> m.AdcCaptureResult: cap = SESSION.require().capture_adc(samples, sample_rate_hz=sample_rate_hz, source=source, trigger=_trigger(trigger_la, trigger_edge), - trigger_timeout=trigger_timeout) + trigger_timeout=trigger_timeout, range=range) SESSION.last_adc = cap return adc_summary(cap, points) diff --git a/packages/embeddedci-mcp/src/embeddedci_mcp/summaries.py b/packages/embeddedci-mcp/src/embeddedci_mcp/summaries.py index 432fa8e..c764adc 100644 --- a/packages/embeddedci-mcp/src/embeddedci_mcp/summaries.py +++ b/packages/embeddedci-mcp/src/embeddedci_mcp/summaries.py @@ -71,7 +71,7 @@ def adc_summary(cap: Capture, points: int) -> m.AdcCaptureResult: source=cap.source or None, unit=unit, mean=r(cap.mean()), min=r(cap.min()), max=r(cap.max()), peak_to_peak=r(cap.peak_to_peak()), rms=r(cap.rms()), rms_ac=r(cap.rms_ac()), dominant_frequency_hz=dominant, envelope_step=step, envelope_min=mins, envelope_max=maxs, - trigger=trigger_text(cap.trigger), + trigger=trigger_text(cap.trigger), range=cap.range or None, clipped_samples=cap.clipped_samples, ) diff --git a/packages/embeddedci-mcp/tests/conftest.py b/packages/embeddedci-mcp/tests/conftest.py index cd020ca..efc08de 100644 --- a/packages/embeddedci-mcp/tests/conftest.py +++ b/packages/embeddedci-mcp/tests/conftest.py @@ -280,8 +280,28 @@ def _cmd_current_out(self, req): code = max(0, round((req["ua"] - 4015.686) / 0.245098)) return {"ua": round(4015.686 + 0.245098 * code), "code": code, **rng} + adc_range_state = "normal" + + def _cmd_adc_range(self, req): + # analog module v3r4 (firmware 3.9.5+): normal / low, and the self-test. + if req.get("test"): + self.adc_range_state = "normal" + return {"points": [{"set_mv": 1000, "normal_mv": 1001, "low_mv": 999, "ok": True}, + {"set_mv": 3500, "normal_mv": 3502, "low_mv": 3497, "ok": True}], + "pass": True, "range": "normal"} + if "range" in req: + self.adc_range_state = req["range"] + low = self.adc_range_state == "low" + return {"range": self.adc_range_state, "supported": True, "fullscale_mv": 5462 if low else 32856, + "normal_fullscale_mv": 32856, "low_fullscale_mv": 5462, + "adc_cal_a_uv": 10923784 if low else 65708158, "adc_cal_b_nv": -166687 if low else -1002644} + def _cmd_adc_read(self, req): + if "range" in req: + self.adc_range_state = req["range"] reply = {"source": req.get("source", "ext"), "mv": 3301, "count": 63049, "span": 2} + if "range" in req: + reply.update({"range": self.adc_range_state, "fullscale_mv": 5462, "overrange": False}) if reply["source"] == "current_in": reply["offset_mv"] = round(self.current_in_offset_uv / 1000) reply["ua"] = round(reply["mv"] * 1000 / 249) diff --git a/packages/embeddedci-mcp/tests/test_tools.py b/packages/embeddedci-mcp/tests/test_tools.py index 07784e0..78213ce 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", "calibrate_dac", "calibrate_adc_gain", + "analog_path", "dac_output", "current_out", "adc_read", "adc_range", "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", @@ -393,7 +393,7 @@ def test_pulls(connected): # -- analog + captures ----------------------------------------------------------------- def test_analog_tools(connected): - assert call("analog_path", path="cal1") == {"path": "cal1"} + assert call("analog_path", path="cal1") == {"path": "cal1", "range": None} out = call("dac_output", path="5v", volts=2.5) assert out == {"path": "5v", "voltage": 2.5, "code": 128, "code16": None} assert call("dac_output", path="off")["voltage"] is None @@ -401,6 +401,24 @@ def test_analog_tools(connected): assert call("adc_read", source="ext")["offset"] is None +def test_adc_range_tool(connected): + out = call("adc_range") + assert connected.requests[-1] == {"cmd": "adc_range"} + assert out["range"] == "normal" and out["supported"] and out["fullscale"] == pytest.approx(32.856) + out = call("adc_range", range="low") + assert connected.requests[-1] == {"cmd": "adc_range", "range": "low"} + assert out["range"] == "low" and out["low_fullscale"] == pytest.approx(5.462) + test = call("adc_range", test=True) + assert {"cmd": "adc_range", "test": True} in connected.requests + assert test["test"]["passed"] and test["test"]["points"][1]["set_voltage"] == pytest.approx(3.5) + assert test["range"] == "normal" # the self-test leaves the range normal + r = call("adc_read", source="ext", range="low") + assert {"cmd": "adc_read", "source": "ext", "range": "low"} in connected.requests + assert r["range"] == "low" and r["fullscale"] == pytest.approx(5.462) and r["overrange"] is False + with pytest.raises(Exception): + call("adc_range", range="high") + + def test_current_out_tool(connected): out = call("current_out", current=0.012) assert connected.requests[-1] == {"cmd": "current_out", "ua": 12000} diff --git a/packages/embeddedci-mcp/tests/tools_surface.json b/packages/embeddedci-mcp/tests/tools_surface.json index 8d86f46..114fed4 100644 --- a/packages/embeddedci-mcp/tests/tools_surface.json +++ b/packages/embeddedci-mcp/tests/tools_surface.json @@ -1,4 +1,42 @@ { + "adc_range": { + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Read or switch the ADC input range" + }, + "input": { + "properties": { + "range": { + "anyOf": [ + { + "enum": [ + "normal", + "low" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "ADC input range: normal (\u00b132.8 V, ~1 mV/count) or low (\u00b15.46 V, ~167 \u00b5V/count, 1.82 M\u03a9 input: 1x leads only; analog module v3r4+). Omit to keep the range the pod is in.", + "title": "Range" + }, + "test": { + "default": false, + "description": "Run the self-test instead: the 5 V output drives the cal1 loopback at 1 V and 3.5 V and both ranges read it. Nothing may be connected to the 5 V output.", + "title": "Test", + "type": "boolean" + } + }, + "title": "adc_rangeArguments", + "type": "object" + } + }, "adc_read": { "annotations": { "idempotentHint": false, @@ -8,6 +46,23 @@ }, "input": { "properties": { + "range": { + "anyOf": [ + { + "enum": [ + "normal", + "low" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "ADC input range: normal (\u00b132.8 V, ~1 mV/count) or low (\u00b15.46 V, ~167 \u00b5V/count, 1.82 M\u03a9 input: 1x leads only; analog module v3r4+). Omit to keep the range the pod is in.", + "title": "Range" + }, "source": { "default": "ext", "enum": [ @@ -48,6 +103,23 @@ ], "title": "Path", "type": "string" + }, + "range": { + "anyOf": [ + { + "enum": [ + "normal", + "low" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "ADC input range: normal (\u00b132.8 V, ~1 mV/count) or low (\u00b15.46 V, ~167 \u00b5V/count, 1.82 M\u03a9 input: 1x leads only; analog module v3r4+). Omit to keep the range the pod is in.", + "title": "Range" } }, "required": [ @@ -113,6 +185,16 @@ }, "input": { "properties": { + "range": { + "default": "normal", + "description": "low measures the ADC low range's gain instead (analog module v3r4+, firmware 3.9.5+): source must be cal1, and the one gain serves every source in the low range.", + "enum": [ + "normal", + "low" + ], + "title": "Range", + "type": "string" + }, "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": [ @@ -453,6 +535,23 @@ "title": "Points", "type": "integer" }, + "range": { + "anyOf": [ + { + "enum": [ + "normal", + "low" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "ADC input range: normal (\u00b132.8 V, ~1 mV/count) or low (\u00b15.46 V, ~167 \u00b5V/count, 1.82 M\u03a9 input: 1x leads only; analog module v3r4+). Omit to keep the range the pod is in.", + "title": "Range" + }, "sample_rate_hz": { "anyOf": [ { diff --git a/packages/embeddedci/CHANGELOG.md b/packages/embeddedci/CHANGELOG.md index 1475584..32fcf9a 100644 --- a/packages/embeddedci/CHANGELOG.md +++ b/packages/embeddedci/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog — `embeddedci` +## 2.13.4 + +- The ADC input range (pod firmware 3.9.5+, analog module v3r4, `Capabilities.adc_low_range`): + `BenchPod.adc_range(range=None)` reads or switches it (`"normal"` ±32.8 V, `"low"` ±5.46 V at + about 6x the resolution), `adc_range_test()` self-tests it through the cal1 loopback, and + `analog_path`, `adc_read`, `capture_adc` take `range=`. `calibrate_adc_gain("cal1", range="low")` + measures the low range's gain (`Calibration.adc_low_gain_ppm`). Captures scale with the fit of + the range in use: the SDK re-reads it whenever a reply says the pod changed range. + `AdcReading` gains `range`, `fullscale`, `overrange`; `Capture` gains `range`, `overrange`, + `clipped_samples`; `AnalogPathState.range`; `Capabilities.adc_range`, `adc_range_fullscale`. + New types `AdcRange`, `AdcRangeState`, `AdcRangeTest`, `AdcRangeTestPoint`. +- `capture_adc(source="current_in")` reads the pod's `current_in` fit after routing, so it is the + fit of the range in use. + ## 2.13.3 - The DMM-guided calibration (pod firmware 3.9.4+, `Capabilities.cal_dmm`): diff --git a/packages/embeddedci/README.md b/packages/embeddedci/README.md index 14f7f93..2e3e319 100644 --- a/packages/embeddedci/README.md +++ b/packages/embeddedci/README.md @@ -983,6 +983,40 @@ microvolt resolution (about 76 µV a step on `"5v"`) and `code16` is the 16-bit stays the nearest 8-bit level. On older firmware `code16` is `None` and the steps are 8-bit. `adc_read` routes the source (`"ext"`, `"cal1"`, `"cal2"`, `"current_in"`) and returns one averaged, calibrated reading; the pod refuses it while the input is still moving (e.g. a DAC left running). +### The ADC input range (analog module v3r4) + +Analog module v3r4 (firmware 3.9.5+, `Capabilities.adc_low_range`) adds a ÷2 low range to the +ADC input next to the normal ÷12 one: + +| Range | Full scale | Resolution | Input impedance | +|---|---|---|---| +| `"normal"` (default) | ±32.8 V | ~1 mV per count | ~1 MΩ | +| `"low"` | ±5.46 V | ~167 µV per count | 1.82 MΩ: use 1x leads, not 10x probes | + +```python +st = bp.adc_range("low") # AdcRangeState(range, supported, fullscale, ...) +r = bp.adc_read("ext") # read in the low range: r.range == "low" +r = bp.adc_read("current_in", range="low") # or switch it with the reading +cap = bp.capture_adc(4096, source="ext", range="low") +print(cap.range, cap.overrange) # a clipped sample: capture again in the normal range +bp.adc_range("normal") +print(bp.adc_range_test().passed) # self-test through the cal1 loopback (5 V output) +``` + +The range applies to `ext`, `cal1` and `current_in` (4-20 mA fits inside it); `cal2` has none. +It stays until it is changed, `analog_path("off")` or `analog_path("cal2")`, or the pod restarts. +`analog_path`, `adc_read` and `capture_adc` take `range=` too. Captures are raw counts: the SDK +keeps the fit it scales them with on the range in use (`Capabilities.adc_range`), and re-reads it +whenever a reply says the pod moved to the other range. `AdcReading.overrange` and +`Capture.overrange` (with `clipped_samples`) flag samples on the ADC's rails. A board without the +low range raises `BenchPodError` for `"low"`. + +`adc_range_test()` drives the 5 V output into `cal1` at 1 V and 3.5 V, reads each point in both +ranges, and fails (`passed` is `False`) when they disagree by more than 5 % + 30 mV. The low range's +gain is calibrated through `cal1` with `calibrate_adc_gain("cal1", range="low")` (after the offset and +the 5 V DMM line, see below); it serves every source in the low range and shows as +`Calibration.adc_low_gain_ppm`. + ### Calibrating the ADC offset `"current_in"` reads the 4-20 mA terminal (J8) across a 249 Ω resistor, so 4 mA is 0.996 V and 20 mA is @@ -1139,9 +1173,9 @@ both = bp.capture_correlated(adc_samples=4096, adc_sample_rate_hz=400_000, print(both.adc.duration, both.la.duration) # one hardware trigger: timebases align ``` -* `capture_adc(samples=4096, *, sample_rate_hz=None, source=None, trigger=None, trigger_timeout=10.0)` — omitted rate = the device +* `capture_adc(samples=4096, *, sample_rate_hz=None, source=None, trigger=None, trigger_timeout=10.0, range=None)` — omitted rate = the device maximum; the **achieved** rate is on the result. `source` routes the ADC first (omitted, the - current routing is left alone). `volts` use the front-SMA calibration; for the other sources + current routing is left alone); `range` sets the [ADC input range](#the-adc-input-range-analog-module-v3r4). `volts` use the front-SMA calibration; for the other sources compare `counts` or use `adc_read`. * `capture_la(samples=4096, *, sample_rate_hz=None, stop_dac_after=None, trigger=None, trigger_timeout=10.0)` — 12-bit words; bit *n* is channel LA*n+1*. The **achieved** rate is on the result and can be below the request: the pod @@ -1630,7 +1664,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(part=None)`, `calibrate_dac(path, points)`, `calibrate_adc_gain(source)`, `DMM_SET_POINTS` | +| Analog | `analog_path(path, *, range=None)`, `adc_read(source="ext", *, range=None)`, `adc_range(range=None)`, `adc_range_test()`, `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, *, range="normal")`, `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()` | @@ -1671,13 +1705,16 @@ few built from other shapes); `to_dict()` goes the other way where a type is sen | `I2CByte` | `I2CMessage.data` | `value`, `ack` | | `UartFrame` | `decode("uart")` | `index`, `start_us`, `end_us`, `value`, `hex`, `text`, `ok`, `error` | | `SpiFrame` | `decode("spi")` | `index`, `start_us`, `end_us`, `mosi`, `miso`, `mosi_hex`, `miso_hex` | -| `AnalogPathState` | `analog_path` | `path`, `dac_mux_register`, `cal_relay_register` | -| `AdcReading` | `adc_read` | `source`, `voltage`, `count`, `span`, `offset`, `current` | +| `AnalogPathState` | `analog_path` | `path`, `dac_mux_register`, `cal_relay_register`, `range` | +| `AdcReading` | `adc_read` | `source`, `voltage`, `count`, `span`, `offset`, `current`, `range`, `fullscale`, `overrange` | +| `AdcRangeState` | `adc_range` | `range`, `supported`, `fullscale`, `normal_fullscale`, `low_fullscale`, `a`, `b` (volts, the `ext` fit of the range) | +| `AdcRangeTest` | `adc_range_test` | `passed`, `points` (each an `AdcRangeTestPoint`: `set_voltage`, `normal`, `low`, `ok`) | +| `AdcRange` (literal) | `adc_range`, `range=` | `"normal"`, `"low"` | | `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`, `zero_count` | +| `Calibration` | `calibrate`, `calibration`, `clear_calibration`, `calibrate_dac`, `calibrate_adc_gain` | `source`, `calibrated`, `offset`, `a`, `b`, `count`, `span`, `samples`, `dac`, `adc_gain_ppm`, `adc_low_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()` | +| `Capture` | `capture_adc` | `counts`, `volts`, `currents`, `sample_rate_hz`, `source`, `trigger`, `range`, `overrange`, `clipped_samples`, `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 | | `Trigger` | `capture_adc`, `capture_la`, `capture_correlated` | `la`, `edge` | @@ -1718,7 +1755,7 @@ reported keeps its default (`False`, `0` or `""`), so a feature check is just | Group | Fields | |---|---| | 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)` | +| ADC | `adc_bits`, `adc_fullscale_mv`, `adc_channels`, `adc_offset_counts`, `adc_affine` (the front-end fit, of the range in use), `adc_low_range` (analog module v3r4+: the low range), `adc_range` (`"normal"`, `"low"`, or `""` before firmware 3.9.5), `adc_range_fullscale` (volts, ±), `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.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) | diff --git a/packages/embeddedci/pyproject.toml b/packages/embeddedci/pyproject.toml index 7f0e021..48ebca9 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.3" +version = "2.13.4" description = "BenchPod SDK and pytest plugin for EmbeddedCI hardware-in-the-loop testing" readme = "README.md" requires-python = ">=3.10" diff --git a/packages/embeddedci/src/embeddedci/benchpod/__init__.py b/packages/embeddedci/src/embeddedci/benchpod/__init__.py index b0b0267..e1fe311 100644 --- a/packages/embeddedci/src/embeddedci/benchpod/__init__.py +++ b/packages/embeddedci/src/embeddedci/benchpod/__init__.py @@ -48,6 +48,7 @@ def test_boots(benchpod_target, firmware): # fixtures from the pytest plugin PIN12, PIN13, PIN14, + AdcRange, AdcSource, CalibrateSource, CalibrationPart, @@ -117,6 +118,9 @@ def test_boots(benchpod_target, firmware): # fixtures from the pytest plugin from .transport.tls import delete_pin as forget_lan_tls, trust_lan_tls from .server_api import ServerApi, ServerApiError, ServerPermissionDeniedError from .state import ( + AdcRangeState, + AdcRangeTest, + AdcRangeTestPoint, AdcReading, AnalogPathState, Calibration, @@ -163,6 +167,9 @@ def test_boots(benchpod_target, firmware): # fixtures from the pytest plugin "PullState", "AnalogPathState", "DacOutput", + "AdcRangeState", + "AdcRangeTest", + "AdcRangeTestPoint", "AdcReading", "Calibration", "DacLine", @@ -257,6 +264,7 @@ def test_boots(benchpod_target, firmware): # fixtures from the pytest plugin "DacPath", "DacOutputPath", "AnalogPath", + "AdcRange", "AdcSource", "CalibrateSource", "CalibrationPart", diff --git a/packages/embeddedci/src/embeddedci/benchpod/capabilities.py b/packages/embeddedci/src/embeddedci/benchpod/capabilities.py index b3a3b6f..12e8a08 100644 --- a/packages/embeddedci/src/embeddedci/benchpod/capabilities.py +++ b/packages/embeddedci/src/embeddedci/benchpod/capabilities.py @@ -115,6 +115,13 @@ class Capabilities: adc_offset_counts: float = 0.0 #: affine front-end fit; None => use the naive linear model. adc_affine: Optional[AffineCal] = None + #: The board has the ADC low range (analog module v3r4+): :meth:`BenchPod.adc_range`. + adc_low_range: bool = False + #: The ADC input range :attr:`adc_affine` is for: ``"normal"`` or ``"low"``; ``""`` when the + #: firmware does not say (before 3.9.5: always normal). + adc_range: str = "" + #: That range's input full scale in volts (±), or 0.0 when not reported. + adc_range_fullscale: float = 0.0 # DAC / replay dac: bool = False @@ -327,6 +334,7 @@ def from_status(cls, status: Mapping[str, Any]) -> "Capabilities": ("current_out", "current_out"), ("can", "can"), ("pod_current", "pod_current"), + ("adc_low_range", "adc_low_range"), ("dac_limits", "dac_limits"), ("sensor_types", "sensor_types"), ("gps", "gps"), @@ -341,8 +349,19 @@ def from_status(cls, status: Mapping[str, Any]) -> "Capabilities": c.last_crash = _crash_text(status.get("last_crash", status.get("crash"))) c.reset_cause = str(status.get("reset", status.get("reset_cause", "")) or "") c._apply_affine(status) + c._apply_range(status) return c + def _apply_range(self, src: Mapping[str, Any], prefix: str = "") -> None: + """The ADC input range the fit is for (firmware 3.9.5+).""" + rng = src.get(prefix + "adc_range") + if isinstance(rng, str) and rng: + self.adc_range = rng + if (v := _as_float(src, prefix + "adc_range_fs_mv")) is not None and v > 0: + self.adc_range_fullscale = v / 1000.0 + if _as_bool(src, prefix + "adc_low_range") is True: + self.adc_low_range = True + @classmethod def from_parameters(cls, params: Mapping[str, Any]) -> "Capabilities": """Parse a server ``cap.*`` device-parameter map (complete capability set).""" @@ -386,6 +405,7 @@ def from_parameters(cls, params: Mapping[str, Any]) -> "Capabilities": ("spi_master", "cap.spi_master"), ("spi_stream", "cap.spi_stream"), ("calibrate", "cap.calibrate"), ("cal_dmm", "cap.cal_dmm"), ("current_out", "cap.current_out"), ("can", "cap.can"), ("pod_current", "cap.pod_current"), + ("adc_low_range", "cap.adc_low_range"), ("sensor_types", "cap.sensor_types"), ("gps", "cap.gps"), ("dac_limits", "cap.dac_limits"), ("ws_auth_v2", "cap.ws_auth_v2"), @@ -413,6 +433,7 @@ def from_parameters(cls, params: Mapping[str, Any]) -> "Capabilities": c.adc_affine = AffineCal(a=cal_a, b=cal_b, unwrap=bool(_as_bool(params, "cap.adc_cal_unwrap"))) else: c._apply_affine(params, board=c.board) + c._apply_range(params, "cap.") return c def _apply_affine(self, src: Mapping[str, Any], board: Optional[str] = None) -> None: diff --git a/packages/embeddedci/src/embeddedci/benchpod/capture.py b/packages/embeddedci/src/embeddedci/benchpod/capture.py index 28d4c5d..01f2fa1 100644 --- a/packages/embeddedci/src/embeddedci/benchpod/capture.py +++ b/packages/embeddedci/src/embeddedci/benchpod/capture.py @@ -147,7 +147,7 @@ def capture_adc(transport: Any, caps: Capabilities, *, samples: int = 4096, counts.extend(_dense(chunk)) volts = [caps.counts_to_volts(c) for c in counts] return Capture(counts=counts, volts=volts, sample_rate_hz=_rate_hz(rate, sample_rate_hz), - source=source) + source=source, range=caps.adc_range) def capture_la(transport: Any, *, samples: int = 4096, sample_rate_hz: Optional[float] = None, @@ -279,7 +279,8 @@ def capture_correlated(transport: Any, caps: Capabilities, *, adc_samples: int = la_words = la_dense[:la_samples] if la_samples else la_dense adc = Capture(counts=adc_counts, volts=[caps.counts_to_volts(c) for c in adc_counts], - sample_rate_hz=_rate_hz(adc_rate, adc_sample_rate_hz), trigger=trigger) + sample_rate_hz=_rate_hz(adc_rate, adc_sample_rate_hz), trigger=trigger, + range=caps.adc_range) la = LaCapture(words=la_words, sample_rate_hz=_rate_hz(la_rate, la_sample_rate_hz), trigger=trigger) return CorrelatedCapture(adc=adc, la=la) diff --git a/packages/embeddedci/src/embeddedci/benchpod/client.py b/packages/embeddedci/src/embeddedci/benchpod/client.py index 67bf93b..4085807 100644 --- a/packages/embeddedci/src/embeddedci/benchpod/client.py +++ b/packages/embeddedci/src/embeddedci/benchpod/client.py @@ -45,10 +45,11 @@ from . import sensor as _sensor from . import spi as _spi from . import uart as _uart -from .capabilities import ADC_WRAP_SPAN, ADC_WRAP_THRESHOLD, Capabilities +from .capabilities import ADC_WRAP_SPAN, ADC_WRAP_THRESHOLD, AffineCal, Capabilities from .constants import ( ADC_SOURCE_PATHS, ADC_GAIN_SOURCES, + ADC_RANGES, ADC_SOURCES, ANALOG_PATHS, CALIBRATE_SOURCES, @@ -69,6 +70,7 @@ REPLAY_MAPPINGS, WAVESHAPES, AdcGainSource, + AdcRange, AdcSource, AnalogPath, CalibrateSource, @@ -101,6 +103,8 @@ from .results import Capture, CorrelatedCapture, LaCapture, LanTls, Trigger from .wiring import Signal, Wiring from .state import ( + AdcRangeState, + AdcRangeTest, AdcReading, AnalogPathState, Calibration, @@ -1147,7 +1151,7 @@ def gps_status(self) -> Dict[str, Any]: # never hand-flips muxes/relays and can never disagree with the pod about how a path is wired. # Raw mux/relay access lives on :attr:`lowlevel`. - def analog_path(self, path: AnalogPath) -> AnalogPathState: + def analog_path(self, path: AnalogPath, *, range: Optional[AdcRange] = None) -> AnalogPathState: """Apply a named analog path, flipping every mux and relay it needs in one step. ``dac_3v3``/``dac_5v``/``dac_12v`` route the DAC to an output (``dac_12v`` is bipolar @@ -1155,9 +1159,18 @@ def analog_path(self, path: AnalogPath) -> AnalogPathState: 12 V DAC output back into the ADC; ``current_in`` reads the 4-20 mA measurement terminal; ``off`` parks it all. ``current_out`` is the 4-20 mA output (J9): it only switches the DAC voltage outputs off, for a waveform played as a current (:meth:`current_out` does it for a held level). + + ``range`` also sets the ADC input range afterwards (:meth:`adc_range`, analog module + v3r4+); without it the range stays as it is, except that ``off`` and ``cal2`` put it back + to ``normal``. """ check_choice(path, ANALOG_PATHS, "path") - state = AnalogPathState.from_reply(self.command({"cmd": "analog_path", "path": path})) + req: Dict[str, Any] = {"cmd": "analog_path", "path": path} + if range is not None: + check_choice(range, ADC_RANGES, "range") + req["range"] = range + state = AnalogPathState.from_reply(self.command(req)) + self._follow_adc_range(state.range) if path in _ANALOG_PATH_DAC: self._routed_dac_path = _ANALOG_PATH_DAC[path] elif path == "off": @@ -1220,7 +1233,7 @@ def current_out_range(self) -> CurrentOutput: self._require_capability("current_out", "4-20 mA output commands") return CurrentOutput.from_reply(self.command({"cmd": "current_out"})) - def adc_read(self, source: AdcSource = "ext") -> AdcReading: + def adc_read(self, source: AdcSource = "ext", *, range: Optional[AdcRange] = None) -> AdcReading: """Route an ADC ``source`` and return one CALIBRATED reading (a short averaged burst). ``ext`` is the front SMA (the ÷12 divider is applied, so ``voltage`` is the true SMA @@ -1230,9 +1243,72 @@ def adc_read(self, source: AdcSource = "ext") -> AdcReading: On ``current_in`` the reading also carries ``current``, the loop current in amps, and already has this pod's calibration offset taken out (:meth:`calibrate`); ``offset`` says how much that was. + + ``range`` switches the ADC input range first (:meth:`adc_range`): ``"low"`` reads up to + ±5.46 V at about 6x the resolution on analog module v3r4+ (not on ``cal2``). The reading + says which range it was taken in, its full scale, and ``overrange`` when it clipped. """ check_choice(source, ADC_SOURCES, "source") - return AdcReading.from_reply(self.command({"cmd": "adc_read", "source": source})) + req: Dict[str, Any] = {"cmd": "adc_read", "source": source} + if range is not None: + check_choice(range, ADC_RANGES, "range") + req["range"] = range + reading = AdcReading.from_reply(self.command(req)) + self._follow_adc_range(reading.range) + return reading + + # -- the ADC input range (analog module v3r4+) ------------------------------- + # Relay K5 swaps the divider in front of the ADC: normal ÷12 (±32.8 V) or low ÷2 (±5.46 V). + # Captures are raw counts, so the SDK keeps the fit it scales them with on the range in use. + + def adc_range(self, range: Optional[AdcRange] = None) -> AdcRangeState: + """Read the ADC input range, or switch it to ``range`` (``"normal"`` / ``"low"``). + + The low range (analog module v3r4 and later, firmware 3.9.5+) is ±5.46 V at about + 167 µV per count, against ±32.8 V and ~1 mV in the normal range: about 6x the resolution + on signals up to ±5 V. **Use 1x leads**: its input is 1.82 MΩ, not the 1 MΩ a 10x probe + expects. It applies to ``ext``, ``cal1`` and ``current_in``; ``cal2`` has none. It stays + until changed, ``analog_path("off")`` or ``"cal2"``, or a restart of the pod. + + Switching waits for the relay to settle, and from then on :meth:`capture_adc` scales with + the new range's fit. Raises :class:`BenchPodError` when the board has no low range. + """ + req: Dict[str, Any] = {"cmd": "adc_range"} + if range is not None: + check_choice(range, ADC_RANGES, "range") + req["range"] = range + state = AdcRangeState.from_reply(self.command(req)) + self._set_adc_range_fit(state) + return state + + def adc_range_test(self) -> AdcRangeTest: + """Self-test the ADC low range: the 5 V output drives the cal1 loopback at 1 V and 3.5 V, + and each point is read in both ranges. ``passed`` is ``False`` when the ranges disagree by + more than 5 % + 30 mV (a relay that does not switch reads 6x off). Takes about 0.5 s and + leaves the outputs off and the range normal. Disconnect anything on the 5 V output.""" + result = AdcRangeTest.from_reply(self.command({"cmd": "adc_range", "test": True})) + self._follow_adc_range("normal") + return result + + def _set_adc_range_fit(self, state: AdcRangeState) -> None: + """Put the range's fit into the capabilities, so captures scale with it.""" + caps = self.capabilities + caps.adc_range = state.range + caps.adc_range_fullscale = state.fullscale + caps.adc_low_range = state.supported + if state.b: + unwrap = caps.adc_affine.unwrap if caps.adc_affine is not None else True + caps.adc_affine = AffineCal(a=state.a or 0.0, b=state.b, unwrap=unwrap) + + def _follow_adc_range(self, rng: str) -> None: + """A reply said the pod is in range ``rng``: re-read the fit when the cached one is for + another range (``off`` and ``cal2`` put the range back to normal).""" + if not rng: + return # firmware before 3.9.5: one range + caps = self.capabilities + if caps.adc_range == rng or (not caps.adc_range and rng == "normal"): + return + self.adc_range() # -- the pod's own calibration --------------------------------------------- # The ADC fits are built into the firmware and the same on every pod. `calibrate` lets a pod @@ -1307,7 +1383,7 @@ def calibrate_dac(self, path: DacCalPath, "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: + def calibrate_adc_gain(self, source: AdcGainSource, *, range: AdcRange = "normal") -> Calibration: """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 @@ -1317,10 +1393,23 @@ def calibrate_adc_gain(self, source: AdcGainSource) -> Calibration: 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. + + ``range="low"`` (analog module v3r4+, firmware 3.9.5+) measures the ADC low range's gain + instead, through ``cal1`` only (the 5 V output at 1 V and 4 V): one gain serves every + source in the low range (``Calibration.adc_low_gain_ppm``). It leaves the range normal. """ check_choice(source, ADC_GAIN_SOURCES, "source") + check_choice(range, ADC_RANGES, "range") self._require_capability("cal_dmm", "DMM calibration") - return Calibration.from_reply(self.command({"cmd": "calibrate", "adc": source})) + req: Dict[str, Any] = {"cmd": "calibrate", "adc": source} + if range != "normal": + if source != "cal1": + raise ValueError("the low range is calibrated through cal1, and serves every source") + req["range"] = range + cal = Calibration.from_reply(self.command(req)) + if range != "normal": + self._follow_adc_range("normal") + return cal # -- CAN (FDCAN1 / TCAN1044 transceiver) ----------------------------------- # Single-node board: use a loopback ``mode`` to self-test on one pod. :meth:`open_can` and @@ -1594,7 +1683,7 @@ def open_motor_emulator( def capture_adc(self, samples: int = 4096, *, sample_rate_hz: Optional[float] = None, source: Optional[AdcSource] = None, trigger: Optional[Trigger] = None, - trigger_timeout: float = 10.0) -> Capture: + trigger_timeout: float = 10.0, range: Optional[AdcRange] = None) -> Capture: """Capture ADC samples and return a :class:`Capture` with CALIBRATED volts. Works over any transport. ``sample_rate_hz`` omitted = the device's maximum rate; the @@ -1612,8 +1701,13 @@ def capture_adc(self, samples: int = 4096, *, sample_rate_hz: Optional[float] = ``trigger`` (a :class:`Trigger`, gateware >= v35) waits for an edge or level on an LA channel before sampling, so t = 0 is that moment; a :class:`~embeddedci.benchpod.errors.TriggerTimeout` is raised after ``trigger_timeout`` seconds without it. + + ``range`` sets the ADC input range first (:meth:`adc_range`): ``"low"`` captures up to + ±5.46 V at about 6x the resolution (analog module v3r4+). Without it the capture is taken + in the range the pod is in. ``Capture.range`` says which, and ``Capture.overrange`` whether + a sample clipped. """ - label, fit = self._route_adc_source(source) + label, fit = self._route_adc_source(source, range) trig = self._resolve_trigger(trigger) with _classified_errors(): cap = _capture.capture_adc(self._transport, self.capabilities, samples=samples, @@ -1621,16 +1715,20 @@ def capture_adc(self, samples: int = 4096, *, sample_rate_hz: Optional[float] = trigger_timeout=trigger_timeout) return self._scale_current_in(cap, fit) - def _route_adc_source(self, source: Optional[str]) -> Tuple[str, Optional[Calibration]]: - """Route an ADC ``source`` for a capture (``None`` leaves the routing alone). Returns the - label for the result and, for ``current_in`` on a pod that reports one, the pod's own fit - for that input, read before the capture starts.""" + def _route_adc_source(self, source: Optional[str], + rng: Optional[str] = None) -> Tuple[str, Optional[Calibration]]: + """Route an ADC ``source`` for a capture (``None`` leaves the routing alone) in range + ``rng`` (``None`` leaves it as it is). Returns the label for the result and, for + ``current_in`` on a pod that reports one, the pod's own fit for that input in the range + in use, read before the capture starts.""" if source is None: + if rng is not None: + self.adc_range(rng) # type: ignore[arg-type] return "", None check_choice(source, ADC_SOURCES, "source") - fit = self.calibration() if source == "current_in" and self.capabilities.calibrate else None - self.analog_path(ADC_SOURCE_PATHS[source]) # type: ignore[arg-type] + self.analog_path(ADC_SOURCE_PATHS[source], range=rng) # type: ignore[arg-type] time.sleep(0.02) # relays (~4 ms) + front-end RC settle, as the firmware's adc_read does + fit = self.calibration() if source == "current_in" and self.capabilities.calibrate else None return source, fit @staticmethod diff --git a/packages/embeddedci/src/embeddedci/benchpod/constants.py b/packages/embeddedci/src/embeddedci/benchpod/constants.py index 99df70e..a0f765c 100644 --- a/packages/embeddedci/src/embeddedci/benchpod/constants.py +++ b/packages/embeddedci/src/embeddedci/benchpod/constants.py @@ -117,6 +117,9 @@ class Pin(IntEnum): #: ADC sources whose gain the pod measures through a loopback (:meth:`BenchPod.calibrate_adc_gain`); #: ``current_in`` follows ``cal1``. AdcGainSource = Literal["cal1", "cal2", "ext"] +#: The ADC input range (:meth:`BenchPod.adc_range`): ``normal`` is ±32.8 V, ``low`` ±5.46 V +#: (analog module v3r4 and later). +AdcRange = Literal["normal", "low"] #: One part of the pod's calibration (:meth:`BenchPod.clear_calibration`). CalibrationPart = Literal["offset", "dac", "adc"] #: Parametric DAC waveforms the firmware generator produces. @@ -149,6 +152,7 @@ class Pin(IntEnum): DAC_CAL_PATHS: Tuple[str, ...] = get_args(DacCalPath) ADC_GAIN_SOURCES: Tuple[str, ...] = get_args(AdcGainSource) CALIBRATION_PARTS: Tuple[str, ...] = get_args(CalibrationPart) +ADC_RANGES: Tuple[str, ...] = get_args(AdcRange) 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/results.py b/packages/embeddedci/src/embeddedci/benchpod/results.py index 0211fbc..7a66e6e 100644 --- a/packages/embeddedci/src/embeddedci/benchpod/results.py +++ b/packages/embeddedci/src/embeddedci/benchpod/results.py @@ -89,10 +89,27 @@ class Capture: currents: List[float] = field(default_factory=list) #: The trigger that started the capture (t = 0 is its moment), or ``None``. trigger: Optional[Trigger] = None + #: The ADC input range the capture was scaled for (``"normal"`` / ``"low"``, analog module + #: v3r4+); ``""`` when the pod does not say (firmware before 3.9.5: normal). + range: str = "" def __len__(self) -> int: return len(self.volts) + #: The 16-bit MCP33131's rails (two's complement +FS / -FS): a sample there is clipped. + ADC_RAILS = (0x7FFF, 0x8000) + + @property + def clipped_samples(self) -> int: + """How many samples sit on an ADC rail: the input went past the range's full scale.""" + return sum(1 for c in self.counts if c in self.ADC_RAILS) + + @property + def overrange(self) -> bool: + """``True`` when any sample is clipped (:attr:`clipped_samples`); in the low range, + capture again in the normal one.""" + return self.clipped_samples > 0 + # -- reductions (volts) ------------------------------------------------- def mean(self) -> float: diff --git a/packages/embeddedci/src/embeddedci/benchpod/state.py b/packages/embeddedci/src/embeddedci/benchpod/state.py index 7793007..c81a45a 100644 --- a/packages/embeddedci/src/embeddedci/benchpod/state.py +++ b/packages/embeddedci/src/embeddedci/benchpod/state.py @@ -215,13 +215,79 @@ class AnalogPathState: #: Raw DAC-mux (U55) and calibration-relay (U58) register values, for diagnostics. dac_mux_register: int = 0 cal_relay_register: int = 0 + #: The ADC input range in use afterwards (``"normal"`` / ``"low"``); ``""`` on firmware + #: before 3.9.5. + range: str = "" raw: Dict[str, Any] = _raw() @classmethod def from_reply(cls, reply: Any) -> "AnalogPathState": d = _d(reply) return cls(path=str(d.get("path", "")), dac_mux_register=_opt_int(d, "u55") or 0, - cal_relay_register=_opt_int(d, "u58") or 0, raw=d) + cal_relay_register=_opt_int(d, "u58") or 0, range=str(d.get("range", "") or ""), raw=d) + + +@dataclass(frozen=True) +class AdcRangeState: + """The ADC input range (``BenchPod.adc_range``). + + ``normal`` is the ÷12 divider (±32.8 V, ~1 mV per count, ~1 MΩ); ``low`` the ÷2 divider of + analog module v3r4 and later (±5.46 V, ~167 µV per count, 1.82 MΩ: 1x leads, not 10x probes). + Full scales are in volts (±), from this pod's fits. + """ + + range: str + #: The board has the low range. + supported: bool + fullscale: float + normal_fullscale: float + #: ``None`` on a board without the low range. + low_fullscale: Optional[float] = None + #: The ``ext`` fit captures are scaled with in this range: ``volts = a + b * count``. + a: Optional[float] = None + b: Optional[float] = None + raw: Dict[str, Any] = _raw() + + @classmethod + def from_reply(cls, reply: Any) -> "AdcRangeState": + d = _d(reply) + low = _opt_int(d, "low_fullscale_mv") + a_uv, b_nv = _opt_int(d, "adc_cal_a_uv"), _opt_int(d, "adc_cal_b_nv") + return cls(range=str(d.get("range", "normal") or "normal"), supported=d.get("supported") is True, + fullscale=(_opt_int(d, "fullscale_mv") or 0) / 1000.0, + normal_fullscale=(_opt_int(d, "normal_fullscale_mv") or 0) / 1000.0, + low_fullscale=None if low is None else low / 1000.0, + a=None if a_uv is None or not b_nv else a_uv / 1e6, + b=None if not b_nv else b_nv / 1e9, raw=d) + + +@dataclass(frozen=True) +class AdcRangeTestPoint: + """One point of the range self-test: the set point and what each range read, in volts.""" + + set_voltage: float + normal: float + low: float + ok: bool + + +@dataclass(frozen=True) +class AdcRangeTest: + """The ADC range self-test (``BenchPod.adc_range_test``): the cal1 loopback read in both ranges.""" + + passed: bool + points: List[AdcRangeTestPoint] = field(default_factory=list) + raw: Dict[str, Any] = _raw() + + @classmethod + def from_reply(cls, reply: Any) -> "AdcRangeTest": + d = _d(reply) + pts = d.get("points") if isinstance(d.get("points"), list) else [] + return cls(passed=d.get("pass") is True, points=[ + AdcRangeTestPoint(set_voltage=(_opt_int(p, "set_mv") or 0) / 1000.0, + normal=(_opt_int(p, "normal_mv") or 0) / 1000.0, + low=(_opt_int(p, "low_mv") or 0) / 1000.0, ok=p.get("ok") is True) + for p in pts if isinstance(p, dict)], raw=d) @dataclass(frozen=True) @@ -301,6 +367,13 @@ class AdcReading: #: Loop current in amps on ``current_in``, the 4-20 mA input: ``voltage`` across the pod's #: 249 Ω sense resistor. ``None`` on every other source and on older firmware. current: Optional[float] = None + #: The ADC input range the reading was taken in (``"normal"`` / ``"low"``) and its full scale + #: in volts (±). ``""`` / ``None`` on firmware before 3.9.5. + range: str = "" + fullscale: Optional[float] = None + #: A sample sat on an ADC rail: the input is past ``fullscale`` and ``voltage`` is only a + #: bound. Use the normal range. ``False`` on firmware before 3.9.5. + overrange: bool = False raw: Dict[str, Any] = _raw() @classmethod @@ -308,10 +381,13 @@ def from_reply(cls, reply: Any) -> "AdcReading": d = _d(reply) offset_mv = _opt_int(d, "offset_mv") ua = _opt_int(d, "ua") + fs = _opt_int(d, "fullscale_mv") return cls(source=str(d.get("source", "")), voltage=(_opt_int(d, "mv") or 0) / 1000.0, count=_opt_int(d, "count") or 0, span=_opt_int(d, "span") or 0, offset=None if offset_mv is None else offset_mv / 1000.0, - current=None if ua is None else ua / 1e6, raw=d) + current=None if ua is None else ua / 1e6, range=str(d.get("range", "") or ""), + fullscale=None if fs is None else fs / 1000.0, overrange=d.get("overrange") is True, + raw=d) @dataclass(frozen=True) @@ -368,6 +444,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 ADC low range's gain correction in ppm (:meth:`BenchPod.calibrate_adc_gain` with + #: ``range="low"``); ``None`` = the divider's nominal ratio, or a board without the low range. + adc_low_gain_ppm: Optional[int] = None #: 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 @@ -390,7 +469,7 @@ def from_reply(cls, reply: Any) -> "Calibration": 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, + dmm=d.get("dmm") is True, adc_low_gain_ppm=_opt_int(d, "adc_low_gain_ppm"), 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")}, diff --git a/packages/embeddedci/tests/api_surface.json b/packages/embeddedci/tests/api_surface.json index 02e6de2..c049efe 100644 --- a/packages/embeddedci/tests/api_surface.json +++ b/packages/embeddedci/tests/api_surface.json @@ -1,5 +1,35 @@ { "AdcGainSource": "Literal['cal1', 'cal2', 'ext']", + "AdcRange": "Literal['normal', 'low']", + "AdcRangeState": { + "__fields__": [ + "range", + "supported", + "fullscale", + "normal_fullscale", + "low_fullscale", + "a", + "b", + "raw" + ], + "from_reply": "(cls, reply: 'Any') -> \"'AdcRangeState'\"" + }, + "AdcRangeTest": { + "__fields__": [ + "passed", + "points", + "raw" + ], + "from_reply": "(cls, reply: 'Any') -> \"'AdcRangeTest'\"" + }, + "AdcRangeTestPoint": { + "__fields__": [ + "set_voltage", + "normal", + "low", + "ok" + ] + }, "AdcReading": { "__fields__": [ "source", @@ -8,6 +38,9 @@ "span", "offset", "current", + "range", + "fullscale", + "overrange", "raw" ], "from_reply": "(cls, reply: 'Any') -> \"'AdcReading'\"" @@ -19,6 +52,7 @@ "path", "dac_mux_register", "cal_relay_register", + "range", "raw" ], "from_reply": "(cls, reply: 'Any') -> \"'AnalogPathState'\"" @@ -40,10 +74,12 @@ }, "BenchPod": { "__init__": "(self, connection: 'Optional[str]' = None, *, la_voltage: 'Optional[float]' = None, timeout: 'float' = 30.0, transport: 'Optional[Transport]' = None, api_base: 'Optional[str]' = None, api_key: 'Optional[str]' = None, cloud_token: 'Optional[str]' = None, cloud_audience: 'Optional[str]' = None, cloud_user_token: 'Optional[Callable[[], str]]' = None, lease: 'bool' = True, lease_wait: 'float' = 600.0, lease_ttl: 'int' = 120, wiring: \"Union[Wiring, Mapping[str, Any], str, 'os.PathLike[str]', None]\" = None, wiring_source: 'Optional[str]' = None) -> 'None'", - "adc_read": "(self, source: 'AdcSource' = 'ext') -> 'AdcReading'", - "analog_path": "(self, path: 'AnalogPath') -> 'AnalogPathState'", + "adc_range": "(self, range: 'Optional[AdcRange]' = None) -> 'AdcRangeState'", + "adc_range_test": "(self) -> 'AdcRangeTest'", + "adc_read": "(self, source: 'AdcSource' = 'ext', *, range: 'Optional[AdcRange]' = None) -> 'AdcReading'", + "analog_path": "(self, path: 'AnalogPath', *, range: 'Optional[AdcRange]' = None) -> 'AnalogPathState'", "calibrate": "(self, source: 'CalibrateSource' = 'current_in') -> 'Calibration'", - "calibrate_adc_gain": "(self, source: 'AdcGainSource') -> 'Calibration'", + "calibrate_adc_gain": "(self, source: 'AdcGainSource', *, range: 'AdcRange' = 'normal') -> 'Calibration'", "calibrate_dac": "(self, path: 'DacCalPath', points: 'Sequence[Tuple[int, float]]') -> 'Calibration'", "calibration": "(self) -> 'Calibration'", "can_analog": "property", @@ -63,7 +99,7 @@ "can_trigger": "property", "can_write": "(self, can_id: 'int', data: 'Union[bytes, Sequence[int], None]' = None, *, ext: 'bool' = False, rtr: 'bool' = False) -> 'Dict[str, Any]'", "capabilities": "property", - "capture_adc": "(self, samples: 'int' = 4096, *, sample_rate_hz: 'Optional[float]' = None, source: 'Optional[AdcSource]' = None, trigger: 'Optional[Trigger]' = None, trigger_timeout: 'float' = 10.0) -> 'Capture'", + "capture_adc": "(self, samples: 'int' = 4096, *, sample_rate_hz: 'Optional[float]' = None, source: 'Optional[AdcSource]' = None, trigger: 'Optional[Trigger]' = None, trigger_timeout: 'float' = 10.0, range: 'Optional[AdcRange]' = None) -> 'Capture'", "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'", @@ -174,6 +210,7 @@ "dac", "adc_gain_ppm", "dmm", + "adc_low_gain_ppm", "zero_count", "measured", "raw" @@ -228,6 +265,9 @@ "adc_channels", "adc_offset_counts", "adc_affine", + "adc_low_range", + "adc_range", + "adc_range_fullscale", "dac", "dac_dc", "dac_replay", @@ -300,8 +340,10 @@ "sample_rate_hz", "source", "currents", - "trigger" + "trigger", + "range" ], + "clipped_samples": "property", "crossing_times": "(self, threshold: 'float', edge: 'Edge' = 'rising', *, hysteresis: 'float' = 0.0) -> 'List[float]'", "dominant_frequency": "(self) -> 'float'", "duration": "property", @@ -310,6 +352,7 @@ "max": "(self) -> 'float'", "mean": "(self) -> 'float'", "min": "(self) -> 'float'", + "overrange": "property", "peak_to_peak": "(self) -> 'float'", "rms": "(self) -> 'float'", "rms_ac": "(self) -> 'float'", @@ -995,6 +1038,10 @@ }, "__all__": [ "AdcGainSource", + "AdcRange", + "AdcRangeState", + "AdcRangeTest", + "AdcRangeTestPoint", "AdcReading", "AdcSource", "AnalogPath", diff --git a/packages/embeddedci/tests/e2e/test_e2e_pod.py b/packages/embeddedci/tests/e2e/test_e2e_pod.py index 10565d9..31c4d45 100644 --- a/packages/embeddedci/tests/e2e/test_e2e_pod.py +++ b/packages/embeddedci/tests/e2e/test_e2e_pod.py @@ -990,3 +990,32 @@ def test_can_loopback_responder_and_typed_reads(pod): time.sleep(0.05) result = pod.can_read(max_frames=8) assert isinstance(result, CanReadResult) and any(f.id == 0x55 for f in result.frames) + + +@ANALOG +def test_adc_low_range_reads_the_cal1_loopback(pod): + """The ÷2 ADC low range (analog module v3r4, firmware 3.9.5+) through the cal1 loopback. + + Nothing wired. Skips on a board without the low range, and checks there that low is refused. + """ + if not pod.capabilities.adc_low_range: + if pod.adc_range().supported is False: + with pytest.raises(BenchPodError): + pod.adc_range("low") + pytest.skip("this board has no ADC low range (analog module v3r4+)") + try: + st = pod.adc_range("low") + assert st.range == "low" and 5.3 < st.fullscale < 5.6, st + assert pod.adc_range_test().passed + for volts in (1.0, 3.5): + pod.dac_output("5v", volts=volts) + time.sleep(0.1) + low = pod.adc_read("cal1", range="low") + normal = pod.adc_read("cal1", range="normal") + assert low.range == "low" and not low.overrange, low + assert abs(low.voltage - volts) < 0.05 and abs(low.voltage - normal.voltage) < 0.05, (volts, low, normal) + pod.dac_output("off") + assert pod.analog_path("off").range == "normal" + finally: + pod.dac_output("off") + pod.adc_range("normal") diff --git a/packages/embeddedci/tests/test_adc_range.py b/packages/embeddedci/tests/test_adc_range.py new file mode 100644 index 0000000..12ceaea --- /dev/null +++ b/packages/embeddedci/tests/test_adc_range.py @@ -0,0 +1,218 @@ +"""The ADC input range (analog module v3r4, firmware 3.9.5+): adc_range, range= on analog_path / +adc_read / capture_adc / calibrate_adc_gain, and captures scaled with the fit of the range in use.""" + +from __future__ import annotations + +from typing import Any, Dict, List + +import pytest + +from embeddedci.benchpod import BenchPod, BenchPodError +from embeddedci.benchpod.capabilities import Capabilities +from embeddedci.benchpod.results import Capture +from embeddedci.benchpod.state import AdcRangeState, AdcRangeTest, AdcReading, AnalogPathState, Calibration + +NORMAL_FIT = {"adc_cal_a_uv": 65708158, "adc_cal_b_nv": -1002644} +LOW_FIT = {"adc_cal_a_uv": 10923784, "adc_cal_b_nv": -166687} + + +class RangePod: + """A v3r4 module pod: tracks the range like the firmware (off and cal2 put it back to normal).""" + + def __init__(self, supported: bool = True) -> None: + self.supported = supported + self.range = "normal" + self.commands: List[dict] = [] + + def status(self) -> Any: + caps = ["calibrate", "cal_dmm", "analog"] + (["adc_low_range"] if self.supported else []) + return {"board": "stm32h563", "version": "3.9.5", "adc_bits": 16, "adc_fullscale_mv": 4096, + "analog": True, "caps": caps, **NORMAL_FIT, "adc_cal_unwrap": True, + "adc_range": "normal", "adc_range_fs_mv": 32856} + + def ping(self) -> Any: + return "pong" + + def _set(self, rng: str) -> None: + if rng == "low" and not self.supported: + raise BenchPodError("range: the low range needs analog module v3r4 or later") + self.range = rng + + def _range_reply(self) -> Dict[str, Any]: + d: Dict[str, Any] = {"range": self.range, "supported": self.supported, + "fullscale_mv": 5462 if self.range == "low" else 32856, "normal_fullscale_mv": 32856} + if self.supported: + d["low_fullscale_mv"] = 5462 + d.update(LOW_FIT if self.range == "low" else NORMAL_FIT) + return d + + def command(self, req: dict) -> Any: + self.commands.append(req) + cmd = req["cmd"] + if cmd == "adc_range": + if req.get("test"): + self.range = "normal" + return {"points": [{"set_mv": 1000, "normal_mv": 1001, "low_mv": 999, "ok": True}, + {"set_mv": 3500, "normal_mv": 3502, "low_mv": 582, "ok": False}], + "pass": False, "range": "normal"} + if "range" in req: + self._set(req["range"]) + return self._range_reply() + if cmd == "analog_path": + if req["path"] in ("off", "cal2"): + self.range = "normal" + if "range" in req: + self._set(req["range"]) + return {"path": req["path"], "u55": 0, "u58": 32 if self.range == "low" else 0, "range": self.range} + if cmd == "adc_read": + if "range" in req: + self._set(req["range"]) + return {"source": req["source"], "mv": 1203, "count": 58322, "span": 9, "range": self.range, + "fullscale_mv": 5462 if self.range == "low" else 32856, "overrange": False} + if cmd == "calibrate": + d = {"source": "current_in", "calibrated": True, "offset_mv": 0, "offset_uv": 0, + "a_uv": 10923784 if self.range == "low" else 65708158, + "b_nv": -166687 if self.range == "low" else -1002644, + "range": self.range, "adc_low_gain_ppm": 12000 if "adc" in req else None} + if req.get("range") == "low": + self.range = "normal" + return d + if cmd == "capture": + return {"samples": [0x8000, 59538], "adc_rate_hz": 400000} + return {} + + def samples(self, req: dict) -> List[int]: + self.commands.append(req) + return [0x8000, 59538] + + def close(self) -> None: + pass + + +def _pod(supported: bool = True) -> "tuple[BenchPod, RangePod]": + t = RangePod(supported) + return BenchPod(transport=t, lease=False), t + + +def test_adc_range_reads_and_switches(): + bp, t = _pod() + st = bp.adc_range() + assert t.commands[-1] == {"cmd": "adc_range"} + assert st.range == "normal" and st.supported and st.fullscale == pytest.approx(32.856) + assert st.low_fullscale == pytest.approx(5.462) + st = bp.adc_range("low") + assert t.commands[-1] == {"cmd": "adc_range", "range": "low"} + assert st.range == "low" and st.fullscale == pytest.approx(5.462) + assert st.a == pytest.approx(10.923784) and st.b == pytest.approx(-166.687e-6) + with pytest.raises(ValueError): + bp.adc_range("high") # type: ignore[arg-type] + + +def test_switching_the_range_rescales_captures(): + bp, t = _pod() + caps = bp.capabilities + assert caps.adc_range == "normal" and caps.adc_low_range + count = 59538 # 6000 counts above 0 V on the inverting front end + v_normal = caps.counts_to_volts(count) + bp.adc_range("low") + assert bp.capabilities.adc_range == "low" + v_low = bp.capabilities.counts_to_volts(count) + assert v_normal == pytest.approx(6.0 * 1.0026, rel=0.01) + assert v_low == pytest.approx(v_normal * 0.16625, rel=0.001) + cap = bp.capture_adc(2) + assert cap.range == "low" + + +def test_off_and_cal2_follow_the_pod_back_to_normal(): + bp, t = _pod() + bp.capabilities + bp.analog_path("adc_ext", range="low") + assert t.commands[-2] == {"cmd": "analog_path", "path": "adc_ext", "range": "low"} + assert t.commands[-1] == {"cmd": "adc_range"} # the reply said low: the fit is re-read + assert bp.capabilities.adc_range == "low" + n = len(t.commands) + bp.analog_path("current_in") # stays low: nothing to re-read + assert len(t.commands) == n + 1 + state = bp.analog_path("cal2") + assert state.range == "normal" and bp.capabilities.adc_range == "normal" + assert t.commands[-1] == {"cmd": "adc_range"} + + +def test_adc_read_with_a_range(): + bp, t = _pod() + r = bp.adc_read("ext", range="low") + assert t.commands[0] == {"cmd": "adc_read", "source": "ext", "range": "low"} + assert r.range == "low" and r.fullscale == pytest.approx(5.462) and not r.overrange + r = bp.adc_read("cal1") + assert t.commands[-1] == {"cmd": "adc_read", "source": "cal1"} + + +def test_capture_adc_with_a_range_routes_then_sets_it(): + bp, t = _pod() + cap = bp.capture_adc(2, source="current_in", range="low") + cmds = [c for c in t.commands if c["cmd"] != "adc_range"] + assert cmds[0] == {"cmd": "analog_path", "path": "current_in", "range": "low"} + assert cmds[1] == {"cmd": "calibrate"} # the current_in fit, read in the low range + assert cap.range == "low" and cap.overrange and cap.clipped_samples == 1 + # current_in volts use the low fit the calibrate reply gave + assert cap.volts[1] == pytest.approx(10.923784 - 166.687e-6 * 59538, abs=1e-6) # about 1.0 V + + +def test_capture_adc_range_without_a_source(): + bp, t = _pod() + bp.capture_adc(2, range="low") + assert t.commands[0] == {"cmd": "adc_range", "range": "low"} + + +def test_low_range_refused_without_k5(): + bp, t = _pod(supported=False) + assert not bp.capabilities.adc_low_range + st = bp.adc_range() + assert not st.supported and st.low_fullscale is None + with pytest.raises(BenchPodError, match="v3r4"): + bp.adc_range("low") + + +def test_range_self_test(): + bp, t = _pod() + bp.adc_range("low") + res = bp.adc_range_test() + assert t.commands[-2] == {"cmd": "adc_range", "test": True} + assert not res.passed and len(res.points) == 2 + assert res.points[0].set_voltage == pytest.approx(1.0) and res.points[0].ok + assert res.points[1].low == pytest.approx(0.582) and not res.points[1].ok + assert bp.capabilities.adc_range == "normal" + + +def test_calibrate_the_low_range_gain(): + bp, t = _pod() + c = bp.calibrate_adc_gain("cal1", range="low") + assert {"cmd": "calibrate", "adc": "cal1", "range": "low"} in t.commands + assert c.adc_low_gain_ppm == 12000 + with pytest.raises(ValueError, match="cal1"): + bp.calibrate_adc_gain("ext", range="low") + bp.calibrate_adc_gain("cal1") + assert t.commands[-1] == {"cmd": "calibrate", "adc": "cal1"} + + +def test_models_from_older_firmware(): + r = AdcReading.from_reply({"source": "adc_ext", "mv": 100, "count": 1, "span": 2}) + assert r.range == "" and r.fullscale is None and not r.overrange + assert AnalogPathState.from_reply({"path": "cal1", "u55": 3, "u58": 9}).range == "" + assert Calibration.from_reply({"source": "current_in"}).adc_low_gain_ppm is None + st = AdcRangeState.from_reply({"range": "normal", "supported": False, "fullscale_mv": 32856}) + assert st.a is None and st.b is None + assert not AdcRangeTest.from_reply({}).passed + caps = Capabilities.from_status({"adc_bits": 16}) + assert caps.adc_range == "" and not caps.adc_low_range and caps.adc_range_fullscale == 0.0 + + +def test_capabilities_from_server_parameters(): + caps = Capabilities.from_parameters({"cap.adc_low_range": "true", "cap.adc_range": "low", + "cap.adc_range_fs_mv": "5462"}) + assert caps.adc_low_range and caps.adc_range == "low" and caps.adc_range_fullscale == pytest.approx(5.462) + + +def test_capture_overrange_counts_rails_only(): + assert Capture(counts=[0x7FFF, 0x8000, 0x7FFE, 0]).clipped_samples == 2 + assert not Capture(counts=[0x8001, 65535]).overrange diff --git a/packages/embeddedci/tests/test_current_out_waveforms.py b/packages/embeddedci/tests/test_current_out_waveforms.py index 78df00b..b2e0341 100644 --- a/packages/embeddedci/tests/test_current_out_waveforms.py +++ b/packages/embeddedci/tests/test_current_out_waveforms.py @@ -108,7 +108,7 @@ def test_replay_takes_amps_and_maps_them_to_the_pods_codes(): def test_capture_of_the_input_is_in_amps_with_the_pods_own_fit(): bp, t = _bp() cap = bp.capture_adc(3, source="current_in", sample_rate_hz=100_000) - assert _cmds(t) == ["calibrate", "analog_path", "capture"] + assert _cmds(t) == ["analog_path", "calibrate", "capture"] # the fit of the range the route left assert cap.source == "current_in" assert cap.currents == pytest.approx([0.004, 0.012, 0.020], abs=5e-6) assert cap.volts == pytest.approx([0.996, 2.988, 4.98], abs=2e-3) @@ -122,8 +122,8 @@ def test_the_input_is_captured_in_amps_next_to_the_logic_channels(): cc = bp.capture_correlated(adc_samples=3, adc_sample_rate_hz=100_000, la_samples=4, la_sample_rate_hz=1e6, source="current_in") # The fit is read and the source routed BEFORE the one capture command that takes both. - assert _cmds(t) == ["calibrate", "analog_path", "capture_dual"] - assert t.commands[1] == {"cmd": "analog_path", "path": "current_in"} + assert _cmds(t) == ["analog_path", "calibrate", "capture_dual"] + assert t.commands[0] == {"cmd": "analog_path", "path": "current_in"} assert cc.adc.source == "current_in" assert cc.adc.currents == pytest.approx([0.004, 0.012, 0.020], abs=5e-6) assert cc.la.words == [1, 1, 3, 3]