Add channel material and downtime analytics

This commit is contained in:
2026-10-01 12:27:47 +02:00
parent 5452dc035b
commit 640ec53330
24 changed files with 2362 additions and 76 deletions
+206 -59
View File
@@ -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")