374 lines
16 KiB
Python
374 lines
16 KiB
Python
|
|
"""
|
||
|
|
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
|
||
|
|
await asyncio.sleep(1)
|
||
|
|
|
||
|
|
# 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
|
||
|
|
import httpx
|
||
|
|
async with httpx.AsyncClient() as client:
|
||
|
|
# Get all setpoints
|
||
|
|
response = await client.get("http://127.0.0.1:8000/api/v1/setpoints")
|
||
|
|
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")
|
||
|
|
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"
|