Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions dvt_excel_migration/README.md
Original file line number Diff line number Diff line change
@@ -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 <uuid> \
--serial-number SN-0001 \
--part-number PCB-REV-A
```

Add `--dry-run` to see what it would create without uploading, and
`--waveforms <dir>` to promote curves to multi-dimensional measurements when
scope CSVs are available (named `<testnr>_<sample>.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.
12 changes: 12 additions & 0 deletions dvt_excel_migration/smps_dvt/phases/crossed_regulation.py
Original file line number Diff line number Diff line change
@@ -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)
39 changes: 39 additions & 0 deletions dvt_excel_migration/smps_dvt/phases/crossed_regulation_sweep.py
Original file line number Diff line number Diff line change
@@ -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)
20 changes: 20 additions & 0 deletions dvt_excel_migration/smps_dvt/phases/ripple_23v.py
Original file line number Diff line number Diff line change
@@ -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)
20 changes: 20 additions & 0 deletions dvt_excel_migration/smps_dvt/phases/ripple_9v.py
Original file line number Diff line number Diff line change
@@ -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)
31 changes: 31 additions & 0 deletions dvt_excel_migration/smps_dvt/plugs/ac_source.py
Original file line number Diff line number Diff line change
@@ -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
142 changes: 142 additions & 0 deletions dvt_excel_migration/smps_dvt/plugs/scope.py
Original file line number Diff line number Diff line change
@@ -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
Loading