Source code for production_configuration.resource_requirements

"""Collect and summarize CORSIKA and sim_telarray resource records."""

import json
from collections.abc import Mapping
from functools import partial
from numbers import Real
from pathlib import Path

import numpy as np
from astropy import units as u
from astropy.table import Table

from simtools.io.file_type import product_filename
from simtools.production_configuration.production_file_selection import (
    discover_manifests,
    filter_manifests,
    resolve_relative_manifest_path,
)
from simtools.production_configuration.production_metadata import get_sim_telarray_event_counts

_BASELINE = "baseline"
_CANDIDATE = "candidate"
_PRODUCTION_LABEL = "production_label"
_JOB_ID = "job_id"
_MESSAGE = "message"
_ENCODING = "utf-8"
_RECORD_ENVIRONMENTS = "_record_environments"
_ROLES = ("corsika", "sim_telarray")
_UNKNOWN = "unknown"
_ENVIRONMENT_COLUMNS = ("cpu_model", "logical_cpu_count", "available_cpu_count", "container_image")
_ENVIRONMENT_GROUP_COLUMNS = (_PRODUCTION_LABEL, "role", *_ENVIRONMENT_COLUMNS)
_COVERAGE_DIMENSIONS = (
    "role",
    "primary",
    "site",
    "array_layout_name",
    "model_version",
    "energy_min_gev",
    "energy_max_gev",
    "zenith_angle_deg",
)
_STATISTIC_FUNCTIONS = {
    "mean": np.mean,
    "median": np.median,
    "std": np.std,
    "min": np.min,
    "max": np.max,
    "p05": partial(np.percentile, q=5),
    "p95": partial(np.percentile, q=95),
}
_STORAGE_OUTPUT_TYPES = ("sim_telarray", "reduced_event_data", "sim_telarray_histogram")
_OUTPUT_TYPES = ("corsika", *_STORAGE_OUTPUT_TYPES)
_COMPACT_METRICS = (
    ("wall_time_seconds_per_event", "Wall time (s/event)"),
    ("cpu_time_seconds_per_event", "CPU time (s/event)"),
    ("peak_rss_bytes", "Peak RSS (bytes)"),
    ("sim_telarray_storage_bytes_per_event", "Storage (bytes/event)"),
)


[docs] def collect_resource_requirements( production_path, selections=None, label=_BASELINE, comparison_role=None ): """Collect one resource row per recorded simulation process. Parameters ---------- production_path : str or pathlib.Path Production directory containing ``job-*`` directories. selections : list[str], optional Manifest selection expressions accepted by ``filter_manifests``. label : str, optional Label identifying this production in combined results. comparison_role : str, optional Stable comparison role, either ``"baseline"`` or ``"candidate"``. Returns ------- tuple[list[dict], list[dict]] Valid resource rows and diagnostics for resource records that cannot be used. Missing CORSIKA output files are represented by ``None``. """ manifests = filter_manifests(discover_manifests(production_path), selections or []) if not manifests: raise ValueError(f"No production jobs matched in {production_path}.") rows = [] diagnostics = [] for manifest in manifests: for role in _ROLES: try: rows.append(_resource_row(manifest, role, label, comparison_role)) except (AttributeError, OSError, TypeError, ValueError) as exc: diagnostics.append( { _PRODUCTION_LABEL: label, _JOB_ID: manifest.data.get(_JOB_ID), "run_number": manifest.run_number, "role": role, _MESSAGE: str(exc), } ) if not rows: raise ValueError( f"No valid CORSIKA or sim_telarray resource records found in {production_path}." ) return rows, diagnostics
[docs] def write_resource_requirements(args_dict, output_directory, plotter): """Collect, write, and plot resource requirements for one or two productions. Parameters ---------- args_dict : dict Compute-comparison application arguments. output_directory : pathlib.Path Destination directory for result products. plotter : callable Plot function accepting rows, output path, and figure formats. Returns ------- dict Paths of the ECSV, JSON, Markdown, and plot outputs plus diagnostics. """ output_directory = Path(output_directory) selections = list(args_dict.get("select") or []) layouts = args_dict.get("array_layout_name") or [] if len(layouts) > 1: raise ValueError("Computing comparison supports one selected array layout at a time.") if layouts: selections.append(f"configuration.array_layout_name={layouts[0]}") rows, diagnostics = collect_resource_requirements( args_dict["baseline_path"], selections, label=args_dict.get("baseline_label") or _BASELINE, comparison_role=_BASELINE, ) if args_dict.get("candidate_path"): candidate_rows, candidate_diagnostics = collect_resource_requirements( args_dict["candidate_path"], selections, label=args_dict.get("candidate_label") or _CANDIDATE, comparison_role=_CANDIDATE, ) rows.extend(candidate_rows) diagnostics.extend(candidate_diagnostics) _validate_resource_coverage(rows, diagnostics) report_mode = args_dict.get("report_mode", "diagnostic") table_file = None if report_mode == "compact": summary = _summarize_compact_resource_requirements(rows) else: table_file = output_directory / "resource_requirements.ecsv" resource_table(rows).write(table_file, format="ascii.ecsv", overwrite=True) summary = summarize_resource_requirements(rows) environments = _execution_environments(rows) json_file = output_directory / product_filename("resource_requirements") json_file.write_text( json.dumps( { "format_version": 1, "resource_row_count": len(rows), "diagnostic_count": len(diagnostics), "summary": summary, "diagnostics": diagnostics, "configurations": _configuration_resource_summary(rows), "execution_environments": environments, }, indent=2, allow_nan=False, ) + "\n", encoding=_ENCODING, ) report_file = output_directory / "resource_requirements.md" if report_mode == "compact": _write_compact_markdown_report(summary, diagnostics, report_file) else: write_markdown_report(summary, diagnostics, report_file) _append_environment_report(environments, report_file) plot_files = plotter( rows, output_directory, figure_format=args_dict.get("figure_format"), report_mode=report_mode, ) return { "table_file": table_file, "json_file": json_file, "report_file": report_file, "plot_files": plot_files, "diagnostics": diagnostics, }
[docs] def resource_table(rows): """Return resource rows as an ECSV-compatible Astropy table.""" return Table( rows=[ {key: value for key, value in row.items() if key != _RECORD_ENVIRONMENTS} for row in rows ] )
def _group_rows(rows, group_columns): """Group rows by the requested columns.""" grouped = {} for row in rows: grouped.setdefault(tuple(row[column] for column in group_columns), []).append(row) return grouped def _summarize_compact_resource_requirements(rows): """Summarize key resource metrics across each production and process role.""" summary = [] for (production_label, role), group_rows in sorted( _group_rows(rows, (_PRODUCTION_LABEL, "role")).items() ): item = {_PRODUCTION_LABEL: production_label, "role": role, "job_count": len(group_rows)} for metric, _ in _COMPACT_METRICS: values = [row[metric] for row in group_rows if row[metric] is not None] item[f"{metric}_median"] = float(np.median(values)) if values else None item[f"{metric}_p05"] = float(np.percentile(values, 5)) if values else None item[f"{metric}_p95"] = float(np.percentile(values, 95)) if values else None summary.append(item) return summary def _skipped_records_lines(diagnostics): """Return the Markdown lines listing skipped resource records.""" if not diagnostics: return [] return [ "", "## Skipped records", "", "| Job | Role | Reason |", "| --- | --- | --- |", *(f"| {item[_JOB_ID]} | {item['role']} | {item[_MESSAGE]} |" for item in diagnostics), ] def _write_compact_markdown_report(summary, diagnostics, output_file): """Write a short resource summary for release review.""" lines = [ "# Production resource requirements", "", f"Valid process records: {sum(item['job_count'] for item in summary)}", f"Skipped process records: {len(diagnostics)}", "", "Median and 5th/95th percentiles across jobs, grouped by production and process role.", "Hardware and concurrency are not controlled; timing ratios require review.", "", "| Production | Role | Jobs | Metric | Median | P05 | P95 |", "| --- | --- | ---: | --- | ---: | ---: | ---: |", ] for item in summary: for metric, label in _COMPACT_METRICS: values = [_format_value(item[f"{metric}_{name}"]) for name in ("median", "p05", "p95")] lines.append( f"| {item[_PRODUCTION_LABEL]} | {item['role']} | {item['job_count']} " f"| {label} | " + " | ".join(values) + " |" ) lines.extend(_skipped_records_lines(diagnostics)) Path(output_file).write_text("\n".join(lines) + "\n", encoding=_ENCODING)
[docs] def summarize_resource_requirements(rows): """Return grouped summary statistics for resource rows. The grouping dimensions deliberately exclude azimuth and run number, but preserve separate CORSIKA and sim_telarray values. """ group_columns = ( _PRODUCTION_LABEL, "role", "primary", "site", "array_layout_name", "model_version", "simtools_version", "energy_min_gev", "energy_max_gev", "energy_midpoint_gev", "zenith_angle_deg", ) metric_columns = ( "wall_time_seconds", "cpu_time_seconds", "triggered_events", "wall_time_seconds_per_event", "cpu_time_seconds_per_event", "wall_time_seconds_per_triggered_event", "cpu_time_seconds_per_triggered_event", "peak_rss_bytes", "corsika_output_bytes", "sim_telarray_output_bytes", "reduced_event_data_bytes", "sim_telarray_histogram_bytes", "sim_telarray_storage_bytes", "corsika_output_bytes_per_event", "sim_telarray_output_bytes_per_event", "sim_telarray_output_bytes_per_triggered_event", "reduced_event_data_bytes_per_event", "reduced_event_data_bytes_per_triggered_event", "sim_telarray_histogram_bytes_per_event", "sim_telarray_histogram_bytes_per_triggered_event", "sim_telarray_storage_bytes_per_event", "sim_telarray_storage_bytes_per_triggered_event", ) summary = [] for key, group_rows in _group_rows(rows, group_columns).items(): item = dict(zip(group_columns, key, strict=True)) item["job_count"] = len(group_rows) for metric in metric_columns: item.update(_metric_statistics(metric, group_rows)) summary.append(item) return sorted(summary, key=lambda item: tuple(str(item[column]) for column in group_columns))
def _metric_statistics(metric, rows): """Return aggregate statistics for one metric (``None`` when no values).""" values = [row[metric] for row in rows if row[metric] is not None] return { f"{metric}_{name}": float(func(values)) if values else None for name, func in _STATISTIC_FUNCTIONS.items() }
[docs] def write_markdown_report(summary, diagnostics, output_file): """Write a compact human-readable resource requirements report.""" output_file = Path(output_file) dimension_specs = ( (_PRODUCTION_LABEL, "Production"), ("role", "Role"), ("primary", "Primary"), ("site", "Site"), ("array_layout_name", "Layout"), ("model_version", "Model version"), ("simtools_version", "simtools version"), ("energy_midpoint_gev", "Energy (GeV)"), ("zenith_angle_deg", "Zenith (deg)"), ("job_count", "Jobs"), ) varying_dimensions = { key for key, _ in dimension_specs if len({str(item[key]) for item in summary}) > 1 } dimensions = tuple( key for key, _ in dimension_specs if key in varying_dimensions or key in {_PRODUCTION_LABEL, "role", "job_count"} ) metrics = ( ("wall_time_seconds", "Wall time (s/run)"), ("cpu_time_seconds", "CPU time (s/run)"), ("wall_time_seconds_per_event", "Wall time (s/event)"), ("cpu_time_seconds_per_event", "CPU time (s/event)"), ("peak_rss_bytes", "Peak RSS (bytes)"), ("corsika_output_bytes", "CORSIKA output (bytes/run)"), ("sim_telarray_output_bytes", "sim_telarray output (bytes/run)"), ("reduced_event_data_bytes", "reduced event data (bytes/run)"), ("sim_telarray_histogram_bytes", "sim_telarray histogram (bytes/run)"), ("sim_telarray_storage_bytes", "sim_telarray Storage (bytes/run)"), ("corsika_output_bytes_per_event", "CORSIKA output (bytes/event)"), ("sim_telarray_output_bytes_per_event", "sim_telarray output (bytes/event)"), ("reduced_event_data_bytes_per_event", "reduced event data (bytes/event)"), ("sim_telarray_histogram_bytes_per_event", "sim_telarray histogram (bytes/event)"), ("sim_telarray_storage_bytes_per_event", "sim_telarray Storage (bytes/event)"), ("triggered_events", "Triggered events (run)"), ("wall_time_seconds_per_triggered_event", "Wall time (s/triggered event)"), ("cpu_time_seconds_per_triggered_event", "CPU time (s/triggered event)"), ( "sim_telarray_storage_bytes_per_triggered_event", "sim_telarray Storage (bytes/triggered event)", ), ) headers = [header for key, header in dimension_specs if key in dimensions] + [ label for _, label in metrics ] lines = [ "# Production resource requirements", "", f"Valid process records: {sum(item['job_count'] for item in summary)}", f"Skipped process records: {len(diagnostics)}", "", "Each metric contains mean, median, standard deviation, minimum, and maximum.", "", "| " + " | ".join(headers) + " |", "| " + " | ".join("---:" for _ in headers) + " |", ] for item in summary: values = [ _format_value(item[dimension]) if dimension in {"energy_midpoint_gev", "zenith_angle_deg"} else item[dimension] for dimension in dimensions ] values.extend(_format_statistic(item, metric) for metric, _ in metrics) lines.append("| " + " | ".join(str(value) for value in values) + " |") lines.extend(_skipped_records_lines(diagnostics)) output_file.write_text("\n".join(lines) + "\n", encoding=_ENCODING)
def _resource_row(manifest, role, label, comparison_role=None): """Build one validated normalized resource row.""" record_paths = _resource_record_paths(manifest, role) records = [json.loads(path.read_text(encoding=_ENCODING)) for path in record_paths] for record, record_path in zip(records, record_paths, strict=True): _validate_record(record, record_path, manifest.run_number, role) record = _combine_records(records) configuration = manifest.data["configuration"] showers = configuration["showers_per_run"] energy_min = _quantity_value(configuration["energy_min"], u.GeV) energy_max = _quantity_value(configuration["energy_max"], u.GeV) output_sizes = _output_sizes(manifest, role) triggered_events = _event_count(manifest.data.get("statistics", {}).get("triggered_events")) if role == "sim_telarray" and triggered_events is None: event_counts = get_sim_telarray_event_counts(manifest.directory, manifest.data["files"]) triggered_events = _event_count( event_counts.get("triggered_events") if event_counts else None ) if role != "sim_telarray": triggered_events = None cpu_time = _sum_or_none(record.get("user_cpu_seconds"), record.get("system_cpu_seconds")) storage_sizes = [output_sizes[key] for key in _STORAGE_OUTPUT_TYPES] storage_size = ( sum(size for size in storage_sizes if size is not None) if any(size is not None for size in storage_sizes) else None ) wall_time = record.get("wall_time_seconds") corsika, sim_tel, reduced, histogram = ( output_sizes[key] for key in ("corsika", *_STORAGE_OUTPUT_TYPES) ) return { _PRODUCTION_LABEL: label, "comparison_role": comparison_role or label, _JOB_ID: manifest.data[_JOB_ID], "run_number": manifest.run_number, "role": role, "production_id": manifest.data.get("production_id") or _UNKNOWN, "primary": configuration["primary"], "site": configuration["site"], "array_layout_name": _layout_name(configuration["array_layout_name"]), "model_version": str(configuration["model_version"]), "simtools_version": record.get("simtools_version") or _UNKNOWN, "energy_min_gev": energy_min, "energy_max_gev": energy_max, "energy_midpoint_gev": ( float(np.sqrt(energy_min * energy_max)) if energy_min > 0 and energy_max > 0 else None ), "zenith_angle_deg": _quantity_value(configuration["zenith_angle"], u.deg), "azimuth_angle_deg": _quantity_value(configuration["azimuth_angle"], u.deg), "showers_per_run": showers, "triggered_events": triggered_events, "wall_time_seconds": wall_time, "cpu_time_seconds": cpu_time, "peak_rss_bytes": record.get("peak_rss_bytes"), "user_cpu_seconds": record.get("user_cpu_seconds"), "system_cpu_seconds": record.get("system_cpu_seconds"), "start_time": record.get("start_time"), "end_time": record.get("end_time"), "measurement_method": record.get("measurement_method"), "platform": record.get("platform"), **_execution_environment(record), _RECORD_ENVIRONMENTS: [_execution_environment(item) for item in records], "wall_time_seconds_per_event": _per_event(wall_time, showers), "cpu_time_seconds_per_event": _per_event(cpu_time, showers), "wall_time_seconds_per_triggered_event": _per_event(wall_time, triggered_events), "cpu_time_seconds_per_triggered_event": _per_event(cpu_time, triggered_events), "corsika_output_bytes": corsika, "sim_telarray_output_bytes": sim_tel, "reduced_event_data_bytes": reduced, "sim_telarray_histogram_bytes": histogram, "corsika_output_bytes_per_event": _per_event(corsika, showers), "sim_telarray_output_bytes_per_event": _per_event(sim_tel, showers), "sim_telarray_output_bytes_per_triggered_event": _per_event(sim_tel, triggered_events), "reduced_event_data_bytes_per_event": _per_event(reduced, showers), "reduced_event_data_bytes_per_triggered_event": _per_event(reduced, triggered_events), "sim_telarray_histogram_bytes_per_event": _per_event(histogram, showers), "sim_telarray_histogram_bytes_per_triggered_event": _per_event(histogram, triggered_events), "sim_telarray_storage_bytes": storage_size, "sim_telarray_storage_bytes_per_event": _per_event(storage_size, showers), "sim_telarray_storage_bytes_per_triggered_event": _per_event( storage_size, triggered_events ), "resource_record": ";".join(str(path) for path in record_paths), } def _execution_environment(record): """Return host, processor, and container identity from a resource record.""" return { "hostname": record.get("hostname") or _UNKNOWN, "cpu_model": record.get("cpu_model") or _UNKNOWN, "logical_cpu_count": record.get("logical_cpu_count"), "available_cpu_count": record.get("available_cpu_count"), "container_image": record.get("container_image") or _UNKNOWN, } def _resource_record_paths(manifest, role): """Find resource records for a manifest role and run number.""" suffix = f".{'simtel' if role == 'sim_telarray' else role}.resources" records = sorted(manifest.directory.rglob(f"*{suffix}*.json")) matching = [path for path in records if f"run{manifest.run_number:06d}" in path.parts] if not matching: raise FileNotFoundError( f"Expected one {role} resource record for run {manifest.run_number} " f"in {manifest.directory}, found {len(matching)}." ) return matching def _combine_records(records): """Combine split process records using additive time and peak RSS semantics.""" if len(records) == 1: return records[0] combined = dict(records[0]) for field in ("wall_time_seconds", "user_cpu_seconds", "system_cpu_seconds"): values = [record[field] for record in records] combined[field] = sum(values) if all(value is not None for value in values) else None rss_values = [record["peak_rss_bytes"] for record in records] combined["peak_rss_bytes"] = ( max(rss_values) if all(value is not None for value in rss_values) else None ) combined["end_time"] = records[-1].get("end_time") combined["return_code"] = max(record.get("return_code", 0) for record in records) return combined def _validate_record(record, record_path, run_number, role): """Validate resource-record fields required by this report.""" if not isinstance(record, Mapping): raise ValueError(f"Malformed resource record in {record_path}: expected a mapping.") if record.get("role") != role: raise ValueError(f"Resource record role mismatch in {record_path}: {record.get('role')!r}.") if str(record.get("run_id")) != str(run_number): raise ValueError( f"Resource record run ID mismatch in {record_path}: {record.get('run_id')!r}." ) if record.get("return_code") != 0: raise ValueError( f"Resource record failed in {record_path}: return code {record.get('return_code')!r}." ) required_fields = ( "start_time", "end_time", "wall_time_seconds", "user_cpu_seconds", "system_cpu_seconds", "peak_rss_bytes", "measurement_method", "platform", ) missing = [field for field in required_fields if field not in record] if missing: raise ValueError(f"Resource record missing fields in {record_path}: {', '.join(missing)}.") for field in ("wall_time_seconds", "user_cpu_seconds", "system_cpu_seconds", "peak_rss_bytes"): value = record[field] if value is not None and (not isinstance(value, Real) or isinstance(value, bool)): raise TypeError(f"Resource record field {field!r} is not numeric in {record_path}.") def _output_sizes(manifest, role): """Return output sizes attributable to a CORSIKA or sim_telarray process.""" sizes = {} for output_type in _OUTPUT_TYPES: belongs_to_role = (output_type == "corsika") == (role == "corsika") if not belongs_to_role: sizes[output_type] = None continue files = manifest.data["files"].get(output_type, []) paths = [resolve_relative_manifest_path(manifest.directory, path) for path in files] sizes[output_type] = sum(path.stat().st_size for path in paths) if paths else None return sizes def _quantity_value(value, unit): """Return a manifest quantity converted to the requested unit.""" quantity = value if isinstance(value, u.Quantity) else value["value"] * u.Unit(value["unit"]) return float(quantity.to_value(unit)) def _layout_name(value): """Return a stable display value for a manifest layout selection.""" return ",".join(value) if isinstance(value, list) else str(value) def _sum_or_none(first, second): """Return a sum only when both process CPU components are measured.""" return None if first is None or second is None else first + second def _event_count(value): """Return a non-negative integer event count or ``None``.""" if value is None: return None try: count = int(value) except TypeError, ValueError: return None return count if count >= 0 else None def _per_event(value, event_count): """Normalize an optional scalar by a positive event count.""" return None if value is None or event_count is None or event_count <= 0 else value / event_count def _format_value(value): """Format an optional numeric report value.""" return "-" if value is None else f"{value:.6g}" def _format_statistic(summary, metric): """Format all aggregate statistics for one metric in the Markdown report.""" statistics = {name: summary[f"{metric}_{name}"] for name in _STATISTIC_FUNCTIONS} if statistics["median"] is None: return "-" return "; ".join(f"{name}={value:.6g}" for name, value in statistics.items()) def _configuration_resource_summary(rows): """Keep small configuration-resolved resource summaries without per-job records.""" dimensions = ( "role", "primary", "site", "array_layout_name", "model_version", "energy_midpoint_gev", "zenith_angle_deg", ) metrics = [metric for metric, _ in _COMPACT_METRICS] groups = {} for row in rows: key = tuple(row.get(name) for name in dimensions) role = row.get("comparison_role") or row[_PRODUCTION_LABEL] groups.setdefault(key, {}).setdefault(role, []).append(row) output = [] for key, productions in sorted(groups.items(), key=lambda item: str(item[0])): item = dict(zip(dimensions, key, strict=True)) item["productions"] = { production: _summarize_production_samples(samples, metrics) for production, samples in productions.items() } if {_BASELINE, _CANDIDATE} <= productions.keys(): item["ratios"] = _baseline_candidate_ratios( item["productions"][_BASELINE], item["productions"][_CANDIDATE], metrics ) output.append(item) return output def _summarize_production_samples(samples, metrics): """Return the sample count and median/p95 per metric for one production.""" values = {"count": len(samples)} for metric in metrics: observations = [row[metric] for row in samples if row.get(metric) is not None] if observations: values[metric] = { "median": float(np.median(observations)), "p95": float(np.percentile(observations, 95)), } return values def _baseline_candidate_ratios(baseline, candidate, metrics): """Return candidate/baseline median ratios per metric.""" ratios = {} for metric in metrics: base_median = baseline.get(metric, {}).get("median") cand_median = candidate.get(metric, {}).get("median") ratios[metric] = ( cand_median / base_median if base_median and cand_median is not None else None ) return ratios def _validate_resource_coverage(rows, diagnostics=None): """Reject omitted configuration/role pairs instead of silently intersecting them.""" keys = { role: { tuple(row.get(key) for key in _COVERAGE_DIMENSIONS) for row in rows if row.get("comparison_role") == role } for role in (_BASELINE, _CANDIDATE) } if keys[_BASELINE] == keys[_CANDIDATE]: return details = [ f"{role} only: {_format_coverage_keys(keys[role] - keys[other])}" for role, other in ((_BASELINE, _CANDIDATE), (_CANDIDATE, _BASELINE)) if keys[role] - keys[other] ] if diagnostics: details.append( "skipped records: " + "; ".join( f"{item[_PRODUCTION_LABEL]} {item[_JOB_ID]} {item['role']}: {item[_MESSAGE]}" for item in diagnostics ) ) raise ValueError( "Resource configuration/role coverage differs between baseline and candidate (" + "; ".join(details) + ")." ) def _format_coverage_keys(keys): """Format configuration/role coverage keys for an error message.""" return ", ".join( "{" + ", ".join( f"{name}={value}" for name, value in zip(_COVERAGE_DIMENSIONS, key, strict=True) ) + "}" for key in sorted(keys, key=str) ) def _execution_environments(rows): """Count resource records and hosts per production, role, and execution environment.""" groups = {} for row in rows: for record_environment in row.get(_RECORD_ENVIRONMENTS, [row]): environment_row = {**row, **record_environment} key = tuple(environment_row.get(column) for column in _ENVIRONMENT_GROUP_COLUMNS) groups.setdefault(key, []).append(environment_row) environments = [] for key, group_rows in sorted(groups.items(), key=lambda item: str(item[0])): environment = dict(zip(_ENVIRONMENT_GROUP_COLUMNS, key, strict=True)) environment["host_count"] = len({row.get("hostname") for row in group_rows}) environment["record_count"] = len(group_rows) environments.append(environment) return environments def _append_environment_report(environments, output_file): """Append the execution environments behind the reported measurements.""" headers = ( "Production", "Role", "CPU model", "Logical CPUs", "Available CPUs", "Container", "Hosts", "Records", ) columns = (*_ENVIRONMENT_GROUP_COLUMNS, "host_count", "record_count") lines = [ "", "## Execution environments", "", "Timing comparisons are only meaningful between matching environments.", "", "| " + " | ".join(headers) + " |", "| " + " | ".join("---" for _ in headers) + " |", ] for item in environments: values = ("-" if item[column] is None else str(item[column]) for column in columns) lines.append("| " + " | ".join(values) + " |") with Path(output_file).open("a", encoding=_ENCODING) as report: report.write("\n".join(lines) + "\n")