/** * Pure utility functions extracted from XRDContext for testability. * These functions contain no React state or side effects. */ /** * Convert wavelength using Bragg's law: lambda = 2d*sin(theta) * For same d-spacing: sin(theta2) = (lambda2/lambda1) * sin(theta1) * * @param {number} theta_deg - 2theta angle in degrees * @param {number} sourceWavelength - Source wavelength in Angstroms * @param {number} targetWavelength - Target wavelength in Angstroms * @returns {number|null} Converted 2theta angle, or null if physically impossible */ export function convertWavelength(theta_deg, sourceWavelength, targetWavelength) { if (Math.abs(sourceWavelength - targetWavelength) < 0.0001) { return theta_deg } const theta_rad = (theta_deg * Math.PI) / 180 const sin_theta2 = (targetWavelength / sourceWavelength) * Math.sin(theta_rad) if (Math.abs(sin_theta2) > 1) { return null } const theta2_rad = Math.asin(sin_theta2) return (theta2_rad * 180) / Math.PI } /** * Interpolate data to fixed size for model input. * * @param {number[]} x - Input x values (2theta) * @param {number[]} y - Input y values (intensity) * @param {number} targetSize - Desired output length * @param {number} [xMin] - Minimum x value for output grid * @param {number} [xMax] - Maximum x value for output grid * @param {string} [strategy='linear'] - Interpolation strategy: 'linear' or 'cubic' * @returns {{x: number[], y: number[]}} Interpolated data */ export function interpolateData(x, y, targetSize, xMin, xMax, strategy = 'linear') { if (x.length === targetSize && xMin === undefined) { return { x, y } } const minX = xMin !== undefined ? xMin : Math.min(...x) const maxX = xMax !== undefined ? xMax : Math.max(...x) const step = (maxX - minX) / (targetSize - 1) const newX = Array.from({ length: targetSize }, (_, i) => minX + i * step) const newY = new Array(targetSize) const dataMinX = Math.min(...x) const dataMaxX = Math.max(...x) if (strategy === 'linear') { for (let i = 0; i < targetSize; i++) { const targetX = newX[i] if (targetX < dataMinX || targetX > dataMaxX) { newY[i] = 0 continue } let idx = x.findIndex(val => val >= targetX) if (idx === -1) idx = x.length - 1 if (idx === 0) idx = 1 const x0 = x[idx - 1] const x1 = x[idx] const y0 = y[idx - 1] const y1 = y[idx] newY[i] = y0 + ((targetX - x0) * (y1 - y0)) / (x1 - x0) } } else if (strategy === 'cubic') { for (let i = 0; i < targetSize; i++) { const targetX = newX[i] if (targetX < dataMinX || targetX > dataMaxX) { newY[i] = 0 continue } let idx = x.findIndex(val => val >= targetX) if (idx === -1) idx = x.length - 1 if (idx === 0) idx = 1 const i0 = Math.max(0, idx - 2) const i1 = Math.max(0, idx - 1) const i2 = Math.min(x.length - 1, idx) const i3 = Math.min(x.length - 1, idx + 1) if (i2 === i1) { newY[i] = y[i1] } else { const t = (targetX - x[i1]) / (x[i2] - x[i1]) const t2 = t * t const t3 = t2 * t const v0 = y[i0] const v1 = y[i1] const v2 = y[i2] const v3 = y[i3] newY[i] = 0.5 * ( 2 * v1 + (-v0 + v2) * t + (2 * v0 - 5 * v1 + 4 * v2 - v3) * t2 + (-v0 + 3 * v1 - 3 * v2 + v3) * t3 ) } } } return { x: newX, y: newY } } /** * Parse DIF or XY format (space-separated 2theta intensity). * * @param {string} text - Raw file content * @returns {{x: number[], y: number[]}} Parsed data points */ export function parseDIF(text) { const lines = text.split('\n') const x = [] const y = [] for (const line of lines) { const trimmed = line.trim() if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('_') || trimmed.startsWith('CELL') || trimmed.startsWith('SPACE') || /^[a-zA-Z]/.test(trimmed)) { continue } const parts = trimmed.split(/\s+/) if (parts.length >= 2) { const xVal = parseFloat(parts[0]) const yVal = parseFloat(parts[1]) if (!isNaN(xVal) && !isNaN(yVal)) { x.push(xVal) y.push(yVal) } } } return { x, y } } /** * Extract metadata from CIF/DIF file text. * * @param {string} text - Raw file content * @returns {{wavelength: number|null, cellParams: string|null, spaceGroup: string|null, crystalSystem: string|null}} */ export function extractMetadata(text) { const metadata = { wavelength: null, cellParams: null, spaceGroup: null, crystalSystem: null } const lines = text.split('\n') const wavelengthPatterns = [ /wavelength[:\s=]+([0-9.]+)/i, /lambda[:\s=]+([0-9.]+)/i, /wave[:\s=]+([0-9.]+)/i, /_pd_wavelength[:\s]+([0-9.]+)/i, /_diffrn_radiation_wavelength[:\s]+([0-9.]+)/i, /radiation.*?([0-9.]+)\s*[AÅ]/i, ] for (const line of lines) { if (!metadata.wavelength) { for (const pattern of wavelengthPatterns) { const match = line.match(pattern) if (match && match[1]) { const wavelength = parseFloat(match[1]) if (wavelength > 0.1 && wavelength < 3.0) { metadata.wavelength = wavelength break } } } if (/Cu\s*K[αa]/i.test(line)) metadata.wavelength = 1.5406 else if (/Mo\s*K[αa]/i.test(line)) metadata.wavelength = 0.7107 else if (/Co\s*K[αa]/i.test(line)) metadata.wavelength = 1.7889 else if (/Cr\s*K[αa]/i.test(line)) metadata.wavelength = 2.2897 } if (/CELL PARAMETERS:/i.test(line)) { const match = line.match(/CELL PARAMETERS:\s*([\d.\s]+)/) if (match) { metadata.cellParams = match[1].trim() } } if (/SPACE GROUP:/i.test(line) || /_symmetry_Int_Tables_number/i.test(line)) { const match = line.match(/(?:SPACE GROUP:|_symmetry_Int_Tables_number)[:\s]+(\d+)/) if (match) { metadata.spaceGroup = match[1] } } if (/Crystal System:/i.test(line)) { const match = line.match(/Crystal System:\s*(\d+)/) if (match) { metadata.crystalSystem = match[1] } } } return metadata }