Visualizing UV/Vis spectra of gold nanorod quantum simulations
Published
July 15, 2025
Nano rods
Nano rods are used widely in biotech. I wrote about it before: quality control of nano rods is important and difficult.
Electron microscopes are expensive and hard to operate. Since nano particles leave a signature in the UV/Vis spectrum, why not use this?
Lookin for signatures in the spectrum
What do we want to know about nano particles? For biotech applications we want to know:
Material of the particles
Size of the particles
Shape of the particles
Can we extract this from the UV/Vis spectrum?
Machine learning for Gold Nanorod
The article “Automated Gold Nanorod Spectral Morphology Analysis Pipeline” by Gleason et al. (2024) uses quantum simulations to generate optical profiles of gold nanorods (Gleason et al. 2024).
I downloaded some of these spectra and visualized them on this page.
Gold nano rods
10637 spectra from quantum simulations
Diameter from 5 to 100 nm
Length from 10 to 500 nm
Aspect ratio (AR) between 1.5 and 10
Wavelength rom 500 to 1600 nm
Datasets
The first dataset that I tried ar_profiles gave unexpected results: many spectra are missing.
I included one of the other datasets main_profiles for comparison, and this one has no missing spectra.
Try it out below.
tf =import("https://cdn.skypack.dev/@tensorflow/tfjs")// Load metadata for both profilesprofilesMetadata =FileAttachment("profiles_metadata.json").json()// Profile selectionviewof selectedProfile = Inputs.radio(["ar_profiles","main_profiles"], {label:"Profile type:",value:"ar_profiles"})// Load profile filesarProfilesData =FileAttachment("ar_profiles.bin").arrayBuffer()mainProfilesData =FileAttachment("main_profiles.bin").arrayBuffer()// Create tensors for both profilesarProfilesTensor = {const buffer =await arProfilesData;const meta =await profilesMetadata;const float32Array =newFloat32Array(buffer);return tf.tensor(float32Array, meta.ar_profiles.shape);}mainProfilesTensor = {const buffer =await mainProfilesData;const meta =await profilesMetadata;const float32Array =newFloat32Array(buffer);return tf.tensor(float32Array, meta.main_profiles.shape);}// Get current profile data based on selectioncurrentProfileData = selectedProfile ==="ar_profiles"? arProfilesTensor : mainProfilesTensor
Data browser
// Set backend (WebGL is default and fastest)// tf.setBackend('webgl')// Display current profile infohtml`<div style="margin: 10px 0; padding: 10px; background: #e3f2fd; border-radius: 5px;"> <h4>Current Profile: ${selectedProfile}</h4> <p><strong>Shape:</strong> ${currentProfileData.shape.join(' × ')}</p> <p><strong>Description:</strong> ${(await profilesMetadata)[selectedProfile].description}</p></div>`
viewof selectedDiameter = Inputs.range([currentRanges.diameter.min, currentRanges.diameter.max], {value:Math.min(20, currentRanges.diameter.max),step:1,label:"Diameter (nm):",description:"Choose nanorod diameter"})viewof selectedLength = Inputs.range([currentRanges.length.min, currentRanges.length.max], {value:Math.min(100, currentRanges.length.max),step:5,label:"Length (nm):",description:"Choose nanorod length"})// Display current nano particle currentParticleInfo = {const diamIdx =awaitdiameterToIndex(selectedDiameter);const lengthIdx =awaitlengthToIndex(selectedLength);returnhtml`<div style="margin: 20px 0; padding: 15px; background: #f8f9fa; border-radius: 5px;"> <h4>Current Nano Particle:</h4> <p><strong>Diameter:</strong> ${selectedDiameter} nm → Index: ${diamIdx}</p> <p><strong>Length:</strong> ${selectedLength} nm → Index: ${lengthIdx}</p> <p><strong>Aspect Ratio:</strong> ${(selectedLength / selectedDiameter).toFixed(2)}</p> </div>`;}// Extract spectrum data for current selectioncurrentSpectrum = {const lengthIdx =lengthToIndex(selectedLength);const diameterIdx =diameterToIndex(selectedDiameter);const r = currentRanges;// Extract spectrum from current profile tensor at [lengthIdx, diameterIdx, :]const spectrumSlice = currentProfileData.slice([lengthIdx, diameterIdx,0], [1,1, r.wavelength.count]);const spectrumData =await spectrumSlice.data();// Create array of {wavelength, intensity} objects for plottingconst result = [];for (let i =0; i < r.wavelength.count; i++) { result.push({wavelength:indexToWavelength(i),intensity: spectrumData[i] }); }return result;}// Plot the current spectrumPlot.plot({title:`${selectedProfile} - ${selectedDiameter}nm × ${selectedLength}nm (AR: ${(selectedLength/selectedDiameter).toFixed(2)})`,width:800,height:400,marginLeft:80,marginBottom:50,x: {label:"Wavelength (nm)",domain: [currentRanges.wavelength.min, currentRanges.wavelength.max],ticks:10,tickFormat: d =>`${d.toFixed(0)}` },y: {label:"Absorption Intensity",grid:true,tickFormat: d => d.toExponential(2) },marks: [ Plot.line(currentSpectrum, {x:"wavelength",y:"intensity",stroke:"#2563eb",strokeWidth:2 }), Plot.ruleY([0]) ]})
zeroSpectraMap = {// Check if each spectrum (last dimension) is all zeros// tf.all() reduces along the specified axisconst tolerance =1e-15;// Create a boolean tensor checking if absolute values are less than toleranceconst isNearZero = currentProfileData.abs().less(tolerance);// Reduce along the wavelength axis (axis=2) to check if ALL values are near zeroconst zeroSpectra = isNearZero.all(2);// Convert to JavaScript array for plottingconst zeroData =await zeroSpectra.data();const shape =await zeroSpectra.shape;// Create data for heatmap - need to flatten and add coordinatesconst heatmapData = [];for (let i =0; i < shape[0]; i++) {for (let j =0; j < shape[1]; j++) { heatmapData.push({length:indexToLength(i),diameter:indexToDiameter(j),lengthIdx: i,diameterIdx: j,isZero: zeroData[i * shape[1] + j] ?1:0,isValid: zeroData[i * shape[1] + j] ?0:1 }); } }// Count statisticsconst totalSpectra = shape[0] * shape[1];const zeroCount = zeroData.filter(d => d).length;const validCount = totalSpectra - zeroCount;return {data: heatmapData,stats: {total: totalSpectra,zero: zeroCount,valid: validCount,percentValid: (validCount / totalSpectra *100).toFixed(1) } };}// Static validity map with pointer interactionvalidityMapPlot = Plot.plot({title:"Valid Optical Profiles Map (Click to select)",subtitle:`${zeroSpectraMap.stats.valid} valid profiles out of ${zeroSpectraMap.stats.total} (${zeroSpectraMap.stats.percentValid}%)`,width:700,height:500,marginLeft:60,marginBottom:60,marginRight:40,marginTop:80,style:"cursor: crosshair;",color: {type:"categorical",domain: [0,1],range: ["#dc2626","#16a34a"],legend:true,tickFormat: d => d ===0?"Zero spectrum":"Valid spectrum" },x: {label:"Diameter (nm)",domain: [currentRanges.diameter.min, currentRanges.diameter.max],grid:true },y: {label:"Length (nm)",domain: [currentRanges.length.min, currentRanges.length.max],grid:true },marks: [ Plot.rect(zeroSpectraMap.data, {x1: d => d.diameter- (currentRanges.diameter.max- currentRanges.diameter.min) / (currentRanges.diameter.count-1) /2,x2: d => d.diameter+ (currentRanges.diameter.max- currentRanges.diameter.min) / (currentRanges.diameter.count-1) /2,y1: d => d.length- (currentRanges.length.max- currentRanges.length.min) / (currentRanges.length.count-1) /2,y2: d => d.length+ (currentRanges.length.max- currentRanges.length.min) / (currentRanges.length.count-1) /2,fill:"isValid",stroke:"isValid",strokeWidth:0,tip: {format: {x1:false,x2:false,y1:false,y2:false,fill:false },channels: {"Diameter":"diameter","Length":"length","Aspect Ratio": d => (d.length/ d.diameter).toFixed(2) } },// Use pointer events to capture click with datapointerEvents:"all",cursor:"pointer" }),// Add marker for current selection Plot.dot([{x: selectedDiameter,y: selectedLength,isValid: currentSpectrum.some(d => d.intensity!==0) ?1:0 }], {x:"x",y:"y",r:8,stroke:"white",strokeWidth:3,fill: d => d.isValid?"#3b82f6":"#f59e0b",tip: {format: {x: d =>`Selected: ${d}nm`}} }) ]})// Add click handler to capture data from clicked element{const plot = validityMapPlot; plot.addEventListener("click", (event) => {console.log("Plot clicked");const target =event.target;if (target && target.__data__!==undefined) {const dataIndex = target.__data__;console.log("Found data index on target:", dataIndex);// Use the data index to get the actual data from zeroSpectraMapconst heatmapData = zeroSpectraMap.data;if (dataIndex >=0&& dataIndex < heatmapData.length) {const data = heatmapData[dataIndex];console.log("Retrieved data object:", data);if (data.diameter&& data.length) {const diameter =Math.round(data.diameter);const length =Math.round(data.length/5) *5;console.log(`Using data: diameter=${diameter}, length=${length}`);// Update slidersconst diameterSlider =document.querySelector('input[type="range"][step="1"]');const lengthSlider =document.querySelector('input[type="range"][step="5"]');if (diameterSlider && lengthSlider) { diameterSlider.value= diameter; lengthSlider.value= length; diameterSlider.dispatchEvent(newEvent('input', { bubbles:true })); lengthSlider.dispatchEvent(newEvent('input', { bubbles:true }));console.log("Sliders updated successfully!"); } else {console.log("Sliders not found"); } } else {console.log("Data object missing diameter or length"); } } else {console.log("Data index out of range:", dataIndex,"max:", heatmapData.length); } } else {console.log("No data found on target"); } });return plot;}
// Conclusion based on selected profilehtml`<div style="margin: 30px 0; padding: 20px; background: #f0f8ff; border-left: 4px solid #0066cc; border-radius: 5px;"> <h3>Conclusion</h3> <p style="font-size: 16px; line-height: 1.6;">${selectedProfile ==="main_profiles"?"<strong>Main Profiles:</strong> No missing profiles, clear boundary between zero and non-zero profiles.":"<strong>AR Profiles:</strong> Missing profiles. No clear boundary between zero and non-zero profiles."} </p></div>`
References
Gleason, Samuel P., Jakob C. Dahl, Mahmoud Elzouka, Xingzhi Wang, Dana O. Byrne, Hannah Cho, Mumtaz Gababa, et al. 2024. “Automated Gold Nanorod Spectral Morphology Analysis Pipeline.”ACS Nano 18 (51): 34646–55. https://doi.org/10.1021/acsnano.4c09753.
Source Code
---title: "Exploring Optical Profiles Of Gold Nanorods"description: "Visualizing UV/Vis spectra of gold nanorod quantum simulations"date: 2025-07-15format: html: toc: trueexecute: echo: false cache: falsejupyter: uv-2025-07-15engine: jupyterbibliography: bibliography.bib---# Nano rodsNano rods are used widely in biotech. I wrote about it before: quality control of nano rods is important and difficult. Electron microscopes are expensive and hard to operate. Since nano particles leave a signature in the UV/Vis spectrum, why not use this?## Looking for signatures in the spectrumWhat do we want to know about nano particles? For biotech applications we want to know:- Material of the particles- Size of the particles- Shape of the particlesCan we extract this from the UV/Vis spectrum?## Machine learning for Gold Nanorod The article "Automated Gold Nanorod Spectral Morphology Analysis Pipeline" by Gleason et al. (2024) uses quantum simulations to generate optical profiles of gold nanorods [@gleasonAutomatedGoldNanorod2024].I downloaded some of these spectra and visualized them on this page. - Gold nano rods- 10637 spectra from quantum simulations- Diameter from 5 to 100 nm- Length from 10 to 500 nm- Aspect ratio (AR) between 1.5 and 10- Wavelength rom 500 to 1600 nm## DatasetsThe first dataset that I tried `ar_profiles` gave unexpected results: many spectra are missing. I included one of the other datasets `main_profiles` for comparison, and this one has no missing spectra. Try it out below. ```{ojs}//| echo: falsetf =import("https://cdn.skypack.dev/@tensorflow/tfjs")// Load metadata for both profilesprofilesMetadata =FileAttachment("profiles_metadata.json").json()// Profile selectionviewof selectedProfile = Inputs.radio(["ar_profiles","main_profiles"], {label:"Profile type:",value:"ar_profiles"})// Load profile filesarProfilesData =FileAttachment("ar_profiles.bin").arrayBuffer()mainProfilesData =FileAttachment("main_profiles.bin").arrayBuffer()// Create tensors for both profilesarProfilesTensor = {const buffer =await arProfilesData;const meta =await profilesMetadata;const float32Array =newFloat32Array(buffer);return tf.tensor(float32Array, meta.ar_profiles.shape);}mainProfilesTensor = {const buffer =await mainProfilesData;const meta =await profilesMetadata;const float32Array =newFloat32Array(buffer);return tf.tensor(float32Array, meta.main_profiles.shape);}// Get current profile data based on selectioncurrentProfileData = selectedProfile ==="ar_profiles"? arProfilesTensor : mainProfilesTensor// Check available backends// tf.engine().backendNames()```# Data browser```{ojs}// Set backend (WebGL is default and fastest)// tf.setBackend('webgl')// Display current profile infohtml`<div style="margin: 10px 0; padding: 10px; background: #e3f2fd; border-radius: 5px;"> <h4>Current Profile: ${selectedProfile}</h4> <p><strong>Shape:</strong> ${currentProfileData.shape.join(' × ')}</p> <p><strong>Description:</strong> ${(await profilesMetadata)[selectedProfile].description}</p></div>```````{ojs}//| echo: false// Load metadata synchronouslymetadata =await profilesMetadata``````{ojs}//| echo: false// Current profile ranges (reactive to selectedProfile)currentRanges = (() => {const profileKey = selectedProfile;return {length: metadata.ranges.length,diameter: metadata.ranges.diameter[profileKey],wavelength: metadata.ranges.wavelength };})()// Mapping functions using current profile rangeslengthToIndex = (length_nm) => {const r = currentRanges;returnMath.round((length_nm - r.length.min) / (r.length.max- r.length.min) * (r.length.count-1));}indexToLength = (index) => {const r = currentRanges;return r.length.min+ (index / (r.length.count-1)) * (r.length.max- r.length.min);}diameterToIndex = (diameter_nm) => {const r = currentRanges;returnMath.round((diameter_nm - r.diameter.min) / (r.diameter.max- r.diameter.min) * (r.diameter.count-1));}indexToDiameter = (index) => {const r = currentRanges;return r.diameter.min+ (index / (r.diameter.count-1)) * (r.diameter.max- r.diameter.min);}wavelengthToIndex = (wavelength_nm) => {const r = currentRanges;returnMath.round((wavelength_nm - r.wavelength.min) / (r.wavelength.max- r.wavelength.min) * (r.wavelength.count-1));}indexToWavelength = (index) => {const r = currentRanges;return r.wavelength.min+ (index / (r.wavelength.count-1)) * (r.wavelength.max- r.wavelength.min);}``````{ojs}//| echo: false// Interactive sliders for selecting nanorod dimensionsviewof selectedDiameter = Inputs.range([currentRanges.diameter.min, currentRanges.diameter.max], {value:Math.min(20, currentRanges.diameter.max),step:1,label:"Diameter (nm):",description:"Choose nanorod diameter"})viewof selectedLength = Inputs.range([currentRanges.length.min, currentRanges.length.max], {value:Math.min(100, currentRanges.length.max),step:5,label:"Length (nm):",description:"Choose nanorod length"})// Display current nano particle currentParticleInfo = {const diamIdx =awaitdiameterToIndex(selectedDiameter);const lengthIdx =awaitlengthToIndex(selectedLength);returnhtml`<div style="margin: 20px 0; padding: 15px; background: #f8f9fa; border-radius: 5px;"> <h4>Current Nano Particle:</h4> <p><strong>Diameter:</strong> ${selectedDiameter} nm → Index: ${diamIdx}</p> <p><strong>Length:</strong> ${selectedLength} nm → Index: ${lengthIdx}</p> <p><strong>Aspect Ratio:</strong> ${(selectedLength / selectedDiameter).toFixed(2)}</p> </div>`;}// Extract spectrum data for current selectioncurrentSpectrum = {const lengthIdx =lengthToIndex(selectedLength);const diameterIdx =diameterToIndex(selectedDiameter);const r = currentRanges;// Extract spectrum from current profile tensor at [lengthIdx, diameterIdx, :]const spectrumSlice = currentProfileData.slice([lengthIdx, diameterIdx,0], [1,1, r.wavelength.count]);const spectrumData =await spectrumSlice.data();// Create array of {wavelength, intensity} objects for plottingconst result = [];for (let i =0; i < r.wavelength.count; i++) { result.push({wavelength:indexToWavelength(i),intensity: spectrumData[i] }); }return result;}// Plot the current spectrumPlot.plot({title:`${selectedProfile} - ${selectedDiameter}nm × ${selectedLength}nm (AR: ${(selectedLength/selectedDiameter).toFixed(2)})`,width:800,height:400,marginLeft:80,marginBottom:50,x: {label:"Wavelength (nm)",domain: [currentRanges.wavelength.min, currentRanges.wavelength.max],ticks:10,tickFormat: d =>`${d.toFixed(0)}` },y: {label:"Absorption Intensity",grid:true,tickFormat: d => d.toExponential(2) },marks: [ Plot.line(currentSpectrum, {x:"wavelength",y:"intensity",stroke:"#2563eb",strokeWidth:2 }), Plot.ruleY([0]) ]})``````{ojs}//| echo: false// Compute 2D map of zero spectrazeroSpectraMap = {// Check if each spectrum (last dimension) is all zeros// tf.all() reduces along the specified axisconst tolerance =1e-15;// Create a boolean tensor checking if absolute values are less than toleranceconst isNearZero = currentProfileData.abs().less(tolerance);// Reduce along the wavelength axis (axis=2) to check if ALL values are near zeroconst zeroSpectra = isNearZero.all(2);// Convert to JavaScript array for plottingconst zeroData =await zeroSpectra.data();const shape =await zeroSpectra.shape;// Create data for heatmap - need to flatten and add coordinatesconst heatmapData = [];for (let i =0; i < shape[0]; i++) {for (let j =0; j < shape[1]; j++) { heatmapData.push({length:indexToLength(i),diameter:indexToDiameter(j),lengthIdx: i,diameterIdx: j,isZero: zeroData[i * shape[1] + j] ?1:0,isValid: zeroData[i * shape[1] + j] ?0:1 }); } }// Count statisticsconst totalSpectra = shape[0] * shape[1];const zeroCount = zeroData.filter(d => d).length;const validCount = totalSpectra - zeroCount;return {data: heatmapData,stats: {total: totalSpectra,zero: zeroCount,valid: validCount,percentValid: (validCount / totalSpectra *100).toFixed(1) } };}// Static validity map with pointer interactionvalidityMapPlot = Plot.plot({title:"Valid Optical Profiles Map (Click to select)",subtitle:`${zeroSpectraMap.stats.valid} valid profiles out of ${zeroSpectraMap.stats.total} (${zeroSpectraMap.stats.percentValid}%)`,width:700,height:500,marginLeft:60,marginBottom:60,marginRight:40,marginTop:80,style:"cursor: crosshair;",color: {type:"categorical",domain: [0,1],range: ["#dc2626","#16a34a"],legend:true,tickFormat: d => d ===0?"Zero spectrum":"Valid spectrum" },x: {label:"Diameter (nm)",domain: [currentRanges.diameter.min, currentRanges.diameter.max],grid:true },y: {label:"Length (nm)",domain: [currentRanges.length.min, currentRanges.length.max],grid:true },marks: [ Plot.rect(zeroSpectraMap.data, {x1: d => d.diameter- (currentRanges.diameter.max- currentRanges.diameter.min) / (currentRanges.diameter.count-1) /2,x2: d => d.diameter+ (currentRanges.diameter.max- currentRanges.diameter.min) / (currentRanges.diameter.count-1) /2,y1: d => d.length- (currentRanges.length.max- currentRanges.length.min) / (currentRanges.length.count-1) /2,y2: d => d.length+ (currentRanges.length.max- currentRanges.length.min) / (currentRanges.length.count-1) /2,fill:"isValid",stroke:"isValid",strokeWidth:0,tip: {format: {x1:false,x2:false,y1:false,y2:false,fill:false },channels: {"Diameter":"diameter","Length":"length","Aspect Ratio": d => (d.length/ d.diameter).toFixed(2) } },// Use pointer events to capture click with datapointerEvents:"all",cursor:"pointer" }),// Add marker for current selection Plot.dot([{x: selectedDiameter,y: selectedLength,isValid: currentSpectrum.some(d => d.intensity!==0) ?1:0 }], {x:"x",y:"y",r:8,stroke:"white",strokeWidth:3,fill: d => d.isValid?"#3b82f6":"#f59e0b",tip: {format: {x: d =>`Selected: ${d}nm`}} }) ]})// Add click handler to capture data from clicked element{const plot = validityMapPlot; plot.addEventListener("click", (event) => {console.log("Plot clicked");const target =event.target;if (target && target.__data__!==undefined) {const dataIndex = target.__data__;console.log("Found data index on target:", dataIndex);// Use the data index to get the actual data from zeroSpectraMapconst heatmapData = zeroSpectraMap.data;if (dataIndex >=0&& dataIndex < heatmapData.length) {const data = heatmapData[dataIndex];console.log("Retrieved data object:", data);if (data.diameter&& data.length) {const diameter =Math.round(data.diameter);const length =Math.round(data.length/5) *5;console.log(`Using data: diameter=${diameter}, length=${length}`);// Update slidersconst diameterSlider =document.querySelector('input[type="range"][step="1"]');const lengthSlider =document.querySelector('input[type="range"][step="5"]');if (diameterSlider && lengthSlider) { diameterSlider.value= diameter; lengthSlider.value= length; diameterSlider.dispatchEvent(newEvent('input', { bubbles:true })); lengthSlider.dispatchEvent(newEvent('input', { bubbles:true }));console.log("Sliders updated successfully!"); } else {console.log("Sliders not found"); } } else {console.log("Data object missing diameter or length"); } } else {console.log("Data index out of range:", dataIndex,"max:", heatmapData.length); } } else {console.log("No data found on target"); } });return plot;}``````{ojs}//| echo: false// Conclusion based on selected profilehtml`<div style="margin: 30px 0; padding: 20px; background: #f0f8ff; border-left: 4px solid #0066cc; border-radius: 5px;"> <h3>Conclusion</h3> <p style="font-size: 16px; line-height: 1.6;">${selectedProfile ==="main_profiles"?"<strong>Main Profiles:</strong> No missing profiles, clear boundary between zero and non-zero profiles.":"<strong>AR Profiles:</strong> Missing profiles. No clear boundary between zero and non-zero profiles."} </p></div>````