From ac923b9edec3b902001597945905dbfcdc890296 Mon Sep 17 00:00:00 2001 From: Edoardo Zoni Date: Wed, 12 Feb 2025 09:59:31 -0800 Subject: [PATCH 01/31] Preliminary setup: CI workflow, add base schema --- demo.py | 118 ------------------------------- schema/BaseElement.py | 13 ++++ schema/Line.py | 24 ------- schema/__init__.py | 1 - schema/elements/Drift.py | 10 --- schema/elements/Marker.py | 13 ---- schema/elements/SBend.py | 14 ---- schema/elements/__init__.py | 3 - schema/elements/base/Element.py | 13 ---- schema/elements/base/Thick.py | 14 ---- schema/elements/base/Thin.py | 10 --- schema/elements/base/__init__.py | 3 - schema_test.py | 7 ++ 13 files changed, 20 insertions(+), 223 deletions(-) delete mode 100755 demo.py create mode 100644 schema/BaseElement.py delete mode 100644 schema/Line.py delete mode 100644 schema/elements/Drift.py delete mode 100644 schema/elements/Marker.py delete mode 100644 schema/elements/SBend.py delete mode 100644 schema/elements/__init__.py delete mode 100644 schema/elements/base/Element.py delete mode 100644 schema/elements/base/Thick.py delete mode 100644 schema/elements/base/Thin.py delete mode 100644 schema/elements/base/__init__.py create mode 100644 schema_test.py diff --git a/demo.py b/demo.py deleted file mode 100755 index 4e1f82c..0000000 --- a/demo.py +++ /dev/null @@ -1,118 +0,0 @@ -#!/usr/bin/env python3 -# -# -*- coding: utf-8 -*- - -import pydantic - -import json -import toml # Python 3.11 tomllib -import yaml - -from schema import Line -from schema.elements import Drift, SBend, Marker - -print(pydantic.__version__) - - -# define -line = Line( - line=[ - Drift(ds=1.0), - SBend(ds=0.5, rc=5), - Marker(name="midpoint"), - ] -) -line.line.extend( - [ - Line( - line=[ - Drift(ds=1.0), - Marker(name="otherpoint"), - SBend(ds=0.5, rc=5, name="special-bend"), - SBend(ds=0.5, rc=5), - ] - ) - ] -) - -# doc strings -print(SBend.__doc__) -# help(SBend) # more to explore for simplified output, like pydantic-autodoc in Sphinx - -# export -print(f"Python:\n{line}") -model = line.model_dump(mode="json", exclude_none=True) -print(f"JSON model:\n{model}") -model_py = line.model_dump(mode="python", exclude_none=True) -print(f"Python model:\n{model_py}") - - -model_json = json.dumps(line.model_dump(exclude_none=True), sort_keys=True, indent=4) -print(model_json) - -with open("line.pmad.json", "w") as out_file: - out_file.write(model_json) - - -# import -with open("line.pmad.json", "r") as in_file: - read_json_dict = json.loads(in_file.read()) - -print(read_json_dict) - -# validate -read_json_model = Line(**read_json_dict) -print(read_json_model) - -# ensures correctness in construction, read-from-file -# AND in interactive use -try: - Drift(ds=-1.0) # fails with: Input should be greater than 0 -except pydantic.ValidationError as e: - print(e) - -try: - d = Drift(ds=1.0) - d.ds = -1.0 # fails with: Input should be greater than 0 -except pydantic.ValidationError as e: - print(e) -print(d) - -# json schema file for validation outside of pydantic -with open("line.pmad.json.schema", "w") as out_file: - out_file.write(json.dumps(line.model_json_schema(), sort_keys=True, indent=4)) - - -# yaml! -# export -with open("line.pmad.yaml", "w") as out_file: - yaml.dump(line.model_dump(exclude_none=True), out_file) - - -# import -def read_yaml(file_path: str) -> dict: - with open(file_path, "r") as stream: - config = yaml.safe_load(stream) - - return Line(**config).model_dump() - - -read_yaml_dict = read_yaml("line.pmad.yaml") - -read_yaml_model = Line(**read_yaml_dict) -print(read_yaml_model) - - -# toml! (looks surprisingly ugly -.-) -# export -with open("line.pmad.toml", "w") as out_file: - toml.dump(line.model_dump(exclude_none=True), out_file) - -# import -with open("line.pmad.toml", "r") as in_file: - read_toml_dict = toml.load(in_file) - -read_toml_model = Line(**read_toml_dict) -print(read_toml_model) - -# XML: https://github.com/martinblech/xmltodict diff --git a/schema/BaseElement.py b/schema/BaseElement.py new file mode 100644 index 0000000..226cb70 --- /dev/null +++ b/schema/BaseElement.py @@ -0,0 +1,13 @@ +from pydantic import BaseModel, ConfigDict +from typing import Optional + + +class BaseElement(BaseModel): + """A custom base element defining common properties""" + + # Validate every time a new value is assigned to an attribute, + # not only when an instance of BaseElement is created + model_config = ConfigDict(validate_assignment=True) + + # Unique element name + name: Optional[str] = None diff --git a/schema/Line.py b/schema/Line.py deleted file mode 100644 index c94970f..0000000 --- a/schema/Line.py +++ /dev/null @@ -1,24 +0,0 @@ -from pydantic import BaseModel, Field -from typing import List, Union - -from .elements import Drift, SBend, Marker - - -class Line(BaseModel): - """A line of elements and/or other lines""" - - line: List[Union[Drift, SBend, Marker, "Line"]] = Field( - ..., discriminator="element" - ) - """A list of elements and/or other lines""" - - # Hints for pure Python usage - class Config: - validate_assignment = True - - -# Hints for pure Python usage -Line.update_forward_refs() - -# TODO / Ideas -# - Validate the Element.name is, if set, unique in a Line (including nested lines). diff --git a/schema/__init__.py b/schema/__init__.py index d97886b..e69de29 100644 --- a/schema/__init__.py +++ b/schema/__init__.py @@ -1 +0,0 @@ -from .Line import Line diff --git a/schema/elements/Drift.py b/schema/elements/Drift.py deleted file mode 100644 index f82b99c..0000000 --- a/schema/elements/Drift.py +++ /dev/null @@ -1,10 +0,0 @@ -from .base import Thick - -from typing import Literal - - -class Drift(Thick): - """A drift element""" - - element: Literal["drift"] = "drift" - """The element type""" diff --git a/schema/elements/Marker.py b/schema/elements/Marker.py deleted file mode 100644 index 6bb28eb..0000000 --- a/schema/elements/Marker.py +++ /dev/null @@ -1,13 +0,0 @@ -from .base import Thin - -from typing import Literal - - -class Marker(Thin): - """A marker of a position in a line""" - - element: Literal["marker"] = "marker" - """The element type""" - - name: str - """"A unique name for the element when placed in the line""" diff --git a/schema/elements/SBend.py b/schema/elements/SBend.py deleted file mode 100644 index a5a4e8a..0000000 --- a/schema/elements/SBend.py +++ /dev/null @@ -1,14 +0,0 @@ -from .base import Thick - -from annotated_types import Gt -from typing import Annotated, Literal - - -class SBend(Thick): - """An ideal sector bend.""" - - element: Literal["sbend"] = "sbend" - """The element type""" - - rc: Annotated[float, Gt(0)] - """Radius of curvature in m""" diff --git a/schema/elements/__init__.py b/schema/elements/__init__.py deleted file mode 100644 index 2788c90..0000000 --- a/schema/elements/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .Drift import Drift -from .SBend import SBend -from .Marker import Marker diff --git a/schema/elements/base/Element.py b/schema/elements/base/Element.py deleted file mode 100644 index 6247817..0000000 --- a/schema/elements/base/Element.py +++ /dev/null @@ -1,13 +0,0 @@ -from pydantic import BaseModel -from typing import Optional - - -class Element(BaseModel): - """A mix-in model for elements, defining common properties""" - - name: Optional[str] = None - """A unique name for the element when placed in the line""" - - # Hints for pure Python usage - class Config: - validate_assignment = True diff --git a/schema/elements/base/Thick.py b/schema/elements/base/Thick.py deleted file mode 100644 index 39b2775..0000000 --- a/schema/elements/base/Thick.py +++ /dev/null @@ -1,14 +0,0 @@ -from annotated_types import Gt -from typing import Annotated - -from .Element import Element - - -class Thick(Element): - """A mix-in model for elements with finite segment length""" - - ds: Annotated[float, Gt(0)] - """Segment length in m""" - - nslice: int = 1 - """Number of slices through the segment (might be numerics and not phyics, thus might be removed)""" diff --git a/schema/elements/base/Thin.py b/schema/elements/base/Thin.py deleted file mode 100644 index 1d5273f..0000000 --- a/schema/elements/base/Thin.py +++ /dev/null @@ -1,10 +0,0 @@ -from typing import Literal - -from .Element import Element - - -class Thin(Element): - """A mix-in model for elements with finite segment length""" - - element: Literal["ds"] = 0.0 - """Segment length in m (thin elements are always zero)""" diff --git a/schema/elements/base/__init__.py b/schema/elements/base/__init__.py deleted file mode 100644 index 339b534..0000000 --- a/schema/elements/base/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .Element import Element -from .Thin import Thin -from .Thick import Thick diff --git a/schema_test.py b/schema_test.py new file mode 100644 index 0000000..684b8fc --- /dev/null +++ b/schema_test.py @@ -0,0 +1,7 @@ +from schema import BaseElement + + +def test_BaseElement(): + element_name = "my_element" + element = BaseElement(name=element_name) + assert element.name == element_name From 208067743a9477317d2dbb224e260778c1b2cdfa Mon Sep 17 00:00:00 2001 From: Edoardo Zoni Date: Wed, 12 Feb 2025 10:23:04 -0800 Subject: [PATCH 02/31] Add pytest to conda environment, minimal .gitignore --- .gitignore | 162 +----------------------------------------------- environment.yml | 1 + 2 files changed, 2 insertions(+), 161 deletions(-) diff --git a/.gitignore b/.gitignore index eed53e5..b267296 100644 --- a/.gitignore +++ b/.gitignore @@ -1,163 +1,3 @@ -# Byte-compiled / optimized / DLL files __pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ .pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/#use-with-ide -.pdm.toml - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ - -# temporary files from demo.py -line.pmad.* +.ruff_cache/ diff --git a/environment.yml b/environment.yml index 0fbe4a2..cb53eb5 100644 --- a/environment.yml +++ b/environment.yml @@ -7,6 +7,7 @@ channels: dependencies: - pre-commit - pydantic + - pytest - python - pyyaml - toml From a2af96e1377e67d4150cc5847353a5cf234a8701 Mon Sep 17 00:00:00 2001 From: Edoardo Zoni Date: Wed, 12 Feb 2025 10:34:03 -0800 Subject: [PATCH 03/31] Fix import statement in test file --- schema_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/schema_test.py b/schema_test.py index 684b8fc..4e92bf8 100644 --- a/schema_test.py +++ b/schema_test.py @@ -1,4 +1,4 @@ -from schema import BaseElement +from schema.BaseElement import BaseElement def test_BaseElement(): From 2a3f341882f1944c126463442be583366a847ca8 Mon Sep 17 00:00:00 2001 From: Edoardo Zoni Date: Wed, 12 Feb 2025 11:03:09 -0800 Subject: [PATCH 04/31] Add GitHub Actions workflow file, requirements --- .github/workflows/unit_tests.yaml | 29 ++++++++++++++++++++++++++ requirements.txt | 5 +++++ tests/__init__.py | 0 schema_test.py => tests/test_schema.py | 0 4 files changed, 34 insertions(+) create mode 100644 .github/workflows/unit_tests.yaml create mode 100644 requirements.txt create mode 100644 tests/__init__.py rename schema_test.py => tests/test_schema.py (100%) diff --git a/.github/workflows/unit_tests.yaml b/.github/workflows/unit_tests.yaml new file mode 100644 index 0000000..45f9e57 --- /dev/null +++ b/.github/workflows/unit_tests.yaml @@ -0,0 +1,29 @@ +name: pals + +on: + push: + branches: + - "main" + pull_request: + +concurrency: + group: ${{ github.ref }}-${{ github.head_ref }}-pals + cancel-in-progress: true + +jobs: + unit_tests: + name: unit tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + - name: Test + run: | + pytest tests diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..30a3e81 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +pydantic +pytest +python +pyyaml +toml diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/schema_test.py b/tests/test_schema.py similarity index 100% rename from schema_test.py rename to tests/test_schema.py From ff5559bc2640338f3187ba504c6b108fa98c32ef Mon Sep 17 00:00:00 2001 From: Edoardo Zoni Date: Wed, 12 Feb 2025 11:25:05 -0800 Subject: [PATCH 05/31] Add thick base element --- .github/workflows/unit_tests.yaml | 2 +- schema/ThickElement.py | 11 +++++++++++ tests/test_schema.py | 28 +++++++++++++++++++++++++++- 3 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 schema/ThickElement.py diff --git a/.github/workflows/unit_tests.yaml b/.github/workflows/unit_tests.yaml index 45f9e57..1e02c2a 100644 --- a/.github/workflows/unit_tests.yaml +++ b/.github/workflows/unit_tests.yaml @@ -26,4 +26,4 @@ jobs: pip install -r requirements.txt - name: Test run: | - pytest tests + pytest tests -v diff --git a/schema/ThickElement.py b/schema/ThickElement.py new file mode 100644 index 0000000..1f053cc --- /dev/null +++ b/schema/ThickElement.py @@ -0,0 +1,11 @@ +from typing import Annotated +from annotated_types import Gt + +from .BaseElement import BaseElement + + +class ThickElement(BaseElement): + """A thick base element with finite segment length""" + + # Segment length in meters (m) + length: Annotated[float, Gt(0)] diff --git a/tests/test_schema.py b/tests/test_schema.py index 4e92bf8..10c7b9a 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -1,7 +1,33 @@ +from pydantic import ValidationError + from schema.BaseElement import BaseElement +from schema.ThickElement import ThickElement def test_BaseElement(): - element_name = "my_element" + # Create base element with custom name + element_name = "my_base_element" element = BaseElement(name=element_name) assert element.name == element_name + + +def test_ThickElement(): + # Create thick element with custom name and length + element_name = "my_thick_element" + element_length = 1.0 + element = ThickElement( + name=element_name, + length=element_length, + ) + assert element.name == element_name + assert element.length == element_length + # Try to assign negative length and + # detect validation error without breaking pytest + element_length = -1.0 + passed = True + try: + element.length = element_length + except ValidationError as e: + print(e) + passed = False + assert not passed From a87f5fc31942d7bd43ae880f88c0ae2f0b8f0dd1 Mon Sep 17 00:00:00 2001 From: Edoardo Zoni Date: Wed, 12 Feb 2025 11:59:18 -0800 Subject: [PATCH 06/31] Rename workflow file extension --- .github/workflows/unit_tests.yaml | 29 ----------------------------- 1 file changed, 29 deletions(-) delete mode 100644 .github/workflows/unit_tests.yaml diff --git a/.github/workflows/unit_tests.yaml b/.github/workflows/unit_tests.yaml deleted file mode 100644 index 1e02c2a..0000000 --- a/.github/workflows/unit_tests.yaml +++ /dev/null @@ -1,29 +0,0 @@ -name: pals - -on: - push: - branches: - - "main" - pull_request: - -concurrency: - group: ${{ github.ref }}-${{ github.head_ref }}-pals - cancel-in-progress: true - -jobs: - unit_tests: - name: unit tests - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.13" - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - - name: Test - run: | - pytest tests -v From febd2d35e0cf2470ab0d72e5d22bf55c8147e871 Mon Sep 17 00:00:00 2001 From: Edoardo Zoni Date: Wed, 12 Feb 2025 14:20:36 -0800 Subject: [PATCH 07/31] Add line --- schema/Line.py | 15 +++++++++++++++ tests/test_schema.py | 11 +++++++++++ 2 files changed, 26 insertions(+) create mode 100644 schema/Line.py diff --git a/schema/Line.py b/schema/Line.py new file mode 100644 index 0000000..eb4ee5a --- /dev/null +++ b/schema/Line.py @@ -0,0 +1,15 @@ +from pydantic import BaseModel, ConfigDict, Field +from typing import List, Union + +from schema.BaseElement import BaseElement + + +class Line(BaseModel): + """A line of elements and/or other lines""" + + # Validate every time a new value is assigned to an attribute, + # not only when an instance of Line is created + model_config = ConfigDict(validate_assignment=True) + + # FIXME TypeError: 'list' is not a valid discriminated union variant; should be a `BaseModel` or `dataclass` + line: List[Union[BaseElement, "Line"]] = Field(..., discriminator="element") diff --git a/tests/test_schema.py b/tests/test_schema.py index 10c7b9a..c8ef9c3 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -2,6 +2,7 @@ from schema.BaseElement import BaseElement from schema.ThickElement import ThickElement +from schema.Line import Line def test_BaseElement(): @@ -31,3 +32,13 @@ def test_ThickElement(): print(e) passed = False assert not passed + + +# TODO +def test_Line(): + # Create two base elements + elements = [] + elements.append(BaseElement(name="element_one")) + elements.append(BaseElement(name="element_two")) + line = Line(elements) + print(line) From 24a32ccc58b42566113a38322abd1f70f41a6496 Mon Sep 17 00:00:00 2001 From: Edoardo Zoni Date: Wed, 12 Feb 2025 14:58:00 -0800 Subject: [PATCH 08/31] Fix line --- schema/Line.py | 7 +++++-- tests/test_schema.py | 11 +++++------ 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/schema/Line.py b/schema/Line.py index eb4ee5a..63302a7 100644 --- a/schema/Line.py +++ b/schema/Line.py @@ -11,5 +11,8 @@ class Line(BaseModel): # not only when an instance of Line is created model_config = ConfigDict(validate_assignment=True) - # FIXME TypeError: 'list' is not a valid discriminated union variant; should be a `BaseModel` or `dataclass` - line: List[Union[BaseElement, "Line"]] = Field(..., discriminator="element") + line: List[Union[BaseElement, "Line"]] = Field(...) + + +# Avoid circular import issues +Line.model_rebuild() diff --git a/tests/test_schema.py b/tests/test_schema.py index c8ef9c3..bd7d8c1 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -7,14 +7,14 @@ def test_BaseElement(): # Create base element with custom name - element_name = "my_base_element" + element_name = "base_element" element = BaseElement(name=element_name) assert element.name == element_name def test_ThickElement(): # Create thick element with custom name and length - element_name = "my_thick_element" + element_name = "thick_element" element_length = 1.0 element = ThickElement( name=element_name, @@ -37,8 +37,7 @@ def test_ThickElement(): # TODO def test_Line(): # Create two base elements - elements = [] - elements.append(BaseElement(name="element_one")) - elements.append(BaseElement(name="element_two")) - line = Line(elements) + element1 = BaseElement(name="element1") + element2 = BaseElement(name="element2") + line = Line(line=[element1, element2]) print(line) From f5a14a273fff771857f92ed0b1ecdd6e98dac180 Mon Sep 17 00:00:00 2001 From: Edoardo Zoni Date: Wed, 12 Feb 2025 15:02:19 -0800 Subject: [PATCH 09/31] Update .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index b267296..2750157 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ __pycache__/ .pytest_cache/ .ruff_cache/ +*.swp From ebfbcf6e51ea016df361597a7a3d69310420a720 Mon Sep 17 00:00:00 2001 From: Edoardo Zoni Date: Wed, 12 Feb 2025 15:47:12 -0800 Subject: [PATCH 10/31] Fix line --- schema/BaseElement.py | 5 ++++- schema/Line.py | 14 ++++++++++++-- schema/ThickElement.py | 5 ++++- tests/test_schema.py | 6 +++--- 4 files changed, 23 insertions(+), 7 deletions(-) diff --git a/schema/BaseElement.py b/schema/BaseElement.py index 226cb70..09e9680 100644 --- a/schema/BaseElement.py +++ b/schema/BaseElement.py @@ -1,5 +1,5 @@ from pydantic import BaseModel, ConfigDict -from typing import Optional +from typing import Literal, Optional class BaseElement(BaseModel): @@ -11,3 +11,6 @@ class BaseElement(BaseModel): # Unique element name name: Optional[str] = None + + # Discriminator field + element: Literal["BaseElement"] = "BaseElement" diff --git a/schema/Line.py b/schema/Line.py index 63302a7..066478a 100644 --- a/schema/Line.py +++ b/schema/Line.py @@ -1,7 +1,8 @@ from pydantic import BaseModel, ConfigDict, Field -from typing import List, Union +from typing import Annotated, List, Literal, Union from schema.BaseElement import BaseElement +from schema.ThickElement import ThickElement class Line(BaseModel): @@ -11,7 +12,16 @@ class Line(BaseModel): # not only when an instance of Line is created model_config = ConfigDict(validate_assignment=True) - line: List[Union[BaseElement, "Line"]] = Field(...) + # NOTE Since pydantic 2.9, the discriminator must be applied to the union type, not the list + # (see https://github.com/pydantic/pydantic/issues/10352) + line: List[ + Annotated[ + Union[BaseElement, ThickElement, "Line"], Field(discriminator="element") + ] + ] + + # Discriminator field + element: Literal["Line"] = "Line" # Avoid circular import issues diff --git a/schema/ThickElement.py b/schema/ThickElement.py index 1f053cc..c8bbd8b 100644 --- a/schema/ThickElement.py +++ b/schema/ThickElement.py @@ -1,4 +1,4 @@ -from typing import Annotated +from typing import Annotated, Literal from annotated_types import Gt from .BaseElement import BaseElement @@ -9,3 +9,6 @@ class ThickElement(BaseElement): # Segment length in meters (m) length: Annotated[float, Gt(0)] + + # Discriminator field + element: Literal["ThickElement"] = "ThickElement" diff --git a/tests/test_schema.py b/tests/test_schema.py index bd7d8c1..4c9df24 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -37,7 +37,7 @@ def test_ThickElement(): # TODO def test_Line(): # Create two base elements - element1 = BaseElement(name="element1") - element2 = BaseElement(name="element2") + element1 = BaseElement(name="base_element") + element2 = ThickElement(name="thick_element", length=1.0) line = Line(line=[element1, element2]) - print(line) + assert line.line == [element1, element2] From eba6ca72187d72b0377c3573e3bec26999ca02ca Mon Sep 17 00:00:00 2001 From: Edoardo Zoni Date: Thu, 13 Feb 2025 11:24:34 -0800 Subject: [PATCH 11/31] Expand line test --- tests/test_schema.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/tests/test_schema.py b/tests/test_schema.py index 4c9df24..2e67a8e 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -34,10 +34,18 @@ def test_ThickElement(): assert not passed -# TODO def test_Line(): - # Create two base elements - element1 = BaseElement(name="base_element") - element2 = ThickElement(name="thick_element", length=1.0) - line = Line(line=[element1, element2]) - assert line.line == [element1, element2] + # Create first line with one base element + element1 = BaseElement(name="element1") + line1 = Line(line=[element1]) + assert line1.line == [element1] + # Extend first line with one thick element + element2 = ThickElement(name="element2", length=2.0) + line1.line.extend(Line(line=[element2]).line) + assert line1.line == [element1, element2] + # Create second line with one thick element + element3 = ThickElement(name="element3", length=3.0) + line2 = Line(line=[element3]) + # Extend first line with second line + line1.line.extend(line2.line) + assert line1.line == [element1, element2, element3] From 33901695971381d3d704fc0d59d86eb49cfa1c9a Mon Sep 17 00:00:00 2001 From: Edoardo Zoni Date: Thu, 13 Feb 2025 11:40:51 -0800 Subject: [PATCH 12/31] Add unit test to write to/read from YAML file --- tests/test_schema.py | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/tests/test_schema.py b/tests/test_schema.py index 2e67a8e..c65ad08 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -1,19 +1,21 @@ from pydantic import ValidationError +import yaml + from schema.BaseElement import BaseElement from schema.ThickElement import ThickElement from schema.Line import Line def test_BaseElement(): - # Create base element with custom name + # Create one base element with custom name element_name = "base_element" element = BaseElement(name=element_name) assert element.name == element_name def test_ThickElement(): - # Create thick element with custom name and length + # Create one thick element with custom name and length element_name = "thick_element" element_length = 1.0 element = ThickElement( @@ -49,3 +51,23 @@ def test_Line(): # Extend first line with second line line1.line.extend(line2.line) assert line1.line == [element1, element2, element3] + + +def test_yaml(): + # Create one base element + element1 = BaseElement(name="element1") + # Create one thick element + element2 = ThickElement(name="element2", length=2.0) + # Create line with both elements + line = Line(line=[element1, element2]) + # Serialize the Line object to YAML + yaml_data = yaml.dump(line.model_dump(), default_flow_style=False) + # Write the YAML data to a file + with open("line.yaml", "w") as file: + file.write(yaml_data) + # Read the YAML data from the file + with open("line.yaml", "r") as file: + yaml_data = yaml.safe_load(file) + # Parse the YAML data back into a Line object + loaded_line = Line(**yaml_data) + assert line == loaded_line From 31d4421ed854669e722200ea9497c3662e6058a4 Mon Sep 17 00:00:00 2001 From: Edoardo Zoni Date: Tue, 18 Feb 2025 14:58:43 -0800 Subject: [PATCH 13/31] Downgrade Python version --- .github/workflows/unit_tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 1e02c2a..843664b 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -19,7 +19,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: "3.13" + python-version: "3.12" - name: Install dependencies run: | python -m pip install --upgrade pip From 5045165dfc073439f4ed28e4ff4687c538055865 Mon Sep 17 00:00:00 2001 From: Edoardo Zoni Date: Tue, 18 Feb 2025 15:01:24 -0800 Subject: [PATCH 14/31] Fix requirements file --- requirements.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 30a3e81..b5d87fb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,4 @@ pydantic pytest -python pyyaml toml From 615e5427105429c051056b9392d2ac2716b6fdb4 Mon Sep 17 00:00:00 2001 From: Edoardo Zoni Date: Tue, 18 Feb 2025 15:12:02 -0800 Subject: [PATCH 15/31] Update CI workflow name --- .github/workflows/unit_tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 843664b..48f0889 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -1,4 +1,4 @@ -name: pals +name: pals-python on: push: @@ -7,7 +7,7 @@ on: pull_request: concurrency: - group: ${{ github.ref }}-${{ github.head_ref }}-pals + group: ${{ github.ref }}-${{ github.head_ref }}-pals-python cancel-in-progress: true jobs: From 2ffbf1ae7ff35467c17022132e1cbb9c3e25d070 Mon Sep 17 00:00:00 2001 From: Edoardo Zoni <59625522+EZoni@users.noreply.github.com> Date: Wed, 12 Mar 2025 11:48:46 -0700 Subject: [PATCH 16/31] Upgrade Python version: 3.13 --- .github/workflows/unit_tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 48f0889..1714249 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -19,7 +19,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: "3.13" - name: Install dependencies run: | python -m pip install --upgrade pip From a666d9af0d4f3c8f864b718469e2a4ca25c9eac5 Mon Sep 17 00:00:00 2001 From: Edoardo Zoni Date: Wed, 26 Mar 2025 09:12:22 -0700 Subject: [PATCH 17/31] Add unit test to write to/read from JSON file --- tests/test_schema.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/test_schema.py b/tests/test_schema.py index c65ad08..7ccd4d7 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -1,5 +1,6 @@ from pydantic import ValidationError +import json import yaml from schema.BaseElement import BaseElement @@ -62,6 +63,7 @@ def test_yaml(): line = Line(line=[element1, element2]) # Serialize the Line object to YAML yaml_data = yaml.dump(line.model_dump(), default_flow_style=False) + print(f"\n{yaml_data}") # Write the YAML data to a file with open("line.yaml", "w") as file: file.write(yaml_data) @@ -71,3 +73,24 @@ def test_yaml(): # Parse the YAML data back into a Line object loaded_line = Line(**yaml_data) assert line == loaded_line + + +def test_json(): + # Create one base element + element1 = BaseElement(name="element1") + # Create one thick element + element2 = ThickElement(name="element2", length=2.0) + # Create line with both elements + line = Line(line=[element1, element2]) + # Serialize the Line object to JSON + json_data = json.dumps(line.model_dump(), sort_keys=True, indent=2) + print(f"\n{json_data}") + # Write the JSON data to a file + with open("line.json", "w") as file: + file.write(json_data) + # Read the JSON data from the file + with open("line.json", "r") as file: + json_data = json.loads(file.read()) + # Parse the JSON data back into a Line object + loaded_line = Line(**json_data) + assert line == loaded_line From f9b6fc4bfae4b710d8a8082c5e63c7bfa04ab2c2 Mon Sep 17 00:00:00 2001 From: Edoardo Zoni Date: Mon, 31 Mar 2025 13:49:03 -0700 Subject: [PATCH 18/31] Remove temporary test files --- tests/test_schema.py | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/tests/test_schema.py b/tests/test_schema.py index 7ccd4d7..a9a044e 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -1,3 +1,4 @@ +import os from pydantic import ValidationError import json @@ -64,14 +65,18 @@ def test_yaml(): # Serialize the Line object to YAML yaml_data = yaml.dump(line.model_dump(), default_flow_style=False) print(f"\n{yaml_data}") - # Write the YAML data to a file - with open("line.yaml", "w") as file: + # Write the YAML data to a test file + test_file = "line.yaml" + with open(test_file, "w") as file: file.write(yaml_data) - # Read the YAML data from the file - with open("line.yaml", "r") as file: + # Read the YAML data from the test file + with open(test_file, "r") as file: yaml_data = yaml.safe_load(file) # Parse the YAML data back into a Line object loaded_line = Line(**yaml_data) + # Remove the test file + os.remove(test_file) + # Validate loaded Line object assert line == loaded_line @@ -85,12 +90,16 @@ def test_json(): # Serialize the Line object to JSON json_data = json.dumps(line.model_dump(), sort_keys=True, indent=2) print(f"\n{json_data}") - # Write the JSON data to a file - with open("line.json", "w") as file: + # Write the JSON data to a test file + test_file = "line.json" + with open(test_file, "w") as file: file.write(json_data) - # Read the JSON data from the file - with open("line.json", "r") as file: + # Read the JSON data from the test file + with open(test_file, "r") as file: json_data = json.loads(file.read()) # Parse the JSON data back into a Line object loaded_line = Line(**json_data) + # Remove the test file + os.remove(test_file) + # Validate loaded Line object assert line == loaded_line From 9f34440228252a124781aa488442bd8b10997f39 Mon Sep 17 00:00:00 2001 From: Edoardo Zoni Date: Mon, 31 Mar 2025 13:52:27 -0700 Subject: [PATCH 19/31] Rename attribute `length` to `Length` --- schema/ThickElement.py | 2 +- tests/test_schema.py | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/schema/ThickElement.py b/schema/ThickElement.py index c8bbd8b..2b37d30 100644 --- a/schema/ThickElement.py +++ b/schema/ThickElement.py @@ -8,7 +8,7 @@ class ThickElement(BaseElement): """A thick base element with finite segment length""" # Segment length in meters (m) - length: Annotated[float, Gt(0)] + Length: Annotated[float, Gt(0)] # Discriminator field element: Literal["ThickElement"] = "ThickElement" diff --git a/tests/test_schema.py b/tests/test_schema.py index a9a044e..ca6abda 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -22,16 +22,16 @@ def test_ThickElement(): element_length = 1.0 element = ThickElement( name=element_name, - length=element_length, + Length=element_length, ) assert element.name == element_name - assert element.length == element_length + assert element.Length == element_length # Try to assign negative length and # detect validation error without breaking pytest element_length = -1.0 passed = True try: - element.length = element_length + element.Length = element_length except ValidationError as e: print(e) passed = False @@ -44,11 +44,11 @@ def test_Line(): line1 = Line(line=[element1]) assert line1.line == [element1] # Extend first line with one thick element - element2 = ThickElement(name="element2", length=2.0) + element2 = ThickElement(name="element2", Length=2.0) line1.line.extend(Line(line=[element2]).line) assert line1.line == [element1, element2] # Create second line with one thick element - element3 = ThickElement(name="element3", length=3.0) + element3 = ThickElement(name="element3", Length=3.0) line2 = Line(line=[element3]) # Extend first line with second line line1.line.extend(line2.line) @@ -59,7 +59,7 @@ def test_yaml(): # Create one base element element1 = BaseElement(name="element1") # Create one thick element - element2 = ThickElement(name="element2", length=2.0) + element2 = ThickElement(name="element2", Length=2.0) # Create line with both elements line = Line(line=[element1, element2]) # Serialize the Line object to YAML @@ -84,7 +84,7 @@ def test_json(): # Create one base element element1 = BaseElement(name="element1") # Create one thick element - element2 = ThickElement(name="element2", length=2.0) + element2 = ThickElement(name="element2", Length=2.0) # Create line with both elements line = Line(line=[element1, element2]) # Serialize the Line object to JSON From e3b6e58d6e3c92957e865f4002975d667a2dba37 Mon Sep 17 00:00:00 2001 From: Edoardo Zoni Date: Mon, 31 Mar 2025 14:58:29 -0700 Subject: [PATCH 20/31] Add drift element --- schema/DriftElement.py | 10 ++++++++++ schema/Line.py | 4 +++- tests/test_schema.py | 27 +++++++++++++++++++++++++-- 3 files changed, 38 insertions(+), 3 deletions(-) create mode 100644 schema/DriftElement.py diff --git a/schema/DriftElement.py b/schema/DriftElement.py new file mode 100644 index 0000000..754f313 --- /dev/null +++ b/schema/DriftElement.py @@ -0,0 +1,10 @@ +from typing import Literal + +from .ThickElement import ThickElement + + +class DriftElement(ThickElement): + """A field free region""" + + # Discriminator field + element: Literal["DriftElement"] = "DriftElement" diff --git a/schema/Line.py b/schema/Line.py index 066478a..2a0c5ee 100644 --- a/schema/Line.py +++ b/schema/Line.py @@ -3,6 +3,7 @@ from schema.BaseElement import BaseElement from schema.ThickElement import ThickElement +from schema.DriftElement import DriftElement class Line(BaseModel): @@ -16,7 +17,8 @@ class Line(BaseModel): # (see https://github.com/pydantic/pydantic/issues/10352) line: List[ Annotated[ - Union[BaseElement, ThickElement, "Line"], Field(discriminator="element") + Union[BaseElement, ThickElement, DriftElement, "Line"], + Field(discriminator="element"), ] ] diff --git a/tests/test_schema.py b/tests/test_schema.py index ca6abda..a3b457a 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -6,6 +6,7 @@ from schema.BaseElement import BaseElement from schema.ThickElement import ThickElement +from schema.DriftElement import DriftElement from schema.Line import Line @@ -38,6 +39,28 @@ def test_ThickElement(): assert not passed +def test_DriftElement(): + # Create one drift element with custom name and length + element_name = "drift_element" + element_length = 1.0 + element = DriftElement( + name=element_name, + Length=element_length, + ) + assert element.name == element_name + assert element.Length == element_length + # Try to assign negative length and + # detect validation error without breaking pytest + element_length = -1.0 + passed = True + try: + element.Length = element_length + except ValidationError as e: + print(e) + passed = False + assert not passed + + def test_Line(): # Create first line with one base element element1 = BaseElement(name="element1") @@ -47,8 +70,8 @@ def test_Line(): element2 = ThickElement(name="element2", Length=2.0) line1.line.extend(Line(line=[element2]).line) assert line1.line == [element1, element2] - # Create second line with one thick element - element3 = ThickElement(name="element3", Length=3.0) + # Create second line with one drift element + element3 = DriftElement(name="element3", Length=3.0) line2 = Line(line=[element3]) # Extend first line with second line line1.line.extend(line2.line) From aaf35e0d23c2237ef65dfb0129046169a1c016c3 Mon Sep 17 00:00:00 2001 From: Edoardo Zoni Date: Mon, 31 Mar 2025 16:43:43 -0700 Subject: [PATCH 21/31] Clean up --- schema/BaseElement.py | 6 +++--- schema/Line.py | 6 +++--- schema/ThickElement.py | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/schema/BaseElement.py b/schema/BaseElement.py index 09e9680..744118e 100644 --- a/schema/BaseElement.py +++ b/schema/BaseElement.py @@ -5,12 +5,12 @@ class BaseElement(BaseModel): """A custom base element defining common properties""" + # Discriminator field + element: Literal["BaseElement"] = "BaseElement" + # Validate every time a new value is assigned to an attribute, # not only when an instance of BaseElement is created model_config = ConfigDict(validate_assignment=True) # Unique element name name: Optional[str] = None - - # Discriminator field - element: Literal["BaseElement"] = "BaseElement" diff --git a/schema/Line.py b/schema/Line.py index 2a0c5ee..55294ff 100644 --- a/schema/Line.py +++ b/schema/Line.py @@ -9,6 +9,9 @@ class Line(BaseModel): """A line of elements and/or other lines""" + # Discriminator field + element: Literal["Line"] = "Line" + # Validate every time a new value is assigned to an attribute, # not only when an instance of Line is created model_config = ConfigDict(validate_assignment=True) @@ -22,9 +25,6 @@ class Line(BaseModel): ] ] - # Discriminator field - element: Literal["Line"] = "Line" - # Avoid circular import issues Line.model_rebuild() diff --git a/schema/ThickElement.py b/schema/ThickElement.py index 2b37d30..6953904 100644 --- a/schema/ThickElement.py +++ b/schema/ThickElement.py @@ -7,8 +7,8 @@ class ThickElement(BaseElement): """A thick base element with finite segment length""" - # Segment length in meters (m) - Length: Annotated[float, Gt(0)] - # Discriminator field element: Literal["ThickElement"] = "ThickElement" + + # Segment length in meters (m) + Length: Annotated[float, Gt(0)] From 2f2cbd0e9eff963ae275852dedea18e6133917bb Mon Sep 17 00:00:00 2001 From: Edoardo Zoni Date: Mon, 31 Mar 2025 17:11:22 -0700 Subject: [PATCH 22/31] Add draft of quadrupole element --- schema/MagneticMultipoleParameters.py | 12 ++++++++++++ schema/QuadrupoleElement.py | 14 ++++++++++++++ tests/test_schema.py | 22 ++++++++++++++++++++++ 3 files changed, 48 insertions(+) create mode 100644 schema/MagneticMultipoleParameters.py create mode 100644 schema/QuadrupoleElement.py diff --git a/schema/MagneticMultipoleParameters.py b/schema/MagneticMultipoleParameters.py new file mode 100644 index 0000000..6c87b8e --- /dev/null +++ b/schema/MagneticMultipoleParameters.py @@ -0,0 +1,12 @@ +from pydantic import BaseModel, ConfigDict + + +class MagneticMultipoleParameters(BaseModel): + """Magnetic multipole parameters""" + + # Validate every time a new value is assigned to an attribute, + # not only when an instance of MagneticMultipoleP is created + model_config = ConfigDict(validate_assignment=True) + + # Tilt + tilt: float diff --git a/schema/QuadrupoleElement.py b/schema/QuadrupoleElement.py new file mode 100644 index 0000000..2a16955 --- /dev/null +++ b/schema/QuadrupoleElement.py @@ -0,0 +1,14 @@ +from typing import Literal + +from .ThickElement import ThickElement +from .MagneticMultipoleParameters import MagneticMultipoleParameters + + +class QuadrupoleElement(ThickElement): + """A quadrupole element""" + + # Discriminator field + element: Literal["QuadrupoleElement"] = "QuadrupoleElement" + + # Magnetic multipole parameters + MagneticMultipoleP: MagneticMultipoleParameters diff --git a/tests/test_schema.py b/tests/test_schema.py index a3b457a..d6a8a4e 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -4,9 +4,13 @@ import json import yaml +from schema.MagneticMultipoleParameters import MagneticMultipoleParameters + from schema.BaseElement import BaseElement from schema.ThickElement import ThickElement from schema.DriftElement import DriftElement +from schema.QuadrupoleElement import QuadrupoleElement + from schema.Line import Line @@ -61,6 +65,24 @@ def test_DriftElement(): assert not passed +# TODO +def test_QuadrupoleElement(): + # Create one drift element with custom name and length + element_name = "quadrupole_element" + element_length = 1.0 + element_magnetic_multipole = MagneticMultipoleParameters(tilt=0.1) + element = QuadrupoleElement( + name=element_name, + Length=element_length, + MagneticMultipoleP=element_magnetic_multipole, + ) + assert element.name == element_name + assert element.Length == element_length + # Serialize the Line object to YAML + yaml_data = yaml.dump(element.model_dump(), default_flow_style=False) + print(f"\n{yaml_data}") + + def test_Line(): # Create first line with one base element element1 = BaseElement(name="element1") From f94a93073b6529556e634706729f04f26b0e6152 Mon Sep 17 00:00:00 2001 From: Edoardo Zoni Date: Tue, 1 Apr 2025 15:39:51 -0700 Subject: [PATCH 23/31] Add custom validation for dynamic tilt parameters 'tiltN' --- schema/MagneticMultipoleParameters.py | 47 +++++++++++++++++++++++---- tests/test_schema.py | 2 +- 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/schema/MagneticMultipoleParameters.py b/schema/MagneticMultipoleParameters.py index 6c87b8e..1796075 100644 --- a/schema/MagneticMultipoleParameters.py +++ b/schema/MagneticMultipoleParameters.py @@ -1,12 +1,47 @@ -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, model_validator +from typing import Any, Dict class MagneticMultipoleParameters(BaseModel): """Magnetic multipole parameters""" - # Validate every time a new value is assigned to an attribute, - # not only when an instance of MagneticMultipoleP is created - model_config = ConfigDict(validate_assignment=True) + # Allow arbitrary fields + model_config = ConfigDict(extra="allow") - # Tilt - tilt: float + # Custom validation to be applied before standard validation + @model_validator(mode="before") + def validate(cls, values: Dict[str, Any]) -> Dict[str, Any]: + # loop over all attributes + for key in values: + # validate tilt parameters 'tiltN' + if key.startswith("tilt"): + key_num = key[4:] + if key_num.isdigit(): + if key_num.startswith("0") and key_num != "0": + raise ValueError( + " ".join( + [ + f"Invalid tilt parameter: '{key}'.", + "Leading zeros are not allowed.", + ] + ) + ) + else: + raise ValueError( + " ".join( + [ + f"Invalid tilt parameter: '{key}'.", + "Tilt parameter must be of the form 'tiltN', where 'N' is an integer.", + ] + ) + ) + else: + raise ValueError( + " ".join( + [ + f"Invalid tilt parameter: '{key}'.", + "Tilt parameter must be of the form 'tiltN', where 'N' is an integer.", + ] + ) + ) + return values diff --git a/tests/test_schema.py b/tests/test_schema.py index d6a8a4e..66146f6 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -70,7 +70,7 @@ def test_QuadrupoleElement(): # Create one drift element with custom name and length element_name = "quadrupole_element" element_length = 1.0 - element_magnetic_multipole = MagneticMultipoleParameters(tilt=0.1) + element_magnetic_multipole = MagneticMultipoleParameters(tilt1=0.1) element = QuadrupoleElement( name=element_name, Length=element_length, From 000cb2d8acee704b180a7a18df3d29dc8c0a65af Mon Sep 17 00:00:00 2001 From: Edoardo Zoni Date: Tue, 1 Apr 2025 17:03:08 -0700 Subject: [PATCH 24/31] Add custom validation for dynamic normal, skew component parameters --- schema/MagneticMultipoleParameters.py | 67 ++++++++++++++++----------- tests/test_schema.py | 6 ++- 2 files changed, 46 insertions(+), 27 deletions(-) diff --git a/schema/MagneticMultipoleParameters.py b/schema/MagneticMultipoleParameters.py index 1796075..ba4f891 100644 --- a/schema/MagneticMultipoleParameters.py +++ b/schema/MagneticMultipoleParameters.py @@ -8,6 +8,14 @@ class MagneticMultipoleParameters(BaseModel): # Allow arbitrary fields model_config = ConfigDict(extra="allow") + # Custom validation of magnetic multipole order + def _validate_order(key_num, msg): + if key_num.isdigit(): + if key_num.startswith("0") and key_num != "0": + raise ValueError(msg) + else: + raise ValueError(msg) + # Custom validation to be applied before standard validation @model_validator(mode="before") def validate(cls, values: Dict[str, Any]) -> Dict[str, Any]: @@ -16,32 +24,39 @@ def validate(cls, values: Dict[str, Any]) -> Dict[str, Any]: # validate tilt parameters 'tiltN' if key.startswith("tilt"): key_num = key[4:] - if key_num.isdigit(): - if key_num.startswith("0") and key_num != "0": - raise ValueError( - " ".join( - [ - f"Invalid tilt parameter: '{key}'.", - "Leading zeros are not allowed.", - ] - ) - ) - else: - raise ValueError( - " ".join( - [ - f"Invalid tilt parameter: '{key}'.", - "Tilt parameter must be of the form 'tiltN', where 'N' is an integer.", - ] - ) - ) + msg = " ".join( + [ + f"Invalid tilt parameter: '{key}'.", + "Tilt parameter must be of the form 'tiltN', where 'N' is an integer.", + ] + ) + cls._validate_order(key_num, msg) + # validate normal component parameters 'BnN' + elif key.startswith("Bn"): + key_num = key[2:] + msg = " ".join( + [ + f"Invalid normal component parameter: '{key}'.", + "Normal component parameter must be of the form 'BnN', where 'N' is an integer.", + ] + ) + cls._validate_order(key_num, msg) + # validate skew component parameters 'BsN' + elif key.startswith("Bs"): + key_num = key[2:] + msg = " ".join( + [ + f"Invalid skew component parameter: '{key}'.", + "Skew component parameter must be of the form 'BsN', where 'N' is an integer.", + ] + ) + cls._validate_order(key_num, msg) else: - raise ValueError( - " ".join( - [ - f"Invalid tilt parameter: '{key}'.", - "Tilt parameter must be of the form 'tiltN', where 'N' is an integer.", - ] - ) + msg = " ".join( + [ + f"Invalid magnetic multipole parameter: '{key}'.", + "Magnetic multipole parameters must be of the form 'tiltN', 'BnN', or 'BsN'.", + ] ) + raise ValueError(msg) return values diff --git a/tests/test_schema.py b/tests/test_schema.py index 66146f6..00f385f 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -70,7 +70,11 @@ def test_QuadrupoleElement(): # Create one drift element with custom name and length element_name = "quadrupole_element" element_length = 1.0 - element_magnetic_multipole = MagneticMultipoleParameters(tilt1=0.1) + element_magnetic_multipole = MagneticMultipoleParameters( + Bn1=1.1, + Bs1=2.2, + tilt1=3.3, + ) element = QuadrupoleElement( name=element_name, Length=element_length, From 8d860291ee7bb9564442cb9049ffcc0c715b7fbe Mon Sep 17 00:00:00 2001 From: Edoardo Zoni Date: Tue, 1 Apr 2025 17:14:27 -0700 Subject: [PATCH 25/31] Improve unit test for quadrupole element --- schema/MagneticMultipoleParameters.py | 2 +- tests/test_schema.py | 22 ++++++++++++++++++---- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/schema/MagneticMultipoleParameters.py b/schema/MagneticMultipoleParameters.py index ba4f891..a1b7dd0 100644 --- a/schema/MagneticMultipoleParameters.py +++ b/schema/MagneticMultipoleParameters.py @@ -55,7 +55,7 @@ def validate(cls, values: Dict[str, Any]) -> Dict[str, Any]: msg = " ".join( [ f"Invalid magnetic multipole parameter: '{key}'.", - "Magnetic multipole parameters must be of the form 'tiltN', 'BnN', or 'BsN'.", + "Magnetic multipole parameters must be of the form 'tiltN', 'BnN', or 'BsN', where 'N' is an integer.", ] ) raise ValueError(msg) diff --git a/tests/test_schema.py b/tests/test_schema.py index 00f385f..7d5b114 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -65,15 +65,23 @@ def test_DriftElement(): assert not passed -# TODO def test_QuadrupoleElement(): # Create one drift element with custom name and length element_name = "quadrupole_element" element_length = 1.0 + element_magnetic_multipole_Bn1 = 1.1 + element_magnetic_multipole_Bn2 = 1.2 + element_magnetic_multipole_Bs1 = 2.1 + element_magnetic_multipole_Bs2 = 2.2 + element_magnetic_multipole_tilt1 = 3.1 + element_magnetic_multipole_tilt2 = 3.2 element_magnetic_multipole = MagneticMultipoleParameters( - Bn1=1.1, - Bs1=2.2, - tilt1=3.3, + Bn1=element_magnetic_multipole_Bn1, + Bs1=element_magnetic_multipole_Bs1, + tilt1=element_magnetic_multipole_tilt1, + Bn2=element_magnetic_multipole_Bn2, + Bs2=element_magnetic_multipole_Bs2, + tilt2=element_magnetic_multipole_tilt2, ) element = QuadrupoleElement( name=element_name, @@ -82,6 +90,12 @@ def test_QuadrupoleElement(): ) assert element.name == element_name assert element.Length == element_length + assert element.MagneticMultipoleP.Bn1 == element_magnetic_multipole_Bn1 + assert element.MagneticMultipoleP.Bs1 == element_magnetic_multipole_Bs1 + assert element.MagneticMultipoleP.tilt1 == element_magnetic_multipole_tilt1 + assert element.MagneticMultipoleP.Bn2 == element_magnetic_multipole_Bn2 + assert element.MagneticMultipoleP.Bs2 == element_magnetic_multipole_Bs2 + assert element.MagneticMultipoleP.tilt2 == element_magnetic_multipole_tilt2 # Serialize the Line object to YAML yaml_data = yaml.dump(element.model_dump(), default_flow_style=False) print(f"\n{yaml_data}") From 5b7ee19bfcf9228e23a321d9ec98d7e3e71a4b98 Mon Sep 17 00:00:00 2001 From: Edoardo Zoni Date: Thu, 10 Apr 2025 17:57:26 -0700 Subject: [PATCH 26/31] Add class `Item`, rename `Length` as `length` --- schema/Item.py | 30 ++++++++++++++++++++++++++++ schema/Line.py | 16 ++++----------- schema/ThickElement.py | 2 +- tests/test_schema.py | 45 +++++++++++++++++++++++------------------- 4 files changed, 60 insertions(+), 33 deletions(-) create mode 100644 schema/Item.py diff --git a/schema/Item.py b/schema/Item.py new file mode 100644 index 0000000..26c2ebf --- /dev/null +++ b/schema/Item.py @@ -0,0 +1,30 @@ +from pydantic import BaseModel, ConfigDict, Field +from typing import Annotated, Literal, Union + +from schema.BaseElement import BaseElement +from schema.ThickElement import ThickElement +from schema.DriftElement import DriftElement +from schema.QuadrupoleElement import QuadrupoleElement + + +class Item(BaseModel): + """An element of a line or a line itself""" + + # Discriminator field + element: Literal["Item"] = "Item" + + # Validate every time a new value is assigned to an attribute, + # not only when an instance of Line is created + model_config = ConfigDict(validate_assignment=True) + + # NOTE Since pydantic 2.9, the discriminator must be applied to the union type, not the list + # (see https://github.com/pydantic/pydantic/issues/10352) + item: Annotated[ + Union[ + BaseElement, + ThickElement, + DriftElement, + QuadrupoleElement, + ], + Field(discriminator="element"), + ] diff --git a/schema/Line.py b/schema/Line.py index 55294ff..67a23de 100644 --- a/schema/Line.py +++ b/schema/Line.py @@ -1,9 +1,8 @@ from pydantic import BaseModel, ConfigDict, Field -from typing import Annotated, List, Literal, Union +from typing import Annotated, List, Literal -from schema.BaseElement import BaseElement -from schema.ThickElement import ThickElement -from schema.DriftElement import DriftElement + +from schema.Item import Item class Line(BaseModel): @@ -16,14 +15,7 @@ class Line(BaseModel): # not only when an instance of Line is created model_config = ConfigDict(validate_assignment=True) - # NOTE Since pydantic 2.9, the discriminator must be applied to the union type, not the list - # (see https://github.com/pydantic/pydantic/issues/10352) - line: List[ - Annotated[ - Union[BaseElement, ThickElement, DriftElement, "Line"], - Field(discriminator="element"), - ] - ] + line: List[Annotated[Item, Field(discriminator="element")]] # Avoid circular import issues diff --git a/schema/ThickElement.py b/schema/ThickElement.py index 6953904..ca7c855 100644 --- a/schema/ThickElement.py +++ b/schema/ThickElement.py @@ -11,4 +11,4 @@ class ThickElement(BaseElement): element: Literal["ThickElement"] = "ThickElement" # Segment length in meters (m) - Length: Annotated[float, Gt(0)] + length: Annotated[float, Gt(0)] diff --git a/tests/test_schema.py b/tests/test_schema.py index 7d5b114..f974449 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -11,6 +11,7 @@ from schema.DriftElement import DriftElement from schema.QuadrupoleElement import QuadrupoleElement +from schema.Item import Item from schema.Line import Line @@ -27,16 +28,16 @@ def test_ThickElement(): element_length = 1.0 element = ThickElement( name=element_name, - Length=element_length, + length=element_length, ) assert element.name == element_name - assert element.Length == element_length + assert element.length == element_length # Try to assign negative length and # detect validation error without breaking pytest element_length = -1.0 passed = True try: - element.Length = element_length + element.length = element_length except ValidationError as e: print(e) passed = False @@ -49,16 +50,16 @@ def test_DriftElement(): element_length = 1.0 element = DriftElement( name=element_name, - Length=element_length, + length=element_length, ) assert element.name == element_name - assert element.Length == element_length + assert element.length == element_length # Try to assign negative length and # detect validation error without breaking pytest element_length = -1.0 passed = True try: - element.Length = element_length + element.length = element_length except ValidationError as e: print(e) passed = False @@ -85,11 +86,11 @@ def test_QuadrupoleElement(): ) element = QuadrupoleElement( name=element_name, - Length=element_length, + length=element_length, MagneticMultipoleP=element_magnetic_multipole, ) assert element.name == element_name - assert element.Length == element_length + assert element.length == element_length assert element.MagneticMultipoleP.Bn1 == element_magnetic_multipole_Bn1 assert element.MagneticMultipoleP.Bs1 == element_magnetic_multipole_Bs1 assert element.MagneticMultipoleP.tilt1 == element_magnetic_multipole_tilt1 @@ -104,27 +105,31 @@ def test_QuadrupoleElement(): def test_Line(): # Create first line with one base element element1 = BaseElement(name="element1") - line1 = Line(line=[element1]) - assert line1.line == [element1] + item1 = Item(item=element1) + line1 = Line(line=[item1]) + assert item1.item == element1 + assert line1.line == [item1] # Extend first line with one thick element - element2 = ThickElement(name="element2", Length=2.0) - line1.line.extend(Line(line=[element2]).line) - assert line1.line == [element1, element2] + element2 = ThickElement(name="element2", length=2.0) + item2 = Item(item=element2) + line1.line.extend([item2]) + assert line1.line == [item1, item2] # Create second line with one drift element - element3 = DriftElement(name="element3", Length=3.0) - line2 = Line(line=[element3]) + element3 = DriftElement(name="element3", length=3.0) + line2 = Line(line=[Item(item=element3)]) # Extend first line with second line line1.line.extend(line2.line) - assert line1.line == [element1, element2, element3] + assert line1.line[:2] == [item1, item2] + assert line1.line[2].item == element3 def test_yaml(): # Create one base element element1 = BaseElement(name="element1") # Create one thick element - element2 = ThickElement(name="element2", Length=2.0) + element2 = ThickElement(name="element2", length=2.0) # Create line with both elements - line = Line(line=[element1, element2]) + line = Line(line=[Item(item=element1), Item(item=element2)]) # Serialize the Line object to YAML yaml_data = yaml.dump(line.model_dump(), default_flow_style=False) print(f"\n{yaml_data}") @@ -147,9 +152,9 @@ def test_json(): # Create one base element element1 = BaseElement(name="element1") # Create one thick element - element2 = ThickElement(name="element2", Length=2.0) + element2 = ThickElement(name="element2", length=2.0) # Create line with both elements - line = Line(line=[element1, element2]) + line = Line(line=[Item(item=element1), Item(item=element2)]) # Serialize the Line object to JSON json_data = json.dumps(line.model_dump(), sort_keys=True, indent=2) print(f"\n{json_data}") From f16798ba75694572c6a71a1a0b38a43065245656 Mon Sep 17 00:00:00 2001 From: Edoardo Zoni Date: Fri, 11 Apr 2025 14:35:15 -0700 Subject: [PATCH 27/31] Update README.md --- README.md | 194 +++++------------------------------------------------- 1 file changed, 18 insertions(+), 176 deletions(-) diff --git a/README.md b/README.md index 6fd75d2..430aa65 100644 --- a/README.md +++ b/README.md @@ -1,205 +1,47 @@ # Meet Your Python PALS -This is a Python implementation for the Particle Accelerator Lattice Standard (PALS). +This is a Python implementation for the Particle Accelerator Lattice Standard ([PALS](https://github.com/campa-consortium/pals)). -To define the PALS schema, [Pydantic](https://docs.pydantic.dev) is used to map to Python objects, perform automatic validation, and to (de)serialize data classes to/from many modern file formats. -Various modern file formats (e.g., YAML, JSON, TOML, XML, ...) are supported, which makes implementation of the schema-following creates files in any modern programming language easy (e.g., Python, Julia, LUA, C++, Javascript, ...). +To define the PALS schema, [Pydantic](https://docs.pydantic.dev) is used to map to Python objects, perform automatic validation, and serialize/deserialize data classes to/from many modern file formats. +Various modern file formats (e.g., YAML, JSON, TOML, XML, etc.) are supported, which makes the implementation of the schema-following files in any modern programming language easy (e.g., Python, Julia, C++, LUA, Javascript, etc.). Here, we do Python. ## Status -This project is a work-in-progress and evolves alongside the Particle Accelerator Lattice Standard (PALS) documents. +This project is a work-in-progress and evolves alongside the Particle Accelerator Lattice Standard ([PALS](https://github.com/campa-consortium/pals)). ## Approach -This project implements the PALS schema in a file agnostic way, mirrored in data objects. -The corresponding serialized files (and optionally, also the corresponding Python objects) can be human-written, human-read, and automatically be validated. +This project implements the PALS schema in a file-agnostic way, mirrored in data objects. +The corresponding serialized files (and optionally, also the corresponding Python objects) can be human-written, human-read, and automatically validated. PALS files follow a schema and readers can error out on issues. Not every PALS implementation needs to be as detailed as this reference implementation in Python. -Nonetheless, you can use this implementation to convert between differnt file formats (see above) or to validate a file before reading it with your favorite YAML/JSON/TOML/XML/... library in your programming language of choice. - -So let's go, let us use the element descriptions we love and do not spend time anymore on parsing differences between code conventions. +Nonetheless, you can use this implementation to convert between differnt file formats or to validate a file before reading it with your favorite YAML/JSON/TOML/XML/... library in your programming language of choice. This will enable us to: -- exchange lattices between codes -- use common GUIs for defining lattices -- use common lattice visualization tools (2D, 3D, etc.) +- exchange lattices between codes; +- use common GUIs for defining lattices; +- use common lattice visualization tools (2D, 3D, etc.). ### FAQ -*Why do you use Pydantic for this implementation?* +*Why use Pydantic for this implementation?* Implementing directly against a specific file format is possible, but cumbersome. -By using widely-used schema engine, we can get the "last" part, serialization and deserialization to various file formats (and converting between them, and validating them) for free. +By using a widely-used schema engine, such as [Pydantic](https://docs.pydantic.dev), we can get serialization/deserialization to/from various file formats, conversion, and validation "for free". ## Roadmap Preliminary roadmap: -1. Define the PALS schema, using Pydantic -2. Document the API well. -3. Reference implementation in Python -3.1. attract additional reference implementations in other languages. +1. Define the PALS schema, using Pydantic. +2. Document the API. +3. Reference implementation in Python. +3.1. Attract additional reference implementations in other languages. 4. Add supporting helpers, which can import existing MAD-X, Elegant, SXF files. -4.1. Try to be pretty feature complete in these importers (yeah, hard). -5. Implement readers in active community codes for beamline modeling. - Reuse the reference implementations: e.g., we will use this project for the [BLAST codes](https://blast.lbl.gov). - - -## Examples - -### YAML - -```yaml -line: -- ds: 1.0 - element: drift - nslice: 1 -- ds: 0.5 - element: sbend - nslice: 1 - rc: 5.0 -- ds: 0.0 - element: marker - name: midpoint -- line: - - ds: 1.0 - element: drift - nslice: 1 - - ds: 0.0 - element: marker - name: otherpoint - - ds: 0.5 - element: sbend - name: special-bend - nslice: 1 - rc: 5.0 - - ds: 0.5 - element: sbend - nslice: 1 - rc: 5.0 -``` - -### JSON - -```json -{ - "line": [ - { - "ds": 1.0, - "element": "drift", - "nslice": 1 - }, - { - "ds": 0.5, - "element": "sbend", - "nslice": 1, - "rc": 5.0 - }, - { - "ds": 0.0, - "element": "marker", - "name": "midpoint" - }, - { - "line": [ - { - "ds": 1.0, - "element": "drift", - "nslice": 1 - }, - { - "ds": 0.0, - "element": "marker", - "name": "otherpoint" - }, - { - "ds": 0.5, - "element": "sbend", - "name": "special-bend", - "nslice": 1, - "rc": 5.0 - }, - { - "ds": 0.5, - "element": "sbend", - "nslice": 1, - "rc": 5.0 - } - ] - } - ] -} -``` - -### Python Dictionary - -```py -{ - "line": [ - { - "ds": 1.0, - "element": "drift", - "nslice": 1 - }, - { - "ds": 0.5, - "element": "sbend", - "nslice": 1, - "rc": 5.0 - }, - { - "ds": 0.0, - "element": "marker", - "name": "midpoint" - }, - { - "line": [ - { - "ds": 1.0, - "element": "drift", - "nslice": 1 - }, - { - "ds": 0.0, - "element": "marker", - "name": "otherpoint" - }, - { - "ds": 0.5, - "element": "sbend", - "name": "special-bend", - "nslice": 1, - "rc": 5.0 - }, - { - "ds": 0.5, - "element": "sbend", - "nslice": 1, - "rc": 5.0 - } - ] - } - ] -} -``` - -### Python Dataclass Objects - -```py -line=[ - Drift(name=None, ds=1.0, nslice=1, element='drift'), - SBend(name=None, ds=0.5, nslice=1, element='sbend', rc=5.0), - Marker(name='midpoint', ds=0.0, element='marker'), - Line(line=[ - Drift(name=None, ds=1.0, nslice=1, element='drift'), - Marker(name='otherpoint', ds=0.0, element='marker'), - SBend(name='special-bend', ds=0.5, nslice=1, element='sbend', rc=5.0), - SBend(name=None, ds=0.5, nslice=1, element='sbend', rc=5.0) - ]) -] -``` +4.1. Try to be as feature complete as possible in these importers. +5. Reuse the reference implementations and implement readers in community codes for beamline modeling (e.g., the [BLAST codes](https://blast.lbl.gov)). From 936b58215f7ce5ee7ce9275b3f35751255d492d0 Mon Sep 17 00:00:00 2001 From: Edoardo Zoni Date: Fri, 11 Apr 2025 14:44:37 -0700 Subject: [PATCH 28/31] Rename `element` as `kind` --- schema/BaseElement.py | 2 +- schema/DriftElement.py | 2 +- schema/Item.py | 4 ++-- schema/Line.py | 4 ++-- schema/QuadrupoleElement.py | 2 +- schema/ThickElement.py | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/schema/BaseElement.py b/schema/BaseElement.py index 744118e..825635e 100644 --- a/schema/BaseElement.py +++ b/schema/BaseElement.py @@ -6,7 +6,7 @@ class BaseElement(BaseModel): """A custom base element defining common properties""" # Discriminator field - element: Literal["BaseElement"] = "BaseElement" + kind: Literal["BaseElement"] = "BaseElement" # Validate every time a new value is assigned to an attribute, # not only when an instance of BaseElement is created diff --git a/schema/DriftElement.py b/schema/DriftElement.py index 754f313..bd6be4f 100644 --- a/schema/DriftElement.py +++ b/schema/DriftElement.py @@ -7,4 +7,4 @@ class DriftElement(ThickElement): """A field free region""" # Discriminator field - element: Literal["DriftElement"] = "DriftElement" + kind: Literal["DriftElement"] = "DriftElement" diff --git a/schema/Item.py b/schema/Item.py index 26c2ebf..df40bb2 100644 --- a/schema/Item.py +++ b/schema/Item.py @@ -11,7 +11,7 @@ class Item(BaseModel): """An element of a line or a line itself""" # Discriminator field - element: Literal["Item"] = "Item" + kind: Literal["Item"] = "Item" # Validate every time a new value is assigned to an attribute, # not only when an instance of Line is created @@ -26,5 +26,5 @@ class Item(BaseModel): DriftElement, QuadrupoleElement, ], - Field(discriminator="element"), + Field(discriminator="kind"), ] diff --git a/schema/Line.py b/schema/Line.py index 67a23de..a848811 100644 --- a/schema/Line.py +++ b/schema/Line.py @@ -9,13 +9,13 @@ class Line(BaseModel): """A line of elements and/or other lines""" # Discriminator field - element: Literal["Line"] = "Line" + kind: Literal["Line"] = "Line" # Validate every time a new value is assigned to an attribute, # not only when an instance of Line is created model_config = ConfigDict(validate_assignment=True) - line: List[Annotated[Item, Field(discriminator="element")]] + line: List[Annotated[Item, Field(discriminator="kind")]] # Avoid circular import issues diff --git a/schema/QuadrupoleElement.py b/schema/QuadrupoleElement.py index 2a16955..6ce1df2 100644 --- a/schema/QuadrupoleElement.py +++ b/schema/QuadrupoleElement.py @@ -8,7 +8,7 @@ class QuadrupoleElement(ThickElement): """A quadrupole element""" # Discriminator field - element: Literal["QuadrupoleElement"] = "QuadrupoleElement" + kind: Literal["QuadrupoleElement"] = "QuadrupoleElement" # Magnetic multipole parameters MagneticMultipoleP: MagneticMultipoleParameters diff --git a/schema/ThickElement.py b/schema/ThickElement.py index ca7c855..9461041 100644 --- a/schema/ThickElement.py +++ b/schema/ThickElement.py @@ -8,7 +8,7 @@ class ThickElement(BaseElement): """A thick base element with finite segment length""" # Discriminator field - element: Literal["ThickElement"] = "ThickElement" + kind: Literal["ThickElement"] = "ThickElement" # Segment length in meters (m) length: Annotated[float, Gt(0)] From 70d3733802a3d6a128d9f1d90afbbc58aba9c896 Mon Sep 17 00:00:00 2001 From: Edoardo Zoni Date: Fri, 11 Apr 2025 16:55:58 -0700 Subject: [PATCH 29/31] Start FODO example --- .github/workflows/unit_tests.yml | 16 ++++++ examples/fodo.py | 88 ++++++++++++++++++++++++++++++++ schema/DriftElement.py | 2 +- schema/Item.py | 5 +- schema/Line.py | 9 ++-- schema/QuadrupoleElement.py | 2 +- 6 files changed, 110 insertions(+), 12 deletions(-) create mode 100644 examples/fodo.py diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 1714249..7a9e2ff 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -27,3 +27,19 @@ jobs: - name: Test run: | pytest tests -v + examples: + name: examples + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + - name: Test + run: | + python examples/fodo.py diff --git a/examples/fodo.py b/examples/fodo.py new file mode 100644 index 0000000..ad80dd5 --- /dev/null +++ b/examples/fodo.py @@ -0,0 +1,88 @@ +import json +import os +import sys +import yaml + +# Add the parent directory to sys.path +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from schema.MagneticMultipoleParameters import MagneticMultipoleParameters + +from schema.DriftElement import DriftElement +from schema.QuadrupoleElement import QuadrupoleElement + +from schema.Item import Item +from schema.Line import Line + + +def main(): + drift1 = DriftElement( + name="drift1", + length=0.25, + ) + quad1 = QuadrupoleElement( + name="quad1", + length=1.0, + MagneticMultipoleP=MagneticMultipoleParameters( + Bn1=1.0, + ), + ) + drift2 = DriftElement( + name="drift2", + length=0.5, + ) + quad2 = QuadrupoleElement( + name="quad2", + length=1.0, + MagneticMultipoleP=MagneticMultipoleParameters( + Bn1=-1.0, + ), + ) + drift3 = DriftElement( + name="drift3", + length=0.5, + ) + # Create line with all elements + line = Line( + line=[ + Item(item=drift1), + Item(item=quad1), + Item(item=drift2), + Item(item=quad2), + Item(item=drift3), + ] + ) + # Serialize to YAML + yaml_data = yaml.dump(line.model_dump(), default_flow_style=False) + print("Dumping YAML data...") + print(f"{yaml_data}") + # Write YAML data to file + yaml_file = "examples_fodo.yaml" + with open(yaml_file, "w") as file: + file.write(yaml_data) + # Read YAML data from file + with open(yaml_file, "r") as file: + yaml_data = yaml.safe_load(file) + # Parse YAML data + loaded_line = Line(**yaml_data) + # Validate loaded data + assert line == loaded_line + # Serialize to JSON + json_data = json.dumps(line.model_dump(), sort_keys=True, indent=2) + print("Dumping JSON data...") + print(f"{json_data}") + # Write JSON data to file + json_file = "examples_fodo.json" + with open(json_file, "w") as file: + file.write(json_data) + # Read JSON data from file + with open(json_file, "r") as file: + json_data = json.loads(file.read()) + # Parse JSON data + loaded_line = Line(**json_data) + # Validate loaded data + assert line == loaded_line + + +if __name__ == "__main__": + main() diff --git a/schema/DriftElement.py b/schema/DriftElement.py index bd6be4f..ce1bc01 100644 --- a/schema/DriftElement.py +++ b/schema/DriftElement.py @@ -7,4 +7,4 @@ class DriftElement(ThickElement): """A field free region""" # Discriminator field - kind: Literal["DriftElement"] = "DriftElement" + kind: Literal["Drift"] = "Drift" diff --git a/schema/Item.py b/schema/Item.py index df40bb2..f0a469c 100644 --- a/schema/Item.py +++ b/schema/Item.py @@ -1,5 +1,5 @@ from pydantic import BaseModel, ConfigDict, Field -from typing import Annotated, Literal, Union +from typing import Annotated, Union from schema.BaseElement import BaseElement from schema.ThickElement import ThickElement @@ -10,9 +10,6 @@ class Item(BaseModel): """An element of a line or a line itself""" - # Discriminator field - kind: Literal["Item"] = "Item" - # Validate every time a new value is assigned to an attribute, # not only when an instance of Line is created model_config = ConfigDict(validate_assignment=True) diff --git a/schema/Line.py b/schema/Line.py index a848811..c407210 100644 --- a/schema/Line.py +++ b/schema/Line.py @@ -1,5 +1,5 @@ -from pydantic import BaseModel, ConfigDict, Field -from typing import Annotated, List, Literal +from pydantic import BaseModel, ConfigDict +from typing import List from schema.Item import Item @@ -8,14 +8,11 @@ class Line(BaseModel): """A line of elements and/or other lines""" - # Discriminator field - kind: Literal["Line"] = "Line" - # Validate every time a new value is assigned to an attribute, # not only when an instance of Line is created model_config = ConfigDict(validate_assignment=True) - line: List[Annotated[Item, Field(discriminator="kind")]] + line: List[Item] # Avoid circular import issues diff --git a/schema/QuadrupoleElement.py b/schema/QuadrupoleElement.py index 6ce1df2..f778a3d 100644 --- a/schema/QuadrupoleElement.py +++ b/schema/QuadrupoleElement.py @@ -8,7 +8,7 @@ class QuadrupoleElement(ThickElement): """A quadrupole element""" # Discriminator field - kind: Literal["QuadrupoleElement"] = "QuadrupoleElement" + kind: Literal["Quadrupole"] = "Quadrupole" # Magnetic multipole parameters MagneticMultipoleP: MagneticMultipoleParameters From abc928d80cc6f8f7beff46a123366daba9e7a78a Mon Sep 17 00:00:00 2001 From: Edoardo Zoni Date: Thu, 24 Apr 2025 11:18:21 -0700 Subject: [PATCH 30/31] Revert to previous implementation without class `Item` --- examples/fodo.py | 11 +++++------ schema/Item.py | 27 --------------------------- schema/Line.py | 25 ++++++++++++++++++++----- tests/test_schema.py | 21 ++++++++------------- 4 files changed, 33 insertions(+), 51 deletions(-) delete mode 100644 schema/Item.py diff --git a/examples/fodo.py b/examples/fodo.py index ad80dd5..c2e6ed5 100644 --- a/examples/fodo.py +++ b/examples/fodo.py @@ -11,7 +11,6 @@ from schema.DriftElement import DriftElement from schema.QuadrupoleElement import QuadrupoleElement -from schema.Item import Item from schema.Line import Line @@ -45,11 +44,11 @@ def main(): # Create line with all elements line = Line( line=[ - Item(item=drift1), - Item(item=quad1), - Item(item=drift2), - Item(item=quad2), - Item(item=drift3), + drift1, + quad1, + drift2, + quad2, + drift3, ] ) # Serialize to YAML diff --git a/schema/Item.py b/schema/Item.py deleted file mode 100644 index f0a469c..0000000 --- a/schema/Item.py +++ /dev/null @@ -1,27 +0,0 @@ -from pydantic import BaseModel, ConfigDict, Field -from typing import Annotated, Union - -from schema.BaseElement import BaseElement -from schema.ThickElement import ThickElement -from schema.DriftElement import DriftElement -from schema.QuadrupoleElement import QuadrupoleElement - - -class Item(BaseModel): - """An element of a line or a line itself""" - - # Validate every time a new value is assigned to an attribute, - # not only when an instance of Line is created - model_config = ConfigDict(validate_assignment=True) - - # NOTE Since pydantic 2.9, the discriminator must be applied to the union type, not the list - # (see https://github.com/pydantic/pydantic/issues/10352) - item: Annotated[ - Union[ - BaseElement, - ThickElement, - DriftElement, - QuadrupoleElement, - ], - Field(discriminator="kind"), - ] diff --git a/schema/Line.py b/schema/Line.py index c407210..e1ad070 100644 --- a/schema/Line.py +++ b/schema/Line.py @@ -1,8 +1,10 @@ -from pydantic import BaseModel, ConfigDict -from typing import List +from pydantic import BaseModel, ConfigDict, Field +from typing import Annotated, List, Literal, Union - -from schema.Item import Item +from schema.BaseElement import BaseElement +from schema.ThickElement import ThickElement +from schema.DriftElement import DriftElement +from schema.QuadrupoleElement import QuadrupoleElement class Line(BaseModel): @@ -12,7 +14,20 @@ class Line(BaseModel): # not only when an instance of Line is created model_config = ConfigDict(validate_assignment=True) - line: List[Item] + kind: Literal["Line"] = "Line" + + line: List[ + Annotated[ + Union[ + BaseElement, + ThickElement, + DriftElement, + QuadrupoleElement, + "Line", + ], + Field(discriminator="kind"), + ] + ] # Avoid circular import issues diff --git a/tests/test_schema.py b/tests/test_schema.py index f974449..3abdc8a 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -11,7 +11,6 @@ from schema.DriftElement import DriftElement from schema.QuadrupoleElement import QuadrupoleElement -from schema.Item import Item from schema.Line import Line @@ -105,22 +104,18 @@ def test_QuadrupoleElement(): def test_Line(): # Create first line with one base element element1 = BaseElement(name="element1") - item1 = Item(item=element1) - line1 = Line(line=[item1]) - assert item1.item == element1 - assert line1.line == [item1] + line1 = Line(line=[element1]) + assert line1.line == [element1] # Extend first line with one thick element element2 = ThickElement(name="element2", length=2.0) - item2 = Item(item=element2) - line1.line.extend([item2]) - assert line1.line == [item1, item2] + line1.line.extend([element2]) + assert line1.line == [element1, element2] # Create second line with one drift element element3 = DriftElement(name="element3", length=3.0) - line2 = Line(line=[Item(item=element3)]) + line2 = Line(line=[element3]) # Extend first line with second line line1.line.extend(line2.line) - assert line1.line[:2] == [item1, item2] - assert line1.line[2].item == element3 + assert line1.line == [element1, element2, element3] def test_yaml(): @@ -129,7 +124,7 @@ def test_yaml(): # Create one thick element element2 = ThickElement(name="element2", length=2.0) # Create line with both elements - line = Line(line=[Item(item=element1), Item(item=element2)]) + line = Line(line=[element1, element2]) # Serialize the Line object to YAML yaml_data = yaml.dump(line.model_dump(), default_flow_style=False) print(f"\n{yaml_data}") @@ -154,7 +149,7 @@ def test_json(): # Create one thick element element2 = ThickElement(name="element2", length=2.0) # Create line with both elements - line = Line(line=[Item(item=element1), Item(item=element2)]) + line = Line(line=[element1, element2]) # Serialize the Line object to JSON json_data = json.dumps(line.model_dump(), sort_keys=True, indent=2) print(f"\n{json_data}") From f131afda7b9578fbf1c235f418ea738d350d2eb6 Mon Sep 17 00:00:00 2001 From: Edoardo Zoni Date: Thu, 24 Apr 2025 11:51:26 -0700 Subject: [PATCH 31/31] Update README.md: how to run tests, examples locally --- README.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/README.md b/README.md index 430aa65..a036dbe 100644 --- a/README.md +++ b/README.md @@ -45,3 +45,29 @@ Preliminary roadmap: 4. Add supporting helpers, which can import existing MAD-X, Elegant, SXF files. 4.1. Try to be as feature complete as possible in these importers. 5. Reuse the reference implementations and implement readers in community codes for beamline modeling (e.g., the [BLAST codes](https://blast.lbl.gov)). + + +## How to run the tests and examples locally + +In order to run the tests and examples locally, please follow these steps: + +1. Create a conda environment from the `environment.yml` file: + ```bash + conda env create -f environment.yml + ``` +2. Activate the conda environment: + ```bash + conda activate pals-python + ``` + Please double check the environment name in the `environment.yml` file. +3. Run the tests locally: + ```bash + pytest tests -v + ``` + The command line option `-v` increases the verbosity of the output. + You can also use the command line option `-s` to display any test output directly in the console (useful for debugging). + Please refer to [pytest's documentation](https://docs.pytest.org/en/stable/) for further details on the available command line options and/or run `pytest --help`. +4. Run the examples locally (e.g., `fodo.py`): + ```bash + python examples/fodo.py + ```