От скрипта к приложению
Типичная история начинается с файла analysis.py на пятьдесят строк, написанного вечером перед семинаром: загрузить осциллограммы, вычесть базовую линию, найти пик, построить график. Скрипт работает, график попадает в отчёт.
Дальнейшее развитие событий, как правило, выглядит следующим образом:
- через полгода в скрипте, разросшемся до 800 строк, появляются калибровка, три формата входных файлов и ветка
if experiment == "march":; - рядом лежат
analysis_new.py,analysis_final.pyиanalysis_final_v2_REAL.py, и никто не помнит, чем они отличаются; - пути к данным (
/home/vasya/data/2026-03-12/) и параметры установки зашиты в код, и перед каждым запуском их правят в исходнике; - на другой машине скрипт не запускается, и начинается выяснение версий numpy;
- приходит новый студент, и автору скрипта приходится полчаса объяснять, какие строки закомментировать, чтобы «просто посчитать спектр».
Одноразовый скрипт является нормальным жанром. Проблемы начинаются, когда код, написанный на один раз, незаметно становится многоразовым: его использует вся группа, на него ссылаются в статьях, а устроен он по-прежнему как черновик.
Разница между скриптом и приложением заключается не в количестве строк, а в свойствах:
- приложение устанавливается на чистую машину одной-двумя командами;
- его запускают, не открывая исходники в редакторе, и запуск описан в README;
- оно внятно сообщает об ошибках и ведёт журнал, сохраняющийся после завершения запуска;
- его можно изменять, не опасаясь незаметно нарушить физику, поскольку за этим следят тесты;
- в нём способен разобраться другой человек или сам автор через полгода.
Глава про жизненный цикл ПО рассматривала разработку сверху. В настоящей главе весь путь проходится на одном примере: скрипт обработки осциллограмм превращается в пакет beamtool для всей группы.
В примерах встретится NumPy: loadtxt читает таблицу чисел из файла, argmax возвращает индекс максимума, trapezoid вычисляет интеграл методом трапеций. Устройство массивов разбирается в главе «NumPy и pandas»; здесь важна обвязка вокруг библиотеки.
Структура проекта
Любой файл .py представляет собой модуль, пригодный для импорта. Каталог с файлом __init__.py является пакетом, набором модулей под общим именем. Как только код перестаёт помещаться в один файл, его раскладывают по модулям по смыслу: чтение данных отдельно, формулы отдельно, графики отдельно. На один модуль приходится одна зона ответственности.
Типовая структура:
beamtool/ # корень git-репозитория
├── pyproject.toml # метаданные проекта и зависимости
├── README.md # что это, как поставить, как запустить
├── .gitignore
├── configs/
│ └── default.toml # параметры обработки по умолчанию
├── src/
│ └── beamtool/ # сам пакет
│ ├── __init__.py
│ ├── io.py # чтение и запись данных
│ ├── physics.py # формулы и численные методы
│ ├── plotting.py # вся работа с matplotlib
│ └── cli.py # интерфейс командной строки
└── tests/
├── test_io.py
└── test_physics.py
Это src-layout: пакет расположен в каталоге src/. Существует и плоский вариант (flat layout), при котором beamtool/ лежит в корне репозитория; для небольших проектов допустим и он. Преимущество src-layout заключается в том, что пакет нельзя случайно импортировать из рабочего каталога, минуя установку, и тесты проверяют именно то, что получит пользователь.
Сырые данные с установки в репозиторий не помещают: git плохо переносит гигабайты бинарных файлов, а история изменений для них бессмысленна. Данные хранятся на сервере или дисках группы, в репозитории остаются путь к ним в конфигурации и небольшие файлы-образцы для тестов (tests/data/). Каталоги data/, .venv/, __pycache__/ и файлы *.log необходимо добавить в .gitignore до первого коммита, иначе в историю попадут два гигабайта данных.
Виртуальные окружения и зависимости
Системным Python пользуются все программы на машине, и обновление библиотеки ради одного проекта нарушает работу другого. Поэтому у каждого проекта должно быть своё виртуальное окружение — набор пакетов, изолированный от системного.
cd beamtool
python -m venv .venv
source .venv/bin/activate # в Windows: .venv\Scripts\activate
pip install numpy matplotlib
Окружение является расходным материалом: повреждённое окружение удаляется вместе с каталогом .venv и создаётся заново за минуту. Это работает только при условии, что список зависимостей хранится в репозитории. Способов записать его два.
Классический способ — файл requirements.txt, список, передаваемый команде pip install -r requirements.txt.
numpy==2.1.3
matplotlib==3.9.2
Современный способ — секция dependencies в pyproject.toml, едином файле с метаданными проекта (он рассматривается в разделе про упаковку). Для нового проекта предпочтительнее pyproject.toml; requirements.txt остаётся как точный слепок собранного окружения.
Режимов закрепления версий два. В библиотеке, устанавливаемой другими, версии ограничивают мягко (numpy>=2.0), чтобы не конфликтовать с чужими зависимостями. В приложении для обработки данных важнее воспроизводимость: pip freeze > requirements.txt фиксирует всё окружение до последней цифры, что позволяет через год пересоздать его и повторить расчёт из статьи. Обновление той же scipy иногда изменяет численные результаты в последних знаках, а для науки это является основанием зафиксировать версии жёстко.
Вместо связки venv + pip можно использовать менеджер проектов, poetry или uv. Они выполняют те же действия: окружение, зависимости и lock-файл с точными версиями, но одной командой и с автоматическим разрешением конфликтов; uv, написанный на Rust, к тому же работает на порядок быстрее pip. Начинать следует с venv и pip, чтобы понимать механику, а переход на менеджеры целесообразен тогда, когда становится ясно, какую рутину они устраняют.
Точки входа
Модуль должен переживать импорт без побочных эффектов: при import beamtool.cli ничего не должно вычисляться, отрисовываться и записываться на диск. Исполняемый код закрывается стандартной конструкцией:
if __name__ == "__main__":
main()
Переменная __name__ равна "__main__" только у файла, запущенного как программа, а не импортированного. Это уже входило в наши требования к коду; следующий шаг — интерфейс командной строки, чтобы параметры передавались аргументами, а не правкой исходника. В стандартной библиотеке для этого предусмотрен модуль argparse:
"""Интерфейс командной строки beamtool."""
import argparse
from pathlib import Path
import numpy as np
def parse_args() -> argparse.Namespace:
"""Разбирает аргументы командной строки."""
parser = argparse.ArgumentParser(
prog="beamtool",
description="Обработка осциллограмм: базовая линия, пик, заряд.",
)
parser.add_argument(
"--input", type=Path, required=True,
help="входной CSV-файл с колонками time, voltage",
)
parser.add_argument(
"--output", type=Path, required=True,
help="файл для записи результатов",
)
parser.add_argument(
"--plot", action="store_true",
help="показать график после обработки",
)
parser.add_argument(
"--baseline-points", type=int, default=100,
help="сколько первых точек считать базовой линией",
)
return parser.parse_args()
def main() -> None:
"""Точка входа приложения."""
args = parse_args()
time, voltage = np.loadtxt(
args.input, delimiter=",", skiprows=1, unpack=True)
# Базовая линия — среднее по первым точкам до прихода сигнала.
baseline = voltage[:args.baseline_points].mean()
signal = voltage - baseline
peak_index = int(np.argmax(signal))
area = float(np.trapezoid(signal, time))
np.savetxt(
args.output,
[[time[peak_index], signal[peak_index], area]],
delimiter=",",
header="peak_time,peak_voltage,area",
)
if args.plot:
# Ленивый импорт: без --plot matplotlib даже не загружается,
# и скрипт работает на сервере без дисплея.
from beamtool.plotting import show_signal
show_signal(time, signal, peak_index)
if __name__ == "__main__":
main()
Запуск: python -m beamtool.cli --input shot_042.csv --output results.csv --plot. Команда заработает после установки пакета в окружение (pip install -e ., см. раздел про упаковку); до установки при src-layout запуск осуществляется командой PYTHONPATH=src python -m beamtool.cli …. Дополнительно предоставляется --help: argparse самостоятельно генерирует справку, проверяет обязательные аргументы и приводит типы к объявленным. Пользователю не требуется открывать код, а значит, он не сможет случайно его повредить.
Для интерфейсов с подкомандами (как git commit, git push) существуют сторонние библиотеки click и typer, решающие ту же задачу декораторами и аннотациями типов. Однако argparse из стандартной библиотеки достаточно на долгое время, и он всегда доступен.
Конфигурация
Магические числа и пути в коде являются основным источником хаоса в лабораторных скриптах. Порог дискриминатора, частота дискретизации АЦП, путь к данным меняются от запуска к запуску и, будучи зашитыми в исходник, требуют правки кода. История в git превращается в набор записей «поменял порог обратно», а воспроизводимость теряется, потому что никто не помнит, с какими параметрами получен график из статьи.
Код отвечает на вопрос «как считать», конфигурация — «что и с какими параметрами». Вынесенные параметры помещают в конфигурационный файл; удобным форматом является TOML, читаемый стандартной библиотекой (Python 3.11+).
# configs/default.toml
[detector]
sampling_rate_hz = 2.5e9 # частота дискретизации АЦП
impedance_ohm = 50.0
[analysis]
baseline_points = 100 # точек на оценку базовой линии
peak_threshold_v = 0.05 # порог отбора событий
import tomllib
with open("configs/default.toml", "rb") as f:
config = tomllib.load(f)
threshold = config["analysis"]["peak_threshold_v"]
YAML устроен аналогично (требуется пакет pyyaml); достаточно выбрать один формат и придерживаться его. Конфигурация в репозитории образует историю параметров обработки: видно, кем, когда и на сколько сдвинут любой порог.
Для того, что зависит от конкретной машины или является секретным, используют переменные окружения:
import os
data_dir = os.environ.get("BEAMTOOL_DATA_DIR", "/data/beam")
Пароли и токены, например доступ к базе данных группы (о них в главе про базы данных), не попадают ни в код, ни в git. Допустимы только переменные окружения или локальный файл .env, добавленный в .gitignore. Общепринятый приоритет источников: значения по умолчанию → конфигурационный файл → переменные окружения → аргументы командной строки; каждый следующий уровень перекрывает предыдущий.
Логирование вместо print
print пригоден для кратковременной отладки. Далее возникают проблемы: отладочный вывод, разбросанный по коду, нельзя отключить, не удаляя его; у сообщений нет ни времени, ни уровня важности; всё уходит в консоль и исчезает вместе с закрытым окном. Когда же ночная обработка останавливается с ошибкой на 3742-м файле из 5000, необходим журнал, сохранивший, что происходило, с какими файлами и когда.
Всё это обеспечивает logging из стандартной библиотеки. У сообщений пять уровней (DEBUG, INFO, WARNING, ERROR, CRITICAL) и глобальный порог, определяющий, что показывать. Настройка, пишущая одновременно в консоль и в файл:
import logging
logger = logging.getLogger(__name__)
def setup_logging(logfile: str = "beamtool.log") -> None:
"""Включает вывод логов в консоль и в файл одновременно."""
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)-8s %(name)s: %(message)s",
handlers=[
logging.StreamHandler(),
logging.FileHandler(logfile, encoding="utf-8"),
],
)
Использование в коде:
logger.info("Загружен файл %s: %d точек", path, n_points)
logger.warning("Пик ниже порога %.3f В, файл пропущен", threshold)
logger.error("Не удалось разобрать файл %s", path, exc_info=True)
getLogger(__name__) в каждом модуле создаёт именованный логгер, и в журнале видно, какой модуль пишет. Флаг --verbose в CLI может понижать порог до DEBUG, не затрагивая код; отладочные сообщения пишутся всегда, а показываются по требованию. exc_info=True добавляет к сообщению полный traceback, так что наутро в журнале обнаружится не только «файл пропущен», но и причина.
Обработка ошибок
Главным принципом научного кода является fail fast: при обнаружении некорректных данных программа должна останавливаться сразу и явно. Тихий NaN проходит через все стадии обработки и превращается в правдоподобный, но неверный график. Остановившуюся программу исправляют; красивую кривую с ошибкой отправляют в статью.
Python сообщает об ошибках исключениями, и разработчик может определять собственные: это обычные классы, наследуемые от Exception.
import numpy as np
class BeamtoolError(Exception):
"""Базовый класс ошибок beamtool."""
class InvalidSignalError(BeamtoolError):
"""Сигнал не пригоден для обработки."""
def find_peak(signal: np.ndarray) -> int:
"""Возвращает индекс максимума сигнала."""
if signal.size == 0:
raise InvalidSignalError("пустой сигнал")
if not np.all(np.isfinite(signal)):
raise InvalidSignalError("в сигнале есть NaN или inf")
return int(np.argmax(signal))
Собственные классы отделяют ожидаемые ошибки от дефектов кода. На верхнем уровне, в main(), перехватывается except BeamtoolError, и некорректный файл в пачке из тысячи оказывается штатной ситуацией: запись в журнал и переход к следующему. KeyError или TypeError, напротив, означают дефект в самом коде; такое исключение должно пройти наружу с полным traceback, чтобы его исправили.
Худшее, что можно сделать с исключением, — подавить его:
# Так делать нельзя: ошибка исчезает бесследно,
# а в результатах молча появляется дыра.
try:
result = process(path)
except Exception:
pass
Исключение следует перехватывать только там, где известно, что с ним делать, а в остальных местах не перехватывать.
Тесты
Научному коду тесты необходимы в большей степени, чем сайтам и мессенджерам. Главный риск здесь не «программа остановилась с ошибкой», а «программа выдала правдоподобное, но неверное число». Особенно опасны рефакторинги формул: выражение «слегка упростили», потеряли двойку в знаменателе, все результаты сместились, а код работает без единой ошибки. Тесты фиксируют поведение, и любое изменение, сдвинувшее численный результат, обнаруживается немедленно, что является защитой от регрессий.
Стандартом де-факто является pytest: тесты лежат в tests/test_*.py, называются test_* и состоят из обычных assert. Рассмотрим функцию из beamtool:
# src/beamtool/physics.py
ELECTRON_REST_ENERGY_MEV = 0.511
def lorentz_gamma(kinetic_energy_mev: float) -> float:
"""Возвращает лоренц-фактор по кинетической энергии электрона."""
if kinetic_energy_mev < 0:
raise ValueError("кинетическая энергия отрицательна")
return 1.0 + kinetic_energy_mev / ELECTRON_REST_ENERGY_MEV
И тесты к ней:
# tests/test_physics.py
import pytest
from beamtool.physics import lorentz_gamma
def test_gamma_at_rest():
# Покоящийся электрон: гамма-фактор равен единице.
assert lorentz_gamma(0.0) == pytest.approx(1.0)
def test_gamma_one_mev():
assert lorentz_gamma(1.0) == pytest.approx(2.9569, abs=1e-4)
def test_negative_energy_rejected():
with pytest.raises(ValueError):
lorentz_gamma(-1.0)
Команда pytest в корне проекта самостоятельно находит и выполняет все тесты. Сравнивать float через == нельзя из-за ошибок округления в последних знаках; pytest.approx сравнивает с заданным допуском, абсолютным (abs=) или относительным (rel=).
В первую очередь тестируют функции-формулы на входах с известным ответом (аналитические пределы, симметрии, законы сохранения), парсеры форматов данных на файлах-образцах из tests/data/, граничные случаи (пустой сигнал, один отсчёт, отрицательная энергия). Для численных методов решатель прогоняют на упрощённой задаче с аналитическим решением и сравнивают результаты.
Типизация
Аннотации типов присутствовали во всех примерах выше:
def rebin(spectrum: np.ndarray, factor: int = 2) -> np.ndarray:
"""Огрубляет спектр, суммируя соседние каналы."""
n_bins = spectrum.size // factor * factor
return spectrum[:n_bins].reshape(-1, factor).sum(axis=1)
Во время выполнения Python их не проверяет: аннотации представляют собой документацию, читаемую машиной. Редактор подсказывает атрибуты и обнаруживает опечатки, а статический анализатор mypy командой mypy src/ находит несоответствия до запуска: в одном месте передан str вместо Path, в другом функция может вернуть None, а вызывающий код об этом не знает. Аннотировать имеет смысл хотя бы функции публичного интерфейса пакета, а mypy добавить в CI рядом с линтерами.
Документация
Docstring у каждой публичной функции сообщает, что она делает, что принимает, что возвращает и в каких единицах (перепутанные мэВ и МэВ не обнаружит ни один тайпчекер). README в корне репозитория отвечает на три вопроса: что это за проект, как его установить, как запустить, и содержит один работающий пример команды; это первое, что увидит коллега, и часто единственное, что он прочтёт. Когда проект вырастает, из docstring'ов и markdown-файлов собирают сайт документации при помощи mkdocs или sphinx; документация хранится в том же репозитории и обновляется вместе с кодом, а не в отдельном текстовом документе на сетевом диске.
Упаковка и распространение
Центральным файлом современного Python-проекта является pyproject.toml.
[project]
name = "beamtool"
version = "0.1.0"
description = "Обработка осциллограмм с датчиков пучка"
requires-python = ">=3.11"
dependencies = [
"numpy>=2.0",
"matplotlib>=3.9",
]
[project.scripts]
beamtool = "beamtool.cli:main"
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
Установка в окружение в режиме разработки:
pip install -e .
Флаг -e (editable) устанавливает пакет ссылкой на рабочий каталог, и правки видны сразу, без переустановки. После установки работают импорты from beamtool.physics import ... из любого места, в том числе из тестов. Секция [project.scripts] создаёт в окружении консольную команду, и функция main() из cli.py вызывается как beamtool --input shot_042.csv --output results.csv.
Коллегам из группы достаточно git-репозитория: pip install git+https://github.com/mygroup/beamtool устанавливает пакет со всеми объявленными зависимостями. Команда python -m build собирает wheel-файл в dist/, который можно приложить к релизу на GitHub или передать на машину без интернета. Публикация на PyPI через twine upload (или uv publish) требуется, когда инструментом предполагают пользоваться незнакомые автору люди; для внутренних инструментов группы она не обязательна.
Рост приложения в систему
Всё сказанное выше относится к одному инструменту, выполняющему одну функцию. Рано или поздно возникает задача, которую одним инструментом не решить, например провести эксперимент целиком: выставить токи магнитов, дождаться стабилизации, снять показания с десятка датчиков, вычислить результат, определить дальнейшее направление, повторить, и так сотни раз подряд, ночью, без человека у пульта.
Такую программу нельзя писать «плоско». Рассмотрим, как устроена библиотека SCAUT, оркеструющая эксперименты на линейном ускорителе инжектора ЦКП «СКИФ». Она согласованно управляет всеми компонентами установки, от магнитов до детекторов.
Ключевой идеей является разделение на слои, где каждый слой знает только о соседнем:
Интерфейс эксперимента что именно сканируем и в каких пределах
│
Управление сканированием планировщик точек, обработчики событий, обработка аварий
│
Абстракция оборудования «мотор» и «датчик» — независимо от того, что за железо
│
Коммуникация EPICS, Tango, OPC UA — или расчётная модель вместо железа
│
Данные сериализация, метаданные эксперимента, хранилище
│
Анализ визуализация, статистика, постобработка
Наиболее ценной границей является абстракция оборудования. Верхние слои оперируют понятиями «мотор» (нечто, принимающее заданное значение) и «датчик» (нечто, у чего значение можно запросить). Что стоит за мотором, источник питания квадруполя, шаговый двигатель экрана или регулятор фазы СВЧ, для процедуры сканирования безразлично. Добавление нового прибора не требует правок в логике эксперимента: пишется новый адаптер, и всё выше него остаётся нетронутым.
Ниже расположен слой коммуникации, отделяющий протокол от смысла. Ускорительными комплексами управляют разные системы, EPICS, Tango, OPC UA, и код эксперимента не должен знать, какая из них находится внизу. На место реального оборудования можно подставить расчётную модель. Тот же сценарий эксперимента, не изменённый ни в одной строке, прогоняется сначала на модели пучка, а потом на установке. Ошибка в логике сканирования обнаруживается на модели, где она ничего не стоит, а не в смену, где она стоит пучкового времени всей группы.
За то, чтобы результат сохранился после эксперимента, отвечает слой данных. Кроме измерений сохраняются метаданные: когда, кем, при каких настройках и какой версией кода они получены. Без этого через полгода из архива нельзя извлечь ничего, кроме массива чисел неизвестного происхождения.
Процедура сканирования всегда проходит одни и те же шесть этапов:
- Инициализация. Проверить, что необходимое оборудование на связи, выставить начальные параметры.
- Планирование. Построить последовательность точек в пространстве параметров.
- Выполнение. На каждом шаге выставить параметры и снять данные.
- Обработка событий. Вызвать пользовательские функции анализа.
- Сериализация. Сохранить результаты и метаданные.
- Завершение. Освободить ресурсы, сформировать отчёт.
Первый и последний пункты выглядят формальностью до первого ночного прогона, прервавшегося на середине. Если инициализация не проверила связь с прибором, эксперимент отработает четыре часа, записав в файл одни нули. Если завершение не освободило ресурсы, следующий запуск не подключится к оборудованию, потому что незавершившийся процесс всё ещё удерживает соединение.
Принцип формулируется так: границы проводят там, где ожидаются изменения. Оборудование на установке меняется, поэтому необходим слой его абстракции. Протоколы управления в разных лабораториях разные, отсюда отдельный слой коммуникации. Чаще всего меняется способ анализа данных, и потому анализ, отделённый от сбора, можно переписывать, не затрагивая остального. Слой, за которым ничего не меняется, только мешает, и вводить его «на будущее» не следует.
Чек-лист: скрипт стал приложением
- Код разложен по модулям со смыслом: ввод-вывод, физика, графика, CLI.
-
Проект устанавливается на чистую машину командами
git cloneиpip install -e .. -
Зависимости с версиями записаны в
pyproject.toml, точный слепок собранного окружения можно получить и восстановить. - Запуск не требует открывать исходники, все параметры передаются через аргументы CLI и конфигурационный файл.
- Пути, калибровки и пороги хранятся в конфигурации, а не в коде; секретов в репозитории нет.
-
Вместо
printиспользуетсяlogging, и после ночного прогона остаётся журнал, размеченный временем и уровнями. - Некорректные данные вызывают внятную ошибку сразу, а не тихий NaN, всплывающий в результатах.
-
Ключевые формулы покрыты тестами;
pytestпроходит перед каждым коммитом. -
Публичные функции аннотированы типами и снабжены docstring;
mypyне выдаёт замечаний. - README отвечает на вопросы «что это», «как поставить», «как запустить».
-
Файл
analysis_final_v2_REAL.pyудалён, поскольку версии хранятся в git, а не в именах файлов.
Нет необходимости выполнять всё в первый же вечер. Структура, окружение и git необходимы сразу, они почти ничего не стоят. Конфигурация, логирование и CLI появляются у скрипта, запускаемого чаще раза в неделю. Тесты приходят, как только результатам начинают доверять другие люди. Превращение скрипта в приложение является не событием, а привычкой: вынести константу в конфигурацию, превратить обнаруженный дефект в тест. Сети и хранилище результатов рассматриваются в следующих главах, а развёртывание собственного сервиса — в главе про асинхронность.
Задание. Довести чужой недописанный проект до состояния, в котором его можно развернуть: «Деплой стартапа „Котики в мир“».