Система расписаний
parent
b8b7e0c378
commit
5c215efbf6
@ -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
|
||||||
@ -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
|
||||||
@ -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…
Reference in New Issue