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:
@@ -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
|
||||
|
||||
64
tests/orchestrator/utils/tag_database_fixtures.py
Normal file
64
tests/orchestrator/utils/tag_database_fixtures.py
Normal 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,
|
||||
}
|
||||
474
tests/orchestrator/utils/test_opc_slots.py
Normal file
474
tests/orchestrator/utils/test_opc_slots.py
Normal 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
778
tests/orchestrator/utils/test_pipeline_configs.py
Normal file
778
tests/orchestrator/utils/test_pipeline_configs.py
Normal 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': {},
|
||||
}
|
||||
291
tests/orchestrator/utils/test_tag_database.py
Normal file
291
tests/orchestrator/utils/test_tag_database.py
Normal 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())
|
||||
Reference in New Issue
Block a user