Week 2 of 4: Instrumentation and Noise Characterization
Last week you calibrated your photodetector and learned the knife-edge technique for measuring beam size. This week you’ll learn Python-based data acquisition and—critically—characterize your measurement system’s noise floor. Your goal: make a quantitative, evidence-based decision about which gain setting to use for Week 4’s automated measurements.
Last week: Aligned optics, calibrated photodetector, introduced knife-edge technique
This week: Learn DAQ programming → Characterize measurement system noise → Choose optimal gain setting
Next week: Learn Gaussian beam theory → Set up motor controller → Take first automated measurement
The second week of the Gaussian Beams lab introduces you to Python for data acquisition and guides you through interfacing Python with the instrumentation and data acquisition systems used in this course. You will also learn about digital sampling theory and characterize your measurement system’s noise floor—a critical step for making informed measurement decisions in Week 4.
An important discovery awaits: The DAQ device that records your data has its own noise floor—and this is what actually limits your Week 4 measurements. Every instrument has limitations. Expert experimentalists characterize those limitations and design around them. This week, you’ll do exactly that.
This week’s lab is divided into two parts. In part 1 (Prelab), you will learn essential curve fitting techniques that you’ll use throughout this course. In part 2 (Lab), you will learn Python programming for data acquisition using a National Instruments DAQ device, the NI USB-6009. This multifunction USB powered device has 8 single-ended (4 differential) analog inputs (14-bit, 48 kS/s), 2 analog outputs (12-bit, 150 S/s), 12 digital I/O channels, and a 32-bit counter. You will then apply these DAQ skills to characterize your measurement system’s noise floor and make a quantitative decision about optimal gain settings.
Python is a versatile programming language widely used in scientific computing and data analysis. Many research labs use Python for instrument control, data acquisition, and analysis. Its advantages include:
You can use Python on the lab laptops where it is already installed. See the Python Resources page for installation instructions if you want to set it up on your own computer.
After completing the prelab, you will be able to:
scipy.optimize.curve_fit.After completing the lab, you will be able to:
This week culminates in a decision: which gain setting will you use for Week 4’s automated beam profiling? This isn’t arbitrary—you’ll build the quantitative evidence to justify your choice.
Prelab: Develop curve-fitting skills you’ll use throughout this course. You’ll learn to minimize χ², interpret residuals, and assess goodness of fit. These skills are essential for extracting beam radii from your knife-edge data.
In Lab: You’ll work through a predict-measure-compare cycle for noise characterization:
See the detailed deliverables checklist at the end of this guide.
This week’s prelab builds on the uncertainty concepts you learned during Week 1’s lab (where you measured voltage fluctuations and calculated standard deviation). Now we move from estimating uncertainties in individual measurements to fitting data and propagating those uncertainties to derived quantities. This is a “user’s guide” to least-squares fitting and determining the goodness of your fits. At the end of the prelab you will be able to:
Question: Why do we call it “least-squares” fitting?
Answer: Because the best fit is determined by minimizing the weighted sum of squares of the deviation between the data and the fit. Properly speaking this “sum of squares” is called “chi-squared” and is given by
\[\chi^2 = {\displaystyle \sum_{i=1}^{N}}\frac{1}{\sigma_i^2}(y_i-y(x_i,a,b,c, \ ... \ ))^2\text{,}\](1)
where there are where \(N\) data points, \((x_i,y_i )\), and the fit function is given by \(y(x_i,a,b,c, \ … \ )\) where \(a, b,\) etc. are the fit parameters.
Question: What assumptions are made for the method to be valid?
Answer: The two assumptions are:
Question: Why does minimizing the sum of squares give us the best fit?
Answer: Given the two above assumptions, the fit that minimizes the sum of squares is the most likely function to produce the observed data. This can be proven using a little calculus and probability. A more detailed explanation is found in Taylor’s Introduction to Error Analysis Sec. 5.5 “Justification of the Mean as Best Estimate” or Bevington and Robinson’s Data Reduction Sec. 4.1 “Method of Least-Squares”.
You will rarely minimize \(\chi^2\) graphically in a lab. However, this exercise will help you better understand what fitting routines actually do to find the best fit.
Download and plot this data set. It was generated by inserting a razor blade into path of a laser beam and measuring the photodetector voltage of the laser light. The \(x\) column is the micrometer (razor) position in meters and the \(y\) column is the photodetector voltage in volts.
import numpy as np
import matplotlib.pyplot as plt
# Load the data
data = np.loadtxt('profile_data_without_errors.csv', delimiter=',', skiprows=1)
x_data = data[:, 0]
y_data = data[:, 1]
# Plot the data
plt.figure(figsize=(10, 6))
plt.scatter(x_data, y_data, label='Data')
plt.xlabel('Position (m)')
plt.ylabel('Voltage (V)')
plt.legend()
plt.show()Define the same fit function as:
\[y(x,a,b,c,w) = a \ Erf\left(\frac{\sqrt{2}}{w}(x-b)\right)+c\]
In Python, this can be written using
scipy.special.erf:
from scipy.special import erf
def beam_profile(x, amplitude, center, width, offset):
"""Error function model for knife-edge beam profile.
Parameters:
x: position (m)
amplitude: half the voltage swing (V)
center: beam center position (m)
width: beam radius w (m)
offset: vertical offset (V)
"""
return amplitude * erf(np.sqrt(2) * (x - center) / width) + offsetNote on terminology: The parameter \(w\) is the beam radius—specifically, the \(1/e^2\) radius where intensity drops to \(1/e^2\) of the peak value. This is not the beam diameter (which would be \(2w\)) or the FWHM. Gaussian beam parameters are discussed further in Week 3.
Reduce the fit to two free parameters. This step is only necessary because it is hard to visualize more than 3 dimensions. Assume \(a_{fit}=(V_{max}-V_{min})/2 = 1.4375\) and \(c_{fit} =(V_{max}+V_{min})/2 = 1.45195\). These were determined by averaging the first 6 data points to get \(V_{min}\) and the last 5 to get \(V_{max}\).
Use Equation 1 to write an expression for \(\chi^2\) in terms of your \(w\) and \(b\) parameters, and the \(x\) (position) data and \(y\) (voltage) data. Since you don’t have any estimate for the uncertainties \(\sigma_i\), assume they are all unity so \(\sigma_i=1\).
def chi_squared(width, center, x_data, y_data, amplitude_fixed, offset_fixed):
"""Calculate chi-squared for given parameters."""
y_fit = beam_profile(x_data, amplitude_fixed, center, width, offset_fixed)
return np.sum((y_data - y_fit)**2)Before running any code, answer these prediction questions in your notebook:
Make a contour plot of \(\chi^2(w,b)\) and tweak the plot range until you see the minimum (see Figure 1 for an example of what a well-tuned plot looks like). You can use AI assistance or the code below. The goal is to interpret the result, not to write the code from scratch.
# Create a grid of center and width values
# NOTE: These ranges may need adjustment to clearly show the minimum!
center_range = np.linspace(0.010, 0.012, 100)
width_range = np.linspace(0.0001, 0.0006, 100)
B, W = np.meshgrid(center_range, width_range)
# Calculate chi-squared for each combination
amplitude_fixed = 1.4375
offset_fixed = 1.45195
Z = np.zeros_like(B)
for i in range(len(width_range)):
for j in range(len(center_range)):
Z[i, j] = chi_squared(width_range[i], center_range[j], x_data, y_data,
amplitude_fixed, offset_fixed)
# Make contour plot
plt.figure(figsize=(10, 8))
plt.contour(B, W, Z, levels=20)
plt.colorbar(label='$\\chi^2$')
plt.xlabel('center of beam, $b$ (m)')
plt.ylabel('beam radius, $w$ (m)')
plt.title('$\\chi^2$ Contour Plot')
plt.show()
Interpretation questions (answer in your notebook):
Graphically determine the best fit parameters to 3 significant digits. Record these values in your notebook—you will use them in step 9.
Checkpoint: Before proceeding, confirm you have recorded your graphical estimates:
Compare with the best fit result from
scipy.optimize.curve_fit (allow all 4 parameters
to vary).
Before running this code, examine the initial guesses below. Compare the center guess (0.01 m) to your data’s position range. What do you predict will happen when you run the fit?
Warning: You may get a
Nonlinear fitting algorithms likeRuntimeError!curve_fitare iterative—they start at your initial guess and search for a minimum. If your initial guess is far from the true minimum, the algorithm may:
- Wander into regions where the function behaves badly
- Exceed its maximum number of iterations without converging
- Return a
RuntimeError: Optimal parameters not found: maxfev exceededThis is expected behavior with poor initial guesses, not a bug in your code. Your \(\chi^2\) contour plot shows exactly why this happens: an initial guess outside the “valley” containing the minimum has no gradient pointing toward the solution.
If you encounter this error, proceed to the fix below using your graphically-determined values.
from scipy.optimize import curve_fit
# Initial guesses: [amplitude, center, width, offset]
# NOTE: These are placeholder values that may not work for your data!
p0 = [1.4375, 0.01, 0.0005, 1.45195]
# Perform the fit
popt, pcov = curve_fit(beam_profile, x_data, y_data, p0=p0)
print("Best fit parameters:")
print(f" amplitude = {popt[0]:.6f}")
print(f" center = {popt[1]:.6f}")
print(f" width = {popt[2]:.6f}")
print(f" offset = {popt[3]:.6f}")If the fit fails (RuntimeError) or produces unreasonable results: Replace the initial guesses with your graphically-determined values from step 8. This is standard practice—graphical exploration provides the physical insight needed to initialize numerical optimization.
# Use YOUR graphically-determined values as starting points
p0 = [amplitude_fixed, your_center_estimate, your_width_estimate, offset_fixed]
popt, pcov = curve_fit(beam_profile, x_data, y_data, p0=p0)After getting a successful fit: Do your graphical and computational results agree to 3 significant figures?
Reflection: Why did the original initial
guesses fail (if they did)? Look at your \(\chi^2\) contour plot—where does
the initial guess center = 0.01 fall relative to
the valley containing the minimum?
Question: Where does the uncertainty in the fit parameters come from?
Answer: The optimal fit parameters depend on the data points \((x_i,y_i)\). The uncertainty, \(\sigma_i\), in the \(y_i\) means there is a propagated uncertainty in the calculation of the fit parameters. The error propagation calculation is explained in detail in the references, especially Bevington and Robinson.
Question: How does curve_fit
calculate the uncertainty in the fit parameters when no error
estimate for the \(\sigma_i\)
is provided?
Answer: When no uncertainties are provided,
curve_fit (and other fitting routines) estimate
the uncertainty in the data \(\sigma_y^2\) using the “residuals”
of the best fit:
\[\sigma_y^2 = \frac{1}{N-n}{\displaystyle \sum_{i=1}^{N}}(y_i-y(x_i,a_0,b_0,c_0, \ ... \ ))^2\text{,}\quad\quad\](2)
where there are \(N\) data points \(y_i\) and the best fit value at each point is given by \(y\), which depends on \(x_i\) and the \(n\) best fit parameters \(a_0,b_0,c_0, \ ... \ \). It is very similar to how you would estimate the standard deviation of a repeated measurement, which for comparison’s sake is given by:
\[\sigma_y^2 = \frac{1}{N-n}{\displaystyle \sum_{i=1}^{N}}(y_i-\overline{y})^2\text{.}\](3)
The parameter uncertainties are then extracted from the covariance matrix:
# Get parameter uncertainties from the covariance matrix
perr = np.sqrt(np.diag(pcov))
print("Parameter uncertainties:")
print(f" σ_amplitude = {perr[0]:.6f}")
print(f" σ_center = {perr[1]:.6f}")
print(f" σ_width = {perr[2]:.6f}")
print(f" σ_offset = {perr[3]:.6f}")Use Equation 2 and your best fit parameters to estimate \(\sigma_y^2\), the statistical error of each data point given by your data.
# Calculate residuals
y_fit = beam_profile(x_data, *popt)
residuals = y_data - y_fit
# Estimate variance (N data points, n=4 parameters)
N = len(y_data)
n = 4
sigma_y_squared = np.sum(residuals**2) / (N - n)
sigma_y = np.sqrt(sigma_y_squared)
print(f"Estimated σ_y = {sigma_y:.6f} V")Compare your result with the estimate from the fit. The estimated variance can be calculated from the residuals.
Do the estimates agree? Why or why not?
This section covers two ways to analyze if a fit is good.
The first step is to look at the residuals. The residuals, \(r_i\), are defined as the difference between the data and the fit.
\[r_i=y_i-y(x_i,a,b,c, \ ... \ )\]
Make a plot of the residuals:
# Calculate and plot residuals
residuals = y_data - beam_profile(x_data, *popt)
plt.figure(figsize=(10, 4))
plt.scatter(x_data, residuals)
plt.axhline(y=0, color='r', linestyle='--')
plt.xlabel('Position (m)')
plt.ylabel('Residuals (V)')
plt.title('Fit Residuals')
plt.grid(True, alpha=0.3)
plt.show()Since we didn’t provide any estimates of the uncertainties, the fitting assumed the uncertainty of every point is the same. Based on the plot of residuals, was this a good assumption?
Do the residuals look randomly scattered about zero or do you notice any systematic error sources?
Is the distribution of residuals scattered evenly around zero? Or is there a particular range of \(x\) values where the residuals are larger than others?
What is the most likely source of the large uncertainty as the beam is cut near the center of the beam?
Question: If I have a good fit, should every data point lie within an error bar?
Answer: No. Most should, but we wouldn’t expect every data point to lie within an error bar. If the uncertainty is Gaussian distributed with a standard deviation \(\sigma_i\) for each data point, \(y_i\), then we expect roughly 68% of the data points to lie within their error bar. This is because 68% of the probability in a Gaussian distribution lies within one standard deviation of the mean.
This section answers the question “What should \(\chi^2\) be for a good fit?”
Suppose the only uncertainty in the data is statistical error, with a known standard deviation \(\sigma_i\), then on average each term in the sum is
\[\frac{1}{\sigma_i^2}(y_i-y(x_i,a,b,c, \ ... \ ))^2 \approx 1\text{,}\](4)
and the full \(\chi^2\) sum of squares is approximately
\[\chi^2 = {\displaystyle \sum_{i=1}^{N}}\frac{1}{\sigma_i^2}(y_i-y(x_i,a,b,c, \ ... \ ))^2\approx N-n\text{.}\quad\quad\](5)
So a good fit has
\[\chi_{red}^2 \equiv \frac{\chi^2}{N-n}\approx 1\text{.}\](6)
Considering your answers from Section 4.6.1 (especially 4.6.1.5), which method would give you the best estimate of the uncertainty for each data point, and why?
Eyeballing the fluctuations in each data point.
Taking \(N\) measurements at each razor position and then going to the next position.
Taking the entire data set \(N\) times.
When you have estimated the uncertainty \(\sigma_i\) of each data point \(y_i\) you should use this information when fitting to correctly evaluate the \(\chi^2\) expression in Equation 1. The points with high uncertainty contribute less information when choosing the best fit parameters.
In Python’s curve_fit, you provide
uncertainties using the sigma parameter:
# Weighted fit with known uncertainties
popt, pcov = curve_fit(
beam_profile,
x_data,
y_data,
p0=p0,
sigma=sigma_list, # Your uncertainty estimates
absolute_sigma=True # Use actual sigma values (not relative)
)Download this data set for a beam size measurement with uncertainties. The first column is razor position in meters, the second column is photodetector output voltage, and the third column is the uncertainty on the photodetector output voltage.
# Load data with uncertainties
data = np.loadtxt('profile_data_with_errors.csv', delimiter=',', skiprows=1)
x_data = data[:, 0]
y_data = data[:, 1]
y_err = data[:, 2]Do a weighted fit using the same fit function as in Section 4.3. Use the uncertainty estimates in the third column.
# Weighted fit
popt, pcov = curve_fit(
beam_profile, x_data, y_data,
p0=[1.4, 0.01, 0.0005, 1.45],
sigma=y_err,
absolute_sigma=True
)
perr = np.sqrt(np.diag(pcov))Calculate \(\chi^2\):
# Calculate chi-squared
y_fit = beam_profile(x_data, *popt)
chi2 = np.sum(((y_data - y_fit) / y_err)**2)
dof = len(y_data) - len(popt) # degrees of freedom
chi2_red = chi2 / dof
print(f"Chi-squared: {chi2:.2f}")
print(f"Degrees of freedom: {dof}")
print(f"Reduced chi-squared: {chi2_red:.2f}")How close is the reduced chi-squared to 1?
The “chi-squared test”. This part helps us understand if the value of \(\chi^2\) is statistically likely or not. The following graph gives the probability of exceeding a particular value of \(\chi^2\) for \(\nu=𝑁−𝑛=22\) degrees of freedom. It can be calculated using the Cumulative Density Function (CDF) for the chi-squared distribution. Use the graph to estimate the likelihood this value of \(\chi^2\) occurred by chance.
from scipy import stats
# Calculate p-value (probability of getting this chi2 or higher by chance)
p_value = 1 - stats.chi2.cdf(chi2, dof)
print(f"P-value: {p_value:.4f}")
Overestimating the uncertainties makes the fit seem good (according to a \(\chi^2\) test), even when it might be obviously a bad fit. It is best to do the \(\chi^2\) test using an honest estimate of your uncertainties. If the \(\chi^2\) is larger than expected \((\chi^2>𝑁−𝑛)\), then you should consider both the possibility of systematic error sources and the quality of your estimates of the uncertainties. On the other hand, if the \(\chi^2\) test is good \((\chi^2\approx 𝑁−𝑛)\), then it shows you have a good handle on the model of your system, and your sources of uncertainty. Finally, if \(\chi^2\ll (𝑁−𝑛)\), this likely indicates overestimated uncertainties.
curve_fit underestimate the true uncertainty?The uncertainty reported by curve_fit comes
from the covariance matrix and assumes:
In real experiments, these assumptions often fail. Consider these scenarios relevant to your beam size measurements:
Systematic errors in position: - If your
micrometer has a 0.01 mm systematic offset, this affects all
measurements the same way - curve_fit doesn’t know
about this, so it can’t include it in the parameter uncertainty
- The true uncertainty in beam size includes uncertainty in
position calibration
Model limitations: - The error function model assumes a perfectly Gaussian beam - Real laser beams may have slight deviations from Gaussian - The fit uncertainty assumes the model is exact
Correlated noise: - If 60 Hz interference
affects multiple adjacent points similarly, they’re not
independent - curve_fit assumes independent
errors, so it underestimates uncertainty
Reflection questions:
Under what conditions might the curve_fit
uncertainty be a good estimate of your true measurement
uncertainty?
In Week 4, you’ll extract beam waist \(w_0\) from fits at multiple positions. Besides statistical noise in voltage measurements, what other sources of uncertainty should you consider? List at least two.
Understanding the distinction between statistical and systematic uncertainties is crucial for proper error analysis. This distinction becomes especially important in Week 4 when you construct an uncertainty budget.
Statistical uncertainties (also called “random uncertainties”) are unpredictable fluctuations that vary from measurement to measurement. They average out over many measurements—take 100 readings and compute the mean, and the statistical uncertainty in that mean decreases by \(\sqrt{100} = 10\).
Examples from this lab: - Voltage noise on the photodetector (varies each reading) - Thermal fluctuations in electronic components - Shot noise from photon arrival times
Systematic uncertainties are consistent biases that affect all measurements the same way. They do NOT average out—take 100 readings and the systematic error remains exactly the same.
Examples from this lab: - Micrometer calibration offset (if it reads 0.02 mm high, ALL positions are 0.02 mm high) - DAQ voltage offset (shifts all readings by a fixed amount) - Beam not perfectly perpendicular to knife edge (consistent underestimate of width)
Why this matters for fitting:
curve_fit only sees statistical scatter around
your fit function. It has no way to detect systematic offsets.
If your micrometer is miscalibrated by 0.1 mm, the fit will
find parameters that are systematically shifted, and
curve_fit will not include this in the reported
uncertainty.
Quick self-test: Classify each of these as statistical (St) or systematic (Sy):
Answers: 1=Sy, 2=St (if averaging over many cycles), 3=Sy (drift is correlated, doesn’t average out), 4=St
In Week 4, when you construct your uncertainty budget, you will need to identify the dominant sources of both statistical AND systematic uncertainty, and combine them appropriately.
The error bar is a graphical way to display the uncertainty in a measurement. In order to put error bars on a plot you must first estimate the error for each point. Anytime you include error bars in a plot you should explain how the uncertainty in each point was estimated (e.g., you “eyeballed” the uncertainty, or you sampled it \(N\) times and took the standard deviation of the mean, etc.)
Creating plots with error bars in Python is straightforward
using plt.errorbar():
import numpy as np
import matplotlib.pyplot as plt
# Load data with uncertainties
data = np.loadtxt('gaussian_data_with_errors.txt', skiprows=1)
x = data[:, 0] # Position
y = data[:, 1] # Voltage
y_err = data[:, 2] # Uncertainty
# Create plot with error bars
plt.figure(figsize=(10, 6))
plt.errorbar(x, y, yerr=y_err, fmt='o', capsize=3,
label='Data with uncertainties')
plt.xlabel('Micrometer Position (inches)')
plt.ylabel('Photodetector Voltage (V)')
plt.title('Gaussian Beam Size Measurement')
plt.legend()
plt.grid(True, alpha=0.3)
plt.show()The errorbar() function parameters: -
x, y: Data points -
yerr: Uncertainty values (can also use
xerr for horizontal error bars) -
fmt='o': Marker style (circles) -
capsize=3: Size of error bar caps
Suppose you had estimated the uncertainty at every point in a width measurement of your Gaussian laser beam to be \(0.04 \ V\). This error was chosen to demonstrate the mechanics of making a plot with error bars, but the uncertainty in the actual data was probably smaller than this.
| Micrometer Position (inches) | Photodetector Voltage (V) | Estimated uncertainty (V) |
|---|---|---|
| 0.410 | 0.015 | 0.04 |
| 0.412 | 0.016 | 0.04 |
| 0.414 | 0.017 | 0.04 |
| 0.416 | 0.026 | 0.04 |
| 0.418 | 0.060 | 0.04 |
| 0.420 | 0.176 | 0.04 |
| 0.422 | 0.460 | 0.04 |
| 0.424 | 0.849 | 0.04 |
| 0.426 | 1.364 | 0.04 |
| 0.428 | 1.971 | 0.04 |
| 0.430 | 2.410 | 0.04 |
| 0.432 | 2.703 | 0.04 |
| 0.434 | 2.795 | 0.04 |
| 0.436 | 2.861 | 0.04 |
| 0.438 | 2.879 | 0.04 |
| 0.440 | 2.884 | 0.04 |
Download this data set and create a plot with error bars like Figure 3.
import numpy as np
import matplotlib.pyplot as plt
# Load data
data = np.loadtxt('gaussian_data_with_errors.txt', skiprows=1)
position = data[:, 0]
voltage = data[:, 1]
uncertainty = data[:, 2]
# Create plot
plt.figure(figsize=(10, 6))
plt.errorbar(position, voltage, yerr=uncertainty,
fmt='o', capsize=3, markersize=5)
plt.xlabel('Position (inches)')
plt.ylabel('Photodetector Output (V)')
plt.title('Gaussian Beam Size Measurement')
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
Now that you understand curve fitting, apply it to beam size analysis. This exercise prepares you for analyzing your own knife-edge measurements.
Note on AI assistance: You may use AI tools
to help write your curve fitting code. However, the learning
goal is to understand what the code does and why. Be prepared
to explain: (1) what the fit parameters mean physically, (2)
why the error function is the appropriate model, and (3) how to
interpret the uncertainties reported by
curve_fit.
In Week 1, you derived that blocking a Gaussian beam with a knife edge produces a signal described by:
\[P(x) = \frac{P_0}{2}\left[1 + \text{erf}\left(\frac{\sqrt{2}(x-x_0)}{w}\right)\right]\]
where \(w\) is the beam radius, \(x_0\) is the beam center position, and \(P_0\) is the total power.
For fitting, we use a slightly more general form that accounts for offsets:
\[y(x) = a \cdot \text{erf}\left(\frac{\sqrt{2}}{w}(x-b)\right) + c\]
where: - \(a\) = amplitude (half the voltage swing) - \(b\) = beam center position - \(w\) = beam radius (what we want!) - \(c\) = vertical offset
Note on terminology: The parameter \(w\) is the beam radius—specifically, the \(1/e^2\) radius where intensity drops to \(1/e^2\) of the peak value. This is not the beam diameter (which would be \(2w\)) or the FWHM. Gaussian beam parameters are discussed further in Week 3.
Download Test_Profile_Data.csv and complete the following:
Plot the raw data (position vs. voltage). Does it look like an error function?
Define the fit function (same as used earlier):
from scipy.special import erf
def beam_profile(x, amplitude, center, width, offset):
"""Error function model for knife-edge beam profile.
Parameters:
x: position (m)
amplitude: half the voltage swing (V)
center: beam center position (m)
width: beam radius w (m)
offset: vertical offset (V)
"""
return amplitude * erf(np.sqrt(2) * (x - center) / width) + offsetPerform the fit:
from scipy.optimize import curve_fit
# Load data
data = np.loadtxt('Test_Profile_Data.csv', delimiter=',', skiprows=1)
x = data[:, 0] # Position (m)
y = data[:, 1] # Voltage (V)
# Initial guesses (estimate from your plot)
# Order: [amplitude, center, width, offset]
p0 = [1.0, 0.001, 0.0005, 0.5]
# Fit
popt, pcov = curve_fit(beam_profile, x, y, p0=p0)
perr = np.sqrt(np.diag(pcov)) # Standard errors
print(f"Beam size w = {popt[2]:.2e} ± {perr[2]:.2e} m")Verify your result: You should get \(w = 4.52 \times 10^{-4}\) m (approximately 0.45 mm).
Plot data and fit together to verify the fit is reasonable.
Interpret the uncertainties: What is the fractional uncertainty in your beam size? Is this uncertainty dominated by statistical noise or could there be systematic effects?
Save your fitting code—you will use this same procedure to analyze your own knife-edge data in lab.
In this part of the lab, you will learn to use Python for
data acquisition. We’ll use the nidaqmx library to
interface with National Instruments DAQ devices.
For this lab, we recommend starting with Jupyter Notebook for interactive exploration, then transitioning to VS Code or another editor for writing reusable scripts. See the Python Resources page for setup instructions.
Ensure the NI-DAQmx drivers are installed (they should already be on lab computers). If needed, download from NI-DAQmx.
Connect the USB cable to your computer and the USB-6009.
Open NI Measurement & Automation Explorer (NI-MAX) to verify the device is recognized:
Use Python to list available DAQ devices:
import nidaqmx
from nidaqmx.system import System
# List all connected DAQ devices
system = System.local()
for device in system.devices:
print(f"Device: {device.name}")
print(f" Product Type: {device.product_type}")
print(f" AI Channels: {[ch.name for ch in device.ai_physical_chans]}")Connect the 5V power rail to AI0+ and
ground to AI0-. You must connect both wires since
the device measures a potential difference between the two
terminals.
Read a voltage to verify the connection:
with nidaqmx.Task() as task:
task.ai_channels.add_ai_voltage_chan("Dev1/ai0")
voltage = task.read()
print(f"Measured voltage: {voltage:.4f} V")To capture time-varying signals, you need to configure the sample rate and number of samples.
When acquiring data, you must specify:
For example, to capture 5 periods of a 1 kHz sine wave with 20 samples per period:
import nidaqmx
import numpy as np
import matplotlib.pyplot as plt
from nidaqmx.constants import AcquisitionType
# Configuration
sample_rate = 20000 # Hz
samples_to_read = 100
# Acquire data
with nidaqmx.Task() as task:
task.ai_channels.add_ai_voltage_chan("Dev1/ai0")
task.timing.cfg_samp_clk_timing(
rate=sample_rate,
sample_mode=AcquisitionType.FINITE,
samps_per_chan=samples_to_read
)
data = task.read(number_of_samples_per_channel=samples_to_read)
# Create time array
time = np.arange(samples_to_read) / sample_rate
# Plot the data
plt.figure(figsize=(10, 6))
plt.plot(time * 1000, data) # Time in ms
plt.xlabel('Time (ms)')
plt.ylabel('Voltage (V)')
plt.title('Acquired Signal')
plt.grid(True, alpha=0.3)
plt.show()Equipment: Use the Tektronix TBS 2000 Series oscilloscope and Keysight EDU33212A waveform generator at your station. These are the same instruments you used in PHYS 3330.
Now that you can acquire data with the DAQ, it’s important to understand how the choice of sample rate affects your measurements. This section explores what happens when you sample a signal too slowly.
Modify your Python script so that the Number of Samples and Sample Rate are easily configurable variables at the top:
import nidaqmx
import numpy as np
import matplotlib.pyplot as plt
from nidaqmx.constants import AcquisitionType
# Configuration - easily adjustable
SAMPLE_RATE = 500 # Samples per second
NUM_SAMPLES = 500 # Total samples (1 second of data)
DAQ_CHANNEL = "Dev1/ai0"
def acquire_data(sample_rate, num_samples, channel):
"""Acquire data from DAQ with specified parameters."""
with nidaqmx.Task() as task:
task.ai_channels.add_ai_voltage_chan(channel)
task.timing.cfg_samp_clk_timing(
rate=sample_rate,
sample_mode=AcquisitionType.FINITE,
samps_per_chan=num_samples
)
data = task.read(number_of_samples_per_channel=num_samples)
return np.array(data)Set up a function generator to produce a 1 kHz sine wave.
Connect the function generator’s output to both the oscilloscope and the DAQ.
Set the sample rate in your Python script to 500 samples per second and the number of samples such that it records 1 second of data.
Record and plot a dataset with both the oscilloscope and the DAQ. Make sure that the time range on the oscilloscope is set such that it is on the same order as the data being recorded by the DAQ.
# Acquire and plot data
data = acquire_data(SAMPLE_RATE, NUM_SAMPLES, DAQ_CHANNEL)
time = np.arange(NUM_SAMPLES) / SAMPLE_RATE
plt.figure(figsize=(10, 6))
plt.plot(time, data)
plt.xlabel('Time (s)')
plt.ylabel('Voltage (V)')
plt.title(f'Acquired Signal ({SAMPLE_RATE} Hz sample rate)')
plt.grid(True, alpha=0.3)
plt.show()Compare the two plots. What are the major differences between the two?
Why might one or both of these plots be giving an incorrect result? Think about the wave you are measuring and the result you are getting. How do they relate?
This section will guide you to an understanding of Nyquist’s theorem and a more appropriate sample rate for digital data collection.
Why do you think the data from the DAQ produced a wave of lower frequency?
Adjust the sample rate in a way you think might provide a more accurate measurement of the wave. What do you think the measured waveform will look like this time?
Take a dataset, record and plot it. Did it match your predictions?
Now record another dataset with the function generator set to the same parameters but the sample rate set to 3000 samples per second and the number of samples set to record 1 second of data.
Plot this new dataset. What is the frequency of the new dataset?
What are the fundamental differences between the first, second, and third datasets?
The discrepancies between the sampled waveforms can be explained by Nyquist’s theorem. It states that to accurately measure a signal by discrete sampling methods (like the DAQ) the sampling rate must be at least twice that of the measured signal. If this were not the case, a measurement might not be taken at every interval of oscillation, a situation called “undersampling.” Sampling the signal at least twice as fast as the maximum frequency of interest ensures that at least two data points are recorded each period.
Definition:
The Nyquist Frequency is defined to be half the sample rate.
Predict the apparent frequency (in Hz) of the signal recorded by the DAQ. Observe what really happens using your waveform generator, DAQ, and Python script. Explain the result. Suppose the DAQ is set to 1 kS/s sample rate in all of the cases, while the waveform generator is set to:
In understanding what is going on, it may help to draw a few periods of the wave and then indicate where the DAQ will sample the waveform.
You want to measure the random fluctuations (noise) in a signal from 0-100 Hz.
Undersampling on the oscilloscope. Undersampling is an issue with any device that samples data at regular discrete time intervals. This question requires the use of a Tektronix TBS 2000 Series oscilloscope and the Keysight EDU33212A waveform generator.
Note: You may need to use the waveform generator’s “Sync/Trigger out” connector to trigger the oscilloscope externally for stable display at certain frequencies.
You’ve learned how sample rate affects what you can measure—too slow, and you get aliasing. Now you’ll investigate what limits how precisely you can measure: the noise floor.
In Week 1, you calibrated your photodetector’s gain and offset. This week, you’ll characterize your measurement system’s noise floor—and discover what actually limits your measurements.
Connection to Week 1: The “RMS noise” you measure here IS the standard deviation of repeated voltage measurements. When we say the DAQ has “5 mV RMS noise,” we mean that if you sample a constant input many times, the standard deviation will be about 5 mV. This becomes the minimum achievable uncertainty (\(\sigma_V\)) for any voltage measurement.
Connection to aliasing: The noise you’ll measure is broadband—it contains all frequencies. This is why your sample rate matters even for noise measurements: higher sample rates capture more of the noise spectrum.
Why this matters: In Week 4, you’ll measure beam profiles where the signal varies over a wide range. Understanding your actual noise floor is essential for making informed measurement decisions.
You’ll discover how DAQ configuration affects measurements—and what happens when configuration doesn’t match your physical setup.
This is a reasonable setup for a single-ended measurement: one signal wire connected to ground.
The USB-6009 can measure voltages in two ways:
Predict: You’ve grounded AI0+ and left AI0- floating. What will happen if you measure in each mode?
Measure:
import nidaqmx
import numpy as np
from nidaqmx.constants import AcquisitionType, TerminalConfiguration
def measure_noise(channel="Dev1/ai0", num_samples=1000, sample_rate=10000,
terminal_config=TerminalConfiguration.RSE,
voltage_range=10.0):
"""Measure noise with specified configuration."""
with nidaqmx.Task() as task:
task.ai_channels.add_ai_voltage_chan(
channel,
terminal_config=terminal_config,
min_val=-voltage_range,
max_val=voltage_range
)
task.timing.cfg_samp_clk_timing(
rate=sample_rate,
sample_mode=AcquisitionType.FINITE,
samps_per_chan=num_samples
)
data = np.array(task.read(number_of_samples_per_channel=num_samples))
return np.mean(data), np.std(data)
# Compare terminal configurations (same ±10V range)
dc_rse, noise_rse = measure_noise(terminal_config=TerminalConfiguration.RSE, voltage_range=10.0)
dc_diff, noise_diff = measure_noise(terminal_config=TerminalConfiguration.DIFF, voltage_range=10.0)
print(f"RSE ±10V: DC = {dc_rse*1000:7.2f} mV, RMS noise = {noise_rse*1000:.2f} mV")
print(f"DIFF ±10V: DC = {dc_diff*1000:7.2f} mV, RMS noise = {noise_diff*1000:.2f} mV")Record your results:
| Configuration | DC (mV) | RMS Noise (mV) |
|---|---|---|
| RSE ±10V | ||
| DIFF ±10V |
What happened?
The lesson: Your software configuration must match your physical wiring. A floating input in differential mode doesn’t give zero—it gives garbage.
Now let’s see how voltage range affects noise with a properly configured measurement.
Change your wiring: Connect both AI0+ and AI0- to GND. This creates a valid zero-volt input for differential mode.
Predict: The ±1V range can measure smaller voltages more precisely than ±10V. Will this affect noise?
Measure:
# Compare voltage ranges (same DIFF terminal config)
dc_10v, noise_10v = measure_noise(terminal_config=TerminalConfiguration.DIFF, voltage_range=10.0)
dc_1v, noise_1v = measure_noise(terminal_config=TerminalConfiguration.DIFF, voltage_range=1.0)
print(f"DIFF ±10V: DC = {dc_10v*1000:7.2f} mV, RMS noise = {noise_10v*1000:.2f} mV")
print(f"DIFF ±1V: DC = {dc_1v*1000:7.2f} mV, RMS noise = {noise_1v*1000:.2f} mV")Record your results:
| Configuration | DC (mV) | RMS Noise (mV) |
|---|---|---|
| DIFF ±10V | ||
| DIFF ±1V |
Configuration must match wiring: When you configured differential mode but only grounded AI0+, the floating AI0- produced garbage readings. This is a common mistake—your software settings must match your physical circuit.
Range affects noise: Narrower voltage ranges have lower noise floors. The USB-6009 datasheet confirms this:
Tradeoffs exist: Lower noise sounds better, but the ±1V range saturates (clips) any signal above 1V. Your Week 4 beam profile will have signals from near-zero to several volts—you need the ±10V range despite its higher noise.
For the rest of this lab and Week 4, use RSE mode with ±10V range:
Use this function for all remaining measurements:
import nidaqmx
import numpy as np
from nidaqmx.constants import AcquisitionType, TerminalConfiguration
def measure_noise(channel="Dev1/ai0", num_samples=1000, sample_rate=10000):
"""Measure DC level and RMS noise. Uses RSE ±10V configuration."""
with nidaqmx.Task() as task:
task.ai_channels.add_ai_voltage_chan(
channel,
terminal_config=TerminalConfiguration.RSE,
min_val=-10.0,
max_val=10.0
)
task.timing.cfg_samp_clk_timing(
rate=sample_rate,
sample_mode=AcquisitionType.FINITE,
samps_per_chan=num_samples
)
data = np.array(task.read(number_of_samples_per_channel=num_samples))
return np.mean(data), np.std(data)Now the key question: when you connect your photodetector, will the noise change?
Before measuring, make a prediction:
Given information:
Your prediction: When you connect the capped photodetector (dark, no light), the measured noise will:
Your reasoning (1-2 sentences): _______________________________________________
# Measure at 0 dB gain (set on photodetector)
dc_0db, noise_0db = measure_noise()
print(f"0 dB gain - Noise: {noise_0db*1000:.2f} mV")
# Change photodetector to 70 dB gain, then measure again
dc_70db, noise_70db = measure_noise()
print(f"70 dB gain - Noise: {noise_70db*1000:.2f} mV")Results:
| Gain | Measured Noise | Photodetector Spec | DAQ Noise |
|---|---|---|---|
| 0 dB | _______ mV | 0.3 mV | ~5 mV |
| 70 dB | _______ mV | 1.1 mV | ~5 mV |
Was your prediction correct? Did noise change significantly when you connected the photodetector?
Reflect on your reasoning: If your prediction was correct, what reasoning led you to the right answer? If incorrect, what assumption failed?
What does this tell you? If noise stayed at ~5 mV regardless of gain, which component dominates your system noise?
Why doesn’t photodetector noise show up? The photodetector’s 0.3-1.1 mV noise is real, but it’s smaller than the DAQ’s 5 mV noise floor. You cannot measure something smaller than your instrument’s noise floor.
Here’s the key insight: if noise is fixed at ~5 mV regardless of gain, then increasing gain improves your signal-to-noise ratio.
| Gain | Signal | Noise | SNR |
|---|---|---|---|
| Low (0 dB) | Small | ~5 mV | Low |
| High (70 dB) | Large | ~5 mV | High |
The strategy: Use the highest gain that doesn’t saturate your signal.
Choose your photodetector gain setting for Week 4 beam profile measurements.
Constraints:
Your task: Select a gain that maximizes SNR without saturating on your brightest measurement.
Selected gain: _______ dB
Justification (include your reasoning about saturation margin and expected SNR): _______________________________________________
Compare with a neighboring group. Different choices can be valid if the reasoning is sound.
The noise floor you measured (~5 mV) will directly affect your Week 4 beam profile uncertainty:
Keep your noise measurement accessible—you’ll need it for Week 3’s error propagation exercises.
The oscilloscope has a much lower noise floor than the DAQ. If time permits, compare noise measurements.
Setup: Connect the photodetector output to oscilloscope CH1.
import pyvisa
import numpy as np
rm = pyvisa.ResourceManager()
scope = rm.open_resource("USB0::0x0699::...") # Your scope address
scope.timeout = 10000
# Capture waveform and compute noise (std dev)
scope.write("DATA:SOURCE CH1")
raw = scope.query_binary_values("CURVE?", datatype='b', container=np.array)
v_scale = float(scope.query("WFMPRE:YMULT?"))
v_off = float(scope.query("WFMPRE:YOFF?"))
voltage = (raw - v_off) * v_scale
print(f"RMS noise: {np.std(voltage)*1000:.2f} mV")
scope.close()See VISA Instrument Control for complete oscilloscope examples.
Question: With the oscilloscope’s lower noise floor, can you now see the photodetector noise increase with gain?
Save your acquired data to a CSV file for later analysis:
import numpy as np
from datetime import datetime
# After acquiring data...
# Create a timestamp for the filename
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"data_{timestamp}.csv"
# Create time array
time = np.arange(len(data)) / sample_rate
# Save to CSV
np.savetxt(
filename,
np.column_stack([time, data]),
delimiter=',',
header='Time (s), Voltage (V)',
comments=''
)
print(f"Data saved to {filename}")Acquire a waveform and save it to a CSV file.
Load the data back and plot it:
# Load data
loaded_data = np.loadtxt(filename, delimiter=',', skiprows=1)
time_loaded = loaded_data[:, 0]
voltage_loaded = loaded_data[:, 1]
# Plot
plt.figure(figsize=(10, 6))
plt.plot(time_loaded * 1000, voltage_loaded)
plt.xlabel('Time (ms)')
plt.ylabel('Voltage (V)')
plt.title('Loaded Data')
plt.show()Verify the loaded data matches your original acquisition.
The USB-6009 can also generate analog voltages (though at a limited rate of 150 S/s). Note that the USB-6009’s analog outputs have a range of 0-5V only, so we must specify this range explicitly:
import nidaqmx
# Output a DC voltage
with nidaqmx.Task() as task:
task.ao_channels.add_ao_voltage_chan("Dev1/ao0", min_val=0.0, max_val=5.0)
task.write(2.5, auto_start=True) # Output 2.5 V
print("Outputting 2.5 V on AO0")
input("Press Enter to stop...")AO0.AO0 to AI0 (loopback
test).AO0AI0Always include error handling in your data acquisition code:
import nidaqmx
from nidaqmx.errors import DaqError
try:
with nidaqmx.Task() as task:
task.ai_channels.add_ai_voltage_chan("Dev1/ai0")
voltage = task.read()
print(f"Voltage: {voltage:.4f} V")
except DaqError as e:
print(f"DAQ Error: {e}")
print("Check that:")
print(" - The DAQ device is connected")
print(" - The device name is correct (try 'Dev1', 'Dev2', etc.)")
print(" - NI-DAQmx drivers are installed")In this lab, you learned to:
Key takeaway: You discovered that instrument behavior depends on configuration—default settings are not always what you expect. You also found that the DAQ noise floor (~5 mV in RSE mode) limits your measurements, not the photodetector noise. These authentic discoveries—that understanding your instruments matters—are central to experimental physics.
See the Python
Resources page and the example scripts in the
python/ folder for more detailed examples.
Your lab notebook should include the following for this week:
Photodetector Noise Test
| Gain | Measured Noise | Photodetector Spec | DAQ Noise | Dominant Source |
|---|---|---|---|---|
| 0 dB | _______ mV | 0.3 mV | ~5 mV | _______ |
| 70 dB | _______ mV | 1.1 mV | ~5 mV | _______ |
measure_noise() function with explicit
RSE configurationYou measured DAQ noise with two different configurations. Why does the choice of terminal mode and voltage range affect the noise level? What lesson does this teach about working with instruments?
Given that the DAQ noise floor is fixed at ~5 mV (in RSE mode), explain in 2-3 sentences why using higher gain improves SNR. What limits how high you can set the gain?
You discovered that differential mode with ±1V range has ~0.5 mV noise—10× lower than RSE mode. Why can’t you use this lower-noise configuration for your Week 4 measurements?