SIENTIAPDE-1974

SIENTIAPDE-1974  Split orchestrator_functions into pipeline configs, tag database and OPC slots
- orchestrator_functions.py held four unrelated concerns (pipeline
  builders, tag database indexes, PI Web API resolution, OPC slot v2)
  and had grown to 1135 lines. Move them, unchanged, into:
  pipeline_configs.py, tag_database.py and opc_slots.py.
- Make the helpers shared across modules public: is_complete,
  tag_id_text and pi_connection.
- Split the utils tests the same way, with the pipeline and tag
  database documents they share in tag_database_fixtures.py.
- Point formatters, the orchestrator workflow, README and CLAUDE.md at
  the new modules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 16:38:53 -03:00
parent 11e350a6ce
commit 9eaec54fc8
14 changed files with 2777 additions and 2706 deletions

View File

@@ -6,7 +6,7 @@ from pytest import fixture
from sientia_do.notifications.models import NotificationLevel
from orchestrator.activities.formatters import Formatters
from orchestrator.utils.orchestrator_functions import pi_web_api_scouter, predictions_batch
from orchestrator.utils.pipeline_configs import pi_web_api_scouter, predictions_batch
@fixture

View File

@@ -0,0 +1,64 @@
"""Pipeline and tag database documents shared by the utils tests."""
def scouter_pipeline_doc(schedule_name, model_id, read_tags, workflow_type='scouter'):
return {
'workflow_type': workflow_type,
'schedule_name': schedule_name,
'model_id': model_id,
'model': {'name': f'model {model_id}'},
'read_tags': read_tags,
}
def legacy_tag(address, name, frequency=1000, server_id='1', **extra):
return {
'server_id': server_id,
'tag_address': address,
'tag_name': name,
'frequency': frequency,
**extra,
}
TAGS_BY_ID = {
'7': {'tag_id': 7, 'connector_id': 3, 'address': 'ns=3;i=1001', 'tag_name': 'Pressure'},
'8': {'tag_id': 8, 'connector_id': 5, 'address': 'ns=3;i=1002', 'tag_name': 'Level'},
}
# PI Web API through the tag database (connectors-pi-web-api)
PI_SECRET = {'name': 'pi-web-api-auth-token', 'key': 'token'}
def pi_connector_doc(connector_id='pi-usina', **pi_web_api):
return {
'connector_id': connector_id,
'name': 'PI Usina',
'url': f'https://{connector_id}.example/piwebapi',
'pi_web_api': {'auth': {'type': 'bearer', 'secret': dict(PI_SECRET)}, **pi_web_api},
}
def pi_tag_doc(tag_id, address, tag_name, category='Read', connector_id='pi-usina'):
return {
'tag_id': tag_id,
'connector_id': connector_id,
'address': address,
'tag_name': tag_name,
'category': category,
}
def pi_scouter_pipeline_doc(read_tags, **fields):
return {
'workflow_type': 'pi_web_api_scouter',
'schedule_name': 'pi_schedule',
'model_id': 'model_id',
'model': {'name': 'model_name'},
'frequency': '1m',
'read_tags': read_tags,
'pi_web_api_config': {'endpoint': '/streamsets/recorded'},
**fields,
}

View File

@@ -0,0 +1,474 @@
from orchestrator.utils.opc_slots import (
build_tag_config,
gather_read_tags,
)
from tests.orchestrator.utils.tag_database_fixtures import (
TAGS_BY_ID,
legacy_tag,
scouter_pipeline_doc,
)
def _name(schedule_name, tag_name, model_id):
return {
'collection': f'raw_{schedule_name}',
'tag_name': tag_name,
'model_id': model_id,
'model_name': f'model {model_id}',
}
opc_servers = {
'1': {
'id': '1',
'server_name': 'kepserver',
'url': 'opc.tcp://kep:49320',
'uri': 'urn:kep',
'cert_path': 'certs/client.pem',
},
'2': {'id': '2', 'server_name': 'simulator', 'url': 'opc.tcp://sim:4840', 'uri': 'urn:sim'},
}
connectors = {
'3': {
'connector_id': 3,
'name': 'plant',
'url': 'opc.tcp://plant:4840',
'server_uri': 'urn:plant',
'use_security': True,
'subscription_period_ms': 1500,
}
}
def _gather(pipelines, servers=None, connector_index=None, tag_index=None):
return gather_read_tags(
pipelines,
opc_servers if servers is None else servers,
connectors if connector_index is None else connector_index,
TAGS_BY_ID if tag_index is None else tag_index,
)
def test_gather_read_tags_multi_name():
"""One address read by two pipelines with different names: one entry, two names"""
pipelines = [
scouter_pipeline_doc('sin', 'm1', [legacy_tag('ns=2;s=A', 'Counter', frequency=15000)]),
scouter_pipeline_doc('cos', 'm2', [legacy_tag('ns=2;s=A', 'Contador', frequency=5000)]),
]
entries, issues = _gather(pipelines)
assert len(entries) == 1
assert entries[0]['address'] == 'ns=2;s=A'
assert entries[0]['frequency'] == 5000
assert entries[0]['names'] == [_name('cos', 'Contador', 'm2'), _name('sin', 'Counter', 'm1')]
assert issues == {'tags': set(), 'connectors': set(), 'servers': set(), 'invalid': set()}
def test_gather_read_tags_same_name_in_two_pipelines():
"""The same name read by two pipelines still goes to both collections"""
pipelines = [
scouter_pipeline_doc('sin', 'm1', [legacy_tag('ns=2;s=A', 'Counter')]),
scouter_pipeline_doc('cos', 'm2', [legacy_tag('ns=2;s=A', 'Counter')]),
]
entries, _issues = _gather(pipelines)
assert entries[0]['names'] == [_name('cos', 'Counter', 'm2'), _name('sin', 'Counter', 'm1')]
def test_gather_read_tags_duplicate_tag_in_one_pipeline():
pipelines = [
scouter_pipeline_doc(
'sin', 'm1', [legacy_tag('ns=2;s=A', 'a'), legacy_tag('ns=2;s=A', 'a')]
),
]
entries, _issues = _gather(pipelines)
assert entries[0]['names'] == [_name('sin', 'a', 'm1')]
def test_gather_read_tags_same_address_on_two_servers():
pipelines = [
scouter_pipeline_doc(
'sin',
'm1',
[legacy_tag('ns=2;s=A', 'a', server_id='2'), legacy_tag('ns=2;s=A', 'a')],
),
]
entries, _issues = _gather(pipelines)
assert [(e['server']['server_id'], e['address']) for e in entries] == [
('1', 'ns=2;s=A'),
('2', 'ns=2;s=A'),
]
def test_gather_read_tags_legacy_server():
"""Without tag_id: opc-servers fields, no certificate paths, security from cert_path"""
entries, _issues = _gather([scouter_pipeline_doc('sin', 'm1', [legacy_tag('ns=2;s=A', 'a')])])
assert entries[0]['server'] == {
'server_id': '1',
'name': 'kepserver',
'url': 'opc.tcp://kep:49320',
'server_uri': 'urn:kep',
'use_security': True,
'subscription_period_ms': None,
'from_tag_database': False,
'key': 'kepserver',
}
def test_gather_read_tags_numeric_server_id():
"""A numeric server_id in the pipeline still finds its opc-servers document"""
entries, issues = _gather(
[scouter_pipeline_doc('sin', 'm1', [legacy_tag('ns=2;s=A', 'a', server_id=1)])]
)
assert entries[0]['server']['server_id'] == '1'
assert not issues['servers']
def test_gather_read_tags_with_tag_database():
"""With tag_id: address, name and server come from connector_tags + connectors"""
entries, _issues = _gather(
[scouter_pipeline_doc('sin', 'm1', [{'tag_id': 7, 'frequency': 1000}])]
)
assert entries == [
{
'server': {
'server_id': '3',
'name': 'plant',
'url': 'opc.tcp://plant:4840',
'server_uri': 'urn:plant',
'use_security': True,
'subscription_period_ms': 1500,
'from_tag_database': True,
'key': 'plant',
},
'address': 'ns=3;i=1001',
'frequency': 1000,
'active_read': {'enabled': False},
'names': [_name('sin', 'Pressure', 'm1')],
}
]
def test_gather_read_tags_pipeline_name_wins_over_tag_database():
entries, _issues = _gather(
[scouter_pipeline_doc('sin', 'm1', [{'tag_id': 7, 'tag_name': 'P1', 'frequency': 1000}])]
)
assert entries[0]['names'] == [_name('sin', 'P1', 'm1')]
def test_gather_read_tags_unknown_tag_id():
entries, issues = _gather(
[scouter_pipeline_doc('sin', 'm1', [{'tag_id': 404, 'frequency': 1000}])]
)
assert entries == []
assert issues == {'tags': {'404'}, 'connectors': set(), 'servers': set(), 'invalid': set()}
def test_gather_read_tags_inactive_connector():
"""Connector 5 is not among the active connectors: its tag is left out"""
entries, issues = _gather(
[scouter_pipeline_doc('sin', 'm1', [{'tag_id': 8, 'frequency': 1000}])]
)
assert entries == []
assert issues == {'tags': set(), 'connectors': {'5'}, 'servers': set(), 'invalid': set()}
def test_gather_read_tags_unknown_server():
entries, issues = _gather(
[scouter_pipeline_doc('sin', 'm1', [legacy_tag('ns=2;s=A', 'a', server_id='9')])]
)
assert entries == []
assert issues == {'tags': set(), 'connectors': set(), 'servers': {'9'}, 'invalid': set()}
def test_gather_read_tags_mixed_with_and_without_tag_id():
"""Both paths in one run; the same node via tag_id and via tag_address merges"""
connector_1 = {
'connector_id': 1,
'name': 'kepserver',
'url': 'opc.tcp://kep:49320',
'server_uri': 'urn:kep',
'use_security': True,
'subscription_period_ms': 2000,
}
tag_index = {
'20': {'tag_id': 20, 'connector_id': 1, 'address': 'ns=2;s=A', 'tag_name': 'A from db'},
}
pipelines = [
scouter_pipeline_doc(
'sin', 'm1', [legacy_tag('ns=2;s=A', 'a'), legacy_tag('ns=2;s=B', 'b')]
),
scouter_pipeline_doc(
'cos', 'm2', [{'tag_id': 20, 'frequency': 500}, {'tag_id': 7, 'frequency': 1000}]
),
]
entries, issues = _gather(
pipelines,
connector_index={**connectors, '1': connector_1},
tag_index={**TAGS_BY_ID, **tag_index},
)
assert [(e['server']['server_id'], e['address']) for e in entries] == [
('1', 'ns=2;s=A'),
('1', 'ns=2;s=B'),
('3', 'ns=3;i=1001'),
]
assert entries[0]['names'] == [_name('cos', 'A from db', 'm2'), _name('sin', 'a', 'm1')]
assert entries[0]['frequency'] == 500
# Same server_id in connectors and opc-servers: the connectors fields win
assert entries[0]['server']['subscription_period_ms'] == 2000
assert entries[1]['server'] is entries[0]['server']
assert issues == {'tags': set(), 'connectors': set(), 'servers': set(), 'invalid': set()}
def test_gather_read_tags_server_name_clash():
"""Two servers with the same name get distinct slot keys, stable by server_id"""
servers = {
'1': opc_servers['1'],
'2': {**opc_servers['2'], 'server_name': 'kepserver'},
}
pipelines = [
scouter_pipeline_doc(
'sin',
'm1',
[legacy_tag('ns=2;s=A', 'a', server_id='2'), legacy_tag('ns=2;s=B', 'b')],
),
]
entries, _issues = _gather(pipelines, servers=servers)
assert [e['server']['key'] for e in entries] == ['kepserver', 'kepserver:2']
assert [e['server']['name'] for e in entries] == ['kepserver', 'kepserver']
def test_gather_read_tags_active_read():
"""Active read: on if any pipeline asks, with the lowest cycle_multiple (D3)"""
pipelines = [
scouter_pipeline_doc('p1', 'm1', [legacy_tag('ns=2;s=A', 'a')]),
scouter_pipeline_doc(
'p2',
'm2',
[legacy_tag('ns=2;s=A', 'a', active_read={'enabled': True, 'cycle_multiple': 4})],
),
scouter_pipeline_doc(
'p3',
'm3',
[legacy_tag('ns=2;s=A', 'a', active_read={'enabled': True, 'cycle_multiple': 2})],
),
scouter_pipeline_doc(
'p4',
'm4',
[legacy_tag('ns=2;s=B', 'b', active_read={'enabled': False, 'cycle_multiple': 1})],
),
scouter_pipeline_doc(
'p5', 'm5', [legacy_tag('ns=2;s=C', 'c', active_read={'enabled': True})]
),
]
entries, _issues = _gather(pipelines)
assert [e['active_read'] for e in entries] == [
{'enabled': True, 'cycle_multiple': 2},
{'enabled': False},
{'enabled': True, 'cycle_multiple': 1},
]
def test_gather_read_tags_filters_non_scouter_workflows():
"""Only scouter pipelines feed the slots"""
pipelines = [
scouter_pipeline_doc('sin', 'm1', [legacy_tag('ns=2;s=A', 'a')]),
scouter_pipeline_doc(
'pred', 'm2', [legacy_tag('ns=2;s=P', 'p')], workflow_type='predictions_batch'
),
scouter_pipeline_doc(
'retrain', 'm3', [legacy_tag('ns=2;s=R', 'r')], workflow_type='minimal_retrain'
),
scouter_pipeline_doc('drift', 'm4', [legacy_tag('ns=2;s=D', 'd')], workflow_type='drift'),
]
entries, _issues = _gather(pipelines)
assert [e['address'] for e in entries] == ['ns=2;s=A']
def test_gather_read_tags_without_read_tags():
pipeline = scouter_pipeline_doc('sin', 'm1', [])
del pipeline['read_tags']
assert _gather([pipeline]) == (
[],
{'tags': set(), 'connectors': set(), 'servers': set(), 'invalid': set()},
)
def test_build_tag_config():
"""Slot v2 (contract C1): no certificate paths, subscription period per server"""
pipelines = [
scouter_pipeline_doc(
'sin',
'm1',
[
legacy_tag('ns=2;s=A', 'a', frequency=1000),
legacy_tag('ns=2;s=B', 'b', frequency='300'),
legacy_tag('ns=2;s=C', 'c', frequency=200, server_id='2'),
{'tag_id': 7, 'frequency': 100},
],
)
]
entries, _issues = _gather(pipelines)
result = build_tag_config(entries)
assert result == {
'kepserver': {
'server_id': '1',
'name': 'kepserver',
'url': 'opc.tcp://kep:49320',
'server_uri': 'urn:kep',
'use_security': True,
'subscription_period_ms': 150,
'tags': {
'ns=2;s=A': {
'frequency': 1000,
'active_read': {'enabled': False},
'names': [_name('sin', 'a', 'm1')],
},
'ns=2;s=B': {
'frequency': 300,
'active_read': {'enabled': False},
'names': [_name('sin', 'b', 'm1')],
},
},
},
'simulator': {
'server_id': '2',
'name': 'simulator',
'url': 'opc.tcp://sim:4840',
'server_uri': 'urn:sim',
'use_security': False,
'subscription_period_ms': 100,
'tags': {
'ns=2;s=C': {
'frequency': 200,
'active_read': {'enabled': False},
'names': [_name('sin', 'c', 'm1')],
},
},
},
'plant': {
'server_id': '3',
'name': 'plant',
'url': 'opc.tcp://plant:4840',
'server_uri': 'urn:plant',
'use_security': True,
'subscription_period_ms': 1500,
'tags': {
'ns=3;i=1001': {
'frequency': 100,
'active_read': {'enabled': False},
'names': [_name('sin', 'Pressure', 'm1')],
},
},
},
}
def test_build_tag_config_empty():
assert build_tag_config([]) == {}
def test_gather_read_tags_empty_tag_id_keeps_legacy_fields():
"""tag_id '' is no tag database reference: the current fields are used (P4)"""
entries, issues = _gather(
[scouter_pipeline_doc('sin', 'm1', [legacy_tag('ns=2;s=A', 'a', tag_id='')])]
)
assert [(e['server']['server_id'], e['address']) for e in entries] == [('1', 'ns=2;s=A')]
assert issues == {'tags': set(), 'connectors': set(), 'servers': set(), 'invalid': set()}
def test_gather_read_tags_invalid_read_tags():
"""Unusable read tags are reported and skipped; the others still make the slot"""
pipelines = [
scouter_pipeline_doc(
'sin',
'm1',
[
legacy_tag('ns=2;s=A', 'a'),
{'server_id': '1', 'tag_address': 'ns=2;s=B', 'tag_name': 'b'},
legacy_tag('ns=2;s=C', 'c', frequency='fast'),
{'server_id': '1', 'tag_address': 'ns=2;s=D', 'frequency': 1000},
{'tag_address': 'ns=2;s=E', 'tag_name': 'e', 'frequency': 1000},
{'tag_id': 7},
],
)
]
entries, issues = _gather(pipelines)
assert [e['address'] for e in entries] == ['ns=2;s=A']
assert issues['invalid'] == {
'sin/7',
'sin/ns=2;s=B',
'sin/ns=2;s=C',
'sin/ns=2;s=D',
'sin/ns=2;s=E',
}
def test_gather_read_tags_frequency_type_does_not_depend_on_order():
"""15000 and '15000' give the same int frequency in any pipeline order (O6)"""
as_int = scouter_pipeline_doc('sin', 'm1', [legacy_tag('ns=2;s=A', 'a', frequency=15000)])
as_text = scouter_pipeline_doc('cos', 'm2', [legacy_tag('ns=2;s=A', 'a', frequency='15000')])
first, _ = _gather([as_int, as_text])
second, _ = _gather([as_text, as_int])
assert first[0]['frequency'] == second[0]['frequency'] == 15000
assert isinstance(first[0]['frequency'], int)
assert isinstance(second[0]['frequency'], int)
def test_gather_read_tags_server_key_never_collides():
"""A server literally named 'x:3' does not share its key with server 3"""
servers = {
'1': {**opc_servers['1'], 'server_name': 'x:3'},
'2': {**opc_servers['2'], 'server_name': 'x'},
'3': {'id': '3', 'server_name': 'x', 'url': 'opc.tcp://three:4840', 'uri': 'urn:3'},
}
pipelines = [
scouter_pipeline_doc(
'sin',
'm1',
[
legacy_tag('ns=2;s=A', 'a', server_id='1'),
legacy_tag('ns=2;s=B', 'b', server_id='2'),
legacy_tag('ns=2;s=C', 'c', server_id='3'),
],
),
]
entries, _issues = _gather(pipelines, servers=servers)
slot = build_tag_config(entries)
keys = [e['server']['key'] for e in entries]
assert len(set(keys)) == 3
assert {server['server_id'] for server in slot.values()} == {'1', '2', '3'}
assert all(len(server['tags']) == 1 for server in slot.values())

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,778 @@
from unittest.mock import call, patch
from pytest import mark
from orchestrator.utils.pipeline_configs import (
base_scouter,
chunk_period_warning,
common_config,
drift,
frequency_alert_warning,
minimal_retrain,
overlap_filter_config,
pi_web_api_scouter,
predictions_batch,
process_path_priority,
scouter,
simple_metrics,
)
from tests.orchestrator.utils.tag_database_fixtures import (
PI_SECRET,
pi_connector_doc,
pi_scouter_pipeline_doc,
)
def test_common_config():
config = {
'workflow_type': 'scouter',
'schedule_name': 'test_schedule',
'model_id': 'test_model_id',
'model': {'name': 'test_model_name', 'model_config': {'test_config': 'test_config'}},
}
result = common_config(config)
expected = {
'workflow_type': 'scouter',
'schedule_name': 'test_schedule',
'frequency': '1m',
'offset': '0m',
'max_retry_policy': 1,
'model_id': 'test_model_id',
'model_name': 'test_model_name',
'model_config': {'test_config': 'test_config'},
'on_conflict': 'error',
'execution_timeout_seconds': 300,
'task_timeout_seconds': 300,
'runtime': 'legacy',
}
assert result == expected
def test_common_config_preserves_explicit_runtime():
config = {
'workflow_type': 'scouter',
'schedule_name': 'test_schedule',
'model_id': 'test_model_id',
'model': {'name': 'test_model_name'},
'runtime': 'tenant-x',
}
assert common_config(config)['runtime'] == 'tenant-x'
def test_drift():
config = {
'workflow_type': 'drift',
'schedule_name': 'test_schedule',
'model_id': 'test_model_id',
'model': {'name': 'test_model_name', 'model_config': {'test_config': 'test_config'}},
'interval_minutes': 120,
'drift_metrics': ['kolmogorov_smirnov', 'jensen_shannon'],
}
result = drift(config)
expected = {
'workflow_type': 'drift',
'schedule_name': 'test_schedule',
'frequency': '1m',
'offset': '0m',
'max_retry_policy': 1,
'model_id': 'test_model_id',
'model_name': 'test_model_name',
'model_config': {'test_config': 'test_config'},
'on_conflict': 'error',
'schema': 'sientia_data',
'source_table_name': 'laborious_data',
'target_table_name': 'drift_metrics',
'interval': 120,
'chunk_period': 'min',
'drift_metrics': ['kolmogorov_smirnov', 'jensen_shannon'],
'execution_timeout_seconds': 300,
'task_timeout_seconds': 300,
'runtime': 'legacy',
}
assert result == expected
def _drift_config(**fields):
return {
'workflow_type': 'drift',
'schedule_name': 'm1-drift',
'model_id': 'm1',
'model': {'name': 'm1'},
**fields,
}
def test_drift_default_window_is_frequency():
result = drift(_drift_config())
assert result['frequency'] == '1m'
assert result['interval'] == 1
@mark.parametrize(
('frequency', 'interval'),
[('1h', 60), ('30s', 1), ('90s', 2)],
)
def test_drift_window_derived_from_frequency(frequency, interval):
assert drift(_drift_config(frequency=frequency))['interval'] == interval
def test_drift_explicit_interval_minutes_wins():
result = drift(_drift_config(frequency='1m', interval_minutes=60))
assert result['interval'] == 60
def test_drift_invalid_frequency_falls_back_to_legacy_window():
result = drift(_drift_config(frequency='abc'))
assert result['frequency'] == 'abc'
assert result['interval'] == 60
def test_drift_chunk_period_defaults_to_min():
result = drift(_drift_config())
assert result['chunk_period'] == 'min'
assert 'chunk_period_configured' not in result
def test_drift_chunk_period_seconds_kept():
result = drift(_drift_config(chunk_period='s'))
assert result['chunk_period'] == 's'
assert 'chunk_period_configured' not in result
@mark.parametrize('chunk_period', ['hour', 5])
def test_drift_invalid_chunk_period_falls_back_to_min(chunk_period):
result = drift(_drift_config(chunk_period=chunk_period))
assert result['chunk_period'] == 'min'
assert result['chunk_period_configured'] == chunk_period
@mark.parametrize('chunk_period', [None, 'min', 's'])
def test_chunk_period_warning_none_when_absent_or_valid(chunk_period):
assert chunk_period_warning(chunk_period) is None
@mark.parametrize('chunk_period', ['hour', 5])
def test_chunk_period_warning_message_when_invalid(chunk_period):
warning = chunk_period_warning(chunk_period)
assert warning is not None
assert repr(chunk_period) in warning
def test_simple_metrics():
config = {
'workflow_type': 'simple_metrics',
'schedule_name': 'test_schedule',
'model_id': 'test_model_id',
'model': {'name': 'test_model_name', 'model_config': {'test_config': 'test_config'}},
'interval_minutes': 120,
'metrics': ['rmse', 'mse'],
}
result = simple_metrics(config)
expected = {
'workflow_type': 'simple_metrics',
'schedule_name': 'test_schedule',
'frequency': '1m',
'offset': '0m',
'max_retry_policy': 1,
'model_id': 'test_model_id',
'model_name': 'test_model_name',
'model_config': {'test_config': 'test_config'},
'on_conflict': 'error',
'schema': 'sientia_data',
'predictions_table_name': 'predictions',
'data_table_name': 'laborious_data',
'target_table_name': 'simple_metrics',
'interval_minutes': 120,
'metrics': ['rmse', 'mse'],
'frequency_alert': None,
'execution_timeout_seconds': 300,
'task_timeout_seconds': 300,
'runtime': 'legacy',
}
assert result == expected
def _simple_metrics_config(**fields):
return {
'workflow_type': 'simple_metrics',
'schedule_name': 'm1-simple_metrics-60m',
'model_id': 'm1',
'model': {'name': 'm1'},
'frequency': '1h',
**fields,
}
def test_simple_metrics_escalated_uses_frequency_alert():
config = _simple_metrics_config(
frequency_alert='5m', adaptive_state={'escalated': True, 'healthy_streak': 0}
)
result = simple_metrics(config)
assert result['frequency'] == '5m'
assert result['frequency_alert'] == '5m'
def test_simple_metrics_not_escalated_keeps_frequency():
config = _simple_metrics_config(
frequency_alert='5m', adaptive_state={'escalated': False, 'healthy_streak': 2}
)
assert simple_metrics(config)['frequency'] == '1h'
def test_simple_metrics_escalated_without_frequency_alert_keeps_frequency():
# Removing frequency_alert turns adaptive mode off even if a stale state says escalated.
config = _simple_metrics_config(adaptive_state={'escalated': True, 'healthy_streak': 0})
assert simple_metrics(config)['frequency'] == '1h'
def test_simple_metrics_frequency_alert_without_state_keeps_frequency():
assert simple_metrics(_simple_metrics_config(frequency_alert='5m'))['frequency'] == '1h'
def test_simple_metrics_escalated_unparseable_frequency_alert_keeps_frequency():
# An unparseable frequency would make the Temporal schedule create/update fail.
config = _simple_metrics_config(frequency_alert='5min', adaptive_state={'escalated': True})
assert simple_metrics(config)['frequency'] == '1h'
def test_simple_metrics_escalated_below_floor_frequency_alert_is_applied():
# Decision 7: below the floor only warns, the value is kept.
config = _simple_metrics_config(frequency_alert='30s', adaptive_state={'escalated': True})
assert simple_metrics(config)['frequency'] == '30s'
@mark.parametrize('adaptive_state', ['yes', {'escalated': 'false'}])
def test_simple_metrics_malformed_adaptive_state_keeps_frequency(adaptive_state):
config = _simple_metrics_config(frequency_alert='5m', adaptive_state=adaptive_state)
assert simple_metrics(config)['frequency'] == '1h'
def test_minimal_retrain():
config = {
'workflow_type': 'minimal_retrain',
'schedule_name': 'test_schedule',
'model_id': 'test_model_id',
'model': {'name': 'test_model_name', 'model_config': {'test_config': 'test_config'}},
'query': 'select * from sientia_data.laborious_data order by "timestamp" desc limit 30;',
'datetime_columns': ['timestamp'],
}
result = minimal_retrain(config)
expected = {
'workflow_type': 'minimal_retrain',
'schedule_name': 'test_schedule',
'frequency': '1m',
'offset': '0m',
'max_retry_policy': 1,
'model_id': 'test_model_id',
'model_name': 'test_model_name',
'model_config': {'test_config': 'test_config'},
'on_conflict': 'error',
'query': 'select * from sientia_data.laborious_data order by "timestamp" desc limit 30;',
'schema': 'sientia_data',
'table_name': 'log_retrain',
'datetime_columns': ['timestamp'],
'execution_timeout_seconds': 300,
'task_timeout_seconds': 300,
'runtime': 'legacy',
}
assert result == expected
def test_scouter():
config = {
'workflow_type': 'scouter',
'schedule_name': 'test_schedule',
'model_id': 'test_model_id',
'model': {'name': 'test_model_name', 'model_config': {'test_config': 'test_config'}},
'filters': [{'filter_name': 'test_filter_name', 'policy': 'test_policy'}],
'read_tags': [
{'tag_name': 'test_tag_name', 'aggr_func': 'test_aggr_func', 'data_range': [1, 2]}
],
'tag_retention_minutes': 10,
'execution_timeout_seconds': 300,
'task_timeout_seconds': 300,
'runtime': 'legacy',
}
result = scouter(config)
expected = {
'workflow_type': 'scouter',
'schedule_name': 'test_schedule',
'frequency': '1m',
'offset': '0m',
'max_retry_policy': 1,
'model_id': 'test_model_id',
'model_name': 'test_model_name',
'model_config': {'test_config': 'test_config'},
'on_conflict': 'error',
'topic': 'raw_test_schedule',
'trigger_laborious': False,
'filters': {'test_filter_name': {'policy': 'test_policy'}},
'schema': 'sientia_data',
'table_name': 'laborious_data',
'retention_time': 10 * 60,
'model_tags': {'test_tag_name': {'aggr_func': 'test_aggr_func', 'data_range': [1, 2]}},
'debug_data_package': False,
'execution_timeout_seconds': 300,
'task_timeout_seconds': 300,
'fill_missing_tags': False,
'runtime': 'legacy',
}
assert result == expected
def test_overlap_filter_config():
config = [
{'filter_name': 'test_filter_name', 'policy': 'test_policy'},
{'filter_name': 'test_filter_name2', 'policy': 'test_policy2'},
]
result = overlap_filter_config({'test_filter_name': {'policy': 'test_policy'}}, config)
expected = {
'test_filter_name': {'policy': 'test_policy', 'config': {}},
'test_filter_name2': {'policy': 'test_policy2', 'config': {}},
}
assert result == expected
def test_process_path_priority():
config = ['OTHER', 'STOP', 'CONTINUE']
result = process_path_priority(config)
expected = ['STOP', 'CONTINUE', 'REPEAT']
assert result == expected
@patch(
'orchestrator.utils.pipeline_configs.overlap_filter_config',
return_value={'test_filter_name': {'policy': 'test_policy', 'config': {}}},
)
@patch(
'orchestrator.utils.pipeline_configs.process_path_priority',
return_value=['STOP', 'CONTINUE', 'REPEAT'],
)
def test_predictions_batch(mock_process_path_priority, mock_overlap_filter_config):
config = {
'schedule_name': 'test_schedule',
'workflow_type': 'predictions_batch',
'model_id': 'test_model_id',
'model': {'name': 'test_model_name', 'model_config': {'test_config': 'test_config'}},
'query': 'test_query',
'write_tags': [
{'server_id': 'test_server_id', 'type': 'prediction', 'addr': 'test_addr'},
{'server_id': 'test_server_id', 'type': 'confidence', 'addr': 'test_addr'},
],
'pi_web_api_output_config': {
'endpoint': 'test_endpoint',
'prediction_tags': {
'tag_1': 'webid_1',
},
'confidence_tags': {
'tag_2': 'webid_2',
},
},
'input_filters': [{'filter_name': 'test_filter_name', 'policy': 'test_policy'}],
'mlflow_transform_filters': [{'filter_name': 'test_filter_name', 'policy': 'test_policy'}],
'mlflow_predict_filters': [{'filter_name': 'test_filter_name', 'policy': 'test_policy'}],
'path_priority': ['STOP', 'CONTINUE', 'REPEAT'],
'datetime_columns': ['timestamp'],
'predictions_storage_policy': 'erl:1',
}
result = predictions_batch(config)
mock_overlap_filter_config.assert_has_calls(
[call({'EMPTY_DATA': {'policy': 'STOP', 'config': {}}}, config['input_filters'])]
)
mock_overlap_filter_config.assert_has_calls(
[
call(
{
'EMPTY_DATA': {'policy': 'STOP', 'config': {}},
'API_ERROR': {'policy': 'STOP', 'config': {}},
},
config['mlflow_transform_filters'],
)
]
)
mock_overlap_filter_config.assert_has_calls(
[call({'API_ERROR': {'policy': 'STOP', 'config': {}}}, config['mlflow_predict_filters'])]
)
mock_process_path_priority.assert_called_once_with(config['path_priority'])
expected = {
'workflow_type': 'predictions_batch',
'schedule_name': 'test_schedule',
'frequency': '1m',
'offset': '0m',
'max_retry_policy': 1,
'model_id': 'test_model_id',
'model_name': 'test_model_name',
'model_config': {'test_config': 'test_config'},
'on_conflict': 'error',
'query': 'test_query',
'schema': 'sientia_data',
'table_name': 'predictions',
'save_transform': True,
'transform_table_name': 'transformed_data',
'retention_time': 60 * 60,
'opc_output_config': {
'test_server_id': {
'prediction_tags': {'test_addr': {'data_type': 'float'}},
'confidence_tags': {'test_addr': {'data_type': 'float'}},
}
},
'pi_web_api_output_config': {
'endpoint': 'test_endpoint',
'prediction_tags': {
'tag_1': 'webid_1',
},
'confidence_tags': {
'tag_2': 'webid_2',
},
},
'input_filters': {'test_filter_name': {'policy': 'test_policy', 'config': {}}},
'mlflow_transform_filters': {'test_filter_name': {'policy': 'test_policy', 'config': {}}},
'mlflow_predict_filters': {'test_filter_name': {'policy': 'test_policy', 'config': {}}},
'path_priority': ['STOP', 'CONTINUE', 'REPEAT'],
'datetime_columns': ['timestamp'],
'predictions_storage_policy': 'erl:1',
'execution_timeout_seconds': 300,
'task_timeout_seconds': 300,
'runtime': 'legacy',
}
assert result == expected
def test_scouter_skips_tags_without_name():
"""A tag_id the tag database cannot resolve has no name and stays out of model_tags"""
config = {
'workflow_type': 'scouter',
'schedule_name': 'sin',
'model_id': 'm1',
'model': {'name': 'model m1'},
'read_tags': [{'tag_name': 'a'}, {'tag_id': 404}],
}
assert list(scouter(config)['model_tags']) == ['a']
def test_base_scouter():
config = {
'workflow_type': 'scouter',
'schedule_name': 'test_schedule',
'model_id': 'test_model_id',
'model': {'name': 'test_model_name', 'model_config': {'test_config': 'test_config'}},
'filters': [
{'filter_name': 'test_filter_name', 'policy': 'test_policy'},
{'filter_name': 'test_filter_name2', 'policy': 'test_policy2'},
],
'tag_retention_minutes': 30,
'debug_data_package': True,
'fill_missing_tags': True,
}
result = base_scouter(config)
expected = {
'workflow_type': 'scouter',
'schedule_name': 'test_schedule',
'frequency': '1m',
'offset': '0m',
'max_retry_policy': 1,
'model_id': 'test_model_id',
'model_name': 'test_model_name',
'model_config': {'test_config': 'test_config'},
'on_conflict': 'error',
'trigger_laborious': False,
'filters': {
'test_filter_name': {'policy': 'test_policy'},
'test_filter_name2': {'policy': 'test_policy2'},
},
'schema': 'sientia_data',
'table_name': 'laborious_data',
'retention_time': 30 * 60,
'debug_data_package': True,
'execution_timeout_seconds': 300,
'task_timeout_seconds': 300,
'fill_missing_tags': True,
'runtime': 'legacy',
}
assert result == expected
def test_pi_web_api_scouter():
config = {
'workflow_type': 'scouter',
'schedule_name': 'test_schedule',
'model_id': 'test_model_id',
'model': {'name': 'test_model_name', 'model_config': {'test_config': 'test_config'}},
'filters': [{'filter_name': 'test_filter_name', 'policy': 'test_policy'}],
'read_tags': {
'test_tag_name': {
'webid': 'test_webid',
'aggr_func': 'test_aggr_func',
'data_range': [1, 2],
}
},
'tag_retention_minutes': 10,
'pi_web_api_config': {
'endpoint': 'https://test-endpoint.com',
'period': '*-2d',
'max_count': 5,
'api_timeout': 30,
},
'frequency': '1m',
}
result = pi_web_api_scouter(config)
expected = {
'workflow_type': 'scouter',
'schedule_name': 'test_schedule',
'frequency': '1m',
'offset': '0m',
'max_retry_policy': 1,
'model_id': 'test_model_id',
'model_name': 'test_model_name',
'model_config': {'test_config': 'test_config'},
'on_conflict': 'error',
'trigger_laborious': False,
'filters': {'test_filter_name': {'policy': 'test_policy'}},
'schema': 'sientia_data',
'table_name': 'laborious_data',
'retention_time': 10 * 60,
'model_tags': {
'test_tag_name': {
'webid': 'test_webid',
'aggr_func': 'test_aggr_func',
'data_range': [1, 2],
}
},
'debug_data_package': False,
'execution_timeout_seconds': 300,
'task_timeout_seconds': 300,
'fill_missing_tags': False,
'pi_web_api_query': {
'endpoint': 'https://test-endpoint.com',
'period': '*-2d',
'max_count': 5,
'api_timeout': 30,
},
'runtime': 'legacy',
}
assert result == expected
def test_pi_web_api_scouter_with_timeout_greater_than_frequency():
config = {
'workflow_type': 'scouter',
'schedule_name': 'test_schedule',
'model_id': 'test_model_id',
'model': {'name': 'test_model_name', 'model_config': {'test_config': 'test_config'}},
'filters': [],
'read_tags': {
'test_tag_name': {
'webid': 'test_webid',
}
},
'tag_retention_minutes': 10,
'pi_web_api_config': {
'endpoint': 'https://test-endpoint.com',
'api_timeout': 120,
},
'frequency': '1m',
}
result = pi_web_api_scouter(config)
expected = {
'workflow_type': 'scouter',
'schedule_name': 'test_schedule',
'frequency': '1m',
'offset': '0m',
'max_retry_policy': 1,
'model_id': 'test_model_id',
'model_name': 'test_model_name',
'model_config': {'test_config': 'test_config'},
'on_conflict': 'error',
'trigger_laborious': False,
'filters': {},
'schema': 'sientia_data',
'table_name': 'laborious_data',
'retention_time': 10 * 60,
'model_tags': {
'test_tag_name': {'webid': 'test_webid', 'aggr_func': 'lts', 'data_range': [-100, 100]}
},
'debug_data_package': False,
'execution_timeout_seconds': 300,
'task_timeout_seconds': 300,
'fill_missing_tags': False,
'pi_web_api_query': {
'endpoint': 'https://test-endpoint.com',
'period': '*-1d',
'max_count': 1,
'api_timeout': 60,
},
'runtime': 'legacy',
}
assert result == expected
def test_pi_web_api_scouter_with_no_timeout():
config = {
'workflow_type': 'scouter',
'schedule_name': 'test_schedule',
'model_id': 'test_model_id',
'model': {'name': 'test_model_name', 'model_config': {'test_config': 'test_config'}},
'filters': [],
'read_tags': {
'test_tag_name': {
'webid': 'test_webid',
}
},
'tag_retention_minutes': 10,
'pi_web_api_config': {
'endpoint': 'https://test-endpoint.com',
},
'frequency': '30s',
}
result = pi_web_api_scouter(config)
expected = {
'workflow_type': 'scouter',
'schedule_name': 'test_schedule',
'frequency': '30s',
'offset': '0m',
'max_retry_policy': 1,
'model_id': 'test_model_id',
'model_name': 'test_model_name',
'model_config': {'test_config': 'test_config'},
'on_conflict': 'error',
'trigger_laborious': False,
'filters': {},
'schema': 'sientia_data',
'table_name': 'laborious_data',
'retention_time': 10 * 60,
'model_tags': {
'test_tag_name': {'webid': 'test_webid', 'aggr_func': 'lts', 'data_range': [-100, 100]}
},
'debug_data_package': False,
'execution_timeout_seconds': 300,
'task_timeout_seconds': 300,
'fill_missing_tags': False,
'pi_web_api_query': {
'endpoint': 'https://test-endpoint.com',
'period': '*-1d',
'max_count': 1,
'api_timeout': 30,
},
'runtime': 'legacy',
}
assert result == expected
@mark.parametrize(
'frequency_alert, warns',
[
(None, False),
('5m', False),
('1m', False), # the floor itself is allowed
('60s', False),
('30s', True),
('5min', True), # unparseable
(5, True), # not a string: must warn, never raise
],
)
def test_frequency_alert_warning(frequency_alert, warns):
assert (frequency_alert_warning(frequency_alert) is not None) is warns
def test_pi_web_api_scouter_legacy_shape():
"""Without a connector the output is exactly the pre-tag-database one"""
config = pi_scouter_pipeline_doc(
{'TI-101': {'webid': 'F1DP-X'}},
pi_web_api_config={'endpoint': '/streamsets/recorded', 'max_count': 5, 'api_timeout': 10},
)
result = pi_web_api_scouter(config)
assert 'connection' not in result
assert result['model_tags'] == {
'TI-101': {'webid': 'F1DP-X', 'aggr_func': 'lts', 'data_range': [-100, 100]}
}
assert result['pi_web_api_query'] == {
'endpoint': '/streamsets/recorded',
'period': '*-1d',
'max_count': 5,
'api_timeout': 10,
}
def test_pi_web_api_scouter_connector_defaults_with_pipeline_override():
connector = pi_connector_doc(
period='*-2h', max_count=10, api_timeout=600, data_server_webid='DS1'
)
config = pi_scouter_pipeline_doc(
{'TI-101': {'tag_id': 42, 'webid': 'F1DP-A', 'aggr_func': 'avg'}},
pi_web_api_config={'endpoint': '/streamsets/recorded', 'max_count': 5},
pi_web_api_connector=connector,
)
result = pi_web_api_scouter(config)
assert result['model_tags'] == {
'TI-101': {'webid': 'F1DP-A', 'aggr_func': 'avg', 'data_range': [-100, 100]}
}
assert result['pi_web_api_query'] == {
'endpoint': '/streamsets/recorded',
'period': '*-2h',
'max_count': 5,
# The connector default is still capped at the 1m frequency
'api_timeout': 60,
}
assert result['connection'] == {
'connector_id': 'pi-usina',
'base_url': 'https://pi-usina.example/piwebapi',
'auth': {'type': 'bearer', 'secret': PI_SECRET},
'data_server_webid': 'DS1',
}
def test_pi_web_api_scouter_connection_carries_only_the_secret_reference():
connector = pi_connector_doc()
connector['pi_web_api']['auth']['secret']['value'] = 'leaked-credential'
connector['pi_web_api']['auth']['token'] = 'leaked-credential'
config = pi_scouter_pipeline_doc(
{'TI-101': {'tag_id': 42, 'webid': 'F1DP-A'}}, pi_web_api_connector=connector
)
result = pi_web_api_scouter(config)
assert result['connection']['auth'] == {'type': 'bearer', 'secret': PI_SECRET}
assert 'leaked-credential' not in str(result)
def test_predictions_batch_groups_write_tags_by_server_and_type():
config = {
'workflow_type': 'predictions_batch',
'schedule_name': 'batch',
'model_id': 'model_id',
'model': {'name': 'model_name'},
'query': 'SELECT 1',
'write_tags': [
{'server_id': '1', 'type': 'prediction', 'addr': 'ns=2;s=P1'},
{'server_id': '1', 'type': 'prediction', 'addr': 'ns=2;s=P2', 'data_type': 'int'},
# Neither prediction nor confidence: the server is known, the tag is ignored
{'server_id': '2', 'type': 'other', 'addr': 'ns=2;s=O1'},
],
}
result = predictions_batch(config)
assert result['opc_output_config'] == {
'1': {
'prediction_tags': {
'ns=2;s=P1': {'data_type': 'float'},
'ns=2;s=P2': {'data_type': 'int'},
}
},
'2': {},
}

View File

@@ -0,0 +1,291 @@
from orchestrator.utils.tag_database import (
filter_by_tag_ids,
index_connector_tags,
index_connectors,
index_pi_connectors,
referenced_pi_tag_ids,
referenced_tag_ids,
resolve_pi_output_tags,
resolve_pi_read_tags,
resolve_tag_names,
)
from tests.orchestrator.utils.tag_database_fixtures import (
TAGS_BY_ID,
legacy_tag,
pi_connector_doc,
pi_scouter_pipeline_doc,
pi_tag_doc,
scouter_pipeline_doc,
)
def test_index_connector_tags():
tags = [
{'tag_id': 7, 'connector_id': 3, 'address': 'ns=3;i=1', 'tag_name': 'a'},
{'tag_id': '8', 'connector_id': 3, 'address': 'ns=3;i=2', 'tag_name': 'b'},
]
assert index_connector_tags(tags) == {'7': tags[0], '8': tags[1]}
def test_resolve_tag_names():
read_tags = [
{'tag_id': 7},
{'tag_id': '7', 'tag_name': 'own name'},
{'tag_id': 404},
{'tag_name': 'legacy'},
]
assert resolve_tag_names(read_tags, TAGS_BY_ID) == [
{'tag_id': 7, 'tag_name': 'Pressure'},
{'tag_id': '7', 'tag_name': 'own name'},
{'tag_id': 404},
{'tag_name': 'legacy'},
]
def test_referenced_tag_ids():
"""Ids in int and text form, scouter pipelines only, empty or null tag_id ignored"""
pipelines = [
scouter_pipeline_doc(
'sin', 'm1', [{'tag_id': 7}, {'tag_id': '12'}, {'tag_id': ''}, {'tag_id': None}]
),
scouter_pipeline_doc('cos', 'm2', [{'tag_id': 'abc'}, legacy_tag('ns=2;s=A', 'a')]),
scouter_pipeline_doc('pred', 'm3', [{'tag_id': 99}], workflow_type='predictions_batch'),
]
# Sorted by text (ints first), so the query is the same on every run
assert referenced_tag_ids(pipelines) == [12, 7, '12', '7', 'abc']
def test_referenced_tag_ids_without_tag_ids():
pipeline = scouter_pipeline_doc('sin', 'm1', [legacy_tag('ns=2;s=A', 'a')])
no_read_tags = scouter_pipeline_doc('cos', 'm2', [])
del no_read_tags['read_tags']
assert referenced_tag_ids([pipeline, no_read_tags]) == []
def test_filter_by_tag_ids():
"""A tag_id $match goes in front, on a new aggregation list"""
query = {'collection': 'connector_tags', 'aggregation': [{'$project': {'_id': 0}}]}
result = filter_by_tag_ids(query, [7, '7'])
assert result == {
'collection': 'connector_tags',
'aggregation': [{'$match': {'tag_id': {'$in': [7, '7']}}}, {'$project': {'_id': 0}}],
}
assert result['aggregation'] is not query['aggregation']
assert query['aggregation'] == [{'$project': {'_id': 0}}]
def test_index_skips_incomplete_documents():
"""A malformed tag database document is left out instead of failing the run"""
tags = [
{'tag_id': 7, 'connector_id': 3, 'address': 'ns=3;i=1', 'tag_name': 'a'},
{'connector_id': 3, 'address': 'ns=3;i=2', 'tag_name': 'no id'},
{'tag_id': 9, 'connector_id': 3, 'address': '', 'tag_name': 'no address'},
]
connector_list = [
{'connector_id': 3, 'name': 'plant', 'url': 'opc.tcp://p:4840', 'server_uri': 'urn:p'},
{'connector_id': 4, 'name': 'no url', 'server_uri': 'urn:x'},
{'name': 'no id', 'url': 'opc.tcp://x:4840', 'server_uri': 'urn:x'},
]
assert list(index_connector_tags(tags)) == ['7']
assert list(index_connectors(connector_list)) == ['3']
def test_index_pi_connectors_leaves_out_incomplete():
complete = pi_connector_doc()
no_url = {**pi_connector_doc('pi-a'), 'url': ''}
no_auth = {**pi_connector_doc('pi-b'), 'pi_web_api': {}}
inline_token = {
**pi_connector_doc('pi-c'),
'pi_web_api': {'auth': {'type': 'bearer', 'token': 'secret-value'}},
}
no_secret_key = {
**pi_connector_doc('pi-d'),
'pi_web_api': {'auth': {'type': 'basic', 'secret': {'name': 'x'}}},
}
index = index_pi_connectors([complete, no_url, no_auth, inline_token, no_secret_key])
assert index == {'pi-usina': complete}
def test_referenced_pi_tag_ids():
pipelines = [
pi_scouter_pipeline_doc({'TI-101': {'tag_id': 42}, 'TI-102': {'webid': 'F1DP-X'}}),
{
'workflow_type': 'predictions_batch',
'pi_web_api_output_config': {
'prediction_tags': [{'tag_id': '77'}],
'confidence_tags': {'CONF-1': 'F1DP-C'},
},
},
{
'workflow_type': 'xgboost_predictions_batch',
'pi_web_api_output_config': {'confidence_tags': [{'tag_id': 78}]},
},
# OPC scouter tag_ids belong to the OPC loads
{'workflow_type': 'scouter', 'read_tags': [{'tag_id': 7}]},
{'workflow_type': 'drift'},
]
assert referenced_pi_tag_ids(pipelines) == [42, 77, 78, '42', '77', '78']
def test_referenced_pi_tag_ids_none():
assert referenced_pi_tag_ids([pi_scouter_pipeline_doc({'TI-101': {'webid': 'F1DP-X'}})]) == []
def test_resolve_pi_read_tags_by_tag_id():
connectors = index_pi_connectors([pi_connector_doc()])
tags = index_connector_tags([pi_tag_doc(42, 'F1DP-A', 'TI-101-db')])
resolved, connector, issues = resolve_pi_read_tags(
{'TI-101': {'tag_id': 42, 'aggr_func': 'avg'}}, tags, connectors
)
# The key stays the model tag name; only the WebID comes from the tag database
assert resolved == {'TI-101': {'tag_id': 42, 'aggr_func': 'avg', 'webid': 'F1DP-A'}}
assert connector == connectors['pi-usina']
assert not any(issues.values())
def test_resolve_pi_read_tags_legacy_untouched():
legacy = {'TI-101': {'webid': 'F1DP-X'}}
resolved, connector, issues = resolve_pi_read_tags(legacy, {}, {})
assert resolved == legacy
assert resolved['TI-101'] is legacy['TI-101']
assert connector is None
assert not any(issues.values())
def test_resolve_pi_read_tags_issues():
connectors = index_pi_connectors([pi_connector_doc()])
tags = index_connector_tags(
[
pi_tag_doc(42, 'F1DP-A', 'TI-101'),
pi_tag_doc(43, 'F1DP-W', 'PRED-1', category='Write'),
pi_tag_doc(44, 'F1DP-O', 'TI-OFF', connector_id='pi-inactive'),
]
)
resolved, connector, issues = resolve_pi_read_tags(
{
'TI-101': {'tag_id': 42},
'UNKNOWN': {'tag_id': 99},
'WRITE': {'tag_id': 43},
'OFF': {'tag_id': 44},
'LEGACY': {'webid': 'F1DP-X'},
},
tags,
connectors,
)
# Unresolvable tags are left out; the rest of the pipeline is still scheduled
assert set(resolved) == {'TI-101', 'LEGACY'}
assert connector == connectors['pi-usina']
assert issues == {
'tags': {'99'},
'categories': {'43'},
'connectors': {'pi-inactive'},
'mixed': set(),
}
def test_resolve_pi_read_tags_mixed_connectors():
connectors = index_pi_connectors([pi_connector_doc('pi-a'), pi_connector_doc('pi-b')])
tags = index_connector_tags(
[
pi_tag_doc(1, 'F1DP-1', 'A', connector_id='pi-a'),
pi_tag_doc(2, 'F1DP-2', 'B', connector_id='pi-b'),
]
)
_resolved, connector, issues = resolve_pi_read_tags(
{'A': {'tag_id': 1}, 'B': {'tag_id': 2}}, tags, connectors
)
assert connector is None
assert issues['mixed'] == {'pi-a, pi-b'}
def test_resolve_pi_output_tags_by_tag_id():
connectors = index_pi_connectors([pi_connector_doc()])
tags = index_connector_tags(
[
pi_tag_doc(77, 'F1DP-P', 'PRED-101', category='Write'),
pi_tag_doc(78, 'F1DP-C', 'CONF-101', category='Prediction Confidence'),
]
)
resolved, issues = resolve_pi_output_tags(
{
'prediction_tags': [{'tag_id': 77}],
'confidence_tags': [{'tag_id': 78}],
'other': 'kept',
},
tags,
connectors,
)
assert resolved == {
'prediction_tags': {'PRED-101': 'F1DP-P'},
'confidence_tags': {'CONF-101': 'F1DP-C'},
'other': 'kept',
}
assert not any(issues.values())
def test_resolve_pi_output_tags_legacy_untouched():
legacy = {'prediction_tags': {'PRED-101': 'F1DP-P'}, 'confidence_tags': {}}
resolved, issues = resolve_pi_output_tags(legacy, {}, {})
assert resolved == legacy
assert resolved['prediction_tags'] is legacy['prediction_tags']
assert not any(issues.values())
def test_resolve_pi_output_tags_issues():
connectors = index_pi_connectors([pi_connector_doc()])
tags = index_connector_tags(
[
pi_tag_doc(42, 'F1DP-R', 'TI-101', category='Read'),
pi_tag_doc(77, 'F1DP-P', 'PRED-101', category='Write'),
]
)
resolved, issues = resolve_pi_output_tags(
{
# A Read tag as prediction, a Write tag as confidence, an unknown tag_id
'prediction_tags': [{'tag_id': 42}, {'tag_id': 99}, {'PRED-LEGACY': 'F1DP-L'}],
'confidence_tags': [{'tag_id': 77}],
},
tags,
connectors,
)
assert resolved['prediction_tags'] == {'PRED-LEGACY': 'F1DP-L'}
assert resolved['confidence_tags'] == {}
assert issues['tags'] == {'99'}
assert issues['categories'] == {'42', '77'}
def test_referenced_pi_tag_ids_ignores_malformed_read_tags():
assert referenced_pi_tag_ids([pi_scouter_pipeline_doc([{'tag_id': 42}])]) == []
def test_resolve_pi_output_tags_ignores_non_dict_entries():
resolved, issues = resolve_pi_output_tags(
{'prediction_tags': ['F1DP-P', None, {'PRED-101': 'F1DP-P'}]}, {}, {}
)
assert resolved == {'prediction_tags': {'PRED-101': 'F1DP-P'}}
assert not any(issues.values())