commit 80cc116f2a2421e092e480477ae82a5b29d8cd01 Author: gitea_admin Date: Sun Jun 28 03:02:58 2026 +0000 Code import - branch 0.5.0 diff --git a/.env b/.env new file mode 100644 index 0000000..ae4d8e4 --- /dev/null +++ b/.env @@ -0,0 +1,36 @@ +POSTGRES_HOST="db.sientia.ai" +POSTGRES_PORT="30432" +POSTGRES_USER="postgres" +POSTGRES_PASSWORD="nFqc81y6kwmr2zuAIx43DhiOosFCVPpeEfTtTWZflkNjB2j1KtEeIANkhFR9mAX3" +POSTGRES_DBNAME="sientia" +POSTGRES_MIN_CONNECTIONS="10" +POSTGRES_MAX_CONNECTIONS="30" + +MLFLOW_HOST="https://tracking.sientia.ai" +MLFLOW_PORT="80" +MLFLOW_USERNAME="aignosi" +MLFLOW_PASSWORD="1L0FP50j3ncp123" + +OPC_ID="1" +OPC_URL="opc.tcp://sientia-opc-simulator-opc.sientia.svc.cluster.local:4840" + +LOG_LEVEL="DEBUG" +HTTP_METRICS_PORT="9090" +HTTP_SDK_METRICS_PORT="9091" +PROJECT_NAME="sientia-laborious" + +TEMPORAL_HOST="orchestrator.sientia.ai" +TEMPORAL_NAMESPACE="laborious" +TEMPORAL_USE_TLS="true" + +MONGODB_USERNAME="root" +MONGODB_PASSWORD="wKZDbMNU1c" +MONGODB_URL="db.sientia.ai:32017" +MONGODB_DATABASE="sientia" +MONGODB_TTL_INDEX_HOURS="1" + +MINIO_ENDPOINT_URL="storage.sientia.ai" +MINIO_ACCESS_KEY="admin" +MINIO_SECRET_KEY="LiArt4eNmJ" +MINIO_DEFAULT_BUCKET="sientia" +MINIO_SECURE="true" diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6e621e9 --- /dev/null +++ b/.env.example @@ -0,0 +1,35 @@ +POSTGRES_HOST="paradedb-rw.paradedb.svc.cluster.local" +POSTGRES_PORT="5432" +POSTGRES_USER="sientia" +POSTGRES_PASSWORD="password" +POSTGRES_DBNAME="sientia" +POSTGRES_MIN_CONNECTIONS="10" +POSTGRES_MAX_CONNECTIONS="30" + +MLFLOW_HOST="http://sientia-tracker-mlflow-tracking.sientia-tracker.svc.cluster.local" +MLFLOW_PORT="80" +MLFLOW_USERNAME="aignosi" +MLFLOW_PASSWORD="mlflow_password" + +OPC_ID="1" +OPC_URL="opc.tcp://sientia-opc-simulator-opc.sientia.svc.cluster.local:4840" + +LOG_LEVEL="DEBUG" +HTTP_METRICS_PORT="9090" +HTTP_SDK_METRICS_PORT="9091" +PROJECT_NAME="sientia-laborious" + +TEMPORAL_HOST="temporal-frontend.temporal.svc.cluster.local:7233" +TEMPORAL_NAMESPACE="laborious" + +MONGODB_USERNAME="mongo_user" +MONGODB_PASSWORD="mongo_db_password" +MONGODB_URL="my-release-mongodb.mongodb.svc.cluster.local:27017" +MONGODB_DATABASE="sientia" +MONGODB_TTL_INDEX_HOURS="1" + +MINIO_ENDPOINT_URL="http://localhost:9000" +MINIO_ACCESS_KEY="sientia" +MINIO_SECRET_KEY="sientia" +MINIO_REGION_NAME="sa-east-1" +MINIO_DEFAULT_BUCKET="sientia" \ No newline at end of file diff --git a/.github/workflows/quality-gate.yml b/.github/workflows/quality-gate.yml new file mode 100644 index 0000000..79249d6 --- /dev/null +++ b/.github/workflows/quality-gate.yml @@ -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/python-quality-gate.yml@main + permissions: write-all + with: + project_name: 'laborious' + repositories: 'sientia-dataops-library, sientia-mlops-library' + requirements_file: 'requirements-light.txt' + secrets: inherit \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e051ea9 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,25 @@ +name: Create Release on Merge to Main + +on: + pull_request: + types: [closed] + branches: + - main + workflow_dispatch: + inputs: + version: + description: 'Version to release' + required: false + type: string + +jobs: + release: + if: | + (github.event_name == 'pull_request' && github.event.pull_request.merged == true) || + github.event_name == 'workflow_dispatch' + uses: Aignosi/github_workflow_templates/.github/workflows/dataops-module-release.yml@main + permissions: write-all + with: + project_name: 'laborious' + release_version: ${{ github.event.inputs.version || '' }} + secrets: inherit \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c849e0a --- /dev/null +++ b/.gitignore @@ -0,0 +1,58 @@ +# 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 keys +git_key* + +git_log + +.env + +tmp/ +catboost_info/ + +.ruff_cache/ +.mypy_cache/ +mlruns/ + +relatorio* +openspec/ +.cursor/ \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..66aae81 --- /dev/null +++ b/Makefile @@ -0,0 +1,7 @@ +VERSION = 1.0.8 +name = sientia-laborious +# ENVIRONMENT = production + +docker-hub: + @docker build --no-cache -t aignosi.azurecr.io/$(name):$(VERSION) . + @docker push aignosi.azurecr.io/$(name):$(VERSION) \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..0b5c844 --- /dev/null +++ b/README.md @@ -0,0 +1,1223 @@ +# Sientia DataOps Laborious + +A comprehensive, Temporal-based ML orchestration system for industrial data processing and model inference. Laborious delivers enterprise-grade batch prediction, model management, optional real-time export (OPC and PI Web API), and automated retraining with strong data quality validation and observability. + + +## πŸ“‘ Table of Contents + +- [Features](#features) + - [Core Functionality](#core-functionality) + - [Advanced Capabilities](#advanced-capabilities) + - [Development & Quality Assurance](#development--quality-assurance) +- [Architecture](#architecture) + - [Architecture Principles](#architecture-principles) + - [Key Components](#key-components) + - [Data Flow Architecture](#data-flow-architecture) + - [Security Architecture](#security-architecture) +- [Workflows](#workflows) + - [Predictions Batch Workflow](#1-predictions-batch-workflow-predictions_batchpy) + - [Prediction Process Workflow](#2-prediction-process-workflow-prediction_processpy) + - [Format and Export Prediction Workflow](#3-format-and-export-prediction-workflow-format_and_export_predictionpy) + - [Minimal Retrain Workflow](#4-minimal-retrain-workflow-minimal_retrainpy) + - [Drift Workflow](#5-drift-workflow-driftpy) + - [Simple Metrics Workflow](#6-simple-metrics-workflow-simple_metricspy) +- [Installation & Setup](#installation--setup) + - [Prerequisites](#prerequisites) + - [Environment Setup](#environment-setup) + - [Temporal Namespace Setup](#temporal-namespace-setup) + - [Local Development Setup](#local-development-setup) +- [How to Run](#how-to-run) + - [Running the Laborious Application](#running-the-laborious-application) + - [Running Tests and Coverage](#running-tests-and-coverage) + - [Manual Test Execution](#manual-test-execution) + - [Manual Application Execution](#manual-application-execution) +- [Code Quality & Validation](#code-quality--validation) + - [Overview](#overview) + - [Validation Tools](#validation-tools) + - [Tools Installation](#tools-installation) + - [Complete Validation](#complete-validation) + - [Automatic Fixes](#automatic-fixes) + - [Configuration](#configuration) + - [CI/CD Integration](#cicd-integration) + - [Best Practices](#best-practices) +- [Testing](#testing) + - [Test Structure](#test-structure) + - [Test Execution](#test-execution) +- [Monitoring and Metrics](#monitoring-and-metrics) + - [Application Health Metrics](#application-health-metrics) + - [Prediction Operation Metrics](#prediction-operation-metrics) + - [OPC Export Metrics](#opc-export-metrics) + - [Data Quality Metrics](#data-quality-metrics) +- [OPC UA Communication](#opc-ua-communication) +- [Configuration](#configuration-1) + - [Environment Variables](#environment-variables) + - [OPC Configuration](#opc-configuration) + - [Workflow Configuration](#workflow-configuration) +- [Development](#development) + - [Project Structure](#project-structure) + - [Adding New Features](#adding-new-features) +- [Troubleshooting](#troubleshooting) + - [Common Issues](#common-issues) + - [Debug Mode](#debug-mode) +- [Performance Tuning](#performance-tuning) + - [Key Parameters](#key-parameters) + - [Scaling Considerations](#scaling-considerations) +- [Contributing](#contributing) + - [Code Quality Standards](#code-quality-standards) +- [License](#license) +- [Support](#support) + +## Features + +### Core Functionality +- **Batch Prediction Processing**: High-throughput ML inference using MLFlow models +- **Temporal Workflow Orchestration**: Robust workflow management with retries and fault tolerance +- **Data Quality Gates**: Configurable filtering for input data and MLFlow API responses +- **Multi-Model Support**: Flexible model management with retention and versioning +- **Optional Real-time Export**: PostgreSQL persistence, OPC server integration, and PI Web API integration for industrial systems +- **Comprehensive Monitoring**: Prometheus metrics and structured logging for observability + +### Advanced Capabilities +- **Incremental Data Processing**: Timestamp-based loading to avoid reprocessing +- **Configurable Data Retention**: Model retention policies with automatic cleanup +- **MinIO Payload Offload**: Automatic offload of large DataFrames to MinIO with retention cleanup +- **Data Drift Detection**: Univariate and multivariate drift monitoring against reference data +- **Regression Metrics**: Automated RMSE, MSE, MAE, RΒ² calculation and export +- **Notification System**: Integrated alerting via MongoDB +- **Scalable Architecture**: Kubernetes-ready with horizontal scaling +- **Model Retraining**: Automated retraining workflows with production model updates + +### Development & Quality Assurance +- **Code Quality Tools**: Ruff (lint/format), mypy (types), Bandit (security) +- **Automated Validation**: CI quality gates and individual tool commands +- **Comprehensive Testing**: pytest with async support and high coverage +- **Type Safety**: Static type checking with mypy +- **Coverage Visualization**: Coverage Gutters integration + +## Architecture + +Laborious uses a Temporal-based architecture with strong separation of concerns and defensive error handling for production ML. + +### Architecture Principles + +#### 1. **Separation of Concerns** +- **Worker Layer**: Temporal workers, task queues, lifecycle +- **Workflow Layer**: Business orchestration and coordination +- **Activity Layer**: External system interactions and isolated operations +- **Data Layer**: Persistence, caching, connectors + +#### 2. **Fault Tolerance & Resilience** +- **Automatic Retry Policies** for transient failures +- **Graceful Degradation** and circuit breaking for dependencies +- **Detailed Error Handling** with notifications + +#### 3. **Scalability & Performance** +- **Horizontal Scaling**: Multiple worker instances for load distribution +- **Task Queue Isolation**: Separate queues for different workflow types +- **Connection Pooling**: Optimized database and external service connections +- **Asynchronous Processing**: Non-blocking operations for improved throughput + +#### 4. **Observability & Monitoring** +- **Prometheus Metrics**: Comprehensive system and business metrics +- **Structured Logging**: Consistent log format with correlation IDs +- **Health Checks**: Endpoint health monitoring and alerting +- **Performance Tracing**: Request flow tracking and bottleneck identification + +### Key Components + +#### **Worker (`laborious/worker/worker.py`)** +- Temporal client setup, four workers via `sientia_do.temporal.worker.prepare_worker` +- Runtime-scoped task queues: `{workflow}-{RUNTIME}-queue` for all workflows +- Metrics server initialization, notification handler setup +- Graceful shutdown and autoscaling-friendly behavior + +**Breaking (schedulers):** drift and simple_metrics queues are no longer `drift-queue` / +`simple_metrics-queue`. Use `drift-{RUNTIME}-queue` and `simple_metrics-{RUNTIME}-queue` +matching the worker pod `RUNTIME` env (same as `predictions_batch` / `minimal_retrain`). + +#### **Workflows (`laborious/workflows/`)** +- `predictions_batch.py`: Batch prediction entry point +- `sub_workflows/prediction_process.py`: Core prediction pipeline +- `sub_workflows/format_and_export_prediction.py`: Formatting and export +- `minimal_retrain.py`: Automated model retraining and production update +- `drift.py`: Data drift detection and monitoring +- `simple_metrics.py`: Regression metrics calculation (RMSE, MSE, MAE, RΒ²) + +#### **Activities (`laborious/activities/`)** +- `gates.py`: Data quality validation, filtering, and data formatting operations + - Input/response/content gates for quality validation + - Prediction and transformed data formatting + - Retrain report formatting and metrics recording +- `mlflow.py`: Transform, predict, and model management operations + - MLFlow model transformation and prediction + - Model retraining and production updates + - Reference data retrieval from MLflow Model Registry +- `storage.py`: PostgreSQL queries and MinIO-aware data loading + - `load_query_with_minio_offload`: SQL load with automatic MinIO offload + - `export_payload_to_postgres`: Resolve MinIO payloads and export to Postgres + - `cleanup_minio_objects_expired`: Retention-based MinIO object cleanup + - `query_to_minio`: Legacy parquet upload for retraining data +- `model_metrics.py`: Drift detection and regression metrics + - Univariate and multivariate drift calculation + - Simple metrics (RMSE, MSE, MAE, RΒ²) +- `opc.py`: OPC UA export to industrial systems (optional) +- `api.py`: PI Web API export operations (optional) + - Prediction and confidence data writing to PI Web API + - Error handling and notification integration +- `activities.py`: Aggregates all activity interfaces (Storage, MLFlow, Gates, OPC, ModelMetrics, API) + +#### **Data Services (`laborious/utils/`)** +- `connectors_config.py`: Env-driven configuration builders +- `models/minio_dataframe_payload.py`: MinIO-offloaded DataFrame payload model +- `repository/model_repository.py`: MLFlow operations and retraining +- `repository/opc_repository.py`: OPC UA client, writes, session recovery (see [OPC UA Communication](#opc-ua-communication)) +- `repository/minio_manager.py`: MinIO object storage operations +- `filters/conditional_filters.py` and `filters/mlflow_filters.py` + +### Data Flow Architecture + +#### **1. Batch Prediction Pipeline** +``` +Input Data (PostgreSQL) β†’ Data Quality Gates β†’ MLFlow Transform β†’ +MLFlow Prediction β†’ Response Validation β†’ Format & Export + β”œβ”€β†’ Predictions β†’ PostgreSQL [+ OPC] [+ PI Web API] + └─→ Transformed Data β†’ PostgreSQL (optional) +``` + +#### **2. Model Retraining Pipeline** +``` +Training Data β†’ Model Retraining β†’ Quality Validation β†’ +Production Update β†’ Notification & Monitoring +``` + +#### **3. Drift Detection Pipeline** +``` +Target Data (PostgreSQL) + Reference Data (MLFlow) β†’ +Drift Calculation (univariate + multivariate) β†’ PostgreSQL Export +``` + +#### **4. Simple Metrics Pipeline** +``` +Predictions + Targets (PostgreSQL JOIN) β†’ +Metrics Calculation (RMSE, MSE, MAE, RΒ²) β†’ PostgreSQL Export +``` + +### Security Architecture + +#### **Authentication & Authorization** +- **MLFlow API Authentication**: Username/password +- **Database Security**: Encrypted connections and credential management +- **OPC Certificates** (if enabled): Client/server certs +- **PI Web API Authentication**: Bearer token or basic authentication +- **Kubernetes Secrets**: Secure secret storage + +#### **Network Security** +- TLS/SSL, network policies, service mesh, firewalls, VPN + +#### **Data Security** +- At-rest/in-transit encryption, RBAC, audit logging, lifecycle management + +## Workflows + +### 1. Predictions Batch Workflow (`predictions_batch.py`) + +The **PredictionsBatch** workflow is the main entry point for batch prediction pipelines. It orchestrates the complete prediction process and implements a robust data loading and processing pattern. + +#### Purpose +- **Batch Prediction Orchestration**: Coordinates data loading and prediction processing +- **Data Preparation**: Loads data using custom SQL queries with configurable schemas +- **Workflow Delegation**: Delegates actual prediction processing to the PredictionProcess workflow +- **Configuration Management**: Handles model configuration, filters, and retention policies + +#### Execution Flow +1. **Data Loading**: Executes custom SQL query to load data from PostgreSQL +2. **Input Preparation**: Prepares prediction input with metadata and configuration +3. **Workflow Delegation**: Spawns PredictionProcess child workflow for actual processing +4. **Error Handling**: Implements comprehensive error handling with retry policies + +#### Key Features +- **Custom Query Support**: Flexible SQL-based data loading +- **Schema Configuration**: Configurable data schema definitions +- **Automatic Retry**: Implements Temporal retry policies for fault tolerance +- **Timeout Management**: 60-second timeout for all activities +- **Comprehensive Error Handling**: Detailed error reporting and notification integration + +#### Input Parameters +```json +{ + "schedule_name": "hourly_predictions", + "model_name": "temperature_prediction_model", + "model_id": "temp_pred_001", + "query": "SELECT * FROM sensor_data WHERE timestamp > NOW() - INTERVAL '1 hour'", + "schema": { + "timestamp": "datetime", + "temperature": "float", + "humidity": "float" + }, + "table_name": "predictions", + "input_filters": { + "EMPTY_DATA": {"POLICY": "STOP"} + }, + "mlflow_transform_filters": { + "API_ERROR": {"POLICY": "STOP"} + }, + "mlflow_predict_filters": { + "API_ERROR": {"POLICY": "STOP"} + }, + "model_retention": 60, + "path_priority": ["STOP", "CONTINUE", "REPEAT"], + "opc_output_config": { + "server_id": "opc_server_1", + "tags": ["prediction_output"] + }, + "pi_web_api_output_config": { + "endpoint": "https://pi-server.com/piwebapi", + "prediction_tags": {"tag1": "web_id_1"}, + "confidence_tags": {"tag2": "web_id_2"} + } +} +``` + +#### Architecture Diagram +```mermaid +flowchart LR + A[1. load_custom_query] --> B[2. prediction_process πŸ”ƒ] + + A -.-> Database[(Database)] +``` + +### 2. Prediction Process Workflow (`prediction_process.py`) + +The **PredictionProcess** workflow implements the core prediction pipeline for ML model inference. It handles data quality validation, MLFlow model interactions, and prediction processing. + +#### Purpose +- **Data Quality Validation**: Applies configurable filters for data integrity +- **MLFlow Integration**: Manages model transformation and prediction requests +- **Response Validation**: Filters MLFlow API responses for quality assurance +- **Prediction Export**: Delegates prediction formatting and export operations + +#### Execution Flow +1. **Input Data Gate**: Applies configured filters for data quality validation +2. **Path Decision**: Determines processing path based on filter results +3. **MLFlow Transform**: Requests data transformation using MLFlow models +4. **Response Validation**: Filters transform responses for quality assurance +5. **Content Validation**: Filters transformed data content for quality check +6. **MLFlow Prediction**: Executes prediction using transformed data +7. **Prediction Response Validation**: Filters prediction responses for final quality check +8. **Export Delegation**: Delegates to FormatAndExportPrediction workflow +9. **MinIO Cleanup**: Cleans up expired offloaded payloads (if any, in `finally` block) + +#### Key Features +- **Configurable Quality Gates**: Multiple filter types with policy-based configuration +- **Flexible Path Handling**: Configurable decision paths (STOP, CONTINUE, REPEAT) +- **MLFlow Integration**: Comprehensive model management and inference +- **MinIO Cleanup**: Automatic retention-based cleanup of offloaded payloads +- **Comprehensive Monitoring**: Detailed metrics and error reporting + +#### Input Parameters +```json +{ + "metadata": { + "schedule_name": "hourly_predictions", + "model_name": "temperature_prediction_model", + "model_id": "temp_pred_001", + "workflow_name": "predictions_batch" + }, + "data": {...}, + "schema": {...}, + "table_name": "predictions", + "model_id": "temp_pred_001", + "model_name": "temperature_prediction_model", + "input_filters": { + "EMPTY_DATA": {"POLICY": "STOP"}, + "SPECIFIC_VARIABLES_NULL_VALUES": { + "POLICY": "STOP", + "config": {"variables": ["temperature", "humidity"]} + } + }, + "mlflow_transform_filters": { + "API_ERROR": {"POLICY": "STOP"} + }, + "mlflow_predict_filters": { + "API_ERROR": {"POLICY": "STOP"}, + "NAN_VALUES": {"POLICY": "STOP"} + }, + "model_retention": 60, + "path_priority": ["STOP", "CONTINUE", "REPEAT"], + "opc_output_config": {...}, + "pi_web_api_output_config": { + "endpoint": "https://pi-server.com/piwebapi", + "prediction_tags": {"tag1": "web_id_1"}, + "confidence_tags": {"tag2": "web_id_2"} + } +} +``` + +#### Architecture Diagram +```mermaid +flowchart LR + A[1. input_gate] --> B[2. request_transform] --> C[3. mlflow_response_gate] --> D[4. mlflow_content_gate] --> E[5. request_predict] --> F[6. mlflow_response_gate] --> G[7. format_and_export_predictionπŸ”ƒ] + G --> H[8. cleanup_minio_objects_expired] + + B -.-> MLFlow[MLFlow] + E -.-> MLFlow[MLFlow] + H -.-> MinIO[(MinIO)] +``` + +### 3. Format and Export Prediction Workflow (`format_and_export_prediction.py`) + +The **FormatAndExportPrediction** workflow handles prediction data formatting and export operations to multiple destinations. + +#### Purpose +- **Data Formatting**: Formats prediction data for different output destinations +- **PostgreSQL Export**: Persists predictions to database with metrics +- **OPC Integration**: Writes predictions to OPC servers for real-time access +- **PI Web API Integration**: Writes predictions and confidence to PI Web API for industrial systems +- **Metrics Recording**: Tracks export operations and performance metrics + +#### Execution Flow +1. **Path Decision**: Determines formatting path based on configuration +2. **Data Formatting**: Formats prediction data for specific output requirements +3. **Transformed Data Processing**: Optionally formats and exports transformed data separately +4. **PI Web API Export**: Writes predictions and confidence to PI Web API (if configured) +5. **OPC Export**: Writes predictions to OPC servers (if configured) +6. **PostgreSQL Export**: Writes formatted predictions to database +7. **Metrics Recording**: Records export performance and success metrics + +#### Key Features +- **Flexible Formatting**: Configurable output formats for different destinations +- **Multi-Destination Export**: PostgreSQL, OPC server, and PI Web API integration +- **Transformed Data Export**: Optional separate export of MLFlow transformed data +- **Performance Monitoring**: Comprehensive metrics for export operations +- **Error Handling**: Robust error handling with notification integration + +#### Architecture Diagram +```mermaid +flowchart LR + A[1. format_prediction/format_default_prediction] --> B[2. format_transformed_data] --> C[3. write_pi_web_api_data] --> D[4. write_opc_data] --> E[5. export_data_to_postgres] --> F[6. write_metrics] + + A -.-> Format[Data Formatting] + B -.-> Transform[Transformed Data] + C -.-> PIWebAPI[PI Web API] + D -.-> OPC[OPC Servers] + E -.-> PostgreSQL[(PostgreSQL)] + F -.-> Prometheus[Prometheus] +``` + +#### Transformed Data Export +When `transformed_data` is provided in the input, the workflow will: +- Format the transformed data using `format_transformed_data` activity +- Export it to a separate table (`transform_table_name`) asynchronously +- Wait for both prediction and transformed data exports to complete +- This enables separate tracking of model transformations for analysis and debugging + +### 4. Minimal Retrain Workflow (`minimal_retrain.py`) + +The **MinimalRetrain** workflow handles automated model retraining and production model updates. + +#### Purpose +- **Model Retraining**: Automates ML model retraining processes +- **Production Updates**: Manages production model version updates +- **Data Export**: Exports training data for model development +- **Quality Assurance**: Ensures model quality before production deployment + +#### Execution Flow +1. **Data Loading**: Loads training data using custom queries +2. **Model Retraining**: Executes model retraining process +3. **Quality Validation**: Validates retrained model performance +4. **Production Update**: Updates production model if quality criteria met +5. **Data Export**: Exports training data for analysis + +#### Architecture Diagram +```mermaid +flowchart LR + A[1. load_custom_query] --> B[2. retrain_model] --> C[3. update_production_model] --> D[4. export_data_to_postgres] + + A -.-> Database[(Database)] + B -.-> MLFlow[MLFlow] + C -.-> MLFlow[MLFlow] + D -.-> PostgreSQL[(PostgreSQL)] +``` + +### 5. Drift Workflow (`drift.py`) + +The **Drift** workflow detects data drift by comparing current data against a reference dataset from the MLflow Model Registry. + +#### Execution Flow +1. **Data Loading**: Loads target data and reference data in parallel +2. **Drift Calculation**: Calculates univariate and multivariate drift metrics +3. **Data Export**: Exports drift metrics to PostgreSQL + +#### Architecture Diagram +```mermaid +flowchart LR + A[1. load_custom_query] --> C[3. calculate_drift] --> D[4. export_data_to_postgres] + B[2. get_reference_data] --> C + + A -.-> Database[(Database)] + B -.-> MLFlow[MLFlow] + D -.-> PostgreSQL[(PostgreSQL)] +``` + +#### Input Parameters +```json +{ + "schedule_name": "hourly_drift", + "model_name": "temperature_model", + "model_id": "temp_001", + "schema": "sientia_data", + "source_table_name": "laborious_data", + "target_table_name": "drift_metrics", + "interval": 60, + "model_config": { "target": "temperature" }, + "drift_metrics": ["kolmogorov_smirnov", "jensen_shannon", "wasserstein"], + "chunk_period": "min" +} +``` + +### 6. Simple Metrics Workflow (`simple_metrics.py`) + +The **SimpleMetrics** workflow calculates regression metrics (RMSE, MSE, MAE, RΒ²) by comparing predictions against actual target values. + +#### Execution Flow +1. **Data Loading**: Loads prediction vs target data via a JOIN query +2. **Metrics Calculation**: Calculates configured regression metrics +3. **Data Export**: Exports metrics to PostgreSQL + +#### Architecture Diagram +```mermaid +flowchart LR + A[1. load_custom_query] --> B[2. calculate_simple_metrics] --> C[3. export_data_to_postgres] + + A -.-> Database[(Database)] + C -.-> PostgreSQL[(PostgreSQL)] +``` + +#### Input Parameters +```json +{ + "schedule_name": "hourly_metrics", + "model_name": "temperature_model", + "model_id": "temp_001", + "schema": "sientia_data", + "predictions_table_name": "predictions", + "data_table_name": "laborious_data", + "target_table_name": "simple_metrics", + "interval_minutes": 60, + "model_config": { "target": "temperature" }, + "metrics": ["rmse", "mse", "mae", "r2"] +} +``` + +## πŸ“‹ Prerequisites + +- Python 3.11+ +- Temporal server/cluster +- PostgreSQL database +- MLFlow server +- MinIO object storage (for MLFlow artifacts) +- MongoDB server (for notifications) +- OPC server(s) if using OPC export +- PI Web API server if using PI Web API export + +**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 + cd sientia-dataops-laborious + ``` + +2. **Create virtual environment** + ```bash + python3.11 -m venv venv + source ./venv/bin/activate + ``` + +3. **Install dependencies** + + 1. **Install github cli** + ```bash + sudo apt update + sudo apt install gh -y + ``` + + 2. **Authenticate with github** + ```bash + gh auth login + ``` + + 3. **Run the install_dependencies.sh script** + ```bash + chmod +x install_dependencies.sh + ./install_dependencies.sh + ``` + +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/postgresql 5432:5432 + kubectl port-forward svc/mlflow 5000:5000 + kubectl port-forward svc/mongodb 27017:27017 + + # Or connect to external services + # Ensure services are accessible on localhost with appropriate ports + ``` + +## πŸ“¦ How to Run + +### Running the Laborious 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 laborious 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=laborious --cov-report=html + +# Run specific test categories +pytest tests/laborious/activities/ +pytest tests/laborious/workflows/ +``` + +### 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 laborious worker +python -m laborious.worker.worker +``` + +## Code Quality & Validation + +### Overview + +Since Python is not compiled, we validate quality, security, and correctness before execution. + +### Validation Tools + +- Ruff: Linting and formatting +- mypy: Static type checking +- Bandit: Security analysis +- pytest: Unit/integration testing with coverage + +### Tools Installation + +```bash +pip install -r requirements-dev.txt +``` + +### Complete Validation + +Run each validation step individually: +```bash +ruff format --check laborious/ tests/ +ruff check laborious/ tests/ +mypy laborious/ +bandit -r laborious/ -ll +pytest tests/ --cov=laborious --cov-report=term-missing +``` + +### Automatic Fixes + +```bash +ruff format laborious/ tests/ +ruff check --fix laborious/ tests/ +``` + +### Configuration + +All settings reside in `pyproject.toml` (Ruff, mypy, pytest, Bandit). + +### CI/CD Integration + +The workflow at `.github/workflows/quality-gate.yml` executes validations on each push/PR. + +### Best Practices + +- Run all validation steps before committing +- Use `ruff check --watch` for continuous feedback +- Add type hints and tests for new code + +## πŸ§ͺ Testing + +### Test Structure +``` +tests/ +β”œβ”€β”€ conftest.py # Global fixtures and env setup +β”œβ”€β”€ laborious/ +β”‚ β”œβ”€β”€ activities/ # Activity implementation tests +β”‚ β”‚ β”œβ”€β”€ test_activities.py # Activities aggregator tests +β”‚ β”‚ β”œβ”€β”€ test_gates.py # Data quality gates and formatting tests +β”‚ β”‚ β”œβ”€β”€ test_mlflow.py # MLFlow operations and reference data tests +β”‚ β”‚ β”œβ”€β”€ test_storage.py # Storage and MinIO offload tests +β”‚ β”‚ β”œβ”€β”€ test_model_metrics.py # Drift and simple metrics tests +β”‚ β”‚ β”œβ”€β”€ test_opc.py # OPC operations tests +β”‚ β”‚ └── test_api.py # PI Web API operations tests +β”‚ β”œβ”€β”€ workflows/ # Workflow orchestration tests +β”‚ β”‚ β”œβ”€β”€ test_predictions_batch.py +β”‚ β”‚ β”œβ”€β”€ test_minimal_retrain.py +β”‚ β”‚ β”œβ”€β”€ test_drift.py +β”‚ β”‚ β”œβ”€β”€ test_simple_metrics.py +β”‚ β”‚ └── subworkflows/ +β”‚ β”‚ β”œβ”€β”€ test_prediction_process.py +β”‚ β”‚ └── test_format_and_export_prediction.py +β”‚ └── utils/ # Utility function tests +β”‚ β”œβ”€β”€ test_connectors_config.py +β”‚ β”œβ”€β”€ models/ +β”‚ β”‚ └── test_minio_dataframe_payload.py +β”‚ β”œβ”€β”€ filters/ +β”‚ β”‚ β”œβ”€β”€ test_conditional_filters.py +β”‚ β”‚ └── test_mlflow_filters.py +β”‚ └── repository/ +β”‚ β”œβ”€β”€ test_model_repository.py +β”‚ └── test_opc_repository.py +``` + +### Test Coverage +The test suite provides comprehensive coverage for: +- **Data Quality Gates**: Input, response, and content validation filters +- **Data Formatting**: Prediction, transformed data, and retrain report formatting +- **MLFlow Operations**: Transform, predict, retrain, and reference data retrieval +- **Workflow Orchestration**: Complete workflow execution paths and error handling +- **Metrics Recording**: Performance monitoring and OPC export metrics + +### Test Execution +```bash +# Install test dependencies +pip install pytest pytest-cov pytest-asyncio + +# Run tests with coverage +pytest --cov=laborious --cov-report=html + +# Run specific test modules +pytest tests/laborious/activities/test_gates.py +pytest tests/laborious/workflows/test_predictions_batch.py +``` + +## πŸ“Š Monitoring and Metrics + +The Laborious system exposes comprehensive Prometheus metrics for operational visibility and performance monitoring: + +### Application Health Metrics +- `app_up`: Application health status (1=healthy, 0=unhealthy) + - Labels: `pod_id` + +### Prediction Operation Metrics +- `laborious_predictions_written_count`: Counter for successful prediction exports + - Labels: `pod_id`, `model_name`, `workflow_name` +- `laborious_prediction_confidence_monitor`: Gauge for current prediction confidence levels + - Labels: `pod_id`, `model_name`, `workflow_name` +- `laborious_prediction_response_time_monitor`: Histogram for prediction response times + - Labels: `pod_id`, `model_name`, `workflow_name` + - Buckets: [0.01, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0] + +### OPC Export Metrics +- `laborious_prediction_opc_writing_count`: Counter for OPC server write operations + - Labels: `pod_id`, `model_name`, `workflow_name`, `opc_server_id` +- `laborious_prediction_opc_writing_response_time_monitor`: Histogram for OPC write response times + - Labels: `pod_id`, `model_name`, `workflow_name`, `opc_server_id` + - Buckets: [0.01, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0] + +OPC UA session and write diagnostics (Prometheus, `laborious/metrics.py`): + +- `opc_connections_initiated_total`, `opc_connections_failed_total`, `opc_connection_status` +- `opc_session_created_total`, `opc_session_closed_total`, `opc_session_revised_timeout_milliseconds` +- `opc_write_attempts_total` (label `result`: `OK` or exception name, e.g. `BadSessionIdInvalid`) +- `opc_write_inter_arrival_over_session_timeout_total` + +See [OPC UA Communication](#opc-ua-communication) for semantics, concurrency, and confidence codes **12** / **14**. + +### Data Quality Metrics +- Filter pass/fail rates through notification system +- MLFlow API response validation metrics +- Data quality gate performance tracking + +## βš™οΈ Configuration + +### Environment Variables + +| Variable | Description | Default | Required | +|----------|-------------|---------|----------| +| `TEMPORAL_HOST` | Temporal server address | `localhost:7233` | Yes | +| `TEMPORAL_NAMESPACE` | Temporal namespace | `laborious` | No | +| `RUNTIME` | Task queue suffix for all workflows (`{workflow}-{RUNTIME}-queue`) | _(none)_ | Yes | +| `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 | +| `POSTGRES_MIN_CONNECTIONS` | Minimum PostgreSQL connections | `5` | No | +| `POSTGRES_MAX_CONNECTIONS` | Maximum PostgreSQL connections | `20` | No | +| `MLFLOW_HOST` | MLFlow server hostname | `http://localhost` | Yes | +| `MLFLOW_PORT` | MLFlow server port | `5080` | Yes | +| `MLFLOW_USERNAME` | MLFlow username | `aignosi` | Yes | +| `MLFLOW_PASSWORD` | MLFlow password | `aignosi` | Yes | +| `OPC_CONFIG` | OPC server configuration (JSON) | `{}` | No | +| `OPC_ID` | OPC server identifier | `1` | No | +| `OPC_URL` | OPC server URL | `opc.tcp://localhost:4840` | No | +| `OPC_SERVER_URI` | OPC server URI | `opc.tcp://localhost:4840` | No | +| `OPC_CERT_PATH` | OPC client certificate path | `None` | No | +| `OPC_PRIVATE_KEY_PATH` | OPC private key path | `None` | No | +| `OPC_SERVER_CERT_PATH` | OPC server certificate path | `None` | No | +| `OPC_RECONNECTION_INTERVAL` | Minimum seconds between OPC reconnects | `120` | No | +| `PI_WEB_API_BASE_URL` | PI Web API server base URL | `None` | No | +| `PI_WEB_API_AUTH_TYPE` | PI Web API authentication type (basic/bearer) | `None` | No | +| `PI_WEB_API_AUTH_TOKEN` | PI Web API authentication token | `None` | No | +| `MONGODB_URL` | MongoDB connection URI | `localhost:27018` | Yes | +| `MONGODB_USERNAME` | MongoDB username | `root` | Yes | +| `MONGODB_PASSWORD` | MongoDB password | `wKZDbMNU1c` | Yes | +| `MONGODB_DATABASE_NAME` | MongoDB database name | `sientia` | Yes | +| `MONGODB_TTL_INDEX_HOURS` | MongoDB TTL index hours | `1` | No | +| `LOG_LEVEL` | Application log level | `INFO` | No | +| `PROJECT_NAME` | Project name for metrics | `laborious` | No | +| `HTTP_METRICS_PORT` | Prometheus metrics port | `9090` | No | +| `HTTP_SDK_METRICS_PORT` | Temporal SDK metrics port | `9091` | No | +| `POD_ID` | Kubernetes pod identifier | `None` | No | +| `MINIO_ENDPOINT_URL` | MinIO endpoint URL | `http://localhost:9000` | Yes | +| `MINIO_ACCESS_KEY` | MinIO access key | `minioadmin` | Yes | +| `MINIO_SECRET_KEY` | MinIO secret key | `minioadmin` | Yes | +| `MINIO_REGION_NAME` | MinIO region name | `us-east-1` | No | +| `MINIO_DEFAULT_BUCKET` | Default MinIO bucket | `laborious` | No | +| `MINIO_RETENTION_HOURS` | Retention window (hours) for offloaded MinIO objects | `24` | No | +| `SIENTIA_MINIO_OFFLOAD_THRESHOLD_MEGABYTES` | Offload threshold for DataFrame-derived payloads | `int(1.5 * 1024 * 1024)` | No | + + + + +### MinIO Payload Offload & Retention + +Laborious uses MinIO to prevent Temporal workflow history from carrying very large in-memory payloads (pandas `DataFrame`-derived dicts). +Whenever a payload exceeds a configurable size threshold, it is stored as a parquet file in MinIO and the workflow history only keeps a lightweight reference. + +Notes: +- `SIENTIA_MINIO_OFFLOAD_THRESHOLD_MEGABYTES` supports: + - Integer bytes (e.g. `"1572864"`) + - Float MiB (e.g. `"1.5"`), converted to bytes as `MiB * 1024 * 1024` +- Fallback behavior uses `1.5 MiB` when the env var is missing or invalid. + +#### Wire Contract: `MinioDataFramePayload` + +The payload is implemented in `laborious/utils/models/minio_dataframe_payload.py`. +The dataclass does **not** store a pandas `DataFrame` field. +Instead, the `DataFrame` is only used at build time by: +- `MinioDataFramePayload.from_dataframe(...)` +- `MinioDataFramePayload.from_dataframe_to_dict(...)` + +After evaluation, the payload is serialized for Temporal as a flat dict: +- **Inline path**: `data` contains `df.to_dict()`, and MinIO keys (`object_key`, `bucket`, ...) are absent / `None`. +- **MinIO path**: the dict contains: + - `bucket` + - `object_key` (full MinIO object name returned by `MinioRepository.upload_file`) + - `object_prefix` (directory prefix used for cleanup listing; relative to the repository namespace) + - `uri` (best-effort `s3:///<...>` string) + - `data` is omitted / set to `None`. + +When an activity needs pandas operations, it resolves references using: +- `MinioDataFramePayload.retrieve(minio_repo)` β€” downloads from MinIO or returns inline data as a DataFrame + +#### MinIO Object Naming (Retention Parsing) + +MinIO object basename (required convention): +`{model_name}-{operation}-{timestamp}.parquet` + +Where: +- `model_name`: model identifier used by the pipeline +- `operation`: `initial` (SQL/query load before transform) or `transform` (after MLFlow transform) +- `timestamp`: `DATETIME_FORMAT_FILENAME` from `sientia_do.temporal.constants` + +The relative object key (under the repository namespace) is always shaped as: +`training_datasets/{model_name}/{basename}` + +Retention cleanup parses timestamps from the basename using the `-initial-` / `-transform-` anchors. +`model_name` may contain hyphens; parsing is resilient to it. + +#### Workflows / Activities Integration + +Predictions batch uses MinIO offload as follows: +1. `predictions_batch` calls `Activities.load_query_with_minio_offload` + - On success, it puts the serialized `MinioDataFramePayload` dict into `prediction_input["data"]`. +2. `sub_workflows/prediction_process` + - Tracks which MinIO prefixes were referenced for offloaded payloads. + - Runs `Activities.cleanup_minio_objects_expired` in a `finally` block (only when MinIO offload happened). +3. `laborious/activities/gates.py` and `laborious/activities/mlflow.py` + - Resolve offloaded payloads transparently before constructing pandas `DataFrame` objects. + +#### Legacy: `query_to_minio` (Minimal Retrain) + +`Storage.query_to_minio` is intentionally kept with its legacy behavior for `minimal_retrain`. +It always uploads parquet and returns `{success, object_key, uri}`. +It is not used by predictions batch MinIO offload, and its objects are not part of the retention parser described above. + +Legacy MinIO object layout (relative key): +`training_datasets/{model_name}/{object_prefix}_{timestamp}.parquet` where `object_prefix` is sanitized +(slashes replaced by underscores) to keep a stable model-level directory. + +## OPC UA Communication + +Full reference: **[docs/opc-communication.md](docs/opc-communication.md)** (connection lifecycle, Tier-1 `Bad*` reconnect, connection lock / session readiness, metrics, PostgreSQL confidence **12** vs **14**, tests). + +Implementation plan: [`.cursor/plans/opc_bad_reconnect_ac4c6045.plan.md`](.cursor/plans/opc_bad_reconnect_ac4c6045.plan.md). + +### OPC Configuration + +For multiple OPC servers, use the `OPC_CONFIG` environment variable: + +```json +{ + "opc_server_1": { + "url": "opc.tcp://server1:4840", + "name": "Server1", + "server_uri": "urn:server1:opcua", + "cert_path": "/path/to/cert.pem", + "private_key_path": "/path/to/key.pem", + "server_cert_path": "/path/to/server_cert.pem", + "reconnection_interval": 5000 + }, + "opc_server_2": { + "url": "opc.tcp://server2:4840", + "name": "Server2", + "server_uri": "urn:server2:opcua", + "cert_path": "/path/to/cert.pem", + "private_key_path": "/path/to/key.pem", + "server_cert_path": "/path/to/server_cert.pem", + "reconnection_interval": 5000 + } +} +``` + +For single OPC server, use individual environment variables: +- `OPC_URL` +- `OPC_NAME` +- `OPC_SERVER_URI` +- `OPC_CERT_PATH` +- `OPC_PRIVATE_KEY_PATH` +- `OPC_SERVER_CERT_PATH` +- `OPC_RECONNECTION_INTERVAL` + +### PI Web API Configuration + +PI Web API configuration is built from environment variables using the `build_api_config` function from `sientia_do.connectors_config`. The configuration includes: + +- `PI_WEB_API_BASE_URL`: Base URL of the PI Web API server +- `PI_WEB_API_AUTH_TYPE`: Authentication type ('basic' or 'bearer') +- `PI_WEB_API_AUTH_TOKEN`: Authentication token for API access + +The PI Web API export is optional and can be configured per workflow through the `pi_web_api_output_config` parameter: + +```json +{ + "pi_web_api_output_config": { + "endpoint": "https://pi-server.com/piwebapi", + "prediction_tags": { + "tag1": "web_id_1", + "tag2": "web_id_2" + }, + "confidence_tags": { + "tag3": "web_id_3", + "tag4": "web_id_4" + } + } +} +``` + +Where: +- `endpoint`: PI Web API endpoint URL +- `prediction_tags`: Dictionary mapping tag names to web IDs for prediction values +- `confidence_tags`: Dictionary mapping tag names to web IDs for confidence values + +### Workflow Configuration + +MongoDB pipeline configuration: + +#### Predictions Batch Workflow configuration sample + +This is the configuration for the Predictions Batch Workflow, to be inserted into the MongoDB pipeline collection. + +```json +{ + "schedule_name": "laborious-orchestrated-pipeline", + "model_id": "1", + "workflow_type": "predictions_batch", + "frequency": "30s", + "max_retry_policy": 1, + "query": "select * from sientia_data.laborious_data where model_id = 1 and \"timestamp\" > NOW() - INTERVAL '5 minutes' order by \"timestamp\" desc limit 30;", + "write_tags": [ + { + "server_id": "server1", + "type": "prediction", + "addr": "ns=2;i=5", + "data_type": "double" + }, + { + "server_id": "server1", + "type": "confidence", + "addr": "ns=2;i=6", + "data_type": "double" + } + ], + "input_filters": { + "EMPTY_DATA": {"POLICY": "STOP"}, + "SPECIFIC_VARIABLES_NULL_VALUES": { + "POLICY": "CONTINUE", + "config": {"variables": ["Counter"]} + } + }, + "mlflow_transform_filters": { + "API_ERROR": {"POLICY": "REPEAT"}, + "NAN_VALUES": {"POLICY": "STOP"} + }, + "mlflow_predict_filters": { + "API_ERROR": {"POLICY": "CONTINUE"} + }, + "path_priority": ["STOP", "CONTINUE", "REPEAT"], + "active": true, + "updated_at": { + "$date": "2025-09-16T10:00:00.000Z" + }, + "datetime_columns": ["timestamp", "created_at"], + "predictions_storage_policy": "lts:1" +} +``` + +This is the configuration created by the Orchestrator in Temporal. + +```json +{ + "datetime_columns":["timestamp","created_at"], + "frequency":"15m", + "input_filters":{"EMPTY_DATA":{"config":{},"policy":"STOP"}}, + "max_retry_policy":1, + "mlflow_predict_filters":{"API_ERROR":{"config":{},"policy":"CONTINUE"}}, + "mlflow_transform_filters":{ + "API_ERROR":{"config":{},"policy":"CONTINUE"}, + "EMPTY_DATA":{"config":{},"policy":"STOP"} + }, + "model_config":{ + "is_compressed":true, + "predict_flavor":"pyfunc", + "retention_minutes":60, + "retention_target":"artifact", + "transform_function_keyword":"transform" + }, + "model_id":"352", + "model_name":"courier", + "opc_output_config":{}, + "path_priority":["STOP","CONTINUE","REPEAT"], + "predictions_storage_policy":"lts:1", + "query":"select * from sientia_data.laborious_data where model_id = 352 order by \"timestamp\" desc limit 300;", + "retention_time":3600, + "schedule_name":"laborious-courier", + "schema":"sientia_data", + "table_name":"predictions", + "updated_at":"2025-09-12 19:35:01.600000+0000", + "workflow_type":"predictions_batch" +} +``` + +## πŸ”§ Development + +### Project Structure +``` +laborious/ +β”œβ”€β”€ activities/ # Temporal activity implementations +β”‚ β”œβ”€β”€ activities.py # Main activities aggregator +β”‚ β”œβ”€β”€ gates.py # Data quality gates and filtering +β”‚ β”œβ”€β”€ mlflow.py # MLFlow model operations +β”‚ β”œβ”€β”€ storage.py # PostgreSQL queries and MinIO offload +β”‚ β”œβ”€β”€ model_metrics.py # Drift and regression metrics +β”‚ β”œβ”€β”€ opc.py # OPC server operations +β”‚ └── api.py # PI Web API operations +β”œβ”€β”€ workflows/ # Temporal workflow definitions +β”‚ β”œβ”€β”€ predictions_batch.py # Main batch prediction workflow +β”‚ β”œβ”€β”€ minimal_retrain.py # Model retraining workflow +β”‚ β”œβ”€β”€ drift.py # Data drift detection workflow +β”‚ β”œβ”€β”€ simple_metrics.py # Regression metrics workflow +β”‚ └── sub_workflows/ # Sub-workflow implementations +β”‚ β”œβ”€β”€ prediction_process.py # Core prediction workflow +β”‚ └── format_and_export_prediction.py # Export workflow +β”œβ”€β”€ worker/ # Worker implementation +β”‚ └── worker.py # Main worker orchestrator (uses sientia_do prepare_worker) +β”œβ”€β”€ utils/ # Utility functions +β”‚ β”œβ”€β”€ connectors_config.py # Environment-driven config builders +β”‚ β”œβ”€β”€ models/ # Data models +β”‚ β”‚ └── minio_dataframe_payload.py # MinIO-offloaded DataFrame payload +β”‚ β”œβ”€β”€ filters/ # Data quality filters +β”‚ β”‚ β”œβ”€β”€ conditional_filters.py # Conditional data filters +β”‚ β”‚ └── mlflow_filters.py # MLFlow response filters +β”‚ └── repository/ # Data access layer +β”‚ β”œβ”€β”€ model_repository.py # MLFlow model operations +β”‚ β”œβ”€β”€ opc_repository.py # OPC server operations +β”‚ └── minio_manager.py # MinIO object storage operations +β”œβ”€β”€ metrics.py # Prometheus metrics definitions +└── __init__.py +``` + +### 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. **MLFlow Connection Issues** + - Verify MLFlow server is running and accessible + - Check authentication credentials and permissions + - Ensure model names and versions exist + +3. **Database Connection Issues** + - Verify PostgreSQL service is running + - Check connection credentials and network access + - Ensure proper connection pool configuration + +4. **OPC Connection Failures** + - See [docs/opc-communication.md](docs/opc-communication.md) + - Verify OPC server is accessible and `OPC_RECONNECTION_INTERVAL` is appropriate + - Check certificate and key file paths + - Correlate `opc_write_attempts_total` with `opc_session_*` metrics; count session errors via `prediction_confidence = 14` + - Review OPC server logs for connection issues + +5. **PI Web API Connection Failures** + - Verify PI Web API server is accessible + - Check authentication credentials and token validity + - Verify web IDs exist and have write permissions + - Review PI Web API server logs for connection issues + - Check notification system for error details + +6. **Workflow Execution Failures** + - Review activity error logs and notifications + - Check data quality filter configurations + - Verify input data format and required fields + +### 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` +- **Connection Pools**: Optimize database connection pool sizes +- **Model Retention**: Configure MLFlow model retention based on requirements +- **Batch Sizes**: Adjust data processing batch sizes for optimal throughput + +### Scaling Considerations + +- **Horizontal Scaling**: Deploy multiple worker instances +- **Task Queue Distribution**: One worker pod per `RUNTIME`; queues are + `predictions_batch-{RUNTIME}-queue`, `minimal_retrain-{RUNTIME}-queue`, + `drift-{RUNTIME}-queue`, `simple_metrics-{RUNTIME}-queue` +- **Database Performance**: Optimize indexes and connection pooling +- **MLFlow Performance**: Configure appropriate model serving resources + +## 🀝 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 Laborious system is designed for production use in industrial ML environments. Ensure proper security configuration and network isolation for production deployments. diff --git a/debug.log b/debug.log new file mode 100644 index 0000000..63317a1 --- /dev/null +++ b/debug.log @@ -0,0 +1,65 @@ +Loading environment variables from .env... +Environment variables loaded from .env +Starting ingestor application... +at=INFO msg="Starting Worker with POD_ID: None" pod_id= model_name=- model_id=- workflow_name=- schedule_name=- timestamp="2026-05-13 21:23:15.938811" +at=INFO msg="Starting prometheus client..." pod_id= model_name=- model_id=- workflow_name=- schedule_name=- timestamp="2026-05-13 21:23:15.938972" +Prometheus server started on port 9090. +at=INFO msg="Starting Notification Handler..." pod_id= model_name=- model_id=- workflow_name=- schedule_name=- timestamp="2026-05-13 21:23:15.939516" +at=INFO msg="Starting Activities..." pod_id= model_name=- model_id=- workflow_name=- schedule_name=- timestamp="2026-05-13 21:23:16.974243" +Traceback (most recent call last): + File "", line 198, in _run_module_as_main + File "", line 88, in _run_code + File "/home/bruno-domingues/repos/sientia-dataops-laborious_temporal/laborious/worker/worker.py", line 267, in + asyncio.run(main()) + File "/home/bruno-domingues/repos/sientia-dataops-laborious_temporal/venv/lib/python3.11/asyncio/runners.py", line 190, in run + return runner.run(main) + ^^^^^^^^^^^^^^^^ + File "/home/bruno-domingues/repos/sientia-dataops-laborious_temporal/venv/lib/python3.11/asyncio/runners.py", line 118, in run + return self._loop.run_until_complete(task) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/home/bruno-domingues/repos/sientia-dataops-laborious_temporal/venv/lib/python3.11/asyncio/base_events.py", line 654, in run_until_complete + return future.result() + ^^^^^^^^^^^^^^^ + File "/home/bruno-domingues/repos/sientia-dataops-laborious_temporal/laborious/worker/worker.py", line 118, in main + activities = Activities( + ^^^^^^^^^^^ + File "/home/bruno-domingues/repos/sientia-dataops-laborious_temporal/laborious/activities/activities.py", line 77, in __init__ + minio_repository = MinioRepository( + ^^^^^^^^^^^^^^^^ + File "/home/bruno-domingues/repos/sientia-dataops-laborious_temporal/venv/lib/python3.11/site-packages/sientia_do/repository/minio_repository.py", line 81, in __init__ + self._ensure_bucket(self.bucket) + File "/home/bruno-domingues/repos/sientia-dataops-laborious_temporal/venv/lib/python3.11/site-packages/sientia_do/repository/minio_repository.py", line 110, in _ensure_bucket + if not self.minio_client.bucket_exists(bucket): + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/home/bruno-domingues/repos/sientia-dataops-laborious_temporal/venv/lib/python3.11/site-packages/minio/api.py", line 700, in bucket_exists + self._execute("HEAD", bucket_name) + File "/home/bruno-domingues/repos/sientia-dataops-laborious_temporal/venv/lib/python3.11/site-packages/minio/api.py", line 444, in _execute + return self._url_open( + ^^^^^^^^^^^^^^^ + File "/home/bruno-domingues/repos/sientia-dataops-laborious_temporal/venv/lib/python3.11/site-packages/minio/api.py", line 427, in _url_open + raise response_error +minio.error.S3Error: S3 operation failed; code: AccessDenied, message: Access denied, resource: /sientia, request_id: 18AF3CF44F895143, host_id: dd9025bab4ad464b049177c95eb6ebf374d3b3fd1af9251148b658df7ac2e3e8, bucket_name: sientia +Exception ignored in: +Traceback (most recent call last): + File "/home/bruno-domingues/repos/sientia-dataops-laborious_temporal/venv/lib/python3.11/site-packages/sientia_do/notifications/handlers.py", line 57, in __del__ + File "/home/bruno-domingues/repos/sientia-dataops-laborious_temporal/venv/lib/python3.11/site-packages/sientia_do/notifications/handlers.py", line 51, in shutdown + File "/home/bruno-domingues/repos/sientia-dataops-laborious_temporal/venv/lib/python3.11/site-packages/sientia_do/observability/logger.py", line 52, in error +ImportError: sys.meta_path is None, Python is likely shutting down +Exception ignored in: +Traceback (most recent call last): + File "/home/bruno-domingues/repos/sientia-dataops-laborious_temporal/laborious/activities/storage.py", line 209, in __del__ + File "/home/bruno-domingues/repos/sientia-dataops-laborious_temporal/laborious/activities/storage.py", line 205, in close + File "/home/bruno-domingues/repos/sientia-dataops-laborious_temporal/venv/lib/python3.11/site-packages/sientia_do/temporal/activities/postgres.py", line 85, in close +AttributeError: 'Activities' object has no attribute 'engine' +Exception ignored in: +Traceback (most recent call last): + File "/home/bruno-domingues/repos/sientia-dataops-laborious_temporal/venv/lib/python3.11/site-packages/sientia_do/repository/minio_repository.py", line 102, in __del__ + File "/home/bruno-domingues/repos/sientia-dataops-laborious_temporal/venv/lib/python3.11/site-packages/sientia_do/repository/minio_repository.py", line 96, in close + File "/home/bruno-domingues/repos/sientia-dataops-laborious_temporal/venv/lib/python3.11/site-packages/sientia_do/observability/sientia_monitoring.py", line 64, in shutdown + File "/home/bruno-domingues/repos/sientia-dataops-laborious_temporal/venv/lib/python3.11/asyncio/runners.py", line 190, in run + File "/home/bruno-domingues/repos/sientia-dataops-laborious_temporal/venv/lib/python3.11/asyncio/runners.py", line 118, in run + File "/home/bruno-domingues/repos/sientia-dataops-laborious_temporal/venv/lib/python3.11/asyncio/base_events.py", line 654, in run_until_complete + File "/home/bruno-domingues/repos/sientia-dataops-laborious_temporal/venv/lib/python3.11/site-packages/sientia_do/observability/sientia_monitoring.py", line 50, in _shutdown_with_timeout + File "/home/bruno-domingues/repos/sientia-dataops-laborious_temporal/venv/lib/python3.11/site-packages/sientia_do/observability/metrics_controller.py", line 72, in shutdown + File "/home/bruno-domingues/repos/sientia-dataops-laborious_temporal/venv/lib/python3.11/site-packages/sientia_do/observability/logger.py", line 70, in debug +ImportError: sys.meta_path is None, Python is likely shutting down diff --git a/docs/opc-communication.md b/docs/opc-communication.md new file mode 100644 index 0000000..15d78e7 --- /dev/null +++ b/docs/opc-communication.md @@ -0,0 +1,170 @@ +# OPC UA communication (Laborious) + +Laborious exports predictions to OPC UA servers through `OpcRepository` ([`laborious/utils/repository/opc_repository.py`](../laborious/utils/repository/opc_repository.py)) and the Temporal activity layer in [`laborious/activities/opc.py`](../laborious/activities/opc.py). + +Implementation plan for session/channel recovery on Tier-1 `Bad*` errors: [`.cursor/plans/opc_bad_reconnect_ac4c6045.plan.md`](../.cursor/plans/opc_bad_reconnect_ac4c6045.plan.md). + +## Architecture + +```text +Worker (long-lived) + └── OpcRepository per OPC server id (from OPC_CONFIG / env) + β”œβ”€β”€ connect / disconnect / validate_connection (read-only) + β”œβ”€β”€ _connect_locked / _reconnect_locked (under _connection_lock) + β”œβ”€β”€ write_data (single attempt per call) + └── background reconnect on Tier-1 Bad*, protocol closed, or stale session + +Temporal activity write_opc_data + └── OPC.manage_output_tags β†’ write_data per tag (sequential per activity) +``` + +One worker process holds one `OpcRepository` instance per configured server. Multiple Temporal activities can call `write_data` concurrently on the same repository. + +## Connection lifecycle + +| Phase | Behavior | +|-------|----------| +| Startup | `init_opc()` creates repositories and calls `connect()` β†’ `_connect_locked()` | +| Steady state | `validate_connection()` is read-only (`protocol.state` only); `_session_ready` is checked in `write_data` | +| Tier-1 Bad* / protocol closed | `_start_reconnect` β†’ `_run_reconnect` β†’ `_reconnect_locked()` (respects `reconnection_interval`) | +| Write | `write_data()` checks reconnect task, `_session_ready`, validates protocol, then one `get_node` + `write_value` | +| Shutdown | `close()` disconnects all repositories | + +### Session and channel timeouts + +Requested session and secure-channel lifetime: **10 minutes** (`OPC_UA_SESSION_AND_CHANNEL_TIMEOUT_MS` in `opc_repository.py`). The server may revise these values; negotiated values are logged after connect and exposed as `opc_session_revised_timeout_milliseconds`. + +### Reconnection interval + +`OPC_RECONNECTION_INTERVAL` is in **seconds** (default `120`). It gates **background** reconnect after Tier-1 `Bad*`, closed protocol, or stale session (`last_reconnection_time` is updated only in `_reconnect_locked()`). It limits load on the OPC server when many workflows fail at once. + +## Concurrency: connection lock and session readiness + +To allow **multiple concurrent writes** when the session is healthy, but **block all writes** while the connection is being torn down or re-established: + +| Primitive | Role | +|-----------|------| +| `_connection_lock` (`asyncio.Lock`) | Held for the entire `disconnect` β†’ `connect` path. Only one connection-maintenance task at a time. | +| `_session_ready` (`asyncio.Event`) | Set when a session is ready for writes; cleared before reconnect starts and set again after a successful connect. | + +**Connection methods (caller holds `_connection_lock` for `_*_locked` helpers):** + +| Method | Role | +|--------|------| +| `_create_client()` | Create asyncua `Client` + optional `set_security`; raises if `client` already exists | +| `_open_session()` | `client.connect()` + metrics; raises if session already open or client missing | +| `_connect_locked()` | `_create_client()` (when needed) + `_open_session()`; raises if already connected | +| `_disconnect_locked()` | Teardown session and clear `client` | +| `_reconnect_locked()` | `_disconnect_locked()` + `_connect_locked()`; sets `last_reconnection_time` | + +Public `connect()` / `disconnect()` acquire the lock and call `_connect_locked()` / `_disconnect_locked()`. + +**Write path (`write_data`):** + +1. If a reconnect task is **in flight** β†’ **fail immediately** (`opc_error_kind=reconnect_in_progress`). +2. If `_session_ready` is cleared and no task is running β†’ schedule reconnect (`SessionNotReady`); fail with `connection_lost` or `reconnect_in_progress` if a task started. +3. `validate_connection()` checks `protocol.state` only (read-only). If closed β†’ schedule reconnect (`ProtocolClosed`) and fail with `opc_error_kind=connection_lost`. +4. Single `get_node` + `write_value` (no retry). Tier-1 `Bad*` on write also schedules reconnect. + +**Reconnect path (`_run_reconnect`):** + +1. `_start_reconnect` clears `_session_ready` and schedules the task when the interval allows and `_allow_reconnect` is true. +2. `async with _connection_lock:` β†’ `_reconnect_locked()`. +3. `_session_ready` is set on successful `_open_session()`. +4. `disconnect()` sets `_allow_reconnect=False` so shutdown does not respawn sessions. + +A second `_connect_locked()` while a session is already open raises `OpcSessionAlreadyConnectedError` (disconnect first). + +**asyncua note:** Concurrent `write_value` on the same session is only safe if the stack tolerates it. If production shows issues, serialize writes with an optional `asyncio.Semaphore(1)` while keeping the connection lock semantics above. + +**Future threads:** replace `asyncio.Lock` / `Event` with `threading` primitives or route all OPC I/O through one dedicated loop. + +## Reconnect triggers + +Background reconnect is scheduled when: + +- `validate_connection()` sees a closed or missing protocol (`ProtocolClosed`). +- `_session_ready` is clear after a failed reconnect (`SessionNotReady`). +- A write raises a Tier-1 `UaStatusCodeError` in `RECONNECTABLE_OPC_BAD_NAMES`. + +For Tier-1 `Bad*` when the server invalidates the session (e.g. `BadSessionIdInvalid`) but the client still sees transport as open, `write_data` fails once, records the OPC status in metrics, and **schedules** reconnect if: + +- The exception is a `UaStatusCodeError` whose name is in `RECONNECTABLE_OPC_BAD_NAMES` (see plan), and +- `reconnection_interval` has elapsed since `last_reconnection_time`, and +- No reconnect task is already running. + +There is **no write retry**: the failed export is not sent again in the same activity. + +## Prediction confidence and PostgreSQL comments + +| `prediction_confidence` | Meaning | +|-------------------------|---------| +| (unchanged) | Successful OPC export | +| **12** | Generic OPC write failure (`OPC_WRITTING_ERROR_CONFIDENCE`) | +| **14** | Tier-1 session/channel `Bad*` on export (`OPC_SESSION_BAD_CONFIDENCE`) | +| **14** | Write while reconnect in progress (`OPC_SESSION_BAD_CONFIDENCE`, comment `OPC UA reconnect in progress`) | +| **13** | PI Web API write failure (separate path) | + +Session/channel errors use a stable comment for counting: + +```text +OPC UA session/channel error: BadSessionIdInvalid +``` + +Reconnect-in-progress exports use: + +```text +OPC UA reconnect in progress +``` + +Example SQL: + +```sql +SELECT count(*) FROM predictions WHERE prediction_confidence = 14; +SELECT count(*) FROM predictions WHERE comments LIKE 'OPC UA session/channel error:%'; +``` + +## Prometheus metrics (`opc_*`) + +Defined in [`laborious/metrics.py`](../laborious/metrics.py). Do not rename in production without a dashboard migration. + +| Metric | Purpose | +|--------|---------| +| `opc_connections_initiated_total` | Connection attempts | +| `opc_connections_failed_total` | Failed connects | +| `opc_connection_status` | Gauge 1=connected, 0=disconnected | +| `opc_session_created_total` | Session established after connect | +| `opc_session_closed_total` | Disconnect initiated | +| `opc_session_revised_timeout_milliseconds` | Negotiated session timeout (ms) | +| `opc_write_attempts_total` | Per write; label `result` = `OK` or exception name | +| `opc_write_inter_arrival_over_session_timeout_total` | Successful writes spaced longer than revised session timeout | + +Legacy activity metrics: `laborious_prediction_opc_writing_count`, `laborious_prediction_opc_writing_response_time_monitor`. + +## Environment variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `OPC_CONFIG` | β€” | JSON map of server configs (overrides single-server env) | +| `OPC_ID` | `1` | Server id | +| `OPC_URL` | `opc.tcp://localhost:4840` | Endpoint | +| `OPC_SERVER_NAME` | `default_server` | Label for metrics/logs | +| `OPC_SERVER_URI` | same as URL | Application URI / cert SAN | +| `OPC_CERT_PATH` | β€” | Client certificate (secure mode) | +| `OPC_PRIVATE_KEY_PATH` | β€” | Client private key | +| `OPC_SERVER_CERT_PATH` | β€” | Server certificate | +| `OPC_RECONNECTION_INTERVAL` | `120` | Minimum seconds between reconnects | + +## Operations checklist + +- Correlate `BadSessionIdInvalid` in `opc_write_attempts_total` with `opc_session_closed_total` / `opc_session_created_total` (reconnect may finish after the row is stored with confidence 14). +- Use confidence **14** and comment prefix for session invalidation rates; use **12** for other OPC failures. +- Respect `OPC_RECONNECTION_INTERVAL` under parallel load; bursts of confidence 14 are expected until the next successful cycle. + +## Related tests + +- Unit: [`tests/laborious/utils/repository/test_opc_repository.py`](../tests/laborious/utils/repository/test_opc_repository.py) +- Unit: [`tests/laborious/activities/test_opc.py`](../tests/laborious/activities/test_opc.py) +- E2E (mock OPC): [`e2e/test_predictions_batch_format_export.py`](../e2e/test_predictions_batch_format_export.py) +- E2E (in-process asyncua server + real `OpcRepository`): [`e2e/test_opc_real_server.py`](../e2e/test_opc_real_server.py) β€” scenarios 3.1.2, 3.2.2, 3.2.4, 3.2.5 +- Scenarios: [`e2e/scenarios.md`](../e2e/scenarios.md) diff --git a/e2e/__init__.py b/e2e/__init__.py new file mode 100644 index 0000000..b7a5d65 --- /dev/null +++ b/e2e/__init__.py @@ -0,0 +1,3 @@ +""" +End-to-end tests for laborious temporal workflows. +""" diff --git a/e2e/conftest.py b/e2e/conftest.py new file mode 100644 index 0000000..cc49d9b --- /dev/null +++ b/e2e/conftest.py @@ -0,0 +1,681 @@ +""" +Pytest configuration and fixtures for E2E tests. +""" + +import sys +from unittest.mock import AsyncMock, MagicMock, patch + +# E2E workflows under test do not run ModelAnalysis; stub before Activities import. +_model_analysis_module = MagicMock() +_model_analysis_module.ModelAnalysis = MagicMock +sys.modules.setdefault('sientia', MagicMock()) +sys.modules.setdefault('sientia.ModelAnalysis', _model_analysis_module) +from io import BytesIO + +import pandas as pd +import pytest +import pytest_asyncio +from sqlalchemy import create_engine, text +from testcontainers.minio import MinioContainer +from testcontainers.postgres import PostgresContainer +from temporalio.testing import WorkflowEnvironment +from temporalio.worker import Worker + +from e2e.opc_test_server import OpcE2ETestServer +from laborious.activities.activities import Activities +from laborious.workflows.predictions_batch import PredictionsBatch +from laborious.workflows.sub_workflows.prediction_process import PredictionProcess +from laborious.workflows.sub_workflows.format_and_export_prediction import FormatAndExportPrediction +from sientia_do.notifications.handlers import CoreNotificationHandler +from sientia_do.observability.logger import Logger +from sientia_do.observability.metrics_controller import MetricsController + +# Test constants +TEST_MONGODB_CONNECTION_STRING = 'mongodb://localhost:27017' +TEST_DATABASE_NAME = 'test_db' + + +@pytest_asyncio.fixture(scope='session') +def minio_container(): + """ + MinIO S3-compatible storage for E2E tests that exercise real offload uploads. + """ + minio = MinioContainer() + minio.start() + yield minio + minio.stop() + + +@pytest_asyncio.fixture(scope='session') +def postgres_container(): + """ + Create a PostgreSQL container using testcontainers. + + This fixture creates a real PostgreSQL database in a Docker container + that will be used for all tests in the session. + """ + postgres = PostgresContainer('postgres:15') + postgres.start() + yield postgres + postgres.stop() + + +@pytest_asyncio.fixture +def postgres_engine(postgres_container): + """ + Create SQLAlchemy engine for PostgreSQL test database. + + This fixture creates a connection to the PostgreSQL container + created by the postgres_container fixture. + """ + engine = create_engine(postgres_container.get_connection_url()) + + yield engine + + engine.dispose() + + +def _create_schema_and_tables(engine): + """ + Helper function to create schema and tables in the given engine. + + Creates predictions_schema with: + - laborious_data: Input data table for queries + - predictions: Output predictions table + - transformed_data: Output transformed data table + """ + # Use begin() to ensure transaction is properly committed + with engine.begin() as conn: + # Create predictions_schema + conn.execute(text("CREATE SCHEMA IF NOT EXISTS predictions_schema")) + + # Create laborious_data table (input data from sensors) + create_laborious_data_sql = """ + CREATE TABLE IF NOT EXISTS predictions_schema.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 NOT NULL, + PRIMARY KEY (id) + ); + """ + conn.execute(text(create_laborious_data_sql)) + + # Create predictions table + create_predictions_sql = """ + CREATE TABLE if not exists predictions_schema.predictions ( + id SERIAL NOT NULL , + model_id int4 NOT NULL, + prediction numeric NULL, + prediction_confidence numeric NOT NULL, + response_time numeric NOT NULL, + prediction_status text NOT NULL, + "timestamp" timestamptz NOT NULL, + created_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, + "comments" text NULL, + PRIMARY KEY (id, created_at) + ); + """ + conn.execute(text(create_predictions_sql)) + + # Create transformed_data table + create_transformed_sql = """ + CREATE TABLE IF NOT EXISTS predictions_schema.transformed_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) + ); + """ + conn.execute(text(create_transformed_sql)) + + +@pytest_asyncio.fixture(autouse=True) +def setup_postgres_schema_and_tables(postgres_engine): + """ + Automatically create necessary schema and tables before each test. + + This fixture runs automatically (autouse=True) and ensures + that the predictions_schema and tables exist with the correct structure. + """ + _create_schema_and_tables(postgres_engine) + yield + + +@pytest_asyncio.fixture +def mock_logger(): + """Mock logger for testing.""" + def message(message): + print(f"[LOG] {message}") + def custom_message(message, _metadata={}): + print(f"[LOG] {message}") + logger = MagicMock() + logger.info = MagicMock( + side_effect=message + ) + logger.debug = MagicMock( + side_effect=message + ) + logger.error = MagicMock( + side_effect=message + ) + logger.warning = MagicMock( + side_effect=message + ) + logger.custom_info = MagicMock( + side_effect=custom_message + ) + logger.custom_debug = MagicMock( + side_effect=custom_message + ) + logger.custom_error = MagicMock( + side_effect=custom_message + ) + logger.custom_warning = MagicMock( + side_effect=custom_message + ) + return logger + + +@pytest_asyncio.fixture +def mock_mongo_client(): + """ + Mock MongoDB client to avoid real connections. + + This fixture mocks the pymongo.MongoClient used by CoreNotificationHandler, + allowing us to use a real NotificationHandler instance without connecting to MongoDB. + """ + mock_client = MagicMock() + mock_db = MagicMock() + mock_collection = MagicMock() + + # Configure the mock chain: client[database] -> db[collection] -> collection + mock_client.__getitem__.return_value = mock_db + mock_db.__getitem__.return_value = mock_collection + + # Mock server_info() to avoid connection attempts + mock_client.server_info = MagicMock() + + # Mock insert_one for notifications + mock_collection.insert_one = MagicMock() + + return mock_client + + +@pytest.fixture +def notification_inserts(mock_mongo_client): + """ + Mongo insert_one mock used by CoreNotificationHandler for notification persistence. + + Yields: + MagicMock for insert_one, reset before each test. + """ + mock_db = mock_mongo_client.__getitem__.return_value + mock_collection = mock_db.__getitem__.return_value + mock_collection.insert_one.reset_mock() + yield mock_collection.insert_one + + +@pytest_asyncio.fixture +def notification_handler(mock_logger, mock_mongo_client): + """ + Create a real NotificationHandler instance with mocked MongoDB client. + + This fixture creates a real CoreNotificationHandler instance but mocks + the underlying MongoDB connection to avoid real database connections. + """ + # Patch MongoClient where it's imported in the handlers module + with patch('sientia_do.notifications.handlers.MongoClient', return_value=mock_mongo_client): + handler = CoreNotificationHandler( + connection_string=TEST_MONGODB_CONNECTION_STRING, + database=TEST_DATABASE_NAME, + logger=mock_logger, + project_name='laborious', + ) + yield handler + handler.shutdown() + + +@pytest_asyncio.fixture +def metrics_controller(mock_logger): + """Create a real MetricsController instance.""" + return MetricsController(logger=mock_logger) + + +@pytest_asyncio.fixture +def mock_minio_repository(): + """Mock MinIO repository for object storage operations.""" + mock_repo = MagicMock() + + # Provide at least valid parquet bytes so that MinioDataFramePayload.retrieve() + # can decode the payload if offloading is exercised in an integration scenario. + parquet_df = pd.DataFrame({'a': [1]}) + parquet_buffer = BytesIO() + parquet_df.to_parquet(parquet_buffer, engine='pyarrow', index=True) + parquet_bytes = parquet_buffer.getvalue() + + # sientia_do MinioRepository API + mock_repo.bucket = 'test-bucket' + mock_repo.upload_file = AsyncMock( + side_effect=lambda file_bytes, relative_key, content_type='application/octet-stream', bucket=None, metadata=None: { + 'minio_object_name': f'sientia/streamlit-connectors/{relative_key}', + 'original_filename': relative_key.rsplit('/', 1)[-1], + 'uploaded_at': '2024-01-01T00:00:00Z', + 'sha256_hash': 'deadbeef', + } + ) + mock_repo.download_file = AsyncMock(return_value=parquet_bytes) + mock_repo.list_objects = AsyncMock(return_value=[]) + mock_repo.delete_file = AsyncMock() + mock_repo.close = MagicMock() + + return mock_repo + + +@pytest_asyncio.fixture +def mock_pi_web_api_repository(): + """Mock PI Web API repository for PI Web API operations.""" + mock_repo = MagicMock() + + async def _write_value(web_ids, value, metadata=None, **kwargs): + """ + Mirror successful PI writes: one response item per requested web_id. + + write_pi_web_api_data passes the list into process_pi_web_api_response (not a + wrapped {'Items': ...} envelope). + """ + return [{'WebId': wid, 'Errors': []} for wid in web_ids] + + mock_repo.write_value = AsyncMock(side_effect=_write_value) + mock_repo.close = MagicMock() + return mock_repo + +@pytest_asyncio.fixture +def mock_opc_repository(): + """Mock OPC repository for OPC operations.""" + mock_repo = MagicMock() + mock_repo.write_data = AsyncMock( + return_value=(True, {'response_time': 0.1}) + ) + mock_repo.disconnect = AsyncMock() + return mock_repo + + +@pytest_asyncio.fixture +async def opc_e2e_server(): + """ + In-process asyncua OPC UA server for E2E tests against OpcRepository. + """ + server = OpcE2ETestServer() + await server.start() + try: + yield server + finally: + await server.stop() + +@pytest_asyncio.fixture +def patch_create_engine(postgres_engine): + """Patch create_engine to return test postgres_engine.""" + with patch('sientia_do.temporal.activities.postgres.create_engine', return_value=postgres_engine): + yield + + +@pytest_asyncio.fixture +def patch_minio_repository(mock_minio_repository): + """Patch MinioRepository to return mock.""" + # Patch where Activities resolves the symbol (import binds the original class). + with patch('laborious.activities.activities.MinioRepository', return_value=mock_minio_repository): + yield + +@pytest_asyncio.fixture +def patch_pi_web_api_repository(mock_pi_web_api_repository): + """Patch MLflowRepository to return mock.""" + with patch('laborious.activities.api.PIWebAPIClient', return_value=mock_pi_web_api_repository): + yield + +@pytest_asyncio.fixture +def mock_mlflow_models(): + """Create mock models for MLflow load_model methods.""" + # Mock transform model - returns DataFrame with same index as input + mock_transform_model = MagicMock() + def mock_transform_predict(data): + num_rows = max(len(data), 1) if hasattr(data, '__len__') else 1 + print(data.to_csv()) + print(data.index) + result = pd.DataFrame({ + 'feature_1': [0.234] * num_rows, + 'feature_2': [0.783] * num_rows, + }) + result.index = data.index + return result + mock_transform_model.predict = MagicMock(side_effect=mock_transform_predict) + + # Mock predict model - returns array/list of predictions + mock_predict_model = MagicMock() + def mock_predict_predict(data): + num_rows = max(len(data), 1) if hasattr(data, '__len__') else 1 + return [0.5] * num_rows + mock_predict_model.predict = MagicMock(side_effect=mock_predict_predict) + + # Mock PyFuncModel for compressed models + mock_pyfunc_model = MagicMock() + mock_pyfunc_model._model_impl = MagicMock() + mock_pyfunc_model._model_impl.python_model = mock_transform_model + + return { + 'transform_model': mock_transform_model, + 'predict_model': mock_predict_model, + 'pyfunc_model': mock_pyfunc_model, + } + + +@pytest_asyncio.fixture +def patch_mlflow(mock_mlflow_models): + """Patch mlflow module in repository with load_model mocks.""" + mock_mlflow = MagicMock() + + # Mock sklearn.load_model + def mock_sklearn_load_model(model_uri): + if 'data_model' in model_uri or 'transform' in model_uri.lower(): + return mock_mlflow_models['transform_model'] + return mock_mlflow_models['predict_model'] + mock_mlflow.sklearn = MagicMock() + mock_mlflow.sklearn.load_model = MagicMock(side_effect=mock_sklearn_load_model) + + # Mock pyfunc.load_model + def mock_pyfunc_load_model(model_uri): + if 'artifacts' in model_uri or 'tmp' in model_uri: + return mock_mlflow_models['pyfunc_model'] + if 'data_model' in model_uri or 'transform' in model_uri.lower(): + return mock_mlflow_models['transform_model'] + return mock_mlflow_models['predict_model'] + mock_mlflow.pyfunc = MagicMock() + mock_mlflow.pyfunc.load_model = MagicMock(side_effect=mock_pyfunc_load_model) + + # Mock pytorch.load_model + mock_mlflow.pytorch = MagicMock() + mock_mlflow.pytorch.load_model = MagicMock(return_value=mock_mlflow_models['predict_model']) + + # Mock other mlflow methods that might be called + mock_mlflow.set_tracking_uri = MagicMock() + mock_mlflow.get_run = MagicMock(return_value=MagicMock(info=MagicMock(artifact_uri='mlflow-artifacts:/test_run_id'))) + mock_mlflow.tracking = MagicMock() + mock_mlflow.tracking.MlflowClient = MagicMock(return_value=MagicMock( + search_registered_models=MagicMock(return_value=[MagicMock(name='test_model')]), + search_model_versions=MagicMock(return_value=[MagicMock( + current_stage='Production', + version='1', + source='runs:/artifacts/test_run_id' + )]) + )) + + with patch('laborious.utils.repository.model_repository.mlflow', new=mock_mlflow): + yield mock_mlflow + + +@pytest_asyncio.fixture(scope='function') +async def test_activities( + postgres_engine, + postgres_container, + mock_logger, + notification_handler, + metrics_controller, + mock_minio_repository, + patch_create_engine, + patch_minio_repository, + patch_mlflow, + patch_pi_web_api_repository, + mock_opc_repository +): + """ + Create Activities instance with test dependencies. + + This fixture creates a real Activities instance with: + - PostgreSQL database (via testcontainers) + - Mocked MinIO client + - Real NotificationHandler and MetricsController (with mocked underlying services) + """ + activities = Activities( + postgres_config={ + 'host': 'localhost', + 'port': postgres_container.get_exposed_port(5432), + 'user': 'test', + 'password': 'test', + 'dbname': 'test', + 'min_connections': 1, + 'max_connections': 5, + }, + mlflow_config={ + 'host': 'http://localhost', + 'port': '5000', + 'username': 'test', + 'password': 'test', + }, + minio_config={ + # Host:port only; Minio() prepends http(s):// from the secure flag. + 'endpoint_url': 'localhost:9000', + 'access_key': 'test', + 'secret_key': 'test', + 'default_bucket': 'test-bucket', + 'retention_hours': 24, + 'secure': False, + }, + opc_config={}, + pi_web_api_config={ + 'base_url': 'http://localhost:8080', + 'auth_type': 'bearer', + 'auth_token': 'test_token', + }, + logger=mock_logger, + notification_handler=notification_handler, + ) + + activities.opc_repository = { + '1': mock_opc_repository, + } + + try: + yield activities + finally: + # Cleanup - ALWAYS runs, even if test fails + await activities.shutdown() + + +@pytest_asyncio.fixture(scope='function') +async def test_activities_real_minio( + postgres_engine, + postgres_container, + minio_container, + mock_logger, + notification_handler, + metrics_controller, + patch_create_engine, + patch_mlflow, + patch_pi_web_api_repository, + mock_opc_repository, +): + """ + Activities with a real MinIO testcontainer (no MinioRepository patch) for offload tests. + """ + minio_client = minio_container.get_client() + if not minio_client.bucket_exists('test-bucket'): + minio_client.make_bucket('test-bucket') + minio_port = minio_container.get_exposed_port(9000) + activities = Activities( + postgres_config={ + 'host': 'localhost', + 'port': postgres_container.get_exposed_port(5432), + 'user': 'test', + 'password': 'test', + 'dbname': 'test', + 'min_connections': 1, + 'max_connections': 5, + }, + mlflow_config={ + 'host': 'http://localhost', + 'port': '5000', + 'username': 'test', + 'password': 'test', + }, + minio_config={ + 'endpoint_url': f'localhost:{minio_port}', + 'access_key': 'minioadmin', + 'secret_key': 'minioadmin', + 'default_bucket': 'test-bucket', + 'retention_hours': 24, + 'secure': False, + }, + opc_config={}, + pi_web_api_config={ + 'base_url': 'http://localhost:8080', + 'auth_type': 'bearer', + 'auth_token': 'test_token', + }, + logger=mock_logger, + notification_handler=notification_handler, + ) + activities.opc_repository = {'1': mock_opc_repository} + try: + yield activities + finally: + await activities.shutdown() + + +def _worker_activity_list(test_activities: Activities): + return [ + test_activities.load_custom_query, + test_activities.load_query_with_minio_offload, + test_activities.cleanup_minio_objects_expired, + test_activities.input_gate, + test_activities.request_transform, + test_activities.mlflow_response_gate, + test_activities.mlflow_content_gate, + test_activities.request_predict, + test_activities.repeat_last_prediction, + test_activities.format_prediction, + test_activities.format_transformed_data, + test_activities.format_default_prediction, + test_activities.write_pi_web_api_data, + test_activities.write_opc_data, + test_activities.export_data_to_postgres, + test_activities.export_payload_to_postgres, + test_activities.write_metrics, + ] + + +@pytest_asyncio.fixture(scope='function') +async def temporal_test_env(): + """Create Temporal test environment.""" + env = await WorkflowEnvironment.start_time_skipping() + async with env: + yield env + + +@pytest_asyncio.fixture(scope='function') +async def temporal_worker(temporal_test_env, test_activities): + """Create Temporal worker with test activities.""" + async with Worker( + temporal_test_env.client, + task_queue='test-queue', + workflows=[PredictionsBatch, PredictionProcess, FormatAndExportPrediction], + activities=_worker_activity_list(test_activities), + ) as worker: + yield worker + + +@pytest_asyncio.fixture(scope='function') +async def temporal_worker_real_minio(temporal_test_env, test_activities_real_minio): + """Temporal worker backed by Activities using real MinIO testcontainer.""" + async with Worker( + temporal_test_env.client, + task_queue='test-queue', + workflows=[PredictionsBatch, PredictionProcess, FormatAndExportPrediction], + activities=_worker_activity_list(test_activities_real_minio), + ) as worker: + yield worker + + +@pytest_asyncio.fixture(scope='function') +async def test_activities_real_opc( + postgres_engine, + postgres_container, + opc_e2e_server: OpcE2ETestServer, + mock_logger, + notification_handler, + metrics_controller, + patch_create_engine, + patch_minio_repository, + patch_mlflow, + patch_pi_web_api_repository, +): + """ + Activities with a real OpcRepository connected to the in-process OPC UA server. + """ + activities = Activities( + postgres_config={ + 'host': 'localhost', + 'port': postgres_container.get_exposed_port(5432), + 'user': 'test', + 'password': 'test', + 'dbname': 'test', + 'min_connections': 1, + 'max_connections': 5, + }, + mlflow_config={ + 'host': 'http://localhost', + 'port': '5000', + 'username': 'test', + 'password': 'test', + }, + minio_config={ + 'endpoint_url': 'localhost:9000', + 'access_key': 'test', + 'secret_key': 'test', + 'default_bucket': 'test-bucket', + 'retention_hours': 24, + 'secure': False, + }, + opc_config={ + '1': { + 'id': '1', + 'server_name': 'e2e-opc', + 'url': opc_e2e_server.url, + 'server_uri': opc_e2e_server.url, + 'cert_path': None, + 'private_key_path': None, + 'server_cert_path': None, + 'reconnection_interval': 0, + } + }, + pi_web_api_config={ + 'base_url': 'http://localhost:8080', + 'auth_type': 'bearer', + 'auth_token': 'test_token', + }, + logger=mock_logger, + notification_handler=notification_handler, + ) + await activities.init_opc() + repo = activities.opc_repository['1'] + assert repo._session_ready.is_set(), 'OPC E2E server connection failed during init_opc' + try: + yield activities + finally: + await activities.shutdown() + + +@pytest_asyncio.fixture(scope='function') +async def temporal_worker_real_opc(temporal_test_env, test_activities_real_opc): + """Temporal worker backed by Activities using the in-process OPC UA server.""" + async with Worker( + temporal_test_env.client, + task_queue='test-queue', + workflows=[PredictionsBatch, PredictionProcess, FormatAndExportPrediction], + activities=_worker_activity_list(test_activities_real_opc), + ) as worker: + yield worker diff --git a/e2e/helpers.py b/e2e/helpers.py new file mode 100644 index 0000000..bb404ca --- /dev/null +++ b/e2e/helpers.py @@ -0,0 +1,181 @@ +""" +Shared helpers for E2E tests (Temporal workflows + PostgreSQL). +""" + +import asyncio +from datetime import datetime +from decimal import Decimal +from typing import Any + +from sqlalchemy import text +from sqlalchemy.engine import Engine + + +async def start_and_await_workflow(client, workflow_run, input_data: dict, workflow_id: str, timeout: float = 60.0): + """ + Start a workflow and wait for its result. + + Args: + client: Temporal client from WorkflowEnvironment. + workflow_run: Workflow run method (e.g. PredictionsBatch.run). + input_data: Workflow input payload. + workflow_id: Unique workflow id. + timeout: Max seconds to wait for completion. + + Return: + Workflow result value. + """ + handle = await client.start_workflow( + workflow_run, + input_data, + id=workflow_id, + task_queue='test-queue', + ) + return await asyncio.wait_for(handle.result(), timeout=timeout) + + +def insert_sample_data(postgres_engine: Engine, model_id: int, values: list[Any]) -> None: + """ + Replace laborious_data rows for a model_id with one row per value (sensor_1..n). + + Args: + postgres_engine: SQLAlchemy engine. + model_id: Model id column value. + values: Per-sensor values; use string 'NULL' for SQL NULL. + """ + with postgres_engine.begin() as conn: + conn.execute(text(f'DELETE FROM predictions_schema.laborious_data WHERE model_id = {model_id}')) + values_sql = [] + for i, value in enumerate(values): + values_sql.append(f""" + ({model_id}, 'sensor_{i + 1}', {value}, '2024-01-01 12:00:00+00:00', '2024-01-01 12:00:00+00:00') + """) + insert_sql = f""" + INSERT INTO predictions_schema.laborious_data (model_id, variable, value, timestamp, created_at) + VALUES + {', '.join(values_sql)} + """ + conn.execute(text(insert_sql)) + + +def assert_prediction( + postgres_engine: Engine, + model_id: int, + prediction: float = 0.5, + prediction_confidence: int | Decimal = 0, + prediction_status: str = 'Good', + comments: str | None = None, + comments_contains: str | None = None, +) -> None: + """ + Assert exactly one prediction row exists for model_id with expected columns. + + Args: + postgres_engine: SQLAlchemy engine. + model_id: Expected model_id. + prediction: Expected prediction value. + prediction_confidence: Expected confidence (int or Decimal for numeric column). + prediction_status: Expected status string. + comments: Expected exact comments string (optional). + comments_contains: Substring expected in comments when queued (optional). + """ + import pytest + + with postgres_engine.connect() as conn: + result_query = conn.execute( + text( + f'SELECT model_id, prediction, prediction_confidence, prediction_status, comments ' + f'FROM predictions_schema.predictions WHERE model_id = {model_id} ' + f'ORDER BY created_at ASC' + ) + ) + prediction_rows = result_query.fetchall() + assert len(prediction_rows) == 1, f'Expected one prediction record, got {len(prediction_rows)}' + row = prediction_rows[0] + assert row[0] == model_id, f'Expected model_id={model_id}, got {row[0]}' + assert row[1] == prediction or Decimal(str(row[1])) == Decimal(str(prediction)), ( + f'Expected prediction={prediction}, got {row[1]}' + ) + assert row[2] == prediction_confidence or Decimal(str(row[2])) == Decimal( + str(prediction_confidence) + ), f'Expected prediction_confidence={prediction_confidence}, got {row[2]}' + assert row[3] == prediction_status, f"Expected prediction_status='{prediction_status}', got {row[3]}" + if comments is not None: + assert row[4] == comments, f"Expected comments='{comments}', got {row[4]}" + if comments_contains is not None: + assert comments_contains in row[4], ( + f"Expected comments to contain '{comments_contains}', got {row[4]}" + ) + + +def assert_continue( + postgres_engine: Engine, + model_id: int, + prediction_confidence: Decimal = Decimal(2), + comments: str = 'Input data with bad quality', +) -> None: + """Assert one default-style prediction row after CONTINUE gate path.""" + with postgres_engine.connect() as conn: + result_query = conn.execute( + text( + f'SELECT model_id, prediction, prediction_confidence, prediction_status, comments ' + f'FROM predictions_schema.predictions WHERE model_id = {model_id}' + ) + ) + prediction_rows = result_query.fetchall() + assert len(prediction_rows) == 1, 'Expected one prediction record despite warnings' + row = prediction_rows[0] + assert row[1] == 0, f'Expected prediction=0, got {row[1]}' + assert row[2] == prediction_confidence, ( + f'Expected prediction_confidence={prediction_confidence}, got {row[2]}' + ) + assert row[3] == 'Bad', f"Expected prediction_status='Bad', got {row[3]}" + assert row[4] == comments, f"Expected comments='{comments}', got {row[4]}" + + +def assert_stop(postgres_engine: Engine, model_id: int) -> None: + """Assert no prediction rows for model_id.""" + import pytest + + with postgres_engine.connect() as conn: + result_query = conn.execute( + text(f'SELECT COUNT(*) FROM predictions_schema.predictions WHERE model_id = {model_id}') + ) + count = result_query.scalar() + assert count == 0, f'Expected no predictions, but found {count} records' + + +def assert_repeat(postgres_engine: Engine, model_id: int, last_prediction: tuple) -> None: + """ + Assert two prediction rows for model_id both match last_prediction. + + Rows are compared in created_at order for stability. + + Args: + postgres_engine: SQLAlchemy engine. + model_id: Model id. + last_prediction: Tuple (model_id, prediction, confidence, status) to match both rows. + """ + import pytest + + with postgres_engine.connect() as conn: + result_query = conn.execute( + text( + f'SELECT model_id, prediction, prediction_confidence, prediction_status ' + f'FROM predictions_schema.predictions WHERE model_id = {model_id} ' + f'ORDER BY created_at ASC' + ) + ) + prediction_rows = result_query.fetchall() + assert len(prediction_rows) == 2, 'Expected two prediction records' + assert prediction_rows[0] == last_prediction, ( + f'Expected first row {last_prediction}, got {prediction_rows[0]}' + ) + assert prediction_rows[1] == last_prediction, ( + f'Expected second row {last_prediction}, got {prediction_rows[1]}' + ) + + +def make_workflow_id(prefix: str) -> str: + """Build a unique workflow id using a prefix and current timestamp.""" + return f'{prefix}-{datetime.now().timestamp()}' diff --git a/e2e/opc_test_server.py b/e2e/opc_test_server.py new file mode 100644 index 0000000..69b02c8 --- /dev/null +++ b/e2e/opc_test_server.py @@ -0,0 +1,189 @@ +""" +In-process OPC UA server for E2E tests (asyncua). + +Provides writable prediction/confidence nodes and optional write faults +(Tier-1 BadSessionIdInvalid via PreWrite callback). +""" + +from __future__ import annotations + +import socket +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from asyncua import Server, ua +from asyncua.common.callback import CallbackType +from asyncua.common.utils import ServiceError + +if TYPE_CHECKING: + from asyncua.common.node import Node + + +UNKNOWN_NODE_ID = 'ns=99;i=9999' + + +@dataclass(frozen=True) +class OpcE2ENodeIds: + """NodeId strings used in opc_output_config for E2E workflows.""" + + prediction: str + confidence: str + unknown: str = UNKNOWN_NODE_ID + + +class OpcE2ETestServer: + """ + Ephemeral asyncua server with Laborious E2E variables and controllable faults. + + Args: + host: Bind address (default 127.0.0.1). + """ + + def __init__(self, host: str = '127.0.0.1') -> None: + self._host = host + self._server: Server | None = None + self._prediction_node: Node | None = None + self._confidence_node: Node | None = None + self._session_bad_on_write = False + self._url: str | None = None + self._node_ids: OpcE2ENodeIds | None = None + + @property + def url(self) -> str: + if self._url is None: + raise RuntimeError('OPC E2E server is not started') + return self._url + + @property + def node_ids(self) -> OpcE2ENodeIds: + if self._node_ids is None: + raise RuntimeError('OPC E2E server is not started') + return self._node_ids + + def set_session_bad_on_write(self, enabled: bool) -> None: + """ + When enabled, every client Write is rejected with BadSessionIdInvalid. + + Args: + enabled (bool): Turn Tier-1 session fault injection on or off. + """ + self._session_bad_on_write = enabled + + async def start(self) -> OpcE2ENodeIds: + """ + Start the OPC UA server on a free TCP port. + + Return: + OpcE2ENodeIds: NodeId strings for prediction and confidence tags. + """ + port = _free_port(self._host) + self._url = f'opc.tcp://{self._host}:{port}/freeopcua/server/' + + server = Server() + server.set_endpoint(self._url) + await server.init() + server.iserver.callback_service.addListener( + CallbackType.PreWrite, + self._pre_write_callback, + ) + + idx = await server.register_namespace('http://sientia.test/laborious-e2e') + e2e_object = await server.nodes.objects.add_object(idx, 'LaboriousE2E') + prediction = await e2e_object.add_variable( + idx, + 'Prediction', + ua.Variant(0.0, ua.VariantType.Float), + ) + confidence = await e2e_object.add_variable( + idx, + 'Confidence', + ua.Variant(0.0, ua.VariantType.Float), + ) + await prediction.set_writable() + await confidence.set_writable() + + await server.start() + self._server = server + self._prediction_node = prediction + self._confidence_node = confidence + self._node_ids = OpcE2ENodeIds( + prediction=prediction.nodeid.to_string(), + confidence=confidence.nodeid.to_string(), + ) + return self._node_ids + + async def stop(self) -> None: + """Stop the OPC UA server and release the listening port.""" + if self._server is not None: + await self._server.stop() + self._server = None + self._prediction_node = None + self._confidence_node = None + self._url = None + self._node_ids = None + self._session_bad_on_write = False + + async def read_prediction(self) -> float: + """ + Read the current prediction variable value from the address space. + + Return: + float: Stored prediction value. + """ + if self._prediction_node is None: + raise RuntimeError('OPC E2E server is not started') + value = await self._prediction_node.read_value() + return float(value) + + async def read_confidence(self) -> float: + """ + Read the current confidence variable value from the address space. + + Return: + float: Stored confidence value. + """ + if self._confidence_node is None: + raise RuntimeError('OPC E2E server is not started') + value = await self._confidence_node.read_value() + return float(value) + + async def _pre_write_callback(self, _event, _service) -> None: + if self._session_bad_on_write: + raise ServiceError(ua.StatusCodes.BadSessionIdInvalid) + + +def _free_port(host: str) -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind((host, 0)) + return int(sock.getsockname()[1]) + + +def build_opc_output_config( + node_ids: OpcE2ENodeIds, + *, + prediction_tag: str | None = None, + confidence_tag: str | None = None, + prediction_only: bool = False, + server_key: str = '1', +) -> dict[str, dict]: + """ + Build opc_output_config for PredictionsBatch using real server NodeIds. + + Args: + node_ids (OpcE2ENodeIds): Node ids from OpcE2ETestServer. + prediction_tag (str | None): Override prediction NodeId (default: node_ids.prediction). + confidence_tag (str | None): Override confidence NodeId (default: node_ids.confidence). + prediction_only (bool): When True, omit confidence_tags (single write per activity). + server_key (str): OPC server id key in opc_output_config. + + Return: + dict: opc_output_config payload for workflow input. + """ + pred = prediction_tag if prediction_tag is not None else node_ids.prediction + conf = confidence_tag if confidence_tag is not None else node_ids.confidence + server_config: dict = { + 'prediction_tags': {pred: {'data_type': 'float'}}, + } + if not prediction_only: + server_config['confidence_tags'] = {conf: {'data_type': 'float'}} + return {server_key: server_config} diff --git a/e2e/scenarios.md b/e2e/scenarios.md new file mode 100644 index 0000000..8841424 --- /dev/null +++ b/e2e/scenarios.md @@ -0,0 +1,564 @@ +# Test Scenarios for Predictions Batch Workflow + +This document describes all possible test scenarios for the `predictions_batch` workflow and its child workflows `prediction_process` and `format_and_export_prediction`. + +## Running automated E2E tests (`e2e/`) + +- **Runtime**: Docker (or a Docker-compatible daemon) must be available so [testcontainers](https://testcontainers.com/) can start **PostgreSQL** and **MinIO** containers. +- **Dependencies**: install dev requirements (includes `testcontainers[postgres,minio]`). +- **Invocation**: run only integration-marked tests, for example: `pytest e2e/ -m integration`. +- **MinIO tests**: `e2e/test_minio_offload.py` exercises real S3 uploads; other E2E modules continue to mock MinIO on the worker used by most scenarios. +- **OPC tests (real server)**: `e2e/test_opc_real_server.py` uses an in-process **asyncua** server and real `OpcRepository` (`test_activities_real_opc`). Scenarios 3.1.2, 3.2.2, 3.2.4, and 3.2.5 are covered there. Other E2E modules keep the OPC mock. +- Run only OPC real-server tests: `pytest e2e/test_opc_real_server.py -m "integration and opc"`. + +## Workflow Overview + +The `predictions_batch` workflow: +1. Loads data using a custom SQL query +2. Prepares prediction configuration +3. Delegates to `prediction_process` child workflow which: + - Retrieves last timestamp for incremental processing + - Applies input data quality gates + - Executes MLFlow transform operation + - Validates transform response + - Executes MLFlow predict operation + - Validates predict response + - Delegates to `format_and_export_prediction` child workflow +4. The `format_and_export_prediction` workflow: + - Formats prediction data (normal or default) + - Exports to PI Web API (optional) + - Exports to OPC server (optional) + - Exports to PostgreSQL + - Writes metrics + +--- + +## 1. Predictions Batch - Main Workflow Scenarios + +### 1.1 Success Scenarios + +#### Scenario 1.1.1: Happy Path - Complete Success +**Description**: Workflow completes successfully with valid SQL query and all activities succeed + +**Input**: +- Valid `schedule_name`, `model_name`, `model_id` +- Valid `query` returning non-empty DataFrame +- Valid `schema`, `table_name`, `transform_table_name` +- Optional `datetime_columns` for timestamp parsing +- Optional `input_filters`, `mlflow_transform_filters`, `mlflow_predict_filters` +- Optional `path_priority`, `opc_output_config`, `pi_web_api_output_config` + +**Expected Behavior**: +- `load_custom_query` returns DataFrame with data +- Workflow prepares prediction input with all configurations +- `prediction_process` child workflow executes successfully +- All gates pass with no issues +- Transform and predict operations succeed +- Data exported to PostgreSQL +- Metrics written + +**Assertions**: +- SQL query executed once +- `prediction_process` workflow called with correct parameters +- Data exists in PostgreSQL (predictions table) +- Metrics recorded +- No errors raised + +--- + +### 1.2 Error Scenarios + +#### Scenario 1.2.1: SQL Query Execution Error +**Description**: SQL query fails due to syntax error or connection issue + +**Input**: +- Invalid SQL query (syntax error) +- Or database connection unavailable + +**Expected Behavior**: +- `load_custom_query` raises exception (caught by Temporal retry policy) +- Notification sent with SQL error details +- After retries, activity may return empty data or workflow may fail +- If empty data returned, workflow completes with early exit via input gate + +**Assertions**: +- Error notification sent +- Workflow completes (either fails or exits early) +- No data in predictions table + +--- + +#### Scenario 1.2.2: Missing Required Parameters +**Description**: Essential parameters missing from input + +**Input**: +- Missing `query` or `model_id` or `schema` or `table_name` + +**Expected Behavior**: +- Workflow or activity raises KeyError or validation error +- Workflow fails immediately + +**Assertions**: +- Workflow fails with parameter error +- Error notification sent +- No child workflow called + +--- + +#### Scenario 1.2.3: Invalid Datetime Column Specification +**Description**: Datetime column specified doesn't exist in query results + +**Input**: +- `datetime_columns: ['nonexistent_column']` +- Query results don't have this column + +**Expected Behavior**: +- `load_custom_query` may raise KeyError or warning +- Depending on implementation, workflow may fail or continue +- Error notification sent + +**Assertions**: +- Error raised or warning logged +- Workflow behavior depends on error handling policy + +--- + +## 2. Prediction Process - Child Workflow Scenarios + +### 2.1 Input gate Early Exit Scenarios + +#### Scenario 2.1.1: Input Gate Triggers CONTINUE +**Description**: Input gate determines data should use previous prediction + +**Input**: +- Data that should continue with input data as prediction +- `input_filters` configured with `POLICY: 'CONTINUE'` +- `path_priority` includes CONTINUE + +**Expected Behavior**: +- `input_gate` returns `path_flag='CONTINUE'` +- `path_flag_handler` calls export workflow with input data directly +- MLFlow transform and predict skipped +- Data exported as-is + +**Assertions**: +- `input_gate` called +- MLFlow operations NOT called +- Export workflow called with original data +- Workflow completes + + +#### Scenario 2.1.2: Input Gate Triggers STOP +**Description**: Input data quality gate fails with STOP policy + +**Input**: +- Data with EMPTY_DATA or other critical issues +- `input_filters` configured with `POLICY: 'STOP'` + +**Expected Behavior**: +- `input_gate` returns `path_flag='STOP'` +- `path_flag_handler` detects STOP +- Workflow returns early without calling MLFlow +- No prediction exported + +**Assertions**: +- `input_gate` called +- `path_flag_handler` returns True (early exit) +- MLFlow transform NOT called +- Export workflow NOT called +- Workflow completes without error + + +#### Scenario 2.1.3: Input Gate Triggers REPEAT +**Description**: Input gate determines data should repeat last prediction + +**Input**: +- Data with quality issues that require using previous prediction +- `input_filters` configured with `POLICY: 'REPEAT'` +- `path_priority` includes REPEAT + +**Expected Behavior**: +- `input_gate` returns `path_flag='REPEAT'` +- `path_flag_handler` calls `repeat_last_prediction` activity +- MLFlow transform and predict skipped +- Last prediction repeated and exported + +**Assertions**: +- `input_gate` called +- MLFlow operations NOT called +- `repeat_last_prediction` activity called +- Workflow completes + +--- + +### 2.2 Transform gate Early Exit Scenarios + +#### Scenario 2.2.1: Transform Gate Triggers CONTINUE +**Description**: Transform response gate determines data should continue despite issues + +**Input**: +- Valid input data +- Transform response has quality issues but policy is CONTINUE +- `mlflow_transform_filters` configured with `POLICY: 'CONTINUE'` +- `path_priority` includes CONTINUE + +**Expected Behavior**: +- `request_transform` succeeds +- `mlflow_response_gate` for transform returns `path_flag='CONTINUE'` +- `path_flag_handler` calls export workflow with transform data +- MLFlow predict skipped +- Transform data exported as-is + +**Assertions**: +- Transform completed +- `mlflow_response_gate` called for transform +- MLFlow predict NOT called +- Export workflow called with transform data +- Workflow completes + +--- + +#### Scenario 2.2.2: Transform Gate Triggers STOP +**Description**: Transform response validation fails with STOP policy + +**Input**: +- Valid input data +- Transform response has critical errors +- `mlflow_transform_filters` configured with `POLICY: 'STOP'` + +**Expected Behavior**: +- `request_transform` succeeds but response invalid +- `mlflow_response_gate` for transform returns `path_flag='STOP'` +- Workflow exits without calling predict or export + +**Assertions**: +- Transform completed but validation failed +- `mlflow_response_gate` called for transform +- MLFlow predict NOT called +- Export workflow NOT called +- Workflow completes without error + +--- + +#### Scenario 2.2.3: Transform Gate Triggers REPEAT +**Description**: Transform response gate determines data should repeat last prediction + +**Input**: +- Valid input data +- Transform response has quality issues that require using previous prediction +- `mlflow_transform_filters` configured with `POLICY: 'REPEAT'` +- `path_priority` includes REPEAT + +**Expected Behavior**: +- `request_transform` succeeds but response has issues +- `mlflow_response_gate` for transform returns `path_flag='REPEAT'` +- `path_flag_handler` calls `repeat_last_prediction` activity +- MLFlow predict skipped +- Last prediction repeated and exported + +**Assertions**: +- Transform completed but validation triggered REPEAT +- `mlflow_response_gate` called for transform +- MLFlow predict NOT called +- `repeat_last_prediction` activity called +- Workflow completes + +--- + +### 2.3 Predict gate Early Exit Scenarios + +#### Scenario 2.3.1: Predict Gate Triggers CONTINUE +**Description**: Predict response gate determines data should continue despite issues + +**Input**: +- Valid input and transform data +- Predict response has quality issues but policy is CONTINUE +- `mlflow_predict_filters` configured with `POLICY: 'CONTINUE'` +- `path_priority` includes CONTINUE + +**Expected Behavior**: +- `request_predict` succeeds +- `mlflow_response_gate` for predict returns `path_flag='CONTINUE'` +- `path_flag_handler` calls export workflow with predict data +- Prediction exported despite quality issues + +**Assertions**: +- Transform and predict completed +- `mlflow_response_gate` called for predict +- Export workflow called with predict data +- Workflow completes + +--- + +#### Scenario 2.3.2: Predict Gate Triggers STOP +**Description**: Prediction validation fails with STOP policy + +**Input**: +- Valid input and transform +- Predict response has critical errors +- `mlflow_predict_filters` configured with `POLICY: 'STOP'` + +**Expected Behavior**: +- `request_predict` succeeds but response invalid +- `mlflow_response_gate` for predict returns `path_flag='STOP'` +- Workflow exits without export + +**Assertions**: +- Transform completed +- Predict completed but validation failed +- Export workflow NOT called +- Workflow completes without error + +--- + +#### Scenario 2.3.3: Predict Gate Triggers REPEAT +**Description**: Predict response gate determines data should repeat last prediction + +**Input**: +- Valid input and transform data +- Predict response has quality issues that require using previous prediction +- `mlflow_predict_filters` configured with `POLICY: 'REPEAT'` +- `path_priority` includes REPEAT + +**Expected Behavior**: +- `request_predict` succeeds but response has issues +- `mlflow_response_gate` for predict returns `path_flag='REPEAT'` +- `path_flag_handler` calls `repeat_last_prediction` activity +- Last prediction repeated and exported + +**Assertions**: +- Transform and predict completed but validation triggered REPEAT +- `mlflow_response_gate` called for predict +- `repeat_last_prediction` activity called +- Export workflow NOT called with current prediction +- Workflow completes + +--- + +## 3. Format and Export Prediction - Child Workflow Scenarios + +### 3.1 Success Scenarios + +#### Scenario 3.1.1: Default Prediction Export +**Description**: Error prediction path creates default prediction + +**Input**: +- `path_flag: 'ERROR'` or other non-None value (not STOP/CONTINUE/REPEAT) +- `comment` provided with error details + +**Expected Behavior**: +- `format_default_prediction` called instead of `format_prediction` +- Default prediction created with error metadata +- Exported to PostgreSQL only +- Transformed data NOT processed +- Metrics written + +**Assertions**: +- `format_default_prediction` called +- `format_prediction` NOT called +- `format_transformed_data` NOT called +- One PostgreSQL export only +- Default values in prediction data +- Comment included + +--- + +#### Scenario 3.1.2: Export with OPC only +**Description**: Export to PostgreSQL and OPC server only (no PI Web API) + +**Input**: +- `path_flag: None` +- `opc_output_config` configured with valid OPC settings +- `pi_web_api_output_config: None` or `{}` + +**Expected Behavior**: +- Normal formatting +- PostgreSQL export executed +- OPC export executed +- PI Web API activity skipped +- Metrics written with OPC metrics + +**Assertions**: +- PI Web API activity NOT called +- OPC activity called +- PostgreSQL export called +- Metrics written with `opc_metrics` populated + +--- + +#### Scenario 3.1.3: Export with PI Web API only +**Description**: Export to PostgreSQL and PI Web API only (no OPC) + +**Input**: +- `path_flag: None` +- `pi_web_api_output_config` configured with valid PI Web API settings +- `opc_output_config: None` or `{}` + +**Expected Behavior**: +- Normal formatting +- PostgreSQL export executed +- PI Web API export executed +- OPC activity skipped +- Metrics written without OPC metrics + +**Assertions**: +- OPC activity NOT called +- PI Web API activity called +- PostgreSQL export called +- Metrics written with empty `opc_metrics` + +--- + +#### Scenario 3.1.4: Export Without Optional Outputs +**Description**: Export only to PostgreSQL (no OPC or PI Web API) + +**Input**: +- `path_flag: None` +- `opc_output_config: None` or `{}` +- `pi_web_api_output_config: None` or `{}` + +**Expected Behavior**: +- Normal formatting +- Only PostgreSQL export executed +- OPC and PI Web API activities skipped +- Metrics written without OPC metrics + +**Assertions**: +- PI Web API activity NOT called +- OPC activity NOT called +- PostgreSQL export called +- Metrics written with empty `opc_metrics` + +--- + +#### Scenario 3.1.5: Export Without Transformed Data +**Description**: Only prediction exported, no transform table + +**Input**: +- `path_flag: None` +- `transformed_data: None` or `save_transform: False` +- `opc_output_config: None` or `{}` +- `pi_web_api_output_config: None` or `{}` + +**Expected Behavior**: +- Only prediction formatted and exported +- Transform export skipped +- Single PostgreSQL write + +**Assertions**: +- `format_transformed_data` NOT called +- One PostgreSQL export +- Transform table remains empty + +--- + +### 3.2 Error Scenarios + +These paths do **not** rely on Temporal activity retries for export failures: the write activities run once, errors are handled inside the activity, and the **workflow completes successfully** with degraded metadata on the persisted prediction (`prediction_confidence` and `comments`). + +#### Scenario 3.2.1: PI Web API Write Error +**Description**: PI Web API export fails + +**Input**: +- Valid prediction +- PI Web API service unavailable or invalid config + +**Expected Behavior**: +- `write_pi_web_api_data` surfaces the failure (exception handled in the activity layer) +- Notification may be sent +- Workflow **completes** (does not fail) +- Prediction row is still written to PostgreSQL with error confidence **13** and a comment describing the PI error +- Subsequent steps (e.g. OPC, Postgres) still run per workflow order with the updated prediction payload + +**Assertions**: +- PI Web API error notification sent (when applicable) +- Workflow completes +- PostgreSQL contains the prediction with `prediction_confidence` 13 and expected `comments` + +--- + +#### Scenario 3.2.2: OPC Write Error +**Description**: OPC server write fails + +**Input**: +- Valid prediction +- OPC server unavailable or invalid configuration + +**Expected Behavior**: +- `write_opc_data` reports failure without aborting the workflow +- Notification may be sent +- Workflow **completes** (does not fail) +- Prediction row is written to PostgreSQL with OPC error confidence **12** and a comment indicating OPC write issues + +**Assertions**: +- OPC error notification sent (when applicable) +- Workflow completes +- PostgreSQL contains the prediction with `prediction_confidence` 12 and expected `comments` + +--- + +#### Scenario 3.2.4: OPC Session / Channel Bad* (Tier-1) +**Description**: OPC write fails with a Tier-1 session or channel status (e.g. `BadSessionIdInvalid`) while transport may still appear open on the client + +**Input**: +- Valid prediction and OPC output config +- Mock or server returning Tier-1 `UaStatusCodeError` on write (no write retry in the same activity) + +**Expected Behavior**: +- `write_opc_data` fails forward for affected tags; background reconnect may be scheduled if `OPC_RECONNECTION_INTERVAL` allows +- Workflow **completes** +- PostgreSQL row uses **`prediction_confidence` 14** and comment prefix `OPC UA session/channel error:` (including OPC status name) +- `opc_write_attempts_total` records `result=BadSessionIdInvalid` (or matching status); no second write attempt in the same activity + +**Assertions**: +- Workflow completes +- `prediction_confidence = 14` +- `comments` matches `OPC UA session/channel error:%` +- Generic OPC error confidence **12** is not used for this case + +**Reference**: [docs/opc-communication.md](../docs/opc-communication.md), plan `.cursor/plans/opc_bad_reconnect_ac4c6045.plan.md` + +--- + +#### Scenario 3.2.5: OPC Write Blocked During Reconnect +**Description**: A write is attempted while the repository is reconnecting (session not ready) + +**Input**: +- Valid prediction +- Simulated slow reconnect (e.g. delayed `connect`) or concurrent writes where the first triggers reconnect + +**Expected Behavior**: +- Second write (or parallel write) is rejected **immediately** when reconnect is in progress or `_session_ready` is cleared β€” **without** calling `write_value` +- No wait/sleep on the write path; no duplicate `connect` from parallel writers (connection lock) +- `prediction_confidence = 14`, `comments = OPC UA reconnect in progress` (distinguish from Tier-1 `Bad*` via comment prefix in SQL) + +**Assertions**: +- At most one reconnect sequence (`disconnect` + `connect`) for the overlapping window +- No write retry after failure +- Tests in `test_opc_repository` (unit) and optional e2e in `test_predictions_batch_format_export.py` + +--- + +#### Scenario 3.2.3: PI Web API Partial Write Error +**Description**: Two prediction tags attempt to be written to PI Web API, but only one succeeds + +**Input**: +- Valid prediction +- Two prediction tags configured +- PI Web API returns partial success (one tag succeeds, one fails) + +**Expected Behavior**: +- `write_pi_web_api_data` processes response +- `process_pi_web_api_response` detects partial failure +- Error confidence set (13) +- Notification sent for failed tag +- Workflow completes with error confidence (single activity attempt; no retry loop) + +**Assertions**: +- One tag written successfully +- One tag failed +- Error confidence set in prediction +- Error notification sent +- Workflow completes + +--- \ No newline at end of file diff --git a/e2e/test_child_workflows_e2e.py b/e2e/test_child_workflows_e2e.py new file mode 100644 index 0000000..52a4b3f --- /dev/null +++ b/e2e/test_child_workflows_e2e.py @@ -0,0 +1,74 @@ +""" +Direct E2E execution of child workflows (smaller surface than PredictionsBatch). +""" + +from decimal import Decimal + +import pytest +from sqlalchemy import text +from temporalio.testing import WorkflowEnvironment +from temporalio.worker import Worker + +from e2e.helpers import make_workflow_id, start_and_await_workflow +from laborious.workflows.sub_workflows.format_and_export_prediction import FormatAndExportPrediction + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_format_and_export_prediction_default_path_e2e( + temporal_test_env: WorkflowEnvironment, + temporal_worker: Worker, + postgres_engine, +): + """ + Run FormatAndExportPrediction with path_flag set (format_default_prediction path). + """ + client = temporal_test_env.client + model_id = 401 + with postgres_engine.begin() as conn: + conn.execute(text(f'DELETE FROM predictions_schema.predictions WHERE model_id = {model_id}')) + + metadata = { + 'metadata': { + 'model_id': model_id, + 'model_name': 'test_model', + 'schedule_name': 'test-schedule', + 'workflow_name': 'subworkflow.format_and_export_prediction', + } + } + input_data = { + 'metadata': metadata, + 'path_flag': 'CONTINUE', + 'data': {'last_timestamp': '2024-01-01 12:00:00+00:00'}, + 'prediction_confidence': 2, + 'timestamp': '2024-01-01 12:00:00+00:00', + 'model_id': model_id, + 'model_name': 'test_model', + 'schema': 'predictions_schema', + 'table_name': 'predictions', + 'transform_table_name': 'transformed_data', + 'comment': 'e2e child workflow default path', + 'opc_output_config': {}, + 'pi_web_api_output_config': {}, + 'prediction_store_policy': 'lts:1', + } + + await start_and_await_workflow( + client, + FormatAndExportPrediction.run, + input_data, + make_workflow_id('e2e-format-export-child'), + ) + + with postgres_engine.connect() as conn: + row = conn.execute( + text( + f'SELECT prediction, prediction_confidence, prediction_status, comments ' + f'FROM predictions_schema.predictions WHERE model_id = {model_id}' + ) + ).fetchone() + assert row is not None + assert row[0] == 0 + assert row[1] == Decimal(2) + assert row[2] == 'Bad' + assert row[3] == 'e2e child workflow default path' diff --git a/e2e/test_minio_offload.py b/e2e/test_minio_offload.py new file mode 100644 index 0000000..f8c0122 --- /dev/null +++ b/e2e/test_minio_offload.py @@ -0,0 +1,124 @@ +""" +E2E-style tests for MinIO offload using a real MinIO testcontainer. +""" + +from unittest.mock import patch + +import pytest +from sqlalchemy import text +from temporalio.testing import WorkflowEnvironment +from temporalio.worker import Worker + +from e2e.helpers import insert_sample_data, make_workflow_id, start_and_await_workflow +from laborious.activities.activities import Activities +from laborious.utils.models import minio_dataframe_payload as mdp +from laborious.workflows.predictions_batch import PredictionsBatch + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_load_query_with_minio_offload_writes_object_to_bucket( + postgres_engine, + minio_container, + test_activities_real_minio: Activities, +): + """ + With a tiny offload threshold, query results are uploaded as Parquet to MinIO. + + Uses real MinioRepository against testcontainers MinIO (no MinIO mock). + """ + model_id = 501 + with postgres_engine.begin() as conn: + conn.execute(text(f'DELETE FROM predictions_schema.laborious_data WHERE model_id = {model_id}')) + insert_sample_data(postgres_engine, model_id, [1.0, 2.0]) + + metadata = { + 'metadata': { + 'schedule_name': 'test-schedule', + 'model_name': 'test_model', + 'model_id': model_id, + 'workflow_name': 'predictions_batch', + } + } + with patch.object(mdp, 'OFFLOAD_THRESHOLD_BYTES', 1): + payload = await test_activities_real_minio.load_query_with_minio_offload( + { + **metadata, + 'query': ( + 'SELECT timestamp, variable, value, created_at ' + f'FROM predictions_schema.laborious_data WHERE model_id = {model_id}' + ), + 'model_name': 'test_model', + 'datetime_columns': ['timestamp', 'created_at'], + } + ) + assert payload.object_key, 'offloaded payload must reference a MinIO object' + assert payload.data is None or payload.data == {}, 'large payloads should not inline tabular dict' + + df = await payload.retrieve(test_activities_real_minio.minio_repository, metadata['metadata']) + assert len(df) >= 1 + + client = minio_container.get_client() + listed = list(client.list_objects('test-bucket', recursive=True)) + names = [getattr(o, 'object_name', None) or getattr(o, '_object_name', '') for o in listed] + assert any(n and 'prediction_datasets' in n for n in names), f'unexpected object listing: {names!r}' + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_predictions_batch_with_minio_offload_path( + temporal_test_env: WorkflowEnvironment, + temporal_worker_real_minio: Worker, + postgres_engine, + test_activities_real_minio: Activities, +): + """ + Full PredictionsBatch run with offload: load step stores Parquet in MinIO; pipeline completes. + """ + model_id = 502 + with postgres_engine.begin() as conn: + conn.execute(text(f'DELETE FROM predictions_schema.laborious_data WHERE model_id = {model_id}')) + conn.execute(text(f'DELETE FROM predictions_schema.predictions WHERE model_id = {model_id}')) + conn.execute(text(f'DELETE FROM predictions_schema.transformed_data WHERE model_id = {model_id}')) + insert_sample_data(postgres_engine, model_id, [10.0, 20.0, 30.0]) + + input_data = { + 'schedule_name': 'test-schedule', + 'model_name': 'test_model', + 'model_id': model_id, + 'query': ( + 'SELECT timestamp, variable, value, created_at ' + f'FROM predictions_schema.laborious_data WHERE model_id = {model_id}' + ), + 'schema': 'predictions_schema', + 'table_name': 'predictions', + 'transform_table_name': 'transformed_data', + 'input_filters': {'EMPTY_DATA': {'POLICY': 'STOP', 'CONFIG': {}}}, + 'mlflow_transform_filters': {'API_ERROR': {'POLICY': 'STOP', 'CONFIG': {}}}, + 'mlflow_predict_filters': {'API_ERROR': {'POLICY': 'STOP', 'CONFIG': {}}}, + 'path_priority': ['STOP', 'CONTINUE', 'REPEAT'], + 'opc_output_config': {}, + 'pi_web_api_output_config': {}, + 'save_transform': True, + 'prediction_store_policy': 'lts:1', + 'model_config': { + 'retention_minutes': 0, + 'transform_flavor': 'sklearn', + 'predict_flavor': 'sklearn', + }, + 'datetime_columns': ['timestamp', 'created_at'], + } + + with patch.object(mdp, 'OFFLOAD_THRESHOLD_BYTES', 1): + await start_and_await_workflow( + temporal_test_env.client, + PredictionsBatch.run, + input_data, + make_workflow_id('test-batch-minio-offload'), + ) + + with postgres_engine.connect() as conn: + count = conn.execute( + text(f'SELECT COUNT(*) FROM predictions_schema.predictions WHERE model_id = {model_id}') + ).scalar() + assert count == 1 diff --git a/e2e/test_opc_real_server.py b/e2e/test_opc_real_server.py new file mode 100644 index 0000000..200a8a4 --- /dev/null +++ b/e2e/test_opc_real_server.py @@ -0,0 +1,194 @@ +""" +E2E tests for OPC export using an in-process asyncua server and real OpcRepository. + +Covers scenarios 3.1.2, 3.2.2, 3.2.4, and 3.2.5 from e2e/scenarios.md. +Mock-based OPC tests remain in test_predictions_batch_format_export.py. +""" + +import asyncio + +import pytest +from temporalio.testing import WorkflowEnvironment +from temporalio.worker import Worker + +from e2e.helpers import assert_prediction, insert_sample_data, make_workflow_id, start_and_await_workflow +from e2e.opc_test_server import UNKNOWN_NODE_ID, OpcE2ETestServer, build_opc_output_config +from e2e.test_predictions_batch_format_export import get_base_input_data +from laborious.activities.activities import Activities +from laborious.activities.opc import OPC_RECONNECT_IN_PROGRESS_COMMENT +from laborious.utils.repository.opc_repository import OpcRepository +from laborious.workflows.predictions_batch import PredictionsBatch + + +async def _slow_reconnect_under_lock(repo: OpcRepository, hold_seconds: float = 0.75) -> None: + """ + Hold the connection lock briefly so concurrent writes see reconnect_in_progress. + + Args: + repo (OpcRepository): Connected repository. + hold_seconds (float): Time to keep the lock before reconnecting. + """ + async with repo._connection_lock: + await asyncio.sleep(hold_seconds) + await repo._reconnect_locked() + + +@pytest.mark.asyncio +@pytest.mark.integration +@pytest.mark.opc +async def test_scenario_3_1_2_export_with_opc_only_real_server( + temporal_test_env: WorkflowEnvironment, + temporal_worker_real_opc: Worker, + test_activities_real_opc: Activities, + opc_e2e_server: OpcE2ETestServer, + postgres_engine, +): + """ + Scenario 3.1.2 (real OPC): connect, write prediction and confidence, verify server values. + """ + client = temporal_test_env.client + model_id = 412 + insert_sample_data(postgres_engine, model_id, [23.5, 78.2]) + + input_data = get_base_input_data(model_id) + input_data['opc_output_config'] = build_opc_output_config(opc_e2e_server.node_ids) + input_data['pi_web_api_output_config'] = None + + await start_and_await_workflow( + client, + PredictionsBatch.run, + input_data, + make_workflow_id('test-opc-real-happy'), + ) + + test_activities_real_opc.pi_web_api_client.write_value.assert_not_called() + assert await opc_e2e_server.read_prediction() == pytest.approx(0.5) + assert await opc_e2e_server.read_confidence() == pytest.approx(0.0) + assert_prediction(postgres_engine, model_id) + + +@pytest.mark.asyncio +@pytest.mark.integration +@pytest.mark.opc +async def test_scenario_3_2_2_opc_write_error_real_server( + temporal_test_env: WorkflowEnvironment, + temporal_worker_real_opc: Worker, + test_activities_real_opc: Activities, + opc_e2e_server: OpcE2ETestServer, + postgres_engine, +): + """ + Scenario 3.2.2 (real OPC): unknown NodeId yields generic write failure (confidence 12). + """ + client = temporal_test_env.client + model_id = 422 + insert_sample_data(postgres_engine, model_id, [23.5, 78.2]) + + node_ids = opc_e2e_server.node_ids + input_data = get_base_input_data(model_id) + input_data['opc_output_config'] = build_opc_output_config( + node_ids, + prediction_tag=UNKNOWN_NODE_ID, + confidence_tag=UNKNOWN_NODE_ID, + ) + input_data['pi_web_api_output_config'] = None + + await start_and_await_workflow( + client, + PredictionsBatch.run, + input_data, + make_workflow_id('test-opc-real-bad-node'), + ) + + assert_prediction( + postgres_engine, + model_id, + prediction_confidence=12, + comments='Some data could not be written to OPC servers', + ) + + +@pytest.mark.asyncio +@pytest.mark.integration +@pytest.mark.opc +async def test_scenario_3_2_4_opc_session_bad_real_server( + temporal_test_env: WorkflowEnvironment, + temporal_worker_real_opc: Worker, + test_activities_real_opc: Activities, + opc_e2e_server: OpcE2ETestServer, + postgres_engine, +): + """ + Scenario 3.2.4 (real OPC): server PreWrite fault injects BadSessionIdInvalid (confidence 14). + """ + client = temporal_test_env.client + model_id = 424 + insert_sample_data(postgres_engine, model_id, [23.5, 78.2]) + + opc_e2e_server.set_session_bad_on_write(True) + try: + input_data = get_base_input_data(model_id) + input_data['opc_output_config'] = build_opc_output_config( + opc_e2e_server.node_ids, + prediction_only=True, + ) + input_data['pi_web_api_output_config'] = None + + await start_and_await_workflow( + client, + PredictionsBatch.run, + input_data, + make_workflow_id('test-opc-real-session-bad'), + ) + finally: + opc_e2e_server.set_session_bad_on_write(False) + + assert_prediction( + postgres_engine, + model_id, + prediction_confidence=14, + comments_contains='OPC UA session/channel error: BadSessionIdInvalid', + ) + + +@pytest.mark.asyncio +@pytest.mark.integration +@pytest.mark.opc +async def test_scenario_3_2_5_opc_write_blocked_during_reconnect_real_server( + temporal_test_env: WorkflowEnvironment, + temporal_worker_real_opc: Worker, + test_activities_real_opc: Activities, + opc_e2e_server: OpcE2ETestServer, + postgres_engine, +): + """ + Scenario 3.2.5 (real OPC): writes rejected while reconnect holds the connection lock. + """ + client = temporal_test_env.client + model_id = 425 + insert_sample_data(postgres_engine, model_id, [23.5, 78.2]) + + repo = test_activities_real_opc.opc_repository['1'] + repo._session_ready.clear() + reconnect_task = asyncio.create_task(_slow_reconnect_under_lock(repo)) + + input_data = get_base_input_data(model_id) + input_data['opc_output_config'] = build_opc_output_config(opc_e2e_server.node_ids) + input_data['pi_web_api_output_config'] = None + + try: + await start_and_await_workflow( + client, + PredictionsBatch.run, + input_data, + make_workflow_id('test-opc-real-reconnect-block'), + ) + finally: + await reconnect_task + + assert_prediction( + postgres_engine, + model_id, + prediction_confidence=14, + comments_contains=OPC_RECONNECT_IN_PROGRESS_COMMENT, + ) diff --git a/e2e/test_predictions_batch_format_export.py b/e2e/test_predictions_batch_format_export.py new file mode 100644 index 0000000..5582d0c --- /dev/null +++ b/e2e/test_predictions_batch_format_export.py @@ -0,0 +1,786 @@ +""" +End-to-end tests for PredictionsBatch workflow - Format and Export scenarios. +""" + +from decimal import Decimal +from typing import Any, cast +from unittest.mock import ANY, AsyncMock, call + +import pytest +from sientia_do.notifications.models import NotificationLevel +from sqlalchemy import text +from temporalio.testing import WorkflowEnvironment +from temporalio.worker import Worker + +from e2e.helpers import assert_prediction, insert_sample_data, make_workflow_id, start_and_await_workflow +from laborious.activities.activities import Activities +from laborious.workflows.predictions_batch import PredictionsBatch + +base_input_data = { + 'schedule_name': 'test-schedule', + 'model_name': 'test_model', + 'model_id': 301, + 'query': 'SELECT timestamp, variable, value, created_at FROM predictions_schema.laborious_data WHERE model_id = 301', + 'schema': 'predictions_schema', + 'table_name': 'predictions', + 'transform_table_name': 'transformed_data', + 'input_filters': { + 'EMPTY_DATA': {'POLICY': 'STOP', 'CONFIG': {}}, + }, + 'mlflow_transform_filters': { + 'API_ERROR': {'POLICY': 'STOP', 'CONFIG': {}}, + }, + 'mlflow_predict_filters': { + 'API_ERROR': {'POLICY': 'STOP', 'CONFIG': {}}, + }, + 'path_priority': ['STOP', 'CONTINUE', 'REPEAT'], + 'opc_output_config': {}, + 'pi_web_api_output_config': {}, + 'save_transform': True, + 'prediction_store_policy': 'lts:1', + 'model_config': { + 'retention_minutes': 0, + 'transform_flavor': 'sklearn', + 'predict_flavor': 'sklearn', + }, + 'datetime_columns': ['timestamp', 'created_at'], +} + +base_query = "SELECT timestamp, variable, value, created_at FROM predictions_schema.laborious_data WHERE model_id = {model_id}" + +def get_base_input_data(model_id): + return { + **base_input_data, + 'model_id': model_id, + 'query': base_query.format(model_id=model_id), + } + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_scenario_3_1_1_default_prediction_export( + temporal_test_env: WorkflowEnvironment, + temporal_worker: Worker, + test_activities: Activities, + postgres_engine, +): + """ + Scenario 3.1.1: Default prediction export (non-None path_flag). + + Triggers input_gate CONTINUE via SPECIFIC_VARIABLES_NULL_VALUES so + PredictionProcess calls FormatAndExportPrediction with path_flag set. + That workflow uses format_default_prediction (not format_prediction) and + skips format_transformed_data / transform Postgres export. + + Optional PI Web API and OPC outputs still run when configured. + """ + client = temporal_test_env.client + + model_id = 311 + + with postgres_engine.begin() as conn: + conn.execute(text(f"DELETE FROM predictions_schema.predictions WHERE model_id = {model_id}")) + conn.execute(text(f"DELETE FROM predictions_schema.transformed_data WHERE model_id = {model_id}")) + insert_sample_data(postgres_engine, model_id, ['NULL', 78.2]) + + input_data = get_base_input_data(model_id) + input_data['input_filters'] = { + 'SPECIFIC_VARIABLES_NULL_VALUES': { + 'POLICY': 'CONTINUE', + 'CONFIG': {'variables': ['sensor_1']}, + }, + } + input_data['pi_web_api_output_config'] = { + 'endpoint': 'test_endpoint', + 'prediction_tags': {'tag_1': 'web_id_1'}, + 'confidence_tags': {'tag_2': 'web_id_2'}, + } + input_data['opc_output_config'] = { + '1': { + 'prediction_tags': { + 'addr_1': { + 'data_type': 'float', + } + }, + 'confidence_tags': { + 'addr_2': { + 'data_type': 'float', + } + }, + } + } + + wid = make_workflow_id('test-default-prediction') + + await start_and_await_workflow(client, PredictionsBatch.run, input_data, wid) + + test_activities.pi_web_api_client.write_value.assert_has_calls( + [ + call( + web_ids=['web_id_1'], + value={ + 'Timestamp': '2024-01-01 12:00:00+0000', + 'Value': 0, + }, + metadata={ + 'model_id': 311, + 'model_name': 'test_model', + 'schedule_name': 'test-schedule', + 'workflow_name': 'predictions_batch', + }, + ), + call( + web_ids=['web_id_2'], + value={ + 'Timestamp': '2024-01-01 12:00:00+0000', + 'Value': 2, + }, + metadata={ + 'model_id': 311, + 'model_name': 'test_model', + 'schedule_name': 'test-schedule', + 'workflow_name': 'predictions_batch', + }, + ), + ], + any_order=True, + ) + + opc_write_data = cast(Any, test_activities.opc_repository['1'].write_data) + opc_write_data.assert_has_calls( + [ + call( + 'addr_1', + 0, + 'float', + { + 'model_id': 311, + 'model_name': 'test_model', + 'schedule_name': 'test-schedule', + 'workflow_name': 'predictions_batch', + }, + ), + call( + 'addr_2', + 2, + 'float', + { + 'model_id': 311, + 'model_name': 'test_model', + 'schedule_name': 'test-schedule', + 'workflow_name': 'predictions_batch', + }, + ), + ] + ) + + with postgres_engine.connect() as conn: + tf_count = conn.execute( + text(f"SELECT COUNT(*) FROM predictions_schema.transformed_data WHERE model_id = {model_id}") + ).scalar() + assert tf_count == 0, 'transform export must be skipped when path_flag is set' + + assert_prediction( + postgres_engine, + model_id, + prediction=0, + prediction_confidence=Decimal(2), + prediction_status='Bad', + comments='Input data with bad quality', + ) + + + + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_scenario_3_1_2_export_with_opc_only( + temporal_test_env: WorkflowEnvironment, + temporal_worker: Worker, + test_activities: Activities, + postgres_engine, +): + """ + Scenario 3.1.2: Export with OPC only + + Description: + Export to PostgreSQL and OPC server only (no PI Web API). + + Expected Behavior: + - Normal formatting + - PostgreSQL export executed + - OPC export executed + - PI Web API activity skipped + - Metrics written with OPC metrics + + Assertions: + - PI Web API activity NOT called + - OPC activity called + - PostgreSQL export called + - Metrics written with opc_metrics populated + """ + client = temporal_test_env.client + + model_id = 312 + + insert_sample_data(postgres_engine, model_id, [23.5, 78.2]) + + input_data = get_base_input_data(model_id) + input_data['opc_output_config'] = { + '1': { + 'prediction_tags': { + 'addr_1': { + 'data_type': 'float', + } + }, + 'confidence_tags': { + 'addr_2': { + 'data_type': 'float', + } + }, + } + } + input_data['pi_web_api_output_config'] = None # No PI Web API config + + await start_and_await_workflow( + client, PredictionsBatch.run, input_data, make_workflow_id('test-opc-only') + ) + + opc_write_data = cast(Any, test_activities.opc_repository['1'].write_data) + opc_write_data.assert_has_calls( + [ + call( + 'addr_1', + 0.5, + 'float', + { + 'model_id': 312, + 'model_name': 'test_model', + 'schedule_name': 'test-schedule', + 'workflow_name': 'predictions_batch', + }, + ), + call( + 'addr_2', + 0, + 'float', + { + 'model_id': 312, + 'model_name': 'test_model', + 'schedule_name': 'test-schedule', + 'workflow_name': 'predictions_batch', + }, + ), + ] + ) + + test_activities.pi_web_api_client.write_value.assert_not_called() + + assert_prediction(postgres_engine, model_id) + + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_scenario_3_1_3_export_with_pi_web_api_only( + temporal_test_env: WorkflowEnvironment, + temporal_worker: Worker, + test_activities: Activities, + postgres_engine, +): + """ + Scenario 3.1.3: Export with PI Web API only + + Description: + Export to PostgreSQL and PI Web API only (no OPC). + + Expected Behavior: + - Normal formatting + - PostgreSQL export executed + - PI Web API export executed + - OPC activity skipped + - Metrics written without OPC metrics + + Assertions: + - OPC activity NOT called + - PI Web API activity called + - PostgreSQL export called + - Metrics written with empty opc_metrics + """ + client = temporal_test_env.client + + model_id = 313 + + insert_sample_data(postgres_engine, model_id, [23.5, 78.2]) + + input_data = get_base_input_data(model_id) + input_data['pi_web_api_output_config'] = { + 'endpoint': 'test_endpoint', + 'prediction_tags': {'tag_1': 'web_id_1'}, + 'confidence_tags': {'tag_2': 'web_id_2'}, + } + input_data['opc_output_config'] = None # No OPC config + + await start_and_await_workflow( + client, PredictionsBatch.run, input_data, make_workflow_id('test-pi-api-only') + ) + + test_activities.pi_web_api_client.write_value.assert_has_calls( + [ + call( + web_ids=['web_id_1'], + value={ + 'Timestamp': '2024-01-01 12:00:00+0000', + 'Value': 0.5, + }, + metadata={ + 'model_id': 313, + 'model_name': 'test_model', + 'schedule_name': 'test-schedule', + 'workflow_name': 'predictions_batch', + }, + ), + call( + web_ids=['web_id_2'], + value={ + 'Timestamp': '2024-01-01 12:00:00+0000', + 'Value': 0, + }, + metadata={ + 'model_id': 313, + 'model_name': 'test_model', + 'schedule_name': 'test-schedule', + 'workflow_name': 'predictions_batch', + }, + ), + ], + any_order=True, + ) + + opc_write_data = cast(Any, test_activities.opc_repository['1'].write_data) + opc_write_data.assert_not_called() + + assert_prediction(postgres_engine, model_id) + + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_scenario_3_1_4_export_without_optional_outputs( + temporal_test_env: WorkflowEnvironment, + temporal_worker: Worker, + test_activities: Activities, + postgres_engine, +): + """ + Scenario 3.1.4: Export Without Optional Outputs + + Description: + Export only to PostgreSQL (no OPC or PI Web API). + + Expected Behavior: + - Normal formatting + - Only PostgreSQL export executed + - OPC and PI Web API activities skipped + - Metrics written without OPC metrics + + Assertions: + - PI Web API activity NOT called + - OPC activity NOT called + - PostgreSQL export called + - Metrics written with empty opc_metrics + """ + client = temporal_test_env.client + + model_id = 314 + + insert_sample_data(postgres_engine, model_id, [23.5, 78.2]) + + input_data = get_base_input_data(model_id) + input_data['opc_output_config'] = None # No OPC config + input_data['pi_web_api_output_config'] = None # No PI Web API config + + await start_and_await_workflow( + client, PredictionsBatch.run, input_data, make_workflow_id('test-no-optional-outputs') + ) + + test_activities.pi_web_api_client.write_value.assert_not_called() + opc_write_data = cast(Any, test_activities.opc_repository['1'].write_data) + opc_write_data.assert_not_called() + + assert_prediction(postgres_engine, model_id) + + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_scenario_3_1_5_export_without_transformed_data( + temporal_test_env: WorkflowEnvironment, + temporal_worker: Worker, + test_activities: Activities, + postgres_engine, +): + """ + Scenario 3.1.5: Export Without Transformed Data + + Description: + Only prediction exported, no transform table. + + Expected Behavior: + - Only prediction formatted and exported + - Transform export skipped + - Single PostgreSQL write + + Assertions: + - format_transformed_data NOT called + - One PostgreSQL export + - Transform table remains empty + """ + client = temporal_test_env.client + + model_id = 315 + + insert_sample_data(postgres_engine, model_id, [23.5, 78.2]) + with postgres_engine.begin() as conn: + conn.execute(text(f"DELETE FROM predictions_schema.transformed_data WHERE model_id = {model_id}")) + + input_data = get_base_input_data(model_id) + input_data['save_transform'] = False # Don't save transformed data + input_data['pi_web_api_output_config'] = { + 'endpoint': 'test_endpoint', + 'prediction_tags': {'tag_1': 'web_id_1'}, + 'confidence_tags': {'tag_2': 'web_id_2'}, + } + input_data['opc_output_config'] = { + '1': { + 'prediction_tags': { + 'addr_1': { + 'data_type': 'float', + } + }, + 'confidence_tags': { + 'addr_2': { + 'data_type': 'float', + } + }, + } + } + + await start_and_await_workflow( + client, PredictionsBatch.run, input_data, make_workflow_id('test-no-transform-export') + ) + + test_activities.pi_web_api_client.write_value.assert_has_calls( + [ + call( + web_ids=['web_id_1'], + value={ + 'Timestamp': '2024-01-01 12:00:00+0000', + 'Value': 0.5, + }, + metadata={ + 'model_id': 315, + 'model_name': 'test_model', + 'schedule_name': 'test-schedule', + 'workflow_name': 'predictions_batch', + }, + ), + call( + web_ids=['web_id_2'], + value={ + 'Timestamp': '2024-01-01 12:00:00+0000', + 'Value': 0, + }, + metadata={ + 'model_id': 315, + 'model_name': 'test_model', + 'schedule_name': 'test-schedule', + 'workflow_name': 'predictions_batch', + }, + ), + ], + any_order=True, + ) + + opc_write_data = cast(Any, test_activities.opc_repository['1'].write_data) + opc_write_data.assert_has_calls( + [ + call( + 'addr_1', + 0.5, + 'float', + { + 'model_id': 315, + 'model_name': 'test_model', + 'schedule_name': 'test-schedule', + 'workflow_name': 'predictions_batch', + }, + ), + call( + 'addr_2', + 0, + 'float', + { + 'model_id': 315, + 'model_name': 'test_model', + 'schedule_name': 'test-schedule', + 'workflow_name': 'predictions_batch', + }, + ), + ] + ) + + with postgres_engine.connect() as conn: + result_query = conn.execute( + text(f"SELECT COUNT(*) FROM predictions_schema.transformed_data WHERE model_id = {model_id}") + ) + count = result_query.scalar() + assert count == 0, f"Expected transform table to be empty, but found {count} records" + + assert_prediction(postgres_engine, model_id) + + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_scenario_3_2_1_pi_web_api_write_error( + temporal_test_env: WorkflowEnvironment, + temporal_worker: Worker, + test_activities: Activities, + postgres_engine, + notification_inserts, +): + """ + Scenario 3.2.1: PI Web API Write Error + + Export failure is handled inside the activity; there is no retry loop. The + workflow completes and PostgreSQL stores prediction_confidence 13 and the + error message in comments. + """ + client = temporal_test_env.client + + model_id = 321 + + insert_sample_data(postgres_engine, model_id, [23.5, 78.2]) + + test_activities.pi_web_api_client.write_value.side_effect = Exception( + "PI Web API service unavailable") + + input_data = get_base_input_data(model_id) + input_data['pi_web_api_output_config'] = { + 'endpoint': 'test_endpoint', + 'prediction_tags': {'tag_1': 'web_id_1'}, + 'confidence_tags': {'tag_2': 'web_id_2'}, + } + input_data['opc_output_config'] = { + '1': { + 'prediction_tags': { + 'addr_1': { + 'data_type': 'float', + } + }, + 'confidence_tags': { + 'addr_2': { + 'data_type': 'float', + } + }, + } + } + + await start_and_await_workflow( + client, PredictionsBatch.run, input_data, make_workflow_id('test-pi-api-error') + ) + + assert_prediction( + postgres_engine, model_id, + prediction_confidence=13, + comments='PI Web API service unavailable', + ) + assert notification_inserts.call_count >= 1 + + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_scenario_3_2_2_opc_write_error( + temporal_test_env: WorkflowEnvironment, + temporal_worker: Worker, + test_activities: Activities, + postgres_engine, +): + """ + Scenario 3.2.2: OPC Write Error + + OPC failure is reported without failing the workflow; there is no retry + loop. PostgreSQL stores prediction_confidence 12 and OPC error comments. + """ + client = temporal_test_env.client + + model_id = 322 + + insert_sample_data(postgres_engine, model_id, [23.5, 78.2]) + + opc_write_data = cast(Any, test_activities.opc_repository['1'].write_data) + opc_write_data.return_value = (False, { + 'notification_id': 'OPC_WRITE_DATA_ERROR_1', + 'message': 'OPC server unavailable', + 'block': 'opc_repository', + 'level': NotificationLevel.ERROR, + 'attachment_content': 'OPC server unavailable', + }) + + input_data = get_base_input_data(model_id) + input_data['opc_output_config'] = { + '1': { + 'prediction_tags': { + 'addr_1': { + 'data_type': 'float', + } + }, + 'confidence_tags': { + 'addr_2': { + 'data_type': 'float', + } + }, + } + } + input_data['pi_web_api_output_config'] = { + 'endpoint': 'test_endpoint', + 'prediction_tags': {'tag_1': 'web_id_1'}, + 'confidence_tags': {'tag_2': 'web_id_2'}, + } + + await start_and_await_workflow( + client, PredictionsBatch.run, input_data, make_workflow_id('test-opc-error') + ) + + assert_prediction( + postgres_engine, model_id, + prediction_confidence=12, + comments='Some data could not be written to OPC servers', + ) + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_scenario_3_2_4_opc_session_bad_error( + temporal_test_env: WorkflowEnvironment, + temporal_worker: Worker, + test_activities: Activities, + postgres_engine, +): + """ + Scenario 3.2.4: OPC session/channel Tier-1 Bad* (e.g. BadSessionIdInvalid). + + PostgreSQL stores prediction_confidence 14 and a stable session error comment. + """ + client = temporal_test_env.client + + model_id = 324 + + insert_sample_data(postgres_engine, model_id, [23.5, 78.2]) + + opc_write_data = cast(Any, test_activities.opc_repository['1'].write_data) + opc_write_data.return_value = ( + False, + { + 'notification_id': 'OPC_WRITE_DATA_ERROR_1', + 'message': 'BadSessionIdInvalid', + 'block': 'opc_repository', + 'level': NotificationLevel.ERROR, + 'attachment_content': 'BadSessionIdInvalid', + 'opc_error_kind': 'session_bad', + 'opc_status': 'BadSessionIdInvalid', + }, + ) + + input_data = get_base_input_data(model_id) + input_data['opc_output_config'] = { + '1': { + 'prediction_tags': { + 'addr_1': { + 'data_type': 'float', + } + }, + 'confidence_tags': { + 'addr_2': { + 'data_type': 'float', + } + }, + } + } + input_data['pi_web_api_output_config'] = None + + await start_and_await_workflow( + client, PredictionsBatch.run, input_data, make_workflow_id('test-opc-session-bad') + ) + + assert_prediction( + postgres_engine, + model_id, + prediction_confidence=14, + comments='OPC UA session/channel error: BadSessionIdInvalid', + ) + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_scenario_3_2_3_pi_web_api_partial_write_error( + temporal_test_env: WorkflowEnvironment, + temporal_worker: Worker, + test_activities: Activities, + postgres_engine, +): + """ + Scenario 3.2.3: PI Web API Partial Write Error + + Partial PI write: confidence 13, descriptive comments, workflow completes + without an activity retry loop. + """ + client = temporal_test_env.client + + model_id = 323 + + insert_sample_data(postgres_engine, model_id, [23.5, 78.2]) + + test_activities.pi_web_api_client.write_value = AsyncMock( + side_effect=[ + # Prediction batch: two web_ids requested, only one acknowledged. + [{'WebId': 'web_id_1', 'Errors': []}], + # Confidence write succeeds. + [{'WebId': 'web_id_2', 'Errors': []}], + ] + ) + + input_data = get_base_input_data(model_id) + input_data['pi_web_api_output_config'] = { + 'endpoint': 'test_endpoint', + 'prediction_tags': {'tag_1': 'web_id_1', 'tag_3': 'web_id_3'}, + 'confidence_tags': {'tag_2': 'web_id_2'}, + } + input_data['opc_output_config'] = { + '1': { + 'prediction_tags': { + 'addr_1': { + 'data_type': 'float', + } + }, + 'confidence_tags': { + 'addr_2': { + 'data_type': 'float', + } + }, + } + } + + await start_and_await_workflow( + client, PredictionsBatch.run, input_data, make_workflow_id('test-pi-api-partial-error') + ) + + assert_prediction( + postgres_engine, model_id, + prediction_confidence=13, + comments="The number of written tags does not match the number of tag names: Expected ['tag_1', 'tag_3'] tags, but ['tag_1'] tags were written.", + ) + diff --git a/e2e/test_predictions_batch_main_workflow.py b/e2e/test_predictions_batch_main_workflow.py new file mode 100644 index 0000000..3f8d37f --- /dev/null +++ b/e2e/test_predictions_batch_main_workflow.py @@ -0,0 +1,303 @@ +""" +End-to-end tests for PredictionsBatch workflow - Main workflow scenarios. +""" + +import asyncio + +from sqlalchemy import text +from temporalio.testing import WorkflowEnvironment +from temporalio.worker import Worker + +import pytest + +from e2e.helpers import make_workflow_id, start_and_await_workflow +from laborious.activities.activities import Activities +from laborious.workflows.predictions_batch import PredictionsBatch + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_scenario_1_1_1_happy_path_complete_success( + temporal_test_env: WorkflowEnvironment, + temporal_worker: Worker, + test_activities: Activities, + postgres_engine, +): + """Scenario 1.1.1: Happy path with SQL load, MLflow mocks, Postgres predictions and transforms.""" + client = temporal_test_env.client + + with postgres_engine.begin() as conn: + conn.execute(text('DELETE FROM predictions_schema.laborious_data WHERE model_id = 123')) + insert_sql = """ + INSERT INTO predictions_schema.laborious_data (model_id, variable, value, timestamp, created_at) + VALUES + (123, 'sensor_1', 23.5, '2024-01-01 12:00:00+00:00', '2024-01-01 12:00:00+00:00'), + (123, 'sensor_2', 78.2, '2024-01-01 12:00:00+00:00', '2024-01-01 12:00:00+00:00'), + (123, 'sensor_3', 120.8, '2024-01-01 12:00:00+00:00', '2024-01-01 12:00:00+00:00') + """ + conn.execute(text(insert_sql)) + + input_data = { + 'metadata': { + 'metadata': { + 'model_id': 123, + 'model_name': 'test_model', + 'schedule_name': 'test-schedule', + 'workflow_name': 'predictions_batch', + } + }, + 'schedule_name': 'test-schedule', + 'model_name': 'test_model', + 'model_id': 123, + 'query': 'SELECT timestamp, variable, value, created_at FROM predictions_schema.laborious_data WHERE model_id = 123', + 'schema': 'predictions_schema', + 'table_name': 'predictions', + 'transform_table_name': 'transformed_data', + 'input_filters': { + 'EMPTY_DATA': {'POLICY': 'STOP', 'CONFIG': {}}, + }, + 'mlflow_transform_filters': { + 'API_ERROR': {'POLICY': 'STOP', 'CONFIG': {}}, + }, + 'mlflow_predict_filters': { + 'API_ERROR': {'POLICY': 'STOP', 'CONFIG': {}}, + }, + 'path_priority': ['STOP', 'CONTINUE', 'REPEAT'], + 'opc_output_config': {}, + 'pi_web_api_output_config': {}, + 'save_transform': True, + 'prediction_store_policy': 'lts:1', + 'model_config': { + 'retention_minutes': 0, + 'transform_flavor': 'sklearn', + 'predict_flavor': 'sklearn', + }, + 'datetime_columns': ['timestamp', 'created_at'], + } + + await start_and_await_workflow( + client, + PredictionsBatch.run, + input_data, + make_workflow_id('test-predictions-batch'), + ) + + schema_name = 'predictions_schema' + with postgres_engine.connect() as conn: + result_query = conn.execute( + text( + f'SELECT model_id, prediction, prediction_confidence, response_time, prediction_status, comments ' + f'FROM {schema_name}.predictions WHERE model_id = 123' + ) + ) + prediction_rows = result_query.fetchall() + assert len(prediction_rows) == 1 + row = prediction_rows[0] + assert row[0] == 123 + assert row[1] == 0.5 + assert row[2] == 0, f'Expected prediction_confidence=0, got {row[2]}' + assert row[3] is not None + assert row[4] == 'Good' + assert row[5] == '' + + result_query = conn.execute( + text( + f'SELECT model_id, variable, value FROM {schema_name}.transformed_data WHERE model_id = 123' + ) + ) + transformed_rows = result_query.fetchall() + assert len(transformed_rows) == 2 + assert transformed_rows[0][0] == 123 + assert transformed_rows[0][1] == 'feature_1' + assert float(transformed_rows[0][2]) == 0.234 + assert transformed_rows[1][0] == 123 + assert transformed_rows[1][1] == 'feature_2' + assert float(transformed_rows[1][2]) == 0.783 + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_scenario_1_2_1_sql_query_execution_error( + temporal_test_env: WorkflowEnvironment, + temporal_worker: Worker, + test_activities: Activities, + postgres_engine, +): + """Invalid SQL: workflow may complete with early exit; no prediction rows.""" + client = temporal_test_env.client + + input_data = { + 'metadata': { + 'metadata': { + 'model_id': 128, + 'model_name': 'test_model', + 'schedule_name': 'test-schedule', + 'workflow_name': 'predictions_batch', + } + }, + 'schedule_name': 'test-schedule', + 'model_name': 'test_model', + 'model_id': 128, + 'query': 'SELECT * FROM nonexistent_table WHERE invalid_syntax =', + 'schema': 'predictions_schema', + 'table_name': 'predictions', + 'transform_table_name': 'transformed_data', + 'input_filters': { + 'EMPTY_DATA': {'POLICY': 'STOP', 'CONFIG': {}}, + }, + 'mlflow_transform_filters': { + 'API_ERROR': {'POLICY': 'STOP', 'CONFIG': {}}, + }, + 'mlflow_predict_filters': { + 'API_ERROR': {'POLICY': 'STOP', 'CONFIG': {}}, + }, + 'path_priority': ['STOP', 'CONTINUE', 'REPEAT'], + 'opc_output_config': {}, + 'pi_web_api_output_config': {}, + 'save_transform': True, + 'prediction_store_policy': 'lts:1', + 'model_config': { + 'retention_minutes': 0, + 'transform_flavor': 'sklearn', + 'predict_flavor': 'sklearn', + }, + } + + await start_and_await_workflow( + client, + PredictionsBatch.run, + input_data, + make_workflow_id('test-sql-error'), + ) + + with postgres_engine.connect() as conn: + count = conn.execute( + text('SELECT COUNT(*) FROM predictions_schema.predictions WHERE model_id = 128') + ).scalar() + assert count == 0 + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_scenario_1_2_2_missing_required_parameters( + temporal_test_env: WorkflowEnvironment, + temporal_worker: Worker, + test_activities: Activities, + postgres_engine, +): + """Missing query: workflow does not produce predictions and is terminated explicitly.""" + client = temporal_test_env.client + + input_data = { + 'metadata': { + 'metadata': { + 'model_id': 129, + 'model_name': 'test_model', + 'schedule_name': 'test-schedule', + 'workflow_name': 'predictions_batch', + } + }, + 'schedule_name': 'test-schedule', + 'model_name': 'test_model', + 'model_id': 129, + 'schema': 'predictions_schema', + 'table_name': 'predictions', + 'transform_table_name': 'transformed_data', + } + + handle = await client.start_workflow( + PredictionsBatch.run, + input_data, + id=make_workflow_id('test-missing-param'), + task_queue='test-queue', + ) + + # Let Temporal process a few workflow tasks; for this case, result() can hang. + await asyncio.sleep(2.0) + + with postgres_engine.connect() as conn: + count = conn.execute( + text('SELECT COUNT(*) FROM predictions_schema.predictions WHERE model_id = 129') + ).scalar() + assert count == 0 + + await handle.terminate('expected failure path in e2e test (missing required parameters)') + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_scenario_1_2_3_invalid_datetime_column_specification( + temporal_test_env: WorkflowEnvironment, + temporal_worker: Worker, + test_activities: Activities, + postgres_engine, +): + """Invalid datetime column: no predictions persisted; workflow terminated after validation.""" + client = temporal_test_env.client + + with postgres_engine.begin() as conn: + conn.execute(text('DELETE FROM predictions_schema.laborious_data WHERE model_id = 130')) + conn.execute( + text( + """ + INSERT INTO predictions_schema.laborious_data (model_id, variable, value, timestamp, created_at) + VALUES (130, 'sensor_1', 23.5, '2024-01-01 12:00:00+00:00', '2024-01-01 12:00:00+00:00') + """ + ) + ) + + input_data = { + 'metadata': { + 'metadata': { + 'model_id': 130, + 'model_name': 'test_model', + 'schedule_name': 'test-schedule', + 'workflow_name': 'predictions_batch', + } + }, + 'schedule_name': 'test-schedule', + 'model_name': 'test_model', + 'model_id': 130, + 'query': 'SELECT timestamp, variable, value FROM predictions_schema.laborious_data WHERE model_id = 130', + 'schema': 'predictions_schema', + 'table_name': 'predictions', + 'transform_table_name': 'transformed_data', + 'input_filters': { + 'EMPTY_DATA': {'POLICY': 'STOP', 'CONFIG': {}}, + }, + 'mlflow_transform_filters': { + 'API_ERROR': {'POLICY': 'STOP', 'CONFIG': {}}, + }, + 'mlflow_predict_filters': { + 'API_ERROR': {'POLICY': 'STOP', 'CONFIG': {}}, + }, + 'path_priority': ['STOP', 'CONTINUE', 'REPEAT'], + 'opc_output_config': {}, + 'pi_web_api_output_config': {}, + 'save_transform': True, + 'prediction_store_policy': 'lts:1', + 'model_config': { + 'retention_minutes': 0, + 'transform_flavor': 'sklearn', + 'predict_flavor': 'sklearn', + }, + 'datetime_columns': ['nonexistent_column'], + } + + handle = await client.start_workflow( + PredictionsBatch.run, + input_data, + id=make_workflow_id('test-invalid-datetime-col'), + task_queue='test-queue', + ) + + # Let Temporal process and surface the failure path internally. + await asyncio.sleep(2.0) + + with postgres_engine.connect() as conn: + count = conn.execute( + text('SELECT COUNT(*) FROM predictions_schema.predictions WHERE model_id = 130') + ).scalar() + assert count == 0 + + await handle.terminate('expected failure path in e2e test (invalid datetime column)') diff --git a/e2e/test_predictions_batch_prediction_process.py b/e2e/test_predictions_batch_prediction_process.py new file mode 100644 index 0000000..af097cd --- /dev/null +++ b/e2e/test_predictions_batch_prediction_process.py @@ -0,0 +1,379 @@ +""" +End-to-end tests for PredictionsBatch workflow - Prediction Process scenarios. +""" + +from decimal import Decimal +from unittest.mock import MagicMock, patch + +import numpy as np +import pandas as pd +import pytest +from sqlalchemy import text +from temporalio.testing import WorkflowEnvironment +from temporalio.worker import Worker + +from e2e.helpers import ( + assert_continue, + assert_repeat, + assert_stop, + insert_sample_data, + make_workflow_id, + start_and_await_workflow, +) +from laborious.activities.activities import Activities +from laborious.workflows.predictions_batch import PredictionsBatch + +base_input_data = { + 'schedule_name': 'test-schedule', + 'model_name': 'test_model', + 'model_id': 201, + 'query': 'SELECT timestamp, variable, value, created_at FROM predictions_schema.laborious_data WHERE model_id = 201', + 'schema': 'predictions_schema', + 'table_name': 'predictions', + 'transform_table_name': 'transformed_data', + 'input_filters': { + 'SPECIFIC_VARIABLES_NULL_VALUES': { + 'POLICY': 'CONTINUE', + 'CONFIG': {'variables': ['sensor_1']}, + }, + }, + 'mlflow_transform_filters': { + 'API_ERROR': {'POLICY': 'STOP', 'CONFIG': {}}, + }, + 'mlflow_predict_filters': { + 'API_ERROR': {'POLICY': 'STOP', 'CONFIG': {}}, + }, + 'path_priority': ['STOP', 'CONTINUE', 'REPEAT'], + 'opc_output_config': {}, + 'pi_web_api_output_config': {}, + 'save_transform': True, + 'prediction_store_policy': 'lts:1', + 'model_config': { + 'retention_minutes': 0, + 'transform_flavor': 'sklearn', + 'predict_flavor': 'sklearn', + }, + 'datetime_columns': ['timestamp', 'created_at'], +} + +base_query = 'SELECT timestamp, variable, value, created_at FROM predictions_schema.laborious_data WHERE model_id = {model_id}' + + +def get_base_input_data(model_id): + return { + **base_input_data, + 'model_id': model_id, + 'query': base_query.format(model_id=model_id), + } + + +def insert_sample_prediction(postgres_engine, model_id): + with postgres_engine.begin() as conn: + conn.execute(text(f'DELETE FROM predictions_schema.predictions WHERE model_id = {model_id}')) + insert_sql = f""" + INSERT INTO predictions_schema.predictions (model_id, timestamp, prediction, prediction_confidence, prediction_status, comments, response_time) + VALUES + ({model_id}, '2024-01-01 12:00:00+00:00', 10, 0, 'Good', '', 0.1) + """ + conn.execute(text(insert_sql)) + return (model_id, Decimal(10), Decimal(0), 'Good') + + +@pytest.fixture +def bad_data_model(patch_mlflow): + model = MagicMock(predict=MagicMock(side_effect=Exception('Bad data model'))) + patch_mlflow.sklearn.load_model = MagicMock(return_value=model) + return model + + +@pytest.fixture +def bad_predict_model(patch_mlflow, mock_mlflow_models): + model = MagicMock(predict=MagicMock(side_effect=Exception('Bad predict model'))) + + def mock_sklearn_load_model(model_uri): + if 'data_model' in model_uri or 'transform' in model_uri.lower(): + return mock_mlflow_models['transform_model'] + return model + + patch_mlflow.sklearn = MagicMock() + patch_mlflow.sklearn.load_model = MagicMock(side_effect=mock_sklearn_load_model) + return model + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_scenario_2_1_1_input_gate_triggers_continue( + temporal_test_env: WorkflowEnvironment, + temporal_worker: Worker, + test_activities: Activities, + postgres_engine, + mock_mlflow_models, +): + """Input gate CONTINUE: export default prediction; MLflow transform/predict not used.""" + client = temporal_test_env.client + model_id = 211 + insert_sample_data(postgres_engine, model_id, ['NULL', 78.2]) + input_data = get_base_input_data(model_id) + await start_and_await_workflow( + client, PredictionsBatch.run, input_data, make_workflow_id('test-continue-policy') + ) + assert_continue(postgres_engine, model_id) + mock_mlflow_models['transform_model'].predict.assert_not_called() + mock_mlflow_models['predict_model'].predict.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_scenario_2_1_2_input_gate_triggers_stop( + temporal_test_env: WorkflowEnvironment, + temporal_worker: Worker, + test_activities: Activities, + postgres_engine, + mock_mlflow_models, +): + """Input gate STOP: no export, no MLflow.""" + client = temporal_test_env.client + model_id = 212 + insert_sample_data(postgres_engine, model_id, ['NULL', 78.2]) + input_data = get_base_input_data(model_id) + input_data['input_filters']['SPECIFIC_VARIABLES_NULL_VALUES']['POLICY'] = 'STOP' + await start_and_await_workflow( + client, PredictionsBatch.run, input_data, make_workflow_id('test-input-stop') + ) + assert_stop(postgres_engine, model_id) + mock_mlflow_models['transform_model'].predict.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_scenario_2_1_3_input_gate_triggers_repeat( + temporal_test_env: WorkflowEnvironment, + temporal_worker: Worker, + test_activities: Activities, + postgres_engine, + mock_mlflow_models, +): + """Input gate REPEAT with existing history.""" + client = temporal_test_env.client + model_id = 213 + insert_sample_data(postgres_engine, model_id, ['NULL', 78.2]) + data = insert_sample_prediction(postgres_engine, model_id) + input_data = get_base_input_data(model_id) + input_data['input_filters']['SPECIFIC_VARIABLES_NULL_VALUES']['POLICY'] = 'REPEAT' + await start_and_await_workflow( + client, PredictionsBatch.run, input_data, make_workflow_id('test-input-repeat') + ) + assert_repeat(postgres_engine, model_id, data) + mock_mlflow_models['transform_model'].predict.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_scenario_2_1_4_input_gate_repeat_without_prior_prediction( + temporal_test_env: WorkflowEnvironment, + temporal_worker: Worker, + test_activities: Activities, + postgres_engine, +): + """REPEAT when no prior row in predictions: repeat_last_prediction runs; still no new duplicate export path.""" + client = temporal_test_env.client + model_id = 214 + insert_sample_data(postgres_engine, model_id, ['NULL', 78.2]) + with postgres_engine.begin() as conn: + conn.execute(text(f'DELETE FROM predictions_schema.predictions WHERE model_id = {model_id}')) + input_data = get_base_input_data(model_id) + input_data['input_filters']['SPECIFIC_VARIABLES_NULL_VALUES']['POLICY'] = 'REPEAT' + await start_and_await_workflow( + client, PredictionsBatch.run, input_data, make_workflow_id('test-input-repeat-no-history') + ) + assert_stop(postgres_engine, model_id) + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_scenario_2_2_1_transform_gate_triggers_continue( + temporal_test_env: WorkflowEnvironment, + temporal_worker: Worker, + test_activities: Activities, + postgres_engine, + bad_data_model, +): + client = temporal_test_env.client + model_id = 221 + insert_sample_data(postgres_engine, model_id, [60.0, 78.2]) + input_data = get_base_input_data(model_id) + input_data['mlflow_transform_filters']['API_ERROR']['POLICY'] = 'CONTINUE' + await start_and_await_workflow( + client, PredictionsBatch.run, input_data, make_workflow_id('test-transform-continue') + ) + assert_continue( + postgres_engine=postgres_engine, + model_id=model_id, + prediction_confidence=Decimal(10), + comments='Unknown MLFlow API error', + ) + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_scenario_2_2_2_transform_gate_triggers_stop( + temporal_test_env: WorkflowEnvironment, + temporal_worker: Worker, + test_activities: Activities, + postgres_engine, + bad_data_model, + mock_mlflow_models, +): + client = temporal_test_env.client + model_id = 222 + insert_sample_data(postgres_engine, model_id, [60.0, 78.2]) + input_data = get_base_input_data(model_id) + input_data['mlflow_transform_filters']['API_ERROR']['POLICY'] = 'STOP' + await start_and_await_workflow( + client, PredictionsBatch.run, input_data, make_workflow_id('test-transform-stop') + ) + assert_stop(postgres_engine, model_id) + mock_mlflow_models['predict_model'].predict.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_scenario_2_2_3_transform_gate_triggers_repeat( + temporal_test_env: WorkflowEnvironment, + temporal_worker: Worker, + test_activities: Activities, + postgres_engine, + bad_data_model, +): + client = temporal_test_env.client + model_id = 223 + insert_sample_data(postgres_engine, model_id, [60.0, 78.2]) + data = insert_sample_prediction(postgres_engine, model_id) + input_data = get_base_input_data(model_id) + input_data['mlflow_transform_filters']['API_ERROR']['POLICY'] = 'REPEAT' + await start_and_await_workflow( + client, PredictionsBatch.run, input_data, make_workflow_id('test-transform-repeat') + ) + assert_repeat(postgres_engine, model_id, data) + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_scenario_2_2_4_transform_content_gate_nan_values_stop( + temporal_test_env: WorkflowEnvironment, + temporal_worker: Worker, + test_activities: Activities, + postgres_engine, + mock_mlflow_models, +): + """mlflow_content_gate triggers STOP when transform output is all NaN (NAN_VALUES filter).""" + client = temporal_test_env.client + model_id = 224 + + def all_nan_transform(data): + num_rows = max(len(data), 1) if hasattr(data, '__len__') else 1 + result = pd.DataFrame({'feature_1': [np.nan] * num_rows, 'feature_2': [np.nan] * num_rows}) + result.index = data.index + return result + + mock_mlflow_models['transform_model'].predict = MagicMock(side_effect=all_nan_transform) + + insert_sample_data(postgres_engine, model_id, [60.0, 78.2]) + input_data = get_base_input_data(model_id) + input_data['mlflow_transform_filters'] = { + 'API_ERROR': {'POLICY': 'STOP', 'CONFIG': {}}, + 'NAN_VALUES': {'POLICY': 'STOP', 'CONFIG': {}}, + } + await start_and_await_workflow( + client, PredictionsBatch.run, input_data, make_workflow_id('test-transform-content-stop') + ) + assert_stop(postgres_engine, model_id) + mock_mlflow_models['predict_model'].predict.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_scenario_2_3_1_predict_gate_triggers_continue( + temporal_test_env: WorkflowEnvironment, + temporal_worker: Worker, + test_activities: Activities, + postgres_engine, + bad_predict_model, +): + client = temporal_test_env.client + model_id = 231 + insert_sample_data(postgres_engine, model_id, [60.0, 78.2]) + input_data = get_base_input_data(model_id) + input_data['mlflow_predict_filters']['API_ERROR']['POLICY'] = 'CONTINUE' + await start_and_await_workflow( + client, PredictionsBatch.run, input_data, make_workflow_id('test-predict-continue') + ) + assert_continue( + postgres_engine=postgres_engine, + model_id=model_id, + prediction_confidence=Decimal(10), + comments='Unknown MLFlow API error', + ) + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_scenario_2_3_2_predict_gate_triggers_stop( + temporal_test_env: WorkflowEnvironment, + temporal_worker: Worker, + test_activities: Activities, + postgres_engine, + bad_predict_model, +): + client = temporal_test_env.client + model_id = 232 + insert_sample_data(postgres_engine, model_id, [23.5, 78.2]) + input_data = get_base_input_data(model_id) + input_data['mlflow_predict_filters']['API_ERROR']['POLICY'] = 'STOP' + await start_and_await_workflow( + client, PredictionsBatch.run, input_data, make_workflow_id('test-predict-stop') + ) + assert_stop(postgres_engine, model_id) + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_scenario_2_3_3_predict_gate_triggers_repeat( + temporal_test_env: WorkflowEnvironment, + temporal_worker: Worker, + test_activities: Activities, + postgres_engine, + bad_predict_model, +): + client = temporal_test_env.client + model_id = 233 + insert_sample_data(postgres_engine, model_id, [23.5, 78.2]) + data = insert_sample_prediction(postgres_engine, model_id) + input_data = get_base_input_data(model_id) + input_data['mlflow_predict_filters']['API_ERROR']['POLICY'] = 'REPEAT' + input_data['path_priority'] = ['REPEAT', 'STOP', 'CONTINUE'] + await start_and_await_workflow( + client, PredictionsBatch.run, input_data, make_workflow_id('test-predict-repeat') + ) + assert_repeat(postgres_engine, model_id, data) + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_scenario_2_4_1_input_empty_data_stop( + temporal_test_env: WorkflowEnvironment, + temporal_worker: Worker, + test_activities: Activities, + postgres_engine, +): + """EMPTY_DATA filter with STOP when query returns no rows (offload payload empty).""" + client = temporal_test_env.client + model_id = 241 + with postgres_engine.begin() as conn: + conn.execute(text(f'DELETE FROM predictions_schema.laborious_data WHERE model_id = {model_id}')) + input_data = get_base_input_data(model_id) + input_data['input_filters'] = {'EMPTY_DATA': {'POLICY': 'STOP', 'CONFIG': {}}} + await start_and_await_workflow( + client, PredictionsBatch.run, input_data, make_workflow_id('test-empty-data-stop') + ) + assert_stop(postgres_engine, model_id) diff --git a/encode.sh b/encode.sh new file mode 100755 index 0000000..2beb0d1 --- /dev/null +++ b/encode.sh @@ -0,0 +1,13 @@ +source ./venv/bin/activate + +pip install pathspec +pip install pyyaml + +echo " +.git" >> .gitignore + +python encrypt.py ./ code --ignore .gitignore --chunk-size 100000 + +sed -i '/.git/d' .gitignore + +xdg-open . \ No newline at end of file diff --git a/encrypt.py b/encrypt.py new file mode 100644 index 0000000..ad990a6 --- /dev/null +++ b/encrypt.py @@ -0,0 +1,113 @@ +import os +import argparse +from pathspec import PathSpec +import yaml # type: ignore +from typing import Any + +''' +Usage: + python .\encrypt.py path_to_dir output_file --ignore ignore_file --chunk-size 100000 +''' + + +def load_ignore_patterns(ignore_file, include_library): + # Ensure the .gitignore file exists + if not os.path.exists(ignore_file): + raise FileNotFoundError(f"Ignore file not found at {ignore_file}") + + # Load and parse the .gitignore patterns + with open(ignore_file, 'r') as file: + patterns = file.readlines() + if not include_library: + patterns.append('**/deploy/library/') + + spec = PathSpec.from_lines('gitwildmatch', patterns) + return spec + + +def is_ignored(file_path, spec): + """Check if a file should be ignored based on the ignore patterns.""" + return spec.match_file(file_path) if spec else False + + +def encode_file_tree_to_yaml(directory, ignore_file, include_library): + """Encode the file tree into a single YAML file.""" + ignore_patterns = load_ignore_patterns( + ignore_file, include_library) if ignore_file else None + file_tree: dict[str, Any] = {} + + for root, dirs, files in os.walk(directory): + # Skip ignored directories + dirs[:] = [d for d in dirs if not is_ignored( + os.path.join(root, d), ignore_patterns)] + + for file in files: + file_path = os.path.join(root, file) + + # Skip ignored files + if is_ignored(file_path, ignore_patterns): + continue + + # Read file content + try: + with open(file_path, 'r', encoding='utf-8') as f: + content = f.read() + except Exception as e: + print(f"Error reading file {file_path}: {e}") + raise + + # Create nested dictionary structure + path_parts = os.path.relpath(file_path, directory).split(os.sep) + current_level = file_tree + + # all except the last part (the file name) + for part in path_parts[:-1]: + current_level = current_level.setdefault(part, {}) + + # Add the file and its content + current_level[path_parts[-1]] = content + return yaml.dump(file_tree, default_flow_style=False) + + +def chunk_and_write_file_tree_to_yaml(yaml_content, output_file, chunk_size=None): + """Chunk the YAML content and write it to the output file.""" + + chunks = [yaml_content] if chunk_size is None else [ + yaml_content[i:i + chunk_size] for i in range(0, len(yaml_content), chunk_size)] + + for i, chunk in enumerate(chunks): + chunk_file = f"{output_file}_{i}.yaml" + # Write the file tree to the output YAML file + with open(chunk_file, 'w', encoding='utf-8') as yaml_file: + yaml_file.write(chunk) + + +def main(): + parser = argparse.ArgumentParser( + description="Encrypts file tree to yaml file") + parser.add_argument("input_directory", help="Directory to encode") + parser.add_argument("output_yaml_file", help="Output YAML file") + parser.add_argument("--ignore", default=None, + help="Path to the ignore file") + parser.add_argument("--chunk-size", type=int, default=None, + help="Chunk size for the output YAML file") + parser.add_argument("--library", type=bool, default=False, + help="Incude the library in the output YAML file") + + # Parse arguments + args = parser.parse_args() + + # Example usage + directory_to_encode = args.input_directory + ignore_file_path = args.ignore + output_yaml_file = args.output_yaml_file + include_library = args.library + + content = encode_file_tree_to_yaml( + directory_to_encode, ignore_file_path, include_library) + chunk_and_write_file_tree_to_yaml( + content, output_yaml_file, args.chunk_size) + + +if __name__ == "__main__": + main() diff --git a/git-requirements-mapping.txt b/git-requirements-mapping.txt new file mode 100644 index 0000000..24095da --- /dev/null +++ b/git-requirements-mapping.txt @@ -0,0 +1,2 @@ +git+ssh://git@github.com/Aignosi/sientia-dataops-library.git:sientia-do +git+ssh://git@github.com/Aignosi/sientia-mlops-library.git:sientia \ No newline at end of file diff --git a/inter_arrival.py b/inter_arrival.py new file mode 100644 index 0000000..bce9cee --- /dev/null +++ b/inter_arrival.py @@ -0,0 +1,25 @@ +# %% + +# Load logs.txt +with open('logs.txt', 'r') as file: + lines = file.readlines() + +# %% +import re +# Grep "inter-arrival_s=number" with regex +intervals = [] +for line in lines: + match = re.search(r'inter-arrival_s=([0-9.]+)', line) + if match: + intervals.append(float(match.group(1))) +# %% + +print(intervals) +# %% +import matplotlib.pyplot as plt +plt.plot(intervals) +plt.ylabel('Inter-arrival time (s)') +plt.xlabel('Sample') +plt.title('Inter-arrival time distribution') +plt.show() +# %% diff --git a/laborious/__init__.py b/laborious/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/laborious/activities/__init__.py b/laborious/activities/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/laborious/activities/activities.py b/laborious/activities/activities.py new file mode 100644 index 0000000..9a03f54 --- /dev/null +++ b/laborious/activities/activities.py @@ -0,0 +1,169 @@ +from sientia_do.observability.metrics_controller import MetricsController +from temporalio import workflow + +with workflow.unsafe.imports_passed_through(): + from typing import Any + + from sientia_do.notifications.handlers import CoreNotificationHandler as NotificationHandler + from sientia_do.observability.logger import Logger + from sientia_do.repository.minio_repository import MinioRepository + + from laborious.activities.api import API + from laborious.activities.gates import Gates + from laborious.activities.mlflow import MLFlow + from laborious.activities.model_metrics import ModelMetrics + from laborious.activities.opc import OPC + from laborious.activities.storage import Storage + + +class Activities(Storage, MLFlow, Gates, OPC, ModelMetrics, API): + """ + Main activities orchestrator for the Laborious system. + + This class combines functionality from multiple activity classes to provide + a unified interface for all workflow operations. It manages database connections, + MLFlow model interactions, data quality validation, and OPC server communications. + + The class implements multiple inheritance to combine specialized functionality: + - Storage: Database operations and data persistence + - MLFlow: Model inference and transformation operations + - Gates: Data quality validation and filtering mechanisms + - OPC: Real-time data export to OPC servers + - ModelMetrics: Model performance metrics and drift detection + - API: PI Web API export operations for industrial systems + + Attributes: + postgres_config (dict): PostgreSQL connection configuration + mlflow_config (dict): MLFlow server configuration + opc_config (dict): OPC server configuration + pi_web_api_config (dict): PI Web API server configuration + logger (Logger): Logging and observability instance + notification_handler (NotificationHandler): Notification management instance + """ + + def __init__( + self, + postgres_config: dict[str, Any], + mlflow_config: dict[str, Any], + minio_config: dict[str, Any], + opc_config: dict[str, Any], + pi_web_api_config: dict[str, Any], + logger: Logger, + notification_handler: NotificationHandler, + ): + """ + Initialize the Activities orchestrator with all required configurations. + + This constructor initializes all parent classes with their respective + configurations and sets up the foundation for all activity operations. + + Args: + postgres_config: PostgreSQL connection configuration dictionary + Required keys: host, port, user, password, dbname, min_connections, max_connections + mlflow_config: MLFlow server configuration dictionary + Required keys: host, port, username, password + opc_config: OPC server configuration dictionary + Can contain multiple server configurations + pi_web_api_config: PI Web API server configuration dictionary + Required keys: base_url, auth_type, auth_token + logger: Logger instance for observability and debugging + notification_handler: Notification handler for alerts and monitoring + + Raises: + Exception: If any parent class initialization fails + """ + metrics_controller = MetricsController(logger=logger) + + minio_repository = MinioRepository( + endpoint=minio_config['endpoint_url'], + access_key=minio_config['access_key'], + secret_key=minio_config['secret_key'], + bucket=minio_config['default_bucket'], + logger=logger, + notification_handler=notification_handler, + metrics_controller=metrics_controller, + secure=minio_config['secure'], + ) + + # Initialize parent classes + Storage.__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'], + retention_hours=minio_config['retention_hours'], + minio_repository=minio_repository, + logger=logger, + notification_handler=notification_handler, + metrics_controller=metrics_controller, + ) + + MLFlow.__init__( + self, + mlflow_host=mlflow_config['host'], + mlflow_port=mlflow_config['port'], + mlflow_username=mlflow_config['username'], + mlflow_password=mlflow_config['password'], + minio_repository=minio_repository, + logger=logger, + notification_handler=notification_handler, + metrics_controller=metrics_controller, + ) + + Gates.__init__( + self, + minio_repository=minio_repository, + logger=logger, + notification_handler=notification_handler, + metrics_controller=metrics_controller, + ) + + OPC.__init__( + self, + opc_servers=opc_config, + logger=logger, + notification_handler=notification_handler, + metrics_controller=metrics_controller, + ) + + ModelMetrics.__init__( + self, + logger=logger, + notification_handler=notification_handler, + metrics_controller=metrics_controller, + ) + + API.__init__( + self, + base_url=pi_web_api_config['base_url'], + auth_type=pi_web_api_config['auth_type'], + auth_token=pi_web_api_config['auth_token'], + logger=logger, + notification_handler=notification_handler, + metrics_controller=metrics_controller, + ) + + async def shutdown(self): + """ + Gracefully shutdown all activities and clean up resources. + + This method ensures proper cleanup of all resources including: + - PostgreSQL connection pools + - OPC server connections + - PI Web API client connections + - MLFlow model repositories + - Any other resources that need explicit cleanup + + The method should be called before the application terminates to ensure + proper resource cleanup and prevent resource leaks. + """ + Storage.close(self) + MLFlow.close(self) + Gates.close(self) + await OPC.close(self) + ModelMetrics.close(self) + API.close(self) diff --git a/laborious/activities/api.py b/laborious/activities/api.py new file mode 100644 index 0000000..0e4114f --- /dev/null +++ b/laborious/activities/api.py @@ -0,0 +1,305 @@ +from temporalio import activity, workflow + +with workflow.unsafe.imports_passed_through(): + import json + 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.metrics_controller import MetricsController + from sientia_do.observability.sientia_monitoring import SientiaMonitoring + from sientia_do.repository.pi_web_api_client import PIWebAPIClient + + from laborious import metrics + + +PI_WEB_API_PREDICTION_ERROR_CONFIDENCE = 13 + + +class API(SientiaMonitoring): + """ + PI Web API operations for writing prediction data to PI Web API. + + This class provides Temporal activities for interacting with the PI Web API + to write prediction and confidence values to industrial systems. It handles + error scenarios gracefully by setting error confidence values and sending + notifications when write operations fail. + + The class implements comprehensive error handling for both prediction and + confidence value writes, ensuring that partial failures are properly + reported and handled. + """ + + 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-Laborious-Agent/1.0', + }, + ) + + def get_pi_web_api_core_labels( + self, + metadata: dict[str, Any], + operation_type: str = 'write_pi_web_api_data', + ) -> dict[str, Any]: + """ + Generate core labels for PI Web API metrics. + + PI Web API metrics in laborious use the shared ``CORE_LABELS`` from + ``sientia_do``, which includes ``operation_type``. For this reason, + operation_type must always be present in emitted labels. + + Args: + - metadata (dict[str, Any]): Workflow execution metadata used to derive labels. + - operation_type (str): Operation type label for metric cardinality. + + Return: + dict[str, Any]: Core labels dictionary including operation_type. + """ + return super().get_core_labels( + metadata=metadata, + operation_type=operation_type, + ) + + def close(self) -> None: + """ + Close the PI Web API client and shutdown monitoring services. + + This method properly closes all connections and resources associated + with the PI Web API client and monitoring services. + """ + self.pi_web_api_client.close() + SientiaMonitoring.shutdown(self) + + async def process_pi_web_api_response( + self, + response_data: list[dict[str, Any]], + tags: dict[str, str], + core_labels: dict[str, str], + metadata: dict[str, Any], + ) -> tuple[int, str]: + """ + Process the response data from PI Web API write operation. + + Validates that all tags were successfully written, emits metrics for each tag + (success or error), and returns the appropriate prediction confidence value. + Sets error confidence if any tag write fails or if the number of written tags + doesn't match the expected count. + + Args: + - response_data (dict[str, Any]): The response data from the PI Web API write operation. + - tags (dict[str, str]): The tags that were written to the PI Web API. + - core_labels (dict[str, str]): The core labels of the workflow execution. + - metadata (dict[str, Any]): The metadata of the workflow execution. + Returns: + int: Prediction confidence value (0 for success, 13 for errors) + """ + + # Convert tags from name:webid to webid:name + tags = {w: t for t, w in tags.items()} + + tag_names = list[str](tags.values()) + + confidence = 0 + + message = '' + + # Evaluate response for each tag + written_tags = [] + for item in response_data: + web_id = item.get('WebId') + if not web_id: + self.error('The response did not contain some WebIds', metadata) + continue + errors = item.get('Errors', []) + tag_name = tags.get(web_id) + if not tag_name: + self.error( + f'The response did not contain the tag name for WebId {web_id}', metadata + ) + continue + if errors: + self.error( + f'Error writing tag {tag_name}:{web_id} to PI Web API: {errors}', metadata + ) + await self.emit_metric( + metric_object=metrics.PI_WEB_API_PREDICTION_WRITTEN_ERROR_COUNT, + tags={ + **core_labels, + 'tag_name': tag_name, + }, + ) + confidence = PI_WEB_API_PREDICTION_ERROR_CONFIDENCE + else: + await self.emit_metric( + metric_object=metrics.PI_WEB_API_PREDICTION_WRITTEN_COUNT, + tags={ + **core_labels, + 'tag_name': tag_name, + }, + ) + written_tags.append(tag_name) + + if len(written_tags) != len(tag_names): + message = f'The number of written tags does not match the number of tag names: Expected {tag_names} tags, but {written_tags} tags were written.' + + self.error( + f'{message}\nResponse:\n {json.dumps(response_data, indent=4)}\nTags:\n {json.dumps(tags, indent=4)}', + metadata, + ) + + await self.send_notification_async( + metadata=metadata, + notification_id='WRITE_PI_WEB_API_PREDICTION_ERROR', + message=f'The number of written tags does not match the number of tag names: Expected {tag_names} tags, but {written_tags} tags were written.\nResponse:\n {json.dumps(response_data, indent=4)}\nTags:\n {json.dumps(tags, indent=4)}', + block='write_pi_web_api_data', + level=NotificationLevel.ERROR, + ) + confidence = PI_WEB_API_PREDICTION_ERROR_CONFIDENCE + + return confidence, message + + @activity.defn(name='write_pi_web_api_data') + async def write_pi_web_api_data(self, input_data: dict[str, Any]) -> dict[Any, Any]: + """ + Write prediction and confidence data to PI Web API. + + Writes prediction values and confidence scores to PI Web API using configured + web IDs. Processes responses to validate writes and emit metrics. Handles errors + gracefully by setting error confidence values when writes fail and sending + notifications for both prediction and confidence write errors. + + Args: + input_data (dict[str, Any]): The input data containing: + - metadata (dict[str, Any]): Workflow execution metadata + - pi_web_api_output_config (dict[str, Any]): PI Web API configuration with: + - endpoint (str): PI Web API endpoint URL + - prediction_tags (dict[str, str]): Mapping of tag names to web IDs for predictions + - confidence_tags (dict[str, str]): Mapping of tag names to web IDs for confidence + - data (dict[str, Any]): Prediction data, its a dataframe converted to dict. + Returns: + dict[Any, Any]: Data dictionary with potentially modified confidence values + If prediction write fails, prediction_confidence is set to error value (13) + """ + metadata = input_data['metadata'] + data = DataFrame(input_data['data']) + pi_web_api_output_config = input_data['pi_web_api_output_config'] + + self.info(f'Writing data to PI Web API... config: {pi_web_api_output_config}', metadata) + + raw_prediction_tags = pi_web_api_output_config['prediction_tags'] + raw_confidence_tags = pi_web_api_output_config['confidence_tags'] + prediction_tags = list[str](raw_prediction_tags.values()) + confidence_tags = list(raw_confidence_tags.values()) + + core_labels = self.get_pi_web_api_core_labels(metadata) + + prediction_value = data.head(1)['prediction'].values[0] + confidence_value = data.head(1)['prediction_confidence'].values[0] + + try: + prediction_response = await self.pi_web_api_client.write_value( + web_ids=prediction_tags, + value={ + 'Timestamp': data.head(1)['timestamp'].values[0], + 'Value': prediction_value, + }, + metadata=metadata, + ) + + confidence, message = await self.process_pi_web_api_response( + response_data=prediction_response, + tags=raw_prediction_tags, + core_labels=core_labels, + metadata=metadata, + ) + + # Preserve incoming confidence/comments on successful PI writes. + # Only downgrade confidence or override comments when PI response + # explicitly reports a problem (e.g. partial write mismatch). + if confidence != 0: + data['prediction_confidence'] = confidence + if message: + data['comments'] = message + + except Exception as e: + trace = traceback.format_exc() + await self.send_notification_async( + metadata=metadata, + notification_id='WRITE_PI_WEB_API_PREDICTION_ERROR', + message=f'Error writing prediction data to PI Web API: {e}\n Tags: {raw_prediction_tags}', + block='write_pi_web_api_data', + level=NotificationLevel.ERROR, + attachment_content=trace, + ) + + self.error(trace, metadata) + + data['prediction_confidence'] = PI_WEB_API_PREDICTION_ERROR_CONFIDENCE + data['comments'] = str(e) + + return data.to_dict() + + try: + confidence_response = await self.pi_web_api_client.write_value( + web_ids=confidence_tags, + value={ + 'Timestamp': data.head(1)['timestamp'].values[0], + 'Value': float(confidence_value), + }, + metadata=metadata, + ) + + await self.process_pi_web_api_response( + response_data=confidence_response, + tags=raw_confidence_tags, + core_labels=core_labels, + metadata=metadata, + ) + + except Exception as e: + trace = traceback.format_exc() + await self.send_notification_async( + metadata=metadata, + notification_id='WRITE_PI_WEB_API_CONFIDENCE_ERROR', + message=f'Error writing confidence data to PI Web API: {e}\n Tags: {raw_confidence_tags}', + block='write_pi_web_api_data', + level=NotificationLevel.ERROR, + attachment_content=trace, + ) + + return data.to_dict() diff --git a/laborious/activities/gates.py b/laborious/activities/gates.py new file mode 100644 index 0000000..ad88ca1 --- /dev/null +++ b/laborious/activities/gates.py @@ -0,0 +1,804 @@ +from sientia_do.repository.minio_repository import MinioRepository +from temporalio import activity, workflow + +from laborious.utils.repository.minio_manager import MinioManager + +with workflow.unsafe.imports_passed_through(): + import traceback + from collections.abc import Callable, Mapping + 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.metrics_controller import MetricsController + from sientia_do.utils.formatters import create_sample_dict + + from laborious import metrics + from laborious.utils.dataframe_debug import build_dataframe_debug_message + from laborious.utils.filters.conditional_filters import ( + filter_empty_data, + filter_specific_variables_null_values, + ) + from laborious.utils.filters.mlflow_filters import api_error_filter, nan_values_filter + from laborious.utils.models.minio_dataframe_payload import MinioDataFramePayload + +# Strongly-typed filter function signatures +InputFilterFunc = Callable[[DataFrame, dict[str, Any]], bool] +ResponseFilterFunc = Callable[[dict[str, Any], dict[str, Any]], bool] +ContentFilterFunc = Callable[[DataFrame, dict[str, Any]], bool] + +# Input filter function mappings +input_filter_functions: dict[str, InputFilterFunc] = { + 'SPECIFIC_VARIABLES_NULL_VALUES': filter_specific_variables_null_values, + 'EMPTY_DATA': filter_empty_data, +} + +# Confidence mappings kept separate from function maps to avoid Union types +input_path_confidence: Mapping[str, int] = { + 'STOP': -1, + 'CONTINUE': 2, + 'REPEAT': -1, +} + +# MLFlow response filter function mappings +mlflow_response_filter_functions: dict[str, ResponseFilterFunc] = { + 'API_ERROR': api_error_filter, +} + +mlflow_response_path_confidence: Mapping[str, int] = { + 'STOP': -1, + 'CONTINUE': 10, + 'REPEAT': -1, +} + +# MLFlow content filter function mappings +mlflow_content_filter_functions: dict[str, ContentFilterFunc] = { + 'NAN_VALUES': nan_values_filter, + 'EMPTY_DATA': filter_empty_data, +} + +mlflow_content_path_confidence: Mapping[str, int] = { + 'STOP': -1, + 'CONTINUE': 18, + 'REPEAT': -1, +} + + +class Gates(MinioManager): + """ + Data quality gates and filtering activities for the Laborious system. + + This class implements comprehensive data quality validation and filtering + mechanisms that can be applied at different stages of the prediction pipeline. + It provides configurable filters with policy-based decision making to ensure + data integrity and quality throughout the ML workflow. + + The class supports multiple filter types and implements a flexible policy + system that can be configured for different validation requirements. Each + filter returns a path decision (STOP, CONTINUE, REPEAT) along with confidence + scores and detailed comments for monitoring and debugging. + + Attributes: + input_filter_functions (dict): Mapping of input filter names to functions + mlflow_response_filter_functions (dict): Mapping of MLFlow response filter names to functions + mlflow_content_filter_functions (dict): Mapping of MLFlow content filter names to functions + """ + + minio_repository: MinioRepository | None = None + _MAX_DEBUG_DATAFRAME_ROWS = 100 + + def __init__( + self, + minio_repository: MinioRepository | None = None, + logger: Logger | None = None, + notification_handler: NotificationHandler | None = None, + metrics_controller: MetricsController | None = None, + ): + """ + Initialize data quality gates with logging and notification capabilities. + + Args: + logger: Logger instance for observability and debugging + notification_handler: Notification handler for alerts and monitoring + + Raises: + Exception: If BaseActivity initialization fails + """ + MinioManager.__init__( + self, minio_repository, logger, notification_handler, metrics_controller + ) + + def close(self) -> None: + """ + Close the gates activity and clean up resources. + """ + + MinioManager.close(self) + + def __del__(self): + self.close() + + def _debug_dataframe(self, message: str, data: Any, metadata: dict[str, Any]) -> None: + """ + Log dataframe content only when row count is below the configured threshold + + Args: + - message (str): Base log message to identify the dataframe in logs + - data (Any): Dataframe-like payload to be logged + - metadata (dict[str, Any]): Workflow metadata for contextual logging + """ + self.debug( + build_dataframe_debug_message( + message=message, + data=data, + max_rows=self._MAX_DEBUG_DATAFRAME_ROWS, + ), + metadata, + ) + + @staticmethod + def _read_filter_entry(config: dict[str, Any]) -> tuple[str, dict[str, Any]]: + """ + Read filter policy/config keys in a case-insensitive way. + + Args: + config (dict[str, Any]): Filter configuration dictionary. + + Return: + tuple[str, dict[str, Any]]: Parsed policy and config payload. + """ + normalized = {str(key).upper(): value for key, value in config.items()} + policy = normalized['POLICY'] + filter_config = normalized.get('CONFIG', {}) + return policy, filter_config + + @activity.defn(name='input_gate') + async def input_gate(self, input_data: dict[str, Any]) -> tuple[str | None, int, str]: + """ + Apply input data quality filters and validation. + + This activity validates input data quality using configurable filters + before proceeding with ML operations. It applies multiple filter types + and returns a path decision based on the filter results and configured + policies. + + The method implements a comprehensive filtering system that: + 1. Applies configured filters to input data + 2. Evaluates filter results against policy configurations + 3. Determines appropriate path decisions (STOP, CONTINUE, REPEAT) + 4. Provides confidence scores and detailed comments + 5. Handles errors gracefully with notification integration + + Args: + input_data: Configuration and data for input validation + Required keys: + - metadata (dict): Workflow execution metadata + - filters (dict): Filter configuration and policies + - data (dict): Input data to validate + - path_priority (list[str]): Priority order for path decisions + + Returns: + tuple: (path_flag, confidence, comment) + - path_flag (str | None): Decision path (STOP, CONTINUE, REPEAT, or None) + - confidence (int): Confidence score for the decision + - comment (str): Detailed explanation of the decision + + Raises: + Exception: If filter execution fails or configuration is invalid + """ + metadata = input_data['metadata'] + + self.info('Performing input gate...', metadata) + + filters = input_data['filters'] + payload = MinioDataFramePayload.from_dict(input_data['data']) + data = await payload.retrieve(self.minio_repository, metadata) + path_priority = input_data['path_priority'] + + filter_output = [] + + self._debug_dataframe('Input data:', data, metadata) + self.debug(f'Filters: {filters}', metadata) + + # Apply each configured filter + for fil, config in filters.items(): + if fil not in input_filter_functions: + self.error(f'Filter {fil} not found', metadata) + continue + policy, filter_config = self._read_filter_entry(config) + try: + if input_filter_functions[fil](data, filter_config): + self.debug(f'Data not passed the input filter {fil}:{config}', metadata) + filter_output.append(policy) + except Exception as e: + trace = traceback.format_exc() + await self.send_notification_async( + metadata=metadata, + notification_id=f'INTPUT_GATE_ERROR__{fil}', + message=f'Error in filter {fil}:{config}: \n {e}', + block='input_gate', + level=NotificationLevel.ERROR, + attachment_content=trace, + ) + + for path_flag in path_priority: + if path_flag in filter_output: + self.info(f'Input gate result: {path_flag}', metadata) + return path_flag, input_path_confidence[path_flag], 'Input data with bad quality' + + self.info('Nothing was filtered by the input gate', metadata) + + del data + + return None, 0, '' + + @activity.defn(name='mlflow_response_gate') + async def mlflow_response_gate(self, input_data: dict[str, Any]) -> tuple[str | None, int, str]: + """ + Validate MLFlow API response quality and integrity. + + This activity validates MLFlow API responses to ensure they meet quality + standards before proceeding with further processing. It applies response-specific + filters and determines appropriate path decisions based on response quality. + + The method implements response validation that: + 1. Applies MLFlow response-specific filters + 2. Evaluates API response quality and integrity + 3. Determines path decisions based on response validation results + 4. Provides confidence scores and detailed validation comments + 5. Handles API errors and response validation failures + + Args: + input_data: Configuration and data for response validation + Required keys: + - metadata (dict): Workflow execution metadata + - filters (dict): Response filter configuration and policies + - data (dict): MLFlow API response data to validate + - type (str): Type of MLFlow operation (transform, predict) + - path_priority (list[str]): Priority order for path decisions + + Returns: + tuple: (path_flag, confidence, comment) + - path_flag (str | None): Decision path (STOP, CONTINUE, REPEAT, or None) + - confidence (int): Confidence score for the decision + - comment (str): Detailed explanation of the decision + + Raises: + Exception: If response validation fails or configuration is invalid + """ + metadata = input_data['metadata'] + self.info('Performing mlflow response gate...', metadata) + raw_data = input_data['data'] + filters = input_data['filters'] + + self.debug( + f'Input data: \n {create_sample_dict(raw_data, max_items=5, max_depth=5)}', metadata + ) + self.debug(f'Filters: {filters}', metadata) + + payload = MinioDataFramePayload.from_dict(raw_data) + data = await payload.retrieve(self.minio_repository, metadata) + + gate_type = input_data['type'] + path_priority = input_data['path_priority'] + + filter_output = [] + + comments = [] + + status = payload.status or {} + + for fil, config in filters.items(): + if fil not in mlflow_response_filter_functions: + continue + policy, filter_config = self._read_filter_entry(config) + try: + if mlflow_response_filter_functions[fil](status, filter_config): + filter_output.append(policy) + comments.append(status.get('message', 'Unknown MLFlow API error')) + await self.send_notification_async( + metadata=metadata, + notification_id=f'{gate_type.upper()}_GATE_RESPONSE_FILTER__{fil}', + message=status.get('message', 'Unknown MLFlow API error'), + block='mlflow_gate', + level=NotificationLevel.ERROR, + attachment_content=status.get('traceback'), + ) + except Exception as e: + trace = traceback.format_exc() + await self.send_notification_async( + metadata=metadata, + notification_id=f'MLFLOW_GATE_RESPONSE_FILTER__{fil}', + message=f'Error in filter {fil}:{config}: \n {e}', + block='mlflow_gate', + level=NotificationLevel.ERROR, + attachment_content=trace, + ) + + for path_flag in path_priority: + if path_flag in filter_output: + self.info(f'Mlflow response gate result: {path_flag}', metadata) + return path_flag, mlflow_response_path_confidence[path_flag], ', '.join(comments) + + self.info('Nothing was filtered by the mlflow response gate', metadata) + + del data + + return None, 0, '' + + @activity.defn(name='mlflow_content_gate') + async def mlflow_content_gate(self, input_data: dict[str, Any]) -> tuple[str | None, int, str]: + """ + Validate MLFlow prediction content quality and integrity. + + This activity validates the content of MLFlow predictions to ensure they + meet quality standards before export and persistence. It applies content-specific + filters and determines appropriate path decisions based on content quality. + + The method implements content validation that: + 1. Applies MLFlow content-specific filters + 2. Evaluates prediction content quality and integrity + 3. Determines path decisions based on content validation results + 4. Provides confidence scores and detailed validation comments + 5. Handles content validation failures and quality issues + + Args: + input_data: Configuration and data for content validation + Required keys: + - metadata (dict): Workflow execution metadata + - filters (dict): Content filter configuration and policies + - data (dict): MLFlow prediction content to validate + - type (str): Type of MLFlow operation (transform, predict) + - path_priority (list[str]): Priority order for path decisions + + Returns: + tuple: (path_flag, confidence, comment) + - path_flag (str | None): Decision path (STOP, CONTINUE, REPEAT, or None) + - confidence (int): Confidence score for the decision + - comment (str): Detailed explanation of the decision + + Raises: + Exception: If content validation fails or configuration is invalid + """ + metadata = input_data['metadata'] + self.info('Performing mlflow content gate...', metadata) + + filters = input_data['filters'] + + payload = MinioDataFramePayload.from_dict(input_data['data']) + data = await payload.retrieve(self.minio_repository, metadata) + + gate_type = input_data['type'] + path_priority = input_data['path_priority'] + + filter_output = [] + + self._debug_dataframe('Input data:', data, metadata) + self.debug(f'Filters: \n {filters}', metadata) + + for fil, config in filters.items(): + if fil not in mlflow_content_filter_functions: + continue + policy, filter_config = self._read_filter_entry(config) + try: + if mlflow_content_filter_functions[fil](data, filter_config): + filter_output.append(policy) + await self.send_notification_async( + metadata=metadata, + notification_id=f'{gate_type.upper()}_GATE_CONTENT_FILTER__{fil}', + message=f'Data not passed the content filter {fil}:{config}', + block='mlflow_gate', + level=NotificationLevel.WARNING, + attachment_content=data.to_string(), + ) + except Exception as e: + trace = traceback.format_exc() + await self.send_notification_async( + metadata=metadata, + notification_id=f'MLFLOW_GATE_CONTENT_FILTER__{fil}', + message=f'Error in filter {fil}:{config}: \n {e}', + block='mlflow_gate', + level=NotificationLevel.ERROR, + attachment_content=trace, + ) + + for path_flag in path_priority: + if path_flag in filter_output: + self.info(f'Mlflow content gate result: {path_flag}', metadata) + return ( + path_flag, + mlflow_content_path_confidence[path_flag], + 'Transformed data not passed the content filter', + ) + + self.info('Nothing was filtered by the mlflow content gate', metadata) + + del data + + return None, 0, '' + + def get_prediction_store_policy( + self, prediction_store_policy: str, metadata: dict[str, Any] + ) -> tuple[str, int]: + """ + Parse and validate prediction store policy configuration. + + This method parses prediction store policy strings in the format 'type:value' + and validates them against allowed policy types and values. It provides + sensible defaults for invalid configurations and logs policy validation + failures for operational monitoring. + + Supported Policy Types: + - 'lts': Latest timestamp - sorts data by timestamp descending + - 'erl': Earliest timestamp - sorts data by timestamp ascending + + Args: + prediction_store_policy (str): Policy string in format 'type:value' + metadata (dict[str, Any]): Context metadata for logging and notifications + + Returns: + tuple[str, int]: (policy_type, policy_value) + - policy_type (str): Validated policy type ('lts' or 'erl') + - policy_value (int): Number of rows to retain + """ + policy_elements = prediction_store_policy.split(':') + + if len(policy_elements) < 2: + self.error( + f'Invalid prediction store policy: {prediction_store_policy}, using default policy', + metadata, + ) + return 'lts', 1 + + policy_type = policy_elements[0] + policy_value = policy_elements[1] + + # If the policy_type is not lts or erl, we use the default policy + # If the policty_value is not a number or 0, we use the default policy + if ( + policy_type not in ['lts', 'erl'] + or not policy_value.isdigit() + or int(policy_value) == 0 + ): + self.error( + f'Invalid prediction store policy: {prediction_store_policy}, using default policy', + metadata, + ) + return 'lts', 1 + + return policy_type, int(policy_value) + + @activity.defn(name='format_transformed_data') + async def format_transformed_data(self, input_data: dict[str, Any]) -> MinioDataFramePayload: + """ + Format transformed data for storage and export operations. + + This method formats transformed data from MLFlow model transformations + into a standardized format suitable for database storage. It converts + wide-format data (columns as variables) into long-format (melted) + with proper timestamp handling and model identification. + + The formatting process includes: + 1. Converting input data dictionary to DataFrame + 2. Extracting timestamps from DataFrame index + 3. Resetting index to create sequential row numbers + 4. Melting data from wide format to long format (variable-value pairs) + 5. Adding model_id for data lineage tracking + + Args: + input_data (dict): Input data containing: + - metadata (dict): Workflow execution metadata + - data (dict[str, Any]): Transformed data to format (DataFrame-compatible dict) + - model_id (str): Unique identifier for the ML model + + Returns: + dict: Formatted data dictionary with keys: + - timestamp (dict): Timestamp values indexed by row number + - variable (dict): Variable names indexed by row number + - value (dict): Variable values indexed by row number + - model_id (dict): Model identifiers indexed by row number + """ + metadata = input_data['metadata'] + + model_id = input_data['model_id'] + + self.info('Formatting transformed data...', metadata) + + payload = MinioDataFramePayload.from_dict(input_data['data']) + data = await payload.retrieve(self.minio_repository, metadata) + + data['timestamp'] = data.index + data = data.reset_index(drop=True) + + data = data.melt(id_vars='timestamp', var_name='variable', value_name='value') + data['model_id'] = model_id + + return await MinioDataFramePayload.from_dataframe( + dataframe=data, + minio_repo=self.minio_repository, + model_name=input_data['model_name'], + operation='transform', + workflow_metadata=metadata, + last_timestamp=payload.last_timestamp, + logger=self.logger, + ) + + @activity.defn(name='format_prediction') + async def format_prediction(self, input_data: dict[str, Any]) -> dict: + """ + Format prediction data according to configured storage policies. + + This method formats prediction data for storage and export operations. + It applies timestamp-based sorting policies, adds metadata fields, + and ensures data consistency before persistence. The method supports + multiple storage policies for flexible data retention strategies. + + If only one row is present, we use the last timestamp as the timestamp + + Storage Policies: + - 'lts:N': Latest timestamp - retains N most recent predictions + - 'erl:N': Earliest timestamp - retains N oldest predictions + + Args: + input_data (dict): Input data containing: + - data (dict[str, Any]): Raw prediction data to format + - timestamp (str): Timestamp of the data + - model_id (str): Unique identifier for the ML model + - prediction_confidence (float): Confidence score for the prediction + - prediction_store_policy (str): Storage policy in format 'type:value' + + Returns: + dict: Formatted prediction data ready for storage and export + """ + metadata = input_data['metadata'] + last_timestamp = input_data['timestamp'] + prediction_store_policy = input_data['prediction_store_policy'] + self.info('Formatting prediction...', metadata) + + payload = MinioDataFramePayload.from_dict(input_data['data']) + data = await payload.retrieve(self.minio_repository, metadata) + + # Create timestamp column from index and reset index + data['timestamp'] = data.index + data = data.reset_index(drop=True) + + self.debug(f'Prediction store policy: {prediction_store_policy}', metadata) + self._debug_dataframe('Prediction data:', data, metadata) + + policy_type, policy_value = self.get_prediction_store_policy( + prediction_store_policy, metadata + ) + + # If data has no timestamp, we use the default timestamp and not sort the data + self.info( + f'Sorting data by timestamp and applying policy: {policy_type}:{policy_value}', metadata + ) + + # If policy_type is lts, we need to sort the data by timestamp descending and take the first policy_value rows + if policy_type == 'lts': + self.debug('Sorting data by timestamp descending', metadata) + data = data.sort_values(by='timestamp', ascending=False) + # If policy_type is erl, we need to sort the data by timestamp ascending and take the first policy_value rows + elif policy_type == 'erl': + self.debug('Sorting data by timestamp ascending', metadata) + data = data.sort_values(by='timestamp', ascending=True) + else: + self.error(f'Invalid policy type: {policy_type}, using default policy', metadata) + raise ValueError(f'Invalid policy type: {policy_type}') + + int_policy_value = int(policy_value) + + data = data.head(int_policy_value) + if int_policy_value == 1: + data['timestamp'] = last_timestamp + + data['model_id'] = input_data['model_id'] + data['prediction_confidence'] = input_data['prediction_confidence'] + data['prediction_status'] = 'Good' + data['comments'] = '' + data = data.sort_values(by='timestamp', ascending=False) + data = data.reset_index(drop=True) + + self.info(f'Prediction formatted: {len(data)} rows', metadata) + self._debug_dataframe('Prediction data:', data, metadata) + + return data.to_dict() + + @activity.defn(name='format_default_prediction') + async def format_default_prediction(self, input_data: dict[str, Any]) -> dict: + """ + Create and format default prediction data for error conditions. + + This method generates default prediction data when the main prediction + pipeline encounters errors or quality issues. It creates a standardized + data structure with zero values for predictions and useful metadata + for operational monitoring and debugging. + + The default prediction serves as a fallback mechanism to: + 1. Maintain data pipeline continuity during failures + 2. Provide operational visibility into prediction quality issues + 3. Enable downstream systems to handle error conditions gracefully + 4. Support debugging and troubleshooting efforts + + Args: + input_data (dict): Input data containing: + - timestamp (str): Timestamp for the default prediction + - model_id (str): Unique identifier for the ML model + - prediction_confidence (float): Confidence score (typically low for errors) + - comment (str): Error description or operational comment + + Returns: + dict: Formatted default prediction data with error indicators + """ + + metadata = input_data['metadata'] + self.debug('Formatting default prediction...', metadata) + + data = DataFrame( + { + 'prediction': [0], + 'response_time': [0], + 'timestamp': [input_data['timestamp']], + 'model_id': [input_data['model_id']], + 'prediction_confidence': [input_data['prediction_confidence']], + 'prediction_status': ['Bad'], + 'comments': [input_data['comment']], + } + ) + + self.info(f'Default prediction formatted: {data.size} rows', metadata) + return data.to_dict() + + @activity.defn(name='format_retrain_report') + async def format_retrain_report(self, input_data: dict[str, Any]) -> dict: + """ + Format retrain report data for storage and audit trail maintenance. + + This method formats model retraining operation results into a standardized + report format suitable for database storage and operational monitoring. + It captures retraining status, timestamps, and model version information + for comprehensive audit trails and operational visibility. + + The formatting process includes: + 1. Extracting retraining experiment response data + 2. Capturing model update report information (version, MLflow IDs) + 3. Formatting timestamps and status information + 4. Conditionally including version information for successful retrains + + Args: + input_data (dict): Input data containing: + - metadata (dict): Workflow execution metadata + - experiment_response (dict): Retraining experiment response containing: + - success (bool): Retraining operation success status + - timestamp (str): Timestamp of the retraining operation + - message (str): Status message or error description + - update_report (dict): Model update report containing: + - version (str): New model version identifier + - mlflow_run_id (str): MLflow run identifier + - mlflow_experiment_id (str): MLflow experiment identifier + - model_id (str): Unique identifier for the ML model + - model_name (str): Name of the ML model + + Returns: + dict: Formatted retrain report dictionary with keys: + - model_id (dict): Model identifiers indexed by row number + - model_name (dict): Model names indexed by row number + - timestamp (dict): Retraining timestamps indexed by row number + - status (dict): Retraining status messages indexed by row number + - version (dict, optional): Model versions indexed by row number + Only included if experiment_response['success'] is True + - mlflow_run_id (dict, optional): MLflow run IDs indexed by row number + Only included if experiment_response['success'] is True + - mlflow_experiment_id (dict, optional): MLflow experiment IDs indexed by row number + Only included if experiment_response['success'] is True + """ + metadata = input_data['metadata'] + self.info('Formatting retrain report...', metadata) + + experiment_response = input_data['experiment_response'] + update_report = input_data['update_report'] + model_id = input_data['model_id'] + model_name = input_data['model_name'] + + report = DataFrame( + { + 'model_id': [model_id], + 'model_name': [model_name], + 'timestamp': [experiment_response['timestamp']], + 'status': [experiment_response['message']], + } + ) + + if experiment_response['success']: + # Retrain was successfull + report['version'] = update_report['version'] + report['mlflow_run_id'] = update_report['mlflow_run_id'] + report['mlflow_experiment_id'] = update_report['mlflow_experiment_id'] + + self._debug_dataframe('Retrain report:', report, metadata) + + return report.to_dict() + + @activity.defn(name='write_metrics') + async def write_metrics(self, input_data: dict[str, Any]): + """ + Write prediction performance metrics to Prometheus monitoring system. + + This method records comprehensive metrics for prediction operations, + enabling operational monitoring, performance analysis, and alerting. + It tracks prediction counts, confidence levels, and response times + for each model and pipeline combination. + + Metrics Recorded: + 1. Prediction Count: Incremental counter for successful predictions + 2. Confidence Monitor: Current confidence level for predictions + 3. Response Time Monitor: Histogram of prediction response times + + Args: + input_data (dict): Input data containing: + - metadata (dict[str, Any]): Workflow execution metadata + - prediction (dict[str, Any]): Prediction data with metrics + + Raises: + Exception: If metrics writing fails or configuration is invalid + """ + metadata = input_data['metadata'] + prediction = DataFrame(input_data['prediction']) + prediction_confidence = prediction['prediction_confidence'].values[0] + response_time = prediction['response_time'].values[0] + opc_metrics = input_data['opc_metrics'] + + self.info(f'Writing metrics for model {metadata["model_name"]}', metadata) + + core_tags = { + 'pod_id': self.pod_id, + 'runtime': self.runtime, + 'operation_type': 'predict', + 'model_name': metadata['model_name'], + 'workflow_name': metadata['workflow_name'], + } + await self.emit_metric( + metric_object=metrics.PREDICTIONS_WRITTEN_COUNT, + tags=core_tags, + ) + + await self.emit_metric( + metric_object=metrics.PREDICTION_CONFIDENCE_MONITOR, + method='set', + tags=core_tags, + value=prediction_confidence, + ) + + await self.emit_metric( + metric_object=metrics.PREDICTION_RESPONSE_TIME_MONITOR, + method='observe', + tags=core_tags, + value=response_time, + ) + + for server_id, tags in opc_metrics.items(): + for tag, response_time in tags.items(): + if response_time is not None: + await self.emit_metric( + metric_object=metrics.PREDICTION_OPC_WRITING_RESPONSE_TIME_MONITOR, + method='observe', + tags={ + **core_tags, + 'opc_server_id': server_id, + 'tag': tag, + }, + value=response_time, + ) + + await self.emit_metric( + metric_object=metrics.PREDICTION_OPC_WRITING_COUNT, + tags={ + **core_tags, + 'opc_server_id': server_id, + 'tag': tag, + }, + ) + + self.info(f'Metrics written for model {metadata["model_name"]}', metadata) diff --git a/laborious/activities/mlflow.py b/laborious/activities/mlflow.py new file mode 100644 index 0000000..f4d42a3 --- /dev/null +++ b/laborious/activities/mlflow.py @@ -0,0 +1,528 @@ +from temporalio import activity, workflow + +with workflow.unsafe.imports_passed_through(): + import traceback + from typing import Any + + import numpy as np + from pandas import to_datetime + 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.metrics_controller import MetricsController + from sientia_do.repository.minio_repository import MinioRepository + from sientia_do.temporal.constants import ( + DATETIME_FORMAT, + DATETIME_FORMAT_MS_WITH_TZ, + DATETIME_FORMAT_WITH_TZ, + now, + ) + from sientia_do.utils.formatters import create_sample_dict + + from laborious.utils.dataframe_debug import build_dataframe_debug_message + from laborious.utils.models.minio_dataframe_payload import MinioDataFramePayload + from laborious.utils.repository.minio_manager import MinioManager + from laborious.utils.repository.model_repository import MLFlowRepository + + +class MLFlow(MinioManager): + """ + MLFlow integration activities for model inference operations. + + This class provides activities for interacting with MLFlow models, including + data transformation and prediction operations. It handles authentication, + data preprocessing, and model management with configurable retention policies. + + The class implements comprehensive error handling and logging for all + MLFlow operations, ensuring reliable model inference in production environments. + + Attributes: + mlflow_host (str): MLFlow server hostname + mlflow_port (int): MLFlow server port + mlflow_username (str): MLFlow authentication username + mlflow_password (str): MLFlow authentication password + model_monitoring_repository (MLFlowRepository): Repository for MLFlow operations + """ + + _MAX_DEBUG_DATAFRAME_ROWS = 100 + + def __init__( + self, + mlflow_host: str, + mlflow_port: int, + mlflow_username: str, + mlflow_password: str, + minio_repository: MinioRepository | None = None, + logger: Logger | None = None, + notification_handler: NotificationHandler | None = None, + metrics_controller: MetricsController | None = None, + ): + """ + Initialize MLFlow activities with server configuration. + + Args: + mlflow_host: MLFlow server hostname or IP address + mlflow_port: MLFlow server port number + mlflow_username: Username for MLFlow authentication + mlflow_password: Password for MLFlow authentication + logger: Logger instance for observability and debugging + notification_handler: Notification handler for alerts and monitoring + + Raises: + Exception: If MLFlowRepository initialization fails + """ + MinioManager.__init__( + self, minio_repository, logger, notification_handler, metrics_controller + ) + self.mlflow_host = mlflow_host + self.mlflow_port = mlflow_port + self.mlflow_username = mlflow_username + self.mlflow_password = mlflow_password + + self.model_monitoring_repository = MLFlowRepository( + f'{mlflow_host}:{mlflow_port}', + mlflow_username, + mlflow_password, + logger, + notification_handler, + metrics_controller, + ) + + def close(self) -> None: + """ + Close the MLFlow activity and clean up resources. + """ + MinioManager.close(self) + + def __del__(self): + self.close() + + def _debug_dataframe(self, message: str, data: Any, metadata: dict[str, Any]) -> None: + """ + Log dataframe content only when row count is below the configured threshold + + Args: + - message (str): Base log message to identify the dataframe in logs + - data (Any): Dataframe-like object expected to expose shape and to_csv + - metadata (dict[str, Any]): Workflow metadata for contextual logging + """ + self.debug( + build_dataframe_debug_message( + message=message, + data=data, + max_rows=self._MAX_DEBUG_DATAFRAME_ROWS, + ), + metadata, + ) + + @activity.defn(name='request_transform') + async def request_transform(self, input_data: dict[str, Any]) -> MinioDataFramePayload: + """ + Transform input data using MLFlow models. + + This activity processes input data through MLFlow model transformation, + including data preprocessing, format conversion, and validation. It handles + data deduplication, pivoting, and cleanup to ensure optimal model performance. + + The transformation process includes: + 1. Data deduplication based on variable and timestamp + 2. Data pivoting for model input format + 3. Null value handling and cleanup + 4. MLFlow model transformation request + 5. Response validation and logging + + Args: + input_data: Configuration and data for transformation + Required keys: + - metadata (dict): Workflow execution metadata + - data (dict): Input data for transformation + - model_name (str): Name of the MLFlow model to use + - model_retention (int): Model retention period in minutes + + Returns: + dict: Transformed data from MLFlow model + + Raises: + Exception: If transformation fails or MLFlow model is unavailable + """ + metadata = input_data['metadata'] + self.info('Transforming data...', metadata) + + payload = MinioDataFramePayload.from_dict(input_data['data']) + data = await payload.retrieve(self.minio_repository, metadata) + + model_name = input_data['model_name'] + model_config = input_data.get('model_config', {}) + + self._debug_dataframe('Raw input data:', data, metadata) + + # Sort by created_at in descending order and keep first occurrence of each variable/timestamp pair + data = data.sort_values('created_at', ascending=False).drop_duplicates( + subset=['variable', 'timestamp'], keep='first' + ) + + # Pivot data for model input format + data = data.pivot(index='timestamp', columns='variable', values='value') + data.fillna(np.nan, inplace=True) + + data.columns.name = None + data.index.name = None + + data['timestamp'] = data.index + + self._debug_dataframe('Processed input data:', data, metadata) + + # Request transformation from MLFlow model + response_data = await self.model_monitoring_repository.transform( + model_name, data, model_config, metadata + ) + + self.debug( + f'Transform raw response data: \n {create_sample_dict(response_data, max_items=5, max_depth=5)}', + metadata, + ) + + self.debug( + f'Transform response data: \n {create_sample_dict(response_data, max_items=5, max_depth=5)}', + metadata, + ) + + self.info('Data transformed successfully', metadata) + + if not response_data.get('success', False): + return await MinioDataFramePayload.from_dataframe( + dataframe=None, + minio_repo=self.minio_repository, + model_name=model_name, + operation='transform', + status=response_data, + workflow_metadata=metadata, + last_timestamp=payload.last_timestamp, + logger=self.logger, + ) + + return await MinioDataFramePayload.from_dataframe( + dataframe=response_data['content'], + minio_repo=self.minio_repository, + model_name=model_name, + operation='transform', + workflow_metadata=metadata, + status={ + 'success': True, + }, + last_timestamp=payload.last_timestamp, + logger=self.logger, + ) + + @activity.defn(name='request_predict') + async def request_predict(self, input_data: dict[str, Any]) -> MinioDataFramePayload: + """ + Execute predictions using MLFlow models. + + This activity performs ML model inference using MLFlow models with the + transformed data. It handles data format conversion, null value processing, + and model prediction requests with comprehensive error handling. + + The prediction process includes: + 1. Data format validation and cleanup + 2. Null value handling for model compatibility + 3. MLFlow model prediction request + 4. Response validation and logging + 5. Performance monitoring and metrics + + Args: + input_data: Configuration and data for prediction + Required keys: + - metadata (dict): Workflow execution metadata + - data (dict): Transformed data for prediction + - model_name (str): Name of the MLFlow model to use + - model_retention (int): Model retention period in minutes + + Returns: + dict: Prediction results from MLFlow model + + Raises: + Exception: If prediction fails or MLFlow model is unavailable + """ + metadata = input_data['metadata'] + self.info('Predicting data...', metadata) + + payload = MinioDataFramePayload.from_dict(input_data['data']) + data = await payload.retrieve(self.minio_repository, metadata) + + model_name = input_data['model_name'] + model_config = input_data.get('model_config', {}) + + self._debug_dataframe('Input data for prediction:', data, metadata) + + # Convert numpy.nan to None for model compatibility + data.replace(np.nan, None, inplace=True) + + data['timestamp'] = data.index + data['timestamp'] = to_datetime( + data['timestamp'], format=DATETIME_FORMAT_WITH_TZ + ).dt.strftime(DATETIME_FORMAT) + + # Request prediction from MLFlow model + response_data = await self.model_monitoring_repository.predict( + model_name, data, model_config, metadata + ) + + self.debug( + f'Prediction response data: \n {create_sample_dict(response_data, max_items=5, max_depth=5)}', + metadata, + ) + + self.info('Data predicted successfully', metadata) + + if not response_data.get('success', False): + return await MinioDataFramePayload.from_dataframe( + dataframe=None, + minio_repo=self.minio_repository, + model_name=model_name, + operation='predict', + status=response_data, + workflow_metadata=metadata, + last_timestamp=payload.last_timestamp, + logger=self.logger, + ) + + return await MinioDataFramePayload.from_dataframe( + dataframe=response_data['content'], + minio_repo=self.minio_repository, + model_name=model_name, + operation='predict', + workflow_metadata=metadata, + status={ + 'success': True, + }, + last_timestamp=payload.last_timestamp, + logger=self.logger, + ) + + @activity.defn(name='retrain_model') + async def retrain_model(self, input_data: dict[str, Any]) -> dict[str, Any]: + """ + Retrain MLFlow models with updated training data. + + This activity orchestrates the complete model retraining process, + including data preparation, model retraining execution, and result + validation. It handles data preprocessing, column cleanup, and + comprehensive error handling for production model management. + + The retraining process includes: + 1. Data timestamp extraction and validation + 2. Column cleanup and data preparation + 3. Data pivoting for model input format + 4. MLFlow model retraining execution + 5. Result validation and error handling + + Args: + input_data (dict): Input data containing: + - metadata (dict): Workflow execution metadata + - data (dict[str, Any]): Training data for model retraining + - model_name (str): Name of the MLFlow model to retrain + + Returns: + dict: Retraining results containing: + - status (str): Retraining operation status + - timestamp (str): Timestamp of the retraining operation + - experiment (str): MLFlow experiment identifier + + Raises: + Exception: If retraining fails or encounters critical errors + """ + + if self.minio_repository is None: + raise ValueError('Minio repository not initialized') + + metadata = input_data['metadata'] + + try: + # Payload-based retrain input (inline dict or MinIO offloaded). + payload = MinioDataFramePayload.from_dict(input_data['data']) + data = await payload.retrieve(self.minio_repository, metadata) + + except Exception as e: + trace = traceback.format_exc() + await self.send_notification_async( + metadata=metadata, + notification_id='ERROR_LOADING_RETRAIN_DATA', + message=f'Error loading retrain data: {e}', + block='retrain_model', + level=NotificationLevel.ERROR, + attachment_content=trace, + ) + self.error(trace, metadata) + return { + 'success': False, + 'message': f'Error loading retrain data: {e}', + 'traceback': trace, + 'timestamp': now().strftime(DATETIME_FORMAT_MS_WITH_TZ), + } + + self.debug(f'Retrain data loaded successfully: shape {data.shape}', metadata) + + model_name = input_data['model_name'] + model_config = input_data.get('model_config', {}) + + self.info(f'Retraining model {model_name}...', metadata) + + timestamp = data['timestamp'].max() + self.debug(f'Timestamp: {timestamp}', metadata) + + # Sort by created_at in descending order and keep first occurrence of each variable/timestamp pair + if 'created_at' in data.columns: + data = data.sort_values('created_at', ascending=False).drop_duplicates( + subset=['variable', 'timestamp'], keep='first' + ) + else: + data = data.drop_duplicates(subset=['variable', 'timestamp'], keep='first') + + data.drop(columns=['model_id'], inplace=True, errors='ignore') + data.drop(columns=['created_at'], inplace=True, errors='ignore') + + # Pivot data for model input format + data = data.pivot(index='timestamp', columns='variable', values='value') + data.fillna(np.nan, inplace=True) + # data.reset_index(inplace=True) + data.columns.name = None + + data['timestamp'] = data.index + data['timestamp'] = to_datetime( + data['timestamp'], format=DATETIME_FORMAT_WITH_TZ + ).dt.strftime(DATETIME_FORMAT) + data['timestamp'] = to_datetime(data['timestamp'], format=DATETIME_FORMAT) + + data.columns.name = None + + retrain_output = await self.model_monitoring_repository.retrain_model( + data=data, model_name=model_name, model_config=model_config, metadata=metadata + ) + + if not retrain_output['success']: + trace = retrain_output['traceback'] + await self.send_notification_async( + metadata=metadata, + notification_id='RETRAIN_MODEL_ERROR', + message=f'Error retraining model {model_name}: {retrain_output["message"]}', + block='retrain_model', + level=NotificationLevel.ERROR, + attachment_content=trace, + ) + self.error(trace, metadata=metadata) + + return {**retrain_output, 'timestamp': timestamp} + + @activity.defn(name='update_production_model') + async def update_production_model(self, input_data: dict[str, Any]) -> dict[Any, Any]: + """ + Update production model with newly trained model version. + + This activity manages the critical process of updating production + models with newly trained versions. It handles model deployment, + status tracking, and comprehensive reporting for operational + visibility and audit trails. + + The update process includes: + 1. Production model update execution + 2. Status and metadata tracking + 3. Comprehensive reporting and logging + 4. Error handling and notification + 5. Audit trail maintenance + + Args: + input_data (dict): Input data containing: + - metadata (dict): Workflow execution metadata + - model_name (str): Name of the MLFlow model to update + - experiment (str): MLFlow experiment identifier + - model_id (str): Unique identifier for the model version + - timestamp (str): Timestamp of the update operation + - status (str): Current status of the model update + + Returns: + dict[Any, Any]: Comprehensive update report containing: + - model_id (str): Model version identifier + - model_name (str): Name of the updated model + - timestamp (str): Update operation timestamp + - status (str): Update operation status + - Additional MLFlow response metadata + + Raises: + Exception: If production model update fails + """ + metadata = input_data['metadata'] + model_name = input_data['model_name'] + experiment = input_data['experiment'] + self.info( + f'Updating production model {model_name} from experiment {experiment}...', metadata + ) + + try: + response = await self.model_monitoring_repository.update_production_model( + experiment=experiment, model_name=model_name, metadata=metadata + ) + + self.info(f'Production model {model_name} updated successfully', metadata) + return response + + except Exception as e: + trace = traceback.format_exc() + await self.send_notification_async( + metadata=metadata, + notification_id='UPDATE_PRODUCTION_MODEL_ERROR', + message=f'Error updating production model {model_name}: {e}', + block='update_production_model', + level=NotificationLevel.ERROR, + attachment_content=trace, + ) + self.error(trace, metadata=metadata) + raise e + + @activity.defn(name='get_reference_data') + async def get_reference_data(self, input_data: dict[str, Any]) -> list[dict] | None: + """ + Get reference data from the MLflow Model Registry. + + This method retrieves evaluation reference data stored as artifacts in the + MLflow Model Registry. The reference data is typically used for model + drift detection, performance comparison, and quality validation. The method + loads the data from a CSV artifact file and formats timestamps for + consistent processing. + + The method handles: + 1. Loading evaluation data artifact from MLflow Model Registry + 2. Timestamp parsing and formatting for consistency + 3. Data conversion to dictionary format for workflow consumption + 4. Graceful handling of missing reference data + + Args: + input_data (dict): Input data containing: + - metadata (dict): Workflow execution metadata + - model_name (str): Name of the MLFlow model to get reference data from + + Returns: + list[dict[Hashable, Any]] | None: Reference data from the MLflow Model Registry + as a list of dictionaries. Returns None if reference data is not found + or if the artifact does not exist. + + Raises: + Exception: If artifact loading fails or encounters errors during processing + """ + + metadata = input_data['metadata'] + model_name = input_data['model_name'] + artifact = 'evaluation_data.csv' + + reference_data = await self.model_monitoring_repository.load_artifact_dataframe( + model_name=model_name, artifact_path=artifact, metadata=metadata + ) + + if reference_data is None: + self.warning(f'Reference data not found for model {model_name}', metadata) + return None + + reference_data['timestamp'] = to_datetime(reference_data['timestamp']) + reference_data['timestamp'] = reference_data['timestamp'].dt.strftime(DATETIME_FORMAT) + + return reference_data.to_dict(orient='records') diff --git a/laborious/activities/model_metrics.py b/laborious/activities/model_metrics.py new file mode 100644 index 0000000..79b140c --- /dev/null +++ b/laborious/activities/model_metrics.py @@ -0,0 +1,364 @@ +from temporalio import activity, workflow + +with workflow.unsafe.imports_passed_through(): + import time + import traceback + import warnings + from typing import Any + + import numpy as np + from pandas import DataFrame, Index, to_datetime + from sientia.ModelAnalysis import ModelAnalysis + 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.metrics_controller import MetricsController + from sientia_do.observability.sientia_monitoring import SientiaMonitoring + from sientia_do.temporal.constants import DATETIME_FORMAT, DATETIME_FORMAT_WITH_TZ + + from laborious import metrics + from laborious.utils.dataframe_debug import build_dataframe_debug_message + +warnings.filterwarnings('ignore', category=RuntimeWarning, message='Degrees of freedom <= 0') +warnings.filterwarnings( + 'ignore', category=RuntimeWarning, message='invalid value encountered in scalar divide' +) + + +class ModelMetrics(SientiaMonitoring): + """ + Metrics activities for the Laborious system. + + This class provides activities for writing metrics to the Prometheus monitoring system. + """ + + _MAX_DEBUG_DATAFRAME_ROWS = 100 + + def __init__( + self, + logger: Logger, + notification_handler: NotificationHandler, + metrics_controller: MetricsController, + ): + SientiaMonitoring.__init__(self, logger, notification_handler, metrics_controller) + + def close(self) -> None: + """ + Close the model metrics activity and clean up resources. + """ + SientiaMonitoring.shutdown(self) + + def __del__(self): + self.close() + + def _debug_dataframe(self, message: str, data: Any, metadata: dict[str, Any]) -> None: + """ + Log dataframe content only when row count is below the configured threshold + + Args: + - message (str): Base log message to identify the dataframe in logs + - data (Any): Dataframe-like payload to be logged + - metadata (dict[str, Any]): Workflow metadata for contextual logging + """ + self.debug( + build_dataframe_debug_message( + message=message, + data=data, + max_rows=self._MAX_DEBUG_DATAFRAME_ROWS, + ), + metadata, + ) + + async def get_drift_metrics( + self, + reference_data: DataFrame, + target_data: DataFrame, + target_name: str, + reference_columns: Index, + drift_metrics: list[str], + chunk_period: str, + metadata: dict[str, Any], + ) -> DataFrame: + """ + Calculate univariate drift metrics for a model. + Args: + model_analysis (ModelAnalysis): Model analysis object + reference_data (DataFrame): Reference data + target_data (DataFrame): Target data + reference_columns (list[str]): Reference columns + drift_metrics (list[str]): Drift metrics + metadata (dict[str, Any]): Workflow execution metadata + """ + + config = { + 'target': target_name, + 'prediction': 'prediction', + 'timestamp': 'timestamp', + 'features': reference_columns, + } + + model_analysis = ModelAnalysis(config=config) + + self._debug_dataframe( + f'Reference data: Size {reference_data.shape}', reference_data, metadata + ) + + self._debug_dataframe(f'Target data: Size {target_data.shape}', target_data, metadata) + + core_labels = self.get_core_labels(metadata, operation_type='detect_univariate_drift') + start_time = time.time() + try: + univariate_drift = model_analysis.detect_univariate_drift( + reference_df=reference_data, + analysis_df=target_data, + features=reference_columns, + timestamp_col=config['timestamp'], + methods=drift_metrics, + chunk_period=chunk_period, + ) + except Exception as e: + self.error(f'Error detecting univariate drift: {e}', metadata) + await self.emit_metric( + metric_object=metrics.MODEL_ANALYZE_ERROR_COUNT, tags=core_labels + ) + raise e + await self.observe_lag(start_time, metrics.MODEL_ANALYZE_LAG, core_labels) + await self.emit_metric(metric_object=metrics.MODEL_ANALYZE_COUNT, tags=core_labels) + + core_labels = self.get_core_labels(metadata, operation_type='detect_multivariate_drift') + start_time = time.time() + try: + multivariate_drift = model_analysis.detect_multivariate_drift( + reference_df=reference_data, + analysis_df=target_data, + features=reference_columns, + timestamp_col=config['timestamp'], + chunk_period=chunk_period, + ) + except Exception as e: + self.error(f'Error detecting multivariate drift: {e}', metadata) + await self.emit_metric( + metric_object=metrics.MODEL_ANALYZE_ERROR_COUNT, tags=core_labels + ) + raise e + await self.observe_lag(start_time, metrics.MODEL_ANALYZE_LAG, core_labels) + await self.emit_metric(metric_object=metrics.MODEL_ANALYZE_COUNT, tags=core_labels) + + start_time = time.time() + core_labels = self.get_core_labels(metadata, operation_type='get_drift_metrics_dataframe') + try: + drift_df = model_analysis.get_drift_metrics_dataframe( + univariate_drift=univariate_drift, + multivariate_drift=multivariate_drift, + ) + except Exception as e: + self.error(f'Error getting drift metrics: {e}', metadata) + await self.emit_metric( + metric_object=metrics.MODEL_ANALYZE_ERROR_COUNT, tags=core_labels + ) + raise e + await self.observe_lag(start_time, metrics.MODEL_ANALYZE_LAG, core_labels) + await self.emit_metric(metric_object=metrics.MODEL_ANALYZE_COUNT, tags=core_labels) + + self._debug_dataframe(f'Drift dataframe: Size {drift_df.shape}', drift_df, metadata) + + return drift_df + + @activity.defn(name='calculate_drift') + async def calculate_drift(self, input_data: dict[str, Any]) -> list[dict]: + """ + Calculate drift metrics for a model. + + Args: + input_data (dict[str, Any]): Input data containing: + - metadata (dict): Workflow execution metadata + - model_name (str): Name of the MLFlow model to calculate drift for + - reference_data (pd.DataFrame): Reference data for the model + - target_data (pd.DataFrame): Target data for calculating drift + - target_name (str): Name of the target column + - drift_metrics (list[str]): List of drift metrics to calculate + """ + metadata = input_data['metadata'] + model_name = input_data['model_name'] + model_id = input_data['model_id'] + reference_raw_data = input_data['reference_data'] + target_data = DataFrame(input_data['target_data']) + target_name = input_data['target_name'] + drift_metrics = input_data['drift_metrics'] + chunk_period = input_data['chunk_period'] + + if chunk_period not in ['min', 's']: + self.error(f'Invalid chunk period: {chunk_period}', metadata) + raise ValueError(f'Invalid chunk period: {chunk_period}, must be "min" or "s"') + + self.info(f'Calculating drift for model {model_name}', metadata) + + target_data = target_data.pivot(index='timestamp', columns='variable', values='value') + target_data['timestamp'] = target_data.index + target_data['timestamp'] = to_datetime(target_data['timestamp']) + target_data['timestamp'] = target_data['timestamp'].dt.strftime(DATETIME_FORMAT) + target_data = target_data.reset_index(drop=True) + target_data.dropna(inplace=True) + + if reference_raw_data is not None: + self.info('Using reference data', metadata) + reference_data = DataFrame(reference_raw_data) + accurate = True + else: + # Get 30% first rows of target_data + self.warning('Using 30% first rows of target data as reference data', metadata) + target_data.sort_values(by='timestamp', ascending=True, inplace=True) + reference_data = target_data.head(int(len(target_data) * 0.3)) + accurate = False + + await self.send_notification_async( + metadata=metadata, + notification_id='MODEL_METRICS_REFERENCE_DATA_WARNING', + message='Using 30% first rows of target data as reference data', + block='model_metrics', + level=NotificationLevel.WARNING, + attachment_content=reference_data.to_csv(), + ) + + reference_columns = reference_data.drop( + columns=[target_name, 'timestamp', 'target', 'prediction'], errors='ignore' + ).columns + + try: + drift_df = await self.get_drift_metrics( + reference_data=reference_data, + target_data=target_data, + target_name=target_name, + reference_columns=reference_columns, + drift_metrics=drift_metrics, + chunk_period=chunk_period, + metadata=metadata, + ) + except Exception as e: + self.error(f'Error getting drift metrics: {e}', metadata) + await self.send_notification_async( + metadata=metadata, + notification_id='MODEL_METRICS_GET_DRIFT_METRICS_ERROR', + message=f'Error getting drift metrics: {e}', + block='model_metrics', + level=NotificationLevel.ERROR, + attachment_content=traceback.format_exc(), + ) + return [] + + if drift_df.empty: + self.warning('No drift metrics found', metadata) + return [] + + # Drop unnecessary columns + drift_df.drop(columns=['p_value'], inplace=True) + + # Extract timestamps only until minutes + if chunk_period == 'min': + target_timestamps = target_data['timestamp'].apply(lambda x: x[:16]) + else: + target_timestamps = target_data['timestamp'] + + # Drop rows where timestamp is not in target data, to avoid save drift from reference + drift_df = drift_df[drift_df['timestamp'].isin(target_timestamps)] + + if drift_df.empty: + self.warning( + 'No drift metrics found after dropping rows where timestamp is not in target data', + metadata, + ) + return [] + + # Rename columns to match database columns + drift_df.rename( + columns={ + 'metric': 'method', + 'statistic': 'value', + }, + inplace=True, + ) + + # Drop duplicates + drift_df.drop_duplicates( + subset=['timestamp', 'method', 'feature'], keep='first', inplace=True + ) + + drift_df['model_id'] = model_id + drift_df['accurate'] = accurate + + drift_df['timestamp'] = to_datetime(drift_df['timestamp']) + drift_df['timestamp'] = drift_df['timestamp'].dt.tz_localize('UTC') + drift_df['timestamp'] = drift_df['timestamp'].dt.strftime(DATETIME_FORMAT_WITH_TZ) + + self._debug_dataframe(f'Drift dataframe: Size {drift_df.shape}', drift_df, metadata) + + return drift_df.to_dict(orient='records') + + @activity.defn(name='calculate_simple_metrics') + async def calculate_simple_metrics(self, input_data: dict[str, Any]) -> list[dict]: + """ + Calculate simple metrics for a model. Metrics available are: + - rmse + - mse + - mae + - r2 + - accuracy + - precision + - recall + - f1 + Args: + input_data (dict[str, Any]): Input data containing: + - metadata (dict): Workflow execution metadata + - model_id (str): ID of the MLFlow model + - target_data (pd.DataFrame): Target data for calculating metrics, containing target and prediction columns + - metrics (list[str]): List of metrics to calculate + Returns: + dict[Hashable, Any]: Dictionary containing the calculated metrics + """ + + metadata = input_data['metadata'] + model_id = input_data['model_id'] + target_data = DataFrame(input_data['target_data']) + metrics = input_data['metrics'] + interval_minutes = input_data['interval_minutes'] + + data_size = target_data.shape[0] + + output_data = [] + + diff = target_data['target'] - target_data['prediction'] + diff_squared = diff**2 + + self.info(f'Calculating simple metrics for model {model_id}: {metrics}', metadata) + + for metric in metrics: + if metric == 'rmse': + output_data.append({'metric': 'rmse', 'value': np.sqrt(np.mean(diff_squared))}) + elif metric == 'mse': + output_data.append({'metric': 'mse', 'value': np.mean(diff_squared)}) + elif metric == 'mae': + output_data.append({'metric': 'mae', 'value': np.mean(np.abs(diff))}) + elif metric == 'r2': + y_true = target_data['target'] + y_mean = np.mean(y_true) + + ss_res = np.sum(diff_squared) + ss_tot = np.sum((y_true - y_mean) ** 2) + + # Evita divisΓ£o por zero + if ss_tot == 0: + r2_score = 0.0 + else: + r2_score = 1 - (ss_res / ss_tot) + + output_data.append({'metric': 'r2', 'value': r2_score}) + + data = DataFrame(output_data) + data['model_id'] = model_id + data['timestamp'] = target_data['timestamp'].max() + data['data_size'] = data_size + data['interval_minutes'] = interval_minutes + + self._debug_dataframe(f'Simple metrics dataframe: Size {data.shape}', data, metadata) + + return data.to_dict(orient='records') diff --git a/laborious/activities/opc.py b/laborious/activities/opc.py new file mode 100644 index 0000000..be5a941 --- /dev/null +++ b/laborious/activities/opc.py @@ -0,0 +1,515 @@ +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.metrics_controller import MetricsController + from sientia_do.observability.sientia_monitoring import SientiaMonitoring + + from laborious.utils.repository.opc_repository import OpcRepository + +OPC_WRITTING_ERROR_CONFIDENCE = 12 +OPC_SESSION_BAD_CONFIDENCE = 14 +OPC_SESSION_BAD_COMMENT_PREFIX = 'OPC UA session/channel error:' +OPC_WRITTING_ERROR_MESSAGE = 'Some data could not be written to OPC servers' +OPC_RECONNECT_IN_PROGRESS_COMMENT = 'OPC UA reconnect in progress' +OPC_COMMENT_SEPARATOR = ' | ' + + +def _opc_session_bad_comment(opc_status: str | None) -> str: + status = opc_status or 'Unknown' + return f'{OPC_SESSION_BAD_COMMENT_PREFIX} {status}' + + +def _apply_opc_write_error( + error_info: dict[str, Any] | None, + session_bad_seen: bool, + session_bad_status: str | None, + reconnect_in_progress_seen: bool, +) -> tuple[bool, str | None, bool]: + """ + Update session/reconnect flags from an OPC write error payload. + + Args: + error_info: Repository error details, or None when the write succeeded. + session_bad_seen: Whether a session_bad error was seen so far. + session_bad_status: Last known OPC status for session errors. + reconnect_in_progress_seen: Whether reconnect_in_progress was seen so far. + + Return: + Updated (session_bad_seen, session_bad_status, reconnect_in_progress_seen). + """ + if not error_info: + return session_bad_seen, session_bad_status, reconnect_in_progress_seen + + kind = error_info.get('opc_error_kind') + if kind == 'session_bad': + return True, error_info.get('opc_status', session_bad_status), reconnect_in_progress_seen + if kind == 'reconnect_in_progress': + return session_bad_seen, session_bad_status, True + return session_bad_seen, session_bad_status, reconnect_in_progress_seen + + +class OPC(SientiaMonitoring): + """ + OPC server integration activities for real-time data export. + + This class provides comprehensive OPC UA client functionality for connecting + to multiple OPC servers and writing prediction data in real-time. It implements + secure communication with certificate-based authentication and automatic + reconnection capabilities. + + The class supports multiple OPC servers with individual configurations and + provides robust error handling and monitoring for production environments. + + Attributes: + opc_servers (dict): Configuration for multiple OPC servers + opc_repository (dict): Active OPC repository connections + logger (Logger): Logging and observability instance + notification_handler (NotificationHandler): Notification management instance + """ + + def __init__( + self, + opc_servers: dict[str, dict[str, Any]], + logger: Logger, + notification_handler: NotificationHandler, + metrics_controller: MetricsController, + ): + self.logger = logger + self.notification_handler = notification_handler + self.opc_servers = opc_servers + + SientiaMonitoring.__init__(self, logger, notification_handler, metrics_controller) + + self.opc_repository: dict[str, OpcRepository] = {} + + async def init_opc(self): + """ + Initialize OPC server connections and establish communication channels. + + This method iterates through all configured OPC servers and attempts to + establish secure connections using certificate-based authentication. + Each server connection is managed independently, and connection failures + are reported through the notification system. + + The method performs the following operations: + 1. Creates OpcRepository instances for each configured server + 2. Establishes secure connections with certificate validation + 3. Reports connection success/failure through notifications + 4. Logs connection status for operational visibility + + Raises: + Exception: If OPC repository initialization fails or connection + establishment encounters critical errors + + Note: + Connection failures are logged and reported but do not prevent + the initialization of other OPC servers. Each server is handled + independently to ensure maximum availability. + """ + self.logger.info('Initializing OPC servers...') + for opc_id, server in self.opc_servers.items(): + self.opc_repository[opc_id] = OpcRepository( + opc_id=server['id'], + server_name=server['server_name'], + url=server['url'], + logger=self.logger, + server_uri=server['server_uri'], + cert_path=server['cert_path'], + private_key_path=server['private_key_path'], + server_cert_path=server['server_cert_path'], + notification_handler=self.notification_handler, + reconnection_interval=server['reconnection_interval'], + metrics_controller=self.metrics_controller, + ) + is_connected, error_data = await self.opc_repository[opc_id].connect() + if not is_connected: + await self.send_notification_async( + metadata={ + 'model_id': '-', + 'model_name': '-', + 'workflow_name': '-', + 'schedule_name': 'INITIALIZATION', + }, + notification_id=error_data['notification_id'], + message=error_data['message'], + block=error_data['block'], + level=error_data.get('level', NotificationLevel.ERROR), + attachment_content=error_data.get('attachment_content', None), + ) + else: + self.logger.info( + f'OPC server {opc_id}:{server["server_name"]} connected successfully.' + ) + + async def write_data( + self, + server_id: str, + tag: str, + data: Any, + data_type: str, + tag_type: str, + metadata: dict[str, Any], + ) -> tuple[float | None, dict[str, Any] | None]: + """ + Write data to a specific OPC server tag with comprehensive error handling. + + Return: + tuple[float | None, dict[str, Any] | None]: Response time on success, or + (None, error info_data) on repository failure. + """ + + try: + is_success, info_data = await self.opc_repository[server_id].write_data( + tag, data, data_type, metadata + ) + if not is_success: + await self.send_notification_async( + metadata=metadata, + notification_id=info_data['notification_id'], + message=info_data['message'], + block=info_data['block'], + level=info_data.get('level', NotificationLevel.ERROR), + attachment_content=info_data.get('attachment_content', None), + ) + return None, info_data + return info_data['response_time'], None + except Exception as e: + trace = traceback.format_exc() + await self.send_notification_async( + metadata=metadata, + notification_id=f'WRITE_OPC_{tag_type.upper()}_ERROR', + message=f'Error writing data to OPC server: {e}', + block='write_opc_data', + level=NotificationLevel.ERROR, + attachment_content=trace, + ) + raise e + + async def validate_server(self, server_id: str, metadata: dict[str, Any]) -> bool: + """ + Validate that an OPC server is available and configured for write operations. + + This method checks if the specified OPC server exists in the active + repository and is available for data writing operations. It provides + immediate feedback for server availability and logs validation failures + for operational monitoring. + + Args: + server_id (str): Unique identifier for the OPC server to validate + metadata (dict[str, Any]): Context metadata for logging and notifications + + Returns: + bool: True if server is available, False otherwise + + Note: + Server validation failures are automatically reported through the + notification system with detailed information about available servers. + This helps operators quickly identify configuration issues. + """ + if self.opc_repository.get(server_id) is None: + message = f'OPC server {server_id} not found to perform write operation.' + await self.send_notification_async( + metadata=metadata, + notification_id='OPC_SERVER_NOT_FOUND', + message=message, + block='write_opc_data', + level=NotificationLevel.ERROR, + attachment_content=f'OPC servers: {list(self.opc_repository.keys())}', + ) + return False + return True + + async def _write_tags_from_config( + self, + server_id: str, + tags_config: dict[str, dict[str, Any]], + data: DataFrame, + data_column: str, + tag_type: str, + log_label: str, + metadata: dict[str, Any], + ) -> tuple[dict[str, float | None], bool, str | None, bool]: + """ + Write a group of OPC tags and collect response times and error flags. + + Args: + server_id: Target OPC server identifier. + tags_config: Tag name to configuration mapping. + data: DataFrame with prediction/confidence columns. + data_column: Column name whose first row value is written. + tag_type: Tag category passed to write_data ('prediction' or 'confidence'). + log_label: Human-readable label for success logs. + metadata: Context metadata for logging and notifications. + + Return: + (response_times, session_bad_seen, session_bad_status, reconnect_in_progress_seen) + """ + response_times: dict[str, float | None] = {} + session_bad_seen = False + session_bad_status: str | None = None + reconnect_in_progress_seen = False + + for tag, tag_config in tags_config.items(): + response_time, error_info = await self.write_data( + server_id=server_id, + tag=tag, + data=data.head(1)[data_column].values[0], + data_type=tag_config['data_type'], + tag_type=tag_type, + metadata=metadata, + ) + session_bad_seen, session_bad_status, reconnect_in_progress_seen = ( + _apply_opc_write_error( + error_info, + session_bad_seen, + session_bad_status, + reconnect_in_progress_seen, + ) + ) + if response_time is not None: + self.info( + f'{log_label} written to OPC server {server_id} for tag {tag}.', + metadata, + ) + response_times[tag] = response_time + + return response_times, session_bad_seen, session_bad_status, reconnect_in_progress_seen + + async def manage_output_tags( + self, + server_id: str, + config: dict[str, Any], + data: DataFrame, + metadata: dict[str, Any], + ) -> tuple[bool, dict[str, float | None], bool, str | None, bool]: + """ + Manage the writing of prediction and confidence data to OPC server tags. + + This method orchestrates the writing of multiple data types to OPC servers + based on configuration. It handles both prediction data and confidence + values independently, allowing for flexible tag configuration and + comprehensive error handling. + + The method supports two main tag types: + 1. Prediction tags: Write actual prediction values to configured OPC tags + 2. Confidence tags: Write confidence scores to separate OPC tags + + Args: + server_id (str): Unique identifier for the target OPC server + config (dict[str, Any]): OPC tag configuration containing: + - prediction_tags (dict, optional): Prediction tag configurations + - confidence_tags (dict, optional): Confidence tag configurations + data (DataFrame): DataFrame containing prediction and confidence data + metadata (dict[str, Any]): Context metadata for logging and notifications + success (bool): Current success status to maintain across operations + + Returns: + tuple[bool, int]: (overall_success, total_tags_written) + - overall_success: True if all configured tags were written successfully + - total_tags_written: Count of successfully written tags + """ + response_times: dict[str, float | None] = {} + session_bad_seen = False + session_bad_status: str | None = None + reconnect_in_progress_seen = False + + tag_groups = ( + ('prediction_tags', 'prediction', 'prediction', 'Prediction data'), + ('confidence_tags', 'prediction_confidence', 'confidence', 'Confidence data'), + ) + for config_key, data_column, tag_type, log_label in tag_groups: + if config_key not in config: + continue + ( + group_times, + group_session_bad, + group_status, + group_reconnect, + ) = await self._write_tags_from_config( + server_id=server_id, + tags_config=config[config_key], + data=data, + data_column=data_column, + tag_type=tag_type, + log_label=log_label, + metadata=metadata, + ) + response_times.update(group_times) + if group_session_bad: + session_bad_seen = True + session_bad_status = group_status or session_bad_status + if group_reconnect: + reconnect_in_progress_seen = True + + success = None not in response_times.values() + return ( + success, + response_times, + session_bad_seen, + session_bad_status, + reconnect_in_progress_seen, + ) + + @activity.defn(name='write_opc_data') + async def write_opc_data( + self, input_data: dict[str, Any] + ) -> tuple[dict[Hashable, Any], dict[str, dict[str, float | None]]]: + """ + Write prediction and confidence data to OPC servers. The two writing + operations are optional and independent of each other. + + Args: + - input_data(dict[str, Any]): The input data. Contains the following keys: + - data(dict[str, Any]): The dataframe that contains the data to write + to the OPC servers. + - opc_output_config(dict[str, Any]): The OPC writing configuration. + The keys are the OPC server names and the values contain: + - prediction_tags(dict[str, Any]): The tags to write to the OPC servers. + - confidence_tags(dict[str, Any]): The tags to write to the OPC servers. + + Returns: + - dict[Any, Any]: The data that was written to the OPC servers. + + """ + metadata = input_data['metadata'] + self.info('Writing data to OPC servers...', metadata) + data = DataFrame(input_data['data']) + opc_output_config = input_data['opc_output_config'] + self.info(f'Data to write: {data.size} rows', metadata) + + success = True + session_bad_seen = False + session_bad_status: str | None = None + reconnect_in_progress_seen = False + + metrics: dict[str, dict[str, float | None]] = {} + + for server_id, config in opc_output_config.items(): + if not await self.validate_server(server_id, metadata): + success = False + continue + + ( + local_success, + local_response_times, + local_session_bad, + local_status, + local_reconnect_in_progress, + ) = await self.manage_output_tags(server_id, config, data, metadata) + metrics[server_id] = local_response_times + local_count = len(local_response_times) + success = success and local_success + if local_session_bad: + session_bad_seen = True + session_bad_status = local_status or session_bad_status + if local_reconnect_in_progress: + reconnect_in_progress_seen = True + + self.info( + f'Process completed for OPC server {server_id}: {local_count} of {len(config.get("prediction_tags", []))} prediction tags and {len(config.get("confidence_tags", []))} confidence tags', + metadata, + ) + + return ( + self.process_confidence( + data, + success, + metadata, + session_bad=session_bad_seen, + opc_status=session_bad_status, + reconnect_in_progress=reconnect_in_progress_seen, + ), + metrics, + ) + + def process_confidence( + self, + data: DataFrame, + success: bool, + metadata: dict[str, Any], + *, + session_bad: bool = False, + opc_status: str | None = None, + reconnect_in_progress: bool = False, + ) -> dict[Hashable, Any]: + """ + Process prediction confidence based on OPC write operation success. + + This method updates the prediction confidence values in the DataFrame + based on the success status of OPC server write operations. If any + write operations failed, it sets the confidence to a predefined error + value to indicate data quality issues. + + The method implements a confidence degradation strategy: + - Success: Maintains original confidence values + - Failure: Sets confidence to error value for operational awareness + + Args: + data (DataFrame): DataFrame containing prediction and confidence data + success (bool): Overall success status of OPC write operations + metadata (dict[str, Any]): Context metadata for logging and notifications + + Returns: + dict[Any, Any]: Processed data as a dictionary with updated confidence values + + Note: + The error confidence value (OPC_WRITTING_ERROR_CONFIDENCE = 12) is + used to indicate that data was not successfully exported to OPC servers. + This allows downstream systems to handle data quality appropriately. + """ + + if not success: + comment_parts: list[str] = [] + confidence = OPC_WRITTING_ERROR_CONFIDENCE + + if session_bad: + comment_parts.append(_opc_session_bad_comment(opc_status)) + confidence = OPC_SESSION_BAD_CONFIDENCE + if reconnect_in_progress: + comment_parts.append(OPC_RECONNECT_IN_PROGRESS_COMMENT) + confidence = OPC_SESSION_BAD_CONFIDENCE + if not comment_parts: + comment_parts.append(OPC_WRITTING_ERROR_MESSAGE) + + comments = OPC_COMMENT_SEPARATOR.join(comment_parts) + data['prediction_confidence'] = confidence + data['comments'] = comments + self.debug( + f'OPC write issues, confidence={confidence}, comments={comments}', + metadata, + ) + else: + self.debug('Data written to OPC servers successfully.', metadata) + + return data.to_dict() + + async def close(self): + """ + Gracefully shutdown all OPC server connections and cleanup resources. + + This method ensures proper cleanup of all active OPC server connections + by calling the disconnect method on each repository instance. It's + designed to be called during application shutdown to prevent resource + leaks and ensure clean termination. + + The method performs the following cleanup operations: + 1. Iterates through all active OPC repository connections + 2. Calls disconnect() on each repository instance + 3. Allows for graceful connection termination + 4. Prevents resource leaks and connection hanging + + Note: + This method should be called during application shutdown to ensure + proper cleanup. It handles all active connections regardless of + their current state and provides a clean shutdown experience. + """ + for opc in self.opc_repository.values(): + await opc.disconnect() diff --git a/laborious/activities/storage.py b/laborious/activities/storage.py new file mode 100644 index 0000000..d64cc52 --- /dev/null +++ b/laborious/activities/storage.py @@ -0,0 +1,209 @@ +from temporalio import activity, workflow + +from laborious.utils.repository.minio_manager import MinioManager + +with workflow.unsafe.imports_passed_through(): + # Extend the Temporal Postgres activities for convenient query -> MinIO export + import traceback + from datetime import timedelta + from typing import Any + + import pandas as pd + 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.metrics_controller import MetricsController + from sientia_do.repository.minio_repository import MinioRepository + from sientia_do.temporal.activities.postgres import Postgres + from sientia_do.temporal.constants import now + + from laborious.utils.models.minio_dataframe_payload import MinioDataFramePayload + +_LOAD_QUERY_OFFLOAD_SKIP_KEYS = frozenset({'model_name', 'key_prefix', 'size_threshold_bytes'}) + + +class Storage(Postgres, MinioManager): + """ + Extensions for Postgres activities with a helper to export query results + directly to MinIO as Parquet and return the object name. + """ + + minio_repository: MinioRepository | None = None + + def __init__( + self, + host: str, + port: int, + user: str, + password: str, + dbname: str, + min_connections: int, + max_connections: int, + retention_hours: int = 24, + minio_repository: MinioRepository | None = None, + logger: Logger | None = None, + notification_handler: NotificationHandler | None = None, + metrics_controller: MetricsController | None = None, + ): + self.retention_hours = retention_hours + Postgres.__init__( + self, + host=host, + port=port, + user=user, + password=password, + dbname=dbname, + min_connections=min_connections, + max_connections=max_connections, + logger=logger, + notification_handler=notification_handler, + metrics_controller=metrics_controller, + ) + + MinioManager.__init__( + self, minio_repository, logger, notification_handler, metrics_controller + ) + + @activity.defn(name='load_query_with_minio_offload') + async def load_query_with_minio_offload( + self, input_data: dict[str, Any] + ) -> MinioDataFramePayload: + """ + Run the custom SQL load, then return a MinIO-aware dataframe wire dict. + + Args (input_data): + metadata (dict): Workflow metadata (same as load_custom_query). + query (str): SQL query. + datetime_columns (list[str], optional): Datetime column names. + model_name (str): Model name for object key basename. + key_prefix (str, optional): Directory prefix inside the bucket. + size_threshold_bytes (int, optional): Override env offload threshold. + + Returns: + dict[str, Any]: Flat ``MinioDataFramePayload`` dict or ``success: False`` on failure. + """ + if self.minio_repository is None: + raise ValueError('Minio repository not initialized') + + metadata: dict = input_data.get('metadata', {}) + model_name = input_data['model_name'] + + rows = await self.load_custom_query( + input_data, + ) + if not rows: + self.error( + 'load_query_with_minio_offload failed: No data returned from query', metadata + ) + dataframe = None + else: + dataframe = pd.DataFrame(rows) + + return await MinioDataFramePayload.from_dataframe( + dataframe, + minio_repo=self.minio_repository, + workflow_metadata=metadata, + model_name=model_name, + operation='initial', + logger=self.logger, + ) + + @activity.defn(name='export_payload_to_postgres') + async def export_payload_to_postgres(self, input_data: dict[str, Any]) -> dict: + """ + Export a payload to PostgreSQL. + """ + metadata = input_data.get('metadata') + payload = MinioDataFramePayload.from_dict(input_data['data']) + data = await payload.retrieve(self.minio_repository, metadata) + + return await self.export_data_to_postgres( + { + **input_data, + 'data': data, + } + ) + + @activity.defn(name='cleanup_minio_objects_expired') + async def cleanup_minio_objects_expired(self, input_data: dict[str, Any]) -> dict[str, Any]: + """ + Delete objects under the given prefixes that are older than the retention window. + + Args (input_data): + metadata (dict): Workflow metadata for logging and metrics. + prefixes (list[str]): Key prefixes to scan (one level or subtree per prefix). + + Returns: + dict[str, Any]: ``success``, ``deleted_count``, and optional ``message``. + """ + if self.minio_repository is None: + raise ValueError('Minio repository not initialized') + + metadata = input_data.get('metadata', {}) + payload = MinioDataFramePayload.from_dict(input_data['data']) + prefix = payload.cleanup_prefix() + base = now() + cutoff = (base.replace(tzinfo=None) if base.tzinfo else base) - timedelta( + hours=self.retention_hours + ) + + report: dict[str, Any] = { + 'failed': {}, + 'deleted': {}, + 'failed_count': 0, + 'deleted_count': 0, + } + try: + keys = await self.minio_repository.list_objects( + prefix=prefix, + recursive=True, + metadata=metadata, + ) + for key in keys: + try: + ts = MinioDataFramePayload.parse_object_timestamp(key) + if ts is None: + continue + if ts >= cutoff: + continue + await self.minio_repository.delete_file( + object_name=key, + metadata=metadata, + ) + except Exception as e: + report['failed'][key] = { + 'success': False, + 'message': str(e), + } + report['failed_count'] += 1 + continue + report['deleted'][key] = { + 'success': True, + 'message': 'Deleted', + } + report['deleted_count'] += 1 + except Exception as e: + trace = traceback.format_exc() + await self.send_notification_async( + metadata=metadata, + notification_id='ERROR_CLEANUP_MINIO_OBJECTS_EXPIRED', + message=f'Error cleaning up MinIO objects: {e}', + block='cleanup_minio_objects_expired', + level=NotificationLevel.ERROR, + attachment_content=trace, + ) + self.error(trace, metadata) + else: + # Cleanup success is expected in normal flow; avoid noisy INFO notifications + # that do not impact behavior and can flood observability in test runs. + self.info('MinIO objects cleaned up successfully', metadata) + + return report + + def close(self) -> None: + """Close Storage resources (MinIO client and Postgres engine).""" + Postgres.close(self) + MinioManager.close(self) + + def __del__(self): + self.close() diff --git a/laborious/metrics.py b/laborious/metrics.py new file mode 100644 index 0000000..74a9189 --- /dev/null +++ b/laborious/metrics.py @@ -0,0 +1,200 @@ +""" +Laborious Metrics Module + +This module defines all Prometheus metrics used by the Sientia DataOps Laborious system +for monitoring and observability. The metrics provide insights into system performance, +prediction quality, and operational health. + +The metrics are designed to be scraped by Prometheus and can be visualized in +Grafana or other monitoring dashboards to provide real-time visibility into +the system's operation. + +Key Metric Categories: +- Application Health: Overall system status and availability +- Prediction Operations: Count and performance of prediction operations +- Data Quality: Confidence levels and validation results +- Export Operations: Database and OPC export performance +- Response Times: Performance monitoring for various operations + +Metric Labels: +- pod_id: Kubernetes pod identifier for multi-instance deployments +- runtime: Runtime / environment identifier (matches ``RUNTIME`` env, see ``SientiaMonitoring``) +- model_name: Name of the ML model being used +- workflow_name: Name of the prediction pipeline +- opc_server_id: Identifier for OPC server operations +""" + +from prometheus_client import Counter, Gauge, Histogram +from sientia_do.observability.metrics import CORE_LABELS + +# Application health metric +APP_UP = Gauge( + 'app_up', + 'Indicates if the application is running (1) or shutting down (0)', + ['pod_id'], +) + +# Prediction operation metrics +PREDICTIONS_WRITTEN_COUNT = Counter( + 'laborious_predictions_written_count', + 'Number of predictions written to the database table predictions', + CORE_LABELS, +) + +# Prediction quality metrics +PREDICTION_CONFIDENCE_MONITOR = Gauge( + 'laborious_prediction_confidence_monitor', + 'Current confidence of each prediction', + CORE_LABELS, +) + +# Prediction total response time +PREDICTION_RESPONSE_TIME_MONITOR = Histogram( + 'laborious_prediction_response_time_monitor', + 'Current response time of each prediction', + CORE_LABELS, + buckets=[0.01, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0], +) + + +# ================== OPC metrics ================== + + +PREDICTION_OPC_WRITING_COUNT = Counter( + 'laborious_prediction_opc_writing_count', + 'Number of predictions written to the OPC server', + [*CORE_LABELS, 'opc_server_id', 'tag'], +) + +PREDICTION_OPC_WRITING_RESPONSE_TIME_MONITOR = Histogram( + 'laborious_prediction_opc_writing_response_time_monitor', + 'Current response time of each prediction written to the OPC server', + [*CORE_LABELS, 'opc_server_id', 'tag'], + buckets=[0.01, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0], +) + +OPC_CONNECTIONS_TOTAL = Counter( + 'opc_connections_initiated_total', + 'Total connection attempts to OPC servers', + ['pod_id', 'server_name'], +) +OPC_CONNECTIONS_FAILED = Counter( + 'opc_connections_failed_total', + 'Total failed connection attempts to OPC servers', + ['pod_id', 'server_name'], +) +OPC_CONNECTION_STATUS = Gauge( + 'opc_connection_status', + 'Connection status with the OPC server (1=connected, 0=disconnected)', + ['pod_id', 'server_name', 'server_url'], +) + +_OPC_SESSION_DEBUG_LABELS = ['pod_id', 'server_name', 'runtime', 'opc_server_id', 'session_id'] + +OPC_SESSION_CREATED_TOTAL = Counter( + 'opc_session_created_total', + 'OPC UA sessions established (after successful connect)', + _OPC_SESSION_DEBUG_LABELS, +) + +OPC_SESSION_CLOSED_TOTAL = Counter( + 'opc_session_closed_total', + 'OPC UA client disconnects completed (session tear-down initiated)', + _OPC_SESSION_DEBUG_LABELS, +) + +OPC_SESSION_REVISED_TIMEOUT_MS = Gauge( + 'opc_session_revised_timeout_milliseconds', + 'Server-revised OPC UA session timeout (RevisedSessionTimeout) in ms after connect', + _OPC_SESSION_DEBUG_LABELS, +) + +OPC_WRITE_ATTEMPT_LABELS = [*_OPC_SESSION_DEBUG_LABELS, 'model_id', 'model_name', 'result'] + +OPC_WRITE_ATTEMPTS_TOTAL = Counter( + 'opc_write_attempts_total', + 'OPC UA write attempts with session and outcome (result=OK or exception class name)', + OPC_WRITE_ATTEMPT_LABELS, +) + +OPC_WRITE_INTER_ARRIVAL_OVER_SESSION_TIMEOUT_TOTAL = Counter( + 'opc_write_inter_arrival_over_session_timeout_total', + 'Successful writes where seconds since the previous successful write exceeded RevisedSessionTimeout (ms)', + _OPC_SESSION_DEBUG_LABELS, +) + +# ================== Model metrics ================== + +MODEL_READ_LAG = Histogram( + 'laborious_model_read_lag', + 'Lag between the start and read of read operations', + CORE_LABELS, + buckets=[0.01, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0], +) + +MODEL_WRITE_LAG = Histogram( + 'laborious_model_write_lag', + 'Lag between the start and end of write operations', + CORE_LABELS, + buckets=[0.01, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0], +) + +MODEL_READ_COUNT = Counter( + 'laborious_model_read_count', + 'Number of reads from the model', + CORE_LABELS, +) + +MODEL_WRITE_COUNT = Counter( + 'laborious_model_write_count', + 'Number of writes to the model', + CORE_LABELS, +) + +MODEL_READ_ERROR_COUNT = Counter( + 'laborious_model_read_error_count', + 'Number of errors reading from the model', + CORE_LABELS, +) + +MODEL_WRITE_ERROR_COUNT = Counter( + 'laborious_model_write_error_count', + 'Number of errors writing to the model', + CORE_LABELS, +) + +MODEL_ANALYZE_LAG = Histogram( + 'laborious_model_analyze_lag', + 'Lag between the start and end of analyze operations', + CORE_LABELS, + buckets=[0.01, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0], +) + +MODEL_ANALYZE_COUNT = Counter( + 'laborious_model_analyze_count', + 'Number of analyze operations', + CORE_LABELS, +) + +MODEL_ANALYZE_ERROR_COUNT = Counter( + 'laborious_model_analyze_error_count', + 'Number of errors during analyze operations', + CORE_LABELS, +) + + +# ================== PI Web API metrics ================== + +PI_WEB_API_LABELS = [*CORE_LABELS, 'tag_name'] + +PI_WEB_API_PREDICTION_WRITTEN_COUNT = Counter( + 'laborious_pi_web_api_prediction_written_count', + 'Number of predictions written to the PI Web API', + PI_WEB_API_LABELS, +) + +PI_WEB_API_PREDICTION_WRITTEN_ERROR_COUNT = Counter( + 'laborious_pi_web_api_prediction_written_error_count', + 'Number of errors writing predictions to the PI Web API', + PI_WEB_API_LABELS, +) diff --git a/laborious/utils/__init__.py b/laborious/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/laborious/utils/connectors_config.py b/laborious/utils/connectors_config.py new file mode 100644 index 0000000..ed3477a --- /dev/null +++ b/laborious/utils/connectors_config.py @@ -0,0 +1,92 @@ +import json +from os import getenv +from typing import Any + + +def build_mlflow_config() -> dict[str, Any]: + """ + Build MLFlow server configuration from environment variables. + + This function constructs an MLFlow configuration dictionary from + environment variables with sensible defaults for local development. + It handles server connection and authentication parameters. + + Environment Variables: + MLFLOW_HOST: MLFlow server hostname (default: http://localhost) + MLFLOW_PORT: MLFlow server port (default: 5080) + MLFLOW_USERNAME: MLFlow username (default: aignosi) + MLFLOW_PASSWORD: MLFlow password (default: aignosi) + + Returns: + dict: MLFlow configuration dictionary with all required parameters + """ + return { + 'host': getenv('MLFLOW_HOST', 'http://localhost'), + 'port': int(getenv('MLFLOW_PORT', '5080')), + 'username': getenv('MLFLOW_USERNAME', 'aignosi'), + 'password': getenv('MLFLOW_PASSWORD', 'aignosi'), + } + + +def build_opc_config() -> dict[str, Any]: + """ + Build OPC server configuration from environment variables. + + This function constructs an OPC server configuration dictionary from + environment variables. It supports both single server and multi-server + configurations with flexible parameter handling. + + Environment Variables: + OPC_CONFIG: JSON string containing multiple OPC server configurations + OPC_ID: OPC server ID (fallback, default: 1) + OPC_URL: Single OPC server URL (fallback, default: opc.tcp://localhost:4840) + OPC_SERVER_URI: Single OPC server URI (fallback, default: opc.tcp://localhost:4840) + OPC_CERT_PATH: Client certificate path (fallback, default: None) + OPC_PRIVATE_KEY_PATH: Client private key path (fallback, default: None) + OPC_SERVER_CERT_PATH: Server certificate path (fallback, default: None) + OPC_RECONNECTION_INTERVAL: Reconnection interval in seconds (fallback, default: 120) + + Returns: + dict: OPC server configuration dictionary + """ + opc_raw = getenv('OPC_CONFIG', None) + + if opc_raw: + return json.loads(opc_raw) + + return { + getenv('OPC_ID', '1'): { + 'id': getenv('OPC_ID', '1'), + 'server_name': getenv('OPC_SERVER_NAME', 'default_server'), + 'url': getenv('OPC_URL', 'opc.tcp://localhost:4840'), + 'server_uri': getenv('OPC_SERVER_URI', 'opc.tcp://localhost:4840'), + 'cert_path': getenv('OPC_CERT_PATH', None), + 'private_key_path': getenv('OPC_PRIVATE_KEY_PATH', None), + 'server_cert_path': getenv('OPC_SERVER_CERT_PATH', None), + 'reconnection_interval': int(getenv('OPC_RECONNECTION_INTERVAL', '120')), + } + } + + +def build_minio_config() -> dict[str, Any]: + """ + Build MinIO (S3-compatible) configuration from environment variables. + + Environment Variables: + MINIO_ENDPOINT: MinIO endpoint including scheme (default: http://localhost:9000) + MINIO_ACCESS_KEY: Access key (default: minioadmin) + MINIO_SECRET_KEY: Secret key (default: minioadmin) + MINIO_REGION: Region name for S3 client (default: us-east-1) + MINIO_BUCKET_DEFAULT: Default bucket for uploads (default: laborious) + MINIO_SECURE: Whether to use HTTPS (default: false) + Returns: + dict: MinIO configuration dictionary + """ + return { + 'endpoint_url': getenv('MINIO_ENDPOINT_URL', 'http://localhost:9000'), + 'access_key': getenv('MINIO_ACCESS_KEY', 'minioadmin'), + 'secret_key': getenv('MINIO_SECRET_KEY', 'minioadmin'), + 'default_bucket': getenv('MINIO_DEFAULT_BUCKET', 'laborious'), + 'retention_hours': int(getenv('MINIO_RETENTION_HOURS', '24')), + 'secure': getenv('MINIO_SECURE', 'false') == 'true', + } diff --git a/laborious/utils/dataframe_debug.py b/laborious/utils/dataframe_debug.py new file mode 100644 index 0000000..995b606 --- /dev/null +++ b/laborious/utils/dataframe_debug.py @@ -0,0 +1,34 @@ +from typing import Any + +from pandas import DataFrame + +DEFAULT_MAX_DEBUG_DATAFRAME_ROWS = 100 + + +def build_dataframe_debug_message( + message: str, + data: Any, + max_rows: int = DEFAULT_MAX_DEBUG_DATAFRAME_ROWS, +) -> str: + """ + Build a safe debug message for dataframe payloads + + Args: + - message (str): Base message to identify the logged payload + - data (Any): Payload to evaluate for dataframe-aware logging + - max_rows (int): Maximum dataframe row count allowed for full payload logging + + Return: + Formatted debug message with full dataframe content or compact summary + """ + if not isinstance(data, DataFrame): + return f'{message} {data}' + + rows = data.shape[0] + if rows <= max_rows: + return f'{message}\n{data.to_csv()}' + + return ( + f'{message} skipped because dataframe has {rows} rows ' + f'(max: {max_rows}). Shape: {data.shape}' + ) diff --git a/laborious/utils/filters/__init__.py b/laborious/utils/filters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/laborious/utils/filters/conditional_filters.py b/laborious/utils/filters/conditional_filters.py new file mode 100644 index 0000000..967e52c --- /dev/null +++ b/laborious/utils/filters/conditional_filters.py @@ -0,0 +1,48 @@ +from pandas import DataFrame + + +def filter_specific_variables_null_values(data: DataFrame, config: dict) -> bool: + """ + Filter to check if specific variables contain null values. + + This function examines a DataFrame to determine if any of the specified variables + contain null (NaN) values. It returns True if null values are found for any of + the specified variables, False otherwise. + + Args: + data (DataFrame): The pandas DataFrame to be examined. Must contain columns + named 'variable' and 'value'. + config (dict): Configuration dictionary containing the following key: + - variables (list): List of variable names to check for null values + + Returns: + bool: True if any of the specified variables contain null values, + False if none of the specified variables contain null values. + + """ + + if data.empty: + return False + + return not data[data['variable'].isin(config['variables']) & data['value'].isna()].empty + + +def filter_empty_data(data: DataFrame, _config: dict) -> bool: + """ + Filter to check if the DataFrame is empty. + + This function determines whether the provided DataFrame contains any data. + It's a simple utility function that can be used in conditional logic to + handle cases where no data is available. + + Args: + data (DataFrame): The pandas DataFrame to be checked for emptiness. + _config (dict): Configuration dictionary (unused in this function). + The underscore prefix indicates this parameter is required for + interface consistency but not used in the implementation. + + Returns: + bool: True if the DataFrame is empty (has no rows), False if it contains data. + + """ + return data.empty diff --git a/laborious/utils/filters/mlflow_filters.py b/laborious/utils/filters/mlflow_filters.py new file mode 100644 index 0000000..82970e8 --- /dev/null +++ b/laborious/utils/filters/mlflow_filters.py @@ -0,0 +1,64 @@ +import numpy as np +from pandas import DataFrame + + +def api_error_filter(response: dict, _config: dict) -> bool: + """ + Filter MLFlow API responses for error conditions. + + This function analyzes MLFlow API responses to detect error conditions + and determine if the response should be filtered out due to quality + or reliability issues. + + + Args: + response: MLFlow API response data (dict) + _config: Filter configuration dictionary + Required keys: + - error_codes (list, optional): List of error codes to detect + - error_keywords (list, optional): List of error keywords to detect + - check_structure (bool, optional): Whether to validate response structure + + Returns: + bool: True if data should be filtered (contains errors), False otherwise + + """ + if not response: + return True + + if not response['success']: + return True + + return False + + +def nan_values_filter(predictions: DataFrame, _config: dict) -> bool: + """ + Filter data for NaN (Not a Number) values. + + This function detects NaN values in MLFlow prediction results and + determines if the data quality is sufficient for further processing + or export operations. + + Args: + predictions: DataFrame containing prediction data to check for NaN values + _config: Filter configuration dictionary + Required keys: + - max_nan_ratio (float, optional): Maximum allowed NaN value ratio (0.0 to 1.0) + - max_nan_count (int, optional): Maximum allowed NaN value count + - check_nested (bool, optional): Whether to check nested data structures + + Returns: + bool: True if data should be filtered (too many NaN values), False otherwise + + """ + data = ( + predictions.replace({None: np.nan}) + .drop(columns=['timestamp'], errors='ignore') + .infer_objects() + ) + + if data.isna().all().all(): + return True + + return False diff --git a/laborious/utils/models/__init__.py b/laborious/utils/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/laborious/utils/models/minio_dataframe_payload.py b/laborious/utils/models/minio_dataframe_payload.py new file mode 100644 index 0000000..0521f5e --- /dev/null +++ b/laborious/utils/models/minio_dataframe_payload.py @@ -0,0 +1,348 @@ +""" +MinIO-backed DataFrame payload for Temporal workflows. + +Data is never stored as a pandas ``DataFrame`` field on the dataclass. +Instead, the DataFrame is only provided as an input to: +`from_dataframe` / `from_dataframe_to_dict`. + +At build time, the DataFrame is evaluated for its serialized size; if it exceeds +the configured threshold, it is serialized to parquet bytes and uploaded to MinIO. +Otherwise, it is inlined as a Temporal-friendly ``dict``. +""" + +import pickle +import re +from collections.abc import Hashable +from dataclasses import dataclass +from datetime import datetime +from io import BytesIO +from os import getenv +from typing import Any, Literal + +from pandas import DataFrame, read_parquet +from sientia_do.observability.logger import Logger +from sientia_do.repository.minio_repository import MinioRepository +from sientia_do.temporal.constants import DATETIME_FORMAT_FILENAME, DATETIME_FORMAT_WITH_TZ, now + +# Keys that are part of the serialized wire format (not arbitrary metadata). +_SERIALIZED_FIELD_KEYS = frozenset({'data', 'bucket', 'object_key', 'object_prefix', 'uri'}) + +_OBJECT_TIMESTAMP_PATTERN = re.compile( + r'-(?:initial|transform)-(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})\.parquet$' +) + +OFFLOAD_THRESHOLD_BYTES = int( + float(getenv('SIENTIA_MINIO_OFFLOAD_THRESHOLD_MEGABYTES', '1.5')) * 1024 * 1024 +) + +# Relative prefix used for storing offloaded prediction datasets in MinIO. +# It is also the root directory for retention cleanup listing. +PREDICTION_DATASETS_PREFIX = 'prediction_datasets' + +OperationKind = Literal['initial', 'transform', 'predict'] + + +def _build_object_key( + model_name: str, operation: OperationKind, timestamp: str +) -> tuple[str, str | None]: + """ + Build the MinIO object key and the directory prefix used for retention listing. + + Args: + model_name: Registered model name used in the pipeline. + operation: Either initial (pre-transform load) or transform (post-MLFlow transform). + timestamp: Filename timestamp segment from DATETIME_FORMAT_FILENAME. + + Return: + tuple[str, str | None]: Full object key and normalized prefix (or None if at bucket root). + """ + # Naming convention: + # - Directory is always `prediction_datasets/` + # - Filename follows the retention-parsing pattern + basename = f'{model_name}-{operation}-{timestamp}.parquet' + model_dir = model_name.strip().strip('/') + prefix = f'{PREDICTION_DATASETS_PREFIX}/{model_dir}' + return f'{prefix}/{basename}', prefix + + +@dataclass +class MinioDataFramePayload: + """ + Serializable payload after a DataFrame was evaluated: inline tabular dict and/or MinIO keys. + + Build from a live DataFrame only via `from_dataframe` / `from_dataframe_to_dict`. + Rehydrate from Temporal via `from_dict`. The DataFrame is not a field on this class. + """ + + last_timestamp: str + status: dict[str, Any] | None = None + data: dict[Hashable, Any] | None = None + bucket: str | None = None + object_key: str | None = None + object_prefix: str | None = None + uri: str | None = None + + @staticmethod + def _debug( + logger: Logger | None, + message: str, + metadata: dict[str, Any] | None = None, + ) -> None: + """ + Emit debug logs only when logger is provided + + Args: + - logger (Logger | None): Logger instance used for debug messages + - message (str): Message to be logged + - metadata (dict[str, Any] | None): Optional workflow metadata context + """ + if logger is None: + return + logger.custom_debug(message, metadata) + + @classmethod + def from_dict(cls, raw: 'dict[str, Any] | MinioDataFramePayload') -> 'MinioDataFramePayload': + """ + Reconstruct a MinioDataFramePayload from a plain dict produced by Temporal serialization. + + Temporal converts dataclass return values into plain dicts when crossing + workflow/activity boundaries. This method rebuilds the typed instance so + that methods like ``retrieve``, ``cleanup_prefix`` and ``has_data`` are + available on the receiving side. + + If the argument is already a MinioDataFramePayload, it is returned as-is. + + Args: + raw: Dict with keys matching the dataclass fields + (last_timestamp, status, data, bucket, object_key, object_prefix, uri), + or an existing MinioDataFramePayload instance. + + Return: + MinioDataFramePayload: Reconstructed (or original) instance. + """ + if isinstance(raw, MinioDataFramePayload): + return raw + return cls( + last_timestamp=raw['last_timestamp'], + status=raw.get('status'), + data=raw.get('data'), + bucket=raw.get('bucket'), + object_key=raw.get('object_key'), + object_prefix=raw.get('object_prefix'), + uri=raw.get('uri'), + ) + + @staticmethod + def estimate_size_bytes( + df: DataFrame, + metadata: dict[str, Any] | None = None, + logger: Logger | None = None, + ) -> int: + """ + Approximate serialized size of the DataFrame as the default-orient dict. + + Args: + df: DataFrame whose tabular content size is estimated. + + Return: + int: Estimated size in bytes (pickle of dict representation). + """ + try: + size = len(pickle.dumps(df.to_dict())) + except Exception: + size = len(pickle.dumps(df)) + + MinioDataFramePayload._debug( + logger, + f'DataFrame size: {size} bytes', + metadata, + ) + return size + + @staticmethod + def parse_object_timestamp(object_key: str) -> datetime | None: + """ + Parse the timestamp embedded in the object key basename (before .parquet). + + Args: + object_key: S3/MinIO object key whose basename follows + ``{model}-{initial|transform}-{DATETIME_FORMAT_FILENAME}.parquet``. + + Return: + datetime | None: Parsed UTC-naive datetime from the key, or None if not matched. + """ + basename = object_key.rsplit('/', 1)[-1] + match = _OBJECT_TIMESTAMP_PATTERN.search(basename) + if not match: + return None + try: + return datetime.strptime(match.group(1), DATETIME_FORMAT_FILENAME) + except ValueError: + return None + + def cleanup_prefix(self) -> str | None: + """ + Return True if cleanup is enabled for this payload. + """ + if self.object_key is not None and self.data is None: + return self.object_prefix + return None + + def has_data(self) -> bool: + """ + Return True if the payload has some data internally or in MinIO. + """ + return (self.data is not None and self.data != {}) or self.object_key is not None + + @classmethod + async def from_dataframe( + cls, + dataframe: DataFrame | None, + minio_repo: MinioRepository, + model_name: str, + operation: OperationKind, + status: dict[str, Any] | None = None, + workflow_metadata: dict | None = None, + last_timestamp: str | None = None, + logger: Logger | None = None, + ) -> 'MinioDataFramePayload': + """ + Evaluate the DataFrame size, then either inline dict or upload parquet to MinIO. + + The DataFrame is not stored on the returned instance. + + Args: + dataframe: Tabular data to evaluate and persist (inline or MinIO). + metadata: Small metadata dict merged into the payload (e.g. success, message). + minio_repo: sientia_do MinioRepository (or compatible) with `upload_file()`. + workflow_metadata: Metadata passed to MinIO store for logging/metrics. + model_name: Registered model name used in the object basename. + operation: Either ``initial`` (query load) or ``transform`` (post-transform). + key_prefix: Backward-compatible parameter (currently ignored for object naming). + size_threshold_bytes: Byte limit before offload. When None, the module-level + environment-derived default is used. + + Return: + MinioDataFramePayload: Instance with data and/or MinIO fields set. + """ + + if dataframe is None or dataframe.empty: + cls._debug( + logger, + 'MinioDataFramePayload.from_dataframe received empty dataframe, returning empty payload', + workflow_metadata, + ) + return cls( + data=None, last_timestamp=now().strftime(DATETIME_FORMAT_WITH_TZ), status=status + ) + + if last_timestamp is None: + last_timestamp = max(dataframe['timestamp'].values.tolist()) + + dataframe_size = cls.estimate_size_bytes(dataframe, workflow_metadata, logger) + cls._debug( + logger, + ( + f'MinioDataFramePayload.from_dataframe estimated size: {dataframe_size} bytes ' + f'(threshold: {OFFLOAD_THRESHOLD_BYTES} bytes)' + ), + workflow_metadata, + ) + + if dataframe_size <= OFFLOAD_THRESHOLD_BYTES: + cls._debug( + logger, + 'MinioDataFramePayload.from_dataframe using inline payload', + workflow_metadata, + ) + return cls(data=dataframe.to_dict(), last_timestamp=last_timestamp, status=status) + + timestamp = now().strftime(DATETIME_FORMAT_FILENAME) + object_key, object_prefix = _build_object_key(model_name, operation, timestamp) + cls._debug( + logger, + ( + 'MinioDataFramePayload.from_dataframe offloading payload to MinIO ' + f'with key {object_key}' + ), + workflow_metadata, + ) + + # Upload using the relative object key. The upstream repository will + # prefix it internally under its MinIO namespace. + parquet_buffer = BytesIO() + dataframe.to_parquet(parquet_buffer, engine='pyarrow', index=True) + file_bytes = parquet_buffer.getvalue() + + upload_result = await minio_repo.upload_file( + file_bytes=file_bytes, + relative_key=object_key, + metadata=workflow_metadata, + ) + + bucket = minio_repo.bucket + object_key_full = upload_result.get('minio_object_name', object_key) + uri = f's3://{bucket}/{object_key_full}' if bucket else None + cls._debug( + logger, + f'MinioDataFramePayload.from_dataframe upload completed: {uri}', + workflow_metadata, + ) + + return cls( + data=None, + bucket=bucket, + object_key=object_key_full, + object_prefix=object_prefix, + uri=uri, + last_timestamp=last_timestamp, + status=status, + ) + + async def retrieve( + self, + minio_repo: MinioRepository, + workflow_metadata: dict[str, Any] | None = None, + logger: Logger | None = None, + ) -> DataFrame: + """ + Load parquet from MinIO when object_key is set and populate inline data. + + Args: + minio_repo: sientia_do MinioRepository (or compatible) with download_file(). + workflow_metadata: Metadata passed to MinIO read for logging/metrics. + + Return: + dict[str, Any]: Flat dict with data filled (same keys as to_dict after load). + """ + if self.data is not None: + self._debug( + logger, + 'MinioDataFramePayload.retrieve using inline payload data', + workflow_metadata, + ) + return DataFrame(self.data) + + if not self.has_data(): + self._debug( + logger, + 'MinioDataFramePayload.retrieve found no payload data, returning empty dataframe', + workflow_metadata, + ) + return DataFrame() + + self._debug( + logger, + f'MinioDataFramePayload.retrieve downloading object from MinIO: {self.object_key}', + workflow_metadata, + ) + file_bytes = await minio_repo.download_file( + object_name=self.object_key, metadata=workflow_metadata + ) + df = read_parquet(BytesIO(file_bytes)) + self._debug( + logger, + f'MinioDataFramePayload.retrieve loaded dataframe from MinIO with shape {df.shape}', + workflow_metadata, + ) + return df diff --git a/laborious/utils/repository/minio_manager.py b/laborious/utils/repository/minio_manager.py new file mode 100644 index 0000000..17c85e6 --- /dev/null +++ b/laborious/utils/repository/minio_manager.py @@ -0,0 +1,32 @@ +from sientia_do.notifications.handlers import NotificationHandler +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.minio_repository import MinioRepository + + +class MinioManager(SientiaMonitoring): + minio_repository: MinioRepository | None = None + + def __init__( + self, + minio_repository: MinioRepository | None = None, + logger: Logger | None = None, + notification_handler: NotificationHandler | None = None, + metrics_controller: MetricsController | None = None, + ): + if self.minio_repository is None: + self.minio_repository = minio_repository + SientiaMonitoring.__init__(self, logger, notification_handler, metrics_controller) + + def close(self) -> None: + """ + Close the MinioManager and clean up resources. + """ + if self.minio_repository is not None: + try: + self.minio_repository.close() + finally: + self.minio_repository = None + + SientiaMonitoring.shutdown(self) diff --git a/laborious/utils/repository/model_repository.py b/laborious/utils/repository/model_repository.py new file mode 100644 index 0000000..6a25b8c --- /dev/null +++ b/laborious/utils/repository/model_repository.py @@ -0,0 +1,1493 @@ +""" +MLflow repository utilities + +This module provides the `MLFlowRepository` class and helpers to interact with +an MLflow tracking server and model registry. It covers model discovery, +downloading/loading with multiple flavors, cached operations with retention +policies, transformation/prediction interfaces, retraining workflows, and +production model promotion. + +Capabilities: +- Model loading and caching with retention policies +- Data transformation and prediction operations +- Model retraining workflows +- Production model updates and versioning +""" + +import ctypes +import gc +import threading +import time +import traceback +from datetime import datetime, timedelta +from io import StringIO +from os import environ, makedirs, path +from shutil import rmtree +from typing import Any, Literal, overload + +import mlflow +import pandas as pd +from mlflow.entities import Experiment +from numpy import ndarray +from sientia_do.notifications.handlers import NotificationHandler +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.temporal.constants import DATETIME_FORMAT_WITH_TZ + +from laborious import metrics +from laborious.utils.dataframe_debug import build_dataframe_debug_message + +ARTIFACTS_PATH = './tmp/artifacts' +TRANSFORMED_COMPRESSED_PATH = 'artifacts/training_transformer.pkl' +PREDICTION_COMPRESSED_PATH = 'artifacts/stacking_model.pkl' + +INVALID_FLAVOR_MESSAGE = "Invalid flavor. Use 'sklearn' or 'pyfunc' or 'pytorch'." + + +def force_memory_release(logger: Logger): + """Attempt to release memory from the Python process. + + Executes a garbage collection cycle and calls `malloc_trim(0)` on glibc + where available to return free memory to the OS. This may be a no-op on + non-glibc systems. + + Args: + logger (Logger): Logger for observability. + """ + gc.collect() + + try: + ctypes.CDLL('libc.so.6').malloc_trim(0) + logger.info('Memory released') + except Exception as e: + logger.info(f'Memory release failed: {e}') + + +class MLFlowRepository(SientiaMonitoring): + _MAX_DEBUG_DATAFRAME_ROWS = 100 + + def __init__( + self, + host: str, + username: str, + password: str, + logger: Logger, + notification_handler: NotificationHandler, + metrics_controller: MetricsController, + ): + """Initialize MLflow client and base state. + + Args: + host (str): MLflow tracking URI. + username (str): MLflow username. + password (str): MLflow password. + logger (Logger): Logger instance. + """ + # set tracking uri + SientiaMonitoring.__init__(self, logger, notification_handler, metrics_controller) + mlflow.set_tracking_uri(host) + + environ['MLFLOW_TRACKING_USERNAME'] = username + environ['MLFLOW_TRACKING_PASSWORD'] = password + # Create an MLflow client + self.client = mlflow.tracking.MlflowClient() + + self.model_cache: dict[str, Any] = {} + self._cache_lock = threading.RLock() + self.logger = logger + + def _debug_dataframe(self, message: str, data: Any, metadata: dict[str, Any]) -> None: + """ + Log dataframe content only when row count is below the configured threshold + + Args: + - message (str): Base log message to identify the dataframe in logs + - data (Any): Dataframe-like object expected to expose shape and to_csv + - metadata (dict[str, Any]): Metadata for contextual logging + """ + self.debug( + build_dataframe_debug_message( + message=message, + data=data, + max_rows=self._MAX_DEBUG_DATAFRAME_ROWS, + ), + metadata, + ) + + """ + Functions related to get model registry parameters + """ + + def get_model_uri(self, run_id: str, prediction: bool = True): + """Build the artifact URI for a run's model. + + Args: + run_id (str): MLflow run identifier. + prediction (bool): If True, return `prediction_model` URI, + otherwise return `data_model` URI. + + Returns: + str: Artifact URI to the selected model within the run. + """ + run_info = mlflow.get_run(run_id) + if prediction: + model_uri = run_info.info.artifact_uri + '/prediction_model' + else: + model_uri = run_info.info.artifact_uri + '/data_model' + return model_uri + + def get_model_run_id(self, model_name: str, stage: str = 'Production') -> str: + """Resolve the run_id for a registered model at a given stage. + + Args: + model_name (str): Registered model name. + stage (str): Desired stage (e.g., 'Production'). + + Returns: + str: Run ID for the latest version at the given stage. + """ + # Use search_registered_models instead of deprecated get_latest_versions + registered_models = self.client.search_registered_models( + filter_string=f"name='{model_name}'" + ) + + if not registered_models: + raise mlflow.exceptions.MlflowException( + f"Model '{model_name}' not found in the Model Registry." + ) + + # Get all versions of the model and filter by stage + model_versions = self.client.search_model_versions(filter_string=f"name='{model_name}'") + + # Filter versions by the desired stage using current_stage attribute + stage_versions = [mv for mv in model_versions if mv.current_stage == stage] + + if not stage_versions: + raise mlflow.exceptions.MlflowException( + f"Model '{model_name}' in stage '{stage}' not found in the Model Registry." + ) + + # Sort by version number to get the latest + latest_version = max(stage_versions, key=lambda v: int(v.version)) + source = latest_version.source + if source is None: + raise mlflow.exceptions.MlflowException( + f"Model '{model_name}' version '{latest_version.version}' in stage '{stage}' " + 'has no source URI to resolve run ID.' + ) + parts = source.split('/') + if len(parts) <= 2 or not parts[2]: + raise mlflow.exceptions.MlflowException( + f"Model '{model_name}' version '{latest_version.version}' in stage '{stage}' " + f"has invalid source URI '{source}' for run ID resolution." + ) + return parts[2] + + def get_next_run_name(self, model_name: str) -> str: + """ + Generate the next run name for a specific MLflow model. + + This method calculates the next sequential run number for a model + by searching existing runs and incrementing the count. It ensures + unique run names for model training and retraining operations. + + Args: + model_name (str): The name of the MLflow model + + Returns: + str: The next run name in format 'model_name-run_number' + """ + runs = mlflow.search_runs(experiment_names=[model_name], order_by=['start_time desc']) + next_run_number = len(runs) + 1 + return f'{model_name}-{next_run_number}' + + def get_experiment( + self, experiment_name: str, create_if_not_exists: bool = False + ) -> Experiment: + """ + Retrieve MLflow experiment by name, optionally creating it. + + This method searches for an MLFlow experiment by name and + returns its unique identifier. It provides error handling + for non-existent experiments. + + Args: + experiment_name (str): Name of the MLflow experiment + + Returns: + Experiment: MLflow experiment object + + Raises: + ValueError: If the experiment name is not found and creation is disabled + """ + experiment = mlflow.get_experiment_by_name(experiment_name) + + if experiment is None: + if create_if_not_exists: + experiment_id = mlflow.create_experiment(experiment_name) + experiment = mlflow.get_experiment(experiment_id) + if experiment is None: + raise ValueError( + f'Experiment {experiment_name} not found after creation, unknown reason' + ) + else: + raise ValueError(f'Experiment {experiment_name} not found') + + return experiment + + def get_model_params(self, run_id: str): + """Fetch parameters associated with a given MLflow run. + + Args: + run_id (str): Run identifier to inspect. + + Returns: + dict: Mapping of parameter names to values. + """ + run_info = mlflow.get_run(run_id) + return run_info.data.params + + def check_artifact_exists( + self, run_id: str, artifact_path: str, metadata: dict[str, Any] + ) -> bool: + """ + Check if an artifact exists in the MLflow Model Registry. + + Args: + run_id (str): Run identifier to inspect. + artifact_path (str): Path to the artifact to check. + + Returns: + bool: True if the artifact exists, False otherwise. + """ + artifacts = self.client.list_artifacts(run_id) + + self.debug(f'Artifacts of {run_id}: \n{artifacts}', metadata) + self.debug(f'Looking for artifact {artifact_path} in {run_id}', metadata) + + return any(artifact.path == artifact_path for artifact in artifacts) + + """ + Functions related to download and load models + """ + + async def dowload_artifacts( + self, model_name: str, metadata: dict[str, Any], artifact_path: str = 'data_model' + ) -> str: + """ + Download artifacts from the latest production run of a model. + + Args: + model_name (str): Registered model name. + metadata (dict[str, Any]): Metadata used for structured logging. + artifact_path (str): Relative path to artifacts within the run. + + Returns: + str: Local filesystem path where artifacts are saved. + """ + run_id = self.get_model_run_id(model_name=model_name, stage='Production') + output_dir = f'{ARTIFACTS_PATH}/{model_name}' + + full_path = path.join(output_dir, artifact_path) + + if path.exists(full_path): + # Remove the directory and create a new one + rmtree(full_path) + makedirs(output_dir, exist_ok=True) + + self.info(f'Downloading artifacts from {run_id} to {output_dir}') + + core_labels = self.get_core_labels(metadata, operation_type='download_artifacts') + + start_time = time.time() + try: + artifacts = self.client.download_artifacts(run_id, artifact_path, output_dir) + except Exception as e: + await self.emit_metric(metric_object=metrics.MODEL_READ_ERROR_COUNT, tags=core_labels) + raise e + + await self.observe_lag(start_time, metrics.MODEL_READ_LAG, core_labels) + await self.emit_metric(metric_object=metrics.MODEL_READ_COUNT, tags=core_labels) + + return artifacts + + async def load_artifact_dataframe( + self, model_name: str, artifact_path: str, metadata: dict[str, Any] + ) -> pd.DataFrame | None: + """ + Load the dataframe content of an artifact from the MLflow Model Registry. + + Args: + model_name (str): The name of the model to download from the registry. + artifact_path (str): The path to the artifact to load. + metadata (dict[str, Any]): Metadata used for structured logging. + + Returns: + pd.DataFrame: The dataframe content of the artifact. + """ + run_id = self.get_model_run_id(model_name=model_name, stage='Production') + core_labels = self.get_core_labels(metadata, operation_type='load_text') + + if not self.check_artifact_exists(run_id, artifact_path, metadata): + return None + + artifact_path = path.join('runs:/', run_id, artifact_path) + + start_time = time.time() + try: + content = mlflow.artifacts.load_text(artifact_path) + except Exception as e: + await self.emit_metric(metric_object=metrics.MODEL_READ_ERROR_COUNT, tags=core_labels) + raise e + + await self.observe_lag(start_time, metrics.MODEL_READ_LAG, core_labels) + await self.emit_metric(metric_object=metrics.MODEL_READ_COUNT, tags=core_labels) + + self.debug(f'Content of {run_id}/{artifact_path}: \n{content}', metadata) + + dataframe = pd.read_csv(StringIO(content)) + + self.info(f'Loaded dataframe from {model_name}:{artifact_path}', metadata) + return dataframe + + async def load_predict_model( + self, model_name: str, metadata: dict[str, Any], flavor: str = 'sklearn' + ) -> Any: + """ + Load a predictive model from the MLflow Model Registry. + + Args: + model_name (str): The name of the model to download from the registry. + metadata (dict[str, Any]): Metadata used for structured logging. + flavor (str): Model flavor ('pyfunc', 'sklearn', 'pytorch') + artifact_path (str | None): Path to compressed artifacts if model is compressed + + Returns: + mlflow.pyfunc.PyFuncModel: The loaded predictive model. + + Notes: + - The model is fetched from the "Production" stage of the MLflow Model Registry. + - Warnings during the model loading process are suppressed. + """ + model_uri = f'models:/{model_name}/production' + self.info(f'Loading prediction model {model_name} from {model_uri}') + + core_labels = self.get_core_labels(metadata, operation_type='load_predict_model') + start_time = time.time() + try: + if flavor == 'pyfunc': + model = mlflow.pyfunc.load_model(model_uri) + elif flavor == 'sklearn': + model = mlflow.sklearn.load_model(model_uri) + elif flavor == 'pytorch': + model = mlflow.pytorch.load_model(model_uri) + else: + raise ValueError(INVALID_FLAVOR_MESSAGE) + except Exception as e: + await self.emit_metric(metric_object=metrics.MODEL_READ_ERROR_COUNT, tags=core_labels) + raise e + + await self.observe_lag(start_time, metrics.MODEL_READ_LAG, core_labels) + await self.emit_metric(metric_object=metrics.MODEL_READ_COUNT, tags=core_labels) + return model + + async def load_transform_model( + self, model_name: str, metadata: dict[str, Any], flavor: str = 'sklearn' + ) -> Any: + """ + Load the latest Production version of a transformation model. + + This method retrieves the latest production model run ID for the given + model name, constructs the model URI, and loads the model using MLflow. + + Args: + model_name (str): The name of the model to download. + metadata (dict[str, Any]): Metadata used for structured logging. + flavor (str): Model flavor ('sklearn', 'pyfunc', 'pytorch') + artifact_path (str | None): Path to compressed artifacts if model is compressed + + Returns: + Any: The loaded model object, depending on the flavor used. + + Raises: + Exception: If the model run ID or URI cannot be retrieved, or if the + model cannot be loaded. + """ + + latest_production_id = self.get_model_run_id(model_name=model_name, stage='Production') + model_uri = self.get_model_uri(latest_production_id, prediction=False) + + self.info(f'Loading data model {model_name} from {model_uri}') + + core_labels = self.get_core_labels(metadata, operation_type='load_transform_model') + start_time = time.time() + try: + if flavor == 'sklearn': + model = mlflow.sklearn.load_model(model_uri) + elif flavor == 'pyfunc': + model = mlflow.pyfunc.load_model(model_uri) + elif flavor == 'pytorch': + model = mlflow.pytorch.load_model(model_uri) + else: + raise ValueError(INVALID_FLAVOR_MESSAGE) + except Exception as e: + await self.emit_metric(metric_object=metrics.MODEL_READ_ERROR_COUNT, tags=core_labels) + raise e + + await self.observe_lag(start_time, metrics.MODEL_READ_LAG, core_labels) + await self.emit_metric(metric_object=metrics.MODEL_READ_COUNT, tags=core_labels) + + return model + + async def download_model( + self, + model_name: str, + metadata: dict[str, Any], + model_type: str, + flavor: str, + load_wrapper: bool = False, + ) -> tuple[Any, str | None]: + """ + Download model based on type ("predict" or "transform"). + + Args: + model_name (str): Name of the model to download + metadata (dict[str, Any]): Metadata used for structured logging. + model_type (str): Type of model ('predict' or 'transform') + flavor (str): Model flavor ('sklearn', 'pyfunc', 'pytorch') + load_wrapper (bool): Whether to load wrapper + + Returns: + tuple[Any, str | None]: Model object and optional artifact path. + """ + + self.info( + f'Downloading {model_type} model {model_name} with flavor {flavor} and load_wrapper {load_wrapper}' + ) + + if model_type not in ['predict', 'transform']: + raise ValueError("Invalid model_type. Use 'predict' or 'transform'.") + + artifact_path = None + + if load_wrapper: + self.info(f'Loading wrapper for {model_type} model {model_name} with flavor {flavor}') + + target = 'prediction_model' if model_type == 'predict' else 'data_model' + + artifact_path = await self.dowload_artifacts(model_name, metadata, target) + + self.info( + f'Model with type {model_type} and name {model_name} is compressed, loading from {artifact_path}' + ) + + raw_model = mlflow.pyfunc.load_model(artifact_path) + model = raw_model._model_impl.python_model + + self.debug( + f'Model wrapper loaded: {model.__class__.__name__}:{model.__dict__}', metadata + ) + else: + if model_type == 'predict': + model = await self.load_predict_model(model_name, metadata, flavor) + + else: + model = await self.load_transform_model(model_name, metadata, flavor) + + return model, artifact_path + + """ + Functions related to data format + """ + + def detect_and_parse_datetime_index(self, data: pd.DataFrame, metadata: dict) -> pd.DataFrame: + """ + Normalize DataFrame index to the expected timestamp string format. + + The index must be timestamp-like. If the index is: + - string: it must match `DATETIME_FORMAT_WITH_TZ` + - datetime or pandas Timestamp: it will be converted to that format + Any other type raises a ValueError. + + Args: + data (pd.DataFrame): DataFrame with timestamp index. + metadata (dict): Metadata for structured logging. + + Returns: + pd.DataFrame: DataFrame with converted datetime index. + """ + if data.empty: + self.info('Data is empty, skipping datetime index detection and parsing', metadata) + return data + + index = data.index + + # Get type of first element of index + index_type = type(index[0]) + + self.info(f'Index type: {index_type}', metadata) + + message = f'Index must be all timestamp like column. Valid formats are: pandas Timestamp, datetime, string in format {DATETIME_FORMAT_WITH_TZ}.' + + # Check if all in index are of the same type + if not all(isinstance(i, index_type) for i in index): + types = map(str, map(type, index)) + raise ValueError(f'{message}. Elements are {",".join(types)}') + + # Check type and converts to DATETIME_FORMAT_WITH_TZ + if index_type is str: + # Validate format of string and return error if not valid + try: + pd.to_datetime(data.index, format=DATETIME_FORMAT_WITH_TZ) + except ValueError as e: + raise ValueError(f'{message}. Unable to parse given date format: {e}') from e + + elif index_type == datetime or index_type == pd.Timestamp: + index = data.index + if hasattr(index, 'tz') and index.tz is None: + data.index = index.tz_localize('UTC') # type: ignore[attr-defined] + + data.index = data.index.strftime(DATETIME_FORMAT_WITH_TZ) # type: ignore[attr-defined] + else: + raise ValueError(f'{message}. Got {index_type}.') + + return data + + """ + Functions related to cache management of models + """ + + def check_cache_retention(self, cache: dict, retention: int) -> bool: + """ + Check whether cached model data is still valid. + + Args: + cache (dict): Cached model data with a 'timestamp' key. + retention (int): Retention time in minutes. + + Returns: + bool: True if cache is still valid, False if expired. + """ + current_time = datetime.now() + cache_time = cache['timestamp'] + if current_time - cache_time >= timedelta(minutes=retention): + return False + return True + + def handle_valid_model(self, model_name: str, cache: dict) -> dict: + """ + Return the cached model configuration when retention is valid. + + Args: + model_name (str): Name of the model (for logging/consistency). + cache (dict): Cached model data structure. + + Returns: + dict: Model configuration. + """ + self.debug(f'Model {model_name} is still valid, using cached version') + + return cache['target'] + + def handle_outdated_model(self, model_name: str, model_key: str) -> None: + """ + Clean up outdated cached model and its artifacts. + + Args: + model_name (str): Name of the model. + model_key (str): Cache key for the model. + + Returns: + None + """ + self.debug(f'Model {model_name} is outdated, downloading a new one') + + del self.model_cache[model_key]['target'] + del self.model_cache[model_key] + + async def get_model( + self, + model_name: str, + metadata: dict[str, Any], + retention: int, + model_type: str, + flavor: str, + ) -> Any: + """ + Retrieve a model with caching support based on retention policy. + + Args: + model_name (str): Name of the model to retrieve + metadata (dict[str, Any]): Metadata used for structured logging. + retention (int): Cache retention time in minutes (0 = no cache). + model_type (str): Type of model ('predict' or 'transform') + flavor (str): Model flavor ('sklearn', 'pyfunc', 'pytorch') + + Returns: + Any: Model object. + """ + # Retention is 0, download a new model + if retention <= 0: + model, _artifact_path = await self.download_model( + model_name=model_name, + metadata=metadata, + model_type=model_type, + flavor=flavor, + load_wrapper=False, + ) + return model + + model_key = f'{model_name}_{model_type}' + + # Acquire lock to check cache + with self._cache_lock: + if model_key in self.model_cache: + cache = self.model_cache[model_key] + + # Check if config has changed or is outdated + if self.check_cache_retention(cache, retention): + return self.handle_valid_model(model_name=model_name, cache=cache) + else: + # Model is outdated, delete old model files + self.handle_outdated_model(model_name=model_name, model_key=model_key) + else: + self.debug( + f'Model {model_name} is not in {model_type} cache, downloading a new one' + ) + + # Donwload new model (without lock to avoid blocking other threads) + model, _artifact_path = await self.download_model( + model_name=model_name, + metadata=metadata, + model_type=model_type, + flavor=flavor, + load_wrapper=False, + ) + + # Update cache with lock + with self._cache_lock: + cache = {'target': model, 'timestamp': datetime.now()} + self.model_cache[model_key] = cache + + return model + + @overload + async def get_cached_operation( + self, + model_name: str, + data: pd.DataFrame, + operation: Literal['transform'], + retention: int, + flavor: str, + metadata: dict[str, Any], + ) -> pd.DataFrame: ... + + @overload + async def get_cached_operation( + self, + model_name: str, + data: pd.DataFrame, + operation: Literal['predict'], + retention: int, + flavor: str, + metadata: dict[str, Any], + ) -> pd.DataFrame | ndarray: ... + + async def get_cached_operation( + self, + model_name: str, + data: pd.DataFrame, + operation: str, + retention: int, + flavor: str, + metadata: dict[str, Any], + ) -> pd.DataFrame | ndarray: + """ + Execute a cached operation using the requested model. + + Args: + model_name (str): Registered model name. + data (pd.DataFrame): Input data. + retention (int): Cache retention in minutes. + flavor (str): Model flavor ('sklearn', 'pyfunc', 'pytorch'). + metadata (dict[str, Any]): Metadata used for structured logging. + Returns: + pd.DataFrame | ndarray: Operation result. + """ + if operation not in ['transform', 'predict']: + raise ValueError("Invalid operation. Use 'transform' or 'predict'.") + + model = await self.get_model( + model_name=model_name, + metadata=metadata, + retention=retention, + model_type=operation, + flavor=flavor, + ) + + prediction = model.predict(data) + + if retention == 0: + self.info(f'Deleting model {model_name}:{operation} from memory') + del model + + force_memory_release(self.logger) + + return prediction + + """ + Functions related to model retraining + """ + + def get_prediction_data( + self, + prediction_model: Any, + retrain_dataset: pd.DataFrame, + target_name: str, + predict_flavor: str, + ) -> pd.DataFrame: + """ + Get prediction data from prediction model. + """ + input_index = retrain_dataset.index + + if predict_flavor == 'pyfunc': + prediction_data = prediction_model.predict({}, retrain_dataset) + else: + prediction_data = prediction_model.predict(retrain_dataset) + + if isinstance(prediction_data, pd.DataFrame): + prediction_data.columns = pd.Index(['prediction']) + + else: + prediction_data = pd.DataFrame(prediction_data, columns=['prediction']) + + prediction_data.index = input_index + + # Merge prediction data with retrain_dataset on index + prediction_data = pd.merge( # NOSONAR + retrain_dataset, prediction_data, left_index=True, right_index=True, how='left' + ) + + # Rename column "target_name" to "target" + prediction_data.rename(columns={target_name: 'target'}, inplace=True) + + prediction_data['timestamp'] = prediction_data.index + + prediction_data.reset_index(drop=True, inplace=True) + + prediction_data = prediction_data.sort_values(by='timestamp', ascending=True) + + return prediction_data + + async def fit_models( + self, + model_name: str, + data: pd.DataFrame, + latest_production_id: str, + metadata: dict, + transform_flavor: str = 'sklearn', + skip_transform: bool = False, + predict_flavor: str = 'sklearn', + target_name: str | None = None, + ) -> dict[str, Any]: + """ + Prepare models and data for a retraining run. + + This method sets up the complete environment for model retraining by: + 1. Loading the current production prediction model + 2. Loading the current production transformation model + 3. Fitting the transformation model with new data + 4. Preparing data for prediction model retraining + 5. Setting up the MLFlow experiment context + + Args: + model_name (str): Name of the MLflow model to retrain. + data (pd.DataFrame): Training data for model retraining. + transform_flavor (str): Flavor for transformation model. + predict_flavor (str): Flavor for prediction model. + target_name (str | None): Optional target column; if None, use model target. + metadata (dict): Metadata for logging. + + Returns: + dict[str, dict[str, Any]]: Mapping with prepared `prediction_model` and + `data_model`, including optional artifact paths. + """ + + self.info(f'Starting model experiment creation for {model_name}', metadata) + self.debug( + f'Model configuration - transform_flavor: {transform_flavor}, predict_flavor: {predict_flavor}, target_name: {target_name}', + metadata, + ) + + # data.to_csv( + # f"tmp/retrain_data_{model_name}.csv", index=True) + + self.info(f'Retrieved latest production run ID: {latest_production_id}', metadata) + self.info(f'Loading transformation model for {model_name}', metadata) + + load_transform_wrapper = transform_flavor == 'pyfunc' + + data_model, data_artifact_path = await self.download_model( + model_name=model_name, + metadata=metadata, + model_type='transform', + flavor=transform_flavor, + load_wrapper=load_transform_wrapper, + ) + + self.info(f'Loading prediction model for {model_name}', metadata) + + load_predict_wrapper = predict_flavor == 'pyfunc' + + prediction_model, prediction_artifact_path = await self.download_model( + model_name=model_name, + metadata=metadata, + model_type='predict', + flavor=predict_flavor, + load_wrapper=load_predict_wrapper, + ) + + if not skip_transform: + treated_data_candidate = data_model.fit(data) + else: + treated_data_candidate = data_model + + if not isinstance(treated_data_candidate, pd.DataFrame): + data_model = treated_data_candidate + treated_data = data_model.predict(data) + else: + treated_data = treated_data_candidate + + # Stores current index as timestamp, Courier model expects a timestamp column + # with specific format + treated_data['timestamp'] = treated_data.index + + # Parses timestamp column to datetime format to align with data + treated_data = self.detect_and_parse_datetime_index(treated_data, metadata) + + treated_data = treated_data.drop_duplicates(subset=['timestamp'], keep='first') + + self.debug(f'Treated data index: {treated_data.index}', metadata) + + # treated_data.to_csv( + # f"tmp/retrain_treated_data_{model_name}.csv", index=True) + + self.debug(f'Transformed data shape: {treated_data.shape}', metadata) + + if target_name is None: + target_name = data_model.target_variable + self.debug(f'Using target variable from data model: {target_name}', metadata) + else: + self.debug(f'Using provided target variable: {target_name}', metadata) + + # Check if treated_data contains target variable + if target_name not in treated_data.columns: + self.debug( + f'Target variable {target_name} not found in treated data, aligning data with treated data indexes', + metadata, + ) + # Aligns data with treated data indexes to get target variable + aligned_data = data.loc[treated_data.index] + aligned_series = aligned_data[target_name] + retrain_dataset = pd.merge( # NOSONAR + treated_data, aligned_series, left_index=True, right_index=True + ) + else: + # Uses target variable from treated data + self.debug(f'Target variable {target_name} found in treated data, using it', metadata) + retrain_dataset = treated_data + + # retrain_dataset.to_csv( + # f"tmp/retrain_retrain_dataset_{model_name}.csv", index=True) + + prediction_model.fit(retrain_dataset) + + # get prediction data + prediction_data = self.get_prediction_data( + prediction_model, retrain_dataset, target_name, predict_flavor + ) + + self.info(f'Model experiment creation completed successfully for {model_name}', metadata) + + retrain_data = { + 'prediction_model': { + 'model': prediction_model, + 'artifact_path': prediction_artifact_path, + }, + 'data_model': {'model': data_model, 'artifact_path': data_artifact_path}, + 'prediction_data': prediction_data, + } + return retrain_data + + async def log_model(self, model_data: dict, flavor: str, model_type: str, metadata: dict): + """Log a model into the active MLflow run. + + Args: + model_data (dict): Model holder with keys 'model' and optional 'artifact_path'. + flavor (str): Model flavor ('sklearn', 'pyfunc', 'pytorch'). + model_type (str): Artifact name, e.g., 'prediction_model' or 'data_model'. + metadata (dict): Metadata for structured logging. + """ + model = model_data['model'] + + self.debug(f'Logging {model_type} model to {model_type}', metadata) + + core_labels = self.get_core_labels(metadata, operation_type='log_model') + start_time = time.time() + + try: + if flavor == 'sklearn': + mlflow.sklearn.log_model(model, model_type) + elif flavor == 'pyfunc': + code_path = [path.join(model_data['artifact_path'], 'code', 'utils')] + + self.debug(f'Code path: {code_path}', metadata) + + model.store_model(artifact_path=model_type, code_path=code_path, to_disk=False) + + self.debug('Model uploaded successfully', metadata) + elif flavor == 'pytorch': + mlflow.pytorch.log_model(model, model_type) + else: + raise ValueError(INVALID_FLAVOR_MESSAGE) + + except Exception as e: + await self.emit_metric(metric_object=metrics.MODEL_WRITE_ERROR_COUNT, tags=core_labels) + raise e + + await self.observe_lag(start_time, metrics.MODEL_WRITE_LAG, core_labels) + await self.emit_metric(metric_object=metrics.MODEL_WRITE_COUNT, tags=core_labels) + + async def create_new_experiment( + self, + model_name: str, + data: pd.DataFrame, + retrain_data: dict, + latest_production_id: str, + metadata: dict, + transform_flavor: str = 'sklearn', + predict_flavor: str = 'sklearn', + ) -> dict: + """ + Execute the complete model retraining process in MLflow. + + This method performs the actual model retraining by: + 1. Starting a new MLFlow run with descriptive metadata + 2. Logging model parameters and hyperparameters + 3. Retraining both prediction and transformation models + 4. Logging training data as artifacts + 5. Saving retrained models to MLFlow registry + + Args: + prediction_model: MLFlow prediction model to retrain + data_model: MLFlow transformation model to retrain + experiment (str): MLflow experiment name for the retraining. + model_name (str): Name of the model being retrained. + data (pd.DataFrame): Training data used for retraining. + transform_flavor (str): Flavor for transformation model. + predict_flavor (str): Flavor for prediction model. + metadata (dict): Metadata for logging. + + Returns: + dict: Metadata about the created run and experiment. + """ + + prediction_model = retrain_data['prediction_model'] + data_model = retrain_data['data_model'] + prediction_data = retrain_data['prediction_data'] + + model_temp_path = path.join(ARTIFACTS_PATH, model_name) + + self.info(f'Starting model retraining process for {model_name}', metadata) + + original_params = self.get_model_params(latest_production_id) + retrain_params = { + **original_params, + 'retrain': True, + 'retrain_date': datetime.now().isoformat(), + 'source_run_id': latest_production_id, + 'retrain_samples': str(data.shape), + } + experiment_description = f'Retrain model {model_name} with new data' + + experiment = self.get_experiment(model_name, create_if_not_exists=True) + experiment_name = experiment.name + + current_run_name = self.get_next_run_name(experiment_name) + + self.debug(f'Attributes: {retrain_params}', metadata) + + data_path = f'{model_temp_path}/retrain_data.csv' + prediction_data_path = f'{model_temp_path}/evaluation_data.csv' + + makedirs(model_temp_path, exist_ok=True) + + data.to_csv(data_path, index=False) + prediction_data.to_csv(prediction_data_path, index=False) + + self.info( + f'Starting model upload for {experiment_name} with run name {current_run_name}', + metadata, + ) + + core_labels = self.get_core_labels(metadata, operation_type='create_new_experiment') + start_time = time.time() + try: + with mlflow.start_run( + experiment_id=experiment.experiment_id, + run_name=current_run_name, + description=experiment_description, + ) as _run: + run_id = _run.info.run_id + self.info('Logging data model', metadata) + # dynamic parameters, including model itself + await self.log_model(data_model, transform_flavor, 'data_model', metadata) + + # dynamic parameters, including model itself + self.info('Logging prediction model', metadata) + await self.log_model(prediction_model, predict_flavor, 'prediction_model', metadata) + + self.info(f'Model logged successfully for {model_name}', metadata) + + self.info(f'Logging remaining parameters for {model_name}', metadata) + + # update transfomation model + # fixed parameters + mlflow.log_params(retrain_params) + + # log the data raw + mlflow.log_artifact(data_path) + mlflow.log_artifact(prediction_data_path) + + except Exception as e: + await self.emit_metric(metric_object=metrics.MODEL_WRITE_ERROR_COUNT, tags=core_labels) + raise e + + await self.observe_lag(start_time, metrics.MODEL_WRITE_LAG, core_labels) + await self.emit_metric(metric_object=metrics.MODEL_WRITE_COUNT, tags=core_labels) + + self.info('Deleting model from filesystem', metadata) + if path.exists(model_temp_path): + rmtree(model_temp_path) + + self.info('Deleting prediction model from memory', metadata) + del prediction_model['model'] + del prediction_model + + self.info('Deleting data model from memory', metadata) + del data_model['model'] + del data_model + + force_memory_release(self.logger) + + return { + 'run_id': run_id, + 'experiment_id': experiment.experiment_id, + 'experiment_name': experiment.name, + } + + async def update_production_model_by_run_id( + self, run_id: str, model_name: str, metadata: dict + ) -> dict: + """ + Promote a specific run's model to Production. + + This method promotes a model from a specific MLFlow run to + production stage. It handles model registration, versioning, + and stage transitions with proper error handling. + + Args: + run_id (str): MLflow run ID containing the model to promote. + model_name (str): Name of the MLflow model. + metadata (dict): Metadata for logging. + + Returns: + dict: Model update metadata containing: + - model_name (str): Name of the updated model + - version (str): New model version number + - mlflow_run_id (str): Source run ID + + Update Process: + 1. Registers the model from the specified run + 2. Retrieves the latest model version + 3. Transitions the model to 'Production' stage + 4. Archives existing production versions + """ + + self.info( + f'Starting production model update for {model_name} with run ID: {run_id}', metadata + ) + + # Registrar o modelo + # Aqui estamos assumindo que vocΓͺ jΓ‘ tem um modelo salvo, caso contrΓ‘rio vocΓͺ precisarΓ‘ treinΓ‘-lo e salvΓ‘-lo primeiro. + # Se o modelo jΓ‘ estΓ‘ registrado, vocΓͺ pode usar o mΓ©todo register_model() ou pyfunc.load_model() para isso. + + core_labels = self.get_core_labels(metadata, operation_type='register_model') + start_time = time.time() + try: + mlflow.register_model(f'runs:/{run_id}/prediction_model', model_name) + except Exception as e: + await self.emit_metric(metric_object=metrics.MODEL_WRITE_ERROR_COUNT, tags=core_labels) + raise e + + await self.observe_lag(start_time, metrics.MODEL_WRITE_LAG, core_labels) + await self.emit_metric(metric_object=metrics.MODEL_WRITE_COUNT, tags=core_labels) + + # Obter a versΓ£o mais recente registrada do modelo + model_versions = self.client.get_registered_model(model_name).latest_versions + + if not isinstance(model_versions, list): + raise ValueError('Model versions is not a list') + + max_version = max(model_versions, key=lambda x: int(x.version)).version + + # Mover a versΓ£o mais recente do modelo para o estΓ‘gio de 'Production' + core_labels = self.get_core_labels( + metadata, operation_type='transition_model_version_stage' + ) + start_time = time.time() + try: + self.client.transition_model_version_stage( + name=model_name, + version=max_version, + stage='Production', + archive_existing_versions=True, + ) + except Exception as e: + await self.emit_metric(metric_object=metrics.MODEL_WRITE_ERROR_COUNT, tags=core_labels) + raise e + + await self.observe_lag(start_time, metrics.MODEL_WRITE_LAG, core_labels) + await self.emit_metric(metric_object=metrics.MODEL_WRITE_COUNT, tags=core_labels) + + return {'model_name': model_name, 'version': max_version, 'mlflow_run_id': run_id} + + """ + Functions that provide the interface to model operations + """ + + async def transform( + self, model_name: str, data: pd.DataFrame, model_config: dict, metadata: dict + ) -> dict[str, Any]: + """ + Transform data using a cached transformation model. + + This method provides a high-level interface for data transformation operations + using MLFlow models. It handles model caching, error management, and data + format conversion automatically. + + Process Flow: + 1. Retrieves or downloads the transformation model using caching mechanism + 2. Applies the transformation model to the input data + 3. Converts the transformed data to dictionary format for API response + 4. Handles any exceptions and returns structured error information + 5. Manages model lifecycle based on retention policy (cleanup artifacts if needed) + + Parameters: + model_name (str): The name of the MLflow model to use for transformation. + data (pd.DataFrame): The input data to be transformed by the model. + model_config (dict): Model configuration parameters + metadata (dict): Metadata for logging + + Returns: + dict: Response dictionary containing: + - success (bool): Operation success status + - content (dict): Transformed data as dictionary, or error information + if operation failed. Error content includes: + - message (str): Error description + - traceback (str): Full exception traceback + + Raises: + Exception: Any exception during model loading or transformation is caught + and returned in the response structure rather than propagated. + """ + + self._debug_dataframe('Data received for model transformation:', data, metadata) + + # data.to_csv( + # f"tmp/data_{model_name}.csv", index=True) + + model_retention = model_config.get('retention_minutes', 0) + flavor = model_config.get('transform_flavor', 'sklearn') + + try: + transformed_data: pd.DataFrame = await self.get_cached_operation( + model_name=model_name, + data=data, + operation='transform', + retention=model_retention, + flavor=flavor, + metadata=metadata, + ) + + self._debug_dataframe( + 'Data received from model transformation:', transformed_data, metadata + ) + + # transformed_data.to_csv( + # f"tmp/transformed_data_{model_name}.csv", index=True) + + transformed_data = self.detect_and_parse_datetime_index(transformed_data, metadata) + + return {'success': True, 'content': transformed_data} + + except Exception as e: + return { + 'success': False, + 'content': {'message': str(e), 'traceback': traceback.format_exc()}, + } + + async def predict( + self, model_name: str, data: pd.DataFrame, model_config: dict, metadata: dict + ): + """ + Generate predictions using a cached prediction model. + + This method provides a high-level interface for model prediction operations + using MLFlow models. It handles model caching, performance monitoring, + response formatting, and error management automatically. + + Process Flow: + 1. Preserves input data index for result alignment + 2. Records prediction start time for performance measurement + 3. Retrieves or downloads the prediction model using caching mechanism + 4. Executes model prediction on the input data + 5. Formats predictions into DataFrame with proper column naming + 6. Restores original data index to maintain data alignment + 7. Calculates and adds response time measurement + 8. Converts results to dictionary format for API response + 9. Handles any exceptions and returns structured error information + + Parameters: + model_name (str): The name of the MLflow model to use for prediction. + data (pd.DataFrame): The input data to make predictions on. + model_retention (int): Cache retention time in minutes (0 = no caching). + model_config (dict): Model configuration parameters + metadata (dict): Metadata for logging + + Returns: + dict: Response dictionary containing: + - success (bool): Operation success status + - content (dict): Prediction results as dictionary with: + - prediction: Model predictions array + - response_time: Prediction execution time in seconds + Or error information if operation failed: + - message (str): Error description + - traceback (str): Full exception traceback + + Raises: + Exception: Any exception during model loading or prediction is caught + and returned in the response structure rather than propagated. + """ + + model_retention = model_config.get('retention_minutes', 0) + flavor = model_config.get('predict_flavor', 'sklearn') + + try: + input_index = data.index + start_time = datetime.now() + + self._debug_dataframe('Data received for model prediction:', data, metadata) + + # data.to_csv( + # f"tmp/treated_data_{model_name}.csv", index=True) + + predict_data = await self.get_cached_operation( + model_name=model_name, + data=data, + operation='predict', + retention=model_retention, + flavor=flavor, + metadata=metadata, + ) + + end_time = datetime.now() + + if isinstance(predict_data, pd.DataFrame): + self._debug_dataframe( + 'Data received from model prediction:', predict_data, metadata + ) + + # predict_data.to_csv( + # f"tmp/predicted_data_{model_name}.csv", index=True) + predict_data.columns = pd.Index(['prediction']) + + else: + self.debug( + f'Data received from model prediction (not a DataFrame): {predict_data}', + metadata, + ) + predict_data = pd.DataFrame(predict_data, columns=['prediction']) + # predict_data.to_csv( + # f"tmp/predicted_data_{model_name}.csv", index=True) + + predict_data.index = input_index + predict_data['response_time'] = (end_time - start_time).total_seconds() + + return {'success': True, 'content': predict_data} + + except Exception as e: + return { + 'success': False, + 'content': {'message': str(e), 'traceback': traceback.format_exc()}, + } + + async def retrain_model( + self, data: pd.DataFrame, model_name: str, model_config: dict, metadata: dict + ) -> dict[str, Any]: + """ + Orchestrate the complete model retraining workflow. + + This method coordinates the entire model retraining process by managing + the MLFlow experiment lifecycle, model loading, retraining execution, + and artifact management. It provides a comprehensive retraining solution + that maintains model versioning and experiment tracking. + + Process Flow: + 1. Creates MLFlow experiment environment: + - Loads current production prediction model + - Loads current production transformation model + - Fits transformation model with new training data + - Prepares transformed data for prediction model retraining + - Sets up MLFlow experiment context + 2. Executes model retraining: + - Starts new MLFlow run with descriptive metadata + - Logs model parameters and hyperparameters + - Retrains both prediction and transformation models + - Logs training data as artifacts + - Saves retrained models to MLFlow registry + - Cleans up temporary files + 3. Returns comprehensive retraining results + + Args: + data (pd.DataFrame): Training data for model retraining. Must contain + all features required by both transformation and + prediction models, including target variable. + model_name (str): Name of the MLflow model to retrain. Must exist + in the MLflow Model Registry in Production stage. + model_config (dict): Model configuration parameters + metadata (dict): Metadata for logging + + Returns: + dict: Retraining operation results and experiment details. + + Raises: + mlflow.exceptions.MlflowException: If model not found in registry + ValueError: If experiment cannot be created or models cannot be loaded + Exception: Any other exception during the retraining process + """ + + self.info(f'Starting model retraining workflow for {model_name}', metadata) + self._debug_dataframe('Data received for model retraining:', data, metadata) + + target_name = model_config.get('target', None) + + transform_flavor = model_config.get('transform_flavor', 'sklearn') + predict_flavor = model_config.get('predict_flavor', 'sklearn') + skip_transform = model_config.get('skip_transform', False) + + self.debug( + f'Model configuration - transform_flavor: {transform_flavor}, predict_flavor: {predict_flavor}, target_name: {target_name}', + metadata, + ) + + try: + latest_production_id = self.get_model_run_id(model_name, stage='Production') + self.info('Creating model experiment environment', metadata) + retrain_data = await self.fit_models( + model_name=model_name, + data=data, + transform_flavor=transform_flavor, + skip_transform=skip_transform, + predict_flavor=predict_flavor, + target_name=target_name, + metadata=metadata, + latest_production_id=latest_production_id, + ) + self.info(f'Model experiment created successfully: {retrain_data}', metadata) + + self.info('Saving model retrain', metadata) + experiment = await self.create_new_experiment( + model_name=model_name, + data=data, + retrain_data=retrain_data, + transform_flavor=transform_flavor, + predict_flavor=predict_flavor, + metadata=metadata, + latest_production_id=latest_production_id, + ) + self.info( + f'Model retraining completed successfully for experiment: {experiment}', metadata + ) + + return { + 'success': True, + 'experiment': experiment, + 'message': 'Model retrained successfully.', + } + except Exception as e: + error_msg = f'Error retraining model {model_name}: {e}' + self.info(error_msg, metadata) + return { + 'success': False, + 'experiment': None, + 'message': error_msg, + 'traceback': traceback.format_exc(), + } + + async def update_production_model( + self, experiment: dict[str, Any], model_name: str, metadata: dict + ) -> dict: + """ + Update production model using the latest retraining run. + + This method orchestrates the complete production model update process by + identifying the most recent retraining run and promoting it to production + stage. It handles model registration, versioning, and stage transitions + with comprehensive metadata tracking. + + Process Flow: + 1. Retrieves experiment information: + - Converts experiment name to MLFlow experiment ID + - Searches for the most recent retraining run in the experiment + - Filters runs by 'retrain' parameter and orders by completion time + 2. Promotes model to production: + - Registers the model from the specified run to MLFlow Model Registry + - Retrieves the latest model version number + - Transitions the model to 'Production' stage + - Archives existing production versions automatically + 3. Returns comprehensive update metadata + + Args: + experiment (str): MLflow experiment name containing the retraining runs. + Must be a valid experiment that exists in MLFlow. + model_name (str): Name of the MLFlow model to update. Must exist + in the MLFlow Model Registry. + metadata (dict): Metadata for logging + + Returns: + dict: Complete model update metadata containing: + - model_name (str): Name of the updated model + - version (str): New model version number (incremented automatically) + - mlflow_run_id (str): Source run ID of the promoted model + - mlflow_experiment_id (int): Experiment ID for tracking + + Raises: + ValueError: If experiment not found or model versions are invalid + mlflow.exceptions.MlflowException: If model registration or stage + transition fails + Exception: Any other exception during the update process + + Note: + This operation is irreversible. The previous production model will + be automatically archived when the new version is promoted. + """ + run_id = experiment['run_id'] + experiment_id = experiment['experiment_id'] + metadata_result = await self.update_production_model_by_run_id(run_id, model_name, metadata) + + metadata_result['mlflow_experiment_id'] = experiment_id + + return metadata_result diff --git a/laborious/utils/repository/opc_repository.py b/laborious/utils/repository/opc_repository.py new file mode 100644 index 0000000..2ecc27a --- /dev/null +++ b/laborious/utils/repository/opc_repository.py @@ -0,0 +1,866 @@ +import asyncio +import json +import time +import traceback +from datetime import datetime +from pathlib import Path +from typing import Any + +from asyncua import Client +from asyncua.crypto.security_policies import SecurityPolicyBasic256 +from asyncua.ua import DataValue, Variant, VariantType +from asyncua.ua.uaerrors import UaStatusCodeError +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.metrics_controller import MetricsController +from sientia_do.observability.sientia_monitoring import SientiaMonitoring + +from laborious import metrics + +# Requested session and secure channel lifetime (ms) before server revision; 10 minutes. +OPC_UA_SESSION_AND_CHANNEL_TIMEOUT_MS = 10 * 60 * 1000 + + +class OpcClientAlreadyExistsError(RuntimeError): + """Raised when _create_client is called while self.client is already set.""" + + +class OpcSessionAlreadyConnectedError(RuntimeError): + """Raised when _open_session is called while a UA session is already open.""" + + +class OpcClientNotInitializedError(RuntimeError): + """Raised when _open_session is called before _create_client.""" + + +RECONNECTABLE_OPC_BAD_NAMES: frozenset[str] = frozenset( + { + 'BadSessionIdInvalid', + 'BadSessionClosed', + 'BadSessionNotActivated', + 'BadSecureChannelIdInvalid', + 'BadSecureChannelClosed', + 'BadSecureChannelTokenUnknown', + 'BadTcpSecureChannelUnknown', + 'BadServerNotConnected', + 'BadConnectionClosed', + 'BadDisconnect', + 'BadConnectionRejected', + 'BadCommunicationError', + 'BadRequestInterrupted', + 'BadUnknownResponse', + 'BadTimeout', + 'BadRequestTimeout', + 'BadSequenceNumberInvalid', + 'BadSequenceNumberUnknown', + 'BadSecurityModeInsufficient', + 'BadRequestHeaderInvalid', + 'BadInvalidState', + } +) + + +def _opc_authentication_token_str(client: Client | None) -> str: + """ + Serialize the current OPC UA authentication token (session handle) for logging and metrics. + + Return: + str: Token string, or "unknown" if unavailable. + """ + if client is None: + return 'unknown' + try: + proto = client.uaclient.protocol + if proto is None: + return 'unknown' + tok = getattr(proto, 'authentication_token', None) + if tok is None: + return 'unknown' + return str(tok) + except Exception: + return 'unknown' + + +def _opc_status_from_exception(exc: BaseException) -> str: + """ + Resolve OPC UA status name from an exception, including chained UaStatusCodeError causes. + + Args: + exc (BaseException): Raised error from asyncua. + + Return: + str: Status class name or generic Python exception name. + """ + current: BaseException | None = exc + while current is not None: + if isinstance(current, UaStatusCodeError): + return type(current).__name__ + current = current.__cause__ + return type(exc).__name__ + + +def is_reconnectable_opcua_bad(exc: BaseException) -> bool: + """ + Return whether the exception is a Tier-1 OPC UA Bad* that should trigger reconnect. + + Args: + exc (BaseException): Raised error from get_node or write_value. + + Return: + bool: True if reconnect should be scheduled. + """ + return _opc_status_from_exception(exc) in RECONNECTABLE_OPC_BAD_NAMES + + +def _model_labels_from_write_metadata(metadata: dict[str, Any] | None) -> dict[str, str]: + """ + Extract model_id and model_name from write metadata for Prometheus labels. + + Args: + metadata (dict[str, Any] | None): Context passed into write_data; may omit keys. + + Return: + dict[str, str]: Labels model_id and model_name, defaulting to "unknown". + """ + if not metadata: + return {'model_id': 'unknown', 'model_name': 'unknown'} + return { + 'model_id': str(metadata.get('model_id', 'unknown')), + 'model_name': str(metadata.get('model_name', 'unknown')), + } + + +data_type_map = { + 'float': { + 'converter': float, + 'opc_type': VariantType.Float, + }, + 'double': { + 'converter': float, + 'opc_type': VariantType.Double, + }, + 'int': { + 'converter': int, + 'opc_type': VariantType.Int32, + }, + 'bool': { + 'converter': bool, + 'opc_type': VariantType.Boolean, + }, + 'str': { + 'converter': str, + 'opc_type': VariantType.String, + }, +} + + +class OpcRepository(SientiaMonitoring): + def __init__( + self, + opc_id: str, + url: str, + server_name: str, + logger: Logger, + notification_handler: NotificationHandler, + metrics_controller: MetricsController, + reconnection_interval: int = 60, + server_uri: str | None = None, + cert_path: str | None = None, + private_key_path: str | None = None, + server_cert_path: str | None = None, + ): + self.url = url + self.id = opc_id + self.server_name = server_name + self.server_uri = server_uri + self.cert_path = cert_path + self.private_key_path = private_key_path + self.server_cert_path = server_cert_path + self.reconnection_interval = reconnection_interval + self.last_reconnection_time: None | datetime = None + self.disconnection_interval = 10.0 + self.notification_handler = notification_handler + self.client: None | Client = None + + SientiaMonitoring.__init__(self, logger, notification_handler, metrics_controller) + + self.metadata = { + 'model_name': '-', + 'model_id': '-', + 'workflow_name': 'opc_repository', + 'schedule_name': '-', + } + self._last_write_mono: float | None = None + self._connection_lock = asyncio.Lock() + self._session_ready = asyncio.Event() + self._reconnect_task: asyncio.Task[None] | None = None + self._allow_reconnect = True + + def _opc_debug_tags(self, session_id: str) -> dict[str, str]: + """ + Build Prometheus/log label tags for OPC session-scoped metrics. + + Args: + session_id (str): OPC UA session token string. + + Return: + dict[str, str]: Labels pod_id, server_name, runtime, opc_server_id, session_id. + """ + return { + 'pod_id': str(getattr(self, 'pod_id', 'unknown')), + 'server_name': self.server_name, + 'runtime': str(getattr(self, 'runtime', 'unknown')), + 'opc_server_id': self.id, + 'session_id': session_id, + } + + def _is_session_open(self) -> bool: + """ + Return whether the asyncua client has an open transport session. + + Return: + bool: True when protocol exists and is not closed. + """ + if self.client is None: + return False + try: + proto = self.client.uaclient.protocol + return proto is not None and proto.state != 'closed' + except Exception: + return False + + def _reconnection_window_elapsed(self) -> bool: + """ + Return whether enough time has passed since the last reconnect attempt. + + Return: + bool: True if a new reconnect is allowed. + """ + if self.last_reconnection_time is None: + return True + return ( + datetime.now() - self.last_reconnection_time + ).total_seconds() > self.reconnection_interval + + def _not_connected_error(self) -> dict[str, Any]: + """ + Build the standard error payload when validate_connection finds no open protocol. + + Return: + dict[str, Any]: Notification fields for OPC_CONNECTION_NOT_READY. + """ + return { + 'notification_id': f'OPC_CONNECTION_NOT_READY_{self.id}', + 'message': f'OPC server {self.id} is not connected', + 'block': 'opc_repository', + 'level': NotificationLevel.WARNING, + } + + async def set_security(self) -> None: + """ + Configure certificates and timeouts on the asyncua client. + + Raises: + ValueError: If cert paths or client are missing. + """ + if self.cert_path is None or self.private_key_path is None: + raise ValueError( + 'Certificate and private key paths must be provided for secure connection.' + ) + + cert = Path(self.cert_path) + private_key = Path(self.private_key_path) + server_cert = Path(self.server_cert_path) if self.server_cert_path else None + + if self.client is None: + raise ValueError('Client must be initialized before setting security') + + self.client.application_uri = self.server_uri + self.info('Setting security...', self.metadata) + await self.client.set_security( + SecurityPolicyBasic256, + certificate=str(cert), + private_key=str(private_key), + server_certificate=str(server_cert) if server_cert else None, + ) + self.client.secure_channel_timeout = OPC_UA_SESSION_AND_CHANNEL_TIMEOUT_MS + self.client.session_timeout = OPC_UA_SESSION_AND_CHANNEL_TIMEOUT_MS + + async def _create_client(self) -> None: + """ + Instantiate the asyncua Client and apply security when configured. + + Caller must hold _connection_lock. Does not open a UA session. + + Raises: + OpcClientAlreadyExistsError: If self.client is already set. + """ + if self.client is not None: + raise OpcClientAlreadyExistsError( + f'OPC client already exists for server {self.id}; ' + 'call disconnect() before creating a new client' + ) + + self.client = Client(self.url, timeout=10, watchdog_intervall=50) # type: ignore[attr-defined] + self.client.name = self.pod_id + self.client.application_name = self.pod_id + pod_uri = self.pod_id.replace('-', ':') + self.client.application_uri = pod_uri + self.client.product_uri = pod_uri + if self.cert_path: + await self.set_security() + + async def _open_session(self) -> tuple[bool, dict[str, Any]]: + """ + Open the OPC UA session on the existing client. + + Caller must hold _connection_lock. + + Raises: + OpcClientNotInitializedError: If self.client is None. + OpcSessionAlreadyConnectedError: If a session is already open. + + Return: + tuple[bool, dict[str, Any]]: Success flag and error payload on connect failure. + """ + if self.client is None: + raise OpcClientNotInitializedError( + f'OPC client is not initialized for server {self.id}; ' + 'call _create_client() before opening a session' + ) + if self._is_session_open(): + raise OpcSessionAlreadyConnectedError( + f'OPC session already connected for server {self.id}; ' + 'call disconnect() before connecting again' + ) + + tags = { + 'pod_id': self.pod_id, + 'server_name': self.server_name, + } + await self.emit_metric(metrics.OPC_CONNECTIONS_TOTAL, tags) + + try: + await self.client.connect() + + session_id = _opc_authentication_token_str(self.client) + revised_session_timeout_ms = int(self.client.session_timeout) + revised_secure_channel_timeout_ms = int(self.client.secure_channel_timeout) + self.info( + f'OPC new session connected opc_server_id={self.id} session_id={session_id} ' + f'revised_session_timeout_ms={revised_session_timeout_ms} ' + f'revised_secure_channel_timeout_ms={revised_secure_channel_timeout_ms}', + self.metadata, + ) + await self.emit_metric( + metrics.OPC_SESSION_CREATED_TOTAL, self._opc_debug_tags(session_id) + ) + await self.emit_metric( + metric_object=metrics.OPC_SESSION_REVISED_TIMEOUT_MS, + method='set', + tags=self._opc_debug_tags(session_id), + value=revised_session_timeout_ms, + ) + await self.emit_metric( + metric_object=metrics.OPC_CONNECTION_STATUS, + method='set', + tags={**tags, 'server_url': self.url}, + value=1, + ) + + self._last_write_mono = None + self._session_ready.set() + return True, {} + + except Exception as e: + await self._disconnect_locked() + trace = traceback.format_exc() + self.error(trace, self.metadata) + await self.emit_metric(metrics.OPC_CONNECTIONS_FAILED, tags) + return False, { + 'notification_id': f'OPC_CONNECTION_ERROR_{self.id}', + 'message': f'Failed to connect to OPC server: {e}', + 'block': 'opc_repository', + 'level': NotificationLevel.ERROR, + 'attachment_content': trace, + } + + async def _connect_locked(self) -> tuple[bool, dict[str, Any]]: + """ + Create the client when absent, then open a UA session. + + Caller must hold _connection_lock. + + Raises: + OpcSessionAlreadyConnectedError: If a session is already open. + + Return: + tuple[bool, dict[str, Any]]: Result from _open_session on connect failure. + """ + if self._is_session_open(): + raise OpcSessionAlreadyConnectedError( + f'OPC session already connected for server {self.id}; ' + 'call disconnect() before connecting again' + ) + if self.client is None: + await self._create_client() + return await self._open_session() + + async def _disconnection_fallback(self) -> list[dict[str, Any]]: + """ + Try up to five times to disconnect from the OPC UA server. + """ + assert self.client is not None + error_stack: list[dict[str, Any]] = [] + for i in range(5): + try: + self.info( + f'Disconnecting from OPC UA server, attempt {i + 1} of 5', + self.metadata, + ) + await self.client.disconnect() + return [] + except Exception as e: + self.error( + f'Failed to disconnect from OPC UA server in attempt {i + 1} of 5: {e}', + self.metadata, + ) + error_stack.append( + { + 'attempt': i + 1, + 'error': str(e), + 'traceback': traceback.format_exc(), + } + ) + await asyncio.sleep(self.disconnection_interval * i) + return error_stack + + async def _disconnect_locked(self) -> None: + """ + Tear down the current session and client. + + Caller must hold _connection_lock. + """ + self._last_write_mono = None + self._session_ready.clear() + + if self.client is None: + return + + session_id = _opc_authentication_token_str(self.client) + self.info( + f'OPC disconnecting opc_server_id={self.id} session_id={session_id}', + self.metadata, + ) + await self.emit_metric(metrics.OPC_SESSION_CLOSED_TOTAL, self._opc_debug_tags(session_id)) + + errors = await self._disconnection_fallback() + if errors: + await self.send_notification_async( + metadata=self.metadata, + notification_id=f'OPC_DISCONNECTION_ERROR_{self.id}', + message='Failed to disconnect from OPC server in 5 attempts.', + block='opc_repository', + level=NotificationLevel.ERROR, + attachment_content=json.dumps(errors, indent=4), + ) + else: + self.warning(f'Disconnected from OPC server {self.id} successfully', self.metadata) + + await self.emit_metric( + metric_object=metrics.OPC_CONNECTION_STATUS, + method='set', + tags={ + 'pod_id': self.pod_id, + 'server_name': self.server_name, + 'server_url': self.url, + }, + value=0, + ) + self.client = None + + async def _reconnect_locked(self) -> tuple[bool, dict[str, Any]]: + """ + Close the current session and open a new one. + + Caller must hold _connection_lock. Records last_reconnection_time for interval gating. + + Return: + tuple[bool, dict[str, Any]]: Result from _connect_locked after teardown. + """ + self.last_reconnection_time = datetime.now() + await self._disconnect_locked() + return await self._connect_locked() + + async def connect(self) -> tuple[bool, dict[str, Any]]: + """ + Open an OPC UA session under the connection lock (worker initialization). + """ + async with self._connection_lock: + self.info( + f'Starting connection to OPC server {self.id}:{self.server_name}...', + self.metadata, + ) + return await self._connect_locked() + + async def disconnect(self) -> None: + """ + Gracefully disconnect from the OPC server under the connection lock. + + Disables background reconnect so late writes during worker shutdown do not + respawn sessions. + """ + async with self._connection_lock: + self._allow_reconnect = False + await self._disconnect_locked() + + async def validate_connection(self) -> tuple[bool, dict[str, Any]]: + """ + Read-only check that the asyncua protocol is open. + + Caller must ensure _session_ready before writing. Does not connect or reconnect. + + Return: + tuple[bool, dict[str, Any]]: (True, {}) when open, otherwise (False, error). + """ + if self._is_session_open(): + return True, {} + self.error(f'OPC server {self.id} is not connected', self.metadata) + return False, self._not_connected_error() + + def _reconnect_task_in_progress(self) -> bool: + """ + Return whether a background reconnect task is currently running. + + Return: + bool: True when a reconnect task exists and has not finished. + """ + return self._reconnect_task is not None and not self._reconnect_task.done() + + async def _start_reconnect(self, reason: str, session_id: str) -> None: + """ + Schedule a background reconnect when allowed by interval and task state. + + Clears _session_ready before starting the task. No-op when _allow_reconnect is + False, the reconnection window has not elapsed, or a reconnect is already running. + + Args: + reason (str): Trigger for reconnect (OPC status name or synthetic reason). + session_id (str): Session token before failure. + """ + if not self._allow_reconnect: + return + if not self._reconnection_window_elapsed(): + self.warning( + f'OPC reconnect skipped reason=reconnection_window opc_server_id={self.id} ' + f'reconnect_reason={reason}', + self.metadata, + ) + return + if self._reconnect_task_in_progress(): + self.warning( + f'OPC reconnect skipped reason=in_progress opc_server_id={self.id} ' + f'reconnect_reason={reason}', + self.metadata, + ) + return + + self._session_ready.clear() + self.info( + f'OPC reconnect scheduled reconnect_reason={reason} opc_server_id={self.id} ' + f'old_session_id={session_id}', + self.metadata, + ) + self._reconnect_task = asyncio.create_task(self._run_reconnect(reason, session_id)) + + async def _run_reconnect(self, reason: str, session_id: str) -> None: + """ + Background task that tears down and re-establishes the OPC UA session. + + Args: + reason (str): Trigger for reconnect (OPC status or ProtocolClosed). + session_id (str): Previous session token string for logging. + """ + try: + async with self._connection_lock: + self.info( + f'OPC reconnect started reconnect_reason={reason} opc_server_id={self.id} ' + f'old_session_id={session_id}', + self.metadata, + ) + success, error = await self._reconnect_locked() + if not success: + self.error( + f'OPC reconnect failed reconnect_reason={reason} opc_server_id={self.id}', + self.metadata, + ) + if error: + self.error(error.get('message', ''), self.metadata) + except Exception: + self.error( + f'OPC reconnect task failed opc_server_id={self.id} reconnect_reason={reason}', + self.metadata, + ) + self.error(traceback.format_exc(), self.metadata) + + async def _log_write_inter_arrival(self, session_id: str, node: str) -> None: + """ + Log elapsed wall time since the previous successful OPC write on this repository. + + Args: + session_id (str): Current OPC UA session token string. + node (str): Node id written in this operation. + """ + now = time.monotonic() + if self._last_write_mono is not None: + delta_s = now - self._last_write_mono + self.info( + f'OPC write inter-arrival_s={delta_s:.6f} opc_server_id={self.id} ' + f'session_id={session_id} node={node}', + self.metadata, + ) + if self.client is not None: + session_timeout_ms = float(self.client.session_timeout) + if session_timeout_ms > 0 and delta_s > (session_timeout_ms / 1000.0): + await self.emit_metric( + metrics.OPC_WRITE_INTER_ARRIVAL_OVER_SESSION_TIMEOUT_TOTAL, + self._opc_debug_tags(session_id), + ) + self._last_write_mono = now + + async def _emit_opc_write_metric( + self, session_id: str, result: str, metadata: dict[str, Any] | None + ) -> None: + """ + Emit opc_write_attempts_total for a single write attempt outcome. + + Args: + session_id (str): OPC UA session token string, or "unknown". + result (str): Outcome label (OK, OPC status name, ProtocolClosed, etc.). + metadata (dict[str, Any] | None): Write context for model_id/model_name labels. + """ + await self.emit_metric( + metrics.OPC_WRITE_ATTEMPTS_TOTAL, + { + **self._opc_debug_tags(session_id), + **_model_labels_from_write_metadata(metadata), + 'result': result, + }, + ) + + def _write_failure_payload( + self, + notification_id: str, + message: str, + level: NotificationLevel = NotificationLevel.ERROR, + attachment_content: str | None = None, + opc_error_kind: str | None = None, + opc_status: str | None = None, + ) -> dict[str, Any]: + """ + Build a structured error dict returned from failed write_data paths. + + Args: + notification_id (str): Stable notification identifier. + message (str): Human-readable failure message. + level (NotificationLevel): Severity for downstream notifications. + attachment_content (str | None): Optional traceback or diagnostic text. + opc_error_kind (str | None): Classifier (session_bad, connection_lost, etc.). + opc_status (str | None): OPC UA status name or synthetic reason. + + Return: + dict[str, Any]: Error payload consumed by the OPC activity layer. + """ + payload: dict[str, Any] = { + 'notification_id': notification_id, + 'message': message, + 'block': 'opc_repository', + 'level': level, + } + if attachment_content is not None: + payload['attachment_content'] = attachment_content + if opc_error_kind is not None: + payload['opc_error_kind'] = opc_error_kind + if opc_status is not None: + payload['opc_status'] = opc_status + return payload + + async def _handle_tier1_bad( + self, + exc: BaseException, + session_id: str, + node: str, + metadata: dict[str, Any], + phase: str, + ) -> tuple[bool, dict[str, Any]]: + """ + Record metrics/logs and schedule reconnect after a Tier-1 Bad* error. + + Args: + exc (BaseException): Tier-1 OPC UA error. + session_id (str): Session token at failure time. + node (str): Node id being written. + metadata (dict[str, Any]): Write context. + phase (str): get_node or write_value. + + Return: + tuple[bool, dict[str, Any]]: Always (False, error payload). + """ + opc_status = _opc_status_from_exception(exc) + trace = traceback.format_exc() + self.error(trace, metadata) + await self._emit_opc_write_metric(session_id, opc_status, metadata) + self.error( + f'OPC write failed opc_status={opc_status} opc_server_id={self.id} ' + f'session_id={session_id} model_id={metadata.get("model_id", "unknown")} ' + f'model_name={metadata.get("model_name", "unknown")} node={node} phase={phase}', + metadata, + ) + await self._start_reconnect(opc_status, session_id) + return False, self._write_failure_payload( + notification_id=f'OPC_WRITE_DATA_ERROR_{self.id}', + message=f'Failed to {phase} on OPC server: {exc} | metadata: {metadata}', + attachment_content=trace, + opc_error_kind='session_bad', + opc_status=opc_status, + ) + + async def _write_reconnect_in_progress( + self, metadata: dict[str, Any] + ) -> tuple[bool, dict[str, Any]]: + """ + Fail a write because a background reconnect task is already running. + + Args: + metadata (dict[str, Any]): Write context passed through to the activity. + + Return: + tuple[bool, dict[str, Any]]: (False, error info with opc_error_kind reconnect_in_progress). + """ + await self._emit_opc_write_metric('unknown', 'ReconnectInProgress', metadata) + self.warning( + f'OPC write rejected reconnect_in_progress opc_server_id={self.id} ' + f'model_id={metadata.get("model_id", "unknown")} ' + f'model_name={metadata.get("model_name", "unknown")}', + metadata, + ) + return False, { + 'notification_id': f'OPC_WRITE_RECONNECT_IN_PROGRESS_{self.id}', + 'message': f'OPC write skipped: reconnect in progress | metadata: {metadata}', + 'block': 'opc_repository', + 'level': NotificationLevel.WARNING, + 'opc_error_kind': 'reconnect_in_progress', + } + + async def _write_connection_lost( + self, metadata: dict[str, Any], opc_status: str + ) -> tuple[bool, dict[str, Any]]: + """ + Fail a write after scheduling reconnect for a closed or stale session. + + Args: + metadata (dict[str, Any]): Write context passed through to the activity. + opc_status (str): Synthetic reason (ProtocolClosed, SessionNotReady). + + Return: + tuple[bool, dict[str, Any]]: (False, error info with opc_error_kind connection_lost). + """ + await self._emit_opc_write_metric('unknown', opc_status, metadata) + return False, self._write_failure_payload( + notification_id=f'OPC_WRITE_CONNECTION_LOST_{self.id}', + message=f'OPC write skipped: connection lost ({opc_status}) | metadata: {metadata}', + level=NotificationLevel.WARNING, + opc_error_kind='connection_lost', + opc_status=opc_status, + ) + + async def write_data( + self, node: str, value: Any, data_type: str, metadata: dict[str, Any] + ) -> tuple[bool, dict[str, Any]]: + """ + Write data to OPC server with a single attempt and background reconnect scheduling. + + Reconnect is scheduled on Tier-1 Bad*, closed protocol, or stale session readiness. + There is no retry within the same call. + + Args: + node (str): OPC UA node id to write. + value (Any): Value to convert and send. + data_type (str): Logical type key (float, int, bool, str, double). + metadata (dict[str, Any]): Activity context (model_id, model_name, etc.). + + Return: + tuple[bool, dict[str, Any]]: (True, {response_time}) on success, or + (False, structured error info) on failure. + """ + if self._reconnect_task_in_progress(): + return await self._write_reconnect_in_progress(metadata) + + if not self._session_ready.is_set(): + session_id = _opc_authentication_token_str(self.client) + await self._start_reconnect('SessionNotReady', session_id) + if self._reconnect_task_in_progress(): + return await self._write_reconnect_in_progress(metadata) + return await self._write_connection_lost(metadata, 'SessionNotReady') + + is_connected, _error = await self.validate_connection() + if not is_connected: + session_id = _opc_authentication_token_str(self.client) + await self._start_reconnect('ProtocolClosed', session_id) + return await self._write_connection_lost(metadata, 'ProtocolClosed') + + start_time = time.time() + session_id = _opc_authentication_token_str(self.client) + + try: + node_obj = self.client.get_node(node) # type: ignore[union-attr] + except Exception as e: + if is_reconnectable_opcua_bad(e): + return await self._handle_tier1_bad(e, session_id, node, metadata, 'get_node') + trace = traceback.format_exc() + self.error(trace, metadata) + await self._emit_opc_write_metric( + session_id, f'GetNodeError:{type(e).__name__}', metadata + ) + return False, self._write_failure_payload( + notification_id=f'OPC_WRITE_GET_NODE_ERROR_{self.id}', + message=f'Failed to get node from OPC server: {e} | metadata: {metadata}', + attachment_content=trace, + ) + + if data_type not in data_type_map: + await self._emit_opc_write_metric(session_id, 'UnsupportedDataType', metadata) + return False, self._write_failure_payload( + notification_id=f'OPC_WRITE_DATA_TYPE_ERROR_{self.id}', + message=f'Unsupported data type: {data_type} | metadata: {metadata}', + ) + + data = data_type_map[data_type]['converter'](value) + self.info(f'Writing {data} - {type(data)} to {node}', metadata) + ua_data = DataValue( + Variant(data, data_type_map[data_type]['opc_type']), + ) + + try: + await node_obj.write_value(ua_data) + end_time = time.time() + response_time = end_time - start_time + except Exception as e: + if is_reconnectable_opcua_bad(e): + return await self._handle_tier1_bad(e, session_id, node, metadata, 'write_value') + trace = traceback.format_exc() + self.error(trace, metadata) + await self._emit_opc_write_metric(session_id, type(e).__name__, metadata) + return False, self._write_failure_payload( + notification_id=f'OPC_WRITE_DATA_ERROR_{self.id}', + message=f'Failed to write data to OPC server: {e} | metadata: {metadata}', + attachment_content=trace, + ) + + await self._emit_opc_write_metric(session_id, 'OK', metadata) + await self._log_write_inter_arrival(session_id, node) + + return True, { + 'response_time': response_time, + } diff --git a/laborious/worker/__init__.py b/laborious/worker/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/laborious/worker/worker.py b/laborious/worker/worker.py new file mode 100644 index 0000000..7a420ba --- /dev/null +++ b/laborious/worker/worker.py @@ -0,0 +1,289 @@ +""" +Laborious Worker Module + +This module provides the main worker implementation for the Sientia DataOps Laborious system. +It orchestrates Temporal workers, manages task queues, and handles the lifecycle of +prediction and retraining workflows. + +The worker supports multiple runtime-scoped task queues (via ``sientia_do.temporal.worker.prepare_worker``): +- predictions_batch-{runtime}-queue: Batch prediction workflows (heavy workload) +- minimal_retrain-{runtime}-queue: Model retraining workflows +- drift-{runtime}-queue: Drift detection workflows +- simple_metrics-{runtime}-queue: Simple metrics workflows + +``RUNTIME`` must be set; it is passed to every ``prepare_worker`` call. Schedulers must use the +same queue names (breaking change vs legacy ``drift-queue`` / ``simple_metrics-queue``). + +Key Features: +- Resource-based scaling with WorkerTuner (CPU and memory aware) +- Automatic polling scaling with PollerBehaviorAutoscaling +- Prometheus metrics integration +- Comprehensive error handling and logging +- Graceful shutdown with cleanup +- Multiple worker instances for different workflow types + +Environment Variables: +- RUNTIME: Required non-empty string; suffix for all task queue names +- TEMPORAL_HOST: Temporal server address (default: localhost:7233) +- TEMPORAL_NAMESPACE: Temporal namespace (default: laborious) +- POD_ID: Kubernetes pod identifier for metrics +- HTTP_METRICS_PORT: Prometheus metrics server port (default: 9090) +- HTTP_SDK_METRICS_PORT: Temporal SDK metrics port (default: 9091) +- PROJECT_NAME: Project name for notifications (default: laborious) +""" + +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, + ) + + from laborious import metrics + from laborious.activities.activities import Activities + from laborious.utils.connectors_config import ( + build_minio_config, + build_mlflow_config, + build_opc_config, + ) + from laborious.workflows.drift import Drift + from laborious.workflows.minimal_retrain import MinimalRetrain + from laborious.workflows.predictions_batch import PredictionsBatch + from laborious.workflows.simple_metrics import SimpleMetrics + from laborious.workflows.sub_workflows.format_and_export_prediction import ( + FormatAndExportPrediction, + ) + from laborious.workflows.sub_workflows.prediction_process import PredictionProcess + +POD_ID = os.getenv('HOSTNAME') +SDK_METRICS_PORT = int(os.getenv('HTTP_SDK_METRICS_PORT', '9091')) + + +async def main(): + """ + Main entry point for the Laborious worker application. + + This function initializes and starts all components of the worker: + 1. Sets up logging and metadata + 2. Starts Prometheus metrics server + 3. Initializes notification handler + 4. Creates and configures activities + 5. Initializes OPC connections + 6. Starts Temporal client and workers + 7. Manages worker lifecycle and graceful shutdown + + The function runs indefinitely until interrupted or an error occurs. + On error, it performs cleanup and exits with a non-zero status code. + + Raises: + Exception: Any unhandled exception during worker execution + SystemExit: On graceful shutdown or error conditions + """ + 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) + + runtime = os.getenv('RUNTIME', '').strip() + if not runtime: + logger.custom_critical( + 'RUNTIME environment variable is required and must be non-empty', + metadata, + ) + metrics.APP_UP.labels(pod_id=POD_ID).set(0) + sys.exit(1) + + metadata_runtime = {**metadata, 'runtime': runtime} + + logger.custom_info('Starting prometheus client...', metadata_runtime) + start_prometheus_server() + + logger.custom_info('Starting Notification Handler...', metadata_runtime) + + 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', 'laborious'), + ) + + logger.custom_info('Starting Activities...', metadata_runtime) + + activities = Activities( + postgres_config=build_postgres_config(), + mlflow_config=build_mlflow_config(), + minio_config=build_minio_config(), + opc_config=build_opc_config(), + pi_web_api_config=build_api_config(), + logger=logger, + notification_handler=notification_handler, + ) + + logger.custom_info('Initializing OPC...', metadata_runtime) + await activities.init_opc() + + logger.custom_info( + f'Starting SDK Metrics Server on port {SDK_METRICS_PORT}...', + metadata_runtime, + ) + + new_runtime = Runtime( + telemetry=TelemetryConfig( + metrics=PrometheusConfig(bind_address=f'0.0.0.0:{SDK_METRICS_PORT}') + ) + ) + + logger.custom_info(f'Starting Temporal Client at {host}...', metadata_runtime) + + temporal_client = await client.Client.connect( + target_host=host, + namespace=os.getenv('TEMPORAL_NAMESPACE', 'laborious'), + runtime=new_runtime, + ) + + logger.custom_info(f'Starting Workers (runtime={runtime})...', metadata_runtime) + + workers = [ + prepare_worker( + temporal_client=temporal_client, + main_workflow=MinimalRetrain, + other_workflows=[], + activities=[ + activities.load_query_with_minio_offload, + activities.retrain_model, + activities.update_production_model, + activities.format_retrain_report, + activities.export_data_to_postgres, + ], + logger=logger, + runtime=runtime, + ), + prepare_worker( + temporal_client=temporal_client, + main_workflow=SimpleMetrics, + other_workflows=[], + activities=[ + activities.load_custom_query, + activities.calculate_simple_metrics, + activities.export_data_to_postgres, + ], + logger=logger, + runtime=runtime, + ), + prepare_worker( + temporal_client=temporal_client, + main_workflow=Drift, + other_workflows=[], + activities=[ + activities.load_custom_query, + activities.get_reference_data, + activities.calculate_drift, + activities.export_data_to_postgres, + ], + logger=logger, + runtime=runtime, + ), + prepare_worker( + temporal_client=temporal_client, + main_workflow=PredictionsBatch, + other_workflows=[PredictionProcess, FormatAndExportPrediction], + activities=[ + # MLFlow + activities.request_predict, + activities.request_transform, + # Gates + activities.input_gate, + activities.mlflow_response_gate, + activities.mlflow_content_gate, + activities.format_transformed_data, + activities.format_prediction, + activities.format_default_prediction, + # OPC + activities.write_opc_data, + # Postgres / MinIO offload + activities.load_query_with_minio_offload, + activities.cleanup_minio_objects_expired, + activities.repeat_last_prediction, + activities.export_data_to_postgres, + activities.export_payload_to_postgres, + activities.write_metrics, + # Pi Web API + activities.write_pi_web_api_data, + ], + logger=logger, + runtime=runtime, + ), + ] + + handlers = [] + for w in workers: + handlers.append(w.run()) + + logger.custom_info('Workers started successfully', metadata_runtime) + + exit_code = 0 + try: + # This will run the workers and wait for them to complete. + # If an exception occurs in any of the worker handlers, it will be propagated here. + await asyncio.gather(*handlers) + except BaseException as e: # NOSONAR + logger.custom_error(f'An unhandled exception occurred: {e}', metadata) + exit_code = 1 + finally: + if notification_handler: + notification_handler.shutdown() + if activities: + await activities.shutdown() + metrics.APP_UP.labels(pod_id=POD_ID).set(0) # Mark app as DOWN + sys.exit(exit_code) + + +def start_prometheus_server(): + """ + Starts the Prometheus metrics server for monitoring and observability. + + This function initializes the Prometheus HTTP server on the configured port + and sets the application health metric to indicate the service is running. + + The server exposes metrics that can be scraped by Prometheus for monitoring + the health and performance of the Laborious worker. + + Environment Variables: + HTTP_METRICS_PORT: Port for the metrics server (default: 9090) + POD_ID: Pod identifier for metrics labeling + + Raises: + SystemExit: If the 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()) diff --git a/laborious/workflows/__init__.py b/laborious/workflows/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/laborious/workflows/drift.py b/laborious/workflows/drift.py new file mode 100644 index 0000000..2faff32 --- /dev/null +++ b/laborious/workflows/drift.py @@ -0,0 +1,107 @@ +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 laborious.activities.activities import Activities + + +@workflow.defn(name='drift') +class Drift: + @workflow.run + async def run(self, input_data: dict[str, Any]): + """ + Execute the drift workflow. + + This method orchestrates the complete drift process by: + 1. Loading data using the provided custom SQL query + 2. Preparing prediction configuration and filters + 3. Delegating to the PredictionProcess workflow for ML operations + """ + + metadata = { + 'metadata': { + 'schedule_name': input_data['schedule_name'], + 'model_name': input_data['model_name'], + 'model_id': input_data['model_id'], + 'workflow_name': 'drift', + } + } + + print(f'Input data: {input_data}', metadata) + + model_config = input_data['model_config'] + target_name = model_config['target'] + + gathering_query = f""" + SELECT * + FROM "{input_data['schema']}"."{input_data['source_table_name']}" + WHERE + model_id = '{input_data['model_id']}' AND + timestamp > NOW() - INTERVAL '{input_data['interval']} minutes' + ORDER BY timestamp ASC + """ # nosec B608 - values come from internal Temporal workflow config, not user input + + target_data_handler = workflow.start_activity_method( + Activities.load_custom_query, + { + **metadata, + 'query': gathering_query, + 'datetime_columns': ['timestamp', 'created_at'], + 'orient': 'records', + }, + retry_policy=retry_policy, + start_to_close_timeout=timedelta(seconds=300), + ) + + reference_data_handler = workflow.start_activity_method( + Activities.get_reference_data, + {**metadata, 'model_name': input_data['model_name']}, + retry_policy=retry_policy, + start_to_close_timeout=timedelta(seconds=300), + ) + + target_data = await target_data_handler + reference_data = await reference_data_handler + + if not target_data: + return + + drift_data = await workflow.execute_local_activity_method( + Activities.calculate_drift, + { + **metadata, + 'target_data': target_data, + 'reference_data': reference_data, + 'model_name': input_data['model_name'], + 'model_id': input_data['model_id'], + 'target_name': target_name, + 'drift_metrics': input_data.get( + 'drift_metrics', ['kolmogorov_smirnov', 'jensen_shannon', 'wasserstein'] + ), + 'chunk_period': input_data.get('chunk_period', 'min'), + }, + retry_policy=retry_policy, + start_to_close_timeout=timedelta(seconds=300), + ) + + if drift_data: + await workflow.execute_activity_method( + Activities.export_data_to_postgres, + { + **metadata, + 'data': drift_data, + 'schema': input_data['schema'], + 'table_name': input_data['target_table_name'], + 'timestamp_conversion': { + 'column': 'timestamp', + 'format': DATETIME_FORMAT_WITH_TZ, + }, + }, + retry_policy=retry_policy, + start_to_close_timeout=timedelta(seconds=300), + ) diff --git a/laborious/workflows/minimal_retrain.py b/laborious/workflows/minimal_retrain.py new file mode 100644 index 0000000..f8f5151 --- /dev/null +++ b/laborious/workflows/minimal_retrain.py @@ -0,0 +1,137 @@ +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 laborious.activities.activities import Activities + from laborious.utils.models.minio_dataframe_payload import MinioDataFramePayload + + +@workflow.defn(name='minimal_retrain') +class MinimalRetrain: + """ + Automated model retraining workflow for the Laborious system. + + This workflow implements a complete model retraining pipeline that loads + training data, executes model retraining, updates production models, + and maintains comprehensive audit trails. It's designed for automated + model lifecycle management with minimal manual intervention. + + The workflow provides a robust retraining process with: + - Automated data loading from configured data sources + - MLFlow model retraining with quality validation + - Production model updates with version control + - Comprehensive reporting and audit trail maintenance + - Error handling and notification integration + """ + + @workflow.run + async def run(self, input_data: dict[str, Any]): + """ + Execute the automated model retraining workflow. + + This method orchestrates the complete model retraining process by: + 1. Loading training data using the provided custom SQL query + 2. Executing MLFlow model retraining with the loaded data + 3. Updating production models with newly trained versions + 4. Persisting comprehensive retraining reports to database + + The method implements comprehensive error handling and ensures all + required parameters are properly configured before proceeding. + + Args: + input_data: Complete configuration for the retraining workflow + Required keys: + - schedule_name (str): Schedule identifier for the retraining + - model_name (str): Name of the ML model to retrain + - model_id (int): Unique identifier for the model version + - query (str): SQL query for training data loading + - schema (str, optional): Database schema for report storage + - table_name (str, optional): Target table for retraining reports + - datetime_columns (list[str], optional): Columns to treat as datetime + + Returns: + None: The workflow completes successfully when all steps finish + + Raises: + Exception: If any required parameters are missing or if the workflow fails + during data loading, retraining, or model update operations + """ + + metadata = { + 'metadata': { + 'schedule_name': input_data['schedule_name'], + 'model_name': input_data['model_name'], + 'model_id': input_data['model_id'], + 'workflow_name': 'minimal_retrain', + } + } + + model_name = input_data['model_name'] + model_config = input_data.get('model_config', {}) + + storage_result = await workflow.execute_activity_method( + Activities.load_query_with_minio_offload, + { + **metadata, + 'query': input_data['query'], + 'datetime_columns': input_data.get('datetime_columns', []), + 'model_name': model_name, + }, + retry_policy=retry_policy, + start_to_close_timeout=timedelta(seconds=600), + ) + + storage_payload = MinioDataFramePayload.from_dict(storage_result) + if not storage_payload.has_data(): + raise ValueError('No data returned from query') + + experiment_response = await workflow.execute_activity_method( + Activities.retrain_model, + { + **metadata, + 'data': storage_result, + 'model_name': model_name, + 'model_config': model_config, + }, + retry_policy=retry_policy, + start_to_close_timeout=timedelta(hours=1), + ) + + if experiment_response['success']: + update_report = await workflow.execute_activity_method( + Activities.update_production_model, + {**metadata, 'model_name': model_name, **experiment_response}, + retry_policy=retry_policy, + start_to_close_timeout=timedelta(seconds=60), + ) + else: + update_report = {} + + report = await workflow.execute_local_activity_method( + Activities.format_retrain_report, + { + **metadata, + 'experiment_response': experiment_response, + 'model_name': model_name, + 'model_id': input_data['model_id'], + 'update_report': update_report, + }, + retry_policy=retry_policy, + start_to_close_timeout=timedelta(seconds=60), + ) + + await workflow.execute_activity_method( + Activities.export_data_to_postgres, + { + **metadata, + 'data': report, + 'schema': input_data['schema'], + 'table_name': input_data['table_name'], + }, + retry_policy=retry_policy, + start_to_close_timeout=timedelta(seconds=600), + ) diff --git a/laborious/workflows/predictions_batch.py b/laborious/workflows/predictions_batch.py new file mode 100644 index 0000000..e68c215 --- /dev/null +++ b/laborious/workflows/predictions_batch.py @@ -0,0 +1,127 @@ +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 laborious.activities.activities import Activities + + +@workflow.defn(name='predictions_batch') +class PredictionsBatch: + """ + Main batch prediction workflow for the Laborious system. + + This workflow orchestrates the complete batch prediction process, handling + data loading, configuration management, and workflow delegation. It serves + as the primary entry point for batch prediction operations and ensures + proper data preparation before ML model inference. + + The workflow implements a robust data processing pipeline with: + - Custom SQL query execution for data loading + - Comprehensive configuration management + - Data quality filter application + - MLFlow model integration + - Workflow delegation to specialized sub-workflows + + Workflow Execution: + 1. Data Loading: Executes custom SQL query to load prediction data + 2. Configuration Preparation: Sets up prediction parameters and filters + 3. Workflow Delegation: Spawns PredictionProcess child workflow + 4. Error Handling: Implements comprehensive error handling and retry policies + """ + + @workflow.run + async def run(self, input_data: dict[str, Any]): + """ + Execute the batch prediction workflow. + + This method orchestrates the complete batch prediction process by: + 1. Loading data using the provided custom SQL query + 2. Preparing prediction configuration and filters + 3. Delegating to the PredictionProcess workflow for ML operations + + The method implements comprehensive error handling and ensures all + required parameters are properly configured before proceeding. + + Args: + input_data: Complete configuration for the batch prediction + Required keys: + - schedule_name (str): Schedule identifier for the prediction + - model_name (str): Name of the ML model to use + - model_id (int): Unique identifier for the model + - query (str): SQL query for data loading + - schema (dict, optional): Data schema definition + - table_name (str, optional): Target table for predictions + - input_filters (dict, optional): Data quality filters + - mlflow_transform_filters (dict, optional): MLFlow transform filters + - mlflow_predict_filters (dict, optional): MLFlow prediction filters + - model_retention (int, optional): Model retention period in minutes + - path_priority (list[str]): Decision path priority configuration + - opc_output_config (dict, optional): OPC server export configuration + - pi_web_api_output_config (dict, optional): PI Web API export configuration + - datetime_columns (list[str], optional): Columns to treat as datetime + - save_transform (bool, optional): Whether to save transformed data (default: True) + - prediction_store_policy (str, optional): Data retention policy (default: 'lts:1') + + Returns: + None: The workflow completes successfully when the child workflow finishes + + Raises: + Exception: If any required parameters are missing or if the workflow fails + during data loading or workflow delegation + """ + + metadata = { + 'metadata': { + 'schedule_name': input_data['schedule_name'], + 'model_name': input_data['model_name'], + 'model_id': input_data['model_id'], + 'workflow_name': 'predictions_batch', + } + } + + # Load data using custom query with optional MinIO offload for large frames + data = await workflow.execute_activity_method( + Activities.load_query_with_minio_offload, + { + **metadata, + 'query': input_data['query'], + 'datetime_columns': input_data.get('datetime_columns', []), + 'model_name': input_data['model_name'], + }, + retry_policy=retry_policy, + start_to_close_timeout=timedelta(seconds=300), + ) + + # Prepare input for prediction_process workflow + prediction_input = { + 'metadata': metadata, + 'data': data, + 'schema': input_data['schema'], + 'table_name': input_data['table_name'], + 'transform_table_name': input_data['transform_table_name'], + 'model_id': input_data['model_id'], + 'model_name': input_data['model_name'], + 'input_filters': input_data.get( + 'input_filters', {'EMPTY_DATA': {'POLICY': 'STOP', 'CONFIG': {}}} + ), + 'mlflow_transform_filters': input_data.get( + 'mlflow_transform_filters', {'API_ERROR': {'POLICY': 'STOP', 'CONFIG': {}}} + ), + 'mlflow_predict_filters': input_data.get( + 'mlflow_predict_filters', {'API_ERROR': {'POLICY': 'STOP', 'CONFIG': {}}} + ), + 'model_config': input_data.get('model_config', {}), + 'path_priority': input_data.get('path_priority', ['STOP', 'CONTINUE', 'REPEAT']), + 'opc_output_config': input_data.get('opc_output_config', {}), + 'on_conflict': input_data.get('on_conflict', 'error'), + 'pi_web_api_output_config': input_data.get('pi_web_api_output_config', {}), + 'prediction_store_policy': input_data.get('prediction_store_policy', 'lts:1'), + 'save_transform': input_data.get('save_transform', True), + } + + # Execute prediction process workflow + await workflow.execute_child_workflow('subworkflow.prediction_process', prediction_input) diff --git a/laborious/workflows/simple_metrics.py b/laborious/workflows/simple_metrics.py new file mode 100644 index 0000000..38b47c4 --- /dev/null +++ b/laborious/workflows/simple_metrics.py @@ -0,0 +1,95 @@ +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 laborious.activities.activities import Activities + + +@workflow.defn(name='simple_metrics') +class SimpleMetrics: + @workflow.run + async def run(self, input_data: dict[str, Any]): + """ + Execute the simple metrics workflow. + """ + metadata = { + 'metadata': { + 'model_id': input_data['model_id'], + 'model_name': input_data['model_name'], + 'workflow_name': 'simple_metrics', + 'schedule_name': input_data['schedule_name'], + } + } + + model_id = input_data['model_id'] + interval_minutes = input_data['interval_minutes'] + + model_config = input_data['model_config'] + target_name = model_config['target'] + + query = f""" + select p."timestamp", p.prediction, ld.value as "target" + from "{input_data['schema']}"."{input_data['predictions_table_name']}" p + inner join "{input_data['schema']}"."{input_data['data_table_name']}" ld + on p."timestamp" = ld."timestamp" + where + p.model_id = '{model_id}' and + p.prediction is not null and + ld.variable = '{target_name}' and + ld.value is not null and + p."timestamp" >= NOW() - INTERVAL '{interval_minutes} minutes' + order by + p."timestamp" desc; + """ # nosec B608 - values come from internal Temporal workflow config, not user input + + target_data = await workflow.execute_activity_method( + Activities.load_custom_query, + { + **metadata, + 'query': query, + 'datetime_columns': ['timestamp'], + 'orient': 'records', + }, + retry_policy=retry_policy, + start_to_close_timeout=timedelta(seconds=300), + ) + + if not target_data: + return + + simple_metrics = await workflow.execute_local_activity_method( + Activities.calculate_simple_metrics, + { + **metadata, + 'model_id': model_id, + 'target_data': target_data, + 'metrics': input_data.get('metrics', ['rmse', 'mse', 'mae', 'r2']), + 'interval_minutes': interval_minutes, + }, + retry_policy=retry_policy, + start_to_close_timeout=timedelta(seconds=300), + ) + + if not simple_metrics: + return + + await workflow.execute_activity_method( + Activities.export_data_to_postgres, + { + **metadata, + 'data': simple_metrics, + 'schema': input_data['schema'], + 'table_name': input_data['target_table_name'], + 'timestamp_conversion': { + 'column': 'timestamp', + 'format': DATETIME_FORMAT_WITH_TZ, + }, + }, + retry_policy=retry_policy, + start_to_close_timeout=timedelta(seconds=300), + ) diff --git a/laborious/workflows/sub_workflows/__init__.py b/laborious/workflows/sub_workflows/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/laborious/workflows/sub_workflows/format_and_export_prediction.py b/laborious/workflows/sub_workflows/format_and_export_prediction.py new file mode 100644 index 0000000..68552b2 --- /dev/null +++ b/laborious/workflows/sub_workflows/format_and_export_prediction.py @@ -0,0 +1,220 @@ +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 laborious.activities.activities import Activities + + +@workflow.defn(name='subworkflow.format_and_export_prediction') +class FormatAndExportPrediction: + """ + Data formatting and export workflow for prediction results. + + This workflow handles the final stages of the prediction pipeline, including + data formatting, database persistence, OPC server export, and metrics recording. + It implements flexible formatting based on prediction quality and provides + comprehensive export capabilities to multiple destinations. + + The workflow supports two main prediction paths: + 1. Normal Prediction: Formats and exports successful prediction results + 2. Default Prediction: Creates fallback predictions for error conditions + + Export Destinations: + - PostgreSQL Database: Persistent storage with timestamp conversion + - PI Web API: Real-time industrial system integration for prediction and confidence values + - OPC Servers: Real-time industrial system integration + - Prometheus Metrics: Performance monitoring and operational visibility + """ + + @workflow.run + async def run(self, input_data: dict[str, Any]): + """ + Execute the prediction formatting and export workflow. + + This method orchestrates the complete data export process by: + 1. Determining the appropriate formatting strategy based on path_flag + 2. Formatting prediction data according to quality and requirements + 3. Exporting data to PI Web API for real-time industrial access (if configured) + 4. Exporting data to OPC servers for real-time industrial access (if configured) + 5. Persisting data to PostgreSQL database with comprehensive metadata + 6. Recording performance metrics for operational monitoring + + The method implements flexible formatting strategies: + - Normal predictions: Full data formatting with confidence scores + - Error predictions: Default formatting with error indicators + - Comprehensive export: Multi-destination data distribution + + Args: + input_data: Complete configuration for the export workflow + Required keys: + - metadata (dict): Workflow execution metadata + - path_flag (str | None): Decision path flag for formatting strategy + - None: Normal prediction path with full formatting + - Any other value: Default prediction path for error conditions + - data (dict[str, Any]): Prediction data to format and export + - prediction_confidence (float): Confidence score for the prediction + - timestamp (str): ISO-formatted timestamp for the prediction + - model_id (int): Unique identifier for the ML model + - model_name (str): Name of the ML model + - schema (str): Database schema for data storage + - table_name (str): Target table for data persistence + Optional keys: + - opc_output_config (dict[str, Any]): OPC server export configuration + - pi_web_api_output_config (dict[str, Any]): PI Web API export configuration + Contains endpoint, prediction_tags, and confidence_tags mappings + - transformed_data (dict[str, Any]): Transformed data to export separately + Only processed when path_flag is None + - transform_table_name (str): Target table for transformed data export + Required if transformed_data is provided + - prediction_store_policy (str): Data retention policy (e.g., 'lts:1', 'erl:2') + Required when path_flag is None + - comment (str): Operational comment or error description + Required when path_flag is not None + + Returns: + None: The workflow completes successfully when all export operations finish + + Note: + When transformed_data is provided and path_flag is None, the workflow will: + 1. Format the transformed data using format_transformed_data + 2. Export it to a separate table (transform_table_name) asynchronously + 3. Wait for both prediction and transformed data exports to complete + """ + metadata = input_data['metadata'] + path_flag = input_data['path_flag'] + data = input_data['data'] + transformed_data = input_data.get('transformed_data', None) + prediction_confidence = input_data['prediction_confidence'] + + opc_output_config = input_data.get('opc_output_config', None) + pi_web_api_output_config = input_data.get('pi_web_api_output_config', None) + + if path_flag is None: + # Normal prediction path: format prediction data with full metadata + prediction = await workflow.execute_local_activity_method( + Activities.format_prediction, + { + **metadata, + 'data': data, + 'timestamp': input_data['timestamp'], + 'model_id': input_data['model_id'], + 'prediction_confidence': prediction_confidence, + 'prediction_store_policy': input_data['prediction_store_policy'], + 'model_name': input_data['model_name'], + }, + retry_policy=retry_policy, + start_to_close_timeout=timedelta(seconds=60), + ) + + # Optionally format and export transformed data to separate table + if transformed_data is not None: + transformed = await workflow.execute_local_activity_method( + Activities.format_transformed_data, + { + **metadata, + 'data': transformed_data, + 'model_id': input_data['model_id'], + 'model_name': input_data['model_name'], + }, + retry_policy=retry_policy, + start_to_close_timeout=timedelta(seconds=60), + ) + + write_transformed_handler = workflow.start_activity_method( + Activities.export_payload_to_postgres, + { + **metadata, + 'schema': input_data['schema'], + 'table_name': input_data['transform_table_name'], + 'data': transformed, + 'timestamp_conversion': { + 'column': 'timestamp', + 'format': DATETIME_FORMAT_WITH_TZ, + }, + }, + retry_policy=retry_policy, + start_to_close_timeout=timedelta(seconds=60), + ) + + else: + write_transformed_handler = None + + else: + # Error path: create default prediction with error indicators + prediction = await workflow.execute_local_activity_method( + Activities.format_default_prediction, + { + **metadata, + 'timestamp': input_data['timestamp'], + 'model_id': input_data['model_id'], + 'prediction_confidence': prediction_confidence, + 'comment': input_data['comment'], + }, + retry_policy=retry_policy, + start_to_close_timeout=timedelta(seconds=60), + ) + + write_transformed_handler = None + + opc_metrics = {} + + # write to pi web api + if pi_web_api_output_config: + prediction = await workflow.execute_activity_method( + Activities.write_pi_web_api_data, + { + 'pi_web_api_output_config': pi_web_api_output_config, + 'data': prediction, + **metadata, + }, + retry_policy=retry_policy, + start_to_close_timeout=timedelta(seconds=60), + ) + + # write to opc + if opc_output_config: + prediction, opc_metrics = await workflow.execute_activity_method( + Activities.write_opc_data, + { + 'opc_output_config': opc_output_config, + 'data': prediction, + **metadata, + }, + retry_policy=retry_policy, + start_to_close_timeout=timedelta(seconds=60), + ) + + # write to postgres + await workflow.execute_activity_method( + Activities.export_data_to_postgres, + { + **metadata, + 'schema': input_data['schema'], + 'table_name': input_data['table_name'], + 'data': prediction, + 'timestamp_conversion': {'column': 'timestamp', 'format': DATETIME_FORMAT_WITH_TZ}, + 'on_conflict': input_data.get('on_conflict', 'error'), + 'unique_columns': ['model_id', 'timestamp'], + }, + retry_policy=retry_policy, + start_to_close_timeout=timedelta(seconds=180), + ) + + if write_transformed_handler is not None: + await write_transformed_handler + + await workflow.execute_activity_method( + Activities.write_metrics, + { + **metadata, + 'prediction': prediction, + 'opc_metrics': opc_metrics, + }, + retry_policy=retry_policy, + start_to_close_timeout=timedelta(seconds=60), + ) diff --git a/laborious/workflows/sub_workflows/prediction_process.py b/laborious/workflows/sub_workflows/prediction_process.py new file mode 100644 index 0000000..da4125b --- /dev/null +++ b/laborious/workflows/sub_workflows/prediction_process.py @@ -0,0 +1,346 @@ +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 laborious.activities.activities import Activities + + +@workflow.defn(name='subworkflow.prediction_process') +class PredictionProcess: + """ + Core prediction processing workflow for the Laborious system. + + This workflow implements the complete ML model inference pipeline, handling + data quality validation, MLFlow model interactions, and prediction processing. + It serves as the central orchestrator for all prediction operations and ensures + data quality throughout the entire process. + + The workflow implements a robust data processing pipeline with: + - Data quality validation using configurable filters + - MLFlow model transformation and prediction + - Response validation and quality assurance + - Flexible decision path handling + - Comprehensive error handling and retry policies + + Workflow Execution: + 1. Timestamp Retrieval: Gets last processed timestamp for incremental processing + 2. Input Data Gate: Applies data quality filters + 3. Path Decision: Determines processing path based on filter results + 4. MLFlow Transform: Requests data transformation using MLFlow models + 5. Response Validation: Filters transform responses for quality assurance + 6. MLFlow Prediction: Executes prediction using transformed data + 7. Content Validation: Filters prediction responses for final quality check + 8. Export Delegation: Delegates to FormatAndExportPrediction workflow + """ + + @workflow.run + async def run(self, input_data: dict[str, Any]): + """ + Execute the prediction process workflow. + + This method orchestrates the complete prediction processing pipeline by: + 1. Retrieving the last processed timestamp for incremental processing + 2. Applying data quality filters to validate input data + 3. Executing MLFlow model transformation and prediction + 4. Validating all responses for quality assurance + 5. Delegating to export workflow for data persistence + + The method implements comprehensive error handling and ensures all + data quality requirements are met before proceeding with ML operations. + + Args: + input_data: Complete configuration for the prediction process + Required keys: + - metadata (dict): Workflow execution metadata + - data (dict): Input data for prediction processing + - schema (dict): Data schema definition + - table_name (str): Target table for predictions + - model_id (str): ML model identifier + - model_name (str): ML model name + - input_filters (dict): Data quality filters + - mlflow_transform_filters (dict): MLFlow transform filters + - mlflow_predict_filters (dict): MLFlow prediction filters + - model_retention (int): Model retention period in minutes + - path_priority (list[str]): Decision path priority configuration + - opc_output_config (dict, optional): OPC server export configuration + - pi_web_api_output_config (dict, optional): PI Web API export configuration + - save_transform (bool, optional): Whether to save transformed data (default: True) + - prediction_store_policy (str, optional): Data retention policy (default: 'lts:1') + + Returns: + None: The workflow completes successfully when export workflow finishes + + Raises: + Exception: If any required parameters are missing or if the workflow fails + during data processing, MLFlow operations, or workflow delegation + + """ + + metadata = input_data['metadata'] + data = input_data['data'] + model_id = input_data['model_id'] + model_name = input_data['model_name'] + model_config = input_data.get('model_config', {}) + save_transform = input_data.get('save_transform', True) + + try: + await self._run_prediction_pipeline( + input_data, + metadata, + data, + model_id, + model_name, + model_config, + save_transform, + ) + await workflow.execute_activity_method( + Activities.cleanup_minio_objects_expired, + {**metadata, 'data': data}, + retry_policy=retry_policy, + start_to_close_timeout=timedelta(minutes=5), + ) + except Exception as e: + await workflow.execute_activity_method( + Activities.cleanup_minio_objects_expired, + {**metadata, 'data': data}, + retry_policy=retry_policy, + start_to_close_timeout=timedelta(minutes=5), + ) + raise e + + async def _run_prediction_pipeline( + self, + input_data: dict[str, Any], + metadata: dict[str, Any], + data: dict[str, Any], + model_id: str, + model_name: str, + model_config: dict[str, Any], + save_transform: bool, + ) -> None: + last_timestamp = data['last_timestamp'] + + # Apply input data quality gates + gate_input = { + **metadata, + 'filters': input_data['input_filters'], + 'data': data, + 'path_priority': input_data['path_priority'], + } + + path_flag, confidence, comment = await workflow.execute_local_activity_method( + Activities.input_gate, + gate_input, + retry_policy=retry_policy, + start_to_close_timeout=timedelta(minutes=1), + ) + + # Handle path decision based on filter results + if await self.path_flag_handler( + data, path_flag, input_data, confidence, last_timestamp, comment + ): + return + + # Request MLFlow model transformation + transformed_data = await workflow.execute_activity_method( + Activities.request_transform, + {**metadata, 'data': data, 'model_name': model_name, 'model_config': model_config}, + retry_policy=retry_policy, + start_to_close_timeout=timedelta(minutes=5), + ) + + # Validate MLFlow transform response + path_flag, confidence, comment = await workflow.execute_local_activity_method( + Activities.mlflow_response_gate, + { + **metadata, + 'filters': input_data['mlflow_transform_filters'], + 'data': transformed_data, + 'type': 'transform', + 'path_priority': input_data['path_priority'], + }, + retry_policy=retry_policy, + start_to_close_timeout=timedelta(minutes=1), + ) + + # Handle path decision based on transform validation + if await self.path_flag_handler( + data, path_flag, input_data, confidence, last_timestamp, comment + ): + return + + path_flag, confidence, comment = await workflow.execute_local_activity_method( + Activities.mlflow_content_gate, + { + **metadata, + 'filters': input_data['mlflow_transform_filters'], + 'data': transformed_data, + 'type': 'transform', + 'path_priority': input_data['path_priority'], + }, + retry_policy=retry_policy, + start_to_close_timeout=timedelta(minutes=1), + ) + + if await self.path_flag_handler( + data, path_flag, input_data, confidence, last_timestamp, comment + ): + return + + predicted_data = await workflow.execute_activity_method( + Activities.request_predict, + { + **metadata, + 'data': transformed_data, + 'model_name': model_name, + 'model_config': model_config, + }, + retry_policy=retry_policy, + start_to_close_timeout=timedelta(minutes=5), + ) + + # Validate MLFlow prediction response + path_flag, confidence, comment = await workflow.execute_local_activity_method( + Activities.mlflow_response_gate, + { + **metadata, + 'filters': input_data['mlflow_predict_filters'], + 'data': predicted_data, + 'type': 'predict', + 'path_priority': input_data['path_priority'], + }, + retry_policy=retry_policy, + start_to_close_timeout=timedelta(minutes=1), + ) + + # Handle path decision based on prediction validation + if await self.path_flag_handler( + data, path_flag, input_data, confidence, last_timestamp, comment + ): + return + + # Delegate to export workflow for data persistence + await workflow.execute_child_workflow( + 'subworkflow.format_and_export_prediction', + { + 'metadata': metadata, + 'on_conflict': input_data.get('on_conflict', 'error'), + 'path_flag': path_flag, + 'data': predicted_data, + 'transformed_data': transformed_data if save_transform else None, + 'prediction_confidence': confidence, + 'timestamp': last_timestamp, + 'model_id': model_id, + 'model_name': model_name, + 'model_config': model_config, + 'opc_output_config': input_data['opc_output_config'], + 'pi_web_api_output_config': input_data['pi_web_api_output_config'], + 'schema': input_data['schema'], + 'table_name': input_data['table_name'], + 'transform_table_name': input_data['transform_table_name'], + 'comment': comment, + 'prediction_store_policy': input_data['prediction_store_policy'], + }, + ) + + async def path_flag_handler( + self, + data: dict[str, Any], + path_flag: str, + input_data: dict, + confidence: int, + last_timestamp: str, + comment: str, + ) -> bool: + """ + Handle path decisions based on filter results and confidence levels. + + This method determines the appropriate action based on the path flag + returned by data quality filters. It can stop processing, continue, + or repeat operations based on the configured path priority. + + Args: + data: Input data for processing + path_flag: Path decision from filter (STOP, CONTINUE, REPEAT) + input_data: Complete workflow input configuration including: + - metadata (dict): Workflow execution metadata + - schema (str): Database schema + - table_name (str): Target table for predictions + - transform_table_name (str): Target table for transformed data + - model_id (str): ML model identifier + - model_name (str): ML model name + - model_config (dict, optional): Model configuration + - opc_output_config (dict, optional): OPC server export configuration + - pi_web_api_output_config (dict, optional): PI Web API export configuration + - prediction_store_policy (str, optional): Data retention policy + confidence: Confidence level from filter validation + last_timestamp: Last processed timestamp + comment: Additional information about the filter result + + Returns: + bool: True if processing should stop, False to continue + + Path Handling: + - STOP: Terminates workflow execution + - CONTINUE: Delegates to FormatAndExportPrediction workflow with current data + - REPEAT: Repeats last prediction if available + """ + metadata = input_data['metadata'] + + schema = input_data['schema'] + table_name = input_data['table_name'] + transform_table_name = input_data['transform_table_name'] + model_id = input_data['model_id'] + model_name = input_data['model_name'] + model_config = input_data.get('model_config', {}) + + path_flag = path_flag.upper() if path_flag else '' + + if path_flag == 'STOP': + # Stop processing and exit workflow + return True + elif path_flag == 'REPEAT': + # Repeat last prediction if available + await workflow.execute_activity_method( + Activities.repeat_last_prediction, + { + **metadata, + 'schema': schema, + 'table_name': table_name, + 'model': model_id, + 'last_timestamp': last_timestamp, + }, + retry_policy=retry_policy, + start_to_close_timeout=timedelta(minutes=1), + ) + return True + elif path_flag == 'CONTINUE': + # call write workflow + await workflow.execute_child_workflow( + 'subworkflow.format_and_export_prediction', + { + 'metadata': metadata, + 'path_flag': path_flag, + 'data': data, + 'prediction_confidence': confidence, + 'timestamp': last_timestamp, + 'model_id': model_id, + 'model_name': model_name, + 'model_config': model_config, + 'schema': schema, + 'table_name': table_name, + 'transform_table_name': transform_table_name, + 'comment': comment, + 'opc_output_config': input_data['opc_output_config'], + 'pi_web_api_output_config': input_data['pi_web_api_output_config'], + 'prediction_store_policy': input_data['prediction_store_policy'], + 'on_conflict': input_data.get('on_conflict', 'error'), + }, + ) + return True + + return False diff --git a/model_convert.ipynb b/model_convert.ipynb new file mode 100644 index 0000000..42f6c3d --- /dev/null +++ b/model_convert.ipynb @@ -0,0 +1,106 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 23, + "id": "e838ff21", + "metadata": {}, + "outputs": [], + "source": [ + "import csv\n", + "\n", + "def csv_to_tag_lists(csv_path: str) -> dict:\n", + " read_tags = []\n", + " write_tags = []\n", + "\n", + " def to_float(val):\n", + " try:\n", + " return float(str(val).strip())\n", + " except Exception:\n", + " return None\n", + "\n", + " with open(csv_path, newline=\"\", encoding=\"utf-8\") as f:\n", + " reader = csv.DictReader(f)\n", + " for row in reader:\n", + " # Basic normalization\n", + " op = (row.get(\"operation\") or \"\").strip()\n", + "\n", + " if op == \"READ\":\n", + " # Build common tag payload with required mappings\n", + " tag = {\n", + " \"server_id\": \"1\",\n", + " \"tag_address\": row.get(\"opc_tag\"),\n", + " \"tag_name\": row.get(\"name\"),\n", + " \"data_range\": [to_float(row.get(\"min_value\")), to_float(row.get(\"max_value\"))],\n", + " \"aggr_func\": row.get(\"aggregation_func\").lower(),\n", + " # keep other fields with their original names\n", + " \"frequency\": row.get(\"frequency\"),\n", + " \"local\": row.get(\"local\"),\n", + " \"area\": row.get(\"area\"),\n", + " \"description\": row.get(\"description\"),\n", + " }\n", + "\n", + " read_tags.append(tag)\n", + "\n", + " else:\n", + " tag = {\n", + " \"server_id\": \"1\",\n", + " \"addr\": row.get(\"opc_tag\"),\n", + " \"tag_name\": row.get(\"name\"),\n", + " \"local\": row.get(\"local\"),\n", + " \"area\": row.get(\"area\"),\n", + " \"description\": row.get(\"description\"),\n", + " }\n", + " \n", + " if op == \"WRITE_PREDICTION\":\n", + " tag[\"type\"] = \"prediction\"\n", + " write_tags.append(tag)\n", + " elif op == \"WRITE_CONFIDENCE\":\n", + " tag[\"type\"] = \"confidence\"\n", + " write_tags.append(tag)\n", + " # ignore any other operation values silently\n", + "\n", + " return {\"read_tags\": read_tags, \"write_tags\": write_tags}" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4621cd43", + "metadata": {}, + "outputs": [], + "source": [ + "import json\n", + "\n", + "file_names = [\"Courier - PΓ‘gina1.csv\"]\n", + "\n", + "for file_name in file_names:\n", + " write_file = file_name.replace(\".csv\", \".json\")\n", + "\n", + " with open(write_file, \"w\", encoding=\"utf-8\") as f:\n", + " json.dump(csv_to_tag_lists(file_name), f, indent=2, ensure_ascii=False)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.13" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a878882 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,158 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "laborious" +version = "0.0.0" +description = "Sientia DataOps Laborious - 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", + "tests/laborious/workflows/subworkflows/test_prediction_process.py", +] + +[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) + "S608", # potential sql injection (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", +] +markers = [ + "asyncio: marks tests as async", + "integration: marks tests as integration tests", + "unit: marks tests as unit tests", + "opc: marks tests that use the in-process OPC UA server (OpcRepository E2E)", +] + +[tool.coverage.run] +source = ["laborious"] +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", "B608"] # Skip assert, shell injection, and SQL injection (false positives) \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..4d0f89d --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,21 @@ +# 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) +testcontainers[postgres,minio] # PostgreSQL and MinIO containers for E2E tests + +# Development Tools +ipython>=8.12.0 # Enhanced Python shell +ipdb>=0.13.13 # IPython debugger +ipykernel==6.30.1 # IPython kernel for Jupyter notebooks diff --git a/requirements-light.txt b/requirements-light.txt new file mode 100644 index 0000000..302fb79 --- /dev/null +++ b/requirements-light.txt @@ -0,0 +1,18 @@ +temporalio +psycopg2-binary +sqlalchemy +asyncua==1.0.6 +redis +sientia_do>=1.12.1 +mlflow +prometheus-client +botocore +boto3 +s3fs +pyarrow +kaleido +hyperopt +shap +pycurl +scipy<1.14.0 +scikit-learn==1.5.2 diff --git a/requirements-local.txt b/requirements-local.txt new file mode 100644 index 0000000..73a9a47 --- /dev/null +++ b/requirements-local.txt @@ -0,0 +1,18 @@ +temporalio +psycopg2-binary +sqlalchemy +asyncua==1.0.6 +redis +git+ssh://git@github.com/Aignosi/sientia-dataops-library.git@1.12.1 +git+ssh://git@github.com/Aignosi/sientia-model-library.git@0.10.0 +prometheus-client +botocore +boto3 +s3fs +pyarrow +kaleido +hyperopt +shap +pycurl +scipy<1.14.0 +scikit-learn==1.5.2 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4ad005d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,18 @@ +temporalio +psycopg2-binary +sqlalchemy +asyncua==1.0.6 +redis +sientia_do>=1.12.1 +sientia>0.40.0 +prometheus-client +botocore +boto3 +s3fs +pyarrow +kaleido +hyperopt +shap +pycurl +scipy<1.14.0 +scikit-learn==1.5.2 diff --git a/run_coverage.sh b/run_coverage.sh new file mode 100755 index 0000000..f9af4cb --- /dev/null +++ b/run_coverage.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +# Exit on any error +set -e + +echo "Activating virtual environment..." +source ./venv/bin/activate + +pytest --cov=laborious --cov-report=html + +xdg-open htmlcov/index.html \ No newline at end of file diff --git a/run_local.sh b/run_local.sh new file mode 100755 index 0000000..2bbd5c2 --- /dev/null +++ b/run_local.sh @@ -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 ingestor application..." +python -m laborious.worker.worker diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..a2e5f65 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,11 @@ +sonar.projectKey=Aignosi_sientia-dataops-laborious_temporal_beaec423-6c42-4f26-8134-b676287b499d +sonar.projectName=sientia-dataops-laborious_temporal +sonar.sources=laborious +sonar.tests=tests +sonar.projectVersion=1.0.0 +sonar.coverage.exclusions=laborious/worker/* +sonar.qualitygate.wait=true +sonar.qualitygate.timeout=300 +sonar.python.coverage.reportPaths=coverage.xml +sonar.python.xunit.reportPath=pytest.xml +sonar.python.version=3.11 diff --git a/tests.ipynb b/tests.ipynb new file mode 100644 index 0000000..8926385 --- /dev/null +++ b/tests.ipynb @@ -0,0 +1,938 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "b10e5c25", + "metadata": {}, + "outputs": [], + "source": [ + "import pandas as pd\n", + "import numpy as np\n", + "\n", + "# Set random seed for reproducibility\n", + "rng = np.random.default_rng(42)\n", + "\n", + "# Generate random walks starting at 0\n", + "counter = np.zeros(300)\n", + "rollout = np.zeros(300)\n", + "\n", + "# Generate random steps between -1 and 1\n", + "counter_steps = rng.uniform(-1, 1, 299)\n", + "rollout_steps = rng.uniform(-1, 1, 299)\n", + "\n", + "print(counter_steps)\n", + "print(rollout_steps)\n", + "\n", + "# Calculate cumulative sum and scale to -100 to 100 range\n", + "for i in range(1, 300):\n", + " counter[i] = counter[i-1] + counter_steps[i-1]\n", + " rollout[i] = rollout[i-1] + rollout_steps[i-1]\n", + "\n", + "# normalize values between -100 and 100, lowest value is -100, highest value is 100\n", + "counter = (counter - min(counter)) / (max(counter) - min(counter)) * 200 - 100\n", + "rollout = (rollout - min(rollout)) / (max(rollout) - min(rollout)) * 200 - 100\n", + "\n", + "# Create DataFrame\n", + "df = pd.DataFrame({\n", + " 'Counter': counter,\n", + " 'Rollout': rollout,\n", + " 'CounterPlusRollout': counter + rollout\n", + "})\n", + "\n", + "# add a timestamp column\n", + "df['Timestamp'] = pd.date_range(start='2025-01-01', periods=300, freq='1s')\n", + "\n", + "# Save to CSV\n", + "df.to_csv('random_walks.csv', index=False)\n", + "\n", + "# Display first few rows\n", + "print(df.head())\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c61be7ab", + "metadata": {}, + "outputs": [], + "source": [ + "import pandas as pd\n", + "import numpy as np\n", + "\n", + "# Set random seed for reproducibility\n", + "rng = np.random.default_rng(42)\n", + "\n", + "size = 50\n", + "\n", + "# Generate random walks starting at 0\n", + "counter = np.zeros(size)\n", + "rollout = np.zeros(size)\n", + "square = np.zeros(size)\n", + "\n", + "# Generate random steps between -1 and 1\n", + "counter_steps = rng.uniform(-1, 1, size-1)\n", + "rollout_steps = rng.uniform(-1, 1, size-1)\n", + "square_steps = rng.uniform(-1, 1, size-1)\n", + "\n", + "print(counter_steps)\n", + "print(rollout_steps)\n", + "\n", + "# Calculate cumulative sum and scale to -100 to 100 range\n", + "for i in range(1, size):\n", + " counter[i] = counter[i-1] + counter_steps[i-1]\n", + " rollout[i] = rollout[i-1] + rollout_steps[i-1]\n", + " square[i] = square[i-1] + square_steps[i-1]\n", + "\n", + "# normalize values between -100 and 100, lowest value is -100, highest value is 100\n", + "counter = (counter - min(counter)) / (max(counter) - min(counter)) * 200 - 100\n", + "rollout = (rollout - min(rollout)) / (max(rollout) - min(rollout)) * 200 - 100\n", + "square = (square - min(square)) / (max(square) - min(square)) * 200 - 100\n", + "\n", + "# Create DataFrame\n", + "df = pd.DataFrame({\n", + " 'Counter': counter,\n", + " 'Rollout': rollout,\n", + " 'Square': square\n", + "})\n", + "\n", + "# add a timestamp column\n", + "df['Timestamp'] = pd.date_range(start='2025-01-01', periods=size, freq='5s')\n", + "\n", + "# Save to CSV\n", + "df.to_csv('random_walks_demo.csv', index=False)\n", + "\n", + "# Display first few rows\n", + "print(df.head())\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e7c8eeb1", + "metadata": {}, + "outputs": [], + "source": [ + "from pandas import DataFrame\n", + "\n", + "a = DataFrame({\n", + " \"a\": {\"2025-01-01\": 1, \"2025-01-02\": 2, \"2025-01-03\": 3},\n", + " \"b\": {\"2025-01-01\": 4, \"2025-01-02\": 5, \"2025-01-03\": 6},\n", + "})\n", + "\n", + "a.index.name = \"timestamp\"\n", + "\n", + "display(a)\n", + "\n", + "print(\"a\" in a.columns)\n", + "\n", + "b = a.tail(1)\n", + "\n", + "display(b)\n", + "\n", + "print(len(a))\n", + "print(len(b))\n", + "print(b.size)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f3374174", + "metadata": {}, + "outputs": [], + "source": [ + "from pandas import DataFrame\n", + "\n", + "data = DataFrame({\n", + " \"a\": {\"2025-01-01\": 1, \"2025-01-02\": 2, \"2025-01-03\": 3},\n", + " \"b\": {\"2025-01-01\": 4, \"2025-01-02\": 5, \"2025-01-03\": 6},\n", + "})\n", + "\n", + "index = data.index\n", + "\n", + "# Get type of first element of index\n", + "index_type = type(index[0])\n", + "\n", + "print(index_type)\n", + "\n", + "# Check if all in index are of the same type\n", + "if all(isinstance(i, index_type) for i in index):\n", + " print(\"All elements in index are of the same type\")\n", + "else:\n", + " print(\"Elements in index are of different types\")\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "40e72c60", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1fbb3788", + "metadata": {}, + "outputs": [], + "source": [ + "from unittest.mock import MagicMock\n", + "from asyncua.ua.uaerrors import BadAlreadyExists\n", + "\n", + "mock1 = MagicMock(\n", + " side_effect = Exception(\"test\")\n", + ")\n", + "\n", + "mock2 = MagicMock(\n", + " side_effect = BadAlreadyExists(\"test\")\n", + ")\n", + "\n", + "try:\n", + " mock1()\n", + "except ValueError as e:\n", + " try:\n", + " mock2()\n", + " except BadAlreadyExists as e:\n", + " print(e)\n", + "except Exception as e:\n", + " print(e)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "771ab4ee", + "metadata": {}, + "outputs": [], + "source": [ + "from pandas import DataFrame, merge\n", + "\n", + "retrain_dataset = DataFrame({\n", + " 'a': {'2025-01-01': 1, '2025-01-02': 2, '2025-01-03': 3},\n", + " 'b': {'2025-01-01': 4, '2025-01-02': 5, '2025-01-03': 6},\n", + " 'c': {'2025-01-01': 7, '2025-01-02': 8, '2025-01-03': 9},\n", + "})\n", + "\n", + "prediction_data = DataFrame({\n", + " 'prediction': {'1': 1, '2': 2, '3': 3},\n", + "})\n", + "\n", + "prediction_data.index = retrain_dataset.index\n", + "\n", + "prediction_data = merge(\n", + " retrain_dataset, prediction_data, left_index=True, right_index=True, how='left')\n", + "\n", + "prediction_data.rename(columns={'c': 'target'}, inplace=True)\n", + "\n", + "prediction_data['timestamp'] = prediction_data.index\n", + "\n", + "prediction_data.reset_index(drop=True, inplace=True)\n", + "\n", + "display(prediction_data)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "486b95b3", + "metadata": {}, + "outputs": [], + "source": [ + "from pandas import DataFrame\n", + "\n", + "data = DataFrame()\n", + "\n", + "display(data.to_dict(orient='records'))\n", + "\n", + "data = DataFrame({\n", + " \"a\": {\"2025-01-01\": 1, \"2025-01-02\": 2, \"2025-01-03\": 3},\n", + " \"b\": {\"2025-01-01\": 4, \"2025-01-02\": 5, \"2025-01-03\": 6},\n", + "})\n", + "\n", + "display(data)\n", + "\n", + "data_list = data.to_dict('split')\n", + "\n", + "display(data_list)\n", + "\n", + "data_rec = DataFrame.from_dict(data_list, orient='index')\n", + "\n", + "display(data_rec)\n", + "\n", + "data.shape[0]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d67d551f", + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "import shutil\n", + "import mlflow\n", + "from mlflow.tracking import MlflowClient\n", + "from rich.console import Console\n", + "import sys\n", + "\n", + "if \"src\" not in sys.path:\n", + " sys.path.insert(0, \"src\")\n", + "\n", + "console = Console()\n", + "\n", + "os.environ[\"MLFLOW_TRACKING_URI\"] = \"http://localhost:35785/\"\n", + "os.environ[\"MLFLOW_TRACKING_USERNAME\"] = \"aignosi\"\n", + "os.environ[\"MLFLOW_TRACKING_PASSWORD\"] = \"1L0FP50j3ncp123\"\n", + "\n", + "client = MlflowClient()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "98bdd6af", + "metadata": {}, + "outputs": [], + "source": [ + "from pandas import DataFrame, read_json\n", + "\n", + "# Load from data file (json)\n", + "data = read_json('data.json')\n", + "\n", + "column_order = [\n", + " 'CI-J3J01S1', 'CI-J3P01T1A', 'CI-J3P03S1', 'CI-W3A05F1',\n", + " 'CI-W3A50A1', 'CI-W3A50A2', 'CI-W3A50A3', 'CI-W3A50P1', 'CI-W3A50T1',\n", + " 'CI-W3A55P1', 'CI-W3A55T1', 'CI-W3A65_Cl', 'CI-W3A65_SO3', 'CI-W3A71P1',\n", + " 'CI-W3A71P2', 'CI-W3A71P3', 'CI-W3E01F1', 'CI-W3K01S1', 'CI-W3K01T1',\n", + " 'CI-W3K01T2', 'CI-W3K01T4', 'CI-W3K14P1', 'CI-W3P17S1', 'CI-W3V04P1',\n", + " 'CI-W3V04P3', 'CI-W3V21F1', 'CI-W3V21P1', 'CI-W3V30F1', 'CI-W3V33P1',\n", + " 'CI-W3W01G1', 'CI-W3W01P1', 'CI-W3W01P2', 'CI-W3W03I1', 'CI-W3W03S1',\n", + " 'CI-W3_C3S', 'CI-W3_CAO', 'CI-W3_MA', 'CI-W3_MS', 'CI-W3_PL']\n", + "\n", + "ordered_data = data[column_order]\n", + "\n", + "display(ordered_data.head(3))\n", + "display(data.head(3))\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d7e73c30", + "metadata": {}, + "outputs": [], + "source": [ + "import mlflow\n", + "\n", + "model_name = \"vcm-o2-vanilla-ice\"\n", + "\n", + "model = mlflow.pyfunc.load_model(f'models:/{model_name}/production')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4e5bae06", + "metadata": {}, + "outputs": [], + "source": [ + "model.predict(ordered_data)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cd1bfaa0", + "metadata": {}, + "outputs": [], + "source": [ + "# Download pkl file from mlflow\n", + "model_uri = f'models:/{model_name}/production'\n", + "model_path = mlflow.artifacts.download_artifacts(model_uri, dst_path='./backup-model')\n", + "\n", + "print(model_path)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0ef9c913", + "metadata": {}, + "outputs": [], + "source": [ + "file_path = f'{model_path}/artifacts/xgboost_model.pkl'\n", + "\n", + "# Verificar os primeiros bytes do arquivo\n", + "with open(file_path, 'rb') as f:\n", + " first_bytes = f.read(10)\n", + " print(first_bytes)" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "6bbb8cf7", + "metadata": {}, + "outputs": [], + "source": [ + "from pandas import DataFrame, to_datetime\n", + "from pandas import DataFrame, to_datetime\n", + "from sientia_do.temporal.activities.postgres import Postgres\n", + "from unittest.mock import MagicMock, AsyncMock\n", + "import matplotlib.pyplot as plt\n", + "\n", + "postgres = Postgres(\n", + " host=\"localhost\",\n", + " port=5432,\n", + " dbname=\"sientia\",\n", + " user=\"sientia\",\n", + " password=\"sientia\",\n", + " min_connections=1,\n", + " max_connections=10,\n", + " logger=MagicMock(),\n", + " notification_handler=MagicMock(),\n", + " metrics_controller=AsyncMock()\n", + ")\n" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "74378ac4", + "metadata": {}, + "outputs": [], + "source": [ + "from pandas import DataFrame, merge\n", + "\n", + "async def load_data(period_hours, model_id, variable):\n", + " query = f\"\"\"\n", + " select p.prediction, ld.variable, ld.value, p.\"timestamp\" from sientia_data.predictions p\n", + " join sientia_data.laborious_data ld on p.\"timestamp\" = ld.\"timestamp\"\n", + " where p.model_id = '{model_id}' and variable in ('{variable}', 'CI-W3X21IN')\n", + " and ld.model_id = '4' and p.\"timestamp\" >= NOW() - INTERVAL '{period_hours} HOUR'\n", + " order by p.created_at;\n", + " \"\"\"\n", + "\n", + " data = DataFrame(await postgres.load_custom_query(\n", + " {\n", + " \"query\": query,\n", + " \"metadata\": {},\n", + " \"datetime_columns\": [\"timestamp\"],\n", + " }\n", + " ))\n", + "\n", + " data.sort_values(by='timestamp', inplace=True)\n", + "\n", + " # print(data.head())\n", + "\n", + " data.drop_duplicates(subset=['timestamp', 'prediction', 'variable'], inplace=True, keep='first')\n", + "\n", + " data = data.pivot(index=['timestamp', 'prediction'], columns='variable', values='value').reset_index()\n", + "\n", + " # print(data.head())\n", + "\n", + " data.rename(columns={variable: 'value', 'CI-W3X21IN': 'sensor_in'}, inplace=True)\n", + "\n", + " data.sort_values(by='timestamp', inplace=True)\n", + "\n", + " data['timestamp'] = to_datetime(data['timestamp'])\n", + "\n", + " return data\n", + "\n", + "async def load_transformed_data(period_hours, model_id, variable):\n", + " query = f\"\"\"\n", + " WITH original_data AS (\n", + " SELECT * \n", + " FROM sientia_data.laborious_data \n", + " WHERE model_id = '4' \n", + " AND \\\"timestamp\\\" >= NOW() - INTERVAL '{period_hours} hours'\n", + " AND variable in ('{variable}', 'CI-W3X21IN')\n", + " ),\n", + " ci1_with_prev AS (\n", + " SELECT \n", + " *,\n", + " (\n", + " SELECT value\n", + " FROM sientia_data.laborious_data\n", + " WHERE model_id = '4' \n", + " AND variable = '{variable}'\n", + " AND \\\"timestamp\\\" < od.\\\"timestamp\\\"\n", + " ORDER BY \\\"timestamp\\\" DESC\n", + " LIMIT 1\n", + " ) as prev_value\n", + " FROM original_data od\n", + " WHERE variable = '{variable}'\n", + " ),\n", + " ci1_with_predictions AS (\n", + " SELECT \n", + " ci.*,\n", + " p.prediction as current_prediction,\n", + " (\n", + " SELECT prediction\n", + " FROM sientia_data.predictions\n", + " WHERE model_id = '{model_id}'\n", + " AND \\\"timestamp\\\" < ci.\\\"timestamp\\\"\n", + " ORDER BY \\\"timestamp\\\" DESC\n", + " LIMIT 1\n", + " ) as prev_prediction\n", + " FROM ci1_with_prev ci\n", + " LEFT JOIN sientia_data.predictions p \n", + " ON p.model_id = '{model_id}' \n", + " AND p.\\\"timestamp\\\" = ci.\\\"timestamp\\\"\n", + " )\n", + " SELECT * \n", + " FROM original_data\n", + " UNION ALL\n", + " SELECT\n", + " model_id,\n", + " variable || '_AR' as variable,\n", + " COALESCE(prev_value, 0) as value,\n", + " \\\"timestamp\\\",\n", + " created_at\n", + " FROM ci1_with_prev\n", + " UNION ALL\n", + " SELECT\n", + " model_id,\n", + " variable || '_PRa' as variable,\n", + " COALESCE(current_prediction, 0) as value,\n", + " \\\"timestamp\\\",\n", + " created_at\n", + " FROM ci1_with_predictions\n", + " UNION ALL\n", + " SELECT\n", + " model_id,\n", + " variable || '_PR' as variable,\n", + " COALESCE(prev_prediction, 0) as value,\n", + " \\\"timestamp\\\",\n", + " created_at\n", + " FROM ci1_with_predictions\n", + " ORDER BY \\\"timestamp\\\" DESC, variable;\n", + "\n", + " \"\"\"\n", + "\n", + " data = DataFrame(await postgres.load_custom_query(\n", + " {\n", + " \"query\": query,\n", + " \"metadata\": {},\n", + " \"datetime_columns\": [\"timestamp\"],\n", + " }\n", + " ))\n", + "\n", + " data.sort_values(by='timestamp', inplace=True)\n", + "\n", + " data.drop_duplicates(subset=['timestamp', 'variable'], inplace=True, keep='first')\n", + "\n", + " data = data.pivot(index=['timestamp'], columns='variable', values='value').reset_index()\n", + "\n", + " data.rename(columns={\n", + " 'CI-W3X21IN': 'sensor_in',\n", + " variable: 'current_value',\n", + " variable + '_AR': 'previous_value',\n", + " variable + '_PRa': 'current_prediction',\n", + " variable + '_PR': 'previous_prediction'\n", + " }, inplace=True)\n", + "\n", + "\n", + " query_transformed = f\"\"\"\n", + " select td.\"timestamp\", td.value, td.variable from sientia_data.transformed_data td\n", + " where\n", + " td.model_id = '{model_id}' and\n", + " td.variable = '{variable}_AR' and\n", + " td.\"timestamp\" >= NOW() - INTERVAL '{period_hours} hours';\n", + " \"\"\"\n", + "\n", + " transformed_data = DataFrame(await postgres.load_custom_query(\n", + " {\n", + " \"query\": query_transformed,\n", + " \"metadata\": {},\n", + " \"datetime_columns\": [\"timestamp\"],\n", + " }\n", + " ))\n", + "\n", + " transformed_data.sort_values(by='timestamp', inplace=True)\n", + "\n", + " transformed_data.drop_duplicates(subset=['timestamp', 'variable'], inplace=True, keep='first')\n", + "\n", + " transformed_data = transformed_data.pivot(index=['timestamp'], columns='variable', values='value').reset_index()\n", + "\n", + " transformed_data.rename(columns={variable + '_AR': 'selected_value'}, inplace=True)\n", + "\n", + " data = merge(\n", + " data,\n", + " transformed_data,\n", + " left_on='timestamp',\n", + " right_on='timestamp',\n", + " how='left'\n", + " )\n", + "\n", + "\n", + " data['timestamp'] = to_datetime(data['timestamp'])\n", + "\n", + " return data\n", + "\n", + "# display(await load_transformed_data(1, '4', 'CI-W3W01A3'))\n", + "\n", + "def plot_data(data: dict[str, DataFrame]):\n", + " size = len(data)\n", + "\n", + " plt.figure(figsize=(10, size * 5))\n", + "\n", + " i = 1\n", + "\n", + " print(f\"Plotting {size} plots\")\n", + "\n", + " for title, df in data.items():\n", + " print(f\"Plotting {i}: {title}\")\n", + "\n", + " # Eixo principal (esquerdo)\n", + " ax1 = plt.subplot(size, 1, i)\n", + "\n", + " l1, = ax1.plot(df['timestamp'], df['value'], label=\"real\")\n", + " l2, = ax1.plot(df['timestamp'], df['prediction'], label=\"prediction\")\n", + " ax1.set_ylabel(\"value / prediction\")\n", + "\n", + " ax1.set_title(title)\n", + " ax1.set_xlim(\n", + " df['timestamp'].min(),\n", + " df['timestamp'].max()\n", + " )\n", + " ax1.set_ylim(\n", + " min(df['value'].min(), df['prediction'].min()),\n", + " max(df['value'].max(), df['prediction'].max())\n", + " )\n", + "\n", + " # Eixo secundΓ‘rio (direito) SOMENTE para sensor_in\n", + " ax2 = ax1.twinx()\n", + " l3, = ax2.plot(df['timestamp'], df['sensor_in'], label=\"sensor_in\", color='black')\n", + " ax2.set_ylabel(\"sensor_in\")\n", + "\n", + " # Legenda combinada\n", + " ax1.legend(\n", + " handles=[l1, l2, l3],\n", + " loc=\"upper left\"\n", + " )\n", + "\n", + " i += 1\n", + "\n", + " plt.tight_layout()\n", + " plt.show()\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "46340afd", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/home/grezewave/Documents/projects/sientia/sientia-dataops-laborious_temporal/venv/lib/python3.11/site-packages/sientia_do/observability/sientia_monitoring.py:80: RuntimeWarning: coroutine 'AsyncMockMixin._execute_mock_call' was never awaited\n", + " self.metrics_controller.start()\n", + "RuntimeWarning: Enable tracemalloc to get the object allocation traceback\n", + "/home/grezewave/Documents/projects/sientia/sientia-dataops-laborious_temporal/venv/lib/python3.11/site-packages/sientia_do/observability/sientia_monitoring.py:80: RuntimeWarning: coroutine 'AsyncMockMixin._execute_mock_call' was never awaited\n", + " self.metrics_controller.start()\n", + "RuntimeWarning: Enable tracemalloc to get the object allocation traceback\n", + "/home/grezewave/Documents/projects/sientia/sientia-dataops-laborious_temporal/venv/lib/python3.11/site-packages/sientia_do/observability/sientia_monitoring.py:80: RuntimeWarning: coroutine 'AsyncMockMixin._execute_mock_call' was never awaited\n", + " self.metrics_controller.start()\n", + "RuntimeWarning: Enable tracemalloc to get the object allocation traceback\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Plotting 3 plots\n", + "Plotting 1: NOx\n", + "Plotting 2: CO\n", + "Plotting 3: O2\n" + ] + }, + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAA90AAAXRCAYAAACD3P7HAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjgsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvwVt1zgAAAAlwSFlzAAAPYQAAD2EBqD+naQABAABJREFUeJzsnXeYXGXZ/z9n6vaaZDebTgJphBAIiaFIC4SqiIUmIPVVCNJUQGkiioUmRRE0gP5A8QVeREUkhB5KCgQICSG9bLKbzfY+7fz+OGXKzuzO7s6ZOc/M87muubI5c+aZZ+5Tv+duiqqqKhKJRCKRSCQSiUQikUhSjiPTE5BIJBKJRCKRSCQSiSRbkaJbIpFIJBKJRCKRSCQSi5CiWyKRSCQSiUQikUgkEouQolsikUgkEolEIpFIJBKLkKJbIpFIJBKJRCKRSCQSi5CiWyKRSCQSiUQikUgkEouQolsikUgkEolEIpFIJBKLkKJbIpFIJBKJRCKRSCQSi5CiWyKRSCQSiUQikUgkEouQolsikUgkEolEIpFIJBKLkKJbIpFIJBIb8sQTT6AoCnl5edTW1vZ5/5hjjuHAAw+MWub3+3nggQc47LDDKC4upqioiMMOO4wHHngAv9+frqlLJBKJRCKJQIpuiUQikUhsTG9vL7/85S8HXK+zs5MTTjiBq6++murqan75y1/ym9/8hpqaGq6++mpOOOEEOjs70zBjiUQikUgkkUjRLZFIJBKJjTn44IN57LHH2L17d7/rXXfddbz55ps8+OCD/POf/+TKK6/ke9/7Hv/4xz946KGHePPNN/nBD36QpllLJBKJRCIxkKJbIpFIJBIb8+Mf/5hgMNivt3vXrl386U9/4rjjjmPx4sV93r/yyis59thj+eMf/8iuXbsAePzxx1EUhSVLlkSt+4tf/AJFUXjppZdS+0MkEolEIslRpOiWSCQSicTGTJo0iQsuuKBfb/d//vMfgsEgF1xwQcJxLrjgAgKBAC+//DIAF110EaeddhrXXXcdO3fuBODTTz/lpz/9KZdccgmnnHJK6n+MRCKRSCQ5iBTdEolEIpHYnJ/85CcEAgF+9atfxX1/3bp1AMyePTvhGMZ769evN5c99thjOJ1OLrnkEnw+HxdeeCHV1dXce++9KZy9RCKRSCS5jRTdEolEIpHYnP3224/zzz+fRx99lD179vR5v729HYDi4uKEYxjvtbW1mcuqq6t5+OGHWbp0KUcddRRr1qxhyZIllJSUpPgXSCQSiUSSu0jRLZFIJBKJANx8880EAoG4ud2GoDbEdzwSCfOzzz6bU089lRUrVnDZZZdx/PHHp3DWEolEIpFIpOiWSCQSiUQA9ttvP7797W/H9XZPnz4dgE8++STh5433ZsyYEbW8sbGRVatWAVqYeigUSuW0JRKJRCLJeaTolkgkEolEEAxvd2xu98knn4zT6eQvf/lLws/++c9/xuVycdJJJ0Utv/LKK2lvb+euu+7inXfe4f7777di6hKJRCKR5CxSdEskEolEIgiTJ0/m29/+Nn/4wx+oq6szl48bN46LLrqIV199ld///vd9PvfII4/w2muvcckllzB27Fhz+bPPPsszzzzDL3/5S2688UbOPvtsbr75Zr744ou0/B6JRCKRSHIBRVVVNdOTkEgkEolEEs0TTzzBRRddxMqVK5k7d665fNOmTUybNo1gMMjMmTNZu3YtAB0dHZx88sm88847fOUrXzE92v/973/5xz/+wdFHH82///1vCgsLAdi7dy8zZ85k1qxZLFu2DEVRaGxsZObMmey333688847OBzy2bxEIpFIJMNFXk0lEolEIhGIKVOm8O1vf7vP8qKiIpYtW8Z9991HbW0tP/zhD/nBD37Azp07uf/++1m6dKkpuAG+973v0dvby+OPP46iKABUVlby6KOP8t5773H33Xen7TdJJBKJRJLNSE+3RCKRSCQSiUQikUgkFiE93RKJRCKRSCQSiUQikViEFN0SiUQikUgkEolEIpFYhBTdEolEIpFIJBKJRCKRWIQU3RKJRCKRSCQSiUQikViEFN0SiUQikUgkEolEIpFYhCvTExCVQCDARx99RFVVlexjKpFIJBKJRCKRSCRJEAqFqK+vZ86cObhcuSFHc+NXWsBHH33EvHnzMj0NiUQikUgkEolEIhGOFStWcNhhh2V6GmlBiu4hUlVVBWg7y+jRozM8G4lEIrGWL/1imfn32z86FrdLRvhIJBKJRCIZPHv27GHevHmmnsoFpOgeIkZI+ejRoxk7dmyGZyORSCTWMmH8OGpbugFY2+ri9Nk1GZ6RRCKRSCQSkcmlFN3c+aUSiUQiGTK9gaD591/e357BmUgkEolEIpGIhRTdEolEIhmQ9p6A+feKrU3saOzK4GwkEolEIpFIxEGKbolEIpH0iy8QojcQAmBqVTEA727el8kpSSQSiUQikQiDzOm2mGAwiN/vz/Q0JCnC7XbjdDozPQ2JJK109oa93AtnjGJDfTvvbm7k7HnjMzgriUQikUgkEjGQotsiVFWlrq6OlpaWTE9FkmLKysqorq5GUZRMT0UiSQsduujOdzs5av+RPPz6Zt7b0oiqqvI4kEgkEolEIhkAKbotwhDco0aNoqCgQN6YZgGqqtLV1cXevXsBZKs4Sc5giO6iPBdzxpfhdTloaO9lc0MHU0YVZ3h2EolEIpFIJPZGim4LCAaDpuCurKzM9HQkKSQ/Px+AvXv3MmrUKBlqLskJDNFd7HXhdTmZO7Gc5ZsaeXdzoxTdEolEIpFIJAMgC6lZgJHDXVBQkOGZSKzA2K4yV1+SK3T0hD3dAIdPHgHAu5saMzYniUQikUgkElGQottCZEh5diK3qyTXaDfCy72a6J4/qQKAj3Y2Z2xOEolEIpFIJKIgRbdEIpFI+sX0dOuie3yFFu3R0N5LMKRmbF4SiUQikUgkIiBFt0QikUj6paNXS6UwRHdlkReHAiEVGjt7Mzk1iUQikUgkEtsjRbfEUrZt24aiKKxZsybTU5FIJEMkNqfb6VCoKPQCmrdbIpFIJBKJRJIYKbolEolE0i+xOd0AI4ul6JZIJBKJRCJJBim6JQnx+XyZnoJEIrEBsZ5ukKJbIpFIJBKJJFmk6E4DqqrS5Qtk5KWqyRc5OuaYY1i8eDHXXHMNI0aMYNGiRaxdu5aTTz6ZoqIiqqqqOP/889m3b5/5mZdffpkjjzySsrIyKisrOe2009i8ebMVZpRIJBkisk+3wShddO+VolsikUgkEomFvPXWW5x++unU1NSgKAovvPDCgJ954403OOSQQ/B6vUyZMoUnnnjC8nn2h2vgVSTDpdsfZMat/83Id6+7YxEFnuQ385NPPsn3vvc9li9fTktLC8cddxyXXnop9913H93d3dxwww1861vf4rXXXgOgs7OT6667joMOOoiOjg5uvfVWvva1r7FmzRocDvlMRyLJBgzRLT3dEolEIpFI0k1nZyezZ8/m4osv5swzzxxw/a1bt3Lqqafy3e9+l6eeeoply5Zx6aWXMnr0aBYtWpSGGcdBlQyJnTt3qoC6c+fOqOU9PT3q7NmzVafTGfVCcQz55SwZqY654gl13DV/V10VYwf32Zh59PcCVMD8v8PhUBVFibveQOPcdNNNqqqq6i9/+csB18/ka8KECWptba0aCATUI444IuPzGejl8XjUX/ziF6qqqur111+vulyujM8p0Wv//fdXm5ub1fr6enXSpEkZn89Ar+LiYvW5555TVVVVL7vsspSM6XK51MWLF6uqqqpPP/20WlhYmNI5n3/++aqqquo///lPtaSkxDLbKA7tHOaIWOZwassUR+a3nRWvdNk2115WHxO5/HK73epPfvITVVVV9Q9/+IOan5+f8TkN9DrwwAPVrq4udevWrerYsWMzPp9EL4/Ho/7qV79SVVVVf/Ob36herzfjcxroNW/ePNXv96tr165Vq6ur465zwgknqKFQSH3vvffUyspK1el0qpMnT1YbGxutvIW2lFAopH7lK1/JuP0z9Tr55JPVUCikvv3222pFRUXG55Po5XA4VEBdt26d2traar56enoG3MaA+n//93/9rvOjH/1InTlzZtSys846S120aNFwdq9hIUX3EEkkuj/99FNTvKby9fQzf1dfe/MtS8a24jV79mxVVVV14cKFGZ/LQK/nn3/e3J4ivA477DBVVVV14sSJGZ/LQK/XXntN/fe//53xeST7uuSSS1RVVdWysrKUjVldXa2qqqqed955KZ9vXl6eqqqqeuWVV2bcdtn2kra17mXlMZHrr2nTpqmqqqqnnnpqxueS7Gv16tXq008/nfF5DPT60pe+pKqqqh5xxBEZn0uyr82bN6u/+93v+l2nublZvfPOO6OWvfLKKxbeQVtLT09Pxu2e6Vd7e7t6++23Z3weQ3nddtttA25jGFh0H3XUUerVV18dtWzJkiVqSUnJ0HeuYSLDy1NMKBQCoLy8nFWrVpGXlzes8b72ta+xYsUKPE4HHqcWrj1hwgTefffdYc81lq9//evMnDmTO+64A4DzzjuP/Px8fvKTn/RZt6qqioKCAr785S8zZswYrrjiCqqrq3n11Ve58847TTsY/1577bX84Ac/SPmch8MZZ5zBypUrUVXVnKfH42Hr1q39fq6np4edO3cybty4YW/fwfDqq69y4YUXmnn6xpz//e9/c/DBB6dtHslw7LHH8sUXX0TZdvbs2bz00ksZnll8HnzwQX75y1/2se3bb7/NfvvtN6Qx161bxwknnNBnzNtvv53LLrts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lZXbmgkEk/faSNtah7StdYhmW+nptg4ZXm7/bWQFou2nyeLxeDj00ENZtmyZuSwUCrFs2TIWLFgQ9zNHHHEEmzZtijq/fPHFF4wePRqPx2P5nOMxNBcL8Mknn5h/r1u3LiqmPhgM8vLLL2e0LHumsOJgVxQFVVXjhkAb4eWm6G7Vw7xHHwQHnxt3PK/LydTqYtbWtvG5c38WgObpVtW+edTtiUW3UURtXl4tBGF9aAKlE76C4rwH8IdzTZPydGuie4Rfr1xucY9uEEF0RxyegR5w5/V9Wq1EFlIzcrqLInK6DdFtfU63PzKn26E9MBChKq2BFblQ/Y7pGF4hDxFztyzH4dDSYEbNhB3vsl9oByV55bT1BFi3p42DxpYlNYy0rXVI21qHaLaNnW/kMrshum37E92yenn2ELtN7R6RMRiuu+46LrzwQubOncu8efO4//776ezs5KKLLgLgggsuYMyYMWZO+Pe+9z0eeughrr76aq666io2btzIL37xC77//e9n7DcMeWscfPDBzJkzB0VROO644zj44IPN16GHHsqdd97Jrbfemsq5AtqOdMsttzBp0iTy8/OZPHkyP/vZz6JODqqqcuuttzJ69Gjy8/NZuHAhGzdG96JuamrivPPOo6SkhLKyMi655BI6Ogb2yA6EVaI70f97Y/t0t+o9rkvH9jvmgTVaiPn7XWNAcWph6IZX28DfA116WKYuuvd+9BI97z0GHz1FnR7mcaBLE/rr1fHUuiagHPQtAIJGITVfhF13fwSNm7W/fV2w4WUIBtjT1oObAEXde7T30hBePlCRukziiiykBgk93Qrxq5e79BNtIJ053SEVV0xOd9RcbWLbRPR3nNlpzHSMLTx6/QlHwzrmTaoEBhdiLm1rHdK21iGabfsTgnZDdNsmY+tY0S16Trfdt5EViLafDoazzjqLu+++m1tvvZWDDz6YNWvW8PLLL5vF1Xbs2MGePXvM9ceNG8d///tfVq5cyUEHHcT3v/99rr76am688cZM/YShe7q3bt2Kqqrst99+rFixgpEjwy1wPB4Po0aNsqQk+69+9St+//vf8+STTzJz5kxWrVrFRRddRGlpqfn04te//jUPPPAATz75JJMmTeKWW25h0aJFrFu3jry8PADOO+889uzZw9KlS/H7/Vx00UVcfvnlPP3008OanxWi2+FwJAy/Cud0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R/v27et5mPv378+uXUrhBs0whYWFIdsUFhYGXsvLy2P//v0hr3u9XoqKiuoZtiUEwlpaqWWYltNNoGWYMQ4us2VYbLDblLlV+O2B59wpoftnY/uaJtrdOuV0f7t6Hxkonve8vPrpJKK0gtEwW4aZaOhp29PG9GWDrFyIjrJsYlTXNkHtwqL3dANgc8C4Pyt/t+sbmzFjhLnf6oeIthXlPBGvtg23TTSiW5IkLtYKqh2Bnt3Bnm6PuwavXyYJt/KiPbqWfKIQz6L7+eefp1u3brhcLsaOHcvixYub9b4PPvgASZI466yz9J1gE0S9agTnOWdkZJCdnc0JJ5zAo48+yp133hmLOYYwYcIENm7cGPLcpk2b6NpV6RHYvXt38vLymDVrVuD10tJSFi1aFIjFHz9+PMXFxSxbtiywzc8//4zf72fs2Mh7fLZ2Tne9lmEGDS838snJ0Dndanj4Vjoz3zKCd7xT2D82NIyysX1NC093+9VtYii6q9w+arx+2khKLqozvX5LItEWftPTbaKhp21PHtielVJ/AEZbNjKyaxvYv055sU23mH0OR90Kl34Gx8T+PBwN5n6rHyLaVpTzRLzatrGc7pYWUtM4d2QnHFYLq/NLWKYWi2wttDlbJKipUTzcSdQoLyaI6DayMykaPvzwQ6ZNm8a9997L8uXLGTp0KCeddFI9J2pdduzYwV//+tdA668jiW4qIzc3t544jgW33XYbCxcu5KGHHmLLli28//77vPzyy9x4442AsoP95S9/4cEHH+Srr75i9erVXH755XTo0CFwh6N///6cfPLJXHfddSxevJj58+dz0003ceGFF9KhQ4eI53bk+nQbu5CakQ96I5/wNU93ld/KrZa7+If3aqzOpJBtGpuztW71cl/sRHdxlXLnuK2ktiRKalNvGyPbNhym6DbR0NO2SQ4rUlfl5u5E61r6p1aooluKbfVtiwV6TY68QJlOmPutfohoW1HOE/Fq21iHl4OSRnPWcOVa+tVft0U0RqQEh5fX1ChiO9Wiim5HYohuUY6plvLkk09y3XXXcdVVVzFgwABefPFFkpOTef311xt8j8/n45JLLuGf//wnPXr0aMXZhifqnO5Vq1aFPJZlmX379vHII48wbNiwaIevx+jRo/n888+ZPn06999/P927d+fpp5/mkksuCWxz5513UlFRwfXXX09x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vUZChH2l0LRpU0RERKB379548MEHcc899wBASOyr+t5Pw4YNw5dffoklS5bg5ZdfxooVKzB8+HDYbDbnOnxOVUaE/aQQyucTUL/76vTp0ygoKMD06dMxbNgwLFq0CDfeeCNGjhyJFStWONdr3Lgx0tPTne+9nVPKZ1WtEyz7SoT9pDBgwABYrVa0adMGAwcOxP/93/85Pwv2/QTU//VPyxdffIHY2FiMHDnSZTn3U5URYT8piN5PhQW6AUzw8+CDD2Lnzp34448/XJb/4x//QE5ODn7//Xc0bNgQ8+bNw6233opVq1ahS5cutfqtzZs346233sKWLVsgSZLX9b788stabT/YEWlfrVq1CgUFBVi3bh2efvpptG7dGqNHj65VW0SjPvcTANx2223O1126dEHXrl3RqlUrLF++HFdccQUAPqc8IdJ+CuXzCajffWW32wEA119/PR577DEAQPfu3bFmzRp88MEHTu/NtGnT6vCPghOR9tOcOXOQn5+PP//8E5MnT8Zrr72GJ598slZtEZH6vv5pmTlzJsaMGQOr1eqynPupyoi0n4TvpwId384ENw8++KDctGlT+dChQy7LDxw4IAOQd+7c6bL8iiuu8DmnCR7yRd58801ZkiTZbDY7HwBkk8kkN2/e3Ou2Bg4cKD/yyCMuy2bOnCnHxcXJsizLpaWlstlsrvR748aNk6+77jqf2mt0RNlXnnjppZfktm3byrIc/PuqvveTNxo2bCh/8MEHXj8P9XNKlP3kiVA6n2S5/vdVaWmpHBYWJr/00ksuy5988kl5wIABXrc1duzYSjn7S5culQE4C+Y1a9asUl2MKVOmyF27dvWpvUZGlP3kia+++kqOjIyUKyoqZFkO7v0ky4G9/q1cuVIGIG/btq3abXE/JcZ+8oSI/RSHlzO6IMsyJk6ciB9//BFLly5FRkaGy+dFRUUAKOdCi9lsdo4u14axY8di+/bt2LZtm/ORlpaGyZMnY+HChV6/179/fyxZssRl2eLFi9G/f38AgMViQa9evVzWsdvtWLJkiXMdURFtX3nCbrejtLQUQPDuq0DtJ08cP34c586d8xj+pRCq55Ro+8kToXA+AYHbVxaLBX369Kk0Rc++ffvQvHlzr9/r378/Vq5c6TL11OLFi9GuXTs0aNDAuU5V552IiLafPGG321FeXu5sTzDuJ8AY179P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/urUVxClO1DR7TwNJETp9iPe9QEDPaZbFxdoyV5uHTplpXUh2csFFyJb6xDZWodustWpvzr1FSR7eaCim75wKrz66qukpaURFhbGgAEDWLZs2Qn3/+yzz+jYsSNhYWF069aNH374oZZ6WjF6zByaUBsx3bpcRLoohjrGdOsyoYql27o2a6PtQEdkax0iW+vQTbY69VenvoJ/Yrp1TaSmy/2cFeimL1SV6dOnM2nSJB599FFWrFhBjx49GDlyJBkZGRXuv2jRIq6++mquv/56Vq5cyejRoxk9ejTr1q2r5Z57CKqzb24g/Pe//yUmJgaAgoICwBnTjX+fDjbEyb4+IIqhdYhsrWuzNtoOdES21iGytQ7dZKtTf3XqK/jHvfzVV18lISHBoh76n9zcXECfMbIC3c7TqvL8889z4403ct111wEwZcoUZsyYwdSpU3nggQfK7f/f//6XUaNGce+99wLw2GOPMWvWLF555RWmTJlSq313IUp3DXnuuefKbYuIiMCRp177y9IdGRnp87++o0t/DcMgPDycwsJCwsLC6ro7VUIX2YaGhmK3291/OhAVFQX4V7ZWtFkbbQc6Ilvr0GUO0xHdZBsREQHo0V/dZFuV/lY2z0VGRpKZmclLL71kXQctRJcxsgLdztOcnByys7Pd70NDQwkNDfXZp6ioiOXLl/Pggw+6t9lsNkaMGMHixYsrbHfx4sVMmjTJZ9vIkSP5+uuv/df5U0SU7hoyduxY96QF0LNnT5o3b87mTcrdwV8eLq+99hqrV6+mc+fO/mnQYu644w6io6PdT6TqMx999BE5OTnExsbWdVeqxP3330+LFi248sor67orJyQiIoL333+f4OBggoL0mGoeffRRevTowQUXXOC3NseMGcOePXu44oor/Nami5EjR/LEE09w/vnn+73tQGfUqFE88cQTfj0XBIWV10SgM27cOLKzsxk/fnxdd6VKJCYm8tZbb5GUlFTXXTkpN998MzabjRtuuKGuu1Il2rVrx0svvUSnTp0q3adv3748++yzDBo0yGf722+/XafKSU3o2rUrrVq1qutu1Bmpqam89tprpKWl1XVXqkRZvebRRx/ln//8p8+2w4cPU1paWm6eSEpKYuPGjRW2m56eXuH+6enpNe90NTFM3bIk1BP27t1Lamoqe/bsISUlpdzn8zZmcN17v9OteSzf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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "period_hours = 4\n", + "\n", + "plot_data({\n", + " 'NOx': await load_data(period_hours, '4', 'CI-W3W01A3'),\n", + " 'CO': await load_data(period_hours, '8', 'CI-W3W01A1'),\n", + " 'O2': await load_data(period_hours, '5', 'CI-W3W01A2')\n", + "})" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "6137c2f7", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/home/grezewave/Documents/projects/sientia/sientia-dataops-laborious_temporal/venv/lib/python3.11/site-packages/sientia_do/observability/sientia_monitoring.py:80: RuntimeWarning: coroutine 'AsyncMockMixin._execute_mock_call' was never awaited\n", + " self.metrics_controller.start()\n", + "RuntimeWarning: Enable tracemalloc to get the object allocation traceback\n", + "/home/grezewave/Documents/projects/sientia/sientia-dataops-laborious_temporal/venv/lib/python3.11/site-packages/sientia_do/observability/sientia_monitoring.py:80: RuntimeWarning: coroutine 'AsyncMockMixin._execute_mock_call' was never awaited\n", + " self.metrics_controller.start()\n", + "RuntimeWarning: Enable tracemalloc to get the object allocation traceback\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
variabletimestampcurrent_valueprevious_valueprevious_predictioncurrent_predictionsensor_inselected_value
02026-01-27 07:40:59+00:00529.374900524.902649523.069031508.5605471.0524.902649
12026-01-27 07:41:29+00:00585.815900529.374900508.560547513.4113161.0529.374900
22026-01-27 07:41:59+00:00624.189453585.815900513.411316560.6661991.0585.815900
32026-01-27 07:42:29+00:00617.834000624.189453560.666199599.8107301.0624.189453
42026-01-27 07:42:59+00:00575.486450617.834000599.810730591.3146361.0617.834000
........................
3072026-01-27 10:33:59+00:00710.051900793.385254688.647400744.0227051.0793.385254
3082026-01-27 10:35:29+00:00763.943000710.051900744.0227050.0000001.0NaN
3092026-01-27 10:35:59+00:00710.051900763.943000744.022705716.5969241.0763.943000
3102026-01-27 10:36:29+00:00705.572100710.051900716.596924672.2628781.0710.051900
3112026-01-27 10:37:59+00:00766.472168705.572100672.262878672.2576291.0705.572100
\n", + "

312 rows Γ— 7 columns

\n", + "
" + ], + "text/plain": [ + "variable timestamp current_value previous_value \\\n", + "0 2026-01-27 07:40:59+00:00 529.374900 524.902649 \n", + "1 2026-01-27 07:41:29+00:00 585.815900 529.374900 \n", + "2 2026-01-27 07:41:59+00:00 624.189453 585.815900 \n", + "3 2026-01-27 07:42:29+00:00 617.834000 624.189453 \n", + "4 2026-01-27 07:42:59+00:00 575.486450 617.834000 \n", + ".. ... ... ... \n", + "307 2026-01-27 10:33:59+00:00 710.051900 793.385254 \n", + "308 2026-01-27 10:35:29+00:00 763.943000 710.051900 \n", + "309 2026-01-27 10:35:59+00:00 710.051900 763.943000 \n", + "310 2026-01-27 10:36:29+00:00 705.572100 710.051900 \n", + "311 2026-01-27 10:37:59+00:00 766.472168 705.572100 \n", + "\n", + "variable previous_prediction current_prediction sensor_in selected_value \n", + "0 523.069031 508.560547 1.0 524.902649 \n", + "1 508.560547 513.411316 1.0 529.374900 \n", + "2 513.411316 560.666199 1.0 585.815900 \n", + "3 560.666199 599.810730 1.0 624.189453 \n", + "4 599.810730 591.314636 1.0 617.834000 \n", + ".. ... ... ... ... \n", + "307 688.647400 744.022705 1.0 793.385254 \n", + "308 744.022705 0.000000 1.0 NaN \n", + "309 744.022705 716.596924 1.0 763.943000 \n", + "310 716.596924 672.262878 1.0 710.051900 \n", + "311 672.262878 672.257629 1.0 705.572100 \n", + "\n", + "[312 rows x 7 columns]" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/png": "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eLY8bBI+jMOrzpZf/Ssd/kIf74NDHYGyVQCOitM+hqK8yi34dag7L0a1fQLNWF3ifSl/D/eTcsWQTQA3jKvsOkvBpRuAcENZizz44IM47bTT8Oabb2Lx4sU488wzMXHiRJxxxhkAgF//+te48sorMX/+fADAihUrcMQRR+C6667Dfffdh82bNwvCdv/992Pu3Lm48cYbsWLFCkydOhUA8PHHH+ODDz7AE0884TmGiy66CE888QQefPBBTJo0CQsWLMCcOXOwfPnyvC113Jg7dy522vcQrAfQNqwVP//5zxEKhUpaNpFI4Prrr8epFyzAPe8nkM3qaO/oAGLDwI+S5cuX44knnsDf/vY3qKoKADj75B8iFFVx52N3oqmpCc//+V847LDDsGzZMrS1teEvf/kLrrrqKvz2t7/FgQceiD/+8Y+47bbbsP322+cdyyWXXIK7774bx19+F57fBBz0ta9hdHQNtoq0RBP/+te/sNtuu+VVGUvdn5dddhluuukmjBw5Ej/60Y9w6qmn4tVXX3WuzK2glHkuJ7Jpx+f2/s155iwOfm0xTMfQagpnApCfChvfF/a1y/CrzhaDH9GMLxICZasA5PRbv3qXAYDC/kqmafmeoi0/cdXSQ2CaJkypCbUFo/hCHr6HLxsmTJiAW265BTvvvDPmzp2Lc845B7fccov4/tBDD8VPf/pTTJ06FVOnTsWNN96IuXPn4vzzz8eOO+6I/fffH7fddhseeughpFIp7Lbbbpg+fToeecRWMB5++GHMnDkTO+ywQ872+/v7ceedd+JXv/oVjjzySOy66664++67EYvFcO+995b8O9asWYOZX/0qAEDTNPznf/4npk+fXtKy2WwWd9xxhyCHe+41A+k0JQn8eM5kMnjooYcwY8YM7LHHHnjllVfw/jvv4eZ7b8ZX9vwKJk2dhBsX/BKtra14/PHHAQC33norTjvtNJx22mnYeeedcd1112HXXXfNO47e3l785je/wYIFC7DvPnsDAEaOHIXTTz/dJjuWheHDh2PMmDGeRLSc/Xn99dfj4IMPxq677oqf//zneO2113KUN/77B3oj7884yVZST5S1vAy3suUXqeBj8NM7Vqhdj1/NuU2rPrJWhyrKVrZ6e3txxRVX4O9//zs2bdqEGTNm4De/+Q323XdfAPQmPH/+fNx9993o6urCAQccgDvvvBM77rijWEdnZyfOOecc/OMf/4CiKDj++OPxm9/8Bo2NxVuw1BJ+Vkt3jYSOQ5ri14XIL0m5va8LRNGlKcXDiFXbR6E4VZlqjVC87EW++tWvOv4+s2bNwk033QTDoGR1n332ccz//vvv44MPPsDDDz8splmWBdM0sXLlSkybNg1z587FfffdJ0KSjz76KC644ALP7a9YsQLZbBYHHHCA/TNCIey333745JNPSv4dF1xwAa6++hqMOukWJPr7HMpaMWiahn333RcrF68DADQ2NEJh6hXfN5MmTcLIkSMd+yHR348DdrLHrRAFyWQSK1asAAB88skn+NGPfuTY1qxZs/DCCy94juOTTz5BOp3GYYcdhoXrnB4tm2sVPq7L2Z977LGHeD927FgAwKZNmzBx4kQxPccnVXDruUjpGcfnpO7dFLwUiJu66aMxXbGvtbZyXx9hRL8K3yrSNd9PO81QR9lk6/TTT8dHH32EP/7xjxg3bhz+9Kc/Yfbs2ViyZAnGjx+PBQsW4LbbbsODDz6IKVOm4IorrsCcOXOwZMkS4YWYO3cuNm7ciOeffx7ZbBannHIKzjzzTMfTcn1Azl6qj2xEMc2Hccio9T5ZsbXdOaGEit7yuCzLqtwFgpCyw3n1ioYG5+/o6+vDD3/4Q5x77rk58/Kb9IknnoiLL74Y77zzDpLJJNauXYvvfOc7Ax6Doig5od5sNuv4fNVVV2Hfb3wbZz+1Fpl0Crvuuisee+wxHHfccSVvx+nhI9L/3vth5OhRuP/J+8S08Y0TEQ9HcvxlpSIWi+VM47+aEwyUUNKkVMhhVrv9i3P97hID5Z4iyayTbKWNVJ45i4OHEf1U7719urUfByd9srLlX50ttn35mu/3DWgIoqwwYjKZxBNPPIEFCxbga1/7GnbYYQdcddVV2GGHHXDnnXfCsizceuutuPzyy3HMMcdgjz32wEMPPYQNGzbgySefBECf7p599lncc889mDlzJg488EDcfvvteOyxx7Bhgw9qQQF4FvD08QLgV9d3x1h8UvtWd3c4PpdT1BT48mYkvvHGG47Pr7/+OnbccUfhS3Jjr732wpIlS7DDDjvk/OMeou222w4HH3wwHn74YTz88MM4/PDDMWrUKM/1TZ06FeFw2OEVymazeOutt0TIbeTIkejt7RUmfAB47733ctY1gZG90aNG4dvf/jbuv//+kvaBrutYvHixuJn39fXBZKQm37G71157YcumzVA1FRO3n4iJ20/ElKnbY4cddsCIESMAANOmTfPcv/mw4447IhaLYeHChblFTSWDPFcdvVDK/iwH9rWFfy7vZE66PFuDIls5Bvnaw9E1hE/zxSBff9mIDgJa0xF8MVAW2dJ1HYZhCIWKIxaL4ZVXXsHKlSvR3t6O2bNni+9aWlowc+ZMLFq0CACwaNEitLa2OsIXs2fPhqIoORcujnQ6jZ6eHvGvt7fXc75Kw5bY/c1MkdORxbSaj4Jv159snXU9rMaWwdWBMoqa4stb13TNmjW44IILsHTpUjz66KO4/fbbcd555+Wd/+KLL8Zrr72GefPm4b333sNnn32G//mf/8G8efMc882dOxePPfYY/vrXv2Lu3Ll519fQ0ICzzjoLF154IZ599lksWbIEZ5xxBhKJBE477TQAwMyZMxGPx3HppZdixYoVeOSRR/DAAw+IdSSTScybNw+LF78NAMik03jrrbcwbdq0kvZBKBTCOeecg89Xfg4AeP/9DxAO02tYvmN39uzZ2HPfvXDuSefi1Rdexfo167Hotddw2WWXiSzI8847D/fddx/uv/9+LFu2DPPnz8fHH3+cdxzRaBQXX3wxLrroIixm2YNbtmzBvffeK26kmqri2WefRUdHB7q7uwe0P8uB+9pStrLlCiNmzMGHEe2x+JiN6Ley5eE39a/OlnTND8KIA0ZZZKupqQmzZs3Ctddeiw0bNsAwDPzpT3/CokWLsHHjRrS301DP6NGjHcuNHj1afNfe3p7zFKxpGtra2sQ8btx4441oaWkR/wbyBDcQOJURHzNT3PK6T+OQt+uoVVSDsbT3bwEAaFYrm1J66QegChmJQwQnnXQSkskk9ttvP5x99tk477zzRIkHL+yxxx546aWXsGzZMhx00EGYMWMGrrzySowb58y+POGEE7B161YkEomi1c1/8Ytf4Pjjj8f3v/997LXXXli+fDmee+45DBs2DADQ1taGP/3pT3jmmWew++6749FHH8VVV10llldVFVu3bsVVV9PSEps2deDII4/E1VdfXdI+iMfjuPjii/H7u+6i69M0DB9Ozef5jl1CCO5+9AHsPWtvXHHuFTjqq0fh1JNOxurVq8X17Tvf+Q6uuOIKXHTRRdh7772xevVqnHXWWQXHcsUVV+CnP/0p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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "transformed_data = await load_transformed_data(4, '4', 'CI-W3W01A3')\n", + "\n", + "display(transformed_data)\n", + "\n", + "ax1 = plt.subplot(1, 1, 1)\n", + "l1, = ax1.plot(transformed_data['timestamp'], transformed_data['previous_value'])\n", + "l2, = ax1.plot(transformed_data['timestamp'], transformed_data['previous_prediction'])\n", + "l3, = ax1.plot(transformed_data['timestamp'], transformed_data['selected_value'])\n", + "\n", + "ax1.set_xlim(\n", + " transformed_data['timestamp'].min(),\n", + " transformed_data['timestamp'].max()\n", + ")\n", + "\n", + "ax1.set_ylim(\n", + " transformed_data['previous_value'].min(),\n", + " transformed_data['previous_value'].max()\n", + ")\n", + "\n", + "ax2 = ax1.twinx()\n", + "l3, = ax2.plot(transformed_data['timestamp'], transformed_data['sensor_in'])\n", + "ax2.set_ylabel(\"sensor_in\")\n", + "\n", + "ax1.legend([l1, l2, l3], ['previous_value', 'previous_prediction', 'sensor_in'])\n", + "\n", + "plt.show()\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.14" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..7671dce --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,59 @@ +import os +import sys +from unittest.mock import MagicMock + +# The production code converts SIENTIA_MINIO_OFFLOAD_THRESHOLD_MEGABYTES to int at import-time. +# Tests must set it to a valid integer string to avoid import errors. +os.environ.setdefault('SIENTIA_MINIO_OFFLOAD_THRESHOLD_MEGABYTES', '1') + + +class DummyMinioDataFramePayload: + """ + Minimal payload double used by unit tests. + + The production workflow/gates expect a MinioDataFramePayload-like object with: + - async retrieve(minio_repo, workflow_metadata) -> DataFrame | dict + - has_data() -> bool + - cleanup_prefix() -> str | None + - last_timestamp: attribute + - status: attribute + """ + + def __init__( + self, + *, + retrieve_return=None, + has_data: bool = True, + cleanup_prefix: str | None = None, + last_timestamp: str = '2024-01-01', + status: dict | None = None, + ): + self._retrieve_return = retrieve_return + self._has_data = has_data + self._cleanup_prefix = cleanup_prefix + self.last_timestamp = last_timestamp + self.status = status + + async def retrieve(self, _minio_repo, _workflow_metadata=None): + return self._retrieve_return + + def has_data(self) -> bool: + return self._has_data + + def cleanup_prefix(self) -> str | None: + return self._cleanup_prefix + + +""" +Pytest configuration file with global mocks for external dependencies. + +This module mocks the 'sientia' module to avoid requiring its installation +during unit tests. The mock is registered in sys.modules before any test +imports are executed. +""" + +# Mock sientia module +sientia_mock = MagicMock() +sientia_mock.ModelAnalysis = MagicMock +sys.modules['sientia'] = sientia_mock +sys.modules['sientia.ModelAnalysis'] = MagicMock() diff --git a/tests/laborious/__init__.py b/tests/laborious/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/laborious/activities/__init__.py b/tests/laborious/activities/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/laborious/activities/test_activities.py b/tests/laborious/activities/test_activities.py new file mode 100644 index 0000000..8e6cd78 --- /dev/null +++ b/tests/laborious/activities/test_activities.py @@ -0,0 +1,229 @@ +from unittest.mock import ANY, AsyncMock, MagicMock, patch + +from pytest import mark + +from laborious.activities.activities import Activities +from laborious.activities.api import API +from laborious.activities.gates import Gates +from laborious.activities.mlflow import MLFlow +from laborious.activities.model_metrics import ModelMetrics +from laborious.activities.opc import OPC +from laborious.activities.storage import Storage + + +@patch('laborious.activities.activities.Storage.__init__') +@patch('laborious.activities.activities.MLFlow.__init__') +@patch('laborious.activities.activities.OPC.__init__') +@patch('laborious.activities.activities.Gates.__init__') +@patch('laborious.activities.activities.ModelMetrics.__init__') +@patch('laborious.activities.activities.API.__init__') +@patch('laborious.activities.activities.MinioRepository') +@patch('laborious.activities.activities.MetricsController') +def test___init__( + mock_metrics_controller, + mock_minio_repository, + mock_api_init, + mock_model_metrics_init, + mock_gates_init, + mock_opc_init, + mock_mlflow_init, + mock_storage_init, +): + postgres_config = { + 'host': 'localhost', + 'port': 5432, + 'user': 'postgres', + 'password': 'postgres', + 'dbname': 'postgres', + 'min_connections': 1, + 'max_connections': 10, + } + + minio_config = { + 'endpoint_url': 'localhost:9000', + 'access_key': 'minio', + 'secret_key': 'minio123', + 'default_bucket': 'test', + 'retention_hours': 24, + 'secure': False, + } + + mlflow_config = {'host': 'localhost', 'port': 5000, 'username': 'mlflow', 'password': 'mlflow'} + + opc_config = { + 'bootstrap_servers': 'localhost:9092', + 'polling_time': 1000, + 'group_id': 'test-group', + } + + pi_web_api_config = { + 'base_url': 'https://test-pi-server.com', + 'auth_type': 'bearer', + 'auth_token': 'test_token', + } + + logger = MagicMock() + notification_handler = MagicMock() + + activities = Activities( + postgres_config=postgres_config, + mlflow_config=mlflow_config, + minio_config=minio_config, + opc_config=opc_config, + pi_web_api_config=pi_web_api_config, + logger=logger, + notification_handler=notification_handler, + ) + + assert isinstance(activities, Activities) + assert isinstance(activities, Storage) + assert isinstance(activities, MLFlow) + assert isinstance(activities, OPC) + assert isinstance(activities, Gates) + assert isinstance(activities, ModelMetrics) + assert isinstance(activities, API) + + mock_storage_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'], + retention_hours=minio_config['retention_hours'], + minio_repository=mock_minio_repository.return_value, + logger=logger, + notification_handler=notification_handler, + metrics_controller=mock_metrics_controller.return_value, + ) + + mock_mlflow_init.assert_called_once_with( + ANY, + mlflow_host=mlflow_config['host'], + mlflow_port=mlflow_config['port'], + mlflow_username=mlflow_config['username'], + mlflow_password=mlflow_config['password'], + minio_repository=mock_minio_repository.return_value, + logger=logger, + notification_handler=notification_handler, + metrics_controller=mock_metrics_controller.return_value, + ) + + mock_opc_init.assert_called_once_with( + ANY, + opc_servers=opc_config, + logger=logger, + notification_handler=notification_handler, + metrics_controller=mock_metrics_controller.return_value, + ) + + mock_gates_init.assert_called_once_with( + ANY, + minio_repository=mock_minio_repository.return_value, + logger=logger, + notification_handler=notification_handler, + metrics_controller=mock_metrics_controller.return_value, + ) + + mock_model_metrics_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=pi_web_api_config['base_url'], + auth_type=pi_web_api_config['auth_type'], + auth_token=pi_web_api_config['auth_token'], + logger=logger, + notification_handler=notification_handler, + metrics_controller=mock_metrics_controller.return_value, + ) + + mock_minio_repository.assert_called_once_with( + endpoint=minio_config['endpoint_url'], + access_key=minio_config['access_key'], + secret_key=minio_config['secret_key'], + bucket=minio_config['default_bucket'], + logger=logger, + notification_handler=notification_handler, + metrics_controller=mock_metrics_controller.return_value, + secure=minio_config['secure'], + ) + + +@mark.asyncio +@patch('laborious.activities.activities.Storage') +@patch('laborious.activities.activities.MLFlow') +@patch('laborious.activities.activities.OPC') +@patch('laborious.activities.activities.Gates') +@patch('laborious.activities.activities.ModelMetrics') +@patch('laborious.activities.activities.API') +@patch('laborious.activities.activities.MinioRepository') +async def test_shutdown( + _mock_minio_repository, + mock_api_init, + mock_model_metrics_init, + mock_gates_init, + mock_opc_init, + mock_mlflow_init, + mock_storage_init, +): + mock_opc_init.close = AsyncMock() + postgres_config = { + 'host': 'localhost', + 'port': 5432, + 'user': 'postgres', + 'password': 'postgres', + 'dbname': 'postgres', + 'min_connections': 1, + 'max_connections': 10, + } + + minio_config = { + 'endpoint_url': 'localhost:9000', + 'access_key': 'minio', + 'secret_key': 'minio123', + 'default_bucket': 'test', + 'retention_hours': 24, + 'secure': False, + } + + mlflow_config = {'host': 'localhost', 'port': 5000, 'username': 'mlflow', 'password': 'mlflow'} + + opc_config = { + 'bootstrap_servers': 'localhost:9092', + 'polling_time': 1000, + 'group_id': 'test-group', + } + + pi_web_api_config = { + 'base_url': 'https://test-pi-server.com', + 'auth_type': 'bearer', + 'auth_token': 'test_token', + } + + logger = MagicMock() + notification_handler = MagicMock() + + activities = Activities( + postgres_config=postgres_config, + mlflow_config=mlflow_config, + minio_config=minio_config, + opc_config=opc_config, + pi_web_api_config=pi_web_api_config, + logger=logger, + notification_handler=notification_handler, + ) + + await activities.shutdown() + mock_opc_init.close.assert_called_once() + mock_storage_init.close.assert_called_once() + mock_mlflow_init.close.assert_called_once() + mock_gates_init.close.assert_called_once() + mock_model_metrics_init.close.assert_called_once() + mock_api_init.close.assert_called_once() diff --git a/tests/laborious/activities/test_api.py b/tests/laborious/activities/test_api.py new file mode 100644 index 0000000..bf5b9f1 --- /dev/null +++ b/tests/laborious/activities/test_api.py @@ -0,0 +1,492 @@ +from unittest.mock import ANY, AsyncMock, MagicMock, call, patch + +import pytest_asyncio +from pytest import fixture, mark +from sientia_do.notifications.models import NotificationLevel + +from laborious.activities.api import API, PI_WEB_API_PREDICTION_ERROR_CONFIDENCE + +metadata = { + 'metadata': { + 'model_id': 'test_model', + 'model_name': 'test_model', + 'workflow_name': 'test_workflow', + 'schema_name': 'test_schedule', + }, +} + + +def _create_mock_dataframe(to_dict_return=None): + """Helper function to create a mocked DataFrame for testing.""" + mock_df = MagicMock() + mock_head = MagicMock() + + def get_column_values(key): + if key == 'prediction': + return MagicMock(values=[0.75]) + elif key == 'prediction_confidence': + return MagicMock(values=[0.95]) + else: + return MagicMock(values=['2024-01-01T00:00:00+00:00']) + + mock_head.__getitem__.side_effect = get_column_values + mock_df.head.return_value = mock_head + + if to_dict_return is None: + to_dict_return = { + 'prediction': [0.75], + 'prediction_confidence': [0.95], + 'timestamp': ['2024-01-01T00:00:00+00:00'], + } + mock_df.to_dict.return_value = to_dict_return + + return mock_df + + +@fixture +def base_input_data(): + """Base input data for PI Web API tests.""" + return { + **metadata, + 'data': { + 'prediction': [0.75], + 'prediction_confidence': [0.95], + 'timestamp': ['2024-01-01T00:00:00+00:00'], + }, + 'pi_web_api_output_config': { + 'endpoint': 'https://test-pi-server.com/piwebapi', + 'prediction_tags': {'tag1': 'web_id_1'}, + 'confidence_tags': {'tag2': 'web_id_2'}, + }, + } + + +@patch('laborious.activities.api.PIWebAPIClient') +def test_get_pi_web_api_core_labels_without_operation_type(mock_pi_web_api_client): + from sientia_do.observability.sientia_monitoring import SientiaMonitoring + + api_instance = API( + base_url='https://test-pi-server.com', + auth_type='bearer', + auth_token='test_token', + logger=MagicMock(), + notification_handler=MagicMock(), + metrics_controller=AsyncMock(), + ) + with patch.object( + SientiaMonitoring, + 'get_core_labels', + return_value={ + 'pod_id': 'test_pod', + 'runtime': 'local', + 'model_name': 'test_model', + 'workflow_name': 'test_workflow', + 'operation_type': 'write_pi_web_api_data', + }, + ): + labels = api_instance.get_pi_web_api_core_labels(metadata=metadata['metadata']) + assert labels['operation_type'] == 'write_pi_web_api_data' + assert labels == { + 'pod_id': 'test_pod', + 'runtime': 'local', + 'model_name': 'test_model', + 'workflow_name': 'test_workflow', + 'operation_type': 'write_pi_web_api_data', + } + + +@patch('laborious.activities.api.PIWebAPIClient') +def test_get_pi_web_api_core_labels_with_operation_type(mock_pi_web_api_client): + from sientia_do.observability.sientia_monitoring import SientiaMonitoring + + api_instance = API( + base_url='https://test-pi-server.com', + auth_type='bearer', + auth_token='test_token', + logger=MagicMock(), + notification_handler=MagicMock(), + metrics_controller=AsyncMock(), + ) + with patch.object( + SientiaMonitoring, + 'get_core_labels', + return_value={ + 'pod_id': 'test_pod', + 'runtime': 'k8s', + 'model_name': 'test_model', + 'workflow_name': 'test_workflow', + 'operation_type': 'write', + }, + ): + labels = api_instance.get_pi_web_api_core_labels( + metadata=metadata['metadata'], operation_type='write' + ) + assert labels['operation_type'] == 'write' + assert labels['runtime'] == 'k8s' + + +def test__init__(): + api = API( + base_url='https://test-pi-server.com', + auth_type='bearer', + auth_token='test_token', + logger=MagicMock(), + notification_handler=MagicMock(), + metrics_controller=AsyncMock(), + ) + + assert api.pi_web_api_client is not None + + +@pytest_asyncio.fixture +@patch('laborious.activities.api.PIWebAPIClient') +def api(mock_pi_web_api_client): + mock_client = MagicMock() + mock_client.write_value = AsyncMock() + mock_client.close = MagicMock() + mock_client.base_url = 'https://test-pi-server.com' + mock_pi_web_api_client.return_value = mock_client + + api_instance = API( + base_url='https://test-pi-server.com', + auth_type='bearer', + auth_token='test_token', + logger=MagicMock(), + notification_handler=MagicMock(), + metrics_controller=AsyncMock(), + ) + api_instance.send_notification_async = AsyncMock() + api_instance.info = MagicMock() + api_instance.error = MagicMock() + api_instance.emit_metric = AsyncMock() + api_instance.get_core_labels = MagicMock( + return_value={ + 'pod_id': 'test_pod', + 'runtime': 'local', + 'model_name': 'test_model', + 'workflow_name': 'test_workflow', + } + ) + return api_instance + + +@mark.asyncio +@patch('laborious.activities.api.DataFrame') +async def test_write_pi_web_api_data_success(mock_dataframe, api, base_input_data): + input_data = { + **base_input_data, + 'pi_web_api_output_config': { + 'endpoint': 'https://test-pi-server.com/piwebapi', + 'prediction_tags': {'tag1': 'web_id_1', 'tag2': 'web_id_2'}, + 'confidence_tags': {'tag3': 'web_id_3', 'tag4': 'web_id_4'}, + }, + } + + mock_dataframe.return_value = _create_mock_dataframe() + + # Mock successful responses + api.pi_web_api_client.write_value.side_effect = [ + [{'WebId': 'web_id_1', 'Errors': []}, {'WebId': 'web_id_2', 'Errors': []}], + [{'WebId': 'web_id_3', 'Errors': []}, {'WebId': 'web_id_4', 'Errors': []}], + ] + + result = await api.write_pi_web_api_data(input_data) + + api.pi_web_api_client.write_value.assert_has_calls( + [ + call( + web_ids=['web_id_1', 'web_id_2'], + value={ + 'Timestamp': '2024-01-01T00:00:00+00:00', + 'Value': 0.75, + }, + metadata=metadata['metadata'], + ), + call( + web_ids=['web_id_3', 'web_id_4'], + value={ + 'Timestamp': '2024-01-01T00:00:00+00:00', + 'Value': 0.95, + }, + metadata=metadata['metadata'], + ), + ] + ) + + assert result == { + 'prediction': [0.75], + 'prediction_confidence': [0.95], + 'timestamp': ['2024-01-01T00:00:00+00:00'], + } + + +@mark.asyncio +@patch('laborious.activities.api.DataFrame') +async def test_write_pi_web_api_data_prediction_error(mock_dataframe, api, base_input_data): + mock_dataframe.return_value = _create_mock_dataframe( + { + 'prediction': [0.75], + 'prediction_confidence': [PI_WEB_API_PREDICTION_ERROR_CONFIDENCE], + 'timestamp': ['2024-01-01T00:00:00+00:00'], + } + ) + + api.pi_web_api_client.write_value.side_effect = Exception('Prediction write failed') + + result = await api.write_pi_web_api_data(base_input_data) + + api.send_notification_async.assert_called_once_with( + metadata=metadata['metadata'], + notification_id='WRITE_PI_WEB_API_PREDICTION_ERROR', + message="Error writing prediction data to PI Web API: Prediction write failed\n Tags: {'tag1': 'web_id_1'}", + block='write_pi_web_api_data', + level=NotificationLevel.ERROR, + attachment_content=ANY, + ) + + assert result['prediction_confidence'][0] == PI_WEB_API_PREDICTION_ERROR_CONFIDENCE + assert api.pi_web_api_client.write_value.call_count == 1 + + +@mark.asyncio +@patch('laborious.activities.api.DataFrame') +async def test_write_pi_web_api_data_confidence_error(mock_dataframe, api, base_input_data): + mock_dataframe.return_value = _create_mock_dataframe() + + # First call succeeds, second fails + api.pi_web_api_client.write_value.side_effect = [ + [{'WebId': 'web_id_1', 'Errors': []}], + Exception('Confidence write failed'), + ] + + result = await api.write_pi_web_api_data(base_input_data) + + api.send_notification_async.assert_called_once_with( + metadata=metadata['metadata'], + notification_id='WRITE_PI_WEB_API_CONFIDENCE_ERROR', + message="Error writing confidence data to PI Web API: Confidence write failed\n Tags: {'tag2': 'web_id_2'}", + block='write_pi_web_api_data', + level=NotificationLevel.ERROR, + attachment_content=ANY, + ) + + assert result == { + 'prediction': [0.75], + 'prediction_confidence': [0.95], + 'timestamp': ['2024-01-01T00:00:00+00:00'], + } + assert api.pi_web_api_client.write_value.call_count == 2 + + +@mark.asyncio +@patch('laborious.activities.api.DataFrame') +async def test_write_pi_web_api_data_empty_tags(mock_dataframe, api, base_input_data): + input_data = { + **base_input_data, + 'pi_web_api_output_config': { + 'endpoint': 'https://test-pi-server.com/piwebapi', + 'prediction_tags': {}, + 'confidence_tags': {}, + }, + } + + mock_dataframe.return_value = _create_mock_dataframe() + + # Mock empty responses + api.pi_web_api_client.write_value.side_effect = [ + [], + [], + ] + + result = await api.write_pi_web_api_data(input_data) + + api.pi_web_api_client.write_value.assert_has_calls( + [ + call( + web_ids=[], + value={ + 'Timestamp': '2024-01-01T00:00:00+00:00', + 'Value': 0.75, + }, + metadata=metadata['metadata'], + ), + call( + web_ids=[], + value={ + 'Timestamp': '2024-01-01T00:00:00+00:00', + 'Value': 0.95, + }, + metadata=metadata['metadata'], + ), + ] + ) + + assert result == { + 'prediction': [0.75], + 'prediction_confidence': [0.95], + 'timestamp': ['2024-01-01T00:00:00+00:00'], + } + + +@mark.asyncio +async def test_close(api): + api.close() + + api.pi_web_api_client.close.assert_called_once() + + +@mark.asyncio +async def test_process_pi_web_api_response_success(api): + """Test successful processing of PI Web API response with all tags written.""" + response_data = [ + {'WebId': 'web_id_1', 'Errors': []}, + {'WebId': 'web_id_2', 'Errors': []}, + ] + tags = {'tag1': 'web_id_1', 'tag2': 'web_id_2'} + core_labels = { + 'pod_id': 'test_pod', + 'runtime': 'local', + 'model_name': 'test_model', + 'workflow_name': 'test_workflow', + } + + confidence, message = await api.process_pi_web_api_response( + response_data=response_data, + tags=tags, + core_labels=core_labels, + metadata=metadata['metadata'], + ) + + assert confidence == 0 + assert message == '' + assert api.emit_metric.call_count == 2 + # Verify that emit_metric was called with correct tags structure + call_args_list = api.emit_metric.call_args_list + assert len(call_args_list) == 2 + # Check that all calls include core_labels and tag_name + for call_args in call_args_list: + assert 'tag_name' in call_args.kwargs['tags'] + assert call_args.kwargs['tags']['tag_name'] in ['tag1', 'tag2'] + + +@mark.asyncio +async def test_process_pi_web_api_response_with_errors(api): + """Test processing response with errors in some tags.""" + response_data = [ + {'WebId': 'web_id_1', 'Errors': ['Error writing tag']}, + {'WebId': 'web_id_2', 'Errors': []}, + ] + tags = {'tag1': 'web_id_1', 'tag2': 'web_id_2'} + core_labels = { + 'pod_id': 'test_pod', + 'runtime': 'local', + 'model_name': 'test_model', + 'workflow_name': 'test_workflow', + } + + confidence, message = await api.process_pi_web_api_response( + response_data=response_data, + tags=tags, + core_labels=core_labels, + metadata=metadata['metadata'], + ) + + assert confidence == PI_WEB_API_PREDICTION_ERROR_CONFIDENCE + assert ( + message + == "The number of written tags does not match the number of tag names: Expected ['tag1', 'tag2'] tags, but ['tag2'] tags were written." + ) + assert api.emit_metric.call_count == 2 + + +@mark.asyncio +async def test_process_pi_web_api_response_missing_tags(api): + """Test processing response when number of written tags doesn't match expected.""" + response_data = [ + {'WebId': 'web_id_1', 'Errors': []}, + ] + tags = {'tag1': 'web_id_1', 'tag2': 'web_id_2'} + core_labels = { + 'pod_id': 'test_pod', + 'runtime': 'local', + 'model_name': 'test_model', + 'workflow_name': 'test_workflow', + } + + confidence, message = await api.process_pi_web_api_response( + response_data=response_data, + tags=tags, + core_labels=core_labels, + metadata=metadata['metadata'], + ) + + assert confidence == PI_WEB_API_PREDICTION_ERROR_CONFIDENCE + assert ( + message + == "The number of written tags does not match the number of tag names: Expected ['tag1', 'tag2'] tags, but ['tag1'] tags were written." + ) + api.send_notification_async.assert_called_once() + call_args = api.send_notification_async.call_args + assert call_args.kwargs['notification_id'] == 'WRITE_PI_WEB_API_PREDICTION_ERROR' + assert call_args.kwargs['level'] == NotificationLevel.ERROR + + +@mark.asyncio +async def test_process_pi_web_api_response_missing_webid(api): + """Test processing response when WebId is missing in response item.""" + response_data = [ + {'Errors': []}, + {'WebId': 'web_id_2', 'Errors': []}, + ] + tags = {'tag1': 'web_id_1', 'tag2': 'web_id_2'} + core_labels = { + 'pod_id': 'test_pod', + 'runtime': 'local', + 'model_name': 'test_model', + 'workflow_name': 'test_workflow', + } + + confidence, message = await api.process_pi_web_api_response( + response_data=response_data, + tags=tags, + core_labels=core_labels, + metadata=metadata['metadata'], + ) + + assert confidence == PI_WEB_API_PREDICTION_ERROR_CONFIDENCE + assert ( + message + == "The number of written tags does not match the number of tag names: Expected ['tag1', 'tag2'] tags, but ['tag2'] tags were written." + ) + api.error.assert_any_call('The response did not contain some WebIds', metadata['metadata']) + + +@mark.asyncio +async def test_process_pi_web_api_response_missing_tag_name(api): + """Test processing response when tag name is not found for WebId.""" + response_data = [ + {'WebId': 'unknown_web_id', 'Errors': []}, + ] + tags = {'tag1': 'web_id_1'} + core_labels = { + 'pod_id': 'test_pod', + 'runtime': 'local', + 'model_name': 'test_model', + 'workflow_name': 'test_workflow', + } + + confidence, message = await api.process_pi_web_api_response( + response_data=response_data, + tags=tags, + core_labels=core_labels, + metadata=metadata['metadata'], + ) + + assert confidence == PI_WEB_API_PREDICTION_ERROR_CONFIDENCE + assert ( + message + == "The number of written tags does not match the number of tag names: Expected ['tag1'] tags, but [] tags were written." + ) + api.error.assert_any_call( + 'The response did not contain the tag name for WebId unknown_web_id', metadata['metadata'] + ) diff --git a/tests/laborious/activities/test_gates.py b/tests/laborious/activities/test_gates.py new file mode 100644 index 0000000..c144068 --- /dev/null +++ b/tests/laborious/activities/test_gates.py @@ -0,0 +1,1008 @@ +from unittest.mock import ANY, AsyncMock, MagicMock, call, patch + +from pandas import DataFrame +from pytest import fixture, mark +from sientia_do.notifications.models import NotificationLevel + +from laborious.activities.gates import Gates + + +@fixture(autouse=True) +def _passthrough_from_dict(): + with patch( + 'laborious.activities.gates.MinioDataFramePayload.from_dict', side_effect=lambda x: x + ): + yield + + +def _minio_payload(retrieve_return, status=None): + """ + Build a MinioDataFramePayload-like test double with async retrieve. + + Args: + retrieve_return: Value returned from await retrieve(minio_repo, metadata). + status: Optional status dict for MLflow response gate (payload.status). + + Return: + MagicMock: Object with async retrieve and optional status. + """ + p = MagicMock() + p.retrieve = AsyncMock(return_value=retrieve_return) + p.status = status + return p + + +@fixture +def gates_activity(): + gates = Gates( + logger=MagicMock(), + notification_handler=MagicMock(), + metrics_controller=AsyncMock(), + ) + gates.error = MagicMock() + gates.debug = MagicMock() + gates.info = MagicMock() + gates.warning = MagicMock() + gates.critical = MagicMock() + gates.send_notification = MagicMock() + gates.send_notification_async = AsyncMock() + gates.emit_metric = AsyncMock() + return gates + + +metadata = { + 'metadata': { + 'model_id': 'test_model', + 'model_name': 'test_model', + 'workflow_name': 'test_workflow', + 'schema_name': 'test_schedule', + }, +} + + +@mark.asyncio +async def test_input_gate_invalid_filter(gates_activity): + # Arrange + input_data = { + **metadata, + 'filters': {'INVALID_FILTER': {'POLICY': 'STOP'}}, + 'data': _minio_payload(DataFrame({'value': [1, 2, 3]})), + 'path_priority': ['STOP', 'CONTINUE', 'REPEAT'], + } + + # Act + result = await gates_activity.input_gate(input_data) + + # Assert + assert result == (None, 0, '') + gates_activity.error.assert_called_once_with( + 'Filter INVALID_FILTER not found', metadata['metadata'] + ) + + +@mark.asyncio +@patch('laborious.activities.gates.input_filter_functions') +async def test_input_gate_filter_exception(mock_input_filter_functions, gates_activity): + # Arrange + mock_input_filter_functions.__contains__.return_value = True + mock_input_filter_functions.__getitem__.return_value = MagicMock( + side_effect=Exception('Test error') + ) + input_data = { + **metadata, + 'filters': {'EMPTY_DATA': {'POLICY': 'STOP', 'CONFIG': {}}}, + 'data': _minio_payload(DataFrame({'value': []})), + 'path_priority': ['STOP', 'CONTINUE', 'REPEAT'], + } + + # Act + result = await gates_activity.input_gate(input_data) + + # Assert + assert result == (None, 0, '') + gates_activity.send_notification_async.assert_called_once_with( + metadata=metadata['metadata'], + notification_id='INTPUT_GATE_ERROR__EMPTY_DATA', + message="Error in filter EMPTY_DATA:{'POLICY': 'STOP', 'CONFIG': {}}: \n Test error", + block='input_gate', + level=NotificationLevel.ERROR, + attachment_content=ANY, + ) + + +@mark.asyncio +async def test_input_gate_no_filters(gates_activity): + # Arrange + input_data = { + **metadata, + 'filters': {}, + 'data': _minio_payload(DataFrame({'value': [1, 2, 3]})), + 'path_priority': ['CONTINUE', 'STOP', 'REPEAT'], + } + + # Act + result = await gates_activity.input_gate(input_data) + + # Assert + assert result == (None, 0, '') + gates_activity.debug.assert_called() + + +@mark.asyncio +async def test_input_gate_with_filter(gates_activity): + # Arrange + input_data = { + **metadata, + 'filters': {'EMPTY_DATA': {'POLICY': 'STOP', 'CONFIG': {}}}, + 'data': _minio_payload(DataFrame({'value': []})), + 'path_priority': ['STOP', 'CONTINUE', 'REPEAT'], + } + + # Act + result = await gates_activity.input_gate(input_data) + + # Assert + assert result == ('STOP', -1, 'Input data with bad quality') + gates_activity.debug.assert_called() + + +@mark.asyncio +async def test_input_gate_with_filter_lowercase_keys(gates_activity): + # Arrange + input_data = { + **metadata, + 'filters': {'EMPTY_DATA': {'policy': 'STOP', 'config': {}}}, + 'data': _minio_payload(DataFrame({'value': []})), + 'path_priority': ['STOP', 'CONTINUE', 'REPEAT'], + } + + # Act + result = await gates_activity.input_gate(input_data) + + # Assert + assert result == ('STOP', -1, 'Input data with bad quality') + + +@mark.asyncio +async def test_input_gate_with_filter_capitalized_keys(gates_activity): + # Arrange + input_data = { + **metadata, + 'filters': {'EMPTY_DATA': {'Policy': 'STOP', 'Config': {}}}, + 'data': _minio_payload(DataFrame({'value': []})), + 'path_priority': ['STOP', 'CONTINUE', 'REPEAT'], + } + + # Act + result = await gates_activity.input_gate(input_data) + + # Assert + assert result == ('STOP', -1, 'Input data with bad quality') + + +@mark.asyncio +async def test_input_gate_with_filter_not_caught(gates_activity): + # Arrange + input_data = { + **metadata, + 'filters': {'EMPTY_DATA': {'POLICY': 'STOP', 'CONFIG': {}}}, + 'data': _minio_payload(DataFrame({'value': [1, 2, 3]})), + 'path_priority': ['STOP', 'CONTINUE', 'REPEAT'], + } + + # Act + result = await gates_activity.input_gate(input_data) + + # Assert + assert result == (None, 0, '') + gates_activity.debug.assert_called() + + +@mark.asyncio +async def test_mlflow_response_gate_invalid_filter(gates_activity): + # Arrange + input_data = { + **metadata, + 'filters': {'INVALID_FILTER': {'POLICY': 'STOP'}}, + 'data': _minio_payload( + {'content': {'message': 'success'}}, + status={'success': True}, + ), + 'type': 'test', + 'path_priority': ['STOP', 'CONTINUE', 'REPEAT'], + } + + # Act + result = await gates_activity.mlflow_response_gate(input_data) + + # Assert + assert result == (None, 0, '') + + +@mark.asyncio +@patch('laborious.activities.gates.mlflow_response_filter_functions') +async def test_mlflow_response_gate_filter_exception( + mock_mlflow_response_filter_functions, gates_activity +): + # Arrange + mock_mlflow_response_filter_functions.__contains__.return_value = True + mock_mlflow_response_filter_functions.__getitem__.return_value = MagicMock( + side_effect=Exception('Test error') + ) + input_data = { + **metadata, + 'filters': {'INVALID_FILTER': {'POLICY': 'STOP'}}, + 'data': _minio_payload( + {'content': {'message': 'success'}}, + status={'success': True}, + ), + 'type': 'test', + 'path_priority': ['STOP', 'CONTINUE', 'REPEAT'], + } + + # Act + result = await gates_activity.mlflow_response_gate(input_data) + + # Assert + assert result == (None, 0, '') + gates_activity.send_notification_async.assert_called_once_with( + metadata=metadata['metadata'], + notification_id='MLFLOW_GATE_RESPONSE_FILTER__INVALID_FILTER', + message="Error in filter INVALID_FILTER:{'POLICY': 'STOP'}: \n Test error", + block='mlflow_gate', + level=NotificationLevel.ERROR, + attachment_content=ANY, + ) + + +@mark.asyncio +async def test_mlflow_response_gate_no_filters(gates_activity): + # Arrange + input_data = { + **metadata, + 'filters': {}, + 'data': _minio_payload( + {'content': {'message': 'success'}}, + status={'success': True}, + ), + 'type': 'test', + 'path_priority': ['CONTINUE', 'STOP', 'REPEAT'], + } + + # Act + result = await gates_activity.mlflow_response_gate(input_data) + + # Assert + assert result == (None, 0, '') + gates_activity.debug.assert_called() + + +@mark.asyncio +async def test_mlflow_response_gate_with_filter(gates_activity): + # Arrange + input_data = { + **metadata, + 'filters': {'API_ERROR': {'POLICY': 'STOP'}}, + 'data': _minio_payload( + {'content': {'message': 'API error occurred', 'traceback': 'error trace'}}, + status={'success': False, 'message': 'API error occurred'}, + ), + 'type': 'test', + 'path_priority': ['STOP', 'CONTINUE', 'REPEAT'], + } + + # Act + result = await gates_activity.mlflow_response_gate(input_data) + + # Assert + assert result == ('STOP', -1, 'API error occurred') + gates_activity.debug.assert_called() + gates_activity.send_notification_async.assert_called() + + +@mark.asyncio +async def test_mlflow_response_gate_with_filter_capitalized_keys(gates_activity): + # Arrange + input_data = { + **metadata, + 'filters': {'API_ERROR': {'Policy': 'STOP'}}, + 'data': _minio_payload( + {'content': {'message': 'API error occurred', 'traceback': 'error trace'}}, + status={'success': False, 'message': 'API error occurred'}, + ), + 'type': 'test', + 'path_priority': ['STOP', 'CONTINUE', 'REPEAT'], + } + + # Act + result = await gates_activity.mlflow_response_gate(input_data) + + # Assert + assert result == ('STOP', -1, 'API error occurred') + + +@mark.asyncio +async def test_mlflow_response_gate_with_filter_not_caught(gates_activity): + # Arrange + input_data = { + **metadata, + 'filters': {'API_ERROR': {'POLICY': 'STOP'}}, + 'data': _minio_payload( + {'content': {'message': 'success'}}, + status={'success': True}, + ), + 'type': 'test', + 'path_priority': ['STOP', 'CONTINUE', 'REPEAT'], + } + + # Act + result = await gates_activity.mlflow_response_gate(input_data) + + # Assert + assert result == (None, 0, '') + gates_activity.debug.assert_called() + + +@mark.asyncio +async def test_mlflow_content_gate_invalid_filter(gates_activity): + # Arrange + input_data = { + **metadata, + 'filters': {'INVALID_FILTER': {'POLICY': 'STOP'}}, + 'data': _minio_payload(DataFrame({'value': [1, 2, 3]})), + 'type': 'test', + 'path_priority': ['STOP', 'CONTINUE', 'REPEAT'], + } + + # Act + result = await gates_activity.mlflow_content_gate(input_data) + + # Assert + assert result == (None, 0, '') + + +@mark.asyncio +@patch('laborious.activities.gates.mlflow_content_filter_functions') +async def test_mlflow_content_gate_filter_exception( + mock_mlflow_content_filter_functions, gates_activity +): + # Arrange + mock_mlflow_content_filter_functions.__contains__.return_value = True + mock_mlflow_content_filter_functions.__getitem__.return_value = MagicMock( + side_effect=Exception('Test error') + ) + input_data = { + **metadata, + 'filters': {'API_ERROR': {'POLICY': 'STOP'}}, + 'data': _minio_payload(DataFrame({'value': [1, 2, 3]})), + 'type': 'test', + 'path_priority': ['STOP', 'CONTINUE', 'REPEAT'], + } + + # Act + result = await gates_activity.mlflow_content_gate(input_data) + + # Assert + assert result == (None, 0, '') + gates_activity.debug.assert_called() + gates_activity.send_notification_async.assert_called_once_with( + metadata=metadata['metadata'], + notification_id='MLFLOW_GATE_CONTENT_FILTER__API_ERROR', + message="Error in filter API_ERROR:{'POLICY': 'STOP'}: \n Test error", + block='mlflow_gate', + level=NotificationLevel.ERROR, + attachment_content=ANY, + ) + + +@mark.asyncio +async def test_mlflow_content_gate_no_filters(gates_activity): + # Arrange + input_data = { + **metadata, + 'filters': {}, + 'data': _minio_payload(DataFrame({'value': [1, 2, 3]})), + 'type': 'test', + 'path_priority': ['CONTINUE', 'STOP', 'REPEAT'], + } + + # Act + result = await gates_activity.mlflow_content_gate(input_data) + + # Assert + assert result == (None, 0, '') + gates_activity.debug.assert_called() + + +@mark.asyncio +async def test_mlflow_content_gate_with_filter(gates_activity): + # Arrange + input_data = { + **metadata, + 'filters': {'NAN_VALUES': {'POLICY': 'STOP', 'CONFIG': {}}}, + 'data': _minio_payload(DataFrame({'value': [None, None, None]})), + 'type': 'test', + 'path_priority': ['STOP', 'CONTINUE', 'REPEAT'], + } + + # Act + result = await gates_activity.mlflow_content_gate(input_data) + + # Assert + assert result == ('STOP', -1, 'Transformed data not passed the content filter') + gates_activity.debug.assert_called() + gates_activity.send_notification_async.assert_called() + + +@mark.asyncio +async def test_mlflow_content_gate_with_filter_not_caught(gates_activity): + # Arrange + input_data = { + **metadata, + 'filters': {'API_ERROR': {'POLICY': 'STOP'}}, + 'data': _minio_payload(DataFrame({'value': [1, 2, 3]})), + 'type': 'test', + 'path_priority': ['STOP', 'CONTINUE', 'REPEAT'], + } + + # Act + result = await gates_activity.mlflow_content_gate(input_data) + + # Assert + assert result == (None, 0, '') + gates_activity.debug.assert_called() + + +@mark.asyncio +async def test_mlflow_content_gate_filter_returns_false(gates_activity): + input_data = { + **metadata, + 'filters': {'NAN_VALUES': {'POLICY': 'STOP', 'CONFIG': {}}}, + 'data': _minio_payload(DataFrame({'value': [1, 2, 3]})), + 'type': 'test', + 'path_priority': ['STOP', 'CONTINUE', 'REPEAT'], + } + + result = await gates_activity.mlflow_content_gate(input_data) + + assert result == (None, 0, '') + gates_activity.debug.assert_called() + + +def test_get_prediction_store_policy_invalid_policy(gates_activity): + # Arrange + prediction_store_policy = 'INVALID_POLICY' + + # Act + policy_type, policy_value = gates_activity.get_prediction_store_policy( + prediction_store_policy, metadata + ) + + # Assert + assert policy_type == 'lts' + assert policy_value == 1 + + +def test_get_prediction_store_policy_invalid_policy_value(gates_activity): + # Arrange + prediction_store_policy = 'abc:INVALID_VALUE' + + # Act + policy_type, policy_value = gates_activity.get_prediction_store_policy( + prediction_store_policy, metadata + ) + + # Assert + assert policy_type == 'lts' + assert policy_value == 1 + + +def test_get_prediction_store_policy_valid_policy_type(gates_activity): + # Arrange + prediction_store_policy = 'abc:1' + + # Act + policy_type, policy_value = gates_activity.get_prediction_store_policy( + prediction_store_policy, metadata + ) + + # Assert + assert policy_type == 'lts' + assert policy_value == 1 + + +def test_get_prediction_store_policy_valid_policy(gates_activity): + # Arrange + prediction_store_policy = 'erl:1' + + # Act + policy_type, policy_value = gates_activity.get_prediction_store_policy( + prediction_store_policy, metadata + ) + + # Assert + assert policy_type == 'erl' + assert policy_value == 1 + + +@mark.asyncio +async def test_format_prediction_no_timestamp(gates_activity): + # Arrange + input_data = { + **metadata, + 'data': _minio_payload( + DataFrame( + { + 'prediction': {'2023-05-26 11:12:27': 1}, + 'response_time': {'2023-05-26 11:12:27': 0.1}, + } + ) + ), + 'model_id': 'test_model', + 'prediction_confidence': 0.9, + 'prediction_store_policy': 'lts:1', + 'timestamp': '2023-05-26 11:12:27', + } + + # Act + result = await gates_activity.format_prediction(input_data) + + # Assert + assert result['prediction'] == {0: 1} + assert result['response_time'] == {0: ANY} + assert result['timestamp'] == {0: '2023-05-26 11:12:27'} + assert result['model_id'] == {0: 'test_model'} + assert result['prediction_confidence'] == {0: 0.9} + assert result['prediction_status'] == {0: 'Good'} + assert result['comments'] == {0: ''} + + +@mark.asyncio +async def test_format_prediction_with_timestamp_erl(gates_activity): + # Arrange + input_data = { + **metadata, + 'data': _minio_payload( + DataFrame( + { + 'prediction': { + '2023-05-26 11:12:27': 1, + '2023-05-26 11:12:28': 2, + '2023-05-26 11:12:29': 3, + }, + 'response_time': { + '2023-05-26 11:12:27': 0.1, + '2023-05-26 11:12:28': 0.2, + '2023-05-26 11:12:29': 0.3, + }, + } + ) + ), + 'model_id': 'test_model', + 'prediction_confidence': 0.9, + 'prediction_store_policy': 'erl:2', + 'timestamp': '2023-05-26 11:12:27', + } + + # Act + result = await gates_activity.format_prediction(input_data) + + # Assert + assert result['prediction'] == {0: 2, 1: 1} + assert result['response_time'] == {0: 0.2, 1: 0.1} + assert result['timestamp'] == {0: '2023-05-26 11:12:28', 1: '2023-05-26 11:12:27'} + assert result['model_id'] == {0: 'test_model', 1: 'test_model'} + assert result['prediction_confidence'] == {0: 0.9, 1: 0.9} + assert result['prediction_status'] == {0: 'Good', 1: 'Good'} + assert result['comments'] == {0: '', 1: ''} + + +@mark.asyncio +async def test_format_prediction_with_timestamp_lts(gates_activity): + # Arrange + input_data = { + **metadata, + 'data': _minio_payload( + DataFrame( + { + 'prediction': { + '2023-05-26 11:12:27': 1, + '2023-05-26 11:12:28': 2, + '2023-05-26 11:12:29': 3, + }, + 'response_time': { + '2023-05-26 11:12:27': 0.1, + '2023-05-26 11:12:28': 0.2, + '2023-05-26 11:12:29': 0.3, + }, + } + ) + ), + 'model_id': 'test_model', + 'prediction_confidence': 0.9, + 'prediction_store_policy': 'lts:2', + 'timestamp': '2023-05-26 11:12:27', + } + + # Act + result = await gates_activity.format_prediction(input_data) + + # Assert + assert result['prediction'] == {0: 3, 1: 2} + assert result['response_time'] == {0: 0.3, 1: 0.2} + assert result['timestamp'] == {0: '2023-05-26 11:12:29', 1: '2023-05-26 11:12:28'} + assert result['model_id'] == {0: 'test_model', 1: 'test_model'} + assert result['prediction_confidence'] == {0: 0.9, 1: 0.9} + assert result['prediction_status'] == {0: 'Good', 1: 'Good'} + assert result['comments'] == {0: '', 1: ''} + + +@mark.asyncio +async def test_format_prediction_with_timestamp_invalid_policy(gates_activity): + # Arrange + input_data = { + **metadata, + 'data': _minio_payload( + DataFrame( + { + 'prediction': [1, 2, 3], + 'response_time': [0.1, 0.2, 0.3], + 'timestamp': [ + '2023-05-26 11:12:27', + '2023-05-26 11:12:28', + '2023-05-26 11:12:29', + ], + } + ) + ), + 'model_id': 'test_model', + 'prediction_confidence': 0.9, + 'prediction_store_policy': 'lts:2', + 'timestamp': '2023-05-26 11:12:27', + } + gates_activity.get_prediction_store_policy = MagicMock(return_value=('invalid', 1)) + + try: + await gates_activity.format_prediction(input_data) + except ValueError as e: + assert str(e) == 'Invalid policy type: invalid' + else: + raise AssertionError('Expected ValueError') + + +@mark.asyncio +@patch('laborious.activities.gates.MinioDataFramePayload.from_dataframe', new_callable=AsyncMock) +async def test_format_transformed_data_single_row(mock_from_dataframe, gates_activity): + # Arrange + payload_result = MagicMock() + mock_from_dataframe.return_value = payload_result + input_data = { + **metadata, + 'data': _minio_payload( + DataFrame( + { + 'var1': {'2023-05-26 11:12:27': 1.0}, + 'var2': {'2023-05-26 11:12:27': 2.0}, + } + ) + ), + 'model_id': 'test_model', + 'model_name': 'test_model', + } + + # Act + result = await gates_activity.format_transformed_data(input_data) + + # Assert + assert result is payload_result + mock_from_dataframe.assert_called_once() + kwargs = mock_from_dataframe.call_args.kwargs + assert kwargs['model_name'] == 'test_model' + assert kwargs['operation'] == 'transform' + assert kwargs['workflow_metadata'] == metadata['metadata'] + assert kwargs['minio_repo'] is gates_activity.minio_repository + assert 'dataframe' in kwargs + gates_activity.info.assert_called() + + +@mark.asyncio +@patch('laborious.activities.gates.MinioDataFramePayload.from_dataframe', new_callable=AsyncMock) +async def test_format_transformed_data_multiple_rows(mock_from_dataframe, gates_activity): + # Arrange + payload_result = MagicMock() + mock_from_dataframe.return_value = payload_result + input_data = { + **metadata, + 'data': _minio_payload( + DataFrame( + { + 'var1': { + '2023-05-26 11:12:27': 1.0, + '2023-05-26 11:12:28': 2.0, + }, + 'var2': { + '2023-05-26 11:12:27': 3.0, + '2023-05-26 11:12:28': 4.0, + }, + } + ) + ), + 'model_id': 'test_model', + 'model_name': 'test_model', + } + + # Act + result = await gates_activity.format_transformed_data(input_data) + + # Assert + assert result is payload_result + mock_from_dataframe.assert_called_once() + kwargs = mock_from_dataframe.call_args.kwargs + assert kwargs['model_name'] == 'test_model' + assert kwargs['operation'] == 'transform' + assert kwargs['workflow_metadata'] == metadata['metadata'] + assert kwargs['minio_repo'] is gates_activity.minio_repository + assert 'dataframe' in kwargs + gates_activity.info.assert_called() + + +@mark.asyncio +@patch('laborious.activities.gates.MinioDataFramePayload.from_dataframe', new_callable=AsyncMock) +async def test_format_transformed_data_empty_data(mock_from_dataframe, gates_activity): + # Arrange + payload_result = MagicMock() + mock_from_dataframe.return_value = payload_result + input_data = { + **metadata, + 'data': _minio_payload(DataFrame()), + 'model_id': 'test_model', + 'model_name': 'test_model', + } + + # Act + result = await gates_activity.format_transformed_data(input_data) + + # Assert + assert result is payload_result + mock_from_dataframe.assert_called_once() + kwargs = mock_from_dataframe.call_args.kwargs + assert kwargs['model_name'] == 'test_model' + assert kwargs['operation'] == 'transform' + assert kwargs['workflow_metadata'] == metadata['metadata'] + assert kwargs['minio_repo'] is gates_activity.minio_repository + assert 'dataframe' in kwargs + gates_activity.info.assert_called() + + +@mark.asyncio +async def test_format_default_prediction(gates_activity): + # Arrange + input_data = { + **metadata, + 'timestamp': '2023-05-26 11:12:27', + 'model_id': 'test_model', + 'prediction_confidence': 0.1, + 'comment': 'Test comment', + } + + # Act + result = await gates_activity.format_default_prediction(input_data) + + # Assert + assert result['prediction'] == {0: 0} + assert result['response_time'] == {0: 0} + assert result['timestamp'] == {0: '2023-05-26 11:12:27'} + assert result['model_id'] == {0: 'test_model'} + assert result['prediction_confidence'] == {0: 0.1} + assert result['prediction_status'] == {0: 'Bad'} + assert result['comments'] == {0: 'Test comment'} + gates_activity.debug.assert_called() + + +@mark.asyncio +async def test_format_retrain_report(gates_activity): + # Arrange + input_data = { + **metadata, + 'experiment_response': { + 'success': True, + 'timestamp': '2023-05-26 11:12:27', + 'message': 'success', + }, + 'update_report': { + 'version': '1.0.0', + 'mlflow_run_id': 'test_mlflow_run_id', + 'mlflow_experiment_id': 'test_mlflow_experiment_id', + }, + 'model_id': 'test_model', + 'model_name': 'test_model', + } + + # Act + result = await gates_activity.format_retrain_report(input_data) + + # Assert + assert result['model_id'] == {0: 'test_model'} + assert result['model_name'] == {0: 'test_model'} + assert result['timestamp'] == {0: '2023-05-26 11:12:27'} + assert result['status'] == {0: 'success'} + assert result['version'] == {0: '1.0.0'} + assert result['mlflow_run_id'] == {0: 'test_mlflow_run_id'} + assert result['mlflow_experiment_id'] == {0: 'test_mlflow_experiment_id'} + + +@mark.asyncio +async def test_format_retrain_report_failure(gates_activity): + # Arrange + input_data = { + **metadata, + 'experiment_response': { + 'success': False, + 'timestamp': '2023-05-26 11:12:27', + 'message': 'failure', + }, + 'update_report': { + 'version': '1.0.0', + 'mlflow_run_id': 'test_mlflow_run_id', + 'mlflow_experiment_id': 'test_mlflow_experiment_id', + }, + 'model_id': 'test_model', + 'model_name': 'test_model', + } + + # Act + result = await gates_activity.format_retrain_report(input_data) + + # Assert + assert result['model_id'] == {0: 'test_model'} + assert result['model_name'] == {0: 'test_model'} + assert result['timestamp'] == {0: '2023-05-26 11:12:27'} + assert result['status'] == {0: 'failure'} + assert 'version' not in result + assert 'mlflow_run_id' not in result + assert 'mlflow_experiment_id' not in result + gates_activity.info.assert_called() + gates_activity.debug.assert_called() + + +@mark.asyncio +@patch('laborious.activities.gates.metrics') +async def test_write_metrics(mock_metrics, gates_activity): + """Test write_metrics method.""" + input_data = { + **metadata, + 'prediction': { + 'prediction': [1, 2, 3], + 'prediction_confidence': [0.9, 0.8, 0.7], + 'response_time': [0.1, 0.2, 0.3], + }, + 'opc_metrics': {'server1': {'tag1': 0.1, 'tag2': 0.2}}, + } + await gates_activity.write_metrics(input_data) + core_tags = { + 'pod_id': gates_activity.pod_id, + 'runtime': gates_activity.runtime, + 'operation_type': 'predict', + 'model_name': metadata['metadata']['model_name'], + 'workflow_name': metadata['metadata']['workflow_name'], + } + gates_activity.emit_metric.assert_has_calls( + [ + call( + metric_object=mock_metrics.PREDICTIONS_WRITTEN_COUNT, + tags=core_tags, + ), + ] + ) + gates_activity.emit_metric.assert_has_calls( + [ + call( + metric_object=mock_metrics.PREDICTION_CONFIDENCE_MONITOR, + method='set', + tags=core_tags, + value=0.9, + ), + ] + ) + gates_activity.emit_metric.assert_has_calls( + [ + call( + metric_object=mock_metrics.PREDICTION_RESPONSE_TIME_MONITOR, + method='observe', + tags=core_tags, + value=0.1, + ), + ] + ) + gates_activity.emit_metric.assert_has_calls( + [ + call( + metric_object=mock_metrics.PREDICTION_OPC_WRITING_COUNT, + tags={ + **core_tags, + 'opc_server_id': 'server1', + 'tag': 'tag1', + }, + ), + ] + ) + gates_activity.emit_metric.assert_has_calls( + [ + call( + metric_object=mock_metrics.PREDICTION_OPC_WRITING_RESPONSE_TIME_MONITOR, + method='observe', + tags={ + **core_tags, + 'opc_server_id': 'server1', + 'tag': 'tag1', + }, + value=0.1, + ), + ] + ) + gates_activity.emit_metric.assert_has_calls( + [ + call( + metric_object=mock_metrics.PREDICTION_OPC_WRITING_COUNT, + tags={ + **core_tags, + 'opc_server_id': 'server1', + 'tag': 'tag2', + }, + ), + ] + ) + gates_activity.emit_metric.assert_has_calls( + [ + call( + metric_object=mock_metrics.PREDICTION_OPC_WRITING_RESPONSE_TIME_MONITOR, + method='observe', + tags={ + **core_tags, + 'opc_server_id': 'server1', + 'tag': 'tag2', + }, + value=0.2, + ), + ] + ) + + +@mark.asyncio +@patch('laborious.activities.gates.metrics') +async def test_write_metrics_with_none_opc_response_time(mock_metrics, gates_activity): + """Test write_metrics method with None response_time in opc_metrics.""" + input_data = { + **metadata, + 'prediction': { + 'prediction': [1], + 'prediction_confidence': [0.9], + 'response_time': [0.1], + }, + 'opc_metrics': {'server1': {'tag1': 0.1, 'tag2': None}}, + } + await gates_activity.write_metrics(input_data) + + # Verify that metrics for tag1 are emitted + gates_activity.emit_metric.assert_any_call( + metric_object=mock_metrics.PREDICTION_OPC_WRITING_RESPONSE_TIME_MONITOR, + method='observe', + tags={ + 'pod_id': gates_activity.pod_id, + 'runtime': gates_activity.runtime, + 'operation_type': 'predict', + 'model_name': metadata['metadata']['model_name'], + 'workflow_name': metadata['metadata']['workflow_name'], + 'opc_server_id': 'server1', + 'tag': 'tag1', + }, + value=0.1, + ) + + # Verify that metrics for tag2 (with None response_time) are NOT emitted + calls = [ + c + for c in gates_activity.emit_metric.call_args_list + if len(c[1].get('tags', {})) > 0 and c[1]['tags'].get('tag') == 'tag2' + ] + assert len(calls) == 0, 'Metrics should not be emitted for None response_time' diff --git a/tests/laborious/activities/test_mlflow.py b/tests/laborious/activities/test_mlflow.py new file mode 100644 index 0000000..5d242e5 --- /dev/null +++ b/tests/laborious/activities/test_mlflow.py @@ -0,0 +1,629 @@ +from unittest.mock import ANY, AsyncMock, MagicMock, call, patch + +import numpy as np +from pytest import fixture, mark, raises +from sientia_do.notifications.models import NotificationLevel +from sientia_do.temporal.constants import DATETIME_FORMAT, DATETIME_FORMAT_WITH_TZ + +from laborious.activities.mlflow import MLFlow + + +@fixture(autouse=True) +def _passthrough_from_dict(): + with patch( + 'laborious.activities.mlflow.MinioDataFramePayload.from_dict', side_effect=lambda x: x + ): + yield + + +@patch('laborious.activities.mlflow.MLFlowRepository') +@patch('laborious.activities.mlflow.MinioRepository') +def test___init__(mock_minio_repository, mock_mlflow_repository): + logger = MagicMock() + notification_handler = MagicMock() + metrics_controller = AsyncMock() + minio_repo = mock_minio_repository( + endpoint='localhost:9000', + access_key='minio', + secret_key='minio123', + logger=logger, + notification_handler=notification_handler, + metrics_controller=metrics_controller, + bucket='test', + ) + mlflow = MLFlow( + mlflow_host='http://localhost', + mlflow_port=5000, + mlflow_username='admin', + mlflow_password='admin', + minio_repository=minio_repo, + logger=logger, + notification_handler=notification_handler, + metrics_controller=metrics_controller, + ) + + assert mlflow.mlflow_host == 'http://localhost' + assert mlflow.mlflow_port == 5000 + assert mlflow.mlflow_username == 'admin' + assert mlflow.mlflow_password == 'admin' + + mock_mlflow_repository.assert_called_once_with( + 'http://localhost:5000', 'admin', 'admin', ANY, ANY, ANY + ) + + mock_minio_repository.assert_called_once_with( + endpoint='localhost:9000', + access_key='minio', + secret_key='minio123', + logger=ANY, + notification_handler=ANY, + metrics_controller=ANY, + bucket='test', + ) + + +@fixture +@patch('laborious.activities.mlflow.MLFlowRepository') +@patch('laborious.activities.mlflow.MinioRepository') +def mlflow(mock_minio_repository, mock_mlflow_repository): + logger = MagicMock() + notification_handler = MagicMock() + metrics_controller = AsyncMock() + minio_repo = mock_minio_repository( + endpoint='localhost:9000', + access_key='minio', + secret_key='minio123', + logger=logger, + notification_handler=notification_handler, + metrics_controller=metrics_controller, + bucket='test', + ) + mlflow = MLFlow( + mlflow_host='http://localhost:5000', + mlflow_port=5000, + mlflow_username='admin', + mlflow_password='admin', + minio_repository=minio_repo, + logger=logger, + notification_handler=notification_handler, + metrics_controller=metrics_controller, + ) + + mlflow.model_monitoring_repository = AsyncMock() + mlflow.minio_repository = AsyncMock() + + mlflow.send_notification = MagicMock() + mlflow.emit_metric = AsyncMock() + mlflow.send_notification_async = AsyncMock() + mlflow.error = MagicMock() + mlflow.debug = MagicMock() + mlflow.info = MagicMock() + mlflow.warning = MagicMock() + mlflow.critical = MagicMock() + + return mlflow + + +metadata = { + 'metadata': { + 'model_id': 'test_model', + 'model_name': 'test_model', + 'workflow_name': 'test_workflow', + 'schema_name': 'test_schedule', + }, +} + + +@mark.asyncio +@patch( + 'laborious.activities.mlflow.MinioDataFramePayload.from_dataframe', + new_callable=AsyncMock, +) +async def test_request_transform_success(mock_from_dataframe, mlflow): + data_mock = MagicMock() + payload = AsyncMock() + payload.retrieve = AsyncMock(return_value=data_mock) + + input_data = { + **metadata, + 'data': payload, + 'model_name': 'test_model', + 'model_config': {}, + } + + transform_response = {'success': True, 'content': MagicMock()} + mlflow.model_monitoring_repository.transform.return_value = transform_response + + data_mock.sort_values.return_value = data_mock + data_mock.drop_duplicates.return_value = data_mock + data_mock.pivot.return_value = data_mock + + response_data = await mlflow.request_transform(input_data) + + mlflow.model_monitoring_repository.transform.assert_called_once_with( + 'test_model', data_mock, {}, metadata['metadata'] + ) + mock_from_dataframe.assert_called_once() + assert response_data == mock_from_dataframe.return_value + + +@mark.asyncio +@patch( + 'laborious.activities.mlflow.MinioDataFramePayload.from_dataframe', + new_callable=AsyncMock, +) +async def test_request_transform_failure(mock_from_dataframe, mlflow): + data_mock = MagicMock() + payload = AsyncMock() + payload.retrieve = AsyncMock(return_value=data_mock) + + input_data = { + **metadata, + 'data': payload, + 'model_name': 'test_model', + 'model_config': {}, + } + + transform_response = {'success': False, 'message': 'Transform failed'} + mlflow.model_monitoring_repository.transform.return_value = transform_response + + data_mock.sort_values.return_value = data_mock + data_mock.drop_duplicates.return_value = data_mock + data_mock.pivot.return_value = data_mock + + response_data = await mlflow.request_transform(input_data) + + mock_from_dataframe.assert_called_once_with( + dataframe=None, + minio_repo=mlflow.minio_repository, + model_name='test_model', + operation='transform', + status=transform_response, + workflow_metadata=metadata['metadata'], + last_timestamp=payload.last_timestamp, + logger=mlflow.logger, + ) + assert response_data == mock_from_dataframe.return_value + + +@mark.asyncio +@patch( + 'laborious.activities.mlflow.MinioDataFramePayload.from_dataframe', + new_callable=AsyncMock, +) +@patch('laborious.activities.mlflow.to_datetime') +async def test_request_predict(mock_to_datetime, mock_from_dataframe, mlflow): + data_mock = MagicMock() + payload = AsyncMock() + payload.retrieve = AsyncMock(return_value=data_mock) + + input_data = { + **metadata, + 'data': payload, + 'model_name': 'test_model', + 'model_config': {}, + } + + predict_response = {'success': True, 'content': MagicMock()} + mlflow.model_monitoring_repository.predict.return_value = predict_response + + response_data = await mlflow.request_predict(input_data) + + data_mock.replace.assert_called_once_with(np.nan, None, inplace=True) + mock_to_datetime.assert_called_once_with( + data_mock.__getitem__.return_value, format=DATETIME_FORMAT_WITH_TZ + ) + mock_to_datetime.return_value.dt.strftime.assert_called_once_with(DATETIME_FORMAT) + + mlflow.model_monitoring_repository.predict.assert_called_once_with( + 'test_model', data_mock, {}, metadata['metadata'] + ) + mock_from_dataframe.assert_called_once() + assert response_data == mock_from_dataframe.return_value + + +@mark.asyncio +@patch( + 'laborious.activities.mlflow.MinioDataFramePayload.from_dataframe', + new_callable=AsyncMock, +) +@patch('laborious.activities.mlflow.to_datetime') +async def test_request_predict_failure(mock_to_datetime, mock_from_dataframe, mlflow): + data_mock = MagicMock() + payload = AsyncMock() + payload.retrieve = AsyncMock(return_value=data_mock) + + input_data = { + **metadata, + 'data': payload, + 'model_name': 'test_model', + 'model_config': {}, + } + + predict_response = {'success': False, 'message': 'Predict failed'} + mlflow.model_monitoring_repository.predict.return_value = predict_response + + response_data = await mlflow.request_predict(input_data) + + mock_from_dataframe.assert_called_once_with( + dataframe=None, + minio_repo=mlflow.minio_repository, + model_name='test_model', + operation='predict', + status=predict_response, + workflow_metadata=metadata['metadata'], + last_timestamp=payload.last_timestamp, + logger=mlflow.logger, + ) + assert response_data == mock_from_dataframe.return_value + + +@mark.asyncio +@patch('laborious.activities.mlflow.to_datetime') +async def test_retrain_model_success_data_success_retrain(mock_to_datetime, mlflow): + mlflow.model_monitoring_repository.retrain_model.return_value = { + 'success': True, + 'experiment': 'test_experiment', + 'message': 'Model retrained successfully.', + } + + raw_data = MagicMock(columns=['variable', 'timestamp', 'value']) + payload = AsyncMock() + payload.retrieve = AsyncMock(return_value=raw_data) + + response = await mlflow.retrain_model( + { + **metadata, + 'data': payload, + 'model_name': 'test_model', + 'model_config': { + 'target': 'target', + 'transform_flavor': 'sklearn', + 'predict_flavor': 'pyfunc', + }, + } + ) + + timestamp = raw_data.__getitem__.return_value.max.return_value + + raw_data.sort_values.assert_not_called() + raw_data.drop_duplicates.assert_called_once_with(subset=['variable', 'timestamp'], keep='first') + raw_data = raw_data.drop_duplicates.return_value + + raw_data.drop.assert_has_calls( + [ + call(columns=['model_id'], inplace=True, errors='ignore'), + call(columns=['created_at'], inplace=True, errors='ignore'), + ] + ) + raw_data.pivot.assert_called_once_with(index='timestamp', columns='variable', values='value') + raw_data.pivot.return_value.fillna.assert_called_once_with(np.nan, inplace=True) + + raw_data = raw_data.pivot.return_value + + raw_data.__setitem__.assert_has_calls( + [ + call('timestamp', raw_data.index), + call('timestamp', mock_to_datetime.return_value.dt.strftime.return_value), + call('timestamp', mock_to_datetime.return_value), + ] + ) + mock_to_datetime.assert_has_calls( + [call(raw_data.__getitem__.return_value, format=DATETIME_FORMAT_WITH_TZ)] + ) + mock_to_datetime.assert_has_calls( + [call(raw_data.__getitem__.return_value, format=DATETIME_FORMAT)] + ) + + mlflow.model_monitoring_repository.retrain_model.assert_called_once_with( + data=raw_data, + model_name='test_model', + model_config={ + 'target': 'target', + 'transform_flavor': 'sklearn', + 'predict_flavor': 'pyfunc', + }, + metadata=metadata['metadata'], + ) + + assert response == { + 'success': True, + 'experiment': 'test_experiment', + 'message': 'Model retrained successfully.', + 'timestamp': timestamp, + } + + +@mark.asyncio +@patch('laborious.activities.mlflow.to_datetime') +async def test_retrain_model_success_with_payload_data(mock_to_datetime, mlflow): + mlflow.model_monitoring_repository.retrain_model.return_value = { + 'success': True, + 'experiment': 'test_experiment', + 'message': 'Model retrained successfully.', + } + + raw_data = MagicMock(columns=['variable', 'timestamp', 'value', 'created_at']) + payload = AsyncMock() + payload.retrieve = AsyncMock(return_value=raw_data) + + response = await mlflow.retrain_model( + { + **metadata, + 'data': payload, + 'model_name': 'test_model', + 'model_config': { + 'target': 'target', + 'transform_flavor': 'sklearn', + 'predict_flavor': 'pyfunc', + }, + } + ) + + assert response['success'] is True + mlflow.minio_repository.download_file.assert_not_called() + + +@mark.asyncio +@patch('laborious.activities.mlflow.to_datetime') +async def test_retrain_model_success_data_fail_retrain(mock_to_datetime, mlflow): + mlflow.model_monitoring_repository.retrain_model.return_value = { + 'success': False, + 'traceback': 'test_traceback', + 'message': 'Model retrained failed.', + } + + raw_data = MagicMock(columns=['variable', 'timestamp', 'value', 'created_at']) + payload = AsyncMock() + payload.retrieve = AsyncMock(return_value=raw_data) + + response = await mlflow.retrain_model( + { + **metadata, + 'data': payload, + 'model_name': 'test_model', + 'model_config': { + 'target': 'target', + 'transform_flavor': 'sklearn', + 'predict_flavor': 'pyfunc', + }, + } + ) + + timestamp = raw_data.__getitem__.return_value.max.return_value + + raw_data.sort_values.assert_called_once_with('created_at', ascending=False) + raw_data.sort_values.return_value.drop_duplicates.assert_called_once_with( + subset=['variable', 'timestamp'], keep='first' + ) + raw_data = raw_data.sort_values.return_value.drop_duplicates.return_value + + raw_data.drop.assert_has_calls( + [ + call(columns=['model_id'], inplace=True, errors='ignore'), + call(columns=['created_at'], inplace=True, errors='ignore'), + ] + ) + raw_data.pivot.assert_called_once_with(index='timestamp', columns='variable', values='value') + raw_data.pivot.return_value.fillna.assert_called_once_with(np.nan, inplace=True) + + raw_data = raw_data.pivot.return_value + + raw_data.__setitem__.assert_has_calls( + [ + call('timestamp', raw_data.index), + call('timestamp', mock_to_datetime.return_value.dt.strftime.return_value), + call('timestamp', mock_to_datetime.return_value), + ] + ) + mock_to_datetime.assert_has_calls( + [call(raw_data.__getitem__.return_value, format=DATETIME_FORMAT_WITH_TZ)] + ) + mock_to_datetime.assert_has_calls( + [call(raw_data.__getitem__.return_value, format=DATETIME_FORMAT)] + ) + + mlflow.model_monitoring_repository.retrain_model.assert_called_once_with( + data=raw_data, + model_name='test_model', + model_config={ + 'target': 'target', + 'transform_flavor': 'sklearn', + 'predict_flavor': 'pyfunc', + }, + metadata=metadata['metadata'], + ) + + mlflow.send_notification_async.assert_called_once_with( + metadata=metadata['metadata'], + notification_id='RETRAIN_MODEL_ERROR', + message='Error retraining model test_model: Model retrained failed.', + block='retrain_model', + level=NotificationLevel.ERROR, + attachment_content=ANY, + ) + + assert response == { + 'success': False, + 'traceback': 'test_traceback', + 'message': 'Model retrained failed.', + 'timestamp': timestamp, + } + + +@mark.asyncio +async def test_retrain_model_data_error(mlflow): + response = await mlflow.retrain_model( + { + **metadata, + 'model_name': 'test_model', + 'model_config': { + 'target': 'target', + 'transform_flavor': 'sklearn', + 'predict_flavor': 'pyfunc', + }, + } + ) + + assert response == { + 'success': False, + 'message': "Error loading retrain data: 'data'", + 'traceback': ANY, + 'timestamp': ANY, + } + + +@mark.asyncio +async def test_retrain_model_data_error_no_minio_repository(mlflow): + mlflow.minio_repository = None + + with raises(ValueError) as e: + await mlflow.retrain_model( + { + **metadata, + 'object_key': 'test_object_key', + 'model_name': 'test_model', + 'model_config': { + 'target': 'target', + 'transform_flavor': 'sklearn', + 'predict_flavor': 'pyfunc', + }, + } + ) + + assert str(e.value) == 'Minio repository not initialized' + + +@mark.asyncio +async def test_update_production_model(mlflow): + input_data = { + **metadata, + 'model_name': 'test_model', + 'model_id': 1, + 'experiment': 'test', + 'timestamp': 2, + 'status': 'success', + } + + response = await mlflow.update_production_model(input_data) + + mlflow.model_monitoring_repository.update_production_model.assert_called_once_with( + experiment='test', model_name='test_model', metadata=metadata['metadata'] + ) + + assert response == mlflow.model_monitoring_repository.update_production_model.return_value + + +@mark.asyncio +async def test_update_production_model_error(mlflow): + mlflow.model_monitoring_repository.update_production_model.side_effect = Exception( + 'Error updating production model' + ) + + input_data = { + **metadata, + 'model_name': 'test_model', + 'model_id': 1, + 'experiment': 'test', + 'timestamp': 2, + 'status': 'success', + } + + try: + await mlflow.update_production_model(input_data) + except Exception as e: + assert str(e) == 'Error updating production model' + mlflow.send_notification_async.assert_called_once_with( + metadata=metadata['metadata'], + notification_id='UPDATE_PRODUCTION_MODEL_ERROR', + message='Error updating production model test_model: Error updating production model', + block='update_production_model', + level=NotificationLevel.ERROR, + attachment_content=ANY, + ) + else: + raise AssertionError('No exception raised') + + +@mark.asyncio +@patch('laborious.activities.mlflow.to_datetime') +async def test_get_reference_data_success(mock_to_datetime, mlflow): + # Arrange + input_data = { + **metadata, + 'model_name': 'test_model', + } + + # Mock reference data DataFrame + mock_reference_data = MagicMock() + mock_reference_data.__getitem__.return_value = MagicMock() + mock_to_datetime.return_value.dt.strftime.return_value = MagicMock() + mock_reference_data.to_dict.return_value = [ + {'timestamp': '2023-05-26 11:12:27', 'value': 1.0}, + {'timestamp': '2023-05-26 11:12:28', 'value': 2.0}, + ] + + mlflow.model_monitoring_repository.load_artifact_dataframe.return_value = mock_reference_data + + # Act + result = await mlflow.get_reference_data(input_data) + + # Assert + mlflow.model_monitoring_repository.load_artifact_dataframe.assert_called_once_with( + model_name='test_model', + artifact_path='evaluation_data.csv', + metadata=metadata['metadata'], + ) + mock_to_datetime.assert_called_once_with(mock_reference_data.__getitem__.return_value) + + mock_reference_data.to_dict.assert_called_once_with(orient='records') + assert result == mock_reference_data.to_dict.return_value + + +@mark.asyncio +async def test_get_reference_data_not_found(mlflow): + # Arrange + input_data = { + **metadata, + 'model_name': 'test_model', + } + + mlflow.model_monitoring_repository.load_artifact_dataframe.return_value = None + + # Act + result = await mlflow.get_reference_data(input_data) + + # Assert + mlflow.model_monitoring_repository.load_artifact_dataframe.assert_called_once_with( + model_name='test_model', + artifact_path='evaluation_data.csv', + metadata=metadata['metadata'], + ) + mlflow.warning.assert_called_once_with( + 'Reference data not found for model test_model', metadata['metadata'] + ) + assert result is None + + +@mark.asyncio +async def test_get_reference_data_exception(mlflow): + # Arrange + input_data = { + **metadata, + 'model_name': 'test_model', + } + + mlflow.model_monitoring_repository.load_artifact_dataframe.side_effect = Exception( + 'Error loading artifact' + ) + + # Act & Assert + with raises(Exception) as e: + await mlflow.get_reference_data(input_data) + + assert str(e.value) == 'Error loading artifact' + mlflow.model_monitoring_repository.load_artifact_dataframe.assert_called_once_with( + model_name='test_model', + artifact_path='evaluation_data.csv', + metadata=metadata['metadata'], + ) diff --git a/tests/laborious/activities/test_model_metrics.py b/tests/laborious/activities/test_model_metrics.py new file mode 100644 index 0000000..b2b5e0b --- /dev/null +++ b/tests/laborious/activities/test_model_metrics.py @@ -0,0 +1,1108 @@ +from unittest.mock import ANY, AsyncMock, MagicMock, patch + +from pandas import DataFrame +from pytest import fixture, mark +from sientia_do.notifications.models import NotificationLevel + +from laborious.activities.model_metrics import ModelMetrics + + +@fixture +def model_metrics_activity(): + model_metrics = ModelMetrics( + logger=MagicMock(), + notification_handler=MagicMock(), + metrics_controller=AsyncMock(), + ) + model_metrics.error = MagicMock() + model_metrics.debug = MagicMock() + model_metrics.info = MagicMock() + model_metrics.warning = MagicMock() + model_metrics.critical = MagicMock() + model_metrics.send_notification = MagicMock() + model_metrics.send_notification_async = AsyncMock() + model_metrics.emit_metric = AsyncMock() + model_metrics.get_core_labels = MagicMock( + return_value={ + 'pod_id': 'test_pod', + 'model_name': 'test_model', + 'workflow_name': 'test_workflow', + } + ) + model_metrics.observe_lag = AsyncMock() + model_metrics.pod_id = 'test_pod' + return model_metrics + + +metadata = { + 'metadata': { + 'model_id': 'test_model', + 'model_name': 'test_model', + 'workflow_name': 'test_workflow', + 'schema_name': 'test_schedule', + }, +} + + +@mark.asyncio +async def test_calculate_drift_invalid_chunk_period(model_metrics_activity): + # Arrange + input_data = { + **metadata, + 'model_name': 'test_model', + 'model_id': 'test_model_id', + 'reference_data': None, + 'target_data': { + 'timestamp': ['2023-05-26 11:12:27'], + 'variable': ['feature1'], + 'value': [1.0], + }, + 'target_name': 'target', + 'drift_metrics': ['ks_test'], + 'chunk_period': 'invalid', + } + + # Act & Assert + try: + await model_metrics_activity.calculate_drift(input_data) + except ValueError as e: + assert str(e) == 'Invalid chunk period: invalid, must be "min" or "s"' + model_metrics_activity.error.assert_called_once_with( + 'Invalid chunk period: invalid', metadata['metadata'] + ) + else: + raise AssertionError('Expected ValueError') + + +@mark.asyncio +@patch('laborious.activities.model_metrics.DataFrame') +@patch('laborious.activities.model_metrics.to_datetime') +async def test_calculate_drift_with_reference_data( + mock_to_datetime, mock_dataframe, model_metrics_activity +): + # Arrange + mock_to_datetime.return_value.dt.strftime.return_value = '2023-05-26 11:12:27' + mock_to_datetime.return_value.dt.tz_localize.return_value.dt.strftime.return_value = ( + '2023-05-26 11:12:27+00:00' + ) + + mock_drift_df = MagicMock() + mock_drift_df.empty = False + mock_drift_df.drop.return_value = mock_drift_df + mock_drift_df.__getitem__.return_value.isin.return_value = [True] + mock_drift_df.__getitem__.return_value = mock_drift_df + mock_drift_df.__getitem__.return_value.dt.tz_localize.return_value.dt.strftime.return_value = ( + '2023-05-26 11:12:27+00:00' + ) + mock_drift_df.rename.return_value = mock_drift_df + mock_drift_df.drop_duplicates.return_value = mock_drift_df + mock_drift_df.to_dict.return_value = [ + { + 'method': 'ks_test', + 'value': 0.5, + 'feature': 'feature1', + 'timestamp': '2023-05-26 11:12:27+00:00', + 'model_id': 'test_model_id', + 'accurate': True, + } + ] + + model_metrics_activity.get_drift_metrics = AsyncMock(return_value=mock_drift_df) + + reference_data = DataFrame( + { + 'timestamp': ['2023-05-26 11:12:27'], + 'target': [1.0], + 'feature1': [1.0], + } + ) + + mock_target_df = MagicMock() + mock_target_df.pivot.return_value = mock_target_df + mock_target_df.index = ['2023-05-26 11:12:27'] + mock_target_df.reset_index.return_value = mock_target_df + mock_target_df.dropna.return_value = mock_target_df + mock_target_df.__getitem__.return_value.apply.return_value = ['2023-05-26 11:12:27'] + mock_target_df.drop.return_value.columns = ['feature1'] + mock_dataframe.return_value = mock_target_df + + input_data = { + **metadata, + 'model_name': 'test_model', + 'model_id': 'test_model_id', + 'reference_data': reference_data.to_dict(), + 'target_data': { + 'timestamp': ['2023-05-26 11:12:27'], + 'variable': ['feature1'], + 'value': [1.0], + }, + 'target_name': 'target', + 'drift_metrics': ['ks_test'], + 'chunk_period': 'min', + } + + # Act + result = await model_metrics_activity.calculate_drift(input_data) + + # Assert + assert isinstance(result, list) + assert result == mock_drift_df.to_dict.return_value # type: ignore[comparison-overlap] + model_metrics_activity.info.assert_called() + model_metrics_activity.get_drift_metrics.assert_called_once() + # Verify transformations were called + mock_drift_df.drop.assert_called_once_with(columns=['p_value'], inplace=True) + mock_drift_df.__getitem__.assert_called() + mock_drift_df.rename.assert_called_once_with( + columns={'metric': 'method', 'statistic': 'value'}, inplace=True + ) + mock_drift_df.drop_duplicates.assert_called_once_with( + subset=['timestamp', 'method', 'feature'], keep='first', inplace=True + ) + mock_drift_df.to_dict.assert_called_once_with(orient='records') + + +@mark.asyncio +@patch('laborious.activities.model_metrics.DataFrame') +@patch('laborious.activities.model_metrics.to_datetime') +async def test_calculate_drift_without_reference_data( + mock_to_datetime, mock_dataframe, model_metrics_activity +): + # Arrange + mock_to_datetime.return_value.dt.strftime.return_value = '2023-05-26 11:12:27' + mock_to_datetime.return_value.dt.tz_localize.return_value.dt.strftime.return_value = ( + '2023-05-26 11:12:27+00:00' + ) + + mock_drift_df = MagicMock() + mock_drift_df.empty = False + mock_drift_df.drop.return_value = mock_drift_df + mock_drift_df.__getitem__.return_value.isin.return_value = [True] + mock_drift_df.__getitem__.return_value = mock_drift_df + mock_drift_df.__getitem__.return_value.dt.tz_localize.return_value.dt.strftime.return_value = ( + '2023-05-26 11:12:27+00:00' + ) + mock_drift_df.rename.return_value = mock_drift_df + mock_drift_df.drop_duplicates.return_value = mock_drift_df + mock_drift_df.to_dict.return_value = [ + { + 'method': 'ks_test', + 'value': 0.5, + 'feature': 'feature1', + 'timestamp': '2023-05-26 11:12:27+00:00', + 'model_id': 'test_model_id', + 'accurate': False, + } + ] + + model_metrics_activity.get_drift_metrics = AsyncMock(return_value=mock_drift_df) + + target_data_dict = { + 'timestamp': ['2023-05-26 11:12:27', '2023-05-26 11:12:28', '2023-05-26 11:12:29'], + 'variable': ['feature1', 'feature1', 'feature1'], + 'value': [1.0, 2.0, 3.0], + } + + mock_target_df = MagicMock() + mock_target_df.pivot.return_value = mock_target_df + mock_target_df.index = ['2023-05-26 11:12:27', '2023-05-26 11:12:28', '2023-05-26 11:12:29'] + mock_target_df.reset_index.return_value = mock_target_df + mock_target_df.dropna.return_value = mock_target_df + mock_target_df.sort_values.return_value = mock_target_df + mock_target_df.head.return_value = DataFrame( + {'timestamp': ['2023-05-26 11:12:27'], 'feature1': [1.0]} + ) + mock_target_df.__getitem__.return_value.apply.return_value = [ + '2023-05-26 11:12:27', + '2023-05-26 11:12:28', + '2023-05-26 11:12:29', + ] + mock_target_df.drop.return_value.columns = ['feature1'] + mock_dataframe.return_value = mock_target_df + mock_dataframe.side_effect = lambda x=None: mock_target_df if x is not None else mock_target_df + + input_data = { + **metadata, + 'model_name': 'test_model', + 'model_id': 'test_model_id', + 'reference_data': None, + 'target_data': target_data_dict, + 'target_name': 'target', + 'drift_metrics': ['ks_test'], + 'chunk_period': 's', + } + + # Act + result = await model_metrics_activity.calculate_drift(input_data) + + # Assert + assert isinstance(result, list) + assert result == mock_drift_df.to_dict.return_value + model_metrics_activity.warning.assert_called() + model_metrics_activity.send_notification_async.assert_called_once_with( + metadata=metadata['metadata'], + notification_id='MODEL_METRICS_REFERENCE_DATA_WARNING', + message='Using 30% first rows of target data as reference data', + block='model_metrics', + level=NotificationLevel.WARNING, + attachment_content=ANY, + ) + # Verify transformations were called + mock_drift_df.drop.assert_called_once_with(columns=['p_value'], inplace=True) + mock_drift_df.__getitem__.assert_called() + mock_drift_df.rename.assert_called_once_with( + columns={'metric': 'method', 'statistic': 'value'}, inplace=True + ) + mock_drift_df.drop_duplicates.assert_called_once_with( + subset=['timestamp', 'method', 'feature'], keep='first', inplace=True + ) + mock_drift_df.to_dict.assert_called_once_with(orient='records') + + +@mark.asyncio +@patch('laborious.activities.model_metrics.DataFrame') +@patch('laborious.activities.model_metrics.to_datetime') +async def test_calculate_drift_empty_drift_df( + mock_to_datetime, mock_dataframe, model_metrics_activity +): + # Arrange + mock_to_datetime.return_value.dt.strftime.return_value = '2023-05-26 11:12:27' + + model_metrics_activity.get_drift_metrics = AsyncMock(return_value=DataFrame()) + + reference_data = DataFrame( + { + 'timestamp': ['2023-05-26 11:12:27'], + 'target': [1.0], + 'feature1': [1.0], + } + ) + + mock_target_df = MagicMock() + mock_target_df.pivot.return_value = mock_target_df + mock_target_df.index = ['2023-05-26 11:12:27'] + mock_target_df.reset_index.return_value = mock_target_df + mock_target_df.dropna.return_value = mock_target_df + mock_target_df.__getitem__.return_value.apply.return_value = ['2023-05-26 11:12:27'] + mock_target_df.drop.return_value.columns = ['feature1'] + mock_dataframe.return_value = mock_target_df + + input_data = { + **metadata, + 'model_name': 'test_model', + 'model_id': 'test_model_id', + 'reference_data': reference_data.to_dict(), + 'target_data': { + 'timestamp': ['2023-05-26 11:12:27'], + 'variable': ['feature1'], + 'value': [1.0], + }, + 'target_name': 'target', + 'drift_metrics': ['ks_test'], + 'chunk_period': 'min', + } + + # Act + result = await model_metrics_activity.calculate_drift(input_data) + + # Assert + assert result == [] + model_metrics_activity.warning.assert_called_with( + 'No drift metrics found', metadata['metadata'] + ) + + +@mark.asyncio +@patch('laborious.activities.model_metrics.DataFrame') +@patch('laborious.activities.model_metrics.to_datetime') +async def test_calculate_drift_empty_after_timestamp_filter( + mock_to_datetime, mock_dataframe, model_metrics_activity +): + # Arrange + mock_to_datetime.return_value.dt.strftime.return_value = '2023-05-26 11:12:27' + + mock_drift_df = MagicMock() + mock_drift_df.empty = False + mock_drift_df.drop.return_value = mock_drift_df + + # Set up __getitem__ to handle filtering - timestamp access returns series with isin=False + # and filtering returns empty DataFrame + mock_timestamp_series = MagicMock() + mock_timestamp_series.isin.return_value = [False] + mock_empty_df = MagicMock() + mock_empty_df.empty = True + + def getitem_side_effect(key): + if key == 'timestamp': + return mock_timestamp_series + else: + # This is the filtering operation - return empty DataFrame + return mock_empty_df + + mock_drift_df.__getitem__.side_effect = getitem_side_effect + + model_metrics_activity.get_drift_metrics = AsyncMock(return_value=mock_drift_df) + + reference_data = DataFrame( + { + 'timestamp': ['2023-05-26 11:12:27'], + 'target': [1.0], + 'feature1': [1.0], + } + ) + + mock_target_df = MagicMock() + mock_target_df.pivot.return_value = mock_target_df + mock_target_df.index = ['2023-05-26 11:12:27'] + mock_target_df.reset_index.return_value = mock_target_df + mock_target_df.dropna.return_value = mock_target_df + mock_target_df.__getitem__.return_value.apply.return_value = ['2023-05-26 11:12:27'] + mock_target_df.drop.return_value.columns = ['feature1'] + mock_dataframe.return_value = mock_target_df + + input_data = { + **metadata, + 'model_name': 'test_model', + 'model_id': 'test_model_id', + 'reference_data': reference_data.to_dict(), + 'target_data': { + 'timestamp': ['2023-05-26 11:12:27'], + 'variable': ['feature1'], + 'value': [1.0], + }, + 'target_name': 'target', + 'drift_metrics': ['ks_test'], + 'chunk_period': 'min', + } + + # Act + result = await model_metrics_activity.calculate_drift(input_data) + + # Assert + assert result == [] + model_metrics_activity.warning.assert_called_with( + 'No drift metrics found after dropping rows where timestamp is not in target data', + metadata['metadata'], + ) + # Verify transformations were called + mock_drift_df.drop.assert_called_once_with(columns=['p_value'], inplace=True) + mock_drift_df.__getitem__.assert_called() + + +@mark.asyncio +@patch('laborious.activities.model_metrics.DataFrame') +@patch('laborious.activities.model_metrics.to_datetime') +async def test_calculate_drift_success_min( + mock_to_datetime, mock_dataframe, model_metrics_activity +): + # Arrange + mock_to_datetime.return_value.dt.strftime.return_value = '2023-05-26 11:12:27' + mock_to_datetime.return_value.dt.tz_localize.return_value.dt.strftime.return_value = ( + '2023-05-26 11:12:27+00:00' + ) + + mock_drift_df = MagicMock() + mock_drift_df.empty = False + mock_drift_df.drop.return_value = mock_drift_df + mock_drift_df.__getitem__.return_value.isin.return_value = [True] + mock_drift_df.__getitem__.return_value = mock_drift_df + mock_drift_df.__getitem__.return_value.dt.tz_localize.return_value.dt.strftime.return_value = ( + '2023-05-26 11:12:27+00:00' + ) + mock_drift_df.rename.return_value = mock_drift_df + mock_drift_df.drop_duplicates.return_value = mock_drift_df + mock_drift_df.to_dict.return_value = [ + { + 'method': 'ks_test', + 'value': 0.5, + 'feature': 'feature1', + 'timestamp': '2023-05-26 11:12:27+00:00', + 'model_id': 'test_model_id', + 'accurate': True, + } + ] + + model_metrics_activity.get_drift_metrics = AsyncMock(return_value=mock_drift_df) + + reference_data = DataFrame( + { + 'timestamp': ['2023-05-26 11:12:27'], + 'target': [1.0], + 'feature1': [1.0], + } + ) + + mock_target_df = MagicMock() + mock_target_df.pivot.return_value = mock_target_df + mock_target_df.index = ['2023-05-26 11:12:27'] + mock_target_df.reset_index.return_value = mock_target_df + mock_target_df.dropna.return_value = mock_target_df + mock_target_df.__getitem__.return_value.isin.return_value = [True] + mock_target_df.drop.return_value.columns = ['feature1'] + mock_dataframe.return_value = mock_target_df + + input_data = { + **metadata, + 'model_name': 'test_model', + 'model_id': 'test_model_id', + 'reference_data': reference_data.to_dict(), + 'target_data': { + 'timestamp': ['2023-05-26 11:12:27'], + 'variable': ['feature1'], + 'value': [1.0], + }, + 'target_name': 'target', + 'drift_metrics': ['ks_test'], + 'chunk_period': 'min', + } + + # Act + result = await model_metrics_activity.calculate_drift(input_data) + + # Assert + assert isinstance(result, list) + assert result == mock_drift_df.to_dict.return_value # type: ignore[comparison-overlap] + model_metrics_activity.info.assert_called() + model_metrics_activity.get_drift_metrics.assert_called_once() + # Verify transformations were called + mock_drift_df.drop.assert_called_once_with(columns=['p_value'], inplace=True) + mock_drift_df.__getitem__.assert_called() + mock_drift_df.rename.assert_called_once_with( + columns={'metric': 'method', 'statistic': 'value'}, inplace=True + ) + mock_drift_df.drop_duplicates.assert_called_once_with( + subset=['timestamp', 'method', 'feature'], keep='first', inplace=True + ) + mock_drift_df.to_dict.assert_called_once_with(orient='records') + + +@mark.asyncio +@patch('laborious.activities.model_metrics.DataFrame') +@patch('laborious.activities.model_metrics.to_datetime') +async def test_calculate_drift_success_s(mock_to_datetime, mock_dataframe, model_metrics_activity): + # Arrange + mock_to_datetime.return_value.dt.strftime.return_value = '2023-05-26 11:12:27' + mock_to_datetime.return_value.dt.tz_localize.return_value.dt.strftime.return_value = ( + '2023-05-26 11:12:27+00:00' + ) + + mock_drift_df = MagicMock() + mock_drift_df.empty = False + mock_drift_df.drop.return_value = mock_drift_df + mock_drift_df.__getitem__.return_value.isin.return_value = [True] + mock_drift_df.__getitem__.return_value = mock_drift_df + mock_drift_df.__getitem__.return_value.dt.tz_localize.return_value.dt.strftime.return_value = ( + '2023-05-26 11:12:27+00:00' + ) + mock_drift_df.rename.return_value = mock_drift_df + mock_drift_df.drop_duplicates.return_value = mock_drift_df + mock_drift_df.to_dict.return_value = [ + { + 'method': 'ks_test', + 'value': 0.5, + 'feature': 'feature1', + 'timestamp': '2023-05-26 11:12:27+00:00', + 'model_id': 'test_model_id', + 'accurate': True, + } + ] + + model_metrics_activity.get_drift_metrics = AsyncMock(return_value=mock_drift_df) + + reference_data = DataFrame( + { + 'timestamp': ['2023-05-26 11:12:27'], + 'target': [1.0], + 'feature1': [1.0], + } + ) + + mock_target_df = MagicMock() + mock_target_df.pivot.return_value = mock_target_df + mock_target_df.index = ['2023-05-26 11:12:27'] + mock_target_df.reset_index.return_value = mock_target_df + mock_target_df.dropna.return_value = mock_target_df + mock_target_df.__getitem__.return_value.isin.return_value = [True] + mock_target_df.drop.return_value.columns = ['feature1'] + mock_dataframe.return_value = mock_target_df + + input_data = { + **metadata, + 'model_name': 'test_model', + 'model_id': 'test_model_id', + 'reference_data': reference_data.to_dict(), + 'target_data': { + 'timestamp': ['2023-05-26 11:12:27'], + 'variable': ['feature1'], + 'value': [1.0], + }, + 'target_name': 'target', + 'drift_metrics': ['ks_test'], + 'chunk_period': 's', + } + + # Act + result = await model_metrics_activity.calculate_drift(input_data) + + # Assert + assert isinstance(result, list) + assert result == mock_drift_df.to_dict.return_value # type: ignore[comparison-overlap] + model_metrics_activity.info.assert_called() + model_metrics_activity.get_drift_metrics.assert_called_once() + # Verify transformations were called + mock_drift_df.drop.assert_called_once_with(columns=['p_value'], inplace=True) + mock_drift_df.__getitem__.assert_called() + mock_drift_df.rename.assert_called_once_with( + columns={'metric': 'method', 'statistic': 'value'}, inplace=True + ) + mock_drift_df.drop_duplicates.assert_called_once_with( + subset=['timestamp', 'method', 'feature'], keep='first', inplace=True + ) + mock_drift_df.to_dict.assert_called_once_with(orient='records') + + +@mark.asyncio +@patch('laborious.activities.model_metrics.DataFrame') +@patch('laborious.activities.model_metrics.to_datetime') +async def test_calculate_drift_get_drift_metrics_error( + mock_to_datetime, mock_dataframe, model_metrics_activity +): + # Arrange + mock_to_datetime.return_value.dt.strftime.return_value = '2023-05-26 11:12:27' + + model_metrics_activity.get_drift_metrics = AsyncMock( + side_effect=Exception('Get drift metrics error') + ) + + reference_data = DataFrame( + { + 'timestamp': ['2023-05-26 11:12:27'], + 'target': [1.0], + 'feature1': [1.0], + } + ) + + mock_target_df = MagicMock() + mock_target_df.pivot.return_value = mock_target_df + mock_target_df.index = ['2023-05-26 11:12:27'] + mock_target_df.reset_index.return_value = mock_target_df + mock_target_df.dropna.return_value = mock_target_df + mock_target_df.__getitem__.return_value.apply.return_value = ['2023-05-26 11:12:27'] + mock_target_df.drop.return_value.columns = ['feature1'] + mock_dataframe.return_value = mock_target_df + + input_data = { + **metadata, + 'model_name': 'test_model', + 'model_id': 'test_model_id', + 'reference_data': reference_data.to_dict(), + 'target_data': { + 'timestamp': ['2023-05-26 11:12:27'], + 'variable': ['feature1'], + 'value': [1.0], + }, + 'target_name': 'target', + 'drift_metrics': ['ks_test'], + 'chunk_period': 'min', + } + + # Act + result = await model_metrics_activity.calculate_drift(input_data) + + # Assert + assert result == [] + model_metrics_activity.error.assert_called_once_with( + 'Error getting drift metrics: Get drift metrics error', metadata['metadata'] + ) + model_metrics_activity.send_notification_async.assert_called_once_with( + metadata=metadata['metadata'], + notification_id='MODEL_METRICS_GET_DRIFT_METRICS_ERROR', + message='Error getting drift metrics: Get drift metrics error', + block='model_metrics', + level=NotificationLevel.ERROR, + attachment_content=ANY, + ) + + +@mark.asyncio +@patch('laborious.activities.model_metrics.to_datetime') +@patch('laborious.activities.model_metrics.time.time') +@patch('laborious.activities.model_metrics.ModelAnalysis') +@patch('laborious.activities.model_metrics.metrics') +async def test_get_drift_metrics_success( + mock_metrics, mock_model_analysis, mock_time, mock_to_datetime, model_metrics_activity +): + # Arrange + mock_time.return_value = 1000.0 + + mock_drift_df = DataFrame( + { + 'timestamp': ['2023-05-26 11:12:27'], + 'metric': ['ks_test'], + 'statistic': [0.5], + 'feature': ['feature1'], + } + ) + + mock_model_analysis.return_value.detect_univariate_drift.return_value = MagicMock() + mock_model_analysis.return_value.detect_multivariate_drift.return_value = MagicMock() + mock_model_analysis.return_value.get_drift_metrics_dataframe.return_value = mock_drift_df + + reference_data = DataFrame( + { + 'timestamp': ['2023-05-26 11:12:27'], + 'target': [1.0], + 'feature1': [1.0], + } + ) + + target_data = DataFrame( + { + 'timestamp': ['2023-05-26 11:12:27'], + 'target': [1.0], + 'feature1': [1.0], + } + ) + + reference_columns = reference_data.drop( + columns=['target', 'timestamp'], errors='ignore' + ).columns + + # Act + result = await model_metrics_activity.get_drift_metrics( + reference_data=reference_data, + target_data=target_data, + target_name='target', + reference_columns=reference_columns, + drift_metrics=['ks_test'], + chunk_period='min', + metadata=metadata['metadata'], + ) + + # Assert + assert isinstance(result, DataFrame) + model_metrics_activity.debug.assert_called() + model_metrics_activity.observe_lag.assert_called() + model_metrics_activity.emit_metric.assert_called() + + +@mark.asyncio +@patch('laborious.activities.model_metrics.to_datetime') +@patch('laborious.activities.model_metrics.time.time') +@patch('laborious.activities.model_metrics.ModelAnalysis') +@patch('laborious.activities.model_metrics.metrics') +async def test_get_drift_metrics_univariate_error( + mock_metrics, mock_model_analysis, mock_time, mock_to_datetime, model_metrics_activity +): + # Arrange + mock_time.return_value = 1000.0 + + mock_model_analysis.return_value.detect_univariate_drift.side_effect = Exception( + 'Univariate drift error' + ) + + reference_data = DataFrame( + { + 'timestamp': ['2023-05-26 11:12:27'], + 'target': [1.0], + 'feature1': [1.0], + } + ) + + target_data = DataFrame( + { + 'timestamp': ['2023-05-26 11:12:27'], + 'target': [1.0], + 'feature1': [1.0], + } + ) + + reference_columns = reference_data.drop( + columns=['target', 'timestamp'], errors='ignore' + ).columns + + # Act & Assert + try: + await model_metrics_activity.get_drift_metrics( + reference_data=reference_data, + target_data=target_data, + target_name='target', + reference_columns=reference_columns, + drift_metrics=['ks_test'], + chunk_period='min', + metadata=metadata['metadata'], + ) + except Exception as e: + assert str(e) == 'Univariate drift error' + model_metrics_activity.error.assert_called_once_with( + 'Error detecting univariate drift: Univariate drift error', metadata['metadata'] + ) + model_metrics_activity.emit_metric.assert_called_with( + metric_object=mock_metrics.MODEL_ANALYZE_ERROR_COUNT, tags=ANY + ) + else: + raise AssertionError('Expected Exception') + + +@mark.asyncio +@patch('laborious.activities.model_metrics.to_datetime') +@patch('laborious.activities.model_metrics.time.time') +@patch('laborious.activities.model_metrics.ModelAnalysis') +@patch('laborious.activities.model_metrics.metrics') +async def test_get_drift_metrics_multivariate_error( + mock_metrics, mock_model_analysis, mock_time, mock_to_datetime, model_metrics_activity +): + mock_time.return_value = 1000.0 + + mock_model_analysis.return_value.detect_univariate_drift.return_value = MagicMock() + mock_model_analysis.return_value.detect_multivariate_drift.side_effect = Exception( + 'Multivariate drift error' + ) + + reference_data = DataFrame( + {'timestamp': ['2023-05-26 11:12:27'], 'target': [1.0], 'feature1': [1.0]} + ) + target_data = DataFrame( + {'timestamp': ['2023-05-26 11:12:27'], 'target': [1.0], 'feature1': [1.0]} + ) + reference_columns = reference_data.drop( + columns=['target', 'timestamp'], errors='ignore' + ).columns + + try: + await model_metrics_activity.get_drift_metrics( + reference_data=reference_data, + target_data=target_data, + target_name='target', + reference_columns=reference_columns, + drift_metrics=['ks_test'], + chunk_period='min', + metadata=metadata['metadata'], + ) + except Exception as e: + assert str(e) == 'Multivariate drift error' + model_metrics_activity.error.assert_called_once_with( + 'Error detecting multivariate drift: Multivariate drift error', metadata['metadata'] + ) + model_metrics_activity.emit_metric.assert_called_with( + metric_object=mock_metrics.MODEL_ANALYZE_ERROR_COUNT, tags=ANY + ) + else: + raise AssertionError('Expected Exception') + + +@mark.asyncio +@patch('laborious.activities.model_metrics.to_datetime') +@patch('laborious.activities.model_metrics.time.time') +@patch('laborious.activities.model_metrics.ModelAnalysis') +@patch('laborious.activities.model_metrics.metrics') +async def test_get_drift_metrics_dataframe_error( + mock_metrics, mock_model_analysis, mock_time, mock_to_datetime, model_metrics_activity +): + mock_time.return_value = 1000.0 + + mock_model_analysis.return_value.detect_univariate_drift.return_value = MagicMock() + mock_model_analysis.return_value.detect_multivariate_drift.return_value = MagicMock() + mock_model_analysis.return_value.get_drift_metrics_dataframe.side_effect = Exception( + 'Dataframe error' + ) + + reference_data = DataFrame( + {'timestamp': ['2023-05-26 11:12:27'], 'target': [1.0], 'feature1': [1.0]} + ) + target_data = DataFrame( + {'timestamp': ['2023-05-26 11:12:27'], 'target': [1.0], 'feature1': [1.0]} + ) + reference_columns = reference_data.drop( + columns=['target', 'timestamp'], errors='ignore' + ).columns + + try: + await model_metrics_activity.get_drift_metrics( + reference_data=reference_data, + target_data=target_data, + target_name='target', + reference_columns=reference_columns, + drift_metrics=['ks_test'], + chunk_period='min', + metadata=metadata['metadata'], + ) + except Exception as e: + assert str(e) == 'Dataframe error' + model_metrics_activity.error.assert_called_once_with( + 'Error getting drift metrics: Dataframe error', metadata['metadata'] + ) + model_metrics_activity.emit_metric.assert_called_with( + metric_object=mock_metrics.MODEL_ANALYZE_ERROR_COUNT, tags=ANY + ) + else: + raise AssertionError('Expected Exception') + + +@mark.asyncio +async def test_calculate_simple_metrics_success_all_metrics(model_metrics_activity): + # Arrange + target_data = DataFrame( + { + 'timestamp': ['2023-05-26 11:12:27', '2023-05-26 11:12:28', '2023-05-26 11:12:29'], + 'target': [1.0, 2.0, 3.0], + 'prediction': [1.1, 2.1, 2.9], + } + ) + + input_data = { + **metadata, + 'model_id': 'test_model_id', + 'target_data': target_data.to_dict(), + 'metrics': ['rmse', 'mse', 'mae', 'r2'], + 'interval_minutes': 5, + } + + # Act + result = DataFrame(await model_metrics_activity.calculate_simple_metrics(input_data)) + + # Assert + assert len(result['metric']) == 4 + assert 'rmse' in result['metric'].values + assert 'mse' in result['metric'].values + assert 'mae' in result['metric'].values + assert 'r2' in result['metric'].values + assert all(model_id == 'test_model_id' for model_id in result['model_id'].values) + assert all(timestamp == '2023-05-26 11:12:29' for timestamp in result['timestamp'].values) + assert all(data_size == 3 for data_size in result['data_size'].values) + assert all(interval_minutes == 5 for interval_minutes in result['interval_minutes'].values) + model_metrics_activity.info.assert_called_once_with( + "Calculating simple metrics for model test_model_id: ['rmse', 'mse', 'mae', 'r2']", + metadata['metadata'], + ) + model_metrics_activity.debug.assert_called_once() + + +@mark.asyncio +async def test_calculate_simple_metrics_success_rmse_only(model_metrics_activity): + # Arrange + target_data = DataFrame( + { + 'timestamp': ['2023-05-26 11:12:27', '2023-05-26 11:12:28'], + 'target': [1.0, 2.0], + 'prediction': [1.1, 2.1], + } + ) + + input_data = { + **metadata, + 'model_id': 'test_model_id', + 'target_data': target_data.to_dict(), + 'metrics': ['rmse'], + 'interval_minutes': 5, + } + + # Act + result = DataFrame(await model_metrics_activity.calculate_simple_metrics(input_data)) + + # Assert + assert len(result['metric']) == 1 + assert result['metric'].values[0] == 'rmse' + assert result['model_id'].values[0] == 'test_model_id' + assert result['timestamp'].values[0] == '2023-05-26 11:12:28' + assert result['data_size'].values[0] == 2 + assert result['interval_minutes'].values[0] == 5 + model_metrics_activity.info.assert_called_once_with( + "Calculating simple metrics for model test_model_id: ['rmse']", metadata['metadata'] + ) + + +@mark.asyncio +async def test_calculate_simple_metrics_success_mse_only(model_metrics_activity): + # Arrange + target_data = DataFrame( + { + 'timestamp': ['2023-05-26 11:12:27', '2023-05-26 11:12:28'], + 'target': [1.0, 2.0], + 'prediction': [1.1, 2.1], + } + ) + + input_data = { + **metadata, + 'model_id': 'test_model_id', + 'target_data': target_data.to_dict(), + 'metrics': ['mse'], + 'interval_minutes': 5, + } + + # Act + result = DataFrame(await model_metrics_activity.calculate_simple_metrics(input_data)) + + # Assert + assert len(result['metric']) == 1 + assert result['metric'].values[0] == 'mse' + assert result['model_id'].values[0] == 'test_model_id' + assert result['timestamp'].values[0] == '2023-05-26 11:12:28' + assert result['data_size'].values[0] == 2 + assert result['interval_minutes'].values[0] == 5 + model_metrics_activity.info.assert_called_once_with( + "Calculating simple metrics for model test_model_id: ['mse']", metadata['metadata'] + ) + + +@mark.asyncio +async def test_calculate_simple_metrics_success_mae_only(model_metrics_activity): + # Arrange + target_data = DataFrame( + { + 'timestamp': ['2023-05-26 11:12:27', '2023-05-26 11:12:28'], + 'target': [1.0, 2.0], + 'prediction': [1.1, 2.1], + } + ) + + input_data = { + **metadata, + 'model_id': 'test_model_id', + 'target_data': target_data.to_dict(), + 'metrics': ['mae'], + 'interval_minutes': 5, + } + + # Act + result = DataFrame(await model_metrics_activity.calculate_simple_metrics(input_data)) + + # Assert + assert len(result['metric']) == 1 + assert result['metric'].values[0] == 'mae' + assert result['model_id'].values[0] == 'test_model_id' + assert result['timestamp'].values[0] == '2023-05-26 11:12:28' + assert result['data_size'].values[0] == 2 + assert result['interval_minutes'].values[0] == 5 + model_metrics_activity.info.assert_called_once_with( + "Calculating simple metrics for model test_model_id: ['mae']", metadata['metadata'] + ) + + +@mark.asyncio +async def test_calculate_simple_metrics_success_r2_only(model_metrics_activity): + # Arrange + target_data = DataFrame( + { + 'timestamp': ['2023-05-26 11:12:27', '2023-05-26 11:12:28'], + 'target': [1.0, 2.0], + 'prediction': [1.1, 2.1], + } + ) + + input_data = { + **metadata, + 'model_id': 'test_model_id', + 'target_data': target_data.to_dict(), + 'metrics': ['r2'], + 'interval_minutes': 5, + } + + # Act + result = DataFrame(await model_metrics_activity.calculate_simple_metrics(input_data)) + + # Assert + assert len(result['metric']) == 1 + assert result['metric'].values[0] == 'r2' + assert result['model_id'].values[0] == 'test_model_id' + assert result['timestamp'].values[0] == '2023-05-26 11:12:28' + assert result['data_size'].values[0] == 2 + assert result['interval_minutes'].values[0] == 5 + model_metrics_activity.info.assert_called_once_with( + "Calculating simple metrics for model test_model_id: ['r2']", metadata['metadata'] + ) + + +@mark.asyncio +async def test_calculate_simple_metrics_r2_zero_ss_tot(model_metrics_activity): + # Arrange + # All target values are the same, so ss_tot will be 0 + target_data = DataFrame( + { + 'timestamp': ['2023-05-26 11:12:27', '2023-05-26 11:12:28'], + 'target': [1.0, 1.0], + 'prediction': [1.1, 1.1], + } + ) + + input_data = { + **metadata, + 'model_id': 'test_model_id', + 'target_data': target_data.to_dict(), + 'metrics': ['r2'], + 'interval_minutes': 5, + } + + # Act + result = DataFrame(await model_metrics_activity.calculate_simple_metrics(input_data)) + + # Assert + assert len(result['metric']) == 1 + assert result['metric'].values[0] == 'r2' + assert result['value'].values[0] == 0.0 # Should return 0.0 when ss_tot == 0 + assert result['model_id'].values[0] == 'test_model_id' + assert result['timestamp'].values[0] == '2023-05-26 11:12:28' + assert result['data_size'].values[0] == 2 + assert result['interval_minutes'].values[0] == 5 + model_metrics_activity.info.assert_called_once_with( + "Calculating simple metrics for model test_model_id: ['r2']", metadata['metadata'] + ) + + +@mark.asyncio +async def test_calculate_simple_metrics_success_multiple_metrics_subset(model_metrics_activity): + # Arrange + target_data = DataFrame( + { + 'timestamp': ['2023-05-26 11:12:27', '2023-05-26 11:12:28', '2023-05-26 11:12:29'], + 'target': [1.0, 2.0, 3.0], + 'prediction': [1.1, 2.1, 2.9], + } + ) + + input_data = { + **metadata, + 'model_id': 'test_model_id', + 'target_data': target_data.to_dict(), + 'metrics': ['rmse', 'mae'], + 'interval_minutes': 5, + } + + # Act + result = DataFrame(await model_metrics_activity.calculate_simple_metrics(input_data)) + + # Assert + assert len(result['metric']) == 2 + assert 'rmse' in result['metric'].values + assert 'mae' in result['metric'].values + assert all(model_id == 'test_model_id' for model_id in result['model_id'].values) + assert all(timestamp == '2023-05-26 11:12:29' for timestamp in result['timestamp'].values) + assert all(data_size == 3 for data_size in result['data_size'].values) + assert all(interval_minutes == 5 for interval_minutes in result['interval_minutes'].values) + model_metrics_activity.info.assert_called_once_with( + "Calculating simple metrics for model test_model_id: ['rmse', 'mae']", metadata['metadata'] + ) + + +@mark.asyncio +async def test_calculate_simple_metrics_unknown_metric_ignored(model_metrics_activity): + target_data = DataFrame( + { + 'timestamp': ['2023-05-26 11:12:27', '2023-05-26 11:12:28'], + 'target': [1.0, 2.0], + 'prediction': [1.1, 2.1], + } + ) + + input_data = { + **metadata, + 'model_id': 'test_model_id', + 'target_data': target_data.to_dict(), + 'metrics': ['unknown_metric', 'rmse'], + 'interval_minutes': 5, + } + + result = DataFrame(await model_metrics_activity.calculate_simple_metrics(input_data)) + + assert len(result['metric']) == 1 + assert result['metric'].values[0] == 'rmse' diff --git a/tests/laborious/activities/test_opc.py b/tests/laborious/activities/test_opc.py new file mode 100644 index 0000000..c2d37dd --- /dev/null +++ b/tests/laborious/activities/test_opc.py @@ -0,0 +1,738 @@ +from unittest.mock import ANY, AsyncMock, MagicMock, call, patch + +import pytest_asyncio +from pandas import DataFrame +from pytest import mark +from sientia_do.notifications.models import NotificationLevel + +from laborious.activities.opc import ( + OPC, + OPC_COMMENT_SEPARATOR, + OPC_RECONNECT_IN_PROGRESS_COMMENT, + OPC_SESSION_BAD_COMMENT_PREFIX, + OPC_SESSION_BAD_CONFIDENCE, + OPC_WRITTING_ERROR_CONFIDENCE, + OPC_WRITTING_ERROR_MESSAGE, + _apply_opc_write_error, +) + +metadata = { + 'metadata': { + 'model_id': 'test_model', + 'model_name': 'test_model', + 'workflow_name': 'test_workflow', + 'schema_name': 'test_schedule', + }, +} + + +def test__init__(): + servers = {'server1': {'id': 'server1'}} + opc = OPC( + opc_servers=servers, + logger=MagicMock(), + notification_handler=MagicMock(), + metrics_controller=AsyncMock(), + ) + + assert opc.opc_servers == servers + assert opc.opc_repository == {} + + +@mark.asyncio +@patch('laborious.activities.opc.OpcRepository') +@patch('laborious.activities.opc.OPC.send_notification_async') +async def test_init_opc(mock_send_notification, mock_opc_repository): + mock_logger = MagicMock() + mock_metrics_controller = AsyncMock() + server1 = MagicMock( + connect=AsyncMock(return_value=(True, {})), write_data=AsyncMock(return_value=(True, {})) + ) + server2 = MagicMock( + connect=AsyncMock(return_value=(True, {})), write_data=AsyncMock(return_value=(True, {})) + ) + server3 = MagicMock( + connect=AsyncMock( + return_value=( + False, + { + 'notification_id': 'OPC_CONNECTION_ERROR_server3', + 'message': 'Failed to connect to OPC server: Test error', + 'block': 'opc_repository', + 'level': NotificationLevel.ERROR, + 'attachment_content': 'Test error', + }, + ) + ), + write_data=AsyncMock(return_value=(True, {})), + ) + mock_opc_repository.side_effect = [server1, server2, server3] + mock_notification_handler = MagicMock() + servers = { + 'server1': { + 'server_name': 'server1', + 'id': 'server1', + 'url': 'http://localhost:8080', + 'server_uri': 'opc.tcp://localhost:4840', + 'cert_path': '', + 'private_key_path': '', + 'server_cert_path': '', + 'reconnection_interval': 60, + }, + 'server2': { + 'server_name': 'server2', + 'id': 'server2', + 'url': 'http://localhost:8080', + 'server_uri': 'opc.tcp://localhost:4840', + 'cert_path': '', + 'private_key_path': '', + 'server_cert_path': '', + 'reconnection_interval': 60, + }, + 'server3': { + 'server_name': 'server3', + 'id': 'server3', + 'url': 'http://localhost:8080', + 'server_uri': 'opc.tcp://localhost:4840', + 'cert_path': '', + 'private_key_path': '', + 'server_cert_path': '', + 'reconnection_interval': 60, + }, + } + opc = OPC( + opc_servers=servers, + logger=mock_logger, + notification_handler=mock_notification_handler, + metrics_controller=mock_metrics_controller, + ) + await opc.init_opc() + + assert opc.opc_servers == servers + assert opc.logger == mock_logger + assert opc.notification_handler == mock_notification_handler + assert opc.opc_repository['server1'] == server1 + assert opc.opc_repository['server2'] == server2 + + mock_opc_repository.assert_has_calls( + [ + call( + opc_id='server1', + server_name='server1', + url='http://localhost:8080', + logger=mock_logger, + server_uri='opc.tcp://localhost:4840', + cert_path='', + private_key_path='', + server_cert_path='', + notification_handler=mock_notification_handler, + reconnection_interval=60, + metrics_controller=mock_metrics_controller, + ), + ] + ) + mock_opc_repository.assert_has_calls( + [ + call( + opc_id='server2', + server_name='server2', + url='http://localhost:8080', + logger=mock_logger, + server_uri='opc.tcp://localhost:4840', + cert_path='', + private_key_path='', + server_cert_path='', + notification_handler=mock_notification_handler, + reconnection_interval=60, + metrics_controller=mock_metrics_controller, + ) + ] + ) + + server1.connect.assert_called_once() + server2.connect.assert_called_once() + + mock_send_notification.assert_has_calls( + [ + call( + metadata={ + 'model_id': '-', + 'model_name': '-', + 'workflow_name': '-', + 'schedule_name': 'INITIALIZATION', + }, + notification_id='OPC_CONNECTION_ERROR_server3', + message='Failed to connect to OPC server: Test error', + block='opc_repository', + level=NotificationLevel.ERROR, + attachment_content=ANY, + ) + ] + ) + + +@pytest_asyncio.fixture +@patch('laborious.activities.opc.OpcRepository') +async def opc(mock_opc_repository): + servers = { + 'server1': { + 'id': 'server1', + 'server_name': 'server1', + 'url': 'http://localhost:8080', + 'server_uri': 'opc.tcp://localhost:4840', + 'cert_path': '', + 'private_key_path': '', + 'server_cert_path': '', + 'reconnection_interval': 60, + } + } + + mock_opc_repository.return_value.write_data = AsyncMock(return_value=(True, {})) + mock_opc_repository.return_value.connect = AsyncMock(return_value=(True, {})) + opc = OPC( + opc_servers=servers, + logger=MagicMock(), + notification_handler=MagicMock(), + metrics_controller=AsyncMock(), + ) + await opc.init_opc() + opc.send_notification = MagicMock() + opc.send_notification_async = AsyncMock() + opc.emit_metric = AsyncMock() + return opc + + +WRITE_DATA_CASES = [ + ('tag1', 'int', 50), + ('tag2', 'float', 50.5), + ('tag3', 'bool', True), + ('tag4', 'string', 'test'), +] + + +@mark.parametrize('tag,data_type,data', WRITE_DATA_CASES) +@mark.asyncio +async def test_write_data_success(opc, tag, data_type, data): + opc.opc_repository['server1'].write_data.return_value = (True, {'response_time': 0.1}) + + response_time, error_info = await opc.write_data( + server_id='server1', + tag=tag, + data=data, + data_type=data_type, + tag_type='prediction', + metadata=metadata, + ) + assert response_time == 0.1 + assert error_info is None + opc.opc_repository['server1'].write_data.assert_called_once_with(tag, data, data_type, metadata) + + +@mark.asyncio +async def test_write_data_failed(opc): + opc.opc_repository['server1'].write_data.return_value = ( + False, + { + 'notification_id': 'OPC_WRITE_DATA_ERROR_server1', + 'message': 'Failed to write data to OPC server: Test error', + 'block': 'opc_repository', + 'level': NotificationLevel.ERROR, + 'attachment_content': 'Test error', + }, + ) + + response_time, error_info = await opc.write_data( + server_id='server1', + tag='tag1', + data=50, + data_type='int', + tag_type='prediction', + metadata=metadata, + ) + assert response_time is None + assert error_info is not None + + opc.send_notification_async.assert_called_once_with( + metadata=metadata, + notification_id='OPC_WRITE_DATA_ERROR_server1', + message='Failed to write data to OPC server: Test error', + block='opc_repository', + level=NotificationLevel.ERROR, + attachment_content=ANY, + ) + + +@mark.asyncio +async def test_write_data_exception(opc): + opc.opc_repository['server1'].write_data.side_effect = Exception('Test error') + + try: + await opc.write_data( + server_id='server1', + tag='tag1', + data=50, + data_type='int', + tag_type='prediction', + metadata=metadata, + ) + + except Exception: + opc.send_notification_async.assert_called_once_with( + metadata=metadata, + notification_id='WRITE_OPC_PREDICTION_ERROR', + message='Error writing data to OPC server: Test error', + block='write_opc_data', + level=NotificationLevel.ERROR, + attachment_content=ANY, + ) + + else: + raise AssertionError('Expected an exception to be raised') + + +@mark.parametrize( + 'error_info,initial_seen,initial_status,initial_reconnect,expected', + [ + (None, False, None, False, (False, None, False)), + ({}, False, None, False, (False, None, False)), + ( + {'opc_error_kind': 'session_bad', 'opc_status': 'BadSessionIdInvalid'}, + False, + None, + False, + (True, 'BadSessionIdInvalid', False), + ), + ( + {'opc_error_kind': 'session_bad', 'opc_status': 'NewStatus'}, + True, + 'OldStatus', + False, + (True, 'NewStatus', False), + ), + ( + {'opc_error_kind': 'session_bad'}, + True, + 'KeptStatus', + False, + (True, 'KeptStatus', False), + ), + ( + {'opc_error_kind': 'reconnect_in_progress'}, + False, + None, + False, + (False, None, True), + ), + ( + {'opc_error_kind': 'other'}, + True, + 'Status', + True, + (True, 'Status', True), + ), + ], +) +def test_apply_opc_write_error( + error_info, initial_seen, initial_status, initial_reconnect, expected +): + result = _apply_opc_write_error( + error_info, + initial_seen, + initial_status, + initial_reconnect, + ) + assert result == expected + + +@mark.asyncio +async def test_write_tags_from_config_prediction_success(opc): + opc.write_data = AsyncMock(return_value=(0.1, None)) + data = DataFrame({'prediction': [0.75], 'prediction_confidence': [0.95]}) + tags_config = {'tag1': {'data_type': 'float'}} + + response_times, session_bad, opc_status, reconnect = await opc._write_tags_from_config( + server_id='server1', + tags_config=tags_config, + data=data, + data_column='prediction', + tag_type='prediction', + log_label='Prediction data', + metadata=metadata['metadata'], + ) + + assert response_times == {'tag1': 0.1} + assert session_bad is False + assert opc_status is None + assert reconnect is False + opc.write_data.assert_called_once_with( + server_id='server1', + tag='tag1', + data=0.75, + data_type='float', + tag_type='prediction', + metadata=metadata['metadata'], + ) + + +@mark.asyncio +async def test_write_tags_from_config_confidence_success(opc): + opc.write_data = AsyncMock(return_value=(0.2, None)) + data = DataFrame({'prediction': [0.75], 'prediction_confidence': [0.95]}) + tags_config = {'tag2': {'data_type': 'float'}} + + response_times, session_bad, opc_status, reconnect = await opc._write_tags_from_config( + server_id='server1', + tags_config=tags_config, + data=data, + data_column='prediction_confidence', + tag_type='confidence', + log_label='Confidence data', + metadata=metadata['metadata'], + ) + + assert response_times == {'tag2': 0.2} + assert session_bad is False + assert opc_status is None + assert reconnect is False + opc.write_data.assert_called_once_with( + server_id='server1', + tag='tag2', + data=0.95, + data_type='float', + tag_type='confidence', + metadata=metadata['metadata'], + ) + + +@mark.asyncio +async def test_write_tags_from_config_write_failure(opc): + opc.write_data = AsyncMock(return_value=(None, {})) + data = DataFrame({'prediction': [0.75], 'prediction_confidence': [0.95]}) + + response_times, session_bad, opc_status, reconnect = await opc._write_tags_from_config( + server_id='server1', + tags_config={'tag1': {'data_type': 'float'}}, + data=data, + data_column='prediction', + tag_type='prediction', + log_label='Prediction data', + metadata=metadata['metadata'], + ) + + assert response_times == {'tag1': None} + assert session_bad is False + assert opc_status is None + assert reconnect is False + + +@mark.asyncio +async def test_write_tags_from_config_session_bad(opc): + opc.write_data = AsyncMock( + return_value=( + None, + { + 'opc_error_kind': 'session_bad', + 'opc_status': 'BadSessionIdInvalid', + }, + ) + ) + data = DataFrame({'prediction': [0.75], 'prediction_confidence': [0.95]}) + + response_times, session_bad, opc_status, reconnect = await opc._write_tags_from_config( + server_id='server1', + tags_config={'tag1': {'data_type': 'float'}}, + data=data, + data_column='prediction', + tag_type='prediction', + log_label='Prediction data', + metadata=metadata['metadata'], + ) + + assert response_times == {'tag1': None} + assert session_bad is True + assert opc_status == 'BadSessionIdInvalid' + assert reconnect is False + + +@mark.asyncio +async def test_write_tags_from_config_reconnect_in_progress(opc): + opc.write_data = AsyncMock( + return_value=( + None, + {'opc_error_kind': 'reconnect_in_progress'}, + ) + ) + data = DataFrame({'prediction': [0.75], 'prediction_confidence': [0.95]}) + + response_times, session_bad, opc_status, reconnect = await opc._write_tags_from_config( + server_id='server1', + tags_config={'tag1': {'data_type': 'float'}}, + data=data, + data_column='prediction', + tag_type='prediction', + log_label='Prediction data', + metadata=metadata['metadata'], + ) + + assert response_times == {'tag1': None} + assert session_bad is False + assert opc_status is None + assert reconnect is True + + +@mark.asyncio +async def test_manage_output_tags_success(opc): + opc._write_tags_from_config = AsyncMock( + side_effect=[ + ({'tag1': 0.1}, False, None, False), + ({'tag2': 0.1}, False, None, False), + ] + ) + data = DataFrame({'prediction': [0.75], 'prediction_confidence': [0.95]}) + config = { + 'prediction_tags': {'tag1': {'data_type': 'float'}}, + 'confidence_tags': {'tag2': {'data_type': 'float'}}, + } + + output_data, opc_metrics, session_bad, opc_status, reconnect = await opc.manage_output_tags( + server_id='server1', + config=config, + data=data, + metadata=metadata['metadata'], + ) + + assert output_data is True + assert opc_metrics == {'tag1': 0.1, 'tag2': 0.1} + assert session_bad is False + assert opc_status is None + assert reconnect is False + assert opc._write_tags_from_config.await_count == 2 + + +@mark.asyncio +async def test_manage_output_tags_failed(opc): + opc._write_tags_from_config = AsyncMock( + side_effect=[ + ({'tag1': 0.1}, False, None, False), + ({'tag2': None}, False, None, False), + ] + ) + data = DataFrame({'prediction': [0.75], 'prediction_confidence': [0.95]}) + config = { + 'prediction_tags': {'tag1': {'data_type': 'float'}}, + 'confidence_tags': {'tag2': {'data_type': 'float'}}, + } + + output_data, opc_metrics, _, _, _ = await opc.manage_output_tags( + server_id='server1', + config=config, + data=data, + metadata=metadata['metadata'], + ) + + assert output_data is False + assert opc_metrics == {'tag1': 0.1, 'tag2': None} + + +@mark.asyncio +async def test_manage_output_tags_do_nothing(opc): + opc._write_tags_from_config = AsyncMock() + data = DataFrame({'prediction': [0.75], 'prediction_confidence': [0.95]}) + config = {'_invalid_key': {'tag1': {'data_type': 'float'}}} + + output_data, opc_metrics, _, _, _ = await opc.manage_output_tags( + server_id='server1', + config=config, + data=data, + metadata=metadata['metadata'], + ) + + assert output_data is True + assert opc_metrics == {} + opc._write_tags_from_config.assert_not_called() + + +@mark.asyncio +@patch('laborious.activities.opc.DataFrame') +async def test_write_opc_data_success(mock_dataframe, opc): + # Arrange + input_data = { + **metadata, + 'data': {'prediction': [0.75], 'prediction_confidence': [0.95]}, + 'opc_output_config': { + 'server1': { + 'prediction_tags': {'tag1': {'data_type': 'float'}}, + 'confidence_tags': {'tag2': {'data_type': 'float'}}, + } + }, + } + + # Act + opc.manage_output_tags = AsyncMock( + return_value=(True, {'tag1': 0.1, 'tag2': 0.2}, False, None, False) + ) + + opc.process_confidence = MagicMock(return_value={'data': 'data'}) + output_data, opc_metrics = await opc.write_opc_data(input_data) + + # Assert + assert output_data == {'data': 'data'} + assert opc_metrics == {'server1': {'tag1': 0.1, 'tag2': 0.2}} + opc.manage_output_tags.assert_called_once_with( + 'server1', + input_data['opc_output_config']['server1'], + mock_dataframe.return_value, + metadata['metadata'], + ) + opc.process_confidence.assert_called_once_with( + mock_dataframe.return_value, + True, + metadata['metadata'], + session_bad=False, + opc_status=None, + reconnect_in_progress=False, + ) + + +@mark.asyncio +async def test_write_opc_data_empty_config(opc): + # Arrange + input_data = { + **metadata, + 'data': {'prediction': [0.75], 'prediction_confidence': [0.95]}, + 'opc_servers': ['server1'], + 'opc_output_config': {'server1': {'prediction_tags': {}, 'confidence_tags': {}}}, + } + + # Act + await opc.write_opc_data(input_data) + + # Assert + opc.opc_repository['server1'].write_data.assert_not_called() + + +@mark.asyncio +async def test_write_opc_data_no_validate_server(opc): + opc.validate_server = AsyncMock(return_value=False) + input_data = { + **metadata, + 'data': {'prediction': [0.75], 'prediction_confidence': [0.95]}, + 'opc_output_config': { + 'server1': { + 'prediction_tags': {'tag1': {'data_type': 'float'}}, + 'confidence_tags': {'tag2': {'data_type': 'float'}}, + } + }, + } + + # Act + await opc.write_opc_data(input_data) + + # Assert + opc.opc_repository['server1'].write_data.assert_not_called() + + +@mark.parametrize( + 'data,success,expected', + [ + (DataFrame({'prediction_confidence': [0]}), True, 0), + (DataFrame({'prediction_confidence': [0]}), False, 12), + ], +) +def test_process_confidence(opc, data, success, expected): + result = opc.process_confidence(data, success, metadata['metadata']) + assert result['prediction_confidence'][0] == expected + + +def test_process_confidence_session_bad(opc): + data = DataFrame({'prediction_confidence': [0.9]}) + result = opc.process_confidence( + data, + False, + metadata['metadata'], + session_bad=True, + opc_status='BadSessionIdInvalid', + ) + assert result['prediction_confidence'][0] == OPC_SESSION_BAD_CONFIDENCE + assert result['comments'][0].startswith(OPC_SESSION_BAD_COMMENT_PREFIX) + assert 'BadSessionIdInvalid' in result['comments'][0] + + +def test_process_confidence_generic_failure(opc): + data = DataFrame({'prediction_confidence': [0.9]}) + result = opc.process_confidence(data, False, metadata['metadata']) + assert result['prediction_confidence'][0] == OPC_WRITTING_ERROR_CONFIDENCE + assert result['comments'][0] == OPC_WRITTING_ERROR_MESSAGE + + +@mark.asyncio +async def test_manage_output_tags_merges_error_flags(opc): + opc._write_tags_from_config = AsyncMock( + side_effect=[ + ({'tag1': None}, True, 'BadSessionIdInvalid', False), + ({'tag2': 0.2}, False, None, True), + ] + ) + data = DataFrame({'prediction': [0.75], 'prediction_confidence': [0.95]}) + config = { + 'prediction_tags': {'tag1': {'data_type': 'float'}}, + 'confidence_tags': {'tag2': {'data_type': 'float'}}, + } + + ( + success, + metrics, + session_bad_seen, + opc_status, + reconnect_in_progress, + ) = await opc.manage_output_tags('server1', config, data, metadata['metadata']) + + assert success is False + assert session_bad_seen is True + assert reconnect_in_progress is True + assert opc_status == 'BadSessionIdInvalid' + assert metrics == {'tag1': None, 'tag2': 0.2} + + +def test_process_confidence_reconnect_in_progress(opc): + data = DataFrame({'prediction_confidence': [0.9]}) + result = opc.process_confidence( + data, + False, + metadata['metadata'], + reconnect_in_progress=True, + ) + assert result['prediction_confidence'][0] == OPC_SESSION_BAD_CONFIDENCE + assert result['comments'][0] == OPC_RECONNECT_IN_PROGRESS_COMMENT + + +def test_process_confidence_concatenates_multiple_comments(opc): + data = DataFrame({'prediction_confidence': [0.9]}) + session_comment = f'{OPC_SESSION_BAD_COMMENT_PREFIX} BadSessionIdInvalid' + + result = opc.process_confidence( + data, + False, + metadata['metadata'], + session_bad=True, + opc_status='BadSessionIdInvalid', + reconnect_in_progress=True, + ) + + assert result['prediction_confidence'][0] == OPC_SESSION_BAD_CONFIDENCE + assert result['comments'][0] == OPC_COMMENT_SEPARATOR.join( + [session_comment, OPC_RECONNECT_IN_PROGRESS_COMMENT] + ) + + +@mark.asyncio +async def test_validate_server(opc): + assert await opc.validate_server('server1', metadata) is True + assert await opc.validate_server('server2', metadata) is False + + +@mark.asyncio +async def test_close(opc): + opc.opc_repository['server1'].disconnect = AsyncMock(return_value=True) + await opc.close() + opc.opc_repository['server1'].disconnect.assert_called_once() diff --git a/tests/laborious/activities/test_storage.py b/tests/laborious/activities/test_storage.py new file mode 100644 index 0000000..ce99ff7 --- /dev/null +++ b/tests/laborious/activities/test_storage.py @@ -0,0 +1,323 @@ +import datetime +import os +from unittest.mock import ANY, AsyncMock, MagicMock, patch + +from pytest import fixture, mark, raises +from sientia_do.notifications.models import NotificationLevel +from sientia_do.temporal.activities.postgres import Postgres + +from laborious.activities.storage import Storage + + +@fixture(autouse=True) +def _passthrough_from_dict(): + with patch( + 'laborious.activities.storage.MinioDataFramePayload.from_dict', side_effect=lambda x: x + ): + yield + + +metadata = { + 'metadata': { + 'model_id': 'test_model_id', + 'model_name': 'test_model', + 'workflow_name': 'test_workflow', + 'schedule_name': 'test_schedule', + } +} + + +@fixture +@patch('laborious.activities.storage.MinioRepository') +def storage(mock_minio_repository): + return Storage( + host='localhost', + port=5432, + user='postgres', + password='postgres', + dbname='postgres', + min_connections=1, + max_connections=10, + retention_hours=24, + minio_repository=mock_minio_repository.return_value, + logger=MagicMock(), + notification_handler=MagicMock(), + metrics_controller=AsyncMock(), + ) + + +@patch('laborious.activities.storage.MinioRepository') +def test___init___not_hasattr(mock_minio_repository): + logger = MagicMock() + notification_handler = MagicMock() + metrics_controller = AsyncMock() + minio_repo = mock_minio_repository.return_value + storage = Storage( + host='localhost', + port=5432, + user='postgres', + password='postgres', + dbname='postgres', + min_connections=1, + max_connections=10, + retention_hours=24, + minio_repository=minio_repo, + logger=logger, + notification_handler=notification_handler, + metrics_controller=metrics_controller, + ) + assert isinstance(storage, Postgres) + + assert storage.minio_repository is minio_repo + mock_minio_repository.assert_not_called() + + +@patch('laborious.activities.storage.MinioRepository') +def test___init___none_minio_repository(mock_minio_repository, storage): + storage.minio_repository = None + logger = MagicMock() + notification_handler = MagicMock() + metrics_controller = AsyncMock() + storage.__init__( + host='localhost', + port=5432, + user='postgres', + password='postgres', + dbname='postgres', + min_connections=1, + max_connections=10, + retention_hours=24, + minio_repository=None, + logger=logger, + notification_handler=notification_handler, + metrics_controller=metrics_controller, + ) + + assert storage.minio_repository is None + mock_minio_repository.assert_not_called() + + +@patch('laborious.activities.storage.MinioRepository') +def test___init___done_repository(mock_minio_repository, storage): + storage.__init__( + host='localhost', + port=5432, + user='postgres', + password='postgres', + dbname='postgres', + min_connections=1, + max_connections=10, + retention_hours=24, + minio_repository=mock_minio_repository.return_value, + logger=MagicMock(), + notification_handler=MagicMock(), + metrics_controller=AsyncMock(), + ) + mock_minio_repository.assert_not_called() + assert storage.minio_repository is not None + + +def test_close(storage): + storage.minio_repository = MagicMock() + + storage.close() + + assert storage.minio_repository is None + + +def test___del__(storage): + storage.close = MagicMock() + + storage.__del__() + + storage.close.assert_called_once() + + +@mark.asyncio +async def test_load_query_with_minio_offload_no_rows(storage): + storage.load_custom_query = AsyncMock(return_value=None) + storage_result = {'success': False} + with patch( + 'laborious.activities.storage.MinioDataFramePayload.from_dataframe', + new_callable=AsyncMock, + return_value=storage_result, + ) as mock_from_dataframe: + result = await storage.load_query_with_minio_offload( + {**metadata, 'query': 'SELECT 1', 'model_name': 'm', 'key_prefix': 'predictions/s'} + ) + assert result == storage_result + mock_from_dataframe.assert_awaited_once() + + +@mark.asyncio +async def test_load_query_with_minio_offload_inline(storage): + storage.load_custom_query = AsyncMock(return_value=[{'a': 1}]) + storage_result = {'success': True, 'data': {'a': [1]}, 'object_key': None} + with patch( + 'laborious.activities.storage.MinioDataFramePayload.from_dataframe', + new_callable=AsyncMock, + return_value=storage_result, + ) as mock_from_dataframe: + result = await storage.load_query_with_minio_offload( + { + **metadata, + 'query': 'SELECT 1', + 'model_name': 'my-model', + 'key_prefix': 'predictions/s', + } + ) + assert result == storage_result + mock_from_dataframe.assert_awaited_once() + + +@mark.asyncio +async def test_load_query_with_minio_offload_minio(storage): + storage.load_custom_query = AsyncMock(return_value=[{'a': 1}]) + storage_result = {'success': True, 'data': None, 'object_key': 'object-key'} + with patch( + 'laborious.activities.storage.MinioDataFramePayload.from_dataframe', + new_callable=AsyncMock, + return_value=storage_result, + ) as mock_from_dataframe: + result = await storage.load_query_with_minio_offload( + {**metadata, 'query': 'SELECT 1', 'model_name': 'm', 'key_prefix': 'predictions/s'} + ) + + assert result == storage_result + mock_from_dataframe.assert_awaited_once() + + +@mark.asyncio +@patch.dict(os.environ, {'SIENTIA_MINIO_RETENTION_HOURS': '1'}) +@patch('laborious.activities.storage.now') +async def test_cleanup_minio_objects_expired(mock_now, storage): + mock_now.return_value = datetime.datetime(2025, 1, 10, 12, 0, 0) + storage.minio_repository.list_objects = AsyncMock( + return_value=[ + 'sientia/streamlit-connectors/training_datasets/m/m-initial-2024-12-01_00-00-00.parquet', + 'sientia/streamlit-connectors/training_datasets/m/m-initial-2025-01-10_12-00-00.parquet', + ] + ) + storage.minio_repository.delete_file = AsyncMock() + storage.send_notification_async = AsyncMock() + + data_mock = MagicMock() + data_mock.cleanup_prefix.return_value = 'training_datasets/m' + + result = await storage.cleanup_minio_objects_expired({**metadata, 'data': data_mock}) + + assert result['deleted_count'] == 1 + assert result['failed_count'] == 0 + deleted_key = ( + 'sientia/streamlit-connectors/training_datasets/m/m-initial-2024-12-01_00-00-00.parquet' + ) + assert deleted_key in result['deleted'] + assert result['deleted'][deleted_key]['success'] is True + storage.minio_repository.list_objects.assert_called_once_with( + prefix='training_datasets/m', + recursive=True, + metadata=metadata['metadata'], + ) + storage.minio_repository.delete_file.assert_called_once_with( + object_name='sientia/streamlit-connectors/training_datasets/m/m-initial-2024-12-01_00-00-00.parquet', + metadata=metadata['metadata'], + ) + + +@mark.asyncio +async def test_load_query_with_minio_offload_minio_not_initialized(storage): + storage.minio_repository = None + + with raises(ValueError, match='Minio repository not initialized'): + await storage.load_query_with_minio_offload( + {**metadata, 'query': 'SELECT 1', 'model_name': 'm'} + ) + + +@mark.asyncio +async def test_export_payload_to_postgres(storage): + payload = AsyncMock() + payload.retrieve = AsyncMock(return_value=MagicMock()) + storage.export_data_to_postgres = AsyncMock(return_value={'success': True}) + + result = await storage.export_payload_to_postgres( + {**metadata, 'data': payload, 'schema': 'public', 'table': 't'} + ) + + payload.retrieve.assert_awaited_once_with(storage.minio_repository, metadata['metadata']) + storage.export_data_to_postgres.assert_awaited_once() + assert result == {'success': True} + + +@mark.asyncio +async def test_cleanup_minio_objects_expired_minio_not_initialized(storage): + storage.minio_repository = None + + data_mock = MagicMock() + data_mock.cleanup_prefix.return_value = 'test' + with raises(ValueError, match='Minio repository not initialized'): + await storage.cleanup_minio_objects_expired({**metadata, 'data': data_mock}) + + +@mark.asyncio +@patch('laborious.activities.storage.now') +async def test_cleanup_minio_objects_expired_unparseable_key(mock_now, storage): + mock_now.return_value = datetime.datetime(2025, 1, 10, 12, 0, 0) + storage.minio_repository.list_objects = AsyncMock( + return_value=['some/random/key-without-timestamp.parquet'] + ) + storage.minio_repository.delete_file = AsyncMock() + storage.send_notification_async = AsyncMock() + + data_mock = MagicMock() + data_mock.cleanup_prefix.return_value = 'test' + result = await storage.cleanup_minio_objects_expired({**metadata, 'data': data_mock}) + + assert result['deleted_count'] == 0 + assert result['failed_count'] == 0 + storage.minio_repository.delete_file.assert_not_called() + + +@mark.asyncio +@patch('laborious.activities.storage.now') +async def test_cleanup_minio_objects_expired_delete_fails(mock_now, storage): + mock_now.return_value = datetime.datetime(2025, 1, 10, 12, 0, 0) + old_key = 'training_datasets/m/m-initial-2024-12-01_00-00-00.parquet' + storage.minio_repository.list_objects = AsyncMock(return_value=[old_key]) + storage.minio_repository.delete_file = AsyncMock(side_effect=Exception('delete error')) + storage.send_notification_async = AsyncMock() + + data_mock = MagicMock() + data_mock.cleanup_prefix.return_value = 'training_datasets/m' + result = await storage.cleanup_minio_objects_expired({**metadata, 'data': data_mock}) + + assert result['deleted_count'] == 0 + assert result['failed_count'] == 1 + assert old_key in result['failed'] + assert result['failed'][old_key]['success'] is False + assert result['failed'][old_key]['message'] == 'delete error' + + +@mark.asyncio +@patch('laborious.activities.storage.now') +async def test_cleanup_minio_objects_expired_list_objects_error(mock_now, storage): + mock_now.return_value = datetime.datetime(2025, 1, 10, 12, 0, 0) + storage.minio_repository.list_objects = AsyncMock(side_effect=Exception('list error')) + storage.send_notification_async = AsyncMock() + storage.error = MagicMock() + + data_mock = MagicMock() + data_mock.cleanup_prefix.return_value = 'training_datasets/m' + result = await storage.cleanup_minio_objects_expired({**metadata, 'data': data_mock}) + + assert result['deleted_count'] == 0 + assert result['failed_count'] == 0 + storage.send_notification_async.assert_called_once_with( + metadata=metadata['metadata'], + notification_id='ERROR_CLEANUP_MINIO_OBJECTS_EXPIRED', + message='Error cleaning up MinIO objects: list error', + block='cleanup_minio_objects_expired', + level=NotificationLevel.ERROR, + attachment_content=ANY, + ) + storage.error.assert_called_once() diff --git a/tests/laborious/utils/__init__.py b/tests/laborious/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/laborious/utils/filters/__init__.py b/tests/laborious/utils/filters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/laborious/utils/filters/test_conditional_filters.py b/tests/laborious/utils/filters/test_conditional_filters.py new file mode 100644 index 0000000..8a35801 --- /dev/null +++ b/tests/laborious/utils/filters/test_conditional_filters.py @@ -0,0 +1,44 @@ +from pandas import DataFrame + +from laborious.utils.filters.conditional_filters import ( + filter_empty_data, + filter_specific_variables_null_values, +) + + +def test_filter_specific_variables_null_values(): + assert ( + filter_specific_variables_null_values( + DataFrame({'variable': ['variable1', 'variable2'], 'value': [1, 2]}), + config={'variables': ['variable2']}, + ) + is False + ) + + +def test_filter_specific_variables_null_values_with_empty_data(): + assert ( + filter_specific_variables_null_values(DataFrame(), config={'variables': ['variable2']}) + is False + ) + + +def test_filter_specific_variables_null_values_with_null_values(): + assert ( + filter_specific_variables_null_values( + DataFrame({'variable': ['variable1', 'variable2'], 'value': [1, None]}), + config={'variables': ['variable2']}, + ) + is True + ) + + +def test_filter_empty_data(): + assert filter_empty_data(DataFrame(), {}) is True + + +def test_filter_empty_data_with_data(): + assert ( + filter_empty_data(DataFrame({'variable': ['variable1', 'variable2'], 'value': [1, 2]}), {}) + is False + ) diff --git a/tests/laborious/utils/filters/test_mlflow_filters.py b/tests/laborious/utils/filters/test_mlflow_filters.py new file mode 100644 index 0000000..5353a6f --- /dev/null +++ b/tests/laborious/utils/filters/test_mlflow_filters.py @@ -0,0 +1,23 @@ +from pandas import DataFrame + +from laborious.utils.filters.mlflow_filters import api_error_filter, nan_values_filter + + +def test_api_error_filter_invalid_response(): + assert api_error_filter(None, {}) is True # NOSONAR + + +def test_api_error_filter_valid_response_fail(): + assert api_error_filter({'success': False}, {}) is True + + +def test_api_error_filter_valid_response_success(): + assert api_error_filter({'success': True}, {}) is False + + +def test_nan_values_filter_all_nan_values(): + assert nan_values_filter(DataFrame({'variable': [None, None]}), {}) is True + + +def test_nan_values_filter_no_nan_values(): + assert nan_values_filter(DataFrame({'variable': [1, 2]}), {}) is False diff --git a/tests/laborious/utils/models/test_minio_dataframe_payload.py b/tests/laborious/utils/models/test_minio_dataframe_payload.py new file mode 100644 index 0000000..c27765d --- /dev/null +++ b/tests/laborious/utils/models/test_minio_dataframe_payload.py @@ -0,0 +1,266 @@ +from datetime import datetime +from io import BytesIO +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from pandas import DataFrame + +from laborious.utils.models.minio_dataframe_payload import ( + MinioDataFramePayload, + _build_object_key, +) + + +def test_parse_object_timestamp_hyphenated_model(): + key = 'predictions/sched/my-long-model-initial-2024-06-15_10-30-45.parquet' + ts = MinioDataFramePayload.parse_object_timestamp(key) + assert ts == datetime(2024, 6, 15, 10, 30, 45) + + +def test_parse_object_timestamp_transform(): + key = 'p/m-transform-2024-01-02_03-04-05.parquet' + ts = MinioDataFramePayload.parse_object_timestamp(key) + assert ts == datetime(2024, 1, 2, 3, 4, 5) + + +def test_parse_object_timestamp_invalid(): + assert MinioDataFramePayload.parse_object_timestamp('bad.parquet') is None + + +def test_estimate_size_bytes_returns_positive_for_nonempty_frame(): + df = DataFrame({'a': [1, 2]}) + size = MinioDataFramePayload.estimate_size_bytes(df) + assert isinstance(size, int) + assert size > 0 + + +def test_cleanup_prefix_when_offloaded_returns_object_prefix(): + payload = MinioDataFramePayload( + last_timestamp='t', + data=None, + object_key='training_datasets/m/m-initial-2024-01-01_00-00-00.parquet', + object_prefix='training_datasets/m', + ) + assert MinioDataFramePayload.cleanup_prefix(payload) == 'training_datasets/m' + + +def test_cleanup_prefix_when_inline_returns_none(): + payload = MinioDataFramePayload(last_timestamp='t', data={'x': [1]}, object_key=None) + assert MinioDataFramePayload.cleanup_prefix(payload) is None + + +def test_has_data_true_when_object_key_set(): + payload = MinioDataFramePayload(last_timestamp='t', data=None, object_key='k') + assert payload.has_data() is True + + +@pytest.mark.asyncio +async def test_retrieve_inline_dict_as_dataframe(): + payload = MinioDataFramePayload(last_timestamp='t', data={'a': [1, 2]}) + minio = AsyncMock() + out = await payload.retrieve(minio, {'metadata': {}}) + assert list(out.columns) == ['a'] + minio.download_file.assert_not_called() + + +@pytest.mark.asyncio +async def test_retrieve_downloads_parquet_when_offloaded(): + source = DataFrame({'a': [1, 2]}) + buf = BytesIO() + source.to_parquet(buf, engine='pyarrow', index=True) + file_bytes = buf.getvalue() + + payload = MinioDataFramePayload( + last_timestamp='t', + data=None, + object_key='training_datasets/m/f.parquet', + object_prefix='training_datasets/m', + ) + minio = AsyncMock() + minio.download_file = AsyncMock(return_value=file_bytes) + + out = await payload.retrieve(minio, {'metadata': {}}) + + minio.download_file.assert_awaited_once_with( + object_name='training_datasets/m/f.parquet', + metadata={'metadata': {}}, + ) + assert list(out.columns) == ['a'] + + +def test_build_object_key(): + key, prefix = _build_object_key('my-model', 'initial', '2024-01-01_00-00-00') + assert key == 'prediction_datasets/my-model/my-model-initial-2024-01-01_00-00-00.parquet' + assert prefix == 'prediction_datasets/my-model' + + +def test_build_object_key_strips_slashes(): + key, prefix = _build_object_key(' /my-model/ ', 'transform', '2024-06-15_10-30-45') + assert prefix == 'prediction_datasets/my-model' + assert key.startswith('prediction_datasets/my-model/') + + +def test_estimate_size_bytes_fallback(): + df = DataFrame({'a': [1, 2]}) + with patch.object(df, 'to_dict', side_effect=RuntimeError('to_dict failed')): + size = MinioDataFramePayload.estimate_size_bytes(df) + assert isinstance(size, int) + assert size > 0 + + +def test_parse_object_timestamp_bad_datetime(): + key = 'p/m-initial-9999-99-99_99-99-99.parquet' + assert MinioDataFramePayload.parse_object_timestamp(key) is None + + +@pytest.mark.asyncio +async def test_retrieve_empty_when_no_data(): + payload = MinioDataFramePayload(last_timestamp='t', data=None, object_key=None) + minio = AsyncMock() + out = await payload.retrieve(minio, {}) + assert out.empty + minio.download_file.assert_not_called() + + +@pytest.mark.asyncio +@patch('laborious.utils.models.minio_dataframe_payload.now') +async def test_from_dataframe_none(mock_now): + mock_now.return_value = datetime(2024, 1, 1, 0, 0, 0) + minio = AsyncMock() + result = await MinioDataFramePayload.from_dataframe( + dataframe=None, + minio_repo=minio, + model_name='m', + operation='initial', + status={'success': False, 'message': 'no data'}, + ) + assert result.data is None + assert result.status == {'success': False, 'message': 'no data'} + assert result.object_key is None + + +@pytest.mark.asyncio +@patch('laborious.utils.models.minio_dataframe_payload.now') +async def test_from_dataframe_empty(mock_now): + mock_now.return_value = datetime(2024, 1, 1, 0, 0, 0) + minio = AsyncMock() + mock_df = MagicMock() + mock_df.__bool__ = MagicMock(return_value=True) + mock_df.empty = True + result = await MinioDataFramePayload.from_dataframe( + dataframe=mock_df, + minio_repo=minio, + model_name='m', + operation='initial', + ) + assert result.data is None + assert result.object_key is None + + +def _mock_dataframe(data_dict, timestamp_values=None): + """Build a MagicMock that behaves enough like a DataFrame for from_dataframe.""" + mock_df = MagicMock() + mock_df.__bool__ = MagicMock(return_value=True) + mock_df.empty = False + if timestamp_values is None: + timestamp_values = data_dict.get('timestamp', ['2024-01-01']) + ts_col = MagicMock() + ts_col.values.tolist.return_value = timestamp_values + mock_df.__getitem__ = MagicMock(return_value=ts_col) + mock_df.to_dict.return_value = data_dict + buf = BytesIO() + DataFrame(data_dict).to_parquet(buf, engine='pyarrow', index=True) + mock_df.to_parquet = MagicMock(side_effect=lambda b, **kw: b.write(buf.getvalue())) + return mock_df + + +@pytest.mark.asyncio +@patch('laborious.utils.models.minio_dataframe_payload.OFFLOAD_THRESHOLD_BYTES', 10**9) +async def test_from_dataframe_inline(): + minio = AsyncMock() + df = _mock_dataframe({'timestamp': ['2024-01-01'], 'value': [42]}) + result = await MinioDataFramePayload.from_dataframe( + dataframe=df, + minio_repo=minio, + model_name='m', + operation='initial', + ) + assert result.data is not None + assert result.object_key is None + assert result.last_timestamp == '2024-01-01' + + +@pytest.mark.asyncio +@patch('laborious.utils.models.minio_dataframe_payload.now') +@patch('laborious.utils.models.minio_dataframe_payload.OFFLOAD_THRESHOLD_BYTES', 0) +async def test_from_dataframe_offloaded(mock_now): + mock_now.return_value = datetime(2024, 1, 1, 0, 0, 0) + minio = AsyncMock() + minio.upload_file = AsyncMock(return_value={'minio_object_name': 'full/key.parquet'}) + minio.bucket = 'test-bucket' + + df = _mock_dataframe({'timestamp': ['2024-01-01'], 'value': [42]}) + result = await MinioDataFramePayload.from_dataframe( + dataframe=df, + minio_repo=minio, + model_name='m', + operation='initial', + workflow_metadata={'wf': 'data'}, + ) + assert result.data is None + assert result.object_key == 'full/key.parquet' + assert result.bucket == 'test-bucket' + assert result.uri == 's3://test-bucket/full/key.parquet' + minio.upload_file.assert_awaited_once() + + +def test_from_dict_inline(): + raw = { + 'last_timestamp': '2024-01-01T00:00:00+00:00', + 'status': None, + 'data': {'col1': {0: 'val1'}}, + 'bucket': None, + 'object_key': None, + 'object_prefix': None, + 'uri': None, + } + payload = MinioDataFramePayload.from_dict(raw) + assert isinstance(payload, MinioDataFramePayload) + assert payload.last_timestamp == '2024-01-01T00:00:00+00:00' + assert payload.data == {'col1': {0: 'val1'}} + assert payload.object_key is None + + +def test_from_dict_offloaded(): + raw = { + 'last_timestamp': '2024-06-15T10:30:45+00:00', + 'status': {'success': True}, + 'data': None, + 'bucket': 'my-bucket', + 'object_key': 'training_datasets/model/model-initial-2024-06-15_10-30-45.parquet', + 'object_prefix': 'training_datasets/model', + 'uri': 's3://my-bucket/training_datasets/model/model-initial-2024-06-15_10-30-45.parquet', + } + payload = MinioDataFramePayload.from_dict(raw) + assert isinstance(payload, MinioDataFramePayload) + assert payload.data is None + assert payload.bucket == 'my-bucket' + assert payload.object_key == raw['object_key'] + assert payload.object_prefix == 'training_datasets/model' + assert payload.uri == raw['uri'] + assert payload.status == {'success': True} + + +def test_from_dict_minimal_keys(): + raw = {'last_timestamp': '2024-01-01'} + payload = MinioDataFramePayload.from_dict(raw) + assert payload.last_timestamp == '2024-01-01' + assert payload.data is None + assert payload.bucket is None + assert payload.object_key is None + + +def test_from_dict_passthrough_existing_instance(): + original = MinioDataFramePayload(last_timestamp='2024-01-01', data={'a': 1}, bucket='b') + result = MinioDataFramePayload.from_dict(original) + assert result is original diff --git a/tests/laborious/utils/repository/test_model_repository.py b/tests/laborious/utils/repository/test_model_repository.py new file mode 100644 index 0000000..54a75bb --- /dev/null +++ b/tests/laborious/utils/repository/test_model_repository.py @@ -0,0 +1,1715 @@ +from datetime import UTC, datetime +from unittest.mock import ANY, AsyncMock, MagicMock, call, patch + +import mlflow as mlflow_lib +import numpy as np +import pytest +from pandas import DataFrame, Timestamp + +from laborious import metrics +from laborious.utils.repository.model_repository import MLFlowRepository, force_memory_release + + +@patch('laborious.utils.repository.model_repository.ctypes') +@patch('laborious.utils.repository.model_repository.gc') +def test_force_memory_release_success(gc, ctypes): + logger = MagicMock() + + force_memory_release(logger) + + gc.collect.assert_called_once() + ctypes.CDLL.return_value.malloc_trim.assert_called_once_with(0) + logger.info.assert_called_once_with('Memory released') + + +@patch('laborious.utils.repository.model_repository.ctypes') +@patch('laborious.utils.repository.model_repository.gc') +def test_force_memory_release_error(gc, ctypes): + logger = MagicMock() + ctypes.CDLL.return_value.malloc_trim.side_effect = Exception('error') + force_memory_release(logger) + + gc.collect.assert_called_once() + ctypes.CDLL.return_value.malloc_trim.assert_called_once_with(0) + + logger.info.assert_called_once_with('Memory release failed: error') + + +@pytest.fixture +def mlflow_repository(): + with patch('laborious.utils.repository.model_repository.mlflow'): + repo = MLFlowRepository( + host='http://localhost:5000', + username='admin', + password='admin', + logger=MagicMock(), + notification_handler=MagicMock(), + metrics_controller=AsyncMock(), + ) + repo.emit_metric = AsyncMock() + repo.observe_lag = AsyncMock() + repo.send_notification = MagicMock() + repo.send_notification_async = AsyncMock() + return repo + + +@pytest.fixture +def mlflow(): + with patch('laborious.utils.repository.model_repository.mlflow') as mlflow: + yield mlflow + + +metadata = { + 'metadata': { + 'model_id': 'test_model', + 'model_name': 'test_model', + 'workflow_name': 'test_workflow', + 'schema_name': 'test_schedule', + }, +} + + +class Any: + pass + + +def test_get_model_uri_prediction(mlflow, mlflow_repository): + mlflow.get_run.return_value = MagicMock(info=MagicMock(artifact_uri='test')) + output = mlflow_repository.get_model_uri('0', prediction=True) + assert output == 'test/prediction_model' + mlflow.get_run.assert_called_once_with('0') + + +def test_get_model_uri_transform(mlflow, mlflow_repository): + mlflow.get_run.return_value = MagicMock(info=MagicMock(artifact_uri='test')) + output = mlflow_repository.get_model_uri('0', prediction=False) + assert output == 'test/data_model' + mlflow.get_run.assert_called_once_with('0') + + +def test_get_model_run_id_not_registered_models(mlflow_repository): + mlflow_repository.client.search_registered_models.return_value = [] + with pytest.raises(mlflow_lib.exceptions.MlflowException) as e: + mlflow_repository.get_model_run_id('test') + + mlflow_repository.client.search_registered_models.assert_called_once_with( + filter_string="name='test'" + ) + + assert str(e.value) == "Model 'test' not found in the Model Registry." + + +def test_get_model_run_id_not_stage_versions(mlflow_repository): + mlflow_repository.client.search_registered_models.return_value = [MagicMock(name='test')] + + mlflow_repository.client.search_model_versions.return_value = [ + MagicMock(current_stage='Staging'), + MagicMock(current_stage='Staging'), + MagicMock(current_stage='Archived'), + ] + + with pytest.raises(mlflow_lib.exceptions.MlflowException) as e: + mlflow_repository.get_model_run_id('test') + + mlflow_repository.client.search_registered_models.assert_called_once_with( + filter_string="name='test'" + ) + mlflow_repository.client.search_model_versions.assert_called_once_with( + filter_string="name='test'" + ) + + assert str(e.value) == "Model 'test' in stage 'Production' not found in the Model Registry." + + +def test_get_model_run_id_success(mlflow_repository): + mlflow_repository.client.search_registered_models.return_value = [MagicMock(name='test')] + + mlflow_repository.client.search_model_versions.return_value = [ + MagicMock(current_stage='Production', version='1'), + MagicMock(current_stage='Production', version='2', source='runs/test/1'), + MagicMock(current_stage='Archived', version='3'), + ] + + output = mlflow_repository.get_model_run_id('test') + + mlflow_repository.client.search_registered_models.assert_called_once_with( + filter_string="name='test'" + ) + mlflow_repository.client.search_model_versions.assert_called_once_with( + filter_string="name='test'" + ) + + assert output == '1' + + +def test_get_model_run_id_missing_source(mlflow_repository): + mlflow_repository.client.search_registered_models.return_value = [MagicMock(name='test')] + + mlflow_repository.client.search_model_versions.return_value = [ + MagicMock(current_stage='Production', version='1', source='runs/test/0'), + MagicMock(current_stage='Production', version='2', source=None), + ] + + with pytest.raises(mlflow_lib.exceptions.MlflowException) as exc_info: + mlflow_repository.get_model_run_id('test') + + assert ( + str(exc_info.value) + == "Model 'test' version '2' in stage 'Production' has no source URI to resolve run ID." + ) + + +def test_get_model_run_id_invalid_source(mlflow_repository): + mlflow_repository.client.search_registered_models.return_value = [MagicMock(name='test')] + + mlflow_repository.client.search_model_versions.return_value = [ + MagicMock(current_stage='Production', version='1', source='runs/test/0'), + MagicMock(current_stage='Production', version='2', source='runs/test'), + ] + + with pytest.raises(mlflow_lib.exceptions.MlflowException) as exc_info: + mlflow_repository.get_model_run_id('test') + + assert ( + str(exc_info.value) + == "Model 'test' version '2' in stage 'Production' has invalid source URI " + "'runs/test' for run ID resolution." + ) + + +def test_get_next_run_name(mlflow, mlflow_repository): + mlflow.search_runs.return_value = [1, 2, 3] + output = mlflow_repository.get_next_run_name('run') + assert output == 'run-4' + mlflow.search_runs.assert_called_once_with( + experiment_names=['run'], + order_by=['start_time desc'], + ) + + +def test_get_experiment_experiment_exists(mlflow, mlflow_repository): + experiment = MagicMock(experiment_id='0') + + mlflow.get_experiment_by_name.return_value = experiment + + output = mlflow_repository.get_experiment('test') + + assert output == experiment + + +def test_get_experiment_none_create(mlflow, mlflow_repository): + experiment = MagicMock(experiment_id='0') + + mlflow.get_experiment_by_name.return_value = None + + mlflow.get_experiment.return_value = experiment + + output = mlflow_repository.get_experiment('test', create_if_not_exists=True) + + mlflow.create_experiment.assert_called_once_with('test') + mlflow.get_experiment.assert_called_once_with(mlflow.create_experiment.return_value) + + assert output == experiment + + +def test_get_experiment_none_not_create(mlflow, mlflow_repository): + mlflow.get_experiment_by_name.return_value = None + + with pytest.raises(ValueError) as e: + mlflow_repository.get_experiment('test', create_if_not_exists=False) + + assert str(e) == 'Experiment test not found' + + +def test_get_model_params(mlflow, mlflow_repository): + output = mlflow_repository.get_model_params('test') + assert output == mlflow.get_run.return_value.data.params + + +def test_check_artifact_exists_true(mlflow_repository): + artifact = MagicMock(path='test_artifact') + mlflow_repository.client.list_artifacts.return_value = [artifact] + + result = mlflow_repository.check_artifact_exists( + 'run_id', 'test_artifact', metadata['metadata'] + ) + + assert result is True + mlflow_repository.client.list_artifacts.assert_called_once_with('run_id') + + +def test_check_artifact_exists_false(mlflow_repository): + artifact = MagicMock(path='other_artifact') + mlflow_repository.client.list_artifacts.return_value = [artifact] + + result = mlflow_repository.check_artifact_exists( + 'run_id', 'test_artifact', metadata['metadata'] + ) + + assert result is False + mlflow_repository.client.list_artifacts.assert_called_once_with('run_id') + + +@pytest.mark.asyncio +@patch('laborious.utils.repository.model_repository.path') +@patch('laborious.utils.repository.model_repository.rmtree') +@patch('laborious.utils.repository.model_repository.makedirs') +async def test_download_artifacts_success(makedirs, rmtree, path, mlflow_repository): + mlflow_repository.get_model_run_id = MagicMock(return_value='test') + + path.exists.return_value = True + + output = await mlflow_repository.dowload_artifacts('test', {}, 'path') + + mlflow_repository.get_model_run_id.assert_called_once_with( + model_name='test', stage='Production' + ) + + path.join.assert_called_once_with('./tmp/artifacts/test', 'path') + + path.exists.assert_called_once_with(path.join.return_value) + + rmtree.assert_called_once_with(path.join.return_value) + + makedirs.assert_called_once_with('./tmp/artifacts/test', exist_ok=True) + + mlflow_repository.client.download_artifacts.assert_called_once_with( + mlflow_repository.get_model_run_id.return_value, 'path', './tmp/artifacts/test' + ) + + assert output == mlflow_repository.client.download_artifacts.return_value + + mlflow_repository.observe_lag.assert_called_once_with(ANY, metrics.MODEL_READ_LAG, ANY) + mlflow_repository.emit_metric.assert_called_once_with( + metric_object=metrics.MODEL_READ_COUNT, tags=ANY + ) + + +@pytest.mark.asyncio +@patch('laborious.utils.repository.model_repository.path') +@patch('laborious.utils.repository.model_repository.rmtree') +@patch('laborious.utils.repository.model_repository.makedirs') +async def test_download_artifacts_success_path_false(makedirs, rmtree, path, mlflow_repository): + mlflow_repository.get_model_run_id = MagicMock(return_value='test') + + path.exists.return_value = False + + output = await mlflow_repository.dowload_artifacts('test', {}, 'path') + + mlflow_repository.get_model_run_id.assert_called_once_with( + model_name='test', stage='Production' + ) + + path.join.assert_called_once_with('./tmp/artifacts/test', 'path') + + path.exists.assert_called_once_with(path.join.return_value) + + rmtree.assert_not_called() + + makedirs.assert_called_once_with('./tmp/artifacts/test', exist_ok=True) + + mlflow_repository.client.download_artifacts.assert_called_once_with( + mlflow_repository.get_model_run_id.return_value, 'path', './tmp/artifacts/test' + ) + + assert output == mlflow_repository.client.download_artifacts.return_value + + +@pytest.mark.asyncio +@patch('laborious.utils.repository.model_repository.path') +@patch('laborious.utils.repository.model_repository.rmtree') +@patch('laborious.utils.repository.model_repository.makedirs') +async def test_download_artifacts_error(makedirs, rmtree, path, mlflow_repository): + mlflow_repository.get_model_run_id = MagicMock(return_value='test') + + path.exists.return_value = True + + mlflow_repository.client.download_artifacts.side_effect = ValueError('test') + + with pytest.raises(ValueError): + await mlflow_repository.dowload_artifacts('test', {}, 'path') + + mlflow_repository.emit_metric.assert_called_once_with( + metric_object=metrics.MODEL_READ_ERROR_COUNT, tags=ANY + ) + mlflow_repository.observe_lag.assert_not_called() + + +@pytest.mark.asyncio +@patch('laborious.utils.repository.model_repository.mlflow') +@patch('laborious.utils.repository.model_repository.pd') +@patch('laborious.utils.repository.model_repository.StringIO') +async def test_load_artifact_dataframe_success(_stringio, pd, mlflow, mlflow_repository): + mlflow_repository.get_model_run_id = MagicMock(return_value='run_id') + mlflow_repository.check_artifact_exists = MagicMock(return_value=True) + + mlflow.artifacts.load_text.return_value = 'col1,col2\n1,2\n3,4' + + result = await mlflow_repository.load_artifact_dataframe( + 'model_name', 'artifact_path', metadata['metadata'] + ) + + mlflow_repository.get_model_run_id.assert_called_once_with( + model_name='model_name', stage='Production' + ) + mlflow_repository.check_artifact_exists.assert_called_once_with( + 'run_id', 'artifact_path', metadata['metadata'] + ) + mlflow.artifacts.load_text.assert_called_once_with('runs:/run_id/artifact_path') + assert result == pd.read_csv.return_value + mlflow_repository.observe_lag.assert_called_once_with(ANY, metrics.MODEL_READ_LAG, ANY) + mlflow_repository.emit_metric.assert_called_once_with( + metric_object=metrics.MODEL_READ_COUNT, tags=ANY + ) + + +@pytest.mark.asyncio +async def test_load_artifact_dataframe_not_exists(mlflow_repository): + mlflow_repository.get_model_run_id = MagicMock(return_value='run_id') + mlflow_repository.check_artifact_exists = MagicMock(return_value=False) + + result = await mlflow_repository.load_artifact_dataframe( + 'model_name', 'artifact_path', metadata['metadata'] + ) + + assert result is None + mlflow_repository.get_model_run_id.assert_called_once_with( + model_name='model_name', stage='Production' + ) + mlflow_repository.check_artifact_exists.assert_called_once_with( + 'run_id', 'artifact_path', metadata['metadata'] + ) + + +@pytest.mark.asyncio +@patch('laborious.utils.repository.model_repository.mlflow') +async def test_load_artifact_dataframe_error(mlflow, mlflow_repository): + mlflow_repository.get_model_run_id = MagicMock(return_value='run_id') + mlflow_repository.check_artifact_exists = MagicMock(return_value=True) + mlflow.artifacts.load_text.side_effect = ValueError('error') + + with pytest.raises(ValueError): + await mlflow_repository.load_artifact_dataframe( + 'model_name', 'artifact_path', metadata['metadata'] + ) + + mlflow_repository.emit_metric.assert_called_once_with( + metric_object=metrics.MODEL_READ_ERROR_COUNT, tags=ANY + ) + mlflow_repository.observe_lag.assert_not_called() + + +def test_get_experiment_error(mlflow, mlflow_repository): + mlflow.get_experiment_by_name.return_value = None + + try: + mlflow_repository.get_experiment('test') + except ValueError as e: + assert str(e) == 'Experiment test not found' + else: + raise AssertionError('Expected ValueError') + + +def test_get_experiment_create_error(mlflow, mlflow_repository): + mlflow.get_experiment_by_name.return_value = None + mlflow.create_experiment.return_value = None + mlflow.get_experiment.return_value = None + with pytest.raises(ValueError) as e: + mlflow_repository.get_experiment('test', create_if_not_exists=True) + + assert str(e) == 'Experiment test not found after creation, unknown reason' + + +@pytest.mark.asyncio +async def test_load_predict_model_sklearn(mlflow, mlflow_repository): + result = await mlflow_repository.load_predict_model('test_model', {}, 'sklearn') + + assert result == mlflow.sklearn.load_model.return_value + mlflow.sklearn.load_model.assert_called_once_with('models:/test_model/production') + mlflow_repository.observe_lag.assert_called_once_with(ANY, metrics.MODEL_READ_LAG, ANY) + mlflow_repository.emit_metric.assert_called_once_with( + metric_object=metrics.MODEL_READ_COUNT, tags=ANY + ) + + +@pytest.mark.asyncio +async def test_load_predict_model_pyfunc(mlflow, mlflow_repository): + result = await mlflow_repository.load_predict_model('test_model', {}, 'pyfunc') + assert result == mlflow.pyfunc.load_model.return_value + mlflow.pyfunc.load_model.assert_called_once_with('models:/test_model/production') + mlflow_repository.observe_lag.assert_called_once_with(ANY, metrics.MODEL_READ_LAG, ANY) + mlflow_repository.emit_metric.assert_called_once_with( + metric_object=metrics.MODEL_READ_COUNT, tags=ANY + ) + + +@pytest.mark.asyncio +async def test_load_predict_model_pytorch(mlflow, mlflow_repository): + result = await mlflow_repository.load_predict_model('test_model', {}, 'pytorch') + assert result == mlflow.pytorch.load_model.return_value + mlflow.pytorch.load_model.assert_called_once_with('models:/test_model/production') + mlflow_repository.observe_lag.assert_called_once_with(ANY, metrics.MODEL_READ_LAG, ANY) + mlflow_repository.emit_metric.assert_called_once_with( + metric_object=metrics.MODEL_READ_COUNT, tags=ANY + ) + + +@pytest.mark.asyncio +async def test_load_predict_model_error(mlflow_repository): + with pytest.raises(ValueError) as e: + await mlflow_repository.load_predict_model('test_model', {}, 'invalid') + assert str(e) == "Invalid flavor. Use 'sklearn' or 'pyfunc' or 'pytorch'." + + mlflow_repository.emit_metric.assert_called_once_with( + metric_object=metrics.MODEL_READ_ERROR_COUNT, tags=ANY + ) + mlflow_repository.observe_lag.assert_not_called() + + +def validate_common_load_transform_model_mocks(mlflow_repository, model_name): + mlflow_repository.get_model_run_id.assert_called_once_with( + model_name=model_name, stage='Production' + ) + mlflow_repository.get_model_uri.assert_called_once_with( + mlflow_repository.get_model_run_id.return_value, prediction=False + ) + + +@pytest.mark.asyncio +async def test_load_transform_model_sklearn(mlflow, mlflow_repository): + mlflow_repository.get_model_run_id = MagicMock() + mlflow_repository.get_model_uri = MagicMock() + + result = await mlflow_repository.load_transform_model('test_model', {}, 'sklearn') + + validate_common_load_transform_model_mocks(mlflow_repository, 'test_model') + + assert result == mlflow.sklearn.load_model.return_value + mlflow.sklearn.load_model.assert_called_once_with(mlflow_repository.get_model_uri.return_value) + + mlflow_repository.observe_lag.assert_called_once_with(ANY, metrics.MODEL_READ_LAG, ANY) + mlflow_repository.emit_metric.assert_called_once_with( + metric_object=metrics.MODEL_READ_COUNT, tags=ANY + ) + + +@pytest.mark.asyncio +async def test_load_transform_model_pyfunc(mlflow, mlflow_repository): + mlflow_repository.get_model_run_id = MagicMock() + mlflow_repository.get_model_uri = MagicMock() + + result = await mlflow_repository.load_transform_model('test_model', {}, 'pyfunc') + + validate_common_load_transform_model_mocks(mlflow_repository, 'test_model') + + assert result == mlflow.pyfunc.load_model.return_value + mlflow.pyfunc.load_model.assert_called_once_with(mlflow_repository.get_model_uri.return_value) + + mlflow_repository.observe_lag.assert_called_once_with(ANY, metrics.MODEL_READ_LAG, ANY) + mlflow_repository.emit_metric.assert_called_once_with( + metric_object=metrics.MODEL_READ_COUNT, tags=ANY + ) + + +@pytest.mark.asyncio +async def test_load_transform_model_pytorch(mlflow, mlflow_repository): + mlflow_repository.get_model_run_id = MagicMock() + mlflow_repository.get_model_uri = MagicMock() + + result = await mlflow_repository.load_transform_model('test_model', {}, 'pytorch') + validate_common_load_transform_model_mocks(mlflow_repository, 'test_model') + + assert result == mlflow.pytorch.load_model.return_value + mlflow.pytorch.load_model.assert_called_once_with(mlflow_repository.get_model_uri.return_value) + + mlflow_repository.observe_lag.assert_called_once_with(ANY, metrics.MODEL_READ_LAG, ANY) + mlflow_repository.emit_metric.assert_called_once_with( + metric_object=metrics.MODEL_READ_COUNT, tags=ANY + ) + + +@pytest.mark.asyncio +async def test_load_transform_model_error(mlflow_repository): + mlflow_repository.get_model_run_id = MagicMock() + mlflow_repository.get_model_uri = MagicMock() + + with pytest.raises(ValueError) as e: + await mlflow_repository.load_transform_model('test_model', {}, 'invalid') + assert str(e) == "Invalid flavor. Use 'sklearn' or 'pyfunc' or 'pytorch'." + + mlflow_repository.emit_metric.assert_called_once_with( + metric_object=metrics.MODEL_READ_ERROR_COUNT, tags=ANY + ) + mlflow_repository.observe_lag.assert_not_called() + + +@pytest.mark.asyncio +async def test_download_model_invalid_model_type(mlflow_repository): + with pytest.raises(ValueError) as e: + await mlflow_repository.download_model('test_model', {}, 'invalid', 'sklearn') + assert str(e) == "Invalid model_type. Use 'predict' or 'transform'." + + mlflow_repository.emit_metric.assert_called_once_with( + metric_object=metrics.MODEL_READ_ERROR_COUNT, tags=ANY + ) + mlflow_repository.observe_lag.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + 'model_type', [('predict', 'prediction_model'), ('transform', 'data_model')] +) +async def test_download_model_load_wrapper(mlflow, mlflow_repository, model_type): + mlflow_repository.dowload_artifacts = AsyncMock() + + result = await mlflow_repository.download_model('test_model', {}, model_type[0], 'pyfunc', True) + + mlflow_repository.dowload_artifacts.assert_called_once_with('test_model', {}, model_type[1]) + + mlflow.pyfunc.load_model.assert_called_once_with( + mlflow_repository.dowload_artifacts.return_value + ) + + assert result == ( + mlflow.pyfunc.load_model.return_value._model_impl.python_model, + mlflow_repository.dowload_artifacts.return_value, + ) + + +@pytest.mark.asyncio +async def test_download_model_predict(mlflow_repository): + mlflow_repository.load_predict_model = AsyncMock() + mlflow_repository.load_transform_model = AsyncMock() + + result = await mlflow_repository.download_model('test_model', {}, 'predict', 'pyfunc', False) + + mlflow_repository.load_predict_model.assert_called_once_with('test_model', {}, 'pyfunc') + mlflow_repository.load_transform_model.assert_not_called() + + assert result == (mlflow_repository.load_predict_model.return_value, None) + + +@pytest.mark.asyncio +async def test_download_model_transform(mlflow_repository): + mlflow_repository.load_predict_model = AsyncMock() + mlflow_repository.load_transform_model = AsyncMock() + + result = await mlflow_repository.download_model('test_model', {}, 'transform', 'pyfunc', False) + + mlflow_repository.load_predict_model.assert_not_called() + mlflow_repository.load_transform_model.assert_called_once_with('test_model', {}, 'pyfunc') + + assert result == (mlflow_repository.load_transform_model.return_value, None) + + +def test_detect_and_parse_datetime_index_empty(mlflow_repository): + input_data = DataFrame() + response = mlflow_repository.detect_and_parse_datetime_index(input_data, metadata['metadata']) + assert response.equals(input_data) + + +invalid_cases = [ + ( + {'value': {'2024-01-01 12:00:00': 1, 2024: 2}}, + "Index must be all timestamp like column. Valid formats are: pandas Timestamp, datetime, string in format %Y-%m-%d %H:%M:%S. Elements are , .", + ), + ( + {'value': {'2024-01-01': 1, '2024-01-02': 2}}, + 'Index must be all timestamp like column. Valid formats are: pandas Timestamp, datetime, string in format %Y-%m-%d %H:%M:%S. Unable to parse given date format', + ), + ( + {'value': {Any(): 1, Any(): 2}}, + 'Index must be all timestamp like column. Valid formats are: pandas Timestamp, datetime, string in format %Y-%m-%d %H:%M:%S. Got .', + ), +] + + +@pytest.mark.parametrize('data', invalid_cases) +def test_detect_and_parse_datetime_index_error_cases(mlflow_repository, data): + input_data = DataFrame(data[0]) + + message = data[1] + + with pytest.raises(ValueError) as e: + mlflow_repository.detect_and_parse_datetime_index(input_data, metadata['metadata']) + + assert str(e) == message + + +valid_cases = [ + ( + {'value': {'2024-01-01 12:00:00+0000': 1, '2024-01-02 12:00:00+0000': 2}}, + ['2024-01-01 12:00:00+0000', '2024-01-02 12:00:00+0000'], + ), + ( + { + 'value': { + datetime(2025, 1, 1, 12, 0, 0, tzinfo=UTC): 1, + datetime(2025, 1, 2, 12, 0, 0, tzinfo=UTC): 2, + } + }, + ['2025-01-01 12:00:00+0000', '2025-01-02 12:00:00+0000'], + ), + ( + { + 'value': { + datetime(2025, 1, 1, 12, 0, 0, tzinfo=None): 1, + datetime(2025, 1, 2, 12, 0, 0, tzinfo=None): 2, + } + }, + ['2025-01-01 12:00:00+0000', '2025-01-02 12:00:00+0000'], + ), + ( + { + 'value': { + Timestamp(2026, 1, 1, 12, 0, 0, tzinfo=UTC): 1, + Timestamp(2026, 1, 2, 12, 0, 0, tzinfo=UTC): 2, + } + }, + ['2026-01-01 12:00:00+0000', '2026-01-02 12:00:00+0000'], + ), +] + + +@pytest.mark.parametrize('data,expected', valid_cases) +def test_detect_and_parse_datetime_index_valid_format(mlflow_repository, data, expected): + input_data = DataFrame(data) + + response = mlflow_repository.detect_and_parse_datetime_index(input_data, metadata['metadata']) + + assert response.index.tolist() == expected + + +@patch('laborious.utils.repository.model_repository.datetime') +def test_check_cache_retention_false(datetime_mock, mlflow_repository): + datetime_mock.now = MagicMock(return_value=datetime.strptime('2025-01-02', '%Y-%m-%d')) + cache = {'timestamp': datetime.strptime('2025-01-01', '%Y-%m-%d')} + + assert mlflow_repository.check_cache_retention(cache, 1) is False + + +@patch('laborious.utils.repository.model_repository.datetime') +def test_check_cache_retention_true(datetime_mock, mlflow_repository): + datetime_mock.now = MagicMock(return_value=datetime.strptime('2025-01-01', '%Y-%m-%d')) + cache = {'timestamp': datetime.strptime('2025-01-01', '%Y-%m-%d')} + assert mlflow_repository.check_cache_retention(cache, 1) is True + + +def test_handle_valid_model(mlflow_repository): + cache = {'target': {'model': 'model', 'artifact_path': 'test_artifact_path'}} + output = mlflow_repository.handle_valid_model('model_name', cache) + assert output == {'model': 'model', 'artifact_path': 'test_artifact_path'} + + +def test_handle_outdated_model(mlflow_repository): + mlflow_repository.model_cache = { + 'model_name_transform': { + 'target': {'model': 'model', 'artifact_path': 'test_artifact_path'} + } + } + mlflow_repository.handle_outdated_model('model_name', 'model_name_transform') + assert mlflow_repository.model_cache == {} + + +@pytest.mark.asyncio +async def test_get_model_retention_0(mlflow_repository): + model = MagicMock() + + mlflow_repository.download_model = AsyncMock(return_value=(model, 'artifact_path')) + output = await mlflow_repository.get_model('model_name', {}, 0, 'predict', 'pyfunc') + + assert output == model + mlflow_repository.download_model.assert_called_once_with( + model_name='model_name', + metadata={}, + model_type='predict', + flavor='pyfunc', + load_wrapper=False, + ) + + +@pytest.mark.asyncio +async def test_get_model_cached_valid(mlflow_repository): + mlflow_repository.check_cache_retention = MagicMock(return_value=True) + mlflow_repository.handle_valid_model = MagicMock() + mlflow_repository.handle_outdated_model = MagicMock() + mlflow_repository.model_cache = { + 'model_name_predict': { + 'target': 'cached_model', + } + } + + output = await mlflow_repository.get_model('model_name', {}, 1, 'predict', 'pyfunc') + + assert output == mlflow_repository.handle_valid_model.return_value + mlflow_repository.check_cache_retention.assert_called_once_with( + mlflow_repository.model_cache['model_name_predict'], 1 + ) + + mlflow_repository.handle_valid_model.assert_called_once_with( + model_name='model_name', cache=mlflow_repository.model_cache['model_name_predict'] + ) + + mlflow_repository.handle_outdated_model.assert_not_called() + + +@pytest.mark.asyncio +async def test_get_model_cached_outdated(mlflow_repository): + mlflow_repository.check_cache_retention = MagicMock(return_value=False) + mlflow_repository.handle_valid_model = MagicMock() + mlflow_repository.handle_outdated_model = MagicMock() + model = MagicMock() + mlflow_repository.download_model = AsyncMock(return_value=(model, 'artifact_path')) + cache = { + 'model_name_predict': { + 'target': 'cached_model', + } + } + mlflow_repository.model_cache = cache + + output = await mlflow_repository.get_model('model_name', {}, 1, 'predict', 'pyfunc') + assert output == model + mlflow_repository.check_cache_retention.assert_called_once_with( + { + 'target': 'cached_model', + }, + 1, + ) + mlflow_repository.handle_valid_model.assert_not_called() + mlflow_repository.handle_outdated_model.assert_called_once_with( + model_name='model_name', model_key='model_name_predict' + ) + + +@pytest.mark.asyncio +async def test_get_model_cached_not_found(mlflow_repository): + mlflow_repository.check_cache_retention = MagicMock(return_value=False) + mlflow_repository.handle_valid_model = MagicMock() + mlflow_repository.handle_outdated_model = MagicMock() + mlflow_repository.model_cache = {} + model = MagicMock() + mlflow_repository.download_model = AsyncMock(return_value=(model, 'artifact_path')) + output = await mlflow_repository.get_model('model_name', {}, 1, 'predict', 'pyfunc') + assert output == model + mlflow_repository.check_cache_retention.assert_not_called() + mlflow_repository.handle_valid_model.assert_not_called() + mlflow_repository.handle_outdated_model.assert_not_called() + + +@patch('laborious.utils.repository.model_repository.force_memory_release') +@pytest.mark.asyncio +async def test_get_cached_operation_retention_0(force_memory_release, mlflow_repository): + model = MagicMock() + data = MagicMock() + mlflow_repository.get_model = AsyncMock(return_value=model) + output = await mlflow_repository.get_cached_operation( + 'model_name', data, 'transform', 0, 'sklearn', {} + ) + assert output == model.predict.return_value + force_memory_release.assert_called_once_with(mlflow_repository.logger) + + +@patch('laborious.utils.repository.model_repository.force_memory_release') +@pytest.mark.asyncio +async def test_get_cached_predict_retention_not_0(force_memory_release, mlflow_repository): + model = MagicMock() + data = MagicMock() + mlflow_repository.get_model = AsyncMock(return_value=model) + output = await mlflow_repository.get_cached_operation( + 'model_name', data, 'predict', 1, 'sklearn', {} + ) + assert output == model.predict.return_value + force_memory_release.assert_not_called() + + +@patch('laborious.utils.repository.model_repository.force_memory_release') +@pytest.mark.asyncio +async def test_get_cached_operation_invalid_operation(force_memory_release, mlflow_repository): + data = MagicMock() + with pytest.raises(ValueError) as e: + await mlflow_repository.get_cached_operation( + 'model_name', data, 'invalid', 0, 'sklearn', {} + ) + assert str(e) == "Invalid operation. Use 'transform' or 'predict'." + + +@patch('laborious.utils.repository.model_repository.pd.merge') +@patch('laborious.utils.repository.model_repository.isinstance') +@pytest.mark.asyncio +async def test_fit_models_not_df_target_name_none_and_not_in_model( + isinstance_mock, pd_merge, mlflow_repository +): + isinstance_mock.return_value = False + + data_model = MagicMock() + prediction_model = MagicMock() + mlflow_repository.download_model = AsyncMock( + side_effect=[(data_model, 'artifact_path'), (prediction_model, 'artifact_path')], + ) + mlflow_repository.detect_and_parse_datetime_index = MagicMock( + return_value=MagicMock(drop_duplicates=MagicMock(return_value=MagicMock(columns=[]))) + ) + mlflow_repository.get_prediction_data = MagicMock(return_value=DataFrame()) + + data = MagicMock() + + output = await mlflow_repository.fit_models( + 'model_name', + data, + 'latest_production_id', + metadata['metadata'], + 'sklearn', + False, + 'pyfunc', + None, + ) + + mlflow_repository.download_model.assert_has_calls( + [ + call( + model_name='model_name', + metadata=metadata['metadata'], + model_type='transform', + flavor='sklearn', + load_wrapper=False, + ), + call( + model_name='model_name', + metadata=metadata['metadata'], + model_type='predict', + flavor='pyfunc', + load_wrapper=True, + ), + ] + ) + + data_model.fit.assert_called_once_with(data) + + data_model.fit.return_value.predict.assert_called_once_with(data) + + transformed_data = data_model.fit.return_value.predict.return_value + + transformed_data.__setitem__.assert_called_once_with('timestamp', transformed_data.index) + + mlflow_repository.detect_and_parse_datetime_index.assert_called_once_with( + transformed_data, metadata['metadata'] + ) + + transformed_data = mlflow_repository.detect_and_parse_datetime_index.return_value + + transformed_data.drop_duplicates.assert_called_once_with(subset=['timestamp'], keep='first') + + transformed_data = transformed_data.drop_duplicates.return_value + + data.loc.__getitem__.assert_called_once_with(transformed_data.index) + + aligned_data = data.loc.__getitem__.return_value + + aligned_data.__getitem__.assert_called_once_with(data_model.fit.return_value.target_variable) + + pd_merge.assert_called_once_with( + transformed_data, aligned_data.__getitem__.return_value, left_index=True, right_index=True + ) + + prediction_model.fit.assert_called_once_with(pd_merge.return_value) + + mlflow_repository.get_prediction_data.assert_called_once_with( + prediction_model, + pd_merge.return_value, + data_model.fit.return_value.target_variable, + 'pyfunc', + ) + + assert output == { + 'prediction_model': {'model': prediction_model, 'artifact_path': 'artifact_path'}, + 'data_model': {'model': data_model.fit.return_value, 'artifact_path': 'artifact_path'}, + 'prediction_data': mlflow_repository.get_prediction_data.return_value, + } + + +@patch('laborious.utils.repository.model_repository.pd.merge') +@patch('laborious.utils.repository.model_repository.isinstance') +@pytest.mark.asyncio +async def test_fit_models_df_target_name_not_none_and_in_model( + isinstance_mock, pd_merge, mlflow_repository +): + isinstance_mock.return_value = True + + data_model = MagicMock( + target_variable='feat_2', + ) + prediction_model = MagicMock() + mlflow_repository.download_model = AsyncMock( + side_effect=[(data_model, 'artifact_path'), (prediction_model, 'artifact_path')], + ) + mlflow_repository.detect_and_parse_datetime_index = MagicMock( + return_value=MagicMock( + drop_duplicates=MagicMock(return_value=MagicMock(columns=['feat_1'])) + ) + ) + mlflow_repository.get_prediction_data = MagicMock(return_value=DataFrame()) + + data = MagicMock() + + output = await mlflow_repository.fit_models( + 'model_name', + data, + 'latest_production_id', + metadata['metadata'], + 'sklearn', + False, + 'pyfunc', + 'feat_1', + ) + + mlflow_repository.download_model.assert_has_calls( + [ + call( + model_name='model_name', + metadata=metadata['metadata'], + model_type='transform', + flavor='sklearn', + load_wrapper=False, + ), + call( + model_name='model_name', + metadata=metadata['metadata'], + model_type='predict', + flavor='pyfunc', + load_wrapper=True, + ), + ] + ) + + data_model.fit.assert_called_once_with(data) + + transformed_data = data_model.fit.return_value + + transformed_data.__setitem__.assert_called_once_with('timestamp', transformed_data.index) + + mlflow_repository.detect_and_parse_datetime_index.assert_called_once_with( + transformed_data, metadata['metadata'] + ) + + transformed_data = mlflow_repository.detect_and_parse_datetime_index.return_value + + transformed_data.drop_duplicates.assert_called_once_with(subset=['timestamp'], keep='first') + + transformed_data = transformed_data.drop_duplicates.return_value + + data.loc.__getitem__.assert_not_called() + + pd_merge.assert_not_called() + + prediction_model.fit.assert_called_once_with(transformed_data) + + mlflow_repository.get_prediction_data.assert_called_once_with( + prediction_model, transformed_data, 'feat_1', 'pyfunc' + ) + + assert output == { + 'prediction_model': {'model': prediction_model, 'artifact_path': 'artifact_path'}, + 'data_model': {'model': data_model, 'artifact_path': 'artifact_path'}, + 'prediction_data': mlflow_repository.get_prediction_data.return_value, + } + + +@pytest.mark.asyncio +async def test_log_model_sklearn(mlflow, mlflow_repository): + model_data = {'model': MagicMock(), 'artifact_path': 'artifact_path'} + await mlflow_repository.log_model( + model_data, 'sklearn', 'prediction_model', metadata['metadata'] + ) + mlflow.sklearn.log_model.assert_called_once_with(model_data['model'], 'prediction_model') + + mlflow_repository.observe_lag.assert_called_once_with(ANY, metrics.MODEL_WRITE_LAG, ANY) + mlflow_repository.emit_metric.assert_called_once_with( + metric_object=metrics.MODEL_WRITE_COUNT, tags=ANY + ) + + +@patch('laborious.utils.repository.model_repository.path') +@pytest.mark.asyncio +async def test_log_model_pyfunc(path, mlflow, mlflow_repository): + model_mock = MagicMock() + model_data = {'model': model_mock, 'artifact_path': 'artifact_path'} + await mlflow_repository.log_model( + model_data, 'pyfunc', 'prediction_model', metadata['metadata'] + ) + + mlflow.pyfunc.log_model.assert_not_called() + + path.join.assert_called_once_with('artifact_path', 'code', 'utils') + + model_mock.store_model.assert_called_once_with( + artifact_path='prediction_model', code_path=[path.join.return_value], to_disk=False + ) + + mlflow_repository.observe_lag.assert_called_once_with(ANY, metrics.MODEL_WRITE_LAG, ANY) + mlflow_repository.emit_metric.assert_called_once_with( + metric_object=metrics.MODEL_WRITE_COUNT, tags=ANY + ) + + +@pytest.mark.asyncio +async def test_log_model_pytorch(mlflow, mlflow_repository): + model_data = {'model': MagicMock(), 'artifact_path': 'artifact_path'} + await mlflow_repository.log_model( + model_data, 'pytorch', 'prediction_model', metadata['metadata'] + ) + mlflow.pytorch.log_model.assert_called_once_with(model_data['model'], 'prediction_model') + + mlflow_repository.observe_lag.assert_called_once_with(ANY, metrics.MODEL_WRITE_LAG, ANY) + mlflow_repository.emit_metric.assert_called_once_with( + metric_object=metrics.MODEL_WRITE_COUNT, tags=ANY + ) + + +@pytest.mark.asyncio +async def test_log_model_error(mlflow_repository): + model_data = {'model': MagicMock(), 'artifact_path': 'artifact_path'} + with pytest.raises(ValueError) as e: + await mlflow_repository.log_model( + model_data, 'invalid', 'prediction_model', metadata['metadata'] + ) + assert str(e) == "Invalid flavor. Use 'sklearn' or 'pyfunc' or 'pytorch'." + mlflow_repository.emit_metric.assert_called_once_with( + metric_object=metrics.MODEL_WRITE_ERROR_COUNT, tags=ANY + ) + mlflow_repository.observe_lag.assert_not_called() + + +@patch('laborious.utils.repository.model_repository.force_memory_release') +@patch('laborious.utils.repository.model_repository.path') +@patch('laborious.utils.repository.model_repository.rmtree') +@pytest.mark.asyncio +async def test_create_new_experiment( + _rmtree, path, force_memory_release, mlflow, mlflow_repository +): + model_name = 'model_name' + data = MagicMock() + prediction_data = MagicMock(spec=DataFrame) + retrain_data = { + 'prediction_model': {'model': MagicMock(), 'artifact_path': 'artifact_path'}, + 'data_model': {'model': MagicMock(), 'artifact_path': 'artifact_path'}, + 'prediction_data': prediction_data, + } + + mlflow_repository.get_model_params = MagicMock( + return_value={ + 'transform_flavor': 'sklearn', + 'predict_flavor': 'pyfunc', + 'target_name': 'target_name', + } + ) + + mlflow_repository.get_experiment = MagicMock() + mlflow_repository.get_next_run_name = MagicMock() + mlflow_repository.log_model = AsyncMock() + path.exists.return_value = True + path.join.return_value = './tmp/artifacts/model_name' + + report = await mlflow_repository.create_new_experiment( + model_name, + data, + retrain_data, + 'latest_production_id', + metadata['metadata'], + 'sklearn', + 'pyfunc', + ) + + path.join.assert_called_once_with('./tmp/artifacts', 'model_name') + + mlflow_repository.get_model_params.assert_called_once_with('latest_production_id') + mlflow_repository.get_experiment.assert_called_once_with(model_name, create_if_not_exists=True) + mlflow_repository.get_next_run_name.assert_called_once_with( + mlflow_repository.get_experiment.return_value.name + ) + + data.to_csv.assert_called_once_with('./tmp/artifacts/model_name/retrain_data.csv', index=False) + # Verify prediction_data.to_csv was called with correct arguments + prediction_data.to_csv.assert_called_once() + assert ( + prediction_data.to_csv.call_args[0][0] == './tmp/artifacts/model_name/evaluation_data.csv' + ) + assert prediction_data.to_csv.call_args[1]['index'] is False + + mlflow.start_run.assert_called_once_with( + experiment_id=mlflow_repository.get_experiment.return_value.experiment_id, + run_name=mlflow_repository.get_next_run_name.return_value, + description='Retrain model model_name with new data', + ) + + mlflow_repository.log_model.assert_has_calls( + [ + call(retrain_data['data_model'], 'sklearn', 'data_model', metadata['metadata']), + call( + retrain_data['prediction_model'], 'pyfunc', 'prediction_model', metadata['metadata'] + ), + ] + ) + + mlflow.log_params.assert_called_once_with( + { + 'transform_flavor': 'sklearn', + 'predict_flavor': 'pyfunc', + 'target_name': 'target_name', + 'retrain': True, + 'retrain_date': ANY, + 'source_run_id': 'latest_production_id', + 'retrain_samples': data.shape.__str__.return_value, + } + ) + + mlflow.log_artifact.assert_has_calls( + [ + call('./tmp/artifacts/model_name/retrain_data.csv'), + call('./tmp/artifacts/model_name/evaluation_data.csv'), + ] + ) + + force_memory_release.assert_called_once_with(mlflow_repository.logger) + + assert report == { + 'run_id': mlflow.start_run.return_value.__enter__.return_value.info.run_id, + 'experiment_id': mlflow_repository.get_experiment.return_value.experiment_id, + 'experiment_name': mlflow_repository.get_experiment.return_value.name, + } + + mlflow_repository.observe_lag.assert_called_once_with(ANY, metrics.MODEL_WRITE_LAG, ANY) + mlflow_repository.emit_metric.assert_called_once_with( + metric_object=metrics.MODEL_WRITE_COUNT, tags=ANY + ) + + +@patch('laborious.utils.repository.model_repository.force_memory_release') +@patch('laborious.utils.repository.model_repository.path') +@patch('laborious.utils.repository.model_repository.rmtree') +@pytest.mark.asyncio +async def test_create_new_experiment_error( + _rmtree, path, force_memory_release, mlflow, mlflow_repository +): + mlflow.start_run.side_effect = ValueError('error') + model_name = 'model_name' + data = MagicMock() + prediction_data = MagicMock(spec=DataFrame) + retrain_data = { + 'prediction_model': {'model': MagicMock(), 'artifact_path': 'artifact_path'}, + 'data_model': {'model': MagicMock(), 'artifact_path': 'artifact_path'}, + 'prediction_data': prediction_data, + } + + mlflow_repository.get_model_params = MagicMock( + return_value={ + 'transform_flavor': 'sklearn', + 'predict_flavor': 'pyfunc', + 'target_name': 'target_name', + } + ) + + mlflow_repository.get_experiment = MagicMock() + mlflow_repository.get_next_run_name = MagicMock() + mlflow_repository.log_model = AsyncMock() + path.exists.return_value = True + path.join.return_value = './tmp/artifacts/model_name' + + with pytest.raises(ValueError): + await mlflow_repository.create_new_experiment( + model_name, + data, + retrain_data, + 'latest_production_id', + metadata['metadata'], + 'sklearn', + 'pyfunc', + ) + + mlflow_repository.emit_metric.assert_called_once_with( + metric_object=metrics.MODEL_WRITE_ERROR_COUNT, tags=ANY + ) + mlflow_repository.observe_lag.assert_not_called() + + +@pytest.mark.asyncio +async def test_update_production_model_by_run_id(mlflow, mlflow_repository): + mlflow_repository.client.get_registered_model.return_value = MagicMock( + latest_versions=[ + MagicMock(version='1'), + MagicMock(version='2'), + MagicMock(version='3'), + ] + ) + output = await mlflow_repository.update_production_model_by_run_id( + '0', 'test', metadata['metadata'] + ) + + mlflow.register_model.assert_called_once_with( + 'runs:/0/prediction_model', + 'test', + ) + + mlflow_repository.client.get_registered_model.assert_called_once_with('test') + mlflow_repository.client.transition_model_version_stage.assert_called_once_with( + name='test', + version='3', + stage='Production', + archive_existing_versions=True, + ) + + assert output == { + 'model_name': 'test', + 'version': '3', + 'mlflow_run_id': '0', + } + + mlflow_repository.observe_lag.assert_has_calls( + [ + call(ANY, metrics.MODEL_WRITE_LAG, ANY), + call(ANY, metrics.MODEL_WRITE_LAG, ANY), + ] + ) + mlflow_repository.emit_metric.assert_has_calls( + [ + call(metric_object=metrics.MODEL_WRITE_COUNT, tags=ANY), + call(metric_object=metrics.MODEL_WRITE_COUNT, tags=ANY), + ] + ) + + +@pytest.mark.asyncio +async def test_update_production_model_by_run_id_error_register_model(mlflow, mlflow_repository): + mlflow.register_model.side_effect = ValueError('error') + with pytest.raises(ValueError): + await mlflow_repository.update_production_model_by_run_id('0', 'test', metadata['metadata']) + + mlflow_repository.emit_metric.assert_called_once_with( + metric_object=metrics.MODEL_WRITE_ERROR_COUNT, tags=ANY + ) + mlflow_repository.observe_lag.assert_not_called() + + +@pytest.mark.asyncio +async def test_update_production_model_by_run_id_error_transition_model_version_stage( + mlflow, mlflow_repository +): + mlflow_repository.client.transition_model_version_stage.side_effect = ValueError('error') + with pytest.raises(ValueError): + await mlflow_repository.update_production_model_by_run_id('0', 'test', metadata['metadata']) + + mlflow_repository.emit_metric.assert_called_once_with( + metric_object=metrics.MODEL_WRITE_ERROR_COUNT, tags=ANY + ) + mlflow_repository.observe_lag.assert_not_called() + + +@pytest.mark.asyncio +async def test_update_production_model_by_run_id_error(mlflow, mlflow_repository): + mlflow_repository.client.get_registered_model.return_value = MagicMock( + get_registered_model=MagicMock(return_value=MagicMock(latest_versions={})) + ) + + try: + await mlflow_repository.update_production_model_by_run_id('0', 'test', metadata['metadata']) + except Exception as e: + assert str(e) == 'Model versions is not a list' + else: + raise AssertionError('Expected Exception') + + +@pytest.mark.asyncio +async def test_transform_success(mlflow_repository): + data = MagicMock() + model_name = 'model' + model_config = { + 'retention_minutes': 60, + 'transform_flavor': 'sklearn', + 'predict_flavor': 'pyfunc', + } + + mlflow_repository.get_cached_operation = AsyncMock(return_value=data) + + mlflow_repository.detect_and_parse_datetime_index = MagicMock() + + output = await mlflow_repository.transform(model_name, data, model_config, metadata['metadata']) + + mlflow_repository.get_cached_operation.assert_called_once_with( + model_name=model_name, + data=data, + operation='transform', + retention=60, + flavor='sklearn', + metadata=metadata['metadata'], + ) + + mlflow_repository.detect_and_parse_datetime_index.assert_called_once_with( + mlflow_repository.get_cached_operation.return_value, metadata['metadata'] + ) + + assert output['success'] is True + assert output['content'] is mlflow_repository.detect_and_parse_datetime_index.return_value + + +@pytest.mark.asyncio +async def test_transform_error(mlflow_repository): + data = MagicMock() + model_name = 'model' + model_config = { + 'retention_minutes': 60, + 'transform_flavor': 'sklearn', + 'predict_flavor': 'pyfunc', + } + + mlflow_repository.get_cached_operation = AsyncMock(side_effect=Exception('error')) + + output = await mlflow_repository.transform(model_name, data, model_config, metadata['metadata']) + + mlflow_repository.get_cached_operation.assert_called_once_with( + model_name=model_name, + data=data, + operation='transform', + retention=60, + flavor='sklearn', + metadata=metadata['metadata'], + ) + + assert output == {'success': False, 'content': {'message': 'error', 'traceback': ANY}} + + +@pytest.mark.asyncio +async def test_predict_success_array(mlflow_repository): + data = DataFrame({'feat_1': {'index_1': 2, 'index_2': 3}}) + model_config = {'retention_minutes': 60, 'predict_flavor': 'pyfunc'} + model_name = 'model' + mlflow_repository.get_cached_operation = AsyncMock( + return_value=DataFrame({'prediction': {'index_1': 2, 'index_2': 3}}) + ) + + output = await mlflow_repository.predict(model_name, data, model_config, metadata['metadata']) + + mlflow_repository.get_cached_operation.assert_called_once_with( + model_name=model_name, + data=ANY, + operation='predict', + retention=60, + flavor='pyfunc', + metadata=metadata['metadata'], + ) + + assert output['success'] is True + content = output['content'] + assert isinstance(content, DataFrame) + assert 'prediction' in content.columns + assert 'response_time' in content.columns + assert list(content.columns) == ['prediction', 'response_time'] + assert content.index.tolist() == data.index.tolist() + + +@pytest.mark.asyncio +async def test_predict_success_df(mlflow_repository): + data = DataFrame({'feat_1': {'index_1': 2, 'index_2': 3}}) + model_config = {'retention_minutes': 60, 'predict_flavor': 'pyfunc'} + model_name = 'model' + + mlflow_repository.get_cached_operation = AsyncMock( + return_value=DataFrame({'feat_1': {'index_3': 2, 'index_4': 3}}) + ) + + output = await mlflow_repository.predict(model_name, data, model_config, metadata['metadata']) + + mlflow_repository.get_cached_operation.assert_called_once_with( + model_name=model_name, + data=ANY, + operation='predict', + retention=60, + flavor='pyfunc', + metadata=metadata['metadata'], + ) + + assert output['success'] is True + content = output['content'] + assert isinstance(content, DataFrame) + assert 'prediction' in content.columns + assert 'response_time' in content.columns + assert list(content.columns) == ['prediction', 'response_time'] + assert content.index.tolist() == data.index.tolist() + + +@pytest.mark.asyncio +async def test_predict_error(mlflow_repository): + data = DataFrame({'feat_1': {'index_1': 2, 'index_2': 3}}) + model_name = 'model' + model_config = {'retention_minutes': 60, 'predict_flavor': 'pyfunc'} + + mlflow_repository.get_cached_operation = AsyncMock(side_effect=Exception('error')) + + output = await mlflow_repository.predict(model_name, data, model_config, metadata['metadata']) + + mlflow_repository.get_cached_operation.assert_called_once_with( + model_name=model_name, + data=ANY, + operation='predict', + retention=60, + flavor='pyfunc', + metadata=metadata['metadata'], + ) + + assert output == {'success': False, 'content': {'message': 'error', 'traceback': ANY}} + + +@pytest.mark.asyncio +async def test_retrain_model(mlflow_repository): + data = MagicMock() + model_name = 'test' + model_config = {'target': 'target', 'transform_flavor': 'sklearn', 'predict_flavor': 'pyfunc'} + + mlflow_repository.get_model_run_id = MagicMock() + mlflow_repository.fit_models = AsyncMock() + mlflow_repository.create_new_experiment = AsyncMock() + + output = await mlflow_repository.retrain_model( + data, model_name, model_config, metadata['metadata'] + ) + + mlflow_repository.get_model_run_id.assert_called_once_with(model_name, stage='Production') + + mlflow_repository.fit_models.assert_called_once_with( + model_name=model_name, + data=data, + transform_flavor='sklearn', + skip_transform=False, + predict_flavor='pyfunc', + target_name='target', + metadata=metadata['metadata'], + latest_production_id=mlflow_repository.get_model_run_id.return_value, + ) + + mlflow_repository.create_new_experiment.assert_called_once_with( + model_name=model_name, + data=data, + retrain_data=mlflow_repository.fit_models.return_value, + transform_flavor='sklearn', + predict_flavor='pyfunc', + metadata=metadata['metadata'], + latest_production_id=mlflow_repository.get_model_run_id.return_value, + ) + + assert output == { + 'success': True, + 'experiment': mlflow_repository.create_new_experiment.return_value, + 'message': 'Model retrained successfully.', + } + + +@pytest.mark.asyncio +async def test_retrain_model_error(mlflow_repository): + data = MagicMock() + model_name = 'test' + model_config = {'target': 'target', 'transform_flavor': 'sklearn', 'predict_flavor': 'pyfunc'} + mlflow_repository.get_model_run_id = MagicMock(side_effect=Exception('error')) + output = await mlflow_repository.retrain_model( + data, model_name, model_config, metadata['metadata'] + ) + assert output == { + 'success': False, + 'experiment': None, + 'message': 'Error retraining model test: error', + 'traceback': ANY, + } + + +@pytest.mark.asyncio +async def test_update_production_model(mlflow_repository): + experiment = {'run_id': '0', 'experiment_id': '0'} + model_name = 'test' + mlflow_repository.update_production_model_by_run_id = AsyncMock() + mlflow_repository.update_production_model_by_run_id.return_value = { + 'model_name': 'test', + 'version': '3', + 'mlflow_run_id': '0', + } + + output = await mlflow_repository.update_production_model( + experiment, model_name, metadata['metadata'] + ) + + mlflow_repository.update_production_model_by_run_id.assert_called_once_with( + '0', 'test', metadata['metadata'] + ) + + assert output == { + 'model_name': 'test', + 'version': '3', + 'mlflow_run_id': '0', + 'mlflow_experiment_id': '0', + } + + +def test_get_prediction_data_dataframe(mlflow_repository): + prediction_model = MagicMock() + retrain_dataset = DataFrame({'feat_1': [1, 2], 'target': [3, 4]}, index=['idx1', 'idx2']) + prediction_model.predict.return_value = DataFrame({'pred': [5, 6]}, index=['idx1', 'idx2']) + target_name = 'target' + predict_flavor = 'sklearn' + + result = mlflow_repository.get_prediction_data( + prediction_model, retrain_dataset, target_name, predict_flavor + ) + + prediction_model.predict.assert_called_once_with(retrain_dataset) + assert 'prediction' in result.columns + assert 'target' in result.columns + assert 'timestamp' in result.columns + assert result.index.tolist() == [0, 1] + + +def test_get_prediction_data_array(mlflow_repository): + prediction_model = MagicMock() + retrain_dataset = DataFrame({'feat_1': [1, 2], 'target': [3, 4]}, index=['idx1', 'idx2']) + prediction_model.predict.return_value = [5, 6] + target_name = 'target' + predict_flavor = 'sklearn' + + result = mlflow_repository.get_prediction_data( + prediction_model, retrain_dataset, target_name, predict_flavor + ) + + prediction_model.predict.assert_called_once_with(retrain_dataset) + assert 'prediction' in result.columns + assert 'target' in result.columns + assert 'timestamp' in result.columns + assert result.index.tolist() == [0, 1] + + +def test_get_prediction_data_pyfunc(mlflow_repository): + prediction_model = MagicMock() + retrain_dataset = DataFrame({'feat_1': [1, 2], 'target': [3, 4]}, index=['idx1', 'idx2']) + prediction_model.predict.return_value = DataFrame({'pred': [5, 6]}, index=['idx1', 'idx2']) + target_name = 'target' + predict_flavor = 'pyfunc' + + result = mlflow_repository.get_prediction_data( + prediction_model, retrain_dataset, target_name, predict_flavor + ) + + prediction_model.predict.assert_called_once_with({}, retrain_dataset) + assert 'prediction' in result.columns + assert 'target' in result.columns + assert 'timestamp' in result.columns + assert result.index.tolist() == [0, 1] + + +@patch('laborious.utils.repository.model_repository.pd.merge') +@patch('laborious.utils.repository.model_repository.isinstance') +@pytest.mark.asyncio +async def test_fit_models_skip_transform(isinstance_mock, pd_merge, mlflow_repository): + isinstance_mock.return_value = True + + data_model = MagicMock(target_variable='feat_2') + prediction_model = MagicMock() + mlflow_repository.download_model = AsyncMock( + side_effect=[(data_model, 'artifact_path'), (prediction_model, 'artifact_path')], + ) + mlflow_repository.detect_and_parse_datetime_index = MagicMock( + return_value=MagicMock( + drop_duplicates=MagicMock(return_value=MagicMock(columns=['feat_1'])) + ) + ) + mlflow_repository.get_prediction_data = MagicMock(return_value=DataFrame()) + + data = MagicMock() + + output = await mlflow_repository.fit_models( + 'model_name', + data, + 'latest_production_id', + metadata['metadata'], + 'sklearn', + True, + 'pyfunc', + 'feat_1', + ) + + data_model.fit.assert_not_called() + + assert output['data_model'] == {'model': data_model, 'artifact_path': 'artifact_path'} + + +@patch('laborious.utils.repository.model_repository.force_memory_release') +@patch('laborious.utils.repository.model_repository.path') +@patch('laborious.utils.repository.model_repository.rmtree') +@pytest.mark.asyncio +async def test_create_new_experiment_path_not_exists( + _rmtree, path, force_memory_release, mlflow, mlflow_repository +): + model_name = 'model_name' + data = MagicMock() + prediction_data = MagicMock(spec=DataFrame) + retrain_data = { + 'prediction_model': {'model': MagicMock(), 'artifact_path': 'artifact_path'}, + 'data_model': {'model': MagicMock(), 'artifact_path': 'artifact_path'}, + 'prediction_data': prediction_data, + } + + mlflow_repository.get_model_params = MagicMock( + return_value={ + 'transform_flavor': 'sklearn', + 'predict_flavor': 'pyfunc', + 'target_name': 'target_name', + } + ) + + mlflow_repository.get_experiment = MagicMock() + mlflow_repository.get_next_run_name = MagicMock() + mlflow_repository.log_model = AsyncMock() + path.exists.return_value = False + path.join.return_value = './tmp/artifacts/model_name' + + await mlflow_repository.create_new_experiment( + model_name, + data, + retrain_data, + 'latest_production_id', + metadata['metadata'], + 'sklearn', + 'pyfunc', + ) + + _rmtree.assert_not_called() + + +@pytest.mark.asyncio +async def test_update_production_model_by_run_id_transition_error(mlflow, mlflow_repository): + mlflow_repository.client.get_registered_model.return_value = MagicMock( + latest_versions=[ + MagicMock(version='1'), + MagicMock(version='2'), + ] + ) + mlflow_repository.client.transition_model_version_stage.side_effect = Exception( + 'transition error' + ) + + with pytest.raises(Exception, match='transition error'): + await mlflow_repository.update_production_model_by_run_id('0', 'test', metadata['metadata']) + + mlflow_repository.emit_metric.assert_called_with( + metric_object=metrics.MODEL_WRITE_ERROR_COUNT, tags=ANY + ) + + +@pytest.mark.asyncio +async def test_predict_success_ndarray(mlflow_repository): + data = DataFrame({'feat_1': {'index_1': 2, 'index_2': 3}}) + model_config = {'retention_minutes': 60, 'predict_flavor': 'pyfunc'} + model_name = 'model' + mlflow_repository.get_cached_operation = AsyncMock(return_value=np.array([5.0, 6.0])) + + output = await mlflow_repository.predict(model_name, data, model_config, metadata['metadata']) + + assert output['success'] is True + content = output['content'] + assert isinstance(content, DataFrame) + assert 'prediction' in content.columns + assert 'response_time' in content.columns + assert content.index.tolist() == data.index.tolist() diff --git a/tests/laborious/utils/repository/test_opc_repository.py b/tests/laborious/utils/repository/test_opc_repository.py new file mode 100644 index 0000000..365a400 --- /dev/null +++ b/tests/laborious/utils/repository/test_opc_repository.py @@ -0,0 +1,610 @@ +import asyncio +import json +from datetime import datetime +from unittest.mock import ANY, AsyncMock, MagicMock, Mock, patch + +import pytest +from asyncua.crypto.security_policies import SecurityPolicyBasic256 +from asyncua.ua.uaerrors import BadNodeIdUnknown, BadSessionIdInvalid +from sientia_do.notifications.models import NotificationLevel + +from laborious.utils.repository.opc_repository import ( + OpcClientAlreadyExistsError, + OpcClientNotInitializedError, + OpcRepository, + OpcSessionAlreadyConnectedError, + is_reconnectable_opcua_bad, +) + + +@pytest.fixture +def mock_logger(): + return Mock() + + +@pytest.fixture +def opc_repository(mock_logger): + repository = OpcRepository( + opc_id='test_repo', + server_name='test_server', + url='opc.tcp://localhost:4840', + logger=mock_logger, + notification_handler=Mock(), + reconnection_interval=60, + server_uri='urn:test:server', + cert_path='/path/to/cert.pem', + private_key_path='/path/to/key.pem', + server_cert_path='/path/to/server_cert.pem', + metrics_controller=AsyncMock(), + ) + repository.disconnection_interval = 0.1 + repository.send_notification = MagicMock() + repository.send_notification_async = AsyncMock() + repository.emit_metric = AsyncMock() + repository.info = MagicMock() + repository.error = MagicMock() + repository.warning = MagicMock() + repository.debug = MagicMock() + repository._session_ready.set() + return repository + + +@pytest.fixture +def mock_client(): + with patch('laborious.utils.repository.opc_repository.Client') as mock: + client_instance = AsyncMock() + mock.return_value = client_instance + yield client_instance + + +metadata = { + 'metadata': { + 'model_id': 'test_model', + 'model_name': 'test_model', + 'workflow_name': 'test_workflow', + 'schema_name': 'test_schedule', + }, +} + + +def test_init(opc_repository): + assert opc_repository.id == 'test_repo' + assert opc_repository.server_name == 'test_server' + assert opc_repository.url == 'opc.tcp://localhost:4840' + assert opc_repository.server_uri == 'urn:test:server' + assert opc_repository.cert_path == '/path/to/cert.pem' + assert opc_repository.private_key_path == '/path/to/key.pem' + assert opc_repository.server_cert_path == '/path/to/server_cert.pem' + assert opc_repository.reconnection_interval == 60 + assert opc_repository.client is None + assert opc_repository.last_reconnection_time is None + + +@pytest.mark.asyncio +async def test_set_security(opc_repository, mock_client): + opc_repository.client = mock_client + await opc_repository.set_security() + + mock_client.application_uri = 'urn:test:server' + mock_client.set_security.assert_called_once_with( + SecurityPolicyBasic256, + certificate='/path/to/cert.pem', + private_key='/path/to/key.pem', + server_certificate='/path/to/server_cert.pem', + ) + assert mock_client.secure_channel_timeout == 600_000 + assert mock_client.session_timeout == 600_000 + + +@pytest.mark.asyncio +async def test_set_security_missing_certificates(opc_repository): + opc_repository.cert_path = None + opc_repository.private_key_path = None + + try: + await opc_repository.set_security() + except ValueError as e: + assert str(e) == 'Certificate and private key paths must be provided for secure connection.' + + +@pytest.mark.asyncio +async def test_set_security_missing_client(opc_repository): + opc_repository.client = None + try: + await opc_repository.set_security() + except ValueError as e: + assert str(e) == 'Client must be initialized before setting security' + + +@pytest.mark.asyncio +async def test_connect_with_security(opc_repository, mock_client): + opc_repository._create_client = AsyncMock() + opc_repository._open_session = AsyncMock(return_value=(True, {})) + result = await opc_repository.connect() + + opc_repository._create_client.assert_called_once() + opc_repository._open_session.assert_called_once() + assert result == (True, {}) + + +@pytest.mark.asyncio +async def test_connect_without_security(opc_repository, mock_client): + opc_repository.cert_path = None + opc_repository._create_client = AsyncMock() + opc_repository._open_session = AsyncMock(return_value=(True, {})) + opc_repository.set_security = AsyncMock() + result = await opc_repository.connect() + + opc_repository._create_client.assert_called_once() + opc_repository._open_session.assert_called_once() + opc_repository.set_security.assert_not_called() + assert result == (True, {}) + + +@pytest.mark.asyncio +async def test_connect_raises_when_session_already_open(opc_repository, mock_client): + opc_repository.client = mock_client + proto = MagicMock() + proto.state = 'open' + mock_client.uaclient = MagicMock(protocol=proto) + + with pytest.raises(OpcSessionAlreadyConnectedError, match='disconnect'): + await opc_repository.connect() + + +@pytest.mark.asyncio +async def test_create_client_raises_when_client_exists(opc_repository, mock_client): + opc_repository.client = mock_client + + with pytest.raises(OpcClientAlreadyExistsError, match='already exists'): + await opc_repository._create_client() + + +@pytest.mark.asyncio +async def test_open_session_success(opc_repository): + closed_proto = MagicMock() + closed_proto.state = 'closed' + opc_repository.client = AsyncMock() + opc_repository.client.uaclient = MagicMock(protocol=closed_proto) + opc_repository.client.session_timeout = 600_000 + opc_repository.client.secure_channel_timeout = 600_000 + + open_proto = MagicMock() + open_proto.state = 'open' + open_proto.authentication_token = 'tok' + + async def connect_side_effect(): + opc_repository.client.uaclient.protocol = open_proto + + opc_repository.client.connect = AsyncMock(side_effect=connect_side_effect) + + result = await opc_repository._open_session() + + opc_repository.client.connect.assert_called_once() + assert opc_repository.last_reconnection_time is None + assert result == (True, {}) + assert opc_repository._session_ready.is_set() + + +@pytest.mark.asyncio +async def test_open_session_raises_when_already_connected(opc_repository, mock_client): + opc_repository.client = mock_client + proto = MagicMock() + proto.state = 'open' + mock_client.uaclient = MagicMock(protocol=proto) + + with pytest.raises(OpcSessionAlreadyConnectedError, match='disconnect'): + await opc_repository._open_session() + + +@pytest.mark.asyncio +async def test_open_session_fail(opc_repository): + opc_repository._disconnect_locked = AsyncMock() + opc_repository.client = MagicMock() + opc_repository.client.uaclient = MagicMock(protocol=MagicMock(state='closed')) + opc_repository.client.connect = AsyncMock(side_effect=Exception('Test error')) + + is_connected, error_data = await opc_repository._open_session() + + opc_repository._disconnect_locked.assert_called_once() + opc_repository.client.connect.assert_called_once() + assert is_connected is False + assert error_data['notification_id'] == f'OPC_CONNECTION_ERROR_{opc_repository.id}' + assert error_data['message'] == 'Failed to connect to OPC server: Test error' + assert error_data['block'] == 'opc_repository' + assert error_data['level'] == NotificationLevel.ERROR + assert error_data['attachment_content'] is not None + + +@pytest.mark.asyncio +async def test_open_session_raises_when_no_client(opc_repository): + opc_repository.client = None + + with pytest.raises(OpcClientNotInitializedError, match='not initialized'): + await opc_repository._open_session() + + +@pytest.mark.asyncio +async def test_disconnection_fallback_success(opc_repository, mock_client): + opc_repository.client = mock_client + mock_client.disconnect.return_value = True + result = await opc_repository._disconnection_fallback() + + mock_client.disconnect.assert_called_once() + assert result == [] + + +@pytest.mark.asyncio +async def test_disconnection_fallback_fail(opc_repository, mock_client): + opc_repository.client = mock_client + mock_client.disconnect.side_effect = Exception('Test error') + result = await opc_repository._disconnection_fallback() + assert result == [ + {'attempt': 1, 'error': 'Test error', 'traceback': ANY}, + {'attempt': 2, 'error': 'Test error', 'traceback': ANY}, + {'attempt': 3, 'error': 'Test error', 'traceback': ANY}, + {'attempt': 4, 'error': 'Test error', 'traceback': ANY}, + {'attempt': 5, 'error': 'Test error', 'traceback': ANY}, + ] + assert mock_client.disconnect.call_count == 5 + + +@pytest.mark.asyncio +async def test_disconnect(opc_repository, mock_client): + opc_repository.client = mock_client + opc_repository._disconnection_fallback = AsyncMock(return_value=[]) + await opc_repository.disconnect() + + opc_repository._disconnection_fallback.assert_called_once() + assert opc_repository.client is None + assert opc_repository._allow_reconnect is False + + +@pytest.mark.asyncio +async def test_disconnect_no_client(opc_repository): + opc_repository.client = None + assert await opc_repository.disconnect() is None + + +@pytest.mark.asyncio +async def test_disconnect_error(opc_repository, mock_client): + opc_repository.client = mock_client + opc_repository._disconnection_fallback = AsyncMock( + return_value=[{'attempt': 1, 'error': 'Test error', 'traceback': 'text'}] + ) + await opc_repository.disconnect() + + opc_repository._disconnection_fallback.assert_called_once() + opc_repository.send_notification_async.assert_called_once_with( + metadata=opc_repository.metadata, + notification_id=f'OPC_DISCONNECTION_ERROR_{opc_repository.id}', + message='Failed to disconnect from OPC server in 5 attempts.', + block='opc_repository', + level=NotificationLevel.ERROR, + attachment_content=json.dumps( + [{'attempt': 1, 'error': 'Test error', 'traceback': 'text'}], indent=4 + ), + ) + assert opc_repository.client is None + + +@pytest.mark.asyncio +async def test_validate_connection_none_client(opc_repository): + opc_repository.client = None + response = await opc_repository.validate_connection() + assert response == (False, opc_repository._not_connected_error()) + + +@pytest.mark.asyncio +async def test_validate_connection_session_not_open(opc_repository): + opc_repository.client = MagicMock() + opc_repository.client.uaclient.protocol = None + + response = await opc_repository.validate_connection() + + assert response == (False, opc_repository._not_connected_error()) + opc_repository.error.assert_called_once() + + +@pytest.mark.asyncio +async def test_validate_connection_success(opc_repository): + opc_repository.client = MagicMock() + opc_repository.client.uaclient.protocol = MagicMock() + opc_repository.client.uaclient.protocol.state = 'open' + + output = await opc_repository.validate_connection() + assert output == (True, {}) + + +@pytest.mark.asyncio +async def test_write_data_validate_connection_do_nothing(opc_repository): + opc_repository.validate_connection = AsyncMock(return_value=(True, {})) + opc_repository.client = AsyncMock(get_node=MagicMock()) + mock_node = AsyncMock() + opc_repository.client.get_node.return_value = mock_node + + result = await opc_repository.write_data('ns=2;s=TestNode', 42.0, 'float', metadata['metadata']) + + opc_repository.validate_connection.assert_called_once() + opc_repository.client.get_node.assert_called_once_with('ns=2;s=TestNode') + assert result == (True, {'response_time': ANY}) + + +@pytest.mark.asyncio +async def test_write_data_validate_connection_failed(opc_repository): + opc_repository.client = MagicMock() + opc_repository.client.uaclient.protocol = MagicMock(state='closed') + opc_repository._start_reconnect = AsyncMock() + + is_success, error_data = await opc_repository.write_data( + 'ns=2;s=TestNode', 42.0, 'float', metadata['metadata'] + ) + + opc_repository._start_reconnect.assert_called_once() + assert opc_repository._start_reconnect.call_args.args[0] == 'ProtocolClosed' + assert is_success is False + assert error_data['opc_error_kind'] == 'connection_lost' + assert error_data['opc_status'] == 'ProtocolClosed' + + +@pytest.mark.asyncio +async def test_write_data_get_node_failed(opc_repository): + opc_repository.validate_connection = AsyncMock(return_value=(True, {})) + opc_repository.client = AsyncMock() + opc_repository.client.get_node = MagicMock(side_effect=Exception('Test error')) + + is_success, error_data = await opc_repository.write_data( + 'ns=2;s=TestNode', 42.0, 'float', metadata['metadata'] + ) + + opc_repository.validate_connection.assert_called_once() + opc_repository.client.get_node.assert_called_once_with('ns=2;s=TestNode') + assert is_success is False + assert error_data['notification_id'] == f'OPC_WRITE_GET_NODE_ERROR_{opc_repository.id}' + assert ( + error_data['message'] + == "Failed to get node from OPC server: Test error | metadata: {'model_id': 'test_model', 'model_name': 'test_model', 'workflow_name': 'test_workflow', 'schema_name': 'test_schedule'}" + ) + assert error_data['block'] == 'opc_repository' + assert error_data['level'] == NotificationLevel.ERROR + assert error_data['attachment_content'] is not None + + +@pytest.mark.asyncio +async def test_write_data_invalid_data_type(opc_repository, mock_client): + opc_repository.validate_connection = AsyncMock(return_value=(True, {})) + opc_repository.client = mock_client + mock_node = AsyncMock() + mock_client.get_node = MagicMock(return_value=mock_node) + + is_success, error_data = await opc_repository.write_data( + 'ns=2;s=TestNode', 42.0, 'invalid_type', metadata['metadata'] + ) + + opc_repository.validate_connection.assert_called_once() + mock_client.get_node.assert_called_once_with('ns=2;s=TestNode') + + assert is_success is False + assert error_data['notification_id'] == f'OPC_WRITE_DATA_TYPE_ERROR_{opc_repository.id}' + assert ( + error_data['message'] + == "Unsupported data type: invalid_type | metadata: {'model_id': 'test_model', 'model_name': 'test_model', 'workflow_name': 'test_workflow', 'schema_name': 'test_schedule'}" + ) + assert error_data['block'] == 'opc_repository' + assert error_data['level'] == NotificationLevel.ERROR + assert error_data.get('attachment_content') is None + + +@pytest.mark.asyncio +async def test_write_data(opc_repository, mock_client): + opc_repository.validate_connection = AsyncMock(return_value=(True, {})) + opc_repository.client = mock_client + mock_node = AsyncMock() + mock_client.get_node = MagicMock(return_value=mock_node) + + result = await opc_repository.write_data('ns=2;s=TestNode', 42.0, 'float', metadata['metadata']) + + mock_client.get_node.assert_called_once_with('ns=2;s=TestNode') + mock_node.write_value.assert_called_once() + assert result == (True, {'response_time': ANY}) + + +@pytest.mark.asyncio +async def test_write_data_write_value_failed(opc_repository, mock_client): + opc_repository.validate_connection = AsyncMock(return_value=(True, {})) + opc_repository.client = mock_client + mock_node = AsyncMock() + mock_client.get_node = MagicMock(return_value=mock_node) + mock_node.write_value.side_effect = Exception('Test error') + + is_success, error_data = await opc_repository.write_data( + 'ns=2;s=TestNode', 42.0, 'float', metadata['metadata'] + ) + + opc_repository.validate_connection.assert_called_once() + mock_client.get_node.assert_called_once_with('ns=2;s=TestNode') + mock_node.write_value.assert_called_once() + assert is_success is False + assert error_data['notification_id'] == f'OPC_WRITE_DATA_ERROR_{opc_repository.id}' + assert ( + error_data['message'] + == "Failed to write data to OPC server: Test error | metadata: {'model_id': 'test_model', 'model_name': 'test_model', 'workflow_name': 'test_workflow', 'schema_name': 'test_schedule'}" + ) + assert error_data['block'] == 'opc_repository' + assert error_data['level'] == NotificationLevel.ERROR + assert error_data['attachment_content'] is not None + + +def test_is_reconnectable_opcua_bad(): + assert is_reconnectable_opcua_bad(BadSessionIdInvalid()) is True + assert is_reconnectable_opcua_bad(BadNodeIdUnknown()) is False + assert is_reconnectable_opcua_bad(Exception('other')) is False + + +@pytest.mark.asyncio +async def test_write_data_bad_session_id_invalid_schedules_reconnect(opc_repository, mock_client): + opc_repository.validate_connection = AsyncMock(return_value=(True, {})) + opc_repository.client = mock_client + opc_repository._start_reconnect = AsyncMock() + mock_node = AsyncMock() + mock_client.get_node = MagicMock(return_value=mock_node) + mock_node.write_value.side_effect = BadSessionIdInvalid() + + is_success, error_data = await opc_repository.write_data( + 'ns=2;s=TestNode', 42.0, 'float', metadata['metadata'] + ) + + mock_node.write_value.assert_called_once() + opc_repository._start_reconnect.assert_called_once() + assert is_success is False + assert error_data['opc_error_kind'] == 'session_bad' + assert error_data['opc_status'] == 'BadSessionIdInvalid' + + +@pytest.mark.asyncio +async def test_write_data_reconnect_in_progress_immediate(opc_repository): + opc_repository._session_ready.clear() + opc_repository._reconnect_task = asyncio.create_task(asyncio.sleep(60)) + opc_repository.validate_connection = AsyncMock() + + is_success, error_data = await opc_repository.write_data( + 'ns=2;s=TestNode', 42.0, 'float', metadata['metadata'] + ) + + opc_repository._reconnect_task.cancel() + with pytest.raises(asyncio.CancelledError): + await opc_repository._reconnect_task + opc_repository._reconnect_task = None + + opc_repository.validate_connection.assert_not_called() + assert is_success is False + assert error_data['opc_error_kind'] == 'reconnect_in_progress' + + +@pytest.mark.asyncio +async def test_start_reconnect_skips_within_interval(opc_repository): + opc_repository.last_reconnection_time = datetime.now() + opc_repository.reconnection_interval = 3600 + + await opc_repository._start_reconnect('BadSessionIdInvalid', 'tok') + + assert opc_repository._reconnect_task is None + + +@pytest.mark.asyncio +async def test_write_data_protocol_closed_schedules_reconnect(opc_repository): + opc_repository.client = MagicMock() + opc_repository.client.uaclient.protocol = MagicMock(state='closed') + opc_repository._start_reconnect = AsyncMock() + + is_success, error_data = await opc_repository.write_data( + 'ns=2;s=TestNode', 42.0, 'float', metadata['metadata'] + ) + + opc_repository._start_reconnect.assert_called_once() + assert opc_repository._start_reconnect.call_args.args[0] == 'ProtocolClosed' + assert is_success is False + assert error_data['opc_error_kind'] == 'connection_lost' + assert error_data['opc_status'] == 'ProtocolClosed' + + +@pytest.mark.asyncio +async def test_write_data_protocol_closed_skips_reconnect_within_interval(opc_repository): + opc_repository.client = MagicMock() + opc_repository.client.uaclient.protocol = MagicMock(state='closed') + opc_repository.last_reconnection_time = datetime.now() + opc_repository.reconnection_interval = 3600 + + is_success, error_data = await opc_repository.write_data( + 'ns=2;s=TestNode', 42.0, 'float', metadata['metadata'] + ) + + assert opc_repository._reconnect_task is None + assert is_success is False + assert error_data['opc_error_kind'] == 'connection_lost' + + +@pytest.mark.asyncio +async def test_write_data_after_failed_reconnect_schedules_again(opc_repository): + opc_repository._session_ready.clear() + opc_repository.reconnection_interval = 0 + opc_repository.last_reconnection_time = None + opc_repository._reconnect_locked = AsyncMock( + return_value=(False, {'message': 'connect failed'}) + ) + + await opc_repository.write_data('ns=2;s=TestNode', 42.0, 'float', metadata['metadata']) + await asyncio.sleep(0.1) + assert opc_repository._reconnect_locked.call_count == 1 + assert not opc_repository._reconnect_task_in_progress() + + await opc_repository.write_data('ns=2;s=TestNode', 42.0, 'float', metadata['metadata']) + await asyncio.sleep(0.1) + assert opc_repository._reconnect_locked.call_count == 2 + + +@pytest.mark.asyncio +async def test_write_data_after_disconnect_does_not_schedule_reconnect(opc_repository, mock_client): + opc_repository.client = mock_client + proto = MagicMock() + proto.state = 'closed' + mock_client.uaclient = MagicMock(protocol=proto) + opc_repository._disconnection_fallback = AsyncMock(return_value=[]) + await opc_repository.disconnect() + + is_success, error_data = await opc_repository.write_data( + 'ns=2;s=TestNode', 42.0, 'float', metadata['metadata'] + ) + + assert opc_repository._reconnect_task is None + assert is_success is False + assert error_data['opc_error_kind'] == 'connection_lost' + + +@pytest.mark.asyncio +async def test_parallel_bad_writes_single_reconnect_task(opc_repository, mock_client): + opc_repository.validate_connection = AsyncMock(return_value=(True, {})) + opc_repository.client = mock_client + opc_repository.reconnection_interval = 0 + opc_repository.last_reconnection_time = None + mock_node = AsyncMock() + mock_client.get_node = MagicMock(return_value=mock_node) + mock_node.write_value.side_effect = BadSessionIdInvalid() + + connect_count = 0 + + async def slow_reconnect(): + nonlocal connect_count + connect_count += 1 + await asyncio.sleep(0.05) + opc_repository._session_ready.set() + return True, {} + + opc_repository._reconnect_locked = slow_reconnect + + results = await asyncio.gather( + opc_repository.write_data('ns=2;s=TestNode', 1.0, 'float', metadata['metadata']), + opc_repository.write_data('ns=2;s=TestNode2', 2.0, 'float', metadata['metadata']), + ) + await asyncio.sleep(0.15) + + assert connect_count <= 1 + assert 1 <= mock_node.write_value.call_count <= 2 + error_kinds = [r[1].get('opc_error_kind') for r in results] + assert error_kinds.count('session_bad') >= 1 + assert all(k in ('session_bad', 'reconnect_in_progress') for k in error_kinds) + + +@pytest.mark.asyncio +@patch('laborious.utils.repository.opc_repository.datetime') +async def test_reconnect_locked_sets_last_reconnection_time(mock_datetime, opc_repository): + mock_datetime.now = MagicMock(return_value=datetime(2025, 1, 1, 12, 0, 0)) + opc_repository._disconnect_locked = AsyncMock() + opc_repository._connect_locked = AsyncMock(return_value=(True, {})) + + result = await opc_repository._reconnect_locked() + + opc_repository._disconnect_locked.assert_called_once() + opc_repository._connect_locked.assert_called_once() + assert result == (True, {}) + assert opc_repository.last_reconnection_time == datetime(2025, 1, 1, 12, 0, 0) diff --git a/tests/laborious/utils/test_connectors_config.py b/tests/laborious/utils/test_connectors_config.py new file mode 100644 index 0000000..b94bb8d --- /dev/null +++ b/tests/laborious/utils/test_connectors_config.py @@ -0,0 +1,122 @@ +from os import environ + +from laborious.utils.connectors_config import ( + build_minio_config, + build_mlflow_config, + build_opc_config, +) + + +def test_build_mlflow_config_with_env_vars(): + # Arrange + environ['MLFLOW_HOST'] = 'http://test-host' + environ['MLFLOW_PORT'] = '8080' + environ['MLFLOW_USERNAME'] = 'test-user' + environ['MLFLOW_PASSWORD'] = 'test-pass' + + # Act + config = build_mlflow_config() + + # Assert + assert config['host'] == 'http://test-host' + assert config['port'] == 8080 + assert config['username'] == 'test-user' + assert config['password'] == 'test-pass' + + +def test_build_mlflow_config_with_defaults(): + # Arrange + # Clear any existing env vars + environ.pop('MLFLOW_HOST', None) + environ.pop('MLFLOW_PORT', None) + environ.pop('MLFLOW_USERNAME', None) + environ.pop('MLFLOW_PASSWORD', None) + + # Act + config = build_mlflow_config() + + # Assert + assert config['host'] == 'http://localhost' + assert config['port'] == 5080 + assert config['username'] == 'aignosi' + assert config['password'] == 'aignosi' + + +def test_build_opc_config_with_env_vars(): + # Arrange + environ['OPC_CONFIG'] = '{"opc": {"name": "test-opc", "url": "opc.tcp://test:4840"}}' + + # Act + config = build_opc_config() + + # Assert + assert config['opc']['name'] == 'test-opc' + assert config['opc']['url'] == 'opc.tcp://test:4840' + + +def test_build_opc_config_with_individual_env_vars(): + # Arrange + environ.pop('OPC_CONFIG', None) + environ['OPC_ID'] = '1' + environ['OPC_URL'] = 'opc.tcp://test:4840' + environ['OPC_SERVER_URI'] = 'opc.tcp://test:4840' + environ['OPC_RECONNECTION_INTERVAL'] = '300' + + # Act + config = build_opc_config() + + # Assert + assert config['1']['id'] == '1' + assert config['1']['url'] == 'opc.tcp://test:4840' + assert config['1']['server_uri'] == 'opc.tcp://test:4840' + assert config['1']['reconnection_interval'] == 300 + + +def test_build_opc_config_with_defaults(): + # Arrange + environ.pop('OPC_CONFIG', None) + environ.pop('OPC_ID', None) + environ.pop('OPC_URL', None) + environ.pop('OPC_SERVER_URI', None) + environ.pop('OPC_RECONNECTION_INTERVAL', None) + + # Act + config = build_opc_config() + + # Assert + assert config['1']['id'] == '1' + assert config['1']['url'] == 'opc.tcp://localhost:4840' + assert config['1']['server_uri'] == 'opc.tcp://localhost:4840' + assert config['1']['reconnection_interval'] == 120 + + +def test_build_minio_config_with_env_vars(): + environ['MINIO_ENDPOINT_URL'] = 'http://test-host' + environ['MINIO_ACCESS_KEY'] = 'test-key' + environ['MINIO_SECRET_KEY'] = 'test-secret' + environ['MINIO_REGION_NAME'] = 'test-region' + environ['MINIO_DEFAULT_BUCKET'] = 'test-bucket' + assert build_minio_config() == { + 'endpoint_url': 'http://test-host', + 'access_key': 'test-key', + 'secret_key': 'test-secret', + 'default_bucket': 'test-bucket', + 'retention_hours': 24, + 'secure': False, + } + + +def test_build_minio_config_with_defaults(): + environ.pop('MINIO_ENDPOINT_URL', None) + environ.pop('MINIO_ACCESS_KEY', None) + environ.pop('MINIO_SECRET_KEY', None) + environ.pop('MINIO_REGION_NAME', None) + environ.pop('MINIO_DEFAULT_BUCKET', None) + assert build_minio_config() == { + 'endpoint_url': 'http://localhost:9000', + 'access_key': 'minioadmin', + 'secret_key': 'minioadmin', + 'default_bucket': 'laborious', + 'retention_hours': 24, + 'secure': False, + } diff --git a/tests/laborious/worker/test_runtime_task_queues.py b/tests/laborious/worker/test_runtime_task_queues.py new file mode 100644 index 0000000..8b40d4a --- /dev/null +++ b/tests/laborious/worker/test_runtime_task_queues.py @@ -0,0 +1,14 @@ +from sientia_do.temporal.worker.prepare_worker import build_queue_name + +from laborious.workflows.drift import Drift +from laborious.workflows.minimal_retrain import MinimalRetrain +from laborious.workflows.predictions_batch import PredictionsBatch +from laborious.workflows.simple_metrics import SimpleMetrics + + +def test_runtime_scoped_queue_names(): + runtime = 'prod-a' + assert build_queue_name(PredictionsBatch.__name__, runtime) == 'predictions_batch-prod-a-queue' + assert build_queue_name(MinimalRetrain.__name__, runtime) == 'minimal_retrain-prod-a-queue' + assert build_queue_name(Drift.__name__, runtime) == 'drift-prod-a-queue' + assert build_queue_name(SimpleMetrics.__name__, runtime) == 'simple_metrics-prod-a-queue' diff --git a/tests/laborious/workflows/subworkflows/test_format_and_export_prediction.py b/tests/laborious/workflows/subworkflows/test_format_and_export_prediction.py new file mode 100644 index 0000000..4fbc62b --- /dev/null +++ b/tests/laborious/workflows/subworkflows/test_format_and_export_prediction.py @@ -0,0 +1,679 @@ +from unittest.mock import ANY, AsyncMock, MagicMock, call, patch + +from pytest import fixture, mark +from sientia_do.temporal.constants import DATETIME_FORMAT_WITH_TZ + +from laborious.activities.activities import Activities +from laborious.workflows.sub_workflows.format_and_export_prediction import FormatAndExportPrediction + + +@fixture +def format_and_export_prediction(): + return FormatAndExportPrediction() + + +metadata = { + 'metadata': { + 'model_id': 'test_model', + 'model_name': 'test_model', + 'workflow_name': 'test_workflow', + 'schema_name': 'test_schedule', + }, +} + + +@mark.asyncio +@patch( + 'laborious.workflows.sub_workflows.format_and_export_prediction.workflow', + new_callable=AsyncMock, +) +async def test_run_none_path_flag(workflow_mock, format_and_export_prediction): + input_data = { + 'metadata': metadata, + 'path_flag': None, + 'data': {'test': 'data'}, + 'timestamp': '2021-01-01', + 'model_id': 1, + 'model_name': metadata['metadata']['model_name'], + 'prediction_confidence': 0, + 'schema': 'test_schema', + 'table_name': 'test_table', + 'opc_servers': ['test_server'], + 'opc_output_config': {'test': 'config'}, + 'prediction_store_policy': 'erl:1', + } + + prediction_data = MagicMock() + opc_metrics = MagicMock() + + workflow_mock.execute_activity_method.side_effect = [ + (prediction_data, opc_metrics), + MagicMock(), + MagicMock(), + ] + + await format_and_export_prediction.run(input_data) + + workflow_mock.execute_local_activity_method.assert_has_calls( + [ + call( + Activities.format_prediction, + { + **metadata, + 'data': input_data['data'], + 'timestamp': input_data['timestamp'], + 'model_id': input_data['model_id'], + 'prediction_confidence': input_data['prediction_confidence'], + 'prediction_store_policy': input_data['prediction_store_policy'], + 'model_name': input_data['model_name'], + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ) + ] + ) + + workflow_mock.execute_activity_method.assert_has_calls( + [ + call( + Activities.write_opc_data, + { + 'opc_output_config': input_data['opc_output_config'], + 'data': workflow_mock.execute_local_activity_method.return_value, + **metadata, + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ) + ] + ) + + workflow_mock.execute_activity_method.assert_has_calls( + [ + call( + Activities.export_data_to_postgres, + { + **metadata, + 'schema': input_data['schema'], + 'table_name': input_data['table_name'], + 'data': prediction_data, + 'timestamp_conversion': { + 'column': 'timestamp', + 'format': DATETIME_FORMAT_WITH_TZ, + }, + 'on_conflict': 'error', + 'unique_columns': ['model_id', 'timestamp'], + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ) + ] + ) + + workflow_mock.execute_activity_method.assert_has_calls( + [ + call( + Activities.write_metrics, + { + **metadata, + 'prediction': prediction_data, + 'opc_metrics': opc_metrics, + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ) + ] + ) + + assert workflow_mock.execute_activity_method.call_count == 3 + assert workflow_mock.execute_local_activity_method.call_count == 1 + + +@mark.asyncio +@patch( + 'laborious.workflows.sub_workflows.format_and_export_prediction.workflow', + new_callable=AsyncMock, +) +async def test_run_none_path_flag_with_transformed_data( + workflow_mock, format_and_export_prediction +): + # Arrange + input_data = { + 'metadata': metadata, + 'path_flag': None, + 'data': {'test': 'data'}, + 'transformed_data': {'transformed': 'data'}, + 'timestamp': '2021-01-01', + 'model_id': 1, + 'model_name': metadata['metadata']['model_name'], + 'prediction_confidence': 0.9, + 'schema': 'test_schema', + 'table_name': 'test_table', + 'transform_table_name': 'test_transform_table', + 'opc_servers': ['test_server'], + 'opc_output_config': {'test': 'config'}, + 'prediction_store_policy': 'lts:1', + } + + prediction_data = MagicMock() + opc_metrics = MagicMock() + transformed_data = MagicMock() + + workflow_mock.execute_local_activity_method.side_effect = [ + prediction_data, # format_prediction + transformed_data, # format_transformed_data + ] + + write_transformed_handler = AsyncMock() + workflow_mock.start_activity_method.return_value = write_transformed_handler + workflow_mock.execute_activity_method.side_effect = [ + (prediction_data, opc_metrics), # write_opc_data + MagicMock(), # export_data_to_postgres (prediction) + MagicMock(), # write_metrics + ] + + # Act + await format_and_export_prediction.run(input_data) + + # Assert - format_prediction call + workflow_mock.execute_local_activity_method.assert_has_calls( + [ + call( + Activities.format_prediction, + { + **metadata, + 'data': input_data['data'], + 'timestamp': input_data['timestamp'], + 'model_id': input_data['model_id'], + 'prediction_confidence': input_data['prediction_confidence'], + 'prediction_store_policy': input_data['prediction_store_policy'], + 'model_name': input_data['model_name'], + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ), + call( + Activities.format_transformed_data, + { + **metadata, + 'data': input_data['transformed_data'], + 'model_id': input_data['model_id'], + 'model_name': input_data['model_name'], + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ), + ] + ) + + # Assert - start_activity_method for transformed data export + workflow_mock.start_activity_method.assert_called_once_with( + Activities.export_payload_to_postgres, + { + **metadata, + 'schema': input_data['schema'], + 'table_name': input_data['transform_table_name'], + 'data': transformed_data, + 'timestamp_conversion': { + 'column': 'timestamp', + 'format': DATETIME_FORMAT_WITH_TZ, + }, + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ) + + # Assert - write_opc_data call + workflow_mock.execute_activity_method.assert_has_calls( + [ + call( + Activities.write_opc_data, + { + 'opc_output_config': input_data['opc_output_config'], + 'data': prediction_data, + **metadata, + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ) + ] + ) + + # Assert - export_data_to_postgres for prediction call + workflow_mock.execute_activity_method.assert_has_calls( + [ + call( + Activities.export_data_to_postgres, + { + **metadata, + 'schema': input_data['schema'], + 'table_name': input_data['table_name'], + 'data': prediction_data, + 'timestamp_conversion': { + 'column': 'timestamp', + 'format': DATETIME_FORMAT_WITH_TZ, + }, + 'on_conflict': 'error', + 'unique_columns': ['model_id', 'timestamp'], + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ) + ] + ) + + # Assert - write_metrics call + workflow_mock.execute_activity_method.assert_has_calls( + [ + call( + Activities.write_metrics, + { + **metadata, + 'prediction': prediction_data, + 'opc_metrics': opc_metrics, + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ) + ] + ) + + # Assert - verify counts + assert workflow_mock.execute_activity_method.call_count == 3 + assert workflow_mock.execute_local_activity_method.call_count == 2 + assert workflow_mock.start_activity_method.call_count == 1 + + +@mark.asyncio +@patch( + 'laborious.workflows.sub_workflows.format_and_export_prediction.workflow', + new_callable=AsyncMock, +) +async def test_run_default_path_flag(workflow_mock, format_and_export_prediction): + input_data = { + 'metadata': metadata, + 'path_flag': 'default', + 'data': {'test': 'data'}, + 'timestamp': '2021-01-01', + 'model_id': 1, + 'model_name': metadata['metadata']['model_name'], + 'prediction_confidence': 0, + 'schema': 'test_schema', + 'table_name': 'test_table', + 'opc_servers': ['test_server'], + 'opc_output_config': {'test': 'config'}, + 'comment': 'test_comment', + } + + prediction_data = MagicMock() + opc_metrics = MagicMock() + + workflow_mock.execute_activity_method.side_effect = [ + (prediction_data, opc_metrics), + MagicMock(), + MagicMock(), + ] + + await format_and_export_prediction.run(input_data) + + workflow_mock.execute_local_activity_method.assert_has_calls( + [ + call( + Activities.format_default_prediction, + { + **metadata, + 'timestamp': input_data['timestamp'], + 'model_id': input_data['model_id'], + 'prediction_confidence': input_data['prediction_confidence'], + 'comment': input_data['comment'], + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ) + ] + ) + + workflow_mock.execute_activity_method.assert_has_calls( + [ + call( + Activities.write_opc_data, + { + 'opc_output_config': input_data['opc_output_config'], + 'data': workflow_mock.execute_local_activity_method.return_value, + **metadata, + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ) + ] + ) + + workflow_mock.execute_activity_method.assert_has_calls( + [ + call( + Activities.export_data_to_postgres, + { + **metadata, + 'schema': input_data['schema'], + 'table_name': input_data['table_name'], + 'data': prediction_data, + 'timestamp_conversion': { + 'column': 'timestamp', + 'format': DATETIME_FORMAT_WITH_TZ, + }, + 'on_conflict': 'error', + 'unique_columns': ['model_id', 'timestamp'], + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ) + ] + ) + + workflow_mock.execute_activity_method.assert_has_calls( + [ + call( + Activities.write_metrics, + { + **metadata, + 'prediction': prediction_data, + 'opc_metrics': opc_metrics, + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ) + ] + ) + + assert workflow_mock.execute_activity_method.call_count == 3 + assert workflow_mock.execute_local_activity_method.call_count == 1 + + +@mark.asyncio +@patch( + 'laborious.workflows.sub_workflows.format_and_export_prediction.workflow', + new_callable=AsyncMock, +) +async def test_run_none_path_flag_with_pi_web_api(workflow_mock, format_and_export_prediction): + input_data = { + 'metadata': metadata, + 'path_flag': None, + 'data': {'test': 'data'}, + 'timestamp': '2021-01-01', + 'model_id': 1, + 'model_name': metadata['metadata']['model_name'], + 'prediction_confidence': 0, + 'schema': 'test_schema', + 'table_name': 'test_table', + 'pi_web_api_output_config': { + 'endpoint': 'https://test-pi-server.com', + 'prediction_tags': {}, + 'confidence_tags': {}, + }, + 'prediction_store_policy': 'erl:1', + } + + pi_web_api_data = MagicMock() + + workflow_mock.execute_activity_method.side_effect = [ + pi_web_api_data, # write_pi_web_api_data + MagicMock(), # export_data_to_postgres + MagicMock(), # write_metrics + ] + + await format_and_export_prediction.run(input_data) + + workflow_mock.execute_local_activity_method.assert_has_calls( + [ + call( + Activities.format_prediction, + { + **metadata, + 'data': input_data['data'], + 'timestamp': input_data['timestamp'], + 'model_id': input_data['model_id'], + 'prediction_confidence': input_data['prediction_confidence'], + 'prediction_store_policy': input_data['prediction_store_policy'], + 'model_name': input_data['model_name'], + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ) + ] + ) + + workflow_mock.execute_activity_method.assert_has_calls( + [ + call( + Activities.write_pi_web_api_data, + { + 'pi_web_api_output_config': input_data['pi_web_api_output_config'], + 'data': workflow_mock.execute_local_activity_method.return_value, + **metadata, + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ) + ] + ) + + workflow_mock.execute_activity_method.assert_has_calls( + [ + call( + Activities.export_data_to_postgres, + { + **metadata, + 'schema': input_data['schema'], + 'table_name': input_data['table_name'], + 'data': pi_web_api_data, + 'timestamp_conversion': { + 'column': 'timestamp', + 'format': DATETIME_FORMAT_WITH_TZ, + }, + 'on_conflict': 'error', + 'unique_columns': ['model_id', 'timestamp'], + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ) + ] + ) + + workflow_mock.execute_activity_method.assert_has_calls( + [ + call( + Activities.write_metrics, + { + **metadata, + 'prediction': pi_web_api_data, + 'opc_metrics': {}, + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ) + ] + ) + + assert workflow_mock.execute_activity_method.call_count == 3 + assert workflow_mock.execute_local_activity_method.call_count == 1 + + +@mark.asyncio +@patch( + 'laborious.workflows.sub_workflows.format_and_export_prediction.workflow', + new_callable=AsyncMock, +) +async def test_run_none_path_flag_with_pi_web_api_and_opc( + workflow_mock, format_and_export_prediction +): + input_data = { + 'metadata': metadata, + 'path_flag': None, + 'data': {'test': 'data'}, + 'timestamp': '2021-01-01', + 'model_id': 1, + 'model_name': metadata['metadata']['model_name'], + 'prediction_confidence': 0, + 'schema': 'test_schema', + 'table_name': 'test_table', + 'opc_output_config': {'test': 'config'}, + 'pi_web_api_output_config': { + 'endpoint': 'https://test-pi-server.com', + 'prediction_tags': {}, + 'confidence_tags': {}, + }, + 'prediction_store_policy': 'erl:1', + } + + prediction_data = MagicMock() + pi_web_api_data = MagicMock() + opc_metrics = MagicMock() + + workflow_mock.execute_activity_method.side_effect = [ + pi_web_api_data, # write_pi_web_api_data + (prediction_data, opc_metrics), # write_opc_data + MagicMock(), # export_data_to_postgres + MagicMock(), # write_metrics + ] + + await format_and_export_prediction.run(input_data) + + workflow_mock.execute_activity_method.assert_has_calls( + [ + call( + Activities.write_pi_web_api_data, + { + 'pi_web_api_output_config': input_data['pi_web_api_output_config'], + 'data': workflow_mock.execute_local_activity_method.return_value, + **metadata, + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ), + call( + Activities.write_opc_data, + { + 'opc_output_config': input_data['opc_output_config'], + 'data': pi_web_api_data, + **metadata, + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ), + ] + ) + + workflow_mock.execute_activity_method.assert_has_calls( + [ + call( + Activities.export_data_to_postgres, + { + **metadata, + 'schema': input_data['schema'], + 'table_name': input_data['table_name'], + 'data': prediction_data, + 'timestamp_conversion': { + 'column': 'timestamp', + 'format': DATETIME_FORMAT_WITH_TZ, + }, + 'on_conflict': 'error', + 'unique_columns': ['model_id', 'timestamp'], + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ) + ] + ) + + workflow_mock.execute_activity_method.assert_has_calls( + [ + call( + Activities.write_metrics, + { + **metadata, + 'prediction': prediction_data, + 'opc_metrics': opc_metrics, + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ) + ] + ) + + assert workflow_mock.execute_activity_method.call_count == 4 + assert workflow_mock.execute_local_activity_method.call_count == 1 + + +@mark.asyncio +@patch( + 'laborious.workflows.sub_workflows.format_and_export_prediction.workflow', + new_callable=AsyncMock, +) +async def test_run_default_path_flag_with_pi_web_api(workflow_mock, format_and_export_prediction): + input_data = { + 'metadata': metadata, + 'path_flag': 'default', + 'data': {'test': 'data'}, + 'timestamp': '2021-01-01', + 'model_id': 1, + 'model_name': metadata['metadata']['model_name'], + 'prediction_confidence': 0, + 'schema': 'test_schema', + 'table_name': 'test_table', + 'pi_web_api_output_config': { + 'endpoint': 'https://test-pi-server.com', + 'prediction_tags': {}, + 'confidence_tags': {}, + }, + 'comment': 'test_comment', + } + + pi_web_api_data = MagicMock() + + workflow_mock.execute_activity_method.side_effect = [ + pi_web_api_data, # write_pi_web_api_data + MagicMock(), # export_data_to_postgres + MagicMock(), # write_metrics + ] + + await format_and_export_prediction.run(input_data) + + workflow_mock.execute_activity_method.assert_has_calls( + [ + call( + Activities.write_pi_web_api_data, + { + 'pi_web_api_output_config': input_data['pi_web_api_output_config'], + 'data': workflow_mock.execute_local_activity_method.return_value, + **metadata, + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ) + ] + ) + + workflow_mock.execute_activity_method.assert_has_calls( + [ + call( + Activities.export_data_to_postgres, + { + **metadata, + 'schema': input_data['schema'], + 'table_name': input_data['table_name'], + 'data': pi_web_api_data, + 'timestamp_conversion': { + 'column': 'timestamp', + 'format': DATETIME_FORMAT_WITH_TZ, + }, + 'on_conflict': 'error', + 'unique_columns': ['model_id', 'timestamp'], + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ) + ] + ) + + assert workflow_mock.execute_activity_method.call_count == 3 + assert workflow_mock.execute_local_activity_method.call_count == 1 diff --git a/tests/laborious/workflows/subworkflows/test_prediction_process.py b/tests/laborious/workflows/subworkflows/test_prediction_process.py new file mode 100644 index 0000000..a018504 --- /dev/null +++ b/tests/laborious/workflows/subworkflows/test_prediction_process.py @@ -0,0 +1,842 @@ +from unittest.mock import ANY, AsyncMock, MagicMock, call, patch + +from pytest import fixture, mark + +from laborious.activities.activities import Activities +from laborious.workflows.sub_workflows.prediction_process import PredictionProcess + + +@fixture +def prediction_process(): + return PredictionProcess() + + +metadata = { + 'metadata': { + 'model_id': 'test_model', + 'model_name': 'test_model', + 'workflow_name': 'test_workflow', + 'schema_name': 'test_schedule', + 'schedule_name': 'test_schedule', + }, +} + + +@mark.asyncio +@patch('laborious.workflows.sub_workflows.prediction_process.workflow', new_callable=AsyncMock) +async def test_run(workflow_mock, prediction_process): + prediction_process.path_flag_handler = AsyncMock(return_value=False) + # Arrange + data_payload = MagicMock() + data_payload.cleanup_prefix.return_value = 'training_datasets/test' + data_payload.__getitem__ = ( + lambda self, key: '2024-01-01' if key == 'last_timestamp' else MagicMock() + ) + input_data = { + 'metadata': metadata, + 'data': data_payload, + 'schema': 'test_schema', + 'table_name': 'test_table', + 'transform_table_name': 'test_transform_table', + 'model_id': 1, + 'input_filters': {'test': 'filter'}, + 'mlflow_transform_filters': {'test': 'filter'}, + 'mlflow_predict_filters': {'test': 'filter'}, + 'model_name': 'test_model_name', + 'model_config': {'retention': '30'}, + 'path_priority': ['continue', 'repeat', 'stop'], + 'opc_output_config': {'test': 'config'}, + 'pi_web_api_output_config': {'test': 'config'}, + 'prediction_store_policy': 'lts:1', + } + + # Mock the activity responses + workflow_mock.execute_activity_method.side_effect = [ + {'content': 'transformed_data', 'timestamp': '2024-01-01'}, + {'content': 'predicted_data', 'timestamp': '2024-01-01'}, + MagicMock(), + ] + workflow_mock.execute_local_activity_method.side_effect = [ + ('continue', 0.95, 'Input data with bad quality'), # input_gate + # mlflow_response_gate (transform) + ('continue', 0.95, 'Error'), + # mlflow_content_gate (transform) + ('continue', 0.95, 'Transformed data not passed the content filter'), + # mlflow_response_gate (predict) + ('continue', 0.95, 'Error'), + ] + + # Act + await prediction_process.run(input_data) + + # Assert + assert workflow_mock.execute_local_activity_method.call_count == 4 + workflow_mock.execute_local_activity_method.assert_has_calls( + [ + call( + Activities.input_gate, + { + **metadata, + 'filters': input_data['input_filters'], + 'data': input_data['data'], + 'path_priority': input_data['path_priority'], + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ) + ] + ) + workflow_mock.execute_activity_method.assert_has_calls( + [ + call( + Activities.request_transform, + { + **metadata, + 'data': input_data['data'], + 'model_name': input_data['model_name'], + 'model_config': input_data['model_config'], + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ) + ] + ) + workflow_mock.execute_local_activity_method.assert_has_calls( + [ + call( + Activities.mlflow_response_gate, + { + **metadata, + 'filters': input_data['mlflow_transform_filters'], + 'data': {'content': 'transformed_data', 'timestamp': '2024-01-01'}, + 'type': 'transform', + 'path_priority': input_data['path_priority'], + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ) + ] + ) + workflow_mock.execute_local_activity_method.assert_has_calls( + [ + call( + Activities.mlflow_content_gate, + { + **metadata, + 'filters': input_data['mlflow_transform_filters'], + 'data': {'content': 'transformed_data', 'timestamp': '2024-01-01'}, + 'type': 'transform', + 'path_priority': input_data['path_priority'], + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ) + ] + ) + workflow_mock.execute_activity_method.assert_has_calls( + [ + call( + Activities.request_predict, + { + **metadata, + 'data': {'content': 'transformed_data', 'timestamp': '2024-01-01'}, + 'model_name': input_data['model_name'], + 'model_config': input_data['model_config'], + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ) + ] + ) + workflow_mock.execute_local_activity_method.assert_has_calls( + [ + call( + Activities.mlflow_response_gate, + { + **metadata, + 'filters': input_data['mlflow_predict_filters'], + 'data': {'content': 'predicted_data', 'timestamp': '2024-01-01'}, + 'type': 'predict', + 'path_priority': input_data['path_priority'], + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ) + ] + ) + + workflow_mock.execute_child_workflow.assert_called_once_with( + 'subworkflow.format_and_export_prediction', + { + 'metadata': metadata, + 'on_conflict': 'error', + 'path_flag': 'continue', + 'data': {'content': 'predicted_data', 'timestamp': '2024-01-01'}, + 'transformed_data': {'content': 'transformed_data', 'timestamp': '2024-01-01'}, + 'prediction_confidence': 0.95, + 'timestamp': '2024-01-01', + 'model_id': 1, + 'model_name': 'test_model_name', + 'model_config': input_data['model_config'], + 'opc_output_config': input_data['opc_output_config'], + 'pi_web_api_output_config': input_data['pi_web_api_output_config'], + 'schema': input_data['schema'], + 'table_name': input_data['table_name'], + 'transform_table_name': input_data['transform_table_name'], + 'comment': 'Error', + 'prediction_store_policy': input_data['prediction_store_policy'], + }, + ) + + +@mark.asyncio +@patch('laborious.workflows.sub_workflows.prediction_process.workflow', new_callable=AsyncMock) +async def test_run_stop_at_input_gate(workflow_mock, prediction_process): + prediction_process.path_flag_handler = AsyncMock(return_value=True) + # Arrange + data_payload = MagicMock() + data_payload.cleanup_prefix.return_value = 'training_datasets/test' + data_payload.__getitem__ = ( + lambda self, key: '2024-01-01' if key == 'last_timestamp' else MagicMock() + ) + input_data = { + 'metadata': metadata, + 'data': data_payload, + 'schema': 'test_schema', + 'table_name': 'test_table', + 'transform_table_name': 'test_transform_table', + 'model_id': 1, + 'input_filters': {'test': 'filter'}, + 'mlflow_transform_filters': {'test': 'filter'}, + 'mlflow_predict_filters': {'test': 'filter'}, + 'model_name': 'test_model_name', + 'model_config': {'retention': '30'}, + 'path_priority': ['continue', 'repeat', 'stop'], + 'opc_output_config': {'test': 'config'}, + } + + # Mock the activity responses + workflow_mock.execute_local_activity_method.side_effect = [ + ('stop', 0.95, 'Input data with bad quality'), # input_gate + ] + + # Act + await prediction_process.run(input_data) + + # Assert + assert workflow_mock.execute_local_activity_method.call_count == 1 + workflow_mock.execute_local_activity_method.assert_has_calls( + [ + call( + Activities.input_gate, + { + 'filters': input_data['input_filters'], + 'data': input_data['data'], + 'path_priority': input_data['path_priority'], + **metadata, + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ), + ] + ) + workflow_mock.execute_child_workflow.assert_not_called() + + +@mark.asyncio +@patch('laborious.workflows.sub_workflows.prediction_process.workflow', new_callable=AsyncMock) +async def test_run_stop_at_first_mlflow_response_gate(workflow_mock, prediction_process): + prediction_process.path_flag_handler = AsyncMock(side_effect=[False, True]) + # Arrange + data_payload = MagicMock() + data_payload.cleanup_prefix.return_value = 'training_datasets/test' + data_payload.__getitem__ = ( + lambda self, key: '2024-01-01' if key == 'last_timestamp' else MagicMock() + ) + input_data = { + 'metadata': metadata, + 'data': data_payload, + 'schema': 'test_schema', + 'table_name': 'test_table', + 'transform_table_name': 'test_transform_table', + 'model_id': 1, + 'input_filters': {'test': 'filter'}, + 'mlflow_transform_filters': {'test': 'filter'}, + 'mlflow_predict_filters': {'test': 'filter'}, + 'model_name': 'test_model_name', + 'model_config': {'retention': '30'}, + 'path_priority': ['continue', 'repeat', 'stop'], + 'opc_output_config': {'test': 'config'}, + } + + # Mock the activity responses + workflow_mock.execute_activity_method.side_effect = [ + {'content': 'transformed_data', 'timestamp': '2024-01-01'}, + MagicMock(), + ] + workflow_mock.execute_local_activity_method.side_effect = [ + ('repeat', 0.95, 'Input data with bad quality'), # input_gate + ('continue', 0.95, 'Error'), # mlflow_response_gate (transform) + ] + + # Act + await prediction_process.run(input_data) + + # Assert + assert workflow_mock.execute_local_activity_method.call_count == 2 + workflow_mock.execute_local_activity_method.assert_has_calls( + [ + call( + Activities.input_gate, + { + 'filters': input_data['input_filters'], + 'data': input_data['data'], + 'path_priority': input_data['path_priority'], + **metadata, + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ) + ] + ) + workflow_mock.execute_activity_method.assert_has_calls( + [ + call( + Activities.request_transform, + { + 'data': input_data['data'], + 'model_name': input_data['model_name'], + 'model_config': input_data['model_config'], + **metadata, + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ) + ] + ) + workflow_mock.execute_local_activity_method.assert_has_calls( + [ + call( + Activities.mlflow_response_gate, + { + 'filters': input_data['mlflow_transform_filters'], + 'data': {'content': 'transformed_data', 'timestamp': '2024-01-01'}, + 'type': 'transform', + 'path_priority': input_data['path_priority'], + **metadata, + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ) + ] + ) + workflow_mock.execute_child_workflow.assert_not_called() + + +@mark.asyncio +@patch('laborious.workflows.sub_workflows.prediction_process.workflow', new_callable=AsyncMock) +async def test_run_stop_at_mlflow_content_gate(workflow_mock, prediction_process): + prediction_process.path_flag_handler = AsyncMock(side_effect=[False, False, True]) + # Arrange + data_payload = MagicMock() + data_payload.cleanup_prefix.return_value = 'training_datasets/test' + data_payload.__getitem__ = ( + lambda self, key: '2024-01-01' if key == 'last_timestamp' else MagicMock() + ) + input_data = { + 'metadata': metadata, + 'data': data_payload, + 'schema': 'test_schema', + 'table_name': 'test_table', + 'transform_table_name': 'test_transform_table', + 'model_id': 1, + 'input_filters': {'test': 'filter'}, + 'mlflow_transform_filters': {'test': 'filter'}, + 'mlflow_predict_filters': {'test': 'filter'}, + 'model_name': 'test_model_name', + 'model_config': {'retention': '30'}, + 'path_priority': ['continue', 'repeat', 'stop'], + 'opc_output_config': {'test': 'config'}, + } + + # Mock the activity responses + workflow_mock.execute_activity_method.side_effect = [ + {'content': 'transformed_data', 'timestamp': '2024-01-01'}, + MagicMock(), + ] + workflow_mock.execute_local_activity_method.side_effect = [ + ('continue', 0.95, 'Input data with bad quality'), # input_gate + # mlflow_response_gate (transform) + ('continue', 0.95, 'Error'), + # mlflow_content_gate (transform) + ('continue', 0.95, 'Transformed data not passed the content filter'), + ] + + # Act + await prediction_process.run(input_data) + + # Assert + assert workflow_mock.execute_local_activity_method.call_count == 3 + + workflow_mock.execute_local_activity_method.assert_has_calls( + [ + call( + Activities.input_gate, + { + 'filters': input_data['input_filters'], + 'data': input_data['data'], + 'path_priority': input_data['path_priority'], + **metadata, + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ) + ] + ) + workflow_mock.execute_activity_method.assert_has_calls( + [ + call( + Activities.request_transform, + { + 'data': input_data['data'], + 'model_name': input_data['model_name'], + 'model_config': input_data['model_config'], + **metadata, + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ) + ] + ) + workflow_mock.execute_local_activity_method.assert_has_calls( + [ + call( + Activities.mlflow_response_gate, + { + 'filters': input_data['mlflow_transform_filters'], + 'data': {'content': 'transformed_data', 'timestamp': '2024-01-01'}, + 'type': 'transform', + 'path_priority': input_data['path_priority'], + **metadata, + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ) + ] + ) + workflow_mock.execute_local_activity_method.assert_has_calls( + [ + call( + Activities.mlflow_content_gate, + { + 'filters': input_data['mlflow_transform_filters'], + 'data': {'content': 'transformed_data', 'timestamp': '2024-01-01'}, + 'type': 'transform', + 'path_priority': input_data['path_priority'], + **metadata, + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ) + ] + ) + workflow_mock.execute_child_workflow.assert_not_called() + + +@mark.asyncio +@patch('laborious.workflows.sub_workflows.prediction_process.workflow', new_callable=AsyncMock) +async def test_run_stop_at_mlflow_last_response_gate(workflow_mock, prediction_process): + prediction_process.path_flag_handler = AsyncMock(side_effect=[False, False, False, True]) + # Arrange + data_payload = MagicMock() + data_payload.cleanup_prefix.return_value = 'training_datasets/test' + data_payload.__getitem__ = ( + lambda self, key: '2024-01-01' if key == 'last_timestamp' else MagicMock() + ) + input_data = { + 'metadata': metadata, + 'data': data_payload, + 'schema': 'test_schema', + 'table_name': 'test_table', + 'transform_table_name': 'test_transform_table', + 'model_id': 1, + 'input_filters': {'test': 'filter'}, + 'mlflow_transform_filters': {'test': 'filter'}, + 'mlflow_predict_filters': {'test': 'filter'}, + 'model_name': 'test_model_name', + 'model_config': {'retention': '30'}, + 'path_priority': ['continue', 'repeat', 'stop'], + 'opc_output_config': {'test': 'config'}, + } + + # Mock the activity responses + workflow_mock.execute_activity_method.side_effect = [ + {'content': 'transformed_data', 'timestamp': '2024-01-01'}, + {'content': 'predicted_data', 'timestamp': '2024-01-01'}, + MagicMock(), + ] + workflow_mock.execute_local_activity_method.side_effect = [ + ('continue', 0.95, 'Input data with bad quality'), # input_gate + # mlflow_response_gate (transform) + ('continue', 0.95, 'Error'), + # mlflow_content_gate (transform) + ('continue', 0.95, 'Transformed data not passed the content filter'), + ('continue', 0.95, 'Error'), # mlflow_response_gate (predict) + ] + + # Act + await prediction_process.run(input_data) + + # Assert + assert workflow_mock.execute_local_activity_method.call_count == 4 + workflow_mock.execute_local_activity_method.assert_has_calls( + [ + call( + Activities.input_gate, + { + 'filters': input_data['input_filters'], + 'data': input_data['data'], + 'path_priority': input_data['path_priority'], + **metadata, + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ) + ] + ) + workflow_mock.execute_activity_method.assert_has_calls( + [ + call( + Activities.request_transform, + { + 'data': input_data['data'], + 'model_name': input_data['model_name'], + 'model_config': input_data['model_config'], + **metadata, + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ) + ] + ) + workflow_mock.execute_local_activity_method.assert_has_calls( + [ + call( + Activities.mlflow_response_gate, + { + 'filters': input_data['mlflow_transform_filters'], + 'data': {'content': 'transformed_data', 'timestamp': '2024-01-01'}, + 'type': 'transform', + 'path_priority': input_data['path_priority'], + **metadata, + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ) + ] + ) + workflow_mock.execute_local_activity_method.assert_has_calls( + [ + call( + Activities.mlflow_content_gate, + { + 'filters': input_data['mlflow_transform_filters'], + 'data': {'content': 'transformed_data', 'timestamp': '2024-01-01'}, + 'type': 'transform', + 'path_priority': input_data['path_priority'], + **metadata, + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ) + ] + ) + workflow_mock.execute_activity_method.assert_has_calls( + [ + call( + Activities.request_predict, + { + 'data': {'content': 'transformed_data', 'timestamp': '2024-01-01'}, + 'model_name': input_data['model_name'], + 'model_config': input_data['model_config'], + **metadata, + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ) + ] + ) + workflow_mock.execute_local_activity_method.assert_has_calls( + [ + call( + Activities.mlflow_response_gate, + { + 'filters': input_data['mlflow_predict_filters'], + 'data': {'content': 'predicted_data', 'timestamp': '2024-01-01'}, + 'type': 'predict', + 'path_priority': input_data['path_priority'], + **metadata, + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ) + ] + ) + workflow_mock.execute_child_workflow.assert_not_called() + + +@mark.asyncio +@patch('laborious.workflows.sub_workflows.prediction_process.workflow', new_callable=AsyncMock) +async def test_path_flag_handler_stop(workflow_mock, prediction_process): + # Arrange + data = {'test': 'data'} + path_flag = 'STOP' + confidence = 0.95 + schema = 'test_schema' + table_name = 'test_table' + model = 'test_model' + last_timestamp = '2024-01-01' + model_name = 'test_model_name' + model_config = {'retention': '30'} + + # Act + result = await prediction_process.path_flag_handler( + data, + path_flag, + { + 'metadata': metadata, + 'schema': schema, + 'table_name': table_name, + 'transform_table_name': 'test_transform_table', + 'model_id': model, + 'last_timestamp': last_timestamp, + 'model_name': model_name, + 'model_config': model_config, + }, + confidence, + last_timestamp, + '', + ) + + # Assert + assert result is True + workflow_mock.execute_local_activity_method.assert_not_called() + workflow_mock.execute_child_workflow.assert_not_called() + + +@mark.asyncio +@patch('laborious.workflows.sub_workflows.prediction_process.workflow', new_callable=AsyncMock) +async def test_path_flag_handler_repeat(workflow_mock, prediction_process): + # Arrange + data = {'test': 'data'} + path_flag = 'repeat' + confidence = 0.95 + schema = 'test_schema' + table_name = 'test_table' + model = 'test_model' + last_timestamp = '2024-01-01' + model_name = 'test_model_name' + model_config = {'retention': '30'} + + # Act + result = await prediction_process.path_flag_handler( + data, + path_flag, + { + 'metadata': metadata, + 'schema': schema, + 'table_name': table_name, + 'transform_table_name': 'test_transform_table', + 'model_id': model, + 'last_timestamp': last_timestamp, + 'model_name': model_name, + 'model_config': model_config, + }, + confidence, + last_timestamp, + '', + ) + + # Assert + assert result is True + workflow_mock.execute_activity_method.assert_called_once_with( + Activities.repeat_last_prediction, + { + **metadata, + 'schema': schema, + 'table_name': table_name, + 'model': model, + 'last_timestamp': last_timestamp, + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ) + workflow_mock.execute_child_workflow.assert_not_called() + + +@mark.asyncio +@patch('laborious.workflows.sub_workflows.prediction_process.workflow', new_callable=AsyncMock) +async def test_path_flag_handler_continue(workflow_mock, prediction_process): + # Arrange + data = {'test': 'data'} + path_flag = 'CONTINUE' + confidence = 0.95 + schema = 'test_schema' + table_name = 'test_table' + model = 'test_model' + last_timestamp = '2024-01-01' + model_name = 'test_model_name' + model_config = {'retention': '30'} + prediction_store_policy = 'erl:1' + + # Act + result = await prediction_process.path_flag_handler( + data, + path_flag, + { + 'metadata': metadata, + 'schema': schema, + 'table_name': table_name, + 'transform_table_name': 'test_transform_table', + 'model_id': model, + 'last_timestamp': last_timestamp, + 'model_name': model_name, + 'model_config': model_config, + 'opc_output_config': {'test': 'config'}, + 'pi_web_api_output_config': { + 'endpoint': 'https://test-pi-server.com', + 'prediction_tags': {}, + 'confidence_tags': {}, + }, + 'prediction_store_policy': prediction_store_policy, + }, + confidence, + last_timestamp, + 'Prediction Process', + ) + + # Assert + assert result is True + workflow_mock.execute_activity_method.assert_not_called() + workflow_mock.execute_child_workflow.assert_called_once_with( + 'subworkflow.format_and_export_prediction', + { + 'metadata': metadata, + 'path_flag': path_flag, + 'data': data, + 'prediction_confidence': confidence, + 'timestamp': last_timestamp, + 'model_id': model, + 'model_name': model_name, + 'model_config': model_config, + 'schema': schema, + 'table_name': table_name, + 'transform_table_name': 'test_transform_table', + 'comment': 'Prediction Process', + 'opc_output_config': {'test': 'config'}, + 'pi_web_api_output_config': { + 'endpoint': 'https://test-pi-server.com', + 'prediction_tags': {}, + 'confidence_tags': {}, + }, + 'prediction_store_policy': prediction_store_policy, + 'on_conflict': 'error', + }, + ) + + +@mark.asyncio +@patch('laborious.workflows.sub_workflows.prediction_process.workflow', new_callable=AsyncMock) +async def test_path_flag_handler_unknown(workflow_mock, prediction_process): + # Arrange + data = {'test': 'data'} + path_flag = 'unknown' + confidence = 0.95 + schema = 'test_schema' + table_name = 'test_table' + model = 'test_model' + last_timestamp = '2024-01-01' + model_name = 'test_model_name' + model_config = {'retention': '30'} + prediction_store_policy = 'erl:1' + # Act + result = await prediction_process.path_flag_handler( + data, + path_flag, + { + **metadata, + 'schema': schema, + 'table_name': table_name, + 'transform_table_name': 'test_transform_table', + 'model_id': model, + 'last_timestamp': last_timestamp, + 'model_name': model_name, + 'model_config': model_config, + 'opc_output_config': {'test': 'config'}, + 'pi_web_api_output_config': { + 'endpoint': 'https://test-pi-server.com', + 'prediction_tags': {}, + 'confidence_tags': {}, + }, + 'prediction_store_policy': prediction_store_policy, + }, + confidence, + last_timestamp, + '', + ) + + # Assert + assert result is False + workflow_mock.execute_activity_method.assert_not_called() + workflow_mock.execute_child_workflow.assert_not_called() + + +@mark.asyncio +@patch('laborious.workflows.sub_workflows.prediction_process.workflow', new_callable=AsyncMock) +async def test_run_with_cleanup_prefixes(workflow_mock, prediction_process): + prediction_process.path_flag_handler = AsyncMock(return_value=False) + prediction_process.cleanup_prefixes = {'training_datasets/test'} + + data_payload = MagicMock() + data_payload.cleanup_prefix.return_value = 'training_datasets/test' + data_payload.__getitem__ = ( + lambda self, key: '2024-01-01' if key == 'last_timestamp' else MagicMock() + ) + input_data = { + 'metadata': metadata, + 'data': data_payload, + 'schema': 'test_schema', + 'table_name': 'test_table', + 'transform_table_name': 'test_transform_table', + 'model_id': 1, + 'input_filters': {'test': 'filter'}, + 'mlflow_transform_filters': {'test': 'filter'}, + 'mlflow_predict_filters': {'test': 'filter'}, + 'model_name': 'test_model_name', + 'model_config': {'retention': '30'}, + 'path_priority': ['continue', 'repeat', 'stop'], + 'opc_output_config': {'test': 'config'}, + 'pi_web_api_output_config': {'test': 'config'}, + 'prediction_store_policy': 'lts:1', + } + + workflow_mock.execute_activity_method.side_effect = [ + {'content': 'transformed_data', 'timestamp': '2024-01-01'}, + {'content': 'predicted_data', 'timestamp': '2024-01-01'}, + MagicMock(), + ] + workflow_mock.execute_local_activity_method.side_effect = [ + ('continue', 0.95, 'ok'), + ('continue', 0.95, ''), + ('continue', 0.95, ''), + ('continue', 0.95, ''), + ] + + await prediction_process.run(input_data) + + workflow_mock.execute_activity_method.assert_any_call( + Activities.cleanup_minio_objects_expired, + {**metadata, 'data': data_payload}, + retry_policy=ANY, + start_to_close_timeout=ANY, + ) diff --git a/tests/laborious/workflows/test_drift.py b/tests/laborious/workflows/test_drift.py new file mode 100644 index 0000000..c31dafc --- /dev/null +++ b/tests/laborious/workflows/test_drift.py @@ -0,0 +1,248 @@ +from unittest.mock import ANY, AsyncMock, call, patch + +from pytest import fixture, mark +from sientia_do.temporal.constants import DATETIME_FORMAT_WITH_TZ + +from laborious.activities.activities import Activities +from laborious.workflows.drift import Drift + + +@fixture +def drift() -> Drift: + return Drift() + + +metadata = { + 'metadata': { + 'model_id': 'test_model_id', + 'model_name': 'test_model', + 'workflow_name': 'drift', + 'schedule_name': 'test_schedule', + }, +} + + +@mark.asyncio +@patch('laborious.workflows.drift.workflow', new_callable=AsyncMock) +async def test_run(workflow_mock: AsyncMock, drift: Drift): + # Arrange + input_data = { + 'schedule_name': 'test_schedule', + 'model_name': 'test_model', + 'model_id': 'test_model_id', + 'schema': 'test_schema', + 'source_table_name': 'test_source_table', + 'target_table_name': 'test_target_table', + 'interval': 60, + 'model_config': {'target': 'test_target'}, + 'drift_metrics': ['psi', 'ks'], + 'chunk_period': 'hour', + } + + target_name = input_data['model_config']['target'] + + target_data = {'data': 'test_target_data'} + reference_data = {'data': 'test_reference_data'} + drift_data = {'drift': 'test_drift_data'} + + workflow_mock.start_activity_method.side_effect = [target_data, reference_data] + workflow_mock.execute_local_activity_method = AsyncMock(return_value=drift_data) + workflow_mock.execute_activity_method = AsyncMock(return_value=None) + + # Act + await drift.run(input_data) + + # Assert - Check start_local_activity_method calls + # Query format matches psycopg2.sql output (identifiers with double quotes, literals with single quotes) + expected_gathering_query = f""" + SELECT * + FROM "{input_data['schema']}"."{input_data['source_table_name']}" + WHERE + model_id = '{input_data['model_id']}' AND + timestamp > NOW() - INTERVAL '{input_data['interval']} minutes' + ORDER BY timestamp ASC + """ + + workflow_mock.start_activity_method.assert_has_calls( + [ + call( + Activities.load_custom_query, + { + **metadata, + 'query': expected_gathering_query, + 'datetime_columns': ['timestamp', 'created_at'], + 'orient': 'records', + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ), + call( + Activities.get_reference_data, + { + **metadata, + 'model_name': input_data['model_name'], + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ), + ] + ) + + # Assert - Check calculate_drift call + workflow_mock.execute_local_activity_method.assert_called_once_with( + Activities.calculate_drift, + { + **metadata, + 'target_data': target_data, + 'reference_data': reference_data, + 'model_name': input_data['model_name'], + 'model_id': input_data['model_id'], + 'target_name': target_name, + 'drift_metrics': input_data['drift_metrics'], + 'chunk_period': input_data['chunk_period'], + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ) + + # Assert - Check export_data_to_postgres call + workflow_mock.execute_activity_method.assert_called_once_with( + Activities.export_data_to_postgres, + { + **metadata, + 'data': drift_data, + 'schema': input_data['schema'], + 'table_name': input_data['target_table_name'], + 'timestamp_conversion': {'column': 'timestamp', 'format': DATETIME_FORMAT_WITH_TZ}, + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ) + + +@mark.asyncio +@patch('laborious.workflows.drift.workflow', new_callable=AsyncMock) +async def test_run_empty_target_data(workflow_mock: AsyncMock, drift: Drift): + # Arrange + input_data = { + 'schedule_name': 'test_schedule', + 'model_name': 'test_model', + 'model_id': 'test_model_id', + 'schema': 'test_schema', + 'source_table_name': 'test_source_table', + 'target_table_name': 'test_target_table', + 'interval': 60, + 'model_config': {'target': 'test_target'}, + 'drift_metrics': ['psi', 'ks'], + } + + target_data = None + reference_data = {'data': 'test_reference_data'} + + workflow_mock.start_activity_method.side_effect = [target_data, reference_data] + + workflow_mock.execute_activity_method = AsyncMock() + workflow_mock.execute_activity_method = AsyncMock() + + # Act + await drift.run(input_data) + + # Assert - Should not call calculate_drift or export + workflow_mock.execute_activity_method.assert_not_called() + + +@mark.asyncio +@patch('laborious.workflows.drift.workflow', new_callable=AsyncMock) +async def test_run_empty_drift_data(workflow_mock: AsyncMock, drift: Drift): + # Arrange + input_data = { + 'schedule_name': 'test_schedule', + 'model_name': 'test_model', + 'model_id': 'test_model_id', + 'schema': 'test_schema', + 'source_table_name': 'test_source_table', + 'target_table_name': 'test_target_table', + 'interval': 60, + 'model_config': {'target': 'test_target'}, + 'drift_metrics': ['psi', 'ks'], + } + + target_name = input_data['model_config']['target'] + + target_data = {'data': 'test_target_data'} + reference_data = {'data': 'test_reference_data'} + drift_data = None + + workflow_mock.start_activity_method.side_effect = [target_data, reference_data] + workflow_mock.execute_local_activity_method = AsyncMock(return_value=drift_data) + workflow_mock.execute_activity_method = AsyncMock() + + # Act + await drift.run(input_data) + + # Assert - Should call calculate_drift but not export + workflow_mock.execute_local_activity_method.assert_called_once_with( + Activities.calculate_drift, + { + **metadata, + 'target_data': target_data, + 'reference_data': reference_data, + 'model_name': input_data['model_name'], + 'model_id': input_data['model_id'], + 'target_name': target_name, + 'drift_metrics': input_data['drift_metrics'], + 'chunk_period': input_data.get('chunk_period', 'min'), + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ) + + workflow_mock.execute_activity_method.assert_not_called() + + +@mark.asyncio +@patch('laborious.workflows.drift.workflow', new_callable=AsyncMock) +async def test_run_default_chunk_period(workflow_mock: AsyncMock, drift: Drift): + # Arrange + input_data = { + 'schedule_name': 'test_schedule', + 'model_name': 'test_model', + 'model_id': 'test_model_id', + 'schema': 'test_schema', + 'source_table_name': 'test_source_table', + 'target_table_name': 'test_target_table', + 'interval': 60, + 'model_config': {'target': 'test_target'}, + 'drift_metrics': ['psi', 'ks'], + # chunk_period not provided, should default to 'min' + } + + target_name = input_data['model_config']['target'] + + target_data = {'data': 'test_target_data'} + reference_data = {'data': 'test_reference_data'} + drift_data = {'drift': 'test_drift_data'} + + workflow_mock.start_activity_method.side_effect = [target_data, reference_data] + workflow_mock.execute_local_activity_method = AsyncMock(return_value=drift_data) + workflow_mock.execute_activity_method = AsyncMock(return_value=None) + + # Act + await drift.run(input_data) + + # Assert - Check calculate_drift call with default chunk_period + workflow_mock.execute_local_activity_method.assert_called_once_with( + Activities.calculate_drift, + { + **metadata, + 'target_data': target_data, + 'reference_data': reference_data, + 'model_name': input_data['model_name'], + 'model_id': input_data['model_id'], + 'target_name': target_name, + 'drift_metrics': input_data['drift_metrics'], + 'chunk_period': 'min', # Default value + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ) diff --git a/tests/laborious/workflows/test_minimal_retrain.py b/tests/laborious/workflows/test_minimal_retrain.py new file mode 100644 index 0000000..94d86da --- /dev/null +++ b/tests/laborious/workflows/test_minimal_retrain.py @@ -0,0 +1,327 @@ +from unittest.mock import ANY, AsyncMock, call, patch + +from pytest import fixture, mark + +from laborious.activities.activities import Activities +from laborious.workflows.minimal_retrain import MinimalRetrain + + +@fixture +def minimal_retrain() -> MinimalRetrain: + return MinimalRetrain() + + +metadata = { + 'metadata': { + 'model_id': 'test_model_id', + 'model_name': 'test_model', + 'workflow_name': 'minimal_retrain', + 'schedule_name': 'test_schedule', + }, +} + + +@mark.asyncio +@patch('laborious.workflows.minimal_retrain.workflow', new_callable=AsyncMock) +async def test_run(workflow_mock: AsyncMock, minimal_retrain: MinimalRetrain): + input_data = { + 'model_id': 'test_model_id', + 'model_name': 'test_model', + 'workflow_name': 'minimal_retrain', + 'schedule_name': 'test_schedule', + 'query': 'test_query', + 'schema': 'test_schema', + 'table_name': 'test_table', + 'model_config': { + 'target': 'test_target', + 'transform_flavor': 'test_transform_flavor', + 'predict_flavor': 'test_predict_flavor', + }, + } + + storage_result = { + 'last_timestamp': '2024-01-01 00:00:00+0000', + 'status': {'success': True}, + 'data': {'timestamp': {0: '2024-01-01 00:00:00+0000'}, 'value': {0: 1.0}}, + 'bucket': None, + 'object_key': None, + 'object_prefix': None, + 'uri': None, + } + + workflow_mock.execute_activity_method = AsyncMock( + side_effect=[ + storage_result, + {'success': True, 'experiment': 'test_experiment'}, + { + 'success': True, + 'version': 'test_version', + 'mlflow_run_id': 'test_mlflow_run_id', + 'mlflow_experiment_id': 'test_mlflow_experiment_id', + }, + {'report': 'test_report'}, + ] + ) + + await minimal_retrain.run(input_data) + + workflow_mock.execute_activity_method.assert_has_calls( + [ + call( + Activities.load_query_with_minio_offload, + { + **metadata, + 'query': input_data['query'], + 'datetime_columns': input_data.get('datetime_columns', []), + 'model_name': input_data['model_name'], + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ) + ] + ) + + workflow_mock.execute_activity_method.assert_has_calls( + [ + call( + Activities.retrain_model, + { + **metadata, + 'data': storage_result, + 'model_name': input_data['model_name'], + 'model_config': input_data['model_config'], + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ) + ] + ) + + workflow_mock.execute_activity_method.assert_has_calls( + [ + call( + Activities.update_production_model, + { + **metadata, + 'model_name': input_data['model_name'], + 'success': True, + 'experiment': 'test_experiment', + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ) + ] + ) + + workflow_mock.execute_local_activity_method.assert_has_calls( + [ + call( + Activities.format_retrain_report, + { + **metadata, + 'experiment_response': {'success': True, 'experiment': 'test_experiment'}, + 'model_name': input_data['model_name'], + 'model_id': input_data['model_id'], + 'update_report': { + 'success': True, + 'version': 'test_version', + 'mlflow_run_id': 'test_mlflow_run_id', + 'mlflow_experiment_id': 'test_mlflow_experiment_id', + }, + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ) + ] + ) + + workflow_mock.execute_activity_method.assert_has_calls( + [ + call( + Activities.export_data_to_postgres, + { + **metadata, + 'data': workflow_mock.execute_local_activity_method.return_value, + 'schema': input_data['schema'], + 'table_name': input_data['table_name'], + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ) + ] + ) + + +@mark.asyncio +@patch('laborious.workflows.minimal_retrain.workflow', new_callable=AsyncMock) +async def test_run_storage_fail(workflow_mock: AsyncMock, minimal_retrain: MinimalRetrain): + input_data = { + 'model_id': 'test_model_id', + 'model_name': 'test_model', + 'workflow_name': 'minimal_retrain', + 'schedule_name': 'test_schedule', + 'query': 'test_query', + 'schema': 'test_schema', + 'table_name': 'test_table', + 'model_config': { + 'target': 'test_target', + 'transform_flavor': 'test_transform_flavor', + 'predict_flavor': 'test_predict_flavor', + }, + } + + storage_result = { + 'last_timestamp': '2024-01-01 00:00:00+0000', + 'status': {'success': True}, + 'data': {}, + 'bucket': None, + 'object_key': None, + 'object_prefix': None, + 'uri': None, + } + + workflow_mock.execute_activity_method = AsyncMock( + side_effect=[ + storage_result, + {'success': True, 'experiment': 'test_experiment'}, + { + 'success': True, + 'version': 'test_version', + 'mlflow_run_id': 'test_mlflow_run_id', + 'mlflow_experiment_id': 'test_mlflow_experiment_id', + }, + {'report': 'test_report'}, + ] + ) + + from pytest import raises + + with raises(ValueError, match='No data returned from query'): + await minimal_retrain.run(input_data) + + workflow_mock.execute_activity_method.assert_called_once_with( + Activities.load_query_with_minio_offload, + { + **metadata, + 'query': input_data['query'], + 'datetime_columns': input_data.get('datetime_columns', []), + 'model_name': input_data['model_name'], + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ) + + workflow_mock.execute_local_activity_method.assert_not_called() + + +@mark.asyncio +@patch('laborious.workflows.minimal_retrain.workflow', new_callable=AsyncMock) +async def test_run_fail_retrain(workflow_mock: AsyncMock, minimal_retrain: MinimalRetrain): + input_data = { + 'model_id': 'test_model_id', + 'model_name': 'test_model', + 'workflow_name': 'minimal_retrain', + 'schedule_name': 'test_schedule', + 'query': 'test_query', + 'schema': 'test_schema', + 'table_name': 'test_table', + 'model_config': { + 'target': 'test_target', + 'transform_flavor': 'test_transform_flavor', + 'predict_flavor': 'test_predict_flavor', + }, + } + + storage_result = { + 'last_timestamp': '2024-01-01 00:00:00+0000', + 'status': {'success': True}, + 'data': {'timestamp': {0: '2024-01-01 00:00:00+0000'}, 'value': {0: 1.0}}, + 'bucket': None, + 'object_key': None, + 'object_prefix': None, + 'uri': None, + } + + workflow_mock.execute_activity_method = AsyncMock( + side_effect=[ + storage_result, + {'success': False, 'experiment': 'test_experiment'}, + { + 'success': True, + 'version': 'test_version', + 'mlflow_run_id': 'test_mlflow_run_id', + 'mlflow_experiment_id': 'test_mlflow_experiment_id', + }, + {'report': 'test_report'}, + ] + ) + + await minimal_retrain.run(input_data) + + workflow_mock.execute_activity_method.assert_has_calls( + [ + call( + Activities.load_query_with_minio_offload, + { + **metadata, + 'query': input_data['query'], + 'datetime_columns': input_data.get('datetime_columns', []), + 'model_name': input_data['model_name'], + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ) + ] + ) + + workflow_mock.execute_activity_method.assert_has_calls( + [ + call( + Activities.retrain_model, + { + **metadata, + 'data': storage_result, + 'model_name': input_data['model_name'], + 'model_config': input_data['model_config'], + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ) + ] + ) + + workflow_mock.execute_local_activity_method.assert_has_calls( + [ + call( + Activities.format_retrain_report, + { + **metadata, + 'experiment_response': {'success': False, 'experiment': 'test_experiment'}, + 'model_name': input_data['model_name'], + 'model_id': input_data['model_id'], + 'update_report': {}, + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ) + ] + ) + + workflow_mock.execute_activity_method.assert_has_calls( + [ + call( + Activities.export_data_to_postgres, + { + **metadata, + 'data': workflow_mock.execute_local_activity_method.return_value, + 'schema': input_data['schema'], + 'table_name': input_data['table_name'], + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ) + ] + ) + + assert workflow_mock.execute_activity_method.call_count == 3 + assert workflow_mock.execute_local_activity_method.call_count == 1 diff --git a/tests/laborious/workflows/test_predictions_batch.py b/tests/laborious/workflows/test_predictions_batch.py new file mode 100644 index 0000000..14280d6 --- /dev/null +++ b/tests/laborious/workflows/test_predictions_batch.py @@ -0,0 +1,106 @@ +from unittest.mock import ANY, AsyncMock, MagicMock, call, patch + +from pytest import fixture, mark + +from laborious.activities.activities import Activities +from laborious.workflows.predictions_batch import PredictionsBatch + + +@fixture +def predictions_batch() -> PredictionsBatch: + return PredictionsBatch() + + +metadata = { + 'model_id': 'test_model_id', + 'model_name': 'test_model', + 'workflow_name': 'predictions_batch', + 'schedule_name': 'test_schedule', +} + + +@mark.asyncio +@patch('laborious.workflows.predictions_batch.workflow', new_callable=AsyncMock) +async def test_run(workflow_mock: AsyncMock, predictions_batch: PredictionsBatch): + activity_return = MagicMock() + workflow_mock.execute_activity_method.return_value = activity_return + + input_data = { + 'schedule_name': 'test_schedule', + 'model_name': 'test_model', + 'model_id': 'test_model_id', + 'query': 'SELECT * FROM test', + 'schema': 'test_schema', + 'table_name': 'test_table', + 'transform_table_name': 'test_transform_table', + 'opc_output_config': 'test_opc_output_config', + 'pi_web_api_output_config': 'test_pi_web_api_output_config', + 'datetime_columns': ['timestamp', 'created_at'], + 'prediction_store_policy': 'erl:1', + 'model_config': {'retention': '30'}, + } + + await predictions_batch.run(input_data) + + workflow_mock.execute_activity_method.assert_has_calls( + [ + call( + Activities.load_query_with_minio_offload, + { + 'metadata': metadata, + 'query': input_data['query'], + 'datetime_columns': input_data.get('datetime_columns', []), + 'model_name': input_data['model_name'], + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ) + ] + ) + prediction_input = { + 'metadata': {'metadata': metadata}, + 'data': activity_return, + 'schema': input_data['schema'], + 'table_name': input_data['table_name'], + 'transform_table_name': input_data['transform_table_name'], + 'model_id': input_data['model_id'], + 'model_name': input_data['model_name'], + 'input_filters': input_data.get( + 'input_filters', + { + 'EMPTY_DATA': { + 'POLICY': 'STOP', + 'CONFIG': {}, + } + }, + ), + 'mlflow_transform_filters': input_data.get( + 'mlflow_transform_filters', + { + 'API_ERROR': { + 'POLICY': 'STOP', + 'CONFIG': {}, + } + }, + ), + 'mlflow_predict_filters': input_data.get( + 'mlflow_predict_filters', + { + 'API_ERROR': { + 'POLICY': 'STOP', + 'CONFIG': {}, + } + }, + ), + 'model_config': input_data.get('model_config', {}), + 'path_priority': input_data.get('path_priority', ['STOP', 'CONTINUE', 'REPEAT']), + 'opc_output_config': input_data.get('opc_output_config', {}), + 'on_conflict': input_data.get('on_conflict', 'error'), + 'pi_web_api_output_config': input_data.get('pi_web_api_output_config', {}), + 'prediction_store_policy': input_data.get('prediction_store_policy', 'lts:1'), + 'save_transform': input_data.get('save_transform', True), + } + + workflow_mock.execute_child_workflow.assert_has_calls( + [call('subworkflow.prediction_process', prediction_input)] + ) diff --git a/tests/laborious/workflows/test_simple_metrics.py b/tests/laborious/workflows/test_simple_metrics.py new file mode 100644 index 0000000..dbda735 --- /dev/null +++ b/tests/laborious/workflows/test_simple_metrics.py @@ -0,0 +1,215 @@ +from unittest.mock import ANY, AsyncMock, call, patch + +from pytest import fixture, mark +from sientia_do.temporal.constants import DATETIME_FORMAT_WITH_TZ + +from laborious.activities.activities import Activities +from laborious.workflows.simple_metrics import SimpleMetrics + + +@fixture +def simple_metrics() -> SimpleMetrics: + return SimpleMetrics() + + +metadata = { + 'metadata': { + 'model_id': 'test_model_id', + 'model_name': 'test_model', + 'workflow_name': 'simple_metrics', + 'schedule_name': 'test_schedule', + }, +} + + +@mark.asyncio +@patch('laborious.workflows.simple_metrics.workflow', new_callable=AsyncMock) +async def test_run(workflow_mock: AsyncMock, simple_metrics: SimpleMetrics): + # Arrange + input_data = { + 'schedule_name': 'test_schedule', + 'model_name': 'test_model', + 'model_id': 'test_model_id', + 'interval_minutes': 60, + 'model_config': {'target': 'test_target'}, + 'schema': 'test_schema', + 'predictions_table_name': 'test_predictions_table', + 'data_table_name': 'test_data_table', + 'target_table_name': 'test_target_table', + 'metrics': ['rmse', 'mse', 'mae', 'r2'], + } + + target_data = {'data': 'test_target_data'} + simple_metrics_data = {'metrics': 'test_simple_metrics_data'} + + workflow_mock.execute_activity_method = AsyncMock(side_effect=[target_data, None]) + workflow_mock.execute_local_activity_method = AsyncMock(return_value=simple_metrics_data) + + # Act + await simple_metrics.run(input_data) + + # Assert - Check load_custom_query call + # Query format matches psycopg2.sql output (identifiers with double quotes, literals with single quotes) + expected_query = f""" + select p."timestamp", p.prediction, ld.value as "target" + from "{input_data['schema']}"."{input_data['predictions_table_name']}" p + inner join "{input_data['schema']}"."{input_data['data_table_name']}" ld + on p."timestamp" = ld."timestamp" + where + p.model_id = '{input_data['model_id']}' and + p.prediction is not null and + ld.variable = '{input_data['model_config']['target']}' and + ld.value is not null and + p."timestamp" >= NOW() - INTERVAL '{input_data['interval_minutes']} minutes' + order by + p."timestamp" desc; + """ + + workflow_mock.execute_activity_method.assert_has_calls( + [ + call( + Activities.load_custom_query, + { + **metadata, + 'query': expected_query, + 'datetime_columns': ['timestamp'], + 'orient': 'records', + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ), + call( + Activities.export_data_to_postgres, + { + **metadata, + 'data': simple_metrics_data, + 'schema': input_data['schema'], + 'table_name': input_data['target_table_name'], + 'timestamp_conversion': { + 'column': 'timestamp', + 'format': DATETIME_FORMAT_WITH_TZ, + }, + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ), + ] + ) + workflow_mock.execute_local_activity_method.assert_called_once_with( + Activities.calculate_simple_metrics, + { + **metadata, + 'model_id': input_data['model_id'], + 'target_data': target_data, + 'metrics': input_data['metrics'], + 'interval_minutes': input_data['interval_minutes'], + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ) + + +@mark.asyncio +@patch('laborious.workflows.simple_metrics.workflow', new_callable=AsyncMock) +async def test_run_empty_target_data(workflow_mock: AsyncMock, simple_metrics: SimpleMetrics): + # Arrange + input_data = { + 'schedule_name': 'test_schedule', + 'model_name': 'test_model', + 'model_id': 'test_model_id', + 'interval_minutes': 60, + 'model_config': {'target': 'test_target'}, + 'schema': 'test_schema', + 'predictions_table_name': 'test_predictions_table', + 'data_table_name': 'test_data_table', + 'target_table_name': 'test_target_table', + 'metrics': ['rmse', 'mse'], + } + + target_data = None + + workflow_mock.execute_activity_method = AsyncMock(return_value=target_data) + + # Act + await simple_metrics.run(input_data) + + # Assert - Should not call calculate_simple_metrics or export + assert workflow_mock.execute_activity_method.call_count == 1 + + +@mark.asyncio +@patch('laborious.workflows.simple_metrics.workflow', new_callable=AsyncMock) +async def test_run_empty_simple_metrics(workflow_mock: AsyncMock, simple_metrics: SimpleMetrics): + # Arrange + input_data = { + 'schedule_name': 'test_schedule', + 'model_name': 'test_model', + 'model_id': 'test_model_id', + 'interval_minutes': 60, + 'model_config': {'target': 'test_target'}, + 'schema': 'test_schema', + 'predictions_table_name': 'test_predictions_table', + 'data_table_name': 'test_data_table', + 'target_table_name': 'test_target_table', + 'metrics': ['rmse', 'mse'], + } + + target_data = {'data': 'test_target_data'} + simple_metrics_data = None + + workflow_mock.execute_activity_method = AsyncMock(return_value=target_data) + workflow_mock.execute_local_activity_method = AsyncMock(return_value=simple_metrics_data) + + # Act + await simple_metrics.run(input_data) + + # Assert - Should call calculate_simple_metrics but not export + workflow_mock.execute_activity_method.assert_called_once() + workflow_mock.execute_local_activity_method.assert_called_once() + + +@mark.asyncio +@patch('laborious.workflows.simple_metrics.workflow', new_callable=AsyncMock) +async def test_run_default_metrics(workflow_mock: AsyncMock, simple_metrics: SimpleMetrics): + # Arrange + input_data = { + 'schedule_name': 'test_schedule', + 'model_name': 'test_model', + 'model_id': 'test_model_id', + 'interval_minutes': 60, + 'model_config': {'target': 'test_target'}, + 'schema': 'test_schema', + 'predictions_table_name': 'test_predictions_table', + 'data_table_name': 'test_data_table', + 'target_table_name': 'test_target_table', + # metrics not provided, should default to ['rmse', 'mse', 'mae', 'r2'] + } + + target_data = {'data': 'test_target_data'} + simple_metrics_data = {'metrics': 'test_simple_metrics_data'} + + workflow_mock.execute_activity_method = AsyncMock(side_effect=[target_data, None]) + workflow_mock.execute_local_activity_method = AsyncMock(return_value=simple_metrics_data) + + # Act + await simple_metrics.run(input_data) + + # Assert - Check calculate_simple_metrics call with default metrics + workflow_mock.execute_activity_method.assert_any_call( + Activities.load_custom_query, + ANY, + retry_policy=ANY, + start_to_close_timeout=ANY, + ) + workflow_mock.execute_local_activity_method.assert_called_once_with( + Activities.calculate_simple_metrics, + { + **metadata, + 'model_id': input_data['model_id'], + 'target_data': target_data, + 'metrics': ['rmse', 'mse', 'mae', 'r2'], # Default value + 'interval_minutes': input_data['interval_minutes'], + }, + retry_policy=ANY, + start_to_close_timeout=ANY, + ) diff --git a/values.yaml b/values.yaml new file mode 100644 index 0000000..25d3418 --- /dev/null +++ b/values.yaml @@ -0,0 +1,318 @@ +# Default values for sientia-module. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. + +# This will set the replicaset count more information can be found here: https://kubernetes.io/docs/concepts/workloads/controllers/replicaset/ +replicaCount: 1 + +# This sets the container image more information can be found here: https://kubernetes.io/docs/concepts/containers/images/ +image: + repository: aignosi.azurecr.io/sientia-module + # This sets the pull policy for images. + pullPolicy: Always + # Overrides the image tag whose default is the chart appVersion. + tag: "1.1.2" + +# This is for the secrets for pulling an image from a private repository more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/ +imagePullSecrets: +- name: docker-hub-secret +# This is to override the chart name. +nameOverride: "sientia-laborious-legacy-worker" +fullnameOverride: "sientia-laborious-legacy-worker" +namespace: sientia + +# This section builds out the service account more information can be found here: https://kubernetes.io/docs/concepts/security/service-accounts/ +serviceAccount: + # Specifies whether a service account should be created + create: true + # Automatically mount a ServiceAccount's API credentials? + automount: true + # Annotations to add to the service account + annotations: {} + # The name of the service account to use. + # If not set and create is true, a name is generated using the fullname template + name: "sientia-laborious-legacy-worker" + +# This is for setting Kubernetes Annotations to a Pod. +# For more information checkout: https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/ +podAnnotations: {} +# This is for setting Kubernetes Labels to a Pod. +# For more information checkout: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/ +podLabels: {} + +podSecurityContext: {} + # fsGroup: 2000 + +securityContext: {} + # capabilities: + # drop: + # - ALL + # readOnlyRootFilesystem: true + # runAsNonRoot: true + # runAsUser: 1000 + + +resources: + # Resource limits and requests are important for ResourceBasedTuner to work correctly. + # The tuner monitors system CPU and memory usage, so proper resource limits must be set. + limits: + cpu: 2000m # 2 CPU cores + memory: 20Gi # 20 GB memory + requests: + cpu: 1000m # 1 CPU core + memory: 2Gi # 2 GB memory + +# This is to setup the liveness and readiness probes more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/ +# This is to setup the liveness and readiness probes more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/ +livenessProbe: + exec: + command: + - sh + - -c + - | + curl -sf http://localhost:9090/metrics | grep -q '^app_up{.*} 1' + initialDelaySeconds: 1260 + periodSeconds: 15 + timeoutSeconds: 5 + failureThreshold: 3 + +readinessProbe: + exec: + command: + - sh + - -c + - | + curl -sf http://localhost:9090/metrics | grep -q '^app_up{.*} 1' + initialDelaySeconds: 1200 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 2 + + + +# This section is for setting up autoscaling more information can be found here: https://kubernetes.io/docs/concepts/workloads/autoscaling/ +autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 100 + targetCPUUtilizationPercentage: 80 + # targetMemoryUtilizationPercentage: 80 + +# Additional volumes on the output Deployment definition. +volumes: [] +# - name: foo +# secret: +# secretName: mysecret +# optional: false + +# Additional volumeMounts on the output Deployment definition. +volumeMounts: [] +# - name: foo +# mountPath: "/etc/foo" +# readOnly: true + +nodeSelector: {} + +tolerations: [] + +affinity: {} + +services: + sdk-metrics: + enabled: true + type: ClusterIP + port: 9091 + targetPort: 9091 + name: sdk-metrics + + metrics: + enabled: true + type: ClusterIP + port: 9090 + targetPort: 9090 + name: metrics + +# ConfiguraΓ§Γ£o do ServiceMonitor para o Prometheus Operator +# ref: https://github.com/prometheus-operator/prometheus-operator +serviceMonitor: + # Se true, um recurso ServiceMonitor serΓ‘ criado. + enabled: true + # O intervalo no qual as mΓ©tricas devem ser coletadas (ex: 30s, 1m). + endpoints: + - port: metrics + path: /metrics + interval: 30s + relabelings: [] + - port: sdk-metrics + path: /metrics + interval: 30s + relabelings: [] + + additionalLabels: + release: kube-prometheus-stack + + +env: + # Entrypoint variables + - name: GITHUB_REPO_URL + value: "git@github.com:Aignosi/sientia-dataops-laborious_temporal.git" + - name: GITHUB_BRANCH + value: "fix/SIENTIAPDE-1811" + - name: PYTHON_APP + value: "laborious.worker.worker" + + # Application variables + - name: POSTGRES_HOST + value: "paradedb-rw.paradedb.svc.cluster.local" + - name: POSTGRES_PORT + value: "5432" + - name: POSTGRES_USER + value: "postgres" + - name: POSTGRES_PASSWORD + value: "nFqc81y6kwmr2zuAIx43DhiOosFCVPpeEfTtTWZflkNjB2j1KtEeIANkhFR9mAX3" + - name: POSTGRES_DBNAME + value: "sientia" + - name: POSTGRES_MIN_CONNECTIONS + value: "20" + # max_connections = number_of_workers * max_concurrent_activities * safety_factor + # Example: 4 workers * 50 activities * 0.5 = 100 connections + - name: POSTGRES_MAX_CONNECTIONS + value: "100" + + - name: MLFLOW_HOST + value: "http://sientia-tracker-mlflow-tracking.sientia-tracker.svc.cluster.local" + - name: MLFLOW_PORT + value: "80" + - name: MLFLOW_USERNAME + value: "aignosi" + - name: MLFLOW_PASSWORD + value: "1L0FP50j3ncp123" + + - name: OPC_ID + value: "1" + - name: OPC_SERVER_NAME + value: "default_server" + - name: OPC_URL + value: "opc.tcp://sientia-opc-simulator-opc.sientia.svc.cluster.local:4840" + + + - name: LOG_LEVEL + value: "DEBUG" + - name: HTTP_METRICS_PORT + value: "9090" + - name: HTTP_SDK_METRICS_PORT + value: "9091" + - name: PROJECT_NAME + value: "sientia-laborious" + + - name: TEMPORAL_HOST + value: "temporal-frontend.temporal.svc.cluster.local:7233" + - name: TEMPORAL_NAMESPACE + value: "laborious" + # Suffix for all Temporal task queues: {workflow}-{RUNTIME}-queue + - name: RUNTIME + value: "legacy" + + - name: MONGODB_USERNAME + value: "root" + - name: MONGODB_PASSWORD + value: "wKZDbMNU1c" + - name: MONGODB_URL + value: "my-release-mongodb.mongodb.svc.cluster.local:27017" + - name: MONGODB_DATABASE + value: "sientia" + - name: MONGODB_TTL_INDEX_HOURS + value: "1" + + - name: MINIO_ENDPOINT_URL + value: "minio.minio.svc.cluster.local:9000" + - name: MINIO_ACCESS_KEY + value: "admin" + - name: MINIO_SECRET_KEY + value: "LiArt4eNmJ" + - name: MINIO_DEFAULT_BUCKET + value: "sientia" + - name: MINIO_RETENTION_HOURS + value: "24" + - name: SIENTIA_MINIO_OFFLOAD_THRESHOLD_MEGABYTES + value: "0.5" + + # Temporal worker tuning for PredictionsBatch. + # IMPORTANT: prefix must be PREDICTIONSBATCH_ (from class name PredictionsBatch). + # Keep workflow-task concurrency moderate to reduce task completion races under load. + - name: PREDICTIONSBATCH_MAX_CONCURRENT_WORKFLOW_TASKS + value: "20" + # Allow higher activity parallelism because most activities are I/O-bound, but keep headroom. + - name: PREDICTIONSBATCH_MAX_CONCURRENT_ACTIVITIES + value: "60" + # Keep local activities controlled so they do not monopolize the event loop. + - name: PREDICTIONSBATCH_MAX_CONCURRENT_LOCAL_ACTIVITIES + value: "20" + # Cache enough workflows for reuse without excessive memory growth. + - name: PREDICTIONSBATCH_MAX_CACHED_WORKFLOWS + value: "200" + # Start with one workflow poller to avoid burst contention at startup. + - name: PREDICTIONSBATCH_WORKFLOW_POLLER_BEHAVIOUR_MINIMUM + value: "3" + # Small initial poller count warms up gradually instead of spiking task fetches. + - name: PREDICTIONSBATCH_WORKFLOW_POLLER_BEHAVIOUR_INITIAL + value: "5" + # Cap workflow pollers to limit scheduling pressure and avoid over-polling. + - name: PREDICTIONSBATCH_WORKFLOW_POLLER_BEHAVIOUR_MAXIMUM + value: "15" + # Keep at least two activity pollers so activity queues do not starve during spikes. + - name: PREDICTIONSBATCH_ACTIVITY_POLLER_BEHAVIOUR_MINIMUM + value: "3" + # Moderate initial activity pollers for faster ramp-up with controlled pressure. + - name: PREDICTIONSBATCH_ACTIVITY_POLLER_BEHAVIOUR_INITIAL + value: "10" + # Limit max activity pollers to preserve CPU for workflow-task completion. + - name: PREDICTIONSBATCH_ACTIVITY_POLLER_BEHAVIOUR_MAXIMUM + value: "30" + + - name: MINIMALRETRAIN_MAX_CONCURRENT_ACTIVITIES + value: "1" + - name: MINIMALRETRAIN_MAX_CONCURRENT_LOCAL_ACTIVITIES + value: "1" + - name: MINIMALRETRAIN_MAX_CACHED_WORKFLOWS + value: "1" + - name: MINIMALRETRAIN_WORKFLOW_POLLER_BEHAVIOUR_MINIMUM + value: "1" + - name: MINIMALRETRAIN_WORKFLOW_POLLER_BEHAVIOUR_INITIAL + value: "1" + - name: MINIMALRETRAIN_WORKFLOW_POLLER_BEHAVIOUR_MAXIMUM + value: "1" + - name: MINIMALRETRAIN_ACTIVITY_POLLER_BEHAVIOUR_MINIMUM + value: "1" + - name: MINIMALRETRAIN_ACTIVITY_POLLER_BEHAVIOUR_INITIAL + value: "1" + - name: MINIMALRETRAIN_ACTIVITY_POLLER_BEHAVIOUR_MAXIMUM + value: "1" + + - name: PI_WEB_API_BASE_URL + value: "https://pivision.votorantimcimentos.com/piwebapi" + - name: PI_WEB_API_AUTH_TYPE + value: "basic" + - name: PI_WEB_API_AUTH_TOKEN + valueFrom: + secretKeyRef: + name: pi-web-api-auth-token + key: token + + - name: PYPI_SERVER + value: "http://library-distribution-server.library.svc.cluster.local:5000" + +ssh: + enabled: true + secretName: git-ssh-key-sientia-laborious-worker + sshPath: /mnt/.ssh + knownHostsPath: /mnt/known_hosts + +# kubectl create secret docker-registry docker-hub-secret --namespace sientia --docker-server=http://aignosi.azurecr.io --docker-username=aignosi --docker-password=5I5zpQ6sRaHqX1hD3dr+2mo647yO3FRc359/wu6gsP+ACRDRz5mp + +# helm upgrade --install sientia-laborious-legacy-worker sientia/sientia-module -n sientia --create-namespace -f ./values.yaml --version 0.6.0 + +# kubectl create secret generic git-ssh-key-sientia-laborious-worker \ +# --namespace sientia \ +# --from-file=ssh-privatekey=git_key \ +# --type=kubernetes.io/ssh-auth