Reproducing Kim et al. (2023) from dandiset 000633¶

Paper. Kim V, Semenov I, Kiester AS, Keppler MA, Ibey BL, Bixler JN, Colunga Biancatelli RML, Pakhomov AG (2023). Control of the electroporation efficiency of nanosecond pulses by swinging the electric field vector direction. Int. J. Mol. Sci. 24, 10921. doi:10.3390/ijms241310921.

Claims file. claims/000633/10.3390-ijms241310921.yaml, written from the version of record (open access, Europe PMC PMC10341945). The paper has no supplement and links no code.

Dandiset. DANDI:000633, version 0.231013.2226. The paper cites no version of the dandiset. This is the only published version, and the most recent; the draft lists the same two assets with the same sizes and was last modified when this version was published (2023-10-13), so the two do not differ in anything the analysis uses.

What the dandiset holds. Two NWB files, each one stitched whole-dish fluorescence image of a BPAE cell monolayer in two channels (MemBrite and YO-PRO-1): Single-pulse_trains and Paired-pulse_trains. The dandiset's title is the legend of Figure 4 ("The difference in electroporation patterns produced by a train of single pulses and a train of paired pulses"), so these are taken as the images of Figure 4A (single pulses) and Figure 4B (paired pulses). The session description of the single-pulse file describes another exposure (see the data check), so this identification rests on the file names, the dandiset's title and the images themselves (three electrodes in a triangle, uptake around one base electrode), and both Figure 4 outcomes rest on it. The ROI measurements of Figure 2 (n = 3 at each of 1 Hz and 833 kHz), the square-array experiments of Figure 3 and the strobe imaging of Figure 5 are not in it.

Outside inputs. None. Two claims need the electric field map of the paper's Sim4Life simulation (Methods 4.3), which is not deposited. It is computed here from the geometry the paper states and the electrode positions measured in the images (see swing.py); this substitutes for the paper's solver and is recorded as a deviation.

Claim Tested here
fig2-cancellation-at-180 Blocked: data missing (Figure 2 data not deposited)
fig2-crossover-angle Blocked: data missing
fig2-facilitation-plateau Blocked: data missing
fig2-60-fold-co-counter Blocked: data missing
fig3-1hz-co-cross-same Blocked: data missing (Figure 3 data not deposited)
fig3-770khz-co-enhanced Blocked: data missing
fig3-770khz-cross-same-as-1hz Blocked: data missing
fig4a-single-pulse-follows-field Tested
fig4bc-paired-pulse-focal-apex Tested
fig4-comet-shape Tested
fig5-base-sawtooth Blocked: data missing (Figure 5 data not deposited)
fig5-apex-larger-polarization Blocked: data missing

For the Figure 2 claims, a section near the end computes, for information only, the 833 kHz angle curve that the two Figure 4 images allow (one dish each, not the paper's n = 3, and no 1 Hz data). It decides no outcome.

In [1]:
import time
import warnings

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from scipy import stats
from scipy.ndimage import median_filter, uniform_filter

import swing

pd.set_option("display.width", 200)
warnings.filterwarnings("ignore", category=RuntimeWarning)

started = time.time()
assets = swing.assets()
meta, img = {}, {}
for a in assets:
    m, im = swing.load_binned(a["url"])
    m["path"], m["size"], m["url"] = a["path"], a["size"], a["url"]
    meta[m["session_id"]], img[m["session_id"]] = m, im
SINGLE, PAIRED = "Single-pulse_trains", "Paired-pulse_trains"
print(f"dandiset {swing.DANDISET_ID} version {swing.DANDISET_VERSION}: {len(assets)} files streamed "
      f"in {time.time() - started:.0f} s, binned {swing.BIN} x {swing.BIN}")
dandiset 000633 version 0.231013.2226: 2 files streamed in 77 s, binned 16 x 16

Data check¶

Each check of the description against the values is stated under its output.

In [2]:
for s in (SINGLE, PAIRED):
    m = meta[s]
    print(f"== {m['path']} ({m['size'] / 1e9:.2f} GB, {m['bytes_fetched'] / 1e9:.2f} GB fetched)")
    print("  session_id / subject_id:", m["session_id"], "/", m["subject_id"], "| strain:", m["strain"])
    print("  data shape", m["shape"], m["dtype"], "chunks", m["chunks"], "unit", repr(m["unit"]),
          "ImageSeries rate", m["rate"], "Hz")
    for c, st in enumerate(m["full_stats"]):
        print(f"  channel {c}: min {st['min']}, max {st['max']}, mean {st['mean']:.1f}, "
              f"fraction of zero pixels {st['zero_fraction']:.3f}")
    print("  session_description:", m["session_description"])
print("\nimage description (same in both files):", meta[SINGLE]["image_description"])
== sub-Single-pulse-trains/sub-Single-pulse-trains_ses-Single-pulse-trains_image.nwb (1.08 GB, 1.08 GB fetched)
  session_id / subject_id: Single-pulse_trains / Single-pulse_trains | strain: cellline: BPAE
  data shape (22323, 22323, 2) uint16 chunks (175, 349, 1) unit 'n.a.' ImageSeries rate 2.0 Hz
  channel 0: min 0, max 65444, mean 355.0, fraction of zero pixels 0.163
  channel 1: min 0, max 65448, mean 354.1, fraction of zero pixels 0.163
  session_description: Cells were electroporated by 4 co-directional paired-pulse (600 + 600 ns) trains, trains were applied at frequency of 770 kHz, amplitude 10 kV/cm. The stainless-steel electrodes were aranged in triangular array; The electric field direction stayed the same in each pair.
== sub-Paired-pulse-trains/sub-Paired-pulse-trains_ses-Paired-pulse-trains_image.nwb (1.06 GB, 1.06 GB fetched)
  session_id / subject_id: Paired-pulse_trains / Paired-pulse_trains | strain: cellline: BPAE
  data shape (22323, 22323, 2) uint16 chunks (175, 349, 1) unit 'n.a.' ImageSeries rate 2.0 Hz
  channel 0: min 0, max 65441, mean 328.2, fraction of zero pixels 0.163
  channel 1: min 0, max 65434, mean 293.5, fraction of zero pixels 0.163
  session_description: In this experimental setup, a cell monolayer is subjected to a train of electrical pulses, delivered through a triangular electrode array where two base electrodes are connected to two separate channels. The innovative design ensures that as one channel is energized, the other is automatically grounded, and vice versa. This dynamic allows for the efficient electroporation of the cells with a train of 5 paired-pulses, each consisting of a 600+600 ns duration. The application occurs at a frequency of 833 kHz and an amplitude of 2 kV, ensuring precise and controlled exposure.

image description (same in both files): IX83 microscope used; MS-2000 scanning stage; X-Cite 110LED illuminator; ORCA-Flash4 sCMOS camera;CellSens software for automation; Channel 1 ex/em 350/460nm for MemBrite dye; ex/em 480/535nm for YOPRO-1; dark circles indicate electrode footprints; 144 images captured in a 12x12 grid from a six-well plate; 216 additional images with DAPI and FITC filter cubes; all images automatically stitched for a final composite; 10×, 0.38NA objective used for high-resolution imaging.

The image is stored as an ImageSeries of shape (22323, 22323, 2) with a rate of 2 Hz. NWB reads the first axis of an ImageSeries as time, so a standard reader sees 22323 frames of 22323 x 2 pixels; the data are one image of 22323 x 22323 pixels in two channels, as the description of stitched tiles says. No pixel size, channel names or intensity unit (n.a.) are stored; the channel order comes only from the free-text description (MemBrite listed first, then YO-PRO-1), and is checked below. About 16% of pixels are exactly zero in every channel: the corners outside the scanned tiles.

The session description of Single-pulse_trains describes another experiment: "4 co-directional paired-pulse (600 + 600 ns) trains ... 770 kHz, amplitude 10 kV/cm", which are the settings of Figure 3 (Section 2.2; there, a square array of four electrodes). The image shows three electrode footprints in a triangle (below), as in Figure 4A, and its uptake lies around one base electrode, as in Figure 4A. The file is taken as Figure 4A from its name, the dandiset's title and this layout; the description contradicts it, and nothing in the data can settle which exposure the image records. Figure 4A is five single 600 ns pulses at 833 kHz and 4 kV; if the image were instead four co-directional pairs at 770 kHz, it would be an exposure in which "the electric field direction stayed the same in each pair", as the description says. The description does not say which electrodes were energized; that the field came from one base electrode is an inference from the uptake around one of them. The tests of fig4a and fig4bc rest on this identification. The Paired-pulse_trains description gives an amplitude of 2 kV, while the paper gives 4 kV for both Figure 4 images (Section 2.3); the field check below bears on this. The description mentions a six-well plate; the paper used 35 mm glass-bottom dishes (Methods 4.1). Subject age is "P1D" for a cell line, and each "subject" is a condition, not an animal.

In [3]:
# Electrode footprints and image scale (swing.find_footprints), and channel identity.
fps = {s: swing.find_footprints(img[s][1]) for s in (SINGLE, PAIRED)}
rows = []
for s in (SINGLE, PAIRED):
    for f in fps[s]:
        rows.append({"image": s, "x_bin": round(f["x"], 1), "y_bin": round(f["y"], 1), "radius_bin": round(f["r"], 1),
                     "edge_sd_bin": round(f["edge_sd"], 1), "edge_points_of_72": f["edge_points"],
                     "coarse_contrast": round(f["contrast"], 3)})
fp_table = pd.DataFrame(rows)
print(fp_table.to_string(index=False))

r_paired = np.mean([f["r"] for f in fps[PAIRED]])
um = swing.NEEDLE_OD_UM / (2 * r_paired)
print(f"\nScale from the paired-image footprints (their edges are sharp; edge sd {fp_table[fp_table.image == PAIRED].edge_sd_bin.max()} bins or less):")
print(f"  mean footprint diameter {2 * r_paired:.1f} binned px = 1.5 mm  ->  {um:.2f} um per binned px, "
      f"{um / swing.BIN:.4f} um per full-resolution px")
print("  ORCA-Flash4 (6.5 um pixels) behind a 10x objective gives 0.65 um per pixel")
print(f"  image width: {meta[SINGLE]['shape'][1] * um / swing.BIN / 1000:.2f} mm")
r_single = np.mean([f["r"] for f in fps[SINGLE]])
print(f"  single-image footprints fitted to a mean diameter of {2 * r_single * um / 1000:.2f} mm (edges less sharp)")

E = {}
for s in (SINGLE, PAIRED):
    apex, ba, bb = swing.label_electrodes(fps[s])
    E[s] = {"apex": apex, "base_a": ba, "base_b": bb}
    P = lambda f: np.array([f["x"], f["y"]]) * um
    print(f"\n{s}: apex at ({apex['x']:.0f}, {apex['y']:.0f}), base electrodes at ({ba['x']:.0f}, {ba['y']:.0f}) "
          f"and ({bb['x']:.0f}, {bb['y']:.0f}) binned px")
    print(f"  base side {np.linalg.norm(P(ba) - P(bb)) / 1000:.2f} mm, sides to apex "
          f"{np.linalg.norm(P(apex) - P(ba)) / 1000:.2f} and {np.linalg.norm(P(apex) - P(bb)) / 1000:.2f} mm")

print("\nFootprint contrast by channel (median inside the footprint cores / median of a ring 0.2 to 0.6 mm outside the edges):")
for s in (SINGLE, PAIRED):
    for c in range(2):
        ch = img[s][c]
        inner = np.concatenate([ch[swing.disc_mask(ch.shape, f["x"], f["y"], swing.FOOTPRINT_CORE * 750 / um)] for f in fps[s]])
        ring = np.concatenate([ch[swing.disc_mask(ch.shape, f["x"], f["y"], (750 + 600) / um)
                                  & ~swing.disc_mask(ch.shape, f["x"], f["y"], (750 + 200) / um)] for f in fps[s]])
        print(f"  {s} channel {c}: {np.median(inner):.0f} / {np.median(ring):.0f} = {np.median(inner) / np.median(ring):.2f}")

a, b = img[SINGLE][1].ravel(), img[PAIRED][1].ravel()
print("\nCorrelation of the two YP images, binned pixel by pixel:", round(float(np.corrcoef(a, b)[0, 1]), 3))
              image  x_bin  y_bin  radius_bin  edge_sd_bin  edge_points_of_72  coarse_contrast
Single-pulse_trains  942.6  894.4        67.1         12.2                 69            0.337
Single-pulse_trains  502.0  949.5        58.2         13.8                 72            0.159
Single-pulse_trains  673.2  324.2        49.4         12.6                 71            0.144
Paired-pulse_trains  720.1  979.2        71.7          3.2                 65            0.620
Paired-pulse_trains  904.0  354.0        70.7          9.5                 70            0.263
Paired-pulse_trains  453.7  410.8        72.8          4.7                 59            0.133

Scale from the paired-image footprints (their edges are sharp; edge sd 9.5 bins or less):
  mean footprint diameter 143.4 binned px = 1.5 mm  ->  10.46 um per binned px, 0.6535 um per full-resolution px
  ORCA-Flash4 (6.5 um pixels) behind a 10x objective gives 0.65 um per pixel
  image width: 14.59 mm
  single-image footprints fitted to a mean diameter of 1.22 mm (edges less sharp)

Single-pulse_trains: apex at (673, 324), base electrodes at (943, 894) and (502, 950) binned px
  base side 4.64 mm, sides to apex 6.59 and 6.78 mm

Paired-pulse_trains: apex at (720, 979), base electrodes at (904, 354) and (454, 411) binned px
  base side 4.75 mm, sides to apex 6.81 and 6.56 mm

Footprint contrast by channel (median inside the footprint cores / median of a ring 0.2 to 0.6 mm outside the edges):
  Single-pulse_trains channel 0: 247 / 240 = 1.03
  Single-pulse_trains channel 1: 268 / 332 = 0.81
  Paired-pulse_trains channel 0: 257 / 258 = 0.99
  Paired-pulse_trains channel 1: 196 / 291 = 0.68

Correlation of the two YP images, binned pixel by pixel: 0.277

Both images show three dark footprints in an isosceles triangle with a base of 4.6 to 4.7 mm and sides of 6.6 to 6.8 mm, so the two dishes used the same array, turned about 180 degrees between them. The scale set from the 1.5 mm needle (Methods 4.2) is 0.654 um per pixel, which is what the camera and objective named in the file give (6.5 um / 10 = 0.65 um), and makes the image 14.6 mm wide, about the size of a 14 mm glass window. The scale is consistent with the data, not shown by them. Footprints are dark in channel 1, where cells under the needle were shielded from the field and took up no dye (ratio 0.68 and 0.81), and not in channel 0 (0.99 and 1.03); channel 1 is YO-PRO-1 and channel 0 MemBrite, as the description lists them. The two images are distinct recordings (different footprint positions, low pixel correlation), so neither is deposited twice. They are still images: there are no time series or trial tables whose clocks need to agree.

In [4]:
# The field model (swing.solve_potential): 2D conduction between three 1.5 mm electrodes in a 35 mm dish.
field = {}
for s in (SINGLE, PAIRED):
    e = E[s]
    els = [(f["x"] * um, f["y"] * um) for f in (e["apex"], e["base_a"], e["base_b"])]
    ny, nx = img[s][1].shape
    t0 = time.time()
    origin, h, phi = swing.solve_potential(els, [[0, 1, 0], [0, 0, 1]], (nx / 2 * um, ny / 2 * um))
    field[s] = {"origin": origin, "h": h, "phi": phi}
    for k in range(2):
        ex, ey = swing.field_from_potential(phi[k], h)
        field[s][f"e{k}"] = (ex, ey)
        field[s][f"mag{k}"] = np.hypot(ex, ey) * 1e4          # V/cm per volt applied
    print(f"{s}: solved on a {phi.shape[1]} x {phi.shape[2]} grid of {h:.0f} um in {time.time() - t0:.0f} s")

# The paper's field along the bisecting line (Section 2.1: 5 to 10 kV/cm) against the amplitude.
e = E[PAIRED]
bx, by, bd = swing.bisector_points(e["apex"], (e["base_a"], e["base_b"]), um)
mag_b = swing.sample(field[PAIRED]["mag0"], field[PAIRED]["origin"], field[PAIRED]["h"], bx * um, by * um)
print(f"\nField on the bisecting line of the paired-pulse dish, {len(bd)} points from the base midpoint to the apex footprint:")
for volts in (4000, 2000):
    print(f"  at {volts / 1000:.0f} kV: {mag_b.min() * volts / 1000:.1f} to {mag_b.max() * volts / 1000:.1f} kV/cm")

# Grid check: the same solve on a 60 um grid.
els = [(f["x"] * um, f["y"] * um) for f in (e["apex"], e["base_a"], e["base_b"])]
ny, nx = img[PAIRED][1].shape
o60, h60, phi60 = swing.solve_potential(els, [[0, 1, 0]], (nx / 2 * um, ny / 2 * um), h=60.0)
ex60, ey60 = swing.field_from_potential(phi60[0], h60)
m60 = swing.sample(np.hypot(ex60, ey60) * 1e4, o60, h60, bx * um, by * um)
print(f"  60 um grid against 40 um grid on the bisecting line: largest relative difference {np.max(np.abs(m60 / mag_b - 1)):.3f}")
Single-pulse_trains: solved on a 878 x 878 grid of 40 um in 9 s
Paired-pulse_trains: solved on a 878 x 878 grid of 40 um in 8 s

Field on the bisecting line of the paired-pulse dish, 27 points from the base midpoint to the apex footprint:
  at 4 kV: 4.2 to 9.7 kV/cm
  at 2 kV: 2.1 to 4.8 kV/cm
  60 um grid against 40 um grid on the bisecting line: largest relative difference 0.013

With the 4 kV of Section 2.3, the computed field on the bisecting line runs from about 4 to 10 kV/cm, close to the "5 to 10 kV/cm" Section 2.1 gives for this array; with the 2 kV of the file's description it would be 2 to 5 kV/cm. This is consistent with the paper's amplitude, not a proof of it: the field model is computed here, not measured. The solve changes little with the grid.

In [5]:
fig, axes = plt.subplots(1, 2, figsize=(13, 6.5))
for ax, s in zip(axes, (SINGLE, PAIRED)):
    yp = median_filter(img[s][1], 3)
    lo, hi = np.percentile(yp[yp > 0], [1, 99.5])
    ext = [0, yp.shape[1] * um / 1000, yp.shape[0] * um / 1000, 0]
    ax.imshow(yp, cmap="gray", vmin=lo, vmax=hi, extent=ext)
    for name, f in E[s].items():
        ax.add_patch(plt.Circle((f["x"] * um / 1000, f["y"] * um / 1000), 0.75, fill=False, color="tab:orange", lw=1))
        ax.annotate(name, (f["x"] * um / 1000, f["y"] * um / 1000), color="tab:orange", ha="center", va="center", fontsize=8)
    ax.set_title(f"{s}, YO-PRO-1 (display {lo:.0f} to {hi:.0f})")
    ax.set_xlabel("mm"); ax.set_ylabel("mm")
plt.tight_layout(); plt.show()
print("Display range clips the brightest 0.5% of binned pixels in each panel.")
No description has been provided for this image
Display range clips the brightest 0.5% of binned pixels in each panel.
In [6]:
# The fitted footprints up close (problem of the less sharp edges in the single-pulse image).
fig, axes = plt.subplots(2, 3, figsize=(13, 8.5))
half = int(round(1400 / um))
for row, s in zip(axes, (SINGLE, PAIRED)):
    yp = median_filter(img[s][1], 3)
    lo, hi = np.percentile(yp[yp > 0], [1, 99.5])
    for ax, name in zip(row, ("apex", "base_a", "base_b")):
        f = E[s][name]
        x0, y0 = int(round(f["x"])) - half, int(round(f["y"])) - half
        ax.imshow(yp[y0:y0 + 2 * half, x0:x0 + 2 * half], cmap="gray", vmin=lo, vmax=hi,
                  extent=[-half * um / 1000, half * um / 1000, half * um / 1000, -half * um / 1000])
        c = ((f["x"] - x0 - half) * um / 1000, (f["y"] - y0 - half) * um / 1000)
        ax.add_patch(plt.Circle(c, f["r"] * um / 1000, fill=False, color="tab:orange", lw=1))
        ax.add_patch(plt.Circle(c, 0.75, fill=False, color="tab:cyan", lw=1, ls="--"))
        ax.set_title(f"{s}, {name}: fitted d = {2 * f['r'] * um / 1000:.2f} mm, edge sd {f['edge_sd'] * um:.0f} um", fontsize=9)
        ax.set_xlabel("mm from the fitted centre")
plt.tight_layout(); plt.show()
print("Orange: the fitted edge circle. Dashed cyan: the 1.5 mm needle circle used for every ROI (750 um from the fitted centre).")
for s in (SINGLE, PAIRED):
    for name in ("apex", "base_a", "base_b"):
        f = E[s][name]
        print(f"  {s:20s} {name:7s} fitted diameter {2 * f['r'] * um / 1000:.2f} mm, edge sd {f['edge_sd'] * um:4.0f} um, "
              f"{f['edge_points']} of 72 edge points kept")
No description has been provided for this image
Orange: the fitted edge circle. Dashed cyan: the 1.5 mm needle circle used for every ROI (750 um from the fitted centre).
  Single-pulse_trains  apex    fitted diameter 1.03 mm, edge sd  132 um, 71 of 72 edge points kept
  Single-pulse_trains  base_a  fitted diameter 1.40 mm, edge sd  128 um, 69 of 72 edge points kept
  Single-pulse_trains  base_b  fitted diameter 1.22 mm, edge sd  144 um, 72 of 72 edge points kept
  Paired-pulse_trains  apex    fitted diameter 1.50 mm, edge sd   33 um, 65 of 72 edge points kept
  Paired-pulse_trains  base_a  fitted diameter 1.48 mm, edge sd   99 um, 70 of 72 edge points kept
  Paired-pulse_trains  base_b  fitted diameter 1.52 mm, edge sd   50 um, 59 of 72 edge points kept

In the single-pulse image the footprints have soft edges and fit to smaller circles (1.03 to 1.40 mm, edge s.d. 128 to 144 um, against 1.47 to 1.52 mm and 33 to 99 um in the paired-pulse image). Only the fitted centres are used: every ROI is placed from a 750 um radius around the fitted centre, the needle's own radius. The views above show whether the centres sit in the middle of the dark discs.

fig4a-single-pulse-follows-field¶

Under a train of five single pulses applied to one base electrode (833 kHz, 4 kV), YP uptake is distributed as the electric field strength map.

Criterion (claims file): background-subtracted YP in 200 x 200 um ROIs over the image (footprint background, Methods 4.4), compared with the field in the same ROIs by (a) Spearman rank correlation or (b) the order of the three electrode neighbourhoods by YP matching their order by field. Reproduced when both agree with the map (correlation positive with p < 0.05, and the same order).

The field map is computed here (see the data check), for a single pulse on one base electrode with the other two grounded (Methods 4.2), scaled to 4 kV. Which base electrode is E1, the one pulsed, is not in the file, and the paper's text does not give its place: the Figure 4 legend says only that the panels are shown "with electrode positions labeled" and that E1 is the electrode pulsed in A. The published panel A has the apex at the top and E1 at the lower right, and so does the deposited image (the data check prints the apex position), but the registry's checks keep a published figure out of the notebook, so that placement is not shown here; and the deposited images do not keep one orientation (the paired-pulse image has its apex at the bottom). E1 is taken as base_a, the lower-right base electrode, which is also the base electrode with the most uptake (with E1 = base_b, the grounded electrode would show 2.5 times the uptake of the pulsed one). Both assignments are run. The neighbourhood of an electrode is taken as the ROIs whose centre lies within 500 um of its footprint edge (the claims file does not fix it; 250 and 1000 um are shown too, with the neighbourhood means for each). The ROI grid covers the image within 6 mm of its centre, leaving out ROIs whose centre is within 150 um of a footprint edge; the effect of both choices is shown. Contiguous ROIs are not independent, so the p-value of the full grid is far too small; the correlation is also tested on ROIs thinned to 1 mm and 2 mm spacing.

In [7]:
s = SINGLE
e = E[s]
yp = img[s][1].astype(float)
bg, bg_each = swing.background(yp, fps[s], 750 / um)
ypb = yp - bg
print(f"Footprint background: {bg:.1f} (per footprint {np.round(bg_each, 1).tolist()}); dish median {np.median(yp[yp > 0]):.1f}")
names = ["apex", "base_a", "base_b"]
assign = {"E1 = base_a (lower right)": 0, "E1 = base_b (lower left)": 1}
short = {0: "base_a", 1: "base_b"}

def fig4a_measure(radius_um, margin_um, ring_um, k):
    cen = swing.roi_grid(yp.shape, fps[s], um, radius_um, margin_um)
    Y = np.array([swing.roi_mean(ypb, x, y, swing.ROI_UM / um) for x, y in cen])
    F = swing.roi_field(field[s][f"mag{k}"] * 4000 / 1000, field[s]["origin"], field[s]["h"], cen, um)  # kV/cm
    rho, p = stats.spearmanr(F, Y)
    thin = {}
    for step in (1000, 2000):
        i = swing.thin_rois(cen, um, step)
        thin[step] = (len(i), *stats.spearmanr(F[i], Y[i]))
    neigh = []
    for n in names:
        f = e[n]
        d = np.hypot(cen[:, 0] - f["x"], cen[:, 1] - f["y"]) * um
        m = d < 750 + ring_um
        neigh.append((n, Y[m].mean(), F[m].mean(), int(m.sum())))
    order_yp = [x[0] for x in sorted(neigh, key=lambda t: -t[1])]
    order_f = [x[0] for x in sorted(neigh, key=lambda t: -t[2])]
    return len(cen), rho, p, neigh, order_yp, order_f, thin

rows = []
for label, k in assign.items():
    n, rho, p, neigh, oy, of, thin = fig4a_measure(6000, 150, 500, k)
    print(f"\n{label}: {n} ROIs, Spearman rho = {rho:.3f}, p = {p:.2g}")
    for step, (nt, rt, pt) in thin.items():
        print(f"   thinned to {step / 1000:.0f} mm spacing: {nt} ROIs, rho = {rt:.3f}, p = {pt:.2g}")
    for name, y, fld, cnt in neigh:
        print(f"   {name:7s} neighbourhood (0-500 um): {cnt} ROIs, mean YP above background {y:7.1f}, mean field {fld:5.2f} kV/cm")
    print(f"   order by YP: {oy}; order by field: {of}; same order: {oy == of}")
    for radius in (5000, 6000):
        for margin in (150, 400):
            for ring in (250, 500, 1000):
                if ring <= margin:
                    continue  # no ROI centre lies between the margin and the neighbourhood's outer edge
                n2, r2, p2, ng2, oy2, of2, th2 = fig4a_measure(radius, margin, ring, k)
                rows.append({"E1": short[k], "grid_radius_mm": radius / 1000, "margin_um": margin, "neighbourhood_um": ring,
                             "rois": n2, "spearman_rho": round(r2, 3), "p": float(f"{p2:.2g}"),
                             "n_1mm": th2[1000][0], "rho_1mm": round(th2[1000][1], 3), "p_1mm": float(f"{th2[1000][2]:.2g}"),
                             "n_2mm": th2[2000][0], "rho_2mm": round(th2[2000][1], 3), "p_2mm": float(f"{th2[2000][2]:.2g}"),
                             **{f"YP_{n}": round(v[1], 1) for n, v in zip(names, ng2)},
                             **{f"F_{n}": round(v[2], 2) for n, v in zip(names, ng2)},
                             "same_order": oy2 == of2})
print("\nHow the choices change the result:")
print(pd.DataFrame(rows).to_string(index=False))
print("YP_ and F_ columns: mean YP above background and mean field (kV/cm) of each electrode neighbourhood.")
print("n_, rho_ and p_ 1mm / 2mm: the Spearman test on the ROIs thinned to 1 mm and 2 mm spacing.")
print("A neighbourhood of 250 um is not run with the 400 um margin, as it then holds no ROI.")
Footprint background: 272.1 (per footprint [272.1, 285.7, 247.9]); dish median 322.2
E1 = base_a (lower right): 2627 ROIs, Spearman rho = 0.666, p = 0
   thinned to 1 mm spacing: 105 ROIs, rho = 0.666, p = 9.3e-15
   thinned to 2 mm spacing: 24 ROIs, rho = 0.654, p = 0.00053
   apex    neighbourhood (0-500 um): 58 ROIs, mean YP above background    82.2, mean field  5.31 kV/cm
   base_a  neighbourhood (0-500 um): 58 ROIs, mean YP above background   649.8, mean field 12.88 kV/cm
   base_b  neighbourhood (0-500 um): 57 ROIs, mean YP above background   262.6, mean field  7.38 kV/cm
   order by YP: ['base_a', 'base_b', 'apex']; order by field: ['base_a', 'base_b', 'apex']; same order: True
E1 = base_b (lower left): 2627 ROIs, Spearman rho = 0.460, p = 1.2e-137
   thinned to 1 mm spacing: 105 ROIs, rho = 0.476, p = 2.9e-07
   thinned to 2 mm spacing: 24 ROIs, rho = 0.397, p = 0.054
   apex    neighbourhood (0-500 um): 58 ROIs, mean YP above background    82.2, mean field  5.28 kV/cm
   base_a  neighbourhood (0-500 um): 58 ROIs, mean YP above background   649.8, mean field  7.80 kV/cm
   base_b  neighbourhood (0-500 um): 57 ROIs, mean YP above background   262.6, mean field 12.43 kV/cm
   order by YP: ['base_a', 'base_b', 'apex']; order by field: ['base_b', 'base_a', 'apex']; same order: False
How the choices change the result:
    E1  grid_radius_mm  margin_um  neighbourhood_um  rois  spearman_rho             p  n_1mm  rho_1mm        p_1mm  n_2mm  rho_2mm   p_2mm  YP_apex  YP_base_a  YP_base_b  F_apex  F_base_a  F_base_b  same_order
base_a             5.0        150               250  1763         0.746  0.000000e+00     69    0.704 1.500000e-11     16    0.724 0.00150     79.4      697.6      276.6    6.07     14.73      7.66        True
base_a             5.0        150               500  1763         0.746  0.000000e+00     69    0.704 1.500000e-11     16    0.724 0.00150     84.5      649.8      262.6    5.41     12.88      7.38        True
base_a             5.0        150              1000  1763         0.746  0.000000e+00     69    0.704 1.500000e-11     16    0.724 0.00150     71.8      513.5      223.1    4.76     10.47      6.32        True
base_a             5.0        400               500  1644         0.726 5.000000e-269     61    0.640 2.700000e-08     12    0.503 0.09500     71.0      659.3      215.3    4.36     12.09      6.23        True
base_a             5.0        400              1000  1644         0.726 5.000000e-269     61    0.640 2.700000e-08     12    0.503 0.09500     65.0      474.0      205.1    4.36      9.64      5.85        True
base_a             6.0        150               250  2627         0.666  0.000000e+00    105    0.666 9.300000e-15     24    0.654 0.00053     79.4      697.6      276.6    6.07     14.73      7.66        True
base_a             6.0        150               500  2627         0.666  0.000000e+00    105    0.666 9.300000e-15     24    0.654 0.00053     82.2      649.8      262.6    5.31     12.88      7.38        True
base_a             6.0        150              1000  2627         0.666  0.000000e+00    105    0.666 9.300000e-15     24    0.654 0.00053     61.8      511.9      221.9    4.40     10.44      6.28        True
base_a             6.0        400               500  2506         0.640 3.500000e-289     97    0.613 2.600000e-11     24    0.654 0.00053     68.6      659.3      215.3    4.28     12.09      6.23        True
base_a             6.0        400              1000  2506         0.640 3.500000e-289     97    0.613 2.600000e-11     24    0.654 0.00053     54.1      472.3      203.9    4.00      9.61      5.82        True
base_b             5.0        150               250  1763         0.510 1.900000e-117     69    0.489 2.000000e-05     16    0.547 0.02800     79.4      697.6      276.6    5.92      9.22     13.54       False
base_b             5.0        150               500  1763         0.510 1.900000e-117     69    0.489 2.000000e-05     16    0.547 0.02800     84.5      649.8      262.6    5.35      7.80     12.43       False
base_b             5.0        150              1000  1763         0.510 1.900000e-117     69    0.489 2.000000e-05     16    0.547 0.02800     71.8      513.5      223.1    4.56      6.29     10.33       False
base_b             5.0        400               500  1644         0.481  6.400000e-96     61    0.445 3.300000e-04     12    0.643 0.02400     71.0      659.3      215.3    5.24      7.93     10.74       False
base_b             5.0        400              1000  1644         0.481  6.400000e-96     61    0.445 3.300000e-04     12    0.643 0.02400     65.0      474.0      205.1    4.24      5.85      9.49       False
base_b             6.0        150               250  2627         0.460 1.200000e-137    105    0.476 2.900000e-07     24    0.397 0.05400     79.4      697.6      276.6    5.92      9.22     13.54       False
base_b             6.0        150               500  2627         0.460 1.200000e-137    105    0.476 2.900000e-07     24    0.397 0.05400     82.2      649.8      262.6    5.28      7.80     12.43       False
base_b             6.0        150              1000  2627         0.460 1.200000e-137    105    0.476 2.900000e-07     24    0.397 0.05400     61.8      511.9      221.9    4.22      6.27     10.28       False
base_b             6.0        400               500  2506         0.424 8.300000e-110     97    0.415 2.400000e-05     24    0.397 0.05400     68.6      659.3      215.3    5.16      7.93     10.74       False
base_b             6.0        400              1000  2506         0.424 8.300000e-110     97    0.415 2.400000e-05     24    0.397 0.05400     54.1      472.3      203.9    3.88      5.83      9.43       False
YP_ and F_ columns: mean YP above background and mean field (kV/cm) of each electrode neighbourhood.
n_, rho_ and p_ 1mm / 2mm: the Spearman test on the ROIs thinned to 1 mm and 2 mm spacing.
A neighbourhood of 250 um is not run with the 400 um margin, as it then holds no ROI.
In [8]:
fig, axes = plt.subplots(1, 3, figsize=(16, 5))
cen = swing.roi_grid(yp.shape, fps[s], um)
Y = np.array([swing.roi_mean(ypb, x, y, swing.ROI_UM / um) for x, y in cen])
for ax, (label, k) in zip(axes[:2], assign.items()):
    F = swing.roi_field(field[s][f"mag{k}"] * 4, field[s]["origin"], field[s]["h"], cen, um)
    ax.scatter(F, Y, s=4, alpha=0.4, color="tab:blue")
    ax.set_xscale("log"); ax.set_xlabel("computed field in the ROI (kV/cm, 4 kV)"); ax.set_ylabel("YP above footprint background")
    ax.set_title(label, fontsize=10)
axes[2].imshow(np.log10(field[s]["mag0"] * 4), cmap="viridis", vmin=0, vmax=1.3)
axes[2].set_title("log10 field (kV/cm), E1 = base_a, field grid", fontsize=10); axes[2].axis("off")
plt.tight_layout(); plt.show()
print(f"YP per ROI ranges from {Y.min():.0f} to {Y.max():.0f}; field per ROI {F.min():.2f} to {F.max():.2f} kV/cm; all points are inside the axes.")
No description has been provided for this image
YP per ROI ranges from -42 to 1851; field per ROI 0.05 to 19.18 kV/cm; all points are inside the axes.

Result: reproduced, resting on the assumption that E1 is base_a, the lower-right base electrode, and that this file is the image of Figure 4A. With E1 = base_a, YP and the computed field are rank-correlated over 2627 ROIs (rho = 0.666), and the correlation holds on ROIs thinned to 1 mm spacing (rho = 0.666, p = 9.3e-15, 105 ROIs) and to 2 mm (rho = 0.654, p = 0.00053, 24 ROIs). The three neighbourhoods have the same order by YP and by field (base_a 649.8, base_b 262.6, apex 82.2 above background; 12.88, 7.38 and 5.31 kV/cm). Across grid radius, margin and neighbourhood width (250, 500 and 1000 um), rho is 0.640 to 0.746 and the order always matches; thinned to 1 mm, p is 2.7e-8 or less; thinned to 2 mm, p is below 0.05 in 8 of 10 variants and 0.095 in the two with a 5 mm grid and a 400 um margin, where only 12 ROIs are left. With E1 = base_b the correlation is weaker (0.424 to 0.510; 0.397 to 0.643 at 2 mm spacing, p up to 0.054) and the order differs in every variant, so the criterion's two readings would disagree. Where Figure 4A places E1 cannot be shown in this notebook, and the deposited images do not keep one orientation, so the assignment rests in the end on the uptake: E1 is the base electrode with the most uptake. That is the same direction the test checks, so the order reading (b) is not an independent check of the assignment; the correlation reading (a) is positive under both assignments. The outcome also rests on this file being the image of Figure 4A, which its session description contradicts (data check).

fig4bc-paired-pulse-focal-apex¶

Under five paired pulses applied to the two base electrodes in alternation (833 kHz, 4 kV), electroporation is strong near the grounded apex electrode and weak or absent near the base electrodes, despite the strong field there.

Criterion (claims file): background-subtracted YP in 200 x 200 um ROIs centred (a) 100 um and (b) 500 um outside the edge of each footprint, on the line from that electrode toward the array centre. Reproduced when, under both readings, YP near the apex in the paired-pulse image exceeds that near each base electrode, and the ratio of apex to base YP is higher in the paired-pulse image than in the single-pulse image. The array centre is taken as the centroid of the three electrode centres. The footprint edge is 750 um from the fitted centre (Methods 4.2).

In [9]:
near = {}
for s in (SINGLE, PAIRED):
    e = E[s]
    yp = img[s][1].astype(float)
    bg, _ = swing.background(yp, fps[s], 750 / um)
    cen = np.mean([[e[n]["x"], e[n]["y"]] for n in names], axis=0)
    for d in (100, 500):
        for n in names:
            f = e[n]
            v = cen - np.array([f["x"], f["y"]]); v /= np.linalg.norm(v)
            x, y = np.array([f["x"], f["y"]]) + v * (750 + d) / um
            near[(s, d, n)] = (swing.roi_mean(yp - bg, x, y, swing.ROI_UM / um), swing.roi_mean(yp, x, y, swing.ROI_UM / um))
rows = []
for d in (100, 500):
    for s in (SINGLE, PAIRED):
        a = near[(s, d, "apex")][0]
        rows.append({"distance_um": d, "image": s, **{f"YP_{n}": round(near[(s, d, n)][0], 1) for n in names},
                     "apex/base_a": round(a / near[(s, d, "base_a")][0], 3), "apex/base_b": round(a / near[(s, d, "base_b")][0], 3)})
tab = pd.DataFrame(rows)
print("YP above the footprint background in the ROIs near each electrode:")
print(tab.to_string(index=False))
for d in (100, 500):
    P = tab[(tab.distance_um == d) & (tab.image == PAIRED)].iloc[0]
    S = tab[(tab.distance_um == d) & (tab.image == SINGLE)].iloc[0]
    print(f"\n{d} um: paired apex > each base: {P.YP_apex > P.YP_base_a and P.YP_apex > P.YP_base_b}; "
          f"apex/base higher in paired than single: base_a {P['apex/base_a'] > S['apex/base_a']}, base_b {P['apex/base_b'] > S['apex/base_b']}")
print("\nVariant without background subtraction (raw YP), paired image:")
for d in (100, 500):
    print(f"  {d} um: " + ", ".join(f"{n} {near[(PAIRED, d, n)][1]:.1f}" for n in names))
print("Field at these ROIs in the paired dish (kV/cm at 4 kV, pulse on base_a / on base_b):")
for d in (100, 500):
    e = E[PAIRED]; cen = np.mean([[e[n]["x"], e[n]["y"]] for n in names], axis=0)
    out = []
    for n in names:
        f = e[n]; v = cen - np.array([f["x"], f["y"]]); v /= np.linalg.norm(v)
        x, y = (np.array([f["x"], f["y"]]) + v * (750 + d) / um) * um
        m0 = swing.sample(field[PAIRED]["mag0"] * 4, field[PAIRED]["origin"], field[PAIRED]["h"], x, y)[0]
        m1 = swing.sample(field[PAIRED]["mag1"] * 4, field[PAIRED]["origin"], field[PAIRED]["h"], x, y)[0]
        out.append(f"{n} {m0:.1f}/{m1:.1f}")
    print(f"  {d} um: " + ", ".join(out))
YP above the footprint background in the ROIs near each electrode:
 distance_um               image  YP_apex  YP_base_a  YP_base_b  apex/base_a  apex/base_b
         100 Single-pulse_trains     49.2      968.1      512.5        0.051        0.096
         100 Paired-pulse_trains    843.9      255.3      104.1        3.306        8.108
         500 Single-pulse_trains    140.2      896.5      701.6        0.156        0.200
         500 Paired-pulse_trains    725.7      269.6      119.0        2.692        6.100

100 um: paired apex > each base: True; apex/base higher in paired than single: base_a True, base_b True

500 um: paired apex > each base: True; apex/base higher in paired than single: base_a True, base_b True

Variant without background subtraction (raw YP), paired image:
  100 um: apex 1035.8, base_a 447.2, base_b 296.0
  500 um: apex 917.6, base_a 461.5, base_b 310.9
Field at these ROIs in the paired dish (kV/cm at 4 kV, pulse on base_a / on base_b):
  100 um: apex 8.4/9.0, base_a 19.6/11.9, base_b 11.7/20.0
  500 um: apex 5.8/6.2, base_a 13.8/8.7, base_b 8.4/13.9

Result: reproduced, resting on the assumption that the single-pulse file is the image of Figure 4A. Under both readings, YP near the apex in the paired-pulse image is above YP near each base electrode (2.7 to 8.1 times, from the table), and the apex-to-base ratios are far higher in the paired-pulse image than in the single-pulse image, where the apex neighbourhood has the least uptake (ratios 0.05 to 0.20). The computed field near the base electrodes during the pulse on that electrode is higher than near the apex, as the claim says ("despite the strong field there"). The single-pulse half of the contrast rests on the file being the image of Figure 4A (data check). The exposure its description gives instead is four co-directional pairs, in which "the electric field direction stayed the same in each pair"; it does not say which electrodes were energized, and that the field came from one base electrode is an inference from the uptake around one of them.

fig4-comet-shape¶

Under paired pulses the electroporated cells form a comet-shaped region pointing from the apex electrode towards the base.

Criterion (claims file): the electroporated region is background-subtracted YP above (a) half and (b) a quarter of its maximum in the paired-pulse image, after smoothing over 200 x 200 um. Reproduced when, under both readings, the region touches the apex footprint and extends further along the bisecting line toward the base than across it.

The region is the set of connected pixels above threshold that touch the apex footprint (swing.comet_region), inside 6 mm of the image centre and outside the footprints. The 200 um mean is taken over the pixels inside that area only (swing.masked_mean_filter), so the dark footprints and image corners do not lower the values next to them. "Further toward the base than across" is measured from the apex centre: the reach along the bisecting line toward the base against the largest distance across it, on either side. The extents along and across the line are shown too. How the maximum is taken matters (below): the literal maximum of the 200 um mean lies on one object far brighter than anything else in the dish. That object is shown at full resolution in both channels. Four versions are run: the literal maximum of the 200 um mean; the literal maximum of the 200 um mean after leaving out objects that are bright in the MemBrite channel (swing.debris_mask; MemBrite stains all cell membranes about evenly, so an object many times brighter than the monolayer in it is not a monolayer cell; thresholds of 5, 10 and 20 times the dish median); the 99.5th percentile of the 200 um mean; and the maximum of a 200 um median filter.

In [10]:
s = PAIRED
e = E[s]
yp = img[s][1].astype(float)
mb = img[s][0].astype(float)
bg, _ = swing.background(yp, fps[s], 750 / um)
ypb = yp - bg
ny, nx = yp.shape
Yg, Xg = np.mgrid[:ny, :nx]
valid = np.hypot(Xg - nx / 2, Yg - ny / 2) * um < 6000
for n in names:
    valid &= ~swing.disc_mask(yp.shape, e[n]["x"], e[n]["y"], 750 / um)
k = int(round(swing.ROI_UM / um))
# The 200 um mean uses only pixels inside `valid` (footprints and the dark corners left out).
sm_mean = swing.masked_mean_filter(ypb, valid, k)
sm_med = median_filter(ypb, k)
iy, ix = np.unravel_index(np.nanargmax(np.where(valid, sm_mean, np.nan)), sm_mean.shape)
print(f"Smoothing window {k} binned px ({k * um:.0f} um).")
print(f"Literal maximum of the 200 um mean: {sm_mean[iy, ix]:.0f} at ({ix}, {iy}) binned px; "
      f"next highest away from it (outside 500 um): "
      f"{np.nanmax(np.where(valid & (np.hypot(Xg - ix, Yg - iy) * um > 500), sm_mean, np.nan)):.0f}")
print(f"  binned YP around it: max {img[s][1][iy - 3:iy + 4, ix - 3:ix + 4].max():.0f}, against a 99.9th percentile "
      f"of binned YP in the dish of {np.percentile(yp[valid], 99.9):.0f}")
print(f"  binned MemBrite around it: max {mb[iy - 3:iy + 4, ix - 3:ix + 4].max():.0f}, against a dish median of "
      f"{np.median(mb[valid]):.0f} and a 99.9th percentile of {np.percentile(mb[valid], 99.9):.0f}")
print(f"  distance from the apex centre {np.hypot(ix - e['apex']['x'], iy - e['apex']['y']) * um:.0f} um")

# The object under the literal maximum at full resolution, both channels (1600 x 1600 px, about 1 mm).
hw = 800
cx, cy = ix * swing.BIN + swing.BIN // 2, iy * swing.BIN + swing.BIN // 2
crop = swing.load_crop(meta[s]["url"], cx - hw, cy - hw, cx + hw, cy + hw).astype(float)
px_um = um / swing.BIN
mb_med = np.median(mb[valid])
obj_mask = crop[0] > swing.DEBRIS_FACTOR * mb_med
from scipy.ndimage import label as _label
lab, nobj = _label(obj_mask)
big = np.argmax(np.bincount(lab.ravel())[1:]) + 1
obj = lab == big
print(f"\nFull-resolution crop of {2 * hw} x {2 * hw} px ({2 * hw * px_um:.0f} um) centred on the literal maximum:")
print(f"  YP: median {np.median(crop[1]):.0f}, max {crop[1].max():.0f} (the uint16 range ends at 65535)")
print(f"  largest object with MemBrite above {swing.DEBRIS_FACTOR:.0f} x the dish median ({swing.DEBRIS_FACTOR * mb_med:.0f}): "
      f"{obj.sum() * px_um ** 2:.0f} um2, equivalent diameter {2 * np.sqrt(obj.sum() / np.pi) * px_um:.0f} um")
print(f"  inside it: YP median {np.median(crop[1][obj]):.0f}, MemBrite median {np.median(crop[0][obj]):.0f}; "
      f"outside it in the crop: YP median {np.median(crop[1][~obj]):.0f}, MemBrite median {np.median(crop[0][~obj]):.0f}")

# Objects bright in MemBrite across the dish, left out in the fourth version.
debris = {f: swing.debris_mask(mb, valid, factor=f) for f in (5.0, swing.DEBRIS_FACTOR, 20.0)}
for f, m in debris.items():
    print(f"  MemBrite above {f:.0f} x the dish median (grown by {swing.DEBRIS_DILATE} binned px): "
          f"{(m & valid).sum() / valid.sum() * 100:.2f}% of the binned pixels in the region")

versions = {"mean, literal max": (sm_mean, valid, np.nanmax(sm_mean[valid])),
            "mean, 99.5th percentile": (sm_mean, valid, np.nanpercentile(sm_mean[valid], 99.5)),
            "median filter, max": (sm_med, valid, sm_med[valid].max())}
for f, m in debris.items():
    v2 = valid & ~m
    sm2 = swing.masked_mean_filter(ypb, v2, k)
    versions[f"mean, max without objects ({f:.0f}x)"] = (sm2, v2, np.nanmax(sm2[v2]))
rows, regions = [], {}
for vname, (im, vmask, M) in versions.items():
    jy, jx = np.unravel_index(np.nanargmax(np.where(vmask, im, np.nan)), im.shape)
    for frac in (0.5, 0.25):
        R, o = swing.comet_region(im, frac * M, e["apex"], (e["base_a"], e["base_b"]), um, vmask)
        regions[(vname, frac)] = R
        ok = o["touches_apex"] and o["pixels"] > 0 and o["reach_toward_base"] > o["reach_across"]
        rows.append({"version": vname, "max": round(M),
                     "max_from_apex_um": (round(float(np.hypot(jx - e["apex"]["x"], jy - e["apex"]["y"]) * um))
                                          if "percentile" not in vname else None),
                     "fraction": frac, "area_mm2": round(o["area_mm2"], 2), "touches_apex": o["touches_apex"],
                     **{c: float(np.round(o.get(c, np.nan))) for c in ("reach_toward_base", "reach_away", "reach_across", "u_extent", "v_extent")},
                     "touches_base": o.get("touches_base"), "toward_base>across": ok})
print("\nDistances in um, from the apex centre (u along the bisecting line toward the base, v across it):")
print(pd.DataFrame(rows).to_string(index=False))
Smoothing window 19 binned px (199 um).
Literal maximum of the 200 um mean: 3999 at (562, 804) binned px; next highest away from it (outside 500 um): 1063
  binned YP around it: max 26996, against a 99.9th percentile of binned YP in the dish of 2953
  binned MemBrite around it: max 9702, against a dish median of 257 and a 99.9th percentile of 2222
  distance from the apex centre 2468 um
Full-resolution crop of 1600 x 1600 px (1046 um) centred on the literal maximum:
  YP: median 307, max 40132 (the uint16 range ends at 65535)
  largest object with MemBrite above 10 x the dish median (2570): 8688 um2, equivalent diameter 105 um
  inside it: YP median 14577, MemBrite median 5266; outside it in the crop: YP median 305, MemBrite median 255
  MemBrite above 5 x the dish median (grown by 3 binned px): 1.50% of the binned pixels in the region
  MemBrite above 10 x the dish median (grown by 3 binned px): 0.54% of the binned pixels in the region
  MemBrite above 20 x the dish median (grown by 3 binned px): 0.19% of the binned pixels in the region
Distances in um, from the apex centre (u along the bisecting line toward the base, v across it):
                        version  max  max_from_apex_um  fraction  area_mm2  touches_apex  reach_toward_base  reach_away  reach_across  u_extent  v_extent   touches_base  toward_base>across
              mean, literal max 3999            2468.0      0.50      0.00         False                NaN         NaN           NaN       NaN       NaN           None               False
              mean, literal max 3999            2468.0      0.25      0.00         False                NaN         NaN           NaN       NaN       NaN           None               False
        mean, 99.5th percentile  737               NaN      0.50      5.34          True             2935.0      1224.0        1607.0    4160.0    3038.0 [False, False]                True
        mean, 99.5th percentile  737               NaN      0.25     13.96          True             4127.0      1538.0        2269.0    5665.0    4399.0 [False, False]                True
             median filter, max  632            1069.0      0.50      1.54          True             2603.0       592.0        1248.0    3195.0    1750.0 [False, False]                True
             median filter, max  632            1069.0      0.25      9.70          True             4002.0      1299.0        2064.0    5301.0    3800.0 [False, False]                True
 mean, max without objects (5x) 1063            1063.0      0.50      1.90          True             1755.0       691.0        1277.0    2446.0    2473.0 [False, False]                True
 mean, max without objects (5x) 1063            1063.0      0.25      8.46          True             3929.0      1289.0        1749.0    5218.0    3368.0 [False, False]                True
mean, max without objects (10x) 1063            1063.0      0.50      1.97          True             1755.0       691.0        1315.0    2446.0    2592.0 [False, False]                True
mean, max without objects (10x) 1063            1063.0      0.25      8.69          True             3929.0      1289.0        1749.0    5218.0    3368.0 [False, False]                True
mean, max without objects (20x) 1063            1063.0      0.50      1.98          True             1755.0       691.0        1315.0    2446.0    2592.0 [False, False]                True
mean, max without objects (20x) 1063            1063.0      0.25      8.79          True             3929.0      1289.0        1749.0    5218.0    3368.0 [False, False]                True
In [11]:
fig, axes = plt.subplots(1, 2, figsize=(12, 5.5))
ext = [-hw * px_um, hw * px_um, hw * px_um, -hw * px_um]
for ax, ch, cname in zip(axes, (1, 0), ("YO-PRO-1", "MemBrite")):
    lo, hi = np.percentile(crop[ch], [1, 99.9])
    ax.imshow(np.log10(np.clip(crop[ch], lo, hi)), cmap="gray", extent=ext)
    ax.contour(np.linspace(-hw, hw, 2 * hw) * px_um, np.linspace(-hw, hw, 2 * hw) * px_um, obj.astype(float),
               levels=[0.5], colors="tab:orange", linewidths=0.8)
    ax.set_title(f"{cname}, full resolution, log scale {lo:.0f} to {hi:.0f}", fontsize=10)
    ax.set_xlabel("um from the literal maximum")
plt.tight_layout(); plt.show()
print("Orange: the object with MemBrite above 10 x the dish median. Values outside the 1st to 99.9th percentile of each crop are clipped.")
No description has been provided for this image
Orange: the object with MemBrite above 10 x the dish median. Values outside the 1st to 99.9th percentile of each crop are clipped.
In [12]:
fig, axes = plt.subplots(1, 3, figsize=(18, 6.5))
ext = [0, nx * um / 1000, ny * um / 1000, 0]
for ax, vname in zip(axes, ("mean, max without objects (10x)", "mean, 99.5th percentile", "median filter, max")):
    im, vm, M = versions[vname]
    ax.imshow(np.where(vm, im, np.nan), cmap="gray", extent=ext, vmin=0, vmax=M)
    for frac, col in ((0.5, "tab:orange"), (0.25, "tab:blue")):
        ax.contour(np.linspace(0, nx * um / 1000, nx), np.linspace(0, ny * um / 1000, ny), regions[(vname, frac)].astype(float),
                   levels=[0.5], colors=col, linewidths=1)
    ax.plot([e["apex"]["x"] * um / 1000, (e["base_a"]["x"] + e["base_b"]["x"]) / 2 * um / 1000],
            [e["apex"]["y"] * um / 1000, (e["base_a"]["y"] + e["base_b"]["y"]) / 2 * um / 1000], ls="--", color="white", lw=0.8)
    ax.set_title(f"{vname}: half (orange) and quarter (blue) maximum", fontsize=10)
    ax.set_xlabel("mm"); ax.set_ylabel("mm")
plt.tight_layout(); plt.show()
print("Grey scale 0 to the maximum of each version; values above it are shown at the top of the scale.")
No description has been provided for this image
Grey scale 0 to the maximum of each version; values above it are shown at the top of the scale.

Result: reproduced, resting on the assumption that objects bright in the MemBrite channel are not part of the electroporated monolayer. The literal maximum of the 200 um mean (3999) lies on one object 2468 um from the apex: at full resolution it is a blurred disc about 105 um across (8688 um2), bright in both channels (YP median 14577 and MemBrite median 5266, against 305 and 255 around it), larger than a cell and out of focus, so likely debris above the monolayer. With that maximum, the region at half and at a quarter of it is empty, and the claim fails under both readings. Leaving out the objects whose MemBrite is above 10 times the dish median (0.54% of the region), the literal maximum of the 200 um mean is 1063, 1063 um from the apex, and the region touches the apex footprint and reaches further toward the base than across the line at half (1755 against 1315 um) and at a quarter (3929 against 1749 um); thresholds of 5 and 20 times give the same. The 99.5th percentile of the 200 um mean (half, 2935 against 1607 um; quarter, 4127 against 2269 um) and the maximum of a 200 um median filter (2603 against 1248; 4002 against 2064) agree. In every version with a region, it reaches less far away from the base than toward it and touches neither base footprint.

The pass rests on the measure: the reach from the apex centre along the bisecting line toward the base, against the largest distance across the line. The full extents of the region do not always agree with it. At half maximum, in the versions without bright objects, on which the outcome rests, the extent along the line is smaller than the extent across it (2446 against 2592 um at 10 and 20 times, 2446 against 2473 um at 5 times), and the region is a blob around the apex footprint about as wide as it is long (map above). The extents agree with the reach at a quarter maximum (5218 against 3368 um) and in the 99.5th-percentile and median-filter versions (half maximum, 4160 against 3038 and 3195 against 1750 um). The rule leaves out every binned pixel above the MemBrite threshold, 0.54% of the region at 10 times (1.50% at 5 times), which is several objects, not only the one under the literal maximum; the maximum without them (1063) is also the highest value outside 500 um of that object.

Figure 2 at 833 kHz from the Figure 4 images (for information; decides no outcome)¶

The Figure 2 claims need the ROI data of Figure 2: three experiments at each of 1 Hz and 833 kHz. They are not in the dandiset, and the four Figure 2 claims are blocked. The two Figure 4 images are one dish each of five single 600 ns pulses and of five paired pulses at 833 kHz in the same array, so they allow one 833 kHz curve with n = 1, if they were made in the way of Section 2.1. The paper does not say they were. The curve is computed as Section 2.1 describes, for information: contiguous 200 um ROIs on the bisecting line from the base midpoint to the apex footprint, YP above the footprint background, the paired-pulse value as % of the single-pulse value at the same distance from the base midpoint, and the angle between the field vectors of the two pulses of a pair from the computed field. The paper's 32 ROIs do not fit on this line (5.5 mm, 27 ROIs).

In [13]:
prof = {}
for s in (SINGLE, PAIRED):
    e = E[s]
    yp = img[s][1].astype(float)
    bg, _ = swing.background(yp, fps[s], 750 / um)
    x, y, d = swing.bisector_points(e["apex"], (e["base_a"], e["base_b"]), um)
    prof[s] = (d, np.array([swing.roi_mean(yp - bg, xx, yy, swing.ROI_UM / um) for xx, yy in zip(x, y)]), x, y)
dP, P, xP, yP = prof[PAIRED]; dS, S, _, _ = prof[SINGLE]
n = min(len(P), len(S))
F = field[PAIRED]
f1 = tuple(swing.sample(c, F["origin"], F["h"], xP * um, yP * um) for c in F["e0"])
f2 = tuple(swing.sample(c, F["origin"], F["h"], xP * um, yP * um) for c in F["e1"])
angle = swing.vector_angle(f1, f2)
curve = pd.DataFrame({"distance_um": dP[:n], "angle_deg": angle[:n].round(1), "single_YP": S[:n].round(1),
                      "paired_YP": P[:n].round(1), "paired_pct_of_single": (100 * P[:n] / S[:n]).round(1)})
print(curve.to_string(index=False))
r180 = curve.paired_pct_of_single.iloc[0]
fit = np.polyfit(curve.angle_deg[curve.angle_deg > 140], curve.paired_pct_of_single[curve.angle_deg > 140], 2)
print(f"\n180 degrees: ROI nearest the base ({curve.angle_deg.iloc[0]} deg) {r180:.1f}% -> {100 / r180:.1f}-fold weaker than single pulses "
      f"(paper 20-fold at 833 kHz); quadratic fit over angles above 140 deg, at 180: {np.polyval(fit, 180):.1f}%")
pc = curve.paired_pct_of_single.values; ang = curve.angle_deg.values
cross = [i for i in range(len(pc) - 1) if (pc[i] - 100) * (pc[i + 1] - 100) < 0]
print("Crossings of 100% (angle by linear interpolation):",
      [round(float(ang[i] + (100 - pc[i]) * (ang[i + 1] - ang[i]) / (pc[i + 1] - pc[i])), 1) for i in cross], "(paper about 130 deg at 833 kHz)")
band = curve[(curve.angle_deg >= 90) & (curve.angle_deg <= 100)]
print(f"90 to 100 deg: {len(band)} ROI(s), mean {band.paired_pct_of_single.mean():.0f}% (paper about 300%)")
plateau = curve[curve.angle_deg < 90]
print(f"Below 90 deg: {len(plateau)} ROIs, mean {plateau.paired_pct_of_single.mean():.0f}%, median {plateau.paired_pct_of_single.median():.0f}%; "
      f"single-pulse YP there {plateau.single_YP.min():.1f} to {plateau.single_YP.max():.1f} above background, so the ratio is unstable")
print(f"Co- over counter-directional (paper 60-fold): plateau mean / 180 = {plateau.paired_pct_of_single.mean() / r180:.0f}, "
      f"ROI nearest 0 deg ({ang[-1]} deg) / 180 = {pc[-1] / r180:.0f}")
 distance_um  angle_deg  single_YP  paired_YP  paired_pct_of_single
       100.0      168.7      270.2       46.6                  17.2
       300.0      165.8      439.3       57.0                  13.0
       500.0      162.7      384.5       65.9                  17.2
       700.0      159.3      359.2       62.8                  17.5
       900.0      155.5      420.7       71.3                  16.9
      1100.0      151.5      332.7       99.7                  30.0
      1300.0      147.1      229.7      127.8                  55.6
      1500.0      142.4      246.8      117.9                  47.8
      1700.0      137.4      228.0      119.0                  52.2
      1900.0      132.2      173.5       82.4                  47.5
      2100.0      126.6      116.3      144.1                 123.9
      2300.0      120.8      257.3      195.6                  76.0
      2500.0      114.7       50.7      233.4                 460.2
      2700.0      108.4      133.9      361.5                 270.0
      2900.0      101.9      100.5      289.5                 288.0
      3100.0       95.2       58.2      220.2                 378.4
      3300.0       88.3       11.4      273.4                2402.0
      3500.0       81.3       94.9      303.5                 319.7
      3700.0       74.1      138.8      457.1                 329.4
      3900.0       66.8       62.8      397.3                 633.0
      4100.0       59.4        4.3      312.5                7202.1
      4300.0       51.9       69.1      439.5                 635.8
      4500.0       44.4       65.3      484.2                 741.3
      4700.0       36.7       63.1      651.7                1033.2
      4900.0       28.7       59.2      747.4                1263.3
      5100.0       20.4      169.5      905.9                 534.4
      5300.0       11.4       96.3      927.1                 962.5

180 degrees: ROI nearest the base (168.7 deg) 17.2% -> 5.8-fold weaker than single pulses (paper 20-fold at 833 kHz); quadratic fit over angles above 140 deg, at 180: 25.6%
Crossings of 100% (angle by linear interpolation): [128.4, 123.7, 120.4] (paper about 130 deg at 833 kHz)
90 to 100 deg: 1 ROI(s), mean 378% (paper about 300%)
Below 90 deg: 11 ROIs, mean 1460%, median 741%; single-pulse YP there 4.3 to 169.5 above background, so the ratio is unstable
Co- over counter-directional (paper 60-fold): plateau mean / 180 = 85, ROI nearest 0 deg (11.4 deg) / 180 = 56

These numbers come from one pair of dishes that the paper presents as Figure 4, not from the Figure 2 experiments, and they decide no outcome. Near the base the single-pulse YP is high and the ratio is stable; toward the apex the single-pulse YP falls close to the footprint background, so the ratio there swings widely from one ROI to the next.

Blocked claims¶

  • fig2-cancellation-at-180, fig2-crossover-angle, fig2-facilitation-plateau, fig2-60-fold-co-counter: data missing. The 32-ROI measurements of Figure 2, three experiments at each of 1 Hz and 833 kHz, are not in the dandiset; there are no 1 Hz images at all. The Sim4Life field simulation that gives each ROI's angle is not deposited either.
  • fig3-1hz-co-cross-same, fig3-770khz-co-enhanced, fig3-770khz-cross-same-as-1hz: data missing. No image from the square four-electrode array of Figure 3 is in the dandiset (both images show three footprints).
  • fig5-base-sawtooth, fig5-apex-larger-polarization: data missing. The strobe images of FluoVolt-loaded CHO cells are not in the dandiset.

Summary¶

Claim Outcome
fig2-cancellation-at-180 Blocked: data missing
fig2-crossover-angle Blocked: data missing
fig2-facilitation-plateau Blocked: data missing
fig2-60-fold-co-counter Blocked: data missing
fig3-1hz-co-cross-same Blocked: data missing
fig3-770khz-co-enhanced Blocked: data missing
fig3-770khz-cross-same-as-1hz Blocked: data missing
fig4a-single-pulse-follows-field Reproduced (assumed: the file is Figure 4A, and E1 is its lower-right base electrode)
fig4bc-paired-pulse-focal-apex Reproduced (assumed: the single-pulse file is Figure 4A)
fig4-comet-shape Reproduced (assumed: objects bright in MemBrite, likely debris, left out of the maximum)
fig5-base-sawtooth Blocked: data missing
fig5-apex-larger-polarization Blocked: data missing

How the analysis differs from the paper's. The field map is computed here by a two-dimensional finite-difference solve, in place of the paper's Sim4Life model; the electrode positions are measured in the images and the scale is set from the 1.5 mm needles. The measures for the image-only findings of Figure 4 are the claims file's readings, as the paper gives none. The single-pulse file is taken as Figure 4A although its session description gives other settings, and E1 as its lower-right base electrode, which is also the one with the most uptake; where Figure 4A places E1 is not shown here. For the comet shape, the pixels on objects bright in MemBrite (0.54% of the region, several objects, among them the one under the literal maximum, likely debris) are left out before the maximum is taken; with them, the half- and quarter-maximum regions are empty. The comet passes by the reach along the bisecting line against the distance across it; at half maximum the full extent of the region along the line is smaller than across it.

What was found about the dataset. It holds only the two images of Figure 4; the data behind Figures 2, 3 and 5, on which the paper's quantitative findings rest, are not deposited. The image is stored as an ImageSeries whose first axis NWB reads as time, with no pixel size, channel names or intensity unit. The session description of the single-pulse file gives the settings of a Figure 3 experiment (co-directional pairs, 770 kHz, 10 kV/cm), and that of the paired-pulse file an amplitude of 2 kV where the paper gives 4 kV. The file does not say which base electrode is E1. One object about 105 um across, bright in both channels, holds the brightest YP in the paired-pulse image.