from temporalio import activity, workflow with workflow.unsafe.imports_passed_through(): import traceback from collections.abc import Hashable from typing import Any from pandas import DataFrame from sientia_do.notifications.handlers import CoreNotificationHandler as NotificationHandler from sientia_do.notifications.models import NotificationLevel from sientia_do.observability.logger import Logger from sientia_do.observability.metrics_controller import MetricsController from sientia_do.observability.sientia_monitoring import SientiaMonitoring from laborious.utils.repository.opc_repository import OpcRepository OPC_WRITTING_ERROR_CONFIDENCE = 12 OPC_SESSION_BAD_CONFIDENCE = 14 OPC_SESSION_BAD_COMMENT_PREFIX = 'OPC UA session/channel error:' OPC_WRITTING_ERROR_MESSAGE = 'Some data could not be written to OPC servers' OPC_RECONNECT_IN_PROGRESS_COMMENT = 'OPC UA reconnect in progress' OPC_COMMENT_SEPARATOR = ' | ' def _opc_session_bad_comment(opc_status: str | None) -> str: status = opc_status or 'Unknown' return f'{OPC_SESSION_BAD_COMMENT_PREFIX} {status}' def _apply_opc_write_error( error_info: dict[str, Any] | None, session_bad_seen: bool, session_bad_status: str | None, reconnect_in_progress_seen: bool, ) -> tuple[bool, str | None, bool]: """ Update session/reconnect flags from an OPC write error payload. Args: error_info: Repository error details, or None when the write succeeded. session_bad_seen: Whether a session_bad error was seen so far. session_bad_status: Last known OPC status for session errors. reconnect_in_progress_seen: Whether reconnect_in_progress was seen so far. Return: Updated (session_bad_seen, session_bad_status, reconnect_in_progress_seen). """ if not error_info: return session_bad_seen, session_bad_status, reconnect_in_progress_seen kind = error_info.get('opc_error_kind') if kind == 'session_bad': return True, error_info.get('opc_status', session_bad_status), reconnect_in_progress_seen if kind == 'reconnect_in_progress': return session_bad_seen, session_bad_status, True return session_bad_seen, session_bad_status, reconnect_in_progress_seen class OPC(SientiaMonitoring): """ OPC server integration activities for real-time data export. This class provides comprehensive OPC UA client functionality for connecting to multiple OPC servers and writing prediction data in real-time. It implements secure communication with certificate-based authentication and automatic reconnection capabilities. The class supports multiple OPC servers with individual configurations and provides robust error handling and monitoring for production environments. Attributes: opc_servers (dict): Configuration for multiple OPC servers opc_repository (dict): Active OPC repository connections 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.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.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, reconnection_interval=server.get('reconnection_interval', 60), server_uri=server['server_uri'], cert_path=server['cert_path'], private_key_path=server['private_key_path'], server_cert_path=server['server_cert_path'], ) is_connected, error_data = self.opc_repository[opc_id].connect() if not is_connected: self.send_notification( metadata={ 'model_id': '-', 'model_name': '-', 'workflow_name': '-', 'schedule_name': 'INITIALIZATION', }, notification_id=error_data['notification_id'], message=error_data['message'], block=error_data['block'], level=error_data.get('level', NotificationLevel.ERROR), attachment_content=error_data.get('attachment_content', None), ) else: self.info( f'OPC server {opc_id}:{server["server_name"]} connected successfully.', None, ) def write_data( self, server_id: str, tag: str, data: Any, data_type: str, tag_type: str, metadata: dict[str, Any], ) -> tuple[float | None, dict[str, Any] | None]: """ Write data to a specific OPC server tag with comprehensive error handling. Return: tuple[float | None, dict[str, Any] | None]: Response time on success, or (None, error info_data) on repository failure. """ try: is_success, info_data = self.opc_repository[server_id].write_data( tag, data, data_type, 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, info_data return info_data['response_time'], None 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. 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 _write_tags_from_config( self, server_id: str, tags_config: dict[str, dict[str, Any]], data: DataFrame, data_column: str, tag_type: str, log_label: str, metadata: dict[str, Any], ) -> tuple[dict[str, float | None], bool, str | None, bool]: """ Write a group of OPC tags and collect response times and error flags. Args: server_id: Target OPC server identifier. tags_config: Tag name to configuration mapping. data: DataFrame with prediction/confidence columns. data_column: Column name whose first row value is written. tag_type: Tag category passed to write_data ('prediction' or 'confidence'). log_label: Human-readable label for success logs. metadata: Context metadata for logging and notifications. Return: (response_times, session_bad_seen, session_bad_status, reconnect_in_progress_seen) """ response_times: dict[str, float | None] = {} session_bad_seen = False session_bad_status: str | None = None reconnect_in_progress_seen = False for tag, tag_config in tags_config.items(): response_time, error_info = 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]: """ Write prediction and confidence values for one OPC server configuration. Return: tuple: success flag, per-tag response times, session_bad flags. """ response_times: dict[str, float | None] = {} session_bad_seen = False session_bad_status: str | None = None reconnect_in_progress_seen = False tag_groups = ( ('prediction_tags', 'prediction', 'prediction', 'Prediction data'), ('confidence_tags', 'prediction_confidence', 'confidence', 'Confidence data'), ) for config_key, data_column, tag_type, log_label in tag_groups: if config_key not in config: continue ( group_times, group_session_bad, group_status, group_reconnect, ) = self._write_tags_from_config( server_id=server_id, tags_config=config[config_key], data=data, data_column=data_column, tag_type=tag_type, log_label=log_label, metadata=metadata, ) response_times.update(group_times) if group_session_bad: session_bad_seen = True session_bad_status = group_status or session_bad_status if group_reconnect: reconnect_in_progress_seen = True success = None not in response_times.values() return ( success, response_times, session_bad_seen, session_bad_status, reconnect_in_progress_seen, ) @activity.defn(name='write_opc_data') 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. 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 per server/tag. """ metadata = input_data['metadata'] self.info('Writing data to OPC servers...', metadata) data = DataFrame(input_data['data']) opc_output_config = input_data['opc_output_config'] self.info(f'Data to write: {data.size} rows', metadata) success = True session_bad_seen = False session_bad_status: str | None = None reconnect_in_progress_seen = False opc_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, 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 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( 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, 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], *, session_bad: bool = False, opc_status: str | None = None, reconnect_in_progress: bool = False, ) -> dict[Hashable, Any]: """ Apply fallback confidence/comment values when OPC writes are not fully successful. Return: dict[Hashable, Any]: Serialized dataframe dict with updated confidence/comments on failure. """ if not success: comment_parts: list[str] = [] confidence = OPC_WRITTING_ERROR_CONFIDENCE if session_bad: comment_parts.append(_opc_session_bad_comment(opc_status)) confidence = OPC_SESSION_BAD_CONFIDENCE if reconnect_in_progress: comment_parts.append(OPC_RECONNECT_IN_PROGRESS_COMMENT) confidence = OPC_SESSION_BAD_CONFIDENCE if not comment_parts: comment_parts.append(OPC_WRITTING_ERROR_MESSAGE) comments = OPC_COMMENT_SEPARATOR.join(comment_parts) data['prediction_confidence'] = confidence data['comments'] = comments self.debug( f'OPC write issues, confidence={confidence}, comments={comments}', metadata, ) else: self.debug('Data written to OPC servers successfully.', metadata) return data.to_dict() def close(self) -> None: """ Disconnect all tracked OPC repositories and clear in-memory references. """ for opc in self.opc_repository.values(): opc.disconnect() self.opc_repository.clear()