Skip to content

Commit 3b719aa

Browse files
Returns reporting state (#136)
* Adds empty reporting data json file to zip contents * Moves CheckResult into models.py to prevent circular imports * Started implementing generate_json_reporting_data * WIP generation of reporting data * WIP * Adds ReadingType and PackedReadings * Adds serializable Site class * Adds sites (and site der) to reporting data * Adds timeline to reporting data * Fixes dataframe serialization bug * Revert runner back to original reporting code * Replace multiple asserts with assert_class_instance_equality * Adds version number to reporting data * Put version into reporting data filename * Fix linter issues * Fix linter issues * Updates cactus-schema dependency to v0.0.15 --------- Co-authored-by: LachlanJW <watsonjlachlan@gmail.com>
1 parent b435649 commit 3b719aa

12 files changed

Lines changed: 827 additions & 48 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ classifiers = [
2222
dependencies = [
2323
"aiohttp>=3.11.12,<4",
2424
"cactus-test-definitions>=1.9.2,<2",
25-
"cactus-schema>=0.0.14,<1",
25+
"cactus-schema>=0.0.15,<1",
2626
"envoy @ git+https://github.com/bsgip/envoy.git@v1.3.2",
2727
"psycopg[binary]>=3.2.5,<4",
2828
"asyncpg>=0.30.0,<1",

src/cactus_runner/app/check.py

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
import http
22
import logging
33
import re
4-
from dataclasses import dataclass
54
from datetime import datetime, timedelta
65
from itertools import chain
76
from typing import Annotated, Any, Iterable, Optional, Sequence
8-
from cactus_runner.app.uri import does_endpoint_match
7+
98
import pydantic
109
import pydantic.alias_generators
1110
import pydantic.fields
@@ -41,7 +40,13 @@
4140
ResolvedParam,
4241
resolve_variable_expressions_from_parameters,
4342
)
44-
from cactus_runner.models import ActiveTestProcedure, ClientCertificateType, RequestEntry
43+
from cactus_runner.app.uri import does_endpoint_match
44+
from cactus_runner.models import (
45+
ActiveTestProcedure,
46+
CheckResult,
47+
ClientCertificateType,
48+
RequestEntry,
49+
)
4550

4651
logger = logging.getLogger(__name__)
4752

@@ -153,14 +158,6 @@ class ParamsDERCapabilityContents(pydantic.BaseModel):
153158
] = None
154159

155160

156-
@dataclass
157-
class CheckResult:
158-
"""Represents the results of a running a single check"""
159-
160-
passed: bool # True if the check is considered passed or successful. False otherwise
161-
description: Optional[str] # Human readable description of what the check "considered" or wants to elaborate about
162-
163-
164161
class SoftChecker:
165162
"""Collects all failed results suppressing them until finalized"""
166163

src/cactus_runner/app/finalize.py

Lines changed: 87 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from pathlib import Path
1010
from typing import cast
1111

12+
import pandas as pd
1213
from envoy.server.model.archive.site import ArchiveSiteDERSetting
1314
from envoy.server.model.site import SiteDERSetting
1415
from sqlalchemy import select
@@ -35,10 +36,22 @@
3536
)
3637
from cactus_runner.app.requests_archive import copy_request_response_files_to_archive
3738
from cactus_runner.app.status import get_active_runner_status
38-
from cactus_runner.models import RunnerState
39+
from cactus_runner.models import (
40+
CheckResult,
41+
PackedReadings,
42+
ReadingType,
43+
ReportingData,
44+
RunnerState,
45+
Site,
46+
)
47+
48+
# Cactus runner supports returning different versions of the reporting data
49+
# Define the currently preferred reporting data version
50+
CURRENT_REPORTING_DATA_VERSION: int = 1
3951

4052
GENERATION_ERRORS_FILE_NAME = "generation-errors.txt"
4153

54+
4255
logger = logging.getLogger(__name__)
4356

4457

@@ -66,10 +79,12 @@ def get_file_name_no_extension(file_path: str) -> str:
6679

6780
def get_zip_contents(
6881
json_status_summary: str | None,
82+
json_reporting_data: str | None,
6983
log_file_paths: list[str],
7084
pdf_data: bytes | None,
7185
errors: list[str],
7286
filename_infix: str = "",
87+
reporting_data_filename_prefix: str | None = "ReportingData",
7388
) -> bytes:
7489
"""Returns the contents of the zipped test procedures artifacts in bytes."""
7590

@@ -89,6 +104,12 @@ def get_zip_contents(
89104
with open(file_path, "w") as f:
90105
f.write(json_status_summary)
91106

107+
# Create reporting data json file
108+
if json_reporting_data is not None and reporting_data_filename_prefix is not None:
109+
file_path = archive_dir / f"{reporting_data_filename_prefix}{filename_infix}.json"
110+
with open(file_path, "w") as f:
111+
f.write(json_reporting_data)
112+
92113
# Copy all log files into the archive - preserving the names
93114
for log_file_path in log_file_paths:
94115
log_file_name = get_file_name_no_extension(log_file_path)
@@ -221,6 +242,44 @@ async def generate_pdf(
221242
return pdf_data
222243

223244

245+
async def generate_json_reporting_data(
246+
runner_state: RunnerState,
247+
check_results: dict[str, CheckResult],
248+
readings: dict[ReadingType, pd.DataFrame],
249+
reading_counts: dict[ReadingType, int],
250+
sites: list[Site],
251+
timeline: timeline.Timeline | None,
252+
errors,
253+
version: int = 1,
254+
set_max_w_varied: bool = False,
255+
) -> str | None:
256+
created_at = datetime.now(timezone.utc)
257+
258+
try:
259+
# Repack readings into something serializable
260+
packed_readings = [
261+
PackedReadings(reading_type=k, readings_as_json=readings[k].to_json(), reading_counts=v)
262+
for k, v in reading_counts.items()
263+
]
264+
265+
reporting_data = ReportingData.v(version)(
266+
created_at=created_at,
267+
runner_state=runner_state,
268+
check_results=check_results,
269+
readings=packed_readings,
270+
sites=sites,
271+
timeline=timeline,
272+
set_max_w_varied=set_max_w_varied,
273+
)
274+
json_reporting_data = reporting_data.to_json()
275+
except Exception as exc:
276+
logger.error("Error generating reporting data. Omitting reporting data from final zip.", exc_info=exc)
277+
errors.append(f"Error generating reporting data: {exc}")
278+
json_reporting_data = None
279+
280+
return json_reporting_data
281+
282+
224283
async def finish_active_test(runner_state: RunnerState, session: AsyncSession) -> bytes:
225284
"""For the specified RunnerState - move the active test into a "Finished" state by calculating the final ZIP
226285
contents. Raises NoActiveTestProcedure if there isn't an active test procedure for the specified RunnerState
@@ -277,11 +336,11 @@ async def finish_active_test(runner_state: RunnerState, session: AsyncSession) -
277336
# Add a "virtual" check covering XSD errors in incoming requests
278337
xsd_error_counts = [len(rh.body_xml_errors) for rh in runner_state.request_history if rh.body_xml_errors]
279338
if xsd_error_counts:
280-
xsd_check = check.CheckResult(
339+
xsd_check = CheckResult(
281340
False, f"Detected {sum(xsd_error_counts)} xsd errors over {len(xsd_error_counts)} request(s)."
282341
)
283342
else:
284-
xsd_check = check.CheckResult(True, "No XSD errors detected in any requests.")
343+
xsd_check = CheckResult(True, "No XSD errors detected in any requests.")
285344
check_results["all-requests-xsd-valid"] = xsd_check
286345

287346
# Figure out the testing timeline
@@ -299,7 +358,7 @@ async def finish_active_test(runner_state: RunnerState, session: AsyncSession) -
299358
errors.append(f"Failed to generate test timeline: {exc}")
300359
test_timeline = None
301360

302-
# Fetch raw DB data and create PDF
361+
# Fetch raw DB data and create PDF
303362
try:
304363
sites = await get_sites(session)
305364
readings = await get_readings(session, reading_specifiers=MANDATORY_READING_SPECIFIERS)
@@ -329,15 +388,38 @@ async def finish_active_test(runner_state: RunnerState, session: AsyncSession) -
329388
errors=errors,
330389
set_max_w_varied=set_max_w_varied,
331390
)
391+
392+
# Convert to serialisable types
393+
serializable_readings = {ReadingType.from_site_reading_type(k): v for k, v in readings.items()}
394+
serializable_reading_counts = {ReadingType.from_site_reading_type(k): v for k, v in reading_counts.items()}
395+
serializable_sites = [Site.from_site(s) for s in sites]
396+
397+
# Collect reporting state into json object
398+
reporting_data_version = CURRENT_REPORTING_DATA_VERSION
399+
json_reporting_data = await generate_json_reporting_data(
400+
runner_state=runner_state,
401+
check_results=check_results,
402+
readings=serializable_readings,
403+
reading_counts=serializable_reading_counts,
404+
sites=serializable_sites,
405+
timeline=test_timeline,
406+
errors=errors,
407+
version=reporting_data_version,
408+
set_max_w_varied=set_max_w_varied,
409+
)
410+
reporting_data_filename_prefix = f"ReportingData_v{reporting_data_version}"
332411
except Exception as exc:
333412
logger.error("Failed to generate PDF report", exc_info=exc)
334413
errors.append(f"Failed to generate PDF report: {exc}")
335414
pdf_data = None
415+
json_reporting_data = None
416+
reporting_data_filename_prefix = None
336417

337418
generation_timestamp = now.replace(microsecond=0)
338419

339420
active_test_procedure.finished_zip_data = get_zip_contents(
340421
json_status_summary=json_status_summary,
422+
json_reporting_data=json_reporting_data,
341423
log_file_paths=[
342424
LOG_FILE_ENVOY_SERVER,
343425
LOG_FILE_ENVOY_ADMIN,
@@ -346,6 +428,7 @@ async def finish_active_test(runner_state: RunnerState, session: AsyncSession) -
346428
],
347429
pdf_data=pdf_data,
348430
filename_infix=f"_{int(generation_timestamp.timestamp())}_{active_test_procedure.name}",
431+
reporting_data_filename_prefix=reporting_data_filename_prefix,
349432
errors=errors,
350433
)
351434
return active_test_procedure.finished_zip_data

src/cactus_runner/app/readings.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from dataclasses import dataclass
22
from decimal import Decimal
3-
from typing import Sequence
3+
from typing import Any, Sequence
44

55
import pandas as pd
66
from envoy.server.model.site_reading import SiteReading, SiteReadingType
@@ -217,8 +217,16 @@ def scale_readings(reading_type: SiteReadingType, readings: Sequence[SiteReading
217217
if not readings:
218218
raise ValueError("Expected at least 1 entry in readings. Got 0/None")
219219

220+
def filter_attributes(attributes: dict[str, Any]):
221+
"""Removes attributes that start with _.
222+
223+
This is mainly about targeting and removing sqlalchemy attributes
224+
that are prefixed by `_sa_`.
225+
"""
226+
return {k: v for k, v in attributes.items() if not k.startswith("_")}
227+
220228
# Convert list of readings into a dataframe
221-
df = pd.DataFrame([reading.__dict__ for reading in readings])
229+
df = pd.DataFrame([filter_attributes(reading.__dict__) for reading in readings])
222230

223231
# Calculate value with proper scaling applied (power_10)
224232
scale_factor = Decimal(10**reading_type.power_of_ten_multiplier)

src/cactus_runner/app/reporting.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,10 @@
5353
)
5454

5555
from cactus_runner import __version__ as cactus_runner_version
56-
from cactus_runner.app.check import CheckResult
5756
from cactus_runner.app.envoy_common import ReadingLocation
5857
from cactus_runner.app.timeline import Timeline, duration_to_label
5958
from cactus_runner.models import (
59+
CheckResult,
6060
ClientCertificateType,
6161
ClientInteraction,
6262
ClientInteractionType,

src/cactus_runner/app/timeline.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from itertools import chain
55
from typing import Any, Callable, Sequence, cast
66

7+
from dataclass_wizard import JSONWizard
78
from envoy.server.model.archive import ArchiveBase
89
from envoy.server.model.archive.doe import (
910
ArchiveDynamicOperatingEnvelope,
@@ -25,7 +26,7 @@
2526

2627

2728
@dataclass
28-
class TimelineDataStream:
29+
class TimelineDataStream(JSONWizard):
2930

3031
label: str # Descriptive label of this data stream
3132
offset_watt_values: list[
@@ -36,7 +37,7 @@ class TimelineDataStream:
3637

3738

3839
@dataclass
39-
class Timeline:
40+
class Timeline(JSONWizard):
4041
"""Represents a series of regular "power" observations aligned on interval_seconds offsets relative to start"""
4142

4243
start: datetime # The basis time

0 commit comments

Comments
 (0)