diff --git a/Dashboard/generate_predictions.py b/Dashboard/generate_predictions.py index 48988ad..e14ab72 100644 --- a/Dashboard/generate_predictions.py +++ b/Dashboard/generate_predictions.py @@ -1,496 +1,22 @@ -#!/usr/bin/env python3 -""" -VE1ATM HF Propagation Prediction Generator -Uses DVOACAP-Python prediction engine for accurate HF band forecasts - -This script generates 24-hour propagation predictions for VE1ATM's station -to major DX regions worldwide, using the complete DVOACAP prediction engine. -""" - -import json -import sys -import numpy as np -from datetime import datetime, timezone, timedelta -from pathlib import Path -from typing import Dict, List, Tuple - - -# Custom JSON encoder to handle numpy types -class NumpyEncoder(json.JSONEncoder): - def default(self, obj): - if isinstance(obj, (np.integer, np.int64, np.int32)): - return int(obj) - elif isinstance(obj, (np.floating, np.float64, np.float32)): - return float(obj) - elif isinstance(obj, np.ndarray): - return obj.tolist() - return super(NumpyEncoder, self).default(obj) - -# Add parent directory to path to import dvoacap -sys.path.insert(0, str(Path(__file__).parent.parent)) - -try: - from src.dvoacap.path_geometry import GeoPoint - from src.dvoacap.prediction_engine import PredictionEngine - from src.dvoacap.space_weather_sources import MultiSourceSpaceWeatherFetcher - from src.dvoacap.antenna_gain import create_antenna - import requests -except ImportError as e: - print(f"Error: Could not import DVOACAP modules: {e}") - print("Make sure you're running from the dvoacap-python directory") - sys.exit(1) - - -# ============================================================================= -# VE1ATM Station Configuration -# ============================================================================= - -MY_QTH = { - 'call': 'VE1ATM', - 'lat': 44.374, # FN74ui - Lunenburg, Nova Scotia - 'lon': -64.300, - 'grid': 'FN74ui', - 'antenna': 'DX Commander 7m Vertical', - 'location': GeoPoint.from_degrees(44.374, -64.300) -} - -# HF Amateur bands (center frequencies in MHz) -BANDS = { - '160m': 1.900, - '80m': 3.600, - '40m': 7.150, - '30m': 10.125, - '20m': 14.150, - '17m': 18.118, - '15m': 21.200, - '12m': 24.940, - '10m': 28.500 -} - -# Target DX regions -TARGET_REGIONS = { - 'EU': {'name': 'Europe', 'location': GeoPoint.from_degrees(50.0, 10.0)}, - 'UK': {'name': 'United Kingdom', 'location': GeoPoint.from_degrees(54.0, -2.0)}, - 'JA': {'name': 'Japan', 'location': GeoPoint.from_degrees(36.0, 138.0)}, - 'VK': {'name': 'Australia', 'location': GeoPoint.from_degrees(-25.0, 135.0)}, - 'ZL': {'name': 'New Zealand', 'location': GeoPoint.from_degrees(-41.0, 174.0)}, - 'AF': {'name': 'Africa', 'location': GeoPoint.from_degrees(0.0, 20.0)}, - 'SA': {'name': 'South America', 'location': GeoPoint.from_degrees(-15.0, -55.0)}, - 'CA': {'name': 'Central America', 'location': GeoPoint.from_degrees(15.0, -90.0)}, - 'AS': {'name': 'Asia', 'location': GeoPoint.from_degrees(30.0, 100.0)}, - 'OC': {'name': 'Oceania', 'location': GeoPoint.from_degrees(-10.0, 150.0)}, -} - - -# ============================================================================= -# Antenna Configuration -# ============================================================================= - -def load_antenna_configuration() -> Dict: - """ - Load antenna configuration from antenna_config.json - - Returns dict with: - - antennas: List of antenna definitions - - band_assignments: Dict mapping bands to antenna names - """ - config_file = Path(__file__).parent / 'antenna_config.json' - - if config_file.exists(): - try: - with open(config_file, 'r') as f: - config = json.load(f) - print(f"[OK] Loaded antenna configuration: {len(config.get('antennas', []))} antennas") - return config - except Exception as e: - print(f"[WARNING] Error loading antenna config: {e}") - return {'antennas': [], 'band_assignments': {}} - else: - # Default: DX Commander vertical for all bands - print("[INFO] No antenna config found, using default (Vertical Monopole)") - return { - 'antennas': [{ - 'id': 'default', - 'name': 'DX Commander Vertical', - 'type': 'vertical' - }], - 'band_assignments': {band: 'default' for band in BANDS.keys()} - } - - -def configure_antennas(engine: PredictionEngine, config: Dict) -> None: - """ - Configure antenna farm based on user configuration - - Args: - engine: PredictionEngine instance - config: Antenna configuration dict - """ - # Get band-to-frequency mapping - band_freqs = { - '160m': (1.8, 2.0), - '80m': (3.5, 4.0), - '40m': (7.0, 7.3), - '30m': (10.1, 10.15), - '20m': (14.0, 14.35), - '17m': (18.068, 18.168), - '15m': (21.0, 21.45), - '12m': (24.89, 24.99), - '10m': (28.0, 29.7) - } - - antennas = config.get('antennas', []) - band_assignments = config.get('band_assignments', {}) - - # Build antenna lookup by ID - antenna_lookup = {ant['id']: ant for ant in antennas} - - # For each band, add the assigned antenna to the farm - for band, antenna_id in band_assignments.items(): - if band in band_freqs and antenna_id in antenna_lookup: - antenna_info = antenna_lookup[antenna_id] - antenna_type = antenna_info.get('type', 'vertical') - - # Get frequency range for this band - low_freq, high_freq = band_freqs[band] - - try: - # Create antenna instance - antenna = create_antenna( - antenna_type=antenna_type, - low_frequency=low_freq, - high_frequency=high_freq, - tx_power_dbw=10.0 # Will be set from engine.params.tx_power - ) - - # Add to both TX and RX antenna farms - engine.tx_antennas.add_antenna(antenna) - engine.rx_antennas.add_antenna(antenna) - - print(f" [OK] {band}: {antenna_info['name']} ({antenna_type})") - except ValueError as e: - print(f" [WARNING] {band}: Could not create antenna: {e}") - - print(f"[OK] Configured {len(engine.tx_antennas.antennas)} antenna(s)") - - -# ============================================================================= -# Solar Data Fetching -# ============================================================================= - -def fetch_solar_conditions() -> Dict: - """ - Fetch current solar-terrestrial conditions from multiple international sources - Returns dict with SFI, SSN, Kp, A-index - - Data sources (with automatic fallback): - - Solar Flux Index (F10.7): NOAA SWPC, LISIRD, Space Weather Canada - - Sunspot Number: SIDC/SILSO (Belgium), NOAA SWPC - - Kp Index: GFZ Potsdam (Germany), NOAA SWPC - - A Index: GFZ Potsdam (Germany), NOAA SWPC - - This function uses the MultiSourceSpaceWeatherFetcher which automatically - tries multiple sources and falls back if primary sources are unavailable. - """ - print("\n" + "=" * 70) - print("Fetching Space Weather Data from International Sources") - print("=" * 70) - - # Use the multi-source fetcher for increased reliability - fetcher = MultiSourceSpaceWeatherFetcher(timeout=10, verbose=True) - - # Fetch data in legacy format for backward compatibility - solar_data = fetcher.fetch_all_legacy_format() - - print() - print(f"[OK] Solar conditions: SFI={solar_data['sfi']:.0f}, SSN={solar_data['ssn']:.0f}, " - f"Kp={solar_data['kp']:.1f}, A={solar_data['a_index']:.1f}") - print(f" Overall source: {solar_data['source']}") - print(f" Detailed sources:") - for param, source in solar_data.get('sources_detail', {}).items(): - print(f" {param.upper()}: {source}") +"""Backward-compatibility shim. - if solar_data.get('errors'): - print(f" Note: Some sources failed, see above for details") +This script has moved to ``dvoacap.dashboard.generate_predictions``. Use:: - print("=" * 70) + python -m dvoacap.dashboard.generate_predictions - return solar_data - - -# ============================================================================= -# Prediction Generation -# ============================================================================= - -def generate_prediction( - engine: PredictionEngine, - region_code: str, - region_info: Dict, - utc_hour: int, - frequencies: List[float] -) -> Dict: - """ - Generate propagation prediction for a specific region at a specific hour - - Returns dict with band-by-band predictions - """ - utc_fraction = utc_hour / 24.0 - - try: - # Run prediction - engine.predict( - rx_location=region_info['location'], - utc_time=utc_fraction, - frequencies=frequencies - ) - - # Extract predictions for each band - band_predictions = {} - for band_name, freq in BANDS.items(): - # Find the prediction for this frequency - pred_idx = None - for i, f in enumerate(frequencies): - if abs(f - freq) < 0.1: # Match within 100 kHz - pred_idx = i - break - - if pred_idx is not None and pred_idx < len(engine.predictions): - pred = engine.predictions[pred_idx] - - # Determine status based on reliability and SNR - reliability = pred.signal.reliability * 100 - snr = pred.signal.snr_db - - if reliability >= 60 and snr >= 10: - status = 'GOOD' - elif reliability >= 30 or snr >= 3: - status = 'FAIR' - elif reliability > 0: - status = 'POOR' - else: - status = 'CLOSED' - - band_predictions[band_name] = { - 'status': status, - 'reliability': round(reliability, 1), - 'snr': round(snr, 1), - 'mode': pred.get_mode_name(engine.path.dist), - 'hops': pred.hop_count, - 'elevation': round(np.rad2deg(pred.tx_elevation), 1), - # Enhanced data for prop charts - # muf_day is probability that MUF exceeds frequency (1.0=well below MUF, 0.0=at/above MUF) - # Dashboard inverts this to show "MUF usage percentage" - 'muf_day': round(pred.signal.muf_day * 100, 1), # MUF probability (%) - inverted by dashboard for display - 'signal_dbw': round(pred.signal.power_dbw, 1), # Median signal power - # power10/power90 are DEVIATIONS from median, not absolute values - 'signal_10': round(pred.signal.power_dbw - pred.signal.power10, 1), # 10th percentile (weaker) - 'signal_90': round(pred.signal.power_dbw + pred.signal.power90, 1), # 90th percentile (stronger) - 'snr_10': round(pred.signal.snr10, 1), # Lower decile SNR - 'snr_90': round(pred.signal.snr90, 1), # Upper decile SNR - } - else: - # No prediction available - band_predictions[band_name] = { - 'status': 'CLOSED', - 'reliability': 0, - 'snr': -999, - 'mode': 'N/A', - 'hops': 0, - 'elevation': 0, - 'muf_day': 0, - 'signal_dbw': -999, - 'signal_10': -999, - 'signal_90': -999, - 'snr_10': -999, - 'snr_90': -999, - } - - # Calculate path info - distance_km = engine.path.dist * 6370 - azimuth_deg = np.rad2deg(engine.path.azim_tr) - - return { - 'region': region_code, - 'region_name': region_info['name'], - 'utc_hour': utc_hour, - 'distance_km': round(distance_km, 0), - 'azimuth': round(azimuth_deg, 1), - 'muf': round(engine.circuit_muf.muf, 2) if engine.circuit_muf else 0, - 'bands': band_predictions - } - - except Exception as e: - print(f" [WARNING] Error predicting {region_code} at {utc_hour:02d}00 UTC: {e}") - # Return empty prediction with all enhanced fields - return { - 'region': region_code, - 'region_name': region_info['name'], - 'utc_hour': utc_hour, - 'distance_km': 0, - 'azimuth': 0, - 'muf': 0, - 'bands': {band: {'status': 'ERROR', 'reliability': 0, 'snr': -999, - 'mode': 'N/A', 'hops': 0, 'elevation': 0, - 'muf_day': 0, 'signal_dbw': -999, 'signal_10': -999, - 'signal_90': -999, 'snr_10': -999, 'snr_90': -999} - for band in BANDS.keys()} - } - - -def generate_24hour_forecast() -> Dict: - """ - Generate complete 24-hour propagation forecast for all regions - """ - print("=" * 80) - print("VE1ATM HF Propagation Prediction Generator") - print("Using DVOACAP-Python Full Prediction Engine") - print("=" * 80) - print() - - # Get solar conditions - solar = fetch_solar_conditions() - - # Initialize prediction engine - print("\n[OK] Initializing DVOACAP prediction engine...") - engine = PredictionEngine() - - # Configure engine - now = datetime.now(timezone.utc) - engine.params.ssn = solar['ssn'] - engine.params.month = now.month - engine.params.tx_power = 100.0 # 100W - engine.params.tx_location = MY_QTH['location'] - engine.params.min_angle = np.deg2rad(3.0) # 3° minimum takeoff angle - engine.params.required_snr = 10.0 # 10 dB SNR for good copy - engine.params.required_reliability = 0.9 - - print(f"[OK] Configuration: Month={now.month}, SSN={solar['ssn']:.0f}, TX Power=100W") - - # Configure antennas - print("\n[OK] Configuring antennas...") - antenna_config = load_antenna_configuration() - configure_antennas(engine, antenna_config) - - # Generate predictions - frequencies = list(BANDS.values()) - all_predictions = [] - - # Generate hourly predictions for detailed prop charts (v1.0 enhancement) - utc_hours = range(0, 24, 1) # Changed from 2-hour to 1-hour intervals - - print(f"\n[OK] Generating predictions for {len(TARGET_REGIONS)} regions, {len(utc_hours)} time points...") - print() - - for utc_hour in utc_hours: - print(f" Processing {utc_hour:02d}00 UTC...", end=' ') - hour_count = 0 - - for region_code, region_info in TARGET_REGIONS.items(): - pred = generate_prediction(engine, region_code, region_info, utc_hour, frequencies) - all_predictions.append(pred) - hour_count += 1 - - print(f"[OK] {hour_count} regions") - - # Build output structure - # Create JSON-safe station info (exclude GeoPoint object) - station_info = {k: v for k, v in MY_QTH.items() if k != 'location'} - - output = { - 'generated': datetime.now(timezone.utc).isoformat(), - 'station': station_info, - 'solar_conditions': solar, - 'bands': list(BANDS.keys()), - 'regions': {code: info['name'] for code, info in TARGET_REGIONS.items()}, - 'predictions': all_predictions - } - - return output - - -# ============================================================================= -# Main -# ============================================================================= - -def main(): - """Main entry point""" - - # Generate predictions - data = generate_24hour_forecast() - - # Save to JSON - output_file = Path(__file__).parent / 'propagation_data.json' - with open(output_file, 'w') as f: - json.dump(data, f, indent=2, cls=NumpyEncoder) - - print() - print("=" * 80) - print(f"[OK] Predictions saved to: {output_file}") - print("=" * 80) +or the bundled ``dvoacap-dashboard`` console script. +""" - # Transform data to dashboard-compatible format - print() - print("[OK] Transforming data for dashboard...") - transform_script = Path(__file__).parent / 'transform_data.py' - try: - import subprocess - result = subprocess.run( - [sys.executable, str(transform_script)], - capture_output=True, - text=True, - check=False, - ) - if result.stdout: - print(result.stdout, end="") - if result.returncode != 0: - print( - f"[WARNING] transform_data.py exited with code " - f"{result.returncode}.", - file=sys.stderr, - ) - if result.stderr: - print("--- transform_data.py stderr ---", file=sys.stderr) - print(result.stderr, end="", file=sys.stderr) - print("--------------------------------", file=sys.stderr) - print( - "Dashboard may not display correctly without " - "enhanced_predictions.json.", - file=sys.stderr, - ) - except FileNotFoundError as e: - print( - f"[WARNING] Could not run transform_data.py: {e}", - file=sys.stderr, - ) - print( - "Dashboard may not display correctly without " - "enhanced_predictions.json.", - file=sys.stderr, - ) - except Exception as e: - print( - f"[WARNING] Unexpected error invoking transform_data.py: {e}", - file=sys.stderr, - ) - print( - "Dashboard may not display correctly without " - "enhanced_predictions.json.", - file=sys.stderr, - ) +import warnings - print() - print("Summary:") - print(f" * Total predictions: {len(data['predictions'])}") - print(f" * Regions covered: {len(TARGET_REGIONS)}") - print(f" * Bands: {', '.join(BANDS.keys())}") - print(f" * Generated: {data['generated']}") - print() - print("Next steps:") - print(" 1. Start the server: python3 server.py") - print(" 2. Open http://localhost:8000 in your browser") - print(" 3. View the updated predictions with live refresh capability") - print() +warnings.warn( + "Running generate_predictions.py from Dashboard/ is deprecated. " + "Use `python -m dvoacap.dashboard.generate_predictions` instead.", + DeprecationWarning, + stacklevel=2, +) +from dvoacap.dashboard.generate_predictions import main # noqa: E402 if __name__ == "__main__": main() diff --git a/Dashboard/requirements.txt b/Dashboard/requirements.txt deleted file mode 100644 index ad02651..0000000 --- a/Dashboard/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -flask>=2.3.0 -flask-cors>=4.0.0 -requests>=2.31.0 -numpy>=1.24.0 diff --git a/Dashboard/server.py b/Dashboard/server.py index 3a584e4..61e36f6 100644 --- a/Dashboard/server.py +++ b/Dashboard/server.py @@ -1,502 +1,25 @@ -#!/usr/bin/env python3 -""" -VE1ATM HF Propagation Dashboard Server +"""Backward-compatibility shim. -Lightweight Flask server that: -- Serves the dashboard and static files -- Provides API endpoint to trigger prediction generation -- Allows on-demand refresh from the web interface +The dashboard has moved to ``src/dvoacap/dashboard/``. To run it, install +the dashboard extra and use the console script:: -Usage: - python3 server.py + pip install -e ".[dashboard]" + dvoacap-dashboard -Then visit: http://localhost:8000 +This shim allows the old ``python Dashboard/server.py`` invocation to keep +working. """ -import sys -import json -import threading -import subprocess -from pathlib import Path -from datetime import datetime, timezone -from flask import Flask, jsonify, send_from_directory, request, make_response -from flask_cors import CORS - -# Add parent directory to import dvoacap -sys.path.insert(0, str(Path(__file__).parent.parent)) - -app = Flask(__name__) -CORS(app) # Enable CORS for API requests - -# Global state for prediction generation -generation_state = { - 'running': False, - 'progress': 0, - 'message': 'Ready', - 'last_updated': None, - 'error': None -} - -# Global configuration -server_config = { - 'disable_cache': False # Set to True to disable HTTP caching -} - - -def apply_cache_control(response): - """ - Apply cache control headers to a response based on server configuration - - Args: - response: Flask response object - - Returns: - Modified response with cache control headers - """ - if server_config.get('disable_cache', False) or app.debug: - # Disable caching for development - response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate' - response.headers['Pragma'] = 'no-cache' - response.headers['Expires'] = '0' - else: - # Allow caching in production with 5 minute max-age - response.headers['Cache-Control'] = 'public, max-age=300' - - return response - - -def run_prediction_generator(): - """ - Run the prediction generator in a background thread - Updates global state as it progresses - """ - global generation_state - - try: - generation_state['running'] = True - generation_state['progress'] = 10 - generation_state['message'] = 'Starting prediction engine...' - generation_state['error'] = None - - # Run the prediction generator as a subprocess - script_path = Path(__file__).parent / 'generate_predictions.py' - - generation_state['progress'] = 20 - generation_state['message'] = 'Fetching solar conditions...' - - # Execute the script - result = subprocess.run( - [sys.executable, str(script_path)], - cwd=str(Path(__file__).parent), - capture_output=True, - text=True, - timeout=300 # 5 minute timeout - ) - - if result.returncode == 0: - generation_state['progress'] = 100 - generation_state['message'] = 'Predictions updated successfully!' - generation_state['last_updated'] = datetime.now().isoformat() - else: - # Combine stdout and stderr for complete error message - error_output = result.stderr if result.stderr else result.stdout - if not error_output: - error_output = f"Process exited with code {result.returncode}" - generation_state['error'] = f"Generator failed: {error_output[:500]}" - generation_state['message'] = 'Generation failed' - - except subprocess.TimeoutExpired: - generation_state['error'] = 'Prediction generation timed out (>5 minutes)' - generation_state['message'] = 'Timeout error' - except Exception as e: - generation_state['error'] = str(e) - generation_state['message'] = f'Error: {str(e)[:100]}' - finally: - generation_state['running'] = False - - -# ============================================================================= -# API Endpoints -# ============================================================================= - -@app.route('/api/generate', methods=['POST']) -def trigger_generation(): - """ - API endpoint to trigger prediction generation - - Returns: - JSON with status - """ - global generation_state - - if generation_state['running']: - return jsonify({ - 'status': 'already_running', - 'message': 'Prediction generation already in progress' - }), 409 - - # Start generation in background thread - thread = threading.Thread(target=run_prediction_generator, daemon=True) - thread.start() - - return jsonify({ - 'status': 'started', - 'message': 'Prediction generation started' - }) - - -@app.route('/api/health', methods=['GET']) -def health_check(): - """ - API endpoint for health check monitoring - - Returns: - JSON with health status - """ - return jsonify({ - 'status': 'healthy', - 'timestamp': datetime.now().isoformat(), - 'service': 'dvoacap-dashboard' - }) - - -@app.route('/api/train', methods=['GET', 'POST']) -def train_model(): - """ - API endpoint for model training status - - Returns: - JSON with training status - """ - # For now, return a placeholder response - # This can be expanded to handle actual model training - return jsonify({ - 'status': 'not_implemented', - 'message': 'Training endpoint placeholder', - 'timestamp': datetime.now().isoformat() - }) - - -@app.route('/api/status', methods=['GET']) -def get_status(): - """ - API endpoint to check generation status - - Returns: - JSON with current state - """ - return jsonify(generation_state) - - -@app.route('/api/data', methods=['GET']) -def get_prediction_data(): - """ - API endpoint to fetch current prediction data - - Returns: - JSON prediction data - """ - try: - data_file = Path(__file__).parent / 'enhanced_predictions.json' - if data_file.exists(): - with open(data_file, 'r') as f: - data = json.load(f) - # Extract predictions object from enhanced data structure - predictions = data.get('predictions', data) - return jsonify(predictions) - else: - return jsonify({'error': 'No prediction data available'}), 404 - except Exception as e: - return jsonify({'error': str(e)}), 500 - - -@app.route('/api/station-config', methods=['GET', 'POST']) -def station_config(): - """ - API endpoint to get/set station configuration - - GET: Returns current station configuration - POST: Saves new station configuration - - Returns: - JSON with station configuration - """ - config_file = Path(__file__).parent / 'station_config.json' - - if request.method == 'POST': - try: - config = request.get_json() - with open(config_file, 'w') as f: - json.dump(config, f, indent=2) - return jsonify({'status': 'success', 'message': 'Station configuration saved'}) - except Exception as e: - return jsonify({'error': str(e)}), 500 - - else: # GET - try: - if config_file.exists(): - with open(config_file, 'r') as f: - config = json.load(f) - return jsonify(config) - else: - # Return default configuration - return jsonify({ - 'name': '', - 'callsign': 'VE1ATM', - 'grid': 'FN74ui', - 'lat': 44.374, - 'lon': -64.300, - 'power': 100 - }) - except Exception as e: - return jsonify({'error': str(e)}), 500 - - -@app.route('/api/antenna-config', methods=['GET', 'POST']) -def antenna_config(): - """ - API endpoint to get/set antenna configuration - - GET: Returns current antenna configuration and band assignments - POST: Saves new antenna configuration - - Returns: - JSON with antenna configuration - """ - config_file = Path(__file__).parent / 'antenna_config.json' - - if request.method == 'POST': - try: - config = request.get_json() - with open(config_file, 'w') as f: - json.dump(config, f, indent=2) - return jsonify({'status': 'success', 'message': 'Antenna configuration saved'}) - except Exception as e: - return jsonify({'error': str(e)}), 500 - - else: # GET - try: - if config_file.exists(): - with open(config_file, 'r') as f: - config = json.load(f) - return jsonify(config) - else: - # Return empty configuration - return jsonify({ - 'antennas': [], - 'band_assignments': {} - }) - except Exception as e: - return jsonify({'error': str(e)}), 500 - - -@app.route('/api/debug/cache', methods=['GET']) -def debug_cache(): - """ - API endpoint to debug HTTP caching configuration - - Returns: - JSON with current cache configuration and sample headers - """ - # Create a sample response to show what headers would be sent - sample_response = make_response("sample") - sample_response = apply_cache_control(sample_response) - - return jsonify({ - 'cache_enabled': not (server_config.get('disable_cache', False) or app.debug), - 'debug_mode': app.debug, - 'disable_cache_flag': server_config.get('disable_cache', False), - 'sample_headers': dict(sample_response.headers), - 'explanation': { - '200': 'First request - Full content sent with ETag/Last-Modified headers', - '304': 'Subsequent requests - Browser sends If-None-Match/If-Modified-Since, server responds with 304 if unchanged', - 'cache_control': sample_response.headers.get('Cache-Control', 'default') - }, - 'tips': { - 'disable_cache': 'Start server with --no-cache flag to disable caching', - 'debug_mode': 'Start server with --debug flag to auto-disable caching', - 'browser_refresh': 'Use Ctrl+Shift+R (Cmd+Shift+R on Mac) for hard refresh' - } - }) - - -# ============================================================================= -# Static File Serving -# ============================================================================= - -@app.route('/') -def index(): - """Serve the main dashboard""" - response = make_response(send_from_directory('.', 'dashboard.html')) - return apply_cache_control(response) - - -@app.route('/') -def serve_static(path): - """Serve static files""" - response = make_response(send_from_directory('.', path)) - return apply_cache_control(response) - - -# ============================================================================= -# Main -# ============================================================================= - -def check_dependencies(): - """ - Check if required dependencies are installed - Returns tuple: (success: bool, missing: list) - """ - missing = [] - - # Check core dependencies - try: - import numpy - except ImportError: - missing.append('numpy') - - try: - import requests - except ImportError: - missing.append('requests') - - # Check if dvoacap module is importable - try: - sys.path.insert(0, str(Path(__file__).parent.parent)) - from src.dvoacap.prediction_engine import PredictionEngine - except ImportError as e: - missing.append(f'dvoacap ({str(e)})') - - return len(missing) == 0, missing - - -def check_and_generate_predictions(skip_auto_gen=False): - """ - Check if prediction data exists and is recent. - Auto-generate if missing or stale (>24 hours old). - - Args: - skip_auto_gen: If True, skip automatic generation - - Returns: - bool: True if data is available, False otherwise - """ - data_file = Path(__file__).parent / 'enhanced_predictions.json' - - # Check if data file exists - if not data_file.exists(): - if skip_auto_gen: - print("⚠️ No prediction data found (use --skip-auto-gen=False to auto-generate)") - return False - - print("\n" + "=" * 80) - print("No prediction data found - generating initial predictions...") - print("=" * 80) - generate_predictions_now() - return True - - # Check if data is stale (>24 hours old) - file_age = datetime.now(timezone.utc).timestamp() - data_file.stat().st_mtime - hours_old = file_age / 3600 - - if hours_old > 24: - if skip_auto_gen: - print(f"⚠️ Prediction data is {hours_old:.1f} hours old (use --skip-auto-gen=False to auto-refresh)") - return True - - print("\n" + "=" * 80) - print(f"Prediction data is {hours_old:.1f} hours old - regenerating...") - print("=" * 80) - generate_predictions_now() - else: - print(f"✓ Prediction data is {hours_old:.1f} hours old (fresh)") - - return True - - -def generate_predictions_now(): - """ - Synchronously generate predictions during server startup - """ - script_path = Path(__file__).parent / 'generate_predictions.py' - - try: - result = subprocess.run( - [sys.executable, str(script_path)], - cwd=str(Path(__file__).parent), - capture_output=True, - text=True, - timeout=300 # 5 minute timeout - ) - - if result.returncode == 0: - print("✓ Predictions generated successfully") - # Print summary from the output - if "Total predictions:" in result.stdout: - for line in result.stdout.split('\n'): - if 'Total predictions:' in line or 'Generated:' in line: - print(f" {line.strip()}") - else: - print(f"✗ Prediction generation failed: {result.stderr[:200]}") - - except subprocess.TimeoutExpired: - print("✗ Prediction generation timed out (>5 minutes)") - except Exception as e: - print(f"✗ Error generating predictions: {e}") - - -def main(): - """Start the Flask server""" - import argparse - - parser = argparse.ArgumentParser(description='VE1ATM Propagation Dashboard Server') - parser.add_argument('--host', default='127.0.0.1', help='Host to bind to (default: 127.0.0.1)') - parser.add_argument('--port', type=int, default=8000, help='Port to bind to (default: 8000)') - parser.add_argument('--debug', action='store_true', help='Enable debug mode') - parser.add_argument('--no-cache', action='store_true', help='Disable HTTP caching (for development)') - parser.add_argument('--skip-deps-check', action='store_true', help='Skip dependency check') - parser.add_argument('--skip-auto-gen', action='store_true', help='Skip automatic prediction generation on startup') - - args = parser.parse_args() - - # Configure caching - if args.no_cache: - server_config['disable_cache'] = True - - # Check dependencies unless skipped - if not args.skip_deps_check: - success, missing = check_dependencies() - if not success: - print("=" * 80) - print("ERROR: Missing Dependencies") - print("=" * 80) - print("\nThe following required packages are not installed:") - for dep in missing: - print(f" ✗ {dep}") - print("\nTo fix this, run:") - print(" pip install -e .[dashboard]") - print("\nOr install individual packages:") - print(" pip install numpy requests flask flask-cors") - print("=" * 80) - sys.exit(1) - - print("=" * 80) - print("VE1ATM HF Propagation Dashboard Server") - print("=" * 80) - print(f"\n✓ Server starting on http://{args.host}:{args.port}") - print(f"✓ Dashboard: http://{args.host}:{args.port}/") - print(f"✓ Debug mode: {'Enabled' if args.debug else 'Disabled'}") - print(f"✓ HTTP caching: {'Disabled' if server_config['disable_cache'] or args.debug else 'Enabled'}") - - # Check and auto-generate predictions if needed - print() - check_and_generate_predictions(skip_auto_gen=args.skip_auto_gen) - - print(f"\n✓ Press Ctrl+C to stop") - print("=" * 80) +import warnings - app.run(host=args.host, port=args.port, debug=args.debug) +warnings.warn( + "Running the dashboard from Dashboard/ is deprecated. " + "Install with `pip install -e .[dashboard]` and run `dvoacap-dashboard` instead.", + DeprecationWarning, + stacklevel=2, +) +from dvoacap.dashboard.server import _parse_args_and_run, main # noqa: E402,F401 -if __name__ == '__main__': - main() +if __name__ == "__main__": + _parse_args_and_run() diff --git a/Dashboard/transform_data.py b/Dashboard/transform_data.py old mode 100755 new mode 100644 index 505d34f..bf39859 --- a/Dashboard/transform_data.py +++ b/Dashboard/transform_data.py @@ -1,258 +1,21 @@ -#!/usr/bin/env python3 -""" -Transform propagation_data.json to the format expected by dashboard.html +"""Backward-compatibility shim. + +This script has moved to ``dvoacap.dashboard.transform_data``. Use:: -This script converts the raw prediction data structure to the enhanced format -that includes current_conditions, timeline_24h, and DXCC tracking. + python -m dvoacap.dashboard.transform_data """ -import json import sys -from pathlib import Path -from datetime import datetime -from collections import defaultdict - - -# Make stdout/stderr UTF-8 where the runtime supports it (Python 3.7+). -# This keeps the script portable across consoles whose default code page -# (e.g. cp1252 on Windows, or sandboxed Microsoft Store Python) cannot -# encode characters outside ASCII. -for _stream in (sys.stdout, sys.stderr): - try: - _stream.reconfigure(encoding="utf-8", errors="replace") - except (AttributeError, ValueError): - pass - - -def transform_predictions(input_file: Path, output_file: Path, dxcc_file: Path): - """ - Transform raw prediction data to dashboard-compatible format - """ - print(f"Loading prediction data from {input_file}...") - - with open(input_file, 'r') as f: - raw_data = json.load(f) - - # Load DXCC data if available - dxcc_data = {} - if dxcc_file.exists(): - print(f"Loading DXCC data from {dxcc_file}...") - with open(dxcc_file, 'r') as f: - dxcc_data = json.load(f) - - print("Transforming data structure...") - - # Build current conditions (using UTC hour 0 as baseline) - current_hour = datetime.now().hour - - # Group predictions by region and hour - predictions_by_hour = defaultdict(list) - for pred in raw_data['predictions']: - predictions_by_hour[pred['utc_hour']].append(pred) - - # Find the closest hour to current time - available_hours = sorted(predictions_by_hour.keys()) - closest_hour = min(available_hours, key=lambda h: abs(h - current_hour)) - current_preds = predictions_by_hour[closest_hour] - - # Build current conditions bands structure - current_bands = {} - for band_name in raw_data['bands']: - # Get frequency from first prediction - freq = 0 - if band_name == '160m': freq = 1.900 - elif band_name == '80m': freq = 3.600 - elif band_name == '40m': freq = 7.150 - elif band_name == '30m': freq = 10.125 - elif band_name == '20m': freq = 14.150 - elif band_name == '17m': freq = 18.118 - elif band_name == '15m': freq = 21.200 - elif band_name == '12m': freq = 24.940 - elif band_name == '10m': freq = 28.500 - - current_bands[band_name] = { - 'frequency': freq, - 'regions': {} - } - - # Populate regions for each band - for pred in current_preds: - region_code = pred['region'] - region_name = pred['region_name'] - - for band_name, band_data in pred['bands'].items(): - if band_data['status'] in ['GOOD', 'FAIR']: - quality = band_data['status'] - elif band_data['status'] == 'POOR': - quality = 'POOR' - else: - continue # Skip CLOSED bands - - current_bands[band_name]['regions'][region_code] = { - 'name': region_name, - 'quality': quality, - 'reliability': band_data['reliability'], - 'snr_db': band_data['snr'], - 'distance_km': int(pred['distance_km']), - 'bearing': int(pred['azimuth']), - 'muf': pred['muf'] - } - - # Build timeline structure (24-72 hour forecast) - timeline_hours = [] - - # Parse the generated timestamp to get the base date - generated_dt = datetime.fromisoformat(raw_data['generated'].replace('Z', '+00:00')) - base_date = generated_dt.date() - - for hour in sorted(predictions_by_hour.keys()): - hour_data = { - 'time': f"{base_date}T{hour:02d}:00:00Z", - 'hour_utc': hour, - 'bands': {} - } - - # For each band, find which regions are open - for band_name in raw_data['bands']: - open_regions = [] - marginal_regions = [] - - for pred in predictions_by_hour[hour]: - band_pred = pred['bands'][band_name] - if band_pred['status'] == 'GOOD': - open_regions.append(pred['region']) - elif band_pred['status'] == 'FAIR': - marginal_regions.append(pred['region']) - - hour_data['bands'][band_name] = { - 'open': open_regions, - 'marginal': marginal_regions - } - - timeline_hours.append(hour_data) - - # Build propagation charts data (hourly metrics for each band and region) - prop_charts = {} - for band_name in raw_data['bands']: - prop_charts[band_name] = { - 'hours': [], - 'regions': {} - } - - # For each hour, collect metrics for this band - for hour in sorted(predictions_by_hour.keys()): - prop_charts[band_name]['hours'].append(hour) - - # Collect metrics for each region - for pred in predictions_by_hour[hour]: - region_code = pred['region'] - region_name = pred['region_name'] - - if region_code not in prop_charts[band_name]['regions']: - prop_charts[band_name]['regions'][region_code] = { - 'name': region_name, - 'reliability': [], - 'snr': [], - 'signal_dbw': [], - 'signal_10': [], - 'signal_90': [], - 'muf_day': [] - } - - band_data = pred['bands'][band_name] - prop_charts[band_name]['regions'][region_code]['reliability'].append(band_data['reliability']) - prop_charts[band_name]['regions'][region_code]['snr'].append(band_data['snr']) - prop_charts[band_name]['regions'][region_code]['signal_dbw'].append(band_data['signal_dbw']) - prop_charts[band_name]['regions'][region_code]['signal_10'].append(band_data['signal_10']) - prop_charts[band_name]['regions'][region_code]['signal_90'].append(band_data['signal_90']) - prop_charts[band_name]['regions'][region_code]['muf_day'].append(band_data['muf_day']) - - # Build the transformed structure - transformed = { - 'predictions': { - 'current_conditions': { - 'generated': raw_data['generated'], - 'solar': { - 'sfi': raw_data['solar_conditions']['sfi'], - 'ssn': raw_data['solar_conditions']['ssn'], - 'kp': raw_data['solar_conditions']['kp'], - 'a_index': raw_data['solar_conditions']['a_index'] - }, - 'bands': current_bands - }, - 'timeline_24h': { - 'hours': timeline_hours - }, - 'propagation_charts': prop_charts, - 'dxcc': dxcc_data if dxcc_data else { - 'dxcc_worked': [], - 'dxcc_confirmed_lotw': [], - 'dxcc_missing': [], - 'entity_names': {} - } - } - } - - # Write output - print(f"Writing transformed data to {output_file}...") - with open(output_file, 'w') as f: - json.dump(transformed, f, indent=2) - - print("[OK] Transformation complete!") - print(f" - Bands: {len(current_bands)}") - print(f" - Timeline hours: {len(timeline_hours)}") - print(f" - Generated: {raw_data['generated']}") - - -def main(): - """Main entry point""" - base_dir = Path(__file__).parent - - input_file = base_dir / 'propagation_data.json' - output_file = base_dir / 'enhanced_predictions.json' - dxcc_file = base_dir / 'dxcc_summary.json' - - if not input_file.exists(): - print(f"Error: {input_file} not found!", file=sys.stderr) - print("Run generate_predictions.py first to create the input data.", - file=sys.stderr) - return 1 - - try: - transform_predictions(input_file, output_file, dxcc_file) - except KeyError as e: - print( - f"Error: required field {e} is missing from {input_file.name}.", - file=sys.stderr, - ) - print( - "This usually means generate_predictions.py produced an " - "incomplete or older-format file. Try regenerating predictions.", - file=sys.stderr, - ) - return 2 - except json.JSONDecodeError as e: - print( - f"Error: {input_file.name} is not valid JSON ({e}).", - file=sys.stderr, - ) - return 3 - except OSError as e: - print(f"Error: file I/O failed ({e}).", file=sys.stderr) - return 4 - except ValueError as e: - print( - f"Error: {input_file.name} contains no usable predictions ({e}).", - file=sys.stderr, - ) - print( - "Re-run generate_predictions.py to produce a populated file.", - file=sys.stderr, - ) - return 5 +import warnings - return 0 +warnings.warn( + "Running transform_data.py from Dashboard/ is deprecated. " + "Use `python -m dvoacap.dashboard.transform_data` instead.", + DeprecationWarning, + stacklevel=2, +) +from dvoacap.dashboard.transform_data import main # noqa: E402 -if __name__ == '__main__': +if __name__ == "__main__": sys.exit(main()) diff --git a/Dashboard/update_predictions.sh b/Dashboard/update_predictions.sh deleted file mode 100644 index 3f9b816..0000000 --- a/Dashboard/update_predictions.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/bin/bash -# -# VE1ATM Propagation Update Script -# Run this to generate fresh predictions with latest solar data -# - -echo "=========================================" -echo " VE1ATM HF Propagation Dashboard" -echo " Updating with latest solar data..." -echo "=========================================" -echo "" - -# Run the Python prediction generator -python3 generate_predictions.py - -echo "" -echo "✓ Predictions updated!" -echo "" -echo "View your dashboard:" -echo " • Open: dashboard.html" -echo " • Or run: python3 -m http.server 8000" -echo " • Then visit: http://localhost:8000/dashboard.html" -echo "" -echo "=========================================" diff --git a/README.md b/README.md index f91ae37..3002628 100644 --- a/README.md +++ b/README.md @@ -147,46 +147,55 @@ DVOACAP-Python includes a web-based dashboard for visualizing propagation predic ### Quick Start with Dashboard -**Option A: Flask Server (Recommended)** +**Option A: Install via pip (recommended)** ```bash -cd Dashboard -pip install -r requirements.txt -python3 server.py +pip install "dvoacap[dashboard] @ git+https://github.com/skyelaird/dvoacap-python.git" +dvoacap-dashboard --data-dir ~/dvoacap-data +``` + +Visit `http://localhost:8000`. + +**Option B: From a clone (for development)** -# Visit http://localhost:8000 -# Click "⚡ Refresh Predictions" button to generate new predictions +```bash +git clone https://github.com/skyelaird/dvoacap-python.git +cd dvoacap-python +pip install -e ".[dashboard]" +dvoacap-dashboard ``` +Once published to PyPI, Option A becomes simply `pip install dvoacap[dashboard]`. + The Flask server provides: - API endpoints for prediction generation (`/api/generate`) - Real-time progress monitoring (`/api/status`) - Background processing (non-blocking) - Automatic dashboard reload when complete -**Option B: Static Files** - -```bash -cd Dashboard -python3 generate_predictions.py -open dashboard.html -``` +Generated files (`propagation_data.json`, `enhanced_predictions.json`, etc.) +are written to the directory passed via `--data-dir`, or the current working +directory if no flag is given. You can also set `DVOACAP_DATA_DIR` in the +environment. ### Configuration -Edit `Dashboard/dvoacap_wrapper.py` to customize: +Edit `src/dvoacap/dashboard/dvoacap_wrapper.py` to customize: - Your callsign and QTH coordinates - Station power and antenna characteristics - Target bands and DX entities - Update frequency +(Note: this config currently lives inside the package and is overwritten on +upgrade. Phase 2 will move it to `data_dir/dvoacap_config.json`.) + ### Dashboard Documentation -See [Dashboard/README.md](Dashboard/README.md) for complete setup instructions, configuration options, and API documentation. +See [src/dvoacap/dashboard/README.md](src/dvoacap/dashboard/README.md) for complete setup instructions, configuration options, and API documentation. ### Future Plans -See [Dashboard/ISSUE_MULTI_USER_WEB_APP.md](Dashboard/ISSUE_MULTI_USER_WEB_APP.md) for the roadmap to expand the dashboard into a multi-user community service with: +See [src/dvoacap/dashboard/ISSUE_MULTI_USER_WEB_APP.md](src/dvoacap/dashboard/ISSUE_MULTI_USER_WEB_APP.md) for the roadmap to expand the dashboard into a multi-user community service with: - User authentication and accounts - Per-user station configurations - Database backend for historical tracking @@ -346,14 +355,16 @@ dvoacap-python/ │ │ └── reflectrix.py # Phase 4 │ └── original/ # Reference Pascal source │ └── *.pas -├── Dashboard/ # Web-based visualization dashboard +├── src/dvoacap/dashboard/ # Web-based visualization dashboard (pip-installable subpackage) │ ├── server.py # Flask API server +│ ├── cli.py # `dvoacap-dashboard` console script +│ ├── paths.py # Data dir / static asset resolution │ ├── dashboard.html # Interactive dashboard UI │ ├── generate_predictions.py # Prediction generation script │ ├── dvoacap_wrapper.py # DVOACAP integration wrapper -│ ├── requirements.txt # Server dependencies │ ├── README.md # Dashboard documentation │ └── ISSUE_MULTI_USER_WEB_APP.md # Multi-user service roadmap +├── Dashboard/ # Backward-compatibility shims (deprecated) ├── tests/ # Test suite │ ├── test_path_geometry.py │ ├── test_voacap_parser.py diff --git a/pyproject.toml b/pyproject.toml index b5f49b7..d8f6de0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,6 +71,9 @@ all = [ "dvoacap[dashboard,dev,docs]", ] +[project.scripts] +dvoacap-dashboard = "dvoacap.dashboard.cli:main" + [project.urls] Homepage = "https://github.com/skyelaird/dvoacap-python" Documentation = "https://skyelaird.github.io/dvoacap-python/" @@ -88,6 +91,13 @@ exclude = ["original*"] [tool.setuptools.package-data] dvoacap = ["DVoaData/*.dat"] +"dvoacap.dashboard" = [ + "*.html", + "*.json", + "*.md", + "*.sh", + "requirements.txt", +] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/Dashboard/CHANGELOG_FIXES.md b/src/dvoacap/dashboard/CHANGELOG_FIXES.md similarity index 100% rename from Dashboard/CHANGELOG_FIXES.md rename to src/dvoacap/dashboard/CHANGELOG_FIXES.md diff --git a/Dashboard/DESIGN_ANALYSIS.md b/src/dvoacap/dashboard/DESIGN_ANALYSIS.md similarity index 100% rename from Dashboard/DESIGN_ANALYSIS.md rename to src/dvoacap/dashboard/DESIGN_ANALYSIS.md diff --git a/Dashboard/FIX_SUMMARY.md b/src/dvoacap/dashboard/FIX_SUMMARY.md similarity index 100% rename from Dashboard/FIX_SUMMARY.md rename to src/dvoacap/dashboard/FIX_SUMMARY.md diff --git a/Dashboard/ISSUE_MULTI_USER_WEB_APP.md b/src/dvoacap/dashboard/ISSUE_MULTI_USER_WEB_APP.md similarity index 100% rename from Dashboard/ISSUE_MULTI_USER_WEB_APP.md rename to src/dvoacap/dashboard/ISSUE_MULTI_USER_WEB_APP.md diff --git a/Dashboard/MOCKUPS_README.md b/src/dvoacap/dashboard/MOCKUPS_README.md similarity index 100% rename from Dashboard/MOCKUPS_README.md rename to src/dvoacap/dashboard/MOCKUPS_README.md diff --git a/Dashboard/PROPAGATION_MAPS_README.md b/src/dvoacap/dashboard/PROPAGATION_MAPS_README.md similarity index 100% rename from Dashboard/PROPAGATION_MAPS_README.md rename to src/dvoacap/dashboard/PROPAGATION_MAPS_README.md diff --git a/Dashboard/README.md b/src/dvoacap/dashboard/README.md similarity index 100% rename from Dashboard/README.md rename to src/dvoacap/dashboard/README.md diff --git a/Dashboard/USER_MANUAL.md b/src/dvoacap/dashboard/USER_MANUAL.md similarity index 100% rename from Dashboard/USER_MANUAL.md rename to src/dvoacap/dashboard/USER_MANUAL.md diff --git a/src/dvoacap/dashboard/__init__.py b/src/dvoacap/dashboard/__init__.py new file mode 100644 index 0000000..b0d848b --- /dev/null +++ b/src/dvoacap/dashboard/__init__.py @@ -0,0 +1,7 @@ +"""DVOACAP Dashboard subpackage. + +A Flask-based web dashboard for visualising HF propagation predictions +produced by the DVOACAP prediction engine. + +Run via the ``dvoacap-dashboard`` console script (see ``cli.py``). +""" diff --git a/Dashboard/antenna_config.json b/src/dvoacap/dashboard/antenna_config.json similarity index 100% rename from Dashboard/antenna_config.json rename to src/dvoacap/dashboard/antenna_config.json diff --git a/src/dvoacap/dashboard/cli.py b/src/dvoacap/dashboard/cli.py new file mode 100644 index 0000000..dd37c0c --- /dev/null +++ b/src/dvoacap/dashboard/cli.py @@ -0,0 +1,72 @@ +"""CLI entry point for the dvoacap dashboard.""" + +from __future__ import annotations + +import argparse +import os +import sys +from pathlib import Path + + +def main(): + parser = argparse.ArgumentParser( + prog="dvoacap-dashboard", + description="Run the DVOACAP HF propagation dashboard.", + ) + parser.add_argument( + "--data-dir", + type=Path, + default=None, + help="Directory for generated data and user config " + "(default: current working directory).", + ) + parser.add_argument( + "--host", + default="127.0.0.1", + help="Host to bind (default: 127.0.0.1).", + ) + parser.add_argument( + "--port", + type=int, + default=8000, + help="Port to bind (default: 8000).", + ) + parser.add_argument( + "--debug", + action="store_true", + help="Enable Flask debug mode.", + ) + parser.add_argument( + "--no-cache", + action="store_true", + help="Disable HTTP caching (useful during development).", + ) + parser.add_argument( + "--skip-deps-check", + action="store_true", + help="Skip startup dependency check.", + ) + parser.add_argument( + "--skip-auto-gen", + action="store_true", + help="Skip automatic prediction generation on startup.", + ) + args = parser.parse_args() + + if args.data_dir: + os.environ["DVOACAP_DATA_DIR"] = str(args.data_dir.expanduser().resolve()) + + # Imported here so DVOACAP_DATA_DIR is honoured by the time paths.py runs. + from .server import main as server_main + return server_main( + host=args.host, + port=args.port, + debug=args.debug, + no_cache=args.no_cache, + skip_deps_check=args.skip_deps_check, + skip_auto_gen=args.skip_auto_gen, + ) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/Dashboard/dashboard.html b/src/dvoacap/dashboard/dashboard.html similarity index 100% rename from Dashboard/dashboard.html rename to src/dvoacap/dashboard/dashboard.html diff --git a/Dashboard/dashboard_mockup.html b/src/dvoacap/dashboard/dashboard_mockup.html similarity index 100% rename from Dashboard/dashboard_mockup.html rename to src/dvoacap/dashboard/dashboard_mockup.html diff --git a/Dashboard/dvoacap_wrapper.py b/src/dvoacap/dashboard/dvoacap_wrapper.py similarity index 98% rename from Dashboard/dvoacap_wrapper.py rename to src/dvoacap/dashboard/dvoacap_wrapper.py index 67e4e80..08f52d2 100644 --- a/Dashboard/dvoacap_wrapper.py +++ b/src/dvoacap/dashboard/dvoacap_wrapper.py @@ -1,4 +1,7 @@ #!/usr/bin/env python3 +# TODO(phase 2): Externalise station/QTH/callsign config to +# `data_dir/dvoacap_config.json` so it survives package upgrades. +# Phase 1 keeps this edit-in-place inside the package. """ DVOACAP Python Wrapper - Corrected Version Calls the dvoa.dll with the EXACT format it expects diff --git a/src/dvoacap/dashboard/generate_predictions.py b/src/dvoacap/dashboard/generate_predictions.py new file mode 100644 index 0000000..72dff51 --- /dev/null +++ b/src/dvoacap/dashboard/generate_predictions.py @@ -0,0 +1,488 @@ +#!/usr/bin/env python3 +""" +VE1ATM HF Propagation Prediction Generator +Uses DVOACAP-Python prediction engine for accurate HF band forecasts + +This script generates 24-hour propagation predictions for VE1ATM's station +to major DX regions worldwide, using the complete DVOACAP prediction engine. +""" + +import json +import sys +import numpy as np +from datetime import datetime, timezone, timedelta +from pathlib import Path +from typing import Dict, List, Tuple + +from dvoacap.path_geometry import GeoPoint +from dvoacap.prediction_engine import PredictionEngine +from dvoacap.space_weather_sources import MultiSourceSpaceWeatherFetcher +from dvoacap.antenna_gain import create_antenna + +from .paths import get_data_dir, get_user_antenna_config + + +# Custom JSON encoder to handle numpy types +class NumpyEncoder(json.JSONEncoder): + def default(self, obj): + if isinstance(obj, (np.integer, np.int64, np.int32)): + return int(obj) + elif isinstance(obj, (np.floating, np.float64, np.float32)): + return float(obj) + elif isinstance(obj, np.ndarray): + return obj.tolist() + return super(NumpyEncoder, self).default(obj) + + +# ============================================================================= +# VE1ATM Station Configuration +# ============================================================================= + +MY_QTH = { + 'call': 'VE1ATM', + 'lat': 44.374, # FN74ui - Lunenburg, Nova Scotia + 'lon': -64.300, + 'grid': 'FN74ui', + 'antenna': 'DX Commander 7m Vertical', + 'location': GeoPoint.from_degrees(44.374, -64.300) +} + +# HF Amateur bands (center frequencies in MHz) +BANDS = { + '160m': 1.900, + '80m': 3.600, + '40m': 7.150, + '30m': 10.125, + '20m': 14.150, + '17m': 18.118, + '15m': 21.200, + '12m': 24.940, + '10m': 28.500 +} + +# Target DX regions +TARGET_REGIONS = { + 'EU': {'name': 'Europe', 'location': GeoPoint.from_degrees(50.0, 10.0)}, + 'UK': {'name': 'United Kingdom', 'location': GeoPoint.from_degrees(54.0, -2.0)}, + 'JA': {'name': 'Japan', 'location': GeoPoint.from_degrees(36.0, 138.0)}, + 'VK': {'name': 'Australia', 'location': GeoPoint.from_degrees(-25.0, 135.0)}, + 'ZL': {'name': 'New Zealand', 'location': GeoPoint.from_degrees(-41.0, 174.0)}, + 'AF': {'name': 'Africa', 'location': GeoPoint.from_degrees(0.0, 20.0)}, + 'SA': {'name': 'South America', 'location': GeoPoint.from_degrees(-15.0, -55.0)}, + 'CA': {'name': 'Central America', 'location': GeoPoint.from_degrees(15.0, -90.0)}, + 'AS': {'name': 'Asia', 'location': GeoPoint.from_degrees(30.0, 100.0)}, + 'OC': {'name': 'Oceania', 'location': GeoPoint.from_degrees(-10.0, 150.0)}, +} + + +# ============================================================================= +# Antenna Configuration +# ============================================================================= + +def load_antenna_configuration() -> Dict: + """ + Load antenna configuration from antenna_config.json + + Returns dict with: + - antennas: List of antenna definitions + - band_assignments: Dict mapping bands to antenna names + """ + config_file = get_user_antenna_config() + + if config_file.exists(): + try: + with open(config_file, 'r') as f: + config = json.load(f) + print(f"[OK] Loaded antenna configuration: {len(config.get('antennas', []))} antennas") + return config + except Exception as e: + print(f"[WARNING] Error loading antenna config: {e}") + return {'antennas': [], 'band_assignments': {}} + else: + # Default: DX Commander vertical for all bands + print("[INFO] No antenna config found, using default (Vertical Monopole)") + return { + 'antennas': [{ + 'id': 'default', + 'name': 'DX Commander Vertical', + 'type': 'vertical' + }], + 'band_assignments': {band: 'default' for band in BANDS.keys()} + } + + +def configure_antennas(engine: PredictionEngine, config: Dict) -> None: + """ + Configure antenna farm based on user configuration + + Args: + engine: PredictionEngine instance + config: Antenna configuration dict + """ + # Get band-to-frequency mapping + band_freqs = { + '160m': (1.8, 2.0), + '80m': (3.5, 4.0), + '40m': (7.0, 7.3), + '30m': (10.1, 10.15), + '20m': (14.0, 14.35), + '17m': (18.068, 18.168), + '15m': (21.0, 21.45), + '12m': (24.89, 24.99), + '10m': (28.0, 29.7) + } + + antennas = config.get('antennas', []) + band_assignments = config.get('band_assignments', {}) + + # Build antenna lookup by ID + antenna_lookup = {ant['id']: ant for ant in antennas} + + # For each band, add the assigned antenna to the farm + for band, antenna_id in band_assignments.items(): + if band in band_freqs and antenna_id in antenna_lookup: + antenna_info = antenna_lookup[antenna_id] + antenna_type = antenna_info.get('type', 'vertical') + + # Get frequency range for this band + low_freq, high_freq = band_freqs[band] + + try: + # Create antenna instance + antenna = create_antenna( + antenna_type=antenna_type, + low_frequency=low_freq, + high_frequency=high_freq, + tx_power_dbw=10.0 # Will be set from engine.params.tx_power + ) + + # Add to both TX and RX antenna farms + engine.tx_antennas.add_antenna(antenna) + engine.rx_antennas.add_antenna(antenna) + + print(f" [OK] {band}: {antenna_info['name']} ({antenna_type})") + except ValueError as e: + print(f" [WARNING] {band}: Could not create antenna: {e}") + + print(f"[OK] Configured {len(engine.tx_antennas.antennas)} antenna(s)") + + +# ============================================================================= +# Solar Data Fetching +# ============================================================================= + +def fetch_solar_conditions() -> Dict: + """ + Fetch current solar-terrestrial conditions from multiple international sources + Returns dict with SFI, SSN, Kp, A-index + + Data sources (with automatic fallback): + - Solar Flux Index (F10.7): NOAA SWPC, LISIRD, Space Weather Canada + - Sunspot Number: SIDC/SILSO (Belgium), NOAA SWPC + - Kp Index: GFZ Potsdam (Germany), NOAA SWPC + - A Index: GFZ Potsdam (Germany), NOAA SWPC + + This function uses the MultiSourceSpaceWeatherFetcher which automatically + tries multiple sources and falls back if primary sources are unavailable. + """ + print("\n" + "=" * 70) + print("Fetching Space Weather Data from International Sources") + print("=" * 70) + + # Use the multi-source fetcher for increased reliability + fetcher = MultiSourceSpaceWeatherFetcher(timeout=10, verbose=True) + + # Fetch data in legacy format for backward compatibility + solar_data = fetcher.fetch_all_legacy_format() + + print() + print(f"[OK] Solar conditions: SFI={solar_data['sfi']:.0f}, SSN={solar_data['ssn']:.0f}, " + f"Kp={solar_data['kp']:.1f}, A={solar_data['a_index']:.1f}") + print(f" Overall source: {solar_data['source']}") + print(f" Detailed sources:") + for param, source in solar_data.get('sources_detail', {}).items(): + print(f" {param.upper()}: {source}") + + if solar_data.get('errors'): + print(f" Note: Some sources failed, see above for details") + + print("=" * 70) + + return solar_data + + +# ============================================================================= +# Prediction Generation +# ============================================================================= + +def generate_prediction( + engine: PredictionEngine, + region_code: str, + region_info: Dict, + utc_hour: int, + frequencies: List[float] +) -> Dict: + """ + Generate propagation prediction for a specific region at a specific hour + + Returns dict with band-by-band predictions + """ + utc_fraction = utc_hour / 24.0 + + try: + # Run prediction + engine.predict( + rx_location=region_info['location'], + utc_time=utc_fraction, + frequencies=frequencies + ) + + # Extract predictions for each band + band_predictions = {} + for band_name, freq in BANDS.items(): + # Find the prediction for this frequency + pred_idx = None + for i, f in enumerate(frequencies): + if abs(f - freq) < 0.1: # Match within 100 kHz + pred_idx = i + break + + if pred_idx is not None and pred_idx < len(engine.predictions): + pred = engine.predictions[pred_idx] + + # Determine status based on reliability and SNR + reliability = pred.signal.reliability * 100 + snr = pred.signal.snr_db + + if reliability >= 60 and snr >= 10: + status = 'GOOD' + elif reliability >= 30 or snr >= 3: + status = 'FAIR' + elif reliability > 0: + status = 'POOR' + else: + status = 'CLOSED' + + band_predictions[band_name] = { + 'status': status, + 'reliability': round(reliability, 1), + 'snr': round(snr, 1), + 'mode': pred.get_mode_name(engine.path.dist), + 'hops': pred.hop_count, + 'elevation': round(np.rad2deg(pred.tx_elevation), 1), + # Enhanced data for prop charts + # muf_day is probability that MUF exceeds frequency (1.0=well below MUF, 0.0=at/above MUF) + # Dashboard inverts this to show "MUF usage percentage" + 'muf_day': round(pred.signal.muf_day * 100, 1), # MUF probability (%) - inverted by dashboard for display + 'signal_dbw': round(pred.signal.power_dbw, 1), # Median signal power + # power10/power90 are DEVIATIONS from median, not absolute values + 'signal_10': round(pred.signal.power_dbw - pred.signal.power10, 1), # 10th percentile (weaker) + 'signal_90': round(pred.signal.power_dbw + pred.signal.power90, 1), # 90th percentile (stronger) + 'snr_10': round(pred.signal.snr10, 1), # Lower decile SNR + 'snr_90': round(pred.signal.snr90, 1), # Upper decile SNR + } + else: + # No prediction available + band_predictions[band_name] = { + 'status': 'CLOSED', + 'reliability': 0, + 'snr': -999, + 'mode': 'N/A', + 'hops': 0, + 'elevation': 0, + 'muf_day': 0, + 'signal_dbw': -999, + 'signal_10': -999, + 'signal_90': -999, + 'snr_10': -999, + 'snr_90': -999, + } + + # Calculate path info + distance_km = engine.path.dist * 6370 + azimuth_deg = np.rad2deg(engine.path.azim_tr) + + return { + 'region': region_code, + 'region_name': region_info['name'], + 'utc_hour': utc_hour, + 'distance_km': round(distance_km, 0), + 'azimuth': round(azimuth_deg, 1), + 'muf': round(engine.circuit_muf.muf, 2) if engine.circuit_muf else 0, + 'bands': band_predictions + } + + except Exception as e: + print(f" [WARNING] Error predicting {region_code} at {utc_hour:02d}00 UTC: {e}") + # Return empty prediction with all enhanced fields + return { + 'region': region_code, + 'region_name': region_info['name'], + 'utc_hour': utc_hour, + 'distance_km': 0, + 'azimuth': 0, + 'muf': 0, + 'bands': {band: {'status': 'ERROR', 'reliability': 0, 'snr': -999, + 'mode': 'N/A', 'hops': 0, 'elevation': 0, + 'muf_day': 0, 'signal_dbw': -999, 'signal_10': -999, + 'signal_90': -999, 'snr_10': -999, 'snr_90': -999} + for band in BANDS.keys()} + } + + +def generate_24hour_forecast() -> Dict: + """ + Generate complete 24-hour propagation forecast for all regions + """ + print("=" * 80) + print("VE1ATM HF Propagation Prediction Generator") + print("Using DVOACAP-Python Full Prediction Engine") + print("=" * 80) + print() + + # Get solar conditions + solar = fetch_solar_conditions() + + # Initialize prediction engine + print("\n[OK] Initializing DVOACAP prediction engine...") + engine = PredictionEngine() + + # Configure engine + now = datetime.now(timezone.utc) + engine.params.ssn = solar['ssn'] + engine.params.month = now.month + engine.params.tx_power = 100.0 # 100W + engine.params.tx_location = MY_QTH['location'] + engine.params.min_angle = np.deg2rad(3.0) # 3° minimum takeoff angle + engine.params.required_snr = 10.0 # 10 dB SNR for good copy + engine.params.required_reliability = 0.9 + + print(f"[OK] Configuration: Month={now.month}, SSN={solar['ssn']:.0f}, TX Power=100W") + + # Configure antennas + print("\n[OK] Configuring antennas...") + antenna_config = load_antenna_configuration() + configure_antennas(engine, antenna_config) + + # Generate predictions + frequencies = list(BANDS.values()) + all_predictions = [] + + # Generate hourly predictions for detailed prop charts (v1.0 enhancement) + utc_hours = range(0, 24, 1) # Changed from 2-hour to 1-hour intervals + + print(f"\n[OK] Generating predictions for {len(TARGET_REGIONS)} regions, {len(utc_hours)} time points...") + print() + + for utc_hour in utc_hours: + print(f" Processing {utc_hour:02d}00 UTC...", end=' ') + hour_count = 0 + + for region_code, region_info in TARGET_REGIONS.items(): + pred = generate_prediction(engine, region_code, region_info, utc_hour, frequencies) + all_predictions.append(pred) + hour_count += 1 + + print(f"[OK] {hour_count} regions") + + # Build output structure + # Create JSON-safe station info (exclude GeoPoint object) + station_info = {k: v for k, v in MY_QTH.items() if k != 'location'} + + output = { + 'generated': datetime.now(timezone.utc).isoformat(), + 'station': station_info, + 'solar_conditions': solar, + 'bands': list(BANDS.keys()), + 'regions': {code: info['name'] for code, info in TARGET_REGIONS.items()}, + 'predictions': all_predictions + } + + return output + + +# ============================================================================= +# Main +# ============================================================================= + +def main(): + """Main entry point""" + + # Generate predictions + data = generate_24hour_forecast() + + # Save to JSON + output_file = get_data_dir() / 'propagation_data.json' + with open(output_file, 'w') as f: + json.dump(data, f, indent=2, cls=NumpyEncoder) + + print() + print("=" * 80) + print(f"[OK] Predictions saved to: {output_file}") + print("=" * 80) + + # Transform data to dashboard-compatible format + print() + print("[OK] Transforming data for dashboard...") + try: + import subprocess + result = subprocess.run( + [sys.executable, '-m', 'dvoacap.dashboard.transform_data'], + capture_output=True, + text=True, + check=False, + ) + if result.stdout: + print(result.stdout, end="") + if result.returncode != 0: + print( + f"[WARNING] transform_data.py exited with code " + f"{result.returncode}.", + file=sys.stderr, + ) + if result.stderr: + print("--- transform_data.py stderr ---", file=sys.stderr) + print(result.stderr, end="", file=sys.stderr) + print("--------------------------------", file=sys.stderr) + print( + "Dashboard may not display correctly without " + "enhanced_predictions.json.", + file=sys.stderr, + ) + except FileNotFoundError as e: + print( + f"[WARNING] Could not run transform_data.py: {e}", + file=sys.stderr, + ) + print( + "Dashboard may not display correctly without " + "enhanced_predictions.json.", + file=sys.stderr, + ) + except Exception as e: + print( + f"[WARNING] Unexpected error invoking transform_data.py: {e}", + file=sys.stderr, + ) + print( + "Dashboard may not display correctly without " + "enhanced_predictions.json.", + file=sys.stderr, + ) + + print() + print("Summary:") + print(f" * Total predictions: {len(data['predictions'])}") + print(f" * Regions covered: {len(TARGET_REGIONS)}") + print(f" * Bands: {', '.join(BANDS.keys())}") + print(f" * Generated: {data['generated']}") + print() + print("Next steps:") + print(" 1. Start the server: python3 server.py") + print(" 2. Open http://localhost:8000 in your browser") + print(" 3. View the updated predictions with live refresh capability") + print() + + +if __name__ == "__main__": + main() diff --git a/Dashboard/generate_propagation_maps.py b/src/dvoacap/dashboard/generate_propagation_maps.py similarity index 96% rename from Dashboard/generate_propagation_maps.py rename to src/dvoacap/dashboard/generate_propagation_maps.py index e882f5b..36fb405 100644 --- a/Dashboard/generate_propagation_maps.py +++ b/src/dvoacap/dashboard/generate_propagation_maps.py @@ -13,12 +13,11 @@ from datetime import datetime from typing import Dict, List, Tuple -# Add parent directory to import dvoacap -sys.path.insert(0, str(Path(__file__).parent.parent)) +from dvoacap.path_geometry import GeoPoint +from dvoacap.prediction_engine import PredictionEngine -from src.dvoacap.path_geometry import GeoPoint -from src.dvoacap.prediction_engine import PredictionEngine -from Dashboard.mode_presets import MODE_PRESETS, apply_mode_preset +from .mode_presets import MODE_PRESETS, apply_mode_preset +from .paths import get_data_dir def maidenhead_to_latlon(grid: str) -> Tuple[float, float]: @@ -286,7 +285,7 @@ def main(): {'freq': 21.074, 'mode': 'FT8', 'hour': 18, 'band': '15m'}, ] - output_dir = Path(__file__).parent / 'propagation_maps' + output_dir = get_data_dir() / 'propagation_maps' output_dir.mkdir(exist_ok=True) for config in test_configs: diff --git a/Dashboard/index.html b/src/dvoacap/dashboard/index.html similarity index 100% rename from Dashboard/index.html rename to src/dvoacap/dashboard/index.html diff --git a/Dashboard/mockup_v1_regions.html b/src/dvoacap/dashboard/mockup_v1_regions.html similarity index 100% rename from Dashboard/mockup_v1_regions.html rename to src/dvoacap/dashboard/mockup_v1_regions.html diff --git a/Dashboard/mockup_v2_heatmap.html b/src/dvoacap/dashboard/mockup_v2_heatmap.html similarity index 100% rename from Dashboard/mockup_v2_heatmap.html rename to src/dvoacap/dashboard/mockup_v2_heatmap.html diff --git a/Dashboard/mockup_v3_hybrid.html b/src/dvoacap/dashboard/mockup_v3_hybrid.html similarity index 100% rename from Dashboard/mockup_v3_hybrid.html rename to src/dvoacap/dashboard/mockup_v3_hybrid.html diff --git a/Dashboard/mockup_v4_maidenhead_grid.html b/src/dvoacap/dashboard/mockup_v4_maidenhead_grid.html similarity index 100% rename from Dashboard/mockup_v4_maidenhead_grid.html rename to src/dvoacap/dashboard/mockup_v4_maidenhead_grid.html diff --git a/Dashboard/mode_presets.py b/src/dvoacap/dashboard/mode_presets.py similarity index 100% rename from Dashboard/mode_presets.py rename to src/dvoacap/dashboard/mode_presets.py diff --git a/Dashboard/parse_adif.py b/src/dvoacap/dashboard/parse_adif.py similarity index 100% rename from Dashboard/parse_adif.py rename to src/dvoacap/dashboard/parse_adif.py diff --git a/src/dvoacap/dashboard/paths.py b/src/dvoacap/dashboard/paths.py new file mode 100644 index 0000000..54f6d09 --- /dev/null +++ b/src/dvoacap/dashboard/paths.py @@ -0,0 +1,61 @@ +"""Path resolution helpers for the dvoacap dashboard. + +The dashboard ships static files (HTML, JSON config templates) inside the +package, but writes generated predictions and user-editable config into a +separate data directory chosen at runtime. + +Phase 1 keeps this simple: + +- Static assets are always located via ``PACKAGE_DIR``. +- The data directory is taken from ``$DVOACAP_DATA_DIR`` if set, otherwise + the current working directory. + +A future phase 2 will introduce a proper user-data directory +(e.g. ``~/.dvoacap/``) and a config file. +""" + +from __future__ import annotations + +import os +import shutil +from pathlib import Path + +PACKAGE_DIR = Path(__file__).parent + + +def get_data_dir() -> Path: + """Return the directory for generated/user data files. + + Resolution order: + 1. ``DVOACAP_DATA_DIR`` environment variable (expanded and resolved) + 2. Current working directory + + The directory is created if it does not exist. + """ + env = os.environ.get("DVOACAP_DATA_DIR") + if env: + p = Path(env).expanduser().resolve() + else: + p = Path.cwd() + p.mkdir(parents=True, exist_ok=True) + return p + + +def get_static_file(name: str) -> Path: + """Return the path to a packaged static file (HTML, JSON template, etc.).""" + return PACKAGE_DIR / name + + +def get_user_antenna_config() -> Path: + """Return the path to the user's antenna_config.json. + + If the file does not exist in the data directory, copy the packaged + template there on first access. + """ + data_dir = get_data_dir() + user_path = data_dir / "antenna_config.json" + if not user_path.exists(): + template = PACKAGE_DIR / "antenna_config.json" + if template.exists(): + shutil.copyfile(template, user_path) + return user_path diff --git a/Dashboard/propagation_maps.html b/src/dvoacap/dashboard/propagation_maps.html similarity index 100% rename from Dashboard/propagation_maps.html rename to src/dvoacap/dashboard/propagation_maps.html diff --git a/Dashboard/proppy_net_api.py b/src/dvoacap/dashboard/proppy_net_api.py similarity index 100% rename from Dashboard/proppy_net_api.py rename to src/dvoacap/dashboard/proppy_net_api.py diff --git a/Dashboard/pskreporter_api.py b/src/dvoacap/dashboard/pskreporter_api.py similarity index 100% rename from Dashboard/pskreporter_api.py rename to src/dvoacap/dashboard/pskreporter_api.py diff --git a/src/dvoacap/dashboard/requirements.txt b/src/dvoacap/dashboard/requirements.txt new file mode 100644 index 0000000..7fd897f --- /dev/null +++ b/src/dvoacap/dashboard/requirements.txt @@ -0,0 +1,8 @@ +# Deprecated: dependencies are now declared in pyproject.toml under the +# [project.optional-dependencies] dashboard extra. Install with: +# pip install -e ".[dashboard]" +# This file is preserved for reference only. +flask>=2.3.0 +flask-cors>=4.0.0 +requests>=2.31.0 +numpy>=1.24.0 diff --git a/src/dvoacap/dashboard/server.py b/src/dvoacap/dashboard/server.py new file mode 100644 index 0000000..126f7bc --- /dev/null +++ b/src/dvoacap/dashboard/server.py @@ -0,0 +1,530 @@ +#!/usr/bin/env python3 +""" +VE1ATM HF Propagation Dashboard Server + +Lightweight Flask server that: +- Serves the dashboard and static files +- Provides API endpoint to trigger prediction generation +- Allows on-demand refresh from the web interface + +Usage: + python3 server.py + +Then visit: http://localhost:8000 +""" + +import sys +import json +import threading +import subprocess +from pathlib import Path +from datetime import datetime, timezone +from flask import Flask, jsonify, send_from_directory, request, make_response +from flask_cors import CORS + +from .paths import PACKAGE_DIR, get_data_dir, get_static_file, get_user_antenna_config + +app = Flask(__name__) +CORS(app) # Enable CORS for API requests + +# Global state for prediction generation +generation_state = { + 'running': False, + 'progress': 0, + 'message': 'Ready', + 'last_updated': None, + 'error': None +} + +# Global configuration +server_config = { + 'disable_cache': False # Set to True to disable HTTP caching +} + + +def apply_cache_control(response): + """ + Apply cache control headers to a response based on server configuration + + Args: + response: Flask response object + + Returns: + Modified response with cache control headers + """ + if server_config.get('disable_cache', False) or app.debug: + # Disable caching for development + response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate' + response.headers['Pragma'] = 'no-cache' + response.headers['Expires'] = '0' + else: + # Allow caching in production with 5 minute max-age + response.headers['Cache-Control'] = 'public, max-age=300' + + return response + + +def run_prediction_generator(): + """ + Run the prediction generator in a background thread + Updates global state as it progresses + """ + global generation_state + + try: + generation_state['running'] = True + generation_state['progress'] = 10 + generation_state['message'] = 'Starting prediction engine...' + generation_state['error'] = None + + generation_state['progress'] = 20 + generation_state['message'] = 'Fetching solar conditions...' + + # Execute the prediction generator as a subprocess via -m so that + # relative imports resolve correctly regardless of install location. + result = subprocess.run( + [sys.executable, '-m', 'dvoacap.dashboard.generate_predictions'], + cwd=str(get_data_dir()), + capture_output=True, + text=True, + timeout=300 # 5 minute timeout + ) + + if result.returncode == 0: + generation_state['progress'] = 100 + generation_state['message'] = 'Predictions updated successfully!' + generation_state['last_updated'] = datetime.now().isoformat() + else: + # Combine stdout and stderr for complete error message + error_output = result.stderr if result.stderr else result.stdout + if not error_output: + error_output = f"Process exited with code {result.returncode}" + generation_state['error'] = f"Generator failed: {error_output[:500]}" + generation_state['message'] = 'Generation failed' + + except subprocess.TimeoutExpired: + generation_state['error'] = 'Prediction generation timed out (>5 minutes)' + generation_state['message'] = 'Timeout error' + except Exception as e: + generation_state['error'] = str(e) + generation_state['message'] = f'Error: {str(e)[:100]}' + finally: + generation_state['running'] = False + + +# ============================================================================= +# API Endpoints +# ============================================================================= + +@app.route('/api/generate', methods=['POST']) +def trigger_generation(): + """ + API endpoint to trigger prediction generation + + Returns: + JSON with status + """ + global generation_state + + if generation_state['running']: + return jsonify({ + 'status': 'already_running', + 'message': 'Prediction generation already in progress' + }), 409 + + # Start generation in background thread + thread = threading.Thread(target=run_prediction_generator, daemon=True) + thread.start() + + return jsonify({ + 'status': 'started', + 'message': 'Prediction generation started' + }) + + +@app.route('/api/health', methods=['GET']) +def health_check(): + """ + API endpoint for health check monitoring + + Returns: + JSON with health status + """ + return jsonify({ + 'status': 'healthy', + 'timestamp': datetime.now().isoformat(), + 'service': 'dvoacap-dashboard' + }) + + +@app.route('/api/train', methods=['GET', 'POST']) +def train_model(): + """ + API endpoint for model training status + + Returns: + JSON with training status + """ + # For now, return a placeholder response + # This can be expanded to handle actual model training + return jsonify({ + 'status': 'not_implemented', + 'message': 'Training endpoint placeholder', + 'timestamp': datetime.now().isoformat() + }) + + +@app.route('/api/status', methods=['GET']) +def get_status(): + """ + API endpoint to check generation status + + Returns: + JSON with current state + """ + return jsonify(generation_state) + + +@app.route('/api/data', methods=['GET']) +def get_prediction_data(): + """ + API endpoint to fetch current prediction data + + Returns: + JSON prediction data + """ + try: + data_file = get_data_dir() / 'enhanced_predictions.json' + if data_file.exists(): + with open(data_file, 'r') as f: + data = json.load(f) + # Extract predictions object from enhanced data structure + predictions = data.get('predictions', data) + return jsonify(predictions) + else: + return jsonify({'error': 'No prediction data available'}), 404 + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +@app.route('/api/station-config', methods=['GET', 'POST']) +def station_config(): + """ + API endpoint to get/set station configuration + + GET: Returns current station configuration + POST: Saves new station configuration + + Returns: + JSON with station configuration + """ + config_file = get_data_dir() / 'station_config.json' + + if request.method == 'POST': + try: + config = request.get_json() + with open(config_file, 'w') as f: + json.dump(config, f, indent=2) + return jsonify({'status': 'success', 'message': 'Station configuration saved'}) + except Exception as e: + return jsonify({'error': str(e)}), 500 + + else: # GET + try: + if config_file.exists(): + with open(config_file, 'r') as f: + config = json.load(f) + return jsonify(config) + else: + # Return default configuration + return jsonify({ + 'name': '', + 'callsign': 'VE1ATM', + 'grid': 'FN74ui', + 'lat': 44.374, + 'lon': -64.300, + 'power': 100 + }) + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +@app.route('/api/antenna-config', methods=['GET', 'POST']) +def antenna_config(): + """ + API endpoint to get/set antenna configuration + + GET: Returns current antenna configuration and band assignments + POST: Saves new antenna configuration + + Returns: + JSON with antenna configuration + """ + config_file = get_user_antenna_config() + + if request.method == 'POST': + try: + config = request.get_json() + with open(config_file, 'w') as f: + json.dump(config, f, indent=2) + return jsonify({'status': 'success', 'message': 'Antenna configuration saved'}) + except Exception as e: + return jsonify({'error': str(e)}), 500 + + else: # GET + try: + if config_file.exists(): + with open(config_file, 'r') as f: + config = json.load(f) + return jsonify(config) + else: + # Return empty configuration + return jsonify({ + 'antennas': [], + 'band_assignments': {} + }) + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +@app.route('/api/debug/cache', methods=['GET']) +def debug_cache(): + """ + API endpoint to debug HTTP caching configuration + + Returns: + JSON with current cache configuration and sample headers + """ + # Create a sample response to show what headers would be sent + sample_response = make_response("sample") + sample_response = apply_cache_control(sample_response) + + return jsonify({ + 'cache_enabled': not (server_config.get('disable_cache', False) or app.debug), + 'debug_mode': app.debug, + 'disable_cache_flag': server_config.get('disable_cache', False), + 'sample_headers': dict(sample_response.headers), + 'explanation': { + '200': 'First request - Full content sent with ETag/Last-Modified headers', + '304': 'Subsequent requests - Browser sends If-None-Match/If-Modified-Since, server responds with 304 if unchanged', + 'cache_control': sample_response.headers.get('Cache-Control', 'default') + }, + 'tips': { + 'disable_cache': 'Start server with --no-cache flag to disable caching', + 'debug_mode': 'Start server with --debug flag to auto-disable caching', + 'browser_refresh': 'Use Ctrl+Shift+R (Cmd+Shift+R on Mac) for hard refresh' + } + }) + + +# ============================================================================= +# Static File Serving +# ============================================================================= + +@app.route('/') +def index(): + """Serve the main dashboard""" + response = make_response(send_from_directory(str(PACKAGE_DIR), 'dashboard.html')) + return apply_cache_control(response) + + +@app.route('/') +def serve_static(path): + """Serve static files. + + HTML/JS/CSS shipped with the package come from PACKAGE_DIR. + Generated JSON (predictions, dxcc summary, etc.) lives in the data dir. + Files in the data dir take precedence so users can override packaged + templates simply by creating a file there. + """ + data_path = get_data_dir() / path + if data_path.is_file(): + response = make_response(send_from_directory(str(get_data_dir()), path)) + else: + response = make_response(send_from_directory(str(PACKAGE_DIR), path)) + return apply_cache_control(response) + + +# ============================================================================= +# Main +# ============================================================================= + +def check_dependencies(): + """ + Check if required dependencies are installed + Returns tuple: (success: bool, missing: list) + """ + missing = [] + + # Check core dependencies + try: + import numpy + except ImportError: + missing.append('numpy') + + try: + import requests + except ImportError: + missing.append('requests') + + # Check if dvoacap module is importable + try: + from dvoacap.prediction_engine import PredictionEngine # noqa: F401 + except ImportError as e: + missing.append(f'dvoacap ({str(e)})') + + return len(missing) == 0, missing + + +def check_and_generate_predictions(skip_auto_gen=False): + """ + Check if prediction data exists and is recent. + Auto-generate if missing or stale (>24 hours old). + + Args: + skip_auto_gen: If True, skip automatic generation + + Returns: + bool: True if data is available, False otherwise + """ + data_file = get_data_dir() / 'enhanced_predictions.json' + + # Check if data file exists + if not data_file.exists(): + if skip_auto_gen: + print("⚠️ No prediction data found (use --skip-auto-gen=False to auto-generate)") + return False + + print("\n" + "=" * 80) + print("No prediction data found - generating initial predictions...") + print("=" * 80) + generate_predictions_now() + return True + + # Check if data is stale (>24 hours old) + file_age = datetime.now(timezone.utc).timestamp() - data_file.stat().st_mtime + hours_old = file_age / 3600 + + if hours_old > 24: + if skip_auto_gen: + print(f"⚠️ Prediction data is {hours_old:.1f} hours old (use --skip-auto-gen=False to auto-refresh)") + return True + + print("\n" + "=" * 80) + print(f"Prediction data is {hours_old:.1f} hours old - regenerating...") + print("=" * 80) + generate_predictions_now() + else: + print(f"✓ Prediction data is {hours_old:.1f} hours old (fresh)") + + return True + + +def generate_predictions_now(): + """ + Synchronously generate predictions during server startup + """ + try: + result = subprocess.run( + [sys.executable, '-m', 'dvoacap.dashboard.generate_predictions'], + cwd=str(get_data_dir()), + capture_output=True, + text=True, + timeout=300 # 5 minute timeout + ) + + if result.returncode == 0: + print("✓ Predictions generated successfully") + # Print summary from the output + if "Total predictions:" in result.stdout: + for line in result.stdout.split('\n'): + if 'Total predictions:' in line or 'Generated:' in line: + print(f" {line.strip()}") + else: + print(f"✗ Prediction generation failed: {result.stderr[:200]}") + + except subprocess.TimeoutExpired: + print("✗ Prediction generation timed out (>5 minutes)") + except Exception as e: + print(f"✗ Error generating predictions: {e}") + + +def main( + host: str = '127.0.0.1', + port: int = 8000, + debug: bool = False, + no_cache: bool = False, + skip_deps_check: bool = False, + skip_auto_gen: bool = False, +): + """Start the Flask server. + + All configuration is via keyword arguments. Direct invocation as a + script (``python -m dvoacap.dashboard.server``) is handled by the + ``__main__`` block below, which parses argv and calls this function. + """ + # Configure caching + if no_cache: + server_config['disable_cache'] = True + + # Check dependencies unless skipped + if not skip_deps_check: + success, missing = check_dependencies() + if not success: + print("=" * 80) + print("ERROR: Missing Dependencies") + print("=" * 80) + print("\nThe following required packages are not installed:") + for dep in missing: + print(f" ✗ {dep}") + print("\nTo fix this, run:") + print(" pip install -e .[dashboard]") + print("\nOr install individual packages:") + print(" pip install numpy requests flask flask-cors") + print("=" * 80) + sys.exit(1) + + print("=" * 80) + print("DVOACAP HF Propagation Dashboard Server") + print("=" * 80) + print(f"\n✓ Server starting on http://{host}:{port}") + print(f"✓ Dashboard: http://{host}:{port}/") + print(f"✓ Data directory: {get_data_dir()}") + print(f"✓ Debug mode: {'Enabled' if debug else 'Disabled'}") + print(f"✓ HTTP caching: {'Disabled' if server_config['disable_cache'] or debug else 'Enabled'}") + + # Check and auto-generate predictions if needed + print() + check_and_generate_predictions(skip_auto_gen=skip_auto_gen) + + print(f"\n✓ Press Ctrl+C to stop") + print("=" * 80) + + app.run(host=host, port=port, debug=debug) + + +def _parse_args_and_run(): + """Parse command-line arguments and run the server.""" + import argparse + + parser = argparse.ArgumentParser(description='DVOACAP Propagation Dashboard Server') + parser.add_argument('--host', default='127.0.0.1', help='Host to bind to (default: 127.0.0.1)') + parser.add_argument('--port', type=int, default=8000, help='Port to bind to (default: 8000)') + parser.add_argument('--debug', action='store_true', help='Enable debug mode') + parser.add_argument('--no-cache', action='store_true', help='Disable HTTP caching (for development)') + parser.add_argument('--skip-deps-check', action='store_true', help='Skip dependency check') + parser.add_argument('--skip-auto-gen', action='store_true', help='Skip automatic prediction generation on startup') + + args = parser.parse_args() + main( + host=args.host, + port=args.port, + debug=args.debug, + no_cache=args.no_cache, + skip_deps_check=args.skip_deps_check, + skip_auto_gen=args.skip_auto_gen, + ) + + +if __name__ == '__main__': + _parse_args_and_run() diff --git a/src/dvoacap/dashboard/transform_data.py b/src/dvoacap/dashboard/transform_data.py new file mode 100755 index 0000000..f9b260e --- /dev/null +++ b/src/dvoacap/dashboard/transform_data.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python3 +""" +Transform propagation_data.json to the format expected by dashboard.html + +This script converts the raw prediction data structure to the enhanced format +that includes current_conditions, timeline_24h, and DXCC tracking. +""" + +import json +import sys +from pathlib import Path +from datetime import datetime +from collections import defaultdict + +from .paths import get_data_dir + + +# Make stdout/stderr UTF-8 where the runtime supports it (Python 3.7+). +# This keeps the script portable across consoles whose default code page +# (e.g. cp1252 on Windows, or sandboxed Microsoft Store Python) cannot +# encode characters outside ASCII. +for _stream in (sys.stdout, sys.stderr): + try: + _stream.reconfigure(encoding="utf-8", errors="replace") + except (AttributeError, ValueError): + pass + + +def transform_predictions(input_file: Path, output_file: Path, dxcc_file: Path): + """ + Transform raw prediction data to dashboard-compatible format + """ + print(f"Loading prediction data from {input_file}...") + + with open(input_file, 'r') as f: + raw_data = json.load(f) + + # Load DXCC data if available + dxcc_data = {} + if dxcc_file.exists(): + print(f"Loading DXCC data from {dxcc_file}...") + with open(dxcc_file, 'r') as f: + dxcc_data = json.load(f) + + print("Transforming data structure...") + + # Build current conditions (using UTC hour 0 as baseline) + current_hour = datetime.now().hour + + # Group predictions by region and hour + predictions_by_hour = defaultdict(list) + for pred in raw_data['predictions']: + predictions_by_hour[pred['utc_hour']].append(pred) + + # Find the closest hour to current time + available_hours = sorted(predictions_by_hour.keys()) + closest_hour = min(available_hours, key=lambda h: abs(h - current_hour)) + current_preds = predictions_by_hour[closest_hour] + + # Build current conditions bands structure + current_bands = {} + for band_name in raw_data['bands']: + # Get frequency from first prediction + freq = 0 + if band_name == '160m': freq = 1.900 + elif band_name == '80m': freq = 3.600 + elif band_name == '40m': freq = 7.150 + elif band_name == '30m': freq = 10.125 + elif band_name == '20m': freq = 14.150 + elif band_name == '17m': freq = 18.118 + elif band_name == '15m': freq = 21.200 + elif band_name == '12m': freq = 24.940 + elif band_name == '10m': freq = 28.500 + + current_bands[band_name] = { + 'frequency': freq, + 'regions': {} + } + + # Populate regions for each band + for pred in current_preds: + region_code = pred['region'] + region_name = pred['region_name'] + + for band_name, band_data in pred['bands'].items(): + if band_data['status'] in ['GOOD', 'FAIR']: + quality = band_data['status'] + elif band_data['status'] == 'POOR': + quality = 'POOR' + else: + continue # Skip CLOSED bands + + current_bands[band_name]['regions'][region_code] = { + 'name': region_name, + 'quality': quality, + 'reliability': band_data['reliability'], + 'snr_db': band_data['snr'], + 'distance_km': int(pred['distance_km']), + 'bearing': int(pred['azimuth']), + 'muf': pred['muf'] + } + + # Build timeline structure (24-72 hour forecast) + timeline_hours = [] + + # Parse the generated timestamp to get the base date + generated_dt = datetime.fromisoformat(raw_data['generated'].replace('Z', '+00:00')) + base_date = generated_dt.date() + + for hour in sorted(predictions_by_hour.keys()): + hour_data = { + 'time': f"{base_date}T{hour:02d}:00:00Z", + 'hour_utc': hour, + 'bands': {} + } + + # For each band, find which regions are open + for band_name in raw_data['bands']: + open_regions = [] + marginal_regions = [] + + for pred in predictions_by_hour[hour]: + band_pred = pred['bands'][band_name] + if band_pred['status'] == 'GOOD': + open_regions.append(pred['region']) + elif band_pred['status'] == 'FAIR': + marginal_regions.append(pred['region']) + + hour_data['bands'][band_name] = { + 'open': open_regions, + 'marginal': marginal_regions + } + + timeline_hours.append(hour_data) + + # Build propagation charts data (hourly metrics for each band and region) + prop_charts = {} + for band_name in raw_data['bands']: + prop_charts[band_name] = { + 'hours': [], + 'regions': {} + } + + # For each hour, collect metrics for this band + for hour in sorted(predictions_by_hour.keys()): + prop_charts[band_name]['hours'].append(hour) + + # Collect metrics for each region + for pred in predictions_by_hour[hour]: + region_code = pred['region'] + region_name = pred['region_name'] + + if region_code not in prop_charts[band_name]['regions']: + prop_charts[band_name]['regions'][region_code] = { + 'name': region_name, + 'reliability': [], + 'snr': [], + 'signal_dbw': [], + 'signal_10': [], + 'signal_90': [], + 'muf_day': [] + } + + band_data = pred['bands'][band_name] + prop_charts[band_name]['regions'][region_code]['reliability'].append(band_data['reliability']) + prop_charts[band_name]['regions'][region_code]['snr'].append(band_data['snr']) + prop_charts[band_name]['regions'][region_code]['signal_dbw'].append(band_data['signal_dbw']) + prop_charts[band_name]['regions'][region_code]['signal_10'].append(band_data['signal_10']) + prop_charts[band_name]['regions'][region_code]['signal_90'].append(band_data['signal_90']) + prop_charts[band_name]['regions'][region_code]['muf_day'].append(band_data['muf_day']) + + # Build the transformed structure + transformed = { + 'predictions': { + 'current_conditions': { + 'generated': raw_data['generated'], + 'solar': { + 'sfi': raw_data['solar_conditions']['sfi'], + 'ssn': raw_data['solar_conditions']['ssn'], + 'kp': raw_data['solar_conditions']['kp'], + 'a_index': raw_data['solar_conditions']['a_index'] + }, + 'bands': current_bands + }, + 'timeline_24h': { + 'hours': timeline_hours + }, + 'propagation_charts': prop_charts, + 'dxcc': dxcc_data if dxcc_data else { + 'dxcc_worked': [], + 'dxcc_confirmed_lotw': [], + 'dxcc_missing': [], + 'entity_names': {} + } + } + } + + # Write output + print(f"Writing transformed data to {output_file}...") + with open(output_file, 'w') as f: + json.dump(transformed, f, indent=2) + + print("[OK] Transformation complete!") + print(f" - Bands: {len(current_bands)}") + print(f" - Timeline hours: {len(timeline_hours)}") + print(f" - Generated: {raw_data['generated']}") + + +def main(): + """Main entry point""" + base_dir = get_data_dir() + + input_file = base_dir / 'propagation_data.json' + output_file = base_dir / 'enhanced_predictions.json' + dxcc_file = base_dir / 'dxcc_summary.json' + + if not input_file.exists(): + print(f"Error: {input_file} not found!", file=sys.stderr) + print("Run generate_predictions.py first to create the input data.", + file=sys.stderr) + return 1 + + try: + transform_predictions(input_file, output_file, dxcc_file) + except KeyError as e: + print( + f"Error: required field {e} is missing from {input_file.name}.", + file=sys.stderr, + ) + print( + "This usually means generate_predictions.py produced an " + "incomplete or older-format file. Try regenerating predictions.", + file=sys.stderr, + ) + return 2 + except json.JSONDecodeError as e: + print( + f"Error: {input_file.name} is not valid JSON ({e}).", + file=sys.stderr, + ) + return 3 + except OSError as e: + print(f"Error: file I/O failed ({e}).", file=sys.stderr) + return 4 + except ValueError as e: + print( + f"Error: {input_file.name} contains no usable predictions ({e}).", + file=sys.stderr, + ) + print( + "Re-run generate_predictions.py to produce a populated file.", + file=sys.stderr, + ) + return 5 + + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/src/dvoacap/dashboard/update_predictions.sh b/src/dvoacap/dashboard/update_predictions.sh new file mode 100644 index 0000000..0c829c6 --- /dev/null +++ b/src/dvoacap/dashboard/update_predictions.sh @@ -0,0 +1,26 @@ +#!/bin/bash +# +# DVOACAP Propagation Update Script +# Run this to generate fresh predictions with latest solar data. +# +# Use DVOACAP_DATA_DIR to control where output files are written +# (defaults to the current working directory). +# + +echo "=========================================" +echo " DVOACAP HF Propagation Dashboard" +echo " Updating with latest solar data..." +echo "=========================================" +echo "" + +# Run the prediction generator via the installed package. +python3 -m dvoacap.dashboard.generate_predictions + +echo "" +echo "✓ Predictions updated!" +echo "" +echo "View your dashboard:" +echo " • Run: dvoacap-dashboard" +echo " • Then visit: http://localhost:8000/" +echo "" +echo "========================================="