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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions src/pals/PALS.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@
from pydantic import model_validator
from typing import Self

from .commands.all_commands import get_all_command_types
from .kinds import Lattice
from .kinds.all_elements import get_all_elements_as_annotation
from .functions import load_file_to_dict, store_dict_to_file


Facility = list[get_all_elements_as_annotation()]
# Unlike an element list, the facility list also holds commands.
Facility = list[get_all_elements_as_annotation(extra_types=get_all_command_types())]


class Author(BaseModel):
Expand Down Expand Up @@ -95,7 +97,9 @@ def unpack_json_structure(cls, data):
if data.get("facility") is not None:
if not isinstance(data["facility"], list):
raise TypeError("'facility' must be a list")
data["facility"] = unpack_element_items(data["facility"], "facility")
data["facility"] = unpack_element_items(
data["facility"], "facility", allow_commands=True
)

return data

Expand Down
1 change: 1 addition & 0 deletions src/pals/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
simpler import statements like `from pals import Drift`.
"""

from .commands import * # noqa
from .kinds import * # noqa
from .parameters import * # noqa
from .PALS import Author, ExtensionLabels, PALSroot, load, store # noqa
Expand Down
19 changes: 19 additions & 0 deletions src/pals/commands/FacilityCommand.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from pydantic import BaseModel


# A command value is either a number or an expression written as a string, per
# the standard's Expression type. Expressions are recorded verbatim: this
# implementation builds the exact representation of a PALS file and does not
# evaluate them.
CommandValue = bool | int | float | str | None


class FacilityCommand(BaseModel, extra="forbid"):
"""A `facility` entry that is a command rather than a lattice element.

Element entries are one-key mappings whose key is the element's name;
command entries are one-key mappings whose key is a reserved node keyword
(`set`, `sets`, ...). Commands keep their place in the ordered `facility`
list, because the standard's lattice expansion only lets a command act on
what was defined before it.
"""
53 changes: 53 additions & 0 deletions src/pals/commands/SetCommand.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
from pydantic import model_serializer

from .FacilityCommand import CommandValue, FacilityCommand


class SetCommand(FacilityCommand):
"""The `set` command, which writes a parameter of the elements it matches.

See the standard's Setting Parameters section. `parameter` is a pattern, so
one command can write several elements. In the `value` expression,
`PARAMETER` is the current value of the parameter being written and `SELF`
is the element it belongs to.

If both `absolute_error` and `relative_error` are given, the true error is
`absolute_error + relative_error * |value|`. The standard documents both as
defaulting to zero; they default to `None` here so that a value the file did
not write stays out of the serialized output.

Note that `set` is a reserved key of the `facility` list: an element cannot
be named `set`.

Example:
>>> SetCommand(parameter="B1.*>BendP.e1", value="2*PARAMETER")
SetCommand(parameter='B1.*>BendP.e1', value='2*PARAMETER', ...)
"""

parameter: str
value: CommandValue = None
absolute_error: float | None = None
relative_error: float | None = None

@model_serializer(mode="plain")
def _serialize_as_command(self) -> dict:
"""Serialize back into the one-key `set:` form the standard writes.

A plain serializer (rather than a `model_dump` override, as the
elements use) also covers the case where the enclosing model serializes
the whole `facility` union at once.
"""
return {"set": self.command_body()}

def command_body(self) -> dict:
"""The command's properties, without the ones the file did not set.

Unset properties are dropped, the same way `exclude_none` drops them
for elements and for the root node.
"""
body = {"parameter": self.parameter}
for key in ("value", "absolute_error", "relative_error"):
value = getattr(self, key)
if value is not None:
body[key] = value
return body
32 changes: 32 additions & 0 deletions src/pals/commands/SetsCommand.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
from pydantic import model_serializer

from .FacilityCommand import FacilityCommand
from .SetCommand import SetCommand


class SetsCommand(FacilityCommand):
"""The compact `sets` command: a sequence of parameter/value pairs.

See the standard's Setting Parameters section, which offers this form for
sets that only have a value:

```yaml
- sets:
- param1: value1
- param2: value2
```

Each pair is held as a `SetCommand`, so a reader can treat the compact form
and the `set` form alike; the compact form is what gets written back out.
Being a sequence, it keeps its order and may name the same parameter twice.

Note that `sets` is a reserved key of the `facility` list: an element cannot
be named `sets`.
"""

sets: list[SetCommand]

@model_serializer(mode="plain")
def _serialize_as_command(self) -> dict:
"""Serialize back into the one-key `sets:` form the standard writes."""
return {"sets": [{pair.parameter: pair.value} for pair in self.sets]}
7 changes: 7 additions & 0 deletions src/pals/commands/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"""Re-export commonly used classes from submodules so callers can use
simpler import statements like `from pals import SetCommand`.
"""

from .FacilityCommand import FacilityCommand # noqa: F401
from .SetCommand import SetCommand # noqa: F401
from .SetsCommand import SetsCommand # noqa: F401
76 changes: 76 additions & 0 deletions src/pals/commands/all_commands.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"""Helper module that knows every command the `facility` list can hold.

This is the one place that maps a reserved node keyword (`set`, `sets`, ...) to
the model that holds it, so adding a command means touching this module and
nothing else.
"""

from .FacilityCommand import FacilityCommand
from .SetCommand import SetCommand
from .SetsCommand import SetsCommand


def _build_set(body) -> SetCommand:
"""Build a `set` command from its properties mapping."""
if not isinstance(body, dict):
raise TypeError(
f"Value for the 'set' command must be a dict (the command's properties), "
f"but we got {body!r}"
)
return SetCommand(**body)


def _build_sets(body) -> SetsCommand:
"""Build a `sets` command from its sequence of parameter/value pairs."""
if not isinstance(body, list):
raise TypeError(
f"Value for the 'sets' command must be a list of parameter/value pairs, "
f"but we got {body!r}"
)

pairs = []
for entry in body:
if not isinstance(entry, dict) or len(entry) != 1:
raise ValueError(
f"Each 'sets' entry must be a dict with exactly one key (the "
f"parameter to set), but we got {entry!r}"
)
parameter, value = next(iter(entry.items()))
pairs.append(SetCommand(parameter=parameter, value=value))

return SetsCommand(sets=pairs)


# Reserved keys of the `facility` list, and how to build what they hold. A
# facility entry keyed by one of these is a command, not an element, so an
# element cannot carry one of these names.
_COMMAND_BUILDERS = {
"set": _build_set,
"sets": _build_sets,
}


def get_all_command_types() -> tuple:
"""Return a tuple of all command types that can appear in a facility."""
return (SetCommand, SetsCommand)


def build_facility_command(item) -> FacilityCommand | None:
"""Build the command a raw facility entry holds.

Args:
item: One raw entry of the `facility` list

Returns:
The command, or None if the entry is not a command and should be
unpacked as a lattice element instead
"""
if not isinstance(item, dict) or len(item) != 1:
return None

keyword, body = next(iter(item.items()))
builder = _COMMAND_BUILDERS.get(keyword)
if builder is None:
return None

return builder(body)
24 changes: 22 additions & 2 deletions src/pals/kinds/mixin/all_element_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
from ..PlaceholderName import PlaceholderName


def unpack_element_items(items: list, container_type: str) -> list:
def unpack_element_items(
items: list, container_type: str, allow_commands: bool = False
) -> list:
"""Deserialize the JSON/YAML/...-like items of an element list.

Each item can be a reference string, a `use:` reference, a one-key dict
Expand All @@ -17,13 +19,31 @@ def unpack_element_items(items: list, container_type: str) -> list:
Args:
items: The list of raw element entries
container_type: Type of container for error messages (e.g., "line" or "union")
allow_commands: Whether entries keyed by a reserved command keyword
(`set`, `sets`, ...) are commands rather than elements. Only the
`facility` list holds commands; element lists do not.

Returns:
A new list with each entry unpacked to a dict, PlaceholderName, or element
A new list with each entry unpacked to a dict, PlaceholderName, command,
or element
"""
from pals.commands.all_commands import build_facility_command
from pals.commands.FacilityCommand import FacilityCommand

new_list = []
# Loop over all elements in the list
for item in items:
# A facility entry keyed by a reserved keyword is a command, which is
# checked before the `{name: properties}` element form below.
if allow_commands:
if isinstance(item, FacilityCommand):
new_list.append(item)
continue
command = build_facility_command(item)
if command is not None:
new_list.append(command)
continue

# An element can be a string that refers to another element
if isinstance(item, str):
# Wrap the string in a Placeholder name object
Expand Down
8 changes: 8 additions & 0 deletions tests/pals_files/sets/bad_set_key.pals.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# The `set` command has a fixed set of properties, so a misspelled one is an
# error rather than a silently kept extra.
PALS:
facility:
- set:
parameter: d1>length
value: 1.0
absolut_error: 0.001
5 changes: 5 additions & 0 deletions tests/pals_files/sets/bad_set_sequence.pals.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# The `set` command holds its properties in a mapping, not a sequence.
PALS:
facility:
- set:
- parameter: d1>length
6 changes: 6 additions & 0 deletions tests/pals_files/sets/bad_sets_entry.pals.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Each `sets` entry writes one parameter, so it holds exactly one key.
PALS:
facility:
- sets:
- d1>length: 1.0
q1>length: 2.0
5 changes: 5 additions & 0 deletions tests/pals_files/sets/bad_sets_mapping.pals.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# The compact `sets` form is a sequence of pairs, not a mapping.
PALS:
facility:
- sets:
d1>length: 1.0
20 changes: 20 additions & 0 deletions tests/pals_files/sets/set_command.pals.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# The `set` command in its mapping form: an expression value that uses
# PARAMETER, a pattern that matches several elements, and the optional error
# terms.
PALS:
version: null

facility:
- d1:
kind: Drift
length: 1.0

- set:
parameter: d1>length
value: 2 * PARAMETER

- set:
parameter: B1.*>BendP.e1
value: 0.25
absolute_error: 0.001
relative_error: 0.02
14 changes: 14 additions & 0 deletions tests/pals_files/sets/sets_compact.pals.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# The compact `sets` form: an ordered sequence of parameter/value pairs. Being
# a sequence, it keeps its order and may name the same parameter twice.
PALS:
version: null

facility:
- d1:
kind: Drift
length: 1.0

- sets:
- d1>length: 2 * 1.5
- q1>MagneticMultipoleP.Kn1L: 0.25
- d1>length: 3.0
5 changes: 2 additions & 3 deletions tests/standard_examples_known_failures.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
# to load as an error, so this list shrinks as support lands. Blank lines and
# `#` comments are ignored.

# The sequence form of `variables` and the compact `sets` form are not
# modeled.
# The sequence form of `variables` is not modeled, and expressions are not
# accepted where a parameter is typed as a number (e.g. `length: 0.1*log(x)`).
unit_tests/expressions/inline_expressions.pals.yaml
unit_tests/sets/sets_compact.pals.yaml
Loading