Code import - branch 1.3.0

This commit is contained in:
2026-08-05 13:53:40 +00:00
commit 4c2e9288df
87 changed files with 11302 additions and 0 deletions

28
.env.example Normal file
View File

@@ -0,0 +1,28 @@
POSTGRES_HOST=postgres
POSTGRES_PORT=5432
POSTGRES_USER=sientia
POSTGRES_PASSWORD=sientia
POSTGRES_DB=sientia
POSTGRES_MIN_CONNECTIONS=5
POSTGRES_MAX_CONNECTIONS=20
MONGODB_USERNAME="root"
MONGODB_PASSWORD="password"
MONGODB_URL="my-release-mongodb.mongodb.svc.cluster.local:27017"
MONGODB_DATABASE="sientia"
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_USERNAME="user"
REDIS_PASSWORD="pass"
TEMPORAL_HOST=localhost:7233
TEMPORAL_NAMESPACE=scouter
PI_WEB_API_BASE_URL="https://piwebapi.link.com/piwebapi"
PI_WEB_API_AUTH_TYPE="basic"
PI_WEB_API_AUTH_TOKEN="password"
LOG_LEVEL=INFO
PROJECT_NAME=scouter

17
.github/workflows/quality-gate.yml vendored Normal file
View File

@@ -0,0 +1,17 @@
name: Quality gate
on:
pull_request:
branches:
- main
types: [ opened, synchronize, reopened ]
jobs:
quality-gate:
uses: Aignosi/github_workflow_templates/.github/workflows/dataops-module-quality-gate.yml@main
permissions: write-all
with:
project_name: 'scouter'
repositories: 'sientia-dataops-library'
requirements_file: 'requirements-local.txt'
secrets: inherit

16
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,16 @@
name: Create Release on Merge to Main
on:
pull_request:
types: [closed]
branches:
- main
jobs:
release:
if: github.event.pull_request.merged == true
uses: Aignosi/github_workflow_templates/.github/workflows/dataops-module-release.yml@main
permissions: write-all
with:
project_name: 'scouter'
secrets: inherit

50
.gitignore vendored Normal file
View File

@@ -0,0 +1,50 @@
# Ignorar volumes do Docker
docker-compose.override.yml
**/db_data/
**/kafka-volume/
**/zookeeper-volume/
**/mage_data/
**/minio_data/
**/venv/
**/certs/*.pem
**/certs/*.der
**/certs/*.csr
**/deploy/*.yaml
scouter/.file_versions/
scouter/pipelines/**/triggers.yaml
**/postgres_data/**
# Ignorar arquivos e diretórios de cache do Python
__pycache__/
*.pyc
*.pyo
*.pyd
# Ignorar logs
*.log
# Ignorar arquivos de configuração locais
.vscode/
.pytest_cache/
.idea/
*.swp
# Ignorar arquivos temporários
*.tmp
*.bak
*.old
.secret
# Ignorar coverage
htmlcov/
.coverage
coverage.xml
git_log
.env
.ruff_cache/
.mypy_cache/
.cursor
openspec
collect_scripts

689
README.md Normal file
View File

@@ -0,0 +1,689 @@
# Sientia DataOps Scouter
A high-performance, scalable data processing and ML model orchestration system built on Temporal.io for industrial data collection, processing, and analytics. The Scouter system provides enterprise-grade data ingestion from multiple sources with automatic data quality validation, aggregation, and export capabilities.
## Features
### Core Functionality
- **Multi-Source Data Ingestion**: Support for Kafka topics, direct OPC server access, PI Web API endpoints, and real-time triggers
- **Temporal Workflow Orchestration**: Robust workflow management with automatic retry policies and fault tolerance
- **Data Quality Gates**: Configurable filtering for null values, out-of-bounds data, and custom validation rules
- **Time-Series Aggregation**: Flexible aggregation functions (average, median, max, min, latest) with configurable parameters
- **Multi-Database Integration**: PostgreSQL for persistent storage, Redis for caching, MongoDB for data retrieval
- **Real-time Monitoring**: Prometheus metrics and comprehensive logging for operational visibility
### Advanced Capabilities
- **Incremental Data Processing**: Timestamp-based data loading to avoid reprocessing
- **Configurable Data Retention**: Redis-based temporary storage with TTL management
- **Notification System**: Integrated alerting and notification management via MongoDB
- **Scalable Architecture**: Kubernetes-ready deployment with horizontal scaling support
- **Debug Mode**: Optional data package storage for debugging and troubleshooting
- **Worker Autoscaling**: Configurable poller behavior with aggressive autoscaling policies
# Architecture
The Scouter system uses a Temporal-based workflow architecture with clear separation of concerns:
### Key Components
- **Worker**: Main application orchestrator managing Temporal workers and task queues
- **Workflows**: Temporal workflow definitions for data processing orchestration
- **Activities**: Temporal activities implementing data processing operations
- **Data Services**: Database connectors and data access layer
- **Quality Filters**: Configurable data validation and filtering mechanisms
## 🔄 Workflows
The Scouter system implements a parent-child workflow pattern for data processing orchestration.
### 1. Scouter Workflow (`scouter.py`)
The **Scouter** workflow is the main entry point for data processing pipelines. It orchestrates the complete data ingestion process and implements a robust incremental data processing pattern.
#### Purpose
- **Data Ingestion Orchestration**: Coordinates data loading from MongoDB collections
- **Timestamp Management**: Tracks last processed timestamps to enable incremental processing
- **Workflow Delegation**: Delegates actual data processing to the CoreScouter workflow
- **Data Continuity**: Ensures no data is lost or reprocessed between executions
#### Execution Flow
1. **Timestamp Retrieval**: Gets the last processed timestamp from Redis for the specific workflow and schedule
2. **Data Loading**: Loads new data from MongoDB since the last timestamp using the collection name `raw_{schedule_name}`
3. **Timestamp Update**: Updates the last processed timestamp with the most recent data point
4. **Data Processing**: Delegates data processing to the CoreScouter child workflow
5. **Metadata Management**: Maintains workflow execution metadata throughout the process
#### Key Features
- **Incremental Processing**: Only processes new data since last execution
- **Automatic Retry**: Implements Temporal retry policies for fault tolerance
- **Timeout Management**: 60-second timeout for all activities
- **Error Handling**: Comprehensive error handling with notification integration
#### Input Parameters
```json
{
"topic": "sensor_data_topic",
"schedule_name": "hourly_collection",
"model_name": "temperature_sensors",
"model_id": "temp_001",
"trigger_laborious": false,
"filters": {...},
"schema": "sensor_data",
"table_name": "temperature_readings",
"retention_time": 3600,
"model_tags": {...}
}
```
#### Architecture
```mermaid
flowchart LR
A[1. get_last_data_timestamp] --> B[2. load_latest_data] --> C[3. put_last_data_timestamp] --> D[4. core_scouter 🔃]
A -.-> Redis[(Redis)]
B -.-> MongoDB[(MongoDB)]
C -.-> Redis
```
### 2. CoreScouter Workflow (`core_scouter.py`)
The **CoreScouter** workflow implements the core data processing pipeline for industrial time-series data. It handles data quality validation, aggregation, and export operations.
#### Purpose
- **Data Quality Validation**: Applies configurable filters for data integrity
- **Time-Series Aggregation**: Groups and aggregates data using specified functions
- **Data Organization**: Groups data by tags and applies retention policies
- **Persistent Storage**: Exports processed data to PostgreSQL
- **Metrics Collection**: Records processing metrics for monitoring
#### Execution Flow
1. **Data Quality Gate**: Applies configured filters (null values, out-of-bounds, custom rules)
2. **Data Aggregation**: Groups data by tag and name, applies aggregation functions
3. **Data Grouping**: Organizes data and stores temporarily in Redis with TTL
4. **Data Export**: Persists processed data to PostgreSQL database with timestamp conversion
5. **Metrics Recording**: Writes processing metrics for operational visibility
**Note**: The data export step uses timestamp conversion to ensure consistent datetime
formatting. The export operation receives the schema, table name, data, and timestamp
conversion configuration. Conflict resolution and unique column constraints are handled
by the underlying PostgreSQL activity implementation.
#### Aggregation Functions
- **`lts`**: Latest value (most recent data point)
- **`avg`**: Average of all values in the group
- **`mdn`**: Median of all values in the group
- **`max`**: Maximum value in the group
- **`min`**: Minimum value in the group
#### Key Features
- **Configurable Quality Gates**: Multiple filter types with policy-based configuration
- **Flexible Aggregation**: Tag-specific aggregation function configuration
- **Batch Processing**: Efficient handling of large datasets
- **Asynchronous Export**: Non-blocking data export operations
- **Comprehensive Monitoring**: Detailed metrics and error reporting
#### Input Parameters
```json
{
"metadata": {...},
"workflow_name": "scouter",
"schedule_name": "hourly_collection",
"model_name": "temperature_sensors",
"model_id": "temp_001",
"data": {...},
"trigger_laborious": false,
"filters": {...},
"schema": "sensor_data",
"table_name": "temperature_readings",
"retention_time": 3600,
"fill_missing_tags": false,
"debug_data_package": false,
"model_tags": {
"Temperature": {
"data_range": [-50, 150],
"aggr_function": "avg",
"frequency": "60000"
}
}
}
```
**Additional Parameters:**
- `fill_missing_tags` (bool): Enable filling of missing tag values with default data
- `debug_data_package` (bool): Store raw and processed data packages in MongoDB for debugging
#### Architecture
```mermaid
flowchart LR
A[1. data_quality_gate] --> B[2. aggregate_data] --> C[3. group_and_hold_data] --> D[4. export_data_to_postgres] --> E[5. write_metrics]
E --> F{debug_data_package?}
F -->|yes| G[6. store_data_package]
B -.-> Redis1[(Redis)]
C -.-> Redis2[(Redis)]
D -.-> PostgreSQL[(PostgreSQL)]
E -.-> Metrics[Prometheus]
G -.-> MongoDB[(MongoDB)]
```
#### Debug Mode
When `debug_data_package` is set to `true`, the workflow stores both raw and processed data packages in MongoDB for debugging and troubleshooting purposes. This is useful for:
- Investigating data processing issues
- Validating data transformations
- Auditing data quality gate decisions
### 3. PI Web API Scouter Workflow (`pi_web_api_scouter.py`)
The **PI Web API Scouter** workflow serves as the entry point for PI Web API data processing pipelines. Unlike the standard Scouter workflow that loads data from MongoDB collections, this workflow directly queries PI Web API endpoints to retrieve tag values and processes them for downstream use.
#### Purpose
- **Direct API Ingestion**: Retrieves data directly from PI Web API endpoints
- **Real-time Data Processing**: Supports real-time and historical data retrieval
- **Data Normalization**: Normalizes timestamps to ensure consistency across records
- **Workflow Orchestration**: Delegates data processing to the CoreScouter workflow
- **Error Handling**: Comprehensive error handling with retry policies
#### Execution Flow
1. **Tag Value Retrieval**: Retrieves tag values from PI Web API using configured WebIds and time periods
2. **Data Normalization**: Normalizes timestamps to ensure all records in a batch share the same timestamp value
3. **Data Validation**: Validates retrieved data and handles empty responses
4. **Data Processing**: Delegates data processing to the CoreScouter child workflow
**Note**: The timestamp normalization process converts all timestamps to string format and then sets all records to the maximum timestamp value (lexicographically) found in the dataset. This ensures consistency across all records in a single batch.
#### Key Features
- **Configurable Time Periods**: Supports flexible time period configurations (e.g., '*-1d', '*-1h')
- **Data Point Limits**: Configurable maximum data points per tag via `max_count` parameter
- **Timeout Management**: Configurable API request timeouts for reliable operation
- **Empty Data Handling**: Gracefully handles empty responses without processing
- **Standardized Processing**: Uses CoreScouter for consistent data quality and export operations
#### Input Parameters
```json
{
"model_name": "pi_sensors",
"model_id": "pi_001",
"schedule_name": "hourly_pi_collection",
"pi_web_api_query": {
"endpoint": "/streamsets/recorded",
"period": "*-1d",
"max_count": 10,
"api_timeout": 30
},
"model_tags": {
"Temperature": {
"webid": "F1AbCdEfGhIjKlMnOpQrStUvWxYz",
"aggr_function": "avg",
"data_range": [-50, 150]
},
"Pressure": {
"webid": "F2AbCdEfGhIjKlMnOpQrStUvWxYz",
"aggr_function": "max",
"data_range": [0, 100]
}
},
"trigger_laborious": false,
"filters": {
"OUT_OF_BOUNDS_FILTER": {"policy": "DISCARD"},
"NULL_VALUES_FILTER": {"policy": "DISCARD"}
},
"schema": "sensor_data",
"table_name": "pi_readings",
"retention_time": 3600,
"fill_missing_tags": false,
"debug_data_package": false
}
```
**PI Web API Query Parameters:**
- `endpoint` (str): PI Web API endpoint path (e.g., '/streamsets/recorded')
- `period` (str): Time period configuration (e.g., '*-1d' for last day, '*-1h' for last hour)
- `max_count` (int, optional): Maximum data points per tag. Defaults to 1
- `api_timeout` (int): Request timeout in seconds for PI Web API calls
**Model Tags Configuration:**
- `webid` (str): PI Web API WebId for the tag
- `aggr_function` (str): Aggregation method (avg, mdn, max, min, lts)
- `data_range` (list[int]): [min, max] values for data validation
#### Architecture
```mermaid
flowchart LR
A[1. get_tag_values] --> B{data empty?}
B -->|yes| C[Exit]
B -->|no| D[2. core_scouter 🔃]
A -.-> PI_API[(PI Web API)]
D -.-> CoreScouter[CoreScouter Workflow]
```
#### Data Normalization
The `get_tag_values` activity normalizes timestamps to ensure consistency:
1. Converts all timestamps to string format using the configured datetime format
2. Identifies the maximum timestamp value (lexicographically) in the dataset
3. Sets all records to use this normalized timestamp value
This normalization ensures that all records in a single batch share the same timestamp, which is useful for batch processing and data consistency in downstream operations.
## 📋 Prerequisites
- Python 3.11+
- Temporal server/cluster
- PostgreSQL database
- Redis server
- MongoDB server
- Kafka cluster (for data ingestion)
- PI Web API server (for PI Web API Scouter workflow)
**Note**: External dependencies must be available either through:
- Kubernetes cluster deployment
- Docker Compose setup
- Cloud-managed services
- Local installations
## 🚀 Installation
### Local Development Setup
1. **Clone the repository**
```bash
git clone <repository-url>
cd sientia-dataops-scouter
```
2. **Create virtual environment**
```bash
python3.11 -m venv venv
source ./venv/bin/activate
```
3. **Install dependencies**
```bash
pip install -r requirements.txt
```
4. **Create environment configuration file**
```bash
cp .env.example .env
# Edit .env with your connection details
```
5. **Configure external dependencies**
You'll need to set up port forwarding or connections to external services. For example:
```bash
# Port forwarding from Kubernetes cluster
kubectl port-forward svc/redis-master 6379:6379
kubectl port-forward svc/mongodb 27017:27017
kubectl port-forward svc/kafka 9092:9092
# Or connect to external services
# Ensure services are accessible on localhost with appropriate ports
```
## 📦 How to Run
### Running the Scouter Application
Use the provided script to run the application locally:
```bash
# Make script executable (first time only)
chmod +x run_local.sh
# Run the application
./run_local.sh
```
The script will:
- Activate the virtual environment
- Load environment variables from `.env`
- Start the scouter worker application
### Running Tests and Coverage
Use the provided script to run tests with coverage:
```bash
# Make script executable (first time only)
chmod +x run_coverage.sh
# Run tests with coverage
./run_coverage.sh
```
The script will:
- Activate the virtual environment
- Run pytest with coverage reporting
- Generate HTML coverage report
- Open the coverage report in your browser
### Manual Test Execution
You can also run tests manually:
```bash
# Activate virtual environment
source ./venv/bin/activate
# Run all tests
pytest
# Run with coverage
pytest --cov=scouter --cov-report=html
# Run specific test categories
pytest tests/activities/
pytest tests/workflow/
```
### Manual Application Execution
For manual execution without scripts:
```bash
# Activate virtual environment
source ./venv/bin/activate
# Load environment variables (if using .env file)
if [ -f .env ]; then
export $(cat .env | grep -v '^#' | xargs)
fi
# Start the scouter worker
python -m scouter.worker.worker
```
### Environment Configuration
Before running the application, ensure your `.env` file contains the necessary configuration.
## 🧪 Testing
### Test Structure
```
tests/
├── activities/ # Activity implementation tests
├── workflow/ # Workflow orchestration tests
├── utils/ # Utility function tests
└── integration/ # End-to-end workflow tests
```
### Test Execution
```bash
# Install test dependencies
pip install pytest pytest-cov pytest-asyncio
# Run tests with coverage
pytest --cov=scouter --cov-report=html
# Run specific test modules
pytest tests/activities/test_redis.py
pytest tests/workflow/test_scouter.py
```
## 📊 Monitoring and Metrics
The Scouter system exposes comprehensive Prometheus metrics:
### Application Metrics
- `app_up`: Application health status (1=healthy, 0=unhealthy)
- `scouter_laborious_data_written_count`: Data export operation count
- `scouter_tag_changes_monitor`: Tag value change monitoring
### Temporal Metrics
- Workflow execution counts and durations
- Activity execution success/failure rates
- Task queue processing metrics
- Worker health and performance indicators
### Database Metrics
- Connection pool utilization
- Query execution times
- Error rates and retry counts
## ⚙️ Configuration
### Environment Variables
| Variable | Description | Default | Required |
|----------|-------------|---------|----------|
| `TEMPORAL_HOST` | Temporal server address | `localhost:7233` | Yes |
| `TEMPORAL_NAMESPACE` | Temporal namespace | `scouter` | No |
| `POSTGRES_HOST` | PostgreSQL hostname | `localhost` | Yes |
| `POSTGRES_PORT` | PostgreSQL port | `5432` | Yes |
| `POSTGRES_USER` | PostgreSQL username | `sientia` | Yes |
| `POSTGRES_PASSWORD` | PostgreSQL password | `sientia` | Yes |
| `POSTGRES_DBNAME` | PostgreSQL database | `sientia` | Yes |
| `REDIS_HOST` | Redis hostname | `localhost` | Yes |
| `REDIS_PORT` | Redis port | `6379` | Yes |
| `MONGODB_URL` | MongoDB connection URI | `localhost:27017` | Yes |
| `KAFKA_BOOTSTRAP_SERVERS` | Kafka broker addresses | `localhost:9092` | No |
| `PI_WEB_API_BASE_URL` | PI Web API base URL | - | Yes (for PI Web API Scouter) |
| `PI_WEB_API_AUTH_TYPE` | PI Web API authentication type ('basic' or 'bearer') | - | Yes (for PI Web API Scouter) |
| `PI_WEB_API_AUTH_TOKEN` | PI Web API authentication token | - | Yes (for PI Web API Scouter) |
| `HTTP_METRICS_PORT` | Prometheus metrics port | `9090` | No |
| `HTTP_SDK_METRICS_PORT` | Temporal SDK metrics port | `9091` | No |
| `PROJECT_NAME` | Project identifier for notifications | `scouter` | No |
### Worker Configuration
The worker supports advanced configuration for optimizing performance and latency:
| Variable | Description | Default | Recommended |
|----------|-------------|---------|-------------|
| `MAX_CONCURRENT_WORKFLOW_TASKS` | Maximum concurrent workflow tasks | `200` | 100-500 |
| `MAX_CONCURRENT_ACTIVITIES` | Maximum concurrent activities | `200` | 100-500 |
| `MAX_CONCURRENT_LOCAL_ACTIVITIES` | Maximum concurrent local activities | `200` | 100-500 |
| `MAX_CACHED_WORKFLOWS` | Maximum cached workflow instances | `200` | 100-500 |
### Poller Autoscaling Configuration
The worker implements aggressive autoscaling policies for workflow and activity pollers:
**Workflow Poller Behavior:**
| Variable | Description | Default |
|----------|-------------|---------|
| `WORKFLOW_POLLER_BEHAVIOUR_MINIMUM` | Minimum workflow pollers | `10` |
| `WORKFLOW_POLLER_BEHAVIOUR_INITIAL` | Initial workflow pollers | `100` |
| `WORKFLOW_POLLER_BEHAVIOUR_MAXIMUM` | Maximum workflow pollers | `200` |
**Activity Poller Behavior:**
| Variable | Description | Default |
|----------|-------------|---------|
| `ACTIVITY_POLLER_BEHAVIOUR_MINIMUM` | Minimum activity pollers | `10` |
| `ACTIVITY_POLLER_BEHAVIOUR_INITIAL` | Initial activity pollers | `100` |
| `ACTIVITY_POLLER_BEHAVIOUR_MAXIMUM` | Maximum activity pollers | `200` |
### Workflow Configuration
MongoDB pipeline configuration:
#### Scouter Workflow Input Parameters
```json
{
"schedule_name": "scouter-opcua-orchestrated-pipeline",
"model_id": "1",
"workflow_type": "scouter",
"frequency": "5s", # Workflow execution frequency
"max_retry_policy": 1, # Maximum number of retries for the workflow
"read_tags": [
{
"tag_name": "Counter",
"server_id": "1",
"aggr_func": "avg",
"tag_address": "ns=2;i=2",
"frequency": "15000", # Tag expected frequency (used in Ingestor)
"data_range": [
-100,
100
]
},
{
"tag_name": "Rollout",
"server_id": "1",
"aggr_func": "mdn",
"tag_address": "ns=2;i=3",
"frequency": "15000",
"data_range": [
-100,
100
]
}
],
"filters": [
{
"filter_name": "OUT_OF_BOUNDS_FILTER",
"policy": "DISCARD"
},
{
"filter_name": "NULL_VALUES_FILTER",
"policy": "DISCARD"
}
],
"tag_retention_minutes": 60, # Time that tag data is cached in Redis
"active": true,
"updated_at": {
"$date": "2025-08-13T18:35:01.600Z"
}
}
```
## 🔧 Development
### Project Structure
```
scouter/
├── activities/ # Temporal activity implementations
│ ├── activities.py # Main activities orchestrator
│ ├── api.py # PI Web API operations (tag value retrieval)
│ ├── redis.py # Redis operations (caching, timestamps)
│ ├── gates.py # Data quality gates and filtering
│ └── mongodb.py # MongoDB operations (data loading)
├── workflow/ # Temporal workflow definitions
│ ├── scouter.py # Main data ingestion workflow
│ ├── pi_web_api_scouter.py # PI Web API data ingestion workflow
│ └── sub_workflows/ # Sub-workflow implementations
│ └── core_scouter.py # Core data processing workflow
├── worker/ # Worker implementation
│ └── worker.py # Main worker orchestrator
├── utils/ # Utility functions
│ ├── connectors_config.py # Database configuration
│ └── quality/ # Data quality filters
├── metrics.py # Prometheus metrics definitions
└── __init__.py
```
### Activity Implementations
The Activities class combines multiple service classes through multiple inheritance:
- **Postgres** (from sientia-dataops-library): PostgreSQL data export and persistence
- **Redis**: Timestamp management, data caching, and temporary storage
- **Gates**: Data quality validation and filtering logic
- **MongoDB**: Data loading from raw collections
- **API**: PI Web API tag value retrieval and data normalization
All activities support:
- Comprehensive logging and error handling
- Notification integration for errors and alerts
- Prometheus metrics collection
- Graceful shutdown and resource cleanup
### Adding New Features
1. **Follow Temporal patterns** for new workflows and activities
2. **Add comprehensive docstrings** for all public methods
3. **Include Prometheus metrics** for monitoring
4. **Add unit tests** for new functionality
5. **Update this README** with new features and configuration
## 🐛 Troubleshooting
### Common Issues
1. **Temporal Connection Failures**
- Verify Temporal server is running and accessible
- Check namespace configuration and permissions
- Review server logs for connection issues
2. **Database Connection Issues**
- Verify all database services are running
- Check connection credentials and network access
- Ensure proper connection pool configuration
3. **Workflow Execution Failures**
- Review activity error logs and notifications
- Check data quality filter configurations
- Verify input data format and required fields
4. **Performance Issues**
- Monitor Prometheus metrics for bottlenecks
- Review database query performance
- Check Temporal worker configuration
### Debug Mode
Enable debug logging by setting the log level:
```bash
export LOG_LEVEL=DEBUG
```
## ⚡ Performance Tuning
### Key Parameters
- **Worker Concurrency**: Adjust `MAX_CONCURRENT_WORKFLOW_TASKS` and `MAX_CONCURRENT_ACTIVITIES` (default: 200)
- **Poller Autoscaling**: Configure minimum, initial, and maximum poller counts for optimal throughput
- **Connection Pools**: Optimize database connection pool sizes (configured in `build_*_config()` functions)
- **Data Retention**: Configure Redis TTL via `retention_time` parameter (in seconds)
- **Workflow Caching**: Set `MAX_CACHED_WORKFLOWS` to balance memory usage and performance
### Scaling Considerations
- **Horizontal Scaling**: Deploy multiple worker instances (each registers to `scouter-queue`)
- **Poller Autoscaling**: Workers implement aggressive autoscaling (10-200 pollers) for latency optimization
- **Database Performance**: Connection pooling is configured in `utils/connectors_config.py`
- **Worker Placement**: Use pod anti-affinity rules in Kubernetes for optimal distribution
- **Resource Limits**: Configure appropriate CPU/memory limits based on concurrency settings
## 🤝 Contributing
1. Fork the repository
2. Create a feature branch
3. Make your changes with comprehensive testing
4. Update documentation and docstrings
5. Submit a pull request
### Code Quality Standards
- Follow PEP 8 style guidelines
- Include comprehensive docstrings for all public methods
- Maintain test coverage above 80%
- Use type hints where appropriate
- Follow Temporal.io best practices
## 📄 License
This project is licensed under the terms specified in the LICENSE file.
## 🆘 Support
For support and questions:
- Check the troubleshooting section above
- Review the metrics and logs for error patterns
- Open an issue in the project repository
- Contact the development team
---
**Note**: The Scouter system is designed for production use in industrial data processing environments. Ensure proper security configuration and network isolation for production deployments.

37
e2e/README.md Normal file
View File

@@ -0,0 +1,37 @@
# Scouter end-to-end tests
Production-faithful E2E tests for `Scouter`, `PIWebAPIScouter`, and `CoreScouter` against real backing services.
## Requirements
- Docker (for testcontainers: MongoDB, Redis, PostgreSQL)
- Python 3.11+ with dev dependencies: `pip install -r requirements-dev.txt`
## Run locally
```bash
pytest e2e/ --override-ini testpaths=e2e -m e2e -v
```
Stop on first failure:
```bash
pytest e2e/ --override-ini testpaths=e2e -m e2e -x
```
## Coverage (separate from unit tests)
```bash
COVERAGE_FILE=.coverage.e2e pytest e2e/ --override-ini testpaths=e2e -m e2e --cov=scouter --cov-branch
coverage combine .coverage .coverage.e2e && coverage report
```
## Scenario catalog
See [scenarios.md](scenarios.md) for numbered scenarios and how they map to `test_scenario_*` functions. Section `## 0` of that file lists the harness smoke tests in `test_harness_smoke.py` (infra liveness checks, not business scenarios).
## Production code is not mocked
E2E uses real testcontainers, an in-process PI Web API HTTP server, `WorkflowEnvironment.start_local()`, and production `Activities` wiring. The only stand-ins are `mock_logger` and the optional `notification_inserts` spy. If a scenario fails due to a production defect, it is marked `xfail(strict=True)` and tracked in `openspec/changes/standardize-and-complete-e2e-tests/notes.md` when applicable.
Unit tests under `tests/` remain Docker-free and run with the default `pytest` invocation.

0
e2e/__init__.py Normal file
View File

324
e2e/conftest.py Normal file
View File

@@ -0,0 +1,324 @@
"""
Pytest configuration and fixtures for production-faithful Scouter E2E tests.
"""
from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from unittest.mock import MagicMock
import pytest
import pytest_asyncio
from pymongo import MongoClient
from redis import Redis
from sqlalchemy import create_engine, text
from testcontainers.mongodb import MongoDbContainer
from testcontainers.postgres import PostgresContainer
from testcontainers.redis import RedisContainer
from temporalio.testing import WorkflowEnvironment
from temporalio.worker import PollerBehaviorAutoscaling, Worker
from e2e.helpers import SCOUTER_TASK_QUEUE, postgres_connection_parts
from e2e.pi_web_api_test_server import PIWebAPITestServer
from scouter.activities.activities import Activities
from scouter.workflow.pi_web_api_scouter import PIWebAPIScouter
from scouter.workflow.scouter import Scouter
from scouter.workflow.sub_workflows.core_scouter import CoreScouter
from sientia_do.notifications.handlers import CoreNotificationHandler
from sientia_do.observability.logger import Logger
E2E_DATABASE = 'scouter_e2e_test'
E2E_NOTIFICATION_COLLECTION = 'notification_queue'
DB_SCHEMA_PATH = Path(__file__).resolve().parent / 'db_schema.sql'
def _activity_list(activities: Activities) -> list:
"""Return bound activity callables for the E2E worker."""
return [
activities.load_latest_data,
activities.get_last_data_timestamp,
activities.put_last_data_timestamp,
activities.get_tag_values,
activities.data_quality_gate,
activities.aggregate_data,
activities.group_and_hold_data,
activities.export_data_to_postgres,
activities.write_metrics,
activities.store_data_package,
]
@pytest.fixture(scope='session')
def postgres_container():
"""
Session-scoped PostgreSQL testcontainer.
Return:
Running PostgresContainer instance
"""
container = PostgresContainer('postgres:15')
container.start()
yield container
container.stop()
@pytest.fixture(scope='session')
def mongo_container():
"""
Session-scoped MongoDB testcontainer.
Return:
Running MongoDbContainer instance
"""
container = MongoDbContainer('mongo:7')
container.start()
yield container
container.stop()
@pytest.fixture(scope='session')
def redis_container():
"""
Session-scoped Redis testcontainer.
Return:
Running RedisContainer instance
"""
container = RedisContainer('redis:7')
container.start()
yield container
container.stop()
@pytest.fixture(scope='session')
def postgres_engine(postgres_container):
"""
SQLAlchemy engine bound to the Postgres testcontainer for the session.
Return:
SQLAlchemy Engine
"""
engine = create_engine(postgres_container.get_connection_url())
yield engine
engine.dispose()
@pytest.fixture(scope='session')
def mongo_uri(mongo_container):
"""
MongoDB connection string for the testcontainer.
Return:
Connection URI string
"""
return mongo_container.get_connection_url()
@pytest.fixture(scope='session')
def redis_client(redis_container):
"""
Redis client connected to the testcontainer.
Return:
redis.Redis client with decode_responses=True
"""
host = redis_container.get_container_host_ip()
port = int(redis_container.get_exposed_port(6379))
client = Redis(host=host, port=port, decode_responses=True)
yield client
client.close()
@pytest.fixture(autouse=True)
def setup_postgres_schema_and_table(postgres_engine):
"""
Apply db_schema.sql before each test so laborious_data is empty and current.
"""
sql = DB_SCHEMA_PATH.read_text(encoding='utf-8')
with postgres_engine.begin() as conn:
conn.exec_driver_sql(sql)
yield
@pytest.fixture(autouse=True)
def reset_mongo_collections(mongo_uri):
"""
Drop all collections in the E2E Mongo database between tests.
"""
client = MongoClient(mongo_uri)
try:
db = client[E2E_DATABASE]
for name in db.list_collection_names():
db.drop_collection(name)
finally:
client.close()
yield
@pytest.fixture(autouse=True)
def reset_redis(redis_client):
"""
Flush the Redis testcontainer between tests.
"""
redis_client.flushdb()
yield
@pytest.fixture
def mock_logger():
"""
Logger stand-in (only permitted MagicMock in the E2E harness).
Return:
MagicMock with Logger spec
"""
logger = MagicMock(spec=Logger)
logger.info = MagicMock()
logger.debug = MagicMock()
logger.error = MagicMock()
logger.warning = MagicMock()
logger.custom_info = MagicMock()
return logger
@pytest.fixture
def notification_handler(mock_logger, mongo_uri):
"""
Real CoreNotificationHandler backed by the Mongo testcontainer.
Return:
CoreNotificationHandler instance
"""
handler = CoreNotificationHandler(
connection_string=mongo_uri,
database=E2E_DATABASE,
logger=mock_logger,
project_name='scouter-e2e',
notification_topic=E2E_NOTIFICATION_COLLECTION,
)
yield handler
handler.shutdown()
@pytest.fixture
def notification_inserts(notification_handler):
"""
Spy wrapper around notification collection insert_one (still writes to Mongo).
Return:
MagicMock wrapping insert_one
"""
collection = notification_handler.mongo_collection
spy = MagicMock(wraps=collection.insert_one)
collection.insert_one = spy
return spy
@pytest.fixture(scope='session')
def pi_web_api_server():
"""
Session-scoped in-process PI Web API HTTP stub.
Return:
Started PIWebAPITestServer instance
"""
server = PIWebAPITestServer()
server.start()
yield server
server.stop()
@pytest.fixture(autouse=True)
def cleanup_pi_web_api_server(pi_web_api_server):
"""
Reset PI Web API stub state between tests.
"""
pi_web_api_server.clear()
yield
@pytest_asyncio.fixture(scope='session')
async def temporal_env():
"""
Session-scoped WorkflowEnvironment using the real local Temporal dev server.
Return:
WorkflowEnvironment from start_local()
"""
async with await WorkflowEnvironment.start_local() as env:
yield env
@pytest.fixture
def test_activities(
postgres_container,
mongo_uri,
redis_container,
pi_web_api_server,
mock_logger,
notification_handler,
):
"""
Production Activities wired to testcontainers and the PI Web API stub.
Return:
Live Activities instance (no unittest.mock.patch)
"""
pg_parts = postgres_connection_parts(
postgres_container.get_connection_url())
redis_host = redis_container.get_container_host_ip()
redis_port = int(redis_container.get_exposed_port(6379))
activities = Activities(
postgres_config={
**pg_parts,
'min_connections': 1,
'max_connections': 5,
},
redis_config={
'host': redis_host,
'port': redis_port,
'username': '',
'password': '',
},
mongodb_config={
'connection_string': mongo_uri,
'database_name': E2E_DATABASE,
},
api_config={
'base_url': pi_web_api_server.base_url,
'auth_type': 'bearer',
'auth_token': 'e2e-test-token',
},
logger=mock_logger,
notification_handler=notification_handler,
)
yield activities
activities.shutdown()
@pytest_asyncio.fixture
async def temporal_worker(temporal_env, test_activities):
"""
Temporal worker registering all Scouter workflows and activities on the E2E queue.
Return:
Running temporalio.worker.Worker
"""
async with Worker(
temporal_env.client,
task_queue=SCOUTER_TASK_QUEUE,
workflows=[Scouter, PIWebAPIScouter, CoreScouter],
activities=_activity_list(test_activities),
activity_executor=ThreadPoolExecutor(
max_workers=50, thread_name_prefix='e2e-activity'),
max_concurrent_workflow_tasks=50,
max_concurrent_activities=50,
max_concurrent_local_activities=50,
workflow_task_poller_behavior=PollerBehaviorAutoscaling(
minimum=1, initial=2, maximum=10),
activity_task_poller_behavior=PollerBehaviorAutoscaling(
minimum=1, initial=2, maximum=10),
) as worker:
yield worker

16
e2e/db_schema.sql Normal file
View File

@@ -0,0 +1,16 @@
-- Single source of truth for E2E Postgres DDL (non-partitioned mirror of production sientia_data.laborious_data).
DROP TABLE IF EXISTS sientia_data.laborious_data;
DROP SCHEMA IF EXISTS sientia_data CASCADE;
CREATE SCHEMA sientia_data;
CREATE TABLE sientia_data.laborious_data (
id SERIAL NOT NULL,
model_id int4 NOT NULL,
variable text NOT NULL,
value numeric NULL,
"timestamp" timestamptz NOT NULL,
created_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL,
PRIMARY KEY (id, created_at)
);

0
e2e/fixtures/__init__.py Normal file
View File

404
e2e/helpers.py Normal file
View File

@@ -0,0 +1,404 @@
"""
Shared helpers for Scouter end-to-end tests.
"""
from __future__ import annotations
import json
import re
import uuid
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
from pymongo import MongoClient
from redis import Redis
from sientia_do.temporal.constants import DATETIME_FORMAT_MS_WITH_TZ
from sqlalchemy import create_engine, text
from sqlalchemy.engine import Engine
from temporalio.client import Client
SCENARIO_INPUTS_DIR = Path(__file__).resolve().parent / 'scenario_inputs'
SCOUTER_TASK_QUEUE = 'scouter-test-queue'
_NOW_MARKER = re.compile(r'^@now(?:([+-])(\d+)([smhd]))?$')
def _resolve_timestamp_marker(value: str) -> datetime:
"""
Resolve @now relative timestamp markers to timezone-aware datetimes.
Args:
- value: Marker string such as @now, @now-1h, or @now+30m
Return:
Resolved datetime in UTC
"""
match = _NOW_MARKER.match(value.strip())
if not match:
raise ValueError(f'Invalid timestamp marker: {value}')
now = datetime.now(UTC)
if match.group(1) is None:
return now
sign, amount, unit = match.group(1), int(match.group(2)), match.group(3)
delta_kwargs = {'seconds': 0, 'minutes': 0, 'hours': 0, 'days': 0}
if unit == 's':
delta_kwargs['seconds'] = amount
elif unit == 'm':
delta_kwargs['minutes'] = amount
elif unit == 'h':
delta_kwargs['hours'] = amount
elif unit == 'd':
delta_kwargs['days'] = amount
delta = timedelta(**delta_kwargs)
return now - delta if sign == '-' else now + delta
def _resolve_payload(node: Any) -> Any:
"""
Recursively resolve @now markers inside JSON-loaded structures.
Args:
- node: JSON node (dict, list, or scalar)
Return:
Structure with markers replaced by datetimes or formatted strings
"""
if isinstance(node, dict):
return {key: _resolve_payload(value) for key, value in node.items()}
if isinstance(node, list):
return [_resolve_payload(item) for item in node]
if isinstance(node, str) and node.startswith('@now'):
resolved = _resolve_timestamp_marker(node)
return resolved.strftime('%Y-%m-%d %H:%M:%S.%f%z')
return node
def load_scenario_input(name: str, **overrides: Any) -> dict[str, Any]:
"""
Load a scenario JSON file and apply optional overrides.
Args:
- name: Scenario slug with or without .json suffix
- overrides: Top-level keys merged into the loaded document
Return:
Parsed scenario document with @now markers resolved
"""
slug = name.removesuffix('.json')
path = SCENARIO_INPUTS_DIR / f'{slug}.json'
with path.open(encoding='utf-8') as handle:
payload = json.load(handle)
resolved = _resolve_payload(payload)
if overrides:
resolved.update(overrides)
return resolved
def make_workflow_id(prefix: str) -> str:
"""
Build a unique Temporal workflow id for E2E runs.
Args:
- prefix: Human-readable prefix for the workflow id
Return:
Unique workflow id string
"""
return f'{prefix}-{uuid.uuid4().hex[:12]}'
async def start_and_await_workflow(
client: Client,
workflow_run: Any,
input_data: dict[str, Any],
workflow_id: str,
*,
task_queue: str = SCOUTER_TASK_QUEUE,
timeout: float = 300.0,
) -> Any:
"""
Start a workflow on the E2E task queue and await its result.
Args:
- client: Temporal client from WorkflowEnvironment
- workflow_run: Workflow run method (e.g. Scouter.run)
- input_data: Workflow input payload
- workflow_id: Unique workflow id
- task_queue: Task queue name
- timeout: Maximum seconds to wait for completion
Return:
Workflow result (None for Scouter-family workflows)
"""
return await client.execute_workflow(
workflow_run,
input_data,
id=workflow_id,
task_queue=task_queue,
execution_timeout=timedelta(seconds=timeout),
)
def _coerce_mongo_document(document: dict[str, Any]) -> dict[str, Any]:
"""
Convert string timestamps in seed documents to BSON datetimes for Mongo filters.
Args:
- document: Raw document dict from scenario JSON
Return:
Document with inserted_at as datetime when present
"""
doc = dict(document)
inserted_at = doc.get('inserted_at')
if isinstance(inserted_at, str):
doc['inserted_at'] = datetime.strptime(inserted_at, DATETIME_FORMAT_MS_WITH_TZ).replace(
tzinfo=UTC
)
return doc
def seed_raw_collection(
mongo_uri: str,
database: str,
schedule_name: str,
documents: list[dict[str, Any]],
) -> None:
"""
Insert raw Mongo documents into raw_<schedule_name>.
Args:
- mongo_uri: MongoDB connection string
- database: Database name
- schedule_name: Schedule slug used in collection name
- documents: Documents to insert
"""
collection_name = f'raw_{schedule_name}'
client = MongoClient(mongo_uri)
try:
collection = client[database][collection_name]
if documents:
collection.insert_many([_coerce_mongo_document(doc) for doc in documents])
finally:
client.close()
def seed_last_data_timestamp(
redis_client: Redis,
workflow_name: str,
schedule_name: str,
value: str,
) -> None:
"""
Pre-seed last_data_timestamp Redis key the same way RedisRepository.set stores strings.
Args:
- redis_client: Connected Redis client
- workflow_name: Workflow name segment in the key
- schedule_name: Schedule name segment in the key
- value: Timestamp string to store
"""
key = f'last_data_timestamp:{workflow_name}:{schedule_name}'
redis_client.set(key, json.dumps(value))
def count_laborious_rows(engine: Engine, model_id: str | int | None = None) -> int:
"""
Count rows in sientia_data.laborious_data, optionally filtered by model_id.
Args:
- engine: SQLAlchemy engine bound to the Postgres testcontainer
- model_id: Optional model id filter
Return:
Row count
"""
query = 'SELECT COUNT(*) FROM sientia_data.laborious_data'
params: dict[str, Any] = {}
if model_id is not None:
query += ' WHERE model_id = :model_id'
params['model_id'] = int(model_id)
with engine.connect() as conn:
return conn.execute(text(query), params).scalar() or 0
def fetch_laborious_rows(engine: Engine, model_id: str | int | None = None) -> list[dict[str, Any]]:
"""
Fetch laborious_data rows as plain dicts.
Args:
- engine: SQLAlchemy engine bound to the Postgres testcontainer
- model_id: Optional model id filter
Return:
List of row dicts with variable and value keys
"""
query = 'SELECT model_id, variable, value, timestamp FROM sientia_data.laborious_data'
params: dict[str, Any] = {}
if model_id is not None:
query += ' WHERE model_id = :model_id'
params['model_id'] = int(model_id)
query += ' ORDER BY variable'
with engine.connect() as conn:
rows = conn.execute(text(query), params).mappings().all()
return [dict(row) for row in rows]
def count_held_data_keys(redis_client: Redis) -> int:
"""
Count Redis keys matching held_data_*.
Args:
- redis_client: Connected Redis client
Return:
Number of matching keys
"""
return len(redis_client.keys('held_data_*'))
def count_notifications(
mongo_uri: str,
database: str,
*,
notification_id: str | None = None,
level: str | None = None,
) -> int:
"""
Count notification documents in the E2E notification collection.
Args:
- mongo_uri: MongoDB connection string
- database: Database name
- notification_id: Optional notification_id filter
- level: Optional level filter (WARNING, ERROR, ...)
Return:
Matching document count
"""
client = MongoClient(mongo_uri)
try:
collection = client[database]['notification_queue']
query: dict[str, Any] = {}
if notification_id:
query['notification_id'] = notification_id
if level:
query['level'] = level
return collection.count_documents(query)
finally:
client.close()
def default_model_tags(
*,
names: list[str],
aggr: str = 'avg',
data_range: list[float] | None = None,
frequency: int = 60000,
) -> dict[str, dict[str, Any]]:
"""
Build a minimal model_tags map for E2E scenarios.
Args:
- names: Tag names to include
- aggr: Aggregation function (aggr_func field)
- data_range: Optional [min, max] validation range
- frequency: Collection frequency in milliseconds
Return:
model_tags dict keyed by tag name
"""
if data_range is None:
data_range = [0, 100]
return {
name: {
'webid': f'webid_{name}',
'aggr_func': aggr,
'data_range': data_range,
'frequency': frequency,
}
for name in names
}
def default_scouter_input(
*,
model_id: str = '1',
model_name: str = 'Test Model',
schedule_name: str = 'test-schedule',
workflow_name: str = 'scouter',
**overrides: Any,
) -> dict[str, Any]:
"""
Return a base workflow input dict for Scouter / CoreScouter E2E runs.
Args:
- model_id: Model identifier
- model_name: Human-readable model name
- schedule_name: Schedule slug
- workflow_name: Parent workflow name
- overrides: Additional keys merged into the payload
Return:
Workflow input dictionary
"""
payload: dict[str, Any] = {
'topic': 'e2e-topic',
'model_id': model_id,
'model_name': model_name,
'schedule_name': schedule_name,
'workflow_name': workflow_name,
'trigger_laborious': False,
'filters': {},
'schema': 'sientia_data',
'table_name': 'laborious_data',
'retention_time': 3600,
'fill_missing_tags': False,
'model_tags': default_model_tags(names=['tag1']),
}
payload.update(overrides)
return payload
def apply_pi_web_api_server_config(server: Any, config: dict[str, Any] | None) -> None:
"""
Configure the in-process PI Web API stub from a scenario pi_web_api_server block.
Args:
- server: PIWebAPITestServer instance
- config: Optional mode/rows/timeout_sleep_seconds dict from scenario JSON
"""
if not config:
return
server.set_mode(
config.get('mode', 'success'),
rows=config.get('rows'),
timeout_sleep_seconds=config.get('timeout_sleep_seconds', 60),
)
def postgres_connection_parts(connection_url: str) -> dict[str, Any]:
"""
Parse a SQLAlchemy Postgres URL into Activities postgres_config fields.
Args:
- connection_url: SQLAlchemy connection URL from testcontainers
Return:
Dict with host, port, user, password, dbname keys
"""
parsed = urlparse(connection_url)
return {
'host': parsed.hostname or 'localhost',
'port': parsed.port or 5432,
'user': parsed.username or 'test',
'password': parsed.password or 'test',
'dbname': (parsed.path or '/test').lstrip('/'),
}

View File

@@ -0,0 +1,124 @@
"""
In-process HTTP server emulating PI Web API streamsets/recorded responses for E2E tests.
"""
from __future__ import annotations
import json
import time
from typing import Any, Literal
from pytest_httpserver import HTTPServer
from werkzeug import Request
from werkzeug.wrappers import Response
PIWebAPIMode = Literal['success', 'empty', 'error', 'timeout']
STREAMSETS_RECORDED_PATH = '/streamsets/recorded'
class PIWebAPITestServer:
"""
Thread-backed PI Web API stub using pytest-httpserver (real HTTP for pycurl clients).
"""
def __init__(self) -> None:
self._httpserver = HTTPServer(host='127.0.0.1', port=0)
self._mode: PIWebAPIMode = 'success'
self._rows: list[dict[str, Any]] = []
self._timeout_sleep_seconds = 60
self.requests: list[Request] = []
@property
def host(self) -> str:
return self._httpserver.host
@property
def port(self) -> int:
return self._httpserver.port
@property
def base_url(self) -> str:
return f'http://{self.host}:{self.port}'
def start(self) -> None:
"""Start the HTTP server and register the streamsets handler."""
self._httpserver.start()
self._register_handler()
def stop(self) -> None:
"""Stop the HTTP server."""
self._httpserver.stop()
def clear(self) -> None:
"""Clear recorded requests and reset mode to success with no rows."""
self.requests.clear()
self._mode = 'success'
self._rows = []
self._httpserver.clear()
self._register_handler()
def set_mode(
self,
mode: PIWebAPIMode,
rows: list[dict[str, Any]] | None = None,
*,
timeout_sleep_seconds: int = 60,
) -> None:
"""
Configure the next responses from the stub server.
Args:
- mode: Response mode (success, empty, error, timeout)
- rows: Optional list of row dicts with keys name, webid, timestamp, value
- timeout_sleep_seconds: Sleep duration for timeout mode (must exceed client timeout)
"""
self._mode = mode
if rows is not None:
self._rows = rows
self._timeout_sleep_seconds = timeout_sleep_seconds
self._register_handler()
def _register_handler(self) -> None:
self._httpserver.expect_request(
STREAMSETS_RECORDED_PATH,
method='GET',
).respond_with_handler(self._handle_streamsets_recorded)
def _handle_streamsets_recorded(self, request: Request):
self.requests.append(request)
if self._mode == 'timeout':
time.sleep(self._timeout_sleep_seconds)
return self._json_response({'Items': []}, status=200)
if self._mode == 'error':
return self._json_response({'error': 'internal'}, status=500)
if self._mode == 'empty' or not self._rows:
return self._json_response({'Items': []}, status=200)
items_by_name: dict[str, list[dict[str, Any]]] = {}
for row in self._rows:
name = row['name']
items_by_name.setdefault(name, []).append(
{
'Timestamp': row['timestamp'],
'Value': row['value'],
'Good': True,
'Questionable': False,
}
)
items = [
{'Name': name, 'Items': points}
for name, points in items_by_name.items()
]
return self._json_response({'Items': items}, status=200)
@staticmethod
def _json_response(payload: dict[str, Any], *, status: int) -> Response:
return Response(
json.dumps(payload),
status=status,
mimetype='application/json',
)

View File

@@ -0,0 +1,24 @@
{
"workflow_input": {
"metadata": {"metadata": {"model_id": "24", "model_name": "Core Avg", "schedule_name": "core-avg", "workflow_name": "pi_web_api_scouter"}},
"workflow_name": "pi_web_api_scouter",
"schedule_name": "core-avg",
"model_name": "Core Avg",
"model_id": "24",
"data": [
{"timestamp": "2024-01-01 12:00:00+0000", "name": "tag_avg", "value": 10.0, "tag": "w1"},
{"timestamp": "2024-01-01 12:01:00+0000", "name": "tag_avg", "value": 20.0, "tag": "w1"},
{"timestamp": "2024-01-01 12:02:00+0000", "name": "tag_avg", "value": 30.0, "tag": "w1"}
],
"trigger_laborious": false,
"filters": {},
"schema": "sientia_data",
"table_name": "laborious_data",
"retention_time": 3600,
"fill_missing_tags": false,
"model_tags": {
"tag_avg": {"webid": "w1", "aggr_func": "avg", "data_range": [0, 1000], "frequency": 60000}
}
},
"expected_value": 20.0
}

View File

@@ -0,0 +1,24 @@
{
"workflow_input": {
"metadata": {"metadata": {"model_id": "28", "model_name": "Core Lts", "schedule_name": "core-lts", "workflow_name": "pi_web_api_scouter"}},
"workflow_name": "pi_web_api_scouter",
"schedule_name": "core-lts",
"model_name": "Core Lts",
"model_id": "28",
"data": [
{"timestamp": "2024-01-01 12:00:00+0000", "name": "tag_lts", "value": 100.0, "tag": "w1"},
{"timestamp": "2024-01-01 12:01:00+0000", "name": "tag_lts", "value": 200.0, "tag": "w1"},
{"timestamp": "2024-01-01 12:02:00+0000", "name": "tag_lts", "value": 300.0, "tag": "w1"}
],
"trigger_laborious": false,
"filters": {},
"schema": "sientia_data",
"table_name": "laborious_data",
"retention_time": 3600,
"fill_missing_tags": false,
"model_tags": {
"tag_lts": {"webid": "w1", "aggr_func": "lts", "data_range": [0, 1000], "frequency": 60000}
}
},
"expected_value": 300.0
}

View File

@@ -0,0 +1,24 @@
{
"workflow_input": {
"metadata": {"metadata": {"model_id": "26", "model_name": "Core Max", "schedule_name": "core-max", "workflow_name": "pi_web_api_scouter"}},
"workflow_name": "pi_web_api_scouter",
"schedule_name": "core-max",
"model_name": "Core Max",
"model_id": "26",
"data": [
{"timestamp": "2024-01-01 12:00:00+0000", "name": "tag_max", "value": 5.0, "tag": "w1"},
{"timestamp": "2024-01-01 12:01:00+0000", "name": "tag_max", "value": 15.0, "tag": "w1"},
{"timestamp": "2024-01-01 12:02:00+0000", "name": "tag_max", "value": 10.0, "tag": "w1"}
],
"trigger_laborious": false,
"filters": {},
"schema": "sientia_data",
"table_name": "laborious_data",
"retention_time": 3600,
"fill_missing_tags": false,
"model_tags": {
"tag_max": {"webid": "w1", "aggr_func": "max", "data_range": [0, 1000], "frequency": 60000}
}
},
"expected_value": 15.0
}

View File

@@ -0,0 +1,24 @@
{
"workflow_input": {
"metadata": {"metadata": {"model_id": "25", "model_name": "Core Mdn", "schedule_name": "core-mdn", "workflow_name": "pi_web_api_scouter"}},
"workflow_name": "pi_web_api_scouter",
"schedule_name": "core-mdn",
"model_name": "Core Mdn",
"model_id": "25",
"data": [
{"timestamp": "2024-01-01 12:00:00+0000", "name": "tag_mdn", "value": 1.0, "tag": "w1"},
{"timestamp": "2024-01-01 12:01:00+0000", "name": "tag_mdn", "value": 9.0, "tag": "w1"},
{"timestamp": "2024-01-01 12:02:00+0000", "name": "tag_mdn", "value": 5.0, "tag": "w1"}
],
"trigger_laborious": false,
"filters": {},
"schema": "sientia_data",
"table_name": "laborious_data",
"retention_time": 3600,
"fill_missing_tags": false,
"model_tags": {
"tag_mdn": {"webid": "w1", "aggr_func": "mdn", "data_range": [0, 1000], "frequency": 60000}
}
},
"expected_value": 5.0
}

View File

@@ -0,0 +1,24 @@
{
"workflow_input": {
"metadata": {"metadata": {"model_id": "27", "model_name": "Core Min", "schedule_name": "core-min", "workflow_name": "pi_web_api_scouter"}},
"workflow_name": "pi_web_api_scouter",
"schedule_name": "core-min",
"model_name": "Core Min",
"model_id": "27",
"data": [
{"timestamp": "2024-01-01 12:00:00+0000", "name": "tag_min", "value": 50.0, "tag": "w1"},
{"timestamp": "2024-01-01 12:01:00+0000", "name": "tag_min", "value": 30.0, "tag": "w1"},
{"timestamp": "2024-01-01 12:02:00+0000", "name": "tag_min", "value": 40.0, "tag": "w1"}
],
"trigger_laborious": false,
"filters": {},
"schema": "sientia_data",
"table_name": "laborious_data",
"retention_time": 3600,
"fill_missing_tags": false,
"model_tags": {
"tag_min": {"webid": "w1", "aggr_func": "min", "data_range": [0, 1000], "frequency": 60000}
}
},
"expected_value": 30.0
}

View File

@@ -0,0 +1,22 @@
{
"workflow_input": {
"metadata": {"metadata": {"model_id": "30", "model_name": "Core Debug", "schedule_name": "core-debug", "workflow_name": "subworkflow.core_scouter"}},
"workflow_name": "subworkflow.core_scouter",
"schedule_name": "core-debug",
"model_name": "Core Debug",
"model_id": "30",
"data": [
{"timestamp": "2024-01-01 12:00:00+0000", "name": "tag1", "value": 11.0, "tag": "webid1"}
],
"trigger_laborious": false,
"filters": {},
"schema": "sientia_data",
"table_name": "laborious_data",
"retention_time": 3600,
"fill_missing_tags": false,
"debug_data_package": true,
"model_tags": {
"tag1": {"webid": "webid1", "aggr_func": "avg", "data_range": [0, 100], "frequency": 60000}
}
}
}

View File

@@ -0,0 +1,24 @@
{
"workflow_input": {
"metadata": {"metadata": {"model_id": "31", "model_name": "Core Empty Group", "schedule_name": "core-empty-group", "workflow_name": "pi_web_api_scouter"}},
"workflow_name": "pi_web_api_scouter",
"schedule_name": "core-empty-group",
"model_name": "Core Empty Group",
"model_id": "31",
"data": {
"timestamp": [],
"name": [],
"value": [],
"tag": []
},
"trigger_laborious": false,
"filters": {},
"schema": "sientia_data",
"table_name": "laborious_data",
"retention_time": 3600,
"fill_missing_tags": false,
"model_tags": {
"tag1": {"webid": "webid1", "aggr_func": "avg", "data_range": [0, 100], "frequency": 60000}
}
}
}

View File

@@ -0,0 +1,24 @@
{
"workflow_input": {
"metadata": {"metadata": {"model_id": "29", "model_name": "Core Fill Tags", "schedule_name": "core-fill", "workflow_name": "pi_web_api_scouter"}},
"workflow_name": "pi_web_api_scouter",
"schedule_name": "core-fill",
"model_name": "Core Fill Tags",
"model_id": "29",
"data": [
{"timestamp": "2024-01-01 12:00:00+0000", "name": "tag1", "value": 10.0, "tag": "webid1"}
],
"trigger_laborious": false,
"filters": {},
"schema": "sientia_data",
"table_name": "laborious_data",
"retention_time": 3600,
"fill_missing_tags": true,
"model_tags": {
"tag1": {"webid": "webid1", "aggr_func": "avg", "data_range": [0, 100], "frequency": 60000},
"tag2": {"webid": "webid2", "aggr_func": "avg", "data_range": [0, 100], "frequency": 60000},
"tag3": {"webid": "webid3", "aggr_func": "avg", "data_range": [0, 100], "frequency": 60000}
}
},
"expected_missing_tags": ["tag2", "tag3"]
}

View File

@@ -0,0 +1,28 @@
{
"workflow_input": {
"metadata": {
"metadata": {
"model_id": "20",
"model_name": "Core Happy",
"schedule_name": "core-happy",
"workflow_name": "pi_web_api_scouter"
}
},
"workflow_name": "pi_web_api_scouter",
"schedule_name": "core-happy",
"model_name": "Core Happy",
"model_id": "20",
"data": [
{"timestamp": "2024-01-01 12:00:00+0000", "name": "tag1", "value": 10.5, "tag": "webid1"}
],
"trigger_laborious": false,
"filters": {},
"schema": "sientia_data",
"table_name": "laborious_data",
"retention_time": 3600,
"fill_missing_tags": false,
"model_tags": {
"tag1": {"webid": "webid1", "aggr_func": "avg", "data_range": [0, 100], "frequency": 60000}
}
}
}

View File

@@ -0,0 +1,23 @@
{
"workflow_input": {
"metadata": {"metadata": {"model_id": "32", "model_name": "Core Bad Aggr", "schedule_name": "core-bad-aggr", "workflow_name": "pi_web_api_scouter"}},
"workflow_name": "pi_web_api_scouter",
"schedule_name": "core-bad-aggr",
"model_name": "Core Bad Aggr",
"model_id": "32",
"data": [
{"timestamp": "2024-01-01 12:00:00+0000", "name": "tag_ok", "value": 10.0, "tag": "w1"},
{"timestamp": "2024-01-01 12:00:00+0000", "name": "tag_bad", "value": 20.0, "tag": "w2"}
],
"trigger_laborious": false,
"filters": {},
"schema": "sientia_data",
"table_name": "laborious_data",
"retention_time": 3600,
"fill_missing_tags": false,
"model_tags": {
"tag_ok": {"webid": "w1", "aggr_func": "avg", "data_range": [0, 100], "frequency": 60000},
"tag_bad": {"webid": "w2", "aggr_func": "bogus", "data_range": [0, 100], "frequency": 60000}
}
}
}

View File

@@ -0,0 +1,23 @@
{
"workflow_input": {
"metadata": {"metadata": {"model_id": "21", "model_name": "Core Null Discard", "schedule_name": "core-null-discard", "workflow_name": "pi_web_api_scouter"}},
"workflow_name": "pi_web_api_scouter",
"schedule_name": "core-null-discard",
"model_name": "Core Null Discard",
"model_id": "21",
"data": [
{"timestamp": "2024-01-01 12:00:00+0000", "name": "tag1", "value": null, "tag": "webid1"},
{"timestamp": "2024-01-01 12:00:00+0000", "name": "tag2", "value": 50.0, "tag": "webid2"}
],
"trigger_laborious": false,
"filters": {"NULL_VALUES_FILTER": {"policy": "DISCARD"}},
"schema": "sientia_data",
"table_name": "laborious_data",
"retention_time": 3600,
"fill_missing_tags": false,
"model_tags": {
"tag1": {"webid": "webid1", "aggr_func": "avg", "data_range": [0, 100], "frequency": 60000},
"tag2": {"webid": "webid2", "aggr_func": "avg", "data_range": [0, 100], "frequency": 60000}
}
}
}

View File

@@ -0,0 +1,23 @@
{
"workflow_input": {
"metadata": {"metadata": {"model_id": "22", "model_name": "Core Null Warn", "schedule_name": "core-null-warn", "workflow_name": "pi_web_api_scouter"}},
"workflow_name": "pi_web_api_scouter",
"schedule_name": "core-null-warn",
"model_name": "Core Null Warn",
"model_id": "22",
"data": [
{"timestamp": "2024-01-01 12:00:00+0000", "name": "tag1", "value": null, "tag": "webid1"},
{"timestamp": "2024-01-01 12:00:00+0000", "name": "tag2", "value": 50.0, "tag": "webid2"}
],
"trigger_laborious": false,
"filters": {"NULL_VALUES_FILTER": {"policy": "WARN"}},
"schema": "sientia_data",
"table_name": "laborious_data",
"retention_time": 3600,
"fill_missing_tags": false,
"model_tags": {
"tag1": {"webid": "webid1", "aggr_func": "avg", "data_range": [0, 100], "frequency": 60000},
"tag2": {"webid": "webid2", "aggr_func": "avg", "data_range": [0, 100], "frequency": 60000}
}
}
}

View File

@@ -0,0 +1,23 @@
{
"workflow_input": {
"metadata": {"metadata": {"model_id": "23", "model_name": "Core OOB Discard", "schedule_name": "core-oob-discard", "workflow_name": "pi_web_api_scouter"}},
"workflow_name": "pi_web_api_scouter",
"schedule_name": "core-oob-discard",
"model_name": "Core OOB Discard",
"model_id": "23",
"data": [
{"timestamp": "2024-01-01 12:00:00+0000", "name": "tag1", "value": 150.0, "tag": "webid1"},
{"timestamp": "2024-01-01 12:00:00+0000", "name": "tag2", "value": 50.0, "tag": "webid2"}
],
"trigger_laborious": false,
"filters": {"OUT_OF_BOUNDS_FILTER": {"policy": "DISCARD"}},
"schema": "sientia_data",
"table_name": "laborious_data",
"retention_time": 3600,
"fill_missing_tags": false,
"model_tags": {
"tag1": {"webid": "webid1", "aggr_func": "avg", "data_range": [0, 100], "frequency": 60000},
"tag2": {"webid": "webid2", "aggr_func": "avg", "data_range": [0, 100], "frequency": 60000}
}
}
}

View File

@@ -0,0 +1,21 @@
{
"workflow_input": {
"metadata": {"metadata": {"model_id": "33", "model_name": "Core PG Fail", "schedule_name": "core-pg-fail", "workflow_name": "pi_web_api_scouter"}},
"workflow_name": "pi_web_api_scouter",
"schedule_name": "core-pg-fail",
"model_name": "Core PG Fail",
"model_id": "33",
"data": [
{"timestamp": "2024-01-01 12:00:00+0000", "name": "tag1", "value": 10.0, "tag": "webid1"}
],
"trigger_laborious": false,
"filters": {},
"schema": "sientia_data",
"table_name": "laborious_data",
"retention_time": 3600,
"fill_missing_tags": false,
"model_tags": {
"tag1": {"webid": "webid1", "aggr_func": "avg", "data_range": [0, 100], "frequency": 60000}
}
}
}

View File

@@ -0,0 +1,23 @@
{
"workflow_input": {
"model_id": "14",
"model_name": "PI Error",
"schedule_name": "pi-error",
"trigger_laborious": false,
"filters": {},
"schema": "sientia_data",
"table_name": "laborious_data",
"retention_time": 3600,
"fill_missing_tags": false,
"model_tags": {
"tag1": {"webid": "webid1", "aggr_func": "avg", "data_range": [0, 100], "frequency": 60000}
},
"pi_web_api_query": {
"endpoint": "/streamsets/recorded",
"period": "*-1d",
"max_count": 1,
"api_timeout": 30
}
},
"pi_web_api_server": {"mode": "error"}
}

View File

@@ -0,0 +1,29 @@
{
"workflow_input": {
"model_id": "12",
"model_name": "PI Debug Package",
"schedule_name": "pi-debug",
"trigger_laborious": false,
"filters": {},
"schema": "sientia_data",
"table_name": "laborious_data",
"retention_time": 3600,
"fill_missing_tags": false,
"debug_data_package": true,
"model_tags": {
"tag1": {"webid": "webid1", "aggr_func": "avg", "data_range": [0, 100], "frequency": 60000}
},
"pi_web_api_query": {
"endpoint": "/streamsets/recorded",
"period": "*-1d",
"max_count": 5,
"api_timeout": 30
}
},
"pi_web_api_server": {
"mode": "success",
"rows": [
{"name": "tag1", "timestamp": "2024-06-01T12:00:00Z", "value": 7.5}
]
}
}

View File

@@ -0,0 +1,23 @@
{
"workflow_input": {
"model_id": "13",
"model_name": "PI Empty",
"schedule_name": "pi-empty",
"trigger_laborious": false,
"filters": {},
"schema": "sientia_data",
"table_name": "laborious_data",
"retention_time": 3600,
"fill_missing_tags": false,
"model_tags": {
"tag1": {"webid": "webid1", "aggr_func": "avg", "data_range": [0, 100], "frequency": 60000}
},
"pi_web_api_query": {
"endpoint": "/streamsets/recorded",
"period": "*-1d",
"max_count": 1,
"api_timeout": 30
}
},
"pi_web_api_server": {"mode": "empty"}
}

View File

@@ -0,0 +1,30 @@
{
"workflow_input": {
"model_id": "10",
"model_name": "PI Web API Happy",
"schedule_name": "pi-happy",
"trigger_laborious": false,
"filters": {},
"schema": "sientia_data",
"table_name": "laborious_data",
"retention_time": 3600,
"fill_missing_tags": false,
"model_tags": {
"tag1": {"webid": "webid1", "aggr_func": "avg", "data_range": [0, 100], "frequency": 60000},
"tag2": {"webid": "webid2", "aggr_func": "avg", "data_range": [0, 100], "frequency": 60000}
},
"pi_web_api_query": {
"endpoint": "/streamsets/recorded",
"period": "*-1d",
"max_count": 10,
"api_timeout": 30
}
},
"pi_web_api_server": {
"mode": "success",
"rows": [
{"name": "tag1", "timestamp": "2024-06-01T12:00:00Z", "value": 10.5},
{"name": "tag2", "timestamp": "2024-06-01T12:00:00Z", "value": 20.3}
]
}
}

View File

@@ -0,0 +1,23 @@
{
"workflow_input": {
"model_id": "16",
"model_name": "PI Invalid Endpoint",
"schedule_name": "pi-invalid",
"trigger_laborious": false,
"filters": {},
"schema": "sientia_data",
"table_name": "laborious_data",
"retention_time": 3600,
"fill_missing_tags": false,
"model_tags": {
"tag1": {"webid": "webid1", "aggr_func": "avg", "data_range": [0, 100], "frequency": 60000}
},
"pi_web_api_query": {
"endpoint": "/invalid/endpoint",
"period": "*-1d",
"max_count": 1,
"api_timeout": 30
}
},
"pi_web_api_server": {"mode": "success", "rows": []}
}

View File

@@ -0,0 +1,53 @@
{
"workflow_input": {
"model_id": "11",
"model_name": "PI Multi Tag",
"schedule_name": "pi-multi",
"trigger_laborious": false,
"filters": {},
"schema": "sientia_data",
"table_name": "laborious_data",
"retention_time": 3600,
"fill_missing_tags": false,
"model_tags": {
"tag_avg": {"webid": "w1", "aggr_func": "avg", "data_range": [0, 1000], "frequency": 60000},
"tag_mdn": {"webid": "w2", "aggr_func": "mdn", "data_range": [0, 1000], "frequency": 60000},
"tag_max": {"webid": "w3", "aggr_func": "max", "data_range": [0, 1000], "frequency": 60000},
"tag_min": {"webid": "w4", "aggr_func": "min", "data_range": [0, 1000], "frequency": 60000},
"tag_lts": {"webid": "w5", "aggr_func": "lts", "data_range": [0, 1000], "frequency": 60000}
},
"pi_web_api_query": {
"endpoint": "/streamsets/recorded",
"period": "*-1d",
"max_count": 3,
"api_timeout": 30
}
},
"pi_web_api_server": {
"mode": "success",
"rows": [
{"name": "tag_avg", "timestamp": "2024-06-01T12:00:00Z", "value": 10},
{"name": "tag_avg", "timestamp": "2024-06-01T12:01:00Z", "value": 20},
{"name": "tag_avg", "timestamp": "2024-06-01T12:02:00Z", "value": 30},
{"name": "tag_mdn", "timestamp": "2024-06-01T12:00:00Z", "value": 1},
{"name": "tag_mdn", "timestamp": "2024-06-01T12:01:00Z", "value": 9},
{"name": "tag_mdn", "timestamp": "2024-06-01T12:02:00Z", "value": 5},
{"name": "tag_max", "timestamp": "2024-06-01T12:00:00Z", "value": 5},
{"name": "tag_max", "timestamp": "2024-06-01T12:01:00Z", "value": 15},
{"name": "tag_max", "timestamp": "2024-06-01T12:02:00Z", "value": 10},
{"name": "tag_min", "timestamp": "2024-06-01T12:00:00Z", "value": 50},
{"name": "tag_min", "timestamp": "2024-06-01T12:01:00Z", "value": 30},
{"name": "tag_min", "timestamp": "2024-06-01T12:02:00Z", "value": 40},
{"name": "tag_lts", "timestamp": "2024-06-01T12:00:00Z", "value": 100},
{"name": "tag_lts", "timestamp": "2024-06-01T12:01:00Z", "value": 200},
{"name": "tag_lts", "timestamp": "2024-06-01T12:02:00Z", "value": 300}
]
},
"expected_values": {
"tag_avg": 20.0,
"tag_mdn": 5.0,
"tag_max": 15.0,
"tag_min": 30.0,
"tag_lts": 300.0
}
}

View File

@@ -0,0 +1,23 @@
{
"workflow_input": {
"model_id": "15",
"model_name": "PI Timeout",
"schedule_name": "pi-timeout",
"trigger_laborious": false,
"filters": {},
"schema": "sientia_data",
"table_name": "laborious_data",
"retention_time": 3600,
"fill_missing_tags": false,
"model_tags": {
"tag1": {"webid": "webid1", "aggr_func": "avg", "data_range": [0, 100], "frequency": 60000}
},
"pi_web_api_query": {
"endpoint": "/streamsets/recorded",
"period": "*-1d",
"max_count": 1,
"api_timeout": 2
}
},
"pi_web_api_server": {"mode": "timeout", "timeout_sleep_seconds": 5}
}

View File

@@ -0,0 +1,23 @@
{
"workflow_input": {
"topic": "e2e-topic",
"model_id": "3",
"model_name": "Scouter Empty Mongo",
"schedule_name": "scouter-empty",
"trigger_laborious": false,
"filters": {},
"schema": "sientia_data",
"table_name": "laborious_data",
"retention_time": 3600,
"fill_missing_tags": false,
"model_tags": {
"tag1": {
"webid": "webid1",
"aggr_func": "avg",
"data_range": [0, 100],
"frequency": 60000
}
}
},
"raw_documents": []
}

View File

@@ -0,0 +1,38 @@
{
"workflow_input": {
"topic": "e2e-topic",
"model_id": "1",
"model_name": "Scouter E2E Model",
"schedule_name": "scouter-happy",
"trigger_laborious": false,
"filters": {},
"schema": "sientia_data",
"table_name": "laborious_data",
"retention_time": 3600,
"fill_missing_tags": false,
"model_tags": {
"tag1": {
"webid": "webid1",
"aggr_func": "avg",
"data_range": [0, 100],
"frequency": 60000
}
}
},
"raw_documents": [
{
"inserted_at": "2024-06-01 12:00:00.000000+0000",
"timestamp": "2024-06-01 12:00:00+0000",
"name": "tag1",
"value": 10.5,
"tag": "webid1"
},
{
"inserted_at": "2024-06-01 12:01:00.000000+0000",
"timestamp": "2024-06-01 12:01:00+0000",
"name": "tag1",
"value": 20.0,
"tag": "webid1"
}
]
}

View File

@@ -0,0 +1,43 @@
{
"workflow_input": {
"topic": "e2e-topic",
"model_id": "2",
"model_name": "Scouter Incremental",
"schedule_name": "scouter-incremental",
"trigger_laborious": false,
"filters": {},
"schema": "sientia_data",
"table_name": "laborious_data",
"retention_time": 3600,
"fill_missing_tags": false,
"model_tags": {
"tag1": {
"webid": "webid1",
"aggr_func": "avg",
"data_range": [0, 100],
"frequency": 60000
}
}
},
"redis_seed": {
"last_data_timestamp": "2024-06-01 10:00:00.000000+0000"
},
"raw_documents": [
{
"inserted_at": "2024-06-01 09:00:00.000000+0000",
"timestamp": "2024-06-01 09:00:00+0000",
"name": "tag1",
"value": 1.0,
"tag": "webid1"
},
{
"inserted_at": "2024-06-01 11:00:00.000000+0000",
"timestamp": "2024-06-01 11:00:00+0000",
"name": "tag1",
"value": 99.0,
"tag": "webid1"
}
],
"expected_newer_count": 1,
"expected_last_timestamp": "2024-06-01 11:00:00.000000+0000"
}

View File

@@ -0,0 +1,31 @@
{
"workflow_input": {
"topic": "e2e-topic",
"model_id": "4",
"model_name": "Scouter First Run",
"schedule_name": "scouter-first-run",
"trigger_laborious": false,
"filters": {},
"schema": "sientia_data",
"table_name": "laborious_data",
"retention_time": 3600,
"fill_missing_tags": false,
"model_tags": {
"tag1": {
"webid": "webid1",
"aggr_func": "lts",
"data_range": [0, 100],
"frequency": 60000
}
}
},
"raw_documents": [
{
"inserted_at": "2024-06-01 08:00:00.000000+0000",
"timestamp": "2024-06-01 08:00:00+0000",
"name": "tag1",
"value": 42.0,
"tag": "webid1"
}
]
}

138
e2e/scenarios.md Normal file
View File

@@ -0,0 +1,138 @@
# Scouter E2E scenario catalog
## Execution context
- **MongoDB**, **Redis**, and **PostgreSQL** run as session-scoped testcontainers with autouse cleanup between tests.
- **PI Web API** is an in-process HTTP server (`e2e/pi_web_api_test_server.py`) speaking the wire format consumed by `PIWebAPIClient`.
- **Temporal** uses `WorkflowEnvironment.start_local()` and a single worker on `scouter-test-queue`.
- **Production code is not mocked** (except `Logger` and optional notification insert spy).
---
## 0. Harness smoke tests
Diagnostic-only checks under `e2e/test_harness_smoke.py`. They are not business scenarios; they exist to fail fast when the harness itself (Docker / containers / Temporal worker wiring) is broken, before the numbered suite runs.
### 0.0.1 Postgres schema ready (`test_postgres_schema_ready`)
Confirms the autouse fixture executed `db_schema.sql` and `sientia_data.laborious_data` exists in the Postgres testcontainer.
### 0.0.2 Activities construct (`test_activities_construct`)
Confirms the production `Activities` instance initializes against the Mongo/Redis/Postgres testcontainers without hanging (no `patch(...)` involved).
### 0.0.3 Temporal PI happy path (`test_temporal_pi_happy_path`)
End-to-end liveness check: `WorkflowEnvironment.start_local()` + worker + in-process PI server + `PIWebAPIScouter` complete without raising. Functional assertions for this flow live in scenario **2.1.1**.
---
## 1. Scouter workflow
### 1.1.1 Happy path
Seed `raw_<schedule>` with multiple documents, run `Scouter`, assert Postgres rows and Redis `last_data_timestamp:scouter:<schedule>`.
### 1.2.1 Incremental load
Pre-seed Redis timestamp; seed older and newer Mongo docs; assert only newer rows export and timestamp advances.
### 1.3.1 Empty Mongo early exit
Empty `raw_<schedule>`; workflow exits without Postgres rows or Redis timestamp key.
### 1.3.2 No Redis timestamp first run
No prior Redis key; all seeded Mongo docs load and timestamp is written after success.
---
## 2. PIWebAPIScouter workflow
### 2.1.1 Happy path
PI server `success` mode with two tags; assert Postgres rows, Redis hold key, one HTTP request recorded.
### 2.1.2 Multiple tags
Five tags with `avg` / `mdn` / `max` / `min` / `lts`; assert five distinct `variable` values and exact aggregated numbers in Postgres.
### 2.1.3 Debug data package
`debug_data_package=True`; assert `data_package_pi_web_api_scouter_*` Redis key with `data` and `held_data`.
### 2.2.1 Empty response early exit
Server `empty` mode; zero Postgres rows for `model_id`, one request recorded.
### 2.3.1 PI Web API connection error
Server `error` mode (HTTP 5xx); workflow fails; `PI_WEB_API_REQUEST_ERROR` notification in Mongo.
### 2.3.2 PI Web API timeout
Server `timeout` mode; workflow fails; `PI_WEB_API_REQUEST_ERROR` notification sent.
### 2.3.3 Invalid endpoint
Workflow uses `/invalid/endpoint` (404); workflow fails; `PI_WEB_API_REQUEST_ERROR` notification sent.
---
## 3. CoreScouter subworkflow
### 3.1.1 Complete processing success
Single tag, no filters; Postgres row and `held_data_*` Redis key; no `data_package_*` key.
### 3.1.2 Null values filter discard
`NULL_VALUES_FILTER` DISCARD; one WARNING notification; only valid row in Postgres.
### 3.1.3 Null values filter warn
`NULL_VALUES_FILTER` WARN; notification sent; both rows in Postgres.
### 3.1.4 Out of bounds filter discard
`OUT_OF_BOUNDS_FILTER` DISCARD; in-range row only; WARNING notification.
### 3.1.5 Aggregation avg
Three points; Postgres `value` equals arithmetic mean (20.0).
### 3.1.6 Aggregation mdn
Median equals 5.0 in Postgres.
### 3.1.7 Aggregation max
Maximum equals 15.0 in Postgres.
### 3.1.8 Aggregation min
Minimum equals 30.0 in Postgres.
### 3.1.9 Aggregation lts
Last-by-timestamp value equals 300.0 in Postgres.
### 3.1.10 Fill missing tags
`fill_missing_tags=True`; `held_data_*` contains missing tag keys with `None`.
### 3.1.11 Debug data package
`debug_data_package=True`; `data_package_*` Redis key decodes to dict with `data` and `held_data`.
### 3.2.1 Empty after grouping early exit
Empty column-oriented `data`; no Postgres rows; no populated `held_data_*`.
### 3.3.1 Invalid aggregation function
`aggr_func= bogus`; `AGGREGATION_ISSUES` ERROR notification; bogus tag absent from Postgres.
### 3.3.2 Postgres export failure surfaces
Drop `value` column before run; workflow fails; ERROR notification in Mongo.

51
e2e/test_harness_smoke.py Normal file
View File

@@ -0,0 +1,51 @@
"""Fast smoke checks for E2E fixture wiring."""
import pytest
from e2e.helpers import (
apply_pi_web_api_server_config,
load_scenario_input,
make_workflow_id,
start_and_await_workflow,
)
from e2e.pi_web_api_test_server import PIWebAPITestServer
from scouter.workflow.pi_web_api_scouter import PIWebAPIScouter
from temporalio.testing import WorkflowEnvironment
from temporalio.worker import Worker
@pytest.mark.e2e
def test_postgres_schema_ready(postgres_engine):
"""Verify autouse schema setup created laborious_data."""
with postgres_engine.connect() as conn:
count = conn.exec_driver_sql(
'SELECT COUNT(*) FROM information_schema.tables '
"WHERE table_schema = 'sientia_data' AND table_name = 'laborious_data'"
).scalar()
assert count == 1
@pytest.mark.e2e
def test_activities_construct(test_activities):
"""Verify Activities initializes against testcontainers without hanging."""
assert test_activities.redis_repository is not None
assert test_activities.mongodb_repository is not None
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_temporal_pi_happy_path(
temporal_env: WorkflowEnvironment,
temporal_worker: Worker,
pi_web_api_server: PIWebAPITestServer,
):
"""Minimal Temporal path: PIWebAPIScouter happy path completes."""
scenario = load_scenario_input('pi_web_api_scouter_happy_path')
apply_pi_web_api_server_config(pi_web_api_server, scenario.get('pi_web_api_server'))
await start_and_await_workflow(
temporal_env.client,
PIWebAPIScouter.run,
scenario['workflow_input'],
make_workflow_id('smoke-pi-happy'),
timeout=60.0,
)

View File

@@ -0,0 +1,222 @@
"""
End-to-end tests for the PIWebAPIScouter main workflow.
"""
import json
import pytest
from redis import Redis
from temporalio.client import WorkflowFailureError
from temporalio.testing import WorkflowEnvironment
from temporalio.worker import Worker
from e2e.conftest import E2E_DATABASE
from e2e.helpers import (
apply_pi_web_api_server_config,
count_laborious_rows,
count_notifications,
fetch_laborious_rows,
load_scenario_input,
make_workflow_id,
start_and_await_workflow,
)
from e2e.pi_web_api_test_server import PIWebAPITestServer
from scouter.workflow.pi_web_api_scouter import PIWebAPIScouter
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_scenario_2_1_1_happy_path(
temporal_env: WorkflowEnvironment,
temporal_worker: Worker,
pi_web_api_server: PIWebAPITestServer,
postgres_engine,
redis_client: Redis,
):
scenario = load_scenario_input('pi_web_api_scouter_happy_path')
apply_pi_web_api_server_config(pi_web_api_server, scenario.get('pi_web_api_server'))
workflow_input = scenario['workflow_input']
await start_and_await_workflow(
temporal_env.client,
PIWebAPIScouter.run,
workflow_input,
make_workflow_id('pi-happy'),
)
model_id = workflow_input['model_id']
assert count_laborious_rows(postgres_engine, model_id) >= 1
assert len(redis_client.keys('held_data_*')) >= 1
assert len(pi_web_api_server.requests) == 1
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_scenario_2_1_2_multiple_tags(
temporal_env: WorkflowEnvironment,
temporal_worker: Worker,
pi_web_api_server: PIWebAPITestServer,
postgres_engine,
):
scenario = load_scenario_input('pi_web_api_scouter_multiple_tags')
apply_pi_web_api_server_config(pi_web_api_server, scenario.get('pi_web_api_server'))
workflow_input = scenario['workflow_input']
await start_and_await_workflow(
temporal_env.client,
PIWebAPIScouter.run,
workflow_input,
make_workflow_id('pi-multi'),
)
rows = {
row['variable']: float(row['value'])
for row in fetch_laborious_rows(postgres_engine, workflow_input['model_id'])
}
for tag, expected in scenario['expected_values'].items():
assert tag in rows
assert rows[tag] == pytest.approx(expected)
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_scenario_2_1_3_debug_data_package(
temporal_env: WorkflowEnvironment,
temporal_worker: Worker,
pi_web_api_server: PIWebAPITestServer,
redis_client: Redis,
):
scenario = load_scenario_input('pi_web_api_scouter_debug_data_package')
apply_pi_web_api_server_config(pi_web_api_server, scenario.get('pi_web_api_server'))
workflow_input = scenario['workflow_input']
await start_and_await_workflow(
temporal_env.client,
PIWebAPIScouter.run,
workflow_input,
make_workflow_id('pi-debug'),
)
keys = redis_client.keys('data_package_pi_web_api_scouter_*')
assert len(keys) >= 1
payload = json.loads(redis_client.get(keys[0]))
assert 'data' in payload and 'held_data' in payload
@pytest.mark.e2e
@pytest.mark.asyncio
@pytest.mark.xfail(
strict=True,
reason='Empty PI DataFrame lacks timestamp column in get_tag_values; tracked in fix-pi-empty-response-handling',
)
async def test_scenario_2_2_1_empty_response_early_exit(
temporal_env: WorkflowEnvironment,
temporal_worker: Worker,
pi_web_api_server: PIWebAPITestServer,
postgres_engine,
):
scenario = load_scenario_input('pi_web_api_scouter_empty_response')
apply_pi_web_api_server_config(pi_web_api_server, scenario.get('pi_web_api_server'))
workflow_input = scenario['workflow_input']
await start_and_await_workflow(
temporal_env.client,
PIWebAPIScouter.run,
workflow_input,
make_workflow_id('pi-empty'),
)
assert count_laborious_rows(postgres_engine, workflow_input['model_id']) == 0
assert len(pi_web_api_server.requests) == 1
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_scenario_2_3_1_pi_web_api_connection_error(
temporal_env: WorkflowEnvironment,
temporal_worker: Worker,
pi_web_api_server: PIWebAPITestServer,
mongo_uri: str,
):
scenario = load_scenario_input('pi_web_api_scouter_connection_error')
apply_pi_web_api_server_config(pi_web_api_server, scenario.get('pi_web_api_server'))
workflow_input = scenario['workflow_input']
with pytest.raises(WorkflowFailureError):
await start_and_await_workflow(
temporal_env.client,
PIWebAPIScouter.run,
workflow_input,
make_workflow_id('pi-conn-error'),
)
assert (
count_notifications(
mongo_uri,
E2E_DATABASE,
notification_id='PI_WEB_API_REQUEST_ERROR',
level='ERROR',
)
>= 1
)
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_scenario_2_3_2_pi_web_api_timeout(
temporal_env: WorkflowEnvironment,
temporal_worker: Worker,
pi_web_api_server: PIWebAPITestServer,
mongo_uri: str,
):
scenario = load_scenario_input('pi_web_api_scouter_timeout')
apply_pi_web_api_server_config(pi_web_api_server, scenario.get('pi_web_api_server'))
workflow_input = scenario['workflow_input']
with pytest.raises(WorkflowFailureError):
await start_and_await_workflow(
temporal_env.client,
PIWebAPIScouter.run,
workflow_input,
make_workflow_id('pi-timeout'),
timeout=180.0,
)
assert (
count_notifications(
mongo_uri,
E2E_DATABASE,
notification_id='PI_WEB_API_REQUEST_ERROR',
)
>= 1
)
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_scenario_2_3_3_invalid_endpoint(
temporal_env: WorkflowEnvironment,
temporal_worker: Worker,
pi_web_api_server: PIWebAPITestServer,
mongo_uri: str,
):
scenario = load_scenario_input('pi_web_api_scouter_invalid_endpoint')
apply_pi_web_api_server_config(pi_web_api_server, scenario.get('pi_web_api_server'))
workflow_input = scenario['workflow_input']
with pytest.raises(WorkflowFailureError):
await start_and_await_workflow(
temporal_env.client,
PIWebAPIScouter.run,
workflow_input,
make_workflow_id('pi-invalid-endpoint'),
)
assert (
count_notifications(
mongo_uri,
E2E_DATABASE,
notification_id='PI_WEB_API_REQUEST_ERROR',
)
>= 1
)

View File

@@ -0,0 +1,154 @@
"""
End-to-end tests for the Scouter main workflow (Mongo load path).
"""
import json
import pytest
from redis import Redis
from e2e.conftest import E2E_DATABASE
from e2e.helpers import (
count_laborious_rows,
load_scenario_input,
make_workflow_id,
seed_last_data_timestamp,
seed_raw_collection,
start_and_await_workflow,
)
from scouter.workflow.scouter import Scouter
from temporalio.testing import WorkflowEnvironment
from temporalio.worker import Worker
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_scenario_1_1_1_happy_path(
temporal_env: WorkflowEnvironment,
temporal_worker: Worker,
mongo_uri: str,
redis_client: Redis,
postgres_engine,
):
scenario = load_scenario_input('scouter_happy_path')
workflow_input = scenario['workflow_input']
seed_raw_collection(
mongo_uri,
E2E_DATABASE,
workflow_input['schedule_name'],
scenario['raw_documents'],
)
await start_and_await_workflow(
temporal_env.client,
Scouter.run,
workflow_input,
make_workflow_id('scouter-happy'),
)
model_id = workflow_input['model_id']
assert count_laborious_rows(postgres_engine, model_id) >= 1
key = f"last_data_timestamp:scouter:{workflow_input['schedule_name']}"
assert redis_client.get(key) is not None
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_scenario_1_2_1_incremental_load(
temporal_env: WorkflowEnvironment,
temporal_worker: Worker,
mongo_uri: str,
redis_client: Redis,
postgres_engine,
):
scenario = load_scenario_input('scouter_incremental_load')
workflow_input = scenario['workflow_input']
redis_seed = scenario['redis_seed']
seed_last_data_timestamp(
redis_client,
'scouter',
workflow_input['schedule_name'],
redis_seed['last_data_timestamp'],
)
seed_raw_collection(
mongo_uri,
E2E_DATABASE,
workflow_input['schedule_name'],
scenario['raw_documents'],
)
await start_and_await_workflow(
temporal_env.client,
Scouter.run,
workflow_input,
make_workflow_id('scouter-incremental'),
)
model_id = workflow_input['model_id']
assert count_laborious_rows(postgres_engine, model_id) == scenario['expected_newer_count']
stored = json.loads(
redis_client.get(f"last_data_timestamp:scouter:{workflow_input['schedule_name']}")
)
assert stored == scenario['expected_last_timestamp']
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_scenario_1_3_1_empty_mongo_early_exit(
temporal_env: WorkflowEnvironment,
temporal_worker: Worker,
mongo_uri: str,
redis_client: Redis,
postgres_engine,
):
scenario = load_scenario_input('scouter_empty_mongo')
workflow_input = scenario['workflow_input']
seed_raw_collection(
mongo_uri,
E2E_DATABASE,
workflow_input['schedule_name'],
scenario['raw_documents'],
)
await start_and_await_workflow(
temporal_env.client,
Scouter.run,
workflow_input,
make_workflow_id('scouter-empty'),
)
assert count_laborious_rows(postgres_engine, workflow_input['model_id']) == 0
key = f"last_data_timestamp:scouter:{workflow_input['schedule_name']}"
assert redis_client.get(key) is None
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_scenario_1_3_2_no_redis_timestamp_first_run(
temporal_env: WorkflowEnvironment,
temporal_worker: Worker,
mongo_uri: str,
redis_client: Redis,
postgres_engine,
):
scenario = load_scenario_input('scouter_no_redis_timestamp_first_run')
workflow_input = scenario['workflow_input']
seed_raw_collection(
mongo_uri,
E2E_DATABASE,
workflow_input['schedule_name'],
scenario['raw_documents'],
)
await start_and_await_workflow(
temporal_env.client,
Scouter.run,
workflow_input,
make_workflow_id('scouter-first-run'),
)
assert count_laborious_rows(postgres_engine, workflow_input['model_id']) >= 1
key = f"last_data_timestamp:scouter:{workflow_input['schedule_name']}"
assert redis_client.get(key) is not None

View File

@@ -0,0 +1,360 @@
"""
End-to-end tests for the CoreScouter subworkflow.
"""
import json
import pytest
from redis import Redis
from sqlalchemy import text
from temporalio.client import WorkflowFailureError
from temporalio.testing import WorkflowEnvironment
from temporalio.worker import Worker
from e2e.conftest import E2E_DATABASE
from e2e.helpers import (
count_laborious_rows,
count_notifications,
fetch_laborious_rows,
load_scenario_input,
make_workflow_id,
start_and_await_workflow,
)
from scouter.activities.activities import Activities
from scouter.workflow.sub_workflows.core_scouter import CoreScouter
def _held_data_blob(test_activities: Activities, workflow_input: dict) -> dict | None:
"""
Read held_data Redis payload via the production RedisRepository.
Return:
Decoded held-data dict or None
"""
key = (
f"held_data_{workflow_input['workflow_name']}_{workflow_input['schedule_name']}"
)
metadata = workflow_input['metadata']['metadata']
return test_activities.redis_repository.get(key, metadata=metadata)
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_scenario_3_1_1_complete_processing_success(
temporal_env: WorkflowEnvironment,
temporal_worker: Worker,
test_activities: Activities,
postgres_engine,
redis_client: Redis,
):
scenario = load_scenario_input('core_scouter_happy_path')
workflow_input = scenario['workflow_input']
await start_and_await_workflow(
temporal_env.client,
CoreScouter.run,
workflow_input,
make_workflow_id('core-happy'),
)
assert count_laborious_rows(postgres_engine, workflow_input['model_id']) >= 1
assert _held_data_blob(test_activities, workflow_input) is not None
assert len(redis_client.keys('data_package_*')) == 0
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_scenario_3_1_2_null_values_filter_discard(
temporal_env: WorkflowEnvironment,
temporal_worker: Worker,
mongo_uri: str,
postgres_engine,
):
scenario = load_scenario_input('core_scouter_null_values_filter_discard')
workflow_input = scenario['workflow_input']
await start_and_await_workflow(
temporal_env.client,
CoreScouter.run,
workflow_input,
make_workflow_id('core-null-discard'),
)
assert (
count_notifications(
mongo_uri,
E2E_DATABASE,
notification_id='DATA_QUALITY_GATE_ISSUES__NULL_VALUES_FILTER',
level='WARNING',
)
== 1
)
rows = fetch_laborious_rows(postgres_engine, workflow_input['model_id'])
assert len(rows) == 1
assert rows[0]['variable'] == 'tag2'
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_scenario_3_1_3_null_values_filter_warn(
temporal_env: WorkflowEnvironment,
temporal_worker: Worker,
mongo_uri: str,
postgres_engine,
):
scenario = load_scenario_input('core_scouter_null_values_filter_warn')
workflow_input = scenario['workflow_input']
await start_and_await_workflow(
temporal_env.client,
CoreScouter.run,
workflow_input,
make_workflow_id('core-null-warn'),
)
assert (
count_notifications(
mongo_uri,
E2E_DATABASE,
notification_id='DATA_QUALITY_GATE_ISSUES__NULL_VALUES_FILTER',
level='WARNING',
)
== 1
)
assert count_laborious_rows(postgres_engine, workflow_input['model_id']) == 2
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_scenario_3_1_4_out_of_bounds_filter_discard(
temporal_env: WorkflowEnvironment,
temporal_worker: Worker,
mongo_uri: str,
postgres_engine,
):
scenario = load_scenario_input('core_scouter_out_of_bounds_filter_discard')
workflow_input = scenario['workflow_input']
await start_and_await_workflow(
temporal_env.client,
CoreScouter.run,
workflow_input,
make_workflow_id('core-oob-discard'),
)
assert (
count_notifications(
mongo_uri,
E2E_DATABASE,
notification_id='DATA_QUALITY_GATE_ISSUES__OUT_OF_BOUNDS_FILTER',
level='WARNING',
)
== 1
)
rows = fetch_laborious_rows(postgres_engine, workflow_input['model_id'])
assert len(rows) == 1
assert rows[0]['variable'] == 'tag2'
async def _run_aggregation_scenario(
temporal_env: WorkflowEnvironment,
postgres_engine,
slug: str,
workflow_id_prefix: str,
) -> None:
scenario = load_scenario_input(slug)
workflow_input = scenario['workflow_input']
await start_and_await_workflow(
temporal_env.client,
CoreScouter.run,
workflow_input,
make_workflow_id(workflow_id_prefix),
)
rows = fetch_laborious_rows(postgres_engine, workflow_input['model_id'])
tag_name = next(iter(workflow_input['model_tags']))
value = next(row['value'] for row in rows if row['variable'] == tag_name)
assert float(value) == pytest.approx(scenario['expected_value'])
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_scenario_3_1_5_aggregation_avg(
temporal_env: WorkflowEnvironment,
temporal_worker: Worker,
postgres_engine,
):
await _run_aggregation_scenario(
temporal_env, postgres_engine, 'core_scouter_aggregation_avg', 'core-avg'
)
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_scenario_3_1_6_aggregation_mdn(
temporal_env: WorkflowEnvironment,
temporal_worker: Worker,
postgres_engine,
):
await _run_aggregation_scenario(
temporal_env, postgres_engine, 'core_scouter_aggregation_mdn', 'core-mdn'
)
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_scenario_3_1_7_aggregation_max(
temporal_env: WorkflowEnvironment,
temporal_worker: Worker,
postgres_engine,
):
await _run_aggregation_scenario(
temporal_env, postgres_engine, 'core_scouter_aggregation_max', 'core-max'
)
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_scenario_3_1_8_aggregation_min(
temporal_env: WorkflowEnvironment,
temporal_worker: Worker,
postgres_engine,
):
await _run_aggregation_scenario(
temporal_env, postgres_engine, 'core_scouter_aggregation_min', 'core-min'
)
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_scenario_3_1_9_aggregation_lts(
temporal_env: WorkflowEnvironment,
temporal_worker: Worker,
postgres_engine,
):
await _run_aggregation_scenario(
temporal_env, postgres_engine, 'core_scouter_aggregation_lts', 'core-lts'
)
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_scenario_3_1_10_fill_missing_tags(
temporal_env: WorkflowEnvironment,
temporal_worker: Worker,
test_activities: Activities,
):
scenario = load_scenario_input('core_scouter_fill_missing_tags')
workflow_input = scenario['workflow_input']
await start_and_await_workflow(
temporal_env.client,
CoreScouter.run,
workflow_input,
make_workflow_id('core-fill-tags'),
)
held = _held_data_blob(test_activities, workflow_input)
assert held is not None
for tag in scenario['expected_missing_tags']:
assert tag in held
assert held[tag] is None
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_scenario_3_1_11_debug_data_package(
temporal_env: WorkflowEnvironment,
temporal_worker: Worker,
redis_client: Redis,
):
scenario = load_scenario_input('core_scouter_debug_data_package')
workflow_input = scenario['workflow_input']
await start_and_await_workflow(
temporal_env.client,
CoreScouter.run,
workflow_input,
make_workflow_id('core-debug-pkg'),
)
keys = redis_client.keys('data_package_*')
assert len(keys) >= 1
payload = json.loads(redis_client.get(keys[0]))
assert 'data' in payload and 'held_data' in payload
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_scenario_3_2_1_empty_after_grouping_early_exit(
temporal_env: WorkflowEnvironment,
temporal_worker: Worker,
test_activities: Activities,
postgres_engine,
):
scenario = load_scenario_input('core_scouter_empty_after_grouping')
workflow_input = scenario['workflow_input']
await start_and_await_workflow(
temporal_env.client,
CoreScouter.run,
workflow_input,
make_workflow_id('core-empty-group'),
)
assert count_laborious_rows(postgres_engine, workflow_input['model_id']) == 0
assert _held_data_blob(test_activities, workflow_input) is None
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_scenario_3_3_1_invalid_aggregation_function(
temporal_env: WorkflowEnvironment,
temporal_worker: Worker,
mongo_uri: str,
postgres_engine,
):
scenario = load_scenario_input('core_scouter_invalid_aggregation')
workflow_input = scenario['workflow_input']
await start_and_await_workflow(
temporal_env.client,
CoreScouter.run,
workflow_input,
make_workflow_id('core-bad-aggr'),
)
variables = {row['variable'] for row in fetch_laborious_rows(postgres_engine, workflow_input['model_id'])}
assert 'tag_bad' not in variables
assert 'tag_ok' in variables
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_scenario_3_3_2_postgres_export_failure_surfaces(
temporal_env: WorkflowEnvironment,
temporal_worker: Worker,
mongo_uri: str,
postgres_engine,
):
scenario = load_scenario_input('core_scouter_postgres_export_failure')
workflow_input = scenario['workflow_input']
with postgres_engine.begin() as conn:
conn.execute(text('ALTER TABLE sientia_data.laborious_data DROP COLUMN value'))
with pytest.raises(WorkflowFailureError):
await start_and_await_workflow(
temporal_env.client,
CoreScouter.run,
workflow_input,
make_workflow_id('core-pg-fail'),
)
assert (
count_notifications(
mongo_uri,
E2E_DATABASE,
notification_id='ERROR_EXPORTING_DATA_TO_POSTGRES',
)
>= 1
)

604
get_data_pims.py Normal file
View File

@@ -0,0 +1,604 @@
import requests # type: ignore
import pandas as pd # type: ignore
from typing import Optional, Dict, List
class PIMSClient:
"""
Cliente para interagir com a API do PIMS (PI System) da Votorantim.
Esta classe fornece métodos para autenticação e busca de dados de streams/tags
do sistema PIMS através da API REST.
"""
def __init__(self, base_url: str, api_key: Optional[str] = None, api_key_header: Optional[str] = "apikey", additional_headers: Optional[Dict[str, str]] = None):
"""
Inicializa o cliente PIMS.
Args:
base_url (str): URL base da API (ex: https://votorantim.apimanagement.br10.hana.ondemand.com/v2/webapi/piwebapi)
api_key (Optional[str]): API Key para autenticação
api_key_header (Optional[str]): Nome do header onde a chave deve ser enviada (ex: "X-API-Key", "Ocp-Apim-Subscription-Key", "apikey")
additional_headers (Optional[Dict[str, str]]): Cabeçalhos adicionais para incluir em todas as requisições
"""
self.base_url = base_url.rstrip('/')
self.api_key = api_key
self.api_key_header = api_key_header
self.session = requests.Session()
self.additional_headers = additional_headers or {}
self._authenticated = False
def authenticate(self) -> bool:
"""
Configura a autenticação via cabeçalhos.
Returns:
bool: True se a configuração foi bem-sucedida, False caso contrário
"""
try:
default_headers: Dict[str, str] = {
'Content-Type': 'application/json',
'Accept': 'application/json'
}
if self.api_key and self.api_key_header:
default_headers[self.api_key_header] = self.api_key
# Mescla cabeçalhos adicionais (sobrescrevem os padrões se necessário)
default_headers.update(self.additional_headers)
self.session.headers.update(default_headers)
# Não faz chamada de teste aqui para evitar 401 em endpoints protegidos; assume headers configurados
self._authenticated = True
return True
except requests.exceptions.RequestException as e:
print(f"Erro na configuração da autenticação: {e}")
return False
def get_stream_data(self, web_ids: Dict[str, str], start_time: str = "*-3d", end_time: str = "*") -> Optional[pd.DataFrame]:
"""
Busca dados de múltiplos streams/tags.
Args:
web_ids (Dict[str, str]): Dicionário no formato {tag_name: web_id}
start_time (str): Data/hora de início (formato: "*-3d" ou "yyyy-mm-dd")
end_time (str): Data/hora de fim (formato: "*" ou "yyyy-mm-dd")
Returns:
pd.DataFrame: DataFrame onde as colunas são o nome da tag, o índice é o Timestamp, e os valores são os valores das tags
"""
if not self._authenticated:
if not self.authenticate():
print("Falha na autenticação")
return None
all_series = []
for tag_name, web_id in web_ids.items():
try:
url = f"{self.base_url}/streams/{web_id}/recorded"
params = {
"startTime": start_time,
"endTime": end_time
}
response = self.session.get(url, params=params)
response.raise_for_status()
data = response.json()
if 'Items' in data and data['Items']:
df = pd.DataFrame(data['Items'])
if 'Timestamp' in df.columns and 'Value' in df.columns:
# Converte timestamp
try:
df['Timestamp'] = pd.to_datetime(
df['Timestamp'],
format='ISO8601',
utc=True,
errors='coerce'
)
except TypeError:
df['Timestamp'] = pd.to_datetime(
df['Timestamp'],
utc=True,
errors='coerce'
)
# Arredonda timestamps para precisão de segundos
df['Timestamp'] = df['Timestamp'].dt.floor('s')
# Normaliza valores: quando a API retorna um dict, tenta extrair um número
def _extract_numeric(v):
if isinstance(v, dict):
# Casos comuns: {'Value': <num>} ou aninhados
inner = v.get('Value')
if isinstance(inner, (int, float)):
return inner
# Tenta outros campos conhecidos
for k in ('NumericValue',):
inner2 = v.get(k)
if isinstance(inner2, (int, float)):
return inner2
return None
if isinstance(v, (int, float)):
return v
# Converte strings numéricas, demais viram NaN
return pd.to_numeric(v, errors='coerce')
df['Value'] = df['Value'].apply(_extract_numeric)
df['Value'] = pd.to_numeric(df['Value'], errors='coerce')
df.set_index('Timestamp', inplace=True)
# Agrega valores por segundo para remover índices duplicados
series = (
df['Value']
.groupby(level=0)
.mean()
.sort_index()
.rename(tag_name)
)
all_series.append(series)
else:
print(f"Nenhum dado encontrado para a tag '{tag_name}' no período especificado")
except requests.exceptions.RequestException as e:
print(f"Erro ao buscar dados do stream {tag_name}: {e}")
if all_series:
result_df = pd.concat(all_series, axis=1)
return result_df
else:
print("Nenhum dado encontrado para as tags informadas.")
return pd.DataFrame()
def search_streams(self, name_filter: Optional[str] = None, tag_filter: Optional[str] = None) -> Optional[List[Dict]]:
"""
Busca streams disponíveis com filtros opcionais.
Args:
name_filter (str): Filtro por nome do stream
tag_filter (str): Filtro por tag
Returns:
List[Dict]: Lista de streams encontrados ou None se houver erro
"""
if not self._authenticated:
if not self.authenticate():
print("Falha na autenticação")
return None
try:
search_url = f"{self.base_url}/streams"
params = {}
if name_filter:
params['nameFilter'] = name_filter
if tag_filter:
params['tag'] = tag_filter
response = self.session.get(search_url, params=params)
response.raise_for_status()
return response.json().get('Items', [])
except requests.exceptions.RequestException as e:
print(f"Erro ao buscar streams: {e}")
return None
def get_stream_info(self, web_id: str) -> Optional[Dict]:
"""
Obtém informações detalhadas de um stream específico.
Args:
web_id (str): WebID do stream
Returns:
Dict: Informações do stream ou None se houver erro
"""
if not self._authenticated:
if not self.authenticate():
print("Falha na autenticação")
return None
try:
info_url = f"{self.base_url}/streams/{web_id}"
response = self.session.get(info_url)
response.raise_for_status()
return response.json()
except requests.exceptions.RequestException as e:
print(f"Erro ao buscar informações do stream: {e}")
return None
def get_web_ids_by_tags(self, data_server_id: str, tag_names: List[str]) -> Dict[str, Optional[str]]:
"""
Retorna os WebIds para uma lista de tags (pontos) em um Data Server específico.
Args:
data_server_id (str): ID/WebId do Data Server (ex: "F1DS-...")
tag_names (List[str]): Lista com os nomes exatos das tags
Returns:
Dict[str, Optional[str]]: Dicionário mapeando tag -> WebId (ou None se não encontrada)
"""
# Garante autenticação, similar ao script de teste
if not self._authenticated:
if not self.authenticate():
print("Falha na autenticação")
return {tag: None for tag in tag_names}
results: Dict[str, Optional[str]] = {}
for tag in tag_names:
try:
# Monta a URL seguindo a lógica do script de teste fornecido no contexto
url = f"{self.base_url}/dataservers/{data_server_id}/points"
params = {"namefilter": tag}
print(url, params)
response = self.session.get(url, params=params)
response.raise_for_status()
data = response.json()
# Corrige: procurar a lista 'Items' como no script de teste
items = data.get("Items", []) if isinstance(data, dict) else []
web_id_value: Optional[str] = None
if items:
# Emula exatamente o resultado do script: pega primeiro item se disponível
first_item = items[0]
if isinstance(first_item, dict):
web_id_value = first_item.get("WebId")
results[tag] = web_id_value
except requests.exceptions.RequestException as e:
print(f"Erro ao buscar WebId para tag '{tag}': {e}")
results[tag] = None
return results
def multi_tags_agregadas(
self,
web_ids: List[str],
start_time: str,
end_time: str,
summary_duration: str = "15m",
summary_type: str = "average",
selected_fields: str = "Items.Name;Items.Items.Type;Items.Items.Value.Timestamp;Items.Items.Value.Value;Items.Items.Value.Good",
batch_size: int = 50,
) -> Optional[Dict]:
"""
Chama o endpoint /streamsets/summary com múltiplos webids via GET e retorna o JSON bruto.
Para evitar URLs muito longas, realiza chamadas em lotes e agrega os resultados.
Args:
web_ids (List[str]): Lista de WebIds a consultar
start_time (str): Início do período (ex.: "2024-09-05" ou "*-1d")
end_time (str): Fim do período (ex.: "2024-09-06" ou "*")
summary_duration (str): Duração do resumo (ex.: "15m")
summary_type (str): Tipo de resumo (ex.: "average", "minimum", "maximum", etc.)
selected_fields (str): Campos a retornar
batch_size (int): Tamanho do lote de WebIds por requisição
Returns:
Optional[Dict]: JSON com "Items" unificados ou None em caso de erro
"""
if not self._authenticated:
if not self.authenticate():
print("Falha na autenticação")
return None
try:
url = f"{self.base_url}/streamsets/summary"
all_items: List[Dict] = []
for i in range(0, len(web_ids), batch_size):
chunk = web_ids[i:i + batch_size]
# Constrói lista de tuplas para repetir 'webid' como múltiplos params
params: List[tuple] = [("webid", wid) for wid in chunk]
params.extend([
("startTime", start_time),
("endtime", end_time), # conforme imagem
("summaryDuration", summary_duration),
("summaryType", summary_type),
("selectedFields", selected_fields),
])
response = self.session.get(url, params=params, timeout=600000)
response.raise_for_status()
data = response.json()
items = data.get("Items", []) if isinstance(data, dict) else []
if isinstance(items, list):
all_items.extend(items)
return {"Items": all_items}
except requests.exceptions.RequestException as e:
print(f"Erro ao buscar dados brutos de múltiplas tags: {e}")
return None
def multi_tags_agregadas_df(
self,
web_ids: List[str],
start_time: str,
end_time: str,
summary_duration: str = "1m",
summary_type: str = "average",
selected_fields: str = "Items.Name;Items.Items.Type;Items.Items.Value.Timestamp;Items.Items.Value.Value;Items.Items.Value.Good"
) -> pd.DataFrame:
"""
Chama /streamsets/summary para múltiplos webids e retorna DataFrame:
- índice: Timestamp (precisão de segundos)
- colunas: nome da tag
- células: Value (numérico)
"""
raw = self.multi_tags_agregadas(
web_ids=web_ids,
start_time=start_time,
end_time=end_time,
summary_duration=summary_duration,
summary_type=summary_type,
selected_fields=selected_fields,
)
if not raw or 'Items' not in raw or not isinstance(raw['Items'], list):
return pd.DataFrame()
records = []
def _extract_numeric(v):
if isinstance(v, dict):
inner = v.get('Value')
if isinstance(inner, (int, float)):
return inner
for k in ('NumericValue',):
inner2 = v.get(k)
if isinstance(inner2, (int, float)):
return inner2
return None
if isinstance(v, (int, float)):
return v
return pd.to_numeric(v, errors='coerce')
for entry in raw['Items']:
tag_name = entry.get('Name')
series_items = entry.get('Items') or []
for it in series_items:
v = (it.get('Value') or {}) if isinstance(it, dict) else {}
ts = v.get('Timestamp') if isinstance(v, dict) else None
val = v.get('Value') if isinstance(v, dict) else None
val = _extract_numeric(val)
if ts is not None:
records.append({
'Timestamp': ts,
'Tag': tag_name,
'Value': val,
})
if not records:
return pd.DataFrame()
df = pd.DataFrame.from_records(records)
# Converte e arredonda timestamps
try:
df['Timestamp'] = pd.to_datetime(df['Timestamp'], format='ISO8601', utc=True, errors='coerce')
except TypeError:
df['Timestamp'] = pd.to_datetime(df['Timestamp'], utc=True, errors='coerce')
df['Timestamp'] = df['Timestamp'].dt.floor('s')
# Pivot: índice timestamp, colunas nome da tag, valores numéricos
df_pivot = df.pivot_table(index='Timestamp', columns='Tag', values='Value', aggfunc='mean')
df_pivot.sort_index(inplace=True)
return df_pivot
def multi_tags_brutas(
self,
web_ids: List[str],
start_time: str,
end_time: str,
max_count: int = 10000,
batch_size: int = 50,
) -> Optional[Dict]:
"""
Chama o endpoint /streamsets/recorded com múltiplos webids via GET e retorna o JSON bruto.
Para evitar URLs muito longas, realiza chamadas em lotes e agrega os resultados.
Args:
web_ids (List[str]): Lista de WebIds a consultar
start_time (str): Início do período (ex.: "2024-09-01" ou "*-1d")
end_time (str): Fim do período (ex.: "2024-09-09" ou "*")
max_count (int): Número máximo de registros a retornar por requisição
batch_size (int): Tamanho do lote de WebIds por requisição
Returns:
Optional[Dict]: JSON com "Items" unificados ou None em caso de erro
"""
if not self._authenticated:
if not self.authenticate():
print("Falha na autenticação")
return None
try:
url = f"{self.base_url}/streamsets/recorded"
all_items: List[Dict] = []
for i in range(0, len(web_ids), batch_size):
chunk = web_ids[i:i + batch_size]
# Constrói lista de tuplas para repetir 'webid' como múltiplos params
params: List[tuple] = [("webid", wid) for wid in chunk]
params.extend([
("startTime", start_time),
("endTime", end_time),
("maxCount", str(max_count)),
])
response = self.session.get(url, params=params, timeout=600000)
response.raise_for_status()
data = response.json()
items = data.get("Items", []) if isinstance(data, dict) else []
if isinstance(items, list):
all_items.extend(items)
return {"Items": all_items}
except requests.exceptions.RequestException as e:
print(f"Erro ao buscar dados brutos de múltiplas tags: {e}")
return None
def multi_tags_brutas_df(
self,
web_ids: List[str],
start_time: str,
end_time: str,
max_count: int = 10000,
) -> pd.DataFrame:
"""
Chama /streamsets/recorded para múltiplos webids e retorna DataFrame:
- índice: Timestamp (precisão de segundos)
- colunas: nome da tag
- células: Value (numérico)
Args:
web_ids (List[str]): Lista de WebIds a consultar
start_time (str): Início do período (ex.: "2024-09-01" ou "*-1d")
end_time (str): Fim do período (ex.: "2024-09-09" ou "*")
max_count (int): Número máximo de registros a retornar por requisição
Returns:
pd.DataFrame: DataFrame com timestamp como índice e tags como colunas
"""
raw = self.multi_tags_brutas(
web_ids=web_ids,
start_time=start_time,
end_time=end_time,
max_count=max_count,
)
if not raw or 'Items' not in raw or not isinstance(raw['Items'], list):
return pd.DataFrame()
records = []
def _extract_numeric(v):
if isinstance(v, dict):
inner = v.get('Value')
if isinstance(inner, (int, float)):
return inner
for k in ('NumericValue',):
inner2 = v.get(k)
if isinstance(inner2, (int, float)):
return inner2
return None
if isinstance(v, (int, float)):
return v
return pd.to_numeric(v, errors='coerce')
for entry in raw['Items']:
tag_name = entry.get('Name')
# Para /streamsets/recorded, cada entry tem uma lista 'Items' com objetos contendo Timestamp e Value diretamente
series_items = entry.get('Items') or []
# Processa items aninhados
for it in series_items:
if isinstance(it, dict):
# Estrutura: {'Timestamp': '2024-09-01T23:00:00Z', 'Value': 4431.94141, ...}
if 'Timestamp' in it and 'Value' in it:
ts = it.get('Timestamp')
val = it.get('Value')
val = _extract_numeric(val)
if ts is not None:
records.append({
'Timestamp': ts,
'Tag': tag_name,
'Value': val,
})
if not records:
return pd.DataFrame()
df = pd.DataFrame.from_records(records)
# Converte e arredonda timestamps
try:
df['Timestamp'] = pd.to_datetime(df['Timestamp'], format='ISO8601', utc=True, errors='coerce')
except TypeError:
df['Timestamp'] = pd.to_datetime(df['Timestamp'], utc=True, errors='coerce')
df['Timestamp'] = df['Timestamp'].dt.floor('s')
# Pivot: índice timestamp, colunas nome da tag, valores numéricos
# Agrega valores duplicados no mesmo timestamp usando média
df_pivot = df.pivot_table(index='Timestamp', columns='Tag', values='Value', aggfunc='mean')
df_pivot.sort_index(inplace=True)
return df_pivot
def close(self):
"""Fecha a sessão HTTP."""
self.session.close()
# Exemplo de uso
if __name__ == "__main__":
# Configuração do cliente
base_url = "https://votorantim.apimanagement.br10.hana.ondemand.com/v2/webapi/piwebapi"
api_key = "zK4WbZAZGBwSaQ5GJzhPpp06P1PGueqP"
# Cria instância do cliente
pims_client = PIMSClient(base_url, api_key)
# Exemplo: buscar dados de um stream específico
tag_forms = [
'CI-J3J01S1', 'CI-J3P01T1A', 'CI-J3P03S1', 'CI-W3A05F1',
'CI-W3A50A1', 'CI-W3A50A2', 'CI-W3A50A3', 'CI-W3A50P1', 'CI-W3A50T1',
'CI-W3A55P1', 'CI-W3A55T1', 'CI-W3A65_Cl', 'CI-W3A65_SO3', 'CI-W3A71P1',
'CI-W3A71P2', 'CI-W3A71P3', 'CI-W3E01F1', 'CI-W3K01S1', 'CI-W3K01T1',
'CI-W3K01T2', 'CI-W3K01T3', 'CI-W3K01T4', 'CI-W3K14P1', 'CI-W3P17S1', 'CI-W3V04P1',
'CI-W3V04P3', 'CI-W3V21F1', 'CI-W3V21P1', 'CI-W3V30F1', 'CI-W3V33P1',
'CI-W3W01A1', 'CI-W3W01A2', 'CI-W3W01A3', 'CI-W3W01G1', 'CI-W3W01P1',
'CI-W3W01P2', 'CI-W3W03I1', 'CI-W3W03S1', 'CI-W3X21IN', 'CI-W3_C3S',
'CI-W3_CAO', 'CI-W3_MA', 'CI-W3_MS', 'CI-W3_PL'
]
web_ids = pims_client.get_web_ids_by_tags("F1DS-7fYgsRTtUOa7V9NIwSujAUElIQVZD", tag_forms)
web_ids_list: List[str] = [wid for wid in web_ids.values() if isinstance(wid, str)]
from datetime import datetime, timedelta
# Parâmetros iniciais apenas até o dia (granulometria diária)
inicio = datetime(2023, 1, 1) # Somente a data, sem horas/minutos/segundos
fim = datetime.today().replace(hour=0, minute=0, second=0, microsecond=0) # Até hoje à 00:00 (começo do dia atual)
delta = timedelta(days=1)
dfs = [] # lista para armazenar os dataframes parciais
while inicio < fim:
proximo = min(inicio + delta, fim) # garante que não passa da data atual
print(f"Buscando de {inicio:%Y-%m-%d} até {proximo:%Y-%m-%d}...")
df_parcial = pims_client.multi_tags_brutas_df(
web_ids_list,
inicio.strftime("%Y-%m-%d"),
proximo.strftime("%Y-%m-%d"),
max_count=1000
)
dfs.append(df_parcial)
inicio = proximo # avança o cursor
# break
# concatena todos em um único dataframe
df_final = pd.concat(dfs, ignore_index=False)
df_final.reset_index(inplace=True)
df_final.rename(columns={'index': 'timestamp'}, inplace=True)
# df_final = df_final.ffill()
# df_final = df_final.bfill()
# save to csv
if not df_final.empty:
df_final.to_parquet("data_brutos_pims_no_fill.parquet", index=False)
print(df_final.head())
print(df_final.shape)
print(df_final.columns)

View File

@@ -0,0 +1 @@
git+ssh://git@github.com/Aignosi/sientia-dataops-library.git:sientia-do

133
init_port_forward.sh Executable file
View File

@@ -0,0 +1,133 @@
#!/bin/bash
# Usage:
# 1) Edit the PORT_FORWARDS list below with entries of:
# <namespace> <service_name> <local_port> <service_port>
# 2) Run: ./init_port_forward.sh
#
# The script will start all port-forwards in the background and keep running
# until interrupted (Ctrl+C). On exit, it will clean up started port-forward processes.
set -euo pipefail
# Define your namespace/service/port combinations here
# Example entries:
# "default my-service 8080 80"
# "observability grafana 3000 3000"
PORT_FORWARDS=(
"mongodb my-release-mongodb 27017 27017"
"paradedb paradedb-rw 5432 5432"
"redis redis-master 6379 6379"
"temporal temporal-frontend 7233 7233"
)
if [ ${#PORT_FORWARDS[@]} -eq 0 ]; then
echo "No port-forward entries defined. Edit PORT_FORWARDS in $(basename "$0")."
exit 1
fi
PIDS=()
cleanup() {
echo "\nStopping port-forward processes..."
for pid in "${PIDS[@]}"; do
if kill -0 "$pid" >/dev/null 2>&1; then
kill "$pid" >/dev/null 2>&1 || true
fi
done
}
trap cleanup EXIT INT TERM
timestamp() { date '+%Y-%m-%d %H:%M:%S'; }
# Allow overriding kubectl binary if needed
KUBECTL=${KUBECTL:-kubectl}
is_port_free() {
local port="$1"
# Consider port free if nothing is listening locally on it
if command -v ss >/dev/null 2>&1; then
! ss -ltn | awk '{print $4}' | grep -E "(^|:|\\])${port}$" >/dev/null 2>&1
else
if command -v lsof >/dev/null 2>&1; then
! lsof -tiTCP:"${port}" -sTCP:LISTEN >/dev/null 2>&1
else
# Fallback: attempt to open a TCP connection; expect failure when nothing is listening
! (exec 3<>"/dev/tcp/127.0.0.1/${port}") 2>/dev/null
fi
fi
}
free_port_if_stuck() {
local port="$1"
# Try multiple tools to free a stuck listener (often old kubectl PF)
if command -v lsof >/dev/null 2>&1; then
local pids
pids=$(lsof -tiTCP:"${port}" -sTCP:LISTEN 2>/dev/null || true)
if [ -n "${pids}" ]; then
echo "[$(timestamp)] Found listeners on ${port}: ${pids}; terminating"
kill ${pids} 2>/dev/null || true
sleep 0.5
fi
fi
if ! is_port_free "${port}"; then
if command -v fuser >/dev/null 2>&1; then
echo "[$(timestamp)] Forcing free of ${port} via fuser"
fuser -k "${port}/tcp" 2>/dev/null || true
sleep 0.5
fi
fi
}
run_port_forward() {
local namespace="$1"
local service_name="$2"
local local_port="$3"
local service_port="$4"
# simple and robust supervisor loop with gentle backoff on failures
local delay=2
local max_delay=20
while true; do
free_port_if_stuck "${local_port}"
if ! is_port_free "${local_port}"; then
echo "[$(timestamp)] ns=${namespace} svc=${service_name} ${local_port}:${service_port} -> local port busy, retrying in 3s"
sleep 3
continue
fi
echo "[$(timestamp)] Starting port-forward: ns=${namespace} svc=${service_name} ${local_port}:${service_port}"
${KUBECTL} -n "${namespace}" port-forward "svc/${service_name}" "${local_port}:${service_port}" \
--address=127.0.0.1 --pod-running-timeout=2m --request-timeout=0
rc=$?
# If kubectl exits (e.g., connection reset by peer), wait a bit and retry
echo "[$(timestamp)] Port-forward exited (rc=${rc}): ns=${namespace} svc=${service_name} ${local_port}:${service_port}"
sleep "${delay}"
# Exponential backoff up to max_delay
if [ ${delay} -lt ${max_delay} ]; then
delay=$(( delay * 2 ))
if [ ${delay} -gt ${max_delay} ]; then
delay=${max_delay}
fi
fi
done
}
ONLY_SERVICE_NAME="${ONLY_SERVICE_NAME:-}"
for entry in "${PORT_FORWARDS[@]}"; do
read -r NAMESPACE SERVICE_NAME LOCAL_PORT SERVICE_PORT <<< "$entry"
if [ -n "${ONLY_SERVICE_NAME}" ] && [ "${SERVICE_NAME}" != "${ONLY_SERVICE_NAME}" ]; then
continue
fi
run_port_forward "${NAMESPACE}" "${SERVICE_NAME}" "${LOCAL_PORT}" "${SERVICE_PORT}" &
PIDS+=("$!")
done
echo "All port-forwards started: ${#PIDS[@]} process(es). Press Ctrl+C to stop."
# Do not exit the script if one port-forward fails; they self-restart
set +e
wait

306
pi_web_api_fetch_data.py Normal file
View File

@@ -0,0 +1,306 @@
"""
Script to fetch WebIds from PI Web API and then retrieve historical values
for a list of tags over a given period in 30-day chunks, storing results
in a DataFrame indexed by timestamp.
Based on pi_web_api_client.py and tests.ipynb. Run with project venv active.
Use # %% cell separators: run each cell in order (Run Cell / Shift+Enter).
"""
# %% 1. Imports and configuration
import asyncio
import concurrent.futures
import json
import os
import time
from typing import Any
import pandas as pd
import requests
from unittest.mock import MagicMock, AsyncMock
from sientia_do.repository.pi_web_api_client import PIWebAPIClient
BASE_URL = 'https://pivision.votorantimcimentos.com/piwebapi'
AUTH_TOKEN = 'dmlkX3ZjbmV0XHN2Yy5waW9zaS5wcmQud2ViYXBpOlN2Y1ByRFdlQkBQaQ=='
WEBID_LOOKUP_PATH = 'dataservers/F1DS-7fYgsRTtUOa7V9NIwSujAUElIQVZD/points'
PERIOD_DAYS = (365 * 3) + 50
CHUNK_DAYS = 5
ENDPOINT = '/streamsets/recorded'
API_TIMEOUT = 60
WEB_IDS_SAVE_PATH = 'web_ids.json'
TAG_NAMES = [
'CI-J3J01S1', 'CI-J3P01T1A', 'CI-J3P03S1', 'CI-W3A05F1',
'CI-W3A50A1', 'CI-W3A50A2', 'CI-W3A50A3', 'CI-W3A50P1', 'CI-W3A50T1',
'CI-W3A55P1', 'CI-W3A55T1', 'CI-W3A65_Cl', 'CI-W3A65_SO3', 'CI-W3A71P1',
'CI-W3A71P2', 'CI-W3A71P3', 'CI-W3E01F1', 'CI-W3K01S1', 'CI-W3K01T1',
'CI-W3K01T2', 'CI-W3K01T3', 'CI-W3K01T4', 'CI-W3K14P1', 'CI-W3P17S1', 'CI-W3V04P1',
'CI-W3V04P3', 'CI-W3V21F1', 'CI-W3V21P1', 'CI-W3V30F1', 'CI-W3V33P1',
'CI-W3W01A1', 'CI-W3W01A2', 'CI-W3W01G1', 'CI-W3W01P1',
'CI-W3W01P2', 'CI-W3W03I1', 'CI-W3W03S1', 'CI-W3X21IN', 'CI-W3_C3S',
'CI-W3_CAO', 'CI-W3_MA', 'CI-W3_MS', 'CI-W3_PL'
]
print(f'Config: BASE_URL={BASE_URL}, PERIOD_DAYS={PERIOD_DAYS}, CHUNK_DAYS={CHUNK_DAYS}, tags={len(TAG_NAMES)}, WEB_IDS_SAVE_PATH={WEB_IDS_SAVE_PATH}')
def run_async(coro):
"""
Run a coroutine from sync code. Works in scripts and in Jupyter (where an event loop is already running).
Args:
coro: Coroutine to run.
Return:
Result of the coroutine.
"""
try:
asyncio.get_running_loop()
except RuntimeError:
return asyncio.run(coro)
with concurrent.futures.ThreadPoolExecutor() as pool:
future = pool.submit(asyncio.run, coro)
return future.result()
# %% 2. Helper: fetch WebIds from PI Web API
def fetch_webids(
tag_names: list[str],
base_url: str,
auth_token: str,
webid_lookup_path: str,
delay_seconds: float = 0.5,
) -> dict[str, dict[str, Any]]:
"""
Resolve WebIds for the given tag names via PI Web API points endpoint.
Args:
tag_names: List of tag names to resolve.
base_url: PI Web API base URL (no trailing slash).
auth_token: Basic auth token (base64-encoded user:password).
webid_lookup_path: Path relative to base_url, with {tag} placeholder for namefilter.
delay_seconds: Delay between requests to avoid rate limiting.
Returns:
dict mapping tag name to {'webid': str, 'aggr_func': str, 'data_range': list}.
"""
base_url = base_url.rstrip('/')
url_template = f'{base_url}/{webid_lookup_path}'
if '?' in url_template:
url_template = f'{url_template}&namefilter={{tag}}'
else:
url_template = f'{url_template}?namefilter={{tag}}'
headers = {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-Requested-With': 'piwebapistreams',
'Authorization': f'Basic {auth_token}',
}
web_ids: dict[str, dict[str, Any]] = {}
for idx, tag in enumerate(tag_names, start=1):
url = url_template.format(tag=tag)
resp = requests.get(url, headers=headers, timeout=API_TIMEOUT)
resp.raise_for_status()
data = resp.json()
items = data.get('Items', [])
if not items:
raise ValueError(f'No point found for tag: {tag}')
web_ids[tag] = {
'webid': items[0]['WebId'],
'aggr_func': 'lts',
'data_range': [-100000, 100000],
}
print(f' Resolved tag {idx}/{len(tag_names)}: {tag}')
time.sleep(delay_seconds)
return web_ids
# %% 3. Helper: load WebIds from JSON
def load_web_ids(json_path: str | None) -> dict[str, dict[str, Any]] | None:
"""
Load web_ids from a JSON file if path is provided.
Args:
json_path: Path to JSON file with tag -> {webid, ...} structure.
Return:
Loaded dict or None if json_path is None or file missing.
"""
if not json_path or not os.path.isfile(json_path):
return None
with open(json_path, encoding='utf-8') as f:
out = json.load(f)
print(f' Loaded {len(out)} web_ids from {json_path}')
return out
# %% 4. Helper: fetch values in chunks (async)
async def fetch_values_chunked(
web_ids: dict[str, dict[str, Any]],
period_days: int,
chunk_days: int,
base_url: str,
auth_token: str,
endpoint: str,
request_timeout: int,
) -> pd.DataFrame:
"""
Fetch historical values for web_ids over period_days in chunks of chunk_days.
Args:
web_ids: Dict mapping tag name to at least {'webid': str}.
period_days: Total period to fetch (e.g. 180 for last 180 days).
chunk_days: Size of each time chunk in days (e.g. 30).
base_url: PI Web API base URL.
auth_token: Basic auth token.
endpoint: PI Web API endpoint (e.g. /streamsets/recorded).
request_timeout: Request timeout in seconds.
Returns:
DataFrame with timestamp index and one column per tag (values).
"""
logger = MagicMock()
notification_handler = AsyncMock()
metrics_controller = AsyncMock()
client = PIWebAPIClient(
base_url=base_url,
auth_config={'type': 'basic', 'token': auth_token},
logger=logger,
notification_handler=notification_handler,
metrics_controller=metrics_controller,
headers_config={
'Content-Type': 'application/json',
'Accept': 'application/json',
'x-requested-with': 'piwebapistreams',
'User-Agent': 'PiWebApiFetchData/1.0',
},
)
metadata: dict[str, Any] = {}
chunks: list[pd.DataFrame] = []
try:
for i in range(period_days, 0, -chunk_days):
j = i - chunk_days
start_time_pi = f'*-{i}d'
end_time_pi = f'*-{j}d' if j > 0 else '*'
print(f' Fetching chunk: {start_time_pi} to {end_time_pi}')
df = await client.get_latest_values_df(
web_ids=web_ids,
endpoint=endpoint,
start_time=start_time_pi,
end_time=end_time_pi,
max_count=None,
request_timeout=request_timeout,
metadata=metadata,
)
if not df.empty:
chunks.append(df)
print(f' Chunk size: {df.shape}')
print(f' Chunk sample: {df.head(3)}')
else:
print(f' Chunk size: 0 ')
await asyncio.sleep(1)
finally:
client.close()
if not chunks:
return pd.DataFrame()
data = pd.concat(chunks, ignore_index=True)
print(f"Amount of names: {len(data['name'].unique())}")
raw_count = len(data)
# data['timestamp'] = pd.to_datetime(data['timestamp'], utc=True).dt.floor('s')
data_timestamp_na = data[data['timestamp'].isna()]
print(f"NA timestamp: {data_timestamp_na}")
print(f"Amount of names: {len(data['name'].unique())}")
data = data.sort_values('timestamp')
data = data.drop_duplicates(subset=['timestamp', 'name'], keep='last')
print(f"Amount of names: {len(data['name'].unique())}")
dedup_count = len(data)
print(f' Total raw rows: {raw_count}, after dedup: {dedup_count}')
pivot = data.pivot(index='timestamp', columns='name', values='value')
pivot.sort_index(inplace=True)
print(f' Pivot shape: {pivot.shape} (index=timestamp, columns=tags)')
return pivot
# %% 5. Step: load or fetch WebIds (saved to WEB_IDS_SAVE_PATH after fetch for continuity)
print('Step 5: Load or fetch WebIds')
print(f' Trying WEB_IDS_SAVE_PATH={WEB_IDS_SAVE_PATH}')
web_ids = load_web_ids(WEB_IDS_SAVE_PATH)
if web_ids is None:
if not AUTH_TOKEN:
raise ValueError('Set AUTH_TOKEN at top to fetch WebIds.')
print(f'Fetching WebIds for {len(TAG_NAMES)} tags...')
web_ids = fetch_webids(
tag_names=TAG_NAMES,
base_url=BASE_URL,
auth_token=AUTH_TOKEN,
webid_lookup_path=WEBID_LOOKUP_PATH,
)
print(f'Resolved {len(web_ids)} WebIds.')
with open(WEB_IDS_SAVE_PATH, 'w', encoding='utf-8') as f:
json.dump(web_ids, f, indent=4)
print(f'Saved web_ids to {WEB_IDS_SAVE_PATH} for continuity.')
else:
print(f'Loaded {len(web_ids)} WebIds from {WEB_IDS_SAVE_PATH}.')
web_ids
# %% 6. Step: fetch values in chunks
print('Step 6: Fetch values in chunks')
print(f' Period: {PERIOD_DAYS} days, chunk size: {CHUNK_DAYS} days, tags: {list(web_ids.keys())}')
df = run_async(
fetch_values_chunked(
web_ids=web_ids,
period_days=PERIOD_DAYS,
chunk_days=CHUNK_DAYS,
base_url=BASE_URL,
auth_token=AUTH_TOKEN,
endpoint=ENDPOINT,
request_timeout=API_TIMEOUT,
)
)
if df.empty:
print(' Done. No data returned.')
else:
print(f' Done. Shape: {df.shape}, index range: {df.index.min()} to {df.index.max()}')
df
# %% 7. Step: inspect and optionally save
print('Step 7: Inspect and optionally save')
if df.empty:
print(' DataFrame is empty.')
else:
print(f' Shape: {df.shape}, columns: {list(df.columns)}')
print(f' Index (timestamp) range: {df.index.min()} to {df.index.max()}')
df.head()
df.to_csv('pi_web_api_data.csv')
# df.to_parquet('pi_web_api_data.parquet')
# %%
from pandas import read_csv, to_datetime
data = read_csv('pi_web_api_data.csv')
data['timestamp'] = to_datetime(data['timestamp'])
print(data['timestamp'].min())
print(data['timestamp'].max())
# %%
print(data.shape)
# %%

159
pyproject.toml Normal file
View File

@@ -0,0 +1,159 @@
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "scouter"
version = "0.0.0"
description = "Sientia DataOps Scouter - ML Model Orchestration System"
readme = "README.md"
requires-python = ">=3.11"
authors = [
{name = "Aignosi", email = "dev@aignosi.com"}
]
[tool.ruff]
line-length = 100
target-version = "py311"
exclude = [
".git",
".venv",
"venv",
"__pycache__",
"*.pyc",
".pytest_cache",
"htmlcov",
]
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"UP", # pyupgrade
"N", # pep8-naming
"YTT", # flake8-2020
"S", # flake8-bandit
"BLE", # flake8-blind-except
"A", # flake8-builtins
"C90", # mccabe complexity
]
ignore = [
"BLE001", # ignore blind except, we need to send notifications with any error
"E501", # line too long (handled by formatter)
"S101", # use of assert (needed for tests)
"S105", # possible hardcoded password (false positives)
"S106", # possible hardcoded password (false positives)
"N802", # function name should be lowercase (temporal decorators)
"N806", # variable in function should be lowercase
]
[tool.ruff.lint.per-file-ignores]
"tests/**/*.py" = [
"S101", # assert allowed in tests
"S105", # hardcoded passwords ok in tests
"S106", # hardcoded passwords ok in tests
]
[tool.ruff.lint.mccabe]
max-complexity = 15
[tool.ruff.format]
quote-style = "single"
indent-style = "space"
line-ending = "auto"
[tool.mypy]
python_version = "3.11"
warn_return_any = false
warn_unused_configs = true
disallow_untyped_defs = false
disallow_incomplete_defs = false
check_untyped_defs = true
no_implicit_optional = true
warn_redundant_casts = true
warn_unused_ignores = false
warn_no_return = true
strict_equality = true
ignore_missing_imports = true
# Ignore missing imports for external packages
[[tool.mypy.overrides]]
module = "temporalio.*"
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = "sientia_do.*"
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = "mlflow.*"
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = "prometheus_client.*"
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = "sientia.*"
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = "pandas.*"
ignore_missing_imports = true
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
addopts = [
"-v",
"--strict-markers",
]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "session"
asyncio_default_test_loop_scope = "session"
markers = [
"asyncio: marks tests as async",
"e2e: end-to-end tests against real backing services (Docker required)",
"integration: marks tests as integration tests",
"unit: marks tests as unit tests",
]
[tool.coverage.run]
source = ["scouter"]
omit = [
"*/tests/*",
"*/venv/*",
"*/__pycache__/*",
"*/site-packages/*",
]
branch = true
[tool.coverage.report]
precision = 2
show_missing = true
skip_covered = false
exclude_lines = [
"pragma: no cover",
"def __repr__",
"def __str__",
"raise AssertionError",
"raise NotImplementedError",
"if __name__ == .__main__.:",
"if TYPE_CHECKING:",
"class .*\\bProtocol\\):",
"@(abc\\.)?abstractmethod",
]
[tool.coverage.html]
directory = "htmlcov"
[tool.bandit]
exclude_dirs = ["tests", "venv", ".venv"]
skips = ["B101", "B601"] # Skip assert and shell injection in controlled environments

23
requirements-dev.txt Normal file
View File

@@ -0,0 +1,23 @@
# Development and Testing Dependencies
# These packages are only needed for development, testing, and code quality checks
# Install with: pip install -r requirements-dev.txt
# Code Quality & Linting
ruff>=0.1.0 # Fast Python linter and formatter (replaces flake8, black, isort)
mypy>=1.7.0 # Static type checker
bandit>=1.7.5 # Security vulnerability scanner
pandas-stubs>=2.0.0 # Type stubs for pandas
types-requests>=2.31.0 # Type stubs for requests
# Testing
pytest>=7.4.0 # Testing framework
pytest-cov>=4.1.0 # Coverage plugin for pytest
pytest-asyncio>=0.21.0 # Async test support (already in main requirements)
# E2E Testing Dependencies
pytest-httpserver>=1.0.10
# Development Tools
ipython>=8.12.0 # Enhanced Python shell
ipdb>=0.13.13 # IPython debugger
testcontainers[postgres,mongodb,redis]>=4.0

8
requirements-local.txt Normal file
View File

@@ -0,0 +1,8 @@
temporalio
psycopg2-binary
sqlalchemy
redis
pymongo
git+ssh://git@github.com/Aignosi/sientia-dataops-library.git@1.12.1
prometheus-client
pycurl

8
requirements.txt Normal file
View File

@@ -0,0 +1,8 @@
temporalio
psycopg2-binary
sqlalchemy
redis
pymongo
sientia_do
prometheus-client
pycurl

11
run_coverage.sh Executable file
View File

@@ -0,0 +1,11 @@
#!/bin/bash
# Exit on any error
set -e
echo "Activating virtual environment..."
source ./venv/bin/activate
pytest --cov=scouter --cov-report=html
xdg-open htmlcov/index.html

18
run_local.sh Executable file
View File

@@ -0,0 +1,18 @@
#!/bin/bash
# Exit on any error
set -e
echo "Activating virtual environment..."
source ./venv/bin/activate
echo "Loading environment variables from .env..."
if [ -f .env ]; then
export $(cat .env | grep -v '^#' | xargs)
echo "Environment variables loaded from .env"
else
echo "Warning: .env file not found. Continuing without environment variables."
fi
echo "Starting scouter application..."
python -m scouter.worker.worker

0
scouter/__init__.py Normal file
View File

View File

View File

@@ -0,0 +1,132 @@
from sientia_do.observability.metrics_controller import MetricsController
from temporalio import workflow
with workflow.unsafe.imports_passed_through():
from os import getenv
from typing import Any
from sientia_do.notifications.handlers import CoreNotificationHandler as NotificationHandler
from sientia_do.observability.logger import Logger
from sientia_do.temporal.activities.postgres_sync import Postgres
from scouter.activities.api import API
from scouter.activities.gates import Gates
from scouter.activities.mongodb import MongoDB
from scouter.activities.redis import Redis
class Activities(Postgres, Redis, Gates, MongoDB, API):
"""
Unified activities class that combines multiple data processing services.
This class provides a comprehensive interface for all data processing activities
by inheriting from specialized service classes. It handles:
- PostgreSQL operations for data persistence
- Redis operations for caching and temporary storage
- Data quality gates and filtering
- MongoDB operations for data retrieval
- PI Web API operations for external data ingestion
- Notification handling and logging
The class implements the multiple inheritance pattern to provide a unified
interface while maintaining separation of concerns across different data services.
"""
def __init__(
self,
postgres_config: dict[str, Any],
redis_config: dict[str, Any],
mongodb_config: dict[str, Any],
api_config: dict[str, Any],
logger: Logger,
notification_handler: NotificationHandler,
):
"""
Initialize the Activities class with all required services.
Args:
postgres_config (dict[str, Any]): PostgreSQL connection configuration.
Required fields: host, port, user, password, dbname, min_connections, max_connections
redis_config (dict[str, Any]): Redis connection configuration.
Required fields: host, port, username, password
mongodb_config (dict[str, Any]): MongoDB connection configuration.
Required fields: connection_string, database_name
api_config (dict[str, Any]): PI Web API configuration.
Required fields: base_url, auth_type, auth_token
logger (Logger): Logger instance for application logging
notification_handler (NotificationHandler): Handler for system notifications
"""
metrics_controller = MetricsController(
logger=logger,
)
# Initialize Postgres
Postgres.__init__(
self,
host=postgres_config['host'],
port=postgres_config['port'],
user=postgres_config['user'],
password=postgres_config['password'],
dbname=postgres_config['dbname'],
min_connections=postgres_config['min_connections'],
max_connections=postgres_config['max_connections'],
logger=logger,
notification_handler=notification_handler,
metrics_controller=metrics_controller,
)
# Initialize Redis
Redis.__init__(
self,
host=redis_config['host'],
port=redis_config['port'],
logger=logger,
notification_handler=notification_handler,
username=redis_config['username'],
password=redis_config['password'],
metrics_controller=metrics_controller,
)
# Initialize Gates
Gates.__init__(
self,
logger=logger,
notification_handler=notification_handler,
metrics_controller=metrics_controller,
)
# Initialize MongoDB
MongoDB.__init__(
self,
connection_string=mongodb_config['connection_string'],
database_name=mongodb_config['database_name'],
logger=logger,
notification_handler=notification_handler,
metrics_controller=metrics_controller,
)
# Initialize API
API.__init__(
self,
base_url=api_config['base_url'],
auth_type=api_config['auth_type'],
auth_token=api_config['auth_token'],
logger=logger,
notification_handler=notification_handler,
metrics_controller=metrics_controller,
)
self.pod_id = getenv('HOSTNAME', 'localhost')
def shutdown(self):
"""
Gracefully shutdown all service connections.
This method ensures proper cleanup of database connections and resources
to prevent connection leaks and ensure graceful application termination.
"""
Postgres.close(self)
MongoDB.close(self)
Redis.close(self)
Gates.close(self)
API.close(self)

157
scouter/activities/api.py Normal file
View File

@@ -0,0 +1,157 @@
from temporalio import activity, workflow
with workflow.unsafe.imports_passed_through():
import traceback
from typing import Any
from sientia_do.notifications.handlers import NotificationHandler
from sientia_do.notifications.models import NotificationLevel
from sientia_do.observability.logger import Logger
from sientia_do.observability.metrics_controller import MetricsController
from sientia_do.observability.sientia_monitoring import SientiaMonitoring
from sientia_do.repository.pi_web_api_client_sync import PIWebAPIClient
from sientia_do.temporal.constants import DATETIME_FORMAT_WITH_TZ
class API(SientiaMonitoring):
"""
PI Web API operations for data retrieval.
This class provides Temporal activities for interacting with the PI Web API
to retrieve tag values and historical data. It implements:
- Tag value retrieval from PI Web API endpoints
- Data quality filtering and validation
- Error handling with notifications
- Metrics collection for monitoring
The class wraps the PIWebAPIClient to provide Temporal-aware activity methods
that can be used in workflow orchestration.
"""
def __init__(
self,
base_url: str,
auth_type: str,
auth_token: str,
logger: Logger,
notification_handler: NotificationHandler,
metrics_controller: MetricsController,
) -> None:
"""
Initialize API activity with PI Web API client.
Args:
base_url (str): Base URL of the PI Web API server
auth_type (str): Authentication type ('basic' or 'bearer')
auth_token (str): Authentication token
logger (Logger): Logger instance for operation logging
notification_handler (NotificationHandler): Handler for system notifications
metrics_controller (MetricsController): Controller for metrics collection
"""
SientiaMonitoring.__init__(self, logger, notification_handler, metrics_controller)
self.pi_web_api_client = PIWebAPIClient(
base_url=base_url,
auth_config={
'type': auth_type,
'token': auth_token,
},
logger=logger,
notification_handler=notification_handler,
metrics_controller=metrics_controller,
headers_config={
'Content-Type': 'application/json',
'Accept': 'application/json',
'x-requested-with': 'piwebapistreams',
'User-Agent': 'Aig-Scouter-Agent/1.0',
},
)
def close(self) -> None:
"""
Close the PI Web API client and shutdown monitoring services.
This method performs cleanup operations:
- Closes the PI Web API client connection
- Shuts down SientiaMonitoring services (metrics, notifications)
"""
self.pi_web_api_client.close()
SientiaMonitoring.shutdown(self)
@activity.defn(name='get_tag_values')
def get_tag_values(self, input_data: dict[str, Any]) -> list[dict]:
"""
Retrieve tag values from PI Web API for specified WebIds.
This activity fetches historical or real-time data from the PI Web API
for a set of configured tags. It returns the data as a list of dictionaries
suitable for further processing in the workflow.
The timestamps are normalized to ensure consistency across all records in the
response. After converting timestamps to string format, all timestamps are
set to the maximum timestamp value (lexicographically) found in the dataset.
This ensures all records in a single batch share the same timestamp value.
Args:
input_data (dict[str, Any]): Activity input parameters.
Required fields:
- metadata (dict[str, Any]): Workflow execution metadata
- endpoint (str): PI Web API endpoint path
- web_ids (dict[str, str | None]): Tag names mapped to WebIds
- period (dict[str, str]): Time period with 'start_time' field
- api_timeout (int): Request timeout in seconds
- max_count (int, optional): Maximum data points per tag. Defaults to 1
Returns:
list[dict]: List of data records, each containing:
- timestamp: Normalized timestamp string (all records share the same value)
- name: Tag name
- value: Numeric value
- tag: WebId
Raises:
PIMSRequestError: If API request fails
Exception: If data retrieval or processing fails
"""
metadata = input_data['metadata']
endpoint = input_data['endpoint']
web_ids = input_data['web_ids']
period = input_data['period']
end_time = input_data.get('end_time', '*')
max_count = input_data.get('max_count', 1)
api_timeout = input_data['api_timeout']
self.info(f'Getting tag values from {endpoint}', metadata=metadata)
self.debug(f'Web IDs: {web_ids}', metadata=metadata)
try:
latest_values = self.pi_web_api_client.get_latest_values_df(
endpoint=endpoint,
web_ids=web_ids,
start_time=period,
end_time=end_time,
max_count=max_count,
metadata=metadata,
request_timeout=api_timeout,
)
except Exception as e:
self.send_notification(
metadata=metadata,
notification_id='PI_WEB_API_REQUEST_ERROR',
message=f'Error getting tag values from PI Web API: {e}',
block='get_tag_values',
level=NotificationLevel.ERROR,
attachment_content=traceback.format_exc(),
)
raise e
latest_values['timestamp'] = latest_values['timestamp'].dt.strftime(DATETIME_FORMAT_WITH_TZ)
self.debug(f'Latest values: {latest_values.to_string()}', metadata=metadata)
# Normalize the package timestamp
valid_timestamp_values = latest_values['timestamp'].dropna()
latest_values['timestamp'] = valid_timestamp_values.max()
self.info(f'Gathered {len(latest_values)} tag values', metadata=metadata)
return latest_values.to_dict(orient='records')

319
scouter/activities/gates.py Normal file
View File

@@ -0,0 +1,319 @@
from collections.abc import Hashable
from sientia_do.observability.metrics_controller import MetricsController
from temporalio import activity, workflow
with workflow.unsafe.imports_passed_through():
import traceback
from typing import Any
from pandas import DataFrame
from sientia_do.notifications.handlers import NotificationHandler
from sientia_do.notifications.models import NotificationLevel
from sientia_do.observability.logger import Logger
from sientia_do.observability.sientia_monitoring import SientiaMonitoring
from scouter import metrics
from scouter.utils.quality.filters import null_values_filter, out_of_bounds_filter
quality_gate_filters = {
'NULL_VALUES_FILTER': null_values_filter,
'OUT_OF_BOUNDS_FILTER': out_of_bounds_filter,
}
class Gates(SientiaMonitoring):
"""
Data quality gates and filtering operations.
This class implements data quality validation and filtering for industrial
time-series data. It provides:
- Configurable data quality filters
- Data aggregation functions for time-series data
- Comprehensive error handling and notification
- Metrics collection for quality monitoring
The class supports multiple aggregation strategies and quality filters to
ensure data integrity and enable flexible data processing workflows.
"""
def __init__(
self,
logger: Logger,
notification_handler: NotificationHandler,
metrics_controller: MetricsController,
):
"""
Initialize the Gates class with logging and notification services.
Args:
logger (Logger): Logger instance for operation logging
notification_handler (NotificationHandler): Handler for system notifications
metrics_controller (MetricsController): Metrics controller instance
"""
SientiaMonitoring.__init__(self, logger, notification_handler, metrics_controller)
def close(self):
"""
Close the Gates class.
"""
SientiaMonitoring.shutdown(self)
def apply_aggregation(
self, values: DataFrame, aggr_function: str, metadata: dict[str, Any]
) -> float | None | str:
"""
Apply aggregation function to a group of time-series data.
This method applies the specified aggregation function to a group of
data points. It handles edge cases and provides comprehensive error
reporting for invalid aggregation functions.
Args:
values (DataFrame): Group of data points to aggregate (pre-sorted by timestamp)
aggr_function (str): Aggregation function to apply.
Supported functions: 'lts' (latest), 'avg' (average), 'mdn' (median),
'max' (maximum), 'min' (minimum)
metadata (dict[str, Any]): Workflow metadata for error reporting
Returns:
float | None | str: Aggregated value, None if no valid data, or 'continue' for errors
Raises:
NotificationError: If invalid aggregation function is specified
"""
aggregation_map = {
'lts': lambda x: x.iloc[-1],
'avg': lambda x: x.mean(),
'mdn': lambda x: x.median(),
'max': lambda x: x.max(),
'min': lambda x: x.min(),
}
if aggr_function not in aggregation_map:
self.send_notification(
metadata=metadata,
notification_id='AGGREGATION_ISSUES',
message=f'Invalid aggregation function: {aggr_function}',
block='aggregate_data',
level=NotificationLevel.ERROR,
attachment_content=traceback.format_exc(),
)
return 'continue'
if len(values) == 1:
return values['value'].iloc[0]
if aggr_function == 'lts':
return aggregation_map['lts'](values['value'])
clean_values = values['value'].dropna()
if clean_values.empty:
return None
return aggregation_map[aggr_function](clean_values)
@activity.defn(name='aggregate_data')
def aggregate_data(self, input_data: dict[str, Any]) -> dict[Hashable, Any]:
"""
Aggregate time-series data by tag and name using specified functions.
This activity processes time-series data by grouping it by tag and name,
then applying the configured aggregation functions. It handles data
validation and provides comprehensive error reporting.
Args:
input_data (dict[str, Any]): Activity input parameters.
Required fields:
- data (dict[str, Any]): Time-series data to aggregate
- model_tags (dict[str, Any]): Tag configuration with aggregation functions
Returns:
dict[Hashable, Any]: Aggregated data organized by tag and name
Raises:
Exception: If aggregation operation fails
"""
metadata = input_data['metadata']
try:
# Convert input data to DataFrame
df = DataFrame(input_data['data'])
self.info(f'Aggregating time series data for {len(df)} rows', metadata=metadata)
# Sort once by timestamp for all data (more efficient than sorting each group)
df = df.sort_values(['tag', 'name', 'timestamp'])
# Group by tag and name
# sort=False since we already sorted
grouped = df.groupby(['tag', 'name'], sort=False)
# Prepare aggregation functions mapping
model_tags = input_data['model_tags']
# Process groups efficiently
results = []
for (tag, name), group in grouped:
# Get the aggregation function from model_tags
aggr_function = model_tags.get(name, {}).get('aggr_func', 'lts')
# Get the latest timestamp (last row since data is sorted)
latest_timestamp = group['timestamp'].iloc[-1]
aggr_value = self.apply_aggregation(group, aggr_function, metadata)
if aggr_value == 'continue':
continue
# Store the result directly in list for better performance
results.append(
{
'tag': tag,
'name': name,
'value': aggr_value,
'timestamp': latest_timestamp,
'aggregation_function': aggr_function,
}
)
self.info(f'Aggregated data has {len(results)} rows', metadata=metadata)
# Convert to DataFrame only once at the end if we have results
if results:
result_df = DataFrame(results)
self.debug(f'Final aggregated data:\n{result_df.to_string()}', metadata=metadata)
return result_df.to_dict()
else:
# Return empty DataFrame dict structure
return DataFrame().to_dict()
except Exception as e:
trace = traceback.format_exc()
self.send_notification(
metadata=metadata,
notification_id='AGGREGATION_ISSUES',
message=f'Error aggregating data: {e}',
block='aggregate_data',
level=NotificationLevel.ERROR,
attachment_content=trace,
)
self.error(trace, metadata=metadata)
raise e
@activity.defn(name='data_quality_gate')
def data_quality_gate(self, input_data: dict[str, Any]) -> dict[Hashable, Any]:
"""
Apply data quality filters to incoming data.
This activity applies configurable quality filters to validate incoming
data. It supports multiple filter types and provides comprehensive
error reporting for quality issues.
Args:
input_data (dict[str, Any]): Activity input parameters.
Required fields:
- data (dict[str, Any]): Data to validate
- filters (dict[str, str]): Filter configuration
- model_tags (dict[str, Any]): Tag-specific validation rules
Returns:
dict[Hashable, Any]: Filtered data that passes quality validation
Raises:
Exception: If quality validation fails
"""
metadata = input_data['metadata']
filters = input_data['filters']
data = DataFrame(input_data['data'])
model_tags = input_data['model_tags']
self.info(f'Applying quality gate to data to {len(data)} rows', metadata=metadata)
tags = list(model_tags.keys())
data = data[data['name'].isin(tags)]
for filter_name, config in filters.items():
policy = config['policy']
if filter_name not in quality_gate_filters:
self.warning(f'Filter {filter_name} not found', metadata=metadata)
continue
try:
filtered_data = quality_gate_filters[filter_name](data, model_tags)
except Exception as e:
trace = traceback.format_exc()
self.send_notification(
metadata=metadata,
notification_id='DATA_QUALITY_GATE_ISSUES',
message=f'Error applying filter {filter_name}: {e}',
block='data_quality_gate',
level=NotificationLevel.ERROR,
attachment_content=trace,
)
self.error(trace, metadata=metadata)
else:
if filtered_data.empty:
continue
message = f'{len(filtered_data)} rows has quality issues: {filter_name}: {policy}'
attachment = filtered_data.to_string()
self.send_notification(
metadata=metadata,
notification_id=f'DATA_QUALITY_GATE_ISSUES__{filter_name}',
message=message,
block='data_quality_gate',
level=NotificationLevel.WARNING,
attachment_content=attachment,
)
if policy == 'DISCARD':
data = data[~data.index.isin(filtered_data.index)]
self.info(f'Data quality gate applied, final data has {len(data)} rows', metadata=metadata)
return data.to_dict()
@activity.defn(name='write_metrics')
def write_metrics(self, input_data: dict[str, Any]) -> None:
"""
Write metrics to the database.
input_data:
metadata: dict[str, Any]
"""
metadata = input_data['metadata']
tag_values = DataFrame(input_data['tag_values'])
self.info(f'Writing metrics for {metadata["model_name"]}', metadata=metadata)
metrics.LABORIOUS_DATA_WRITTEN_COUNT.labels(
pod_id=self.pod_id,
model_name=metadata['model_name'],
workflow_name=metadata['workflow_name'],
).inc()
# Register metrics
for _, row in tag_values.iterrows():
value = row['value']
if value is not None:
metrics.TAG_CHANGES_MONITOR.labels(
pod_id=self.pod_id,
model_name=metadata['model_name'],
workflow_name=metadata['workflow_name'],
tag_name=row['variable'],
).set(row['value'])
self.info(f'Metrics written for {metadata["model_name"]}', metadata=metadata)

View File

@@ -0,0 +1,154 @@
from datetime import UTC
from sientia_do.observability.metrics_controller import MetricsController
from temporalio import activity, workflow
with workflow.unsafe.imports_passed_through():
import traceback
from datetime import datetime
from typing import Any
from sientia_do.notifications.handlers import CoreNotificationHandler as NotificationHandler
from sientia_do.notifications.models import NotificationLevel
from sientia_do.observability.logger import Logger
from sientia_do.observability.sientia_monitoring import SientiaMonitoring
from sientia_do.repository.mongodb_repository_sync import MongoDBRepository
from sientia_do.temporal.constants import DATETIME_FORMAT_MS_WITH_TZ
class MongoDB(SientiaMonitoring):
"""
MongoDB operations for data retrieval and storage.
This class provides MongoDB connectivity and operations for the Scouter system.
It handles:
- Connection management with automatic reconnection
- Data retrieval with timestamp-based filtering
- Document cleaning and preprocessing
- Error handling and notification integration
The class implements Temporal activities for MongoDB operations, enabling
distributed data processing with fault tolerance and monitoring.
"""
def __init__(
self,
connection_string: str,
database_name: str,
logger: Logger,
notification_handler: NotificationHandler,
metrics_controller: MetricsController,
):
"""
Initialize MongoDB connection and services.
Args:
connection_string (str): MongoDB connection URI string
database_name (str): Name of the target database
logger (Logger): Logger instance for operation logging
notification_handler (NotificationHandler): Handler for system notifications
Raises:
ConnectionError: If MongoDB connection fails
"""
self.mongodb_repository = MongoDBRepository(
connection_string=connection_string,
database_name=database_name,
logger=logger,
notification_handler=notification_handler,
metrics_controller=metrics_controller,
)
SientiaMonitoring.__init__(
self,
logger=logger,
notification_handler=notification_handler,
metrics_controller=metrics_controller,
)
def close(self):
"""
Close the MongoDB connection.
"""
self.mongodb_repository.close()
SientiaMonitoring.shutdown(self)
def __del__(self):
"""
Destructor to ensure MongoDB client is closed.
This destructor ensures that MongoDB connections are properly closed
when the object is garbage collected, preventing resource leaks.
"""
self.close()
@activity.defn(name='load_latest_data')
def load_latest_data(self, input_data: dict[str, Any]) -> list[dict[str, Any]]:
"""
Load the latest data from MongoDB collection since a specified timestamp.
This activity retrieves data from a MongoDB collection, optionally
filtering by timestamp to enable incremental data processing. It
handles connection management and provides comprehensive error reporting.
Args:
input_data (dict[str, Any]): Activity input parameters.
Required fields:
- metadata (dict[str, Any]): Workflow execution metadata
- collection_name (str): Name of the MongoDB collection
- last_data_timestamp (str | None): Last processed timestamp for filtering
Returns:
dict[str, Any]: Retrieved data, or empty dict if no data found
Raises:
Exception: If MongoDB operation fails
"""
metadata = input_data['metadata']
collection_name = input_data['collection_name']
last_data_timestamp = input_data['last_data_timestamp']
self.info(f'Loading data from MongoDB: {input_data}', metadata=metadata)
try:
if last_data_timestamp is None:
data_filter = {}
else:
data_filter = {
'inserted_at': {
'$gt': datetime.strptime(last_data_timestamp, DATETIME_FORMAT_MS_WITH_TZ)
}
}
self.debug(f'Data filter: {data_filter}', metadata=metadata)
data = self.mongodb_repository.find(
collection_name=collection_name,
filters=data_filter,
metadata=metadata,
)
self.debug(f'Collected: {data}', metadata=metadata)
for item in data:
item['inserted_at'] = (
item['inserted_at'].replace(tzinfo=UTC).strftime(DATETIME_FORMAT_MS_WITH_TZ)
)
self.info(f'Loaded {len(data)} documents from MongoDB', metadata=metadata)
self.debug(f'Loaded data: {data}', metadata=metadata)
return data
except Exception as e:
trace = traceback.format_exc()
self.send_notification(
metadata=metadata,
notification_id='MONGO_LOAD_ERROR',
message=f'Error loading data from MongoDB: {e}',
block='load_latest_data',
level=NotificationLevel.ERROR,
attachment_content=trace,
)
raise e

311
scouter/activities/redis.py Normal file
View File

@@ -0,0 +1,311 @@
from sientia_do.observability.metrics_controller import MetricsController
from temporalio import activity, workflow
with workflow.unsafe.imports_passed_through():
import traceback
from collections.abc import Hashable
from typing import Any
from pandas import DataFrame
from sientia_do.notifications.handlers import CoreNotificationHandler as NotificationHandler
from sientia_do.notifications.models import NotificationLevel
from sientia_do.observability.logger import Logger
from sientia_do.observability.sientia_monitoring import SientiaMonitoring
from sientia_do.repository.redis_repository_sync import RedisRepository
from sientia_do.temporal.constants import DATETIME_FORMAT, now
class Redis(SientiaMonitoring):
"""
Redis operations for data caching and temporary storage.
This class extends the base Redis functionality to provide specialized
operations for the Scouter system, including:
- Data timestamp management for incremental processing
- Temporary data storage with configurable TTL
- Data grouping and holding for batch processing
- Error handling and notification integration
The class implements Temporal activities for Redis operations, enabling
distributed data processing with fault tolerance and monitoring.
"""
def __init__(
self,
host: str,
port: int,
username: str,
password: str,
logger: Logger,
notification_handler: NotificationHandler,
metrics_controller: MetricsController,
):
"""
Initialize Redis connection and services.
Args:
host (str): Redis server hostname or IP address
port (int): Redis server port number
username (str): Redis authentication username
password (str): Redis authentication password
logger (Logger): Logger instance for operation logging
notification_handler (NotificationHandler): Handler for system notifications
"""
SientiaMonitoring.__init__(self, logger, notification_handler, metrics_controller)
self.redis_repository = RedisRepository(
host=host,
port=port,
username=username,
password=password,
logger=logger,
notification_handler=notification_handler,
metrics_controller=metrics_controller,
)
def close(self):
"""
Close the Redis connection.
"""
self.redis_repository.close()
SientiaMonitoring.shutdown(self)
@activity.defn(name='get_last_data_timestamp')
def get_last_data_timestamp(self, input_data: dict[str, Any]) -> str | None:
"""
Retrieve the last processed data timestamp from Redis.
This activity retrieves the timestamp of the last successfully processed
data point for a specific workflow and schedule combination. It's used
for incremental data processing to avoid reprocessing the same data.
Args:
input_data (dict[str, Any]): Activity input parameters.
Required fields:
- metadata (dict[str, Any]): Workflow execution metadata
- workflow_name (str): Name of the workflow
- schedule_name (str): Name of the data collection schedule
Returns:
str | None: Last processed timestamp string, or None if no previous data exists
Raises:
Exception: If Redis operation fails
"""
metadata = input_data['metadata']
key = f'last_data_timestamp:{input_data["workflow_name"]}:{input_data["schedule_name"]}'
self.info(f'Getting last data timestamp for {key}', metadata=metadata)
try:
data_hold = self.redis_repository.get(key, metadata=metadata)
except Exception as e:
self.send_notification(
metadata=metadata,
notification_id='REDIS_GET_ERROR',
message=f'Error getting last data timestamp: {e}',
block='get_last_data_timestamp',
level=NotificationLevel.ERROR,
attachment_content=traceback.format_exc(),
)
raise e
self.info(f'Last collected timestamp: {data_hold}', metadata=metadata)
if not data_hold:
return None
return data_hold
@activity.defn(name='put_last_data_timestamp')
def put_last_data_timestamp(self, input_data: dict[str, Any]) -> str | None:
"""
Store the last processed data timestamp in Redis.
This activity stores the timestamp of the most recent data point that
has been successfully processed. The timestamp is used for incremental
data loading in subsequent workflow executions.
Args:
input_data (dict[str, Any]): Activity input parameters.
Required fields:
- metadata (dict[str, Any]): Workflow execution metadata
- data (dict[str, Any]): Processed data to extract timestamp from
- workflow_name (str): Name of the workflow
- schedule_name (str): Name of the data collection schedule
Returns:
str | None: The timestamp that was stored, or None if no data was processed
Raises:
Exception: If Redis operation fails
"""
metadata = input_data['metadata']
key = f'last_data_timestamp:{input_data["workflow_name"]}:{input_data["schedule_name"]}'
self.info(f'Putting last data timestamp for {key}', metadata=metadata)
data = DataFrame(input_data['data'])
if data.empty:
self.warning('No data to insert', metadata=metadata)
return None
last_data_timestamp = data['inserted_at'].max()
self.info(f'Last collected timestamp to insert: {last_data_timestamp}', metadata=metadata)
try:
self.redis_repository.set(key, last_data_timestamp, ttl=60 * 60 * 5, metadata=metadata)
except Exception as e:
self.send_notification(
metadata=metadata,
notification_id='REDIS_SET_ERROR',
message=f'Error setting last data timestamp: {e}',
block='put_last_data_timestamp',
level=NotificationLevel.ERROR,
attachment_content=traceback.format_exc(),
)
raise e
return last_data_timestamp
@activity.defn(name='group_and_hold_data')
def group_and_hold_data(self, input_data: dict[str, Any]) -> dict[Hashable, Any]:
"""
Group data by tags and store temporarily in Redis with TTL.
This activity organizes processed data by tag names and stores it in Redis
with a configurable retention period. The data is grouped to enable
efficient batch processing and export operations.
Args:
input_data (dict[str, Any]): Activity input parameters.
Required fields:
- metadata (dict[str, Any]): Workflow execution metadata
- schedule_name (str): Name of the data collection schedule
- workflow_name (str): Name of the workflow
- data (dict[str, Any]): Data to group and store
- model_id (str): Unique model identifier
- model_tags (dict[str, Any]): Tag configuration
- retention_time (int): Data retention period in seconds
Returns:
dict[str, Any]: Grouped data organized by tag names
Raises:
Exception: If Redis operation fails
"""
metadata = input_data['metadata']
self.debug('Grouping and holding data...', metadata=metadata)
data = DataFrame(input_data['data'])
model_tags = input_data['model_tags']
retention_time = input_data['retention_time']
fill_missing_tags = input_data['fill_missing_tags']
key = f'held_data_{input_data["workflow_name"]}_{input_data["schedule_name"]}'
self.info(f'Getting held data for {key}', metadata=metadata)
try:
data_hold = self.redis_repository.get(key, metadata=metadata)
except Exception as e:
self.send_notification(
metadata=metadata,
notification_id='REDIS_GET_ERROR',
message=f'Error getting held data: {e}',
block='group_and_hold_data',
level=NotificationLevel.ERROR,
attachment_content=traceback.format_exc(),
)
raise e
if not data_hold:
data_hold = {}
if data.empty:
self.warning('No data to export', metadata=metadata)
return data_hold
self.info(f'Grouping and holding data for {len(data)} rows')
try:
# Remove possibly removed tags
tags = list(model_tags.keys())
tags.append('timestamp')
self.debug(f'Tags to keep: {tags}', metadata=metadata)
data_hold = {tag: content for tag, content in data_hold.items() if tag in tags}
self.debug(f'Data hold after removing removed tags: {data_hold}', metadata=metadata)
for _, row in data.iterrows():
value = row['value']
data_hold[row['name']] = value
if fill_missing_tags:
self.debug('Filling missing tags in data package', metadata=metadata)
missing_tags = [tag for tag in tags if tag not in list(data_hold.keys())]
for tag in missing_tags:
data_hold[tag] = None
data_hold['timestamp'] = (
data['timestamp'].max() if not data.empty else data_hold['timestamp']
)
self.redis_repository.set(key, data_hold, ttl=retention_time, metadata=metadata)
data_hold_df = DataFrame(data_hold, index=[0])
data_hold_melted = data_hold_df.melt(
id_vars='timestamp', var_name='variable', value_name='value'
)
data_hold_melted['model_id'] = input_data['model_id']
data_hold_melted.reset_index(drop=True, inplace=True)
except Exception as e:
self.send_notification(
metadata=metadata,
notification_id='REDIS_SET_ERROR',
message=f'Error setting held data: {e}',
block='group_and_hold_data',
level=NotificationLevel.ERROR,
attachment_content=traceback.format_exc(),
)
raise e
self.info(f'Data held and melted has {len(data_hold_melted)} rows')
self.debug(f'Data held and melted:\n {data_hold_melted.to_string()}', metadata=metadata)
return data_hold_melted.to_dict()
@activity.defn(name='store_data_package')
def store_data_package(self, input_data: dict[str, Any]):
"""
Stores the data package in redis. It's a debug feature and must be toggled on.
input_data:
metadata: The metadata of the workflow.
workflow_name: The name of the workflow.
schedule_name: The name of the schedule.
held_data: The final scouter output.
data: The data used to collect the data.
"""
metadata = input_data['metadata']
key = f'data_package_{input_data["workflow_name"]}_{input_data["schedule_name"]}_{now().strftime(DATETIME_FORMAT)}'
data = DataFrame(input_data['data'])
held_data = DataFrame(input_data['held_data'])
cache = {'data': data.to_dict(), 'held_data': held_data.to_dict()}
try:
self.redis_repository.set(key, cache, ttl=120, metadata=metadata)
except Exception as e:
self.send_notification(
metadata=metadata,
notification_id='REDIS_SET_ERROR',
message=f'Error setting data package: {e}',
block='store_data_package',
level=NotificationLevel.ERROR,
attachment_content=traceback.format_exc(),
)
raise e

25
scouter/metrics.py Normal file
View File

@@ -0,0 +1,25 @@
from prometheus_client import Counter, Gauge
# Application health and status metrics
APP_UP = Gauge(
'app_up',
'Indicates if the application is running (1) or shutting down (0)',
['pod_id'],
)
# Core labels for consistent metric labeling
CORE_LABELS = ['pod_id', 'model_name', 'workflow_name']
# Data processing metrics
LABORIOUS_DATA_WRITTEN_COUNT = Counter(
'scouter_laborious_data_written_count',
'Number of writings to the database table laborious_data',
CORE_LABELS,
)
# Tag monitoring metrics
TAG_CHANGES_MONITOR = Gauge(
'scouter_tag_changes_monitor',
'Current value change of each tag',
[*CORE_LABELS, 'tag_name'],
)

View File

View File

@@ -0,0 +1,19 @@
from os import getenv
from typing import Any
def build_kafka_config() -> dict[str, Any]:
"""
Build Kafka configuration from environment variables.
Returns:
dict[str, Any]: Kafka configuration dictionary with keys:
- bootstrap_servers: Kafka broker addresses (default: localhost:9092)
- polling_time: Consumer polling interval in milliseconds (default: 1000)
- group_id: Consumer group identifier (default: scouter-group)
"""
return {
'bootstrap_servers': getenv('KAFKA_BOOTSTRAP_SERVERS', 'localhost:9092'),
'polling_time': int(getenv('KAFKA_POLLING_TIME', '1000')),
'group_id': 'scouter-group',
}

View File

@@ -0,0 +1,81 @@
from typing import Any
import numpy as np
from pandas import DataFrame
def check_data_range(value: float | int | None, val_range: list) -> bool:
"""
Check if a value falls outside the specified range.
This function validates if a numeric value is within the acceptable range
defined by the minimum and maximum bounds. It handles edge cases including
None values and NaN values.
Args:
value (float | int | None): The numeric value to validate
val_range (list): List containing [min_value, max_value] bounds
Returns:
bool: True if value is outside the range, False if within range
Note:
None and NaN values are considered out of range (return True)
"""
if value is None or np.isnan(value):
return True
bottom = val_range[0]
up = val_range[-1]
return value < bottom or value > up
def out_of_bounds_filter(df: DataFrame, model_tags: dict[str, Any]) -> DataFrame:
"""
Filter DataFrame rows where values are outside configured ranges.
This function applies range validation to each row in the DataFrame based
on tag-specific configuration. Rows with values outside the configured
ranges are filtered out.
Args:
df (DataFrame): DataFrame containing sensor data with 'name' and 'value' columns
model_tags (dict[str, Any]): Tag configuration containing data_range for each tag.
If a tag doesn't have data_range, it's considered to have infinite bounds.
Returns:
DataFrame: Filtered DataFrame with out-of-bounds values removed
Note:
Tags without data_range configuration are treated as having infinite bounds
"""
return df[
df.apply(
lambda x: check_data_range(
x['value'], model_tags[x['name']].get('data_range', (-np.inf, np.inf))
),
axis=1,
)
]
def null_values_filter(df: DataFrame, _model_tags: dict[str, Any]) -> DataFrame:
"""
Filter DataFrame rows containing null values.
This function removes rows where the 'value' column contains null values.
It's used for data quality filtering to ensure only complete data records
are processed.
Args:
df (DataFrame): DataFrame containing sensor data with 'value' column
_model_tags (dict[str, Any]): Tag configuration (unused in this filter)
Returns:
DataFrame: Filtered DataFrame with null values removed
Note:
The _model_tags parameter is included for interface consistency but not used
"""
return df[df['value'].isnull()]

View File

187
scouter/worker/worker.py Normal file
View File

@@ -0,0 +1,187 @@
from temporalio import client, workflow
from temporalio.runtime import PrometheusConfig, Runtime, TelemetryConfig
with workflow.unsafe.imports_passed_through():
import asyncio
import os
import sys
from prometheus_client import start_http_server
from sientia_do.notifications.handlers import CoreNotificationHandler as NotificationHandler
from sientia_do.observability.logger import get_logger
from sientia_do.temporal.worker.prepare_worker import prepare_worker
from sientia_do.utils.connectors_config import (
build_api_config,
build_mongodb_config,
build_postgres_config,
build_redis_config,
)
from scouter import metrics
from scouter.activities.activities import Activities
from scouter.workflow.pi_web_api_scouter import PIWebAPIScouter
from scouter.workflow.scouter import Scouter
from scouter.workflow.sub_workflows.core_scouter import CoreScouter
# Environment configuration
POD_ID = os.getenv('HOSTNAME', 'localhost')
SDK_METRICS_PORT = int(os.getenv('HTTP_SDK_METRICS_PORT', '9091'))
async def main():
"""
Main entry point for the Scouter Temporal worker.
This function initializes and starts all required services:
- Prometheus metrics server
- Notification handler for MongoDB
- Activity implementations for data processing
- Temporal client and workers
- Multiple task queues for different workflow types
The worker supports two main task queues:
- scouter-queue: Main data processing workflows
- fake_data-queue: Test data generation workflows
Returns:
None
Raises:
SystemExit: If worker initialization or execution fails
"""
host = os.getenv('TEMPORAL_HOST', 'localhost:7233')
logger = get_logger(__name__)
metadata = {
'pod_id': POD_ID,
'model_name': '-',
'model_id': '-',
'workflow_name': '-',
'schedule_name': '-',
}
logger.custom_info(f'Starting Worker with pod_id: {POD_ID}', metadata)
logger.custom_info('Starting prometheus client...', metadata)
start_prometheus_server()
logger.custom_info('Starting Notification Handler...', metadata)
mongo_config = build_mongodb_config()
notification_handler = NotificationHandler(
connection_string=mongo_config['connection_string'],
database=mongo_config['database_name'],
logger=logger,
project_name=os.getenv('PROJECT_NAME', 'scouter'),
)
logger.custom_info('Starting Activities...', metadata)
activities = Activities(
logger=logger,
notification_handler=notification_handler,
postgres_config=build_postgres_config(),
redis_config=build_redis_config(),
mongodb_config=build_mongodb_config(),
api_config=build_api_config(),
)
logger.custom_info(f'Starting SDK Metrics Server on port {SDK_METRICS_PORT}...', metadata)
new_runtime = Runtime(
telemetry=TelemetryConfig(
metrics=PrometheusConfig(bind_address=f'0.0.0.0:{SDK_METRICS_PORT}')
)
)
logger.custom_info('Starting Temporal Client...', metadata)
temporal_client = await client.Client.connect(
target_host=host, namespace=os.getenv('TEMPORAL_NAMESPACE', 'scouter'), runtime=new_runtime
)
logger.custom_info('Starting Workers...', metadata)
workers = [
prepare_worker(
temporal_client=temporal_client,
main_workflow=Scouter,
other_workflows=[CoreScouter],
activities=[
activities.load_latest_data,
activities.get_last_data_timestamp,
activities.put_last_data_timestamp,
activities.data_quality_gate,
activities.aggregate_data,
activities.group_and_hold_data,
activities.export_data_to_postgres,
activities.write_metrics,
activities.store_data_package,
],
logger=logger,
),
prepare_worker(
temporal_client=temporal_client,
main_workflow=PIWebAPIScouter,
other_workflows=[CoreScouter],
activities=[
activities.get_tag_values,
activities.data_quality_gate,
activities.aggregate_data,
activities.group_and_hold_data,
activities.export_data_to_postgres,
activities.write_metrics,
activities.store_data_package,
],
logger=logger,
),
]
handlers = []
for w in workers:
handlers.append(w.run())
logger.custom_info('Workers started successfully', metadata)
try:
await asyncio.gather(*handlers)
except BaseException: # NOSONAR
logger.custom_error('An unhandled exception occurred: %s', metadata=metadata)
finally:
if notification_handler:
notification_handler.shutdown()
if activities:
activities.shutdown()
# Exit with a non-zero status code to indicate failure to Kubernetes
metrics.APP_UP.labels(pod_id=POD_ID).set(0) # Mark app as DOWN
sys.exit(1)
def start_prometheus_server():
"""
Start the Prometheus metrics HTTP server.
This function initializes the Prometheus metrics server on the configured
port and sets the application health status. It's essential for
monitoring and observability of the Scouter system.
Returns:
None
Raises:
SystemExit: If metrics server fails to start
"""
try:
port = int(os.getenv('HTTP_METRICS_PORT', 9090))
start_http_server(port)
print(f'Prometheus server started on port {port}.')
metrics.APP_UP.labels(pod_id=POD_ID).set(1) # Mark app as UP
except Exception as e:
print(f'Failed to start Prometheus server: {e}')
os._exit(1)
if __name__ == '__main__':
asyncio.run(main())

View File

View File

@@ -0,0 +1,108 @@
from temporalio import workflow
with workflow.unsafe.imports_passed_through():
from datetime import timedelta
from typing import Any
from sientia_do.temporal.policies import retry_policy
from scouter.activities.activities import Activities
@workflow.defn(name='pi_web_api_scouter')
class PIWebAPIScouter:
"""
PI Web API Scouter workflow that orchestrates data ingestion from PI systems.
This workflow serves as the entry point for PI Web API data processing pipelines.
Unlike the standard Scouter that loads from MongoDB, this workflow directly queries
PI Web API endpoints to retrieve tag values and processes them for downstream use.
The workflow implements a direct API ingestion pattern with:
- Real-time data retrieval from PI Web API
- Configurable time periods and data point limits
- Error handling and retry policies
- Child workflow orchestration for data processing
- Integration with CoreScouter for standardized processing
"""
@workflow.run
async def run(self, input_data: dict[str, Any]) -> None:
"""
Execute the PI Web API Scouter workflow.
This method orchestrates the complete data ingestion process from PI Web API:
1. Retrieves tag values from PI Web API using configured WebIds
2. Validates and normalizes the retrieved data (timestamps are normalized)
3. Delegates data processing to the CoreScouter workflow
If no data is retrieved from the PI Web API, the workflow exits early without
invoking the CoreScouter workflow.
Args:
input_data (dict[str, Any]): Configuration and parameters for the workflow execution.
Required fields:
- schedule_name (str): Unique identifier for the data collection schedule
- model_name (str): Name of the data model being processed
- model_id (str): Unique identifier for the data model
- pi_web_api_query (dict[str, Any]): PI Web API query configuration containing:
- endpoint (str): PI Web API endpoint path (e.g., '/streamsets/recorded')
- period (str): Time period configuration (e.g., '*-1d', '*-1h')
- api_timeout (int): Request timeout in seconds for PI Web API calls
- max_count (int, optional): Maximum data points per tag. Defaults to 1
- trigger_laborious (bool): Flag to enable intensive data processing
- filters (dict[str, str]): Data quality filters configuration
- schema (str): Target database schema for data export
- table_name (str): Target table name for data export
- retention_time (int): Data retention period in Redis (seconds)
- model_tags (dict[str, Any]): Tag-specific configuration mapping tag names
to WebIds and processing rules, including:
- webid (str): PI Web API WebId for the tag
- data_range: [min, max] values for data validation
- aggr_func: Aggregation method (avg, mdn, max, min, lts)
- frequency: Data collection frequency in milliseconds
- topics: List of Kafka topics for data routing
Returns:
None: This workflow doesn't return data, it orchestrates data processing
Raises:
WorkflowExecutionError: If workflow execution fails
ActivityExecutionError: If any activity fails after retry attempts
PIMSRequestError: If PI Web API request fails
"""
input_data['workflow_name'] = 'pi_web_api_scouter'
metadata = {
'metadata': {
'model_id': input_data['model_id'],
'model_name': input_data['model_name'],
'schedule_name': input_data['schedule_name'],
'workflow_name': input_data['workflow_name'],
}
}
pi_web_api_query = input_data['pi_web_api_query']
data = await workflow.execute_local_activity_method(
Activities.get_tag_values,
{
**metadata,
'endpoint': pi_web_api_query['endpoint'],
'web_ids': input_data['model_tags'],
'period': pi_web_api_query['period'],
'max_count': pi_web_api_query.get('max_count', 1),
'api_timeout': pi_web_api_query['api_timeout'],
},
start_to_close_timeout=timedelta(seconds=60),
retry_policy=retry_policy,
)
if not data:
return
input_data['data'] = data
input_data['metadata'] = metadata
await workflow.execute_child_workflow('subworkflow.core_scouter', input_data)

120
scouter/workflow/scouter.py Normal file
View File

@@ -0,0 +1,120 @@
from temporalio import workflow
with workflow.unsafe.imports_passed_through():
from datetime import timedelta
from typing import Any
from sientia_do.temporal.policies import retry_policy
from scouter.activities.activities import Activities
@workflow.defn(name='scouter')
class Scouter:
"""
Main Scouter workflow that orchestrates data ingestion and processing.
This workflow serves as the entry point for data processing pipelines. It loads
data from MongoDB collections, manages data timestamps for incremental processing,
and delegates the actual data processing to the CoreScouter workflow.
The workflow implements a robust data ingestion pattern with:
- Incremental data loading based on last processed timestamp
- Automatic timestamp management for data continuity
- Error handling and retry policies
- Child workflow orchestration for data processing
"""
@workflow.run
async def run(self, input_data: dict[str, Any]) -> None:
"""
Execute the main Scouter workflow.
This method orchestrates the complete data ingestion process:
1. Retrieves the last processed timestamp from Redis
2. Loads new data from MongoDB since the last timestamp using collection name
format: `raw_{schedule_name}`
3. Updates the last processed timestamp with the most recent data point
4. Delegates data processing to the CoreScouter workflow
If no new data is found in MongoDB, the workflow exits early without updating
the timestamp or invoking the CoreScouter workflow.
Args:
input_data (dict[str, Any]): Configuration and parameters for the workflow execution.
Required fields:
- topic (str): The Kafka topic name for data source identification
- schedule_name (str): Unique identifier for the data collection schedule
- model_name (str): Name of the data model being processed
- model_id (str): Unique identifier for the data model
- trigger_laborious (bool): Flag to enable intensive data processing
- filters (dict[str, str]): Data quality filters configuration
- schema (str): Target database schema for data export
- table_name (str): Target table name for data export
- retention_time (int): Data retention period in Redis (seconds)
- model_tags (dict[str, Any]): Tag-specific configuration including:
- data_range: [min, max] values for data validation
- aggr_function: Aggregation method (avg, mdn, max, min, lts)
- frequency: Data collection frequency in milliseconds
- topics: List of Kafka topics for data routing
Returns:
None: This workflow doesn't return data, it orchestrates data processing
Raises:
WorkflowExecutionError: If workflow execution fails
ActivityExecutionError: If any activity fails after retry attempts
"""
input_data['workflow_name'] = 'scouter'
metadata = {
'metadata': {
'model_id': input_data['model_id'],
'model_name': input_data['model_name'],
'schedule_name': input_data['schedule_name'],
'workflow_name': input_data['workflow_name'],
}
}
last_data_timestamp = await workflow.execute_local_activity_method(
Activities.get_last_data_timestamp,
{
**metadata,
'workflow_name': input_data['workflow_name'],
'schedule_name': input_data['schedule_name'],
},
start_to_close_timeout=timedelta(seconds=60),
retry_policy=retry_policy,
)
data = await workflow.execute_local_activity_method(
Activities.load_latest_data,
{
**metadata,
'collection_name': f'raw_{input_data["schedule_name"]}',
'last_data_timestamp': last_data_timestamp,
},
start_to_close_timeout=timedelta(seconds=60),
retry_policy=retry_policy,
)
if not data:
return
await workflow.execute_activity_method(
Activities.put_last_data_timestamp,
{
**metadata,
'data': data,
'workflow_name': input_data['workflow_name'],
'schedule_name': input_data['schedule_name'],
},
start_to_close_timeout=timedelta(seconds=60),
retry_policy=retry_policy,
)
input_data['data'] = data
input_data['metadata'] = metadata
await workflow.execute_child_workflow('subworkflow.core_scouter', input_data)

View File

@@ -0,0 +1,154 @@
from temporalio import workflow
with workflow.unsafe.imports_passed_through():
from datetime import timedelta
from typing import Any
from sientia_do.temporal.constants import DATETIME_FORMAT_WITH_TZ
from sientia_do.temporal.policies import retry_policy
from scouter.activities.activities import Activities
@workflow.defn(name='subworkflow.core_scouter')
class CoreScouter:
"""
Core data processing workflow that handles data quality, aggregation, and export.
This workflow implements the core data processing pipeline for industrial data:
- Data quality validation and filtering
- Time-series data aggregation using configurable functions
- Data grouping and temporary storage in Redis
- Asynchronous export to PostgreSQL for persistent storage
- Metrics collection and monitoring
The workflow is designed for high-throughput data processing with configurable
quality gates and aggregation strategies. It is typically invoked as a child
workflow by parent workflows such as Scouter or PIWebAPIScouter.
"""
@workflow.run
async def run(self, input_data: dict[str, Any]) -> None:
"""
Execute the core data processing workflow.
This method processes industrial time-series data through a series of stages:
1. Data Quality Gate: Applies configurable filters for data validation
2. Data Aggregation: Groups and aggregates data using specified functions
3. Data Grouping: Organizes data by tags and applies retention policies
4. Data Export: Persists processed data to PostgreSQL with timestamp conversion
5. Metrics Collection: Records processing metrics for monitoring
The workflow implements early exit conditions:
- If held_data is empty after grouping, the workflow exits without exporting
- If data export results in zero or negative affected_rows, the workflow exits
without writing metrics or storing debug packages
Args:
input_data (dict[str, Any]): Complete workflow configuration and data.
Required fields:
- metadata (dict[str, Any]): Workflow execution metadata
- workflow_name (str): Name of the parent workflow
- schedule_name (str): Data collection schedule identifier
- model_name (str): Data model name
- model_id (str): Unique model identifier
- data (dict[str, Any]): Raw time-series data to process
- trigger_laborious (bool): Enable intensive processing mode
- filters (dict[str, str]): Data quality filter configurations
- schema (str): Target database schema
- table_name (str): Target database table
- retention_time (int): Redis data retention period (seconds)
- model_tags (dict[str, Any]): Tag-specific processing rules
- fill_missing_tags (bool): Enable filling of missing tag values
- debug_data_package (bool, optional): Store data packages for debugging.
When True, stores both raw and processed data in MongoDB for debugging
Returns:
None: This workflow processes data but doesn't return results
Raises:
WorkflowExecutionError: If workflow execution fails
ActivityExecutionError: If any activity fails after retry attempts
"""
metadata = input_data['metadata']
filtered_data = await workflow.execute_local_activity_method(
Activities.data_quality_gate,
{
**metadata,
'filters': input_data['filters'],
'data': input_data['data'],
'model_tags': input_data['model_tags'],
},
retry_policy=retry_policy,
start_to_close_timeout=timedelta(seconds=60),
)
grouped_data = await workflow.execute_local_activity_method(
Activities.aggregate_data,
{**metadata, 'data': filtered_data, 'model_tags': input_data['model_tags']},
retry_policy=retry_policy,
start_to_close_timeout=timedelta(seconds=60),
)
held_data = await workflow.execute_local_activity_method(
Activities.group_and_hold_data,
{
**metadata,
'schedule_name': input_data['schedule_name'],
'workflow_name': input_data['workflow_name'],
'data': grouped_data,
'model_id': input_data['model_id'],
'model_tags': input_data['model_tags'],
'retention_time': input_data['retention_time'],
'fill_missing_tags': input_data['fill_missing_tags'],
},
retry_policy=retry_policy,
start_to_close_timeout=timedelta(seconds=60),
)
if held_data == {}:
return
data_exported = await workflow.execute_activity_method(
Activities.export_data_to_postgres,
{
**metadata,
'schema': input_data['schema'],
'table_name': input_data['table_name'],
'data': held_data,
'on_conflict': 'ignore',
'unique_columns': ['model_id', 'timestamp', 'variable'],
'timestamp_conversion': {'column': 'timestamp', 'format': DATETIME_FORMAT_WITH_TZ},
},
retry_policy=retry_policy,
start_to_close_timeout=timedelta(seconds=60),
)
if data_exported.get('affected_rows', 0) <= 0:
return
await workflow.execute_activity_method(
Activities.write_metrics,
{
**metadata,
'tag_values': held_data,
},
retry_policy=retry_policy,
start_to_close_timeout=timedelta(seconds=60),
)
if input_data.get('debug_data_package', False):
await workflow.execute_activity_method(
Activities.store_data_package,
{
**metadata,
'data': input_data['data'],
'held_data': held_data,
'workflow_name': input_data['workflow_name'],
'schedule_name': input_data['schedule_name'],
},
retry_policy=retry_policy,
start_to_close_timeout=timedelta(seconds=60),
)

11
sonar-project.properties Normal file
View File

@@ -0,0 +1,11 @@
sonar.projectKey=Aignosi_sientia-dataops-scouter_temporal_82f6f501-b2d4-45ec-b2bc-d8fdc1a8a06d
sonar.projectName=sientia-dataops-scouter_temporal
sonar.sources=scouter
sonar.tests=tests
sonar.qualitygate.wait=true
sonar.qualitygate.timeout=300
sonar.python.coverage.reportPaths=coverage.xml
sonar.coverage.exclusions=scouter/worker/worker.py
sonar.python.xunit.reportPath=pytest.xml
sonar.python.version=3.11
sonar.projectVersion=1.0.0

2433
tests.ipynb Normal file

File diff suppressed because it is too large Load Diff

0
tests/__init__.py Normal file
View File

View File

View File

@@ -0,0 +1,194 @@
import inspect
from unittest.mock import ANY, MagicMock, patch
from sientia_do.temporal.activities.postgres_sync import Postgres
from scouter.activities.activities import Activities
from scouter.activities.api import API
from scouter.activities.gates import Gates
from scouter.activities.mongodb import MongoDB
from scouter.activities.redis import Redis
@patch('scouter.activities.activities.MongoDB.__init__')
@patch('scouter.activities.activities.Postgres.__init__')
@patch('scouter.activities.activities.Redis.__init__')
@patch('scouter.activities.activities.Gates.__init__')
@patch('scouter.activities.activities.API.__init__')
@patch('scouter.activities.activities.MetricsController')
def test___init__(
mock_metrics_controller,
mock_api_init,
mock_gates_init,
mock_redis_init,
mock_postgres_init,
mock_mongodb_init,
):
postgres_config = {
'host': 'localhost',
'port': 5432,
'user': 'postgres',
'password': 'postgres',
'dbname': 'postgres',
'min_connections': 1,
'max_connections': 10,
}
redis_config = {'host': 'localhost', 'port': 6379, 'username': 'redis', 'password': 'redis'}
mongodb_config = {
'connection_string': 'mongodb://localhost:27017',
'database_name': 'test_database',
}
api_config = {
'base_url': 'https://api.example.com',
'auth_type': 'bearer',
'auth_token': 'test_token',
}
logger = MagicMock()
notification_handler = MagicMock()
activities = Activities(
postgres_config=postgres_config,
redis_config=redis_config,
mongodb_config=mongodb_config,
api_config=api_config,
logger=logger,
notification_handler=notification_handler,
)
assert isinstance(activities, Activities)
assert isinstance(activities, Postgres)
assert isinstance(activities, Redis)
assert isinstance(activities, MongoDB)
assert isinstance(activities, Gates)
assert isinstance(activities, API)
mock_postgres_init.assert_called_once_with(
ANY,
host=postgres_config['host'],
port=postgres_config['port'],
user=postgres_config['user'],
password=postgres_config['password'],
dbname=postgres_config['dbname'],
min_connections=postgres_config['min_connections'],
max_connections=postgres_config['max_connections'],
logger=logger,
notification_handler=notification_handler,
metrics_controller=mock_metrics_controller.return_value,
)
mock_redis_init.assert_called_once_with(
ANY,
host=redis_config['host'],
port=redis_config['port'],
username=redis_config['username'],
password=redis_config['password'],
logger=logger,
notification_handler=notification_handler,
metrics_controller=mock_metrics_controller.return_value,
)
mock_mongodb_init.assert_called_once_with(
ANY,
connection_string=mongodb_config['connection_string'],
database_name=mongodb_config['database_name'],
logger=logger,
notification_handler=notification_handler,
metrics_controller=mock_metrics_controller.return_value,
)
mock_gates_init.assert_called_once_with(
ANY,
logger=logger,
notification_handler=notification_handler,
metrics_controller=mock_metrics_controller.return_value,
)
mock_api_init.assert_called_once_with(
ANY,
base_url=api_config['base_url'],
auth_type=api_config['auth_type'],
auth_token=api_config['auth_token'],
logger=logger,
notification_handler=notification_handler,
metrics_controller=mock_metrics_controller.return_value,
)
@patch('scouter.activities.activities.Postgres.__init__')
@patch('scouter.activities.activities.Redis.__init__')
@patch('scouter.activities.activities.Gates.__init__')
@patch('scouter.activities.activities.MongoDB.__init__')
@patch('scouter.activities.activities.API.__init__')
@patch('scouter.activities.activities.Postgres.close')
@patch('scouter.activities.activities.MongoDB.close')
@patch('scouter.activities.activities.Redis.close')
@patch('scouter.activities.activities.Gates.close')
@patch('scouter.activities.activities.API.close')
def test_shutdown(
mock_api_close,
mock_gates_close,
mock_redis_close,
mock_mongodb_close,
mock_postgres_close,
_mock_api_init,
_mock_mongodb_init,
_mock_gates_init,
_mock_redis_init,
_mock_postgres_init,
):
postgres_config = {
'host': 'localhost',
'port': 5432,
'user': 'postgres',
'password': 'postgres',
'dbname': 'postgres',
'min_connections': 1,
'max_connections': 10,
}
redis_config = {'host': 'localhost', 'port': 6379, 'username': 'redis', 'password': 'redis'}
mongodb_config = {
'connection_string': 'mongodb://localhost:27017',
'database_name': 'test_database',
}
api_config = {
'base_url': 'https://api.example.com',
'auth_type': 'bearer',
'auth_token': 'test_token',
}
logger = MagicMock()
notification_handler = MagicMock()
activities = Activities(
postgres_config=postgres_config,
redis_config=redis_config,
mongodb_config=mongodb_config,
api_config=api_config,
logger=logger,
notification_handler=notification_handler,
)
activities.shutdown()
mock_postgres_close.assert_called()
mock_mongodb_close.assert_called()
mock_redis_close.assert_called()
mock_gates_close.assert_called()
mock_api_close.assert_called()
def test_activity_methods_are_sync():
"""Every @activity.defn method on Activities must be a synchronous def."""
for cls in Activities.__mro__:
for name, member in vars(cls).items():
if getattr(member, '__temporal_activity_definition', None) is not None:
assert not inspect.iscoroutinefunction(member), (
f'{cls.__name__}.{name} must not be async'
)

View File

@@ -0,0 +1,340 @@
from unittest.mock import ANY, MagicMock, Mock, patch
import pandas as pd
import pytest
from sientia_do.notifications.models import NotificationLevel
from scouter.activities.api import API
@pytest.fixture
@patch('scouter.activities.api.PIWebAPIClient')
def api_activity(mock_pi_web_api_client):
"""Fixture to create an API activity instance with mocked dependencies."""
logger = MagicMock()
notification_handler = MagicMock()
metrics_controller = MagicMock()
activity = API(
base_url='https://pi.example.com',
auth_type='basic',
auth_token='test_token',
logger=logger,
notification_handler=notification_handler,
metrics_controller=metrics_controller,
)
activity.logger = logger
activity.notification_handler = notification_handler
activity.metrics_controller = metrics_controller
activity.pod_id = 'test_pod_id'
return activity
metadata = {
'metadata': {
'model_id': 'test_model_id',
'model_name': 'test_model',
'schedule_name': 'test_schedule',
'workflow_name': 'pi_web_api_scouter',
}
}
@patch('scouter.activities.api.PIWebAPIClient')
def test_api_initialization(mock_pi_web_api_client):
"""Test API activity initialization."""
logger = MagicMock()
notification_handler = MagicMock()
metrics_controller = MagicMock()
activity = API(
base_url='https://pi.example.com',
auth_type='basic',
auth_token='test_token',
logger=logger,
notification_handler=notification_handler,
metrics_controller=metrics_controller,
)
mock_pi_web_api_client.assert_called_once_with(
base_url='https://pi.example.com',
auth_config={
'type': 'basic',
'token': 'test_token',
},
logger=logger,
notification_handler=notification_handler,
metrics_controller=metrics_controller,
headers_config={
'Content-Type': 'application/json',
'Accept': 'application/json',
'x-requested-with': 'piwebapistreams',
'User-Agent': 'Aig-Scouter-Agent/1.0',
},
)
assert activity.pi_web_api_client is not None
@patch('scouter.activities.api.SientiaMonitoring')
def test_close(mock_sientia_monitoring, api_activity):
"""Test close method."""
api_activity.close()
api_activity.pi_web_api_client.close.assert_called_once()
mock_sientia_monitoring.shutdown.assert_called_once()
def test_get_tag_values_success(api_activity):
"""Test get_tag_values with successful data retrieval."""
# Setup test data
test_data = {
**metadata,
'endpoint': '/streamsets/recorded',
'web_ids': {
'tag1': {
'webid': 'webid1',
'aggr_function': 'avg',
'data_range': [0, 100],
},
'tag2': {
'webid': 'webid2',
'aggr_function': 'avg',
'data_range': [0, 100],
},
'tag3': {
'webid': 'webid3',
'aggr_function': 'avg',
'data_range': [0, 100],
},
},
'period': '*-1d',
'max_count': 10,
'api_timeout': 30,
}
# Mock DataFrame response
mock_df = pd.DataFrame(
{
'timestamp': [
'2023-01-01 12:00:00+0000',
'2023-01-01 12:01:00+0000',
'2023-01-01 12:02:00+0000',
],
'name': ['tag1', 'tag2', 'tag3'],
'value': [10.5, 20.3, 30.7],
'tag': ['webid1', 'webid2', 'webid3'],
}
)
mock_df['timestamp'] = pd.to_datetime(mock_df['timestamp'])
api_activity.pi_web_api_client.get_latest_values_df = Mock(return_value=mock_df)
# Execute
result = api_activity.get_tag_values(test_data)
# Verify
api_activity.pi_web_api_client.get_latest_values_df.assert_called_once_with(
endpoint='/streamsets/recorded',
web_ids={
'tag1': {'webid': 'webid1', 'aggr_function': 'avg', 'data_range': [0, 100]},
'tag2': {'webid': 'webid2', 'aggr_function': 'avg', 'data_range': [0, 100]},
'tag3': {'webid': 'webid3', 'aggr_function': 'avg', 'data_range': [0, 100]},
},
start_time='*-1d',
end_time='*',
max_count=10,
metadata=metadata['metadata'],
request_timeout=30,
)
assert len(result) == 3
assert result[0]['name'] == 'tag1'
assert result[0]['value'] == pytest.approx(10.5)
assert result[1]['name'] == 'tag2'
assert result[2]['name'] == 'tag3'
assert result[0]['timestamp'] == '2023-01-01 12:02:00+0000'
assert result[1]['timestamp'] == '2023-01-01 12:02:00+0000'
assert result[2]['timestamp'] == '2023-01-01 12:02:00+0000'
def test_get_tag_values_with_default_max_count(api_activity):
"""Test get_tag_values with default max_count value."""
# Setup test data without max_count
test_data = {
**metadata,
'endpoint': '/streamsets/recorded',
'web_ids': {
'tag1': {
'webid': 'webid1',
'aggr_function': 'avg',
'data_range': [0, 100],
}
},
'period': '*-1h',
'api_timeout': 15,
}
mock_df = pd.DataFrame(
{
'timestamp': ['2023-01-01 12:00:00+0000'],
'name': ['tag1'],
'value': [42.0],
'tag': ['webid1'],
}
)
mock_df['timestamp'] = pd.to_datetime(mock_df['timestamp'])
api_activity.pi_web_api_client.get_latest_values_df = Mock(return_value=mock_df)
# Execute
result = api_activity.get_tag_values(test_data)
# Verify default max_count is 1
api_activity.pi_web_api_client.get_latest_values_df.assert_called_once_with(
endpoint='/streamsets/recorded',
web_ids={'tag1': {'webid': 'webid1', 'aggr_function': 'avg', 'data_range': [0, 100]}},
start_time='*-1h',
end_time='*',
max_count=1,
metadata=metadata['metadata'],
request_timeout=15,
)
assert len(result) == 1
def test_get_tag_values_with_none_webids(api_activity):
"""Test get_tag_values with some None WebIds."""
# Setup test data with None values
test_data = {
**metadata,
'endpoint': '/streamsets/recorded',
'web_ids': {
'tag1': {
'webid': 'webid1',
'aggr_function': 'avg',
'data_range': [0, 100],
},
'tag2': None,
'tag3': {
'webid': 'webid3',
'aggr_function': 'max',
'data_range': [0, 200],
},
},
'period': '*-1h',
'max_count': 5,
'api_timeout': 20,
}
mock_df = pd.DataFrame(
{
'timestamp': ['2023-01-01 12:00:00+0000', '2023-01-01 12:01:00+0000'],
'name': ['tag1', 'tag3'],
'value': [10.5, 30.7],
'tag': ['webid1', 'webid3'],
}
)
mock_df['timestamp'] = pd.to_datetime(mock_df['timestamp'])
api_activity.pi_web_api_client.get_latest_values_df = Mock(return_value=mock_df)
# Execute
result = api_activity.get_tag_values(test_data)
# Verify - should only query non-None WebIds
assert len(result) == 2
assert all(r['name'] in ['tag1', 'tag3'] for r in result)
def test_get_tag_values_api_error(api_activity):
"""Test get_tag_values when PI Web API client raises an error and sends notification."""
# Setup test data
test_data = {
**metadata,
'endpoint': '/streamsets/recorded',
'web_ids': {
'tag1': {
'webid': 'webid1',
'aggr_function': 'avg',
'data_range': [0, 100],
}
},
'period': '*-1d',
'max_count': 1,
'api_timeout': 30,
}
# Mock API error
api_activity.pi_web_api_client.get_latest_values_df = Mock(
side_effect=Exception('PI Web API connection error')
)
api_activity.send_notification = MagicMock()
# Execute and verify exception is raised
with pytest.raises(Exception) as exc_info:
api_activity.get_tag_values(test_data)
assert str(exc_info.value) == 'PI Web API connection error'
# Verify notification was sent
api_activity.send_notification.assert_called_once_with(
metadata=metadata['metadata'],
notification_id='PI_WEB_API_REQUEST_ERROR',
message='Error getting tag values from PI Web API: PI Web API connection error',
block='get_tag_values',
level=NotificationLevel.ERROR,
attachment_content=ANY,
)
def test_get_tag_values_with_nan_values(api_activity):
"""Test get_tag_values handling NaN values in the DataFrame."""
# Setup test data
test_data = {
**metadata,
'endpoint': '/streamsets/recorded',
'web_ids': {
'tag1': {
'webid': 'webid1',
'aggr_function': 'avg',
'data_range': [0, 100],
},
'tag2': {
'webid': 'webid2',
'aggr_function': 'avg',
'data_range': [0, 100],
},
},
'period': '*-1d',
'max_count': 1,
'api_timeout': 30,
}
# Mock DataFrame with NaN values
mock_df = pd.DataFrame(
{
'timestamp': ['2023-01-01 12:00:00+0000', '2023-01-01 12:00:00+0000'],
'name': ['tag1', 'tag2'],
'value': [10.0, float('nan')],
'tag': ['webid1', 'webid2'],
}
)
mock_df['timestamp'] = pd.to_datetime(mock_df['timestamp'])
api_activity.pi_web_api_client.get_latest_values_df = Mock(return_value=mock_df)
# Execute
result = api_activity.get_tag_values(test_data)
# Verify
assert len(result) == 2
assert result[0]['value'] == pytest.approx(10.0)
# NaN should be preserved in the result
assert pd.isna(result[1]['value'])

View File

@@ -0,0 +1,409 @@
from typing import Any
from unittest.mock import ANY, MagicMock, Mock, call, patch
import numpy as np
import pandas as pd
import pytest
from sientia_do.notifications.models import NotificationLevel
from scouter.activities.gates import Gates
@pytest.fixture
def gates_fixture():
"""Fixture to create a Gates instance with mocked dependencies."""
logger = Mock()
notification_handler = MagicMock()
metrics_controller = MagicMock()
gates = Gates(
logger=logger,
notification_handler=notification_handler,
metrics_controller=metrics_controller,
)
gates.send_notification = MagicMock()
gates.logger = logger
gates.notification_handler = notification_handler
gates.metrics_controller = metrics_controller
gates.pod_id = 'localhost'
return gates
metadata = {
'metadata': {
'model_id': 'test_model_id',
'model_name': 'test_model',
'schedule_name': 'test_schedule',
'workflow_name': 'scouter',
}
}
@patch('scouter.activities.gates.SientiaMonitoring')
def test_close(mock_sientia_monitoring, gates_fixture):
"""Test close method."""
gates_fixture.close()
mock_sientia_monitoring.shutdown.assert_called_once()
def test_data_quality_gate_with_null_values_filter_discard(gates_fixture):
"""Test data_quality_gate with NULL_VALUES_FILTER and DISCARD policy."""
# Setup test data
input_data = {
'filters': {'NULL_VALUES_FILTER': {'policy': 'DISCARD'}},
'data': {
'name': ['tag1', 'tag2', 'tag3'],
'tag': ['tag1', 'tag2', 'tag3'],
'value': [1.0, None, 3.0],
'timestamp': ['2023-01-01', '2023-01-02', '2023-01-03'],
},
'model_tags': {
'tag1': {'data_range': [0, 100]},
'tag2': {'data_range': [0, 100]},
'tag3': {'data_range': [0, 100]},
},
**metadata,
}
# Execute
result = gates_fixture.data_quality_gate(input_data)
# Verify
assert len(result['tag']) == 2
assert 'tag2' not in result['tag']
gates_fixture.send_notification.assert_called_once()
def test_data_quality_gate_with_out_of_bounds_filter_keep(gates_fixture):
"""Test data_quality_gate with OUT_OF_BOUNDS_FILTER and KEEP policy."""
# Setup test data with out of bounds values
input_data = {
'filters': {'OUT_OF_BOUNDS_FILTER': {'policy': 'KEEP'}},
'data': {
'name': ['tag1', 'tag2', 'tag3'],
'tag': ['tag1', 'tag2', 'tag3'],
'value': [1.0, 200.0, 3.0],
'timestamp': ['2023-01-01', '2023-01-02', '2023-01-03'],
},
'model_tags': {
'tag1': {'data_range': [0, 100]},
'tag2': {'data_range': [0, 100]},
'tag3': {'data_range': [0, 100]},
},
**metadata,
}
# Mock the out_of_bounds_filter to return rows with out of bounds values
with patch(
'scouter.activities.gates.quality_gate_filters',
{'OUT_OF_BOUNDS_FILTER': lambda df: df[df['tag'] == 'tag2']},
):
# Execute
result = gates_fixture.data_quality_gate(input_data)
# Verify data is kept but notification is sent
assert len(result['tag']) == 3 # All rows kept
gates_fixture.send_notification.assert_called_once()
def test_data_quality_gate_with_multiple_filters(gates_fixture):
"""Test data_quality_gate with multiple filters."""
# Setup test data
input_data = {
'filters': {
'NULL_VALUES_FILTER': {'policy': 'DISCARD'},
'OUT_OF_BOUNDS_FILTER': {'policy': 'DISCARD'},
},
'data': {
'tag': ['tag1', 'tag2', 'tag3', 'tag4'],
'name': ['tag1', 'tag2', 'tag3', 'tag4'],
'value': [1.0, None, 300.0, 4.0],
'timestamp': ['2023-01-01', '2023-01-02', '2023-01-03', '2023-01-04'],
},
'model_tags': {
'tag1': {'data_range': [0, 100]},
'tag2': {'data_range': [0, 100]},
'tag3': {'data_range': [0, 100]},
'tag4': {'data_range': [0, 100]},
},
**metadata,
}
result = gates_fixture.data_quality_gate(input_data)
# Verify only tag1 and tag4 remain (tag2 has null, tag3 is out of bounds)
assert result == {
'tag': {0: 'tag1', 3: 'tag4'},
'name': {0: 'tag1', 3: 'tag4'},
'value': {0: 1.0, 3: 4.0},
'timestamp': {0: '2023-01-01', 3: '2023-01-04'},
}
# Should be called twice (once for each filter)
assert gates_fixture.send_notification.call_count == 2
def test_data_quality_gate_with_unknown_filter(gates_fixture):
"""Test data_quality_gate with an unknown filter."""
# Setup test data with unknown filter
gates_fixture.warning = MagicMock()
input_data = {
'filters': {'UNKNOWN_FILTER': {'policy': 'DISCARD'}},
'data': {'tag': ['tag1'], 'name': ['tag1'], 'value': [1.0], 'timestamp': ['2023-01-01']},
'model_tags': {'tag1': {'data_range': [0, 100]}},
**metadata,
}
# Execute
result = gates_fixture.data_quality_gate(input_data)
# Verify data is unchanged and warning is logged
assert len(result['tag']) == 1
gates_fixture.warning.assert_called_once_with(
'Filter UNKNOWN_FILTER not found', metadata=metadata['metadata']
)
def test_data_quality_gate_with_filter_error(gates_fixture):
"""Test data_quality_gate when a filter raises an exception."""
# Setup test data
input_data = {
'filters': {'NULL_VALUES_FILTER': {'policy': 'DISCARD'}},
'data': {'tag': ['tag1'], 'name': ['tag1'], 'value': [1.0], 'timestamp': ['2023-01-01']},
'model_tags': {'tag1': {'data_range': [0, 100]}},
**metadata,
}
# Mock the filter to raise an exception
def failing_filter(_, _model_tags):
raise ValueError('Filter error')
with patch(
'scouter.activities.gates.quality_gate_filters', {'NULL_VALUES_FILTER': failing_filter}
):
# Execute
result = gates_fixture.data_quality_gate(input_data)
# Verify error notification is sent and data is unchanged
assert len(result['tag']) == 1
gates_fixture.send_notification.assert_called_once()
call_args = gates_fixture.send_notification.call_args[1]
assert call_args['notification_id'] == 'DATA_QUALITY_GATE_ISSUES'
assert call_args['level'] == NotificationLevel.ERROR
assert 'Filter error' in call_args['message']
def test_data_quality_gate_with_empty_data(gates_fixture):
"""Test data_quality_gate with empty input data."""
# Setup empty input data
input_data = {
'filters': {'NULL_VALUES_FILTER': {'policy': 'DISCARD'}},
'data': {'tag': [], 'name': [], 'value': [], 'timestamp': []},
'model_tags': {},
**metadata,
}
# Execute
result = gates_fixture.data_quality_gate(input_data)
# Verify empty result and no notifications
assert len(result['tag']) == 0
gates_fixture.send_notification.assert_not_called()
def test_data_quality_gate_with_no_filters(gates_fixture):
"""Test data_quality_gate with no filters specified."""
# Setup test data with no filters
input_data = {
'filters': {},
'data': {'tag': ['tag1'], 'name': ['tag1'], 'value': [1.0], 'timestamp': ['2023-01-01']},
'model_tags': {'tag1': {'data_range': [0, 100]}},
**metadata,
}
# Execute
result = gates_fixture.data_quality_gate(input_data)
# Verify data is unchanged and no notifications
assert len(result['tag']) == 1
gates_fixture.send_notification.assert_not_called()
@pytest.mark.parametrize(
'group_data, aggr_function, expected_result',
[
# Single value case
(pd.DataFrame({'value': [10.0]}), 'avg', 10.0),
# Multiple values with different aggregation functions
(pd.DataFrame({'value': [1.0, 2.0, 3.0, 4.0]}), 'avg', 2.5),
(pd.DataFrame({'value': [1.0, 2.0, 3.0, 4.0]}), 'mdn', 2.5),
(pd.DataFrame({'value': [1.0, 2.0, 3.0, 4.0]}), 'max', 4.0),
(pd.DataFrame({'value': [1.0, 2.0, 3.0, 4.0]}), 'min', 1.0),
(pd.DataFrame({'value': [1.0, 2.0, 3.0, 4.0]}), 'lts', 4.0),
# With NaN values
(pd.DataFrame({'value': [1.0, np.nan, 3.0, 4.0]}), 'avg', 2.6666666666666665),
# Empty group after dropping NaN
(pd.DataFrame({'value': [np.nan, np.nan]}), 'avg', None),
# Invalid aggregation function
(pd.DataFrame({'value': [1.0, 2.0]}), 'invalid', 'continue'),
(pd.DataFrame({'value': [10.0]}), 'invalid', 'continue'),
],
)
def test_apply_aggregation(gates_fixture, group_data, aggr_function, expected_result):
"""Test apply_aggregation method with various scenarios."""
result = gates_fixture.apply_aggregation(group_data, aggr_function, metadata)
assert result == expected_result
# Check notification was sent for invalid function
if aggr_function == 'invalid':
gates_fixture.send_notification.assert_called_once()
else:
gates_fixture.send_notification.assert_not_called()
def test_aggregate_data(gates_fixture):
"""Test aggregate_data method with multiple groups and aggregation functions."""
input_data = {
'data': [
{'tag': 'tag1', 'name': 'name1', 'value': 1.0, 'timestamp': '2023-01-01'},
{'tag': 'tag1', 'name': 'name1', 'value': 2.0, 'timestamp': '2023-01-02'},
{'tag': 'tag1', 'name': 'name1', 'value': 3.0, 'timestamp': '2023-01-03'},
{'tag': 'tag2', 'name': 'name2', 'value': 4.0, 'timestamp': '2023-01-01'},
{'tag': 'tag2', 'name': 'name2', 'value': 5.0, 'timestamp': '2023-01-02'},
{'tag': 'tag2', 'name': 'name2', 'value': 6.0, 'timestamp': '2023-01-03'},
{'tag': 'tag1', 'name': 'name1', 'value': None, 'timestamp': '2023-01-04'},
],
'model_tags': {
'name1': {'aggr_func': 'avg'},
'name2': {'aggr_func': 'max'},
},
**metadata,
}
# Expected result
expected_result = {
'tag': {0: 'tag1', 1: 'tag2'},
'name': {0: 'name1', 1: 'name2'},
'value': {0: 2.0, 1: 6.0},
'timestamp': {0: '2023-01-04', 1: '2023-01-03'},
'aggregation_function': {0: 'avg', 1: 'max'},
}
# Execute
result = gates_fixture.aggregate_data(input_data)
# Verify
assert result == expected_result
gates_fixture.send_notification.assert_not_called()
def test_aggregate_data_with_continue(gates_fixture):
gates_fixture.apply_aggregation = MagicMock(return_value='continue')
input_data = {
'data': [
{'tag': 'tag1', 'name': 'name1', 'value': 1.0, 'timestamp': '2023-01-01'},
{'tag': 'tag1', 'name': 'name1', 'value': 2.0, 'timestamp': '2023-01-02'},
{'tag': 'tag1', 'name': 'name1', 'value': 3.0, 'timestamp': '2023-01-03'},
{'tag': 'tag2', 'name': 'name2', 'value': 4.0, 'timestamp': '2023-01-01'},
{'tag': 'tag2', 'name': 'name2', 'value': 5.0, 'timestamp': '2023-01-02'},
{'tag': 'tag2', 'name': 'name2', 'value': 6.0, 'timestamp': '2023-01-03'},
{'tag': 'tag1', 'name': 'name1', 'value': None, 'timestamp': '2023-01-04'},
],
'model_tags': {
'name1': {'aggr_function': 'avg'},
'name2': {'aggr_function': 'max'},
},
**metadata,
}
# Expected result
expected_result: dict[str, Any] = {}
# Execute
result = gates_fixture.aggregate_data(input_data)
# Verify
assert result == expected_result
gates_fixture.send_notification.assert_not_called()
def test_aggregate_data_raise_exception(gates_fixture):
gates_fixture.apply_aggregation = MagicMock(side_effect=Exception('Test exception'))
input_data = {
'data': [
{'tag': 'tag1', 'name': 'name1', 'value': 1.0, 'timestamp': '2023-01-01'},
{'tag': 'tag1', 'name': 'name1', 'value': 2.0, 'timestamp': '2023-01-02'},
{'tag': 'tag1', 'name': 'name1', 'value': 3.0, 'timestamp': '2023-01-03'},
{'tag': 'tag2', 'name': 'name2', 'value': 4.0, 'timestamp': '2023-01-01'},
{'tag': 'tag2', 'name': 'name2', 'value': 5.0, 'timestamp': '2023-01-02'},
{'tag': 'tag2', 'name': 'name2', 'value': 6.0, 'timestamp': '2023-01-03'},
{'tag': 'tag1', 'name': 'name1', 'value': None, 'timestamp': '2023-01-04'},
],
'model_tags': {
'name1': {'aggr_function': 'avg'},
'name2': {'aggr_function': 'max'},
},
**metadata,
}
try:
gates_fixture.aggregate_data(input_data)
except Exception as e:
assert str(e) == 'Test exception'
gates_fixture.send_notification.assert_called_once_with(
metadata=metadata['metadata'],
notification_id='AGGREGATION_ISSUES',
message='Error aggregating data: Test exception',
block='aggregate_data',
level=NotificationLevel.ERROR,
attachment_content=ANY,
)
else:
raise AssertionError('Exception not raised')
@patch('scouter.activities.gates.metrics')
def test_write_metrics(mock_metrics, gates_fixture):
"""Test write_metrics method."""
input_data = {
'metadata': metadata['metadata'],
'tag_values': {
'variable': ['tag1', 'tag2', 'tag3'],
'value': [1.0, 2.0, None],
},
}
gates_fixture.write_metrics(input_data)
mock_metrics.LABORIOUS_DATA_WRITTEN_COUNT.labels.assert_called_once_with(
pod_id=gates_fixture.pod_id,
model_name=metadata['metadata']['model_name'],
workflow_name=metadata['metadata']['workflow_name'],
)
mock_metrics.LABORIOUS_DATA_WRITTEN_COUNT.labels.return_value.inc.assert_called_once()
# Only tags with None values should be registered
mock_metrics.TAG_CHANGES_MONITOR.labels.return_value.set.assert_has_calls(
[
call(1.0),
call(2.0),
],
any_order=True,
)
mock_metrics.TAG_CHANGES_MONITOR.labels.assert_has_calls(
[
call(
pod_id=gates_fixture.pod_id,
model_name=metadata['metadata']['model_name'],
workflow_name=metadata['metadata']['workflow_name'],
tag_name='tag1',
),
call(
pod_id=gates_fixture.pod_id,
model_name=metadata['metadata']['model_name'],
workflow_name=metadata['metadata']['workflow_name'],
tag_name='tag2',
),
],
any_order=True,
)

View File

@@ -0,0 +1,174 @@
from datetime import datetime
from unittest.mock import ANY, MagicMock, Mock, patch
from pytest import fixture
from sientia_do.notifications.models import NotificationLevel
from sientia_do.temporal.constants import DATETIME_FORMAT_MS_WITH_TZ
from scouter.activities.mongodb import MongoDB
@patch('scouter.activities.mongodb.MongoDBRepository')
def test_mongodb___init__(mock_mongodb_repository):
"""Test MongoDB __init__"""
logger = MagicMock()
notification_handler = MagicMock()
metrics_controller = MagicMock()
mongo = MongoDB(
connection_string='mongodb://localhost:27017',
database_name='test_db',
logger=logger,
notification_handler=notification_handler,
metrics_controller=metrics_controller,
)
mock_mongodb_repository.assert_called_once_with(
connection_string='mongodb://localhost:27017',
database_name='test_db',
logger=logger,
notification_handler=notification_handler,
metrics_controller=metrics_controller,
)
assert mongo.mongodb_repository is not None
@fixture
@patch('scouter.activities.mongodb.MongoDBRepository')
def mongodb_activity(mock_mongodb_repository):
"""Test MongoDB activity"""
mongo = MongoDB(
connection_string='mongodb://localhost:27017',
database_name='test_db',
logger=MagicMock(),
notification_handler=MagicMock(),
metrics_controller=MagicMock(),
)
return mongo
@patch('scouter.activities.mongodb.SientiaMonitoring')
def test_close(mock_sientia_monitoring, mongodb_activity):
"""Test close"""
mongodb_activity.close()
mongodb_activity.mongodb_repository.close.assert_called_once()
mock_sientia_monitoring.shutdown.assert_called_once()
def test_del(mongodb_activity):
mongodb_activity.close = MagicMock()
mongodb_activity.__del__()
mongodb_activity.close.assert_called_once()
def test_load_latest_data_none_last_data_timestamp(mongodb_activity):
"""Test load_latest_data"""
mongodb_activity.mongodb_repository.find = Mock(
return_value=[
{
'name': 'test1',
'value': 1,
'inserted_at': datetime.strptime(
'2023-01-01 12:00:00.000000+0000', DATETIME_FORMAT_MS_WITH_TZ
),
}
]
)
result = mongodb_activity.load_latest_data(
{
'metadata': {'workflow_name': 'test_pipeline', 'schedule_name': 'test_schedule'},
'collection_name': 'test_collection',
'last_data_timestamp': None,
}
)
mongodb_activity.mongodb_repository.find.assert_called_once_with(
collection_name='test_collection',
filters={},
metadata={'workflow_name': 'test_pipeline', 'schedule_name': 'test_schedule'},
)
assert result == [
{
'name': 'test1',
'value': 1,
'inserted_at': '2023-01-01 12:00:00.000000+0000',
}
]
def test_load_latest_data_not_none_last_data_timestamp(mongodb_activity):
"""Test load_latest_data"""
mongodb_activity.mongodb_repository.find = Mock(
return_value=[
{
'name': 'test1',
'value': 1,
'inserted_at': datetime.strptime(
'2023-01-01 12:00:00.000000+0000', DATETIME_FORMAT_MS_WITH_TZ
),
}
]
)
result = mongodb_activity.load_latest_data(
{
'metadata': {'workflow_name': 'test_pipeline', 'schedule_name': 'test_schedule'},
'collection_name': 'test_collection',
'last_data_timestamp': '2023-01-01 12:00:00.000000+0000',
}
)
mongodb_activity.mongodb_repository.find.assert_called_once_with(
collection_name='test_collection',
filters={
'inserted_at': {
'$gt': datetime.strptime(
'2023-01-01 12:00:00.000000+0000', DATETIME_FORMAT_MS_WITH_TZ
)
}
},
metadata={'workflow_name': 'test_pipeline', 'schedule_name': 'test_schedule'},
)
assert result == [
{
'name': 'test1',
'value': 1,
'inserted_at': '2023-01-01 12:00:00.000000+0000',
}
]
def test_load_latest_data_error(mongodb_activity):
"""Test load_latest_data"""
mongodb_activity.mongodb_repository.find.side_effect = Exception('test')
mongodb_activity.send_notification = MagicMock()
try:
mongodb_activity.load_latest_data(
{
'metadata': {'workflow_name': 'test_pipeline', 'schedule_name': 'test_schedule'},
'collection_name': 'test_collection',
'last_data_timestamp': '2023-01-01 12:00:00.000000+0000',
}
)
except Exception as e:
assert str(e) == 'test'
mongodb_activity.send_notification.assert_called_once_with(
metadata={'workflow_name': 'test_pipeline', 'schedule_name': 'test_schedule'},
notification_id='MONGO_LOAD_ERROR',
message='Error loading data from MongoDB: test',
block='load_latest_data',
level=NotificationLevel.ERROR,
attachment_content=ANY,
)

View File

@@ -0,0 +1,525 @@
from datetime import datetime
from unittest.mock import ANY, MagicMock, Mock, patch
import numpy as np
import pytest
from pandas import DataFrame
from sientia_do.notifications.handlers import CoreNotificationHandler as NotificationHandler
from sientia_do.notifications.models import NotificationLevel
from scouter.activities.redis import Redis
@pytest.fixture
@patch('scouter.activities.redis.RedisRepository')
def redis_activity(mock_redis_repository):
logger = MagicMock()
notification_handler = MagicMock(spec=NotificationHandler)
metrics_controller = MagicMock()
activity = Redis(
host='localhost',
port=6379,
logger=logger,
notification_handler=notification_handler,
username='test',
password='test',
metrics_controller=metrics_controller,
)
activity.redis_client = MagicMock()
activity.logger = logger
activity.notification_handler = notification_handler
activity.pod_id = 'test_pod_id'
return activity
metadata = {
'metadata': {
'model_id': 'test_model_id',
'model_name': 'test_model',
'schedule_name': 'test_schedule',
'workflow_name': 'scouter',
}
}
@patch('scouter.activities.redis.SientiaMonitoring')
def test_close(mock_sientia_monitoring, redis_activity):
"""Test close method."""
redis_activity.close()
redis_activity.redis_repository.close.assert_called_once()
mock_sientia_monitoring.shutdown.assert_called_once()
@patch('scouter.activities.redis.RedisRepository')
def test_redis_initialization(mock_redis_repository):
"""Test Redis activity initialization"""
logger = MagicMock()
notification_handler = MagicMock(spec=NotificationHandler)
metrics_controller = MagicMock()
activity = Redis(
host='localhost',
port=6379,
logger=logger,
notification_handler=notification_handler,
username='test',
password='test',
metrics_controller=metrics_controller,
)
mock_redis_repository.assert_called_once_with(
host='localhost',
port=6379,
username='test',
password='test',
logger=logger,
notification_handler=notification_handler,
metrics_controller=metrics_controller,
)
assert activity.redis_repository is not None
def test_get_last_data_timestamp_none(redis_activity):
"""Test get_last_data_timestamp"""
test_data = {
**metadata,
'workflow_name': 'test_pipeline',
'schedule_name': 'test_schedule',
}
redis_activity.redis_repository.get = Mock(return_value=None)
result = redis_activity.get_last_data_timestamp(test_data)
redis_activity.redis_repository.get.assert_called_once_with(
'last_data_timestamp:test_pipeline:test_schedule',
metadata=metadata['metadata'],
)
assert result is None
def test_get_last_data_timestamp_not_none(redis_activity):
"""Test get_last_data_timestamp"""
test_data = {**metadata, 'workflow_name': 'test_pipeline', 'schedule_name': 'test_schedule'}
redis_activity.redis_repository.get = Mock(return_value='2023-01-01 12:00:00')
result = redis_activity.get_last_data_timestamp(test_data)
redis_activity.redis_repository.get.assert_called_once_with(
'last_data_timestamp:test_pipeline:test_schedule',
metadata=metadata['metadata'],
)
assert result == '2023-01-01 12:00:00'
def test_get_last_data_timestamp_error(redis_activity):
"""Test get_last_data_timestamp error"""
test_data = {**metadata, 'workflow_name': 'test_pipeline', 'schedule_name': 'test_schedule'}
redis_activity.send_notification = Mock()
redis_activity.redis_repository.get.side_effect = Exception('test')
try:
redis_activity.get_last_data_timestamp(test_data)
except Exception as e:
assert str(e) == 'test'
redis_activity.send_notification.assert_called_once_with(
metadata=metadata['metadata'],
notification_id='REDIS_GET_ERROR',
message='Error getting last data timestamp: test',
block='get_last_data_timestamp',
level=NotificationLevel.ERROR,
attachment_content=ANY,
)
else:
raise AssertionError('Expected exception')
def test_put_last_data_timestamp_empty_dataframe(redis_activity):
"""Test put_last_data_timestamp with empty dataframe"""
test_data = {
**metadata,
'workflow_name': 'test_pipeline',
'schedule_name': 'test_schedule',
'data': DataFrame(columns=['name', 'value', 'timestamp']).to_dict('records'),
}
redis_activity.redis_repository.set = MagicMock()
result = redis_activity.put_last_data_timestamp(test_data)
assert result is None
redis_activity.redis_repository.set.assert_not_called()
def test_put_last_data_timestamp_not_empty_dataframe(redis_activity):
"""Test put_last_data_timestamp with not empty dataframe"""
data = DataFrame(
{
'name': ['sensor1', 'sensor2'],
'value': [25.5, 30.0],
'inserted_at': ['2023-01-01 12:00:00', '2023-01-01 12:00:01'],
}
)
test_data = {
**metadata,
'workflow_name': 'test_pipeline',
'schedule_name': 'test_schedule',
'data': data.to_dict('records'),
}
redis_activity.redis_repository.set = Mock()
result = redis_activity.put_last_data_timestamp(test_data)
assert result == '2023-01-01 12:00:01'
redis_activity.redis_repository.set.assert_called_once_with(
'last_data_timestamp:test_pipeline:test_schedule',
'2023-01-01 12:00:01',
ttl=18000,
metadata=metadata['metadata'],
)
def test_put_last_data_timestamp_error(redis_activity):
"""Test put_last_data_timestamp error"""
test_data = {
**metadata,
'workflow_name': 'test_pipeline',
'schedule_name': 'test_schedule',
'data': DataFrame(
{
'name': ['sensor1', 'sensor2'],
'value': [25.5, 30.0],
'inserted_at': ['2023-01-01 12:00:00'] * 2,
}
).to_dict('records'),
}
redis_activity.send_notification = Mock()
redis_activity.redis_repository.set = Mock(side_effect=Exception('test'))
try:
redis_activity.put_last_data_timestamp(test_data)
except Exception as e:
assert str(e) == 'test'
redis_activity.send_notification.assert_called_once_with(
metadata=metadata['metadata'],
notification_id='REDIS_SET_ERROR',
message='Error setting last data timestamp: test',
block='put_last_data_timestamp',
level=NotificationLevel.ERROR,
attachment_content=ANY,
)
else:
raise AssertionError('Expected exception')
def test_group_and_hold_data_new_key(redis_activity):
"""Test group_and_hold_data with a new key"""
# Setup
test_data = {
**metadata,
'workflow_name': 'test_pipeline',
'schedule_name': 'test_schedule',
'retention_time': 3600,
'model_id': 1,
'data': DataFrame(
{
'name': ['sensor1', 'sensor2'],
'value': [25.5, 30.0],
'timestamp': ['2023-01-01 12:00:00'] * 2,
}
).to_dict('records'),
'model_tags': {'sensor1': 'sensor1', 'sensor2': 'sensor2'},
'fill_missing_tags': False,
}
# Mock get to return None for new key
redis_activity.redis_repository.get = Mock(return_value=None)
redis_activity.redis_repository.set = Mock()
# Call the method
result = redis_activity.group_and_hold_data(test_data)
# Verify the result
expected_result = {
'timestamp': {0: '2023-01-01 12:00:00', 1: '2023-01-01 12:00:00'},
'variable': {0: 'sensor1', 1: 'sensor2'},
'value': {0: 25.5, 1: 30.0},
'model_id': {0: 1, 1: 1},
}
assert result == expected_result
# Verify set was called with correct arguments
redis_activity.redis_repository.set.assert_called_once_with(
'held_data_test_pipeline_test_schedule',
{'sensor1': 25.5, 'sensor2': 30.0, 'timestamp': '2023-01-01 12:00:00'},
ttl=3600,
metadata=metadata['metadata'],
)
def test_group_and_hold_data_update_existing_fill_missing(redis_activity):
"""Test updating existing data with group_and_hold_data"""
# Setup initial data in Redis
existing_data = {'sensor1': 20.0, 'sensor2': 28.0, 'timestamp': '2023-01-01 11:00:00'}
# New data to update with
test_data = {
**metadata,
'workflow_name': 'test_workflow',
'schedule_name': 'test_schedule',
'retention_time': 3600,
'model_id': 1,
'data': DataFrame(
{
'name': ['sensor1', 'sensor3'],
'value': [25.5, 42.0],
'timestamp': ['2023-01-01 12:00:00'] * 2,
}
).to_dict('records'),
'model_tags': {
'sensor1': 'sensor1',
'sensor2': 'sensor2',
'sensor3': 'sensor3',
'sensor4': 'sensor4',
},
'fill_missing_tags': True,
}
# Mock get to return existing data
redis_activity.redis_repository.get = Mock(return_value=existing_data)
redis_activity.redis_repository.set = Mock()
# Call the method
result = redis_activity.group_and_hold_data(test_data)
# Verify the result
expected_result = {
'timestamp': {
0: '2023-01-01 12:00:00',
1: '2023-01-01 12:00:00',
2: '2023-01-01 12:00:00',
3: '2023-01-01 12:00:00',
},
'variable': {0: 'sensor1', 1: 'sensor2', 2: 'sensor3', 3: 'sensor4'},
'value': {0: 25.5, 1: 28.0, 2: 42.0, 3: None},
'model_id': {0: 1, 1: 1, 2: 1, 3: 1},
}
assert result == expected_result
# Verify set was called with correct arguments
redis_activity.redis_repository.set.assert_called_once_with(
'held_data_test_workflow_test_schedule',
{
'sensor1': 25.5,
'sensor2': 28.0,
'sensor3': 42.0,
'sensor4': None,
'timestamp': '2023-01-01 12:00:00',
},
ttl=3600,
metadata=metadata['metadata'],
)
def test_group_and_hold_data_with_none_values(redis_activity):
"""Test handling of None values in group_and_hold_data"""
# Setup test data with None values
test_data = {
**metadata,
'workflow_name': 'test_workflow',
'schedule_name': 'test_schedule',
'retention_time': 3600,
'model_id': 1,
'data': DataFrame(
{
'name': ['sensor1', 'sensor2'],
'value': [None, 30.0],
'timestamp': [datetime(2023, 1, 1, 12, 0, 0)] * 2,
}
).to_dict('records'),
'model_tags': {'sensor1': 'sensor1', 'sensor2': 'sensor2'},
'fill_missing_tags': False,
}
# Mock get to return None for new key
redis_activity.redis_repository.get = Mock(return_value=None)
redis_activity.redis_repository.set = Mock()
# Call the method
result = redis_activity.group_and_hold_data(test_data)
# Verify None was converted to np.nan and values are as expected
assert np.isnan(result['value'][0])
assert result['value'][1] == pytest.approx(30.0)
def test_group_and_hold_data_empty_dataframe(redis_activity):
"""Test group_and_hold_data with empty DataFrame"""
# Setup test with empty data
test_data = {
**metadata,
'workflow_name': 'test_workflow',
'schedule_name': 'test_schedule',
'retention_time': 3600,
'data': DataFrame(columns=['name', 'value', 'timestamp']).to_dict('records'),
'model_tags': {'sensor1': 'sensor1', 'sensor2': 'sensor2'},
'fill_missing_tags': False,
}
redis_activity.redis_repository.get = Mock(return_value=None)
# Call the method
result = redis_activity.group_and_hold_data(test_data)
assert result == {}
def test_group_and_hold_data_error_get(redis_activity):
"""Test group_and_hold_data error"""
test_data = {
**metadata,
'workflow_name': 'test_workflow',
'schedule_name': 'test_schedule',
'retention_time': 3600,
'model_id': 1,
'data': DataFrame(columns=['name', 'value', 'timestamp']).to_dict('records'),
'model_tags': {'sensor1': 'sensor1', 'sensor2': 'sensor2'},
'fill_missing_tags': False,
}
redis_activity.redis_repository.get = Mock(side_effect=Exception('test'))
redis_activity.send_notification = Mock()
try:
redis_activity.group_and_hold_data(test_data)
except Exception as e:
assert str(e) == 'test'
redis_activity.send_notification.assert_called_once_with(
metadata=metadata['metadata'],
notification_id='REDIS_GET_ERROR',
message='Error getting held data: test',
block='group_and_hold_data',
level=NotificationLevel.ERROR,
attachment_content=ANY,
)
else:
raise AssertionError('Expected exception')
def test_group_and_hold_data_error_set(redis_activity):
"""Test group_and_hold_data error"""
test_data = {
**metadata,
'workflow_name': 'test_workflow',
'schedule_name': 'test_schedule',
'retention_time': 3600,
'model_id': 1,
'data': DataFrame(columns=['name', 'value', 'timestamp']).to_dict('records'),
'model_tags': {'sensor1': 'sensor1', 'sensor2': 'sensor2'},
'fill_missing_tags': False,
}
existing_data = {'sensor1': 20.0, 'sensor2': 28.0, 'timestamp': '2023-01-01 11:00:00'}
# Mock get to return existing data
redis_activity.redis_repository.get = Mock(return_value=existing_data)
redis_activity.redis_repository.set = Mock(side_effect=Exception('test'))
redis_activity.send_notification = Mock()
try:
redis_activity.group_and_hold_data(test_data)
except Exception as e:
assert str(e) == 'test'
def test_store_data_package(redis_activity):
"""Test store_data_package"""
redis_activity.redis_repository.set = Mock()
test_data = {
**metadata,
'workflow_name': 'test_workflow',
'schedule_name': 'test_schedule',
'held_data': DataFrame(
{
'name': ['sensor1', 'sensor2'],
'value': [25.5, 30.0],
'timestamp': ['2023-01-01 12:00:00'] * 2,
}
).to_dict(),
'data': DataFrame(
{
'name': ['sensor1', 'sensor2'],
'value': [25.5, 30.0],
'timestamp': ['2023-01-01 12:00:00'] * 2,
}
).to_dict(),
'model_tags': {'sensor1': 'sensor1', 'sensor2': 'sensor2'},
}
redis_activity.store_data_package(test_data)
redis_activity.redis_repository.set.assert_called_once_with(
ANY,
{'data': test_data['data'], 'held_data': test_data['held_data']},
ttl=120,
metadata=metadata['metadata'],
)
def test_store_data_package_error(redis_activity):
"""Test store_data_package error"""
redis_activity.redis_repository.set = Mock(side_effect=ValueError('test'))
redis_activity.send_notification = Mock()
test_data = {
**metadata,
'workflow_name': 'test_workflow',
'schedule_name': 'test_schedule',
'held_data': DataFrame(
{
'name': ['sensor1', 'sensor2'],
'value': [25.5, 30.0],
'timestamp': ['2023-01-01 12:00:00'] * 2,
}
).to_dict(),
'data': DataFrame(
{
'name': ['sensor1', 'sensor2'],
'value': [25.5, 30.0],
'timestamp': ['2023-01-01 12:00:00'] * 2,
}
).to_dict(),
'model_tags': {'sensor1': 'sensor1', 'sensor2': 'sensor2'},
}
with pytest.raises(ValueError):
redis_activity.store_data_package(test_data)
redis_activity.send_notification.assert_called_once_with(
metadata=metadata['metadata'],
notification_id='REDIS_SET_ERROR',
message='Error setting data package: test',
block='store_data_package',
level=NotificationLevel.ERROR,
attachment_content=ANY,
)

32
tests/test_metrics.py Normal file
View File

@@ -0,0 +1,32 @@
# tests/unit/test_metrics.py
from prometheus_client import Counter, Gauge
import scouter.metrics as metrics
# --- Test Functions for Each Metric (Corrected for v0.22.0 _name behavior) ---
def test_scouter_laborious_data_written_count():
"""Verify the definition of LABORIOUS_DATA_WRITTEN_COUNT."""
assert metrics.LABORIOUS_DATA_WRITTEN_COUNT is not None
assert isinstance(metrics.LABORIOUS_DATA_WRITTEN_COUNT, Counter)
assert metrics.LABORIOUS_DATA_WRITTEN_COUNT._name == 'scouter_laborious_data_written_count'
assert set(metrics.LABORIOUS_DATA_WRITTEN_COUNT._labelnames) == {
'pod_id',
'model_name',
'workflow_name',
}
def test_scouter_tag_changes_monitor():
"""Verify the definition of TAG_CHANGES_MONITOR."""
assert metrics.TAG_CHANGES_MONITOR is not None
assert isinstance(metrics.TAG_CHANGES_MONITOR, Gauge)
assert metrics.TAG_CHANGES_MONITOR._name == 'scouter_tag_changes_monitor'
assert set(metrics.TAG_CHANGES_MONITOR._labelnames) == {
'pod_id',
'model_name',
'workflow_name',
'tag_name',
}

0
tests/utils/__init__.py Normal file
View File

View File

@@ -0,0 +1,139 @@
import numpy as np
import pandas as pd
import pytest
from pandas.testing import assert_frame_equal
from scouter.utils.quality.filters import check_data_range, null_values_filter, out_of_bounds_filter
# Fixtures
@pytest.fixture
def sample_dataframe():
"""Fixture providing a sample DataFrame for testing."""
return pd.DataFrame(
{
'tag': ['temp', 'temp', 'pressure', 'pressure', 'humidity', 'wind_speed'],
'name': ['temp', 'temp', 'pressure', 'pressure', 'humidity', 'wind_speed'],
'value': [25, 35, 95, 105, 60, None],
'timestamp': pd.date_range(start='2023-01-01', periods=6),
}
)
@pytest.fixture
def nodes_data_range():
"""Fixture providing data ranges for different tags."""
return {
'temp': {'data_range': [10, 30]},
'pressure': {'data_range': [90, 100]},
'humidity': {'data_range': [40, 80]},
'wind_speed': {'data_range': [0, 50]},
}
# Parameterized test data
CHECK_DATA_RANGE_CASES = [
# (value, val_range, expected)
# Values within range
(5, [0, 10], False),
(0, [0, 10], False), # Edge case: value equals lower bound
(10, [0, 10], False), # Edge case: value equals upper bound
# Values outside range
(-1, [0, 10], True),
(11, [0, 10], True),
# Single value range
(5, [5, 5], False),
(4, [5, 5], True),
# Empty or None value
(None, [0, 10], True),
(np.nan, [0, 10], True),
]
# Tests for check_data_range
@pytest.mark.parametrize('value,val_range,expected', CHECK_DATA_RANGE_CASES)
def test_check_data_range(value, val_range, expected):
"""Test the check_data_range function with various input scenarios."""
result = check_data_range(value, val_range)
if isinstance(value, float) and np.isnan(value):
assert result is True
else:
assert result == expected
# Tests for out_of_bounds_filter
def test_out_of_bounds_filter(sample_dataframe, nodes_data_range):
"""Test filtering out-of-bounds values from a DataFrame."""
# Expected result: rows where value is outside the defined range
expected_data = {
'tag': ['temp', 'pressure', 'wind_speed'],
'name': ['temp', 'pressure', 'wind_speed'],
'value': [35, 105, None],
'timestamp': [
pd.Timestamp('2023-01-02'),
pd.Timestamp('2023-01-04'),
pd.Timestamp('2023-01-06'),
],
}
expected_df = pd.DataFrame(expected_data)
result = out_of_bounds_filter(sample_dataframe, nodes_data_range)
result = result.reset_index(drop=True)
expected_df = expected_df.reset_index(drop=True)
assert_frame_equal(result, expected_df)
def test_out_of_bounds_filter_empty_df(nodes_data_range):
"""Test with an empty DataFrame."""
df = pd.DataFrame(columns=['tag', 'name', 'value', 'timestamp'])
result = out_of_bounds_filter(df, nodes_data_range)
assert result.empty
assert list(result.columns) == ['tag', 'name', 'value', 'timestamp']
# Tests for null_values_filter
def test_null_values_filter(sample_dataframe, nodes_data_range):
"""Test filtering null values from a DataFrame."""
expected_data = {
'tag': ['wind_speed'],
'name': ['wind_speed'],
'value': [np.nan],
'timestamp': [pd.Timestamp('2023-01-06')],
}
expected_df = pd.DataFrame(expected_data)
result = null_values_filter(sample_dataframe, nodes_data_range)
result = result.reset_index(drop=True)
expected_df = expected_df.reset_index(drop=True)
assert_frame_equal(result, expected_df, check_dtype=False)
def test_null_values_filter_no_nulls(nodes_data_range):
"""Test with a DataFrame containing no null values."""
df = pd.DataFrame(
{
'tag': ['temp', 'pressure'],
'name': ['temp', 'pressure'],
'value': [25, 100],
'timestamp': pd.date_range(start='2023-01-01', periods=2),
}
)
result = null_values_filter(df, nodes_data_range)
assert result.empty
assert list(result.columns) == ['tag', 'name', 'value', 'timestamp']
def test_null_values_filter_empty_df(nodes_data_range):
"""Test with an empty DataFrame."""
df = pd.DataFrame(columns=['tag', 'name', 'value', 'timestamp'])
result = null_values_filter(df, nodes_data_range)
assert result.empty
assert list(result.columns) == ['tag', 'name', 'value', 'timestamp']

View File

@@ -0,0 +1,42 @@
import os
from unittest.mock import patch
import pytest
from scouter.utils.connectors_config import (
build_kafka_config,
)
@pytest.fixture
def mock_env_vars():
with patch.dict(os.environ, {}, clear=True):
yield
@pytest.mark.usefixtures('mock_env_vars')
def test_build_kafka_config_defaults():
"""Test that build_kafka_config returns default values when no env vars are set"""
config = build_kafka_config()
assert config == {
'bootstrap_servers': 'localhost:9092',
'polling_time': 1000,
'group_id': 'scouter-group',
}
@pytest.mark.usefixtures('mock_env_vars')
def test_build_kafka_config_with_env_vars():
"""Test that build_kafka_config uses env vars when set"""
with patch.dict(
os.environ,
{'KAFKA_BOOTSTRAP_SERVERS': 'kafka.example.com:9092', 'KAFKA_POLLING_TIME': '5000'},
):
config = build_kafka_config()
assert config == {
'bootstrap_servers': 'kafka.example.com:9092',
'polling_time': 5000,
'group_id': 'scouter-group',
}

0
tests/worker/__init__.py Normal file
View File

View File

View File

@@ -0,0 +1,403 @@
from unittest.mock import ANY, AsyncMock, call, patch
import pytest
from sientia_do.temporal.constants import DATETIME_FORMAT_WITH_TZ
from scouter.activities.activities import Activities
from scouter.workflow.sub_workflows.core_scouter import CoreScouter
@pytest.fixture
def core_scouter():
return CoreScouter()
@pytest.mark.asyncio
@patch('scouter.workflow.sub_workflows.core_scouter.workflow', new_callable=AsyncMock)
async def test_core_scouter_workflow_success(mock_workflow, core_scouter):
mock_workflow.execute_local_activity_method.side_effect = [
'filtered_data',
'grouped_data',
'held_data',
]
mock_workflow.execute_activity_method.side_effect = [
{'affected_rows': 10}, # export_data_to_postgres
None, # write_metrics
None, # store_data_package
]
await core_scouter.run(
input_data={
'metadata': {
'metadata': {
'model_id': 'test_model_id',
'model_name': 'test_model',
'schedule_name': 'test_schedule',
'workflow_name': 'test_workflow',
}
},
'workflow_name': 'test_workflow',
'schedule_name': 'test_schedule',
'model_name': 'test_model',
'model_id': 'test_model_id',
'data': 'test_data',
'trigger_laborious': False,
'filters': {'test_filter': 'test_value'},
'schema': 'test_schema',
'table_name': 'test_table',
'retention_time': 3600,
'model_tags': {},
'debug_data_package': True,
'fill_missing_tags': False,
}
)
expected_metadata = {
'metadata': {
'model_id': 'test_model_id',
'model_name': 'test_model',
'schedule_name': 'test_schedule',
'workflow_name': 'test_workflow',
}
}
mock_workflow.execute_local_activity_method.assert_has_calls(
[
call(
Activities.data_quality_gate,
{
**expected_metadata,
'filters': {'test_filter': 'test_value'},
'data': 'test_data',
'model_tags': {},
},
retry_policy=ANY,
start_to_close_timeout=ANY,
)
]
)
mock_workflow.execute_local_activity_method.assert_has_calls(
[
call(
Activities.aggregate_data,
{**expected_metadata, 'data': 'filtered_data', 'model_tags': {}},
retry_policy=ANY,
start_to_close_timeout=ANY,
)
]
)
mock_workflow.execute_local_activity_method.assert_has_calls(
[
call(
Activities.group_and_hold_data,
{
**expected_metadata,
'workflow_name': 'test_workflow',
'schedule_name': 'test_schedule',
'data': 'grouped_data',
'model_id': 'test_model_id',
'retention_time': 3600,
'model_tags': {},
'fill_missing_tags': False,
},
retry_policy=ANY,
start_to_close_timeout=ANY,
)
]
)
mock_workflow.execute_activity_method.assert_has_calls(
[
call(
Activities.export_data_to_postgres,
{
**expected_metadata,
'schema': 'test_schema',
'table_name': 'test_table',
'data': 'held_data',
'on_conflict': 'ignore',
'unique_columns': ['model_id', 'timestamp', 'variable'],
'timestamp_conversion': {
'column': 'timestamp',
'format': DATETIME_FORMAT_WITH_TZ,
},
},
retry_policy=ANY,
start_to_close_timeout=ANY,
)
]
)
mock_workflow.execute_activity_method.assert_has_calls(
[
call(
Activities.write_metrics,
{
**expected_metadata,
'tag_values': 'held_data',
},
retry_policy=ANY,
start_to_close_timeout=ANY,
)
]
)
mock_workflow.execute_activity_method.assert_has_calls(
[
call(
Activities.store_data_package,
{
**expected_metadata,
'workflow_name': 'test_workflow',
'schedule_name': 'test_schedule',
'held_data': 'held_data',
'data': 'test_data',
},
retry_policy=ANY,
start_to_close_timeout=ANY,
)
]
)
@pytest.mark.asyncio
@patch('scouter.workflow.sub_workflows.core_scouter.workflow', new_callable=AsyncMock)
async def test_core_scouter_workflow_with_empty_data(mock_workflow, core_scouter):
mock_workflow.execute_local_activity_method.return_value = {}
await core_scouter.run(
input_data={
'metadata': {
'metadata': {
'model_id': 'test_model_id',
'model_name': 'test_model',
'schedule_name': 'test_schedule',
'workflow_name': 'test_workflow',
}
},
'workflow_name': 'test_workflow',
'schedule_name': 'test_schedule',
'model_name': 'test_model',
'model_id': 'test_model_id',
'data': 'test_data',
'trigger_laborious': False,
'filters': {'test_filter': 'test_value'},
'schema': 'test_schema',
'table_name': 'test_table',
'retention_time': 3600,
'model_tags': {},
'fill_missing_tags': False,
}
)
expected_metadata = {
'metadata': {
'model_id': 'test_model_id',
'model_name': 'test_model',
'schedule_name': 'test_schedule',
'workflow_name': 'test_workflow',
}
}
mock_workflow.execute_local_activity_method.assert_has_calls(
[
call(
Activities.data_quality_gate,
{
**expected_metadata,
'filters': {'test_filter': 'test_value'},
'data': 'test_data',
'model_tags': {},
},
retry_policy=ANY,
start_to_close_timeout=ANY,
)
]
)
mock_workflow.execute_local_activity_method.assert_has_calls(
[
call(
Activities.aggregate_data,
{**expected_metadata, 'data': {}, 'model_tags': {}},
retry_policy=ANY,
start_to_close_timeout=ANY,
)
]
)
mock_workflow.execute_local_activity_method.assert_has_calls(
[
call(
Activities.group_and_hold_data,
{
**expected_metadata,
'workflow_name': 'test_workflow',
'schedule_name': 'test_schedule',
'data': {},
'model_id': 'test_model_id',
'retention_time': 3600,
'model_tags': {},
'fill_missing_tags': False,
},
retry_policy=ANY,
start_to_close_timeout=ANY,
)
]
)
assert mock_workflow.execute_local_activity_method.call_count == 3
mock_workflow.execute_activity_method.assert_not_called()
@pytest.mark.asyncio
@patch('scouter.workflow.sub_workflows.core_scouter.workflow', new_callable=AsyncMock)
async def test_core_scouter_workflow_with_zero_affected_rows(mock_workflow, core_scouter):
"""
Test that workflow stops after export when no rows are affected
"""
mock_workflow.execute_local_activity_method.side_effect = [
'filtered_data',
'grouped_data',
'held_data',
]
mock_workflow.execute_activity_method.return_value = {'affected_rows': 0}
await core_scouter.run(
input_data={
'metadata': {
'metadata': {
'model_id': 'test_model_id',
'model_name': 'test_model',
'schedule_name': 'test_schedule',
'workflow_name': 'test_workflow',
}
},
'workflow_name': 'test_workflow',
'schedule_name': 'test_schedule',
'model_name': 'test_model',
'model_id': 'test_model_id',
'data': 'test_data',
'trigger_laborious': False,
'filters': {'test_filter': 'test_value'},
'schema': 'test_schema',
'table_name': 'test_table',
'retention_time': 3600,
'model_tags': {},
'debug_data_package': True,
'fill_missing_tags': False,
}
)
expected_metadata = {
'metadata': {
'model_id': 'test_model_id',
'model_name': 'test_model',
'schedule_name': 'test_schedule',
'workflow_name': 'test_workflow',
}
}
mock_workflow.execute_activity_method.assert_called_once_with(
Activities.export_data_to_postgres,
{
**expected_metadata,
'schema': 'test_schema',
'table_name': 'test_table',
'data': 'held_data',
'on_conflict': 'ignore',
'unique_columns': ['model_id', 'timestamp', 'variable'],
'timestamp_conversion': {
'column': 'timestamp',
'format': DATETIME_FORMAT_WITH_TZ,
},
},
retry_policy=ANY,
start_to_close_timeout=ANY,
)
@pytest.mark.asyncio
@patch('scouter.workflow.sub_workflows.core_scouter.workflow', new_callable=AsyncMock)
async def test_core_scouter_workflow_without_debug_data_package(mock_workflow, core_scouter):
"""
Test that store_data_package is not called when debug_data_package is False
"""
mock_workflow.execute_local_activity_method.side_effect = [
'filtered_data',
'grouped_data',
'held_data',
]
mock_workflow.execute_activity_method.side_effect = [
{'affected_rows': 5},
None,
]
await core_scouter.run(
input_data={
'metadata': {
'metadata': {
'model_id': 'test_model_id',
'model_name': 'test_model',
'schedule_name': 'test_schedule',
'workflow_name': 'test_workflow',
}
},
'workflow_name': 'test_workflow',
'schedule_name': 'test_schedule',
'model_name': 'test_model',
'model_id': 'test_model_id',
'data': 'test_data',
'trigger_laborious': False,
'filters': {'test_filter': 'test_value'},
'schema': 'test_schema',
'table_name': 'test_table',
'retention_time': 3600,
'model_tags': {},
'debug_data_package': False,
'fill_missing_tags': False,
}
)
expected_metadata = {
'metadata': {
'model_id': 'test_model_id',
'model_name': 'test_model',
'schedule_name': 'test_schedule',
'workflow_name': 'test_workflow',
}
}
mock_workflow.execute_activity_method.assert_has_calls(
[
call(
Activities.export_data_to_postgres,
{
**expected_metadata,
'schema': 'test_schema',
'table_name': 'test_table',
'data': 'held_data',
'on_conflict': 'ignore',
'unique_columns': ['model_id', 'timestamp', 'variable'],
'timestamp_conversion': {
'column': 'timestamp',
'format': DATETIME_FORMAT_WITH_TZ,
},
},
retry_policy=ANY,
start_to_close_timeout=ANY,
)
]
)
mock_workflow.execute_activity_method.assert_has_calls(
[
call(
Activities.write_metrics,
{
**expected_metadata,
'tag_values': 'held_data',
},
retry_policy=ANY,
start_to_close_timeout=ANY,
)
]
)
assert mock_workflow.execute_activity_method.call_count == 2

View File

@@ -0,0 +1,133 @@
from unittest.mock import ANY, AsyncMock, patch
from pytest import fixture, mark
from scouter.activities.activities import Activities
from scouter.workflow.pi_web_api_scouter import PIWebAPIScouter
@fixture
def pi_web_api_scouter():
return PIWebAPIScouter()
@mark.asyncio
@patch('scouter.workflow.pi_web_api_scouter.workflow', new_callable=AsyncMock)
async def test_pi_web_api_scouter_workflow(mock_workflow, pi_web_api_scouter):
mock_workflow.execute_local_activity_method.return_value = 'test_data'
await pi_web_api_scouter.run(
input_data={
'model_name': 'test_model',
'model_id': 'test_model_id',
'schedule_name': 'test_schedule',
'model_tags': {'tag1': 'webid1', 'tag2': 'webid2'},
'trigger_laborious': True,
'filters': {'quality': 'good'},
'schema': 'test_schema',
'table_name': 'test_table',
'retention_time': 3600,
'pi_web_api_query': {
'endpoint': '/streamsets/recorded',
'period': '*-1d',
'max_count': 10,
'api_timeout': 30,
},
}
)
expected_metadata = {
'metadata': {
'model_id': 'test_model_id',
'model_name': 'test_model',
'schedule_name': 'test_schedule',
'workflow_name': 'pi_web_api_scouter',
}
}
mock_workflow.execute_local_activity_method.assert_called_once_with(
Activities.get_tag_values,
{
**expected_metadata,
'endpoint': '/streamsets/recorded',
'web_ids': {'tag1': 'webid1', 'tag2': 'webid2'},
'period': '*-1d',
'api_timeout': 30,
'max_count': 10,
},
start_to_close_timeout=ANY,
retry_policy=ANY,
)
mock_workflow.execute_child_workflow.assert_called_once_with(
'subworkflow.core_scouter',
{
'model_name': 'test_model',
'model_id': 'test_model_id',
'schedule_name': 'test_schedule',
'model_tags': {'tag1': 'webid1', 'tag2': 'webid2'},
'trigger_laborious': True,
'filters': {'quality': 'good'},
'schema': 'test_schema',
'table_name': 'test_table',
'retention_time': 3600,
'workflow_name': 'pi_web_api_scouter',
'data': 'test_data',
'metadata': expected_metadata,
'pi_web_api_query': {
'endpoint': '/streamsets/recorded',
'period': '*-1d',
'max_count': 10,
'api_timeout': 30,
},
},
)
@mark.asyncio
@patch('scouter.workflow.pi_web_api_scouter.workflow', new_callable=AsyncMock)
async def test_pi_web_api_scouter_workflow_empty(mock_workflow, pi_web_api_scouter):
mock_workflow.execute_local_activity_method.return_value = []
await pi_web_api_scouter.run(
input_data={
'model_name': 'test_model',
'model_id': 'test_model_id',
'schedule_name': 'test_schedule',
'model_tags': {'tag1': 'webid1', 'tag2': 'webid2'},
'trigger_laborious': True,
'filters': {'quality': 'good'},
'schema': 'test_schema',
'table_name': 'test_table',
'retention_time': 3600,
'pi_web_api_query': {
'endpoint': '/streamsets/recorded',
'period': '*-1d',
'max_count': 1,
'api_timeout': 30,
},
}
)
expected_metadata = {
'metadata': {
'model_id': 'test_model_id',
'model_name': 'test_model',
'schedule_name': 'test_schedule',
'workflow_name': 'pi_web_api_scouter',
}
}
mock_workflow.execute_local_activity_method.assert_called_once_with(
Activities.get_tag_values,
{
**expected_metadata,
'web_ids': {'tag1': 'webid1', 'tag2': 'webid2'},
'period': '*-1d',
'api_timeout': 30,
'max_count': 1,
'endpoint': '/streamsets/recorded',
},
start_to_close_timeout=ANY,
retry_policy=ANY,
)
mock_workflow.execute_child_workflow.assert_not_called()

View File

@@ -0,0 +1,129 @@
from unittest.mock import ANY, AsyncMock, call, patch
from pytest import fixture, mark
from scouter.activities.activities import Activities
from scouter.workflow.scouter import Scouter
@fixture
def scouter():
return Scouter()
@mark.asyncio
@patch('scouter.workflow.scouter.workflow', new_callable=AsyncMock)
async def test_scouter_workflow(mock_workflow, scouter):
mock_workflow.execute_local_activity_method.side_effect = [
'test_last_data_timestamp',
'test_data',
]
await scouter.run(
input_data={
'topic': 'test_topic',
'schedule_name': 'test_schedule',
'model_name': 'test_model',
'model_id': 'test_model_id',
}
)
expected_metadata = {
'metadata': {
'model_id': 'test_model_id',
'model_name': 'test_model',
'schedule_name': 'test_schedule',
'workflow_name': 'scouter',
}
}
mock_workflow.execute_local_activity_method.assert_has_calls(
[
call(
Activities.get_last_data_timestamp,
{**expected_metadata, 'workflow_name': 'scouter', 'schedule_name': 'test_schedule'},
retry_policy=ANY,
start_to_close_timeout=ANY,
)
]
)
mock_workflow.execute_local_activity_method.assert_has_calls(
[
call(
Activities.load_latest_data,
{
**expected_metadata,
'collection_name': 'raw_test_schedule',
'last_data_timestamp': 'test_last_data_timestamp',
},
retry_policy=ANY,
start_to_close_timeout=ANY,
)
]
)
mock_workflow.execute_activity_method.assert_called_once_with(
Activities.put_last_data_timestamp,
{
**expected_metadata,
'data': 'test_data',
'workflow_name': 'scouter',
'schedule_name': 'test_schedule',
},
retry_policy=ANY,
start_to_close_timeout=ANY,
)
mock_workflow.execute_child_workflow.assert_called_once_with(
'subworkflow.core_scouter',
{
'metadata': expected_metadata,
'topic': 'test_topic',
'data': 'test_data',
'workflow_name': 'scouter',
'schedule_name': 'test_schedule',
'model_name': 'test_model',
'model_id': 'test_model_id',
},
)
@mark.asyncio
@patch('scouter.workflow.scouter.workflow', new_callable=AsyncMock)
async def test_scouter_workflow_empty(mock_workflow, scouter):
mock_workflow.execute_local_activity_method.side_effect = ['test_last_data_timestamp', {}]
await scouter.run(
input_data={
'topic': 'test_topic',
'schedule_name': 'test_schedule',
'model_name': 'test_model',
'model_id': 'test_model_id',
}
)
expected_metadata = {
'metadata': {
'model_id': 'test_model_id',
'model_name': 'test_model',
'schedule_name': 'test_schedule',
'workflow_name': 'scouter',
}
}
mock_workflow.execute_local_activity_method.assert_has_calls(
[
call(
Activities.load_latest_data,
{
**expected_metadata,
'collection_name': 'raw_test_schedule',
'last_data_timestamp': 'test_last_data_timestamp',
},
retry_policy=ANY,
start_to_close_timeout=ANY,
)
]
)
mock_workflow.execute_activity_method.assert_not_called()
mock_workflow.execute_child_workflow.assert_not_called()