"""Encoding and decoding logic for :mod:`python_app.models.run_config_schema`.""" from __future__ import annotations import math from typing import Any from python_app.models.run_config_schema import ( ComboModel, GprRxGeometryModel, GprTxGeometryModel, PreprocessAssetModel, PreprocessNotchModel, RunConfigModel, ) from python_app.models.run_config_validation import ( load_control_button_payload, load_ring_payload, load_switch_payload, validate_gpr_model, ) def _as_dict(value: Any, context: str) -> dict[str, Any]: """Validate payload node is object-like, treating missing values as empty object.""" if value is None: return {} if not isinstance(value, dict): raise ValueError(f"{context} must be a JSON object") return value def _as_list(value: Any, context: str) -> list[Any]: """Validate payload node is array-like, treating missing values as an empty list. A present-but-non-array value is rejected (rather than silently dropped) so a malformed config section fails loudly instead of quietly emptying out. """ if value is None: return [] if not isinstance(value, list): raise ValueError(f"{context} must be a JSON array") return value def _read_str(payload: dict[str, Any], key: str, default: str) -> str: """Return payload string, treating an explicit JSON `null` as missing. `payload.get(key, default)` returns `None` when the key exists with value `null`, which is then coerced into the literal string `"None"` by `str()`. """ value = payload.get(key, default) if value is None: return default # A JSON array/object reaching a scalar field is a config error, not a # str() fallback; surface it as ValueError to keep the error contract uniform. if isinstance(value, (dict, list)): raise ValueError(f"{key} must be a JSON string") return str(value) def _read_int(payload: dict[str, Any], key: str, default: int) -> int: """Return payload integer, treating an explicit JSON `null` as 'use default'. Without this, `int(payload.get(key, default))` raises TypeError on an explicit `null`. JSON arrays/objects (and other non-numeric scalars) are rejected as ValueError so malformed types share the config-error contract. """ value = payload.get(key, default) if value is None: return default # Accept only a genuine JSON integer (not bool, not float, not numeric string): # silently truncating 5.7 or parsing "5" would hide a malformed config. Mirrors # gui_profile_codec._optional_int so the two codecs agree. if isinstance(value, bool) or not isinstance(value, int): raise ValueError(f"{key} must be a JSON integer") return value def _read_float(payload: dict[str, Any], key: str, default: float) -> float: """Return payload float, treating an explicit JSON `null` as 'use default'. Rejects JSON arrays/objects (and other non-numeric scalars) as ValueError, and rejects non-finite values (NaN/Infinity) at decode time so the C++ pipeline never receives a value it cannot honor. """ value = payload.get(key, default) if value is None: return default # Accept only a genuine JSON number (int/float, not bool, not numeric string): # parsing "1e9" would hide a malformed config. Mirrors gui_profile_codec. if isinstance(value, bool) or not isinstance(value, (int, float)): raise ValueError(f"{key} must be a JSON number") result = float(value) if not math.isfinite(result): raise ValueError(f"{key} must be a finite number") return result def _read_bool(payload: dict[str, Any], key: str, default: bool) -> bool: """Return payload boolean, treating an explicit JSON `null` as 'use default'. Plain `bool(payload.get(key, default))` would silently flip the default to `False` on an explicit `null`; here `null` keeps the default instead. Non-boolean JSON types are rejected as ValueError. """ value = payload.get(key, default) if value is None: return default if not isinstance(value, bool): raise ValueError(f"{key} must be a JSON boolean") return value def _load_preprocess_asset(payload: dict[str, Any], target: PreprocessAssetModel) -> None: """Load preprocess asset fields into target model.""" target.set_name = _read_str(payload, "set_name", target.set_name) target.bundle_path = _read_str(payload, "bundle_path", target.bundle_path) def _load_string_list(payload: dict[str, Any], key: str, context: str) -> list[str]: """Load an optional list of strings with strict shape validation.""" raw_value = payload.get(key, []) if raw_value is None: return [] if not isinstance(raw_value, list): raise ValueError(f"{context}.{key} must be a JSON array") values: list[str] = [] for index, item in enumerate(raw_value): if not isinstance(item, str): raise ValueError(f"{context}.{key}[{index}] must be a JSON string") values.append(item) return values def _load_string_dict(payload: dict[str, Any], key: str, context: str) -> dict[str, str]: """Load an optional string-to-string dictionary with strict shape validation.""" raw_value = payload.get(key, {}) if raw_value is None: return {} if not isinstance(raw_value, dict): raise ValueError(f"{context}.{key} must be a JSON object") values: dict[str, str] = {} for item_key, item_value in raw_value.items(): if not isinstance(item_key, str) or not isinstance(item_value, str): raise ValueError(f"{context}.{key} must contain only string keys and values") values[item_key] = item_value return values def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel: """Decode JSON-like payload into :class:`RunConfigModel`.""" # Schema carries only minimal-safe fallbacks; operational defaults live in run_config.json. model = RunConfigModel() radar_payload = _as_dict(payload.get("radar"), "radar") sweep_payload = _as_dict(radar_payload.get("sweep"), "radar.sweep") switches_payload = _as_dict(payload.get("switches"), "switches") port1_payload = _as_dict(switches_payload.get("port1"), "switches.port1") port2_payload = _as_dict(switches_payload.get("port2"), "switches.port2") control_button_payload = _as_dict(payload.get("control_button"), "control_button") run_payload = _as_dict(payload.get("run"), "run") preprocess_payload = _as_dict(payload.get("preprocess"), "preprocess") gpr_payload = _as_dict(payload.get("gpr"), "gpr") rings_payload = _as_dict(payload.get("rings"), "rings") raw_ring_payload = _as_dict(rings_payload.get("raw"), "rings.raw") raw_tap_ring_payload = _as_dict(rings_payload.get("raw_tap"), "rings.raw_tap") pre_ring_payload = _as_dict(rings_payload.get("preprocessed"), "rings.preprocessed") pre_tap_ring_payload = _as_dict(rings_payload.get("preprocessed_tap"), "rings.preprocessed_tap") result_ring_payload = _as_dict(rings_payload.get("results"), "rings.results") locator_server_payload = _as_dict( run_payload.get("locator_server", run_payload.get("locator")), "run.locator_server", ) multi_device_payload = _as_dict(radar_payload.get("multi_device"), "radar.multi_device") kamil_adc_payload = _as_dict(radar_payload.get("kamil_adc"), "radar.kamil_adc") laser_control_payload = _as_dict(radar_payload.get("laser_control"), "radar.laser_control") model.radar.model = _read_str(radar_payload, "model", model.radar.model) model.radar.serial = _read_str(radar_payload, "serial", model.radar.serial) model.radar.remote_host = _read_str(radar_payload, "remote_host", model.radar.remote_host) model.radar.remote_port = _read_int(radar_payload, "remote_port", model.radar.remote_port) model.radar.driver_mode = _read_str(radar_payload, "driver_mode", model.radar.driver_mode) model.radar.mock_signal_hz = _read_float(radar_payload, "mock_signal_hz", model.radar.mock_signal_hz) model.radar.visa_library = _read_str(radar_payload, "visa_library", model.radar.visa_library) model.radar.sweep.start_hz = _read_float(sweep_payload, "start_hz", model.radar.sweep.start_hz) model.radar.sweep.stop_hz = _read_float(sweep_payload, "stop_hz", model.radar.sweep.stop_hz) model.radar.sweep.points = _read_int(sweep_payload, "points", model.radar.sweep.points) model.radar.sweep.if_bandwidth_hz = _read_float( sweep_payload, "if_bandwidth_hz", model.radar.sweep.if_bandwidth_hz ) model.radar.sweep.power_dbm = _read_float(sweep_payload, "stimulus_power_dbm", model.radar.sweep.power_dbm) slave_serials_payload = multi_device_payload.get( "slave_serials", multi_device_payload.get("slave_serial_numbers", model.radar.multi_device.slave_serials), ) if isinstance(slave_serials_payload, list): model.radar.multi_device.slave_serials = [str(value).strip() for value in slave_serials_payload if str(value).strip()] elif isinstance(slave_serials_payload, str): model.radar.multi_device.slave_serials = [ value.strip() for value in slave_serials_payload.split(",") if value.strip() ] else: raise ValueError( "radar.multi_device.slave_serials must be a JSON array or comma-separated string" ) model.radar.multi_device.force_external_reference = _read_bool( multi_device_payload, "force_external_reference", model.radar.multi_device.force_external_reference, ) model.radar.multi_device.recovery_attempts = _read_int( multi_device_payload, "recovery_attempts", model.radar.multi_device.recovery_attempts, ) model.radar.kamil_adc.project_dir = _read_str( kamil_adc_payload, "project_dir", model.radar.kamil_adc.project_dir ) model.radar.kamil_adc.executable_path = _read_str( kamil_adc_payload, "executable_path", model.radar.kamil_adc.executable_path ) model.radar.kamil_adc.tty_path = _read_str( kamil_adc_payload, "tty_path", model.radar.kamil_adc.tty_path ) model.radar.kamil_adc.args = _load_string_list(kamil_adc_payload, "args", "radar.kamil_adc") model.radar.kamil_adc.env = _load_string_dict(kamil_adc_payload, "env", "radar.kamil_adc") model.radar.kamil_adc.startup_timeout_s = _read_float( kamil_adc_payload, "startup_timeout_s", model.radar.kamil_adc.startup_timeout_s ) model.radar.kamil_adc.sweep_timeout_s = _read_float( kamil_adc_payload, "sweep_timeout_s", model.radar.kamil_adc.sweep_timeout_s ) model.radar.kamil_adc.stop_timeout_s = _read_float( kamil_adc_payload, "stop_timeout_s", model.radar.kamil_adc.stop_timeout_s ) model.radar.laser_control.enabled = _read_bool( laser_control_payload, "enabled", model.radar.laser_control.enabled ) model.radar.laser_control.port = _read_str( laser_control_payload, "port", model.radar.laser_control.port ) model.radar.laser_control.mode = _read_str( laser_control_payload, "mode", model.radar.laser_control.mode ) model.radar.laser_control.pi_coeff1_p = _read_int( laser_control_payload, "pi_coeff1_p", model.radar.laser_control.pi_coeff1_p ) model.radar.laser_control.pi_coeff1_i = _read_int( laser_control_payload, "pi_coeff1_i", model.radar.laser_control.pi_coeff1_i ) model.radar.laser_control.pi_coeff2_p = _read_int( laser_control_payload, "pi_coeff2_p", model.radar.laser_control.pi_coeff2_p ) model.radar.laser_control.pi_coeff2_i = _read_int( laser_control_payload, "pi_coeff2_i", model.radar.laser_control.pi_coeff2_i ) laser_manual_payload = _as_dict(laser_control_payload.get("manual"), "radar.laser_control.manual") model.radar.laser_control.manual.temp1 = _read_float( laser_manual_payload, "temp1", model.radar.laser_control.manual.temp1 ) model.radar.laser_control.manual.temp2 = _read_float( laser_manual_payload, "temp2", model.radar.laser_control.manual.temp2 ) model.radar.laser_control.manual.current1 = _read_float( laser_manual_payload, "current1", model.radar.laser_control.manual.current1 ) model.radar.laser_control.manual.current2 = _read_float( laser_manual_payload, "current2", model.radar.laser_control.manual.current2 ) laser_variation_payload = _as_dict( laser_control_payload.get("variation"), "radar.laser_control.variation", ) model.radar.laser_control.variation.variation_type = _read_str( laser_variation_payload, "variation_type", model.radar.laser_control.variation.variation_type, ) model.radar.laser_control.variation.static_temp1 = _read_float( laser_variation_payload, "static_temp1", model.radar.laser_control.variation.static_temp1, ) model.radar.laser_control.variation.static_temp2 = _read_float( laser_variation_payload, "static_temp2", model.radar.laser_control.variation.static_temp2, ) model.radar.laser_control.variation.static_current1 = _read_float( laser_variation_payload, "static_current1", model.radar.laser_control.variation.static_current1, ) model.radar.laser_control.variation.static_current2 = _read_float( laser_variation_payload, "static_current2", model.radar.laser_control.variation.static_current2, ) model.radar.laser_control.variation.min_value = _read_float( laser_variation_payload, "min_value", model.radar.laser_control.variation.min_value ) model.radar.laser_control.variation.max_value = _read_float( laser_variation_payload, "max_value", model.radar.laser_control.variation.max_value ) model.radar.laser_control.variation.step = _read_float( laser_variation_payload, "step", model.radar.laser_control.variation.step ) model.radar.laser_control.variation.time_step = _read_int( laser_variation_payload, "time_step", model.radar.laser_control.variation.time_step ) model.radar.laser_control.variation.delay_time = _read_int( laser_variation_payload, "delay_time", model.radar.laser_control.variation.delay_time ) load_switch_payload(port1_payload, model.output_switch) load_switch_payload(port2_payload, model.input_switch) load_control_button_payload(control_button_payload, model.control_button) model.apply_device_model_constraints() runtime = model.runtime locator = runtime.locator_server runtime.settling_ms = _read_int(run_payload, "settling_ms", runtime.settling_ms) runtime.idle_sleep_ms = _read_int(run_payload, "idle_sleep_ms", runtime.idle_sleep_ms) runtime.continuous = _read_bool(run_payload, "continuous", runtime.continuous) runtime.processing_live_config_path = _read_str( run_payload, "processing_live_config_path", runtime.processing_live_config_path ) locator.device_id = _read_int(locator_server_payload, "device_id", locator.device_id) locator.protocol_version = _read_int(locator_server_payload, "protocol_version", locator.protocol_version) locator.host = _read_str(locator_server_payload, "host", locator.host) locator.port = _read_int(locator_server_payload, "port", locator.port) locator.max_payload_bytes = _read_int(locator_server_payload, "max_payload_bytes", locator.max_payload_bytes) locator.client_queue_size = _read_int(locator_server_payload, "client_queue_size", locator.client_queue_size) locator.logger_name = _read_str(locator_server_payload, "logger_name", locator.logger_name) s21_preprocess_payload = _as_dict(preprocess_payload.get("s21"), "preprocess.s21") _load_preprocess_asset( _as_dict(s21_preprocess_payload.get("calibration"), "preprocess.s21.calibration"), model.preprocess.s21.calibration, ) _load_preprocess_asset( _as_dict(s21_preprocess_payload.get("reference"), "preprocess.s21.reference"), model.preprocess.s21.reference, ) s11_preprocess_payload = _as_dict(preprocess_payload.get("s11"), "preprocess.s11") s11_calibration_payload = _as_dict(s11_preprocess_payload.get("calibration"), "preprocess.s11.calibration") _load_preprocess_asset( _as_dict(s11_calibration_payload.get("open"), "preprocess.s11.calibration.open"), model.preprocess.s11.calibration.open, ) _load_preprocess_asset( _as_dict(s11_calibration_payload.get("short"), "preprocess.s11.calibration.short"), model.preprocess.s11.calibration.short, ) _load_preprocess_asset( _as_dict(s11_calibration_payload.get("load"), "preprocess.s11.calibration.load"), model.preprocess.s11.calibration.load, ) _load_preprocess_asset( _as_dict(s11_preprocess_payload.get("reference"), "preprocess.s11.reference"), model.preprocess.s11.reference, ) notch_payload = _as_dict(preprocess_payload.get("notch"), "preprocess.notch") model.preprocess.notch = PreprocessNotchModel( enabled=_read_bool(notch_payload, "enabled", model.preprocess.notch.enabled), taper_width_hz=_read_float(notch_payload, "taper_width_hz", model.preprocess.notch.taper_width_hz), taper_type=_read_str(notch_payload, "taper_type", model.preprocess.notch.taper_type), bands_hz=[], ) bands_payload = _as_list(notch_payload.get("bands_hz"), "preprocess.notch.bands_hz") for band in bands_payload: if not (isinstance(band, (list, tuple)) and len(band) == 2): raise ValueError("preprocess.notch.bands_hz entries must be [low_hz, high_hz] pairs") model.preprocess.notch.bands_hz.append((float(band[0]), float(band[1]))) model.gpr.relative_permittivity = _read_float( gpr_payload, "relative_permittivity", model.gpr.relative_permittivity ) model.gpr.tx_geometry = [] for entry in _as_list(gpr_payload.get("tx_geometry"), "gpr.tx_geometry"): entry_payload = _as_dict(entry, "gpr.tx_geometry[]") model.gpr.tx_geometry.append( GprTxGeometryModel( output_pos=_read_int(entry_payload, "output_pos", 0), x_m=_read_float(entry_payload, "x_m", 0.0), y_m=_read_float(entry_payload, "y_m", 0.0), z_m=_read_float(entry_payload, "z_m", 0.0), ) ) model.gpr.rx_geometry = [] for entry in _as_list(gpr_payload.get("rx_geometry"), "gpr.rx_geometry"): entry_payload = _as_dict(entry, "gpr.rx_geometry[]") model.gpr.rx_geometry.append( GprRxGeometryModel( input_pos=_read_int(entry_payload, "input_pos", 0), x_m=_read_float(entry_payload, "x_m", 0.0), y_m=_read_float(entry_payload, "y_m", 0.0), z_m=_read_float(entry_payload, "z_m", 0.0), ) ) model.apply_device_model_constraints() # Pass sweep= so #36 sweep bounds (points > 0, stop_hz >= start_hz) are validated # on the config-load path instead of crashing the C++ acquisition process at boot. validate_gpr_model( model.gpr, input_switch_positions=model.input_switch.positions, output_switch_positions=model.output_switch.positions, sweep=model.radar.sweep, ) load_ring_payload(raw_ring_payload, model.rings.raw) load_ring_payload(raw_tap_ring_payload, model.rings.raw_tap) load_ring_payload(pre_ring_payload, model.rings.preprocessed) load_ring_payload(pre_tap_ring_payload, model.rings.preprocessed_tap) load_ring_payload(result_ring_payload, model.rings.results) model.combos = [] for combo in _as_list(run_payload.get("combos"), "run.combos"): combo_payload = _as_dict(combo, "run.combos[]") model.combos.append( ComboModel( input=_read_int(combo_payload, "input", 0), output=_read_int(combo_payload, "output", 0), ) ) model.ensure_combos() return model def run_config_to_dict(model: RunConfigModel) -> dict[str, Any]: """Encode :class:`RunConfigModel` to C++ pipeline-compatible JSON structure.""" model.ensure_combos() sweep_payload = { "start_hz": model.radar.sweep.start_hz, "stop_hz": model.radar.sweep.stop_hz, "if_bandwidth_hz": model.radar.sweep.if_bandwidth_hz, "stimulus_power_dbm": model.radar.sweep.power_dbm, } if not model.is_kamil_adc: sweep_payload["points"] = model.radar.sweep.points return { "radar": { "model": model.radar.model, "serial": model.radar.serial, "remote_host": model.radar.remote_host, "remote_port": model.radar.remote_port, "driver_mode": model.radar.driver_mode, "mock_signal_hz": model.radar.mock_signal_hz, "visa_library": model.radar.visa_library, "multi_device": { "slave_serials": list(model.radar.multi_device.slave_serials), "force_external_reference": model.radar.multi_device.force_external_reference, "recovery_attempts": model.radar.multi_device.recovery_attempts, }, "kamil_adc": { "project_dir": model.radar.kamil_adc.project_dir, "executable_path": model.radar.kamil_adc.executable_path, "tty_path": model.radar.kamil_adc.tty_path, "args": list(model.radar.kamil_adc.args), "env": dict(model.radar.kamil_adc.env), "startup_timeout_s": model.radar.kamil_adc.startup_timeout_s, "sweep_timeout_s": model.radar.kamil_adc.sweep_timeout_s, "stop_timeout_s": model.radar.kamil_adc.stop_timeout_s, }, "laser_control": { "enabled": model.radar.laser_control.enabled, "port": model.radar.laser_control.port, "mode": model.radar.laser_control.mode, "pi_coeff1_p": model.radar.laser_control.pi_coeff1_p, "pi_coeff1_i": model.radar.laser_control.pi_coeff1_i, "pi_coeff2_p": model.radar.laser_control.pi_coeff2_p, "pi_coeff2_i": model.radar.laser_control.pi_coeff2_i, "manual": { "temp1": model.radar.laser_control.manual.temp1, "temp2": model.radar.laser_control.manual.temp2, "current1": model.radar.laser_control.manual.current1, "current2": model.radar.laser_control.manual.current2, }, "variation": { "variation_type": model.radar.laser_control.variation.variation_type, "static_temp1": model.radar.laser_control.variation.static_temp1, "static_temp2": model.radar.laser_control.variation.static_temp2, "static_current1": model.radar.laser_control.variation.static_current1, "static_current2": model.radar.laser_control.variation.static_current2, "min_value": model.radar.laser_control.variation.min_value, "max_value": model.radar.laser_control.variation.max_value, "step": model.radar.laser_control.variation.step, "time_step": model.radar.laser_control.variation.time_step, "delay_time": model.radar.laser_control.variation.delay_time, }, }, "sweep": sweep_payload, }, "switches": { "port1": { "name": model.output_switch.name, "driver_mode": model.output_switch.driver_mode, "driver": model.output_switch.driver, "radar_port": model.output_switch.radar_port, "positions": model.output_switch.positions, "default_position": model.output_switch.default_position, "gpio_chip": model.output_switch.gpio_chip, "pin_a": model.output_switch.pin_a, "pin_b": model.output_switch.pin_b, "invert_logic": model.output_switch.invert_logic, }, "port2": { "name": model.input_switch.name, "driver_mode": model.input_switch.driver_mode, "driver": model.input_switch.driver, "radar_port": model.input_switch.radar_port, "positions": model.input_switch.positions, "default_position": model.input_switch.default_position, "gpio_chip": model.input_switch.gpio_chip, "pin_a": model.input_switch.pin_a, "pin_b": model.input_switch.pin_b, "invert_logic": model.input_switch.invert_logic, }, }, "control_button": { "enabled": model.control_button.enabled, "gpio_chip": model.control_button.gpio_chip, "pin": model.control_button.pin, "active_low": model.control_button.active_low, "bias": model.control_button.bias, "debounce_ms": model.control_button.debounce_ms, "action": model.control_button.action, }, "run": { "settling_ms": model.runtime.settling_ms, "idle_sleep_ms": model.runtime.idle_sleep_ms, "continuous": model.runtime.continuous, "processing_live_config_path": model.runtime.processing_live_config_path, "locator_server": { "device_id": model.runtime.locator_server.device_id, "protocol_version": model.runtime.locator_server.protocol_version, "host": model.runtime.locator_server.host, "port": model.runtime.locator_server.port, "max_payload_bytes": model.runtime.locator_server.max_payload_bytes, "client_queue_size": model.runtime.locator_server.client_queue_size, "logger_name": model.runtime.locator_server.logger_name, }, "combos": [{"input": combo.input, "output": combo.output} for combo in model.combos], }, "preprocess": { "s21": { "calibration": { "set_name": model.preprocess.s21.calibration.set_name, "bundle_path": model.preprocess.s21.calibration.bundle_path, }, "reference": { "set_name": model.preprocess.s21.reference.set_name, "bundle_path": model.preprocess.s21.reference.bundle_path, }, }, "s11": { "calibration": { "open": { "set_name": model.preprocess.s11.calibration.open.set_name, "bundle_path": model.preprocess.s11.calibration.open.bundle_path, }, "short": { "set_name": model.preprocess.s11.calibration.short.set_name, "bundle_path": model.preprocess.s11.calibration.short.bundle_path, }, "load": { "set_name": model.preprocess.s11.calibration.load.set_name, "bundle_path": model.preprocess.s11.calibration.load.bundle_path, }, }, "reference": { "set_name": model.preprocess.s11.reference.set_name, "bundle_path": model.preprocess.s11.reference.bundle_path, }, }, "notch": { "enabled": model.preprocess.notch.enabled, "bands_hz": [[low_hz, high_hz] for low_hz, high_hz in model.preprocess.notch.bands_hz], "taper_width_hz": model.preprocess.notch.taper_width_hz, "taper_type": model.preprocess.notch.taper_type, }, }, "gpr": { "relative_permittivity": model.gpr.relative_permittivity, "tx_geometry": [ { "output_pos": entry.output_pos, "x_m": entry.x_m, "y_m": entry.y_m, "z_m": entry.z_m, } for entry in model.gpr.tx_geometry ], "rx_geometry": [ { "input_pos": entry.input_pos, "x_m": entry.x_m, "y_m": entry.y_m, "z_m": entry.z_m, } for entry in model.gpr.rx_geometry ], }, "rings": { "raw": { "name": model.rings.raw.name, "capacity": model.rings.raw.capacity, "slot_size_bytes": model.rings.raw.slot_size_bytes, }, "raw_tap": { "name": model.rings.raw_tap.name, "capacity": model.rings.raw_tap.capacity, "slot_size_bytes": model.rings.raw_tap.slot_size_bytes, }, "preprocessed": { "name": model.rings.preprocessed.name, "capacity": model.rings.preprocessed.capacity, "slot_size_bytes": model.rings.preprocessed.slot_size_bytes, }, "preprocessed_tap": { "name": model.rings.preprocessed_tap.name, "capacity": model.rings.preprocessed_tap.capacity, "slot_size_bytes": model.rings.preprocessed_tap.slot_size_bytes, }, "results": { "name": model.rings.results.name, "capacity": model.rings.results.capacity, "slot_size_bytes": model.rings.results.slot_size_bytes, }, }, }