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:
2026-10-29 08:25:01 -03:00
parent 9eaec54fc8
commit 2c331b5f16
28 changed files with 4451 additions and 1385 deletions

View File

@@ -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
)

View File

@@ -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)

View File

@@ -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):

View File

@@ -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', [])

View File

@@ -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

View File

@@ -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),