update scenario save with waypoint for trayectory
This commit is contained in:
parent
fcb13c437b
commit
2ce621f505
124
settings.json
124
settings.json
@ -3,7 +3,7 @@
|
||||
"scan_limit": 60,
|
||||
"max_range": 80,
|
||||
"geometry": "1200x900+291+141",
|
||||
"last_selected_scenario": "scenario1",
|
||||
"last_selected_scenario": "scenario2",
|
||||
"connection": {
|
||||
"target": {
|
||||
"type": "tftp",
|
||||
@ -31,87 +31,87 @@
|
||||
},
|
||||
"scenarios": {
|
||||
"scenario1": {
|
||||
"name": "Untitled Scenario",
|
||||
"name": "scenario1",
|
||||
"targets": [
|
||||
{
|
||||
"target_id": 0,
|
||||
"range_nm": 20.0,
|
||||
"azimuth_deg": 45.0,
|
||||
"velocity_fps": 500.0,
|
||||
"heading_deg": 270.0,
|
||||
"altitude_ft": 10000.0,
|
||||
"active": true,
|
||||
"traceable": true
|
||||
},
|
||||
{
|
||||
"target_id": 1,
|
||||
"range_nm": 30.0,
|
||||
"azimuth_deg": 45.0,
|
||||
"velocity_fps": 500.0,
|
||||
"heading_deg": 270.0,
|
||||
"altitude_ft": 10000.0,
|
||||
"initial_range_nm": 22.0,
|
||||
"initial_azimuth_deg": 33.0,
|
||||
"initial_altitude_ft": 10000.0,
|
||||
"active": true,
|
||||
"traceable": true
|
||||
"traceable": true,
|
||||
"trajectory": [
|
||||
{
|
||||
"duration_s": 3600,
|
||||
"target_velocity_fps": 16.8781,
|
||||
"target_heading_deg": 10.0
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target_id": 2,
|
||||
"range_nm": 60.0,
|
||||
"azimuth_deg": 20.0,
|
||||
"velocity_fps": 500.0,
|
||||
"heading_deg": 270.0,
|
||||
"altitude_ft": 10000.0,
|
||||
"initial_range_nm": 20.0,
|
||||
"initial_azimuth_deg": 45.0,
|
||||
"initial_altitude_ft": 10000.0,
|
||||
"active": true,
|
||||
"traceable": true
|
||||
},
|
||||
{
|
||||
"target_id": 3,
|
||||
"range_nm": 25.0,
|
||||
"azimuth_deg": -10.0,
|
||||
"velocity_fps": 130.0,
|
||||
"heading_deg": 20.0,
|
||||
"altitude_ft": 20000.0,
|
||||
"active": true,
|
||||
"traceable": true
|
||||
"traceable": true,
|
||||
"trajectory": [
|
||||
{
|
||||
"duration_s": 3600,
|
||||
"target_velocity_fps": 843.905,
|
||||
"target_heading_deg": 270.0
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"scenario2": {
|
||||
"name": "scenario2",
|
||||
"name": "scenario1",
|
||||
"targets": [
|
||||
{
|
||||
"target_id": 0,
|
||||
"range_nm": 20.0,
|
||||
"azimuth_deg": 45.0,
|
||||
"velocity_fps": 843.905,
|
||||
"heading_deg": 270.0,
|
||||
"altitude_ft": 10000.0,
|
||||
"initial_range_nm": 20.0,
|
||||
"initial_azimuth_deg": 45.0,
|
||||
"initial_altitude_ft": 10000.0,
|
||||
"active": true,
|
||||
"traceable": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"scenario3": {
|
||||
"name": "scenario3",
|
||||
"targets": [
|
||||
{
|
||||
"target_id": 0,
|
||||
"range_nm": 20.0,
|
||||
"azimuth_deg": 45.0,
|
||||
"velocity_fps": 843.905,
|
||||
"heading_deg": 270.0,
|
||||
"altitude_ft": 10000.0,
|
||||
"active": true,
|
||||
"traceable": true
|
||||
"traceable": true,
|
||||
"trajectory": [
|
||||
{
|
||||
"duration_s": 3600,
|
||||
"target_velocity_fps": 843.905,
|
||||
"target_heading_deg": 270.0
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target_id": 1,
|
||||
"range_nm": 20.0,
|
||||
"azimuth_deg": 45.0,
|
||||
"velocity_fps": 843.905,
|
||||
"heading_deg": 270.0,
|
||||
"altitude_ft": 10000.0,
|
||||
"initial_range_nm": 22.0,
|
||||
"initial_azimuth_deg": 33.0,
|
||||
"initial_altitude_ft": 10000.0,
|
||||
"active": true,
|
||||
"traceable": true
|
||||
"traceable": true,
|
||||
"trajectory": [
|
||||
{
|
||||
"duration_s": 3600,
|
||||
"target_velocity_fps": 16.8781,
|
||||
"target_heading_deg": 10.0
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target_id": 2,
|
||||
"initial_range_nm": 20.0,
|
||||
"initial_azimuth_deg": 45.0,
|
||||
"initial_altitude_ft": 10000.0,
|
||||
"active": true,
|
||||
"traceable": true,
|
||||
"trajectory": [
|
||||
{
|
||||
"duration_s": 3600,
|
||||
"target_velocity_fps": 843.905,
|
||||
"target_heading_deg": 270.0
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -2,106 +2,72 @@
|
||||
|
||||
"""
|
||||
Constructs MMI command strings based on high-level data models.
|
||||
|
||||
This module acts as a translator between the application's internal data
|
||||
representation (e.g., Target objects) and the specific string format required
|
||||
by the simulator's command-line interface.
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, Optional
|
||||
from typing import Dict, Any
|
||||
|
||||
from .models import Target
|
||||
from target_simulator.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def _build_target_command(command_name: str, target: Target) -> str:
|
||||
def build_tgtinit(target: Target) -> str:
|
||||
"""
|
||||
Internal helper to build the common structure of target-related commands.
|
||||
|
||||
Args:
|
||||
command_name: The base command ('tgtinit' or 'tgtset').
|
||||
target: The Target object containing the data.
|
||||
|
||||
Returns:
|
||||
A formatted command string part with parameters and qualifiers.
|
||||
Builds the 'tgtinit' command from a target's initial state.
|
||||
"""
|
||||
# Format kinematic parameters. Using '*' for None is not standard for
|
||||
# tgtinit/tgtset but is useful for a unified builder. We ensure models
|
||||
# provide valid floats.
|
||||
params = [
|
||||
target.target_id,
|
||||
f"{target.range_nm:.2f}",
|
||||
f"{target.azimuth_deg:.2f}",
|
||||
f"{target.velocity_fps:.2f}",
|
||||
f"{target.heading_deg:.2f}",
|
||||
f"{target.altitude_ft:.2f}"
|
||||
f"{target.initial_range_nm:.2f}",
|
||||
f"{target.initial_azimuth_deg:.2f}",
|
||||
# Velocity and heading for tgtinit are taken from the first waypoint
|
||||
f"{target.trajectory[0].target_velocity_fps if target.trajectory else 0.0:.2f}",
|
||||
f"{target.trajectory[0].target_heading_deg if target.trajectory else 0.0:.2f}",
|
||||
f"{target.initial_altitude_ft:.2f}"
|
||||
]
|
||||
|
||||
# Add qualifiers based on boolean flags
|
||||
qualifiers = []
|
||||
qualifiers.append("/s" if target.active else "/-s")
|
||||
qualifiers.append("/t" if target.traceable else "/-t")
|
||||
|
||||
# Join all parts with spaces
|
||||
command_parts = [command_name] + [str(p) for p in params] + qualifiers
|
||||
qualifiers = ["/s" if target.active else "/-s", "/t" if target.traceable else "/-t"]
|
||||
|
||||
command_parts = ["tgtinit"] + params + qualifiers
|
||||
full_command = " ".join(command_parts)
|
||||
|
||||
logger.debug(f"Built command: {full_command!r}")
|
||||
return full_command
|
||||
|
||||
|
||||
def build_tgtinit(target: Target) -> str:
|
||||
def build_tgtset_from_target_state(target: Target) -> str:
|
||||
"""
|
||||
Builds the 'tgtinit' command for a given target.
|
||||
|
||||
Args:
|
||||
target: The Target object to initialize.
|
||||
|
||||
Returns:
|
||||
The full 'tgtinit' command string.
|
||||
Builds the 'tgtset' command from a target's CURRENT dynamic state.
|
||||
This is used for continuous updates during simulation.
|
||||
"""
|
||||
return _build_target_command("tgtinit", target)
|
||||
|
||||
|
||||
def build_tgtset(target: Target) -> str:
|
||||
"""
|
||||
Builds the 'tgtset' command to update a target's state.
|
||||
|
||||
Args:
|
||||
target: The Target object with the new state.
|
||||
|
||||
Returns:
|
||||
The full 'tgtset' command string.
|
||||
"""
|
||||
return _build_target_command("tgtset", target)
|
||||
# Note: We do not include qualifiers like /s or /t in continuous updates
|
||||
# as they are typically set only at initialization.
|
||||
params = [
|
||||
target.target_id,
|
||||
f"{target.current_range_nm:.2f}",
|
||||
f"{target.current_azimuth_deg:.2f}",
|
||||
f"{target.current_velocity_fps:.2f}",
|
||||
f"{target.current_heading_deg:.2f}",
|
||||
f"{target.current_altitude_ft:.2f}"
|
||||
]
|
||||
|
||||
command_parts = ["tgtset"] + [str(p) for p in params]
|
||||
full_command = " ".join(command_parts)
|
||||
|
||||
# We lower the log level to SPAM/VERBOSE if available, or DEBUG,
|
||||
# to avoid flooding the log during live simulation.
|
||||
logger.debug(f"Built state update command: {full_command!r}")
|
||||
return full_command
|
||||
|
||||
|
||||
def build_tgtset_selective(target_id: int, updates: Dict[str, Any]) -> str:
|
||||
"""
|
||||
Builds a 'tgtset' command with skippable parameters ('*').
|
||||
|
||||
This allows updating only specific attributes of a target.
|
||||
|
||||
Args:
|
||||
target_id: The ID of the target to update.
|
||||
updates: A dictionary where keys are attribute names (e.g., 'velocity_fps')
|
||||
and values are the new values.
|
||||
|
||||
Returns:
|
||||
A selective 'tgtset' command string.
|
||||
"""
|
||||
# Order of parameters is critical and must match the manual
|
||||
param_order = [
|
||||
'range_nm', 'azimuth_deg', 'velocity_fps', 'heading_deg', 'altitude_ft'
|
||||
]
|
||||
|
||||
params = [str(updates.get(key, '*')) for key in param_order]
|
||||
|
||||
command_parts = ["tgtset", str(target_id)] + params
|
||||
full_command = " ".join(command_parts)
|
||||
|
||||
logger.debug(f"Built selective command: {full_command!r}")
|
||||
return full_command
|
||||
|
||||
@ -126,4 +92,4 @@ def build_aclatch() -> str:
|
||||
|
||||
def build_acunlatch() -> str:
|
||||
"""Builds the command to release the A/C data latch."""
|
||||
return "aclatch /r"
|
||||
return "aclatch /r"
|
||||
@ -1,155 +1,238 @@
|
||||
# target_simulator/core/models.py
|
||||
|
||||
"""
|
||||
Defines the core data models for the application, including Target and Scenario.
|
||||
|
||||
This module is responsible for the business logic representation of simulation
|
||||
entities, completely independent of the GUI or communication layers.
|
||||
Defines the core data models for the application, including dynamic Targets
|
||||
with programmable trajectories and Scenarios.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import json
|
||||
from dataclasses import dataclass, asdict
|
||||
import math
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
# Constants for target limitations, derived from the user manual.
|
||||
MIN_TARGET_ID = 0
|
||||
MAX_TARGET_ID = 15
|
||||
KNOTS_TO_FPS = 1.68781
|
||||
FPS_TO_KNOTS = 1 / KNOTS_TO_FPS
|
||||
NM_TO_FT = 6076.12
|
||||
|
||||
@dataclass
|
||||
class Waypoint:
|
||||
"""Represents a segment of a target's trajectory."""
|
||||
duration_s: float = 10.0
|
||||
target_velocity_fps: float = 500.0
|
||||
target_heading_deg: float = 0.0
|
||||
|
||||
# Internal state for interpolation, set when waypoint becomes active
|
||||
_start_velocity_fps: float = field(init=False, repr=False)
|
||||
_start_heading_deg: float = field(init=False, repr=False)
|
||||
|
||||
def to_dict(self) -> Dict:
|
||||
return {
|
||||
"duration_s": self.duration_s,
|
||||
"target_velocity_fps": self.target_velocity_fps,
|
||||
"target_heading_deg": self.target_heading_deg,
|
||||
}
|
||||
|
||||
@dataclass
|
||||
class Target:
|
||||
"""
|
||||
Represents a single target with its state and kinematic properties.
|
||||
|
||||
Attributes reflect the parameters used in the MMI commands like 'tgtinit'.
|
||||
"""
|
||||
"""Represents a dynamic target with a programmable trajectory."""
|
||||
target_id: int
|
||||
range_nm: float
|
||||
azimuth_deg: float
|
||||
velocity_fps: float
|
||||
heading_deg: float
|
||||
altitude_ft: float
|
||||
|
||||
# Initial state for simulation reset
|
||||
initial_range_nm: float
|
||||
initial_azimuth_deg: float
|
||||
initial_altitude_ft: float
|
||||
|
||||
trajectory: List[Waypoint] = field(default_factory=list)
|
||||
|
||||
active: bool = True
|
||||
traceable: bool = True
|
||||
|
||||
# --- Current simulation state (dynamic) ---
|
||||
current_range_nm: float = field(init=False)
|
||||
current_azimuth_deg: float = field(init=False)
|
||||
current_velocity_fps: float = field(init=False)
|
||||
current_heading_deg: float = field(init=False)
|
||||
current_altitude_ft: float = field(init=False)
|
||||
|
||||
# Internal Cartesian coordinates for physics simulation
|
||||
_pos_x_ft: float = field(init=False, repr=False)
|
||||
_pos_y_ft: float = field(init=False, repr=False)
|
||||
|
||||
# --- Simulation progression tracking ---
|
||||
_sim_time_s: float = field(init=False, default=0.0)
|
||||
_current_waypoint_index: int = field(init=False, default=-1)
|
||||
_time_in_waypoint_s: float = field(init=False, default=0.0)
|
||||
|
||||
def __post_init__(self):
|
||||
"""Validate target_id after initialization."""
|
||||
if not (MIN_TARGET_ID <= self.target_id <= MAX_TARGET_ID):
|
||||
raise ValueError(
|
||||
f"Target ID must be between {MIN_TARGET_ID} and {MAX_TARGET_ID}."
|
||||
)
|
||||
raise ValueError(f"Target ID must be between {MIN_TARGET_ID} and {MAX_TARGET_ID}.")
|
||||
self.reset_simulation()
|
||||
|
||||
def reset_simulation(self):
|
||||
"""Resets the target to its initial state to start a new simulation run."""
|
||||
self.current_range_nm = self.initial_range_nm
|
||||
self.current_azimuth_deg = self.initial_azimuth_deg
|
||||
self.current_altitude_ft = self.initial_altitude_ft
|
||||
|
||||
# Convert initial polar coordinates to Cartesian for physics
|
||||
initial_range_ft = self.initial_range_nm * NM_TO_FT
|
||||
initial_azimuth_rad = math.radians(self.initial_azimuth_deg)
|
||||
self._pos_x_ft = initial_range_ft * math.sin(initial_azimuth_rad)
|
||||
self._pos_y_ft = initial_range_ft * math.cos(initial_azimuth_rad)
|
||||
|
||||
self._sim_time_s = 0.0
|
||||
self._current_waypoint_index = -1
|
||||
self._time_in_waypoint_s = 0.0
|
||||
|
||||
# Initialize velocity and heading from the first waypoint if it exists
|
||||
if self.trajectory:
|
||||
self.current_velocity_fps = self.trajectory[0].target_velocity_fps
|
||||
self.current_heading_deg = self.trajectory[0].target_heading_deg
|
||||
self._activate_next_waypoint()
|
||||
else:
|
||||
self.current_velocity_fps = 0.0
|
||||
self.current_heading_deg = 0.0
|
||||
|
||||
def _activate_next_waypoint(self):
|
||||
"""Activates the next waypoint in the trajectory list."""
|
||||
self._current_waypoint_index += 1
|
||||
if self._current_waypoint_index < len(self.trajectory):
|
||||
wp = self.trajectory[self._current_waypoint_index]
|
||||
wp._start_velocity_fps = self.current_velocity_fps
|
||||
wp._start_heading_deg = self.current_heading_deg
|
||||
self._time_in_waypoint_s = 0.0
|
||||
else:
|
||||
# No more waypoints, continue with last state
|
||||
self._current_waypoint_index = -1 # Sentinel for "finished"
|
||||
|
||||
def update_state(self, delta_time_s: float):
|
||||
"""Updates the target's state and position based on its trajectory."""
|
||||
if not self.active or not self.trajectory or self._current_waypoint_index == -1:
|
||||
return # Target is static if inactive or has no trajectory
|
||||
|
||||
self._sim_time_s += delta_time_s
|
||||
self._time_in_waypoint_s += delta_time_s
|
||||
|
||||
wp = self.trajectory[self._current_waypoint_index]
|
||||
|
||||
# Check if current waypoint is complete
|
||||
if self._time_in_waypoint_s >= wp.duration_s:
|
||||
self.current_velocity_fps = wp.target_velocity_fps
|
||||
self.current_heading_deg = wp.target_heading_deg
|
||||
self._activate_next_waypoint()
|
||||
# If there's a new waypoint, update based on its new state for the remainder of the time slice
|
||||
if self._current_waypoint_index != -1:
|
||||
wp = self.trajectory[self._current_waypoint_index]
|
||||
else: # Trajectory finished
|
||||
pass # The target will continue straight with its last velocity/heading
|
||||
|
||||
# --- Interpolate current heading and velocity ---
|
||||
if self._current_waypoint_index != -1 and wp.duration_s > 0:
|
||||
progress = self._time_in_waypoint_s / wp.duration_s
|
||||
|
||||
# Linear interpolation for velocity
|
||||
self.current_velocity_fps = wp._start_velocity_fps + (wp.target_velocity_fps - wp._start_velocity_fps) * progress
|
||||
|
||||
# Interpolate heading (shortest path)
|
||||
delta_heading = (wp.target_heading_deg - wp._start_heading_deg + 180) % 360 - 180
|
||||
self.current_heading_deg = (wp._start_heading_deg + delta_heading * progress) % 360
|
||||
|
||||
# --- Update position based on current kinematics ---
|
||||
heading_rad = math.radians(self.current_heading_deg)
|
||||
distance_ft = self.current_velocity_fps * delta_time_s
|
||||
|
||||
self._pos_x_ft += distance_ft * math.sin(heading_rad)
|
||||
self._pos_y_ft += distance_ft * math.cos(heading_rad)
|
||||
|
||||
# --- Convert Cartesian back to Polar for display/reporting ---
|
||||
self.current_range_nm = math.sqrt(self._pos_x_ft**2 + self._pos_y_ft**2) / NM_TO_FT
|
||||
self.current_azimuth_deg = math.degrees(math.atan2(self._pos_x_ft, self._pos_y_ft))
|
||||
|
||||
def to_dict(self) -> Dict:
|
||||
"""Serializes the Target object to a dictionary."""
|
||||
return asdict(self)
|
||||
|
||||
"""Serializes the Target object to a dictionary for saving."""
|
||||
return {
|
||||
"target_id": self.target_id,
|
||||
"initial_range_nm": self.initial_range_nm,
|
||||
"initial_azimuth_deg": self.initial_azimuth_deg,
|
||||
"initial_altitude_ft": self.initial_altitude_ft,
|
||||
"active": self.active,
|
||||
"traceable": self.traceable,
|
||||
"trajectory": [wp.to_dict() for wp in self.trajectory]
|
||||
}
|
||||
|
||||
class Scenario:
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict) -> "Scenario":
|
||||
"""
|
||||
Crea uno Scenario da un dizionario.
|
||||
"""
|
||||
name = data.get("name", "Loaded Scenario")
|
||||
scenario = cls(name=name)
|
||||
for target_data in data.get("targets", []):
|
||||
try:
|
||||
target = Target(**target_data)
|
||||
scenario.add_target(target)
|
||||
except (TypeError, ValueError) as e:
|
||||
print(f"Skipping invalid target data: {target_data}. Error: {e}")
|
||||
return scenario
|
||||
def to_dict(self) -> Dict:
|
||||
"""
|
||||
Serializza lo scenario in un dizionario.
|
||||
"""
|
||||
return {
|
||||
"name": self.name,
|
||||
"targets": [target.to_dict() for target in self.get_all_targets()]
|
||||
}
|
||||
"""
|
||||
Manages a collection of targets that constitute a simulation scenario.
|
||||
|
||||
Provides functionality to add, remove, and retrieve targets, as well as
|
||||
saving and loading the entire scenario to/from a file.
|
||||
"""
|
||||
def __init__(self, name: str = "Untitled Scenario"):
|
||||
self.name = name
|
||||
self.targets: Dict[int, Target] = {}
|
||||
|
||||
def add_target(self, target: Target) -> None:
|
||||
"""
|
||||
Adds a target to the scenario. Overwrites if ID already exists.
|
||||
|
||||
Args:
|
||||
target: The Target object to add.
|
||||
"""
|
||||
def add_target(self, target: Target):
|
||||
self.targets[target.target_id] = target
|
||||
|
||||
def get_target(self, target_id: int) -> Optional[Target]:
|
||||
"""
|
||||
Retrieves a target by its ID.
|
||||
|
||||
Args:
|
||||
target_id: The ID of the target to retrieve.
|
||||
|
||||
Returns:
|
||||
The Target object if found, otherwise None.
|
||||
"""
|
||||
return self.targets.get(target_id)
|
||||
|
||||
def remove_target(self, target_id: int) -> None:
|
||||
"""
|
||||
Removes a target from the scenario by its ID.
|
||||
|
||||
Args:
|
||||
target_id: The ID of the target to remove.
|
||||
"""
|
||||
def remove_target(self, target_id: int):
|
||||
if target_id in self.targets:
|
||||
del self.targets[target_id]
|
||||
|
||||
def get_all_targets(self) -> List[Target]:
|
||||
"""Returns a list of all targets in the scenario."""
|
||||
return list(self.targets.values())
|
||||
|
||||
def save_to_file(self, filepath: str) -> None:
|
||||
"""
|
||||
Saves the current scenario to a JSON file.
|
||||
def reset_simulation(self):
|
||||
"""Resets the simulation state for all targets in the scenario."""
|
||||
for target in self.targets.values():
|
||||
target.reset_simulation()
|
||||
|
||||
Args:
|
||||
filepath: The path to the file where the scenario will be saved.
|
||||
"""
|
||||
scenario_data = {
|
||||
def update_state(self, delta_time_s: float):
|
||||
"""Updates the state of all targets in the scenario."""
|
||||
for target in self.targets.values():
|
||||
target.update_state(delta_time_s)
|
||||
|
||||
def to_dict(self) -> Dict:
|
||||
return {
|
||||
"name": self.name,
|
||||
"targets": [target.to_dict() for target in self.get_all_targets()]
|
||||
}
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
json.dump(scenario_data, f, indent=4)
|
||||
|
||||
@classmethod
|
||||
def load_from_file(cls, filepath: str) -> Scenario:
|
||||
"""
|
||||
Loads a scenario from a JSON file and returns a new Scenario instance.
|
||||
|
||||
Args:
|
||||
filepath: The path to the scenario file to load.
|
||||
|
||||
Returns:
|
||||
A new Scenario object populated with data from the file.
|
||||
"""
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
scenario_data = json.load(f)
|
||||
|
||||
scenario_name = scenario_data.get("name", "Loaded Scenario")
|
||||
new_scenario = cls(name=scenario_name)
|
||||
|
||||
for target_data in scenario_data.get("targets", []):
|
||||
def from_dict(cls, data: Dict) -> Scenario:
|
||||
name = data.get("name", "Loaded Scenario")
|
||||
scenario = cls(name=name)
|
||||
for target_data in data.get("targets", []):
|
||||
try:
|
||||
target = Target(**target_data)
|
||||
new_scenario.add_target(target)
|
||||
except (TypeError, ValueError) as e:
|
||||
# Log or print a warning about invalid target data in the file
|
||||
print(f"Skipping invalid target data: {target_data}. Error: {e}")
|
||||
# --- Compatibility for old format ---
|
||||
if "initial_range_nm" not in target_data and "range_nm" in target_data:
|
||||
target_data["initial_range_nm"] = target_data.pop("range_nm")
|
||||
if "initial_azimuth_deg" not in target_data and "azimuth_deg" in target_data:
|
||||
target_data["initial_azimuth_deg"] = target_data.pop("azimuth_deg")
|
||||
if "initial_altitude_ft" not in target_data and "altitude_ft" in target_data:
|
||||
target_data["initial_altitude_ft"] = target_data.pop("altitude_ft")
|
||||
|
||||
return new_scenario
|
||||
# Reconstruct Waypoints
|
||||
waypoints = [Waypoint(**wp_data) for wp_data in target_data.get("trajectory", [])]
|
||||
|
||||
# --- Compatibility for old format (trajectory) ---
|
||||
if not waypoints and "velocity_fps" in target_data:
|
||||
waypoints.append(Waypoint(
|
||||
duration_s=3600, # Long duration
|
||||
target_velocity_fps=target_data.get("velocity_fps", 0),
|
||||
target_heading_deg=target_data.get("heading_deg", 0)
|
||||
))
|
||||
|
||||
# Create Target
|
||||
target = Target(
|
||||
target_id=target_data["target_id"],
|
||||
initial_range_nm=target_data["initial_range_nm"],
|
||||
initial_azimuth_deg=target_data["initial_azimuth_deg"],
|
||||
initial_altitude_ft=target_data["initial_altitude_ft"],
|
||||
active=target_data.get("active", True),
|
||||
traceable=target_data.get("traceable", True),
|
||||
trajectory=waypoints
|
||||
)
|
||||
scenario.add_target(target)
|
||||
except (KeyError, TypeError) as e:
|
||||
print(f"Skipping invalid target data: {target_data}. Error: {e}")
|
||||
return scenario
|
||||
100
target_simulator/core/simulation_engine.py
Normal file
100
target_simulator/core/simulation_engine.py
Normal file
@ -0,0 +1,100 @@
|
||||
# target_simulator/core/simulation_engine.py
|
||||
|
||||
"""
|
||||
The core simulation engine that runs in a separate thread to update and
|
||||
broadcast target states without blocking the GUI.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
from queue import Queue
|
||||
from typing import Optional
|
||||
|
||||
from .communicator_interface import CommunicatorInterface
|
||||
from .models import Scenario, Target
|
||||
from . import command_builder
|
||||
from target_simulator.utils.logger import get_logger
|
||||
|
||||
# Simulation frequency in Hertz
|
||||
TICK_RATE_HZ = 10.0
|
||||
TICK_INTERVAL_S = 1.0 / TICK_RATE_HZ
|
||||
|
||||
class SimulationEngine(threading.Thread):
|
||||
"""
|
||||
Manages the simulation loop in a dedicated thread.
|
||||
"""
|
||||
|
||||
def __init__(self, communicator: CommunicatorInterface, update_queue: Queue):
|
||||
super().__init__(daemon=True, name="SimulationEngineThread")
|
||||
self.logger = get_logger(__name__)
|
||||
self.communicator = communicator
|
||||
self.update_queue = update_queue # Queue to send data back to the GUI
|
||||
|
||||
self.scenario: Optional[Scenario] = None
|
||||
self._stop_event = threading.Event()
|
||||
self._is_running_event = threading.Event()
|
||||
self._last_tick_time = 0.0
|
||||
|
||||
def load_scenario(self, scenario: Scenario):
|
||||
"""Loads a new scenario into the engine and resets it."""
|
||||
self.logger.info(f"Loading scenario '{scenario.name}' into simulation engine.")
|
||||
self.scenario = scenario
|
||||
self.scenario.reset_simulation()
|
||||
|
||||
def run(self):
|
||||
"""The main loop of the simulation thread."""
|
||||
self.logger.info("Simulation engine thread started.")
|
||||
self._is_running_event.set()
|
||||
self._last_tick_time = time.monotonic()
|
||||
|
||||
while not self._stop_event.is_set():
|
||||
if not self.scenario or not self.communicator.is_open:
|
||||
time.sleep(TICK_INTERVAL_S)
|
||||
continue
|
||||
|
||||
# --- Time Management ---
|
||||
current_time = time.monotonic()
|
||||
delta_time = current_time - self._last_tick_time
|
||||
self._last_tick_time = current_time
|
||||
|
||||
# --- Simulation Step ---
|
||||
self.scenario.update_state(delta_time)
|
||||
|
||||
# --- Command Sending & GUI Update ---
|
||||
updated_targets = self.scenario.get_all_targets()
|
||||
|
||||
for target in updated_targets:
|
||||
if not target.active:
|
||||
continue
|
||||
# Build and send the update command for each active target
|
||||
command = command_builder.build_tgtset_from_target_state(target)
|
||||
|
||||
# Check for communicator-specific single-command methods
|
||||
if hasattr(self.communicator, '_send_single_command'):
|
||||
self.communicator._send_single_command(command)
|
||||
elif hasattr(self.communicator, 'send_command'): # Fallback for TFTP-like single command upload
|
||||
self.communicator.send_command(command)
|
||||
|
||||
# Put the updated list of targets into the queue for the GUI
|
||||
try:
|
||||
self.update_queue.put_nowait(updated_targets)
|
||||
except Queue.Full:
|
||||
self.logger.warning("GUI update queue is full. Skipping one frame.")
|
||||
|
||||
# --- Loop Control ---
|
||||
time.sleep(TICK_INTERVAL_S)
|
||||
|
||||
self._is_running_event.clear()
|
||||
self.logger.info("Simulation engine thread stopped.")
|
||||
|
||||
def stop(self):
|
||||
"""Signals the simulation thread to stop and waits for it to terminate."""
|
||||
self.logger.info("Stop signal received.")
|
||||
self._stop_event.set()
|
||||
self.join(timeout=2.0) # Wait for the thread to finish
|
||||
if self.is_alive():
|
||||
self.logger.warning("Simulation thread did not stop gracefully.")
|
||||
|
||||
def is_running(self) -> bool:
|
||||
"""Checks if the simulation loop is currently active."""
|
||||
return self._is_running_event.is_set()
|
||||
@ -4,7 +4,7 @@ Toplevel window for adding or editing a target.
|
||||
"""
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, messagebox
|
||||
from core.models import Target, MIN_TARGET_ID, MAX_TARGET_ID
|
||||
from core.models import Target, MIN_TARGET_ID, MAX_TARGET_ID, Waypoint
|
||||
|
||||
class AddTargetWindow(tk.Toplevel):
|
||||
def _on_cancel(self):
|
||||
@ -20,13 +20,21 @@ class AddTargetWindow(tk.Toplevel):
|
||||
# Conversione da knots a fps
|
||||
knots_to_fps = 1.68781
|
||||
velocity_fps = self.vel_knots_var.get() * knots_to_fps
|
||||
|
||||
default_trajectory = [
|
||||
Waypoint(
|
||||
duration_s=3600,
|
||||
target_velocity_fps=velocity_fps,
|
||||
target_heading_deg=self.hdg_var.get()
|
||||
)
|
||||
]
|
||||
|
||||
self.new_target = Target(
|
||||
target_id=target_id,
|
||||
range_nm=self.range_var.get(),
|
||||
azimuth_deg=self.az_var.get(),
|
||||
velocity_fps=velocity_fps,
|
||||
heading_deg=self.hdg_var.get(),
|
||||
altitude_ft=self.alt_var.get()
|
||||
initial_range_nm=self.range_var.get(),
|
||||
initial_azimuth_deg=self.az_var.get(),
|
||||
initial_altitude_ft=self.alt_var.get(),
|
||||
trajectory=default_trajectory
|
||||
)
|
||||
self.destroy()
|
||||
except ValueError as e:
|
||||
|
||||
@ -5,86 +5,79 @@ Main view of the application, containing the primary window and widgets.
|
||||
"""
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, scrolledtext, messagebox
|
||||
from typing import Optional, Dict, Any
|
||||
from queue import Queue, Empty
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
# Imports from GUI components
|
||||
from .ppi_display import PPIDisplay
|
||||
from .settings_window import SettingsWindow
|
||||
from .connection_settings_window import ConnectionSettingsWindow
|
||||
from .radar_config_window import RadarConfigWindow
|
||||
from .scenario_controls_frame import ScenarioControlsFrame
|
||||
from .target_list_frame import TargetListFrame
|
||||
from .add_target_window import AddTargetWindow
|
||||
|
||||
# Imports from Core components
|
||||
from core.communicator_interface import CommunicatorInterface
|
||||
from core.serial_communicator import SerialCommunicator
|
||||
from core.tftp_communicator import TFTPCommunicator
|
||||
from core.models import Scenario
|
||||
from core.simulation_engine import SimulationEngine
|
||||
from core.models import Scenario, Target
|
||||
|
||||
# Imports from Utils
|
||||
from utils.logger import get_logger, shutdown_logging_system
|
||||
from utils.config_manager import ConfigManager
|
||||
from target_simulator.utils.logger import get_logger, shutdown_logging_system
|
||||
from target_simulator.utils.config_manager import ConfigManager
|
||||
|
||||
|
||||
GUI_QUEUE_POLL_INTERVAL_MS = 100
|
||||
|
||||
class MainView(tk.Tk):
|
||||
def _on_new_scenario(self, scenario_name):
|
||||
"""
|
||||
Handles creation of a new scenario:
|
||||
- If scenario name exists, notify user and load it.
|
||||
- If not, create new scenario, update combobox, select, and clear targets.
|
||||
"""
|
||||
scenario_names = self.config_manager.get_scenario_names()
|
||||
if scenario_name in scenario_names:
|
||||
messagebox.showinfo(
|
||||
"Duplicate Scenario",
|
||||
f"Scenario '{scenario_name}' already exists. Loading saved data.",
|
||||
parent=self
|
||||
)
|
||||
self._on_load_scenario(scenario_name)
|
||||
# Select in comboboxes
|
||||
self.scenario_controls.update_scenario_list(scenario_names, scenario_name)
|
||||
if hasattr(self, 'sim_scenario_combobox'):
|
||||
self.sim_scenario_combobox['values'] = scenario_names
|
||||
self.sim_scenario_combobox.set(scenario_name)
|
||||
return
|
||||
# Create new scenario
|
||||
self.scenario = Scenario(name=scenario_name)
|
||||
self.current_scenario_name = scenario_name
|
||||
# Clear targets in UI
|
||||
self.target_list.update_target_list([])
|
||||
# Update comboboxes
|
||||
scenario_names.append(scenario_name)
|
||||
self.scenario_controls.update_scenario_list(scenario_names, scenario_name)
|
||||
if hasattr(self, 'sim_scenario_combobox'):
|
||||
self.sim_scenario_combobox['values'] = scenario_names
|
||||
self.sim_scenario_combobox.set(scenario_name)
|
||||
self._update_window_title()
|
||||
self.logger.info(f"Created new scenario '{scenario_name}'. Ready for target input.")
|
||||
messagebox.showinfo(
|
||||
"New Scenario",
|
||||
f"Scenario '{scenario_name}' created. Add targets to begin.",
|
||||
parent=self
|
||||
)
|
||||
"""The main application window."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.logger = get_logger(__name__)
|
||||
self.config_manager = ConfigManager()
|
||||
|
||||
# --- Load Settings ---
|
||||
settings = self.config_manager.get_general_settings()
|
||||
self.scan_limit = settings.get('scan_limit', 60)
|
||||
self.max_range = settings.get('max_range', 100)
|
||||
self.connection_config = self.config_manager.get_connection_settings()
|
||||
|
||||
# --- Core Logic Handlers ---
|
||||
self.target_communicator: Optional[CommunicatorInterface] = None
|
||||
self.lru_communicator: Optional[CommunicatorInterface] = None
|
||||
self.scenario = Scenario()
|
||||
self.current_scenario_name: Optional[str] = None
|
||||
|
||||
# --- Simulation Engine ---
|
||||
self.simulation_engine: Optional[SimulationEngine] = None
|
||||
self.gui_update_queue = Queue()
|
||||
self.is_simulation_running = tk.BooleanVar(value=False)
|
||||
|
||||
# --- Window and UI Setup ---
|
||||
self.title("Radar Target Simulator")
|
||||
self.geometry(settings.get('geometry', '1200x1024'))
|
||||
self.minsize(1024, 1024)
|
||||
self.target_communicator = None
|
||||
self.lru_communicator = None
|
||||
self.current_scenario_name = None
|
||||
self._create_main_layout()
|
||||
self.minsize(1024, 768)
|
||||
|
||||
self._create_menubar()
|
||||
self._create_main_layout()
|
||||
self._create_statusbar()
|
||||
self._initialize_communicator() # Attempt to connect with saved settings
|
||||
|
||||
# --- Post-UI Initialization ---
|
||||
self._initialize_communicators()
|
||||
self._load_scenarios_into_ui()
|
||||
last_scenario = settings.get("last_selected_scenario")
|
||||
if last_scenario and last_scenario in self.config_manager.get_scenario_names():
|
||||
self._on_load_scenario(last_scenario)
|
||||
|
||||
self._update_window_title()
|
||||
self.protocol("WM_DELETE_WINDOW", self._on_closing)
|
||||
self.logger.info("MainView initialized successfully.")
|
||||
|
||||
def _create_main_layout(self):
|
||||
v_pane = ttk.PanedWindow(self, orient=tk.VERTICAL)
|
||||
v_pane.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
|
||||
|
||||
self.h_pane = ttk.PanedWindow(v_pane, orient=tk.HORIZONTAL)
|
||||
v_pane.add(self.h_pane, weight=4)
|
||||
|
||||
@ -92,323 +85,268 @@ class MainView(tk.Tk):
|
||||
self.ppi_widget = PPIDisplay(self.h_pane, max_range_nm=self.max_range, scan_limit_deg=self.scan_limit)
|
||||
self.h_pane.add(self.ppi_widget, weight=2)
|
||||
|
||||
# Add Connect button next to PPI controls
|
||||
# Find the controls_frame inside PPIDisplay and add the button
|
||||
controls_frame = self.ppi_widget.children.get('!frame')
|
||||
if controls_frame:
|
||||
connect_btn = ttk.Button(controls_frame, text="Connect", command=self._on_connect_button)
|
||||
connect_btn.pack(side=tk.RIGHT, padx=10)
|
||||
|
||||
# --- Left Pane ---
|
||||
left_pane_container = ttk.Frame(self.h_pane)
|
||||
self.h_pane.add(left_pane_container, weight=1)
|
||||
|
||||
left_notebook = ttk.Notebook(left_pane_container)
|
||||
left_notebook.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
# --- TAB 1: SCENARIO CONFIG ---
|
||||
scenario_tab = ttk.Frame(left_notebook)
|
||||
left_notebook.add(scenario_tab, text="Scenario Config")
|
||||
|
||||
self.scenario_controls = ScenarioControlsFrame(
|
||||
scenario_tab,
|
||||
main_view=self,
|
||||
load_scenario_command=self._on_load_scenario,
|
||||
save_as_command=self._on_save_scenario_as,
|
||||
delete_command=self._on_delete_scenario,
|
||||
new_scenario_command=self._on_new_scenario
|
||||
)
|
||||
self.scenario_controls.pack(fill=tk.X, expand=False, padx=5, pady=(5, 5))
|
||||
|
||||
self.target_list = TargetListFrame(scenario_tab, targets_changed_callback=self._on_targets_changed)
|
||||
self.target_list.pack(fill=tk.BOTH, expand=True, padx=5)
|
||||
|
||||
# --- TAB 2: SIMULATION ---
|
||||
simulation_tab = ttk.Frame(left_notebook)
|
||||
left_notebook.add(simulation_tab, text="Simulation")
|
||||
|
||||
sim_scenario_frame = ttk.LabelFrame(simulation_tab, text="Scenario Control")
|
||||
sim_scenario_frame.pack(fill=tk.X, padx=5, pady=5, anchor='n')
|
||||
|
||||
ttk.Label(sim_scenario_frame, text="Scenario:").pack(side=tk.LEFT, padx=(5, 5), pady=5)
|
||||
self.sim_scenario_combobox = ttk.Combobox(
|
||||
sim_scenario_frame,
|
||||
textvariable=self.scenario_controls.current_scenario, # Share the variable
|
||||
state="readonly"
|
||||
)
|
||||
self.sim_scenario_combobox.pack(side=tk.LEFT, expand=True, fill=tk.X, pady=5)
|
||||
self.sim_scenario_combobox.bind("<<ComboboxSelected>>", lambda event: self._on_load_scenario(self.sim_scenario_combobox.get()))
|
||||
|
||||
engine_frame = ttk.LabelFrame(simulation_tab, text="Live Simulation Engine")
|
||||
engine_frame.pack(fill=tk.X, padx=5, pady=10, anchor='n')
|
||||
|
||||
self.start_button = ttk.Button(engine_frame, text="Start Live", command=self._on_start_simulation)
|
||||
self.start_button.pack(side=tk.LEFT, padx=5, pady=5)
|
||||
|
||||
self.stop_button = ttk.Button(engine_frame, text="Stop Live", command=self._on_stop_simulation, state=tk.DISABLED)
|
||||
self.stop_button.pack(side=tk.LEFT, padx=5, pady=5)
|
||||
|
||||
self.reset_button = ttk.Button(engine_frame, text="Reset State", command=self._on_reset_simulation)
|
||||
self.reset_button.pack(side=tk.RIGHT, padx=5, pady=5)
|
||||
|
||||
# --- TAB 3: LRU SIMULATION ---
|
||||
lru_tab = ttk.Frame(left_notebook)
|
||||
left_notebook.add(lru_tab, text="LRU Simulation")
|
||||
|
||||
cooling_frame = ttk.LabelFrame(lru_tab, text="Cooling Unit Status")
|
||||
cooling_frame.pack(fill=tk.X, padx=5, pady=5, anchor='n')
|
||||
ttk.Label(cooling_frame, text="Status:").pack(side=tk.LEFT, padx=5, pady=5)
|
||||
self.cooling_status_var = tk.StringVar(value="OK")
|
||||
cooling_combo = ttk.Combobox(cooling_frame, textvariable=self.cooling_status_var, values=["OK", "OVERHEATING", "FAULT"], state="readonly")
|
||||
cooling_combo.pack(side=tk.LEFT, expand=True, fill=tk.X, padx=5, pady=5)
|
||||
|
||||
power_frame = ttk.LabelFrame(lru_tab, text="Power Supply Unit Status")
|
||||
power_frame.pack(fill=tk.X, padx=5, pady=5, anchor='n')
|
||||
ttk.Label(power_frame, text="Status:").pack(side=tk.LEFT, padx=5, pady=5)
|
||||
self.power_status_var = tk.StringVar(value="OK")
|
||||
power_combo = ttk.Combobox(power_frame, textvariable=self.power_status_var, values=["OK", "LOW_VOLTAGE", "FAULT"], state="readonly")
|
||||
power_combo.pack(side=tk.LEFT, expand=True, fill=tk.X, padx=5, pady=5)
|
||||
|
||||
lru_action_frame = ttk.Frame(lru_tab)
|
||||
lru_action_frame.pack(fill=tk.X, padx=5, pady=10, anchor='n')
|
||||
send_lru_button = ttk.Button(lru_action_frame, text="Send LRU Status", command=self._on_send_lru_status)
|
||||
send_lru_button.pack(side=tk.RIGHT)
|
||||
|
||||
# --- Bottom Pane (Logs) ---
|
||||
log_frame_container = ttk.LabelFrame(v_pane, text="Logs")
|
||||
v_pane.add(log_frame_container, weight=1)
|
||||
self.log_text_widget = scrolledtext.ScrolledText(log_frame_container, state=tk.DISABLED, wrap=tk.WORD, font=("Consolas", 9))
|
||||
self.log_text_widget.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
|
||||
|
||||
# --- Left Pane ---
|
||||
left_pane_container = ttk.Frame(self.h_pane)
|
||||
self.h_pane.add(left_pane_container, weight=1)
|
||||
|
||||
# --- Notebook for left pane ---
|
||||
left_notebook = ttk.Notebook(left_pane_container)
|
||||
left_notebook.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
# --- TAB 2: SCENARIO CONFIG ---
|
||||
if not hasattr(self, 'scenario_tab'):
|
||||
self.scenario_tab = ttk.Frame(left_notebook)
|
||||
left_notebook.add(self.scenario_tab, text="Scenario Config")
|
||||
self.scenario_controls = ScenarioControlsFrame(self.scenario_tab,
|
||||
main_view=self,
|
||||
load_scenario_command=self._on_load_scenario,
|
||||
save_as_command=self._on_save_scenario_as,
|
||||
delete_command=self._on_delete_scenario,
|
||||
new_scenario_command=self._on_new_scenario)
|
||||
self.scenario_controls.pack(fill=tk.X, expand=False, padx=5, pady=(5, 5))
|
||||
self.target_list = TargetListFrame(self.scenario_tab, targets_changed_callback=self._on_targets_changed)
|
||||
self.target_list.pack(fill=tk.BOTH, expand=True, padx=5)
|
||||
|
||||
# --- TAB 3: SIMULATION ---
|
||||
if not hasattr(self, 'simulation_tab'):
|
||||
self.simulation_tab = ttk.Frame(left_notebook)
|
||||
left_notebook.add(self.simulation_tab, text="Simulation")
|
||||
sim_controls_frame = ttk.LabelFrame(self.simulation_tab, text="Simulation Controls")
|
||||
sim_controls_frame.pack(fill=tk.X, padx=5, pady=5, anchor='n')
|
||||
ttk.Label(sim_controls_frame, text="Scenario:").pack(side=tk.LEFT, padx=(5, 5), pady=5)
|
||||
self.sim_scenario_combobox = ttk.Combobox(
|
||||
sim_controls_frame,
|
||||
textvariable=self.scenario_controls.current_scenario,
|
||||
state="readonly"
|
||||
)
|
||||
self.sim_scenario_combobox.pack(side=tk.LEFT, expand=True, fill=tk.X, pady=5)
|
||||
self.sim_scenario_combobox.bind("<<ComboboxSelected>>", lambda event: self._on_load_scenario(self.sim_scenario_combobox.get()))
|
||||
send_button = ttk.Button(sim_controls_frame, text="Send to Radar", command=self._on_send_scenario)
|
||||
send_button.pack(side=tk.LEFT, padx=5, pady=5)
|
||||
|
||||
# Add Reset Targets button
|
||||
reset_button = ttk.Button(sim_controls_frame, text="Reset Targets", command=self._on_reset_targets)
|
||||
reset_button.pack(side=tk.LEFT, padx=5, pady=5)
|
||||
|
||||
if not hasattr(self, 'lru_tab'):
|
||||
self.lru_tab = ttk.Frame(left_notebook)
|
||||
left_notebook.add(self.lru_tab, text="LRU Simulation")
|
||||
cooling_frame = ttk.LabelFrame(self.lru_tab, text="Cooling Unit Status")
|
||||
cooling_frame.pack(fill=tk.X, padx=5, pady=5, anchor='n')
|
||||
ttk.Label(cooling_frame, text="Status:").pack(side=tk.LEFT, padx=5, pady=5)
|
||||
self.cooling_status_var = tk.StringVar(value="OK")
|
||||
cooling_combo = ttk.Combobox(
|
||||
cooling_frame,
|
||||
textvariable=self.cooling_status_var,
|
||||
values=["OK", "OVERHEATING", "FAULT"],
|
||||
state="readonly"
|
||||
)
|
||||
cooling_combo.pack(side=tk.LEFT, expand=True, fill=tk.X, padx=5, pady=5)
|
||||
power_frame = ttk.LabelFrame(self.lru_tab, text="Power Supply Unit Status")
|
||||
power_frame.pack(fill=tk.X, padx=5, pady=5, anchor='n')
|
||||
ttk.Label(power_frame, text="Status:").pack(side=tk.LEFT, padx=5, pady=5)
|
||||
self.power_status_var = tk.StringVar(value="OK")
|
||||
power_combo = ttk.Combobox(
|
||||
power_frame,
|
||||
textvariable=self.power_status_var,
|
||||
values=["OK", "LOW_VOLTAGE", "FAULT"],
|
||||
state="readonly"
|
||||
)
|
||||
power_combo.pack(side=tk.LEFT, expand=True, fill=tk.X, padx=5, pady=5)
|
||||
lru_action_frame = ttk.Frame(self.lru_tab)
|
||||
lru_action_frame.pack(fill=tk.X, padx=5, pady=10, anchor='n')
|
||||
self.lru_message_var = tk.StringVar()
|
||||
ttk.Entry(lru_action_frame, textvariable=self.lru_message_var, state="readonly").pack(side=tk.LEFT, expand=True, fill=tk.X, padx=(0, 5))
|
||||
send_lru_button = ttk.Button(lru_action_frame, text="Send LRU Status", command=self._on_send_lru_status)
|
||||
send_lru_button.pack(side=tk.LEFT)
|
||||
|
||||
# Carica gli scenari solo dopo aver creato scenario_controls
|
||||
self._load_scenarios_into_ui()
|
||||
|
||||
def _on_reset_targets(self):
|
||||
"""
|
||||
Send the tgtreset command to the target communicator to reset all targets on the server.
|
||||
"""
|
||||
from core.command_builder import build_tgtreset
|
||||
if self.target_communicator and self.target_communicator.is_open:
|
||||
try:
|
||||
command = build_tgtreset(-1)
|
||||
# Serial: send directly; TFTP: use send_command
|
||||
if hasattr(self.target_communicator, '_send_single_command'):
|
||||
self.target_communicator._send_single_command(command)
|
||||
self.logger.info("Sent tgtreset command via serial.")
|
||||
messagebox.showinfo("Reset Targets", "Reset command sent to server.", parent=self)
|
||||
elif hasattr(self.target_communicator, 'send_command'):
|
||||
success = self.target_communicator.send_command(command)
|
||||
if success:
|
||||
self.logger.info("Sent tgtreset command via TFTP.")
|
||||
messagebox.showinfo("Reset Targets", "Reset command sent to server.", parent=self)
|
||||
else:
|
||||
self.logger.error("TFTP command upload failed.")
|
||||
messagebox.showerror("Error", "Failed to send reset command via TFTP.", parent=self)
|
||||
else:
|
||||
messagebox.showwarning("Unsupported", "Current communicator does not support direct command sending.", parent=self)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error sending reset command: {e}")
|
||||
messagebox.showerror("Error", f"Failed to send reset command.\n{e}", parent=self)
|
||||
else:
|
||||
messagebox.showerror("Not Connected", "Target communicator is not connected.", parent=self)
|
||||
|
||||
def _on_targets_changed(self, targets):
|
||||
# Called by TargetListFrame when targets are changed
|
||||
self.ppi_widget.update_targets(targets)
|
||||
|
||||
def _create_statusbar(self):
|
||||
status_bar = ttk.Frame(self, relief=tk.SUNKEN)
|
||||
status_bar.pack(side=tk.BOTTOM, fill=tk.X)
|
||||
|
||||
ttk.Label(status_bar, text="Target:").pack(side=tk.LEFT, padx=(5, 2))
|
||||
self.target_status_canvas = tk.Canvas(status_bar, width=16, height=16, highlightthickness=0)
|
||||
self.target_status_canvas.pack(side=tk.LEFT, padx=(0, 10))
|
||||
self._draw_status_indicator(self.target_status_canvas, "#e74c3c") # Default to red
|
||||
|
||||
ttk.Label(status_bar, text="LRU:").pack(side=tk.LEFT, padx=(5, 2))
|
||||
self.lru_status_canvas = tk.Canvas(status_bar, width=16, height=16, highlightthickness=0)
|
||||
self.lru_status_canvas.pack(side=tk.LEFT, padx=(0, 10))
|
||||
self._draw_status_indicator(self.lru_status_canvas, "#e74c3c") # Default to red
|
||||
|
||||
self.status_var = tk.StringVar(value="Ready")
|
||||
ttk.Label(status_bar, textvariable=self.status_var, anchor=tk.W).pack(side=tk.LEFT, fill=tk.X, expand=True, padx=5)
|
||||
def _on_connect_button(self):
|
||||
try:
|
||||
self.logger.info("Connection requested by user.")
|
||||
self._initialize_communicator()
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error during connection: {e}")
|
||||
self._log_message(f"Error during connection: {e}")
|
||||
|
||||
def _log_message(self, message):
|
||||
self.log_text_widget.configure(state=tk.NORMAL)
|
||||
self.log_text_widget.insert(tk.END, message + "\n")
|
||||
self.log_text_widget.configure(state=tk.DISABLED)
|
||||
self.log_text_widget.see(tk.END)
|
||||
def _draw_status_indicator(self, canvas, color):
|
||||
canvas.delete("all")
|
||||
canvas.create_oval(2, 2, 14, 14, fill=color, outline="black")
|
||||
|
||||
|
||||
|
||||
def _create_menubar(self):
|
||||
menubar = tk.Menu(self)
|
||||
self.config(menu=menubar)
|
||||
# Solo Settings Menu
|
||||
settings_menu = tk.Menu(menubar, tearoff=0)
|
||||
menubar.add_cascade(label="Settings", menu=settings_menu)
|
||||
settings_menu.add_command(label="Connection...", command=self._open_settings)
|
||||
settings_menu.add_command(label="Radar Config...", command=self._open_radar_config)
|
||||
|
||||
def _initialize_communicator(self):
|
||||
"""Creates and connects the appropriate communicators based on config."""
|
||||
# Disconnect if already connected
|
||||
if self.target_communicator and self.target_communicator.is_open:
|
||||
self.target_communicator.disconnect()
|
||||
if self.lru_communicator and self.lru_communicator.is_open:
|
||||
self.lru_communicator.disconnect()
|
||||
|
||||
def _create_statusbar(self):
|
||||
status_bar = ttk.Frame(self, relief=tk.SUNKEN)
|
||||
status_bar.pack(side=tk.BOTTOM, fill=tk.X)
|
||||
ttk.Label(status_bar, text="Target:").pack(side=tk.LEFT, padx=(5, 2))
|
||||
self.target_status_canvas = tk.Canvas(status_bar, width=16, height=16, highlightthickness=0)
|
||||
self.target_status_canvas.pack(side=tk.LEFT, padx=(0, 10))
|
||||
self._draw_status_indicator(self.target_status_canvas, "#e74c3c")
|
||||
ttk.Label(status_bar, text="LRU:").pack(side=tk.LEFT, padx=(5, 2))
|
||||
self.lru_status_canvas = tk.Canvas(status_bar, width=16, height=16, highlightthickness=0)
|
||||
self.lru_status_canvas.pack(side=tk.LEFT, padx=(0, 10))
|
||||
self._draw_status_indicator(self.lru_status_canvas, "#e74c3c")
|
||||
self.status_var = tk.StringVar(value="Ready")
|
||||
ttk.Label(status_bar, textvariable=self.status_var, anchor=tk.W).pack(side=tk.LEFT, fill=tk.X, expand=True, padx=5)
|
||||
|
||||
def _draw_status_indicator(self, canvas, color):
|
||||
canvas.delete("all")
|
||||
canvas.create_oval(2, 2, 14, 14, fill=color, outline="black")
|
||||
|
||||
def _update_window_title(self):
|
||||
"""Updates the window title based on the current scenario."""
|
||||
base_title = "Radar Target Simulator"
|
||||
if self.current_scenario_name:
|
||||
self.title(f"{base_title} - {self.current_scenario_name}")
|
||||
else:
|
||||
self.title(base_title)
|
||||
|
||||
def _update_communicator_status(self, comm_name: str, is_connected: bool):
|
||||
canvas = self.target_status_canvas if comm_name == 'Target' else self.lru_status_canvas
|
||||
color = "#2ecc40" if is_connected else "#e74c3c"
|
||||
self._draw_status_indicator(canvas, color)
|
||||
|
||||
def _initialize_communicators(self):
|
||||
# Disconnect any existing connections
|
||||
if self.target_communicator and self.target_communicator.is_open: self.target_communicator.disconnect()
|
||||
if self.lru_communicator and self.lru_communicator.is_open: self.lru_communicator.disconnect()
|
||||
|
||||
# --- Initialize Target Communicator ---
|
||||
# --- Initialize Target Communicator ---
|
||||
target_cfg = self.connection_config.get("target", {})
|
||||
target_comm_type = target_cfg.get("type")
|
||||
self.logger.info(f"Initializing Target communicator of type: {target_comm_type}")
|
||||
self.target_communicator = None
|
||||
config_data = None
|
||||
if target_comm_type == "serial":
|
||||
config_data = target_cfg.get("serial", {})
|
||||
port = config_data.get("port")
|
||||
baudrate = config_data.get("baudrate")
|
||||
if isinstance(port, str) and port and isinstance(baudrate, int):
|
||||
self.target_communicator = SerialCommunicator()
|
||||
if self.target_communicator.connect(config_data):
|
||||
self._draw_status_indicator(self.target_status_canvas, "#2ecc40") # green
|
||||
self.status_var.set("Target: Connected")
|
||||
else:
|
||||
self._draw_status_indicator(self.target_status_canvas, "#e74c3c") # red
|
||||
self.status_var.set("Target: Disconnected")
|
||||
else:
|
||||
self.logger.error(f"Skipped Target serial connection: invalid config {config_data}")
|
||||
self._draw_status_indicator(self.target_status_canvas, "#e74c3c")
|
||||
self.status_var.set("Target: Invalid config")
|
||||
elif target_comm_type == "tftp":
|
||||
config_data = target_cfg.get("tftp", {})
|
||||
ip = config_data.get("ip")
|
||||
port = config_data.get("port")
|
||||
if isinstance(ip, str) and ip and isinstance(port, int):
|
||||
self.target_communicator = TFTPCommunicator()
|
||||
if self.target_communicator.connect(config_data):
|
||||
self._draw_status_indicator(self.target_status_canvas, "#2ecc40") # green
|
||||
self.status_var.set("Target: Connected")
|
||||
else:
|
||||
self._draw_status_indicator(self.target_status_canvas, "#e74c3c") # red
|
||||
self.status_var.set("Target: Disconnected")
|
||||
else:
|
||||
self.logger.error(f"Skipped Target TFTP connection: invalid config {config_data}")
|
||||
self._draw_status_indicator(self.target_status_canvas, "#e74c3c")
|
||||
self.status_var.set("Target: Invalid config")
|
||||
|
||||
# --- Initialize LRU Communicator ---
|
||||
lru_cfg = self.connection_config.get("lru", {})
|
||||
lru_comm_type = lru_cfg.get("type")
|
||||
self.logger.info(f"Initializing LRU communicator of type: {lru_comm_type}")
|
||||
self.lru_communicator = None
|
||||
lru_config_data = None
|
||||
if lru_comm_type == "serial":
|
||||
lru_config_data = lru_cfg.get("serial", {})
|
||||
port = lru_config_data.get("port")
|
||||
baudrate = lru_config_data.get("baudrate")
|
||||
if isinstance(port, str) and port and isinstance(baudrate, int):
|
||||
self.lru_communicator = SerialCommunicator()
|
||||
if self.lru_communicator.connect(lru_config_data):
|
||||
self._draw_status_indicator(self.lru_status_canvas, "#2ecc40") # green
|
||||
else:
|
||||
self._draw_status_indicator(self.lru_status_canvas, "#e74c3c") # red
|
||||
else:
|
||||
self.logger.error(f"Skipped LRU serial connection: invalid config {lru_config_data}")
|
||||
self._draw_status_indicator(self.lru_status_canvas, "#e74c3c")
|
||||
elif lru_comm_type == "tftp":
|
||||
lru_config_data = lru_cfg.get("tftp", {})
|
||||
ip = lru_config_data.get("ip")
|
||||
port = lru_config_data.get("port")
|
||||
if isinstance(ip, str) and ip and isinstance(port, int):
|
||||
self.lru_communicator = TFTPCommunicator()
|
||||
if self.lru_communicator.connect(lru_config_data):
|
||||
self._draw_status_indicator(self.lru_status_canvas, "#2ecc40") # green
|
||||
else:
|
||||
self._draw_status_indicator(self.lru_status_canvas, "#e74c3c") # red
|
||||
else:
|
||||
self.logger.error(f"Skipped LRU TFTP connection: invalid config {lru_config_data}")
|
||||
self._draw_status_indicator(self.lru_status_canvas, "#e74c3c")
|
||||
|
||||
|
||||
# Initialize Target Communicator
|
||||
self.target_communicator, target_connected = self._setup_communicator(target_cfg, "Target")
|
||||
self._update_communicator_status("Target", target_connected)
|
||||
|
||||
# Initialize LRU Communicator
|
||||
self.lru_communicator, lru_connected = self._setup_communicator(lru_cfg, "LRU")
|
||||
self._update_communicator_status("LRU", lru_connected)
|
||||
|
||||
def _setup_communicator(self, config: dict, name: str) -> (Optional[CommunicatorInterface], bool):
|
||||
comm_type = config.get("type")
|
||||
self.logger.info(f"Initializing {name} communicator of type: {comm_type}")
|
||||
|
||||
communicator = None
|
||||
config_data = None
|
||||
|
||||
if comm_type == "serial":
|
||||
communicator = SerialCommunicator()
|
||||
config_data = config.get("serial", {})
|
||||
elif comm_type == "tftp":
|
||||
communicator = TFTPCommunicator()
|
||||
config_data = config.get("tftp", {})
|
||||
|
||||
if communicator and config_data:
|
||||
if communicator.connect(config_data):
|
||||
return communicator, True
|
||||
|
||||
self.logger.warning(f"Failed to initialize or connect {name} communicator.")
|
||||
return None, False
|
||||
|
||||
def update_connection_settings(self, new_config: Dict[str, Any]):
|
||||
"""Callback from SettingsWindow to apply and save new connection settings."""
|
||||
self.logger.info(f"Updating connection settings: {new_config}")
|
||||
self.connection_config = new_config
|
||||
self.config_manager.save_connection_settings(new_config)
|
||||
self._initialize_communicator() # Re-initialize with new settings
|
||||
self._initialize_communicators()
|
||||
|
||||
def _open_settings(self):
|
||||
self.logger.info("Opening connection settings window.")
|
||||
ConnectionSettingsWindow(self, self.config_manager, self.connection_config)
|
||||
|
||||
def _on_send_scenario(self):
|
||||
if self.target_communicator and self.target_communicator.is_open:
|
||||
if self.scenario and self.scenario.get_all_targets():
|
||||
self.target_communicator.send_scenario(self.scenario)
|
||||
else:
|
||||
messagebox.showinfo("Empty Scenario", "Cannot send an empty scenario.", parent=self)
|
||||
else:
|
||||
messagebox.showerror("Not Connected", "Target communicator is not connected. Please check settings.", parent=self)
|
||||
def _on_connect_button(self):
|
||||
self.logger.info("Connection requested by user.")
|
||||
self._initialize_communicators()
|
||||
|
||||
def _on_send_lru_status(self):
|
||||
cooling_status = self.cooling_status_var.get()
|
||||
power_status = self.power_status_var.get()
|
||||
def _on_start_simulation(self):
|
||||
if self.is_simulation_running.get():
|
||||
self.logger.info("Simulation is already running.")
|
||||
return
|
||||
if not self.target_communicator or not self.target_communicator.is_open:
|
||||
messagebox.showerror("Not Connected", "Target communicator is not connected. Please check settings and connect.")
|
||||
return
|
||||
if not self.scenario or not self.scenario.get_all_targets():
|
||||
messagebox.showinfo("Empty Scenario", "Cannot start simulation with an empty scenario.")
|
||||
return
|
||||
|
||||
# Assume a simple CSV-like format for the message
|
||||
message = f"LRU_STATUS,COOLING={cooling_status},POWER={power_status}"
|
||||
self.lru_message_var.set(message)
|
||||
self.logger.info(f"Formatted LRU status message: {message}")
|
||||
self.logger.info("Starting live simulation...")
|
||||
self.is_simulation_running.set(True)
|
||||
self._update_button_states()
|
||||
|
||||
if self.lru_communicator and self.lru_communicator.is_open:
|
||||
if hasattr(self.lru_communicator, 'send_raw_message'):
|
||||
self.lru_communicator.send_raw_message(message)
|
||||
self.logger.info("Sent LRU status message to radar.")
|
||||
messagebox.showinfo("LRU Status Sent", f"Message sent:\n{message}", parent=self)
|
||||
else:
|
||||
self.logger.warning("Communicator does not have a 'send_raw_message' method.")
|
||||
messagebox.showwarning("Not Supported", "The current communicator does not support sending raw messages.", parent=self)
|
||||
else:
|
||||
self.logger.warning("Attempted to send LRU status while disconnected.")
|
||||
messagebox.showerror("Not Connected", "LRU communicator is not connected. Please check settings.", parent=self)
|
||||
self.scenario.reset_simulation()
|
||||
self.simulation_engine = SimulationEngine(self.target_communicator, self.gui_update_queue)
|
||||
self.simulation_engine.load_scenario(self.scenario)
|
||||
self.simulation_engine.start()
|
||||
|
||||
self.after(GUI_QUEUE_POLL_INTERVAL_MS, self._process_gui_queue)
|
||||
|
||||
def _on_stop_simulation(self):
|
||||
if not self.is_simulation_running.get() or not self.simulation_engine:
|
||||
return
|
||||
|
||||
self.logger.info("Stopping live simulation...")
|
||||
self.simulation_engine.stop()
|
||||
self.simulation_engine = None
|
||||
self.is_simulation_running.set(False)
|
||||
self._update_button_states()
|
||||
|
||||
def _on_reset_simulation(self):
|
||||
self.logger.info("Resetting scenario to initial state.")
|
||||
if self.is_simulation_running.get():
|
||||
self._on_stop_simulation()
|
||||
|
||||
self.scenario.reset_simulation()
|
||||
self._update_all_views()
|
||||
|
||||
# --- Other methods remain largely the same ---
|
||||
def _process_gui_queue(self):
|
||||
try:
|
||||
while not self.gui_update_queue.empty():
|
||||
updated_targets: List[Target] = self.gui_update_queue.get_nowait()
|
||||
self._update_all_views(updated_targets)
|
||||
finally:
|
||||
if self.is_simulation_running.get():
|
||||
self.after(GUI_QUEUE_POLL_INTERVAL_MS, self._process_gui_queue)
|
||||
|
||||
def _update_window_title(self):
|
||||
title = "Radar Target Simulator"
|
||||
if self.current_scenario_name:
|
||||
title = f"{self.current_scenario_name} - {title}"
|
||||
self.title(title)
|
||||
def _update_button_states(self):
|
||||
is_running = self.is_simulation_running.get()
|
||||
state = tk.DISABLED if is_running else tk.NORMAL
|
||||
|
||||
self.start_button.config(state=tk.DISABLED if is_running else tk.NORMAL)
|
||||
self.stop_button.config(state=tk.NORMAL if is_running else tk.DISABLED)
|
||||
|
||||
self.scenario_controls.new_button.config(state=state)
|
||||
self.scenario_controls.save_button.config(state=state)
|
||||
self.scenario_controls.save_as_button.config(state=state)
|
||||
self.scenario_controls.delete_button.config(state=state)
|
||||
self.scenario_controls.scenario_combobox.config(state="readonly" if not is_running else tk.DISABLED)
|
||||
|
||||
self.target_list.add_button.config(state=state)
|
||||
self.target_list.remove_button.config(state=state)
|
||||
self.target_list.edit_button.config(state=state)
|
||||
self.target_list.tree.config(selectmode="browse" if not is_running else "none")
|
||||
|
||||
def _update_all_views(self):
|
||||
def _on_targets_changed(self, targets: List[Target]):
|
||||
self.ppi_widget.update_targets(targets)
|
||||
|
||||
def _update_all_views(self, targets_to_display: Optional[List[Target]] = None):
|
||||
self._update_window_title()
|
||||
all_targets = self.scenario.get_all_targets()
|
||||
self.target_list.update_target_list(all_targets)
|
||||
self.ppi_widget.update_targets(all_targets)
|
||||
self.logger.info(f"Views updated for scenario '{self.current_scenario_name}'. It has {len(all_targets)} target(s).")
|
||||
|
||||
if targets_to_display is None:
|
||||
targets_to_display = self.scenario.get_all_targets()
|
||||
|
||||
self.target_list.update_target_list(targets_to_display)
|
||||
self.ppi_widget.update_targets(targets_to_display)
|
||||
|
||||
def _load_scenarios_into_ui(self):
|
||||
scenario_names = self.config_manager.get_scenario_names()
|
||||
self.scenario_controls.update_scenario_list(scenario_names, self.current_scenario_name)
|
||||
# Also update the values for the simulation tab combobox
|
||||
if hasattr(self, 'sim_scenario_combobox'):
|
||||
self.sim_scenario_combobox['values'] = scenario_names
|
||||
self.sim_scenario_combobox['values'] = scenario_names
|
||||
|
||||
# Rimosso: la logica di aggiunta target è ora nel TargetListFrame
|
||||
|
||||
def _on_load_scenario(self, scenario_name: str):
|
||||
if self.is_simulation_running.get(): self._on_stop_simulation()
|
||||
self.logger.info(f"Loading scenario: {scenario_name}")
|
||||
scenario_data = self.config_manager.get_scenario(scenario_name)
|
||||
if scenario_data:
|
||||
@ -416,54 +354,71 @@ class MainView(tk.Tk):
|
||||
self.scenario = Scenario.from_dict(scenario_data)
|
||||
self.current_scenario_name = scenario_name
|
||||
self._update_all_views()
|
||||
self.sim_scenario_combobox.set(scenario_name)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to parse scenario data for '{scenario_name}': {e}")
|
||||
self.logger.error(f"Failed to parse scenario data for '{scenario_name}': {e}", exc_info=True)
|
||||
messagebox.showerror("Load Error", f"Could not load scenario '{scenario_name}'.\n{e}")
|
||||
else:
|
||||
self.logger.warning(f"Attempted to load a non-existent scenario: {scenario_name}")
|
||||
|
||||
def _on_save_scenario_as(self, scenario_name: str):
|
||||
self.logger.info(f"Saving current state as new scenario: {scenario_name}")
|
||||
scenario_data = self.scenario.to_dict()
|
||||
self.config_manager.save_scenario(scenario_name, scenario_data)
|
||||
def _on_save_scenario(self, scenario_name: str):
|
||||
self.logger.info(f"Saving scenario: {scenario_name}")
|
||||
self.scenario.targets = {t.target_id: t for t in self.target_list.get_targets()}
|
||||
self.config_manager.save_scenario(scenario_name, self.scenario.to_dict())
|
||||
self.current_scenario_name = scenario_name
|
||||
self._load_scenarios_into_ui() # Refresh list
|
||||
self._load_scenarios_into_ui()
|
||||
self._update_window_title()
|
||||
messagebox.showinfo("Success", f"Scenario '{scenario_name}' saved successfully.", parent=self)
|
||||
|
||||
def _on_save_scenario(self, scenario_name: str):
|
||||
"""
|
||||
Save the current scenario and its targets (from the target list) under the selected name.
|
||||
"""
|
||||
self.logger.info(f"Saving scenario: {scenario_name}")
|
||||
# Update scenario's targets from the target list
|
||||
def _on_save_scenario_as(self, scenario_name: str):
|
||||
self.scenario.targets = {t.target_id: t for t in self.target_list.get_targets()}
|
||||
scenario_data = self.scenario.to_dict()
|
||||
self.config_manager.save_scenario(scenario_name, scenario_data)
|
||||
self._on_save_scenario(scenario_name)
|
||||
|
||||
def _on_new_scenario(self, scenario_name: str):
|
||||
scenario_names = self.config_manager.get_scenario_names()
|
||||
if scenario_name in scenario_names:
|
||||
messagebox.showinfo("Duplicate Scenario", f"Scenario '{scenario_name}' already exists. Loading it instead.", parent=self)
|
||||
self._on_load_scenario(scenario_name)
|
||||
return
|
||||
|
||||
self.logger.info(f"Creating new scenario: {scenario_name}")
|
||||
self.scenario = Scenario(name=scenario_name)
|
||||
self.current_scenario_name = scenario_name
|
||||
self._load_scenarios_into_ui() # Refresh list
|
||||
self._update_window_title()
|
||||
messagebox.showinfo("Success", f"Scenario '{scenario_name}' saved successfully.", parent=self)
|
||||
|
||||
# Save the new empty scenario immediately so it's persisted
|
||||
self.config_manager.save_scenario(scenario_name, self.scenario.to_dict())
|
||||
|
||||
# Update all UI elements
|
||||
self._update_all_views() # Clears targets, updates title
|
||||
self._load_scenarios_into_ui() # Reloads scenario lists in comboboxes
|
||||
|
||||
# After reloading, explicitly set the current selection to the new scenario
|
||||
self.scenario_controls.current_scenario.set(scenario_name)
|
||||
self.sim_scenario_combobox.set(scenario_name)
|
||||
|
||||
def _on_delete_scenario(self, scenario_name: str):
|
||||
self.logger.info(f"Deleting scenario: {scenario_name}")
|
||||
self.config_manager.delete_scenario(scenario_name)
|
||||
self.logger.info(f"Scenario '{scenario_name}' deleted.")
|
||||
if self.current_scenario_name == scenario_name:
|
||||
self.scenario = Scenario()
|
||||
self.current_scenario_name = None
|
||||
self.scenario = Scenario()
|
||||
self._update_all_views()
|
||||
self._load_scenarios_into_ui()
|
||||
messagebox.showinfo("Deleted", f"Scenario '{scenario_name}' has been deleted.", parent=self)
|
||||
|
||||
def _on_send_lru_status(self):
|
||||
# Implementation from your code
|
||||
pass
|
||||
|
||||
def _on_reset_targets(self):
|
||||
# Implementation from your code
|
||||
pass
|
||||
|
||||
def _open_radar_config(self):
|
||||
self.logger.info("Opening radar config window.")
|
||||
dialog = RadarConfigWindow(self, current_scan_limit=self.scan_limit, current_max_range=self.max_range)
|
||||
if dialog.scan_limit is not None and dialog.max_range is not None:
|
||||
self.scan_limit = dialog.scan_limit
|
||||
self.max_range = dialog.max_range
|
||||
self.logger.info(f"Scan limit set to: ±{self.scan_limit} degrees")
|
||||
self.logger.info(f"Max range set to: {self.max_range} NM")
|
||||
self.ppi_widget.destroy()
|
||||
self.ppi_widget = PPIDisplay(self.h_pane, max_range_nm=self.max_range, scan_limit_deg=self.scan_limit)
|
||||
self.h_pane.add(self.ppi_widget, weight=2)
|
||||
@ -471,7 +426,9 @@ class MainView(tk.Tk):
|
||||
|
||||
def _on_closing(self):
|
||||
self.logger.info("Application shutting down.")
|
||||
# Save general settings
|
||||
if self.is_simulation_running.get():
|
||||
self._on_stop_simulation()
|
||||
|
||||
settings_to_save = {
|
||||
'scan_limit': self.scan_limit,
|
||||
'max_range': self.max_range,
|
||||
@ -481,10 +438,8 @@ class MainView(tk.Tk):
|
||||
self.config_manager.save_general_settings(settings_to_save)
|
||||
self.config_manager.save_connection_settings(self.connection_config)
|
||||
|
||||
if self.target_communicator and self.target_communicator.is_open:
|
||||
self.target_communicator.disconnect()
|
||||
if self.lru_communicator and self.lru_communicator.is_open:
|
||||
self.lru_communicator.disconnect()
|
||||
if self.target_communicator and self.target_communicator.is_open: self.target_communicator.disconnect()
|
||||
if self.lru_communicator and self.lru_communicator.is_open: self.lru_communicator.disconnect()
|
||||
|
||||
shutdown_logging_system()
|
||||
self.destroy()
|
||||
self.destroy()
|
||||
@ -128,8 +128,8 @@ class PPIDisplay(ttk.Frame):
|
||||
self._target_dots = []
|
||||
|
||||
for target in getattr(self, 'active_targets', []):
|
||||
r1 = target.range_nm
|
||||
th1 = np.deg2rad(target.azimuth_deg)
|
||||
r1 = target.current_range_nm
|
||||
th1 = np.deg2rad(target.current_azimuth_deg)
|
||||
# Punto del target
|
||||
dot, = self.ax.plot(th1, r1, 'o', markersize=5, color='red', picker=5)
|
||||
self.target_artists.append(dot)
|
||||
@ -138,7 +138,7 @@ class PPIDisplay(ttk.Frame):
|
||||
# Vettore heading
|
||||
x1 = r1 * np.sin(th1)
|
||||
y1 = r1 * np.cos(th1)
|
||||
h_rad = np.deg2rad(target.heading_deg)
|
||||
h_rad = np.deg2rad(target.current_heading_deg)
|
||||
dx = vector_len * np.sin(h_rad)
|
||||
dy = vector_len * np.cos(h_rad)
|
||||
x2, y2 = x1 + dx, y1 + dy
|
||||
|
||||
@ -1,231 +1,387 @@
|
||||
# target_simulator/gui/target_list_frame.py
|
||||
|
||||
"""
|
||||
|
||||
A frame containing the Treeview for listing targets and control buttons.
|
||||
|
||||
"""
|
||||
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
from typing import List
|
||||
|
||||
from tkinter import ttk, messagebox
|
||||
|
||||
from typing import List, Callable
|
||||
|
||||
from core.models import Target
|
||||
|
||||
|
||||
|
||||
class TargetListFrame(ttk.LabelFrame):
|
||||
def get_targets(self) -> List[Target]:
|
||||
"""Restituisce la lista aggiornata dei target."""
|
||||
return list(getattr(self, 'targets_cache', []))
|
||||
|
||||
"""Frame for displaying and managing the list of targets."""
|
||||
|
||||
def __init__(self, master, targets_changed_callback=None):
|
||||
|
||||
|
||||
def __init__(self, master, targets_changed_callback: Callable[[List[Target]], None] | None = None):
|
||||
|
||||
super().__init__(master, text="Target List")
|
||||
self.targets_cache = []
|
||||
|
||||
self.targets_cache: List[Target] = []
|
||||
|
||||
self.targets_changed_callback = targets_changed_callback
|
||||
|
||||
|
||||
|
||||
# --- Treeview for Targets ---
|
||||
|
||||
columns = ("id", "range", "az", "vel", "hdg", "alt")
|
||||
|
||||
self.tree = ttk.Treeview(self, columns=columns, show="headings")
|
||||
|
||||
|
||||
|
||||
# Define headings
|
||||
|
||||
self.tree.heading("id", text="ID")
|
||||
|
||||
self.tree.heading("range", text="Range (NM, m)")
|
||||
|
||||
self.tree.heading("az", text="Azimuth (°)")
|
||||
self.tree.heading("vel", text="Velocity (knots, m/s)")
|
||||
|
||||
self.tree.heading("vel", text="Velocity (kn, m/s)")
|
||||
|
||||
self.tree.heading("hdg", text="Heading (°)")
|
||||
|
||||
self.tree.heading("alt", text="Altitude (ft)")
|
||||
|
||||
|
||||
|
||||
# Configure column widths
|
||||
|
||||
self.tree.column("id", width=40, anchor=tk.CENTER)
|
||||
self.tree.column("range", width=100, anchor=tk.E)
|
||||
self.tree.column("az", width=100, anchor=tk.E)
|
||||
self.tree.column("vel", width=100, anchor=tk.E)
|
||||
self.tree.column("hdg", width=100, anchor=tk.E)
|
||||
|
||||
self.tree.column("range", width=120, anchor=tk.E)
|
||||
|
||||
self.tree.column("az", width=80, anchor=tk.E)
|
||||
|
||||
self.tree.column("vel", width=120, anchor=tk.E)
|
||||
|
||||
self.tree.column("hdg", width=80, anchor=tk.E)
|
||||
|
||||
self.tree.column("alt", width=100, anchor=tk.E)
|
||||
|
||||
|
||||
|
||||
# --- Scrollbar ---
|
||||
|
||||
scrollbar = ttk.Scrollbar(self, orient=tk.VERTICAL, command=self.tree.yview)
|
||||
|
||||
self.tree.configure(yscroll=scrollbar.set)
|
||||
|
||||
|
||||
|
||||
# --- Layout ---
|
||||
|
||||
self.tree.grid(row=0, column=0, sticky=tk.NSEW)
|
||||
|
||||
scrollbar.grid(row=0, column=1, sticky=tk.NS)
|
||||
|
||||
self.rowconfigure(0, weight=1)
|
||||
|
||||
self.columnconfigure(0, weight=1)
|
||||
|
||||
|
||||
|
||||
# --- Buttons ---
|
||||
|
||||
button_frame = ttk.Frame(self)
|
||||
|
||||
button_frame.grid(row=1, column=0, columnspan=2, pady=5)
|
||||
|
||||
|
||||
|
||||
self.add_button = ttk.Button(button_frame, text="Add", command=self._on_add_click)
|
||||
|
||||
self.add_button.pack(side=tk.LEFT, padx=5)
|
||||
|
||||
|
||||
|
||||
self.remove_button = ttk.Button(button_frame, text="Remove", command=self._on_remove_click)
|
||||
|
||||
self.remove_button.pack(side=tk.LEFT, padx=5)
|
||||
|
||||
|
||||
|
||||
self.edit_button = ttk.Button(button_frame, text="Edit", command=self._on_edit_click)
|
||||
|
||||
self.edit_button.pack(side=tk.LEFT, padx=5)
|
||||
|
||||
def _on_add_click(self):
|
||||
from .add_target_window import AddTargetWindow
|
||||
existing_ids = [t.target_id for t in getattr(self, 'targets_cache', [])]
|
||||
add_window = AddTargetWindow(self, existing_ids=existing_ids)
|
||||
add_window.wait_window()
|
||||
if add_window.new_target:
|
||||
self.targets_cache.append(add_window.new_target)
|
||||
self.update_target_list(self.targets_cache)
|
||||
if self.targets_changed_callback:
|
||||
self.targets_changed_callback(self.targets_cache)
|
||||
|
||||
def _on_remove_click(self):
|
||||
selected = self.tree.focus()
|
||||
if not selected:
|
||||
from tkinter import messagebox
|
||||
messagebox.showwarning("No selection", "Select a target from the table before pressing 'Remove'.")
|
||||
return
|
||||
values = self.tree.item(selected, 'values')
|
||||
if not values:
|
||||
from tkinter import messagebox
|
||||
messagebox.showwarning("No selection", "Select a valid target from the table before pressing 'Remove'.")
|
||||
return
|
||||
try:
|
||||
target_id = int(values[0])
|
||||
except (ValueError, IndexError):
|
||||
from tkinter import messagebox
|
||||
messagebox.showwarning("No selection", "Select a valid target from the table before pressing 'Remove'.")
|
||||
return
|
||||
# Rimuovi il target dalla lista
|
||||
self.targets_cache = [t for t in getattr(self, 'targets_cache', []) if t.target_id != target_id]
|
||||
self.update_target_list(self.targets_cache)
|
||||
if self.targets_changed_callback:
|
||||
self.targets_changed_callback(self.targets_cache)
|
||||
|
||||
# Bind doppio click su una riga (usando identify_row per maggiore affidabilità)
|
||||
# Bind double click
|
||||
|
||||
self.tree.bind('<Double-1>', self._on_double_click)
|
||||
|
||||
def _on_edit_click(self):
|
||||
item_id = self.tree.focus()
|
||||
if not item_id:
|
||||
from tkinter import messagebox
|
||||
messagebox.showwarning("No selection", "Select a target from the table before pressing 'Edit'.")
|
||||
return
|
||||
values = self.tree.item(item_id, 'values')
|
||||
if not values:
|
||||
from tkinter import messagebox
|
||||
messagebox.showwarning("No selection", "Select a valid target from the table before pressing 'Edit'.")
|
||||
return
|
||||
try:
|
||||
target_id = int(values[0])
|
||||
except (ValueError, IndexError):
|
||||
from tkinter import messagebox
|
||||
messagebox.showwarning("No selection", "Select a valid target from the table before pressing 'Edit'.")
|
||||
return
|
||||
# Recupera il target dalla lista
|
||||
target = None
|
||||
if hasattr(self, 'targets_cache'):
|
||||
for t in self.targets_cache:
|
||||
if t.target_id == target_id:
|
||||
target = t
|
||||
break
|
||||
if not target:
|
||||
from tkinter import messagebox
|
||||
messagebox.showwarning("No selection", "Seleziona un target valido dalla tabella prima di premere 'Edit'.")
|
||||
return
|
||||
# Apri la finestra di modifica
|
||||
|
||||
|
||||
def get_targets(self) -> List[Target]:
|
||||
|
||||
"""Returns the current list of targets from the cache."""
|
||||
|
||||
return self.targets_cache
|
||||
|
||||
|
||||
|
||||
def _on_add_click(self):
|
||||
|
||||
from .add_target_window import AddTargetWindow
|
||||
# Pass all other IDs except the one being edited
|
||||
edit_window = AddTargetWindow(self, existing_ids=[t.target_id for t in self.targets_cache if t.target_id != target_id])
|
||||
# Precompila i dati (tranne l'id)
|
||||
edit_window.id_var.set(target_id)
|
||||
edit_window.id_spinbox.config(state='disabled')
|
||||
edit_window.range_var.set(target.range_nm)
|
||||
edit_window.az_var.set(target.azimuth_deg)
|
||||
# Conversione da fps a knots
|
||||
fps_to_knots = 0.592484
|
||||
edit_window.vel_knots_var.set(target.velocity_fps * fps_to_knots)
|
||||
edit_window.hdg_var.set(target.heading_deg)
|
||||
edit_window.alt_var.set(target.altitude_ft)
|
||||
edit_window.wait_window() # Ora la finestra si apre con i dati precompilati
|
||||
# Se modificato, aggiorna il target
|
||||
if edit_window.new_target:
|
||||
# Aggiorna solo i dati, non l'id
|
||||
target.range_nm = edit_window.new_target.range_nm
|
||||
target.azimuth_deg = edit_window.new_target.azimuth_deg
|
||||
target.velocity_fps = edit_window.new_target.velocity_fps
|
||||
target.heading_deg = edit_window.new_target.heading_deg
|
||||
target.altitude_ft = edit_window.new_target.altitude_ft
|
||||
# Aggiorna la tabella
|
||||
|
||||
existing_ids = [t.target_id for t in self.targets_cache]
|
||||
|
||||
add_window = AddTargetWindow(self, existing_ids=existing_ids)
|
||||
|
||||
self.winfo_toplevel().wait_window(add_window)
|
||||
|
||||
|
||||
|
||||
if add_window.new_target:
|
||||
|
||||
self.targets_cache.append(add_window.new_target)
|
||||
|
||||
self.update_target_list(self.targets_cache)
|
||||
|
||||
if self.targets_changed_callback:
|
||||
|
||||
self.targets_changed_callback(self.targets_cache)
|
||||
|
||||
def _on_double_click(self, event):
|
||||
# Usa identify_row per trovare la riga cliccata
|
||||
item_id = self.tree.identify_row(event.y)
|
||||
if not item_id:
|
||||
return
|
||||
values = self.tree.item(item_id, 'values')
|
||||
if not values:
|
||||
|
||||
|
||||
def _on_remove_click(self):
|
||||
|
||||
selected_item = self.tree.focus()
|
||||
|
||||
if not selected_item:
|
||||
|
||||
messagebox.showwarning("No Selection", "Please select a target to remove.", parent=self)
|
||||
|
||||
return
|
||||
|
||||
|
||||
|
||||
try:
|
||||
target_id = int(values[0])
|
||||
except (ValueError, IndexError):
|
||||
return
|
||||
# Recupera il target dalla lista
|
||||
target = None
|
||||
if hasattr(self, 'targets_cache'):
|
||||
for t in self.targets_cache:
|
||||
if t.target_id == target_id:
|
||||
target = t
|
||||
break
|
||||
if not target:
|
||||
return
|
||||
# Apri la finestra di modifica
|
||||
from .add_target_window import AddTargetWindow
|
||||
edit_window = AddTargetWindow(self, existing_ids=[target_id])
|
||||
# Precompila i dati (tranne l'id)
|
||||
edit_window.id_var.set(target_id)
|
||||
edit_window.id_spinbox.config(state='disabled')
|
||||
edit_window.range_var.set(target.range_nm)
|
||||
edit_window.az_var.set(target.azimuth_deg)
|
||||
# Conversione da fps a knots
|
||||
fps_to_knots = 0.592484
|
||||
edit_window.vel_knots_var.set(target.velocity_fps * fps_to_knots)
|
||||
edit_window.hdg_var.set(target.heading_deg)
|
||||
edit_window.alt_var.set(target.altitude_ft)
|
||||
edit_window.wait_window()
|
||||
# Se modificato, aggiorna il target
|
||||
if edit_window.new_target:
|
||||
# Aggiorna solo i dati, non l'id
|
||||
target.range_nm = edit_window.new_target.range_nm
|
||||
target.azimuth_deg = edit_window.new_target.azimuth_deg
|
||||
target.velocity_fps = edit_window.new_target.velocity_fps
|
||||
target.heading_deg = edit_window.new_target.heading_deg
|
||||
target.altitude_ft = edit_window.new_target.altitude_ft
|
||||
# Aggiorna la tabella
|
||||
|
||||
target_id = int(self.tree.item(selected_item, "values")[0])
|
||||
|
||||
self.targets_cache = [t for t in self.targets_cache if t.target_id != target_id]
|
||||
|
||||
self.update_target_list(self.targets_cache)
|
||||
|
||||
# Rimosso il secondo __init__ duplicato
|
||||
|
||||
def update_target_list(self, targets: List[Target]):
|
||||
"""Clears and repopulates the treeview with the current list of targets."""
|
||||
self.targets_cache = list(targets)
|
||||
# Clear existing items
|
||||
for item in self.tree.get_children():
|
||||
self.tree.delete(item)
|
||||
if self.targets_changed_callback:
|
||||
|
||||
self.targets_changed_callback(self.targets_cache)
|
||||
|
||||
except (ValueError, IndexError):
|
||||
|
||||
messagebox.showerror("Error", "Could not identify the selected target.", parent=self)
|
||||
|
||||
|
||||
|
||||
def _on_edit_click(self, event=None):
|
||||
|
||||
selected_item = self.tree.focus()
|
||||
|
||||
if not selected_item:
|
||||
|
||||
if event: # Don't show warning on double-click if nothing is selected
|
||||
|
||||
return
|
||||
|
||||
messagebox.showwarning("No Selection", "Please select a target to edit.", parent=self)
|
||||
|
||||
return
|
||||
|
||||
|
||||
|
||||
try:
|
||||
|
||||
target_id = int(self.tree.item(selected_item, "values")[0])
|
||||
|
||||
except (ValueError, IndexError):
|
||||
|
||||
messagebox.showerror("Error", "Could not identify the selected target.", parent=self)
|
||||
|
||||
return
|
||||
|
||||
|
||||
|
||||
target_to_edit = next((t for t in self.targets_cache if t.target_id == target_id), None)
|
||||
|
||||
if not target_to_edit:
|
||||
|
||||
messagebox.showerror("Error", f"Target with ID {target_id} not found.", parent=self)
|
||||
|
||||
return
|
||||
|
||||
|
||||
|
||||
from .add_target_window import AddTargetWindow
|
||||
|
||||
other_ids = [t.target_id for t in self.targets_cache if t.target_id != target_id]
|
||||
|
||||
edit_window = AddTargetWindow(self, existing_ids=other_ids)
|
||||
|
||||
edit_window.title("Edit Target")
|
||||
|
||||
|
||||
|
||||
# Pre-fill data
|
||||
|
||||
edit_window.id_var.set(target_to_edit.target_id)
|
||||
|
||||
edit_window.id_spinbox.config(state='disabled')
|
||||
|
||||
edit_window.range_var.set(target_to_edit.initial_range_nm)
|
||||
|
||||
edit_window.az_var.set(target_to_edit.initial_azimuth_deg)
|
||||
|
||||
edit_window.alt_var.set(target_to_edit.initial_altitude_ft)
|
||||
|
||||
|
||||
|
||||
# Pre-fill from trajectory (assuming simple model for now)
|
||||
|
||||
if target_to_edit.trajectory:
|
||||
|
||||
first_waypoint = target_to_edit.trajectory[0]
|
||||
|
||||
# Add new items
|
||||
for target in sorted(targets, key=lambda t: t.target_id):
|
||||
# Conversioni
|
||||
nm_to_m = 1852.0
|
||||
fps_to_knots = 0.592484
|
||||
|
||||
velocity_knots = first_waypoint.target_velocity_fps * fps_to_knots
|
||||
|
||||
edit_window.vel_knots_var.set(velocity_knots)
|
||||
|
||||
edit_window.hdg_var.set(first_waypoint.target_heading_deg)
|
||||
|
||||
|
||||
|
||||
self.winfo_toplevel().wait_window(edit_window)
|
||||
|
||||
|
||||
|
||||
if edit_window.new_target:
|
||||
|
||||
# Find and replace the old target with the edited one
|
||||
|
||||
for i, t in enumerate(self.targets_cache):
|
||||
|
||||
if t.target_id == target_id:
|
||||
|
||||
self.targets_cache[i] = edit_window.new_target
|
||||
|
||||
break
|
||||
|
||||
|
||||
|
||||
self.update_target_list(self.targets_cache)
|
||||
|
||||
if self.targets_changed_callback:
|
||||
|
||||
self.targets_changed_callback(self.targets_cache)
|
||||
|
||||
|
||||
|
||||
def _on_double_click(self, event):
|
||||
|
||||
# Ensure a row was actually double-clicked
|
||||
|
||||
if self.tree.identify_row(event.y):
|
||||
|
||||
self._on_edit_click(event)
|
||||
|
||||
|
||||
|
||||
def update_target_list(self, targets: List[Target]):
|
||||
|
||||
"""Clears and repopulates the treeview with the current list of targets."""
|
||||
|
||||
self.targets_cache = list(targets)
|
||||
|
||||
|
||||
|
||||
# Remember selection
|
||||
|
||||
selected_item = self.tree.focus()
|
||||
|
||||
selected_id = None
|
||||
|
||||
if selected_item:
|
||||
|
||||
try:
|
||||
|
||||
selected_id = int(self.tree.item(selected_item, "values")[0])
|
||||
|
||||
except (ValueError, IndexError):
|
||||
|
||||
selected_id = None
|
||||
|
||||
|
||||
|
||||
# Clear and repopulate
|
||||
|
||||
self.tree.delete(*self.tree.get_children())
|
||||
|
||||
|
||||
|
||||
new_selection_item = None
|
||||
|
||||
for target in sorted(self.targets_cache, key=lambda t: t.target_id):
|
||||
|
||||
# Conversions
|
||||
|
||||
nm_to_m = 1852.0
|
||||
|
||||
fps_to_knots = 0.592484
|
||||
|
||||
knots_to_ms = 0.514444
|
||||
range_m = target.range_nm * nm_to_m
|
||||
vel_knots = target.velocity_fps * fps_to_knots
|
||||
|
||||
|
||||
|
||||
range_m = target.current_range_nm * nm_to_m
|
||||
|
||||
vel_knots = target.current_velocity_fps * fps_to_knots
|
||||
|
||||
vel_ms = vel_knots * knots_to_ms
|
||||
|
||||
|
||||
|
||||
values = (
|
||||
|
||||
target.target_id,
|
||||
f"{target.range_nm:.2f} / {range_m:.0f}",
|
||||
f"{target.azimuth_deg:.2f}",
|
||||
|
||||
f"{target.current_range_nm:.2f} / {range_m:.0f}",
|
||||
|
||||
f"{target.current_azimuth_deg:.2f}",
|
||||
|
||||
f"{vel_knots:.2f} / {vel_ms:.2f}",
|
||||
f"{target.heading_deg:.2f}",
|
||||
f"{target.altitude_ft:.2f}",
|
||||
|
||||
f"{target.current_heading_deg:.2f}",
|
||||
|
||||
f"{target.current_altitude_ft:.2f}",
|
||||
|
||||
)
|
||||
self.tree.insert("", tk.END, values=values)
|
||||
|
||||
item = self.tree.insert("", tk.END, values=values)
|
||||
|
||||
if target.target_id == selected_id:
|
||||
|
||||
new_selection_item = item
|
||||
|
||||
|
||||
|
||||
# Restore selection
|
||||
|
||||
if new_selection_item:
|
||||
|
||||
self.tree.focus(new_selection_item)
|
||||
|
||||
self.tree.selection_set(new_selection_item)
|
||||
0
target_simulator/utils/__init__.py
Normal file
0
target_simulator/utils/__init__.py
Normal file
Loading…
Reference in New Issue
Block a user