SIENTIAPDE-1646 Sync laborious/ from local release/SIENTIAPDE-1646 (272e02d)
This commit is contained in:
@@ -1,99 +1,177 @@
|
|||||||
from temporalio import activity, workflow
|
from sientia_do.observability.metrics_controller import MetricsController
|
||||||
|
from temporalio import workflow
|
||||||
|
|
||||||
with workflow.unsafe.imports_passed_through():
|
with workflow.unsafe.imports_passed_through():
|
||||||
from sientia_do.temporal.activities.postgres import Postgres
|
|
||||||
from sientia_do.notifications.handlers import CoreNotificationHandler as NotificationHandler
|
|
||||||
from sientia_do.observability.logger import Logger
|
|
||||||
from laborious.activities.mlflow import MLFlow
|
|
||||||
from laborious.activities.gates import Gates
|
|
||||||
from laborious.activities.opc import OPC
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from sientia_do.notifications.handlers import CoreNotificationHandler as NotificationHandler
|
||||||
|
from sientia_do.observability.logger import Logger
|
||||||
|
from sientia_do.repository.minio_repository_sync import MinioRepository
|
||||||
|
from sientia_model.model_repository.mlflow_repository import SientiaMLflowRepository
|
||||||
|
from sientia_model.model_repository.plugin_store import PluginStore
|
||||||
|
|
||||||
class Activities(Postgres, MLFlow, Gates, OPC):
|
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
|
||||||
|
from laborious.utils.connectors_config import build_mlflow_config
|
||||||
|
|
||||||
|
|
||||||
|
class Activities(Storage, MLFlow, Gates, OPC, ModelMetrics, API):
|
||||||
"""
|
"""
|
||||||
Main activities orchestrator for the Laborious system.
|
Central orchestrator for all Temporal activities used by Laborious workflows.
|
||||||
|
|
||||||
This class combines functionality from multiple activity classes to provide
|
Composes Storage (Postgres + MinIO offload), MLFlow (wrapper-based inference and retrain
|
||||||
a unified interface for all workflow operations. It manages database connections,
|
via ``SientiaMLflowRepository``), Gates (data quality and ML response filters), OPC exports,
|
||||||
MLFlow model interactions, data quality validation, and OPC server communications.
|
drift/simple metrics, and PI Web API writes. The worker constructs one ``Activities`` instance
|
||||||
|
per process and registers its callables on multiple workers bound to different task queues.
|
||||||
|
|
||||||
The class implements multiple inheritance to combine specialized functionality:
|
MLflow connectivity: unless ``mlflow_repository`` is injected (tests only), this class builds
|
||||||
- Postgres: Database operations and data persistence
|
``SientiaMLflowRepository`` from ``build_mlflow_config()`` so tracking credentials and URL
|
||||||
- MLFlow: Model inference and transformation operations
|
stay aligned with the rest of Laborious env-based configuration.
|
||||||
- Gates: Data quality validation and filtering mechanisms
|
|
||||||
- OPC: Real-time data export to OPC servers
|
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
postgres_config (dict): PostgreSQL connection configuration
|
Inherits and exposes behaviour from mixins; the MLFlow mixin holds ``mlflow_repository``
|
||||||
mlflow_config (dict): MLFlow server configuration
|
and ``plugin_store`` after ``__init__``.
|
||||||
opc_config (dict): OPC server configuration
|
|
||||||
logger (Logger): Logging and observability instance
|
|
||||||
notification_handler (NotificationHandler): Notification management instance
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self,
|
def __init__(
|
||||||
postgres_config: dict[str, Any],
|
self,
|
||||||
mlflow_config: dict[str, Any],
|
postgres_config: dict[str, Any],
|
||||||
opc_config: dict[str, Any],
|
plugin_store: PluginStore,
|
||||||
logger: Logger,
|
minio_config: dict[str, Any],
|
||||||
notification_handler: NotificationHandler):
|
opc_config: dict[str, Any],
|
||||||
|
pi_web_api_config: dict[str, Any],
|
||||||
|
logger: Logger,
|
||||||
|
notification_handler: NotificationHandler,
|
||||||
|
metrics_controller: MetricsController | None = None,
|
||||||
|
mlflow_repository: SientiaMLflowRepository | None = None,
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Initialize the Activities orchestrator with all required configurations.
|
Wire Postgres, MinIO, MLflow, OPC, gates, metrics, and PI Web API into a single object.
|
||||||
|
|
||||||
This constructor initializes all parent classes with their respective
|
A single ``MetricsController`` instance is created (or reused) and passed to MinIO,
|
||||||
configurations and sets up the foundation for all activity operations.
|
MLflow repository, and all mixins so Prometheus and SDK metrics stay consistent.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
postgres_config: PostgreSQL connection configuration dictionary
|
- postgres_config: Host, port, credentials, db name, and pool bounds for Storage.
|
||||||
Required keys: host, port, user, password, dbname, min_connections, max_connections
|
- plugin_store: ``PluginStore`` instance; the worker must call ``install_runtime`` before
|
||||||
mlflow_config: MLFlow server configuration dictionary
|
activities run so wrapper code is importable.
|
||||||
Required keys: host, port, username, password
|
- minio_config: Endpoint, keys, bucket, retention, and TLS flag for object storage payloads.
|
||||||
opc_config: OPC server configuration dictionary
|
- opc_config: Map of OPC server id to connection settings for ``OPC`` mixin.
|
||||||
Can contain multiple server configurations
|
- pi_web_api_config: Base URL and auth for ``API`` mixin.
|
||||||
logger: Logger instance for observability and debugging
|
- logger: Structured logger used across all activities.
|
||||||
notification_handler: Notification handler for alerts and monitoring
|
- notification_handler: Handler for alerts and persisted notifications.
|
||||||
|
- metrics_controller: Optional shared controller; if ``None``, a new one is created.
|
||||||
|
- mlflow_repository: Optional ``SientiaMLflowRepository`` for unit/e2e tests; in production
|
||||||
|
leave unset so the repository is built from environment via ``build_mlflow_config()``.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
Exception: If any parent class initialization fails
|
Exception: If any parent ``__init__`` fails (e.g. invalid config keys).
|
||||||
|
|
||||||
|
Return:
|
||||||
|
None
|
||||||
"""
|
"""
|
||||||
# Initialize parent classes
|
|
||||||
Postgres.__init__(self, host=postgres_config['host'],
|
|
||||||
port=postgres_config['port'],
|
|
||||||
user=postgres_config['user'],
|
|
||||||
password=postgres_config['password'],
|
|
||||||
dbname=postgres_config['dbname'],
|
|
||||||
min_connections=postgres_config['min_connections'],
|
|
||||||
max_connections=postgres_config['max_connections'],
|
|
||||||
logger=logger,
|
|
||||||
notification_handler=notification_handler)
|
|
||||||
|
|
||||||
MLFlow.__init__(self, mlflow_host=mlflow_config['host'],
|
mc = metrics_controller or MetricsController(logger=logger)
|
||||||
mlflow_port=mlflow_config['port'],
|
|
||||||
mlflow_username=mlflow_config['username'],
|
|
||||||
mlflow_password=mlflow_config['password'],
|
|
||||||
logger=logger,
|
|
||||||
notification_handler=notification_handler)
|
|
||||||
|
|
||||||
Gates.__init__(self, logger=logger,
|
# Production path: one shared MLflow client for all model registry / tracking calls.
|
||||||
notification_handler=notification_handler)
|
if mlflow_repository is None:
|
||||||
|
mlflow_cfg = build_mlflow_config()
|
||||||
|
mlflow_repository = SientiaMLflowRepository(
|
||||||
|
host=mlflow_cfg['url'],
|
||||||
|
username=mlflow_cfg['username'],
|
||||||
|
password=mlflow_cfg['password'],
|
||||||
|
logger=logger,
|
||||||
|
notification_handler=notification_handler,
|
||||||
|
metrics_controller=mc,
|
||||||
|
)
|
||||||
|
|
||||||
OPC.__init__(self,
|
minio_repository = MinioRepository(
|
||||||
opc_servers=opc_config,
|
endpoint=minio_config['endpoint_url'],
|
||||||
logger=logger,
|
access_key=minio_config['access_key'],
|
||||||
notification_handler=notification_handler)
|
secret_key=minio_config['secret_key'],
|
||||||
|
bucket=minio_config['default_bucket'],
|
||||||
|
logger=logger,
|
||||||
|
notification_handler=notification_handler,
|
||||||
|
metrics_controller=mc,
|
||||||
|
secure=minio_config['secure'],
|
||||||
|
)
|
||||||
|
|
||||||
async def shutdown(self):
|
Storage.__init__(
|
||||||
|
self,
|
||||||
|
host=postgres_config['host'],
|
||||||
|
port=postgres_config['port'],
|
||||||
|
user=postgres_config['user'],
|
||||||
|
password=postgres_config['password'],
|
||||||
|
dbname=postgres_config['dbname'],
|
||||||
|
min_connections=postgres_config['min_connections'],
|
||||||
|
max_connections=postgres_config['max_connections'],
|
||||||
|
retention_hours=minio_config['retention_hours'],
|
||||||
|
minio_repository=minio_repository,
|
||||||
|
logger=logger,
|
||||||
|
notification_handler=notification_handler,
|
||||||
|
metrics_controller=mc,
|
||||||
|
)
|
||||||
|
|
||||||
|
MLFlow.__init__(
|
||||||
|
self,
|
||||||
|
mlflow_repository=mlflow_repository,
|
||||||
|
plugin_store=plugin_store,
|
||||||
|
minio_repository=minio_repository,
|
||||||
|
logger=logger,
|
||||||
|
notification_handler=notification_handler,
|
||||||
|
metrics_controller=mc,
|
||||||
|
)
|
||||||
|
|
||||||
|
Gates.__init__(
|
||||||
|
self,
|
||||||
|
minio_repository=minio_repository,
|
||||||
|
logger=logger,
|
||||||
|
notification_handler=notification_handler,
|
||||||
|
metrics_controller=mc,
|
||||||
|
)
|
||||||
|
|
||||||
|
OPC.__init__(
|
||||||
|
self,
|
||||||
|
opc_servers=opc_config,
|
||||||
|
logger=logger,
|
||||||
|
notification_handler=notification_handler,
|
||||||
|
metrics_controller=mc,
|
||||||
|
)
|
||||||
|
|
||||||
|
ModelMetrics.__init__(
|
||||||
|
self,
|
||||||
|
logger=logger,
|
||||||
|
notification_handler=notification_handler,
|
||||||
|
metrics_controller=mc,
|
||||||
|
)
|
||||||
|
|
||||||
|
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=mc,
|
||||||
|
)
|
||||||
|
|
||||||
|
def shutdown(self) -> None:
|
||||||
"""
|
"""
|
||||||
Gracefully shutdown all activities and clean up resources.
|
Close database pools, sync clients, and OPC sessions in a defined order.
|
||||||
|
|
||||||
This method ensures proper cleanup of all resources including:
|
Should be invoked on worker exit so connection pools and OPC sessions are released
|
||||||
- PostgreSQL connection pools
|
cleanly before process termination.
|
||||||
- OPC server connections
|
|
||||||
- Any other resources that need explicit cleanup
|
|
||||||
|
|
||||||
The method should be called before the application terminates to ensure
|
Return:
|
||||||
proper resource cleanup and prevent resource leaks.
|
None
|
||||||
"""
|
"""
|
||||||
Postgres.close(self)
|
Storage.close(self)
|
||||||
await OPC.shutdown(self)
|
MLFlow.close(self)
|
||||||
|
Gates.close(self)
|
||||||
|
OPC.close(self)
|
||||||
|
ModelMetrics.close(self)
|
||||||
|
API.close(self)
|
||||||
|
|||||||
305
laborious/activities/api.py
Normal file
305
laborious/activities/api.py
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
from temporalio import activity, workflow
|
||||||
|
|
||||||
|
with workflow.unsafe.imports_passed_through():
|
||||||
|
import json
|
||||||
|
import traceback
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pandas import DataFrame
|
||||||
|
from sientia_do.notifications.handlers import NotificationHandler
|
||||||
|
from sientia_do.notifications.models import NotificationLevel
|
||||||
|
from sientia_do.observability.logger import Logger
|
||||||
|
from sientia_do.observability.metrics_controller import MetricsController
|
||||||
|
from sientia_do.observability.sientia_monitoring import SientiaMonitoring
|
||||||
|
from sientia_do.repository.pi_web_api_client_sync import PIWebAPIClient
|
||||||
|
|
||||||
|
from laborious import metrics
|
||||||
|
|
||||||
|
|
||||||
|
PI_WEB_API_PREDICTION_ERROR_CONFIDENCE = 13
|
||||||
|
|
||||||
|
|
||||||
|
class API(SientiaMonitoring):
|
||||||
|
"""
|
||||||
|
PI Web API operations for writing prediction data to PI Web API.
|
||||||
|
|
||||||
|
This class provides Temporal activities for interacting with the PI Web API
|
||||||
|
to write prediction and confidence values to industrial systems. It handles
|
||||||
|
error scenarios gracefully by setting error confidence values and sending
|
||||||
|
notifications when write operations fail.
|
||||||
|
|
||||||
|
The class implements comprehensive error handling for both prediction and
|
||||||
|
confidence value writes, ensuring that partial failures are properly
|
||||||
|
reported and handled.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
base_url: str,
|
||||||
|
auth_type: str,
|
||||||
|
auth_token: str,
|
||||||
|
logger: Logger,
|
||||||
|
notification_handler: NotificationHandler,
|
||||||
|
metrics_controller: MetricsController,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Initialize API activity with PI Web API client.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_url (str): Base URL of the PI Web API server
|
||||||
|
auth_type (str): Authentication type ('basic' or 'bearer')
|
||||||
|
auth_token (str): Authentication token
|
||||||
|
logger (Logger): Logger instance for operation logging
|
||||||
|
notification_handler (NotificationHandler): Handler for system notifications
|
||||||
|
metrics_controller (MetricsController): Controller for metrics collection
|
||||||
|
"""
|
||||||
|
SientiaMonitoring.__init__(self, logger, notification_handler, metrics_controller)
|
||||||
|
self.pi_web_api_client = PIWebAPIClient(
|
||||||
|
base_url=base_url,
|
||||||
|
auth_config={
|
||||||
|
'type': auth_type,
|
||||||
|
'token': auth_token,
|
||||||
|
},
|
||||||
|
logger=logger,
|
||||||
|
notification_handler=notification_handler,
|
||||||
|
metrics_controller=metrics_controller,
|
||||||
|
headers_config={
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'x-requested-with': 'piwebapistreams',
|
||||||
|
'User-Agent': 'Aig-Laborious-Agent/1.0',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_pi_web_api_core_labels(
|
||||||
|
self,
|
||||||
|
metadata: dict[str, Any],
|
||||||
|
operation_type: str = 'write_pi_web_api_data',
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Generate core labels for PI Web API metrics.
|
||||||
|
|
||||||
|
PI Web API metrics in laborious use the shared ``CORE_LABELS`` from
|
||||||
|
``sientia_do``, which includes ``operation_type``. For this reason,
|
||||||
|
operation_type must always be present in emitted labels.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
- metadata (dict[str, Any]): Workflow execution metadata used to derive labels.
|
||||||
|
- operation_type (str): Operation type label for metric cardinality.
|
||||||
|
|
||||||
|
Return:
|
||||||
|
dict[str, Any]: Core labels dictionary including operation_type.
|
||||||
|
"""
|
||||||
|
return super().get_core_labels(
|
||||||
|
metadata=metadata,
|
||||||
|
operation_type=operation_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""
|
||||||
|
Close the PI Web API client and shutdown monitoring services.
|
||||||
|
|
||||||
|
This method properly closes all connections and resources associated
|
||||||
|
with the PI Web API client and monitoring services.
|
||||||
|
"""
|
||||||
|
self.pi_web_api_client.close()
|
||||||
|
SientiaMonitoring.shutdown(self)
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
self.emit_metric_sync(
|
||||||
|
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:
|
||||||
|
self.emit_metric_sync(
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.send_notification(
|
||||||
|
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')
|
||||||
|
def write_pi_web_api_data(self, input_data: dict[str, Any]) -> dict[Any, Any]:
|
||||||
|
"""
|
||||||
|
Write prediction and confidence data to PI Web API.
|
||||||
|
|
||||||
|
Writes prediction values and confidence scores to PI Web API using configured
|
||||||
|
web IDs. Processes responses to validate writes and emit metrics. Handles errors
|
||||||
|
gracefully by setting error confidence values when writes fail and sending
|
||||||
|
notifications for both prediction and confidence write errors.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_data (dict[str, Any]): The input data containing:
|
||||||
|
- metadata (dict[str, Any]): Workflow execution metadata
|
||||||
|
- pi_web_api_output_config (dict[str, Any]): PI Web API configuration with:
|
||||||
|
- endpoint (str): PI Web API endpoint URL
|
||||||
|
- prediction_tags (dict[str, str]): Mapping of tag names to web IDs for predictions
|
||||||
|
- confidence_tags (dict[str, str]): Mapping of tag names to web IDs for confidence
|
||||||
|
- data (dict[str, Any]): Prediction data, its a dataframe converted to dict.
|
||||||
|
Returns:
|
||||||
|
dict[Any, Any]: Data dictionary with potentially modified confidence values
|
||||||
|
If prediction write fails, prediction_confidence is set to error value (13)
|
||||||
|
"""
|
||||||
|
metadata = input_data['metadata']
|
||||||
|
data = DataFrame(input_data['data'])
|
||||||
|
pi_web_api_output_config = input_data['pi_web_api_output_config']
|
||||||
|
|
||||||
|
self.info(f'Writing data to PI Web API... config: {pi_web_api_output_config}', metadata)
|
||||||
|
|
||||||
|
raw_prediction_tags = pi_web_api_output_config['prediction_tags']
|
||||||
|
raw_confidence_tags = pi_web_api_output_config['confidence_tags']
|
||||||
|
prediction_tags = list[str](raw_prediction_tags.values())
|
||||||
|
confidence_tags = list(raw_confidence_tags.values())
|
||||||
|
|
||||||
|
core_labels = self.get_pi_web_api_core_labels(metadata)
|
||||||
|
|
||||||
|
prediction_value = data.head(1)['prediction'].values[0]
|
||||||
|
confidence_value = data.head(1)['prediction_confidence'].values[0]
|
||||||
|
|
||||||
|
try:
|
||||||
|
prediction_response = 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 = self.process_pi_web_api_response(
|
||||||
|
response_data=prediction_response,
|
||||||
|
tags=raw_prediction_tags,
|
||||||
|
core_labels=core_labels,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Preserve incoming confidence/comments on successful PI writes.
|
||||||
|
# Only downgrade confidence or override comments when PI response
|
||||||
|
# explicitly reports a problem (e.g. partial write mismatch).
|
||||||
|
if confidence != 0:
|
||||||
|
data['prediction_confidence'] = confidence
|
||||||
|
if message:
|
||||||
|
data['comments'] = message
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
trace = traceback.format_exc()
|
||||||
|
self.send_notification(
|
||||||
|
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 = self.pi_web_api_client.write_value(
|
||||||
|
web_ids=confidence_tags,
|
||||||
|
value={
|
||||||
|
'Timestamp': data.head(1)['timestamp'].values[0],
|
||||||
|
'Value': float(confidence_value),
|
||||||
|
},
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
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()
|
||||||
|
self.send_notification(
|
||||||
|
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()
|
||||||
@@ -1,57 +1,71 @@
|
|||||||
from temporalio import activity, workflow
|
from temporalio import activity, workflow
|
||||||
|
|
||||||
|
|
||||||
with workflow.unsafe.imports_passed_through():
|
with workflow.unsafe.imports_passed_through():
|
||||||
import traceback
|
import traceback
|
||||||
|
from collections.abc import Callable, Mapping
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pandas import DataFrame
|
||||||
from sientia_do.notifications.handlers import CoreNotificationHandler as NotificationHandler
|
from sientia_do.notifications.handlers import CoreNotificationHandler as NotificationHandler
|
||||||
from sientia_do.notifications.models import NotificationLevel
|
from sientia_do.notifications.models import NotificationLevel
|
||||||
from sientia_do.temporal.activities.base import BaseActivity
|
|
||||||
from sientia_do.observability.logger import Logger
|
from sientia_do.observability.logger import Logger
|
||||||
from sientia_do.temporal.constants import DATETIME_FORMAT_WITH_TZ, now
|
from sientia_do.observability.metrics_controller import MetricsController
|
||||||
from sientia_do.formatters import create_sample_dict
|
from sientia_do.observability.sientia_monitoring import SientiaMonitoring
|
||||||
from laborious.utils.filters.mlflow_filters import nan_values_filter, api_error_filter
|
from sientia_do.repository.minio_repository_sync import MinioRepository
|
||||||
from typing import Any
|
from sientia_do.utils.formatters import create_sample_dict
|
||||||
|
|
||||||
|
from laborious import metrics
|
||||||
|
from laborious.utils.dataframe_debug import build_dataframe_debug_message
|
||||||
from laborious.utils.filters.conditional_filters import (
|
from laborious.utils.filters.conditional_filters import (
|
||||||
filter_empty_data,
|
filter_empty_data,
|
||||||
filter_specific_variables_null_values
|
filter_specific_variables_null_values,
|
||||||
)
|
)
|
||||||
from pandas import DataFrame
|
from laborious.utils.filters.mlflow_filters import api_error_filter, nan_values_filter
|
||||||
from laborious import metrics
|
from laborious.utils.models.minio_dataframe_payload import MinioDataFramePayload
|
||||||
|
|
||||||
|
# Strongly-typed filter function signatures
|
||||||
|
InputFilterFunc = Callable[[DataFrame, dict[str, Any]], bool]
|
||||||
|
ResponseFilterFunc = Callable[[dict[str, Any], dict[str, Any]], bool]
|
||||||
|
ContentFilterFunc = Callable[[DataFrame, dict[str, Any]], bool]
|
||||||
|
|
||||||
# Input filter function mappings
|
# Input filter function mappings
|
||||||
input_filter_functions = {
|
input_filter_functions: dict[str, InputFilterFunc] = {
|
||||||
'SPECIFIC_VARIABLES_NULL_VALUES': filter_specific_variables_null_values,
|
'SPECIFIC_VARIABLES_NULL_VALUES': filter_specific_variables_null_values,
|
||||||
'EMPTY_DATA': filter_empty_data,
|
'EMPTY_DATA': filter_empty_data,
|
||||||
'path_confidence': {
|
}
|
||||||
'STOP': -1,
|
|
||||||
'CONTINUE': 2,
|
# Confidence mappings kept separate from function maps to avoid Union types
|
||||||
'REPEAT': -1
|
input_path_confidence: Mapping[str, int] = {
|
||||||
}
|
'STOP': -1,
|
||||||
|
'CONTINUE': 2,
|
||||||
|
'REPEAT': -1,
|
||||||
}
|
}
|
||||||
|
|
||||||
# MLFlow response filter function mappings
|
# MLFlow response filter function mappings
|
||||||
mlflow_response_filter_functions = {
|
mlflow_response_filter_functions: dict[str, ResponseFilterFunc] = {
|
||||||
'API_ERROR': api_error_filter,
|
'API_ERROR': api_error_filter,
|
||||||
'path_confidence': {
|
}
|
||||||
'STOP': -1,
|
|
||||||
'CONTINUE': 10,
|
mlflow_response_path_confidence: Mapping[str, int] = {
|
||||||
'REPEAT': -1
|
'STOP': -1,
|
||||||
},
|
'CONTINUE': 10,
|
||||||
|
'REPEAT': -1,
|
||||||
}
|
}
|
||||||
|
|
||||||
# MLFlow content filter function mappings
|
# MLFlow content filter function mappings
|
||||||
mlflow_content_filter_functions = {
|
mlflow_content_filter_functions: dict[str, ContentFilterFunc] = {
|
||||||
'NAN_VALUES': nan_values_filter,
|
'NAN_VALUES': nan_values_filter,
|
||||||
'EMPTY_DATA': filter_empty_data,
|
'EMPTY_DATA': filter_empty_data,
|
||||||
'path_confidence': {
|
}
|
||||||
'STOP': -1,
|
|
||||||
'CONTINUE': 18,
|
mlflow_content_path_confidence: Mapping[str, int] = {
|
||||||
'REPEAT': -1
|
'STOP': -1,
|
||||||
}
|
'CONTINUE': 18,
|
||||||
|
'REPEAT': -1,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class Gates(BaseActivity):
|
class Gates(SientiaMonitoring):
|
||||||
"""
|
"""
|
||||||
Data quality gates and filtering activities for the Laborious system.
|
Data quality gates and filtering activities for the Laborious system.
|
||||||
|
|
||||||
@@ -71,7 +85,16 @@ class Gates(BaseActivity):
|
|||||||
mlflow_content_filter_functions (dict): Mapping of MLFlow content filter names to functions
|
mlflow_content_filter_functions (dict): Mapping of MLFlow content filter names to functions
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, logger: Logger, notification_handler: NotificationHandler):
|
minio_repository: MinioRepository | None = None
|
||||||
|
_MAX_DEBUG_DATAFRAME_ROWS = 100
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
minio_repository: MinioRepository | None = None,
|
||||||
|
logger: Logger | None = None,
|
||||||
|
notification_handler: NotificationHandler | None = None,
|
||||||
|
metrics_controller: MetricsController | None = None,
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Initialize data quality gates with logging and notification capabilities.
|
Initialize data quality gates with logging and notification capabilities.
|
||||||
|
|
||||||
@@ -82,11 +105,65 @@ class Gates(BaseActivity):
|
|||||||
Raises:
|
Raises:
|
||||||
Exception: If BaseActivity initialization fails
|
Exception: If BaseActivity initialization fails
|
||||||
"""
|
"""
|
||||||
BaseActivity.__init__(
|
self.minio_repository = minio_repository
|
||||||
self, logger, notification_handler, set_error_counter=True)
|
SientiaMonitoring.__init__(
|
||||||
|
self,
|
||||||
|
logger=logger,
|
||||||
|
notification_handler=notification_handler,
|
||||||
|
metrics_controller=metrics_controller,
|
||||||
|
)
|
||||||
|
|
||||||
@activity.defn(name="input_gate")
|
def close(self) -> None:
|
||||||
async def input_gate(self, input_data: dict[str, Any]) -> tuple[str | None, int, str]:
|
"""
|
||||||
|
Close the gates activity and clean up resources.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if self.minio_repository is not None:
|
||||||
|
try:
|
||||||
|
self.minio_repository.close()
|
||||||
|
finally:
|
||||||
|
self.minio_repository = None
|
||||||
|
SientiaMonitoring.shutdown(self)
|
||||||
|
|
||||||
|
def __del__(self):
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
def _debug_dataframe(self, message: str, data: Any, metadata: dict[str, Any]) -> None:
|
||||||
|
"""
|
||||||
|
Log dataframe content only when row count is below the configured threshold
|
||||||
|
|
||||||
|
Args:
|
||||||
|
- message (str): Base log message to identify the dataframe in logs
|
||||||
|
- data (Any): Dataframe-like payload to be logged
|
||||||
|
- metadata (dict[str, Any]): Workflow metadata for contextual logging
|
||||||
|
"""
|
||||||
|
self.debug(
|
||||||
|
build_dataframe_debug_message(
|
||||||
|
message=message,
|
||||||
|
data=data,
|
||||||
|
max_rows=self._MAX_DEBUG_DATAFRAME_ROWS,
|
||||||
|
),
|
||||||
|
metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _read_filter_entry(config: dict[str, Any]) -> tuple[str, dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Read filter policy/config keys in a case-insensitive way.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config (dict[str, Any]): Filter configuration dictionary.
|
||||||
|
|
||||||
|
Return:
|
||||||
|
tuple[str, dict[str, Any]]: Parsed policy and config payload.
|
||||||
|
"""
|
||||||
|
normalized = {str(key).upper(): value for key, value in config.items()}
|
||||||
|
policy = normalized['POLICY']
|
||||||
|
filter_config = normalized.get('CONFIG', {})
|
||||||
|
return policy, filter_config
|
||||||
|
|
||||||
|
@activity.defn(name='input_gate')
|
||||||
|
def input_gate(self, input_data: dict[str, Any]) -> tuple[str | None, int, str]:
|
||||||
"""
|
"""
|
||||||
Apply input data quality filters and validation.
|
Apply input data quality filters and validation.
|
||||||
|
|
||||||
@@ -121,49 +198,52 @@ class Gates(BaseActivity):
|
|||||||
"""
|
"""
|
||||||
metadata = input_data['metadata']
|
metadata = input_data['metadata']
|
||||||
|
|
||||||
self.info("Performing input gate...", metadata)
|
self.info('Performing input gate...', metadata)
|
||||||
|
|
||||||
filters = input_data['filters']
|
filters = input_data['filters']
|
||||||
data = DataFrame(input_data['data'])
|
payload = MinioDataFramePayload.from_dict(input_data['data'])
|
||||||
|
data = payload.retrieve(self.minio_repository, metadata)
|
||||||
path_priority = input_data['path_priority']
|
path_priority = input_data['path_priority']
|
||||||
|
|
||||||
filter_output = []
|
filter_output = []
|
||||||
|
|
||||||
self.debug(f"Input data: {data.head(5).to_string()}", metadata)
|
self._debug_dataframe('Input data:', data, metadata)
|
||||||
self.debug(f"Filters: {filters}", metadata)
|
self.debug(f'Filters: {filters}', metadata)
|
||||||
|
|
||||||
# Apply each configured filter
|
# Apply each configured filter
|
||||||
for fil, config in filters.items():
|
for fil, config in filters.items():
|
||||||
if fil not in input_filter_functions:
|
if fil not in input_filter_functions:
|
||||||
self.error(f"Filter {fil} not found", metadata)
|
self.error(f'Filter {fil} not found', metadata)
|
||||||
continue
|
continue
|
||||||
|
policy, filter_config = self._read_filter_entry(config)
|
||||||
try:
|
try:
|
||||||
if input_filter_functions[fil](data, config['config']):
|
if input_filter_functions[fil](data, filter_config):
|
||||||
self.debug(
|
self.debug(f'Data not passed the input filter {fil}:{config}', metadata)
|
||||||
f"Data not passed the input filter {fil}:{config}", metadata)
|
filter_output.append(policy)
|
||||||
filter_output.append(config['policy'])
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
trace = traceback.format_exc()
|
trace = traceback.format_exc()
|
||||||
self.send_notification(
|
self.send_notification(
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
notification_id=f"INTPUT_GATE_ERROR__{fil}",
|
notification_id=f'INTPUT_GATE_ERROR__{fil}',
|
||||||
message=f"Error in filter {fil}:{config}: \n {e}",
|
message=f'Error in filter {fil}:{config}: \n {e}',
|
||||||
block="input_gate",
|
block='input_gate',
|
||||||
level=NotificationLevel.ERROR,
|
level=NotificationLevel.ERROR,
|
||||||
attachment_content=trace
|
attachment_content=trace,
|
||||||
)
|
)
|
||||||
|
|
||||||
for path_flag in path_priority:
|
for path_flag in path_priority:
|
||||||
if path_flag in filter_output:
|
if path_flag in filter_output:
|
||||||
self.info(f"Input gate result: {path_flag}", metadata)
|
self.info(f'Input gate result: {path_flag}', metadata)
|
||||||
return path_flag, input_filter_functions['path_confidence'][path_flag], \
|
return path_flag, input_path_confidence[path_flag], 'Input data with bad quality'
|
||||||
"Input data with bad quality"
|
|
||||||
|
|
||||||
self.info("Nothing was filtered by the input gate", metadata)
|
self.info('Nothing was filtered by the input gate', metadata)
|
||||||
return None, 0, ""
|
|
||||||
|
|
||||||
@activity.defn(name="mlflow_response_gate")
|
del data
|
||||||
async def mlflow_response_gate(self, input_data: dict[str, Any]) -> tuple[str | None, int, str]:
|
|
||||||
|
return None, 0, ''
|
||||||
|
|
||||||
|
@activity.defn(name='mlflow_response_gate')
|
||||||
|
def mlflow_response_gate(self, input_data: dict[str, Any]) -> tuple[str | None, int, str]:
|
||||||
"""
|
"""
|
||||||
Validate MLFlow API response quality and integrity.
|
Validate MLFlow API response quality and integrity.
|
||||||
|
|
||||||
@@ -197,59 +277,67 @@ class Gates(BaseActivity):
|
|||||||
Exception: If response validation fails or configuration is invalid
|
Exception: If response validation fails or configuration is invalid
|
||||||
"""
|
"""
|
||||||
metadata = input_data['metadata']
|
metadata = input_data['metadata']
|
||||||
self.info("Performing mlflow response gate...", metadata)
|
self.info('Performing mlflow response gate...', metadata)
|
||||||
|
raw_data = input_data['data']
|
||||||
filters = input_data['filters']
|
filters = input_data['filters']
|
||||||
data = input_data['data']
|
|
||||||
|
self.debug(
|
||||||
|
f'Input data: \n {create_sample_dict(raw_data, max_items=5, max_depth=5)}', metadata
|
||||||
|
)
|
||||||
|
self.debug(f'Filters: {filters}', metadata)
|
||||||
|
|
||||||
|
payload = MinioDataFramePayload.from_dict(raw_data)
|
||||||
|
data = payload.retrieve(self.minio_repository, metadata)
|
||||||
|
|
||||||
gate_type = input_data['type']
|
gate_type = input_data['type']
|
||||||
path_priority = input_data['path_priority']
|
path_priority = input_data['path_priority']
|
||||||
|
|
||||||
filter_output = []
|
filter_output = []
|
||||||
|
|
||||||
self.debug(
|
|
||||||
f"Input data: \n {create_sample_dict(data, max_items=5, max_depth=2)}", metadata)
|
|
||||||
self.debug(f"Filters: {filters}", metadata)
|
|
||||||
|
|
||||||
comments = []
|
comments = []
|
||||||
|
|
||||||
|
status = payload.status or {}
|
||||||
|
|
||||||
for fil, config in filters.items():
|
for fil, config in filters.items():
|
||||||
if fil not in mlflow_response_filter_functions:
|
if fil not in mlflow_response_filter_functions:
|
||||||
self.error(f"Filter {fil} not found", metadata)
|
|
||||||
continue
|
continue
|
||||||
|
policy, filter_config = self._read_filter_entry(config)
|
||||||
try:
|
try:
|
||||||
if mlflow_response_filter_functions[fil](data, config):
|
if mlflow_response_filter_functions[fil](status, filter_config):
|
||||||
filter_output.append(config['policy'])
|
filter_output.append(policy)
|
||||||
comments.append(data['content']['message'])
|
comments.append(status.get('message', 'Unknown MLFlow API error'))
|
||||||
self.send_notification(
|
self.send_notification(
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
notification_id=f"{gate_type.upper()}_GATE_RESPONSE_FILTER__{fil}",
|
notification_id=f'{gate_type.upper()}_GATE_RESPONSE_FILTER__{fil}',
|
||||||
message=data['content']['message'],
|
message=status.get('message', 'Unknown MLFlow API error'),
|
||||||
block="mlflow_gate",
|
block='mlflow_gate',
|
||||||
level=NotificationLevel.ERROR,
|
level=NotificationLevel.ERROR,
|
||||||
attachment_content=data['content']['traceback']
|
attachment_content=status.get('traceback'),
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
trace = traceback.format_exc()
|
trace = traceback.format_exc()
|
||||||
self.send_notification(
|
self.send_notification(
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
notification_id=f"MLFLOW_GATE_RESPONSE_FILTER__{fil}",
|
notification_id=f'MLFLOW_GATE_RESPONSE_FILTER__{fil}',
|
||||||
message=f"Error in filter {fil}:{config}: \n {e}",
|
message=f'Error in filter {fil}:{config}: \n {e}',
|
||||||
block="mlflow_gate",
|
block='mlflow_gate',
|
||||||
level=NotificationLevel.ERROR,
|
level=NotificationLevel.ERROR,
|
||||||
attachment_content=trace
|
attachment_content=trace,
|
||||||
)
|
)
|
||||||
|
|
||||||
for path_flag in path_priority:
|
for path_flag in path_priority:
|
||||||
if path_flag in filter_output:
|
if path_flag in filter_output:
|
||||||
self.info(
|
self.info(f'Mlflow response gate result: {path_flag}', metadata)
|
||||||
f"Mlflow response gate result: {path_flag}", metadata)
|
return path_flag, mlflow_response_path_confidence[path_flag], ', '.join(comments)
|
||||||
return path_flag, mlflow_response_filter_functions['path_confidence'][path_flag], \
|
|
||||||
", ".join(comments)
|
|
||||||
|
|
||||||
self.info("Nothing was filtered by the mlflow response gate", metadata)
|
self.info('Nothing was filtered by the mlflow response gate', metadata)
|
||||||
return None, 0, ""
|
|
||||||
|
|
||||||
@activity.defn(name="mlflow_content_gate")
|
del data
|
||||||
async def mlflow_content_gate(self, input_data: dict[str, Any]) -> tuple[str | None, int, str]:
|
|
||||||
|
return None, 0, ''
|
||||||
|
|
||||||
|
@activity.defn(name='mlflow_content_gate')
|
||||||
|
def mlflow_content_gate(self, input_data: dict[str, Any]) -> tuple[str | None, int, str]:
|
||||||
"""
|
"""
|
||||||
Validate MLFlow prediction content quality and integrity.
|
Validate MLFlow prediction content quality and integrity.
|
||||||
|
|
||||||
@@ -283,56 +371,65 @@ class Gates(BaseActivity):
|
|||||||
Exception: If content validation fails or configuration is invalid
|
Exception: If content validation fails or configuration is invalid
|
||||||
"""
|
"""
|
||||||
metadata = input_data['metadata']
|
metadata = input_data['metadata']
|
||||||
self.info("Performing mlflow content gate...", metadata)
|
self.info('Performing mlflow content gate...', metadata)
|
||||||
|
|
||||||
filters = input_data['filters']
|
filters = input_data['filters']
|
||||||
data = DataFrame(input_data['data'])
|
|
||||||
|
payload = MinioDataFramePayload.from_dict(input_data['data'])
|
||||||
|
data = payload.retrieve(self.minio_repository, metadata)
|
||||||
|
|
||||||
gate_type = input_data['type']
|
gate_type = input_data['type']
|
||||||
path_priority = input_data['path_priority']
|
path_priority = input_data['path_priority']
|
||||||
|
|
||||||
filter_output = []
|
filter_output = []
|
||||||
|
|
||||||
self.debug(f"Input data:\n {data.head(5).to_string()}", metadata)
|
self._debug_dataframe('Input data:', data, metadata)
|
||||||
self.debug(f"Filters: \n {create_sample_dict(filters)}", metadata)
|
self.debug(f'Filters: \n {filters}', metadata)
|
||||||
|
|
||||||
for fil, config in filters.items():
|
for fil, config in filters.items():
|
||||||
if fil not in mlflow_content_filter_functions:
|
if fil not in mlflow_content_filter_functions:
|
||||||
continue
|
continue
|
||||||
|
policy, filter_config = self._read_filter_entry(config)
|
||||||
try:
|
try:
|
||||||
if mlflow_content_filter_functions[fil](data, config):
|
if mlflow_content_filter_functions[fil](data, filter_config):
|
||||||
filter_output.append(config['policy'])
|
filter_output.append(policy)
|
||||||
self.send_notification(
|
self.send_notification(
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
notification_id=f"{gate_type.upper()}_GATE_CONTENT_FILTER__{fil}",
|
notification_id=f'{gate_type.upper()}_GATE_CONTENT_FILTER__{fil}',
|
||||||
message=f"Data not passed the content filter {fil}:{config}",
|
message=f'Data not passed the content filter {fil}:{config}',
|
||||||
block="mlflow_gate",
|
block='mlflow_gate',
|
||||||
level=NotificationLevel.WARNING,
|
level=NotificationLevel.WARNING,
|
||||||
attachment_content=data.to_string()
|
attachment_content=data.to_string(),
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
trace = traceback.format_exc()
|
trace = traceback.format_exc()
|
||||||
self.send_notification(
|
self.send_notification(
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
notification_id=f"MLFLOW_GATE_CONTENT_FILTER__{fil}",
|
notification_id=f'MLFLOW_GATE_CONTENT_FILTER__{fil}',
|
||||||
message=f"Error in filter {fil}:{config}: \n {e}",
|
message=f'Error in filter {fil}:{config}: \n {e}',
|
||||||
block="mlflow_gate",
|
block='mlflow_gate',
|
||||||
level=NotificationLevel.ERROR,
|
level=NotificationLevel.ERROR,
|
||||||
attachment_content=trace
|
attachment_content=trace,
|
||||||
)
|
)
|
||||||
|
|
||||||
for path_flag in path_priority:
|
for path_flag in path_priority:
|
||||||
if path_flag in filter_output:
|
if path_flag in filter_output:
|
||||||
self.info(
|
self.info(f'Mlflow content gate result: {path_flag}', metadata)
|
||||||
f"Mlflow content gate result: {path_flag}", metadata)
|
return (
|
||||||
return path_flag, mlflow_content_filter_functions['path_confidence'][path_flag], \
|
path_flag,
|
||||||
"Transformed data not passed the content filter"
|
mlflow_content_path_confidence[path_flag],
|
||||||
|
'Transformed data not passed the content filter',
|
||||||
|
)
|
||||||
|
|
||||||
self.info("Nothing was filtered by the mlflow content gate", metadata)
|
self.info('Nothing was filtered by the mlflow content gate', metadata)
|
||||||
return None, 0, ""
|
|
||||||
|
|
||||||
def get_prediction_store_policy(self,
|
del data
|
||||||
prediction_store_policy: str,
|
|
||||||
metadata: dict[str, Any]) -> tuple[str, int]:
|
return None, 0, ''
|
||||||
|
|
||||||
|
def get_prediction_store_policy(
|
||||||
|
self, prediction_store_policy: str, metadata: dict[str, Any]
|
||||||
|
) -> tuple[str, int]:
|
||||||
"""
|
"""
|
||||||
Parse and validate prediction store policy configuration.
|
Parse and validate prediction store policy configuration.
|
||||||
|
|
||||||
@@ -358,7 +455,9 @@ class Gates(BaseActivity):
|
|||||||
|
|
||||||
if len(policy_elements) < 2:
|
if len(policy_elements) < 2:
|
||||||
self.error(
|
self.error(
|
||||||
f"Invalid prediction store policy: {prediction_store_policy}, using default policy", metadata)
|
f'Invalid prediction store policy: {prediction_store_policy}, using default policy',
|
||||||
|
metadata,
|
||||||
|
)
|
||||||
return 'lts', 1
|
return 'lts', 1
|
||||||
|
|
||||||
policy_type = policy_elements[0]
|
policy_type = policy_elements[0]
|
||||||
@@ -366,15 +465,76 @@ class Gates(BaseActivity):
|
|||||||
|
|
||||||
# If the policy_type is not lts or erl, we use the default policy
|
# If the policy_type is not lts or erl, we use the default policy
|
||||||
# If the policty_value is not a number or 0, we use the default policy
|
# If the policty_value is not a number or 0, we use the default policy
|
||||||
if policy_type not in ['lts', 'erl'] or not policy_value.isdigit() or int(policy_value) == 0:
|
if (
|
||||||
|
policy_type not in ['lts', 'erl']
|
||||||
|
or not policy_value.isdigit()
|
||||||
|
or int(policy_value) == 0
|
||||||
|
):
|
||||||
self.error(
|
self.error(
|
||||||
f"Invalid prediction store policy: {prediction_store_policy}, using default policy", metadata)
|
f'Invalid prediction store policy: {prediction_store_policy}, using default policy',
|
||||||
|
metadata,
|
||||||
|
)
|
||||||
return 'lts', 1
|
return 'lts', 1
|
||||||
|
|
||||||
return policy_type, int(policy_value)
|
return policy_type, int(policy_value)
|
||||||
|
|
||||||
@activity.defn(name="format_prediction")
|
@activity.defn(name='format_transformed_data')
|
||||||
async def format_prediction(self, input_data: dict[str, Any]) -> dict[Any, Any]:
|
def format_transformed_data(self, input_data: dict[str, Any]) -> MinioDataFramePayload:
|
||||||
|
"""
|
||||||
|
Format transformed data for storage and export operations.
|
||||||
|
|
||||||
|
This method formats transformed data from MLFlow model transformations
|
||||||
|
into a standardized format suitable for database storage. It converts
|
||||||
|
wide-format data (columns as variables) into long-format (melted)
|
||||||
|
with proper timestamp handling and model identification.
|
||||||
|
|
||||||
|
The formatting process includes:
|
||||||
|
1. Converting input data dictionary to DataFrame
|
||||||
|
2. Extracting timestamps from DataFrame index
|
||||||
|
3. Resetting index to create sequential row numbers
|
||||||
|
4. Melting data from wide format to long format (variable-value pairs)
|
||||||
|
5. Adding model_id for data lineage tracking
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_data (dict): Input data containing:
|
||||||
|
- metadata (dict): Workflow execution metadata
|
||||||
|
- data (dict[str, Any]): Transformed data to format (DataFrame-compatible dict)
|
||||||
|
- model_id (str): Unique identifier for the ML model
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Formatted data dictionary with keys:
|
||||||
|
- timestamp (dict): Timestamp values indexed by row number
|
||||||
|
- variable (dict): Variable names indexed by row number
|
||||||
|
- value (dict): Variable values indexed by row number
|
||||||
|
- model_id (dict): Model identifiers indexed by row number
|
||||||
|
"""
|
||||||
|
metadata = input_data['metadata']
|
||||||
|
|
||||||
|
model_id = input_data['model_id']
|
||||||
|
|
||||||
|
self.info('Formatting transformed data...', metadata)
|
||||||
|
|
||||||
|
payload = MinioDataFramePayload.from_dict(input_data['data'])
|
||||||
|
data = payload.retrieve(self.minio_repository, metadata)
|
||||||
|
|
||||||
|
data['timestamp'] = data.index
|
||||||
|
data = data.reset_index(drop=True)
|
||||||
|
|
||||||
|
data = data.melt(id_vars='timestamp', var_name='variable', value_name='value')
|
||||||
|
data['model_id'] = model_id
|
||||||
|
|
||||||
|
return MinioDataFramePayload.from_dataframe(
|
||||||
|
dataframe=data,
|
||||||
|
minio_repo=self.minio_repository,
|
||||||
|
model_name=input_data['model_name'],
|
||||||
|
operation='transform',
|
||||||
|
workflow_metadata=metadata,
|
||||||
|
last_timestamp=payload.last_timestamp,
|
||||||
|
logger=self.logger,
|
||||||
|
)
|
||||||
|
|
||||||
|
@activity.defn(name='format_prediction')
|
||||||
|
def format_prediction(self, input_data: dict[str, Any]) -> dict:
|
||||||
"""
|
"""
|
||||||
Format prediction data according to configured storage policies.
|
Format prediction data according to configured storage policies.
|
||||||
|
|
||||||
@@ -383,6 +543,8 @@ class Gates(BaseActivity):
|
|||||||
and ensures data consistency before persistence. The method supports
|
and ensures data consistency before persistence. The method supports
|
||||||
multiple storage policies for flexible data retention strategies.
|
multiple storage policies for flexible data retention strategies.
|
||||||
|
|
||||||
|
If only one row is present, we use the last timestamp as the timestamp
|
||||||
|
|
||||||
Storage Policies:
|
Storage Policies:
|
||||||
- 'lts:N': Latest timestamp - retains N most recent predictions
|
- 'lts:N': Latest timestamp - retains N most recent predictions
|
||||||
- 'erl:N': Earliest timestamp - retains N oldest predictions
|
- 'erl:N': Earliest timestamp - retains N oldest predictions
|
||||||
@@ -390,7 +552,7 @@ class Gates(BaseActivity):
|
|||||||
Args:
|
Args:
|
||||||
input_data (dict): Input data containing:
|
input_data (dict): Input data containing:
|
||||||
- data (dict[str, Any]): Raw prediction data to format
|
- data (dict[str, Any]): Raw prediction data to format
|
||||||
- timestamp (str): Default timestamp if data lacks timestamp column
|
- timestamp (str): Timestamp of the data
|
||||||
- model_id (str): Unique identifier for the ML model
|
- model_id (str): Unique identifier for the ML model
|
||||||
- prediction_confidence (float): Confidence score for the prediction
|
- prediction_confidence (float): Confidence score for the prediction
|
||||||
- prediction_store_policy (str): Storage policy in format 'type:value'
|
- prediction_store_policy (str): Storage policy in format 'type:value'
|
||||||
@@ -399,58 +561,61 @@ class Gates(BaseActivity):
|
|||||||
dict: Formatted prediction data ready for storage and export
|
dict: Formatted prediction data ready for storage and export
|
||||||
"""
|
"""
|
||||||
metadata = input_data['metadata']
|
metadata = input_data['metadata']
|
||||||
|
last_timestamp = input_data['timestamp']
|
||||||
prediction_store_policy = input_data['prediction_store_policy']
|
prediction_store_policy = input_data['prediction_store_policy']
|
||||||
self.info("Formatting prediction...", metadata)
|
self.info('Formatting prediction...', metadata)
|
||||||
|
|
||||||
data = DataFrame(input_data['data'])
|
payload = MinioDataFramePayload.from_dict(input_data['data'])
|
||||||
|
data = payload.retrieve(self.minio_repository, metadata)
|
||||||
|
|
||||||
# Create timestamp column from index and reset index
|
# Create timestamp column from index and reset index
|
||||||
data['timestamp'] = data.index
|
data['timestamp'] = data.index
|
||||||
data = data.reset_index(drop=True)
|
data = data.reset_index(drop=True)
|
||||||
|
|
||||||
self.debug(
|
self.debug(f'Prediction store policy: {prediction_store_policy}', metadata)
|
||||||
f"Prediction store policy: {prediction_store_policy}", metadata)
|
self._debug_dataframe('Prediction data:', data, metadata)
|
||||||
self.debug(f"Prediction data: {data.head(5).to_string()}", metadata)
|
|
||||||
|
|
||||||
policy_type, policy_value = self.get_prediction_store_policy(
|
policy_type, policy_value = self.get_prediction_store_policy(
|
||||||
prediction_store_policy, metadata)
|
prediction_store_policy, metadata
|
||||||
|
)
|
||||||
|
|
||||||
# If data has no timestamp, we use the default timestamp and not sort the data
|
# If data has no timestamp, we use the default timestamp and not sort the data
|
||||||
self.info(
|
self.info(
|
||||||
f"Sorting data by timestamp and applying policy: {policy_type}:{policy_value}", metadata)
|
f'Sorting data by timestamp and applying policy: {policy_type}:{policy_value}', metadata
|
||||||
|
)
|
||||||
|
|
||||||
# If policy_type is lts, we need to sort the data by timestamp descending and take the first policy_value rows
|
# If policy_type is lts, we need to sort the data by timestamp descending and take the first policy_value rows
|
||||||
if policy_type == 'lts':
|
if policy_type == 'lts':
|
||||||
self.debug(
|
self.debug('Sorting data by timestamp descending', metadata)
|
||||||
"Sorting data by timestamp descending", metadata)
|
|
||||||
data = data.sort_values(by='timestamp', ascending=False)
|
data = data.sort_values(by='timestamp', ascending=False)
|
||||||
# If policy_type is erl, we need to sort the data by timestamp ascending and take the first policy_value rows
|
# If policy_type is erl, we need to sort the data by timestamp ascending and take the first policy_value rows
|
||||||
elif policy_type == 'erl':
|
elif policy_type == 'erl':
|
||||||
self.debug(
|
self.debug('Sorting data by timestamp ascending', metadata)
|
||||||
"Sorting data by timestamp ascending", metadata)
|
|
||||||
data = data.sort_values(by='timestamp', ascending=True)
|
data = data.sort_values(by='timestamp', ascending=True)
|
||||||
else:
|
else:
|
||||||
self.error(
|
self.error(f'Invalid policy type: {policy_type}, using default policy', metadata)
|
||||||
f"Invalid policy type: {policy_type}, using default policy", metadata)
|
raise ValueError(f'Invalid policy type: {policy_type}')
|
||||||
raise ValueError(
|
|
||||||
f"Invalid policy type: {policy_type}")
|
|
||||||
|
|
||||||
data = data.head(int(policy_value))
|
int_policy_value = int(policy_value)
|
||||||
|
|
||||||
|
data = data.head(int_policy_value)
|
||||||
|
if int_policy_value == 1:
|
||||||
|
data['timestamp'] = last_timestamp
|
||||||
|
|
||||||
data['model_id'] = input_data['model_id']
|
data['model_id'] = input_data['model_id']
|
||||||
data['prediction_confidence'] = input_data['prediction_confidence']
|
data['prediction_confidence'] = input_data['prediction_confidence']
|
||||||
data['prediction_status'] = 'Good'
|
data['prediction_status'] = 'Good'
|
||||||
data['comments'] = ""
|
data['comments'] = ''
|
||||||
data = data.sort_values(by='timestamp', ascending=False)
|
data = data.sort_values(by='timestamp', ascending=False)
|
||||||
data = data.reset_index(drop=True)
|
data = data.reset_index(drop=True)
|
||||||
|
|
||||||
self.info(f"Prediction formatted: {len(data)} rows", metadata)
|
self.info(f'Prediction formatted: {len(data)} rows', metadata)
|
||||||
self.debug(f"Prediction data: {data.head(5).to_string()}", metadata)
|
self._debug_dataframe('Prediction data:', data, metadata)
|
||||||
|
|
||||||
return data.to_dict()
|
return data.to_dict()
|
||||||
|
|
||||||
@activity.defn(name="format_default_prediction")
|
@activity.defn(name='format_default_prediction')
|
||||||
async def format_default_prediction(self, input_data: dict[str, Any]) -> dict[Any, Any]:
|
def format_default_prediction(self, input_data: dict[str, Any]) -> dict:
|
||||||
"""
|
"""
|
||||||
Create and format default prediction data for error conditions.
|
Create and format default prediction data for error conditions.
|
||||||
|
|
||||||
@@ -477,65 +642,95 @@ class Gates(BaseActivity):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
metadata = input_data['metadata']
|
metadata = input_data['metadata']
|
||||||
self.debug("Formatting default prediction...", metadata)
|
self.debug('Formatting default prediction...', metadata)
|
||||||
|
|
||||||
data = DataFrame({
|
data = DataFrame(
|
||||||
'prediction': [0],
|
{
|
||||||
'response_time': [0],
|
'prediction': [0],
|
||||||
'timestamp': [input_data['timestamp']],
|
'response_time': [0],
|
||||||
'model_id': [input_data['model_id']],
|
'timestamp': [input_data['timestamp']],
|
||||||
'prediction_confidence': [input_data['prediction_confidence']],
|
'model_id': [input_data['model_id']],
|
||||||
'prediction_status': ['Bad'],
|
'prediction_confidence': [input_data['prediction_confidence']],
|
||||||
'comments': [input_data['comment']]
|
'prediction_status': ['Bad'],
|
||||||
})
|
'comments': [input_data['comment']],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
self.info(f"Default prediction formatted: {data.size} rows", metadata)
|
self.info(f'Default prediction formatted: {data.size} rows', metadata)
|
||||||
return data.to_dict()
|
return data.to_dict()
|
||||||
|
|
||||||
@activity.defn(name="get_last_timestamp")
|
@activity.defn(name='format_retrain_report')
|
||||||
async def get_last_timestamp(self, input_data: dict[str, Any]) -> str:
|
def format_retrain_report(self, input_data: dict[str, Any]) -> dict:
|
||||||
"""
|
"""
|
||||||
Extract the most recent timestamp from prediction data.
|
Format retrain report data for storage and audit trail maintenance.
|
||||||
|
|
||||||
This method analyzes prediction data to find the latest timestamp,
|
This method formats model retraining operation results into a standardized
|
||||||
enabling incremental processing and data continuity tracking.
|
report format suitable for database storage and operational monitoring.
|
||||||
It handles empty datasets gracefully by returning the current time
|
It captures retraining status, timestamps, and model version information
|
||||||
as a fallback timestamp.
|
for comprehensive audit trails and operational visibility.
|
||||||
|
|
||||||
The method is essential for:
|
The formatting process includes:
|
||||||
1. Incremental data processing workflows
|
1. Extracting retraining experiment response data
|
||||||
2. Data continuity validation
|
2. Capturing model update report information (version, MLflow IDs)
|
||||||
3. Timestamp-based data loading optimization
|
3. Formatting timestamps and status information
|
||||||
4. Workflow execution tracking
|
4. Conditionally including version information for successful retrains
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
input_data (dict): Input data containing:
|
input_data (dict): Input data containing:
|
||||||
- data (dict[str, Any]): Prediction data to analyze
|
- metadata (dict): Workflow execution metadata
|
||||||
|
- experiment_response (dict): Retraining experiment response containing:
|
||||||
|
- success (bool): Retraining operation success status
|
||||||
|
- timestamp (str): Timestamp of the retraining operation
|
||||||
|
- message (str): Status message or error description
|
||||||
|
- update_report (dict): Model update report containing:
|
||||||
|
- version (str): New model version identifier
|
||||||
|
- mlflow_run_id (str): MLflow run identifier
|
||||||
|
- mlflow_experiment_id (str): MLflow experiment identifier
|
||||||
|
- model_id (str): Unique identifier for the ML model
|
||||||
|
- model_name (str): Name of the ML model
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: Formatted timestamp string in UTC with timezone
|
dict: Formatted retrain report dictionary with keys:
|
||||||
|
- model_id (dict): Model identifiers indexed by row number
|
||||||
|
- model_name (dict): Model names indexed by row number
|
||||||
|
- timestamp (dict): Retraining timestamps indexed by row number
|
||||||
|
- status (dict): Retraining status messages indexed by row number
|
||||||
|
- version (dict, optional): Model versions indexed by row number
|
||||||
|
Only included if experiment_response['success'] is True
|
||||||
|
- mlflow_run_id (dict, optional): MLflow run IDs indexed by row number
|
||||||
|
Only included if experiment_response['success'] is True
|
||||||
|
- mlflow_experiment_id (dict, optional): MLflow experiment IDs indexed by row number
|
||||||
|
Only included if experiment_response['success'] is True
|
||||||
"""
|
"""
|
||||||
metadata = input_data['metadata']
|
metadata = input_data['metadata']
|
||||||
|
self.info('Formatting retrain report...', metadata)
|
||||||
|
|
||||||
self.info("Getting last timestamp...", metadata)
|
experiment_response = input_data['experiment_response']
|
||||||
|
update_report = input_data['update_report']
|
||||||
|
model_id = input_data['model_id']
|
||||||
|
model_name = input_data['model_name']
|
||||||
|
|
||||||
data = DataFrame(input_data['data'])
|
report = DataFrame(
|
||||||
|
{
|
||||||
|
'model_id': [model_id],
|
||||||
|
'model_name': [model_name],
|
||||||
|
'timestamp': [experiment_response['timestamp']],
|
||||||
|
'status': [experiment_response['message']],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
self.debug(f"Input data: {data.head(5).to_string()}", metadata)
|
if experiment_response['success']:
|
||||||
|
# Retrain was successfull
|
||||||
|
report['version'] = update_report['version']
|
||||||
|
report['mlflow_run_id'] = update_report['mlflow_run_id']
|
||||||
|
report['mlflow_experiment_id'] = update_report['mlflow_experiment_id']
|
||||||
|
|
||||||
if data.empty:
|
self._debug_dataframe('Retrain report:', report, metadata)
|
||||||
return now().strftime(DATETIME_FORMAT_WITH_TZ)
|
|
||||||
|
|
||||||
max_timestamp = max(
|
return report.to_dict()
|
||||||
data['timestamp'].values.tolist())
|
|
||||||
|
|
||||||
self.info(
|
@activity.defn(name='write_metrics')
|
||||||
f"Last timestamp: {max_timestamp}", metadata)
|
def write_metrics(self, input_data: dict[str, Any]):
|
||||||
|
|
||||||
return max_timestamp
|
|
||||||
|
|
||||||
@activity.defn(name="write_metrics")
|
|
||||||
async def write_metrics(self, input_data: dict[str, Any]):
|
|
||||||
"""
|
"""
|
||||||
Write prediction performance metrics to Prometheus monitoring system.
|
Write prediction performance metrics to Prometheus monitoring system.
|
||||||
|
|
||||||
@@ -561,27 +756,57 @@ class Gates(BaseActivity):
|
|||||||
prediction = DataFrame(input_data['prediction'])
|
prediction = DataFrame(input_data['prediction'])
|
||||||
prediction_confidence = prediction['prediction_confidence'].values[0]
|
prediction_confidence = prediction['prediction_confidence'].values[0]
|
||||||
response_time = prediction['response_time'].values[0]
|
response_time = prediction['response_time'].values[0]
|
||||||
|
opc_metrics = input_data['opc_metrics']
|
||||||
|
|
||||||
self.info(
|
self.info(f'Writing metrics for model {metadata["model_name"]}', metadata)
|
||||||
f"Writing metrics for model {metadata['model_name']}", metadata)
|
|
||||||
|
|
||||||
metrics.PREDICTIONS_WRITTEN_COUNT.labels(
|
core_tags = {
|
||||||
pod_id=self.pod_id,
|
'pod_id': self.pod_id,
|
||||||
model_name=metadata['model_name'],
|
'runtime': self.runtime,
|
||||||
pipeline_name=metadata['workflow_name']
|
'operation_type': 'predict',
|
||||||
).inc()
|
'model_name': metadata['model_name'],
|
||||||
|
'workflow_name': metadata['workflow_name'],
|
||||||
|
}
|
||||||
|
self.emit_metric_sync(
|
||||||
|
metric_object=metrics.PREDICTIONS_WRITTEN_COUNT,
|
||||||
|
tags=core_tags,
|
||||||
|
)
|
||||||
|
|
||||||
metrics.PREDICTION_CONFIDENCE_MONITOR.labels(
|
self.emit_metric_sync(
|
||||||
pod_id=self.pod_id,
|
metric_object=metrics.PREDICTION_CONFIDENCE_MONITOR,
|
||||||
model_name=metadata['model_name'],
|
method='set',
|
||||||
pipeline_name=metadata['workflow_name']
|
tags=core_tags,
|
||||||
).set(prediction_confidence)
|
value=prediction_confidence,
|
||||||
|
)
|
||||||
|
|
||||||
metrics.PREDICTION_RESPONSE_TIME_MONITOR.labels(
|
self.emit_metric_sync(
|
||||||
pod_id=self.pod_id,
|
metric_object=metrics.PREDICTION_RESPONSE_TIME_MONITOR,
|
||||||
model_name=metadata['model_name'],
|
method='observe',
|
||||||
pipeline_name=metadata['workflow_name']
|
tags=core_tags,
|
||||||
).observe(response_time)
|
value=response_time,
|
||||||
|
)
|
||||||
|
|
||||||
self.info(
|
for server_id, tags in opc_metrics.items():
|
||||||
f"Metrics written for model {metadata['model_name']}", metadata)
|
for tag, response_time in tags.items():
|
||||||
|
if response_time is not None:
|
||||||
|
self.emit_metric_sync(
|
||||||
|
metric_object=metrics.PREDICTION_OPC_WRITING_RESPONSE_TIME_MONITOR,
|
||||||
|
method='observe',
|
||||||
|
tags={
|
||||||
|
**core_tags,
|
||||||
|
'opc_server_id': server_id,
|
||||||
|
'tag': tag,
|
||||||
|
},
|
||||||
|
value=response_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.emit_metric_sync(
|
||||||
|
metric_object=metrics.PREDICTION_OPC_WRITING_COUNT,
|
||||||
|
tags={
|
||||||
|
**core_tags,
|
||||||
|
'opc_server_id': server_id,
|
||||||
|
'tag': tag,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.info(f'Metrics written for model {metadata["model_name"]}', metadata)
|
||||||
|
|||||||
@@ -1,333 +1,649 @@
|
|||||||
from temporalio import activity, workflow
|
from temporalio import activity, workflow
|
||||||
|
|
||||||
|
|
||||||
with workflow.unsafe.imports_passed_through():
|
with workflow.unsafe.imports_passed_through():
|
||||||
|
import tempfile
|
||||||
|
import traceback
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pandas import Timestamp, to_datetime
|
from pathlib import Path
|
||||||
from sientia_do.temporal.constants import DATETIME_FORMAT, DATETIME_FORMAT_WITH_TZ
|
from shutil import rmtree
|
||||||
from sientia_do.temporal.activities.base import BaseActivity
|
from typing import Any
|
||||||
|
|
||||||
|
import mlflow
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from pandas import DataFrame, to_datetime
|
||||||
from sientia_do.notifications.handlers import CoreNotificationHandler as NotificationHandler
|
from sientia_do.notifications.handlers import CoreNotificationHandler as NotificationHandler
|
||||||
from sientia_do.notifications.models import NotificationLevel
|
from sientia_do.notifications.models import NotificationLevel
|
||||||
from sientia_do.observability.logger import Logger
|
from sientia_do.observability.logger import Logger
|
||||||
from sientia_do.formatters import create_sample_dict
|
from sientia_do.observability.metrics_controller import MetricsController
|
||||||
from laborious.utils.repository.model_repository import MLFlowRepository
|
from sientia_do.observability.sientia_monitoring import SientiaMonitoring
|
||||||
from typing import Any
|
from sientia_do.repository.minio_repository_sync import MinioRepository
|
||||||
import numpy as np
|
from sientia_do.temporal.constants import (
|
||||||
from pandas import DataFrame
|
DATETIME_FORMAT,
|
||||||
import traceback
|
DATETIME_FORMAT_MS_WITH_TZ,
|
||||||
|
DATETIME_FORMAT_WITH_TZ,
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
from sientia_do.utils.formatters import create_sample_dict
|
||||||
|
from sientia_model.model_repository.mlflow_repository import SientiaMLflowRepository
|
||||||
|
from sientia_model.model_repository.plugin_store import PluginStore
|
||||||
|
|
||||||
|
from laborious.utils.dataframe_debug import build_dataframe_debug_message
|
||||||
|
from laborious.utils.models.minio_dataframe_payload import MinioDataFramePayload
|
||||||
|
|
||||||
|
|
||||||
class MLFlow(BaseActivity):
|
class MLFlow(SientiaMonitoring):
|
||||||
"""
|
"""
|
||||||
MLFlow integration activities for model inference operations.
|
Temporal activities that talk to MLflow through ``SientiaMLflowRepository`` and ``SientiaModel`` wrappers.
|
||||||
|
|
||||||
This class provides activities for interacting with MLFlow models, including
|
Models are resolved by registered name and the ``production`` alias (not by legacy stages or
|
||||||
data transformation and prediction operations. It handles authentication,
|
separate transform/predict flavors). ``get_cached_model`` loads or reuses a wrapper; inference
|
||||||
data preprocessing, and model management with configurable retention policies.
|
uses ``wrapper.transform`` / ``wrapper.predict``; retrain uses ``wrapper.retrain`` or
|
||||||
|
``wrapper.train`` plus ``store_model`` and registry promotion via ``promote_to_alias``.
|
||||||
|
|
||||||
The class implements comprehensive error handling and logging for all
|
Large inputs and outputs flow through ``MinioDataFramePayload`` when workflows offload parquet
|
||||||
MLFlow operations, ensuring reliable model inference in production environments.
|
to MinIO. On failure, transform/predict still return a payload with ``success: False`` and
|
||||||
|
error details for downstream gates.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
mlflow_host (str): MLFlow server hostname
|
mlflow_repository: Client for tracking, registry, artifact download, and run lifecycle.
|
||||||
mlflow_port (int): MLFlow server port
|
plugin_store: Reference to the store (runtime is installed on the worker; reserved for
|
||||||
mlflow_username (str): MLFlow authentication username
|
future store-backed helpers).
|
||||||
mlflow_password (str): MLFlow authentication password
|
|
||||||
model_monitoring_repository (MLFlowRepository): Repository for MLFlow operations
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, mlflow_host: str, mlflow_port: int, mlflow_username: str,
|
_MAX_DEBUG_DATAFRAME_ROWS = 100
|
||||||
mlflow_password: str, logger: Logger, notification_handler: NotificationHandler):
|
_DEFAULT_MODEL_ALIAS = 'production'
|
||||||
|
_REFERENCE_ARTIFACT_CANDIDATES = ('evaluation_data.csv', 'test_data.csv')
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
mlflow_repository: SientiaMLflowRepository,
|
||||||
|
plugin_store: PluginStore,
|
||||||
|
minio_repository: MinioRepository | None = None,
|
||||||
|
logger: Logger | None = None,
|
||||||
|
notification_handler: NotificationHandler | None = None,
|
||||||
|
metrics_controller: MetricsController | None = None,
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Initialize MLFlow activities with server configuration.
|
Attach shared MLflow and MinIO clients used by all ML activities in this mixin.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
mlflow_host: MLFlow server hostname or IP address
|
- mlflow_repository: Repository built by ``Activities`` (or injected in tests).
|
||||||
mlflow_port: MLFlow server port number
|
- plugin_store: Plugin store instance from worker bootstrap.
|
||||||
mlflow_username: Username for MLFlow authentication
|
- minio_repository: MinIO client for ``MinioDataFramePayload`` upload/download.
|
||||||
mlflow_password: Password for MLFlow authentication
|
- logger: Structured logger.
|
||||||
logger: Logger instance for observability and debugging
|
- notification_handler: Notifications on hard failures where applicable.
|
||||||
notification_handler: Notification handler for alerts and monitoring
|
- metrics_controller: Shared metrics controller.
|
||||||
|
|
||||||
Raises:
|
Return:
|
||||||
Exception: If MLFlowRepository initialization fails
|
None
|
||||||
"""
|
"""
|
||||||
BaseActivity.__init__(
|
|
||||||
self, logger, notification_handler, set_error_counter=True)
|
|
||||||
self.mlflow_host = mlflow_host
|
|
||||||
self.mlflow_port = mlflow_port
|
|
||||||
self.mlflow_username = mlflow_username
|
|
||||||
self.mlflow_password = mlflow_password
|
|
||||||
|
|
||||||
self.model_monitoring_repository = MLFlowRepository(
|
self.minio_repository = minio_repository
|
||||||
f"{mlflow_host}:{mlflow_port}", mlflow_username, mlflow_password, logger
|
SientiaMonitoring.__init__(
|
||||||
|
self,
|
||||||
|
logger=logger,
|
||||||
|
notification_handler=notification_handler,
|
||||||
|
metrics_controller=metrics_controller,
|
||||||
|
)
|
||||||
|
self.mlflow_repository = mlflow_repository
|
||||||
|
self.plugin_store = plugin_store
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""
|
||||||
|
Release MinIO manager resources held by the mixin.
|
||||||
|
|
||||||
|
Return:
|
||||||
|
None
|
||||||
|
"""
|
||||||
|
if self.minio_repository is not None:
|
||||||
|
try:
|
||||||
|
self.minio_repository.close()
|
||||||
|
finally:
|
||||||
|
self.minio_repository = None
|
||||||
|
SientiaMonitoring.shutdown(self)
|
||||||
|
|
||||||
|
def __del__(self):
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
def _debug_dataframe(self, message: str, data: Any, metadata: dict[str, Any]) -> None:
|
||||||
|
"""
|
||||||
|
Log dataframe content only when row count is below the configured threshold
|
||||||
|
|
||||||
|
Args:
|
||||||
|
- message (str): Base log message to identify the dataframe in logs
|
||||||
|
- data (Any): Dataframe-like object expected to expose shape and to_csv
|
||||||
|
- metadata (dict[str, Any]): Workflow metadata for contextual logging
|
||||||
|
"""
|
||||||
|
self.debug(
|
||||||
|
build_dataframe_debug_message(
|
||||||
|
message=message,
|
||||||
|
data=data,
|
||||||
|
max_rows=self._MAX_DEBUG_DATAFRAME_ROWS,
|
||||||
|
),
|
||||||
|
metadata,
|
||||||
)
|
)
|
||||||
|
|
||||||
@activity.defn(name="request_transform")
|
def _detect_and_parse_datetime_index(self, data: pd.DataFrame, metadata: dict) -> pd.DataFrame:
|
||||||
async def request_transform(self, input_data: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
"""
|
"""
|
||||||
Transform input data using MLFlow models.
|
Ensure the transform output index is homogeneous and encoded as ``DATETIME_FORMAT_WITH_TZ`` strings.
|
||||||
|
|
||||||
This activity processes input data through MLFlow model transformation,
|
Accepts an all-string index (validated against the format), or all-``datetime`` /
|
||||||
including data preprocessing, format conversion, and validation. It handles
|
``Timestamp`` (naive timestamps are localized to UTC before formatting). Mixed element types
|
||||||
data deduplication, pivoting, and cleanup to ensure optimal model performance.
|
or unsupported types raise ``ValueError`` with a message logged at info level.
|
||||||
|
|
||||||
The transformation process includes:
|
|
||||||
1. Data deduplication based on variable and timestamp
|
|
||||||
2. Data pivoting for model input format
|
|
||||||
3. Null value handling and cleanup
|
|
||||||
4. MLFlow model transformation request
|
|
||||||
5. Response validation and logging
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
input_data: Configuration and data for transformation
|
- data: DataFrame whose index carries the time dimension after transform.
|
||||||
Required keys:
|
- metadata: Workflow metadata for log correlation.
|
||||||
- metadata (dict): Workflow execution metadata
|
|
||||||
- data (dict): Input data for transformation
|
|
||||||
- model_name (str): Name of the MLFlow model to use
|
|
||||||
- model_retention (int): Model retention period in minutes
|
|
||||||
|
|
||||||
Returns:
|
Return:
|
||||||
dict: Transformed data from MLFlow model
|
``pd.DataFrame``: Same frame with a normalized string index; empty frames are returned as-is.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if data.empty:
|
||||||
|
self.info('Data is empty, skipping datetime index detection and parsing', metadata)
|
||||||
|
return data
|
||||||
|
|
||||||
|
index = data.index
|
||||||
|
index_type = type(index[0])
|
||||||
|
|
||||||
|
self.info(f'Index type: {index_type}', metadata)
|
||||||
|
|
||||||
|
message = (
|
||||||
|
f'Index must be all timestamp like column. Valid formats are: pandas Timestamp, datetime, '
|
||||||
|
f'string in format {DATETIME_FORMAT_WITH_TZ}.'
|
||||||
|
)
|
||||||
|
|
||||||
|
if not all(isinstance(i, index_type) for i in index):
|
||||||
|
types = map(str, map(type, index))
|
||||||
|
raise ValueError(f'{message}. Elements are {",".join(types)}')
|
||||||
|
|
||||||
|
if index_type is str:
|
||||||
|
try:
|
||||||
|
pd.to_datetime(data.index, format=DATETIME_FORMAT_WITH_TZ)
|
||||||
|
except ValueError as e:
|
||||||
|
raise ValueError(f'{message}. Unable to parse given date format: {e}') from e
|
||||||
|
|
||||||
|
elif index_type is datetime or index_type is pd.Timestamp:
|
||||||
|
idx = data.index
|
||||||
|
if hasattr(idx, 'tz') and idx.tz is None:
|
||||||
|
data.index = idx.tz_localize('UTC') # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
data.index = data.index.strftime(DATETIME_FORMAT_WITH_TZ) # type: ignore[attr-defined]
|
||||||
|
else:
|
||||||
|
raise ValueError(f'{message}. Got {index_type}.')
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
def _resolve_model_version_for_run(self, run_id: str) -> str:
|
||||||
|
"""
|
||||||
|
Map an MLflow ``run_id`` to the latest registered model version that produced that run.
|
||||||
|
|
||||||
|
``search_model_versions`` may return multiple versions if the model was registered more than
|
||||||
|
once for the same run; the highest numeric ``version`` wins so promotion targets the newest
|
||||||
|
artifact set.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
- run_id: Run UUID from ``retrain_model`` / experiment payload.
|
||||||
|
|
||||||
|
Return:
|
||||||
|
str: Registry version string acceptable by ``promote_to_alias``.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
Exception: If transformation fails or MLFlow model is unavailable
|
ValueError: If the filter returns no versions (model not registered for this run).
|
||||||
|
"""
|
||||||
|
|
||||||
|
versions = self.mlflow_repository._client.search_model_versions(
|
||||||
|
filter_string=f"run_id='{run_id}'"
|
||||||
|
)
|
||||||
|
if not versions:
|
||||||
|
raise ValueError(f'No registered model version found for run_id={run_id}')
|
||||||
|
latest = max(versions, key=lambda v: int(v.version))
|
||||||
|
return str(latest.version)
|
||||||
|
|
||||||
|
def _resolve_model_alias(self, model_config: dict[str, Any] | None = None) -> str:
|
||||||
|
"""
|
||||||
|
Resolve which MLflow alias should be used for model lookup/promotion.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
- model_config: Optional model configuration that may include ``alias``.
|
||||||
|
|
||||||
|
Return:
|
||||||
|
str: Alias name trimmed and normalized; defaults to ``production``.
|
||||||
|
"""
|
||||||
|
if not model_config:
|
||||||
|
return self._DEFAULT_MODEL_ALIAS
|
||||||
|
alias = str(model_config.get('alias', self._DEFAULT_MODEL_ALIAS)).strip()
|
||||||
|
return alias or self._DEFAULT_MODEL_ALIAS
|
||||||
|
|
||||||
|
@activity.defn(name='request_transform')
|
||||||
|
def request_transform(self, input_data: dict[str, Any]) -> MinioDataFramePayload:
|
||||||
|
"""
|
||||||
|
Pivot long-format sensor rows, load the production wrapper, and run ``wrapper.transform``.
|
||||||
|
|
||||||
|
Expected tabular shape after load: columns including ``variable``, ``timestamp``, ``value``,
|
||||||
|
and ``created_at`` for deduplication. Data are sorted by ``created_at``, de-duplicated per
|
||||||
|
``(variable, timestamp)``, pivoted wide, then passed to the model. ``model_config`` may
|
||||||
|
include ``retention_minutes`` for wrapper cache TTL.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
- input_data: Dict with ``metadata``, ``model_name``, ``data`` (``MinioDataFramePayload``
|
||||||
|
dict or inline dataframe dict), and optional ``model_config``.
|
||||||
|
|
||||||
|
Return:
|
||||||
|
``MinioDataFramePayload`` with transformed frame and ``success: True``, or a payload
|
||||||
|
with ``success: False`` and exception details in ``status`` if transform fails.
|
||||||
"""
|
"""
|
||||||
metadata = input_data['metadata']
|
metadata = input_data['metadata']
|
||||||
self.info('Transforming data...', metadata)
|
self.info('Transforming data...', metadata)
|
||||||
data = DataFrame(input_data['data'])
|
|
||||||
|
payload = MinioDataFramePayload.from_dict(input_data['data'])
|
||||||
|
data = payload.retrieve(self.minio_repository, metadata)
|
||||||
|
|
||||||
model_name = input_data['model_name']
|
model_name = input_data['model_name']
|
||||||
model_config = input_data.get('model_config', {})
|
model_config = input_data.get('model_config', {})
|
||||||
|
model_alias = self._resolve_model_alias(model_config)
|
||||||
|
|
||||||
self.debug("Raw input data:", metadata)
|
self._debug_dataframe('Raw input data:', data, metadata)
|
||||||
self.debug(data.head(5).to_string(), metadata)
|
|
||||||
|
|
||||||
# Sort by created_at in descending order and keep first occurrence of each variable/timestamp pair
|
# Long → wide: keep newest row per (variable, timestamp), then pivot for the wrapper API.
|
||||||
data = data.sort_values('created_at', ascending=False).drop_duplicates(
|
data = data.sort_values('created_at', ascending=False).drop_duplicates(
|
||||||
subset=['variable', 'timestamp'], keep='first'
|
subset=['variable', 'timestamp'], keep='first'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Pivot data for model input format
|
data = data.pivot(index='timestamp', columns='variable', values='value')
|
||||||
data = data.pivot(
|
|
||||||
index='timestamp', columns='variable',
|
|
||||||
values='value')
|
|
||||||
data.fillna(np.nan, inplace=True)
|
data.fillna(np.nan, inplace=True)
|
||||||
# data.reset_index(inplace=True)
|
|
||||||
data.columns.name = None
|
data.columns.name = None
|
||||||
|
data.index.name = None
|
||||||
|
|
||||||
self.debug("Processed input data:", metadata)
|
data['timestamp'] = data.index
|
||||||
self.debug(data.head(5).to_string(), metadata)
|
|
||||||
|
|
||||||
# Request transformation from MLFlow model
|
self._debug_dataframe('Processed input data:', data, metadata)
|
||||||
response_data = self.model_monitoring_repository.transform(
|
|
||||||
model_name, data, model_config, metadata
|
try:
|
||||||
|
wrapper = self.mlflow_repository.get_cached_model(
|
||||||
|
model_name=model_name,
|
||||||
|
alias=model_alias,
|
||||||
|
retention_minutes=model_config.get('retention_minutes', 0),
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
transformed_df, transform_meta = wrapper.transform(data)
|
||||||
|
if transform_meta:
|
||||||
|
self.info(f'Wrapper transform metadata: {transform_meta}', metadata)
|
||||||
|
|
||||||
|
transformed_df = self._detect_and_parse_datetime_index(transformed_df, metadata)
|
||||||
|
|
||||||
|
response_data: dict[str, Any] = {'success': True, 'content': transformed_df}
|
||||||
|
except Exception as e:
|
||||||
|
response_data = {
|
||||||
|
'success': False,
|
||||||
|
'content': {'message': str(e), 'traceback': traceback.format_exc()},
|
||||||
|
}
|
||||||
|
|
||||||
|
self.debug(
|
||||||
|
f'Transform raw response data: \n {create_sample_dict(response_data, max_items=5, max_depth=5)}',
|
||||||
|
metadata,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.debug(
|
self.debug(
|
||||||
f"Transform raw response data: \n {create_sample_dict(response_data, max_items=5, max_depth=5)}", metadata)
|
f'Transform response data: \n {create_sample_dict(response_data, max_items=5, max_depth=5)}',
|
||||||
|
metadata,
|
||||||
|
)
|
||||||
|
|
||||||
self.debug(
|
self.info('Data transformed successfully', metadata)
|
||||||
f"Transform response data: \n {create_sample_dict(response_data, max_items=5, max_depth=5)}", metadata)
|
|
||||||
|
|
||||||
self.info("Data transformed successfully", metadata)
|
if not response_data.get('success', False):
|
||||||
|
return MinioDataFramePayload.from_dataframe(
|
||||||
|
dataframe=None,
|
||||||
|
minio_repo=self.minio_repository,
|
||||||
|
model_name=model_name,
|
||||||
|
operation='transform',
|
||||||
|
status=response_data,
|
||||||
|
workflow_metadata=metadata,
|
||||||
|
last_timestamp=payload.last_timestamp,
|
||||||
|
logger=self.logger,
|
||||||
|
)
|
||||||
|
|
||||||
return response_data
|
return MinioDataFramePayload.from_dataframe(
|
||||||
|
dataframe=response_data['content'],
|
||||||
|
minio_repo=self.minio_repository,
|
||||||
|
model_name=model_name,
|
||||||
|
operation='transform',
|
||||||
|
workflow_metadata=metadata,
|
||||||
|
status={
|
||||||
|
'success': True,
|
||||||
|
},
|
||||||
|
last_timestamp=payload.last_timestamp,
|
||||||
|
logger=self.logger,
|
||||||
|
)
|
||||||
|
|
||||||
@activity.defn(name="request_predict")
|
@activity.defn(name='request_predict')
|
||||||
async def request_predict(self, input_data: dict[str, Any]) -> dict[str, Any]:
|
def request_predict(self, input_data: dict[str, Any]) -> MinioDataFramePayload:
|
||||||
"""
|
"""
|
||||||
Execute predictions using MLFlow models.
|
Load the production wrapper and call ``wrapper.predict`` on the prepared feature frame.
|
||||||
|
|
||||||
This activity performs ML model inference using MLFlow models with the
|
The activity normalizes ``NaN`` to ``None`` for JSON-friendly columns, sets the row index
|
||||||
transformed data. It handles data format conversion, null value processing,
|
the same way as ``retrain_model`` (UTC ``DatetimeIndex`` from ``DATETIME_FORMAT_WITH_TZ``),
|
||||||
and model prediction requests with comprehensive error handling.
|
restores that index on the prediction frame, normalizes the prediction index to
|
||||||
|
``DATETIME_FORMAT_WITH_TZ`` strings like ``request_transform``, and records ``response_time``.
|
||||||
The prediction process includes:
|
Non-DataFrame predictions are coerced to a single ``prediction`` column.
|
||||||
1. Data format validation and cleanup
|
|
||||||
2. Null value handling for model compatibility
|
|
||||||
3. MLFlow model prediction request
|
|
||||||
4. Response validation and logging
|
|
||||||
5. Performance monitoring and metrics
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
input_data: Configuration and data for prediction
|
- input_data: Same envelope as ``request_transform`` (``metadata``, ``model_name``,
|
||||||
Required keys:
|
``data``, optional ``model_config`` with ``retention_minutes``).
|
||||||
- metadata (dict): Workflow execution metadata
|
|
||||||
- data (dict): Transformed data for prediction
|
|
||||||
- model_name (str): Name of the MLFlow model to use
|
|
||||||
- model_retention (int): Model retention period in minutes
|
|
||||||
|
|
||||||
Returns:
|
Return:
|
||||||
dict: Prediction results from MLFlow model
|
``MinioDataFramePayload`` with predictions or error status mirroring transform behaviour.
|
||||||
|
|
||||||
Raises:
|
|
||||||
Exception: If prediction fails or MLFlow model is unavailable
|
|
||||||
"""
|
"""
|
||||||
metadata = input_data['metadata']
|
metadata = input_data['metadata']
|
||||||
self.info('Predicting data...', metadata)
|
self.info('Predicting data...', metadata)
|
||||||
data = DataFrame(input_data['data'])
|
|
||||||
|
payload = MinioDataFramePayload.from_dict(input_data['data'])
|
||||||
|
data = payload.retrieve(self.minio_repository, metadata)
|
||||||
|
|
||||||
model_name = input_data['model_name']
|
model_name = input_data['model_name']
|
||||||
model_config = input_data.get('model_config', {})
|
model_config = input_data.get('model_config', {})
|
||||||
|
model_alias = self._resolve_model_alias(model_config)
|
||||||
|
|
||||||
self.debug(f"Input data for: \n {data.head(5).to_string()}", metadata)
|
self._debug_dataframe('Input data for prediction:', data, metadata)
|
||||||
|
|
||||||
# Convert numpy.nan to None for model compatibility
|
|
||||||
data.replace(np.nan, None, inplace=True)
|
data.replace(np.nan, None, inplace=True)
|
||||||
|
|
||||||
data['timestamp'] = data.index
|
data.index = pd.DatetimeIndex(
|
||||||
data['timestamp'] = to_datetime(
|
to_datetime(data.index, format=DATETIME_FORMAT_WITH_TZ, utc=True)
|
||||||
data['timestamp'], format=DATETIME_FORMAT_WITH_TZ).dt.strftime(DATETIME_FORMAT)
|
|
||||||
|
|
||||||
# Request prediction from MLFlow model
|
|
||||||
response_data = self.model_monitoring_repository.predict(
|
|
||||||
model_name, data, model_config, metadata
|
|
||||||
)
|
)
|
||||||
|
input_index = data.index
|
||||||
|
|
||||||
|
try:
|
||||||
|
wrapper = self.mlflow_repository.get_cached_model(
|
||||||
|
model_name=model_name,
|
||||||
|
alias=model_alias,
|
||||||
|
retention_minutes=model_config.get('retention_minutes', 0),
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
start_time = datetime.now()
|
||||||
|
predict_data, pred_meta = wrapper.predict({}, data)
|
||||||
|
end_time = datetime.now()
|
||||||
|
|
||||||
|
if pred_meta:
|
||||||
|
self.info(f'Wrapper predict metadata: {pred_meta}', metadata)
|
||||||
|
|
||||||
|
if isinstance(predict_data, DataFrame):
|
||||||
|
self._debug_dataframe(
|
||||||
|
'Data received from model prediction:', predict_data, metadata
|
||||||
|
)
|
||||||
|
predict_data.columns = pd.Index(['prediction'])
|
||||||
|
else:
|
||||||
|
self.debug(
|
||||||
|
f'Data received from model prediction (not a DataFrame): {predict_data}',
|
||||||
|
metadata,
|
||||||
|
)
|
||||||
|
predict_data = pd.DataFrame(predict_data, columns=['prediction'])
|
||||||
|
|
||||||
|
predict_data.index = input_index
|
||||||
|
predict_data['response_time'] = (end_time - start_time).total_seconds()
|
||||||
|
predict_data = self._detect_and_parse_datetime_index(predict_data, metadata)
|
||||||
|
|
||||||
|
response_data: dict[str, Any] = {'success': True, 'content': predict_data}
|
||||||
|
except Exception as e:
|
||||||
|
response_data = {
|
||||||
|
'success': False,
|
||||||
|
'content': {'message': str(e), 'traceback': traceback.format_exc()},
|
||||||
|
}
|
||||||
|
|
||||||
self.debug(
|
self.debug(
|
||||||
f"Prediction response data: \n {create_sample_dict(response_data, max_items=5, max_depth=5)}", metadata)
|
f'Prediction response data: \n {create_sample_dict(response_data, max_items=5, max_depth=5)}',
|
||||||
|
metadata,
|
||||||
|
)
|
||||||
|
|
||||||
self.info("Data predicted successfully", metadata)
|
self.info('Data predicted successfully', metadata)
|
||||||
|
|
||||||
return response_data
|
if not response_data.get('success', False):
|
||||||
|
return MinioDataFramePayload.from_dataframe(
|
||||||
|
dataframe=None,
|
||||||
|
minio_repo=self.minio_repository,
|
||||||
|
model_name=model_name,
|
||||||
|
operation='predict',
|
||||||
|
status=response_data,
|
||||||
|
workflow_metadata=metadata,
|
||||||
|
last_timestamp=payload.last_timestamp,
|
||||||
|
logger=self.logger,
|
||||||
|
)
|
||||||
|
|
||||||
@activity.defn(name="retrain_model")
|
return MinioDataFramePayload.from_dataframe(
|
||||||
async def retrain_model(self, input_data: dict[str, Any]) -> dict[str, Any]:
|
dataframe=response_data['content'],
|
||||||
|
minio_repo=self.minio_repository,
|
||||||
|
model_name=model_name,
|
||||||
|
operation='predict',
|
||||||
|
workflow_metadata=metadata,
|
||||||
|
status={
|
||||||
|
'success': True,
|
||||||
|
},
|
||||||
|
last_timestamp=payload.last_timestamp,
|
||||||
|
logger=self.logger,
|
||||||
|
)
|
||||||
|
|
||||||
|
@activity.defn(name='retrain_model')
|
||||||
|
def retrain_model(self, input_data: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Retrain MLFlow models with updated training data.
|
Fit an updated wrapper from historical data, then log and register in MLflow.
|
||||||
|
|
||||||
This activity orchestrates the complete model retraining process,
|
Flow: load long-format data from MinIO → dedupe/pivot like inference prep → require
|
||||||
including data preparation, model retraining execution, and result
|
``model_config['target']`` → read current ``production`` version for ``source_run_id`` tag →
|
||||||
validation. It handles data preprocessing, column cleanup, and
|
run ``wrapper.retrain`` outside run timing → ``start_run`` with retrain tags → log input
|
||||||
comprehensive error handling for production model management.
|
CSV artifact → ``store_model`` and ``log_params``. Does not promote; the workflow calls
|
||||||
|
``update_production_model`` after validation.
|
||||||
The retraining process includes:
|
|
||||||
1. Data timestamp extraction and validation
|
|
||||||
2. Column cleanup and data preparation
|
|
||||||
3. Data pivoting for model input format
|
|
||||||
4. MLFlow model retraining execution
|
|
||||||
5. Result validation and error handling
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
input_data (dict): Input data containing:
|
- input_data: Must include ``metadata``, ``model_name``, ``data`` (payload), and
|
||||||
- metadata (dict): Workflow execution metadata
|
``model_config`` with at least ``target``.
|
||||||
- data (dict[str, Any]): Training data for model retraining
|
|
||||||
- model_name (str): Name of the MLFlow model to retrain
|
|
||||||
|
|
||||||
Returns:
|
Return:
|
||||||
dict: Retraining results containing:
|
On success: ``success``, ``experiment`` (``run_id``, ``experiment_id``, ``experiment_name``),
|
||||||
- status (str): Retraining operation status
|
``message``, ``timestamp``. On failure: ``success: False``, error fields, and optional trace.
|
||||||
- timestamp (str): Timestamp of the retraining operation
|
|
||||||
- experiment (str): MLFlow experiment identifier
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
Exception: If retraining fails or encounters critical errors
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
if self.minio_repository is None:
|
||||||
|
raise ValueError('Minio repository not initialized')
|
||||||
|
|
||||||
metadata = input_data['metadata']
|
metadata = input_data['metadata']
|
||||||
data = DataFrame(input_data['data'])
|
|
||||||
|
try:
|
||||||
|
payload = MinioDataFramePayload.from_dict(input_data['data'])
|
||||||
|
data = payload.retrieve(self.minio_repository, metadata)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
trace = traceback.format_exc()
|
||||||
|
self.send_notification(
|
||||||
|
metadata=metadata,
|
||||||
|
notification_id='ERROR_LOADING_RETRAIN_DATA',
|
||||||
|
message=f'Error loading retrain data: {e}',
|
||||||
|
block='retrain_model',
|
||||||
|
level=NotificationLevel.ERROR,
|
||||||
|
attachment_content=trace,
|
||||||
|
)
|
||||||
|
self.error(trace, metadata)
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'message': f'Error loading retrain data: {e}',
|
||||||
|
'traceback': trace,
|
||||||
|
'timestamp': now().strftime(DATETIME_FORMAT_MS_WITH_TZ),
|
||||||
|
}
|
||||||
|
|
||||||
|
self.debug(f'Retrain data loaded successfully: shape {data.shape}', metadata)
|
||||||
|
|
||||||
model_name = input_data['model_name']
|
model_name = input_data['model_name']
|
||||||
|
model_config = input_data.get('model_config', {})
|
||||||
|
|
||||||
self.info(f'Retraining model {model_name}...', metadata)
|
self.info(f'Retraining model {model_name}...', metadata)
|
||||||
|
|
||||||
timestamp = data['timestamp'].max()
|
timestamp = data['timestamp'].max()
|
||||||
self.debug(f'Timestamp: {timestamp}', metadata)
|
self.debug(f'Timestamp: {timestamp}', metadata)
|
||||||
|
|
||||||
|
if 'created_at' in data.columns:
|
||||||
|
data = data.sort_values('created_at', ascending=False).drop_duplicates(
|
||||||
|
subset=['variable', 'timestamp'], keep='first'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
data = data.drop_duplicates(subset=['variable', 'timestamp'], keep='first')
|
||||||
|
|
||||||
data.drop(columns=['model_id'], inplace=True, errors='ignore')
|
data.drop(columns=['model_id'], inplace=True, errors='ignore')
|
||||||
data.drop(columns=['created_at'], inplace=True, errors='ignore')
|
data.drop(columns=['created_at'], inplace=True, errors='ignore')
|
||||||
|
|
||||||
data = data.pivot(index='timestamp', columns='variable',
|
data = data.pivot(index='timestamp', columns='variable', values='value')
|
||||||
values='value')
|
data.fillna(np.nan, inplace=True)
|
||||||
data.sort_index(inplace=True)
|
|
||||||
data.reset_index(inplace=True)
|
|
||||||
|
|
||||||
data = data.dropna()
|
|
||||||
data.columns.name = None
|
data.columns.name = None
|
||||||
|
data.index.name = None
|
||||||
|
|
||||||
|
data.index = pd.DatetimeIndex(
|
||||||
|
to_datetime(data.index, format=DATETIME_FORMAT_WITH_TZ, utc=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
target = model_config.get('target')
|
||||||
|
if target is None:
|
||||||
|
msg = 'model_config must include "target" for retraining'
|
||||||
|
self.info(msg, metadata)
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'experiment': None,
|
||||||
|
'message': msg,
|
||||||
|
'traceback': '',
|
||||||
|
'timestamp': str(timestamp),
|
||||||
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
retrain_output, experiment = self.model_monitoring_repository.retrain_model(
|
model_alias = self._resolve_model_alias(model_config)
|
||||||
data=data,
|
mv_src = self.mlflow_repository._client.get_model_version_by_alias(
|
||||||
model_name=model_name
|
name=model_name,
|
||||||
|
alias=model_alias,
|
||||||
)
|
)
|
||||||
|
source_run_id = mv_src.run_id
|
||||||
|
|
||||||
|
wrapper = self.mlflow_repository.get_cached_model(
|
||||||
|
model_name=model_name,
|
||||||
|
alias=model_alias,
|
||||||
|
retention_minutes=0,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Keep heavy model fitting outside MLflow run timing.
|
||||||
|
prediction_data = wrapper.retrain(data)
|
||||||
|
prediction_data.rename(columns={target: 'prediction'}, inplace=True)
|
||||||
|
|
||||||
|
# Merge prediction data with retrain data
|
||||||
|
evaluation_data = pd.merge(
|
||||||
|
data, prediction_data, left_index=True, right_index=True, how='left'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Rename target column to "target"
|
||||||
|
evaluation_data.rename(columns={target: 'target'}, inplace=True)
|
||||||
|
|
||||||
|
# Reset index and put as column "timestamp"
|
||||||
|
evaluation_data['timestamp'] = evaluation_data.index
|
||||||
|
evaluation_data.reset_index(drop=True, inplace=True)
|
||||||
|
evaluation_data.sort_values(by='timestamp', inplace=True, ascending=True)
|
||||||
|
|
||||||
|
run_name = f'{model_name}-retrain-{datetime.now().strftime("%Y%m%d%H%M%S")}'
|
||||||
|
|
||||||
|
with self.mlflow_repository.start_run(
|
||||||
|
model_name=model_name,
|
||||||
|
run_name=run_name,
|
||||||
|
experiment_name=model_name,
|
||||||
|
tags={'retrain': 'true', 'source_run_id': source_run_id},
|
||||||
|
metadata=metadata,
|
||||||
|
) as run_info:
|
||||||
|
tmp_dir = tempfile.mkdtemp(prefix='laborious_retrain_')
|
||||||
|
try:
|
||||||
|
raw_csv = Path(tmp_dir) / 'retrain_input.csv'
|
||||||
|
evaluation_csv = Path(tmp_dir) / 'evaluation_data.csv'
|
||||||
|
data.to_csv(raw_csv, index=False)
|
||||||
|
evaluation_data.to_csv(evaluation_csv, index=False)
|
||||||
|
|
||||||
|
mlflow.log_artifact(str(raw_csv))
|
||||||
|
mlflow.log_artifact(str(evaluation_csv))
|
||||||
|
finally:
|
||||||
|
rmtree(tmp_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
wrapper.store_model(name=model_name)
|
||||||
|
|
||||||
|
self.mlflow_repository.log_params(
|
||||||
|
{
|
||||||
|
'retrain': 'true',
|
||||||
|
'retrain_date': datetime.now().isoformat(),
|
||||||
|
'source_run_id': source_run_id,
|
||||||
|
'retrain_samples': str(data.shape),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
experiment_payload = {
|
||||||
|
'run_id': run_info.run_id,
|
||||||
|
'experiment_id': run_info.experiment_id,
|
||||||
|
'experiment_name': model_name,
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'status': retrain_output,
|
'success': True,
|
||||||
'timestamp': timestamp,
|
'experiment': experiment_payload,
|
||||||
'experiment': experiment
|
'message': 'Model retrained successfully.',
|
||||||
|
'timestamp': str(timestamp),
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
trace = traceback.format_exc()
|
error_msg = f'Error retraining model {model_name}: {e}'
|
||||||
self.send_notification(
|
self.info(error_msg, metadata)
|
||||||
metadata=metadata,
|
return {
|
||||||
notification_id='RETRAIN_MODEL_ERROR',
|
'success': False,
|
||||||
message=f'Error retraining model {model_name}: {e}',
|
'experiment': None,
|
||||||
block='retrain_model',
|
'message': error_msg,
|
||||||
level=NotificationLevel.ERROR,
|
'traceback': traceback.format_exc(),
|
||||||
attachment_content=trace
|
'timestamp': str(timestamp),
|
||||||
)
|
}
|
||||||
self.error(trace, metadata=metadata)
|
|
||||||
raise e
|
|
||||||
|
|
||||||
@activity.defn(name="update_production_model")
|
@activity.defn(name='update_production_model')
|
||||||
async def update_production_model(self, input_data: dict[str, Any]) -> dict[Any, Any]:
|
def update_production_model(self, input_data: dict[str, Any]) -> dict[Any, Any]:
|
||||||
"""
|
"""
|
||||||
Update production model with newly trained model version.
|
Point the ``production`` alias at the model version registered for the retrain run.
|
||||||
|
|
||||||
This activity manages the critical process of updating production
|
Resolves the highest numeric registry version whose ``run_id`` matches
|
||||||
models with newly trained versions. It handles model deployment,
|
``experiment['run_id']``, then calls ``promote_to_alias``. On failure, sends a notification
|
||||||
status tracking, and comprehensive reporting for operational
|
and re-raises so the workflow can surface the error.
|
||||||
visibility and audit trails.
|
|
||||||
|
|
||||||
The update process includes:
|
|
||||||
1. Production model update execution
|
|
||||||
2. Status and metadata tracking
|
|
||||||
3. Comprehensive reporting and logging
|
|
||||||
4. Error handling and notification
|
|
||||||
5. Audit trail maintenance
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
input_data (dict): Input data containing:
|
- input_data: ``metadata``, ``model_name``, and ``experiment`` with ``run_id`` and
|
||||||
- metadata (dict): Workflow execution metadata
|
``experiment_id`` (as returned from ``retrain_model``).
|
||||||
- model_name (str): Name of the MLFlow model to update
|
|
||||||
- experiment (str): MLFlow experiment identifier
|
|
||||||
- model_id (str): Unique identifier for the model version
|
|
||||||
- timestamp (str): Timestamp of the update operation
|
|
||||||
- status (str): Current status of the model update
|
|
||||||
|
|
||||||
Returns:
|
Return:
|
||||||
dict[Any, Any]: Comprehensive update report containing:
|
Dict with ``model_name``, promoted ``version``, ``mlflow_run_id``, ``mlflow_experiment_id``.
|
||||||
- model_id (str): Model version identifier
|
|
||||||
- model_name (str): Name of the updated model
|
|
||||||
- timestamp (str): Update operation timestamp
|
|
||||||
- status (str): Update operation status
|
|
||||||
- Additional MLFlow response metadata
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
Exception: If production model update fails
|
|
||||||
"""
|
"""
|
||||||
metadata = input_data['metadata']
|
metadata = input_data['metadata']
|
||||||
model_name = input_data['model_name']
|
model_name = input_data['model_name']
|
||||||
model_id = input_data['model_id']
|
|
||||||
experiment = input_data['experiment']
|
experiment = input_data['experiment']
|
||||||
timestamp = input_data['timestamp']
|
|
||||||
status = input_data['status']
|
|
||||||
|
|
||||||
self.info(
|
self.info(
|
||||||
f'Updating production model {model_name} from experiment {experiment}...', metadata)
|
f'Updating production model {model_name} from experiment {experiment}...', metadata
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = self.model_monitoring_repository.update_production_model(
|
run_id = experiment['run_id']
|
||||||
experiment=experiment,
|
experiment_id = experiment['experiment_id']
|
||||||
model_name=model_name
|
|
||||||
|
version = self._resolve_model_version_for_run(run_id)
|
||||||
|
|
||||||
|
promote_alias = self._resolve_model_alias(input_data.get('model_config'))
|
||||||
|
self.mlflow_repository.promote_to_alias(
|
||||||
|
model_name=model_name,
|
||||||
|
version=version,
|
||||||
|
alias=promote_alias,
|
||||||
|
metadata=metadata,
|
||||||
)
|
)
|
||||||
|
|
||||||
report = DataFrame([response])
|
self.info(f'Production model {model_name} updated successfully', metadata)
|
||||||
report['model_id'] = model_id
|
return {
|
||||||
report['model_name'] = model_name
|
'model_name': model_name,
|
||||||
report['timestamp'] = timestamp
|
'version': version,
|
||||||
report['status'] = status
|
'mlflow_run_id': run_id,
|
||||||
|
'mlflow_experiment_id': experiment_id,
|
||||||
self.info(
|
}
|
||||||
f'Production model {model_name} updated successfully', metadata)
|
|
||||||
return report.to_dict()
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
trace = traceback.format_exc()
|
trace = traceback.format_exc()
|
||||||
@@ -337,7 +653,103 @@ class MLFlow(BaseActivity):
|
|||||||
message=f'Error updating production model {model_name}: {e}',
|
message=f'Error updating production model {model_name}: {e}',
|
||||||
block='update_production_model',
|
block='update_production_model',
|
||||||
level=NotificationLevel.ERROR,
|
level=NotificationLevel.ERROR,
|
||||||
attachment_content=trace
|
attachment_content=trace,
|
||||||
)
|
)
|
||||||
self.error(trace, metadata=metadata)
|
self.error(trace, metadata=metadata)
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
|
def _resolve_reference_artifact_name(self, run_id: str) -> str | None:
|
||||||
|
"""
|
||||||
|
Pick the first available reference CSV artifact path from the MLflow run.
|
||||||
|
|
||||||
|
Candidates are checked in priority order: ``retrain_input.csv``, then ``train_data.csv``.
|
||||||
|
A path matches when it equals the candidate or ends with ``/<candidate>`` for nested layouts.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
- run_id: MLflow run UUID linked to the production model version.
|
||||||
|
|
||||||
|
Return:
|
||||||
|
Artifact path string for ``download_artifacts``, or ``None`` if no candidate exists.
|
||||||
|
"""
|
||||||
|
listed = self.mlflow_repository._client.list_artifacts(run_id)
|
||||||
|
paths = [file_info.path for file_info in listed]
|
||||||
|
for candidate in self._REFERENCE_ARTIFACT_CANDIDATES:
|
||||||
|
for path in paths:
|
||||||
|
if path == candidate or path.endswith(f'/{candidate}'):
|
||||||
|
return path
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _find_downloaded_csv(self, tmpdir: str, artifact_name: str) -> Path | None:
|
||||||
|
"""
|
||||||
|
Locate a downloaded reference CSV in the temp directory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
- tmpdir: Directory where ``download_artifacts`` wrote files.
|
||||||
|
- artifact_name: Basename of the resolved artifact (e.g. ``retrain_input.csv``).
|
||||||
|
|
||||||
|
Return:
|
||||||
|
``Path`` to the CSV file if found, else ``None``.
|
||||||
|
"""
|
||||||
|
direct = Path(tmpdir) / artifact_name
|
||||||
|
if direct.exists():
|
||||||
|
return direct
|
||||||
|
matches = list(Path(tmpdir).rglob(artifact_name))
|
||||||
|
return matches[0] if matches else None
|
||||||
|
|
||||||
|
@activity.defn(name='get_reference_data')
|
||||||
|
def get_reference_data(self, input_data: dict[str, Any]) -> list[dict] | None:
|
||||||
|
"""
|
||||||
|
Download reference training CSV from the MLflow run linked to the production alias.
|
||||||
|
|
||||||
|
Resolves ``retrain_input.csv`` or ``train_data.csv`` via artifact listing before download.
|
||||||
|
``retrain_input.csv`` is preferred when both exist (most recent retrain snapshot). Used by
|
||||||
|
drift workflows to compare live data against the reference distribution logged with the model.
|
||||||
|
Timestamps are normalized to ``DATETIME_FORMAT`` string columns before returning records.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
- input_data: ``metadata``, ``model_name``, and optional ``model_config`` with ``alias``.
|
||||||
|
|
||||||
|
Return:
|
||||||
|
List of row dicts with normalized timestamps, or ``None`` if resolution or load fails.
|
||||||
|
"""
|
||||||
|
metadata = input_data['metadata']
|
||||||
|
model_name = input_data['model_name']
|
||||||
|
|
||||||
|
try:
|
||||||
|
model_alias = self._resolve_model_alias(input_data.get('model_config'))
|
||||||
|
mv = self.mlflow_repository._client.get_model_version_by_alias(
|
||||||
|
name=model_name,
|
||||||
|
alias=model_alias,
|
||||||
|
)
|
||||||
|
run_id = mv.run_id
|
||||||
|
|
||||||
|
artifact_path = self._resolve_reference_artifact_name(run_id)
|
||||||
|
if artifact_path is None:
|
||||||
|
self.warning(f'Reference data not found for model {model_name}', metadata)
|
||||||
|
return None
|
||||||
|
|
||||||
|
artifact_name = Path(artifact_path).name
|
||||||
|
tmpdir = tempfile.mkdtemp(prefix='laborious_eval_')
|
||||||
|
try:
|
||||||
|
self.mlflow_repository.download_artifacts(
|
||||||
|
run_id=run_id,
|
||||||
|
artifact_path=artifact_path,
|
||||||
|
dst_path=tmpdir,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
csv_path = self._find_downloaded_csv(tmpdir, artifact_name)
|
||||||
|
if csv_path is None:
|
||||||
|
self.warning(f'Reference data not found for model {model_name}', metadata)
|
||||||
|
return None
|
||||||
|
reference_data = pd.read_csv(csv_path)
|
||||||
|
finally:
|
||||||
|
rmtree(tmpdir, ignore_errors=True)
|
||||||
|
|
||||||
|
reference_data['timestamp'] = to_datetime(reference_data['timestamp'])
|
||||||
|
reference_data['timestamp'] = reference_data['timestamp'].dt.strftime(DATETIME_FORMAT)
|
||||||
|
|
||||||
|
return reference_data.to_dict(orient='records')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.warning(f'Reference data not found for model {model_name}: {e}', metadata)
|
||||||
|
return None
|
||||||
|
|||||||
432
laborious/activities/model_metrics.py
Normal file
432
laborious/activities/model_metrics.py
Normal file
@@ -0,0 +1,432 @@
|
|||||||
|
from temporalio import activity, workflow
|
||||||
|
|
||||||
|
with workflow.unsafe.imports_passed_through():
|
||||||
|
import time
|
||||||
|
import traceback
|
||||||
|
import warnings
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from pandas import DataFrame, Index, Series, to_datetime
|
||||||
|
from sientia_do.notifications.handlers import CoreNotificationHandler as NotificationHandler
|
||||||
|
from sientia_do.notifications.models import NotificationLevel
|
||||||
|
from sientia_do.observability.logger import Logger
|
||||||
|
from sientia_do.observability.metrics_controller import MetricsController
|
||||||
|
from sientia_do.observability.sientia_monitoring import SientiaMonitoring
|
||||||
|
from sientia_do.temporal.constants import DATETIME_FORMAT_WITH_TZ
|
||||||
|
from sientia_model.analytics.drift_analysis import DriftAnalysis, DriftInsufficientDataError
|
||||||
|
|
||||||
|
from laborious import metrics
|
||||||
|
from laborious.utils.dataframe_debug import build_dataframe_debug_message
|
||||||
|
|
||||||
|
warnings.filterwarnings('ignore', category=RuntimeWarning, message='Degrees of freedom <= 0')
|
||||||
|
warnings.filterwarnings(
|
||||||
|
'ignore', category=RuntimeWarning, message='invalid value encountered in scalar divide'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ModelMetrics(SientiaMonitoring):
|
||||||
|
"""
|
||||||
|
Metrics and statistical analysis activities for the Laborious pipeline.
|
||||||
|
|
||||||
|
This class centralizes drift/statistical computations and model-quality
|
||||||
|
aggregates used by scheduled workflows. Besides producing tabular outputs
|
||||||
|
for persistence, it also emits operational metrics (count, lag, error)
|
||||||
|
through ``SientiaMonitoring`` so execution health is observable in runtime.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_MAX_DEBUG_DATAFRAME_ROWS = 100
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
logger: Logger,
|
||||||
|
notification_handler: NotificationHandler,
|
||||||
|
metrics_controller: MetricsController,
|
||||||
|
):
|
||||||
|
SientiaMonitoring.__init__(self, logger, notification_handler, metrics_controller)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""
|
||||||
|
Shutdown monitoring resources associated with model metrics activities.
|
||||||
|
|
||||||
|
This is invoked during worker teardown to flush/close metric controller
|
||||||
|
internals and prevent dangling telemetry tasks.
|
||||||
|
"""
|
||||||
|
SientiaMonitoring.shutdown(self)
|
||||||
|
|
||||||
|
def __del__(self):
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
def _debug_dataframe(self, message: str, data: Any, metadata: dict[str, Any]) -> None:
|
||||||
|
"""
|
||||||
|
Log dataframe content only when row count is below the configured threshold
|
||||||
|
|
||||||
|
Args:
|
||||||
|
- message (str): Base log message to identify the dataframe in logs
|
||||||
|
- data (Any): Dataframe-like payload to be logged
|
||||||
|
- metadata (dict[str, Any]): Workflow metadata for contextual logging
|
||||||
|
"""
|
||||||
|
self.debug(
|
||||||
|
build_dataframe_debug_message(
|
||||||
|
message=message,
|
||||||
|
data=data,
|
||||||
|
max_rows=self._MAX_DEBUG_DATAFRAME_ROWS,
|
||||||
|
),
|
||||||
|
metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _drift_analyze_stage_error(
|
||||||
|
self,
|
||||||
|
exc: Exception,
|
||||||
|
context: str,
|
||||||
|
metadata: dict[str, Any],
|
||||||
|
core_labels: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Log analyzer failure for a drift stage and increment the analyze error metric.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
- exc (Exception): Failure raised by ``sientia_model``.
|
||||||
|
- context (str): Short label for the log line (e.g. univariate detection).
|
||||||
|
- metadata (dict[str, Any]): Workflow metadata for logging.
|
||||||
|
- core_labels (dict[str, Any]): Tags from ``get_core_labels`` for metrics.
|
||||||
|
"""
|
||||||
|
self.error(f'{context}: {exc}', metadata)
|
||||||
|
self.emit_metric_sync(metric_object=metrics.MODEL_ANALYZE_ERROR_COUNT, tags=core_labels)
|
||||||
|
|
||||||
|
def get_drift_metrics(
|
||||||
|
self,
|
||||||
|
reference_data: DataFrame,
|
||||||
|
target_data: DataFrame,
|
||||||
|
target_name: str,
|
||||||
|
reference_columns: Index,
|
||||||
|
drift_metrics: list[str],
|
||||||
|
chunk_period: str,
|
||||||
|
metadata: dict[str, Any],
|
||||||
|
) -> DataFrame:
|
||||||
|
"""
|
||||||
|
Compute univariate and multivariate drift outputs and merge them into one dataframe.
|
||||||
|
|
||||||
|
The method orchestrates three analysis stages (univariate drift,
|
||||||
|
multivariate drift, and dataframe projection), emitting lag/count/error
|
||||||
|
metrics for each stage independently so failures are attributable.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
- reference_data (DataFrame): Baseline dataset representing expected behavior.
|
||||||
|
- target_data (DataFrame): Current analysis dataset to compare against reference.
|
||||||
|
- target_name (str): Target column name used by ``DriftAnalysis`` config.
|
||||||
|
- reference_columns (Index): Feature columns evaluated for drift.
|
||||||
|
- drift_metrics (list[str]): Enabled univariate methods.
|
||||||
|
- chunk_period (str): Time bucket granularity used by analysis methods.
|
||||||
|
- metadata (dict[str, Any]): Workflow metadata for logs and notifications.
|
||||||
|
|
||||||
|
Return:
|
||||||
|
DataFrame: Consolidated drift dataframe from ``get_drift_metrics_dataframe`` using
|
||||||
|
``method`` / ``value`` (and optional ``threshold``, ``drift_type``), ready for
|
||||||
|
activity-level formatting before Postgres export.
|
||||||
|
"""
|
||||||
|
# ``DriftAnalysis`` uses truthiness checks on ``features`` (e.g. ``if not features``);
|
||||||
|
# a pandas ``Index`` is ambiguous in boolean context — normalize to a list.
|
||||||
|
feature_names: list[str] = list(reference_columns)
|
||||||
|
|
||||||
|
config = {
|
||||||
|
'target': target_name,
|
||||||
|
'prediction': 'prediction',
|
||||||
|
'timestamp': 'timestamp',
|
||||||
|
'features': feature_names,
|
||||||
|
}
|
||||||
|
|
||||||
|
drift_analysis = DriftAnalysis(config=config)
|
||||||
|
|
||||||
|
self._debug_dataframe(
|
||||||
|
f'Reference data: Size {reference_data.shape}', reference_data, metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
self._debug_dataframe(f'Target data: Size {target_data.shape}', target_data, metadata)
|
||||||
|
|
||||||
|
core_labels = self.get_core_labels(metadata, operation_type='detect_univariate_drift')
|
||||||
|
start_time = time.time()
|
||||||
|
try:
|
||||||
|
univariate_drift = drift_analysis.detect_univariate_drift(
|
||||||
|
reference_df=reference_data,
|
||||||
|
analysis_df=target_data,
|
||||||
|
features=feature_names,
|
||||||
|
timestamp_col=config['timestamp'],
|
||||||
|
methods=drift_metrics,
|
||||||
|
chunk_period=chunk_period,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
if isinstance(e, DriftInsufficientDataError):
|
||||||
|
raise
|
||||||
|
self._drift_analyze_stage_error(
|
||||||
|
e, 'Error detecting univariate drift', metadata, core_labels
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
self.observe_lag_sync(start_time, metrics.MODEL_ANALYZE_LAG, core_labels)
|
||||||
|
self.emit_metric_sync(metric_object=metrics.MODEL_ANALYZE_COUNT, tags=core_labels)
|
||||||
|
|
||||||
|
core_labels = self.get_core_labels(metadata, operation_type='detect_multivariate_drift')
|
||||||
|
start_time = time.time()
|
||||||
|
try:
|
||||||
|
multivariate_drift = drift_analysis.detect_multivariate_drift(
|
||||||
|
reference_df=reference_data,
|
||||||
|
analysis_df=target_data,
|
||||||
|
features=feature_names,
|
||||||
|
timestamp_col=config['timestamp'],
|
||||||
|
chunk_period=chunk_period,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
if isinstance(e, DriftInsufficientDataError):
|
||||||
|
raise
|
||||||
|
self._drift_analyze_stage_error(
|
||||||
|
e, 'Error detecting multivariate drift', metadata, core_labels
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
self.observe_lag_sync(start_time, metrics.MODEL_ANALYZE_LAG, core_labels)
|
||||||
|
self.emit_metric_sync(metric_object=metrics.MODEL_ANALYZE_COUNT, tags=core_labels)
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
core_labels = self.get_core_labels(metadata, operation_type='get_drift_metrics_dataframe')
|
||||||
|
try:
|
||||||
|
drift_df = drift_analysis.get_drift_metrics_dataframe(
|
||||||
|
univariate_drift=univariate_drift,
|
||||||
|
multivariate_drift=multivariate_drift,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
if isinstance(e, DriftInsufficientDataError):
|
||||||
|
raise
|
||||||
|
self._drift_analyze_stage_error(
|
||||||
|
e, 'Error building drift metrics dataframe', metadata, core_labels
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
self.observe_lag_sync(start_time, metrics.MODEL_ANALYZE_LAG, core_labels)
|
||||||
|
self.emit_metric_sync(metric_object=metrics.MODEL_ANALYZE_COUNT, tags=core_labels)
|
||||||
|
|
||||||
|
return drift_df
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _to_naive_utc(series: Series) -> Series:
|
||||||
|
"""
|
||||||
|
Parse ``series`` as datetime and return a TZ-naive UTC copy.
|
||||||
|
|
||||||
|
``sientia_model.analytics.drift_analysis.DriftAnalysis`` preserves the
|
||||||
|
timezone of the input dataframe in its outputs, while target rows
|
||||||
|
loaded from PostgreSQL come in with ``+00:00``. Forcing both sides of
|
||||||
|
a comparison to TZ-naive UTC keeps ``isin`` / ``floor`` operations
|
||||||
|
deterministic regardless of how the analyzer constructs its
|
||||||
|
timestamps.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
- series (Series): Input series containing datetime-parseable values.
|
||||||
|
|
||||||
|
Return:
|
||||||
|
Series: Datetime64 series with ``tz=None`` representing UTC instants.
|
||||||
|
"""
|
||||||
|
parsed = to_datetime(series)
|
||||||
|
if getattr(parsed.dt, 'tz', None) is not None:
|
||||||
|
parsed = parsed.dt.tz_convert('UTC').dt.tz_localize(None)
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
@activity.defn(name='calculate_drift')
|
||||||
|
def calculate_drift(self, input_data: dict[str, Any]) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Calculate drift metrics for a model.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_data (dict[str, Any]): Input data containing:
|
||||||
|
- metadata (dict): Workflow execution metadata
|
||||||
|
- model_name (str): Name of the MLFlow model to calculate drift for
|
||||||
|
- reference_data (pd.DataFrame): Reference data for the model
|
||||||
|
- target_data (pd.DataFrame): Target data for calculating drift
|
||||||
|
- target_name (str): Name of the target column
|
||||||
|
- drift_metrics (list[str]): List of drift metrics to calculate
|
||||||
|
"""
|
||||||
|
metadata = input_data['metadata']
|
||||||
|
model_name = input_data['model_name']
|
||||||
|
model_id = input_data['model_id']
|
||||||
|
reference_raw_data = input_data['reference_data']
|
||||||
|
target_data = DataFrame(input_data['target_data'])
|
||||||
|
target_name = input_data['target_name']
|
||||||
|
drift_metrics = input_data['drift_metrics']
|
||||||
|
chunk_period = input_data['chunk_period']
|
||||||
|
|
||||||
|
if chunk_period not in ['min', 's']:
|
||||||
|
self.error(f'Invalid chunk period: {chunk_period}', metadata)
|
||||||
|
raise ValueError(f'Invalid chunk period: {chunk_period}, must be "min" or "s"')
|
||||||
|
|
||||||
|
self.info(f'Calculating drift for model {model_name}', metadata)
|
||||||
|
|
||||||
|
target_data = target_data.pivot(index='timestamp', columns='variable', values='value')
|
||||||
|
target_data['timestamp'] = target_data.index
|
||||||
|
# Keep timestamps as datetime: DriftAnalysis._chunk_dataframe relies on
|
||||||
|
# ``pd.Grouper(freq=...)`` which rejects string timestamp columns.
|
||||||
|
target_data['timestamp'] = to_datetime(target_data['timestamp'])
|
||||||
|
target_data = target_data.reset_index(drop=True)
|
||||||
|
target_data.dropna(inplace=True)
|
||||||
|
|
||||||
|
if reference_raw_data is not None:
|
||||||
|
self.info('Using reference data', metadata)
|
||||||
|
reference_data = DataFrame(reference_raw_data)
|
||||||
|
if 'timestamp' in reference_data.columns:
|
||||||
|
reference_data['timestamp'] = to_datetime(reference_data['timestamp'])
|
||||||
|
accurate = True
|
||||||
|
else:
|
||||||
|
# Get 30% first rows of target_data
|
||||||
|
self.warning('Using 30% first rows of target data as reference data', metadata)
|
||||||
|
target_data.sort_values(by='timestamp', ascending=True, inplace=True)
|
||||||
|
reference_data = target_data.head(int(len(target_data) * 0.3))
|
||||||
|
accurate = False
|
||||||
|
|
||||||
|
self.send_notification(
|
||||||
|
metadata=metadata,
|
||||||
|
notification_id='MODEL_METRICS_REFERENCE_DATA_WARNING',
|
||||||
|
message='Using 30% first rows of target data as reference data',
|
||||||
|
block='model_metrics',
|
||||||
|
level=NotificationLevel.WARNING,
|
||||||
|
attachment_content=reference_data.to_csv(),
|
||||||
|
)
|
||||||
|
|
||||||
|
reference_columns = reference_data.drop(
|
||||||
|
columns=[target_name, 'timestamp', 'target', 'prediction'], errors='ignore'
|
||||||
|
).columns
|
||||||
|
|
||||||
|
try:
|
||||||
|
drift_df = self.get_drift_metrics(
|
||||||
|
reference_data=reference_data,
|
||||||
|
target_data=target_data,
|
||||||
|
target_name=target_name,
|
||||||
|
reference_columns=reference_columns,
|
||||||
|
drift_metrics=drift_metrics,
|
||||||
|
chunk_period=chunk_period,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
if isinstance(e, DriftInsufficientDataError):
|
||||||
|
self.error(str(e), metadata)
|
||||||
|
notification_id = e.notification_id
|
||||||
|
notification_message = str(e)
|
||||||
|
else:
|
||||||
|
self.error(f'Error getting drift metrics: {e}', metadata)
|
||||||
|
notification_id = 'MODEL_METRICS_GET_DRIFT_METRICS_ERROR'
|
||||||
|
notification_message = f'Error getting drift metrics: {e}'
|
||||||
|
self.send_notification(
|
||||||
|
metadata=metadata,
|
||||||
|
notification_id=notification_id,
|
||||||
|
message=notification_message,
|
||||||
|
block='model_metrics',
|
||||||
|
level=NotificationLevel.ERROR,
|
||||||
|
attachment_content=traceback.format_exc(),
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
# Drop chunks whose floored timestamp does not appear in the analysis window.
|
||||||
|
# ``DriftAnalysis`` chunks over ``analysis_df``; this only excludes rows that
|
||||||
|
# do not belong to the current target window (e.g. stray merged reference rows).
|
||||||
|
target_floor = self._to_naive_utc(target_data['timestamp']).dt.floor(chunk_period)
|
||||||
|
drift_floor = self._to_naive_utc(drift_df['timestamp']).dt.floor(chunk_period)
|
||||||
|
drift_df = drift_df[drift_floor.isin(target_floor)]
|
||||||
|
|
||||||
|
if drift_df.empty:
|
||||||
|
self.warning(
|
||||||
|
'No drift metrics found after dropping rows where timestamp is not in target data',
|
||||||
|
metadata,
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Analyzer emits diagnostic columns that are not stored in ``sientia_data.drift_metrics``.
|
||||||
|
drift_df = drift_df.drop(columns=['threshold', 'drift_type'], errors='ignore')
|
||||||
|
|
||||||
|
drift_df['model_id'] = str(model_id)
|
||||||
|
drift_df['accurate'] = accurate
|
||||||
|
|
||||||
|
# ``timestamp`` is overridden with the most recent target instant so
|
||||||
|
# every persisted row shares a single business timestamp (the run's
|
||||||
|
# logical "now"), matching what downstream consumers expect.
|
||||||
|
latest_target_timestamp = self._to_naive_utc(target_data['timestamp']).max()
|
||||||
|
drift_df['timestamp'] = (
|
||||||
|
pd.Timestamp(latest_target_timestamp)
|
||||||
|
.tz_localize('UTC')
|
||||||
|
.strftime(DATETIME_FORMAT_WITH_TZ)
|
||||||
|
)
|
||||||
|
|
||||||
|
# ``chunk_start_date`` / ``chunk_end_date`` may carry nanosecond
|
||||||
|
# precision (beyond ``timestamptz`` microseconds), so serialize as ISO
|
||||||
|
# text for the ``text`` Postgres columns.
|
||||||
|
for column in ('chunk_start_date', 'chunk_end_date'):
|
||||||
|
drift_df[column] = drift_df[column].apply(
|
||||||
|
lambda value: pd.Timestamp(value).isoformat() if pd.notna(value) else None
|
||||||
|
)
|
||||||
|
|
||||||
|
self._debug_dataframe(f'Drift dataframe: Size {drift_df.shape}', drift_df, metadata)
|
||||||
|
|
||||||
|
return drift_df.to_dict(orient='records')
|
||||||
|
|
||||||
|
@activity.defn(name='calculate_simple_metrics')
|
||||||
|
def calculate_simple_metrics(self, input_data: dict[str, Any]) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Calculate simple metrics for a model. Metrics available are:
|
||||||
|
- rmse
|
||||||
|
- mse
|
||||||
|
- mae
|
||||||
|
- r2
|
||||||
|
- accuracy
|
||||||
|
- precision
|
||||||
|
- recall
|
||||||
|
- f1
|
||||||
|
Args:
|
||||||
|
input_data (dict[str, Any]): Input data containing:
|
||||||
|
- metadata (dict): Workflow execution metadata
|
||||||
|
- model_id (str): ID of the MLFlow model
|
||||||
|
- target_data (pd.DataFrame): Target data for calculating metrics, containing target and prediction columns
|
||||||
|
- metrics (list[str]): List of metrics to calculate
|
||||||
|
Returns:
|
||||||
|
dict[Hashable, Any]: Dictionary containing the calculated metrics
|
||||||
|
"""
|
||||||
|
|
||||||
|
metadata = input_data['metadata']
|
||||||
|
model_id = input_data['model_id']
|
||||||
|
target_data = DataFrame(input_data['target_data'])
|
||||||
|
metric_names = input_data['metrics']
|
||||||
|
interval_minutes = input_data['interval_minutes']
|
||||||
|
|
||||||
|
data_size = target_data.shape[0]
|
||||||
|
|
||||||
|
output_data = []
|
||||||
|
|
||||||
|
diff = target_data['target'] - target_data['prediction']
|
||||||
|
diff_squared = diff**2
|
||||||
|
|
||||||
|
self.info(f'Calculating simple metrics for model {model_id}: {metric_names}', metadata)
|
||||||
|
|
||||||
|
for metric in metric_names:
|
||||||
|
if metric == 'rmse':
|
||||||
|
output_data.append({'metric': 'rmse', 'value': np.sqrt(np.mean(diff_squared))})
|
||||||
|
elif metric == 'mse':
|
||||||
|
output_data.append({'metric': 'mse', 'value': np.mean(diff_squared)})
|
||||||
|
elif metric == 'mae':
|
||||||
|
output_data.append({'metric': 'mae', 'value': np.mean(np.abs(diff))})
|
||||||
|
elif metric == 'r2':
|
||||||
|
y_true = target_data['target']
|
||||||
|
y_mean = np.mean(y_true)
|
||||||
|
|
||||||
|
ss_res = np.sum(diff_squared)
|
||||||
|
ss_tot = np.sum((y_true - y_mean) ** 2)
|
||||||
|
|
||||||
|
# Evita divisão por zero
|
||||||
|
if ss_tot == 0:
|
||||||
|
r2_score = 0.0
|
||||||
|
else:
|
||||||
|
r2_score = 1 - (ss_res / ss_tot)
|
||||||
|
|
||||||
|
output_data.append({'metric': 'r2', 'value': r2_score})
|
||||||
|
|
||||||
|
data = DataFrame(output_data)
|
||||||
|
data['model_id'] = model_id
|
||||||
|
data['timestamp'] = target_data['timestamp'].max()
|
||||||
|
data['data_size'] = data_size
|
||||||
|
data['interval_minutes'] = interval_minutes
|
||||||
|
|
||||||
|
self._debug_dataframe(f'Simple metrics dataframe: Size {data.shape}', data, metadata)
|
||||||
|
|
||||||
|
return data.to_dict(orient='records')
|
||||||
@@ -1,20 +1,62 @@
|
|||||||
from temporalio import activity, workflow
|
from temporalio import activity, workflow
|
||||||
|
|
||||||
|
|
||||||
with workflow.unsafe.imports_passed_through():
|
with workflow.unsafe.imports_passed_through():
|
||||||
|
import traceback
|
||||||
|
from collections.abc import Hashable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pandas import DataFrame
|
||||||
from sientia_do.notifications.handlers import CoreNotificationHandler as NotificationHandler
|
from sientia_do.notifications.handlers import CoreNotificationHandler as NotificationHandler
|
||||||
from sientia_do.notifications.models import NotificationLevel
|
from sientia_do.notifications.models import NotificationLevel
|
||||||
from sientia_do.temporal.activities.base import BaseActivity
|
|
||||||
from sientia_do.observability.logger import Logger
|
from sientia_do.observability.logger import Logger
|
||||||
|
from sientia_do.observability.metrics_controller import MetricsController
|
||||||
|
from sientia_do.observability.sientia_monitoring import SientiaMonitoring
|
||||||
|
|
||||||
from laborious.utils.repository.opc_repository import OpcRepository
|
from laborious.utils.repository.opc_repository import OpcRepository
|
||||||
from typing import Any
|
|
||||||
import traceback
|
|
||||||
from pandas import DataFrame
|
|
||||||
|
|
||||||
OPC_WRITTING_ERROR_CONFIDENCE = 12
|
OPC_WRITTING_ERROR_CONFIDENCE = 12
|
||||||
|
OPC_SESSION_BAD_CONFIDENCE = 14
|
||||||
|
OPC_SESSION_BAD_COMMENT_PREFIX = 'OPC UA session/channel error:'
|
||||||
|
OPC_WRITTING_ERROR_MESSAGE = 'Some data could not be written to OPC servers'
|
||||||
|
OPC_RECONNECT_IN_PROGRESS_COMMENT = 'OPC UA reconnect in progress'
|
||||||
|
OPC_COMMENT_SEPARATOR = ' | '
|
||||||
|
|
||||||
|
|
||||||
class OPC(BaseActivity):
|
def _opc_session_bad_comment(opc_status: str | None) -> str:
|
||||||
|
status = opc_status or 'Unknown'
|
||||||
|
return f'{OPC_SESSION_BAD_COMMENT_PREFIX} {status}'
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_opc_write_error(
|
||||||
|
error_info: dict[str, Any] | None,
|
||||||
|
session_bad_seen: bool,
|
||||||
|
session_bad_status: str | None,
|
||||||
|
reconnect_in_progress_seen: bool,
|
||||||
|
) -> tuple[bool, str | None, bool]:
|
||||||
|
"""
|
||||||
|
Update session/reconnect flags from an OPC write error payload.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
error_info: Repository error details, or None when the write succeeded.
|
||||||
|
session_bad_seen: Whether a session_bad error was seen so far.
|
||||||
|
session_bad_status: Last known OPC status for session errors.
|
||||||
|
reconnect_in_progress_seen: Whether reconnect_in_progress was seen so far.
|
||||||
|
|
||||||
|
Return:
|
||||||
|
Updated (session_bad_seen, session_bad_status, reconnect_in_progress_seen).
|
||||||
|
"""
|
||||||
|
if not error_info:
|
||||||
|
return session_bad_seen, session_bad_status, reconnect_in_progress_seen
|
||||||
|
|
||||||
|
kind = error_info.get('opc_error_kind')
|
||||||
|
if kind == 'session_bad':
|
||||||
|
return True, error_info.get('opc_status', session_bad_status), reconnect_in_progress_seen
|
||||||
|
if kind == 'reconnect_in_progress':
|
||||||
|
return session_bad_seen, session_bad_status, True
|
||||||
|
return session_bad_seen, session_bad_status, reconnect_in_progress_seen
|
||||||
|
|
||||||
|
|
||||||
|
class OPC(SientiaMonitoring):
|
||||||
"""
|
"""
|
||||||
OPC server integration activities for real-time data export.
|
OPC server integration activities for real-time data export.
|
||||||
|
|
||||||
@@ -33,20 +75,20 @@ class OPC(BaseActivity):
|
|||||||
notification_handler (NotificationHandler): Notification management instance
|
notification_handler (NotificationHandler): Notification management instance
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, opc_servers: dict[str, dict[str, Any]],
|
def __init__(
|
||||||
logger: Logger, notification_handler: NotificationHandler):
|
self,
|
||||||
|
opc_servers: dict[str, dict[str, Any]],
|
||||||
self.logger = logger
|
logger: Logger,
|
||||||
self.notification_handler = notification_handler
|
notification_handler: NotificationHandler,
|
||||||
|
metrics_controller: MetricsController,
|
||||||
|
):
|
||||||
self.opc_servers = opc_servers
|
self.opc_servers = opc_servers
|
||||||
|
|
||||||
BaseActivity.__init__(
|
SientiaMonitoring.__init__(self, logger, notification_handler, metrics_controller)
|
||||||
self, logger, notification_handler, set_error_counter=True)
|
|
||||||
|
|
||||||
self.opc_repository: dict[str, OpcRepository] = {}
|
self.opc_repository: dict[str, OpcRepository] = {}
|
||||||
self.opc_servers = opc_servers
|
|
||||||
|
|
||||||
async def init_opc(self):
|
def init_opc(self):
|
||||||
"""
|
"""
|
||||||
Initialize OPC server connections and establish communication channels.
|
Initialize OPC server connections and establish communication channels.
|
||||||
|
|
||||||
@@ -70,10 +112,11 @@ class OPC(BaseActivity):
|
|||||||
the initialization of other OPC servers. Each server is handled
|
the initialization of other OPC servers. Each server is handled
|
||||||
independently to ensure maximum availability.
|
independently to ensure maximum availability.
|
||||||
"""
|
"""
|
||||||
self.logger.info("Initializing OPC servers...")
|
self.info('Initializing OPC servers...')
|
||||||
for id, server in self.opc_servers.items():
|
for opc_id, server in self.opc_servers.items():
|
||||||
self.opc_repository[id] = OpcRepository(
|
self.opc_repository[opc_id] = OpcRepository(
|
||||||
id=server['id'],
|
opc_id=opc_id,
|
||||||
|
server_name=server['server_name'],
|
||||||
url=server['url'],
|
url=server['url'],
|
||||||
logger=self.logger,
|
logger=self.logger,
|
||||||
server_uri=server['server_uri'],
|
server_uri=server['server_uri'],
|
||||||
@@ -81,76 +124,70 @@ class OPC(BaseActivity):
|
|||||||
private_key_path=server['private_key_path'],
|
private_key_path=server['private_key_path'],
|
||||||
server_cert_path=server['server_cert_path'],
|
server_cert_path=server['server_cert_path'],
|
||||||
notification_handler=self.notification_handler,
|
notification_handler=self.notification_handler,
|
||||||
reconnection_interval=server['reconnection_interval'],
|
reconnection_interval=server.get('reconnection_interval', 60),
|
||||||
pod_id=self.pod_id
|
metrics_controller=self.metrics_controller,
|
||||||
)
|
)
|
||||||
is_connected, error_data = await self.opc_repository[id].connect()
|
is_connected, error_data = self.opc_repository[opc_id].connect()
|
||||||
if not is_connected:
|
if not is_connected:
|
||||||
self.send_notification(
|
self.send_notification(
|
||||||
metadata={
|
metadata={
|
||||||
'model_id': '-',
|
'model_id': '-',
|
||||||
'model_name': '-',
|
'model_name': '-',
|
||||||
'workflow_name': '-',
|
'workflow_name': '-',
|
||||||
'schedule_name': 'INITIALIZATION'
|
'schedule_name': 'INITIALIZATION',
|
||||||
},
|
},
|
||||||
notification_id=error_data['notification_id'],
|
notification_id=error_data['notification_id'],
|
||||||
message=error_data['message'],
|
message=error_data['message'],
|
||||||
block=error_data['block'],
|
block=error_data['block'],
|
||||||
level=error_data.get('level', NotificationLevel.ERROR),
|
level=error_data.get('level', NotificationLevel.ERROR),
|
||||||
attachment_content=error_data.get(
|
attachment_content=error_data.get('attachment_content', None),
|
||||||
'attachment_content', None)
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.logger.info(
|
self.info(f'OPC server {opc_id}:{server["server_name"]} connected successfully.')
|
||||||
f"OPC server {id} connected successfully.")
|
|
||||||
|
|
||||||
async def write_data(self, server_id: str, tag: str, data: Any,
|
def write_data(
|
||||||
data_type: str, tag_type: str, metadata: dict[str, Any]) -> bool:
|
self,
|
||||||
|
server_id: str,
|
||||||
|
tag: str,
|
||||||
|
data: Any,
|
||||||
|
data_type: str,
|
||||||
|
tag_type: str,
|
||||||
|
metadata: dict[str, Any],
|
||||||
|
) -> tuple[float | None, dict[str, Any] | None]:
|
||||||
"""
|
"""
|
||||||
Write data to a specific OPC server tag with comprehensive error handling.
|
Write data to a specific OPC server tag with comprehensive error handling.
|
||||||
|
|
||||||
This method provides a secure and reliable way to write data to OPC servers
|
Return:
|
||||||
with automatic error handling, notification integration, and detailed logging.
|
tuple[float | None, dict[str, Any] | None]: Response time on success, or
|
||||||
It validates server availability before attempting write operations and
|
(None, error info_data) on repository failure.
|
||||||
provides comprehensive error reporting for operational monitoring.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
- server_id (str): The id of the OPC server.
|
|
||||||
- tag (str): The tag to write to.
|
|
||||||
- data (Any): The data to write.
|
|
||||||
- data_type (str): The data type.
|
|
||||||
- tag_type (str): The tag type.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
- bool: True if the data was written successfully, False otherwise.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
is_success, error_data = await self.opc_repository[server_id].write_data(
|
is_success, info_data = self.opc_repository[server_id].write_data(
|
||||||
tag, data, data_type, self.logger, metadata)
|
tag, data, data_type, metadata
|
||||||
|
)
|
||||||
if not is_success:
|
if not is_success:
|
||||||
self.send_notification(
|
self.send_notification(
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
notification_id=error_data['notification_id'],
|
notification_id=info_data['notification_id'],
|
||||||
message=error_data['message'],
|
message=info_data['message'],
|
||||||
block=error_data['block'],
|
block=info_data['block'],
|
||||||
level=error_data.get('level', NotificationLevel.ERROR),
|
level=info_data.get('level', NotificationLevel.ERROR),
|
||||||
attachment_content=error_data.get(
|
attachment_content=info_data.get('attachment_content', None),
|
||||||
'attachment_content', None)
|
|
||||||
)
|
)
|
||||||
return False
|
return None, info_data
|
||||||
return True
|
return info_data['response_time'], None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
trace = traceback.format_exc()
|
trace = traceback.format_exc()
|
||||||
self.send_notification(
|
self.send_notification(
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
notification_id=f"WRITE_OPC_{tag_type.upper()}_ERROR",
|
notification_id=f'WRITE_OPC_{tag_type.upper()}_ERROR',
|
||||||
message=f"Error writing data to OPC server: {e}",
|
message=f'Error writing data to OPC server: {e}',
|
||||||
block="write_opc_data",
|
block='write_opc_data',
|
||||||
level=NotificationLevel.ERROR,
|
level=NotificationLevel.ERROR,
|
||||||
attachment_content=trace
|
attachment_content=trace,
|
||||||
)
|
)
|
||||||
raise e
|
raise
|
||||||
|
|
||||||
def validate_server(self, server_id: str, metadata: dict[str, Any]) -> bool:
|
def validate_server(self, server_id: str, metadata: dict[str, Any]) -> bool:
|
||||||
"""
|
"""
|
||||||
@@ -174,21 +211,81 @@ class OPC(BaseActivity):
|
|||||||
This helps operators quickly identify configuration issues.
|
This helps operators quickly identify configuration issues.
|
||||||
"""
|
"""
|
||||||
if self.opc_repository.get(server_id) is None:
|
if self.opc_repository.get(server_id) is None:
|
||||||
message = f"OPC server {server_id} not found to perform write operation."
|
message = f'OPC server {server_id} not found to perform write operation.'
|
||||||
self.send_notification(
|
self.send_notification(
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
notification_id="OPC_SERVER_NOT_FOUND",
|
notification_id='OPC_SERVER_NOT_FOUND',
|
||||||
message=message,
|
message=message,
|
||||||
block="write_opc_data",
|
block='write_opc_data',
|
||||||
level=NotificationLevel.ERROR,
|
level=NotificationLevel.ERROR,
|
||||||
attachment_content=f"OPC servers: {list(self.opc_repository.keys())}"
|
attachment_content=f'OPC servers: {list(self.opc_repository.keys())}',
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def manage_output_tags(
|
def _write_tags_from_config(
|
||||||
self, server_id: str, config: dict[str, Any], data: DataFrame,
|
self,
|
||||||
metadata: dict[str, Any], success: bool) -> tuple[bool, int]:
|
server_id: str,
|
||||||
|
tags_config: dict[str, dict[str, Any]],
|
||||||
|
data: DataFrame,
|
||||||
|
data_column: str,
|
||||||
|
tag_type: str,
|
||||||
|
log_label: str,
|
||||||
|
metadata: dict[str, Any],
|
||||||
|
) -> tuple[dict[str, float | None], bool, str | None, bool]:
|
||||||
|
"""
|
||||||
|
Write a group of OPC tags and collect response times and error flags.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
server_id: Target OPC server identifier.
|
||||||
|
tags_config: Tag name to configuration mapping.
|
||||||
|
data: DataFrame with prediction/confidence columns.
|
||||||
|
data_column: Column name whose first row value is written.
|
||||||
|
tag_type: Tag category passed to write_data ('prediction' or 'confidence').
|
||||||
|
log_label: Human-readable label for success logs.
|
||||||
|
metadata: Context metadata for logging and notifications.
|
||||||
|
|
||||||
|
Return:
|
||||||
|
(response_times, session_bad_seen, session_bad_status, reconnect_in_progress_seen)
|
||||||
|
"""
|
||||||
|
response_times: dict[str, float | None] = {}
|
||||||
|
session_bad_seen = False
|
||||||
|
session_bad_status: str | None = None
|
||||||
|
reconnect_in_progress_seen = False
|
||||||
|
|
||||||
|
for tag, tag_config in tags_config.items():
|
||||||
|
response_time, error_info = self.write_data(
|
||||||
|
server_id=server_id,
|
||||||
|
tag=tag,
|
||||||
|
data=data.head(1)[data_column].values[0],
|
||||||
|
data_type=tag_config['data_type'],
|
||||||
|
tag_type=tag_type,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
session_bad_seen, session_bad_status, reconnect_in_progress_seen = (
|
||||||
|
_apply_opc_write_error(
|
||||||
|
error_info,
|
||||||
|
session_bad_seen,
|
||||||
|
session_bad_status,
|
||||||
|
reconnect_in_progress_seen,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if response_time is not None:
|
||||||
|
self.info(
|
||||||
|
f'{log_label} written to OPC server {server_id} for tag {tag}.',
|
||||||
|
metadata,
|
||||||
|
)
|
||||||
|
response_times[tag] = response_time
|
||||||
|
|
||||||
|
return response_times, session_bad_seen, session_bad_status, reconnect_in_progress_seen
|
||||||
|
|
||||||
|
def manage_output_tags(
|
||||||
|
self,
|
||||||
|
server_id: str,
|
||||||
|
config: dict[str, Any],
|
||||||
|
data: DataFrame,
|
||||||
|
metadata: dict[str, Any],
|
||||||
|
) -> tuple[bool, dict[str, float | None], bool, str | None, bool]:
|
||||||
"""
|
"""
|
||||||
Manage the writing of prediction and confidence data to OPC server tags.
|
Manage the writing of prediction and confidence data to OPC server tags.
|
||||||
|
|
||||||
@@ -215,44 +312,52 @@ class OPC(BaseActivity):
|
|||||||
- overall_success: True if all configured tags were written successfully
|
- overall_success: True if all configured tags were written successfully
|
||||||
- total_tags_written: Count of successfully written tags
|
- total_tags_written: Count of successfully written tags
|
||||||
"""
|
"""
|
||||||
|
response_times: dict[str, float | None] = {}
|
||||||
|
session_bad_seen = False
|
||||||
|
session_bad_status: str | None = None
|
||||||
|
reconnect_in_progress_seen = False
|
||||||
|
|
||||||
count = 0
|
tag_groups = (
|
||||||
if 'prediction_tags' in config:
|
('prediction_tags', 'prediction', 'prediction', 'Prediction data'),
|
||||||
for tag, tag_config in config['prediction_tags'].items():
|
('confidence_tags', 'prediction_confidence', 'confidence', 'Confidence data'),
|
||||||
local_success = await self.write_data(
|
)
|
||||||
server_id=server_id,
|
for config_key, data_column, tag_type, log_label in tag_groups:
|
||||||
tag=tag,
|
if config_key not in config:
|
||||||
data=data.head(1)['prediction'].values[0],
|
continue
|
||||||
data_type=tag_config['data_type'],
|
(
|
||||||
tag_type='prediction',
|
group_times,
|
||||||
metadata=metadata
|
group_session_bad,
|
||||||
)
|
group_status,
|
||||||
if local_success:
|
group_reconnect,
|
||||||
self.info(
|
) = self._write_tags_from_config(
|
||||||
f"Prediction data written to OPC server {server_id} for tag {tag}.", metadata)
|
server_id=server_id,
|
||||||
count += 1
|
tags_config=config[config_key],
|
||||||
success = success and local_success
|
data=data,
|
||||||
|
data_column=data_column,
|
||||||
|
tag_type=tag_type,
|
||||||
|
log_label=log_label,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
response_times.update(group_times)
|
||||||
|
if group_session_bad:
|
||||||
|
session_bad_seen = True
|
||||||
|
session_bad_status = group_status or session_bad_status
|
||||||
|
if group_reconnect:
|
||||||
|
reconnect_in_progress_seen = True
|
||||||
|
|
||||||
if 'confidence_tags' in config:
|
success = None not in response_times.values()
|
||||||
for tag, tag_config in config['confidence_tags'].items():
|
return (
|
||||||
local_success = await self.write_data(
|
success,
|
||||||
server_id=server_id,
|
response_times,
|
||||||
tag=tag,
|
session_bad_seen,
|
||||||
data=data.head(1)['prediction_confidence'].values[0],
|
session_bad_status,
|
||||||
data_type=tag_config['data_type'],
|
reconnect_in_progress_seen,
|
||||||
tag_type='confidence',
|
)
|
||||||
metadata=metadata
|
|
||||||
)
|
|
||||||
if local_success:
|
|
||||||
self.info(
|
|
||||||
f"Confidence data written to OPC server {server_id} for tag {tag}.", metadata)
|
|
||||||
count += 1
|
|
||||||
success = success and local_success
|
|
||||||
|
|
||||||
return success, count
|
|
||||||
|
|
||||||
@activity.defn(name='write_opc_data')
|
@activity.defn(name='write_opc_data')
|
||||||
async def write_opc_data(self, input_data: dict[str, Any]) -> dict[Any, Any]:
|
def write_opc_data(
|
||||||
|
self, input_data: dict[str, Any]
|
||||||
|
) -> tuple[dict[Hashable, Any], dict[str, dict[str, float | None]]]:
|
||||||
"""
|
"""
|
||||||
Write prediction and confidence data to OPC servers. The two writing
|
Write prediction and confidence data to OPC servers. The two writing
|
||||||
operations are optional and independent of each other.
|
operations are optional and independent of each other.
|
||||||
@@ -271,29 +376,68 @@ class OPC(BaseActivity):
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
metadata = input_data['metadata']
|
metadata = input_data['metadata']
|
||||||
self.info("Writing data to OPC servers...", metadata)
|
self.info('Writing data to OPC servers...', metadata)
|
||||||
data = DataFrame(input_data['data'])
|
data = DataFrame(input_data['data'])
|
||||||
opc_output_config = input_data['opc_output_config']
|
opc_output_config = input_data['opc_output_config']
|
||||||
self.info(f"Data to write: {data.size} rows", metadata)
|
self.info(f'Data to write: {data.size} rows', metadata)
|
||||||
|
|
||||||
success = True
|
success = True
|
||||||
|
session_bad_seen = False
|
||||||
|
session_bad_status: str | None = None
|
||||||
|
reconnect_in_progress_seen = False
|
||||||
|
|
||||||
|
opc_metrics: dict[str, dict[str, float | None]] = {}
|
||||||
|
|
||||||
for server_id, config in opc_output_config.items():
|
for server_id, config in opc_output_config.items():
|
||||||
|
|
||||||
if not self.validate_server(server_id, metadata):
|
if not self.validate_server(server_id, metadata):
|
||||||
success = False
|
success = False
|
||||||
continue
|
continue
|
||||||
|
|
||||||
local_success, local_count = await self.manage_output_tags(
|
(
|
||||||
server_id, config, data, metadata, success)
|
local_success,
|
||||||
|
local_response_times,
|
||||||
|
local_session_bad,
|
||||||
|
local_status,
|
||||||
|
local_reconnect_in_progress,
|
||||||
|
) = self.manage_output_tags(server_id, config, data, metadata)
|
||||||
|
opc_metrics[server_id] = local_response_times
|
||||||
|
local_count = len(local_response_times)
|
||||||
success = success and local_success
|
success = success and local_success
|
||||||
|
if local_session_bad:
|
||||||
|
session_bad_seen = True
|
||||||
|
session_bad_status = local_status or session_bad_status
|
||||||
|
if local_reconnect_in_progress:
|
||||||
|
reconnect_in_progress_seen = True
|
||||||
|
|
||||||
|
n_pred = len(config.get('prediction_tags') or {})
|
||||||
|
n_conf = len(config.get('confidence_tags') or {})
|
||||||
self.info(
|
self.info(
|
||||||
f"Process completed for OPC server {server_id}: {local_count} of {len(config['prediction_tags'])} prediction tags and {len(config['confidence_tags'])} confidence tags", metadata)
|
f'Process completed for OPC server {server_id}: {local_count} of {n_pred} prediction tags and {n_conf} confidence tags',
|
||||||
|
metadata,
|
||||||
|
)
|
||||||
|
|
||||||
return self.process_confidence(data, success, metadata)
|
return (
|
||||||
|
self.process_confidence(
|
||||||
|
data,
|
||||||
|
success,
|
||||||
|
metadata,
|
||||||
|
session_bad=session_bad_seen,
|
||||||
|
opc_status=session_bad_status,
|
||||||
|
reconnect_in_progress=reconnect_in_progress_seen,
|
||||||
|
),
|
||||||
|
opc_metrics,
|
||||||
|
)
|
||||||
|
|
||||||
def process_confidence(self, data: DataFrame, success: bool, metadata: dict[str, Any]) -> dict[Any, Any]:
|
def process_confidence(
|
||||||
|
self,
|
||||||
|
data: DataFrame,
|
||||||
|
success: bool,
|
||||||
|
metadata: dict[str, Any],
|
||||||
|
*,
|
||||||
|
session_bad: bool = False,
|
||||||
|
opc_status: str | None = None,
|
||||||
|
reconnect_in_progress: bool = False,
|
||||||
|
) -> dict[Hashable, Any]:
|
||||||
"""
|
"""
|
||||||
Process prediction confidence based on OPC write operation success.
|
Process prediction confidence based on OPC write operation success.
|
||||||
|
|
||||||
@@ -321,18 +465,31 @@ class OPC(BaseActivity):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
if not success:
|
if not success:
|
||||||
data['prediction_confidence'] = OPC_WRITTING_ERROR_CONFIDENCE
|
comment_parts: list[str] = []
|
||||||
self.debug(
|
confidence = OPC_WRITTING_ERROR_CONFIDENCE
|
||||||
f"Some data could not be written to OPC servers, setting confidence to {OPC_WRITTING_ERROR_CONFIDENCE}.",
|
|
||||||
metadata
|
|
||||||
)
|
|
||||||
|
|
||||||
|
if session_bad:
|
||||||
|
comment_parts.append(_opc_session_bad_comment(opc_status))
|
||||||
|
confidence = OPC_SESSION_BAD_CONFIDENCE
|
||||||
|
if reconnect_in_progress:
|
||||||
|
comment_parts.append(OPC_RECONNECT_IN_PROGRESS_COMMENT)
|
||||||
|
confidence = OPC_SESSION_BAD_CONFIDENCE
|
||||||
|
if not comment_parts:
|
||||||
|
comment_parts.append(OPC_WRITTING_ERROR_MESSAGE)
|
||||||
|
|
||||||
|
comments = OPC_COMMENT_SEPARATOR.join(comment_parts)
|
||||||
|
data['prediction_confidence'] = confidence
|
||||||
|
data['comments'] = comments
|
||||||
|
self.debug(
|
||||||
|
f'OPC write issues, confidence={confidence}, comments={comments}',
|
||||||
|
metadata,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
self.debug("Data written to OPC servers successfully.", metadata)
|
self.debug('Data written to OPC servers successfully.', metadata)
|
||||||
|
|
||||||
return data.to_dict()
|
return data.to_dict()
|
||||||
|
|
||||||
async def shutdown(self):
|
def close(self):
|
||||||
"""
|
"""
|
||||||
Gracefully shutdown all OPC server connections and cleanup resources.
|
Gracefully shutdown all OPC server connections and cleanup resources.
|
||||||
|
|
||||||
@@ -353,4 +510,5 @@ class OPC(BaseActivity):
|
|||||||
their current state and provides a clean shutdown experience.
|
their current state and provides a clean shutdown experience.
|
||||||
"""
|
"""
|
||||||
for opc in self.opc_repository.values():
|
for opc in self.opc_repository.values():
|
||||||
await opc.disconnect()
|
opc.disconnect()
|
||||||
|
self.opc_repository.clear()
|
||||||
|
|||||||
231
laborious/activities/storage.py
Normal file
231
laborious/activities/storage.py
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
from temporalio import activity, workflow
|
||||||
|
|
||||||
|
with workflow.unsafe.imports_passed_through():
|
||||||
|
# Extend the Temporal Postgres activities for convenient query -> MinIO export
|
||||||
|
import traceback
|
||||||
|
from datetime import timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
from sientia_do.notifications.handlers import CoreNotificationHandler as NotificationHandler
|
||||||
|
from sientia_do.notifications.models import NotificationLevel
|
||||||
|
from sientia_do.observability.logger import Logger
|
||||||
|
from sientia_do.observability.metrics_controller import MetricsController
|
||||||
|
from sientia_do.observability.sientia_monitoring import SientiaMonitoring
|
||||||
|
from sientia_do.repository.minio_repository_sync import MinioRepository
|
||||||
|
from sientia_do.temporal.activities.postgres_sync import Postgres
|
||||||
|
from sientia_do.temporal.constants import now
|
||||||
|
|
||||||
|
from laborious.utils.models.minio_dataframe_payload import MinioDataFramePayload
|
||||||
|
|
||||||
|
_LOAD_QUERY_OFFLOAD_SKIP_KEYS = frozenset({'model_name', 'key_prefix', 'size_threshold_bytes'})
|
||||||
|
|
||||||
|
|
||||||
|
class Storage(Postgres, SientiaMonitoring):
|
||||||
|
"""
|
||||||
|
Extensions for Postgres activities with a helper to export query results
|
||||||
|
directly to MinIO as Parquet and return the object name.
|
||||||
|
"""
|
||||||
|
|
||||||
|
minio_repository: MinioRepository | None = None
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
host: str,
|
||||||
|
port: int,
|
||||||
|
user: str,
|
||||||
|
password: str,
|
||||||
|
dbname: str,
|
||||||
|
min_connections: int,
|
||||||
|
max_connections: int,
|
||||||
|
retention_hours: int = 24,
|
||||||
|
minio_repository: MinioRepository | None = None,
|
||||||
|
logger: Logger | None = None,
|
||||||
|
notification_handler: NotificationHandler | None = None,
|
||||||
|
metrics_controller: MetricsController | None = None,
|
||||||
|
):
|
||||||
|
self.retention_hours = retention_hours
|
||||||
|
Postgres.__init__(
|
||||||
|
self,
|
||||||
|
host=host,
|
||||||
|
port=port,
|
||||||
|
user=user,
|
||||||
|
password=password,
|
||||||
|
dbname=dbname,
|
||||||
|
min_connections=min_connections,
|
||||||
|
max_connections=max_connections,
|
||||||
|
logger=logger,
|
||||||
|
notification_handler=notification_handler,
|
||||||
|
metrics_controller=metrics_controller,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.minio_repository = minio_repository
|
||||||
|
SientiaMonitoring.__init__(
|
||||||
|
self,
|
||||||
|
logger=logger,
|
||||||
|
notification_handler=notification_handler,
|
||||||
|
metrics_controller=metrics_controller,
|
||||||
|
)
|
||||||
|
|
||||||
|
@activity.defn(name='load_query_with_minio_offload')
|
||||||
|
def load_query_with_minio_offload(self, input_data: dict[str, Any]) -> MinioDataFramePayload:
|
||||||
|
"""
|
||||||
|
Run the custom SQL load, then return a MinIO-aware dataframe wire dict.
|
||||||
|
|
||||||
|
Args (input_data):
|
||||||
|
metadata (dict): Workflow metadata (same as load_custom_query).
|
||||||
|
query (str): SQL query.
|
||||||
|
datetime_columns (list[str], optional): Datetime column names.
|
||||||
|
model_name (str): Model name for object key basename.
|
||||||
|
key_prefix (str, optional): Directory prefix inside the bucket.
|
||||||
|
size_threshold_bytes (int, optional): Override env offload threshold.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict[str, Any]: Flat ``MinioDataFramePayload`` dict or ``success: False`` on failure.
|
||||||
|
"""
|
||||||
|
if self.minio_repository is None:
|
||||||
|
raise ValueError('Minio repository not initialized')
|
||||||
|
|
||||||
|
metadata: dict = input_data.get('metadata', {})
|
||||||
|
model_name = input_data['model_name']
|
||||||
|
|
||||||
|
rows = self.load_custom_query(
|
||||||
|
input_data,
|
||||||
|
)
|
||||||
|
if not rows:
|
||||||
|
self.error(
|
||||||
|
'load_query_with_minio_offload failed: No data returned from query', metadata
|
||||||
|
)
|
||||||
|
dataframe = None
|
||||||
|
else:
|
||||||
|
dataframe = pd.DataFrame(rows)
|
||||||
|
|
||||||
|
return MinioDataFramePayload.from_dataframe(
|
||||||
|
dataframe,
|
||||||
|
minio_repo=self.minio_repository,
|
||||||
|
workflow_metadata=metadata,
|
||||||
|
model_name=model_name,
|
||||||
|
operation='initial',
|
||||||
|
logger=self.logger,
|
||||||
|
)
|
||||||
|
|
||||||
|
@activity.defn(name='export_payload_to_postgres')
|
||||||
|
def export_payload_to_postgres(self, input_data: dict[str, Any]) -> dict:
|
||||||
|
"""
|
||||||
|
Resolve a MinIO-aware payload into a DataFrame and persist it into PostgreSQL.
|
||||||
|
|
||||||
|
This activity accepts the serialized payload produced by previous steps
|
||||||
|
(inline dict or MinIO object reference), reconstructs the tabular data,
|
||||||
|
and delegates the final write to ``export_data_to_postgres`` using the
|
||||||
|
same input contract expected by the Postgres activity mixin.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
- input_data (dict[str, Any]): Activity input containing ``data`` as a
|
||||||
|
``MinioDataFramePayload``-compatible dict plus database write options
|
||||||
|
(schema/table/on_conflict/metadata and related fields).
|
||||||
|
|
||||||
|
Return:
|
||||||
|
dict: Result dictionary returned by ``export_data_to_postgres``, including
|
||||||
|
success status and optional write diagnostics.
|
||||||
|
"""
|
||||||
|
metadata = input_data.get('metadata')
|
||||||
|
payload = MinioDataFramePayload.from_dict(input_data['data'])
|
||||||
|
data = payload.retrieve(self.minio_repository, metadata)
|
||||||
|
|
||||||
|
return self.export_data_to_postgres(
|
||||||
|
{
|
||||||
|
**input_data,
|
||||||
|
'data': data,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@activity.defn(name='cleanup_minio_objects_expired')
|
||||||
|
def cleanup_minio_objects_expired(self, input_data: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Delete objects under the given prefixes that are older than the retention window.
|
||||||
|
|
||||||
|
Args (input_data):
|
||||||
|
metadata (dict): Workflow metadata for logging and metrics.
|
||||||
|
prefixes (list[str]): Key prefixes to scan (one level or subtree per prefix).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict[str, Any]: ``success``, ``deleted_count``, and optional ``message``.
|
||||||
|
"""
|
||||||
|
if self.minio_repository is None:
|
||||||
|
raise ValueError('Minio repository not initialized')
|
||||||
|
|
||||||
|
metadata = input_data.get('metadata', {})
|
||||||
|
payload = MinioDataFramePayload.from_dict(input_data['data'])
|
||||||
|
prefix = payload.cleanup_prefix()
|
||||||
|
base = now()
|
||||||
|
cutoff = (base.replace(tzinfo=None) if base.tzinfo else base) - timedelta(
|
||||||
|
hours=self.retention_hours
|
||||||
|
)
|
||||||
|
|
||||||
|
report: dict[str, Any] = {
|
||||||
|
'failed': {},
|
||||||
|
'deleted': {},
|
||||||
|
'failed_count': 0,
|
||||||
|
'deleted_count': 0,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
keys = self.minio_repository.list_objects(
|
||||||
|
prefix=prefix,
|
||||||
|
recursive=True,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
for key in keys:
|
||||||
|
try:
|
||||||
|
ts = MinioDataFramePayload.parse_object_timestamp(key)
|
||||||
|
if ts is None:
|
||||||
|
continue
|
||||||
|
if ts >= cutoff:
|
||||||
|
continue
|
||||||
|
self.minio_repository.delete_file(
|
||||||
|
object_name=key,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
report['failed'][key] = {
|
||||||
|
'success': False,
|
||||||
|
'message': str(e),
|
||||||
|
}
|
||||||
|
report['failed_count'] += 1
|
||||||
|
continue
|
||||||
|
report['deleted'][key] = {
|
||||||
|
'success': True,
|
||||||
|
'message': 'Deleted',
|
||||||
|
}
|
||||||
|
report['deleted_count'] += 1
|
||||||
|
except Exception as e:
|
||||||
|
trace = traceback.format_exc()
|
||||||
|
self.send_notification(
|
||||||
|
metadata=metadata,
|
||||||
|
notification_id='ERROR_CLEANUP_MINIO_OBJECTS_EXPIRED',
|
||||||
|
message=f'Error cleaning up MinIO objects: {e}',
|
||||||
|
block='cleanup_minio_objects_expired',
|
||||||
|
level=NotificationLevel.ERROR,
|
||||||
|
attachment_content=trace,
|
||||||
|
)
|
||||||
|
self.error(trace, metadata)
|
||||||
|
else:
|
||||||
|
# Cleanup success is expected in normal flow; avoid noisy INFO notifications
|
||||||
|
# that do not impact behavior and can flood observability in test runs.
|
||||||
|
self.info('MinIO objects cleaned up successfully', metadata)
|
||||||
|
|
||||||
|
return report
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""
|
||||||
|
Shutdown Storage resources in deterministic order.
|
||||||
|
|
||||||
|
The method first closes Postgres resources via ``Postgres.close`` (engine,
|
||||||
|
sessions, and monitoring hooks), then closes the optional MinIO repository
|
||||||
|
and clears the local reference to avoid accidental reuse after shutdown.
|
||||||
|
"""
|
||||||
|
Postgres.close(self)
|
||||||
|
if self.minio_repository is not None:
|
||||||
|
try:
|
||||||
|
self.minio_repository.close()
|
||||||
|
finally:
|
||||||
|
self.minio_repository = None
|
||||||
@@ -18,55 +18,183 @@ Key Metric Categories:
|
|||||||
|
|
||||||
Metric Labels:
|
Metric Labels:
|
||||||
- pod_id: Kubernetes pod identifier for multi-instance deployments
|
- pod_id: Kubernetes pod identifier for multi-instance deployments
|
||||||
|
- runtime: Runtime / environment identifier (matches ``RUNTIME`` env, see ``SientiaMonitoring``)
|
||||||
- model_name: Name of the ML model being used
|
- model_name: Name of the ML model being used
|
||||||
- pipeline_name: Name of the prediction pipeline
|
- workflow_name: Name of the prediction pipeline
|
||||||
- opc_server_id: Identifier for OPC server operations
|
- opc_server_id: Identifier for OPC server operations
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from prometheus_client import Gauge, Counter, Histogram
|
from prometheus_client import Counter, Gauge, Histogram
|
||||||
|
from sientia_do.observability.metrics import CORE_LABELS
|
||||||
|
|
||||||
# Application health metric
|
# Application health metric
|
||||||
APP_UP = Gauge(
|
APP_UP = Gauge(
|
||||||
"app_up",
|
'app_up',
|
||||||
"Indicates if the application is running (1) or shutting down (0)",
|
'Indicates if the application is running (1) or shutting down (0)',
|
||||||
["pod_id"],
|
['pod_id'],
|
||||||
)
|
)
|
||||||
|
|
||||||
# Core labels used across multiple metrics
|
|
||||||
CORE_LABELS = ["pod_id", "model_name", "pipeline_name"]
|
|
||||||
|
|
||||||
# Prediction operation metrics
|
# Prediction operation metrics
|
||||||
PREDICTIONS_WRITTEN_COUNT = Counter(
|
PREDICTIONS_WRITTEN_COUNT = Counter(
|
||||||
"laborious_predictions_written_count",
|
'laborious_predictions_written_count',
|
||||||
"Number of predictions written to the database table predictions",
|
'Number of predictions written to the database table predictions',
|
||||||
CORE_LABELS,
|
CORE_LABELS,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Prediction quality metrics
|
# Prediction quality metrics
|
||||||
PREDICTION_CONFIDENCE_MONITOR = Gauge(
|
PREDICTION_CONFIDENCE_MONITOR = Gauge(
|
||||||
"laborious_prediction_confidence_monitor",
|
'laborious_prediction_confidence_monitor',
|
||||||
"Current confidence of each prediction",
|
'Current confidence of each prediction',
|
||||||
CORE_LABELS,
|
CORE_LABELS,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Performance monitoring metrics
|
# Prediction total response time
|
||||||
PREDICTION_RESPONSE_TIME_MONITOR = Histogram(
|
PREDICTION_RESPONSE_TIME_MONITOR = Histogram(
|
||||||
"laborious_prediction_response_time_monitor",
|
'laborious_prediction_response_time_monitor',
|
||||||
"Current response time of each prediction",
|
'Current response time of each prediction',
|
||||||
CORE_LABELS,
|
CORE_LABELS,
|
||||||
buckets=[0.01, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0]
|
buckets=[0.01, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0],
|
||||||
)
|
)
|
||||||
|
|
||||||
# OPC export metrics
|
|
||||||
|
# ================== OPC metrics ==================
|
||||||
|
|
||||||
|
|
||||||
PREDICTION_OPC_WRITING_COUNT = Counter(
|
PREDICTION_OPC_WRITING_COUNT = Counter(
|
||||||
"laborious_prediction_opc_writing_count",
|
'laborious_prediction_opc_writing_count',
|
||||||
"Number of predictions written to the OPC server",
|
'Number of predictions written to the OPC server',
|
||||||
[*CORE_LABELS, "opc_server_id"],
|
[*CORE_LABELS, 'opc_server_id', 'tag'],
|
||||||
)
|
)
|
||||||
|
|
||||||
PREDICTION_OPC_WRITING_RESPONSE_TIME_MONITOR = Histogram(
|
PREDICTION_OPC_WRITING_RESPONSE_TIME_MONITOR = Histogram(
|
||||||
"laborious_prediction_opc_writing_response_time_monitor",
|
'laborious_prediction_opc_writing_response_time_monitor',
|
||||||
"Current response time of each prediction written to the OPC server",
|
'Current response time of each prediction written to the OPC server',
|
||||||
[*CORE_LABELS, "opc_server_id"],
|
[*CORE_LABELS, 'opc_server_id', 'tag'],
|
||||||
buckets=[0.01, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0]
|
buckets=[0.01, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0],
|
||||||
|
)
|
||||||
|
|
||||||
|
OPC_CONNECTIONS_TOTAL = Counter(
|
||||||
|
'opc_connections_initiated_total',
|
||||||
|
'Total connection attempts to OPC servers',
|
||||||
|
['pod_id', 'server_name'],
|
||||||
|
)
|
||||||
|
OPC_CONNECTIONS_FAILED = Counter(
|
||||||
|
'opc_connections_failed_total',
|
||||||
|
'Total failed connection attempts to OPC servers',
|
||||||
|
['pod_id', 'server_name'],
|
||||||
|
)
|
||||||
|
OPC_CONNECTION_STATUS = Gauge(
|
||||||
|
'opc_connection_status',
|
||||||
|
'Connection status with the OPC server (1=connected, 0=disconnected)',
|
||||||
|
['pod_id', 'server_name', 'server_url'],
|
||||||
|
)
|
||||||
|
|
||||||
|
_OPC_SESSION_DEBUG_LABELS = ['pod_id', 'server_name', 'runtime', 'opc_server_id', 'session_id']
|
||||||
|
|
||||||
|
OPC_SESSION_CREATED_TOTAL = Counter(
|
||||||
|
'opc_session_created_total',
|
||||||
|
'OPC UA sessions established (after successful connect)',
|
||||||
|
_OPC_SESSION_DEBUG_LABELS,
|
||||||
|
)
|
||||||
|
|
||||||
|
OPC_SESSION_CLOSED_TOTAL = Counter(
|
||||||
|
'opc_session_closed_total',
|
||||||
|
'OPC UA client disconnects completed (session tear-down initiated)',
|
||||||
|
_OPC_SESSION_DEBUG_LABELS,
|
||||||
|
)
|
||||||
|
|
||||||
|
OPC_SESSION_REVISED_TIMEOUT_MS = Gauge(
|
||||||
|
'opc_session_revised_timeout_milliseconds',
|
||||||
|
'Server-revised OPC UA session timeout (RevisedSessionTimeout) in ms after connect',
|
||||||
|
_OPC_SESSION_DEBUG_LABELS,
|
||||||
|
)
|
||||||
|
|
||||||
|
OPC_WRITE_ATTEMPT_LABELS = [*_OPC_SESSION_DEBUG_LABELS, 'model_id', 'model_name', 'result']
|
||||||
|
|
||||||
|
OPC_WRITE_ATTEMPTS_TOTAL = Counter(
|
||||||
|
'opc_write_attempts_total',
|
||||||
|
'OPC UA write attempts with session and outcome (result=OK or exception class name)',
|
||||||
|
OPC_WRITE_ATTEMPT_LABELS,
|
||||||
|
)
|
||||||
|
|
||||||
|
OPC_WRITE_INTER_ARRIVAL_OVER_SESSION_TIMEOUT_TOTAL = Counter(
|
||||||
|
'opc_write_inter_arrival_over_session_timeout_total',
|
||||||
|
'Successful writes where seconds since the previous successful write exceeded RevisedSessionTimeout (ms)',
|
||||||
|
_OPC_SESSION_DEBUG_LABELS,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ================== Model metrics ==================
|
||||||
|
|
||||||
|
MODEL_READ_LAG = Histogram(
|
||||||
|
'laborious_model_read_lag',
|
||||||
|
'Lag between the start and read of read operations',
|
||||||
|
CORE_LABELS,
|
||||||
|
buckets=[0.01, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0],
|
||||||
|
)
|
||||||
|
|
||||||
|
MODEL_WRITE_LAG = Histogram(
|
||||||
|
'laborious_model_write_lag',
|
||||||
|
'Lag between the start and end of write operations',
|
||||||
|
CORE_LABELS,
|
||||||
|
buckets=[0.01, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0],
|
||||||
|
)
|
||||||
|
|
||||||
|
MODEL_READ_COUNT = Counter(
|
||||||
|
'laborious_model_read_count',
|
||||||
|
'Number of reads from the model',
|
||||||
|
CORE_LABELS,
|
||||||
|
)
|
||||||
|
|
||||||
|
MODEL_WRITE_COUNT = Counter(
|
||||||
|
'laborious_model_write_count',
|
||||||
|
'Number of writes to the model',
|
||||||
|
CORE_LABELS,
|
||||||
|
)
|
||||||
|
|
||||||
|
MODEL_READ_ERROR_COUNT = Counter(
|
||||||
|
'laborious_model_read_error_count',
|
||||||
|
'Number of errors reading from the model',
|
||||||
|
CORE_LABELS,
|
||||||
|
)
|
||||||
|
|
||||||
|
MODEL_WRITE_ERROR_COUNT = Counter(
|
||||||
|
'laborious_model_write_error_count',
|
||||||
|
'Number of errors writing to the model',
|
||||||
|
CORE_LABELS,
|
||||||
|
)
|
||||||
|
|
||||||
|
MODEL_ANALYZE_LAG = Histogram(
|
||||||
|
'laborious_model_analyze_lag',
|
||||||
|
'Lag between the start and end of analyze operations',
|
||||||
|
CORE_LABELS,
|
||||||
|
buckets=[0.01, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0],
|
||||||
|
)
|
||||||
|
|
||||||
|
MODEL_ANALYZE_COUNT = Counter(
|
||||||
|
'laborious_model_analyze_count',
|
||||||
|
'Number of analyze operations',
|
||||||
|
CORE_LABELS,
|
||||||
|
)
|
||||||
|
|
||||||
|
MODEL_ANALYZE_ERROR_COUNT = Counter(
|
||||||
|
'laborious_model_analyze_error_count',
|
||||||
|
'Number of errors during analyze operations',
|
||||||
|
CORE_LABELS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ================== PI Web API metrics ==================
|
||||||
|
|
||||||
|
PI_WEB_API_LABELS = [*CORE_LABELS, 'tag_name']
|
||||||
|
|
||||||
|
PI_WEB_API_PREDICTION_WRITTEN_COUNT = Counter(
|
||||||
|
'laborious_pi_web_api_prediction_written_count',
|
||||||
|
'Number of predictions written to the PI Web API',
|
||||||
|
PI_WEB_API_LABELS,
|
||||||
|
)
|
||||||
|
|
||||||
|
PI_WEB_API_PREDICTION_WRITTEN_ERROR_COUNT = Counter(
|
||||||
|
'laborious_pi_web_api_prediction_written_error_count',
|
||||||
|
'Number of errors writing predictions to the PI Web API',
|
||||||
|
PI_WEB_API_LABELS,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,65 +1,68 @@
|
|||||||
from os import getenv
|
|
||||||
import json
|
import json
|
||||||
from typing import Dict, Any
|
from os import getenv
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
def build_postgres_config() -> Dict[str, Any]:
|
def build_mlflow_config() -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Build PostgreSQL database configuration from environment variables.
|
Read MLflow tracking and registry credentials from the environment.
|
||||||
|
|
||||||
This function constructs a PostgreSQL configuration dictionary from
|
Used by ``Activities`` when constructing ``SientiaMLflowRepository``. The ``url`` value is the
|
||||||
environment variables with sensible defaults for local development.
|
same string workers and notebooks should use for ``MLFLOW_TRACKING_URI``-style clients.
|
||||||
It handles connection pool configuration and security parameters.
|
|
||||||
|
|
||||||
Environment Variables:
|
Environment Variables:
|
||||||
POSTGRES_HOST: Database hostname (default: localhost)
|
MLFLOW_URL: Host with scheme
|
||||||
POSTGRES_PORT: Database port (default: 5432)
|
MLFLOW_USERNAME: Basic-auth or service user (default: aignosi)
|
||||||
POSTGRES_USER: Database username (default: sientia)
|
MLFLOW_PASSWORD: Password or token (default: aignosi)
|
||||||
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:
|
Return:
|
||||||
dict: PostgreSQL configuration dictionary with all required parameters
|
dict[str, Any]: ``url``, ``username``, ``password``.
|
||||||
"""
|
"""
|
||||||
return {
|
return {
|
||||||
'host': getenv('POSTGRES_HOST', 'localhost'),
|
'url': getenv('MLFLOW_URL', 'http://localhost:5080'),
|
||||||
'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.
|
|
||||||
|
|
||||||
This function constructs an MLFlow configuration dictionary from
|
|
||||||
environment variables with sensible defaults for local development.
|
|
||||||
It handles server connection and authentication parameters.
|
|
||||||
|
|
||||||
Environment Variables:
|
|
||||||
MLFLOW_HOST: MLFlow server hostname (default: http://localhost)
|
|
||||||
MLFLOW_PORT: MLFlow server port (default: 5080)
|
|
||||||
MLFLOW_USERNAME: MLFlow username (default: aignosi)
|
|
||||||
MLFLOW_PASSWORD: MLFlow password (default: aignosi)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: MLFlow configuration dictionary with all required parameters
|
|
||||||
"""
|
|
||||||
return {
|
|
||||||
'host': getenv('MLFLOW_HOST', 'http://localhost'),
|
|
||||||
'port': int(getenv('MLFLOW_PORT', '5080')),
|
|
||||||
'username': getenv('MLFLOW_USERNAME', 'aignosi'),
|
'username': getenv('MLFLOW_USERNAME', 'aignosi'),
|
||||||
'password': getenv('MLFLOW_PASSWORD', 'aignosi')
|
'password': getenv('MLFLOW_PASSWORD', 'aignosi'),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def build_opc_config() -> Dict[str, Any]:
|
def build_plugin_store_config() -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Collect settings for ``PluginStore`` (Git-backed catalog + runtime install via pip).
|
||||||
|
|
||||||
|
Mirrors the model-manager service: the worker passes these kwargs into ``PluginStore`` after
|
||||||
|
``install_runtime`` resolves wheels from the configured PyPI index. Missing optional env vars
|
||||||
|
become ``None`` so the store can run without auth in local dev.
|
||||||
|
|
||||||
|
Environment Variables:
|
||||||
|
STORE_BASE_URL: Git HTTP(S) server (e.g. Gitea) base URL (default: http://localhost:3000)
|
||||||
|
STORE_OWNER: Namespace or org owning the store repo (default: sientia)
|
||||||
|
STORE_REPO: Repository name (default: model-library-store)
|
||||||
|
STORE_BRANCH: Checkout branch; unset lets the client use default
|
||||||
|
STORE_USERNAME / STORE_PASSWORD: HTTP basic credentials for Git fetch
|
||||||
|
STORE_CACHE_TTL_SECONDS: Optional integer seconds for metadata cache TTL
|
||||||
|
PYPI_SERVER: Index URL for ``pip install`` during runtime install (default: http://localhost:5000)
|
||||||
|
PYPI_USERNAME / PYPI_PASSWORD: Optional index authentication
|
||||||
|
|
||||||
|
Return:
|
||||||
|
dict[str, Any]: Keys aligned with ``PluginStore`` constructor parameter names.
|
||||||
|
"""
|
||||||
|
|
||||||
|
cache_ttl_seconds = getenv('STORE_CACHE_TTL_SECONDS')
|
||||||
|
return {
|
||||||
|
'base_url': getenv('STORE_BASE_URL', 'http://localhost:3000'),
|
||||||
|
'owner': getenv('STORE_OWNER', 'sientia'),
|
||||||
|
'repo': getenv('STORE_REPO', 'model-library-store'),
|
||||||
|
'username': getenv('STORE_USERNAME'),
|
||||||
|
'password': getenv('STORE_PASSWORD'),
|
||||||
|
'branch': getenv('STORE_BRANCH'),
|
||||||
|
'cache_ttl_seconds': int(cache_ttl_seconds) if cache_ttl_seconds else None,
|
||||||
|
'pypi_index_url': getenv('PYPI_SERVER', 'http://localhost:5000'),
|
||||||
|
'pypi_username': getenv('PYPI_USERNAME'),
|
||||||
|
'pypi_password': getenv('PYPI_PASSWORD'),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_opc_config() -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Build OPC server configuration from environment variables.
|
Build OPC server configuration from environment variables.
|
||||||
|
|
||||||
@@ -75,7 +78,7 @@ def build_opc_config() -> Dict[str, Any]:
|
|||||||
OPC_CERT_PATH: Client certificate path (fallback, default: None)
|
OPC_CERT_PATH: Client certificate path (fallback, default: None)
|
||||||
OPC_PRIVATE_KEY_PATH: Client private key path (fallback, default: None)
|
OPC_PRIVATE_KEY_PATH: Client private key path (fallback, default: None)
|
||||||
OPC_SERVER_CERT_PATH: Server certificate path (fallback, default: None)
|
OPC_SERVER_CERT_PATH: Server certificate path (fallback, default: None)
|
||||||
OPC_RECONNECTION_INTERVAL: Reconnection interval in milliseconds (fallback, default: 120)
|
OPC_RECONNECTION_INTERVAL: Reconnection interval in seconds (fallback, default: 120)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: OPC server configuration dictionary
|
dict: OPC server configuration dictionary
|
||||||
@@ -88,42 +91,37 @@ def build_opc_config() -> Dict[str, Any]:
|
|||||||
return {
|
return {
|
||||||
getenv('OPC_ID', '1'): {
|
getenv('OPC_ID', '1'): {
|
||||||
'id': getenv('OPC_ID', '1'),
|
'id': getenv('OPC_ID', '1'),
|
||||||
|
'server_name': getenv('OPC_SERVER_NAME', 'default_server'),
|
||||||
'url': getenv('OPC_URL', 'opc.tcp://localhost:4840'),
|
'url': getenv('OPC_URL', 'opc.tcp://localhost:4840'),
|
||||||
'server_uri': getenv('OPC_SERVER_URI', 'opc.tcp://localhost:4840'),
|
'server_uri': getenv('OPC_SERVER_URI', 'opc.tcp://localhost:4840'),
|
||||||
'cert_path': getenv('OPC_CERT_PATH', None),
|
'cert_path': getenv('OPC_CERT_PATH', None),
|
||||||
'private_key_path': getenv('OPC_PRIVATE_KEY_PATH', None),
|
'private_key_path': getenv('OPC_PRIVATE_KEY_PATH', None),
|
||||||
'server_cert_path': getenv('OPC_SERVER_CERT_PATH', None),
|
'server_cert_path': getenv('OPC_SERVER_CERT_PATH', None),
|
||||||
'reconnection_interval': int(getenv('OPC_RECONNECTION_INTERVAL', '120'))
|
'reconnection_interval': int(getenv('OPC_RECONNECTION_INTERVAL', '120')),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def build_mongodb_config() -> Dict[str, Any]:
|
def build_minio_config() -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Build MongoDB configuration from environment variables.
|
Build MinIO (S3-compatible) 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:
|
Environment Variables:
|
||||||
MONGODB_USERNAME: MongoDB username (default: root)
|
MINIO_ENDPOINT_URL: Host:port or URL for the S3 API (default: http://localhost:9000)
|
||||||
MONGODB_PASSWORD: MongoDB password (default: wKZDbMNU1c)
|
MINIO_ACCESS_KEY: Access key (default: minioadmin)
|
||||||
MONGODB_URL: MongoDB connection URI (default: localhost:27018)
|
MINIO_SECRET_KEY: Secret key (default: minioadmin)
|
||||||
MONGODB_DATABASE_NAME: MongoDB database name (default: sientia)
|
MINIO_DEFAULT_BUCKET: Default bucket for Laborious payloads (default: laborious)
|
||||||
MONGODB_TTL_INDEX_HOURS: TTL index duration in hours (default: 1)
|
MINIO_RETENTION_HOURS: Offloaded object retention window (default: 24)
|
||||||
|
MINIO_SECURE: If ``true``, use HTTPS (default: false)
|
||||||
|
|
||||||
Returns:
|
Return:
|
||||||
dict: MongoDB configuration dictionary with connection parameters
|
dict[str, Any]: Keys consumed by ``Activities`` / ``MinioRepository``.
|
||||||
"""
|
"""
|
||||||
username = getenv('MONGODB_USERNAME', 'root')
|
|
||||||
password = getenv('MONGODB_PASSWORD', 'wKZDbMNU1c')
|
|
||||||
uri = getenv('MONGODB_URL', 'localhost:27018')
|
|
||||||
|
|
||||||
connection_string = f'mongodb://{username}:{password}@{uri}'
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'connection_string': connection_string,
|
'endpoint_url': getenv('MINIO_ENDPOINT_URL', 'http://localhost:9000'),
|
||||||
'database_name': getenv('MONGODB_DATABASE_NAME', 'sientia'),
|
'access_key': getenv('MINIO_ACCESS_KEY', 'minioadmin'),
|
||||||
'ttl_index_seconds': int(getenv('MONGODB_TTL_INDEX_HOURS', '1')) * 3600
|
'secret_key': getenv('MINIO_SECRET_KEY', 'minioadmin'),
|
||||||
|
'default_bucket': getenv('MINIO_DEFAULT_BUCKET', 'laborious'),
|
||||||
|
'retention_hours': int(getenv('MINIO_RETENTION_HOURS', '24')),
|
||||||
|
'secure': getenv('MINIO_SECURE', 'false') == 'true',
|
||||||
}
|
}
|
||||||
|
|||||||
34
laborious/utils/dataframe_debug.py
Normal file
34
laborious/utils/dataframe_debug.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pandas import DataFrame
|
||||||
|
|
||||||
|
DEFAULT_MAX_DEBUG_DATAFRAME_ROWS = 100
|
||||||
|
|
||||||
|
|
||||||
|
def build_dataframe_debug_message(
|
||||||
|
message: str,
|
||||||
|
data: Any,
|
||||||
|
max_rows: int = DEFAULT_MAX_DEBUG_DATAFRAME_ROWS,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Build a safe debug message for dataframe payloads
|
||||||
|
|
||||||
|
Args:
|
||||||
|
- message (str): Base message to identify the logged payload
|
||||||
|
- data (Any): Payload to evaluate for dataframe-aware logging
|
||||||
|
- max_rows (int): Maximum dataframe row count allowed for full payload logging
|
||||||
|
|
||||||
|
Return:
|
||||||
|
Formatted debug message with full dataframe content or compact summary
|
||||||
|
"""
|
||||||
|
if not isinstance(data, DataFrame):
|
||||||
|
return f'{message} {data}'
|
||||||
|
|
||||||
|
rows = data.shape[0]
|
||||||
|
if rows <= max_rows:
|
||||||
|
return f'{message}\n{data.to_csv()}'
|
||||||
|
|
||||||
|
return (
|
||||||
|
f'{message} skipped because dataframe has {rows} rows '
|
||||||
|
f'(max: {max_rows}). Shape: {data.shape}'
|
||||||
|
)
|
||||||
@@ -20,8 +20,11 @@ def filter_specific_variables_null_values(data: DataFrame, config: dict) -> bool
|
|||||||
False if none of the specified variables contain null values.
|
False if none of the specified variables contain null values.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
return not data[
|
|
||||||
data['variable'].isin(config['variables']) & data['value'].isna()].empty
|
if data.empty:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return not data[data['variable'].isin(config['variables']) & data['value'].isna()].empty
|
||||||
|
|
||||||
|
|
||||||
def filter_empty_data(data: DataFrame, _config: dict) -> bool:
|
def filter_empty_data(data: DataFrame, _config: dict) -> bool:
|
||||||
|
|||||||
@@ -52,8 +52,11 @@ def nan_values_filter(predictions: DataFrame, _config: dict) -> bool:
|
|||||||
bool: True if data should be filtered (too many NaN values), False otherwise
|
bool: True if data should be filtered (too many NaN values), False otherwise
|
||||||
|
|
||||||
"""
|
"""
|
||||||
data = predictions.replace({None: np.nan}).drop(
|
data = (
|
||||||
columns=['timestamp'], errors='ignore').infer_objects()
|
predictions.replace({None: np.nan})
|
||||||
|
.drop(columns=['timestamp'], errors='ignore')
|
||||||
|
.infer_objects()
|
||||||
|
)
|
||||||
|
|
||||||
if data.isna().all().all():
|
if data.isna().all().all():
|
||||||
return True
|
return True
|
||||||
|
|||||||
0
laborious/utils/models/__init__.py
Normal file
0
laborious/utils/models/__init__.py
Normal file
362
laborious/utils/models/minio_dataframe_payload.py
Normal file
362
laborious/utils/models/minio_dataframe_payload.py
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
"""
|
||||||
|
MinIO-backed DataFrame payload for Temporal workflows.
|
||||||
|
|
||||||
|
Data is never stored as a pandas ``DataFrame`` field on the dataclass.
|
||||||
|
Instead, the DataFrame is only provided as an input to:
|
||||||
|
`from_dataframe` / `from_dataframe_to_dict`.
|
||||||
|
|
||||||
|
At build time, the DataFrame is evaluated for its serialized size; if it exceeds
|
||||||
|
the configured threshold, it is serialized to parquet bytes and uploaded to MinIO.
|
||||||
|
Otherwise, it is inlined as a Temporal-friendly ``dict``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pickle
|
||||||
|
import re
|
||||||
|
from collections.abc import Hashable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from io import BytesIO
|
||||||
|
from os import getenv
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from pandas import DataFrame, read_parquet
|
||||||
|
from sientia_do.observability.logger import Logger
|
||||||
|
from sientia_do.repository.minio_repository_sync import MinioRepository
|
||||||
|
from sientia_do.temporal.constants import DATETIME_FORMAT_FILENAME, DATETIME_FORMAT_WITH_TZ, now
|
||||||
|
|
||||||
|
# Keys that are part of the serialized wire format (not arbitrary metadata).
|
||||||
|
_SERIALIZED_FIELD_KEYS = frozenset({'data', 'bucket', 'object_key', 'object_prefix', 'uri'})
|
||||||
|
|
||||||
|
_OBJECT_TIMESTAMP_PATTERN = re.compile(
|
||||||
|
r'-(?:initial|transform)-(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})\.parquet$'
|
||||||
|
)
|
||||||
|
|
||||||
|
OFFLOAD_THRESHOLD_BYTES = int(
|
||||||
|
float(getenv('SIENTIA_MINIO_OFFLOAD_THRESHOLD_MEGABYTES', '1.5')) * 1024 * 1024
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relative prefix used for storing offloaded prediction datasets in MinIO.
|
||||||
|
# It is also the root directory for retention cleanup listing.
|
||||||
|
PREDICTION_DATASETS_PREFIX = 'prediction_datasets'
|
||||||
|
|
||||||
|
OperationKind = Literal['initial', 'transform', 'predict']
|
||||||
|
|
||||||
|
|
||||||
|
def _build_object_key(
|
||||||
|
model_name: str, operation: OperationKind, timestamp: str
|
||||||
|
) -> tuple[str, str | None]:
|
||||||
|
"""
|
||||||
|
Build the MinIO object key and the directory prefix used for retention listing.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model_name: Registered model name used in the pipeline.
|
||||||
|
operation: Either initial (pre-transform load) or transform (post-MLFlow transform).
|
||||||
|
timestamp: Filename timestamp segment from DATETIME_FORMAT_FILENAME.
|
||||||
|
|
||||||
|
Return:
|
||||||
|
tuple[str, str | None]: Full object key and normalized prefix (or None if at bucket root).
|
||||||
|
"""
|
||||||
|
# Naming convention:
|
||||||
|
# - Directory is always `prediction_datasets/<model_name>`
|
||||||
|
# - Filename follows the retention-parsing pattern
|
||||||
|
basename = f'{model_name}-{operation}-{timestamp}.parquet'
|
||||||
|
model_dir = model_name.strip().strip('/')
|
||||||
|
prefix = f'{PREDICTION_DATASETS_PREFIX}/{model_dir}'
|
||||||
|
return f'{prefix}/{basename}', prefix
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MinioDataFramePayload:
|
||||||
|
"""
|
||||||
|
Serializable payload after a DataFrame was evaluated: inline tabular dict and/or MinIO keys.
|
||||||
|
|
||||||
|
Build from a live DataFrame only via `from_dataframe` / `from_dataframe_to_dict`.
|
||||||
|
Rehydrate from Temporal via `from_dict`. The DataFrame is not a field on this class.
|
||||||
|
"""
|
||||||
|
|
||||||
|
last_timestamp: str
|
||||||
|
status: dict[str, Any] | None = None
|
||||||
|
data: dict[Hashable, Any] | None = None
|
||||||
|
bucket: str | None = None
|
||||||
|
object_key: str | None = None
|
||||||
|
object_prefix: str | None = None
|
||||||
|
uri: str | None = None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _debug(
|
||||||
|
logger: Logger | None,
|
||||||
|
message: str,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Emit a debug message only when a logger instance is available.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
- logger (Logger | None): Logger instance used for debug messages
|
||||||
|
- message (str): Message to be logged
|
||||||
|
- metadata (dict[str, Any] | None): Optional workflow metadata context
|
||||||
|
"""
|
||||||
|
if logger is None:
|
||||||
|
return
|
||||||
|
logger.custom_debug(message, metadata)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, raw: 'dict[str, Any] | MinioDataFramePayload') -> 'MinioDataFramePayload':
|
||||||
|
"""
|
||||||
|
Reconstruct a MinioDataFramePayload from a plain dict produced by Temporal serialization.
|
||||||
|
|
||||||
|
Temporal converts dataclass return values into plain dicts when crossing
|
||||||
|
workflow/activity boundaries. This method rebuilds the typed instance so
|
||||||
|
that methods like ``retrieve``, ``cleanup_prefix`` and ``has_data`` are
|
||||||
|
available on the receiving side.
|
||||||
|
|
||||||
|
If the argument is already a MinioDataFramePayload, it is returned as-is.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
raw: Dict with keys matching the dataclass fields
|
||||||
|
(last_timestamp, status, data, bucket, object_key, object_prefix, uri),
|
||||||
|
or an existing MinioDataFramePayload instance.
|
||||||
|
|
||||||
|
Return:
|
||||||
|
MinioDataFramePayload: Reconstructed (or original) instance.
|
||||||
|
"""
|
||||||
|
if isinstance(raw, MinioDataFramePayload):
|
||||||
|
return raw
|
||||||
|
return cls(
|
||||||
|
last_timestamp=raw['last_timestamp'],
|
||||||
|
status=raw.get('status'),
|
||||||
|
data=raw.get('data'),
|
||||||
|
bucket=raw.get('bucket'),
|
||||||
|
object_key=raw.get('object_key'),
|
||||||
|
object_prefix=raw.get('object_prefix'),
|
||||||
|
uri=raw.get('uri'),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def estimate_size_bytes(
|
||||||
|
df: DataFrame,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
logger: Logger | None = None,
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Approximate serialized size of the DataFrame as the default-orient dict.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
df: DataFrame whose tabular content size is estimated.
|
||||||
|
|
||||||
|
Return:
|
||||||
|
int: Estimated size in bytes (pickle of dict representation).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
size = len(pickle.dumps(df.to_dict()))
|
||||||
|
except Exception:
|
||||||
|
size = len(pickle.dumps(df))
|
||||||
|
|
||||||
|
MinioDataFramePayload._debug(
|
||||||
|
logger,
|
||||||
|
f'DataFrame size: {size} bytes',
|
||||||
|
metadata,
|
||||||
|
)
|
||||||
|
return size
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse_object_timestamp(object_key: str) -> datetime | None:
|
||||||
|
"""
|
||||||
|
Parse the timestamp embedded in the object key basename (before .parquet).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
object_key: S3/MinIO object key whose basename follows
|
||||||
|
``{model}-{initial|transform}-{DATETIME_FORMAT_FILENAME}.parquet``.
|
||||||
|
|
||||||
|
Return:
|
||||||
|
datetime | None: Parsed UTC-naive datetime from the key, or None if not matched.
|
||||||
|
"""
|
||||||
|
basename = object_key.rsplit('/', 1)[-1]
|
||||||
|
match = _OBJECT_TIMESTAMP_PATTERN.search(basename)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.strptime(match.group(1), DATETIME_FORMAT_FILENAME)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def cleanup_prefix(self) -> str | None:
|
||||||
|
"""
|
||||||
|
Return the MinIO prefix eligible for retention cleanup.
|
||||||
|
|
||||||
|
Cleanup is only applicable when payload data was offloaded to MinIO
|
||||||
|
(``object_key`` present and inline ``data`` absent). Inline-only payloads
|
||||||
|
return ``None`` because there is no object tree to prune.
|
||||||
|
|
||||||
|
Return:
|
||||||
|
str | None: Prefix used by cleanup listing, or ``None`` when cleanup does not apply.
|
||||||
|
"""
|
||||||
|
if self.object_key is not None and self.data is None:
|
||||||
|
return self.object_prefix
|
||||||
|
return None
|
||||||
|
|
||||||
|
def has_data(self) -> bool:
|
||||||
|
"""
|
||||||
|
Indicate whether the payload contains retrievable tabular content.
|
||||||
|
|
||||||
|
A payload is considered non-empty when either inline ``data`` exists
|
||||||
|
(and is not an empty dict) or an ``object_key`` is available for MinIO
|
||||||
|
download.
|
||||||
|
|
||||||
|
Return:
|
||||||
|
bool: ``True`` when data can be retrieved, ``False`` otherwise.
|
||||||
|
"""
|
||||||
|
return (self.data is not None and self.data != {}) or self.object_key is not None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dataframe(
|
||||||
|
cls,
|
||||||
|
dataframe: DataFrame | None,
|
||||||
|
minio_repo: MinioRepository,
|
||||||
|
model_name: str,
|
||||||
|
operation: OperationKind,
|
||||||
|
status: dict[str, Any] | None = None,
|
||||||
|
workflow_metadata: dict | None = None,
|
||||||
|
last_timestamp: str | None = None,
|
||||||
|
logger: Logger | None = None,
|
||||||
|
) -> 'MinioDataFramePayload':
|
||||||
|
"""
|
||||||
|
Evaluate the DataFrame size, then either inline dict or upload parquet to MinIO.
|
||||||
|
|
||||||
|
The DataFrame is not stored on the returned instance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dataframe: Tabular data to evaluate and persist (inline or MinIO).
|
||||||
|
metadata: Small metadata dict merged into the payload (e.g. success, message).
|
||||||
|
minio_repo: sientia_do MinioRepository (or compatible) with `upload_file()`.
|
||||||
|
workflow_metadata: Metadata passed to MinIO store for logging/metrics.
|
||||||
|
model_name: Registered model name used in the object basename.
|
||||||
|
operation: Either ``initial`` (query load) or ``transform`` (post-transform).
|
||||||
|
key_prefix: Backward-compatible parameter (currently ignored for object naming).
|
||||||
|
size_threshold_bytes: Byte limit before offload. When None, the module-level
|
||||||
|
environment-derived default is used.
|
||||||
|
|
||||||
|
Return:
|
||||||
|
MinioDataFramePayload: Instance with data and/or MinIO fields set.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if dataframe is None or dataframe.empty:
|
||||||
|
cls._debug(
|
||||||
|
logger,
|
||||||
|
'MinioDataFramePayload.from_dataframe received empty dataframe, returning empty payload',
|
||||||
|
workflow_metadata,
|
||||||
|
)
|
||||||
|
return cls(
|
||||||
|
data=None, last_timestamp=now().strftime(DATETIME_FORMAT_WITH_TZ), status=status
|
||||||
|
)
|
||||||
|
|
||||||
|
if last_timestamp is None:
|
||||||
|
last_timestamp = max(dataframe['timestamp'].values.tolist())
|
||||||
|
|
||||||
|
dataframe_size = cls.estimate_size_bytes(dataframe, workflow_metadata, logger)
|
||||||
|
cls._debug(
|
||||||
|
logger,
|
||||||
|
(
|
||||||
|
f'MinioDataFramePayload.from_dataframe estimated size: {dataframe_size} bytes '
|
||||||
|
f'(threshold: {OFFLOAD_THRESHOLD_BYTES} bytes)'
|
||||||
|
),
|
||||||
|
workflow_metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
if dataframe_size <= OFFLOAD_THRESHOLD_BYTES:
|
||||||
|
cls._debug(
|
||||||
|
logger,
|
||||||
|
'MinioDataFramePayload.from_dataframe using inline payload',
|
||||||
|
workflow_metadata,
|
||||||
|
)
|
||||||
|
return cls(data=dataframe.to_dict(), last_timestamp=last_timestamp, status=status)
|
||||||
|
|
||||||
|
timestamp = now().strftime(DATETIME_FORMAT_FILENAME)
|
||||||
|
object_key, object_prefix = _build_object_key(model_name, operation, timestamp)
|
||||||
|
cls._debug(
|
||||||
|
logger,
|
||||||
|
(
|
||||||
|
'MinioDataFramePayload.from_dataframe offloading payload to MinIO '
|
||||||
|
f'with key {object_key}'
|
||||||
|
),
|
||||||
|
workflow_metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Upload using the relative object key. The upstream repository will
|
||||||
|
# prefix it internally under its MinIO namespace.
|
||||||
|
parquet_buffer = BytesIO()
|
||||||
|
dataframe.to_parquet(parquet_buffer, engine='pyarrow', index=True)
|
||||||
|
file_bytes = parquet_buffer.getvalue()
|
||||||
|
|
||||||
|
upload_result = minio_repo.upload_file(
|
||||||
|
file_bytes=file_bytes,
|
||||||
|
relative_key=object_key,
|
||||||
|
metadata=workflow_metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
bucket = minio_repo.bucket
|
||||||
|
object_key_full = upload_result.get('minio_object_name', object_key)
|
||||||
|
uri = f's3://{bucket}/{object_key_full}' if bucket else None
|
||||||
|
cls._debug(
|
||||||
|
logger,
|
||||||
|
f'MinioDataFramePayload.from_dataframe upload completed: {uri}',
|
||||||
|
workflow_metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
data=None,
|
||||||
|
bucket=bucket,
|
||||||
|
object_key=object_key_full,
|
||||||
|
object_prefix=object_prefix,
|
||||||
|
uri=uri,
|
||||||
|
last_timestamp=last_timestamp,
|
||||||
|
status=status,
|
||||||
|
)
|
||||||
|
|
||||||
|
def retrieve(
|
||||||
|
self,
|
||||||
|
minio_repo: MinioRepository,
|
||||||
|
workflow_metadata: dict[str, Any] | None = None,
|
||||||
|
logger: Logger | None = None,
|
||||||
|
) -> DataFrame:
|
||||||
|
"""
|
||||||
|
Load parquet from MinIO when object_key is set and populate inline data.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
minio_repo: sientia_do MinioRepository (or compatible) with download_file().
|
||||||
|
workflow_metadata: Metadata passed to MinIO read for logging/metrics.
|
||||||
|
|
||||||
|
Return:
|
||||||
|
dict[str, Any]: Flat dict with data filled (same keys as to_dict after load).
|
||||||
|
"""
|
||||||
|
if self.data is not None:
|
||||||
|
self._debug(
|
||||||
|
logger,
|
||||||
|
'MinioDataFramePayload.retrieve using inline payload data',
|
||||||
|
workflow_metadata,
|
||||||
|
)
|
||||||
|
return DataFrame(self.data)
|
||||||
|
|
||||||
|
if not self.has_data():
|
||||||
|
self._debug(
|
||||||
|
logger,
|
||||||
|
'MinioDataFramePayload.retrieve found no payload data, returning empty dataframe',
|
||||||
|
workflow_metadata,
|
||||||
|
)
|
||||||
|
return DataFrame()
|
||||||
|
|
||||||
|
self._debug(
|
||||||
|
logger,
|
||||||
|
f'MinioDataFramePayload.retrieve downloading object from MinIO: {self.object_key}',
|
||||||
|
workflow_metadata,
|
||||||
|
)
|
||||||
|
file_bytes = minio_repo.download_file(
|
||||||
|
object_name=self.object_key, metadata=workflow_metadata
|
||||||
|
)
|
||||||
|
df = read_parquet(BytesIO(file_bytes))
|
||||||
|
self._debug(
|
||||||
|
logger,
|
||||||
|
f'MinioDataFramePayload.retrieve loaded dataframe from MinIO with shape {df.shape}',
|
||||||
|
workflow_metadata,
|
||||||
|
)
|
||||||
|
return df
|
||||||
@@ -1,481 +0,0 @@
|
|||||||
"""
|
|
||||||
Model Monitoring Repository
|
|
||||||
|
|
||||||
This module contains the ModelMonitoringRepository class,
|
|
||||||
which is responsible for handling the communication with the Model Monitoring API.
|
|
||||||
|
|
||||||
It includes the methods that are used to answer ModelMonitoringService
|
|
||||||
requests using the Model Monitoring API functions.
|
|
||||||
|
|
||||||
By Monitoring we mean the evaluation of the performance of models, the generation of reports.
|
|
||||||
|
|
||||||
"""
|
|
||||||
from datetime import datetime
|
|
||||||
import traceback
|
|
||||||
import pandas as pd
|
|
||||||
import mlflow
|
|
||||||
from os import makedirs, path, remove
|
|
||||||
from sientia.ModelServing import ModelServing
|
|
||||||
from sientia_do.temporal.constants import DATETIME_FORMAT_WITH_TZ
|
|
||||||
from sientia_do.observability.logger import Logger
|
|
||||||
|
|
||||||
|
|
||||||
class MLFlowRepository():
|
|
||||||
def __init__(self, host, username, password, logger: Logger):
|
|
||||||
|
|
||||||
self.model_serving = ModelServing(tracking_uri=host,
|
|
||||||
username=username, password=password,
|
|
||||||
logger=logger)
|
|
||||||
self.logger = logger
|
|
||||||
|
|
||||||
def detect_and_parse_datetime_index(self, data: pd.DataFrame, metadata: dict) -> pd.DataFrame:
|
|
||||||
"""
|
|
||||||
Detect and parse datetime index from data. index must be a timestamp like column.
|
|
||||||
This function must detect the timestamp type (pandas Timestamp or datetime) and convert it to DATETIME_FORMAT_WITH_TZ.
|
|
||||||
If the index is a string, must be in format DATETIME_FORMAT_WITH_TZ.
|
|
||||||
If another type or format, must raise an error.
|
|
||||||
"""
|
|
||||||
index = data.index
|
|
||||||
|
|
||||||
# Get type of first element of index
|
|
||||||
index_type = type(index[0])
|
|
||||||
|
|
||||||
self.logger.custom_info(f"Index type: {index_type}", metadata)
|
|
||||||
|
|
||||||
message = f"Index must be all timestamp like column. Valid formats are: pandas Timestamp, datetime, string in format {DATETIME_FORMAT_WITH_TZ}"
|
|
||||||
|
|
||||||
# Check if all in index are of the same type
|
|
||||||
if not all(isinstance(i, index_type) for i in index):
|
|
||||||
raise ValueError(
|
|
||||||
f"{message}")
|
|
||||||
|
|
||||||
# Check type and converts to DATETIME_FORMAT_WITH_TZ
|
|
||||||
if index_type == str:
|
|
||||||
# Validate format of string and return error if not valid
|
|
||||||
try:
|
|
||||||
pd.to_datetime(data.index, format=DATETIME_FORMAT_WITH_TZ)
|
|
||||||
except ValueError:
|
|
||||||
raise ValueError(
|
|
||||||
f"{message}")
|
|
||||||
|
|
||||||
elif index_type == datetime or index_type == pd.Timestamp:
|
|
||||||
data.index = data.index.strftime(DATETIME_FORMAT_WITH_TZ)
|
|
||||||
else:
|
|
||||||
raise ValueError(
|
|
||||||
f"{message}")
|
|
||||||
|
|
||||||
return data
|
|
||||||
|
|
||||||
def transform(self, model_name: str, data: pd.DataFrame, model_config: dict, metadata: dict) -> dict:
|
|
||||||
"""
|
|
||||||
Transform data using a model.
|
|
||||||
|
|
||||||
Parameters:
|
|
||||||
- model_name (str): The name of the model to use for transformation.
|
|
||||||
- data (pandas.DataFrame): The data to transform.
|
|
||||||
- model_retention (int): The number of minutes to keep the model.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
- dict: A dictionary containing the transformed data.
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.logger.custom_debug(
|
|
||||||
f"Data received for model transformation: {data.to_csv()}", metadata)
|
|
||||||
|
|
||||||
model_retention = model_config.get('retention_minutes', 0)
|
|
||||||
flavor = model_config.get('transform_flavor', 'sklearn')
|
|
||||||
compressed = model_config.get('is_compressed', False)
|
|
||||||
retention_target = model_config.get('retention_target', 'model')
|
|
||||||
transform_keyword = model_config.get(
|
|
||||||
'transform_function_keyword', 'predict')
|
|
||||||
|
|
||||||
transformed_data = self.model_serving.get_cached_transform(
|
|
||||||
model_name, data, model_retention, flavor,
|
|
||||||
compressed, retention_target, transform_keyword
|
|
||||||
)
|
|
||||||
|
|
||||||
self.logger.custom_debug(
|
|
||||||
f"Data received from model transformation: {transformed_data.to_csv()}", metadata)
|
|
||||||
|
|
||||||
transformed_data = self.detect_and_parse_datetime_index(
|
|
||||||
transformed_data, metadata)
|
|
||||||
|
|
||||||
return {
|
|
||||||
'success': True,
|
|
||||||
'content': transformed_data.to_dict()
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
return {
|
|
||||||
'success': False,
|
|
||||||
'content': {
|
|
||||||
'message': str(e),
|
|
||||||
'traceback': traceback.format_exc()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
def predict(self, model_name: str, data: pd.DataFrame, model_config: dict, metadata: dict) -> dict:
|
|
||||||
"""
|
|
||||||
Predict data using a model.
|
|
||||||
|
|
||||||
Parameters:
|
|
||||||
- model_name (str): The name of the model to use for prediction.
|
|
||||||
- data (pandas.DataFrame): The data to predict.
|
|
||||||
- model_retention (int): The number of minutes to keep the model.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
- dict: A dictionary containing the predicted data.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
model_retention = model_config.get('retention_minutes', 0)
|
|
||||||
flavor = model_config.get('predict_flavor', 'pyfunc')
|
|
||||||
compressed = model_config.get('is_compressed', False)
|
|
||||||
retention_target = model_config.get('retention_target', 'model')
|
|
||||||
|
|
||||||
input_index = data.index
|
|
||||||
start_time = datetime.now()
|
|
||||||
|
|
||||||
self.logger.custom_debug(
|
|
||||||
f"Data received for model prediction: {data.to_csv()}", metadata)
|
|
||||||
data = self.model_serving.get_cached_predict(
|
|
||||||
model_name, data, model_retention, flavor,
|
|
||||||
compressed, retention_target
|
|
||||||
)
|
|
||||||
|
|
||||||
end_time = datetime.now()
|
|
||||||
data = pd.DataFrame(data, columns=['prediction'])
|
|
||||||
self.logger.custom_debug(
|
|
||||||
f"Data received from model prediction: {data.to_csv()}", metadata)
|
|
||||||
data.index = input_index
|
|
||||||
data['response_time'] = (end_time - start_time).total_seconds()
|
|
||||||
|
|
||||||
return {
|
|
||||||
'success': True,
|
|
||||||
'content': data.to_dict()
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
return {
|
|
||||||
'success': False,
|
|
||||||
'content': {
|
|
||||||
'message': str(e),
|
|
||||||
'traceback': traceback.format_exc()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_experiment_by_run_id(self, run_id: str) -> dict:
|
|
||||||
# Get the run information using the run_id
|
|
||||||
run = mlflow.get_run(run_id)
|
|
||||||
|
|
||||||
# Extract the experiment ID from the run
|
|
||||||
experiment_id = run.info.experiment_id
|
|
||||||
|
|
||||||
# Get the experiment details using the experiment ID
|
|
||||||
experiment = mlflow.get_experiment(experiment_id)
|
|
||||||
experiment_name = experiment.name
|
|
||||||
return experiment_name
|
|
||||||
|
|
||||||
def get_next_run_name(self, model_name: str) -> str:
|
|
||||||
"""
|
|
||||||
Generate the next run name for a specific MLFlow model.
|
|
||||||
|
|
||||||
This method calculates the next sequential run number for a model
|
|
||||||
by searching existing runs and incrementing the count. It ensures
|
|
||||||
unique run names for model training and retraining operations.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
model_name (str): The name of the MLFlow model
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: The next run name in format 'model_name-run_number'
|
|
||||||
"""
|
|
||||||
runs = mlflow.search_runs(
|
|
||||||
experiment_names=[model_name], order_by=["start_time desc"])
|
|
||||||
next_run_number = len(runs) + 1
|
|
||||||
return f"{model_name}-{next_run_number}"
|
|
||||||
|
|
||||||
def create_model_experiment(self, model_name: str, data: pd.DataFrame) -> tuple:
|
|
||||||
"""
|
|
||||||
Create a new MLFlow experiment for model retraining.
|
|
||||||
|
|
||||||
This method sets up the complete environment for model retraining by:
|
|
||||||
1. Loading the current production prediction model
|
|
||||||
2. Loading the current production transformation model
|
|
||||||
3. Fitting the transformation model with new data
|
|
||||||
4. Preparing data for prediction model retraining
|
|
||||||
5. Setting up the MLFlow experiment context
|
|
||||||
|
|
||||||
Args:
|
|
||||||
model_name (str): Name of the MLFlow model to retrain
|
|
||||||
data (pd.DataFrame): Training data for model retraining
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
tuple: (prediction_model, data_model, experiment)
|
|
||||||
- prediction_model: Loaded prediction model for retraining
|
|
||||||
- data_model: Fitted transformation model
|
|
||||||
- experiment: MLFlow experiment name
|
|
||||||
"""
|
|
||||||
# load predictor model
|
|
||||||
predictor_uri = f"models:/{model_name}/production"
|
|
||||||
# load transform model
|
|
||||||
latest_production_id = self.model_serving.get_model_run_id(
|
|
||||||
model_name, stage="Production"
|
|
||||||
)
|
|
||||||
transform_uri = self.model_serving.get_model_uri(
|
|
||||||
latest_production_id, prediction=False
|
|
||||||
)
|
|
||||||
# load
|
|
||||||
data_model = mlflow.sklearn.load_model(transform_uri)
|
|
||||||
prediction_model = mlflow.sklearn.load_model(predictor_uri)
|
|
||||||
data_model = data_model.fit(data)
|
|
||||||
treated_data = data_model.predict(data)
|
|
||||||
|
|
||||||
target_name = data_model.target_variable
|
|
||||||
y = data[target_name]
|
|
||||||
treated_data = pd.merge(
|
|
||||||
treated_data, y, left_index=True, right_index=True)
|
|
||||||
prediction_model = prediction_model.fit(treated_data)
|
|
||||||
experiment = self.get_experiment_by_run_id(latest_production_id)
|
|
||||||
mlflow.set_experiment(experiment)
|
|
||||||
|
|
||||||
return prediction_model, data_model, experiment
|
|
||||||
|
|
||||||
def perform_model_retrain(self,
|
|
||||||
prediction_model,
|
|
||||||
data_model,
|
|
||||||
experiment: str,
|
|
||||||
model_name: str,
|
|
||||||
data: pd.DataFrame):
|
|
||||||
"""
|
|
||||||
Execute the complete model retraining process in MLFlow.
|
|
||||||
|
|
||||||
This method performs the actual model retraining by:
|
|
||||||
1. Starting a new MLFlow run with descriptive metadata
|
|
||||||
2. Logging model parameters and hyperparameters
|
|
||||||
3. Retraining both prediction and transformation models
|
|
||||||
4. Logging training data as artifacts
|
|
||||||
5. Saving retrained models to MLFlow registry
|
|
||||||
|
|
||||||
Args:
|
|
||||||
prediction_model: MLFlow prediction model to retrain
|
|
||||||
data_model: MLFlow transformation model to retrain
|
|
||||||
experiment (str): MLFlow experiment name for the retraining
|
|
||||||
model_name (str): Name of the model being retrained
|
|
||||||
data (pd.DataFrame): Training data used for retraining
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
tuple: (status_message, experiment_name)
|
|
||||||
- status_message (str): Success confirmation message
|
|
||||||
- experiment_name (str): Name of the experiment
|
|
||||||
"""
|
|
||||||
pred_model_atributes = vars(prediction_model) # load class attributes
|
|
||||||
data_model_atributes = vars(data_model) # load class attributes
|
|
||||||
experiment_description = f"Retrain model {model_name} with new data"
|
|
||||||
current_run_name = self.get_next_run_name(experiment)
|
|
||||||
with mlflow.start_run(
|
|
||||||
run_name=current_run_name, description=experiment_description
|
|
||||||
) as _run:
|
|
||||||
# update transfomation model
|
|
||||||
# fixed parameters
|
|
||||||
for name_atribute, val_atribute in pred_model_atributes.items():
|
|
||||||
if name_atribute != "model":
|
|
||||||
mlflow.log_param(name_atribute, val_atribute)
|
|
||||||
# update prediction model
|
|
||||||
for name_atribute, val_atribute in data_model_atributes.items():
|
|
||||||
if name_atribute != "model":
|
|
||||||
mlflow.log_param(name_atribute, val_atribute)
|
|
||||||
# dynamic parameters, including model itself
|
|
||||||
mlflow.sklearn.log_model(data_model, "data_model")
|
|
||||||
|
|
||||||
makedirs("temp", exist_ok=True)
|
|
||||||
|
|
||||||
file_path = f"temp/raw_data_{model_name}.csv"
|
|
||||||
data.to_csv(file_path, index=True)
|
|
||||||
|
|
||||||
# log the data raw
|
|
||||||
mlflow.log_artifact(file_path)
|
|
||||||
|
|
||||||
# dynamic parameters, including model itself
|
|
||||||
mlflow.sklearn.log_model(prediction_model, "prediction_model")
|
|
||||||
mlflow.log_param("retrain", True)
|
|
||||||
|
|
||||||
# clear temp file
|
|
||||||
if path.exists(file_path):
|
|
||||||
remove(file_path)
|
|
||||||
|
|
||||||
return "Model retrained successfully", experiment
|
|
||||||
|
|
||||||
def retrain_model(self, data: pd.DataFrame, model_name: str) -> tuple:
|
|
||||||
"""
|
|
||||||
Orchestrate the complete model retraining workflow.
|
|
||||||
|
|
||||||
This method coordinates the entire model retraining process by:
|
|
||||||
1. Creating the MLFlow experiment environment
|
|
||||||
2. Loading existing production models
|
|
||||||
3. Executing the retraining process
|
|
||||||
4. Returning comprehensive retraining results
|
|
||||||
|
|
||||||
Args:
|
|
||||||
data (pd.DataFrame): Training data for model retraining
|
|
||||||
model_name (str): Name of the MLFlow model to retrain
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
tuple: (status_message, experiment_name)
|
|
||||||
- status_message (str): Retraining operation status
|
|
||||||
- experiment_name (str): MLFlow experiment identifier
|
|
||||||
"""
|
|
||||||
prediction_model, data_model, experiment = self.create_model_experiment(
|
|
||||||
model_name, data)
|
|
||||||
retrain_result = self.perform_model_retrain(
|
|
||||||
prediction_model, data_model, experiment, model_name, data)
|
|
||||||
return retrain_result
|
|
||||||
|
|
||||||
def get_experiment(self, experiment_name: str) -> int:
|
|
||||||
"""
|
|
||||||
Retrieve MLFlow experiment ID by experiment name.
|
|
||||||
|
|
||||||
This method searches for an MLFlow experiment by name and
|
|
||||||
returns its unique identifier. It provides error handling
|
|
||||||
for non-existent experiments.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
experiment_name (str): Name of the MLFlow experiment
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
int: MLFlow experiment ID
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If the experiment name is not found
|
|
||||||
"""
|
|
||||||
experiment = mlflow.get_experiment_by_name(experiment_name)
|
|
||||||
|
|
||||||
if experiment is None:
|
|
||||||
raise ValueError(f'Experiment {experiment_name} not found')
|
|
||||||
|
|
||||||
return int(experiment.experiment_id)
|
|
||||||
|
|
||||||
def get_experiment_last_run(self, experiment_id: int) -> str:
|
|
||||||
"""
|
|
||||||
Retrieve the most recent retraining run ID for an experiment.
|
|
||||||
|
|
||||||
This method searches for the latest run in an MLFlow experiment
|
|
||||||
that has been marked as a retraining run. It filters runs by
|
|
||||||
the 'retrain' parameter and orders them by completion time.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
experiment_id (int): MLFlow experiment ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: MLFlow run ID of the most recent retraining run
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If runs data is not in expected DataFrame format
|
|
||||||
"""
|
|
||||||
runs = mlflow.search_runs(
|
|
||||||
experiment_ids=[experiment_id],
|
|
||||||
filter_string="", # Sem filtro no MLflow ainda
|
|
||||||
output_format="pandas"
|
|
||||||
)
|
|
||||||
|
|
||||||
if not isinstance(runs, pd.DataFrame):
|
|
||||||
raise ValueError('Runs is not a pandas DataFrame')
|
|
||||||
|
|
||||||
# Filtrar apenas as runs onde params.retrain == True
|
|
||||||
filtered_runs = runs[runs["params.retrain"] == 'True']
|
|
||||||
|
|
||||||
# Converter a coluna 'end_time' para datetime
|
|
||||||
filtered_runs['end_time'] = pd.to_datetime(filtered_runs['end_time'])
|
|
||||||
|
|
||||||
# Ordenar o DataFrame de forma descendente pela coluna 'end_time'
|
|
||||||
filtered_runs = filtered_runs.sort_values(
|
|
||||||
by='end_time', ascending=False)
|
|
||||||
|
|
||||||
# Pegar a última run_id do DataFrame filtrado e ordenado
|
|
||||||
latest_run_id = filtered_runs.iloc[0]['run_id']
|
|
||||||
|
|
||||||
return latest_run_id
|
|
||||||
|
|
||||||
def update_production_model_by_run_id(self, run_id: str, model_name: str) -> dict:
|
|
||||||
"""
|
|
||||||
Update production model with a specific MLFlow run.
|
|
||||||
|
|
||||||
This method promotes a model from a specific MLFlow run to
|
|
||||||
production stage. It handles model registration, versioning,
|
|
||||||
and stage transitions with proper error handling.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
run_id (str): MLFlow run ID containing the model to promote
|
|
||||||
model_name (str): Name of the MLFlow model
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Model update metadata containing:
|
|
||||||
- model_name (str): Name of the updated model
|
|
||||||
- version (str): New model version number
|
|
||||||
- mlflow_run_id (str): Source run ID
|
|
||||||
|
|
||||||
Update Process:
|
|
||||||
1. Registers the model from the specified run
|
|
||||||
2. Retrieves the latest model version
|
|
||||||
3. Transitions the model to 'Production' stage
|
|
||||||
4. Archives existing production versions
|
|
||||||
"""
|
|
||||||
# Registrar o modelo
|
|
||||||
# Aqui estamos assumindo que você já tem um modelo salvo, caso contrário você precisará treiná-lo e salvá-lo primeiro.
|
|
||||||
# Se o modelo já está registrado, você pode usar o método register_model() ou pyfunc.load_model() para isso.
|
|
||||||
mlflow.register_model(
|
|
||||||
f"runs:/{run_id}/prediction_model", model_name)
|
|
||||||
|
|
||||||
# Colocar a versão do modelo em produção
|
|
||||||
# Depois de registrar o modelo, precisamos pegar a versão mais recente do modelo e movê-lo para o estágio 'Production'
|
|
||||||
client = mlflow.tracking.MlflowClient()
|
|
||||||
|
|
||||||
# Obter a versão mais recente registrada do modelo
|
|
||||||
model_versions = client.get_registered_model(
|
|
||||||
model_name).latest_versions
|
|
||||||
|
|
||||||
if not isinstance(model_versions, list):
|
|
||||||
raise ValueError('Model versions is not a list')
|
|
||||||
|
|
||||||
max_version = max(model_versions, key=lambda x: int(x.version)).version
|
|
||||||
|
|
||||||
# Mover a versão mais recente do modelo para o estágio de 'Production'
|
|
||||||
client.transition_model_version_stage(
|
|
||||||
name=model_name,
|
|
||||||
version=max_version,
|
|
||||||
stage="Production",
|
|
||||||
archive_existing_versions=True
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
'model_name': model_name,
|
|
||||||
'version': max_version,
|
|
||||||
'mlflow_run_id': run_id
|
|
||||||
}
|
|
||||||
|
|
||||||
def update_production_model(self, experiment: str, model_name: str) -> dict:
|
|
||||||
"""
|
|
||||||
Update production model using the latest retraining run.
|
|
||||||
|
|
||||||
This method orchestrates the complete production model update
|
|
||||||
process by identifying the most recent retraining run and
|
|
||||||
promoting it to production stage.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
experiment (str): MLFlow experiment name
|
|
||||||
model_name (str): Name of the MLFlow model
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Complete model update metadata containing:
|
|
||||||
- model_name (str): Name of the updated model
|
|
||||||
- version (str): New model version number
|
|
||||||
- mlflow_run_id (str): Source run ID
|
|
||||||
- mlflow_experiment_id (int): Experiment ID
|
|
||||||
"""
|
|
||||||
experiment_id = self.get_experiment(experiment)
|
|
||||||
run_id = self.get_experiment_last_run(experiment_id)
|
|
||||||
metadata = self.update_production_model_by_run_id(run_id, model_name)
|
|
||||||
|
|
||||||
metadata['mlflow_experiment_id'] = experiment_id
|
|
||||||
|
|
||||||
return metadata
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,78 +1,88 @@
|
|||||||
"""
|
"""
|
||||||
Laborious Worker Module
|
Laborious Worker Module
|
||||||
|
|
||||||
This module provides the main worker implementation for the Sientia DataOps Laborious system.
|
Entry process that connects to Temporal, registers Laborious activities, and runs four workers in
|
||||||
It orchestrates Temporal workers, manages task queues, and handles the lifecycle of
|
parallel. Each worker shares the same ``Activities`` instance (single Postgres pool, single MLflow
|
||||||
prediction and retraining workflows.
|
repository, single PluginStore handle) but polls a different task queue.
|
||||||
|
|
||||||
The worker supports two main task queues:
|
Task queues (see ``sientia_do.temporal.worker.prepare_worker``):
|
||||||
- predictions_batch-queue: Handles batch prediction workflows
|
- ``predictions_batch-{runtime}-queue`` + sub-workflows on the same queue (ML-heavy path).
|
||||||
- minimal_retrain-queue: Handles model retraining workflows
|
- ``minimal_retrain-{runtime}-queue`` (retrain + promote + export).
|
||||||
|
- ``drift-queue`` and ``simple_metrics-queue`` without a runtime suffix so existing schedulers
|
||||||
|
keep stable queue names.
|
||||||
|
|
||||||
Key Features:
|
Bootstrap order:
|
||||||
- Automatic scaling with PollerBehaviorAutoscaling
|
1. Prometheus app metrics and Mongo-backed notification handler.
|
||||||
- Prometheus metrics integration
|
2. ``RUNTIME`` validation and ``PluginStore.install_runtime`` so ``SientiaModel`` code is importable.
|
||||||
- Comprehensive error handling and logging
|
3. ``Activities`` construction (builds ``SientiaMLflowRepository`` internally from env).
|
||||||
- Graceful shutdown with cleanup
|
4. OPC client initialization inside activities.
|
||||||
- Multiple worker instances for different workflow types
|
5. Temporal ``Runtime`` with SDK Prometheus bind, client connect, then ``prepare_worker`` per workflow.
|
||||||
|
|
||||||
|
Shutdown closes workers, notifications, activities (pools + OPC), and clears ``app_up``.
|
||||||
|
|
||||||
Environment Variables:
|
Environment Variables:
|
||||||
- TEMPORAL_HOST: Temporal server address (default: localhost:7233)
|
- RUNTIME: Required non-empty string passed to ``install_runtime``.
|
||||||
- TEMPORAL_NAMESPACE: Temporal namespace (default: laborious)
|
- STORE_* / PYPI_*: Plugin store and private index (see ``build_plugin_store_config``).
|
||||||
- POD_ID: Kubernetes pod identifier for metrics
|
- TEMPORAL_HOST, TEMPORAL_NAMESPACE: Cluster connection.
|
||||||
- HTTP_METRICS_PORT: Prometheus metrics server port (default: 9090)
|
- POD_ID, HTTP_METRICS_PORT, HTTP_SDK_METRICS_PORT: Observability.
|
||||||
- HTTP_SDK_METRICS_PORT: Temporal SDK metrics port (default: 9091)
|
- PROJECT_NAME, MONGODB_*: Notifications (via ``build_mongodb_config`` in handler).
|
||||||
- PROJECT_NAME: Project name for notifications (default: laborious)
|
- POSTGRES_*, MINIO_*, OPC_*, PI_WEB_API_*, MLFLOW_*: Passed through ``Activities`` helpers.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from temporalio import workflow, client
|
from temporalio import client, workflow
|
||||||
from temporalio.worker import Worker, PollerBehaviorAutoscaling
|
from temporalio.runtime import PrometheusConfig, Runtime, TelemetryConfig
|
||||||
from temporalio.runtime import Runtime, TelemetryConfig, PrometheusConfig
|
|
||||||
|
|
||||||
with workflow.unsafe.imports_passed_through():
|
with workflow.unsafe.imports_passed_through():
|
||||||
|
import asyncio
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import asyncio
|
|
||||||
from laborious.workflows.minimal_retrain import MinimalRetrain
|
from prometheus_client import start_http_server
|
||||||
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 laborious.activities.activities import Activities
|
|
||||||
from laborious.utils.connectors_config import (
|
|
||||||
build_postgres_config,
|
|
||||||
build_mlflow_config,
|
|
||||||
build_opc_config,
|
|
||||||
build_mongodb_config
|
|
||||||
)
|
|
||||||
from sientia_do.notifications.handlers import CoreNotificationHandler as NotificationHandler
|
from sientia_do.notifications.handlers import CoreNotificationHandler as NotificationHandler
|
||||||
from sientia_do.observability.logger import get_logger
|
from sientia_do.observability.logger import get_logger
|
||||||
from laborious import metrics
|
from sientia_do.observability.metrics_controller import MetricsController
|
||||||
from prometheus_client import start_http_server
|
from sientia_do.temporal.worker.prepare_worker import prepare_worker
|
||||||
|
from sientia_do.utils.connectors_config import (
|
||||||
|
build_api_config,
|
||||||
|
build_mongodb_config,
|
||||||
|
build_postgres_config,
|
||||||
|
)
|
||||||
|
from sientia_model.model_repository.plugin_store import PluginStore
|
||||||
|
|
||||||
POD_ID = os.getenv('POD_ID')
|
from laborious import metrics
|
||||||
SDK_METRICS_PORT = int(os.getenv('HTTP_SDK_METRICS_PORT', "9091"))
|
from laborious.activities.activities import Activities
|
||||||
|
from laborious.utils.connectors_config import (
|
||||||
|
build_minio_config,
|
||||||
|
build_opc_config,
|
||||||
|
build_plugin_store_config,
|
||||||
|
)
|
||||||
|
from laborious.workflows.drift import Drift
|
||||||
|
from laborious.workflows.minimal_retrain import MinimalRetrain
|
||||||
|
from laborious.workflows.predictions_batch import PredictionsBatch
|
||||||
|
from laborious.workflows.simple_metrics import SimpleMetrics
|
||||||
|
from laborious.workflows.sub_workflows.format_and_export_prediction import (
|
||||||
|
FormatAndExportPrediction,
|
||||||
|
)
|
||||||
|
from laborious.workflows.sub_workflows.prediction_process import PredictionProcess
|
||||||
|
|
||||||
|
POD_ID = os.getenv('HOSTNAME')
|
||||||
|
SDK_METRICS_PORT = int(os.getenv('HTTP_SDK_METRICS_PORT', '9091'))
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
"""
|
"""
|
||||||
Main entry point for the Laborious worker application.
|
Run the full worker lifecycle: metrics, notifications, runtime install, workers, gather.
|
||||||
|
|
||||||
This function initializes and starts all components of the worker:
|
Exits the process with code 0 on normal completion of all worker tasks, or 1 after logging
|
||||||
1. Sets up logging and metadata
|
if any worker raises. ``finally`` always shuts down notifications and activities and sets
|
||||||
2. Starts Prometheus metrics server
|
``app_up`` to 0 before ``sys.exit``.
|
||||||
3. Initializes notification handler
|
|
||||||
4. Creates and configures activities
|
|
||||||
5. Initializes OPC connections
|
|
||||||
6. Starts Temporal client and workers
|
|
||||||
7. Manages worker lifecycle and graceful shutdown
|
|
||||||
|
|
||||||
The function runs indefinitely until interrupted or an error occurs.
|
|
||||||
On error, it performs cleanup and exits with a non-zero status code.
|
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
Exception: Any unhandled exception during worker execution
|
Exception: Propagated from ``asyncio.gather`` only before ``finally`` handling; typically
|
||||||
SystemExit: On graceful shutdown or error conditions
|
workers run until cancelled.
|
||||||
|
|
||||||
|
Return:
|
||||||
|
None (process terminates via ``sys.exit`` from the ``finally`` block).
|
||||||
"""
|
"""
|
||||||
host = os.getenv('TEMPORAL_HOST', 'localhost:7233')
|
host = os.getenv('TEMPORAL_HOST', 'localhost:7233')
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
@@ -87,7 +97,7 @@ async def main():
|
|||||||
|
|
||||||
logger.custom_info(f'Starting Worker with POD_ID: {POD_ID}', metadata)
|
logger.custom_info(f'Starting Worker with POD_ID: {POD_ID}', metadata)
|
||||||
|
|
||||||
logger.custom_info("Starting prometheus client...", metadata)
|
logger.custom_info('Starting prometheus client...', metadata)
|
||||||
start_prometheus_server()
|
start_prometheus_server()
|
||||||
|
|
||||||
logger.custom_info('Starting Notification Handler...', metadata)
|
logger.custom_info('Starting Notification Handler...', metadata)
|
||||||
@@ -97,29 +107,77 @@ async def main():
|
|||||||
connection_string=mongo_config['connection_string'],
|
connection_string=mongo_config['connection_string'],
|
||||||
database=mongo_config['database_name'],
|
database=mongo_config['database_name'],
|
||||||
logger=logger,
|
logger=logger,
|
||||||
project_name=os.getenv('PROJECT_NAME', 'laborious')
|
project_name=os.getenv('PROJECT_NAME', 'laborious'),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
metrics_controller = MetricsController(logger=logger)
|
||||||
|
|
||||||
|
runtime = os.getenv('RUNTIME', '').strip()
|
||||||
|
if not runtime:
|
||||||
|
logger.custom_critical(
|
||||||
|
'RUNTIME environment variable is required and must be non-empty',
|
||||||
|
metadata,
|
||||||
|
)
|
||||||
|
metrics.APP_UP.labels(pod_id=POD_ID).set(0)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
metadata_runtime = {**metadata, 'runtime': runtime}
|
||||||
|
logger.custom_info(f'Installing PluginStore runtime: {runtime}', metadata_runtime)
|
||||||
|
|
||||||
|
ps_cfg = build_plugin_store_config()
|
||||||
|
plugin_store = PluginStore(
|
||||||
|
base_url=ps_cfg['base_url'],
|
||||||
|
owner=ps_cfg['owner'],
|
||||||
|
repo=ps_cfg['repo'],
|
||||||
|
username=ps_cfg['username'],
|
||||||
|
password=ps_cfg['password'],
|
||||||
|
branch=ps_cfg['branch'],
|
||||||
|
cache_ttl_seconds=ps_cfg['cache_ttl_seconds'],
|
||||||
|
pypi_index_url=ps_cfg['pypi_index_url'],
|
||||||
|
pypi_username=ps_cfg['pypi_username'],
|
||||||
|
pypi_password=ps_cfg['pypi_password'],
|
||||||
|
logger=logger,
|
||||||
|
notification_handler=notification_handler,
|
||||||
|
metrics_controller=metrics_controller,
|
||||||
|
)
|
||||||
|
|
||||||
|
if runtime == 'legacy':
|
||||||
|
to_install_runtime = 'single'
|
||||||
|
else:
|
||||||
|
to_install_runtime = runtime
|
||||||
|
|
||||||
|
try:
|
||||||
|
await plugin_store.install_runtime(
|
||||||
|
runtime_name=to_install_runtime, metadata=metadata_runtime
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.custom_critical(
|
||||||
|
f'Failed to install runtime {to_install_runtime}: {exc}', metadata_runtime
|
||||||
|
)
|
||||||
|
metrics.APP_UP.labels(pod_id=POD_ID).set(0)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
logger.custom_info('Starting Activities...', metadata)
|
logger.custom_info('Starting Activities...', metadata)
|
||||||
|
|
||||||
activities = Activities(
|
activities = Activities(
|
||||||
postgres_config=build_postgres_config(),
|
postgres_config=build_postgres_config(),
|
||||||
mlflow_config=build_mlflow_config(),
|
plugin_store=plugin_store,
|
||||||
|
minio_config=build_minio_config(),
|
||||||
opc_config=build_opc_config(),
|
opc_config=build_opc_config(),
|
||||||
|
pi_web_api_config=build_api_config(),
|
||||||
logger=logger,
|
logger=logger,
|
||||||
notification_handler=notification_handler
|
notification_handler=notification_handler,
|
||||||
|
metrics_controller=metrics_controller,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.custom_info('Initializing OPC...', metadata)
|
logger.custom_info('Initializing OPC...', metadata)
|
||||||
await activities.init_opc()
|
activities.init_opc()
|
||||||
|
|
||||||
logger.custom_info(
|
logger.custom_info(f'Starting SDK Metrics Server on port {SDK_METRICS_PORT}...', metadata)
|
||||||
f'Starting SDK Metrics Server on port {SDK_METRICS_PORT}...', metadata)
|
|
||||||
|
|
||||||
new_runtime = Runtime(
|
new_runtime = Runtime(
|
||||||
telemetry=TelemetryConfig(
|
telemetry=TelemetryConfig(
|
||||||
metrics=PrometheusConfig(
|
metrics=PrometheusConfig(bind_address=f'0.0.0.0:{SDK_METRICS_PORT}')
|
||||||
bind_address=f"0.0.0.0:{SDK_METRICS_PORT}")
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -128,34 +186,55 @@ async def main():
|
|||||||
temporal_client = await client.Client.connect(
|
temporal_client = await client.Client.connect(
|
||||||
target_host=host,
|
target_host=host,
|
||||||
namespace=os.getenv('TEMPORAL_NAMESPACE', 'laborious'),
|
namespace=os.getenv('TEMPORAL_NAMESPACE', 'laborious'),
|
||||||
runtime=new_runtime
|
runtime=new_runtime,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.custom_info('Starting Workers...', metadata)
|
logger.custom_info('Starting Workers...', metadata)
|
||||||
|
|
||||||
workers = [
|
workers = [
|
||||||
Worker(
|
prepare_worker(
|
||||||
temporal_client,
|
temporal_client=temporal_client,
|
||||||
task_queue='minimal_retrain-queue',
|
main_workflow=MinimalRetrain,
|
||||||
workflows=[MinimalRetrain],
|
other_workflows=[],
|
||||||
activities=[
|
activities=[
|
||||||
activities.load_custom_query,
|
activities.load_query_with_minio_offload,
|
||||||
activities.retrain_model,
|
activities.retrain_model,
|
||||||
activities.update_production_model,
|
activities.update_production_model,
|
||||||
activities.export_data_to_postgres
|
activities.format_retrain_report,
|
||||||
|
activities.export_data_to_postgres,
|
||||||
],
|
],
|
||||||
max_concurrent_workflow_tasks=50,
|
logger=logger,
|
||||||
max_concurrent_activities=50,
|
runtime=runtime,
|
||||||
max_concurrent_local_activities=50,
|
|
||||||
max_cached_workflows=200,
|
|
||||||
workflow_task_poller_behavior=PollerBehaviorAutoscaling(),
|
|
||||||
activity_task_poller_behavior=PollerBehaviorAutoscaling()
|
|
||||||
),
|
),
|
||||||
Worker(
|
prepare_worker(
|
||||||
temporal_client,
|
temporal_client=temporal_client,
|
||||||
task_queue='predictions_batch-queue',
|
main_workflow=SimpleMetrics,
|
||||||
workflows=[PredictionsBatch, PredictionProcess,
|
other_workflows=[],
|
||||||
FormatAndExportPrediction],
|
activities=[
|
||||||
|
activities.load_custom_query,
|
||||||
|
activities.calculate_simple_metrics,
|
||||||
|
activities.export_data_to_postgres,
|
||||||
|
],
|
||||||
|
logger=logger,
|
||||||
|
runtime='core',
|
||||||
|
),
|
||||||
|
prepare_worker(
|
||||||
|
temporal_client=temporal_client,
|
||||||
|
main_workflow=Drift,
|
||||||
|
other_workflows=[],
|
||||||
|
activities=[
|
||||||
|
activities.load_custom_query,
|
||||||
|
activities.get_reference_data,
|
||||||
|
activities.calculate_drift,
|
||||||
|
activities.export_data_to_postgres,
|
||||||
|
],
|
||||||
|
logger=logger,
|
||||||
|
runtime='core',
|
||||||
|
),
|
||||||
|
prepare_worker(
|
||||||
|
temporal_client=temporal_client,
|
||||||
|
main_workflow=PredictionsBatch,
|
||||||
|
other_workflows=[PredictionProcess, FormatAndExportPrediction],
|
||||||
activities=[
|
activities=[
|
||||||
# MLFlow
|
# MLFlow
|
||||||
activities.request_predict,
|
activities.request_predict,
|
||||||
@@ -164,24 +243,24 @@ async def main():
|
|||||||
activities.input_gate,
|
activities.input_gate,
|
||||||
activities.mlflow_response_gate,
|
activities.mlflow_response_gate,
|
||||||
activities.mlflow_content_gate,
|
activities.mlflow_content_gate,
|
||||||
|
activities.format_transformed_data,
|
||||||
activities.format_prediction,
|
activities.format_prediction,
|
||||||
activities.format_default_prediction,
|
activities.format_default_prediction,
|
||||||
activities.get_last_timestamp,
|
|
||||||
# OPC
|
# OPC
|
||||||
activities.write_opc_data,
|
activities.write_opc_data,
|
||||||
# Postgres
|
# Postgres / MinIO offload
|
||||||
activities.load_custom_query,
|
activities.load_query_with_minio_offload,
|
||||||
|
activities.cleanup_minio_objects_expired,
|
||||||
activities.repeat_last_prediction,
|
activities.repeat_last_prediction,
|
||||||
activities.export_data_to_postgres,
|
activities.export_data_to_postgres,
|
||||||
activities.write_metrics
|
activities.export_payload_to_postgres,
|
||||||
|
activities.write_metrics,
|
||||||
|
# Pi Web API
|
||||||
|
activities.write_pi_web_api_data,
|
||||||
],
|
],
|
||||||
max_concurrent_workflow_tasks=50,
|
logger=logger,
|
||||||
max_concurrent_activities=50,
|
runtime=runtime,
|
||||||
max_concurrent_local_activities=50,
|
),
|
||||||
max_cached_workflows=200,
|
|
||||||
workflow_task_poller_behavior=PollerBehaviorAutoscaling(),
|
|
||||||
activity_task_poller_behavior=PollerBehaviorAutoscaling()
|
|
||||||
)
|
|
||||||
]
|
]
|
||||||
|
|
||||||
handlers = []
|
handlers = []
|
||||||
@@ -190,20 +269,17 @@ async def main():
|
|||||||
|
|
||||||
logger.custom_info('Workers started successfully', metadata)
|
logger.custom_info('Workers started successfully', metadata)
|
||||||
|
|
||||||
|
exit_code = 0
|
||||||
try:
|
try:
|
||||||
# This will run the workers and wait for them to complete.
|
|
||||||
# If an exception occurs in any of the worker handlers, it will be propagated here.
|
|
||||||
await asyncio.gather(*handlers)
|
await asyncio.gather(*handlers)
|
||||||
except BaseException as e: # NOSONAR
|
except BaseException as e: # NOSONAR
|
||||||
logger.custom_error(f"An unhandled exception occurred: {e}", metadata)
|
logger.custom_error(f'An unhandled exception occurred: {e}', metadata)
|
||||||
|
exit_code = 1
|
||||||
finally:
|
finally:
|
||||||
if notification_handler:
|
notification_handler.shutdown()
|
||||||
notification_handler.shutdown()
|
activities.shutdown()
|
||||||
if activities:
|
|
||||||
await activities.shutdown()
|
|
||||||
# Exit with a non-zero status code to indicate failure to Kubernetes
|
|
||||||
metrics.APP_UP.labels(pod_id=POD_ID).set(0) # Mark app as DOWN
|
metrics.APP_UP.labels(pod_id=POD_ID).set(0) # Mark app as DOWN
|
||||||
sys.exit(1)
|
sys.exit(exit_code)
|
||||||
|
|
||||||
|
|
||||||
def start_prometheus_server():
|
def start_prometheus_server():
|
||||||
@@ -224,12 +300,12 @@ def start_prometheus_server():
|
|||||||
SystemExit: If the metrics server fails to start
|
SystemExit: If the metrics server fails to start
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
port = int(os.getenv("HTTP_METRICS_PORT", 9090))
|
port = int(os.getenv('HTTP_METRICS_PORT', 9090))
|
||||||
start_http_server(port)
|
start_http_server(port)
|
||||||
print(f"Prometheus server started on port {port}.")
|
print(f'Prometheus server started on port {port}.')
|
||||||
metrics.APP_UP.labels(pod_id=POD_ID).set(1) # Mark app as UP
|
metrics.APP_UP.labels(pod_id=POD_ID).set(1) # Mark app as UP
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Failed to start Prometheus server: {e}")
|
print(f'Failed to start Prometheus server: {e}')
|
||||||
os._exit(1)
|
os._exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
107
laborious/workflows/drift.py
Normal file
107
laborious/workflows/drift.py
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
from temporalio import workflow
|
||||||
|
|
||||||
|
with workflow.unsafe.imports_passed_through():
|
||||||
|
from datetime import timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sientia_do.temporal.constants import DATETIME_FORMAT_WITH_TZ
|
||||||
|
from sientia_do.temporal.policies import retry_policy
|
||||||
|
|
||||||
|
from laborious.activities.activities import Activities
|
||||||
|
|
||||||
|
|
||||||
|
@workflow.defn(name='drift')
|
||||||
|
class Drift:
|
||||||
|
@workflow.run
|
||||||
|
async def run(self, input_data: dict[str, Any]):
|
||||||
|
"""
|
||||||
|
Execute the drift workflow.
|
||||||
|
|
||||||
|
This method orchestrates the complete drift process by:
|
||||||
|
1. Loading data using the provided custom SQL query
|
||||||
|
2. Preparing prediction configuration and filters
|
||||||
|
3. Delegating to the PredictionProcess workflow for ML operations
|
||||||
|
"""
|
||||||
|
|
||||||
|
metadata = {
|
||||||
|
'metadata': {
|
||||||
|
'schedule_name': input_data['schedule_name'],
|
||||||
|
'model_name': input_data['model_name'],
|
||||||
|
'model_id': input_data['model_id'],
|
||||||
|
'workflow_name': 'drift',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
print(f'Input data: {input_data}', metadata)
|
||||||
|
|
||||||
|
model_config = input_data['model_config']
|
||||||
|
target_name = model_config['target']
|
||||||
|
|
||||||
|
gathering_query = f"""
|
||||||
|
SELECT *
|
||||||
|
FROM "{input_data['schema']}"."{input_data['source_table_name']}"
|
||||||
|
WHERE
|
||||||
|
model_id = '{input_data['model_id']}' AND
|
||||||
|
timestamp > NOW() - INTERVAL '{input_data['interval']} minutes'
|
||||||
|
ORDER BY timestamp ASC
|
||||||
|
""" # nosec B608 - values come from internal Temporal workflow config, not user input
|
||||||
|
|
||||||
|
target_data_handler = workflow.start_activity_method(
|
||||||
|
Activities.load_custom_query,
|
||||||
|
{
|
||||||
|
**metadata,
|
||||||
|
'query': gathering_query,
|
||||||
|
'datetime_columns': ['timestamp', 'created_at'],
|
||||||
|
'orient': 'records',
|
||||||
|
},
|
||||||
|
retry_policy=retry_policy,
|
||||||
|
start_to_close_timeout=timedelta(seconds=300),
|
||||||
|
)
|
||||||
|
|
||||||
|
reference_data_handler = workflow.start_activity_method(
|
||||||
|
Activities.get_reference_data,
|
||||||
|
{**metadata, 'model_name': input_data['model_name']},
|
||||||
|
retry_policy=retry_policy,
|
||||||
|
start_to_close_timeout=timedelta(seconds=300),
|
||||||
|
)
|
||||||
|
|
||||||
|
target_data = await target_data_handler
|
||||||
|
reference_data = await reference_data_handler
|
||||||
|
|
||||||
|
if not target_data:
|
||||||
|
return
|
||||||
|
|
||||||
|
drift_data = await workflow.execute_local_activity_method(
|
||||||
|
Activities.calculate_drift,
|
||||||
|
{
|
||||||
|
**metadata,
|
||||||
|
'target_data': target_data,
|
||||||
|
'reference_data': reference_data,
|
||||||
|
'model_name': input_data['model_name'],
|
||||||
|
'model_id': input_data['model_id'],
|
||||||
|
'target_name': target_name,
|
||||||
|
'drift_metrics': input_data.get(
|
||||||
|
'drift_metrics', ['kolmogorov_smirnov', 'jensen_shannon', 'wasserstein']
|
||||||
|
),
|
||||||
|
'chunk_period': input_data.get('chunk_period', 'min'),
|
||||||
|
},
|
||||||
|
retry_policy=retry_policy,
|
||||||
|
start_to_close_timeout=timedelta(seconds=300),
|
||||||
|
)
|
||||||
|
|
||||||
|
if drift_data:
|
||||||
|
await workflow.execute_activity_method(
|
||||||
|
Activities.export_data_to_postgres,
|
||||||
|
{
|
||||||
|
**metadata,
|
||||||
|
'data': drift_data,
|
||||||
|
'schema': input_data['schema'],
|
||||||
|
'table_name': input_data['target_table_name'],
|
||||||
|
'timestamp_conversion': {
|
||||||
|
'column': 'timestamp',
|
||||||
|
'format': DATETIME_FORMAT_WITH_TZ,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
retry_policy=retry_policy,
|
||||||
|
start_to_close_timeout=timedelta(seconds=300),
|
||||||
|
)
|
||||||
@@ -1,14 +1,17 @@
|
|||||||
from temporalio import workflow
|
from temporalio import workflow
|
||||||
|
|
||||||
with workflow.unsafe.imports_passed_through():
|
with workflow.unsafe.imports_passed_through():
|
||||||
from laborious.activities.activities import Activities
|
|
||||||
from typing import Any
|
|
||||||
from sientia_do.temporal.policies import retry_policy
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sientia_do.temporal.policies import retry_policy
|
||||||
|
|
||||||
|
from laborious.activities.activities import Activities
|
||||||
|
from laborious.utils.models.minio_dataframe_payload import MinioDataFramePayload
|
||||||
|
|
||||||
|
|
||||||
@workflow.defn(name="minimal_retrain")
|
@workflow.defn(name='minimal_retrain')
|
||||||
class MinimalRetrain():
|
class MinimalRetrain:
|
||||||
"""
|
"""
|
||||||
Automated model retraining workflow for the Laborious system.
|
Automated model retraining workflow for the Laborious system.
|
||||||
|
|
||||||
@@ -63,44 +66,62 @@ class MinimalRetrain():
|
|||||||
'schedule_name': input_data['schedule_name'],
|
'schedule_name': input_data['schedule_name'],
|
||||||
'model_name': input_data['model_name'],
|
'model_name': input_data['model_name'],
|
||||||
'model_id': input_data['model_id'],
|
'model_id': input_data['model_id'],
|
||||||
'workflow_name': 'minimal_retrain'
|
'workflow_name': 'minimal_retrain',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
model_name = input_data['model_name']
|
model_name = input_data['model_name']
|
||||||
|
model_config = input_data.get('model_config', {})
|
||||||
|
|
||||||
data = await workflow.execute_local_activity_method(
|
storage_result = await workflow.execute_activity_method(
|
||||||
Activities.load_custom_query,
|
Activities.load_query_with_minio_offload,
|
||||||
{
|
{
|
||||||
**metadata,
|
**metadata,
|
||||||
'query': input_data['query'],
|
'query': input_data['query'],
|
||||||
'datetime_columns': input_data.get('datetime_columns', [])
|
'datetime_columns': input_data.get('datetime_columns', []),
|
||||||
|
'model_name': model_name,
|
||||||
},
|
},
|
||||||
retry_policy=retry_policy,
|
retry_policy=retry_policy,
|
||||||
start_to_close_timeout=timedelta(seconds=60)
|
start_to_close_timeout=timedelta(seconds=600),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
storage_payload = MinioDataFramePayload.from_dict(storage_result)
|
||||||
|
if not storage_payload.has_data():
|
||||||
|
raise ValueError('No data returned from query')
|
||||||
|
|
||||||
experiment_response = await workflow.execute_activity_method(
|
experiment_response = await workflow.execute_activity_method(
|
||||||
Activities.retrain_model,
|
Activities.retrain_model,
|
||||||
{
|
{
|
||||||
**metadata,
|
**metadata,
|
||||||
'data': data,
|
'data': storage_result,
|
||||||
'model_name': model_name
|
'model_name': model_name,
|
||||||
|
'model_config': model_config,
|
||||||
},
|
},
|
||||||
retry_policy=retry_policy,
|
retry_policy=retry_policy,
|
||||||
start_to_close_timeout=timedelta(seconds=60)
|
start_to_close_timeout=timedelta(hours=1),
|
||||||
)
|
)
|
||||||
|
|
||||||
report = await workflow.execute_activity_method(
|
if experiment_response['success']:
|
||||||
Activities.update_production_model,
|
update_report = await workflow.execute_activity_method(
|
||||||
|
Activities.update_production_model,
|
||||||
|
{**metadata, 'model_name': model_name, **experiment_response},
|
||||||
|
retry_policy=retry_policy,
|
||||||
|
start_to_close_timeout=timedelta(seconds=60),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
update_report = {}
|
||||||
|
|
||||||
|
report = await workflow.execute_local_activity_method(
|
||||||
|
Activities.format_retrain_report,
|
||||||
{
|
{
|
||||||
**metadata,
|
**metadata,
|
||||||
|
'experiment_response': experiment_response,
|
||||||
'model_name': model_name,
|
'model_name': model_name,
|
||||||
'model_id': input_data['model_id'],
|
'model_id': input_data['model_id'],
|
||||||
**experiment_response
|
'update_report': update_report,
|
||||||
},
|
},
|
||||||
retry_policy=retry_policy,
|
retry_policy=retry_policy,
|
||||||
start_to_close_timeout=timedelta(seconds=60)
|
start_to_close_timeout=timedelta(seconds=60),
|
||||||
)
|
)
|
||||||
|
|
||||||
await workflow.execute_activity_method(
|
await workflow.execute_activity_method(
|
||||||
@@ -109,8 +130,8 @@ class MinimalRetrain():
|
|||||||
**metadata,
|
**metadata,
|
||||||
'data': report,
|
'data': report,
|
||||||
'schema': input_data['schema'],
|
'schema': input_data['schema'],
|
||||||
'table_name': input_data['table_name']
|
'table_name': input_data['table_name'],
|
||||||
},
|
},
|
||||||
retry_policy=retry_policy,
|
retry_policy=retry_policy,
|
||||||
start_to_close_timeout=timedelta(seconds=60)
|
start_to_close_timeout=timedelta(seconds=600),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
from temporalio import workflow
|
from temporalio import workflow
|
||||||
|
|
||||||
with workflow.unsafe.imports_passed_through():
|
with workflow.unsafe.imports_passed_through():
|
||||||
from laborious.activities.activities import Activities
|
|
||||||
from typing import Any
|
|
||||||
from sientia_do.temporal.policies import retry_policy
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sientia_do.temporal.policies import retry_policy
|
||||||
|
|
||||||
|
from laborious.activities.activities import Activities
|
||||||
|
|
||||||
|
|
||||||
@workflow.defn(name="predictions_batch")
|
@workflow.defn(name='predictions_batch')
|
||||||
class PredictionsBatch():
|
class PredictionsBatch:
|
||||||
"""
|
"""
|
||||||
Main batch prediction workflow for the Laborious system.
|
Main batch prediction workflow for the Laborious system.
|
||||||
|
|
||||||
@@ -59,7 +61,10 @@ class PredictionsBatch():
|
|||||||
- model_retention (int, optional): Model retention period in minutes
|
- model_retention (int, optional): Model retention period in minutes
|
||||||
- path_priority (list[str]): Decision path priority configuration
|
- path_priority (list[str]): Decision path priority configuration
|
||||||
- opc_output_config (dict, optional): 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
|
||||||
- datetime_columns (list[str], optional): Columns to treat as datetime
|
- 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:
|
Returns:
|
||||||
None: The workflow completes successfully when the child workflow finishes
|
None: The workflow completes successfully when the child workflow finishes
|
||||||
@@ -74,20 +79,21 @@ class PredictionsBatch():
|
|||||||
'schedule_name': input_data['schedule_name'],
|
'schedule_name': input_data['schedule_name'],
|
||||||
'model_name': input_data['model_name'],
|
'model_name': input_data['model_name'],
|
||||||
'model_id': input_data['model_id'],
|
'model_id': input_data['model_id'],
|
||||||
'workflow_name': 'predictions_batch'
|
'workflow_name': 'predictions_batch',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Load data using custom query
|
# Load data using custom query with optional MinIO offload for large frames
|
||||||
data = await workflow.execute_local_activity_method(
|
data = await workflow.execute_activity_method(
|
||||||
Activities.load_custom_query,
|
Activities.load_query_with_minio_offload,
|
||||||
{
|
{
|
||||||
**metadata,
|
**metadata,
|
||||||
'query': input_data['query'],
|
'query': input_data['query'],
|
||||||
'datetime_columns': input_data.get('datetime_columns', [])
|
'datetime_columns': input_data.get('datetime_columns', []),
|
||||||
|
'model_name': input_data['model_name'],
|
||||||
},
|
},
|
||||||
retry_policy=retry_policy,
|
retry_policy=retry_policy,
|
||||||
start_to_close_timeout=timedelta(seconds=300)
|
start_to_close_timeout=timedelta(seconds=300),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Prepare input for prediction_process workflow
|
# Prepare input for prediction_process workflow
|
||||||
@@ -96,30 +102,26 @@ class PredictionsBatch():
|
|||||||
'data': data,
|
'data': data,
|
||||||
'schema': input_data['schema'],
|
'schema': input_data['schema'],
|
||||||
'table_name': input_data['table_name'],
|
'table_name': input_data['table_name'],
|
||||||
|
'transform_table_name': input_data['transform_table_name'],
|
||||||
'model_id': input_data['model_id'],
|
'model_id': input_data['model_id'],
|
||||||
'model_name': input_data['model_name'],
|
'model_name': input_data['model_name'],
|
||||||
'input_filters': input_data.get('input_filters', {
|
'input_filters': input_data.get(
|
||||||
'EMPTY_DATA': {
|
'input_filters', {'EMPTY_DATA': {'POLICY': 'STOP', 'CONFIG': {}}}
|
||||||
'POLICY': 'STOP'
|
),
|
||||||
}
|
'mlflow_transform_filters': input_data.get(
|
||||||
}),
|
'mlflow_transform_filters', {'API_ERROR': {'POLICY': 'STOP', 'CONFIG': {}}}
|
||||||
'mlflow_transform_filters': input_data.get('mlflow_transform_filters', {
|
),
|
||||||
'API_ERROR': {
|
'mlflow_predict_filters': input_data.get(
|
||||||
'POLICY': 'STOP'
|
'mlflow_predict_filters', {'API_ERROR': {'POLICY': 'STOP', 'CONFIG': {}}}
|
||||||
}
|
),
|
||||||
}),
|
|
||||||
'mlflow_predict_filters': input_data.get('mlflow_predict_filters', {
|
|
||||||
'API_ERROR': {
|
|
||||||
'POLICY': 'STOP'
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
'model_config': input_data.get('model_config', {}),
|
'model_config': input_data.get('model_config', {}),
|
||||||
'path_priority': input_data.get('path_priority', ['STOP', 'CONTINUE', 'REPEAT']),
|
'path_priority': input_data.get('path_priority', ['STOP', 'CONTINUE', 'REPEAT']),
|
||||||
'opc_output_config': input_data.get('opc_output_config', {}),
|
'opc_output_config': input_data.get('opc_output_config', {}),
|
||||||
'prediction_store_policy': input_data.get(
|
'on_conflict': input_data.get('on_conflict', 'error'),
|
||||||
'prediction_store_policy', 'lts:1')
|
'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
|
# Execute prediction process workflow
|
||||||
await workflow.execute_child_workflow(
|
await workflow.execute_child_workflow('subworkflow.prediction_process', prediction_input)
|
||||||
'prediction_process', prediction_input)
|
|
||||||
|
|||||||
95
laborious/workflows/simple_metrics.py
Normal file
95
laborious/workflows/simple_metrics.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
from temporalio import workflow
|
||||||
|
|
||||||
|
with workflow.unsafe.imports_passed_through():
|
||||||
|
from datetime import timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sientia_do.temporal.constants import DATETIME_FORMAT_WITH_TZ
|
||||||
|
from sientia_do.temporal.policies import retry_policy
|
||||||
|
|
||||||
|
from laborious.activities.activities import Activities
|
||||||
|
|
||||||
|
|
||||||
|
@workflow.defn(name='simple_metrics')
|
||||||
|
class SimpleMetrics:
|
||||||
|
@workflow.run
|
||||||
|
async def run(self, input_data: dict[str, Any]):
|
||||||
|
"""
|
||||||
|
Execute the simple metrics workflow.
|
||||||
|
"""
|
||||||
|
metadata = {
|
||||||
|
'metadata': {
|
||||||
|
'model_id': input_data['model_id'],
|
||||||
|
'model_name': input_data['model_name'],
|
||||||
|
'workflow_name': 'simple_metrics',
|
||||||
|
'schedule_name': input_data['schedule_name'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
model_id = input_data['model_id']
|
||||||
|
interval_minutes = input_data['interval_minutes']
|
||||||
|
|
||||||
|
model_config = input_data['model_config']
|
||||||
|
target_name = model_config['target']
|
||||||
|
|
||||||
|
query = f"""
|
||||||
|
select p."timestamp", p.prediction, ld.value as "target"
|
||||||
|
from "{input_data['schema']}"."{input_data['predictions_table_name']}" p
|
||||||
|
inner join "{input_data['schema']}"."{input_data['data_table_name']}" ld
|
||||||
|
on p."timestamp" = ld."timestamp"
|
||||||
|
where
|
||||||
|
p.model_id = '{model_id}' and
|
||||||
|
p.prediction is not null and
|
||||||
|
ld.variable = '{target_name}' and
|
||||||
|
ld.value is not null and
|
||||||
|
p."timestamp" >= NOW() - INTERVAL '{interval_minutes} minutes'
|
||||||
|
order by
|
||||||
|
p."timestamp" desc;
|
||||||
|
""" # nosec B608 - values come from internal Temporal workflow config, not user input
|
||||||
|
|
||||||
|
target_data = await workflow.execute_activity_method(
|
||||||
|
Activities.load_custom_query,
|
||||||
|
{
|
||||||
|
**metadata,
|
||||||
|
'query': query,
|
||||||
|
'datetime_columns': ['timestamp'],
|
||||||
|
'orient': 'records',
|
||||||
|
},
|
||||||
|
retry_policy=retry_policy,
|
||||||
|
start_to_close_timeout=timedelta(seconds=300),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not target_data:
|
||||||
|
return
|
||||||
|
|
||||||
|
simple_metrics = await workflow.execute_local_activity_method(
|
||||||
|
Activities.calculate_simple_metrics,
|
||||||
|
{
|
||||||
|
**metadata,
|
||||||
|
'model_id': model_id,
|
||||||
|
'target_data': target_data,
|
||||||
|
'metrics': input_data.get('metrics', ['rmse', 'mse', 'mae', 'r2']),
|
||||||
|
'interval_minutes': interval_minutes,
|
||||||
|
},
|
||||||
|
retry_policy=retry_policy,
|
||||||
|
start_to_close_timeout=timedelta(seconds=300),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not simple_metrics:
|
||||||
|
return
|
||||||
|
|
||||||
|
await workflow.execute_activity_method(
|
||||||
|
Activities.export_data_to_postgres,
|
||||||
|
{
|
||||||
|
**metadata,
|
||||||
|
'data': simple_metrics,
|
||||||
|
'schema': input_data['schema'],
|
||||||
|
'table_name': input_data['target_table_name'],
|
||||||
|
'timestamp_conversion': {
|
||||||
|
'column': 'timestamp',
|
||||||
|
'format': DATETIME_FORMAT_WITH_TZ,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
retry_policy=retry_policy,
|
||||||
|
start_to_close_timeout=timedelta(seconds=300),
|
||||||
|
)
|
||||||
@@ -1,15 +1,17 @@
|
|||||||
from temporalio import workflow
|
from temporalio import workflow
|
||||||
|
|
||||||
with workflow.unsafe.imports_passed_through():
|
with workflow.unsafe.imports_passed_through():
|
||||||
from laborious.activities.activities import Activities
|
|
||||||
from typing import Any
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from sientia_do.temporal.policies import retry_policy
|
from typing import Any
|
||||||
|
|
||||||
from sientia_do.temporal.constants import DATETIME_FORMAT_WITH_TZ
|
from sientia_do.temporal.constants import DATETIME_FORMAT_WITH_TZ
|
||||||
|
from sientia_do.temporal.policies import retry_policy
|
||||||
|
|
||||||
|
from laborious.activities.activities import Activities
|
||||||
|
|
||||||
|
|
||||||
@workflow.defn(name="format_and_export_prediction")
|
@workflow.defn(name='subworkflow.format_and_export_prediction')
|
||||||
class FormatAndExportPrediction():
|
class FormatAndExportPrediction:
|
||||||
"""
|
"""
|
||||||
Data formatting and export workflow for prediction results.
|
Data formatting and export workflow for prediction results.
|
||||||
|
|
||||||
@@ -24,6 +26,7 @@ class FormatAndExportPrediction():
|
|||||||
|
|
||||||
Export Destinations:
|
Export Destinations:
|
||||||
- PostgreSQL Database: Persistent storage with timestamp conversion
|
- 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
|
- OPC Servers: Real-time industrial system integration
|
||||||
- Prometheus Metrics: Performance monitoring and operational visibility
|
- Prometheus Metrics: Performance monitoring and operational visibility
|
||||||
"""
|
"""
|
||||||
@@ -36,9 +39,10 @@ class FormatAndExportPrediction():
|
|||||||
This method orchestrates the complete data export process by:
|
This method orchestrates the complete data export process by:
|
||||||
1. Determining the appropriate formatting strategy based on path_flag
|
1. Determining the appropriate formatting strategy based on path_flag
|
||||||
2. Formatting prediction data according to quality and requirements
|
2. Formatting prediction data according to quality and requirements
|
||||||
3. Exporting data to OPC servers for real-time industrial access
|
3. Exporting data to PI Web API for real-time industrial access (if configured)
|
||||||
4. Persisting data to PostgreSQL database with comprehensive metadata
|
4. Exporting data to OPC servers for real-time industrial access (if configured)
|
||||||
5. Recording performance metrics for operational monitoring
|
5. Persisting data to PostgreSQL database with comprehensive metadata
|
||||||
|
6. Recording performance metrics for operational monitoring
|
||||||
|
|
||||||
The method implements flexible formatting strategies:
|
The method implements flexible formatting strategies:
|
||||||
- Normal predictions: Full data formatting with confidence scores
|
- Normal predictions: Full data formatting with confidence scores
|
||||||
@@ -48,29 +52,50 @@ class FormatAndExportPrediction():
|
|||||||
Args:
|
Args:
|
||||||
input_data: Complete configuration for the export workflow
|
input_data: Complete configuration for the export workflow
|
||||||
Required keys:
|
Required keys:
|
||||||
|
- metadata (dict): Workflow execution metadata
|
||||||
- path_flag (str | None): Decision path flag for formatting strategy
|
- path_flag (str | None): Decision path flag for formatting strategy
|
||||||
|
- None: Normal prediction path with full formatting
|
||||||
|
- Any other value: Default prediction path for error conditions
|
||||||
- data (dict[str, Any]): Prediction data to format and export
|
- data (dict[str, Any]): Prediction data to format and export
|
||||||
- prediction_confidence (float): Confidence score for the prediction
|
- prediction_confidence (float): Confidence score for the prediction
|
||||||
- timestamp (str): ISO-formatted timestamp for the prediction
|
- timestamp (str): ISO-formatted timestamp for the prediction
|
||||||
- model_id (int): Unique identifier for the ML model
|
- model_id (int): Unique identifier for the ML model
|
||||||
- model_name (str): Name of the ML model
|
- model_name (str): Name of the ML model
|
||||||
- model_retention (str): Model retention policy configuration
|
|
||||||
- comment (str): Operational comment or error description
|
|
||||||
- schema (str): Database schema for data storage
|
- schema (str): Database schema for data storage
|
||||||
- table_name (str): Target table for data persistence
|
- table_name (str): Target table for data persistence
|
||||||
|
Optional keys:
|
||||||
- opc_output_config (dict[str, Any]): OPC server export configuration
|
- opc_output_config (dict[str, Any]): OPC server export configuration
|
||||||
- prediction_store_policy (str, optional): Data retention policy
|
- pi_web_api_output_config (dict[str, Any]): PI Web API export configuration
|
||||||
|
Contains endpoint, prediction_tags, and confidence_tags mappings
|
||||||
|
- transformed_data (dict[str, Any]): Transformed data to export separately
|
||||||
|
Only processed when path_flag is None
|
||||||
|
- transform_table_name (str): Target table for transformed data export
|
||||||
|
Required if transformed_data is provided
|
||||||
|
- prediction_store_policy (str): Data retention policy (e.g., 'lts:1', 'erl:2')
|
||||||
|
Required when path_flag is None
|
||||||
|
- comment (str): Operational comment or error description
|
||||||
|
Required when path_flag is not None
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bool: True if the workflow completes successfully, False otherwise
|
None: The workflow completes successfully when all export operations finish
|
||||||
|
|
||||||
|
Note:
|
||||||
|
When transformed_data is provided and path_flag is None, the workflow will:
|
||||||
|
1. Format the transformed data using format_transformed_data
|
||||||
|
2. Export it to a separate table (transform_table_name) asynchronously
|
||||||
|
3. Wait for both prediction and transformed data exports to complete
|
||||||
"""
|
"""
|
||||||
metadata = input_data['metadata']
|
metadata = input_data['metadata']
|
||||||
path_flag = input_data['path_flag']
|
path_flag = input_data['path_flag']
|
||||||
data = input_data['data']
|
data = input_data['data']
|
||||||
|
transformed_data = input_data.get('transformed_data', None)
|
||||||
prediction_confidence = input_data['prediction_confidence']
|
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:
|
if path_flag is None:
|
||||||
# proceed with formatting and exporting
|
# Normal prediction path: format prediction data with full metadata
|
||||||
prediction = await workflow.execute_local_activity_method(
|
prediction = await workflow.execute_local_activity_method(
|
||||||
Activities.format_prediction,
|
Activities.format_prediction,
|
||||||
{
|
{
|
||||||
@@ -79,14 +104,48 @@ class FormatAndExportPrediction():
|
|||||||
'timestamp': input_data['timestamp'],
|
'timestamp': input_data['timestamp'],
|
||||||
'model_id': input_data['model_id'],
|
'model_id': input_data['model_id'],
|
||||||
'prediction_confidence': prediction_confidence,
|
'prediction_confidence': prediction_confidence,
|
||||||
'prediction_store_policy': input_data['prediction_store_policy']
|
'prediction_store_policy': input_data['prediction_store_policy'],
|
||||||
|
'model_name': input_data['model_name'],
|
||||||
},
|
},
|
||||||
retry_policy=retry_policy,
|
retry_policy=retry_policy,
|
||||||
start_to_close_timeout=timedelta(seconds=60)
|
start_to_close_timeout=timedelta(seconds=60),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Optionally format and export transformed data to separate table
|
||||||
|
if transformed_data is not None:
|
||||||
|
transformed = await workflow.execute_local_activity_method(
|
||||||
|
Activities.format_transformed_data,
|
||||||
|
{
|
||||||
|
**metadata,
|
||||||
|
'data': transformed_data,
|
||||||
|
'model_id': input_data['model_id'],
|
||||||
|
'model_name': input_data['model_name'],
|
||||||
|
},
|
||||||
|
retry_policy=retry_policy,
|
||||||
|
start_to_close_timeout=timedelta(seconds=60),
|
||||||
|
)
|
||||||
|
|
||||||
|
write_transformed_handler = workflow.start_activity_method(
|
||||||
|
Activities.export_payload_to_postgres,
|
||||||
|
{
|
||||||
|
**metadata,
|
||||||
|
'schema': input_data['schema'],
|
||||||
|
'table_name': input_data['transform_table_name'],
|
||||||
|
'data': transformed,
|
||||||
|
'timestamp_conversion': {
|
||||||
|
'column': 'timestamp',
|
||||||
|
'format': DATETIME_FORMAT_WITH_TZ,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
retry_policy=retry_policy,
|
||||||
|
start_to_close_timeout=timedelta(seconds=60),
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
write_transformed_handler = None
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# create default prediction
|
# Error path: create default prediction with error indicators
|
||||||
prediction = await workflow.execute_local_activity_method(
|
prediction = await workflow.execute_local_activity_method(
|
||||||
Activities.format_default_prediction,
|
Activities.format_default_prediction,
|
||||||
{
|
{
|
||||||
@@ -94,23 +153,41 @@ class FormatAndExportPrediction():
|
|||||||
'timestamp': input_data['timestamp'],
|
'timestamp': input_data['timestamp'],
|
||||||
'model_id': input_data['model_id'],
|
'model_id': input_data['model_id'],
|
||||||
'prediction_confidence': prediction_confidence,
|
'prediction_confidence': prediction_confidence,
|
||||||
'comment': input_data['comment']
|
'comment': input_data['comment'],
|
||||||
},
|
},
|
||||||
retry_policy=retry_policy,
|
retry_policy=retry_policy,
|
||||||
start_to_close_timeout=timedelta(seconds=60)
|
start_to_close_timeout=timedelta(seconds=60),
|
||||||
|
)
|
||||||
|
|
||||||
|
write_transformed_handler = None
|
||||||
|
|
||||||
|
opc_metrics: dict[str, dict[str, float | None]] = {}
|
||||||
|
|
||||||
|
# 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
|
# write to opc
|
||||||
prediction = await workflow.execute_activity_method(
|
if opc_output_config:
|
||||||
Activities.write_opc_data,
|
prediction, opc_metrics = await workflow.execute_activity_method(
|
||||||
{
|
Activities.write_opc_data,
|
||||||
**metadata,
|
{
|
||||||
'opc_output_config': input_data['opc_output_config'],
|
'opc_output_config': opc_output_config,
|
||||||
'data': prediction
|
'data': prediction,
|
||||||
},
|
**metadata,
|
||||||
retry_policy=retry_policy,
|
},
|
||||||
start_to_close_timeout=timedelta(seconds=60)
|
retry_policy=retry_policy,
|
||||||
)
|
start_to_close_timeout=timedelta(seconds=60),
|
||||||
|
)
|
||||||
|
|
||||||
# write to postgres
|
# write to postgres
|
||||||
await workflow.execute_activity_method(
|
await workflow.execute_activity_method(
|
||||||
@@ -120,21 +197,24 @@ class FormatAndExportPrediction():
|
|||||||
'schema': input_data['schema'],
|
'schema': input_data['schema'],
|
||||||
'table_name': input_data['table_name'],
|
'table_name': input_data['table_name'],
|
||||||
'data': prediction,
|
'data': prediction,
|
||||||
'timestamp_conversion': {
|
'timestamp_conversion': {'column': 'timestamp', 'format': DATETIME_FORMAT_WITH_TZ},
|
||||||
'column': 'timestamp',
|
'on_conflict': input_data.get('on_conflict', 'error'),
|
||||||
'format': DATETIME_FORMAT_WITH_TZ
|
'unique_columns': ['model_id', 'timestamp'],
|
||||||
}
|
|
||||||
},
|
},
|
||||||
retry_policy=retry_policy,
|
retry_policy=retry_policy,
|
||||||
start_to_close_timeout=timedelta(seconds=60)
|
start_to_close_timeout=timedelta(seconds=180),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if write_transformed_handler is not None:
|
||||||
|
await write_transformed_handler
|
||||||
|
|
||||||
await workflow.execute_activity_method(
|
await workflow.execute_activity_method(
|
||||||
Activities.write_metrics,
|
Activities.write_metrics,
|
||||||
{
|
{
|
||||||
**metadata,
|
**metadata,
|
||||||
'prediction': prediction
|
'prediction': prediction,
|
||||||
|
'opc_metrics': opc_metrics,
|
||||||
},
|
},
|
||||||
retry_policy=retry_policy,
|
retry_policy=retry_policy,
|
||||||
start_to_close_timeout=timedelta(seconds=60)
|
start_to_close_timeout=timedelta(seconds=60),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
from temporalio import workflow
|
from temporalio import workflow
|
||||||
|
|
||||||
with workflow.unsafe.imports_passed_through():
|
with workflow.unsafe.imports_passed_through():
|
||||||
from laborious.activities.activities import Activities
|
|
||||||
from typing import Any
|
|
||||||
from sientia_do.temporal.policies import retry_policy
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sientia_do.temporal.policies import retry_policy
|
||||||
|
|
||||||
|
from laborious.activities.activities import Activities
|
||||||
|
|
||||||
|
|
||||||
@workflow.defn(name="prediction_process")
|
@workflow.defn(name='subworkflow.prediction_process')
|
||||||
class PredictionProcess():
|
class PredictionProcess:
|
||||||
"""
|
"""
|
||||||
Core prediction processing workflow for the Laborious system.
|
Core prediction processing workflow for the Laborious system.
|
||||||
|
|
||||||
@@ -64,7 +66,10 @@ class PredictionProcess():
|
|||||||
- mlflow_predict_filters (dict): MLFlow prediction filters
|
- mlflow_predict_filters (dict): MLFlow prediction filters
|
||||||
- model_retention (int): Model retention period in minutes
|
- model_retention (int): Model retention period in minutes
|
||||||
- path_priority (list[str]): Decision path priority configuration
|
- 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:
|
Returns:
|
||||||
None: The workflow completes successfully when export workflow finishes
|
None: The workflow completes successfully when export workflow finishes
|
||||||
@@ -80,24 +85,51 @@ class PredictionProcess():
|
|||||||
model_id = input_data['model_id']
|
model_id = input_data['model_id']
|
||||||
model_name = input_data['model_name']
|
model_name = input_data['model_name']
|
||||||
model_config = input_data.get('model_config', {})
|
model_config = input_data.get('model_config', {})
|
||||||
|
save_transform = input_data.get('save_transform', True)
|
||||||
|
|
||||||
# Get last timestamp for incremental processing
|
try:
|
||||||
last_timestamp = await workflow.execute_local_activity_method(
|
await self._run_prediction_pipeline(
|
||||||
Activities.get_last_timestamp,
|
input_data,
|
||||||
{
|
metadata,
|
||||||
**metadata,
|
data,
|
||||||
'data': data
|
model_id,
|
||||||
},
|
model_name,
|
||||||
retry_policy=retry_policy,
|
model_config,
|
||||||
start_to_close_timeout=timedelta(minutes=1),
|
save_transform,
|
||||||
)
|
)
|
||||||
|
await workflow.execute_activity_method(
|
||||||
|
Activities.cleanup_minio_objects_expired,
|
||||||
|
{**metadata, 'data': data},
|
||||||
|
retry_policy=retry_policy,
|
||||||
|
start_to_close_timeout=timedelta(minutes=5),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
await workflow.execute_activity_method(
|
||||||
|
Activities.cleanup_minio_objects_expired,
|
||||||
|
{**metadata, 'data': data},
|
||||||
|
retry_policy=retry_policy,
|
||||||
|
start_to_close_timeout=timedelta(minutes=5),
|
||||||
|
)
|
||||||
|
raise e
|
||||||
|
|
||||||
|
async def _run_prediction_pipeline(
|
||||||
|
self,
|
||||||
|
input_data: dict[str, Any],
|
||||||
|
metadata: dict[str, Any],
|
||||||
|
data: dict[str, Any],
|
||||||
|
model_id: str,
|
||||||
|
model_name: str,
|
||||||
|
model_config: dict[str, Any],
|
||||||
|
save_transform: bool,
|
||||||
|
) -> None:
|
||||||
|
last_timestamp = data['last_timestamp']
|
||||||
|
|
||||||
# Apply input data quality gates
|
# Apply input data quality gates
|
||||||
gate_input = {
|
gate_input = {
|
||||||
**metadata,
|
**metadata,
|
||||||
'filters': input_data['input_filters'],
|
'filters': input_data['input_filters'],
|
||||||
'data': data,
|
'data': data,
|
||||||
'path_priority': input_data['path_priority']
|
'path_priority': input_data['path_priority'],
|
||||||
}
|
}
|
||||||
|
|
||||||
path_flag, confidence, comment = await workflow.execute_local_activity_method(
|
path_flag, confidence, comment = await workflow.execute_local_activity_method(
|
||||||
@@ -114,14 +146,9 @@ class PredictionProcess():
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Request MLFlow model transformation
|
# Request MLFlow model transformation
|
||||||
response_data = await workflow.execute_local_activity_method(
|
transformed_data = await workflow.execute_activity_method(
|
||||||
Activities.request_transform,
|
Activities.request_transform,
|
||||||
{
|
{**metadata, 'data': data, 'model_name': model_name, 'model_config': model_config},
|
||||||
**metadata,
|
|
||||||
'data': data,
|
|
||||||
'model_name': model_name,
|
|
||||||
'model_config': model_config
|
|
||||||
},
|
|
||||||
retry_policy=retry_policy,
|
retry_policy=retry_policy,
|
||||||
start_to_close_timeout=timedelta(minutes=5),
|
start_to_close_timeout=timedelta(minutes=5),
|
||||||
)
|
)
|
||||||
@@ -132,9 +159,9 @@ class PredictionProcess():
|
|||||||
{
|
{
|
||||||
**metadata,
|
**metadata,
|
||||||
'filters': input_data['mlflow_transform_filters'],
|
'filters': input_data['mlflow_transform_filters'],
|
||||||
'data': response_data,
|
'data': transformed_data,
|
||||||
'type': 'transform',
|
'type': 'transform',
|
||||||
'path_priority': input_data['path_priority']
|
'path_priority': input_data['path_priority'],
|
||||||
},
|
},
|
||||||
retry_policy=retry_policy,
|
retry_policy=retry_policy,
|
||||||
start_to_close_timeout=timedelta(minutes=1),
|
start_to_close_timeout=timedelta(minutes=1),
|
||||||
@@ -146,8 +173,6 @@ class PredictionProcess():
|
|||||||
):
|
):
|
||||||
return
|
return
|
||||||
|
|
||||||
transformed_data = response_data['content']
|
|
||||||
|
|
||||||
path_flag, confidence, comment = await workflow.execute_local_activity_method(
|
path_flag, confidence, comment = await workflow.execute_local_activity_method(
|
||||||
Activities.mlflow_content_gate,
|
Activities.mlflow_content_gate,
|
||||||
{
|
{
|
||||||
@@ -155,7 +180,7 @@ class PredictionProcess():
|
|||||||
'filters': input_data['mlflow_transform_filters'],
|
'filters': input_data['mlflow_transform_filters'],
|
||||||
'data': transformed_data,
|
'data': transformed_data,
|
||||||
'type': 'transform',
|
'type': 'transform',
|
||||||
'path_priority': input_data['path_priority']
|
'path_priority': input_data['path_priority'],
|
||||||
},
|
},
|
||||||
retry_policy=retry_policy,
|
retry_policy=retry_policy,
|
||||||
start_to_close_timeout=timedelta(minutes=1),
|
start_to_close_timeout=timedelta(minutes=1),
|
||||||
@@ -166,13 +191,13 @@ class PredictionProcess():
|
|||||||
):
|
):
|
||||||
return
|
return
|
||||||
|
|
||||||
response_data = await workflow.execute_local_activity_method(
|
predicted_data = await workflow.execute_activity_method(
|
||||||
Activities.request_predict,
|
Activities.request_predict,
|
||||||
{
|
{
|
||||||
**metadata,
|
**metadata,
|
||||||
'data': transformed_data,
|
'data': transformed_data,
|
||||||
'model_name': model_name,
|
'model_name': model_name,
|
||||||
'model_config': model_config
|
'model_config': model_config,
|
||||||
},
|
},
|
||||||
retry_policy=retry_policy,
|
retry_policy=retry_policy,
|
||||||
start_to_close_timeout=timedelta(minutes=5),
|
start_to_close_timeout=timedelta(minutes=5),
|
||||||
@@ -184,9 +209,9 @@ class PredictionProcess():
|
|||||||
{
|
{
|
||||||
**metadata,
|
**metadata,
|
||||||
'filters': input_data['mlflow_predict_filters'],
|
'filters': input_data['mlflow_predict_filters'],
|
||||||
'data': response_data,
|
'data': predicted_data,
|
||||||
'type': 'predict',
|
'type': 'predict',
|
||||||
'path_priority': input_data['path_priority']
|
'path_priority': input_data['path_priority'],
|
||||||
},
|
},
|
||||||
retry_policy=retry_policy,
|
retry_policy=retry_policy,
|
||||||
start_to_close_timeout=timedelta(minutes=1),
|
start_to_close_timeout=timedelta(minutes=1),
|
||||||
@@ -200,26 +225,37 @@ class PredictionProcess():
|
|||||||
|
|
||||||
# Delegate to export workflow for data persistence
|
# Delegate to export workflow for data persistence
|
||||||
await workflow.execute_child_workflow(
|
await workflow.execute_child_workflow(
|
||||||
'format_and_export_prediction',
|
'subworkflow.format_and_export_prediction',
|
||||||
{
|
{
|
||||||
'metadata': metadata,
|
'metadata': metadata,
|
||||||
|
'on_conflict': input_data.get('on_conflict', 'error'),
|
||||||
'path_flag': path_flag,
|
'path_flag': path_flag,
|
||||||
'data': response_data['content'],
|
'data': predicted_data,
|
||||||
|
'transformed_data': transformed_data if save_transform else None,
|
||||||
'prediction_confidence': confidence,
|
'prediction_confidence': confidence,
|
||||||
'timestamp': last_timestamp,
|
'timestamp': last_timestamp,
|
||||||
'model_id': model_id,
|
'model_id': model_id,
|
||||||
'model_name': model_name,
|
'model_name': model_name,
|
||||||
'model_config': model_config,
|
'model_config': model_config,
|
||||||
'opc_output_config': input_data['opc_output_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'],
|
'schema': input_data['schema'],
|
||||||
'table_name': input_data['table_name'],
|
'table_name': input_data['table_name'],
|
||||||
|
'transform_table_name': input_data['transform_table_name'],
|
||||||
'comment': comment,
|
'comment': comment,
|
||||||
'prediction_store_policy': input_data['prediction_store_policy']
|
'prediction_store_policy': input_data['prediction_store_policy'],
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
async def path_flag_handler(self, data: dict, path_flag: str, input_data: dict,
|
async def path_flag_handler(
|
||||||
confidence: int, last_timestamp: str, comment: str) -> bool:
|
self,
|
||||||
|
data: dict[str, Any],
|
||||||
|
path_flag: str | None,
|
||||||
|
input_data: dict,
|
||||||
|
confidence: int,
|
||||||
|
last_timestamp: str,
|
||||||
|
comment: str,
|
||||||
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
Handle path decisions based on filter results and confidence levels.
|
Handle path decisions based on filter results and confidence levels.
|
||||||
|
|
||||||
@@ -230,7 +266,17 @@ class PredictionProcess():
|
|||||||
Args:
|
Args:
|
||||||
data: Input data for processing
|
data: Input data for processing
|
||||||
path_flag: Path decision from filter (STOP, CONTINUE, REPEAT)
|
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
|
confidence: Confidence level from filter validation
|
||||||
last_timestamp: Last processed timestamp
|
last_timestamp: Last processed timestamp
|
||||||
comment: Additional information about the filter result
|
comment: Additional information about the filter result
|
||||||
@@ -240,13 +286,14 @@ class PredictionProcess():
|
|||||||
|
|
||||||
Path Handling:
|
Path Handling:
|
||||||
- STOP: Terminates workflow execution
|
- STOP: Terminates workflow execution
|
||||||
- CONTINUE: Proceeds with normal processing
|
- CONTINUE: Delegates to FormatAndExportPrediction workflow with current data
|
||||||
- REPEAT: Repeats last prediction if available
|
- REPEAT: Repeats last prediction if available
|
||||||
"""
|
"""
|
||||||
metadata = input_data['metadata']
|
metadata = input_data['metadata']
|
||||||
|
|
||||||
schema = input_data['schema']
|
schema = input_data['schema']
|
||||||
table_name = input_data['table_name']
|
table_name = input_data['table_name']
|
||||||
|
transform_table_name = input_data['transform_table_name']
|
||||||
model_id = input_data['model_id']
|
model_id = input_data['model_id']
|
||||||
model_name = input_data['model_name']
|
model_name = input_data['model_name']
|
||||||
model_config = input_data.get('model_config', {})
|
model_config = input_data.get('model_config', {})
|
||||||
@@ -265,7 +312,7 @@ class PredictionProcess():
|
|||||||
'schema': schema,
|
'schema': schema,
|
||||||
'table_name': table_name,
|
'table_name': table_name,
|
||||||
'model': model_id,
|
'model': model_id,
|
||||||
'last_timestamp': last_timestamp
|
'last_timestamp': last_timestamp,
|
||||||
},
|
},
|
||||||
retry_policy=retry_policy,
|
retry_policy=retry_policy,
|
||||||
start_to_close_timeout=timedelta(minutes=1),
|
start_to_close_timeout=timedelta(minutes=1),
|
||||||
@@ -274,7 +321,7 @@ class PredictionProcess():
|
|||||||
elif path_flag == 'CONTINUE':
|
elif path_flag == 'CONTINUE':
|
||||||
# call write workflow
|
# call write workflow
|
||||||
await workflow.execute_child_workflow(
|
await workflow.execute_child_workflow(
|
||||||
'format_and_export_prediction',
|
'subworkflow.format_and_export_prediction',
|
||||||
{
|
{
|
||||||
'metadata': metadata,
|
'metadata': metadata,
|
||||||
'path_flag': path_flag,
|
'path_flag': path_flag,
|
||||||
@@ -286,10 +333,13 @@ class PredictionProcess():
|
|||||||
'model_config': model_config,
|
'model_config': model_config,
|
||||||
'schema': schema,
|
'schema': schema,
|
||||||
'table_name': table_name,
|
'table_name': table_name,
|
||||||
|
'transform_table_name': transform_table_name,
|
||||||
'comment': comment,
|
'comment': comment,
|
||||||
'opc_output_config': input_data['opc_output_config'],
|
'opc_output_config': input_data['opc_output_config'],
|
||||||
'prediction_store_policy': input_data['prediction_store_policy']
|
'pi_web_api_output_config': input_data['pi_web_api_output_config'],
|
||||||
}
|
'prediction_store_policy': input_data['prediction_store_policy'],
|
||||||
|
'on_conflict': input_data.get('on_conflict', 'error'),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user