Радиация окружает нас повсюду – естественный радиоактивный фон присутствует всегда и везде. Имея под рукой плату ESP32 за 8 долларов, OLED-дисплей за 3 доллара, MicroPython и трубку Гейгера – Мюллера, можно без труда собрать полноценный детектор радиации. Он сможет не только выводить текущие показатели на экран, но и отправлять данные на GMCMap, в Home Assistant и другие сервисы.

А в качестве бонуса в конце статьи я покажу несколько забавных научных экспериментов – например, как засечь радон у себя дома с помощью носка и обычного пылесоса.

❯ 1. Аппаратная часть

Очевидно, одним питоном тут не обойтись – для замера радиации понадобятся железяки. Схема подключения предельно проста:

Все компоненты стоят недорого.

  • Самая крупная деталь на схеме – готовый модуль счетчика Гейгера, который можно купить примерно за 50 долларов.

  • Сердцем модуля выступает трубка Гейгера – Мюллера, улавливающая радиоактивные частицы.

  • При пролете частицы плата формирует низковольтный импульс, который считывает микроконтроллер ESP32. На борту ESP32 есть вайфай, так что данные можно сходу отправлять на публичный сервис GMCMap или в Home Assistant.

  • Наконец, результаты выводятся на OLED-экран, что очень пригодится для наших экспериментов.

Вся схема легко собирается на беспаечной макетной плате, так что паяльник даже не понадобится. Модуль счетчика крайне надежен: у меня он проработал на балконе в режиме 24/7 около полугода. В связке с Raspberry Pi он отлично справлялся с анализом космических лучей и фонового излучения. Но сегодня мы остановимся именно на ESP32. Если вы еще не работали с MicroPython на этой плате, рекомендую предварительно прочесть вот эту вводную статью.

Как только железо собрано, можно переходить к написанию кода.

❯ 2. Программирование

Подсчет частиц

Любой дозиметр фиксирует отдельные пролетающие частицы – вы слышали характерное пощелкивание счетчика Гейгера. Когда заряженная частица пролетает через колбу с разреженным газом, происходит ионизация, и динамик издает щелчок. Наша первая задача – научиться эти щелчки считать.

Выход детектора подключен к пину GPIO18. Инициализируем его как вход:

from machine import Pin

geiger_input = Pin(18, Pin.IN, None)

Третий параметр крайне важен. На плате счетчика уже установлен подтягивающий резистор, поэтому внутреннюю подтяжку ESP32 нужно отключить (None), иначе импульсы просто не будут регистрироваться.

Теперь настроим прерывание для перехвата импульсов:

geiger_input_bounce_ms = 2
total_pulses = 0
last_trigger_time = 0

def geiger_input_isr(pin: Pin):
    """Эта функция вызывается мгновенно при срабатывании входа."""
    global total_pulses, last_trigger_time

    # Получаем текущее время в миллисекундах
    current_time = time.ticks_ms()

    # Защита от дребезга: считаем частицу, только если прошло больше 2 мс
    if time.ticks_diff(current_time, last_trigger_time) > geiger_input_bounce_ms:
        total_pulses += 1
        last_trigger_time = current_time

geiger_input.irq(trigger=Pin.IRQ_FALLING, handler=geiger_input_isr)

Логика простая, но есть нюанс. В идеальном мире прерывание срабатывает строго при переходе сигнала из высокого состояния в низкое (я использую Pin.IRQ_FALLING). Однако из-за электромагнитных помех форма импульса искажается, и у меня на каждую частицу прерывание срабатывало дважды.

Решение этой проблемы классическое – антидребезг: если с момента последнего отсчета прошло меньше 2 мс, новое прерывание просто игнорируется. В естественном фоне трубка дает порядка 10–30 импульсов в минуту, а при сильном излучении – до 1000–3000. Так что пауза в 2 мс нас полностью устраивает и не приведет к пропуску частиц.

Переход к CPM и CPS

Считать частицы мы научились. Однако на практике от «сырого» счетчика толку мало: характеристики и калибровка всех трубок приводятся к значению импульсов в минуту (CPM – counts per minute).

Можно, конечно, честно ждать целую минуту и выводить итоговую цифру. Именно так работали первые цифровые дозиметры, собранные на жесткой логике и счетчиках: нажал кнопку «Сброс», подождал 60 секунд – получил результат. Сегодня нам нужна более динамичная картинка в реальном времени, поэтому удобнее сначала считать импульсы в секунду (CPS – counts per second).

Подсчет CPS прост: фиксируем время последнего обновления и, как только проходит секунда, вычисляем прирост. Напомню, что наше прерывание суммирует импульсы в переменной total_pulses:

total_pulses = 0
last_pulse_count = 0
last_second_time = time.ticks_ms()
uptime_seconds = 0
screen_needs_update = False

def calculate_cps():
    """Рассчитывает новое значение CPS."""
    global total_pulses, last_second_time, last_pulse_count
    global screen_needs_update, uptime_seconds

    current_time = time.ticks_ms()
    if time.ticks_diff(current_time, last_second_time) >= 1000:
         pulses_this_sec = total_pulses - last_pulse_count  # Наш новый CPS
         last_pulse_count = total_pulses

         last_second_time = current_time
         uptime_seconds += 1
         screen_needs_update = True

Добавим эту функцию в основной цикл:

while True:
    calculate_cps()

    # Перерисовываем экран при появлении новых данных
    if screen_needs_update:
        update_display()
        screen_needs_update = False

    time.sleep_ms(50)

Точность можно повысить, задействовав аппаратный таймер на 1 секунду, но ради простоты кода пока обойдемся простым циклом – его точности вполне достаточно.

Теперь каждую секунду мы получаем свежее значение CPS. Чтобы перевести его в CPM (количество импульсов за минуту), будем сохранять историю измерений в обычный список:

HISTORY_LENGTH = 600

cps_history = [0] * HISTORY_LENGTH

В списке хранятся данные за последние 10 минут (600 секунд) – это пригодится и для последующей отправки на GMCMap. При получении нового значения просто сдвигаем буфер:

cps_history.pop(0)
cps_history.append(pulses_this_sec)

Имея скользящее окно данных, рассчитать CPM не составляет труда:

# Расчет CPM
if uptime_seconds < 60:
    # Экстраполируем показания за накопленные секунды
    current_sum = sum(cps_history[-uptime_seconds:])
    cpm_value = int((current_sum / uptime_seconds) * 60)
else:
    # Берем сумму за последние 60 секунд
    cpm_value = sum(cps_history[-60:])

Тут есть краевой случай: если прибор только включился и минуты еще не прошло, мы аппроксимируем показания на основе накопленных секунд. В первые 10–20 секунд возможна погрешность, но затем значение стабилизируется.

Вывод на OLED-дисплей

Детектор почти готов – пора вывести показания на экран. Воспользуемся стандартной библиотекой MicroPython для дисплеев SSD1306, работающих по I2C:

import ssd1306
from machine import SoftI2C

oled_width: int = 128
oled_height: int = 64
i2c = SoftI2C(scl=Pin(22), sda=Pin(21))
oled = ssd1306.SSD1306_I2C(oled_width, oled_height, i2c)

def update_display():
    """Перерисовывает интерфейс с актуальными данными."""
    oled.fill(0)

    # Рассчитываем мощность дозы в микрозивертах в час
    usv_h = calculate_usv_value(cpm_value)

    # Выводим CPM слева
    cpm_str = f"CPM:{cpm_value}"
    oled.text(cpm_str, 0, 0)

    # Выводим uSv с выравниванием по правому краю (8px на символ)
    usv_str = f"{usv_h:.2f}uSv"
    usv_x_pos = oled_width - (len(usv_str) * 8)
    oled.text(usv_str, usv_x_pos, 0)

    # График CPS за последнюю минуту внизу экрана
    draw_cps_graph(oled, cps_history[-60:], 4, 20, 120, 43)

    oled.show()

Значение cpm_value уже рассчитано, по нему удобно оценивать отклонение от фона. Чтобы перевести CPM в мощность эквивалентной дозы (микрозиверты в час), применяются коэффициенты калибровки, индивидуальные для каждой модели трубки:

def calculate_usv_value(cpm: int) -> float:
    """Переводит CPM в мкЗв/ч. Коэффициент зависит от модели трубки.
    Популярные трубки Гейгера – Мюллера:
    СБМ-20   0.0057
    СБМ-19   0.0021
    СИ-29БГ  0.0082
    СИ-180Г  0.0031
    LND-712  0.0081
    J305     0.0081
    СБТ11-А  0.0031
    СБТ-9    0.0117
    """
    return cpm * 0.0057

Экран небольшой, поэтому график построим только за последнюю минуту. Сделать это просто, так как массив значений CPS у нас уже под рукой:

def draw_cps_graph(display: ssd1306.SSD1306_I2C, data: list, x: int, y: int, width: int, height: int):
    """Строит гистограмму истории CPS с автомасштабированием."""
    max_val = max(data) if max(data) > 0 else 1
    for i, val in enumerate(data):
        bar_height = int((val / max_val) * height)

        px = x + (i * 2)
        py = y + height - bar_height

        display.fill_rect(px, py, 2, bar_height, 1)

Кнопки принтскрина на ESP32, увы, нет, но фотография отлично передает суть. Я поднес к счетчику банку из уранового стекла – и на графике сразу виден скачок активности:

Крупный шрифт для читаемости

Прибор работает и готов к замерам. Но экранчик маленький, и мелкие цифры не всегда удобны. Было бы отлично выводить уровень радиации крупным шрифтом. В веб-разработке это дело двух секунд, а вот у ESP32 нет ни операционной системы, ни встроенных векторных шрифтов. Воспользуемся утилитой micropython-font-to-py.

Сконвертируем нужный TTF-шрифт в Python-модуль с помощью команды:

python3 font_to_py.py Roboto-Bold.ttf 24 font_big.py -c 1234567890.

Здесь 24 – кегль шрифта, а 123456789. – набор экспортируемых символов. Флеш-память ESP32 ограничена (обычно 2–4 МБ), поэтому лишние знаки конвертировать не стоит. В результате сгенерируется файл font_big.py:

_font =\
b'\x0b\x00\x1f\x00\x7f\x80\x7b\xc0\x71\xc0\x01\xc0\x01\xc0\x03\x80'\
b'\x07\x80\x0e\x00\x0e\x00\x0e\x00\x00\x00\x00\x00\x0e\x00\x1e\x00'
...

_index =\
b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x22\x00\x44\x00'

Теперь задействуем этот шрифт в нашей программе:

from writer import Writer
import font_big

writer_big = Writer(oled, font_big)

def update_display_large_font():
    """Режим крупного текста: выводит только дозу и статус."""
    usv_h = calculate_usv_value(cpm_value)
    usv_str = "{:.2f}".format(usv_h)

    # Выводим дозу (Roboto 24px) по центру
    text_width = writer_big.stringlen(usv_str)
    dose_x = (oled_width - text_width) // 2
    Writer.set_textpos(oled, col=dose_x, row=10)
    writer_big.printstring(usv_str)

    # Выводим статус текстовым уровнем: LOW, MEDIUM или HIGH
    status_str = get_dose_rate(usv_h)
    text_width = writer_medium.stringlen(status_str)
    status_x = (oled_width - text_width) // 2
    Writer.set_textpos(oled, col=status_x, row=40)
    writer_medium.printstring(status_str)

    oled.show()

Для наглядности я добавил функцию get_dose_rate, которая классифицирует уровень радиации словесно:

def get_dose_rate(usv: float) -> str:
    """Определяет категорию уровня радиации в мкЗв/ч."""
    if usv < 0.25:
        return "LOW"
    elif usv < 1.0:
        return "MEDIUM"
    else:
        return "HIGH"

Выглядит это следующим образом:

Оба режима хороши по-своему. Если нужно детально изучить конкретный предмет (например, старинные часы со светомассой постоянного действия или урановое стекло), график CPS незаменим. А если дозиметр висит на стене для непрерывного мониторинга комнаты или подвала – крупный шрифт читается намного лучше.

❯ 3. Публикация данных на GMCMap

Прибор полностью работособен. Напоследок, перед началом опытов, подключим ESP32 к вайфаю и наладим передачу данных на глобальную карту радиационного фона GMCMap. Выглядит этот сервис так:

Проект полностью бесплатен и держится на энтузиастах. Наш датчик тоже может вносить свой вклад в общее дело – давайте настроим отправку.

Для начала регистрируемся на GMCMap и получаем айди аккаунта (Account ID) и айди счетчика (Geiger Counter ID). Естественно, понадобятся и данные для подключения к вайфаю:

WIFI_SSID = ""
WIFI_PASS = ""

GMC_AID = "xxxxx"       # GMCMap Account ID
GMC_GID = "xxxxxxxxxx"  # GMCMap Geiger Counter ID
GMC_UPLOAD_INTERVAL_MS = 2 * 60_000

Напишем функцию подключения к вайфаю:

import network

wlan = network.WLAN(network.STA_IF)

def connect_wifi():
    """Подключается к локальной вайфай-сети."""
    wlan.active(True)

    if not wlan.isconnected():
        wlan.connect(WIFI_SSID, WIFI_PASS)
        # Ждем соединения
        while not wlan.isconnected():
            time.sleep(0.5)

И создаем метод для отправки показаний:

def upload_to_gmcmap():
    """Отправляет текущие значения CPM и uSv на сервер GMCmap."""
    if not wlan.isconnected():
        # Переподключаемся при необходимости
        wlan.connect(WIFI_SSID, WIFI_PASS)
        return

    global cpm_value

    usv_h = calculate_usv_value(cpm_value)
    cpm_avg = calculate_average_cpm(minutes=5)

    try:
        # Формируем URL
        url = f"http://www.GMCmap.com/log2.asp?AID={GMC\\_AID}&GID={GMC\\_GID}&CPM={cpm\\_value}&ACPM={cpm\\_avg}&uSV={usv\\_h:.4f}"
        # Отправляем HTTP GET-запрос
        response = urequests.get(url)
        response.close()
    except Exception as exc:
        print(f"Ошибка отправки: {exc}")

Как видите, данные уходят самым обычным GET-запросом (да-да, именно GET, а не POST). Протокол HTTP используется без шифрования (без https): API изначально разрабатывалось под простые счетчики GMC с весьма слабыми микроконтроллерами.

Встроим вызов метода в главный цикл. Сервер GMCMap принимает данные не чаще одного раза в 2–5 минут, поэтому отслеживаем время последнего запроса:

last_upload_time = time.ticks_ms()

while True:
    calculate_cps()

    # Обновляем экран каждую секунду
    if screen_needs_update:
        update_display()
        screen_needs_update = False

    # Периодически отправляем данные на GMCMap
    current_time = time.ticks_ms()
    if time.ticks_diff(current_time, last_upload_time) >= GMC_UPLOAD_INTERVAL_MS:
        upload_to_gmcmap()
        last_upload_time = current_time

Запускаем ESP32 и наблюдаем график на сайте:

Карта открыта для всех, поэтому мониторить обстановку дома или на даче теперь можно из любой точки мира. Кроме того, ваши данные помогают общей статистике. Иногда случаются и аномалии. Однажды я заметил всплеск радиационного фона в 1,5–2 раза выше нормы, который держался несколько часов. Скорее всего, причиной была вспышка на Солнце – во всяком случае, тревожных новостей или экстренных рассылок в тот день не было.

Программная часть позади – самое время перейти к наглядным экспериментам.

❯ 4. Эксперименты

Посмотрим на наш самодельный прибор в деле. Я покажу три простых, но очень показательных опыта.

Естественный радиационный фон

Первый опыт предельно прост: оставляем прибор в покое и наблюдаем за графиком:

Перед нами – тот самый естественный фоновый уровень. Из этого наблюдения можно сделать несколько важных выводов:

  • Радиация вокруг нас есть всегда, фон никогда не равен нулю. Трубка СБМ-20 улавливает около 20 частиц в минуту, а значит, сквозь наши тела каждую секунду пролетают тысячи радиоактивных частиц. Эта радиация называется ионизирующей, так как ее энергии достаточно для ионизации атомов и разрушения химических связей. К счастью, клетки нашего организма умеют эффективно восстанавливать поврежденную ДНК. Ощутимый вред здоровью и повышение риска онкологии начинаются лишь при дозах от 100 миллизивертов (100 000 микрозивертов).

  • Радиоактивный распад – процесс абсолютно случайный, в отличие от программных генераторов псевдослучайных чисел. Небольшая доработка кода – и у вас в руках настоящий физический генератор истинно случайных чисел или сверхнадежных паролей (оставляю эту идею читателям в качестве домашнего задания).

  • Если взять этот дозиметр с собой в самолет (только предварительно собрав его в цивильный корпус, чтобы не напугать службу безопасности в аэропорту), вы увидите, что на высоте 10 000 метров уровень радиации возрастает примерно в 10 раз из-за космического излучения.

Бета- и гамма-излучение

Экран показывает количество частиц, но сами по себе они бывают разными по своей природе:

  • Альфа-частицы – это тяжелые ядра гелия. Их излучают некоторые изотопы (например, америций) и урановые минералы. У альфа-частиц огромная энергия, но мизерная проникающая способность: их задерживает даже обычный лист бумаги.

  • Бета-излучение – поток быстрых электронов. Проникают глубже, но их можно экранировать тонким листом алюминия, пластиком или оргстеклом.

  • Гамма-излучение – высокоэнергетические фотоны (по сути, свет, но с ультракороткой длиной волны). Гамма-кванты легко прошивают бумагу и пластик; остановить их может только толстый слой свинца, бетона или воды.

Трубка СБМ-20 нечувствительна к альфа-частицам (их не пропускает металлический корпус), но разделить бета- и гамма-составляющие мы вполне можем. Для второго опыта я взял баночку, покрытую урановой глазурью. Она активно излучает бета-частицы и немного гаммы. Теперь накроем банку обычным пластиковым проездным:

Результат налицо! Хотя глазами поток электронов не увидеть, график отчетливо показывает падение уровня радиации примерно на 50%. Это значит, что около половины излучения банки приходилось на бета-частицы, застрявшие в пластике, а вторая половина – проникающая гамма.

Охота на радон

Третий эксперимент имеет практическую пользу, хотя его результаты могут немного напугать. Радон – это тяжелый радиоактивный газ, выделяемый земной корой, гранитом и строительными материалами. Он присутствует практически в любом доме, а по данным ВОЗ именно радон является второй по частоте причиной рака легких после курения.

Сам опыт до смешного прост: надеваем плотный носок на трубу пылесоса и оставляем его работать минут на 10, гоняя комнатный воздух через ткань:

Затем снимаем носок и прикладываем его к трубке Гейгера. С высокой долей вероятности показания прибора подскочат! Чтобы исключить погрешность, я взял более чувствительный прибор GMC – после 5 минут прокачки воздуха ткань носка фонила в 3,5 раза сильнее нормы:

Откуда взялась радиация? Пылесос собрал на ткани дочерние продукты распада радона – короткоживущие изотопы полония, свинца и висмута. Сознание того, что эти частицы постоянно летают в воздухе и оседают в легких, немного настораживает, но регулярное проветривание отлично решает эту проблему.


В этой статье мы собрали портативный IoT-детектор радиации на ESP32, написали прошивку на MicroPython и провели несколько опытов. Спасибо за внимание!

Может быть интересно:
Перейти ↩

Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram‑канале