Initial commit: Radiacode Monitor project
This commit is contained in:
commit
ad079e5771
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
*.json
|
||||||
|
*.db
|
||||||
38
README.md
Normal file
38
README.md
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
# Radiacode Monitor
|
||||||
|
|
||||||
|
This project provides a real-time monitoring dashboard for a Radiacode gamma spectrometer. It processes live radiation data, stores it in a SQLite database, and serves an updated JSON payload for a web-based frontend.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Real-time Monitoring**: Displays live dose rate and count rate trends.
|
||||||
|
- **Energy Spectrum**: Shows the accumulated gamma energy spectrum (keV).
|
||||||
|
- **24-Hour Spectrogram**: A heatmap waterfall view of the radiation spectrum at 5-minute intervals over the last 24 hours.
|
||||||
|
- **Data Persistence**: Uses SQLite to store historical radiation data.
|
||||||
|
- **Web Dashboard**: A lightweight HTML/JavaScript frontend using Plotly for interactive charting.
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
- `rc_read2.py`: The main Python script that interfaces with the Radiacode device, processes data, and updates the database and web JSON.
|
||||||
|
- `frontend.htm`: The web dashboard displaying the charts.
|
||||||
|
- `radiacode_data.db`: SQLite database containing historical spectrum and rate data.
|
||||||
|
- `live_spectrum.json`: Aggregated JSON data used by the frontend.
|
||||||
|
|
||||||
|
## Setup and Usage
|
||||||
|
|
||||||
|
1. **Prerequisites**:
|
||||||
|
- A Radiacode device connected to the system.
|
||||||
|
- Python 3 with `sqlite3` and the `radiacode` wrapper library installed.
|
||||||
|
|
||||||
|
2. **Running the Monitor**:
|
||||||
|
Execute the Python script to start the data collection loop:
|
||||||
|
```bash
|
||||||
|
python rc_read2.py
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Viewing the Dashboard**:
|
||||||
|
The dashboard is designed to be served via a web server (like Apache) that can access the `live_spectrum.json` file. Point your web server to the directory containing `frontend.htm`.
|
||||||
|
|
||||||
|
## Data Processing
|
||||||
|
|
||||||
|
- **Intervals**: The script polls for spectrum data every 5 minutes and performs a hardware reset to ensure clean window alignment.
|
||||||
|
- **Aggregation**: The `update_web_json` function downsamples the last 24 hours of rate data and aggregates spectrum counts to keep the JSON payload size manageable for the web frontend.
|
||||||
110
frontend.htm
Normal file
110
frontend.htm
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
<h1>Radiacode Monitor</h1>
|
||||||
|
|
||||||
|
<div class="chart-container">
|
||||||
|
<h2>24-Hour Gamma Spectrum</h2>
|
||||||
|
<div id="spectrumChart"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chart-container">
|
||||||
|
<h2>Dose Rate (Last 24h)</h2>
|
||||||
|
<div id="rateChart"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chart-container">
|
||||||
|
<h2>Count Rate (Last 24h)</h2>
|
||||||
|
<div id="countChart"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chart-container">
|
||||||
|
<h2>24-Hour Spectrogram Waterfall</h2>
|
||||||
|
<div id="waterfallChart"></div>
|
||||||
|
</div>
|
||||||
|
<div class="download">
|
||||||
|
<h2>Raw Data</h2>
|
||||||
|
<div id="link"><a href=/live_spectrum.json>Download</a></div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div class="info">
|
||||||
|
<h2>Information</h2>
|
||||||
|
<div id="oddities">There are a few oddities with the data here. You will notice a spike at the highest energy channel. This captures all energies at 2822 KeV and above, making it the widest channel.<br><br>During rain, you will see a gradual increase in radiation.<br><br>The gamma spectrometer is down the street from a location that performs RT (Radiographic Testing) on their oil/gas equipment, so on many weeknights, you will see spikes in the real time radiation levels.<br><br>The radiation units appear to be uRem, even though I have the Radiacode set to uSv.</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<script src="https://cdn.plot.ly/plotly-2.24.1.min.js"></script>
|
||||||
|
<script>
|
||||||
|
async function fetchAndRender() {
|
||||||
|
// Fetch the aggregated JSON file served by Apache
|
||||||
|
const response = await fetch('/live_spectrum.json');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// 1. Render Current Spectrum
|
||||||
|
const xKeV = data.accumulated_spectrum.kev;
|
||||||
|
const yCounts = data.accumulated_spectrum.count;
|
||||||
|
|
||||||
|
Plotly.newPlot('spectrumChart', [{
|
||||||
|
x: xKeV,
|
||||||
|
y: yCounts,
|
||||||
|
type: 'scatter',
|
||||||
|
mode: 'lines',
|
||||||
|
line: { color: '#00ffcc' }
|
||||||
|
}], {
|
||||||
|
title: 'Energy Spectrum',
|
||||||
|
xaxis: { title: 'Energy (keV)' },
|
||||||
|
yaxis: { title: 'Counts', type: 'log' },
|
||||||
|
paper_bgcolor: '#1e1e1e', plot_bgcolor: '#1e1e1e', font: { color: '#fff' }
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Render Dose and Count Rates
|
||||||
|
const rateTimes = data.rates_history.time;
|
||||||
|
const doseRates = data.rates_history.dose_rate;
|
||||||
|
const countRates = data.rates_history.count_rate;
|
||||||
|
|
||||||
|
Plotly.newPlot('rateChart', [{
|
||||||
|
x: rateTimes,
|
||||||
|
y: doseRates,
|
||||||
|
type: 'scatter',
|
||||||
|
name: 'Dose Rate (Sv/h)',
|
||||||
|
line: { color: '#ff5555' }
|
||||||
|
}], {
|
||||||
|
title: 'Real-time Radiation Levels',
|
||||||
|
xaxis: { title: 'Time', rangeslider: {} },
|
||||||
|
yaxis: { title: 'Dose Rate' },
|
||||||
|
paper_bgcolor: '#1e1e1e', plot_bgcolor: '#1e1e1e', font: { color: '#fff' }
|
||||||
|
});
|
||||||
|
|
||||||
|
Plotly.newPlot('countChart', [{
|
||||||
|
x: rateTimes,
|
||||||
|
y: countRates,
|
||||||
|
type: 'scatter',
|
||||||
|
name: 'Count Rate (counts/s)',
|
||||||
|
line: { color: '#ff5555' }
|
||||||
|
}], {
|
||||||
|
title: 'Real-time Radiation Levels',
|
||||||
|
xaxis: { title: 'Time', rangeslider: {} },
|
||||||
|
yaxis: { title: 'Count Rate' },
|
||||||
|
paper_bgcolor: '#1e1e1e', plot_bgcolor: '#1e1e1e', font: { color: '#fff' }
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Render 24h Spectrogram Waterfall (Heatmap)
|
||||||
|
const times = data.spectrogram_history.time;
|
||||||
|
const zValues = data.spectrogram_history.counts;
|
||||||
|
const xChannels = data.accumulated_spectrum.kev;
|
||||||
|
|
||||||
|
Plotly.newPlot('waterfallChart', [{
|
||||||
|
x: xChannels,
|
||||||
|
y: times,
|
||||||
|
z: zValues,
|
||||||
|
type: 'heatmap',
|
||||||
|
colorscale: 'Viridis'
|
||||||
|
}], {
|
||||||
|
title: '24-Hour Spectrogram (5 minute intervals)',
|
||||||
|
xaxis: { title: 'Energy (keV)' },
|
||||||
|
yaxis: { title: 'Time', autorange: 'reverse' },
|
||||||
|
paper_bgcolor: '#1e1e1e', plot_bgcolor: '#1e1e1e', font: { color: '#fff' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Run on load and refresh every 60 seconds
|
||||||
|
fetchAndRender();
|
||||||
|
setInterval(fetchAndRender, 60000);
|
||||||
|
</script>
|
||||||
196
rc_read2.py
Normal file
196
rc_read2.py
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
import time
|
||||||
|
import datetime
|
||||||
|
import sqlite3
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from radiacode import RadiaCode # Assuming this is your wrapper
|
||||||
|
|
||||||
|
# Paths on your mounted NFS share
|
||||||
|
DB_PATH = "./radiacode_data.db"
|
||||||
|
JSON_OUTPUT_PATH = "/mnt/Share/radiation/live_spectrum.json"
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
# Table for 5-minute spectrum snapshots
|
||||||
|
cursor.execute('''CREATE TABLE IF NOT EXISTS spectrums (
|
||||||
|
timestamp TEXT PRIMARY KEY,
|
||||||
|
duration_secs REAL,
|
||||||
|
channels_json TEXT)''')
|
||||||
|
# Table for instantaneous dose rate snapshots
|
||||||
|
cursor.execute('''CREATE TABLE IF NOT EXISTS rates (
|
||||||
|
timestamp TEXT PRIMARY KEY,
|
||||||
|
count_rate REAL,
|
||||||
|
dose_rate REAL)''')
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def calculate_channels(a0, a1, a2, counts):
|
||||||
|
"""Calculates keV for each channel and pairs it with the count."""
|
||||||
|
spectrum_data = []
|
||||||
|
for channel_num, count in enumerate(counts):
|
||||||
|
# Formula: a0 + a1*ch + a2*ch^2
|
||||||
|
kev = a0 + (a1 * channel_num) + (a2 * (channel_num ** 2))
|
||||||
|
spectrum_data.append({"kev": round(kev, 2), "count": count})
|
||||||
|
return spectrum_data
|
||||||
|
|
||||||
|
def update_web_json():
|
||||||
|
"""Generates a small JSON file containing the latest state for Apache."""
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Define the 24-hour lookback window
|
||||||
|
yesterday = (datetime.datetime.now() - datetime.timedelta(hours=24)).isoformat()
|
||||||
|
|
||||||
|
# 1. Fetch real-time count and dose rate history for the timeline chart
|
||||||
|
cursor.execute("SELECT timestamp, count_rate, dose_rate FROM rates WHERE timestamp > ? ORDER BY timestamp ASC", (yesterday,))
|
||||||
|
all_rates = cursor.fetchall()
|
||||||
|
|
||||||
|
# Downsample rates to avoid massive JSON files (target ~1000 points)
|
||||||
|
# Instead of taking arbitrary interleaved points (which causes graph jitter and misses peaks),
|
||||||
|
# we group the data into bins and calculate the maximum value for each bin.
|
||||||
|
rates_history = []
|
||||||
|
if all_rates:
|
||||||
|
step = max(1, len(all_rates) // 1000)
|
||||||
|
for i in range(0, len(all_rates), step):
|
||||||
|
chunk = all_rates[i:i+step]
|
||||||
|
max_dose = max(r[2] for r in chunk)
|
||||||
|
max_count = max(r[1] for r in chunk)
|
||||||
|
# Use the middle timestamp of the chunk to represent the bin's time
|
||||||
|
timestamp = chunk[len(chunk)//2][0]
|
||||||
|
rates_history.append((timestamp, max_count, max_dose))
|
||||||
|
|
||||||
|
# 2. Fetch all 5-minute isolated intervals from the last 24 hours
|
||||||
|
|
||||||
|
cursor.execute("SELECT timestamp, channels_json FROM spectrums WHERE timestamp > ? ORDER BY timestamp ASC", (yesterday,))
|
||||||
|
spectrogram_rows = cursor.fetchall()
|
||||||
|
|
||||||
|
spectrogram_history_list = []
|
||||||
|
master_aggregation_dict = {}
|
||||||
|
|
||||||
|
# 3. Parse rows: build the waterfall history AND calculate the 24-hour combined sum
|
||||||
|
for row in spectrogram_rows:
|
||||||
|
timestamp = row[0]
|
||||||
|
channels = json.loads(row[1])
|
||||||
|
|
||||||
|
# Keep this isolated chunk intact for the spectrogram waterfall
|
||||||
|
# We MUST keep the full array (including zeros) because Plotly's heatmap
|
||||||
|
# expects zValues[row][col] to map directly to xChannels[col]
|
||||||
|
spectrogram_history_list.append({"time": timestamp, "counts": [ch["count"] for ch in channels]})
|
||||||
|
|
||||||
|
# Accumulate the values into our master math dictionary to wipe out empty channels
|
||||||
|
# Only the accumulated total spectrum can be filtered for zeros to save size
|
||||||
|
for ch in channels:
|
||||||
|
kev = ch["kev"]
|
||||||
|
count = ch["count"]
|
||||||
|
master_aggregation_dict[kev] = master_aggregation_dict.get(kev, 0) + count
|
||||||
|
|
||||||
|
# Convert the flattened dictionary back to a sorted list of objects for Plotly
|
||||||
|
accumulated_spectrum_kev = []
|
||||||
|
accumulated_spectrum_counts = []
|
||||||
|
for kev, count in sorted(master_aggregation_dict.items()):
|
||||||
|
accumulated_spectrum_kev.append(round(kev, 2))
|
||||||
|
accumulated_spectrum_counts.append(count)
|
||||||
|
|
||||||
|
# Prepare parallel arrays for rates_history
|
||||||
|
rates_history_times = []
|
||||||
|
rates_history_count_rates = []
|
||||||
|
rates_history_dose_rates = []
|
||||||
|
for r in rates_history:
|
||||||
|
rates_history_times.append(r[0])
|
||||||
|
rates_history_count_rates.append(r[1])
|
||||||
|
rates_history_dose_rates.append(r[2])
|
||||||
|
|
||||||
|
# Prepare parallel arrays for spectrogram_history
|
||||||
|
spectrogram_history_times = [s["time"] for s in spectrogram_history_list]
|
||||||
|
spectrogram_history_counts = [s["counts"] for s in spectrogram_history_list]
|
||||||
|
|
||||||
|
# 4. Construct the output payload
|
||||||
|
payload = {
|
||||||
|
"accumulated_spectrum": {
|
||||||
|
"kev": accumulated_spectrum_kev,
|
||||||
|
"count": accumulated_spectrum_counts
|
||||||
|
},
|
||||||
|
"rates_history": {
|
||||||
|
"time": rates_history_times,
|
||||||
|
"count_rate": rates_history_count_rates,
|
||||||
|
"dose_rate": rates_history_dose_rates
|
||||||
|
},
|
||||||
|
"spectrogram_history": {
|
||||||
|
"time": spectrogram_history_times,
|
||||||
|
"counts": spectrogram_history_counts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Atomic write to prevent Apache from reading a half-written file
|
||||||
|
temp_path = JSON_OUTPUT_PATH + ".tmp"
|
||||||
|
with open(temp_path, 'w') as f:
|
||||||
|
json.dump(payload, f)
|
||||||
|
os.replace(temp_path, JSON_OUTPUT_PATH)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def main():
|
||||||
|
init_db()
|
||||||
|
rc = RadiaCode()
|
||||||
|
|
||||||
|
# Reset device memory on script startup to ensure a clean window alignment
|
||||||
|
try:
|
||||||
|
rc.spectrum_reset()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Initial hardware reset failed: {e}")
|
||||||
|
|
||||||
|
# Generate the JSON immediately on startup so the frontend is populated
|
||||||
|
update_web_json()
|
||||||
|
print("Initial web JSON generated.")
|
||||||
|
|
||||||
|
last_spectrum_time = time.time()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
# 1. Handle Continuous Data Buffer
|
||||||
|
buf = rc.data_buf()
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
for msg in buf:
|
||||||
|
# Check for DoseRateDB messages
|
||||||
|
if hasattr(msg, 'dose_rate'):
|
||||||
|
ts = msg.dt.isoformat()
|
||||||
|
cursor.execute("INSERT OR REPLACE INTO rates VALUES (?, ?, ?)",
|
||||||
|
(ts, msg.count_rate, msg.dose_rate))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# 2. Handle 5-minute Spectrum Polling
|
||||||
|
if time.time() - last_spectrum_time >= 300: # 300 seconds = 5 mins
|
||||||
|
spec = rc.spectrum()
|
||||||
|
|
||||||
|
# RE-ENABLED RESET: Wipes the device memory so the NEXT 5 mins start from zero
|
||||||
|
rc.spectrum_reset()
|
||||||
|
|
||||||
|
ts = datetime.datetime.now().isoformat()
|
||||||
|
|
||||||
|
# Process and format this isolated chunk
|
||||||
|
channels = calculate_channels(spec.a0, spec.a1, spec.a2, spec.counts)
|
||||||
|
channels_json = json.dumps(channels)
|
||||||
|
|
||||||
|
# Save isolated snapshot to DB
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("INSERT OR REPLACE INTO spectrums VALUES (?, ?, ?)",
|
||||||
|
(ts, spec.duration.total_seconds(), channels_json))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# 3. Pull from DB, compile math, and write out to Apache
|
||||||
|
update_web_json()
|
||||||
|
last_spectrum_time = time.time()
|
||||||
|
print(f"[{ts}] Database and web JSON refreshed with hardware interval reset.")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error loop caught: {e}")
|
||||||
|
|
||||||
|
time.sleep(1) # Sleep briefly to prevent CPU pinning
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Loading…
x
Reference in New Issue
Block a user