diff --git a/NN_server/server.py b/NN_server/server.py index 067f849..0c7ef5f 100644 --- a/NN_server/server.py +++ b/NN_server/server.py @@ -1,9 +1,13 @@ from flask import Flask, request, jsonify -from dotenv import dotenv_values +from dotenv import dotenv_values, load_dotenv +from common.nn_profile_schedule import ( + get_profile_model_entries, + normalize_profile_name, + resolve_active_profile, +) from common.runtime import load_root_env, validate_env, as_int, as_str import os import sys -import re import matplotlib.pyplot as plt from Model import Model import numpy as np @@ -16,6 +20,7 @@ import shutil import json import gc import logging +from pathlib import Path TORCHSIG_PATH = "/app/torchsig" if TORCHSIG_PATH not in sys.path: @@ -39,6 +44,9 @@ plt.ioff() alg_list = [] model_list = [] ROOT_ENV = load_root_env(__file__) +RUNTIME_ENV = Path(ROOT_ENV).parent / "runtime" / "nn_active_profile.env" +if RUNTIME_ENV.exists(): + load_dotenv(RUNTIME_ENV, override=True) validate_env("NN_server/server.py", { "GENERAL_SERVER_IP": as_str, "GENERAL_SERVER_PORT": as_int, @@ -49,10 +57,8 @@ validate_env("NN_server/server.py", { "SRC_EXAMPLE": as_str, }) config = dict(dotenv_values(ROOT_ENV)) - - -def is_model_config_key(key, value): - return bool(re.fullmatch(r"NN_\d+", key or "")) and isinstance(value, str) and " && " in value +if RUNTIME_ENV.exists(): + config.update(dotenv_values(RUNTIME_ENV)) def get_required_drone_streak(freq): @@ -85,11 +91,20 @@ def update_drone_streak(freq, prediction): if not config: raise RuntimeError("[NN_server/server.py] .env was loaded but no keys were parsed") -if not any(is_model_config_key(key, value) for key, value in config.items()): - raise RuntimeError("[NN_server/server.py] no NN_* model entries configured") logging.info("NN config loaded from %s", ROOT_ENV) +if RUNTIME_ENV.exists(): + logging.info("NN runtime overrides loaded from %s", RUNTIME_ENV) gen_server_ip = config['GENERAL_SERVER_IP'] gen_server_port = config['GENERAL_SERVER_PORT'] +requested_profile = normalize_profile_name(config.get("NN_ACTIVE_PROFILE")) +active_profile = resolve_active_profile({k: v for k, v in config.items() if k != "NN_SCHEDULE"}) +if requested_profile != active_profile: + logging.warning( + "Requested NN profile %s is not configured, falling back to %s", + requested_profile, + active_profile, + ) +logging.info("NN active profile: %s", active_profile) drone_streaks = {} def init_data_for_inference(): @@ -106,29 +121,32 @@ def init_data_for_inference(): try: global model_list - for key, value in config.items(): - if is_model_config_key(key, value): - params = value.split(' && ') - module = importlib.import_module('Models.' + params[4]) - classes = {} - for value in params[9][1:-1].split(','): - classes[len(classes)] = value - model = Model(file_model=params[0], file_config=params[1], src_example=params[2], src_result=params[3], - type_model=params[4], build_model_func=getattr(module, params[5]), - pre_func=getattr(module, params[6]), inference_func=getattr(module, params[7]), - post_func=getattr(module, params[8]), classes=classes, number_synthetic_examples=int(params[10]), - number_src_data_for_one_synthetic_example=int(params[11]), path_to_src_dataset=params[12]) - model_list.append(model) - # if key.startswith('ALG_'): - # params = config[key].split(' && ') - # module = importlib.import_module('Algorithms.' + params[2]) - # classes = {} - # for value in params[6][1:-1].split(','): - # classes[len(classes)] = value - # alg = Algorithm(src_example=params[0], src_result=params[1], type_alg=params[2], pre_func=getattr(module, params[3]), - # inference_func=getattr(module, params[4]), post_func=getattr(module, params[5]), classes=classes, - # number_synthetic_examples=int(params[7]), number_src_data_for_one_synthetic_example=int(params[8]), path_to_src_dataset=params[9]) - # alg_list.append(alg) + model_list = [] + loaded_model_keys = [] + model_entries = get_profile_model_entries(config, active_profile) + if not model_entries: + raise RuntimeError(f"[NN_server/server.py] no models configured for profile {active_profile!r}") + + for key, value in model_entries: + params = value.split(' && ') + module = importlib.import_module('Models.' + params[4]) + classes = {} + for value in params[9][1:-1].split(','): + classes[len(classes)] = value + model = Model(file_model=params[0], file_config=params[1], src_example=params[2], src_result=params[3], + type_model=params[4], build_model_func=getattr(module, params[5]), + pre_func=getattr(module, params[6]), inference_func=getattr(module, params[7]), + post_func=getattr(module, params[8]), classes=classes, number_synthetic_examples=int(params[10]), + number_src_data_for_one_synthetic_example=int(params[11]), path_to_src_dataset=params[12]) + model_list.append(model) + loaded_model_keys.append(key) + + logging.info( + "Loaded %s NN models for profile %s: %s", + len(model_list), + active_profile, + ", ".join(loaded_model_keys), + ) except Exception as exc: print(str(exc)) print() diff --git a/README.md b/README.md index 61f3937..45c08b7 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ chmod +x install_all.sh ### Docker Compose - `dronedetector-server-to-master` -> `src/server_to_master.py` - `dronedetector-nn-server` -> `NN_server/server.py` +- `dronedetector-telemetry-server` -> `telemetry/telemetry_server.py` Compose unit: - `dronedetector-compose.service` @@ -55,6 +56,47 @@ Compose unit: Все entrypoint'ы загружают root `.env` через `common/runtime.py` и валидируют обязательные переменные. При ошибке сервис падает сразу с понятным сообщением. +### Переключение NN-профилей по расписанию + +`NN_server` поднимает один активный профиль моделей на весь процесс. + +Базовый профиль: +```bash +NN_1=... +NN_21=... +NN_22=... +``` + +Именованные профили: +```bash +NN_PROFILE_DAY_1=... +NN_PROFILE_DAY_21=... +NN_PROFILE_DAY_22=... + +NN_PROFILE_NIGHT_1=... +NN_PROFILE_NIGHT_21=... +NN_PROFILE_NIGHT_22=... +``` + +Управляющие ключи: +```bash +NN_ACTIVE_PROFILE=DEFAULT +NN_SCHEDULE=08:00-20:00=DAY;20:00-08:00=NIGHT +``` + +Правила: +- если `NN_SCHEDULE` пустой, используется `NN_ACTIVE_PROFILE` +- если `NN_ACTIVE_PROFILE` не задан, используется `DEFAULT` +- если выбранный профиль отсутствует, `NN_server` откатывается на `DEFAULT` +- формат расписания: `HH:MM-HH:MM=PROFILE` +- поддерживаются интервалы через полночь + +Host-side переключатель: +- `scripts/nn_profile_switcher.py` +- хранит applied profile в `runtime/nn_active_profile.env` +- при смене профиля делает `docker compose restart dronedetector-nn-server` +- запускается через `dronedetector-nn-profile-switch.timer` + ## 4. API (без изменения контрактов) - NN_server: `POST /receive_data` @@ -70,6 +112,8 @@ systemctl status dronedetector-sdr-*.service journalctl -u dronedetector-sdr-868.service -n 200 --no-pager systemctl status dronedetector-compose.service journalctl -u dronedetector-compose.service -n 200 --no-pager +systemctl status dronedetector-nn-profile-switch.timer +journalctl -u dronedetector-nn-profile-switch.service -n 100 --no-pager ``` ### docker compose @@ -105,6 +149,7 @@ SDR precheck перед каждым unit запуском: - Docker Engine (если отсутствует) - NVIDIA Container Toolkit - `docker compose up -d --build` +- применение активного NN-профиля через switcher - установка unit'ов в `/etc/systemd/system` - verify + авто-логи при ошибке @@ -123,7 +168,7 @@ SDR precheck перед каждым unit запуском: ## 9. Ручная приемка 1. `./install_all.sh` выполняется до конца. -2. `docker compose -f deploy/docker/docker-compose.yml up -d` поднимает оба контейнера. +2. `docker compose -f deploy/docker/docker-compose.yml up -d` поднимает все Docker-сервисы. 3. Все `dronedetector-sdr-*` имеют `active (running)`. 4. Тестовый POST в `NN_server /receive_data` доходит до `server_to_master /process_data`. 5. Контур работает минимум 1 минуту без падений. @@ -210,4 +255,4 @@ docker compose -f deploy/docker/docker-compose.yml logs --timestamps dronedetect sudo hackrf_spiflash -w hackrf_one_usb.bin ``` -./.venv-sdr/bin/python scripts_nn/data_saver_headless.py --serial 000 --freq 4500000000 --save-dir /home/sibscience-4/Dataset/3300 --file-tag DJI_3_ --samp-rate 20000000 --split-size 400000 --delay 0.1 --rf-gain 12 --if-gain 30 --bb-gain 36 \ No newline at end of file +./.venv-sdr/bin/python scripts_nn/data_saver_headless.py --serial 000 --freq 4500000000 --save-dir /home/sibscience-4/Dataset/3300 --file-tag DJI_3_ --samp-rate 20000000 --split-size 400000 --delay 0.1 --rf-gain 12 --if-gain 30 --bb-gain 36 diff --git a/common/nn_profile_schedule.py b/common/nn_profile_schedule.py new file mode 100644 index 0000000..f573edd --- /dev/null +++ b/common/nn_profile_schedule.py @@ -0,0 +1,191 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +import re +from typing import Mapping + + +DEFAULT_PROFILE = "DEFAULT" +MODEL_VALUE_SEPARATOR = " && " + +_DEFAULT_MODEL_KEY_RE = re.compile(r"^NN_(?P\d+)$") +_PROFILE_MODEL_KEY_RE = re.compile(r"^NN_PROFILE_(?P[A-Za-z0-9_]+)_(?P\d+)$") +_SCHEDULE_RULE_RE = re.compile( + r"^\s*(?P\d{2}:\d{2})\s*-\s*(?P\d{2}:\d{2})\s*=\s*(?P[A-Za-z0-9_]+)\s*$" +) + + +@dataclass(frozen=True) +class ScheduleRule: + start_minutes: int + end_minutes: int + profile: str + raw_rule: str + + +def normalize_profile_name(value: str | None) -> str: + normalized = str(value or "").strip().upper() + return normalized or DEFAULT_PROFILE + + +def load_simple_env_file(path: str | Path) -> dict[str, str]: + env_path = Path(path) + if not env_path.exists(): + return {} + + values: dict[str, str] = {} + for line in env_path.read_text(encoding="utf-8").splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + if stripped.startswith("export "): + stripped = stripped[len("export ") :].strip() + if "=" not in stripped: + continue + + key, raw_value = stripped.split("=", 1) + key = key.strip() + raw_value = raw_value.strip() + if ( + len(raw_value) >= 2 + and raw_value[0] == raw_value[-1] + and raw_value[0] in {"'", '"'} + ): + raw_value = raw_value[1:-1] + values[key] = raw_value + return values + + +def parse_schedule(schedule_raw: str | None) -> list[ScheduleRule]: + schedule_text = str(schedule_raw or "").strip() + if not schedule_text: + return [] + + rules: list[ScheduleRule] = [] + for raw_rule in schedule_text.split(";"): + candidate = raw_rule.strip() + if not candidate: + continue + + match = _SCHEDULE_RULE_RE.fullmatch(candidate) + if match is None: + raise ValueError(f"invalid NN_SCHEDULE rule: {candidate!r}") + + start_minutes = _parse_hhmm(match.group("start")) + end_minutes = _parse_hhmm(match.group("end")) + if start_minutes == end_minutes: + raise ValueError(f"invalid NN_SCHEDULE rule with zero-length interval: {candidate!r}") + + rules.append( + ScheduleRule( + start_minutes=start_minutes, + end_minutes=end_minutes, + profile=normalize_profile_name(match.group("profile")), + raw_rule=candidate, + ) + ) + + if not rules: + raise ValueError("NN_SCHEDULE does not contain any usable rules") + return rules + + +def get_requested_active_profile( + config: Mapping[str, str | None], now: datetime | None = None +) -> str: + default_profile = normalize_profile_name(config.get("NN_ACTIVE_PROFILE")) + rules = parse_schedule(config.get("NN_SCHEDULE")) + if not rules: + return default_profile + + current = now or datetime.now() + current_minutes = current.hour * 60 + current.minute + for rule in rules: + if _rule_matches(rule, current_minutes): + return rule.profile + return default_profile + + +def resolve_active_profile(config: Mapping[str, str | None], now: datetime | None = None) -> str: + available_profiles = get_available_profiles(config) + if not available_profiles: + raise ValueError("no NN_* model entries configured") + + requested_profile = get_requested_active_profile(config, now=now) + if requested_profile in available_profiles: + return requested_profile + if DEFAULT_PROFILE in available_profiles: + return DEFAULT_PROFILE + raise ValueError( + f"requested NN profile {requested_profile!r} is not configured and DEFAULT profile is unavailable" + ) + + +def get_available_profiles(config: Mapping[str, str | None]) -> set[str]: + profiles: set[str] = set() + for key, value in config.items(): + if not _is_model_value(value): + continue + + if _DEFAULT_MODEL_KEY_RE.fullmatch(key): + profiles.add(DEFAULT_PROFILE) + continue + + match = _PROFILE_MODEL_KEY_RE.fullmatch(key) + if match is not None: + profiles.add(normalize_profile_name(match.group("profile"))) + return profiles + + +def get_profile_model_entries( + config: Mapping[str, str | None], profile: str +) -> list[tuple[str, str]]: + selected_profile = normalize_profile_name(profile) + entries: list[tuple[int, str, str]] = [] + + for key, value in config.items(): + if not _is_model_value(value): + continue + + if selected_profile == DEFAULT_PROFILE: + match = _DEFAULT_MODEL_KEY_RE.fullmatch(key) + if match is None: + continue + entries.append((int(match.group("id")), key, value)) + continue + + match = _PROFILE_MODEL_KEY_RE.fullmatch(key) + if match is None: + continue + if normalize_profile_name(match.group("profile")) != selected_profile: + continue + logical_key = f"NN_{match.group('id')}" + entries.append((int(match.group("id")), logical_key, value)) + + entries.sort(key=lambda item: item[0]) + return [(logical_key, value) for _, logical_key, value in entries] + + +def format_active_profile_env(profile: str) -> str: + return f"NN_ACTIVE_PROFILE={normalize_profile_name(profile)}\n" + + +def _parse_hhmm(token: str) -> int: + hours_str, minutes_str = token.split(":", 1) + hours = int(hours_str) + minutes = int(minutes_str) + if not (0 <= hours <= 23 and 0 <= minutes <= 59): + raise ValueError(f"invalid time value {token!r}") + return hours * 60 + minutes + + +def _rule_matches(rule: ScheduleRule, current_minutes: int) -> bool: + if rule.start_minutes < rule.end_minutes: + return rule.start_minutes <= current_minutes < rule.end_minutes + return current_minutes >= rule.start_minutes or current_minutes < rule.end_minutes + + +def _is_model_value(value: str | None) -> bool: + return isinstance(value, str) and MODEL_VALUE_SEPARATOR in value diff --git a/deploy/docker/docker-compose.yml b/deploy/docker/docker-compose.yml index e217370..65298c8 100644 --- a/deploy/docker/docker-compose.yml +++ b/deploy/docker/docker-compose.yml @@ -48,6 +48,7 @@ services: - "8080:8080" volumes: - ../../.env:/app/.env:ro + - ../../runtime:/app/runtime - ../../NN_server:/app/NN_server - ../../common:/app/common - ../../train_scripts:/app/train_scripts:ro diff --git a/deploy/systemd/dronedetector-nn-profile-switch.service b/deploy/systemd/dronedetector-nn-profile-switch.service new file mode 100644 index 0000000..ce917eb --- /dev/null +++ b/deploy/systemd/dronedetector-nn-profile-switch.service @@ -0,0 +1,12 @@ +[Unit] +Description=DroneDetector NN Profile Switcher +After=network-online.target docker.service dronedetector-compose.service +Wants=network-online.target +Requires=docker.service +ConditionPathExists=__PROJECT_ROOT__/.env + +[Service] +Type=oneshot +WorkingDirectory=__PROJECT_ROOT__ +ExecCondition=/usr/bin/systemctl --quiet is-active dronedetector-compose.service +ExecStart=/usr/bin/python3 __PROJECT_ROOT__/scripts/nn_profile_switcher.py --project-root __PROJECT_ROOT__ diff --git a/deploy/systemd/dronedetector-nn-profile-switch.timer b/deploy/systemd/dronedetector-nn-profile-switch.timer new file mode 100644 index 0000000..e0e81cd --- /dev/null +++ b/deploy/systemd/dronedetector-nn-profile-switch.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Run DroneDetector NN Profile Switcher Every Minute + +[Timer] +OnBootSec=1min +OnUnitActiveSec=1min +Persistent=true +Unit=dronedetector-nn-profile-switch.service + +[Install] +WantedBy=timers.target diff --git a/install_all.sh b/install_all.sh index eef0613..23aebed 100755 --- a/install_all.sh +++ b/install_all.sh @@ -22,6 +22,9 @@ SDR_UNITS=( dronedetector-sdr-2400.service ) +NN_SWITCHER_SERVICE="dronedetector-nn-profile-switch.service" +NN_SWITCHER_TIMER="dronedetector-nn-profile-switch.timer" + log() { printf '[install_all] %s\n' "$*" } @@ -34,6 +37,8 @@ die() { print_failure_logs() { log "Collecting diagnostics..." systemctl --no-pager --full status dronedetector-compose.service || true + systemctl --no-pager --full status "${NN_SWITCHER_SERVICE}" || true + systemctl --no-pager --full status "${NN_SWITCHER_TIMER}" || true for unit in "${SDR_UNITS[@]}"; do systemctl --no-pager --full status "$unit" || true done @@ -45,6 +50,8 @@ print_failure_logs() { fi journalctl -u dronedetector-compose.service -n 150 --no-pager || true + journalctl -u "${NN_SWITCHER_SERVICE}" -n 150 --no-pager || true + journalctl -u "${NN_SWITCHER_TIMER}" -n 50 --no-pager || true for unit in "${SDR_UNITS[@]}"; do journalctl -u "$unit" -n 120 --no-pager || true done @@ -103,6 +110,11 @@ install_host_non_python_deps() { gr-osmosdr } +prepare_runtime_dir() { + log "Preparing runtime directory" + install -d -m 0775 -o "${RUN_USER}" -g "${RUN_GROUP}" "${PROJECT_ROOT}/runtime" +} + setup_sdr_python_env() { log "Setting up SDR python environment" local venv_path="${PROJECT_ROOT}/.venv-sdr" @@ -168,13 +180,19 @@ build_and_run_compose() { docker compose -f "$COMPOSE_FILE" up -d --build } +apply_nn_profile_schedule() { + log "Applying NN profile schedule" + python3 "${PROJECT_ROOT}/scripts/nn_profile_switcher.py" --project-root "${PROJECT_ROOT}" +} + install_systemd_units() { log "Installing systemd units" install -m 0755 "${PROJECT_ROOT}/deploy/systemd/precheck-sdr.sh" /usr/local/bin/dronedetector-precheck-sdr.sh local src dst - for src in "${PROJECT_ROOT}"/deploy/systemd/*.service; do + for src in "${PROJECT_ROOT}"/deploy/systemd/*.service "${PROJECT_ROOT}"/deploy/systemd/*.timer; do + [[ -f "$src" ]] || continue dst="${SYSTEMD_TARGET_DIR}/$(basename "$src")" sed \ -e "s|__PROJECT_ROOT__|${PROJECT_ROOT}|g" \ @@ -186,6 +204,7 @@ install_systemd_units() { systemctl daemon-reload systemctl enable dronedetector-compose.service systemctl restart dronedetector-compose.service + systemctl enable --now "${NN_SWITCHER_TIMER}" for unit in "${SDR_UNITS[@]}"; do systemctl enable "$unit" @@ -212,6 +231,7 @@ verify_installation() { log "Verifying services" wait_for_systemd_active dronedetector-compose.service 30 || die "dronedetector-compose.service is not active" + wait_for_systemd_active "${NN_SWITCHER_TIMER}" 15 || die "${NN_SWITCHER_TIMER} is not active" for unit in "${SDR_UNITS[@]}"; do wait_for_systemd_active "$unit" 45 || die "$unit is not active" done @@ -223,6 +243,7 @@ verify_installation() { printf '%s\n' "$running_services" | grep -Fxq "dronedetector-server-to-master" || die "server_to_master is not running" printf '%s\n' "$running_services" | grep -Fxq "dronedetector-nn-server" || die "NN_server is not running" + printf '%s\n' "$running_services" | grep -Fxq "dronedetector-telemetry-server" || die "telemetry_server is not running" log "Verification completed" } @@ -235,10 +256,12 @@ main() { preflight install_host_non_python_deps + prepare_runtime_dir setup_sdr_python_env install_docker_if_needed install_nvidia_container_toolkit build_and_run_compose + apply_nn_profile_schedule install_systemd_units verify_installation diff --git a/scripts/nn_profile_switcher.py b/scripts/nn_profile_switcher.py new file mode 100644 index 0000000..9d20bdc --- /dev/null +++ b/scripts/nn_profile_switcher.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +from pathlib import Path +import subprocess +import sys +from tempfile import NamedTemporaryFile + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from common.nn_profile_schedule import ( # noqa: E402 + DEFAULT_PROFILE, + format_active_profile_env, + get_requested_active_profile, + load_simple_env_file, + normalize_profile_name, + resolve_active_profile, +) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Switch DroneDetector NN model profile by schedule") + parser.add_argument( + "--project-root", + default=str(PROJECT_ROOT), + help="Path to repository root (default: script parent project)", + ) + parser.add_argument( + "--no-restart", + action="store_true", + help="Update runtime profile file without restarting dronedetector-nn-server", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + project_root = Path(args.project_root).resolve() + env_path = project_root / ".env" + runtime_path = project_root / "runtime" / "nn_active_profile.env" + compose_file = project_root / "deploy" / "docker" / "docker-compose.yml" + + if not env_path.exists(): + raise SystemExit(f"Missing root .env: {env_path}") + if not compose_file.exists(): + raise SystemExit(f"Missing docker-compose file: {compose_file}") + + config = load_simple_env_file(env_path) + requested_profile = get_requested_active_profile(config) + desired_profile = resolve_active_profile(config) + runtime_exists = runtime_path.exists() + current_profile = read_current_profile(runtime_path) + + if requested_profile != desired_profile: + print( + f"[nn_profile_switcher] requested profile {requested_profile} is not configured, " + f"falling back to {desired_profile}" + ) + + if current_profile == desired_profile and runtime_exists: + print(f"[nn_profile_switcher] profile already active: {desired_profile}") + return 0 + + previous_contents = runtime_path.read_text(encoding="utf-8") if runtime_path.exists() else None + write_runtime_profile(runtime_path, desired_profile) + if runtime_exists: + print( + f"[nn_profile_switcher] updated runtime profile {current_profile or DEFAULT_PROFILE} -> {desired_profile}" + ) + else: + print(f"[nn_profile_switcher] initialized runtime profile: {desired_profile}") + + if current_profile == desired_profile: + return 0 + + if args.no_restart: + print("[nn_profile_switcher] --no-restart set, skipping container restart") + return 0 + + try: + restart_nn_server(project_root, compose_file) + except Exception: + restore_runtime_profile(runtime_path, previous_contents) + raise + + print("[nn_profile_switcher] restarted dronedetector-nn-server") + return 0 + + +def read_current_profile(runtime_path: Path) -> str: + runtime_values = load_simple_env_file(runtime_path) + return normalize_profile_name(runtime_values.get("NN_ACTIVE_PROFILE")) + + +def write_runtime_profile(runtime_path: Path, profile: str) -> None: + runtime_path.parent.mkdir(parents=True, exist_ok=True) + contents = format_active_profile_env(profile) + with NamedTemporaryFile("w", encoding="utf-8", dir=runtime_path.parent, delete=False) as handle: + handle.write(contents) + temp_path = Path(handle.name) + temp_path.replace(runtime_path) + + +def restore_runtime_profile(runtime_path: Path, previous_contents: str | None) -> None: + if previous_contents is None: + runtime_path.unlink(missing_ok=True) + return + + with NamedTemporaryFile("w", encoding="utf-8", dir=runtime_path.parent, delete=False) as handle: + handle.write(previous_contents) + temp_path = Path(handle.name) + temp_path.replace(runtime_path) + + +def restart_nn_server(project_root: Path, compose_file: Path) -> None: + subprocess.run( + [ + "docker", + "compose", + "-f", + str(compose_file), + "restart", + "dronedetector-nn-server", + ], + cwd=project_root, + check=True, + ) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_nn_profile_schedule.py b/tests/test_nn_profile_schedule.py new file mode 100644 index 0000000..967b1ac --- /dev/null +++ b/tests/test_nn_profile_schedule.py @@ -0,0 +1,84 @@ +from datetime import datetime +from pathlib import Path +from tempfile import TemporaryDirectory +import unittest + +from common.nn_profile_schedule import ( + DEFAULT_PROFILE, + format_active_profile_env, + get_available_profiles, + get_profile_model_entries, + get_requested_active_profile, + load_simple_env_file, + parse_schedule, + resolve_active_profile, +) + + +MODEL_2400 = "model_2400 && cfg && ex && out && Resnet18_1_2400 && build && pre && infer && post && [drone,noise] && 10 && 1 && /dataset" +MODEL_915 = "model_915 && cfg && ex && out && ensemble_915 && build && pre && infer && post && [drone,noise] && 10 && 1 && /dataset" + + +class NNProfileScheduleTests(unittest.TestCase): + def test_parse_schedule_supports_wraparound(self): + rules = parse_schedule("08:00-20:00=DAY;20:00-08:00=NIGHT") + + self.assertEqual(len(rules), 2) + self.assertEqual(rules[0].profile, "DAY") + self.assertEqual(rules[1].profile, "NIGHT") + + def test_requested_profile_uses_local_time_schedule(self): + config = {"NN_SCHEDULE": "08:00-20:00=DAY;20:00-08:00=NIGHT", "NN_ACTIVE_PROFILE": "DEFAULT"} + + self.assertEqual( + get_requested_active_profile(config, now=datetime(2026, 4, 23, 9, 0)), + "DAY", + ) + self.assertEqual( + get_requested_active_profile(config, now=datetime(2026, 4, 23, 21, 0)), + "NIGHT", + ) + + def test_resolve_active_profile_falls_back_to_default(self): + config = { + "NN_ACTIVE_PROFILE": "DAY", + "NN_1": MODEL_2400, + } + + self.assertEqual(resolve_active_profile(config), DEFAULT_PROFILE) + + def test_available_profiles_include_default_and_named(self): + config = { + "NN_1": MODEL_2400, + "NN_PROFILE_DAY_21": MODEL_915, + "NN_PROFILE_NIGHT_22": MODEL_2400, + } + + self.assertEqual(get_available_profiles(config), {"DEFAULT", "DAY", "NIGHT"}) + + def test_get_profile_model_entries_renumbers_to_logical_nn_keys(self): + config = { + "NN_PROFILE_DAY_21": MODEL_915, + "NN_PROFILE_DAY_1": MODEL_2400, + } + + entries = get_profile_model_entries(config, "day") + + self.assertEqual(entries[0][0], "NN_1") + self.assertEqual(entries[1][0], "NN_21") + + def test_load_simple_env_file_strips_quotes(self): + with TemporaryDirectory() as tmpdir: + env_path = Path(tmpdir) / "profile.env" + env_path.write_text("NN_ACTIVE_PROFILE='night'\n", encoding="utf-8") + + values = load_simple_env_file(env_path) + + self.assertEqual(values["NN_ACTIVE_PROFILE"], "night") + + def test_format_active_profile_env_normalizes_value(self): + self.assertEqual(format_active_profile_env("night"), "NN_ACTIVE_PROFILE=NIGHT\n") + + +if __name__ == "__main__": + unittest.main()