Set up proper Calejo Control Adapter repository structure
- Renamed repository to calejo-control-adapter - Created comprehensive Python project structure - Added safety framework with multi-layer limits - Added auto-discovery module - Added database client with PostgreSQL support - Created configuration management with pydantic-settings - Added Docker containerization - Created comprehensive README with architecture and setup instructions - Added MIT license - Created test structure for safety framework - Updated package.json with proper project metadata
This commit is contained in:
parent
311064ad09
commit
cff1f77a06
|
|
@ -1,17 +0,0 @@
|
||||||
# Development Setup
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
1. **Review Specification**: Open `specification.html` in a browser to review the project requirements
|
|
||||||
2. **Technology Stack**: Determine the appropriate technology stack based on the specification
|
|
||||||
3. **Project Setup**:
|
|
||||||
- Set up development environment
|
|
||||||
- Install dependencies
|
|
||||||
4. **Implementation**: Begin implementing features according to the specification
|
|
||||||
|
|
||||||
## Current Status
|
|
||||||
|
|
||||||
- ✅ Specification downloaded and saved
|
|
||||||
- ✅ Basic repository structure created
|
|
||||||
- ✅ Git repository initialized and pushed to remote
|
|
||||||
- ⏳ Ready for development setup and implementation
|
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
# Calejo Control Adapter Dockerfile
|
||||||
|
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
gcc \
|
||||||
|
libpq-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy requirements and install Python dependencies
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN useradd -m -u 1000 calejo && chown -R calejo:calejo /app
|
||||||
|
USER calejo
|
||||||
|
|
||||||
|
# Expose ports
|
||||||
|
EXPOSE 8080 # REST API
|
||||||
|
EXPOSE 4840 # OPC UA
|
||||||
|
EXPOSE 502 # Modbus TCP
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:8080/health || exit 1
|
||||||
|
|
||||||
|
# Run the application
|
||||||
|
CMD ["python", "-m", "src.main"]
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 Calejo Control Team
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
196
README.md
196
README.md
|
|
@ -1,22 +1,192 @@
|
||||||
# Project
|
# Calejo Control Adapter
|
||||||
|
|
||||||
This repository contains the project implementation based on the provided specification.
|
**Multi-protocol integration adapter for municipal wastewater pump stations with comprehensive safety and security framework**
|
||||||
|
|
||||||
## Structure
|
[](https://www.python.org/)
|
||||||
|
[](https://opensource.org/licenses/MIT)
|
||||||
|
[](https://www.docker.com/)
|
||||||
|
|
||||||
- `specification.html` - Project specification document (HTML format)
|
## Overview
|
||||||
- `specification.txt` - Information about the specification format
|
|
||||||
- `src/` - Source code for the project
|
The Calejo Control Adapter translates optimized pump control plans from Calejo Optimize into real-time control signals for municipal wastewater pump stations. It supports diverse SCADA systems with minimal configuration through automatic discovery and multiple protocol support.
|
||||||
- `tests/` - Test files
|
|
||||||
- `config/` - Configuration files
|
### Key Features
|
||||||
|
|
||||||
|
- **Multi-Protocol Support**: OPC UA, Modbus TCP, and REST API simultaneously
|
||||||
|
- **Auto-Discovery**: Automatically discovers pump stations and pumps from database
|
||||||
|
- **Safety Framework**: Multi-layer limits, watchdogs, emergency stop, failsafe mechanisms
|
||||||
|
- **Security**: Authentication, authorization, encryption, audit logging
|
||||||
|
- **Compliance**: IEC 62443, NIS2 Directive, ISO 27001
|
||||||
|
- **High Availability**: Caching, failover, health monitoring
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ Calejo Optimize Container (Existing) │
|
||||||
|
│ - Optimization Engine │
|
||||||
|
│ - PostgreSQL Database (pump plans) │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ Calejo Control Adapter (NEW - TO BE IMPLEMENTED) │
|
||||||
|
│ │
|
||||||
|
│ ┌────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Core Components: │ │
|
||||||
|
│ │ 1. Auto-Discovery Module │ │
|
||||||
|
│ │ 2. Security Layer │ │
|
||||||
|
│ │ 3. Safety Framework ⚠️ NEW │ │
|
||||||
|
│ │ 4. Plan-to-Setpoint Logic Engine │ │
|
||||||
|
│ │ 5. Multi-Protocol Server │ │
|
||||||
|
│ │ - OPC UA Server │ │
|
||||||
|
│ │ - Modbus TCP Server │ │
|
||||||
|
│ │ - REST API │ │
|
||||||
|
│ └────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
↓
|
||||||
|
(Multiple Protocols)
|
||||||
|
↓
|
||||||
|
┌─────────────────┼─────────────────┐
|
||||||
|
↓ ↓ ↓
|
||||||
|
Siemens WinCC Schneider EcoStruxure Rockwell FactoryTalk
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
calejo-control-adapter/
|
||||||
|
├── src/
|
||||||
|
│ ├── core/
|
||||||
|
│ │ ├── auto_discovery.py # Auto-discovery module
|
||||||
|
│ │ ├── security.py # Security layer
|
||||||
|
│ │ ├── safety.py # Safety framework
|
||||||
|
│ │ └── plan_to_setpoint.py # Plan-to-setpoint logic
|
||||||
|
│ ├── protocols/
|
||||||
|
│ │ ├── opc_ua_server.py # OPC UA server
|
||||||
|
│ │ ├── modbus_server.py # Modbus TCP server
|
||||||
|
│ │ └── rest_api.py # REST API server
|
||||||
|
│ ├── database/
|
||||||
|
│ │ ├── client.py # Database client
|
||||||
|
│ │ └── models.py # Data models
|
||||||
|
│ ├── monitoring/
|
||||||
|
│ │ ├── watchdog.py # Database watchdog
|
||||||
|
│ │ └── alerts.py # Alert manager
|
||||||
|
│ └── main.py # Main application
|
||||||
|
├── tests/
|
||||||
|
├── config/
|
||||||
|
│ ├── settings.py # Application settings
|
||||||
|
│ └── docker-compose.yml # Docker configuration
|
||||||
|
├── docs/
|
||||||
|
│ └── specification.txt # Full implementation specification
|
||||||
|
├── requirements.txt # Python dependencies
|
||||||
|
├── Dockerfile # Docker container definition
|
||||||
|
└── README.md # This file
|
||||||
|
```
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
1. Review the specification in `specification.html` (requires JavaScript execution in browser)
|
### Prerequisites
|
||||||
2. Read `specification.txt` for information about the specification format
|
|
||||||
3. Set up development environment
|
- Python 3.11+
|
||||||
4. Start implementing features according to the specification
|
- PostgreSQL 14+
|
||||||
|
- Docker (optional)
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
1. **Clone the repository**
|
||||||
|
```bash
|
||||||
|
git clone http://95.111.206.201:3000/calejocontrol/CalejoControl.git
|
||||||
|
cd calejo-control-adapter
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Install dependencies**
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Set up environment variables**
|
||||||
|
```bash
|
||||||
|
cp config/.env.example config/.env
|
||||||
|
# Edit config/.env with your database and security settings
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Run the application**
|
||||||
|
```bash
|
||||||
|
python -m src.main
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build the container
|
||||||
|
docker build -t calejo-control-adapter .
|
||||||
|
|
||||||
|
# Run the container
|
||||||
|
docker run -p 8080:8080 -p 4840:4840 -p 502:502 calejo-control-adapter
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Key configuration options:
|
||||||
|
|
||||||
|
- `DATABASE_URL`: PostgreSQL connection string
|
||||||
|
- `OPC_UA_ENDPOINT`: OPC UA server endpoint (default: opc.tcp://0.0.0.0:4840)
|
||||||
|
- `MODBUS_PORT`: Modbus TCP port (default: 502)
|
||||||
|
- `REST_API_PORT`: REST API port (default: 8080)
|
||||||
|
- `SAFETY_TIMEOUT_SECONDS`: Database watchdog timeout (default: 1200)
|
||||||
|
|
||||||
|
## Safety Framework
|
||||||
|
|
||||||
|
The adapter implements a comprehensive three-layer safety architecture:
|
||||||
|
|
||||||
|
1. **Layer 1**: Physical Hard Limits (PLC/VFD) - 15-55 Hz
|
||||||
|
2. **Layer 2**: Station Safety Limits (Database) - 20-50 Hz (enforced by adapter)
|
||||||
|
3. **Layer 3**: Optimization Constraints (Calejo Optimize) - 25-45 Hz
|
||||||
|
|
||||||
|
### Safety Features
|
||||||
|
|
||||||
|
- **Hard Operational Limits**: Speed, level, power, and flow limits
|
||||||
|
- **Rate of Change Limits**: Prevent sudden speed changes
|
||||||
|
- **Database Watchdog**: Reverts to safe defaults if updates stop
|
||||||
|
- **Emergency Stop**: Manual override capability
|
||||||
|
- **Failsafe Mode**: Automatic fallback to default setpoints
|
||||||
|
|
||||||
|
## Security & Compliance
|
||||||
|
|
||||||
|
- **Authentication**: JWT tokens and certificate-based authentication
|
||||||
|
- **Authorization**: Role-based access control (RBAC)
|
||||||
|
- **Encryption**: TLS/SSL for all communications
|
||||||
|
- **Audit Logging**: Immutable audit trail for compliance
|
||||||
|
- **Standards**: IEC 62443, ISO 27001, NIS2 Directive
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
This project is in early development stages. The specification has been downloaded and the basic repository structure has been set up.
|
### Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Code Quality
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flake8 src/ tests/ # Linting
|
||||||
|
mypy src/ # Type checking
|
||||||
|
black src/ tests/ # Code formatting
|
||||||
|
```
|
||||||
|
|
||||||
|
### Contributing
|
||||||
|
|
||||||
|
1. Fork the repository
|
||||||
|
2. Create a feature branch
|
||||||
|
3. Make your changes
|
||||||
|
4. Add tests
|
||||||
|
5. Submit a pull request
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is licensed under the MIT License - see the LICENSE file for details.
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For support and documentation, refer to the specification in `docs/specification.txt` or contact the development team.
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
# Database Configuration
|
||||||
|
DATABASE_URL=postgresql://control_reader:secure_password@localhost:5432/calejo
|
||||||
|
|
||||||
|
# Protocol Endpoints
|
||||||
|
OPC_UA_ENDPOINT=opc.tcp://0.0.0.0:4840
|
||||||
|
MODBUS_PORT=502
|
||||||
|
REST_API_PORT=8080
|
||||||
|
|
||||||
|
# Safety Settings
|
||||||
|
SAFETY_TIMEOUT_SECONDS=1200
|
||||||
|
|
||||||
|
# Security Settings
|
||||||
|
JWT_SECRET_KEY=your-secret-key-change-in-production
|
||||||
|
JWT_ALGORITHM=HS256
|
||||||
|
|
||||||
|
# Alert Settings (Optional)
|
||||||
|
SMTP_SERVER=smtp.example.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USERNAME=your-email@example.com
|
||||||
|
SMTP_PASSWORD=your-email-password
|
||||||
|
TWILIO_ACCOUNT_SID=your-twilio-account-sid
|
||||||
|
TWILIO_AUTH_TOKEN=your-twilio-auth-token
|
||||||
|
TWILIO_PHONE_NUMBER=+1234567890
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
"""
|
||||||
|
Application settings for Calejo Control Adapter.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
"""Application settings loaded from environment variables."""
|
||||||
|
|
||||||
|
# Database
|
||||||
|
database_url: str = "postgresql://control_reader:secure_password@localhost:5432/calejo"
|
||||||
|
|
||||||
|
# Protocol endpoints
|
||||||
|
opc_ua_endpoint: str = "opc.tcp://0.0.0.0:4840"
|
||||||
|
modbus_port: int = 502
|
||||||
|
rest_api_port: int = 8080
|
||||||
|
|
||||||
|
# Safety settings
|
||||||
|
safety_timeout_seconds: int = 1200 # 20 minutes
|
||||||
|
|
||||||
|
# Security settings
|
||||||
|
jwt_secret_key: str = "your-secret-key-change-in-production"
|
||||||
|
jwt_algorithm: str = "HS256"
|
||||||
|
|
||||||
|
# Alert settings
|
||||||
|
smtp_server: Optional[str] = None
|
||||||
|
smtp_port: Optional[int] = 587
|
||||||
|
smtp_username: Optional[str] = None
|
||||||
|
smtp_password: Optional[str] = None
|
||||||
|
twilio_account_sid: Optional[str] = None
|
||||||
|
twilio_auth_token: Optional[str] = None
|
||||||
|
twilio_phone_number: Optional[str] = None
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
log_level: str = "INFO"
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
env_file = ".env"
|
||||||
|
env_file_encoding = "utf-8"
|
||||||
File diff suppressed because it is too large
Load Diff
21
package.json
21
package.json
|
|
@ -1,14 +1,17 @@
|
||||||
{
|
{
|
||||||
"name": "project",
|
"name": "calejo-control-adapter",
|
||||||
"version": "1.0.0",
|
"version": "2.0.0",
|
||||||
"description": "Project implementation based on specification",
|
"description": "Multi-protocol integration adapter for municipal wastewater pump stations with comprehensive safety and security framework",
|
||||||
"main": "index.js",
|
"main": "src/main.py",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "echo 'Development server not yet configured'",
|
"dev": "python -m src.main",
|
||||||
"build": "echo 'Build process not yet configured'",
|
"test": "pytest tests/",
|
||||||
"test": "echo 'Tests not yet configured'"
|
"lint": "flake8 src/ tests/",
|
||||||
|
"type-check": "mypy src/",
|
||||||
|
"docker-build": "docker build -t calejo-control-adapter .",
|
||||||
|
"docker-run": "docker run -p 8080:8080 calejo-control-adapter"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": ["scada", "opc-ua", "modbus", "wastewater", "pump-control", "safety", "security", "industrial-automation"],
|
||||||
"author": "",
|
"author": "Calejo Control Team",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
# Core dependencies
|
||||||
|
asyncua==1.0.6
|
||||||
|
pymodbus==3.5.4
|
||||||
|
fastapi==0.104.1
|
||||||
|
uvicorn[standard]==0.24.0
|
||||||
|
psycopg2-binary==2.9.9
|
||||||
|
pydantic==2.5.0
|
||||||
|
pydantic-settings==2.1.0
|
||||||
|
cryptography==41.0.7
|
||||||
|
PyJWT==2.8.0
|
||||||
|
structlog==23.2.0
|
||||||
|
python-dotenv==1.0.0
|
||||||
|
|
||||||
|
# Optional dependencies
|
||||||
|
redis==5.0.1 # For distributed caching
|
||||||
|
prometheus-client==0.19.0 # For metrics
|
||||||
|
aiohttp==3.9.1 # For async HTTP requests
|
||||||
|
aiosmtplib==3.0.1 # For email alerts
|
||||||
|
twilio==8.10.0 # For SMS alerts
|
||||||
|
httpx==0.25.0 # For webhook alerts
|
||||||
|
|
||||||
|
# Development dependencies
|
||||||
|
pytest==7.4.3
|
||||||
|
pytest-asyncio==0.21.1
|
||||||
|
pytest-cov==4.1.0
|
||||||
|
flake8==6.1.0
|
||||||
|
mypy==1.7.1
|
||||||
|
black==23.11.0
|
||||||
|
isort==5.13.2
|
||||||
|
pre-commit==3.5.0
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -1,21 +0,0 @@
|
||||||
SPECIFICATION INFORMATION
|
|
||||||
========================
|
|
||||||
|
|
||||||
This file contains information about the specification document.
|
|
||||||
|
|
||||||
Original Source:
|
|
||||||
- URL: https://manus.im/share/file/2c4bf3ba-3413-489d-a2bd-c845ba4fc2b8
|
|
||||||
- File Type: HTML (Next.js application)
|
|
||||||
- File Size: 42,931 bytes
|
|
||||||
|
|
||||||
Status:
|
|
||||||
- The specification was downloaded as an HTML file from the Manus file sharing service.
|
|
||||||
- The HTML file appears to be a Next.js application shell that loads content dynamically via JavaScript.
|
|
||||||
- The actual specification content is not directly embedded in the HTML file.
|
|
||||||
|
|
||||||
Next Steps:
|
|
||||||
- To view the actual specification, open the HTML file in a web browser where JavaScript can execute.
|
|
||||||
- The specification.html file should be served through a web server or opened directly in a browser.
|
|
||||||
- Alternatively, you may need to access the original URL to view the specification content.
|
|
||||||
|
|
||||||
Note: This is a common pattern with modern web applications where content is loaded dynamically rather than being embedded directly in the initial HTML.
|
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
"""
|
||||||
|
Auto-Discovery Module for Calejo Control Adapter.
|
||||||
|
|
||||||
|
Automatically discovers pump stations and pumps from database on startup.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Dict, List
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
from src.database.client import DatabaseClient
|
||||||
|
|
||||||
|
logger = structlog.get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
class AutoDiscovery:
|
||||||
|
"""Auto-discovery module for pump stations and pumps."""
|
||||||
|
|
||||||
|
def __init__(self, db_client: DatabaseClient):
|
||||||
|
self.db_client = db_client
|
||||||
|
self.pump_stations: Dict[str, Dict] = {}
|
||||||
|
self.pumps: Dict[str, List[Dict]] = {}
|
||||||
|
|
||||||
|
async def discover(self):
|
||||||
|
"""Discover all pump stations and pumps from database."""
|
||||||
|
try:
|
||||||
|
# Discover pump stations
|
||||||
|
stations = self.db_client.get_pump_stations()
|
||||||
|
for station in stations:
|
||||||
|
self.pump_stations[station['station_id']] = station
|
||||||
|
|
||||||
|
# Discover pumps
|
||||||
|
pumps = self.db_client.get_pumps()
|
||||||
|
for pump in pumps:
|
||||||
|
station_id = pump['station_id']
|
||||||
|
if station_id not in self.pumps:
|
||||||
|
self.pumps[station_id] = []
|
||||||
|
self.pumps[station_id].append(pump)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"auto_discovery_completed",
|
||||||
|
station_count=len(self.pump_stations),
|
||||||
|
pump_count=len(pumps)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Log discovered stations and pumps
|
||||||
|
for station_id, station in self.pump_stations.items():
|
||||||
|
station_pumps = self.pumps.get(station_id, [])
|
||||||
|
logger.info(
|
||||||
|
"station_discovered",
|
||||||
|
station_id=station_id,
|
||||||
|
station_name=station['station_name'],
|
||||||
|
pump_count=len(station_pumps)
|
||||||
|
)
|
||||||
|
|
||||||
|
for pump in station_pumps:
|
||||||
|
logger.info(
|
||||||
|
"pump_discovered",
|
||||||
|
station_id=station_id,
|
||||||
|
pump_id=pump['pump_id'],
|
||||||
|
pump_name=pump['pump_name'],
|
||||||
|
control_type=pump['control_type']
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("auto_discovery_failed", error=str(e))
|
||||||
|
raise
|
||||||
|
|
||||||
|
def get_stations(self) -> Dict[str, Dict]:
|
||||||
|
"""Get all discovered pump stations."""
|
||||||
|
return self.pump_stations
|
||||||
|
|
||||||
|
def get_pumps(self, station_id: str = None) -> List[Dict]:
|
||||||
|
"""Get all discovered pumps, optionally filtered by station."""
|
||||||
|
if station_id:
|
||||||
|
return self.pumps.get(station_id, [])
|
||||||
|
else:
|
||||||
|
all_pumps = []
|
||||||
|
for station_pumps in self.pumps.values():
|
||||||
|
all_pumps.extend(station_pumps)
|
||||||
|
return all_pumps
|
||||||
|
|
||||||
|
def get_pump(self, station_id: str, pump_id: str) -> Dict:
|
||||||
|
"""Get a specific pump by station and pump ID."""
|
||||||
|
station_pumps = self.pumps.get(station_id, [])
|
||||||
|
for pump in station_pumps:
|
||||||
|
if pump['pump_id'] == pump_id:
|
||||||
|
return pump
|
||||||
|
return None
|
||||||
|
|
@ -0,0 +1,163 @@
|
||||||
|
"""
|
||||||
|
Safety Framework for Calejo Control Adapter.
|
||||||
|
|
||||||
|
Implements multi-layer safety mechanisms to prevent equipment damage
|
||||||
|
and operational hazards.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Tuple, List, Optional, Dict
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
from src.database.client import DatabaseClient
|
||||||
|
|
||||||
|
logger = structlog.get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SafetyLimits:
|
||||||
|
"""Safety limits for a pump."""
|
||||||
|
hard_min_speed_hz: float
|
||||||
|
hard_max_speed_hz: float
|
||||||
|
hard_min_level_m: Optional[float]
|
||||||
|
hard_max_level_m: Optional[float]
|
||||||
|
hard_max_power_kw: Optional[float]
|
||||||
|
max_speed_change_hz_per_min: float
|
||||||
|
|
||||||
|
|
||||||
|
class SafetyLimitEnforcer:
|
||||||
|
"""
|
||||||
|
Enforces multi-layer safety limits on all setpoints.
|
||||||
|
|
||||||
|
This is the LAST line of defense before setpoints are exposed to SCADA.
|
||||||
|
ALL setpoints MUST pass through this enforcer.
|
||||||
|
|
||||||
|
Three-Layer Architecture:
|
||||||
|
- Layer 1: Physical Hard Limits (PLC/VFD) - 15-55 Hz
|
||||||
|
- Layer 2: Station Safety Limits (Database) - 20-50 Hz (enforced here)
|
||||||
|
- Layer 3: Optimization Constraints (Calejo Optimize) - 25-45 Hz
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, db_client: DatabaseClient):
|
||||||
|
self.db_client = db_client
|
||||||
|
self.safety_limits_cache: Dict[Tuple[str, str], SafetyLimits] = {}
|
||||||
|
self.previous_setpoints: Dict[Tuple[str, str], float] = {}
|
||||||
|
|
||||||
|
async def load_safety_limits(self):
|
||||||
|
"""Load safety limits from database into cache."""
|
||||||
|
try:
|
||||||
|
limits = self.db_client.get_safety_limits()
|
||||||
|
|
||||||
|
for limit in limits:
|
||||||
|
key = (limit['station_id'], limit['pump_id'])
|
||||||
|
self.safety_limits_cache[key] = SafetyLimits(
|
||||||
|
hard_min_speed_hz=limit['hard_min_speed_hz'],
|
||||||
|
hard_max_speed_hz=limit['hard_max_speed_hz'],
|
||||||
|
hard_min_level_m=limit.get('hard_min_level_m'),
|
||||||
|
hard_max_level_m=limit.get('hard_max_level_m'),
|
||||||
|
hard_max_power_kw=limit.get('hard_max_power_kw'),
|
||||||
|
max_speed_change_hz_per_min=limit['max_speed_change_hz_per_min']
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("safety_limits_loaded", pump_count=len(limits))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("failed_to_load_safety_limits", error=str(e))
|
||||||
|
raise
|
||||||
|
|
||||||
|
def enforce_setpoint(
|
||||||
|
self,
|
||||||
|
station_id: str,
|
||||||
|
pump_id: str,
|
||||||
|
setpoint: float
|
||||||
|
) -> Tuple[float, List[str]]:
|
||||||
|
"""
|
||||||
|
Enforce safety limits on setpoint.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
station_id: Station identifier
|
||||||
|
pump_id: Pump identifier
|
||||||
|
setpoint: Proposed setpoint (Hz)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (enforced_setpoint, violations)
|
||||||
|
- enforced_setpoint: Safe setpoint (clamped if necessary)
|
||||||
|
- violations: List of safety violations (for logging/alerting)
|
||||||
|
"""
|
||||||
|
violations = []
|
||||||
|
enforced_setpoint = setpoint
|
||||||
|
|
||||||
|
# Get safety limits
|
||||||
|
key = (station_id, pump_id)
|
||||||
|
limits = self.safety_limits_cache.get(key)
|
||||||
|
|
||||||
|
if not limits:
|
||||||
|
logger.error(
|
||||||
|
"no_safety_limits",
|
||||||
|
station_id=station_id,
|
||||||
|
pump_id=pump_id
|
||||||
|
)
|
||||||
|
# CRITICAL: No safety limits defined - reject setpoint
|
||||||
|
return (0.0, ["NO_SAFETY_LIMITS_DEFINED"])
|
||||||
|
|
||||||
|
# Check minimum speed
|
||||||
|
if enforced_setpoint < limits.hard_min_speed_hz:
|
||||||
|
violations.append(
|
||||||
|
f"BELOW_MIN_SPEED: {enforced_setpoint:.2f} < {limits.hard_min_speed_hz:.2f}"
|
||||||
|
)
|
||||||
|
enforced_setpoint = limits.hard_min_speed_hz
|
||||||
|
|
||||||
|
# Check maximum speed
|
||||||
|
if enforced_setpoint > limits.hard_max_speed_hz:
|
||||||
|
violations.append(
|
||||||
|
f"ABOVE_MAX_SPEED: {enforced_setpoint:.2f} > {limits.hard_max_speed_hz:.2f}"
|
||||||
|
)
|
||||||
|
enforced_setpoint = limits.hard_max_speed_hz
|
||||||
|
|
||||||
|
# Check rate of change (prevent sudden speed changes that damage equipment)
|
||||||
|
previous_setpoint = self.previous_setpoints.get(key)
|
||||||
|
if previous_setpoint is not None:
|
||||||
|
max_change = limits.max_speed_change_hz_per_min * 5.0 # 5-minute interval
|
||||||
|
actual_change = abs(enforced_setpoint - previous_setpoint)
|
||||||
|
|
||||||
|
if actual_change > max_change:
|
||||||
|
# Limit rate of change
|
||||||
|
direction = 1 if enforced_setpoint > previous_setpoint else -1
|
||||||
|
enforced_setpoint = previous_setpoint + (direction * max_change)
|
||||||
|
violations.append(
|
||||||
|
f"RATE_OF_CHANGE_LIMITED: {actual_change:.2f} Hz > {max_change:.2f} Hz"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Store current setpoint for next rate-of-change check
|
||||||
|
self.previous_setpoints[key] = enforced_setpoint
|
||||||
|
|
||||||
|
# Log violations
|
||||||
|
if violations:
|
||||||
|
logger.warning(
|
||||||
|
"safety_limit_violation",
|
||||||
|
station_id=station_id,
|
||||||
|
pump_id=pump_id,
|
||||||
|
requested_setpoint=setpoint,
|
||||||
|
enforced_setpoint=enforced_setpoint,
|
||||||
|
violations=violations
|
||||||
|
)
|
||||||
|
|
||||||
|
# Record violation in database for audit
|
||||||
|
self._record_violation(station_id, pump_id, setpoint, enforced_setpoint, violations)
|
||||||
|
|
||||||
|
return (enforced_setpoint, violations)
|
||||||
|
|
||||||
|
def _record_violation(
|
||||||
|
self,
|
||||||
|
station_id: str,
|
||||||
|
pump_id: str,
|
||||||
|
requested: float,
|
||||||
|
enforced: float,
|
||||||
|
violations: List[str]
|
||||||
|
):
|
||||||
|
"""Record safety limit violation in database."""
|
||||||
|
query = """
|
||||||
|
INSERT INTO safety_limit_violations
|
||||||
|
(station_id, pump_id, requested_setpoint, enforced_setpoint, violations, timestamp)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, NOW())
|
||||||
|
"""
|
||||||
|
self.db_client.execute(query, (station_id, pump_id, requested, enforced, violations))
|
||||||
|
|
@ -0,0 +1,128 @@
|
||||||
|
"""
|
||||||
|
Database client for Calejo Control Adapter.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import psycopg2
|
||||||
|
import psycopg2.extras
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
logger = structlog.get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseClient:
|
||||||
|
"""Database client for PostgreSQL operations."""
|
||||||
|
|
||||||
|
def __init__(self, database_url: str):
|
||||||
|
self.database_url = database_url
|
||||||
|
self.connection = None
|
||||||
|
self.cursor = None
|
||||||
|
|
||||||
|
async def connect(self):
|
||||||
|
"""Connect to the PostgreSQL database."""
|
||||||
|
try:
|
||||||
|
self.connection = psycopg2.connect(
|
||||||
|
self.database_url,
|
||||||
|
cursor_factory=psycopg2.extras.RealDictCursor
|
||||||
|
)
|
||||||
|
self.cursor = self.connection.cursor()
|
||||||
|
logger.info("database_connected")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("database_connection_failed", error=str(e))
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def disconnect(self):
|
||||||
|
"""Disconnect from the database."""
|
||||||
|
if self.cursor:
|
||||||
|
self.cursor.close()
|
||||||
|
if self.connection:
|
||||||
|
self.connection.close()
|
||||||
|
logger.info("database_disconnected")
|
||||||
|
|
||||||
|
def execute_query(self, query: str, params: tuple = None) -> List[Dict[str, Any]]:
|
||||||
|
"""Execute a SELECT query and return results."""
|
||||||
|
try:
|
||||||
|
self.cursor.execute(query, params)
|
||||||
|
return self.cursor.fetchall()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("query_execution_failed", query=query, error=str(e))
|
||||||
|
raise
|
||||||
|
|
||||||
|
def execute(self, query: str, params: tuple = None) -> None:
|
||||||
|
"""Execute an INSERT/UPDATE/DELETE query."""
|
||||||
|
try:
|
||||||
|
self.cursor.execute(query, params)
|
||||||
|
self.connection.commit()
|
||||||
|
except Exception as e:
|
||||||
|
self.connection.rollback()
|
||||||
|
logger.error("query_execution_failed", query=query, error=str(e))
|
||||||
|
raise
|
||||||
|
|
||||||
|
def get_pump_stations(self) -> List[Dict[str, Any]]:
|
||||||
|
"""Get all active pump stations."""
|
||||||
|
query = """
|
||||||
|
SELECT station_id, station_name, location, latitude, longitude, timezone
|
||||||
|
FROM pump_stations
|
||||||
|
WHERE active = TRUE
|
||||||
|
ORDER BY station_id
|
||||||
|
"""
|
||||||
|
return self.execute_query(query)
|
||||||
|
|
||||||
|
def get_pumps(self, station_id: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||||
|
"""Get all active pumps, optionally filtered by station."""
|
||||||
|
if station_id:
|
||||||
|
query = """
|
||||||
|
SELECT pump_id, station_id, pump_name, pump_type, control_type,
|
||||||
|
manufacturer, model, rated_power_kw, min_speed_hz, max_speed_hz,
|
||||||
|
default_setpoint_hz, control_parameters
|
||||||
|
FROM pumps
|
||||||
|
WHERE station_id = %s AND active = TRUE
|
||||||
|
ORDER BY pump_id
|
||||||
|
"""
|
||||||
|
return self.execute_query(query, (station_id,))
|
||||||
|
else:
|
||||||
|
query = """
|
||||||
|
SELECT pump_id, station_id, pump_name, pump_type, control_type,
|
||||||
|
manufacturer, model, rated_power_kw, min_speed_hz, max_speed_hz,
|
||||||
|
default_setpoint_hz, control_parameters
|
||||||
|
FROM pumps
|
||||||
|
WHERE active = TRUE
|
||||||
|
ORDER BY station_id, pump_id
|
||||||
|
"""
|
||||||
|
return self.execute_query(query)
|
||||||
|
|
||||||
|
def get_safety_limits(self) -> List[Dict[str, Any]]:
|
||||||
|
"""Get all safety limits for pumps."""
|
||||||
|
query = """
|
||||||
|
SELECT station_id, pump_id, hard_min_speed_hz, hard_max_speed_hz,
|
||||||
|
hard_min_level_m, hard_max_level_m, hard_max_power_kw,
|
||||||
|
max_speed_change_hz_per_min
|
||||||
|
FROM pump_safety_limits
|
||||||
|
ORDER BY station_id, pump_id
|
||||||
|
"""
|
||||||
|
return self.execute_query(query)
|
||||||
|
|
||||||
|
def get_latest_pump_plans(self) -> List[Dict[str, Any]]:
|
||||||
|
"""Get the latest pump plans for all active pumps."""
|
||||||
|
query = """
|
||||||
|
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
|
||||||
|
FROM pump_plans
|
||||||
|
WHERE interval_start <= NOW() AND interval_end >= NOW()
|
||||||
|
ORDER BY station_id, pump_id, plan_created_at DESC
|
||||||
|
"""
|
||||||
|
return self.execute_query(query)
|
||||||
|
|
||||||
|
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."""
|
||||||
|
query = """
|
||||||
|
SELECT timestamp, actual_speed_hz, actual_power_kw, actual_flow_m3h,
|
||||||
|
wet_well_level_m, pump_running, alarm_active, alarm_code
|
||||||
|
FROM pump_feedback
|
||||||
|
WHERE station_id = %s AND pump_id = %s
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT %s
|
||||||
|
"""
|
||||||
|
return self.execute_query(query, (station_id, pump_id, limit))
|
||||||
|
|
@ -0,0 +1,137 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Calejo Control Adapter - Main Application
|
||||||
|
|
||||||
|
Multi-protocol integration adapter for municipal wastewater pump stations
|
||||||
|
with comprehensive safety and security framework.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import signal
|
||||||
|
import structlog
|
||||||
|
from typing import Dict, Any
|
||||||
|
|
||||||
|
from config.settings import Settings
|
||||||
|
from src.database.client import DatabaseClient
|
||||||
|
from src.core.auto_discovery import AutoDiscovery
|
||||||
|
from src.core.security import SecurityManager
|
||||||
|
from src.core.safety import SafetyLimitEnforcer
|
||||||
|
from src.core.plan_to_setpoint import PlanToSetpointEngine
|
||||||
|
from src.monitoring.watchdog import DatabaseWatchdog
|
||||||
|
from src.monitoring.alerts import AlertManager
|
||||||
|
from src.protocols.opc_ua_server import OPCUAServer
|
||||||
|
from src.protocols.modbus_server import ModbusServer
|
||||||
|
from src.protocols.rest_api import RESTAPIServer
|
||||||
|
|
||||||
|
logger = structlog.get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
class CalejoControlAdapter:
|
||||||
|
"""Main application class for Calejo Control Adapter."""
|
||||||
|
|
||||||
|
def __init__(self, settings: Settings):
|
||||||
|
self.settings = settings
|
||||||
|
self.running = False
|
||||||
|
|
||||||
|
# Initialize components
|
||||||
|
self.db_client = DatabaseClient(settings.database_url)
|
||||||
|
self.auto_discovery = AutoDiscovery(self.db_client)
|
||||||
|
self.security_manager = SecurityManager()
|
||||||
|
self.safety_enforcer = SafetyLimitEnforcer(self.db_client)
|
||||||
|
self.plan_engine = PlanToSetpointEngine(self.db_client, self.safety_enforcer)
|
||||||
|
self.alert_manager = AlertManager(settings)
|
||||||
|
self.watchdog = DatabaseWatchdog(
|
||||||
|
self.db_client, self.alert_manager, settings.safety_timeout_seconds
|
||||||
|
)
|
||||||
|
|
||||||
|
# Protocol servers
|
||||||
|
self.opc_ua_server = OPCUAServer(settings.opc_ua_endpoint, self.plan_engine)
|
||||||
|
self.modbus_server = ModbusServer(settings.modbus_port, self.plan_engine)
|
||||||
|
self.rest_api = RESTAPIServer(settings.rest_api_port, self.plan_engine)
|
||||||
|
|
||||||
|
async def start(self):
|
||||||
|
"""Start the Calejo Control Adapter."""
|
||||||
|
logger.info("starting_calejo_control_adapter", version="2.0.0")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Connect to database
|
||||||
|
await self.db_client.connect()
|
||||||
|
|
||||||
|
# Load safety limits
|
||||||
|
await self.safety_enforcer.load_safety_limits()
|
||||||
|
|
||||||
|
# Auto-discover pump stations and pumps
|
||||||
|
await self.auto_discovery.discover()
|
||||||
|
|
||||||
|
# Start protocol servers
|
||||||
|
await asyncio.gather(
|
||||||
|
self.opc_ua_server.start(),
|
||||||
|
self.modbus_server.start(),
|
||||||
|
self.rest_api.start(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Start monitoring
|
||||||
|
await self.watchdog.start()
|
||||||
|
|
||||||
|
self.running = True
|
||||||
|
logger.info("calejo_control_adapter_started")
|
||||||
|
|
||||||
|
# Keep application running
|
||||||
|
while self.running:
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("failed_to_start_adapter", error=str(e))
|
||||||
|
await self.stop()
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def stop(self):
|
||||||
|
"""Stop the Calejo Control Adapter gracefully."""
|
||||||
|
logger.info("stopping_calejo_control_adapter")
|
||||||
|
self.running = False
|
||||||
|
|
||||||
|
# Stop protocol servers
|
||||||
|
await asyncio.gather(
|
||||||
|
self.opc_ua_server.stop(),
|
||||||
|
self.modbus_server.stop(),
|
||||||
|
self.rest_api.stop(),
|
||||||
|
return_exceptions=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Close database connection
|
||||||
|
await self.db_client.disconnect()
|
||||||
|
|
||||||
|
logger.info("calejo_control_adapter_stopped")
|
||||||
|
|
||||||
|
|
||||||
|
def handle_shutdown(signum, frame):
|
||||||
|
"""Handle shutdown signals gracefully."""
|
||||||
|
logger.info("received_shutdown_signal", signal=signum)
|
||||||
|
# Signal handling will be implemented in the async context
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""Main application entry point."""
|
||||||
|
# Load settings
|
||||||
|
settings = Settings()
|
||||||
|
|
||||||
|
# Set up signal handlers
|
||||||
|
signal.signal(signal.SIGINT, handle_shutdown)
|
||||||
|
signal.signal(signal.SIGTERM, handle_shutdown)
|
||||||
|
|
||||||
|
# Create and start adapter
|
||||||
|
adapter = CalejoControlAdapter(settings)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await adapter.start()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.info("keyboard_interrupt_received")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("unexpected_error", error=str(e))
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
await adapter.stop()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
"""
|
||||||
|
Tests for the Safety Framework.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from src.core.safety import SafetyLimitEnforcer, SafetyLimits
|
||||||
|
|
||||||
|
|
||||||
|
class TestSafetyLimitEnforcer:
|
||||||
|
"""Test safety limit enforcement."""
|
||||||
|
|
||||||
|
def test_enforce_setpoint_within_limits(self):
|
||||||
|
"""Test setpoint within limits is not modified."""
|
||||||
|
enforcer = SafetyLimitEnforcer(None)
|
||||||
|
|
||||||
|
# Set up safety limits
|
||||||
|
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
|
||||||
|
)
|
||||||
|
enforcer.safety_limits_cache[('station1', 'pump1')] = limits
|
||||||
|
|
||||||
|
# Test setpoint within limits
|
||||||
|
enforced, violations = enforcer.enforce_setpoint('station1', 'pump1', 35.0)
|
||||||
|
|
||||||
|
assert enforced == 35.0
|
||||||
|
assert violations == []
|
||||||
|
|
||||||
|
def test_enforce_setpoint_below_min(self):
|
||||||
|
"""Test setpoint below minimum is clamped."""
|
||||||
|
enforcer = SafetyLimitEnforcer(None)
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
enforcer.safety_limits_cache[('station1', 'pump1')] = limits
|
||||||
|
|
||||||
|
enforced, violations = enforcer.enforce_setpoint('station1', 'pump1', 15.0)
|
||||||
|
|
||||||
|
assert enforced == 20.0
|
||||||
|
assert len(violations) == 1
|
||||||
|
assert "BELOW_MIN_SPEED" in violations[0]
|
||||||
|
|
||||||
|
def test_enforce_setpoint_above_max(self):
|
||||||
|
"""Test setpoint above maximum is clamped."""
|
||||||
|
enforcer = SafetyLimitEnforcer(None)
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
enforcer.safety_limits_cache[('station1', 'pump1')] = limits
|
||||||
|
|
||||||
|
enforced, violations = enforcer.enforce_setpoint('station1', 'pump1', 55.0)
|
||||||
|
|
||||||
|
assert enforced == 50.0
|
||||||
|
assert len(violations) == 1
|
||||||
|
assert "ABOVE_MAX_SPEED" in violations[0]
|
||||||
|
|
||||||
|
def test_enforce_setpoint_no_limits(self):
|
||||||
|
"""Test setpoint without safety limits defined."""
|
||||||
|
enforcer = SafetyLimitEnforcer(None)
|
||||||
|
|
||||||
|
enforced, violations = enforcer.enforce_setpoint('station1', 'pump1', 35.0)
|
||||||
|
|
||||||
|
assert enforced == 0.0
|
||||||
|
assert violations == ["NO_SAFETY_LIMITS_DEFINED"]
|
||||||
Loading…
Reference in New Issue