""" Integration tests for optimization-to-SCADA workflow. Tests the complete data flow: 1. Optimization plan in database 2. Setpoint calculation by setpoint manager 3. Protocol server exposure (OPC UA, Modbus, REST API) 4. SCADA client access to setpoints """ import pytest import pytest_asyncio import asyncio import time from typing import Dict, Any, Optional from unittest.mock import Mock, patch from src.database.flexible_client import FlexibleDatabaseClient from src.core.auto_discovery import AutoDiscovery from src.core.setpoint_manager import SetpointManager from src.core.safety import SafetyLimitEnforcer from src.core.emergency_stop import EmergencyStopManager from src.monitoring.watchdog import DatabaseWatchdog from src.protocols.opcua_server import OPCUAServer from src.protocols.modbus_server import ModbusServer from src.protocols.rest_api import RESTAPIServer from src.core.security import SecurityManager from src.core.compliance_audit import ComplianceAuditLogger class TestOptimizationToSCADAIntegration: """Test complete workflow from optimization to SCADA.""" @pytest_asyncio.fixture async def db_client(self): """Create database client with comprehensive test data.""" client = FlexibleDatabaseClient( database_url="sqlite:///:memory:", pool_size=5, max_overflow=10, pool_timeout=30 ) await client.connect() client.create_tables() # Insert comprehensive test data self._insert_comprehensive_test_data(client) return client def _insert_comprehensive_test_data(self, db_client): """Insert realistic test data for multiple scenarios.""" # Insert multiple pump stations db_client.execute( """INSERT INTO pump_stations (station_id, station_name, location) VALUES ('STATION_001', 'Main Station', 'Location A'), ('STATION_002', 'Backup Station', 'Location B'), ('STATION_003', 'Emergency Station', 'Location C')""" ) # Insert multiple pumps with different configurations db_client.execute( """INSERT INTO pumps (station_id, pump_id, pump_name, control_type, min_speed_hz, max_speed_hz, default_setpoint_hz) VALUES ('STATION_001', 'PUMP_001', 'Main Pump 1', 'DIRECT_SPEED', 20.0, 60.0, 35.0), ('STATION_001', 'PUMP_002', 'Main Pump 2', 'LEVEL_CONTROLLED', 25.0, 55.0, 40.0), ('STATION_002', 'PUMP_003', 'Backup Pump 1', 'POWER_CONTROLLED', 30.0, 50.0, 38.0), ('STATION_003', 'PUMP_004', 'Emergency Pump', 'DIRECT_SPEED', 15.0, 70.0, 45.0)""" ) # Insert optimization plans for different scenarios import datetime now = datetime.datetime.now() # Insert plans one by one to handle datetime values db_client.execute( f"""INSERT INTO pump_plans ( station_id, pump_id, target_flow_m3h, target_power_kw, target_level_m, suggested_speed_hz, interval_start, interval_end, plan_version, plan_status, optimization_run_id ) VALUES ( 'STATION_001', 'PUMP_001', 150.0, NULL, NULL, 42.5, '{now - datetime.timedelta(hours=1)}', '{now + datetime.timedelta(hours=1)}', 1, 'ACTIVE', 'OPT_RUN_001' )""" ) db_client.execute( f"""INSERT INTO pump_plans ( station_id, pump_id, target_flow_m3h, target_power_kw, target_level_m, suggested_speed_hz, interval_start, interval_end, plan_version, plan_status, optimization_run_id ) VALUES ( 'STATION_001', 'PUMP_002', NULL, NULL, 2.5, 38.0, '{now - datetime.timedelta(minutes=30)}', '{now + datetime.timedelta(hours=2)}', 1, 'ACTIVE', 'OPT_RUN_002' )""" ) db_client.execute( f"""INSERT INTO pump_plans ( station_id, pump_id, target_flow_m3h, target_power_kw, target_level_m, suggested_speed_hz, interval_start, interval_end, plan_version, plan_status, optimization_run_id ) VALUES ( 'STATION_002', 'PUMP_003', NULL, 12.5, NULL, 36.0, '{now - datetime.timedelta(hours=2)}', '{now + datetime.timedelta(hours=3)}', 1, 'ACTIVE', 'OPT_RUN_003' )""" ) db_client.execute( f"""INSERT INTO pump_plans ( station_id, pump_id, target_flow_m3h, target_power_kw, target_level_m, suggested_speed_hz, interval_start, interval_end, plan_version, plan_status, optimization_run_id ) VALUES ( 'STATION_003', 'PUMP_004', 200.0, NULL, NULL, 48.0, '{now - datetime.timedelta(hours=1)}', '{now + datetime.timedelta(hours=4)}', 1, 'ACTIVE', 'OPT_RUN_004' )""" ) # Insert safety limits for all pumps db_client.execute( """INSERT INTO pump_safety_limits (station_id, pump_id, hard_min_speed_hz, hard_max_speed_hz, hard_min_level_m, hard_max_level_m, emergency_stop_level_m, dry_run_protection_level_m, hard_max_power_kw, hard_max_flow_m3h, max_starts_per_hour, min_run_time_seconds, max_continuous_run_hours, max_speed_change_hz_per_min, set_by, approved_by) VALUES ('STATION_001', 'PUMP_001', 20.0, 60.0, 1.0, 5.0, 0.5, 1.5, 15.0, 200.0, 6, 300, 24, 10.0, 'admin', 'admin'), ('STATION_001', 'PUMP_002', 25.0, 55.0, 1.5, 4.5, 1.0, 2.0, 12.0, 180.0, 6, 300, 24, 10.0, 'admin', 'admin'), ('STATION_002', 'PUMP_003', 30.0, 50.0, 2.0, 4.0, 1.5, 2.5, 10.0, 160.0, 6, 300, 24, 10.0, 'admin', 'admin'), ('STATION_003', 'PUMP_004', 15.0, 70.0, 0.5, 6.0, 0.2, 1.0, 20.0, 250.0, 6, 300, 24, 10.0, 'admin', 'admin')""" ) @pytest_asyncio.fixture async def system_components(self, db_client): """Create all system components for integration testing.""" # Create auto discovery auto_discovery = AutoDiscovery(db_client) await auto_discovery.discover() # Create safety components safety_enforcer = SafetyLimitEnforcer(db_client) emergency_stop_manager = EmergencyStopManager(db_client) # Load safety limits await safety_enforcer.load_safety_limits() # Create mock alert manager for watchdog mock_alert_manager = Mock() watchdog = DatabaseWatchdog(db_client, mock_alert_manager) # Create setpoint manager setpoint_manager = SetpointManager( discovery=auto_discovery, db_client=db_client, safety_enforcer=safety_enforcer, emergency_stop_manager=emergency_stop_manager, watchdog=watchdog ) # Create security components security_manager = SecurityManager() audit_logger = ComplianceAuditLogger(db_client) return { 'db_client': db_client, 'auto_discovery': auto_discovery, 'setpoint_manager': setpoint_manager, 'safety_enforcer': safety_enforcer, 'emergency_stop_manager': emergency_stop_manager, 'watchdog': watchdog, 'security_manager': security_manager, 'audit_logger': audit_logger } @pytest.mark.asyncio async def test_optimization_plan_to_setpoint_calculation(self, system_components): """Test that optimization plans are correctly converted to setpoints.""" setpoint_manager = system_components['setpoint_manager'] # Test setpoint calculation for different pump types test_cases = [ ('STATION_001', 'PUMP_001', 42.5), # DIRECT_SPEED ('STATION_001', 'PUMP_002', 38.0), # LEVEL_CONTROL ('STATION_002', 'PUMP_003', 36.0), # POWER_OPTIMIZATION ('STATION_003', 'PUMP_004', 48.0), # DIRECT_SPEED (emergency) ] for station_id, pump_id, expected_setpoint in test_cases: setpoint = setpoint_manager.get_current_setpoint(station_id, pump_id) assert setpoint == expected_setpoint, f"Setpoint mismatch for {station_id}/{pump_id}" @pytest.mark.asyncio async def test_opcua_server_setpoint_exposure(self, system_components): """Test that OPC UA server correctly exposes setpoints.""" setpoint_manager = system_components['setpoint_manager'] security_manager = system_components['security_manager'] audit_logger = system_components['audit_logger'] # Create OPC UA server opcua_server = OPCUAServer( setpoint_manager=setpoint_manager, security_manager=security_manager, audit_logger=audit_logger, enable_security=False, # Disable security for testing endpoint="opc.tcp://127.0.0.1:4840" ) try: # Start server await opcua_server.start() # Wait for server to initialize await asyncio.sleep(1) # Test that setpoints are available stations = setpoint_manager.discovery.get_stations() for station_id in stations: pumps = setpoint_manager.discovery.get_pumps(station_id) for pump in pumps: pump_id = pump['pump_id'] # Verify setpoint is calculated setpoint = setpoint_manager.get_current_setpoint(station_id, pump_id) assert setpoint is not None, f"No setpoint for {station_id}/{pump_id}" # Verify setpoint is within safety limits pump_info = setpoint_manager.discovery.get_pump(station_id, pump_id) min_speed = pump_info.get('min_speed_hz', 20.0) max_speed = pump_info.get('max_speed_hz', 60.0) assert min_speed <= setpoint <= max_speed, f"Setpoint out of range for {station_id}/{pump_id}" finally: # Clean up await opcua_server.stop() @pytest.mark.asyncio async def test_modbus_server_setpoint_exposure(self, system_components): """Test that Modbus server correctly exposes setpoints.""" setpoint_manager = system_components['setpoint_manager'] security_manager = system_components['security_manager'] audit_logger = system_components['audit_logger'] # Create Modbus server modbus_server = ModbusServer( setpoint_manager=setpoint_manager, security_manager=security_manager, audit_logger=audit_logger, enable_security=False, # Disable security for testing allowed_ips=["127.0.0.1"], rate_limit_per_minute=100, host="127.0.0.1", port=5020 ) try: # Start server await modbus_server.start() # Wait for server to initialize await asyncio.sleep(1) # Test that pump mapping is created assert len(modbus_server.pump_addresses) > 0, "No pumps mapped to Modbus addresses" # Verify each pump has a setpoint register for (station_id, pump_id), addresses in modbus_server.pump_addresses.items(): assert 'setpoint_register' in addresses, f"No setpoint register for {station_id}/{pump_id}" # Verify setpoint is calculated setpoint = setpoint_manager.get_current_setpoint(station_id, pump_id) assert setpoint is not None, f"No setpoint for {station_id}/{pump_id}" finally: # Clean up await modbus_server.stop() @pytest.mark.asyncio async def test_rest_api_setpoint_exposure(self, system_components): """Test that REST API correctly exposes setpoints.""" setpoint_manager = system_components['setpoint_manager'] emergency_stop_manager = system_components['emergency_stop_manager'] # Create REST API server rest_api = RESTAPIServer( setpoint_manager=setpoint_manager, emergency_stop_manager=emergency_stop_manager, host="127.0.0.1", port=8000 ) try: # Start server await rest_api.start() # Wait for server to initialize - use retry mechanism import httpx max_retries = 10 for attempt in range(max_retries): try: async with httpx.AsyncClient() as client: response = await client.get("http://127.0.0.1:8000/api/v1/setpoints", timeout=1.0) if response.status_code == 200: break except (httpx.ConnectError, httpx.ReadTimeout): if attempt < max_retries - 1: await asyncio.sleep(0.5) else: raise # Test that setpoint endpoints are available routes = [route.path for route in rest_api.app.routes] assert "/api/v1/setpoints" in routes assert "/api/v1/setpoints/{station_id}/{pump_id}" in routes # Test setpoint retrieval via REST API async with httpx.AsyncClient() as client: # First authenticate to get token login_response = await client.post( "http://127.0.0.1:8000/api/v1/auth/login", json={"username": "admin", "password": "admin123"} ) assert login_response.status_code == 200 login_data = login_response.json() token = login_data["access_token"] # Set up headers with authentication headers = {"Authorization": f"Bearer {token}"} # Get all setpoints response = await client.get("http://127.0.0.1:8000/api/v1/setpoints", headers=headers) assert response.status_code == 200 setpoints = response.json() assert len(setpoints) > 0 # Get specific setpoint response = await client.get("http://127.0.0.1:8000/api/v1/setpoints/STATION_001/PUMP_001", headers=headers) assert response.status_code == 200 setpoint_data = response.json() assert 'setpoint_hz' in setpoint_data assert setpoint_data['setpoint_hz'] == 42.5 finally: # Clean up await rest_api.stop() @pytest.mark.asyncio async def test_safety_limit_enforcement_integration(self, system_components): """Test that safety limits are enforced throughout the workflow.""" setpoint_manager = system_components['setpoint_manager'] safety_enforcer = system_components['safety_enforcer'] # Test safety limit enforcement for each pump stations = setpoint_manager.discovery.get_stations() for station_id in stations: pumps = setpoint_manager.discovery.get_pumps(station_id) for pump in pumps: pump_id = pump['pump_id'] # Get calculated setpoint setpoint = setpoint_manager.get_current_setpoint(station_id, pump_id) # Verify safety enforcement enforced_setpoint, violations = safety_enforcer.enforce_setpoint( station_id, pump_id, setpoint ) # Setpoint should be within limits after enforcement pump_info = setpoint_manager.discovery.get_pump(station_id, pump_id) min_speed = pump_info.get('min_speed_hz', 20.0) max_speed = pump_info.get('max_speed_hz', 60.0) assert min_speed <= enforced_setpoint <= max_speed, \ f"Safety enforcement failed for {station_id}/{pump_id}" @pytest.mark.asyncio async def test_emergency_stop_integration(self, system_components): """Test emergency stop integration with setpoint calculation.""" setpoint_manager = system_components['setpoint_manager'] emergency_stop_manager = system_components['emergency_stop_manager'] # Test station/pump station_id = 'STATION_001' pump_id = 'PUMP_001' # Get normal setpoint normal_setpoint = setpoint_manager.get_current_setpoint(station_id, pump_id) assert normal_setpoint is not None # Activate emergency stop emergency_stop_manager.emergency_stop_pump(station_id, pump_id, "integration_test") # Setpoint should be 0 during emergency stop emergency_setpoint = setpoint_manager.get_current_setpoint(station_id, pump_id) assert emergency_setpoint == 0.0, "Setpoint not zero during emergency stop" # Clear emergency stop emergency_stop_manager.clear_emergency_stop_pump(station_id, pump_id, "integration_test") # Setpoint should return to normal recovered_setpoint = setpoint_manager.get_current_setpoint(station_id, pump_id) assert recovered_setpoint == normal_setpoint, "Setpoint did not recover after emergency stop"