Source code for palsparserpy.parser

"""
The lattice-level API: expanding a PALS document into its five views, evaluating
an expression, mapping nodes across the derivation chain, resolving a name-match
string, and reading a parameter value.
"""

from __future__ import annotations

import ctypes
import os
import sys
from typing import Dict, List, Optional, Union

from ._clib import (PARAM_VALUE_NUMBER, PARAM_VALUE_STRING, ParamValueC,
                    ProblemListC, encode, libparser, take_string, YAML_NULL_ID)
from .node import PALSParseError, YAMLNode, _root_node
from .structs import (Lattices, NodeCorrespondence, Problem, ProblemOrigin,
                      ProblemSeverity)

__all__ = ["parse_and_expand_pals", "evaluate_pals_expression",
           "node_correspondence", "match_names", "parameter_value"]


# ─── parse_and_expand_pals ───────────────────────────────────────────────────

def _take_problem_list(problems: ProblemListC) -> List[Problem]:
    """Copy the C-owned problems into a list and free the underlying C array.

    Always frees, even when the list is empty. Both strings are copied out before
    the free, so nothing points into C memory afterwards.
    """
    out = []
    for i in range(problems.count):
        entry = problems.items[i]
        out.append(Problem(entry.message.decode("utf-8"),
                           entry.path.decode("utf-8"),
                           ProblemSeverity(entry.severity),
                           ProblemOrigin(entry.origin)))
    libparser().free_lattice_problems(problems)
    return out


def _report_problems(problems: List[Problem], mode) -> None:
    """Apply the ``problems`` output policy: ``"print"`` (the default) writes to
    stderr, ``"none"`` does nothing, and anything else names a file to write."""
    if mode == "none":
        return
    if mode == "print":
        if problems:
            print(f"parse_and_expand_pals: {len(problems)} problem(s) encountered "
                  "during lattice expansion:", file=sys.stderr)
            for problem in problems:
                print(f"  - {problem}", file=sys.stderr)
        return
    with open(mode, "w") as out:
        if not problems:
            out.write("No problems encountered during lattice expansion.\n")
        else:
            out.write(f"{len(problems)} problem(s) encountered during lattice "
                      "expansion:\n")
            for problem in problems:
                out.write(f"  - {problem}\n")


[docs] def parse_and_expand_pals(filename, root_lattice: str = "", *, problems: Union[str, os.PathLike] = "print") -> Lattices: """Parse a PALS lattice file and return its five views. Returns a :class:`~palsparserpy.structs.Lattices` holding the ``original``, ``combined``, ``expanded``, ``full_expanded`` and ``adjunct`` views together with the list of expansion ``problems``. Args: filename: Path to the top-level YAML lattice file. root_lattice: Name of the lattice to expand. If empty (the default), the lattice to expand is chosen with the following priority: 1. the lattice named by the last ``use`` statement, or 2. the last lattice defined in the file if no ``use`` statement is present. problems: What to do with the list of problems found while expanding (undefined lattice, dangling element/line references, undefined ``inherit``/``repeat``/``Fork`` targets, and expressions that could not be evaluated). One of: - ``"print"`` (the default) -- print the problems to ``stderr`` (nothing is printed when there are none); - ``"none"`` -- do nothing (no printing, no file); - any other path -- write the problems to that file, printing nothing. Those two names are reserved, so a report cannot be written to a file called ``print`` or ``none``. The same problems handed to ``problems`` are also returned in the ``problems`` field regardless of the reporting mode, so ``"none"`` still lets the caller inspect them programmatically. Each entry carries a ``message``, the ``path`` it was found at, a ``severity`` and an ``origin``; only a ``PROBLEM_INPUT`` can be cleared by editing the lattice. The five tree views are: - ``original``: the tree as read in, mapping each file (including any file it includes or loads) to its unparsed contents. - ``combined``: the tree with all ``include`` directives resolved and spliced inline, and every ``load`` merged in subnode by subnode. - ``full_expanded``: the selected lattice fully expanded, and nothing else -- scalars substituted with their full definitions, every ``repeat`` unrolled, every ``inherit`` merged in, forks resolved, ``set`` commands executed and ABSOLUTE controllers applied. It is rooted at a map holding the single ``name -> Lattice`` entry, without the ``PALS``/ ``facility`` scaffolding the lattice was defined under, so the lattice is reached as ``lat.full_expanded["lat1"]`` rather than through ``["PALS"]["facility"]``. Its ``branches`` entries are branches, not the ``BeamLine``s they were built from, and so carry no ``kind``; a ``BeamLine`` referenced inside a ``line`` is a sub-line whose contents are spliced directly into the enclosing line, so no nested ``BeamLine`` survives in the expanded tree. Elements of a ``multipass`` line carry a ``multipass_index`` giving their pass number -- how many times a particle will have travelled through that physical element by that point -- so every element of one traversal shares an index (the nearest enclosing ``multipass`` line wins when they nest). Every dependent parameter is computed and present: each element carries its ``element_index`` (its position, counting from one, in the branch line that holds it), its ``ReferenceP``, ``FloorP`` and ``s_position``, the derived members of every parameter family it uses, and the non-zero defaults of the groups it carries; each branch is capped with a ``branch_end`` ``Placeholder`` holding its final reference and floor, numbered with the rest. - ``expanded``: the same lattice with all of that removed -- what the author wrote decides which parameters stay. It is ``full_expanded`` with nodes pruned rather than an earlier snapshot, so a parameter present in both views holds the same value in both. Use it to see the inputs rather than their consequences, or to write a lattice back out without the computed values. - ``adjunct``: everything the expanded views do not carry, keeping its ``PALS``/``facility`` scaffolding: element and beamline definitions, ``use`` statements, constants and variables, ``Controller``s, ``set`` commands, and any ``Lattice`` that was not the one expanded. A definition that expansion substituted into the lattice is *copied*, so it appears in both trees. Every mathematical expression is evaluated to a number across the expanded views and ``adjunct`` (see :func:`evaluate_pals_expression`; ``random()``/``random_gauss()`` are left as text). ``Controller`` elements are evaluated against their own scoped variable tables, with each control ``expression`` computed and stored back in its control entry; controllers are facility-level, so they are found in ``adjunct``. Each view is backed by its own tree; all five are freed independently when their nodes are garbage collected. """ filename = os.fspath(filename) if not os.path.isfile(filename): raise FileNotFoundError(f"File not found: {filename}") handles = libparser().parse_and_expand_PALS(encode(filename), encode(root_lattice)) # Take ownership of the problem list before anything can raise. problem_list = _take_problem_list(handles.problems) # NULL handles mean a fatal parse failure (a malformed top-level file): # there is no tree to expand. The C library reports why -- with the offending # line/column -- as the single problem, so surface that rather than a bare # failure. if not all((handles.original, handles.combined, handles.expanded, handles.full_expanded, handles.adjunct)): detail = "" if not problem_list else \ "\n " + "\n ".join(p.message for p in problem_list) raise PALSParseError(f"Failed to parse lattice file: {filename}{detail}") _report_problems(problem_list, problems) return Lattices(_root_node(handles.original), _root_node(handles.combined), _root_node(handles.expanded), _root_node(handles.full_expanded), _root_node(handles.adjunct), problem_list)
# ─── expression evaluation ───────────────────────────────────────────────────
[docs] def evaluate_pals_expression(expr: str) -> float: """Evaluate a single PALS mathematical expression to a ``float``. Supports the full PALS expression grammar: arithmetic (``+ - * / ^``), unary signs, parentheses, the built-in constants (``pi``, ``c_light``, ``r_electron``, ...), the math functions (``sqrt``, ``log``, ``sin``, ``floor``, ``modulo``, ...), and the particle-data functions ``mass_of``, ``charge_of`` and ``anomalous_moment_of`` (backed by AtomicAndPhysicalConstantsCLib), whose species-name argument must be quoted, e.g. ``mass_of("#3He")`` (a mass number carries a leading ``#``). A leading ``expr(...)`` wrapper is accepted and unwrapped. This evaluates a standalone string, so user-defined constants and variables are **not** in scope -- use :func:`parse_and_expand_pals` for whole-lattice evaluation, whose expanded trees already have every expression resolved to a number. Raises ``ValueError`` if ``expr`` is not evaluable: a parse error, an unknown identifier or species, a ``random()``/``random_gauss()`` expression (which is intentionally deferred), or a non-finite result. Example: >>> evaluate_pals_expression("3.75e7 / c_light^2") # 4.172...e-10 >>> evaluate_pals_expression('mass_of("electron")') # 510998.95069... >>> evaluate_pals_expression("expr(2 * pi)") # 6.283... """ ok = ctypes.c_bool(False) value = libparser().evaluate_pals_expression(encode(expr), ctypes.byref(ok)) if not ok.value: raise ValueError(f'Not an evaluable PALS expression: "{expr}"') return value
# ─── node correspondence ─────────────────────────────────────────────────────
[docs] def node_correspondence(lat: Lattices) -> Dict[YAMLNode, NodeCorrespondence]: """Map every node of a lattice to the nodes it corresponds to across the ``original``, ``combined``, ``full_expanded`` and ``adjunct`` trees. The correspondence is exact: it is computed from provenance recorded while the trees were derived from one another (``original`` -> ``combined`` -> ``full_expanded`` and ``adjunct``), not by re-matching after the fact. Because expansion can duplicate a node (scalar substitution, ``repeat``, ``inherit``, forks), the correspondence is one-to-many -- a single ``combined``/``original`` node can map to several ``full_expanded`` copies -- so each field of the returned value is a list of nodes. Expansion splits the document, so a node of ``combined`` may land in ``full_expanded``, in ``adjunct``, or in both: a definition that was substituted into the lattice is copied there while its definition stays behind. Those copies share one equivalence class, tied together through the ``combined`` node they came from. The ``expanded`` view takes no part in the correspondence: it is a pruned copy of ``full_expanded`` rather than a step in the derivation chain, so a node in it is found by the path it sits at, not by a recorded link. Returns: A ``dict`` keyed by node. For any node that participates in the correspondence, ``corr[node]`` is a :class:`~palsparserpy.structs.NodeCorrespondence` -- ``(original, combined, full_expanded, adjunct)`` -- listing every corresponding node grouped by tree. The queried node appears in its own tree's list, so the four lists together are the full equivalence class of ``node``. A list is empty when a tree has no corresponding node (e.g. the synthesised ``destination_pointer`` scalar exists only in ``full_expanded``, and a constant that the lattice never references exists only in ``adjunct``). Example: >>> lat = parse_and_expand_pals("lattice.pals.yaml") >>> corr = node_correspondence(lat) >>> a_const = lat.combined["PALS"]["facility"][0]["constants"][0]["a_const"] >>> corr[a_const].original # the same constant in the original tree >>> corr[a_const].adjunct # constants are not part of the lattice >>> corr[a_const].full_expanded # empty unless the lattice referenced it """ lib = libparser() cmap = lib.build_correspondence_map(lat.original.tree.handle, lat.combined.tree.handle, lat.full_expanded.tree.handle, lat.adjunct.tree.handle) try: links = [(cmap.links[i].original, cmap.links[i].combined, cmap.links[i].full_expanded, cmap.links[i].adjunct) for i in range(cmap.count)] finally: lib.free_correspondence_map(cmap) # Each participating node is a (tree tag, id) key. A link ties together the # original/combined nodes of one logical entity with its copy in one of the # two derived trees; union those keys and then read off the connected # components. Copies that share a combined node -- the same definition in # `full_expanded` and in `adjunct` -- are joined transitively through it. parent: Dict[tuple, tuple] = {} def add(key): parent.setdefault(key, key) return key def find(key): root = key while parent[root] != root: root = parent[root] while parent[key] != root: # path compression parent[key], key = root, parent[key] return root def union(a, b): parent[find(a)] = find(b) for original, combined, full_expanded, adjunct in links: # A link names a node in exactly one of the two derived trees. derived = add(("full_expanded", full_expanded)) \ if full_expanded != YAML_NULL_ID else add(("adjunct", adjunct)) if combined != YAML_NULL_ID: key_combined = add(("combined", combined)) union(derived, key_combined) if original != YAML_NULL_ID: union(key_combined, add(("original", original))) # Gather the members of each connected component. groups: Dict[tuple, list] = {} for key in parent: groups.setdefault(find(key), []).append(key) trees = {"original": lat.original.tree, "combined": lat.combined.tree, "full_expanded": lat.full_expanded.tree, "adjunct": lat.adjunct.tree} def node_of(key): return YAMLNode(trees[key[0]], key[1]) result: Dict[YAMLNode, NodeCorrespondence] = {} for members in groups.values(): entry = NodeCorrespondence( original=[node_of(k) for k in members if k[0] == "original"], combined=[node_of(k) for k in members if k[0] == "combined"], full_expanded=[node_of(k) for k in members if k[0] == "full_expanded"], adjunct=[node_of(k) for k in members if k[0] == "adjunct"]) for key in members: result[node_of(key)] = entry return result
[docs] def original_node(lat: Lattices, node: YAMLNode) -> Optional[YAMLNode]: """The node of ``lat.original`` that ``node`` came from: the text it was written as. ``node`` may be in ``lat.expanded``, ``lat.full_expanded``, ``lat.adjunct`` or ``lat.combined``. Every value in the expanded trees is a number. This leads back to the expression it was evaluated from, so a program can tell, for instance, whether it was delayed (``expr(...)``). A parameter written by a ``set`` leads to that set's ``value``, the text that gave it its value, not to the value it replaced. Returns ``None`` for a value the parser computed (a ``FloorP`` coordinate, say), and for one an ABSOLUTE controller drives: that value is the controllers' (several may sum), not any one text's. Unlike :func:`node_correspondence`, this answers for one node and also takes the ``expanded`` tree. Example: >>> lat = parse_and_expand_pals("lattice.pals.yaml") >>> kn1 = lat.expanded["lat1"]["branches"][0]["ln"]["line"][1]["q2"]["MagneticMultipoleP"]["Kn1"] >>> original_node(lat, kn1).value.startswith("expr(") # was it delayed? """ node_id = libparser().original_node(lat.combined.tree.handle, node.tree.handle, node.id) if node_id == YAML_NULL_ID: return None return YAMLNode(lat.original.tree, node_id)
[docs] def controller_expressions(lat: Lattices, node: YAMLNode) -> List[YAMLNode]: """The controllers driving a parameter. ``node`` is a parameter's value node in ``lat.expanded`` or ``lat.full_expanded``. For each ``controls`` entry reaching it, ABSOLUTE or RELATIVE, the result holds that entry's ``expression`` node in ``lat.original``, in the order the controllers were evaluated. Several controllers may drive one parameter (ABSOLUTE ones sum), which is why this is a list. It is empty when no controller drives ``node``. The controller itself is three :func:`get_parent` steps up from an expression: the ``controls`` entry, the ``controls`` list, then the controller. Example: >>> for e in controller_expressions(lat, kn1): ... ctrl = get_parent(get_parent(get_parent(e))) ... print(node_key(ctrl), ":", e.value) # e.g. "ps1 : 2 * cur" """ lib = libparser() nodes = lib.controller_expressions(lat.combined.tree.handle, node.tree.handle, node.id) try: ids = [nodes.nodes[i] for i in range(nodes.count)] finally: lib.free_node_list(nodes) return [YAMLNode(lat.original.tree, node_id) for node_id in ids]
# ─── name matching ───────────────────────────────────────────────────────────
[docs] def match_names(node: YAMLNode, match_string: str) -> List[YAMLNode]: """Every named construct in ``node``'s tree that is matched by ``match_string``, following PALS *Name Matching*. ``node`` may be any node of the tree to search (typically a lattice-view root such as ``lat.full_expanded``); the whole tree is searched and the returned nodes belong to that same tree. ``match_string`` has the form:: {element-set}[>{group}.{sub}. ... .{parameter}] where ``{element-set}`` is a ``,`` union of ``&`` intersections of ``:`` ranges of element names, and an element name is:: [{lattice}>>>][{branch}>>][{kind}::]{name}[#{N}] The operators bind, tightest first: ``>>>``, ``>>``, ``::``, ``#``, ``:``, ``&``, ``,``, ``>``. ``{lattice}``, ``{branch}`` and ``{name}`` are `PCRE2 <https://www.pcre.org>`_ patterns matched against the whole name; characters inside a ``(...)``, ``[...]`` or ``{...}`` group belong to the pattern. A ``{name}`` of digits only is the 1-based index of an element in its line. ``{kind}`` is an element kind, ``Controller``, or one of the MetaP components ``alias``, ``ID``, ``label`` and ``description``. ``#{N}`` keeps the N-th match, and a range ``{a}:{b}`` runs from each ``a`` to the next ``b`` in the same line. The parameter path after the single ``>`` is matched exactly, key by key, and applies to every element of the set. In an expanded lattice (``lat.expanded``, ``lat.full_expanded``) a name reaches the elements of the lattice. In a tree holding a ``PALS`` node (``lat.original``, ``lat.combined``, ``lat.adjunct``, or a file read with ``parse_file``) an unqualified name reaches the element definitions under ``facility`` or ``post_expansion``, and only a ``{branch}>>`` qualified name reaches the elements written in a beamline's ``line``. The node returned for each match is whatever the string resolves to: the element node (no parameter path), or the parameter-group or parameter node (with a path). Controllers are matched by ``Controller::{name}`` and a controller variable by ``{controller}>{variable}``. A bare name -- one element name with no qualifier and no parameter path -- additionally matches each constant and variable of that name defined under the ``PALS``, ``facility`` or ``post_expansion`` node, and each controller of that name. Constants, variables and controllers are found in ``lat.adjunct``, not in ``lat.full_expanded``. Results are de-duplicated and returned in match order. A malformed pattern yields an empty list. Example: >>> lat = parse_and_expand_pals("lattice.pals.yaml") >>> match_names(lat.full_expanded, "B1.*>BendP.e1") # e1 of every B1... bend >>> match_names(lat.full_expanded, "Quadrupole::.*") # every quadrupole >>> match_names(lat.full_expanded, "inj>>>arc>>Q.*>length") >>> match_names(lat.full_expanded, "Q1:Q5 & Quadrupole::.*") >>> match_names(lat.adjunct, "a_.*") # constants/variables """ lib = libparser() matches = lib.match_names(node.tree.handle, encode(match_string)) try: ids = [matches.nodes[i] for i in range(matches.count)] finally: lib.free_name_matches(matches) return [YAMLNode(node.tree, node_id) for node_id in ids]
# ─── parameter values ──────────────────────────────────────────────────────── def _param_value_result(value: ParamValueC) -> Union[float, str, None]: """Turn the raw ``param_value`` returned by the C API into a Python value: a ``float`` for a number, a ``str`` for a string (copied out, then the owning C string is freed), or ``None``.""" if value.kind == PARAM_VALUE_NUMBER: return value.number if value.kind != PARAM_VALUE_STRING: return None return take_string(value.string)
[docs] def parameter_value(lat: Lattices, match_string: str) -> Union[float, str, None]: """The value of the lattice parameter named by ``match_string``, looked up in the expanded lattice ``lat``. ``match_string`` uses the same PALS *Name Matching* syntax as :func:`match_names`. It names either an element parameter (with a ``>{group}.{sub}. ... .{parameter}`` path) or, as a *bare* name (no lattice/branch/kind qualifier and no path), a constant or variable -- the same constructs :func:`match_names` resolves. Only two of ``lat``'s five views are searched: ``lat.full_expanded``, which holds the element parameters, and then, if the name is not found there, ``lat.adjunct``, which holds the facility-level constants, variables, and any definitions not spliced into the lattice. The raw ``lat.original`` and ``lat.combined`` views are **not** searched -- they carry unevaluated, pre-expansion text. ``lat.expanded`` is not searched either: a dependent parameter is a legitimate thing to ask for, and only ``full_expanded`` carries one. Because both searched views are post-expansion, values come back already evaluated: a numeric value as a ``float``, and a non-numeric one (e.g. a species name like ``"#3He"``, or an expression expansion left unevaluated such as one using ``random()``) verbatim as a ``str``. The value is resolved as follows: - **Element parameter, set:** its value -- a ``float``, or a ``str`` when non-numeric. - **Element parameter, not set:** the parameter's default is returned (``0.0`` for every parameter, for now -- real per-parameter defaults come later). - **Constant or variable (bare name):** its value, the same way. - **Nothing identified:** ``None``, when the name matches nothing in either view, names a bare element (an element has no single scalar value), stops on a whole parameter group, or several matches carry conflicting values. Example: >>> lat = parse_and_expand_pals("lattice.pals.yaml") >>> parameter_value(lat, "quad1>MagneticMultipoleP.Bn1") # 1.0 >>> parameter_value(lat, "quad1>BendP.g") # 0.0 (unset) >>> parameter_value(lat, "a_const") # from adjunct >>> parameter_value(lat, "quad1>nope.nope") # None """ value = libparser().get_lattice_parameter_value( lat.full_expanded.tree.handle, lat.adjunct.tree.handle, encode(match_string)) return _param_value_result(value)