diff --git a/.github/workflows/docs-cleanup.yml b/.github/workflows/docs-cleanup.yml new file mode 100644 index 0000000..c2dd97e --- /dev/null +++ b/.github/workflows/docs-cleanup.yml @@ -0,0 +1,39 @@ +name: Documentation preview cleanup + +# Remove a PR's documentation preview (previews/PR/ on the gh-pages branch) +# once the PR is closed or merged. + +on: + pull_request: + types: [closed] + +permissions: + contents: write + +concurrency: + group: docs-cleanup + cancel-in-progress: false + +jobs: + cleanup: + runs-on: ubuntu-latest + steps: + - name: Checkout gh-pages + uses: actions/checkout@v5 + with: + ref: gh-pages + continue-on-error: true # nothing to do if gh-pages doesn't exist yet + + - name: Remove preview directory + run: | + set -e + dir="previews/PR${{ github.event.number }}" + if [ -d "$dir" ]; then + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git rm -rf "$dir" + git commit -m "docs: remove preview for PR #${{ github.event.number }}" + git push + else + echo "No preview directory '$dir' to remove." + fi diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..fe7d381 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,74 @@ +name: Documentation + +on: + push: + branches: [main] + tags: ['*'] + pull_request: + workflow_dispatch: + +concurrency: + # One docs build per branch/PR; cancel superseded runs. + group: docs-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: write # push the built site to the gh-pages branch + pull-requests: write # post the preview-link comment on PRs + +jobs: + docs: + name: Build & deploy documentation + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + + # docs/build.py pip-installs the Sphinx toolchain, builds the narrative + # docs and the autodoc API reference into docs/build/html, and copies the + # result into ./gh-pages. autodoc imports palsparserpy but never calls into + # the C library, so no PALSParserCpp build is needed here. + - name: Build documentation + run: python docs/build.py + + # ---- Deploy ---- + - name: Deploy main site + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./gh-pages + keep_files: true # preserve previews/ on main deploys + + - name: Deploy PR preview + if: github.event_name == 'pull_request' + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./gh-pages + destination_dir: previews/PR${{ github.event.number }} + keep_files: true + + - name: Comment preview link + if: github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const n = context.issue.number; + const url = `https://${owner.toLowerCase()}.github.io/${repo}/previews/PR${n}/`; + const marker = ''; + const body = `${marker}\nπŸ“– **Documentation preview** for this PR: ${url}\n\n` + + `_Rebuilt on every push; removed automatically when the PR closes._`; + const { data: comments } = await github.rest.issues.listComments( + { owner, repo, issue_number: n }); + const existing = comments.find(c => c.body && c.body.includes(marker)); + if (existing) { + await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }); + } else { + await github.rest.issues.createComment({ owner, repo, issue_number: n, body }); + } diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..d3e2dac --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,75 @@ +name: Python Tests + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + test: + name: Python ${{ matrix.python-version }} - ${{ matrix.os }} + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + python-version: ['3.9', '3.12'] + os: [macos-latest, ubuntu-latest] + + steps: + - name: Checkout PALSParserPy repository + uses: actions/checkout@v5 + with: + path: PALSParserPy + + - name: Checkout PALSParserCpp repository + uses: actions/checkout@v5 + with: + repository: pals-project/PALSParserCpp + path: PALSParserCpp + + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + + # PALSParserCpp fetches rapidyaml (and the rest) itself via CMake FetchContent, + # so a compiler and CMake are all that is needed. Both runners ship CMake + # preinstalled; only Linux needs a compiler pulled in explicitly. + - name: Install system dependencies (Ubuntu) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y cmake build-essential + + - name: Build C++ library + run: | + cd PALSParserCpp + mkdir -p build + cd build + cmake .. + make -j$(nproc 2>/dev/null || echo 2) + ls -lah + shell: bash + + # PALSParserPy finds ../PALSParserCpp/build/ by itself, which is the layout + # the two checkouts above make. pytest puts the checkout on sys.path, so the + # package needs no install; pytest itself does. + - name: Run tests + run: | + cd $GITHUB_WORKSPACE/PALSParserPy + python -m pip install --upgrade pip + python -m pip install pytest + python -m pytest -v + shell: bash + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results-${{ matrix.os }}-python-${{ matrix.python-version }} + path: | + PALSParserPy/*.log + PALSParserPy/lattice_files/*_out.* + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index 83972fa..33ed9c3 100644 --- a/.gitignore +++ b/.gitignore @@ -216,3 +216,10 @@ __marimo__/ # Streamlit .streamlit/secrets.toml + +# Built documentation site +gh-pages/ + +# Translator output written by the examples +lattice_files/*.pals_out.* +lattice_files/expand.pals.yaml diff --git a/README.md b/README.md index 85c6a6e..0a8c5b5 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,91 @@ # PALSParserPy + Python Interface for Particle Accelerator Language Standard (PALS) files. + +## Introduction + +`PALSParserPy` is a parser for the Particle Accelerator Language Standard +([PALS](https://github.com/campa-consortium/pals)) for the Python language. + +In addition, `PALSParserPy` provides translation functions: + +- From `PALS` files to [`Bmad`](https://github.com/bmad-sim/bmad-ecosystem) lattice files. +- From `PALS` files to [`SciBmad`](https://github.com/bmad-sim/SciBmad.jl) lattice files. +- From `PALS` files to [`MAD-X`](https://mad.web.cern.ch/mad/) lattice files. + +For a translator from `Bmad` to `PALS`, the `Bmad` based `Tao` program can be used. +A translator from `SciBmad` to `PALS` is planned. + +## Status + +- 2026-08-05: Initial port of + [PALSParserJ](https://github.com/pals-project/PALSParserJ.jl), the Julia + interface to the same C library. + +## Installation + +PALSParserPy is a thin Python wrapper around the C library built by +[PALSParserCpp](https://github.com/pals-project/PALSParserCpp), so both +repositories must be cloned side by side and the C library must be built first: + +```console +git clone https://github.com/pals-project/PALSParserCpp.git +git clone https://github.com/pals-project/PALSParserPy.git + +cd PALSParserCpp && cmake -S . -B build && cmake --build build && cd .. + +cd PALSParserPy && pip install -e . +``` + +`pip install -e .` installs nothing but the package itself β€” PALSParserPy has no +Python dependencies. If PALSParserCpp lives somewhere other than beside this +checkout, point at it with `PALS_PARSER_CPP_DIR` or `PALS_PARSER_CPP_LIB`. + +**See the [Installation guide](https://pals-project.github.io/PALSParserPy/guide/installation.html) +for full step-by-step instructions.** + +## Quick start + +```python +import palsparserpy as pp + +lat = pp.parse_and_expand_pals("lattice_files/ex.pals.yaml") +print(lat.full_expanded) # the expanded lattice, as YAML + +pp.parameter_value(lat, "Q1a>length") # one parameter's value +pp.match_names(lat.full_expanded, "B1.*>BendP.e1") # the nodes a name selects + +pp.write_bmad_file(pp.pals_to_bmad(pp.parse_file("lattice_files/bta.pals.yaml")), + "bta.bmad") +``` + +## Examples + +For usage examples, see the runnable scripts in the `examples` directory, e.g. + +```console +python examples/read_pals.py +``` + +They insert the repository root on `sys.path`, so they run from a checkout +whether or not the package has been installed. + +### Jupyter notebooks + +Some examples are also provided as Jupyter notebooks (e.g. +`examples/manipulate_tree.ipynb`). To run them you need Jupyter: + +```console +pip install jupyter +jupyter notebook examples/manipulate_tree.ipynb +``` + +## Tests + +```console +pip install -e ".[test]" +pytest +``` + +The tests import the package from the checkout, so `pip install -e .` is +optional; the C library, however, must be built. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..5e98289 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,62 @@ +# Documentation + +The documentation site is one **Sphinx + MyST + Furo** build: + +- the narrative docs live in `docs/src/` (MyST Markdown, `conf.py`); +- the API reference is generated by `sphinx.ext.autodoc` from the package + docstrings (`docs/src/api.md`). + +(The Julia interface to the same library, PALSParserJ, needs two engines for this +β€” Documenter for the Julia docstrings and Sphinx for the prose β€” and stitches +them into one site. Python's autodoc reads the docstrings from inside Sphinx, so +there is nothing to combine.) + +`docs/build.py` builds the site into `gh-pages/`. + +> **Note:** autodoc imports the `palsparserpy` package to read its docstrings, +> but importing it does not call into the C library, so a compiled +> `libPALSParserCpp` is **not** required to build the docs. + +`.github/workflows/docs.yml` runs `docs/build.py` and publishes `gh-pages/` to +the `gh-pages` branch. Pull requests get a full preview at +`previews/PR/` with a link posted as a PR comment; the preview is +deleted on PR close by `.github/workflows/docs-cleanup.yml`. + +## One-time repository setup + +1. In **Settings β†’ Pages**, set the source to **Deploy from a branch**, branch + **`gh-pages`**, folder **`/ (root)`**. +2. Ensure **Settings β†’ Actions β†’ General β†’ Workflow permissions** is set to + **Read and write permissions** so the workflow can push to `gh-pages` and + comment on PRs. + +The published site is at . + +> **Note on fork PRs:** previews deploy by pushing to `gh-pages`; PRs opened from +> a *fork* have a read-only token and cannot deploy a preview. PRs from branches +> within this repository work normally. + +## Viewing the documentation locally + +The easiest way is the helper script [`docs/build_local.sh`](build_local.sh), +which builds the site and serves it: + +```sh +docs/build_local.sh +``` + +Then open . Press `Ctrl-C` to stop. Options: +`--port 9000`, `--no-serve`. Requirement: `python3` (the Sphinx toolchain is +pip-installed automatically from `requirements.txt`). + +## Building manually + +```sh +python docs/build.py # -> gh-pages/ +``` + +Or run Sphinx directly, from `docs/`, after pip-installing `requirements.txt`: + +```sh +cd docs && sphinx-build -b html src build/html # -> docs/build/html/ +``` diff --git a/docs/build.py b/docs/build.py new file mode 100755 index 0000000..e21cac8 --- /dev/null +++ b/docs/build.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Build the PALSParserPy documentation. + +Produces a single site in ``gh-pages/`` (at the repository root): the narrative +docs and the autodoc API reference are one Sphinx build, so unlike the Julia +interface -- whose API reference is a separate Documenter site -- there is +nothing to stitch together afterwards. + +Run from anywhere: python docs/build.py +""" + +import shutil +import subprocess +import sys +from pathlib import Path + +docs_dir = Path(__file__).parent.resolve() +project_root = docs_dir.parent + + +def run(cmd, cwd): + print(f"\n$ {' '.join(str(c) for c in cmd)} (in {cwd})") + result = subprocess.run(cmd, cwd=cwd) + if result.returncode != 0: + sys.exit(result.returncode) + + +# 1. Install the Sphinx toolchain. +print("==> Installing Sphinx dependencies…") +run([sys.executable, "-m", "pip", "install", "-r", "requirements.txt"], cwd=docs_dir) + +# 2. Build the site. autodoc imports palsparserpy to read its docstrings, but +# importing it does not load the C library -- that happens lazily on the first +# call -- so no C++ toolchain is needed here. +print("\n==> Building documentation (Sphinx + Furo)…") +run(["sphinx-build", "-b", "html", "src", "build/html"], cwd=docs_dir) + +# 3. Publish into gh-pages/. +print("\n==> Copying into gh-pages/…") +gh_pages = project_root / "gh-pages" +if gh_pages.exists(): + shutil.rmtree(gh_pages) +gh_pages.mkdir() +shutil.copytree(docs_dir / "build" / "html", gh_pages, dirs_exist_ok=True) +(gh_pages / ".nojekyll").touch() + +print(f"\nDone! Site in {gh_pages}") +print(f"Open {gh_pages / 'index.html'}.") diff --git a/docs/build_local.sh b/docs/build_local.sh new file mode 100755 index 0000000..9eca68f --- /dev/null +++ b/docs/build_local.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# --------------------------------------------------------------------------- +# docs/build_local.sh +# +# Build the documentation site locally and serve it for viewing. +# Runs docs/build.py (Sphinx/Furo narrative + autodoc API -> ./gh-pages), then +# starts a local web server. +# +# Usage: +# docs/build_local.sh # build, then serve at http://localhost:8000/ +# docs/build_local.sh --port 9000 # serve on a different port +# docs/build_local.sh --no-serve # just build gh-pages/, don't start a server +# +# Requirements: python3 (the Sphinx toolchain is pip-installed by docs/build.py +# from docs/requirements.txt). +# --------------------------------------------------------------------------- +set -euo pipefail + +PORT=8000 +SERVE=1 + +while [ $# -gt 0 ]; do + case "$1" in + --no-serve) SERVE=0 ;; + --port) PORT="$2"; shift ;; + --port=*) PORT="${1#*=}" ;; + -h|--help) sed -n '2,16p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "Unknown option: $1" >&2; exit 2 ;; + esac + shift +done + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$ROOT" + +command -v python3 >/dev/null || { echo "ERROR: 'python3' not found in PATH." >&2; exit 1; } + +echo "==> Building documentation (docs/build.py)…" +python3 docs/build.py + +echo "==> Done. Site is in: $ROOT/gh-pages" +if [ "$SERVE" -eq 0 ]; then + echo " Open gh-pages/index.html, or serve with: python3 -m http.server --directory gh-pages" + exit 0 +fi + +echo +echo " Documentation : http://localhost:$PORT/" +echo " (Press Ctrl-C to stop the server.)" +echo + +exec python3 -m http.server "$PORT" --directory gh-pages diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 0000000..f1c2234 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,4 @@ +sphinx>=7.0 +myst-parser>=2.0 # MyST Markdown for Sphinx +furo # documentation theme +linkify-it-py # MyST "linkify" extension (auto-link bare URLs) diff --git a/docs/src/_static/custom.css b/docs/src/_static/custom.css new file mode 100644 index 0000000..027b1d0 --- /dev/null +++ b/docs/src/_static/custom.css @@ -0,0 +1,9 @@ +/* PALSParserPy β€” minor Furo tweaks. */ + +/* Give the external-links block a little separation from the page navigation + above it. */ +.sidebar-tree + .sidebar-tree { + margin-top: 0.25rem; + border-top: 1px solid var(--color-background-border); + padding-top: 0.5rem; +} diff --git a/docs/src/_templates/sidebar-external-links.html b/docs/src/_templates/sidebar-external-links.html new file mode 100644 index 0000000..a8c37c0 --- /dev/null +++ b/docs/src/_templates/sidebar-external-links.html @@ -0,0 +1,8 @@ + diff --git a/docs/src/api.md b/docs/src/api.md new file mode 100644 index 0000000..65e2a28 --- /dev/null +++ b/docs/src/api.md @@ -0,0 +1,103 @@ +# API Reference + +Everything below is exported from the top-level `palsparserpy` package, so +`pp.parse_and_expand_pals(...)` reaches it after `import palsparserpy as pp`. + +## The tree objects + +```{eval-rst} +.. autoclass:: palsparserpy.YAMLNode + :members: + :special-members: __getitem__, __setitem__, __delitem__, __contains__, __len__, __iter__ + +.. autoclass:: palsparserpy.YAMLTree + +.. autoexception:: palsparserpy.PALSParseError +``` + +## Parsing and building + +```{eval-rst} +.. autofunction:: palsparserpy.parse_file +.. autofunction:: palsparserpy.parse_string +.. autofunction:: palsparserpy.create_empty_tree +``` + +## Function forms of the node operations + +Each of these is the free-function spelling of the like-named +{class}`~palsparserpy.YAMLNode` method above. + +```{eval-rst} +.. autofunction:: palsparserpy.is_map +.. autofunction:: palsparserpy.is_sequence +.. autofunction:: palsparserpy.is_scalar +.. autofunction:: palsparserpy.get_parent +.. autofunction:: palsparserpy.node_key +.. autofunction:: palsparserpy.add_scalar +.. autofunction:: palsparserpy.add_map +.. autofunction:: palsparserpy.add_sequence +.. autofunction:: palsparserpy.set_scalar +.. autofunction:: palsparserpy.set_key +.. autofunction:: palsparserpy.remove +.. autofunction:: palsparserpy.deep_copy_node +.. autofunction:: palsparserpy.deep_copy_children +.. autofunction:: palsparserpy.to_yaml_string +.. autofunction:: palsparserpy.write_yaml +``` + +## Lattices + +```{eval-rst} +.. autofunction:: palsparserpy.parse_and_expand_pals +.. autofunction:: palsparserpy.evaluate_pals_expression +.. autofunction:: palsparserpy.node_correspondence +.. autofunction:: palsparserpy.match_names +.. autofunction:: palsparserpy.parameter_value +``` + +## What expansion hands back + +```{eval-rst} +.. autoclass:: palsparserpy.Lattices + :members: + +.. autoclass:: palsparserpy.Problem + :members: + +.. autoclass:: palsparserpy.ProblemSeverity + :members: + +.. autoclass:: palsparserpy.ProblemOrigin + :members: + +.. autoclass:: palsparserpy.NodeCorrespondence + :members: +``` + +## Translation + +```{eval-rst} +.. autofunction:: palsparserpy.pals_to_bmad +.. autofunction:: palsparserpy.write_bmad_file +.. autoclass:: palsparserpy.BmadLattice +.. autoclass:: palsparserpy.BmadEleDef +.. autoclass:: palsparserpy.BmadBeamline +.. autoclass:: palsparserpy.BmadController + +.. autofunction:: palsparserpy.pals_to_madx +.. autofunction:: palsparserpy.write_madx_file +.. autoclass:: palsparserpy.MadxLattice +.. autoclass:: palsparserpy.MadxEleDef +.. autoclass:: palsparserpy.MadxBeamline +.. autoclass:: palsparserpy.MadxController +.. autoclass:: palsparserpy.MadxAlignment + +.. autofunction:: palsparserpy.pals_to_scibmad +.. autofunction:: palsparserpy.write_scibmad_file +.. autoclass:: palsparserpy.SciBmadLattice +.. autoclass:: palsparserpy.SciBmadEle +.. autoclass:: palsparserpy.SciBmadBeamline +.. autoclass:: palsparserpy.SciBmadLatticeList +.. autoclass:: palsparserpy.SciBmadController +``` diff --git a/docs/src/conf.py b/docs/src/conf.py new file mode 100644 index 0000000..5f8f923 --- /dev/null +++ b/docs/src/conf.py @@ -0,0 +1,79 @@ +# Configuration file for the Sphinx documentation builder. +# +# The whole site is one Sphinx build: the narrative documentation is written in +# MyST Markdown under docs/src/guide/, and the API reference is generated by +# autodoc from the package docstrings (docs/src/api.md). + +import os +import sys + +# So autodoc can import the package from the checkout without installing it. +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), + "..", ".."))) + +# -- Project information ----------------------------------------------------- +project = "PALSParserPy" +copyright = "2026, PALSParserPy Contributors" +author = "PALSParserPy Contributors" + +# -- General configuration --------------------------------------------------- +extensions = [ + "myst_parser", # MyST Markdown + "sphinx.ext.autodoc", # API reference from the docstrings + "sphinx.ext.napoleon", # Google-style Args:/Returns: sections + "sphinx.ext.viewcode", # "[source]" links + "sphinx.ext.githubpages", # emit .nojekyll for GitHub Pages + "sphinx.ext.intersphinx", + "sphinx.ext.mathjax", +] + +numfig = True + +intersphinx_mapping = {"python": ("https://docs.python.org/3", None)} + +# -- autodoc ----------------------------------------------------------------- +# Importing palsparserpy does not load the C library -- that happens lazily on +# the first call -- so the docs build needs no C++ toolchain. +autodoc_member_order = "bysource" +autodoc_typehints = "description" +autodoc_default_options = { + "members": True, + "undoc-members": False, + "show-inheritance": True, +} + +# -- MyST configuration ------------------------------------------------------ +myst_enable_extensions = [ + "dollarmath", + "amsmath", + "deflist", + "colon_fence", + "linkify", +] +myst_heading_anchors = 3 + +# -- HTML output (Furo) ------------------------------------------------------ +html_theme = "furo" +html_theme_options = { + "source_repository": "https://github.com/pals-project/PALSParserPy", + "source_branch": "main", + "source_directory": "docs/src/", + "navigation_with_keys": True, + "sidebar_hide_name": False, +} +html_title = "PALSParserPy" +templates_path = ["_templates"] +html_static_path = ["_static"] +html_css_files = ["custom.css"] + +# Add the "GitHub β†—" link to the Furo sidebar on every page. +html_sidebars = { + "**": [ + "sidebar/brand.html", + "sidebar/search.html", + "sidebar/scroll-start.html", + "sidebar/navigation.html", + "sidebar-external-links.html", + "sidebar/scroll-end.html", + ] +} diff --git a/docs/src/guide/expressions.md b/docs/src/guide/expressions.md new file mode 100644 index 0000000..cbc5f0e --- /dev/null +++ b/docs/src/guide/expressions.md @@ -0,0 +1,213 @@ +# Evaluating expressions + +A PALS lattice may write numeric values as mathematical *expressions* β€” +`0.3 * r_electron`, `a_const^2`, `mass_of("proton")`. When +`parse_and_expand_pals` expands a lattice it evaluates every such expression to +a plain number β€” across the expanded views (**`expanded`** and +**`full_expanded`**, which hold the same values) and **`adjunct`** β€” so the +expanded lattice is fully numeric and ready for a simulation program to consume. +The `original` and `combined` views always keep the expression text exactly as +written. + +```python +import palsparserpy as pp + +lat = pp.parse_and_expand_pals("ex.pals.yaml") + +# `combined` keeps the source text; the evaluated number is downstream of it. +pp.to_yaml_string(lat.combined) # length: 0.3 * r_electron +pp.to_yaml_string(lat.expanded) # length: 8.4538209614999992e-16 +``` + +Only scalars that are genuine expressions are touched. Names that happen to sit +where a value could go β€” element and line references in a `line:`, `kind:` +names, booleans β€” are not expressions and are left untouched. + +## What gets evaluated, and when + +Two kinds of expression are evaluated to a number in the expanded tree: + +- **Immediate** expressions β€” a bare value such as `length: 0.3 * r_electron`. +- **Delayed** expressions wrapped in `expr(...)`, such as + `Kn1: expr(3.74 * a_var)`. In the fully expanded tree the distinction no + longer matters: both become numbers. + +One case is deliberately **not** evaluated. An expression that calls `random()` +or `random_gauss()` is left as text, so that expanding the same lattice twice +gives byte-identical output: + +```yaml +Kn2: 0.01 + 0.003*random_gauss() # kept verbatim in `expanded` +``` + +## Grammar + +The expression grammar is the arithmetic you would expect: + +- operators `+` `-` `*` `/` `^`, with the usual precedence; +- `^` (power) is **right-associative**, so `2^3^2` is `2^(3^2) = 512`; +- a unary sign binds *looser* than `^`, so `-2^2` is `-(2^2) = -4`, while a + signed exponent still works: `2^-2` is `2^(-2) = 0.25` β€” the Fortran/Bmad + convention used across the ecosystem; +- parentheses group sub-expressions. + +Note that this is PALS' grammar, not Python's: `^` is exponentiation here, where +Python spells it `**`. + +## Built-in constants + +The named physical constants below are available in every expression. Their +values come from +[AtomicAndPhysicalConstantsCLib](https://github.com/pals-project/AtomicAndPhysicalConstantsCLib) +(a C++ mirror of +[AtomicAndPhysicalConstants.jl](https://github.com/bmad-sim/AtomicAndPhysicalConstants.jl), +CODATA 2022), so PALS shares one set of numbers with the rest of the toolchain. + +| Constant | Meaning | +| --- | --- | +| `pi` | Ο€ | +| `c_light` | speed of light | +| `h_planck` | Planck constant | +| `hbar` | reduced Planck constant | +| `k_boltzmann` | Boltzmann constant | +| `r_electron`, `r_proton` | classical electron / proton radius | +| `e_charge` | elementary charge | +| `mu_0`, `epsilon_0` | vacuum permeability / permittivity | +| `classical_radius_factor` | 1 / (4Ο€ Ξ΅β‚€ cΒ²) | +| `fine_structure` | fine-structure constant | +| `n_avogadro` | Avogadro constant | + +## Functions + +Standard math functions are available: `sqrt`, `exp`, `log`, `abs`, `sign`, +`factorial`; the trigonometric and hyperbolic families and their inverses +(`sin`, `cos`, `tan`, `cot`, `sinc`, `asin`, `acos`, `atan`, `atan2`, `sinh`, +`cosh`, `tanh`, `coth`, `asinh`, `acosh`, `atanh`, `acoth`); and the rounding +helpers `int` (toward zero), `nint` (nearest), `floor`, `ceiling`, and +`modulo(x, p)`. + +### Particle-data functions + +`mass_of`, `charge_of`, and `anomalous_moment_of` look a particle up by name and +return its mass (eV), charge (units of `e`), or anomalous magnetic moment. **The +species name must be quoted** (single or double quotes); an unquoted name is an +error. A mass number must carry a leading `#` β€” write `"#3He"`, not `"3He"`. +Quoting also lets that `#` be written without tripping YAML's comment rule: + +```yaml +m_e: mass_of("electron") +q_he: charge_of("helion") +b_const: 0.45 * mass_of("#3He") +``` + +The argument may also be a **user constant or variable whose value is a species +name**, passed by name (without quotes), so one definition can fix the species +and every expression refer to it: + +```yaml +- constants: + species: "#3He" # a species-valued constant + b_const: 0.45 * mass_of(species) # resolves species -> "#3He" +``` + +Such a species-valued constant may also be referenced **directly** wherever a +species name is expected: a bare identifier used as a parameter value is +replaced by the constant's species string in the expanded tree. + +```yaml +- begin: + kind: BeginningEle + ReferenceP: + species_ref: species # -> "#3He" in `expanded` +``` + +## User constants and variables + +A lattice can define its own constants and variables and refer to them by name +from later expressions. Both the full form and the compact form are recognised: + +```yaml +facility: + # Full form. + - r_scaled: + kind: constant + value: 0.3 * r_electron + # Compact form (a seq of single-key maps, or a plain map β€” both accepted). + - constants: + a_const: 0.3 * r_electron + b_const: 0.45 + - variables: + a_var: a_const^2 # may reference an earlier definition +``` + +Definitions are resolved in dependency order, so a later value may reference an +earlier one (`a_var` uses `a_const` above). A reference that cannot be resolved, +or a genuine cycle, leaves the value as text rather than raising. + +## Element-parameter references + +An expression may also reference another element's parameter by name, using the +`element>group.sub. … .param` syntax (the same parameter path used elsewhere in +the standard). It resolves to that parameter's value, itself evaluated as an +expression: + +```yaml +- thingB: + kind: Sextupole + MagneticMultipoleP: + Kn2L: 0.1 +- DH1A: + kind: Bend + BendP: + edge_int2: 0.02 * thingB>MagneticMultipoleP.Kn2L # β†’ 0.002 +``` + +The reference names one specific element (an exact name β€” pattern matching is +not used in a value expression) and its full parameter path. As with any other +reference, one that cannot be resolved leaves the value as text. + +## Controllers + +A `Controller` element bundles expressions that drive lattice parameters. Its +`variables:` form a symbol table *scoped to that controller*, and each +`controls:` entry pairs a `parameter` target with an `expression`. During +expansion the controller variables are evaluated against that scoped table, and +each control `expression` is computed and written back into its control entry. +Controller variables may reference one another and, via the +`controller>variable` syntax, variables of another controller: + +```yaml +- ps27: + kind: Controller + control_type: ABSOLUTE + variables: + cur1: 0.023 + cur2: cur1 / c_light # references an earlier controller variable + controls: + - parameter: Qa.*>MagneticMultipoleP.Ks2L + expression: 0.075*sin(cur1) + 0.3*cur2 # β†’ a number in `expanded` +``` + +The `parameter` target specification and `control_type` are names, not +expressions, and are left untouched. + +## Evaluating a single expression + +`evaluate_pals_expression` evaluates one expression string on its own and +returns a `float`. It is handy for quick checks and for reusing the same grammar +outside a lattice: + +```python +pp.evaluate_pals_expression("3.75e7 / c_light^2") # 4.172…e-10 +pp.evaluate_pals_expression('mass_of("electron")') # 510998.95069… +pp.evaluate_pals_expression("expr(2 * pi)") # 6.283… +``` + +This evaluates a *standalone* string, so user-defined constants and variables +are **not** in scope β€” use `parse_and_expand_pals` for whole-lattice +evaluation, whose expanded trees already have every expression resolved. It +raises `ValueError` when the string is not evaluable: a parse error, an unknown +identifier or species, an unquoted species name, a `random()`/`random_gauss()` +expression (intentionally deferred), or a non-finite result. + +A runnable version of these examples is in `examples/evaluate_expressions.py`. diff --git a/docs/src/guide/installation.md b/docs/src/guide/installation.md new file mode 100644 index 0000000..718b922 --- /dev/null +++ b/docs/src/guide/installation.md @@ -0,0 +1,108 @@ +# Installation + +PALSParserPy is a Python wrapper around the C library built by +[PALSParserCpp](https://github.com/pals-project/PALSParserCpp). That library is +compiled from that repository rather than shipped with this package, so +PALSParserPy has to be told where it is. By default it looks for a PALSParserCpp +checkout beside its own, which is the layout below; if you keep PALSParserCpp +somewhere else, see [Pointing at a PALSParserCpp +elsewhere](#pointing-at-a-palsparsercpp-elsewhere) instead. + +macOS, Linux, and Windows are all supported β€” the correct library extension for +the platform (`.dylib`, `.so`, `.dll`) is worked out at load time. Python 3.8 or +newer is required, and the interpreter must be built for the same architecture as +the library (an x86-64 Python cannot load an arm64 `.dylib`). + +## 1. Clone the repositories + +```console +git clone https://github.com/pals-project/PALSParserCpp.git +git clone https://github.com/pals-project/PALSParserPy.git +``` + +The default layout looks like this β€” PALSParserPy locates the compiled library +relative to its own source tree, at `../PALSParserCpp/build/`: + +```text +some-directory/ +β”œβ”€β”€ PALSParserCpp/ +β”‚ └── build/ +β”‚ └── libPALSParserCpp.dylib (or .so / .dll) +└── PALSParserPy/ +``` + +A PALSParserPy checkout that sits *inside* a PALSParserCpp checkout works too: +that repository's own `build/` directory is searched as well. + +## 2. Build the C library + +From the `PALSParserCpp` directory, configure and build with CMake (this needs +CMake and a C++17 compiler β€” Apple Clang on macOS, GCC or Clang on Linux, MSVC +on Windows): + +```console +cmake -S . -B build +cmake --build build +``` + +CMake fetches the [rapidyaml](https://github.com/biojppm/rapidyaml) backend +automatically. The result is the shared library `libPALSParserCpp.dylib` +(macOS), `.so` (Linux), or `.dll` (Windows) under `PALSParserCpp/build/`. +Rebuild with `cmake --build build` after changing any PALSParserCpp source. See +the PALSParserCpp `README` for more detail. + +## 3. Install the Python package + +From the `PALSParserPy` directory: + +```console +pip install -e . +``` + +PALSParserPy has no Python dependencies β€” the C library is all it binds β€” so this +installs nothing but the package itself. The `-e` (editable) install means edits +to the checkout take effect without reinstalling. + +Installing is optional if you only want to run the bundled scripts: the examples +and the test suite put the repository root on `sys.path` themselves. + +## Check the installation + +```python +import palsparserpy as pp + +root = pp.create_empty_tree() +root["hello"] = "world" +print(pp.to_yaml_string(root)) +``` + +If that prints `hello: world`, the Python package and the underlying C library +are wired up correctly. + +## Pointing at a PALSParserCpp elsewhere + +The side-by-side layout is only the default. Two environment variables override +it, read the first time PALSParserPy calls into the library β€” so setting either +one any time before that first call works, including after `import palsparserpy`: + +| Variable | Meaning | +|---|---| +| `PALS_PARSER_CPP_DIR` | Path to a PALSParserCpp checkout; its `build/` directory is searched. | +| `PALS_PARSER_CPP_LIB` | Full path to the shared library itself, wherever it lives. | + +```python +import os +os.environ["PALS_PARSER_CPP_DIR"] = "/opt/src/PALSParserCpp" + +import palsparserpy as pp +``` + +`PALS_PARSER_CPP_LIB` wins if both are set. +`palsparserpy._clib.libparser()._name` returns the resolved path, which is worth +checking first if calls behave unexpectedly. + +If the library cannot be found, the first call fails with a `FileNotFoundError` +listing every path that was tried, which is usually enough to spot a missing +build or a typo in the variable. Note that `import palsparserpy` itself always +succeeds: the library is looked up lazily so that tooling which only reads the +package β€” building these docs, for one β€” does not need a C++ toolchain. diff --git a/docs/src/guide/lattices.md b/docs/src/guide/lattices.md new file mode 100644 index 0000000..f52b7d5 --- /dev/null +++ b/docs/src/guide/lattices.md @@ -0,0 +1,391 @@ +# Reading and expanding lattices + +The `parse_and_expand_pals` entry point reads a PALS lattice +file, resolves any files it includes, and expands the lattice line into an +ordered list of elements. It returns a `Lattices` value with five independent +views of the document: + +- **`original`** β€” the lattice exactly as written, one entry per file read. +- **`combined`** β€” the lattice after its `include`d and `load`ed files have been + merged in. +- **`full_expanded`** β€” the fully expanded root lattice, with lines resolved into + a flat ordered sequence of elements and every dependent parameter computed. +- **`expanded`** β€” the same lattice with the computed parameters pruned, leaving + what the author wrote. +- **`adjunct`** β€” everything else the document contained. + +Each view is an ordinary `YAMLNode`, so everything in +[Parsing and writing YAML](parsing.md) applies to it. + +## The expanded views and `adjunct` + +Expansion picks one lattice β€” the root lattice β€” and resolves it. The two +expanded views hold *only* that result, and are rooted at the lattice entry +itself, without the `PALS:`/`facility:` scaffolding the lattice was written +under: + +```yaml +lat1: + kind: Lattice + branches: + - main_line: + ... +``` + +so the lattice is reached as `lat.full_expanded["lat1"]`, not through +`["PALS"]["facility"]`. + +Everything the root lattice did not absorb stays in `adjunct`, which *does* +keep the full `PALS:`/`facility:` document: element and beamline definitions, +`use` statements, constants and variables, `Controller`s, `set` commands, and any +`Lattice` other than the one expanded. Definitions that expansion substituted +into the lattice are copied rather than moved, so they appear in both views β€” the +definition in `adjunct`, its inlined copy in the expanded lattice. + +## `full_expanded` and `expanded` + +`full_expanded` is the lattice with everything it implies worked out: each +element carries its `ReferenceP`, `FloorP` and `s_position`, the derived members +of every parameter family it uses (`Kn1L` alongside `Kn1`, `voltage` alongside +`gradient`, …) and the non-zero defaults of the groups it carries, and each +branch is capped with a `branch_end` `Placeholder` holding its final reference +and floor. + +`expanded` is that same tree with all of it pruned: a parameter is kept only +when the author wrote it (or a post-`expand_lattice` `set` wrote it). It is +`full_expanded` with nodes removed rather than an earlier snapshot, so a +parameter present in both holds the *same* value in both, with every `set` and +ABSOLUTE controller applied. + +Which to reach for: + +- **`full_expanded`** to ask what the lattice *is* β€” placement, reference + energy, or any parameter derived from another. `match_names` and + `parameter_value` search it for that reason. +- **`expanded`** to see the inputs rather than their consequences, or to write a + lattice back out without the computed values. + +## Basic use + +```python +import palsparserpy as pp + +lat = pp.parse_and_expand_pals("ex.pals.yaml") + +print(pp.to_yaml_string(lat.original)) +print(pp.to_yaml_string(lat.combined)) +print(pp.to_yaml_string(lat.full_expanded)) +print(pp.to_yaml_string(lat.expanded)) +print(pp.to_yaml_string(lat.adjunct)) +``` + +To expand a single named lattice from a file that defines several, pass its +name as the second argument: + +```python +lat = pp.parse_and_expand_pals("ex.pals.yaml", "main_ring") +``` + +## Reporting problems + +Expanding a lattice can hit problems that are not fatal but are worth knowing +about: a `line` that references an element which was never defined, an +`inherit`/`repeat`/`Fork` whose target is missing, or an expression that could +not be evaluated (an unknown constant, a dangling element-parameter reference, a +dependency cycle). Rather than abort, expansion keeps going β€” leaving the +offending value as text β€” and collects a list of every such problem. + +The `problems` argument controls what is done with that list: + +```python +# Default: print the problems to stderr (nothing prints when there are none). +lat = pp.parse_and_expand_pals("ex.pals.yaml") + +# Write the problems to a file instead, printing nothing. +lat = pp.parse_and_expand_pals("ex.pals.yaml", problems="problems.txt") + +# Say nothing at all. +lat = pp.parse_and_expand_pals("ex.pals.yaml", problems="none") +``` + +`"print"` and `"none"` are the two reserved names; any other value is taken as +the path of the file to write. + +A typical report looks like: + +```text +parse_and_expand_pals: 2 problem(s) encountered during lattice expansion: + - ERROR: reference to undefined element or line 'NoSuchElement' + - ERROR: could not evaluate expression for BendP.edge_int2: 0.02 * thingB>MagneticMultipoleP.NotThere +``` + +### Reading the list programmatically + +Whatever the reporting mode, the same list comes back in `lat.problems` as a +list of `Problem`, so `"none"` still lets you inspect it. Each entry carries more +than its `message`: + +- `path` β€” where it was found (`"q1>ApertureP.shape"`), empty when the problem + is not tied to one spot. +- `severity` β€” `PROBLEM_ERROR` when the trees can no longer be trusted around + the fault, `PROBLEM_WARNING` when expansion produced a sound result anyway. +- `origin` β€” `PROBLEM_INPUT` when your lattice is what needs fixing, + `PROBLEM_UNSUPPORTED` when it is valid PALS that PALSParserCpp does not implement + yet, and `PROBLEM_UNSPECIFIED` when the PALS standard does not define the + case, so nothing was invented. + +The last one is the one to filter on before failing a build, since editing the +lattice can only ever clear a `PROBLEM_INPUT`: + +```python +lat = pp.parse_and_expand_pals("ex.pals.yaml", problems="none") + +mine = [p for p in lat.problems if p.origin is pp.PROBLEM_INPUT] +if mine: + raise SystemExit(f"{len(mine)} problem(s) to fix:\n" + + "\n".join(f" {p}" for p in mine)) +``` + +Only values that look like expressions (an operator, a parenthesis, an +element-parameter `>` reference, or an explicit `expr(...)`) are flagged when +they fail to evaluate; a plain name, label, or boolean that happens not to be a +number is left alone. + +## Relative includes + +Include paths inside a lattice file are resolved relative to the working +directory when the C library opens them. If your lattice `include`s other files +by relative path, change into the lattice directory first: + +```python +import contextlib +import os + +@contextlib.contextmanager +def chdir(path): + old = os.getcwd() + os.chdir(path) + try: + yield + finally: + os.chdir(old) + +with chdir("lattice_files"): + lat = pp.parse_and_expand_pals("ex.pals.yaml") +``` + +(On Python 3.11 and newer, `contextlib.chdir` does this for you.) + +## Correspondence between the views + +The derivation-chain trees describe the same lattice at successive stages of +processing, so most of their nodes correspond: the constant `a_const`, for +instance, exists in `original`, in `combined`, and β€” since it is not part of the +lattice β€” in `adjunct`. `node_correspondence` builds that mapping: given any +node, it returns the nodes it corresponds to in the other views. + +```python +lat = pp.parse_and_expand_pals("ex.pals.yaml") +corr = pp.node_correspondence(lat) +``` + +The result is a `dict` keyed by `YAMLNode`. Looking up a node returns a named +tuple whose fields β€” `original`, `combined`, `full_expanded`, `adjunct` β€” are +each a list of `YAMLNode` listing the corresponding nodes in that view: + +```python +a_const = lat.combined["PALS"]["facility"][0]["constants"]["a_const"] + +corr[a_const].original # [ the a_const node in the original tree ] +corr[a_const].adjunct # [ the a_const node in the adjunct tree ] +corr[a_const].full_expanded # [] -- the lattice never referenced it +``` + +The queried node is included in its own view's list, so the four lists together +form the complete set of nodes that correspond to one another. You can look a +class up starting from *any* of those four trees and get the same result: + +```python +corr[corr[a_const].original[0]] == corr[a_const] # True +``` + +Because expansion splits the document, a `combined` node can reach +`full_expanded`, `adjunct`, or both. A beamline named by the root lattice is a +good example: its definition stays in `adjunct` while a copy of it is inlined +into the expanded lattice, and both belong to the same class. + +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. + +### One-to-many correspondences + +Expansion can turn a single node into several β€” a `repeat` unrolls a line, an +`inherit` copies fields in, a bare element name is substituted with its full +definition, and a fork spawns a new branch. The correspondence follows every +copy, which is why each field is a *list*: one `combined` node can map to many +`full_expanded` nodes. + +```python +# The sub-line repeated inside inj_line appears once in `combined` +# but several times in `full_expanded`. +for node, cls in corr.items(): + if len(cls.combined) == 1 and node == cls.combined[0] and \ + len(cls.full_expanded) > 1: + print("combined node β†’", len(cls.full_expanded), "expanded copies") +``` + +A list is empty when a view has no corresponding node. For example, the +`destination_pointer` scalar that expansion synthesises exists only in +`full_expanded`, so its `original` and `combined` lists are empty; a constant +the lattice never refers to exists only in `adjunct`, so its `full_expanded` +list is empty. + +:::{note} +**The mapping is exact, not heuristic.** The correspondence is not recovered by +re-matching the finished trees. The views are built as a derivation chain +(`original` β†’ `combined` β†’ `full_expanded` and `adjunct`), and the provenance of +every node is recorded as it is copied. `node_correspondence` reads back that +recorded provenance, so the mapping is exact even where nodes are duplicated, +merged, or renamed during expansion. +::: + +A runnable version of these examples is in `examples/node_correspondence.py`. + +## Matching constructs by name + +Once a lattice is expanded, `match_names` finds every named construct that a +PALS *Name Matching* string refers to β€” elements, parameter groups, parameters, +constants, and variables β€” and returns them as a list of `YAMLNode`. The syntax +is: + +```text +[{lattice}>>>][{branch}>>][{kind}::]{name}[>{group}.{subgroup}. … .{parameter}] +``` + +`{lattice}`, `{branch}`, and `{name}` are [PCRE2](https://www.pcre.org) patterns +matched against the *whole* name (anchored at both ends), so `B1.*` matches `B1a` +and `B1b` but `B1` on its own matches neither. `{kind}` is matched exactly, and +the dotted parameter path after the single `>` is matched exactly, key by key. +An omitted or empty pattern matches every name at that level, and `{branch}` +matches an element if any enclosing BeamLine/Branch name matches β€” so elements in +sub-lines are included. + +```python +lat = pp.parse_and_expand_pals("ex.pals.yaml") + +# The `e1` bend parameter of every element whose name begins with `B1`: +pp.match_names(lat.full_expanded, "B1.*>BendP.e1") + +# Restrict to an element kind with `::`: +pp.match_names(lat.full_expanded, "Quadrupole::.*>length") + +# Restrict to a named beamline/branch (`>>`) or lattice (`>>>`): +pp.match_names(lat.full_expanded, "inj_line>>Q.*>length") +pp.match_names(lat.full_expanded, "ring>>>inj_line>>Q.*>length") + +# Omit the parameter path to match the element itself, or the group: +pp.match_names(lat.full_expanded, "Q1a") # the element node +pp.match_names(lat.full_expanded, "Q1a>BendP") # a parameter-group node +``` + +Pass any node of the tree you want to search β€” `lat.full_expanded` for beamlines +and elements, since those are only fully realised after expansion. The returned +nodes belong to that same tree, so you can read or modify them in place: + +```python +for n in pp.match_names(lat.full_expanded, "B1.*>BendP.e1"): + n.set_scalar("0.0") # zero the entrance-face angle of each B1… bend +``` + +### Constants and variables + +Lattice parameters include constant and variable names. A *bare* name β€” no +lattice/branch/kind qualifier and no parameter path β€” also matches every +constant and variable defined directly under the `PALS` or `facility` node, in +both the full (`kind: constant` / `kind: variable`) and compact +(`constants:` / `variables:` list) forms. + +Constants and variables are defined at facility level rather than inside the +lattice, so they are found in `lat.adjunct` β€” searching `lat.full_expanded` for +one matches nothing, as the `PALS`/`facility` node it lives under is not part of +that tree: + +```python +pp.match_names(lat.adjunct, "a_const") # one named constant +pp.match_names(lat.adjunct, "a_.*") # every constant/var named a_… +``` + +For a compact-form entry the matched node is the `name: value` scalar; for a +full-form entry it is the named node, underneath which `kind`/`value` live. + +:::{note} +**Not yet implemented.** The full *Element Name Matching* grammar also defines +`#N` instance selection, `{e1}:{e2}` ranges, `,` unions, and `&` intersections. +These are not yet handled by `match_names`. +::: + +Results are de-duplicated and returned in document order, and a malformed +pattern yields an empty list. A runnable version of these examples is in +`examples/match_names.py`. + +## Reading a parameter value + +Where `match_names` returns the *nodes* a string refers to, `parameter_value` +returns the single *value* a parameter holds. It takes the whole expanded lattice +`lat` and the same *Name Matching* syntax, and returns a `float`, a `str`, or +`None`. Like `match_names`, the string names either an element parameter (with a +parameter path) or, as a *bare* name, a constant or variable: + +```python +lat = pp.parse_and_expand_pals("ex.pals.yaml") + +pp.parameter_value(lat, "lat1>>>B1a>BendP.e1") # 0.1 (from full_expanded) +pp.parameter_value(lat, "F1>ReferenceP.species_ref") # '#3He' (a string) +pp.parameter_value(lat, "Q1>BendP.g") # 0.0 (unset β†’ default) +pp.parameter_value(lat, "Q1") # None (not a value) +pp.parameter_value(lat, "a_const") # a constant (from adjunct) +``` + +`parameter_value` searches only two of `lat`'s five views: `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, and neither is `lat.expanded`: 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 (a species name, +or an expression expansion left unevaluated such as one using `random()`) +verbatim as a `str`: + +- **Element parameter, set** β€” its value: a `float`, or a `str` when + non-numeric. +- **Element parameter, unset** β€” an element that exists but does not set the + parameter yields the parameter's default. That default is `0.0` for every + parameter for now; real per-parameter defaults come later. +- **Constant or variable** β€” a bare name yields its value, the same way. +- **Unidentified** β€” `None`, when the name matches nothing in either view, is a + bare element (an element has no single scalar value), stops on a whole + parameter group rather than a single value, or several matches disagree on the + value. (Matches that *agree* β€” the same element reused, or several that all + take the default β€” collapse to the one shared value.) + +:::{note} +**Defaults are provisional.** Because there is no parameter schema yet, an unset +parameter and a name that is not a real parameter are indistinguishable, so both +return the `0.0` default rather than `None`. When defaults arrive, an unknown +parameter name will return `None` instead. +::: + +## Command-line driver + +`examples/read_pals.py` is a small runnable program that wraps the above: it +reads a lattice, expands it, and prints all five views. + +```console +python examples/read_pals.py +``` + +Place your lattice files under `lattice_files/`. diff --git a/docs/src/guide/parsing.md b/docs/src/guide/parsing.md new file mode 100644 index 0000000..70514d3 --- /dev/null +++ b/docs/src/guide/parsing.md @@ -0,0 +1,189 @@ +# Parsing and writing YAML + +PALSParserPy represents a parsed document as a tree of `YAMLNode` values. Each +node knows whether it is a map, a sequence, or a scalar, and supports the +standard Python collection idioms. The owning `YAMLTree` frees the underlying C +tree automatically when it is garbage-collected, so you never manage memory by +hand. + +## Making the functions available + +Everything documented here is exported from the top-level package, so one import +brings the whole API into scope: + +```python +import palsparserpy as pp + +root = pp.parse_file("config.pals.yaml") +``` + +Every tree operation is also a method on the node itself, so `pp.is_map(node)` +and `node.is_map()` are the same call written two ways. The rest of this guide +uses whichever reads better in context. + +## Reading + +Parse from a file or from a string. Both return a `YAMLNode` pointing at the +tree root: + +| Function | Description | +| --- | --- | +| `parse_file(filename)` | Parse a YAML file from disk. | +| `parse_string(yaml_str)` | Parse YAML from a string. | +| `create_empty_tree()` | Create a new, empty MAP tree to build up from scratch. | +| `parse_and_expand_pals(filename, root_lattice="")` | Parse a PALS lattice file and return original, combined, expanded, full_expanded and adjunct views. | + +```python +root = pp.parse_file("config.pals.yaml") +# or +root = pp.parse_string(""" +server: + host: localhost + port: 8080 +features: + - auth + - logging +""") +``` + +A malformed document raises `PALSParseError`, whose message carries the offending +line and column. + +`parse_and_expand_pals` is PALS-specific: it returns a `Lattices` value holding +five independent tree views (`original`, `combined`, `expanded`, +`full_expanded`, `adjunct`), each freed on its own when garbage-collected. + +## Querying the tree + +Use these to inspect a node's kind, walk the tree, and read out its structure. +None of them modify the document. + +### Kind checks + +Every node is exactly one of map, sequence, or scalar: + +| Function | Description | +| --- | --- | +| `is_map(node)` | `True` if `node` is a map (key/value pairs). | +| `is_sequence(node)` | `True` if `node` is a sequence (ordered list). | +| `is_scalar(node)` | `True` if `node` is a scalar leaf value. | + +### Navigation and inspection + +| Expression | Description | +| --- | --- | +| `node[key]` | The direct child of a map `node` under string `key` (`KeyError` if absent). | +| `node[index]` | The `index`-th child (0-based, negative counts from the end) of a map or sequence. | +| `node.get(key, default)` | The child under `key`, or `default` if there is none. | +| `key in node` | `True` if the map `node` has a direct child under `key`. | +| `len(node)` | Number of direct children (0 for a scalar). | +| `node.keys()` | The keys of a map `node`, in order, as a list of `str`. | +| `node.values()` | The children of `node`, in order. | +| `node.items()` | The `(key, child)` pairs of a map `node`. | +| `node.node_key()` | The key `node` is stored under in its parent, or `None`. | +| `node.child(i)` | The `i`-th child *by position*, which is how a single-key map entry is opened. | +| `get_parent(node)` | The parent node (`ValueError` if `node` is the root). | +| `iter(node)` | Sequences yield their elements; maps yield their keys, as `dict` does. | + +```python +"features" in root # True +len(root["features"]) # 2 +root["server"].keys() # ['host', 'port'] + +for item in root["features"]: + print(item.value) # auth, logging + +for k, v in root["server"].items(): + print(k, "=>", v.value) # host => localhost, port => 8080 + +parent = pp.get_parent(root["server"]) # back up to the root +``` + +Indexing is **0-based**, matching Python and the underlying C API. (The Julia +interface to the same library is 1-based, so an index carried across from a +PALSParserJ script needs one subtracted.) + +### Reading scalar values + +Convert a scalar leaf node to the Python type you want: + +| Expression | Description | +| --- | --- | +| `node.value` | The scalar value as a `str` (the raw text). | +| `node.as_int()` | The scalar parsed as an `int`. | +| `node.as_float()` | The scalar parsed as a `float`. | +| `node.as_bool()` | The scalar parsed as a `bool` (`"true"` / `"false"`). | + +```python +host = root["server"]["host"].value # 'localhost' +port = root["server"]["port"].as_int() # 8080 +``` + +`int(node)` and `float(node)` work as well. There is deliberately no +`bool(node)` conversion: a node is always truthy, so `if node:` asks whether you +*have* a node rather than what it says. + +## Building and editing + +Create an empty document and add maps, sequences, and scalars to it. Each +builder returns the newly created child node: + +| Expression | Description | +| --- | --- | +| `parent.add_scalar(value, key=None, index=None)` | Add a scalar child. | +| `parent.add_map(key=None, index=None)` | Add an empty map child. | +| `parent.add_sequence(key=None, index=None)` | Add an empty sequence child. | +| `node[key] = value` | Set (or create) a scalar child under `key`. | +| `node.set_scalar(value)` | Set or replace a node's scalar value in place. | +| `node.set_key(key)` | Set or replace the key a node is stored under. | +| `node.remove()`, `del parent[key]` | Remove a node and all its descendants. | +| `node.copy()` | An independent deep copy of `node` in a new tree. | +| `dst.deep_copy_node(src)` | Overwrite `dst` with a deep copy of `src`. | +| `dst.deep_copy_children(src, index=None)` | Copy all children of `src` into `dst`. | + +Pass `key` for map children and omit it for sequence elements. `index` selects +the 0-based position among the existing children; it defaults to `None`, which +appends at the end, so you usually leave it out. + +```python +root = pp.create_empty_tree() + +server = root.add_map(key="server") +server["host"] = "localhost" +server["port"] = "8080" + +features = root.add_sequence(key="features") +features.add_scalar("auth") +features.add_scalar("logging") +``` + +The `deep_copy_node` / `deep_copy_children` pair works across different trees, +so you can graft one subtree onto another. + +## Writing + +Serialize a node to a string or straight to disk: + +| Expression | Description | +| --- | --- | +| `to_yaml_string(node, exclude=...)` | The node and its descendants as a YAML `str`. | +| `write_yaml(node, filename, exclude=...)` | Write the whole tree containing `node` to a file. | + +```python +text = pp.to_yaml_string(root) # YAML as a str -- print(root) does the same +pp.write_yaml(root, "out.pals.yaml") +``` + +Both take an `exclude` argument naming keys to leave out, which is handy for +printing or saving a large lattice without the bulky subtrees. Every MAP entry +with a matching key is dropped, at any depth, together with its subtree; the tree +in memory is not modified. + +```python +print(pp.to_yaml_string(root, exclude=["FloorP", "ReferenceP"])) +print(pp.to_yaml_string(root, exclude="FloorP")) # a single key needs no list +pp.write_yaml(root, "out.pals.yaml", exclude=["FloorP", "ReferenceP"]) +``` + +See the [API Reference](../api.md) for the full list of functions and their +signatures. diff --git a/docs/src/guide/translation.md b/docs/src/guide/translation.md new file mode 100644 index 0000000..70e6de0 --- /dev/null +++ b/docs/src/guide/translation.md @@ -0,0 +1,516 @@ +# Translating to SciBmad, Bmad and MAD-X + +PALSParserPy can translate a PALS-format lattice into three accelerator formats: + +- **`palsparserpy/to_scibmad.py`** β€” emits a [SciBmad](https://github.com/bmad-sim/SciBmad.jl) + / [Beamlines](https://github.com/bmad-sim/Beamlines.jl) description. +- **`palsparserpy/to_bmad.py`** β€” emits a classic [Bmad](https://www.classe.cornell.edu/bmad/) + lattice. +- **`palsparserpy/to_madx.py`** β€” emits a [MAD-X](https://mad.web.cern.ch/mad/) lattice. + +All three translators take a lattice already parsed by `parse_file`, +walk the element list, and map each PALS element and its parameters +onto the corresponding target-format element. + +## Running the translators + +Translating is a three-step process: parse, translate, write. `parse_file` reads +a PALS-YAML file into a parsed tree (a `YAMLNode`); `pals_to_bmad` / +`pals_to_madx` / `pals_to_scibmad` translate that tree into an in-memory model of +the *target* lattice (a `BmadLattice` / `MadxLattice` / `SciBmadLattice` of +elements, beamlines, and parameters); and `write_bmad_file` / `write_madx_file` / +`write_scibmad_file` take that structure and an output path and serialize the +lattice file: + +```python +import os +import palsparserpy as pp + +bmad = pp.pals_to_bmad(pp.parse_file(os.path.join("lattice_files", "bta.pals.yaml"))) +pp.write_bmad_file(bmad, os.path.join("lattice_files", "bta.pals_out.bmad")) + +madx = pp.pals_to_madx(pp.parse_file(os.path.join("lattice_files", "bta.pals.yaml"))) +pp.write_madx_file(madx, os.path.join("lattice_files", "bta.pals_out.madx")) + +scibmad = pp.pals_to_scibmad(pp.parse_file(os.path.join("lattice_files", + "convert.pals.yaml"))) +pp.write_scibmad_file(scibmad, os.path.join("lattice_files", "convert.pals_out.jl")) +``` + +The [`examples/`](https://github.com/pals-project/PALSParserPy/tree/main/examples) +directory has runnable scripts, such as `examples/pals_to_bmad.py` and +`examples/pals_to_madx.py`. + +Anything a translator cannot express is either reported on stdout and skipped, or +raised as a `ValueError` when skipping it would quietly change the physics. + +## Element and parameter mapping + +PALS element kinds and their parameters do not map one-to-one onto Bmad or MAD-X. +The translators encode the conversions β€” renamed parameters, unit changes, and +cases that have no equivalent (and are skipped with a warning). For example, +an `ApertureP` becomes a Bmad `ApertureParams`, with `x_min`/`x_max` mapped to +`x1_limit`/`x2_limit` (or derived from `x_center`/`x_width`). + +The complete, element-by-element list of these mappings is given in the +[Parameter mapping reference](#parameter-mapping-reference) below. Consult it +when adding support for a new element or when a parameter comes through +untranslated. MAD-X differs from Bmad widely enough to be worth reading +[What MAD-X does differently](#what-mad-x-does-differently) first. + +## Extending a translator + +To add support for a new element or parameter: + +1. Find its PALS definition and decide on the target-format equivalent; record + it in the [Parameter mapping reference](#parameter-mapping-reference) below. +2. Add the mapping to the element builder β€” `_make_bmad_ele` in + `palsparserpy/to_bmad.py`, `_make_madx_ele` in `palsparserpy/to_madx.py`, or + `_make_scibmad_ele` in `palsparserpy/to_scibmad.py` β€” and to any helper it + calls (e.g. `_ele_to_bmad_str`, `_make_bmad_line`, `_ele_to_madx_str`, or + `_ele_to_scibmad_str`, `_make_scibmad_beamline`). +3. Translate a lattice that exercises the element and check the output. + +## What MAD-X does differently + +Bmad and MAD-X share a great deal, but a PALS lattice meets the differences at +almost every element. The ones that shape the whole translation: + +- **Every MAD-X strength is normalized.** MAD-X has no field-valued attribute at + all, where Bmad has `field_master` and `B1_GRADIENT`. A PALS component stated as + a field (`Bn1`, `Bsol`, `bend_field_ref`) is therefore divided by the signed + rigidity `P0/q`, which the file defines once as + `pals_brho := beam->brho * beam->charge / abs(beam->charge);` and leaves MAD-X to + evaluate from its own `BEAM` command. +- **MAD-X coefficients carry no `1/n!`.** MAD-X states a multipole as + `Kn L = (L/Brho) d^n By/dx^n`, and so does PALS, so a PALS `KnN` is MAD-X's `KN` + outright. (Bmad's `An`/`Bn` do carry the factorial, which is why the Bmad + translation divides by one and this one does not.) +- **MAD-X has a skew attribute for each of the orders an element owns** β€” `k1s`, + `k2s`, `k3s` β€” so a tilted or skew multipole of the element's own order needs no + multipole element of its own. But MAD-X magnets are strictly single-order: + a quadrupole may not carry a sextupole component, and only a `multipole` element + has the `knl`/`ksl` arrays. Any other order on a magnet is reported and dropped. +- **A bend's geometry and its field are the one `angle`.** MAD-X does not use `k0` + in its bend map, so it has no equivalent of Bmad's `dg`, the departure of the + field from the reference bend. PALS decouples the two (the actual field is `Kn0`, + and `Kn0_from_g_ref` says whether it defaults to the reference field); MAD-X cannot. + The reference geometry is what is written out; a `Kn0` that disagrees with it is + reported and dropped, because writing the field out instead would move every element + downstream of the bend. +- **A misalignment lives outside the element definition.** A PALS `BodyShiftP` + becomes a `SELECT, FLAG=ERROR` / `EALIGN` pair after the `USE` statement, not an + attribute of the element. MAD-X rotates about the entrance of an element where + PALS and Bmad rotate about its centre, so the two agree only to first order in + the angles. +- **MAD-X has no controller element.** A PALS `Controller` becomes what MAD-X has + instead: its variables become ordinary MAD-X variables and each of its controls + becomes a deferred assignment, `q1->k1 := 2*k;`. A `RELATIVE` controller has both + the value it varies and its own starting point written into the assignment, MAD-X + forbidding the circular `q1->k1 := q1->k1 + dk` and having no notion of a knob's + delta. MAD-X variables are global where a PALS controller's are its own, so a name + two controllers both claim is prefixed with the controller that owns it. +- **MAD-X units are not PALS units.** Energies are GeV against eV, voltages MV + against V, frequencies MHz against Hz, and phases are counted in turns where PALS + counts Twiss phases in radians. A value written as a number is converted during + translation; one written as an expression is left for MAD-X to evaluate. +- **The longitudinal coordinate is measured against the energy.** MAD-X's `T`, `PT` + and its dispersion are derivatives with respect to `pt = dE/(p0 c)` where PALS + uses `pz = dp/p0`, and `pt = beta * pz`. Those quantities are written out divided + or multiplied by MAD-X's own `beam->beta` rather than converted here. +- **Section order matters.** A MAD-X name has to be defined above the point of use, + `BEAM` has to precede `USE`, and `EALIGN` can only follow it, there being no + expanded sequence to apply an error to before then. `write_madx_file` writes the + sections in that order. + +## Parameter mapping reference + +The following is the element-by-element mapping between PALS parameter groups +and their SciBmad/Bmad/MAD-X equivalents. + +### Element kinds --> MAD-X keywords + +- Bend --> sbend (PALS has the one bend, whose reference geometry is a sector) +- CrabCavity --> crabcavity +- Drift --> drift +- Kicker --> kicker +- Multipole --> multipole +- Octupole --> octupole +- Quadrupole --> quadrupole +- RFCavity --> rfcavity +- Sextupole --> sextupole +- Solenoid --> solenoid +- BeamBeam --> beambeam +- Mask --> collimator +- Instrument --> instrument +- Marker, BeginningEle --> marker +- Placeholder --> placeholder +- Patch --> changeref +- Taylor --> matrix (the map itself is not yet translated) +- ACKicker, Wiggler, Converter, EGun, Foil, Match, Fiducial, FloorShift, Fork, + ReferenceChange, Girder, UnionEle, Feedback --> no MAD-X equivalent (an error) + +### ACKickerP --> None + +### ApertureP --> ApertureParams +- x_min --> x1_limit +- x_max --> x2_limit +- x_width and x_center: + - x1_limit = x_center - x_width / 2 + - x2_limit = x_center + x_width / 2 +- Note: Either both min and max are defined, or width and center are defined, not both. +- y_min --> y1_limit +- y_may --> y2_limit +- y_width and y_center: + - y1_limit = y_center - y_width / 2 + - y2_limit = y_center + y_width / 2 + +- shape --> aperture_shape + - RECTANGULAR --> Rectangular + - ELLIPTICAL --> Elliptical + - VERTICES --> none + - CUSTOM_SHAPE --> none + +- location --> aperture_at + - ENTRANCE_END --> Entrance + - EXIT_END --> Exit + - BOTH_ENDS --> BothEnds + - EVERYWHERE --> BothEnds + - CENTER --> BothEnds + - NOWHERE --> none + +- aperture_shifts_with_body --> aperture_shifts_with_body +- aperture_active --> aperture_active +- vertices --> none +- material --> none +- thickness --> none + +- Note (Bmad): Bmad states a limit as a distance from the axis rather than as a + coordinate β€” it loses a particle at `x < -x1_limit` β€” so the low-side limit is + the negated PALS `x_min`. +- Note (MAD-X): MAD-X states a half extent about the axis and the offset of the + centre separately, so both PALS forms come to `aperture = {x_half, y_half}` and + `aper_offset = {x_center, y_center}`; `shape` becomes `apertype` + (RECTANGULAR --> rectangle, ELLIPTICAL --> ellipse). +- Note (MAD-X): the `shape` decides which components describe the aperture. A + `RECTANGULAR` or `ELLIPTICAL` one is bounded by its limits and ignores any vertices; + a `VERTICES` (or `CUSTOM_SHAPE`) one is reported, MAD-X taking a vertex outline only + from a file of its own, which PALS does not name. +- Note (MAD-X): MAD-X checks an aperture at the entrance of an element only, so + `location` is not translated; nor are `aperture_shifts_with_body`, `material` and + `thickness`, and an `aperture_active: false` cannot be expressed. +- Note (MAD-X): MAD-X cannot put an aperture on a drift β€” use a collimator β€” and + its aperture values are positional, so a group that bounds one plane and not the + other has the unbounded one written out as 1 m. +- Note: shape, location and the rest describe an aperture; they do not put one + there. A group that sets no limit is skipped entirely by all three translators, + since writing it out would give the element an aperture the PALS lattice does + not have. + +### BeamBeamP --> Not in SciBmad yet +- Note (MAD-X): sigma_x --> sigx, sigma_y --> sigy, charge --> charge, + N_particle --> npart. MAD-X models the opposite beam as a four-dimensional lens, + so sigma_z, alpha_x, beta_x, alpha_y, beta_y and energy have no equivalent. + +### BendP --> BendParams +- radius_ref -> caluclated (Bmad: rho) +- Bn0_ref -> calculated (Bmad: B_field) +- e1 --> e1 +- e2 --> e2 +- e1_rect --> calculated +- e2_rect --> calcualted +- edge1_int --> edge1_int +- edge2_int --> edge2_int +- g_ref --> g_ref +- h1 --> not in scibmad +- h2 --> not in scibmad +- L_chord --> calculated +- L_sagitta --> calculated +- tilt_ref --> tilt_ref + +- Note (MAD-X): PALS states a bend's geometry with any two of three sets of mutually + dependent parameters β€” a curvature (`g_ref`, `radius_ref`, `Bn0_ref`), a length + (`length`, `L_chord`, `L_rectangle`) and the angle (`angle_ref`) β€” one from each of + two different sets. MAD-X wants one particular pair, the `angle` and the arc length + `l`, so whichever pair was given is turned into that pair: + `angle = g_ref * length`, `= 2*asin(g_ref*L_chord/2)`, `= asin(g_ref*L_rectangle)`; + `l = angle_ref / g_ref`, `= angle_ref*L_chord/(2*sin(angle_ref/2))`, + `= angle_ref*L_rectangle/sin(angle_ref)`. Only `Bn0_ref` needs `pals_brho`, the rest + being pure geometry. A bend that states too little for both is reported. +- Note (MAD-X): e1 --> e1, e2 --> e2 (a MAD-X sbend measures its pole faces against + the same sector geometry PALS does). `e1_rect`/`e2_rect` are converted according to + `ref_geometry`: `ARC`/`CHORD` --> `e = e_rect + angle/2`; `ENTRANCE_COORDS` --> + `e1 = e1_rect`, `e2 = e2_rect + angle`; `EXIT_COORDS` --> `e1 = e1_rect + angle`, + `e2 = e2_rect`. +- Note (MAD-X): edge1_int --> `fint = 0.5, hgap = 2*edge1_int`, edge2_int --> + `fintx`/`hgapx`; h1 --> h1, h2 --> h2; tilt_ref --> tilt. +- Note (MAD-X): a MAD-X sbend is always an arc with vertically pure multipoles, so a + `ref_geometry` other than `ARC`, or a `multipole_geometry` other than + `FOLLOWS_REF_GEOMETRY`/`VERTICALLY_PURE`, is reported. `L_sagitta` is an output + parameter and is an error. +- Note (MAD-X): `Kn0_from_g_ref: false` with no order-0 multipole set gives a bend with + the reference geometry and no field of its own, which MAD-X β€” tracking through the + same `angle` it bends the reference orbit with β€” cannot express, and is reported. + +### BodyShiftP --> AlignmentParams +- x_offset --> x_offset +- y_offset --> y_offset +- z_offset --> z_offset +- x_rot --> x_rot +- y_rot --> y_rot +- z_rot --> tilt + +- Note (Bmad): x_rot --> `y_pitch = -x_rot` and y_rot --> `x_pitch`, Bmad naming a + rotation by the plane it tips the element into rather than by the axis it turns + about. +- Note (MAD-X): the whole group becomes an `EALIGN` command, not element attributes: + x_offset --> dx, y_offset --> dy, z_offset --> ds, x_rot --> -dphi, + y_rot --> dtheta, z_rot --> dpsi. MAD-X rotates about the entrance of an element + where PALS rotates about its centre, so the two agree only to first order. + +### ElectricMultipoleP --> Not in SciBmad yet + +### FloorP --> Calculated + +### CoordinateSetP --> Set in floor shift element (to be added to scibmad) +- Note (MAD-X): MAD-X has no element that sets the global coordinates of the reference + curve, so the group is an error. `FloorShift` and `Fiducial`, the two kinds that + carry it, have no MAD-X equivalent either. + +### ForkP --> Needs to be Implemented in scibmad + +### GirderP In Contruction + +### MagneticMultipoleP --> BMultipoleParams +- tiltN --> tiltN +- [BK][ns]NL? --> [BK][ns]NL? +- BnN(L) --> BnN(L) +- Note (Bmad): the normal component of the multipole that is an element's own strength becomes + that strength: `Kn1` --> `K1` for a quadrupole, `Kn2` --> `K2`, `Kn3` --> `K3`, and `Kn0` --> + `dg` for a bend (`Bn1` --> `B1_GRADIENT`, ..., `Bn0` --> `db_field`). Every other order stays + a multipole, and becomes the integrated `An`/`Bn`. +- Note (Bmad): a Bmad bend carries a quadrupole and a sextupole component of its own besides its + bending field, so a bend's `Kn1` --> `K1` and `Kn2` --> `K2` as well (`Bn1` --> + `B1_GRADIENT`, `Bn2` --> `B2_GRADIENT`). These two hold a normal field only, and are components + added to a field the bend already has rather than the strength that makes it a bend, so an + order with a skew part -- a `Ks1`/`Ks2`, or a `tilt1`/`tilt2` that rotates one into being -- + keeps both parts in the `An`/`Bn` form instead. A bend has no attribute above order 2, so its + higher multipoles stay `An`/`Bn` either way. +- Note (Bmad): PALS states a bend's field outright, where Bmad states its departure from the + reference bend. So `Kn0` is translated as `dg = Kn0 - g_ref` -- against `1/radius_ref` when + the reference bend is given as a radius, and against `Bn0_ref` when the field is not + normalized. A field equal to the reference bend departs from it by nothing, and no `dg` is + written. The two flavors cannot be mixed: measuring a `Kn0` against a `Bn0_ref` (or the + reverse) takes the reference momentum, which belongs to the branch and not to the element. +- Note (Bmad): Bmad reads an `An`/`Bn` on an ordinary element as a fraction of that + element's own strength, where a PALS multipole is the field integral itself, so + an element left carrying one also gets `scale_multipoles = F`. The kinds that hold + nothing but multipoles do no such scaling and have no such attribute. + +- Note (MAD-X): a PALS coefficient is a MAD-X coefficient outright β€” neither carries + the `1/N!` of the field expansion β€” so the orders an element owns become: + `KnN` --> `kN` and `KsN` --> `kNs` for a quadrupole (N=1), sextupole (2) and + octupole (3); `Kn0` --> `angle`, `Kn1` --> `k1`, `Ks1` --> `k1s`, `Kn2` --> `k2` + for a bend; `Kn0L` --> `-hkick` and `Ks0L` --> `vkick` for a kicker. +- Note (MAD-X): a `tiltN` is rotated into the normal and skew components, MAD-X's + own `tilt` being one roll for the whole element rather than one per order. +- Note (MAD-X): a length is put in or taken out to match the attribute β€” `angle`, + `hkick` and `vkick` are integrated, the rest are not. +- Note (MAD-X): a MAD-X magnet is strictly single-order. Only a `multipole` element + has multipole arrays, where each order becomes the integrated `knl[N]`/`ksl[N]`; + any other order on any other magnet is reported and dropped. +- Note (MAD-X): a `BnN`/`BsN` is divided by the signed rigidity `pals_brho`, MAD-X + having no field-valued strength attribute. + +### MetaP --> MetaParams +- alias --> alias (Bmad: alias) +- label --> label (Bmad: type) +- description --> description (Bmad: descrip) +- ID --> none +- location --> none +- history --> none +- Note: any other (non-standard) component --> none +- Note: a component holding a structure rather than a string is not translated. +- Note (Bmad): Bmad has no escape for a quote inside a string, so a value holding one + quote character is wrapped in the other, and one holding both is not translated. +- Note (MAD-X): a MAD-X element holds no metadata of its own, so every component + that is a plain string becomes a comment line above the element definition. + +### ParticleP --> Create new bunch +- Note (MAD-X): MAD-X starts a particle with the `START` command of the `TRACK` + module, which has no place in a lattice file, so the coordinates are written out + as a comment: x, px, y, py as they stand, `z` --> `t = z / beam->beta` and + `pz` --> `pt = pz * beam->beta`. Spin has no MAD-X equivalent. + +### PatchP --> PatchParams +- x_offset --> x_offset +- y_offset --> y_offset +- z_offset --> z_offset +- t_offset --> dt (not in PALS yet) +- x_rot --> x_rot +- y_rot --> y_rot +- z_rot --> z_rot +- flexible --> none +- ref_coords --> none +- user_sets_length --> none +- Note (MAD-X): the offsets become `patch_trans = {x, y, z}` and the rotations + `patch_ang = {x_rot, y_rot, z_rot}` of a `changeref`. MAD-X applies the three + angles in an order of its own, so the correspondence is exact only to first order + in the angles. A changeref has no length, and `flexible`, `ref_coords` and + `user_sets_length` have no equivalent. + +### ReferenceP --> Beamline Properties +- species_ref --> species_ref +- pc_ref --> pc_ref +- E_tot_ref --> E_ref +- time_ref --> none +- location --> none +- Note (MAD-X): the group becomes the `BEAM` command: species_ref --> particle + (positron, electron, proton, antiproton, posmuon, negmuon; anything else has to + be given its mass and charge by hand), pc_ref --> `pc` and E_tot_ref --> `energy`, + both in GeV rather than eV. + +### ReferenceChangeP --> Beamline Properties +- extra_dtime_ref --> none +- dE_ref --> dE_ref +- E_tot_ref --> E_ref +- species_ref --> species_ref +- Note (MAD-X): MAD-X takes the reference energy from the `BEAM` command and cannot + change it in mid-line, so the whole group is an error. + +### RFP --> RFParams +- frequency --> rate, rate_meaning = false +- harmon --> rate, if rate_meaning = true +- if neither frequency or harmon exist, set rate_meaning = -1 +- voltage --> voltage +- gradient --> none +- phase --> phi0 +- multipass_phase --> none +- cavity_type --> traveling_wave + - STANDING_WAVE --> false + - TRAVELING_WAVE --> true +- num_cells --> tracking_method = SaganCavity(num_cells) +- zero_phase --> zero_phase + - ACCELERATING --> Accelerating + - BELOW_TRANSITION --> BelowTransition + - ABOVE_TRANSITION --> AboveTransition + +- Note (MAD-X): frequency --> `freq` in MHz, harmon --> `harmon`, + voltage --> `volt` in MV, gradient --> `volt = gradient * L_active`. +- Note (MAD-X): phase --> `lag`, offset by what `zero_phase` measures from. MAD-X's + zero lag is the zero crossing half a period from the one Bmad and PALS call the + stable point above transition (`phi0 = lag + 0.5`), so + ABOVE_TRANSITION --> `lag = phase - 0.5`, BELOW_TRANSITION --> `lag = phase`, and + ACCELERATING --> `lag = phase - 0.25`. +- Note (MAD-X): a TRAVELING_WAVE cavity is MAD-X's `twcavity`, which only PTC + tracks, so it is translated as an `rfcavity` with a warning; `multipass_phase`, + `num_cells`, `L_active` and `dE_ref` have no equivalent. + +### SolenoidP --> BMultipoleParams +- Ksol --> Ksol +- Bsol --> Bsol +- Note (MAD-X): Ksol --> `ks`, Bsol --> `ks = Bsol / pals_brho`. A solenoid of zero + length also needs MAD-X's integrated `ksi`, which PALS does not state. + +### TrackingP --> UniversalParams.tracking_method +- Note (MAD-X): tracking parameters are program specific by design and are skipped. + +### TwissP --> initial conditions +- Note (Bmad): the group becomes `beginning[...]` settings, which PALS and Bmad + name alike bar the coupling matrix's underscore (`cmat11` --> `cmat_11`). +- Note (MAD-X): the group becomes a `BETA0` block. beta_a --> betx, + beta_b --> bety, alpha_a --> alfx, alpha_b --> alfy, phi_a --> `mux = phi_a/2pi` + (MAD-X counts the phase in turns), eta_x --> `dx = eta_x / beam->beta`, and + likewise eta_y, etap_x, etap_y. +- Note (MAD-X): PALS states the Twiss parameters in the a/b normal modes and MAD-X + in the x/y planes, which are the same thing only when the lattice is uncoupled. + The coupling itself is Bmad's C matrix here and MAD-X's R matrix there, which are + different parametrizations, so `cmatNN` is not translated; nor is `deta_x_ds`, + MAD-X having no dispersion derivative. + +### Lattices +- Beamlines --> Beamlines +- To be added: Lattices in PALS --> Lattices +- Note (MAD-X): a BeamLine becomes a `line` and a Lattice becomes the `use, period` + statement. A leading `BeginningEle` is dropped β€” it carries the reference parameters, + which become the `BEAM` command β€” whether the line spells it out or names it; a line + that does not begin with one keeps every element it has. MAD-X expands one sequence + at a time, so only the first branch is used and the rest are commented out. MAD-X has + no geometry attribute β€” whether a branch closes on itself is decided by how it is + used β€” so `periodic` becomes a comment. + +### Constants and variables --> Bmad `name = value` definitions +- `constants:` / `variables:` list entry --> `name = value` +- `kind: constant` / `kind: variable` definition --> `name = value` +- Note: Bmad draws no constant/variable distinction, so both translate the same way. +- Note: definitions directly under the `PALS` node are translated as well as the facility's own. +- Note: a definition with no `value` takes the PALS default of zero. +- Note: the definitions are written, in the order the PALS file gives them, ahead of every + other section of the Bmad file. Bmad, unlike PALS, resolves a name against what the file + has defined *above* the point of use. +- Not translated to SciBmad. +- Note (MAD-X): the same in every respect, MAD-X also resolving a name against what + is defined above the point of use. A MAD-X variable is a value and nothing else, so + `absolute_error` and `relative_error` are reported. +- Note (MAD-X): an expression is carried across as it stands β€” MAD-X's arithmetic and + ordinary functions are PALS' as well β€” but two things in one are reported rather + than rewritten, expression translation being an open item for all the translators: + the particle-data functions (`mass_of`, `charge_of`, `anomalous_moment_of`), which + MAD-X does not have, and the predefined constants MAD-X spells differently + (`c_light` --> `clight`, `e_charge` --> `qelect`, `r_electron` --> `erad`, + `r_proton` --> `prad`, `mu_0_vac` --> `amu0`) or does not have at all (`h_planck`, + `hbar`, `k_boltzmann`, `eps_0_vac`, `fine_structure`, `n_avogadro`, + `classical_radius_factor`). `pi` is the one they agree on. + +### Controllers --> Bmad overlays and groups +- `control_type: ABSOLUTE` --> an `overlay`, which sets its slaves' attributes. +- `control_type: RELATIVE` --> a `group`, which adds to them. +- variables --> the `var = {...}` list and its initial values. +- controls --> `ele[attribute]: expression`, scaled the same way the element + attribute was: by the length when the attribute and the PALS parameter disagree + about integration, and by the `1/n!` of the multipole convention. An `overlay` + driving a bend's `DG` also has the reference bend subtracted, that attribute + being measured from it rather than from zero; a `group`, which varies rather than + sets, does not. + +### Controllers --> MAD-X variables and deferred assignments +- variables --> `name = value;` (global MAD-X variables) +- controls --> `ele->attribute := expression;` +- MetaP --> comment lines above the definitions +- Note (MAD-X): a PALS controller owns its variables, so `ps1>cur` and `ps2>cur` are + two independent knobs. MAD-X has one namespace for the whole file, so a variable + whose bare name another controller or a constant also claims is written as + `__`, and the expressions using it are rewritten to match. A + name nobody else claims keeps its bare form. +- Note (MAD-X): `control_type: ABSOLUTE` sets the attribute outright, which is what a + deferred assignment does. `RELATIVE` is a knob: the slave keeps the value the lattice + gave it and moves by how far the knob has turned *from where it started*, so the + assignment is `ele->attr := + (expr) - (expr at the variables' + initial settings)`. MAD-X forbids the circular `ele->k1 := ele->k1 + dk`, so the + element's own value is read back out of the element definition; and the last term β€” + which a Bmad `group` keeps track of by itself β€” is left out only when it can be shown + to come to zero, which for a knob resting at zero it does. +- Note (MAD-X): the expression is scaled the same way the element attribute was β€” + by the length when the attribute and the PALS parameter disagree about + integration, by `1/pals_brho` when the parameter is a field, by -1 for an `hkick`. +- Note (MAD-X): a control target may name its element by kind, as `{kind}::{name}`; + the qualifier is checked against the element found and then dropped. A `>>` or `>>>` + qualifier naming the BeamLine or Lattice an element is reached through has no MAD-X + equivalent and is an error. +- Note (MAD-X): a control aimed at a multipole array entry has no target β€” MAD-X + cannot name one entry of a `knl` β€” and is an error, as is one aimed at a tilted + multipole or selecting its slaves by pattern. + +### Controllers --> SciBmad Controllers +- Each control becomes an `(ele, :property) => (ele; vars...) -> expression` pair, + SciBmad keeping the PALS parameter names so that only the group prefix is dropped + and nothing needs rescaling. +- `RELATIVE` adds its expression to the value the element already carries; + `ABSOLUTE` replaces it. + +### TODO +- translating expression +- names of fundamental constants +- names of functions (tan) +- sinc --> sincu +- MAD-X: `ElectricMultipoleP`, `FloorP`, `ForkP`, `GirderP` and `TaylorP` diff --git a/docs/src/index.md b/docs/src/index.md new file mode 100644 index 0000000..9c57786 --- /dev/null +++ b/docs/src/index.md @@ -0,0 +1,66 @@ +# PALSParserPy + +**PALSParserPy** is a Python parser for the Particle Accelerator Lattice Standard +([PALS](https://github.com/campa-consortium/pals)). It reads PALS-format lattice +files, performs lattice expansion, and translates lattices into +[SciBmad](https://github.com/bmad-sim/SciBmad.jl), +[Bmad](https://www.classe.cornell.edu/bmad/) and +[MAD-X](https://mad.web.cern.ch/mad/) formats. + +Under the hood, the package is a thin `ctypes` wrapper around the C library built +by [PALSParserCpp](https://github.com/pals-project/PALSParserCpp) (a +[rapidyaml](https://github.com/biojppm/rapidyaml) backend). A parsed document is +a tree of `YAMLNode` values that you index and mutate with familiar Python idioms +(`node["key"]`, `node[i]`, `key in node`, `.keys()`, `len`, iteration). + +```{toctree} +:hidden: +:caption: User Guide + +guide/installation +guide/parsing +guide/lattices +guide/expressions +guide/translation +``` + +```{toctree} +:hidden: +:caption: Reference + +api +``` + +## What it does + +1. **Parse** PALS-format YAML into a `YAMLNode` tree, or build one from + scratch β€” see [Parsing and writing YAML](guide/parsing.md). +2. **Expand** a lattice β€” read a lattice file, resolve its includes, and + expand the line into an ordered list of elements β€” see + [Reading and expanding lattices](guide/lattices.md). +3. **Evaluate** the mathematical expressions in the expanded lattice to + numbers β€” see [Evaluating expressions](guide/expressions.md). +4. **Translate** a PALS lattice to SciBmad, Bmad or MAD-X format β€” see + [Translating to SciBmad, Bmad and MAD-X](guide/translation.md). + +The complete docstring reference is in the [API Reference](api.md). + +## Quick example + +```python +import palsparserpy as pp + +# Read a lattice file and expand it. +lat = pp.parse_and_expand_pals("ex.pals.yaml") + +print(pp.to_yaml_string(lat.full_expanded)) # the expanded root lattice as YAML +print(pp.to_yaml_string(lat.adjunct)) # everything else in the document + +# Build a document from scratch and write it out. +root = pp.create_empty_tree() +server = root.add_map(key="server") +server["host"] = "localhost" +server["port"] = "8080" + +pp.write_yaml(root, "config.pals.yaml") +``` diff --git a/examples/evaluate_expressions.py b/examples/evaluate_expressions.py new file mode 100644 index 0000000..6c2a42d --- /dev/null +++ b/examples/evaluate_expressions.py @@ -0,0 +1,55 @@ +"""Evaluating the mathematical expressions in a PALS lattice. + +parse_and_expand_pals evaluates every expression to a number, drawing on built-in +physical constants, math and particle-data functions, and any constants/variables +the lattice defines. This happens across the expanded views (`expanded` and +`full_expanded`, which hold the same values) and `adjunct`; the `original` and +`combined` views keep the expression text as written. + +evaluate_pals_expression evaluates a single expression string on its own. +""" + +import os +import sys + +# So the examples run from a checkout without installing it first. +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import palsparserpy as pp # noqa: E402 + +ex_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", + "lattice_files", "ex.pals.yaml") + +lat = pp.parse_and_expand_pals(ex_file) + +# -- combined keeps the source text; the evaluated number is downstream of it -- +# Constants and variables are not part of the lattice, so they are `adjunct`. +consts_c = lat.combined["PALS"]["facility"][0]["constants"] +consts_e = lat.adjunct["PALS"]["facility"][0]["constants"] +vars_e = lat.adjunct["PALS"]["facility"][1]["variables"] + +print("a_const as written :", consts_c["a_const"].value) # 0.3 * r_electron +print("a_const evaluated :", consts_e["a_const"].value) # a number +# a_var references the constant a_const defined above it. +print("a_var evaluated :", vars_e["a_var"].value, " (= a_const^2)\n") + +# -- an element parameter written as an expression is evaluated too ------------ +q1a_length = pp.match_names(lat.full_expanded, "Q1a>length") # 1.03 * pi / c_light +if q1a_length: + print("Q1a length evaluated:", q1a_length[0].value, + " (= 1.03 * pi / c_light)\n") + +# -- evaluating a single expression on its own --------------------------------- +# Built-in constants and math functions: +print("3.75e7 / c_light^2 =", pp.evaluate_pals_expression("3.75e7 / c_light^2")) +# Particle-data functions take a *quoted* species name: +print('mass_of("electron") =', pp.evaluate_pals_expression('mass_of("electron")')) +# A leading expr(...) wrapper is accepted: +print("expr(2 * pi) =", pp.evaluate_pals_expression("expr(2 * pi)")) + +# Non-evaluable strings raise ValueError -- e.g. an unquoted species name, or a +# deferred random_gauss(). Guard with try/except if the input is untrusted: +try: + pp.evaluate_pals_expression("mass_of(electron)") # unquoted -> error +except ValueError as err: + print("\nunquoted species name is rejected:", err) diff --git a/examples/manipulate_tree.ipynb b/examples/manipulate_tree.ipynb new file mode 100644 index 0000000..163531e --- /dev/null +++ b/examples/manipulate_tree.ipynb @@ -0,0 +1,256 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Manipulating a YAML tree in memory\n", + "\n", + "This notebook reads a YAML file and then manipulates the resulting tree\n", + "structure in memory: inspecting nodes, accessing sequence and map elements,\n", + "adding new elements, and writing the edited tree back to a file." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "`import palsparserpy as pp` brings the whole public API into scope under a short\n", + "name, which is how the functions are called throughout this notebook. Every tree\n", + "operation is also a method on the node itself, so `pp.add_map(facility)` and\n", + "`facility.add_map()` are the same call written two ways.\n", + "\n", + "The package has to be importable: either install it (`pip install -e .` from the\n", + "repository root) or run the notebook from a directory where `import palsparserpy`\n", + "resolves." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "import palsparserpy as pp\n", + "\n", + "lattice_dir = os.path.join(\"..\", \"lattice_files\")\n", + "ex_file = os.path.join(lattice_dir, \"ex.pals.yaml\")\n", + "expand_file = os.path.join(lattice_dir, \"expand.pals.yaml\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Reading a lattice from a YAML file\n", + "\n", + "Use `tree = pp.parse_file(filename)` to read any YAML file. To read a PALS file\n", + "*with* lattice expansion, use `pp.parse_and_expand_pals` instead." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "tree = pp.parse_file(ex_file)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Printing a tree\n", + "\n", + "To print a tree to the console, use `pp.to_yaml_string(tree)` -- or just\n", + "`print(tree)`, which does the same thing." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "print(pp.to_yaml_string(tree))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Type checking\n", + "\n", + "The root node of `ex.pals.yaml` is the `PALS` map, so `pp.is_map(tree)` is\n", + "`True`. The lattice contents live under the `facility` node of the `PALS` root,\n", + "which is a sequence." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "facility = tree[\"PALS\"][\"facility\"]\n", + "pp.is_sequence(facility)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Accessing a sequence\n", + "\n", + "Elements in a sequence may be accessed by their index, counting from zero." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "seq1 = facility[0]\n", + "print(pp.to_yaml_string(seq1))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Accessing a map\n", + "\n", + "Elements in a map may be accessed by their key." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "map1 = seq1[\"constants\"][\"a_const\"]\n", + "print(pp.to_yaml_string(map1))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Finding a node's parent\n", + "\n", + "Use `pp.get_parent(node)` to move up the tree. It returns the parent `YAMLNode`,\n", + "or raises if `node` is the root (which has no parent). Here the parent of the\n", + "`a_const` node above is the `constants` map that contains it." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "parent_node = pp.get_parent(map1)\n", + "print(pp.to_yaml_string(parent_node))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Adding new elements\n", + "\n", + "Add a new sequence element to the `facility` containing `new_map: {apples: 5}`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "new_map_entry = facility.add_map()\n", + "map_node = new_map_entry.add_map(key=\"new_map\")\n", + "map_node.add_scalar(\"5\", key=\"apples\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Add another sequence element to the `facility` containing a `magnet_list`:\n", + "\n", + "```yaml\n", + "- magnet_list:\n", + " - magnet1\n", + " - magnet2\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "magnets_entry = facility.add_map()\n", + "sequence = magnets_entry.add_sequence(key=\"magnet_list\")\n", + "sequence.add_scalar(\"magnet1\")\n", + "sequence.add_scalar(\"magnet2\", index=0)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Writing the tree to a file\n", + "\n", + "Use `pp.write_yaml(tree, filename)` to write the edited tree to a file." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "pp.write_yaml(tree, expand_file)\n", + "print(\"Wrote tree to 'expand.pals.yaml'\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## The final modified tree" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "print(pp.to_yaml_string(tree))" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "version": "3.9" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/manipulate_tree.py b/examples/manipulate_tree.py new file mode 100644 index 0000000..aaa72ac --- /dev/null +++ b/examples/manipulate_tree.py @@ -0,0 +1,72 @@ +"""Read a PALS file and then manipulate the resulting tree structure in +memory.""" + +import os +import sys + +# So the examples run from a checkout without installing it first. +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import palsparserpy as pp # noqa: E402 + +lattice_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", + "lattice_files") +ex_file = os.path.join(lattice_dir, "ex.pals.yaml") +expand_file = os.path.join(lattice_dir, "expand.pals.yaml") + +print("============ Printing Developer Information ============") + +# reading a lattice from a yaml file +print("""Use the function 'tree = parse_file(filename)' to read a YAML file. +This reads in any YAML file. To read in a PALS file with lattice expansion, +use the function parse_and_expand_pals.""") + +tree = pp.parse_file(ex_file) + +# printing to terminal +print("To print a tree to console, use the 'pp.to_yaml_string(tree)' function.") +print(pp.to_yaml_string(tree), "\n") + +# type checking +print("The root node of 'ex.pals.yaml' is the 'PALS' map, so is_map(tree) =", + pp.is_map(tree)) + +# The lattice contents live under the 'facility' node of the 'PALS' root. +facility = tree["PALS"]["facility"] +print("The 'facility' node is a sequence, so is_sequence(facility) =", + pp.is_sequence(facility)) + +# accessing a sequence +print("Elements in a sequence may be accessed by their index.") +first_ele = facility[0] +print("The first element of 'facility' is: \n", pp.to_yaml_string(first_ele)) + +# accessing a map +print("Elements in a map may be accessed by their key.") +a_const = first_ele["constants"]["a_const"] +print("The 'a_const' constant has the value:\n ", pp.to_yaml_string(a_const)) + +# add a new sequence element to the facility containing new_map: {apples: 5} +print("Adding a new element '-apples: 5' to facility.") +new_map_entry = facility.add_map() +map_node = new_map_entry.add_map(key="new_map") +map_node.add_scalar("5", key="apples") + +# add a new sequence element to the facility containing magnets +print("Adding a new element") +print(" - magnet_list:") +print(" - magnet1") +print(" - magnet2") +print("to facility.\n") +magnets_entry = facility.add_map() +sequence = magnets_entry.add_sequence(key="magnet_list") +sequence.add_scalar("magnet1") +sequence.add_scalar("magnet2", index=0) + +# writing trees to files +print("Use 'write_yaml(tree, filename)' to write the edited tree to a file.") +pp.write_yaml(tree, expand_file) +print("Wrote tree to 'expand.pals.yaml'\n\n") + +print("========== Printing Final Modified Tree ==========") +print(pp.to_yaml_string(tree)) diff --git a/examples/match_names.py b/examples/match_names.py new file mode 100644 index 0000000..dcf0c89 --- /dev/null +++ b/examples/match_names.py @@ -0,0 +1,66 @@ +"""Finding named constructs by name. + +match_names implements PALS name matching: + + [{lattice}>>>][{branch}>>][{kind}::]{name}[>{group}.{sub}. ... .{parameter}] + +{lattice}, {branch}, {name} are PCRE2 patterns (anchored whole-name matches); +{kind} and the dotted parameter path are matched exactly. It returns the nodes the +string resolves to -- elements, parameter groups, parameters, constants, or +variables -- which live in the tree you searched. Elements are searched for in the +`full_expanded` view; constants and variables are not part of the lattice, so they +are found in `adjunct`. +""" + +import os +import sys + +# So the examples run from a checkout without installing it first. +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import palsparserpy as pp # noqa: E402 + +ex_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", + "lattice_files", "ex.pals.yaml") +lat = pp.parse_and_expand_pals(ex_file) + + +def label(node): + """A node as "key = value", omitting the value for container nodes.""" + if pp.is_map(node) or pp.is_sequence(node): + return pp.node_key(node) + return f"{pp.node_key(node)} = {node.value}" + + +def show_matches(query, tree=None): + tree = lat.full_expanded if tree is None else tree + matches = pp.match_names(tree, query) + print(f' "{query}" β†’ {len(matches)} match(es)') + for node in matches: + print(" ", label(node)) + + +# -- Element parameters -------------------------------------------------------- +print("Element parameters:") +show_matches("Q1a>length") # a named element's length +show_matches("Quadrupole::.*>length") # restrict to a kind with `::` +show_matches("lat1>>>Q1a>length") # restrict to a lattice with `>>>` + +# -- Whole elements ------------------------------------------------------------ +# Drop the parameter path to match the element node itself. +print("\nElements:") +show_matches("Q1a") + +# -- Constants and variables --------------------------------------------------- +# A bare name also matches constants/variables by name. These are defined at +# facility level rather than inside the lattice, so search the adjunct view. +print("\nConstants and variables:") +show_matches("a_const", tree=lat.adjunct) +show_matches(".*_var", tree=lat.adjunct) + +# -- Editing matched parameters in place --------------------------------------- +# The returned nodes belong to lat.full_expanded, so they can be modified directly. +print("\nEditing in place:") +for node in pp.match_names(lat.full_expanded, "Q1a>direction"): + node.set_scalar("1") +show_matches("Q1a>direction") diff --git a/examples/node_correspondence.py b/examples/node_correspondence.py new file mode 100644 index 0000000..c0dd473 --- /dev/null +++ b/examples/node_correspondence.py @@ -0,0 +1,63 @@ +"""Mapping corresponding nodes across the derivation-chain trees of a PALS +lattice. + +parse_and_expand_pals returns five views of a lattice; four of them -- `original`, +`combined`, `full_expanded` and `adjunct` -- form the derivation chain that +node_correspondence connects: given any node, it hands back the nodes it +corresponds to in the others. (`expanded` takes no part: it is a pruned copy of +`full_expanded`, so its nodes are found by path.) The correspondence is computed +from provenance recorded as the trees are derived from one another, so it is exact +even where expansion duplicates a node (a `repeat`, an `inherit`, a scalar +substitution, a fork). +""" + +import os +import sys + +# So the examples run from a checkout without installing it first. +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import palsparserpy as pp # noqa: E402 + +ex_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", + "lattice_files", "ex.pals.yaml") + +lat = pp.parse_and_expand_pals(ex_file) +corr = pp.node_correspondence(lat) + +print(f"Built a correspondence over {len(corr)} nodes.\n") + +# -- A node outside the lattice is left over, not expanded --------------------- +# 'a_const' is defined at the top level of the facility and the lattice never +# refers to it, so expansion leaves it behind: it appears once in `original`, once +# in `combined` and once in `adjunct`, and not at all in `full_expanded`. +a_const = lat.combined["PALS"]["facility"][0]["constants"]["a_const"] +entry = corr[a_const] + +print("Correspondence of the 'a_const' node:") +print(" in original:", [pp.to_yaml_string(n) for n in entry.original]) +print(" in combined:", [pp.to_yaml_string(n) for n in entry.combined]) +print(" in adjunct:", [pp.to_yaml_string(n) for n in entry.adjunct]) +print(" in full_expanded:", [pp.to_yaml_string(n) for n in entry.full_expanded], + " (empty)") +print() + +# The map can be queried from *any* of those four trees and returns the same +# equivalence class -- here we start from the node in the original tree. +assert corr[entry.original[0]] == entry +print("Looking the class up from the original node gives the same result.\n") + +# -- A node duplicated by expansion maps one-to-many --------------------------- +# Find a combined node that expansion turned into several expanded copies (for +# ex.pals.yaml this is the 'repeat'ed sub-line unrolled inside inj_line). +one_to_many = None +for node, e in corr.items(): + if len(e.combined) == 1 and node == e.combined[0] and len(e.full_expanded) > 1: + one_to_many = e + break + +if one_to_many is not None: + print("A combined node that expansion duplicated:") + print(" combined source:", pp.to_yaml_string(one_to_many.combined[0])) + print(f" -> {len(one_to_many.full_expanded)} corresponding full_expanded " + "nodes.") diff --git a/examples/pals_to_bmad.py b/examples/pals_to_bmad.py new file mode 100644 index 0000000..c3c6e84 --- /dev/null +++ b/examples/pals_to_bmad.py @@ -0,0 +1,15 @@ +"""Produces a file "PALSParserPy/lattice_files/bta.pals_out.bmad".""" + +import os +import sys + +# So the examples run from a checkout without installing it first. +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import palsparserpy as pp # noqa: E402 + +pals_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..") +ex_file = os.path.join(pals_dir, "lattice_files", "bta.pals.yaml") +out_file = os.path.join(pals_dir, "lattice_files", "bta.pals_out.bmad") + +pp.write_bmad_file(pp.pals_to_bmad(pp.parse_file(ex_file)), out_file) diff --git a/examples/pals_to_madx.py b/examples/pals_to_madx.py new file mode 100644 index 0000000..7c845c2 --- /dev/null +++ b/examples/pals_to_madx.py @@ -0,0 +1,15 @@ +"""Produces a file "PALSParserPy/lattice_files/bta.pals_out.madx".""" + +import os +import sys + +# So the examples run from a checkout without installing it first. +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import palsparserpy as pp # noqa: E402 + +pals_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..") +ex_file = os.path.join(pals_dir, "lattice_files", "bta.pals.yaml") +out_file = os.path.join(pals_dir, "lattice_files", "bta.pals_out.madx") + +pp.write_madx_file(pp.pals_to_madx(pp.parse_file(ex_file)), out_file) diff --git a/examples/read_pals.py b/examples/read_pals.py new file mode 100644 index 0000000..5fe7e86 --- /dev/null +++ b/examples/read_pals.py @@ -0,0 +1,36 @@ +"""Read a PALS file and print the five views of the lattice it creates in +memory.""" + +import os +import sys + +# So the examples run from a checkout without installing it first. +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import palsparserpy as pp # noqa: E402 + +lattice_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", + "lattice_files") +file_name = os.path.join(lattice_dir, "ex.pals.yaml") +root_lattice = "" + +lat = pp.parse_and_expand_pals(file_name, root_lattice) + +print("Printing original lattice information:") +print(pp.to_yaml_string(lat.original)) +print("\n" + "-" * 50) + +print("Printing combined lattice information:") +print(pp.to_yaml_string(lat.combined)) +print("\n" + "-" * 50) + +print("Printing expanded lattice information:") +print(pp.to_yaml_string(lat.expanded)) +print("\n" + "-" * 50) + +print("Printing full expanded lattice information:") +print(pp.to_yaml_string(lat.full_expanded)) +print("\n" + "-" * 50) + +print("Printing what expansion left over:") +print(pp.to_yaml_string(lat.adjunct)) diff --git a/lattice_files/bta.pals.yaml b/lattice_files/bta.pals.yaml new file mode 100644 index 0000000..2158cf1 --- /dev/null +++ b/lattice_files/bta.pals.yaml @@ -0,0 +1,1482 @@ +PALS: + notes: + - "Translated from Bmad lattice file: bta_oct30.bmad" + + extension_labels: + names: + BmadP: Bmad element data with no PALS equivalent + prefixes: + Bmad_: Bmad data with no PALS equivalent + + Bmad_overlays: + BF_K1: pmom,scale, + BD_K1: pmom,scale, + AF_K1: pmom,scale, + AD_K1: pmom,scale, + CF_K1: pmom,scale, + CD_K1: pmom,scale, + L20_BLW_P: i, + L20_BLW_A: i, + + #--------------------------------------------------------------------------------------- + # Constants + + facility: + + - beginning_b1: + kind: BeginningEle + ReferenceP: + pc_ref: 2.16005801671522903E+009 + species_ref: "proton" + TwissP: + beta_a: 1.25509383999999997E+001 + beta_b: 4.07294188697497539E+000 + alpha_a: 1.82499103066633417E+000 + alpha_b: -6.49975677085136816E-001 + eta_x: 2.72079607809613755E+000 + etap_x: -4.22260065862766376E-001 + BmadP: + Bmad_key: Beginning_Ele + + - btastart: + kind: Marker + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - dhf6a: + kind: Bend + length: 1.25000000000000000E+000 + BendP: + g_ref: -5.71919999999999998E-002 + e2: -7.14899999999999980E-002 + MagneticMultipoleP: + Kn0: -5.71919999999999998E-002 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - drf6a: + kind: Drift + length: 3.94999999999999796E-002 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - dhf6b: + kind: Bend + length: 1.32689999999999997E+000 + BendP: + g_ref: -4.47283141156078062E-002 + e1: -3.32399999999999987E-002 + e2: -7.14899999999999980E-002 + MagneticMultipoleP: + Kn0: -4.47283141156078132E-002 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - drf6b: + kind: Drift + length: 4.06399999999999983E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - pueh001: + kind: Instrument + ApertureP: + shape: RECTANGULAR + location: EXIT_END + BmadP: + Bmad_key: Monitor + + - dr001: + kind: Drift + length: 1.25180000000000025E+000 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - mw006: + kind: Marker + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - dr006: + kind: Drift + length: 4.32599999999999929E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - dv007: + kind: Kicker + length: 2.28599999999999998E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + BmadP: + Bmad_key: VKicker + + - dr007: + kind: Drift + length: 1.60600000000000021E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - qv1: + kind: Quadrupole + length: 5.58799999999999963E-001 + MagneticMultipoleP: + Kn1: -5.43789268756698974E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - drq1: + kind: Drift + length: 4.03150000000000286E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - dh1a: + kind: Bend + length: 2.66653672973886480E-001 + BendP: + g_ref: 6.81858224461081158E-002 + edge1_int: 2.06500000000000017E-002 + edge2_int: 2.06500000000000017E-002 + e1: 9.09100000000000012E-003 + e2: 9.09100000000000012E-003 + MagneticMultipoleP: + Kn0: 6.81858224461081019E-002 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - dh1b: + kind: Bend + length: 2.66653672973886480E-001 + BendP: + g_ref: 6.81858224461081158E-002 + edge1_int: 2.06500000000000017E-002 + edge2_int: 2.06500000000000017E-002 + e1: 9.09100000000000012E-003 + e2: 9.09100000000000012E-003 + MagneticMultipoleP: + Kn0: 6.81858224461081019E-002 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - drd1: + kind: Drift + length: 3.53953999999999991E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - qh2a: + kind: Quadrupole + length: 5.58799999999999963E-001 + MagneticMultipoleP: + Kn1: 1.04929930675140226E+000 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - drq2a: + kind: Drift + length: 1.41195999999999655E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - qh2b: + kind: Quadrupole + length: 5.58799999999999963E-001 + MagneticMultipoleP: + Kn1: 1.04929930675140226E+000 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - drq2b: + kind: Drift + length: 4.59900000000000087E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - xf019: + kind: Marker + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - dr019: + kind: Drift + length: 3.25699999999999323E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - qv3: + kind: Quadrupole + length: 5.58799999999999963E-001 + MagneticMultipoleP: + Kn1: -1.42121025700014481E+000 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - drq3: + kind: Drift + length: 3.70600000000000374E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - foil024: + kind: Marker + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - dr024a: + kind: Drift + length: 1.21000000000003466E-002 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - dv030: + kind: Kicker + length: 2.43799999999999989E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + BmadP: + Bmad_key: VKicker + + - dr024b: + kind: Drift + length: 6.88099999999999490E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - qh4: + kind: Quadrupole + length: 5.58799999999999963E-001 + MagneticMultipoleP: + Kn1: 1.37991746390738079E+000 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - drq4: + kind: Drift + length: 4.86175000000001134E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - dh2: + kind: Bend + length: 2.42384999999999984E+000 + BendP: + g_ref: 1.13744662417228803E-001 + edge1_int: 2.89100000000000017E-002 + edge2_int: 2.89100000000000017E-002 + e1: 5.05800000000000000E-002 + e2: 5.05800000000000000E-002 + MagneticMultipoleP: + Kn0: 1.13744662417228790E-001 + Kn1: 1.95640819357633544E-004 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - drd2: + kind: Drift + length: 4.71424999999999095E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - qv5: + kind: Quadrupole + length: 4.98499999999999999E-001 + MagneticMultipoleP: + Kn1: -1.33548336310089846E-002 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - drq5: + kind: Drift + length: 1.18349999999999983E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - puev046: + kind: Instrument + ApertureP: + shape: RECTANGULAR + location: EXIT_END + BmadP: + Bmad_key: Monitor + + - dr046: + kind: Drift + length: 8.64675000000002303E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - dh3: + kind: Bend + length: 2.42384999999999984E+000 + BendP: + g_ref: 1.13744662417228803E-001 + edge1_int: 2.89100000000000017E-002 + edge2_int: 2.89100000000000017E-002 + e1: 5.05800000000000000E-002 + e2: 5.05800000000000000E-002 + MagneticMultipoleP: + Kn0: 1.13744662417228790E-001 + Kn1: 1.95640819357633544E-004 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - drd3: + kind: Drift + length: 4.89724999999997301E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - qh6: + kind: Quadrupole + length: 4.98499999999999999E-001 + MagneticMultipoleP: + Kn1: 9.74351350912201819E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - drq6: + kind: Drift + length: 4.71450000000000813E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - xf059: + kind: Marker + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - dr059: + kind: Drift + length: 4.69899999999999096E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - mw060: + kind: Marker + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - dr060: + kind: Drift + length: 4.60150000000002279E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - qv7: + kind: Quadrupole + length: 4.98499999999999999E-001 + MagneticMultipoleP: + Kn1: -8.67509645059652779E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - drq7y: + kind: Drift + length: 4.25075000000000003E+000 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - mk077: + kind: Marker + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - drq7z: + kind: Drift + length: 4.25075000000000003E+000 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - qh8: + kind: Quadrupole + length: 4.98499999999999999E-001 + MagneticMultipoleP: + Kn1: 3.18827954728424956E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - drq8: + kind: Drift + length: 7.34099999999999975E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - dh4a: + kind: Bend + length: 2.66650810302187846E-001 + BendP: + g_ref: 3.20269043635076911E-002 + edge1_int: 2.06500000000000017E-002 + edge2_int: 2.06500000000000017E-002 + e1: 4.27000000000000036E-003 + e2: 4.27000000000000036E-003 + MagneticMultipoleP: + Kn0: 3.20269043635076842E-002 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - dh4b: + kind: Bend + length: 2.66650810302187846E-001 + BendP: + g_ref: 3.20269043635076911E-002 + edge1_int: 2.06500000000000017E-002 + edge2_int: 2.06500000000000017E-002 + e1: 4.27000000000000036E-003 + e2: 4.27000000000000036E-003 + MagneticMultipoleP: + Kn0: 3.20269043635076842E-002 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - drd4: + kind: Drift + length: 1.80789999999999873E+000 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - qv9: + kind: Quadrupole + length: 4.98499999999999999E-001 + MagneticMultipoleP: + Kn1: -7.92578808208909136E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - drq9a: + kind: Drift + length: 6.70250000000003010E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - dv120: + kind: Kicker + length: 2.43799999999999989E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + BmadP: + Bmad_key: VKicker + + - drq9b: + kind: Drift + length: 1.56704999999999495E+000 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - qh10: + kind: Quadrupole + length: 4.98499999999999999E-001 + MagneticMultipoleP: + Kn1: 6.94294553863339159E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - drq10: + kind: Drift + length: 5.03015000000000256E+000 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - mw125: + kind: Marker + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - dr125: + kind: Drift + length: 4.30499999999998662E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - dh127: + kind: Kicker + length: 2.43799999999999989E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + BmadP: + Bmad_key: HKicker + + - dr127: + kind: Drift + length: 2.96399999999998054E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - puev129: + kind: Instrument + ApertureP: + shape: RECTANGULAR + location: EXIT_END + BmadP: + Bmad_key: Monitor + + - dr129: + kind: Drift + length: 1.18349999999999983E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - qv11: + kind: Quadrupole + length: 4.98499999999999999E-001 + MagneticMultipoleP: + Kn1: -3.67182823008346426E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - drq11: + kind: Drift + length: 3.01295000000000002E+000 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - mk139: + kind: Marker + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - dr139: + kind: Drift + length: 5.52500000000001990E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - dr141: + kind: Drift + length: 4.18200000000001848E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - pueh143: + kind: Instrument + ApertureP: + shape: RECTANGULAR + location: EXIT_END + BmadP: + Bmad_key: Monitor + + - dr143: + kind: Drift + length: 1.18349999999999983E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - qh12: + kind: Quadrupole + length: 4.98499999999999999E-001 + MagneticMultipoleP: + Kn1: 4.86897444291368886E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - drq12: + kind: Drift + length: 3.78024999999999878E+000 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - mk156: + kind: Marker + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - dr156: + kind: Drift + length: 4.30600000000001981E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - dh158: + kind: Kicker + length: 2.43799999999999989E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + BmadP: + Bmad_key: HKicker + + - dr158: + kind: Drift + length: 2.96399999999998054E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - puev160: + kind: Instrument + ApertureP: + shape: RECTANGULAR + location: EXIT_END + BmadP: + Bmad_key: Monitor + + - dr160: + kind: Drift + length: 1.18349999999999983E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - qv13: + kind: Quadrupole + length: 4.98499999999999999E-001 + MagneticMultipoleP: + Kn1: -9.89545006750720213E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - drq13: + kind: Drift + length: 5.96950000000003200E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - mw166: + kind: Marker + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - dr166: + kind: Drift + length: 5.52399999999998670E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - dr168: + kind: Drift + length: 4.18299999999998062E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - pueh170: + kind: Instrument + ApertureP: + shape: RECTANGULAR + location: EXIT_END + BmadP: + Bmad_key: Monitor + + - dr170: + kind: Drift + length: 1.18349999999999983E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - qh14: + kind: Quadrupole + length: 4.98499999999999999E-001 + MagneticMultipoleP: + Kn1: 7.48989790597664018E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - drq14: + kind: Drift + length: 5.06749999999998257E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - dh5a: + kind: Bend + length: 6.22928612672511539E-001 + BendP: + g_ref: -1.13006528465579306E-001 + edge1_int: 2.06500000000000017E-002 + edge2_int: 2.06500000000000017E-002 + e1: -3.51974999999999996E-002 + e2: -3.51974999999999996E-002 + MagneticMultipoleP: + Kn0: -1.13006528465579306E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - dh5b: + kind: Bend + length: 6.22928612672511539E-001 + BendP: + g_ref: -1.13006528465579306E-001 + edge1_int: 2.06500000000000017E-002 + edge2_int: 2.06500000000000017E-002 + e1: -3.51974999999999996E-002 + e2: -3.51974999999999996E-002 + MagneticMultipoleP: + Kn0: -1.13006528465579306E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - drd5: + kind: Drift + length: 4.51600000000000779E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - dv181: + kind: Kicker + length: 2.43799999999999989E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + BmadP: + Bmad_key: VKicker + + - dr181: + kind: Drift + length: 3.79799999999999582E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - xf183: + kind: Marker + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - dr183: + kind: Drift + length: 5.25500000000003520E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - qv15: + kind: Quadrupole + length: 5.58799999999999963E-001 + MagneticMultipoleP: + Kn1: -7.19337654398680026E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - m1: + kind: Drift + length: 2.26060000000000016E+000 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - l20sptm1: + kind: Bend + length: 1.23095438501483279E+000 + BendP: + g_ref: 4.45711073195769816E-002 + e1: 2.74324999999999986E-002 + e2: 2.74324999999999986E-002 + MagneticMultipoleP: + Kn0: 4.45711073195769886E-002 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - l20sptm2: + kind: Bend + length: 1.23095438501483279E+000 + BendP: + g_ref: 4.45711073195769816E-002 + e1: 2.74324999999999986E-002 + e2: 2.74324999999999986E-002 + MagneticMultipoleP: + Kn0: 4.45711073195769886E-002 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - mm_a01bf: + kind: Bend + length: 2.00664618248814230E+000 + BendP: + g_ref: -1.17122296422273627E-002 + e1: -1.17511504499999992E-002 + e2: -1.17511504499999992E-002 + MagneticMultipoleP: + Kn0: -1.17122296422273627E-002 + Kn1: 4.85045797910831633E-002 + Kn2: 1.01126906712656773E-002 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - d2s: + kind: Drift + length: 6.09514655999999988E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - mm_a02bf: + kind: Bend + length: 2.00664618248814230E+000 + BendP: + g_ref: -1.17122296422273627E-002 + e1: -1.17511504499999992E-002 + e2: -1.17511504499999992E-002 + MagneticMultipoleP: + Kn0: -1.17122296422273627E-002 + Kn1: 4.85045797910831633E-002 + Kn2: 1.01126906712656773E-002 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - dpue: + kind: Drift + length: 2.86999999999999977E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - pue_a02: + kind: Instrument + MetaP: + label: "β€œAGSBPM”" + ApertureP: + shape: RECTANGULAR + location: EXIT_END + BmadP: + Bmad_key: Monitor + + - dhca02: + kind: Kicker + ApertureP: + shape: RECTANGULAR + location: EXIT_END + BmadP: + Bmad_key: HKicker + + - dvca02: + kind: Kicker + ApertureP: + shape: RECTANGULAR + location: EXIT_END + BmadP: + Bmad_key: VKicker + + - d2tx: + kind: Drift + length: 3.22485191999999976E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - mm_a03cd: + kind: Bend + length: 2.38767780201331314E+000 + BendP: + g_ref: -1.17122296301534548E-002 + e1: -1.39825153500000004E-002 + e2: -1.39825153500000004E-002 + MagneticMultipoleP: + Kn0: -1.17122296301534548E-002 + Kn1: -4.86329158212573595E-002 + Kn2: 8.75661659504679314E-003 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - dsq: + kind: Drift + length: 3.71047356000000050E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - qhfv: + kind: Quadrupole + length: 3.90880000000000005E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - qpol: + kind: Quadrupole + length: 3.90880000000000005E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - mm_a04cd: + kind: Bend + length: 2.38767780201331314E+000 + BendP: + g_ref: -1.17122296301534548E-002 + e1: -1.39825153500000004E-002 + e2: -1.39825153500000004E-002 + MagneticMultipoleP: + Kn0: -1.17122296301534548E-002 + Kn1: -4.86329158212573595E-002 + Kn2: 8.75661659504679314E-003 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - pue_a04: + kind: Instrument + MetaP: + label: "β€œAGSBPM”" + ApertureP: + shape: RECTANGULAR + location: EXIT_END + BmadP: + Bmad_key: Monitor + + - d2lx: + kind: Drift + length: 3.22454712000000032E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - mm_a05af: + kind: Bend + length: 2.38767780201331314E+000 + BendP: + g_ref: -1.17122296301534548E-002 + e1: -1.39825153500000004E-002 + e2: -1.39825153500000004E-002 + MagneticMultipoleP: + Kn0: -1.17122296301534548E-002 + Kn1: 4.87019065830381626E-002 + Kn2: 8.72342656462042100E-003 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - d2h_a05: + kind: Drift + length: 2.61927356000000056E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - kckra05: + kind: Bend + length: 1.00000037500009853E+000 + BendP: + g_ref: 2.99999887500012642E-003 + e1: 1.50000000000000003E-003 + e2: 1.50000000000000003E-003 + MagneticMultipoleP: + Kn0: 2.99999887500012642E-003 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - mm_a06af: + kind: Bend + length: 2.38767780201331314E+000 + BendP: + g_ref: -1.17122296301534548E-002 + e1: -1.39825153500000004E-002 + e2: -1.39825153500000004E-002 + MagneticMultipoleP: + Kn0: -1.17122296301534548E-002 + Kn1: 4.87019065830381626E-002 + Kn2: 8.72342656462042100E-003 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - d2l: + kind: Drift + length: 6.09454712000000010E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - mm_a07cd: + kind: Bend + length: 2.38767780201331314E+000 + BendP: + g_ref: -1.17122296301534548E-002 + e1: -1.39825153500000004E-002 + e2: -1.39825153500000004E-002 + MagneticMultipoleP: + Kn0: -1.17122296301534548E-002 + Kn1: -4.86329158212573595E-002 + Kn2: 8.75661659504679314E-003 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - dss: + kind: Drift + length: 4.34427356000000042E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - sxv: + kind: Sextupole + length: 6.55000000000000027E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - mm_a08cd: + kind: Bend + length: 2.38767780201331314E+000 + BendP: + g_ref: -1.17122296301534548E-002 + e1: -1.39825153500000004E-002 + e2: -1.39825153500000004E-002 + MagneticMultipoleP: + Kn0: -1.17122296301534548E-002 + Kn1: -4.86329158212573595E-002 + Kn2: 8.75661659504679314E-003 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - pue_a08: + kind: Instrument + MetaP: + label: "β€œAGSBPM”" + ApertureP: + shape: RECTANGULAR + location: EXIT_END + BmadP: + Bmad_key: Monitor + + - dhca08: + kind: Kicker + ApertureP: + shape: RECTANGULAR + location: EXIT_END + BmadP: + Bmad_key: HKicker + + - dvca08: + kind: Kicker + ApertureP: + shape: RECTANGULAR + location: EXIT_END + BmadP: + Bmad_key: VKicker + + - mm_a09bf: + kind: Bend + length: 2.00664618248814230E+000 + BendP: + g_ref: -1.17122296422273627E-002 + e1: -1.17511504499999992E-002 + e2: -1.17511504499999992E-002 + MagneticMultipoleP: + Kn0: -1.17122296422273627E-002 + Kn1: 4.85045797910831633E-002 + Kn2: 1.01126906712656773E-002 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - mm_a10bf: + kind: Bend + length: 2.00664618248814230E+000 + BendP: + g_ref: -1.17122296422273627E-002 + e1: -1.17511504499999992E-002 + e2: -1.17511504499999992E-002 + MagneticMultipoleP: + Kn0: -1.17122296422273627E-002 + Kn1: 4.85045797910831633E-002 + Kn2: 1.01126906712656773E-002 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - d10: + kind: Drift + length: 1.52383921800000000E+000 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - mm_a11bd: + kind: Bend + length: 2.00664618248814230E+000 + BendP: + g_ref: -1.17122296422273627E-002 + e1: -1.17511504499999992E-002 + e2: -1.17511504499999992E-002 + MagneticMultipoleP: + Kn0: -1.17122296422273627E-002 + Kn1: -4.84198946267085026E-002 + Kn2: 1.00033880343072031E-002 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - mm_a12bd: + kind: Bend + length: 2.00664618248814230E+000 + BendP: + g_ref: -1.17122296422273627E-002 + e1: -1.17511504499999992E-002 + e2: -1.17511504499999992E-002 + MagneticMultipoleP: + Kn0: -1.17122296422273627E-002 + Kn1: -4.84198946267085026E-002 + Kn2: 1.00033880343072031E-002 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - pue_a12: + kind: Instrument + MetaP: + label: "β€œAGSBPM”" + ApertureP: + shape: RECTANGULAR + location: EXIT_END + BmadP: + Bmad_key: Monitor + + - dhca12: + kind: Kicker + ApertureP: + shape: RECTANGULAR + location: EXIT_END + BmadP: + Bmad_key: HKicker + + - dvca12: + kind: Kicker + ApertureP: + shape: RECTANGULAR + location: EXIT_END + BmadP: + Bmad_key: VKicker + + - mm_a13cf: + kind: Bend + length: 2.38767780201331314E+000 + BendP: + g_ref: -1.17122296301534548E-002 + e1: -1.39825153500000004E-002 + e2: -1.39825153500000004E-002 + MagneticMultipoleP: + Kn0: -1.17122296301534548E-002 + Kn1: 4.87425372073730032E-002 + Kn2: 8.84527783214509103E-003 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - sxh: + kind: Sextupole + length: 6.55000000000000027E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - mm_a14cf: + kind: Bend + length: 2.38767780201331314E+000 + BendP: + g_ref: -1.17122296301534548E-002 + e1: -1.39825153500000004E-002 + e2: -1.39825153500000004E-002 + MagneticMultipoleP: + Kn0: -1.17122296301534548E-002 + Kn1: 4.87425372073730032E-002 + Kn2: 8.84527783214509103E-003 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - pue_a14: + kind: Instrument + MetaP: + label: "β€œAGSBPM”" + ApertureP: + shape: RECTANGULAR + location: EXIT_END + BmadP: + Bmad_key: Monitor + + - mm_a15ad: + kind: Bend + length: 2.38767780201331314E+000 + BendP: + g_ref: -1.17122296301534548E-002 + e1: -1.39825153500000004E-002 + e2: -1.39825153500000004E-002 + MagneticMultipoleP: + Kn0: -1.17122296301534548E-002 + Kn1: -4.86098096672228017E-002 + Kn2: 8.60965699526637478E-003 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - d2h: + kind: Drift + length: 7.61927356000000056E-001 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - mwa15: + kind: Marker + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + - end_b0: + kind: Marker + ApertureP: + shape: RECTANGULAR + location: EXIT_END + + #--------------------------------------------------------------------------------------- + # BeamLines and Lattices + + - bta_line: + kind: BeamLine + line: [ beginning_b0, btastart, dhf6a, drf6a, dhf6b, drf6b, pueh001, dr001, mw006, + dr006, dv007, dr007, qv1, drq1, dh1a, dh1b, drd1, qh2a, drq2a, qh2b, drq2b, xf019, + dr019, qv3, drq3, foil024, dr024a, dv030, dr024b, qh4, drq4, dh2, drd2, qv5, drq5, + puev046, dr046, dh3, drd3, qh6, drq6, xf059, dr059, mw060, dr060, qv7, drq7y, mk077, + drq7z, qh8, drq8, dh4a, dh4b, drd4, qv9, drq9a, dv120, drq9b, qh10, drq10, mw125, + dr125, dh127, dr127, puev129, dr129, qv11, drq11, mk139, dr139, dr141, pueh143, dr143, + qh12, drq12, mk156, dr156, dh158, dr158, puev160, dr160, qv13, drq13, mw166, dr166, + dr168, pueh170, dr170, qh14, drq14, dh5a, dh5b, drd5, dv181, dr181, xf183, dr183, + qv15, m1, l20sptm1, l20sptm2, mm_a01bf, d2s, mm_a02bf, dpue, pue_a02, dhca02, dvca02, + d2tx, mm_a03cd, dsq, qhfv, qpol, dsq, mm_a04cd, dpue, pue_a04, d2lx, mm_a05af, d2h_a05, + kckra05, d2h_a05, mm_a06af, d2l, mm_a07cd, dss, sxv, dss, mm_a08cd, dpue, pue_a08, + dhca08, dvca08, d2tx, mm_a09bf, d2s, mm_a10bf, d10, d10, mm_a11bd, d2s, mm_a12bd, + dpue, pue_a12, dhca12, dvca12, d2tx, mm_a13cf, dss, sxh, dss, mm_a14cf, dpue, pue_a14, + d2lx, mm_a15ad, d2h, mwa15, end_b0,] + + - machine: + kind: Lattice + branches: + - bta_line: + periodic: false + + #--------------------------------------------------------------------------------------- + # Constants + + - constants: + - MOM: 2.16005801671522901E+000 + - K1BFM2: -1.03702999999999996E-005 + - K1BFM1: -2.79087000000000020E-004 + - K1BF0: 4.85773999999999997E-002 + - K1BF1: 4.18073399999999981E-005 + - K1BF2: -1.45469600000000004E-005 + - K1BF3: 1.90478100000000007E-006 + - K1BF4: -1.18469399999999995E-007 + - K1BF5: 3.51615299999999982E-009 + - K1BF6: -4.08535200000000026E-011 + - K1BDM2: 1.08550000000000003E-005 + - K1BDM1: 2.83917999999999974E-004 + - K1BD0: -4.85319999999999988E-002 + - K1BD1: -4.32893999999999993E-005 + - K1BD2: 1.49026899999999998E-005 + - K1BD3: -1.93513499999999981E-006 + - K1BD4: 1.19517799999999988E-007 + - K1BD5: -3.52355499999999983E-009 + - K1BD6: 4.07089199999999978E-011 + - K1AFM2: -9.96282000000000027E-006 + - K1AFM1: -2.71244000000000021E-004 + - K1AF0: 4.87708999999999990E-002 + - K1AF1: 4.25196999999999988E-005 + - K1AF2: -1.50273100000000000E-005 + - K1AF3: 1.99004399999999995E-006 + - K1AF4: -1.24748900000000009E-007 + - K1AF5: 3.72396999999999991E-009 + - K1AF6: -4.33033199999999989E-011 + - K1ADM2: 1.04942000000000003E-005 + - K1ADM1: 2.77647000000000007E-004 + - K1AD0: -4.87186999999999967E-002 + - K1AD1: -4.42231599999999976E-005 + - K1AD2: 1.54320600000000015E-005 + - K1AD3: -2.02356599999999990E-006 + - K1AD4: 1.25812100000000007E-007 + - K1AD5: -3.72654400000000028E-009 + - K1AD6: 4.30539500000000015E-011 + - K1CFM2: 1.93892000000000006E-005 + - K1CFM1: 3.72629999999999988E-004 + - K1CF0: 4.85355999999999982E-002 + - K1CF1: 1.56502900000000009E-005 + - K1CF2: -7.01343999999999989E-006 + - K1CF3: 1.12750100000000003E-006 + - K1CF4: -8.21732000000000016E-008 + - K1CF5: 2.77133200000000000E-009 + - K1CF6: -3.56255899999999978E-011 + - K1CDM2: -1.81597000000000017E-005 + - K1CDM1: -3.68214000000000009E-004 + - K1CD0: -4.84682999999999989E-002 + - K1CD1: -1.48046700000000003E-005 + - K1CD2: 6.80257899999999994E-006 + - K1CD3: -1.10179699999999990E-006 + - K1CD4: 8.02484200000000065E-008 + - K1CD5: -2.69635000000000010E-009 + - K1CD6: 3.45699500000000001E-011 + - ANGC: -2.79650307000000008E-002 + - N: 5.00000000000000000E+000 + - BLW_C: 3.58693599999999977E-004 + - NN: 1.00000000000000000E+000 + - Q: 1.00000000000000000E+000 + - K1CD: -4.86329158212332607E-002 + - ANGA: -2.79650307000000008E-002 + - K1CF: 4.87425372073486129E-002 + - K1AD: -4.86098096672406832E-002 + + #--------------------------------------------------------------------------------------- + # Bmad Overlay Translation. + # Note: Translated overlay parameter names use a double underscore of the form __ + + - constants: + BF_K1__pmom: 2.16005801671522901E+000 + - constants: + BF_K1__scale: 1.00039999999999996E+000 + - sets: + - mm_a01bf.Kn1: (BF_K1__scale*(K1BFM3/MOM^3+K1BFM2/MOM^2+K1BFM1/MOM+K1BF0+K1BF1*MOM+K1BF2*MOM^2+K1BF3*MOM^3+K1BF4*MOM^4+K1BF5*MOM^5+K1BF6*MOM^6)) + - sets: + - mm_a02bf.Kn1: (BF_K1__scale*(K1BFM3/MOM^3+K1BFM2/MOM^2+K1BFM1/MOM+K1BF0+K1BF1*MOM+K1BF2*MOM^2+K1BF3*MOM^3+K1BF4*MOM^4+K1BF5*MOM^5+K1BF6*MOM^6)) + - sets: + - mm_a09bf.Kn1: (BF_K1__scale*(K1BFM3/MOM^3+K1BFM2/MOM^2+K1BFM1/MOM+K1BF0+K1BF1*MOM+K1BF2*MOM^2+K1BF3*MOM^3+K1BF4*MOM^4+K1BF5*MOM^5+K1BF6*MOM^6)) + - sets: + - mm_a10bf.Kn1: (BF_K1__scale*(K1BFM3/MOM^3+K1BFM2/MOM^2+K1BFM1/MOM+K1BF0+K1BF1*MOM+K1BF2*MOM^2+K1BF3*MOM^3+K1BF4*MOM^4+K1BF5*MOM^5+K1BF6*MOM^6)) + - constants: + BD_K1__pmom: 2.16005801671522901E+000 + - constants: + BD_K1__scale: 9.99600000000000044E-001 + - sets: + - mm_a11bd.Kn1: (BD_K1__scale*(K1BDM3/MOM^3+K1BDM2/MOM^2+K1BDM1/MOM+K1BD0+K1BD1*MOM+K1BD2*MOM^2+K1BD3*MOM^3+K1BD4*MOM^4+K1BD5*MOM^5+K1BD6*MOM^6)) + - sets: + - mm_a12bd.Kn1: (BD_K1__scale*(K1BDM3/MOM^3+K1BDM2/MOM^2+K1BDM1/MOM+K1BD0+K1BD1*MOM+K1BD2*MOM^2+K1BD3*MOM^3+K1BD4*MOM^4+K1BD5*MOM^5+K1BD6*MOM^6)) + - constants: + AF_K1__pmom: 2.16005801671522901E+000 + - constants: + AF_K1__scale: 1.00039999999999996E+000 + - sets: + - mm_a05af.Kn1: (AF_K1__scale*(K1AFM3/MOM^3+K1AFM2/MOM^2+K1AFM1/MOM+K1AF0+K1AF1*MOM+K1AF2*MOM^2+K1AF3*MOM^3+K1AF4*MOM^4+K1AF5*MOM^5+K1AF6*MOM^6)) + - sets: + - mm_a06af.Kn1: (AF_K1__scale*(K1AFM3/MOM^3+K1AFM2/MOM^2+K1AFM1/MOM+K1AF0+K1AF1*MOM+K1AF2*MOM^2+K1AF3*MOM^3+K1AF4*MOM^4+K1AF5*MOM^5+K1AF6*MOM^6)) + - constants: + AD_K1__pmom: 2.16005801671522901E+000 + - constants: + AD_K1__scale: 9.99600000000000044E-001 + - constants: + CF_K1__pmom: 2.16005801671522901E+000 + - constants: + CF_K1__scale: 1.00039999999999996E+000 + - sets: + - mm_a13cf.Kn1: (CF_K1__scale*(K1CFM3/MOM^3+K1CFM2/MOM^2+K1CFM1/MOM+K1CF0+K1CF1*MOM+K1CF2*MOM^2+K1CF3*MOM^3+K1CF4*MOM^4+K1CF5*MOM^5+K1CF6*MOM^6)) + - constants: + CD_K1__pmom: 2.16005801671522901E+000 + - constants: + CD_K1__scale: 9.99600000000000044E-001 + - sets: + - mm_a03cd.Kn1: (CD_K1__scale*(K1CDM3/MOM^3+K1CDM2/MOM^2+K1CDM1/MOM+K1CD0+K1CD1*MOM+K1CD2*MOM^2+K1CD3*MOM^3+K1CD4*MOM^4+K1CD5*MOM^5+K1CD6*MOM^6)) + - sets: + - mm_a04cd.Kn1: (CD_K1__scale*(K1CDM3/MOM^3+K1CDM2/MOM^2+K1CDM1/MOM+K1CD0+K1CD1*MOM+K1CD2*MOM^2+K1CD3*MOM^3+K1CD4*MOM^4+K1CD5*MOM^5+K1CD6*MOM^6)) + - sets: + - mm_a07cd.Kn1: (CD_K1__scale*(K1CDM3/MOM^3+K1CDM2/MOM^2+K1CDM1/MOM+K1CD0+K1CD1*MOM+K1CD2*MOM^2+K1CD3*MOM^3+K1CD4*MOM^4+K1CD5*MOM^5+K1CD6*MOM^6)) + - sets: + - mm_a07cd.Kn0: 4.18816977381450017E-001 * (ANGC*-1*N*BLW_C*L20_BLW_P__i/(MOM*NN/Q)) + - sets: + - mm_a07cd.Kn1: 4.18816977381450017E-001 * (-K1CD*-1*BLW_C*N*L20_BLW_P__i/(MOM*NN/Q)) + - sets: + - mm_a08cd.Kn1: (CD_K1__scale*(K1CDM3/MOM^3+K1CDM2/MOM^2+K1CDM1/MOM+K1CD0+K1CD1*MOM+K1CD2*MOM^2+K1CD3*MOM^3+K1CD4*MOM^4+K1CD5*MOM^5+K1CD6*MOM^6)) + - sets: + - mm_a08cd.Kn0: 4.18816977381450017E-001 * (ANGC*-1*N*BLW_C*L20_BLW_P__i/(MOM*NN/Q)) + - sets: + - mm_a08cd.Kn1: 4.18816977381450017E-001 * (-K1CD*-1*BLW_C*N*L20_BLW_P__i/(MOM*NN/Q)) + - sets: + - mm_a14cf.Kn1: (CF_K1__scale*(K1CFM3/MOM^3+K1CFM2/MOM^2+K1CFM1/MOM+K1CF0+K1CF1*MOM+K1CF2*MOM^2+K1CF3*MOM^3+K1CF4*MOM^4+K1CF5*MOM^5+K1CF6*MOM^6)) + - sets: + - mm_a14cf.Kn0: 4.18816977381450017E-001 * (ANGC*-1*N*BLW_C*L20_BLW_A__i/(MOM*NN/Q)) + - sets: + - mm_a14cf.Kn1: 4.18816977381450017E-001 * (K1CF*-1*BLW_C*N*L20_BLW_A__i/(MOM*NN/Q)) + - sets: + - mm_a15ad.Kn1: (AD_K1__scale*(K1ADM3/MOM^3+K1ADM2/MOM^2+K1ADM1/MOM+K1AD0+K1AD1*MOM+K1AD2*MOM^2+K1AD3*MOM^3+K1AD4*MOM^4+K1AD5*MOM^5+K1AD6*MOM^6)) + - sets: + - mm_a15ad.Kn0: 4.18816977381450017E-001 * (ANGA*-1*N*BLW_C*L20_BLW_A__i/(MOM*NN/Q)) + - sets: + - mm_a15ad.Kn1: 4.18816977381450017E-001 * (-K1AD*-1*BLW_C*N*L20_BLW_A__i/(MOM*NN/Q)) diff --git a/lattice_files/convert.pals.yaml b/lattice_files/convert.pals.yaml new file mode 100644 index 0000000..c19c59f --- /dev/null +++ b/lattice_files/convert.pals.yaml @@ -0,0 +1,215 @@ +PALS: + facility: + - beg: + kind: BeginningEle + length: 0 + ReferenceP: + species_ref: electron + pc_ref: 3E6 + ParticleP: + x: 1 + y: 2 + z: 3 + px: 4 + py: 5 + pz: 6 + - ap1: + kind: Mask + length: 1 + ApertureP: + x_min: 1 + x_max: 2 + y_min: 3 + y_max: 4 + shape: ELLIPTICAL + location: ENTRANCE_END + vertices: [] + material: "" + thickness: 0 + aperture_shifts_with_body: false + aperture_active: true + - marker1: + kind: Marker + - drift1: + kind: Drift + length: 100 + - ap2: + kind: Mask + length: 0 + ApertureP: + x_width: 2 + y_width: 4 + - s1: + kind: Bend + length: 10 + BendP: + radius_ref: 4.2 # [m] Reference bend radius + Bn0_ref: 1.2 # [T] Reference bend field + e1: 9.1 # [radian] Entrance end pole face rotation with respect to a sector geometry + e2: -10.2 # [radian] Exit end pole face rotation with respect to a sector geometry + e1_rect: 1.1 # [radian] Entrance end pole face rotation with respect to a rectangular geometry + e2_rect: 1.2 # [radian] Exit end pole face rotation with respect to a rectangular geometry + edge1_int: 1.3 # [T*m] Entrance end fringe field integral + edge2_int: 1.4 # [T*m] Exit end fringe field integral + g_ref: 1.5 # [1/m] Reference bend strength = 1/radius_ref + h1: 1.6 # [1/m] Entrance end pole face curvature + h2: 1.7 # [1/m] Exit end pole face curvature + L_chord: 1.8 # [m] Chord length. + L_sagitta: 1.9 # [m] Sagitta length. Output parameter. + tilt_ref: 2.0 # [radian] Reference tilt + BodyShiftP: + x_offset: 9.1 # [m] Offset along x-axis + y_offset: 9.2 # [m] Offset along y-axis + z_offset: 9.3 # [m] Offset along z-axis + x_rot: -1 # [radians] Rotation around x-axis + y_rot: -2 # [radians] Rotation around y-axis + z_rot: -3 # [radians] Rotation around z-axis + - quad1: + kind: Quadrupole + length: 0.01 + MagneticMultipoleP: + tilt1: 1 + Kn1: 32 + Ks1: 10 + ReferenceChangeP: + dE_ref: -0.2 + - sext1: + kind: Sextupole + length: 0.02 + MagneticMultipoleP: + tilt3: 1 + Bn3L: 32 + Bs3L: 10 + - sol1: + kind: Solenoid + SolenoidP: + Ksol: 10 + - multipole1: + kind: Multipole + length: 37.6 + MagneticMultipoleP: + tilt1: 1 + tilt2: 2 + tilt4: 10.3 + Kn9L: 11 + tilt9: 2 + Bn4L: 2 + Bs4L: 3 + Kn2: 1 + Ks2: 10 + Bn1: 3 + Bs1: 9 + Ks9L: 12 + TrackingP: + SciBmad: + tracking_method: scibmad_standard + - patch4: + kind: Patch + length: 0 + PatchP: + x_offset: 1.1 # Offset in x-direction. + y_offset: 1.2 # Offset in y-direction. + z_offset: 1.3 # Offset in z-direction. + x_rot: 1.4 # Rotation around x-axis. + y_rot: 1.5 # Rotation around y-axis. + z_rot: 1.6 # Rotation around z-axis. + flexible: true # Default is False. + # true -> User sets offsets and rot. + # False -> Offsets and rot from branch layout. + ref_coords: ENTRANCE_END # Coordinate system defining the length + user_sets_length: true # Default is False. Is the element length User set? + - rfcav1: + kind: RFCavity + length: 1.3 + RFP: + frequency: 100000 # [Hz] RF frequency + # harmon: 0 # [unitless] RF frequency harmonic number + voltage: 100 # [V] RF voltage + gradient: 0.2 # [V/m] RF gradient + phase: 3.1 # [unitless] RF phase in 0 to 2*pi + multipass_phase: 2 # [unitless] RF Phase added to multipass elements + cavity_type: STANDING_WAVE # [string] Cavity type + num_cells: 1 # [unitles] Number of cavity cells + zero_phase: ACCELERATING # [enum] Sets what phase = 0 means. + + - rfcav2: + kind: RFCavity + length: 1.3 + RFP: + # frequency: 100000 # [Hz] RF frequency + harmon: 2000 # [unitless] RF frequency harmonic number + voltage: 100 # [V] RF voltage + gradient: 0.2 # [V/m] RF gradient + phase: 3.1 # [unitless] RF phase in 0 to 2*pi + multipass_phase: 2 # [unitless] RF Phase added to multipass elements + cavity_type: STANDING_WAVE # [string] Cavity type + num_cells: 1 # [unitles] Number of cavity cells + zero_phase: BELOW_TRANSITION # [enum] Sets what phase = 0 means. + + - rfcav3: + kind: RFCavity + length: 1.3 + RFP: + # frequency: 100000 # [Hz] RF frequency + # harmon: 2000 # [unitless] RF frequency harmonic number + voltage: 100 # [V] RF voltage + gradient: 0.2 # [V/m] RF gradient + phase: 3.1 # [unitless] RF phase in 0 to 2*pi + multipass_phase: 2 # [unitless] RF Phase added to multipass elements + cavity_type: TRAVELING_WAVE # [string] Cavity type + num_cells: 1 # [unitles] Number of cavity cells + zero_phase: ABOVE_TRANSITION # [enum] Sets what phase = 0 means. + - beambeam1: + kind: BeamBeam + length: 0.2 + BeamBeamP: + sigma_x: 1 # [m] The horizontal beam size of the opposite beam. + sigma_y: 2 # [m] The vertical beam size of the opposite beam. + sigma_z: 3 # [m] The longitudinal beam size of the opposite beam. + alpha_x: 4 # [unitless] The horizontal Twiss parameter alpha at interaction point. + beta_x: 5 # [m] The horizontal Twiss parameter beta at interaction point. + alpha_y: 6 # [unitless] The vertical Twiss parameter alpha at interaction point. + beta_y: 7 # [m] The vertical Twiss parameter beta at interaction point. + charge: 1 # [unitless] The charge of the opposite beam. + energy: 2E10 # [eV] The total energy in eV of the opposite beam. + N_particle: 1E3 # [unitless] Number of particles in the opposite beam. + - ring: + kind: BeamLine + line: + - beg: + kind: BeginningEle + length: 0 + ReferenceP: + species_ref: electron + pc_ref: 3E6 + ParticleP: + x: 1 + y: 2 + z: 3 + px: 4 + py: 5 + pz: 6 + - marker1 + - ap1 + - drift1 + - ap2 + - s1 + - quad1 + - sext1: + kind: Sextupole + length: 0.02 + MagneticMultipoleP: + tilt3: 1 + Bn3L: 32 + Bs3L: 10t1 + - sol1 + - multipole1 + - patch1 + - rfcav1 + - rfcav2 + - rfcav3 + - beambeam1 + - lat: + kind: Lattice + branches: + - ring diff --git a/lattice_files/ex.pals.yaml b/lattice_files/ex.pals.yaml new file mode 100644 index 0000000..31d9056 --- /dev/null +++ b/lattice_files/ex.pals.yaml @@ -0,0 +1,42 @@ +PALS: + facility: + + - constants: + a_const: 0.3 * r_electron + b_const: 0.45 + + - variables: + a_var: a_const^2 + b_var: 0.37 * atan2(0.1, 0.2) + + - include: "include.pals.yaml" + + - lat1: + kind: Lattice + branches: + - inj_line + + - lat2: + kind: Lattice + branches: + - a_subline + + - inj_line: + kind: BeamLine + multipass: true + length: 37.8 + zero_point: thingC + line: # This item refers to the name of an element or BeamLine defined elsewhere. + - thingZ: # thingZ inherits parameters from thingB + inherit: thingB + - Q1a: # Define an element in place called Q1a + kind: Quadrupole + length: 1.03 * pi / c_light + direction: -1 + - a_subline: + repeat: 2 # Item a_subline is repeated two times + + + + - use: "lat2" + - use: "lat1" diff --git a/lattice_files/fork.pals.yaml b/lattice_files/fork.pals.yaml new file mode 100644 index 0000000..b507ba0 --- /dev/null +++ b/lattice_files/fork.pals.yaml @@ -0,0 +1,92 @@ +PALS: + facility: + + - begin1: + kind: BeginningEle + ReferenceP: + species_ref: "electron" + E_tot_ref: 1e7 + + - b_begin: + kind: BeginningEle + ReferenceP: + species_ref: "electron" + E_tot_ref: 2e7 + + - a_fork: + kind: Fork + ForkP: + to_line: a_line + + - b_fork: + kind: Fork + ForkP: + to_line: b_line + propagate_reference: false + + + - c_fork: + kind: Fork + ForkP: + to_line: c_line + + - a_back_fork: + kind: Fork + ForkP: + to_line: a_line + destination_element: b_fork + new_branch: null + + - cav: + kind: RFCavity + length: 0.4 + RFP: + gradient: 1e8 + dE_ref: 1e8 + + - m: + kind: Marker + + - dft: + kind: Drift + length: 2 + + - zero_line: + kind: BeamLine + line: + - begin1 + - cav + - a_fork + + - one_line: + kind: BeamLine + line: + - begin1 + - dft + - c_fork + + - a_line: + kind: BeamLine + line: + - m + - cav + - b_fork + + - b_line: + kind: BeamLine + line: + - b_begin + - dft + + - c_line: + kind: BeamLine + line: + - m + - dft + - a_back_fork + + - root_lat: + kind: Lattice + branches: + - zero_line + - one_line \ No newline at end of file diff --git a/lattice_files/include.pals.yaml b/lattice_files/include.pals.yaml new file mode 100644 index 0000000..889560c --- /dev/null +++ b/lattice_files/include.pals.yaml @@ -0,0 +1,27 @@ +- thingB: + kind: Sextupole + +- DH1A: + kind: Bend + length: 0.2666536729738865 + BendP: + g_ref: 0.06818582244610812 + e1: 0.009091 * a_const + edge2_int: 0.02065 + +- a_subline: + kind: BeamLine + line: + - DH1A + - pueh001: + kind: Instrument + ApertureP: + shape: RECTANGULAR + location: EXIT_END + # An include file has no `PALS` root of its own to register an + # `extension_labels` name under, so the extension is marked in place + # with an `extension` key (extensions.md, s:extension-syntax). + bmad_data: + extension: Bmad + Bmad_key: Monitor + - include: "include2.pals.yaml" diff --git a/lattice_files/include2.pals.yaml b/lattice_files/include2.pals.yaml new file mode 100644 index 0000000..9574dd7 --- /dev/null +++ b/lattice_files/include2.pals.yaml @@ -0,0 +1,6 @@ +- quad1: + kind: Quadrupole + MagneticMultipoleP: + Bn1: 1.0 + length: 1.0 + diff --git a/palsparserpy/__init__.py b/palsparserpy/__init__.py new file mode 100644 index 0000000..ffff255 --- /dev/null +++ b/palsparserpy/__init__.py @@ -0,0 +1,73 @@ +""" +PALSParserPy -- a Python wrapper around the PALSParserCpp C library (rapidyaml +backend) for the Particle Accelerator Language Standard (PALS). + +The C API is tree+nodeId-centric: every operation takes a ``YAMLTreeHandle`` +(opaque pointer to a parsed tree) and a ``YAMLNodeId`` (index within that tree). + +On the Python side: + + - :class:`YAMLTree` owns the C tree handle and frees it when it is collected. + - :class:`YAMLNode` is a lightweight value holding a reference to its parent + tree (keeping it alive) and the integer node id. + +Reading a lattice:: + + import palsparserpy as pp + + lat = pp.parse_and_expand_pals("lattice.pals.yaml") + print(lat.full_expanded) + +Translating one:: + + pp.write_bmad_file(pp.pals_to_bmad(pp.parse_file("lattice.pals.yaml")), + "lattice.bmad") +""" + +from ._clib import YAML_NULL_ID +from .node import (PALSParseError, YAMLNode, YAMLTree, add_map, add_scalar, + add_sequence, create_empty_tree, deep_copy_children, + deep_copy_node, get_parent, is_map, is_scalar, is_sequence, + node_key, parse_file, parse_string, remove, set_key, + set_scalar, to_yaml_string, write_yaml) +from .parser import (evaluate_pals_expression, match_names, node_correspondence, + parameter_value, parse_and_expand_pals) +from .structs import (PROBLEM_ERROR, PROBLEM_INPUT, PROBLEM_UNSPECIFIED, + PROBLEM_UNSUPPORTED, PROBLEM_WARNING, Lattices, + NodeCorrespondence, Problem, ProblemOrigin, + ProblemSeverity) +from .to_bmad import (BmadBeamline, BmadController, BmadEleDef, BmadLattice, + pals_to_bmad, write_bmad_file) +from .to_madx import (MadxAlignment, MadxBeamline, MadxController, MadxEleDef, + MadxLattice, pals_to_madx, write_madx_file) +from .to_scibmad import (SciBmadBeamline, SciBmadController, SciBmadEle, + SciBmadLattice, SciBmadLatticeList, pals_to_scibmad, + write_scibmad_file) + +__version__ = "0.1.0" + +__all__ = [ + # tree objects and YAML manipulation + "YAMLTree", "YAMLNode", "PALSParseError", "YAML_NULL_ID", + "parse_file", "parse_string", "create_empty_tree", + "is_map", "is_sequence", "is_scalar", "get_parent", "node_key", + "add_scalar", "add_map", "add_sequence", "set_scalar", "set_key", "remove", + "deep_copy_node", "deep_copy_children", "to_yaml_string", "write_yaml", + # lattice-level API + "parse_and_expand_pals", "evaluate_pals_expression", "node_correspondence", + "match_names", "parameter_value", + # what it hands back + "Lattices", "Problem", "NodeCorrespondence", + "ProblemSeverity", "ProblemOrigin", + "PROBLEM_ERROR", "PROBLEM_WARNING", + "PROBLEM_INPUT", "PROBLEM_UNSUPPORTED", "PROBLEM_UNSPECIFIED", + # translation + "pals_to_bmad", "write_bmad_file", + "BmadLattice", "BmadEleDef", "BmadBeamline", "BmadController", + "pals_to_madx", "write_madx_file", + "MadxLattice", "MadxEleDef", "MadxBeamline", "MadxController", + "MadxAlignment", + "pals_to_scibmad", "write_scibmad_file", + "SciBmadLattice", "SciBmadEle", "SciBmadBeamline", "SciBmadLatticeList", + "SciBmadController", +] diff --git a/palsparserpy/_clib.py b/palsparserpy/_clib.py new file mode 100644 index 0000000..06f2a1e --- /dev/null +++ b/palsparserpy/_clib.py @@ -0,0 +1,250 @@ +""" +Locating and binding the PALSParserCpp shared library. + +The C API is tree+nodeId-centric: every operation takes a ``YAMLTreeHandle`` +(opaque pointer to a parsed tree) and a ``YAMLNodeId`` (index within that tree). +This module holds the ``ctypes`` mirror of ``PALSParserCpp.h`` -- the structs, the +enum constants, and the function prototypes -- and nothing else; the Python-side +object model lives in :mod:`palsparserpy.parser`. +""" + +from __future__ import annotations + +import ctypes +import os +import sys +from ctypes import (POINTER, Structure, c_bool, c_char_p, c_double, c_int, + c_size_t, c_void_p) + +__all__ = [ + "YAML_NULL_ID", "libparser", "take_string", "encode", + "ProblemC", "ProblemListC", "LatticesC", "NodeLinkC", "CorrespondenceMapC", + "NameMatchesC", "ParamValueC", + "PARAM_VALUE_MISSING", "PARAM_VALUE_NUMBER", "PARAM_VALUE_STRING", +] + +# Sentinel meaning "no node" / "append at the end"; (size_t)-1 in C. +YAML_NULL_ID = ctypes.c_size_t(-1).value + +# ─── structs matching the C header ─────────────────────────────────────────── + +class ProblemC(Structure): + """Mirrors ``struct problem``. Both strings are owned by the C side and are + freed with ``free_lattice_problems``; the two enums are C ``int``s.""" + _fields_ = [("message", c_char_p), + ("path", c_char_p), + ("severity", c_int), + ("origin", c_int)] + + +class ProblemListC(Structure): + """Mirrors ``struct problem_list``: an owning array of :class:`ProblemC` and + its length. Freed with ``free_lattice_problems``.""" + _fields_ = [("items", POINTER(ProblemC)), + ("count", c_size_t)] + + +class LatticesC(Structure): + """Mirrors ``struct lattices``: five tree handles plus the problem list, all + by value. Layout must match field for field and in order.""" + _fields_ = [("original", c_void_p), + ("combined", c_void_p), + ("expanded", c_void_p), + ("full_expanded", c_void_p), + ("adjunct", c_void_p), + ("problems", ProblemListC)] + + +class NodeLinkC(Structure): + """Mirrors ``struct node_link``: one logical node's id in each tree.""" + _fields_ = [("original", c_size_t), + ("combined", c_size_t), + ("full_expanded", c_size_t), + ("adjunct", c_size_t)] + + +class CorrespondenceMapC(Structure): + """Mirrors ``struct correspondence_map``: an owning array of links.""" + _fields_ = [("links", POINTER(NodeLinkC)), + ("count", c_size_t)] + + +class NameMatchesC(Structure): + """Mirrors ``struct name_matches``: a flat array of matched node ids.""" + _fields_ = [("nodes", POINTER(c_size_t)), + ("count", c_size_t)] + + +class ParamValueC(Structure): + """Mirrors ``struct param_value``. ``string`` is held as a raw address rather + than a ``c_char_p`` so that the pointer survives to be freed with + ``yaml_free_string``.""" + _fields_ = [("kind", c_int), + ("number", c_double), + ("string", c_void_p)] + + +# ``enum param_value_kind`` from PALSParserCpp.h. +PARAM_VALUE_MISSING = 0 +PARAM_VALUE_NUMBER = 1 +PARAM_VALUE_STRING = 2 + +# ─── function prototypes ───────────────────────────────────────────────────── + +# name -> (restype, argtypes). Every char*-returning function is declared +# c_void_p rather than c_char_p: ctypes would convert the latter to bytes and +# throw the address away, leaking the string the caller is meant to free. +_PROTOTYPES = { + "parse_and_expand_PALS": (LatticesC, [c_char_p, c_char_p]), + "expand_PALS_string": (LatticesC, [c_char_p, c_char_p]), + "free_lattice_problems": (None, [ProblemListC]), + "evaluate_pals_expression": (c_double, [c_char_p, POINTER(c_bool)]), + "build_correspondence_map": (CorrespondenceMapC, + [c_void_p, c_void_p, c_void_p, c_void_p]), + "free_correspondence_map": (None, [CorrespondenceMapC]), + "match_names": (NameMatchesC, [c_void_p, c_char_p]), + "free_name_matches": (None, [NameMatchesC]), + "get_parameter_value": (ParamValueC, [c_void_p, c_char_p]), + "get_lattice_parameter_value": (ParamValueC, [c_void_p, c_void_p, c_char_p]), + "parse_file": (c_void_p, [c_char_p]), + "parse_string": (c_void_p, [c_char_p]), + "yaml_last_parse_error": (c_char_p, []), + "create_empty_tree": (c_void_p, []), + "delete_tree": (None, [c_void_p]), + "remove_node": (None, [c_void_p, c_size_t, c_size_t]), + "get_root": (c_size_t, [c_void_p]), + "get_parent": (c_size_t, [c_void_p, c_size_t]), + "get_child_by_key": (c_size_t, [c_void_p, c_size_t, c_char_p]), + "get_child_by_index": (c_size_t, [c_void_p, c_size_t, c_size_t]), + "get_size": (c_size_t, [c_void_p, c_size_t]), + "get_node_key": (c_void_p, [c_void_p, c_size_t]), + "is_map": (c_bool, [c_void_p, c_size_t]), + "is_sequence": (c_bool, [c_void_p, c_size_t]), + "is_scalar": (c_bool, [c_void_p, c_size_t]), + "as_string": (c_void_p, [c_void_p, c_size_t]), + "add_scalar": (c_size_t, [c_void_p, c_size_t, c_char_p, + c_char_p, c_size_t]), + "add_map": (c_size_t, [c_void_p, c_size_t, c_char_p, c_size_t]), + "add_sequence": (c_size_t, [c_void_p, c_size_t, c_char_p, c_size_t]), + "set_scalar": (None, [c_void_p, c_size_t, c_char_p]), + "set_node_key": (None, [c_void_p, c_size_t, c_char_p]), + "deep_copy_node": (None, [c_void_p, c_size_t, c_void_p, c_size_t]), + "deep_copy_children": (None, [c_void_p, c_size_t, c_void_p, c_size_t, + c_size_t]), + "node_to_string": (c_void_p, [c_void_p, c_size_t]), + "tree_to_string": (c_void_p, [c_void_p]), + "write_file": (c_bool, [c_void_p, c_char_p]), + "yaml_free_string": (None, [c_void_p]), +} + +# ─── library discovery ─────────────────────────────────────────────────────── + +def _dlext() -> str: + """The shared-library extension of this platform.""" + if sys.platform == "darwin": + return "dylib" + if sys.platform in ("win32", "cygwin"): + return "dll" + return "so" + + +def _candidates() -> list[str]: + """Every place the library might be. + + The library is built by PALSParserCpp and is not shipped with this package, + so it has to be searched for. In order: + + 1. ``$PALS_PARSER_CPP_LIB`` -- full path to the shared library itself + 2. ``$PALS_PARSER_CPP_DIR`` -- a PALSParserCpp checkout; its build + directory is searched + 3. a PALSParserCpp checkout beside this one, and the checkout this one sits + inside (both layouts the installation guide describes) + """ + # MSVC drops the "lib" prefix and writes into a per-configuration + # subdirectory; the single-config generators used elsewhere write straight + # into build/. + names = (f"libPALSParserCpp.{_dlext()}", f"PALSParserCpp.{_dlext()}") + subdirs = ("", "Release", "Debug") + + out = [] + if os.environ.get("PALS_PARSER_CPP_LIB"): + out.append(os.environ["PALS_PARSER_CPP_LIB"]) + + here = os.path.dirname(os.path.abspath(__file__)) + roots = [] + if os.environ.get("PALS_PARSER_CPP_DIR"): + roots.append(os.environ["PALS_PARSER_CPP_DIR"]) + roots.append(os.path.normpath(os.path.join(here, "..", "..", "PALSParserCpp"))) + roots.append(os.path.normpath(os.path.join(here, "..", ".."))) + + for root in roots: + for sub in subdirs: + for name in names: + out.append(os.path.normpath(os.path.join(root, "build", sub, name))) + return out + + +def _find_library() -> str: + """Locate the library, or explain exactly what was looked for and how to fix + it.""" + candidates = _candidates() + for path in candidates: + if os.path.isfile(path): + return path + searched = "\n".join(" " + c for c in candidates) + raise FileNotFoundError( + f"PALSParserPy could not find the PALSParserCpp shared library " + f"(libPALSParserCpp.{_dlext()}).\n\n" + "Build it from a PALSParserCpp checkout:\n" + " cmake -S . -B build && cmake --build build\n\n" + "Then either clone PALSParserCpp next to PALSParserPy, or point\n" + "PALSParserPy at it:\n" + " export PALS_PARSER_CPP_DIR=/path/to/PALSParserCpp\n" + f" export PALS_PARSER_CPP_LIB=/path/to/libPALSParserCpp.{_dlext()}\n\n" + f"Searched:\n{searched}") + + +_lib = None + + +def libparser() -> ctypes.CDLL: + """The loaded PALSParserCpp shared library, with every prototype bound. + + Resolved on first use and cached thereafter. Raises ``FileNotFoundError`` + listing every path tried if the library cannot be found. + + Resolution is deliberately lazy rather than done at import: ``import + palsparserpy`` must succeed without the C library present, so that + documentation and other tooling can read the package without a C++ + toolchain. The cost is that a missing library is reported at the first call + rather than at import. + """ + global _lib + if _lib is None: + lib = ctypes.CDLL(_find_library()) + for name, (restype, argtypes) in _PROTOTYPES.items(): + fn = getattr(lib, name) + fn.restype = restype + fn.argtypes = argtypes + _lib = lib + return _lib + + +# ─── string helpers ────────────────────────────────────────────────────────── + +def encode(text) -> bytes | None: + """Encode a Python string for the C API. ``None`` passes through as NULL.""" + return None if text is None else str(text).encode("utf-8") + + +def take_string(ptr) -> str | None: + """Copy a string the library returned and free it, or ``None`` for NULL. + + Every ``char*`` this API hands back is owned by the caller, so the copy and + the free belong together. + """ + if not ptr: + return None + text = ctypes.cast(ptr, c_char_p).value.decode("utf-8") + libparser().yaml_free_string(ptr) + return text diff --git a/palsparserpy/_common.py b/palsparserpy/_common.py new file mode 100644 index 0000000..a479c90 --- /dev/null +++ b/palsparserpy/_common.py @@ -0,0 +1,208 @@ +""" +Helpers the three translators share: number handling, PALS name/value lists, +facility lookup, and the multipole representations. +""" + +from __future__ import annotations + +import cmath +import math +from typing import Dict, List, Optional, Tuple + +from .node import YAMLNode + +__all__ = ["approx", "fmt", "try_float", "name_value_pairs", "value_text", + "ctrl_variables", "facility_entry", "facility_props", + "FullRepresentation", "ABRepresentation", "tilt_rotation", + "fill_multipoles"] + +# The tolerance Julia's `isapprox` uses by default, which is what the reference +# implementation of these translators compared with. +_RTOL = math.sqrt(2.0 ** -52) + + +def approx(a: float, b: float, rtol: float = _RTOL, atol: float = 0.0) -> bool: + """Whether two numbers agree to a relative tolerance. + + Note that ``approx(x, 0)`` is exactly ``x == 0``: with no absolute tolerance + there is nothing for a relative one to be relative to. That is deliberate -- + a strength of 1e-30 is a strength that was written down, and the translators + that ask this question mean "was anything stated here at all". + """ + return abs(a - b) <= max(atol, rtol * max(abs(a), abs(b))) + + +def fmt(value) -> str: + """Render a number for a lattice file. + + Python's own ``repr`` is the shortest text that reads back as the same float, + which is what a lattice file wants; only the exponent form is adjusted, from + ``1e-05`` to the ``1.0e-5`` the accelerator formats are written with. + """ + if isinstance(value, bool) or isinstance(value, int): + return str(value) + text = repr(float(value)) + if "e" in text: + mantissa, _, exponent = text.partition("e") + if "." not in mantissa: + mantissa += ".0" + text = f"{mantissa}e{int(exponent)}" + return text + + +def try_float(text) -> Optional[float]: + """The number ``text`` spells, or ``None`` if it does not spell one. + + A PALS parameter may be written as an expression, which only the target + program can evaluate, or as a plain number, which the translation can work + with; this is what tells the two apart. + """ + try: + return float(str(text).strip()) + except (TypeError, ValueError): + return None + + +# ─── PALS name/value lists ─────────────────────────────────────────────────── + +def value_text(node: YAMLNode) -> str: + """The text of a value ``node``, with a value left unwritten taken as PALS' + default of zero.""" + text = node.value.strip() + return "0" if text in ("", "~", "null") else text + + +def name_value_pairs(node: YAMLNode) -> List[Tuple[str, str]]: + """A PALS name/value list as ``(name, value-text)`` pairs, in definition + order. + + Accepts both forms the standard allows for such a list: a map (``vv: 0.3``) + and a sequence of single-key maps (``- vv: 0.3``). An entry written with no + value takes PALS' default of zero, and one whose value is a structure rather + than a single value is skipped. + """ + pairs = [] + + def add(child): + if child.is_map() or child.is_sequence(): + return + pairs.append((child.node_key(), value_text(child))) + + if node.is_map(): + for key in node.keys(): + add(node[key]) + elif node.is_sequence(): + for entry in node: + for i in range(len(entry)): + add(entry.child(i)) + return pairs + + +def ctrl_variables(props: YAMLNode) -> List[Tuple[str, str]]: + """A controller's ``variables`` as ``(name, value-text)`` pairs, in definition + order.""" + if "variables" not in props: + return [] + return name_value_pairs(props["variables"]) + + +# ─── facility lookup ───────────────────────────────────────────────────────── + +def facility_entry(facility: YAMLNode, name: str) -> Optional[YAMLNode]: + """The ``facility`` entry named ``name``, or ``None`` if there is none. + + The entry is the single-key map the translators take as an element; + :func:`facility_props` gives its properties. + """ + for ele in facility: + if ele.child(0).node_key() == name: + return ele + return None + + +def facility_props(facility: YAMLNode, name: str) -> Optional[YAMLNode]: + """The property map of the ``facility`` entry named ``name``, or ``None`` if + there is none.""" + ele = facility_entry(facility, name) + return None if ele is None else ele.child(0) + + +# ─── multipole representations ─────────────────────────────────────────────── + +class FullRepresentation: + """Raw, over-parametrized multipole form filled directly from PALS-YAML. + + Holds, keyed by multipole order, whether each coefficient is ``normalized`` + (K vs. B) and ``integrated`` (field integral vs. field strength), its + ``magnitude`` (a ``[normal, skew]`` pair), and its ``tilt``, together with the + element length ``L``. It is down-converted to whichever element-specific + representation the element kind requires. + """ + + __slots__ = ("normalized", "integrated", "magnitude", "tilt", "L") + + def __init__(self): + self.normalized: Dict[int, bool] = {} + self.integrated: Dict[int, bool] = {} + self.magnitude: Dict[int, List[float]] = {} + self.tilt: Dict[int, float] = {} + self.L: float = 1.0 + + +class ABRepresentation: + """Element-specific multipole form: only the final A/B field integrals. + + ``A`` and ``B`` map each multipole order to its skew and normal field + integral, respectively. Built from a :class:`FullRepresentation` by combining + each multipole's magnitude, length and tilt into a complex field integral and + storing its imaginary/real parts. + """ + + __slots__ = ("A", "B") + + def __init__(self, full: FullRepresentation): + self.A: Dict[int, float] = {} + self.B: Dict[int, float] = {} + for order in sorted(full.magnitude): + length = 1.0 if full.integrated[order] else full.L + tilt = full.tilt.get(order, 0.0) + fact = 1 / math.factorial(order) + b_ia = ((fact * length) * complex(*full.magnitude[order]) + * tilt_rotation(order, tilt)) + self.A[order] = b_ia.imag + self.B[order] = b_ia.real + + +def tilt_rotation(order: int, tilt: float) -> complex: + """The factor that rotates an ``order`` multipole of the given ``tilt`` into + normal and skew parts. + + A tilt of ``T`` rotates an order-``N`` field by ``(N+1) * T`` in the + normal/skew plane: both PALS and Bmad write the field as + ``(1/N!) (normal + i * skew) exp(-i (N+1) T)``. + """ + return cmath.exp(-1j * (order + 1) * tilt) + + +def fill_multipoles(full: FullRepresentation, mmP: YAMLNode, + name: str) -> FullRepresentation: + """Populate ``full`` from a PALS ``MagneticMultipoleP`` map. + + Parse each key of ``mmP`` into a multipole order and store its magnitude, + ``normalized``, ``integrated`` and ``tilt`` attributes in ``full``; ``name`` + is used in error messages. Returns ``full``. + """ + for key in mmP.keys(): + order = int("".join(ch for ch in key if ch.isdigit())) + if key.startswith("tilt"): + if order in full.tilt: + raise ValueError(f"{name} conflicting multipole definitions {key}") + full.tilt[order] = mmP[key].as_float() + else: + component = 0 if key[1] == "n" else 1 + if order not in full.magnitude: + full.integrated[order] = key.endswith("L") + full.normalized[order] = key.startswith("K") + full.magnitude[order] = [0.0, 0.0] + full.magnitude[order][component] = mmP[key].as_float() + return full diff --git a/palsparserpy/node.py b/palsparserpy/node.py new file mode 100644 index 0000000..9220e9c --- /dev/null +++ b/palsparserpy/node.py @@ -0,0 +1,584 @@ +""" +The YAML tree object model: :class:`YAMLTree`, :class:`YAMLNode`, and the +parsing, navigation, editing and emitting operations on them. + + - :class:`YAMLTree` owns the C tree handle and frees it when it is collected. + - :class:`YAMLNode` is a lightweight value holding a reference to its parent + tree (keeping it alive) and the integer node id. + +Indexing is 0-based, as everywhere else in Python and as in the underlying C +API; a negative index counts from the end. +""" + +from __future__ import annotations + +import os +from typing import Iterable, Iterator, Union + +from ._clib import YAML_NULL_ID, encode, libparser, take_string + +__all__ = [ + "YAMLTree", "YAMLNode", "PALSParseError", + "parse_file", "parse_string", "create_empty_tree", + "is_map", "is_sequence", "is_scalar", "get_parent", "node_key", + "add_scalar", "add_map", "add_sequence", "set_scalar", "set_key", "remove", + "deep_copy_node", "deep_copy_children", "to_yaml_string", "write_yaml", +] + + +class PALSParseError(ValueError): + """A YAML document could not be parsed. + + The message carries what the C library reported -- for a syntax error, + prefixed with the offending ``line L, column C:`` -- so the fault can be + pinpointed instead of reported as a bare failure. + """ + + +# ─── core types ────────────────────────────────────────────────────────────── + +class YAMLTree: + """Owns a C ``YAMLTreeHandle``. Freed automatically when the object is + garbage collected. Do not use the handle after the tree has been freed.""" + + __slots__ = ("handle", "__weakref__") + + def __init__(self, handle): + if not handle: + raise ValueError("Invalid YAML tree handle (C returned NULL)") + self.handle = handle + + def __del__(self): + handle, self.handle = getattr(self, "handle", None), None + if handle: + try: + libparser().delete_tree(handle) + except Exception: # interpreter teardown; nothing left to free into + pass + + def __repr__(self): + return f"" if self.handle else "" + + +class YAMLNode: + """A reference to a single node within a :class:`YAMLTree`. + + Holding a ``YAMLNode`` keeps its tree alive. Node ids are invalidated if the + tree is deleted. + """ + + __slots__ = ("tree", "id") + + def __init__(self, tree: YAMLTree, node_id: int): + self.tree = tree + self.id = node_id + + # Two YAMLNodes are equal when they point at the same id in the same tree. + # Defining these lets YAMLNode be used as a dict key (e.g. in + # node_correspondence). + def __eq__(self, other): + if not isinstance(other, YAMLNode): + return NotImplemented + return self.tree is other.tree and self.id == other.id + + def __hash__(self): + return hash((id(self.tree), self.id)) + + # ─── type checks ─────────────────────────────────────────────────────── + def is_map(self) -> bool: + """Whether this node is a MAP (a collection of key/value pairs). + + A node is exactly one of MAP, sequence, or scalar; use this to decide + before accessing children by key. + """ + return bool(libparser().is_map(self.tree.handle, self.id)) + + def is_sequence(self) -> bool: + """Whether this node is a sequence (an ordered list of elements). + + A node is exactly one of MAP, sequence, or scalar; use this to decide + before accessing children by index. + """ + return bool(libparser().is_sequence(self.tree.handle, self.id)) + + def is_scalar(self) -> bool: + """Whether this node is a scalar (a leaf holding a single string, number + or boolean value). + + Scalar nodes have no children and their value is read with + :attr:`value`, :meth:`as_int`, :meth:`as_float` or :meth:`as_bool`. + """ + return bool(libparser().is_scalar(self.tree.handle, self.id)) + + # ─── traversal ───────────────────────────────────────────────────────── + def parent(self) -> "YAMLNode": + """The parent of this node. Raises ``ValueError`` for the root, which has + no parent.""" + node_id = libparser().get_parent(self.tree.handle, self.id) + if node_id == YAML_NULL_ID: + raise ValueError("Node has no parent (it is the root)") + return YAMLNode(self.tree, node_id) + + def root(self) -> "YAMLNode": + """The root of the tree this node belongs to (this node itself if it is + the root).""" + return YAMLNode(self.tree, libparser().get_root(self.tree.handle)) + + def child(self, index: int) -> "YAMLNode": + """The ``index``-th direct child of a MAP or sequence node, 0-based. + + Unlike ``node[key]``, this reaches a MAP's children by position, which is + how a single-key map entry is opened without knowing its key. + """ + n = len(self) + if index < 0: + index += n + if not 0 <= index < n: + raise IndexError(f"Index out of bounds: {index}") + node_id = libparser().get_child_by_index(self.tree.handle, self.id, index) + if node_id == YAML_NULL_ID: + raise IndexError(f"Index out of bounds: {index}") + return YAMLNode(self.tree, node_id) + + def get(self, key: str, default=None): + """The child stored under ``key``, or ``default`` if there is none.""" + node_id = libparser().get_child_by_key(self.tree.handle, self.id, encode(key)) + return default if node_id == YAML_NULL_ID else YAMLNode(self.tree, node_id) + + def __getitem__(self, key: Union[str, int]) -> "YAMLNode": + """``node[key]`` looks up a direct child of a MAP by its string key; + ``node[i]`` returns the ``i``-th direct child of a MAP or sequence. + + Only direct children are searched (the lookup is not recursive). Raises + ``KeyError`` if no child has the given key -- test with ``key in node`` + if it may be absent -- and ``IndexError`` if the index is out of bounds. + """ + if isinstance(key, int): + return self.child(key) + node_id = libparser().get_child_by_key(self.tree.handle, self.id, encode(key)) + if node_id == YAML_NULL_ID: + raise KeyError(key) + return YAMLNode(self.tree, node_id) + + def __contains__(self, key: str) -> bool: + """Whether the MAP node has a direct child stored under ``key``. Only + direct children are checked; the search is not recursive.""" + return libparser().get_child_by_key( + self.tree.handle, self.id, encode(key)) != YAML_NULL_ID + + def __len__(self) -> int: + """The number of direct children: the number of key/value pairs in a MAP, + or the number of elements in a sequence. Scalar nodes report 0.""" + return int(libparser().get_size(self.tree.handle, self.id)) + + # A node is a thing, not a container to be tested for emptiness: without + # this, __len__ would make an empty map or any scalar falsy. + def __bool__(self) -> bool: + return True + + def keys(self) -> list[str]: + """The keys of a MAP node, in document order. Empty for sequence and + scalar nodes.""" + if not self.is_map(): + return [] + lib = libparser() + out = [] + for i in range(len(self)): + child_id = lib.get_child_by_index(self.tree.handle, self.id, i) + if child_id == YAML_NULL_ID: + continue + key = take_string(lib.get_node_key(self.tree.handle, child_id)) + if key is not None: + out.append(key) + return out + + def values(self) -> list["YAMLNode"]: + """The children of this node, in document order.""" + return [self.child(i) for i in range(len(self))] + + def items(self) -> list[tuple[str, "YAMLNode"]]: + """The ``(key, child)`` pairs of a MAP node, in document order.""" + return [(k, self[k]) for k in self.keys()] + + def __iter__(self) -> Iterator: + """Iterate the node's children: a sequence yields its elements, a MAP + yields its keys (as ``dict`` does; use :meth:`items` for pairs), and a + scalar yields nothing.""" + if self.is_map(): + return iter(self.keys()) + if self.is_sequence(): + return iter(self.values()) + return iter(()) + + def node_key(self) -> Union[str, None]: + """The key under which this node is stored in its parent MAP, or ``None`` + if it has none. Sequence elements and the tree root have no key.""" + return take_string(libparser().get_node_key(self.tree.handle, self.id)) + + # ─── reading values ──────────────────────────────────────────────────── + @property + def value(self) -> str: + """The scalar value of this node, as raw text. + + Raises ``ValueError`` if the node has no value (i.e. it is a MAP or a + bare sequence). Use :meth:`as_int`, :meth:`as_float` or :meth:`as_bool` + for typed values. + """ + text = take_string(libparser().as_string(self.tree.handle, self.id)) + if text is None: + raise ValueError("Node has no scalar value") + return text + + def as_int(self) -> int: + """The scalar value parsed as an ``int``. Raises if the node is not a + scalar or its text is not a valid integer.""" + return int(self.value) + + def as_float(self) -> float: + """The scalar value parsed as a ``float``. Raises if the node is not a + scalar or its text is not a valid floating-point number.""" + return float(self.value) + + def as_bool(self) -> bool: + """The scalar value parsed as a ``bool``. + + Accepts exactly the text ``true`` or ``false``; any other value (or a + non-scalar node) raises ``ValueError``. + """ + text = self.value + if text == "true": + return True + if text == "false": + return False + raise ValueError(f"Cannot convert '{text}' to bool") + + def __int__(self): + return self.as_int() + + def __float__(self): + return self.as_float() + + # ─── modification ────────────────────────────────────────────────────── + def add_scalar(self, value: str, key: str = None, index: int = None) -> "YAMLNode": + """Add a scalar child to this node. + + Pass ``key`` for MAP parents; omit it for sequence elements. ``index`` + selects the 0-based position among the existing children; the default + appends at the end. + """ + node_id = libparser().add_scalar( + self.tree.handle, self.id, encode(key), encode(value), + YAML_NULL_ID if index is None else index) + if node_id == YAML_NULL_ID: + raise ValueError("Failed to add scalar") + return YAMLNode(self.tree, node_id) + + def add_map(self, key: str = None, index: int = None) -> "YAMLNode": + """Add an empty MAP child to this node. + + Pass ``key`` for MAP parents; omit it for sequence elements. ``index`` + selects the 0-based position among the existing children; the default + appends at the end. + """ + node_id = libparser().add_map( + self.tree.handle, self.id, encode(key), + YAML_NULL_ID if index is None else index) + if node_id == YAML_NULL_ID: + raise ValueError("Failed to add map") + return YAMLNode(self.tree, node_id) + + def add_sequence(self, key: str = None, index: int = None) -> "YAMLNode": + """Add an empty sequence child to this node. + + Pass ``key`` for MAP parents; omit it for sequence elements. ``index`` + selects the 0-based position among the existing children; the default + appends at the end. + """ + node_id = libparser().add_sequence( + self.tree.handle, self.id, encode(key), + YAML_NULL_ID if index is None else index) + if node_id == YAML_NULL_ID: + raise ValueError("Failed to add sequence") + return YAMLNode(self.tree, node_id) + + def __setitem__(self, key: str, value: str) -> None: + """``node[key] = value`` sets or updates a scalar value in a MAP node. + + If ``key`` already exists its value is updated; otherwise a new scalar + child is appended. + """ + lib = libparser() + child_id = lib.get_child_by_key(self.tree.handle, self.id, encode(key)) + if child_id != YAML_NULL_ID: + lib.set_scalar(self.tree.handle, child_id, encode(value)) + else: + lib.add_scalar(self.tree.handle, self.id, encode(key), encode(value), + YAML_NULL_ID) + + def set_scalar(self, value: str) -> None: + """Set or replace the scalar value of this node. + + Operates on an existing node in place; to set a value by key within a MAP + (adding the key if absent), use ``node[key] = value`` instead. + """ + libparser().set_scalar(self.tree.handle, self.id, encode(value)) + + def set_key(self, key: str) -> None: + """Set or replace the key under which this node is stored in its parent + MAP. Only meaningful inside a MAP; sequence elements are keyless.""" + libparser().set_node_key(self.tree.handle, self.id, encode(key)) + + def remove(self) -> None: + """Remove this node, together with all of its descendants, from its + parent. + + After removal the ``YAMLNode`` is stale and must not be used again. + Intended for non-root nodes; the root has no parent to be removed from. + """ + lib = libparser() + parent_id = lib.get_parent(self.tree.handle, self.id) + lib.remove_node(self.tree.handle, parent_id, self.id) + + def __delitem__(self, key: Union[str, int]) -> None: + """``del node[key]`` removes a child and all of its descendants.""" + self[key].remove() + + # ─── deep copy ───────────────────────────────────────────────────────── + def deep_copy_node(self, src: "YAMLNode") -> None: + """Copy the type, key, value and all descendants of ``src`` into this + node, overwriting whatever it previously held. Works across trees.""" + libparser().deep_copy_node(self.tree.handle, self.id, + src.tree.handle, src.id) + + def deep_copy_children(self, src: "YAMLNode", index: int = None) -> None: + """Copy all children of ``src`` into this node at the 0-based position + ``index`` among the existing children; the default appends them at the + end. Works across trees.""" + libparser().deep_copy_children( + self.tree.handle, self.id, src.tree.handle, src.id, + YAML_NULL_ID if index is None else index) + + def copy(self) -> "YAMLNode": + """An independent deep copy of this node, in a tree of its own.""" + dst = create_empty_tree() + dst.deep_copy_node(self) + return dst + + def __copy__(self): + return self.copy() + + def __deepcopy__(self, memo): + return self.copy() + + # ─── emitting ────────────────────────────────────────────────────────── + def to_yaml_string(self, exclude: Union[str, Iterable[str]] = ()) -> str: + """Emit this node and its descendants as a YAML string. + + ``exclude`` is a key name, or a collection of key names, to be left out + of the output: every MAP entry whose key matches, at any depth, is + omitted along with its whole subtree. This is a display filter only -- + the node itself is never modified. For example, to print a lattice + without the floor and reference subtrees:: + + print(lat.to_yaml_string(exclude=["FloorP", "ReferenceP"])) + """ + drop = _exclude_set(exclude) + if not drop: + return _emit_yaml(self) + return _emit_yaml(_pruned_copy(self, drop)) + + def write_yaml(self, filename, exclude: Union[str, Iterable[str]] = ()) -> bool: + """Write the entire tree that contains this node to a YAML file. + + Returns ``True`` on success. ``exclude`` is a key name, or a collection + of key names, to be left out of the file: every MAP entry whose key + matches, at any depth, is omitted along with its whole subtree. The tree + in memory is not modified. For example:: + + lat.write_yaml("out.pals.yaml", exclude=["FloorP", "ReferenceP"]) + """ + drop = _exclude_set(exclude) + # Prune a throw-away copy of the whole tree, then write that copy. + target = self if not drop else _pruned_copy(self.root(), drop) + return bool(libparser().write_file(target.tree.handle, encode(os.fspath(filename)))) + + # ─── display ─────────────────────────────────────────────────────────── + def __repr__(self): + if self.is_scalar(): + return f"YAMLNode(scalar: {self.value})" + if self.is_map(): + return f"YAMLNode(map, {len(self)} keys)" + if self.is_sequence(): + return f"YAMLNode(sequence, {len(self)} elements)" + return "YAMLNode(unknown)" + + def __str__(self): + """The node's contents as YAML, so that printing a node shows its full + tree. ``repr`` gives the compact one-line form instead.""" + return self.to_yaml_string().rstrip() + + +# ─── internal helpers ──────────────────────────────────────────────────────── + +def _root_node(handle) -> YAMLNode: + """Wrap a tree handle and return a node pointing to its root.""" + tree = YAMLTree(handle) + return YAMLNode(tree, libparser().get_root(handle)) + + +def _last_parse_error() -> str: + """The most recent parse error recorded by the C library on this thread + (empty when the last parse succeeded).""" + ptr = libparser().yaml_last_parse_error() + return "" if ptr is None else ptr.decode("utf-8") + + +def _exclude_set(exclude) -> set: + """What the ``exclude`` argument of the emitters accepts: one key name, or a + collection of them.""" + if isinstance(exclude, str): + return {exclude} + return {str(k) for k in exclude} + + +def _emit_yaml(node: YAMLNode) -> str: + """Emit ``node`` and its descendants as YAML, without any filtering.""" + text = take_string(libparser().node_to_string(node.tree.handle, node.id)) + if text is None: + raise ValueError("Cannot convert node to YAML string") + return text + + +def _pruned_copy(node: YAMLNode, drop: set) -> YAMLNode: + """An independent copy of ``node``, in a tree of its own, with every entry + keyed by a name in ``drop`` removed. The caller's tree is left untouched.""" + pruned = node.copy() + _prune_keys(pruned, drop) + return pruned + + +def _prune_keys(node: YAMLNode, drop: set) -> YAMLNode: + """Recursively remove, in place, every MAP entry of ``node`` whose key is in + ``drop``.""" + if node.is_map(): + for key in node.keys(): + child = node[key] + if key in drop: + child.remove() + else: + _prune_keys(child, drop) + elif node.is_sequence(): + for i in range(len(node)): + _prune_keys(node.child(i), drop) + return node + + +# ─── parsing ───────────────────────────────────────────────────────────────── + +def parse_file(filename) -> YAMLNode: + """Parse a YAML file from disk. Returns a node pointing to the tree root.""" + filename = os.fspath(filename) + if not os.path.isfile(filename): + raise FileNotFoundError(f"File not found: {filename}") + handle = libparser().parse_file(encode(filename)) + if not handle: + detail = _last_parse_error() + raise PALSParseError(f"Failed to parse YAML file: {filename}" + + (f"\n {detail}" if detail else "")) + return _root_node(handle) + + +def parse_string(yaml_str: str) -> YAMLNode: + """Parse a YAML string. Returns a node pointing to the tree root.""" + handle = libparser().parse_string(encode(yaml_str)) + if not handle: + detail = _last_parse_error() + raise PALSParseError("Failed to parse YAML string" + + (f"\n {detail}" if detail else "")) + return _root_node(handle) + + +def create_empty_tree() -> YAMLNode: + """Create an empty MAP tree. Returns a node pointing to the root MAP.""" + return _root_node(libparser().create_empty_tree()) + + +# ─── function forms of the node methods ────────────────────────────────────── +# The methods above are the primary spelling; these let a node operation be +# written as a call, which reads better in a pipeline and mirrors the C API. + +def is_map(node: YAMLNode) -> bool: + """Whether ``node`` is a MAP. See :meth:`YAMLNode.is_map`.""" + return node.is_map() + + +def is_sequence(node: YAMLNode) -> bool: + """Whether ``node`` is a sequence. See :meth:`YAMLNode.is_sequence`.""" + return node.is_sequence() + + +def is_scalar(node: YAMLNode) -> bool: + """Whether ``node`` is a scalar. See :meth:`YAMLNode.is_scalar`.""" + return node.is_scalar() + + +def get_parent(node: YAMLNode) -> YAMLNode: + """The parent of ``node``. See :meth:`YAMLNode.parent`.""" + return node.parent() + + +def node_key(node: YAMLNode) -> Union[str, None]: + """The key ``node`` is stored under. See :meth:`YAMLNode.node_key`.""" + return node.node_key() + + +def add_scalar(parent: YAMLNode, value: str, key: str = None, + index: int = None) -> YAMLNode: + """Add a scalar child to ``parent``. See :meth:`YAMLNode.add_scalar`.""" + return parent.add_scalar(value, key=key, index=index) + + +def add_map(parent: YAMLNode, key: str = None, index: int = None) -> YAMLNode: + """Add an empty MAP child to ``parent``. See :meth:`YAMLNode.add_map`.""" + return parent.add_map(key=key, index=index) + + +def add_sequence(parent: YAMLNode, key: str = None, index: int = None) -> YAMLNode: + """Add an empty sequence child to ``parent``. See :meth:`YAMLNode.add_sequence`.""" + return parent.add_sequence(key=key, index=index) + + +def set_scalar(node: YAMLNode, value: str) -> None: + """Set the scalar value of ``node``. See :meth:`YAMLNode.set_scalar`.""" + node.set_scalar(value) + + +def set_key(node: YAMLNode, key: str) -> None: + """Set the key of ``node``. See :meth:`YAMLNode.set_key`.""" + node.set_key(key) + + +def remove(node: YAMLNode) -> None: + """Remove ``node`` from its parent. See :meth:`YAMLNode.remove`.""" + node.remove() + + +def deep_copy_node(dst: YAMLNode, src: YAMLNode) -> None: + """Copy ``src`` into ``dst``. See :meth:`YAMLNode.deep_copy_node`.""" + dst.deep_copy_node(src) + + +def deep_copy_children(dst: YAMLNode, src: YAMLNode, index: int = None) -> None: + """Copy the children of ``src`` into ``dst``. See + :meth:`YAMLNode.deep_copy_children`.""" + dst.deep_copy_children(src, index=index) + + +def to_yaml_string(node: YAMLNode, exclude: Union[str, Iterable[str]] = ()) -> str: + """Emit ``node`` as YAML. See :meth:`YAMLNode.to_yaml_string`.""" + return node.to_yaml_string(exclude=exclude) + + +def write_yaml(node: YAMLNode, filename, exclude: Union[str, Iterable[str]] = ()) -> bool: + """Write ``node``'s tree to a file. See :meth:`YAMLNode.write_yaml`.""" + return node.write_yaml(filename, exclude=exclude) diff --git a/palsparserpy/parser.py b/palsparserpy/parser.py new file mode 100644 index 0000000..55793f1 --- /dev/null +++ b/palsparserpy/parser.py @@ -0,0 +1,442 @@ +""" +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") + + +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 ─────────────────────────────────────────────────── + +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 ───────────────────────────────────────────────────── + +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 ─────────────────────────────────────────────────────────── + +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 `_ + 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) + + +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) diff --git a/palsparserpy/structs.py b/palsparserpy/structs.py new file mode 100644 index 0000000..ed044ca --- /dev/null +++ b/palsparserpy/structs.py @@ -0,0 +1,131 @@ +""" +The values :func:`palsparserpy.parse_and_expand_pals` hands back: the problems +found while expanding a lattice, and the five views of the lattice itself. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import IntEnum +from typing import List, NamedTuple + +from .node import YAMLNode + +__all__ = [ + "ProblemSeverity", "ProblemOrigin", "Problem", "Lattices", + "NodeCorrespondence", + "PROBLEM_ERROR", "PROBLEM_WARNING", + "PROBLEM_INPUT", "PROBLEM_UNSUPPORTED", "PROBLEM_UNSPECIFIED", +] + + +class ProblemSeverity(IntEnum): + """Whether a problem leaves the expanded trees trustworthy. + + - ``ERROR`` -- the document is wrong here and expansion could not work + around it. Do not trust the affected part of the trees. + - ``WARNING`` -- expansion produced a usable result; something was assumed + or skipped, but the trees are still sound. + + Mirrors ``enum problem_severity`` in PALSParserCpp.h. + """ + ERROR = 0 + WARNING = 1 + + +class ProblemOrigin(IntEnum): + """Who has to act on a problem. + + - ``INPUT`` -- the document is wrong; the lattice author can fix it. + - ``UNSUPPORTED`` -- valid PALS that PALSParserCpp does not implement yet. + Editing the lattice will not clear it. + - ``UNSPECIFIED`` -- the PALS standard does not define the case, so nothing + was invented. Neither the author nor the library is in the wrong. + + Mirrors ``enum problem_origin`` in PALSParserCpp.h. + """ + INPUT = 0 + UNSUPPORTED = 1 + UNSPECIFIED = 2 + + +# The C spellings, so that a problem list can be filtered without reaching +# through the enum class. +PROBLEM_ERROR = ProblemSeverity.ERROR +PROBLEM_WARNING = ProblemSeverity.WARNING +PROBLEM_INPUT = ProblemOrigin.INPUT +PROBLEM_UNSUPPORTED = ProblemOrigin.UNSUPPORTED +PROBLEM_UNSPECIFIED = ProblemOrigin.UNSPECIFIED + + +@dataclass(frozen=True) +class Problem: + """One problem found while reading or expanding a document. + + - ``message`` -- human-readable description, always present. + - ``path`` -- the logical spot it was found at, such as + ``"q1>ApertureP.shape"``. Empty when the problem is not tied to one place. + This is a location within the document, not a file name or a line number; + ``message`` already names the file where the file is the point. + - ``severity`` -- a :class:`ProblemSeverity`: can the trees still be trusted? + - ``origin`` -- a :class:`ProblemOrigin`: whose problem is it? + + Only a ``PROBLEM_INPUT`` can be cleared by editing the lattice, which is what + makes the last field worth reading: a tool that fails on any problem at all + will fail on lattices whose author has nothing left to fix. + """ + message: str + path: str + severity: ProblemSeverity + origin: ProblemOrigin + + def __str__(self): + out = "ERROR" if self.severity is ProblemSeverity.ERROR else "WARNING" + if self.origin is ProblemOrigin.UNSUPPORTED: + out += " (unsupported)" + elif self.origin is ProblemOrigin.UNSPECIFIED: + out += " (unspecified by PALS)" + if self.path: + out += f" at {self.path}" + return f"{out}: {self.message}" + + +@dataclass(frozen=True) +class Lattices: + """Five representations of a lattice, each as a root :class:`YAMLNode`, plus + the list of problems found while expanding it. + + ``expanded`` and ``full_expanded`` are the same expanded lattice holding the + same values; ``full_expanded`` additionally carries every parameter the + bookkeeper computed, while ``expanded`` keeps only what the author wrote. See + :func:`palsparserpy.parse_and_expand_pals` for what each view holds. + + ``problems`` is a list of :class:`Problem` -- one entry per problem + encountered during expansion (undefined lattice, dangling element/line + references, undefined ``inherit``/``repeat``/``Fork`` targets, misspelled + names, and expressions that could not be evaluated). It is empty when + expansion was clean. Filter it on ``severity`` or ``origin`` to decide what + is worth acting on:: + + lat = parse_and_expand_pals("ex.pals.yaml", problems="none") + mine = [p for p in lat.problems if p.origin is PROBLEM_INPUT] + """ + original: YAMLNode + combined: YAMLNode + expanded: YAMLNode + full_expanded: YAMLNode + adjunct: YAMLNode + problems: List[Problem] = field(default_factory=list) + + +class NodeCorrespondence(NamedTuple): + """The nodes one logical entity maps to in each of the four + derivation-chain trees, grouped by tree. + + ``expanded`` takes no part -- it is a pruned copy of ``full_expanded``, not a + step in the chain. A field is empty when a tree has no corresponding node. + """ + original: List[YAMLNode] + combined: List[YAMLNode] + full_expanded: List[YAMLNode] + adjunct: List[YAMLNode] diff --git a/palsparserpy/to_bmad.py b/palsparserpy/to_bmad.py new file mode 100644 index 0000000..7c09bb8 --- /dev/null +++ b/palsparserpy/to_bmad.py @@ -0,0 +1,1236 @@ +""" +Translation of a PALS lattice into a Bmad lattice file. +""" + +from __future__ import annotations + +import math +import re +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Tuple + +from ._common import (ABRepresentation, FullRepresentation, approx, + ctrl_variables, facility_props, fill_multipoles, fmt, + name_value_pairs, tilt_rotation, value_text) +from .node import YAMLNode + +__all__ = ["BmadEleDef", "BmadBeamline", "BmadController", "BmadLattice", + "pals_to_bmad", "write_bmad_file"] + +_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +_MULTIPOLE_RE = re.compile(r"^MagneticMultipoleP\.([KB])([ns])([0-9]+)(L?)$") + + +@dataclass +class BmadEleDef: + """A single Bmad element definition. + + - ``name``: the element name. + - ``type``: the Bmad element-type name (e.g. ``Drift``, ``Quadrupole``). + - ``attrs``: already-translated attribute fragments, each an + ``"attribute = value"`` string. + """ + name: str + type: str + attrs: List[str] = field(default_factory=list) + + +@dataclass +class BmadBeamline: + """A Bmad ``line`` definition: its ``name`` and the ordered list of member + element ``members`` (by name).""" + name: str + members: List[str] = field(default_factory=list) + + +@dataclass +class BmadController: + """A Bmad ``overlay`` or ``group`` element: what a PALS ``Controller`` + becomes. + + - ``name``: the controller name. + - ``type``: ``"overlay"`` for ``control_type: ABSOLUTE``, ``"group"`` for + ``RELATIVE``. Bmad's overlay sets the slave parameter and its group adds to + it, which is the same split PALS makes. + - ``slaves``: the controlled parameters, each an + ``"ele[attribute]: expression"`` string. + - ``vars``: the variable names, in definition order. + - ``inits``: the variables' initial values, each a ``"name = value"`` string. + """ + name: str + type: str + slaves: List[str] = field(default_factory=list) + vars: List[str] = field(default_factory=list) + inits: List[str] = field(default_factory=list) + + +@dataclass +class BmadLattice: + """An in-memory model of a Bmad lattice. + + Produced by :func:`pals_to_bmad` and serialized to a file by + :func:`write_bmad_file`. The fields mirror the sections of a Bmad lattice + file: + + - ``constants``: ``name = value`` definitions, in definition order. + - ``parameters``: global ``parameter[...] = ...`` settings (species, energy, + geometry). + - ``beginning``: ``beginning[...] = ...`` initial Twiss, coupling and + dispersion settings. + - ``particle_start``: ``particle_start[...] = ...`` initial-coordinate + settings. + - ``elements``: element definitions (:class:`BmadEleDef`). + - ``controllers``: ``overlay``/``group`` definitions + (:class:`BmadController`). + - ``beamlines``: ``line`` definitions (:class:`BmadBeamline`). + - ``use``: branch names for the final ``use, ...`` statement. + """ + constants: List[str] = field(default_factory=list) + parameters: List[str] = field(default_factory=list) + beginning: List[str] = field(default_factory=list) + particle_start: List[str] = field(default_factory=list) + elements: List[BmadEleDef] = field(default_factory=list) + controllers: List[BmadController] = field(default_factory=list) + beamlines: List[BmadBeamline] = field(default_factory=list) + use: List[str] = field(default_factory=list) + + +# --------------------------------------------------------------------------- +def pals_to_bmad(yaml: YAMLNode) -> BmadLattice: + """Translate a parsed PALS lattice ``yaml`` (as returned by + :func:`~palsparserpy.parse_file`) into a :class:`BmadLattice`. + + The returned structure is an in-memory model of the *Bmad* lattice (elements, + beamlines, parameters), not the input PALS tree. Translation is a three-step + process: parse the PALS file with ``parse_file``, build the target model with + ``pals_to_bmad``, then emit the Bmad lattice file with + :func:`write_bmad_file`:: + + yaml = parse_file(file_dir) + write_bmad_file(pals_to_bmad(yaml), filename) + """ + pals = yaml["PALS"] + facility = pals["facility"] + lat = BmadLattice() + + # Constants and variables may be defined directly under `PALS` as well as in + # the facility. + for key in ("constants", "variables"): + if key in pals: + lat.constants.extend(_bmad_constants(pals[key])) + + n_lattices = 0 + for ele in facility: + props = ele.child(0) + # The compact `constants:`/`variables:` list is a facility entry in its + # own right, with no `kind` of its own; every other entry the translation + # looks at is a named element. + if props.node_key() in ("constants", "variables"): + lat.constants.extend(_bmad_constants(props)) + continue + if "kind" not in props: + continue + pals_kind = props["kind"].value + if pals_kind == "BeginningEle": + params, beginning, particle = _ele_to_bmad_str(ele) + lat.parameters.extend(params) + lat.beginning.extend(beginning) + lat.particle_start.extend(particle) + elif pals_kind == "BeamLine": + lat.beamlines.append(_make_bmad_line(ele)) + elif pals_kind == "Lattice": + n_lattices += 1 + if n_lattices > 1: + raise ValueError( + "\nDifferent BeamLine complexes must be translated from " + "separate files.\nBmad only supports one branching lattice " + "per file.\nConsider using different Tao universes.\n") + _add_bmad_branches(lat, props["branches"]) + elif pals_kind == "Controller": + lat.controllers.append(_make_bmad_controller(ele, facility)) + elif pals_kind in ("constant", "variable"): + lat.constants.append(_bmad_constant(props, pals_kind)) + else: + lat.elements.append(_make_bmad_ele(ele)) + return lat + + +# --------------------------------------------------------------------------- +def _add_bmad_branches(lat: BmadLattice, branches: YAMLNode) -> BmadLattice: + """Translate a PALS ``Lattice``'s ``branches`` sequence into ``lat``. + + Append each branch name to ``lat.use`` and its geometry to ``lat.parameters`` + (``parameter[geometry]`` for a single branch, ``[geometry]`` when + several branches are present). + """ + if len(branches) == 0: + return lat + single = len(branches) == 1 + for bl in branches: + if bl.is_scalar(): + name = bl.value + periodic = "open" + elif bl.is_map(): + bl_props = bl.child(0) + name = bl_props.node_key() + # `periodic` is a YAML node, not a string, so it has to be rendered + # before it is compared. + periodic = "closed" if ("periodic" in bl_props and + bl_props["periodic"].value.lower() == "true") \ + else "open" + elif bl.is_sequence(): + raise ValueError("Expanding lattices is not done during PALS>Bmad " + "translation") + else: + raise ValueError(f"This object is neither a scalar, map, nor " + f"sequence: {bl!r}") + lat.use.append(name) + if single: + lat.parameters.append(f"parameter[geometry] = {periodic}") + else: + lat.parameters.append(f"{name}[geometry] = {periodic}") + return lat + + +# --------------------------------------------------------------------------- +def write_bmad_file(lat: BmadLattice, filename) -> None: + """Serialize the :class:`BmadLattice` ``lat`` to ``filename`` as a Bmad + lattice file. + + Write the constant and variable definitions, the global, beginning-Twiss and + particle-start parameters, the element definitions, the ``overlay``/``group`` + definitions, the beamline (``line``) definitions, and the branch (``use``) + statement, each in its own labelled section. The constants come first + because Bmad, unlike PALS, resolves a name against what the file has defined + *above* the point of use. + """ + with open(filename, "w") as out: + n_section = 0 + + # Head each section with the same rule and title, one blank line clear of + # the one before. + def section(title): + nonlocal n_section + n_section += 1 + if n_section != 1: + out.write("\n") + out.write("!=====================================================" + + "=================\n" + f"! {title} \n\n") + + if lat.constants: + section("Constant and variable definitions") + for constant in lat.constants: + out.write(constant + "\n") + + section("Lattice parameters") + for parameter in lat.parameters: + out.write(parameter + "\n") + if lat.beginning: + out.write("\n") + for parameter in lat.beginning: + out.write(parameter + "\n") + if lat.particle_start: + out.write("\n") + for parameter in lat.particle_start: + out.write(parameter + "\n") + + section("Element definitions") + for ele in lat.elements: + out.write(_format_bmad_ele(ele) + "\n") + + # A controller spans several lines, so the definitions are set apart from + # one another. + if lat.controllers: + section("Controller definitions") + out.write("\n\n".join(_format_bmad_controller(c) + for c in lat.controllers) + "\n") + + section("Beamline definitions") + if lat.beamlines: + out.write("\n\n".join(_format_bmad_line(b) for b in lat.beamlines) + "\n") + + section("Branch structure") + if lat.use: + out.write("use, " + ", ".join(lat.use)) + + +# --------------------------------------------------------------------------- +def _format_bmad_ele(ele: BmadEleDef) -> str: + """Render a :class:`BmadEleDef` as a ``name: type, attr = val, ...`` Bmad + element definition, with each attribute on its own tab-indented continuation + line.""" + text = f"{ele.name}: {ele.type}" + for attr in ele.attrs: + text += f",\n\t{attr}" + return text + + +# --------------------------------------------------------------------------- +def _format_bmad_controller(ctrl: BmadController) -> str: + """Render a :class:`BmadController` as a + ``name: overlay = {...}, var = {...}, v = init`` definition. + + A control expression is a line's worth of text on its own, so each slave, the + variable list and each variable's initial value get a tab-indented + continuation line of their own. Every broken line ends in the comma that + continues it. + """ + text = f"{ctrl.name}: {ctrl.type} = {{" + ",\n\t\t".join(ctrl.slaves) + "}" + if ctrl.vars: + text += ",\n\tvar = {" + ", ".join(ctrl.vars) + "}" + for init in ctrl.inits: + text += ",\n\t" + init + return text + + +# --------------------------------------------------------------------------- +def _format_bmad_line(bl: BmadBeamline) -> str: + """Render a :class:`BmadBeamline` as a Bmad ``name: line = (...)`` + definition, wrapping the member list with tab-indented continuation lines to + keep rows under ~80 columns.""" + line_str = "" + tmp = "" + l_tmp = len(bl.name) + 4 + n = len(bl.members) + + for i, member in enumerate(bl.members): + ele_str = member + if i < n - 1: + ele_str += ", " + l_ele_str = len(ele_str) + + if l_tmp + l_ele_str < 80: + tmp += ele_str + l_tmp += l_ele_str + else: + line_str += tmp + "\n" + tmp = "\t" + ele_str + l_tmp = 7 + l_ele_str + line_str += tmp + + wrapped = line_str if len(line_str) < 80 else ("\n\t" + line_str + "\n\t") + return f"{bl.name}: line = ({wrapped})" + + +# --------------------------------------------------------------------------- +def _ele_to_bmad_str(ele: YAMLNode) -> Tuple[List[str], List[str], List[str]]: + """Translate a ``BeginningEle`` element into Bmad global-parameter settings. + + Returns ``(params, beginning, particle_start)`` where ``params`` holds + ``parameter[...]`` strings from the element's ``ReferenceP`` (species and + energy), ``beginning`` holds ``beginning[...]`` strings from its ``TwissP`` + (initial Twiss, coupling and dispersion), and ``particle_start`` holds + ``particle_start[...]`` strings from its ``ParticleP`` (initial phase-space + coordinates and spin). + """ + props = ele.child(0) + params: List[str] = [] + beginning: List[str] = [] + particle: List[str] = [] + for key in props.keys(): + if key == "TwissP": + twissP = props["TwissP"] + for k in twissP.keys(): + # PALS and Bmad give these the same names, bar the coupling + # matrix's underscore. + attribute = "cmat_" + k[4:] if k.startswith("cmat") else k + beginning.append(f"beginning[{attribute}] = {twissP[k].value}") + elif key == "ReferenceP": + referenceP = props["ReferenceP"] + for k in referenceP.keys(): + if k == "species_ref": + params.append(f"parameter[particle] = {referenceP[k].value}") + elif k == "pc_ref": + params.append(f"parameter[p0c] = {referenceP[k].value}") + elif k == "E_tot_ref": + params.append(f"parameter[E_tot] = {referenceP[k].value}") + elif k in ("time_ref", "location"): + print(f"{k} not supported yet") + elif key == "ParticleP": + particleP = props["ParticleP"] + for k in particleP.keys(): + val = particleP[k].value + if k in ("x", "y", "z", "px", "py", "pz", + "spin_x", "spin_y", "spin_z"): + particle.append(f"particle_start[{k}] = {val}") + return params, beginning, particle + + +# --------------------------------------------------------------------------- +def _make_bmad_line(ele: YAMLNode) -> BmadBeamline: + """Translate a ``BeamLine`` element into a :class:`BmadBeamline`. + + Collect the member element names (dropping the leading reference entry, + ``line[0]``, by design) into the returned beamline. + """ + props = ele.child(0) + name = props.node_key() + line = props["line"] + members: List[str] = [] + for i in range(1, len(line)): + line_ele = line.child(i) + if line_ele.is_scalar(): + members.append(line_ele.value) + elif line_ele.is_map() or line_ele.is_sequence(): + members.append(line_ele.child(0).node_key()) + else: + raise ValueError(f"BeamLine {name} element {i + 1} is not scalar or " + "sequence or map") + return BmadBeamline(name, members) + + +# --------------------------------------------------------------------------- +def _bmad_constants(node: YAMLNode) -> List[str]: + """Translate a compact-form ``constants:``/``variables:`` list into Bmad + ``name = value`` definitions. + + Bmad draws no distinction between the two: both become a named value the rest + of the lattice file may use in an expression, so both lists translate the + same way. + """ + return [f"{name} = {value}" for name, value in name_value_pairs(node)] + + +# --------------------------------------------------------------------------- +def _bmad_constant(props: YAMLNode, pals_kind: str) -> str: + """Translate a full-form (``kind: constant``, ``kind: variable``) definition + into a Bmad ``name = value`` definition. + + A definition whose ``value`` is a structure rather than a single value has no + Bmad equivalent and raises an error; one with no ``value`` at all takes PALS' + default of zero. + """ + name = props.node_key() + if "value" not in props: + return f"{name} = 0" + value = props["value"] + if value.is_map() or value.is_sequence(): + raise ValueError(f"{name}: the `value` of a `{pals_kind}` is not a " + "single value") + return f"{name} = {value_text(value)}" + + +# --------------------------------------------------------------------------- +def _bmad_control_target(cname: str, param: str, + facility: YAMLNode) -> Tuple[str, float, float]: + """Translate a controller's ``parameter`` target into a Bmad slave reference. + + Returns ``(target, factor, offset)`` where ``target`` is the + ``"ele[attribute]"`` Bmad reference and the control expression must be + multiplied by ``factor`` and have ``offset`` subtracted from it to hold the + same physics. Neither is trivial in general because the element translation + does not carry PALS parameters across unchanged: a multipole that is not the + element's own becomes Bmad's normalized *integrated* strength ``An``/``Bn``, + so a controller driving a non-integrated one has to pick up the slave's + length (and the ``1/n!`` of the multipole convention) here. A multipole that + *is* the element's own strength becomes ``K1``, ``K2``, ``K3`` or a bend's + ``DG`` (see :func:`_native_strength`), which is not length integrated, so + there an integrated PALS parameter is the one that needs the length; and + ``DG``, alone among them, is measured from the reference bend rather than + from zero, which is the ``offset`` (see :func:`_bend_reference`). A bend's + added ``K1`` and ``K2`` are used only when that order has no skew part, so a + controller driving one of those has to look at the skew component to know + which attribute it will find. + + A target may name its element by kind as well as by name, as + ``{kind}::{name}``; the qualifier is checked against the element found and + then dropped, the Bmad file naming each element once. + + Targets Bmad cannot express -- a pattern matching several elements, a ``>>`` + or ``>>>`` qualifier naming the BeamLine or Lattice an element is reached + through, or a parameter with no Bmad attribute -- raise an error. + """ + # Bmad has a `branch>>ele` qualifier of its own, but a PALS BeamLine is not a + # Bmad branch -- it may be spliced into a longer line -- so the two do not + # correspond. + if ">>" in param: + raise ValueError(f"controller {cname}: `{param}` reaches its element " + "through a BeamLine or Lattice qualifier, which has no " + "Bmad equivalent") + + parts = param.split(">") + if len(parts) != 2: + raise ValueError(f"controller {cname}: control parameter `{param}` is " + "not of the form `element>parameter`") + slave, path = parts + + # An element may be named by its kind as well as by its name. + kind_wanted = None + if "::" in slave: + qualifier = slave.split("::") + if len(qualifier) != 2: + raise ValueError(f"controller {cname}: `{param}` does not name a " + "single element kind") + kind_wanted, slave = qualifier + + if not _NAME_RE.match(slave): + raise ValueError(f"controller {cname}: `{param}` selects slaves by " + "pattern, which a Bmad overlay cannot express") + + props = facility_props(facility, slave) + if props is None: + raise ValueError(f"controller {cname}: `{param}` names no element of the " + "facility") + if kind_wanted is not None: + ele_kind = props["kind"].value if "kind" in props else "" + if kind_wanted != ele_kind: + raise ValueError(f"controller {cname}: `{param}` asks for a " + f"{kind_wanted} but {slave} is a {ele_kind}") + + # A controller may drive another controller's variable, and so may a Bmad + # overlay. + if "kind" in props and props["kind"].value == "Controller": + if not _NAME_RE.match(path): + raise ValueError(f"controller {cname}: `{param}` is not a variable " + f"of controller {slave}") + return f"{slave}[{path}]", 1.0, 0.0 + + if path == "length": + return f"{slave}[L]", 1.0, 0.0 + + m = _MULTIPOLE_RE.match(path) + if m is not None: + order = int(m.group(3)) + skew = m.group(2) == "s" + integrated = m.group(4) == "L" + ele_length = props["length"].as_float() if "length" in props else 1.0 + # A tilted multipole rotates normal and skew into each other, so the one + # PALS parameter no longer maps onto the one Bmad attribute. + if "MagneticMultipoleP" in props and \ + f"tilt{order}" in props["MagneticMultipoleP"]: + if not approx(props["MagneticMultipoleP"][f"tilt{order}"].as_float(), 0): + raise ValueError(f"controller {cname}: `{param}` drives a tilted " + "multipole, which has no single Bmad attribute") + + # The normal component of the element's own multipole is its strength + # attribute, which the element translation writes without the length or + # the factorial. + ele_kind = props["kind"].value if "kind" in props else "" + native = _NATIVE_STRENGTH.get(ele_kind, {}).get(order) + if native is not None and not skew and not (integrated and ele_length == 0) \ + and not (ele_kind == "Bend" and order > 0 and _has_skew(props, order)): + offset = _bend_reference(props, slave, m.group(1) == "K") \ + if ele_kind == "Bend" and order == 0 else 0.0 + attribute = native[0] if m.group(1) == "K" else native[1] + return (f"{slave}[{attribute}]", + 1 / ele_length if integrated else 1.0, offset) + + fact = math.factorial(order) + return (f"{slave}[{'A' if skew else 'B'}{order}]", + (1.0 if integrated else ele_length) / fact, 0.0) + + raise ValueError(f"controller {cname}: control parameter `{param}` is not " + "yet translated to Bmad") + + +# --------------------------------------------------------------------------- +def _make_bmad_controller(ele: YAMLNode, facility: YAMLNode) -> BmadController: + """Translate a ``Controller`` element into a :class:`BmadController`. + + ``facility`` is needed to reach the slave elements: what a control expression + must be scaled by depends on the element it drives (see + :func:`_bmad_control_target`). + """ + props = ele.child(0) + name = props.node_key() + + control_type = props["control_type"].value if "control_type" in props \ + else "ABSOLUTE" + if control_type == "ABSOLUTE": + bmad_type = "overlay" + elif control_type == "RELATIVE": + bmad_type = "group" + else: + raise ValueError(f"{name}: control_type must be ABSOLUTE or RELATIVE, " + f"not {control_type}") + + variables: List[str] = [] + inits: List[str] = [] + for var, value in ctrl_variables(props): + variables.append(var) + inits.append(f"{var} = {value}") + + slaves: List[str] = [] + if "controls" in props: + for control in props["controls"]: + if "parameter" not in control or "expression" not in control: + raise ValueError(f"{name}: a controls entry needs both a " + "`parameter` and an `expression`") + target, factor, offset = _bmad_control_target( + name, control["parameter"].value, facility) + expression = control["expression"].value + if not approx(factor, 1): + expression = f"{fmt(factor)}*({expression})" + # An `overlay` sets the attribute, so an attribute Bmad measures from + # something other than zero needs that something taken off. A `group` + # varies the attribute instead, and what it is measured from is the + # same before and after, so there the offset cancels. + if bmad_type == "overlay" and not approx(offset, 0): + expression = f"{expression} - ({fmt(offset)})" + slaves.append(f"{target}: {expression}") + + return BmadController(name, bmad_type, slaves, variables, inits) + + +# --------------------------------------------------------------------------- +def _bmad_kind(ele_kind: str) -> str: + """The Bmad element-type name for the PALS ``ele_kind``. + + Kinds with no Bmad equivalent (e.g. ``UnionEle``, ``Feedback``) raise an + error. + """ + # Magnets and RF Cavities + # + # PALS has the one `Bend`, whose reference geometry is a sector; the pole + # face rotations that make a bend rectangular are parameters of it + # (`e1_rect`, `e2_rect`), not a second kind. Bmad splits the two, so `Bend` + # maps to Bmad's sector bend and Bmad's `RBend` has no PALS kind to map from. + renamed = {"ACKicker": "AC_Kicker", "Bend": "SBend", + "CrabCavity": "Crab_Cavity", "Multipole": "AB_Multipole", + # Bookkeeping Elements + "BeginningEle": "Beginning_Ele", "FloorShift": "Floor_Shift", + "Placeholder": "Marker", "ReferenceChange": "Patch"} + unchanged = { + # Magnets and RF Cavities + "Drift", "Kicker", "Octupole", "Quadrupole", "RFCavity", "Sextupole", + "Solenoid", "Wiggler", + # Beam and Plasma Elements + "BeamBeam", + # Sources and Collimation + "Converter", "Foil", "Mask", + # Instrumentation and Diagnostics + "Instrument", + # Map Elements + "Match", "Taylor", + # Bookkeeping Elements + "Fiducial", "Fork", "Marker", "Patch", + # Structural and Grouping Elements + "Girder"} + + if ele_kind in renamed: + return renamed[ele_kind] + if ele_kind in unchanged: + return ele_kind + if ele_kind == "EGun": + return "E_Gun" + # Structural and Grouping Elements + if ele_kind == "UnionEle": + raise ValueError("No UnionEle in Bmad") + # External Circuits + if ele_kind == "Feedback": + raise ValueError("No Feedback elements in Bmad") + raise ValueError(f"Element kind {ele_kind} is not translated to Bmad") + + +# --------------------------------------------------------------------------- +def _kind_map(ele_kind: str): + """The multipole representation type used for a given element kind. + + Elements that carry field multipoles map to + :class:`~palsparserpy._common.ABRepresentation`; kinds that have no multipole + attributes, or are unrecognized, raise an error. + """ + if ele_kind in ("Bend", "Quadrupole", "Sextupole", "Octupole", "Multipole", + "Solenoid", "Kicker", "Wiggler", "RFCavity", "CrabCavity"): + return ABRepresentation + if ele_kind in ("EGun", "Mask", "Converter", "Instrument"): + raise ValueError(f"Bmad {ele_kind} has no multipole attributes") + raise ValueError(f"Element type {ele_kind} is unrecognized") + + +# --------------------------------------------------------------------------- +#: The multipole orders an element kind holds as its own strength, and the Bmad +#: attributes that hold them. +#: +#: Each entry maps a PALS element kind to a map from multipole order to +#: ``(normalized_attribute, field_attribute)``: a quadrupole's order-1 field is +#: Bmad's ``K1`` (or ``B1_GRADIENT``), not a ``B1`` multipole. A bend carries a +#: quadrupole and a sextupole component of its own as well as its bending field, +#: so it has three. A bend's order-0 field is ``DG`` (or ``DB_FIELD``), which +#: Bmad measures from the reference bend rather than from zero, so that one is +#: written with an offset (see :func:`_bend_reference`). Kinds whose strength does +#: not line up one-to-one with a PALS multipole -- a kicker's ``HKICK``, a +#: solenoid's ``KS`` -- are deliberately absent, and keep the multipole form. +#: +#: A bend has no attribute above order 2, so its higher multipoles keep the +#: ``An``/``Bn`` form. +_NATIVE_STRENGTH: Dict[str, Dict[int, Tuple[str, str]]] = { + "Bend": {0: ("DG", "DB_FIELD"), 1: ("K1", "B1_GRADIENT"), + 2: ("K2", "B2_GRADIENT")}, + "Quadrupole": {1: ("K1", "B1_GRADIENT")}, + "Sextupole": {2: ("K2", "B2_GRADIENT")}, + "Octupole": {3: ("K3", "B3_GRADIENT")}, +} + + +# --------------------------------------------------------------------------- +def _native_strength(full: FullRepresentation, ele_kind: str, + offset: float = 0.0) -> List[str]: + """Take the multipoles that are an element's own strength out of ``full`` and + return their Bmad attribute fragments. + + The strength of a Bmad quadrupole is its ``K1``, so that is where a PALS + ``Kn1`` belongs: leaving it in a ``B1`` multipole would give an element whose + nominal strength is zero and whose field comes entirely from a multipole slot. + A bend has a ``K1`` and a ``K2`` of its own on top of its bending field, so a + bend's ``Kn1`` and ``Kn2`` land there in the same way. A native attribute is + not length integrated, so an integrated PALS value is divided by the element + length; a tilted one is rotated first, and whatever lands in the skew part is + left behind in ``full`` as an ordinary multipole. That rotation is why the + tilt does not simply become the Bmad element ``tilt``, which is already spoken + for by ``BodyShiftP.z_rot``. + + ``offset`` is subtracted from the order-0 value written, for the one native + attribute Bmad does not measure from zero: a bend's ``DG`` is the departure of + the field from the reference bend (see :func:`_bend_reference`). + + An order is left in ``full`` untouched, to be written in the multipole form, + when it has no native attribute for this kind; when an integrated multipole + sits on a zero-length element, which no non-integrated attribute can express; + and, for a bend's added ``K1`` and ``K2``, when the field has a skew part. As + elsewhere in this conversion, an element with no ``length`` is taken to be one + metre long. + """ + if ele_kind not in _NATIVE_STRENGTH: + return [] + native = _NATIVE_STRENGTH[ele_kind] + + attrs: List[str] = [] + for order in sorted(full.magnitude): + if order not in native: + continue + + length = full.L if full.integrated[order] else 1.0 + if length == 0: + continue + strength = (complex(*full.magnitude[order]) + * tilt_rotation(order, full.tilt.get(order, 0.0)) / length) + + # A bend's `K1` and `K2` are components added to a field the element + # already has, not the strength that makes it the element it is, and Bmad + # has no skew attribute to go with them. So an order with a skew part is + # left whole in the `An`/`Bn` form, which holds both parts in the one + # convention, rather than split between a native attribute and a + # multipole slot. + if ele_kind == "Bend" and order > 0 and not approx(strength.imag, 0): + continue + + # What is left is a skew multipole of the same order, in the same units + # the native attribute was just read in: no longer integrated, and with + # the tilt already applied. + full.magnitude[order] = [0.0, strength.imag] + full.integrated[order] = False + full.tilt.pop(order, None) + + # Order 0 is compared against the offset rather than against zero: a bend + # whose field is the reference bend has no departure from it to write, + # and a PALS file states the two to the same handful of digits, which is + # not enough to subtract exactly. For every other order -- and for an + # offset of zero -- this is the same exact test as before. + off = offset if order == 0 else 0.0 + if approx(strength.real, off): + continue + attribute = native[order][0] if full.normalized[order] else native[order][1] + attrs.append(f"{attribute} = {fmt(strength.real - off)}") + return attrs + + +# --------------------------------------------------------------------------- +def _bend_reference(props: YAMLNode, name: str, normalized: bool) -> float: + """The reference bend strength a ``Bend``'s order-0 normal multipole is + measured against. + + PALS states the field of a bend outright, as ``MagneticMultipoleP.Kn0`` (or + ``Bn0``). Bmad states it as ``DG`` (or ``DB_FIELD``), the departure of the + field from the reference bend the element geometry is built on, so the + reference has to come off the PALS value: ``dg = Kn0 - g_ref``. The reference + is ``BendP.g_ref`` -- or the curvature ``1/radius_ref`` of that same bend -- + for a ``normalized`` multipole, and ``BendP.Bn0_ref`` for an unnormalized one. + A bend with no reference of its own does not bend, and the offset is zero. + + The two flavors cannot be mixed: going from one to the other takes the + reference momentum, which belongs to the branch and not to the element, so a + normalized field measured against an unnormalized reference (or the reverse) + raises an error. + """ + if "BendP" not in props: + return 0.0 + bendP = props["BendP"] + has_g = "g_ref" in bendP or "radius_ref" in bendP + has_B = "Bn0_ref" in bendP + + if normalized: + if has_B and not has_g: + raise ValueError(f"{name}: the bend field (Kn0) and its reference " + "bend (Bn0_ref) are not both normalized") + if "g_ref" in bendP: + return bendP["g_ref"].as_float() + if "radius_ref" in bendP: + return 1 / bendP["radius_ref"].as_float() + else: + if has_g and not has_B: + raise ValueError(f"{name}: the bend field (Bn0) and its reference " + "bend (g_ref) are not both normalized") + if has_B: + return bendP["Bn0_ref"].as_float() + return 0.0 + + +# --------------------------------------------------------------------------- +def _has_skew(props: YAMLNode, order: int) -> bool: + """Whether the element has a nonzero skew multipole of the given ``order``. + + Which Bmad attribute an order lands in can depend on it: a bend's ``K1`` and + ``K2`` are used only for a field with no skew part (see + :func:`_native_strength`), so a controller driving one has to ask. Any of the + four spellings of the component -- normalized or not, integrated or not -- + counts. + """ + if "MagneticMultipoleP" not in props: + return False + mmP = props["MagneticMultipoleP"] + for key in (f"Ks{order}", f"Ks{order}L", f"Bs{order}", f"Bs{order}L"): + if key in mmP and not approx(mmP[key].as_float(), 0): + return True + return False + + +# --------------------------------------------------------------------------- +def _mp_key(rep: ABRepresentation) -> List[str]: + """The Bmad attribute fragments for A/B field-integral multipoles. + + Emits an ``An = ...`` / ``Bn = ...`` fragment for each nonzero coefficient in + ``rep``. + """ + out: List[str] = [] + for order in sorted(rep.A): + if not approx(rep.A[order], 0): + out.append(f"A{order} = {fmt(rep.A[order])}") + if not approx(rep.B[order], 0): + out.append(f"B{order} = {fmt(rep.B[order])}") + return out + + +# --------------------------------------------------------------------------- +def _bmad_quote(text: str) -> Optional[str]: + """``text`` as a quoted Bmad string constant, or ``None`` if it cannot be + quoted. + + Bmad accepts either quote character but has no escape for one inside a string, + so a ``text`` holding a double quote is wrapped in single quotes. One holding + both is unrepresentable. + """ + if '"' not in text: + return f'"{text}"' + if "'" in text: + return None + return f"'{text}'" + + +# --------------------------------------------------------------------------- +def _make_bmad_ele(ele: YAMLNode) -> BmadEleDef: + """Translate a single PALS element into a :class:`BmadEleDef`. + + Dispatch on the element ``kind`` and its parameter groups (aperture, bend, + body shift, multipoles, patch, RF, solenoid, reference change, ...) to build + the Bmad element type and its attribute fragments. Unsupported parameter + groups emit a message or raise an error. + """ + props = ele.child(0) + name = props.node_key() + ele_kind = props["kind"].value + ele_kind_bmad = _bmad_kind(ele_kind) + + attrs: List[str] = [] + + # Strip a trailing comma (and surrounding whitespace) from a fragment before + # storing it. + def push_attr(text): + text = text.rstrip() + if text.endswith(","): + text = text[:-1].rstrip() + if text: + attrs.append(text) + + for key in props.keys(): + if key == "length": + push_attr(f"L = {props['length'].value}") + elif key == "ACKickerP": + raise ValueError("ACKickerP not yet supported") + elif key == "ApertureP": + apertureP = props["ApertureP"] + + has_xmin = "x_min" in apertureP + has_xmax = "x_max" in apertureP + has_xwidth = "x_width" in apertureP + has_xcen = "x_center" in apertureP + has_ymin = "y_min" in apertureP + has_ymax = "y_max" in apertureP + has_ywidth = "y_width" in apertureP + has_ycen = "y_center" in apertureP + + # Shape, location and the rest describe an aperture; they do not put + # one there. Writing them out for a group that sets no limit would + # hand Bmad an aperture the PALS lattice does not have. A `vertices` + # aperture is bounded too, by its vertex list. + has_xaperture = has_xmin or has_xmax or has_xwidth or has_xcen + has_yaperture = has_ymin or has_ymax or has_ywidth or has_ycen + if not (has_xaperture or has_yaperture or "vertices" in apertureP): + continue + + tmp = "" + if (has_xmin or has_xmax) and (has_xwidth or has_xcen): + print(f"\n Ignoring ApertureP of element {name}." + "\n Either x_min and max should be defined " + "or width and center, not both.\n ") + # Bmad states a limit as a distance from the axis, not as a + # coordinate: it loses a particle at `x < -x1_limit`, so the low-side + # limit is the negated PALS `x_min`. + elif has_xwidth: + width = apertureP["x_width"].as_float() + center = apertureP["x_center"].as_float() if has_xcen else 0.0 + tmp += f"x1_limit = {fmt(width / 2 - center)}, " + tmp += f"x2_limit = {fmt(width / 2 + center)}," + elif has_xmin and has_xmax: + tmp += f"x1_limit = {fmt(-apertureP['x_min'].as_float())}, " + tmp += f"x2_limit = {apertureP['x_max'].value}," + push_attr(tmp) + + tmp = "" + if (has_ymin or has_ymax) and (has_ywidth or has_ycen): + print(f"\n Ignoring ApertureP of element {name}." + "\n Either y_min and max should be defined " + "or width and center, not both.\n ") + elif has_ywidth: + width = apertureP["y_width"].as_float() + center = apertureP["y_center"].as_float() if has_ycen else 0.0 + tmp += f"y1_limit = {fmt(width / 2 - center)}, " + tmp += f"y2_limit = {fmt(width / 2 + center)}," + elif has_ymin and has_ymax: + tmp += f"y1_limit = {fmt(-apertureP['y_min'].as_float())}, " + tmp += f"y2_limit = {apertureP['y_max'].value}," + push_attr(tmp) + + for akey in apertureP.keys(): + tmp = "" + if akey == "shape": + shape = apertureP["shape"].value + if shape == "ELLIPTICAL": + tmp += "aperture_type = elliptical," + elif shape == "RECTANGULAR": + tmp += "aperture_type = rectangular," + else: + raise ValueError(f"shape {shape} is not supported") + elif akey == "location": + location = apertureP["location"].value + if location == "ENTRANCE_END": + tmp += "aperture_at = entrance_end," + elif location == "EXIT_END": + tmp += "aperture_at = exit_end," + elif location in ("BOTH_ENDS", "CENTER"): + if location == "CENTER": + print("location=CENTER not supported, set to " + "aperture_at=both_ends") + tmp += "aperture_at = both_ends," + elif location == "EVERYWHERE": + tmp += "aperture_at = continuous," + elif location == "NOWHERE": + tmp += "aperture_at = no_aperture," + elif akey == "aperture_shifts_with_body": + shifts = apertureP["aperture_shifts_with_body"].value.lower() + tmp += f"offset_moves_aperture = {'T' if shifts == 'true' else 'F'}," + elif akey == "aperture_active": + active = apertureP["aperture_active"].value.lower() + tmp += f"is_on = {'T' if active == 'true' else 'F'}," + elif akey == "vertices": + print("vertices not yet supported") + elif akey == "material": + print("material not yet supported") + elif akey == "thickness": + print("thickness not yet supported") + push_attr(tmp) + elif key == "BeamBeamP": + raise ValueError(f"{name}: BeamBeamP not translated yet") + elif key == "BendP": + bendP = props["BendP"] + has_e1 = "e1" in bendP + has_e1_rect = "e1_rect" in bendP + has_e2 = "e2" in bendP + has_e2_rect = "e2_rect" in bendP + if (has_e1 or has_e2) and (has_e1_rect or has_e2_rect): + raise ValueError(f"{name}: should not have both e1 and e1_rect, " + "nor both e2 and e2_rect") + + for bkey in bendP.keys(): + tmp = "" + if bkey == "radius_ref": + tmp += f"rho = {bendP['radius_ref'].value}," + elif bkey == "Bn0_ref": + tmp += f"B_field = {bendP['Bn0_ref'].value}," + + elif bkey in ("e1", "e1_rect"): + tmp += f"e1 = {bendP['e1'].value}," + elif bkey in ("e2", "e2_rect"): + tmp += f"e2 = {bendP['e2'].value}," + + elif bkey == "edge1_int": + val = bendP["edge1_int"].as_float() + if not approx(val, 0): + tmp += "fint = 0.5, " + tmp += f"hgap = {fmt(2 * val)}," + elif bkey == "edge2_int": + val = bendP["edge2_int"].as_float() + if not approx(val, 0): + tmp += "fintx = 0.5, " + tmp += f"hgapx = {fmt(2 * val)}," + elif bkey == "g_ref": + tmp += f"g = {bendP['g_ref'].value}," + elif bkey == "h1": + tmp += f"h1 = {bendP['h1'].value}," + elif bkey == "h2": + tmp += f"h2 = {bendP['h2'].value}," + elif bkey == "L_chord": + raise ValueError(f"{name}: L_chord is a derived quantity for " + "SBend elements") + elif bkey == "L_sagitta": + raise ValueError(f"{name}: L_sagitta is a derived quantity " + "for SBend/RBend elements") + elif bkey == "tilt_ref": + tmp += f"ref_tilt = {bendP['tilt_ref'].value}," + push_attr(tmp) + elif key == "BodyShiftP": + bodyshiftP = props["BodyShiftP"] + for bskey in bodyshiftP.keys(): + tmp = "" + if bskey == "x_offset": + tmp = f"x_offset = {bodyshiftP['x_offset'].value}," + elif bskey == "y_offset": + tmp = f"y_offset = {bodyshiftP['y_offset'].value}," + elif bskey == "z_offset": + tmp = f"z_offset = {bodyshiftP['z_offset'].value}," + elif bskey == "x_rot": + tmp = f"y_pitch = {fmt(-bodyshiftP['x_rot'].as_float())}," + elif bskey == "y_rot": + tmp = f"x_pitch = {bodyshiftP['y_rot'].value}," + elif bskey == "z_rot": + tmp = f"tilt = {bodyshiftP['z_rot'].value}," + push_attr(tmp) + elif key == "ElectricMultipoleP": + raise ValueError("ElectricMultipoleP not yet supported") + elif key == "FloorP": + raise ValueError("FloorP not yet supported") + elif key == "FloorShiftP": + raise ValueError("FloorShiftP not yet supported") + elif key == "ForkP": + raise ValueError("ForkP not yet supported") + elif key == "GirderP": + raise ValueError("GirderP not yet supported") + elif key == "MagneticMultipoleP": + mmP = props["MagneticMultipoleP"] + + full = FullRepresentation() + full.L = props["length"].as_float() if "length" in props else 1.0 + + fill_multipoles(full, mmP, name) + + if all(full.normalized.values()): + pass # push_attr("field_master = F") is the default + elif not any(full.normalized.values()) and ele_kind != "RFCavity": + push_attr("field_master = T") + else: + raise ValueError(f"{name}: Multipoles of one element must be all " + "normalized or all unnormalized.") + + # The multipoles that are the element's own strength become Bmad + # strength attributes; the rest stay multipoles. A bend's order-0 + # attribute is `DG`, which Bmad measures from the reference bend + # rather than from zero. + offset = _bend_reference(props, name, full.normalized[0]) \ + if ele_kind == "Bend" and 0 in full.normalized else 0.0 + attrs.extend(_native_strength(full, ele_kind, offset)) + + # Pick the element-specific representation, then down-convert. + rep = _kind_map(ele_kind)(full) + mp_attrs = _mp_key(rep) + attrs.extend(mp_attrs) + + # Bmad reads An/Bn on an ordinary element as fractions of that + # element's own strength, scaling them by K1*L for a quadrupole, K2*L + # for a sextupole, and so on. PALS multipoles are the field integrals + # themselves, so the scaling has to be turned off. The kinds that hold + # nothing but multipoles do not scale, and have no such attribute to + # set. + if any(re.match(r"[AB][0-9]", a) for a in mp_attrs) and \ + ele_kind_bmad not in ("AB_Multipole", "Multipole", "SAD_Mult"): + push_attr("scale_multipoles = F") + + elif key == "MetaP": + metaP = props["MetaP"] + for mkey in metaP.keys(): + # Bmad keeps three metadata strings. The rest of MetaP (ID, + # location, history and any custom nodes) has nowhere to go in + # Bmad, so it is dropped. + bkey = {"alias": "alias", "label": "type", + "description": "descrip"}.get(mkey, "") + if bkey == "": + print(f"{name}: MetaP.{mkey} has no Bmad equivalent, not " + "translated") + continue + + # `description` (and any component, in principle) may be a + # structure rather than a string, which a Bmad attribute cannot + # hold. + val = metaP[mkey] + if val.is_map() or val.is_sequence(): + print(f"{name}: MetaP.{mkey} is not a simple string, not " + "translated") + continue + + text = _bmad_quote(val.value) + if text is None: + print(f"{name}: MetaP.{mkey} holds both quote characters, " + "not translated") + continue + push_attr(f"{bkey} = {text}") + elif key == "PatchP": + patchP = props["PatchP"] + for pkey in patchP.keys(): + tmp = "" + if pkey == "x_offset": + tmp = f"x_offset = {patchP['x_offset'].value}," + elif pkey == "y_offset": + tmp = f"y_offset = {patchP['y_offset'].value}," + elif pkey == "z_offset": + tmp = f"z_offset = {patchP['z_offset'].value}," + elif pkey == "t_offset": + tmp = f"t_offset = {patchP['t_offset'].value}," + elif pkey == "x_rot": + tmp = f"y_pitch = {fmt(-patchP['x_rot'].as_float())}," + elif pkey == "y_rot": + tmp = f"x_pitch = {patchP['y_rot'].value}," + elif pkey == "z_rot": + tmp = f"tilt = {patchP['z_rot'].value}," + elif pkey == "flexible": + flex = patchP["flexible"].value.lower() + tmp = f"flexible = {'T' if flex == 'true' else 'F'}," + elif pkey == "ref_coords": + ref = patchP["ref_coords"].value + if ref == "ENTRANCE_END": + tmp = "ref_coords = entrance_end," + elif ref == "EXIT_END": + tmp = "ref_coords = exit_end," + elif pkey == "user_sets_length": + usl = patchP["user_sets_length"].value.lower() + tmp = f"user_sets_length = {'T' if usl == 'true' else 'F'}," + push_attr(tmp) + elif key == "RFP": + rfP = props["RFP"] + if props["kind"].value == "CrabCavity": + raise ValueError(f"{name}: CrabCavity not yet translated") + for rfkey in rfP.keys(): + tmp = "" + if rfkey == "frequency": + tmp += f"rf_frequency = {rfP['frequency'].value}, " + tmp += "harmon_master = false," + + elif rfkey == "harmon": + tmp += f"harmon = {rfP['harmon'].value}, " + tmp += "harmon_master = true," + + elif rfkey == "voltage": + tmp += f"voltage = {rfP['voltage'].value}," + + elif rfkey == "gradient": + if "L" in props and props["L"].as_float() != 0: + length = props["L"].as_float() + grad = rfP["gradient"].as_float() + tmp += f"voltage = {fmt(grad * length)}," + print(f"{name}: gradient not yet supported, replacing " + "with voltage = gradient * length") + else: + raise ValueError( + f"{name}: `gradient` not yet supported & `length` is " + "undefined => voltage is undefined") + + elif rfkey == "phase": + tmp += f"phi0 = {rfP['phase'].value}," + + elif rfkey == "multipass_phase": + tmp += f"phi0_multipass = {rfP['multipass_phase'].value}," + + elif rfkey == "cavity_type": + tmp += f"cavity_type = {rfP['cavity_type'].value}," + + elif rfkey == "num_cells": + tmp += f"n_cell = {rfP['num_cells'].value}," + + elif rfkey == "zero_phase": + zp = rfP["zero_phase"].value + if zp == "ACCELERATING": + raise ValueError(f"{name}: `Accelerating` phase is not " + "supported with phi0_autoscale in Bmad") + elif zp == "BELOW_TRANSITION": + tmp += "rf_phase_below_transition_ref = T," + elif zp == "ABOVE_TRANSITION": + tmp += "rf_phase_below_transition_ref = F," + else: + print(f"{name}: unknown zero_phase type") + + elif rfkey == "L_active": + raise ValueError(f"{name}: `L_active` is a dependent " + "parameter in Bmad") + + elif rfkey == "dE_ref": + raise ValueError(f"{name}: needs translation to LCavity for " + "`dE_ref`") + push_attr(tmp) + if "frequency" in rfP and "harmon" in rfP: + raise ValueError(f"{name}: can only define `frequency` or " + "`harmon` but not both") + elif key == "SolenoidP": + solP = props["SolenoidP"] + if solP.keys(): + if "Ksol" in solP: + push_attr(f"ks = {solP['Ksol'].value}") + elif "Bsol" in solP: + push_attr("field_master = T") + push_attr(f"bs_field = {solP['Bsol'].value}") + else: + print(f"{name} - unknown key(s): {solP.keys()}") + elif key == "TrackingP": + trackingP = props["TrackingP"] + for tkey in trackingP.keys(): + if tkey == "Bmad": + pass + elif key == "ReferenceChangeP": + if ele_kind_bmad != "Patch": + raise ValueError( + f"{name}: Bmad reference changes only allowed in Patch " + "elements (PALS: Patch / RefereneChange)") + refchangeP = props["ReferenceChangeP"] + for rkey in refchangeP.keys(): + if rkey == "dtime_ref": + push_attr(f"t_offset = {refchangeP['dtime_ref'].value}") + + elif rkey == "dE_ref": + push_attr(f"E_tot_offset = {refchangeP['dE_ref'].value}") + + elif rkey == "dpc_ref": + raise ValueError(f"{name}: dpc_ref (p0c_offset) not " + "supported by Bmad, only E_tot_offset") + + elif rkey == "time_ref": + raise ValueError(f"{name}: setting time_ref is not supported " + "by Bmad") + + elif rkey == "E_tot_ref": + push_attr(f"E_tot_set = {refchangeP['E_tot_ref'].value}") + + elif rkey == "pc_ref": + push_attr(f"p0c_set = {refchangeP['pc_ref'].value}") + + elif rkey == "species_ref": + raise ValueError(f"{name}: changing species in-beamline is " + "not supported by Bmad") + + return BmadEleDef(name, ele_kind_bmad, attrs) diff --git a/palsparserpy/to_madx.py b/palsparserpy/to_madx.py new file mode 100644 index 0000000..31a5eb9 --- /dev/null +++ b/palsparserpy/to_madx.py @@ -0,0 +1,1926 @@ +""" +Translation of a PALS lattice into a MAD-X lattice file. +""" + +from __future__ import annotations + +import math +import re +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Tuple + +from ._common import (FullRepresentation, approx, ctrl_variables, facility_props, + fill_multipoles, fmt, name_value_pairs, tilt_rotation, + try_float, value_text) +from .node import YAMLNode +from .parser import evaluate_pals_expression + +__all__ = ["MadxEleDef", "MadxBeamline", "MadxController", "MadxAlignment", + "MadxLattice", "pals_to_madx", "write_madx_file"] + +_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +_MULTIPOLE_RE = re.compile(r"^MagneticMultipoleP\.([KB])([ns])([0-9]+)(L?)$") +_ATTR_RE = re.compile(r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$") + +#: The name of the MAD-X variable holding the *signed* magnetic rigidity ``P0/q`` +#: of the reference particle, which the translation writes out when a lattice +#: states a field rather than a normalized strength. +#: +#: MAD-X has no field-valued strength attribute: every magnet strength it holds is +#: normalized. A PALS field therefore has to be divided by the rigidity here, +#: which MAD-X can compute for itself from the ``BEAM`` command: ``beam->brho`` is +#: ``P0/|q|``, so the sign of the charge has to be put back. +_MADX_RIGIDITY = "pals_brho" + +#: The MAD-X expression for the relativistic ``beta`` of the reference particle. +#: +#: MAD-X measures the longitudinal coordinates and the dispersion against +#: ``pt = dE/(p0 c)`` where PALS measures them against ``pz = dp/p0``, and the two +#: differ by exactly this factor (``pt = beta * pz``). MAD-X computes it from the +#: ``BEAM`` command, so the conversion can be left as an expression rather than +#: worked out here. +_MADX_BETA = "beam->beta" + +#: The MAD-X keywords that may not be used as a label. +#: +#: MAD-X protects its keywords: a lattice whose element is named after one is a +#: fatal error there rather than here, so the translation reports it instead. The +#: list holds the element-type keywords and the commands a lattice file is likely +#: to collide with, not every MAD-X command. +_MADX_KEYWORDS = { + "marker", "drift", "sbend", "rbend", "dipedge", "quadrupole", "sextupole", + "octupole", "multipole", "solenoid", "nllens", "hkicker", "vkicker", + "kicker", "tkicker", "rfcavity", "twcavity", "rfmultipole", "crabcavity", + "hacdipole", "vacdipole", "elseparator", "hmonitor", "vmonitor", "monitor", + "instrument", "placeholder", "collimator", "ecollimator", "rcollimator", + "beambeam", "wire", "matrix", "yrotation", "xrotation", "srotation", + "translation", "changeref", "sixmarker", + "line", "sequence", "beam", "beta0", "use", "select", "twiss", "track", + "survey", "match", "ealign", "efcomp", "eoption", "value", "show", "option", + "title", "call", "return"} + + +@dataclass +class MadxEleDef: + """A single MAD-X element definition. + + - ``name``: the element name. + - ``type``: the MAD-X element-type keyword (e.g. ``drift``, ``quadrupole``). + - ``attrs``: already-translated attribute fragments, each an + ``"attribute = value"`` string. + - ``notes``: comment lines to write above the definition. MAD-X elements carry + no metadata strings of their own, so a PALS ``MetaP`` becomes a comment + here, as does anything else worth saying about the element in the file it is + written to. + """ + name: str + type: str + attrs: List[str] = field(default_factory=list) + notes: List[str] = field(default_factory=list) + + +@dataclass +class MadxBeamline: + """A MAD-X ``line`` definition: its ``name`` and the ordered list of member + element ``members`` (by name).""" + name: str + members: List[str] = field(default_factory=list) + + +@dataclass +class MadxController: + """A MAD-X rendering of a PALS ``Controller``. + + MAD-X has no controller element. What it has instead is the deferred + assignment ``:=``, which makes an element attribute depend on a variable + rather than take its value once, and that is what a controller becomes: its + variables become ordinary MAD-X variables and each of its controls becomes a + deferred assignment to the attribute it drives. + + - ``name``: the controller name, written out as a comment heading. + - ``vars``: the variables' initial values, each a ``"name = value"`` string. + - ``controls``: the deferred assignments, each an + ``"ele->attribute := expression"`` string. + - ``notes``: comment lines to write above the definitions, holding the + controller's ``MetaP``. + """ + name: str + vars: List[str] = field(default_factory=list) + controls: List[str] = field(default_factory=list) + notes: List[str] = field(default_factory=list) + + +@dataclass +class MadxAlignment: + """The misalignment of one element: what a PALS ``BodyShiftP`` becomes. + + MAD-X keeps a misalignment apart from the element definition, in an + ``EALIGN`` command applied to whatever the preceding ``SELECT, FLAG=ERROR`` + picked out. ``name`` is the element the errors belong to and ``attrs`` the + ``EALIGN`` attribute fragments. + """ + name: str + attrs: List[str] = field(default_factory=list) + + +@dataclass +class MadxLattice: + """An in-memory model of a MAD-X lattice. + + Produced by :func:`pals_to_madx` and serialized to a file by + :func:`write_madx_file`. The fields mirror the sections of a MAD-X lattice + file: + + - ``constants``: ``name = value;`` definitions, in definition order. + - ``beam``: the attributes of the ``BEAM`` command (species and energy). + - ``beta0``: the attributes of the initial-conditions ``BETA0`` block (Twiss + and dispersion). + - ``particle_start``: initial particle coordinates, which MAD-X takes in the + ``TRACK`` module rather than in a lattice file, and which are written out as + a comment. + - ``elements``: element definitions (:class:`MadxEleDef`). + - ``controllers``: variables and deferred assignments + (:class:`MadxController`). + - ``alignments``: ``EALIGN`` misalignments (:class:`MadxAlignment`). + - ``beamlines``: ``line`` definitions (:class:`MadxBeamline`). + - ``use``: the branches, each a ``(name, periodic)`` pair, for the ``use`` + statement. + - ``rigidity``: whether anything written refers to the rigidity variable, + which then has to be defined ahead of it. + """ + constants: List[str] = field(default_factory=list) + beam: List[str] = field(default_factory=list) + beta0: List[str] = field(default_factory=list) + particle_start: List[str] = field(default_factory=list) + elements: List[MadxEleDef] = field(default_factory=list) + controllers: List[MadxController] = field(default_factory=list) + alignments: List[MadxAlignment] = field(default_factory=list) + beamlines: List[MadxBeamline] = field(default_factory=list) + use: List[Tuple[str, bool]] = field(default_factory=list) + rigidity: bool = False + + +# --------------------------------------------------------------------------- +def pals_to_madx(yaml: YAMLNode) -> MadxLattice: + """Translate a parsed PALS lattice ``yaml`` (as returned by + :func:`~palsparserpy.parse_file`) into a :class:`MadxLattice`. + + The returned structure is an in-memory model of the *MAD-X* lattice + (elements, lines, beam), not the input PALS tree. Translation is a three-step + process: parse the PALS file with ``parse_file``, build the target model with + ``pals_to_madx``, then emit the MAD-X lattice file with + :func:`write_madx_file`:: + + yaml = parse_file(file_dir) + write_madx_file(pals_to_madx(yaml), filename) + + Controllers are translated after the elements, in a second pass: a + ``control_type: RELATIVE`` controller adds to the value the element already + carries, and the only place that value is written down is the element + definition this pass has just built. + """ + pals = yaml["PALS"] + facility = pals["facility"] + lat = MadxLattice() + + # Constants and variables may be defined directly under `PALS` as well as in + # the facility. + for key in ("constants", "variables"): + if key in pals: + lat.constants.extend(_madx_constants(pals[key])) + + n_lattices = 0 + controllers: List[YAMLNode] = [] + for ele in facility: + props = ele.child(0) + # The compact `constants:`/`variables:` list is a facility entry in its + # own right, with no `kind` of its own; every other entry the translation + # looks at is a named element. + if props.node_key() in ("constants", "variables"): + lat.constants.extend(_madx_constants(props)) + continue + if "kind" not in props: + continue + pals_kind = props["kind"].value + if pals_kind == "BeginningEle": + beam, beta0, particle = _ele_to_madx_str(ele) + lat.beam.extend(beam) + lat.beta0.extend(beta0) + lat.particle_start.extend(particle) + elif pals_kind == "BeamLine": + lat.beamlines.append(_make_madx_line(ele, facility)) + elif pals_kind == "Lattice": + n_lattices += 1 + if n_lattices > 1: + raise ValueError( + "\nDifferent BeamLine complexes must be translated from " + "separate files.\nA MAD-X run expands one sequence at a " + "time.\n") + _add_madx_branches(lat, props["branches"]) + elif pals_kind == "Controller": + controllers.append(ele) + elif pals_kind in ("constant", "variable"): + lat.constants.append(_madx_constant(props, pals_kind)) + else: + definition, align = _make_madx_ele(ele) + lat.elements.append(definition) + if align.attrs: + lat.alignments.append(align) + if any(_MADX_RIGIDITY in a for a in definition.attrs): + lat.rigidity = True + + # A PALS controller owns its variables and MAD-X has no such scope, so what + # each one is called in the file has to be settled before any of them is + # written out or referred to. + varmap, initials = _madx_variable_names(controllers, lat.constants) + for ele in controllers: + lat.controllers.append( + _make_madx_controller(ele, facility, lat, varmap, initials)) + _check_madx_variables(lat) + if any(_MADX_RIGIDITY in s for c in lat.controllers for s in c.controls): + lat.rigidity = True + + return lat + + +# --------------------------------------------------------------------------- +def _madx_definition_name(defn: str) -> str: + """The name a ``"name = value"`` definition defines.""" + return defn.split("=")[0].strip() + + +# --------------------------------------------------------------------------- +def _madx_variable_names(controllers: List[YAMLNode], constants: List[str] + ) -> Tuple[Dict[Tuple[str, str], str], Dict[str, str]]: + """Decide what each controller variable is called in the MAD-X file. + + Returns ``(names, initials)`` where ``names`` maps a + ``(controller, variable)`` pair to its MAD-X name and ``initials`` maps that + MAD-X name to the variable's initial value. + + A PALS controller owns its variables: ``ps1>cur`` and ``ps2>cur`` are two + independent knobs, and the standard's own example uses exactly that. A MAD-X + variable is a name in the one namespace the whole file shares, so a variable + whose bare name is claimed by another controller, or by a constant, is + prefixed with the controller that owns it. One that is claimed by nobody else + keeps its bare name, which is what nearly every lattice will have and is far + the easier to read. + """ + claimed: Dict[str, int] = {} + for ele in controllers: + for var, _ in ctrl_variables(ele.child(0)): + claimed[var] = claimed.get(var, 0) + 1 + taken = {_madx_definition_name(c) for c in constants} + + names: Dict[Tuple[str, str], str] = {} + initials: Dict[str, str] = {} + for ele in controllers: + cname = ele.child(0).node_key() + for var, value in ctrl_variables(ele.child(0)): + madx = f"{cname}__{var}" if (claimed[var] > 1 or var in taken) else var + names[(cname, var)] = madx + initials[madx] = value + return names, initials + + +# --------------------------------------------------------------------------- +def _check_madx_variables(lat: MadxLattice) -> None: + """Report two MAD-X definitions that would claim the one name. + + :func:`_madx_variable_names` prefixes a controller variable that another + controller's variable or a constant already claims, which settles every + collision a PALS lattice can have honestly. This is the backstop for the one + it cannot: a constant named after the prefixed form itself. + """ + seen: Dict[str, str] = {} + for constant in lat.constants: + seen[_madx_definition_name(constant)] = "a constant or variable definition" + for ctrl in lat.controllers: + for var in ctrl.vars: + name = _madx_definition_name(var) + if name in seen: + raise ValueError( + f"controller {ctrl.name}: variable `{name}` is already " + f"defined by {seen[name]}; MAD-X variables are global, so " + "the two would drive one another") + seen[name] = f"controller {ctrl.name}" + + +# --------------------------------------------------------------------------- +def _madx_substitute(expr: str, replacements: Dict[str, str]) -> str: + """``expr`` with each name in ``replacements`` replaced by what it maps to. + + Used to put a controller's variables into an expression under whatever MAD-X + calls them (see :func:`_madx_variable_names`), and to put their initial values + in place of them. The match is on whole identifiers, and a name reached + through a dot is left alone, so a variable ``cur`` does not rewrite + ``current`` nor ``SELF.cur``. Every name is replaced in one pass, so a + replacement is never itself replaced. + """ + if not replacements: + return expr + alternatives = "|".join(re.escape(name) for name in replacements) + pattern = re.compile(f"(? MadxLattice: + """Translate a PALS ``Lattice``'s ``branches`` sequence into ``lat``. + + Append each branch to ``lat.use`` as a ``(name, periodic)`` pair. MAD-X has no + geometry attribute of its own: whether a branch closes on itself is decided by + how it is used -- a ``TWISS`` given no initial conditions looks for the + periodic solution -- so the flag is carried through to the comment + :func:`write_madx_file` writes beside the ``use`` statement. + """ + if len(branches) == 0: + return lat + for bl in branches: + if bl.is_scalar(): + name = bl.value + periodic = False + elif bl.is_map(): + bl_props = bl.child(0) + name = bl_props.node_key() + periodic = ("periodic" in bl_props and + bl_props["periodic"].value.lower() == "true") + elif bl.is_sequence(): + raise ValueError("Expanding lattices is not done during PALS>MAD-X " + "translation") + else: + raise ValueError(f"This object is neither a scalar, map, nor " + f"sequence: {bl!r}") + lat.use.append((name, periodic)) + return lat + + +# --------------------------------------------------------------------------- +def write_madx_file(lat: MadxLattice, filename) -> None: + """Serialize the :class:`MadxLattice` ``lat`` to ``filename`` as a MAD-X + lattice file. + + Write the constant and variable definitions, the ``BEAM`` command and initial + conditions, the element definitions, the controller variables and their + deferred assignments, the ``line`` definitions, the ``use`` statement, and the + ``EALIGN`` misalignments, each in its own labelled section. + + The order of the sections is the order MAD-X needs them in, which is stricter + than Bmad's: a name has to be defined above the point of use, ``BEAM`` has to + come before ``USE``, and the ``SELECT``/``EALIGN`` pairs have to come after + it, because there is no sequence to apply an error to until one has been + expanded. + """ + with open(filename, "w") as out: + n_section = 0 + + # Head each section with the same rule and title, one blank line clear of + # the one before. + def section(title): + nonlocal n_section + n_section += 1 + if n_section != 1: + out.write("\n") + out.write("!=====================================================" + + "=================\n" + f"! {title} \n\n") + + if lat.constants: + section("Constant and variable definitions") + for constant in lat.constants: + out.write(constant + ";\n") + + if lat.beam or lat.beta0 or lat.particle_start or lat.rigidity: + section("Beam and initial conditions") + if lat.beam: + out.write("beam, " + ", ".join(lat.beam) + ";\n") + + # A field the lattice states rather than normalizes is divided by + # this. MAD-X's own `beam->brho` is P0/|q|, and the PALS + # normalization is by the signed charge. + if lat.rigidity: + out.write("\n! Signed magnetic rigidity P0/q, which normalizes a " + "stated field.\n" + f"{_MADX_RIGIDITY} := beam->brho * beam->charge / " + "abs(beam->charge);\n") + + if lat.beta0: + out.write("\npals_beta0: beta0,\n\t" + ",\n\t".join(lat.beta0) + + ";\n! twiss, beta0 = pals_beta0;\n") + + # MAD-X starts a particle in the TRACK module, which has no place in + # a lattice file. + if lat.particle_start: + out.write("\n! Initial particle coordinates. MAD-X sets these " + "with the START command:\n" + "! track;\n" + "! start, " + ", ".join(lat.particle_start) + ";\n" + "! run, turns = 1;\n" + "! endtrack;\n") + + section("Element definitions") + for ele in lat.elements: + out.write(_format_madx_ele(ele) + "\n") + + # A controller spans several lines, so the definitions are set apart from + # one another. + if lat.controllers: + section("Controller definitions") + out.write("\n\n".join(_format_madx_controller(c) + for c in lat.controllers) + "\n") + + section("Beamline definitions") + if lat.beamlines: + out.write("\n\n".join(_format_madx_line(b) for b in lat.beamlines) + "\n") + + section("Branch structure") + for i, (name, periodic) in enumerate(lat.use): + # MAD-X expands one sequence at a time, and each `use` replaces the + # last, so only the first branch can be the active one. + prefix = "" if i == 0 else "! " + geometry = "closed" if periodic else "open" + out.write(f"{prefix}use, period = {name};\t! {geometry}\n") + if len(lat.use) > 1: + out.write("! Only one branch can be expanded at a time; the rest are " + "commented out.\n") + + # An EALIGN applies to whatever the preceding SELECT picked out of the + # expanded sequence, so this section can only come after the `use` above. + if lat.alignments: + section("Alignment errors") + out.write("\n\n".join(_format_madx_alignment(a) + for a in lat.alignments) + "\n") + + +# --------------------------------------------------------------------------- +def _format_madx_ele(ele: MadxEleDef) -> str: + """Render a :class:`MadxEleDef` as a ``name: type, attr = val, ...;`` MAD-X + element definition, with each attribute on its own tab-indented continuation + line and each note on a comment line above.""" + text = "" + for note in ele.notes: + text += f"! {note}\n" + text += f"{ele.name}: {ele.type}" + for attr in ele.attrs: + text += f",\n\t{attr}" + return text + ";" + + +# --------------------------------------------------------------------------- +def _format_madx_controller(ctrl: MadxController) -> str: + """Render a :class:`MadxController` as its variable definitions followed by + the deferred assignments that depend on them, under a comment naming the + controller they came from.""" + text = f"! Controller {ctrl.name}\n" + for note in ctrl.notes: + text += f"! {note}\n" + for var in ctrl.vars: + text += f"{var};\n" + return text + "\n".join(c + ";" for c in ctrl.controls) + + +# --------------------------------------------------------------------------- +def _format_madx_alignment(align: MadxAlignment) -> str: + """Render a :class:`MadxAlignment` as the ``SELECT``/``EALIGN`` pair that + applies it. + + The element is picked out by an anchored pattern rather than by a range so + that a name which is a prefix of another one does not take its neighbour's + errors with it. A MAD-X label may hold a decimal point, which a MAD-X pattern + reads as "any character", so the name is escaped. + """ + pattern = re.sub(r"([.*\[\]^$\\])", r"\\\1", align.name) + return ("select, flag = error, clear;\n" + f'select, flag = error, pattern = "^{pattern}$";\n' + "ealign, " + ", ".join(align.attrs) + ";") + + +# --------------------------------------------------------------------------- +def _format_madx_line(bl: MadxBeamline) -> str: + """Render a :class:`MadxBeamline` as a MAD-X ``name: line = (...);`` + definition, wrapping the member list with tab-indented continuation lines to + keep rows under ~80 columns.""" + line_str = "" + tmp = "" + l_tmp = len(bl.name) + 4 + n = len(bl.members) + + for i, member in enumerate(bl.members): + ele_str = member + if i < n - 1: + ele_str += ", " + l_ele_str = len(ele_str) + + if l_tmp + l_ele_str < 80: + tmp += ele_str + l_tmp += l_ele_str + else: + line_str += tmp + "\n" + tmp = "\t" + ele_str + l_tmp = 7 + l_ele_str + line_str += tmp + + wrapped = line_str if len(line_str) < 80 else ("\n\t" + line_str + "\n\t") + return f"{bl.name}: line = ({wrapped});" + + +# --------------------------------------------------------------------------- +def _madx_scale(text: str, factor: float) -> str: + """``text`` scaled by ``factor``, as a MAD-X value. + + PALS and MAD-X differ in the units of nearly every quantity that is not a + length or an angle: energies are eV against GeV, voltages V against MV, + frequencies Hz against MHz. A value written as a number is scaled here and + comes out a number; one written as an expression -- a constant, say -- is left + for MAD-X to evaluate and comes out an expression. + """ + if factor == 1: + return text + value = try_float(text) + return f"({text}) * {fmt(factor)}" if value is None else fmt(value * factor) + + +# --------------------------------------------------------------------------- +def _madx_shift(text: str, offset: float) -> str: + """``text`` with ``offset`` added, as a MAD-X value. As with + :func:`_madx_scale`, a number comes out a number and an expression comes out + an expression.""" + if offset == 0: + return text + value = try_float(text) + return f"({text}) + {fmt(offset)}" if value is None else fmt(value + offset) + + +# --------------------------------------------------------------------------- +def _madx_divide(text: str, divisor: str) -> str: + """``text`` divided by the MAD-X expression ``divisor``, which is not a number + here and so cannot be worked out during translation.""" + return f"({text}) / {divisor}" + + +# --------------------------------------------------------------------------- +def _madx_strength(value: float, normalized: bool) -> str: + """A magnet strength as a MAD-X value. + + Every MAD-X strength attribute is normalized, so a PALS component given as a + field is divided by the reference rigidity instead of being written out as it + stands. + """ + return fmt(value) if normalized else f"{fmt(value)} / {_MADX_RIGIDITY}" + + +# --------------------------------------------------------------------------- +def _madx_logical(node: YAMLNode) -> str: + """A PALS boolean as MAD-X's ``true``/``false``.""" + return "true" if node.value.lower() == "true" else "false" + + +# --------------------------------------------------------------------------- +def _madx_check_name(name: str) -> str: + """Report a name MAD-X cannot hold, and warn about one it would quietly + truncate. + + A MAD-X label is at most sixteen characters -- the rest are dropped, which can + turn two elements into one -- and may not be one of MAD-X's own keywords, + which is a fatal error there. + """ + if name.lower() in _MADX_KEYWORDS: + raise ValueError(f"{name}: is a MAD-X keyword and cannot be used as a " + "label") + if len(name) > 16: + print(f"{name}: is longer than the 16 characters a MAD-X label keeps; " + "the rest will be dropped") + return name + + +# --------------------------------------------------------------------------- +#: The PALS predefined constants MAD-X either spells differently or does not have +#: at all. +#: +#: ``pi`` is the one the two agree on and is absent from this list. A value of +#: ``None`` means MAD-X has no constant for it. MAD-X's ``emass``, ``pmass`` and +#: ``mumass`` are masses in GeV, where PALS' ``mass_of`` is in eV, so they are not +#: a rename of anything here. +_MADX_CONSTANT_NAMES: Dict[str, Optional[str]] = { + "c_light": "clight", "e_charge": "qelect", + "r_electron": "erad", "r_proton": "prad", + "h_planck": None, "hbar": None, + "k_boltzmann": None, "eps_0_vac": None, + "mu_0_vac": "amu0", "classical_radius_factor": None, + "fine_structure": None, "n_avogadro": None} + + +# --------------------------------------------------------------------------- +def _madx_check_expression(where: str, text: str) -> str: + """Report a PALS expression MAD-X has no way to evaluate, and return ``text`` + unchanged. + + An expression is carried across as it stands, MAD-X's arithmetic and its + ordinary functions being PALS' as well. Two things in one are not: PALS' + particle-data functions, which look up a species in a table MAD-X does not + carry, and most of PALS' predefined constants, which MAD-X either spells + differently or does not have. Both are reported rather than rewritten -- as + they are for the other translators, expression translation being an open item + for all of them. + """ + for fn in ("mass_of", "charge_of", "anomalous_moment_of"): + if f"{fn}(" in text: + print(f"{where}: `{fn}` is a PALS function with no MAD-X equivalent; " + f"`{text}` will not evaluate") + for name, madx in _MADX_CONSTANT_NAMES.items(): + if not re.search(f"(? List[str]: + """Translate a compact-form ``constants:``/``variables:`` list into MAD-X + ``name = value`` definitions. + + MAD-X draws no distinction between the two: both become a named value the rest + of the lattice file may use in an expression, so both lists translate the same + way. + """ + return [f"{name} = {_madx_check_expression(name, value)}" + for name, value in name_value_pairs(node)] + + +# --------------------------------------------------------------------------- +def _madx_constant(props: YAMLNode, pals_kind: str) -> str: + """Translate a full-form (``kind: constant``, ``kind: variable``) definition + into a MAD-X ``name = value`` definition. + + A definition whose ``value`` is a structure rather than a single value has no + MAD-X equivalent and raises an error; one with no ``value`` at all takes PALS' + default of zero. A MAD-X variable is a value and nothing else, so the error + bars a PALS definition may carry are reported. + """ + name = props.node_key() + for err in ("absolute_error", "relative_error"): + if err in props: + print(f"{name}: a MAD-X variable carries no error bar, so {err} is " + "not translated") + if "value" not in props: + return f"{name} = 0" + value = props["value"] + if value.is_map() or value.is_sequence(): + raise ValueError(f"{name}: the `value` of a `{pals_kind}` is not a " + "single value") + return f"{name} = {_madx_check_expression(name, value_text(value))}" + + +# --------------------------------------------------------------------------- +def _madx_species(species: str) -> str: + """Map a PALS reference species onto a MAD-X ``PARTICLE``. + + MAD-X knows the mass and charge of a fixed handful of species and nothing + else; anything outside that set has to be given its mass and charge outright, + which PALS does not state and which the translation therefore cannot supply. + """ + known = {"positron": "positron", "electron": "electron", "proton": "proton", + "anti-proton": "antiproton", "antiproton": "antiproton", + "muon+": "posmuon", "posmuon": "posmuon", + "muon-": "negmuon", "negmuon": "negmuon", "muon": "negmuon"} + name = species.strip("\"' ").lower() + if name in known: + return known[name] + print(f"species_ref `{species}` is not one MAD-X knows; its mass and charge " + "have to be given to the BEAM command by hand") + return name + + +# --------------------------------------------------------------------------- +def _ele_to_madx_str(ele: YAMLNode) -> Tuple[List[str], List[str], List[str]]: + """Translate a ``BeginningEle`` element into the MAD-X beam and + initial-condition settings. + + Returns ``(beam, beta0, particle)`` where ``beam`` holds the ``BEAM`` + attributes from the element's ``ReferenceP`` (species and energy), ``beta0`` + holds the ``BETA0`` attributes from its ``TwissP`` (initial Twiss and + dispersion), and ``particle`` holds the ``START`` attributes from its + ``ParticleP`` (initial phase-space coordinates). + + Three PALS quantities do not survive the crossing: + + - PALS states the Twiss parameters in the ``a``/``b`` normal modes and + MAD-X in the ``x``/``y`` planes, which are the same thing only when the + lattice is uncoupled. + - The coupling itself is stated as Bmad's ``C`` matrix here and as MAD-X's + ``R`` matrix there, which are different parametrizations, so ``cmat11`` + and its fellows are not translated. + - MAD-X has no dispersion derivative, only the momentum dispersion, so + ``deta_x_ds`` is not translated either. + """ + props = ele.child(0) + name = props.node_key() + beam: List[str] = [] + beta0: List[str] = [] + particle: List[str] = [] + for key in props.keys(): + if key == "TwissP": + twissP = props["TwissP"] + for k in twissP.keys(): + val = twissP[k].value + if k == "beta_a": + beta0.append(f"betx = {val}") + elif k == "beta_b": + beta0.append(f"bety = {val}") + elif k == "alpha_a": + beta0.append(f"alfx = {val}") + elif k == "alpha_b": + beta0.append(f"alfy = {val}") + # MAD-X counts the phase in turns where PALS counts it in radians. + elif k == "phi_a": + beta0.append(f"mux = {_madx_scale(val, 1 / (2 * math.pi))}") + elif k == "phi_b": + beta0.append(f"muy = {_madx_scale(val, 1 / (2 * math.pi))}") + # MAD-X differentiates against `pt` and PALS against `pz`, and + # `pt = beta * pz`. + elif k == "eta_x": + beta0.append(f"dx = {_madx_divide(val, _MADX_BETA)}") + elif k == "eta_y": + beta0.append(f"dy = {_madx_divide(val, _MADX_BETA)}") + elif k == "etap_x": + beta0.append(f"dpx = {_madx_divide(val, _MADX_BETA)}") + elif k == "etap_y": + beta0.append(f"dpy = {_madx_divide(val, _MADX_BETA)}") + elif k.startswith("cmat"): + print(f"{name}: TwissP.{k} is Bmad's coupling matrix, which " + "is not MAD-X's R matrix, not translated") + elif k in ("deta_x_ds", "deta_y_ds"): + print(f"{name}: TwissP.{k} has no MAD-X equivalent, not " + "translated") + elif key == "ReferenceP": + referenceP = props["ReferenceP"] + for k in referenceP.keys(): + val = referenceP[k].value + if k == "species_ref": + beam.append(f"particle = {_madx_species(val)}") + # PALS states the reference energy in eV, MAD-X in GeV. + elif k == "pc_ref": + beam.append(f"pc = {_madx_scale(val, 1e-9)}") + elif k == "E_tot_ref": + beam.append(f"energy = {_madx_scale(val, 1e-9)}") + elif k in ("time_ref", "location"): + print(f"{name}: ReferenceP.{k} has no MAD-X equivalent, not " + "translated") + elif key == "ParticleP": + particleP = props["ParticleP"] + for k in particleP.keys(): + val = particleP[k].value + if k in ("x", "px", "y", "py"): + particle.append(f"{k} = {val}") + # MAD-X's longitudinal pair is measured against the energy where + # PALS' is measured against the momentum, and the two differ by + # the reference velocity. + elif k == "z": + particle.append(f"t = {_madx_divide(val, _MADX_BETA)}") + elif k == "pz": + particle.append(f"pt = {val} * {_MADX_BETA}") + elif k in ("spin_x", "spin_y", "spin_z"): + print(f"{name}: ParticleP.{k} has no MAD-X equivalent, not " + "translated") + # MAD-X takes the species first and works the rest of the beam out from it, + # so that is the order the BEAM command reads best in whatever order the PALS + # file gave. + beam.sort(key=lambda s: 0 if s.startswith("particle") else 1) + return beam, beta0, particle + + +# --------------------------------------------------------------------------- +def _make_madx_line(ele: YAMLNode, facility: YAMLNode) -> MadxBeamline: + """Translate a ``BeamLine`` element into a :class:`MadxBeamline`. + + Collect the member element names into the returned beamline. A leading + ``BeginningEle`` is dropped -- it carries the reference parameters, which + become the ``BEAM`` command, and MAD-X has no element for it -- whether it is + spelled out in the line or named there and defined in the ``facility``. A line + that does not begin with one, which is a branch forked into with its reference + parameters propagated, keeps every element it has. + """ + props = ele.child(0) + name = _madx_check_name(props.node_key()) + line = props["line"] + + members: List[str] = [] + for i in range(len(line)): + line_ele = line.child(i) + if line_ele.is_scalar(): + member = line_ele.value + elif line_ele.is_map() or line_ele.is_sequence(): + member = line_ele.child(0).node_key() + else: + raise ValueError(f"BeamLine {name} element {i + 1} is not scalar or " + "sequence or map") + + if i == 0: + entry_props = line_ele.child(0) \ + if (line_ele.is_map() or line_ele.is_sequence()) \ + else facility_props(facility, member) + if entry_props is not None and "kind" in entry_props and \ + entry_props["kind"].value == "BeginningEle": + continue + members.append(member) + return MadxBeamline(name, members) + + +# --------------------------------------------------------------------------- +def _madx_kind(ele_kind: str) -> str: + """The MAD-X element-type keyword for the PALS ``ele_kind``. + + Kinds with no MAD-X equivalent raise an error: MAD-X has no branching + (``Fork``), no support structures (``Girder``), no element that changes the + reference energy in mid-line (``ReferenceChange``), and no way to build one + element out of several (``UnionEle``). + """ + # PALS has the one `Bend`, whose reference geometry is a sector; the pole face + # rotations that make a bend rectangular are parameters of it (`e1_rect`, + # `e2_rect`), not a second kind. MAD-X splits the two, so `Bend` maps to + # MAD-X's sector bend and MAD-X's `RBEND` has no PALS kind to map from. + # + # A MAD-X collimator is a drift that its aperture can stop a particle in, + # which is what a PALS Mask is; MAD-X has nothing for the mask pattern itself. + # + # MAD-X has no beginning element: the reference parameters a PALS BeginningEle + # carries are the BEAM command, and are handled before this point. + known = { + # Magnets and RF Cavities + "Bend": "sbend", "CrabCavity": "crabcavity", "Drift": "drift", + "Kicker": "kicker", "Multipole": "multipole", "Octupole": "octupole", + "Quadrupole": "quadrupole", "RFCavity": "rfcavity", + "Sextupole": "sextupole", "Solenoid": "solenoid", + # Beam and Plasma Elements + "BeamBeam": "beambeam", + # Sources and Collimation + "Mask": "collimator", + # Instrumentation and Diagnostics + "Instrument": "instrument", + # Map Elements + "Taylor": "matrix", + # Bookkeeping Elements + "BeginningEle": "marker", "Marker": "marker", + "Placeholder": "placeholder", "Patch": "changeref"} + if ele_kind in known: + return known[ele_kind] + + unsupported = { + "ACKicker": "No MAD-X equivalent of an ACKicker: HACDIPOLE and VACDIPOLE " + "each act in one plane", + "Wiggler": "No Wiggler elements in MAD-X", + "Converter": "No Converter elements in MAD-X", + "EGun": "No EGun elements in MAD-X", + "Foil": "No Foil elements in MAD-X", + "Match": "No Match elements in MAD-X", + "Fiducial": "No Fiducial elements in MAD-X", + "FloorShift": "No FloorShift elements in MAD-X", + "Fork": "No Fork elements in MAD-X: a MAD-X lattice does not branch", + "ReferenceChange": "No ReferenceChange elements in MAD-X: the reference " + "energy is the BEAM command's", + "Girder": "No Girder elements in MAD-X", + "UnionEle": "No UnionEle in MAD-X", + "Feedback": "No Feedback elements in MAD-X"} + if ele_kind in unsupported: + raise ValueError(unsupported[ele_kind]) + raise ValueError(f"Element kind {ele_kind} is not translated to MAD-X") + + +# --------------------------------------------------------------------------- +#: The multipole orders an element kind holds as its own strength, and the MAD-X +#: attributes that hold them. +#: +#: Each entry maps a PALS element kind to a map from ``(order, skew)`` to the +#: MAD-X attribute name. A quadrupole's order-1 field is MAD-X's ``k1``, not a +#: multipole of a general element; a bend also carries a quadrupole and a +#: sextupole component of its own. Every attribute here holds a strength that is +#: not length integrated, bar the kicker's, which holds a deflection angle -- see +#: :func:`_madx_native_strength`. +#: +#: MAD-X's normal and skew coefficients are the plain field derivatives, +#: ``Kn L = (L/Brho) d^n By/dx^n`` and ``Ks n L = (L/Brho) d^n Bx/dx^n``, and so +#: are PALS': the ``1/N!`` of the PALS field expansion belongs to the expansion and +#: not to the coefficient. So, unlike the Bmad translation, nothing here picks up +#: a factorial. +_MADX_NATIVE_STRENGTH: Dict[str, Dict[Tuple[int, bool], str]] = { + "Bend": {(0, False): "angle", (1, False): "k1", (1, True): "k1s", + (2, False): "k2"}, + "Quadrupole": {(1, False): "k1", (1, True): "k1s"}, + "Sextupole": {(2, False): "k2", (2, True): "k2s"}, + "Octupole": {(3, False): "k3", (3, True): "k3s"}, + "Kicker": {(0, False): "hkick", (0, True): "vkick"}} + +#: The MAD-X strength attributes that hold a length-integrated value. +#: +#: Every other attribute in :data:`_MADX_NATIVE_STRENGTH` holds a strength per unit +#: length, so a PALS value has to be integrated or de-integrated to match +#: whichever it lands in. +_MADX_INTEGRATED_STRENGTH = {"angle", "hkick", "vkick"} + +#: The MAD-X element types that have no length attribute at all. +#: +#: MAD-X rejects an attribute an element type does not have, so a ``length`` -- +#: which every PALS element may carry, if only as a zero -- cannot simply be +#: written out. A ``multipole`` is thin too but does have a length of a sort, the +#: fictitious ``lrad``, and is handled apart from these. +_MADX_THIN_KINDS = {"marker", "beambeam", "changeref"} + + +# --------------------------------------------------------------------------- +def _madx_multipole(full: FullRepresentation, order: int) -> Tuple[float, float]: + """The normal and skew components of multipole ``order``, with the + multipole's own tilt rotated into them. + + A tilt of ``T`` on an order-``N`` multipole rotates it by ``(N+1) T`` in the + normal/skew plane. MAD-X has one tilt for the whole element rather than one + per order, so the rotation is worked out here and what comes out is a plain + normal/skew pair. The components keep whatever units the PALS file gave them: + normalized or not, integrated or not. + """ + value = (complex(*full.magnitude[order]) + * tilt_rotation(order, full.tilt.get(order, 0.0))) + return value.real, value.imag + + +# --------------------------------------------------------------------------- +def _madx_native_strength(full: FullRepresentation, ele_kind: str, name: str, + ref_angle=None) -> List[str]: + """Take the multipoles that are an element's own strength out of ``full`` and + return their MAD-X attribute fragments. + + The strength of a MAD-X quadrupole is its ``k1``, so that is where a PALS + ``Kn1`` belongs. Unlike Bmad, MAD-X has an attribute for the skew component of + each of these -- ``k1s``, ``k2s``, ``k3s`` -- so a tilted multipole of the + element's own order needs nothing left over, and unlike Bmad it has no + field-valued attribute, so an unnormalized component is divided by the + reference rigidity. + + The length is put in or taken out to match the attribute: ``k1`` is a strength + per unit length and ``angle`` and the kicker's ``hkick`` are integrated. An + element of zero length whose PALS value is not integrated has no strength to + state, and neither has one whose integrated value cannot be spread over a + length of zero; both are reported. + + Two of these attributes are not simply the multipole they come from: + + - A bend's order-0 field is its ``angle``. Bmad states the departure of the + field from the reference bend and can hold the two apart; MAD-X cannot, + because it builds the bend's geometry out of the same ``angle`` it tracks + through (``k0`` is in its database but not in its map). So ``ref_angle``, + the angle the ``BendP`` geometry has already been written out as (see + :func:`_madx_bend_geometry`), is what the field is checked against: a + ``Kn0`` that agrees with it has nothing left to state, and one that + disagrees is reported and dropped, because the alternative -- writing the + field out as the angle -- would move every element downstream of the bend. + - A kicker's deflection is measured the opposite way round from a bend's, in + both MAD-X and PALS: a positive ``hkick`` bends towards positive ``x`` and + a positive ``Kn0`` towards negative ``x``, so the horizontal one changes + sign. + """ + attrs: List[str] = [] + if ele_kind not in _MADX_NATIVE_STRENGTH: + return attrs + native = _MADX_NATIVE_STRENGTH[ele_kind] + + for order in sorted(full.magnitude): + if (order, False) not in native and (order, True) not in native: + continue + normal, skew = _madx_multipole(full, order) + normalized = full.normalized[order] + + for component, is_skew in ((normal, False), (skew, True)): + attribute = native.get((order, is_skew)) + if attribute is None: + if not approx(component, 0): + print(f"{name}: a MAD-X {_madx_kind(ele_kind)} has no " + f"attribute for the {'skew' if is_skew else 'normal'} " + f"order-{order} multipole, not translated") + continue + + # Put the length in, or take it out, to match what the attribute + # holds. + integrated = attribute in _MADX_INTEGRATED_STRENGTH + value = component + if integrated and not full.integrated[order]: + value *= full.L + elif not integrated and full.integrated[order]: + if full.L == 0: + if not approx(value, 0): + print(f"{name}: an integrated order-{order} multipole " + "cannot be spread over an element of zero length, " + "not translated") + continue + value /= full.L + if attribute == "hkick": + value = -value + + # The bend's geometry has already been written out as this same + # attribute. + if attribute == "angle" and ref_angle is not None: + ref_text, ref_value = ref_angle + # A bend given only a skew order-0 states no field angle to + # reconcile at all. + if approx(value, 0) and approx(full.magnitude[order][0], 0): + continue + if normalized and ref_value is not None and approx(value, ref_value): + continue + print(f"{name}: the bend field states an angle of " + f"{_madx_strength(value, normalized)} where the reference " + f"bend geometry states {ref_text}; MAD-X has the one " + "`angle` for both, so the field is not translated") + continue + + if approx(value, 0): + continue + attrs.append(f"{attribute} = {_madx_strength(value, normalized)}") + + # Whichever components landed somewhere are the element's own and stay + # out of the multipole form; the rest were reported just above. + full.magnitude.pop(order, None) + full.integrated.pop(order, None) + full.normalized.pop(order, None) + full.tilt.pop(order, None) + return attrs + + +# --------------------------------------------------------------------------- +def _madx_multipole_attrs(full: FullRepresentation, name: str) -> List[str]: + """The ``knl``/``ksl`` attribute fragments of a MAD-X ``multipole``. + + MAD-X states a thin multipole as two arrays of integrated coefficients indexed + by order from zero up, so an order that is not there still needs its zero + written in. A component given as a field is divided by the reference rigidity, + which makes the array entry an expression rather than a number -- which MAD-X + is happy with, the entries being expressions in general. + """ + if not full.magnitude: + return [] + n = max(full.magnitude) + knl = ["0"] * (n + 1) + ksl = ["0"] * (n + 1) + has_normal = False + has_skew = False + + for order in sorted(full.magnitude): + normal, skew = _madx_multipole(full, order) + # Every entry of a MAD-X multipole array is length integrated. + if not full.integrated[order]: + normal *= full.L + skew *= full.L + normalized = full.normalized[order] + if not approx(normal, 0): + knl[order] = _madx_strength(normal, normalized) + has_normal = True + if not approx(skew, 0): + ksl[order] = _madx_strength(skew, normalized) + has_skew = True + + attrs: List[str] = [] + if has_normal: + attrs.append("knl = {" + ", ".join(knl) + "}") + if has_skew: + attrs.append("ksl = {" + ", ".join(ksl) + "}") + return attrs + + +# --------------------------------------------------------------------------- +def _madx_fold(text: str, f, *args: str) -> str: + """``text``, or the number it comes to when every one of ``args`` is a number. + + A PALS parameter may be written as an expression, which only MAD-X can + evaluate, or as a plain number, which the translation can work with. Where a + MAD-X value has to be derived from several PALS ones, ``text`` is that + derivation written as a MAD-X expression and ``f`` is the same derivation as a + function, applied here when all of its inputs parse. + """ + values = [try_float(a) for a in args] + if any(v is None for v in values): + return text + result = f(*values) + return fmt(result) if math.isfinite(result) else text + + +# --------------------------------------------------------------------------- +def _madx_times(a: str, b: str) -> str: + """The product of two MAD-X values, without the clutter of a factor of one. + + Two numbers are multiplied out here; a unit factor -- which is what an element + of unit length gives, and what several of the bend derivations reduce to -- + comes back as the other operand alone rather than as a product with nothing in + it. + """ + va, vb = try_float(a), try_float(b) + if va is not None and vb is not None: + return fmt(va * vb) + if va == 1: + return b + if vb == 1: + return a + return f"({a}) * ({b})" + + +# --------------------------------------------------------------------------- +def _madx_bend_geometry(props: YAMLNode, name: str): + """The reference geometry of a ``Bend`` as ``(angle, angle_value, + arc_length)``, or ``None`` if the element states none. + + PALS states a bend's geometry with any two of three sets of mutually dependent + parameters -- a curvature (``g_ref``, ``radius_ref`` or the reference field + ``Bn0_ref``), a length (``length``, ``L_chord`` or ``L_rectangle``), and the + angle (``angle_ref``) -- one parameter from each of two different sets, from + which every other parameter follows. MAD-X states it with exactly two, the + ``angle`` and the arc length ``l``, so whichever pair the PALS file used has + to be turned into that pair here. + + Only the field-valued curvature needs the reference rigidity, the others being + pure geometry. ``angle_value`` is the angle as a number when everything it was + derived from is one, and ``None`` when it is an expression only MAD-X can + evaluate. A bend that states too little for the pair to be worked out is + reported, and comes back with whichever of the two is known. + """ + if "BendP" not in props: + return None + bendP = props["BendP"] + + # The curvature, however the PALS file chose to state it. + if "g_ref" in bendP: + g = bendP["g_ref"].value + elif "radius_ref" in bendP: + radius = bendP["radius_ref"].value + g = _madx_fold(f"1 / ({radius})", lambda r: 1 / r, radius) + elif "Bn0_ref" in bendP: + g = f"{bendP['Bn0_ref'].value} / {_MADX_RIGIDITY}" + else: + g = None + + # A length, and which of the three lengths it is: MAD-X wants the arc. + if "length" in props: + len_kind, length = "arc", props["length"].value + elif "L_chord" in bendP: + len_kind, length = "chord", bendP["L_chord"].value + elif "L_rectangle" in bendP: + len_kind, length = "rect", bendP["L_rectangle"].value + else: + len_kind, length = "none", None + + angle = bendP["angle_ref"].value if "angle_ref" in bendP else None + arc = length if len_kind == "arc" else None + + # The angle and the arc length, from whichever pair of the three sets was + # given. + if angle is None and g is not None and length is not None: + if len_kind == "arc": + angle = _madx_times(g, length) + elif len_kind == "chord": + angle = _madx_fold(f"2 * asin(({g}) * ({length}) / 2)", + lambda a, b: 2 * math.asin(a * b / 2), g, length) + else: + angle = _madx_fold(f"asin(({g}) * ({length}))", + lambda a, b: math.asin(a * b), g, length) + + if arc is None and angle is not None: + if len_kind == "chord": + arc = _madx_fold( + f"({angle}) * ({length}) / (2 * sin(({angle}) / 2))", + lambda a, l: a * l / (2 * math.sin(a / 2)), angle, length) + elif len_kind == "rect": + arc = _madx_fold(f"({angle}) * ({length}) / sin({angle})", + lambda a, l: a * l / math.sin(a), angle, length) + elif g is not None: + arc = _madx_fold(f"({angle}) / ({g})", lambda a, b: a / b, angle, g) + + if angle is None and arc is None: + return None + if angle is None or arc is None: + print(f"{name}: BendP states too little of the bend geometry for MAD-X, " + "which needs both the angle and the arc length") + return angle, (None if angle is None else try_float(angle)), arc + + +# --------------------------------------------------------------------------- +def _madx_bend_faces(bendP: YAMLNode, name: str, angle) -> List[str]: + """The ``e1``/``e2`` attribute fragments of a bend's pole faces. + + MAD-X measures the pole-face rotations of an ``sbend`` against the sector + geometry, which is what PALS' own ``e1`` and ``e2`` are measured against, so + those two come straight across. PALS also has ``e1_rect`` and ``e2_rect``, + measured against fiducial lines parallel to each other, and what separates the + two pairs depends on the bend's ``ref_geometry``:: + + ARC, CHORD e1 = e1_rect + angle/2, e2 = e2_rect + angle/2 + ENTRANCE_COORDS e1 = e1_rect, e2 = e2_rect + angle + EXIT_COORDS e1 = e1_rect + angle, e2 = e2_rect + + A face given both ways is contradictory and raises an error; one given the + rectangular way on a bend whose angle is unknown cannot be converted, and + raises one too. + """ + attrs: List[str] = [] + geometry = bendP["ref_geometry"].value if "ref_geometry" in bendP else "ARC" + + e1_share = 0.0 if geometry == "ENTRANCE_COORDS" else \ + 1.0 if geometry == "EXIT_COORDS" else 0.5 + e2_share = 1.0 if geometry == "ENTRANCE_COORDS" else \ + 0.0 if geometry == "EXIT_COORDS" else 0.5 + + for face, rect, share in (("e1", "e1_rect", e1_share), + ("e2", "e2_rect", e2_share)): + has_face, has_rect = face in bendP, rect in bendP + if has_face and has_rect: + raise ValueError(f"{name}: should not have both {face} and {rect}") + if has_face: + attrs.append(f"{face} = {bendP[face].value}") + elif has_rect: + if angle is None: + raise ValueError(f"{name}: {rect} is measured against the bend " + "angle, which is not given") + rect_text = bendP[rect].value + if share == 0: + attrs.append(f"{face} = {rect_text}") + continue + attrs.append(f"{face} = " + _madx_fold( + f"{rect_text} + {fmt(share)} * ({angle})", + lambda r, a, share=share: r + share * a, rect_text, angle)) + return attrs + + +# --------------------------------------------------------------------------- +def _madx_aperture_attrs(apertureP: YAMLNode, name: str, + madx_kind: str) -> List[str]: + """The ``apertype``/``aperture``/``aper_offset`` attribute fragments of a PALS + ``ApertureP``. + + MAD-X states an aperture as a half width and a half height about the element's + axis, with the offset of the aperture's centre given separately; PALS states + the two edges, or a full width and a centre. Both forms come to the same + half-extent and centre, which is what is written out. + + The ``shape`` decides which of the components describe the aperture: a + ``RECTANGULAR`` or ``ELLIPTICAL`` one is bounded by its limits and ignores any + vertices, and a ``VERTICES`` one is bounded by its vertex list and ignores any + limits. MAD-X can only take a vertex outline from a file of its own, so a + ``VERTICES`` aperture is reported rather than written out. + + Shape, location and the rest describe an aperture; they do not put one there. + Writing them out for a group that sets no limit would hand MAD-X an aperture + the PALS lattice does not have, so a group that bounds nothing is skipped + entirely. A group that bounds one plane and not the other still has to state + both, MAD-X's aperture values being positional; the unbounded plane is left + wide open and reported. + + What MAD-X has no room for is reported: it puts an aperture at the entrance of + an element and nowhere else, so ``location`` is lost, and it has no aperture at + all on a drift. + """ + attrs: List[str] = [] + shape = apertureP["shape"].value if "shape" in apertureP else "ELLIPTICAL" + + if shape == "VERTICES": + print(f"{name}: MAD-X takes a vertex outline from a file of its own, " + "which PALS does not name, so a VERTICES aperture is not translated") + return attrs + if shape == "CUSTOM_SHAPE": + print(f"{name}: a CUSTOM_SHAPE aperture is defined outside PALS and has " + "no MAD-X equivalent, not translated") + return attrs + + has_xmin = "x_min" in apertureP + has_xmax = "x_max" in apertureP + has_xwidth = "x_width" in apertureP + has_xcen = "x_center" in apertureP + has_ymin = "y_min" in apertureP + has_ymax = "y_max" in apertureP + has_ywidth = "y_width" in apertureP + has_ycen = "y_center" in apertureP + + # A RECTANGULAR or ELLIPTICAL aperture is bounded by its limits alone, so a + # group that sets none of them bounds nothing, whatever else it says. + if not (has_xmin or has_xmax or has_xwidth or has_xcen or + has_ymin or has_ymax or has_ywidth or has_ycen): + return attrs + + if madx_kind == "drift": + print(f"{name}: MAD-X cannot put an aperture on a drift; use a " + "collimator, not translated") + return attrs + + def half_and_centre(plane, has_min, has_max, has_width, has_centre): + """The half extent and the centre of one plane, which is what MAD-X + wants, from whichever of the two PALS forms the group used.""" + if (has_min or has_max) and (has_width or has_centre): + print(f"\n Ignoring the {plane} aperture of element " + f"{name}.\n Either {plane}_min and max should be " + "defined or width and center, not both.\n ") + return None + if has_width: + width = apertureP[f"{plane}_width"].as_float() + centre = apertureP[f"{plane}_center"].as_float() if has_centre else 0.0 + return width / 2, centre + if has_min and has_max: + lo = apertureP[f"{plane}_min"].as_float() + hi = apertureP[f"{plane}_max"].as_float() + return (hi - lo) / 2, (hi + lo) / 2 + if has_min or has_max: + print(f"{name}: only one side of the {plane} aperture is set, which " + "MAD-X cannot state") + return None + return None + + x = half_and_centre("x", has_xmin, has_xmax, has_xwidth, has_xcen) + y = half_and_centre("y", has_ymin, has_ymax, has_ywidth, has_ycen) + if x is None and y is None: + print(f"{name}: ApertureP sets no limit MAD-X can state, not translated") + else: + # MAD-X's aperture values are positional, so a plane that is not bounded + # still has to be given a value; one metre is well outside anything an + # accelerator aperture bounds. + if x is None or y is None: + print(f"{name}: MAD-X states both aperture planes together; the " + "unbounded one is written out as 1 m") + x_half, x_centre = (1.0, 0.0) if x is None else x + y_half, y_centre = (1.0, 0.0) if y is None else y + attrs.append(f"aperture = {{{fmt(x_half)}, {fmt(y_half)}}}") + if not (approx(x_centre, 0) and approx(y_centre, 0)): + attrs.append(f"aper_offset = {{{fmt(x_centre)}, {fmt(y_centre)}}}") + + for akey in apertureP.keys(): + if akey == "shape": + shape = apertureP["shape"].value + if shape == "ELLIPTICAL": + attrs.append("apertype = ellipse") + elif shape == "RECTANGULAR": + attrs.append("apertype = rectangle") + else: + raise ValueError(f"{name}: aperture shape {shape} is not supported") + elif akey == "location": + print(f"{name}: MAD-X checks an aperture at the entrance of an " + "element only, so ApertureP.location is not translated") + elif akey == "aperture_active": + if apertureP["aperture_active"].value.lower() == "false": + print(f"{name}: MAD-X cannot switch an aperture off; remove it " + "instead") + # A RECTANGULAR or ELLIPTICAL aperture ignores any vertices, so there is + # nothing to say about them here. + elif akey in ("aperture_shifts_with_body", "material", "thickness"): + print(f"{name}: ApertureP.{akey} has no MAD-X equivalent, not " + "translated") + return attrs + + +# --------------------------------------------------------------------------- +def _make_madx_ele(ele: YAMLNode) -> Tuple[MadxEleDef, MadxAlignment]: + """Translate a single PALS element into a :class:`MadxEleDef` and its + :class:`MadxAlignment`. + + Dispatch on the element ``kind`` and its parameter groups (aperture, bend, + body shift, multipoles, patch, RF, solenoid, ...) to build the MAD-X element + type and its attribute fragments. A ``BodyShiftP`` comes back separately + because MAD-X keeps a misalignment out of the element definition and in an + ``EALIGN`` command of its own. Unsupported parameter groups emit a message or + raise an error. + """ + props = ele.child(0) + name = _madx_check_name(props.node_key()) + ele_kind = props["kind"].value + madx_kind = _madx_kind(ele_kind) + + attrs: List[str] = [] + notes: List[str] = [] + align: List[str] = [] + + # Strip a trailing comma (and surrounding whitespace) from a fragment before + # storing it. + def push_attr(text): + text = text.rstrip() + if text.endswith(","): + text = text[:-1].rstrip() + if text: + attrs.append(text) + + # The bend geometry has to be settled before anything else is: MAD-X holds a + # bend's geometry and its field in the one `angle` attribute, and its arc + # length may be one PALS states only by way of the geometry. + geometry = _madx_bend_geometry(props, name) if madx_kind == "sbend" else None + ref_angle = None if geometry is None else (geometry[0], geometry[1]) + arc_length = None if geometry is None else geometry[2] + + for key in props.keys(): + if key == "length": + if madx_kind in _MADX_THIN_KINDS: + if not approx(props["length"].as_float(), 0): + print(f"{name}: a MAD-X {madx_kind} has no length, so the " + "PALS length is not translated") + # A MAD-X multipole is thin: what length it has is the fictitious one + # used to work out the radiation it emits. + elif madx_kind == "multipole": + push_attr(f"lrad = {props['length'].value}") + else: + push_attr(f"l = {props['length'].value}") + elif key == "ACKickerP": + raise ValueError(f"{name}: ACKickerP not yet supported") + elif key == "ApertureP": + attrs.extend(_madx_aperture_attrs(props["ApertureP"], name, madx_kind)) + elif key == "BeamBeamP": + bbP = props["BeamBeamP"] + for bbkey in bbP.keys(): + val = bbP[bbkey].value + if bbkey == "sigma_x": + push_attr(f"sigx = {val}") + elif bbkey == "sigma_y": + push_attr(f"sigy = {val}") + elif bbkey == "charge": + push_attr(f"charge = {val}") + elif bbkey == "N_particle": + push_attr(f"npart = {val}") + else: + # MAD-X models the opposite beam as a four-dimensional lens: + # it has no place for its length, its optics, or its energy. + print(f"{name}: BeamBeamP.{bbkey} has no MAD-X equivalent, " + "not translated") + elif key == "BendP": + bendP = props["BendP"] + + # MAD-X states the geometry as an angle and an arc length, however + # PALS chose to write the same thing; `_madx_bend_geometry` settled + # both above. + if arc_length is not None and "length" not in props: + push_attr(f"l = {arc_length}") + if ref_angle is not None and ref_angle[0] is not None: + push_attr(f"angle = {ref_angle[0]}") + attrs.extend(_madx_bend_faces( + bendP, name, None if ref_angle is None else ref_angle[0])) + + for bkey in bendP.keys(): + tmp = "" + # Settled above: the geometry parameters, and the pole faces. + if bkey in ("angle_ref", "g_ref", "radius_ref", "Bn0_ref", + "L_chord", "L_rectangle", "e1", "e2", "e1_rect", + "e2_rect"): + continue + + # PALS states the fringe field as an integral with the gap folded + # in; MAD-X states the dimensionless integral and the gap apart, + # so half of one is the whole of the other. + elif bkey == "edge1_int": + val = bendP["edge1_int"].as_float() + if not approx(val, 0): + tmp = f"fint = 0.5, hgap = {fmt(2 * val)}," + elif bkey == "edge2_int": + val = bendP["edge2_int"].as_float() + if not approx(val, 0): + tmp = f"fintx = 0.5, hgapx = {fmt(2 * val)}," + + elif bkey == "h1": + tmp = f"h1 = {bendP['h1'].value}," + elif bkey == "h2": + tmp = f"h2 = {bendP['h2'].value}," + elif bkey == "tilt_ref": + tmp = f"tilt = {bendP['tilt_ref'].value}," + # Whether the actual field defaults to the reference one, which is + # handled with the multipoles below. + elif bkey == "Kn0_from_g_ref": + continue + elif bkey == "L_sagitta": + raise ValueError(f"{name}: BendP.L_sagitta is an output " + "parameter and is not translated") + # A MAD-X sbend is an arc whose multipoles are vertically pure; it + # has no equivalent of the other geometries, nor of multipoles + # referred to something other than its own. + elif bkey == "ref_geometry": + if bendP[bkey].value != "ARC": + print(f"{name}: BendP.ref_geometry = {bendP[bkey].value} " + "has no MAD-X equivalent; a MAD-X sbend is always " + "an arc") + elif bkey == "multipole_geometry": + if bendP[bkey].value not in ("FOLLOWS_REF_GEOMETRY", + "VERTICALLY_PURE"): + print(f"{name}: BendP.multipole_geometry = " + f"{bendP[bkey].value} has no MAD-X equivalent, not " + "translated") + push_attr(tmp) + + # With `Kn0_from_g_ref` false and no order-0 multipole set, the bend + # has the geometry of the reference bend and none of its field -- + # which MAD-X, tracking through the same `angle` it builds the + # geometry from, cannot express. + if "Kn0_from_g_ref" in bendP and \ + bendP["Kn0_from_g_ref"].value.lower() == "false" and \ + not ("MagneticMultipoleP" in props and + any(k in ("Kn0", "Bn0", "Kn0L", "Bn0L") + for k in props["MagneticMultipoleP"].keys())): + print(f"{name}: Kn0_from_g_ref is false and no order-0 multipole " + "is set, so the bend has no actual field; MAD-X tracks " + "through the same angle it bends the reference orbit with " + "and cannot hold the two apart") + elif key == "BodyShiftP": + bodyshiftP = props["BodyShiftP"] + for bskey in bodyshiftP.keys(): + val = bodyshiftP[bskey].value + # MAD-X's DPHI turns the element the other way round from the + # right-hand rule the other two follow, which is where the sign + # comes from. + if bskey == "x_offset": + align.append(f"dx = {val}") + elif bskey == "y_offset": + align.append(f"dy = {val}") + elif bskey == "z_offset": + align.append(f"ds = {val}") + elif bskey == "x_rot": + align.append(f"dphi = {_madx_scale(val, -1)}") + elif bskey == "y_rot": + align.append(f"dtheta = {val}") + elif bskey == "z_rot": + align.append(f"dpsi = {val}") + elif key == "CoordinateSetP": + raise ValueError(f"{name}: MAD-X has no element that sets the global " + "coordinates of the reference curve, so " + "CoordinateSetP cannot be translated") + elif key == "ElectricMultipoleP": + raise ValueError(f"{name}: ElectricMultipoleP not yet supported") + elif key == "FloorP": + raise ValueError(f"{name}: FloorP not yet supported") + elif key == "ForkP": + raise ValueError(f"{name}: ForkP not yet supported") + elif key == "GirderP": + raise ValueError(f"{name}: GirderP not yet supported") + elif key == "MagneticMultipoleP": + full = FullRepresentation() + full.L = props["length"].as_float() if "length" in props else 1.0 + fill_multipoles(full, props["MagneticMultipoleP"], name) + + # The orders that are the element's own become its strength attributes + # and leave `full`; what is left has to go in a multipole array, which + # only a MAD-X multipole has. + attrs.extend(_madx_native_strength(full, ele_kind, name, ref_angle)) + if madx_kind == "multipole": + attrs.extend(_madx_multipole_attrs(full, name)) + elif full.magnitude: + orders = ", ".join(str(o) for o in sorted(full.magnitude)) + print(f"{name}: a MAD-X {madx_kind} cannot carry multipoles of " + f"order {orders}; they need a multipole element of their " + "own, not translated") + elif key == "MetaP": + metaP = props["MetaP"] + # MAD-X elements hold no metadata of their own, so what PALS says + # about an element is kept as a comment above it rather than dropped. + for mkey in metaP.keys(): + val = metaP[mkey] + if val.is_map() or val.is_sequence(): + print(f"{name}: MetaP.{mkey} is not a simple string, not " + "translated") + continue + notes.append(f"{mkey}: {val.value}") + elif key == "PatchP": + patchP = props["PatchP"] + offsets = ["0", "0", "0"] + angles = ["0", "0", "0"] + for pkey in patchP.keys(): + val = patchP[pkey].value + if pkey == "x_offset": + offsets[0] = val + elif pkey == "y_offset": + offsets[1] = val + elif pkey == "z_offset": + offsets[2] = val + elif pkey == "x_rot": + angles[0] = val + elif pkey == "y_rot": + angles[1] = val + elif pkey == "z_rot": + angles[2] = val + else: + # A MAD-X changeref is the transformation and nothing else: it + # cannot be told to work out its own offsets, nor which end + # its length is measured from. + print(f"{name}: PatchP.{pkey} has no MAD-X equivalent, not " + "translated") + if any(o != "0" for o in offsets): + push_attr("patch_trans = {" + ", ".join(offsets) + "}") + if any(a != "0" for a in angles): + push_attr("patch_ang = {" + ", ".join(angles) + "}") + notes.append("MAD-X applies the three changeref angles in an " + "order of its own; the PALS patch rotations match it " + "only to first order in the angles.") + elif key == "RFP": + rfP = props["RFP"] + if "frequency" in rfP and "harmon" in rfP: + raise ValueError(f"{name}: can only define `frequency` or " + "`harmon` but not both") + # MAD-X's zero phase is the zero crossing half a period away from the + # one PALS calls the stable point above transition, whichever of the + # three PALS is measuring from. + zero_phase = rfP["zero_phase"].value if "zero_phase" in rfP \ + else "ACCELERATING" + if zero_phase == "ABOVE_TRANSITION": + lag_offset = -0.5 + elif zero_phase == "BELOW_TRANSITION": + lag_offset = 0.0 + elif zero_phase == "ACCELERATING": + lag_offset = -0.25 + else: + raise ValueError(f"{name}: unknown zero_phase `{zero_phase}`") + + for rfkey in rfP.keys(): + tmp = "" + # PALS states the frequency in Hz and the voltage in volts, MAD-X + # in MHz and MV. + if rfkey == "frequency": + tmp = f"freq = {_madx_scale(rfP['frequency'].value, 1e-6)}," + elif rfkey == "harmon": + tmp = f"harmon = {rfP['harmon'].value}," + elif rfkey == "voltage": + tmp = f"volt = {_madx_scale(rfP['voltage'].value, 1e-6)}," + elif rfkey == "gradient": + if "L_active" in rfP: + length = rfP["L_active"].value + elif "length" in props: + length = props["length"].value + else: + raise ValueError(f"{name}: `gradient` needs a length to " + "become the voltage MAD-X states") + tmp = (f"volt = {_madx_scale(rfP['gradient'].value, 1e-6)} * " + f"{length},") + elif rfkey == "phase": + tmp = f"lag = {_madx_shift(rfP['phase'].value, lag_offset)}," + elif rfkey == "cavity_type": + if rfP["cavity_type"].value == "TRAVELING_WAVE": + print(f"{name}: a traveling wave cavity is MAD-X's " + "twcavity, which only PTC tracks; translated as an " + "rfcavity") + elif rfkey in ("multipass_phase", "num_cells", "L_active", + "dE_ref"): + print(f"{name}: RFP.{rfkey} has no MAD-X equivalent, not " + "translated") + push_attr(tmp) + # A phase of zero still has to be written out: MAD-X measures it from + # somewhere else. + if "phase" not in rfP and lag_offset != 0: + push_attr(f"lag = {fmt(lag_offset)}") + elif key == "SolenoidP": + solP = props["SolenoidP"] + if "Ksol" in solP: + push_attr(f"ks = {solP['Ksol'].value}") + elif "Bsol" in solP: + push_attr(f"ks = {_madx_divide(solP['Bsol'].value, _MADX_RIGIDITY)}") + elif solP.keys(): + print(f"{name} - unknown SolenoidP key(s): {solP.keys()}") + # A thin MAD-X solenoid states its integrated strength instead, `ks` + # alone doing nothing. + if "length" in props and props["length"].as_float() == 0: + print(f"{name}: a solenoid of zero length also needs MAD-X's ksi, " + "which PALS does not state") + elif key == "TaylorP": + raise ValueError(f"{name}: TaylorP is not yet translated to a MAD-X " + "matrix") + elif key == "TrackingP": + # Tracking parameters are program specific by design; MAD-X's have no + # PALS spelling. + pass + elif key == "ReferenceChangeP": + raise ValueError(f"{name}: MAD-X takes the reference energy from the " + "BEAM command and cannot change it in mid-line") + + return MadxEleDef(name, madx_kind, attrs, notes), MadxAlignment(name, align) + + +# --------------------------------------------------------------------------- +def _madx_control_target(cname: str, param: str, facility: YAMLNode, + varmap: Dict[Tuple[str, str], str] + ) -> Tuple[str, float, bool]: + """Translate a controller's ``parameter`` target into a MAD-X attribute + reference. + + Returns ``(target, factor, rigidity)`` where ``target`` is the + ``"ele->attribute"`` MAD-X reference, the control expression must be + multiplied by ``factor``, and ``rigidity`` says whether it must also be + divided by the reference rigidity to hold the same physics. Neither is trivial + in general because the element translation does not carry PALS parameters + across unchanged: the attribute a multipole lands in may be length integrated + where the PALS parameter was not, or the other way round, and a stated field + has to be normalized because MAD-X has no field-valued attribute. + + A target may name its element by kind as well as by name, as + ``{kind}::{name}``; the qualifier is checked against the element found and + then dropped, MAD-X having one namespace for all of them. + + Targets MAD-X cannot express -- a pattern matching several elements, a ``>>`` + or ``>>>`` qualifier naming the BeamLine or Lattice an element is reached + through, a parameter with no MAD-X attribute, or an order that only a + multipole array could hold, MAD-X having no way to name one entry of one -- + raise an error. + """ + if ">>" in param: + raise ValueError(f"controller {cname}: `{param}` reaches its element " + "through a BeamLine or Lattice qualifier, which MAD-X, " + "having one namespace for the whole file, cannot express") + + parts = param.split(">") + if len(parts) != 2: + raise ValueError(f"controller {cname}: control parameter `{param}` is not " + "of the form `element>parameter`") + slave, path = parts + + # An element may be named by its kind as well as by its name. + kind_wanted = None + if "::" in slave: + qualifier = slave.split("::") + if len(qualifier) != 2: + raise ValueError(f"controller {cname}: `{param}` does not name a " + "single element kind") + kind_wanted, slave = qualifier + + if not _NAME_RE.match(slave): + raise ValueError(f"controller {cname}: `{param}` selects slaves by " + "pattern, which MAD-X cannot express") + + props = facility_props(facility, slave) + if props is None: + raise ValueError(f"controller {cname}: `{param}` names no element of the " + "facility") + ele_kind = props["kind"].value if "kind" in props else "" + if kind_wanted is not None and kind_wanted != ele_kind: + raise ValueError(f"controller {cname}: `{param}` asks for a {kind_wanted} " + f"but {slave} is a {ele_kind}") + + # A controller may drive another controller's variable, under whatever MAD-X + # calls it. + if ele_kind == "Controller": + if not _NAME_RE.match(path): + raise ValueError(f"controller {cname}: `{param}` is not a variable of " + f"controller {slave}") + if (slave, path) not in varmap: + raise ValueError(f"controller {cname}: `{param}` names no variable of " + f"controller {slave}") + return varmap[(slave, path)], 1.0, False + + if path == "length": + return f"{slave}->l", 1.0, False + + m = _MULTIPOLE_RE.match(path) + if m is not None: + order = int(m.group(3)) + skew = m.group(2) == "s" + integrated = m.group(4) == "L" + normalized = m.group(1) == "K" + ele_length = props["length"].as_float() if "length" in props else 1.0 + + # A tilted multipole rotates normal and skew into each other, so the one + # PALS parameter no longer maps onto the one MAD-X attribute. + if "MagneticMultipoleP" in props and \ + f"tilt{order}" in props["MagneticMultipoleP"]: + if not approx(props["MagneticMultipoleP"][f"tilt{order}"].as_float(), 0): + raise ValueError(f"controller {cname}: `{param}` drives a tilted " + "multipole, which has no single MAD-X attribute") + + native = _MADX_NATIVE_STRENGTH.get(ele_kind, {}) + attribute = native.get((order, skew)) + if attribute is None: + raise ValueError(f"controller {cname}: a MAD-X {_madx_kind(ele_kind)} " + f"has no attribute for `{param}`; MAD-X cannot name " + "one entry of a multipole array") + + factor = 1.0 + if attribute in _MADX_INTEGRATED_STRENGTH and not integrated: + factor = ele_length + elif attribute not in _MADX_INTEGRATED_STRENGTH and integrated: + if ele_length == 0: + raise ValueError(f"controller {cname}: `{param}` is integrated " + "over an element of zero length, which MAD-X's " + f"`{attribute}` cannot state") + factor = 1 / ele_length + if attribute == "hkick": + factor = -factor + + return f"{slave}->{attribute}", factor, not normalized + + raise ValueError(f"controller {cname}: control parameter `{param}` is not yet " + "translated to MAD-X") + + +# --------------------------------------------------------------------------- +def _madx_base_value(lat: MadxLattice, target: str, + initials: Dict[str, str]) -> str: + """The value ``target`` already holds, as written by the element translation. + + A ``control_type: RELATIVE`` controller varies a parameter rather than setting + it, and a MAD-X deferred assignment can only set one: ``ele->k1 := ele->k1 + + dk`` is the circular definition MAD-X forbids. So the value being varied has to + be written into the assignment, and the one place it is written down is the + definition this reads it back out of. + """ + parts = target.split("->") + # A bare name is another controller's variable, whose value is its initial + # setting. + if len(parts) == 1: + return initials.get(target, "0") + + ele_name, attribute = parts + for ele in lat.elements: + if ele.name != ele_name: + continue + for attr in ele.attrs: + m = _ATTR_RE.match(attr) + if m is None: + continue + if m.group(1).lower() == attribute.lower(): + return m.group(2) + return "0" + return "0" + + +# --------------------------------------------------------------------------- +def _make_madx_controller(ele: YAMLNode, facility: YAMLNode, lat: MadxLattice, + varmap: Dict[Tuple[str, str], str], + initials: Dict[str, str]) -> MadxController: + """Translate a ``Controller`` element into a :class:`MadxController`. + + ``facility`` is needed to reach the slave elements: what a control expression + must be scaled by depends on the element it drives (see + :func:`_madx_control_target`). ``varmap`` and ``initials`` carry what each + controller variable is called in the MAD-X file and what it starts at (see + :func:`_madx_variable_names`). ``lat`` is needed for a ``RELATIVE`` + controller, whose slaves keep the value their element definitions already gave + them. + + The two control types part company here. An ``ABSOLUTE`` controller sets its + slaves outright, and a deferred assignment does the same. A ``RELATIVE`` one is + a knob: its slaves keep the value the lattice gave them and move by however far + the knob has been turned *from where it started*, so the assignment is the + element's own value, plus the expression, less the expression at the variables' + initial settings. That last term is what a Bmad ``group`` keeps track of by + itself and MAD-X has nothing for; it is left out only when it can be shown to + come to zero, which for a knob resting at zero it does. + """ + props = ele.child(0) + name = _madx_check_name(props.node_key()) + + control_type = props["control_type"].value if "control_type" in props \ + else "ABSOLUTE" + if control_type not in ("ABSOLUTE", "RELATIVE"): + raise ValueError(f"{name}: control_type must be ABSOLUTE or RELATIVE, not " + f"{control_type}") + + variables: List[str] = [] + renames: Dict[str, str] = {} # variable -> what MAD-X calls it + starting: Dict[str, str] = {} # variable -> where it starts + for var, value in ctrl_variables(props): + madx = varmap[(name, var)] + variables.append(f"{_madx_check_name(madx)} = " + f"{_madx_check_expression(name, value)}") + renames[var] = madx + starting[var] = f"({value})" + + # A controller may carry a MetaP, which MAD-X has nowhere to put but a + # comment. + notes: List[str] = [] + if "MetaP" in props: + metaP = props["MetaP"] + for mkey in metaP.keys(): + val = metaP[mkey] + if val.is_map() or val.is_sequence(): + print(f"{name}: MetaP.{mkey} is not a simple string, not " + "translated") + continue + notes.append(f"{mkey}: {val.value}") + + controls: List[str] = [] + if "controls" in props: + for control in props["controls"]: + if "parameter" not in control or "expression" not in control: + raise ValueError(f"{name}: a controls entry needs both a " + "`parameter` and an `expression`") + target, factor, rigidity = _madx_control_target( + name, control["parameter"].value, facility, varmap) + + pals_expr = control["expression"].value + + # The same scaling the element attribute was given, whichever form of + # the expression it is being applied to. + def scaled(expr, factor=factor, rigidity=rigidity): + if not approx(factor, 1): + expr = f"{fmt(factor)}*({expr})" + if rigidity: + expr = f"({expr}) / {_MADX_RIGIDITY}" + return expr + + expression = scaled(_madx_check_expression( + name, _madx_substitute(pals_expr, renames))) + + if control_type == "RELATIVE": + expression = (f"{_madx_base_value(lat, target, initials)} + " + f"({expression})") + at_start = _madx_substitute(pals_expr, starting) + # A knob that starts where its expression comes to zero has moved + # nothing yet, and needs no term saying so. Anything the + # standalone evaluator cannot reach -- a user-defined constant, + # say -- is written out and left for MAD-X. + try: + zero_at_start = approx(evaluate_pals_expression(at_start), 0) + except Exception: + zero_at_start = False + if not zero_at_start: + expression += f" - ({scaled(at_start)})" + controls.append(f"{target} := {expression}") + + return MadxController(name, variables, controls, notes) diff --git a/palsparserpy/to_scibmad.py b/palsparserpy/to_scibmad.py new file mode 100644 index 0000000..b598795 --- /dev/null +++ b/palsparserpy/to_scibmad.py @@ -0,0 +1,617 @@ +""" +Translation of a PALS lattice into a SciBmad lattice file. +""" + +from __future__ import annotations + +import math +import re +from dataclasses import dataclass, field +from typing import List, Tuple + +from ._common import ctrl_variables, facility_entry, facility_props, fmt +from .node import YAMLNode + +__all__ = ["SciBmadEle", "SciBmadBeamline", "SciBmadLatticeList", + "SciBmadController", "SciBmadLattice", "pals_to_scibmad", + "write_scibmad_file"] + +_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + + +@dataclass +class SciBmadEle: + """A single SciBmad ``LineElement``: its ``name`` and the already-translated + keyword-argument fragments (``attrs``, each a ``"keyword = value"`` + string).""" + name: str + attrs: List[str] = field(default_factory=list) + + +@dataclass +class SciBmadBeamline: + """A SciBmad ``Beamline``: its ``name``, the ordered member element + ``members`` (by name), and the reference-parameter fragments ``ref`` taken + from the line's first entry.""" + name: str + members: List[str] = field(default_factory=list) + ref: List[str] = field(default_factory=list) + + +@dataclass +class SciBmadLatticeList: + """A SciBmad lattice list: its ``name`` and the ordered branch/beamline + ``branches`` (by name).""" + name: str + branches: List[str] = field(default_factory=list) + + +@dataclass +class SciBmadController: + """A SciBmad ``Controller``: what a PALS ``Controller`` becomes. + + - ``name``: the controller name. + - ``slaves``: the controlled properties, each a + ``"(ele, :prop) => (ele; vars...) -> expr"`` pair-and-function string. + - ``vars``: the variables' initial values, each a ``"name = value"`` string. + """ + name: str + slaves: List[str] = field(default_factory=list) + vars: List[str] = field(default_factory=list) + + +@dataclass +class SciBmadLattice: + """An in-memory model of a SciBmad lattice. + + Produced by :func:`pals_to_scibmad` and serialized to a file by + :func:`write_scibmad_file`: + + - ``particle``: ``BeginningEle`` particle-coordinate lines (including the + ``v = [...]`` vector). + - ``elements``: ``LineElement`` definitions (:class:`SciBmadEle`). + - ``controllers``: ``Controller`` definitions (:class:`SciBmadController`). + - ``beamlines``: ``Beamline`` definitions (:class:`SciBmadBeamline`). + - ``lattices``: lattice lists (:class:`SciBmadLatticeList`). + """ + particle: List[str] = field(default_factory=list) + elements: List[SciBmadEle] = field(default_factory=list) + controllers: List[SciBmadController] = field(default_factory=list) + beamlines: List[SciBmadBeamline] = field(default_factory=list) + lattices: List[SciBmadLatticeList] = field(default_factory=list) + + +# --------------------------------------------------------------------------- +def pals_to_scibmad(yaml: YAMLNode) -> SciBmadLattice: + """Translate a parsed PALS lattice ``yaml`` (as returned by + :func:`~palsparserpy.parse_file`) into a :class:`SciBmadLattice`. + + The returned structure is an in-memory model of the *SciBmad* lattice + (elements, beamlines, lattice lists), not the input PALS tree. Translation is + a three-step process: parse the PALS file with ``parse_file``, build the + target model with ``pals_to_scibmad``, then emit the SciBmad lattice file with + :func:`write_scibmad_file`:: + + yaml = parse_file(file_dir) + write_scibmad_file(pals_to_scibmad(yaml), filename) + """ + facility = yaml["PALS"]["facility"] + lat = SciBmadLattice() + for ele in facility: + props = ele.child(0) + if "kind" not in props: + continue + kind = props["kind"].value + if kind == "BeginningEle": + _, particle = _ele_to_scibmad_str(ele) + lat.particle.extend(particle) + elif kind == "Lattice": + name = props.node_key() + branches = [] + for bl in props["branches"]: + # A branch is either the bare name of a beamline or that name + # carrying the branch's own settings, which SciBmad takes from the + # beamline rather than the lattice list. + branches.append(bl.child(0).node_key() if bl.is_map() else bl.value) + lat.lattices.append(SciBmadLatticeList(name, branches)) + elif kind == "BeamLine": + lat.beamlines.append(_make_scibmad_beamline(ele, facility)) + elif kind == "Controller": + lat.controllers.append(_make_scibmad_controller(ele, facility)) + elif kind in ("constant", "variable"): + raise ValueError(f"{props.node_key()}: `{kind}` definitions are not " + "yet translated to SciBmad") + else: + lat.elements.append(_make_scibmad_ele(ele)) + return lat + + +# --------------------------------------------------------------------------- +def write_scibmad_file(lat: SciBmadLattice, filename) -> None: + """Serialize the :class:`SciBmadLattice` ``lat`` to ``filename`` as a SciBmad + lattice file. + + Write the particle-start block, the ``@elements`` block of ``LineElement``s, + the ``Controller`` definitions, the ``Beamline`` definitions, and the lattice + lists. + """ + with open(filename, "w") as out: + if lat.particle: + out.write("\n".join(lat.particle) + "\n\n") + out.write("@elements begin\n") + for ele in lat.elements: + out.write(_format_scibmad_ele(ele) + "\n") + out.write("end\n\n") + for ctrl in lat.controllers: + out.write(_format_scibmad_controller(ctrl) + "\n") + if lat.controllers: + out.write("\n") + for bl in lat.beamlines: + out.write(_format_scibmad_beamline(bl) + "\n") + for latt in lat.lattices: + out.write(_format_scibmad_lattice(latt) + "\n") + + +# --------------------------------------------------------------------------- +def _format_scibmad_ele(ele: SciBmadEle) -> str: + """Render a :class:`SciBmadEle` as a ``name = LineElement(...)`` definition.""" + return f"{ele.name} = LineElement({', '.join(ele.attrs)})" + + +# --------------------------------------------------------------------------- +def _format_scibmad_controller(ctrl: SciBmadController) -> str: + """Render a :class:`SciBmadController` as a + ``name = Controller(slaves...; vars = (; ...))`` definition.""" + slaves = ",\n ".join(ctrl.slaves) + variables = ", ".join(ctrl.vars) + return f"{ctrl.name} = Controller(\n {slaves};\n vars = (; {variables})\n)" + + +# --------------------------------------------------------------------------- +def _format_scibmad_beamline(bl: SciBmadBeamline) -> str: + """Render a :class:`SciBmadBeamline` as a ``name = Beamline([members], + ref...)`` definition.""" + members = "".join(m + "," for m in bl.members) + ref = "".join(r + "," for r in bl.ref) + return f"{bl.name} = Beamline([{members}], {ref})" + + +# --------------------------------------------------------------------------- +def _format_scibmad_lattice(latt: SciBmadLatticeList) -> str: + """Render a :class:`SciBmadLatticeList` as a ``name = [branches]`` list.""" + inner = "".join(b + "," for b in latt.branches) + return f"{latt.name} = [{inner}]" + + +# --------------------------------------------------------------------------- +def _ele_to_scibmad_str(ele: YAMLNode) -> Tuple[List[str], List[str]]: + """Translate a ``BeginningEle`` element into SciBmad reference and particle + fragments. + + Returns ``(ref, particle)`` where ``ref`` holds the reference-parameter + fragments from the element's ``ReferenceP`` (species and energy) and + ``particle`` holds the coordinate lines from its ``ParticleP`` (followed by + the ``v = [...]`` phase-space vector). + """ + props = ele.child(0) + ref: List[str] = [] + particle: List[str] = [] + for key in props.keys(): + if key == "TwissP": + print("TwissP not yet supported") + elif key == "ReferenceP": + referenceP = props["ReferenceP"] + for k in referenceP.keys(): + if k == "species_ref": + ref.append(f"species_ref = {referenceP[k].value}") + elif k == "pc_ref": + ref.append(f"pc_ref = {referenceP[k].value}") + elif k == "E_tot_ref": + ref.append(f"E_ref = {referenceP[k].value}") + elif k in ("time_ref", "location"): + print(f"{k} not supported yet") + elif key == "ParticleP": + particleP = props["ParticleP"] + for k in particleP.keys(): + val = particleP[k].value + if k in ("x", "y", "z", "px", "py", "pz"): + particle.append(f"{k} = {val}") + elif k in ("spin_x", "spin_y", "spin_z"): + # SciBmad carries spin as a quaternion, not as its components. + print(f"{k} not yet supported") + particle.append("v = [ x px y py z pz ]") + return ref, particle + + +# --------------------------------------------------------------------------- +def _make_scibmad_beamline(ele: YAMLNode, facility: YAMLNode) -> SciBmadBeamline: + """Translate a ``BeamLine`` element into a :class:`SciBmadBeamline`. + + Collect the member element names (dropping the leading reference entry, + ``line[0]``) and the reference parameters read from that first entry. A line + may also name its beginning element instead of spelling it out, in which case + the reference parameters are on that element's ``facility`` definition. + """ + props = ele.child(0) + name = props.node_key() + line = props["line"] + beginning = line.child(0) + if not beginning.is_map(): + beginning = facility_entry(facility, beginning.value) + if beginning is None: + raise ValueError(f"BeamLine {name}: its first element is not defined " + "in the facility") + ref, _ = _ele_to_scibmad_str(beginning) + members: List[str] = [] + for i in range(1, len(line)): + line_ele = line.child(i) + if line_ele.is_scalar(): + members.append(line_ele.value) + elif line_ele.is_map(): + members.append(line_ele.child(0).node_key()) + return SciBmadBeamline(name, members, ref) + + +# --------------------------------------------------------------------------- +def _scibmad_control_target(cname: str, param: str, + facility: YAMLNode) -> Tuple[str, str]: + """Translate a controller's ``parameter`` target into a SciBmad + ``(element, :property)`` pair. + + Returns ``(element, property)``. SciBmad keeps the PALS parameter names, so a + group-qualified target such as ``q>MagneticMultipoleP.Kn1`` needs only its + group prefix dropped. + + A target may name its element by kind as well as by name, as + ``{kind}::{name}``; the qualifier is checked against the element found and + then dropped, SciBmad naming each element once. + + Targets SciBmad cannot express -- a pattern matching several elements, or a + ``>>`` or ``>>>`` qualifier naming the BeamLine or Lattice an element is + reached through -- raise an error. + """ + if ">>" in param: + raise ValueError(f"controller {cname}: `{param}` reaches its element " + "through a BeamLine or Lattice qualifier, which has no " + "SciBmad equivalent") + + parts = param.split(">") + if len(parts) != 2: + raise ValueError(f"controller {cname}: control parameter `{param}` is not " + "of the form `element>parameter`") + slave, path = parts + + # An element may be named by its kind as well as by its name. + if "::" in slave: + qualifier = slave.split("::") + if len(qualifier) != 2: + raise ValueError(f"controller {cname}: `{param}` does not name a " + "single element kind") + kind_wanted, slave = qualifier + props = facility_props(facility, slave) + if props is None: + raise ValueError(f"controller {cname}: `{param}` names no element of " + "the facility") + ele_kind = props["kind"].value if "kind" in props else "" + if kind_wanted != ele_kind: + raise ValueError(f"controller {cname}: `{param}` asks for a " + f"{kind_wanted} but {slave} is a {ele_kind}") + + if not _NAME_RE.match(slave): + raise ValueError(f"controller {cname}: `{param}` selects slaves by " + "pattern, which a SciBmad Controller cannot express") + + # `length` is the one PALS element parameter that is not in a group, and the + # one whose SciBmad name differs. + if path == "length": + return slave, "L" + + prop = path.split(".")[-1] + if not _NAME_RE.match(prop): + raise ValueError(f"controller {cname}: `{param}` does not name a single " + "parameter") + return slave, prop + + +# --------------------------------------------------------------------------- +def _make_scibmad_controller(ele: YAMLNode, + facility: YAMLNode) -> SciBmadController: + """Translate a ``Controller`` element into a :class:`SciBmadController`. + + Each control becomes a function of the controller's variables, which SciBmad + passes as keyword arguments. ``control_type: RELATIVE`` adds its expression to + the value the element already carries -- that is what makes it relative -- + while ``ABSOLUTE`` replaces it. + """ + props = ele.child(0) + name = props.node_key() + + control_type = props["control_type"].value if "control_type" in props \ + else "ABSOLUTE" + if control_type not in ("ABSOLUTE", "RELATIVE"): + raise ValueError(f"{name}: control_type must be ABSOLUTE or RELATIVE, not " + f"{control_type}") + + var_names: List[str] = [] + variables: List[str] = [] + for var, value in ctrl_variables(props): + var_names.append(var) + variables.append(f"{var} = {value}") + # SciBmad calls every control function with all of the controller's variables. + signature = "(ele; " + ", ".join(var_names) + ")" + + slaves: List[str] = [] + if "controls" in props: + for control in props["controls"]: + if "parameter" not in control or "expression" not in control: + raise ValueError(f"{name}: a controls entry needs both a " + "`parameter` and an `expression`") + slave, prop = _scibmad_control_target( + name, control["parameter"].value, facility) + expression = control["expression"].value + if control_type == "RELATIVE": + expression = f"ele.{prop} + ({expression})" + slaves.append(f"({slave}, :{prop}) => {signature} -> {expression}") + + return SciBmadController(name, slaves, variables) + + +# --------------------------------------------------------------------------- +def _make_scibmad_ele(ele: YAMLNode) -> SciBmadEle: + """Translate a single PALS element into a :class:`SciBmadEle`. + + Dispatch on the element's parameter groups (aperture, bend, body shift, + multipoles, patch, RF, solenoid, tracking, reference change, ...) to build the + keyword-argument fragments of a ``LineElement``. Unsupported parameters emit a + message. + """ + props = ele.child(0) + attrs: List[str] = [] + + for key in props.keys(): + if key == "kind": + attrs.append(f"kind = {props['kind'].value}") + elif key == "length": + attrs.append(f"L = {props['length'].value}") + elif key == "ACKickerP": + print("ACKickerP not yet supported") + elif key == "ApertureP": + apertureP = props["ApertureP"] + has_xmin = "x_min" in apertureP + has_xmax = "x_max" in apertureP + has_xwidth = "x_width" in apertureP + has_xcen = "x_center" in apertureP + has_ymin = "y_min" in apertureP + has_ymax = "y_max" in apertureP + has_ywidth = "y_width" in apertureP + has_ycen = "y_center" in apertureP + + # Shape and location describe an aperture; they do not put one there. + # A group that sets no limit bounds nothing, so writing them out would + # give the element an aperture the PALS lattice does not have. A + # `vertices` aperture is bounded by its vertex list. + if not (has_xmin or has_xmax or has_xwidth or has_xcen or + has_ymin or has_ymax or has_ywidth or has_ycen or + "vertices" in apertureP): + continue + + if (has_xmin or has_xmax) and (has_xwidth or has_xcen): + print("Either min and max should be defined or width and center, " + "not both.") + elif (has_xmin and not has_xmax) or (has_xmax and not has_xmin): + print("Both min and max need to be defined.") + elif has_xmin and has_xmax: + attrs.append(f"x1_limit = {apertureP['x_min'].value}") + attrs.append(f"x2_limit = {apertureP['x_max'].value}") + elif (has_xwidth and not has_xcen) or (has_xcen and not has_xwidth): + print("Both width and center need to be defined.") + elif has_xwidth and has_xcen: + width = apertureP["x_width"].as_float() + center = apertureP["x_center"].as_float() + attrs.append(f"x1_limit = {fmt(center - width / 2)}") + attrs.append(f"x2_limit = {fmt(center + width / 2)}") + + if (has_ymin or has_ymax) and (has_ywidth or has_ycen): + print("Either min and max should be defined or width and center, " + "not both.") + elif (has_ymin and not has_ymax) or (has_ymax and not has_ymin): + print("Both min and max need to be defined.") + elif has_ymin and has_ymax: + attrs.append(f"y1_limit = {apertureP['y_min'].value}") + attrs.append(f"y2_limit = {apertureP['y_max'].value}") + elif (has_ywidth and not has_ycen) or (has_ycen and not has_ywidth): + print("Both width and center need to be defined.") + elif has_ywidth and has_ycen: + width = apertureP["y_width"].as_float() + center = apertureP["y_center"].as_float() + attrs.append(f"y1_limit = {fmt(center - width / 2)}") + attrs.append(f"y2_limit = {fmt(center + width / 2)}") + + for akey in apertureP.keys(): + if akey == "shape": + shape = apertureP["shape"].value + if shape == "ELLIPTICAL": + attrs.append("aperture_shape = ApertureShape.Elliptical") + elif shape == "RECTANGULAR": + attrs.append("aperture_shape = ApertureShape.Rectangular") + else: + print(f"shape {shape} is not supported") + elif akey == "location": + location = apertureP["location"].value + if location == "ENTRANCE_END": + attrs.append("aperture_at = ApertureAt.Entrance") + elif location == "EXIT_END": + attrs.append("aperture_at = ApertureAt.Exit") + elif location == "BOTH_ENDS": + attrs.append("aperture_at = ApertureAt.BothEnds") + elif location in ("EVERYWHERE", "CENTER"): + attrs.append("aperture_at = ApertureAt.BothEnds") + print(f"location {location} not supported, set to BothEnds") + elif location == "NOWHERE": + print(f"location {location} not supported") + elif akey == "aperture_shifts_with_body": + shifts = apertureP["aperture_shifts_with_body"].value.lower() + attrs.append("aperture_shifts_with_body = " + f"{str(shifts == 'true').lower()}") + elif akey == "aperture_active": + active = apertureP["aperture_active"].value.lower() + attrs.append(f"aperture_active = {str(active == 'true').lower()}") + elif akey == "vertices": + print("vertices not yet supported") + elif akey == "material": + print("material not yet supported") + elif akey == "thickness": + print("thickness not yet supported") + elif key == "BeamBeamP": + bbP = props["BeamBeamP"] + for bbkey in bbP.keys(): + if bbkey in ("sigma_x", "sigma_y", "sigma_z", "alpha_x", "beta_x", + "alpha_y", "beta_y", "charge", "energy", "N_particle"): + attrs.append(f"{bbkey} = {bbP[bbkey].value}") + elif key == "BendP": + bendP = props["BendP"] + for bkey in bendP.keys(): + if bkey == "radius_ref": + print("radius_ref not yet supported") + elif bkey == "Bn0_ref": + print("Bn0_ref not yet supported") + elif bkey == "e1": + attrs.append(f"e1 = {bendP['e1'].value}") + elif bkey == "e2": + attrs.append(f"e2 = {bendP['e2'].value}") + elif bkey == "e1_rect": + print("e1_rect not yet supported") + elif bkey == "e2_rect": + print("e2_rect not yet supported") + elif bkey == "edge1_int": + attrs.append(f"edge1_int = {bendP['edge1_int'].value}") + elif bkey == "edge2_int": + attrs.append(f"edge2_int = {bendP['edge2_int'].value}") + elif bkey == "g_ref": + attrs.append(f"g_ref = {bendP['g_ref'].value}") + elif bkey == "h1": + print("h1 not yet supported") + elif bkey == "h2": + print("h2 not yet supported") + elif bkey == "L_chord": + print("L_chord not yet supported") + elif bkey == "L_sagitta": + print("L_sagitta not yet supported") + elif bkey == "tilt_ref": + attrs.append(f"tilt_ref = {bendP['tilt_ref'].value}") + elif key == "BodyShiftP": + bodyshiftP = props["BodyShiftP"] + for bskey in bodyshiftP.keys(): + if bskey in ("x_offset", "y_offset", "z_offset", "x_rot", "y_rot"): + attrs.append(f"{bskey} = {bodyshiftP[bskey].value}") + elif bskey == "z_rot": + attrs.append(f"tilt = {bodyshiftP['z_rot'].value}") + elif key == "ElectricMultipoleP": + print("ElectricMultipoleP not yet supported") + elif key == "FloorP": + print("FloorP not yet supported") + elif key == "FloorShiftP": + print("FloorShiftP not yet supported") + elif key == "ForkP": + print("ForkP not yet supported") + elif key == "GirderP": + print("GirderP not yet supported") + elif key == "MagneticMultipoleP": + mmP = props["MagneticMultipoleP"] + for mmkey in mmP.keys(): + attrs.append(f"{mmkey} = {mmP[mmkey].value}") + elif key == "MetaP": + metaP = props["MetaP"] + for mkey in metaP.keys(): + if mkey in ("alias", "label", "description"): + attrs.append(f"{mkey} = {metaP[mkey].value}") + print("MetaP not yet supported") + elif key == "PatchP": + patchP = props["PatchP"] + for pkey in patchP.keys(): + if pkey == "x_offset": + attrs.append(f"dx = {patchP['x_offset'].value}") + elif pkey == "y_offset": + attrs.append(f"dy = {patchP['y_offset'].value}") + elif pkey == "z_offset": + attrs.append(f"dz = {patchP['z_offset'].value}") + elif pkey == "t_offset": + attrs.append(f"dt = {patchP['t_offset'].value}") + elif pkey == "x_rot": + attrs.append(f"dx_rot = {patchP['x_rot'].value}") + elif pkey == "y_rot": + attrs.append(f"dy_rot = {patchP['y_rot'].value}") + elif pkey == "z_rot": + attrs.append(f"dz_rot = {patchP['z_rot'].value}") + elif pkey == "flexible": + print("flexible not yet supported") + elif pkey == "ref_coords": + print("ref_coords not yet supported") + elif pkey == "user_sets_length": + print("user_sets_length not yet supported") + elif key == "RFP": + rfP = props["RFP"] + if props["kind"].value == "CrabCavity": + attrs.append("is_crabcavity = true") + num_cells = 0 + l_active = 0.0 + for rfkey in rfP.keys(): + if rfkey == "frequency": + attrs.append(f"rate = {rfP['frequency'].value}") + attrs.append("rate_meaning = false") + elif rfkey == "harmon": + attrs.append(f"rate = {rfP['harmon'].value}") + attrs.append("rate_meaning = true") + elif rfkey == "voltage": + attrs.append(f"voltage = {rfP['voltage'].value}") + elif rfkey == "gradient": + print("gradient not yet supported") + elif rfkey == "phase": + attrs.append(f"phi0 = {fmt(2 * math.pi * rfP['phase'].as_float())}") + elif rfkey == "multipass_phase": + print("multipass_phase not yet supported") + elif rfkey == "cavity_type": + traveling = rfP["cavity_type"].value == "TRAVELING_WAVE" + attrs.append(f"traveling_wave = {str(traveling).lower()}") + elif rfkey == "num_cells": + num_cells = rfP["num_cells"].as_int() + elif rfkey == "L_active": + l_active = rfP["L_active"].as_float() + elif rfkey == "zero_phase": + zp = rfP["zero_phase"].value + if zp == "ACCELERATING": + attrs.append("zero_phase = Accelerating") + elif zp == "BELOW_TRANSITION": + attrs.append("zero_phase = BelowTransition") + elif zp == "ABOVE_TRANSITION": + attrs.append("zero_phase = AboveTransition") + if "frequency" not in rfP and "harmon" not in rfP: + attrs.append("rate_meaning = -1") + attrs.append(f"tracking_method = SaganCavity(num_cells = {num_cells}, " + f"L_active = {fmt(l_active)})") + elif key == "SolenoidP": + solP = props["SolenoidP"] + for skey in solP.keys(): + attrs.append(f"{skey} = {solP[skey].value}") + elif key == "TrackingP": + trackingP = props["TrackingP"] + for tkey in trackingP.keys(): + if tkey == "SciBmad": + sbm = trackingP["SciBmad"] + for sbkey in sbm.keys(): + if sbkey == "tracking_method": + if sbm["tracking_method"].value == "scibmad_standard": + attrs.append("tracking_method = SciBmadStandard()") + elif key == "ReferenceChangeP": + refchangeP = props["ReferenceChangeP"] + for rkey in refchangeP.keys(): + if rkey == "extra_dtime_ref": + print("extra_dtime_ref not yet supported") + elif rkey == "dE_ref": + attrs.append(f"dE_ref = {refchangeP['dE_ref'].value}") + elif rkey == "E_tot_ref": + attrs.append(f"E_ref = {refchangeP['E_tot_ref'].value}") + elif rkey == "species_ref": + attrs.append(f"species_ref = {refchangeP['species_ref'].value}") + + return SciBmadEle(props.node_key(), attrs) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b13b75b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,37 @@ +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[project] +name = "palsparserpy" +version = "0.1.0" +description = "Python interface for Particle Accelerator Language Standard (PALS) files" +readme = "README.md" +requires-python = ">=3.9" +license = { text = "MIT" } +authors = [ + { name = "David Sagan", email = "david.sagan@gmail.com" }, +] +keywords = ["PALS", "accelerator", "lattice", "Bmad", "MAD-X", "YAML"] +classifiers = [ + "Programming Language :: Python :: 3", + "Topic :: Scientific/Engineering :: Physics", +] +# The C library PALSParserPy binds is built by PALSParserCpp and found at run +# time; there is nothing to install from PyPI. +dependencies = [] + +[project.optional-dependencies] +test = ["pytest"] +docs = ["sphinx", "myst-parser", "sphinx-book-theme"] + +[project.urls] +Homepage = "https://github.com/pals-project/PALSParserPy" +"PALS standard" = "https://github.com/campa-consortium/pals" +"C library" = "https://github.com/pals-project/PALSParserCpp" + +[tool.setuptools] +packages = ["palsparserpy"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..fab56b0 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,16 @@ +"""Shared test setup: make the checkout importable without installing it.""" + +import os +import sys + +import pytest + +_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if _ROOT not in sys.path: + sys.path.insert(0, _ROOT) + + +@pytest.fixture(scope="session") +def lattice_dir(): + """The directory holding the sample PALS lattices shipped with the repo.""" + return os.path.join(_ROOT, "lattice_files") diff --git a/tests/test_correspondence.py b/tests/test_correspondence.py new file mode 100644 index 0000000..b33531c --- /dev/null +++ b/tests/test_correspondence.py @@ -0,0 +1,134 @@ +"""Tests of node_correspondence: mapping one logical node across the +derivation-chain trees.""" + +import pytest + +from palsparserpy import (evaluate_pals_expression, node_correspondence, + parse_and_expand_pals, parse_string) + +# A self-contained lattice: `a_const` sits outside the expanded lattice (so it is +# left over rather than expanded), while `repeat: 3` exercises the one-to-many +# correspondence produced by expansion. +CORR_LATTICE = """ +PALS: + facility: + - constants: + a_const: 0.3 * r_electron + - d1: + kind: Drift + length: 2.0 + - cell: + kind: BeamLine + line: + - d1 + - main_line: + kind: BeamLine + line: + - cell: + repeat: 3 + - lat1: + kind: Lattice + branches: + - main_line + - use: "lat1" +""" + + +@pytest.fixture(scope="module") +def corr(tmp_path_factory): + path = tmp_path_factory.mktemp("corr") / "corr.pals.yaml" + path.write_text(CORR_LATTICE) + lat = parse_and_expand_pals(path) + return lat, node_correspondence(lat) + + +def _a_const(lat): + return lat.combined["PALS"]["facility"][0]["constants"]["a_const"] + + +def test_returns_a_dict_keyed_by_node(corr): + lat, mapping = corr + assert isinstance(mapping, dict) + assert mapping + # The combined, expanded and adjunct roots are keys. + assert lat.combined in mapping + assert lat.full_expanded in mapping + assert lat.adjunct in mapping + + +def test_a_node_outside_the_lattice_maps_into_adjunct_not_expanded(corr): + # a_const is not part of the lattice and nothing in it refers to a_const, so + # expansion leaves it behind: one node in original, combined and adjunct, and + # none in expanded. + lat, mapping = corr + entry = mapping[_a_const(lat)] + assert len(entry.original) == 1 + assert len(entry.combined) == 1 + assert len(entry.adjunct) == 1 + assert entry.full_expanded == [] + assert entry.original[0].value == "0.3 * r_electron" + assert entry.combined[0].value == "0.3 * r_electron" + # The adjunct copy has its expression evaluated to a number, while the + # original/combined copies keep the original expression text. + assert entry.adjunct[0].as_float() == \ + evaluate_pals_expression("0.3 * r_electron") + # The queried node appears in its own tree's list. + assert entry.combined[0] == _a_const(lat) + + +def test_lookup_is_consistent_from_any_tree(corr): + lat, mapping = corr + entry = mapping[_a_const(lat)] + # Reaching the class from the original or adjunct node gives the same set. + assert mapping[entry.original[0]] == entry + assert mapping[entry.adjunct[0]] == entry + + +def test_repeat_gives_one_to_many_correspondence(corr): + # The single `d1` scalar in cell's line is unrolled 3x inside the expanded + # lattice, so it corresponds to several expanded nodes but one combined. + lat, mapping = corr + cell_d1 = lat.combined["PALS"]["facility"][2]["cell"]["line"][0] + entry = mapping[cell_d1] + assert len(entry.combined) == 1 + assert len(entry.full_expanded) >= 3 + # Every expanded copy resolves back to this same class. + assert all(mapping[n] == entry for n in entry.full_expanded) + # The definition it was expanded from is still standing in adjunct, and + # belongs to the same class. + assert len(entry.adjunct) == 1 + assert mapping[entry.adjunct[0]] == entry + + +def test_a_definition_used_by_the_lattice_reaches_both_trees(corr): + # main_line is named by lat1's branches, so expansion inlines a copy of its + # definition into the lattice while the definition itself stays in adjunct. + # The combined node ties the two sides together. `line` is the node to follow, + # not `kind`: inlining main_line made it a branch, and a branch has no kind, so + # no expanded node answers to main_line's. + lat, mapping = corr + ml = lat.combined["PALS"]["facility"][3]["main_line"] + assert mapping[ml["kind"]].full_expanded == [] + + entry = mapping[ml["line"]] + assert len(entry.combined) == 1 + assert len(entry.adjunct) == 1 + assert len(entry.full_expanded) == 1 + # The two copies are the same node of the same definition, but only the + # expanded one has been expanded: `cell: repeat: 3` is unrolled to 3 entries + # there, while the definition in adjunct still holds the 1 entry it was + # written with. Expansion then caps the branch with a zero-length `branch_end` + # Placeholder holding its final floor placement and reference parameters, so + # the expanded line is one longer than the unrolling. + assert len(entry.adjunct[0]) == 1 + assert [n.child(0).node_key() for n in entry.full_expanded[0]] == \ + ["d1", "d1", "d1", "branch_end"] + # Both copies resolve back to the same class. + assert mapping[entry.full_expanded[0]] == entry + assert mapping[entry.adjunct[0]] == entry + + +def test_unmapped_nodes_are_absent_from_the_dict(corr): + # A freshly built, unrelated tree shares no nodes with the correspondence. + _, mapping = corr + assert parse_string("stray: node") not in mapping diff --git a/tests/test_expression.py b/tests/test_expression.py new file mode 100644 index 0000000..49ad890 --- /dev/null +++ b/tests/test_expression.py @@ -0,0 +1,382 @@ +"""Tests of expression evaluation, both standalone and across an expanded +lattice, and of the problem list expansion reports.""" + +import math + +import pytest + +from palsparserpy import (PALSParseError, PROBLEM_ERROR, PROBLEM_INPUT, + ProblemOrigin, ProblemSeverity, + evaluate_pals_expression, parse_and_expand_pals) + +# A lattice exercising the expression evaluator: user variables, an immediate +# expression, an expr()-delayed expression, a particle-function constant, and a +# random_gauss() value that must stay unevaluated. +EXPR_LATTICE = """ +PALS: + facility: + - variables: + - a_var: 3.75e7 / c_light^2 + - b_var: -0.34 + - cleo: + kind: Solenoid + length: 0.1*log(abs(b_var)) + MagneticMultipoleP: + Kn1: expr(3.74 * a_var) + Kn2: 0.01 + 0.003*random_gauss() + - m_e: + kind: constant + value: mass_of("electron") + - main_line: + kind: BeamLine + line: + - cleo + - lat1: + kind: Lattice + branches: + - main_line + - use: "lat1" +""" + +# A lattice with two controllers: `ps27` has inter-referencing variables and +# controls that use a lattice constant and a deferred random_gauss(); `chrom_a` +# references `ps27`'s variable via the `controller>variable` syntax. +CONTROLLER_LATTICE = """ +PALS: + facility: + - my_const: + kind: constant + value: 2.0 + - ps27: + kind: Controller + control_type: ABSOLUTE + variables: + cur1: 0.023 + cur2: 0.023 / c_light + controls: + - parameter: Qa.*>MagneticMultipoleP.Ks2L + expression: 0.075*sin(cur1) + 0.3*cur2 + - parameter: Qb>MagneticMultipoleP.Kn1L + expression: cur1 * my_const + - parameter: Qc>MagneticMultipoleP.Kn0 + expression: 0.01 + random_gauss() + - chrom_a: + kind: Controller + control_type: RELATIVE + variables: + command: 0.4 + derived: 0.4 * 2 + controls: + - parameter: S1>MagneticMultipoleP.Kn2L + expression: 5.62 * command + 0.02 * command^2 + - main_line: + kind: BeamLine + line: + - my_const + - lat1: + kind: Lattice + branches: + - main_line + - use: "lat1" +""" + +# A lattice where one element's parameter references another element's parameter +# via the `element>group.param` syntax inside an expression. +ELEMENT_PARAM_REF_LATTICE = """ +PALS: + facility: + - thingB: + kind: Sextupole + length: 0.3 + MagneticMultipoleP: + Kn2L: 0.1 + - DH1A: + kind: Bend + length: 0.2 + ReferenceP: + species_ref: proton + E_tot_ref: 1.0e9 + BendP: + edge2_int: 0.02 * thingB>MagneticMultipoleP.Kn2L + - main_line: + kind: BeamLine + line: + - DH1A + - lat1: + kind: Lattice + branches: + - main_line + - use: "lat1" +""" + +# A lattice that deliberately fails several ways during expansion: an undefined +# constant reference, a dangling element-parameter reference, a dangling line +# reference, and an undefined `inherit` ancestor. +BROKEN_LATTICE = """ +PALS: + facility: + - constants: + a_const: 0.3 * undefined_thing + - thingB: + kind: Sextupole + MagneticMultipoleP: + Kn2L: 0.1 + - DH1A: + kind: Bend + BendP: + edge2_int: 0.02 * thingB>MagneticMultipoleP.NotThere + - ghost_child: + kind: Bend + inherit: ghost_ancestor + - main_line: + kind: BeamLine + line: + - DH1A + - ghost_child + - NoSuchElement + - lat1: + kind: Lattice + branches: + - main_line + - use: "lat1" +""" + +# A lattice that names a species with a string constant and feeds it to the +# particle-data functions by symbol (mass_of(species)), not a quoted literal. +SPECIES_CONST_LATTICE = """ +PALS: + facility: + - constants: + species: "#3He" + b_const: 0.45 * mass_of(species) + - DH1A: + kind: Bend + ReferenceP: + species_ref: species + BendP: + h1: 1.1 * mass_of(species) + - main_line: + kind: BeamLine + line: + - DH1A + - lat1: + kind: Lattice + branches: + - main_line + - use: "lat1" +""" + + +def _write(tmp_path, name, text): + path = tmp_path / name + path.write_text(text) + return path + + +def _inlined(lat, lattice, beamline, name): + """The element ``name`` as expansion inlined it into ``lat.full_expanded``. + + The expanded tree is rooted at the lattice entry, so the path runs + lattice > branches > beamline > line > element, with no PALS/facility above. + """ + return lat.full_expanded[lattice]["branches"][0][beamline]["line"][0][name] + + +class TestStandaloneEvaluation: + def test_arithmetic_constants_and_functions(self): + assert evaluate_pals_expression("2 + 3 * 4") == 14.0 + assert evaluate_pals_expression("2 ^ 3 ^ 2") == 512.0 # right-associative + assert evaluate_pals_expression("-2 ^ 2") == -4.0 # unary minus looser + assert evaluate_pals_expression("3.75e7 / c_light^2") == \ + pytest.approx(3.75e7 / 2.99792458e8 ** 2) + assert evaluate_pals_expression("sqrt(2)") == pytest.approx(math.sqrt(2)) + assert evaluate_pals_expression("modulo(7, 3)") == 1.0 + assert evaluate_pals_expression("pi") == pytest.approx(math.pi) + # expr(...) wrapper is accepted. + assert evaluate_pals_expression("expr(2 * pi)") == pytest.approx(2 * math.pi) + + def test_particle_data_functions(self): + # Species names must always be quoted (single or double). Values mirror + # AtomicAndPhysicalConstantsCLib (CODATA 2022). + assert evaluate_pals_expression('mass_of("electron")') == \ + pytest.approx(510998.95069000003) + assert evaluate_pals_expression("mass_of('proton')") == \ + pytest.approx(938272089.43000007) + assert evaluate_pals_expression('charge_of("electron")') == -1.0 + assert evaluate_pals_expression('charge_of("anti-proton")') == -1.0 + assert evaluate_pals_expression('charge_of("helion")') == 2.0 + # A mass number must carry a leading `#` (e.g. "#3He", not "3He"). + assert evaluate_pals_expression('mass_of("#3He")') == \ + pytest.approx(2809413524.398952) + with pytest.raises(ValueError): + evaluate_pals_expression('mass_of("3He")') + + @pytest.mark.parametrize("expr", [ + "thingB", # unknown identifier + 'mass_of("nonsense")', # unknown species + "mass_of(electron)", # unquoted species + "0.01 + random_gauss()", # deferred + "1 +", # parse error + ]) + def test_non_evaluable_inputs_raise(self, expr): + with pytest.raises(ValueError): + evaluate_pals_expression(expr) + + +class TestWholeLatticeEvaluation: + def test_parse_and_expand_pals_evaluates_the_expanded_tree(self, tmp_path): + lat = parse_and_expand_pals(_write(tmp_path, "expr.pals.yaml", EXPR_LATTICE)) + + a_var = 3.75e7 / 2.99792458e8 ** 2 + # cleo is named by main_line, so its definition is inlined into lat1. + cleo = _inlined(lat, "lat1", "main_line", "cleo") + mmp = cleo["MagneticMultipoleP"] + + # Immediate expression using a user variable. + assert cleo["length"].as_float() == pytest.approx(0.1 * math.log(0.34)) + # expr()-delayed expression is evaluated to a number in the expanded tree. + assert mmp["Kn1"].as_float() == pytest.approx(3.74 * a_var) + # random_gauss() is deferred: the text is left untouched. + assert mmp["Kn2"].value == "0.01 + 0.003*random_gauss()" + + # m_e is not part of the lattice and nothing in it refers to m_e, so it is + # left over -- evaluated all the same. + assert lat.adjunct["PALS"]["facility"][2]["m_e"]["value"].as_float() == \ + pytest.approx(510998.95069000003) + + # The combined tree keeps the original expression text. + assert lat.combined["PALS"]["facility"][1]["cleo"]["length"].value == \ + "0.1*log(abs(b_var))" + + def test_resolves_element_parameter_references(self, tmp_path): + lat = parse_and_expand_pals( + _write(tmp_path, "eleparamref.pals.yaml", ELEMENT_PARAM_REF_LATTICE)) + + # edge2_int references thingB's Kn2L (0.1) via element>group.param syntax. + dh1a = _inlined(lat, "lat1", "main_line", "DH1A") + assert dh1a["BendP"]["edge2_int"].as_float() == pytest.approx(0.02 * 0.1) + + def test_resolves_a_species_name_constant(self, tmp_path): + lat = parse_and_expand_pals( + _write(tmp_path, "species.pals.yaml", SPECIES_CONST_LATTICE), + problems="none") + + m_3he = evaluate_pals_expression('mass_of("#3He")') + # The constants block is not part of the lattice, so it is left over. + consts = lat.adjunct["PALS"]["facility"][0]["constants"] + dh1a = _inlined(lat, "lat1", "main_line", "DH1A") + + # mass_of(species) resolves the `species: "#3He"` constant by name. + assert consts["b_const"].as_float() == pytest.approx(0.45 * m_3he) + assert dh1a["BendP"]["h1"].as_float() == pytest.approx(1.1 * m_3he) + # A bare identifier naming the species constant (species_ref: species) is + # replaced by its species-name string in the expanded tree. + assert dh1a["ReferenceP"]["species_ref"].value == "#3He" + # The species constant itself keeps its string species name. + assert consts["species"].value == "#3He" + + def test_evaluates_controllers(self, tmp_path): + lat = parse_and_expand_pals( + _write(tmp_path, "controller.pals.yaml", CONTROLLER_LATTICE)) + + cur1 = 0.023 + cur2 = cur1 / 2.99792458e8 + # Controllers are facility-level, so they are left over rather than part + # of the lattice; their expressions are evaluated all the same. + fac = lat.adjunct["PALS"]["facility"] + ps27 = fac[1]["ps27"] + + # An initial value is a constant expression -- it may use the built-in and + # user constants, never a variable (rejecting a variable reference is + # PALSParserCpp's business and is tested there). + assert ps27["variables"]["cur2"].as_float() == pytest.approx(cur2) + # Each control expression is computed and stored back in the entry. + assert ps27["controls"][0]["expression"].as_float() == \ + pytest.approx(0.075 * math.sin(cur1) + 0.3 * cur2) + # Control expressions may reference lattice constants (my_const = 2). + assert ps27["controls"][1]["expression"].as_float() == \ + pytest.approx(cur1 * 2.0) + # random_gauss() stays deferred. + assert ps27["controls"][2]["expression"].value == "0.01 + random_gauss()" + # The parameter target spec is a name, left untouched. + assert ps27["controls"][0]["parameter"].value == \ + "Qa.*>MagneticMultipoleP.Ks2L" + + # A second controller, with a symbol table of its own. + chrom = fac[2]["chrom_a"] + assert chrom["variables"]["derived"].as_float() == pytest.approx(0.8) + assert chrom["controls"][0]["expression"].as_float() == \ + pytest.approx(5.62 * 0.4 + 0.02 * 0.4 ** 2) + + # The combined tree keeps the original controller expression text. + c_ps27 = lat.combined["PALS"]["facility"][1]["ps27"] + assert c_ps27["controls"][0]["expression"].value == \ + "0.075*sin(cur1) + 0.3*cur2" + + +class TestProblemReporting: + def test_parse_and_expand_pals_reports_expansion_problems(self, tmp_path, + capsys): + path = _write(tmp_path, "broken.pals.yaml", BROKEN_LATTICE) + clean = _write(tmp_path, "clean.pals.yaml", ELEMENT_PARAM_REF_LATTICE) + + # A clean lattice prints nothing by default. + parse_and_expand_pals(clean) + assert capsys.readouterr().err == "" + + # "print" (the default) writes the problems to stderr. + parse_and_expand_pals(path) + printed = capsys.readouterr().err + assert "problem(s)" in printed + assert "NoSuchElement" in printed + + # A filename writes the problems to that file and prints nothing. + report = tmp_path / "problems.txt" + parse_and_expand_pals(path, problems=report) + assert capsys.readouterr().err == "" + contents = report.read_text() + assert "reference to undefined element or line 'NoSuchElement'" in contents + assert "inherit: 'ghost_ancestor' is not defined" in contents + assert "could not evaluate expression for constants.a_const" in contents + assert "could not evaluate expression for BendP.edge2_int" in contents + + # "none" prints nothing, but still returns the problems in the struct. + lat = parse_and_expand_pals(path, problems="none") + assert capsys.readouterr().err == "" + assert lat.problems + assert any("NoSuchElement" in p.message for p in lat.problems) + assert any("inherit: 'ghost_ancestor' is not defined" in p.message + for p in lat.problems) + + # Each problem is classified, not just described. A dangling reference is + # the author's to fix and leaves the trees untrustworthy around it. + dangling = [p for p in lat.problems if "NoSuchElement" in p.message] + assert len(dangling) == 1 + assert dangling[0].severity is PROBLEM_ERROR + assert dangling[0].origin is PROBLEM_INPUT + + # Nothing in this list is left unclassified by accident: the enums round + # -trip from C, so an unmapped integer would raise rather than compare. + assert all(isinstance(p.severity, ProblemSeverity) for p in lat.problems) + assert all(isinstance(p.origin, ProblemOrigin) for p in lat.problems) + # `path` is empty rather than undefined when a problem is not tied to one + # spot, so it is always safe to read. + assert all(isinstance(p.path, str) for p in lat.problems) + + # A clean lattice carries an empty problems list. + assert parse_and_expand_pals(clean, problems="none").problems == [] + + def test_malformed_file_raises_pinpointing_it(self, tmp_path): + # A YAML syntax error (a sequence item missing its ':') is fatal: there is + # no tree to expand. It must raise a catchable error naming the line -- the + # C library no longer aborts the whole process. + path = _write(tmp_path, "bad.pals.yaml", + "PALS:\n facility:\n - cav\n kind: RFCavity\n") + with pytest.raises(PALSParseError) as excinfo: + parse_and_expand_pals(path) + message = str(excinfo.value) + assert "line 4" in message + # The message quotes the source: the preceding line (where the missing ':' + # really is) and a caret, so the fault is easy to spot. + assert "3 | - cav" in message + assert "^" in message diff --git a/tests/test_lattice_views.py b/tests/test_lattice_views.py new file mode 100644 index 0000000..c859606 --- /dev/null +++ b/tests/test_lattice_views.py @@ -0,0 +1,102 @@ +"""Tests that each of the five views parse_and_expand_pals returns is the one it +claims to be. + +Each handle of ``struct lattices`` is read by position, so a field added to the C +struct and missed on the Python side would silently hand back the neighbouring +tree. +""" + +import pytest + +from palsparserpy import parse_and_expand_pals + +# One element carrying enough reference data for the bookkeeper to run, so the +# derived parameters it computes are there to tell the two expanded views apart. +# `Kn1: 2 * 0.6` doubles as the marker for the pre-expansion views, which keep the +# expression text. +VIEWS_LATTICE = """ +PALS: + facility: + - ring: + kind: Lattice + branches: + - main: + kind: BeamLine + line: + - q1: + kind: Quadrupole + length: 0.5 + ReferenceP: + species_ref: electron + pc_ref: 1e9 + MagneticMultipoleP: + Kn1: 2 * 0.6 + - use: ring +""" + + +@pytest.fixture(scope="module") +def views(tmp_path_factory): + path = tmp_path_factory.mktemp("views") / "views.pals.yaml" + path.write_text(VIEWS_LATTICE) + return str(path), parse_and_expand_pals(path, problems="none") + + +def _q1(view): + return view["ring"]["branches"][0]["main"]["line"][0]["q1"] + + +def test_the_five_views_are_five_distinct_trees(views): + _, lat = views + trees = [v.tree for v in (lat.original, lat.combined, lat.expanded, + lat.full_expanded, lat.adjunct)] + assert len({id(t) for t in trees}) == 5 + assert lat.problems == [] + + +def test_original_and_combined_keep_the_pre_expansion_text(views): + path, lat = views + # original is keyed by the path of each file read, combined by the PALS root. + assert lat.original.keys() == [path] + assert "PALS" in lat.combined + for root in (lat.original[path], lat.combined): + q1_raw = (root["PALS"]["facility"][0]["ring"]["branches"][0]["main"] + ["line"][0]["q1"]) + assert q1_raw["MagneticMultipoleP"]["Kn1"].value == "2 * 0.6" + + +def test_adjunct_keeps_the_facility_scaffolding(views): + _, lat = views + assert "PALS" in lat.adjunct + assert "ring" not in lat.adjunct + + +def test_both_expanded_views_are_rooted_at_the_lattice(views): + _, lat = views + for view in (lat.expanded, lat.full_expanded): + assert "ring" in view + assert "PALS" not in view + + +def test_what_the_author_wrote_is_the_same_in_both(views): + _, lat = views + for view in (lat.expanded, lat.full_expanded): + assert _q1(view)["length"].as_float() == 0.5 + # Evaluated in both, so a value shared by the two views agrees. + assert _q1(view)["MagneticMultipoleP"]["Kn1"].as_float() == pytest.approx(1.2) + + +def test_only_full_expanded_carries_the_computed_parameters(views): + _, lat = views + full, exp = _q1(lat.full_expanded), _q1(lat.expanded) + # Derived member of a parameter family the element uses. + assert "Kn1L" in full["MagneticMultipoleP"] + assert "Kn1L" not in exp["MagneticMultipoleP"] + # Placement, and a group the author never wrote. + assert "s_position" in full + assert "s_position" not in exp + assert "FloorP" in full + assert "FloorP" not in exp + # The branch_end Placeholder capping the branch is pruned as well. + assert len(lat.full_expanded["ring"]["branches"][0]["main"]["line"]) == 2 + assert len(lat.expanded["ring"]["branches"][0]["main"]["line"]) == 1 diff --git a/tests/test_matching.py b/tests/test_matching.py new file mode 100644 index 0000000..b25f2b9 --- /dev/null +++ b/tests/test_matching.py @@ -0,0 +1,141 @@ +"""Tests of match_names: PALS name matching.""" + +import pytest + +from palsparserpy import is_map, match_names, node_key, parse_string + +# A self-contained two-lattice lattice: constants/variables at the top, elements +# with ungrouped (`length`) and grouped (`BendP.e1`) parameters, a sub-line +# (sub/S1), and a repeated element name (B1a in both lattices) to exercise `>>>`. +MATCH_LATTICE = """ +PALS: + facility: + - constants: + - a_const: 0.3 * r_electron + - a_two: 5 + - my_var: + kind: variable + value: 37 + - lat1: + kind: Lattice + branches: + - main: + kind: BeamLine + line: + - B1a: + kind: Bend + length: 1.2 + BendP: + e1: 0.1 + g_ref: 0.02 + - B1b: + kind: Bend + length: 1.5 + BendP: + e1: 0.3 + - Q1: + kind: Quadrupole + length: 0.5 + - sub: + kind: BeamLine + line: + - S1: + kind: Sextupole + length: 0.2 + - lat2: + kind: Lattice + branches: + - other: + kind: BeamLine + line: + - B1a: + kind: Bend + length: 9.9 +""" + + +@pytest.fixture(scope="module") +def root(): + return parse_string(MATCH_LATTICE) + + +def test_constants_and_variables_bare_name(root): + m = match_names(root, "a_const") + assert len(m) == 1 + assert node_key(m[0]) == "a_const" + assert m[0].value == "0.3 * r_electron" + + m = match_names(root, "a_.*") + assert {node_key(n) for n in m} == {"a_const", "a_two"} + + m = match_names(root, "my_var") # full form -> named map node + assert len(m) == 1 + assert node_key(m[0]) == "my_var" + assert is_map(m[0]) + + assert match_names(root, "a") == [] # anchored whole-name match + + +def test_bare_name_matches_the_elements_themselves(root): + m = match_names(root, "B1a") # in both lattices + assert len(m) == 2 + assert all(node_key(n) == "B1a" and is_map(n) for n in m) + + +def test_element_parameters(root): + m = match_names(root, "B1.*>BendP.e1") # lat1's B1a, B1b + assert {n.value for n in m} == {"0.1", "0.3"} + + m = match_names(root, "B1a>length") # both lattices + assert {n.value for n in m} == {"1.2", "9.9"} + + assert len(match_names(root, ">length")) == 5 # every element + + m = match_names(root, ">BendP.g_ref") + assert len(m) == 1 + assert node_key(m[0]) == "g_ref" + + m = match_names(root, "B1a>BendP") # drop parameter -> group node + assert len(m) == 1 + assert node_key(m[0]) == "BendP" + assert is_map(m[0]) + + +def test_kind_restriction(root): + m = match_names(root, "Quadrupole::.*>length") + assert len(m) == 1 + assert m[0].value == "0.5" + + assert len(match_names(root, "Bend::B1a>length")) == 2 + assert match_names(root, "Sextupole::B1a>length") == [] + + +def test_branch_filter_includes_sub_lines(root): + assert len(match_names(root, "main>>B1.*>length")) == 2 + + m = match_names(root, "main>>S1>length") # S1 is in sub-line of main + assert len(m) == 1 + assert m[0].value == "0.2" + + assert match_names(root, "nobranch>>B1.*>length") == [] + + +def test_lattice_qualifier(root): + m = match_names(root, "lat1>>>B1a>length") + assert len(m) == 1 + assert m[0].value == "1.2" + + m = match_names(root, "lat2>>>B1a>length") + assert len(m) == 1 + assert m[0].value == "9.9" + + +def test_non_matches_and_bad_patterns(root): + assert match_names(root, "nosuch>foo") == [] + assert match_names(root, "B1a>BendP.nope") == [] + assert match_names(root, "(unclosed") == [] + + +def test_returned_nodes_belong_to_the_searched_tree(root): + m = match_names(root, "lat1>>>B1a>length") + assert m[0].tree is root.tree diff --git a/tests/test_parameter_value.py b/tests/test_parameter_value.py new file mode 100644 index 0000000..fe4289f --- /dev/null +++ b/tests/test_parameter_value.py @@ -0,0 +1,86 @@ +"""Tests of parameter_value: reading one parameter out of an expanded lattice.""" + +import pytest + +from palsparserpy import parameter_value, parse_and_expand_pals + +# A single expandable lattice. Its branch line inline-defines the elements (so they +# are realised in the expanded tree), with a plain-number parameter, an expression +# parameter (to show it comes back evaluated, i.e. from `expanded`, not from the +# raw views), and a non-numeric one. Two quads with different Bn1 exercise the +# conflict case. Facility-level constants and a variable live in the adjunct tree. +PARAM_LATTICE = """ +PALS: + facility: + - constants: + - a_two: 5 + - a_expr: 0.3 * 5 + - my_var: + kind: variable + value: 37 + - ring: + kind: Lattice + branches: + - main: + kind: BeamLine + line: + - q1: + kind: Quadrupole + length: 0.5 + MagneticMultipoleP: + Bn1: 2 * 0.6 + - q2: + kind: Quadrupole + MagneticMultipoleP: + Bn1: -1.0 + - f1: + kind: Foil + ReferenceP: + species_ref: "#3He" + - use: ring +""" + + +@pytest.fixture(scope="module") +def pv(tmp_path_factory): + path = tmp_path_factory.mktemp("params") / "params.pals.yaml" + path.write_text(PARAM_LATTICE) + lat = parse_and_expand_pals(path, problems="none") + return lambda s: parameter_value(lat, s) + + +def test_element_parameters_come_from_the_expanded_lattice(pv): + assert pv("q1>length") == 0.5 + # 2 * 0.6 comes back evaluated (1.2), proving the value is read from + # `expanded` and not from the raw `original`/`combined` views. + assert pv("q1>MagneticMultipoleP.Bn1") == 1.2 + assert pv("q2>MagneticMultipoleP.Bn1") == -1.0 + + +def test_non_numeric_values_stay_strings(pv): + assert pv("f1>ReferenceP.species_ref") == "#3He" + assert pv("q1>kind") == "Quadrupole" + + +def test_unset_parameters_return_the_default(pv): + assert pv("q1>BendP.g") == 0.0 # element found, parameter absent + assert pv("q1>not_a_param") == 0.0 # no schema: unknown == unset -> 0 + + +def test_constants_and_variables_fall_through_to_adjunct(pv): + assert pv("a_two") == 5.0 # compact-form constant + assert pv("a_expr") == 1.5 # evaluated in adjunct during expansion + assert pv("my_var") == 37.0 # full-form variable + + +def test_unidentifiable_lookups_return_none(pv): + assert pv("nosuch>length") is None # in neither view + assert pv("q1") is None # a bare element is not a value + assert pv("q1>MagneticMultipoleP") is None # a group, not a single value + assert pv("(unclosed>length") is None # malformed pattern + assert pv("nosuch_const") is None + + +def test_agreeing_matches_collapse_conflicts_are_none(pv): + assert pv("q.>MagneticMultipoleP.Bn1") is None # 1.2 vs -1.0 conflict + assert pv("q.>kind") == "Quadrupole" # both quads agree diff --git a/tests/test_translate.py b/tests/test_translate.py new file mode 100644 index 0000000..9ee4a2b --- /dev/null +++ b/tests/test_translate.py @@ -0,0 +1,1158 @@ +"""Tests of the three translators: PALS to Bmad, MAD-X and SciBmad.""" + +import re +from textwrap import dedent + +import pytest + +from palsparserpy import (pals_to_bmad, pals_to_madx, pals_to_scibmad, + parse_file, write_bmad_file, write_madx_file, + write_scibmad_file) + +# A small but structurally complete PALS lattice. It exercises every branch of the +# translator dispatch: a BeginningEle (reference / particle-start settings), a +# couple of ordinary elements, a BeamLine, and a Lattice with one branch. The +# first `line` member is a map here, spelling the beginning element out; +# CONTROLLER_FIXTURE below names it instead, which is the other form. +TRANSLATE_FIXTURE = dedent(""" + PALS: + facility: + - beg: + kind: BeginningEle + length: 0 + ReferenceP: + species_ref: electron + pc_ref: 3E6 + TwissP: + beta_a: 10 + alpha_a: 0.5 + cmat11: 0.1 + deta_x_ds: 0.2 + ParticleP: + x: 1 + px: 4 + spin_x: 1 + - d1: + kind: Drift + length: 100 + ApertureP: + shape: RECTANGULAR + x_min: -0.03 + x_max: 0.05 + y_min: -0.01 + y_max: 0.02 + - q1: + kind: Quadrupole + length: 0.5 + - ring: + kind: BeamLine + line: + - beg: + kind: BeginningEle + ReferenceP: + species_ref: electron + pc_ref: 3E6 + - d1 + - q1 + - lat: + kind: Lattice + branches: + - ring + """) + +# A lattice built around the two `Controller` kinds. It also names its beginning +# element in the `line` rather than spelling it out, gives its branch as a map, and +# gives q1 an aperture with a shape but no limits -- all forms the translators have +# to accept. s1's aperture does set limits, so the two can be told apart. m1 carries +# nothing but multipoles, which Bmad handles its own way. +CONTROLLER_FIXTURE = dedent(""" + PALS: + facility: + - beg: + kind: BeginningEle + ReferenceP: + species_ref: electron + pc_ref: 3E6 + - q1: + kind: Quadrupole + length: 0.5 + MagneticMultipoleP: + Kn1: 0.25 + ApertureP: + shape: RECTANGULAR + location: EXIT_END + - s1: + kind: Sextupole + length: 0.2 + MagneticMultipoleP: + Ks2L: 1.5 + ApertureP: + shape: ELLIPTICAL + location: BOTH_ENDS + x_width: 0.25 + x_center: 0.0625 + y_width: 0.5 + y_center: 0.125 + - m1: + kind: Multipole + MagneticMultipoleP: + Kn3L: 0.7 + - ring: + kind: BeamLine + line: + - beg + - q1 + - s1 + - m1 + - lat: + kind: Lattice + branches: + - ring: + periodic: false + - knob: + kind: Controller + control_type: ABSOLUTE + variables: + k: 0.3 + controls: + - parameter: q1>MagneticMultipoleP.Kn1 + expression: 2*k + - bump: + kind: Controller + control_type: RELATIVE + variables: + dk: 0.0 + controls: + - parameter: s1>MagneticMultipoleP.Ks2L + expression: dk + """) + +# One quadrupole for each form a PALS multipole of the element's own order can take, +# a tilted sextupole, and an element that is nothing but multipoles. The controller +# drives one parameter of each, so the elements and the controls can be checked +# against each other. +STRENGTH_FIXTURE = dedent(""" + PALS: + facility: + - beg: + kind: BeginningEle + ReferenceP: + species_ref: electron + pc_ref: 3E6 + - q1: + kind: Quadrupole + length: 0.5 + MagneticMultipoleP: + Kn1: 0.25 + Kn3: 0.4 + - q2: + kind: Quadrupole + length: 2 + MagneticMultipoleP: + Kn1L: 0.6 + - q3: + kind: Quadrupole + length: 0.5 + MagneticMultipoleP: + Bn1: 3.0 + - q4: + kind: Quadrupole + length: 0.5 + MagneticMultipoleP: + Ks1: 0.8 + - s2: + kind: Sextupole + length: 1 + MagneticMultipoleP: + Kn2: 1.0 + tilt2: 0.1 + - m1: + kind: Multipole + MagneticMultipoleP: + Kn3L: 0.7 + - kk: + kind: Controller + variables: + a: 1.0 + controls: + - parameter: q1>MagneticMultipoleP.Kn1 + expression: a + - parameter: q2>MagneticMultipoleP.Kn1L + expression: a + - parameter: q3>MagneticMultipoleP.Bn1 + expression: a + - parameter: q4>MagneticMultipoleP.Ks1 + expression: a + - parameter: q1>MagneticMultipoleP.Kn3 + expression: a + - ring: + kind: BeamLine + line: + - beg + - q1 + - q2 + - q3 + - q4 + - s2 + - m1 + - lat: + kind: Lattice + branches: + - ring + """) + +# A bend for each form its order-0 field can take: a plain one, one whose field is +# the reference bend itself, an integrated one measured against a `radius_ref`, and +# an unnormalized one. The controllers drive b1's field absolutely and b2's +# relatively. +BEND_FIXTURE = dedent(""" + PALS: + facility: + - beg: + kind: BeginningEle + ReferenceP: + species_ref: electron + pc_ref: 3E6 + - b1: + kind: Bend + length: 1 + BendP: + g_ref: 0.5 + MagneticMultipoleP: + Kn0: 0.75 + - b2: + kind: Bend + length: 1 + BendP: + g_ref: 0.5 + MagneticMultipoleP: + Kn0: 0.5 + Kn1: 0.2 + - b3: + kind: Bend + length: 2 + BendP: + radius_ref: 4 + MagneticMultipoleP: + Kn0L: 1.5 + - b4: + kind: Bend + length: 1 + BendP: + Bn0_ref: 2.0 + MagneticMultipoleP: + Bn0: 3.0 + - knob: + kind: Controller + control_type: ABSOLUTE + variables: + kk0: 0.6 + controls: + - parameter: b1>MagneticMultipoleP.Kn0 + expression: kk0 + - bump: + kind: Controller + control_type: RELATIVE + variables: + dkk0: 0.0 + controls: + - parameter: b2>MagneticMultipoleP.Kn0 + expression: dkk0 + - ring: + kind: BeamLine + line: + - beg + - b1 + - b2 + - b3 + - b4 + - lat: + kind: Lattice + branches: + - ring + """) + +# BEND_FIXTURE with b1's reference bend given as a field, which its normalized +# `Kn0` has no way of being measured against. +BEND_MISMATCH_FIXTURE = BEND_FIXTURE.replace("g_ref: 0.5", "Bn0_ref: 0.5", 1) + +# The multipoles a bend carries besides its bending field: the quadrupole and +# sextupole components Bmad holds in `K1` and `K2`, an order above those two, and +# the same components with a skew part -- given outright for b2 and coming out of a +# tilt for b3 -- which no `K` attribute can hold. +BEND_MULTIPOLE_FIXTURE = dedent(""" + PALS: + facility: + - beg: + kind: BeginningEle + ReferenceP: + species_ref: electron + pc_ref: 3E6 + - b1: + kind: Bend + length: 2 + BendP: + g_ref: 0.5 + MagneticMultipoleP: + Kn0: 0.5 + Kn1: 0.2 + Kn2L: 1.2 + Kn3: 0.4 + - b2: + kind: Bend + length: 0.5 + BendP: + g_ref: 0.5 + MagneticMultipoleP: + Kn1: 0.2 + Ks1: 0.8 + - b3: + kind: Bend + length: 0.5 + BendP: + g_ref: 0.5 + MagneticMultipoleP: + Kn2: 1.0 + tilt2: 0.1 + - kk: + kind: Controller + variables: + a: 1.0 + controls: + - parameter: b1>MagneticMultipoleP.Kn1 + expression: a + - parameter: b1>MagneticMultipoleP.Kn2L + expression: a + - parameter: b1>MagneticMultipoleP.Kn3 + expression: a + - parameter: b2>MagneticMultipoleP.Kn1 + expression: a + - ring: + kind: BeamLine + line: + - beg + - b1 + - b2 + - b3 + - lat: + kind: Lattice + branches: + - ring + """) + +# The MetaP components Bmad keeps, one it has no place for, one that is a structure +# rather than a string, and one whose text contains the quote character it would +# normally be wrapped in. +META_FIXTURE = dedent(""" + PALS: + facility: + - beg: + kind: BeginningEle + ReferenceP: + species_ref: electron + pc_ref: 3E6 + - q1: + kind: Quadrupole + length: 0.5 + MetaP: + alias: q_one + label: AGSBPM + description: A quadrupole + ID: 0137-85 + history: + - 2022-04-01: Fixed water leak to vacuum. + - q2: + kind: Quadrupole + length: 0.5 + MetaP: + label: 'has a " double quote' + - ring: + kind: BeamLine + line: + - beg + - q1 + - q2 + - lat: + kind: Lattice + branches: + - ring + """) + +# Constants and variables in both the forms the standard allows -- the compact +# `constants:` / `variables:` lists and the full `kind: constant` / +# `kind: variable` definitions -- defined both directly under `PALS` and in the +# facility, one of them written with no value, and an element whose length is given +# as one of them. +CONSTANT_FIXTURE = dedent(""" + PALS: + constants: + - c_top: 1.5 + facility: + - constants: + - c_one: 0.3 + - c_two: 2 * c_one + - variables: + - v_one: 0.5 + - v_two: + - m_e: + kind: constant + value: mass_of("electron") + - my_var: + kind: variable + value: 37 + - beg: + kind: BeginningEle + ReferenceP: + species_ref: electron + pc_ref: 3E6 + - q1: + kind: Quadrupole + length: c_one + - ring: + kind: BeamLine + line: + - beg + - q1 + - lat: + kind: Lattice + branches: + - ring + """) + +# STRENGTH_FIXTURE without the control that drives a quadrupole's order-3 +# multipole. Bmad keeps that one in a `B3` of its own, but a MAD-X quadrupole has +# no attribute for it at all, and no way to name one entry of a multipole array, so +# the control has nowhere to land. +MADX_STRENGTH_FIXTURE = re.sub( + r"\n *- parameter: q1>MagneticMultipoleP\.Kn3\n *expression: a", "", + STRENGTH_FIXTURE) + +# A misaligned element and an RF cavity, for the two things MAD-X keeps outside an +# element definition or states in units of its own. MAD-X has no controller scope +# either, so `knob` and `bump` name the same variable, which is two independent +# knobs in PALS and one in MAD-X. +MADX_ALIGN_FIXTURE = dedent(""" + PALS: + facility: + - beg: + kind: BeginningEle + ReferenceP: + species_ref: electron + pc_ref: 3E9 + - q1: + kind: Quadrupole + length: 0.5 + BodyShiftP: + x_offset: 1e-4 + x_rot: 2e-4 + y_rot: 3e-4 + z_rot: 4e-4 + - rf1: + kind: RFCavity + length: 1.3 + RFP: + frequency: 5E8 + voltage: 1E6 + phase: 0.1 + zero_phase: ABOVE_TRANSITION + - ring: + kind: BeamLine + line: + - beg + - q1 + - rf1 + - lat: + kind: Lattice + branches: + - ring: + periodic: true + """) + +# A bend for each pair of the three sets of mutually dependent geometry parameters +# PALS allows -- a curvature, a length, and the angle -- since MAD-X wants one +# particular pair of them, the angle and the arc length. b2 also has its entrance +# face given the rectangular way and its exit face the sector way; b3's +# `ref_geometry` puts the whole angle on one face rather than splitting it; and b4 +# states a bend that has the reference geometry but no field of its own. +MADX_GEOMETRY_FIXTURE = dedent(""" + PALS: + facility: + - beg: + kind: BeginningEle + ReferenceP: + species_ref: electron + pc_ref: 3E9 + - b1: + kind: Bend + BendP: + angle_ref: 0.25 + g_ref: 0.5 + - b2: + kind: Bend + length: 2 + BendP: + g_ref: 0.25 + e1_rect: 0.01 + e2: 0.3 + - b3: + kind: Bend + BendP: + angle_ref: 0.4 + L_chord: 1.5 + ref_geometry: EXIT_COORDS + e1_rect: 0.02 + e2_rect: 0.03 + - b4: + kind: Bend + length: 1 + BendP: + g_ref: 0.3 + Kn0_from_g_ref: false + - ring: + kind: BeamLine + line: + - beg + - b1 + - b2 + - b3 + - b4 + - lat: + kind: Lattice + branches: + - ring + """) + +# MADX_ALIGN_FIXTURE with two controllers that each own a variable called `kq`, +# which PALS scopes to its controller and MAD-X does not scope at all. The second is +# RELATIVE and rests at a setting where its expression does not come to zero, which +# is the case a MAD-X deferred assignment cannot state without saying where the knob +# started. (The leading newline anchors the match to the facility entry, not to the +# one under `branches:`.) +MADX_CLASH_FIXTURE = MADX_ALIGN_FIXTURE.replace( + "\n - ring:", + "\n - knob:\n kind: Controller\n MetaP:\n" + " description: Model Mitsubishi 800KL\n" + " variables:\n kq: 0.3\n" + " controls:\n - parameter: q1>length\n expression: 2*kq\n" + " - other:\n kind: Controller\n control_type: RELATIVE\n" + " variables:\n kq: 0.5\n" + " controls:\n - parameter: rf1>length\n expression: 4*kq\n" + " - ring:") + +# CONTROLLER_FIXTURE with its `knob` control aimed at every quadrupole at once. +PATTERN_FIXTURE = CONTROLLER_FIXTURE.replace( + "parameter: q1>MagneticMultipoleP.Kn1", + "parameter: q.*>MagneticMultipoleP.Kn1") + +# CONTROLLER_FIXTURE with its `knob` control naming its slave by kind as well as by +# name. +KIND_FIXTURE = CONTROLLER_FIXTURE.replace("parameter: q1>", + "parameter: Quadrupole::q1>") + +# The same, but asking for a kind q1 is not. +WRONG_KIND_FIXTURE = CONTROLLER_FIXTURE.replace("parameter: q1>", + "parameter: Sextupole::q1>") + +# CONTROLLER_FIXTURE with its `knob` control reaching q1 through the BeamLine it +# sits in. +BEAMLINE_QUALIFIED_FIXTURE = CONTROLLER_FIXTURE.replace("parameter: q1>", + "parameter: ring>>q1>") + + +def _parsed(tmp_path, text): + """Write ``text`` to a file in ``tmp_path`` and return the parsed tree.""" + path = tmp_path / "fixture.pals.yaml" + path.write_text(text) + return parse_file(path) + + +def _written(writer, tmp_path, lat, ext): + """Write a translated lattice out and read it back, for the tests that only + want to look at the text.""" + path = tmp_path / f"fixture.pals_out.{ext}" + writer(lat, path) + return path.read_text() + + +def _bmad_text(tmp_path, lat): + return _written(write_bmad_file, tmp_path, lat, "bmad") + + +def _madx_text(tmp_path, lat): + return _written(write_madx_file, tmp_path, lat, "madx") + + +def _scibmad_text(tmp_path, lat): + return _written(write_scibmad_file, tmp_path, lat, "jl") + + +def test_pals_to_bmad_writes_a_bmad_lattice_file(tmp_path): + out = _bmad_text(tmp_path, pals_to_bmad(_parsed(tmp_path, TRANSLATE_FIXTURE))) + + # BeginningEle -> global parameter / beginning / particle_start settings. + assert "parameter[particle] = electron" in out + assert "parameter[p0c] = 3E6" in out + assert "particle_start[x] = 1" in out + assert "particle_start[px] = 4" in out + assert "particle_start[spin_x] = 1" in out + + # TwissP -> the beginning element. Bmad and PALS agree on these names except + # for the coupling matrix, where Bmad has an underscore. + assert "beginning[beta_a] = 10" in out + assert "beginning[alpha_a] = 0.5" in out + assert "beginning[cmat_11] = 0.1" in out + assert "beginning[deta_x_ds] = 0.2" in out + + # Ordinary element definitions. + assert "d1: Drift" in out + assert "L = 100" in out + assert "q1: Quadrupole" in out + + # No element here has multipoles, so none of them needs the scaling turned off. + assert "scale_multipoles" not in out + + # A Bmad limit is a distance from the axis, so the low-side ones come out + # positive: Bmad loses a particle at `x < -x1_limit`, where PALS loses it at + # `x < x_min`. + assert "x1_limit = 0.03, x2_limit = 0.05" in out + assert "y1_limit = 0.01, y2_limit = 0.02" in out + + # BeamLine definition (line[0] is dropped by design, leaving d1, q1). + assert "ring: line = (d1, q1)" in out + + # Branch structure. + assert "parameter[geometry] = open" in out + assert "use, ring" in out + + +def test_pals_to_madx_writes_a_madx_lattice_file(tmp_path): + out = _madx_text(tmp_path, pals_to_madx(_parsed(tmp_path, TRANSLATE_FIXTURE))) + + # BeginningEle -> the BEAM command. MAD-X states the reference energy in GeV + # where PALS states it in eV, and takes the species first and works the rest + # out from it. + assert "beam, particle = electron, pc = 0.003;" in out + + # TwissP -> a BETA0 block, which is where MAD-X takes initial conditions from. + assert "pals_beta0: beta0,\n\tbetx = 10,\n\talfx = 0.5;" in out + + # ParticleP -> the START command of the TRACK module, which has no place in a + # lattice file, so it is written out as a comment rather than as a command. + assert "! start, x = 1, px = 4;" in out + + # Ordinary element definitions. Every MAD-X statement ends in a semicolon. + assert "d1: drift,\n\tl = 100;" in out + assert "q1: quadrupole,\n\tl = 0.5;" in out + + # MAD-X is the one format of the three that cannot put an aperture on a drift, + # so d1's is reported rather than written out. + assert "aperture" not in out + + # Beamline definition (line[0] is dropped by design, leaving d1, q1). + assert "ring: line = (d1, q1);" in out + + # MAD-X has no geometry attribute: whether a branch closes on itself is decided + # by how it is used, so the flag is carried across as a comment beside the + # `use`. + assert "use, period = ring;\t! open" in out + + # Nothing here states a field rather than a normalized strength, so the + # rigidity that would normalize one is not defined. + assert "pals_brho" not in out + + +def test_pals_to_scibmad_writes_a_scibmad_lattice_file(tmp_path): + out = _scibmad_text(tmp_path, + pals_to_scibmad(_parsed(tmp_path, TRANSLATE_FIXTURE))) + + # @elements block with the ordinary elements. + assert "@elements begin" in out + assert "d1 = LineElement(" in out + assert "kind = Drift" in out + + # SciBmad states a limit as an edge position, so the low-side ones stay as PALS + # wrote them -- where Bmad wants the distance from the axis. + assert "x1_limit = -0.03, x2_limit = 0.05" in out + assert "y1_limit = -0.01, y2_limit = 0.02" in out + assert "L = 100" in out + assert "q1 = LineElement(" in out + + # BeginningEle -> particle coordinates and the phase-space vector. + assert "x = 1" in out + assert "v = [ x px y py z pz ]" in out + + # Beamline and lattice list. + assert "ring = Beamline([" in out + assert "lat = [ring,]" in out + + +def test_a_controller_becomes_a_bmad_overlay_or_group(tmp_path): + out = _bmad_text(tmp_path, pals_to_bmad(_parsed(tmp_path, CONTROLLER_FIXTURE))) + + # ABSOLUTE sets the parameter, so it is an overlay. `Kn1` is a quadrupole's own + # strength, which Bmad keeps in `K1` in the same units, so nothing is rescaled. + # The variable list and each initial value get a continuation line of their own. + assert "knob: overlay = {q1[K1]: 2*k},\n\tvar = {k},\n\tk = 0.3\n" in out + + # RELATIVE adds to the parameter, so it is a group. `Ks2L` is already + # integrated -- no length -- but is skew and second order, hence A2 and the + # 1/2! of the convention. + assert "bump: group = {s1[A2]: 0.5*(dk)},\n\tvar = {dk},\n\tdk = 0.0\n" in out + + # q1's strength is its own `K1`, so it has no multipole left to scale. s1's is + # skew, which Bmad has no sextupole attribute for, so it stays the multipole + # `A2` -- and Bmad would otherwise read that as a fraction of s1's strength and + # scale it by that, i.e. by zero, since the strength is the multipole. + assert "q1: Quadrupole,\n\tL = 0.5,\n\tK1 = 0.25\n" in out + assert "A2 = 0.75,\n\tscale_multipoles = F" in out + + # q1's aperture group sets no limit, so it bounds nothing and none of it is + # written out. s1's does, and Bmad states each limit as a distance from the + # axis. + assert "x1_limit = 0.0625, x2_limit = 0.1875" in out + assert "y1_limit = 0.125, y2_limit = 0.375" in out + assert "aperture_type = elliptical,\n\taperture_at = both_ends" in out + assert out.count("aperture_type") == 1 + + # An element that is only multipoles does no such scaling, and has no attribute + # to set. + assert "m1: AB_Multipole,\n\tB3 = 0.11666666666666665\n" in out + + # The rest of the lattice still comes through. + assert "ring: line = (q1, s1, m1)" in out + assert "use, ring" in out + + +def test_a_controller_becomes_madx_variables_and_deferred_assignments(tmp_path): + out = _madx_text(tmp_path, pals_to_madx(_parsed(tmp_path, CONTROLLER_FIXTURE))) + + # MAD-X has no controller element. A variable and the deferred `:=` that makes + # an attribute depend on it are what it has instead, and are what a controller + # becomes. `Kn1` is a quadrupole's own strength, which MAD-X keeps in `k1` in + # the same units. + assert "! Controller knob\nk = 0.3;\nq1->k1 := 2*k;" in out + + # RELATIVE adds to the parameter. A deferred assignment can only set one -- + # MAD-X forbids the circular `s1->k2s := s1->k2s + ...` -- so what is being + # added to has to be written into the assignment, and it is the value the + # element definition already has. `Ks2L` is integrated and MAD-X's `k2s` is + # not, hence the 1/0.2 on both. + assert "s1: sextupole,\n\tl = 0.2,\n\tk2s = 7.5" in out + assert "! Controller bump\ndk = 0.0;\ns1->k2s := 7.5 + (5.0*(dk));" in out + + # Unlike Bmad, MAD-X has a skew attribute for each of these orders, so a skew + # multipole of the element's own order needs no multipole element of its own -- + # and nothing needs its scaling turned off, MAD-X reading no multipole as a + # fraction of anything. + assert "q1: quadrupole,\n\tl = 0.5,\n\tk1 = 0.25;" in out + + # q1's aperture group sets no limit, so it bounds nothing and none of it is + # written out. s1's does: MAD-X states a half extent about the axis and the + # offset of the centre, where PALS states the two edges or a width and a + # centre. + assert ("aperture = {0.125, 0.25},\n\taper_offset = {0.0625, 0.125}," + "\n\tapertype = ellipse;") in out + assert out.count("apertype") == 1 + + # An element that is only multipoles is a MAD-X multipole, whose coefficients + # are the integrated ones indexed by order from zero up, with the gaps filled + # in. + assert "m1: multipole,\n\tknl = {0, 0, 0, 0.7};" in out + + # The rest of the lattice still comes through. + assert "ring: line = (q1, s1, m1);" in out + assert "use, period = ring;" in out + + +def test_a_controller_becomes_a_scibmad_controller(tmp_path): + out = _scibmad_text(tmp_path, + pals_to_scibmad(_parsed(tmp_path, CONTROLLER_FIXTURE))) + + # SciBmad keeps the PALS parameter names, so only the group prefix is dropped + # and nothing needs rescaling. Every control takes all of the controller's + # variables. + assert "knob = Controller(" in out + assert "(q1, :Kn1) => (ele; k) -> 2*k" in out + assert "vars = (; k = 0.3)" in out + + # RELATIVE adds to the value the element already carries. + assert "bump = Controller(" in out + assert "(s1, :Ks2L) => (ele; dk) -> ele.Ks2L + (dk)" in out + + # An aperture group that sets no limit bounds nothing, so q1 gets no aperture + # at all. + assert "q1 = LineElement(kind = Quadrupole, L = 0.5, Kn1 = 0.25)" in out + + # s1's does bound something. SciBmad states each limit as an edge position, so + # the low-side ones are negative -- where Bmad wants a distance from the axis. + assert "x1_limit = -0.0625, x2_limit = 0.1875" in out + assert "y1_limit = -0.125, y2_limit = 0.375" in out + assert ("aperture_shape = ApertureShape.Elliptical, " + "aperture_at = ApertureAt.BothEnds") in out + + # The line names its beginning element; its reference parameters still reach + # the beamline, and the branch given as a map still reaches the lattice list. + assert "species_ref = electron" in out + assert "lat = [ring,]" in out + + +def test_an_elements_own_multipole_becomes_its_bmad_strength_attribute(tmp_path): + out = _bmad_text(tmp_path, pals_to_bmad(_parsed(tmp_path, STRENGTH_FIXTURE))) + + # A quadrupole's order-1 field is its K1, in the same units. Any other order is + # a multipole, which is integrated and carries the 1/n!: 0.4 * 0.5 / 3! here. + assert ("q1: Quadrupole,\n\tL = 0.5,\n\tK1 = 0.25," + "\n\tB3 = 0.03333333333333333,\n\tscale_multipoles = F\n") in out + + # K1 is not length integrated, so an integrated PALS value is divided by the + # length -- and nothing is left over to need the scaling turned off. + assert "q2: Quadrupole,\n\tL = 2,\n\tK1 = 0.3\n" in out + + # An unnormalized multipole gives the field attribute instead, and field_master + # with it. + assert ("q3: Quadrupole,\n\tL = 0.5,\n\tfield_master = T," + "\n\tB1_GRADIENT = 3.0\n") in out + + # Bmad has no skew quadrupole attribute, so a skew multipole stays a multipole: + # 0.8 * 0.5. + assert ("q4: Quadrupole,\n\tL = 0.5,\n\tA1 = 0.4," + "\n\tscale_multipoles = F\n") in out + + # A tilt of T on an order-N multipole rotates it by (N+1)*T, so this sextupole + # turns by 0.3 rad: its normal part is the strength attribute and its skew part + # a multipole. + assert "s2: Sextupole,\n\tL = 1,\n\tK2 = 0.9553364" in out # cos(0.3) + assert "A2 = -0.1477601" in out # -sin(0.3) * 1/2! + + # An element that is only multipoles keeps them all: 0.7 / 3!. + assert "m1: AB_Multipole,\n\tB3 = 0.11666666666666665\n" in out + + # Every control lands on the attribute its element was given, scaled the same + # way, and each gets a line to itself. + assert ("kk: overlay = {q1[K1]: a,\n\t\tq2[K1]: 0.5*(a)," + "\n\t\tq3[B1_GRADIENT]: a,\n\t\tq4[A1]: 0.5*(a)," + "\n\t\tq1[B3]: 0.08333333333333333*(a)}," + "\n\tvar = {a},\n\ta = 1.0\n") in out + + +def test_an_elements_own_multipole_becomes_its_madx_strength_attribute(tmp_path): + out = _madx_text(tmp_path, + pals_to_madx(_parsed(tmp_path, MADX_STRENGTH_FIXTURE))) + + # A quadrupole's order-1 field is its k1, in the same units: unlike Bmad's + # An/Bn, a MAD-X coefficient carries no 1/n!, and neither does a PALS one. Any + # other order has nowhere to go on a MAD-X quadrupole and is reported rather + # than written out. + assert "q1: quadrupole,\n\tl = 0.5,\n\tk1 = 0.25;" in out + + # k1 is not length integrated, so an integrated PALS value is divided by the + # length. + assert "q2: quadrupole,\n\tl = 2,\n\tk1 = 0.3;" in out + + # MAD-X has no field-valued strength attribute at all, so an unnormalized + # multipole is divided by the rigidity -- which MAD-X works out for itself from + # the BEAM command. + assert "pals_brho := beam->brho * beam->charge / abs(beam->charge);" in out + assert "q3: quadrupole,\n\tl = 0.5,\n\tk1 = 3.0 / pals_brho;" in out + + # MAD-X does have a skew quadrupole attribute, where Bmad keeps a skew + # multipole a multipole, so nothing is left over and no length goes into it. + assert "q4: quadrupole,\n\tl = 0.5,\n\tk1s = 0.8;" in out + + # A tilt of T on an order-N multipole rotates it by (N+1)*T, so this sextupole + # turns by 0.3 rad: its normal part is k2 and its skew part k2s, and MAD-X's own + # tilt -- which would turn the whole element -- is left alone. + assert "s2: sextupole,\n\tl = 1,\n\tk2 = 0.9553364" in out # cos(0.3) + assert "k2s = -0.2955202" in out # -sin(0.3) + + # An element that is only multipoles keeps them all, integrated and without a + # factorial. + assert "m1: multipole,\n\tknl = {0, 0, 0, 0.7};" in out + + # Every control lands on the attribute its element was given, scaled the same + # way. + assert ("! Controller kk\na = 1.0;\nq1->k1 := a;\nq2->k1 := 0.5*(a);" + "\nq3->k1 := (a) / pals_brho;\nq4->k1s := a;") in out + + +def test_a_control_madx_has_no_attribute_for_is_reported(tmp_path): + # Bmad keeps a quadrupole's order-3 multipole in a B3 of its own. A MAD-X + # quadrupole has no such attribute, and MAD-X has no way to name one entry of a + # multipole array, so a control aimed there has nowhere to land. + with pytest.raises(ValueError, match="has no attribute for"): + pals_to_madx(_parsed(tmp_path, STRENGTH_FIXTURE)) + + +def test_a_bends_order_0_multipole_becomes_its_madx_angle(tmp_path): + out = _madx_text(tmp_path, pals_to_madx(_parsed(tmp_path, BEND_FIXTURE))) + + # MAD-X builds a bend out of its angle, however PALS chose to state the same + # geometry: as a curvature here, as a radius for b3, as a field for b4. + assert "b1: sbend,\n\tl = 1,\n\tangle = 0.5;" in out + assert "b3: sbend,\n\tl = 2,\n\tangle = 0.5;" in out + assert "b4: sbend,\n\tl = 1,\n\tangle = 2.0 / pals_brho;" in out + + # MAD-X has the one `angle` for the geometry and the field both, so a field + # that agrees with the reference bend has nothing left to state, and one that + # disagrees -- b1, b3 and b4 -- is reported rather than written out, which + # would move everything downstream. The orders that are not the bend's own are + # its own attributes here too. + assert "b2: sbend,\n\tl = 1,\n\tangle = 0.5,\n\tk1 = 0.2;" in out + assert "angle = 0.75" not in out + assert "angle = 1.5" not in out + + # A control on the field is a control on that same angle: `Kn0` is not + # integrated and the angle is, so the length goes in -- which is 1 for b1, + # hence no factor. + assert "! Controller knob\nkk0 = 0.6;\nb1->angle := kk0;" in out + assert "! Controller bump\ndkk0 = 0.0;\nb2->angle := 0.5 + (dkk0);" in out + + +def test_a_bends_order_0_multipole_becomes_its_bmad_dg(tmp_path): + out = _bmad_text(tmp_path, pals_to_bmad(_parsed(tmp_path, BEND_FIXTURE))) + + # PALS states the bend field outright; Bmad states its departure from the + # reference bend. + assert "b1: SBend,\n\tL = 1,\n\tg = 0.5,\n\tDG = 0.25\n" in out + + # A bend whose field is the reference bend departs from it by nothing, so there + # is no DG to write. Its order-1 field is the K1 a Bmad bend has of its own. + assert "b2: SBend,\n\tL = 1,\n\tg = 0.5,\n\tK1 = 0.2\n" in out + + # DG is not length integrated, so an integrated PALS field is divided by the + # length before the reference comes off it -- and the reference may be given as + # the radius of the bend. + assert "b3: SBend,\n\tL = 2,\n\trho = 4,\n\tDG = 0.5\n" in out + + # An unnormalized field is measured against the unnormalized reference, in the + # same way. + assert ("b4: SBend,\n\tL = 1,\n\tB_field = 2.0,\n\tfield_master = T," + "\n\tDB_FIELD = 1.0\n") in out + + # An overlay sets DG, so it has to take the reference off what PALS drives. A + # group varies DG instead, and the reference it is measured from is the same + # before and after. + assert ("knob: overlay = {b1[DG]: kk0 - (0.5)},\n\tvar = {kk0}," + "\n\tkk0 = 0.6\n") in out + assert "bump: group = {b2[DG]: dkk0},\n\tvar = {dkk0},\n\tdkk0 = 0.0\n" in out + + +def test_a_bends_order_1_and_2_multipoles_become_its_bmad_k1_and_k2(tmp_path): + out = _bmad_text(tmp_path, + pals_to_bmad(_parsed(tmp_path, BEND_MULTIPOLE_FIXTURE))) + + # A Bmad bend has a K1 and a K2 of its own, in the same units as the PALS + # field: K2 is not length integrated, so the integrated 1.2 is divided by the + # length. A bend has nothing above order 2, so the order-3 field stays a + # multipole, integrated and with the 1/n!: 0.4 * 2 / 3! here. + assert ("b1: SBend,\n\tL = 2,\n\tg = 0.5,\n\tK1 = 0.2,\n\tK2 = 0.6," + "\n\tB3 = 0.13333333333333333,\n\tscale_multipoles = F\n") in out + + # K1 and K2 hold a normal field only, and the two are components added to a + # field the bend already has rather than the strength that makes it a bend. So + # an order with a skew part keeps both parts in the An/Bn form: 0.2 * 0.5 and + # 0.8 * 0.5. + assert ("b2: SBend,\n\tL = 0.5,\n\tg = 0.5,\n\tA1 = 0.4,\n\tB1 = 0.1," + "\n\tscale_multipoles = F\n") in out + + # A tilt is a skew part too: an order-2 multipole tilted by 0.1 turns by 0.3 + # rad, so this one is B2 = cos(0.3) * 0.5 / 2! and A2 = -sin(0.3) * 0.5 / 2!. + assert "b3: SBend,\n\tL = 0.5,\n\tg = 0.5,\n\tA2 = -0.0738800" in out + assert "B2 = 0.2388341" in out + + # Every control lands on the attribute its element was given, scaled the same + # way: K2 is not integrated, so an integrated PALS parameter picks up 1/L, + # while B3 and B1 are, so a non-integrated one picks up L (and the 1/n!). + assert ("kk: overlay = {b1[K1]: a,\n\t\tb1[K2]: 0.5*(a)," + "\n\t\tb1[B3]: 0.3333333333333333*(a),\n\t\tb2[B1]: 0.5*(a)}," + "\n\tvar = {a},\n\ta = 1.0\n") in out + + +def test_a_bend_field_and_reference_of_different_kinds_are_reported(tmp_path): + # Normalizing one against the other takes the reference momentum, which belongs + # to the branch rather than to the element. + with pytest.raises(ValueError, match="are not both normalized"): + pals_to_bmad(_parsed(tmp_path, BEND_MISMATCH_FIXTURE)) + + +def test_metap_becomes_the_bmad_metadata_strings(tmp_path): + out = _bmad_text(tmp_path, pals_to_bmad(_parsed(tmp_path, META_FIXTURE))) + + # Bmad has three metadata strings: alias, type (PALS `label`) and descrip + # (`description`). + assert ('q1: Quadrupole,\n\tL = 0.5,\n\talias = "q_one",\n\ttype = "AGSBPM",' + '\n\tdescrip = "A quadrupole"\n') in out + + # The components Bmad has no place for are dropped rather than forced into one. + assert "0137-85" not in out + assert "water leak" not in out + + # Bmad cannot escape a quote inside a string, so a string holding one of the + # two quote characters is wrapped in the other. + assert ('q2: Quadrupole,\n\tL = 0.5,\n\ttype = \'has a " double quote\'\n') in out + + +def test_metap_becomes_madx_comments(tmp_path): + out = _madx_text(tmp_path, pals_to_madx(_parsed(tmp_path, META_FIXTURE))) + + # A MAD-X element holds no metadata of its own -- there is no attribute to put + # any of this in -- so what PALS says about an element is kept as a comment + # above it. That leaves room for the components Bmad has to drop, and for a + # quote character that Bmad, having no escape for one, cannot always write. + assert ("! alias: q_one\n! label: AGSBPM\n! description: A quadrupole" + "\n! ID: 0137-85\nq1: quadrupole,") in out + assert '! label: has a " double quote\nq2: quadrupole,' in out + + # A component holding a structure rather than a string still has nowhere to go. + assert "water leak" not in out + + +def test_constants_and_variables_become_madx_definitions(tmp_path): + out = _madx_text(tmp_path, pals_to_madx(_parsed(tmp_path, CONSTANT_FIXTURE))) + + # MAD-X draws no constant/variable distinction either: both are a name with a + # value, written in definition order because a MAD-X name has to be defined + # above the point of use. Those defined directly under `PALS` are translated + # alongside the facility's own. + assert ('c_top = 1.5;\nc_one = 0.3;\nc_two = 2 * c_one;\nv_one = 0.5;' + '\nv_two = 0;\nm_e = mass_of("electron");\nmy_var = 37;\n') in out + + # Which is also why the whole section comes before anything that could use one. + assert out.index("c_one = 0.3") < out.index("beam, particle") + assert out.index("my_var = 37") < out.index("q1: quadrupole") + + # An element parameter given as a constant is carried over as it was written. + assert "q1: quadrupole,\n\tl = c_one;" in out + + +def test_constants_and_variables_become_bmad_definitions(tmp_path): + out = _bmad_text(tmp_path, pals_to_bmad(_parsed(tmp_path, CONSTANT_FIXTURE))) + + # Bmad draws no constant/variable distinction: both are a name with a value, + # written in definition order because a Bmad name has to be defined above the + # point of use. Those defined directly under `PALS` are translated alongside + # the facility's own. + assert ('c_top = 1.5\nc_one = 0.3\nc_two = 2 * c_one\nv_one = 0.5\nv_two = 0\n' + 'm_e = mass_of("electron")\nmy_var = 37\n') in out + + # Which is also why the whole section comes before anything that could use one. + assert out.index("c_one = 0.3") < out.index("parameter[particle]") + assert out.index("my_var = 37") < out.index("q1: Quadrupole") + + # An element parameter given as a constant is carried over as it was written. + assert "q1: Quadrupole,\n\tL = c_one\n" in out + + +def test_a_bodyshiftp_becomes_a_madx_ealign_and_rf_takes_madx_units(tmp_path): + out = _madx_text(tmp_path, pals_to_madx(_parsed(tmp_path, MADX_ALIGN_FIXTURE))) + + # MAD-X keeps a misalignment out of the element definition and in an EALIGN of + # its own, applied to whatever the SELECT before it picked out. Its DPHI turns + # the element the other way round from the right-hand rule the other two angles + # follow. + assert "q1: quadrupole,\n\tl = 0.5;" in out + assert ('select, flag = error, clear;\n' + 'select, flag = error, pattern = "^q1$";\n' + 'ealign, dx = 1e-4, dphi = -0.0002, dtheta = 3e-4, dpsi = 4e-4;') in out + + # Which is also why the EALIGN can only come after the sequence has been + # expanded. + assert out.index("use, period = ring;") < out.index("ealign,") + + # MAD-X states a frequency in MHz and a voltage in MV where PALS states Hz and + # volts, and its zero lag is half a period from the stable point above + # transition. + assert ("rf1: rfcavity,\n\tl = 1.3,\n\tfreq = 500.0,\n\tvolt = 1.0," + "\n\tlag = -0.4;") in out + + # A branch that closes on itself is not something MAD-X states in the lattice. + assert "use, period = ring;\t! closed" in out + + +def test_a_bends_geometry_becomes_madxs_angle_and_arc_length(tmp_path): + out = _madx_text(tmp_path, + pals_to_madx(_parsed(tmp_path, MADX_GEOMETRY_FIXTURE))) + + # PALS states a bend's geometry with any two of a curvature, a length and the + # angle; MAD-X wants one particular pair, the angle and the arc length, so the + # pair given has to be turned into that pair. Here: angle and curvature, so the + # arc is 0.25/0.5. + assert "b1: sbend,\n\tl = 0.5,\n\tangle = 0.25;" in out + + # Curvature and arc length, so the angle is 0.25*2 -- and the entrance face, + # given the rectangular way, is e1_rect + angle/2 while the exit face was given + # the sector way MAD-X measures an sbend against and comes straight across. + assert "b2: sbend,\n\tl = 2,\n\tangle = 0.5,\n\te1 = 0.26,\n\te2 = 0.3;" in out + + # Angle and chord length, so the arc is angle*L_chord/(2 sin(angle/2)). With + # ref_geometry EXIT_COORDS the whole angle lands on the entrance face and none + # on the exit face, rather than being split between them. + assert "b3: sbend,\n\tl = 1.5100468" in out + assert "e1 = 0.42" in out + assert "e2 = 0.03;" in out + + +def test_controller_variables_are_scoped_into_madxs_one_namespace(tmp_path): + out = _madx_text(tmp_path, pals_to_madx(_parsed(tmp_path, MADX_CLASH_FIXTURE))) + + # A PALS controller owns its variables, so `knob>kq` and `other>kq` are two + # independent knobs. A MAD-X variable is a name in the one namespace the whole + # file shares, so a name two controllers both claim is prefixed with the + # controller that owns it -- and the expressions that use it are rewritten to + # match. + assert "knob__kq = 0.3;\nq1->l := 2*knob__kq;" in out + assert "other__kq = 0.5;" in out + + # A controller can carry a MetaP, which MAD-X has nowhere to put but a comment. + assert "! Controller knob\n! description: Model Mitsubishi 800KL" in out + + # A RELATIVE controller is a knob: its slave keeps the value the lattice gave + # it and moves by how far the knob has turned *from where it started*. A Bmad + # group keeps track of that by itself; MAD-X has to be told, and `4*kq` is not + # zero at kq = 0.5. + assert "rf1->l := 1.3 + (4*other__kq) - (4*(0.5));" in out + + +def test_a_control_target_no_translator_can_express_is_reported(tmp_path): + yaml = _parsed(tmp_path, PATTERN_FIXTURE) + for translate in (pals_to_bmad, pals_to_madx, pals_to_scibmad): + with pytest.raises(ValueError, match="selects slaves by pattern"): + translate(yaml) + + +def test_a_control_target_may_name_its_slaves_kind(tmp_path): + # `{kind}::{name}` narrows a name to one kind. All three formats give an + # element the one name, so the qualifier is checked against the element found + # and then dropped. + yaml = _parsed(tmp_path, KIND_FIXTURE) + assert "overlay = {q1[K1]: 2*k}" in _bmad_text(tmp_path, pals_to_bmad(yaml)) + assert "q1->k1 :=" in _madx_text(tmp_path, pals_to_madx(yaml)) + assert "(q1, :Kn1)" in _scibmad_text(tmp_path, pals_to_scibmad(yaml)) + + # A qualifier the element does not answer to is an error, not a silent miss. + wrong = _parsed(tmp_path, WRONG_KIND_FIXTURE) + for translate in (pals_to_bmad, pals_to_madx, pals_to_scibmad): + with pytest.raises(ValueError, + match="asks for a Sextupole but q1 is a Quadrupole"): + translate(wrong) + + +def test_a_control_target_reached_through_a_beamline_is_reported(tmp_path): + # PALS's `>>` and `>>>` name the BeamLine or Lattice an element is reached + # through, which lets one occurrence of a repeated element be driven on its + # own. None of the three formats has that, and `>>` splits into an empty field, + # so it is caught before the split rather than reported as a malformed target. + yaml = _parsed(tmp_path, BEAMLINE_QUALIFIED_FIXTURE) + for translate in (pals_to_bmad, pals_to_madx, pals_to_scibmad): + with pytest.raises(ValueError, + match="reaches its element through a BeamLine or Lattice"): + translate(yaml) + + +def test_a_periodic_branch_becomes_bmads_closed_geometry(tmp_path): + # `periodic` arrives as a YAML node, so it has to be rendered before it is + # compared -- comparing the node itself is always false, which made every + # branch come out open. + out = _bmad_text(tmp_path, pals_to_bmad(_parsed(tmp_path, MADX_CLASH_FIXTURE))) + assert "parameter[geometry] = closed" in out + assert "parameter[geometry] = open" not in out diff --git a/tests/test_yaml.py b/tests/test_yaml.py new file mode 100644 index 0000000..a54a526 --- /dev/null +++ b/tests/test_yaml.py @@ -0,0 +1,457 @@ +"""Tests of the YAML tree wrapper: parsing, navigation, editing and emitting.""" + +import pytest + +from palsparserpy import (PALSParseError, YAMLTree, create_empty_tree, is_map, + is_scalar, is_sequence, parse_file, parse_string, + to_yaml_string, write_yaml) + + +class TestNodeCreation: + def test_create_empty_tree_returns_a_map_root(self): + root = create_empty_tree() + assert root.tree.handle + assert is_map(root) + assert len(root) == 0 + + def test_add_map_and_add_sequence_create_typed_children(self): + root = create_empty_tree() + assert is_map(root.add_map(key="m")) + assert is_sequence(root.add_sequence(key="s")) + + def test_add_scalar_creates_a_scalar_child(self): + # Sequence elements are pure VAL nodes; keyed map entries are KEYVAL and + # ryml's is_val() returns false for those -- use a seq element. + root = create_empty_tree() + seq = root.add_sequence(key="items") + scalar = seq.add_scalar("hello") + assert is_scalar(scalar) + assert scalar.value == "hello" + + def test_invalid_tree_handle_raises(self): + with pytest.raises(ValueError): + YAMLTree(None) + + +class TestParsing: + def test_parse_string_map(self): + node = parse_string("name: Alice\nage: 30\nactive: true\n") + assert is_map(node) + assert "name" in node and "age" in node and "active" in node + assert node["name"].value == "Alice" + assert node["age"].as_int() == 30 + assert node["active"].as_bool() is True + + def test_parse_string_sequence(self): + node = parse_string("- item1\n- item2\n- item3\n") + assert is_sequence(node) + assert len(node) == 3 + assert [n.value for n in node] == ["item1", "item2", "item3"] + + def test_parse_string_nested_structure(self): + node = parse_string("users:\n" + " - name: Alice\n" + " age: 30\n" + " - name: Bob\n" + " age: 25\n") + assert is_map(node) + users = node["users"] + assert is_sequence(users) + assert len(users) == 2 + assert users[0]["name"].value == "Alice" + assert users[0]["age"].as_int() == 30 + + def test_parse_file_round_trip(self, tmp_path): + path = tmp_path / "x.yaml" + path.write_text("x: 1\ny: 2\n") + node = parse_file(path) + assert is_map(node) + assert node["x"].as_int() == 1 + assert node["y"].as_int() == 2 + + def test_parse_file_missing_file_raises(self): + with pytest.raises(FileNotFoundError): + parse_file("/nonexistent/path.yaml") + + def test_malformed_yaml_raises_a_pinpointed_error(self, tmp_path): + # A sequence item missing its ':' is a syntax error. The C library used to + # abort the whole process; it must now raise a catchable error whose + # message names the offending line. + with pytest.raises(PALSParseError, match="line"): + parse_string("- cav\n kind: RFCavity\n") + + path = tmp_path / "bad.yaml" + path.write_text("a: 1\n b: 2\n") + with pytest.raises(PALSParseError): + parse_file(path) + + +class TestTypeChecks: + def test_a_node_is_exactly_one_of_the_three(self): + root = parse_string("key: value") + seq = parse_string("- 1\n- 2\n- 3\n") + scalar = seq[0] # pure VAL node; map entries (KEYVAL) fail is_scalar + + assert is_scalar(scalar) and not is_map(scalar) and not is_sequence(scalar) + assert is_map(root) and not is_scalar(root) and not is_sequence(root) + assert is_sequence(seq) and not is_scalar(seq) and not is_map(seq) + + +class TestAccessOperations: + def test_map_access_by_key(self): + node = parse_string("name: Alice\nage: 30") + assert node["name"].value == "Alice" + assert node["age"].as_int() == 30 + + def test_sequence_access_by_index(self): + node = parse_string("- 10\n- 20\n- 30\n") + assert [n.as_int() for n in node] == [10, 20, 30] + assert node[-1].as_int() == 30 + + def test_key_not_found_raises(self): + node = parse_string("name: Alice") + with pytest.raises(KeyError): + node["nonexistent"] + assert node.get("nonexistent") is None + + def test_index_out_of_bounds_raises(self): + node = parse_string("- 1\n- 2\n- 3\n") + with pytest.raises(IndexError): + node[10] + + def test_contains(self): + node = parse_string("name: Alice\nage: 30") + assert "name" in node + assert "age" in node + assert "nonexistent" not in node + + def test_len(self): + assert len(parse_string("- 1\n- 2\n- 3\n- 4\n- 5\n")) == 5 + assert len(parse_string("a: 1\nb: 2\nc: 3")) == 3 + + def test_iteration_and_items(self): + node = parse_string("a: 1\nb: 2") + assert list(node) == ["a", "b"] + assert [(k, v.as_int()) for k, v in node.items()] == [("a", 1), ("b", 2)] + assert [v.as_int() for v in node.values()] == [1, 2] + + def test_a_node_is_always_truthy(self): + # __len__ alone would make an empty map, and every scalar, falsy. + assert parse_string("{}") + assert parse_string("- x\n")[0] + + +class TestTypeConversions: + def test_string_conversion(self): + assert parse_string("msg: hello world")["msg"].value == "hello world" + + def test_int_conversion(self): + node = parse_string("a: 42\nb: -100") + assert node["a"].as_int() == 42 + assert int(node["b"]) == -100 + + def test_float_conversion(self): + node = parse_string("x: 3.14159\ny: -2.5") + assert node["x"].as_float() == pytest.approx(3.14159) + assert float(node["y"]) == pytest.approx(-2.5) + + def test_bool_conversion(self): + node = parse_string("t: true\nf: false") + assert node["t"].as_bool() is True + assert node["f"].as_bool() is False + with pytest.raises(ValueError): + parse_string("x: yes")["x"].as_bool() + + +class TestModificationOperations: + def test_setitem_adds_and_updates_map_entries(self): + root = create_empty_tree() + root["name"] = "Alice" + root["age"] = "30" + root["pi"] = "3.14" + root["active"] = "true" + + assert root["name"].value == "Alice" + assert root["age"].as_int() == 30 + assert root["pi"].as_float() == pytest.approx(3.14) + assert root["active"].as_bool() is True + + root["age"] = "31" # update an existing key + assert root["age"].as_int() == 31 + + def test_add_map_as_child_of_map(self): + parent = create_empty_tree() + child = parent.add_map(key="child") + child["key"] = "value" + + assert is_map(parent["child"]) + assert parent["child"]["key"].value == "value" + + def test_set_scalar_updates_an_existing_scalar(self): + root = create_empty_tree() + scalar = root.add_scalar("initial", key="val") + scalar.set_scalar("updated") + assert scalar.value == "updated" + + scalar.set_scalar("42") + assert scalar.as_int() == 42 + + scalar.set_scalar("2.71828") + assert scalar.as_float() == pytest.approx(2.71828) + + scalar.set_scalar("false") + assert scalar.as_bool() is False + + def test_add_scalar_appends_to_sequences(self): + root = create_empty_tree() + seq = root.add_sequence(key="items") + + seq.add_scalar("item1") + seq.add_scalar("item2") + assert len(seq) == 2 + assert [n.value for n in seq] == ["item1", "item2"] + + seq.add_scalar("10", index=0) # insert at front + assert seq[0].value == "10" + assert len(seq) == 3 + + def test_add_map_as_sequence_element(self): + root = create_empty_tree() + seq = root.add_sequence(key="records") + elem = seq.add_map() + elem["key"] = "value" + + assert len(seq) == 1 + assert is_map(seq[0]) + assert seq[0]["key"].value == "value" + + def test_remove(self): + root = create_empty_tree() + root["keep"] = "yes" + root["delete"] = "no" + assert len(root) == 2 + + root["delete"].remove() + assert len(root) == 1 + assert "delete" not in root + assert "keep" in root + + def test_delitem(self): + root = create_empty_tree() + root["keep"] = "yes" + root["delete"] = "no" + del root["delete"] + assert "delete" not in root + + +class TestWriteAndEmit: + def test_to_yaml_string(self): + text = to_yaml_string(parse_string("name: Alice\nage: 30")) + assert isinstance(text, str) + for part in ("name", "Alice", "age", "30"): + assert part in text + + def test_to_yaml_string_with_exclude(self): + node = parse_string( + "lat:\n" + " elements:\n" + " - name: q1\n" + " FloorP: {r: [1, 2, 3]}\n" + " L: 0.5\n" + " ReferenceP: {species: electron}\n" + " ReferenceP: {pc: 1e9}\n") + + text = to_yaml_string(node, exclude=["FloorP", "ReferenceP"]) + assert "FloorP" not in text + assert "ReferenceP" not in text + assert "electron" not in text # the excluded subtrees go too + assert "q1" in text + assert "L" in text + + # A single key may be given as a bare string. + text = to_yaml_string(node, exclude="FloorP") + assert "FloorP" not in text + assert "ReferenceP" in text + + # The default and an empty exclude list are the unfiltered output, and the + # node itself is never modified. + assert to_yaml_string(node, exclude=[]) == to_yaml_string(node) + assert "FloorP" in to_yaml_string(node) + + def test_write_yaml_with_exclude(self, tmp_path): + root = parse_string( + "lat:\n" + " elements:\n" + " - name: q1\n" + " FloorP: {r: [1, 2, 3]}\n" + " L: 0.5\n" + " ReferenceP: {pc: 1e9}\n" + "other: stuff\n") + path = tmp_path / "out.yaml" + + # Writing from a non-root node still writes the whole tree. + assert write_yaml(root["lat"], path, exclude=["FloorP", "ReferenceP"]) + text = path.read_text() + assert "FloorP" not in text + assert "ReferenceP" not in text + assert "q1" in text + assert "other" in text + + # The tree in memory keeps everything. + assert "FloorP" in to_yaml_string(root) + + def test_write_yaml_to_file_and_read_back(self, tmp_path): + root = create_empty_tree() + root["test"] = "data" + root["value"] = "123" + path = tmp_path / "out.yaml" + + assert write_yaml(root, path) + assert path.is_file() + + loaded = parse_file(path) + assert loaded["test"].value == "data" + assert loaded["value"].as_int() == 123 + + +class TestDeepCopy: + def test_copy_produces_an_independent_duplicate(self): + original = parse_string("name: Alice\nage: 30") + cloned = original.copy() + + assert cloned["name"].value == "Alice" + assert cloned["age"].as_int() == 30 + + # Mutating the clone must not affect the original. + cloned["name"] = "Bob" + assert cloned["name"].value == "Bob" + assert original["name"].value == "Alice" + + # Trees are independent objects. + assert cloned.tree.handle != original.tree.handle + + def test_deep_copy_node_copies_content_into_existing_node(self): + src = parse_string("x: 10\ny: 20") + dst = create_empty_tree() + dst.deep_copy_node(src) + + assert dst["x"].as_int() == 10 + assert dst["y"].as_int() == 20 + + def test_deep_copy_children_copies_children_into_existing_node(self): + src = parse_string("a: 1\nb: 2") + dst = create_empty_tree() + dst["existing"] = "yes" + + dst.deep_copy_children(src) + assert "existing" in dst + assert "a" in dst and "b" in dst + assert dst["a"].as_int() == 1 + + def test_deep_copy_children_honors_an_explicit_index(self): + src = parse_string("- a\n- b\n") # children to graft in + dst = parse_string("- x\n- y\n") # existing sequence + assert is_sequence(dst) + + dst.deep_copy_children(src, index=0) # insert at the front + assert len(dst) == 4 + assert [n.value for n in dst] == ["a", "b", "x", "y"] + + +class TestDisplay: + def test_repr_names_the_node_type(self): + assert "map" in repr(parse_string("a: 1\nb: 2")) + assert "sequence" in repr(parse_string("- x\n- y\n")) + assert "scalar" in repr(parse_string("- x\n- y\n")[0]) + + def test_str_is_the_yaml_text(self): + assert str(parse_string("a: 1\nb: 2")) == "a: 1\nb: 2" + + +class TestComplexScenarios: + def test_build_nested_structure_programmatically(self): + # {users: [{name: Alice, scores: [90, 85, 92]}, + # {name: Bob, scores: [88, 91, 87]}]} + root = create_empty_tree() + users = root.add_sequence(key="users") + + user1 = users.add_map() + user1["name"] = "Alice" + scores1 = user1.add_sequence(key="scores") + for value in ("90", "85", "92"): + scores1.add_scalar(value) + + user2 = users.add_map() + user2["name"] = "Bob" + scores2 = user2.add_sequence(key="scores") + for value in ("88", "91", "87"): + scores2.add_scalar(value) + + assert is_map(root) + assert is_sequence(root["users"]) + assert len(root["users"]) == 2 + assert root["users"][0]["name"].value == "Alice" + assert len(root["users"][0]["scores"]) == 3 + assert root["users"][0]["scores"][0].as_int() == 90 + + def test_parse_and_modify_existing_yaml(self): + node = parse_string("config:\n timeout: 30\n retries: 3\n") + config = node["config"] + + config["timeout"] = "60" + config["status"] = "enabled" + + assert node["config"]["timeout"].as_int() == 60 + assert node["config"]["status"].value == "enabled" + assert node["config"]["retries"].as_int() == 3 # unchanged + + def test_round_trip_yaml_through_file(self, tmp_path): + original = parse_string( + "application:\n" + " name: MyApp\n" + " version: 1.0.0\n" + " features:\n" + " - authentication\n" + " - logging\n" + " - caching\n" + " settings:\n" + " debug: true\n" + " port: 8080\n") + path = tmp_path / "out.yaml" + + assert write_yaml(original, path) + loaded = parse_file(path) + + app = loaded["application"] + assert app["name"].value == "MyApp" + assert app["version"].value == "1.0.0" + assert len(app["features"]) == 3 + assert app["features"][0].value == "authentication" + assert app["settings"]["debug"].as_bool() is True + assert app["settings"]["port"].as_int() == 8080 + + +class TestEdgeCases: + def test_empty_structures(self): + empty_map = parse_string("{}") + assert is_map(empty_map) + assert len(empty_map) == 0 + + empty_seq = parse_string("[]") + assert is_sequence(empty_seq) + assert len(empty_seq) == 0 + + def test_special_string_values(self): + assert parse_string('text: "true"')["text"].value == "true" + assert parse_string('number: "123"')["number"].value == "123" + + def test_unicode_strings(self): + assert parse_string("greeting: こんにけは")["greeting"].value == "こんにけは" + assert parse_string("emoji: πŸŽ‰")["emoji"].value == "πŸŽ‰" + + def test_multiline_strings(self): + node = parse_string("description: |\n" + " This is a\n" + " multiline\n" + " string\n") + assert "multiline" in node["description"].value