Система расписаний
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