SIENTIAPDE-1974
SIENTIAPDE-1974 Write OPC tags through the tag database and drop the PI Web API path
- predictions_batch write_tags are now [{tag_id}] only: resolve_write_tags builds
the opc_output_config from connector_tags + connectors (server = connector_id,
Write / Prediction Confidence -> prediction_tags / confidence_tags, address and
data_type from the tag, float by default). The legacy server_id/type/addr write
tags are no longer read and are notified as invalid.
- A pipeline's effective updated_at is the latest of its own and of the
connector_tags it references, so renaming, moving or deactivating a tag
redeploys it (removes the rename limitation).
- The connector_tags query loads the referenced tags of every category and
status; the code filters them. aggregate_documents_in_mongodb formats only
datetime timestamp fields.
- Remove the PI Web API tag database path (connectors-pi-web-api patch, PI loads,
resolution, connection and held schedules): PI pipelines behave as on main.
- tag_id is canonical text and the workflow-side id collection never raises;
a malformed pipeline no longer fails the whole OPC slot.
- Replay test against histories recorded on the e2e stack (main and this
version), e2e scenarios for write tags and tag changes, and the runbook
script scripts/sientiapde_1974_backfill_write_tags.js.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -29,13 +29,15 @@ with workflow.unsafe.imports_passed_through():
|
||||
simple_metrics,
|
||||
)
|
||||
from orchestrator.utils.tag_database import (
|
||||
PI_OUTPUT_WORKFLOW_TYPES,
|
||||
WRITE_CATEGORIES,
|
||||
WRITE_WORKFLOW_TYPES,
|
||||
index_connector_tags,
|
||||
index_connectors,
|
||||
index_pi_connectors,
|
||||
resolve_pi_output_tags,
|
||||
resolve_pi_read_tags,
|
||||
index_tag_documents,
|
||||
latest_updated_at,
|
||||
resolve_tag_names,
|
||||
resolve_write_tags,
|
||||
tag_documents_updated_at,
|
||||
)
|
||||
|
||||
topic_separator = '\n ========== \n'
|
||||
@@ -60,34 +62,30 @@ SLOT_ISSUES = (
|
||||
(
|
||||
'invalid',
|
||||
'ORCHESTRATOR_INVALID_READ_TAG_DURING_SLOT_CONFIGURATION',
|
||||
'Read tags {} have no integer frequency or lack server_id, tag_address or tag_name',
|
||||
'Read tags {} have no integer frequency, an active_read without an integer '
|
||||
'cycle_multiple, or lack server_id, tag_address or tag_name; or their pipeline lacks '
|
||||
'schedule_name, model_id or model.name',
|
||||
),
|
||||
)
|
||||
|
||||
# PI Web API tags left out of the schedules, by resolve_pi_read_tags / resolve_pi_output_tags
|
||||
# Write tags left out of the predictions_batch opc_output_config, by resolve_write_tags
|
||||
# issue kind; values are schedule_name/<tag_id or connector_id>
|
||||
PI_SCHEDULE_ISSUES = (
|
||||
WRITE_TAG_ISSUES = (
|
||||
(
|
||||
'tags',
|
||||
'ORCHESTRATOR_PI_TAG_NOT_FOUND_DURING_SCHEDULE_CONFIGURATION',
|
||||
'PI Web API tags {} not found among the active, complete tags of connector_tags',
|
||||
),
|
||||
(
|
||||
'categories',
|
||||
'ORCHESTRATOR_PI_TAG_WRONG_CATEGORY_DURING_SCHEDULE_CONFIGURATION',
|
||||
'PI Web API tags {} have a category the pipeline cannot use (Read for read_tags, '
|
||||
'Write for prediction_tags, Prediction Confidence for confidence_tags)',
|
||||
'ORCHESTRATOR_WRITE_TAG_NOT_FOUND_DURING_SCHEDULE_CONFIGURATION',
|
||||
'Write tags {} not found among the active, complete Write and Prediction Confidence '
|
||||
'tags of connector_tags',
|
||||
),
|
||||
(
|
||||
'connectors',
|
||||
'ORCHESTRATOR_PI_CONNECTOR_NOT_FOUND_DURING_SCHEDULE_CONFIGURATION',
|
||||
'PI Web API connectors {} not found, not active or incomplete in connectors',
|
||||
'ORCHESTRATOR_WRITE_TAG_CONNECTOR_NOT_FOUND_DURING_SCHEDULE_CONFIGURATION',
|
||||
'Connectors {} of write tags not found, not active or incomplete in connectors',
|
||||
),
|
||||
(
|
||||
'mixed',
|
||||
'ORCHESTRATOR_PI_MIXED_CONNECTORS_DURING_SCHEDULE_CONFIGURATION',
|
||||
'Pipelines {} read tags of several PI Web API connectors; their schedules were left '
|
||||
'untouched',
|
||||
'invalid',
|
||||
'ORCHESTRATOR_INVALID_WRITE_TAG_DURING_SCHEDULE_CONFIGURATION',
|
||||
'Write tags {} have no tag_id or a data_type the OPC Gateway does not accept',
|
||||
),
|
||||
)
|
||||
|
||||
@@ -100,46 +98,6 @@ def _merge_issues(
|
||||
issues.setdefault(kind, set()).update(f'{schedule_name}/{value}' for value in values)
|
||||
|
||||
|
||||
def _resolve_pi_pipeline(
|
||||
pipeline: dict[str, Any],
|
||||
pi_tags: dict[str, dict[str, Any]],
|
||||
pi_connectors: dict[str, dict[str, Any]],
|
||||
issues: dict[str, set[str]],
|
||||
) -> dict[str, Any] | None:
|
||||
"""
|
||||
The pipeline with its PI Web API tags resolved through the tag database.
|
||||
|
||||
pi_web_api_scouter read tags get their WebIDs (and the pipeline its connector);
|
||||
predictions_batch output tags get {tag_name: WebID}. Other pipelines come back as
|
||||
they are. What cannot be resolved is added to issues. None means the pipeline must
|
||||
be held: its read tags span several connectors.
|
||||
"""
|
||||
workflow_type = pipeline['workflow_type']
|
||||
schedule_name = pipeline['schedule_name']
|
||||
|
||||
if workflow_type == 'pi_web_api_scouter' and isinstance(pipeline.get('read_tags'), dict):
|
||||
read_tags, connector, read_issues = resolve_pi_read_tags(
|
||||
pipeline['read_tags'], pi_tags, pi_connectors
|
||||
)
|
||||
_merge_issues(issues, read_issues, schedule_name)
|
||||
if read_issues['mixed']:
|
||||
return None
|
||||
pipeline = {**pipeline, 'read_tags': read_tags}
|
||||
if connector is not None:
|
||||
pipeline['pi_web_api_connector'] = connector
|
||||
|
||||
if workflow_type in PI_OUTPUT_WORKFLOW_TYPES and isinstance(
|
||||
pipeline.get('pi_web_api_output_config'), dict
|
||||
):
|
||||
output_config, output_issues = resolve_pi_output_tags(
|
||||
pipeline['pi_web_api_output_config'], pi_tags, pi_connectors
|
||||
)
|
||||
_merge_issues(issues, output_issues, schedule_name)
|
||||
pipeline = {**pipeline, 'pi_web_api_output_config': output_config}
|
||||
|
||||
return pipeline
|
||||
|
||||
|
||||
class ScheduleType(TypedDict):
|
||||
"""
|
||||
Type definition for schedule type configuration entries.
|
||||
@@ -282,18 +240,16 @@ class Formatters(SientiaMonitoring):
|
||||
- input_data (dict[str, Any]): The input data containing
|
||||
the schedules to process.
|
||||
- pipelines (list[dict[str, Any]]): The schedules to process.
|
||||
- connector_tags (list[dict[str, Any]], optional): Active Read tags of
|
||||
the tag database, to name the scouter read tags that carry only a tag_id.
|
||||
- pi_connectors (list[dict[str, Any]], optional): Active pi_web_api
|
||||
connectors of the tag database.
|
||||
- pi_connector_tags (list[dict[str, Any]], optional): Active tags of the
|
||||
tag database referenced by the PI Web API pipelines (all categories).
|
||||
- connectors (list[dict[str, Any]], optional): OPC UA connectors of the
|
||||
tag database, for the write tags.
|
||||
- connector_tags (list[dict[str, Any]], optional): The connector_tags the
|
||||
pipelines reference, of every category and status.
|
||||
|
||||
PI Web API tags given by tag_id (pi_web_api_scouter read tags, predictions_batch
|
||||
pi_web_api_output_config) are resolved through the tag database; what cannot be
|
||||
resolved is left out and notified once per run. A pi_web_api_scouter whose tags
|
||||
span several connectors is held: it gets {'held': True} instead of a config, so
|
||||
create_schedule_config neither creates, updates nor deletes its schedule.
|
||||
Scouter read tags that carry only a tag_id are named from the tag database (the
|
||||
same names as the slot). A predictions_batch gets its opc_output_config from its
|
||||
write_tags ([{tag_id}]) through the tag database; what cannot be resolved is left
|
||||
out and notified once per run. A pipeline's updated_at is the latest of its own and
|
||||
of the tag database documents it references, so changing a tag redeploys it.
|
||||
|
||||
Returns:
|
||||
- dict[str, Any]: The schedule configuration dictionary keyed by namespace
|
||||
@@ -304,10 +260,12 @@ class Formatters(SientiaMonitoring):
|
||||
self.info('Processing schedules...', metadata=metadata)
|
||||
|
||||
pipelines = input_data['pipelines']
|
||||
tags_by_id = index_connector_tags(input_data.get('connector_tags', []))
|
||||
pi_connectors = index_pi_connectors(input_data.get('pi_connectors', []))
|
||||
pi_tags = index_connector_tags(input_data.get('pi_connector_tags', []))
|
||||
pi_issues: dict[str, set[str]] = {}
|
||||
connector_tags = input_data.get('connector_tags', [])
|
||||
read_tags_by_id = index_connector_tags(connector_tags)
|
||||
write_tags_by_id = index_connector_tags(connector_tags, WRITE_CATEGORIES)
|
||||
tag_documents = index_tag_documents(connector_tags)
|
||||
connectors = index_connectors(input_data.get('connectors', []))
|
||||
write_issues: dict[str, set[str]] = {}
|
||||
|
||||
schedule_config: dict[str, dict[str, Any]] = {
|
||||
self.scouter_namespace: {},
|
||||
@@ -330,23 +288,26 @@ class Formatters(SientiaMonitoring):
|
||||
# Same names as the slot's names[] (process_slots): SIENTIAPDE-1974 SC1
|
||||
pipeline = {
|
||||
**pipeline,
|
||||
'read_tags': resolve_tag_names(pipeline['read_tags'], tags_by_id),
|
||||
'read_tags': resolve_tag_names(pipeline['read_tags'], read_tags_by_id),
|
||||
}
|
||||
|
||||
resolved = _resolve_pi_pipeline(pipeline, pi_tags, pi_connectors, pi_issues)
|
||||
if resolved is None:
|
||||
schedule_config[namespace][schedule_name] = {'held': True}
|
||||
continue
|
||||
pipeline = resolved
|
||||
if workflow_type in WRITE_WORKFLOW_TYPES:
|
||||
opc_output_config, issues = resolve_write_tags(
|
||||
pipeline.get('write_tags') or [], write_tags_by_id, connectors
|
||||
)
|
||||
_merge_issues(write_issues, issues, schedule_name)
|
||||
pipeline = {**pipeline, 'opc_output_config': opc_output_config}
|
||||
|
||||
# A pipeline without updated_at is redeployed on every run, as before
|
||||
updated_at = pipeline.get('updated_at') or now().strftime(DATETIME_FORMAT_MS_WITH_TZ)
|
||||
schedule_config[namespace][schedule_name] = {
|
||||
**function(pipeline),
|
||||
'updated_at': pipeline.get(
|
||||
'updated_at', now().strftime(DATETIME_FORMAT_MS_WITH_TZ)
|
||||
'updated_at': latest_updated_at(
|
||||
updated_at, tag_documents_updated_at(pipeline, tag_documents)
|
||||
),
|
||||
}
|
||||
|
||||
self._notify_issues(pi_issues, PI_SCHEDULE_ISSUES, metadata)
|
||||
self._notify_issues(write_issues, WRITE_TAG_ISSUES, metadata)
|
||||
|
||||
self.info('Processed schedules', metadata=metadata)
|
||||
self.debug(json.dumps(schedule_config, indent=4, sort_keys=True), metadata=metadata)
|
||||
@@ -374,8 +335,8 @@ class Formatters(SientiaMonitoring):
|
||||
read tags without tag_id.
|
||||
- connectors (list[dict[str, Any]], optional): Active OPC UA connectors of
|
||||
the tag database, for read tags with tag_id.
|
||||
- connector_tags (list[dict[str, Any]], optional): Active Read tags of the
|
||||
tag database, for read tags with tag_id.
|
||||
- connector_tags (list[dict[str, Any]], optional): The connector_tags the
|
||||
pipelines reference; only the active Read tags are used.
|
||||
- active_ingestors (list[str]): The active ingestors to divide into slots.
|
||||
|
||||
Returns:
|
||||
@@ -420,7 +381,7 @@ class Formatters(SientiaMonitoring):
|
||||
Args:
|
||||
issues (dict[str, set[str]]): What was left out, by kind
|
||||
kinds (tuple[tuple[str, str, str], ...]): (kind, notification_id, message with
|
||||
one {} for the joined values), e.g. SLOT_ISSUES or PI_SCHEDULE_ISSUES
|
||||
one {} for the joined values), e.g. SLOT_ISSUES or WRITE_TAG_ISSUES
|
||||
metadata (dict[str, Any]): Metadata for the notifications
|
||||
"""
|
||||
for kind, notification_id, message in kinds:
|
||||
@@ -526,8 +487,7 @@ class Formatters(SientiaMonitoring):
|
||||
Args:
|
||||
input_data (dict[str, Any]): The input data containing:
|
||||
- current_schedule_config (dict[str, Any]): The current schedule config in Temporal server
|
||||
- schedule_config (dict[str, Any]): The schedule config to process; an
|
||||
entry {'held': True} keeps that schedule exactly as it is
|
||||
- schedule_config (dict[str, Any]): The schedule config to process
|
||||
- metadata (dict): Metadata for logging purposes
|
||||
|
||||
Returns:
|
||||
@@ -559,9 +519,6 @@ class Formatters(SientiaMonitoring):
|
||||
|
||||
for namespace, schedules in schedule_config.items():
|
||||
current_schedules = current_schedule_config.get(namespace, {})
|
||||
# A held schedule (process_schedules) is neither created nor updated; it stays
|
||||
# in schedule_config, so it is not deleted either
|
||||
schedules = {name: s for name, s in schedules.items() if not s.get('held')}
|
||||
self.compare_config_timestamps(
|
||||
schedules, current_schedules, to_update, to_create, namespace, metadata
|
||||
)
|
||||
|
||||
@@ -193,7 +193,8 @@ class MongoDB(SientiaMonitoring):
|
||||
|
||||
for document in aggregated_documents:
|
||||
for timestamp_field in timestamp_fields:
|
||||
if timestamp_field in document:
|
||||
# Only BSON dates: a field already stored as text is left as it is
|
||||
if isinstance(document.get(timestamp_field), datetime):
|
||||
document[timestamp_field] = (
|
||||
document[timestamp_field]
|
||||
.replace(tzinfo=UTC)
|
||||
|
||||
@@ -81,23 +81,45 @@ def _locate_read_tag(
|
||||
return _opc_server(server_id, opc_server), tag['tag_address']
|
||||
|
||||
|
||||
def _cycle_multiple(active_read: Any) -> int | None:
|
||||
"""The cycle_multiple of an enabled active_read as int (default 1), or None when invalid."""
|
||||
try:
|
||||
return int(active_read.get('cycle_multiple', 1))
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _invalid_read_tag(tag: dict[str, Any]) -> bool:
|
||||
"""
|
||||
A read tag the slot cannot use: no integer frequency, or (without tag_id) no
|
||||
server_id, tag_address or tag_name.
|
||||
A read tag the slot cannot use: no integer frequency, an enabled active_read without
|
||||
an integer cycle_multiple, or (without tag_id) no server_id, tag_address or tag_name.
|
||||
"""
|
||||
if _frequency(tag) is None:
|
||||
return True
|
||||
active_read = tag.get('active_read')
|
||||
if isinstance(active_read, dict) and active_read.get('enabled'):
|
||||
if _cycle_multiple(active_read) is None:
|
||||
return True
|
||||
if tag_id_text(tag) is not None:
|
||||
return False
|
||||
return not is_complete(tag, ('server_id', 'tag_address', 'tag_name'))
|
||||
|
||||
|
||||
def _is_slot_pipeline(pipeline: dict[str, Any]) -> bool:
|
||||
"""A scouter pipeline whose read tags can be named in the slot (schedule, model id and name)."""
|
||||
model = pipeline.get('model')
|
||||
return (
|
||||
is_complete(pipeline, ('schedule_name', 'model_id'))
|
||||
and isinstance(model, dict)
|
||||
and is_complete(model, ('name',))
|
||||
)
|
||||
|
||||
|
||||
def _merge_active_read(current: dict[str, Any], requested: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Active read of an address: on if any pipeline asks, with the lowest cycle_multiple (D3)."""
|
||||
if not requested or not requested.get('enabled'):
|
||||
if not isinstance(requested, dict) or not requested.get('enabled'):
|
||||
return current
|
||||
cycle_multiple = int(requested.get('cycle_multiple', 1))
|
||||
cycle_multiple = cast(int, _cycle_multiple(requested))
|
||||
if current.get('enabled'):
|
||||
cycle_multiple = min(cycle_multiple, current['cycle_multiple'])
|
||||
return {'enabled': True, 'cycle_multiple': cycle_multiple}
|
||||
@@ -198,7 +220,8 @@ def gather_read_tags(
|
||||
model_id and model_name, one per pipeline name, sorted
|
||||
- What was left out: 'tags' (tag_id), 'connectors' (connector_id), 'servers'
|
||||
(opc-servers server_id) and 'invalid' (schedule_name/read tag without a
|
||||
usable frequency or legacy fields)
|
||||
usable frequency, cycle_multiple or legacy fields, or the schedule_name of a
|
||||
pipeline without schedule_name, model_id or model.name)
|
||||
"""
|
||||
issues: dict[str, set[str]] = {
|
||||
'tags': set(),
|
||||
@@ -210,7 +233,10 @@ def gather_read_tags(
|
||||
entries: dict[tuple[str, str], dict[str, Any]] = {}
|
||||
|
||||
for pipeline in pipelines:
|
||||
if pipeline['workflow_type'] != 'scouter':
|
||||
if pipeline.get('workflow_type') != 'scouter':
|
||||
continue
|
||||
if not _is_slot_pipeline(pipeline):
|
||||
issues['invalid'].add(str(pipeline.get('schedule_name')))
|
||||
continue
|
||||
|
||||
for tag in resolve_tag_names(pipeline.get('read_tags') or [], tags_by_id):
|
||||
|
||||
@@ -4,7 +4,6 @@ from math import ceil
|
||||
from typing import Any
|
||||
|
||||
from orchestrator.utils.converters import parse_frequency
|
||||
from orchestrator.utils.tag_database import PI_QUERY_DEFAULTS, pi_connection
|
||||
|
||||
|
||||
def common_config(config: dict[str, Any]):
|
||||
@@ -307,26 +306,21 @@ def pi_web_api_scouter(config: dict[str, Any]):
|
||||
|
||||
Args:
|
||||
config (dict[str, Any]): Pipeline configuration containing:
|
||||
- read_tags (dict[str, dict]): Tag configurations keyed by model tag name, already
|
||||
passed through resolve_pi_read_tags (process_schedules does it), with:
|
||||
- read_tags (list[dict]): List of tag configurations with:
|
||||
- tag_name (str): Name of the tag
|
||||
- webid (str): PI Web API WebID for the tag
|
||||
- aggr_func (str, optional): Aggregation function (default: 'lts')
|
||||
- data_range (list[int], optional): Valid data range (default: [-100, 100])
|
||||
- pi_web_api_config (dict): PI Web API query settings with:
|
||||
- pi_web_api_config (dict): PI Web API connection settings with:
|
||||
- endpoint (str): PI Web API endpoint URL
|
||||
- period (str, optional): Time period for data retrieval (default: '*-1d')
|
||||
- max_count (int, optional): Maximum number of values to retrieve (default: 1)
|
||||
- api_timeout (int, optional): API request timeout in seconds
|
||||
- pi_web_api_connector (dict, optional): The tag database connector of the
|
||||
pipeline's resolved tags (set by process_schedules). Its pi_web_api period,
|
||||
max_count and api_timeout are defaults under pi_web_api_config
|
||||
- Additional fields from base_scouter
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: PI Web API scouter configuration with tag mappings and query settings.
|
||||
API timeout is automatically adjusted to not exceed workflow frequency. With a
|
||||
connector, it also carries connection: {connector_id, base_url, auth} (auth holds
|
||||
the secret reference only, never the credential).
|
||||
API timeout is automatically adjusted to not exceed workflow frequency.
|
||||
"""
|
||||
tags = {}
|
||||
for tag, tag_config in config['read_tags'].items():
|
||||
@@ -338,18 +332,7 @@ def pi_web_api_scouter(config: dict[str, Any]):
|
||||
|
||||
base_config = base_scouter(config)
|
||||
|
||||
connector = config.get('pi_web_api_connector')
|
||||
pi_web_api_config = config['pi_web_api_config']
|
||||
if connector:
|
||||
connector_defaults = connector['pi_web_api']
|
||||
pi_web_api_config = {
|
||||
**{
|
||||
key: connector_defaults[key]
|
||||
for key in PI_QUERY_DEFAULTS
|
||||
if key in connector_defaults
|
||||
},
|
||||
**pi_web_api_config,
|
||||
}
|
||||
|
||||
config_timeout = pi_web_api_config.get('api_timeout', None)
|
||||
frequency = parse_frequency(base_config['frequency'])
|
||||
@@ -357,7 +340,7 @@ def pi_web_api_scouter(config: dict[str, Any]):
|
||||
if config_timeout is None or config_timeout > frequency:
|
||||
config_timeout = frequency
|
||||
|
||||
output = {
|
||||
return {
|
||||
**base_config,
|
||||
'model_tags': tags,
|
||||
'pi_web_api_query': {
|
||||
@@ -367,9 +350,6 @@ def pi_web_api_scouter(config: dict[str, Any]):
|
||||
'api_timeout': config_timeout,
|
||||
},
|
||||
}
|
||||
if connector:
|
||||
output['connection'] = pi_connection(connector)
|
||||
return output
|
||||
|
||||
|
||||
def overlap_filter_config(base_filter_config: dict[str, Any], config: list[dict[str, Any]]):
|
||||
@@ -441,11 +421,10 @@ def predictions_batch(config: dict[str, Any]):
|
||||
Args:
|
||||
config (dict[str, Any]): Pipeline configuration containing:
|
||||
- query (str): SQL query to retrieve input data for predictions
|
||||
- write_tags (list[dict]): List of OPC tag configurations for write-back with:
|
||||
- server_id (str): ID of the target OPC server
|
||||
- type (str): Tag type - 'prediction' (model output) or 'confidence' (prediction confidence)
|
||||
- addr (str): OPC tag address/path
|
||||
- data_type (str, optional): OPC data type (default: 'float')
|
||||
- opc_output_config (dict, optional): The OPC write-back configuration, built by
|
||||
process_schedules from the pipeline's write_tags ([{tag_id}]) through the tag
|
||||
database (resolve_write_tags): {connector_id: {prediction_tags |
|
||||
confidence_tags: {address: {data_type}}}} (default: {})
|
||||
- datetime_columns (list[str], optional): Column names to parse as datetime (default: [])
|
||||
- path_priority (list[str], optional): Filter policy execution order (default: ["STOP", "CONTINUE", "REPEAT"])
|
||||
- input_filters (list[dict], optional): Input data validation filters
|
||||
@@ -454,31 +433,13 @@ def predictions_batch(config: dict[str, Any]):
|
||||
- model_retention_minutes (int, optional): Data retention time in minutes (default: 60)
|
||||
- save_transform (bool, optional): Save transformed data to database (default: True)
|
||||
- predictions_storage_policy (str, optional): Prediction storage policy (default: 'lts:1')
|
||||
- pi_web_api_output_config (dict, optional): PI Web API output configuration for write-back (default: {}),
|
||||
already passed through resolve_pi_output_tags (process_schedules does it)
|
||||
- pi_web_api_output_config (dict, optional): PI Web API output configuration for write-back (default: {})
|
||||
- Additional fields from common_config
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: Complete predictions batch configuration with OPC output mappings,
|
||||
PI Web API output configuration, multi-stage filters, SQL query, and retention policies
|
||||
"""
|
||||
tags: dict[str, Any] = {}
|
||||
for tag in config.get('write_tags', []):
|
||||
if tag['server_id'] not in tags:
|
||||
tags[tag['server_id']] = {}
|
||||
|
||||
tag_type = tag['type']
|
||||
|
||||
if tag_type == 'prediction' or tag_type == 'confidence':
|
||||
tag_type_str = f'{tag_type}_tags'
|
||||
|
||||
if tag_type_str not in tags[tag['server_id']]:
|
||||
tags[tag['server_id']][tag_type_str] = {}
|
||||
|
||||
tags[tag['server_id']][tag_type_str][tag['addr']] = {
|
||||
'data_type': tag.get('data_type', 'float'),
|
||||
}
|
||||
|
||||
path_priority = process_path_priority(
|
||||
config.get('path_priority', ['STOP', 'CONTINUE', 'REPEAT'])
|
||||
)
|
||||
@@ -492,7 +453,7 @@ def predictions_batch(config: dict[str, Any]):
|
||||
'save_transform': config.get('save_transform', True),
|
||||
'transform_table_name': 'transformed_data',
|
||||
'retention_time': config.get('model_retention_minutes', 60) * 60,
|
||||
'opc_output_config': tags,
|
||||
'opc_output_config': config.get('opc_output_config', {}),
|
||||
'pi_web_api_output_config': config.get('pi_web_api_output_config', {}),
|
||||
'input_filters': overlap_filter_config(
|
||||
{'EMPTY_DATA': {'policy': 'STOP', 'config': {}}}, config.get('input_filters', [])
|
||||
|
||||
@@ -1,103 +1,104 @@
|
||||
"""
|
||||
Tag database (connectors + connector_tags): which tags the pipelines reference, the
|
||||
indexes of what was loaded, and the resolution of OPC UA names and PI Web API tags.
|
||||
indexes of what was loaded, and the resolution of the OPC UA read and write tags.
|
||||
"""
|
||||
|
||||
from typing import Any, cast
|
||||
from typing import Any
|
||||
|
||||
# Fields a tag database document needs to take part in a slot. Documents missing any
|
||||
# of them are left out of the index, so their read tags are notified as not found
|
||||
# instead of failing the whole run.
|
||||
# Fields a tag database document needs to take part in a slot or a write config.
|
||||
# Documents missing any of them are left out of the index, so their tags are notified
|
||||
# as not found instead of failing the whole run.
|
||||
CONNECTOR_TAG_FIELDS = ('tag_id', 'connector_id', 'address', 'tag_name')
|
||||
|
||||
|
||||
CONNECTOR_FIELDS = ('connector_id', 'name', 'url', 'server_uri')
|
||||
|
||||
|
||||
# connector_tags categories each use accepts
|
||||
READ_CATEGORIES = ('Read',)
|
||||
WRITE_CATEGORIES = ('Write', 'Prediction Confidence')
|
||||
|
||||
|
||||
# opc_output_config block each write category goes to
|
||||
WRITE_TAG_GROUPS = {'Write': 'prediction_tags', 'Prediction Confidence': 'confidence_tags'}
|
||||
|
||||
|
||||
# data_type values the OPC Gateway write engine accepts (contract C2)
|
||||
OPC_DATA_TYPES = ('float', 'double', 'real', 'int', 'integer', 'bool', 'boolean', 'str', 'string')
|
||||
DEFAULT_DATA_TYPE = 'float'
|
||||
|
||||
|
||||
# Workflow types whose write_tags reference the tag database
|
||||
WRITE_WORKFLOW_TYPES = ('predictions_batch', 'xgboost_predictions_batch')
|
||||
|
||||
|
||||
def is_complete(document: dict[str, Any], fields: tuple[str, ...]) -> bool:
|
||||
return all(document.get(field) not in (None, '') for field in fields)
|
||||
|
||||
|
||||
def _is_integer_text(text: str) -> bool:
|
||||
"""True for ASCII decimal text with an optional sign ('7', '007', '-3'), never raising."""
|
||||
digits = text[1:] if text.startswith('-') else text
|
||||
return digits.isascii() and digits.isdecimal()
|
||||
|
||||
|
||||
def tag_id_text(tag: dict[str, Any]) -> str | None:
|
||||
"""The read tag's tag_id as text, or None when it has none (missing, null or empty)."""
|
||||
"""
|
||||
The tag's tag_id as text, or None when it has none (missing, null or empty).
|
||||
|
||||
Integer ids are canonical ('007' and 7 both give '7'), so a pipeline and the tag
|
||||
database always agree on the key whether they store the id as int or text.
|
||||
"""
|
||||
tag_id = tag.get('tag_id')
|
||||
return None if tag_id in (None, '') else str(tag_id)
|
||||
if tag_id in (None, ''):
|
||||
return None
|
||||
text = str(tag_id).strip()
|
||||
return str(int(text)) if _is_integer_text(text) else text
|
||||
|
||||
|
||||
def _is_active(document: dict[str, Any]) -> bool:
|
||||
"""A document without status (an override that does not project it) counts as active."""
|
||||
return document.get('status') in (None, 'Active')
|
||||
|
||||
|
||||
def pipeline_tag_refs(pipeline: dict[str, Any]) -> list[Any]:
|
||||
"""
|
||||
The tag references of a pipeline: the read_tags of a scouter and the write_tags of a
|
||||
predictions_batch. Entries are returned as they are (they may lack a tag_id).
|
||||
"""
|
||||
workflow_type = pipeline.get('workflow_type')
|
||||
if workflow_type == 'scouter':
|
||||
return list(pipeline.get('read_tags') or [])
|
||||
if workflow_type in WRITE_WORKFLOW_TYPES:
|
||||
return list(pipeline.get('write_tags') or [])
|
||||
return []
|
||||
|
||||
|
||||
def referenced_tag_ids(pipelines: list[dict[str, Any]]) -> list[Any]:
|
||||
"""
|
||||
The tag_ids the scouter pipelines reference, for the connector_tags query.
|
||||
The tag_ids the pipelines reference (scouter read_tags and predictions_batch
|
||||
write_tags), for the connector_tags query.
|
||||
|
||||
Each id goes in both forms, int and text, because the tag database stores int and
|
||||
pipelines may hold either. Sorted, so the query is the same on every run.
|
||||
pipelines may hold either. Sorted, so the query is the same on every run. Runs in
|
||||
the workflow, so it never raises on malformed references.
|
||||
|
||||
Args:
|
||||
pipelines (list[dict[str, Any]]): Pipeline configurations
|
||||
|
||||
Returns:
|
||||
list[Any]: The referenced tag_ids (empty when no read tag carries a tag_id)
|
||||
list[Any]: The referenced tag_ids (empty when no tag carries a tag_id)
|
||||
"""
|
||||
tags = [
|
||||
tag
|
||||
for pipeline in pipelines
|
||||
if pipeline.get('workflow_type') == 'scouter'
|
||||
for tag in pipeline.get('read_tags') or []
|
||||
]
|
||||
return _query_tag_ids(tags)
|
||||
|
||||
|
||||
def _query_tag_ids(tags: list[Any]) -> list[Any]:
|
||||
"""The tag_ids of the tags in both forms (int and text), sorted for a stable query."""
|
||||
ids: set[Any] = set()
|
||||
for tag in tags:
|
||||
tag_id = tag_id_text(tag) if isinstance(tag, dict) else None
|
||||
if tag_id is None:
|
||||
continue
|
||||
ids.add(tag_id)
|
||||
if tag_id.lstrip('-').isdigit():
|
||||
ids.add(int(tag_id))
|
||||
return sorted(ids, key=lambda value: (isinstance(value, str), str(value)))
|
||||
|
||||
|
||||
# Workflow types whose pi_web_api_output_config may reference the tag database
|
||||
PI_OUTPUT_WORKFLOW_TYPES = ('predictions_batch', 'xgboost_predictions_batch')
|
||||
|
||||
|
||||
# connector_tags category each PI Web API output mapping accepts
|
||||
PI_OUTPUT_CATEGORIES = {'prediction_tags': 'Write', 'confidence_tags': 'Prediction Confidence'}
|
||||
|
||||
|
||||
# Connection defaults a pi_web_api connector may carry for the scouter query
|
||||
PI_QUERY_DEFAULTS = ('period', 'max_count', 'api_timeout')
|
||||
|
||||
|
||||
def referenced_pi_tag_ids(pipelines: list[dict[str, Any]]) -> list[Any]:
|
||||
"""
|
||||
The tag_ids the PI Web API pipelines reference, for the PI connector_tags query.
|
||||
|
||||
Read tags of pi_web_api_scouter pipelines and the prediction_tags / confidence_tags
|
||||
entries of a predictions_batch pi_web_api_output_config given as a list. Same forms
|
||||
and order as referenced_tag_ids.
|
||||
|
||||
Args:
|
||||
pipelines (list[dict[str, Any]]): Pipeline configurations
|
||||
|
||||
Returns:
|
||||
list[Any]: The referenced tag_ids (empty when no PI Web API tag carries a tag_id)
|
||||
"""
|
||||
tags: list[Any] = []
|
||||
for pipeline in pipelines:
|
||||
workflow_type = pipeline.get('workflow_type')
|
||||
if workflow_type == 'pi_web_api_scouter':
|
||||
read_tags = pipeline.get('read_tags')
|
||||
if isinstance(read_tags, dict):
|
||||
tags.extend(read_tags.values())
|
||||
elif workflow_type in PI_OUTPUT_WORKFLOW_TYPES:
|
||||
output_config = pipeline.get('pi_web_api_output_config') or {}
|
||||
for key in PI_OUTPUT_CATEGORIES:
|
||||
if isinstance(output_config.get(key), list):
|
||||
tags.extend(output_config[key])
|
||||
return _query_tag_ids(tags)
|
||||
for tag in pipeline_tag_refs(pipeline):
|
||||
tag_id = tag_id_text(tag) if isinstance(tag, dict) else None
|
||||
if tag_id is None:
|
||||
continue
|
||||
ids.add(tag_id)
|
||||
if _is_integer_text(tag_id):
|
||||
ids.add(int(tag_id))
|
||||
return sorted(ids, key=lambda value: (isinstance(value, str), str(value)))
|
||||
|
||||
|
||||
def filter_by_tag_ids(query: dict[str, Any], tag_ids: list[Any]) -> dict[str, Any]:
|
||||
@@ -119,22 +120,44 @@ def filter_by_tag_ids(query: dict[str, Any], tag_ids: list[Any]) -> dict[str, An
|
||||
}
|
||||
|
||||
|
||||
def index_connector_tags(connector_tags: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
||||
def index_tag_documents(connector_tags: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
||||
"""
|
||||
Index the tag database's connector_tags by tag_id.
|
||||
Every loaded connector_tags document keyed by tag_id text, whatever its status,
|
||||
category or completeness: the documents whose updated_at a pipeline depends on.
|
||||
"""
|
||||
indexed = {}
|
||||
for tag in connector_tags:
|
||||
tag_id = tag_id_text(tag)
|
||||
if tag_id is not None:
|
||||
indexed[tag_id] = tag
|
||||
return indexed
|
||||
|
||||
The key is the tag_id as text, because pipelines may store it as int or str.
|
||||
Documents without tag_id, connector_id, address or tag_name are left out.
|
||||
|
||||
def index_connector_tags(
|
||||
connector_tags: list[dict[str, Any]], categories: tuple[str, ...] = READ_CATEGORIES
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""
|
||||
Index the tag database's usable connector_tags by tag_id.
|
||||
|
||||
The key is the tag_id as text (tag_id_text), because pipelines may store it as int
|
||||
or str. Documents that are not active, have another category, or lack tag_id,
|
||||
connector_id, address or tag_name are left out.
|
||||
|
||||
Args:
|
||||
connector_tags (list[dict[str, Any]]): connector_tags documents (SIENTIAPDE-1569),
|
||||
each with tag_id, connector_id, address and tag_name
|
||||
connector_tags (list[dict[str, Any]]): connector_tags documents (SIENTIAPDE-1569)
|
||||
categories (tuple[str, ...]): Accepted categories (READ_CATEGORIES for the slot
|
||||
and the scouter names, WRITE_CATEGORIES for the write tags). A document
|
||||
without category is accepted.
|
||||
|
||||
Returns:
|
||||
dict[str, dict[str, Any]]: connector_tags documents keyed by str(tag_id)
|
||||
dict[str, dict[str, Any]]: connector_tags documents keyed by tag_id text
|
||||
"""
|
||||
return {
|
||||
str(tag['tag_id']): tag for tag in connector_tags if is_complete(tag, CONNECTOR_TAG_FIELDS)
|
||||
tag_id: tag
|
||||
for tag_id, tag in index_tag_documents(connector_tags).items()
|
||||
if is_complete(tag, CONNECTOR_TAG_FIELDS)
|
||||
and _is_active(tag)
|
||||
and tag.get('category') in (None, *categories)
|
||||
}
|
||||
|
||||
|
||||
@@ -142,7 +165,8 @@ def index_connectors(connectors: list[dict[str, Any]]) -> dict[str, dict[str, An
|
||||
"""
|
||||
Index the tag database's connectors by connector_id (as text).
|
||||
|
||||
Documents without connector_id, name, url or server_uri are left out.
|
||||
Documents that are not active or lack connector_id, name, url or server_uri are
|
||||
left out.
|
||||
|
||||
Args:
|
||||
connectors (list[dict[str, Any]]): connectors documents (SIENTIAPDE-1569)
|
||||
@@ -153,10 +177,45 @@ def index_connectors(connectors: list[dict[str, Any]]) -> dict[str, dict[str, An
|
||||
return {
|
||||
str(connector['connector_id']): connector
|
||||
for connector in connectors
|
||||
if is_complete(connector, CONNECTOR_FIELDS)
|
||||
if is_complete(connector, CONNECTOR_FIELDS) and _is_active(connector)
|
||||
}
|
||||
|
||||
|
||||
def latest_updated_at(*values: Any) -> str | None:
|
||||
"""
|
||||
The latest of the given updated_at values, or None when none is set.
|
||||
|
||||
The values are text in DATETIME_FORMAT_MS_WITH_TZ (UTC), the format
|
||||
aggregate_documents_in_mongodb gives with timestamp_fields, so they compare as text.
|
||||
"""
|
||||
timestamps = [value for value in values if isinstance(value, str) and value]
|
||||
return max(timestamps) if timestamps else None
|
||||
|
||||
|
||||
def tag_documents_updated_at(
|
||||
pipeline: dict[str, Any], tag_documents: dict[str, dict[str, Any]]
|
||||
) -> str | None:
|
||||
"""
|
||||
The latest updated_at among the tag database documents a pipeline references.
|
||||
|
||||
Every referenced document counts, inactive or of another category too, so renaming,
|
||||
moving, retyping or deactivating a tag redeploys the pipelines that use it.
|
||||
|
||||
Args:
|
||||
pipeline (dict[str, Any]): A pipeline configuration
|
||||
tag_documents (dict[str, dict[str, Any]]): From index_tag_documents
|
||||
|
||||
Returns:
|
||||
str | None: The latest updated_at, or None when no referenced document has one
|
||||
"""
|
||||
values = []
|
||||
for tag in pipeline_tag_refs(pipeline):
|
||||
tag_id = tag_id_text(tag) if isinstance(tag, dict) else None
|
||||
if tag_id is not None and tag_id in tag_documents:
|
||||
values.append(tag_documents[tag_id].get('updated_at'))
|
||||
return latest_updated_at(*values)
|
||||
|
||||
|
||||
def resolve_tag_names(
|
||||
read_tags: list[dict[str, Any]], tags_by_id: dict[str, dict[str, Any]]
|
||||
) -> list[dict[str, Any]]:
|
||||
@@ -186,174 +245,56 @@ def resolve_tag_names(
|
||||
return resolved
|
||||
|
||||
|
||||
def _pi_auth(connector: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""The connector's pi_web_api.auth when it names a type and a secret {name, key}."""
|
||||
block = connector.get('pi_web_api')
|
||||
auth = block.get('auth') if isinstance(block, dict) else None
|
||||
if not isinstance(auth, dict) or auth.get('type') in (None, ''):
|
||||
return None
|
||||
secret = auth.get('secret')
|
||||
if not isinstance(secret, dict) or not is_complete(secret, ('name', 'key')):
|
||||
return None
|
||||
return auth
|
||||
|
||||
|
||||
def index_pi_connectors(connectors: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
||||
"""
|
||||
Index the tag database's pi_web_api connectors by connector_id (as text).
|
||||
|
||||
Documents without connector_id, url or a pi_web_api.auth with type and secret
|
||||
{name, key} are left out, so their tags are notified as connector not found.
|
||||
|
||||
Args:
|
||||
connectors (list[dict[str, Any]]): Active pi_web_api connectors documents
|
||||
|
||||
Returns:
|
||||
dict[str, dict[str, Any]]: connectors documents keyed by str(connector_id)
|
||||
"""
|
||||
return {
|
||||
str(connector['connector_id']): connector
|
||||
for connector in connectors
|
||||
if is_complete(connector, ('connector_id', 'url')) and _pi_auth(connector) is not None
|
||||
}
|
||||
|
||||
|
||||
def pi_connection(connector: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Connection block of a pi_web_api connector for the schedule input.
|
||||
|
||||
Only the secret reference is copied, field by field, so nothing else the document
|
||||
may hold under auth ever reaches the schedule input or the Temporal history (D3).
|
||||
"""
|
||||
auth = cast(dict[str, Any], _pi_auth(connector))
|
||||
connection = {
|
||||
'connector_id': str(connector['connector_id']),
|
||||
'base_url': connector['url'],
|
||||
'auth': {
|
||||
'type': auth['type'],
|
||||
'secret': {'name': auth['secret']['name'], 'key': auth['secret']['key']},
|
||||
},
|
||||
}
|
||||
data_server_webid = connector['pi_web_api'].get('data_server_webid')
|
||||
if data_server_webid:
|
||||
connection['data_server_webid'] = data_server_webid
|
||||
return connection
|
||||
|
||||
|
||||
def _new_pi_issues() -> dict[str, set[str]]:
|
||||
return {'tags': set(), 'categories': set(), 'connectors': set(), 'mixed': set()}
|
||||
|
||||
|
||||
def _locate_pi_tag(
|
||||
tag_id: str,
|
||||
category: str,
|
||||
pi_tags: dict[str, dict[str, Any]],
|
||||
pi_connectors: dict[str, dict[str, Any]],
|
||||
issues: dict[str, set[str]],
|
||||
) -> dict[str, Any] | None:
|
||||
"""
|
||||
The connector_tags document of a PI Web API tag_id, or None when it cannot be used:
|
||||
unknown tag_id ('tags'), another category ('categories'), or a connector that is not
|
||||
an active, complete pi_web_api connector ('connectors').
|
||||
"""
|
||||
connector_tag = pi_tags.get(tag_id)
|
||||
if connector_tag is None:
|
||||
issues['tags'].add(tag_id)
|
||||
return None
|
||||
if connector_tag.get('category') != category:
|
||||
issues['categories'].add(tag_id)
|
||||
return None
|
||||
connector_id = str(connector_tag['connector_id'])
|
||||
if connector_id not in pi_connectors:
|
||||
issues['connectors'].add(connector_id)
|
||||
return None
|
||||
return connector_tag
|
||||
|
||||
|
||||
def resolve_pi_read_tags(
|
||||
read_tags: dict[str, dict[str, Any]],
|
||||
pi_tags: dict[str, dict[str, Any]],
|
||||
pi_connectors: dict[str, dict[str, Any]],
|
||||
) -> tuple[dict[str, dict[str, Any]], dict[str, Any] | None, dict[str, set[str]]]:
|
||||
"""
|
||||
Resolve the read tags of a pi_web_api_scouter pipeline through the tag database.
|
||||
|
||||
An entry with tag_id gets its webid from connector_tags.address (Read category); its
|
||||
key (the model tag name), aggr_func and data_range stay the pipeline's. An entry
|
||||
without tag_id is kept as is (legacy webid). An entry that cannot be resolved is left
|
||||
out. All the resolved tags must belong to one connector (D6).
|
||||
|
||||
Args:
|
||||
read_tags (dict[str, dict[str, Any]]): The pipeline's read_tags
|
||||
pi_tags (dict[str, dict[str, Any]]): PI connector_tags indexed by index_connector_tags
|
||||
pi_connectors (dict[str, dict[str, Any]]): Indexed by index_pi_connectors
|
||||
|
||||
Returns:
|
||||
tuple: the resolved read_tags; the connector of the resolved tags (None when no tag
|
||||
was resolved through the tag database or they span several connectors); and
|
||||
what was left out by kind: 'tags' / 'categories' (tag_id), 'connectors'
|
||||
(connector_id) and 'mixed' (the connector_ids, joined, when there are several)
|
||||
"""
|
||||
issues = _new_pi_issues()
|
||||
resolved: dict[str, dict[str, Any]] = {}
|
||||
connector_ids: set[str] = set()
|
||||
for name, tag in read_tags.items():
|
||||
tag_id = tag_id_text(tag)
|
||||
if tag_id is None:
|
||||
resolved[name] = tag
|
||||
continue
|
||||
connector_tag = _locate_pi_tag(tag_id, 'Read', pi_tags, pi_connectors, issues)
|
||||
if connector_tag is None:
|
||||
continue
|
||||
connector_ids.add(str(connector_tag['connector_id']))
|
||||
resolved[name] = {**tag, 'webid': connector_tag['address']}
|
||||
|
||||
if len(connector_ids) > 1:
|
||||
issues['mixed'].add(', '.join(sorted(connector_ids)))
|
||||
return resolved, None, issues
|
||||
connector = pi_connectors[connector_ids.pop()] if connector_ids else None
|
||||
return resolved, connector, issues
|
||||
|
||||
|
||||
def resolve_pi_output_tags(
|
||||
output_config: dict[str, Any],
|
||||
pi_tags: dict[str, dict[str, Any]],
|
||||
pi_connectors: dict[str, dict[str, Any]],
|
||||
def resolve_write_tags(
|
||||
write_tags: list[Any],
|
||||
tags_by_id: dict[str, dict[str, Any]],
|
||||
connectors: dict[str, dict[str, Any]],
|
||||
) -> tuple[dict[str, Any], dict[str, set[str]]]:
|
||||
"""
|
||||
Resolve the prediction_tags / confidence_tags of a pi_web_api_output_config.
|
||||
Build the opc_output_config of a predictions_batch from its write_tags.
|
||||
|
||||
A mapping given as a list is turned into {tag_name: WebID}: an entry with tag_id takes
|
||||
tag_name and address from connector_tags ('Write' for prediction_tags, 'Prediction
|
||||
Confidence' for confidence_tags); an entry without tag_id is read as a legacy
|
||||
{tag_name: webid} fragment. A mapping already given as a dict passes through unchanged,
|
||||
so the laborious input keeps today's shape.
|
||||
Each write tag is only a tag_id: the tag database gives the rest. The category says
|
||||
where the value goes ('Write' → prediction_tags, 'Prediction Confidence' →
|
||||
confidence_tags), the connector_id is the server the OPC Gateway writes to (it
|
||||
resolves it in connectors), and the address and data_type come from connector_tags
|
||||
(data_type defaults to 'float').
|
||||
|
||||
Args:
|
||||
output_config (dict[str, Any]): The pipeline's pi_web_api_output_config
|
||||
pi_tags (dict[str, dict[str, Any]]): PI connector_tags indexed by index_connector_tags
|
||||
pi_connectors (dict[str, dict[str, Any]]): Indexed by index_pi_connectors
|
||||
write_tags (list[Any]): The pipeline's write_tags ([{tag_id}])
|
||||
tags_by_id (dict[str, dict[str, Any]]): Write connector_tags indexed by
|
||||
index_connector_tags(..., WRITE_CATEGORIES)
|
||||
connectors (dict[str, dict[str, Any]]): Active OPC UA connectors indexed by
|
||||
index_connectors
|
||||
|
||||
Returns:
|
||||
tuple: the resolved output config and what was left out, by kind ('tags',
|
||||
'categories', 'connectors'), as in resolve_pi_read_tags
|
||||
tuple: the opc_output_config ({connector_id: {prediction_tags | confidence_tags:
|
||||
{address: {data_type}}}}) and what was left out, by kind: 'tags' (tag_id not
|
||||
an active, complete Write / Prediction Confidence tag), 'connectors'
|
||||
(connector_id) and 'invalid' (an entry without tag_id, or a tag whose
|
||||
data_type the gateway does not accept)
|
||||
"""
|
||||
issues = _new_pi_issues()
|
||||
resolved = dict(output_config)
|
||||
for key, category in PI_OUTPUT_CATEGORIES.items():
|
||||
entries = output_config.get(key)
|
||||
if not isinstance(entries, list):
|
||||
issues: dict[str, set[str]] = {'tags': set(), 'connectors': set(), 'invalid': set()}
|
||||
output_config: dict[str, Any] = {}
|
||||
for entry in write_tags:
|
||||
tag_id = tag_id_text(entry) if isinstance(entry, dict) else None
|
||||
if tag_id is None:
|
||||
issues['invalid'].add(str(entry))
|
||||
continue
|
||||
mapping: dict[str, Any] = {}
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
tag_id = tag_id_text(entry)
|
||||
if tag_id is None:
|
||||
mapping.update(entry)
|
||||
continue
|
||||
connector_tag = _locate_pi_tag(tag_id, category, pi_tags, pi_connectors, issues)
|
||||
if connector_tag is not None:
|
||||
mapping[connector_tag['tag_name']] = connector_tag['address']
|
||||
resolved[key] = mapping
|
||||
return resolved, issues
|
||||
connector_tag = tags_by_id.get(tag_id)
|
||||
# The category is what tells a prediction from a confidence: it cannot be missing
|
||||
if connector_tag is None or connector_tag.get('category') not in WRITE_TAG_GROUPS:
|
||||
issues['tags'].add(tag_id)
|
||||
continue
|
||||
connector_id = str(connector_tag['connector_id'])
|
||||
if connector_id not in connectors:
|
||||
issues['connectors'].add(connector_id)
|
||||
continue
|
||||
data_type = connector_tag.get('data_type') or DEFAULT_DATA_TYPE
|
||||
if data_type not in OPC_DATA_TYPES:
|
||||
issues['invalid'].add(tag_id)
|
||||
continue
|
||||
group = WRITE_TAG_GROUPS[connector_tag['category']]
|
||||
output_config.setdefault(connector_id, {}).setdefault(group, {})[
|
||||
connector_tag['address']
|
||||
] = {'data_type': data_type}
|
||||
return output_config, issues
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from temporalio import workflow
|
||||
|
||||
with workflow.unsafe.imports_passed_through():
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
@@ -10,7 +9,6 @@ with workflow.unsafe.imports_passed_through():
|
||||
from orchestrator.activities.activities import Activities
|
||||
from orchestrator.utils.tag_database import (
|
||||
filter_by_tag_ids,
|
||||
referenced_pi_tag_ids,
|
||||
referenced_tag_ids,
|
||||
)
|
||||
|
||||
@@ -18,11 +16,14 @@ with workflow.unsafe.imports_passed_through():
|
||||
MATCH = '$match'
|
||||
PROJECT = '$project'
|
||||
|
||||
# Tag database (SIENTIAPDE-1569) read by the slot v2 (SIENTIAPDE-1974). The workflow
|
||||
# input may override these aggregations (connectors_query / connector_tags_query); the
|
||||
# workflow puts a tag_id $match in front of connector_tags_query. Only the fields the
|
||||
# slot uses are projected: other attributes may hold BSON types a Temporal payload
|
||||
# cannot carry.
|
||||
# Tag database (SIENTIAPDE-1569) read by the slot v2 and the schedules (SIENTIAPDE-1974).
|
||||
# The workflow input may override these aggregations (connectors_query /
|
||||
# connector_tags_query); the workflow puts a tag_id $match in front of
|
||||
# connector_tags_query. Every category and status of the referenced tags is loaded: the
|
||||
# slot uses the active Read tags, predictions_batch the active Write / Prediction
|
||||
# Confidence ones, and every referenced tag's updated_at redeploys its pipelines. Only
|
||||
# the fields used are projected: other attributes may hold BSON types a Temporal payload
|
||||
# cannot carry (updated_at is turned into text by timestamp_fields).
|
||||
DEFAULT_CONNECTORS_QUERY = {
|
||||
'collection': 'connectors',
|
||||
'aggregation': [
|
||||
@@ -43,28 +44,6 @@ DEFAULT_CONNECTORS_QUERY = {
|
||||
DEFAULT_CONNECTOR_TAGS_QUERY = {
|
||||
'collection': 'connector_tags',
|
||||
'aggregation': [
|
||||
{MATCH: {'category': 'Read', 'status': 'Active'}},
|
||||
{PROJECT: {'_id': 0, 'tag_id': 1, 'connector_id': 1, 'address': 1, 'tag_name': 1}},
|
||||
],
|
||||
}
|
||||
# Runs started before the tag database loads existed replay without them
|
||||
TAG_DATABASE_PATCH = 'SIENTIAPDE-1974-tag-database'
|
||||
|
||||
# PI Web API side of the tag database, read by process_schedules only (never by the
|
||||
# slot). The input may override them (pi_connectors_query / pi_connector_tags_query);
|
||||
# the workflow puts a tag_id $match in front of pi_connector_tags_query. Tags of every
|
||||
# category are loaded: predictions_batch writes to Write / Prediction Confidence tags.
|
||||
DEFAULT_PI_CONNECTORS_QUERY = {
|
||||
'collection': 'connectors',
|
||||
'aggregation': [
|
||||
{MATCH: {'type': 'pi_web_api', 'status': 'Active'}},
|
||||
{PROJECT: {'_id': 0, 'connector_id': 1, 'name': 1, 'url': 1, 'pi_web_api': 1}},
|
||||
],
|
||||
}
|
||||
DEFAULT_PI_CONNECTOR_TAGS_QUERY = {
|
||||
'collection': 'connector_tags',
|
||||
'aggregation': [
|
||||
{MATCH: {'status': 'Active'}},
|
||||
{
|
||||
PROJECT: {
|
||||
'_id': 0,
|
||||
@@ -73,12 +52,15 @@ DEFAULT_PI_CONNECTOR_TAGS_QUERY = {
|
||||
'address': 1,
|
||||
'tag_name': 1,
|
||||
'category': 1,
|
||||
'data_type': 1,
|
||||
'status': 1,
|
||||
'updated_at': 1,
|
||||
}
|
||||
},
|
||||
],
|
||||
}
|
||||
# Runs started before the PI Web API loads existed replay without them
|
||||
PI_WEB_API_PATCH = 'connectors-pi-web-api'
|
||||
# Runs started before the tag database loads existed replay without them
|
||||
TAG_DATABASE_PATCH = 'SIENTIAPDE-1974-tag-database'
|
||||
|
||||
|
||||
@workflow.defn(name='orchestrator')
|
||||
@@ -99,12 +81,14 @@ class Orchestrator:
|
||||
pipelines: list[dict[str, Any]],
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
"""
|
||||
Load the tag database (SIENTIAPDE-1974) for the read tags that carry a tag_id.
|
||||
Load the tag database (SIENTIAPDE-1974) for the read and write tags that carry a
|
||||
tag_id.
|
||||
|
||||
Only the referenced tags are loaded, with the fields the slot uses, so an
|
||||
environment without a tag database never depends on its content (P4) and the
|
||||
payload stays bounded by the pipelines. Nothing is loaded when no scouter read
|
||||
tag has a tag_id, or when replaying a run started before TAG_DATABASE_PATCH.
|
||||
Only the referenced tags are loaded, with the fields used, so an environment
|
||||
without a tag database never depends on its content (P4) and the payload stays
|
||||
bounded by the pipelines. Nothing is loaded when no scouter read tag nor
|
||||
predictions_batch write tag has a tag_id, or when replaying a run started before
|
||||
TAG_DATABASE_PATCH.
|
||||
|
||||
Args:
|
||||
input_data (dict[str, Any]): Workflow input (optional connectors_query and
|
||||
@@ -127,39 +111,6 @@ class Orchestrator:
|
||||
tag_ids,
|
||||
)
|
||||
|
||||
async def _load_pi_tag_database(
|
||||
self,
|
||||
input_data: dict[str, Any],
|
||||
metadata: dict[str, Any],
|
||||
pipelines: list[dict[str, Any]],
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
"""
|
||||
Load the PI Web API connectors and the tags the PI Web API pipelines reference.
|
||||
|
||||
Same bounds as _load_tag_database: nothing is loaded when no PI Web API tag has a
|
||||
tag_id, or when replaying a run started before PI_WEB_API_PATCH.
|
||||
|
||||
Args:
|
||||
input_data (dict[str, Any]): Workflow input (optional pi_connectors_query and
|
||||
pi_connector_tags_query)
|
||||
metadata (dict[str, Any]): Activity metadata ({'metadata': {...}})
|
||||
pipelines (list[dict[str, Any]]): The loaded pipeline configurations
|
||||
|
||||
Returns:
|
||||
tuple[list[dict[str, Any]], list[dict[str, Any]]]: pi connectors and their tags
|
||||
"""
|
||||
tag_ids = referenced_pi_tag_ids(pipelines)
|
||||
# patched() first: it must run on every execution to record its marker
|
||||
if not workflow.patched(PI_WEB_API_PATCH) or not tag_ids:
|
||||
return [], []
|
||||
|
||||
return await self._load_connectors(
|
||||
metadata,
|
||||
input_data.get('pi_connectors_query', DEFAULT_PI_CONNECTORS_QUERY),
|
||||
input_data.get('pi_connector_tags_query', DEFAULT_PI_CONNECTOR_TAGS_QUERY),
|
||||
tag_ids,
|
||||
)
|
||||
|
||||
async def _load_connectors(
|
||||
self,
|
||||
metadata: dict[str, Any],
|
||||
@@ -174,6 +125,7 @@ class Orchestrator:
|
||||
**metadata,
|
||||
# A fresh aggregation list: the activity appends a stage to the one it gets
|
||||
'query': {**connectors_query, 'aggregation': list(connectors_query['aggregation'])},
|
||||
'timestamp_fields': ['updated_at'],
|
||||
},
|
||||
retry_policy=retry_policy,
|
||||
start_to_close_timeout=timedelta(seconds=60),
|
||||
@@ -181,7 +133,11 @@ class Orchestrator:
|
||||
|
||||
connector_tags_handler = workflow.start_local_activity_method(
|
||||
Activities.aggregate_documents_in_mongodb,
|
||||
{**metadata, 'query': filter_by_tag_ids(connector_tags_query, tag_ids)},
|
||||
{
|
||||
**metadata,
|
||||
'query': filter_by_tag_ids(connector_tags_query, tag_ids),
|
||||
'timestamp_fields': ['updated_at'],
|
||||
},
|
||||
retry_policy=retry_policy,
|
||||
start_to_close_timeout=timedelta(seconds=60),
|
||||
)
|
||||
@@ -204,16 +160,12 @@ class Orchestrator:
|
||||
- schedule_name (str): Name of the orchestration schedule
|
||||
- pipelines_query (dict[str, Any]): MongoDB query for pipeline configurations
|
||||
- opc_servers_query (dict[str, Any]): MongoDB query for OPC server data
|
||||
Optional fields (used only when a scouter read tag carries a tag_id):
|
||||
Optional fields (used only when a scouter read tag or a predictions_batch
|
||||
write tag carries a tag_id):
|
||||
- connectors_query (dict[str, Any]): MongoDB aggregation for the tag
|
||||
database connectors (default: DEFAULT_CONNECTORS_QUERY)
|
||||
- connector_tags_query (dict[str, Any]): MongoDB aggregation for the tag
|
||||
database tags (default: DEFAULT_CONNECTOR_TAGS_QUERY)
|
||||
Optional fields (used only when a PI Web API tag carries a tag_id):
|
||||
- pi_connectors_query (dict[str, Any]): MongoDB aggregation for the
|
||||
pi_web_api connectors (default: DEFAULT_PI_CONNECTORS_QUERY)
|
||||
- pi_connector_tags_query (dict[str, Any]): MongoDB aggregation for their
|
||||
tags (default: DEFAULT_PI_CONNECTOR_TAGS_QUERY)
|
||||
|
||||
Returns:
|
||||
None: Workflow completes without return value
|
||||
@@ -282,10 +234,8 @@ class Orchestrator:
|
||||
|
||||
pipeline_config = await pipeline_config_handler
|
||||
|
||||
# Started in this order on every run, so commands stay deterministic
|
||||
(connectors, connector_tags), (pi_connectors, pi_connector_tags) = await asyncio.gather(
|
||||
self._load_tag_database(input_data, metadata, pipeline_config),
|
||||
self._load_pi_tag_database(input_data, metadata, pipeline_config),
|
||||
connectors, connector_tags = await self._load_tag_database(
|
||||
input_data, metadata, pipeline_config
|
||||
)
|
||||
|
||||
orchestrated_schedules = await orchestrated_schedules_handler
|
||||
@@ -305,9 +255,8 @@ class Orchestrator:
|
||||
{
|
||||
**metadata,
|
||||
'pipelines': pipeline_config,
|
||||
'connectors': connectors,
|
||||
'connector_tags': connector_tags,
|
||||
'pi_connectors': pi_connectors,
|
||||
'pi_connector_tags': pi_connector_tags,
|
||||
},
|
||||
retry_policy=retry_policy,
|
||||
start_to_close_timeout=timedelta(seconds=60),
|
||||
|
||||
Reference in New Issue
Block a user