Add channel material and downtime analytics
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
"""Shared test adaptations for the production PostgreSQL schema."""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def sqlite_schema() -> str:
|
||||
"""Keep PostgreSQL DDL authoritative; adapt JSON storage and clock for SQLite.
|
||||
|
||||
SQLite stores serialized JSON as text. These tests exercise schema and INSERT
|
||||
semantics, not PostgreSQL JSONB operators or TimescaleDB behavior.
|
||||
"""
|
||||
schema = (Path(__file__).resolve().parents[1] / "db" / "schema.sql").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
schema = re.sub(r"::jsonb\b", "", schema)
|
||||
schema = re.sub(r"\bjsonb\b", "text", schema)
|
||||
return schema.replace("DEFAULT now()", "DEFAULT CURRENT_TIMESTAMP")
|
||||
@@ -0,0 +1,158 @@
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from production_analytics.calculations.channel_config import load_channel_material_calculation
|
||||
from production_analytics.calculations.channel_material import (
|
||||
MATERIAL_AMBIGUOUS,
|
||||
MATERIAL_UNAVAILABLE,
|
||||
DosingChannelSample,
|
||||
channel_rates,
|
||||
validate_active_percentage_sum,
|
||||
)
|
||||
from production_analytics.enlyze.gateway import EnlyzeApiGateway, EnlyzeProductionRun
|
||||
from production_analytics.service.channel_material import (
|
||||
ChannelDefinition,
|
||||
ChannelMaterialPollingService,
|
||||
)
|
||||
from production_analytics.service.channel_material_state_store import JsonChannelMaterialStateStore
|
||||
|
||||
NOW = datetime(2026, 9, 1, tzinfo=UTC)
|
||||
E1_CONFIG = (
|
||||
Path(__file__).resolve().parents[1] / "config/e1-channel-material-consumption.example.yaml"
|
||||
)
|
||||
|
||||
|
||||
def sample(*, on=True, percent=100, material=None, timestamp=NOW):
|
||||
return DosingChannelSample(timestamp, 120, on, percent, 10, material)
|
||||
|
||||
|
||||
def test_off_channel_with_nonzero_displayed_percentage_contributes_zero():
|
||||
assert channel_rates((sample(on=False, percent=40),))[0].sample.material_rate_kg_per_hour == 0
|
||||
|
||||
|
||||
def test_e1_config_dry_run_resolves_every_signal_through_gateway_without_sampling():
|
||||
config = load_channel_material_calculation(E1_CONFIG)
|
||||
origins = {
|
||||
reference
|
||||
for channel in config.channels
|
||||
for reference in (
|
||||
channel.total_rate_signal_ref,
|
||||
channel.status_signal_ref,
|
||||
channel.percentage_signal_ref,
|
||||
channel.screw_speed_signal_ref,
|
||||
)
|
||||
}
|
||||
client = Mock()
|
||||
client.get.return_value.body = {
|
||||
"data": [
|
||||
{
|
||||
"uuid": f"variable-{index}",
|
||||
"details": {"origin_identifier": {"code": origin}},
|
||||
}
|
||||
for index, origin in enumerate(sorted(origins))
|
||||
]
|
||||
}
|
||||
|
||||
resolved = EnlyzeApiGateway(client).resolve_dosing_channel_signal_refs(
|
||||
machine_id=config.machine_ref,
|
||||
channels=config.channels,
|
||||
)
|
||||
|
||||
assert config.machine_ref == "8302e3d1-b1e5-42f1-8540-615eb6c73e08"
|
||||
assert len(config.channels) == 14
|
||||
assert len(origins) == 44
|
||||
assert len(resolved) == 14
|
||||
assert all(len(refs) == 5 and refs[-1] is None for refs in resolved.values())
|
||||
client.get.assert_called_once_with("/v2/variables", {"machine": config.machine_ref})
|
||||
client.post_json.assert_not_called()
|
||||
|
||||
|
||||
def test_active_percentages_sum_to_100_and_deviation_is_reported_not_normalized():
|
||||
active = (sample(percent=40), sample(percent=60))
|
||||
assert validate_active_percentage_sum(active, tolerance=0.1)
|
||||
assert not validate_active_percentage_sum(
|
||||
(sample(percent=40), sample(percent=50)), tolerance=0.1
|
||||
)
|
||||
assert [x.sample.material_rate_kg_per_hour for x in channel_rates(active)] == [48, 72]
|
||||
|
||||
|
||||
def test_null_and_duplicate_material_assignments_are_not_inferred():
|
||||
assert (
|
||||
channel_rates((sample(material=None),))[0].material_mapping_status == MATERIAL_UNAVAILABLE
|
||||
)
|
||||
rates = channel_rates((sample(material="123"), sample(material="123")))
|
||||
assert all(rate.material_mapping_status == MATERIAL_AMBIGUOUS for rate in rates)
|
||||
assert all(rate.material_number is None and rate.material_name is None for rate in rates)
|
||||
|
||||
|
||||
def test_run_change_and_json_restart_preserve_channel_total(tmp_path):
|
||||
channels = (ChannelDefinition("Ex1", "C1", "rate", "on", "pct", "rpm"),)
|
||||
store, gateway = JsonChannelMaterialStateStore(tmp_path), Mock()
|
||||
gateway.get_production_runs.return_value = []
|
||||
gateway.get_open_production_run.return_value = EnlyzeProductionRun(
|
||||
"r1", "m", None, "order", NOW, None
|
||||
)
|
||||
key = "C1\0Ex1"
|
||||
gateway.get_dosing_channel_samples.return_value = {
|
||||
key: [sample(timestamp=NOW), sample(timestamp=NOW + timedelta(seconds=10))]
|
||||
}
|
||||
first = ChannelMaterialPollingService(
|
||||
gateway=gateway,
|
||||
state_store=store,
|
||||
machine_id="m",
|
||||
channels=channels,
|
||||
max_sample_gap_seconds=20,
|
||||
)
|
||||
assert first.poll_once(now=NOW + timedelta(seconds=10))[
|
||||
0
|
||||
].state.cumulative_consumption_kg == pytest.approx(10 / 30)
|
||||
gateway.get_open_production_run.return_value = EnlyzeProductionRun(
|
||||
"r2", "m", None, "order", NOW + timedelta(seconds=20), None
|
||||
)
|
||||
gateway.get_dosing_channel_samples.return_value = {
|
||||
key: [
|
||||
sample(timestamp=NOW + timedelta(seconds=20)),
|
||||
sample(timestamp=NOW + timedelta(seconds=30)),
|
||||
]
|
||||
}
|
||||
restarted = ChannelMaterialPollingService(
|
||||
gateway=gateway,
|
||||
state_store=store,
|
||||
machine_id="m",
|
||||
channels=channels,
|
||||
max_sample_gap_seconds=20,
|
||||
)
|
||||
assert restarted.poll_once(now=NOW + timedelta(seconds=30))[
|
||||
0
|
||||
].state.cumulative_consumption_kg == pytest.approx(20 / 30)
|
||||
|
||||
|
||||
def test_bootstrap_replays_closed_runs_without_integrating_the_inter_run_gap(tmp_path):
|
||||
channels = (ChannelDefinition("Ex1", "C1", "rate", "on", "pct", "rpm"),)
|
||||
closed = EnlyzeProductionRun("r1", "m", None, "order", NOW, NOW + timedelta(seconds=10))
|
||||
current = EnlyzeProductionRun("r2", "m", None, "order", NOW + timedelta(seconds=20), None)
|
||||
gateway = Mock()
|
||||
gateway.get_open_production_run.return_value = current
|
||||
gateway.get_production_runs.return_value = [closed, current]
|
||||
key = "C1\0Ex1"
|
||||
gateway.get_dosing_channel_samples.side_effect = [
|
||||
{key: [sample(timestamp=NOW), sample(timestamp=NOW + timedelta(seconds=10))]},
|
||||
{
|
||||
key: [
|
||||
sample(timestamp=NOW + timedelta(seconds=20)),
|
||||
sample(timestamp=NOW + timedelta(seconds=30)),
|
||||
]
|
||||
},
|
||||
]
|
||||
result = ChannelMaterialPollingService(
|
||||
gateway=gateway,
|
||||
state_store=JsonChannelMaterialStateStore(tmp_path),
|
||||
machine_id="m",
|
||||
channels=channels,
|
||||
max_sample_gap_seconds=20,
|
||||
).poll_once(now=NOW + timedelta(seconds=30))
|
||||
assert result is not None
|
||||
assert result[0].state.cumulative_consumption_kg == pytest.approx(20 / 30)
|
||||
@@ -0,0 +1,141 @@
|
||||
import io
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from production_analytics.calculations.channel_config import load_channel_material_calculation
|
||||
from production_analytics.calculations.material_consumption import MaterialIntegrationState
|
||||
from production_analytics.cli.__main__ import _parser, main
|
||||
from production_analytics.enlyze.gateway import EnlyzeProductionRun
|
||||
from production_analytics.service.channel_material import ChannelPollResult
|
||||
from production_analytics.service.channel_material_runner import ChannelMaterialPollingRunner
|
||||
from production_analytics.service.channel_material_runtime import build_channel_material_runner
|
||||
|
||||
EXAMPLE = (
|
||||
Path(__file__).resolve().parents[1] / "config/e1-channel-material-consumption.example.yaml"
|
||||
)
|
||||
NOW = datetime(2026, 9, 1, tzinfo=UTC)
|
||||
|
||||
|
||||
def test_cli_channel_material_arguments():
|
||||
args = _parser().parse_args(
|
||||
[
|
||||
"run",
|
||||
"channel-material-poll",
|
||||
"--config",
|
||||
str(EXAMPLE),
|
||||
"--poll-interval-seconds",
|
||||
"15",
|
||||
"--state-directory",
|
||||
"state",
|
||||
"--secrets-file",
|
||||
"secrets.env",
|
||||
"--once",
|
||||
]
|
||||
)
|
||||
assert args.command == "channel-material-poll"
|
||||
assert args.config == EXAMPLE
|
||||
assert args.poll_interval_seconds == 15
|
||||
assert args.state_directory == Path("state")
|
||||
assert args.secrets_file == Path("secrets.env")
|
||||
assert args.once
|
||||
|
||||
|
||||
def test_runtime_constructs_channel_service(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("ENLYZE_BASE_URL", "https://example.invalid/api/")
|
||||
for key, value in {
|
||||
"HOST": "localhost",
|
||||
"PORT": "5432",
|
||||
"DB": "analytics",
|
||||
"USER": "user",
|
||||
"PASSWORD": "environment-password",
|
||||
}.items():
|
||||
monkeypatch.setenv(f"POSTGRES_{key}", value)
|
||||
secrets = tmp_path / "secrets.env"
|
||||
secrets.write_text("ENLYZE_API_KEY=secret-from-file\nPOSTGRES_PASSWORD=file-password\n")
|
||||
calculation = load_channel_material_calculation(EXAMPLE)
|
||||
with patch(
|
||||
"production_analytics.service.channel_material_runtime.ChannelMaterialPollingService"
|
||||
) as service:
|
||||
runner = build_channel_material_runner(
|
||||
calculation,
|
||||
poll_interval_seconds=7,
|
||||
state_directory=tmp_path / "state",
|
||||
secrets_file=secrets,
|
||||
)
|
||||
kwargs = service.call_args.kwargs
|
||||
assert kwargs["machine_id"] == calculation.machine_ref
|
||||
assert kwargs["channels"] == calculation.channels
|
||||
assert kwargs["max_sample_gap_seconds"] == calculation.max_sample_gap_seconds
|
||||
assert kwargs["percentage_tolerance"] == calculation.percentage_tolerance
|
||||
assert kwargs["material_names"] == calculation.material_names
|
||||
assert kwargs["gateway"]._client._settings.api_key == "secret-from-file"
|
||||
assert kwargs["state_store"].directory == tmp_path / "state"
|
||||
assert runner.service is service.return_value
|
||||
assert runner.interval == 7
|
||||
assert runner.snapshot_writer.settings.password == "file-password"
|
||||
|
||||
|
||||
def test_once_executes_one_cycle_and_exits(capsys):
|
||||
with patch(
|
||||
"production_analytics.service.channel_material_runtime.build_channel_material_runner"
|
||||
) as build:
|
||||
assert main(["run", "channel-material-poll", "--config", str(EXAMPLE), "--once"]) == 0
|
||||
build.return_value.run_once.assert_called_once_with()
|
||||
build.return_value.run.assert_not_called()
|
||||
assert capsys.readouterr().err == ""
|
||||
|
||||
|
||||
def test_no_snapshots_are_written_without_eligible_production_run():
|
||||
writer = Mock()
|
||||
output = io.StringIO()
|
||||
runner = ChannelMaterialPollingRunner(
|
||||
Mock(poll_once=Mock(return_value=None)),
|
||||
machine_id="machine",
|
||||
calculation_id="calculation",
|
||||
snapshot_writer=writer,
|
||||
poll_interval_seconds=10,
|
||||
clock=lambda: NOW,
|
||||
stdout=output,
|
||||
)
|
||||
assert not runner.run_once()
|
||||
writer.write.assert_not_called()
|
||||
assert "snapshots=0" in output.getvalue()
|
||||
|
||||
|
||||
def test_one_cycle_writes_each_channel_snapshot():
|
||||
run = EnlyzeProductionRun("run", "machine", None, "order", NOW, None)
|
||||
first = ChannelPollResult(
|
||||
run, "Ex1", "C1", MaterialIntegrationState(1.5, 10), "42", "Material", "MAPPED", True
|
||||
)
|
||||
second = ChannelPollResult(
|
||||
run, "Ex1", "C2", MaterialIntegrationState(2.5, 10), None, None, "AMBIGUOUS", False
|
||||
)
|
||||
writer = Mock()
|
||||
output = io.StringIO()
|
||||
runner = ChannelMaterialPollingRunner(
|
||||
Mock(poll_once=Mock(return_value=(first, second))),
|
||||
machine_id="machine",
|
||||
calculation_id="calculation",
|
||||
snapshot_writer=writer,
|
||||
poll_interval_seconds=10,
|
||||
clock=lambda: NOW,
|
||||
stdout=output,
|
||||
)
|
||||
assert runner.run_once()
|
||||
assert writer.write.call_count == 2
|
||||
assert writer.write.call_args_list[0].kwargs == {
|
||||
"timestamp": NOW,
|
||||
"calculation_id": "calculation",
|
||||
"machine_id": "machine",
|
||||
"extruder": "Ex1",
|
||||
"channel": "C1",
|
||||
"production_order": "order",
|
||||
"run_id": "run",
|
||||
"cumulative_consumption_kg": 1.5,
|
||||
"material_number": "42",
|
||||
"material_name": "Material",
|
||||
"material_mapping_status": "MAPPED",
|
||||
"percentage_sum_valid": True,
|
||||
}
|
||||
assert "channel snapshots=2 written" in output.getvalue()
|
||||
@@ -0,0 +1,140 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from production_analytics.enlyze.gateway import EnlyzeDowntime
|
||||
from production_analytics.erp import CurrentWorkplaceStatus
|
||||
from production_analytics.service.downtime import (
|
||||
DowntimeCategory,
|
||||
DowntimeReconciliationService,
|
||||
ProductionOrderBoundary,
|
||||
)
|
||||
|
||||
NOW = datetime(2026, 9, 16, 12, tzinfo=UTC)
|
||||
|
||||
|
||||
def source(*, identifier="d", end=NOW, category=None):
|
||||
return EnlyzeDowntime(
|
||||
identifier,
|
||||
"machine",
|
||||
"THRESHOLD",
|
||||
datetime(2026, 9, 16, 10, tzinfo=UTC),
|
||||
end,
|
||||
None,
|
||||
"reason" if category else None,
|
||||
"reason" if category else None,
|
||||
None,
|
||||
None,
|
||||
category,
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def status(order="1", *, remaining=3, good=7, target=10, feedback=NOW):
|
||||
return CurrentWorkplaceStatus(
|
||||
"WP", order, None, None, feedback, target, good, remaining, None, None
|
||||
)
|
||||
|
||||
|
||||
class Gateway:
|
||||
def __init__(self, events):
|
||||
self.events = events
|
||||
|
||||
def get_downtimes(self, machine_id, *, start=None):
|
||||
return self.events
|
||||
|
||||
|
||||
class Erp:
|
||||
def __init__(self, current):
|
||||
self.current = current
|
||||
|
||||
def get_current_workplace_status(self, workplace):
|
||||
return self.current
|
||||
|
||||
|
||||
class Repo:
|
||||
def __init__(self):
|
||||
self.boundary = None
|
||||
self.events = {}
|
||||
self.closures = []
|
||||
|
||||
def current_boundary(self, machine_id):
|
||||
return self.boundary
|
||||
|
||||
def save_boundary(self, boundary):
|
||||
self.boundary = boundary
|
||||
|
||||
def close_order_attribution(self, machine_id, production_order, ended_at):
|
||||
self.closures.append((production_order, ended_at))
|
||||
for event in self.events.values():
|
||||
if event.production_order == production_order and event.attributed_end is None:
|
||||
event # database-specific clipping is covered by SQL contract
|
||||
|
||||
def upsert(self, event):
|
||||
self.events[event.external_id] = event
|
||||
|
||||
|
||||
def service(repo, events, current):
|
||||
return DowntimeReconciliationService(
|
||||
Gateway(events),
|
||||
Erp(current),
|
||||
repo,
|
||||
machine_id="machine",
|
||||
workplace="WP",
|
||||
production_order_format="FA-{production_order}",
|
||||
)
|
||||
|
||||
|
||||
def test_categories_open_and_reconciliation_upsert() -> None:
|
||||
repo = Repo()
|
||||
runner = service(
|
||||
repo,
|
||||
[
|
||||
source(identifier="p", category="PLANNED"),
|
||||
source(identifier="u", category="UNPLANNED"),
|
||||
source(identifier="x", end=None),
|
||||
],
|
||||
status(),
|
||||
)
|
||||
assert runner.reconcile_once(NOW) == 3
|
||||
assert {key: event.category for key, event in repo.events.items()} == {
|
||||
"p": DowntimeCategory.PLANNED,
|
||||
"u": DowntimeCategory.UNPLANNED,
|
||||
"x": DowntimeCategory.UNKNOWN,
|
||||
}
|
||||
assert repo.events["x"].source_end is None
|
||||
# Same UUID is overwritten, never duplicated; delayed classification is accepted.
|
||||
runner.gateway.events = [source(identifier="x", end=NOW, category="PLANNED")]
|
||||
runner.reconcile_once(NOW)
|
||||
assert len(repo.events) == 3
|
||||
assert repo.events["x"].category is DowntimeCategory.PLANNED
|
||||
assert repo.events["x"].source_end == NOW
|
||||
|
||||
|
||||
def test_completion_clips_attribution_and_new_order_closes_previous() -> None:
|
||||
repo = Repo()
|
||||
repo.boundary = ProductionOrderBoundary(
|
||||
"machine",
|
||||
"FA-1",
|
||||
datetime(2026, 9, 16, 9, tzinfo=UTC),
|
||||
None,
|
||||
)
|
||||
event = source(identifier="open", end=None)
|
||||
first = service(repo, [event], status(remaining=0, feedback=NOW))
|
||||
first.reconcile_once(NOW)
|
||||
assert repo.boundary is not None and repo.boundary.ended_at == NOW
|
||||
assert repo.events["open"].production_order == "FA-1"
|
||||
assert repo.events["open"].attributed_end == NOW
|
||||
repo.boundary = ProductionOrderBoundary(
|
||||
"machine",
|
||||
"FA-1",
|
||||
datetime(2026, 9, 16, 8, tzinfo=UTC),
|
||||
None,
|
||||
)
|
||||
service(repo, [], status(order="2", feedback=NOW)).reconcile_once(NOW)
|
||||
assert repo.closures == [("FA-1", NOW), ("FA-1", NOW)]
|
||||
assert repo.boundary.production_order == "FA-2"
|
||||
|
||||
|
||||
def test_unknown_is_not_unplanned_and_no_schedule_is_used() -> None:
|
||||
repo = Repo()
|
||||
service(repo, [source(end=datetime(2026, 9, 20, tzinfo=UTC))], status()).reconcile_once(NOW)
|
||||
assert repo.events["d"].category is DowntimeCategory.UNKNOWN
|
||||
+206
-59
@@ -6,6 +6,52 @@ import pytest
|
||||
from production_analytics.enlyze.gateway import EnlyzeApiGateway
|
||||
|
||||
|
||||
def test_downtime_pagination_and_nullable_reason() -> None:
|
||||
client = Mock()
|
||||
base = {
|
||||
"machine": "machine-1",
|
||||
"type": "THRESHOLD",
|
||||
"comment": None,
|
||||
"start": "2026-09-03T04:00:00Z",
|
||||
"end": None,
|
||||
"reason": None,
|
||||
"updated": None,
|
||||
}
|
||||
client.get.side_effect = [
|
||||
Mock(body={"data": [{**base, "uuid": "d1"}], "metadata": {"next_cursor": "next"}}),
|
||||
Mock(
|
||||
body={
|
||||
"data": [
|
||||
{
|
||||
**base,
|
||||
"uuid": "d2",
|
||||
"end": "2026-09-03T05:00:00Z",
|
||||
"reason": {
|
||||
"uuid": "r",
|
||||
"name": "Break",
|
||||
"description": None,
|
||||
"group": "General",
|
||||
"category": "PLANNED",
|
||||
},
|
||||
}
|
||||
],
|
||||
"metadata": {"next_cursor": None},
|
||||
}
|
||||
),
|
||||
]
|
||||
result = EnlyzeApiGateway(client).get_downtimes(
|
||||
"machine-1",
|
||||
start=datetime(2026, 9, 3, tzinfo=UTC),
|
||||
)
|
||||
assert [item.uuid for item in result] == ["d1", "d2"]
|
||||
assert result[0].end is None and result[0].reason_category is None
|
||||
assert result[1].reason_category == "PLANNED"
|
||||
assert client.get.call_args_list[1].args == (
|
||||
"/v2/downtimes",
|
||||
{"machine": "machine-1", "start": "2026-09-03T00:00:00+00:00", "cursor": "next"},
|
||||
)
|
||||
|
||||
|
||||
def test_get_open_production_run_returns_current_run() -> None:
|
||||
client = Mock()
|
||||
client.get.return_value.body = {
|
||||
@@ -138,11 +184,19 @@ def test_get_material_samples_uses_column_names_not_fixed_positions() -> None:
|
||||
@pytest.fixture
|
||||
def timeseries():
|
||||
client = Mock()
|
||||
client.post_json.return_value.body = {"data": {
|
||||
"columns": ["time", "rate", "gate"], "records": [],
|
||||
}}
|
||||
args = dict(machine_id="m", rate_variable_id="rate", gate_variable_id="gate",
|
||||
start=datetime(2026, 9, 3, tzinfo=UTC), end=datetime(2026, 9, 4, tzinfo=UTC))
|
||||
client.post_json.return_value.body = {
|
||||
"data": {
|
||||
"columns": ["time", "rate", "gate"],
|
||||
"records": [],
|
||||
}
|
||||
}
|
||||
args = dict(
|
||||
machine_id="m",
|
||||
rate_variable_id="rate",
|
||||
gate_variable_id="gate",
|
||||
start=datetime(2026, 9, 3, tzinfo=UTC),
|
||||
end=datetime(2026, 9, 4, tzinfo=UTC),
|
||||
)
|
||||
return EnlyzeApiGateway(client), client, args
|
||||
|
||||
|
||||
@@ -176,9 +230,19 @@ def test_missing_required_column(timeseries, column) -> None:
|
||||
gateway.get_material_samples(**args)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("record", [[], {}, ["bad", 1, 1], [None, 1, 1],
|
||||
["2026-09-03T00:00:00", 1, 1], ["2026-09-03T00:00:00Z", None, 1],
|
||||
["2026-09-03T00:00:00Z", 1, float("inf")], ["2026-09-03T00:00:00Z", True, 1]])
|
||||
@pytest.mark.parametrize(
|
||||
"record",
|
||||
[
|
||||
[],
|
||||
{},
|
||||
["bad", 1, 1],
|
||||
[None, 1, 1],
|
||||
["2026-09-03T00:00:00", 1, 1],
|
||||
["2026-09-03T00:00:00Z", None, 1],
|
||||
["2026-09-03T00:00:00Z", 1, float("inf")],
|
||||
["2026-09-03T00:00:00Z", True, 1],
|
||||
],
|
||||
)
|
||||
def test_malformed_records_fail_clearly(timeseries, record) -> None:
|
||||
gateway, client, args = timeseries
|
||||
client.post_json.return_value.body["data"]["records"] = [record]
|
||||
@@ -188,15 +252,21 @@ def test_malformed_records_fail_clearly(timeseries, record) -> None:
|
||||
|
||||
def test_multiple_open_runs_rejected() -> None:
|
||||
client = Mock()
|
||||
run = dict(uuid="r", machine="m", production_order="o", start="2026-09-03T00:00:00Z",
|
||||
end=None)
|
||||
run = dict(uuid="r", machine="m", production_order="o", start="2026-09-03T00:00:00Z", end=None)
|
||||
client.get.return_value.body = {"data": [run, {**run, "uuid": "r2"}]}
|
||||
with pytest.raises(ValueError, match="multiple open Production Runs"):
|
||||
EnlyzeApiGateway(client).get_open_production_run("m")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("item", [{}, None, {"end": None},
|
||||
dict(uuid="r", machine="m", production_order="o", start="bad", end=None)])
|
||||
@pytest.mark.parametrize(
|
||||
"item",
|
||||
[
|
||||
{},
|
||||
None,
|
||||
{"end": None},
|
||||
dict(uuid="r", machine="m", production_order="o", start="bad", end=None),
|
||||
],
|
||||
)
|
||||
def test_malformed_run_fails_clearly(item) -> None:
|
||||
client = Mock()
|
||||
client.get.return_value.body = {"data": [item]}
|
||||
@@ -219,16 +289,24 @@ def test_timeseries_with_null_cursor(timeseries) -> None:
|
||||
def test_timeseries_pagination_resolves_columns_per_page(timeseries) -> None:
|
||||
gateway, client, args = timeseries
|
||||
client.post_json.side_effect = [
|
||||
Mock(body={
|
||||
"data": {"columns": ["time", "rate", "gate"],
|
||||
"records": [["2026-09-03T00:00:00Z", 10, 2]]},
|
||||
"metadata": {"next_cursor": "continuation"},
|
||||
}),
|
||||
Mock(body={
|
||||
"data": {"columns": ["gate", "time", "rate"],
|
||||
"records": [[3, "2026-09-03T01:00:00Z", 20]]},
|
||||
"metadata": {"next_cursor": None},
|
||||
}),
|
||||
Mock(
|
||||
body={
|
||||
"data": {
|
||||
"columns": ["time", "rate", "gate"],
|
||||
"records": [["2026-09-03T00:00:00Z", 10, 2]],
|
||||
},
|
||||
"metadata": {"next_cursor": "continuation"},
|
||||
}
|
||||
),
|
||||
Mock(
|
||||
body={
|
||||
"data": {
|
||||
"columns": ["gate", "time", "rate"],
|
||||
"records": [[3, "2026-09-03T01:00:00Z", 20]],
|
||||
},
|
||||
"metadata": {"next_cursor": None},
|
||||
}
|
||||
),
|
||||
]
|
||||
samples = gateway.get_material_samples(**args)
|
||||
assert [(s.timestamp, s.material_rate_kg_per_hour, s.gate_value) for s in samples] == [
|
||||
@@ -237,16 +315,32 @@ def test_timeseries_pagination_resolves_columns_per_page(timeseries) -> None:
|
||||
]
|
||||
assert client.post_json.call_count == 2
|
||||
first, second = client.post_json.call_args_list
|
||||
assert first.args == ("/v2/timeseries", {
|
||||
"machine": "m", "start": args["start"].isoformat(), "end": args["end"].isoformat(),
|
||||
"variables": [{"uuid": "rate"}, {"uuid": "gate"}],
|
||||
})
|
||||
assert first.args == (
|
||||
"/v2/timeseries",
|
||||
{
|
||||
"machine": "m",
|
||||
"start": args["start"].isoformat(),
|
||||
"end": args["end"].isoformat(),
|
||||
"variables": [{"uuid": "rate"}, {"uuid": "gate"}],
|
||||
},
|
||||
)
|
||||
assert second.args == ("/v2/timeseries", {**first.args[1], "cursor": "continuation"})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("metadata", [None, [], "bad", {},
|
||||
{"next_cursor": ""}, {"next_cursor": 1}, {"next_cursor": False},
|
||||
{"next_cursor": []}, {"next_cursor": {}}])
|
||||
@pytest.mark.parametrize(
|
||||
"metadata",
|
||||
[
|
||||
None,
|
||||
[],
|
||||
"bad",
|
||||
{},
|
||||
{"next_cursor": ""},
|
||||
{"next_cursor": 1},
|
||||
{"next_cursor": False},
|
||||
{"next_cursor": []},
|
||||
{"next_cursor": {}},
|
||||
],
|
||||
)
|
||||
def test_invalid_pagination_metadata(timeseries, metadata) -> None:
|
||||
gateway, client, args = timeseries
|
||||
client.post_json.return_value.body["metadata"] = metadata
|
||||
@@ -267,25 +361,38 @@ def test_repeated_pagination_cursor(timeseries, cursors) -> None:
|
||||
assert client.post_json.call_count == len(cursors)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("data, error", [
|
||||
({"columns": ["time", "rate", "gate"], "records": [[]]}, "malformed record 0"),
|
||||
({"columns": ["time", "rate", "rate", "gate"], "records": []}, "required column"),
|
||||
({"columns": ["time", "rate"], "records": []}, "required column"),
|
||||
({"columns": None, "records": []}, "columns must be a list"),
|
||||
({"columns": ["time", "rate", "gate"], "records": None}, "records must be a list"),
|
||||
({"columns": ["time", "rate", "gate"],
|
||||
"records": [["2026-09-03T01:00:00", 10, 2]]}, "timezone-aware"),
|
||||
({"columns": ["time", "rate", "gate"],
|
||||
"records": [["2026-09-03T01:00:00Z", float("nan"), 2]]}, "finite"),
|
||||
({"columns": ["time", "rate", "gate"],
|
||||
"records": [["2026-09-03T01:00:00Z", 10, True]]}, "numeric"),
|
||||
])
|
||||
@pytest.mark.parametrize(
|
||||
"data, error",
|
||||
[
|
||||
({"columns": ["time", "rate", "gate"], "records": [[]]}, "malformed record 0"),
|
||||
({"columns": ["time", "rate", "rate", "gate"], "records": []}, "required column"),
|
||||
({"columns": ["time", "rate"], "records": []}, "required column"),
|
||||
({"columns": None, "records": []}, "columns must be a list"),
|
||||
({"columns": ["time", "rate", "gate"], "records": None}, "records must be a list"),
|
||||
(
|
||||
{"columns": ["time", "rate", "gate"], "records": [["2026-09-03T01:00:00", 10, 2]]},
|
||||
"timezone-aware",
|
||||
),
|
||||
(
|
||||
{
|
||||
"columns": ["time", "rate", "gate"],
|
||||
"records": [["2026-09-03T01:00:00Z", float("nan"), 2]],
|
||||
},
|
||||
"finite",
|
||||
),
|
||||
(
|
||||
{"columns": ["time", "rate", "gate"], "records": [["2026-09-03T01:00:00Z", 10, True]]},
|
||||
"numeric",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_malformed_later_page(timeseries, data, error) -> None:
|
||||
gateway, client, args = timeseries
|
||||
first_body = client.post_json.return_value.body
|
||||
first_body["metadata"] = {"next_cursor": "continuation"}
|
||||
client.post_json.side_effect = [
|
||||
Mock(body=first_body), Mock(body={"data": data, "metadata": {"next_cursor": None}}),
|
||||
Mock(body=first_body),
|
||||
Mock(body={"data": data, "metadata": {"next_cursor": None}}),
|
||||
]
|
||||
with pytest.raises(ValueError, match=f"Invalid timeseries response on page 2:.*{error}"):
|
||||
gateway.get_material_samples(**args)
|
||||
@@ -294,8 +401,13 @@ def test_malformed_later_page(timeseries, data, error) -> None:
|
||||
|
||||
def test_production_runs_follow_pages_and_find_open_run() -> None:
|
||||
client = Mock()
|
||||
closed = dict(uuid="closed", machine="m", production_order="opaque-order",
|
||||
start="2026-09-01T00:00:00Z", end="2026-09-01T01:00:00Z")
|
||||
closed = dict(
|
||||
uuid="closed",
|
||||
machine="m",
|
||||
production_order="opaque-order",
|
||||
start="2026-09-01T00:00:00Z",
|
||||
end="2026-09-01T01:00:00Z",
|
||||
)
|
||||
opened = {**closed, "uuid": "open", "end": None}
|
||||
pages = [
|
||||
Mock(body={"data": [closed], "metadata": {"next_cursor": "a"}}),
|
||||
@@ -314,10 +426,20 @@ def test_production_runs_follow_pages_and_find_open_run() -> None:
|
||||
assert gateway.get_open_production_run("m") == runs[1]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("metadata", [
|
||||
None, [], "bad", {}, {"next_cursor": ""}, {"next_cursor": 1},
|
||||
{"next_cursor": False}, {"next_cursor": []}, {"next_cursor": {}},
|
||||
])
|
||||
@pytest.mark.parametrize(
|
||||
"metadata",
|
||||
[
|
||||
None,
|
||||
[],
|
||||
"bad",
|
||||
{},
|
||||
{"next_cursor": ""},
|
||||
{"next_cursor": 1},
|
||||
{"next_cursor": False},
|
||||
{"next_cursor": []},
|
||||
{"next_cursor": {}},
|
||||
],
|
||||
)
|
||||
def test_production_run_invalid_pagination(metadata) -> None:
|
||||
client = Mock()
|
||||
client.get.return_value.body = {"data": [], "metadata": metadata}
|
||||
@@ -329,25 +451,50 @@ def test_production_run_invalid_pagination(metadata) -> None:
|
||||
def test_production_run_cyclic_pagination(cursors) -> None:
|
||||
client = Mock()
|
||||
client.get.side_effect = [
|
||||
Mock(body={"data": [], "metadata": {"next_cursor": cursor}})
|
||||
for cursor in cursors
|
||||
Mock(body={"data": [], "metadata": {"next_cursor": cursor}}) for cursor in cursors
|
||||
]
|
||||
with pytest.raises(ValueError, match="next_cursor has already been followed"):
|
||||
EnlyzeApiGateway(client).get_production_runs("m")
|
||||
assert client.get.call_count == len(cursors)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("body", [
|
||||
None, [], {}, {"data": {}}, {"data": [None]},
|
||||
{"data": [dict(uuid="r", machine="wrong", production_order="o",
|
||||
start="2026-09-03T00:00:00Z", end=None)]},
|
||||
{"data": [dict(uuid="r", machine="m", production_order="o",
|
||||
start="2026-09-03T00:00:00Z", end="2026-09-02T00:00:00Z")]},
|
||||
])
|
||||
@pytest.mark.parametrize(
|
||||
"body",
|
||||
[
|
||||
None,
|
||||
[],
|
||||
{},
|
||||
{"data": {}},
|
||||
{"data": [None]},
|
||||
{
|
||||
"data": [
|
||||
dict(
|
||||
uuid="r",
|
||||
machine="wrong",
|
||||
production_order="o",
|
||||
start="2026-09-03T00:00:00Z",
|
||||
end=None,
|
||||
)
|
||||
]
|
||||
},
|
||||
{
|
||||
"data": [
|
||||
dict(
|
||||
uuid="r",
|
||||
machine="m",
|
||||
production_order="o",
|
||||
start="2026-09-03T00:00:00Z",
|
||||
end="2026-09-02T00:00:00Z",
|
||||
)
|
||||
]
|
||||
},
|
||||
],
|
||||
)
|
||||
def test_production_run_malformed_later_page(body) -> None:
|
||||
client = Mock()
|
||||
client.get.side_effect = [
|
||||
Mock(body={"data": [], "metadata": {"next_cursor": "a"}}), Mock(body=body),
|
||||
Mock(body={"data": [], "metadata": {"next_cursor": "a"}}),
|
||||
Mock(body=body),
|
||||
]
|
||||
with pytest.raises(ValueError, match="Invalid production-run response on page 2"):
|
||||
EnlyzeApiGateway(client).get_production_runs("m")
|
||||
|
||||
@@ -117,11 +117,11 @@ def test_persistence_before_checkpoint_retries_and_disjoint_runs():
|
||||
store.save.assert_called_once()
|
||||
|
||||
|
||||
def test_sql_persistence_idempotent_and_inactive_omitted(monkeypatch):
|
||||
def test_sql_persistence_idempotent_and_inactive_omitted(monkeypatch, sqlite_schema):
|
||||
import sqlite3
|
||||
|
||||
database = sqlite3.connect(':memory:')
|
||||
database.executescript(Path('db/schema.sql').read_text())
|
||||
database.executescript(sqlite_schema)
|
||||
connection = MagicMock()
|
||||
cursor = connection.__enter__.return_value.cursor.return_value.__enter__.return_value
|
||||
cursor.executemany.side_effect = lambda sql, rows: database.executemany(
|
||||
|
||||
@@ -48,10 +48,10 @@ SETTINGS = PostgresSettings("localhost", 5432, "analytics", "writer", "secret")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def storage(monkeypatch):
|
||||
# Real schema and INSERT semantics, with only driver transport adapted for SQLite.
|
||||
def storage(monkeypatch, sqlite_schema):
|
||||
# Production schema and INSERT semantics, adapted for the SQLite dialect/transport.
|
||||
database = sqlite3.connect(":memory:")
|
||||
database.executescript(Path("db/schema.sql").read_text())
|
||||
database.executescript(sqlite_schema)
|
||||
connection = MagicMock()
|
||||
|
||||
def execute(sql, params):
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Regression coverage for SQLite initialization from production DDL."""
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_schema_initialization_and_power_meter_json(sqlite_schema):
|
||||
with sqlite3.connect(":memory:") as database:
|
||||
database.executescript(sqlite_schema)
|
||||
database.executescript(sqlite_schema)
|
||||
tables = {
|
||||
row[0] for row in database.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
}
|
||||
assert {
|
||||
"material_consumption_snapshots",
|
||||
"material_efficiency_snapshots",
|
||||
"material_application_snapshots",
|
||||
"channel_material_consumption_snapshots",
|
||||
"production_downtime_events",
|
||||
"production_order_attribution_state",
|
||||
"power_meter_readings",
|
||||
"power_meter_monthly_reports",
|
||||
} <= tables
|
||||
|
||||
insert = """INSERT INTO power_meter_readings
|
||||
(meter, timestamp, energy_total_kwh, energy_delta_kwh,
|
||||
active_power_total_kw, grid_frequency_hz)
|
||||
VALUES (?, ?, 100, 1, 4, 50)"""
|
||||
database.execute(insert, ("B2", "2026-09-01T00:00:00Z"))
|
||||
phases = {"L1": 1.25, "L2": 2.75}
|
||||
database.execute(insert, ("B2", "2026-09-01T00:01:00Z"))
|
||||
database.execute(
|
||||
"UPDATE power_meter_readings SET phase_active_power_kw=? WHERE timestamp=?",
|
||||
(json.dumps(phases), "2026-09-01T00:01:00Z"),
|
||||
)
|
||||
rows = database.execute(
|
||||
"SELECT phase_active_power_kw FROM power_meter_readings ORDER BY timestamp"
|
||||
).fetchall()
|
||||
assert [json.loads(row[0]) for row in rows] == [{}, phases]
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
database.execute(insert, ("B2", "2026-09-01T00:00:00Z"))
|
||||
|
||||
summary = {"consumption_kwh": 42.5, "first_timestamp": None, "outages": 0}
|
||||
database.execute(
|
||||
"""INSERT INTO power_meter_monthly_reports (meter, month, report_path, summary)
|
||||
VALUES (?, ?, ?, ?)""",
|
||||
("B2", "2026-09", "/reports/B2.pdf", json.dumps(summary)),
|
||||
)
|
||||
path, stored_summary, generated_at = database.execute(
|
||||
"SELECT report_path, summary, generated_at FROM power_meter_monthly_reports"
|
||||
).fetchone()
|
||||
assert path == "/reports/B2.pdf"
|
||||
assert json.loads(stored_summary) == summary
|
||||
assert generated_at is not None
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
database.execute(
|
||||
"""INSERT INTO power_meter_monthly_reports (meter, month, report_path, summary)
|
||||
VALUES ('B2', '2026-10', '/reports/B2.pdf', NULL)"""
|
||||
)
|
||||
Reference in New Issue
Block a user