Files
radiacode/web/static/js/background.js
Jacquin Antoine 0847a3fc80 Fix: CsI(Tl) non-linear response correction + detector calibration overhaul
Root cause of Am-241 misidentification: the Radiacode 103's CsI(Tl) crystal
shifts low-energy peaks upward (59.5 keV → 71.6 keV for Am-241) due to
non-proportional scintillation response. The model was trained on theoretical
peak positions and couldn't match the shifted real peaks.

Changes:
- Add inverse CsI(Tl) non-linear correction to inference pipeline
  (radiacode_monitor.py, web/config.py, test_detection.py)
  E_apparent = E_true * (1 + 0.37 * exp(-E_true/100))
  Corrects channel mapping so peaks appear at theoretical energies
- Fix energy calibration: DetectorConfig now uses E = 0.33 + 2.97*ch
  with 1023 channels, matching the real detector (was energy_min=20,
  skip_first_channel=True, different channel width)
- Add K-escape peaks for CsI(Tl) iodine X-ray escape (E - 28.5 keV)
- Add asymmetric peak shapes for low-energy tails (< 200 keV)
- Add log1p normalization in dataset and inference (replaces max-norm)
- Add background-subtracted training mode (subtract_background flag)
- Add low-signal augmentation (0.01-5 Bq activities, 30-300s durations)
- Update docker-compose.yml: batch_size=32, duration=30-300s,
  CSI_NONLINEAR_ALPHA/BETA env vars for detect and web
- Web dashboard: apply CsI correction to displayed spectra
- Various UI fixes (Chart.js width, zoom/pan, isotope lines)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 17:35:22 +02:00

269 lines
9.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

let bgChart = null;
let bgReferenceData = null;
let bgContinuumData = null;
// Reset zoom button — fully restores the energy range
document.getElementById('reset-zoom-bg')?.addEventListener('click', () => {
if (bgChart) {
bgChart.resetZoom();
delete bgChart._panRange;
const firstPt = bgChart.data.datasets[0]?.data?.[0];
const lastPt = bgChart.data.datasets[0]?.data?.[bgChart.data.datasets[0].data.length - 1];
const fullMin = firstPt?.x ?? 0;
const fullMax = lastPt?.x ?? 3000;
bgChart.options.scales.x.min = fullMin;
bgChart.options.scales.x.max = fullMax;
bgChart.update();
document.getElementById('reset-zoom-bg').style.display = 'none';
}
});
async function loadBgReference() {
try {
const resp = await fetch(`${API_BASE}/api/background/reference`);
if (!resp.ok) return;
bgReferenceData = await resp.json();
} catch {}
}
async function loadBgContinuum(cps, liveTime) {
try {
const resp = await fetch(`${API_BASE}/api/background/continuum?cps=${cps}&live_time_s=${liveTime}`);
if (!resp.ok) return;
bgContinuumData = await resp.json();
} catch {}
}
/**
* Gaussian kernel smoothing.
* Convolves the data with a Gaussian kernel of given sigma (in channels).
* Preserves peak shapes while reducing statistical variation.
*/
function smoothGaussian(data, sigma) {
if (!data || data.length === 0) return data;
const kernelRadius = Math.ceil(sigma * 3);
const kernel = [];
for (let i = -kernelRadius; i <= kernelRadius; i++) {
kernel.push(Math.exp(-0.5 * (i / sigma) ** 2));
}
const result = new Array(data.length);
for (let i = 0; i < data.length; i++) {
let sum = 0;
let wSum = 0;
for (let k = -kernelRadius; k <= kernelRadius; k++) {
const idx = i + k;
if (idx < 0 || idx >= data.length) continue;
const w = kernel[k + kernelRadius];
sum += data[idx] * w;
wSum += w;
}
result[i] = wSum > 0 ? sum / wSum : 0;
}
return result;
}
async function refreshBackground() {
try {
const [infoResp, specResp] = await Promise.all([
fetch(`${API_BASE}/api/background`),
fetch(`${API_BASE}/api/background/spectrum`)
]);
if (!infoResp.ok || !specResp.ok) {
document.getElementById('bg-stats').innerHTML = '<p style="color:#888">Background non disponible</p>';
return;
}
const info = await infoResp.json();
const spec = await specResp.json();
// Stats
document.getElementById('bg-stats').innerHTML = `
<div class="bg-stat"><div class="bg-stat-value">${info.elapsed_hours.toFixed(1)}h</div><div class="bg-stat-label">Durée</div></div>
<div class="bg-stat"><div class="bg-stat-value">${info.live_time_s.toFixed(0)}s</div><div class="bg-stat-label">Live time</div></div>
<div class="bg-stat"><div class="bg-stat-value">${info.total_counts.toFixed(0)}</div><div class="bg-stat-label">Coups</div></div>
<div class="bg-stat"><div class="bg-stat-value">${info.cps.toFixed(2)}</div><div class="bg-stat-label">CPS</div></div>
`;
// Load continuum on first load
if (!bgContinuumData && spec.live_time_s > 0) {
await loadBgContinuum(info.cps || 6.0, spec.live_time_s);
}
// Chart
updateBackgroundChart(spec);
// Peaks table
updatePeaksTable(info.top_peaks || []);
// Show/hide toggles
const refToggle = document.getElementById('show-bg-reference');
if (refToggle) refToggle.parentElement.style.display = spec.reference_available ? 'flex' : 'none';
} catch {}
}
function updateBackgroundChart(spec) {
const showLog = document.getElementById('bg-scale-log')?.checked;
const ctx = document.getElementById('background-chart').getContext('2d');
const showRef = document.getElementById('show-bg-reference')?.checked && bgReferenceData;
const showSmooth = document.getElementById('show-bg-smooth')?.checked;
const showContinuum = document.getElementById('show-bg-continuum')?.checked && bgContinuumData;
const energy = spec.energy_kev;
const toData = (arr) => arr.map((v, i) => ({ x: energy[i], y: v }));
const datasets = [{
label: 'Background (live)',
data: toData(spec.counts),
borderColor: '#ff9800',
backgroundColor: 'rgba(255, 152, 0, 0.1)',
borderWidth: 1,
pointRadius: 0,
fill: true,
}];
if (showSmooth) {
const smoothed = smoothGaussian(spec.counts, 8);
datasets.push({
label: 'Lissé',
data: toData(smoothed),
borderColor: 'rgba(233, 30, 99, 0.9)',
backgroundColor: 'rgba(233, 30, 99, 0.05)',
borderWidth: 2,
pointRadius: 0,
fill: false,
});
}
if (showContinuum) {
datasets.push({
label: 'Continuum',
data: toData(bgContinuumData.counts),
borderColor: 'rgba(156, 39, 176, 0.8)',
backgroundColor: 'rgba(156, 39, 176, 0.05)',
borderWidth: 2,
pointRadius: 0,
fill: false,
borderDash: [8, 4],
});
}
if (showRef) {
const scale = spec.live_time_s > 0 && bgReferenceData.live_time_s > 0
? spec.live_time_s / bgReferenceData.live_time_s
: 1;
const refEnergy = bgReferenceData.energy_kev || energy;
datasets.push({
label: `Référence 24h (×${scale.toFixed(1)})`,
data: bgReferenceData.counts.map((c, i) => ({ x: refEnergy[i] ?? energy[i], y: c * scale })),
borderColor: 'rgba(79, 195, 247, 0.8)',
backgroundColor: 'rgba(79, 195, 247, 0.08)',
borderWidth: 1,
pointRadius: 0,
fill: true,
borderDash: [4, 2],
});
}
const chartData = {
datasets: datasets,
};
// Preserve pan range only if user has explicitly zoomed/panned
const panRange = bgChart?._panRange;
const firstX = datasets[0].data[0]?.x ?? 0;
const lastX = datasets[0].data[datasets[0].data.length - 1]?.x ?? 3000;
const xMin = panRange ? panRange[0] : firstX;
const xMax = panRange ? panRange[1] : lastX;
const options = {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { labels: { color: '#e0e0e0' } },
tooltip: {
enabled: true,
mode: 'index',
intersect: false,
callbacks: {
title: (items) => `${items[0].parsed.x.toFixed(1)} keV`,
label: (item) => `${item.dataset.label}: ${item.parsed.y.toFixed(1)} counts`
}
},
zoom: {
zoom: {
wheel: { enabled: true },
pinch: { enabled: true },
drag: { enabled: false },
mode: 'x',
onZoomComplete: () => {
document.getElementById('reset-zoom-bg').style.display = 'inline-block';
}
}
}
},
scales: {
x: {
type: 'linear',
min: xMin,
max: xMax,
title: { display: true, text: 'Énergie (keV)', color: '#888' },
ticks: { color: '#888', maxTicksLimit: 20 },
grid: { color: '#333' },
},
y: {
type: showLog ? 'logarithmic' : 'linear',
min: showLog ? 0.5 : undefined,
title: { display: true, text: `Comptages (${showLog ? 'log' : 'lin'})`, color: '#888' },
ticks: { color: '#888' },
grid: { color: '#333' },
}
}
};
if (bgChart) {
bgChart.data = chartData;
bgChart.options = options;
bgChart.update();
} else {
bgChart = new Chart(ctx, { type: 'line', data: chartData, ...options });
const firstX = datasets[0].data[0]?.x ?? 0;
const lastX = datasets[0].data[datasets[0].data.length - 1]?.x ?? 3000;
enablePan(bgChart, 'reset-zoom-bg', firstX, lastX);
}
}
function updatePeaksTable(peaks) {
const container = document.getElementById('peaks-table');
if (!peaks || peaks.length === 0) {
container.innerHTML = '<p style="color:#888;text-align:center;padding:8px;">Pas assez de données pour identifier les pics</p>';
return;
}
let html = '<h3 style="margin-bottom:8px;color:#ff9800;">Pics détectés</h3>';
peaks.forEach(p => {
html += `<div class="peak-row">
<span style="color:#4fc3f7">${p.energy_kev.toFixed(1)} keV</span>
<span>${p.counts.toFixed(0)} cts</span>
</div>`;
});
container.innerHTML = html;
}
document.querySelector('[data-tab="background"]').addEventListener('click', () => {
refreshBackground();
loadBgReference();
});
// Toggle handlers
document.getElementById('show-bg-reference')?.addEventListener('change', () => refreshBackground());
document.getElementById('show-bg-continuum')?.addEventListener('change', () => {
if (document.getElementById('show-bg-continuum').checked && !bgContinuumData) {
loadBgContinuum(6.0, 3600).then(() => refreshBackground());
} else {
refreshBackground();
}
});
document.getElementById('show-bg-smooth')?.addEventListener('change', () => refreshBackground());
document.getElementById('bg-scale-log')?.addEventListener('change', () => refreshBackground());