Система расписаний

Automatica_1_v2
Sergey Revyakin 2 weeks ago
parent b8b7e0c378
commit 5c215efbf6

@ -1,9 +1,13 @@
from flask import Flask, request, jsonify 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 from common.runtime import load_root_env, validate_env, as_int, as_str
import os import os
import sys import sys
import re
import matplotlib.pyplot as plt import matplotlib.pyplot as plt
from Model import Model from Model import Model
import numpy as np import numpy as np
@ -16,6 +20,7 @@ import shutil
import json import json
import gc import gc
import logging import logging
from pathlib import Path
TORCHSIG_PATH = "/app/torchsig" TORCHSIG_PATH = "/app/torchsig"
if TORCHSIG_PATH not in sys.path: if TORCHSIG_PATH not in sys.path:
@ -39,6 +44,9 @@ plt.ioff()
alg_list = [] alg_list = []
model_list = [] model_list = []
ROOT_ENV = load_root_env(__file__) 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", { validate_env("NN_server/server.py", {
"GENERAL_SERVER_IP": as_str, "GENERAL_SERVER_IP": as_str,
"GENERAL_SERVER_PORT": as_int, "GENERAL_SERVER_PORT": as_int,
@ -49,10 +57,8 @@ validate_env("NN_server/server.py", {
"SRC_EXAMPLE": as_str, "SRC_EXAMPLE": as_str,
}) })
config = dict(dotenv_values(ROOT_ENV)) config = dict(dotenv_values(ROOT_ENV))
if RUNTIME_ENV.exists():
config.update(dotenv_values(RUNTIME_ENV))
def is_model_config_key(key, value):
return bool(re.fullmatch(r"NN_\d+", key or "")) and isinstance(value, str) and " && " in value
def get_required_drone_streak(freq): def get_required_drone_streak(freq):
@ -85,11 +91,20 @@ def update_drone_streak(freq, prediction):
if not config: if not config:
raise RuntimeError("[NN_server/server.py] .env was loaded but no keys were parsed") 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) 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_ip = config['GENERAL_SERVER_IP']
gen_server_port = config['GENERAL_SERVER_PORT'] 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 = {} drone_streaks = {}
def init_data_for_inference(): def init_data_for_inference():
@ -106,8 +121,13 @@ def init_data_for_inference():
try: try:
global model_list global model_list
for key, value in config.items(): model_list = []
if is_model_config_key(key, value): 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(' && ') params = value.split(' && ')
module = importlib.import_module('Models.' + params[4]) module = importlib.import_module('Models.' + params[4])
classes = {} classes = {}
@ -119,16 +139,14 @@ def init_data_for_inference():
post_func=getattr(module, params[8]), classes=classes, number_synthetic_examples=int(params[10]), 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]) number_src_data_for_one_synthetic_example=int(params[11]), path_to_src_dataset=params[12])
model_list.append(model) model_list.append(model)
# if key.startswith('ALG_'): loaded_model_keys.append(key)
# params = config[key].split(' && ')
# module = importlib.import_module('Algorithms.' + params[2]) logging.info(
# classes = {} "Loaded %s NN models for profile %s: %s",
# for value in params[6][1:-1].split(','): len(model_list),
# classes[len(classes)] = value active_profile,
# alg = Algorithm(src_example=params[0], src_result=params[1], type_alg=params[2], pre_func=getattr(module, params[3]), ", ".join(loaded_model_keys),
# 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)
except Exception as exc: except Exception as exc:
print(str(exc)) print(str(exc))
print() print()

@ -45,6 +45,7 @@ chmod +x install_all.sh
### Docker Compose ### Docker Compose
- `dronedetector-server-to-master` -> `src/server_to_master.py` - `dronedetector-server-to-master` -> `src/server_to_master.py`
- `dronedetector-nn-server` -> `NN_server/server.py` - `dronedetector-nn-server` -> `NN_server/server.py`
- `dronedetector-telemetry-server` -> `telemetry/telemetry_server.py`
Compose unit: Compose unit:
- `dronedetector-compose.service` - `dronedetector-compose.service`
@ -55,6 +56,47 @@ Compose unit:
Все entrypoint'ы загружают root `.env` через `common/runtime.py` и валидируют обязательные переменные. При ошибке сервис падает сразу с понятным сообщением. Все 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 (без изменения контрактов) ## 4. API (без изменения контрактов)
- NN_server: `POST /receive_data` - NN_server: `POST /receive_data`
@ -70,6 +112,8 @@ systemctl status dronedetector-sdr-*.service
journalctl -u dronedetector-sdr-868.service -n 200 --no-pager journalctl -u dronedetector-sdr-868.service -n 200 --no-pager
systemctl status dronedetector-compose.service systemctl status dronedetector-compose.service
journalctl -u dronedetector-compose.service -n 200 --no-pager 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 ### docker compose
@ -105,6 +149,7 @@ SDR precheck перед каждым unit запуском:
- Docker Engine (если отсутствует) - Docker Engine (если отсутствует)
- NVIDIA Container Toolkit - NVIDIA Container Toolkit
- `docker compose up -d --build` - `docker compose up -d --build`
- применение активного NN-профиля через switcher
- установка unit'ов в `/etc/systemd/system` - установка unit'ов в `/etc/systemd/system`
- verify + авто-логи при ошибке - verify + авто-логи при ошибке
@ -123,7 +168,7 @@ SDR precheck перед каждым unit запуском:
## 9. Ручная приемка ## 9. Ручная приемка
1. `./install_all.sh` выполняется до конца. 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)`. 3. Все `dronedetector-sdr-*` имеют `active (running)`.
4. Тестовый POST в `NN_server /receive_data` доходит до `server_to_master /process_data`. 4. Тестовый POST в `NN_server /receive_data` доходит до `server_to_master /process_data`.
5. Контур работает минимум 1 минуту без падений. 5. Контур работает минимум 1 минуту без падений.

@ -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<id>\d+)$")
_PROFILE_MODEL_KEY_RE = re.compile(r"^NN_PROFILE_(?P<profile>[A-Za-z0-9_]+)_(?P<id>\d+)$")
_SCHEDULE_RULE_RE = re.compile(
r"^\s*(?P<start>\d{2}:\d{2})\s*-\s*(?P<end>\d{2}:\d{2})\s*=\s*(?P<profile>[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

@ -48,6 +48,7 @@ services:
- "8080:8080" - "8080:8080"
volumes: volumes:
- ../../.env:/app/.env:ro - ../../.env:/app/.env:ro
- ../../runtime:/app/runtime
- ../../NN_server:/app/NN_server - ../../NN_server:/app/NN_server
- ../../common:/app/common - ../../common:/app/common
- ../../train_scripts:/app/train_scripts:ro - ../../train_scripts:/app/train_scripts:ro

@ -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__

@ -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

@ -22,6 +22,9 @@ SDR_UNITS=(
dronedetector-sdr-2400.service dronedetector-sdr-2400.service
) )
NN_SWITCHER_SERVICE="dronedetector-nn-profile-switch.service"
NN_SWITCHER_TIMER="dronedetector-nn-profile-switch.timer"
log() { log() {
printf '[install_all] %s\n' "$*" printf '[install_all] %s\n' "$*"
} }
@ -34,6 +37,8 @@ die() {
print_failure_logs() { print_failure_logs() {
log "Collecting diagnostics..." log "Collecting diagnostics..."
systemctl --no-pager --full status dronedetector-compose.service || true 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 for unit in "${SDR_UNITS[@]}"; do
systemctl --no-pager --full status "$unit" || true systemctl --no-pager --full status "$unit" || true
done done
@ -45,6 +50,8 @@ print_failure_logs() {
fi fi
journalctl -u dronedetector-compose.service -n 150 --no-pager || true 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 for unit in "${SDR_UNITS[@]}"; do
journalctl -u "$unit" -n 120 --no-pager || true journalctl -u "$unit" -n 120 --no-pager || true
done done
@ -103,6 +110,11 @@ install_host_non_python_deps() {
gr-osmosdr 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() { setup_sdr_python_env() {
log "Setting up SDR python environment" log "Setting up SDR python environment"
local venv_path="${PROJECT_ROOT}/.venv-sdr" local venv_path="${PROJECT_ROOT}/.venv-sdr"
@ -168,13 +180,19 @@ build_and_run_compose() {
docker compose -f "$COMPOSE_FILE" up -d --build 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() { install_systemd_units() {
log "Installing systemd units" log "Installing systemd units"
install -m 0755 "${PROJECT_ROOT}/deploy/systemd/precheck-sdr.sh" /usr/local/bin/dronedetector-precheck-sdr.sh install -m 0755 "${PROJECT_ROOT}/deploy/systemd/precheck-sdr.sh" /usr/local/bin/dronedetector-precheck-sdr.sh
local src dst 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")" dst="${SYSTEMD_TARGET_DIR}/$(basename "$src")"
sed \ sed \
-e "s|__PROJECT_ROOT__|${PROJECT_ROOT}|g" \ -e "s|__PROJECT_ROOT__|${PROJECT_ROOT}|g" \
@ -186,6 +204,7 @@ install_systemd_units() {
systemctl daemon-reload systemctl daemon-reload
systemctl enable dronedetector-compose.service systemctl enable dronedetector-compose.service
systemctl restart dronedetector-compose.service systemctl restart dronedetector-compose.service
systemctl enable --now "${NN_SWITCHER_TIMER}"
for unit in "${SDR_UNITS[@]}"; do for unit in "${SDR_UNITS[@]}"; do
systemctl enable "$unit" systemctl enable "$unit"
@ -212,6 +231,7 @@ verify_installation() {
log "Verifying services" log "Verifying services"
wait_for_systemd_active dronedetector-compose.service 30 || die "dronedetector-compose.service is not active" 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 for unit in "${SDR_UNITS[@]}"; do
wait_for_systemd_active "$unit" 45 || die "$unit is not active" wait_for_systemd_active "$unit" 45 || die "$unit is not active"
done 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-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-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" log "Verification completed"
} }
@ -235,10 +256,12 @@ main() {
preflight preflight
install_host_non_python_deps install_host_non_python_deps
prepare_runtime_dir
setup_sdr_python_env setup_sdr_python_env
install_docker_if_needed install_docker_if_needed
install_nvidia_container_toolkit install_nvidia_container_toolkit
build_and_run_compose build_and_run_compose
apply_nn_profile_schedule
install_systemd_units install_systemd_units
verify_installation verify_installation

@ -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())

@ -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()
Loading…
Cancel
Save