Data Collection 2 — Colab Notebook

Last Updated: March 31, 2026 Download notebook (.ipynb)
  • Team Name: [Write in the name of your team.]
  • Authors: [Write in the name of the authors of this notebook.]
  • Cell Number: [Write in your cell number.]
  • J-V Apparatus Number: [Write in the number of your apparatus (JV1, JV2, or JV3) that you used for your measurements.]
  • EQE Apparatus Number: [Write in the number of the apparatus (EQE1, EQE2, or EQE3) that you used for your measurements.]

1 J-V Analysis

  1. Create a SINGLE plot of the J-V curves of the forward scans with data from all pixels.

    a. Remember you can copy and paste code from previous notebooks and use AI tools.

    b. Also remember that you measured I-V from the pixels, so you will need to determine the current density, J, first before plotting.

# Your code here

Question 1: Discuss what you observe from looking at the comparison of your J-V curves. Do you have any pixels showing different behaviors?

  1. Calculate the \(J_{sc}\) and PCE values for each pixel and save them to a CSV file. The code below will also extract the standard error (SE) of the current at key measurement points, which we will use for uncertainty propagation later in the notebook. The code will loop through this week's JV measurements and output a CSV file with the following column headers:

    Pixel Number | Jsc (mA/cm^2) | SE_Jsc (mA/cm^2) | J_pmax (mA/cm^2) | V_pmax (V) | PCE | SE_Jpmax (mA/cm^2) | dV (V) | n_at_MPP

    Note. If you already have your own loop to do these calculations, feel free to use that. Just make sure your code also reads the Forward_std (mA) and Forward_n columns from the raw data so you can compute the standard error.

# Variables needed for J-V analysis
import numpy as np
import matplotlib.pyplot as plt
import pandas as pd

# Update variables as needed
date_str = "YYYY_MM_DD"
cell_id  = "###"
area_cm2 = 0.14
P_in = 99.8
pixels = range(1, 9)

# Column names matching the CSV output from measurement software
# JV data columns: Voltage (V), Forward_mean (mA), Forward_std (mA), Forward_n,
#                   Reverse_mean (mA), Reverse_std (mA), Reverse_n
COL_V          = "Voltage (V)"
COL_Forward_mA = "Forward_mean (mA)"
COL_Fwd_std    = "Forward_std (mA)"
COL_Fwd_n      = "Forward_n"

# Store raw DataFrames and JV DataFrames for each pixel
raw_data = {}
jv_data  = {}

# Load each pixel's data
for n in pixels:
    fname = f"{date_str}_IV_cell{cell_id}_pixel{n}.csv"
    try:
        df_raw = pd.read_csv(fname)
        raw_data[n] = df_raw  # keep the full raw data for SE calculations

        df_jv = pd.DataFrame({
            "Voltage (V)": df_raw[COL_V].astype(float),
            "Current Density (mA/cm^2)": df_raw[COL_Forward_mA].astype(float) / area_cm2
        }).sort_values("Voltage (V)").reset_index(drop=True)

        # Add power density
        df_jv["Power Density (mW/cm^2)"] = (
            df_jv["Voltage (V)"] * df_jv["Current Density (mA/cm^2)"]
        )

        jv_data[n] = df_jv
        print(f"[ok] Loaded pixel {n}")

    except FileNotFoundError:
        print(f"[skip] Pixel {n}: file not found ({fname})")
    except Exception as e:
        print(f"[skip] Pixel {n}: error reading {fname}: {e}")

# --- Calculate PCE parameters and SE-based uncertainties ---
rows = []
valid_pixels = sorted(jv_data.keys())

for n in valid_pixels:
    jv  = jv_data[n]
    raw = raw_data[n].sort_values(COL_V).reset_index(drop=True)

    V = jv["Voltage (V)"].to_numpy()
    J = jv["Current Density (mA/cm^2)"].to_numpy()

    # --- Standard PV parameters ---
    Voc = float(np.interp(0.0, J, V))
    Jsc = float(np.interp(0.0, V, J))
    P   = V * J
    idx_pmax = int(np.argmin(P))   # most negative power = MPP
    Vmp = float(V[idx_pmax])
    Jmp = float(J[idx_pmax])

    denom = abs(Jsc) * Voc
    FF  = (Vmp * abs(Jmp)) / denom if denom != 0 else np.nan
    PCE = ((Voc * abs(Jsc) * FF) / P_in) * 100 if P_in != 0 else np.nan

    # --- Standard Error at the MPP ---
    std_mA_mpp = float(raw[COL_Fwd_std].iloc[idx_pmax])
    n_mpp      = float(raw[COL_Fwd_n].iloc[idx_pmax])

    if n_mpp > 1:
        SE_I_mpp = std_mA_mpp / np.sqrt(n_mpp)    # SE of current in mA
        SE_J_mpp = SE_I_mpp / area_cm2             # SE of current density in mA/cm^2
    else:
        SE_J_mpp = np.nan
        print(f"  [warn] Pixel {n}: n=1 at MPP, SE undefined")

    # --- Standard Error at V=0 (for Jsc uncertainty) ---
    idx_v0 = int(np.argmin(np.abs(raw[COL_V].to_numpy())))
    std_mA_v0 = float(raw[COL_Fwd_std].iloc[idx_v0])
    n_v0      = float(raw[COL_Fwd_n].iloc[idx_v0])

    if n_v0 > 1:
        SE_Jsc = (std_mA_v0 / np.sqrt(n_v0)) / area_cm2
    else:
        SE_Jsc = np.nan

    # --- Voltage uncertainty: half the voltage step size ---
    if len(V) > 1:
        voltage_step = float(np.median(np.abs(np.diff(V))))
        dV = voltage_step / 2.0
    else:
        dV = np.nan

    rows.append({
        "Pixel Number":        n,
        "Jsc (mA/cm^2)":      Jsc,
        "SE_Jsc (mA/cm^2)":   SE_Jsc,
        "J_pmax (mA/cm^2)":   Jmp,
        "V_pmax (V)":         Vmp,
        "PCE":                PCE,
        "SE_Jpmax (mA/cm^2)": SE_J_mpp,
        "dV (V)":             dV,
        "n_at_MPP":           n_mpp,
    })

# Convert to DataFrame and write out as CSV
out_name = f"{date_str}_jsc_pce_cell{cell_id}.csv"
df_out = pd.DataFrame(rows)

if df_out.empty:
    print("[warn] No valid pixels found — did you run the loader above?")
else:
    df_out.sort_values("Pixel Number").to_csv(out_name, index=False)
    print(f"\n[ok] Wrote {out_name}")
    display(df_out)

NOTE. Even though your code gives you confirmation that it has successfully written your CSV, always open the file and check to make sure the data you meant to write is actually there.

1.1 Measurement Uncertainty and Error Bars

Last week in lecture, we discussed error propagation and the importance of measurement uncertainty in the context of asking and answering scientific questions. To this end, we will calculate uncertainties in our JV measurements, propagate those uncertainties through our analysis, and then include error bars in our plots.

1.1.1 Standard Error of the Mean

When we take repeated measurements of the same quantity, we expect some variability from one measurement to the next. Two important statistical quantities help us characterize this variability:

Standard Deviation (std) tells us how spread out individual measurements are. If you measured a current 10 times, the standard deviation describes the typical spread of those 10 values.

Standard Error of the Mean (SE) tells us how precisely we know the average of those measurements:

\[SE = \frac{\text{std}}{\sqrt{n}}\]

where \(n\) is the number of repeated measurements. As we take more measurements, the standard error gets smaller — our estimate of the mean becomes more precise.

Why SE instead of std? When we report \(J_{pmax}\), \(J_{sc}\), or any other parameter, we are using the mean value from our repeated measurements. The appropriate uncertainty for a mean value is the standard error, not the standard deviation.

Our measurement software already computes the mean, standard deviation, and \(n\) for each voltage point. The columns Forward_std (mA) and Forward_n in your J-V data files contain exactly what we need:

\[SE_{J} = \frac{\texttt{Forward\_std (mA)}}{\sqrt{\texttt{Forward\_n}}} \div \texttt{pixel area}\]

The code above already extracted these SE values at the maximum power point and at \(V = 0\).

1.1.2 Uncertainty Propagation for PCE

We will propagate the uncertainty using the following formula:

\[\delta f = \sqrt{\left(\frac{\partial f}{\partial x}\right)^{2}(\delta x)^{2} + \left(\frac{\partial f}{\partial y}\right)^{2}(\delta y)^{2} + \left(\frac{\partial f}{\partial z}\right)^{2}(\delta z)^{2}}\]

Recall that:

\[PCE = \frac{|J_{pmax}| \times V_{pmax}}{P_{in}} \times 100\%\]

so the uncertainty in the PCE is:

\[\delta_{PCE} = PCE \times \sqrt{\left(\frac{\delta J_{pmax}}{J_{pmax}}\right)^2 + \left(\frac{\delta V_{pmax}}{V_{pmax}}\right)^2 + \left(\frac{\delta P_{in}}{P_{in}}\right)^2}\]

where:

  • \(\delta J_{pmax}\) = standard error of the current density at the maximum power point (from our data)
  • \(\delta V_{pmax}\) = half the voltage step size (instrument resolution)
  • \(\delta P_{in}\) = \(0.9 mW/cm^{2}\) (calibration uncertainty of the solar simulator)

The code in the next cell computes this propagation automatically using the SE values extracted above.

1.1.3 Which uncertainty matters most?

Notice that we have three very different types of uncertainty entering the propagation:

Source Type What it represents
\(\delta J_{pmax}\) Statistical (SE from repeated measurements) How precisely we know the mean current at the MPP
\(\delta V_{pmax}\) Instrumental (voltage step resolution) The finite spacing between voltage set-points
\(\delta P_{in}\) Calibration (solar simulator spec) How well we know the incident light power

After running the code below, look at the relative uncertainty breakdown it prints. Which of these three terms dominates the total PCE uncertainty? What does that tell you about what limits the precision of your measurement — and what you would need to change to reduce it?

# --- PCE Uncertainty Propagation using Standard Error ---
df = pd.read_csv(f"{date_str}_jsc_pce_cell{cell_id}.csv")

# Time variable — update depending on the week
t = 168  # hours since initial measurement (update each week)
df["Time (hr)"] = t

# Uncertainty in light power (from solar simulator calibration)
dP_in = 0.9  # mW/cm^2

# --- Propagate uncertainty for PCE ---
J_abs   = np.abs(df["J_pmax (mA/cm^2)"].to_numpy())
V_vals  = np.abs(df["V_pmax (V)"].to_numpy())
PCE_vals = df["PCE"].to_numpy()
dJ      = df["SE_Jpmax (mA/cm^2)"].to_numpy()
dV      = df["dV (V)"].to_numpy()

# Relative uncertainties
rel_J   = dJ / J_abs
rel_V   = dV / V_vals
rel_Pin = dP_in / P_in

# PCE uncertainty (in percentage points, same units as PCE)
df["PCE_uncertainty (%)"] = PCE_vals * np.sqrt(rel_J**2 + rel_V**2 + rel_Pin**2)

# Jsc uncertainty for reporting
df["Jsc_uncertainty (mA/cm^2)"] = df["SE_Jsc (mA/cm^2)"]

# Write final output
out_name = f"{date_str}_jsc_pce_uncertainties_cell{cell_id}.csv"
cols_to_write = [
    "Pixel Number", "Time (hr)",
    "Jsc (mA/cm^2)", "Jsc_uncertainty (mA/cm^2)",
    "PCE", "PCE_uncertainty (%)"
]
df[cols_to_write].to_csv(out_name, index=False)
print(f"[ok] Wrote {out_name}")
display(df[cols_to_write])

# Print a summary for quick review
print("\n--- Summary ---")
for _, row in df.iterrows():
    pix = int(row["Pixel Number"])
    pce = row["PCE"]
    dpce = row["PCE_uncertainty (%)"]
    jsc = row["Jsc (mA/cm^2)"]
    djsc = row["Jsc_uncertainty (mA/cm^2)"]
    print(f"  Pixel {pix}: PCE = {pce:.2f} +/- {dpce:.2f} %,  "
          f"Jsc = {jsc:.3f} +/- {djsc:.3f} mA/cm^2")

# --- Relative uncertainty breakdown ---
print("\n--- Relative Uncertainty Breakdown (as %) ---")
print(f"  {'Pixel':>5s}  {'δJ/J':>8s}  {'δV/V':>8s}  {'δP/P':>8s}  {'Total':>8s}")
for _, row in df.iterrows():
    pix = int(row["Pixel Number"])
    rJ = row["SE_Jpmax (mA/cm^2)"] / abs(row["J_pmax (mA/cm^2)"]) * 100
    rV = row["dV (V)"] / abs(row["V_pmax (V)"]) * 100
    rP = dP_in / P_in * 100
    total = np.sqrt((rJ/100)**2 + (rV/100)**2 + (rP/100)**2) * 100
    print(f"  {pix:5d}  {rJ:7.3f}%  {rV:7.3f}%  {rP:7.3f}%  {total:7.3f}%")

print(f"\n  Note: δJ/J is the SE-based measurement uncertainty (statistical).")
print(f"        δV/V is the voltage step resolution (instrumental).")
print(f"        δP/P is the solar simulator calibration (systematic).")

1.1.4 Interpreting the Uncertainty Breakdown

Look at the relative uncertainty table printed above. You should notice that:

  1. \(\delta J / J\) is very small (typically < 0.1%). With \(n\) repeated measurements at each voltage, the standard error of the mean current is tiny — our instrument measures current very precisely and reproducibly.
  2. \(\delta V / V\) and \(\delta P_{in} / P_{in}\) dominate (each ~1%). These are not statistical uncertainties from repeated measurements — they are fixed properties of the instrument (the voltage step size chosen for the sweep) and the calibration of the solar simulator.

This reveals something important about this experiment: the precision of the PCE measurement is limited by instrument resolution and calibration, not by measurement repeatability. Taking more current measurements (increasing \(n\)) would make \(\delta J / J\) even smaller, but would barely change the total uncertainty because the other two terms dominate.

Question 2: Looking at the breakdown, which term contributes the most to the total PCE uncertainty? If you wanted to cut the PCE uncertainty in half, what would you need to change about the measurement — and what would not help?

  1. Repeat the J-V analysis above for your other weeks of data. For each week, upload the raw JV CSV files from your team's shared Google Drive folder, update date_str and t, and re-run the code to generate the uncertainty CSV files. (The originals will remain in Google Drive — uploading just creates a working copy in Colab.)

    Once you have uncertainty CSV files for all of your weeks, you are ready to plot.

# Add CSV files you want to have on the plot
files = [
    "YYYY_MM_DD_jsc_pce_uncertainties_cell###.csv",
    "YYYY_MM_DD_jsc_pce_uncertainties_cell###.csv",
    # etc,
]

# Load and combine data, skipping any missing files
dfs = []
for f in files:
    try:
        d = pd.read_csv(f)
        d["source_file"] = f  # optional: helps debug
        dfs.append(d)
    except FileNotFoundError:
        print(f"[skip] missing file: {f}")

if not dfs:
    raise SystemExit("No data files loaded. Check file names above.")

all_df = pd.concat(dfs, ignore_index=True)

# Keep only the columns we need and make them be numbers. You can add additional details from the input files if you want.
need = ["Pixel Number", "Time (hr)", "PCE", "PCE_uncertainty (%)"]
missing = [c for c in need if c not in all_df.columns]
if missing:
    raise ValueError(f"Missing required columns in data: {missing}")

all_df["Pixel Number"] = pd.to_numeric(all_df["Pixel Number"], errors="coerce").astype("Int64")
all_df["Time (hr)"]    = pd.to_numeric(all_df["Time (hr)"], errors="coerce")
all_df["PCE"]          = pd.to_numeric(all_df["PCE"], errors="coerce")
all_df["PCE_uncertainty (%)"] = pd.to_numeric(all_df["PCE_uncertainty (%)"], errors="coerce")

# Drop rows missing basics and dedupe (keep last if duplicates exist)
all_df = (
    all_df.dropna(subset=["Pixel Number", "Time (hr)", "PCE"])
          .drop_duplicates(subset=["Pixel Number", "Time (hr)"], keep="last")
)

# Determine which pixels actually appear
pixels_present = sorted(all_df["Pixel Number"].dropna().unique().astype(int))

# Make a plot
fig, ax = plt.subplots(figsize=(9, 6))

markers = ["o", "s", "^", "D", "v", ">", "x", "*"]

for i, pix in enumerate(pixels_present):
    d = all_df[all_df["Pixel Number"] == pix].sort_values("Time (hr)")
    if d.empty:
        continue

    marker = markers[i % len(markers)]  # cycle markers if many pixels

    ax.errorbar(
        d["Time (hr)"].to_numpy(),
        d["PCE"].to_numpy(),
        yerr=d["PCE_uncertainty (%)"].to_numpy(),
        fmt=marker,
        capsize=4,
        label=f"Pixel {pix}"
    )

# Update legend/labels as desired
ax.set_xlabel("Time (hr)")
ax.set_ylabel("PCE (%)")
ax.set_title(f"PCE vs Time - Cell {cell_id}")
ax.grid(True, alpha=0.4)
ax.legend(fontsize=8, title="Pixels", ncols=2)

plt.tight_layout()
plt.show()

Congratulations! That was a bunch of new code, but you will have to make minimal edits to use it in upcoming weeks.

Question 3: Discuss what you observe from looking at the PCE over time. Has anything changed over the past few weeks?

1.2 EQE Analysis with Uncertainty

The External Quantum Efficiency at each wavelength is:

\[EQE(\lambda) = \frac{I(\lambda)}{P(\lambda)} \times \frac{hc}{e\lambda}\]

where \(I(\lambda)\) is the measured photocurrent and \(P(\lambda)\) is the incident optical power at wavelength \(\lambda\).

Since EQE is a ratio of two measured quantities (each with their own uncertainty), we can propagate the uncertainties using the same approach as for PCE:

\[\frac{\delta EQE}{EQE} = \sqrt{\left(\frac{\delta I}{I}\right)^2 + \left(\frac{\delta P}{P}\right)^2}\]

where:

  • \(\delta I = SE_I = \frac{\texttt{Current\_std (nA)}}{\sqrt{n_I}}\)
  • \(\delta P = SE_P = \frac{\texttt{Power\_std (uW)}}{\sqrt{n_P}}\)

This gives us an uncertainty band around each EQE curve, allowing us to assess whether changes in EQE between weeks are statistically significant.

As a reminder, the EQE data files have the following columns:

  • Power data: Wavelength (nm), Power_mean (uW), Power_std (uW), n
  • Current data: Wavelength (nm), Current_mean (nA), Current_std (nA), n
  1. The code below calculates EQE and its uncertainty for each pixel and saves the results to a CSV file.
  2. The plotting cells that follow create EQE curves with shaded uncertainty bands.
# --- EQE Calculation with Standard Error Uncertainty ---

# Physical constants
h = 6.62607015e-34       # Planck's constant (J*s)
c = 3.0e8                # Speed of light (m/s)
e_charge = 1.602176634e-19  # Elementary charge (C)

# Load power data (shared across all pixels for one apparatus)
power_fname = f"{date_str}_power_cell{cell_id}.csv"
try:
    power_data = pd.read_csv(power_fname)
    print(f"[ok] Loaded power data: {power_fname}")
except FileNotFoundError:
    print(f"[error] Power file not found: {power_fname}")
    print("  Update the filename above to match your power data file.")

wavelength_nm = power_data["Wavelength (nm)"].to_numpy()
wavelength_m  = wavelength_nm * 1e-9

P_mean_uW = power_data["Power_mean (uW)"].to_numpy()
P_std_uW  = power_data["Power_std (uW)"].to_numpy()
n_P       = power_data["n"].to_numpy().astype(float)

# SE of power
SE_P_uW = np.where(n_P > 1, P_std_uW / np.sqrt(n_P), np.nan)

# Build EQE results dataframe
eqe_df = pd.DataFrame({"Wavelength (nm)": wavelength_nm})

for pix in pixels:
    curr_fname = f"{date_str}_current_cell{cell_id}_pixel{pix}.csv"
    try:
        curr_data = pd.read_csv(curr_fname)

        I_mean_nA = curr_data["Current_mean (nA)"].to_numpy()
        I_std_nA  = curr_data["Current_std (nA)"].to_numpy()
        n_I       = curr_data["n"].to_numpy().astype(float)

        # SE of current
        SE_I_nA = np.where(n_I > 1, I_std_nA / np.sqrt(n_I), np.nan)

        # Convert to SI units
        I_mean_A = I_mean_nA * 1e-9
        SE_I_A   = SE_I_nA * 1e-9
        P_mean_W = P_mean_uW * 1e-6
        SE_P_W   = SE_P_uW * 1e-6

        # EQE calculation
        eqe_vals = (I_mean_A / P_mean_W) * (h * c) / (e_charge * wavelength_m)

        # EQE uncertainty propagation: dEQE/EQE = sqrt((dI/I)^2 + (dP/P)^2)
        rel_I = SE_I_A / np.abs(I_mean_A)
        rel_P = SE_P_W / np.abs(P_mean_W)
        eqe_unc = np.abs(eqe_vals) * np.sqrt(rel_I**2 + rel_P**2)

        eqe_df[f"eqe_pix{pix}"]     = eqe_vals
        eqe_df[f"eqe_unc_pix{pix}"] = eqe_unc

        print(f"[ok] Pixel {pix}: peak EQE = {np.nanmax(eqe_vals):.4f} "
              f"({np.nanmax(eqe_vals)*100:.1f}%)")
    except FileNotFoundError:
        print(f"[skip] Pixel {pix}: current file not found ({curr_fname})")
    except Exception as e:
        print(f"[skip] Pixel {pix}: {e}")

# Write to CSV
eqe_out = f"{date_str}_eqe_cell{cell_id}.csv"
eqe_df.to_csv(eqe_out, index=False)
print(f"\n[ok] Wrote {eqe_out}")
# --- EQE Plots with Shaded Uncertainty Bands (This Week) ---
eqe_df_plot = pd.read_csv(f"{date_str}_eqe_cell{cell_id}.csv")
wl = eqe_df_plot["Wavelength (nm)"].to_numpy()

fig, axes = plt.subplots(4, 2, figsize=(12, 16))
axes_flat = axes.flatten()

for i in range(8):
    ax = axes_flat[i]
    pix = i + 1
    col_eqe = f"eqe_pix{pix}"
    col_unc = f"eqe_unc_pix{pix}"

    if col_eqe not in eqe_df_plot.columns:
        ax.set_visible(False)
        continue

    eqe_vals = eqe_df_plot[col_eqe].to_numpy()
    ax.plot(wl, eqe_vals, 'b-', label="EQE")

    if col_unc in eqe_df_plot.columns:
        eqe_unc = eqe_df_plot[col_unc].to_numpy()
        ax.fill_between(wl,
                        eqe_vals - eqe_unc,
                        eqe_vals + eqe_unc,
                        alpha=0.3, color='blue',
                        label="SE uncertainty")

    ax.set_title(f"Pixel {pix}")
    ax.set_xlabel("Wavelength (nm)")
    ax.set_ylabel("EQE")
    ax.legend(fontsize=8)
    ax.set_ylim(bottom=0)

plt.suptitle(f"EQE with Uncertainty — {date_str} Cell {cell_id}", fontsize=14)
plt.tight_layout()
plt.show()
# --- Multi-Week EQE Comparison with Uncertainty Bands ---

# Add your EQE CSV files here (update filenames each week)
eqe_files = {
    "Week 5": "YYYY_MM_DD_eqe_cell###.csv",
    "Week 6": "YYYY_MM_DD_eqe_cell###.csv",
    "Week 7": "YYYY_MM_DD_eqe_cell###.csv",
    # Add more weeks as needed
}

# Color cycle for different weeks
colors = ['blue', 'red', 'green', 'purple', 'orange', 'brown']

# Load all EQE files
eqe_weeks = {}
for label, fname in eqe_files.items():
    try:
        eqe_weeks[label] = pd.read_csv(fname)
        print(f"[ok] Loaded {label}: {fname}")
    except FileNotFoundError:
        print(f"[skip] {label}: {fname} not found")

if not eqe_weeks:
    print("[warn] No EQE files loaded. Check filenames above.")
else:
    fig, axes = plt.subplots(4, 2, figsize=(12, 16))
    axes_flat = axes.flatten()

    for i in range(8):
        ax = axes_flat[i]
        pix = i + 1
        col_eqe = f"eqe_pix{pix}"
        col_unc = f"eqe_unc_pix{pix}"

        for j, (label, df_eqe) in enumerate(eqe_weeks.items()):
            color = colors[j % len(colors)]
            wl = df_eqe["Wavelength (nm)"].to_numpy()

            if col_eqe in df_eqe.columns:
                eqe_vals = df_eqe[col_eqe].to_numpy()
                ax.plot(wl, eqe_vals, color=color, label=label)

                # Add uncertainty band if the column exists
                if col_unc in df_eqe.columns:
                    eqe_unc = df_eqe[col_unc].to_numpy()
                    ax.fill_between(wl,
                                    eqe_vals - eqe_unc,
                                    eqe_vals + eqe_unc,
                                    alpha=0.15, color=color)

        ax.set_title(f"Pixel {pix}")
        ax.set_xlabel("Wavelength (nm)")
        ax.set_ylabel("EQE")
        ax.legend(fontsize=7)
        ax.set_ylim(bottom=0)

    plt.suptitle(f"EQE Comparison Over Time — Cell {cell_id}", fontsize=14)
    plt.tight_layout()
    plt.show()

Question 4: Discuss what you observe from looking at the comparison of all of your EQE curves. Considering the uncertainty bands, are the changes in EQE between weeks statistically significant? At which wavelengths is the uncertainty largest relative to the EQE value, and why?

2 Author Contributions (required)

List the name of each team member and please describe briefly how each member contributed to the work in lab this week. (e.g., taking a measurement, updating the Colab notebook, etc.)

3 Use of AI (required)

  1. How your team used AI tools: Describe the specific tasks where AI-assisted tools were involved. For example, did AI help with writing, debugging, optimizing, or refining your code? Which components of the analysis did your team use AI on?
  2. When your team used AI tools: Specify at what stage(s) of your programming process you used AI. Was it during initial code development, troubleshooting, etc.?
  3. Which AI tools your team used: Identify the AI tools or platforms your team consulted (e.g., ChatGPT, GitHub Copilot, etc.).