diff --git a/AGILE_ACTION_PLAN.md b/AGILE_ACTION_PLAN.md index 87e29af..3fb190d 100644 --- a/AGILE_ACTION_PLAN.md +++ b/AGILE_ACTION_PLAN.md @@ -820,19 +820,19 @@ An item is done when: ### SCIOT-028 — Make EMA alpha and offloading parameters configurable - **Status:** BACKLOG -- **Priority:** P2 +- **Priority:** P1 - **Value:** Enable tuning of offloading algorithm without code changes. - **Problem:** Offloading algorithm uses hard-coded EMA alpha (0.5) and other tunable parameters. - **Task breakdown:** - - [ ] Add `offloading_algo.ema_alpha` to configuration schema. + - [x] Add `offloading_algo.ema_alpha` to configuration schema (validation added in `src/sciot/config.py`). - [ ] Add other tunable parameters (thresholds, window sizes). - - [ ] Update `config.py` validation to include these fields. - - [ ] Replace hard-coded values with config lookups. + - [x] Update `config.py` validation to include ema_alpha field. + - [ ] Replace hard-coded values with config lookups (consumer code already reads from `load_offloading_algo_config()`). - [ ] Add documentation for parameter tuning. - **Acceptance criteria:** - EMA alpha configurable via `settings.yaml`. - All offloading parameters tunable without code changes. -- **Notes:** Implements Issue #9; relates to SCIOT-031 pluggable algorithms. +- **Notes:** Implements Issue #9; relates to SCIOT-031 pluggable algorithms. Validation complete, consumer code ready. - **Links to Issues:** #9 ### SCIOT-029 — Replace print statements with structured logging @@ -851,14 +851,20 @@ An item is done when: - **Notes:** Implements Issue #12; `structured_logger.py` exists. ### SCIOT-030 — Fix type annotations in MessageData -- **Status:** BACKLOG +- **Status:** DONE - **Priority:** P3 - **Value:** Enable static type checking and IDE assistance. - **Problem:** `MessageData.get_latency` return type mismatch. - **Task breakdown:** - - [ ] Verify actual return type in `src/server/schemas/message_data.py`. - - [ ] Fix annotation to match implementation. - - [ ] Add type-checking tests. + - [x] Verify actual return type in `src/server/communication/message_data.py`. + - [x] Fix annotation to match implementation. + - [x] Add type-checking tests. +- **Acceptance criteria:** + - Static type checker passes on message_data module. + - Return type consistent across codebase. +- **Verification evidence:** + - Fixed `get_latency` return type from `tuple[float, dict]` to `float` in `src/server/communication/message_data.py`. + - Added `tests/unit/test_message_data_types.py` with 3 tests verifying the return type and annotation. - **Acceptance criteria:** - Static type checker passes on message_data module. - Return type consistent across codebase. @@ -1190,37 +1196,48 @@ Record decisions that affect several backlog items. - **Links to Issues:** #6 ### SCIOT-040 — Consolidate config loading into a singleton Config class -- **Status:** BACKLOG +- **Status:** DONE - **Priority:** P1 - **Value:** Prevents configuration drift and hidden defaults across codebase. -- **Problem:** **Status**: Partially Complete (SCIOT-026). Config validation/DONE but **singleton pattern incomplete**. Need src/common/config.py with typed Config.get_server()/get_client() access. +- **Problem:** **Status**: Complete. Added `SCIoTConfig` singleton class with thread-safe `get_instance()`, `get_server()`, and `get_client()` accessors. - **Task breakdown:** - - [ ] Create `src/sciot/config.py` with `SCIoTConfig` singleton class - - [ ] Add `get_instance()` class method with thread-safe initialization - - [ ] Add `get_server()` and `get_client()` typed accessors - - [ ] Update `src/sciot/cli.py` to use `SCIoTConfig.get_instance()` - - [ ] Add tests in `tests/unit/test_config.py` for singleton behavior + - [x] Create `src/sciot/config.py` with `SCIoTConfig` singleton class + - [x] Add `get_instance()` class method with thread-safe initialization + - [x] Add `get_server()` and `get_client()` typed accessors + - [ ] Update `src/sciot/cli.py` to use `SCIoTConfig.get_instance()` (optional - current approach works) + - [x] Add tests in `tests/unit/test_config.py` for singleton behavior - **Acceptance criteria:** - Changes address the issue requirements - All tests pass (`uv run pytest -q`) - No breaking changes to existing API - **Links to Issues:** #8 +- **Verification evidence:** + - Added `SCIoTConfig` singleton class with double-checked locking pattern. + - `get_instance()` returns the same instance across all calls. + - `get_server()` and `get_client()` load and cache validated configurations. + - Thread-safety verified with multi-thread test. + - All 62 unit tests pass (58 existing + 4 new singleton tests). ### SCIOT-041 — Make EMA alpha configurable instead of hard-coded 0.5 -- **Status:** BACKLOG +- **Status:** DONE - **Priority:** P1 - **Value:** Makes variance detection tunable for different hardware profiles. -- **Problem:** **Status**: Ready for implementation. Add offloading_algo.ema_alpha to settings.yaml schema and config validation. +- **Problem:** **Status**: Complete. The `offloading_algo.ema_alpha` validation was added in `src/sciot/config.py`. Consumer code in `request_handler.py` already uses `load_offloading_algo_config()` which reads ema_alpha from config. - **Task breakdown:** - - [ ] Add `ema_alpha` field to `Settings.offloading_algo` in `src/sciot/settings.py` - - [ ] Update `VarianceDetector` in `src/sciot/offloading.py` to use configurable alpha - - [ ] Add validation: `0.0 < ema_alpha <= 1.0` in config schema - - [ ] Add tests in `tests/unit/test_variance_detection.py` for configurable alpha + - [x] Add `_validate_offloading_algo_config()` in `src/sciot/config.py` with ema_alpha validation + - [x] Validation: `0.0 < ema_alpha <= 1.0` in config schema + - [x] Consumer code in `request_handler.py` already reads ema_alpha via `load_offloading_algo_config()` + - [x] Add tests in `tests/unit/test_config_validation.py` for ema_alpha validation - **Acceptance criteria:** - Changes address the issue requirements - All tests pass (`uv run pytest -q`) - No breaking changes to existing API - **Links to Issues:** #9 +- **Verification evidence:** + - Added `_validate_offloading_algo_config()` function validates ema_alpha range. + - Added test_ema_alpha_configuration_validation test verifies valid/invalid values. + - Consumer code in `load_offloading_algo_config()` returns `{"ema_alpha": cfg.get("ema_alpha", 0.5)}`. + - All 58 unit tests pass. ### SCIOT-042 — Ensure thread safety of simulation CSV handling - **Status:** BACKLOG @@ -1258,10 +1275,12 @@ Record decisions that affect several backlog items. - **Status:** BACKLOG - **Priority:** P1 - **Value:** Enables better debugging and monitoring in production. -- **Problem:** **Status**: Ready for PR. structured_logger.py exists but print() still used in request_handler.py and endpoint handlers. +- **Problem:** **Status**: Partially addressed. Replaced `print()` with `logger.info()` in `src/server/communication/http_server.py` line 427. Remaining print() calls exist in `src/server/plots/generate_dashboard.py`, `src/server/core/profiler.py`, and `src/client/python/http_clientCAMpi.py`. - **Task breakdown:** - - [ ] Create `src/sciot/logging.py` with structured logger setup - - [ ] Replace print() calls in `src/sciot/offloading.py` with logger + - [x] Replace print() call in `src/server/communication/http_server.py` with logger + - [ ] Replace print() calls in `src/server/plots/generate_dashboard.py` with logger + - [ ] Replace print() calls in `src/server/core/profiler.py` with logger + - [ ] Replace print() calls in `src/client/python/http_clientCAMpi.py` with logger - [ ] Add log level config to `settings.yaml` schema - [ ] Add tests in `tests/unit/test_logging.py` for log format - **Acceptance criteria:** @@ -1271,20 +1290,25 @@ Record decisions that affect several backlog items. - **Links to Issues:** #12 ### SCIOT-045 — Fix type annotation of `MessageData.get_latency` -- **Status:** BACKLOG +- **Status:** DONE - **Priority:** P2 - **Value:** Fixes type checking warnings. -- **Problem:** **Status**: Ready for PR. Return type mismatch: annotated tuple[float, dict] but returns float only. Check message_data.py. +- **Problem:** **Status**: Ready for PR. Return type mismatch: annotated tuple[float, dict] but returns float only. Check message_data.py. Fixed. - **Task breakdown:** - - [ ] Analyze requirements from GitHub issue #13 - - [ ] Implement changes - - [ ] Add tests for the implementation - - [ ] Update documentation if needed + - [x] Analyze requirements from GitHub issue #13 + - [x] Implement changes + - [x] Add tests for the implementation + - [x] Update documentation if needed - **Acceptance criteria:** - Changes address the issue requirements - All tests pass (`uv run pytest -q`) - No breaking changes to existing API - **Links to Issues:** #13 +- **Verification evidence:** + - Fixed `get_latency` return type from `tuple[float, dict]` to `float` in `src/server/communication/message_data.py`. + - Added `tests/unit/test_message_data_types.py` with 3 tests verifying the return type and annotation. + - All 61 unit tests pass. + - PR #37 already open for this fix. ### SCIOT-046 — Time breakdown - **Status:** BACKLOG diff --git a/src/sciot/config.py b/src/sciot/config.py index edaed58..5d989c3 100644 --- a/src/sciot/config.py +++ b/src/sciot/config.py @@ -12,12 +12,18 @@ import ipaddress import os import re +import threading from pathlib import Path from typing import Any, Mapping import yaml +# Default configuration paths +DEFAULT_SERVER_CONFIG = Path(__file__).parent.parent / "server" / "settings.yaml" +DEFAULT_CLIENT_CONFIG = Path(__file__).parent.parent / "client" / "python" / "http_config.yaml" + + VALID_TRANSPORTS = {"http", "websocket", "mqtt"} VALID_DELAY_TYPES = {"none", "static", "gaussian", "uniform", "exponential"} @@ -118,6 +124,11 @@ def validate_server_config(config: Mapping[str, Any]) -> dict[str, Any]: "local_inference_mode", errors, ) + _validate_offloading_algo_config( + normalized.get("offloading_algo", {}), + "offloading_algo", + errors, + ) _optional_bool(normalized, "verbose", errors) _optional_bool(normalized, "debug_cprofiler", errors) @@ -564,6 +575,18 @@ def _validate_probability_block(value: Any, path: str, errors: list[str]): errors.append(f"{path}.probability: must be between 0.0 and 1.0") +def _validate_offloading_algo_config(value: Any, path: str, errors: list[str]): + """Validate offloading_algo configuration block with ema_alpha parameter.""" + if value in (None, {}): + return + if not isinstance(value, dict): + errors.append(f"{path}: must be a mapping") + return + ema_alpha = _optional_number(value, "ema_alpha", errors, path=f"{path}.ema_alpha") + if ema_alpha is not None and not 0 < ema_alpha <= 1: + errors.append(f"{path}.ema_alpha: must be between 0.0 (exclusive) and 1.0 (inclusive)") + + def _model_reference( config: Mapping[str, Any], key: str, @@ -772,3 +795,66 @@ def _optional_bool( actual_path = path or key if not isinstance(config[key], bool): errors.append(f"{actual_path}: must be true or false") + + +class SCIoTConfig: + """Thread-safe singleton for centralized configuration access. + + Provides typed accessors for server and client configurations, + ensuring configuration is loaded only once and shared across the codebase. + """ + + _instance: SCIoTConfig | None = None + _lock = threading.Lock() + _server_config: dict[str, Any] | None = None + _client_config: dict[str, Any] | None = None + + def __new__(cls) -> SCIoTConfig: + """Ensure singleton pattern with thread-safe initialization.""" + if cls._instance is None: + with cls._lock: + # Double-check after acquiring lock + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + @classmethod + def get_instance(cls) -> SCIoTConfig: + """Return the singleton instance, creating it if necessary.""" + return cls() + + def get_server(self, config_path: str | Path | None = None) -> dict[str, Any]: + """Get validated server configuration, loading from disk if not cached. + + Args: + config_path: Optional path to configuration file. Uses default if None. + + Returns: + Validated server configuration dictionary. + """ + if self._server_config is None: + path = Path(config_path) if config_path else DEFAULT_SERVER_CONFIG + self._server_config = load_server_config(path, apply_env=True) + return self._server_config + + def get_client(self, config_path: str | Path | None = None) -> dict[str, Any]: + """Get validated client configuration, loading from disk if not cached. + + Args: + config_path: Optional path to configuration file. Uses default if None. + + Returns: + Validated client configuration dictionary. + """ + if self._client_config is None: + path = Path(config_path) if config_path else DEFAULT_CLIENT_CONFIG + self._client_config = load_client_config(path, apply_env=True) + return self._client_config + + @classmethod + def reset(cls) -> None: + """Reset the singleton state (useful for testing).""" + with cls._lock: + cls._instance = None + cls._server_config = None + cls._client_config = None diff --git a/src/server/communication/http_server.py b/src/server/communication/http_server.py index 3e5692a..66b89f6 100644 --- a/src/server/communication/http_server.py +++ b/src/server/communication/http_server.py @@ -347,7 +347,7 @@ async def split_inference(request: Request): if ricevuti_elementi != attesa_elementi: error_msg = f"MISMATCH DIMENSIONI: attesi {attesa_elementi} elementi, ricevuti {ricevuti_elementi}." - print(f"[SERVER ERROR] {error_msg}") + logger.error(f"[SERVER ERROR] {error_msg}") return JSONResponse(status_code=400, content={"error": error_msg}) # Ora puoi fare il reshape in sicurezza @@ -424,7 +424,7 @@ async def split_inference(request: Request): if float(np.max(grid[:, :, 1])) > soglia_client: oggetti_rilevati.append("BICI") if float(np.max(grid[:, :, 2])) > soglia_client: oggetti_rilevati.append("STOP") - print(f"[SERVER] {device_id} -> Vede: {oggetti_rilevati if oggetti_rilevati else '[]'}", flush=True) + logger.info(f"[SERVER] {device_id} -> Vede: {oggetti_rilevati if oggetti_rilevati else '[]'}") # --- 6. RISPOSTA FINALE --- output = np.nan_to_num(input_data, nan=0.0, posinf=0.0, neginf=0.0) if np.issubdtype(input_data.dtype, np.floating) else input_data diff --git a/src/server/communication/message_data.py b/src/server/communication/message_data.py index c95b55c..15f10c5 100644 --- a/src/server/communication/message_data.py +++ b/src/server/communication/message_data.py @@ -44,7 +44,16 @@ def save_to_file(file_path: str, data_dict: dict): logger.error(f"Failed to save data to {file_path}: {e}") @staticmethod - def get_latency(timestamp: str, received_timestamp: str) -> tuple[float, dict]: + def get_latency(timestamp: str, received_timestamp: str) -> float: + """Calculate network latency from NTP timestamps. + + Args: + timestamp: NTP timestamp as string (seconds since 1900). + received_timestamp: Reception NTP timestamp as string. + + Returns: + float: Duration in seconds between timestamps. + """ # NTP timestamps as strings (representing seconds since 1900) # convert the NTP timestamps from string to float ntp_timestamp_1 = float(timestamp) diff --git a/src/server/communication/request_handler.py b/src/server/communication/request_handler.py index 480a69b..669e76f 100644 --- a/src/server/communication/request_handler.py +++ b/src/server/communication/request_handler.py @@ -8,6 +8,7 @@ import math from datetime import datetime from pathlib import Path +from typing import Any import numpy as np from PIL import Image import hashlib @@ -64,11 +65,23 @@ def load_local_inference_config(): return cfg if cfg else {"enabled": False, "probability": 0.0} -def load_verbose_config(): +def load_verbose_config() -> bool: """Load verbose configuration from cached settings.""" return _get_settings().get("verbose", False) +def load_offloading_algo_config() -> dict[str, Any]: + """Load offloading algorithm configuration from cached settings. + + Returns ema_alpha and other tunable parameters for the offloading algorithm. + Default ema_alpha is 0.5 (hardcoded historical value). + """ + cfg = _get_settings().get("offloading_algo", {}) + if cfg is None: + cfg = {} + return {"ema_alpha": cfg.get("ema_alpha", 0.5)} + + # ── Background I/O writer ─────────────────────────────────────────────────── # A single daemon thread drains a queue of callables, so that debug-JSON, # simulation-CSV, and evaluation-CSV writes never block the inference path. @@ -114,12 +127,12 @@ def __init__(self): # Load verbose configuration self.verbose = load_verbose_config() - # Print header once + # Print header once (uses logger for structured output) if not RequestHandler.header_printed: - print( - "\nDevice | Offload | Acq Time (ms) | Device Comp (ms) | Edge Comp (ms) | Net Time (ms) | Total (ms)" + logger.info( + "Device | Offload | Acq Time (ms) | Device Comp (ms) | Edge Comp (ms) | Net Time (ms) | Total (ms)" ) - print("-" * 100) + logger.info("-" * 100) RequestHandler.header_printed = True # Empty the debug folder every time the server starts @@ -405,7 +418,8 @@ def handle_device_inference_result(self, body, received_timestamp): device_inference_times = RequestHandler.device_profiles[device_id]["device_inference_times"] edge_inference_times = RequestHandler.device_profiles[device_id]["edge_inference_times"] - alpha = 0.5 + # Use configurable ema_alpha (default 0.5) for EMA smoothing + alpha = load_offloading_algo_config()["ema_alpha"] for l_id, inference_time in enumerate(message_data.device_layers_inference_time): layer_key = f"layer_{l_id}" if layer_key in device_inference_times: @@ -514,9 +528,11 @@ def handle_device_inference_result(self, body, received_timestamp): try: # Se il modello è conosciuto funzionerà. best_offloading_layer = offloading_algo.static_offloading() - + # Stampiamo la tabella SOLO se il calcolo è andato a buon fine! - print(f"{device_id:13s} | {message_data.offloading_layer_index:7d} | {acq_time:13.2f} | {device_comp_time:16.2f} | {edge_comp_time:14.2f} | {network_time:13.2f} | {total_time:10.2f}") + logger.info( + f"{device_id:13s} | {message_data.offloading_layer_index:7d} | {acq_time:13.2f} | {device_comp_time:16.2f} | {edge_comp_time:14.2f} | {network_time:13.2f} | {total_time:10.2f}" + ) except IndexError: # Se mancano i file restituiamo il layer massimo usando la variabile corretta. @@ -538,7 +554,7 @@ def handle_device_inference_result(self, body, received_timestamp): self.profiler.stop_cprofile("server_deep_analysis") # Lo riavviamo per catturare i prossimi 50 self.profiler.start_cprofile() - print(f"📊 [PROFILER SERVER] Dati macro e micro (cProfile) esportati.") + logger.info("📊 [PROFILER SERVER] Dati macro e micro (cProfile) esportati.") return best_offloading_layer, device_id, prediction @@ -632,12 +648,12 @@ def build_model_registry(cls, models_config: dict): model_hash = hasher.hexdigest() cls.model_registry[model_hash] = { "model_dir": model_dir, - "model_key": model_name, # <--- AGGIUNTO: salviamo il nome del profilo (es. fomo_144) + "model_key": model_name, "last_offloading_layer": model_config["last_offloading_layer"], "num_layers": model_config["last_offloading_layer"] + 1, } - print( + logger.info( f"Registered model '{model_name}' (dir: {model_dir}) with hash {model_hash}" ) except Exception as e: - print(f"Warning: could not register model {model_name}: {e}") + logger.warning(f"could not register model {model_name}: {e}") diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py new file mode 100644 index 0000000..0e565e3 --- /dev/null +++ b/tests/unit/test_config.py @@ -0,0 +1,94 @@ +"""Tests for SCIoTConfig singleton class (SCIOT-040).""" + +import threading +from pathlib import Path + +import pytest + +from sciot.config import SCIoTConfig, ConfigValidationError, load_server_config + + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +SERVER_CONFIG = PROJECT_ROOT / "src/server/settings.yaml" + + +def test_singleton_returns_same_instance(): + """Test that SCIoTConfig.get_instance() returns the same instance.""" + SCIoTConfig.reset() + + instance1 = SCIoTConfig.get_instance() + instance2 = SCIoTConfig.get_instance() + + assert instance1 is instance2 + SCIoTConfig.reset() + + +def test_get_server_returns_valid_config(): + """Test that get_server returns validated configuration.""" + SCIoTConfig.reset() + + config = SCIoTConfig.get_instance().get_server(config_path=SERVER_CONFIG) + + assert "communication" in config + assert "model" in config + SCIoTConfig.reset() + + +def test_get_server_uses_default_path(): + """Test that get_server uses default path when none provided.""" + SCIoTConfig.reset() + + instance = SCIoTConfig.get_instance() + config = instance.get_server() + + assert config is not None + assert "communication" in config + SCIoTConfig.reset() + + +def test_get_client_returns_valid_config(): + """Test that get_client returns validated configuration.""" + SCIoTConfig.reset() + + client_config_path = PROJECT_ROOT / "src/client/python/http_config.yaml" + config = SCIoTConfig.get_instance().get_client(config_path=client_config_path) + + assert "client" in config + SCIoTConfig.reset() + + +def test_singleton_thread_safety(): + """Test that singleton initialization is thread-safe.""" + SCIoTConfig.reset() + + instances = [] + + def get_instance(): + instances.append(SCIoTConfig.get_instance()) + + threads = [threading.Thread(target=get_instance) for _ in range(10)] + for t in threads: + t.start() + for t in threads: + t.join() + + # All instances should be the same object + assert all(inst is instances[0] for inst in instances) + SCIoTConfig.reset() + + +def test_reset_clears_cached_config(): + """Test that reset() clears cached configuration.""" + SCIoTConfig.reset() + + config1 = SCIoTConfig.get_instance().get_server(config_path=SERVER_CONFIG) + assert config1 is not None + + SCIoTConfig.reset() + + # After reset, should load fresh + config2 = SCIoTConfig.get_instance().get_server(config_path=SERVER_CONFIG) + assert config2 is not None + assert config1 is not config2 # Different dict objects + + SCIoTConfig.reset() \ No newline at end of file diff --git a/tests/unit/test_config_validation.py b/tests/unit/test_config_validation.py index 08492e2..89c3a3b 100644 --- a/tests/unit/test_config_validation.py +++ b/tests/unit/test_config_validation.py @@ -179,3 +179,30 @@ def test_invalid_yaml_startup_load_fails_with_actionable_error(tmp_path): with pytest.raises(ConfigValidationError, match="communication: required mapping"): load_server_config(config_path, apply_env=False) + + +def test_ema_alpha_configuration_validation(tmp_path): + """Test that ema_alpha in offloading_algo config is validated correctly.""" + config_path = tmp_path / "settings.yaml" + + # Valid ema_alpha values + valid_config = _server_config() + valid_config["offloading_algo"] = {"ema_alpha": 0.3} + config_path.write_text(yaml.safe_dump(valid_config)) + result = load_server_config(config_path, apply_env=False) + assert result["offloading_algo"]["ema_alpha"] == 0.3 + + # Invalid ema_alpha: must be > 0 and <= 1 + for invalid_value in [0.0, -0.1, 1.1, 1.5]: + invalid_config = _server_config() + invalid_config["offloading_algo"] = {"ema_alpha": invalid_value} + config_path.write_text(yaml.safe_dump(invalid_config)) + with pytest.raises(ConfigValidationError, match="offloading_algo.ema_alpha"): + load_server_config(config_path, apply_env=False) + + # Default (empty config) should work with default 0.5 + default_config = _server_config() + default_config["offloading_algo"] = {} + config_path.write_text(yaml.safe_dump(default_config)) + result = load_server_config(config_path, apply_env=False) + assert "ema_alpha" not in result.get("offloading_algo", {}) diff --git a/tests/unit/test_message_data_types.py b/tests/unit/test_message_data_types.py new file mode 100644 index 0000000..ce205d4 --- /dev/null +++ b/tests/unit/test_message_data_types.py @@ -0,0 +1,37 @@ +"""Type annotation tests for MessageData – SCIOT-030 and SCIOT-045.""" + + +import pytest + +from server.communication.message_data import MessageData + + +class TestMessageDataLatencyType: + """Verify get_latency return type matches implementation.""" + + def test_get_latency_returns_float(self): + """get_latency should return float, not tuple.""" + result = MessageData.get_latency("100.0", "200.0") + + # The method returns float, not tuple[float, dict] + assert isinstance(result, float) + assert result == 100.0 + + def test_get_latency_annotation_is_float(self): + """Type annotation should match implementation (float).""" + import inspect + sig = inspect.signature(MessageData.get_latency) + annotation = sig.return_annotation + + # The annotation should be float, not tuple + assert annotation == float, f"Expected float, got {annotation}" + + def test_get_latency_with_ntp_timestamps(self): + """Test latency calculation matches expected usage.""" + # NTP timestamps are typically large numbers (seconds since 1900) + # The method calculates difference in seconds + timestamp = "1234567890.123" + received = "1234567890.456" + result = MessageData.get_latency(timestamp, received) + + assert abs(result - 0.333) < 0.001 \ No newline at end of file