SIENTIAPDE-1646
SIENTIAPDE-1646 Enhance OPC integration and E2E testing framework - Updated .gitignore to exclude '.cursor/*' for better file management. - Added new marker in pyproject.toml for E2E tests using OPC. - Introduced async OPC server fixtures and improved connection handling in conftest.py. - Enhanced error handling in OPC activities and added metrics for session management in metrics.py. - Updated E2E tests to cover new OPC scenarios, including session errors and reconnect handling. - Refactored helper functions to improve prediction assertion logic in helpers.py. - Documented new OPC scenarios in scenarios.md for clarity on expected outcomes.
This commit is contained in:
@@ -15,6 +15,45 @@ with workflow.unsafe.imports_passed_through():
|
||||
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):
|
||||
@@ -67,13 +106,14 @@ class OPC(SientiaMonitoring):
|
||||
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'],
|
||||
)
|
||||
ok, err = self.opc_repository[opc_id].connect()
|
||||
if not ok:
|
||||
is_connected, error_data = self.opc_repository[opc_id].connect()
|
||||
if not is_connected:
|
||||
self.send_notification(
|
||||
metadata={
|
||||
'model_id': '-',
|
||||
@@ -81,11 +121,11 @@ class OPC(SientiaMonitoring):
|
||||
'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()),
|
||||
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(
|
||||
@@ -101,19 +141,13 @@ class OPC(SientiaMonitoring):
|
||||
data_type: str,
|
||||
tag_type: str,
|
||||
metadata: dict[str, Any],
|
||||
) -> float | None:
|
||||
) -> tuple[float | None, dict[str, Any] | 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.
|
||||
Return:
|
||||
tuple[float | None, dict[str, Any] | None]: Response time on success, or
|
||||
(None, error info_data) on repository failure.
|
||||
"""
|
||||
|
||||
try:
|
||||
@@ -129,8 +163,8 @@ class OPC(SientiaMonitoring):
|
||||
level=info_data.get('level', NotificationLevel.ERROR),
|
||||
attachment_content=info_data.get('attachment_content', None),
|
||||
)
|
||||
return None
|
||||
return info_data['response_time']
|
||||
return None, info_data
|
||||
return info_data['response_time'], None
|
||||
except Exception as e:
|
||||
trace = traceback.format_exc()
|
||||
self.send_notification(
|
||||
@@ -147,10 +181,6 @@ class OPC(SientiaMonitoring):
|
||||
"""
|
||||
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.
|
||||
@@ -172,72 +202,116 @@ class OPC(SientiaMonitoring):
|
||||
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]]:
|
||||
) -> tuple[bool, dict[str, float | None], bool, str | None, bool]:
|
||||
"""
|
||||
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).
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
return (
|
||||
success,
|
||||
response_times,
|
||||
session_bad_seen,
|
||||
session_bad_status,
|
||||
reconnect_in_progress_seen,
|
||||
)
|
||||
|
||||
@activity.defn(name='write_opc_data')
|
||||
def write_opc_data(
|
||||
@@ -246,19 +320,13 @@ class OPC(SientiaMonitoring):
|
||||
"""
|
||||
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}}``.
|
||||
prediction payload dict and nested metrics per server/tag.
|
||||
"""
|
||||
metadata = input_data['metadata']
|
||||
self.info('Writing data to OPC servers...', metadata)
|
||||
@@ -267,20 +335,32 @@ class OPC(SientiaMonitoring):
|
||||
self.info(f'Data to write: {data.size} rows', metadata)
|
||||
|
||||
success = True
|
||||
session_bad_seen = False
|
||||
session_bad_status: str | None = None
|
||||
reconnect_in_progress_seen = False
|
||||
|
||||
metrics: dict[str, dict[str, float | None]] = {}
|
||||
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 = self.manage_output_tags(
|
||||
server_id, config, data, metadata
|
||||
)
|
||||
metrics[server_id] = local_response_times
|
||||
(
|
||||
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 {})
|
||||
@@ -289,34 +369,55 @@ class OPC(SientiaMonitoring):
|
||||
metadata,
|
||||
)
|
||||
|
||||
return self.process_confidence(data, success, metadata), metrics
|
||||
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]
|
||||
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.
|
||||
|
||||
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.
|
||||
dict[Hashable, Any]: Serialized dataframe dict with updated confidence/comments 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
|
||||
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'{message}, setting confidence to {OPC_WRITTING_ERROR_CONFIDENCE}.',
|
||||
f'OPC write issues, confidence={confidence}, comments={comments}',
|
||||
metadata,
|
||||
)
|
||||
|
||||
else:
|
||||
self.debug('Data written to OPC servers successfully.', metadata)
|
||||
|
||||
@@ -325,9 +426,6 @@ class OPC(SientiaMonitoring):
|
||||
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():
|
||||
|
||||
Reference in New Issue
Block a user