Analysis Guide — Allura Red Multi-Concentration Imaging
Objective
Determine the actual section heights of a stepped-height microfluidic channel by fitting Beer–Lambert absorbance measurements acquired at five Allura Red concentrations under the microscope's 510/10 nm narrow-bandpass filter. Simultaneously recover the camera's quadratic nonlinearity parameter η from the channel data and anchor the absolute height scale using the 80 µm capillary reference geometry.
Background
Beer–Lambert law and transmittance
For a monochromatic beam of wavelength λ passing through a solution of molar concentration C [mol/L] over an optical path length h [cm], the monochromatic transmittance is:
where ε(λ) [L mol⁻¹ cm⁻¹] is the molar absorptivity (a property of the dye at wavelength λ), I₀(λ) is the incident intensity, and I(λ) is the transmitted intensity. The absorbance is:
Absorbance is proportional to both concentration and path length — this is the Beer–Lambert law.
Measured transmittance and absorbance
Images are dark-subtracted and normalised by a light (no-dye) reference:
The dark subtraction removes camera offset and fixed-pattern noise; division by the light reference removes spatial non-uniformity in illumination and pixel-to-pixel sensitivity variation. The max(0,·) enforces the physical constraint that transmittance cannot be negative. No pixel of T should be below 0.
Averaging T over a region of interest (ROI) and converting to absorbance:
Why the illumination spectrum doesn't matter.
The pixel signal for any channel of the camera is I ∝ ∫ I_source(λ)·R(λ)·F(λ)·T(λ) dλ, where I_source(λ) is the lamp spectrum, R(λ) is the camera channel's spectral response, and F(λ) is the 510/10 nm filter transmittance. In the transmittance ratio, I_source(λ)·R(λ) appears identically in numerator and denominator and cancels exactly. Within the 10 nm passband, I_source and R are both approximately flat, so T_meas reduces to the monochromatic Beer–Lambert transmittance. No knowledge of the lamp spectrum is required.
No Nanodrop spectrum or filter transmittance profile is used in the analysis. The Nanodrop spectra acquired in this session were examined qualitatively to confirm that Allura Red's absorption peak overlaps the filter passband — they are not used quantitatively.
Empirical forward model
The 510/10 nm filter has a ~10 nm FWHM bandwidth. The polychromatic Beer–Lambert correction for Allura Red × 510/10 is less than 0.5% across the full concentration and height range of interest. Beer–Lambert is therefore treated as linear in the product C·h, and the effective absorptivity s is determined empirically from the capillary calibration rather than from tabulated molar absorptivity values.
Converting to units convenient for this experiment (C in mM = 10⁻³ mol/L, h in µm = 10⁻⁴ cm), Beer–Lambert becomes:
where s ≡ ε × 10⁻³ mol/L per mM × 10⁻⁴ cm/µm is the effective absorptivity scale in AU/(mM·µm). In practice s also absorbs filter efficiency and camera quantum efficiency, so it is fit empirically from capillary measurements at a known path length rather than calculated from ε.
A real camera may exhibit a small quadratic nonlinearity. Parameterising this as:
where P_ref [mM·µm] is the C·h product at the reference condition (chosen as 1.0 mM × 80 µm = 80 mM·µm). At C·h = P_ref the correction factor equals 1 (no correction); η = 0 recovers pure Beer–Lambert.
| Symbol | Units | Meaning |
|---|---|---|
| C | mM | dye concentration |
| h | µm | optical path length (channel or capillary depth) |
| s | AU/(mM·µm) | effective absorptivity scale; absorbs ε, filter efficiency, camera sensitivity |
| η | — | quadratic camera nonlinearity parameter (0 = linear) |
| P_ref | mM·µm | reference C·h product; sets the pivot where η correction vanishes |
P_REF = 1.0 * 80.0 # mM·µm (1.0 mM × 80 µm capillary reference)
def A_pred(C_mM, h_um, s, eta, _pref=P_REF):
"""Empirical Beer-Lambert: A = s·C·h·(1 + eta·(C·h/P_REF − 1))."""
p = C_mM * h_um # mM·µm
A_l = s * p # linear Beer-Lambert
return A_l * (1.0 + eta * (p / _pref - 1.0))
Camera hardware
The camera produces 16-bit-per-channel RGB images (48-bit total). The ADC is 14-bit, stored left-aligned in the upper 14 bits of each 16-bit word — the 2 least significant bits carry no information.
- Raw saturated pixel value: 16383 × 4 = 65532
- Right-shift averaged values by 2 (divide by 4) to recover 0–16383 range.
Camera nonlinearity and scale degeneracy
With five concentrations spanning a 13× dynamic range (0.375–5 mM), η can be determined from the channel data alone by joint least-squares fitting (Stage 7). η from Stage 6 (capillary only, 3 DoF) provides an independent cross-check.
Beer–Lambert absorbance depends only on the product C·h, so a uniform scale error δ in all nominal concentrations aliases perfectly into the same fractional error in all fitted heights. The channel fit recovers correct height ratios and η regardless, but absolute heights require an independent anchor. The capillary at known L = 80 µm provides this: fitting s from capillary data at known path length pins the C·h product scale and thereby converts fitted height ratios into absolute microns.
Dataset Description
Directory structure
notebooks/2026-08-31_allura_red/ ← working directory for all notebooks
│
├── 2026-08-31_allura_16bit/ ← raw images (do not modify)
│ ├── capillary/
│ │ ├── light/ ← illumination reference, 5 TIFFs
│ │ ├── dark/
│ │ ├── 0.375mM/ 1.0mM/ 1.5mM/ 2.5mM/ 5mM/
│ └── device_1/
│ ├── lights/
│ │ ├── pre-all/ ← illumination reference before any dye
│ │ ├── post_0.375/ post_1.0/ post_1.5/ post_2.5/ post_5.0/
│ ├── dark/
│ └── dye/
│ ├── 0.375mM/ 1.0mM/ 1.5mM/ 2.5mM/ 5.0mM/
│ ↑ note decimal (5.0mM, not 5mM)
│
└── 2026-08-31_allura_ave_14bit/ ← processed images (CREATED by pipeline)
├── capillary/
│ ├── light/mean.tif dark/mean.tif
│ └── 0.375mM/ 1.0mM/ 1.5mM/ 2.5mM/ 5mM/
└── device_1/
├── light_pre/mean.tif
├── light_post_0.375/mean.tif light_post_1.0/mean.tif
├── light_post_1.5/mean.tif light_post_2.5/mean.tif light_post_5.0/mean.tif
├── dark/mean.tif
└── 0.375mM/ 1.0mM/ 1.5mM/ 2.5mM/ 5.0mM/
Important naming difference: the capillary raw directory uses 5mM
(no decimal); the device_1 raw directory uses 5.0mM. The processed
(ave_14bit) directories mirror this distinction.
CAP_KEYS = ["0.375mM", "1.0mM", "1.5mM", "2.5mM", "5mM"] # capillary
DEV_KEYS = ["0.375mM", "1.0mM", "1.5mM", "2.5mM", "5.0mM"] # device_1
Image specifications
| Property | Value |
|---|---|
| Format | 16-bit-per-channel RGB TIFF (48-bit total) |
| Significant bits per channel | 14 — stored left-aligned; max raw value = 65532 |
| Images per condition | 5 (averaged in Stages 2 & 4) |
| Averaged output format | uint16 RGB TIFF, right-shifted by 2 (values 0–16383) |
| Channels | Red (ignored), Green (signal), Blue (signal; compared in Stage 5a) |
| Filter | 510/10 nm narrow bandpass |
JSON key names
capillary_crop.json — uses keys x0, y0,
w, h (not width/height).
channel_rois.json — uses keys x0, y0,
w, h in both crop and each section entry.
Section ROI coordinates are relative to the crop origin.
The capillary images are already cropped in Stage 2 — use the full processed image as the
capillary ROI.
Absorbance Coverage
Target measurement window: 0.05–0.80 AU (lower bound keeps shallow channels above the noise floor; upper bound avoids detector saturation and stray-light nonlinearity). Measurements outside this window are not excluded — they enter the joint fit with reduced statistical weight (see Stage 7).
The five concentrations (0.375, 1.0, 1.5, 2.5, 5.0 mM) were chosen so that the middle two (1.0 and 1.5 mM) fall in-window at all eight heights, while the extremes (0.375 and 5.0 mM) extend coverage toward the edges of the range. The 6.7× concentration span (0.375–2.5 mM) provides strong leverage on η.
Degrees of freedom — joint {hi, η} fit
Ns = 8 sections; s fixed from capillary calibration.
| Concentrations used | Equations | Unknowns | DoF |
|---|---|---|---|
| 1 | 8 | 9 | −1 (underdetermined) |
| 2 | 16 | 9 | +7 |
| 4 | 32 | 9 | +23 |
| 5 | 40 | 9 | +31 |
Analysis Pipeline
marimo skill requirement
Before creating, editing, or running any marimo notebook (.py file containing
import marimo or marimo.App), invoke the marimo-pair skill
via the Skill tool: Skill("marimo-pair").
All notebook mutations must go through the execute-code.sh script —
never through direct Edit/Write on the .py file
while the server is running.
The pipeline divides into four notebooks. Stages 1–4 prepare the data; Stages 5–8 perform the physics analysis.
Capillary crop ROI notebook — capillary_roi.py
Goal: interactively define the crop rectangle for capillary images and save
coordinates to capillary_crop.json.
cd notebooks/2026-08-31_allura_red
uv run marimo edit --sandbox --no-token capillary_roi.py
Notebook cells:
- Imports — numpy, matplotlib, tifffile, pathlib, json.
- Load and average the 5 light TIFFs from
2026-08-31_allura_16bit/capillary/light/. Right-shift by 2 during averaging to recover 14-bit values. - Interactive crop ROI —
x0,y0,w,hnumber inputs. Show the cropped green-channel preview to verify uniform illumination. - Save
capillary_crop.jsonusingon_clickcallback (not thevalueattribute). Closure variables must be captured as explicit default arguments (_cx0=cx0etc.) — referencing outer variables directly breaks after marimo reactive re-execution.
def _do_save(_v, _cx0=cx0, _cy0=cy0, _cw=cw, _ch=ch):
roi = {"x0": _cx0, "y0": _cy0, "w": _cw, "h": _ch}
pathlib.Path("capillary_crop.json").write_text(json.dumps(roi, indent=2))
save_btn = mo.ui.button(label="Save", on_click=_do_save)
Capillary image processing — in process_images.py
Goal: crop every capillary image, average the 5 per condition, right-shift by 2,
write one uint16 RGB TIFF per condition to
2026-08-31_allura_ave_14bit/capillary/<cond>/mean.tif.
def process_condition(src_dir, dst_path, x0, y0, w, h):
files = sorted(src_dir.glob("*.tif"))
assert len(files) == 5
stack = np.stack([
tifffile.imread(str(f)).astype(np.float32)[y0:y0+h, x0:x0+w, :]
for f in files
]) # (5, h, w, 3) float32
if (stack >= 65532).any():
print(f"WARNING: saturated pixels in {src_dir.name}")
avg = stack.mean(axis=0) # (h, w, 3) float32
out = (avg / 4).astype(np.uint16) # right-shift → 0–16383
dst_path.parent.mkdir(parents=True, exist_ok=True)
tifffile.imwrite(str(dst_path), out)
return out
The capillary processed images are already cropped (shape = crop region H×W×3). In Stage 5, use the full processed image as the capillary integration ROI — do not re-index with the original crop coordinates.
Channel crop and section ROI notebook — channel_roi.py
Goal: define (a) overall crop rectangle for channel images and (b) 8 section
ROIs within the cropped coordinate space. Save to channel_rois.json.
uv run marimo edit --sandbox --no-token channel_roi.py
Key implementation notes:
- Load from
2026-08-31_allura_16bit/device_1/lights/pre-all/(notchannel/light/) - Show pixel-labeled axes using
extent=[-0.5, cw-0.5, ch-0.5, -0.5]withorigin="upper" - Use
mo.ui.dictionary(nested) for section ROI inputs — plain Python dicts are not tracked by marimo's reactive graph - Save button: same
on_clickwith default-argument capture as Stage 1
{
"crop": {"x0": 850, "y0": 950, "w": 1000, "h": 250},
"sections": {
"30um": {"x0": 780, "y0": 85, "w": 160, "h": 75},
"40um": {"x0": 680, "y0": 85, "w": 60, "h": 75},
...
"200um": {"x0": 30, "y0": 85, "w": 90, "h": 75}
}
}
All section coordinates are relative to the crop origin (crop-relative pixels).
Channel image processing — in process_images.py
Same process_condition() function as Stage 2; load crop from
channel_rois.json["crop"]. Process all six light conditions plus
dark and the five dye concentrations:
DEV_CONDITIONS = {
# All six light images — compared in Stage 4a to detect adsorption
"light_pre": DEV_RAW_DIR / "lights" / "pre-all",
"light_post_0.375": DEV_RAW_DIR / "lights" / "post_0.375",
"light_post_1.0": DEV_RAW_DIR / "lights" / "post_1.0",
"light_post_1.5": DEV_RAW_DIR / "lights" / "post_1.5",
"light_post_2.5": DEV_RAW_DIR / "lights" / "post_2.5",
"light_post_5.0": DEV_RAW_DIR / "lights" / "post_5.0",
# Dark and dye
"dark": DEV_RAW_DIR / "dark",
"0.375mM": DEV_RAW_DIR / "dye" / "0.375mM",
"1.0mM": DEV_RAW_DIR / "dye" / "1.0mM",
"1.5mM": DEV_RAW_DIR / "dye" / "1.5mM",
"2.5mM": DEV_RAW_DIR / "dye" / "2.5mM",
"5.0mM": DEV_RAW_DIR / "dye" / "5.0mM",
}
Each output lands in 2026-08-31_allura_ave_14bit/device_1/<key>/mean.tif.
Adsorption diagnostic — adsorption_check.py
Goal: determine whether dye is adsorbing onto channel surfaces by comparing all six light images. Adsorption would cause the effective optical path length to be shorter than the physical channel height, biasing absorbance upward and height estimates downward.
Surface passivation context. The stepped-height channel was treated with BSA (bovine serum albumin) prior to imaging to passivate the channel surfaces and suppress non-specific dye adsorption. All surfaces are polymerized polyethylene glycol diacrylate (PEGDA), 3D-printed with a custom resin formulation on a custom printer. This stage checks how effective that passivation is.
Physical picture: between each dye run the channel is flushed with buffer and
a new light image (post_*) is acquired. If dye adsorbs to the PEGDA surfaces,
those surfaces remain absorbing even without dissolved dye. A post-dye light image will then
show reduced intensity compared to pre-all.
Procedure
- Load all six averaged light images from
ave_14bit/device_1/:LIGHT_KEYS = ["light_pre", "light_post_0.375", "light_post_1.0", "light_post_1.5", "light_post_2.5", "light_post_5.0"] lights = {k: load_mean("device_1", k) for k in LIGHT_KEYS} dark = load_mean("device_1", "dark") - For each section ROI and each light key, compute the dark-subtracted mean green-channel signal:
lights_mean = { sk: {lk: roi_mean(lights[lk], SEC_ROIS[sk], "green") for lk in LIGHT_KEYS} for sk in SEC_KEYS } - Normalise to
light_pre:lights_ratio = { sk: {lk: lights_mean[sk][lk] / lights_mean[sk]["light_pre"] for lk in LIGHT_KEYS} for sk in SEC_KEYS } - Quantitative criterion — RMS fractional deviation of all
post_*values from 1.0:all_ratios = [lights_ratio[sk][lk] for sk in SEC_KEYS for lk in LIGHT_KEYS[1:]] rms_drift = float(np.sqrt(np.mean([(r - 1.0)**2 for r in all_ratios])))Decision threshold:
rms_drift < 0.005(0.5%) → uselight_prefor all concentrations. If ≥ 0.005 → use matched light references (below).
If adsorption is detected (rms_drift ≥ 0.005)
Assign the light image representing channel state during each dye measurement. The post-dye light taken immediately before a given concentration is the closest proxy:
| Dye concentration | Best light reference |
|---|---|
| 0.375 mM | light_pre |
| 1.0 mM | light_post_0.375 |
| 1.5 mM | light_post_1.0 |
| 2.5 mM | light_post_1.5 |
| 5.0 mM | light_post_2.5 |
LIGHT_REF = { # matched light reference for each dye concentration
"0.375mM": lights["light_pre"],
"1.0mM": lights["light_post_0.375"],
"1.5mM": lights["light_post_1.0"],
"2.5mM": lights["light_post_1.5"],
"5.0mM": lights["light_post_2.5"],
}
Load averaged images and build helpers — analysis.py
uv run marimo edit --sandbox --no-token analysis.py
P_REF = 1.0 * 80.0 # mM·µm (pivot: 1.0 mM × 80 µm)
def A_pred(C_mM, h_um, s, eta, _pref=P_REF):
p = C_mM * h_um
A_l = s * p
return A_l * (1.0 + eta * (p / _pref - 1.0))
ADC_OUT_MAX = 16383
CH = {"red": 0, "green": 1, "blue": 2}
L_CAP_UM = 80.0
HEIGHTS = np.array([30, 40, 50, 70, 90, 120, 150, 200], dtype=float)
CONCS_mM = np.array([0.375, 1.0, 1.5, 2.5, 5.0])
A_LO, A_HI = 0.05, 0.80
CAP_KEYS = ["0.375mM", "1.0mM", "1.5mM", "2.5mM", "5mM"]
DEV_KEYS = ["0.375mM", "1.0mM", "1.5mM", "2.5mM", "5.0mM"]
AVE_DIR = pathlib.Path("2026-08-31_allura_ave_14bit")
def load_mean(geo, cond):
return tifffile.imread(str(AVE_DIR / geo / cond / "mean.tif")).astype(np.float32)
LIGHT_KEYS = ["light_pre", "light_post_0.375", "light_post_1.0",
"light_post_1.5", "light_post_2.5", "light_post_5.0"]
cap = {c: load_mean("capillary", c) for c in ["light", "dark"] + CAP_KEYS}
dev = {c: load_mean("device_1", c)
for c in LIGHT_KEYS + ["dark"] + DEV_KEYS}
# Default to pre-all; updated after Stage 4a
LIGHT_REF = {k: dev["light_pre"] for k in DEV_KEYS}
def transmittance(I_dye, I_light, I_dark, ch_name, floor=1e-6):
d = I_dye [:, :, CH[ch_name]].astype(float)
l = I_light[:, :, CH[ch_name]].astype(float)
k = I_dark [:, :, CH[ch_name]].astype(float)
denom = l - k
valid = (denom > 0.01 * np.nanmax(denom)) & (l < ADC_OUT_MAX) & (d < ADC_OUT_MAX)
T_raw = np.where(valid, (d - k) / denom, np.nan)
return np.clip(T_raw, floor, 1.0)
valid masks pixels where T is undefined: near-zero denominator (dark region or dead pixel),
or saturated light/dye pixel. Those become np.nan and are excluded by np.nanmean
downstream. np.clip caps T at 1 (noise can push dye slightly above light) and raises the
floor to floor (prevents log10(0)); NaN pixels pass through clip unchanged.
Channel comparison — green vs. blue
Goal: select which channel to use for Stages 6–7. Use capillary data (known L = 80 µm) to compare linearity and noise.
The capillary processed images are already cropped — use the full image:
def cap_absorbance(cond_key, ch_name):
T = transmittance(cap[cond_key], cap["light"], cap["dark"], ch_name)
return -np.log10(np.nanmean(T)) # full image = capillary ROI
A_cap_green = np.array([cap_absorbance(c, "green") for c in CAP_KEYS])
A_cap_blue = np.array([cap_absorbance(c, "blue") for c in CAP_KEYS])
P_cap = CONCS_mM * L_CAP_UM # C·h products at capillary [mM·µm]
s_lin_g = np.dot(A_cap_green, P_cap) / np.dot(P_cap, P_cap)
A_lin_g = s_lin_g * P_cap
Also compute per-channel noise: σA = σT / (T̄ · ln 10).
Decision rule: select the channel with lower RMS residual from its linear fit
(and lower σA). If equal, use green (conventional). Set ACTIVE_CH = "green"
(or "blue") for all subsequent stages.
Capillary calibration — fit (s, η)
Goal: fit the effective absorptivity scale s and camera nonlinearity ηcap from the 5 capillary measurements at known L = 80 µm. This uses only 2 free parameters for 5 measurements → 3 DoF.
def cap_cost_emp(params):
s, eta = params
return sum((A_cap_green[k] - A_pred(CONCS_mM[k], L_CAP_UM, s, eta))**2
for k in range(len(CONCS_mM)))
_s0 = np.dot(A_cap_green, P_cap) / np.dot(P_cap, P_cap)
res = minimize(cap_cost_emp, x0=[_s0, 0.0], method="Nelder-Mead",
options={"xatol": 1e-9, "fatol": 1e-14, "maxiter": 100000})
s_emp, eta_emp = float(res.x[0]), float(res.x[1])
s absorbs all unknown proportionality factors (molar absorptivity ε, filter efficiency, camera quantum efficiency, any systematic concentration offset). It is the only absolute reference tying the height scale to physical microns. η_emp from capillary provides an independent cross-check for Stage 7.
Joint channel height + nonlinearity fit
Goal: fit 8 section heights and η jointly from the 5×8 channel absorbance matrix. Use s from Stage 6 to anchor the absolute scale (31 DoF).
A_meas_ch = np.zeros((8, 5))
for i, sk in enumerate(SEC_KEYS):
s_roi = SEC_ROIS[sk]
for k, ck in enumerate(DEV_KEYS):
T = transmittance(dev[ck], LIGHT_REF[ck], dev["dark"], ACTIVE_CH)
T_roi = T[s_roi["y0"]:s_roi["y0"]+s_roi["h"],
s_roi["x0"]:s_roi["x0"]+s_roi["w"]]
A_meas_ch[i, k] = -np.log10(np.nanmean(T_roi))
def weight7(A):
if A_LO <= A <= A_HI:
return 1.0
return (0.003 / max(abs(A - 0.5*(A_LO + A_HI)), 0.3))**2
def cost7(params):
h_vec, eta = params[:8], params[8]
total = 0.0
for i in range(8):
for k, c in enumerate(CONCS_mM):
A_p = A_pred(c, abs(h_vec[i]), s_emp, eta)
total += weight7(A_meas_ch[i, k]) * (A_meas_ch[i, k] - A_p)**2
return total
result = minimize(cost7, x0=np.append(HEIGHTS.copy(), eta_emp),
method="Nelder-Mead",
options={"xatol": 1e-5, "fatol": 1e-12,
"maxiter": 200000, "adaptive": True})
h_abs = np.abs(result.x[:8])
eta_fit = float(result.x[8])
No separate scale-anchoring step is needed. Because s is already determined from the capillary
at known L = 80 µm, the fitted h_abs values are already in physical microns.
Expected results: η_fit from channel data may differ slightly from η_emp (capillary alone) because the channel spans a wider absorbance range; both values should be small (|η| < 0.1). h_abs values should be close to nominal HEIGHTS; deviations reflect actual fabrication geometry.
Diagnostics, cross-validation, and reporting
- Residual heatmap (8×5) — A_meas_ch − A_pred(Ck, h_abs[i], s_emp, η_fit)
- η agreement — compare η_fit vs. η_emp; large disagreement warrants investigation
- Height comparison — scatter h_abs vs. nominal HEIGHTS; include 1:1 line and ±5 µm band
- Coverage map — in-range / out-of-range indicator for all 40 measurements
- Optional cross-check — if both channels looked good in Stage 5a, repeat Stage 7 with the other channel and compare h_abs values
Key figures
- Channel comparison: A_meas_green and A_meas_blue vs. linear model (capillary)
- Averaged green-channel light image with crop + 8 section ROI boxes
- A_meas vs. concentration for each section (measured points + fitted model)
- Fitted vs. nominal heights (scatter with 1:1 line and ±5 µm band)
- Residuals heatmap (8 sections × 5 concentrations)
Key Implementation Notes
| Issue | Note |
|---|---|
| RGB images, not grayscale | Raw TIFFs are 16-bit-per-channel RGB (shape H×W×3). Always index channel explicitly: img[:,:,1] = green, img[:,:,2] = blue. |
| 14-bit ADC, left-aligned | Raw saturated pixel = 65532 (= 16383 × 4). Right-shift averaged float by 4 (/ 4, cast to uint16) to recover 0–16383. |
| Capillary images already cropped | Stage 2 crops capillary images. In Stage 5, use the full processed image as the capillary ROI — do not re-index with the original raw-pixel crop coordinates. |
| Light reference selection | Use LIGHT_REF[ck] (not a single dev["light"]) when computing transmittance for each dye concentration in Stage 7. After Stage 4a this is either light_pre for all concentrations (no adsorption) or the matched pre-dye light image for each concentration (adsorption detected). |
| T ≥ 0 always | np.clip(T_raw, floor, 1.0) floors at floor (1e-6). No pixel of T should be negative. |
| Average T, not A | Compute A from −log10(mean(T)) over ROI, not mean(−log10(T)). |
| No Nanodrop spectrum | The Nanodrop measurements were used only to visually confirm Allura Red's peak overlaps the 510/10 passband. They are not used in the forward model or any fitting. |
| 5mM vs 5.0mM | Capillary raw directory is 5mM; device_1 raw directory is 5.0mM. Both mirror into their respective ave_14bit subdirectories. |
| JSON keys w/h not width/height | Both capillary_crop.json and channel_rois.json use keys w and h, not width and height. |
| Section ROI coordinates | Section ROIs in channel_rois.json are in crop-relative pixel coordinates, not raw-image coordinates. |
| Save button closure | mo.ui.button(on_click=fn) with values captured as explicit default args in fn's signature. The value attribute of a button is unreliable. |
| mo.ui.dictionary for reactive inputs | Plain Python dicts of UI elements break marimo's reactive graph. Use mo.ui.dictionary({...}) for collections of inputs that must trigger downstream re-execution. |
| marimo-pair skill | Always invoke Skill("marimo-pair") before any marimo notebook work. Edit cells with ctx.edit_cell(id, code=...) inside async with cm.get_context() as ctx: — never with Edit/Write on the .py file. |
| Leading-underscore variables | Variables starting with _ are cell-local in marimo and not exported to the reactive graph. Use plain names for cross-cell variables. |
References
Design analysis
notebooks/2026-08-24_spectra/spectra_analysis_static/allura_red_analysis.html
Key sections:
- Multiple concentrations in stepped-height channel — rationale for 5-concentration strategy and DoF analysis
- Do capillary measurements add anything? — scale degeneracy proof; why the capillary is needed for absolute heights but not height ratios
- Single capillary concentration — discussion of nonlinearity detection with multiple channel concentrations
Prior imaging pipeline
notebooks/2026-08-22_Keyence_data_analysis/ — similar crop → transmittance →
Beer–Lambert → height pipeline; code template for image loading and ROI extraction.
Quick-Start Checklist
- Invoke
Skill("marimo-pair")before any marimo notebook work - ── Data preparation ─────────────────────────────────────────────────
- Stage 1 — Confirm
2026-08-31_allura_16bit/structure; check 5 TIFFs per condition - Stage 1 —
capillary_roi.py: define crop ROI; savecapillary_crop.json - Stage 3 —
channel_roi.py: define crop + 8 section ROIs; savechannel_rois.json - Stages 2&4 —
process_images.py: verify2026-08-31_allura_ave_14bit/populated (includeslight_pre+ 5 ×light_post_*+ dark + 5 dye conditions) - Stage 4a — Adsorption diagnostic: compare all 6 light images per section ROI;
compute
rms_drift; setLIGHT_REFdict (light_prefor all, or matched references) - ── Physics analysis ─────────────────────────────────────────────────
- Stage 5 —
analysis.py: defineA_pred(), load all light images, definetransmittance() - Stage 5a — Channel comparison: linear fit on capillary; set
ACTIVE_CH - Stage 6 — Capillary calibration: fit (s, η_emp) from 5 measurements at L = 80 µm
- Stage 7 — Build
A_meas_ch[8×5]; joint fit {h₀..h₇, η_fit} with s fixed - Stage 8 — Residuals heatmap, η comparison, height comparison, final figures