diff --git a/CHANGELOG.md b/CHANGELOG.md index bb53e50..9be5188 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,27 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.4.1] - 2026-04-03 + +### Added + +- Add builtin mock bolometer dataset generator `datasets.bolometer_moc` +- Add bolometer observer notebook (`docs/notebooks/observer/bolometer.ipynb`) +- Add observer tests and fixtures for loading and visualizing bolometer data +- Add `plotly` to test dependencies for bolometer visualization testing + +### Changed + +- Export builtin dataset helpers from `cherab.imas.datasets` +- Update bolometer visualization output to show ray-through ratio as a percentage +- Refine documentation notebooks and formatting related to plasma and bolometer workflows + +### Fixed + +- Fix dataset path examples in fetcher docstrings +- Fix notebook raw-cell metadata formatting +- Fix Python 3.10 typing compatibility in bolometer enum `from_value()` methods by replacing `Self`-based annotations, preventing version-specific type errors + ## [0.4.0] - 2026-03-31 ### Added diff --git a/docs/notebooks/misc/fractional_abundances.ipynb b/docs/notebooks/misc/fractional_abundances.ipynb index 34a861a..58e7823 100644 --- a/docs/notebooks/misc/fractional_abundances.ipynb +++ b/docs/notebooks/misc/fractional_abundances.ipynb @@ -73,13 +73,10 @@ "cell_type": "raw", "id": "6", "metadata": { - "raw_mimetype": "text/restructuredtext", - "vscode": { - "languageId": "raw" - } + "raw_mimetype": "text/restructuredtext" }, "source": [ - "The `.solve_coronal_equilibrium` function computes the fractional abundances by setting the total density to one." + "The :obj:`.solve_coronal_equilibrium` function computes the fractional abundances by setting the total density to one." ] }, { diff --git a/docs/notebooks/observer/bolometer.ipynb b/docs/notebooks/observer/bolometer.ipynb new file mode 100644 index 0000000..9aa74d0 --- /dev/null +++ b/docs/notebooks/observer/bolometer.ipynb @@ -0,0 +1,165 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Bolometer\n", + "\n", + "Here we demonstrate how to load bolometer observers from a bolometer IDS, and visualize them to check what they look like.\n", + "The dataset used in this example is a mock bolometer dataset, which consists of different types of bolometer cameras." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "from plotly import io\n", + "\n", + "from cherab.imas.datasets import bolometer_moc\n", + "from cherab.imas.observer.bolometer import load_bolometers, visualize\n", + "\n", + "io.renderers.default = \"notebook\"" + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "## Load bolometer dataset\n" + ] + }, + { + "cell_type": "raw", + "id": "3", + "metadata": { + "raw_mimetype": "text/restructuredtext" + }, + "source": [ + "The list of instances :obj:`~cherab.tools.observers.bolometry.BolometerCamera` are loaded from the ``\"bolometer\"`` IDS." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4", + "metadata": {}, + "outputs": [], + "source": [ + "path = bolometer_moc()\n", + "bolometers = load_bolometers(path, \"r\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5", + "metadata": {}, + "outputs": [], + "source": [ + "for bolo in bolometers:\n", + " print(f\"Bolometer: {bolo.name}\")" + ] + }, + { + "cell_type": "markdown", + "id": "6", + "metadata": {}, + "source": [ + "## Visualize bolometer observers" + ] + }, + { + "cell_type": "markdown", + "id": "7", + "metadata": {}, + "source": [ + "### Pinhole camera" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8", + "metadata": {}, + "outputs": [], + "source": [ + "fig = visualize(\n", + " bolometers[0],\n", + " num_rays=100,\n", + " ray_terminate_distance=2.0e-2,\n", + " show=False,\n", + ")\n", + "fig.update_layout(template=\"plotly_dark\")" + ] + }, + { + "cell_type": "markdown", + "id": "9", + "metadata": {}, + "source": [ + "### Collimator cameras" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "10", + "metadata": {}, + "outputs": [], + "source": [ + "fig = visualize(\n", + " bolometers[1],\n", + " num_rays=100,\n", + " ray_from_channel=[0, 3],\n", + " ray_terminate_distance=2.0e-2,\n", + " show=False,\n", + ")\n", + "fig.update_layout(template=\"plotly_dark\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "11", + "metadata": {}, + "outputs": [], + "source": [ + "fig = visualize(\n", + " bolometers[2],\n", + " num_rays=200,\n", + " ray_from_channel=[0, 3],\n", + " ray_terminate_distance=2.0e-2,\n", + " show=False,\n", + ")\n", + "fig.update_layout(template=\"plotly_dark\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "docs", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/notebooks/plasma/emission.ipynb b/docs/notebooks/plasma/emission.ipynb index 01c93fd..f430553 100644 --- a/docs/notebooks/plasma/emission.ipynb +++ b/docs/notebooks/plasma/emission.ipynb @@ -63,10 +63,7 @@ "cell_type": "raw", "id": "4", "metadata": { - "mime_type": "text/restructuredtext", - "vscode": { - "languageId": "raw" - } + "raw_mimetype": "text/restructuredtext" }, "source": [ "When using OpenADAS data first time, the relevant data files must be downloaded and installed.\n", diff --git a/docs/source/_static/images/bolometer.png b/docs/source/_static/images/bolometer.png new file mode 100644 index 0000000..71dc798 Binary files /dev/null and b/docs/source/_static/images/bolometer.png differ diff --git a/docs/source/conf.py b/docs/source/conf.py index b2d4d0f..9bc74d1 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -183,6 +183,9 @@ # -- NBSphinx configuration --------------------------------------------------- # nbsphinx_execute = "never" +nbsphinx_thumbnails = { + "notebooks/observer/bolometer": "_static/images/bolometer.png", +} nbsphinx_prolog = r""" {% set docname = 'docs/' + env.doc2path(env.docname, base=None)|string %} diff --git a/docs/source/examples.md b/docs/source/examples.md index 33096d8..b8fc4b0 100644 --- a/docs/source/examples.md +++ b/docs/source/examples.md @@ -14,6 +14,17 @@ Each notebook is designed to showcase specific functionalities and provide pract notebooks/plasma/* ``` +## Observer + +```{eval-rst} +.. nbgallery:: + :name: observer-gallery + :glob: + :reversed: + + notebooks/observer/* +``` + ## Miscellaneous ```{eval-rst} diff --git a/pyproject.toml b/pyproject.toml index 80f3e7f..8fcb661 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,7 @@ dependencies = [ dynamic = ["version"] [project.optional-dependencies] -test = ["pytest", "pytest-cov", "pooch", "rich"] +test = ["pytest", "pytest-cov", "pooch", "rich", "plotly"] [project.urls] Homepage = "https://github.com/cherab" diff --git a/src/cherab/imas/datasets/__init__.py b/src/cherab/imas/datasets/__init__.py index 3f7b9a2..1bf0faf 100644 --- a/src/cherab/imas/datasets/__init__.py +++ b/src/cherab/imas/datasets/__init__.py @@ -1,11 +1,11 @@ -r"""Sample Dataset utilities for fetching and processing data. +r"""Sample Dataset utilities. Usage of Datasets ================= CHERAB-IMAS dataset methods can be simply called as follows: ``'()'`` -This downloads the dataset files over the network once, and saves the cache, -before returning the path to the downloaded data file. +This downloads/creates the dataset files over the network once, and saves the cache, +before returning the path to the downloaded/created data file. How dataset retrieval and storage works ======================================= @@ -46,7 +46,14 @@ the internet connectivity. """ +from ._builtin import bolometer_moc from ._fetchers import iter_jintrac, iter_jorek, iter_solps from ._utils import clear_cache -__all__ = ["iter_jintrac", "iter_solps", "iter_jorek", "clear_cache"] +__all__ = [ + "iter_jintrac", + "iter_solps", + "iter_jorek", + "bolometer_moc", + "clear_cache", +] diff --git a/src/cherab/imas/datasets/_builtin.py b/src/cherab/imas/datasets/_builtin.py new file mode 100644 index 0000000..5f7a8ff --- /dev/null +++ b/src/cherab/imas/datasets/_builtin.py @@ -0,0 +1,256 @@ +"""Provide functionality to create builtin IMAS sample datasets.""" + +import datetime + +import numpy as np +from raysect.core.math import Point3D, Vector3D, rotate_z + +from imas import DBEntry, IDSFactory +from imas.ids_defs import IDS_TIME_MODE_HOMOGENEOUS + +try: + import pooch + +except ImportError: + pooch = None + +N_CH = 5 # Number of channels per camera +N_APERTURE = 3 # Number of apertures per channel (for collimator cameras) +N_SUBCOL = 3 # Number of subcollimators (for collimator cameras with subcollimator) +POSITION = (9.0, 0.0) # (R, Z) +SLIT_WIDTH = 4.0e-3 +SLIT_HEIGHT = 5.0e-3 +FOIL_WIDTH = 1.3e-3 +FOIL_HEIGHT = 3.8e-3 +SLIT_SENSOR_SEPARATION = 4.0e-2 +FOIL_SEPARATION = 5.08e-3 +SLIT_SEPARATION = 7.5e-3 +SUBCOL_SEPARATION = 1.0e-3 + +Y_AXIS = Vector3D(0, 1, 0) + + +def _bolo_data(): + """ + Create a mock bolometer IDS dataset. + + Returns + ------- + IDSToplevel + Mock bolometer IDS dataset. + """ + ids = IDSFactory().new("bolometer") + + # Set properties + ids.ids_properties.homogeneous_time = IDS_TIME_MODE_HOMOGENEOUS + ids.ids_properties.comment = "Test bolometer IDS" + ids.ids_properties.creation_date = datetime.date.today().isoformat() + + ids.time = [0.0] + + # Set the number of cameras + ids.camera.resize(3) + + # ---------------------- + # === Pinhole camera === + # ---------------------- + camera = ids.camera[0] + camera.name = "Pinhole Camera" + camera.type = "pinhole" + + origin_slit = Point3D(POSITION[0], 0.0, POSITION[1]) + origin_foil = Point3D(POSITION[0] + SLIT_SENSOR_SEPARATION, 0.0, POSITION[1]) + basis_z = origin_foil.vector_to(origin_slit).normalise() + basis_y = Y_AXIS.copy() + basis_x = basis_y.cross(basis_z).normalise() + + camera.channel.resize(N_CH) + for i_ch in range(N_CH): + channel = camera.channel[i_ch] + + # Detector + pos_foil = origin_foil + basis_x * (i_ch - (N_CH - 1) * 0.5) * FOIL_SEPARATION + channel.detector.geometry_type = 3 + channel.detector.centre.r = np.hypot(pos_foil.x, pos_foil.y) + channel.detector.centre.z = pos_foil.z + channel.detector.centre.phi = np.arctan2(pos_foil.y, pos_foil.x) + channel.detector.x1_width = FOIL_HEIGHT + channel.detector.x2_width = FOIL_WIDTH + for xyz in ["x", "y", "z"]: + setattr(channel.detector.x1_unit_vector, xyz, getattr(basis_y, xyz)) + setattr(channel.detector.x2_unit_vector, xyz, getattr(basis_x, xyz)) + setattr(channel.detector.x3_unit_vector, xyz, getattr(basis_z, xyz)) + + # Slit + channel.aperture.resize(1) + aperture = channel.aperture[0] + aperture.geometry_type = 3 + aperture.centre.r = np.hypot(origin_slit.x, origin_slit.y) + aperture.centre.z = origin_slit.z + aperture.centre.phi = np.arctan2(origin_slit.y, origin_slit.x) + aperture.x1_width = SLIT_HEIGHT + aperture.x2_width = SLIT_WIDTH + for xyz in ["x", "y", "z"]: + setattr(aperture.x1_unit_vector, xyz, getattr(basis_y, xyz)) + setattr(aperture.x2_unit_vector, xyz, getattr(basis_x, xyz)) + setattr(aperture.x3_unit_vector, xyz, getattr(basis_z, xyz)) + + # --------------------------------------------- + # === Collimator camera (w/o subcollimator) === + # --------------------------------------------- + camera = ids.camera[1] + camera.name = "Collimator Camera" + camera.type = "collimator" + + angle = 90.0 # [deg] Angle of the collimator camera in toroidal + + origin_slit = Point3D(POSITION[0], 0.0, POSITION[1]).transform(rotate_z(angle)) + origin_foil = Point3D(POSITION[0] + SLIT_SENSOR_SEPARATION, 0.0, POSITION[1]).transform( + rotate_z(angle) + ) + basis_z = origin_foil.vector_to(origin_slit).normalise() + basis_y = Y_AXIS.transform(rotate_z(angle)) + basis_x = basis_y.cross(basis_z).normalise() + + camera.channel.resize(N_CH) + for i_ch in range(N_CH): + channel = camera.channel[i_ch] + + # Detector + pos_foil = origin_foil + basis_x * (i_ch - (N_CH - 1) * 0.5) * FOIL_SEPARATION + channel.detector.geometry_type = 3 + channel.detector.centre.r = np.hypot(pos_foil.x, pos_foil.y) + channel.detector.centre.z = pos_foil.z + channel.detector.centre.phi = np.arctan2(pos_foil.y, pos_foil.x) + channel.detector.x1_width = FOIL_HEIGHT + channel.detector.x2_width = FOIL_WIDTH + for xyz in ["x", "y", "z"]: + setattr(channel.detector.x1_unit_vector, xyz, getattr(basis_y, xyz)) + setattr(channel.detector.x2_unit_vector, xyz, getattr(basis_x, xyz)) + setattr(channel.detector.x3_unit_vector, xyz, getattr(basis_z, xyz)) + + # Slit (w/ inner apertures) + pos_slit = origin_slit + basis_x * (i_ch - (N_CH - 1) * 0.5) * SLIT_SEPARATION + _v = pos_foil.vector_to(pos_slit) + + channel.aperture.resize(N_APERTURE) + for i_ap in range(N_APERTURE): + pos_ap = pos_slit - _v * i_ap / N_APERTURE + + aperture = channel.aperture[i_ap] + aperture.geometry_type = 3 + aperture.centre.r = np.hypot(pos_ap.x, pos_ap.y) + aperture.centre.z = pos_ap.z + aperture.centre.phi = np.arctan2(pos_ap.y, pos_ap.x) + aperture.x1_width = FOIL_HEIGHT + (SLIT_HEIGHT - FOIL_HEIGHT) * (1 - i_ap / N_APERTURE) + aperture.x2_width = FOIL_WIDTH + (SLIT_WIDTH - FOIL_WIDTH) * (1 - i_ap / N_APERTURE) + for xyz in ["x", "y", "z"]: + setattr(aperture.x1_unit_vector, xyz, getattr(basis_y, xyz)) + setattr(aperture.x2_unit_vector, xyz, getattr(basis_x, xyz)) + setattr(aperture.x3_unit_vector, xyz, getattr(basis_z, xyz)) + + # -------------------------------------------- + # === Collimator camera (w/ subcollimator) === + # -------------------------------------------- + camera = ids.camera[2] + camera.name = "Collimator Camera (w/ subcollimator)" + camera.type = "collimator" + + angle = 180.0 # [deg] Angle of the collimator camera in toroidal + + origin_slit = Point3D(POSITION[0], 0.0, POSITION[1]).transform(rotate_z(angle)) + origin_foil = Point3D(POSITION[0] + SLIT_SENSOR_SEPARATION, 0.0, POSITION[1]).transform( + rotate_z(angle) + ) + basis_z = origin_foil.vector_to(origin_slit).normalise() + basis_y = Y_AXIS.transform(rotate_z(angle)) + basis_x = basis_y.cross(basis_z).normalise() + + camera.channel.resize(N_CH) + for i_ch in range(N_CH): + channel = camera.channel[i_ch] + + # Detector + pos_foil = origin_foil + basis_x * (i_ch - (N_CH - 1) * 0.5) * FOIL_SEPARATION + channel.detector.geometry_type = 3 + channel.detector.centre.r = np.hypot(pos_foil.x, pos_foil.y) + channel.detector.centre.z = pos_foil.z + channel.detector.centre.phi = np.arctan2(pos_foil.y, pos_foil.x) + channel.detector.x1_width = FOIL_HEIGHT + channel.detector.x2_width = FOIL_WIDTH + for xyz in ["x", "y", "z"]: + setattr(channel.detector.x1_unit_vector, xyz, getattr(basis_y, xyz)) + setattr(channel.detector.x2_unit_vector, xyz, getattr(basis_x, xyz)) + setattr(channel.detector.x3_unit_vector, xyz, getattr(basis_z, xyz)) + + # Slit (w/ inner apertures & subcollimator) + pos_slit = origin_slit + basis_x * (i_ch - (N_CH - 1) * 0.5) * SLIT_SEPARATION + _v = pos_foil.vector_to(pos_slit) + + channel.subcollimators_n = N_SUBCOL + channel.subcollimators_separation = SUBCOL_SEPARATION + + channel.aperture.resize(N_APERTURE * N_SUBCOL) + for i_ap in range(N_APERTURE): + pos_ap = pos_slit - _v * i_ap / N_APERTURE + width = FOIL_WIDTH + (SLIT_WIDTH - FOIL_WIDTH) * (1 - i_ap / N_APERTURE) + height = FOIL_HEIGHT + (SLIT_HEIGHT - FOIL_HEIGHT) * (1 - i_ap / N_APERTURE) + + for i_subcol in range(N_SUBCOL): + pos_ap_subcol = ( + pos_ap + + basis_y + * (i_subcol - (N_SUBCOL - 1) * 0.5) + * (height + SUBCOL_SEPARATION) + / N_SUBCOL + ) + + aperture = channel.aperture[i_ap * N_SUBCOL + i_subcol] + aperture.geometry_type = 3 + aperture.centre.r = np.hypot(pos_ap_subcol.x, pos_ap_subcol.y) + aperture.centre.z = pos_ap_subcol.z + aperture.centre.phi = np.arctan2(pos_ap_subcol.y, pos_ap_subcol.x) + aperture.x1_width = (height - SUBCOL_SEPARATION * (N_SUBCOL - 1.0)) / N_SUBCOL + aperture.x2_width = width + for xyz in ["x", "y", "z"]: + setattr(aperture.x1_unit_vector, xyz, getattr(basis_y, xyz)) + setattr(aperture.x2_unit_vector, xyz, getattr(basis_x, xyz)) + setattr(aperture.x3_unit_vector, xyz, getattr(basis_z, xyz)) + + return ids + + +def bolometer_moc() -> str: + """Return the path to a mock bolometer dataset for testing purposes. + + Returns + ------- + str + Path to the mock bolometer dataset file. + + Raises + ------ + ImportError + If the `pooch` library is not installed, which is required to fetch the dataset. + + Examples + -------- + >>> from cherab.imas import datasets + >>> data_path = datasets.bolometer_moc() + >>> data_path + '.../cherab/imas/bolometer_moc.nc' + """ + if pooch is None: + raise ImportError("The 'pooch' library is required to fetch the bolometer dataset.") + + path = pooch.os_cache("cherab/imas") / "bolometer_moc.nc" + + path.parent.mkdir(parents=True, exist_ok=True) + + if not path.exists(): + # Create the mock bolometer dataset and save it to the cache path + ids = _bolo_data() + with DBEntry(str(path), "w") as entry: + entry.put(ids) + + return str(path) diff --git a/src/cherab/imas/datasets/_fetchers.py b/src/cherab/imas/datasets/_fetchers.py index 054a9a4..ba121fc 100644 --- a/src/cherab/imas/datasets/_fetchers.py +++ b/src/cherab/imas/datasets/_fetchers.py @@ -64,7 +64,7 @@ def iter_jintrac() -> str: >>> from cherab.imas import datasets >>> data_path = datasets.iter_jintrac() >>> data_path - '.../cherab/imas/iter_jintrac/iter_scenario_53298_seq1_DD4_mod.nc' + '.../cherab/imas/iter_scenario_53298_seq1_DD4_mod.nc' """ path = fetch_data("iter_scenario_53298_seq1_DD4.nc") @@ -91,7 +91,7 @@ def iter_solps() -> str: >>> from cherab.imas import datasets >>> data_path = datasets.iter_solps() >>> data_path - '.../cherab/imas/iter_solps/iter_scenario_123364_1.nc' + '.../cherab/imas/iter_scenario_123364_1.nc' """ return fetch_data("iter_scenario_123364_1.nc") @@ -109,6 +109,6 @@ def iter_jorek() -> str: >>> from cherab.imas import datasets >>> data_path = datasets.iter_jorek() >>> data_path - '.../cherab/imas/iter_jorek/iter_disruption_113112_1.nc' + '.../cherab/imas/iter_disruption_113112_1.nc' """ return fetch_data("iter_disruption_113112_1.nc") diff --git a/src/cherab/imas/datasets/_registry.py b/src/cherab/imas/datasets/_registry.py index ccac2a2..a8f99d1 100644 --- a/src/cherab/imas/datasets/_registry.py +++ b/src/cherab/imas/datasets/_registry.py @@ -12,4 +12,5 @@ "iter_jintrac": ["iter_scenario_53298_seq1_DD4.nc", "iter_scenario_53298_seq1_DD4_mod.nc"], "iter_solps": ["iter_scenario_123364_1.nc"], "iter_jorek": ["iter_disruption_113112_1.nc"], + "bolometer_moc": ["bolometer_moc.nc"], } diff --git a/src/cherab/imas/ids/bolometer/utility.py b/src/cherab/imas/ids/bolometer/utility.py index 3c4b915..9b39253 100644 --- a/src/cherab/imas/ids/bolometer/utility.py +++ b/src/cherab/imas/ids/bolometer/utility.py @@ -18,7 +18,6 @@ """Module for bolometer utility functions.""" from enum import Enum -from typing import Self __all__ = ["CameraType", "GeometryType"] @@ -43,7 +42,7 @@ class CameraType(Enum): OTHER = 0 @classmethod - def from_value(cls, value: int) -> Self: + def from_value(cls, value: int) -> "CameraType": """Get the camera type from a value. Parameters @@ -59,7 +58,7 @@ def from_value(cls, value: int) -> Self: """ if value in cls._value2member_map_: return cls(value) - return cls.OTHER # pyright: ignore[reportReturnType] + return cls.OTHER class GeometryType(Enum): @@ -82,7 +81,7 @@ class GeometryType(Enum): RECTANGLE = 3 @classmethod - def from_value(cls, value: int) -> Self: + def from_value(cls, value: int) -> "GeometryType": """Get the geometry type from a value. Parameters @@ -98,4 +97,4 @@ def from_value(cls, value: int) -> Self: """ if value in cls._value2member_map_: return cls(value) - return cls.RECTANGLE # pyright: ignore[reportReturnType] + return cls.RECTANGLE diff --git a/src/cherab/imas/observer/__init__.py b/src/cherab/imas/observer/__init__.py index a10d71c..834e437 100644 --- a/src/cherab/imas/observer/__init__.py +++ b/src/cherab/imas/observer/__init__.py @@ -17,6 +17,10 @@ # under the Licence. """Subpackage for creating observer objects from IMAS.""" +from . import bolometer from .bolometer import load_bolometers -__all__ = ["load_bolometers"] +__all__ = [ + "bolometer", + "load_bolometers", +] diff --git a/src/cherab/imas/observer/bolometer.py b/src/cherab/imas/observer/bolometer.py index ec22fbf..e68290a 100644 --- a/src/cherab/imas/observer/bolometer.py +++ b/src/cherab/imas/observer/bolometer.py @@ -652,8 +652,9 @@ def visualize( ), ) ) - - text_num_rays_passed = f" ({count}/{num_rays * len(foils_ray_triggered)} Rays Passed)" + total_rays = num_rays * len(foils_ray_triggered) + if total_rays > 0: + text_num_rays_passed = f" ({count / total_rays:.2%} Rays Passed)" # ----------------------- # === Plot local axes === diff --git a/tests/observer/conftest.py b/tests/observer/conftest.py new file mode 100644 index 0000000..5caa73a --- /dev/null +++ b/tests/observer/conftest.py @@ -0,0 +1,15 @@ +import shutil +from pathlib import Path + +import pytest + +from cherab.imas.datasets import bolometer_moc + + +@pytest.fixture(scope="session") +def path_bolometer_moc(tmp_path_factory) -> str: + """Fixture to provide the path to a sample bolometer IMAS dataset.""" + path = Path(bolometer_moc()) + tmp_path = tmp_path_factory.mktemp("cherab-imas-data") + shutil.copy2(path, tmp_path) + return str(tmp_path / path.name) diff --git a/tests/observer/test_bolometer.py b/tests/observer/test_bolometer.py index e69de29..2bcf4ae 100644 --- a/tests/observer/test_bolometer.py +++ b/tests/observer/test_bolometer.py @@ -0,0 +1,27 @@ +from plotly import graph_objs as go + +from cherab.imas.observer.bolometer import load_bolometers, visualize + + +def test_load_bolometers(path_bolometer_moc: str) -> None: + """Test loading bolometer data from an IDS dataset.""" + bolometers = load_bolometers(path_bolometer_moc, "r") + + # Check that the bolometer cameras are loaded correctly + assert len(bolometers) == 3 + + # Check the visualization (this is a smoke test to ensure it runs without errors) + for bolo in bolometers: + fig = visualize( + bolo, num_rays=100, ray_from_channel=0, ray_terminate_distance=1e-2, show=False + ) + assert isinstance(fig, go.Figure) + + fig = visualize( + bolometers[-1], + num_rays=100, + ray_from_channel=[0, 3], + ray_terminate_distance=1e-2, + show=False, + ) + assert isinstance(fig, go.Figure)