Reproducing Semenov et al. (2026) from dandiset 001521¶
Paper: Semenov I, Silkuniene G, Silkunas M, Pakhomova ON, Bixler A, Ibey BL, Kochunov P, Pakhomov AG (2026). Sub-microsecond optical measurements of cell membrane charging and lesioning by pulsed electric fields. Bioelectrochemistry 171, 109283. doi:10.1016/j.bioelechem.2026.109283. The claims file was written from the version of record (main text with Materials and methods, figure legends and Data availability; the paper has no supplement and links no code).
Dandiset: DANDI:001521, version 0.261006.1845. The paper cites no version of the dandiset (its Data availability says data are available on request). This is the only published version, so it is pinned; the draft lists the same 73 assets with the same total size.
What the dandiset holds: 73 widefield Fluo-4 time-lapses of CHO cells (one NWB file each, no ROIs or traces), in two sessions. The 2025-02-03 session (31 files) is the single 1-us pulse experiment of Fig. 6A, and the 2025-05-02 session (42 files) is the 60-pulse 6 Hz train experiment of Fig. 6B. The pulsed-laser potentiometric imaging behind Figs. 1 to 5 and 7 is not in it. Six other dandisets of the same lab name the same paper (001515, 001516, 001517, 001518, 001525 and 001526); by their titles they hold the potentiometric experiments, and they are targets of their own.
Each file gives the pulse as a generator voltage, while the paper gives only field strengths. Two
readings of the conversion are run throughout, and the one that fits the fields and group sizes of Fig. 6B
is adopted (see "Field strengths"). The cell regions are found here
by a standard segmentation, as the paper drew them by hand in MetaMorph (see fluo4.py).
No random numbers are drawn anywhere in this notebook.
| Claim | Status |
|---|---|
| fig6a-ca-entry-1000-1200 | tested |
| fig6a-ca-sustained | tested |
| fig6a-780-not-significant | tested |
| fig6b-train-threshold | tested |
| fig6b-650-weak-but-significant | tested |
| fig5b-conductivity-calibration | tested (a computation from Eq. 2, no recordings) |
| fig1-pole-leakage-pattern, fig2a-fluovolt-saturation, fig2a-fluovolt-stable-within-set, fig2b-di8-decline-within-set, fig2cd-stable-then-drop, fig2c-1000-within-set-trend, fig3-dye-calibration, fig3-fluovolt-nonlinear, fig4a-fluovolt-maximum, fig4b-di8-maximum, fig4b-first-10-pulses, fig4b-cathodic-asymmetry, fig4c-equator-cosine, fig4d-short-pulses-same-limit, fig5a-gd-extends-anodic, fig5a-gd-cathodic-not-higher, fig5c-gd-reduces-conductivity, fig7a-test-pulse-cosine, fig7b-conditioning-sublinear, fig7b-test-pulse-recovery, fig7c-fast-resealing | blocked: data missing (potentiometric imaging not in this dandiset) |
import time
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from scipy import stats
import fluo4
pd.set_option("display.width", 200)
pd.set_option("display.max_rows", 200)
print("dandiset", fluo4.DANDISET_ID, "version", fluo4.DANDISET_VERSION)
dandiset 001521 version 0.261006.1845
t0 = time.time()
files = fluo4.list_files()
res = fluo4.process_all(files.url.tolist())
print(f"{len(res)} files streamed and reduced to cell traces in {time.time() - t0:.0f} s")
73 files streamed and reduced to cell traces in 273 s
Data check¶
Every file is streamed once and reduced, as it loads, to one background-corrected trace per cell (about four minutes). Each check of the metadata against the values is stated under its output.
info = pd.DataFrame([{
"path": p, "set": r["set"], "session_id": r["session_id"], "subject_id": r["subject_id"],
"session_start": r["session_start"], "volts": r["volts"], "n_pulses": r["n_pulses"],
"gap_mm": r["gap_mm"], "rate_hz": r["rate_hz"], "frames": r["shape"][0], "height": r["shape"][1],
"width": r["shape"][2], "duration_s": r["shape"][0] / r["rate_hz"], "pulse_frame": r["pulse_frame"],
"unit": r["unit"], "dtype": r["dtype"], "raw_min": r["raw_min"], "raw_max": r["raw_max"],
"cells": r["dF"].shape[0]} for p, r in zip(files.path, res)])
info["fov"] = info.subject_id.str.replace("CHO_", "")
print("Files by set (session) and voltage:")
print(pd.crosstab(info.set, info.volts, margins=True).to_string())
print("\nFiles by set and pulses per file (0 = sham):")
print(pd.crosstab(info.set, info.n_pulses, margins=True).to_string())
print("\nSubject age and species as the files give them:",
sorted({(r["subject_age"], r["subject_species"]) for r in res}))
print("\nPer set: frames, rate, duration, pulse frame, image size, electrode gap, unit, dtype, raw range")
print(info.groupby("set").agg(files=("path", "size"), subjects=("subject_id", "nunique"),
frames=("frames", "unique"), rate_hz=("rate_hz", "unique"),
duration_s=("duration_s", "unique"), pulse_frame=("pulse_frame", "unique"),
height=("height", "unique"), width=("width", "unique"),
gap_mm=("gap_mm", "unique"), unit=("unit", "unique"), dtype=("dtype", "unique"),
raw_min=("raw_min", "min"), raw_max=("raw_max", "max")).T.to_string())
print("\nGroups and objects held by the files (union over files):")
for key in ["groups", "acquisition", "processing", "stimulus", "analysis"]:
print(f" {key}:", sorted({x for r in res for x in r[key]}))
print("\nImage series descriptions:")
for s in sorted({r["series_description"] for r in res}):
print(" -", s)
print("\nExample session_description (first file of each set):")
for s in ["single", "train"]:
print(f" [{s}]", next(r["description"] for r in res if r["set"] == s)[:400], "...")
Files by set (session) and voltage:
volts 0.0 70.0 100.0 150.0 170.0 180.0 190.0 200.0 210.0 220.0 230.0 250.0 300.0 350.0 400.0 All
set
single 6 0 0 4 0 0 0 5 0 0 0 4 5 5 2 31
train 9 3 1 4 4 1 1 7 1 1 5 4 1 0 0 42
All 15 3 1 8 4 1 1 12 1 1 5 8 6 5 2 73
Files by set and pulses per file (0 = sham):
n_pulses 0 1 60 All
set
single 6 25 0 31
train 9 0 33 42
All 15 25 33 73
Subject age and species as the files give them: [('P1D', 'http://purl.obolibrary.org/obo/NCBITaxon_10029')]
Per set: frames, rate, duration, pulse frame, image size, electrode gap, unit, dtype, raw range
set single train
files 31 42
subjects 6 9
frames [300] [90]
rate_hz [1.0] [0.5]
duration_s [300.0] [180.0]
pulse_frame [60] [15]
height [512] [512]
width [512] [512]
gap_mm [1.0] [1.0]
unit [n.a.] [n.a.]
dtype [uint16] [uint16]
raw_min 119 124
raw_max 52927 6741
Groups and objects held by the files (union over files):
groups: ['acquisition', 'analysis', 'file_create_date', 'general', 'identifier', 'processing', 'session_description', 'session_start_time', 'specifications', 'stimulus', 'timestamps_reference_time']
acquisition: ['TimeLapseImaging']
processing: []
stimulus: []
analysis: []
Image series descriptions:
- Time-lapse recordings of Ca2+ uptake were performed at 0.5 Hz rate for 3 minutes. Recording started 30 s before pulses were applied.Cells were pre-loaded with a calcium-sensitive dye (2.5uM Fluo-4Ca2+) for 30 min.
- Time-lapse recordings of Ca2+ uptake were performed at 1 Hz rate for 5 minutes. Recording started 1 min before pulse was applied.Cells were pre-loaded with a calcium-sensitive dye (2.5uM Fluo-4Ca2+) for 30 min.
Example session_description (first file of each set):
[single] Subject ID: CHO_20250203_c1. Stimulation was applied with a sham recording where no pulse was applied (0 V). Distance between electrodes 1.00 mm, electrode diameter 0.5 mm. Ca2+ uptake in CHO cells was detected using Fluo-4 Ca2+ sensitive dye (cells were loaded with 2.5µM Fluo-4 for 30 min). Recordings were done using FITC filter cube and CoolLED illumination system. ####General experiment descrip ...
[train] Subject ID: CHO_20250502_c1. Stimulation was applied with a sham recording where no pulse was applied (0 V). Distance between electrodes 1.00 mm, electrode diameter 0.5 mm. Ca2+ uptake in CHO cells was detected using Fluo-4 Ca2+ sensitive dye (cells were loaded with 2.5µM Fluo-4 for 30 min). Recordings were done using FITC filter cube and CoolLED illumination system. ####General experiment descrip ...
The files hold one image series each and nothing else (no ROIs, traces, stimulus series or tables). The image unit is "n.a."; the values are raw camera counts (minimum 119, above the camera offset). The rates, frame counts and durations match the series descriptions and Methods 2.4: single pulses at 1 frame/s for 300 s with the pulse at 60 s; trains at 0.5 frame/s for 180 s with the train from 30 s (Methods: "a burst of 60 pulses at 6 Hz began at 30 s ... and recording continued for 150 s", 30 + 150 = 180 s). Every file states an electrode gap of 1.00 mm; the paper's Methods 2.2 give a 1.6 mm spacing. The dandiset's title and description mention only single pulses (next cell), but 33 of the 73 files are trains of 60 pulses; the other 9 files of the train session are shams. Every subject has an age of P1D, though subjects are fields of view of a cell line, and the species NCBITaxon 10029, which the archive's summary names "Cricetulus griseus - Cricetulus aureus", two species for one taxon (next cell).
The archive's metadata, for this dandiset and for the six other dandisets of the lab that name the same paper
(each lists the paper's DOI as IsPublishedIn). By their titles the six hold the membrane polarization and
recovery experiments; they are targets of their own and are not used here.
# Archive metadata (DANDI API) at each dandiset's most recent published version.
from dandi.dandiapi import DandiAPIClient
client = DandiAPIClient()
meta = client.get_dandiset(fluo4.DANDISET_ID, fluo4.DANDISET_VERSION).get_raw_metadata()
print("001521 description:", meta["description"][:420], "...")
print("001521: species", [(s["name"], s["identifier"]) for s in meta["assetsSummary"].get("species", [])])
for d in ["001521", "001515", "001516", "001517", "001518", "001525", "001526"]:
ds = client.get_dandiset(d)
m = ds.get_raw_metadata()
print(f"{d} {ds.version_id}: {m['name']}")
print(" related:", [(r.get("relation"), r.get("identifier")) for r in m.get("relatedResource", [])])
001521 description: This experiment aimed to determine the threshold for calcium uptake in CHO cells subjected to 1-µs electric pulses. Cells were loaded with 2.5 µM Fluo-4, a Ca2+-sensitive dye, for 30 minutes in the dark. Fluo-4 fluorescence was excited using a FITC cube and a CoolLED illumination system. Time-lapse imaging was conducted at 1 frame per second for 5 minutes, with a single 1-µs pulse of varying amplitude applied 1 minut ...
001521: species [('Cricetulus griseus - Cricetulus aureus', 'http://purl.obolibrary.org/obo/NCBITaxon_10029')]
001521 0.261006.1845: Threshold Characterization of Ca2+ Uptake in CHO Cells Following 1-µs Electroporation Pulses
related: [('dcite:IsPublishedIn', '10.1016/j.bioelechem.2026.109283')]
001515 0.261006.1841: Plasma Membrane Polarization Dynamics of CHO Cells During 1 us Pulses: Effects of Cell Size and Electric Field Strength
related: [('dcite:IsPublishedIn', '10.1016/j.bioelechem.2026.109283')]
001516 0.261006.1833: Plasma Membrane Recovery of Cells Following 1 us Electroporation Pulse
related: [('dcite:IsPublishedIn', '10.1016/j.bioelechem.2026.109283')]
001517 0.261006.1856: Evaluation of Di-8-ANEPPS Compatibility with Strobe Fluorescence Microscopy for Membrane Voltage Imaging
related: [('dcite:IsPublishedIn', '10.1016/j.bioelechem.2026.109283')]
001518 0.261006.1957: Comparison of FluoVolt and Di-8-ANEPPS Responses to Plasma Membrane Polarization at Varying Electric Field Strengths
related: [('dcite:IsPublishedIn', '10.1016/j.bioelechem.2026.109283')]
001525 0.261006.1856: Characterization of Plasma Membrane Recovery in Cells After 1-µs Electroporation Using Strobe Fluorescence Microscopy
related: [('dcite:IsPublishedIn', '10.1016/j.bioelechem.2026.109283')]
001526 0.261006.1851: Assessment of Post-Electroporation Suitability of CHO Cells for Polarization Studies Using Strobe Fluorescence Microscopy
related: [('dcite:IsPublishedIn', '10.1016/j.bioelechem.2026.109283')]
# Is a "subject" one cell, one dish or one field of view? Correlate the pre-pulse mean images.
Z = np.array([(r["pre_small"] - r["pre_small"].mean()).ravel() for r in res])
Z /= np.linalg.norm(Z, axis=1, keepdims=True)
C = Z @ Z.T
subj = info.subject_id.to_numpy(dtype=str)
same = subj[:, None] == subj[None, :]
off = ~np.eye(len(res), dtype=bool)
print(f"Correlation of pre-pulse images, files of the same subject: min {C[same & off].min():.3f}, "
f"median {np.median(C[same & off]):.3f} ({(same & off).sum() // 2} pairs)")
print(f"Correlation of pre-pulse images, files of different subjects: max {C[~same].max():.3f}, "
f"median {np.median(C[~same]):.3f}")
dups = [(info.path[i], info.path[j]) for i in range(len(res)) for j in range(i + 1, len(res))
if np.array_equal(res[i]["pre_small"], res[j]["pre_small"])]
print("Pairs of files with identical pre-pulse images:", dups if dups else "none")
order = info.sort_values("session_start")
print("\nRecording order within each subject (voltages in order of session start):")
for s, g in order.groupby("subject_id", sort=False):
print(f" {s}: " + ", ".join(f"{v:.0f} V" for v in g.volts) + f" cells per file {g.cells.tolist()}")
Correlation of pre-pulse images, files of the same subject: min 0.859, median 0.982 (169 pairs) Correlation of pre-pulse images, files of different subjects: max 0.426, median 0.154 Pairs of files with identical pre-pulse images: none Recording order within each subject (voltages in order of session start): CHO_20250203_c1: 0 V, 200 V, 300 V cells per file [5, 5, 5] CHO_20250203_c2: 0 V, 150 V, 200 V, 250 V, 300 V, 350 V cells per file [4, 4, 4, 4, 4, 4] CHO_20250203_c3: 0 V, 150 V, 200 V, 250 V, 300 V, 350 V cells per file [4, 4, 4, 3, 3, 3] CHO_20250203_c4: 0 V, 150 V, 200 V, 250 V, 300 V, 350 V cells per file [7, 7, 7, 7, 7, 7] CHO_20250203_c5: 0 V, 150 V, 200 V, 250 V, 300 V, 350 V, 400 V cells per file [4, 4, 4, 4, 4, 4, 4] CHO_20250203_c6: 0 V, 350 V, 400 V cells per file [3, 3, 3] CHO_20250502_c1: 0 V, 300 V cells per file [6, 6] CHO_20250502_c2: 0 V, 70 V, 150 V, 200 V, 250 V cells per file [12, 12, 13, 12, 12] CHO_20250502_c3: 0 V, 70 V, 150 V, 200 V, 250 V cells per file [8, 8, 8, 8, 8] CHO_20250502_c4: 0 V, 250 V cells per file [10, 11] CHO_20250502_c5: 0 V, 70 V, 100 V, 150 V, 170 V, 200 V, 230 V, 250 V cells per file [8, 8, 8, 8, 8, 8, 8, 8] CHO_20250502_c6: 0 V, 150 V, 170 V, 200 V, 230 V cells per file [9, 9, 9, 10, 9] CHO_20250502_c7: 0 V, 170 V, 200 V, 230 V cells per file [9, 9, 9, 9] CHO_20250502_c8: 0 V, 170 V, 180 V, 190 V, 200 V, 210 V, 220 V, 230 V cells per file [9, 10, 10, 10, 10, 11, 10, 10] CHO_20250502_c9: 0 V, 200 V, 230 V cells per file [11, 11, 11]
A subject is a field of view: the files of one subject show the same image (correlation 0.86 or more) and different subjects differ (0.43 at most). No file is a copy of another. Each field of view was recorded first without a pulse and then at escalating voltages, so the same cells were exposed up to seven times. For the trains this disagrees with Section 3.6 ("Each cell was tested only once"); for single pulses it agrees with the Results' "escalating field strengths". Cells are treated as independent across files of one field of view, as each recording has its own F0; results are broken down by field of view.
# Cells detected, and what was dropped.
seg = pd.concat([r["cells"].assign(file=i, set=r["set"]) for i, r in enumerate(res)])
print("Objects found by the segmentation:", len(seg))
print(" dropped as smaller than", fluo4.MIN_AREA, "px:", int((seg.area < fluo4.MIN_AREA).sum()))
print(" dropped as touching the border:", int((seg.border & (seg.area >= fluo4.MIN_AREA)).sum()))
print(" kept:", int(seg.kept.sum()))
k = seg[seg.kept]
print("Kept cells, equivalent radius (px): median %.1f, range %.1f to %.1f" % (
k.radius_px.median(), k.radius_px.min(), k.radius_px.max()))
print("Fraction of each image used as cell-free background: %.2f to %.2f" % (
min(r["bg_fraction"] for r in res), max(r["bg_fraction"] for r in res)))
print("\nCell exposures by set and voltage (one per cell per file):")
print(info.pivot_table(index="set", columns="volts", values="cells", aggfunc="sum", margins=True).to_string())
first = order.groupby("subject_id").first()
print("\nCells per field of view, counted in its first (sham) recording:")
print(first.groupby("set").cells.agg(["sum", "min", "max", "size"]).to_string())
Objects found by the segmentation: 608
dropped as smaller than 400 px: 69
dropped as touching the border: 7
kept: 532
Kept cells, equivalent radius (px): median 29.7, range 12.2 to 46.4
Fraction of each image used as cell-free background: 0.74 to 0.94
Cell exposures by set and voltage (one per cell per file):
volts 0.0 70.0 100.0 150.0 170.0 180.0 190.0 200.0 210.0 220.0 230.0 250.0 300.0 350.0 400.0 All
set
single 27.0 NaN NaN 19.0 NaN NaN NaN 24.0 NaN NaN NaN 18.0 23.0 21.0 7.0 139
train 82.0 28.0 8.0 38.0 36.0 10.0 10.0 68.0 11.0 10.0 47.0 39.0 6.0 NaN NaN 393
All 109.0 28.0 8.0 57.0 36.0 10.0 10.0 92.0 11.0 10.0 47.0 57.0 29.0 21.0 7.0 532
Cells per field of view, counted in its first (sham) recording:
sum min max size
set
single 27 3 7 6
train 82 6 12 9
Of 608 objects, 76 were dropped (69 too small, 7 on the border). The single-pulse session holds 27 distinct cells in 6 fields of view (139 cell recordings); the paper averaged "across 14 cells" (Fig. 6A legend), which does not match either count, and the dataset does not say which 14. The train session holds 82 distinct cells in 9 fields of view (311 cell exposures to trains); the paper reports 224 cells, each tested once.
cells = pd.concat([pd.DataFrame({
"file": i, "set": r["set"], "fov": info.fov[i], "volts": r["volts"], "cell": np.arange(len(r["F0"])),
"F0": r["F0"], "end": fluo4.end_value(r["dF"]), "peak": fluo4.peak_value(r["dF"], r["pulse_frame"])})
for i, r in enumerate(res)], ignore_index=True)
print("F0 (background-corrected camera counts) by set: median %s, range %s" % (
cells.groupby("set").F0.median().round(1).to_dict(),
cells.groupby("set").F0.agg(["min", "max"]).round(1).to_dict("index")))
print("Background (cell-free median, counts), range over files and frames:",
{s: (round(min(r["bg"].min() for r in res if r["set"] == s), 1),
round(max(r["bg"].max() for r in res if r["set"] == s), 1)) for s in ["single", "train"]})
print("dF range over all cells and frames (%):", round(min(r["dF"].min() for r in res), 1),
"to", round(max(r["dF"].max() for r in res), 1))
for s in ["single", "train"]:
sham = np.concatenate([r["dF"] for r in res if r["set"] == s and r["volts"] == 0])
pf = next(r["pulse_frame"] for r in res if r["set"] == s)
print(f"Sham ({s}), {len(sham)} cells: mean dF at frame 0 {sham[:, 0].mean():.1f}%, "
f"at the pulse frame {sham[:, pf].mean():.1f}%, over the last {fluo4.END_FRAMES} frames "
f"{sham[:, -fluo4.END_FRAMES:].mean():.1f}%")
F0 (background-corrected camera counts) by set: median {'single': 504.70001220703125, 'train': 128.10000610351562}, range {'single': {'min': 178.1999969482422, 'max': 1165.5}, 'train': {'min': 39.70000076293945, 'max': 623.2000122070312}}
Background (cell-free median, counts), range over files and frames: {'single': (np.float32(242.0), np.float32(265.0)), 'train': (np.float32(226.0), np.float32(260.0))}
dF range over all cells and frames (%): -39.1 to 872.1
Sham (single), 27 cells: mean dF at frame 0 3.5%, at the pulse frame -1.6%, over the last 10 frames -12.0%
Sham (train), 82 cells: mean dF at frame 0 3.0%, at the pulse frame -2.9%, over the last 10 frames -15.7%
Without a pulse, dF falls steadily through the recording, to -12% (single-pulse session) and -16% (train session) over the last 10 frames, with F0 taken before the pulse as Methods 2.4 say. The next cell shows where the decline comes from.
# Where does the decline of unpulsed cells come from? Per set and voltage: the end value (last 10
# frames) relative to the pre-pulse mean of the background-corrected cell signal (dF), of the raw
# cell signal without background subtraction, of the cell-free background, and of the whole frame.
def rel_end(x, pf):
x = np.asarray(x, float)
return 100 * (x[..., -fluo4.END_FRAMES:].mean(-1) / x[..., :pf].mean(-1) - 1)
rows = []
for s in ["single", "train"]:
for v in sorted(info.volts[info.set == s].unique()):
rs = [r for r in res if r["set"] == s and r["volts"] == v]
pf = rs[0]["pulse_frame"]
Fc = [r["F0"][:, None] * (1 + r["dF"] / 100) for r in rs] # background-corrected F
raw = [F + r["bg"][None] for F, r in zip(Fc, rs)] # F before background subtraction
rows.append({"set": s, "volts": v, "files": len(rs), "cells": sum(len(F) for F in Fc),
"dF_end": np.concatenate([rel_end(F, pf) for F in Fc]).mean(),
"raw_cell_end": np.concatenate([rel_end(F, pf) for F in raw]).mean(),
"background_end": np.mean([rel_end(r["bg"], pf) for r in rs]),
"whole_frame_end": np.mean([rel_end(r["frame_mean"], pf) for r in rs]),
"background_over_F0": np.mean([r["bg"][:pf].mean() / r["F0"].mean() for r in rs])})
print("End value relative to the pre-pulse mean (%), by set and voltage:")
print(pd.DataFrame(rows).round(2).to_string(index=False))
# Decline before the pulse (no pulse has yet been given in that recording), by recording order
# within the field of view: slope of each cell's dF over the pre-pulse frames, in % per minute.
rec = order.assign(order_in_fov=order.groupby("subject_id").cumcount() + 1)
pre = []
for i, r in enumerate(res):
pf, t_min = r["pulse_frame"], np.arange(r["pulse_frame"]) / r["rate_hz"] / 60
for c in range(len(r["dF"])):
pre.append({"set": r["set"], "volts": r["volts"], "order_in_fov": int(rec.order_in_fov[i]),
"slope_pct_per_min": np.polyfit(t_min, r["dF"][c, :pf], 1)[0]})
pre = pd.DataFrame(pre)
print("\nPre-pulse slope of dF (% per min), by recording order within the field of view (1 = the sham):")
print(pre.groupby(["set", "order_in_fov"]).slope_pct_per_min.agg(["size", "mean", "sem"]).round(2).unstack(0).to_string())
fig, axes = plt.subplots(1, 2, figsize=(10, 3))
for ax, s in zip(axes, ["single", "train"]):
rs = [r for r in res if r["set"] == s and r["volts"] == 0]
pf, t = rs[0]["pulse_frame"], np.arange(rs[0]["shape"][0]) / rs[0]["rate_hz"]
Fc = np.concatenate([r["F0"][:, None] * (1 + r["dF"] / 100) for r in rs])
raw = np.concatenate([r["F0"][:, None] * (1 + r["dF"] / 100) + r["bg"][None] for r in rs])
bg = np.array([r["bg"] for r in rs])
for y, lab in [(Fc, "dF, background-corrected"), (raw, "raw cell signal"), (bg, "background")]:
ax.plot(t, 100 * (y.mean(0) / y[:, :pf].mean() - 1), label=lab)
ax.axhline(0, color="k", lw=0.5, ls=":")
ax.set_title(f"sham, {s} session ({len(Fc)} cells)", fontsize=9)
ax.set_xlabel("time (s)")
axes[0].set_ylabel("change from pre-pulse mean (%)")
axes[0].legend(fontsize=7)
plt.tight_layout()
plt.show()
print("Plotted range (%):", {s: (round(float(d.min()), 1), round(float(d.max()), 1))
for s, d in [("single", np.concatenate([r["dF"] for r in res if r["set"] == "single" and r["volts"] == 0]).mean(0)),
("train", np.concatenate([r["dF"] for r in res if r["set"] == "train" and r["volts"] == 0]).mean(0))]})
End value relative to the pre-pulse mean (%), by set and voltage: set volts files cells dF_end raw_cell_end background_end whole_frame_end background_over_F0 single 0.0 6 27 -12.02 -8.70 -0.98 -1.98 0.45 single 150.0 4 19 -10.26 -7.26 -0.46 -1.26 0.41 single 200.0 5 24 -7.42 -5.05 -0.18 -0.84 0.47 single 250.0 4 18 -5.44 -3.76 -0.38 -0.78 0.47 single 300.0 5 23 -3.83 -2.51 -0.20 -0.39 0.50 single 350.0 5 21 1.24 0.76 -0.04 0.02 0.56 single 400.0 2 7 1.47 1.05 -0.02 0.26 0.67 train 0.0 9 82 -15.68 -7.47 -0.55 -2.05 1.26 train 70.0 3 28 -10.15 -4.25 -0.25 -1.02 1.44 train 100.0 1 8 -9.03 -2.95 -0.17 -0.64 2.19 train 150.0 4 38 -8.11 -2.83 -0.01 -0.49 1.93 train 170.0 4 36 -9.17 -3.37 -0.12 -0.67 2.14 train 180.0 1 10 -6.43 -2.17 0.04 -0.35 2.00 train 190.0 1 10 0.98 -0.32 0.09 0.06 2.22 train 200.0 7 68 -1.98 -0.64 -0.05 -0.11 2.03 train 210.0 1 11 6.42 1.48 0.13 0.39 2.46 train 220.0 1 10 9.63 2.96 0.03 0.41 2.39 train 230.0 5 47 46.19 15.87 0.76 3.91 2.26 train 250.0 4 39 80.99 36.06 1.78 9.31 1.59 train 300.0 1 6 423.10 206.09 7.81 39.11 1.06 Pre-pulse slope of dF (% per min), by recording order within the field of view (1 = the sham):
size mean sem set single train single train single train order_in_fov 1 27.0 82.0 -3.97 -11.39 0.55 0.81 2 27.0 84.0 -2.61 -5.12 0.23 0.54 3 27.0 68.0 -1.97 -4.48 0.37 0.70 4 18.0 57.0 -1.78 -2.04 0.31 0.74 5 18.0 47.0 -1.46 -2.98 0.27 1.19 6 18.0 19.0 -0.41 -2.75 0.60 1.36 7 4.0 18.0 -0.95 -2.04 0.51 1.79 8 NaN 18.0 NaN -5.40 NaN 2.26
Plotted range (%): {'single': (-12.5, 3.5), 'train': (-16.3, 3.0)}
The decline is in the cells' own fluorescence, not in the background handling. In the sham recordings the raw cell signal, before any background is subtracted, falls by 8.7% (single) and 7.5% (train), while the cell-free background falls by 1.0% and 0.6%. Subtracting the background enlarges the relative decline (to -12.0% and -15.7%), because the background is 0.45 (single) to 1.26 (train) times the background-corrected cell signal; no choice of background region removes it, and dropping the subtraction, which Methods 2.4 prescribe, still leaves -8.7%. Fig. 6A of the paper shows the 0 V/cm mean trace rising to about +1% by 300 s, and the Fig. 6B traces are flat near zero, so the recordings or the processing behind the figure differ from these files in a way the paper does not describe (a question for the authors, see the summary).
The decline is also steepest in the first recording of a field of view and smaller in later ones: before the pulse, dF falls by 3.97% per minute in the first (sham) recording of the single-pulse session and by 1.46 to 0.41% per minute in the fifth to seventh; in the train session by 11.4% per minute in the first and 2.0 to 5.4% per minute later, without a steady trend after the first. As each field of view was recorded at escalating voltages, voltage and recording order go together, and any end value compared with the sham, which is always first, carries part of this difference. The step of dF at the pulse (shown for Fig. 6A below) does not.
# Segmentation check: the regions on the pre-pulse mean image of each field of view's first recording
# (images shown at the 4-fold reduced size kept while loading).
firsts = order.groupby("subject_id", sort=False).head(1)
fig, axes = plt.subplots(3, 5, figsize=(15, 9))
for ax, (i, row) in zip(axes.ravel(), firsts.iterrows()):
r = res[i]
ax.imshow(r["pre_small"], cmap="gray")
for c in r["cells"].itertuples():
col = "lime" if c.kept else "red"
ax.add_patch(plt.Circle((c.x / 4, c.y / 4), c.radius_px / 4, fill=False, color=col, lw=0.6))
if c.kept:
ax.add_patch(plt.Circle((c.x / 4, c.y / 4), fluo4.ROI_FRACTION * c.radius_px / 4, fill=False,
color="yellow", lw=0.6))
ax.set_title(f"{row.subject_id} ({row.set}): {int(r['cells'].kept.sum())} kept", fontsize=8)
ax.axis("off")
for ax in axes.ravel()[len(firsts):]:
ax.axis("off")
plt.tight_layout()
plt.show()
print("Green: object outline as a circle of its equivalent radius (kept); red: dropped; yellow: the region used.")
Green: object outline as a circle of its equivalent radius (kept); red: dropped; yellow: the region used.
The regions sit on the cells in every field of view; the dropped objects are small specks and objects cut by the image border.
# Does the response start at the stated pulse time? Step = mean dF over W frames after a putative
# pulse frame minus the W frames before it, strongly pulsed files minus sham files, with the
# putative pulse frame shifted.
def step(r, at, w):
m = r["dF"].mean(0)
return m[at:at + w].mean() - m[at - w:at].mean()
for s, strong, w in [("single", 350, 10), ("train", 230, 5)]:
rs = [r for r in res if r["set"] == s]
pf, n = rs[0]["pulse_frame"], rs[0]["shape"][0]
shifts = np.arange(-pf + w, n - pf - w)
contrast = [np.mean([step(r, pf + d, w) for r in rs if r["volts"] >= strong])
- np.mean([step(r, pf + d, w) for r in rs if r["volts"] == 0]) for d in shifts]
best = shifts[int(np.argmax(contrast))]
rise = [int(np.argmax(np.diff(r["dF"].mean(0)))) + 1 for r in rs if r["volts"] >= strong]
print(f"{s}: stated pulse at frame {pf} ({fluo4.PULSE_S[s]:.0f} s). Contrast (>= {strong} V minus sham) "
f"at the stated frame {contrast[list(shifts).index(0)]:.2f}%, largest at shift {best:+d} frames "
f"({max(contrast):.2f}%); at shifts -{w}, +{w}: "
f"{contrast[list(shifts).index(-w)]:.2f}%, {contrast[list(shifts).index(w)]:.2f}%")
print(f" frame of steepest rise of the mean trace in the {len(rise)} files at >= {strong} V: "
f"{sorted(rise)}")
single: stated pulse at frame 60 (60 s). Contrast (>= 350 V minus sham) at the stated frame 6.49%, largest at shift +1 frames (7.02%); at shifts -10, +10: 0.17%, 1.85% frame of steepest rise of the mean trace in the 7 files at >= 350 V: [40, 61, 61, 61, 67, 83, 249] train: stated pulse at frame 15 (30 s). Contrast (>= 230 V minus sham) at the stated frame 79.10%, largest at shift +3 frames (146.56%); at shifts -5, +5: 1.57%, 116.57% frame of steepest rise of the mean trace in the 10 files at >= 230 V: [17, 18, 18, 18, 18, 19, 20, 20, 20, 33]
The recordings share the clock the descriptions give: the difference between strongly pulsed and sham files in the step of dF across a putative pulse frame is largest within one frame of the stated pulse for single pulses (frame 60 against 61), and three frames after the train onset for trains (the train lasts 10 s, five frames). With the pulse frame shifted 10 frames earlier (single) or 5 earlier (train), the contrast is near zero. This is a check of the stated timing against the responses; it is consistent with the data in both sessions.
Field strengths¶
The paper gives field strengths (Sim4Life values 5 um above the glass, Methods 2.2); the files give only the generator voltage, and neither states the conversion. Two readings are run (author, step 1.6):
- M1, 3.9 V/cm per V, adopted as the main reading (an assumption, see below). The single-pulse voltages 150, 200, 250, 300 and 350 V then give 585, 780, 975, 1170 and 1365 V/cm, the field series the paper uses throughout (570, 780, 1000, 1200, 1400 V/cm), and the train voltages 70, 150, 170, 200, 230 and 250 V fall nearest the six fields labelled in Fig. 6B (230, 570, 650, 780, 900, 1000 V/cm).
- M2, 3.3 V/cm per V, a variant. The train voltages 70, 200 and 230 V then give 231, 660 and 759 V/cm, the 230, 650 and 780 V/cm named for the trains in Section 3.6.
A field the paper names is assigned to the voltage whose field under the reading is nearest. Neither factor follows from the electrode gap (V/d is 10 V/cm per V for the 1.00 mm the files state and 6.25 V/cm per V for the 1.6 mm of Methods 2.2), as the paper's fields are simulated at the cells.
print("Field strength (V/cm) of each voltage under the two readings of the conversion:")
volts = sorted(info.volts.unique())
print(pd.DataFrame({m: fluo4.field(volts, m).round() for m in fluo4.FIELD_PER_VOLT}, index=volts).T.to_string())
avail = {s: sorted(info.volts[info.set == s].unique()) for s in ["single", "train"]}
named = {"single": [780, 1000, 1200], "train": [230, 570, 650, 780, 900, 1000]}
for s in named:
for m in fluo4.FIELD_PER_VOLT:
print(f" {s}, {m}: " + ", ".join(
f"{f} V/cm -> {fluo4.voltage_for(f, m, avail[s]):.0f} V "
f"({fluo4.field(fluo4.voltage_for(f, m, avail[s]), m):.0f} V/cm)" for f in named[s]))
def group_table(d, value):
"""Per voltage: n, mean, s.e., one-sample t-test against 0 and Student's t-test against sham."""
sham = d[d.volts == 0][value]
rows = []
for v, g in d.groupby("volts"):
x = g[value]
one = stats.ttest_1samp(x, 0)
two = stats.ttest_ind(x, sham) if v != 0 else None
rows.append({"volts": v, "n": len(x), "mean": x.mean(), "se": x.sem(),
"t_vs_0": one.statistic, "p_vs_0": one.pvalue,
"diff_vs_sham": x.mean() - sham.mean() if v != 0 else np.nan,
"p_vs_sham": two.pvalue if two else np.nan})
return pd.DataFrame(rows).set_index("volts")
# Fig. 6B: the fields labelled on its panels and the n printed below each (the paper's figure, a
# reference for the comparison, not a result).
FIG6B_N = {230: 28, 570: 37, 650: 34, 780: 54, 900: 35, 1000: 36}
n_by_volts = info[info.set == "train"].groupby("volts").cells.sum()
print("\nFig. 6B groups against the dataset's train groups under each reading (nearest voltage):")
rows = []
for m in fluo4.FIELD_PER_VOLT:
for f, n_paper in FIG6B_N.items():
v = fluo4.voltage_for(f, m, avail["train"])
rows.append({"mapping": m, "field_fig6b": f, "n_fig6b": n_paper, "volts": v,
"field_of_volts": round(fluo4.field(v, m)), "n_dataset": int(n_by_volts[v])})
g6b = pd.DataFrame(rows)
print(g6b.to_string(index=False))
for m, g in g6b.groupby("mapping"):
print(f" {m}: distinct voltages used {g.volts.nunique()} of 6; sum of |n_dataset - n_fig6b| = "
f"{int((g.n_dataset - g.n_fig6b).abs().sum())}; total n {int(g.drop_duplicates('volts').n_dataset.sum())} "
f"(Fig. 6B {sum(FIG6B_N.values())})")
print("Train voltages in the dataset that no Fig. 6B field takes under M1:",
[v for v in avail["train"] if v > 0 and v not in set(g6b[g6b.mapping == "M1"].volts)])
Field strength (V/cm) of each voltage under the two readings of the conversion:
0.0 70.0 100.0 150.0 170.0 180.0 190.0 200.0 210.0 220.0 230.0 250.0 300.0 350.0 400.0
M1 0.0 273.0 390.0 585.0 663.0 702.0 741.0 780.0 819.0 858.0 897.0 975.0 1170.0 1365.0 1560.0
M2 0.0 231.0 330.0 495.0 561.0 594.0 627.0 660.0 693.0 726.0 759.0 825.0 990.0 1155.0 1320.0
single, M1: 780 V/cm -> 200 V (780 V/cm), 1000 V/cm -> 250 V (975 V/cm), 1200 V/cm -> 300 V (1170 V/cm)
single, M2: 780 V/cm -> 250 V (825 V/cm), 1000 V/cm -> 300 V (990 V/cm), 1200 V/cm -> 350 V (1155 V/cm)
train, M1: 230 V/cm -> 70 V (273 V/cm), 570 V/cm -> 150 V (585 V/cm), 650 V/cm -> 170 V (663 V/cm), 780 V/cm -> 200 V (780 V/cm), 900 V/cm -> 230 V (897 V/cm), 1000 V/cm -> 250 V (975 V/cm)
train, M2: 230 V/cm -> 70 V (231 V/cm), 570 V/cm -> 170 V (561 V/cm), 650 V/cm -> 200 V (660 V/cm), 780 V/cm -> 230 V (759 V/cm), 900 V/cm -> 250 V (825 V/cm), 1000 V/cm -> 300 V (990 V/cm)
Fig. 6B groups against the dataset's train groups under each reading (nearest voltage):
mapping field_fig6b n_fig6b volts field_of_volts n_dataset
M1 230 28 70.0 273 28
M1 570 37 150.0 585 38
M1 650 34 170.0 663 36
M1 780 54 200.0 780 68
M1 900 35 230.0 897 47
M1 1000 36 250.0 975 39
M2 230 28 70.0 231 28
M2 570 37 170.0 561 36
M2 650 34 200.0 660 68
M2 780 54 230.0 759 47
M2 900 35 250.0 825 39
M2 1000 36 300.0 990 6
M1: distinct voltages used 6 of 6; sum of |n_dataset - n_fig6b| = 32; total n 256 (Fig. 6B 224)
M2: distinct voltages used 6 of 6; sum of |n_dataset - n_fig6b| = 76; total n 224 (Fig. 6B 224)
Train voltages in the dataset that no Fig. 6B field takes under M1: [np.float64(100.0), np.float64(180.0), np.float64(190.0), np.float64(210.0), np.float64(220.0), np.float64(300.0)]
Under M1 the six Fig. 6B fields take six different train voltages, and the dataset's group sizes are close to the figure's n (equal at 230 V/cm, and within 3 at 570, 650 and 1000 V/cm; larger at 780 and 900 V/cm). Under M2, 900 and 1000 V/cm fall on 250 and 300 V, and the 300 V group has 6 cells where the figure has 36; the six M2 groups happen to sum to 224, the paper's total, while the M1 groups sum to 256. The same electrodes and generator served both sessions (Methods 2.2), so M1 is adopted for both sessions, as an assumption; M2 is still shown for every claim. The train voltages 100, 180, 190, 210, 220 and 300 V are in no Fig. 6B group under M1.
fig6a-ca-entry-1000-1200¶
A single 1-us pulse causes clear Ca2+ entry into CHO cells at 1000 and 1200 V/cm, among field strengths escalated from 0 to 1200 V/cm.
Criterion (claims file): per cell dF as in Methods 2.4; reproduced when, under at least one reading, the post-pulse dF at both 1000 and 1200 V/cm is significantly above zero (one-sample t-test) or above the 0 V/cm group (two-tailed Student's t-test) at p < 0.05, and the group means at 1000 and 1200 V/cm exceed those of every field of 650 V/cm or less. The post-pulse value is read as (a) the mean of the last 10 frames or (b) each cell's peak after the pulse. Both conversions are run.
A = cells[cells.set == "single"]
tabs_A = {v: group_table(A, v) for v in ["end", "peak"]}
for v, t in tabs_A.items():
print(f"Single pulses, per-cell {v} dF (%), by voltage:")
print(t.round(4).to_string(), "\n")
rows = []
for m in fluo4.FIELD_PER_VOLT:
v1000, v1200 = (fluo4.voltage_for(f, m, avail["single"]) for f in (1000, 1200))
low = [v for v in avail["single"] if fluo4.field(v, m) <= 650]
for value, t in tabs_A.items():
exceed = all(t.loc[v, "mean"] > t.loc[low, "mean"].max() for v in (v1000, v1200))
for test in ["one-sample vs 0", "Student vs 0 V/cm"]:
if test.startswith("one"):
sig = [t.loc[v, "p_vs_0"] < 0.05 and t.loc[v, "mean"] > 0 for v in (v1000, v1200)]
ps = [t.loc[v, "p_vs_0"] for v in (v1000, v1200)]
else:
sig = [t.loc[v, "p_vs_sham"] < 0.05 and t.loc[v, "diff_vs_sham"] > 0 for v in (v1000, v1200)]
ps = [t.loc[v, "p_vs_sham"] for v in (v1000, v1200)]
rows.append({"mapping": m, "value": value, "test": test, "V_1000": v1000, "V_1200": v1200,
"low_fields_V": low, "mean_1000": t.loc[v1000, "mean"], "mean_1200": t.loc[v1200, "mean"],
"max_low_mean": t.loc[low, "mean"].max(), "p_1000": ps[0], "p_1200": ps[1],
"sig_1000": sig[0], "sig_1200": sig[1], "exceed_low": exceed,
"holds_both": sig[0] and sig[1] and exceed,
"holds_one": (sig[0] or sig[1]) and exceed})
readings_6a = pd.DataFrame(rows)
print(readings_6a.round(6).to_string())
print("\np-values of the table above, in scientific notation:")
print(readings_6a[["mapping", "value", "test", "p_1000", "p_1200"]].to_string(float_format="%.2e"))
for m in fluo4.FIELD_PER_VOLT:
r = readings_6a[readings_6a.mapping == m]
print(f"{m}: readings holding at both fields: {int(r.holds_both.sum())} of {len(r)}; at one or both: "
f"{int(r.holds_one.sum())}")
Single pulses, per-cell end dF (%), by voltage:
n mean se t_vs_0 p_vs_0 diff_vs_sham p_vs_sham
volts
0.0 27 -12.0196 0.6371 -18.865101 0.0000 NaN NaN
150.0 19 -10.2619 0.8786 -11.679300 0.0000 1.7576 0.1037
200.0 24 -7.4192 0.5193 -14.288100 0.0000 4.6003 0.0000
250.0 18 -5.4359 0.5534 -9.823200 0.0000 6.5837 0.0000
300.0 23 -3.8324 0.7148 -5.361300 0.0000 8.1872 0.0000
350.0 21 1.2426 1.9514 0.636800 0.5315 13.2621 0.0000
400.0 7 1.4665 1.8216 0.805100 0.4515 13.4861 0.0000
Single pulses, per-cell peak dF (%), by voltage:
n mean se t_vs_0 p_vs_0 diff_vs_sham p_vs_sham
volts
0.0 27 0.2256 0.2686 0.8400 0.4086 NaN NaN
150.0 19 0.5479 0.3876 1.4137 0.1745 0.3223 0.4834
200.0 24 1.7551 0.6106 2.8744 0.0086 1.5295 0.0211
250.0 18 2.3045 0.2856 8.0683 0.0000 2.0789 0.0000
300.0 23 5.3462 0.7635 7.0027 0.0000 5.1206 0.0000
350.0 21 13.0542 3.1662 4.1230 0.0005 12.8286 0.0000
400.0 7 17.4242 7.8549 2.2183 0.0684 17.1986 0.0001
mapping value test V_1000 V_1200 low_fields_V mean_1000 mean_1200 max_low_mean p_1000 p_1200 sig_1000 sig_1200 exceed_low holds_both holds_one
0 M1 end one-sample vs 0 250.0 300.0 [0.0, 150.0] -5.435900 -3.832385 -10.261928 0.000000 0.000022 False False True False False
1 M1 end Student vs 0 V/cm 250.0 300.0 [0.0, 150.0] -5.435900 -3.832385 -10.261928 0.000000 0.000000 True True True True True
2 M1 peak one-sample vs 0 250.0 300.0 [0.0, 150.0] 2.304490 5.346184 0.547896 0.000000 0.000000 True True True True True
3 M1 peak Student vs 0 V/cm 250.0 300.0 [0.0, 150.0] 2.304490 5.346184 0.547896 0.000006 0.000000 True True True True True
4 M2 end one-sample vs 0 300.0 350.0 [0.0, 150.0] -3.832385 1.242578 -10.261928 0.000022 0.531498 False False True False False
5 M2 end Student vs 0 V/cm 300.0 350.0 [0.0, 150.0] -3.832385 1.242578 -10.261928 0.000000 0.000000 True True True True True
6 M2 peak one-sample vs 0 300.0 350.0 [0.0, 150.0] 5.346184 13.054199 0.547896 0.000000 0.000528 True True True True True
7 M2 peak Student vs 0 V/cm 300.0 350.0 [0.0, 150.0] 5.346184 13.054199 0.547896 0.000000 0.000035 True True True True True
p-values of the table above, in scientific notation:
mapping value test p_1000 p_1200
0 M1 end one-sample vs 0 2.01e-08 2.21e-05
1 M1 end Student vs 0 V/cm 4.89e-09 3.02e-11
2 M1 peak one-sample vs 0 3.25e-07 5.00e-07
3 M1 peak Student vs 0 V/cm 6.14e-06 1.94e-08
4 M2 end one-sample vs 0 2.21e-05 5.31e-01
5 M2 end Student vs 0 V/cm 3.02e-11 5.98e-09
6 M2 peak one-sample vs 0 5.00e-07 5.28e-04
7 M2 peak Student vs 0 V/cm 1.94e-08 3.53e-05
M1: readings holding at both fields: 3 of 4; at one or both: 3
M2: readings holding at both fields: 3 of 4; at one or both: 3
print("Per field of view: mean end and peak dF (%) by voltage, single pulses")
print(A.pivot_table(index="fov", columns="volts", values="end", aggfunc="mean").round(2).to_string())
print(A.pivot_table(index="fov", columns="volts", values="peak", aggfunc="mean").round(2).to_string())
print(A.pivot_table(index="fov", columns="volts", values="end", aggfunc="size").to_string())
fig, axes = plt.subplots(1, len(avail["single"]), figsize=(18, 3.2), sharey=True)
lims = (-25, 40)
t_a = np.arange(300)
outside = 0
for ax, v in zip(axes, avail["single"]):
tr = np.concatenate([r["dF"] for r in res if r["set"] == "single" and r["volts"] == v])
outside += int(((tr < lims[0]) | (tr > lims[1])).any(1).sum())
ax.plot(t_a, tr.T, color="tab:blue", lw=0.4, alpha=0.5)
mu, se = tr.mean(0), tr.std(0, ddof=1) / np.sqrt(len(tr))
ax.fill_between(t_a, mu - se, mu + se, color="0.6")
ax.plot(t_a, mu, color="tab:orange", lw=2)
ax.axvline(60, color="k", lw=0.8)
ax.axhline(0, color="k", lw=0.5, ls=":")
ax.set_title(f"{v:.0f} V\nM1 {fluo4.field(v, 'M1'):.0f}, M2 {fluo4.field(v, 'M2'):.0f} V/cm", fontsize=9)
ax.set_xlabel("time (s)")
axes[0].set_ylim(*lims)
axes[0].set_ylabel("Fluo-4 dF (%)")
plt.tight_layout()
plt.show()
print(f"Axis {lims}; cell traces with points outside it: {outside}")
Per field of view: mean end and peak dF (%) by voltage, single pulses volts 0.0 150.0 200.0 250.0 300.0 350.0 400.0 fov 20250203_c1 -13.33 NaN -8.03 NaN -4.97 NaN NaN 20250203_c2 -11.54 -8.12 -5.87 -4.10 -3.73 1.38 NaN 20250203_c3 -14.09 -12.99 -9.27 -7.08 -4.47 13.20 NaN 20250203_c4 -11.16 -10.01 -7.02 -5.12 -2.57 -1.09 NaN 20250203_c5 -11.77 -10.12 -7.05 -6.10 -4.25 -0.90 2.88 20250203_c6 -10.05 NaN NaN NaN NaN -2.60 -0.42 volts 0.0 150.0 200.0 250.0 300.0 350.0 400.0 fov 20250203_c1 1.86 NaN 1.57 NaN 8.02 NaN NaN 20250203_c2 0.07 2.14 2.26 2.13 4.28 13.750000 NaN 20250203_c3 -0.58 -1.14 3.25 1.80 4.89 23.370001 NaN 20250203_c4 0.11 0.40 1.17 2.80 5.31 15.190000 NaN 20250203_c5 -0.56 0.90 1.02 2.00 3.48 5.620000 21.950001 20250203_c6 0.10 NaN NaN NaN NaN 6.740000 11.390000 volts 0.0 150.0 200.0 250.0 300.0 350.0 400.0 fov 20250203_c1 5.0 NaN 5.0 NaN 5.0 NaN NaN 20250203_c2 4.0 4.0 4.0 4.0 4.0 4.0 NaN 20250203_c3 4.0 4.0 4.0 3.0 3.0 3.0 NaN 20250203_c4 7.0 7.0 7.0 7.0 7.0 7.0 NaN 20250203_c5 4.0 4.0 4.0 4.0 4.0 4.0 4.0 20250203_c6 3.0 NaN NaN NaN NaN 3.0 3.0
Axis (-25, 40); cell traces with points outside it: 2
# Variants (not the criterion's readings), against two confounds of the end and peak values: the
# decline of the signal, and the recording order (the sham is always recorded first, and the decline
# is steepest in the first recording, see the data check).
# (1) Step at the pulse: each cell's mean dF over the 5 frames after the pulse less the 5 before it.
A = A.assign(step=[s for r in res if r["set"] == "single" for s in
(r["dF"][:, r["pulse_frame"]:r["pulse_frame"] + 5].mean(1)
- r["dF"][:, r["pulse_frame"] - 5:r["pulse_frame"]].mean(1))])
print("Step at the pulse (%), single pulses, by voltage:")
print(group_table(A, "step").round(4).to_string())
print("p-values:", group_table(A, "step")[["p_vs_0", "p_vs_sham"]].to_string(float_format="%.2e"))
# (2) Fields of view as the unit: per field of view, mean at the voltage less mean at its own sham,
# one-sample t-test over fields of view.
rows = []
for value in ["end", "peak", "step"]:
fm = A.pivot_table(index="fov", columns="volts", values=value, aggfunc="mean")
for v in avail["single"][1:]:
d = (fm[v] - fm[0]).dropna()
rows.append({"value": value, "volts": v, "n_fov": len(d), "mean_diff_vs_own_sham": d.mean(),
"min_diff": d.min(), "p_one_sample": stats.ttest_1samp(d, 0).pvalue if len(d) > 1 else np.nan})
print("\nPer field of view, value at the voltage less its own sham (t-test over fields of view):")
print(pd.DataFrame(rows).round(4).to_string(index=False))
# (3) Fig. 6A averages 14 cells: cells per field of view and voltage, and the voltage groups' sizes.
n_fv = A.pivot_table(index="fov", columns="volts", values="end", aggfunc="size")
print("\nCells per voltage group:", A.groupby("volts").size().to_dict(),
"; cells per field of view:", n_fv[0].astype(int).to_dict())
from itertools import combinations
sizes = n_fv[0].astype(int)
print("Sets of fields of view whose cells sum to 14:",
[c for k in range(1, len(sizes) + 1) for c in combinations(sizes.index, k) if sizes[list(c)].sum() == 14])
Step at the pulse (%), single pulses, by voltage:
n mean se t_vs_0 p_vs_0 diff_vs_sham p_vs_sham
volts
0.0 27 -0.2595 0.1801 -1.4414 0.1614 NaN NaN
150.0 19 -0.6440 0.1492 -4.3163 0.0004 -0.3845 0.1294
200.0 24 0.1467 0.1595 0.9201 0.3671 0.4063 0.1011
250.0 18 0.3498 0.1721 2.0329 0.0580 0.6093 0.0247
300.0 23 1.7769 0.3063 5.8022 0.0000 2.0365 0.0000
350.0 21 3.9366 0.7091 5.5517 0.0000 4.1961 0.0000
400.0 7 5.7111 1.7651 3.2355 0.0178 5.9706 0.0000
p-values: p_vs_0 p_vs_sham
volts
0.0 1.61e-01 NaN
150.0 4.16e-04 1.29e-01
200.0 3.67e-01 1.01e-01
250.0 5.80e-02 2.47e-02
300.0 7.74e-06 3.16e-07
350.0 1.96e-05 7.38e-08
400.0 1.78e-02 3.19e-07
Per field of view, value at the voltage less its own sham (t-test over fields of view):
value volts n_fov mean_diff_vs_own_sham min_diff p_one_sample
end 150.0 4 1.830300 1.0953 0.0437
end 200.0 5 4.929500 4.1435 0.0000
end 250.0 4 6.540700 5.6680 0.0005
end 300.0 5 8.381000 7.5183 0.0000
end 350.0 5 13.720200 7.4522 0.0173
end 400.0 2 12.139800 9.6324 0.1297
peak 150.0 4 0.815600 -0.5574 0.2598
peak 200.0 5 1.672600 -0.2919 0.0688
peak 250.0 4 2.420900 2.0598 0.0004
peak 300.0 5 5.015700 4.0346 0.0002
peak 350.0 5 13.105100 6.1786 0.0157
peak 400.0 2 16.897301 11.2854 0.2041
step 150.0 4 -0.390900 -0.9283 0.2121
step 200.0 5 0.452200 0.0001 0.0616
step 250.0 4 0.672600 0.2968 0.0165
step 300.0 5 2.169700 1.5319 0.0010
step 350.0 5 4.279800 2.2491 0.0046
step 400.0 2 6.008900 5.8127 0.0208
Cells per voltage group: {0.0: 27, 150.0: 19, 200.0: 24, 250.0: 18, 300.0: 23, 350.0: 21, 400.0: 7} ; cells per field of view: {'20250203_c1': 5, '20250203_c2': 4, '20250203_c3': 4, '20250203_c4': 7, '20250203_c5': 4, '20250203_c6': 3}
Sets of fields of view whose cells sum to 14: [('20250203_c2', '20250203_c4', '20250203_c6'), ('20250203_c3', '20250203_c4', '20250203_c6'), ('20250203_c4', '20250203_c5', '20250203_c6')]
Result: reproduced, under both conversions. Under M1 (250 and 300 V), three of the four readings hold at both fields: the end value against the 0 V/cm group (p = 4.9e-09 and 3.0e-11), and the peak against zero or against the 0 V/cm group (means 2.30 and 5.35% against at most 0.55% at 650 V/cm or less). The end value against zero fails: it is below zero at both fields (-5.44 and -3.83%), as is the sham (-12.0%). Under M2 (300 and 350 V) the same three readings hold. In every field of view the peak at 250 V is above the peak at 0 V, and at 300 V above the peaks at 0 and 150 V. With fields of view as the unit, the peak less the same field of view's sham is 2.42% at 250 V (4 fields of view, p = 0.0004) and 5.02% at 300 V (5, p = 0.0002).
The end and peak readings compare later recordings with the sham, recorded first, while the decline is steepest in the first recording (data check). The step at the pulse, which that does not affect, is above the sham's at 250 V (0.35 +- 0.17%, against the sham p = 0.025) and 300 V (1.78 +- 0.31%, p = 3.2e-07), and not at 150 or 200 V (p = 0.13 and 0.10); at 250 V it is small and not above zero on its own (p = 0.058). The entry at 1000 V/cm under M1 is therefore a small one, a few tenths of a percent at the pulse, and clearer at 1200 V/cm (1.78%) and above (3.94% at 350 V). The single-pulse session holds 27 cells in 6 fields of view, and three sets of fields of view give 14 cells, so which 14 cells Fig. 6A averages cannot be told from the data.
fig6a-ca-sustained¶
After a single 1-us pulse at 1000 or 1200 V/cm the Ca2+ signal stays elevated through the 5-min observation period.
Criterion (claims file): at both 1000 and 1200 V/cm, the mean dF over the last 10 frames is above zero by a one-sample t-test at p < 0.05; partially reproduced when this holds at one of the two. The ratio of the final to the peak group mean is reported.
rows = []
for m in fluo4.FIELD_PER_VOLT:
for f in (1000, 1200):
v = fluo4.voltage_for(f, m, avail["single"])
t = tabs_A["end"]
tr = np.concatenate([r["dF"] for r in res if r["set"] == "single" and r["volts"] == v]).mean(0)
one_sided = stats.ttest_1samp(A[A.volts == v].end, 0, alternative="greater").pvalue
rows.append({"mapping": m, "field": f, "volts": v, "n": t.loc[v, "n"], "end_mean": t.loc[v, "mean"],
"end_se": t.loc[v, "se"], "p_vs_0_two_sided": t.loc[v, "p_vs_0"],
"p_above_0_one_sided": one_sided, "above_zero": t.loc[v, "mean"] > 0 and t.loc[v, "p_vs_0"] < 0.05,
"peak_of_mean_trace": tr[60:].max(), "final_over_peak": tr[-fluo4.END_FRAMES:].mean() / tr[60:].max(),
"end_minus_sham": t.loc[v, "diff_vs_sham"], "p_vs_sham": t.loc[v, "p_vs_sham"]})
print(pd.DataFrame(rows).round(6).to_string())
print("p-values:")
print(pd.DataFrame(rows)[["mapping", "field", "volts", "p_vs_0_two_sided", "p_above_0_one_sided", "p_vs_sham"]]
.to_string(float_format="%.2e"))
mapping field volts n end_mean end_se p_vs_0_two_sided p_above_0_one_sided above_zero peak_of_mean_trace final_over_peak end_minus_sham p_vs_sham 0 M1 1000 250.0 18 -5.435900 0.553374 0.000000 1.000000 False 0.085884 -63.293648 6.583669 0.0 1 M1 1200 300.0 23 -3.832385 0.714819 0.000022 0.999989 False 1.581884 -2.422671 8.187184 0.0 2 M2 1000 300.0 23 -3.832385 0.714819 0.000022 0.999989 False 1.581884 -2.422671 8.187184 0.0 3 M2 1200 350.0 21 1.242578 1.951384 0.531498 0.265749 False 6.779093 0.183296 13.262148 0.0 p-values: mapping field volts p_vs_0_two_sided p_above_0_one_sided p_vs_sham 0 M1 1000 2.50e+02 2.01e-08 1.00e+00 4.89e-09 1 M1 1200 3.00e+02 2.21e-05 1.00e+00 3.02e-11 2 M2 1000 3.00e+02 2.21e-05 1.00e+00 3.02e-11 3 M2 1200 3.50e+02 5.31e-01 2.66e-01 5.98e-09
# Variant (not the claim's criterion): traces less the mean sham trace of the same field of view.
sham_by_fov = {info.fov[i]: r["dF"].mean(0) for i, r in enumerate(res) if r["set"] == "single" and r["volts"] == 0}
rows = []
for v in avail["single"][1:]:
corr = np.concatenate([r["dF"] - sham_by_fov[info.fov[i]] for i, r in enumerate(res)
if r["set"] == "single" and r["volts"] == v])
e = corr[:, -fluo4.END_FRAMES:].mean(1)
rows.append({"volts": v, "n": len(e), "end_minus_own_sham": e.mean(), "se": e.std(ddof=1) / np.sqrt(len(e)),
"p_vs_0": stats.ttest_1samp(e, 0).pvalue})
print("End dF less the same field of view's sham trace (variant):")
print(pd.DataFrame(rows).set_index("volts").to_string(float_format="%.3g"))
End dF less the same field of view's sham trace (variant):
n end_minus_own_sham se p_vs_0
volts
150.0 19 1.72 0.821 0.0502
200.0 24 4.85 0.484 7.45e-10
250.0 18 6.43 0.525 7.33e-10
300.0 23 8.35 0.7 4.46e-11
350.0 21 12.9 2.1 5.57e-06
400.0 7 12.5 1.98 0.000728
Result: not reproduced, under both conversions. The end-of-recording dF is below zero at 1000 and 1200 V/cm under M1 (-5.44 and -3.83%) and at 1000 V/cm under M2 (-3.83%); at 1200 V/cm under M2 (350 V) it is +1.24% with p = 0.53. The final over peak ratio of the group mean trace is -63 and -2.4 under M1. The decline is the one the sham shows, and it is in the cells' raw fluorescence (-8.7% in the sham), not in the background (-1.0%), so it does not come from how the background was taken (data check). Against the 0 V/cm group the end value is higher by 6.6 and 8.2% (p = 4.9e-09 and 3.0e-11), and less the same field of view's sham trace it is above zero at every voltage of 200 V or more (4.8 to 12.9%, p below 0.001), but these comparisons include the change of the decline with recording order. So the signal stays above that of unpulsed cells to the end, but not above its own pre-pulse level, which is what the criterion tests. The paper's Fig. 6A shows no such decline in its 0 V/cm trace, so the outcome rests on a difference between these recordings and the figure that the paper does not explain.
fig6a-780-not-significant¶
After a single 1-us pulse at 780 V/cm the Ca2+ signal rises only subtly, at the detection limit, and not significantly.
Criterion (claims file): a claim of absence, judged on a one-sample t-test of each cell's end-of-recording dF (last 10 frames) against zero at p < 0.05: reproduced when it does not find an increase. The t-test against the 0 V/cm group and each cell's peak are variants.
rows = []
for m in fluo4.FIELD_PER_VOLT:
v = fluo4.voltage_for(780, m, avail["single"])
for value in ["end", "peak", "step"]:
x, sham = A[A.volts == v][value], A[A.volts == 0][value]
rows.append({"mapping": m, "volts": v, "field": fluo4.field(v, m), "value": value, "n": len(x),
"mean": x.mean(), "se": x.sem(),
"p_increase_one_sample": stats.ttest_1samp(x, 0, alternative="greater").pvalue,
"p_two_sided_one_sample": stats.ttest_1samp(x, 0).pvalue,
"diff_vs_sham": x.mean() - sham.mean(), "p_vs_sham_two_tailed": stats.ttest_ind(x, sham).pvalue})
t780 = pd.DataFrame(rows)
print(t780.round(6).to_string())
main = t780[(t780.value == "end")]
print("\nMain reading (one-sample t-test of the end value against zero, p < 0.05 for an increase):")
for _, r in main.iterrows():
print(f" {r.mapping}: {r.volts:.0f} V, mean {r['mean']:.2f} +- {r.se:.2f}%, one-sided p = {r.p_increase_one_sample:.3g}"
f" -> increase found: {r['mean'] > 0 and r.p_two_sided_one_sample < 0.05}")
x200, x250 = A[A.volts == 200].step, A[A.volts == 250].step
print(f"\nStep at 250 V against the step at 200 V (Student's t-test, two-tailed): p = {stats.ttest_ind(x250, x200).pvalue:.3g}")
print("\nVariant reading that detects 1000 and 1200 V/cm under M1: step at the pulse against the 0 V/cm group "
"(Student's t-test, two-tailed):")
for v in (200, 250, 300):
x, sham = A[A.volts == v].step, A[A.volts == 0].step
print(f" {v} V (M1 {fluo4.field(v, 'M1'):.0f} V/cm): {x.mean():.2f} +- {x.sem():.2f}% against {sham.mean():.2f}%, "
f"p = {stats.ttest_ind(x, sham).pvalue:.3g}")
mapping volts field value n mean se p_increase_one_sample p_two_sided_one_sample diff_vs_sham p_vs_sham_two_tailed 0 M1 200.0 780.0 end 24 -7.419224 0.519258 1.000000 0.000000 4.600346 0.000001 1 M1 200.0 780.0 peak 24 1.755120 0.610610 0.004283 0.008566 1.529517 0.021086 2 M1 200.0 780.0 step 24 0.146748 0.159498 0.183546 0.367091 0.406282 0.101080 3 M2 250.0 825.0 end 18 -5.435900 0.553374 1.000000 0.000000 6.583669 0.000000 4 M2 250.0 825.0 peak 18 2.304490 0.285624 0.000000 0.000000 2.078886 0.000006 5 M2 250.0 825.0 step 18 0.349760 0.172050 0.028992 0.057984 0.609295 0.024695 Main reading (one-sample t-test of the end value against zero, p < 0.05 for an increase): M1: 200 V, mean -7.42 +- 0.52%, one-sided p = 1 -> increase found: False M2: 250 V, mean -5.44 +- 0.55%, one-sided p = 1 -> increase found: False Step at 250 V against the step at 200 V (Student's t-test, two-tailed): p = 0.397 Variant reading that detects 1000 and 1200 V/cm under M1: step at the pulse against the 0 V/cm group (Student's t-test, two-tailed): 200 V (M1 780 V/cm): 0.15 +- 0.16% against -0.26%, p = 0.101 250 V (M1 975 V/cm): 0.35 +- 0.17% against -0.26%, p = 0.0247 300 V (M1 1170 V/cm): 1.78 +- 0.31% against -0.26%, p = 3.16e-07
Result: reproduced, under both conversions. The one-sample test finds no increase at 780 V/cm: the end value is -7.42 +- 0.52% under M1 (200 V, n = 24) and -5.44 +- 0.55% under M2 (250 V, n = 18), one-sided p = 1 for an increase in both. This test cannot detect an increase here: it also finds none at 1000 and 1200 V/cm under M1, where the end values are -5.44 and -3.83%. Of the variants, the end value against the 0 V/cm group (higher by 4.60%, p = 1.3e-06) and the peak (1.76 +- 0.61%, against zero p = 0.0086, against the sham peak p = 0.021) find an effect at 780 V/cm under M1, and both also carry the order effect of the data check. The step at the pulse, which separates 1000 and 1200 V/cm from the sham (p = 0.025 and 3.2e-07), finds none at 780 V/cm under M1 (0.15 +- 0.16%, p = 0.10); under M2, where 780 V/cm is 250 V, it finds one (0.35 +- 0.17%, p = 0.025). The step is the only variant not confounded by recording order, but it is weak evidence here: it separates 1000 V/cm from the sham only barely (p = 0.025, and p = 0.058 against zero), and the steps at 780 and 1000 V/cm under M1 (0.15 +- 0.16 and 0.35 +- 0.17%) do not differ (p = 0.40). The end and peak readings against the sham, which also separate 1000 and 1200 V/cm from it, find an effect at 780 V/cm. So the outcome follows the claims file's test, which says little here. A question on the criterion's test is asked of a reader (questions/001521.yaml).
fig6b-train-threshold¶
With trains of sixty 1-us pulses at 6 Hz (each cell tested once, 224 cells), a strong Ca2+ response occurs in 28% of cells at 780 V/cm and in nearly all cells at higher fields.
Criterion (claims file): a strong response is (a) an end-of-recording dF above the largest among the 0 V/cm (or lowest field) cells, or (b) above 3 s.d. of that group, read here as above its mean plus 3 s.d.; reproduced when, under one reading, the fraction at 780 V/cm is within 28 +- 2.8% and at least 90% respond at every field above 780 V/cm. Expected: 224 cells tested with trains.
B = cells[cells.set == "train"]
sham_B, low_B = B[B.volts == 0].end, B[B.volts == min(v for v in avail["train"] if v > 0)].end
rules = {"a: > max of 0 V/cm": sham_B.max(), "a: > max of lowest field": low_B.max(),
"b: > mean + 3 s.d. of 0 V/cm": sham_B.mean() + 3 * sham_B.std(),
"b: > mean + 3 s.d. of lowest field": low_B.mean() + 3 * low_B.std(),
"b literal: > 3 s.d. of 0 V/cm": 3 * sham_B.std(),
"b literal: > 3 s.d. of lowest field": 3 * low_B.std()}
print("Thresholds on the end-of-recording dF (%):", {k: round(v, 2) for k, v in rules.items()})
frac = pd.DataFrame({k: B.groupby("volts").end.apply(lambda x, t=t: 100 * (x > t).mean()) for k, t in rules.items()})
frac.insert(0, "n", B.groupby("volts").size())
for m in fluo4.FIELD_PER_VOLT:
frac[f"field_{m}"] = fluo4.field(frac.index, m).round()
print(frac.round(1).to_string())
rows = []
for m in fluo4.FIELD_PER_VOLT:
v780 = fluo4.voltage_for(780, m, avail["train"])
# the fields above 780 V/cm in Fig. 6B are 900 and 1000 V/cm
higher = sorted({fluo4.voltage_for(f, m, avail["train"]) for f in (900, 1000)})
for k in rules:
rows.append({"mapping": m, "rule": k, "V_780": v780, "pct_780": frac.loc[v780, k],
"in_tolerance": abs(frac.loc[v780, k] - 28) <= 2.8, "higher_V": higher,
"min_pct_higher": frac.loc[higher, k].min(), "all_higher_ge_90": (frac.loc[higher, k] >= 90).all()})
readings_6b = pd.DataFrame(rows)
print(readings_6b.round(1).to_string())
n_exp = int((B.volts > 0).sum())
print(f"\nCell exposures to trains (non-sham files): {n_exp}; with sham files: {len(B)}; "
f"distinct cells (cells in each field of view's sham recording): {int(first[first.set == 'train'].cells.sum())}")
print("Cells per voltage group (paper: 28 to 54 per field strength):", B[B.volts > 0].groupby("volts").size().to_dict())
for m in fluo4.FIELD_PER_VOLT:
vs = sorted(set(g6b[g6b.mapping == m].volts))
print(f"{m}: cells in the voltage groups of the six Fig. 6B fields {vs}: {int(B.volts.isin(vs).sum())} (paper: 224)")
Thresholds on the end-of-recording dF (%): {'a: > max of 0 V/cm': np.float32(12.02), 'a: > max of lowest field': np.float32(-1.45), 'b: > mean + 3 s.d. of 0 V/cm': np.float32(5.42), 'b: > mean + 3 s.d. of lowest field': np.float32(2.29), 'b literal: > 3 s.d. of 0 V/cm': np.float32(21.1), 'b literal: > 3 s.d. of lowest field': np.float32(12.44)}
n a: > max of 0 V/cm a: > max of lowest field b: > mean + 3 s.d. of 0 V/cm b: > mean + 3 s.d. of lowest field b literal: > 3 s.d. of 0 V/cm b literal: > 3 s.d. of lowest field field_M1 field_M2
volts
0.0 82 0.0 1.2 1.2 1.2 0.0 0.0 0.0 0.0
70.0 28 0.0 0.0 0.0 0.0 0.0 0.0 273.0 231.0
100.0 8 0.0 0.0 0.0 0.0 0.0 0.0 390.0 330.0
150.0 38 0.0 2.6 0.0 0.0 0.0 0.0 585.0 495.0
170.0 36 0.0 5.6 0.0 0.0 0.0 0.0 663.0 561.0
180.0 10 0.0 10.0 0.0 0.0 0.0 0.0 702.0 594.0
190.0 10 10.0 30.0 20.0 20.0 10.0 10.0 741.0 627.0
200.0 68 4.4 22.1 8.8 8.8 2.9 4.4 780.0 660.0
210.0 11 18.2 54.5 27.3 27.3 9.1 18.2 819.0 693.0
220.0 10 20.0 90.0 40.0 50.0 20.0 20.0 858.0 726.0
230.0 47 51.1 78.7 63.8 68.1 44.7 51.1 897.0 759.0
250.0 39 61.5 79.5 64.1 69.2 61.5 61.5 975.0 825.0
300.0 6 100.0 100.0 100.0 100.0 100.0 100.0 1170.0 990.0
mapping rule V_780 pct_780 in_tolerance higher_V min_pct_higher all_higher_ge_90
0 M1 a: > max of 0 V/cm 200.0 4.4 False [230.0, 250.0] 51.1 False
1 M1 a: > max of lowest field 200.0 22.1 False [230.0, 250.0] 78.7 False
2 M1 b: > mean + 3 s.d. of 0 V/cm 200.0 8.8 False [230.0, 250.0] 63.8 False
3 M1 b: > mean + 3 s.d. of lowest field 200.0 8.8 False [230.0, 250.0] 68.1 False
4 M1 b literal: > 3 s.d. of 0 V/cm 200.0 2.9 False [230.0, 250.0] 44.7 False
5 M1 b literal: > 3 s.d. of lowest field 200.0 4.4 False [230.0, 250.0] 51.1 False
6 M2 a: > max of 0 V/cm 230.0 51.1 False [250.0, 300.0] 61.5 False
7 M2 a: > max of lowest field 230.0 78.7 False [250.0, 300.0] 79.5 False
8 M2 b: > mean + 3 s.d. of 0 V/cm 230.0 63.8 False [250.0, 300.0] 64.1 False
9 M2 b: > mean + 3 s.d. of lowest field 230.0 68.1 False [250.0, 300.0] 69.2 False
10 M2 b literal: > 3 s.d. of 0 V/cm 230.0 44.7 False [250.0, 300.0] 61.5 False
11 M2 b literal: > 3 s.d. of lowest field 230.0 51.1 False [250.0, 300.0] 61.5 False
Cell exposures to trains (non-sham files): 311; with sham files: 393; distinct cells (cells in each field of view's sham recording): 82
Cells per voltage group (paper: 28 to 54 per field strength): {70.0: 28, 100.0: 8, 150.0: 38, 170.0: 36, 180.0: 10, 190.0: 10, 200.0: 68, 210.0: 11, 220.0: 10, 230.0: 47, 250.0: 39, 300.0: 6}
M1: cells in the voltage groups of the six Fig. 6B fields [70.0, 150.0, 170.0, 200.0, 230.0, 250.0]: 256 (paper: 224)
M2: cells in the voltage groups of the six Fig. 6B fields [70.0, 170.0, 200.0, 230.0, 250.0, 300.0]: 224 (paper: 224)
# Variant: each field of view's first exposure only, as if each cell had been tested once.
firstexp = (order[(order.set == "train") & (order.volts > 0)].groupby("subject_id").head(1))
fx = B[B.file.isin(firstexp.index)]
print("First train exposure of each field of view:",
firstexp[["subject_id", "volts", "cells"]].to_string(index=False))
print(pd.DataFrame({k: fx.groupby("volts").end.apply(lambda x, t=t: 100 * (x > t).mean()) for k, t in rules.items()})
.assign(n=fx.groupby("volts").size()).round(1).to_string())
fig, axes = plt.subplots(1, len(avail["train"]), figsize=(22, 3.2), sharey=True)
lims = (-40, 200)
t_b = np.arange(90) * 2.0
outside = 0
for ax, v in zip(axes, avail["train"]):
tr = np.concatenate([r["dF"] for r in res if r["set"] == "train" and r["volts"] == v])
outside += int(((tr < lims[0]) | (tr > lims[1])).any(1).sum())
ax.plot(t_b, tr.T, color="tab:blue", lw=0.4, alpha=0.5)
mu, se = tr.mean(0), tr.std(0, ddof=1) / np.sqrt(len(tr))
ax.fill_between(t_b, mu - se, mu + se, color="0.6")
ax.plot(t_b, mu, color="tab:orange", lw=2)
ax.axvspan(30, 40, color="k", alpha=0.15)
ax.set_title(f"{v:.0f} V, n={len(tr)}\nM1 {fluo4.field(v, 'M1'):.0f}, M2 {fluo4.field(v, 'M2'):.0f}", fontsize=8)
ax.set_xlabel("time (s)")
axes[0].set_ylim(*lims)
axes[0].set_ylabel("Fluo-4 dF (%)")
plt.tight_layout()
plt.show()
print(f"Axis {lims}; cell traces with points outside it: {outside}")
First train exposure of each field of view: subject_id volts cells
CHO_20250502_c1 300.0 6
CHO_20250502_c2 70.0 12
CHO_20250502_c3 70.0 8
CHO_20250502_c4 250.0 11
CHO_20250502_c5 70.0 8
CHO_20250502_c6 150.0 9
CHO_20250502_c7 170.0 9
CHO_20250502_c8 170.0 10
CHO_20250502_c9 200.0 11
a: > max of 0 V/cm a: > max of lowest field b: > mean + 3 s.d. of 0 V/cm b: > mean + 3 s.d. of lowest field b literal: > 3 s.d. of 0 V/cm b literal: > 3 s.d. of lowest field n
volts
70.0 0.0 0.0 0.0 0.0 0.0 0.0 28
150.0 0.0 0.0 0.0 0.0 0.0 0.0 9
170.0 0.0 5.3 0.0 0.0 0.0 0.0 19
200.0 9.1 18.2 18.2 18.2 9.1 9.1 11
250.0 90.9 100.0 90.9 90.9 90.9 90.9 11
300.0 100.0 100.0 100.0 100.0 100.0 100.0 6
Axis (-40, 200); cell traces with points outside it: 43
Result: not reproduced. The fields above 780 V/cm are taken as those of Fig. 6B, 900 and 1000 V/cm (230 and 250 V under M1, 250 and 300 V under M2). With the criterion's end window of 10 frames, under no reading is the fraction at 780 V/cm within 25.2 to 30.8%: it is 2.9 to 22.1% under M1 (200 V, 68 cells) and 44.7 to 78.7% under M2 (230 V, 47 cells), over the six rules, including "above 3 s.d." read literally (a threshold of 3 s.d. of the reference group). Nor do 90% respond at both higher fields: the lower of the two fractions is 44.7 to 78.7% under M1 and 61.5 to 79.5% under M2. The six voltage groups that correspond to the Fig. 6B fields hold 256 cells under M1 and 224 under M2, against the paper's 224; in all, the dataset holds 311 cell exposures to trains, of 82 distinct cells, not 224 cells each tested once. Taking only the first train of each field of view, 9.1 to 18.2% respond at 200 V and 90.9 to 100% at 250 V (11 cells each). The region-size check below gives the same outcome in all 36 combinations, and so does a background less affected by dim cells. With an end window of 5 frames and a region of 0.5 of the radius, the 780 V/cm fraction under M1 and the rule "above the largest of the lowest field" is 27.9%, within tolerance, but the fields above it then reach 76.6% (230 V) and 82.1% (250 V), not 90%.
fig6b-650-weak-but-significant¶
With 60-pulse trains at 650 V/cm only 2 cells (5.9%) respond strongly, but the mean Ca2+ signal at the end of recording is significantly above zero and marginally above that at 230 V/cm.
Expected: 2 strongly responding cells, 5.9 +- 0.59%. The claim has no criterion of its own; a strong response is as in fig6b-train-threshold. The statement's tests (one-sample t-test against zero, p < 0.001; single-tailed t-test against 230 V/cm, p < 0.05) are run, with the direction of each effect.
print("Trains, per-cell end dF (%) by voltage: n, mean, s.e., one-sample t-test against 0, and against 70 V "
"(two-tailed and single-tailed for higher):")
lowest = B[B.volts == 70].end
tb = group_table(B, "end").drop(columns=["diff_vs_sham", "p_vs_sham"])
tb["p_vs_70V_two_tailed"] = [stats.ttest_ind(B[B.volts == v].end, lowest).pvalue if v != 70 else np.nan for v in tb.index]
tb["p_above_70V_one_tailed"] = [stats.ttest_ind(B[B.volts == v].end, lowest, alternative="greater").pvalue
if v != 70 else np.nan for v in tb.index]
tb["M1_field"], tb["M2_field"] = fluo4.field(tb.index, "M1").round(), fluo4.field(tb.index, "M2").round()
print(tb.round(5).to_string(), "\n")
rows = []
for m in fluo4.FIELD_PER_VOLT:
v650, v230 = (fluo4.voltage_for(f, m, avail["train"]) for f in (650, 230))
x, y = B[B.volts == v650].end, B[B.volts == v230].end
for k, t in rules.items():
rows.append({"mapping": m, "V_650": v650, "V_230": v230, "rule": k, "n": len(x),
"strong": int((x > t).sum()), "pct_strong": 100 * (x > t).mean()})
one = stats.ttest_1samp(x, 0)
print(f"{m}: {v650:.0f} V ({fluo4.field(v650, m):.0f} V/cm), end dF {x.mean():.2f} +- {x.sem():.2f}% (n={len(x)}), "
f"{'above' if x.mean() > 0 else 'below'} zero; one-sample t-test: t = {one.statistic:.2f}, two-sided p = {one.pvalue:.3g}, "
f"one-sided p for above zero = {stats.ttest_1samp(x, 0, alternative='greater').pvalue:.3g}; "
f"against {v230:.0f} V ({fluo4.field(v230, m):.0f} V/cm, {y.mean():.2f}%, n={len(y)}), single-tailed "
f"Student's t-test for higher p = {stats.ttest_ind(x, y, alternative='greater').pvalue:.3g}")
print(pd.DataFrame(rows).round(1).to_string())
Trains, per-cell end dF (%) by voltage: n, mean, s.e., one-sample t-test against 0, and against 70 V (two-tailed and single-tailed for higher):
n mean se t_vs_0 p_vs_0 p_vs_70V_two_tailed p_above_70V_one_tailed M1_field M2_field
volts
0.0 82 -15.677970 0.776710 -20.185169 0.00000 0.00015 0.99992 0.0 0.0
70.0 28 -10.152490 0.783680 -12.954860 0.00000 NaN NaN 273.0 231.0
100.0 8 -9.028960 1.006140 -8.973890 0.00004 0.47891 0.23946 390.0 330.0
150.0 38 -8.112160 0.569370 -14.247550 0.00000 0.03448 0.01724 585.0 495.0
170.0 36 -9.171470 0.832860 -11.012060 0.00000 0.40526 0.20263 663.0 561.0
180.0 10 -6.430020 1.308880 -4.912600 0.00083 0.01984 0.00992 702.0 594.0
190.0 10 0.981610 6.716470 0.146150 0.88702 0.01060 0.00530 741.0 627.0
200.0 68 -1.980660 1.772840 -1.117230 0.26789 0.00462 0.00231 780.0 660.0
210.0 11 6.416230 6.043690 1.061640 0.31336 0.00015 0.00007 819.0 693.0
220.0 10 9.634290 7.095770 1.357750 0.20761 0.00006 0.00003 858.0 726.0
230.0 47 46.191139 9.954540 4.640200 0.00003 0.00004 0.00002 897.0 759.0
250.0 39 80.989037 15.994040 5.063700 0.00001 0.00001 0.00000 975.0 825.0
300.0 6 423.102509 18.264259 23.165600 0.00000 0.00000 0.00000 1170.0 990.0
M1: 170 V (663 V/cm), end dF -9.17 +- 0.83% (n=36), below zero; one-sample t-test: t = -11.01, two-sided p = 6.41e-13, one-sided p for above zero = 1; against 70 V (273 V/cm, -10.15%, n=28), single-tailed Student's t-test for higher p = 0.203
M2: 200 V (660 V/cm), end dF -1.98 +- 1.77% (n=68), below zero; one-sample t-test: t = -1.12, two-sided p = 0.268, one-sided p for above zero = 0.866; against 70 V (231 V/cm, -10.15%, n=28), single-tailed Student's t-test for higher p = 0.00231
mapping V_650 V_230 rule n strong pct_strong
0 M1 170.0 70.0 a: > max of 0 V/cm 36 0 0.0
1 M1 170.0 70.0 a: > max of lowest field 36 2 5.6
2 M1 170.0 70.0 b: > mean + 3 s.d. of 0 V/cm 36 0 0.0
3 M1 170.0 70.0 b: > mean + 3 s.d. of lowest field 36 0 0.0
4 M1 170.0 70.0 b literal: > 3 s.d. of 0 V/cm 36 0 0.0
5 M1 170.0 70.0 b literal: > 3 s.d. of lowest field 36 0 0.0
6 M2 200.0 70.0 a: > max of 0 V/cm 68 3 4.4
7 M2 200.0 70.0 a: > max of lowest field 68 15 22.1
8 M2 200.0 70.0 b: > mean + 3 s.d. of 0 V/cm 68 6 8.8
9 M2 200.0 70.0 b: > mean + 3 s.d. of lowest field 68 6 8.8
10 M2 200.0 70.0 b literal: > 3 s.d. of 0 V/cm 68 2 2.9
11 M2 200.0 70.0 b literal: > 3 s.d. of lowest field 68 3 4.4
Result: not reproduced. The statement's two tests fail under the adopted conversion and one fails under the other. Under M1 the mean end value at 170 V (650 V/cm) is -9.17 +- 0.83%, significantly below zero, not above it (one-sided p = 1 for above zero), and not above the 70 V group (single-tailed p = 0.20). Under M2 the 200 V mean is -1.98 +- 1.77%, not different from zero (p = 0.27), though above the 70 V group (single-tailed p = 0.0023). The expected count is met under one combination only: M1 (170 V, 36 cells), the rule "above the largest of the lowest field", and the region of 0.7 of the cell radius (2 cells, 5.6%); the other five rules give 0 cells under M1, regions of 0.5 and 1.0 of the radius give 1 cell (2.8%) under the same rule, and M2 (200 V, 68 cells) gives 2 to 15 cells (2.9 to 22.1%). As the tests fail under both conversions, the outcome does not depend on which conversion or rule is taken. The end values carry the decline of the data check.
Region size and end window¶
The region over each cell is 0.7 of the cell's equivalent radius (a standard choice), and the end value is the mean of the last 10 frames (the claims' criterion). The key numbers under regions of 0.5 and 1.0 of the radius and end windows of 5 and 20 frames, then every reading for the trains.
# Region size and end window are standard choices (claims file, opening comment): the key numbers under others.
def cells_for(roi, frames):
out = []
for i, r in enumerate(res):
d = r["dF"] if roi == fluo4.ROI_FRACTION else r["dF_roi"][roi]
out.append(pd.DataFrame({"set": r["set"], "volts": r["volts"], "end": fluo4.end_value(d, frames),
"peak": fluo4.peak_value(d, r["pulse_frame"])}))
return pd.concat(out, ignore_index=True)
rows = []
for roi in (0.5, fluo4.ROI_FRACTION, 1.0):
for frames in (5, fluo4.END_FRAMES, 20):
c = cells_for(roi, frames)
a, b = c[c.set == "single"], c[c.set == "train"]
sham_a = a[a.volts == 0].end
sb, lb = b[b.volts == 0].end, b[b.volts == 70].end
row = {"roi_fraction": roi, "end_frames": frames}
for v in (0, 150, 200, 250, 300, 350):
row[f"A_end_{v}V"] = a[a.volts == v].end.mean()
for v in (250, 300):
row[f"A_p_vs_sham_{v}V"] = stats.ttest_ind(a[a.volts == v].end, sham_a).pvalue
for v in (300, 350):
row[f"A_p_vs_0_{v}V"] = stats.ttest_1samp(a[a.volts == v].end, 0).pvalue
for v in (170, 200, 230, 250, 300):
x = b[b.volts == v].end
row[f"B_pct_{v}V_a0"] = 100 * (x > sb.max()).mean()
row[f"B_pct_{v}V_alow"] = 100 * (x > lb.max()).mean()
row["B_n_strong_170V_alow"] = int((b[b.volts == 170].end > lb.max()).sum())
rows.append(row)
robust = pd.DataFrame(rows)
print(robust.round(3).T.to_string())
0 1 2 3 4 5 6 7 8 roi_fraction 0.500 0.500 0.500 0.700 0.700 0.700 1.000 1.000 1.000 end_frames 5.000 10.000 20.000 5.000 10.000 20.000 5.000 10.000 20.000 A_end_0V -11.760 -11.651 -11.381 -12.122 -12.020 -11.720 -12.207 -12.041 -11.758 A_end_150V -10.687 -10.362 -10.079 -10.532 -10.262 -10.018 -10.144 -9.958 -9.687 A_end_200V -7.323 -7.285 -6.966 -7.565 -7.419 -7.130 -7.753 -7.611 -7.377 A_end_250V -5.671 -5.500 -5.313 -5.539 -5.436 -5.290 -5.629 -5.529 -5.428 A_end_300V -4.110 -4.073 -3.940 -3.873 -3.832 -3.746 -3.591 -3.552 -3.467 A_end_350V 1.496 1.633 1.816 1.140 1.243 1.454 0.834 0.922 1.149 A_p_vs_sham_250V 0.000 0.000 0.000 0.000 0.000 0.000 0.000 0.000 0.000 A_p_vs_sham_300V 0.000 0.000 0.000 0.000 0.000 0.000 0.000 0.000 0.000 A_p_vs_0_300V 0.000 0.000 0.000 0.000 0.000 0.000 0.000 0.000 0.000 A_p_vs_0_350V 0.444 0.409 0.357 0.559 0.531 0.466 0.669 0.644 0.567 B_pct_170V_a0 0.000 0.000 0.000 0.000 0.000 0.000 0.000 0.000 0.000 B_pct_170V_alow 8.333 2.778 0.000 8.333 5.556 0.000 5.556 2.778 0.000 B_pct_200V_a0 4.412 4.412 5.882 4.412 4.412 5.882 4.412 4.412 5.882 B_pct_200V_alow 27.941 25.000 14.706 25.000 22.059 11.765 30.882 25.000 14.706 B_pct_230V_a0 51.064 53.191 61.702 48.936 51.064 57.447 48.936 53.191 57.447 B_pct_230V_alow 76.596 72.340 68.085 80.851 78.723 72.340 87.234 85.106 80.851 B_pct_250V_a0 61.538 61.538 61.538 61.538 61.538 61.538 61.538 61.538 61.538 B_pct_250V_alow 82.051 79.487 76.923 82.051 79.487 76.923 79.487 79.487 76.923 B_pct_300V_a0 100.000 100.000 100.000 100.000 100.000 100.000 100.000 100.000 100.000 B_pct_300V_alow 100.000 100.000 100.000 100.000 100.000 100.000 100.000 100.000 100.000 B_n_strong_170V_alow 3.000 1.000 0.000 3.000 2.000 0.000 2.000 1.000 0.000
For single pulses the group end values move by less than 1% across these choices, and every comparison above keeps its direction and significance. For the trains, the fractions at 200 V under the rule "above the largest of the lowest field" range from 11.8 to 30.9%, and the count at 170 V from 0 to 3. One of these, the 5-frame window with a region of 0.5 of the radius, gives 27.9% at 200 V, within 28 +- 2.8%, but 76.6% at 230 V and 82.1% at 250 V, short of the 90% the criterion needs at the higher fields.
# Trains: every reading of the conversion, the response rule and the region size, with the end
# window of the criterion (last 10 frames).
rows = []
for roi in (0.5, fluo4.ROI_FRACTION, 1.0):
c = cells_for(roi, fluo4.END_FRAMES)
b = c[c.set == "train"]
sb, lb = b[b.volts == 0].end, b[b.volts == 70].end
thr = {"a: > max of 0 V/cm": sb.max(), "a: > max of lowest field": lb.max(),
"b: > mean + 3 s.d. of 0 V/cm": sb.mean() + 3 * sb.std(),
"b: > mean + 3 s.d. of lowest field": lb.mean() + 3 * lb.std(),
"b literal: > 3 s.d. of 0 V/cm": 3 * sb.std(), "b literal: > 3 s.d. of lowest field": 3 * lb.std()}
for m in fluo4.FIELD_PER_VOLT:
v780, v650 = (fluo4.voltage_for(f, m, avail["train"]) for f in (780, 650))
higher = sorted({fluo4.voltage_for(f, m, avail["train"]) for f in (900, 1000)})
for k, t in thr.items():
x650 = b[b.volts == v650].end
rows.append({"roi": roi, "mapping": m, "rule": k,
"pct_780": 100 * (b[b.volts == v780].end > t).mean(),
"min_pct_higher": min(100 * (b[b.volts == v].end > t).mean() for v in higher),
"n_650": len(x650), "strong_650": int((x650 > t).sum()),
"pct_650": 100 * (x650 > t).mean()})
grid = pd.DataFrame(rows)
grid["fig6b_threshold_holds"] = (abs(grid.pct_780 - 28) <= 2.8) & (grid.min_pct_higher >= 90)
grid["fig6b_650_matches"] = (grid.strong_650 == 2) & (abs(grid.pct_650 - 5.9) <= 0.59)
print(grid.round(1).to_string())
print(f"\nfig6b-train-threshold criterion met in {int(grid.fig6b_threshold_holds.sum())} of {len(grid)} combinations; "
f"780 V/cm fraction within 28 +- 2.8% in {int((abs(grid.pct_780 - 28) <= 2.8).sum())}")
print(f"fig6b-650 expected values (2 cells, 5.9 +- 0.59%) met in {int(grid.fig6b_650_matches.sum())} of {len(grid)}: "
+ "; ".join(f"{r.mapping}, {r.rule}, region {r.roi}" for r in grid[grid.fig6b_650_matches].itertuples()))
roi mapping rule pct_780 min_pct_higher n_650 strong_650 pct_650 fig6b_threshold_holds fig6b_650_matches 0 0.5 M1 a: > max of 0 V/cm 4.4 53.2 36 0 0.0 False False 1 0.5 M1 a: > max of lowest field 25.0 72.3 36 1 2.8 False False 2 0.5 M1 b: > mean + 3 s.d. of 0 V/cm 8.8 61.7 36 0 0.0 False False 3 0.5 M1 b: > mean + 3 s.d. of lowest field 8.8 61.7 36 0 0.0 False False 4 0.5 M1 b literal: > 3 s.d. of 0 V/cm 2.9 44.7 36 0 0.0 False False 5 0.5 M1 b literal: > 3 s.d. of lowest field 4.4 53.2 36 0 0.0 False False 6 0.5 M2 a: > max of 0 V/cm 53.2 61.5 68 3 4.4 False False 7 0.5 M2 a: > max of lowest field 72.3 79.5 68 17 25.0 False False 8 0.5 M2 b: > mean + 3 s.d. of 0 V/cm 61.7 64.1 68 6 8.8 False False 9 0.5 M2 b: > mean + 3 s.d. of lowest field 61.7 66.7 68 6 8.8 False False 10 0.5 M2 b literal: > 3 s.d. of 0 V/cm 44.7 61.5 68 2 2.9 False False 11 0.5 M2 b literal: > 3 s.d. of lowest field 53.2 61.5 68 3 4.4 False False 12 0.7 M1 a: > max of 0 V/cm 4.4 51.1 36 0 0.0 False False 13 0.7 M1 a: > max of lowest field 22.1 78.7 36 2 5.6 False True 14 0.7 M1 b: > mean + 3 s.d. of 0 V/cm 8.8 63.8 36 0 0.0 False False 15 0.7 M1 b: > mean + 3 s.d. of lowest field 8.8 68.1 36 0 0.0 False False 16 0.7 M1 b literal: > 3 s.d. of 0 V/cm 2.9 44.7 36 0 0.0 False False 17 0.7 M1 b literal: > 3 s.d. of lowest field 4.4 51.1 36 0 0.0 False False 18 0.7 M2 a: > max of 0 V/cm 51.1 61.5 68 3 4.4 False False 19 0.7 M2 a: > max of lowest field 78.7 79.5 68 15 22.1 False False 20 0.7 M2 b: > mean + 3 s.d. of 0 V/cm 63.8 64.1 68 6 8.8 False False 21 0.7 M2 b: > mean + 3 s.d. of lowest field 68.1 69.2 68 6 8.8 False False 22 0.7 M2 b literal: > 3 s.d. of 0 V/cm 44.7 61.5 68 2 2.9 False False 23 0.7 M2 b literal: > 3 s.d. of lowest field 51.1 61.5 68 3 4.4 False False 24 1.0 M1 a: > max of 0 V/cm 4.4 53.2 36 0 0.0 False False 25 1.0 M1 a: > max of lowest field 25.0 79.5 36 1 2.8 False False 26 1.0 M1 b: > mean + 3 s.d. of 0 V/cm 8.8 63.8 36 0 0.0 False False 27 1.0 M1 b: > mean + 3 s.d. of lowest field 13.2 70.2 36 0 0.0 False False 28 1.0 M1 b literal: > 3 s.d. of 0 V/cm 2.9 44.7 36 0 0.0 False False 29 1.0 M1 b literal: > 3 s.d. of lowest field 4.4 53.2 36 0 0.0 False False 30 1.0 M2 a: > max of 0 V/cm 53.2 61.5 68 3 4.4 False False 31 1.0 M2 a: > max of lowest field 85.1 79.5 68 17 25.0 False False 32 1.0 M2 b: > mean + 3 s.d. of 0 V/cm 63.8 66.7 68 6 8.8 False False 33 1.0 M2 b: > mean + 3 s.d. of lowest field 70.2 74.4 68 9 13.2 False False 34 1.0 M2 b literal: > 3 s.d. of 0 V/cm 44.7 61.5 68 2 2.9 False False 35 1.0 M2 b literal: > 3 s.d. of lowest field 53.2 61.5 68 3 4.4 False False fig6b-train-threshold criterion met in 0 of 36 combinations; 780 V/cm fraction within 28 +- 2.8% in 0 fig6b-650 expected values (2 cells, 5.9 +- 0.59%) met in 1 of 36: M1, a: > max of lowest field, region 0.7
The fig6b-train-threshold criterion is met in none of the 36 combinations of region size, conversion and rule. The fig6b-650 expected values are met in one.
A variant background: the 10th percentile of the cell-free pixels in place of their median, which lowers the effect of dim cells the threshold misses inside the cell-free region (seen in several train fields of view). The sham decline and the train readings under it:
# Variant background (fluo4.BG_LOW_PERCENTILE percentile of the cell-free pixels), main region size.
cl = pd.concat([pd.DataFrame({"set": r["set"], "volts": r["volts"], "end": fluo4.end_value(r["dF_bg_low"]),
"end_median_bg": fluo4.end_value(r["dF"])}) for r in res], ignore_index=True)
print("Background over F0 (pre-pulse means), median against variant:",
{s: (round(float(np.mean([r["bg"][:r["pulse_frame"]].mean() / r["F0"].mean() for r in res if r["set"] == s])), 2),
round(float(np.mean([r["bg_low"][:r["pulse_frame"]].mean() /
(r["F0"] + r["bg"][:r["pulse_frame"]].mean() - r["bg_low"][:r["pulse_frame"]].mean()).mean()
for r in res if r["set"] == s])), 2)) for s in ["single", "train"]})
print("Mean end dF (%) by set and voltage, median background against variant:")
print(cl.groupby(["set", "volts"])[["end_median_bg", "end"]].mean().unstack(0).to_string(float_format="%.2f"))
b = cl[cl.set == "train"]
sb, lb = b[b.volts == 0].end, b[b.volts == 70].end
thr = {"a: > max of 0 V/cm": sb.max(), "a: > max of lowest field": lb.max(),
"b: > mean + 3 s.d. of 0 V/cm": sb.mean() + 3 * sb.std(),
"b: > mean + 3 s.d. of lowest field": lb.mean() + 3 * lb.std(),
"b literal: > 3 s.d. of 0 V/cm": 3 * sb.std(), "b literal: > 3 s.d. of lowest field": 3 * lb.std()}
rows = []
for m in fluo4.FIELD_PER_VOLT:
v780, v650 = (fluo4.voltage_for(f, m, avail["train"]) for f in (780, 650))
higher = sorted({fluo4.voltage_for(f, m, avail["train"]) for f in (900, 1000)})
for k, t in thr.items():
x650 = b[b.volts == v650].end
rows.append({"mapping": m, "rule": k, "pct_780": 100 * (b[b.volts == v780].end > t).mean(),
"min_pct_higher": min(100 * (b[b.volts == v].end > t).mean() for v in higher),
"strong_650": int((x650 > t).sum()), "pct_650": 100 * (x650 > t).mean(),
"mean_650": x650.mean(), "p_650_above_0": stats.ttest_1samp(x650, 0, alternative="greater").pvalue})
gbg = pd.DataFrame(rows)
gbg["fig6b_threshold_holds"] = (abs(gbg.pct_780 - 28) <= 2.8) & (gbg.min_pct_higher >= 90)
gbg["fig6b_650_count_matches"] = (gbg.strong_650 == 2) & (abs(gbg.pct_650 - 5.9) <= 0.59)
print(gbg.round(3).to_string())
print(f"\nfig6b-train-threshold criterion met in {int(gbg.fig6b_threshold_holds.sum())} of {len(gbg)}; "
f"fig6b-650 count met in {int(gbg.fig6b_650_count_matches.sum())} of {len(gbg)}")
Background over F0 (pre-pulse means), median against variant: {'single': (0.49, 0.37), 'train': (1.81, 1.21)}
Mean end dF (%) by set and voltage, median background against variant:
end_median_bg end
set single train single train
volts
0.0 -12.02 -15.68 -11.33 -13.50
70.0 NaN -10.15 NaN -8.49
100.0 NaN -9.03 NaN -7.05
150.0 -10.26 -8.11 -9.62 -6.45
170.0 NaN -9.17 NaN -7.37
180.0 NaN -6.43 NaN -4.97
190.0 NaN 0.98 NaN 0.29
200.0 -7.42 -1.98 -6.85 -1.54
210.0 NaN 6.42 NaN 4.54
220.0 NaN 9.63 NaN 7.24
230.0 NaN 46.19 NaN 36.07
250.0 -5.44 80.99 -5.06 69.04
300.0 -3.83 423.10 -3.51 369.99
350.0 1.24 NaN 1.10 NaN
400.0 1.47 NaN 1.32 NaN
mapping rule pct_780 min_pct_higher strong_650 pct_650 mean_650 p_650_above_0 fig6b_threshold_holds fig6b_650_count_matches
0 M1 a: > max of 0 V/cm 4.412 55.319 0 0.000 -7.369 1.000 False False
1 M1 a: > max of lowest field 23.529 87.179 3 8.333 -7.369 1.000 False False
2 M1 b: > mean + 3 s.d. of 0 V/cm 5.882 63.830 0 0.000 -7.369 1.000 False False
3 M1 b: > mean + 3 s.d. of lowest field 8.824 70.213 0 0.000 -7.369 1.000 False False
4 M1 b literal: > 3 s.d. of 0 V/cm 2.941 42.553 0 0.000 -7.369 1.000 False False
5 M1 b literal: > 3 s.d. of lowest field 4.412 51.064 0 0.000 -7.369 1.000 False False
6 M2 a: > max of 0 V/cm 55.319 61.538 3 4.412 -1.539 0.871 False False
7 M2 a: > max of lowest field 91.489 87.179 16 23.529 -1.539 0.871 False False
8 M2 b: > mean + 3 s.d. of 0 V/cm 63.830 64.103 4 5.882 -1.539 0.871 False False
9 M2 b: > mean + 3 s.d. of lowest field 70.213 84.615 6 8.824 -1.539 0.871 False False
10 M2 b literal: > 3 s.d. of 0 V/cm 42.553 61.538 2 2.941 -1.539 0.871 False False
11 M2 b literal: > 3 s.d. of lowest field 51.064 61.538 3 4.412 -1.539 0.871 False False
fig6b-train-threshold criterion met in 0 of 12; fig6b-650 count met in 0 of 12
With the variant background, the background falls from 1.81 to 1.21 times the corrected cell signal in the train session (pre-pulse means over all train files), but the sham still declines, to -13.5% (train) and -11.3% (single), and the outcomes do not change: the fig6b-train-threshold criterion is met in none of the 12 combinations of conversion and rule (780 V/cm fraction 2.9 to 23.5% under M1 and 42.6 to 91.5% under M2, with the lower of the higher-field fractions at most 87.2%), the 650 V/cm mean stays below zero (-7.37% under M1, -1.54% under M2, one-sided p = 1.0 and 0.87 for above zero), and the count of 2 cells (5.9%) is met in none. So the dim cells inside the cell-free region do not decide these outcomes.
fig5b-conductivity-calibration¶
By Eq. 2, a 10-fold increase of effective membrane conductivity from 0.0003 mS/m reduces the pole IMP by only about 2%, and a 100-fold increase by 12%.
Criterion (claims file): a model computation with the paper's values (cytoplasm 0.4 S/m, medium 1.64 S/m, intact membrane 3e-7 S/m, R 8 um, d 5 nm). The reduction is 1 - f/1.5 (% of IMPC, Fig. 5B legend); f relative to f at the intact conductivity is a variant. Expected: 2 +- 0.2% and 12 +- 1.2%. This uses no recordings.
def f_eq2(lam_m, R=8e-6, d=5e-9, lam_i=0.4, lam_o=1.64):
"""Eq. 2 of the paper, with its constants (Methods 2.3)."""
num = 3 * lam_o * (3 * R**2 * d * lam_i + (3 * d**2 * R - d**3) * (lam_m - lam_i))
den = 2 * R**3 * (lam_m + 2 * lam_o) * (lam_m + 0.5 * lam_i) - 2 * (R - d)**3 * (lam_o - lam_m) * (lam_i - lam_m)
return num / den
lam0 = 3e-7
rows = [{"fold": k, "lambda_m (S/m)": lam0 * k, "f": f_eq2(lam0 * k),
"reduction_vs_IMPC_%": 100 * (1 - f_eq2(lam0 * k) / 1.5),
"reduction_vs_intact_f_%": 100 * (1 - f_eq2(lam0 * k) / f_eq2(lam0))} for k in (1, 10, 100)]
print(pd.DataFrame(rows).to_string(index=False))
fold lambda_m (S/m) f reduction_vs_IMPC_% reduction_vs_intact_f_%
1 3.000000e-07 1.497983 0.134444 0.000000
10 3.000000e-06 1.480074 1.328367 1.195530
100 3.000000e-05 1.322023 11.865152 11.746501
Result: partially reproduced. Eq. 2 with the paper's constants gives a reduction of 1.33% for a 10-fold increase, outside 2 +- 0.2%, and 11.87% for a 100-fold increase, within 12 +- 1.2%. The variant relative to the intact f gives 1.20 and 11.75%. This is a computation from the paper's equation and does not test the dataset.
Summary¶
| Claim | Outcome |
|---|---|
| fig6a-ca-entry-1000-1200 | reproduced |
| fig6a-ca-sustained | not reproduced |
| fig6a-780-not-significant | reproduced |
| fig6b-train-threshold | not reproduced |
| fig6b-650-weak-but-significant | not reproduced |
| fig5b-conductivity-calibration | partially reproduced (no recordings used) |
| 21 claims on potentiometric imaging (Figs. 1 to 5 and 7) | blocked: data missing |
A failure to reproduce here may be a failure of this attempt, not of the paper: see the reasoning and the differences from the paper's method.
How the analysis differs from the paper's: the cell regions are found by a segmentation, not drawn by hand; the region size and the cell-free background region are standard choices; the voltages of the files are converted to field strengths at 3.9 V/cm per V (M1), an assumption that fits the fields and group sizes of Fig. 6B, with 3.3 V/cm per V (M2) shown throughout; all cells in every file are used, where the paper used 14 cells for Fig. 6A and 224 cells, each tested once, for Fig. 6B; the tests the paper does not name are the claims file's readings. No outcome changes between M1 and M2.
What was found about the dataset: it holds the Fluo-4 imaging of Fig. 6 and none of the potentiometric imaging; it gives voltages, not field strengths, and an electrode gap of 1.00 mm where the paper gives 1.6 mm; the train session reuses the same fields of view at up to seven voltages (82 distinct cells, 311 exposures), where the paper says each cell was tested once (224 cells); the single-pulse session holds 27 cells where the paper averaged 14; the description mentions only single pulses though 33 of 73 files are 60-pulse trains; there are no cell regions, so the paper's cell selection cannot be repeated. The Fluo-4 fluorescence of unpulsed cells declines by 12 to 16% over a recording (8.7 and 7.5% before background subtraction, with the background nearly flat), where the 0 V/cm trace of Fig. 6A rises by about 1% and the Fig. 6B traces are flat; the decline decides fig6a-ca-sustained. It is steepest in the first recording of a field of view: in the single-pulse session it slows with each recording, and in the train session it is 11.4% per minute in the first and 2.0 to 5.4% per minute later, without a steady trend. Field strength and recording order therefore go together in both sessions. A background taken as a low percentile of the cell-free pixels, less affected by dim cells there, leaves the decline and every outcome unchanged.