Skip to content

Commit d629ca1

Browse files
committed
Add reading of energy cal from PHD files, and fit for polynomial coefficeints.
Only tested on a single file so far.
1 parent f3ee04a commit d629ca1

File tree

5 files changed

+487
-5
lines changed

5 files changed

+487
-5
lines changed

SpecUtils/EnergyCalibration.h

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -679,22 +679,59 @@ namespace SpecUtils
679679

680680

681681
/** Writes the given energy calibration object as a CALp file.
682-
682+
683683
If a spectrum file has multiple detectors, you may write out each calibration, with the detectors name, to a single file
684-
684+
685685
@param output The stream to write the output to.
686686
@param The energy calibration to write.
687687
@param detector_name The name of the detector - an InterSpec/SpecUtils specific extension of the CALp file format. If blank,
688688
wont be written.
689689
@returns if CALp file was successfully written.
690-
690+
691691
Note, if the energy calibration is Full Range Fraction, then it will be converted to polynomial, and those coefficients written out, but also
692692
the original FRF coefficients will be written out after the other content - this is a InterSpec/SpecUtils specific extension of CALp file
693693
format.
694694
*/
695695
bool write_CALp_file( std::ostream &output,
696696
const std::shared_ptr<const EnergyCalibration> &cal,
697697
const std::string &detector_name );
698+
699+
700+
/** Fits polynomial energy calibration coefficients from channel-energy pairs using unweighted least squares.
701+
702+
Uses simple/unstable solution methods (Gaussian elimination with partial pivoting, not SVD-style fitting).
703+
All data points are treated with equal weight (unweighted fit).
704+
705+
This function solves the normal equations for polynomial fitting:
706+
Energy = c0 + c1*channel + c2*channel^2 + ... + c(n-1)*channel^(n-1)
707+
708+
where n is the number of coefficients (max_orders).
709+
710+
@param channel_energy_pairs Vector of (channel, energy) pairs to fit. The channel values may be fractional.
711+
@param max_orders Maximum number of polynomial coefficients to fit (e.g., 2 for linear with offset, 3 for quadratic).
712+
Must be >= 1 and <= number of data points.
713+
714+
@returns Vector of polynomial coefficients [c0, c1, c2, ...] ordered from constant term to highest order term.
715+
For max_orders=1, returns just the gain coefficient (offset assumed to be 0).
716+
For max_orders>=2, returns offset, gain, and higher order terms.
717+
718+
@throws std::runtime_error if:
719+
- channel_energy_pairs is empty
720+
- max_orders is 0 or greater than the number of data points
721+
- The system of equations is singular or nearly singular (determinant < 1e-10)
722+
- Any resulting coefficient is NaN or Inf
723+
724+
Example usage:
725+
\code
726+
std::vector<std::pair<float,float>> pairs = {{0.0f, 0.0f}, {100.0f, 300.0f}, {200.0f, 600.0f}};
727+
std::vector<float> coeffs = fit_poly_energy_cal_from_points( pairs, 2 ); // Linear fit
728+
// coeffs[0] = offset, coeffs[1] = gain
729+
\endcode
730+
731+
Note: For best numerical stability, consider normalizing channel numbers before fitting if they span a very large range.
732+
*/
733+
std::vector<float> fit_poly_energy_cal_from_points( const std::vector<std::pair<float,float>> &channel_energy_pairs,
734+
const size_t max_orders );
698735
}//namespace SpecUtils
699736

700737
#endif

src/EnergyCalibration.cpp

Lines changed: 159 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2166,7 +2166,165 @@ bool write_CALp_file( std::ostream &output, const shared_ptr<const EnergyCalibra
21662166
}//if( cal->type() == FullRangeFraction )
21672167

21682168
output << "#END" << eol_char << eol_char;
2169-
2169+
21702170
return output.good();
21712171
}//void write_CALp_file(...)
2172+
2173+
2174+
std::vector<float> fit_poly_energy_cal_from_points( const std::vector<std::pair<float,float>> &channel_energy_pairs,
2175+
const size_t max_orders )
2176+
{
2177+
using namespace std;
2178+
2179+
// Validate input
2180+
if( channel_energy_pairs.empty() )
2181+
throw runtime_error( "fit_poly_energy_cal_from_points: No data points provided" );
2182+
2183+
if( max_orders == 0 )
2184+
throw runtime_error( "fit_poly_energy_cal_from_points: max_orders must be at least 1" );
2185+
2186+
if( max_orders > channel_energy_pairs.size() )
2187+
throw runtime_error( "fit_poly_energy_cal_from_points: max_orders cannot exceed number of data points" );
2188+
2189+
// Validate that all input channel and energy values are finite
2190+
for( size_t i = 0; i < channel_energy_pairs.size(); ++i )
2191+
{
2192+
const float ch = channel_energy_pairs[i].first;
2193+
const float en = channel_energy_pairs[i].second;
2194+
2195+
if( isnan( ch ) || isinf( ch ) )
2196+
throw runtime_error( "fit_poly_energy_cal_from_points: Channel value is NaN or Inf at index " + std::to_string(i) );
2197+
2198+
if( isnan( en ) || isinf( en ) )
2199+
throw runtime_error( "fit_poly_energy_cal_from_points: Energy value is NaN or Inf at index " + std::to_string(i) );
2200+
}
2201+
2202+
const size_t n = channel_energy_pairs.size();
2203+
const int num_coefficients = static_cast<int>( max_orders );
2204+
2205+
vector<float> coeffs;
2206+
2207+
if( num_coefficients == 1 )
2208+
{
2209+
// Single coefficient: fit only gain (no offset)
2210+
// E = 0 + gain * channel
2211+
// Solve: gain = sum(channel_i * energy_i) / sum(channel_i^2)
2212+
2213+
double sum_ch_e = 0.0;
2214+
double sum_ch2 = 0.0;
2215+
2216+
for( const auto &pair : channel_energy_pairs )
2217+
{
2218+
const double ch = pair.first;
2219+
const double en = pair.second;
2220+
sum_ch_e += ch * en;
2221+
sum_ch2 += ch * ch;
2222+
}
2223+
2224+
if( sum_ch2 < 1e-10 )
2225+
throw runtime_error( "fit_poly_energy_cal_from_points: All channel values are zero or near-zero" );
2226+
2227+
const float gain = static_cast<float>( sum_ch_e / sum_ch2 );
2228+
2229+
if( isnan( gain ) || isinf( gain ) )
2230+
throw runtime_error( "fit_poly_energy_cal_from_points: Computed gain is NaN or Inf" );
2231+
2232+
// Return [offset=0, gain] to match polynomial format
2233+
coeffs.push_back( 0.0f ); // offset
2234+
coeffs.push_back( gain ); // gain
2235+
}
2236+
else
2237+
{
2238+
// Multiple coefficients: use least squares fitting
2239+
// Solve normal equations for polynomial: E = c0 + c1*ch + c2*ch^2 + ...
2240+
2241+
// Build normal equations matrix: (X^T * X) * coefs = X^T * Y
2242+
// For unweighted least squares
2243+
vector<vector<double>> matrix( num_coefficients, vector<double>( num_coefficients, 0.0 ) );
2244+
vector<double> rhs( num_coefficients, 0.0 );
2245+
2246+
for( size_t i = 0; i < n; ++i )
2247+
{
2248+
const double ch = channel_energy_pairs[i].first;
2249+
const double en = channel_energy_pairs[i].second;
2250+
2251+
// Build X^T * X and X^T * Y
2252+
for( int row = 0; row < num_coefficients; ++row )
2253+
{
2254+
const double ch_pow_row = pow( ch, row );
2255+
2256+
// Right hand side: X^T * Y
2257+
rhs[row] += ch_pow_row * en;
2258+
2259+
// Matrix: X^T * X
2260+
for( int col = 0; col < num_coefficients; ++col )
2261+
{
2262+
const double ch_pow_col = pow( ch, col );
2263+
matrix[row][col] += ch_pow_row * ch_pow_col;
2264+
}
2265+
}
2266+
}
2267+
2268+
// Solve using Gaussian elimination with partial pivoting
2269+
for( int k = 0; k < num_coefficients; ++k )
2270+
{
2271+
// Find pivot
2272+
int pivot_row = k;
2273+
double max_val = fabs( matrix[k][k] );
2274+
for( int i = k + 1; i < num_coefficients; ++i )
2275+
{
2276+
if( fabs( matrix[i][k] ) > max_val )
2277+
{
2278+
max_val = fabs( matrix[i][k] );
2279+
pivot_row = i;
2280+
}
2281+
}
2282+
2283+
if( max_val < 1e-10 )
2284+
throw runtime_error( "fit_poly_energy_cal_from_points: Singular matrix (determinant near zero)" );
2285+
2286+
// Swap rows if needed
2287+
if( pivot_row != k )
2288+
{
2289+
swap( matrix[k], matrix[pivot_row] );
2290+
swap( rhs[k], rhs[pivot_row] );
2291+
}
2292+
2293+
// Eliminate column
2294+
for( int i = k + 1; i < num_coefficients; ++i )
2295+
{
2296+
const double factor = matrix[i][k] / matrix[k][k];
2297+
for( int j = k; j < num_coefficients; ++j )
2298+
matrix[i][j] -= factor * matrix[k][j];
2299+
rhs[i] -= factor * rhs[k];
2300+
}
2301+
}
2302+
2303+
// Back substitution
2304+
coeffs.resize( num_coefficients );
2305+
for( int i = num_coefficients - 1; i >= 0; --i )
2306+
{
2307+
double sum = rhs[i];
2308+
for( int j = i + 1; j < num_coefficients; ++j )
2309+
sum -= matrix[i][j] * coeffs[j];
2310+
coeffs[i] = static_cast<float>( sum / matrix[i][i] );
2311+
}
2312+
2313+
// Validate coefficients
2314+
for( size_t i = 0; i < coeffs.size(); ++i )
2315+
{
2316+
if( isnan( coeffs[i] ) || isinf( coeffs[i] ) )
2317+
throw runtime_error( "fit_poly_energy_cal_from_points: Computed coefficient is NaN or Inf at index " + std::to_string(i) );
2318+
}
2319+
}
2320+
2321+
// Final validation of all coefficients before returning
2322+
for( size_t i = 0; i < coeffs.size(); ++i )
2323+
{
2324+
if( isnan( coeffs[i] ) || isinf( coeffs[i] ) )
2325+
throw runtime_error( "fit_poly_energy_cal_from_points: Final coefficient is NaN or Inf at index " + std::to_string(i) );
2326+
}
2327+
2328+
return coeffs;
2329+
}//fit_poly_energy_cal_from_points(...)
21722330
}//namespace SpecUtils

src/SpecFile_phd.cpp

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,108 @@ bool SpecFile::load_from_phd( std::istream &input )
240240
//165.860 491.7100 0.02968
241241
//...
242242
//1836.060 5448.4400 0.02968
243+
244+
try
245+
{
246+
vector<pair<float,float>> channel_energy_pairs;
247+
248+
// Read energy-channel data lines until next section or STOP
249+
while( SpecUtils::safe_get_line( input, line ) )
250+
{
251+
trim( line );
252+
if( line.empty() )
253+
continue;
254+
255+
// Stop at next section marker or STOP keyword
256+
if( istarts_with( line, "#" ) || iequals_ascii( line, "STOP" ) )
257+
break;
258+
259+
// Parse the three columns: energy, channel, uncertainty
260+
vector<float> fields;
261+
const bool split_success = SpecUtils::split_to_floats( line.c_str(), line.size(), fields );
262+
263+
if( !split_success || fields.size() < 2 )
264+
continue; // Skip malformed lines
265+
266+
const float energy = fields[0];
267+
const float channel = fields[1];
268+
// fields[2] is uncertainty - ignoring for now
269+
270+
// Basic sanity check
271+
if( energy > 0.0f && channel >= 0.0f )
272+
channel_energy_pairs.push_back( make_pair( channel, energy ) );
273+
}//while( reading energy-channel pairs )
274+
275+
// Check if we got usable data
276+
if( channel_energy_pairs.empty() )
277+
throw runtime_error( "No valid energy-channel pairs found" );
278+
279+
// Determine polynomial order based on number of points
280+
size_t num_coefficients = 2; // Default: linear with offset
281+
282+
if( channel_energy_pairs.size() == 1 )
283+
{
284+
// Single point: linear through origin if energy > 100 keV
285+
if( channel_energy_pairs[0].second <= 100.0f )
286+
throw runtime_error( "Single calibration point with energy <= 100 keV" );
287+
num_coefficients = 1; // Just gain, no offset
288+
}else if( channel_energy_pairs.size() >= 5 )
289+
{
290+
num_coefficients = 3; // Quadratic
291+
}
292+
// else 2-4 points: use 2 coefficients (linear with offset)
293+
294+
// Fit polynomial coefficients using the utility function
295+
vector<float> coeffs = SpecUtils::fit_poly_energy_cal_from_points( channel_energy_pairs, num_coefficients );
296+
297+
// Validate coefficients are reasonable
298+
if( coeffs.size() < 2 )
299+
throw runtime_error( "Failed to compute calibration coefficients" );
300+
301+
// Check gain is positive
302+
if( coeffs.size() >= 2 && coeffs[1] <= 0.0f )
303+
throw runtime_error( "Negative or zero gain in energy calibration" );
304+
305+
// Create energy calibration and validate energy range
306+
if( !meas->gamma_counts_ || meas->gamma_counts_->empty() )
307+
throw runtime_error( "No gamma spectrum data available for energy calibration" );
308+
309+
const size_t num_channels = meas->gamma_counts_->size();
310+
auto newcal = make_shared<EnergyCalibration>();
311+
newcal->set_polynomial( num_channels, coeffs, {} );
312+
313+
const float lower_energy = newcal->lower_energy();
314+
const float upper_energy_cal = newcal->upper_energy();
315+
316+
// Reject if energy range is unreasonable
317+
if( upper_energy_cal < 300.0f )
318+
throw runtime_error( "Upper energy < 300 keV" );
319+
320+
if( upper_energy_cal > 15000.0f )
321+
throw runtime_error( "Upper energy > 15 MeV" );
322+
323+
// If we already have a calibration from the simpler method (lines 213-226),
324+
// check that this one is roughly compatible before replacing it
325+
if( meas->energy_calibration_ && meas->energy_calibration_->valid() )
326+
{
327+
const float existing_upper = meas->energy_calibration_->upper_energy();
328+
const float diff_percent = fabs( upper_energy_cal - existing_upper ) / existing_upper * 100.0f;
329+
330+
// Allow up to 10% difference
331+
if( diff_percent > 10.0f )
332+
{
333+
meas->parse_warnings_.push_back( "Energy calibration from #g_Energy section differs significantly from upper energy value" );
334+
}
335+
}
336+
337+
// Set the new calibration
338+
meas->energy_calibration_ = newcal;
339+
340+
}catch( std::exception &e )
341+
{
342+
// Failed to parse or create energy calibration - just continue without it
343+
meas->parse_warnings_.push_back( "Failed to parse #g_Energy section: " + string(e.what()) );
344+
}
243345
}//if( "#g_Energy" )
244346

245347
if( SpecUtils::istarts_with( line, "#g_Resolution") )

src/SpecFile_spc.cpp

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -784,11 +784,14 @@ bool SpecFile::load_from_iaea_spc( std::istream &input )
784784
if( SpecUtils::istarts_with(line, "TSA,") )
785785
throw runtime_error( "This is probably a TSA file, not a Ascii Spc" );
786786

787+
if( SpecUtils::istarts_with(line, "RADDATA://G0/") )
788+
throw runtime_error( "This is probably a URI file, not a Ascii Spc" );
789+
787790
++nnotrecognized;
788791
if( nnotrecognized > 15 && nnotrecognized >= linenum )
789792
throw runtime_error( "To many unregognized begining lines" );
790793

791-
#if(PERFORM_DEVELOPER_CHECKS && !SpecUtils_BUILD_FUZZING_TESTS)
794+
#if(PERFORM_DEVELOPER_CHECKS && !SpecUtils_BUILD_FUZZING_TESTS && !SpecUtils_BUILD_UNIT_TESTS )
792795
cerr << "Warning: SpecFile::load_from_iaea_spc(...): I didnt recognize line: '"
793796
<< line << "'" << endl;
794797
#endif

0 commit comments

Comments
 (0)