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, 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"]["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
# ─── 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:: [{lattice}>>>][{branch}>>][{kind}::]{name}[>{group}.{sub}. ... .{parameter}] ``{lattice}``, ``{branch}`` and ``{name}`` are `PCRE2 <https://www.pcre.org>`_ patterns matched against the whole name (anchored at both ends); ``{kind}`` is matched exactly; the parameter path after the single ``>`` is matched exactly, key by key. An omitted or empty pattern matches any name at that level. ``{branch}`` matches an element if any enclosing BeamLine/Branch name matches, so elements in sub-lines are included. The node returned for each match is whatever the string resolves to: the element node (no parameter path), the parameter-group or parameter node (with a path), or -- for a bare name (no lattice/branch/kind qualifier and no parameter path) -- additionally each matching constant and variable defined directly under the ``PALS`` or ``facility`` node (both the full ``kind: constant``/``kind: variable`` and the compact ``constants:``/``variables:`` forms). Lattice parameters therefore include constant and variable names. Which tree to search follows from that: elements are in ``lat.full_expanded``, while constants and variables are defined at facility level and so are found in ``lat.adjunct``. Searching ``lat.full_expanded`` for a constant matches nothing, since the ``PALS``/``facility`` node it would be defined under is not part of that tree. Not yet implemented from *Element Name Matching*: ``#N`` instance selection, ``{e1}:{e2}`` ranges, ``,`` unions, and ``&`` intersections. Results are de-duplicated and returned in document 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.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)