Радиация окружает нас повсюду – естественный радиоактивный фон присутствует всегда и везде. Имея под рукой плату 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 Pingeiger_input = Pin(18, Pin.IN, None)
Третий параметр крайне важен. На плате счетчика уже установлен подтягивающий резистор, поэтому внутреннюю подтяжку ESP32 нужно отключить (None), иначе импульсы просто не будут регистрироваться.
Теперь настроим прерывание для перехвата импульсов:
geiger_input_bounce_ms = 2total_pulses = 0last_trigger_time = 0def 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_timegeiger_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 = 0last_pulse_count = 0last_second_time = time.ticks_ms()uptime_seconds = 0screen_needs_update = Falsedef 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 = 600cps_history = [0] * HISTORY_LENGTH
В списке хранятся данные за последние 10 минут (600 секунд) – это пригодится и для последующей отправки на GMCMap. При получении нового значения просто сдвигаем буфер:
cps_history.pop(0)cps_history.append(pulses_this_sec)
Имея скользящее окно данных, рассчитать CPM не составляет труда:
# Расчет CPMif 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 ssd1306from machine import SoftI2Coled_width: int = 128oled_height: int = 64i2c = 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 Writerimport font_bigwriter_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 IDGMC_GID = "xxxxxxxxxx" # GMCMap Geiger Counter IDGMC_UPLOAD_INTERVAL_MS = 2 * 60_000
Напишем функцию подключения к вайфаю:
import networkwlan = 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‑канале ↩
ссылка на оригинал статьи https://habr.com/ru/articles/1067542/