@@ -3,6 +3,7 @@ package spectrum
33import (
44 "math"
55 "sort"
6+ "strings"
67)
78
89// AnalyzeMeasurement runs generic peak detection and isotope matching.
@@ -11,12 +12,16 @@ func AnalyzeMeasurement(measurement SpectrumMeasurement) Analysis {
1112 isotopes := matchIsotopes (peaks , DefaultCatalog ())
1213 composites := buildCompositeModels (peaks , isotopes , 3 )
1314 components := estimateSpectrumComponents (peaks )
15+ groupChecks := evaluatePracticalGroups (peaks )
16+ explanation := buildPlainLanguageExplanation (components , groupChecks )
1417 return Analysis {
1518 Measurement : measurement ,
1619 DetectedPeaks : peaks ,
1720 Isotopes : isotopes ,
1821 CompositeModels : composites ,
1922 Components : components ,
23+ GroupChecks : groupChecks ,
24+ Explanation : explanation ,
2025 }
2126}
2227
@@ -369,3 +374,126 @@ func estimateSpectrumComponents(peaks []Peak) []SpectrumComponent {
369374 })
370375 return components
371376}
377+
378+ type practicalGroupRule struct {
379+ GroupID string
380+ DisplayName string
381+ RequiredLines []float64
382+ MinMatches int
383+ }
384+
385+ func evaluatePracticalGroups (peaks []Peak ) []GroupCheck {
386+ rules := []practicalGroupRule {
387+ {GroupID : "k40" , DisplayName : "K-40" , RequiredLines : []float64 {1460.8 }, MinMatches : 1 },
388+ {GroupID : "cs137" , DisplayName : "Cs-137" , RequiredLines : []float64 {661.7 }, MinMatches : 1 },
389+ {GroupID : "co60" , DisplayName : "Co-60" , RequiredLines : []float64 {1173.2 , 1332.5 }, MinMatches : 2 },
390+ {GroupID : "u_ra" , DisplayName : "U/Ra series" , RequiredLines : []float64 {186.2 , 295.2 , 351.9 , 609.3 }, MinMatches : 2 },
391+ {GroupID : "th232" , DisplayName : "Th-232 series" , RequiredLines : []float64 {238.6 , 583.2 , 911.2 , 2614.5 }, MinMatches : 2 },
392+ {GroupID : "am241" , DisplayName : "Am-241" , RequiredLines : []float64 {59.5 }, MinMatches : 1 },
393+ }
394+
395+ checks := make ([]GroupCheck , 0 , len (rules ))
396+ for _ , rule := range rules {
397+ matched := make ([]float64 , 0 , len (rule .RequiredLines ))
398+ missing := make ([]float64 , 0 , len (rule .RequiredLines ))
399+ for _ , lineEnergy := range rule .RequiredLines {
400+ peak , ok := closestPeakForEnergy (peaks , lineEnergy )
401+ if ! ok {
402+ missing = append (missing , lineEnergy )
403+ continue
404+ }
405+ tolerance := 35.0
406+ if lineEnergy < 120 {
407+ tolerance = 25.0
408+ }
409+ if math .Abs (peak .Energy - lineEnergy ) <= tolerance {
410+ matched = append (matched , lineEnergy )
411+ } else {
412+ missing = append (missing , lineEnergy )
413+ }
414+ }
415+ confidence := float64 (len (matched )) / float64 (len (rule .RequiredLines ))
416+ isConfirmed := len (matched ) >= rule .MinMatches
417+ comment := "not confirmed by group lines"
418+ if isConfirmed {
419+ comment = "confirmed by group line pattern"
420+ }
421+ checks = append (checks , GroupCheck {
422+ GroupID : rule .GroupID ,
423+ DisplayName : rule .DisplayName ,
424+ MatchedLines : matched ,
425+ MissingLines : missing ,
426+ Confidence : confidence ,
427+ IsConfirmed : isConfirmed ,
428+ Comment : comment ,
429+ })
430+ }
431+ sort .Slice (checks , func (i , j int ) bool {
432+ if checks [i ].Confidence == checks [j ].Confidence {
433+ return checks [i ].DisplayName < checks [j ].DisplayName
434+ }
435+ return checks [i ].Confidence > checks [j ].Confidence
436+ })
437+ return checks
438+ }
439+
440+ func buildPlainLanguageExplanation (components []SpectrumComponent , groupChecks []GroupCheck ) string {
441+ if len (components ) == 0 {
442+ return "Spectrum does not contain enough stable evidence for component interpretation."
443+ }
444+
445+ leading := make ([]string , 0 , 2 )
446+ for _ , component := range components {
447+ if component .ComponentID == "unknown" {
448+ continue
449+ }
450+ percent := int (math .Round (component .Contribution * 100 ))
451+ if percent < 8 {
452+ continue
453+ }
454+ leading = append (leading , component .DisplayName )
455+ if len (leading ) == 2 {
456+ break
457+ }
458+ }
459+
460+ confirmed := make ([]string , 0 , 3 )
461+ rejected := make ([]string , 0 , 3 )
462+ for _ , check := range groupChecks {
463+ if check .IsConfirmed {
464+ confirmed = append (confirmed , check .DisplayName )
465+ continue
466+ }
467+ if check .Confidence < 0.35 {
468+ rejected = append (rejected , check .DisplayName )
469+ }
470+ }
471+
472+ sentenceParts := make ([]string , 0 , 3 )
473+ if len (leading ) > 0 {
474+ sentenceParts = append (sentenceParts , "Rise is best explained by " + joinHumanList (leading )+ "." )
475+ }
476+ if len (confirmed ) > 0 {
477+ sentenceParts = append (sentenceParts , "Confirmed groups: " + joinHumanList (confirmed )+ "." )
478+ }
479+ if len (rejected ) > 0 {
480+ sentenceParts = append (sentenceParts , "Not confirmed by key lines: " + joinHumanList (rejected )+ "." )
481+ }
482+ if len (sentenceParts ) == 0 {
483+ return "Spectrum fit remains uncertain; key group lines are incomplete."
484+ }
485+ return strings .Join (sentenceParts , " " )
486+ }
487+
488+ func joinHumanList (items []string ) string {
489+ if len (items ) == 0 {
490+ return ""
491+ }
492+ if len (items ) == 1 {
493+ return items [0 ]
494+ }
495+ if len (items ) == 2 {
496+ return items [0 ] + " and " + items [1 ]
497+ }
498+ return items [0 ] + ", " + items [1 ] + ", and " + items [2 ]
499+ }
0 commit comments