7 Commits
1.3.3 ... 1.3.4

Author SHA1 Message Date
github-actions[bot]
1c9940a0bb chore: bump version to 1.3.4 2026-09-14 12:13:49 +00:00
faca362fb8 Merge pull request #44 from Aignosi/fix/SIENTIAPDE-1993
fix: preserve real timestamps in get_tag_values when max_count > 1
2026-09-14 09:13:23 -03:00
Eduardo Rios
1d764439cb test: parametrize SIENTIAPDE-1993 e2e regression over max_count=1 and >1
Feedback (Vitor): the intent was a single scenario covering both
max_count=1 and max_count>1, checking that in both cases each tag's
own latest (properly aggregated) timestamp prevails — not just the
max_count>1 case from the previous commit.

Renamed the existing fixture to
pi_web_api_scouter_distinct_tag_timestamps_multi_point.json and added
a sibling _single_point.json (max_count=1, one point per tag, distinct
timestamps across tags). test_scenario_2_1_4 is now parametrized over
both fixtures with the same assertion: each tag's stored Postgres row
keeps its own real timestamp, never another tag's.

SIENTIAPDE-1993

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 08:39:00 -03:00
Eduardo Rios
4767fbbfb1 test: add e2e regression scenario for SIENTIAPDE-1993
Existing e2e coverage (2.1.2 multiple_tags) never exercised the actual
bug, since all its tags happened to share the same latest timestamp,
so aggregation's own per-tag last-timestamp pick coincided with the
old (buggy) batch-wide max regardless of the fix.

Add scenario 2.1.4: two tags with max_count=2, each with two historical
points, but with genuinely different latest timestamps (~12:00 vs
~15:00). Asserts each tag's stored Postgres row keeps its own real
timestamp rather than the other tag's. This test fails against the
pre-fix code (both rows would show ~15:00) and passes with the fix.

SIENTIAPDE-1993

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 16:17:54 -03:00
Eduardo Rios
f79d51336f fix: remove timestamp normalization instead of gating it by max_count
Review feedback (Vitor): gating the batch-max normalization behind
max_count == 1 just hid the same collapsing behavior for the single-point
case, where it's redundant anyway (aggregation/export don't need it) while
still being the wrong fix conceptually. Removed the normalization block
entirely so every record always keeps its own real timestamp from the PI
Web API, regardless of max_count.

Updated unit tests accordingly and confirmed no e2e scenario needs
adjustment: aggregate_data groups by (tag, name) and always collapses to
one output row per tag per execution, independent of per-record timestamp
values, so pi_web_api_scouter_multiple_tags' aggregated-value assertions
are unaffected.

SIENTIAPDE-1993

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 16:05:37 -03:00
Eduardo Rios
fc3660433f fix: preserve real timestamps in get_tag_values when max_count > 1
Timestamp normalization in API.get_tag_values previously overwrote all
records with the batch's maximum timestamp regardless of max_count.
With max_count > 1, this collapsed distinct historical points for the
same tag onto a single timestamp, causing them to collide on the
(model_id, timestamp, variable) unique constraint and get silently
dropped via ON CONFLICT DO NOTHING.

Normalization is now only applied when max_count == 1, which is the
only case where it is meaningful (aligning minor sampling differences
across tags for a single point each).

SIENTIAPDE-1993

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 14:08:15 -03:00
554a26bf2c ci(deploy): Update env_list_path to array format in workflow 2026-09-11 00:12:06 -03:00
9 changed files with 181 additions and 15 deletions

View File

@@ -20,7 +20,7 @@ jobs:
branch_name: ${{ github.event.pull_request.head.ref }}
project_name: 'scouter'
chart_name: 'scouter-worker'
env_list_path: '.env'
env_list_path: '[".env"]'
update_version_in: '["pyproject"]'
create_tarball: ${{ github.event_name == 'pull_request' }}
mirror: true

View File

@@ -48,7 +48,7 @@ Parent/child Temporal workflow pattern, two independent worker task queues shari
```
Scouter (scouter/workflow/scouter.py) PIWebAPIScouter (scouter/workflow/pi_web_api_scouter.py)
reads raw_{schedule_name} from MongoDB queries PI Web API directly (get_tag_values)
tracks last-processed timestamp in Redis normalizes all timestamps to the batch max
tracks last-processed timestamp in Redis preserves each record's real timestamp
│ │
└───────────────► CoreScouter (scouter/workflow/sub_workflows/core_scouter.py) ◄───────┘
1. data_quality_gate (NULL_VALUES_FILTER / OUT_OF_BOUNDS_FILTER, DISCARD policy)

View File

@@ -0,0 +1,36 @@
{
"workflow_input": {
"model_id": "13",
"model_name": "PI Distinct Tag Timestamps (max_count>1)",
"schedule_name": "pi-distinct-ts-n",
"trigger_laborious": false,
"filters": {},
"schema": "sientia_data",
"table_name": "laborious_data",
"retention_time": 3600,
"fill_missing_tags": false,
"model_tags": {
"tag_early": {"webid": "w1", "aggr_func": "lts", "data_range": [0, 1000], "frequency": 60000},
"tag_late": {"webid": "w2", "aggr_func": "lts", "data_range": [0, 1000], "frequency": 60000}
},
"pi_web_api_query": {
"endpoint": "/streamsets/recorded",
"period": "*-1d",
"max_count": 2,
"api_timeout": 30
}
},
"pi_web_api_server": {
"mode": "success",
"rows": [
{"name": "tag_early", "timestamp": "2024-06-01T12:00:00Z", "value": 10},
{"name": "tag_early", "timestamp": "2024-06-01T12:01:00Z", "value": 20},
{"name": "tag_late", "timestamp": "2024-06-01T15:00:00Z", "value": 100},
{"name": "tag_late", "timestamp": "2024-06-01T15:05:00Z", "value": 200}
]
},
"expected_rows": {
"tag_early": {"value": 20.0, "timestamp": "2024-06-01T12:01:00Z"},
"tag_late": {"value": 200.0, "timestamp": "2024-06-01T15:05:00Z"}
}
}

View File

@@ -0,0 +1,34 @@
{
"workflow_input": {
"model_id": "14",
"model_name": "PI Distinct Tag Timestamps (max_count=1)",
"schedule_name": "pi-distinct-ts-1",
"trigger_laborious": false,
"filters": {},
"schema": "sientia_data",
"table_name": "laborious_data",
"retention_time": 3600,
"fill_missing_tags": false,
"model_tags": {
"tag_early": {"webid": "w1", "aggr_func": "lts", "data_range": [0, 1000], "frequency": 60000},
"tag_late": {"webid": "w2", "aggr_func": "lts", "data_range": [0, 1000], "frequency": 60000}
},
"pi_web_api_query": {
"endpoint": "/streamsets/recorded",
"period": "*-1d",
"max_count": 1,
"api_timeout": 30
}
},
"pi_web_api_server": {
"mode": "success",
"rows": [
{"name": "tag_early", "timestamp": "2024-06-01T12:00:00Z", "value": 10},
{"name": "tag_late", "timestamp": "2024-06-01T15:00:00Z", "value": 100}
]
},
"expected_rows": {
"tag_early": {"value": 10.0, "timestamp": "2024-06-01T12:00:00Z"},
"tag_late": {"value": 100.0, "timestamp": "2024-06-01T15:00:00Z"}
}
}

View File

@@ -61,6 +61,14 @@ Five tags with `avg` / `mdn` / `max` / `min` / `lts`; assert five distinct `vari
`debug_data_package=True`; assert `data_package_pi_web_api_scouter_*` Redis key with `data` and `held_data`.
### 2.1.4 Distinct tag timestamps preserved
Regression test for SIENTIAPDE-1993, parametrized over `max_count=1` and `max_count>1`. Two
tags (`tag_early` around 12:00, `tag_late` around 15:00) per case; assert each tag's stored
`timestamp` in Postgres equals its own latest point after aggregation, not the other tag's
(the batch-wide max across tags) — the invariant must hold whether a tag has one point or
several historical points.
### 2.2.1 Empty response early exit
Server `empty` mode; zero Postgres rows for `model_id`, one request recorded.

View File

@@ -3,6 +3,7 @@ End-to-end tests for the PIWebAPIScouter main workflow.
"""
import json
from datetime import datetime
import pytest
from redis import Redis
@@ -103,6 +104,52 @@ async def test_scenario_2_1_3_debug_data_package(
assert 'data' in payload and 'held_data' in payload
@pytest.mark.e2e
@pytest.mark.asyncio
@pytest.mark.parametrize(
'scenario_name, workflow_id_prefix',
[
('pi_web_api_scouter_distinct_tag_timestamps_single_point', 'pi-distinct-ts-1'),
('pi_web_api_scouter_distinct_tag_timestamps_multi_point', 'pi-distinct-ts-n'),
],
ids=['max_count_1', 'max_count_gt_1'],
)
async def test_scenario_2_1_4_distinct_tag_timestamps_preserved(
temporal_env: WorkflowEnvironment,
temporal_worker: Worker,
pi_web_api_server: PIWebAPITestServer,
postgres_engine,
scenario_name: str,
workflow_id_prefix: str,
):
"""
Regression test for SIENTIAPDE-1993: whether a tag has a single point
(max_count=1) or several historical points (max_count>1), each tag's
stored row must keep the timestamp of its own latest point after
aggregation, not another tag's timestamp from the same batch.
"""
scenario = load_scenario_input(scenario_name)
apply_pi_web_api_server_config(pi_web_api_server, scenario.get('pi_web_api_server'))
workflow_input = scenario['workflow_input']
await start_and_await_workflow(
temporal_env.client,
PIWebAPIScouter.run,
workflow_input,
make_workflow_id(workflow_id_prefix),
)
rows = {
row['variable']: row
for row in fetch_laborious_rows(postgres_engine, workflow_input['model_id'])
}
for tag, expected in scenario['expected_rows'].items():
assert tag in rows
assert rows[tag]['value'] == pytest.approx(expected['value'])
expected_timestamp = datetime.fromisoformat(expected['timestamp'].replace('Z', '+00:00'))
assert rows[tag]['timestamp'] == expected_timestamp
@pytest.mark.e2e
@pytest.mark.asyncio
@pytest.mark.xfail(

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "scouter"
version = "1.3.3"
version = "1.3.4"
description = "Sientia DataOps Scouter - ML Model Orchestration System"
readme = "README.md"
requires-python = ">=3.11"

View File

@@ -86,10 +86,8 @@ class API(SientiaMonitoring):
for a set of configured tags. It returns the data as a list of dictionaries
suitable for further processing in the workflow.
The timestamps are normalized to ensure consistency across all records in the
response. After converting timestamps to string format, all timestamps are
set to the maximum timestamp value (lexicographically) found in the dataset.
This ensures all records in a single batch share the same timestamp value.
Timestamps are converted to string format and each record keeps its own real
timestamp as returned by the PI Web API.
Args:
input_data (dict[str, Any]): Activity input parameters.
@@ -103,7 +101,7 @@ class API(SientiaMonitoring):
Returns:
list[dict]: List of data records, each containing:
- timestamp: Normalized timestamp string (all records share the same value)
- timestamp: Each record's real timestamp string
- name: Tag name
- value: Numeric value
- tag: WebId
@@ -148,10 +146,6 @@ class API(SientiaMonitoring):
self.debug(f'Latest values: {latest_values.to_string()}', metadata=metadata)
# Normalize the package timestamp
valid_timestamp_values = latest_values['timestamp'].dropna()
latest_values['timestamp'] = valid_timestamp_values.max()
self.info(f'Gathered {len(latest_values)} tag values', metadata=metadata)
return latest_values.to_dict(orient='records')

View File

@@ -87,7 +87,7 @@ def test_close(mock_sientia_monitoring, api_activity):
def test_get_tag_values_success(api_activity):
"""Test get_tag_values with successful data retrieval."""
"""Test get_tag_values with successful data retrieval preserves each record's real timestamp."""
# Setup test data
test_data = {
**metadata,
@@ -155,8 +155,55 @@ def test_get_tag_values_success(api_activity):
assert result[0]['value'] == pytest.approx(10.5)
assert result[1]['name'] == 'tag2'
assert result[2]['name'] == 'tag3'
assert result[0]['timestamp'] == '2023-01-01 12:02:00+0000'
assert result[1]['timestamp'] == '2023-01-01 12:02:00+0000'
# Each record keeps its own real timestamp, no cross-tag normalization
assert result[0]['timestamp'] == '2023-01-01 12:00:00+0000'
assert result[1]['timestamp'] == '2023-01-01 12:01:00+0000'
assert result[2]['timestamp'] == '2023-01-01 12:02:00+0000'
def test_get_tag_values_with_max_count_greater_than_one_preserves_timestamps(api_activity):
"""Regression test for SIENTIAPDE-1993: with max_count > 1, each historical point's
real timestamp must be preserved, otherwise distinct records collide on the
(model_id, timestamp, variable) unique constraint downstream and are silently
dropped."""
test_data = {
**metadata,
'endpoint': '/streamsets/recorded',
'web_ids': {
'tag1': {
'webid': 'webid1',
'aggr_function': 'avg',
'data_range': [0, 100],
},
},
'period': '*-1d',
'max_count': 3,
'api_timeout': 30,
}
# Multiple historical points for the SAME tag, with distinct timestamps
mock_df = pd.DataFrame(
{
'timestamp': [
'2023-01-01 12:00:00+0000',
'2023-01-01 12:01:00+0000',
'2023-01-01 12:02:00+0000',
],
'name': ['tag1', 'tag1', 'tag1'],
'value': [10.5, 20.3, 30.7],
'tag': ['webid1', 'webid1', 'webid1'],
}
)
mock_df['timestamp'] = pd.to_datetime(mock_df['timestamp'])
api_activity.pi_web_api_client.get_latest_values_df = Mock(return_value=mock_df)
result = api_activity.get_tag_values(test_data)
assert len(result) == 3
assert result[0]['timestamp'] == '2023-01-01 12:00:00+0000'
assert result[1]['timestamp'] == '2023-01-01 12:01:00+0000'
assert result[2]['timestamp'] == '2023-01-01 12:02:00+0000'