Skip to content

Add saving components and model to json#102

Merged
nvaytet merged 8 commits intomainfrom
to-json
Nov 12, 2025
Merged

Add saving components and model to json#102
nvaytet merged 8 commits intomainfrom
to-json

Conversation

@nvaytet
Copy link
Copy Markdown
Member

@nvaytet nvaytet commented Nov 10, 2025

As a complementary part to #98 , we add saving a model to JSON.
This can be done simply with model.to_json('filename.json').

The output should be loadable by Model.from_json('filename.json').

Fixes #101

Comment thread tests/model_test.py
choppers = [
tof.Chopper(
frequency=14.0 * Hz,
open=sc.array(
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No changes here, just formatting

@SimonHeybrock
Copy link
Copy Markdown
Member

@nvaytet Reviewed this with Claude:

PR #102 Review: Add JSON Serialization

Critical Issues

🚨 CRITICAL BUG: Broken Roundtrip for Custom Sources

The implementation breaks save/load roundtrips for models with custom sources (created via Source.from_neutrons() or Source.from_distribution()).

Problem location: src/tof/source.py:434

def as_json(self) -> dict:
    return {
        'facility': str(self.facility),  # ← Converts None to 'None'
        'neutrons': int(self.neutrons),
        'pulses': self.pulses,
        'seed': self.seed,
        'type': 'source',
    }

What happens:

  1. Source with facility=None is serialized as "facility": "None" (string)
  2. On load, Source.__init__ lowercases it to 'none' (line 208)
  3. Attempts to import tof.facilities.none which doesn't exist
  4. Raises: ValueError: Facility 'none' not found.

How to reproduce:

import tof
import scipp as sc

# Create source from custom neutrons
birth_times = sc.linspace('event', 0, 1000, 10, unit='us')
wavelengths = sc.linspace('event', 1, 10, 10, unit='angstrom')
source = tof.Source.from_neutrons(birth_times, wavelengths, frequency=14*sc.Unit('Hz'))

# Create model
choppers = [tof.Chopper(...)]
detectors = [tof.Detector(...)]
model = tof.Model(source=source, choppers=choppers, detectors=detectors)

# Save and load
model.to_json("test.json")
loaded = tof.Model.from_json("test.json")  # ❌ CRASHES with ValueError

Why the tests missed this: All existing tests only use facility='ess', none test custom sources with facility=None.

Root cause: There are actually TWO issues:

  1. str(None) produces 'None' instead of leaving it as null in JSON
  2. Model.from_json() currently requires facility-based sources (see model.py:158-165) - it has no code path to restore custom sources

Impact: The PR description claims "The output should be loadable by Model.from_json('filename.json')" but this is false for any model using custom sources.

Other Issues

Missing Type Annotation

Location: src/tof/detector.py:49

def __eq__(self, other) -> bool:  # Missing type annotation

Should be:

def __eq__(self, other: object) -> bool:

Note: chopper.py:206 has this correctly implemented.

Violates coding standards: The project uses type annotations for function signatures.

Documentation Incomplete

The documentation in docs/components.ipynb shows:

  • ✅ How to save a model to JSON using model.to_json("new_instrument.json")
  • ❌ Does not demonstrate loading it back

A complete roundtrip example would be more useful:

# Save
model.to_json("new_instrument.json")

# Load
loaded_model = tof.Model.from_json("new_instrument.json")
loaded_model.run().plot()

What Works Well ✅

  1. Excellent test coverage

    • Comprehensive tests for equality (__eq__)
    • Tests for JSON serialization structure
    • Parametrized tests for all chopper fields
    • Tests for different units
    • Roundtrip test for ESS-based sources
  2. Good equality implementation

    • Proper use of NotImplemented for non-matching types
    • Uses sc.identical() to check Variable equality (correct approach)
    • Checks all relevant fields including name and direction
  3. Clean JSON structure

    • Includes type field for each component (chopper/detector/source)
    • Properly serializes scipp Variables to {value, unit} format
    • Handles both scalar and array Variables correctly
    • Uses lowercase for direction names
  4. Well-designed utility function

    • var_to_dict() in utils.py handles both scalars and arrays
    • Uses .tolist() for arrays to ensure JSON compatibility
    • Extracts value and unit separately
  5. Handles optional source

    • Models with source=None work correctly
    • JSON serialization skips source if None
    • Loading from JSON without source works (sets source=None)
  6. Seed field properly added

    • Source now stores self.seed = seed (line 212)
    • Seed is included in JSON serialization
    • Enables reproducible source generation

Suggested Fixes

Fix 1: Source Serialization

File: src/tof/source.py:429-439

def as_json(self) -> dict:
    return {
        'facility': self.facility,  # Don't convert None to string
        'neutrons': int(self.neutrons),
        'pulses': self.pulses,
        'seed': self.seed,
        'type': 'source',
    }

However, this only fixes serialization. Loading still won't work because...

Fix 2: Support Loading Custom Sources

File: src/tof/model.py:140-167

The from_json() method needs a code path to handle sources that aren't facility-based. Currently it only supports:

if "facility" not in item:
    raise ValueError(
        "Currently, only sources from facilities are supported when "
        "loading from JSON."
    )

This error message is honest but defeats the purpose. The PR needs to either:

  1. Support full source serialization (birth_time/wavelength distributions), OR
  2. Document the limitation clearly and add a test that verifies the error, OR
  3. Remove the claim from the PR description that output is always loadable

Fix 3: Add Type Annotation

File: src/tof/detector.py:49

def __eq__(self, other: object) -> bool:

Comment thread src/tof/model.py Outdated
"""
instrument_dict = {}
if self.source is not None:
if (self.source is not None) and (self.source.facility is not None):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this will silently not store it? Should there be a warning at least? Or just raise?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll add a warning, as it's still useful to be able to save beamline parameters.
Currently, the Model is the only way to save multiple choppers and detectors to a single JSON.

Unless we want to move to a free function that could take in different components and save to a single json blob?
Realistically though, non-facility sources are never used...

@nvaytet
Copy link
Copy Markdown
Member Author

nvaytet commented Nov 12, 2025

@SimonHeybrock did you have any more comments?

@nvaytet nvaytet merged commit f4aa9e7 into main Nov 12, 2025
4 checks passed
@nvaytet nvaytet deleted the to-json branch November 12, 2025 08:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Save model and components to json

2 participants