Cold Plate Leak and Thermal Qualification

Qualify a direct-to-chip cold plate: N2 pressure decay, hydrostatic at 1x and 3x MOP, pressure drop as loop minus bypass, thermal resistance and a flush.

TofuPilotQuality AssurancePythonTofuPilot FrameworkGitHub
Cold Plate Leak and Thermal Qualification test setup
Run this procedure.

Get the complete source, dependencies and setup instructions from the template repository.

Open the source on GitHub

Introduction

Cold Plate Qualification Overview

A direct-to-chip liquid-cooled server has no fan moving the heat off its accelerators. A copper cold plate sits on each 1 kW class GPU or CPU through a thermal interface, coolant enters through one quick-disconnect stub, passes a field of microchannel fins a few hundred micrometres wide, and leaves through the other stub about 10 K warmer. Dozens of these plates hang in series and parallel off a tray manifold, the tray off a rack manifold, the rack off a coolant distribution unit, and the whole secondary loop runs PG25, a 25 % propylene glycol water mix with a corrosion inhibitor package. One plate that ships out of spec is not one warm chip: it is a weep at a lid braze that puts glycol on a 10 kW board, a blocked channel that throttles one accelerator in a training job of a thousand, a burr of brazing flux that lodges in the next plate's fins, or a plate shipped wet that freezes in the container.

A copper microchannel cold plate on a small plate with its lid lifted off: the field of fine parallel fins in the open body and two dark-gray quick-disconnect stubs on one edge.

The parts this test exercises: the two quick-disconnect stubs and their seals, the lid-to-body braze or weld that holds the maximum operating pressure, the fin field that sets the thermal resistance and the pressure drop, and whatever the machining and brazing left inside it.

The OCP Cold Plate Development and Qualification white paper gives a production test writer most of the numbers. Leak tightness is a pressure decay under 0.5 % over the hold (the EN 1779 pressure-change method); burst margin is a hydrostatic dwell at 1x the maximum operating pressure for 5 min then 3x for 2 min, quoted from IEC 62368-1; thermal performance is R = (Tcase - Tinlet) / P measured at several flows; pressure drop is the loop with the plate minus the same loop through a bypass, so hoses and quick-disconnects do not end up in the plate's number; cleanliness is no particle above 50 um after the flush; and the coolant velocity stays under 1.5 m/s so the copper does not erode. The OCP ACS cold plate requirements set the interfaces, the OCP Liquid Cooling Integration and Logistics white paper puts a gaseous leak test at L10 before any coolant enters a server, and the Catalina compute tray specification fixes the rack boundary this plate lives in: 40 C supply, 1.25 to 1.5 LPM/kW, 15 psi across the rack loop. Every limit in this template is one of those numbers, or says where it came from instead.

Test Purpose

The procedure records one qualification fingerprint per plate:

  • Coolant conductivity and supply temperature of the bench loop, stamped on the run as metadata, the conductivity also judged, and the dry tare mass of the plate
  • Dry N2 pressure decay at the maximum operating pressure, the whole 96 s trace, judged on the decay over the 60 s hold, with one retry for a stub not fully seated
  • Hydrostatic at 1x MOP for 5 min then 3x for 2 min with the pump isolated, the trace whole, judged on the minimum of each dwell, and the operator's deformation check as a switch bound to a measurement
  • Pressure drop at six flows around the design flow, the loop, the bypass and the difference, judged at the design flow only, with the velocity in the stubs
  • Thermal resistance at four power steps to 1 kW at the design flow, case and inlet temperature and R at each, with the energy balance closed at the outlet
  • Particle counts per size bin after a 10 min flush, judged on the bin above 50 um, and the inline filter's dP settled
  • The residual coolant after the blow-down against the dry tare, the caps confirmed, and the raw pressure log attached to the run

Case rise above the 40 °C inlet against heater power at 1.5 LPM: 4.0, 7.9, 12.0 and 16.0 K at 250, 500, 750 and 1000 W on a 0.0159 K/W line, under the 0.020 K/W limit line that reaches 20 K at 1 kW, with the case temperatures 44.1 to 56.2 °C annotated.

The mock plate's thermal curve: 16.0 K of case rise at 1 kW on a 40.1 C supply, 0.016 K/W, against a limit that allows 20 K. A plate with one channel blocked by flux runs the same line steeper and closes the heat balance short.

The framework mechanics on show are then: {fail: retry} with a retry limit of one on the pressure decay, so the common false fail gets a second seating before the run stops; an operator switch bound to a boolean measurement with bind:; run.metadata for the bench's coolant state; previous-results injection, the teardown reading the dry tare from the identify phase by naming it as a parameter; attach.data for the raw pressure log; and multi-dimensional measurements where the loop and the bypass curves are recorded next to the plate curve that is judged.

Equipment & Setup

To run this qualification on a production line, the following are required:

  • A dry N2 supply with a regulator and a pressure transducer resolving 0.01 kPa at 300 kPa gauge, for the decay
  • A fill and hydrostatic pump reaching 3x the plate's MOP with an isolation valve, so the dwells are held by the plate and not by the pump
  • A flow loop with a flow meter, a bypass valve, two differential pressure cells and a recirculating chiller holding a 40 C PG25 supply
  • A heater block with a PID controller to 1 kW, a class A RTD in its pedestal for the case temperature, and inlet and outlet RTDs in the loop
  • An inline 50 um filter with its own dP cell and a liquid particle counter on the effluent
  • A conductivity probe in the loop and a scale under the fixture resolving 0.1 g at 1 kg
  • The Device Under Test (DUT): a copper microchannel cold plate for a 1 kW class device with two quick-disconnect stubs, dry, as it leaves brazing and machining
  • A TofuPilot Framework procedure to sequence the stimuli and validate the limits
  • The TofuPilot Dashboard to keep the per-plate evidence and the pressure logs a customer's incoming inspection will ask for

Hardware Components

Flow Loop, Pressure Cells and Chiller

The plate's own pressure drop is a subtraction, so the two differential pressure cells matter more than the flow meter: an Endress+Hauser Deltabar or an equivalent wet-wet dP cell spanning 0 to 50 kPa across the plate and across the bypass, with the same tapping points, and a Coriolis or ultrasonic flow meter from Bronkhorst or Keyence resolving 0.005 LPM at 1.5. The chiller has to hold the supply within 0.1 K through a 1 kW step, which is a Julabo or Huber recirculator sized well above the heater, not a bench chiller at its limit. The N2 side is a regulator, a solenoid, and a transducer good to 0.01 kPa; the hydrostatic side is a small piston or diaphragm pump reaching 1 MPa and a valve to isolate it.

Cold plate qualification station: a copper microchannel cold plate with its fin field exposed in a light-gray fixture on the left, two dark hoses to a small off-white bench pump unit with a screen and a red pushbutton, and a production computer showing a green trace on the right.

A single-fixture station: the plate clamped on the heater block with its two stubs mated to the bench's quick-disconnects, the pump, the N2 and the loop in one enclosure beside it, the scale under the fixture.

Heater Block, Particle Counter and Scale

The heater block stands in for the accelerator: a copper pedestal with cartridge heaters driven by a Watlow controller, a class A RTD in a hole a millimetre under the contact face, and the same thermal interface material the product will use. The case temperature is measured there and nowhere else, so every R this bench reports carries the same interface. The particle counter is a light-extinction instrument on the flush effluent, a PAMAS S40 or a Beckman Coulter HIAC class, reporting counts per 100 mL in size bins; the inline filter ahead of the counter is the 50 um element the rack's CDU would carry anyway, with a dP cell across it. The scale is a Mettler Toledo precision balance under the fixture, tared once per plate before the stubs go on and read again after the blow-down. The quick-disconnects on the bench side are the rack's, Amphenol UQD, Parker or CPC depending on the program, so a stub that seals here seals in the tray.

Where the Limits Come From

TestLimitBasis
Coolant conductivity3000 to 6000 uS/cminhibited PG25 reads about 4700 uS/cm at 20 C on supplier data sheets, and the OCP PG25 base spec limits the blend water to 25 uS/cm; the window is this line's
Dry mass695 to 745 gthe plate's drawing, 720 g nominal
Pressure decayunder 0.5 % over the 60 s hold, from at least 285 kPaOCP cold plate qualification (EN 1779 method)
Hydrostaticmin 285 kPa over the 1x dwell (5 min), min 855 kPa over the 3x dwell (2 min)IEC 62368-1 as OCP quotes it for the pressures and times; the 5 % floor is this line's
Deformationnone visibleOCP hydrostatic acceptance
Pressure dropat or under 15 kPa across the plate at 1.5 LPMOCP for the loop-minus-bypass method and the Catalina rack boundary for 1.5 LPM/kW; the 15 kPa is this line's budget, two plates in series and the tray manifold inside the rack's 15 psi
Stub velocityat or under 1.5 m/sOCP erosion-corrosion limit for copper
Thermal resistancemean and full-power R at or under 0.020 K/W, case at or under 60 C, inlet 38 to 42 COCP for R = (Tcase - Tinlet) / P; 0.020 K/W is this line's derivation, a 60 C case ceiling on the Catalina 40 C supply
Heat balance90 to 110 % at 1 kWthis line's, closing the energy balance at the outlet RTD
Particulatezero counts above 50 um, filter dP risen less than 0.2 kPaOCP for the 50 um rule; the filter criterion is this line's
Residual coolantat or under 3.0 gthis line's shipping limit, about 10 % of the 28 mL fill

Two rows in that table are traps. The decay percentage depends on when the hold starts: gas compressed into a cold plate warms, then cools back over tens of seconds, and a hold that starts too early reads a thermal transient as a leak. The procedure waits 30 s after the ramp and judges the next 60 s, and the chart shows both windows. And the thermal resistance depends on the interface as much as on the plate, so the bench uses the product's own TIM and the same pedestal for every plate; a number from a different interface is a different measurement.

Test Procedure

Overview

The procedure maps the qualification onto the framework's three stages. The tare and the coolant state live in setup: with the N2 decay chained after them, so the plate is weighed dry before the stubs go on and no coolant enters a plate that leaks gas. The drain, the blow-down and the residual live in teardown: so a plate goes back on the scale dry even when a main phase fails.

  1. Setup: dry tare on the scale, coolant conductivity and supply temperature on the run.
  2. Setup: dry N2 to 300 kPa, 30 s stabilise, 60 s hold, one retry.
  3. Main: fill with PG25, 300 kPa for 5 min then 900 kPa for 2 min, deformation switch.
  4. Main: six flows from 0.75 to 2.0 LPM, loop and bypass dP, plate dP and stub velocity.
  5. Main: 250 to 1000 W at 1.5 LPM, R at each step, heat balance at 1 kW.
  6. Main: 10 min flush at 3 LPM, particle counts per bin, filter dP settled.
  7. Teardown: drain, blow-down, caps, residual against the tare, pressure log attached.

Every metric validates against limits declared in procedure.yaml, and results stream to TofuPilot as the per-plate evidence.

Why TofuPilot Framework?

TofuPilot Framework is a YAML + Python test framework built for hardware manufacturing. Instead of writing all your test logic, measurements, and limits inside Python code, you describe what the test does in a procedure.yaml file, and how in small Python phase files. The framework handles:

  • Automatic Python environment management (via uv)
  • Operator UI (no frontend code needed)
  • Measurement validation and live charts
  • Process isolation between phases and equipment plugs

Project Structure

procedure.yaml
phases
identify.py
dry_pressure_decay.py
hydrostatic.py
flow_pressure_drop.py
thermal_resistance.py
particulate_flush.py
drain_and_cap.py
plugs
cold_plate_bench.py
utils
recipe.py
ui.json
pyproject.toml
README.md

You can find the full source on GitHub. The ColdPlateBench plug is a mock of the N2 supply, the pump, the flow loop with its dP cells, the heater block with its RTDs, the filter, the particle counter, the conductivity probe and the scale together, synthesizing a healthy plate that decays 0.08 % over the hold, drops 9.96 kPa at 1.5 LPM and runs 0.016 K/W at 1 kW, so the procedure runs end-to-end without a bench or a plate connected.

tofupilot run .

For CI or bench automation, the same run executes headless, with the deformation switch answered from ui.json:

tofupilot run . --no-tui --no-kiosk --json --ui-values ui.json --ui-timeout 60

The Procedure File

procedure.yaml declares the unit, the bench plug with the label serial as constructor argument, and the three stages with every measurement and limit:

procedure.yaml
236 lines
name: Cold Plate Leak and Thermal Qualificationversion: 0.1.0description: Qualification of a direct-to-chip liquid cold plate for a 1 kW class GPU or CPU, OCP ACS style, PG25 coolant. Coolant conductivity and supply temperature on the run, dry N2 pressure decay before any coolant with one retry for a QD not seated, hydrostatic at 1x then 3x the maximum operating pressure with the operator's deformation check bound to a switch, the plate's own pressure drop as loop minus bypass across a flow sweep, thermal resistance at four power steps with the energy balance closed at the outlet, a flush judged on nothing above 50 um, and a teardown that drains, caps, weighs the residual against the dry tare and attaches the raw pressure log.unit:  auto_identify: true  serial_number:    description: "Scan the laser mark on the plate's lid"    placeholder: "CP-1K-000000"    pattern: "^CP-1K-\\d{6}$"    default_value: "CP-1K-003118"  part_number:    default_value: "CP-1K-CU-QD6"  batch_number:    default_value: "LOT-2026-38"plugs:  - name: Cold Plate Bench    description: "N2 supply, fill and hydrostatic pump, flow loop with bypass and two dP cells, heater block with RTDs, inline filter and particle counter, conductivity probe and scale (mock, one plug per bench)"    python: plugs.cold_plate_bench:ColdPlateBench    key: bench    config:      serial_number: "CP-1K-003118"setup:  - name: Identify    key: identify    python: phases.identify    measurements:      - name: Coolant Conductivity        key: coolant_conductivity_us_cm        unit: µS/cm        description: "The bench loop's PG25, at the conductivity probe. Inhibited PG25 reads about 4700 uS/cm (supplier data sheets); a low reading is a loop topped up with water, a high one is contamination. The window is this line's."        validators:          - {operator: ">=", expected_value: 3000.0}          - {operator: "<=", expected_value: 6000.0}      - name: Dry Mass        key: dry_mass_g        unit: g        description: "Dry tare on the scale before anything is connected, 720 g nominal. A plate outside the window is missing a fin field or carries the wrong lid; the teardown subtracts this from the wet mass."        validators:          - {operator: ">=", expected_value: 695.0}          - {operator: "<=", expected_value: 745.0}  - name: Dry Pressure Decay    key: dry_pressure_decay    python: phases.dry_pressure_decay    depends_on: [identify]    retry:      limit: 1    then:      fail: retry    measurements:      - name: Pressure Decay        key: pressure_decay        title: Dry N2 at the maximum operating pressure, 30 s stabilise, 60 s hold        x_axis:          legend: Time          unit: s        y_axis:          - legend: Pressure            key: pressure            unit: kPa            aggregations:              - type: hold_start_kpa                unit: kPa                validators:                  - {operator: ">=", expected_value: 285.0}              - type: decay_pct                unit: "%"                validators:                  - {operator: "<=", expected_value: 0.5}main:  - name: Hydrostatic    key: hydrostatic    python: phases.hydrostatic    ui:      components:        - key: deformation_check          type: switch          label: "No visible deformation after the 3x dwell"          description: "Lid flat under the straightedge, no bulge over the fin field, no weep at the stubs or the lid seam"          required: true          bind: measurements.no_deformation    measurements:      - name: Hydrostatic Pressure        key: hydrostatic        title: Filled with PG25, 1x MOP for 5 min then 3x MOP for 2 min, pump isolated during each dwell        x_axis:          legend: Time          unit: s        y_axis:          - legend: Pressure            key: pressure            unit: kPa            aggregations:              - type: min_1x_dwell_kpa                unit: kPa                validators:                  - {operator: ">=", expected_value: 285.0}              - type: min_3x_dwell_kpa                unit: kPa                validators:                  - {operator: ">=", expected_value: 855.0}      - name: No Deformation        key: no_deformation        description: "The operator's inspection after the 3x dwell, recorded through the switch bound to this measurement."        validators:          - {operator: "==", expected_value: true}  - name: Flow and Pressure Drop    key: flow_pressure_drop    python: phases.flow_pressure_drop    depends_on: [hydrostatic]    measurements:      - name: Flow Curve        key: flow_curve        title: Pressure drop across the plate as loop minus bypass, six flows around 1.5 LPM        x_axis:          legend: Flow          unit: LPM        y_axis:          - legend: Plate dP (loop minus bypass)            key: plate_dp            unit: kPa            aggregations:              - type: at_design_flow_kpa                unit: kPa                validators:                  - {operator: "<=", expected_value: 15.0}          - legend: Loop dP with plate            key: loop_dp            unit: kPa          - legend: Bypass dP            key: bypass_dp            unit: kPa          - legend: Velocity in the QD stubs            key: stub_velocity            unit: m/s            aggregations:              - type: at_design_flow_m_s                unit: m/s                validators:                  - {operator: "<=", expected_value: 1.5}  - name: Thermal Resistance    key: thermal_resistance    python: phases.thermal_resistance    depends_on: [flow_pressure_drop]    timeout: 20m    measurements:      - name: Thermal Curve        key: thermal_curve        title: Case and inlet temperature and R = (Tcase - Tinlet) / P at four power steps, 1.5 LPM, 40 C supply        x_axis:          legend: Heater power          unit: W        y_axis:          - legend: Case temperature            key: tcase_c            unit: °C            aggregations:              - type: max_c                unit: °C                validators:                  - {operator: "<=", expected_value: 60.0}          - legend: Inlet temperature            key: tin_c            unit: °C            aggregations:              - type: mean_c                unit: °C                validators:                  - {operator: ">=", expected_value: 38.0}                  - {operator: "<=", expected_value: 42.0}          - legend: Thermal resistance            key: r_k_per_w            unit: K/W            aggregations:              - type: mean_k_per_w                unit: K/W                validators:                  - {operator: "<=", expected_value: 0.020}              - type: at_full_power_k_per_w                unit: K/W                validators:                  - {operator: "<=", expected_value: 0.020}      - name: Coolant Heat Balance        key: coolant_heat_balance_pct        unit: "%"        description: "Heat carried away by the coolant (mass flow, cp, outlet minus inlet) as a share of the heater power at 1 kW. Below 90 % a channel is blocked or the case RTD sits on a hot spot; above 110 % the flow meter or an RTD is wrong."        validators:          - {operator: ">=", expected_value: 90.0}          - {operator: "<=", expected_value: 110.0}  - name: Particulate Flush    key: particulate_flush    python: phases.particulate_flush    depends_on: [thermal_resistance]    timeout: 20m    measurements:      - name: Particle Counts        key: particle_counts        title: Effluent particle counts per 100 mL by size bin after a 10 min flush at 3 LPM        x_axis:          legend: Size bin lower edge          unit: µm        y_axis:          - legend: Count per 100 mL            key: count            aggregations:              - type: over_50um                validators:                  - {operator: "==", expected_value: 0}      - name: Filter dP Stable        key: filter_dp_stable        description: "The inline 50 um filter's dP rose by less than 0.2 kPa over the flush, so the plate had stopped shedding when the flush ended."        validators:          - {operator: "==", expected_value: true}teardown:  - name: Drain and Cap    key: drain_and_cap    python: phases.drain_and_cap    measurements:      - name: Residual Coolant        key: residual_coolant_g        unit: g        description: "Wet mass after the N2 blow-down minus the dry tare from identify. About 10 % of the 28 mL fill is this line's shipping limit for a plate shipped drained."        validators:          - {operator: "<=", expected_value: 3.0}      - name: QDs Capped        key: qds_capped        validators:          - {operator: "==", expected_value: true}

Framework features to notice:

  1. then: {fail: retry} with retry: {limit: 1}. A stub not fully seated on the bench's quick-disconnect is the common reason a good plate fails the decay. The phase gets one more attempt before the run stops; every attempt is kept, and the dashboard shows both traces side by side.
  2. A switch bound to a measurement. The operator's inspection after the 3x dwell is a boolean the YAML validates with ==, through bind: measurements.no_deformation. The form is up while the trace records; the phase waits for the answer once it is done.
  3. run.metadata for the bench state. The coolant, its conductivity and the supply temperature go onto the run, so every R in the dashboard can be filtered by the loop it was measured on.
  4. Previous results by parameter name. drain_and_cap takes identify as a parameter and reads identify.dry_mass_g; the residual is a subtraction across two phases without a global.
  5. attach.data for the raw log. The decay and the hydrostatic traces go with the run as one JSON file, so a customer's incoming inspection gets the transducer samples, not a summary.
  6. Curves recorded next to the one judged. flow_curve carries the loop, the bypass and the difference on one x-axis; only plate_dp has an aggregation with a limit, the other two stay for the review.
  7. setup: chained, teardown: always. The decay depends on the identify phase so the plate is weighed before the stubs go on; the drain runs whatever a main phase did.

Identify

The plate is on the scale, dry, with nothing connected. The bench loop's conductivity and supply temperature are read once and written to the run as metadata; the conductivity is also a measurement with a window, because a loop topped up with water has half the inhibitor and a diluted coolant transfers heat differently from the one the rack will run. The dry mass is the tare the teardown will subtract, and a plate outside its window is missing a fin field or carries the wrong lid.

phases/identify.py
from utils.recipe import COOLANTdef identify(measurements, bench, unit, run, log):    """Setup: the plate is on the scale, dry, with nothing connected. The    bench's coolant conductivity and supply temperature go onto the run as    metadata, so a thermal resistance measured against a diluted or a warm    loop can be found later without opening the trace."""    cond = bench.coolant_conductivity_us_cm()    tin = bench.inlet_temp_c()    dry = bench.dry_mass_g()    run.metadata["coolant"] = COOLANT    run.metadata["coolant_conductivity_us_cm"] = cond    run.metadata["coolant_supply_c"] = tin    measurements.coolant_conductivity_us_cm = cond    measurements.dry_mass_g = dry    unit.metadata["dry_mass_g"] = dry    log.info(f"{unit.serial_number}: {dry:.1f} g dry, {COOLANT} at {cond:.0f} uS/cm, supply {tin:.2f} C")

Dry Pressure Decay

The OCP L10 gaseous leak step, before any coolant. Dry N2 to the maximum operating pressure, then 30 s for the gas to give back the heat of compression, then a 60 s hold judged on the first sample minus the last. The trace is recorded whole; the phase attaches the pressure at the start of the hold, so a hold that never reached the test pressure fails on its own aggregation rather than passing on a small decay of a small number.

phases/dry_pressure_decay.py
25 lines
import numpy as npfrom utils.recipe import DECAY_HOLD_S, DECAY_STABILISE_S, DECAY_TEST_KPAdef dry_pressure_decay(measurements, bench, log):    """Setup: the OCP L10 gaseous leak step before any coolant. Dry N2 to    the test pressure, 30 s for the gas to give back its compression heat,    then a 60 s hold judged on the decay between its first and last sample.    A quick-disconnect not fully seated is the common false fail, which is    why the YAML retries this phase once before stopping the run."""    t_s, p_kpa = bench.n2_pressure_decay(DECAY_TEST_KPA, DECAY_STABILISE_S, DECAY_HOLD_S)    bench.vent()    t = np.asarray(t_s)    p = np.asarray(p_kpa)    hold_start = t[-1] - DECAY_HOLD_S    hold = p[t >= hold_start]    decay_pct = 100.0 * (hold[0] - hold[-1]) / hold[0]    m = measurements.pressure_decay    m.x_axis = t_s    m.y_axis.pressure = p_kpa    m.y_axis.pressure.aggregations.hold_start_kpa = round(float(hold[0]), 2)    m.y_axis.pressure.aggregations.decay_pct = round(float(decay_pct), 3)    log.info(f"N2 hold from {hold[0]:.2f} to {hold[-1]:.2f} kPa over {DECAY_HOLD_S:.0f} s: {decay_pct:.3f} % (mock: the {DECAY_STABILISE_S + DECAY_HOLD_S:.0f} s trace is returned at once)")

Dry N2 pressure over 96 s: a ramp to 305.5 kPa, a 30 s stabilise window where the compression heat relaxes to 300 kPa, then a 60 s hold from 299.98 kPa decaying 0.084 %, well above the 0.5 % limit floor at 298.48 kPa drawn across the hold only.

The mock plate's decay: 5.5 kPa of compression heat relaxing with a 7 s time constant through the stabilise window, then 0.25 kPa lost over the hold, 0.084 % against 0.5 %. The mock returns the 96 s trace in one call; a real bench records it. A stub seated on its first detent but not its second shows a decay ten times steeper, and the YAML's then: {fail: retry} gives the operator one re-seat before the run stops.

Hydrostatic

Filled with PG25 from the bench pump, ramped to 1x the maximum operating pressure and held for 5 min with the pump isolated, then to 3x for 2 min, then vented. The phase finds each dwell in the trace and reports its minimum; a leak, a weeping braze or a lid lifting off its seal shows as a falling trace during the dwell, not as a pump working harder. The operator's inspection after the 3x dwell lands as no_deformation through the switch bound to it.

phases/hydrostatic.py
34 lines
import numpy as npfrom utils.recipe import HYDRO_1X_DWELL_S, HYDRO_3X_DWELL_S, MOP_KPAdef dwell_min(t, p, target_kpa, dwell_s):    """Lowest pressure inside the dwell at the stated target: the samples    within 2 % of the target, the last dwell_s worth of them."""    at_target = np.where(np.abs(p - target_kpa) < 0.02 * target_kpa)[0]    end = t[at_target[-1]]    window = (t > end - dwell_s) & (t <= end)    return float(p[window].min())def hydrostatic(measurements, bench, log):    """Filled with PG25, 1x the maximum operating pressure for 5 min then 3x    for 2 min (IEC 62368-1 as OCP quotes it), pump isolated during each    dwell so a leak shows as a falling trace. The operator's inspection of    the plate after the 3x dwell lands as the no_deformation measurement    through the switch bound to it; the phase waits for that answer once the    trace is recorded."""    bench.fill()    t_s, p_kpa = bench.hydrostatic(MOP_KPA, HYDRO_1X_DWELL_S, HYDRO_3X_DWELL_S)    t = np.asarray(t_s)    p = np.asarray(p_kpa)    min_1x = dwell_min(t, p, MOP_KPA, HYDRO_1X_DWELL_S)    min_3x = dwell_min(t, p, 3.0 * MOP_KPA, HYDRO_3X_DWELL_S)    m = measurements.hydrostatic    m.x_axis = t_s    m.y_axis.pressure = p_kpa    m.y_axis.pressure.aggregations.min_1x_dwell_kpa = round(min_1x, 1)    m.y_axis.pressure.aggregations.min_3x_dwell_kpa = round(min_3x, 1)    log.info(f"1x MOP dwell held {min_1x:.1f} kPa of {MOP_KPA:.0f}, 3x dwell {min_3x:.1f} kPa of {3 * MOP_KPA:.0f} (mock: the {t[-1]:.0f} s trace is returned at once)")

Hydrostatic pressure over 460 s with the plate filled with PG25: a ramp to 300 kPa held for 5 min with a minimum of 298.2 kPa above the 285 kPa floor, a ramp to 900 kPa held for 2 min with a minimum of 897.0 kPa above the 855 kPa floor, then the vent.

The mock plate holds 298.2 kPa of 300 and 897.0 of 900, the small settle coming from the hoses' compliance. The 5 and 2 min dwells are the spec's; the mock returns the 460 s trace in one call, so the run does not wait the seven minutes a real bench does.

Flow and Pressure Drop

Six flows from half to a third above the design flow. At each point the loop dP is read with the plate in, then again through the bypass with the plate isolated, and the plate's own dP is the difference: the hoses, the quick-disconnects and the fixture are the same in both readings and cancel. The velocity in the stubs comes from the measured flow and the bore, and is the OCP erosion limit for copper.

phases/flow_pressure_drop.py
38 lines
import mathimport numpy as npfrom utils.recipe import DESIGN_FLOW_LPM, FLOW_SWEEP_LPM, PORT_ID_MMdef flow_pressure_drop(measurements, bench, log):    """Flow sweep at six points around the design flow. At each point the    loop dP is read with the plate in and again through the bypass, and the    plate's own dP is the difference (OCP), so the hoses, the QDs and the    fixture do not end up in the plate's number. The velocity in the QD    stubs comes from the measured flow and the bore."""    area_m2 = math.pi * (PORT_ID_MM / 2000.0) ** 2    flows, net, loop, bypass, velocity = [], [], [], [], []    for q in FLOW_SWEEP_LPM:        bench.set_flow_lpm(q)        measured = bench.flow_lpm()        with_plate = bench.dp_kpa("plate")        through_bypass = bench.dp_kpa("bypass")        flows.append(measured)        loop.append(with_plate)        bypass.append(through_bypass)        net.append(with_plate - through_bypass)        velocity.append(measured / 60000.0 / area_m2)        log.info(f"{measured:.3f} LPM: loop {with_plate:.2f} kPa, bypass {through_bypass:.2f} kPa, plate {net[-1]:.2f} kPa, {velocity[-1]:.2f} m/s in the stubs")    bench.set_flow_lpm(DESIGN_FLOW_LPM)    at_design = int(np.argmin(np.abs(np.asarray(flows) - DESIGN_FLOW_LPM)))    m = measurements.flow_curve    m.x_axis = [round(q, 3) for q in flows]    m.y_axis.plate_dp = [round(v, 3) for v in net]    m.y_axis.plate_dp.aggregations.at_design_flow_kpa = round(net[at_design], 2)    m.y_axis.loop_dp = loop    m.y_axis.bypass_dp = bypass    m.y_axis.stub_velocity = [round(v, 3) for v in velocity]    m.y_axis.stub_velocity.aggregations.at_design_flow_m_s = round(velocity[at_design], 3)    log.info(f"Plate dP {net[at_design]:.2f} kPa at {flows[at_design]:.2f} LPM, {velocity[at_design]:.2f} m/s in a {PORT_ID_MM:.0f} mm bore")

Pressure drop against flow from 0.75 to 2.0 LPM: the loop with the plate in gray, the bypass dashed gray, and the plate as loop minus bypass in green reaching 9.96 kPa at the 1.5 LPM design flow, under the 15 kPa limit marker drawn at the design flow only.

The mock plate: 9.96 kPa at 1.5 LPM and 0.88 m/s in a 6 mm bore, with the bypass taking 4 kPa of the 14 the loop shows. The curve is a laminar term in the fins plus a square term in the manifolds, so it bends upward; the limit applies at the design flow only and is drawn there only, because the same plate at 2 LPM reads 15 kPa and is not failing anything.

Thermal Resistance

Four power steps to 1 kW at the design flow, the supply held at 40 C, 180 s of settling at each on a real bench. R = (Tcase - Tinlet) / P at every step; the mean and the full-power value each carry the limit. The outlet RTD closes the energy balance at 1 kW: the coolant must carry away what the heater put in, within 10 %, or a channel is blocked and the case RTD is reading a hot spot the plate is not cooling. The inlet mean is judged too, since an R measured against a drifting supply is not an R.

phases/thermal_resistance.py
36 lines
import numpy as npfrom utils.recipe import DESIGN_FLOW_LPM, PG25_CP_J_PER_KG_K, PG25_DENSITY_KG_L, POWER_STEPS_W, STEADY_STATE_Sdef thermal_resistance(measurements, bench, log):    """Heater block at four power steps up to 1 kW at the design flow, the    supply held at 40 C. R = (Tcase - Tinlet) / P at each step (OCP). The    outlet RTD closes the energy balance at full power: the coolant must    carry away what the heater put in, or a channel is blocked and the    case RTD is reading a hot spot the plate is not cooling."""    bench.set_flow_lpm(DESIGN_FLOW_LPM)    m_cp = DESIGN_FLOW_LPM / 60.0 * PG25_DENSITY_KG_L * PG25_CP_J_PER_KG_K    tin_c, tcase_c, r = [], [], []    balance = None    for p in POWER_STEPS_W:        bench.set_heater_w(p)        log.info(f"{p:.0f} W: settle {STEADY_STATE_S:.0f} s (mock: instant)")        tin, tout, tcase = bench.rtds_c()        tin_c.append(tin)        tcase_c.append(tcase)        r.append((tcase - tin) / p)        balance = 100.0 * m_cp * (tout - tin) / p        log.info(f"{p:.0f} W: inlet {tin:.2f} C, outlet {tout:.2f} C, case {tcase:.2f} C, R {r[-1]:.4f} K/W, coolant carries {balance:.1f} %")    bench.set_heater_w(0.0)    m = measurements.thermal_curve    m.x_axis = POWER_STEPS_W    m.y_axis.tcase_c = tcase_c    m.y_axis.tcase_c.aggregations.max_c = round(max(tcase_c), 2)    m.y_axis.tin_c = tin_c    m.y_axis.tin_c.aggregations.mean_c = round(float(np.mean(tin_c)), 2)    m.y_axis.r_k_per_w = [round(v, 5) for v in r]    m.y_axis.r_k_per_w.aggregations.mean_k_per_w = round(float(np.mean(r)), 4)    m.y_axis.r_k_per_w.aggregations.at_full_power_k_per_w = round(r[-1], 4)    measurements.coolant_heat_balance_pct = round(balance, 1)

The 0.020 K/W limit is not in the OCP paper; it is what a 60 C case ceiling leaves on a 40 C supply for a 1 kW device, and a plate that passes it at this interface has the headroom the product's own TIM tolerance will consume.

Particulate Flush

Ten minutes at twice the design flow through the inline 50 um filter, the effluent through the particle counter. The counts per 100 mL in each size bin are recorded as one curve, and the bin above 50 um is the one judged, at zero. The filter's own dP is read at the start and at the end of the flush: a filter still loading when the flush ends is a plate still shedding, whatever the counter said in the last sample.

phases/particulate_flush.py
from utils.recipe import FILTER_DP_RISE_MAX_KPA, FLUSH_FLOW_LPM, FLUSH_MIN, PARTICLE_BINS_UMdef particulate_flush(measurements, bench, log):    """Flush at twice the design flow through the inline 50 um filter with    the effluent through the particle counter. OCP's rule is nothing above    50 um; the counts per 100 mL in every size bin are recorded, and the    filter's own dP must have stopped rising, or the flush ended while the    plate was still shedding."""    counts, dp_start, dp_end = bench.flush(FLUSH_FLOW_LPM, FLUSH_MIN)    rise = dp_end - dp_start    m = measurements.particle_counts    m.x_axis = PARTICLE_BINS_UM    m.y_axis.count = counts    m.y_axis.count.aggregations.over_50um = counts[-1]    measurements.filter_dp_stable = rise < FILTER_DP_RISE_MAX_KPA    log.info(f"{FLUSH_MIN:.0f} min at {FLUSH_FLOW_LPM:.1f} LPM (mock: instant): counts {counts} per 100 mL from {[int(b) for b in PARTICLE_BINS_UM]} um, filter dP {dp_start:.2f} to {dp_end:.2f} kPa")

Drain and Cap

The teardown drains the plate, blows it down with N2 for 60 s, fits the shipping caps and puts the plate back on the scale. The residual is the wet mass minus the dry tare that the identify phase recorded, read through the injected result of that phase. The raw pressure traces of the decay and the hydrostatic go with the run as one JSON attachment.

phases/drain_and_cap.py
import jsonfrom utils.recipe import BLOW_DOWN_Sdef drain_and_cap(measurements, bench, attach, identify, log):    """Teardown: drain, N2 blow-down, caps on both quick-disconnects, the    plate back on the scale. The residual is the wet mass minus the dry    tare the identify phase recorded, read through the injected result of    that phase. The raw pressure traces of the decay and the hydrostatic    go with the run as one JSON attachment."""    bench.drain_and_blow_down(BLOW_DOWN_S)    capped = bench.cap_qds()    residual = bench.wet_mass_g() - identify.dry_mass_g    measurements.residual_coolant_g = round(residual, 2)    measurements.qds_capped = capped    attach.data(json.dumps(bench.pressure_log()).encode(), "pressure-log.json")    log.info(f"Blow-down {BLOW_DOWN_S:.0f} s (mock: instant), {residual:.2f} g of coolant left, caps {'fitted' if capped else 'missing'}, pressure log attached")

Mock Plug

ColdPlateBench stands in for the whole bench and the plate because every reading depends on what the pump and the heater are doing at that instant, and plugs run in separate processes. Its decay is 5.5 kPa of compression heat with a 7 s time constant over a leak of 0.0027 kPa/s, its hydrostatic dwells settle 0.9 and 2.5 kPa on the hose compliance, its pressure drop is 4.2 kPa/LPM in the fins plus 1.6 kPa/LPM squared in the manifolds with a bypass of 1.78 kPa/LPM squared, its thermal resistance 0.0157 K/W rising 0.0003 per kW, its coolant carrying 97 % of the heater power, its effluent 286, 71, 6 and 0 counts per 100 mL from 5, 10, 25 and 50 um, and its residual 1.8 g. Time is scaled: the 96 s decay, the 460 s hydrostatic, the 180 s settles and the 10 min flush are returned as traces or values in one call, and each phase logs that it did so. Every method returns plain Python types because plug calls cross a JSON boundary; a measurement read back from measurements.<key> returns a proxy, so the phases keep locals for their log lines.

On a real bench, split the class into the N2 side, the pump and its isolation valve, the flow loop with its meter and dP cells, the chiller, the heater controller and the RTD reader, the particle counter, the conductivity probe and the scale, over Modbus, SCPI or their vendor drivers. Record the pressure traces at the transducer's rate and let the phases compute the decay and the dwell minima; wait the real 180 s per power step and the real 10 min flush, the timeout values are sized for them; and keep the same heater pedestal and thermal interface for every plate. The phases, measurements and limits stay the same.

Run your first test in minutes