Skip to content

Commit 4a80563

Browse files
committed
Add practical group confirmation and plain-language spectrum report
1 parent b48e02c commit 4a80563

5 files changed

Lines changed: 174 additions & 0 deletions

File tree

chicha-isotope-map.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5184,6 +5184,8 @@ func processSpectrumXMLUpload(
51845184
"qualitative": spectrumQualitativeSummary(analysis),
51855185
"components": analysis.Components,
51865186
"component_text": spectrumComponentSummary(analysis),
5187+
"group_checks": analysis.GroupChecks,
5188+
"explanation": analysis.Explanation,
51875189
}
51885190

51895191
if currentTrackID != "" && currentHasBounds {

pkg/spectrum/analysis.go

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package spectrum
33
import (
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+
}

pkg/spectrum/model.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,13 +62,27 @@ type SpectrumComponent struct {
6262
AverageLineError float64
6363
}
6464

65+
// GroupCheck tracks whether a practical isotope group has enough line evidence
66+
// to be considered confirmed on low-resolution detectors.
67+
type GroupCheck struct {
68+
GroupID string
69+
DisplayName string
70+
MatchedLines []float64
71+
MissingLines []float64
72+
Confidence float64
73+
IsConfirmed bool
74+
Comment string
75+
}
76+
6577
// Analysis bundles parsed spectrum and lookup results.
6678
type Analysis struct {
6779
Measurement SpectrumMeasurement
6880
DetectedPeaks []Peak
6981
Isotopes []IsotopeHit
7082
CompositeModels []CompositeHit
7183
Components []SpectrumComponent
84+
GroupChecks []GroupCheck
85+
Explanation string
7286
}
7387

7488
// MarkerMatch reports which marker is closest in time to the spectrum window.

pkg/spectrum/radiacode_test.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,4 +140,10 @@ func TestRadiationTypesAndCompositeModel(t *testing.T) {
140140
if analysis.Components[0].Contribution <= 0 {
141141
t.Fatalf("expected non-zero leading component contribution")
142142
}
143+
if len(analysis.GroupChecks) == 0 {
144+
t.Fatalf("expected practical group checks")
145+
}
146+
if analysis.Explanation == "" {
147+
t.Fatalf("expected plain-language explanation")
148+
}
143149
}

public_html/map.html

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8660,6 +8660,30 @@ <h3>${translate('desktop_setup_title')}</h3>
86608660
panel.appendChild(componentBox);
86618661
}
86628662

8663+
const explanationText = String(contextObject.explanation || '').trim();
8664+
if (explanationText) {
8665+
const explanationBox = document.createElement('div');
8666+
explanationBox.style.marginTop = '8px';
8667+
explanationBox.innerHTML = `<strong>Interpretation:</strong> ${escapeHtml(explanationText)}`;
8668+
panel.appendChild(explanationBox);
8669+
}
8670+
8671+
const groupChecks = Array.isArray(contextObject.group_checks) ? contextObject.group_checks : [];
8672+
if (groupChecks.length > 0) {
8673+
const groupTitle = document.createElement('h4');
8674+
groupTitle.style.marginTop = '14px';
8675+
groupTitle.textContent = 'Group confirmation';
8676+
panel.appendChild(groupTitle);
8677+
groupChecks.slice(0, 6).forEach(function(groupCheck) {
8678+
const groupRow = document.createElement('div');
8679+
groupRow.style.marginTop = '4px';
8680+
const confidencePercent = Math.round(Number(groupCheck.confidence || 0) * 100);
8681+
const statusText = groupCheck.isConfirmed ? 'confirmed' : 'not confirmed';
8682+
groupRow.textContent = `${groupCheck.displayName || groupCheck.groupID}: ${statusText} (${confidencePercent}%)`;
8683+
panel.appendChild(groupRow);
8684+
});
8685+
}
8686+
86638687
if (contextObject.start_unix && contextObject.end_unix) {
86648688
const timeBox = document.createElement('div');
86658689
timeBox.style.marginTop = '8px';

0 commit comments

Comments
 (0)