Skip to content

Load parameters from json#98

Merged
nvaytet merged 7 commits intomainfrom
load-from-json
Oct 10, 2025
Merged

Load parameters from json#98
nvaytet merged 7 commits intomainfrom
load-from-json

Conversation

@nvaytet
Copy link
Copy Markdown
Member

@nvaytet nvaytet commented Oct 10, 2025

This allows to read in model params from a JSON file.
Example:

import json

params = {
    "source": {
        "type": "source",
        "facility": "ess",
        "neutrons": 1e6,
        "pulses": 1
    },
    "chopper1": {
        "type": "chopper",
        "frequency": {"value": 56.0, "unit": "Hz"},
        "phase": {"value": 93.244, "unit": "deg"},
        "distance": {"value": 6.85, "unit": "m"},
        "open": {
            "value": [-1.9419, 49.5756, 98.9315, 146.2165, 191.5176, 234.9179],
            "unit": "deg",
        },
        "close": {
            "value": [1.9419, 55.7157, 107.2332, 156.5891, 203.8741, 249.1752],
            "unit": "deg",
        },
        "direction": "clockwise",
    },
    "chopper2": {
        "type": "chopper",
        "frequency": {"value": 14.0, "unit": "Hz"},
        "phase": {"value": 31.080 + 14.0, "unit": "deg"},
        "distance": {"value": 8.45, "unit": "m"},
        "open": {"value": [-23.6029], "unit": "deg"},
        "close": {"value": [23.6029], "unit": "deg"},
        "direction": "clockwise",
    },
    "detector": {"type": "detector", "distance": {"value": 60.5, "unit": "m"}},
}

with open("my_instrument.json", "w") as f:
    json.dump(params, f)

model = tof.Model.from_json("my_instrument.json")

results = model.run()

"centers": [0, 72, 86.4, 115.2, 172.8, 273.6, 288.0, 302.4],
"widths": [2.46, 3.02, 3.27, 3.27, 5.02, 3.93, 3.93, 2.46],
"direction": "anti-clockwise",
"PSC1": {
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 decided it was probably better to have a flat structure where each entry has a "type".
I think it will make it easier to just add/remove components without having to place them in the correct group.

@nvaytet nvaytet requested review from MridulS and Copilot October 10, 2025 08:18
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull Request Overview

This PR implements functionality to load model parameters from JSON files, enabling users to define instruments in a structured JSON format rather than programmatically creating components. The key change is adding a Model.from_json() class method that parses JSON instrument definitions and creates appropriate model components.

Key changes:

  • Added Model.from_json() class method for loading instruments from JSON files
  • Refactored facility data structures to use the new JSON format with explicit units
  • Added comprehensive test coverage for JSON loading functionality

Reviewed Changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
src/tof/model.py Added from_json() class method and helper functions for parsing JSON instrument definitions
src/tof/source.py Converted public attributes to properties with private backing fields
tests/model_test.py Added comprehensive tests for JSON loading with and without sources
src/tof/facilities/ess/odin.py Updated data structure to new JSON format with explicit units
src/tof/facilities/ess/dream.py Updated data structure to new JSON format with explicit units
src/tof/facilities/common.py Removed file as functionality moved to model.py
docs/components.ipynb Added documentation and examples for JSON loading feature

Tip: Customize your code reviews with copilot-instructions.md. Create the file or learn how to get started.

Comment thread src/tof/source.py
Comment thread src/tof/source.py
@SimonHeybrock
Copy link
Copy Markdown
Member

SimonHeybrock commented Oct 10, 2025

@nvaytet I gave this to Claude Code to review, just to see how it works (I did not install the GitHub app, just pointed a local instance to this PR). Here is the report it made. I did not check all details, but most of it seems factual. Is this useful for you?

Edit: I you like I can ask it to implement the suggested fixed?

PR Review: scipp/tof #98 - Load parameters from JSON

Reviewer: Claude Code
Date: 2025-10-10
PR: #98
Branch: load-from-json
Author: Neil Vaytet (@nvaytet)


Summary

This PR adds the ability to load instrument models from JSON files via Model.from_json(). The implementation is functional and includes tests, but there are several issues that should be addressed before merging.

Changes Overview:

  • Added Model.from_json() class method
  • Refactored make_beamline() to work with new JSON format
  • Updated all facility definitions (ODIN, DREAM) to use new flat dict format
  • Made Source attributes read-only via @property decorators
  • Deleted src/tof/facilities/common.py (functionality moved to model.py)
  • Added comprehensive tests and documentation

🔴 Critical Issues

1. Type Annotation Mismatch

Location: src/tof/model.py:102

Issue: The Model.__init__ signature declares source: Source (non-optional), but from_json() can return a Model with source=None:

# Current signature (INCORRECT)
def __init__(
    self,
    source: Source,  # ❌ Type says Source required
    choppers: Chopper | list[Chopper] | tuple[Chopper, ...] | None = None,
    detectors: Detector | list[Detector] | tuple[Detector, ...] | None = None,
):

But in from_json() at line 137:

source = None  # Can be None!
return cls(source=source, **beamline)

Impact: Type checkers (mypy, pyright) won't catch potential None access bugs. The code works at runtime but violates type safety.

Fix:

def __init__(
    self,
    source: Source | None,  # ✅ Correctly allows None
    choppers: Chopper | list[Chopper] | tuple[Chopper, ...] | None = None,
    detectors: Detector | list[Detector] | tuple[Detector, ...] | None = None,
):

2. Silent Ignoring of Unknown Component Types

Location: src/tof/model.py:52-82

Issue: The make_beamline() function silently skips components with unknown types. This could hide typos or configuration errors.

def make_beamline(instrument: dict) -> dict[str, list[Chopper] | list[Detector]]:
    choppers = []
    detectors = []
    for name, comp in instrument.items():
        if comp["type"] == "chopper":
            choppers.append(...)
        elif comp["type"] == "detector":
            detectors.append(...)
        # ❌ Unknown types are silently ignored!
    return {"choppers": choppers, "detectors": detectors}

Example:

params = {
    "monitor": {"type": "monitor", "distance": {"value": 10.0, "unit": "m"}},
    # ^^^ Typo or unsupported type - silently ignored, no error!
}

Impact: Configuration errors go unnoticed. A user might spend time debugging why their component isn't loaded.

Fix:

def make_beamline(instrument: dict) -> dict[str, list[Chopper] | list[Detector]]:
    choppers = []
    detectors = []
    for name, comp in instrument.items():
        comp_type = comp.get("type")
        if comp_type == "chopper":
            choppers.append(...)
        elif comp_type == "detector":
            detectors.append(...)
        elif comp_type == "source":
            continue  # Handled separately in from_json()
        else:
            raise ValueError(
                f"Unknown component type '{comp_type}' for component '{name}'. "
                f"Supported types: 'chopper', 'detector', 'source'"
            )
    return {"choppers": choppers, "detectors": detectors}

3. Poor Error Message for Invalid Direction

Location: src/tof/model.py:61-63

Issue: When an invalid direction like "counterclockwise" is used, the error is a confusing KeyError instead of a helpful ValueError:

direction={"clockwise": Clockwise, "anti-clockwise": AntiClockwise}[
    comp["direction"]  # ❌ KeyError: 'counterclockwise'
]

Example Error:

KeyError: 'counterclockwise'

Impact: User doesn't know what valid values are or where the error occurred.

Fix:

direction_str = comp["direction"]
if direction_str not in {"clockwise", "anti-clockwise"}:
    raise ValueError(
        f"Invalid direction '{direction_str}' for chopper '{name}'. "
        f"Must be 'clockwise' or 'anti-clockwise'."
    )
direction = {"clockwise": Clockwise, "anti-clockwise": AntiClockwise}[direction_str]

⚠️ Medium Issues

4. Missing Type Key Causes Cryptic Error

Location: src/tof/model.py:56

Issue: Components without a "type" key cause a generic KeyError: 'type' without context.

Test result:

params = {
    "chopper1": {
        # Missing "type" field
        "frequency": {"value": 56.0, "unit": "Hz"},
        ...
    }
}
# Error: KeyError: 'type'  ❌ Not helpful

Fix:

for name, comp in instrument.items():
    if "type" not in comp:
        raise ValueError(
            f"Component '{name}' is missing required 'type' field. "
            f"Each component must have a 'type' field ('chopper', 'detector', or 'source')."
        )
    comp_type = comp["type"]
    # ... rest of logic

5. Misleading Documentation Example

Location: PR description and docs/components.ipynb:45

Issue: The example shows Python expressions in what appears to be JSON:

"phase": {"value": 31.080 + 14.0, "unit": "deg"}

Problem: JSON doesn't support expressions! This only works because it's a Python dict being dumped to JSON. A user hand-writing a JSON file might try to use expressions and get syntax errors.

Fix: Add clarifying comment in documentation:

# Note: The expression 31.080 + 14.0 is evaluated by Python when creating the dict.
# The actual JSON file will contain the computed value (45.08).
# Pure JSON files must use literal numbers only.
params = {
    "chopper2": {
        "type": "chopper",
        "frequency": {"value": 14.0, "unit": "Hz"},
        "phase": {"value": 31.080 + 14.0, "unit": "deg"},  # Becomes 45.08 in JSON
        ...
    }
}

Or show both the Python and JSON versions:

When creating the config in Python:
```python
"phase": {"value": 31.080 + 14.0, "unit": "deg"}
```

In a hand-written JSON file:
```json
"phase": {"value": 45.08, "unit": "deg"}
```

6. No Schema Validation

Issue: The JSON loader has no upfront validation. Missing required fields only fail when constructing objects, resulting in generic KeyErrors.

Example:

params = {
    "chopper1": {
        "type": "chopper",
        "frequency": {"value": 56.0, "unit": "Hz"},
        # Missing: phase, distance, open/close, direction
    }
}
# Error: KeyError: 'direction' - which field? which component?

Recommendation: Consider adding:

  1. JSON schema validation (using jsonschema library)
  2. Or at minimum, validate required fields upfront with clear errors

Example implementation:

REQUIRED_CHOPPER_FIELDS = {"frequency", "phase", "distance", "direction"}

def _validate_chopper(name: str, comp: dict):
    missing = REQUIRED_CHOPPER_FIELDS - comp.keys()
    if missing:
        raise ValueError(
            f"Chopper '{name}' is missing required fields: {missing}"
        )
    # Check that open/close OR centers/widths are present
    has_edges = "open" in comp and "close" in comp
    has_centers = "centers" in comp and "widths" in comp
    if not (has_edges or has_centers):
        raise ValueError(
            f"Chopper '{name}' must have either (open, close) or (centers, widths)"
        )

ℹ️ Minor Issues / Observations

7. Source Properties Are Now Read-Only (BREAKING CHANGE)

Location: src/tof/source.py:244-277

Change: Source attributes changed from direct attributes to @property decorators:

# Before (in __init__):
self.facility = facility
self.neutrons = neutrons

# After (in __init__):
self._facility = facility  # Private
self._neutrons = neutrons

# New properties:
@property
def facility(self) -> str | None:
    return self._facility

@property
def neutrons(self) -> int:
    return self._neutrons

Impact:

  • Good: Prevents accidental modification (source.neutrons = 999 now raises AttributeError)
  • ⚠️ Breaking: If any code was modifying these attributes, it will break
  • Not mentioned in PR description

Recommendation: Document this as a breaking change or add setters if modification is needed.


8. Deleted facilities/common.py

Location: Deleted file src/tof/facilities/common.py

Change: The make_beamline() function was moved from facilities/common.py to model.py.

Impact:

  • Cleaner organization (function is now in the module that uses it)
  • Imports in dream.py and odin.py updated correctly
  • No issues detected

Observation: The function is now more general-purpose. Consider:

  • Renaming to _parse_instrument_dict() or similar
  • Adding a docstring explaining the expected dict format
  • Making it private (_make_beamline) if it's only for internal use

9. Instrument Dict Format Change (BREAKING CHANGE)

Change: The instrument definition format changed significantly:

Before:

{
    "choppers": {
        "chopper1": {"frequency": ..., "distance": ...},
        "chopper2": {...}
    },
    "detectors": {
        "detector1": {"distance": ...}
    }
}

After:

{
    "chopper1": {"type": "chopper", "frequency": ..., "distance": ...},
    "chopper2": {"type": "chopper", ...},
    "detector1": {"type": "detector", "distance": ...}
}

Impact:

  • ✅ Cleaner, flatter structure
  • ✅ More extensible (easy to add new component types)
  • ✅ All facility definitions updated in this PR
  • ⚠️ Breaking change for any external code using the old format

Observation: This is a good change! The new format is cleaner and more maintainable.


10. Test Coverage is Good

Positive: The PR includes comprehensive tests:

  • test_model_from_json() - Loading with source
  • test_model_from_json_no_source() - Loading without source
  • ✅ Tests verify model can be run after loading
  • ✅ Tests verify source can be added after loading
  • ✅ Documentation with working example

Test Results: 69/71 tests pass. The 2 failures are pre-existing plotting issues unrelated to this PR.


✅ What Works Well

  1. Core functionality is solid - JSON loading works correctly
  2. Good error handling - Clear error when trying to run without source
  3. Backwards compatible - Existing code using Model() directly still works
  4. Comprehensive refactoring - All facility definitions updated consistently
  5. Good documentation - Nice example in Jupyter notebook
  6. Source encapsulation - Making attributes read-only is good practice
  7. Test coverage - New functionality is well-tested

🔧 Recommended Changes (Priority Order)

Must Fix Before Merge (Critical)

  1. ✅ Fix type annotation: source: Source | None in __init__
  2. ✅ Add validation for unknown component types in make_beamline()
  3. ✅ Better error message for invalid direction values

Should Fix (Medium Priority)

  1. ⚠️ Add explicit check and error for missing "type" field
  2. ⚠️ Clarify expression evaluation in documentation examples
  3. ⚠️ Consider adding JSON schema validation or better field validation

Nice to Have (Low Priority)

  1. ℹ️ Document breaking changes (read-only Source properties, dict format)
  2. ℹ️ Add docstring to make_beamline() explaining expected format
  3. ℹ️ Consider making make_beamline() private (_make_beamline)

Test Results

============================= test session starts ==============================
platform linux -- Python 3.11.13, pytest-8.4.2, pluggy-1.6.0
collected 71 items

tests/model_test.py::test_model_from_json PASSED                         [ 40%]
tests/model_test.py::test_model_from_json_no_source PASSED               [ 42%]

69 passed, 2 failed (unrelated plotting issues)

Custom validation tests:


Suggested Code Fixes

Fix 1: Type Annotation

# src/tof/model.py:100-108
def __init__(
    self,
    source: Source | None,  # Changed from 'Source' to 'Source | None'
    choppers: Chopper | list[Chopper] | tuple[Chopper, ...] | None = None,
    detectors: Detector | list[Detector] | tuple[Detector, ...] | None = None,
):
    self.choppers = _input_to_dict(choppers, kind=Chopper)
    self.detectors = _input_to_dict(detectors, kind=Detector)
    self.source = source

Fix 2: Validate Component Types

# src/tof/model.py:52-82
def make_beamline(instrument: dict) -> dict[str, list[Chopper] | list[Detector]]:
    """
    Parse an instrument dictionary and create chopper and detector objects.

    The instrument dict should have component names as keys and dicts as values.
    Each component dict must have a 'type' field ('chopper' or 'detector').
    """
    choppers = []
    detectors = []
    for name, comp in instrument.items():
        if "type" not in comp:
            raise ValueError(
                f"Component '{name}' is missing required 'type' field"
            )

        comp_type = comp["type"]
        if comp_type == "chopper":
            # Validate direction before accessing
            direction_str = comp.get("direction")
            if direction_str not in {"clockwise", "anti-clockwise"}:
                raise ValueError(
                    f"Invalid direction '{direction_str}' for chopper '{name}'. "
                    f"Must be 'clockwise' or 'anti-clockwise'."
                )

            choppers.append(
                Chopper(
                    frequency=comp["frequency"]["value"]
                    * sc.Unit(comp["frequency"]["unit"]),
                    direction={"clockwise": Clockwise, "anti-clockwise": AntiClockwise}[
                        direction_str
                    ],
                    open=_array_or_none(comp, "open"),
                    close=_array_or_none(comp, "close"),
                    centers=_array_or_none(comp, "centers"),
                    widths=_array_or_none(comp, "widths"),
                    phase=comp["phase"]["value"] * sc.Unit(comp["phase"]["unit"]),
                    distance=comp["distance"]["value"]
                    * sc.Unit(comp["distance"]["unit"]),
                    name=name,
                )
            )
        elif comp_type == "detector":
            detectors.append(
                Detector(
                    distance=comp["distance"]["value"]
                    * sc.Unit(comp["distance"]["unit"]),
                    name=name,
                )
            )
        elif comp_type == "source":
            continue  # Handled separately in from_json()
        else:
            raise ValueError(
                f"Unknown component type '{comp_type}' for component '{name}'. "
                f"Supported types: 'chopper', 'detector', 'source'"
            )
    return {"choppers": choppers, "detectors": detectors}

Fix 3: Clarify Documentation

# docs/components.ipynb (add markdown cell before example)
"""
## Loading from a JSON file

**Note about expressions**: The examples below use Python expressions like
`31.080 + 14.0` when creating the dictionary. These are evaluated by Python
before writing to JSON. Hand-written JSON files must use literal numbers:
`"value": 45.08` instead of `"value": 31.080 + 14.0`.
"""

Conclusion

Overall Assessment: ⚠️ Approve with changes required

The PR implements a useful feature and is well-tested. However, the 3 critical issues should be fixed before merging to ensure:

  1. Type safety (fix annotation)
  2. Better error messages (validation)
  3. Catching user errors (unknown types)

The medium-priority issues would improve user experience but are not blockers.

Estimated effort to fix: ~1-2 hours for critical issues + tests


Additional Files Reviewed:

  • src/tof/model.py - Main implementation
  • src/tof/source.py - Property changes
  • src/tof/facilities/ess/odin.py - Refactored format
  • src/tof/facilities/ess/dream.py - Refactored format
  • tests/model_test.py - New tests
  • docs/components.ipynb - Documentation

@nvaytet
Copy link
Copy Markdown
Member Author

nvaytet commented Oct 10, 2025

Is this useful for you?

Yes, very much so.

Edit: I you like I can ask it to implement the suggested fixed?

Yes please!

Edit: I did it myself 😎

@nvaytet nvaytet merged commit fa38781 into main Oct 10, 2025
4 checks passed
@nvaytet nvaytet deleted the load-from-json branch October 10, 2025 14:59
@nvaytet
Copy link
Copy Markdown
Member Author

nvaytet commented Oct 10, 2025

Merging as reviewed quite thoroughly by Claude

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.

3 participants