from temporalio import activity, workflow with workflow.unsafe.imports_passed_through(): import traceback from collections.abc import Hashable from typing import Any from pandas import DataFrame from sientia_do.notifications.handlers import CoreNotificationHandler as NotificationHandler from sientia_do.notifications.models import NotificationLevel from sientia_do.observability.logger import Logger from sientia_do.observability.metrics_controller import MetricsController from sientia_do.observability.sientia_monitoring import SientiaMonitoring from laborious.utils.repository.opc_repository import OpcRepository OPC_WRITTING_ERROR_CONFIDENCE = 12 class OPC(SientiaMonitoring): """ OPC server integration activities for real-time data export. This class provides comprehensive OPC UA client functionality for connecting to multiple OPC servers and writing prediction data in real-time. It implements secure communication with certificate-based authentication and automatic reconnection capabilities. The class supports multiple OPC servers with individual configurations and provides robust error handling and monitoring for production environments. Attributes: opc_servers (dict): Configuration for multiple OPC servers opc_repository (dict): Active OPC repository connections logger (Logger): Logging and observability instance notification_handler (NotificationHandler): Notification management instance """ def __init__( self, opc_servers: dict[str, dict[str, Any]], logger: Logger, notification_handler: NotificationHandler, metrics_controller: MetricsController, ): self.logger = logger self.notification_handler = notification_handler self.opc_servers = opc_servers SientiaMonitoring.__init__(self, logger, notification_handler, metrics_controller) self.opc_repository: dict[str, OpcRepository] = {} def init_opc(self) -> None: """ Initialize OPC server connections and establish communication channels. This method iterates through all configured OPC servers and attempts to establish secure connections using certificate-based authentication. Each server connection is managed independently, and connection failures are reported through the notification system. """ self.logger.info('Initializing OPC servers...') for opc_id, server in self.opc_servers.items(): self.opc_repository[opc_id] = OpcRepository( opc_id=opc_id, url=server['url'], server_name=server['server_name'], logger=self.logger, notification_handler=self.notification_handler, metrics_controller=self.metrics_controller, server_uri=server['server_uri'], cert_path=server['cert_path'], private_key_path=server['private_key_path'], server_cert_path=server['server_cert_path'], ) ok, err = self.opc_repository[opc_id].connect() if not ok: self.send_notification( metadata={ 'model_id': '-', 'model_name': '-', 'workflow_name': '-', 'schedule_name': 'INITIALIZATION', }, notification_id=f'OPC_CONNECTION_ERROR_{server.get("id", opc_id)}', message=err.get('message', 'Failed to connect to OPC server'), block='opc_repository', level=NotificationLevel.ERROR, attachment_content=err.get('attachment_content', traceback.format_exc()), ) else: self.logger.info( f'OPC server {opc_id}:{server["server_name"]} connected successfully.' ) def write_data( self, server_id: str, tag: str, data: Any, data_type: str, tag_type: str, metadata: dict[str, Any], ) -> float | None: """ Write data to a specific OPC server tag with comprehensive error handling. 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: - float | None: Response time in seconds if successful, None otherwise. """ try: is_success, info_data = self.opc_repository[server_id].write_data( tag, data, data_type, self.logger, metadata ) if not is_success: self.send_notification( metadata=metadata, notification_id=info_data['notification_id'], message=info_data['message'], block=info_data['block'], level=info_data.get('level', NotificationLevel.ERROR), attachment_content=info_data.get('attachment_content', None), ) return None return info_data['response_time'] except Exception as e: trace = traceback.format_exc() self.send_notification( metadata=metadata, notification_id=f'WRITE_OPC_{tag_type.upper()}_ERROR', message=f'Error writing data to OPC server: {e}', block='write_opc_data', level=NotificationLevel.ERROR, attachment_content=trace, ) raise def validate_server(self, server_id: str, metadata: dict[str, Any]) -> bool: """ Validate that an OPC repository exists for the requested server identifier. This guard prevents write attempts against unknown/uninitialized servers. When the server is missing, it emits an error notification with the list of available repositories to help operators diagnose configuration drift. Args: - server_id (str): OPC server identifier from workflow output config. - metadata (dict[str, Any]): Workflow metadata used for logs/alerts. Return: bool: ``True`` when the server repository is available; ``False`` otherwise. """ if self.opc_repository.get(server_id) is None: message = f'OPC server {server_id} not found to perform write operation.' self.send_notification( metadata=metadata, notification_id='OPC_SERVER_NOT_FOUND', message=message, block='write_opc_data', level=NotificationLevel.ERROR, attachment_content=f'OPC servers: {list(self.opc_repository.keys())}', ) return False return True def manage_output_tags( self, server_id: str, config: dict[str, Any], data: DataFrame, metadata: dict[str, Any], ) -> tuple[bool, dict[str, float | None]]: """ Write prediction and confidence values for one OPC server configuration. The method iterates through optional ``prediction_tags`` and ``confidence_tags``, performs synchronous writes for each tag, collects per-tag response times, and returns an aggregate success flag (all tags successful) with a metrics-friendly response map. Args: - server_id (str): Target OPC server id. - config (dict[str, Any]): Server output configuration containing optional ``prediction_tags`` and ``confidence_tags`` sections. - data (DataFrame): Prediction dataframe used as source values. - metadata (dict[str, Any]): Workflow metadata for logging/notifications. Return: tuple[bool, dict[str, float | None]]: Global success flag and response-time map per tag (``None`` for failed writes). """ response_times: dict[str, float | None] = {} if 'prediction_tags' in config: for tag, tag_config in config['prediction_tags'].items(): response_time = self.write_data( server_id=server_id, tag=tag, data=data.head(1)['prediction'].values[0], data_type=tag_config['data_type'], tag_type='prediction', metadata=metadata, ) if response_time is not None: self.info( f'Prediction data written to OPC server {server_id} for tag {tag}.', metadata, ) response_times[tag] = response_time if 'confidence_tags' in config: for tag, tag_config in config['confidence_tags'].items(): response_time = self.write_data( server_id=server_id, tag=tag, data=data.head(1)['prediction_confidence'].values[0], data_type=tag_config['data_type'], tag_type='confidence', metadata=metadata, ) if response_time is not None: self.info( f'Confidence data written to OPC server {server_id} for tag {tag}.', metadata, ) response_times[tag] = response_time success = None not in response_times.values() return success, response_times @activity.defn(name='write_opc_data') def write_opc_data( self, input_data: dict[str, Any] ) -> tuple[dict[Hashable, Any], dict[str, dict[str, float | None]]]: """ Execute OPC writes across all configured servers and collect per-tag metrics. For each server in ``opc_output_config``, this activity validates server availability, writes enabled prediction/confidence tags, accumulates response-time metrics, and then normalizes confidence/comments in the returned prediction payload when at least one write fails. Args: - input_data (dict[str, Any]): Payload containing workflow metadata, data to write, and ``opc_output_config`` server/tag definitions. Return: tuple[dict[Hashable, Any], dict[str, dict[str, float | None]]]: Updated prediction payload dict and nested metrics ``{server_id: {tag_name: response_time_or_none}}``. """ metadata = input_data['metadata'] self.info('Writing data to OPC servers...', metadata) data = DataFrame(input_data['data']) opc_output_config = input_data['opc_output_config'] self.info(f'Data to write: {data.size} rows', metadata) success = True metrics: dict[str, dict[str, float | None]] = {} for server_id, config in opc_output_config.items(): if not self.validate_server(server_id, metadata): success = False continue local_success, local_response_times = self.manage_output_tags( server_id, config, data, metadata ) metrics[server_id] = local_response_times local_count = len(local_response_times) success = success and local_success n_pred = len(config.get('prediction_tags') or {}) n_conf = len(config.get('confidence_tags') or {}) self.info( 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), metrics def process_confidence( self, data: DataFrame, success: bool, metadata: dict[str, Any] ) -> dict[Hashable, Any]: """ Apply fallback confidence/comment values when OPC writes are not fully successful. Args: - data (DataFrame): Prediction dataframe to be returned to downstream steps. - success (bool): Aggregate write status across all attempted OPC tags. - metadata (dict[str, Any]): Workflow metadata used for debug logs. Return: dict[Hashable, Any]: Serialized dataframe dict with original values on success, or downgraded confidence/comment fields on failure. """ message = 'Some data could not be written to OPC servers' if not success: data['prediction_confidence'] = OPC_WRITTING_ERROR_CONFIDENCE data['comments'] = message self.debug( f'{message}, setting confidence to {OPC_WRITTING_ERROR_CONFIDENCE}.', metadata, ) else: self.debug('Data written to OPC servers successfully.', metadata) return data.to_dict() def close(self) -> None: """ Disconnect all tracked OPC repositories and clear in-memory references. This method should be called during worker shutdown to ensure every synchronous OPC session is explicitly closed before process exit. """ for opc in self.opc_repository.values(): opc.disconnect() self.opc_repository.clear()