601 lines
21 KiB
Python
601 lines
21 KiB
Python
"""
|
|
End-to-end tests for the Drift workflow.
|
|
|
|
The drift suite drives the **real** ``sientia_model.analytics.drift_analysis.DriftAnalysis``
|
|
analyzer (no mocking). Each scenario exercises the full pipeline:
|
|
|
|
laborious_data (Postgres)
|
|
-> load_custom_query
|
|
-> calculate_drift (DriftAnalysis univariate + multivariate)
|
|
-> export_data_to_postgres (sientia_data.drift_metrics)
|
|
|
|
Coverage focus:
|
|
|
|
- Happy path persists every column required by ``sientia_data.drift_metrics``
|
|
with a valid reference dataset downloaded from MLflow.
|
|
- 30% fallback path activates when the MLflow reference is unavailable and
|
|
emits the ``MODEL_METRICS_REFERENCE_DATA_WARNING`` notification.
|
|
- Empty target data short-circuits the workflow without persisting anything.
|
|
- Invalid ``chunk_period`` is rejected by ``calculate_drift``.
|
|
- ``chunk_period='s'`` preserves second-level precision in
|
|
``chunk_start_date``.
|
|
|
|
Tests assert behavioral / structural properties (column presence, NOT NULL
|
|
constraints, business-key invariants) rather than exact numeric values, since
|
|
those depend on the real analyzer implementation and synthetic data.
|
|
"""
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pandas as pd
|
|
import pytest
|
|
from sqlalchemy import text
|
|
from temporalio.testing import WorkflowEnvironment
|
|
from temporalio.worker import Worker
|
|
|
|
from e2e.helpers import (
|
|
insert_target_data_for_drift,
|
|
load_scenario_input,
|
|
make_workflow_id,
|
|
start_and_await_workflow,
|
|
)
|
|
from laborious.activities.activities import Activities
|
|
from laborious.activities.model_metrics import ModelMetrics
|
|
from laborious.workflows.drift import Drift
|
|
from sientia_model.analytics.drift_analysis import DriftAnalysis
|
|
|
|
# Drift columns persisted on every row in ``sientia_data.drift_metrics`` —
|
|
# mirrors the production DDL.
|
|
EXPECTED_DRIFT_COLUMNS = [
|
|
'id',
|
|
'model_id',
|
|
'feature',
|
|
'method',
|
|
'value',
|
|
'alert',
|
|
'chunk_index',
|
|
'chunk_start_date',
|
|
'chunk_end_date',
|
|
'accurate',
|
|
'timestamp',
|
|
'created_at',
|
|
]
|
|
|
|
# Columns the DDL marks as NOT NULL. ``feature`` and ``timestamp`` are
|
|
# nullable in the production schema (multivariate rows do not bind to a
|
|
# single feature; ``timestamp`` is allowed to be empty when upstream data has
|
|
# no usable instant).
|
|
NON_NULL_DRIFT_COLUMNS = {
|
|
'id',
|
|
'model_id',
|
|
'method',
|
|
'value',
|
|
'alert',
|
|
'chunk_index',
|
|
'chunk_start_date',
|
|
'chunk_end_date',
|
|
'accurate',
|
|
'created_at',
|
|
}
|
|
|
|
DEFAULT_DRIFT_METHODS = ['kolmogorov_smirnov', 'jensen_shannon', 'wasserstein']
|
|
|
|
|
|
def _chunk_dataframe_skip_empty_groups(
|
|
self: DriftAnalysis,
|
|
df: pd.DataFrame,
|
|
timestamp_col: str,
|
|
chunk_period: str,
|
|
) -> list[tuple[int, pd.DataFrame]]:
|
|
"""
|
|
Same as ``DriftAnalysis._chunk_dataframe`` but omit empty time buckets.
|
|
|
|
``pd.Grouper(freq='s')`` yields every second between min and max timestamp;
|
|
empty buckets still appear in the groupby iterator and produce invalid
|
|
drift rows (e.g. NaT timestamps) that ``calculate_drift`` later filters out
|
|
entirely. Production fix belongs in ``sientia_model``; this shim keeps the
|
|
e2e honest about second-level chunk boundaries with sparse samples.
|
|
"""
|
|
grouped = df.groupby(pd.Grouper(key=timestamp_col, freq=chunk_period), dropna=True)
|
|
chunks: list[tuple[int, pd.DataFrame]] = []
|
|
idx = 0
|
|
for _, chunk in grouped:
|
|
if chunk.empty:
|
|
continue
|
|
chunks.append((idx, chunk.copy()))
|
|
idx += 1
|
|
return chunks
|
|
|
|
|
|
def _drift_input(model_id: int, **overrides) -> dict:
|
|
"""Load the base drift scenario JSON and apply ad-hoc overrides."""
|
|
input_data = load_scenario_input('drift_base.json', model_id=model_id)
|
|
input_data.update(overrides)
|
|
return input_data
|
|
|
|
|
|
def _recent_minute_timestamps(count: int, offset_minutes: int = 6) -> list[str]:
|
|
"""
|
|
Build ``count`` consecutive UTC minute timestamps placed in the recent past.
|
|
|
|
The Drift workflow filters target rows with ``timestamp > NOW() - INTERVAL``,
|
|
so timestamps must be recent for tests to retrieve any data. Snapping to
|
|
minute precision keeps the helper deterministic regardless of clock skew.
|
|
|
|
Args:
|
|
- count (int): How many consecutive minute timestamps to generate.
|
|
- offset_minutes (int): Minutes ago for the EARLIEST generated timestamp.
|
|
|
|
Return:
|
|
list[str]: ISO strings with ``+0000`` offset, one per minute.
|
|
"""
|
|
base = datetime.now(timezone.utc).replace(second=0, microsecond=0) - timedelta(
|
|
minutes=offset_minutes
|
|
)
|
|
return [
|
|
(base + timedelta(minutes=i)).strftime('%Y-%m-%d %H:%M:%S%z') for i in range(count)
|
|
]
|
|
|
|
|
|
def _configure_reference_csv(mlflow_repository_stub, reference_rows: pd.DataFrame) -> None:
|
|
"""
|
|
Wire ``mlflow_repository_stub`` so ``get_reference_data`` returns
|
|
``reference_rows`` by writing them to ``dst_path/retrain_input.csv``.
|
|
|
|
Args:
|
|
- mlflow_repository_stub: External MLflow repository fixture.
|
|
- reference_rows (pd.DataFrame): Rows to expose as the production reference.
|
|
"""
|
|
|
|
def _download(run_id: str, artifact_path: str, dst_path: str, metadata=None):
|
|
target = Path(dst_path) / artifact_path
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
reference_rows.to_csv(target, index=False)
|
|
|
|
mlflow_repository_stub._client.get_model_version_by_alias.return_value = MagicMock(
|
|
run_id='fake-reference-run'
|
|
)
|
|
file_info = MagicMock()
|
|
file_info.path = 'retrain_input.csv'
|
|
mlflow_repository_stub._client.list_artifacts.return_value = [file_info]
|
|
mlflow_repository_stub.download_artifacts.side_effect = _download
|
|
|
|
|
|
def _force_reference_unavailable(mlflow_repository_stub) -> None:
|
|
"""Make ``get_reference_data`` return ``None`` by failing alias resolution."""
|
|
mlflow_repository_stub._client.get_model_version_by_alias.side_effect = Exception(
|
|
'no production alias registered'
|
|
)
|
|
|
|
|
|
def _select_drift_rows(postgres_engine, model_id: int) -> list[dict]:
|
|
"""Read every persisted drift row for ``model_id`` ordered by chunk/feature/method."""
|
|
with postgres_engine.connect() as conn:
|
|
rows = (
|
|
conn.execute(
|
|
text(
|
|
'SELECT * FROM sientia_data.drift_metrics '
|
|
'WHERE model_id = :m '
|
|
'ORDER BY chunk_index, feature, method'
|
|
),
|
|
{'m': str(model_id)},
|
|
)
|
|
.mappings()
|
|
.all()
|
|
)
|
|
return [dict(row) for row in rows]
|
|
|
|
|
|
def _assert_required_columns_populated(rows: list[dict]) -> None:
|
|
"""Validate column presence and NOT NULL constraints on every row."""
|
|
assert rows, 'expected at least one drift row to be persisted'
|
|
seen_columns = set(rows[0].keys())
|
|
for column in EXPECTED_DRIFT_COLUMNS:
|
|
assert column in seen_columns, f'Missing drift column in postgres: {column}'
|
|
for row in rows:
|
|
for column in NON_NULL_DRIFT_COLUMNS:
|
|
assert row[column] is not None, f"Column '{column}' is NULL in {row}"
|
|
assert 'p_value' not in row, 'p_value must not be persisted to drift_metrics'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_drift_happy_path_persists_all_columns_with_reference_data(
|
|
temporal_test_env: WorkflowEnvironment,
|
|
temporal_worker_drift: Worker,
|
|
test_activities: Activities,
|
|
postgres_engine,
|
|
mlflow_repository_stub,
|
|
):
|
|
"""
|
|
Scenario D.1.1: Happy path with reference data downloaded from MLflow.
|
|
|
|
Drives the full pipeline against the real ``DriftAnalysis``. Asserts:
|
|
|
|
- One row is persisted per ``(chunk_index, feature, method)`` combination
|
|
plus the multivariate row block, with every column required by
|
|
``sientia_data.drift_metrics`` populated.
|
|
- The three default univariate methods are forwarded to the analyzer.
|
|
- ``model_id`` and ``timestamp`` are stamped by the activity (not by the
|
|
analyzer); ``timestamp`` equals ``max(target_data.timestamp)`` and is
|
|
identical on every persisted row.
|
|
- ``chunk_start_date`` / ``chunk_end_date`` are persisted as ISO text so
|
|
the analyzer's nanosecond-precision boundaries survive the ``text``
|
|
column type.
|
|
- ``accurate=True`` because the reference dataset was available.
|
|
"""
|
|
client = temporal_test_env.client
|
|
model_id = 411
|
|
|
|
target_timestamps = _recent_minute_timestamps(count=10)
|
|
insert_target_data_for_drift(
|
|
postgres_engine,
|
|
model_id=model_id,
|
|
timestamps=target_timestamps,
|
|
variables_values={
|
|
'sensor_1': [10.0 + i * 0.1 for i in range(10)],
|
|
'sensor_2': [20.0 + i * 0.5 for i in range(10)],
|
|
},
|
|
)
|
|
|
|
reference_df = pd.DataFrame(
|
|
{
|
|
'timestamp': [
|
|
f'2023-12-31 11:{minute:02d}:00+00:00' for minute in range(10)
|
|
],
|
|
'sensor_1': [9.0 + i * 0.05 for i in range(10)],
|
|
'sensor_2': [18.0 + i * 0.25 for i in range(10)],
|
|
}
|
|
)
|
|
_configure_reference_csv(mlflow_repository_stub, reference_df)
|
|
|
|
input_data = _drift_input(model_id)
|
|
await start_and_await_workflow(
|
|
client, Drift.run, input_data, make_workflow_id('test-drift-happy-path')
|
|
)
|
|
|
|
rows = _select_drift_rows(postgres_engine, model_id)
|
|
|
|
exported_csv_path = '/tmp/test_drift_happy_path_exported.csv'
|
|
pd.DataFrame(rows).to_csv(exported_csv_path, index=False)
|
|
print(
|
|
f'\n[test_drift_happy_path] Exported drift dataframe '
|
|
f'({len(rows)} rows) -> {exported_csv_path}'
|
|
)
|
|
|
|
_assert_required_columns_populated(rows)
|
|
|
|
# The activity drops the target column from the feature list, so only
|
|
# ``sensor_2`` participates in univariate analysis (``sensor_1`` is the
|
|
# configured target). Multivariate produces one row per chunk regardless.
|
|
univariate_rows = [r for r in rows if r['feature'] != 'multivariate']
|
|
multivariate_rows = [r for r in rows if r['feature'] == 'multivariate']
|
|
assert univariate_rows, 'expected univariate drift rows for non-target features'
|
|
assert multivariate_rows, 'expected one multivariate drift row per chunk'
|
|
|
|
# All three default methods must reach the analyzer.
|
|
assert {r['method'] for r in univariate_rows} == set(DEFAULT_DRIFT_METHODS)
|
|
assert all(r['method'] == 'multivariate' for r in multivariate_rows)
|
|
assert {r['feature'] for r in univariate_rows} == {'sensor_2'}
|
|
|
|
# ``timestamp`` is stamped uniformly with ``max(target_data.timestamp)``.
|
|
expected_timestamp = pd.to_datetime(max(target_timestamps), utc=True)
|
|
persisted_timestamps = {pd.to_datetime(r['timestamp'], utc=True) for r in rows}
|
|
assert len(persisted_timestamps) == 1, (
|
|
'timestamp must be uniform across all drift rows '
|
|
f'(got {len(persisted_timestamps)} distinct values)'
|
|
)
|
|
assert pd.Timestamp(persisted_timestamps.pop()) == expected_timestamp, (
|
|
'timestamp must equal max(target_data.timestamp)'
|
|
)
|
|
|
|
# ``model_id`` is stamped by ``calculate_drift`` (not produced by the analyzer).
|
|
assert all(r['model_id'] == str(model_id) for r in rows), (
|
|
'model_id must be stamped on every drift row'
|
|
)
|
|
|
|
# Reference path → accurate=True.
|
|
assert all(r['accurate'] is True for r in rows), (
|
|
'reference path should mark all rows as accurate'
|
|
)
|
|
|
|
# ISO text serialization preserves ordering between start/end of each chunk.
|
|
for row in rows:
|
|
assert 'T' in row['chunk_start_date'], (
|
|
f"chunk_start_date should be ISO text, got {row['chunk_start_date']!r}"
|
|
)
|
|
assert 'T' in row['chunk_end_date'], (
|
|
f"chunk_end_date should be ISO text, got {row['chunk_end_date']!r}"
|
|
)
|
|
assert row['chunk_start_date'] <= row['chunk_end_date'], (
|
|
f'chunk_start_date must precede chunk_end_date '
|
|
f"(start={row['chunk_start_date']}, end={row['chunk_end_date']})"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_drift_uses_30pct_fallback_when_reference_unavailable(
|
|
temporal_test_env: WorkflowEnvironment,
|
|
temporal_worker_drift: Worker,
|
|
test_activities: Activities,
|
|
postgres_engine,
|
|
mlflow_repository_stub,
|
|
notification_inserts,
|
|
):
|
|
"""
|
|
Scenario D.1.2: ``get_reference_data`` returns ``None`` (production alias
|
|
missing), so ``calculate_drift`` falls back to using the first 30% of
|
|
target rows as reference. Persisted rows must report ``accurate=False``
|
|
and a ``MODEL_METRICS_REFERENCE_DATA_WARNING`` notification must be
|
|
emitted to mongo.
|
|
"""
|
|
client = temporal_test_env.client
|
|
model_id = 412
|
|
|
|
target_timestamps = _recent_minute_timestamps(count=10)
|
|
insert_target_data_for_drift(
|
|
postgres_engine,
|
|
model_id=model_id,
|
|
timestamps=target_timestamps,
|
|
variables_values={
|
|
'sensor_1': [10.0 + i * 0.1 for i in range(10)],
|
|
'sensor_2': [20.0 + i * 0.5 for i in range(10)],
|
|
},
|
|
)
|
|
|
|
_force_reference_unavailable(mlflow_repository_stub)
|
|
|
|
input_data = _drift_input(model_id)
|
|
await start_and_await_workflow(
|
|
client, Drift.run, input_data, make_workflow_id('test-drift-fallback')
|
|
)
|
|
|
|
rows = _select_drift_rows(postgres_engine, model_id)
|
|
_assert_required_columns_populated(rows)
|
|
|
|
assert all(r['accurate'] is False for r in rows), (
|
|
'fallback path must mark all rows as inaccurate'
|
|
)
|
|
|
|
fallback_warnings = [
|
|
call
|
|
for call in notification_inserts.call_args_list
|
|
if call.args
|
|
and isinstance(call.args[0], dict)
|
|
and call.args[0].get('notification_id') == 'MODEL_METRICS_REFERENCE_DATA_WARNING'
|
|
]
|
|
assert len(fallback_warnings) >= 1, 'expected reference fallback warning notification'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_drift_empty_target_data_short_circuits_workflow(
|
|
temporal_test_env: WorkflowEnvironment,
|
|
temporal_worker_drift: Worker,
|
|
test_activities: Activities,
|
|
postgres_engine,
|
|
mlflow_repository_stub,
|
|
):
|
|
"""
|
|
Scenario D.3.1: When ``load_custom_query`` returns no rows the workflow
|
|
must return early without invoking the analyzer or writing any drift rows.
|
|
"""
|
|
client = temporal_test_env.client
|
|
model_id = 431
|
|
|
|
with postgres_engine.begin() as conn:
|
|
conn.execute(text(f'DELETE FROM sientia_data.laborious_data WHERE model_id = {model_id}'))
|
|
|
|
_force_reference_unavailable(mlflow_repository_stub)
|
|
|
|
input_data = _drift_input(model_id)
|
|
await start_and_await_workflow(
|
|
client, Drift.run, input_data, make_workflow_id('test-drift-empty-target')
|
|
)
|
|
|
|
with postgres_engine.connect() as conn:
|
|
count = conn.execute(
|
|
text('SELECT COUNT(*) FROM sientia_data.drift_metrics WHERE model_id = :m'),
|
|
{'m': str(model_id)},
|
|
).scalar()
|
|
assert count == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_drift_invalid_chunk_period_raises_value_error(
|
|
temporal_test_env: WorkflowEnvironment,
|
|
temporal_worker_drift: Worker,
|
|
test_activities: Activities,
|
|
postgres_engine,
|
|
mlflow_repository_stub,
|
|
):
|
|
"""
|
|
Scenario D.4.2: ``calculate_drift`` validates ``chunk_period`` and rejects
|
|
anything other than ``min`` / ``s``. The workflow must surface the
|
|
``ValueError`` and persist nothing.
|
|
"""
|
|
client = temporal_test_env.client
|
|
model_id = 442
|
|
|
|
target_timestamps = _recent_minute_timestamps(count=5)
|
|
insert_target_data_for_drift(
|
|
postgres_engine,
|
|
model_id=model_id,
|
|
timestamps=target_timestamps,
|
|
variables_values={
|
|
'sensor_1': [1.0, 2.0, 3.0, 4.0, 5.0],
|
|
'sensor_2': [10.0, 20.0, 30.0, 40.0, 50.0],
|
|
},
|
|
)
|
|
_force_reference_unavailable(mlflow_repository_stub)
|
|
|
|
input_data = _drift_input(model_id, chunk_period='hour')
|
|
|
|
with pytest.raises(Exception) as excinfo:
|
|
await start_and_await_workflow(
|
|
client,
|
|
Drift.run,
|
|
input_data,
|
|
make_workflow_id('test-drift-bad-chunk-period'),
|
|
)
|
|
|
|
# Temporal wraps the activity ValueError in WorkflowFailureError; the
|
|
# message may live on ``.message`` or ``str(exc)`` depending on the SDK
|
|
# error class, so walk the cause chain looking for the guard text.
|
|
cause_descriptions = []
|
|
current: BaseException | None = excinfo.value
|
|
while current is not None:
|
|
cause_descriptions.append(
|
|
getattr(current, 'message', None) or str(current) or repr(current)
|
|
)
|
|
current = current.__cause__
|
|
assert any('Invalid chunk period' in msg for msg in cause_descriptions), (
|
|
f'Expected ValueError about chunk period in chain, got: {cause_descriptions}'
|
|
)
|
|
|
|
with postgres_engine.connect() as conn:
|
|
count = conn.execute(
|
|
text('SELECT COUNT(*) FROM sientia_data.drift_metrics WHERE model_id = :m'),
|
|
{'m': str(model_id)},
|
|
).scalar()
|
|
assert count == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_drift_empty_merge_skips_export_without_insufficient_notification(
|
|
temporal_test_env: WorkflowEnvironment,
|
|
temporal_worker_drift: Worker,
|
|
test_activities: Activities,
|
|
postgres_engine,
|
|
mlflow_repository_stub,
|
|
):
|
|
"""
|
|
Scenario D.4.3a: When the analyzer returns an empty merged frame (no metric rows),
|
|
``calculate_drift`` yields ``[]``; the workflow skips export. Real insufficient-data
|
|
cases are signaled by ``DriftInsufficientDataError`` inside ``sientia_model``, not by
|
|
empty output alone.
|
|
"""
|
|
client = temporal_test_env.client
|
|
model_id = 444
|
|
|
|
target_timestamps = _recent_minute_timestamps(count=5)
|
|
insert_target_data_for_drift(
|
|
postgres_engine,
|
|
model_id=model_id,
|
|
timestamps=target_timestamps,
|
|
variables_values={
|
|
'sensor_1': [1.0 + i * 0.1 for i in range(5)],
|
|
'sensor_2': [10.0 + i * 0.5 for i in range(5)],
|
|
},
|
|
)
|
|
_force_reference_unavailable(mlflow_repository_stub)
|
|
|
|
input_data = _drift_input(model_id, chunk_period='min')
|
|
empty_merge = pd.DataFrame(
|
|
columns=[
|
|
'timestamp',
|
|
'feature',
|
|
'method',
|
|
'value',
|
|
'alert',
|
|
'chunk_index',
|
|
'chunk_start_date',
|
|
'chunk_end_date',
|
|
'threshold',
|
|
'drift_type',
|
|
]
|
|
)
|
|
with patch.object(ModelMetrics, 'get_drift_metrics', return_value=empty_merge):
|
|
await start_and_await_workflow(
|
|
client,
|
|
Drift.run,
|
|
input_data,
|
|
make_workflow_id('test-drift-empty-merge'),
|
|
)
|
|
|
|
with postgres_engine.connect() as conn:
|
|
count = conn.execute(
|
|
text('SELECT COUNT(*) FROM sientia_data.drift_metrics WHERE model_id = :m'),
|
|
{'m': str(model_id)},
|
|
).scalar()
|
|
assert count == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_drift_chunk_period_seconds_sufficient_data_preserves_seconds_in_chunk_start_date(
|
|
temporal_test_env: WorkflowEnvironment,
|
|
temporal_worker_drift: Worker,
|
|
test_activities: Activities,
|
|
postgres_engine,
|
|
mlflow_repository_stub,
|
|
):
|
|
"""
|
|
Scenario D.4.3b: With enough sub-minute samples and ``chunk_period='s'``, drift rows
|
|
persist and ``chunk_start_date`` keeps second-level precision (incl. second=30).
|
|
|
|
``DriftAnalysis._chunk_dataframe`` is patched to skip empty ``pd.Grouper(freq='s')``
|
|
buckets so sparse seconds between samples do not flood the pipeline with NaT rows;
|
|
the durable fix belongs in ``sientia_model``.
|
|
"""
|
|
client = temporal_test_env.client
|
|
model_id = 443
|
|
|
|
base = datetime.now(timezone.utc).replace(second=0, microsecond=0) - timedelta(minutes=6)
|
|
target_timestamps = []
|
|
sensor_1_vals = []
|
|
sensor_2_vals = []
|
|
for minute_offset in range(6):
|
|
t0 = base + timedelta(minutes=minute_offset)
|
|
t1 = t0 + timedelta(seconds=30)
|
|
target_timestamps.append(t0.strftime('%Y-%m-%d %H:%M:%S%z'))
|
|
target_timestamps.append(t1.strftime('%Y-%m-%d %H:%M:%S%z'))
|
|
v0 = 1.0 + minute_offset * 0.1
|
|
v1 = v0 + 0.05
|
|
sensor_1_vals.extend([v0, v1])
|
|
sensor_2_vals.extend([10.0 + v0, 10.0 + v1])
|
|
|
|
insert_target_data_for_drift(
|
|
postgres_engine,
|
|
model_id=model_id,
|
|
timestamps=target_timestamps,
|
|
variables_values={
|
|
'sensor_1': sensor_1_vals,
|
|
'sensor_2': sensor_2_vals,
|
|
},
|
|
)
|
|
_force_reference_unavailable(mlflow_repository_stub)
|
|
|
|
input_data = _drift_input(model_id, chunk_period='s')
|
|
with patch.object(DriftAnalysis, '_chunk_dataframe', _chunk_dataframe_skip_empty_groups):
|
|
await start_and_await_workflow(
|
|
client,
|
|
Drift.run,
|
|
input_data,
|
|
make_workflow_id('test-drift-chunk-seconds-sufficient'),
|
|
)
|
|
|
|
with postgres_engine.connect() as conn:
|
|
rows = (
|
|
conn.execute(
|
|
text(
|
|
'SELECT chunk_start_date FROM sientia_data.drift_metrics '
|
|
'WHERE model_id = :m ORDER BY chunk_index'
|
|
),
|
|
{'m': str(model_id)},
|
|
)
|
|
.mappings()
|
|
.all()
|
|
)
|
|
|
|
assert rows, 'expected at least one drift row to be persisted'
|
|
seconds_present = {pd.Timestamp(r['chunk_start_date']).second for r in rows}
|
|
assert 30 in seconds_present, (
|
|
f'expected at least one chunk_start_date with seconds=30, got {seconds_present}'
|
|
)
|