Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions docs/components.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,26 @@
"\n",
"model.run().plot()"
]
},
{
"cell_type": "markdown",
"id": "42",
"metadata": {},
"source": [
"## Saving to JSON\n",
"\n",
"To share beamline parameters and models, you can save a model to a JSON file using the `to_json` method:"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "43",
"metadata": {},
"outputs": [],
"source": [
"model.to_json(\"new_instrument.json\")"
]
}
],
"metadata": {
Expand Down
37 changes: 35 additions & 2 deletions src/tof/chopper.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import scipp as sc

from .reading import ComponentReading
from .utils import two_pi
from .utils import two_pi, var_to_dict


class Direction(Enum):
Expand Down Expand Up @@ -171,7 +171,10 @@ def __repr__(self) -> str:
f"direction={self.direction.name}, cutouts={len(self.open)})"
)

def as_dict(self):
def as_dict(self) -> dict:
"""
Return the chopper as a dictionary.
"""
return {
'frequency': self.frequency,
'open': self.open,
Expand All @@ -182,6 +185,36 @@ def as_dict(self):
'direction': self.direction,
}

def as_json(self) -> dict:
"""
Return the chopper as a JSON-serializable dictionary.
"""
out = {
key: var_to_dict(value)
for key, value in self.as_dict().items()
if isinstance(value, sc.Variable)
}
out.update(
{
'type': 'chopper',
'direction': self.direction.name.lower(),
'name': self.name,
}
)
return out

def __eq__(self, other: object) -> bool:
if not isinstance(other, Chopper):
return NotImplemented
if self.name != other.name:
return False
if self.direction != other.direction:
return False
return all(
sc.identical(getattr(self, field), getattr(other, field))
for field in ('frequency', 'distance', 'phase', 'open', 'close')
)


@dataclass(frozen=True)
class ChopperReading(ComponentReading):
Expand Down
21 changes: 20 additions & 1 deletion src/tof/detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import scipp as sc

from .reading import ComponentReading
from .utils import var_to_dict


class Detector:
Expand All @@ -29,9 +30,27 @@ def __init__(self, distance: sc.Variable, name: str):
def __repr__(self) -> str:
return f"Detector(name={self.name}, distance={self.distance:c})"

def as_dict(self):
def as_dict(self) -> dict:
"""
Return the detector as a dictionary.
"""
return {'distance': self.distance, 'name': self.name}

def as_json(self) -> dict:
"""
Return the detector as a JSON-serializable dictionary.
"""
return {
'type': 'detector',
'distance': var_to_dict(self.distance),
'name': self.name,
}

def __eq__(self, other: object) -> bool:
if not isinstance(other, Detector):
return NotImplemented
return self.name == other.name and sc.identical(self.distance, other.distance)


@dataclass(frozen=True)
class DetectorReading(ComponentReading):
Expand Down
40 changes: 40 additions & 0 deletions src/tof/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from __future__ import annotations

import warnings
from itertools import chain

import scipp as sc
Expand Down Expand Up @@ -142,6 +143,8 @@ def from_json(cls, filename: str) -> Model:
"""
Create a model from a JSON file.

Currently, only sources from facilities are supported when loading from JSON.

Parameters
----------
filename:
Expand All @@ -166,6 +169,43 @@ def from_json(cls, filename: str) -> Model:
break
return cls(source=source, **beamline)

def as_json(self) -> dict:
"""
Return the model as a JSON-serializable dictionary.
If the source is not from a facility, it is not included in the output.
"""
instrument_dict = {}
if self.source is not None:
if self.source.facility is None:
warnings.warn(
"The source is not from a facility, so it will not be included in "
"the JSON output.",
UserWarning,
stacklevel=2,
)
else:
instrument_dict['source'] = self.source.as_json()
for ch in self.choppers.values():
instrument_dict[ch.name] = ch.as_json()
for det in self.detectors.values():
instrument_dict[det.name] = det.as_json()
return instrument_dict

def to_json(self, filename: str):
"""
Save the model to a JSON file.
If the source is not from a facility, it is not included in the output.

Parameters
----------
filename:
The path to the JSON file.
"""
import json

with open(filename, 'w') as f:
json.dump(self.as_json(), f, indent=2)

def add(self, component):
"""
Add a component to the instrument.
Expand Down
13 changes: 13 additions & 0 deletions src/tof/source.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@ def __init__(
self._neutrons = int(neutrons)
self._pulses = int(pulses)
self._data = None
self.seed = seed

if self._facility is not None:
try:
Expand Down Expand Up @@ -425,6 +426,18 @@ def __repr__(self) -> str:
f" frequency={self.frequency:c}\n facility='{self.facility}'"
)

def as_json(self) -> dict:
"""
Return the source as a JSON-serializable dictionary.
"""
return {
'facility': self.facility,
'neutrons': int(self.neutrons),
'pulses': self.pulses,
'seed': self.seed,
'type': 'source',
}


@dataclass(frozen=True)
class SourceParameters:
Expand Down
15 changes: 15 additions & 0 deletions src/tof/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,21 @@ def one_mask(
return out


def var_to_dict(var: sc.Variable) -> dict:
"""
Convert a scipp Variable to a dictionary with 'value' and 'unit' keys.

Parameters
----------
var:
The variable to convert.
"""
return {
'value': var.values.tolist() if var.ndim > 0 else float(var.value),
'unit': str(var.unit),
}


@dataclass
class Plot:
ax: plt.Axes
Expand Down
125 changes: 124 additions & 1 deletion tests/chopper_test.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: BSD-3-Clause
# Copyright (c) 2023 Scipp contributors (https://github.com/scipp)
# Copyright (c) 2025 Scipp contributors (https://github.com/scipp)

import numpy as np
import pytest
Expand Down Expand Up @@ -354,3 +354,126 @@ def test_chopper_create_raises_when_both_edges_and_centers_are_supplied():
distance=5.0 * meter,
name='chopper',
)


def test_as_json():
f = 10.0 * Hz
chopper = tof.Chopper(
frequency=f,
open=7.7 * deg,
close=31.0 * deg,
phase=0.5 * deg,
distance=5.0 * meter,
name='Achopper',
)
json_str = chopper.as_json()
assert json_str['type'] == 'chopper'
assert json_str['frequency']['value'] == chopper.frequency.value
assert json_str['frequency']['unit'] == chopper.frequency.unit
assert np.allclose(json_str['open']['value'], chopper.open.values)
assert json_str['open']['unit'] == chopper.open.unit
assert np.allclose(json_str['close']['value'], chopper.close.values)
assert json_str['close']['unit'] == chopper.close.unit
assert json_str['phase']['value'] == chopper.phase.value
assert json_str['phase']['unit'] == chopper.phase.unit
assert json_str['distance']['value'] == chopper.distance.value
assert json_str['distance']['unit'] == chopper.distance.unit
assert json_str['name'] == chopper.name
assert json_str['direction'] == chopper.direction.name.lower()


def test_equal():
chop = tof.Chopper(
frequency=10.0 * Hz,
open=sc.arange('cutout', 0, 180, 20.0, unit='deg'),
close=sc.arange('cutout', 10, 190, 20.0, unit='deg'),
phase=0.0 * deg,
distance=5.0 * meter,
name='chopper',
)
assert chop == chop


@pytest.mark.parametrize("attr", ["frequency", "open", "close", "phase", "distance"])
def test_not_equal(attr):
chop1 = tof.Chopper(
frequency=10.0 * Hz,
open=sc.arange('cutout', 0, 180, 20.0, unit='deg'),
close=sc.arange('cutout', 10, 190, 20.0, unit='deg'),
phase=0.0 * deg,
distance=5.0 * meter,
name='chopper1',
)
chop2 = tof.Chopper(
frequency=10.0 * Hz,
open=sc.arange('cutout', 0, 180, 20.0, unit='deg'),
close=sc.arange('cutout', 10, 190, 20.0, unit='deg'),
phase=0.0 * deg,
distance=5.0 * meter,
name='chopper1',
)
setattr(
chop2,
attr,
getattr(chop1, attr) + sc.scalar(1.0, unit=getattr(chop1, attr).unit),
)
assert chop1 != chop2


def test_not_equal_different_name():
chop1 = tof.Chopper(
frequency=10.0 * Hz,
open=sc.arange('cutout', 0, 180, 20.0, unit='deg'),
close=sc.arange('cutout', 10, 190, 20.0, unit='deg'),
phase=0.0 * deg,
distance=5.0 * meter,
name='chopper1',
)
chop2 = tof.Chopper(
frequency=10.0 * Hz,
open=sc.arange('cutout', 0, 180, 20.0, unit='deg'),
close=sc.arange('cutout', 10, 190, 20.0, unit='deg'),
phase=0.0 * deg,
distance=5.0 * meter,
name='chopper2',
)
assert chop1 != chop2


@pytest.mark.parametrize("attr", ["frequency", "open", "close", "phase", "distance"])
def test_not_equal_different_unit(attr):
chop1 = tof.Chopper(
frequency=10.0 * Hz,
open=sc.arange('cutout', 0, 180, 20.0, unit='deg'),
close=sc.arange('cutout', 10, 190, 20.0, unit='deg'),
phase=0.0 * deg,
distance=5.0 * meter,
name='chopper1',
)
chop2 = tof.Chopper(
frequency=10.0 * Hz,
open=sc.arange('cutout', 0, 180, 20.0, unit='deg'),
close=sc.arange('cutout', 10, 190, 20.0, unit='deg'),
phase=0.0 * deg,
distance=5.0 * meter,
name='chopper1',
)
if attr == "frequency":
setattr(
chop2,
attr,
getattr(chop1, attr).to(unit='kHz'),
)
elif attr == "distance":
setattr(
chop2,
attr,
getattr(chop1, attr).to(unit='cm'),
)
elif attr in ["open", "close", "phase"]:
setattr(
chop2,
attr,
getattr(chop1, attr).to(unit='rad'),
)
assert chop1 != chop2
33 changes: 33 additions & 0 deletions tests/detector_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# SPDX-License-Identifier: BSD-3-Clause
# Copyright (c) 2025 Scipp contributors (https://github.com/scipp)

import scipp as sc

import tof


def test_as_json():
detector = tof.Detector(distance=sc.scalar(54.0, unit='m'), name='DetectorX')

json_str = detector.as_json()

assert json_str['type'] == 'detector'
assert json_str['distance']['value'] == detector.distance.value
assert json_str['distance']['unit'] == str(detector.distance.unit)
assert json_str['name'] == detector.name


def test_equal():
detector1 = tof.Detector(distance=sc.scalar(54.0, unit='m'), name='DetectorX')
detector2 = tof.Detector(distance=sc.scalar(54.0, unit='m'), name='DetectorX')
detector3 = tof.Detector(distance=sc.scalar(60.0, unit='m'), name='DetectorY')
detector4 = tof.Detector(distance=sc.scalar(54.0, unit='m'), name='DetectorY')
detector5 = tof.Detector(
distance=sc.scalar(54.0, unit='m').to(unit='cm'), name='DetectorX'
)

assert detector1 == detector2
assert detector1 != detector3
assert detector4 != detector3
assert detector1 != detector4
assert detector1 != detector5
Loading
Loading