diff --git a/dvt_excel_migration/README.md b/dvt_excel_migration/README.md new file mode 100644 index 0000000..f4a53cb --- /dev/null +++ b/dvt_excel_migration/README.md @@ -0,0 +1,75 @@ +# DVT Excel migration + +Moving a design-verification report out of a spreadsheet and onto the bench, in +two steps. + +## Step 1 — Import the reports you already have + +`xcu_import.py` reads a DVT workbook and creates one TofuPilot run from it. + +TofuPilot imports Excel natively, but two shapes common in hand-written reports +defeat the column mapper: + +- **Stacked cells.** A cell holding `22.33 - 10.11 / 22.34 - 10.10 / 22.35 - 10.11` + is three samples of a two-rail measurement, not one value. +- **Embedded plots.** Screenshots live in the drawing layer, so a mapper that + reads cells never sees them. Their anchor row is what ties a plot to the test + it proves. + +The script flattens both: it expands stacked cells to one measurement per +sample per temperature block, and pulls the images out of the `.xlsx` zip keyed +by anchor row. + +```bash +pip install tofupilot openpyxl +export TOFUPILOT_API_KEY=... + +python xcu_import.py "DVT report.xlsx" \ + --procedure-id \ + --serial-number SN-0001 \ + --part-number PCB-REV-A +``` + +Add `--dry-run` to see what it would create without uploading, and +`--waveforms ` to promote curves to multi-dimensional measurements when +scope CSVs are available (named `_.csv`). + +On a real 5-sheet report this produced one run with 13 phases, 111 +measurements and 49 attached plots in about 30 seconds. + +## Step 2 — Stop writing the report + +`smps-dvt/` is the same tests as a TofuPilot procedure, so values are captured +as they are measured rather than read off a screen and typed into a sheet. + +``` +smps-dvt/ +├── procedure.yaml phases, measurements and limits +├── phases/ the Python each phase runs +└── plugs/ one class per instrument +``` + +```bash +tofupilot run ./smps-dvt +``` + +The plugs return representative data so the procedure runs anywhere. Every +method keeps its real SCPI call directly above, commented out — swap the two +and the same procedure drives the bench: + +- `plugs/scope.py` — Tektronix MSO5-series, 12-bit High Res, AC-coupled ripple + with a 20 MHz bandwidth limit, `CURVe?` waveform readback with + `YMUlt`/`YOFf`/`YZEro`/`XINcr` scaling +- `plugs/ac_source.py` — programmable AC source for the mains sweep + +What the spreadsheet cannot do: + +- The eleven crossed-regulation rows become **one sweep**, recorded as a + regulation curve indexed by input voltage, with `min`/`max` aggregations + validated against the rail limits. +- Each ripple test keeps **the waveform itself**, not a picture of it, and the + 400 mV ceiling is checked against the trace rather than a transcribed number. + +Because the curve is data, the same measurement stays comparable across samples +and across temperature setpoints. Run one per chamber setpoint and the drift is +visible directly. diff --git a/dvt_excel_migration/smps_dvt/phases/crossed_regulation.py b/dvt_excel_migration/smps_dvt/phases/crossed_regulation.py new file mode 100644 index 0000000..75d7a5c --- /dev/null +++ b/dvt_excel_migration/smps_dvt/phases/crossed_regulation.py @@ -0,0 +1,12 @@ +"""Both rails at nominal input: one row of the report.""" + +CH_23V, CH_9V = 1, 2 + + +def crossed_regulation(measurements, scope, ac_source): + ac_source.set_voltage(230) + scope.configure_channel(CH_23V, 5.0) + scope.configure_channel(CH_9V, 2.0) + + measurements.rail_23v = scope.measure_dc(CH_23V) + measurements.rail_9v = scope.measure_dc(CH_9V) diff --git a/dvt_excel_migration/smps_dvt/phases/crossed_regulation_sweep.py b/dvt_excel_migration/smps_dvt/phases/crossed_regulation_sweep.py new file mode 100644 index 0000000..69158ca --- /dev/null +++ b/dvt_excel_migration/smps_dvt/phases/crossed_regulation_sweep.py @@ -0,0 +1,39 @@ +"""The rows a report fills in one at a time, as a single sweep. + +Each rail becomes a curve indexed by input voltage, so regulation is something +to compare across samples rather than a column of numbers to read. +""" + +# Nominal 230 Vac +/-10%. +MAINS_SWEEP_VAC = (207, 216, 230, 244, 253) + +CH_23V, CH_9V = 1, 2 + + +def crossed_regulation_sweep(measurements, scope, ac_source): + mains = [] + rail_23v = [] + rail_9v = [] + + for volts in MAINS_SWEEP_VAC: + ac_source.set_voltage(volts) + mains.append(volts) + rail_23v.append(scope.measure_dc(CH_23V)) + rail_9v.append(scope.measure_dc(CH_9V)) + + measurements.rail_23v_vs_mains.x_axis = mains + measurements.rail_23v_vs_mains.y_axis.rail_23v = rail_23v + # The sweep passes when the rail stays inside its limits at every point. + measurements.rail_23v_vs_mains.y_axis.rail_23v.aggregations.min = min( + rail_23v) + measurements.rail_23v_vs_mains.y_axis.rail_23v.aggregations.max = max( + rail_23v) + + measurements.rail_9v_vs_mains.x_axis = mains + measurements.rail_9v_vs_mains.y_axis.rail_9v = rail_9v + measurements.rail_9v_vs_mains.y_axis.rail_9v.aggregations.min = min( + rail_9v) + measurements.rail_9v_vs_mains.y_axis.rail_9v.aggregations.max = max( + rail_9v) + + ac_source.set_voltage(230) diff --git a/dvt_excel_migration/smps_dvt/phases/ripple_23v.py b/dvt_excel_migration/smps_dvt/phases/ripple_23v.py new file mode 100644 index 0000000..7ac0e85 --- /dev/null +++ b/dvt_excel_migration/smps_dvt/phases/ripple_23v.py @@ -0,0 +1,20 @@ +"""23V ripple: the two transcribed figures, plus the trace they came from.""" + +CH_23V = 1 + + +def ripple_23v(measurements, scope): + scope.configure_channel(CH_23V, 0.02) # 20 mV/div + scope.set_timebase(0.01) # 10 ms/div: ~20 cycles of 100 Hz ripple + scope.acquire() + + peak_mv, rms_mv = scope.measure_ripple(CH_23V) + measurements.ripple_23v_peak_to_peak = peak_mv + measurements.ripple_23v_rms = rms_mv + + times, values = scope.capture_waveform(CH_23V) + measurements.ripple_23v_waveform.x_axis = times + measurements.ripple_23v_waveform.y_axis.ripple = values + # The same ceiling as the transcribed figure, checked against the trace. + measurements.ripple_23v_waveform.y_axis.ripple.aggregations.peak_to_peak = max( + values) - min(values) diff --git a/dvt_excel_migration/smps_dvt/phases/ripple_9v.py b/dvt_excel_migration/smps_dvt/phases/ripple_9v.py new file mode 100644 index 0000000..8d4b477 --- /dev/null +++ b/dvt_excel_migration/smps_dvt/phases/ripple_9v.py @@ -0,0 +1,20 @@ +"""9V ripple: the two transcribed figures, plus the trace they came from.""" + +CH_9V = 2 + + +def ripple_9v(measurements, scope): + scope.configure_channel(CH_9V, 0.02) # 20 mV/div + scope.set_timebase(0.01) # 10 ms/div: ~20 cycles of 100 Hz ripple + scope.acquire() + + peak_mv, rms_mv = scope.measure_ripple(CH_9V) + measurements.ripple_9v_peak_to_peak = peak_mv + measurements.ripple_9v_rms = rms_mv + + times, values = scope.capture_waveform(CH_9V) + measurements.ripple_9v_waveform.x_axis = times + measurements.ripple_9v_waveform.y_axis.ripple = values + # The same ceiling as the transcribed figure, checked against the trace. + measurements.ripple_9v_waveform.y_axis.ripple.aggregations.peak_to_peak = max( + values) - min(values) diff --git a/dvt_excel_migration/smps_dvt/plugs/ac_source.py b/dvt_excel_migration/smps_dvt/plugs/ac_source.py new file mode 100644 index 0000000..6e4ee38 --- /dev/null +++ b/dvt_excel_migration/smps_dvt/plugs/ac_source.py @@ -0,0 +1,31 @@ +"""Programmable AC source driving the mains side of the regulation sweep.""" + +# VISA resource of the AC source on the bench LAN. +AC_SOURCE_RESOURCE = "TCPIP0::192.0.2.11::inst0::INSTR" + +LINE_FREQUENCY_HZ = 50.0 + + +class ACSource: + def __init__(self): + self._source = None + + # import pyvisa + # + # self._source = pyvisa.ResourceManager().open_resource(AC_SOURCE_RESOURCE) + # self._source.timeout = 10_000 + # self._source.write(f"SOURce:FREQuency {LINE_FREQUENCY_HZ}") + # self._source.write("OUTPut:STATe ON") + + def __del__(self): + # self._source.write("OUTPut:STATe OFF") + pass + + def identity(self) -> str: + # return self._source.query("*IDN?").strip() + return "GW-INSTEK,APS-7100,SIMULATED,1.0" + + def set_voltage(self, volts: float) -> None: + # self._source.write(f"SOURce:VOLTage {volts}") + # self._source.query("*OPC?") + pass diff --git a/dvt_excel_migration/smps_dvt/plugs/scope.py b/dvt_excel_migration/smps_dvt/plugs/scope.py new file mode 100644 index 0000000..75bdb82 --- /dev/null +++ b/dvt_excel_migration/smps_dvt/plugs/scope.py @@ -0,0 +1,142 @@ +"""Tektronix MSO5-series oscilloscope. + +Reads the same figures a hand-written report transcribes off the screen +(Peak-to-Peak and RMS) and, unlike a screenshot, also returns the waveform +behind them. + +The methods below generate representative data so the procedure runs anywhere. +Each keeps its real instrument call directly above, commented out: swap the two +and the same procedure drives the bench. +""" + +import math +import random + +# VISA resource of the scope on the bench LAN. +SCOPE_RESOURCE = "TCPIP0::192.0.2.10::inst0::INSTR" + +RAIL_23V_NOM = 23.0 +RAIL_9V_NOM = 9.0 +CH_23V = 1 + +# Rectified mains ripple sits at twice the line frequency. +LINE_FREQUENCY_HZ = 50.0 + + +class Oscilloscope: + def __init__(self): + self._scope = None + + # import pyvisa + # + # self._scope = pyvisa.ResourceManager().open_resource(SCOPE_RESOURCE) + # self._scope.timeout = 20_000 + # self._scope.write("*RST") + # # 12-bit High Res: at 400 mV of ripple on a 23 V rail the extra bits + # # are the difference between measuring ripple and measuring the + # # quantiser. + # self._scope.write("ACQuire:MODe HIRes") + # self._scope.write("HORizontal:MODe AUTO") + + def identity(self) -> str: + """Model and firmware, recorded with the run for traceability.""" + # return self._scope.query("*IDN?").strip() + return "TEKTRONIX,MSO54,SIMULATED,1.0" + + def configure_channel(self, channel: int, volts_per_div: float) -> None: + """AC-couple the rail so its DC level does not eat the vertical range.""" + # self._scope.write(f"DISplay:GLObal:CH{channel}:STATE ON") + # self._scope.write(f"CH{channel}:COUPling AC") + # # 20 MHz is the usual bandwidth limit for a ripple measurement. + # self._scope.write(f"CH{channel}:BANdwidth 20E6") + # self._scope.write(f"CH{channel}:SCAle {volts_per_div}") + # self._scope.write(f"CH{channel}:OFFSet 0") + pass + + def set_timebase(self, seconds_per_div: float) -> None: + # self._scope.write(f"HORizontal:SCAle {seconds_per_div}") + pass + + def acquire(self) -> None: + """One single-sequence acquisition, so every read is the same capture.""" + # self._scope.write("ACQuire:STOPAfter SEQuence") + # self._scope.write("ACQuire:STATE RUN") + # self._scope.query("*OPC?") + pass + + def measure_dc(self, channel: int) -> float: + """Rail voltage in volts, DC-coupled mean.""" + # self._scope.write(f"CH{channel}:COUPling DC") + # self.acquire() + # self._scope.write("MEASUrement:ADDMEAS MEAN") + # self._scope.write(f"MEASUrement:MEAS1:SOUrce CH{channel}") + # self._scope.write("MEASUrement:MEAS1:TYPe MEAN") + # self._scope.query("*OPC?") + # return float( + # self._scope.query("MEASUrement:MEAS1:RESUlt:CURRentacq:MEAN?") + # ) + nominal = RAIL_23V_NOM if channel == CH_23V else RAIL_9V_NOM + return round(random.gauss(nominal, 0.04), 3) + + def measure_ripple(self, channel: int): + """Peak-to-peak and RMS ripple in millivolts, AC-coupled.""" + # self._scope.write("MEASUrement:ADDMEAS PK2PK") + # self._scope.write(f"MEASUrement:MEAS1:SOUrce CH{channel}") + # self._scope.write("MEASUrement:MEAS1:TYPe PK2PK") + # self._scope.write("MEASUrement:ADDMEAS RMS") + # self._scope.write(f"MEASUrement:MEAS2:SOUrce CH{channel}") + # self._scope.write("MEASUrement:MEAS2:TYPe RMS") + # self._scope.query("*OPC?") + # peak_volts = float( + # self._scope.query("MEASUrement:MEAS1:RESUlt:CURRentacq:MEAN?") + # ) + # rms_volts = float( + # self._scope.query("MEASUrement:MEAS2:RESUlt:CURRentacq:MEAN?") + # ) + # return peak_volts * 1000.0, rms_volts * 1000.0 + peak_mv = random.uniform(120.0, 340.0) + # Rectified-mains ripple sits near a crest factor of 4. + return round(peak_mv, 1), round(peak_mv / 4.2, 2) + + def capture_waveform(self, channel: int): + """The trace behind those numbers, as (seconds, millivolts). + + The record is decimated to a couple of thousand points: enough to keep + the ripple envelope, small enough to store on every run. + """ + # self._scope.write(f"DATa:SOUrce CH{channel}") + # self._scope.write("DATa:ENCdg ASCII") + # self._scope.write("DATa:STARt 1") + # record = int(self._scope.query("HORizontal:RECOrdlength?")) + # self._scope.write(f"DATa:STOP {record}") + # + # counts = [float(v) for v in self._scope.query("CURVe?").split(",")] + # y_multiplier = float(self._scope.query("WFMOutpre:YMUlt?")) + # y_offset = float(self._scope.query("WFMOutpre:YOFf?")) + # y_zero = float(self._scope.query("WFMOutpre:YZEro?")) + # x_increment = float(self._scope.query("WFMOutpre:XINcr?")) + # + # stride = max(1, len(counts) // 2000) + # times, values = [], [] + # for index in range(0, len(counts), stride): + # volts = (counts[index] - y_offset) * y_multiplier + y_zero + # times.append(index * x_increment) + # values.append(volts * 1000.0) + # return times, values + + points = 500 + duration = 0.2 # 10 ms/div across 20 divisions + step = duration / points + amplitude = random.uniform(60.0, 170.0) + ripple_hz = 2 * LINE_FREQUENCY_HZ + + times, values = [], [] + for index in range(points): + moment = index * step + fundamental = amplitude * \ + math.sin(2 * math.pi * ripple_hz * moment) + # Switching noise rides on the rectified ripple. + noise = random.gauss(0, amplitude * 0.06) + times.append(round(moment, 6)) + values.append(round(fundamental + noise, 3)) + return times, values diff --git a/dvt_excel_migration/smps_dvt/procedure.yaml b/dvt_excel_migration/smps_dvt/procedure.yaml new file mode 100644 index 0000000..189fe0d --- /dev/null +++ b/dvt_excel_migration/smps_dvt/procedure.yaml @@ -0,0 +1,132 @@ +name: SMPS Regulation and Ripple +version: 1.0.0 + +unit: + auto_identify: true + serial_number: + default_value: "SN-0001" + part_number: + default_value: "PCB-REV-A" + +plugs: + - key: scope + name: Oscilloscope + python: plugs.scope:Oscilloscope + - key: ac_source + name: AC Source + python: plugs.ac_source:ACSource + +main: + - name: Crossed Regulation + python: phases.crossed_regulation + measurements: + # Rail limits come straight from the report's Min./Max. criteria columns. + - name: Rail 23V + unit: V + validators: + - operator: ">=" + expected_value: 21.9 + - operator: "<=" + expected_value: 23.1 + - name: Rail 9V + unit: V + validators: + - operator: ">=" + expected_value: 8.5 + - operator: "<=" + expected_value: 9.5 + + - name: Crossed Regulation Sweep + python: phases.crossed_regulation_sweep + measurements: + # The rows a report fills in one at a time become a regulation curve. + - name: Rail 23V vs Mains + title: 23V rail across the mains range + x_axis: + legend: Mains + unit: Vac + y_axis: + - legend: Rail 23V + unit: V + aggregations: + - type: min + validators: + - operator: ">=" + expected_value: 21.9 + - type: max + validators: + - operator: "<=" + expected_value: 23.1 + - name: Rail 9V vs Mains + title: 9V rail across the mains range + x_axis: + legend: Mains + unit: Vac + y_axis: + - legend: Rail 9V + unit: V + aggregations: + - type: min + validators: + - operator: ">=" + expected_value: 8.5 + - type: max + validators: + - operator: "<=" + expected_value: 9.5 + + - name: Ripple 23V + python: phases.ripple_23v + measurements: + # The two figures a hand-written report transcribes off the scope screen. + - name: Ripple 23V Peak-to-Peak + unit: mV + validators: + - operator: "<=" + expected_value: 400 + - name: Ripple 23V RMS + unit: mV + validators: + - operator: "<=" + expected_value: 100 + # ...and the trace they were measured on, kept as data. + - name: Ripple 23V Waveform + title: 23V ripple + x_axis: + legend: Time + unit: s + y_axis: + - legend: Ripple + unit: mV + aggregations: + - type: peak_to_peak + validators: + - operator: "<=" + expected_value: 400 + + - name: Ripple 9V + python: phases.ripple_9v + measurements: + - name: Ripple 9V Peak-to-Peak + unit: mV + validators: + - operator: "<=" + expected_value: 400 + - name: Ripple 9V RMS + unit: mV + validators: + - operator: "<=" + expected_value: 100 + - name: Ripple 9V Waveform + title: 9V ripple + x_axis: + legend: Time + unit: s + y_axis: + - legend: Ripple + unit: mV + aggregations: + - type: peak_to_peak + validators: + - operator: "<=" + expected_value: 400 diff --git a/dvt_excel_migration/xcu_import.py b/dvt_excel_migration/xcu_import.py new file mode 100644 index 0000000..4afbd14 --- /dev/null +++ b/dvt_excel_migration/xcu_import.py @@ -0,0 +1,393 @@ +"""Import a DVT report spreadsheet into TofuPilot. + +Written for reports that keep several sample readings inside a single cell and +store their plots as embedded images: neither shape survives the native Excel +import, because the column mapper reads one value per cell and never sees the +drawing layer. This script flattens both. + + pip install tofupilot openpyxl + export TOFUPILOT_API_KEY=... + python xcu_import.py "DVT report.xlsx" --procedure-id \\ + --serial-number SN-0001 --part-number PCB-REV-A + +Waveform CSVs exported from the scope are picked up automatically when a +--waveforms directory is passed: a curve then lands as a real multi-dimensional +measurement (per-axis units, validators, comparable across runs) instead of a +screenshot pinned to the run. +""" + +from __future__ import annotations + +import argparse +import os +import re +import struct +import zipfile +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from urllib.request import Request, urlopen + +import openpyxl +from tofupilot.v2 import TofuPilot + +# Row 50 names the temperature block, row 51 the columns; readings start at 52. +BLOCK_ROW = 50 +HEADER_ROW = 51 +SHEET = "SMPS reg+ripple" + +# Each temperature block repeats (value, units, result) at a fixed offset. The +# sample class sits in row 48 above the same columns. +BLOCK_COLUMNS = (18, 23, 28, 33, 38, 43, 48) # R, W, AB, AG, AL, AQ, AV +VALUE_OFFSET, UNITS_OFFSET, RESULT_OFFSET = 0, 1, 3 + +# "22.33 - 10.11" is one reading of a dual-rail test, not two measurements. +RAIL_SPLIT = re.compile(r"\s*-\s*") +NUMERIC = re.compile(r"-?\d+(?:[.,]\d+)?") + + +@dataclass +class Reading: + """One test row, expanded to one entry per sample.""" + + test_number: str + description: str + procedure: str + conditions: str + sample: str + values: list[float] + units: str + lower: float | None + upper: float | None + outcome: str + plots: list[str] = field(default_factory=list) + + +def parse_stacked(cell: object) -> list[list[float]]: + """Split a cell holding one reading per line into per-sample values. + + "22.33 - 10.11\\n22.34 - 10.10\\n22.35 - 10.11" is three samples of a + two-rail measurement, so it yields [[22.33, 10.11], [22.34, 10.10], ...]. + """ + if cell is None: + return [] + if isinstance(cell, (int, float)): + return [[float(cell)]] + + rows: list[list[float]] = [] + for line in str(cell).replace("\r", "\n").split("\n"): + if not line.strip(): + continue + rails = [NUMERIC.search(part) for part in RAIL_SPLIT.split(line)] + values = [float(m.group().replace(",", ".")) for m in rails if m] + if values: + rows.append(values) + return rows + + +# Letterheads and icons sit in the drawing layer next to the real captures. +# A scope screenshot is a full window grab, so it is wide, roughly landscape, +# and never a small banner. Filtering on pixels rather than file size keeps a +# lightly-compressed plot and drops a detailed logo. +MIN_PLOT_WIDTH = 600 +MIN_PLOT_HEIGHT = 300 + + +def png_size(payload: bytes) -> tuple[int, int] | None: + """Width and height from a PNG's IHDR, without a decoder dependency.""" + if len(payload) < 24 or payload[:8] != b"\x89PNG\r\n\x1a\n": + return None + width, height = struct.unpack(">II", payload[16:24]) + return width, height + + +def is_plot(payload: bytes) -> bool: + size = png_size(payload) + if size is None: + return True # not a PNG we can measure; let it through rather than lose data + width, height = size + return width >= MIN_PLOT_WIDTH and height >= MIN_PLOT_HEIGHT + + +def extract_images(workbook_path: Path, + out_dir: Path) -> dict[int, list[Path]]: + """Pull embedded PNGs out of the .xlsx and group them by anchor row. + + openpyxl drops images on load, so the drawing XML is read straight from the + zip. The anchor row is what ties a plot to the test it proves — the whole + reason these cannot go in as one undifferentiated pile. + + Logos are skipped, and an image repeated across rows is only kept once. + """ + out_dir.mkdir(parents=True, exist_ok=True) + by_row: dict[int, list[Path]] = {} + seen: set[str] = set() + + with zipfile.ZipFile(workbook_path) as z: + rels = {} + for name in z.namelist(): + if re.fullmatch(r"xl/drawings/_rels/drawing\d+\.xml\.rels", name): + body = z.read(name).decode("utf8", "ignore") + rels[name] = re.findall( + r'Id="([^"]+)"[^>]*?media/([^"]+)"', body) + + for name in z.namelist(): + if not re.fullmatch(r"xl/drawings/drawing\d+\.xml", name): + continue + rel_key = name.replace("drawings/", "drawings/_rels/") + ".rels" + media = dict(rels.get(rel_key, [])) + body = z.read(name).decode("utf8", "ignore") + + for anchor in re.finditer( + r".*?(\d+).*?embed=\"([^\"]+)\"", + body, + re.S, + ): + row = int(anchor.group(1)) + 1 # xdr rows are 0-based + target = media.get(anchor.group(2)) + if not target: + continue + + payload = z.read(f"xl/media/{target}") + if not is_plot(payload): + continue # letterhead or icon, not a capture + if target in seen: + continue # same image anchored on several rows + + seen.add(target) + dest = out_dir / target + if not dest.exists(): + dest.write_bytes(payload) + by_row.setdefault(row, []).append(dest) + + return by_row + + +def load_waveform(path: Path) -> tuple[list[float], list[float]]: + """Read a two-column scope CSV into (time, amplitude).""" + xs: list[float] = [] + ys: list[float] = [] + for line in path.read_text(errors="ignore").splitlines(): + parts = line.replace(";", ",").split(",") + if len(parts) < 2: + continue + try: + xs.append(float(parts[0])) + ys.append(float(parts[1])) + except ValueError: + continue # header or preamble line + return xs, ys + + +def read_report(path: Path, images: dict[int, list[Path]]) -> list[Reading]: + sheet = openpyxl.load_workbook(path, data_only=True)[SHEET] + readings: list[Reading] = [] + + def text(row: int, col: int) -> str: + value = sheet.cell(row=row, column=col).value + return "" if value is None else str(value).strip() + + for row in range(HEADER_ROW + 1, sheet.max_row + 1): + description = text(row, 3) # C: Testdescription + if not description: + continue + + limits = [ + sheet.cell( + row=row, + column=col).value for col in ( + 10, + 12)] # J, L + lower, upper = ( + float(v) if isinstance(v, (int, float)) else None for v in limits + ) + conditions = " ".join( + filter(None, (text(row, col) for col in range(5, 10))) # E..I + ) + + for block in BLOCK_COLUMNS: + # Row 50 gives the temperature, row 48 the sample class. + temperature = text(BLOCK_ROW, block) or "Room temp." + sample_class = text(48, block) + per_sample = parse_stacked( + sheet.cell(row=row, column=block + VALUE_OFFSET).value + ) + if not per_sample: + continue + + units = text(row, block + UNITS_OFFSET).split("\n")[0] + outcome = text(row, block + RESULT_OFFSET) + + for index, values in enumerate(per_sample, start=1): + label = f"{sample_class} #{index}" if sample_class else f"#{index}" + readings.append( + Reading( + test_number=text(row, 2) or str(row), # B: Testnr + description=description, + procedure=text(row, 4), # D: Measuring procedure + conditions=conditions, + sample=f"{label} @ {temperature}", + values=values, + units=units, + lower=lower, + upper=upper, + outcome=outcome or "UNSET", + plots=[str(p) for p in images.get(row, [])], + ) + ) + + return readings + + +def to_outcome(raw: str) -> str: + """Their result column is prose ("Same result, Pass", "ToDo").""" + lowered = raw.strip().lower() + if "fail" in lowered: + return "FAIL" + if "pass" in lowered: + return "PASS" + return "UNSET" + + +def build_phases(readings: list[Reading], + waveforms: Path | None) -> list[dict]: + phases: dict[str, dict] = {} + + for reading in readings: + phase = phases.setdefault( + reading.description, + { + "name": reading.description, + "outcome": "PASS", + "started_at": datetime.now(timezone.utc), + "ended_at": datetime.now(timezone.utc), + "docstring": reading.procedure or None, + "measurements": [], + }, + ) + + outcome = to_outcome(reading.outcome) + if outcome == "FAIL": + phase["outcome"] = "FAIL" + + validators = [] + if reading.lower is not None: + validators.append( + {"operator": ">=", "expected_value": reading.lower}) + if reading.upper is not None: + validators.append( + {"operator": "<=", "expected_value": reading.upper}) + + curve = None + if waveforms: + candidate = waveforms / \ + f"{reading.test_number}_{reading.sample}.csv" + if candidate.exists(): + curve = load_waveform(candidate) + + measurement: dict = { + "name": f"{reading.description} — {reading.sample}", + "outcome": outcome, + "docstring": reading.conditions or None, + } + + if curve: + # A real curve carries its own axes, so it stays comparable across + # samples and temperatures rather than being a picture of a result. + times, amplitudes = curve + measurement["x_axis"] = { + "name": "Time", "units": "s", "data": times} + measurement["y_axis"] = [ + { + "name": reading.description, + "units": reading.units or "V", + "data": amplitudes, + "validators": validators or None, + } + ] + else: + measurement["measured_value"] = reading.values[0] + measurement["units"] = reading.units or None + if validators: + measurement["validators"] = validators + + phase["measurements"].append(measurement) + + return list(phases.values()) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("workbook", type=Path) + parser.add_argument("--procedure-id", required=True) + parser.add_argument("--serial-number", required=True) + parser.add_argument("--part-number", required=True) + parser.add_argument( + "--waveforms", + type=Path, + help="Directory of scope CSVs named _.csv", + ) + parser.add_argument("--dry-run", action="store_true") + args = parser.parse_args() + + plots_dir = args.workbook.parent / "plots" + images = extract_images(args.workbook, plots_dir) + readings = read_report(args.workbook, images) + phases = build_phases(readings, args.waveforms) + + curves = sum(1 for p in phases for m in p["measurements"] if "y_axis" in m) + print( + f"{len(readings)} readings across {len(phases)} phases " + f"({curves} as waveforms, {sum(len(v) for v in images.values())} plots)") + + if args.dry_run: + for phase in phases: + print( + f" {phase['name']}: {len(phase['measurements'])} measurements") + return + + now = datetime.now(timezone.utc) + client_options = {"api_key": os.environ["TOFUPILOT_API_KEY"]} + if os.environ.get("TOFUPILOT_URL"): + client_options["server_url"] = os.environ["TOFUPILOT_URL"] + + with TofuPilot(**client_options) as client: + run = client.runs.create( + outcome="FAIL" if any( + p["outcome"] == "FAIL" for p in phases) else "PASS", + procedure_id=args.procedure_id, + started_at=now, + ended_at=now, + serial_number=args.serial_number, + part_number=args.part_number, + phases=phases, + ) + print(f"run {run.id}") + + # Screenshots ride along on the run; a curve imported as a measurement + # above already sits on its own test. Each upload is initialize -> PUT + # to the pre-signed URL -> finalize. + uploads = [] + for paths in images.values(): + for path in paths: + blob = Path(path) + upload = client.attachments.initialize(name=blob.name) + response = urlopen( + Request( + upload.upload_url, + data=blob.read_bytes(), + method="PUT", + headers={"Content-Type": "image/png"}, + ) + ) + response.read() + client.attachments.finalize(id=upload.id) + uploads.append(upload.id) + + if uploads: + client.runs.update(id=run.id, attachments=uploads) + print(f"{len(uploads)} plots attached") + + +if __name__ == "__main__": + main() diff --git a/touchpad_accuracy/README.md b/touchpad_accuracy/README.md new file mode 100644 index 0000000..65e9451 --- /dev/null +++ b/touchpad_accuracy/README.md @@ -0,0 +1,71 @@ +# Touchpad positional accuracy + +A touchpad is tested by pressing it. A robot puts a known force on a known +coordinate, and the pad reports where it thinks it was touched; the number that +matters is the distance between the two. + +`touchpad_ptp/` records that distance against the Precision Touchpad linearity +limits — **0.5 mm** across the pad, relaxed to **1.5 mm** within 3.5 mm of an +edge, where the sensor is least linear. Windows reports these distances in +himetric units (0.01 mm), which is what the HID read in `plugs/touch_robot.py` +converts from. + +``` +touchpad_ptp/ +├── procedure.yaml phases, measurements and limits +├── phases/ the Python each phase runs +└── plugs/ one class per instrument +``` + +```bash +tofupilot run ./touchpad_ptp +``` + +The plugs return representative data so the procedure runs anywhere. Every +method keeps its real call directly above, commented out — swap the two and the +same procedure drives the bench: + +- `plugs/touch_robot.py` — motion controller over VISA for the press, HID + digitizer report for the contact the pad reported +- `plugs/force_gauge.py` — load cell ramped until the dome switch closes, + triggered on the closure rather than sampled and compared afterwards + +## The grid is one measurement, not nine + +A manual test taps each target and writes down whether it looked right. Here +each grid becomes a curve indexed by target, with `max` validated against the +spec limit and `mean` against a tighter working limit: + +```yaml +- name: Edge Positional Error + x_axis: + legend: Target + y_axis: + - legend: Error + unit: mm + aggregations: + - type: max + validators: + - operator: "<=" + expected_value: 1.5 +``` + +`max` is what fails the unit — one target outside the limit is a failure no +average should absorb. Keeping the whole series alongside it is what separates +a pad that is off in one corner from one that is off everywhere, which is the +difference between a fixture problem and a sensor problem. + +The worst edge target is recorded a second time as a scalar, so the failing +number is filterable on its own and trendable across a population. + +## Two limits, one press + +The central grid and the edge band run the same code against the same fixture. +They are separate phases only because the spec allows three times the error +inside the border strip, and a single limit would either pass a bad centre or +fail a good edge. + +Click actuation force comes off the same fixture in the same cycle. It has +nothing to do with position, but a dome that stiffens or softens is an early +sign of the same mechanical drift that moves the positional error, and it costs +one extra phase to catch. diff --git a/touchpad_accuracy/touchpad_ptp/phases/center_linearity.py b/touchpad_accuracy/touchpad_ptp/phases/center_linearity.py new file mode 100644 index 0000000..cb0727f --- /dev/null +++ b/touchpad_accuracy/touchpad_ptp/phases/center_linearity.py @@ -0,0 +1,47 @@ +"""The nine targets a manual test taps by hand, as one curve. + +Each target is a commanded coordinate; the measurement is the distance between +it and the contact the pad reported. Keeping the whole grid as a series means a +unit that is off in one corner is distinguishable from one that is off +everywhere, which a single worst-case number hides. +""" + +import math + +# 2 mm grid across the central region, clear of the edge band. +TARGETS = ( + (26, 16), + (52, 16), + (79, 16), + (26, 32), + (52, 32), + (79, 32), + (26, 49), + (52, 49), + (79, 49), +) + + +def center_linearity(measurements, robot, log): + index = [] + errors = [] + + for n, (target_x, target_y) in enumerate(TARGETS, start=1): + reported_x, reported_y = robot.press(target_x, target_y) + error = round( + math.dist( + (target_x, target_y), (reported_x, reported_y)), 3) + + log.info( + f"({target_x},{target_y}) -> ({reported_x:.2f},{reported_y:.2f}) = {error} mm" + ) + index.append(n) + errors.append(error) + + measurements.center_positional_error.x_axis = index + measurements.center_positional_error.y_axis.error = errors + # The unit passes when every target is inside the limit, not on average. + measurements.center_positional_error.y_axis.error.aggregations.max = max( + errors) + measurements.center_positional_error.y_axis.error.aggregations.mean = round( + sum(errors) / len(errors), 3) diff --git a/touchpad_accuracy/touchpad_ptp/phases/click_actuation_force.py b/touchpad_accuracy/touchpad_ptp/phases/click_actuation_force.py new file mode 100644 index 0000000..796768f --- /dev/null +++ b/touchpad_accuracy/touchpad_ptp/phases/click_actuation_force.py @@ -0,0 +1,13 @@ +"""How hard the pad has to be pressed before it clicks. + +Unrelated to position, but it comes off the same fixture in the same cycle, and +a dome that stiffens or softens is an early sign of the same mechanical problem +that moves the positional error. +""" + + +def click_actuation_force(measurements, gauge, log): + grams = gauge.ramp_until_actuation() + log.info(f"Switch actuated at {grams} g") + + measurements.actuation_force = grams diff --git a/touchpad_accuracy/touchpad_ptp/phases/edge_band_linearity.py b/touchpad_accuracy/touchpad_ptp/phases/edge_band_linearity.py new file mode 100644 index 0000000..c016726 --- /dev/null +++ b/touchpad_accuracy/touchpad_ptp/phases/edge_band_linearity.py @@ -0,0 +1,49 @@ +"""The border, where touchpads actually fail. + +Same press, same measurement as the central grid, but within 3.5 mm of an edge +the spec allows three times the error. Sampling the corners and the middle of +each side is what catches a sensor whose linearity falls apart only at one end. +""" + +import math + +# Targets inside the 3.5 mm border strip: four corners, four side midpoints. +TARGETS = ( + (2, 2), + (52, 2), + (103, 2), + (2, 32), + (103, 32), + (2, 63), + (52, 63), + (103, 63), +) + + +def edge_band_linearity(measurements, robot, log): + index = [] + errors = [] + + for n, (target_x, target_y) in enumerate(TARGETS, start=1): + reported_x, reported_y = robot.press(target_x, target_y) + error = round( + math.dist( + (target_x, target_y), (reported_x, reported_y)), 3) + + log.info( + f"({target_x},{target_y}) -> ({reported_x:.2f},{reported_y:.2f}) = {error} mm" + ) + index.append(n) + errors.append(error) + + measurements.edge_positional_error.x_axis = index + measurements.edge_positional_error.y_axis.error = errors + measurements.edge_positional_error.y_axis.error.aggregations.max = max( + errors) + measurements.edge_positional_error.y_axis.error.aggregations.mean = round( + sum(errors) / len(errors), 3 + ) + + # Repeated as a scalar so the failing number is filterable on its own, and + # so drift on the worst target is trendable across a population. + measurements.worst_edge_error = max(errors) diff --git a/touchpad_accuracy/touchpad_ptp/phases/home_fixture.py b/touchpad_accuracy/touchpad_ptp/phases/home_fixture.py new file mode 100644 index 0000000..c04556f --- /dev/null +++ b/touchpad_accuracy/touchpad_ptp/phases/home_fixture.py @@ -0,0 +1,12 @@ +"""Home the probe and zero the gauge before anything is measured. + +Positional error is only comparable across units if every press starts from the +same reference, so this runs first and the rest depend on it implicitly. +""" + + +def home_fixture(log, robot, gauge): + log.info(f"Robot: {robot.identity()}") + log.info(f"Gauge: {gauge.identity()}") + gauge.zero() + log.info("Fixture homed and zeroed") diff --git a/touchpad_accuracy/touchpad_ptp/plugs/force_gauge.py b/touchpad_accuracy/touchpad_ptp/plugs/force_gauge.py new file mode 100644 index 0000000..875dac7 --- /dev/null +++ b/touchpad_accuracy/touchpad_ptp/plugs/force_gauge.py @@ -0,0 +1,42 @@ +"""Inline load cell reading the force at which the dome switch actuates.""" + +# Load cell indicator on the bench LAN. +import random + +GAUGE_RESOURCE = "TCPIP0::192.0.2.21::inst0::INSTR" + +# Ramp until the switch reports, or give up. +RAMP_LIMIT_G = 120.0 + + +class ForceGauge: + def __init__(self): + self._gauge = None + + # import pyvisa + # + # self._gauge = pyvisa.ResourceManager().open_resource(GAUGE_RESOURCE) + # self._gauge.timeout = 10_000 + # self._gauge.write("UNIT:FORCe GRAM") + + def __del__(self): + pass + + def identity(self) -> str: + # return self._gauge.query("*IDN?").strip() + return "SIMULATED-LOAD-CELL,1.0" + + def zero(self) -> None: + # self._gauge.write("SENSe:CORRection:COLLect:ZERO") + # self._gauge.query("*OPC?") + pass + + def ramp_until_actuation(self) -> float: + """Ramp force at the pad centre; return the force at the click, in grams.""" + # The switch closure is what stops the ramp, so the gauge is read on + # the edge rather than sampled and compared afterwards. + # self._gauge.write(f"SOURce:FORCe:RAMP {RAMP_LIMIT_G}") + # self._gauge.write("TRIGger:SOURce EXTernal") + # return float(self._gauge.query("FETCh:FORCe?")) + + return round(random.gauss(60.5, 3.4), 1) diff --git a/touchpad_accuracy/touchpad_ptp/plugs/touch_robot.py b/touchpad_accuracy/touchpad_ptp/plugs/touch_robot.py new file mode 100644 index 0000000..9f86a3d --- /dev/null +++ b/touchpad_accuracy/touchpad_ptp/plugs/touch_robot.py @@ -0,0 +1,71 @@ +"""Cartesian probe pressing a calibrated force at a commanded x/y target. + +Two halves on a real bench: the motion controller that puts the tip somewhere, +and the HID read that says where the pad thought it was touched. The positional +error is the distance between the two, so both have to come from the same press. +""" + +# Motion controller on the bench LAN. +import random + +ROBOT_RESOURCE = "TCPIP0::192.0.2.20::inst0::INSTR" + +# Pad geometry, mm. The border strip where the spec relaxes its limit. +PAD_W, PAD_H = 105.0, 65.0 +EDGE_BAND = 3.5 + +# Probe force held constant across the grid so error stays comparable. +PROBE_FORCE_G = 60.0 + + +class TouchRobot: + def __init__(self): + self._axes = None + self._hid = None + + # import pyvisa + # import hid + # + # self._axes = pyvisa.ResourceManager().open_resource(ROBOT_RESOURCE) + # self._axes.timeout = 10_000 + # self._axes.write("HOME") + # self._axes.query("*OPC?") + # self._axes.write(f"FORCe {PROBE_FORCE_G}") + # + # # The pad under test, as the OS sees it. + # self._hid = hid.Device(vid=0x0000, pid=0x0000) + + def __del__(self): + # self._axes.write("PARK") + pass + + def identity(self) -> str: + # return self._axes.query("*IDN?").strip() + return "SIMULATED-XY-STAGE,1.0" + + def in_edge_band(self, x: float, y: float) -> bool: + return ( + x < EDGE_BAND + or y < EDGE_BAND + or x > PAD_W - EDGE_BAND + or y > PAD_H - EDGE_BAND + ) + + def press(self, x: float, y: float) -> tuple[float, float]: + """Press at a commanded target; return the contact the pad reported.""" + # self._axes.write(f"MOVE {x:.3f} {y:.3f}") + # self._axes.query("*OPC?") + # self._axes.write("PRESS") + # self._axes.query("*OPC?") + # + # # Digitizer report: absolute X/Y in himetric (0.01 mm), the unit the + # # Precision Touchpad tests report distances in. + # report = self._hid.read(64, timeout=1000) + # reported_x = int.from_bytes(report[2:4], "little") / 100.0 + # reported_y = int.from_bytes(report[4:6], "little") / 100.0 + # return reported_x, reported_y + + # Simulated: the sensor is markedly less linear near the border, which + # is the whole reason the spec carries two limits. + sigma = 0.42 if self.in_edge_band(x, y) else 0.14 + return x + random.gauss(0.0, sigma), y + random.gauss(0.0, sigma) diff --git a/touchpad_accuracy/touchpad_ptp/procedure.yaml b/touchpad_accuracy/touchpad_ptp/procedure.yaml new file mode 100644 index 0000000..cf43eab --- /dev/null +++ b/touchpad_accuracy/touchpad_ptp/procedure.yaml @@ -0,0 +1,89 @@ +name: Touchpad Positional Accuracy +version: 1.0.0 + +unit: + auto_identify: true + serial_number: + default_value: "SN-0001" + part_number: + default_value: "TPAD-REV-A" + +plugs: + - key: robot + name: Touch Robot + python: plugs.touch_robot:TouchRobot + - key: gauge + name: Force Gauge + python: plugs.force_gauge:ForceGauge + +main: + - name: Home Fixture + key: home_fixture + python: phases.home_fixture + + # Every press is referenced to the homed position, so the grid phases wait + # for it rather than racing it. + - name: Center Linearity + python: phases.center_linearity + depends_on: + - home_fixture + measurements: + # Precision Touchpad linearity: 0.5 mm edge to edge, away from the border. + - name: Center Positional Error + title: Positional error across the central region + x_axis: + legend: Target + y_axis: + - legend: Error + unit: mm + aggregations: + - type: max + validators: + - operator: "<=" + expected_value: 0.5 + - type: mean + validators: + - operator: "<=" + expected_value: 0.35 + + - name: Edge Band Linearity + python: phases.edge_band_linearity + depends_on: + - home_fixture + measurements: + # Within 3.5 mm of an edge the same spec relaxes to 1.5 mm. + - name: Edge Positional Error + title: Positional error along the 3.5 mm edge band + x_axis: + legend: Target + y_axis: + - legend: Error + unit: mm + aggregations: + - type: max + validators: + - operator: "<=" + expected_value: 1.5 + - type: mean + validators: + - operator: "<=" + expected_value: 1.1 + # The worst single target is what fails a unit, so it is kept on its own. + - name: Worst Edge Error + unit: mm + validators: + - operator: "<=" + expected_value: 1.5 + + - name: Click Actuation Force + python: phases.click_actuation_force + depends_on: + - home_fixture + measurements: + - name: Actuation Force + unit: g + validators: + - operator: ">=" + expected_value: 50 + - operator: "<=" + expected_value: 70