diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 4308cd6..e051ea9 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -21,5 +21,5 @@ jobs:
permissions: write-all
with:
project_name: 'laborious'
- version: ${{ github.event.inputs.version || '' }}
+ release_version: ${{ github.event.inputs.version || '' }}
secrets: inherit
\ No newline at end of file
diff --git a/README.md b/README.md
index 1126ecf..3041bfc 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# 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 automated retraining with strong data quality validation and observability.
+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
@@ -71,7 +71,7 @@ A comprehensive, Temporal-based ML orchestration system for industrial data proc
- **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 and OPC server integration for industrial systems
+- **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
@@ -140,6 +140,9 @@ Laborious uses a Temporal-based architecture with strong separation of concerns
- Model retraining and production updates
- Reference data retrieval from MLflow Model Registry
- `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 activity interfaces
#### **Data Services (`laborious/utils/`)**
@@ -154,7 +157,7 @@ Laborious uses a Temporal-based architecture with strong separation of concerns
```
Input Data (PostgreSQL) → Data Quality Gates → MLFlow Transform →
MLFlow Prediction → Response Validation → Format & Export
- ├─→ Predictions → PostgreSQL [+ OPC]
+ ├─→ Predictions → PostgreSQL [+ OPC] [+ PI Web API]
└─→ Transformed Data → PostgreSQL (optional)
```
@@ -170,6 +173,7 @@ Production Update → Notification & Monitoring
- **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**
@@ -230,6 +234,11 @@ The **PredictionsBatch** workflow is the main entry point for batch prediction p
"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"}
}
}
```
@@ -299,7 +308,12 @@ The **PredictionProcess** workflow implements the core prediction pipeline for M
},
"model_retention": 60,
"path_priority": ["STOP", "CONTINUE", "REPEAT"],
- "opc_output_config": {...}
+ "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"}
+ }
}
```
@@ -322,19 +336,21 @@ The **FormatAndExportPrediction** workflow handles prediction data formatting an
- **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. **PostgreSQL Export**: Writes formatted predictions to database
-5. **OPC Export**: Writes predictions to OPC servers
-6. **Metrics Recording**: Records export performance and success metrics
+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 and OPC server integration
+- **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
@@ -342,13 +358,14 @@ The **FormatAndExportPrediction** workflow handles prediction data formatting an
#### Architecture Diagram
```mermaid
flowchart LR
- A[1. format_prediction/format_default_prediction] --> B[2. format_transformed_data] --> C[3. write_opc_data] --> D[4. export_data_to_postgres] --> E[5. write_metrics]
+ 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 -.-> OPC[OPC Servers]
- D -.-> PostgreSQL[(PostgreSQL)]
- E -.-> Prometheus[Prometheus]
+ C -.-> PIWebAPI[PI Web API]
+ D -.-> OPC[OPC Servers]
+ E -.-> PostgreSQL[(PostgreSQL)]
+ F -.-> Prometheus[Prometheus]
```
#### Transformed Data Export
@@ -395,6 +412,7 @@ flowchart LR
- 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
@@ -685,6 +703,9 @@ The Laborious system exposes comprehensive Prometheus metrics for operational vi
| `OPC_PRIVATE_KEY_PATH` | OPC private key path | `None` | No |
| `OPC_SERVER_CERT_PATH` | OPC server certificate path | `None` | No |
| `OPC_RECONNECTION_INTERVAL` | OPC reconnection interval (ms) | `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 |
@@ -735,6 +756,37 @@ For single OPC server, use individual environment variables:
- `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:
@@ -833,7 +885,8 @@ laborious/
│ ├── activities.py # Main activities orchestrator
│ ├── gates.py # Data quality gates and filtering
│ ├── mlflow.py # MLFlow model operations
-│ └── opc.py # OPC server operations
+│ ├── 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
@@ -886,7 +939,14 @@ laborious/
- Check certificate and key file paths
- Review OPC server logs for connection issues
-5. **Workflow Execution Failures**
+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
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..5dd208e
--- /dev/null
+++ b/e2e/conftest.py
@@ -0,0 +1,452 @@
+"""
+Pytest configuration and fixtures for E2E tests.
+"""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pandas as pd
+import pytest
+import pytest_asyncio
+from sqlalchemy import create_engine, text
+from testcontainers.postgres import PostgresContainer
+from temporalio.testing import WorkflowEnvironment
+from temporalio.worker import Worker
+
+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 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_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()
+
+ # Mock repository methods
+ mock_repo.put_parquet_from_dataframe = AsyncMock(return_value='test-object-key')
+ mock_repo.get_parquet_as_dataframe = AsyncMock(return_value=pd.DataFrame())
+ mock_repo.minio_bucket = 'test-bucket'
+
+ 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()
+ mock_repo.write_value = AsyncMock(
+ return_value={
+ 'Items': [
+ {
+ 'WebId': 'web_id_1'
+ }
+ ]
+ }
+ )
+ 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 = MagicMock()
+ return mock_repo
+
+@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."""
+ with patch('laborious.utils.repository.minio_repository.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={
+ 'endpoint_url': 'http://localhost:9000',
+ 'access_key': 'test',
+ 'secret_key': 'test',
+ 'region_name': 'us-east-1',
+ 'default_bucket': 'test-bucket',
+ },
+ 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 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=[
+ test_activities.load_custom_query,
+ test_activities.get_last_timestamp,
+ 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.write_metrics,
+ ],
+ ) as worker:
+ yield worker
diff --git a/e2e/scenarios.md b/e2e/scenarios.md
new file mode 100644
index 0000000..eb9c606
--- /dev/null
+++ b/e2e/scenarios.md
@@ -0,0 +1,509 @@
+# 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`.
+
+## 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
+
+#### 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` raises exception
+- Notification sent
+- Workflow fails after retries
+- PostgreSQL export may not execute (depends on execution order)
+
+**Assertions**:
+- PI Web API error notification sent
+- Workflow fails
+- May impact subsequent exports
+
+---
+
+#### 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` raises exception
+- Notification sent
+- Workflow fails after retries
+
+**Assertions**:
+- OPC error notification sent
+- Workflow fails
+- PostgreSQL export may not execute
+
+---
+
+#### 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
+
+**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_predictions_batch_format_export.py b/e2e/test_predictions_batch_format_export.py
new file mode 100644
index 0000000..e7bdd91
--- /dev/null
+++ b/e2e/test_predictions_batch_format_export.py
@@ -0,0 +1,829 @@
+"""
+End-to-end tests for PredictionsBatch workflow - Format and Export scenarios.
+"""
+
+import asyncio
+from datetime import datetime
+from unittest.mock import ANY, AsyncMock, patch, call
+
+import pandas as pd
+import pytest
+from sientia_do.notifications.models import NotificationLevel
+from sqlalchemy import text
+from temporalio.testing import WorkflowEnvironment
+from temporalio.worker import Worker
+
+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),
+ }
+
+def insert_sample_data(postgres_engine, model_id, values: list):
+ 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))
+
+async def start_and_await_workflow(client, input_data, workflow_id):
+ handle = await client.start_workflow(
+ PredictionsBatch.run,
+ input_data,
+ id=workflow_id,
+ task_queue='test-queue',
+ )
+ print("[TEST] ✓ Workflow started")
+
+ print("\n[TEST] 3. Waiting for workflow completion...")
+ try:
+ await asyncio.wait_for(handle.result(), timeout=60.0)
+ print("[TEST] ✓ Workflow completed successfully")
+ except asyncio.TimeoutError:
+ pytest.fail("Workflow execution timed out after 60 seconds")
+
+def assert_prediction(
+ postgres_engine, model_id, prediction: float = 0.5,
+ prediction_confidence: int = 0, prediction_status: str = 'Good',
+ comments: str = '',
+):
+ """
+ Verify prediction was created with correct values in database
+
+ Args:
+ postgres_engine: Database engine
+ model_id: Model ID to check
+ prediction: Expected prediction value (default 0.5 from mock)
+ prediction_confidence: Expected confidence value (default 0 for normal predictions)
+ prediction_status: Expected status (default 'Good')
+ comments: Expected comments (default empty string)
+ """
+ print("\n[TEST] 4. Verifying prediction was created with correct values...")
+ with postgres_engine.connect() as conn:
+ result_query = conn.execute(
+ text(f"SELECT model_id, prediction, prediction_confidence, prediction_status, comments FROM predictions_schema.predictions WHERE model_id = {model_id}")
+ )
+ 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, f"Expected prediction={prediction}, got {row[1]}"
+ assert row[2] == 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]}"
+ assert row[4] == comments, f"Expected comments='{comments}', got {row[4]}"
+
+
+@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
+
+ Description:
+ Error prediction path creates default prediction.
+
+ 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
+ """
+ client = temporal_test_env.client
+
+ model_id = 311
+
+ print("\n[TEST] 1. Inserting test data...")
+ insert_sample_data(postgres_engine, model_id, [23.5, 78.2])
+ print("[TEST] ✓ Data inserted successfully")
+
+ 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',
+ }
+ },
+ }
+ }
+
+ print("\n[TEST] 2. Starting workflow that should create default prediction...")
+ workflow_id = f'test-default-prediction-{datetime.now().timestamp()}'
+
+ await start_and_await_workflow(client, input_data, workflow_id)
+
+ 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,
+ },
+ endpoint='test_endpoint',
+ 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': 0,
+ },
+ endpoint='test_endpoint',
+ metadata={
+ 'model_id': 311,
+ 'model_name': 'test_model',
+ 'schedule_name': 'test-schedule',
+ 'workflow_name': 'predictions_batch',
+ },
+ ),
+ ],
+ any_order=True,
+ )
+
+ test_activities.opc_repository['1'].write_data.assert_has_calls(
+ [
+ call('addr_1', 0.5, 'float', ANY,
+ {
+ 'model_id': 311,
+ 'model_name': 'test_model',
+ 'schedule_name': 'test-schedule',
+ 'workflow_name': 'predictions_batch',
+ }),
+ call('addr_2', 0, 'float', ANY,
+ {
+ 'model_id': 311,
+ 'model_name': 'test_model',
+ 'schedule_name': 'test-schedule',
+ 'workflow_name': 'predictions_batch',
+ }),
+ ]
+ )
+
+ assert_prediction(postgres_engine, model_id)
+
+ print("\n[TEST] ✓ All assertions passed!")
+
+
+
+
+@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
+
+ print("\n[TEST] 1. Inserting test data...")
+ insert_sample_data(postgres_engine, model_id, [23.5, 78.2])
+ print("[TEST] ✓ Data inserted successfully")
+
+ 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
+
+ print("\n[TEST] 2. Starting workflow with OPC only...")
+ workflow_id = f'test-opc-only-{datetime.now().timestamp()}'
+
+ await start_and_await_workflow(client, input_data, workflow_id)
+
+ test_activities.opc_repository['1'].write_data.assert_has_calls(
+ [
+ call('addr_1', 0.5, 'float', ANY,
+ {
+ 'model_id': 312,
+ 'model_name': 'test_model',
+ 'schedule_name': 'test-schedule',
+ 'workflow_name': 'predictions_batch',
+ }),
+ call('addr_2', 0, 'float', ANY,
+ {
+ '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)
+
+ print("\n[TEST] ✓ All assertions passed!")
+
+
+@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
+
+ print("\n[TEST] 1. Inserting test data...")
+ insert_sample_data(postgres_engine, model_id, [23.5, 78.2])
+ print("[TEST] ✓ Data inserted successfully")
+
+ 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
+
+ print("\n[TEST] 2. Starting workflow with PI Web API only...")
+ workflow_id = f'test-pi-api-only-{datetime.now().timestamp()}'
+
+ await start_and_await_workflow(client, input_data, workflow_id)
+
+ 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,
+ },
+ endpoint='test_endpoint',
+ 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,
+ },
+ endpoint='test_endpoint',
+ metadata={
+ 'model_id': 313,
+ 'model_name': 'test_model',
+ 'schedule_name': 'test-schedule',
+ 'workflow_name': 'predictions_batch',
+ },
+ ),
+ ],
+ any_order=True,
+ )
+
+ test_activities.opc_repository['1'].write_data.assert_not_called()
+
+ assert_prediction(postgres_engine, model_id)
+
+ print("\n[TEST] ✓ All assertions passed!")
+
+
+@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
+
+ print("\n[TEST] 1. Inserting test data...")
+ insert_sample_data(postgres_engine, model_id, [23.5, 78.2])
+ print("[TEST] ✓ Data inserted successfully")
+
+ 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
+
+ print("\n[TEST] 2. Starting workflow without optional outputs...")
+ workflow_id = f'test-no-optional-outputs-{datetime.now().timestamp()}'
+
+ await start_and_await_workflow(client, input_data, workflow_id)
+
+ test_activities.pi_web_api_client.write_value.assert_not_called()
+ test_activities.opc_repository['1'].write_data.assert_not_called()
+
+ assert_prediction(postgres_engine, model_id)
+
+ print("\n[TEST] ✓ All assertions passed!")
+
+
+@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
+
+ print("\n[TEST] 1. Inserting test data...")
+ 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}"))
+ print("[TEST] ✓ Data inserted successfully")
+
+ 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',
+ }
+ },
+ }
+ }
+
+ print("\n[TEST] 2. Starting workflow without transformed data export...")
+ workflow_id = f'test-no-transform-export-{datetime.now().timestamp()}'
+
+ await start_and_await_workflow(client, input_data, workflow_id)
+
+ 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,
+ },
+ endpoint='test_endpoint',
+ 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,
+ },
+ endpoint='test_endpoint',
+ metadata={
+ 'model_id': 315,
+ 'model_name': 'test_model',
+ 'schedule_name': 'test-schedule',
+ 'workflow_name': 'predictions_batch',
+ },
+ ),
+ ],
+ any_order=True,
+ )
+
+ test_activities.opc_repository['1'].write_data.assert_has_calls(
+ [
+ call('addr_1', 0.5, 'float', ANY,
+ {
+ 'model_id': 315,
+ 'model_name': 'test_model',
+ 'schedule_name': 'test-schedule',
+ 'workflow_name': 'predictions_batch',
+ }),
+ call('addr_2', 0, 'float', ANY,
+ {
+ '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)
+
+ print("\n[TEST] ✓ All assertions passed!")
+
+
+@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,
+):
+ """
+ Scenario 3.2.1: PI Web API Write Error
+
+ Description:
+ PI Web API export fails.
+
+ Expected Behavior:
+ - write_pi_web_api_data raises exception
+ - Notification sent
+ - Workflow fails after retries
+ - PostgreSQL export may not execute (depends on execution order)
+
+ Assertions:
+ - PI Web API error notification sent
+ - Workflow fails
+ - May impact subsequent exports
+ """
+ client = temporal_test_env.client
+
+ model_id = 321
+
+ print("\n[TEST] 1. Inserting test data...")
+ insert_sample_data(postgres_engine, model_id, [23.5, 78.2])
+ print("[TEST] ✓ Data inserted successfully")
+
+ 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',
+ }
+ },
+ }
+ }
+
+ print("\n[TEST] 2. Starting workflow that should fail on PI Web API write...")
+ workflow_id = f'test-pi-api-error-{datetime.now().timestamp()}'
+
+ await start_and_await_workflow(client, input_data, workflow_id)
+
+ assert_prediction(
+ postgres_engine, model_id,
+ prediction_confidence=13,
+ comments='PI Web API service unavailable',
+ )
+
+ print("\n[TEST] ✓ All assertions passed!")
+
+
+@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
+
+ Description:
+ OPC server write fails.
+
+ Expected Behavior:
+ - write_opc_data raises exception
+ - Notification sent
+ - Workflow fails after retries
+
+ Assertions:
+ - OPC error notification sent
+ - Workflow fails
+ - PostgreSQL export may not execute
+ """
+ client = temporal_test_env.client
+
+ model_id = 322
+
+ print("\n[TEST] 1. Inserting test data...")
+ insert_sample_data(postgres_engine, model_id, [23.5, 78.2])
+ print("[TEST] ✓ Data inserted successfully")
+
+ test_activities.opc_repository['1'].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'},
+ }
+
+ print("\n[TEST] 2. Starting workflow that should fail on OPC write...")
+ workflow_id = f'test-opc-error-{datetime.now().timestamp()}'
+
+ await start_and_await_workflow(client, input_data, workflow_id)
+
+ assert_prediction(
+ postgres_engine, model_id,
+ prediction_confidence=12,
+ comments='Some data could not be written to OPC servers',
+ )
+
+ print("\n[TEST] ✓ All assertions passed!")
+
+
+@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
+
+ Description:
+ Two prediction tags attempt to be written to PI Web API, but only one succeeds.
+
+ 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
+
+ Assertions:
+ - One tag written successfully
+ - One tag failed
+ - Error confidence set in prediction
+ - Error notification sent
+ - Workflow completes
+ """
+ client = temporal_test_env.client
+
+ model_id = 323
+
+ print("\n[TEST] 1. Inserting test data...")
+ insert_sample_data(postgres_engine, model_id, [23.5, 78.2])
+ print("[TEST] ✓ Data inserted successfully")
+
+ test_activities.pi_web_api_client.write_value = AsyncMock(side_effect=[
+ {
+ 'Items': [
+ {
+ 'WebId': 'web_id_1',
+ 'Errors': [],
+ },
+ ]
+ },
+ Exception('Tag write failed'),
+ {
+ 'Items': [
+ {
+ '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',
+ }
+ },
+ }
+ }
+
+ print("\n[TEST] 2. Starting workflow with partial PI Web API write error...")
+ workflow_id = f'test-pi-api-partial-error-{datetime.now().timestamp()}'
+
+ await start_and_await_workflow(client, input_data, workflow_id)
+
+ 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.",
+ )
+
+ print("\n[TEST] ✓ All assertions passed!")
diff --git a/e2e/test_predictions_batch_main_workflow.py b/e2e/test_predictions_batch_main_workflow.py
new file mode 100644
index 0000000..f8469ef
--- /dev/null
+++ b/e2e/test_predictions_batch_main_workflow.py
@@ -0,0 +1,450 @@
+"""
+End-to-end tests for PredictionsBatch workflow - Main workflow scenarios.
+"""
+
+import asyncio
+from datetime import datetime
+
+import pandas as pd
+import pytest
+from sqlalchemy import text
+from temporalio.testing import WorkflowEnvironment
+from temporalio.worker import Worker
+
+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 - Complete Success
+
+ Description:
+ Workflow completes successfully with valid SQL query and all activities succeed.
+
+ Process Flow:
+ 1. load_custom_query returns DataFrame with sensor data
+ 2. Workflow prepares prediction input with all configurations
+ 3. prediction_process child workflow executes:
+ - get_last_timestamp retrieves last processing timestamp
+ - input_gate validates data quality (passes)
+ - request_transform calls MLFlow transform (mocked, returns features)
+ - mlflow_response_gate validates transform response (passes)
+ - mlflow_content_gate validates transform content (passes)
+ - request_predict calls MLFlow predict (mocked, returns predictions)
+ - mlflow_response_gate validates predict response (passes)
+ - mlflow_content_gate validates predict content (passes)
+ 4. format_and_export_prediction child workflow executes:
+ - format_prediction formats the prediction data
+ - format_transformed_data formats transformed data (if save_transform=True)
+ - export_data_to_postgres saves to database
+ - write_metrics records execution metrics
+
+ Expected Behavior:
+ - All activities execute successfully without errors
+ - All gates pass with no quality issues
+ - Transform and predict operations succeed (mocked)
+ - Data exported to PostgreSQL predictions table
+ - Transformed data exported to transformed_data table (if save_transform=True)
+ - Metrics written successfully
+
+ Assertions:
+ - Workflow completes without raising exceptions
+ - Data exists in PostgreSQL predictions table with correct model_id
+ - Data exists in transformed_data table (if save_transform=True)
+ - Prediction data has expected structure (jsonb with predictions)
+ - All required fields are populated (model_id, model_name, timestamp, etc)
+ """
+ client = temporal_test_env.client
+
+ print("\n[TEST] 1. Inserting test data into PostgreSQL...")
+ # Insert test data directly into PostgreSQL
+ # The load_custom_query activity will fetch this data with a real SQL query
+ with postgres_engine.begin() as conn:
+ # Clear any existing data for this model_id
+ conn.execute(text("DELETE FROM predictions_schema.laborious_data WHERE model_id = 123"))
+
+ # Insert sensor data that the workflow will query
+ 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))
+ print("[TEST] ✓ Data inserted successfully")
+
+ # Prepare input data for PredictionsBatch workflow
+ 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'],
+ }
+
+ # Start workflow
+ print("\n[TEST] 2. Starting workflow...")
+ workflow_id = f'test-predictions-batch-{datetime.now().timestamp()}'
+ print(f"[TEST] Workflow ID: {workflow_id}")
+
+ handle = await client.start_workflow(
+ PredictionsBatch.run,
+ input_data,
+ id=workflow_id,
+ task_queue='test-queue',
+ )
+ print("[TEST] ✓ Workflow started")
+
+ # Wait for workflow completion with timeout
+ print("\n[TEST] 3. Waiting for workflow completion (timeout: 60s)...")
+ try:
+ await asyncio.wait_for(handle.result(), timeout=60.0) # 60 seconds timeout
+ print("[TEST] ✓ Workflow completed successfully")
+ except asyncio.TimeoutError:
+ print("[TEST] ✗ Workflow TIMEOUT after 60 seconds!")
+ pytest.fail("Workflow execution timed out after 60 seconds")
+
+ # Verify data was stored in PostgreSQL - use single connection
+ schema_name = 'predictions_schema'
+ predictions_table = 'predictions'
+ transformed_table = 'transformed_data'
+ full_predictions_table = f"{schema_name}.{predictions_table}"
+ full_transformed_table = f"{schema_name}.{transformed_table}"
+
+ # Use a single connection for all verification queries
+ print("\n[TEST] 4. Verifying results in PostgreSQL...")
+ with postgres_engine.connect() as conn:
+ # Verify prediction data
+ result_query = conn.execute(
+ text(f"SELECT model_id, prediction, prediction_confidence, response_time, prediction_status, comments FROM {full_predictions_table} WHERE model_id = 123")
+ )
+ prediction_rows = result_query.fetchall()
+
+ print(f"[TEST] Found {len(prediction_rows)} prediction record(s)")
+ assert len(prediction_rows) == 1, "Expected one prediction record"
+
+ # Verify first row has expected structure
+ row = prediction_rows[0]
+ print(f"[TEST] Prediction: {row}")
+ assert row[0] == 123, f"Expected model_id=123, got {row[0]}"
+ assert row[1] == 0.5, f"Expected prediction=0.5, got {row[1]}"
+ assert row[2] == 0, f"Expected prediction_confidence=0.9, got {row[2]}"
+ assert row[3] is not None, f"Expected response_time=0.1, got {row[3]}"
+ assert row[4] == 'Good', f"Expected prediction_status='Good', got {row[4]}"
+ assert row[5] == '', f"Expected comments='', got {row[5]}"
+ print("[TEST] ✓ Prediction data verified")
+
+ # Verify transformed data
+ result_query = conn.execute(
+ text(f"SELECT model_id, variable, value FROM {full_transformed_table} WHERE model_id = 123")
+ )
+ transformed_rows = result_query.fetchall()
+ print(f"[TEST] Found {len(transformed_rows)} transformed data record(s)")
+ assert len(transformed_rows) == 2, "Expected two transformed data records"
+ row_1 = transformed_rows[0]
+ print(f"[TEST] Transformed data: {row_1}")
+ assert row_1[0] == 123, f"Expected model_id=123, got {row_1[0]}"
+ assert row_1[1] == 'feature_1', f"Expected variable='sensor_1', got {row_1[1]}"
+ assert float(row_1[2]) == 0.234, f"Expected value=0.234, got {row_1[2]}"
+ row_2 = transformed_rows[1]
+ print(f"[TEST] Transformed data: {row_2}")
+ assert row_2[0] == 123, f"Expected model_id=123, got {row_2[0]}"
+ assert row_2[1] == 'feature_2', f"Expected variable='sensor_2', got {row_2[1]}"
+ assert float(row_2[2]) == 0.783, f"Expected value=0.783, got {row_2[2]}"
+
+ print("\n[TEST] ✓ All assertions passed!")
+
+
+@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,
+):
+ """
+ Scenario 1.2.1: SQL Query Execution Error
+
+ Description:
+ SQL query fails due to syntax error or connection issue.
+
+ 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
+ """
+ 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 =', # Invalid SQL
+ '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',
+ },
+ }
+
+ print("\n[TEST] 1. Starting workflow with invalid SQL query...")
+ workflow_id = f'test-sql-error-{datetime.now().timestamp()}'
+
+ handle = await client.start_workflow(
+ PredictionsBatch.run,
+ input_data,
+ id=workflow_id,
+ task_queue='test-queue',
+ )
+ print("[TEST] ✓ Workflow started")
+
+ print("\n[TEST] 2. Waiting for workflow completion...")
+ try:
+ await asyncio.wait_for(handle.result(), timeout=60.0)
+ print("[TEST] ✓ Workflow completed (may have exited early due to empty data)")
+ except Exception as e:
+ print(f"[TEST] ✓ Workflow failed as expected: {type(e).__name__}")
+
+ # Verify no predictions were created (regardless of whether workflow failed or exited early)
+ print("\n[TEST] 3. Verifying no predictions were created...")
+ with postgres_engine.connect() as conn:
+ result_query = conn.execute(
+ text("SELECT COUNT(*) FROM predictions_schema.predictions WHERE model_id = 128")
+ )
+ count = result_query.scalar()
+ assert count == 0, f"Expected no predictions, but found {count} records"
+
+ print("\n[TEST] ✓ All assertions passed!")
+
+
+@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,
+):
+ """
+ Scenario 1.2.2: Missing Required Parameters
+
+ Description:
+ Essential parameters missing from input.
+
+ 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
+ """
+ client = temporal_test_env.client
+
+ # Missing 'query' parameter
+ 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,
+ # 'query' is missing
+ 'schema': 'predictions_schema',
+ 'table_name': 'predictions',
+ 'transform_table_name': 'transformed_data',
+ }
+
+ print("\n[TEST] 1. Starting workflow with missing required parameter...")
+ workflow_id = f'test-missing-param-{datetime.now().timestamp()}'
+
+ handle = await client.start_workflow(
+ PredictionsBatch.run,
+ input_data,
+ id=workflow_id,
+ task_queue='test-queue',
+ )
+ print("[TEST] ✓ Workflow started")
+
+ print("\n[TEST] 2. Waiting for workflow to fail...")
+ try:
+ await asyncio.wait_for(handle.result(), timeout=60.0)
+ pytest.fail("Expected workflow to fail, but it completed successfully")
+ except Exception as e:
+ print(f"[TEST] ✓ Workflow failed as expected: {type(e).__name__}")
+
+ print("\n[TEST] ✓ All assertions passed!")
+
+
+@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,
+):
+ """
+ Scenario 1.2.3: Invalid Datetime Column Specification
+
+ Description:
+ Datetime column specified doesn't exist in query results.
+
+ 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
+ """
+ client = temporal_test_env.client
+
+ print("\n[TEST] 1. Inserting test data...")
+ with postgres_engine.begin() as conn:
+ conn.execute(text("DELETE FROM predictions_schema.laborious_data WHERE model_id = 130"))
+
+ insert_sql = """
+ 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')
+ """
+ conn.execute(text(insert_sql))
+ print("[TEST] ✓ Data inserted successfully")
+
+ 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'], # Column doesn't exist in query result
+ }
+
+ print("\n[TEST] 2. Starting workflow with invalid datetime column...")
+ workflow_id = f'test-invalid-datetime-col-{datetime.now().timestamp()}'
+
+ handle = await client.start_workflow(
+ PredictionsBatch.run,
+ input_data,
+ id=workflow_id,
+ task_queue='test-queue',
+ )
+ print("[TEST] ✓ Workflow started")
+
+ print("\n[TEST] 3. Waiting for workflow completion or failure...")
+ try:
+ await asyncio.wait_for(handle.result(), timeout=60.0)
+ # Workflow may complete or fail depending on error handling
+ print("[TEST] ✓ Workflow completed (may have handled error gracefully)")
+ except Exception as e:
+ print(f"[TEST] ✓ Workflow failed as expected: {type(e).__name__}")
+
+ print("\n[TEST] ✓ Test completed!")
diff --git a/e2e/test_predictions_batch_prediction_process.py b/e2e/test_predictions_batch_prediction_process.py
new file mode 100644
index 0000000..d32980e
--- /dev/null
+++ b/e2e/test_predictions_batch_prediction_process.py
@@ -0,0 +1,629 @@
+"""
+End-to-end tests for PredictionsBatch workflow - Prediction Process scenarios.
+"""
+
+import asyncio
+from datetime import datetime
+from decimal import Decimal
+from unittest.mock import MagicMock, patch
+
+import pandas as pd
+import pytest
+from pytz import timezone
+from sqlalchemy import text
+from temporalio.testing import WorkflowEnvironment
+from temporalio.worker import Worker
+
+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', # Continue despite issues, not STOP
+ '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_data(postgres_engine, model_id, values: list[tuple]):
+ with postgres_engine.begin() as conn:
+ conn.execute(text(f"DELETE FROM predictions_schema.laborious_data WHERE model_id = {model_id}"))
+
+ # Insert data with some null values (quality issue)
+
+ 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 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 data with some null values (quality issue)
+
+ 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')
+
+async def start_and_await_workflow(client, input_data, workflow_id):
+ handle = await client.start_workflow(
+ PredictionsBatch.run,
+ input_data,
+ id=workflow_id,
+ task_queue='test-queue',
+ )
+ print("[TEST] ✓ Workflow started")
+
+ print("\n[TEST] 3. Waiting for workflow completion...")
+ try:
+ await asyncio.wait_for(handle.result(), timeout=60.0)
+ print("[TEST] ✓ Workflow completed successfully")
+ except asyncio.TimeoutError:
+ pytest.fail("Workflow execution timed out after 60 seconds")
+
+def assert_continue(
+ postgres_engine, model_id, prediction_confidence: Decimal = 2,
+ comments: str = 'Input data with bad quality',
+):
+ print("\n[TEST] 4. Verifying prediction was created despite warnings...")
+ with postgres_engine.connect() as conn:
+ result_query = conn.execute(
+ text(f"SELECT model_id, prediction, prediction_confidence, prediction_status, comments 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"
+
+ # Assert prediction value is 0 and other fields
+ 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, model_id):
+ print("\n[TEST] 4. Verifying no predictions were created...")
+ 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, model_id, last_prediction: list):
+ print("\n[TEST] 4. Verifying prediction was repeated...")
+ with postgres_engine.connect() as conn:
+ result_query = conn.execute(
+ text(f'SELECT model_id, prediction, prediction_confidence, prediction_status FROM predictions_schema.predictions WHERE model_id = {model_id}')
+ )
+ prediction_rows = result_query.fetchall()
+ print(prediction_rows)
+ assert len(prediction_rows) == 2, "Expected two prediction records"
+ assert prediction_rows[0] == last_prediction, f"Expected first prediction to be the same as the last prediction, got {prediction_rows[0]}, expected {last_prediction}"
+ assert prediction_rows[1] == last_prediction, f"Expected second prediction to be the same as the last prediction, got {prediction_rows[1]}, expected {last_prediction}"
+
+
+@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,
+):
+ """
+ Scenario 2.1.1: Input Gate Triggers CONTINUE
+
+ Description:
+ Input gate determines data should use previous prediction.
+
+ 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
+ """
+ client = temporal_test_env.client
+
+ model_id = 211
+
+ print("\n[TEST] 1. Inserting test data...")
+
+ insert_sample_data(postgres_engine, model_id, ['NULL', 78.2])
+
+ print("[TEST] ✓ Data inserted successfully")
+
+ input_data = get_base_input_data(model_id)
+
+
+ print("\n[TEST] 2. Starting workflow with CONTINUE policy...")
+ workflow_id = f'test-continue-policy-{datetime.now().timestamp()}'
+
+ await start_and_await_workflow(client, input_data, workflow_id)
+ assert_continue(postgres_engine, model_id)
+
+ print("\n[TEST] ✓ All assertions passed!")
+
+
+@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,
+):
+ """
+ Scenario 2.1.2: Input Gate Triggers STOP
+
+ Description:
+ Input data quality gate fails with STOP policy.
+
+ 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
+ """
+ client = temporal_test_env.client
+
+ model_id = 212
+
+ print("\n[TEST] 1. Inserting test data...")
+ insert_sample_data(postgres_engine, model_id, ['NULL', 78.2])
+ print("[TEST] ✓ Data inserted successfully")
+
+ input_data = get_base_input_data(model_id)
+ input_data['input_filters']['SPECIFIC_VARIABLES_NULL_VALUES']['policy'] = 'STOP'
+
+ print("\n[TEST] 2. Starting workflow that should stop at input gate...")
+ workflow_id = f'test-input-stop-{datetime.now().timestamp()}'
+
+ await start_and_await_workflow(client, input_data, workflow_id)
+
+ assert_stop(postgres_engine, model_id)
+
+ print("\n[TEST] ✓ All assertions passed!")
+
+
+@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,
+):
+ """
+ Scenario 2.1.3: Input Gate Triggers REPEAT
+
+ Description:
+ Input gate determines data should repeat last prediction.
+
+ 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
+ """
+ client = temporal_test_env.client
+
+ model_id = 213
+
+ print("\n[TEST] 1. Inserting test data and previous prediction...")
+ insert_sample_data(postgres_engine, model_id, ['NULL', 78.2])
+ data = insert_sample_prediction(postgres_engine, model_id)
+
+ print("[TEST] ✓ Data and previous prediction inserted")
+
+ input_data = get_base_input_data(model_id)
+ input_data['input_filters']['SPECIFIC_VARIABLES_NULL_VALUES']['policy'] = 'REPEAT'
+
+ print("\n[TEST] 2. Starting workflow that should trigger REPEAT...")
+ workflow_id = f'test-input-repeat-{datetime.now().timestamp()}'
+
+ await start_and_await_workflow(client, input_data, workflow_id)
+ assert_repeat(postgres_engine, model_id, data)
+
+ print("\n[TEST] ✓ All assertions passed!")
+
+@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.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,
+):
+ """
+ Scenario 2.2.1: Transform Gate Triggers CONTINUE
+
+ Description:
+ Transform response gate determines data should continue despite issues.
+
+ 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
+ """
+ client = temporal_test_env.client
+
+ model_id = 221
+
+ print("\n[TEST] 1. Inserting test data...")
+ 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'
+
+ print("\n[TEST] 2. Starting workflow that should trigger CONTINUE at transform gate...")
+ workflow_id = f'test-transform-continue-{datetime.now().timestamp()}'
+
+ await start_and_await_workflow(client, input_data, workflow_id)
+ assert_continue(
+ postgres_engine=postgres_engine,
+ model_id=model_id,
+ prediction_confidence=Decimal(10),
+ comments='Bad data model',
+ )
+
+ print("\n[TEST] ✓ All assertions passed!")
+
+
+@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,
+):
+ """
+ Scenario 2.2.2: Transform Gate Triggers STOP
+
+ Description:
+ Transform response validation fails with STOP policy.
+
+ 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
+ """
+ client = temporal_test_env.client
+
+ model_id = 222
+
+ print("\n[TEST] 1. Inserting test data...")
+ insert_sample_data(postgres_engine, model_id, [60.0, 78.2])
+ print("[TEST] ✓ Data inserted successfully")
+
+ input_data = get_base_input_data(model_id)
+ input_data['mlflow_transform_filters']['API_ERROR']['policy'] = 'STOP'
+
+ print("\n[TEST] 2. Starting workflow that should trigger STOP at transform gate...")
+ workflow_id = f'test-transform-stop-{datetime.now().timestamp()}'
+
+ await start_and_await_workflow(client, input_data, workflow_id)
+ assert_stop(postgres_engine, model_id)
+
+ print("\n[TEST] ✓ All assertions passed!")
+
+
+
+@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,
+):
+ """
+ Scenario 2.2.3: Transform Gate Triggers REPEAT
+
+ Description:
+ Transform response gate determines data should repeat last prediction.
+
+ 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
+ """
+ client = temporal_test_env.client
+
+ model_id = 223
+
+ print("\n[TEST] 1. Inserting test data...")
+ insert_sample_data(postgres_engine, model_id, [60.0, 78.2])
+ data = insert_sample_prediction(postgres_engine, model_id)
+
+ print("[TEST] ✓ Data inserted successfully")
+
+ input_data = get_base_input_data(model_id)
+ input_data['mlflow_transform_filters']['API_ERROR']['policy'] = 'REPEAT'
+
+ print("\n[TEST] 2. Starting workflow that should trigger REPEAT at transform gate...")
+ workflow_id = f'test-transform-repeat-{datetime.now().timestamp()}'
+
+ await start_and_await_workflow(client, input_data, workflow_id)
+ assert_repeat(postgres_engine, model_id, data)
+
+ print("\n[TEST] ✓ All assertions passed!")
+
+
+@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_3_1_predict_gate_triggers_continue(
+ temporal_test_env: WorkflowEnvironment,
+ temporal_worker: Worker,
+ test_activities: Activities,
+ postgres_engine,
+ bad_predict_model,
+):
+ """
+ Scenario 2.3.1: Predict Gate Triggers CONTINUE
+
+ Description:
+ Predict response gate determines data should continue despite issues.
+
+ 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
+ """
+ client = temporal_test_env.client
+
+ model_id = 231
+
+ print("\n[TEST] 1. Inserting test data...")
+ 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'
+
+ print("\n[TEST] 2. Starting workflow that should trigger CONTINUE at predict gate...")
+ workflow_id = f'test-predict-continue-{datetime.now().timestamp()}'
+
+ await start_and_await_workflow(client, input_data, workflow_id)
+ assert_continue(
+ postgres_engine=postgres_engine,
+ model_id=model_id,
+ prediction_confidence=Decimal(10),
+ comments='Bad predict model',
+ )
+
+ print("\n[TEST] ✓ All assertions passed!")
+
+
+@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,
+):
+ """
+ Scenario 2.3.2: Predict Gate Triggers STOP
+
+ Description:
+ Prediction validation fails with STOP policy.
+
+ 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
+ """
+ client = temporal_test_env.client
+
+ model_id = 232
+
+ print("\n[TEST] 1. Inserting test data...")
+ insert_sample_data(postgres_engine, model_id, [23.5, 78.2])
+ print("[TEST] ✓ Data inserted successfully")
+
+ input_data = get_base_input_data(model_id)
+ input_data['mlflow_predict_filters']['API_ERROR']['policy'] = 'STOP'
+
+ print("\n[TEST] 2. Starting workflow that should stop at predict gate...")
+ workflow_id = f'test-predict-stop-{datetime.now().timestamp()}'
+
+ await start_and_await_workflow(client, input_data, workflow_id)
+ assert_stop(postgres_engine, model_id)
+
+ print("\n[TEST] ✓ All assertions passed!")
+
+
+@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,
+):
+ """
+ Scenario 2.3.3: Predict Gate Triggers REPEAT
+
+ Description:
+ Predict response gate determines data should repeat last prediction.
+
+ 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
+ """
+ client = temporal_test_env.client
+
+ model_id = 233
+
+ print("\n[TEST] 1. Inserting test data and previous prediction...")
+ insert_sample_data(postgres_engine, model_id, [23.5, 78.2])
+ data = insert_sample_prediction(postgres_engine, model_id)
+
+ print("[TEST] ✓ Data and previous prediction inserted")
+
+ input_data = get_base_input_data(model_id)
+ input_data['mlflow_predict_filters']['API_ERROR']['policy'] = 'REPEAT'
+ input_data['path_priority'] = ['REPEAT', 'STOP', 'CONTINUE'] # REPEAT first
+
+ print("\n[TEST] 2. Starting workflow that should trigger REPEAT at predict gate...")
+ workflow_id = f'test-predict-repeat-{datetime.now().timestamp()}'
+
+ await start_and_await_workflow(client, input_data, workflow_id)
+ assert_repeat(postgres_engine, model_id, data)
+
+ print("\n[TEST] ✓ All assertions passed!")
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/laborious/activities/activities.py b/laborious/activities/activities.py
index 077a433..f009b10 100644
--- a/laborious/activities/activities.py
+++ b/laborious/activities/activities.py
@@ -7,6 +7,7 @@ with workflow.unsafe.imports_passed_through():
from sientia_do.notifications.handlers import CoreNotificationHandler as NotificationHandler
from sientia_do.observability.logger import Logger
+ 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
@@ -14,7 +15,7 @@ with workflow.unsafe.imports_passed_through():
from laborious.activities.storage import Storage
-class Activities(Storage, MLFlow, Gates, OPC, ModelMetrics):
+class Activities(Storage, MLFlow, Gates, OPC, ModelMetrics, API):
"""
Main activities orchestrator for the Laborious system.
@@ -23,15 +24,18 @@ class Activities(Storage, MLFlow, Gates, OPC, ModelMetrics):
MLFlow model interactions, data quality validation, and OPC server communications.
The class implements multiple inheritance to combine specialized functionality:
- - Postgres: Database operations and data persistence
+ - 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
"""
@@ -42,6 +46,7 @@ class Activities(Storage, MLFlow, Gates, OPC, ModelMetrics):
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,
):
@@ -58,6 +63,8 @@ class Activities(Storage, MLFlow, Gates, OPC, ModelMetrics):
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
@@ -116,6 +123,16 @@ class Activities(Storage, MLFlow, Gates, OPC, ModelMetrics):
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.
@@ -123,6 +140,8 @@ class Activities(Storage, MLFlow, Gates, OPC, ModelMetrics):
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
@@ -133,3 +152,4 @@ class Activities(Storage, MLFlow, Gates, OPC, ModelMetrics):
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..4e4144f
--- /dev/null
+++ b/laborious/activities/api.py
@@ -0,0 +1,270 @@
+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,
+ )
+
+ 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_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,
+ )
+
+ data['prediction_confidence'] = confidence
+ 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': 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
index f0f8474..b581920 100644
--- a/laborious/activities/gates.py
+++ b/laborious/activities/gates.py
@@ -6,13 +6,13 @@ with workflow.unsafe.imports_passed_through():
from typing import Any
from pandas import DataFrame
- from sientia_do.formatters import create_sample_dict
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_WITH_TZ, now
+ from sientia_do.utils.formatters import create_sample_dict
from laborious import metrics
from laborious.utils.filters.conditional_filters import (
diff --git a/laborious/activities/mlflow.py b/laborious/activities/mlflow.py
index 3970bb7..082e720 100644
--- a/laborious/activities/mlflow.py
+++ b/laborious/activities/mlflow.py
@@ -6,7 +6,6 @@ with workflow.unsafe.imports_passed_through():
import numpy as np
from pandas import DataFrame, to_datetime
- from sientia_do.formatters import create_sample_dict
from sientia_do.notifications.handlers import CoreNotificationHandler as NotificationHandler
from sientia_do.notifications.models import NotificationLevel
from sientia_do.observability.logger import Logger
@@ -18,6 +17,7 @@ with workflow.unsafe.imports_passed_through():
DATETIME_FORMAT_WITH_TZ,
now,
)
+ from sientia_do.utils.formatters import create_sample_dict
from laborious.utils.repository.minio_repository import MinioRepository
from laborious.utils.repository.model_repository import MLFlowRepository
@@ -153,11 +153,11 @@ class MLFlow(SientiaMonitoring):
# 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
- self.debug('Processed input data:', metadata)
- self.debug(data.head(5).to_string(), metadata)
+ data.columns.name = None
+ data.index.name = None
+
+ self.debug(f'Processed input data: \n {data.to_csv()}', metadata)
# Request transformation from MLFlow model
response_data = await self.model_monitoring_repository.transform(
diff --git a/laborious/activities/opc.py b/laborious/activities/opc.py
index c4abe95..da36793 100644
--- a/laborious/activities/opc.py
+++ b/laborious/activities/opc.py
@@ -352,10 +352,13 @@ class OPC(SientiaMonitoring):
This allows downstream systems to handle data quality appropriately.
"""
+ message = 'Some data could not be written to OPC servers'
+
if not success:
data['prediction_confidence'] = OPC_WRITTING_ERROR_CONFIDENCE
+ data['comments'] = message
self.debug(
- f'Some data could not be written to OPC servers, setting confidence to {OPC_WRITTING_ERROR_CONFIDENCE}.',
+ f'{message}, setting confidence to {OPC_WRITTING_ERROR_CONFIDENCE}.',
metadata,
)
diff --git a/laborious/metrics.py b/laborious/metrics.py
index be5604d..cdd44db 100644
--- a/laborious/metrics.py
+++ b/laborious/metrics.py
@@ -24,7 +24,9 @@ Metric Labels:
"""
from prometheus_client import Counter, Gauge, Histogram
-from sientia_do.observability.metrics import CORE_LABELS as SIENTIA_CORE_LABELS
+from sientia_do.observability.metrics import (
+ CORE_LABELS as SIENTIA_CORE_LABELS,
+)
# Application health metric
APP_UP = Gauge(
@@ -188,3 +190,20 @@ MODEL_ANALYZE_ERROR_COUNT = Counter(
'Number of errors during analyze operations',
SIENTIA_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/connectors_config.py b/laborious/utils/connectors_config.py
index ba8cd4a..c151377 100644
--- a/laborious/utils/connectors_config.py
+++ b/laborious/utils/connectors_config.py
@@ -3,37 +3,6 @@ from os import getenv
from typing import Any
-def build_postgres_config() -> dict[str, Any]:
- """
- Build PostgreSQL database configuration from environment variables.
-
- This function constructs a PostgreSQL configuration dictionary from
- environment variables with sensible defaults for local development.
- It handles connection pool configuration and security parameters.
-
- Environment Variables:
- POSTGRES_HOST: Database hostname (default: localhost)
- POSTGRES_PORT: Database port (default: 5432)
- POSTGRES_USER: Database username (default: sientia)
- POSTGRES_PASSWORD: Database password (default: sientia)
- POSTGRES_DBNAME: Database name (default: sientia)
- POSTGRES_MIN_CONNECTIONS: Minimum connection pool size (default: 5)
- POSTGRES_MAX_CONNECTIONS: Maximum connection pool size (default: 20)
-
- Returns:
- dict: PostgreSQL configuration dictionary with all required parameters
- """
- return {
- 'host': getenv('POSTGRES_HOST', 'localhost'),
- 'port': int(getenv('POSTGRES_PORT', '5432')),
- 'user': getenv('POSTGRES_USER', 'sientia'),
- 'password': getenv('POSTGRES_PASSWORD', 'sientia'),
- 'dbname': getenv('POSTGRES_DBNAME', 'sientia'),
- 'min_connections': int(getenv('POSTGRES_MIN_CONNECTIONS', '5')),
- 'max_connections': int(getenv('POSTGRES_MAX_CONNECTIONS', '20')),
- }
-
-
def build_mlflow_config() -> dict[str, Any]:
"""
Build MLFlow server configuration from environment variables.
@@ -99,37 +68,6 @@ def build_opc_config() -> dict[str, Any]:
}
-def build_mongodb_config() -> dict[str, Any]:
- """
- Build MongoDB configuration from environment variables.
-
- This function constructs a MongoDB configuration dictionary from
- environment variables with sensible defaults for local development.
- It handles connection string and database name configuration.
-
- Environment Variables:
- MONGODB_USERNAME: MongoDB username (default: root)
- MONGODB_PASSWORD: MongoDB password (default: wKZDbMNU1c)
- MONGODB_URL: MongoDB connection URI (default: localhost:27018)
- MONGODB_DATABASE_NAME: MongoDB database name (default: sientia)
- MONGODB_TTL_INDEX_HOURS: TTL index duration in hours (default: 1)
-
- Returns:
- dict: MongoDB configuration dictionary with connection parameters
- """
- username = getenv('MONGODB_USERNAME', 'root')
- password = getenv('MONGODB_PASSWORD', 'wKZDbMNU1c')
- uri = getenv('MONGODB_URL', 'localhost:27018')
-
- connection_string = f'mongodb://{username}:{password}@{uri}'
-
- return {
- 'connection_string': connection_string,
- 'database_name': getenv('MONGODB_DATABASE_NAME', 'sientia'),
- 'ttl_index_seconds': int(getenv('MONGODB_TTL_INDEX_HOURS', '1')) * 3600,
- }
-
-
def build_minio_config() -> dict[str, Any]:
"""
Build MinIO (S3-compatible) configuration from environment variables.
diff --git a/laborious/utils/repository/model_repository.py b/laborious/utils/repository/model_repository.py
index eaab16c..66862e9 100644
--- a/laborious/utils/repository/model_repository.py
+++ b/laborious/utils/repository/model_repository.py
@@ -752,6 +752,7 @@ class MLFlowRepository(SientiaMonitoring):
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]:
@@ -812,7 +813,10 @@ class MLFlowRepository(SientiaMonitoring):
load_wrapper=load_predict_wrapper,
)
- treated_data_candidate = data_model.fit(data)
+ 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
@@ -1164,7 +1168,7 @@ class MLFlowRepository(SientiaMonitoring):
and returned in the response structure rather than propagated.
"""
- self.debug(f'Data received for model transformation: {data.head(5).to_csv()}', metadata)
+ self.debug(f'Data received for model transformation: {data.to_csv()}', metadata)
# data.to_csv(
# f"tmp/data_{model_name}.csv", index=True)
@@ -1250,7 +1254,9 @@ class MLFlowRepository(SientiaMonitoring):
input_index = data.index
start_time = datetime.now()
- self.debug(f'Data received for model prediction: {data.head(5).to_csv()}', metadata)
+ self.debug(
+ f'Data received for model prediction: {data.to_dict(orient="records")}', metadata
+ )
# data.to_csv(
# f"tmp/treated_data_{model_name}.csv", index=True)
@@ -1268,7 +1274,8 @@ class MLFlowRepository(SientiaMonitoring):
if isinstance(predict_data, pd.DataFrame):
self.debug(
- f'Data received from model prediction: {data.head(5).to_csv()}', metadata
+ f'Data received from model prediction: {predict_data.to_dict(orient="records")}',
+ metadata,
)
# predict_data.to_csv(
@@ -1343,6 +1350,7 @@ class MLFlowRepository(SientiaMonitoring):
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}',
@@ -1356,6 +1364,7 @@ class MLFlowRepository(SientiaMonitoring):
model_name=model_name,
data=data,
transform_flavor=transform_flavor,
+ skip_transform=skip_transform,
predict_flavor=predict_flavor,
target_name=target_name,
metadata=metadata,
diff --git a/laborious/worker/prepare_worker.py b/laborious/worker/prepare_worker.py
new file mode 100644
index 0000000..07ae36f
--- /dev/null
+++ b/laborious/worker/prepare_worker.py
@@ -0,0 +1,72 @@
+import os
+import re
+from collections.abc import Sequence
+from typing import Any
+
+from sientia_do.observability.logger import Logger
+from temporalio.client import Client
+from temporalio.worker import PollerBehaviorAutoscaling, Worker
+
+parameters = [
+ ('MAX_CONCURRENT_WORKFLOW_TASKS', '200'),
+ ('MAX_CONCURRENT_ACTIVITIES', '200'),
+ ('MAX_CONCURRENT_LOCAL_ACTIVITIES', '200'),
+ ('MAX_CACHED_WORKFLOWS', '200'),
+ ('WORKFLOW_POLLER_BEHAVIOUR_MINIMUM', '10'),
+ ('WORKFLOW_POLLER_BEHAVIOUR_INITIAL', '100'),
+ ('WORKFLOW_POLLER_BEHAVIOUR_MAXIMUM', '200'),
+ ('ACTIVITY_POLLER_BEHAVIOUR_MINIMUM', '10'),
+ ('ACTIVITY_POLLER_BEHAVIOUR_INITIAL', '100'),
+ ('ACTIVITY_POLLER_BEHAVIOUR_MAXIMUM', '200'),
+]
+
+
+def camel_to_snake(text: str) -> str:
+ """Convert camelCase or PascalCase to snake_case."""
+ text = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', text)
+ text = re.sub('([a-z0-9])([A-Z])', r'\1_\2', text)
+ return text.lower()
+
+
+def prepare_worker(
+ main_workflow: type,
+ other_workflows: Sequence[type],
+ activities: Sequence[Any],
+ temporal_client: Client,
+ logger: Logger,
+) -> Worker:
+ main_workflow_name = main_workflow.__name__.upper()
+
+ queue_name = f'{camel_to_snake(main_workflow.__name__)}-queue'
+
+ local_workflow_parameters = {}
+
+ for parameter in parameters:
+ local_workflow_parameters[parameter[0]] = int(
+ os.getenv(main_workflow_name + '_' + parameter[0], parameter[1])
+ )
+
+ logger.info(f'Preparing worker for {main_workflow_name} with queue {queue_name}')
+
+ return Worker(
+ temporal_client,
+ task_queue=queue_name,
+ workflows=[main_workflow, *other_workflows],
+ activities=[*activities],
+ max_concurrent_workflow_tasks=local_workflow_parameters['MAX_CONCURRENT_WORKFLOW_TASKS'],
+ max_concurrent_activities=local_workflow_parameters['MAX_CONCURRENT_ACTIVITIES'],
+ max_concurrent_local_activities=local_workflow_parameters[
+ 'MAX_CONCURRENT_LOCAL_ACTIVITIES'
+ ],
+ max_cached_workflows=local_workflow_parameters['MAX_CACHED_WORKFLOWS'],
+ workflow_task_poller_behavior=PollerBehaviorAutoscaling(
+ minimum=local_workflow_parameters['WORKFLOW_POLLER_BEHAVIOUR_MINIMUM'],
+ initial=local_workflow_parameters['WORKFLOW_POLLER_BEHAVIOUR_INITIAL'],
+ maximum=local_workflow_parameters['WORKFLOW_POLLER_BEHAVIOUR_MAXIMUM'],
+ ),
+ activity_task_poller_behavior=PollerBehaviorAutoscaling(
+ minimum=local_workflow_parameters['ACTIVITY_POLLER_BEHAVIOUR_MINIMUM'],
+ initial=local_workflow_parameters['ACTIVITY_POLLER_BEHAVIOUR_INITIAL'],
+ maximum=local_workflow_parameters['ACTIVITY_POLLER_BEHAVIOUR_MAXIMUM'],
+ ),
+ )
diff --git a/laborious/worker/worker.py b/laborious/worker/worker.py
index 33384b0..7281254 100644
--- a/laborious/worker/worker.py
+++ b/laborious/worker/worker.py
@@ -7,6 +7,7 @@ prediction and retraining workflows.
The worker supports multiple task queues:
- predictions_batch-queue: Handles batch prediction workflows (heavy workload)
+ Includes activities for MLFlow, data quality gates, OPC export, PI Web API export, and PostgreSQL
- minimal_retrain-queue: Handles model retraining workflows
- drift-queue: Handles drift detection workflows
- simple_metrics-queue: Handles simple metrics calculation workflows
@@ -26,51 +27,33 @@ Environment Variables:
- 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)
-
-Tuner Configuration (Resource-based scaling):
-- TUNER_TARGET_MEMORY_USAGE: Target memory usage (0.0-1.0, default: 0.75)
-- TUNER_TARGET_CPU_USAGE: Target CPU usage (0.0-1.0, default: 0.80)
-- TUNER_WORKFLOW_MIN_SLOTS: Minimum workflow slots (default: 5)
-- TUNER_WORKFLOW_MAX_SLOTS: Maximum workflow slots (default: 50)
-- TUNER_ACTIVITY_MIN_SLOTS: Minimum activity slots (default: 5)
-- TUNER_ACTIVITY_MAX_SLOTS: Maximum activity slots (default: 50)
-- TUNER_WORKFLOW_RAMP_THROTTLE_MS: Workflow ramp throttle in ms (default: 100)
-- TUNER_ACTIVITY_RAMP_THROTTLE_MS: Activity ramp throttle in ms (default: 50)
-
-Poller Configuration:
-- POLLER_MINIMUM: Minimum number of pollers (default: 1)
-- POLLER_MAXIMUM: Maximum number of pollers (default: 10)
-- POLLER_INITIAL: Initial number of pollers (default: 2)
"""
-
from temporalio import client, workflow
from temporalio.runtime import PrometheusConfig, Runtime, TelemetryConfig
-from temporalio.worker import (
- PollerBehaviorAutoscaling,
- ResourceBasedSlotConfig,
- Worker,
- WorkerTuner,
-)
with workflow.unsafe.imports_passed_through():
import asyncio
+ import os
import sys
- from datetime import timedelta
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.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_mongodb_config,
build_opc_config,
- build_postgres_config,
)
+ from laborious.worker.prepare_worker import prepare_worker
from laborious.workflows.drift import Drift
from laborious.workflows.minimal_retrain import MinimalRetrain
from laborious.workflows.predictions_batch import PredictionsBatch
@@ -79,55 +62,11 @@ with workflow.unsafe.imports_passed_through():
FormatAndExportPrediction,
)
from laborious.workflows.sub_workflows.prediction_process import PredictionProcess
- import os
POD_ID = os.getenv('POD_ID')
SDK_METRICS_PORT = int(os.getenv('HTTP_SDK_METRICS_PORT', '9091'))
-def create_resource_tuner() -> WorkerTuner:
- """Create a resource-based tuner from environment variables."""
- target_memory = float(os.getenv('TUNER_TARGET_MEMORY_USAGE', '0.75'))
- target_cpu = float(os.getenv('TUNER_TARGET_CPU_USAGE', '0.50'))
- workflow_min = int(os.getenv('TUNER_WORKFLOW_MIN_SLOTS', '5'))
- workflow_max = int(os.getenv('TUNER_WORKFLOW_MAX_SLOTS', '50'))
- activity_min = int(os.getenv('TUNER_ACTIVITY_MIN_SLOTS', '5'))
- activity_max = int(os.getenv('TUNER_ACTIVITY_MAX_SLOTS', '50'))
- local_activity_min = int(os.getenv('TUNER_LOCAL_ACTIVITY_MIN_SLOTS', '1'))
- local_activity_max = int(os.getenv('TUNER_LOCAL_ACTIVITY_MAX_SLOTS', '30'))
- workflow_ramp = int(os.getenv('TUNER_WORKFLOW_RAMP_THROTTLE_MS', '100'))
- activity_ramp = int(os.getenv('TUNER_ACTIVITY_RAMP_THROTTLE_MS', '50'))
- local_activity_ramp = int(os.getenv('TUNER_LOCAL_ACTIVITY_RAMP_THROTTLE_MS', '50'))
-
- return WorkerTuner.create_resource_based(
- target_memory_usage=target_memory,
- target_cpu_usage=target_cpu,
- workflow_config=ResourceBasedSlotConfig(
- minimum_slots=workflow_min,
- maximum_slots=workflow_max,
- ramp_throttle=timedelta(milliseconds=workflow_ramp),
- ),
- activity_config=ResourceBasedSlotConfig(
- minimum_slots=activity_min,
- maximum_slots=activity_max,
- ramp_throttle=timedelta(milliseconds=activity_ramp),
- ),
- local_activity_config=ResourceBasedSlotConfig(
- minimum_slots=local_activity_min,
- maximum_slots=local_activity_max,
- ramp_throttle=timedelta(milliseconds=local_activity_ramp),
- ),
- )
-
-
-def create_poller_behavior() -> PollerBehaviorAutoscaling:
- """Create poller behavior from environment variables."""
- minimum = int(os.getenv('POLLER_MINIMUM', '1'))
- maximum = int(os.getenv('POLLER_MAXIMUM', '10'))
- initial = int(os.getenv('POLLER_INITIAL', '2'))
- return PollerBehaviorAutoscaling(minimum=minimum, maximum=maximum, initial=initial)
-
-
async def main():
"""
Main entry point for the Laborious worker application.
@@ -181,6 +120,7 @@ async def main():
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,
)
@@ -206,14 +146,11 @@ async def main():
logger.custom_info('Starting Workers...', metadata)
- tuner = create_resource_tuner()
- poller = create_poller_behavior()
-
workers = [
- Worker(
- temporal_client,
- task_queue='minimal_retrain-queue',
- workflows=[MinimalRetrain],
+ prepare_worker(
+ temporal_client=temporal_client,
+ main_workflow=MinimalRetrain,
+ other_workflows=[],
activities=[
activities.load_custom_query,
activities.query_to_minio,
@@ -222,44 +159,35 @@ async def main():
activities.format_retrain_report,
activities.export_data_to_postgres,
],
- tuner=tuner,
- max_cached_workflows=2,
- workflow_task_poller_behavior=poller,
- activity_task_poller_behavior=poller,
+ logger=logger,
),
- Worker(
- temporal_client,
- task_queue='drift-queue',
- workflows=[Drift],
+ 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,
+ ),
+ 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,
],
- tuner=tuner,
- max_cached_workflows=2,
- workflow_task_poller_behavior=poller,
- activity_task_poller_behavior=poller,
+ logger=logger,
),
- Worker(
- temporal_client,
- task_queue='simple_metrics-queue',
- workflows=[SimpleMetrics],
- activities=[
- activities.load_custom_query,
- activities.calculate_simple_metrics,
- activities.export_data_to_postgres,
- ],
- tuner=tuner,
- max_cached_workflows=2,
- workflow_task_poller_behavior=poller,
- activity_task_poller_behavior=poller,
- ),
- Worker(
- temporal_client,
- task_queue='predictions_batch-queue',
- workflows=[PredictionsBatch, PredictionProcess, FormatAndExportPrediction],
+ prepare_worker(
+ temporal_client=temporal_client,
+ main_workflow=PredictionsBatch,
+ other_workflows=[PredictionProcess, FormatAndExportPrediction],
activities=[
# MLFlow
activities.request_predict,
@@ -279,11 +207,10 @@ async def main():
activities.repeat_last_prediction,
activities.export_data_to_postgres,
activities.write_metrics,
+ # Pi Web API
+ activities.write_pi_web_api_data,
],
- tuner=tuner,
- max_cached_workflows=200,
- workflow_task_poller_behavior=poller,
- activity_task_poller_behavior=poller,
+ logger=logger,
),
]
diff --git a/laborious/workflows/predictions_batch.py b/laborious/workflows/predictions_batch.py
index 6c5f770..42be048 100644
--- a/laborious/workflows/predictions_batch.py
+++ b/laborious/workflows/predictions_batch.py
@@ -61,7 +61,10 @@ class PredictionsBatch:
- 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
@@ -111,7 +114,9 @@ class PredictionsBatch:
'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', {}),
+ '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
diff --git a/laborious/workflows/sub_workflows/format_and_export_prediction.py b/laborious/workflows/sub_workflows/format_and_export_prediction.py
index e50c2cf..684f7a5 100644
--- a/laborious/workflows/sub_workflows/format_and_export_prediction.py
+++ b/laborious/workflows/sub_workflows/format_and_export_prediction.py
@@ -26,6 +26,7 @@ class FormatAndExportPrediction:
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
"""
@@ -38,9 +39,10 @@ class FormatAndExportPrediction:
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 OPC servers for real-time industrial access
- 4. Persisting data to PostgreSQL database with comprehensive metadata
- 5. Recording performance metrics for operational monitoring
+ 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
@@ -61,8 +63,10 @@ class FormatAndExportPrediction:
- model_name (str): Name of the ML model
- schema (str): Database schema for data storage
- table_name (str): Target table for data persistence
- - opc_output_config (dict[str, Any]): OPC server export configuration
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
@@ -87,6 +91,9 @@ class FormatAndExportPrediction:
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(
@@ -152,20 +159,36 @@ class FormatAndExportPrediction:
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
- prediction, opc_metrics = await workflow.execute_activity_method(
- Activities.write_opc_data,
- {
- 'opc_output_config': input_data['opc_output_config'],
- 'data': prediction,
- **metadata,
- },
- retry_policy=retry_policy,
- start_to_close_timeout=timedelta(seconds=60),
- )
+ 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
- prediction_handler = workflow.execute_activity_method(
+ await workflow.execute_activity_method(
Activities.export_data_to_postgres,
{
**metadata,
@@ -178,8 +201,6 @@ class FormatAndExportPrediction:
start_to_close_timeout=timedelta(seconds=180),
)
- await prediction_handler
-
if write_transformed_handler is not None:
await write_transformed_handler
diff --git a/laborious/workflows/sub_workflows/prediction_process.py b/laborious/workflows/sub_workflows/prediction_process.py
index 73f0227..991d262 100644
--- a/laborious/workflows/sub_workflows/prediction_process.py
+++ b/laborious/workflows/sub_workflows/prediction_process.py
@@ -66,7 +66,10 @@ class PredictionProcess:
- 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): OPC server export 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
@@ -207,6 +210,7 @@ class PredictionProcess:
'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'],
@@ -234,7 +238,17 @@ class PredictionProcess:
Args:
data: Input data for processing
path_flag: Path decision from filter (STOP, CONTINUE, REPEAT)
- input_data: Complete workflow input configuration
+ 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
@@ -244,7 +258,7 @@ class PredictionProcess:
Path Handling:
- STOP: Terminates workflow execution
- - CONTINUE: Proceeds with normal processing
+ - CONTINUE: Delegates to FormatAndExportPrediction workflow with current data
- REPEAT: Repeats last prediction if available
"""
metadata = input_data['metadata']
@@ -294,6 +308,7 @@ class PredictionProcess:
'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'],
},
)
diff --git a/pyproject.toml b/pyproject.toml
index 0f912c2..daaeabe 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -123,7 +123,7 @@ markers = [
]
[tool.coverage.run]
-source = ["model_manager"]
+source = ["laborious"]
omit = [
"*/tests/*",
"*/venv/*",
diff --git a/requirements-dev.txt b/requirements-dev.txt
index 56ab376..16a8492 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -13,7 +13,8 @@ types-requests>=2.31.0 # Type stubs for requests
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] # PostgreSQL containers for E2E tests
# Development Tools
ipython>=8.12.0 # Enhanced Python shell
-ipdb>=0.13.13 # IPython debugger
\ No newline at end of file
+ipdb>=0.13.13 # IPython debugger
diff --git a/requirements-light.txt b/requirements-light.txt
index 8831822..55788d0 100644
--- a/requirements-light.txt
+++ b/requirements-light.txt
@@ -3,7 +3,7 @@ psycopg2-binary
sqlalchemy
asyncua
redis
-git+ssh://git@github.com/Aignosi/sientia-dataops-library.git@1.6.1
+git+ssh://git@github.com/Aignosi/sientia-dataops-library.git@1.8.0
prometheus-client
botocore
boto3
diff --git a/requirements.txt b/requirements.txt
index 3921162..c9f111c 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -3,8 +3,8 @@ psycopg2-binary
sqlalchemy
asyncua
redis
-git+ssh://git@github.com/Aignosi/sientia-dataops-library.git@1.6.1
-git+ssh://git@github.com/Aignosi/sientia-mlops-library.git@0.40.6
+git+ssh://git@github.com/Aignosi/sientia-dataops-library.git@1.8.2
+git+ssh://git@github.com/Aignosi/sientia-mlops-library.git@0.40.7
prometheus-client
botocore
boto3
@@ -12,4 +12,5 @@ s3fs
pyarrow
kaleido
hyperopt
-shap
\ No newline at end of file
+shap
+pycurl
\ No newline at end of file
diff --git a/sonar-project.properties b/sonar-project.properties
index b332c70..a2e5f65 100644
--- a/sonar-project.properties
+++ b/sonar-project.properties
@@ -3,7 +3,7 @@ sonar.projectName=sientia-dataops-laborious_temporal
sonar.sources=laborious
sonar.tests=tests
sonar.projectVersion=1.0.0
-sonar.coverage.exclusions=laborious/worker/worker.py
+sonar.coverage.exclusions=laborious/worker/*
sonar.qualitygate.wait=true
sonar.qualitygate.timeout=300
sonar.python.coverage.reportPaths=coverage.xml
diff --git a/tests.ipynb b/tests.ipynb
index b8c1b5c..09a3a9a 100644
--- a/tests.ipynb
+++ b/tests.ipynb
@@ -2,123 +2,10 @@
"cells": [
{
"cell_type": "code",
- "execution_count": 1,
+ "execution_count": null,
"id": "b10e5c25",
"metadata": {},
- "outputs": [
- {
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "[ 0.5479121 -0.12224312 0.71719584 0.39473606 -0.8116453 0.9512447\n",
- " 0.5222794 0.57212861 -0.74377273 -0.09922812 -0.25840395 0.85352998\n",
- " 0.28773024 0.64552323 -0.1131716 -0.54552256 0.10916957 -0.87236549\n",
- " 0.65526234 0.2633288 0.51617548 -0.29094806 0.94139605 0.78624224\n",
- " 0.55676699 -0.61072258 -0.06655799 -0.91239247 -0.69142102 0.36609791\n",
- " 0.48952431 0.93501946 -0.34834928 -0.25908059 -0.06088838 -0.62105728\n",
- " -0.74015699 -0.04859015 -0.5461813 0.33962799 -0.12569616 0.66535639\n",
- " 0.4005302 -0.37526672 0.6645196 0.60952871 -0.22504324 -0.42334379\n",
- " 0.36499101 -0.72049503 -0.6001836 -0.98527546 0.57384876 0.32970171\n",
- " 0.41033076 0.56145806 -0.08216845 0.13748239 -0.720406 -0.77093985\n",
- " 0.33680592 -0.05780759 0.13047221 0.52999771 0.26943664 0.1071588\n",
- " 0.11841432 -0.3920998 -0.93836433 -0.12656522 -0.57083065 -0.18294271\n",
- " 0.70680615 -0.53212103 -0.88339452 -0.43723222 -0.41281248 0.32383303\n",
- " 0.1140643 0.56779642 0.32862708 -0.18722628 0.62804077 -0.66605416\n",
- " -0.95457585 -0.81990428 0.4447187 -0.07624554 -0.67745644 0.00208955\n",
- " -0.69537579 0.39264075 -0.10768745 -0.23795755 -0.39697582 0.26056519\n",
- " -0.27637478 -0.82470016 -0.7639882 0.92379533 0.81716138 0.39941427\n",
- " -0.46826008 0.93835275 0.55750181 0.43378038 -0.101277 -0.45551688\n",
- " -0.80721808 0.80520479 -0.08844742 -0.59527327 -0.38808675 0.15843914\n",
- " -0.64645443 0.71322857 0.51703906 0.43892591 -0.13581392 0.25461768\n",
- " 0.16819594 0.2996932 -0.83111136 -0.1683852 -0.91677165 -0.01201836\n",
- " -0.34027758 -0.71095162 -0.79319406 0.17528914 -0.65881406 0.85024024\n",
- " 0.16212228 -0.30626039 0.18183098 -0.95439226 0.91711843 -0.03539313\n",
- " 0.56547045 -0.83454 -0.02668334 -0.01858601 0.87565291 0.1434561\n",
- " -0.0530212 -0.46604867 -0.33686201 0.0413448 -0.12217708 -0.95677584\n",
- " 0.65258385 0.79232154 -0.71950182 0.10807229 -0.78284852 0.34448019\n",
- " -0.43753243 0.31884527 0.45398923 0.53729498 -0.78451811 0.83202369\n",
- " -0.53957202 -0.92517489 0.10970494 -0.25815543 0.65957949 0.61650294\n",
- " -0.36572221 0.90579879 -0.41816432 0.03011426 -0.48806982 0.87208714\n",
- " -0.67078436 -0.91017876 -0.12980588 0.98475113 0.78335453 0.49721604\n",
- " 0.78158498 0.78689328 0.03771672 -0.3681419 0.54402486 0.32332253\n",
- " -0.25268454 -0.81106666 0.49357922 -0.47507897 0.8736263 -0.51805885\n",
- " -0.75448414 0.66222534 -0.69343137 -0.64146338 0.19876558 0.74912408\n",
- " -0.60713067 -0.37935265 0.55480968 0.94365285 0.00148237 -0.71220499\n",
- " -0.97212742 -0.54068794 -0.73635556 0.35531735 -0.75633499 0.01265986\n",
- " 0.38852487 0.16223322 -0.6004487 0.60824905 0.43081426 0.47796801\n",
- " -0.7378845 -0.75249239 0.8551251 -0.20484361 -0.39810262 -0.02283191\n",
- " 0.32572843 0.91124651 -0.42710755 0.84961686 -0.95028102 0.11039608\n",
- " 0.26795022 -0.78820519 -0.71932081 -0.16177136 0.93246382 0.19208511\n",
- " 0.86604644 0.60872183 -0.0652368 0.5695269 -0.96432643 -0.78171201\n",
- " 0.65885723 0.59363418 -0.53471852 0.06153918 0.21203164 0.73547791\n",
- " 0.20621431 -0.17485686 -0.25163191 -0.14823583 0.30386205 0.73498126\n",
- " -0.09220624 -0.50432087 -0.52667527 0.49202856 0.63313753 -0.78944384\n",
- " -0.86688229 0.18886733 -0.70765351 0.64932838 -0.37933065 -0.71225613\n",
- " 0.84194094 -0.66893655 -0.43055984 -0.69277321 -0.76901987 -0.95770397\n",
- " -0.88920918 -0.65071706 -0.89323613 0.18228763 0.36142905 -0.21273909\n",
- " -0.36401781 0.00905247 0.75000988 0.70226325 -0.91304988 -0.63700318\n",
- " -0.52651026 -0.50122485 0.1424653 -0.16747515 -0.90149176 -0.25277172\n",
- " 0.0475059 -0.79665619 0.66691711 -0.89607627 0.84968374 -0.80177372\n",
- " 0.6871499 0.80530629 0.95914136 0.60405176 0.55895508]\n",
- "[ 0.28496655 0.55799271 -0.73089558 0.07213607 0.02844574 0.71514429\n",
- " -0.07440127 -0.22982101 0.27912654 -0.46707336 -0.72046318 -0.04424545\n",
- " -0.16622126 -0.53486012 -0.26497638 -0.2672151 -0.34500887 -0.24107184\n",
- " 0.37148669 -0.40624705 0.89771585 0.83269604 -0.03817914 -0.34327759\n",
- " 0.07086958 0.69712098 0.30517468 0.60878366 0.06544455 0.26583526\n",
- " -0.42368877 0.46978632 -0.59519081 0.38959626 0.72143814 -0.73579433\n",
- " 0.22875948 -0.8098085 0.45143126 -0.83101356 0.87187965 -0.72518414\n",
- " 0.91776049 0.60176835 0.18736401 0.56524821 0.59022968 0.89205413\n",
- " -0.49323329 0.18015179 -0.8099016 0.2323314 -0.65741739 0.12990122\n",
- " 0.14486103 -0.06802969 0.04526355 0.52784678 0.59848943 -0.01569357\n",
- " 0.19918688 0.86247247 -0.76053282 -0.76579287 -0.82458198 0.31572657\n",
- " -0.1627834 0.54864283 0.34246283 -0.33272448 0.79673309 0.52506429\n",
- " -0.45893012 -0.27161596 -0.37112004 -0.6847767 -0.70443325 0.87225493\n",
- " -0.12419193 -0.23336035 0.45937142 0.10598613 0.87227997 0.56060299\n",
- " -0.04126087 -0.24728105 0.97326309 0.43552047 0.90238932 -0.76304285\n",
- " 0.70106736 0.27414777 -0.75615664 0.176516 0.37219273 -0.97539463\n",
- " -0.09136408 0.65079902 -0.40928195 -0.08290384 -0.11537175 -0.39614522\n",
- " 0.83688379 0.56258807 -0.77882318 0.99406932 0.75840005 -0.43218312\n",
- " 0.67379316 -0.78716094 0.99820946 0.33136947 0.30025003 -0.81911855\n",
- " 0.7940668 -0.94200099 -0.51834388 -0.71395625 0.55353588 -0.60359155\n",
- " 0.82127645 0.31253808 -0.92767458 -0.98914033 -0.89668417 0.21185036\n",
- " 0.60296362 -0.52289436 0.69881769 -0.88553612 0.60192771 0.85559086\n",
- " 0.5442168 0.39624157 0.67596044 -0.9196974 -0.59643578 -0.75015264\n",
- " 0.00906198 0.49037626 0.26002369 0.7022622 -0.68957402 0.46924218\n",
- " -0.61391702 -0.4584825 0.41980939 0.96040957 0.22308721 -0.89099937\n",
- " 0.23261794 -0.9152989 0.76829142 0.41915657 -0.65374431 -0.81655799\n",
- " -0.63293354 0.96005436 -0.08287872 0.5681619 0.27281668 0.1448263\n",
- " -0.70973949 0.89204891 -0.39731473 0.15603443 0.39955189 0.29846631\n",
- " 0.88118882 -0.70312202 0.01670548 -0.19193122 -0.05166254 -0.76156495\n",
- " -0.73181078 -0.44384891 -0.39059079 -0.14419357 0.22197509 0.26925823\n",
- " -0.17637821 -0.18243378 -0.56474295 0.1766125 -0.36591818 -0.92788033\n",
- " -0.16319991 -0.05173465 -0.54881426 0.14491587 0.1315438 0.40400436\n",
- " 0.29589696 0.30486611 -0.3675717 0.57486444 0.09828877 -0.13716361\n",
- " 0.25202496 -0.27868533 0.02547849 0.47341138 0.77280577 0.84211439\n",
- " 0.00726585 0.04055023 0.59974082 -0.37109862 0.67476472 -0.01171671\n",
- " -0.76828655 -0.85588171 0.68398642 -0.88886417 -0.43877713 -0.33173992\n",
- " -0.65401111 -0.37221326 0.48538513 -0.97063431 0.65434685 0.71309605\n",
- " -0.25547685 -0.6927742 0.20168082 -0.76065489 -0.27016128 0.91685836\n",
- " 0.99092895 0.54420978 -0.37807698 0.3753301 0.41081273 -0.22431661\n",
- " 0.28177727 -0.97854471 -0.58188468 0.05017661 -0.67249739 -0.66818626\n",
- " 0.67260858 0.97826601 0.11193886 0.67813946 0.98064333 -0.71680822\n",
- " -0.10350877 -0.21485457 -0.83990143 0.51066035 -0.13244195 -0.06134613\n",
- " -0.69865405 -0.6381467 0.81420724 -0.91070182 -0.53429543 -0.41588134\n",
- " -0.01960492 0.17289035 -0.01342005 -0.83176933 -0.51266509 0.68717677\n",
- " 0.2751774 0.2982981 0.34040651 0.52580604 -0.88378304 -0.26678323\n",
- " 0.07905487 -0.32308703 0.68895775 -0.03485498 0.53725518 0.70403103\n",
- " 0.00958297 0.81910449 0.17424788 0.7005486 -0.31881841 -0.00236608\n",
- " 0.06282208 -0.79004057 -0.20289499 0.83467535 0.26166448 -0.64498684\n",
- " -0.32228873 -0.61679398 -0.95035374 0.85492092 -0.10358534 -0.38492986\n",
- " 0.19695438 -0.98537109 -0.44395579 0.40606693 0.26753955]\n",
- " Counter Rollout CounterPlusRollout Timestamp\n",
- "0 37.270658 -59.474345 -22.203688 2025-01-01 00:00:00\n",
- "1 43.561734 -54.184658 -10.622924 2025-01-01 00:00:01\n",
- "2 42.158150 -43.826926 -1.668776 2025-01-01 00:00:02\n",
- "3 50.392926 -57.394165 -7.001239 2025-01-01 00:00:03\n",
- "4 54.925249 -56.055140 -1.129892 2025-01-01 00:00:04\n"
- ]
- }
- ],
+ "outputs": [],
"source": [
"import pandas as pd\n",
"import numpy as np\n",
@@ -165,41 +52,10 @@
},
{
"cell_type": "code",
- "execution_count": 1,
+ "execution_count": null,
"id": "c61be7ab",
"metadata": {},
- "outputs": [
- {
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "[ 0.5479121 -0.12224312 0.71719584 0.39473606 -0.8116453 0.9512447\n",
- " 0.5222794 0.57212861 -0.74377273 -0.09922812 -0.25840395 0.85352998\n",
- " 0.28773024 0.64552323 -0.1131716 -0.54552256 0.10916957 -0.87236549\n",
- " 0.65526234 0.2633288 0.51617548 -0.29094806 0.94139605 0.78624224\n",
- " 0.55676699 -0.61072258 -0.06655799 -0.91239247 -0.69142102 0.36609791\n",
- " 0.48952431 0.93501946 -0.34834928 -0.25908059 -0.06088838 -0.62105728\n",
- " -0.74015699 -0.04859015 -0.5461813 0.33962799 -0.12569616 0.66535639\n",
- " 0.4005302 -0.37526672 0.6645196 0.60952871 -0.22504324 -0.42334379\n",
- " 0.36499101]\n",
- "[-0.72049503 -0.6001836 -0.98527546 0.57384876 0.32970171 0.41033076\n",
- " 0.56145806 -0.08216845 0.13748239 -0.720406 -0.77093985 0.33680592\n",
- " -0.05780759 0.13047221 0.52999771 0.26943664 0.1071588 0.11841432\n",
- " -0.3920998 -0.93836433 -0.12656522 -0.57083065 -0.18294271 0.70680615\n",
- " -0.53212103 -0.88339452 -0.43723222 -0.41281248 0.32383303 0.1140643\n",
- " 0.56779642 0.32862708 -0.18722628 0.62804077 -0.66605416 -0.95457585\n",
- " -0.81990428 0.4447187 -0.07624554 -0.67745644 0.00208955 -0.69537579\n",
- " 0.39264075 -0.10768745 -0.23795755 -0.39697582 0.26056519 -0.27637478\n",
- " -0.82470016]\n",
- " Counter Rollout Square Timestamp\n",
- "0 -100.000000 100.000000 -37.646304 2025-01-01 00:00:00\n",
- "1 -79.942159 79.589040 -72.994086 2025-01-01 00:00:05\n",
- "2 -84.417207 62.586392 -30.252436 2025-01-01 00:00:10\n",
- "3 -58.162265 34.674447 7.555532 2025-01-01 00:00:15\n",
- "4 -43.711857 50.931053 26.035410 2025-01-01 00:00:20\n"
- ]
- }
- ],
+ "outputs": [],
"source": [
"import pandas as pd\n",
"import numpy as np\n",
@@ -255,133 +111,7 @@
"execution_count": null,
"id": "e7c8eeb1",
"metadata": {},
- "outputs": [
- {
- "data": {
- "text/html": [
- "
\n",
- "\n",
- "
\n",
- " \n",
- " \n",
- " | \n",
- " a | \n",
- " b | \n",
- "
\n",
- " \n",
- " | timestamp | \n",
- " | \n",
- " | \n",
- "
\n",
- " \n",
- " \n",
- " \n",
- " | 2025-01-01 | \n",
- " 1 | \n",
- " 4 | \n",
- "
\n",
- " \n",
- " | 2025-01-02 | \n",
- " 2 | \n",
- " 5 | \n",
- "
\n",
- " \n",
- " | 2025-01-03 | \n",
- " 3 | \n",
- " 6 | \n",
- "
\n",
- " \n",
- "
\n",
- "
"
- ],
- "text/plain": [
- " a b\n",
- "timestamp \n",
- "2025-01-01 1 4\n",
- "2025-01-02 2 5\n",
- "2025-01-03 3 6"
- ]
- },
- "metadata": {},
- "output_type": "display_data"
- },
- {
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "True\n"
- ]
- },
- {
- "data": {
- "text/html": [
- "\n",
- "\n",
- "
\n",
- " \n",
- " \n",
- " | \n",
- " a | \n",
- " b | \n",
- "
\n",
- " \n",
- " | timestamp | \n",
- " | \n",
- " | \n",
- "
\n",
- " \n",
- " \n",
- " \n",
- " | 2025-01-03 | \n",
- " 3 | \n",
- " 6 | \n",
- "
\n",
- " \n",
- "
\n",
- "
"
- ],
- "text/plain": [
- " a b\n",
- "timestamp \n",
- "2025-01-03 3 6"
- ]
- },
- "metadata": {},
- "output_type": "display_data"
- },
- {
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "3\n",
- "1\n"
- ]
- }
- ],
+ "outputs": [],
"source": [
"from pandas import DataFrame\n",
"\n",
@@ -410,16 +140,7 @@
"execution_count": null,
"id": "f3374174",
"metadata": {},
- "outputs": [
- {
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "\n",
- "All elements in index are of the same type\n"
- ]
- }
- ],
+ "outputs": [],
"source": [
"from pandas import DataFrame\n",
"\n",
@@ -453,18 +174,10 @@
},
{
"cell_type": "code",
- "execution_count": 6,
+ "execution_count": null,
"id": "1fbb3788",
"metadata": {},
- "outputs": [
- {
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "test\n"
- ]
- }
- ],
+ "outputs": [],
"source": [
"from unittest.mock import MagicMock\n",
"from asyncua.ua.uaerrors import BadAlreadyExists\n",
@@ -490,78 +203,10 @@
},
{
"cell_type": "code",
- "execution_count": 7,
+ "execution_count": null,
"id": "771ab4ee",
"metadata": {},
- "outputs": [
- {
- "data": {
- "text/html": [
- "\n",
- "\n",
- "
\n",
- " \n",
- " \n",
- " | \n",
- " a | \n",
- " b | \n",
- " target | \n",
- " prediction | \n",
- " timestamp | \n",
- "
\n",
- " \n",
- " \n",
- " \n",
- " | 0 | \n",
- " 1 | \n",
- " 4 | \n",
- " 7 | \n",
- " 1 | \n",
- " 2025-01-01 | \n",
- "
\n",
- " \n",
- " | 1 | \n",
- " 2 | \n",
- " 5 | \n",
- " 8 | \n",
- " 2 | \n",
- " 2025-01-02 | \n",
- "
\n",
- " \n",
- " | 2 | \n",
- " 3 | \n",
- " 6 | \n",
- " 9 | \n",
- " 3 | \n",
- " 2025-01-03 | \n",
- "
\n",
- " \n",
- "
\n",
- "
"
- ],
- "text/plain": [
- " a b target prediction timestamp\n",
- "0 1 4 7 1 2025-01-01\n",
- "1 2 5 8 2 2025-01-02\n",
- "2 3 6 9 3 2025-01-03"
- ]
- },
- "metadata": {},
- "output_type": "display_data"
- }
- ],
+ "outputs": [],
"source": [
"from pandas import DataFrame, merge\n",
"\n",
@@ -591,155 +236,10 @@
},
{
"cell_type": "code",
- "execution_count": 4,
+ "execution_count": null,
"id": "486b95b3",
"metadata": {},
- "outputs": [
- {
- "data": {
- "text/plain": [
- "[]"
- ]
- },
- "metadata": {},
- "output_type": "display_data"
- },
- {
- "data": {
- "text/html": [
- "\n",
- "\n",
- "
\n",
- " \n",
- " \n",
- " | \n",
- " a | \n",
- " b | \n",
- "
\n",
- " \n",
- " \n",
- " \n",
- " | 2025-01-01 | \n",
- " 1 | \n",
- " 4 | \n",
- "
\n",
- " \n",
- " | 2025-01-02 | \n",
- " 2 | \n",
- " 5 | \n",
- "
\n",
- " \n",
- " | 2025-01-03 | \n",
- " 3 | \n",
- " 6 | \n",
- "
\n",
- " \n",
- "
\n",
- "
"
- ],
- "text/plain": [
- " a b\n",
- "2025-01-01 1 4\n",
- "2025-01-02 2 5\n",
- "2025-01-03 3 6"
- ]
- },
- "metadata": {},
- "output_type": "display_data"
- },
- {
- "data": {
- "text/plain": [
- "{'index': ['2025-01-01', '2025-01-02', '2025-01-03'],\n",
- " 'columns': ['a', 'b'],\n",
- " 'data': [[1, 4], [2, 5], [3, 6]]}"
- ]
- },
- "metadata": {},
- "output_type": "display_data"
- },
- {
- "data": {
- "text/html": [
- "\n",
- "\n",
- "
\n",
- " \n",
- " \n",
- " | \n",
- " 0 | \n",
- " 1 | \n",
- " 2 | \n",
- "
\n",
- " \n",
- " \n",
- " \n",
- " | index | \n",
- " 2025-01-01 | \n",
- " 2025-01-02 | \n",
- " 2025-01-03 | \n",
- "
\n",
- " \n",
- " | columns | \n",
- " a | \n",
- " b | \n",
- " None | \n",
- "
\n",
- " \n",
- " | data | \n",
- " [1, 4] | \n",
- " [2, 5] | \n",
- " [3, 6] | \n",
- "
\n",
- " \n",
- "
\n",
- "
"
- ],
- "text/plain": [
- " 0 1 2\n",
- "index 2025-01-01 2025-01-02 2025-01-03\n",
- "columns a b None\n",
- "data [1, 4] [2, 5] [3, 6]"
- ]
- },
- "metadata": {},
- "output_type": "display_data"
- },
- {
- "data": {
- "text/plain": [
- "3"
- ]
- },
- "execution_count": 4,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
+ "outputs": [],
"source": [
"from pandas import DataFrame\n",
"\n",
@@ -764,6 +264,525 @@
"\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": 2,
+ "id": "8ae302f3",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from pandas import DataFrame, to_datetime\n",
+ "from sientia_do.temporal.activities.postgres import Postgres\n",
+ "from unittest.mock import MagicMock, AsyncMock\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": null,
+ "id": "51dd2dbf",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "\n",
+ "period_hours = 1\n",
+ "\n",
+ "samples = 2*60*period_hours\n",
+ "retrain_samples = 2*period_hours\n",
+ "\n",
+ "query_nox = f\"\"\"\n",
+ "select p.prediction, 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 = '4' and variable='CI-W3W01A3' order by p.created_at desc limit {samples};\n",
+ "\"\"\"\n",
+ "\n",
+ "retrain_query = f\"\"\"\n",
+ "select \"timestamp\" from sientia_data.log_retrain lr where model_id = '4' order by lr.\"timestamp\" desc limit {retrain_samples};\n",
+ "\"\"\"\n",
+ "\n",
+ "retrain_data_nox = DataFrame(await postgres.load_custom_query(\n",
+ " {\n",
+ " \"query\": retrain_query,\n",
+ " \"metadata\": {},\n",
+ " }\n",
+ "))\n",
+ "\n",
+ "retrain_data_nox['timestamp'] = to_datetime(retrain_data_nox['timestamp'])\n",
+ "\n",
+ "display(retrain_data_nox)\n",
+ "\n",
+ "data_nox = DataFrame(await postgres.load_custom_query(\n",
+ " {\n",
+ " \"query\": query_nox,\n",
+ " \"metadata\": {},\n",
+ " \"datetime_columns\": [\"timestamp\"],\n",
+ " }\n",
+ "))\n",
+ "\n",
+ "data_nox.drop_duplicates(subset=['timestamp'], inplace=True, keep='first')\n",
+ "data_nox.sort_values(by='timestamp', inplace=True)\n",
+ "data_nox['timestamp'] = to_datetime(data_nox['timestamp'])\n",
+ "\n",
+ "display(data_nox.head(3))\n",
+ "\n",
+ "query_o2 = f\"\"\"\n",
+ "select p.prediction, 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 = '5' and variable='CI-W3W01A2'\n",
+ "and p.\"timestamp\" >= NOW() - INTERVAL {period_hours} HOUR\n",
+ "order by p.created_at;\n",
+ "\"\"\"\n",
+ "\n",
+ "retrain_query_o2 = f\"\"\"\n",
+ "select \"timestamp\" from sientia_data.log_retrain lr where model_id = '5' order by lr.\"timestamp\" desc limit {retrain_samples};\n",
+ "\"\"\"\n",
+ "\n",
+ "retrain_data_o2 = DataFrame(await postgres.load_custom_query(\n",
+ " {\n",
+ " \"query\": retrain_query_o2,\n",
+ " \"metadata\": {},\n",
+ " }\n",
+ "))\n",
+ "\n",
+ "retrain_data_o2['timestamp'] = to_datetime(retrain_data_o2['timestamp'])\n",
+ "\n",
+ "display(retrain_data_o2)\n",
+ "\n",
+ "data_o2 = DataFrame(await postgres.load_custom_query(\n",
+ " {\n",
+ " \"query\": query_o2,\n",
+ " \"metadata\": {},\n",
+ " \"datetime_columns\": [\"timestamp\"],\n",
+ " }\n",
+ "))\n",
+ "\n",
+ "data_o2.drop_duplicates(subset=['timestamp'], inplace=True, keep='first')\n",
+ "data_o2.sort_values(by='timestamp', inplace=True)\n",
+ "data_o2['timestamp'] = to_datetime(data_o2['timestamp'])\n",
+ "\n",
+ "display(data_o2.head(3))\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "7b82e5a7",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "\n",
+ "import matplotlib.pyplot as plt\n",
+ "\n",
+ "plt.figure(figsize=(10, 10))\n",
+ "plt.subplot(2, 1, 1)\n",
+ "plt.plot(data_nox['timestamp'], data_nox['value'])\n",
+ "plt.plot(data_nox['timestamp'], data_nox['prediction'])\n",
+ "plt.vlines(\n",
+ " x=retrain_data_nox['timestamp'],\n",
+ " ymin=plt.ylim()[0],\n",
+ " ymax=plt.ylim()[1],\n",
+ " colors='k',\n",
+ " linestyles='--'\n",
+ ")\n",
+ "\n",
+ "\n",
+ "plt.legend(['real', 'prediction', 'retrain'])\n",
+ "plt.title('NOx')\n",
+ "plt.xlim(\n",
+ " data_nox['timestamp'].min(),\n",
+ " data_nox['timestamp'].max()\n",
+ ")\n",
+ "\n",
+ "plt.subplot(2, 1, 2)\n",
+ "plt.plot(data_o2['timestamp'], data_o2['value'])\n",
+ "plt.plot(data_o2['timestamp'], data_o2['prediction'])\n",
+ "plt.vlines(\n",
+ " x=retrain_data_o2['timestamp'],\n",
+ " ymin=plt.ylim()[0],\n",
+ " ymax=plt.ylim()[1],\n",
+ " colors='k',\n",
+ " linestyles='--'\n",
+ ")\n",
+ "\n",
+ "plt.xlim(\n",
+ " data_o2['timestamp'].min(),\n",
+ " data_o2['timestamp'].max()\n",
+ ")\n",
+ "\n",
+ "plt.legend(['real', 'prediction', 'retrain'])\n",
+ "plt.title('O2')\n",
+ "plt.show()\n",
+ "\n",
+ "\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "id": "6bbb8cf7",
+ "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"
+ ]
+ },
+ {
+ "data": {
+ "text/html": [
+ "\n",
+ "\n",
+ "
\n",
+ " \n",
+ " \n",
+ " | \n",
+ " prediction | \n",
+ " value | \n",
+ " timestamp | \n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " | 0 | \n",
+ " 0.0 | \n",
+ " 495.39563 | \n",
+ " 2026-01-22 12:01:59+0000 | \n",
+ "
\n",
+ " \n",
+ " | 1 | \n",
+ " 0.0 | \n",
+ " 495.39563 | \n",
+ " 2026-01-22 12:01:59+0000 | \n",
+ "
\n",
+ " \n",
+ " | 2 | \n",
+ " 0.0 | \n",
+ " 495.39563 | \n",
+ " 2026-01-22 12:01:59+0000 | \n",
+ "
\n",
+ " \n",
+ "
\n",
+ "
"
+ ],
+ "text/plain": [
+ " prediction value timestamp\n",
+ "0 0.0 495.39563 2026-01-22 12:01:59+0000\n",
+ "1 0.0 495.39563 2026-01-22 12:01:59+0000\n",
+ "2 0.0 495.39563 2026-01-22 12:01:59+0000"
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ },
+ {
+ "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"
+ ]
+ },
+ {
+ "data": {
+ "text/html": [
+ "\n",
+ "\n",
+ "
\n",
+ " \n",
+ " \n",
+ " | \n",
+ " prediction | \n",
+ " value | \n",
+ " timestamp | \n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " | 0 | \n",
+ " 0.0 | \n",
+ " 371.887146 | \n",
+ " 2026-01-22 12:32:00+0000 | \n",
+ "
\n",
+ " \n",
+ " | 1 | \n",
+ " 0.0 | \n",
+ " 386.016541 | \n",
+ " 2026-01-22 12:49:00+0000 | \n",
+ "
\n",
+ " \n",
+ " | 2 | \n",
+ " 0.0 | \n",
+ " 423.022034 | \n",
+ " 2026-01-22 13:02:58+0000 | \n",
+ "
\n",
+ " \n",
+ "
\n",
+ "
"
+ ],
+ "text/plain": [
+ " prediction value timestamp\n",
+ "0 0.0 371.887146 2026-01-22 12:32:00+0000\n",
+ "1 0.0 386.016541 2026-01-22 12:49:00+0000\n",
+ "2 0.0 423.022034 2026-01-22 13:02:58+0000"
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "from pandas import DataFrame, to_datetime\n",
+ "\n",
+ "period_hours = 24\n",
+ "\n",
+ "samples = 2*60*period_hours\n",
+ "retrain_samples = 2*period_hours\n",
+ "\n",
+ "query_nox = f\"\"\"\n",
+ "select p.prediction, 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 = '4' and variable='CI-W3W01A3'\n",
+ "and ld.model_id = '4' and p.\"timestamp\" >= NOW() - INTERVAL '{period_hours} HOUR'\n",
+ "order by p.created_at;\n",
+ "\"\"\"\n",
+ "\n",
+ "data_nox = DataFrame(await postgres.load_custom_query(\n",
+ " {\n",
+ " \"query\": query_nox,\n",
+ " \"metadata\": {},\n",
+ " \"datetime_columns\": [\"timestamp\"],\n",
+ " }\n",
+ "))\n",
+ "\n",
+ "display(data_nox.head(3))\n",
+ "\n",
+ "data_nox.sort_values(by='timestamp', inplace=True)\n",
+ "\n",
+ "data_nox.drop_duplicates(subset=['timestamp'], inplace=True, keep='first')\n",
+ "\n",
+ "data_nox['timestamp'] = to_datetime(data_nox['timestamp'])\n",
+ "\n",
+ "query_nox_extra = f\"\"\"\n",
+ "select p.prediction, 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 = '7' and variable='CI-W3W01A3'\n",
+ "and ld.model_id = '4' and p.\"timestamp\" >= NOW() - INTERVAL '{period_hours} HOUR'\n",
+ "order by p.created_at;\n",
+ "\"\"\"\n",
+ "\n",
+ "data_nox_extra = DataFrame(await postgres.load_custom_query(\n",
+ " {\n",
+ " \"query\": query_nox_extra,\n",
+ " \"metadata\": {},\n",
+ " \"datetime_columns\": [\"timestamp\"],\n",
+ " }\n",
+ "))\n",
+ "\n",
+ "display(data_nox_extra.head(3))\n",
+ "\n",
+ "data_nox_extra.sort_values(by='timestamp', inplace=True)\n",
+ "\n",
+ "data_nox_extra.drop_duplicates(subset=['timestamp'], inplace=True, keep='first')\n",
+ "\n",
+ "data_nox_extra['timestamp'] = to_datetime(data_nox_extra['timestamp'])\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "id": "46340afd",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "image/png": "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X1SpVOKqVav8f6OCKBLPweLFi0WNRiO+8cYb4uHDh8X169eLvXv3Fvv27ev/GxVE9e0crFmzRoyNjRWnT58ue4y0L3q93nZeMjIyxH/961/izp07xYMHD9by3QqOUJ6DefPmid9++6144MAB8cCBA+Lbb78tJiQkiI899pjL4xYXF4tpaWnimDFjxL1794qffPKJU2nhDRs2iBqNRpw9e7b4+++/i08++WTYl4yPtHMwc+ZM8ccffxQPHz4s5ufni7NnzxY1Go341ltv1fLdCrxIfP+PHTsmJiQkiFOmTBH3798vLlu2TExNTRWfe+65Wr5bwRGJ52Dz5s3il19+KR4+fFj8+eefxSFDhoitWrUK2wrP9e0ciKLnayJ/r7UcMeiKAK+//rrYokULUafTiX379hU3b94s2//kk0+KAJy+Fi9e7PKYrh7TsmVLW5tBgwYpthk3bpzb/rZs2VLxcVYvvvii2KZNGzE6Olps3LixOHjwYHHNmjX+vDV1JtLOgShaSsR36tRJjImJETMyMsTRo0eLJ06c8PWtqTP16RyMGzdO8TGDBg2ytTly5IjHNuEmVOfgtddeEzt37izGxsaKiYmJYo8ePcQ33njD49p+u3fvFi+77DIxKipKbNasmfjCCy84tfnss8/E9u3bizqdTuzcubP43Xff+fSe1LVIOwePPfaY2LZtWzE6Olps1KiRmJubK37yySc+vy91JdLef1EUxY0bN4r9+vUTo6KixNatW4vPP/+8aDQafXpf6lKknYN169aJHTt2FKOiosSUlBRxzJgx4smTJ31+X+pSfTsHnq6J/L3WciSIYpiNFSIiIiIiIoognNNFREREREQURAy6iIiIiIiIgohBFxERERERURAx6CIiIiIiIgoiBl1ERERERERBxKCLiIiIiIgoiBh0ERERERERBRGDLiIiIiIioiBi0EVERERERBREDLqIiIiIiIiCiEEXERERERFREDHoIiIiIiIiCiIGXUREREREREHEoIuIiIiIiCiIGHQREREREREFEYMuIiIiIiKiIGLQRUREREREFEQMuoiIiIiIiIKIQRcREREREVEQMegiIiIiIiIKIgZdREREREREQcSgi4iIiIiIKIgYdBEREREREQURgy4iIiIiIqIgYtBFREREREQURAy6iIiIiIiIgohBFxERERERURAx6CIiigBLliyBIAiIjo7GyZMnnfYPHjwYXbp0cdpuMBjw2muvoU+fPkhISEB8fDz69OmD1157DQaDIaB9HDx4MARBUPzKycnx+Xjff/89nnrqqYD20aqyshJPPfUU1q1bF5TjExFRw6IJdQeIiChw9Ho9XnjhBbz++use21ZUVGD48OH46aefcN1112H8+PFQqVRYvnw57r//fixduhTfffcd4uLiAta/5s2bY9asWU7bk5KSfD7W999/j/nz5wcl8KqsrMTTTz8NwBIsEhER1QaDLiKiCNK9e3e89dZbmD59OjIzM922nTZtGn766Se8/vrrmDJlim37Pffcg/nz52PKlCn417/+hQULFgSsf0lJSbjjjjsCdjxvGY1GmM1m6HS6oBy/oqIioMEpERFFFg4vJCKKIP/3f/8Hk8mEF154wW27EydO4J133sGQIUNkAZfV5MmTccUVV+Dtt9/GiRMnAACLFy+GIAhYtGiRrO3MmTMhCAK+//77Wvf/4sWLyMnJQU5ODi5evGjbfv78eWRkZODSSy+FyWTC+PHjMX/+fACQDVMEgKNHj0IQBMyePRtz585FmzZtEBUVhfz8fFRXV+OJJ55Ar169kJSUhLi4OAwcOBBr1661PdfRo0fRtGlTAMDTTz9tO7Y1ozZ+/HjEx8fj8OHDuPbaa5GQkIDRo0cDAMxmM+bOnYvOnTsjOjoaaWlpuPvuu3HhwgWn1/rDDz9g4MCBiIuLQ0JCAoYPH459+/bJ2hQUFGDChAlo3rw5oqKikJGRgeuvvx5Hjx6t9XtNRER1h5kuIqII0qpVK4wdOxZvvfUWHn30UZfZrh9++AEmkwljx451eayxY8di7dq1WL58OSZOnIgJEyZg6dKlmDZtGq666ipkZWVhz549ePrpp3HnnXfi2muv9dg/k8mEs2fPOm2PiYlBXFwcYmJi8O6772LAgAF47LHH8MorrwCwBIElJSVYsmQJ1Go17r77bpw6dQorV67E+++/r/hcixcvRlVVFSZNmoSoqCg0btwYpaWlePvttzFq1CjcddddKCsrwzvvvIO8vDxs3boV3bt3R9OmTbFgwQLcc889uPHGG3HTTTcBALp162Y7ttFoRF5eHi677DLMnj0bsbGxAIC7774bS5YswYQJEzB16lQcOXIE8+bNw86dO7FhwwZotVoAwPvvv49x48YhLy8PL774IiorK7FgwQJcdtll2LlzJ7KzswEAI0eOxL59+3DfffchOzsbRUVFWLlyJY4dO2ZrQ0RE9YBIRET13uLFi0UA4rZt28TDhw+LGo1GnDp1qm3/oEGDxM6dO9t+fuCBB0QA4s6dO10e89dffxUBiNOmTbNtO336tNi4cWPxqquuEvV6vdijRw+xRYsWYklJicc+Dho0SASg+HX33XfL2k6fPl1UqVTizz//LH7++eciAHHu3LmyNpMnTxaV/owdOXJEBCAmJiaKRUVFsn1Go1HU6/WybRcuXBDT0tLEf/zjH7ZtZ86cEQGITz75pNPxx40bJwIQH330Udn2X375RQQgfvjhh7Lty5cvl20vKysTk5OTxbvuukvWrqCgQExKSrJtv3DhgghA/M9//uPUByIiql+Y6SIiijCtW7fGmDFj8Oabb+LRRx9FRkaGU5uysjIAQEJCgsvjWPeVlpbatqWnp2P+/PkYNWoUBg4ciF27dmHlypVITEz0qm/Z2dl46623nLY3b95c9vNTTz2FZcuWYdy4cSgvL8egQYMwdepUr57DauTIkbZhglZqtRpqtRqAZShgcXExzGYzevfujV9//dWn499zzz2ynz///HMkJSXhqquukmXzevXqhfj4eKxduxZ///vfsXLlShQXF2PUqFGydmq1Gv369bMNdYyJiYFOp8O6detw5513olGjRj71j4iIwgeDLiKiCDRjxgy8//77eOGFF/Dqq6867bcGVNbgS4mrwOz222/HBx98gO+++w6TJk3ClVde6XW/4uLiMHToUI/tdDodFi1ahD59+iA6Oto2n8wXrVq1Utz+7rvv4uWXX8Yff/whK4vvqr0SjUbjFCgePHgQJSUlSE1NVXxMUVGRrR0ADBkyRLGdNYCNiorCiy++iIceeghpaWno378/rrvuOowdOxbp6ele95WIiEKPQRcRUQRq3bo17rjjDlu2y1HHjh0BAL/99hu6d++ueIzffvsNANCpUyfZ9nPnzmH79u0AgPz8fJjNZqhUga/LtGLFCgBAVVUVDh486FNQBFgyRY4++OADjB8/HjfccAMefvhhpKamQq1WY9asWTh8+LDXx46KinJ6zWazGampqfjwww8VH2PNupnNZgCWeV1KwZNGY//T/MADD2DEiBH4+uuvsXGk2ooAALypSURBVGLFCjz++OOYNWsW1qxZgx49enjd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eOihh/CnP/0JAFBaWorMzEzMmzcP1113HXbt2oW8vDysX78eQ4YMAQAsWrQIEyZMwPHjx5GTk+PxecvKypCUlITS0lIkJib6e/hERERERBTloiE2COqcrkOHDqGgoADjxo2TbktKSsKwYcOwevVqAMDq1auRnJwsBVwAMG7cOOj1eqxdu1Z1v7W1tSgrK3P6j4iIiIiIKBoENegqKCgAAGRmZjrdnpmZKd1XUFCAjIwMp/uNRiNSU1OlbZRmzpyJpKQk6b/c3NxgHjYREREREVHIREX3whkzZqC0tFT679ixY+E+JCIiIiIiIq8ENejKysoCABQWFjrdXlhYKN2XlZWFoqIip/sbGhpw9uxZaRsli8WCxMREp/+IiIiIiIiiQVCDrk6dOiErKwtLliyRbisrK8PatWuRn58PAMjPz0dJSQk2btwobbN06VLYbDYMGzYsmIdDREREREQUdkZfH1BRUYH9+/dLPx86dAhbtmxBamoq2rdvjwceeADPPPMMunXrhk6dOuGvf/0rcnJypA6HvXr1wqWXXoo77rgDc+fORX19Pe655x5cd911XnUuJCIiIiIiiiY+B10bNmzAhRdeKP08ffp0AMDNN9+MefPm4ZFHHkFlZSXuvPNOlJSU4Pzzz8eiRYtgtVqlx3z88ce45557cNFFF0Gv1+Oaa67BK6+8EoRfh4iIiIiIKLIEtE5XuERDL34iIiIiIgq9aIgNoqJ7IRERERERUbRi0EVERERERBRCDLqIiIiIiIhCiEEXERERERFRCDHoIiIiIiIiCiEGXURERERERCHEoIuIiIiIiCiEGHQRERERERGFEIMuIiIiIiKiEGLQRUREREREFEIMuoiIiIiIiEKIQRcREREREVEIMegiIiIiIiIKIQZdREREREREIcSgi4iIiIiIKIQYdBEREREREYUQgy4iIiIiIqIQYtBFREREREQUQgy6iIiIiIiIQohBFxERERERUQgx6CIiIiIiIgohBl1EREREREQhxKCLiIiIiIgohBh0ERERERERhRCDLiIiIiIiohBi0EVERERERBRCDLqIiIiIiIhCiEEXERERERFRCDHoIiIiIiIiCiEGXURERERERCHEoIuIiIiIiCiEGHQRERERERGFEIMuIiIiIiKiEGLQRUREREREFEIMuoiIiIiIiEKIQRcREREREVEIMegiIiIiIiIKIQZdREREREREIcSgi4iIiIiIKITCGnTNmTMHHTt2hNVqxbBhw7Bu3bpwHg4REREREVHQhS3o+uyzzzB9+nQ88cQT2LRpE/r374/x48ejqKgoXIdEREREREQUdGELul566SXccccdmDp1KvLy8jB37lzExsbivffeC9chERERERERBZ0xHE9aV1eHjRs3YsaMGdJter0e48aNw+rVq122r62tRW1trfRzWVmZ/R9f3AHEmkN+vEREREREFKGq6sJ9BB6FJeg6ffo0GhsbkZmZ6XR7ZmYmdu/e7bL9zJkz8dRTT7nuaO93gEUXqsMkIiIiIqJIVyuE+wg8CkvQ5asZM2Zg+vTp0s9lZWXIzc0FLnkWiI8J45EREREREVFYVVQDz90X7qNwKyxBV5s2bWAwGFBYWOh0e2FhIbKysly2t1gssFgsrjsafDOQmBiqwyQiIiIiokhXVgYgsoOusDTSMJvNGDx4MJYsWSLdZrPZsGTJEuTn54fjkIiIiIiIiEIibOWF06dPx80334whQ4Zg6NChmD17NiorKzF16tRwHRIREREREVHQhS3ouvbaa1FcXIzHH38cBQUFGDBgABYtWuTSXIOIiIiIiCia6QRBiPx2HwplZWVISkpCaWkpEjmni4iIiIio1YqG2CBsiyMTERERERG1Bgy6iIiIiIiIQohBFxERERERUQgx6CIiIiIiIgohBl1EREREREQhxKCLiIiIiIgohBh0ERERERERhRCDLiIiIiIiohAyhvsA/CGu51xWVhbmIyEiIiIionASYwIxRohEURl0lZeXAwByc3PDfCRERERERBQJysvLkZSUFO7DUKUTIjkk1GCz2XDy5EkkJCRAp9OF+3CiUllZGXJzc3Hs2DEkJiaG+3BaNZ6LyMFzETl4LiIHz0Xk4LmIHDwXkUM8Fzt37kSPHj2g10fm7KmozHTp9Xq0a9cu3IfRIiQmJvLDIkLwXEQOnovIwXMROXguIgfPReTguYgcbdu2jdiAC2AjDSIiIiIiopBi0EVERERERBRCDLpaKYvFgieeeAIWiyXch9Lq8VxEDp6LyMFzETl4LiIHz0Xk4LmIHNFyLqKykQYREREREVG0YKaLiIiIiIgohBh0ERERERERhRCDLiIiIiIiohBi0EVERERERBRCDLoixJw5c9CxY0dYrVYMGzYM69atc7r/rbfe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",
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "import matplotlib.pyplot as plt\n",
+ "\n",
+ "plt.figure(figsize=(10, 10))\n",
+ "\n",
+ "plt.subplot(2, 1, 1)\n",
+ "\n",
+ "plt.plot(data_nox['timestamp'], data_nox['value'])\n",
+ "plt.plot(data_nox['timestamp'], data_nox['prediction'])\n",
+ "plt.legend(['real', 'prediction'])#, 'retrain'])\n",
+ "plt.title('NOx')\n",
+ "plt.xlim(\n",
+ " data_nox['timestamp'].min(),\n",
+ " data_nox['timestamp'].max()\n",
+ ")\n",
+ "\n",
+ "plt.subplot(2, 1, 2)\n",
+ "\n",
+ "plt.plot(data_nox_extra['timestamp'], data_nox_extra['value'])\n",
+ "plt.plot(data_nox_extra['timestamp'], data_nox_extra['prediction'])\n",
+ "plt.legend(['real', 'prediction'])#, 'retrain'])\n",
+ "plt.title('NOx Extratrees')\n",
+ "plt.xlim(\n",
+ " data_nox_extra['timestamp'].min(),\n",
+ " data_nox_extra['timestamp'].max()\n",
+ ")\n",
+ "\n",
+ "plt.show()\n"
+ ]
}
],
"metadata": {
@@ -782,7 +801,7 @@
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
- "version": "3.11.13"
+ "version": "3.11.14"
}
},
"nbformat": 4,
diff --git a/tests/laborious/activities/test_activities.py b/tests/laborious/activities/test_activities.py
index e0c3ec9..5c979e9 100644
--- a/tests/laborious/activities/test_activities.py
+++ b/tests/laborious/activities/test_activities.py
@@ -3,8 +3,10 @@ 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
@@ -13,9 +15,17 @@ from laborious.activities.storage import Storage
@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.MetricsController')
def test___init__(
- mock_metrics_controller, mock_gates_init, mock_opc_init, mock_mlflow_init, mock_storage_init
+ mock_metrics_controller,
+ mock_api_init,
+ mock_model_metrics_init,
+ mock_gates_init,
+ mock_opc_init,
+ mock_mlflow_init,
+ mock_storage_init,
):
postgres_config = {
'host': 'localhost',
@@ -43,6 +53,12 @@ def test___init__(
'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()
@@ -51,6 +67,7 @@ def test___init__(
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,
)
@@ -60,6 +77,8 @@ def test___init__(
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,
@@ -103,13 +122,39 @@ def test___init__(
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,
+ )
+
@mark.asyncio
@patch('laborious.activities.activities.Storage')
@patch('laborious.activities.activities.MLFlow')
@patch('laborious.activities.activities.OPC')
@patch('laborious.activities.activities.Gates')
-async def test_shutdown(mock_gates_init, mock_opc_init, mock_mlflow_init, mock_storage_init):
+@patch('laborious.activities.activities.ModelMetrics')
+@patch('laborious.activities.activities.API')
+async def test_shutdown(
+ 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',
@@ -137,6 +182,12 @@ async def test_shutdown(mock_gates_init, mock_opc_init, mock_mlflow_init, mock_s
'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()
@@ -145,6 +196,7 @@ async def test_shutdown(mock_gates_init, mock_opc_init, mock_mlflow_init, mock_s
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,
)
@@ -154,3 +206,5 @@ async def test_shutdown(mock_gates_init, mock_opc_init, mock_mlflow_init, mock_s
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..52c8120
--- /dev/null
+++ b/tests/laborious/activities/test_api.py
@@ -0,0 +1,422 @@
+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'},
+ },
+ }
+
+
+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',
+ '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',
+ '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',
+ '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',
+ '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',
+ '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',
+ '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/utils/repository/test_model_repository.py b/tests/laborious/utils/repository/test_model_repository.py
index 466fae5..5311a22 100644
--- a/tests/laborious/utils/repository/test_model_repository.py
+++ b/tests/laborious/utils/repository/test_model_repository.py
@@ -566,6 +566,12 @@ async def test_download_model_transform(mlflow_repository):
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}},
@@ -812,7 +818,14 @@ async def test_fit_models_not_df_target_name_none_and_not_in_model(
data = MagicMock()
output = await mlflow_repository.fit_models(
- 'model_name', data, 'latest_production_id', metadata['metadata'], 'sklearn', 'pyfunc', None
+ 'model_name',
+ data,
+ 'latest_production_id',
+ metadata['metadata'],
+ 'sklearn',
+ False,
+ 'pyfunc',
+ None,
)
mlflow_repository.download_model.assert_has_calls(
@@ -908,6 +921,7 @@ async def test_fit_models_df_target_name_not_none_and_in_model(
'latest_production_id',
metadata['metadata'],
'sklearn',
+ False,
'pyfunc',
'feat_1',
)
@@ -1425,6 +1439,7 @@ async def test_retrain_model(mlflow_repository):
model_name=model_name,
data=data,
transform_flavor='sklearn',
+ skip_transform=False,
predict_flavor='pyfunc',
target_name='target',
metadata=metadata['metadata'],
diff --git a/tests/laborious/utils/test_connectors_config.py b/tests/laborious/utils/test_connectors_config.py
index 80516ad..80eb19f 100644
--- a/tests/laborious/utils/test_connectors_config.py
+++ b/tests/laborious/utils/test_connectors_config.py
@@ -3,9 +3,7 @@ from os import environ
from laborious.utils.connectors_config import (
build_minio_config,
build_mlflow_config,
- build_mongodb_config,
build_opc_config,
- build_postgres_config,
)
@@ -92,79 +90,6 @@ def test_build_opc_config_with_defaults():
assert config['1']['reconnection_interval'] == 120
-def test_build_postgres_config_with_env_vars():
- # Arrange
- environ['POSTGRES_HOST'] = 'test-host'
- environ['POSTGRES_PORT'] = '5433'
- environ['POSTGRES_USER'] = 'test-user'
- environ['POSTGRES_PASSWORD'] = 'test-pass'
- environ['POSTGRES_DBNAME'] = 'test-db'
- environ['POSTGRES_MIN_CONNECTIONS'] = '10'
- environ['POSTGRES_MAX_CONNECTIONS'] = '30'
-
- # Act
- config = build_postgres_config()
-
- # Assert
- assert config['host'] == 'test-host'
- assert config['port'] == 5433
- assert config['user'] == 'test-user'
- assert config['password'] == 'test-pass'
- assert config['dbname'] == 'test-db'
- assert config['min_connections'] == 10
- assert config['max_connections'] == 30
-
-
-def test_build_postgres_config_with_defaults():
- # Arrange
- environ.pop('POSTGRES_HOST', None)
- environ.pop('POSTGRES_PORT', None)
- environ.pop('POSTGRES_USER', None)
- environ.pop('POSTGRES_PASSWORD', None)
- environ.pop('POSTGRES_DBNAME', None)
- environ.pop('POSTGRES_MIN_CONNECTIONS', None)
- environ.pop('POSTGRES_MAX_CONNECTIONS', None)
-
- # Act
- config = build_postgres_config()
-
- # Assert
- assert config['host'] == 'localhost'
- assert config['port'] == 5432
- assert config['user'] == 'sientia'
- assert config['password'] == 'sientia'
- assert config['dbname'] == 'sientia'
- assert config['min_connections'] == 5
- assert config['max_connections'] == 20
-
-
-def test_build_mongo_db_config_with_env_vars():
- environ['MONGODB_USERNAME'] = 'sientia1'
- environ['MONGODB_PASSWORD'] = 'sientia1'
- environ['MONGODB_URL'] = 'localhost:27018'
- environ['MONGODB_DATABASE_NAME'] = 'test_db'
- environ['MONGODB_TTL_INDEX_HOURS'] = '1'
-
- assert build_mongodb_config() == {
- 'connection_string': 'mongodb://sientia1:sientia1@localhost:27018',
- 'database_name': 'test_db',
- 'ttl_index_seconds': 3600,
- }
-
-
-def test_build_mongo_db_config_with_defaults():
- environ.pop('MONGODB_USERNAME', None)
- environ.pop('MONGODB_PASSWORD', None)
- environ.pop('MONGODB_DATABASE_NAME', None)
- environ.pop('MONGODB_URL', None)
- environ.pop('MONGODB_TTL_INDEX_HOURS', None)
- assert build_mongodb_config() == {
- 'connection_string': 'mongodb://root:wKZDbMNU1c@localhost:27018',
- 'database_name': 'sientia',
- 'ttl_index_seconds': 3600,
- }
-
-
def test_build_minio_config_with_env_vars():
environ['MINIO_ENDPOINT_URL'] = 'http://test-host'
environ['MINIO_ACCESS_KEY'] = 'test-key'
diff --git a/tests/laborious/workflows/subworkflows/test_format_and_export_prediction.py b/tests/laborious/workflows/subworkflows/test_format_and_export_prediction.py
index f5619f3..dd95ee9 100644
--- a/tests/laborious/workflows/subworkflows/test_format_and_export_prediction.py
+++ b/tests/laborious/workflows/subworkflows/test_format_and_export_prediction.py
@@ -375,3 +375,283 @@ async def test_run_default_path_flag(workflow_mock, format_and_export_prediction
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,
+ '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,
+ {
+ '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'],
+ **metadata,
+ },
+ 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,
+ {
+ 'schema': input_data['schema'],
+ 'table_name': input_data['table_name'],
+ 'data': pi_web_api_data,
+ 'timestamp_conversion': {
+ 'column': 'timestamp',
+ 'format': DATETIME_FORMAT_WITH_TZ,
+ },
+ **metadata,
+ },
+ 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,
+ '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,
+ {
+ 'schema': input_data['schema'],
+ 'table_name': input_data['table_name'],
+ 'data': prediction_data,
+ 'timestamp_conversion': {
+ 'column': 'timestamp',
+ 'format': DATETIME_FORMAT_WITH_TZ,
+ },
+ **metadata,
+ },
+ 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,
+ '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,
+ {
+ 'schema': input_data['schema'],
+ 'table_name': input_data['table_name'],
+ 'data': pi_web_api_data,
+ 'timestamp_conversion': {
+ 'column': 'timestamp',
+ 'format': DATETIME_FORMAT_WITH_TZ,
+ },
+ **metadata,
+ },
+ 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
index 6581033..22df274 100644
--- a/tests/laborious/workflows/subworkflows/test_prediction_process.py
+++ b/tests/laborious/workflows/subworkflows/test_prediction_process.py
@@ -40,6 +40,7 @@ async def test_run(workflow_mock, prediction_process):
'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',
}
@@ -183,6 +184,7 @@ async def test_run(workflow_mock, prediction_process):
'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'],
@@ -729,6 +731,11 @@ async def test_path_flag_handler_continue(workflow_mock, prediction_process):
'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,
@@ -755,6 +762,11 @@ async def test_path_flag_handler_continue(workflow_mock, prediction_process):
'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,
},
)
@@ -788,6 +800,11 @@ async def test_path_flag_handler_unknown(workflow_mock, prediction_process):
'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,
diff --git a/tests/laborious/workflows/test_predictions_batch.py b/tests/laborious/workflows/test_predictions_batch.py
index 6513da5..c19d6ef 100644
--- a/tests/laborious/workflows/test_predictions_batch.py
+++ b/tests/laborious/workflows/test_predictions_batch.py
@@ -34,6 +34,7 @@ async def test_run(workflow_mock: AsyncMock, predictions_batch: PredictionsBatch
'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'},
@@ -73,7 +74,9 @@ async def test_run(workflow_mock: AsyncMock, predictions_batch: PredictionsBatch
'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', {}),
+ '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(
diff --git a/validate.sh b/validate.sh
index 87a8df8..6c72694 100755
--- a/validate.sh
+++ b/validate.sh
@@ -70,14 +70,14 @@ FAILED_STEPS=()
# Step 1: Code Formatting Check (Ruff)
# - default: check only
# - --fix: write changes
-if ! run_step "1. Code Formatting (Ruff)" "if \$FIX_MODE; then ruff format laborious/ tests/; else ruff format --check laborious/ tests/; fi"; then
+if ! run_step "1. Code Formatting (Ruff)" "if \$FIX_MODE; then ruff format laborious/ tests/; else ruff format --check laborious/ tests/ e2e/; fi"; then
FAILED_STEPS+=("Code Formatting")
fi
# Step 2: Linting (Ruff)
# - default: check only
# - --fix: apply autofixes
-if ! run_step "2. Code Linting (Ruff)" "if \$FIX_MODE; then ruff check --fix laborious/ tests/; else ruff check laborious/ tests/; fi"; then
+if ! run_step "2. Code Linting (Ruff)" "if \$FIX_MODE; then ruff check --fix laborious/ tests/; else ruff check laborious/ tests/ e2e/; fi"; then
FAILED_STEPS+=("Linting")
fi
diff --git a/values.yaml b/values.yaml
index a41347c..4c1c7a8 100644
--- a/values.yaml
+++ b/values.yaml
@@ -3,17 +3,17 @@
# 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: 2
+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-courier
+ 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"
-0# 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/
+# 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.
@@ -62,24 +62,32 @@ resources:
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
- - pgrep -f "laborious.worker.worker"
- initialDelaySeconds: 20
- periodSeconds: 30
+ - |
+ curl -sf http://localhost:9090/metrics | grep -q '^app_up{.*} 1'
+ initialDelaySeconds: 660
+ periodSeconds: 15
+ timeoutSeconds: 5
+ failureThreshold: 3
readinessProbe:
exec:
command:
- sh
- -c
- - pgrep -f "laborious.worker.worker"
- initialDelaySeconds: 10
- periodSeconds: 15
+ - |
+ curl -sf http://localhost:9090/metrics | grep -q '^app_up{.*} 1'
+ initialDelaySeconds: 600
+ 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/
@@ -149,7 +157,7 @@ env:
- name: GITHUB_REPO_URL
value: "git@github.com:Aignosi/sientia-dataops-laborious_temporal.git"
- name: GITHUB_BRANCH
- value: "feature/SIENTIAPDE-1273"
+ value: "feature/SIENTIAPDE-1478"
- name: PYTHON_APP
value: "laborious.worker.worker"
@@ -187,8 +195,6 @@ env:
- name: OPC_URL
value: "opc.tcp://sientia-opc-simulator-opc.sientia.svc.cluster.local:4840"
- - name: KAFKA_BOOTSTRAP_SERVERS
- value: "kafka.kafka.svc.cluster.local:9092"
- name: LOG_LEVEL
value: "DEBUG"
@@ -226,6 +232,19 @@ env:
- name: MINIO_DEFAULT_BUCKET
value: "sientia"
+ - 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