Compare commits
No commits in common. "fe72175a0476ea6d8f366f006e9d7ca453a584da" and "097574733ec35609d25e776a133e71946e1628cf" have entirely different histories.
fe72175a04
...
097574733e
|
|
@ -77,9 +77,7 @@ calejo-control-adapter/
|
||||||
│ ├── settings.py # Application settings
|
│ ├── settings.py # Application settings
|
||||||
│ └── docker-compose.yml # Docker configuration
|
│ └── docker-compose.yml # Docker configuration
|
||||||
├── docs/
|
├── docs/
|
||||||
│ ├── specification.txt # Full implementation specification
|
│ └── specification.txt # Full implementation specification
|
||||||
│ ├── optimization_plan_management.md # Optimization system documentation
|
|
||||||
│ └── alert_system_setup.md # Alert system configuration guide
|
|
||||||
├── requirements.txt # Python dependencies
|
├── requirements.txt # Python dependencies
|
||||||
├── Dockerfile # Docker container definition
|
├── Dockerfile # Docker container definition
|
||||||
└── README.md # This file
|
└── README.md # This file
|
||||||
|
|
@ -137,11 +135,6 @@ Key configuration options:
|
||||||
- `REST_API_PORT`: REST API port (default: 8080)
|
- `REST_API_PORT`: REST API port (default: 8080)
|
||||||
- `SAFETY_TIMEOUT_SECONDS`: Database watchdog timeout (default: 1200)
|
- `SAFETY_TIMEOUT_SECONDS`: Database watchdog timeout (default: 1200)
|
||||||
|
|
||||||
### Alert System Configuration
|
|
||||||
|
|
||||||
For detailed alert system setup (email, SMS, webhook integration), see:
|
|
||||||
[Alert System Setup Guide](docs/alert_system_setup.md)
|
|
||||||
|
|
||||||
## Safety Framework
|
## Safety Framework
|
||||||
|
|
||||||
The adapter implements a comprehensive three-layer safety architecture:
|
The adapter implements a comprehensive three-layer safety architecture:
|
||||||
|
|
|
||||||
|
|
@ -14,21 +14,13 @@ JWT_SECRET_KEY=your-secret-key-change-in-production
|
||||||
JWT_ALGORITHM=HS256
|
JWT_ALGORITHM=HS256
|
||||||
|
|
||||||
# Alert Settings (Optional)
|
# Alert Settings (Optional)
|
||||||
# Email Configuration
|
|
||||||
SMTP_SERVER=smtp.example.com
|
SMTP_SERVER=smtp.example.com
|
||||||
SMTP_PORT=587
|
SMTP_PORT=587
|
||||||
SMTP_USERNAME=your-email@example.com
|
SMTP_USERNAME=your-email@example.com
|
||||||
SMTP_PASSWORD=your-email-password
|
SMTP_PASSWORD=your-email-password
|
||||||
SMTP_USE_TLS=true
|
|
||||||
|
|
||||||
# SMS Configuration (Twilio)
|
|
||||||
TWILIO_ACCOUNT_SID=your-twilio-account-sid
|
TWILIO_ACCOUNT_SID=your-twilio-account-sid
|
||||||
TWILIO_AUTH_TOKEN=your-twilio-auth-token
|
TWILIO_AUTH_TOKEN=your-twilio-auth-token
|
||||||
TWILIO_PHONE_NUMBER=+1234567890
|
TWILIO_PHONE_NUMBER=+1234567890
|
||||||
|
|
||||||
# Webhook Configuration
|
|
||||||
ALERT_WEBHOOK_URL=https://your-monitoring-system.com/webhook
|
|
||||||
ALERT_WEBHOOK_TOKEN=your_bearer_token
|
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
|
|
@ -90,14 +90,6 @@ class Settings(BaseSettings):
|
||||||
app_version: str = "2.0.0"
|
app_version: str = "2.0.0"
|
||||||
environment: str = "development"
|
environment: str = "development"
|
||||||
|
|
||||||
# Auto-discovery settings
|
|
||||||
auto_discovery_enabled: bool = True
|
|
||||||
auto_discovery_refresh_minutes: int = 60
|
|
||||||
|
|
||||||
# Optimization plan management settings
|
|
||||||
optimization_monitoring_enabled: bool = True
|
|
||||||
optimization_refresh_seconds: int = 30
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def database_url(self) -> str:
|
def database_url(self) -> str:
|
||||||
"""Generate database URL from components."""
|
"""Generate database URL from components."""
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@ COMMENT ON TABLE pumps IS 'Metadata about individual pumps';
|
||||||
COMMENT ON COLUMN pumps.default_setpoint_hz IS 'Default safe setpoint used in failsafe mode (existing pump configuration)';
|
COMMENT ON COLUMN pumps.default_setpoint_hz IS 'Default safe setpoint used in failsafe mode (existing pump configuration)';
|
||||||
COMMENT ON COLUMN pumps.control_parameters IS 'Control-specific parameters in JSON format (PID gains, thresholds, etc.)';
|
COMMENT ON COLUMN pumps.control_parameters IS 'Control-specific parameters in JSON format (PID gains, thresholds, etc.)';
|
||||||
|
|
||||||
-- Create pump_plans table with version-based updates
|
-- Create pump_plans table
|
||||||
CREATE TABLE pump_plans (
|
CREATE TABLE pump_plans (
|
||||||
plan_id SERIAL PRIMARY KEY,
|
plan_id SERIAL PRIMARY KEY,
|
||||||
station_id VARCHAR(50) NOT NULL,
|
station_id VARCHAR(50) NOT NULL,
|
||||||
|
|
@ -74,73 +74,17 @@ CREATE TABLE pump_plans (
|
||||||
target_level_m DECIMAL(5, 2),
|
target_level_m DECIMAL(5, 2),
|
||||||
suggested_speed_hz DECIMAL(5, 2),
|
suggested_speed_hz DECIMAL(5, 2),
|
||||||
|
|
||||||
-- Version-based update metadata
|
-- Metadata
|
||||||
plan_created_at TIMESTAMP DEFAULT NOW(),
|
plan_created_at TIMESTAMP DEFAULT NOW(),
|
||||||
plan_updated_at TIMESTAMP DEFAULT NOW(),
|
|
||||||
plan_version INTEGER DEFAULT 1,
|
|
||||||
optimization_run_id INTEGER,
|
optimization_run_id INTEGER,
|
||||||
|
|
||||||
-- Status tracking
|
FOREIGN KEY (station_id, pump_id) REFERENCES pumps(station_id, pump_id)
|
||||||
plan_status VARCHAR(20) DEFAULT 'ACTIVE', -- 'ACTIVE', 'SUPERSEDED', 'CANCELLED'
|
|
||||||
superseded_by INTEGER, -- Points to plan_id that superseded this plan
|
|
||||||
|
|
||||||
FOREIGN KEY (station_id, pump_id) REFERENCES pumps(station_id, pump_id),
|
|
||||||
FOREIGN KEY (superseded_by) REFERENCES pump_plans(plan_id)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
COMMENT ON TABLE pump_plans IS 'Optimization plans generated by Calejo Optimize with version-based updates';
|
COMMENT ON TABLE pump_plans IS 'Optimization plans generated by Calejo Optimize';
|
||||||
COMMENT ON COLUMN pump_plans.plan_version IS 'Version number for tracking updates (increments on each update)';
|
|
||||||
COMMENT ON COLUMN pump_plans.plan_status IS 'Status of the plan: ACTIVE, SUPERSEDED, or CANCELLED';
|
|
||||||
COMMENT ON COLUMN pump_plans.superseded_by IS 'Reference to plan that superseded this one (for audit trail)';
|
|
||||||
|
|
||||||
-- Create indexes for efficient plan management
|
-- Create index for active pump plans
|
||||||
CREATE INDEX idx_pump_plans_active ON pump_plans(station_id, pump_id, interval_start, interval_end)
|
CREATE INDEX idx_pump_plans_active ON pump_plans(station_id, pump_id, interval_start, interval_end);
|
||||||
WHERE plan_status = 'ACTIVE';
|
|
||||||
CREATE INDEX idx_pump_plans_latest ON pump_plans(station_id, pump_id, plan_created_at DESC)
|
|
||||||
WHERE plan_status = 'ACTIVE';
|
|
||||||
CREATE INDEX idx_pump_plans_version ON pump_plans(station_id, pump_id, plan_version DESC);
|
|
||||||
|
|
||||||
-- Create generic optimization_plans table for future actuator support
|
|
||||||
CREATE TABLE optimization_plans (
|
|
||||||
plan_id SERIAL PRIMARY KEY,
|
|
||||||
station_id VARCHAR(50) NOT NULL,
|
|
||||||
resource_id VARCHAR(50) NOT NULL,
|
|
||||||
resource_type VARCHAR(20) NOT NULL DEFAULT 'PUMP', -- 'PUMP', 'VALVE', 'BLOWER', etc.
|
|
||||||
|
|
||||||
interval_start TIMESTAMP NOT NULL,
|
|
||||||
interval_end TIMESTAMP NOT NULL,
|
|
||||||
|
|
||||||
-- Generic optimization targets (JSON for flexibility)
|
|
||||||
optimization_targets JSONB NOT NULL,
|
|
||||||
|
|
||||||
-- Version-based update metadata
|
|
||||||
plan_created_at TIMESTAMP DEFAULT NOW(),
|
|
||||||
plan_updated_at TIMESTAMP DEFAULT NOW(),
|
|
||||||
plan_version INTEGER DEFAULT 1,
|
|
||||||
optimization_run_id INTEGER,
|
|
||||||
plan_priority INTEGER DEFAULT 1,
|
|
||||||
|
|
||||||
-- Status tracking
|
|
||||||
plan_status VARCHAR(20) DEFAULT 'ACTIVE',
|
|
||||||
superseded_by INTEGER,
|
|
||||||
|
|
||||||
FOREIGN KEY (station_id, resource_id) REFERENCES pumps(station_id, pump_id),
|
|
||||||
FOREIGN KEY (superseded_by) REFERENCES optimization_plans(plan_id),
|
|
||||||
|
|
||||||
-- Ensure resource_type is valid
|
|
||||||
CONSTRAINT valid_resource_type CHECK (resource_type IN ('PUMP', 'VALVE', 'BLOWER', 'COMPRESSOR', 'GATE'))
|
|
||||||
);
|
|
||||||
|
|
||||||
COMMENT ON TABLE optimization_plans IS 'Generic optimization plans for all actuator types with version-based updates';
|
|
||||||
COMMENT ON COLUMN optimization_plans.optimization_targets IS 'JSON structure containing actuator-specific optimization targets';
|
|
||||||
COMMENT ON COLUMN optimization_plans.resource_type IS 'Type of actuator: PUMP, VALVE, BLOWER, COMPRESSOR, GATE';
|
|
||||||
|
|
||||||
-- Create indexes for generic optimization plans
|
|
||||||
CREATE INDEX idx_optimization_plans_active ON optimization_plans(station_id, resource_id, interval_start, interval_end)
|
|
||||||
WHERE plan_status = 'ACTIVE';
|
|
||||||
CREATE INDEX idx_optimization_plans_latest ON optimization_plans(station_id, resource_id, plan_created_at DESC)
|
|
||||||
WHERE plan_status = 'ACTIVE';
|
|
||||||
CREATE INDEX idx_optimization_plans_type ON optimization_plans(resource_type, plan_status);
|
|
||||||
|
|
||||||
-- Create pump_feedback table
|
-- Create pump_feedback table
|
||||||
CREATE TABLE pump_feedback (
|
CREATE TABLE pump_feedback (
|
||||||
|
|
|
||||||
|
|
@ -69,44 +69,24 @@ INSERT INTO pump_safety_limits (
|
||||||
('STATION_003', 'PUMP_002', 20.0, 50.0, 0.8, 3.8, 4.2, 0.6, 60.0, 300.0, 4.0,
|
('STATION_003', 'PUMP_002', 20.0, 50.0, 0.8, 3.8, 4.2, 0.6, 60.0, 300.0, 4.0,
|
||||||
'system_admin', 'safety_engineer', NOW());
|
'system_admin', 'safety_engineer', NOW());
|
||||||
|
|
||||||
-- Insert sample pump plans with version-based updates
|
-- Insert sample pump plans (current and future intervals)
|
||||||
INSERT INTO pump_plans (
|
INSERT INTO pump_plans (
|
||||||
station_id, pump_id, interval_start, interval_end,
|
station_id, pump_id, interval_start, interval_end,
|
||||||
target_flow_m3h, target_power_kw, target_level_m, suggested_speed_hz,
|
target_flow_m3h, target_power_kw, target_level_m, suggested_speed_hz,
|
||||||
optimization_run_id, plan_version, plan_status
|
optimization_run_id
|
||||||
) VALUES
|
) VALUES
|
||||||
-- Initial plans for all pumps (version 1)
|
-- Current plans for all pumps
|
||||||
('STATION_001', 'PUMP_001', NOW() - INTERVAL '5 minutes', NOW() + INTERVAL '55 minutes', 320.5, 65.2, 2.5, 42.3, 1001, 1, 'ACTIVE'),
|
('STATION_001', 'PUMP_001', NOW() - INTERVAL '5 minutes', NOW() + INTERVAL '55 minutes', 320.5, 65.2, 2.5, 42.3, 1001),
|
||||||
('STATION_001', 'PUMP_002', NOW() - INTERVAL '5 minutes', NOW() + INTERVAL '55 minutes', 315.8, 63.8, 2.5, 41.7, 1001, 1, 'ACTIVE'),
|
('STATION_001', 'PUMP_002', NOW() - INTERVAL '5 minutes', NOW() + INTERVAL '55 minutes', 315.8, 63.8, 2.5, 41.7, 1001),
|
||||||
('STATION_002', 'PUMP_001', NOW() - INTERVAL '5 minutes', NOW() + INTERVAL '55 minutes', 180.2, 32.5, 1.8, 35.2, 1001, 1, 'ACTIVE'),
|
('STATION_002', 'PUMP_001', NOW() - INTERVAL '5 minutes', NOW() + INTERVAL '55 minutes', 180.2, 32.5, 1.8, 35.2, 1001),
|
||||||
('STATION_002', 'PUMP_002', NOW() - INTERVAL '5 minutes', NOW() + INTERVAL '55 minutes', 175.8, 31.8, 1.8, 34.8, 1001, 1, 'ACTIVE'),
|
('STATION_002', 'PUMP_002', NOW() - INTERVAL '5 minutes', NOW() + INTERVAL '55 minutes', 175.8, 31.8, 1.8, 34.8, 1001),
|
||||||
('STATION_003', 'PUMP_001', NOW() - INTERVAL '5 minutes', NOW() + INTERVAL '55 minutes', 245.6, 48.3, 2.2, 38.5, 1001, 1, 'ACTIVE'),
|
('STATION_003', 'PUMP_001', NOW() - INTERVAL '5 minutes', NOW() + INTERVAL '55 minutes', 245.6, 48.3, 2.2, 38.5, 1001),
|
||||||
('STATION_003', 'PUMP_002', NOW() - INTERVAL '5 minutes', NOW() + INTERVAL '55 minutes', 242.1, 47.6, 2.2, 38.1, 1001, 1, 'ACTIVE'),
|
('STATION_003', 'PUMP_002', NOW() - INTERVAL '5 minutes', NOW() + INTERVAL '55 minutes', 242.1, 47.6, 2.2, 38.1, 1001),
|
||||||
|
|
||||||
-- Future plans
|
-- Future plans
|
||||||
('STATION_001', 'PUMP_001', NOW() + INTERVAL '1 hour', NOW() + INTERVAL '2 hours', 335.2, 68.1, 2.6, 43.5, 1001, 1, 'ACTIVE'),
|
('STATION_001', 'PUMP_001', NOW() + INTERVAL '1 hour', NOW() + INTERVAL '2 hours', 335.2, 68.1, 2.6, 43.5, 1001),
|
||||||
('STATION_002', 'PUMP_001', NOW() + INTERVAL '1 hour', NOW() + INTERVAL '2 hours', 185.6, 33.8, 1.9, 36.2, 1001, 1, 'ACTIVE'),
|
('STATION_002', 'PUMP_001', NOW() + INTERVAL '1 hour', NOW() + INTERVAL '2 hours', 185.6, 33.8, 1.9, 36.2, 1001),
|
||||||
('STATION_003', 'PUMP_001', NOW() + INTERVAL '1 hour', NOW() + INTERVAL '2 hours', 252.3, 49.8, 2.3, 39.2, 1001, 1, 'ACTIVE');
|
('STATION_003', 'PUMP_001', NOW() + INTERVAL '1 hour', NOW() + INTERVAL '2 hours', 252.3, 49.8, 2.3, 39.2, 1001);
|
||||||
|
|
||||||
-- Insert sample optimization_plans for demonstration
|
|
||||||
INSERT INTO optimization_plans (
|
|
||||||
station_id, resource_id, resource_type, interval_start, interval_end,
|
|
||||||
optimization_targets, optimization_run_id, plan_version
|
|
||||||
) VALUES
|
|
||||||
-- Example pump optimization plan with JSON targets
|
|
||||||
('STATION_001', 'PUMP_001', 'PUMP', NOW() - INTERVAL '5 minutes', NOW() + INTERVAL '55 minutes',
|
|
||||||
'{"target_type": "SPEED", "target_value": 42.3, "secondary_targets": {"power_kw": 65.2, "flow_m3h": 320.5, "level_m": 2.5}, "constraints": {"max_rate_of_change": 5.0, "deadband": 0.2}}'::JSONB,
|
|
||||||
1001, 1),
|
|
||||||
|
|
||||||
-- Example valve optimization plan
|
|
||||||
('STATION_001', 'VALVE_001', 'VALVE', NOW() - INTERVAL '5 minutes', NOW() + INTERVAL '55 minutes',
|
|
||||||
'{"target_type": "POSITION", "target_value": 75.0, "secondary_targets": {"flow_m3h": 200.0, "pressure_bar": 1.5}, "constraints": {"max_rate_of_change": 10.0, "deadband": 1.0}}'::JSONB,
|
|
||||||
1001, 1),
|
|
||||||
|
|
||||||
-- Example blower optimization plan
|
|
||||||
('STATION_002', 'BLOWER_001', 'BLOWER', NOW() - INTERVAL '5 minutes', NOW() + INTERVAL '55 minutes',
|
|
||||||
'{"target_type": "SPEED", "target_value": 35.0, "secondary_targets": {"power_kw": 25.0, "pressure_bar": 1.2}, "constraints": {"max_rate_of_change": 3.0, "deadband": 0.5}}'::JSONB,
|
|
||||||
1001, 1);
|
|
||||||
|
|
||||||
-- Insert sample feedback data
|
-- Insert sample feedback data
|
||||||
INSERT INTO pump_feedback (
|
INSERT INTO pump_feedback (
|
||||||
|
|
|
||||||
|
|
@ -1,291 +0,0 @@
|
||||||
# Alert System Setup Guide
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The Calejo Control Adapter includes a comprehensive multi-channel alert system that can notify operators of safety events, system failures, and operational issues through multiple channels:
|
|
||||||
|
|
||||||
- **Email Alerts** - For all safety events
|
|
||||||
- **SMS Alerts** - For critical events only
|
|
||||||
- **Webhook Integration** - For external monitoring systems
|
|
||||||
- **SCADA Alarms** - For HMI integration (Phase 4)
|
|
||||||
|
|
||||||
## Current Implementation Status
|
|
||||||
|
|
||||||
### ✅ Fully Implemented
|
|
||||||
- **Alert routing and management framework**
|
|
||||||
- **Email sending logic** (requires SMTP configuration)
|
|
||||||
- **Webhook integration** (requires endpoint configuration)
|
|
||||||
- **Alert history and statistics**
|
|
||||||
- **Multi-channel coordination**
|
|
||||||
|
|
||||||
### ⚠️ Requires External Configuration
|
|
||||||
- **SMS Integration** - Needs Twilio account and billing setup
|
|
||||||
- **Email Integration** - Needs real SMTP server credentials
|
|
||||||
- **Webhook Integration** - Needs real webhook endpoints
|
|
||||||
- **SCADA Integration** - Planned for Phase 4
|
|
||||||
|
|
||||||
## Configuration Guide
|
|
||||||
|
|
||||||
### 1. Email Alert Setup
|
|
||||||
|
|
||||||
#### Prerequisites
|
|
||||||
- SMTP server access (Gmail, Office 365, or company SMTP)
|
|
||||||
- Valid email account credentials
|
|
||||||
|
|
||||||
#### Configuration Steps
|
|
||||||
|
|
||||||
1. **Update environment variables** in `config/.env`:
|
|
||||||
```bash
|
|
||||||
# Email Configuration
|
|
||||||
SMTP_SERVER=smtp.gmail.com
|
|
||||||
SMTP_PORT=587
|
|
||||||
SMTP_USERNAME=your-email@gmail.com
|
|
||||||
SMTP_PASSWORD=your-app-password # Use app password for Gmail
|
|
||||||
SMTP_USE_TLS=true
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Update settings** in `config/settings.py`:
|
|
||||||
```python
|
|
||||||
alert_email_enabled: bool = True
|
|
||||||
alert_email_from: str = "calejo-control@your-company.com"
|
|
||||||
alert_email_recipients: List[str] = ["operator1@company.com", "operator2@company.com"]
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **For Gmail users**:
|
|
||||||
- Enable 2-factor authentication
|
|
||||||
- Generate an "App Password" for the application
|
|
||||||
- Use the app password instead of your regular password
|
|
||||||
|
|
||||||
#### Testing Email Configuration
|
|
||||||
```python
|
|
||||||
# Test email configuration
|
|
||||||
from src.monitoring.alerts import AlertManager
|
|
||||||
from config.settings import Settings
|
|
||||||
|
|
||||||
settings = Settings()
|
|
||||||
alert_manager = AlertManager(settings)
|
|
||||||
|
|
||||||
# Send test alert
|
|
||||||
await alert_manager.send_alert(
|
|
||||||
alert_type="TEST_ALERT",
|
|
||||||
severity="INFO",
|
|
||||||
message="Test email configuration",
|
|
||||||
context={"test": "email"}
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. SMS Alert Setup (Twilio)
|
|
||||||
|
|
||||||
#### Prerequisites
|
|
||||||
- Twilio account (https://www.twilio.com)
|
|
||||||
- Verified phone numbers for recipients
|
|
||||||
- Billing information (SMS costs money)
|
|
||||||
|
|
||||||
#### Configuration Steps
|
|
||||||
|
|
||||||
1. **Get Twilio credentials**:
|
|
||||||
- Sign up for Twilio account
|
|
||||||
- Get Account SID and Auth Token from dashboard
|
|
||||||
- Get your Twilio phone number
|
|
||||||
|
|
||||||
2. **Update environment variables** in `config/.env`:
|
|
||||||
```bash
|
|
||||||
# Twilio Configuration
|
|
||||||
TWILIO_ACCOUNT_SID=ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
|
||||||
TWILIO_AUTH_TOKEN=your_auth_token
|
|
||||||
TWILIO_PHONE_NUMBER=+15551234567
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Update settings** in `config/settings.py`:
|
|
||||||
```python
|
|
||||||
alert_sms_enabled: bool = True
|
|
||||||
alert_sms_recipients: List[str] = ["+393401234567", "+393407654321"]
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Implement SMS sending** (currently only logs):
|
|
||||||
- The current implementation only logs SMS alerts
|
|
||||||
- To enable actual SMS sending, uncomment and implement the Twilio integration in `src/monitoring/alerts.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# In _send_sms_alert method, replace the TODO section with:
|
|
||||||
import twilio
|
|
||||||
from twilio.rest import Client
|
|
||||||
|
|
||||||
client = Client(self.settings.twilio_account_sid, self.settings.twilio_auth_token)
|
|
||||||
|
|
||||||
for phone_number in self.settings.alert_sms_recipients:
|
|
||||||
message = client.messages.create(
|
|
||||||
body=f"[{alert_data['severity']}] {alert_data['message']}",
|
|
||||||
from_=self.settings.twilio_phone_number,
|
|
||||||
to=phone_number
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Webhook Integration
|
|
||||||
|
|
||||||
#### Prerequisites
|
|
||||||
- Webhook endpoint URL
|
|
||||||
- Authentication token (if required)
|
|
||||||
|
|
||||||
#### Configuration Steps
|
|
||||||
|
|
||||||
1. **Update environment variables** in `config/.env`:
|
|
||||||
```bash
|
|
||||||
# Webhook Configuration
|
|
||||||
ALERT_WEBHOOK_URL=https://your-monitoring-system.com/webhook
|
|
||||||
ALERT_WEBHOOK_TOKEN=your_bearer_token
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Update settings** in `config/settings.py`:
|
|
||||||
```python
|
|
||||||
alert_webhook_enabled: bool = True
|
|
||||||
alert_webhook_url: str = "https://your-monitoring-system.com/webhook"
|
|
||||||
alert_webhook_token: str = "your_bearer_token"
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Webhook Payload Format
|
|
||||||
When an alert is triggered, the webhook receives a JSON payload:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"alert_type": "SAFETY_VIOLATION",
|
|
||||||
"severity": "ERROR",
|
|
||||||
"message": "Speed limit exceeded",
|
|
||||||
"context": {
|
|
||||||
"requested_speed": 55.0,
|
|
||||||
"max_speed": 50.0
|
|
||||||
},
|
|
||||||
"station_id": "STATION_001",
|
|
||||||
"pump_id": "PUMP_001",
|
|
||||||
"timestamp": 1234567890.0,
|
|
||||||
"app_name": "Calejo Control Adapter",
|
|
||||||
"app_version": "2.0.0"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. SCADA Alarm Integration (Phase 4)
|
|
||||||
|
|
||||||
**Status**: Planned for Phase 4 implementation
|
|
||||||
|
|
||||||
When implemented, SCADA alarms will:
|
|
||||||
- Trigger alarms in SCADA HMI systems via OPC UA
|
|
||||||
- Provide visual and audible alerts in control rooms
|
|
||||||
- Integrate with existing alarm management systems
|
|
||||||
|
|
||||||
## Alert Types and Severity
|
|
||||||
|
|
||||||
### Alert Types
|
|
||||||
- `SAFETY_VIOLATION` - Safety limit exceeded
|
|
||||||
- `FAILSAFE_ACTIVATED` - Failsafe mode activated
|
|
||||||
- `EMERGENCY_STOP` - Emergency stop activated
|
|
||||||
- `SYSTEM_ERROR` - System or communication error
|
|
||||||
- `WATCHDOG_TIMEOUT` - Database update timeout
|
|
||||||
|
|
||||||
### Severity Levels
|
|
||||||
- `INFO` - Informational messages
|
|
||||||
- `WARNING` - Non-critical warnings
|
|
||||||
- `ERROR` - Errors requiring attention
|
|
||||||
- `CRITICAL` - Critical failures requiring immediate action
|
|
||||||
|
|
||||||
## Testing the Alert System
|
|
||||||
|
|
||||||
### Unit Tests
|
|
||||||
Run the comprehensive alert system tests:
|
|
||||||
```bash
|
|
||||||
pytest tests/unit/test_alerts.py -v
|
|
||||||
```
|
|
||||||
|
|
||||||
### Manual Testing
|
|
||||||
```python
|
|
||||||
# Test all alert channels
|
|
||||||
from src.monitoring.alerts import AlertManager
|
|
||||||
from config.settings import Settings
|
|
||||||
|
|
||||||
settings = Settings()
|
|
||||||
alert_manager = AlertManager(settings)
|
|
||||||
|
|
||||||
# Test different alert types and severities
|
|
||||||
test_alerts = [
|
|
||||||
("SAFETY_VIOLATION", "ERROR", "Speed limit exceeded"),
|
|
||||||
("FAILSAFE_ACTIVATED", "CRITICAL", "Failsafe mode activated"),
|
|
||||||
("SYSTEM_ERROR", "WARNING", "Communication timeout"),
|
|
||||||
]
|
|
||||||
|
|
||||||
for alert_type, severity, message in test_alerts:
|
|
||||||
result = await alert_manager.send_alert(
|
|
||||||
alert_type=alert_type,
|
|
||||||
severity=severity,
|
|
||||||
message=message,
|
|
||||||
context={"test": True}
|
|
||||||
)
|
|
||||||
print(f"{alert_type}: {result}")
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Common Issues
|
|
||||||
|
|
||||||
1. **Email not sending**:
|
|
||||||
- Check SMTP server credentials
|
|
||||||
- Verify TLS/SSL settings
|
|
||||||
- Check firewall rules for outbound SMTP
|
|
||||||
|
|
||||||
2. **SMS not working**:
|
|
||||||
- Verify Twilio account is active and funded
|
|
||||||
- Check phone numbers are verified in Twilio
|
|
||||||
- Ensure SMS integration is implemented (currently only logs)
|
|
||||||
|
|
||||||
3. **Webhook failures**:
|
|
||||||
- Verify webhook URL is accessible
|
|
||||||
- Check authentication tokens
|
|
||||||
- Monitor webhook server logs
|
|
||||||
|
|
||||||
4. **No alerts being sent**:
|
|
||||||
- Check alert channels are enabled in settings
|
|
||||||
- Verify alert system is initialized in main application
|
|
||||||
- Check application logs for alert-related errors
|
|
||||||
|
|
||||||
### Logging
|
|
||||||
Alert system activities are logged with the following events:
|
|
||||||
- `alert_sent` - Alert successfully sent
|
|
||||||
- `email_alert_failed` - Email delivery failed
|
|
||||||
- `sms_alert_failed` - SMS delivery failed
|
|
||||||
- `webhook_alert_failed` - Webhook delivery failed
|
|
||||||
- `scada_alert_failed` - SCADA alarm failed
|
|
||||||
|
|
||||||
## Security Considerations
|
|
||||||
|
|
||||||
- Store SMTP and Twilio credentials securely (environment variables)
|
|
||||||
- Use app passwords instead of regular passwords for email
|
|
||||||
- Rotate authentication tokens regularly
|
|
||||||
- Monitor alert system for abuse or excessive alerts
|
|
||||||
- Implement rate limiting if needed
|
|
||||||
|
|
||||||
## Monitoring and Maintenance
|
|
||||||
|
|
||||||
### Alert Statistics
|
|
||||||
Use the built-in statistics to monitor alert patterns:
|
|
||||||
|
|
||||||
```python
|
|
||||||
alert_manager = AlertManager(settings)
|
|
||||||
stats = alert_manager.get_alert_stats()
|
|
||||||
print(f"Total alerts: {stats['total_alerts']}")
|
|
||||||
print(f"Severity counts: {stats['severity_counts']}")
|
|
||||||
print(f"Type counts: {stats['type_counts']}")
|
|
||||||
```
|
|
||||||
|
|
||||||
### Alert History
|
|
||||||
Review recent alerts:
|
|
||||||
```python
|
|
||||||
recent_alerts = alert_manager.get_alert_history(limit=50)
|
|
||||||
for alert in recent_alerts:
|
|
||||||
print(f"{alert['timestamp']} - {alert['alert_type']}: {alert['message']}")
|
|
||||||
```
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
1. **Immediate**: Configure email and webhook for basic alerting
|
|
||||||
2. **Short-term**: Implement Twilio SMS integration if needed
|
|
||||||
3. **Long-term**: Implement SCADA OPC UA alarm integration in Phase 4
|
|
||||||
|
|
||||||
For questions or issues with alert system setup, refer to the application logs or contact the development team.
|
|
||||||
|
|
@ -1,296 +0,0 @@
|
||||||
# Optimization Plan Management
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The Calejo Control Adapter implements a sophisticated version-based optimization plan management system that enables real-time synchronization between optimization intervals and control execution. This system supports both pump-specific plans and generic actuator optimization plans.
|
|
||||||
|
|
||||||
## Database Schema
|
|
||||||
|
|
||||||
### Version-Based Pump Plans (`pump_plans`)
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE pump_plans (
|
|
||||||
plan_id SERIAL PRIMARY KEY,
|
|
||||||
station_id VARCHAR(50) NOT NULL,
|
|
||||||
pump_id VARCHAR(50) NOT NULL,
|
|
||||||
interval_start TIMESTAMP NOT NULL,
|
|
||||||
interval_end TIMESTAMP NOT NULL,
|
|
||||||
|
|
||||||
-- Optimization outputs
|
|
||||||
target_flow_m3h DECIMAL(10, 2),
|
|
||||||
target_power_kw DECIMAL(10, 2),
|
|
||||||
target_level_m DECIMAL(5, 2),
|
|
||||||
suggested_speed_hz DECIMAL(5, 2),
|
|
||||||
|
|
||||||
-- Version-based update metadata
|
|
||||||
plan_created_at TIMESTAMP DEFAULT NOW(),
|
|
||||||
plan_updated_at TIMESTAMP DEFAULT NOW(),
|
|
||||||
plan_version INTEGER DEFAULT 1,
|
|
||||||
optimization_run_id INTEGER,
|
|
||||||
|
|
||||||
-- Status tracking
|
|
||||||
plan_status VARCHAR(20) DEFAULT 'ACTIVE', -- 'ACTIVE', 'SUPERSEDED', 'CANCELLED'
|
|
||||||
superseded_by INTEGER, -- Points to plan_id that superseded this plan
|
|
||||||
|
|
||||||
FOREIGN KEY (station_id, pump_id) REFERENCES pumps(station_id, pump_id),
|
|
||||||
FOREIGN KEY (superseded_by) REFERENCES pump_plans(plan_id)
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Generic Optimization Plans (`optimization_plans`)
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE optimization_plans (
|
|
||||||
plan_id SERIAL PRIMARY KEY,
|
|
||||||
station_id VARCHAR(50) NOT NULL,
|
|
||||||
resource_id VARCHAR(50) NOT NULL,
|
|
||||||
resource_type VARCHAR(20) NOT NULL DEFAULT 'PUMP', -- 'PUMP', 'VALVE', 'BLOWER', etc.
|
|
||||||
|
|
||||||
interval_start TIMESTAMP NOT NULL,
|
|
||||||
interval_end TIMESTAMP NOT NULL,
|
|
||||||
|
|
||||||
-- Generic optimization targets (JSON for flexibility)
|
|
||||||
optimization_targets JSONB NOT NULL,
|
|
||||||
|
|
||||||
-- Version-based update metadata
|
|
||||||
plan_created_at TIMESTAMP DEFAULT NOW(),
|
|
||||||
plan_updated_at TIMESTAMP DEFAULT NOW(),
|
|
||||||
plan_version INTEGER DEFAULT 1,
|
|
||||||
optimization_run_id INTEGER,
|
|
||||||
plan_priority INTEGER DEFAULT 1,
|
|
||||||
|
|
||||||
-- Status tracking
|
|
||||||
plan_status VARCHAR(20) DEFAULT 'ACTIVE',
|
|
||||||
superseded_by INTEGER,
|
|
||||||
|
|
||||||
FOREIGN KEY (station_id, resource_id) REFERENCES pumps(station_id, pump_id),
|
|
||||||
FOREIGN KEY (superseded_by) REFERENCES optimization_plans(plan_id),
|
|
||||||
|
|
||||||
-- Ensure resource_type is valid
|
|
||||||
CONSTRAINT valid_resource_type CHECK (resource_type IN ('PUMP', 'VALVE', 'BLOWER', 'COMPRESSOR', 'GATE'))
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Version-Based Update Strategy (Strategy B)
|
|
||||||
|
|
||||||
### Core Principles
|
|
||||||
|
|
||||||
1. **Immutable Plan History**: Each plan update creates a new version while preserving the old version
|
|
||||||
2. **Status Tracking**: Plans can be `ACTIVE`, `SUPERSEDED`, or `CANCELLED`
|
|
||||||
3. **Audit Trail**: Complete history of all plan changes with timestamps
|
|
||||||
4. **Real-time Synchronization**: Support for optimization systems that update more frequently than control intervals
|
|
||||||
|
|
||||||
### Update Process
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Example update sequence
|
|
||||||
plan_id = 123
|
|
||||||
updates = {
|
|
||||||
'target_flow_m3h': 325.0,
|
|
||||||
'target_power_kw': 66.5,
|
|
||||||
'suggested_speed_hz': 42.8
|
|
||||||
}
|
|
||||||
|
|
||||||
# This creates a new version and marks the old one as SUPERSEDED
|
|
||||||
success = db_client.update_pump_plan(plan_id, updates)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Query Strategy
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- Get latest active plans for current time interval
|
|
||||||
SELECT DISTINCT ON (station_id, pump_id)
|
|
||||||
station_id, pump_id, target_flow_m3h, target_power_kw,
|
|
||||||
target_level_m, suggested_speed_hz, interval_start, interval_end,
|
|
||||||
plan_version, plan_created_at, plan_updated_at
|
|
||||||
FROM pump_plans
|
|
||||||
WHERE interval_start <= NOW() AND interval_end >= NOW()
|
|
||||||
AND plan_status = 'ACTIVE'
|
|
||||||
ORDER BY station_id, pump_id, plan_version DESC;
|
|
||||||
```
|
|
||||||
|
|
||||||
## Optimization Targets JSON Structure
|
|
||||||
|
|
||||||
### Pump Optimization Targets
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"target_type": "SPEED",
|
|
||||||
"target_value": 42.3,
|
|
||||||
"secondary_targets": {
|
|
||||||
"power_kw": 65.2,
|
|
||||||
"flow_m3h": 320.5,
|
|
||||||
"level_m": 2.5
|
|
||||||
},
|
|
||||||
"constraints": {
|
|
||||||
"max_rate_of_change": 5.0,
|
|
||||||
"deadband": 0.2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Valve Optimization Targets
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"target_type": "POSITION",
|
|
||||||
"target_value": 75.0,
|
|
||||||
"secondary_targets": {
|
|
||||||
"flow_m3h": 200.0,
|
|
||||||
"pressure_bar": 1.5
|
|
||||||
},
|
|
||||||
"constraints": {
|
|
||||||
"max_rate_of_change": 10.0,
|
|
||||||
"deadband": 1.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Blower Optimization Targets
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"target_type": "SPEED",
|
|
||||||
"target_value": 35.0,
|
|
||||||
"secondary_targets": {
|
|
||||||
"power_kw": 25.0,
|
|
||||||
"pressure_bar": 1.2
|
|
||||||
},
|
|
||||||
"constraints": {
|
|
||||||
"max_rate_of_change": 3.0,
|
|
||||||
"deadband": 0.5
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Real-Time Plan Management
|
|
||||||
|
|
||||||
### OptimizationPlanManager Class
|
|
||||||
|
|
||||||
The `OptimizationPlanManager` provides real-time monitoring and synchronization:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Initialize manager
|
|
||||||
manager = OptimizationPlanManager(
|
|
||||||
db_client=db_client,
|
|
||||||
refresh_interval_seconds=30
|
|
||||||
)
|
|
||||||
|
|
||||||
# Start monitoring
|
|
||||||
await manager.start_monitoring()
|
|
||||||
|
|
||||||
# Register for plan updates
|
|
||||||
manager.register_plan_update_callback(handle_plan_update)
|
|
||||||
|
|
||||||
# Get current plans
|
|
||||||
current_plan = manager.get_current_pump_plan('STATION_001', 'PUMP_001')
|
|
||||||
```
|
|
||||||
|
|
||||||
### Plan Update Detection
|
|
||||||
|
|
||||||
The manager automatically detects plan updates by:
|
|
||||||
|
|
||||||
1. **Periodic Refresh**: Configurable refresh interval (default: 30 seconds)
|
|
||||||
2. **Version Comparison**: Compares plan versions to detect updates
|
|
||||||
3. **Callback Notification**: Notifies registered callbacks of plan changes
|
|
||||||
4. **Status Tracking**: Maintains cache of current active plans
|
|
||||||
|
|
||||||
### Update Types
|
|
||||||
|
|
||||||
- `NEW_PUMP_PLAN`: New plan detected for a pump
|
|
||||||
- `PUMP_PLAN_UPDATE`: Version update for existing pump plan
|
|
||||||
- `NEW_GENERIC_PLAN`: New plan for generic actuator
|
|
||||||
- `GENERIC_PLAN_UPDATE`: Version update for generic actuator plan
|
|
||||||
|
|
||||||
## Integration with Control System
|
|
||||||
|
|
||||||
### Safety Enforcement Integration
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Get current optimization plan
|
|
||||||
current_plan = optimization_manager.get_current_pump_plan(station_id, pump_id)
|
|
||||||
|
|
||||||
if current_plan:
|
|
||||||
# Extract suggested speed
|
|
||||||
suggested_speed = current_plan['suggested_speed_hz']
|
|
||||||
|
|
||||||
# Apply safety enforcement
|
|
||||||
enforced_speed, violations = safety_enforcer.enforce_setpoint(
|
|
||||||
station_id, pump_id, suggested_speed
|
|
||||||
)
|
|
||||||
|
|
||||||
# Send to control system
|
|
||||||
await send_to_scada(station_id, pump_id, enforced_speed)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Real-Time Plan Updates
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def handle_plan_update(updates):
|
|
||||||
"""Handle real-time optimization plan updates."""
|
|
||||||
for update in updates:
|
|
||||||
if update['type'] == 'PUMP_PLAN_UPDATE':
|
|
||||||
logger.info(
|
|
||||||
"pump_plan_updated_realtime",
|
|
||||||
station_id=update['station_id'],
|
|
||||||
pump_id=update['pump_id'],
|
|
||||||
old_version=update['old_version'],
|
|
||||||
new_version=update['new_version']
|
|
||||||
)
|
|
||||||
|
|
||||||
# Immediately apply updated plan
|
|
||||||
await apply_updated_plan(update['plan'])
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
### Settings
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Auto-discovery settings
|
|
||||||
auto_discovery_enabled: bool = True
|
|
||||||
auto_discovery_refresh_minutes: int = 60
|
|
||||||
|
|
||||||
# Optimization plan management settings
|
|
||||||
optimization_monitoring_enabled: bool = True
|
|
||||||
optimization_refresh_seconds: int = 30
|
|
||||||
```
|
|
||||||
|
|
||||||
## Benefits
|
|
||||||
|
|
||||||
### For Optimization System
|
|
||||||
|
|
||||||
1. **Flexible Update Frequency**: Optimization can update plans more frequently than control intervals
|
|
||||||
2. **Audit Trail**: Complete history of all optimization decisions
|
|
||||||
3. **Rollback Capability**: Can revert to previous plan versions if needed
|
|
||||||
4. **Multi-Actuator Support**: Unified system for pumps, valves, blowers, etc.
|
|
||||||
|
|
||||||
### For Control System
|
|
||||||
|
|
||||||
1. **Real-time Synchronization**: Immediate application of updated optimization plans
|
|
||||||
2. **Safety Integration**: All optimization targets pass through safety enforcement
|
|
||||||
3. **Status Monitoring**: Real-time visibility into optimization plan status
|
|
||||||
4. **Error Recovery**: Automatic handling of optimization system failures
|
|
||||||
|
|
||||||
### For Operations
|
|
||||||
|
|
||||||
1. **Transparency**: Complete visibility into optimization decisions and changes
|
|
||||||
2. **Compliance**: Audit trail for regulatory requirements
|
|
||||||
3. **Troubleshooting**: Historical data for performance analysis
|
|
||||||
4. **Flexibility**: Support for various optimization strategies and intervals
|
|
||||||
|
|
||||||
## Extensibility
|
|
||||||
|
|
||||||
The system is designed to support future extensions:
|
|
||||||
|
|
||||||
1. **Additional Actuator Types**: Easy to add new resource types
|
|
||||||
2. **Advanced Optimization Strategies**: Support for complex multi-actuator coordination
|
|
||||||
3. **Machine Learning Integration**: Real-time model updates and predictions
|
|
||||||
4. **Distributed Optimization**: Support for multiple optimization engines
|
|
||||||
|
|
||||||
## Performance Considerations
|
|
||||||
|
|
||||||
1. **Indexed Queries**: Efficient retrieval of current plans
|
|
||||||
2. **Caching**: In-memory cache of active plans for fast access
|
|
||||||
3. **Partial Indexes**: Only index active plans for better performance
|
|
||||||
4. **Connection Pooling**: Efficient database access patterns
|
|
||||||
24
pytest.ini
24
pytest.ini
|
|
@ -1,24 +0,0 @@
|
||||||
[tool:pytest]
|
|
||||||
testpaths = tests
|
|
||||||
python_files = test_*.py
|
|
||||||
python_classes = Test*
|
|
||||||
python_functions = test_*
|
|
||||||
addopts =
|
|
||||||
-v
|
|
||||||
--tb=short
|
|
||||||
--strict-markers
|
|
||||||
--strict-config
|
|
||||||
--cov=src
|
|
||||||
--cov-report=term-missing
|
|
||||||
--cov-report=html
|
|
||||||
--cov-report=xml
|
|
||||||
--cov-fail-under=80
|
|
||||||
markers =
|
|
||||||
unit: Unit tests (fast, no external dependencies)
|
|
||||||
integration: Integration tests (require external services)
|
|
||||||
database: Tests that require database
|
|
||||||
slow: Tests that take a long time to run
|
|
||||||
safety: Safety framework tests
|
|
||||||
protocol: Protocol server tests
|
|
||||||
security: Security and compliance tests
|
|
||||||
asyncio_mode = auto
|
|
||||||
136
run_tests.py
136
run_tests.py
|
|
@ -1,136 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Test runner script for Calejo Control Adapter.
|
|
||||||
|
|
||||||
This script provides different test execution options:
|
|
||||||
- Run all tests
|
|
||||||
- Run unit tests only
|
|
||||||
- Run integration tests only
|
|
||||||
- Run tests with coverage
|
|
||||||
- Run tests with specific markers
|
|
||||||
"""
|
|
||||||
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
from typing import List, Optional
|
|
||||||
|
|
||||||
|
|
||||||
def run_tests(
|
|
||||||
test_type: str = "all",
|
|
||||||
coverage: bool = False,
|
|
||||||
markers: Optional[List[str]] = None,
|
|
||||||
verbose: bool = False
|
|
||||||
) -> int:
|
|
||||||
"""
|
|
||||||
Run tests using pytest.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
test_type: Type of tests to run ("all", "unit", "integration")
|
|
||||||
coverage: Whether to run with coverage
|
|
||||||
markers: List of pytest markers to filter by
|
|
||||||
verbose: Whether to run in verbose mode
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Exit code from pytest
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Base pytest command
|
|
||||||
cmd = ["pytest"]
|
|
||||||
|
|
||||||
# Add test type filters
|
|
||||||
if test_type == "unit":
|
|
||||||
cmd.extend(["tests/unit"])
|
|
||||||
elif test_type == "integration":
|
|
||||||
cmd.extend(["tests/integration"])
|
|
||||||
else:
|
|
||||||
cmd.extend(["tests"])
|
|
||||||
|
|
||||||
# Add coverage if requested
|
|
||||||
if coverage:
|
|
||||||
cmd.extend([
|
|
||||||
"--cov=src",
|
|
||||||
"--cov-report=term-missing",
|
|
||||||
"--cov-report=html",
|
|
||||||
"--cov-fail-under=80"
|
|
||||||
])
|
|
||||||
|
|
||||||
# Add markers if specified
|
|
||||||
if markers:
|
|
||||||
for marker in markers:
|
|
||||||
cmd.extend(["-m", marker])
|
|
||||||
|
|
||||||
# Add verbose flag
|
|
||||||
if verbose:
|
|
||||||
cmd.append("-v")
|
|
||||||
|
|
||||||
# Add additional pytest options
|
|
||||||
cmd.extend([
|
|
||||||
"--tb=short",
|
|
||||||
"--strict-markers",
|
|
||||||
"--strict-config"
|
|
||||||
])
|
|
||||||
|
|
||||||
print(f"Running tests with command: {' '.join(cmd)}")
|
|
||||||
print("-" * 60)
|
|
||||||
|
|
||||||
# Run pytest
|
|
||||||
result = subprocess.run(cmd)
|
|
||||||
|
|
||||||
return result.returncode
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
"""Main function to parse arguments and run tests."""
|
|
||||||
import argparse
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description="Run Calejo Control Adapter tests")
|
|
||||||
parser.add_argument(
|
|
||||||
"--type",
|
|
||||||
choices=["all", "unit", "integration"],
|
|
||||||
default="all",
|
|
||||||
help="Type of tests to run"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--coverage",
|
|
||||||
action="store_true",
|
|
||||||
help="Run with coverage reporting"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--marker",
|
|
||||||
action="append",
|
|
||||||
help="Run tests with specific markers (can be used multiple times)"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--verbose", "-v",
|
|
||||||
action="store_true",
|
|
||||||
help="Run in verbose mode"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--quick",
|
|
||||||
action="store_true",
|
|
||||||
help="Run quick tests only (unit tests without database)"
|
|
||||||
)
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
# Handle quick mode
|
|
||||||
if args.quick:
|
|
||||||
args.type = "unit"
|
|
||||||
if args.marker is None:
|
|
||||||
args.marker = []
|
|
||||||
args.marker.append("not database")
|
|
||||||
|
|
||||||
# Run tests
|
|
||||||
exit_code = run_tests(
|
|
||||||
test_type=args.type,
|
|
||||||
coverage=args.coverage,
|
|
||||||
markers=args.marker,
|
|
||||||
verbose=args.verbose
|
|
||||||
)
|
|
||||||
|
|
||||||
sys.exit(exit_code)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
|
|
@ -1,327 +0,0 @@
|
||||||
"""
|
|
||||||
Emergency Stop Manager for Calejo Control Adapter.
|
|
||||||
|
|
||||||
Implements system-wide and targeted emergency stop functionality
|
|
||||||
with manual clearance and audit trail.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from typing import Dict, List, Optional, Set, Any
|
|
||||||
from datetime import datetime
|
|
||||||
import structlog
|
|
||||||
|
|
||||||
from src.database.client import DatabaseClient
|
|
||||||
|
|
||||||
logger = structlog.get_logger()
|
|
||||||
|
|
||||||
|
|
||||||
class EmergencyStopManager:
|
|
||||||
"""
|
|
||||||
Manages emergency stop functionality for pumps and stations.
|
|
||||||
|
|
||||||
Features:
|
|
||||||
- Single pump emergency stop
|
|
||||||
- Station-wide emergency stop
|
|
||||||
- System-wide emergency stop
|
|
||||||
- Manual clearance with audit trail
|
|
||||||
- Integration with all protocol interfaces
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, db_client: DatabaseClient):
|
|
||||||
self.db_client = db_client
|
|
||||||
self.emergency_stop_pumps: Set[tuple] = set() # (station_id, pump_id)
|
|
||||||
self.emergency_stop_stations: Set[str] = set()
|
|
||||||
self.system_emergency_stop = False
|
|
||||||
self.emergency_stop_history: List[Dict] = []
|
|
||||||
|
|
||||||
def emergency_stop_pump(self, station_id: str, pump_id: str, reason: str = "Manual stop", user_id: str = "system") -> bool:
|
|
||||||
"""
|
|
||||||
Emergency stop a specific pump.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
station_id: Station identifier
|
|
||||||
pump_id: Pump identifier
|
|
||||||
reason: Reason for emergency stop
|
|
||||||
user_id: User who initiated the stop
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if stop was successful
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
key = (station_id, pump_id)
|
|
||||||
self.emergency_stop_pumps.add(key)
|
|
||||||
|
|
||||||
# Record emergency stop event
|
|
||||||
self._record_emergency_stop_event(station_id, pump_id, 'PUMP', reason, user_id)
|
|
||||||
|
|
||||||
logger.critical(
|
|
||||||
"emergency_stop_pump_activated",
|
|
||||||
station_id=station_id,
|
|
||||||
pump_id=pump_id,
|
|
||||||
reason=reason,
|
|
||||||
user_id=user_id
|
|
||||||
)
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
"emergency_stop_pump_failed",
|
|
||||||
station_id=station_id,
|
|
||||||
pump_id=pump_id,
|
|
||||||
error=str(e)
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
def emergency_stop_station(self, station_id: str, reason: str = "Manual stop", user_id: str = "system") -> bool:
|
|
||||||
"""
|
|
||||||
Emergency stop all pumps in a station.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
station_id: Station identifier
|
|
||||||
reason: Reason for emergency stop
|
|
||||||
user_id: User who initiated the stop
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if stop was successful
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
self.emergency_stop_stations.add(station_id)
|
|
||||||
|
|
||||||
# Record emergency stop event
|
|
||||||
self._record_emergency_stop_event(station_id, None, 'STATION', reason, user_id)
|
|
||||||
|
|
||||||
logger.critical(
|
|
||||||
"emergency_stop_station_activated",
|
|
||||||
station_id=station_id,
|
|
||||||
reason=reason,
|
|
||||||
user_id=user_id
|
|
||||||
)
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
"emergency_stop_station_failed",
|
|
||||||
station_id=station_id,
|
|
||||||
error=str(e)
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
def emergency_stop_system(self, reason: str = "Manual stop", user_id: str = "system") -> bool:
|
|
||||||
"""
|
|
||||||
Emergency stop all pumps in the system.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
reason: Reason for emergency stop
|
|
||||||
user_id: User who initiated the stop
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if stop was successful
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
self.system_emergency_stop = True
|
|
||||||
|
|
||||||
# Record emergency stop event
|
|
||||||
self._record_emergency_stop_event(None, None, 'SYSTEM', reason, user_id)
|
|
||||||
|
|
||||||
logger.critical(
|
|
||||||
"emergency_stop_system_activated",
|
|
||||||
reason=reason,
|
|
||||||
user_id=user_id
|
|
||||||
)
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("emergency_stop_system_failed", error=str(e))
|
|
||||||
return False
|
|
||||||
|
|
||||||
def clear_emergency_stop_pump(self, station_id: str, pump_id: str, reason: str = "Manual clearance", user_id: str = "system") -> bool:
|
|
||||||
"""
|
|
||||||
Clear emergency stop for a specific pump.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
station_id: Station identifier
|
|
||||||
pump_id: Pump identifier
|
|
||||||
reason: Reason for clearance
|
|
||||||
user_id: User who cleared the stop
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if clearance was successful
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
key = (station_id, pump_id)
|
|
||||||
if key in self.emergency_stop_pumps:
|
|
||||||
self.emergency_stop_pumps.remove(key)
|
|
||||||
|
|
||||||
# Record clearance event
|
|
||||||
self._record_emergency_stop_clearance(station_id, pump_id, 'PUMP', reason, user_id)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"emergency_stop_pump_cleared",
|
|
||||||
station_id=station_id,
|
|
||||||
pump_id=pump_id,
|
|
||||||
reason=reason,
|
|
||||||
user_id=user_id
|
|
||||||
)
|
|
||||||
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
logger.warning(
|
|
||||||
"emergency_stop_pump_not_active",
|
|
||||||
station_id=station_id,
|
|
||||||
pump_id=pump_id
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
"emergency_stop_pump_clearance_failed",
|
|
||||||
station_id=station_id,
|
|
||||||
pump_id=pump_id,
|
|
||||||
error=str(e)
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
def clear_emergency_stop_station(self, station_id: str, reason: str = "Manual clearance", user_id: str = "system") -> bool:
|
|
||||||
"""
|
|
||||||
Clear emergency stop for all pumps in a station.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
station_id: Station identifier
|
|
||||||
reason: Reason for clearance
|
|
||||||
user_id: User who cleared the stop
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if clearance was successful
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
if station_id in self.emergency_stop_stations:
|
|
||||||
self.emergency_stop_stations.remove(station_id)
|
|
||||||
|
|
||||||
# Record clearance event
|
|
||||||
self._record_emergency_stop_clearance(station_id, None, 'STATION', reason, user_id)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"emergency_stop_station_cleared",
|
|
||||||
station_id=station_id,
|
|
||||||
reason=reason,
|
|
||||||
user_id=user_id
|
|
||||||
)
|
|
||||||
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
logger.warning(
|
|
||||||
"emergency_stop_station_not_active",
|
|
||||||
station_id=station_id
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
"emergency_stop_station_clearance_failed",
|
|
||||||
station_id=station_id,
|
|
||||||
error=str(e)
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
def clear_emergency_stop_system(self, reason: str = "Manual clearance", user_id: str = "system") -> bool:
|
|
||||||
"""
|
|
||||||
Clear system-wide emergency stop.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
reason: Reason for clearance
|
|
||||||
user_id: User who cleared the stop
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if clearance was successful
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
if self.system_emergency_stop:
|
|
||||||
self.system_emergency_stop = False
|
|
||||||
|
|
||||||
# Record clearance event
|
|
||||||
self._record_emergency_stop_clearance(None, None, 'SYSTEM', reason, user_id)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"emergency_stop_system_cleared",
|
|
||||||
reason=reason,
|
|
||||||
user_id=user_id
|
|
||||||
)
|
|
||||||
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
logger.warning("emergency_stop_system_not_active")
|
|
||||||
return False
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("emergency_stop_system_clearance_failed", error=str(e))
|
|
||||||
return False
|
|
||||||
|
|
||||||
def is_emergency_stop_active(self, station_id: str, pump_id: str) -> bool:
|
|
||||||
"""
|
|
||||||
Check if emergency stop is active for a pump.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
station_id: Station identifier
|
|
||||||
pump_id: Pump identifier
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if emergency stop is active
|
|
||||||
"""
|
|
||||||
# Check system-wide stop
|
|
||||||
if self.system_emergency_stop:
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Check station-wide stop
|
|
||||||
if station_id in self.emergency_stop_stations:
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Check pump-specific stop
|
|
||||||
key = (station_id, pump_id)
|
|
||||||
if key in self.emergency_stop_pumps:
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
def get_emergency_stop_status(self) -> Dict[str, Any]:
|
|
||||||
"""Get current emergency stop status."""
|
|
||||||
return {
|
|
||||||
'system_emergency_stop': self.system_emergency_stop,
|
|
||||||
'emergency_stop_stations': list(self.emergency_stop_stations),
|
|
||||||
'emergency_stop_pumps': [
|
|
||||||
{'station_id': station_id, 'pump_id': pump_id}
|
|
||||||
for station_id, pump_id in self.emergency_stop_pumps
|
|
||||||
],
|
|
||||||
'total_active_stops': (
|
|
||||||
(1 if self.system_emergency_stop else 0) +
|
|
||||||
len(self.emergency_stop_stations) +
|
|
||||||
len(self.emergency_stop_pumps)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
def _record_emergency_stop_event(self, station_id: Optional[str], pump_id: Optional[str],
|
|
||||||
stop_type: str, reason: str, user_id: str):
|
|
||||||
"""Record emergency stop event in database."""
|
|
||||||
try:
|
|
||||||
query = """
|
|
||||||
INSERT INTO emergency_stop_events
|
|
||||||
(station_id, pump_id, stop_type, reason, user_id, timestamp)
|
|
||||||
VALUES (%s, %s, %s, %s, %s, NOW())
|
|
||||||
"""
|
|
||||||
self.db_client.execute(query, (station_id, pump_id, stop_type, reason, user_id))
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("failed_to_record_emergency_stop_event", error=str(e))
|
|
||||||
|
|
||||||
def _record_emergency_stop_clearance(self, station_id: Optional[str], pump_id: Optional[str],
|
|
||||||
stop_type: str, reason: str, user_id: str):
|
|
||||||
"""Record emergency stop clearance event in database."""
|
|
||||||
try:
|
|
||||||
query = """
|
|
||||||
INSERT INTO emergency_stop_events
|
|
||||||
(station_id, pump_id, stop_type, event_type, reason, user_id, timestamp)
|
|
||||||
VALUES (%s, %s, %s, 'CLEARED', %s, %s, NOW())
|
|
||||||
"""
|
|
||||||
self.db_client.execute(query, (station_id, pump_id, stop_type, reason, user_id))
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("failed_to_record_emergency_stop_clearance", error=str(e))
|
|
||||||
|
|
@ -1,214 +0,0 @@
|
||||||
"""
|
|
||||||
Optimization Plan Manager for Calejo Control Adapter.
|
|
||||||
|
|
||||||
Manages version-based optimization plan updates and real-time synchronization
|
|
||||||
between optimization system and control execution.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from typing import Dict, List, Optional, Callable, Any
|
|
||||||
import asyncio
|
|
||||||
import structlog
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
|
|
||||||
from src.database.client import DatabaseClient
|
|
||||||
|
|
||||||
logger = structlog.get_logger()
|
|
||||||
|
|
||||||
|
|
||||||
class OptimizationPlanManager:
|
|
||||||
"""
|
|
||||||
Manages optimization plans with version-based updates.
|
|
||||||
|
|
||||||
Supports real-time synchronization between optimization intervals
|
|
||||||
and control execution with audit trail of all plan changes.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, db_client: DatabaseClient, refresh_interval_seconds: int = 30):
|
|
||||||
self.db_client = db_client
|
|
||||||
self.refresh_interval_seconds = refresh_interval_seconds
|
|
||||||
self.active_pump_plans: Dict[tuple, Dict] = {} # (station_id, pump_id) -> plan
|
|
||||||
self.active_generic_plans: Dict[tuple, Dict] = {} # (station_id, resource_id) -> plan
|
|
||||||
self.plan_update_callbacks: List[Callable] = []
|
|
||||||
self.running = False
|
|
||||||
self.last_refresh = None
|
|
||||||
|
|
||||||
async def start_monitoring(self):
|
|
||||||
"""Start monitoring for optimization plan updates."""
|
|
||||||
self.running = True
|
|
||||||
logger.info("optimization_plan_monitoring_started")
|
|
||||||
|
|
||||||
# Initial load
|
|
||||||
await self.refresh_plans()
|
|
||||||
|
|
||||||
# Start periodic refresh
|
|
||||||
asyncio.create_task(self._periodic_refresh())
|
|
||||||
|
|
||||||
async def stop_monitoring(self):
|
|
||||||
"""Stop monitoring for optimization plan updates."""
|
|
||||||
self.running = False
|
|
||||||
logger.info("optimization_plan_monitoring_stopped")
|
|
||||||
|
|
||||||
async def _periodic_refresh(self):
|
|
||||||
"""Periodically refresh optimization plans."""
|
|
||||||
while self.running:
|
|
||||||
try:
|
|
||||||
await asyncio.sleep(self.refresh_interval_seconds)
|
|
||||||
await self.refresh_plans()
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("periodic_refresh_failed", error=str(e))
|
|
||||||
|
|
||||||
async def refresh_plans(self):
|
|
||||||
"""Refresh all optimization plans from database."""
|
|
||||||
try:
|
|
||||||
# Get latest pump plans
|
|
||||||
pump_plans = self.db_client.get_latest_pump_plans()
|
|
||||||
|
|
||||||
# Get latest generic optimization plans
|
|
||||||
generic_plans = self.db_client.get_generic_optimization_plans()
|
|
||||||
|
|
||||||
# Check for updates
|
|
||||||
pump_updates = self._detect_pump_plan_updates(pump_plans)
|
|
||||||
generic_updates = self._detect_generic_plan_updates(generic_plans)
|
|
||||||
|
|
||||||
# Update caches
|
|
||||||
self.active_pump_plans.clear()
|
|
||||||
for plan in pump_plans:
|
|
||||||
key = (plan['station_id'], plan['pump_id'])
|
|
||||||
self.active_pump_plans[key] = plan
|
|
||||||
|
|
||||||
self.active_generic_plans.clear()
|
|
||||||
for plan in generic_plans:
|
|
||||||
key = (plan['station_id'], plan['resource_id'])
|
|
||||||
self.active_generic_plans[key] = plan
|
|
||||||
|
|
||||||
# Notify callbacks of updates
|
|
||||||
if pump_updates or generic_updates:
|
|
||||||
await self._notify_plan_updates(pump_updates, generic_updates)
|
|
||||||
|
|
||||||
self.last_refresh = datetime.now()
|
|
||||||
logger.info(
|
|
||||||
"optimization_plans_refreshed",
|
|
||||||
pump_plans=len(pump_plans),
|
|
||||||
generic_plans=len(generic_plans),
|
|
||||||
updates_detected=len(pump_updates) + len(generic_updates)
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("optimization_plans_refresh_failed", error=str(e))
|
|
||||||
|
|
||||||
def _detect_pump_plan_updates(self, new_plans: List[Dict]) -> List[Dict]:
|
|
||||||
"""Detect pump plan updates by comparing with current cache."""
|
|
||||||
updates = []
|
|
||||||
|
|
||||||
for plan in new_plans:
|
|
||||||
key = (plan['station_id'], plan['pump_id'])
|
|
||||||
current_plan = self.active_pump_plans.get(key)
|
|
||||||
|
|
||||||
if not current_plan:
|
|
||||||
# New plan
|
|
||||||
updates.append({
|
|
||||||
'type': 'NEW_PUMP_PLAN',
|
|
||||||
'station_id': plan['station_id'],
|
|
||||||
'pump_id': plan['pump_id'],
|
|
||||||
'plan': plan
|
|
||||||
})
|
|
||||||
elif current_plan['plan_version'] < plan['plan_version']:
|
|
||||||
# Version update
|
|
||||||
updates.append({
|
|
||||||
'type': 'PUMP_PLAN_UPDATE',
|
|
||||||
'station_id': plan['station_id'],
|
|
||||||
'pump_id': plan['pump_id'],
|
|
||||||
'old_version': current_plan['plan_version'],
|
|
||||||
'new_version': plan['plan_version'],
|
|
||||||
'plan': plan
|
|
||||||
})
|
|
||||||
|
|
||||||
return updates
|
|
||||||
|
|
||||||
def _detect_generic_plan_updates(self, new_plans: List[Dict]) -> List[Dict]:
|
|
||||||
"""Detect generic plan updates by comparing with current cache."""
|
|
||||||
updates = []
|
|
||||||
|
|
||||||
for plan in new_plans:
|
|
||||||
key = (plan['station_id'], plan['resource_id'])
|
|
||||||
current_plan = self.active_generic_plans.get(key)
|
|
||||||
|
|
||||||
if not current_plan:
|
|
||||||
# New plan
|
|
||||||
updates.append({
|
|
||||||
'type': 'NEW_GENERIC_PLAN',
|
|
||||||
'station_id': plan['station_id'],
|
|
||||||
'resource_id': plan['resource_id'],
|
|
||||||
'resource_type': plan['resource_type'],
|
|
||||||
'plan': plan
|
|
||||||
})
|
|
||||||
elif current_plan['plan_version'] < plan['plan_version']:
|
|
||||||
# Version update
|
|
||||||
updates.append({
|
|
||||||
'type': 'GENERIC_PLAN_UPDATE',
|
|
||||||
'station_id': plan['station_id'],
|
|
||||||
'resource_id': plan['resource_id'],
|
|
||||||
'resource_type': plan['resource_type'],
|
|
||||||
'old_version': current_plan['plan_version'],
|
|
||||||
'new_version': plan['plan_version'],
|
|
||||||
'plan': plan
|
|
||||||
})
|
|
||||||
|
|
||||||
return updates
|
|
||||||
|
|
||||||
async def _notify_plan_updates(self, pump_updates: List[Dict], generic_updates: List[Dict]):
|
|
||||||
"""Notify registered callbacks of plan updates."""
|
|
||||||
all_updates = pump_updates + generic_updates
|
|
||||||
|
|
||||||
for callback in self.plan_update_callbacks:
|
|
||||||
try:
|
|
||||||
await callback(all_updates)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("plan_update_callback_failed", callback=str(callback), error=str(e))
|
|
||||||
|
|
||||||
def register_plan_update_callback(self, callback: Callable):
|
|
||||||
"""Register a callback for plan update notifications."""
|
|
||||||
self.plan_update_callbacks.append(callback)
|
|
||||||
logger.info("plan_update_callback_registered", callback=str(callback))
|
|
||||||
|
|
||||||
def get_current_pump_plan(self, station_id: str, pump_id: str) -> Optional[Dict]:
|
|
||||||
"""Get current optimization plan for a specific pump."""
|
|
||||||
key = (station_id, pump_id)
|
|
||||||
return self.active_pump_plans.get(key)
|
|
||||||
|
|
||||||
def get_current_generic_plan(self, station_id: str, resource_id: str) -> Optional[Dict]:
|
|
||||||
"""Get current optimization plan for a specific resource."""
|
|
||||||
key = (station_id, resource_id)
|
|
||||||
return self.active_generic_plans.get(key)
|
|
||||||
|
|
||||||
def get_all_pump_plans(self) -> List[Dict]:
|
|
||||||
"""Get all current pump optimization plans."""
|
|
||||||
return list(self.active_pump_plans.values())
|
|
||||||
|
|
||||||
def get_all_generic_plans(self) -> List[Dict]:
|
|
||||||
"""Get all current generic optimization plans."""
|
|
||||||
return list(self.active_generic_plans.values())
|
|
||||||
|
|
||||||
def get_plan_history(self, station_id: str, resource_id: str, resource_type: str = 'PUMP') -> List[Dict]:
|
|
||||||
"""Get plan history for a specific resource."""
|
|
||||||
if resource_type == 'PUMP':
|
|
||||||
return self.db_client.get_pump_plan_history(station_id, resource_id)
|
|
||||||
else:
|
|
||||||
# For now, only pump history is implemented
|
|
||||||
logger.warning("plan_history_not_implemented", resource_type=resource_type)
|
|
||||||
return []
|
|
||||||
|
|
||||||
def update_pump_plan(self, plan_id: int, updates: Dict[str, Any]) -> bool:
|
|
||||||
"""Update a pump plan with version increment."""
|
|
||||||
return self.db_client.update_pump_plan(plan_id, updates)
|
|
||||||
|
|
||||||
def get_status(self) -> Dict[str, Any]:
|
|
||||||
"""Get optimization manager status."""
|
|
||||||
return {
|
|
||||||
'running': self.running,
|
|
||||||
'last_refresh': self.last_refresh.isoformat() if self.last_refresh else None,
|
|
||||||
'refresh_interval_seconds': self.refresh_interval_seconds,
|
|
||||||
'active_pump_plans': len(self.active_pump_plans),
|
|
||||||
'active_generic_plans': len(self.active_generic_plans),
|
|
||||||
'registered_callbacks': len(self.plan_update_callbacks)
|
|
||||||
}
|
|
||||||
|
|
@ -10,7 +10,6 @@ from dataclasses import dataclass
|
||||||
import structlog
|
import structlog
|
||||||
|
|
||||||
from src.database.client import DatabaseClient
|
from src.database.client import DatabaseClient
|
||||||
from src.core.emergency_stop import EmergencyStopManager
|
|
||||||
|
|
||||||
logger = structlog.get_logger()
|
logger = structlog.get_logger()
|
||||||
|
|
||||||
|
|
@ -39,9 +38,8 @@ class SafetyLimitEnforcer:
|
||||||
- Layer 3: Optimization Constraints (Calejo Optimize) - 25-45 Hz
|
- Layer 3: Optimization Constraints (Calejo Optimize) - 25-45 Hz
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, db_client: DatabaseClient, emergency_stop_manager: EmergencyStopManager = None):
|
def __init__(self, db_client: DatabaseClient):
|
||||||
self.db_client = db_client
|
self.db_client = db_client
|
||||||
self.emergency_stop_manager = emergency_stop_manager
|
|
||||||
self.safety_limits_cache: Dict[Tuple[str, str], SafetyLimits] = {}
|
self.safety_limits_cache: Dict[Tuple[str, str], SafetyLimits] = {}
|
||||||
self.previous_setpoints: Dict[Tuple[str, str], float] = {}
|
self.previous_setpoints: Dict[Tuple[str, str], float] = {}
|
||||||
|
|
||||||
|
|
@ -88,12 +86,6 @@ class SafetyLimitEnforcer:
|
||||||
violations = []
|
violations = []
|
||||||
enforced_setpoint = setpoint
|
enforced_setpoint = setpoint
|
||||||
|
|
||||||
# Check emergency stop first (highest priority)
|
|
||||||
if self.emergency_stop_manager and self.emergency_stop_manager.is_emergency_stop_active(station_id, pump_id):
|
|
||||||
violations.append("EMERGENCY_STOP_ACTIVE")
|
|
||||||
# Emergency stop overrides everything - set to 0 Hz
|
|
||||||
return (0.0, violations)
|
|
||||||
|
|
||||||
# Get safety limits
|
# Get safety limits
|
||||||
key = (station_id, pump_id)
|
key = (station_id, pump_id)
|
||||||
limits = self.safety_limits_cache.get(key)
|
limits = self.safety_limits_cache.get(key)
|
||||||
|
|
@ -154,31 +146,6 @@ class SafetyLimitEnforcer:
|
||||||
|
|
||||||
return (enforced_setpoint, violations)
|
return (enforced_setpoint, violations)
|
||||||
|
|
||||||
def get_safety_limits(self, station_id: str, pump_id: str) -> Optional[SafetyLimits]:
|
|
||||||
"""Get safety limits for a specific pump."""
|
|
||||||
key = (station_id, pump_id)
|
|
||||||
return self.safety_limits_cache.get(key)
|
|
||||||
|
|
||||||
def has_safety_limits(self, station_id: str, pump_id: str) -> bool:
|
|
||||||
"""Check if safety limits exist for a specific pump."""
|
|
||||||
key = (station_id, pump_id)
|
|
||||||
return key in self.safety_limits_cache
|
|
||||||
|
|
||||||
def clear_safety_limits(self, station_id: str, pump_id: str):
|
|
||||||
"""Clear safety limits for a specific pump."""
|
|
||||||
key = (station_id, pump_id)
|
|
||||||
self.safety_limits_cache.pop(key, None)
|
|
||||||
self.previous_setpoints.pop(key, None)
|
|
||||||
|
|
||||||
def clear_all_safety_limits(self):
|
|
||||||
"""Clear all safety limits."""
|
|
||||||
self.safety_limits_cache.clear()
|
|
||||||
self.previous_setpoints.clear()
|
|
||||||
|
|
||||||
def get_loaded_limits_count(self) -> int:
|
|
||||||
"""Get the number of loaded safety limits."""
|
|
||||||
return len(self.safety_limits_cache)
|
|
||||||
|
|
||||||
def _record_violation(
|
def _record_violation(
|
||||||
self,
|
self,
|
||||||
station_id: str,
|
station_id: str,
|
||||||
|
|
|
||||||
|
|
@ -147,103 +147,13 @@ class DatabaseClient:
|
||||||
query = """
|
query = """
|
||||||
SELECT DISTINCT ON (station_id, pump_id)
|
SELECT DISTINCT ON (station_id, pump_id)
|
||||||
station_id, pump_id, target_flow_m3h, target_power_kw,
|
station_id, pump_id, target_flow_m3h, target_power_kw,
|
||||||
target_level_m, suggested_speed_hz, interval_start, interval_end,
|
target_level_m, suggested_speed_hz, interval_start, interval_end
|
||||||
plan_version, plan_created_at, plan_updated_at
|
|
||||||
FROM pump_plans
|
FROM pump_plans
|
||||||
WHERE interval_start <= NOW() AND interval_end >= NOW()
|
WHERE interval_start <= NOW() AND interval_end >= NOW()
|
||||||
AND plan_status = 'ACTIVE'
|
ORDER BY station_id, pump_id, plan_created_at DESC
|
||||||
ORDER BY station_id, pump_id, plan_version DESC
|
|
||||||
"""
|
"""
|
||||||
return self.execute_query(query)
|
return self.execute_query(query)
|
||||||
|
|
||||||
def get_pump_plan_history(self, station_id: str, pump_id: str, limit: int = 10) -> List[Dict[str, Any]]:
|
|
||||||
"""Get plan history for a specific pump."""
|
|
||||||
query = """
|
|
||||||
SELECT plan_id, target_flow_m3h, target_power_kw, target_level_m,
|
|
||||||
suggested_speed_hz, interval_start, interval_end, plan_version,
|
|
||||||
plan_status, plan_created_at, plan_updated_at, optimization_run_id
|
|
||||||
FROM pump_plans
|
|
||||||
WHERE station_id = %s AND pump_id = %s
|
|
||||||
ORDER BY plan_version DESC
|
|
||||||
LIMIT %s
|
|
||||||
"""
|
|
||||||
return self.execute_query(query, (station_id, pump_id, limit))
|
|
||||||
|
|
||||||
def update_pump_plan(self, plan_id: int, updates: Dict[str, Any]) -> bool:
|
|
||||||
"""Update an existing pump plan with version increment."""
|
|
||||||
try:
|
|
||||||
# Get current plan
|
|
||||||
current_query = """
|
|
||||||
SELECT plan_version, station_id, pump_id, interval_start, interval_end
|
|
||||||
FROM pump_plans
|
|
||||||
WHERE plan_id = %s
|
|
||||||
"""
|
|
||||||
current_plan = self.execute_query(current_query, (plan_id,))
|
|
||||||
|
|
||||||
if not current_plan:
|
|
||||||
logger.error("plan_not_found", plan_id=plan_id)
|
|
||||||
return False
|
|
||||||
|
|
||||||
current = current_plan[0]
|
|
||||||
new_version = current['plan_version'] + 1
|
|
||||||
|
|
||||||
# Update existing plan to SUPERSEDED status
|
|
||||||
supersede_query = """
|
|
||||||
UPDATE pump_plans
|
|
||||||
SET plan_status = 'SUPERSEDED',
|
|
||||||
superseded_by = %s,
|
|
||||||
plan_updated_at = NOW()
|
|
||||||
WHERE plan_id = %s
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Create new version of the plan
|
|
||||||
insert_query = """
|
|
||||||
INSERT INTO pump_plans (
|
|
||||||
station_id, pump_id, interval_start, interval_end,
|
|
||||||
target_flow_m3h, target_power_kw, target_level_m, suggested_speed_hz,
|
|
||||||
optimization_run_id, plan_version, plan_status, superseded_by
|
|
||||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'ACTIVE', %s)
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Execute in transaction
|
|
||||||
with self._get_cursor() as cursor:
|
|
||||||
# Mark old plan as superseded
|
|
||||||
cursor.execute(supersede_query, (plan_id, plan_id))
|
|
||||||
|
|
||||||
# Insert new version
|
|
||||||
cursor.execute(insert_query, (
|
|
||||||
current['station_id'], current['pump_id'],
|
|
||||||
current['interval_start'], current['interval_end'],
|
|
||||||
updates.get('target_flow_m3h'),
|
|
||||||
updates.get('target_power_kw'),
|
|
||||||
updates.get('target_level_m'),
|
|
||||||
updates.get('suggested_speed_hz'),
|
|
||||||
updates.get('optimization_run_id'),
|
|
||||||
new_version,
|
|
||||||
plan_id
|
|
||||||
))
|
|
||||||
|
|
||||||
logger.info("pump_plan_updated", plan_id=plan_id, new_version=new_version)
|
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("pump_plan_update_failed", plan_id=plan_id, error=str(e))
|
|
||||||
return False
|
|
||||||
|
|
||||||
def get_generic_optimization_plans(self, resource_type: str = 'PUMP') -> List[Dict[str, Any]]:
|
|
||||||
"""Get latest optimization plans for all resource types."""
|
|
||||||
query = """
|
|
||||||
SELECT DISTINCT ON (station_id, resource_id)
|
|
||||||
station_id, resource_id, resource_type, interval_start, interval_end,
|
|
||||||
optimization_targets, plan_version, plan_created_at, plan_updated_at
|
|
||||||
FROM optimization_plans
|
|
||||||
WHERE interval_start <= NOW() AND interval_end >= NOW()
|
|
||||||
AND plan_status = 'ACTIVE'
|
|
||||||
AND resource_type = %s
|
|
||||||
ORDER BY station_id, resource_id, plan_version DESC
|
|
||||||
"""
|
|
||||||
return self.execute_query(query, (resource_type,))
|
|
||||||
|
|
||||||
def get_pump_feedback(self, station_id: str, pump_id: str, limit: int = 10) -> List[Dict[str, Any]]:
|
def get_pump_feedback(self, station_id: str, pump_id: str, limit: int = 10) -> List[Dict[str, Any]]:
|
||||||
"""Get recent feedback for a specific pump."""
|
"""Get recent feedback for a specific pump."""
|
||||||
query = """
|
query = """
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,6 @@ import sys
|
||||||
from src.database.client import DatabaseClient
|
from src.database.client import DatabaseClient
|
||||||
from src.core.auto_discovery import AutoDiscovery
|
from src.core.auto_discovery import AutoDiscovery
|
||||||
from src.core.safety import SafetyLimitEnforcer
|
from src.core.safety import SafetyLimitEnforcer
|
||||||
from src.core.emergency_stop import EmergencyStopManager
|
|
||||||
from src.core.optimization_manager import OptimizationPlanManager
|
|
||||||
from src.core.logging import setup_logging, AuditLogger
|
from src.core.logging import setup_logging, AuditLogger
|
||||||
from config.settings import settings
|
from config.settings import settings
|
||||||
|
|
||||||
|
|
@ -39,12 +37,7 @@ class CalejoControlAdapterPhase1:
|
||||||
db_client=self.db_client,
|
db_client=self.db_client,
|
||||||
refresh_interval_minutes=settings.auto_discovery_refresh_minutes
|
refresh_interval_minutes=settings.auto_discovery_refresh_minutes
|
||||||
)
|
)
|
||||||
self.emergency_stop_manager = EmergencyStopManager(self.db_client)
|
self.safety_enforcer = SafetyLimitEnforcer(self.db_client)
|
||||||
self.safety_enforcer = SafetyLimitEnforcer(self.db_client, self.emergency_stop_manager)
|
|
||||||
self.optimization_manager = OptimizationPlanManager(
|
|
||||||
db_client=self.db_client,
|
|
||||||
refresh_interval_seconds=settings.optimization_refresh_seconds
|
|
||||||
)
|
|
||||||
self.audit_logger = AuditLogger(self.db_client)
|
self.audit_logger = AuditLogger(self.db_client)
|
||||||
|
|
||||||
self.running = False
|
self.running = False
|
||||||
|
|
@ -83,10 +76,6 @@ class CalejoControlAdapterPhase1:
|
||||||
# Initialize safety framework
|
# Initialize safety framework
|
||||||
await self.safety_enforcer.load_safety_limits()
|
await self.safety_enforcer.load_safety_limits()
|
||||||
|
|
||||||
# Initialize optimization plan monitoring
|
|
||||||
if settings.optimization_monitoring_enabled:
|
|
||||||
await self.optimization_manager.start_monitoring()
|
|
||||||
|
|
||||||
# Log startup to audit trail
|
# Log startup to audit trail
|
||||||
self.audit_logger.log(
|
self.audit_logger.log(
|
||||||
event_type='SYSTEM_STARTUP',
|
event_type='SYSTEM_STARTUP',
|
||||||
|
|
@ -98,7 +87,7 @@ class CalejoControlAdapterPhase1:
|
||||||
event_data={
|
event_data={
|
||||||
'version': settings.app_version,
|
'version': settings.app_version,
|
||||||
'phase': '1',
|
'phase': '1',
|
||||||
'components_initialized': ['database', 'auto_discovery', 'safety', 'optimization']
|
'components_initialized': ['database', 'auto_discovery', 'safety']
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -110,8 +99,7 @@ class CalejoControlAdapterPhase1:
|
||||||
startup_duration_seconds=round(startup_duration, 2),
|
startup_duration_seconds=round(startup_duration, 2),
|
||||||
station_count=len(self.auto_discovery.get_stations()),
|
station_count=len(self.auto_discovery.get_stations()),
|
||||||
pump_count=len(self.auto_discovery.get_pumps()),
|
pump_count=len(self.auto_discovery.get_pumps()),
|
||||||
safety_limits_loaded=len(self.safety_enforcer.safety_limits_cache),
|
safety_limits_loaded=len(self.safety_enforcer.safety_limits_cache)
|
||||||
optimization_plans_loaded=len(self.optimization_manager.get_all_pump_plans())
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Print status information
|
# Print status information
|
||||||
|
|
@ -141,9 +129,6 @@ class CalejoControlAdapterPhase1:
|
||||||
result='SUCCESS'
|
result='SUCCESS'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Stop optimization monitoring
|
|
||||||
await self.optimization_manager.stop_monitoring()
|
|
||||||
|
|
||||||
# Disconnect from database
|
# Disconnect from database
|
||||||
await self.db_client.disconnect()
|
await self.db_client.disconnect()
|
||||||
|
|
||||||
|
|
@ -158,8 +143,7 @@ class CalejoControlAdapterPhase1:
|
||||||
"environment": settings.environment,
|
"environment": settings.environment,
|
||||||
"database": self.db_client.get_connection_stats(),
|
"database": self.db_client.get_connection_stats(),
|
||||||
"auto_discovery": self.auto_discovery.get_discovery_status(),
|
"auto_discovery": self.auto_discovery.get_discovery_status(),
|
||||||
"safety_limits_loaded": len(self.safety_enforcer.safety_limits_cache),
|
"safety_limits_loaded": len(self.safety_enforcer.safety_limits_cache)
|
||||||
"optimization_manager": self.optimization_manager.get_status()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def print_status(self):
|
def print_status(self):
|
||||||
|
|
@ -191,14 +175,6 @@ class CalejoControlAdapterPhase1:
|
||||||
print(f"\nSafety Framework:")
|
print(f"\nSafety Framework:")
|
||||||
print(f" Limits Loaded: {status['safety_limits_loaded']}")
|
print(f" Limits Loaded: {status['safety_limits_loaded']}")
|
||||||
|
|
||||||
# Optimization manager status
|
|
||||||
opt_status = status['optimization_manager']
|
|
||||||
print(f"\nOptimization Manager:")
|
|
||||||
print(f" Status: {'RUNNING' if opt_status['running'] else 'STOPPED'}")
|
|
||||||
print(f" Pump Plans: {opt_status['active_pump_plans']}")
|
|
||||||
print(f" Generic Plans: {opt_status['active_generic_plans']}")
|
|
||||||
print(f" Last Refresh: {opt_status['last_refresh'] or 'Never'}")
|
|
||||||
|
|
||||||
print("="*60)
|
print("="*60)
|
||||||
print("\nPress Ctrl+C to stop the application\n")
|
print("\nPress Ctrl+C to stop the application\n")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,286 +0,0 @@
|
||||||
"""
|
|
||||||
Alert Manager for Calejo Control Adapter.
|
|
||||||
|
|
||||||
Manages multi-channel alert delivery including email, SMS, webhook,
|
|
||||||
and SCADA alarm integration for safety events and system issues.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import smtplib
|
|
||||||
import json
|
|
||||||
from email.mime.text import MIMEText
|
|
||||||
from email.mime.multipart import MIMEMultipart
|
|
||||||
from typing import Dict, List, Optional, Any
|
|
||||||
import structlog
|
|
||||||
import aiohttp
|
|
||||||
|
|
||||||
from config.settings import Settings
|
|
||||||
|
|
||||||
logger = structlog.get_logger()
|
|
||||||
|
|
||||||
|
|
||||||
class AlertManager:
|
|
||||||
"""
|
|
||||||
Manages multi-channel alert delivery for safety events and system issues.
|
|
||||||
|
|
||||||
Supports:
|
|
||||||
- Email alerts with configurable recipients
|
|
||||||
- SMS alerts for critical events
|
|
||||||
- Webhook integration for external systems
|
|
||||||
- SCADA HMI alarm integration via OPC UA
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, settings: Settings):
|
|
||||||
self.settings = settings
|
|
||||||
self.alert_history: List[Dict] = []
|
|
||||||
self.max_history_size = 1000
|
|
||||||
|
|
||||||
async def send_alert(
|
|
||||||
self,
|
|
||||||
alert_type: str,
|
|
||||||
severity: str,
|
|
||||||
message: str,
|
|
||||||
context: Optional[Dict[str, Any]] = None,
|
|
||||||
station_id: Optional[str] = None,
|
|
||||||
pump_id: Optional[str] = None
|
|
||||||
) -> bool:
|
|
||||||
"""
|
|
||||||
Send alert through all configured channels.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
alert_type: Type of alert (e.g., 'SAFETY_VIOLATION', 'FAILSAFE_ACTIVATED')
|
|
||||||
severity: Severity level ('INFO', 'WARNING', 'ERROR', 'CRITICAL')
|
|
||||||
message: Human-readable alert message
|
|
||||||
context: Additional context data
|
|
||||||
station_id: Optional station identifier
|
|
||||||
pump_id: Optional pump identifier
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if alert was sent successfully through at least one channel
|
|
||||||
"""
|
|
||||||
context = context or {}
|
|
||||||
|
|
||||||
# Create alert data
|
|
||||||
alert_data = {
|
|
||||||
'alert_type': alert_type,
|
|
||||||
'severity': severity,
|
|
||||||
'message': message,
|
|
||||||
'context': context,
|
|
||||||
'station_id': station_id,
|
|
||||||
'pump_id': pump_id,
|
|
||||||
'timestamp': asyncio.get_event_loop().time(),
|
|
||||||
'app_name': self.settings.app_name,
|
|
||||||
'app_version': self.settings.app_version
|
|
||||||
}
|
|
||||||
|
|
||||||
# Store in history
|
|
||||||
self._store_alert_history(alert_data)
|
|
||||||
|
|
||||||
# Send through all configured channels
|
|
||||||
results = await asyncio.gather(
|
|
||||||
self._send_email_alert(alert_data),
|
|
||||||
self._send_sms_alert(alert_data),
|
|
||||||
self._send_webhook_alert(alert_data),
|
|
||||||
self._send_scada_alert(alert_data),
|
|
||||||
return_exceptions=True
|
|
||||||
)
|
|
||||||
|
|
||||||
# Log alert delivery
|
|
||||||
successful_channels = [
|
|
||||||
channel for channel, result in zip(['email', 'sms', 'webhook', 'scada'], results)
|
|
||||||
if result is True
|
|
||||||
]
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"alert_sent",
|
|
||||||
alert_type=alert_type,
|
|
||||||
severity=severity,
|
|
||||||
station_id=station_id,
|
|
||||||
pump_id=pump_id,
|
|
||||||
successful_channels=successful_channels,
|
|
||||||
total_channels=len(results)
|
|
||||||
)
|
|
||||||
|
|
||||||
return len(successful_channels) > 0
|
|
||||||
|
|
||||||
async def _send_email_alert(self, alert_data: Dict) -> bool:
|
|
||||||
"""Send alert via email."""
|
|
||||||
if not self.settings.alert_email_enabled:
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Create email message
|
|
||||||
msg = MIMEMultipart()
|
|
||||||
msg['From'] = self.settings.alert_email_from
|
|
||||||
msg['To'] = ', '.join(self.settings.alert_email_recipients)
|
|
||||||
msg['Subject'] = f"[{alert_data['severity']}] {self.settings.app_name} - {alert_data['alert_type']}"
|
|
||||||
|
|
||||||
# Create email body
|
|
||||||
body = self._format_email_body(alert_data)
|
|
||||||
msg.attach(MIMEText(body, 'plain'))
|
|
||||||
|
|
||||||
# Send email
|
|
||||||
with smtplib.SMTP(self.settings.smtp_host, self.settings.smtp_port) as server:
|
|
||||||
if self.settings.smtp_use_tls:
|
|
||||||
server.starttls()
|
|
||||||
|
|
||||||
if self.settings.smtp_username and self.settings.smtp_password:
|
|
||||||
server.login(self.settings.smtp_username, self.settings.smtp_password)
|
|
||||||
|
|
||||||
server.send_message(msg)
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("email_alert_failed", error=str(e))
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def _send_sms_alert(self, alert_data: Dict) -> bool:
|
|
||||||
"""Send alert via SMS."""
|
|
||||||
if not self.settings.alert_sms_enabled:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Only send SMS for critical alerts
|
|
||||||
if alert_data['severity'] not in ['ERROR', 'CRITICAL']:
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
# For now, log SMS alert (Twilio integration would go here)
|
|
||||||
logger.info(
|
|
||||||
"sms_alert_ready",
|
|
||||||
alert_type=alert_data['alert_type'],
|
|
||||||
severity=alert_data['severity'],
|
|
||||||
recipients=self.settings.alert_sms_recipients,
|
|
||||||
message=alert_data['message']
|
|
||||||
)
|
|
||||||
|
|
||||||
# TODO: Implement actual SMS delivery via Twilio or similar service
|
|
||||||
# This would require actual API credentials and billing setup
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("sms_alert_failed", error=str(e))
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def _send_webhook_alert(self, alert_data: Dict) -> bool:
|
|
||||||
"""Send alert via webhook."""
|
|
||||||
if not self.settings.alert_webhook_enabled:
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with aiohttp.ClientSession() as session:
|
|
||||||
headers = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': f'Bearer {self.settings.alert_webhook_token}'
|
|
||||||
}
|
|
||||||
|
|
||||||
async with session.post(
|
|
||||||
self.settings.alert_webhook_url,
|
|
||||||
json=alert_data,
|
|
||||||
headers=headers,
|
|
||||||
timeout=30
|
|
||||||
) as response:
|
|
||||||
|
|
||||||
if response.status == 200:
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
logger.error(
|
|
||||||
"webhook_alert_failed",
|
|
||||||
status_code=response.status,
|
|
||||||
response_text=await response.text()
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("webhook_alert_failed", error=str(e))
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def _send_scada_alert(self, alert_data: Dict) -> bool:
|
|
||||||
"""Send alert to SCADA system via OPC UA."""
|
|
||||||
if not self.settings.alert_scada_enabled:
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
# For now, log SCADA alert (OPC UA integration would go here)
|
|
||||||
logger.info(
|
|
||||||
"scada_alert_ready",
|
|
||||||
alert_type=alert_data['alert_type'],
|
|
||||||
severity=alert_data['severity'],
|
|
||||||
station_id=alert_data.get('station_id'),
|
|
||||||
pump_id=alert_data.get('pump_id')
|
|
||||||
)
|
|
||||||
|
|
||||||
# TODO: Implement actual OPC UA alarm integration
|
|
||||||
# This will be implemented in Phase 4 with the OPC UA server
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("scada_alert_failed", error=str(e))
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _format_email_body(self, alert_data: Dict) -> str:
|
|
||||||
"""Format email body for alert."""
|
|
||||||
body = f"""
|
|
||||||
Calejo Control Adapter Alert
|
|
||||||
============================
|
|
||||||
|
|
||||||
Alert Type: {alert_data['alert_type']}
|
|
||||||
Severity: {alert_data['severity']}
|
|
||||||
Timestamp: {alert_data['timestamp']}
|
|
||||||
|
|
||||||
Message: {alert_data['message']}
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
if alert_data.get('station_id'):
|
|
||||||
body += f"Station ID: {alert_data['station_id']}\n"
|
|
||||||
|
|
||||||
if alert_data.get('pump_id'):
|
|
||||||
body += f"Pump ID: {alert_data['pump_id']}\n"
|
|
||||||
|
|
||||||
if alert_data.get('context'):
|
|
||||||
body += "\nContext:\n"
|
|
||||||
for key, value in alert_data['context'].items():
|
|
||||||
body += f" {key}: {value}\n"
|
|
||||||
|
|
||||||
body += f"\nApplication: {alert_data['app_name']} v{alert_data['app_version']}\n"
|
|
||||||
|
|
||||||
return body
|
|
||||||
|
|
||||||
def _store_alert_history(self, alert_data: Dict):
|
|
||||||
"""Store alert in history with size limit."""
|
|
||||||
self.alert_history.append(alert_data)
|
|
||||||
|
|
||||||
# Maintain history size limit
|
|
||||||
if len(self.alert_history) > self.max_history_size:
|
|
||||||
self.alert_history = self.alert_history[-self.max_history_size:]
|
|
||||||
|
|
||||||
def get_alert_history(self, limit: int = 50) -> List[Dict]:
|
|
||||||
"""Get recent alert history."""
|
|
||||||
return self.alert_history[-limit:]
|
|
||||||
|
|
||||||
def get_alert_stats(self) -> Dict[str, Any]:
|
|
||||||
"""Get alert statistics."""
|
|
||||||
severity_counts = {}
|
|
||||||
type_counts = {}
|
|
||||||
|
|
||||||
for alert in self.alert_history:
|
|
||||||
severity = alert['severity']
|
|
||||||
alert_type = alert['alert_type']
|
|
||||||
|
|
||||||
severity_counts[severity] = severity_counts.get(severity, 0) + 1
|
|
||||||
type_counts[alert_type] = type_counts.get(alert_type, 0) + 1
|
|
||||||
|
|
||||||
return {
|
|
||||||
'total_alerts': len(self.alert_history),
|
|
||||||
'severity_counts': severity_counts,
|
|
||||||
'type_counts': type_counts,
|
|
||||||
'channels_enabled': {
|
|
||||||
'email': self.settings.alert_email_enabled,
|
|
||||||
'sms': self.settings.alert_sms_enabled,
|
|
||||||
'webhook': self.settings.alert_webhook_enabled,
|
|
||||||
'scada': self.settings.alert_scada_enabled
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,228 +0,0 @@
|
||||||
"""
|
|
||||||
Database Watchdog for Calejo Control Adapter.
|
|
||||||
|
|
||||||
Monitors database updates and triggers failsafe mode when updates stop,
|
|
||||||
preventing stale optimization plans from controlling pumps indefinitely.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import structlog
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from typing import Dict, Optional, Any
|
|
||||||
|
|
||||||
from src.database.client import DatabaseClient
|
|
||||||
|
|
||||||
logger = structlog.get_logger()
|
|
||||||
|
|
||||||
|
|
||||||
class DatabaseWatchdog:
|
|
||||||
"""
|
|
||||||
Monitors database updates and triggers failsafe mode when updates stop.
|
|
||||||
|
|
||||||
Safety Feature: If optimization system stops updating plans for more than
|
|
||||||
20 minutes, automatically revert to default safe setpoints to prevent
|
|
||||||
pumps from running on stale optimization plans.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, db_client: DatabaseClient, timeout_seconds: int = 1200): # 20 minutes default
|
|
||||||
self.db_client = db_client
|
|
||||||
self.timeout_seconds = timeout_seconds
|
|
||||||
self.last_update_times: Dict[tuple, datetime] = {} # (station_id, pump_id) -> last_update
|
|
||||||
self.failsafe_active: Dict[tuple, bool] = {}
|
|
||||||
self.running = False
|
|
||||||
self.check_interval_seconds = 60 # Check every minute
|
|
||||||
|
|
||||||
async def start(self):
|
|
||||||
"""Start the watchdog monitoring."""
|
|
||||||
self.running = True
|
|
||||||
logger.info("database_watchdog_started", timeout_seconds=self.timeout_seconds)
|
|
||||||
|
|
||||||
# Initial check
|
|
||||||
await self._check_updates()
|
|
||||||
|
|
||||||
# Start periodic monitoring
|
|
||||||
asyncio.create_task(self._monitor_loop())
|
|
||||||
|
|
||||||
async def stop(self):
|
|
||||||
"""Stop the watchdog monitoring."""
|
|
||||||
self.running = False
|
|
||||||
logger.info("database_watchdog_stopped")
|
|
||||||
|
|
||||||
async def _monitor_loop(self):
|
|
||||||
"""Main monitoring loop."""
|
|
||||||
while self.running:
|
|
||||||
try:
|
|
||||||
await asyncio.sleep(self.check_interval_seconds)
|
|
||||||
await self._check_updates()
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("watchdog_monitor_loop_error", error=str(e))
|
|
||||||
|
|
||||||
async def _check_updates(self):
|
|
||||||
"""Check for recent updates and trigger failsafe if needed."""
|
|
||||||
try:
|
|
||||||
# Get latest pump plans to check for recent updates
|
|
||||||
latest_plans = self.db_client.get_latest_pump_plans()
|
|
||||||
|
|
||||||
current_time = datetime.now()
|
|
||||||
|
|
||||||
for plan in latest_plans:
|
|
||||||
key = (plan['station_id'], plan['pump_id'])
|
|
||||||
plan_updated_at = plan.get('plan_updated_at') or plan.get('plan_created_at')
|
|
||||||
|
|
||||||
if plan_updated_at:
|
|
||||||
# Update last known update time
|
|
||||||
self.last_update_times[key] = plan_updated_at
|
|
||||||
|
|
||||||
# Check if failsafe should be deactivated
|
|
||||||
if self.failsafe_active.get(key, False):
|
|
||||||
# Recent update detected - deactivate failsafe
|
|
||||||
await self._deactivate_failsafe(plan['station_id'], plan['pump_id'])
|
|
||||||
else:
|
|
||||||
# No update time available - treat as no recent update
|
|
||||||
self.last_update_times[key] = current_time - timedelta(seconds=self.timeout_seconds + 1)
|
|
||||||
|
|
||||||
# Check for stale updates
|
|
||||||
for key, last_update in self.last_update_times.items():
|
|
||||||
station_id, pump_id = key
|
|
||||||
time_since_update = (current_time - last_update).total_seconds()
|
|
||||||
|
|
||||||
if time_since_update > self.timeout_seconds and not self.failsafe_active.get(key, False):
|
|
||||||
# Trigger failsafe mode
|
|
||||||
await self._activate_failsafe(station_id, pump_id, time_since_update)
|
|
||||||
|
|
||||||
# Log status for monitoring
|
|
||||||
if time_since_update > self.timeout_seconds * 0.8: # 80% of timeout
|
|
||||||
logger.warning(
|
|
||||||
"watchdog_update_stale",
|
|
||||||
station_id=station_id,
|
|
||||||
pump_id=pump_id,
|
|
||||||
seconds_since_update=time_since_update,
|
|
||||||
timeout_seconds=self.timeout_seconds
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("watchdog_check_updates_failed", error=str(e))
|
|
||||||
|
|
||||||
async def _activate_failsafe(self, station_id: str, pump_id: str, time_since_update: float):
|
|
||||||
"""Activate failsafe mode for a pump."""
|
|
||||||
try:
|
|
||||||
key = (station_id, pump_id)
|
|
||||||
self.failsafe_active[key] = True
|
|
||||||
|
|
||||||
# Get default setpoint from pump configuration
|
|
||||||
pump_config = self.db_client.get_pump(station_id, pump_id)
|
|
||||||
if pump_config:
|
|
||||||
default_setpoint = pump_config.get('default_setpoint_hz', 30.0)
|
|
||||||
|
|
||||||
# Log failsafe activation
|
|
||||||
logger.critical(
|
|
||||||
"failsafe_mode_activated",
|
|
||||||
station_id=station_id,
|
|
||||||
pump_id=pump_id,
|
|
||||||
time_since_update_seconds=time_since_update,
|
|
||||||
default_setpoint_hz=default_setpoint
|
|
||||||
)
|
|
||||||
|
|
||||||
# Record failsafe event in database
|
|
||||||
self._record_failsafe_event(station_id, pump_id, default_setpoint)
|
|
||||||
|
|
||||||
# TODO: In Phase 3, this will trigger the SetpointManager to use default setpoints
|
|
||||||
# For now, we just log the event
|
|
||||||
|
|
||||||
else:
|
|
||||||
logger.error(
|
|
||||||
"failsafe_activation_failed_no_pump_config",
|
|
||||||
station_id=station_id,
|
|
||||||
pump_id=pump_id
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
"failsafe_activation_failed",
|
|
||||||
station_id=station_id,
|
|
||||||
pump_id=pump_id,
|
|
||||||
error=str(e)
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _deactivate_failsafe(self, station_id: str, pump_id: str):
|
|
||||||
"""Deactivate failsafe mode for a pump."""
|
|
||||||
try:
|
|
||||||
key = (station_id, pump_id)
|
|
||||||
self.failsafe_active[key] = False
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"failsafe_mode_deactivated",
|
|
||||||
station_id=station_id,
|
|
||||||
pump_id=pump_id
|
|
||||||
)
|
|
||||||
|
|
||||||
# Record failsafe deactivation in database
|
|
||||||
self._record_failsafe_deactivation(station_id, pump_id)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
"failsafe_deactivation_failed",
|
|
||||||
station_id=station_id,
|
|
||||||
pump_id=pump_id,
|
|
||||||
error=str(e)
|
|
||||||
)
|
|
||||||
|
|
||||||
def _record_failsafe_event(self, station_id: str, pump_id: str, default_setpoint: float):
|
|
||||||
"""Record failsafe activation in database."""
|
|
||||||
try:
|
|
||||||
query = """
|
|
||||||
INSERT INTO failsafe_events
|
|
||||||
(station_id, pump_id, default_setpoint_hz, timestamp)
|
|
||||||
VALUES (%s, %s, %s, NOW())
|
|
||||||
"""
|
|
||||||
self.db_client.execute(query, (station_id, pump_id, default_setpoint))
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("failed_to_record_failsafe_event", error=str(e))
|
|
||||||
|
|
||||||
def _record_failsafe_deactivation(self, station_id: str, pump_id: str):
|
|
||||||
"""Record failsafe deactivation in database."""
|
|
||||||
try:
|
|
||||||
query = """
|
|
||||||
INSERT INTO failsafe_events
|
|
||||||
(station_id, pump_id, event_type, timestamp)
|
|
||||||
VALUES (%s, %s, 'DEACTIVATED', NOW())
|
|
||||||
"""
|
|
||||||
self.db_client.execute(query, (station_id, pump_id))
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("failed_to_record_failsafe_deactivation", error=str(e))
|
|
||||||
|
|
||||||
def is_failsafe_active(self, station_id: str, pump_id: str) -> bool:
|
|
||||||
"""Check if failsafe mode is active for a pump."""
|
|
||||||
key = (station_id, pump_id)
|
|
||||||
return self.failsafe_active.get(key, False)
|
|
||||||
|
|
||||||
def get_last_update_time(self, station_id: str, pump_id: str) -> Optional[datetime]:
|
|
||||||
"""Get the last known update time for a pump."""
|
|
||||||
key = (station_id, pump_id)
|
|
||||||
return self.last_update_times.get(key)
|
|
||||||
|
|
||||||
def get_status(self) -> Dict[str, Any]:
|
|
||||||
"""Get watchdog status information."""
|
|
||||||
current_time = datetime.now()
|
|
||||||
|
|
||||||
status_info = {
|
|
||||||
'running': self.running,
|
|
||||||
'timeout_seconds': self.timeout_seconds,
|
|
||||||
'check_interval_seconds': self.check_interval_seconds,
|
|
||||||
'monitored_pumps': len(self.last_update_times),
|
|
||||||
'failsafe_active_pumps': sum(self.failsafe_active.values()),
|
|
||||||
'pump_status': {}
|
|
||||||
}
|
|
||||||
|
|
||||||
for key, last_update in self.last_update_times.items():
|
|
||||||
station_id, pump_id = key
|
|
||||||
time_since_update = (current_time - last_update).total_seconds()
|
|
||||||
|
|
||||||
status_info['pump_status'][f"{station_id}_{pump_id}"] = {
|
|
||||||
'last_update': last_update.isoformat(),
|
|
||||||
'seconds_since_update': time_since_update,
|
|
||||||
'failsafe_active': self.failsafe_active.get(key, False),
|
|
||||||
'timeout_percentage': min(100, (time_since_update / self.timeout_seconds) * 100)
|
|
||||||
}
|
|
||||||
|
|
||||||
return status_info
|
|
||||||
232
tests/README.md
232
tests/README.md
|
|
@ -1,232 +0,0 @@
|
||||||
# Calejo Control Adapter Test Suite
|
|
||||||
|
|
||||||
This directory contains comprehensive tests for the Calejo Control Adapter system, following idiomatic Python testing practices.
|
|
||||||
|
|
||||||
## Test Organization
|
|
||||||
|
|
||||||
### Directory Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
tests/
|
|
||||||
├── unit/ # Unit tests (fast, isolated)
|
|
||||||
│ ├── test_database_client.py
|
|
||||||
│ ├── test_auto_discovery.py
|
|
||||||
│ ├── test_safety_framework.py
|
|
||||||
│ └── test_configuration.py
|
|
||||||
├── integration/ # Integration tests (require external services)
|
|
||||||
│ └── test_phase1_integration.py
|
|
||||||
├── fixtures/ # Test data and fixtures
|
|
||||||
├── conftest.py # Pytest configuration and shared fixtures
|
|
||||||
├── test_phase1.py # Legacy Phase 1 test script
|
|
||||||
├── test_safety.py # Legacy safety tests
|
|
||||||
└── README.md # This file
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test Categories
|
|
||||||
|
|
||||||
- **Unit Tests**: Fast tests that don't require external dependencies
|
|
||||||
- **Integration Tests**: Tests that require database or other external services
|
|
||||||
- **Database Tests**: Tests marked with `@pytest.mark.database`
|
|
||||||
- **Safety Tests**: Tests for safety framework components
|
|
||||||
|
|
||||||
## Running Tests
|
|
||||||
|
|
||||||
### Using the Test Runner
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run all tests
|
|
||||||
./run_tests.py
|
|
||||||
|
|
||||||
# Run unit tests only
|
|
||||||
./run_tests.py --type unit
|
|
||||||
|
|
||||||
# Run integration tests only
|
|
||||||
./run_tests.py --type integration
|
|
||||||
|
|
||||||
# Run with coverage
|
|
||||||
./run_tests.py --coverage
|
|
||||||
|
|
||||||
# Run quick tests (unit tests without database)
|
|
||||||
./run_tests.py --quick
|
|
||||||
|
|
||||||
# Run tests with specific markers
|
|
||||||
./run_tests.py --marker safety --marker database
|
|
||||||
|
|
||||||
# Verbose output
|
|
||||||
./run_tests.py --verbose
|
|
||||||
```
|
|
||||||
|
|
||||||
### Using Pytest Directly
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run all tests
|
|
||||||
pytest
|
|
||||||
|
|
||||||
# Run unit tests
|
|
||||||
pytest tests/unit/
|
|
||||||
|
|
||||||
# Run integration tests
|
|
||||||
pytest tests/integration/
|
|
||||||
|
|
||||||
# Run tests with specific markers
|
|
||||||
pytest -m "safety and database"
|
|
||||||
|
|
||||||
# Run tests excluding specific markers
|
|
||||||
pytest -m "not database"
|
|
||||||
|
|
||||||
# Run with coverage
|
|
||||||
pytest --cov=src --cov-report=html
|
|
||||||
```
|
|
||||||
|
|
||||||
## Test Configuration
|
|
||||||
|
|
||||||
### Pytest Configuration
|
|
||||||
|
|
||||||
Configuration is in `pytest.ini` at the project root:
|
|
||||||
|
|
||||||
- **Test Discovery**: Files matching `test_*.py`, classes starting with `Test*`, methods starting with `test_*`
|
|
||||||
- **Markers**: Predefined markers for different test types
|
|
||||||
- **Coverage**: Minimum 80% coverage required
|
|
||||||
- **Async Support**: Auto-mode for async tests
|
|
||||||
|
|
||||||
### Test Fixtures
|
|
||||||
|
|
||||||
Shared fixtures are defined in `tests/conftest.py`:
|
|
||||||
|
|
||||||
- `test_db_client`: Database client for integration tests
|
|
||||||
- `mock_pump_data`: Mock pump data
|
|
||||||
- `mock_safety_limits`: Mock safety limits
|
|
||||||
- `mock_station_data`: Mock station data
|
|
||||||
- `mock_pump_plan`: Mock pump plan
|
|
||||||
- `mock_feedback_data`: Mock feedback data
|
|
||||||
|
|
||||||
## Writing Tests
|
|
||||||
|
|
||||||
### Unit Test Guidelines
|
|
||||||
|
|
||||||
1. **Isolation**: Mock external dependencies
|
|
||||||
2. **Speed**: Tests should run quickly
|
|
||||||
3. **Readability**: Clear test names and assertions
|
|
||||||
4. **Coverage**: Test both success and failure cases
|
|
||||||
|
|
||||||
Example:
|
|
||||||
```python
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_database_connection_success(self, db_client):
|
|
||||||
"""Test successful database connection."""
|
|
||||||
with patch('psycopg2.pool.ThreadedConnectionPool') as mock_pool:
|
|
||||||
mock_pool_instance = Mock()
|
|
||||||
mock_pool.return_value = mock_pool_instance
|
|
||||||
|
|
||||||
await db_client.connect()
|
|
||||||
|
|
||||||
assert db_client.connection_pool is not None
|
|
||||||
mock_pool.assert_called_once()
|
|
||||||
```
|
|
||||||
|
|
||||||
### Integration Test Guidelines
|
|
||||||
|
|
||||||
1. **Markers**: Use `@pytest.mark.integration` and `@pytest.mark.database`
|
|
||||||
2. **Setup**: Use fixtures for test data setup
|
|
||||||
3. **Cleanup**: Ensure proper cleanup after tests
|
|
||||||
4. **Realistic**: Test with realistic data and scenarios
|
|
||||||
|
|
||||||
Example:
|
|
||||||
```python
|
|
||||||
@pytest.mark.integration
|
|
||||||
@pytest.mark.database
|
|
||||||
class TestPhase1Integration:
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_database_connection_integration(self, integration_db_client):
|
|
||||||
"""Test database connection and basic operations."""
|
|
||||||
assert integration_db_client.health_check() is True
|
|
||||||
```
|
|
||||||
|
|
||||||
## Test Data
|
|
||||||
|
|
||||||
### Mock Data
|
|
||||||
|
|
||||||
Mock data is provided through fixtures for consistent testing:
|
|
||||||
|
|
||||||
- **Pump Data**: Complete pump configuration
|
|
||||||
- **Safety Limits**: Safety constraints and limits
|
|
||||||
- **Station Data**: Pump station metadata
|
|
||||||
- **Pump Plans**: Optimization plans from Calejo Optimize
|
|
||||||
- **Feedback Data**: Real-time pump feedback
|
|
||||||
|
|
||||||
### Database Test Data
|
|
||||||
|
|
||||||
Integration tests use the test database with predefined data:
|
|
||||||
|
|
||||||
- Multiple pump stations with different configurations
|
|
||||||
- Various pump types and control methods
|
|
||||||
- Safety limits for different scenarios
|
|
||||||
- Historical pump plans and feedback
|
|
||||||
|
|
||||||
## Continuous Integration
|
|
||||||
|
|
||||||
### Test Execution in CI
|
|
||||||
|
|
||||||
1. **Unit Tests**: Run on every commit
|
|
||||||
2. **Integration Tests**: Run on main branch and PRs
|
|
||||||
3. **Coverage**: Enforce minimum 80% coverage
|
|
||||||
4. **Safety Tests**: Required for safety-critical components
|
|
||||||
|
|
||||||
### Environment Setup
|
|
||||||
|
|
||||||
Integration tests require:
|
|
||||||
|
|
||||||
- PostgreSQL database with test schema
|
|
||||||
- Test database user with appropriate permissions
|
|
||||||
- Environment variables for database connection
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
### Test Naming
|
|
||||||
|
|
||||||
- **Files**: `test_<module_name>.py`
|
|
||||||
- **Classes**: `Test<ClassName>`
|
|
||||||
- **Methods**: `test_<scenario>_<expected_behavior>`
|
|
||||||
|
|
||||||
### Assertions
|
|
||||||
|
|
||||||
- Use descriptive assertion messages
|
|
||||||
- Test both positive and negative cases
|
|
||||||
- Verify side effects when appropriate
|
|
||||||
|
|
||||||
### Async Testing
|
|
||||||
|
|
||||||
- Use `@pytest.mark.asyncio` for async tests
|
|
||||||
- Use `pytest_asyncio.fixture` for async fixtures
|
|
||||||
- Handle async context managers properly
|
|
||||||
|
|
||||||
### Mocking
|
|
||||||
|
|
||||||
- Mock external dependencies
|
|
||||||
- Use `unittest.mock.patch` for module-level mocking
|
|
||||||
- Verify mock interactions when necessary
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Common Issues
|
|
||||||
|
|
||||||
1. **Database Connection**: Ensure test database is running
|
|
||||||
2. **Async Tests**: Use proper async fixtures and markers
|
|
||||||
3. **Import Errors**: Check PYTHONPATH and module structure
|
|
||||||
4. **Mock Issues**: Verify mock setup and teardown
|
|
||||||
|
|
||||||
### Debugging
|
|
||||||
|
|
||||||
- Use `pytest -v` for verbose output
|
|
||||||
- Use `pytest --pdb` to drop into debugger on failure
|
|
||||||
- Check test logs for additional information
|
|
||||||
|
|
||||||
## Coverage Reports
|
|
||||||
|
|
||||||
Coverage reports are generated in HTML format:
|
|
||||||
|
|
||||||
- **Location**: `htmlcov/index.html`
|
|
||||||
- **Requirements**: Run with `--coverage` flag
|
|
||||||
- **Minimum**: 80% coverage enforced
|
|
||||||
|
|
||||||
Run `pytest --cov=src --cov-report=html` and open `htmlcov/index.html` in a browser.
|
|
||||||
|
|
@ -1,127 +0,0 @@
|
||||||
"""
|
|
||||||
Pytest configuration and fixtures for Calejo Control Adapter tests.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import pytest
|
|
||||||
import pytest_asyncio
|
|
||||||
from typing import Dict, Any, AsyncGenerator
|
|
||||||
|
|
||||||
from src.database.client import DatabaseClient
|
|
||||||
from src.core.auto_discovery import AutoDiscovery
|
|
||||||
from src.core.safety import SafetyLimitEnforcer
|
|
||||||
from src.core.logging import setup_logging
|
|
||||||
from config.settings import settings
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def event_loop():
|
|
||||||
"""Create an instance of the default event loop for the test session."""
|
|
||||||
loop = asyncio.get_event_loop_policy().new_event_loop()
|
|
||||||
yield loop
|
|
||||||
loop.close()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def test_settings():
|
|
||||||
"""Test settings with test database configuration."""
|
|
||||||
# Override settings for testing
|
|
||||||
settings.db_name = "calejo_test"
|
|
||||||
settings.db_user = "control_reader_test"
|
|
||||||
settings.environment = "testing"
|
|
||||||
settings.log_level = "WARNING" # Reduce log noise during tests
|
|
||||||
return settings
|
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture(scope="session")
|
|
||||||
async def test_db_client(test_settings) -> AsyncGenerator[DatabaseClient, None]:
|
|
||||||
"""Test database client with test database."""
|
|
||||||
client = DatabaseClient(
|
|
||||||
database_url=test_settings.database_url,
|
|
||||||
min_connections=1,
|
|
||||||
max_connections=3
|
|
||||||
)
|
|
||||||
await client.connect()
|
|
||||||
yield client
|
|
||||||
await client.disconnect()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_pump_data() -> Dict[str, Any]:
|
|
||||||
"""Mock pump data for testing."""
|
|
||||||
return {
|
|
||||||
"station_id": "TEST_STATION",
|
|
||||||
"pump_id": "TEST_PUMP",
|
|
||||||
"pump_name": "Test Pump",
|
|
||||||
"pump_type": "SUBMERSIBLE",
|
|
||||||
"control_type": "DIRECT_SPEED",
|
|
||||||
"manufacturer": "Test Manufacturer",
|
|
||||||
"model": "Test Model",
|
|
||||||
"rated_power_kw": 50.0,
|
|
||||||
"min_speed_hz": 20.0,
|
|
||||||
"max_speed_hz": 50.0,
|
|
||||||
"default_setpoint_hz": 35.0,
|
|
||||||
"control_parameters": {"speed_ramp_rate": 5.0},
|
|
||||||
"active": True
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_safety_limits() -> Dict[str, Any]:
|
|
||||||
"""Mock safety limits for testing."""
|
|
||||||
return {
|
|
||||||
"station_id": "TEST_STATION",
|
|
||||||
"pump_id": "TEST_PUMP",
|
|
||||||
"hard_min_speed_hz": 20.0,
|
|
||||||
"hard_max_speed_hz": 50.0,
|
|
||||||
"hard_min_level_m": 1.0,
|
|
||||||
"hard_max_level_m": 4.0,
|
|
||||||
"emergency_stop_level_m": 4.5,
|
|
||||||
"dry_run_protection_level_m": 0.8,
|
|
||||||
"hard_max_power_kw": 60.0,
|
|
||||||
"hard_max_flow_m3h": 300.0,
|
|
||||||
"max_speed_change_hz_per_min": 5.0
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_station_data() -> Dict[str, Any]:
|
|
||||||
"""Mock station data for testing."""
|
|
||||||
return {
|
|
||||||
"station_id": "TEST_STATION",
|
|
||||||
"station_name": "Test Station",
|
|
||||||
"location": "Test Location",
|
|
||||||
"latitude": 45.4642035,
|
|
||||||
"longitude": 9.189982,
|
|
||||||
"timezone": "Europe/Rome",
|
|
||||||
"active": True
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_pump_plan() -> Dict[str, Any]:
|
|
||||||
"""Mock pump plan for testing."""
|
|
||||||
return {
|
|
||||||
"station_id": "TEST_STATION",
|
|
||||||
"pump_id": "TEST_PUMP",
|
|
||||||
"target_flow_m3h": 250.0,
|
|
||||||
"target_power_kw": 45.0,
|
|
||||||
"target_level_m": 2.5,
|
|
||||||
"suggested_speed_hz": 40.0
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_feedback_data() -> Dict[str, Any]:
|
|
||||||
"""Mock feedback data for testing."""
|
|
||||||
return {
|
|
||||||
"station_id": "TEST_STATION",
|
|
||||||
"pump_id": "TEST_PUMP",
|
|
||||||
"actual_speed_hz": 39.5,
|
|
||||||
"actual_power_kw": 44.2,
|
|
||||||
"actual_flow_m3h": 248.5,
|
|
||||||
"wet_well_level_m": 2.48,
|
|
||||||
"pump_running": True,
|
|
||||||
"alarm_active": False,
|
|
||||||
"alarm_code": None
|
|
||||||
}
|
|
||||||
|
|
@ -1,181 +0,0 @@
|
||||||
"""
|
|
||||||
Integration tests for Phase 1 components.
|
|
||||||
|
|
||||||
These tests require a running PostgreSQL database with the test schema.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
import pytest_asyncio
|
|
||||||
from typing import Dict, Any
|
|
||||||
|
|
||||||
from src.database.client import DatabaseClient
|
|
||||||
from src.core.auto_discovery import AutoDiscovery
|
|
||||||
from src.core.safety import SafetyLimitEnforcer
|
|
||||||
from config.settings import settings
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
|
||||||
@pytest.mark.database
|
|
||||||
class TestPhase1Integration:
|
|
||||||
"""Integration tests for Phase 1 components."""
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture(scope="class")
|
|
||||||
async def integration_db_client(self):
|
|
||||||
"""Create database client for integration tests."""
|
|
||||||
client = DatabaseClient(
|
|
||||||
database_url=settings.database_url,
|
|
||||||
min_connections=1,
|
|
||||||
max_connections=3
|
|
||||||
)
|
|
||||||
await client.connect()
|
|
||||||
yield client
|
|
||||||
await client.disconnect()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_database_connection_integration(self, integration_db_client):
|
|
||||||
"""Test database connection and basic operations."""
|
|
||||||
# Test health check
|
|
||||||
assert integration_db_client.health_check() is True
|
|
||||||
|
|
||||||
# Test connection stats
|
|
||||||
stats = integration_db_client.get_connection_stats()
|
|
||||||
assert stats["pool_status"] == "active"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_database_queries_integration(self, integration_db_client):
|
|
||||||
"""Test database queries with real database."""
|
|
||||||
# Test getting pump stations
|
|
||||||
stations = integration_db_client.get_pump_stations()
|
|
||||||
assert isinstance(stations, list)
|
|
||||||
|
|
||||||
# Test getting pumps
|
|
||||||
pumps = integration_db_client.get_pumps()
|
|
||||||
assert isinstance(pumps, list)
|
|
||||||
|
|
||||||
# Test getting safety limits
|
|
||||||
safety_limits = integration_db_client.get_safety_limits()
|
|
||||||
assert isinstance(safety_limits, list)
|
|
||||||
|
|
||||||
# Test getting pump plans
|
|
||||||
pump_plans = integration_db_client.get_latest_pump_plans()
|
|
||||||
assert isinstance(pump_plans, list)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_auto_discovery_integration(self, integration_db_client):
|
|
||||||
"""Test auto-discovery with real database."""
|
|
||||||
auto_discovery = AutoDiscovery(integration_db_client, refresh_interval_minutes=5)
|
|
||||||
|
|
||||||
await auto_discovery.discover()
|
|
||||||
|
|
||||||
# Verify discovery was successful
|
|
||||||
stations = auto_discovery.get_stations()
|
|
||||||
pumps = auto_discovery.get_pumps()
|
|
||||||
|
|
||||||
assert isinstance(stations, dict)
|
|
||||||
assert isinstance(pumps, list)
|
|
||||||
|
|
||||||
# Verify discovery status
|
|
||||||
status = auto_discovery.get_discovery_status()
|
|
||||||
assert status["last_discovery"] is not None
|
|
||||||
assert status["station_count"] >= 0
|
|
||||||
assert status["pump_count"] >= 0
|
|
||||||
|
|
||||||
# Validate discovery data
|
|
||||||
validation = auto_discovery.validate_discovery()
|
|
||||||
assert isinstance(validation, dict)
|
|
||||||
assert "valid" in validation
|
|
||||||
assert "issues" in validation
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_safety_framework_integration(self, integration_db_client):
|
|
||||||
"""Test safety framework with real database."""
|
|
||||||
safety_enforcer = SafetyLimitEnforcer(integration_db_client)
|
|
||||||
|
|
||||||
await safety_enforcer.load_safety_limits()
|
|
||||||
|
|
||||||
# Verify limits were loaded
|
|
||||||
limits_count = safety_enforcer.get_loaded_limits_count()
|
|
||||||
assert limits_count >= 0
|
|
||||||
|
|
||||||
# Test setpoint enforcement if we have limits
|
|
||||||
if limits_count > 0:
|
|
||||||
# Get first pump with safety limits
|
|
||||||
auto_discovery = AutoDiscovery(integration_db_client)
|
|
||||||
await auto_discovery.discover()
|
|
||||||
pumps = auto_discovery.get_pumps()
|
|
||||||
|
|
||||||
if pumps:
|
|
||||||
pump = pumps[0]
|
|
||||||
station_id = pump['station_id']
|
|
||||||
pump_id = pump['pump_id']
|
|
||||||
|
|
||||||
# Test setpoint enforcement
|
|
||||||
enforced, violations = safety_enforcer.enforce_setpoint(
|
|
||||||
station_id, pump_id, 35.0
|
|
||||||
)
|
|
||||||
|
|
||||||
assert isinstance(enforced, float)
|
|
||||||
assert isinstance(violations, list)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_component_interaction(self, integration_db_client):
|
|
||||||
"""Test interaction between Phase 1 components."""
|
|
||||||
# Initialize all components
|
|
||||||
auto_discovery = AutoDiscovery(integration_db_client)
|
|
||||||
safety_enforcer = SafetyLimitEnforcer(integration_db_client)
|
|
||||||
|
|
||||||
# Perform discovery
|
|
||||||
await auto_discovery.discover()
|
|
||||||
await safety_enforcer.load_safety_limits()
|
|
||||||
|
|
||||||
# Get discovered pumps
|
|
||||||
pumps = auto_discovery.get_pumps()
|
|
||||||
|
|
||||||
# Test setpoint enforcement for discovered pumps
|
|
||||||
for pump in pumps[:2]: # Test first 2 pumps
|
|
||||||
station_id = pump['station_id']
|
|
||||||
pump_id = pump['pump_id']
|
|
||||||
|
|
||||||
# Test setpoint enforcement
|
|
||||||
enforced, violations = safety_enforcer.enforce_setpoint(
|
|
||||||
station_id, pump_id, pump['default_setpoint_hz']
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify results
|
|
||||||
assert isinstance(enforced, float)
|
|
||||||
assert isinstance(violations, list)
|
|
||||||
|
|
||||||
# If we have safety limits, the enforced setpoint should be valid
|
|
||||||
if safety_enforcer.has_safety_limits(station_id, pump_id):
|
|
||||||
limits = safety_enforcer.get_safety_limits(station_id, pump_id)
|
|
||||||
assert limits.hard_min_speed_hz <= enforced <= limits.hard_max_speed_hz
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_error_handling_integration(self, integration_db_client):
|
|
||||||
"""Test error handling with real database."""
|
|
||||||
# Test invalid query
|
|
||||||
with pytest.raises(Exception):
|
|
||||||
integration_db_client.execute_query("SELECT * FROM non_existent_table")
|
|
||||||
|
|
||||||
# Test auto-discovery with invalid station filter
|
|
||||||
auto_discovery = AutoDiscovery(integration_db_client)
|
|
||||||
await auto_discovery.discover()
|
|
||||||
|
|
||||||
# Get pumps for non-existent station
|
|
||||||
pumps = auto_discovery.get_pumps("NON_EXISTENT_STATION")
|
|
||||||
assert pumps == []
|
|
||||||
|
|
||||||
# Get non-existent pump
|
|
||||||
pump = auto_discovery.get_pump("NON_EXISTENT_STATION", "NON_EXISTENT_PUMP")
|
|
||||||
assert pump is None
|
|
||||||
|
|
||||||
# Test safety enforcement for non-existent pump
|
|
||||||
safety_enforcer = SafetyLimitEnforcer(integration_db_client)
|
|
||||||
await safety_enforcer.load_safety_limits()
|
|
||||||
|
|
||||||
enforced, violations = safety_enforcer.enforce_setpoint(
|
|
||||||
"NON_EXISTENT_STATION", "NON_EXISTENT_PUMP", 35.0
|
|
||||||
)
|
|
||||||
|
|
||||||
assert enforced == 0.0
|
|
||||||
assert violations == ["NO_SAFETY_LIMITS_DEFINED"]
|
|
||||||
|
|
@ -1,286 +0,0 @@
|
||||||
"""
|
|
||||||
Unit tests for AlertManager.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from unittest.mock import Mock, AsyncMock, patch
|
|
||||||
|
|
||||||
from src.monitoring.alerts import AlertManager
|
|
||||||
from config.settings import Settings
|
|
||||||
|
|
||||||
|
|
||||||
class TestAlertManager:
|
|
||||||
"""Test cases for AlertManager."""
|
|
||||||
|
|
||||||
def setup_method(self):
|
|
||||||
"""Set up test fixtures."""
|
|
||||||
self.settings = Settings()
|
|
||||||
self.alert_manager = AlertManager(self.settings)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_send_alert_success(self):
|
|
||||||
"""Test sending alert successfully."""
|
|
||||||
# Arrange
|
|
||||||
with patch.object(self.alert_manager, '_send_email_alert', AsyncMock(return_value=True)) as mock_email,\
|
|
||||||
patch.object(self.alert_manager, '_send_sms_alert', AsyncMock(return_value=True)) as mock_sms,\
|
|
||||||
patch.object(self.alert_manager, '_send_webhook_alert', AsyncMock(return_value=True)) as mock_webhook,\
|
|
||||||
patch.object(self.alert_manager, '_send_scada_alert', AsyncMock(return_value=True)) as mock_scada:
|
|
||||||
|
|
||||||
# Act
|
|
||||||
result = await self.alert_manager.send_alert(
|
|
||||||
alert_type='SAFETY_VIOLATION',
|
|
||||||
severity='ERROR',
|
|
||||||
message='Test safety violation',
|
|
||||||
context={'violation_type': 'OVERSPEED'},
|
|
||||||
station_id='STATION_001',
|
|
||||||
pump_id='PUMP_001'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Assert
|
|
||||||
assert result is True
|
|
||||||
assert mock_email.called
|
|
||||||
assert mock_sms.called
|
|
||||||
assert mock_webhook.called
|
|
||||||
assert mock_scada.called
|
|
||||||
|
|
||||||
# Check alert history
|
|
||||||
history = self.alert_manager.get_alert_history()
|
|
||||||
assert len(history) == 1
|
|
||||||
assert history[0]['alert_type'] == 'SAFETY_VIOLATION'
|
|
||||||
assert history[0]['severity'] == 'ERROR'
|
|
||||||
assert history[0]['station_id'] == 'STATION_001'
|
|
||||||
assert history[0]['pump_id'] == 'PUMP_001'
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_send_alert_partial_failure(self):
|
|
||||||
"""Test sending alert with partial channel failures."""
|
|
||||||
# Arrange
|
|
||||||
with patch.object(self.alert_manager, '_send_email_alert', AsyncMock(return_value=True)) as mock_email,\
|
|
||||||
patch.object(self.alert_manager, '_send_sms_alert', AsyncMock(return_value=False)) as mock_sms,\
|
|
||||||
patch.object(self.alert_manager, '_send_webhook_alert', AsyncMock(return_value=False)) as mock_webhook,\
|
|
||||||
patch.object(self.alert_manager, '_send_scada_alert', AsyncMock(return_value=True)) as mock_scada:
|
|
||||||
|
|
||||||
# Act
|
|
||||||
result = await self.alert_manager.send_alert(
|
|
||||||
alert_type='FAILSAFE_ACTIVATED',
|
|
||||||
severity='CRITICAL',
|
|
||||||
message='Test failsafe activation'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Assert
|
|
||||||
assert result is True # Should still return True if at least one channel succeeded
|
|
||||||
assert mock_email.called
|
|
||||||
assert mock_sms.called
|
|
||||||
assert mock_webhook.called
|
|
||||||
assert mock_scada.called
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_send_alert_all_failures(self):
|
|
||||||
"""Test sending alert when all channels fail."""
|
|
||||||
# Arrange
|
|
||||||
with patch.object(self.alert_manager, '_send_email_alert', AsyncMock(return_value=False)) as mock_email,\
|
|
||||||
patch.object(self.alert_manager, '_send_sms_alert', AsyncMock(return_value=False)) as mock_sms,\
|
|
||||||
patch.object(self.alert_manager, '_send_webhook_alert', AsyncMock(return_value=False)) as mock_webhook,\
|
|
||||||
patch.object(self.alert_manager, '_send_scada_alert', AsyncMock(return_value=False)) as mock_scada:
|
|
||||||
|
|
||||||
# Act
|
|
||||||
result = await self.alert_manager.send_alert(
|
|
||||||
alert_type='SYSTEM_ERROR',
|
|
||||||
severity='ERROR',
|
|
||||||
message='Test system error'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Assert
|
|
||||||
assert result is False # Should return False if all channels failed
|
|
||||||
assert mock_email.called
|
|
||||||
assert mock_sms.called
|
|
||||||
assert mock_webhook.called
|
|
||||||
assert mock_scada.called
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_send_email_alert_success(self):
|
|
||||||
"""Test sending email alert successfully."""
|
|
||||||
# Arrange
|
|
||||||
alert_data = {
|
|
||||||
'alert_type': 'TEST_ALERT',
|
|
||||||
'severity': 'INFO',
|
|
||||||
'message': 'Test message',
|
|
||||||
'context': {},
|
|
||||||
'app_name': 'Test App',
|
|
||||||
'app_version': '1.0.0',
|
|
||||||
'timestamp': 1234567890.0
|
|
||||||
}
|
|
||||||
|
|
||||||
with patch('smtplib.SMTP') as mock_smtp:
|
|
||||||
mock_server = Mock()
|
|
||||||
mock_smtp.return_value.__enter__.return_value = mock_server
|
|
||||||
|
|
||||||
# Act
|
|
||||||
result = await self.alert_manager._send_email_alert(alert_data)
|
|
||||||
|
|
||||||
# Assert
|
|
||||||
assert result is True
|
|
||||||
assert mock_smtp.called
|
|
||||||
assert mock_server.send_message.called
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_send_email_alert_failure(self):
|
|
||||||
"""Test sending email alert with failure."""
|
|
||||||
# Arrange
|
|
||||||
alert_data = {
|
|
||||||
'alert_type': 'TEST_ALERT',
|
|
||||||
'severity': 'INFO',
|
|
||||||
'message': 'Test message',
|
|
||||||
'context': {},
|
|
||||||
'app_name': 'Test App',
|
|
||||||
'app_version': '1.0.0'
|
|
||||||
}
|
|
||||||
|
|
||||||
with patch('smtplib.SMTP', side_effect=Exception("SMTP error")):
|
|
||||||
# Act
|
|
||||||
result = await self.alert_manager._send_email_alert(alert_data)
|
|
||||||
|
|
||||||
# Assert
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_send_sms_alert_critical_only(self):
|
|
||||||
"""Test that SMS alerts are only sent for critical events."""
|
|
||||||
# Arrange
|
|
||||||
alert_data_critical = {
|
|
||||||
'alert_type': 'CRITICAL_ALERT',
|
|
||||||
'severity': 'CRITICAL',
|
|
||||||
'message': 'Critical message'
|
|
||||||
}
|
|
||||||
|
|
||||||
alert_data_info = {
|
|
||||||
'alert_type': 'INFO_ALERT',
|
|
||||||
'severity': 'INFO',
|
|
||||||
'message': 'Info message'
|
|
||||||
}
|
|
||||||
|
|
||||||
# Act - Critical alert
|
|
||||||
result_critical = await self.alert_manager._send_sms_alert(alert_data_critical)
|
|
||||||
|
|
||||||
# Act - Info alert
|
|
||||||
result_info = await self.alert_manager._send_sms_alert(alert_data_info)
|
|
||||||
|
|
||||||
# Assert
|
|
||||||
assert result_critical is True # Should attempt to send critical alerts
|
|
||||||
assert result_info is False # Should not send non-critical alerts
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_send_webhook_alert_success(self):
|
|
||||||
"""Test sending webhook alert successfully."""
|
|
||||||
# Arrange
|
|
||||||
alert_data = {
|
|
||||||
'alert_type': 'TEST_ALERT',
|
|
||||||
'severity': 'INFO',
|
|
||||||
'message': 'Test message'
|
|
||||||
}
|
|
||||||
|
|
||||||
with patch('aiohttp.ClientSession.post') as mock_post:
|
|
||||||
mock_response = AsyncMock()
|
|
||||||
mock_response.status = 200
|
|
||||||
mock_post.return_value.__aenter__.return_value = mock_response
|
|
||||||
|
|
||||||
# Act
|
|
||||||
result = await self.alert_manager._send_webhook_alert(alert_data)
|
|
||||||
|
|
||||||
# Assert
|
|
||||||
assert result is True
|
|
||||||
assert mock_post.called
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_send_webhook_alert_failure(self):
|
|
||||||
"""Test sending webhook alert with failure."""
|
|
||||||
# Arrange
|
|
||||||
alert_data = {
|
|
||||||
'alert_type': 'TEST_ALERT',
|
|
||||||
'severity': 'INFO',
|
|
||||||
'message': 'Test message'
|
|
||||||
}
|
|
||||||
|
|
||||||
with patch('aiohttp.ClientSession.post') as mock_post:
|
|
||||||
mock_response = AsyncMock()
|
|
||||||
mock_response.status = 500
|
|
||||||
mock_post.return_value.__aenter__.return_value = mock_response
|
|
||||||
|
|
||||||
# Act
|
|
||||||
result = await self.alert_manager._send_webhook_alert(alert_data)
|
|
||||||
|
|
||||||
# Assert
|
|
||||||
assert result is False
|
|
||||||
assert mock_post.called
|
|
||||||
|
|
||||||
def test_format_email_body(self):
|
|
||||||
"""Test formatting email body."""
|
|
||||||
# Arrange
|
|
||||||
alert_data = {
|
|
||||||
'alert_type': 'SAFETY_VIOLATION',
|
|
||||||
'severity': 'ERROR',
|
|
||||||
'message': 'Speed limit exceeded',
|
|
||||||
'context': {'requested_speed': 55.0, 'max_speed': 50.0},
|
|
||||||
'station_id': 'STATION_001',
|
|
||||||
'pump_id': 'PUMP_001',
|
|
||||||
'timestamp': 1234567890.0,
|
|
||||||
'app_name': 'Test App',
|
|
||||||
'app_version': '1.0.0'
|
|
||||||
}
|
|
||||||
|
|
||||||
# Act
|
|
||||||
body = self.alert_manager._format_email_body(alert_data)
|
|
||||||
|
|
||||||
# Assert
|
|
||||||
assert 'SAFETY_VIOLATION' in body
|
|
||||||
assert 'ERROR' in body
|
|
||||||
assert 'Speed limit exceeded' in body
|
|
||||||
assert 'STATION_001' in body
|
|
||||||
assert 'PUMP_001' in body
|
|
||||||
assert 'requested_speed' in body
|
|
||||||
assert 'Test App v1.0.0' in body
|
|
||||||
|
|
||||||
def test_alert_history_management(self):
|
|
||||||
"""Test alert history management with size limits."""
|
|
||||||
# Arrange - Fill history beyond limit
|
|
||||||
for i in range(1500): # More than max_history_size (1000)
|
|
||||||
self.alert_manager._store_alert_history({
|
|
||||||
'alert_type': f'TEST_{i}',
|
|
||||||
'severity': 'INFO',
|
|
||||||
'message': f'Test message {i}'
|
|
||||||
})
|
|
||||||
|
|
||||||
# Act - Get all history (no limit)
|
|
||||||
history = self.alert_manager.get_alert_history(limit=2000)
|
|
||||||
|
|
||||||
# Assert
|
|
||||||
assert len(history) == 1000 # Should be limited to max_history_size
|
|
||||||
assert history[0]['alert_type'] == 'TEST_500' # Should keep most recent
|
|
||||||
assert history[-1]['alert_type'] == 'TEST_1499' # Most recent at end
|
|
||||||
|
|
||||||
def test_get_alert_stats(self):
|
|
||||||
"""Test getting alert statistics."""
|
|
||||||
# Arrange
|
|
||||||
alerts = [
|
|
||||||
{'alert_type': 'SAFETY_VIOLATION', 'severity': 'ERROR'},
|
|
||||||
{'alert_type': 'SAFETY_VIOLATION', 'severity': 'ERROR'},
|
|
||||||
{'alert_type': 'FAILSAFE_ACTIVATED', 'severity': 'CRITICAL'},
|
|
||||||
{'alert_type': 'SYSTEM_ERROR', 'severity': 'ERROR'},
|
|
||||||
{'alert_type': 'INFO_ALERT', 'severity': 'INFO'}
|
|
||||||
]
|
|
||||||
|
|
||||||
for alert in alerts:
|
|
||||||
self.alert_manager._store_alert_history(alert)
|
|
||||||
|
|
||||||
# Act
|
|
||||||
stats = self.alert_manager.get_alert_stats()
|
|
||||||
|
|
||||||
# Assert
|
|
||||||
assert stats['total_alerts'] == 5
|
|
||||||
assert stats['severity_counts']['ERROR'] == 3
|
|
||||||
assert stats['severity_counts']['CRITICAL'] == 1
|
|
||||||
assert stats['severity_counts']['INFO'] == 1
|
|
||||||
assert stats['type_counts']['SAFETY_VIOLATION'] == 2
|
|
||||||
assert stats['type_counts']['FAILSAFE_ACTIVATED'] == 1
|
|
||||||
assert stats['type_counts']['SYSTEM_ERROR'] == 1
|
|
||||||
assert stats['type_counts']['INFO_ALERT'] == 1
|
|
||||||
|
|
@ -1,280 +0,0 @@
|
||||||
"""
|
|
||||||
Unit tests for AutoDiscovery class.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
import pytest_asyncio
|
|
||||||
from unittest.mock import Mock, patch, AsyncMock
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from typing import Dict, Any, List
|
|
||||||
|
|
||||||
from src.core.auto_discovery import AutoDiscovery
|
|
||||||
|
|
||||||
|
|
||||||
class TestAutoDiscovery:
|
|
||||||
"""Test cases for AutoDiscovery."""
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
|
||||||
async def auto_discovery(self):
|
|
||||||
"""Create a test auto-discovery instance."""
|
|
||||||
mock_db_client = Mock()
|
|
||||||
discovery = AutoDiscovery(mock_db_client, refresh_interval_minutes=5)
|
|
||||||
return discovery
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_discover_success(self, auto_discovery):
|
|
||||||
"""Test successful discovery."""
|
|
||||||
# Mock database responses
|
|
||||||
mock_stations = [
|
|
||||||
{
|
|
||||||
'station_id': 'STATION_001',
|
|
||||||
'station_name': 'Test Station 1',
|
|
||||||
'location': 'Test Location 1',
|
|
||||||
'latitude': 45.4642035,
|
|
||||||
'longitude': 9.189982,
|
|
||||||
'timezone': 'Europe/Rome',
|
|
||||||
'active': True
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
mock_pumps = [
|
|
||||||
{
|
|
||||||
'station_id': 'STATION_001',
|
|
||||||
'pump_id': 'PUMP_001',
|
|
||||||
'pump_name': 'Test Pump 1',
|
|
||||||
'pump_type': 'SUBMERSIBLE',
|
|
||||||
'control_type': 'DIRECT_SPEED',
|
|
||||||
'manufacturer': 'Test Manufacturer',
|
|
||||||
'model': 'Test Model',
|
|
||||||
'rated_power_kw': 50.0,
|
|
||||||
'min_speed_hz': 20.0,
|
|
||||||
'max_speed_hz': 50.0,
|
|
||||||
'default_setpoint_hz': 35.0,
|
|
||||||
'control_parameters': {'speed_ramp_rate': 5.0},
|
|
||||||
'active': True
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
auto_discovery.db_client.get_pump_stations.return_value = mock_stations
|
|
||||||
auto_discovery.db_client.get_pumps.return_value = mock_pumps
|
|
||||||
|
|
||||||
await auto_discovery.discover()
|
|
||||||
|
|
||||||
# Verify stations were discovered
|
|
||||||
stations = auto_discovery.get_stations()
|
|
||||||
assert len(stations) == 1
|
|
||||||
assert 'STATION_001' in stations
|
|
||||||
assert stations['STATION_001']['station_name'] == 'Test Station 1'
|
|
||||||
|
|
||||||
# Verify pumps were discovered
|
|
||||||
pumps = auto_discovery.get_pumps()
|
|
||||||
assert len(pumps) == 1
|
|
||||||
assert pumps[0]['pump_id'] == 'PUMP_001'
|
|
||||||
|
|
||||||
# Verify last discovery timestamp was set
|
|
||||||
assert auto_discovery.last_discovery is not None
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_discover_failure(self, auto_discovery):
|
|
||||||
"""Test discovery failure."""
|
|
||||||
auto_discovery.db_client.get_pump_stations.side_effect = Exception("Database error")
|
|
||||||
|
|
||||||
with pytest.raises(Exception, match="Database error"):
|
|
||||||
await auto_discovery.discover()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_discover_already_running(self, auto_discovery):
|
|
||||||
"""Test discovery when already running."""
|
|
||||||
auto_discovery.discovery_running = True
|
|
||||||
|
|
||||||
await auto_discovery.discover()
|
|
||||||
|
|
||||||
# Should not call database methods
|
|
||||||
auto_discovery.db_client.get_pump_stations.assert_not_called()
|
|
||||||
auto_discovery.db_client.get_pumps.assert_not_called()
|
|
||||||
|
|
||||||
def test_get_stations(self, auto_discovery):
|
|
||||||
"""Test getting discovered stations."""
|
|
||||||
# Set up test data
|
|
||||||
auto_discovery.pump_stations = {
|
|
||||||
'STATION_001': {'station_id': 'STATION_001', 'station_name': 'Test Station 1'},
|
|
||||||
'STATION_002': {'station_id': 'STATION_002', 'station_name': 'Test Station 2'}
|
|
||||||
}
|
|
||||||
|
|
||||||
stations = auto_discovery.get_stations()
|
|
||||||
|
|
||||||
assert len(stations) == 2
|
|
||||||
assert stations['STATION_001']['station_name'] == 'Test Station 1'
|
|
||||||
assert stations['STATION_002']['station_name'] == 'Test Station 2'
|
|
||||||
|
|
||||||
def test_get_pumps_no_filter(self, auto_discovery):
|
|
||||||
"""Test getting all pumps."""
|
|
||||||
# Set up test data
|
|
||||||
auto_discovery.pumps = {
|
|
||||||
'STATION_001': [
|
|
||||||
{'station_id': 'STATION_001', 'pump_id': 'PUMP_001', 'pump_name': 'Pump 1'},
|
|
||||||
{'station_id': 'STATION_001', 'pump_id': 'PUMP_002', 'pump_name': 'Pump 2'}
|
|
||||||
],
|
|
||||||
'STATION_002': [
|
|
||||||
{'station_id': 'STATION_002', 'pump_id': 'PUMP_003', 'pump_name': 'Pump 3'}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
pumps = auto_discovery.get_pumps()
|
|
||||||
|
|
||||||
assert len(pumps) == 3
|
|
||||||
|
|
||||||
def test_get_pumps_with_station_filter(self, auto_discovery):
|
|
||||||
"""Test getting pumps for specific station."""
|
|
||||||
# Set up test data
|
|
||||||
auto_discovery.pumps = {
|
|
||||||
'STATION_001': [
|
|
||||||
{'station_id': 'STATION_001', 'pump_id': 'PUMP_001', 'pump_name': 'Pump 1'},
|
|
||||||
{'station_id': 'STATION_001', 'pump_id': 'PUMP_002', 'pump_name': 'Pump 2'}
|
|
||||||
],
|
|
||||||
'STATION_002': [
|
|
||||||
{'station_id': 'STATION_002', 'pump_id': 'PUMP_003', 'pump_name': 'Pump 3'}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
pumps = auto_discovery.get_pumps('STATION_001')
|
|
||||||
|
|
||||||
assert len(pumps) == 2
|
|
||||||
assert all(pump['station_id'] == 'STATION_001' for pump in pumps)
|
|
||||||
|
|
||||||
def test_get_pump_success(self, auto_discovery):
|
|
||||||
"""Test getting specific pump."""
|
|
||||||
# Set up test data
|
|
||||||
auto_discovery.pumps = {
|
|
||||||
'STATION_001': [
|
|
||||||
{'station_id': 'STATION_001', 'pump_id': 'PUMP_001', 'pump_name': 'Pump 1'},
|
|
||||||
{'station_id': 'STATION_001', 'pump_id': 'PUMP_002', 'pump_name': 'Pump 2'}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
pump = auto_discovery.get_pump('STATION_001', 'PUMP_001')
|
|
||||||
|
|
||||||
assert pump is not None
|
|
||||||
assert pump['pump_name'] == 'Pump 1'
|
|
||||||
|
|
||||||
def test_get_pump_not_found(self, auto_discovery):
|
|
||||||
"""Test getting non-existent pump."""
|
|
||||||
auto_discovery.pumps = {
|
|
||||||
'STATION_001': [
|
|
||||||
{'station_id': 'STATION_001', 'pump_id': 'PUMP_001', 'pump_name': 'Pump 1'}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
pump = auto_discovery.get_pump('STATION_001', 'PUMP_999')
|
|
||||||
|
|
||||||
assert pump is None
|
|
||||||
|
|
||||||
def test_get_station_success(self, auto_discovery):
|
|
||||||
"""Test getting specific station."""
|
|
||||||
auto_discovery.pump_stations = {
|
|
||||||
'STATION_001': {'station_id': 'STATION_001', 'station_name': 'Test Station 1'}
|
|
||||||
}
|
|
||||||
|
|
||||||
station = auto_discovery.get_station('STATION_001')
|
|
||||||
|
|
||||||
assert station is not None
|
|
||||||
assert station['station_name'] == 'Test Station 1'
|
|
||||||
|
|
||||||
def test_get_station_not_found(self, auto_discovery):
|
|
||||||
"""Test getting non-existent station."""
|
|
||||||
station = auto_discovery.get_station('STATION_999')
|
|
||||||
|
|
||||||
assert station is None
|
|
||||||
|
|
||||||
def test_get_discovery_status(self, auto_discovery):
|
|
||||||
"""Test getting discovery status."""
|
|
||||||
auto_discovery.last_discovery = datetime(2023, 1, 1, 12, 0, 0)
|
|
||||||
auto_discovery.pump_stations = {'STATION_001': {}}
|
|
||||||
auto_discovery.pumps = {'STATION_001': [{}, {}]}
|
|
||||||
|
|
||||||
status = auto_discovery.get_discovery_status()
|
|
||||||
|
|
||||||
assert status['last_discovery'] == '2023-01-01T12:00:00'
|
|
||||||
assert status['station_count'] == 1
|
|
||||||
assert status['pump_count'] == 2
|
|
||||||
assert status['refresh_interval_minutes'] == 5
|
|
||||||
assert status['discovery_running'] is False
|
|
||||||
|
|
||||||
def test_is_stale_fresh(self, auto_discovery):
|
|
||||||
"""Test staleness check with fresh data."""
|
|
||||||
auto_discovery.last_discovery = datetime.now() - timedelta(minutes=30)
|
|
||||||
|
|
||||||
assert auto_discovery.is_stale(max_age_minutes=60) is False
|
|
||||||
|
|
||||||
def test_is_stale_stale(self, auto_discovery):
|
|
||||||
"""Test staleness check with stale data."""
|
|
||||||
auto_discovery.last_discovery = datetime.now() - timedelta(minutes=90)
|
|
||||||
|
|
||||||
assert auto_discovery.is_stale(max_age_minutes=60) is True
|
|
||||||
|
|
||||||
def test_is_stale_no_discovery(self, auto_discovery):
|
|
||||||
"""Test staleness check with no discovery."""
|
|
||||||
auto_discovery.last_discovery = None
|
|
||||||
|
|
||||||
assert auto_discovery.is_stale() is True
|
|
||||||
|
|
||||||
def test_validate_discovery_valid(self, auto_discovery):
|
|
||||||
"""Test validation with valid discovery data."""
|
|
||||||
auto_discovery.pump_stations = {
|
|
||||||
'STATION_001': {'station_id': 'STATION_001'}
|
|
||||||
}
|
|
||||||
auto_discovery.pumps = {
|
|
||||||
'STATION_001': [
|
|
||||||
{
|
|
||||||
'station_id': 'STATION_001',
|
|
||||||
'pump_id': 'PUMP_001',
|
|
||||||
'control_type': 'DIRECT_SPEED',
|
|
||||||
'default_setpoint_hz': 35.0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
validation = auto_discovery.validate_discovery()
|
|
||||||
|
|
||||||
assert validation['valid'] is True
|
|
||||||
assert len(validation['issues']) == 0
|
|
||||||
|
|
||||||
def test_validate_discovery_invalid(self, auto_discovery):
|
|
||||||
"""Test validation with invalid discovery data."""
|
|
||||||
auto_discovery.pump_stations = {
|
|
||||||
'STATION_001': {'station_id': 'STATION_001'}
|
|
||||||
}
|
|
||||||
auto_discovery.pumps = {
|
|
||||||
'STATION_002': [ # Station not in pump_stations
|
|
||||||
{
|
|
||||||
'station_id': 'STATION_002',
|
|
||||||
'pump_id': 'PUMP_001',
|
|
||||||
'control_type': None, # Missing control_type
|
|
||||||
'default_setpoint_hz': None # Missing default_setpoint
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
validation = auto_discovery.validate_discovery()
|
|
||||||
|
|
||||||
assert validation['valid'] is False
|
|
||||||
assert len(validation['issues']) == 4 # Unknown station + 2 missing fields + station without pumps
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_start_periodic_discovery(self, auto_discovery):
|
|
||||||
"""Test starting periodic discovery."""
|
|
||||||
with patch('asyncio.sleep', new_callable=AsyncMock) as mock_sleep:
|
|
||||||
# Make sleep return immediately to avoid infinite loop
|
|
||||||
mock_sleep.side_effect = [None, Exception("Break loop")]
|
|
||||||
|
|
||||||
with patch.object(auto_discovery, 'discover', new_callable=AsyncMock) as mock_discover:
|
|
||||||
mock_discover.side_effect = Exception("Break loop")
|
|
||||||
|
|
||||||
with pytest.raises(Exception, match="Break loop"):
|
|
||||||
await auto_discovery.start_periodic_discovery()
|
|
||||||
|
|
||||||
# Verify discover was called
|
|
||||||
mock_discover.assert_called_once()
|
|
||||||
|
|
||||||
# Verify sleep was called with correct interval
|
|
||||||
mock_sleep.assert_called_with(300) # 5 minutes * 60 seconds
|
|
||||||
|
|
@ -1,251 +0,0 @@
|
||||||
"""
|
|
||||||
Unit tests for configuration management.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
import os
|
|
||||||
from unittest.mock import patch, mock_open
|
|
||||||
|
|
||||||
from config.settings import Settings
|
|
||||||
|
|
||||||
|
|
||||||
class TestSettings:
|
|
||||||
"""Test cases for Settings class."""
|
|
||||||
|
|
||||||
def test_settings_default_values(self):
|
|
||||||
"""Test that settings have correct default values."""
|
|
||||||
settings = Settings()
|
|
||||||
|
|
||||||
# Database defaults
|
|
||||||
assert settings.db_host == "localhost"
|
|
||||||
assert settings.db_port == 5432
|
|
||||||
assert settings.db_name == "calejo"
|
|
||||||
assert settings.db_user == "control_reader"
|
|
||||||
assert settings.db_password == "secure_password"
|
|
||||||
assert settings.db_min_connections == 2
|
|
||||||
assert settings.db_max_connections == 10
|
|
||||||
|
|
||||||
# Protocol defaults
|
|
||||||
assert settings.opcua_enabled is True
|
|
||||||
assert settings.opcua_port == 4840
|
|
||||||
assert settings.modbus_enabled is True
|
|
||||||
assert settings.modbus_port == 502
|
|
||||||
assert settings.rest_api_enabled is True
|
|
||||||
assert settings.rest_api_port == 8080
|
|
||||||
|
|
||||||
# Safety defaults
|
|
||||||
assert settings.watchdog_enabled is True
|
|
||||||
assert settings.watchdog_timeout_seconds == 1200
|
|
||||||
assert settings.watchdog_check_interval_seconds == 60
|
|
||||||
|
|
||||||
# Auto-discovery defaults
|
|
||||||
assert settings.auto_discovery_enabled is True
|
|
||||||
assert settings.auto_discovery_refresh_minutes == 60
|
|
||||||
|
|
||||||
# Application defaults
|
|
||||||
assert settings.app_name == "Calejo Control Adapter"
|
|
||||||
assert settings.app_version == "2.0.0"
|
|
||||||
assert settings.environment == "development"
|
|
||||||
|
|
||||||
def test_database_url_property(self):
|
|
||||||
"""Test database URL generation."""
|
|
||||||
settings = Settings()
|
|
||||||
|
|
||||||
expected_url = "postgresql://control_reader:secure_password@localhost:5432/calejo"
|
|
||||||
assert settings.database_url == expected_url
|
|
||||||
|
|
||||||
def test_database_url_with_custom_values(self):
|
|
||||||
"""Test database URL with custom values."""
|
|
||||||
settings = Settings(
|
|
||||||
db_host="test_host",
|
|
||||||
db_port=5433,
|
|
||||||
db_name="test_db",
|
|
||||||
db_user="test_user",
|
|
||||||
db_password="test_password"
|
|
||||||
)
|
|
||||||
|
|
||||||
expected_url = "postgresql://test_user:test_password@test_host:5433/test_db"
|
|
||||||
assert settings.database_url == expected_url
|
|
||||||
|
|
||||||
def test_validate_db_port_valid(self):
|
|
||||||
"""Test valid database port validation."""
|
|
||||||
settings = Settings(db_port=5432)
|
|
||||||
assert settings.db_port == 5432
|
|
||||||
|
|
||||||
def test_validate_db_port_invalid(self):
|
|
||||||
"""Test invalid database port validation."""
|
|
||||||
with pytest.raises(ValueError, match="Database port must be between 1 and 65535"):
|
|
||||||
Settings(db_port=0)
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="Database port must be between 1 and 65535"):
|
|
||||||
Settings(db_port=65536)
|
|
||||||
|
|
||||||
def test_validate_opcua_port_valid(self):
|
|
||||||
"""Test valid OPC UA port validation."""
|
|
||||||
settings = Settings(opcua_port=4840)
|
|
||||||
assert settings.opcua_port == 4840
|
|
||||||
|
|
||||||
def test_validate_opcua_port_invalid(self):
|
|
||||||
"""Test invalid OPC UA port validation."""
|
|
||||||
with pytest.raises(ValueError, match="OPC UA port must be between 1 and 65535"):
|
|
||||||
Settings(opcua_port=0)
|
|
||||||
|
|
||||||
def test_validate_modbus_port_valid(self):
|
|
||||||
"""Test valid Modbus port validation."""
|
|
||||||
settings = Settings(modbus_port=502)
|
|
||||||
assert settings.modbus_port == 502
|
|
||||||
|
|
||||||
def test_validate_modbus_port_invalid(self):
|
|
||||||
"""Test invalid Modbus port validation."""
|
|
||||||
with pytest.raises(ValueError, match="Modbus port must be between 1 and 65535"):
|
|
||||||
Settings(modbus_port=70000)
|
|
||||||
|
|
||||||
def test_validate_rest_api_port_valid(self):
|
|
||||||
"""Test valid REST API port validation."""
|
|
||||||
settings = Settings(rest_api_port=8080)
|
|
||||||
assert settings.rest_api_port == 8080
|
|
||||||
|
|
||||||
def test_validate_rest_api_port_invalid(self):
|
|
||||||
"""Test invalid REST API port validation."""
|
|
||||||
with pytest.raises(ValueError, match="REST API port must be between 1 and 65535"):
|
|
||||||
Settings(rest_api_port=-1)
|
|
||||||
|
|
||||||
def test_validate_log_level_valid(self):
|
|
||||||
"""Test valid log level validation."""
|
|
||||||
for level in ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']:
|
|
||||||
settings = Settings(log_level=level.lower())
|
|
||||||
assert settings.log_level == level
|
|
||||||
|
|
||||||
def test_validate_log_level_invalid(self):
|
|
||||||
"""Test invalid log level validation."""
|
|
||||||
with pytest.raises(ValueError, match="Log level must be one of:"):
|
|
||||||
Settings(log_level="INVALID")
|
|
||||||
|
|
||||||
def test_parse_recipients_string(self):
|
|
||||||
"""Test parsing recipients from comma-separated string."""
|
|
||||||
settings = Settings(
|
|
||||||
alert_email_recipients="user1@test.com, user2@test.com,user3@test.com"
|
|
||||||
)
|
|
||||||
|
|
||||||
assert settings.alert_email_recipients == [
|
|
||||||
"user1@test.com",
|
|
||||||
"user2@test.com",
|
|
||||||
"user3@test.com"
|
|
||||||
]
|
|
||||||
|
|
||||||
def test_parse_recipients_list(self):
|
|
||||||
"""Test parsing recipients from list."""
|
|
||||||
recipients = ["user1@test.com", "user2@test.com"]
|
|
||||||
settings = Settings(alert_email_recipients=recipients)
|
|
||||||
|
|
||||||
assert settings.alert_email_recipients == recipients
|
|
||||||
|
|
||||||
def test_get_sensitive_fields(self):
|
|
||||||
"""Test getting list of sensitive fields."""
|
|
||||||
settings = Settings()
|
|
||||||
sensitive_fields = settings.get_sensitive_fields()
|
|
||||||
|
|
||||||
expected_fields = [
|
|
||||||
'db_password',
|
|
||||||
'api_key',
|
|
||||||
'smtp_password',
|
|
||||||
'twilio_auth_token',
|
|
||||||
'alert_webhook_token'
|
|
||||||
]
|
|
||||||
|
|
||||||
assert set(sensitive_fields) == set(expected_fields)
|
|
||||||
|
|
||||||
def test_get_safe_dict(self):
|
|
||||||
"""Test getting settings dictionary with masked sensitive fields."""
|
|
||||||
settings = Settings(
|
|
||||||
db_password="secret_password",
|
|
||||||
api_key="secret_api_key",
|
|
||||||
smtp_password="secret_smtp_password",
|
|
||||||
twilio_auth_token="secret_twilio_token",
|
|
||||||
alert_webhook_token="secret_webhook_token"
|
|
||||||
)
|
|
||||||
|
|
||||||
safe_dict = settings.get_safe_dict()
|
|
||||||
|
|
||||||
# Check that sensitive fields are masked
|
|
||||||
assert safe_dict['db_password'] == '***MASKED***'
|
|
||||||
assert safe_dict['api_key'] == '***MASKED***'
|
|
||||||
assert safe_dict['smtp_password'] == '***MASKED***'
|
|
||||||
assert safe_dict['twilio_auth_token'] == '***MASKED***'
|
|
||||||
assert safe_dict['alert_webhook_token'] == '***MASKED***'
|
|
||||||
|
|
||||||
# Check that non-sensitive fields are not masked
|
|
||||||
assert safe_dict['db_host'] == 'localhost'
|
|
||||||
assert safe_dict['db_port'] == 5432
|
|
||||||
|
|
||||||
def test_get_safe_dict_with_none_values(self):
|
|
||||||
"""Test safe dict with None values for sensitive fields."""
|
|
||||||
# Pydantic v2 doesn't allow None for string fields by default
|
|
||||||
# Use empty strings instead
|
|
||||||
settings = Settings(
|
|
||||||
db_password="",
|
|
||||||
api_key=""
|
|
||||||
)
|
|
||||||
|
|
||||||
safe_dict = settings.get_safe_dict()
|
|
||||||
|
|
||||||
# Empty values should be masked
|
|
||||||
# Note: The current implementation only masks non-empty values
|
|
||||||
# So empty strings remain empty
|
|
||||||
assert safe_dict['db_password'] == ''
|
|
||||||
assert safe_dict['api_key'] == ''
|
|
||||||
|
|
||||||
def test_settings_from_environment_variables(self):
|
|
||||||
"""Test loading settings from environment variables."""
|
|
||||||
with patch.dict(os.environ, {
|
|
||||||
'DB_HOST': 'env_host',
|
|
||||||
'DB_PORT': '5433',
|
|
||||||
'DB_NAME': 'env_db',
|
|
||||||
'DB_USER': 'env_user',
|
|
||||||
'DB_PASSWORD': 'env_password',
|
|
||||||
'LOG_LEVEL': 'DEBUG',
|
|
||||||
'ENVIRONMENT': 'production'
|
|
||||||
}):
|
|
||||||
settings = Settings()
|
|
||||||
|
|
||||||
assert settings.db_host == 'env_host'
|
|
||||||
assert settings.db_port == 5433
|
|
||||||
assert settings.db_name == 'env_db'
|
|
||||||
assert settings.db_user == 'env_user'
|
|
||||||
assert settings.db_password == 'env_password'
|
|
||||||
assert settings.log_level == 'DEBUG'
|
|
||||||
assert settings.environment == 'production'
|
|
||||||
|
|
||||||
def test_settings_case_insensitive(self):
|
|
||||||
"""Test that settings are case-insensitive."""
|
|
||||||
with patch.dict(os.environ, {
|
|
||||||
'db_host': 'lowercase_host',
|
|
||||||
'DB_PORT': '5434'
|
|
||||||
}):
|
|
||||||
settings = Settings()
|
|
||||||
|
|
||||||
assert settings.db_host == 'lowercase_host'
|
|
||||||
assert settings.db_port == 5434
|
|
||||||
|
|
||||||
def test_settings_with_env_file(self):
|
|
||||||
"""Test loading settings from .env file."""
|
|
||||||
env_content = """
|
|
||||||
DB_HOST=file_host
|
|
||||||
DB_PORT=5435
|
|
||||||
DB_NAME=file_db
|
|
||||||
LOG_LEVEL=WARNING
|
|
||||||
"""
|
|
||||||
|
|
||||||
with patch('builtins.open', mock_open(read_data=env_content)):
|
|
||||||
with patch('os.path.exists', return_value=True):
|
|
||||||
# Pydantic v2 loads .env files differently
|
|
||||||
# We need to test the actual behavior
|
|
||||||
settings = Settings()
|
|
||||||
|
|
||||||
# The test might not work as expected with Pydantic v2
|
|
||||||
# Let's just verify the settings object can be created
|
|
||||||
assert isinstance(settings, Settings)
|
|
||||||
assert hasattr(settings, 'db_host')
|
|
||||||
assert hasattr(settings, 'db_port')
|
|
||||||
assert hasattr(settings, 'db_name')
|
|
||||||
assert hasattr(settings, 'log_level')
|
|
||||||
|
|
@ -1,169 +0,0 @@
|
||||||
"""
|
|
||||||
Unit tests for DatabaseClient class.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
import pytest_asyncio
|
|
||||||
from unittest.mock import Mock, patch, MagicMock
|
|
||||||
from typing import Dict, Any
|
|
||||||
|
|
||||||
from src.database.client import DatabaseClient
|
|
||||||
|
|
||||||
|
|
||||||
class TestDatabaseClient:
|
|
||||||
"""Test cases for DatabaseClient."""
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
|
||||||
async def db_client(self):
|
|
||||||
"""Create a test database client."""
|
|
||||||
client = DatabaseClient(
|
|
||||||
database_url="postgresql://test:test@localhost:5432/test",
|
|
||||||
min_connections=1,
|
|
||||||
max_connections=2
|
|
||||||
)
|
|
||||||
|
|
||||||
# Mock the connection pool
|
|
||||||
client.connection_pool = Mock()
|
|
||||||
client.connection_pool.getconn.return_value = Mock()
|
|
||||||
client.connection_pool.putconn = Mock()
|
|
||||||
|
|
||||||
return client
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_connect_success(self, db_client):
|
|
||||||
"""Test successful database connection."""
|
|
||||||
with patch('psycopg2.pool.ThreadedConnectionPool') as mock_pool:
|
|
||||||
mock_pool_instance = Mock()
|
|
||||||
mock_pool.return_value = mock_pool_instance
|
|
||||||
|
|
||||||
mock_conn = MagicMock()
|
|
||||||
mock_cursor = MagicMock()
|
|
||||||
mock_cursor.fetchone.return_value = {'version': 'PostgreSQL 14.0'}
|
|
||||||
mock_conn.cursor.return_value.__enter__.return_value = mock_cursor
|
|
||||||
mock_conn.cursor.return_value.__exit__.return_value = None
|
|
||||||
|
|
||||||
mock_pool_instance.getconn.return_value = mock_conn
|
|
||||||
|
|
||||||
await db_client.connect()
|
|
||||||
|
|
||||||
assert db_client.connection_pool is not None
|
|
||||||
mock_pool.assert_called_once()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_connect_failure(self, db_client):
|
|
||||||
"""Test database connection failure."""
|
|
||||||
with patch('psycopg2.pool.ThreadedConnectionPool') as mock_pool:
|
|
||||||
mock_pool.side_effect = Exception("Connection failed")
|
|
||||||
|
|
||||||
with pytest.raises(Exception, match="Connection failed"):
|
|
||||||
await db_client.connect()
|
|
||||||
|
|
||||||
def test_execute_query_success(self, db_client):
|
|
||||||
"""Test successful query execution."""
|
|
||||||
# Mock the cursor
|
|
||||||
mock_cursor = MagicMock()
|
|
||||||
mock_cursor.fetchall.return_value = [{'id': 1, 'name': 'test'}]
|
|
||||||
|
|
||||||
# Mock the cursor context manager
|
|
||||||
db_client._get_cursor = MagicMock()
|
|
||||||
db_client._get_cursor.return_value.__enter__.return_value = mock_cursor
|
|
||||||
db_client._get_cursor.return_value.__exit__.return_value = None
|
|
||||||
|
|
||||||
result = db_client.execute_query("SELECT * FROM test", (1,))
|
|
||||||
|
|
||||||
assert result == [{'id': 1, 'name': 'test'}]
|
|
||||||
mock_cursor.execute.assert_called_once_with("SELECT * FROM test", (1,))
|
|
||||||
|
|
||||||
def test_execute_query_failure(self, db_client):
|
|
||||||
"""Test query execution failure."""
|
|
||||||
# Mock the cursor to raise an exception
|
|
||||||
mock_cursor = MagicMock()
|
|
||||||
mock_cursor.execute.side_effect = Exception("Query failed")
|
|
||||||
|
|
||||||
# Mock the cursor context manager
|
|
||||||
db_client._get_cursor = MagicMock()
|
|
||||||
db_client._get_cursor.return_value.__enter__.return_value = mock_cursor
|
|
||||||
db_client._get_cursor.return_value.__exit__.return_value = None
|
|
||||||
|
|
||||||
with pytest.raises(Exception, match="Query failed"):
|
|
||||||
db_client.execute_query("SELECT * FROM test")
|
|
||||||
|
|
||||||
def test_execute_success(self, db_client):
|
|
||||||
"""Test successful execute operation."""
|
|
||||||
# Mock the cursor
|
|
||||||
mock_cursor = MagicMock()
|
|
||||||
|
|
||||||
# Mock the cursor context manager
|
|
||||||
db_client._get_cursor = MagicMock()
|
|
||||||
db_client._get_cursor.return_value.__enter__.return_value = mock_cursor
|
|
||||||
db_client._get_cursor.return_value.__exit__.return_value = None
|
|
||||||
|
|
||||||
db_client.execute("INSERT INTO test VALUES (%s)", (1,))
|
|
||||||
|
|
||||||
mock_cursor.execute.assert_called_once_with("INSERT INTO test VALUES (%s)", (1,))
|
|
||||||
|
|
||||||
def test_health_check_success(self, db_client):
|
|
||||||
"""Test successful health check."""
|
|
||||||
# Mock the cursor
|
|
||||||
mock_cursor = MagicMock()
|
|
||||||
mock_cursor.fetchone.return_value = {'health_check': 1}
|
|
||||||
|
|
||||||
# Mock the cursor context manager
|
|
||||||
db_client._get_cursor = MagicMock()
|
|
||||||
db_client._get_cursor.return_value.__enter__.return_value = mock_cursor
|
|
||||||
db_client._get_cursor.return_value.__exit__.return_value = None
|
|
||||||
|
|
||||||
result = db_client.health_check()
|
|
||||||
|
|
||||||
assert result is True
|
|
||||||
mock_cursor.execute.assert_called_once_with("SELECT 1 as health_check;")
|
|
||||||
|
|
||||||
def test_health_check_failure(self, db_client):
|
|
||||||
"""Test health check failure."""
|
|
||||||
# Mock the cursor to raise an exception
|
|
||||||
mock_cursor = MagicMock()
|
|
||||||
mock_cursor.execute.side_effect = Exception("Health check failed")
|
|
||||||
|
|
||||||
# Mock the cursor context manager
|
|
||||||
db_client._get_cursor = MagicMock()
|
|
||||||
db_client._get_cursor.return_value.__enter__.return_value = mock_cursor
|
|
||||||
db_client._get_cursor.return_value.__exit__.return_value = None
|
|
||||||
|
|
||||||
result = db_client.health_check()
|
|
||||||
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
def test_get_connection_stats(self, db_client):
|
|
||||||
"""Test connection statistics retrieval."""
|
|
||||||
stats = db_client.get_connection_stats()
|
|
||||||
|
|
||||||
expected = {
|
|
||||||
"min_connections": 1,
|
|
||||||
"max_connections": 2,
|
|
||||||
"pool_status": "active"
|
|
||||||
}
|
|
||||||
assert stats == expected
|
|
||||||
|
|
||||||
def test_get_connection_stats_no_pool(self):
|
|
||||||
"""Test connection statistics when pool is not initialized."""
|
|
||||||
client = DatabaseClient("test_url")
|
|
||||||
stats = client.get_connection_stats()
|
|
||||||
|
|
||||||
assert stats == {"status": "pool_not_initialized"}
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_disconnect(self, db_client):
|
|
||||||
"""Test database disconnection."""
|
|
||||||
db_client.connection_pool.closeall = Mock()
|
|
||||||
|
|
||||||
await db_client.disconnect()
|
|
||||||
|
|
||||||
db_client.connection_pool.closeall.assert_called_once()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_disconnect_no_pool(self, db_client):
|
|
||||||
"""Test disconnection when no pool exists."""
|
|
||||||
db_client.connection_pool = None
|
|
||||||
|
|
||||||
# Should not raise an exception
|
|
||||||
await db_client.disconnect()
|
|
||||||
|
|
@ -1,152 +0,0 @@
|
||||||
"""
|
|
||||||
Unit tests for EmergencyStopManager.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from unittest.mock import Mock, AsyncMock
|
|
||||||
|
|
||||||
from src.core.emergency_stop import EmergencyStopManager
|
|
||||||
|
|
||||||
|
|
||||||
class TestEmergencyStopManager:
|
|
||||||
"""Test cases for EmergencyStopManager."""
|
|
||||||
|
|
||||||
def setup_method(self):
|
|
||||||
"""Set up test fixtures."""
|
|
||||||
self.mock_db_client = Mock()
|
|
||||||
self.mock_db_client.execute = Mock()
|
|
||||||
self.emergency_stop_manager = EmergencyStopManager(self.mock_db_client)
|
|
||||||
|
|
||||||
def test_emergency_stop_pump(self):
|
|
||||||
"""Test emergency stop for a specific pump."""
|
|
||||||
# Act
|
|
||||||
result = self.emergency_stop_manager.emergency_stop_pump(
|
|
||||||
station_id='STATION_001',
|
|
||||||
pump_id='PUMP_001',
|
|
||||||
reason='Test emergency stop',
|
|
||||||
user_id='test_user'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Assert
|
|
||||||
assert result is True
|
|
||||||
assert self.emergency_stop_manager.is_emergency_stop_active('STATION_001', 'PUMP_001') is True
|
|
||||||
assert self.mock_db_client.execute.called
|
|
||||||
|
|
||||||
def test_emergency_stop_station(self):
|
|
||||||
"""Test emergency stop for all pumps in a station."""
|
|
||||||
# Act
|
|
||||||
result = self.emergency_stop_manager.emergency_stop_station(
|
|
||||||
station_id='STATION_001',
|
|
||||||
reason='Test station stop',
|
|
||||||
user_id='test_user'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Assert
|
|
||||||
assert result is True
|
|
||||||
assert self.emergency_stop_manager.is_emergency_stop_active('STATION_001', 'PUMP_001') is True
|
|
||||||
assert self.emergency_stop_manager.is_emergency_stop_active('STATION_001', 'PUMP_002') is True
|
|
||||||
assert self.mock_db_client.execute.called
|
|
||||||
|
|
||||||
def test_emergency_stop_system(self):
|
|
||||||
"""Test system-wide emergency stop."""
|
|
||||||
# Act
|
|
||||||
result = self.emergency_stop_manager.emergency_stop_system(
|
|
||||||
reason='Test system stop',
|
|
||||||
user_id='test_user'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Assert
|
|
||||||
assert result is True
|
|
||||||
assert self.emergency_stop_manager.is_emergency_stop_active('STATION_001', 'PUMP_001') is True
|
|
||||||
assert self.emergency_stop_manager.is_emergency_stop_active('STATION_002', 'PUMP_001') is True
|
|
||||||
assert self.mock_db_client.execute.called
|
|
||||||
|
|
||||||
def test_clear_emergency_stop_pump(self):
|
|
||||||
"""Test clearing emergency stop for a specific pump."""
|
|
||||||
# Arrange
|
|
||||||
self.emergency_stop_manager.emergency_stop_pump('STATION_001', 'PUMP_001')
|
|
||||||
|
|
||||||
# Act
|
|
||||||
result = self.emergency_stop_manager.clear_emergency_stop_pump(
|
|
||||||
station_id='STATION_001',
|
|
||||||
pump_id='PUMP_001',
|
|
||||||
reason='Test clearance',
|
|
||||||
user_id='test_user'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Assert
|
|
||||||
assert result is True
|
|
||||||
assert self.emergency_stop_manager.is_emergency_stop_active('STATION_001', 'PUMP_001') is False
|
|
||||||
assert self.mock_db_client.execute.call_count == 2 # Stop + clearance
|
|
||||||
|
|
||||||
def test_clear_emergency_stop_station(self):
|
|
||||||
"""Test clearing emergency stop for a station."""
|
|
||||||
# Arrange
|
|
||||||
self.emergency_stop_manager.emergency_stop_station('STATION_001')
|
|
||||||
|
|
||||||
# Act
|
|
||||||
result = self.emergency_stop_manager.clear_emergency_stop_station(
|
|
||||||
station_id='STATION_001',
|
|
||||||
reason='Test clearance',
|
|
||||||
user_id='test_user'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Assert
|
|
||||||
assert result is True
|
|
||||||
assert self.emergency_stop_manager.is_emergency_stop_active('STATION_001', 'PUMP_001') is False
|
|
||||||
assert self.mock_db_client.execute.call_count == 2 # Stop + clearance
|
|
||||||
|
|
||||||
def test_clear_emergency_stop_system(self):
|
|
||||||
"""Test clearing system-wide emergency stop."""
|
|
||||||
# Arrange
|
|
||||||
self.emergency_stop_manager.emergency_stop_system()
|
|
||||||
|
|
||||||
# Act
|
|
||||||
result = self.emergency_stop_manager.clear_emergency_stop_system(
|
|
||||||
reason='Test clearance',
|
|
||||||
user_id='test_user'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Assert
|
|
||||||
assert result is True
|
|
||||||
assert self.emergency_stop_manager.is_emergency_stop_active('STATION_001', 'PUMP_001') is False
|
|
||||||
assert self.mock_db_client.execute.call_count == 2 # Stop + clearance
|
|
||||||
|
|
||||||
def test_clear_nonexistent_emergency_stop(self):
|
|
||||||
"""Test clearing emergency stop that doesn't exist."""
|
|
||||||
# Act
|
|
||||||
result = self.emergency_stop_manager.clear_emergency_stop_pump('STATION_001', 'PUMP_001')
|
|
||||||
|
|
||||||
# Assert
|
|
||||||
assert result is False
|
|
||||||
assert self.mock_db_client.execute.call_count == 0
|
|
||||||
|
|
||||||
def test_emergency_stop_priority(self):
|
|
||||||
"""Test that system stop overrides station and pump stops."""
|
|
||||||
# Arrange
|
|
||||||
self.emergency_stop_manager.emergency_stop_pump('STATION_001', 'PUMP_001')
|
|
||||||
self.emergency_stop_manager.emergency_stop_station('STATION_002')
|
|
||||||
|
|
||||||
# Act
|
|
||||||
self.emergency_stop_manager.emergency_stop_system()
|
|
||||||
|
|
||||||
# Assert
|
|
||||||
assert self.emergency_stop_manager.is_emergency_stop_active('STATION_001', 'PUMP_001') is True
|
|
||||||
assert self.emergency_stop_manager.is_emergency_stop_active('STATION_002', 'PUMP_001') is True
|
|
||||||
assert self.emergency_stop_manager.is_emergency_stop_active('STATION_003', 'PUMP_001') is True
|
|
||||||
|
|
||||||
def test_get_emergency_stop_status(self):
|
|
||||||
"""Test getting emergency stop status."""
|
|
||||||
# Arrange
|
|
||||||
self.emergency_stop_manager.emergency_stop_pump('STATION_001', 'PUMP_001')
|
|
||||||
self.emergency_stop_manager.emergency_stop_station('STATION_002')
|
|
||||||
self.emergency_stop_manager.emergency_stop_system()
|
|
||||||
|
|
||||||
# Act
|
|
||||||
status = self.emergency_stop_manager.get_emergency_stop_status()
|
|
||||||
|
|
||||||
# Assert
|
|
||||||
assert status['system_emergency_stop'] is True
|
|
||||||
assert 'STATION_002' in status['emergency_stop_stations']
|
|
||||||
assert {'station_id': 'STATION_001', 'pump_id': 'PUMP_001'} in status['emergency_stop_pumps']
|
|
||||||
assert status['total_active_stops'] == 3 # system + station + pump
|
|
||||||
|
|
@ -1,230 +0,0 @@
|
||||||
"""
|
|
||||||
Unit tests for SafetyLimitEnforcer class.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from unittest.mock import Mock, patch
|
|
||||||
from typing import Dict, Any
|
|
||||||
|
|
||||||
from src.core.safety import SafetyLimitEnforcer, SafetyLimits
|
|
||||||
|
|
||||||
|
|
||||||
class TestSafetyLimitEnforcer:
|
|
||||||
"""Test cases for SafetyLimitEnforcer."""
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def safety_enforcer(self):
|
|
||||||
"""Create a test safety enforcer."""
|
|
||||||
mock_db_client = Mock()
|
|
||||||
enforcer = SafetyLimitEnforcer(mock_db_client)
|
|
||||||
return enforcer
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_safety_limits(self):
|
|
||||||
"""Create mock safety limits."""
|
|
||||||
return SafetyLimits(
|
|
||||||
hard_min_speed_hz=20.0,
|
|
||||||
hard_max_speed_hz=50.0,
|
|
||||||
hard_min_level_m=1.0,
|
|
||||||
hard_max_level_m=4.0,
|
|
||||||
hard_max_power_kw=60.0,
|
|
||||||
max_speed_change_hz_per_min=5.0
|
|
||||||
)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_load_safety_limits_success(self, safety_enforcer):
|
|
||||||
"""Test successful loading of safety limits."""
|
|
||||||
# Mock database response
|
|
||||||
mock_limits = [
|
|
||||||
{
|
|
||||||
'station_id': 'STATION_001',
|
|
||||||
'pump_id': 'PUMP_001',
|
|
||||||
'hard_min_speed_hz': 20.0,
|
|
||||||
'hard_max_speed_hz': 50.0,
|
|
||||||
'hard_min_level_m': 1.0,
|
|
||||||
'hard_max_level_m': 4.0,
|
|
||||||
'hard_max_power_kw': 60.0,
|
|
||||||
'max_speed_change_hz_per_min': 5.0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
safety_enforcer.db_client.get_safety_limits.return_value = mock_limits
|
|
||||||
|
|
||||||
await safety_enforcer.load_safety_limits()
|
|
||||||
|
|
||||||
# Verify limits were loaded
|
|
||||||
assert len(safety_enforcer.safety_limits_cache) == 1
|
|
||||||
key = ('STATION_001', 'PUMP_001')
|
|
||||||
assert key in safety_enforcer.safety_limits_cache
|
|
||||||
|
|
||||||
limits = safety_enforcer.safety_limits_cache[key]
|
|
||||||
assert limits.hard_min_speed_hz == 20.0
|
|
||||||
assert limits.hard_max_speed_hz == 50.0
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_load_safety_limits_failure(self, safety_enforcer):
|
|
||||||
"""Test loading safety limits failure."""
|
|
||||||
safety_enforcer.db_client.get_safety_limits.side_effect = Exception("Database error")
|
|
||||||
|
|
||||||
with pytest.raises(Exception, match="Database error"):
|
|
||||||
await safety_enforcer.load_safety_limits()
|
|
||||||
|
|
||||||
def test_enforce_setpoint_within_limits(self, safety_enforcer, mock_safety_limits):
|
|
||||||
"""Test setpoint within limits is not modified."""
|
|
||||||
safety_enforcer.safety_limits_cache[('STATION_001', 'PUMP_001')] = mock_safety_limits
|
|
||||||
|
|
||||||
enforced, violations = safety_enforcer.enforce_setpoint('STATION_001', 'PUMP_001', 35.0)
|
|
||||||
|
|
||||||
assert enforced == 35.0
|
|
||||||
assert violations == []
|
|
||||||
|
|
||||||
def test_enforce_setpoint_below_min(self, safety_enforcer, mock_safety_limits):
|
|
||||||
"""Test setpoint below minimum is clamped."""
|
|
||||||
safety_enforcer.safety_limits_cache[('STATION_001', 'PUMP_001')] = mock_safety_limits
|
|
||||||
|
|
||||||
enforced, violations = safety_enforcer.enforce_setpoint('STATION_001', 'PUMP_001', 15.0)
|
|
||||||
|
|
||||||
assert enforced == 20.0 # Clamped to hard_min_speed_hz
|
|
||||||
assert len(violations) == 1
|
|
||||||
assert "BELOW_MIN_SPEED" in violations[0]
|
|
||||||
|
|
||||||
def test_enforce_setpoint_above_max(self, safety_enforcer, mock_safety_limits):
|
|
||||||
"""Test setpoint above maximum is clamped."""
|
|
||||||
safety_enforcer.safety_limits_cache[('STATION_001', 'PUMP_001')] = mock_safety_limits
|
|
||||||
|
|
||||||
enforced, violations = safety_enforcer.enforce_setpoint('STATION_001', 'PUMP_001', 55.0)
|
|
||||||
|
|
||||||
assert enforced == 50.0 # Clamped to hard_max_speed_hz
|
|
||||||
assert len(violations) == 1
|
|
||||||
assert "ABOVE_MAX_SPEED" in violations[0]
|
|
||||||
|
|
||||||
def test_enforce_setpoint_no_limits(self, safety_enforcer):
|
|
||||||
"""Test setpoint without safety limits defined."""
|
|
||||||
enforced, violations = safety_enforcer.enforce_setpoint('STATION_001', 'PUMP_001', 35.0)
|
|
||||||
|
|
||||||
assert enforced == 0.0 # Default to 0 when no limits
|
|
||||||
assert violations == ["NO_SAFETY_LIMITS_DEFINED"]
|
|
||||||
|
|
||||||
def test_enforce_setpoint_with_rate_limit(self, safety_enforcer, mock_safety_limits):
|
|
||||||
"""Test setpoint with rate of change limit."""
|
|
||||||
safety_enforcer.safety_limits_cache[('STATION_001', 'PUMP_001')] = mock_safety_limits
|
|
||||||
|
|
||||||
# Set previous setpoint
|
|
||||||
safety_enforcer.previous_setpoints[('STATION_001', 'PUMP_001')] = 30.0
|
|
||||||
|
|
||||||
# Test large increase that exceeds rate limit but is within max speed
|
|
||||||
# 30 + 26 = 56, which exceeds 25 Hz limit but is within 50 Hz max
|
|
||||||
enforced, violations = safety_enforcer.enforce_setpoint('STATION_001', 'PUMP_001', 56.0)
|
|
||||||
|
|
||||||
# Should be limited to 30 + 25 = 55.0 (max 5 Hz/min * 5 min = 25 Hz increase)
|
|
||||||
# But since 55.0 is within the hard_max_speed_hz of 50.0, it gets clamped to 50.0
|
|
||||||
assert enforced == 50.0
|
|
||||||
assert len(violations) == 1 # Only max speed violation (rate limit not triggered due to clamping)
|
|
||||||
violation_types = [v.split(':')[0] for v in violations]
|
|
||||||
assert "ABOVE_MAX_SPEED" in violation_types
|
|
||||||
|
|
||||||
def test_enforce_setpoint_multiple_violations(self, safety_enforcer, mock_safety_limits):
|
|
||||||
"""Test setpoint with multiple violations."""
|
|
||||||
safety_enforcer.safety_limits_cache[('STATION_001', 'PUMP_001')] = mock_safety_limits
|
|
||||||
|
|
||||||
# Set previous setpoint
|
|
||||||
safety_enforcer.previous_setpoints[('STATION_001', 'PUMP_001')] = 30.0
|
|
||||||
|
|
||||||
# Test setpoint that violates max speed
|
|
||||||
enforced, violations = safety_enforcer.enforce_setpoint('STATION_001', 'PUMP_001', 60.0)
|
|
||||||
|
|
||||||
# Should be limited to 50.0 (hard_max_speed_hz)
|
|
||||||
assert enforced == 50.0
|
|
||||||
assert len(violations) == 1
|
|
||||||
violation_types = [v.split(':')[0] for v in violations]
|
|
||||||
assert "ABOVE_MAX_SPEED" in violation_types
|
|
||||||
|
|
||||||
def test_get_safety_limits_exists(self, safety_enforcer, mock_safety_limits):
|
|
||||||
"""Test getting existing safety limits."""
|
|
||||||
safety_enforcer.safety_limits_cache[('STATION_001', 'PUMP_001')] = mock_safety_limits
|
|
||||||
|
|
||||||
limits = safety_enforcer.get_safety_limits('STATION_001', 'PUMP_001')
|
|
||||||
|
|
||||||
assert limits is not None
|
|
||||||
assert limits.hard_min_speed_hz == 20.0
|
|
||||||
|
|
||||||
def test_get_safety_limits_not_exists(self, safety_enforcer):
|
|
||||||
"""Test getting non-existent safety limits."""
|
|
||||||
limits = safety_enforcer.get_safety_limits('STATION_001', 'PUMP_001')
|
|
||||||
|
|
||||||
assert limits is None
|
|
||||||
|
|
||||||
def test_has_safety_limits_exists(self, safety_enforcer, mock_safety_limits):
|
|
||||||
"""Test checking for existing safety limits."""
|
|
||||||
safety_enforcer.safety_limits_cache[('STATION_001', 'PUMP_001')] = mock_safety_limits
|
|
||||||
|
|
||||||
assert safety_enforcer.has_safety_limits('STATION_001', 'PUMP_001') is True
|
|
||||||
|
|
||||||
def test_has_safety_limits_not_exists(self, safety_enforcer):
|
|
||||||
"""Test checking for non-existent safety limits."""
|
|
||||||
assert safety_enforcer.has_safety_limits('STATION_001', 'PUMP_001') is False
|
|
||||||
|
|
||||||
def test_clear_safety_limits(self, safety_enforcer, mock_safety_limits):
|
|
||||||
"""Test clearing safety limits."""
|
|
||||||
safety_enforcer.safety_limits_cache[('STATION_001', 'PUMP_001')] = mock_safety_limits
|
|
||||||
safety_enforcer.previous_setpoints[('STATION_001', 'PUMP_001')] = 35.0
|
|
||||||
|
|
||||||
safety_enforcer.clear_safety_limits('STATION_001', 'PUMP_001')
|
|
||||||
|
|
||||||
assert ('STATION_001', 'PUMP_001') not in safety_enforcer.safety_limits_cache
|
|
||||||
assert ('STATION_001', 'PUMP_001') not in safety_enforcer.previous_setpoints
|
|
||||||
|
|
||||||
def test_clear_all_safety_limits(self, safety_enforcer, mock_safety_limits):
|
|
||||||
"""Test clearing all safety limits."""
|
|
||||||
safety_enforcer.safety_limits_cache[('STATION_001', 'PUMP_001')] = mock_safety_limits
|
|
||||||
safety_enforcer.safety_limits_cache[('STATION_002', 'PUMP_001')] = mock_safety_limits
|
|
||||||
safety_enforcer.previous_setpoints[('STATION_001', 'PUMP_001')] = 35.0
|
|
||||||
|
|
||||||
safety_enforcer.clear_all_safety_limits()
|
|
||||||
|
|
||||||
assert len(safety_enforcer.safety_limits_cache) == 0
|
|
||||||
assert len(safety_enforcer.previous_setpoints) == 0
|
|
||||||
|
|
||||||
def test_get_loaded_limits_count(self, safety_enforcer, mock_safety_limits):
|
|
||||||
"""Test getting count of loaded safety limits."""
|
|
||||||
assert safety_enforcer.get_loaded_limits_count() == 0
|
|
||||||
|
|
||||||
safety_enforcer.safety_limits_cache[('STATION_001', 'PUMP_001')] = mock_safety_limits
|
|
||||||
safety_enforcer.safety_limits_cache[('STATION_002', 'PUMP_001')] = mock_safety_limits
|
|
||||||
|
|
||||||
assert safety_enforcer.get_loaded_limits_count() == 2
|
|
||||||
|
|
||||||
def test_safety_limits_dataclass(self):
|
|
||||||
"""Test SafetyLimits dataclass."""
|
|
||||||
limits = SafetyLimits(
|
|
||||||
hard_min_speed_hz=20.0,
|
|
||||||
hard_max_speed_hz=50.0,
|
|
||||||
hard_min_level_m=1.0,
|
|
||||||
hard_max_level_m=4.0,
|
|
||||||
hard_max_power_kw=60.0,
|
|
||||||
max_speed_change_hz_per_min=5.0
|
|
||||||
)
|
|
||||||
|
|
||||||
assert limits.hard_min_speed_hz == 20.0
|
|
||||||
assert limits.hard_max_speed_hz == 50.0
|
|
||||||
assert limits.hard_min_level_m == 1.0
|
|
||||||
assert limits.hard_max_level_m == 4.0
|
|
||||||
assert limits.hard_max_power_kw == 60.0
|
|
||||||
assert limits.max_speed_change_hz_per_min == 5.0
|
|
||||||
|
|
||||||
def test_safety_limits_with_optional_fields(self):
|
|
||||||
"""Test SafetyLimits with optional fields."""
|
|
||||||
limits = SafetyLimits(
|
|
||||||
hard_min_speed_hz=20.0,
|
|
||||||
hard_max_speed_hz=50.0,
|
|
||||||
hard_min_level_m=None,
|
|
||||||
hard_max_level_m=None,
|
|
||||||
hard_max_power_kw=None,
|
|
||||||
max_speed_change_hz_per_min=5.0
|
|
||||||
)
|
|
||||||
|
|
||||||
assert limits.hard_min_speed_hz == 20.0
|
|
||||||
assert limits.hard_max_speed_hz == 50.0
|
|
||||||
assert limits.hard_min_level_m is None
|
|
||||||
assert limits.hard_max_level_m is None
|
|
||||||
assert limits.hard_max_power_kw is None
|
|
||||||
assert limits.max_speed_change_hz_per_min == 5.0
|
|
||||||
|
|
@ -1,183 +0,0 @@
|
||||||
"""
|
|
||||||
Unit tests for DatabaseWatchdog.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from unittest.mock import Mock, AsyncMock, patch
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
|
|
||||||
from src.monitoring.watchdog import DatabaseWatchdog
|
|
||||||
|
|
||||||
|
|
||||||
class TestDatabaseWatchdog:
|
|
||||||
"""Test cases for DatabaseWatchdog."""
|
|
||||||
|
|
||||||
def setup_method(self):
|
|
||||||
"""Set up test fixtures."""
|
|
||||||
self.mock_db_client = Mock()
|
|
||||||
self.mock_db_client.execute = Mock()
|
|
||||||
self.mock_db_client.get_latest_pump_plans = Mock()
|
|
||||||
self.mock_db_client.get_pump = Mock()
|
|
||||||
|
|
||||||
self.watchdog = DatabaseWatchdog(self.mock_db_client, timeout_seconds=300) # 5 minutes for testing
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_start_stop(self):
|
|
||||||
"""Test starting and stopping the watchdog."""
|
|
||||||
# Act
|
|
||||||
await self.watchdog.start()
|
|
||||||
|
|
||||||
# Assert
|
|
||||||
assert self.watchdog.running is True
|
|
||||||
|
|
||||||
# Act
|
|
||||||
await self.watchdog.stop()
|
|
||||||
|
|
||||||
# Assert
|
|
||||||
assert self.watchdog.running is False
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_check_updates_fresh_plans(self):
|
|
||||||
"""Test checking updates with fresh plans."""
|
|
||||||
# Arrange
|
|
||||||
recent_time = datetime.now() - timedelta(minutes=1)
|
|
||||||
self.mock_db_client.get_latest_pump_plans.return_value = [
|
|
||||||
{
|
|
||||||
'station_id': 'STATION_001',
|
|
||||||
'pump_id': 'PUMP_001',
|
|
||||||
'plan_updated_at': recent_time
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
# Act
|
|
||||||
await self.watchdog._check_updates()
|
|
||||||
|
|
||||||
# Assert
|
|
||||||
assert self.watchdog.is_failsafe_active('STATION_001', 'PUMP_001') is False
|
|
||||||
assert self.mock_db_client.get_latest_pump_plans.called
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_check_updates_stale_plans(self):
|
|
||||||
"""Test checking updates with stale plans."""
|
|
||||||
# Arrange
|
|
||||||
stale_time = datetime.now() - timedelta(minutes=10) # 10 minutes old
|
|
||||||
self.mock_db_client.get_latest_pump_plans.return_value = [
|
|
||||||
{
|
|
||||||
'station_id': 'STATION_001',
|
|
||||||
'pump_id': 'PUMP_001',
|
|
||||||
'plan_updated_at': stale_time
|
|
||||||
}
|
|
||||||
]
|
|
||||||
self.mock_db_client.get_pump.return_value = {
|
|
||||||
'default_setpoint_hz': 30.0
|
|
||||||
}
|
|
||||||
|
|
||||||
# Act
|
|
||||||
await self.watchdog._check_updates()
|
|
||||||
|
|
||||||
# Assert
|
|
||||||
assert self.watchdog.is_failsafe_active('STATION_001', 'PUMP_001') is True
|
|
||||||
assert self.mock_db_client.execute.called # Should record failsafe event
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_check_updates_no_plans(self):
|
|
||||||
"""Test checking updates when no plans exist."""
|
|
||||||
# Arrange
|
|
||||||
self.mock_db_client.get_latest_pump_plans.return_value = []
|
|
||||||
|
|
||||||
# Act
|
|
||||||
await self.watchdog._check_updates()
|
|
||||||
|
|
||||||
# Assert
|
|
||||||
assert self.mock_db_client.get_latest_pump_plans.called
|
|
||||||
# Should not trigger failsafe immediately
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_activate_failsafe(self):
|
|
||||||
"""Test activating failsafe mode."""
|
|
||||||
# Arrange
|
|
||||||
self.mock_db_client.get_pump.return_value = {
|
|
||||||
'default_setpoint_hz': 30.0
|
|
||||||
}
|
|
||||||
|
|
||||||
# Act
|
|
||||||
await self.watchdog._activate_failsafe('STATION_001', 'PUMP_001', 350)
|
|
||||||
|
|
||||||
# Assert
|
|
||||||
assert self.watchdog.is_failsafe_active('STATION_001', 'PUMP_001') is True
|
|
||||||
assert self.mock_db_client.execute.called
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_deactivate_failsafe(self):
|
|
||||||
"""Test deactivating failsafe mode."""
|
|
||||||
# Arrange
|
|
||||||
await self.watchdog._activate_failsafe('STATION_001', 'PUMP_001', 350)
|
|
||||||
|
|
||||||
# Act
|
|
||||||
await self.watchdog._deactivate_failsafe('STATION_001', 'PUMP_001')
|
|
||||||
|
|
||||||
# Assert
|
|
||||||
assert self.watchdog.is_failsafe_active('STATION_001', 'PUMP_001') is False
|
|
||||||
assert self.mock_db_client.execute.call_count == 2 # Activation + deactivation
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_failsafe_recovery(self):
|
|
||||||
"""Test failsafe recovery when updates resume."""
|
|
||||||
# Arrange - First check with stale plans (trigger failsafe)
|
|
||||||
stale_time = datetime.now() - timedelta(minutes=10)
|
|
||||||
self.mock_db_client.get_latest_pump_plans.return_value = [
|
|
||||||
{
|
|
||||||
'station_id': 'STATION_001',
|
|
||||||
'pump_id': 'PUMP_001',
|
|
||||||
'plan_updated_at': stale_time
|
|
||||||
}
|
|
||||||
]
|
|
||||||
self.mock_db_client.get_pump.return_value = {'default_setpoint_hz': 30.0}
|
|
||||||
|
|
||||||
await self.watchdog._check_updates()
|
|
||||||
assert self.watchdog.is_failsafe_active('STATION_001', 'PUMP_001') is True
|
|
||||||
|
|
||||||
# Arrange - Second check with fresh plans (should recover)
|
|
||||||
recent_time = datetime.now() - timedelta(minutes=1)
|
|
||||||
self.mock_db_client.get_latest_pump_plans.return_value = [
|
|
||||||
{
|
|
||||||
'station_id': 'STATION_001',
|
|
||||||
'pump_id': 'PUMP_001',
|
|
||||||
'plan_updated_at': recent_time
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
# Act
|
|
||||||
await self.watchdog._check_updates()
|
|
||||||
|
|
||||||
# Assert
|
|
||||||
assert self.watchdog.is_failsafe_active('STATION_001', 'PUMP_001') is False
|
|
||||||
|
|
||||||
def test_get_last_update_time(self):
|
|
||||||
"""Test getting last update time."""
|
|
||||||
# Arrange
|
|
||||||
test_time = datetime.now()
|
|
||||||
self.watchdog.last_update_times[('STATION_001', 'PUMP_001')] = test_time
|
|
||||||
|
|
||||||
# Act
|
|
||||||
result = self.watchdog.get_last_update_time('STATION_001', 'PUMP_001')
|
|
||||||
|
|
||||||
# Assert
|
|
||||||
assert result == test_time
|
|
||||||
|
|
||||||
def test_get_status(self):
|
|
||||||
"""Test getting watchdog status."""
|
|
||||||
# Arrange
|
|
||||||
test_time = datetime.now() - timedelta(minutes=2)
|
|
||||||
self.watchdog.last_update_times[('STATION_001', 'PUMP_001')] = test_time
|
|
||||||
self.watchdog.failsafe_active[('STATION_001', 'PUMP_001')] = False
|
|
||||||
|
|
||||||
# Act
|
|
||||||
status = self.watchdog.get_status()
|
|
||||||
|
|
||||||
# Assert
|
|
||||||
assert status['running'] is False # Not started yet
|
|
||||||
assert status['timeout_seconds'] == 300
|
|
||||||
assert status['monitored_pumps'] == 1
|
|
||||||
assert status['failsafe_active_pumps'] == 0
|
|
||||||
assert 'STATION_001_PUMP_001' in status['pump_status']
|
|
||||||
Loading…
Reference in New Issue