Exploratory Data Analysis: Gamma Spectroscopy in Python (Part 3)

Editor
27 Min Read


objects around us can be slightly radioactive. Americium in smoke detectors, radium in some vintage watches, or uranium in vintage glass; a full list can be long. Mostly, these objects are safe and cannot cause a health risk. It is also interesting to identify them and study the matter on the atomic level. And we can do this using a radiation detector. In the first part, I did an exploratory data analysis of the gamma spectroscopy data. In the second part, I created a machine learning model for detecting radioactive isotopes. This is the last 3rd part, and it’s time to add a created model to the real app!

In this story, I will test two approaches:

  • I will create a public Streamlit app that will be hosted for free on Streamlit Cloud (the app link is added to the end of the article).
  • As a more flexible and universal solution, I will create a Python HTMX-based app that can communicate with real hardware and make predictions in real time.

In the same way as in the previous part, I will use a Radiacode scintillation detector to get the data (disclaimer: the device used in this test was provided by the manufacturer; I don’t get any commercial profit from their sales, and I did not get any editorial input about all the tests). Readers who don’t have a Radiacode hardware will be able to test the app and the model using files available on Kaggle.

Let’s get started!

1. Isotopes Classification Model

This model was described in the previous part. It is based on XGBoost, and I trained the model using different radioactive samples. I used samples that can be legally purchased, like vintage uranium glass or old watches with radium dials made in the 1950s. As mentioned before, I also used a Radiacode scintillation detector, which allows me to get the gamma spectrum of the object. Only 10-20 years ago, these types of detectors were available only in big labs; today, they can be purchased for the price of a mid-range smartphone.

The model contains three components:

  • The XGBoost-based model itself.
  • A list of radioactive isotopes (like Lead-214 or Actinium-228), on which the model was trained. The Radiacode scintillation detector returns 1024 spectrum values, and 23 of them were used for the model.
  • A label encoder to convert list indexes into human-readable names.

Let’s wrap all this into a single Python class:

from xgboost import XGBClassifier
from sklearn.preprocessing import LabelEncoder


class IsotopesClassificationModel:
    """ Gamma Spectrum Classification Model """

    def __init__(self):
        """ Load models """
        path = self._get_models_path()
        self._classifier = self._load_model(path + "/XGBClassifier.json")
        self._isotopes = self._load_isotopes(path + "/isotopes.json")
        self._labels_encoder = self._load_labels_encoder(path + "/LabelEncoder.npy")

    def predict(self, spectrum: Spectrum) -> str:
        """ Predict the isotope """
        features = SpectrumPreprocessing.convert_to_features(
            spectrum, self._isotopes
        )
        preds = self._classifier.predict([features])
        preds = self._labels_encoder.inverse_transform(preds)
        return preds[0]

    @staticmethod
    def _load_model(filename: str) -> XGBClassifier:
        """ Load model from file """
        bst = XGBClassifier()
        bst.load_model(filename)
        return bst

    @staticmethod
    def _load_isotopes(filename: str) -> List:
        with open(filename, "r") as f_in:
            return json.load(f_in)

    @staticmethod
    def _load_labels_encoder(filename: str) -> LabelEncoder:
        le = LabelEncoder()
        le.classes_ = np.load(filename)
        return le

    @staticmethod
    def _get_models_path() -> str:
        """ Get path to models. Model files are stored in 
            'models/V1/' folder """
        parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
        return parent_dir + f"/models/{IsotopesClassificationModel.VERSION}"

A Spectrum class contains the spectrum data we get from a radiation detector:

@dataclass
class Spectrum:
    """ Radiation spectrum data """

    duration: datetime.timedelta
    a0: float
    a1: float
    a2: float
    counts: list[int]

Here, counts is a gamma spectrum, which is represented by a list of 1024 channel values. Spectrum data can be exported using the official Radiacode Android app or retrieved directly from a device using a radiacode Python library.

To load the spectrum into the model, I created a SpectrumPreprocessing class:

class SpectrumPreprocessing:
    """ Gamma Spectrum Preprocessing """

    @staticmethod
    def convert_to_features(spectrum: Spectrum, isotopes: List) -> np.array:
        """ Convert the spectrum to the list of features for prediction """
        sp_norm = SpectrumPreprocessing._normalize(spectrum)
        energies = [energy for _, energy in isotopes]
        channels = [SpectrumPreprocessing.energy_to_channel(spectrum, energy) for energy in energies]
        return np.array([sp_norm.counts[ch] for ch in channels])

    @staticmethod 
    def load_from_xml_file(file_path: str) -> Spectrum:
        """ Load spectrum from a Radiacode Android app file """

Here, I skip some code blocks that were already published in the previous part. Extracting features from the gamma spectrum was also explained there, and I highly recommend reading that part first.

Now, let’s test the model! I took a Radiacode detector and collected a gamma spectrum within 10 minutes:

Radiacode radiation detector, Image by author

This Chinese pendant was advertised as “ion-generated,” and it is slightly radioactive. Its gamma spectrum, collected in the official Radiacode Android app, looks like this:

Screenshot by author

After waiting for ~10 minutes, I exported the spectrum into an XML file. Now, we can run the model:

from spectrum import SpectrumPreprocessing
from ml_models import IsotopesClassificationModel

sp = SpectrumPreprocessing.load_from_file("spectrum.xml")
model = IsotopesClassificationModel()
result = model.predict(sp)
print(result)

#> Thorium

As we can see, the model works well. We can compare the peaks with spectra of known isotopes (for example, here or here) and confirm that the spectrum belongs to thorium.

2. Streamlit

The model works; however, we live in the XXI century, and almost nobody will run the console app to get the results. Instead, we can make the app available online, so all Radiacode users will be able to run it.

There are many Python frameworks for making browser-based apps, and Streamlit is probably the most popular in the data science community. And what is important for us, a Streamlit Community Cloud platform allows everyone to publish their apps completely for free. To do this, let’s make the app first.

2.1 Streamlit App

A Streamlit framework is relatively easy to use, at least if a standard-looking app is good for us. Personally, I’m not a fan of this approach. These frameworks hide all low-level implementation details from users. It is straightforward to make a prototype, but the UI logic will be tightly coupled with a very niche framework and cannot be reused anywhere else. Doing everything non-standard, which is not supported by the framework, can be almost impossible or hard to implement without digging into tons of abstractions and pages of code. However, in our case, the prototype is all we need.

In general, a Streamlit code is simple, and we just need to describe the logical hierarchy of our page:

import streamlit as st
import logging
logger = logging.getLogger(__name__)


def is_xml_valid(xml_data: str) -> bool:
    """ Check if the XML has valid size and data """
    return len(xml_data) < 65535 and xml_data.startswith("<?xml")


def get_spectrum(stringio: StringIO) -> Optional[Spectrum]:
    """ Load spectrum from the StringIO stream """
    xml_data = stringio.read()
    if is_xml_valid(xml_data):
        return SpectrumPreprocessing.load_from_xml(xml_data)
    return None

def main():
    """ Main app """
    st.set_page_config(page_title="Gamma Spectrum")
    st.title("Radiacode Spectrum Detection")
    st.text(
        "Export the spectrum to XML using the Radiacode app, and "
        "upload it to see the results."
    )

    # File Upload
    uploaded_file = st.file_uploader(
        "Choose the XML file", type="xml", key="uploader",
    )
    if uploaded_file is not None:
        stringio = StringIO(uploaded_file.getvalue().decode("utf-8"))
        if sp := get_spectrum(stringio):
            # Prediction
            model = IsotopesClassificationModel()
            result = model.predict(sp)
            logger.info(f"Spectrum prediction: {result}")

            # Show result
            st.success(f"Prediction Result: {result}")
            # Draw
            fig = get_spectrum_barchart(sp)
            st.pyplot(fig)


if __name__ == "__main__":
    logger.setLevel(logging.INFO)
    main()

As we can see, the full app requires a minimal amount of Python code. Streamlit will render all HTML for us, with title, file upload, and results. As a bonus, I will also display a spectrum using Matplotlib:

def get_spectrum_barchart(sp: Spectrum) -> plt.Figure:
    """ Get Matplotlib's barchart """
    counts = SpectrumPreprocessing.get_counts(sp)
    energy = [
       SpectrumPreprocessing.channel_to_energy(sp, x) for x in range(len(counts))
    ]

    fig, ax = plt.subplots(figsize=(9, 6))
    ax.spines["top"].set_color("lightgray")
    ax.spines["right"].set_color("lightgray")
    # Bars
    ax.bar(energy, counts, width=3.0, label="Counts")
    # X values
    ticks_x = [SpectrumPreprocessing.channel_to_energy(sp, ch) for ch in range(0, len(counts), len(counts) // 20)]
    labels_x = [f"{int(ch)}" for ch in ticks_x]
    ax.set_xticks(ticks_x, labels=labels_x, rotation=45)
    ax.set_xlim(energy[0], energy[-1])
    ax.set_ylim(0, None)
    ax.set_title("Gamma spectrum")
    ax.set_xlabel("Energy, keV")
    ax.set_ylabel("Counts")
    return fig

Now we can run the app locally:

streamlit run st-app.py

After that, our app is fully operational and can be tested in a browser:

Screenshot by author

As mentioned before, I am not a fan of very high-level frameworks and prefer to have a better understanding of how things work “under the hood.” However, considering that I spent only about 100 lines of code to make a fully functional web app, I cannot complain – for prototyping, it works well.

2.2 Streamlit Community Cloud

When the app is tested locally, it’s time to make it public! A Streamlit Cloud is a free service, and obviously, it has a lot of limitations:

  • The app runs in a Docker-like container. Your GitHub account must be linked to Streamlit. When the container starts, it pulls your code from GitHub and runs it.
  • At the time of writing this text, container resources are limited to 2 cores and up to 2,7 GB of RAM. It would be too constrained to run a 70B size LLM, but for a small XGBoost model, it is more than enough.
  • Streamlit does not provide any permanent storage. After shutdown or restart, all logs and temporary files will be lost (you can use API secrets and connect to any other cloud storage from your Python code if needed).
  • After a period of inactivity (about 30 minutes), the container will be stopped, and all temporary files will also be lost. If someone opens the app link, it will run again.

As readers can guess, an inactive app costs Streamlit almost nothing because it stores only a small configuration file. And it is a nice solution for a free service – it allows us to publish the app without any costs and give people a link to run it.

To publish the app in Streamlit, we need to perform three simple steps.

First, we need to commit our Python app to GitHub. A requirements.txt file is also mandatory. Streamlit container uses it to install required Python dependencies. In my case, it looks like this:

xgboost==3.0.2
scikit-learn==1.6.1
numpy==1.26.4
streamlit==1.47.0
pillow==11.1.0
matplotlib==3.10.3
xmltodict==0.14.2

Server settings can be changed using a .streamlit/config.toml file. In my case, I limited the uploaded file size to 1 MB because all spectra files are smaller:

[server]
# Max size, in megabytes, for files uploaded with the file_uploader.
# Default: 200
maxUploadSize = 1

Second, we need to log in to share.streamlit.io using a GitHub account and give permission to access the source code.

Finally, we can create a new Streamlit project. In the project settings, we can also select the desired URL and environment:

Image by author

If everything was done correctly, we can see our app running:

Image by author

At this moment, users worldwide can also access our app! In my case, I selected a gammaspectrumdetection name, and the app is available using this URL.

3. FastAPI + HTMX App

As readers can see, Streamlit is a nice solution for a simple prototype. However, in the case of the radiation detector, I would like to see data coming from real Radiacode hardware. This would be impossible to do in Streamlit; this library just was not designed for that. Instead, I will use several production-grade frameworks:

  • An HTMX framework allows us to make a fully functional web interface.
  • FastAPI will run the server.
  • The ML model will process the data retrieved in real-time from a radiation detector using a Radiacode library.

As mentioned before, those readers who don’t have a Radiacode hardware will be able to replay the data using raw log files, saved from a real device. A link to the app and all files is available at the end of the article.

Let’s get into it!

3.1 HTML/HTMX

The app is connected to a Radiacode detector, and I decided to show a connection status, radiation level, and a spectrum graph on the page. At the bottom, a spectrum collection time and an ML model prediction will be displayed.

An index.html file for this layout looks like this:

<!DOCTYPE html>
<head>
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Gamma Spectrum & Monitoring</title>
    <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/htmx.min.js" crossorigin="anonymous"></script>
    <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
    <link rel="stylesheet" href="{{ url_for('static', path='styles.css') }}">
</head>

<body>
    <script type="text/javascript">
        function createChart() {
            const ctx = document.getElementById('gammaChart').getContext('2d');
            window.chart = new Chart(ctx, {
                 ...
            });
        }

        function updateChartData(labels, data) {
            window.chart.data.labels = labels;
            window.chart.data.datasets[0].data = data;
            window.chart.update();
        }

        document.addEventListener('DOMContentLoaded', function() {
            // console.log('Page loaded');
            createChart();
        }, false);
    </script>

    <div class="dashboard">
        <div id="reload-status" hx-get="/device/poll" hx-target="#device-info" hx-swap="outerHTML" hx-trigger="load, every 1s"/>
    
        <div class="info-row-top">
            <div class="info-box-left">
                <span id="device-info">Device: n/a</span><br/>
            </div>
            <div class="info-box-right">
                <span id="device-cps">CPS: n/a</span>
            </div>
        </div>

        <canvas id="gammaChart" class="gamma-chart"></canvas>
        <div id="gammaChartData"></div>

        <div class="info-row-bottom">
            <div class="info-box-left">
                <span>Collection Time:</span><br/>
                <span id="collection-time">n/a</span>
            </div>
            <div class="info-box-right">
                <span>Prediction:</span><br/>
                <span id="prediction-result">n/a</span>
           </div>
        </div>
        <button class="reset-button" hx-post="/device/reset_spectrum" hx-swap="none">Reset Spectrum</button>
    </div>
</body>
</html>

Here, I used Chart.js to make a graph, and all UI controls are located in the div section. HTMX allows us to make an interactive web app without using JavaScript. This HTMX string performs the actual update:

hx-get="/device/poll" hx-target="#device-info" hx-swap="outerHTML" hx-trigger="load, every 1s"

Here, HTMX is configured to call the /device/poll endpoint every second, and the result will be placed into the control with a #device-info ID.

Readers are welcome to change the HTML if needed. I am not a frontend developer, and maybe this UI can be done more effectively. However, the page works and does its job:

Screenshot by author

3.2 FastAPI

As mentioned before, I decided to use FastAPI for the backend part.

First, we need to create a FastAPI instance and add the required endpoints:

from fastapi import FastAPI, Request, Response
from fastapi.responses import FileResponse
from contextlib import asynccontextmanager


@asynccontextmanager
async def lifespan(app: FastAPI):
    logging.info("FastAPI::Started")
    app.state.device = RadiaCodeDevice()
    app.state.device.start_in_background()
    yield
    app.state.device.stop()
    logging.info("FastAPI::Ended")

app = FastAPI(lifespan=lifespan)
app.mount("/static", StaticFiles(directory="static"), name="static")

@app.get("/device/poll")
async def device_poll():
    """ Get device data """
    # The code is placed below

@app.post("/device/reset_spectrum")
async def device_reset_spectrum():
    """ Reset gamma spectrum data """
    app.state.device.reset_spectrum()
    return "OK"

@app.get("/")
def read_index(request: Request):
    context = {"request": request}
    return templates.TemplateResponse("index.html", context)

@app.get("/favicon.ico", include_in_schema=False)
async def favicon():
    return FileResponse("static/favicon.ico")


if __name__ == '__main__':
    import uvicorn
    uvicorn.run("app:app", host='0.0.0.0', port=8000, reload=True)

As readers can see, I used a lifespan callback to run the Radiacode connection when the FastAPI server is started. The app.state is a global state object in FastAPI. We have only a single device instance, so the use of a global state is a good place for that. FastAPI itself is based on asyncio, and the Radiacode connection thread works in the background. In asyncio, I cannot block the main thread, so I used two queues (one for data and one for commands) for data exchange.

The way of updating the page in HTMX is interesting. Let’s say I want to update the CPS (counts per second) value. As a reminder, in HTML, I have these controls:

<span id="device-info">Device: n/a</span><br/>

<div class="info-box-left">
    <span id="device-info">Device: n/a</span><br/>
</div>
<div class="info-box-right">
    <span id="device-cps">CPS: n/a</span>
</div>

<div id="reload-status" hx-get="/device/poll" hx-target="#device-info"
     hx-swap="outerHTML" hx-trigger="load, every 1s"
/>

In a response to /device/poll, I need to return a proper HTML for the corresponding IDs:

def get_connection_data(self) -> str:
     """ Get device data for the HTML poll request """
     status = "Connected"
     return f'<span id="device-info">{status}</span>'

def get_cps_data(self) -> str:
    """ Get CPS data for the HTML poll request """
    return f'<span id="device-cps" hx-swap-oob="true">CPS: {self.radiation_cps:.2f}</span>'

def get_spectrum_data(self) -> str:
    """ Get spectrum data for the HTML poll request """
    return f'<script>updateChartData([], [])</script>'

def get_spectrum_prediction(self) -> str:
    ...

@app.get("/device/poll")
async def device_poll():
    """ Get device data """
    poll = app.state.device.get_connection_data()
    cps = app.state.device.get_cps_data()
    spectrum = app.state.device.get_spectrum_data()
    prediction = app.state.device.get_spectrum_prediction()
    return Response(
        content=poll + cps + prediction + spectrum,
        media_type="application/text"
    )

HTMX will automatically trigger the /device/poll endpoint and replace the controls on a web page with new data. The hx-swap-oob key allows us to update several controls in a single request. As we can see, HTMX allows us to seamlessly connect a frontend page in a browser with a Python backend.

A get_spectrum_data method is slightly different. Updating a Chart.js bar chart is not supported directly by HTMX. However, I can return a JavaScript code block that will be executed on the page. I use this feature to update the graph by calling the updateChartData method, which was placed in index.html before.

The full source code is longer, and here I displayed only the crucial parts. A link to a full project is available at the end of the article.

4. Testing

Finally, let’s see both apps in action! My model can classify isotopes, and I wanted to test it using an object that was not “seen” by the model before. To do this, I visited the minerals shop in the city center:

Image by author

I know that some minerals can be slightly radioactive, so I chose the best one (or the worst one, depending on your preference) using a radiation detector. It was a blue apatite. I tested it at home, and my GMC radiation meter shows that its radioactivity level is 0,71 µSv/h, which was considered by the device’s algorithm as “High”:

Image by author

Here, “high” does not mean “dangerous” – this value is just high for the average background radiation level, which is usually about 0,1 µSv/h. However, in this case, the higher level is only about 1-2cm around the mineral. It is not dangerous and cannot cause any harm (as a comparison, the radiation in the airplane, caused by cosmic rays, is ~1.5 times higher than that). And as described in the previous part, a Geiger counter can show us the value, but cannot tell us why this mineral is radioactive. So, I will use a Radiacode gamma detector to find it out.

As a reminder, first I created a Streamlit app and published it on the Streamlit Community Cloud platform. The app is available online, but while nobody uses it, the app is in “sleep mode”:

Screenshot by author

I saved a spectrum using a Radiacode Android app. When the Streamlit app started (the container spin-up takes 2-3 minutes), I uploaded the file:

Classification result, screenshot by author

As a second test, let’s run the local app. It is more functional, it can communicate with the Radiacode detector via USB, and can also work as a UI to control the hardware. Another crucial difference is that we can see data from the detector in real time.

I pressed the “Reset Spectrum” button to restart the accumulation, and after 2-3 minutes of collecting the data, the mineral was identified as thorium:

Screenshot by author

This was an interesting find, because according to Wikipedia, apatite may contain uranium, but thorium was not mentioned there. However, according to the USGS (U.S. Geological Survey) page, thorium may often be found in apatites. In this case, the Wikipedia article was just not complete enough.

Conclusion

In this article, I tested the machine learning model for detecting radioactive isotopes. I tested two ways of using the model – a Streamlit app, published in Streamlit Community Cloud, and a fully functional FastAPI app that gets data from the hardware and can be used as a web interface for a real radiation detector.

As we can see, the model works well, and I was able to find trace amounts of thorium in a blue apatite. This fact was not even mentioned in Wikipedia, and it’s always nice to learn something new. However, as was mentioned in the previous part, the model’s capabilities are limited. I am not a nuclear institution, and the model was trained only on radioactive objects that can be legally purchased, like a vintage watch with radium dials. I don’t have test sources like Cesium or Plutonium. Still, it was fun to train the model and to use it in the app. Probably, projects like this have no commercial value, and there is a 0.00% market demand for the isotope classification model. However, while doing this, I was able to use tools like FastAPI, HTMX, or CSS, which can be useful in other projects. Last but not least, I had fun, which is the most important part 😉

All data files were collected using a Radiacode scintillation detector. It’s a nice and compact device with a price of a mid-range smartphone, that, as we can see, allows us to perform some fun experiments (disclaimer: I don’t have any profit or other commercial interest from its sales). For those readers who don’t have a Radiacode hardware, all collected data is freely available on Kaggle. The archive contains spectra of different objects used to train the model. Spectra in XML format can be used to test a Streamlit app. The “replay” folder contains the log, saved from a Radiacode device, and can be used to test the HTMX app.

The full source code for this article is available on my Patreon page. This support helps me to buy equipment and electronics for future tests like this. And readers are also welcome to connect via LinkedIn, where I periodically publish smaller posts that are not big enough for a full article.

Thanks for reading.

Share this Article
Please enter CoinGecko Free Api Key to get this plugin works.