diff --git a/.prettierignore b/.prettierignore
index 3891a9359..b894e10fb 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -26,6 +26,7 @@ docs/docs/assets/
node_modules
# Vendored snapshots
+src/easydiffraction/display/structure/renderers/vendor/
src/easydiffraction/report/templates/html/vendor/
src/easydiffraction/report/templates/tex/styles/
src/easydiffraction/utils/_vendored/jupyter_dark_detect/
diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md
index ceb7fcf87..96e7c70ec 100644
--- a/THIRD_PARTY_LICENSES.md
+++ b/THIRD_PARTY_LICENSES.md
@@ -11,3 +11,15 @@ The vendored report LaTeX style files are documented in
The vendored report HTML assets are documented in
`src/easydiffraction/report/templates/html/vendor/LICENSES.md`.
+
+## Structure-View Three.js
+
+The vendored Three.js assets for the crysview structure view (MIT) are
+documented in
+`src/easydiffraction/display/structure/renderers/vendor/threejs/LICENSES.md`.
+
+## Structure-View Element Data
+
+The bundled per-element radii and colour palettes (with per-source
+provenance) are documented in
+`src/easydiffraction/display/structure/assets/LICENSES.md`.
diff --git a/docs/dev/adrs/accepted/category-owner-sections.md b/docs/dev/adrs/accepted/category-owner-sections.md
index a5cf27b39..0091b4c59 100644
--- a/docs/dev/adrs/accepted/category-owner-sections.md
+++ b/docs/dev/adrs/accepted/category-owner-sections.md
@@ -85,13 +85,13 @@ Its current children are:
The public API stays flat and user-facing:
- `project.info`
-- `project.chart`
-- `project.table`
+- `project.rendering_plot`
+- `project.rendering_table`
Saved `project.cif` remains a section file without a `data_` header. It
-serializes the `_project.*` metadata category plus the `_chart.*` and
-`_table.*` configuration categories without pretending that the project
-config is a real datablock.
+serializes the `_project.*` metadata category plus the
+`_rendering_plot.*` and `_rendering_table.*` configuration categories
+without pretending that the project config is a real datablock.
### 4. CIF serialization is split by responsibility
diff --git a/docs/dev/adrs/accepted/crysview-structure-visualization.md b/docs/dev/adrs/accepted/crysview-structure-visualization.md
new file mode 100644
index 000000000..c6dd4c154
--- /dev/null
+++ b/docs/dev/adrs/accepted/crysview-structure-visualization.md
@@ -0,0 +1,745 @@
+# ADR: crysview Structure Visualization
+
+## Status
+
+Accepted.
+
+**Date:** 2026-05-31
+
+**Implementation note:** A later implementation pass split
+structure-view configuration into `rendering_structure`,
+`structure_view`, and `structure_style`. This ADR reflects that final
+surface; no separate `structure-view-settings` ADR exists.
+
+## Context
+
+EasyDiffraction refines crystal structures but offers no interactive 3D
+view of them. The accepted [Display UX Facade](display-ux.md) ADR
+defines `project.display` for 1D pattern charts and for parameter, fit,
+and posterior tables and plots, but nothing spatial: there is no way to
+look at the atoms, the unit cell, or the anisotropic displacement
+parameters a refinement is adjusting.
+
+A working prototype establishes the target experience and the data it
+needs. It lives at
+[`crysview-threejs-demo.html`](crysview-threejs-demo.html) and
+demonstrates, against a non-orthogonal unit cell:
+
+- atoms as spheres with element radius and colour;
+- anisotropic ADP ellipsoids (semi-axis lengths plus orientation);
+- mixed-occupancy atoms drawn as occupancy wedges (a sphere split by
+ site occupancy);
+- two-colour bonds split at their midpoint;
+- magnetic-moment arrows;
+- an a/b/c axis triad drawn longer than the cell edges;
+- a Plotly-style modebar: perspective/parallel projection toggle,
+ view-along-a/b/c buttons, a home/reset button, and per-feature
+ visibility toggles for cell, axes, atoms, bonds, moments, and labels;
+- a shrink-wrapped legend, hover tooltips, and persistent atom labels;
+- orbit / zoom / pan controls and both perspective and orthographic
+ cameras, with parallel projection as the default.
+
+The prototype's input comment already separates the concerns: a
+crystallography layer performs symmetry expansion, fractional →
+Cartesian conversion, ADP eigendecomposition, and element-radius lookup;
+a visualization layer chooses sizes, colours, bonds, and occupancy
+splitting; and the renderer only consumes prepared geometry.
+
+Relevant facts about the current codebase:
+
+- The structure model lives under
+ `src/easydiffraction/datablocks/structure/categories/`: `cell`,
+ `atom_sites` (fractional coordinates, occupancy, isotropic ADP),
+ `atom_site_aniso` (anisotropic ADP), and `space_group`.
+- The 1D charting subsystem already uses a switchable-engine pattern.
+ `project.rendering_plot.type` selects a plotter engine implemented
+ under `src/easydiffraction/display/plotters/` (`ascii.py`,
+ `plotly.py`), and `project.rendering_table.type` selects a tabler.
+ These follow the switchable-category ADRs, with CIF tags
+ `_rendering_plot.type` and `_rendering_table.type`.
+- `easycrystallography` is **not** a dependency today and is not
+ imported anywhere in `src/`. Any layering that places a separate
+ visualization package between `easycrystallography` and
+ `easydiffraction` is therefore a future direction, not the current
+ state.
+
+The audience is scientists, often non-programmers, working mostly in
+Jupyter notebooks and the planned GUI. Discoverability, clear names, and
+safe defaults take priority over developer ergonomics.
+
+## Decision
+
+This ADR records the accepted first version of the structure viewer.
+Points that earlier reviews left open are settled in the sections below;
+the historical open questions are retained only to document the final
+choices.
+
+### 1. Build a renderer-neutral structure scene
+
+crysview converts a crystal structure into a prepared, renderer-neutral
+**structure scene**: a flat collection of typed primitives expressed in
+Cartesian space and carrying no rendering-library types. The primitive
+set matches the prototype:
+
+- atom spheres (centre, radius, colour);
+- occupancy wedges for mixed-occupancy sites;
+- ADP ellipsoids (semi-axes scaled to the configured probability, plus
+ orientation);
+- bonds (two endpoints, split colour);
+- magnetic-moment arrows;
+- unit-cell edges;
+- the a/b/c axis triad;
+- text labels.
+
+All crystallographic computation — symmetry expansion over the
+configured cell range (section 3), fractional → Cartesian conversion,
+ADP eigendecomposition, radii and colours from the selected model and
+colour scheme, bond detection, and occupancy splitting — happens while
+building the scene, upstream of any renderer. This is the contract the
+prototype already assumes.
+
+### 2. Draw the scene with thin, pluggable renderers
+
+Renderers consume the scene and draw it; they hold no crystallographic
+logic. Renderer choice mirrors `project.rendering_plot.type`:
+
+- an ASCII renderer for terminal, CLI, and headless contexts;
+- a Three.js renderer for notebooks (embedded HTML/JS) and standalone
+ HTML;
+- a raster renderer that emits a static, z-buffered PNG image for the
+ TeX/PDF report — a trimetric projection of the same scene with a
+ per-pixel depth buffer, so hidden-surface removal is exact (atoms,
+ bonds, cell edges, and axes all occlude correctly). It is **not** a
+ user-selectable engine (it is invoked by the report, like `pgfplots`
+ is for the fit plot). The z-buffer rasterisation is plain numpy; it
+ uses `Pillow` to draw the a/b/c axis labels and the element legend and
+ to encode the PNG;
+- a Qt Quick 3D renderer for the GUI is planned.
+
+ASCII and Three.js are the initial interactive engines, shipping
+together exactly as the `ascii` and `plotly` chart engines do; the
+raster renderer serves the TeX/PDF report, and Qt Quick 3D follows for
+the GUI.
+
+A switchable engine selector is added on the project owner, parallel to
+`project.rendering_plot` / `project.rendering_table`. It is named
+`rendering_structure`:
+
+```python
+project.rendering_structure.type = 'auto' # default: 'threejs' in Jupyter, 'ascii' in a terminal
+project.rendering_structure.show_supported()
+```
+
+with CIF tag `_rendering_structure.type`. The name parallels
+`rendering_plot` / `rendering_table`, and follows the category-owned
+selector contract: `project.rendering_structure` is a read-only
+attribute on the owner; `project.rendering_structure.type` is the
+writable selector; `project.rendering_structure.show_supported()` lists
+engines. Switching `type` calls the owner's private
+`_swap_rendering_structure` hook, which rebinds the active renderer —
+the same Family B rebinding the plot engine selector uses — so no public
+`rendering_structure_type` setter or
+`show_supported_rendering_structure_types()` is added. The default is
+`auto`, which resolves at draw time to `threejs` in a Jupyter notebook
+and `ascii` in a terminal — exactly as `_rendering_plot.type` /
+`_rendering_table.type` resolve their environment defaults.
+
+### 3. Add a `structure()` entry point on the display facade
+
+Add `project.display.structure(struct_name=...)`, parallel to the
+existing `project.display.pattern(expt_name=...)`. It renders one
+structure with the active `view` engine — interactive 3D in a notebook,
+a schematic projection in the terminal. In the Three.js engine, feature
+visibility, projection, and view-along presets are interactive through
+the modebar with sensible defaults (parallel projection; cell, axes,
+atoms, and bonds visible, plus moments where the data exists; labels
+off). In a notebook it embeds an interactive view (an IPython HTML
+representation); like the HTML report it can also write a standalone
+HTML file to a path. The exact return and save signature is left to the
+implementation plan.
+
+Content selection mirrors `pattern(include=...)` rather than inventing a
+new vocabulary:
+
+```python
+project.display.structure(struct_name='lbco')
+project.display.structure(struct_name='lbco', include='auto')
+project.display.structure(
+ struct_name='lbco',
+ include=('atoms', 'bonds', 'cell', 'axes', 'moments', 'labels'),
+)
+```
+
+`include='auto'` shows what the structure state supports (cell, axes,
+atoms, bonds, and moments where moment data exists; labels off by
+default). The option vocabulary is `auto`, `atoms`, `bonds`, `cell`,
+`axes`, `moments`, and `labels`. ADP ellipsoids and mixed-occupancy
+splits are not separate keywords: they are drawn automatically as part
+of `atoms` (anisotropic ADP gives an ellipsoid, isotropic a sphere; a
+mixed site is split), so the data decides. The interactive modebar
+toggles the same features after the initial view is drawn, so `include`
+sets the starting state and the modebar refines it.
+
+A companion `project.display.show_structure_options(struct_name=...)`
+mirrors the existing `show_pattern_options(expt_name=...)`: it lists
+each `include=` option with whether the active engine and the current
+structure state support it, and the reason when they do not — for
+example `moments` is unavailable until the structure model carries
+moment fields, and the `ascii` engine reports the features only the 3D
+engines draw. This gives the structure view the same per-option
+discoverability the pattern view already offers.
+
+The view also has a spatial extent: which symmetry-equivalent atoms the
+scene contains. The scene builder takes the unique (asymmetric-unit)
+atoms, applies the space-group symmetry, and keeps every generated copy
+whose fractional coordinates fall within a per-axis range, **borders
+included**. The default range is `[0, 1]` on each of a, b, and c, so a
+full unit cell is drawn with the atoms on the 0 and 1 faces, edges, and
+corners all present (a corner site therefore appears at all eight
+corners). The range is user-settable per axis, validated so each minimum
+is below its maximum, and need not be integer — `[0, 2]` along a draws
+two cells, `[-0.2, 1.2]` adds a margin. Like the other settings it is
+persisted and overridable per call:
+
+```python
+# Persisted per-axis bounds — six scalar settings, like the cell
+# parameters (defaults 0 and 1 on each axis = the full cell, borders
+# included):
+project.structure_view.range_a_max = 2 # two cells along a
+project.structure_view.range_c_min, project.structure_view.range_c_max = -0.2, 1.2 # margin on c
+
+# A convenience tuple overrides the persisted range for one call only:
+project.display.structure(
+ struct_name='lbco',
+ range=((0, 2), (0, 1), (0, 1)),
+)
+```
+
+Symmetry expansion can map several operations onto one point — a site on
+a special position, or the shared 0-and-1 faces the default range keeps
+— so the scene builder applies a **scene-atom identity rule** as it
+collects copies. Two generated atoms are the same scene atom when they
+come from the same atom-site row _and_ their fractional coordinates
+coincide within a small tolerance (`1e-4` in fractional units); the
+builder keeps one and drops the rest. The tolerance is far below any
+cell fraction, so a copy at 0 and its border-included copy at 1 are
+distinct positions and both survive — special-position overlaps collapse
+without discarding the intentional boundary translations. The atom-site
+row participates in the key, so two different rows that happen to share
+a position are not merged here; that case is occupancy grouping, handled
+next.
+
+When two or more atom-site rows resolve to the same position (within
+that same tolerance), the scene builder groups them into one
+**occupancy-wedge sphere** rather than overdrawing coincident spheres:
+each row contributes a wedge whose angular share is proportional to its
+occupancy. Coincident position is the only grouping signal the model
+offers — atom sites carry an occupancy but no disorder-group or
+occupancy-group field — so it is the documented version-1 criterion.
+When the grouped occupancies sum below one, the remainder is drawn as a
+vacancy wedge so the empty fraction is visible (a lone site with
+occupancy below one is the one-row case); when they meet or exceed one,
+the shares are normalized to their sum and no vacancy wedge is drawn.
+The builder invents no occupancies — it shows exactly what the rows
+carry.
+
+Because expansion happens in the scene builder (section 1), the 3D
+engines draw this expanded set in full. The `ascii` engine is the
+reduced-fidelity sibling (section 7): it always renders the single
+default cell and reports a wider view range as a 3D-only capability
+through `show_structure_options()`, the same way it announces the other
+features only the 3D engines draw.
+
+### 4. Start internal, design for later extraction
+
+Implement crysview first as an internal subpackage that mirrors
+`display/plotters/` (for example
+`src/easydiffraction/display/structure/` with renderers under a
+`renderers/` subpackage). Keep the scene model free of
+easydiffraction-domain imports so it can later be extracted into a
+standalone `crysview` package and, eventually, consume
+`easycrystallography`. Do **not** add `easycrystallography` as a
+dependency now.
+
+### 5. Pin and deliver Three.js deliberately
+
+The prototype loads a pinned Three.js (`three@0.160.0`) plus
+`OrbitControls` and `CSS2DRenderer` through a CDN importmap. Production
+ships the pinned Three.js bundled with the package so the notebook and
+standalone-HTML views are autonomous — they render with no network,
+which is what a CDN-blocked or sandboxed context (where the demo renders
+blank) needs. This mirrors how the existing report path already embeds
+its JavaScript when asked: `report/html_renderer.py` exposes an
+`offline` flag that sets `include_plotlyjs=True` (embed) versus `'cdn'`,
+gated in the template by `html_offline`.
+
+The HTML report's structure view follows the same rule: it honours the
+report's `html_offline` flag, embedding the Three.js assets when offline
+and otherwise linking them, so a structure figure behaves like the
+existing Plotly figures in a report.
+
+### 6. Source styling from standard models and colour schemes
+
+Atom radii and colours are not typed in per element. They follow from
+**standard, user-selected models** that every structure viewer
+recognises, looked up automatically from each atom's element (and its
+charge where the model needs it):
+
+- a **radius model** turns an element into a sphere radius — van der
+ Waals, ionic (Shannon; the site charge where a model carries one,
+ otherwise a documented per-element default, see below), or covalent;
+- a **colour scheme** is a named element-colour palette — the Jmol/CPK
+ scheme, the VESTA scheme, and similar well-known sets.
+
+A scientist picks one model and one scheme instead of editing dozens of
+per-element rows, which keeps the view consistent and reproducible:
+
+```python
+project.structure_style.atom_view = 'covalent' # vdw | covalent | ionic | adp
+project.structure_style.color_scheme = 'jmol' # jmol | vesta
+project.structure_style.atom_view.show_supported()
+project.structure_style.color_scheme.show_supported()
+```
+
+How an atom is sized and shaped is a single **display-style switch**,
+`atom_view`, because the standard radius models and the ADP probability
+surface are alternative depictions and a view shows one of them at a
+time:
+
+- `'vdw'`, `'covalent'`, `'ionic'` draw every atom as a **radius-model
+ sphere** for the named standard radius table; displacement parameters
+ do not affect size. This is the familiar ball-and-stick depiction and
+ works for any structure, with or without ADP.
+- `'adp'` draws each atom as its **ADP probability surface** — a sphere
+ for an atom with only isotropic ADP, an ellipsoid (semi-axes and
+ orientation from the ADP tensor) for an anisotropic one. Atoms that
+ carry no ADP fall back to a covalent-radius sphere. This is the
+ thermal-ellipsoid (ORTEP) depiction crystallographers use to inspect
+ the displacement parameters a refinement adjusts.
+
+The default is `'covalent'`, because it gives every structure a stable
+charge-free ball view. Users can switch to `'adp'` when they want to
+inspect displacement surfaces.
+
+> **Amendment — `atom_view` merge.** An earlier design split this into
+> two settings: `atom_shape` (`ball`/`ortep`) and `radius_model`
+> (`vdw`/`covalent`/`ionic`/`atomic`). They were merged into the single
+> `atom_view` selector because `radius_model` was meaningful only in
+> ball mode, so the two-field form carried four degenerate
+> `ortep`×radius-model combinations. The flat list removes the dead
+> states and matches how VESTA/Mercury present the choice. The
+> `atomic`/empirical option was then dropped, leaving
+> `{vdw, covalent, ionic, adp}`: its radii are within a few percent of
+> `covalent` for most elements (and identical for some), so after
+> ball-size compression it was visually indistinguishable and added a
+> redundant choice. The atomic radii remain in the element database,
+> unused by the public selector. The `adp` view still uses covalent
+> radii for the ball fallback and for mixed-occupancy sites. CIF field:
+> `_structure_style.atom_view`.
+
+In `'adp'` the surfaces are drawn at one **probability level**,
+`adp_probability`, a fraction in the open interval (0, 1) — not a
+percentage — validated on assignment. It defaults to `0.5` (the ORTEP
+and journal 50% convention) and is freely changeable (for example
+`0.95`). It has no effect in the radius-model views.
+
+Which bonds the view draws is **not** a styling choice — it is a
+geometric property of the structure, and it follows the **standard
+cif_core `_geom` auto-bonding model**, not the display `atom_view`. A
+bond is drawn between two sites when their distance `d` satisfies
+`_geom.min_bond_distance_cutoff ≤ d ≤ r_bond(i) + r_bond(j) + _geom.bond_distance_incr`,
+where the per-type bonding radius `r_bond` is `_atom_type.radius_bond`
+when the structure carries it, otherwise the element's covalent radius
+from the bundled database. Matches are then pruned to the first
+coordination shell — a contact is kept only when it is within `1.3×` the
+nearer atom's nearest-neighbour distance — so the large covalent radii
+of ionic A-site cations do not bond to every surrounding anion (a
+heuristic stop-gap; see open issue #108 for the full near-neighbour
+approach). These two cutoffs live on the **structure** and persist in
+the structure's own CIF (see section 8), not in
+`project.structure_style`. The `atom_view` radius models (vdw / covalent
+/ ionic) change only the rendered sphere _size_ — they never decide
+which bonds appear; bond detection is governed solely by the `_geom`
+cutoffs and the per-type bonding radius. Version 1 draws bonds computed
+on the fly from this rule while the scene is built and persists no bond
+table. The full computed bond and angle geometry — the standard
+`_geom_bond` and `_geom_angle` loops, with distances, angles, symmetry
+codes, and standard uncertainties — is a separate, related feature that
+reuses the same symmetry-expansion and distance math (see Deferred
+Work).
+
+`atom_view` and `color_scheme` are finite, closed value sets, so each is
+a `(str, Enum)` validated on assignment per the
+[Enum-Backed Closed Value Sets](enum-backed-closed-values.md) ADR, and
+each selector lists its accepted values through descriptor-level
+`show_supported()` — for example
+`project.structure_style.atom_view.show_supported()`. `structure_style`
+is a plain category, not a switchable one: it has no factory-swapped
+`type`, only these validated value settings.
+
+The defaults are the **`covalent`** atom view and the **Jmol/CPK**
+colour scheme, so the view looks right with no configuration. Covalent
+radii are preferred because they are backed by complete, well-documented
+per-element data and need no oxidation state: today's atom-site model
+carries only an element symbol — no charge, oxidation-state, or
+coordination field — so a model that depends on charge cannot be
+resolved per site yet.
+
+The radii and colours come from a **bundled element database** — a
+package asset, like the colour palettes, not a per-project value, so it
+is not CIF-serialized; the project CIF records only which model and
+scheme are selected. The database carries, per element, the van der
+Waals, covalent, ionic (a representative Shannon radius at a documented
+default oxidation state and coordination), and atomic/empirical radii,
+plus the Jmol/CPK and VESTA colour palettes, each value carrying a
+documented provenance. The ionic entries let `atom_view = 'ionic'` work
+today against the documented default oxidation state; when a future
+atom-site charge field exists the ionic model will prefer the site's
+charge. An element with no entry for the selected radius model falls
+back to its covalent radius, and `show_structure_options()` reports the
+substitution instead of failing. Version 1 adds no per-element overrides
+on top of the chosen model and scheme.
+
+All of this is CIF-persisted, so a reopened project renders identically.
+The decision is that styling is **an atom-shape mode plus model, scheme,
+and probability-level selection**, not a per-element table; the exact
+CIF tag names and serialization shape are pinned in the implementation
+plan (Open Questions, resolved).
+
+The view also adapts to the host's **colour theme**. Like the Plotly
+chart engine — which selects the `plotly_dark` or `plotly_white`
+template from the detected theme — the structure view reuses the
+project's existing dark/light detection (`is_dark()` in
+`utils/_vendored`) and switches the scene background and the label,
+axis, and edge colours to match, so a notebook in dark mode gets a dark
+canvas. Element colours still come from the selected colour scheme
+regardless of theme; only the surrounding canvas and annotations follow
+it. The theme is auto-detected, not a persisted styling value.
+
+### 7. Terminal view (ASCII engine)
+
+The `ascii` engine renders in the terminal, mirroring the existing
+`ascii` chart plotter: it builds a character grid and prints it, with no
+GUI or JavaScript. Like that chart engine — which openly announces the
+features only Plotly can draw — it is a deliberately reduced-fidelity
+sibling of the 3D engines: one schematic projection, one unit cell, and
+no bonds, labels, ADP ellipsoids, or moment arrows. When an `include=`
+request asks for one of those features, the engine announces it is
+available with the 3D engines and skips it, just as the ascii chart
+engine does for Plotly-only features. A view range wider than the
+default single cell is treated the same way: the terminal view always
+draws one cell and announces that multi-cell and margin ranges are
+honored only by the 3D engines, so its schematic stays uncluttered and
+the single parallelogram never disagrees with the atoms it frames.
+
+Like the other engines it consumes the same renderer-neutral scene
+(section 1): it projects the scene's Cartesian atom centres and
+unit-cell edges onto a plane and draws a schematic 2D view. The longest
+in-plane cell axis runs horizontally, the shortest vertically, and the
+remaining (middle-length) axis is the viewing direction.
+
+The cell is drawn as a schematic parallelogram. Its two side edges are
+rasterized with the asciichartpy glyph set (`│ ╭ ╮ ╯ ╰ ─`), and the
+staircase slope encodes the in-plane angle: near 90° gives long `│` runs
+with few corners (a rectangle at exactly 90°), while a larger deviation
+from 90° introduces more `╭╯` steps (mirrored to `╰╮` for the opposite
+lean). The view is schematic — lengths and angles are approximate, just
+enough to convey the cell — so non-orthogonal cells render the same way
+as orthogonal ones, with the slant shown rather than dropped.
+
+Atoms are drawn as coloured Unicode circles: colour by element from the
+selected colour scheme (the scene colour from section 6, mapped to the
+nearest terminal colour) and size by a small radius-bucketed glyph ramp
+(for example `· • ● ⬤`). Each axis arrow points to its letter: the
+vertical axis is the letter stacked over an up-arrow above the cell (`c`
+then `↑`), and the horizontal axis is a right-arrow pointing to the
+letter at the end of the bottom-border line, after a short gap (`→ a`).
+Each axis arrow and its letter are tinted with that axis's colour — the
+same a/b/c colours the scene gives the 3D engines, mapped to the nearest
+terminal colour and reset afterwards, just as the existing ASCII chart
+legend colours its entries. A legend maps each glyph to its element
+name, and both the legend glyph and its element label are tinted with
+that element's colour-scheme colour (mapped to the nearest terminal
+colour and reset afterwards), so the atoms in the cell, the legend, and
+the axis letters all share the one selected colour scheme. The mocks
+below are monochrome; a real terminal shows these colours.
+
+An orthorhombic cell viewed down b, with vertical side edges:
+
+```
+ c
+ ↑
+ ╭─────────────────────────╮
+ │ ● ● │
+ │ ⬤ │
+ │ • ● │
+ ╰─────────────────────────╯ → a
+
+ Legend: ● La ● Ba ⬤ Co • O
+```
+
+A monoclinic cell viewed down b, with slanted side edges:
+
+```
+ c
+ ↑
+ ╭─────────────────────────╮
+ ╭╯ ● ● ╭╯
+ ╭╯ ⬤ ╭╯
+ ╭╯ • ● ╭╯
+ ╰─────────────────────────╯ → a
+
+ Legend: ● La ● Ba ⬤ Co • O
+```
+
+A small gap-free line helper provides the edge rasterization: it
+generalizes the asciichartpy connector (fill vertical runs with `│`, cap
+bends with corner glyphs) so it can be walked row-major for the
+near-vertical edges that the column-major chart code cannot express.
+
+### 8. Configuring what is shown and how
+
+The view has three configuration axes — _which engine_ draws it, _what_
+is shown, and _how_ it is styled. They are three flat project
+categories, all persisted to CIF:
+
+- `project.rendering_structure` selects the renderer engine only.
+- `project.structure_view` stores durable content and region settings.
+- `project.structure_style` stores durable appearance settings.
+
+```python
+# How: renderer engine
+project.rendering_structure.type = 'auto' # default: 'threejs' in Jupyter, 'ascii' in a terminal
+project.rendering_structure.show_supported()
+
+# How: standard styling models, not per-element values (visual only)
+project.structure_style.atom_view = 'covalent' # vdw | covalent | ionic | adp
+project.structure_style.color_scheme = 'jmol' # jmol | vesta
+project.structure_style.adp_probability = 0.5 # ADP probability level (0, 1)
+project.structure_style.atom_scale = 0.3 # overall atom scale (0, 1]
+project.structure_style.atom_view.show_supported()
+project.structure_style.color_scheme.show_supported()
+
+# Which bonds exist: a per-structure geometric property, not styling.
+# Standard cif_core _geom auto-bonding (r_bond defaults to covalent radius):
+# bond iff min_cutoff <= d <= r_bond(i) + r_bond(j) + incr.
+structure = project.structures['lbco']
+structure.geom.min_bond_distance_cutoff = 0.0 # default 0.0 Å
+structure.geom.bond_distance_incr = 0.25 # default 0.25 Å (documented, tunable)
+
+# What (per call): content for one view, overriding the initial defaults
+project.display.structure(struct_name='lbco') # 'auto'
+project.display.structure(
+ struct_name='lbco',
+ include=('atoms', 'bonds', 'cell', 'axes'),
+)
+
+# Initial view state (persisted): what is shown when the view opens. The
+# Three.js modebar stays active, so the user can still toggle each
+# feature live afterwards. show_moments stays inert until the structure
+# model carries moment fields (see Deferred Work).
+project.structure_view.show_labels = False
+project.structure_view.show_moments = True
+
+# What region (persisted): six per-axis fractional bounds (defaults 0 and
+# 1 = full cell, borders included), mirroring the six scalar cell
+# parameters.
+project.structure_view.range_a_min = 0
+project.structure_view.range_a_max = 1 # range_b_min/max and range_c_min/max likewise
+```
+
+The persisted equivalent in the project CIF:
+
+```
+# In the project CIF (project-level view + style):
+_rendering_structure.type auto
+
+_structure_view.show_labels false
+_structure_view.show_moments true
+_structure_view.range_a_min 0
+_structure_view.range_a_max 1
+_structure_view.range_b_min 0
+_structure_view.range_b_max 1
+_structure_view.range_c_min 0
+_structure_view.range_c_max 1
+
+_structure_style.atom_view covalent
+_structure_style.color_scheme jmol
+_structure_style.adp_probability 0.5
+_structure_style.atom_scale 0.3
+
+# In the structure (sample) CIF, beside _cell / _atom_site (per-structure):
+_geom.min_bond_distance_cutoff 0.0
+_geom.bond_distance_incr 0.25
+```
+
+The `_rendering_structure.type` tag follows `_rendering_plot.type` /
+`_rendering_table.type` from the Display UX Facade ADR, including their
+`auto` environment-default convention (resolved to `threejs` in Jupyter,
+`ascii` in a terminal); `_geom.min_bond_distance_cutoff` and
+`_geom.bond_distance_incr` are the **standard cif_core** bond-cutoff
+tags (`_atom_type.radius_bond` is the standard per-type bonding radius,
+used when present). The `_structure_view.*`, `_structure_style.*`, and
+`_rendering_structure.type` tags are project-internal app settings.
+
+Initial visibility resolves in a fixed order, so a reopened project and
+a per-call request behave predictably:
+
+1. **An explicit `include=(...)` tuple wins outright.** The view opens
+ showing exactly those features; persisted `_structure_view.show_*`
+ flags are ignored for that call. So `include=('atoms',)` shows only
+ atoms even when `show_labels=True` is persisted.
+2. **`include='auto'`** — the default, and what a bare `structure()`
+ call uses — resolves each feature in turn from: data availability
+ first (a feature with no data is off, such as moments without moment
+ fields), then the persisted `_structure_view.show_*` flag where one
+ exists, then the built-in default otherwise. Version 1 persists flags
+ only for the two features whose default a scientist most often flips
+ — `show_labels` (off) and `show_moments` (on where data exists);
+ atoms, bonds, cell, and axes follow their built-in 'auto' defaults
+ and are set per call through an explicit `include=` tuple. So
+ `show_labels=True` with `include='auto'` opens with labels on.
+3. **Unsupported options are skipped and announced, never errored.**
+ Whether it arrived through an explicit tuple or 'auto', a feature the
+ engine cannot draw (any 3D-only feature under `ascii`) or the data
+ does not support (moments without fields) is reported by
+ `show_structure_options()` and at draw time.
+4. **Live modebar changes apply on top of that initial state and are
+ runtime-only.** Toggling a feature in the Three.js modebar never
+ rewrites the persisted `_structure_view.show_*` flags or the
+ `include=` set, so reopening the project restores the resolved
+ initial state rather than the last live toggle.
+
+## Consequences
+
+- `project.display` gains a spatial view (`structure()`) that
+ complements the 1D `pattern()` view and reuses the `include=`
+ vocabulary.
+- `project.display` also gains `show_structure_options()`, parallel to
+ `show_pattern_options()`, so the supported content for a given
+ structure and engine is discoverable with reasons.
+- Keeping crystallography in the scene builder and out of renderers lets
+ several front-ends (Three.js now, Qt Quick 3D later) share one model.
+- A switchable `rendering_structure` category
+ (`project.rendering_structure.type`, CIF `_rendering_structure.type`)
+ selects only the engine, per the switchable-category and
+ category-owner ADRs. Plain `structure_view` and `structure_style`
+ sibling categories hold content/region and appearance settings.
+- The `ascii` and `threejs` engines ship together, mirroring the chart
+ engines: `ascii` needs no JavaScript and renders a schematic view in
+ the terminal, CLI, and headless contexts, while `threejs` covers
+ notebooks and HTML.
+- Content selection (`include=`) and a small set of visibility flags
+ become persisted _initial-view_ settings, so a project reopens looking
+ the same; the interactive engines still let the user toggle features
+ live.
+- The scene's spatial extent is configurable: a per-axis fractional
+ range (default `[0, 1]`, borders included) decides which
+ symmetry-equivalent atoms are generated, so a single cell, an added
+ margin, or several cells need no new primitives. The 3D engines draw
+ the expanded set; the `ascii` engine draws the single default cell and
+ reports wider ranges as a 3D-only capability.
+- The styling category lets scientists choose a standard atom view
+ (`vdw`, `covalent`, `ionic`, or `adp`), a colour scheme, an ADP
+ probability level, and an overall atom scale — not hand-edit
+ per-element rows — all CIF-persisted, with defaults that work
+ unconfigured. The radii and colours come from a bundled element
+ database (covalent, vdW, ionic, and atomic radii; Jmol/CPK and VESTA
+ palettes) shipped as a package asset.
+- Bond generation is a per-structure geometric property, not styling: it
+ uses the standard cif_core `_geom` auto-bonding cutoffs
+ (`_geom.min_bond_distance_cutoff`, `_geom.bond_distance_incr`) plus a
+ per-type bonding radius (`_atom_type.radius_bond`, defaulting to the
+ covalent radius), all on the structure and persisted in the structure
+ CIF — not in `project.structure_style`, and independent of the display
+ `atom_view`. Version 1 draws bonds on the fly and persists no bond
+ table; the full computed `_geom_bond` / `_geom_angle` tables are
+ deferred to a separate feature.
+- The structure view auto-detects the host's dark/light theme (reusing
+ the project's existing `is_dark()` detection) and adapts its
+ background and annotation colours, mirroring how the Plotly chart
+ engine switches templates; element colours still come from the
+ selected colour scheme.
+- The scene builder must expose occupancy splitting, anisotropic ADP,
+ and magnetic moments. Where the current structure model lacks a field
+ (magnetic moments are not in `atom_sites`/`atom_site_aniso` today),
+ that feature stays gated until the model provides the data.
+- A pinned Three.js version becomes a bundled package asset to keep up
+ to date, and the HTML report embeds it under `html_offline`.
+- Tutorials and public API docs gain a structure-view example.
+
+## Alternatives Considered
+
+- **Reuse the 1D chart engines (Plotly) for 3D.** Rejected: Plotly's 3D
+ primitives do not express ADP ellipsoids, occupancy wedges, or
+ crystallographic camera/axis controls cleanly.
+- **Put rendering directly in easydiffraction with no scene
+ abstraction.** Rejected: it couples crystallography to one rendering
+ library and blocks the planned GUI renderer.
+- **Start as a standalone `crysview` package and adopt
+ `easycrystallography` now.** Rejected for the first step as a
+ premature dependency and repo split before the design is proven;
+ retained as the strategic direction.
+- **Server-rendered static images instead of an interactive scene.**
+ Rejected: it loses the interactivity (rotate, toggle, view-along)
+ scientists expect when inspecting a structure.
+
+## Open Questions
+
+All items below are now **resolved** so the implementation plan can be
+executed autonomously; the plan records the verified data sources and
+the final names.
+
+- **CIF tag spelling — resolved (see the §8 _Updated_ note for the final
+ split).** Project CIF: `_structure_style.atom_view` /
+ `_structure_style.color_scheme` / `_structure_style.adp_probability` /
+ `_structure_style.atom_scale`; `_structure_view.show_labels` /
+ `_structure_view.show_moments` /
+ `_structure_view.range_{a,b,c}_{min,max}`; and
+ `_rendering_structure.type` (engine only). These are project-internal
+ app/settings tags (`_rendering_structure.type` follows the Display-UX
+ `_rendering_plot.type` / `_rendering_table.type` precedent); the radii
+ and colours are a bundled element-database asset, not CIF-serialized.
+- **Per-structure bond-cutoff category — resolved (standard
+ `_geom.*`).** A single-record `structure.geom` category holding the
+ cif_core cutoffs `_geom.min_bond_distance_cutoff` (default `0.0` Å)
+ and `_geom.bond_distance_incr` (default `0.25` Å, documented and
+ tunable), in the structure datablock. A bond is drawn when
+ `min_bond_distance_cutoff ≤ d ≤ r_bond(i) + r_bond(j) + bond_distance_incr`,
+ with `r_bond` = `_atom_type.radius_bond` when present, else the
+ covalent radius. These are the **standard** cif_core tags (review-4
+ finding 1): `_geom.min_bond_distance_cutoff` (dic 13084),
+ `_geom.bond_distance_incr` (dic 13044), `_atom_type.radius_bond` (dic
+ 25419); the earlier project-internal `_bonds.*` proposal was dropped.
+ The computed `_geom_bond.*` / `_geom_angle.*` loops remain reserved
+ for the deferred geometry tables.
+- **ASCII rendering details — resolved.** A 4-bucket radius glyph ramp
+ (`· • ● ⬤`) and the 8/16-colour ANSI mapping the existing ascii chart
+ legend already uses.
+- **Per-axis range boundary completion — resolved.** Version 1 draws
+ only atoms inside the range (borders included) and bonds only between
+ in-scene atoms — no out-of-range partner atoms or edge-coordination
+ completion. The range is persisted as six scalar tags
+ `_structure_view.range_{a,b,c}_{min,max}` (one number each, defaults 0
+ and 1), mirroring the six scalar cell parameters; a per-call `range=`
+ tuple on `structure()` overrides them for one call.
+
+## Deferred Work
+
+- The computed bond and angle geometry tables — the standard
+ `_geom_bond` and `_geom_angle` loops (atom-pair/triplet labels,
+ distances, angles, site-symmetry codes, standard uncertainties,
+ `publ_flag`) — as a separate, related feature. It reuses crysview's
+ symmetry-expansion and distance math and the same per-structure
+ `_geom` cutoffs (extended with the angle/contact increments cif_core
+ already defines). Version 1 draws bonds on the fly from the `_geom`
+ bond cutoffs and persists no geometry table.
+- The Qt Quick 3D renderer for the GUI.
+- Magnetic-moment fields on the structure model (a separate
+ magnetic-structure effort); the scene's moment-arrow primitive stays
+ gated until they exist.
+- Extraction of a standalone `crysview` package and the
+ `easycrystallography` layering.
+- Advanced depictions beyond atoms, bonds, and ADP surfaces, such as
+ coordination polyhedra. Symmetry expansion and multiple-cell views are
+ in scope through the per-axis range (section 3).
diff --git a/docs/dev/adrs/accepted/crysview-threejs-demo.html b/docs/dev/adrs/accepted/crysview-threejs-demo.html
new file mode 100644
index 000000000..82148ff35
--- /dev/null
+++ b/docs/dev/adrs/accepted/crysview-threejs-demo.html
@@ -0,0 +1,855 @@
+
+
+
+
+
+ crysview — Three.js structure prototype
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ a
+
+
+ b
+
+
+ c
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ A/B (50/50)
+
+
C
+
D
+
+
+
+
drag = rotate
+
wheel = zoom
+
right-drag = pan
+
+
+
+
+
diff --git a/docs/dev/adrs/accepted/display-ux.md b/docs/dev/adrs/accepted/display-ux.md
index 379bdb8c8..40ee2c0d1 100644
--- a/docs/dev/adrs/accepted/display-ux.md
+++ b/docs/dev/adrs/accepted/display-ux.md
@@ -43,21 +43,22 @@ defaults.
Use `project.display` as the user-facing facade for display actions.
Move serialized renderer settings out of that facade and into separate
-project categories named `project.chart` and `project.table`.
+project categories named `project.rendering_plot` and
+`project.rendering_table`.
Renderer settings:
```python
-project.chart.type = 'plotly'
-project.table.type = 'pandas'
-project.chart.show_supported()
-project.table.show_supported()
+project.rendering_plot.type = 'plotly'
+project.rendering_table.type = 'pandas'
+project.rendering_plot.show_supported()
+project.rendering_table.show_supported()
```
CIF names:
-- `_chart.type`
-- `_table.type`
+- `_rendering_plot.type`
+- `_rendering_table.type`
No legacy loader is required for `_display.plotter_type` or
`_display.tabler_type`. The project is in beta, so this cleanup may
diff --git a/docs/dev/adrs/accepted/iucr-cif-tag-alignment.md b/docs/dev/adrs/accepted/iucr-cif-tag-alignment.md
index 3072bc81c..d36193346 100644
--- a/docs/dev/adrs/accepted/iucr-cif-tag-alignment.md
+++ b/docs/dev/adrs/accepted/iucr-cif-tag-alignment.md
@@ -5,12 +5,12 @@
Reframes the earlier "IUCr CIF Tag Alignment for Fit Outputs" suggestion
(2026-05-24, PR #181) into a tiered policy. The default saved CIFs stay
-optimised for day-to-day UX; a separate IUCr export path produces
-journal-submission CIFs on demand. Amends parts of
+optimised for day-to-day UX; a separate IUCr export path produces clean
+report CIFs on demand. Amends parts of
[`analysis-cif-fit-state.md`](analysis-cif-fit-state.md) and
[`minimizer-input-output-split.md`](minimizer-input-output-split.md);
runs alongside the
-[`python-cif-category-correspondence.md`](../suggestions/python-cif-category-correspondence.md)
+[`python-cif-category-correspondence.md`](python-cif-category-correspondence.md)
suggestion (Python-side correspondence).
Grounded in:
@@ -40,11 +40,11 @@ commonly produced by tooling that has not yet caught up with the current
DDLm spec. The dictionaries are the source of truth.
The submission-specific publication dictionary (`cif_publ.dic`) is not
-consulted directly — the publication-block items are all present in
-`cif_core.dic` under `_journal.*`, `_journal_coeditor.*`,
-`_journal_date.*`, `_publ_author.*`, `_publ_contact_author.*`,
-`_publ_body.*`, `_publ_manuscript.*`, `_audit.*`, and
-`_chemical_formula.*`.
+consulted directly. The v1 report CIF deliberately avoids empty journal
+and author template fields; the retained global metadata comes from
+`cif_core.dic` items such as `_audit.*`, `_computing.*`, and
+`_chemical_formula.*`. Deferred journal/publication tags are listed in
+[`project-summary-rendering.md`](project-summary-rendering.md) §5.1.
## Context
@@ -59,11 +59,12 @@ and item names:
without a custom mapping layer.
- **Day-to-day UX.** Users switch between Python and direct CIF editing
in a CLI. Some IUCr-canonical structures are awkward for hand editing
- — submission templates require multi-datablock layouts with
- `data_global` publication metadata, embedded `_publ_*` placeholder
- fields, and TOF calibration as a coefficient loop indexed by integer
- `power`. Parametric profile shape (Caglioti, FCJ, TOF sigma/gamma) has
- no IUCr counterpart at all.
+ — submission templates often include multi-datablock layouts with
+ `data_global` metadata and TOF calibration as a coefficient loop
+ indexed by integer `power`. The v1 report keeps the structural pieces
+ and omits empty `_publ_*` / `_journal_*` placeholders. Parametric
+ profile shape (Caglioti, FCJ, TOF sigma/gamma) has no IUCr counterpart
+ at all.
A blanket "align with IUCr everywhere" policy pays a UX cost the project
does not need to absorb for files that are not submission targets. A
@@ -74,7 +75,7 @@ Two earlier ADRs already touch this surface:
- [`loop-category-key-identity.md`](loop-category-key-identity.md) pins
loop-key naming on COMCIFS conventions.
-- [`python-cif-category-correspondence.md`](../suggestions/python-cif-category-correspondence.md)
+- [`python-cif-category-correspondence.md`](python-cif-category-correspondence.md)
catalogues Python-vs-CIF category mismatches and chooses which side
should bend.
@@ -86,8 +87,8 @@ In scope:
- A tiered category-and-item-name policy for the default save, split by
domain (structure / analysis / experiment).
-- A new IUCr export path that produces a single journal-submission CIF
- on demand, separate from the default save.
+- A new IUCr export path that produces a single clean report CIF on
+ demand, separate from the default save.
- ADP write-side single-tag emission and casing alignment in the
structure tier.
- Loop-tag style policy: dotted DDLm form universally on write, both
@@ -96,21 +97,21 @@ In scope:
on `CifHandler`, category-level `IucrCategoryTransformer` for
structural reshapings).
- Multi-datablock layout in the IUCr export, including the `data_global`
- publication-metadata block.
+ audit, software, and chemistry metadata block.
Out of scope:
- Python attribute renames. This ADR changes CIF emission only.
Cross-reference
- [`python-cif-category-correspondence.md`](../suggestions/python-cif-category-correspondence.md)
+ [`python-cif-category-correspondence.md`](python-cif-category-correspondence.md)
for Python-side decisions.
- Adding new CIF categories the project does not currently track
(`_chemical.*`, `_publ.*`, `_journal.*`) **for the default save**. The
- IUCr export emits the publication-metadata categories per §2.3a with
- `?` placeholders where the project has no source data.
+ IUCr export derives `_chemical_formula.*` for the report only and does
+ not emit `_publ_*` / `_journal_*` placeholders in v1.
- imgCIF (`cif_img.dic`); no raw image persistence path exists.
-- Project-level singleton categories `_info.*`, `_chart.*`, `_table.*`,
- `_verbosity.*` — out of scope here; see
+- Project-level singleton categories `_info.*`, `_rendering_plot.*`,
+ `_rendering_table.*`, `_verbosity.*` — out of scope here; see
`python-cif-category-correspondence`.
## Design Philosophy: Tiered Default Save + Separate IUCr Export
@@ -159,44 +160,44 @@ Project CIF categories audited against `cif_core.dic` v3.4.0 and
category changes in the default save; the "IUCr export" column shows the
dotted DDLm tag emitted by the IUCr CIF report writer.
-| Category (current) | IUCr dictionary | Default-save tier | IUCr export (dotted DDLm) |
-| ----------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
-| `_cell.*` | core | Structure — unchanged | `_cell.length_a`, `_cell.angle_alpha`, etc. |
-| `_atom_site.*` (most fields) | core | Structure — unchanged | `_atom_site.label`, `_atom_site.fract_x`, … |
-| `_atom_site.adp_type` | core (`_atom_site.ADP_type`) | Structure — casing fix | `_atom_site.ADP_type` (uppercase ADP per dictionary). |
-| `_atom_site.wyckoff_letter` | core (`_atom_site.Wyckoff_symbol`) | Structure — rename | `_atom_site.Wyckoff_symbol` (uppercase W, "symbol" not "letter"). |
-| `_atom_site.B_iso_or_equiv` / `U_iso_or_equiv` | core | Structure — single-tag emit | `_atom_site.B_iso_or_equiv` xor `_atom_site.U_iso_or_equiv` per row, based on `_atom_site.ADP_type`. |
-| `_atom_site_aniso.B_*` / `U_*` | core | Structure — single-tag emit | `_atom_site_aniso.B_*` xor `_atom_site_aniso.U_*` per row. |
-| `_space_group.name_h_m` | core (`_space_group.name_H-M_alt`) | Structure — casing fix | `_space_group.name_H-M_alt`. |
-| `_space_group.it_coordinate_system_code` | core (`_space_group.IT_coordinate_system_code`) | Structure — casing fix | `_space_group.IT_coordinate_system_code`. |
-| symmetry operations | core (`_space_group_symop.*`) | (not emitted today) | `_space_group_symop.id` + `_space_group_symop.operation_xyz` loop alongside the H-M name. |
-| `_diffrn.ambient_temperature`, `ambient_pressure` | core | Experiment — unchanged | `_diffrn.ambient_temperature`, `_diffrn.ambient_pressure`. |
-| `_diffrn.ambient_magnetic_field`, `ambient_electric_field` | none | Experiment — unchanged | `_easydiffraction_diffrn.ambient_magnetic_field`, `…electric_field` (project extension). |
-| `_refln.*` | core | (no default save under refln) | `_refln.*` reflections loop (column set differs by domain — see §2.3). |
-| `_pd_meas.*`, `_pd_proc.*`, `_pd_calc.*`, `_pd_data.*` | pdCIF | Experiment — unchanged | `_pd_meas.*`, `_pd_proc.*`, `_pd_calc.*` profile-data loop (see §2.3). |
-| `_pd_background.*` | pdCIF | Experiment — unchanged | `_pd_background.*`. |
-| `_pd_phase_block.*` | pdCIF | Experiment — unchanged | `_pd_phase_block.*`. |
-| `_sc_crystal_block.*` | community (no IUCr counterpart) | Experiment — unchanged | `_easydiffraction_sc_crystal_block.*` in IUCr export. |
-| `_instr.wavelength` | core (`_diffrn_radiation_wavelength.value`) | Experiment — unchanged | `_diffrn_radiation_wavelength.{id, value, wt}` — single-row category for monochromatic; loop only for multi-λ. |
-| `_instr.2theta_offset` | pdCIF (`_pd_calib.2theta_offset`) | Experiment — unchanged | `_pd_calib.2theta_offset`. |
-| `_instr.2theta_bank`, `d_to_tof_*` | pdCIF (`_pd_calib_d_to_tof.*` loop) | Experiment — unchanged | Four-row loop `_pd_calib_d_to_tof.{id, coeff, power, coeff_su, diffractogram_id}`. |
-| `_peak.*` (parametric profile shape) | none (pdCIF has no shape parameters) | Experiment — unchanged | `_easydiffraction_peak.*` + `_pd_proc_ls.profile_function` free-text descriptor. |
-| `_extinction.*` | core (`_refine_ls.extinction_*` items) | Experiment — unchanged | `_easydiffraction_extinction.*` + dual emit `_refine_ls.extinction_{method,coef,expression}`. |
-| `_excluded_region.*` | pdCIF (`_pd_proc.info_excluded_regions` free-text) | Experiment — unchanged | `_easydiffraction_excluded_region.*` + `_pd_proc.info_excluded_regions` free-text rendering. |
-| `_expt_type.*` | none | Experiment — unchanged | `_easydiffraction_experiment_type.*`. |
-| `_calculator.type`, `_minimizer.type` | none | Analysis — unchanged | Selection fields remain settings only; identity is read from `analysis.software` for `_easydiffraction_software.{framework, calculator, minimizer}` and `_computing.structure_refinement`. |
-| `_software.*` | none | Analysis — new provenance category | Source for `_easydiffraction_software.{framework, calculator, minimizer}`, `_easydiffraction_software.fit_datetime`, and `_computing.structure_refinement` in `data_global`. |
-| `_minimizer.*` settings (tolerances, max_iter, …) | none | Analysis — unchanged | `_easydiffraction_minimizer.*` (settings only, separate from the identification triple). |
-| `_fitting_mode.type`, `_background.type` | none | Analysis / Experiment — unchanged | `_easydiffraction_fitting_mode.type`, `_easydiffraction_background.type` selectors. |
-| `_fit_result.reduced_chi_square`, `n_data_points`, `n_parameters` | core (`_refine_ls.*`) and pdCIF (`_pd_proc_ls.*`) | Analysis — unchanged (topology-neutral) | Shape-shifting per topology: see §1.2 and §3 transformers. |
-| `_fit_result.*` (R-factors, counts, profile/background function) | core / pdCIF | Analysis — new fields under `_fit_result.*` | IUCr export remaps to per-topology `_refine_ls.*` / `_pd_proc_ls.*`; item names already match dictionary casing (§1.2). |
-| `_fit_result.*` (Bayesian diagnostics, success, message, fitting_time, iterations, result_kind) | none | Analysis — unchanged | `_easydiffraction_fit_result.*`. |
-| `_fit_parameter`, `_fit_parameter_correlation` | none / partial | Analysis — unchanged | `_easydiffraction_fit_parameter*` (no IUCr counterpart for per-parameter posterior). |
-| `_alias`, `_constraint` | none | Analysis — unchanged | `_easydiffraction_alias*`, `_easydiffraction_constraint*`. |
-| `_joint_fit`, `_sequential_fit*` | none | Analysis — unchanged | `_easydiffraction_joint_fit*`, `_easydiffraction_sequential_fit*`. |
-| reflection-set aggregates | core (`_reflns.*`) | Analysis — new fields | `_reflns.number_total`, `_reflns.number_gt`, `_reflns.threshold_expression` (e.g. `'I>3\s(I)'`). |
-| publication metadata | core (`_journal.*`, `_publ_author.*`, `_publ_contact_author.*`, `_audit.*`) | (not emitted today) | Emitted in `data_global` block per §2.3a with `?` placeholders. |
-| analysis-stack identification | core (`_computing.structure_refinement`) | Analysis — `_software.*` persisted | `_easydiffraction_software.{framework, calculator, minimizer}` triple + `_easydiffraction_software.fit_datetime` + `_computing.structure_refinement` derived from `analysis.software`. |
+| Category (current) | IUCr dictionary | Default-save tier | IUCr export (dotted DDLm) |
+| ----------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| `_cell.*` | core | Structure — unchanged | `_cell.length_a`, `_cell.angle_alpha`, etc. |
+| `_atom_site.*` (most fields) | core | Structure — unchanged | `_atom_site.label`, `_atom_site.fract_x`, … |
+| `_atom_site.adp_type` | core (`_atom_site.ADP_type`) | Structure — casing fix | `_atom_site.ADP_type` (uppercase ADP per dictionary). |
+| `_atom_site.wyckoff_letter` | core (`_atom_site.Wyckoff_symbol`) | Structure — rename | `_atom_site.Wyckoff_symbol` (uppercase W, "symbol" not "letter"). |
+| `_atom_site.B_iso_or_equiv` / `U_iso_or_equiv` | core | Structure — single-tag emit | `_atom_site.B_iso_or_equiv` xor `_atom_site.U_iso_or_equiv` per row, based on `_atom_site.ADP_type`. |
+| `_atom_site_aniso.B_*` / `U_*` | core | Structure — single-tag emit | `_atom_site_aniso.B_*` xor `_atom_site_aniso.U_*` per row. |
+| `_space_group.name_h_m` | core (`_space_group.name_H-M_alt`) | Structure — casing fix | `_space_group.name_H-M_alt`. |
+| `_space_group.it_coordinate_system_code` | core (`_space_group.IT_coordinate_system_code`) | Structure — casing fix | `_space_group.IT_coordinate_system_code`. |
+| symmetry operations | core (`_space_group_symop.*`) | (not emitted today) | `_space_group_symop.id` + `_space_group_symop.operation_xyz` loop alongside the H-M name. |
+| `_diffrn.ambient_temperature`, `ambient_pressure` | core | Experiment — unchanged | `_diffrn.ambient_temperature`, `_diffrn.ambient_pressure`. |
+| `_diffrn.ambient_magnetic_field`, `ambient_electric_field` | none | Experiment — unchanged | `_easydiffraction_diffrn.ambient_magnetic_field`, `…electric_field` (project extension). |
+| `_refln.*` | core | (no default save under refln) | `_refln.*` reflections loop (column set differs by domain — see §2.3). |
+| `_pd_meas.*`, `_pd_proc.*`, `_pd_calc.*`, `_pd_data.*` | pdCIF | Experiment — unchanged | `_pd_meas.*`, `_pd_proc.*`, `_pd_calc.*` profile-data loop (see §2.3). |
+| `_pd_background.*` | pdCIF | Experiment — unchanged | `_pd_background.*`. |
+| `_pd_phase_block.*` | pdCIF | Experiment — unchanged | `_pd_phase_block.*`. |
+| `_sc_crystal_block.*` | community (no IUCr counterpart) | Experiment — unchanged | `_easydiffraction_sc_crystal_block.*` in IUCr export. |
+| `_instr.wavelength` | core (`_diffrn_radiation_wavelength.value`) | Experiment — unchanged | `_diffrn_radiation_wavelength.{id, value, wt}` — single-row category for monochromatic; loop only for multi-λ. |
+| `_instr.2theta_offset` | pdCIF (`_pd_calib.2theta_offset`) | Experiment — unchanged | `_pd_calib.2theta_offset`. |
+| `_instr.2theta_bank`, `d_to_tof_*` | pdCIF (`_pd_calib_d_to_tof.*` loop) | Experiment — unchanged | Four-row loop `_pd_calib_d_to_tof.{id, coeff, power, coeff_su, diffractogram_id}`. |
+| `_peak.*` (parametric profile shape) | none (pdCIF has no shape parameters) | Experiment — unchanged | `_easydiffraction_peak.*` + `_pd_proc_ls.profile_function` free-text descriptor. |
+| `_extinction.*` | core (`_refine_ls.extinction_*` items) | Experiment — unchanged | `_easydiffraction_extinction.*` + dual emit `_refine_ls.extinction_{method,coef,expression}`. |
+| `_excluded_region.*` | pdCIF (`_pd_proc.info_excluded_regions` free-text) | Experiment — unchanged | `_easydiffraction_excluded_region.*` + `_pd_proc.info_excluded_regions` free-text rendering. |
+| `_expt_type.*` | none | Experiment — unchanged | `_easydiffraction_experiment_type.*`. |
+| `_calculator.type`, `_minimizer.type` | none | Analysis — unchanged | Selection fields remain settings only; identity is read from `analysis.software` for `_easydiffraction_software.{framework, calculator, minimizer}` and `_computing.structure_refinement`. |
+| `_software.*` | none | Analysis — new provenance category | Source for `_easydiffraction_software.{framework, calculator, minimizer}`, `_easydiffraction_software.fit_datetime`, and `_computing.structure_refinement` in `data_global`. |
+| `_minimizer.*` settings (tolerances, max_iter, …) | none | Analysis — unchanged | `_easydiffraction_minimizer.*` (settings only, separate from the identification triple). |
+| `_fitting_mode.type`, `_background.type` | none | Analysis / Experiment — unchanged | `_easydiffraction_fitting_mode.type`, `_easydiffraction_background.type` selectors. |
+| `_fit_result.reduced_chi_square`, `n_data_points`, `n_parameters` | core (`_refine_ls.*`) and pdCIF (`_pd_proc_ls.*`) | Analysis — unchanged (topology-neutral) | Shape-shifting per topology: see §1.2 and §3 transformers. |
+| `_fit_result.*` (R-factors, counts, profile/background function) | core / pdCIF | Analysis — new fields under `_fit_result.*` | IUCr export remaps to per-topology `_refine_ls.*` / `_pd_proc_ls.*`; item names already match dictionary casing (§1.2). |
+| `_fit_result.*` (Bayesian diagnostics, success, message, fitting_time, iterations, result_kind) | none | Analysis — unchanged | `_easydiffraction_fit_result.*`. |
+| `_fit_parameter`, `_fit_parameter_correlation` | none / partial | Analysis — unchanged | `_easydiffraction_fit_parameter*` (no IUCr counterpart for per-parameter posterior). |
+| `_alias`, `_constraint` | none | Analysis — unchanged | `_easydiffraction_alias*`, `_easydiffraction_constraint*`. |
+| `_joint_fit`, `_sequential_fit*` | none | Analysis — unchanged | `_easydiffraction_joint_fit*`, `_easydiffraction_sequential_fit*`. |
+| reflection-set aggregates | core (`_reflns.*`) | Analysis — new fields | `_reflns.number_total`, `_reflns.number_gt`, `_reflns.threshold_expression` (e.g. `'I>3\s(I)'`). |
+| report metadata | core (`_audit.*`, `_computing.*`, `_chemical_formula.*`) and project extension (`_easydiffraction_software.*`) | Analysis / derived report state | Emitted in `data_global` block per §2.3a. Empty `_journal.*`, `_publ_*`, and `_pd_meas.info_author_*` placeholders are excluded by the clean-report policy in `project-summary-rendering.md` §5. |
+| analysis-stack identification | core (`_computing.structure_refinement`) | Analysis — `_software.*` persisted | `_easydiffraction_software.{framework, calculator, minimizer}` triple + `_easydiffraction_software.fit_datetime` + `_computing.structure_refinement` derived from `analysis.software`. |
## Decision
@@ -296,7 +297,7 @@ Specifically:
- Parametric profile shape (`_peak.*` Caglioti / Lorentzian / FCJ / TOF
coefficients) stays under `_peak.*`.
-### 2. Reports — IUCr submission CIF
+### 2. Reports — IUCr-aligned report CIF
#### 2.1 API
@@ -360,7 +361,7 @@ quartz_sc/
# plus Bayesian / non-IUCr fields)
# _easydiffraction_minimizer.* (settings)
reports/
- quartz_sc.cif # data_global — _journal.*, _publ_*, _audit.*,
+ quartz_sc.cif # data_global — _audit.*,
# _easydiffraction_software.{framework,
# calculator, minimizer},
# _computing.structure_refinement,
@@ -386,21 +387,21 @@ mgo_rietveld/
analysis/
analysis.cif
reports/
- mgo_rietveld.cif # data_global — publication metadata, software, _chemical_formula
- # data_mgo_rietveld_overall — _pd_proc_ls.prof_R_factor,
- # .prof_wR_factor,
- # .prof_wR_expected,
- # .profile_function,
- # .background_function,
- # _refine_ls.number_parameters,
- # _pd_block_id cross-refs
- # data_mgo_rietveld_phase_0 — MgO structure
- # data_mgo_rietveld_pwd_0 — _pd_meas.* profile loop
- # (_2theta_scan, intensity_total,
- # _pd_calc.intensity_total,
- # _pd_proc.intensity_bkg_calc,
- # _pd_proc_ls.weight),
- # _refln.* powder reflections loop
+ mgo_rietveld.cif # data_global — audit, software, _chemical_formula
+ # data_overall — _pd_proc_ls.prof_R_factor,
+ # .prof_wR_factor,
+ # .prof_wR_expected,
+ # .profile_function,
+ # .background_function,
+ # _refine_ls.number_parameters,
+ # _pd_block_id cross-refs
+ # data_mgo — MgO structure
+ # data_npd — _pd_meas.* profile loop
+ # (_2theta_scan, intensity_total,
+ # _pd_calc.intensity_total,
+ # _pd_proc.intensity_bkg_calc,
+ # _pd_proc_ls.weight),
+ # _refln.* powder reflections loop
```
**Example C — Joint Rietveld, multi-experiment (neutron + X-ray).**
@@ -416,12 +417,12 @@ co2sio4/
analysis/
analysis.cif # _joint_fit weights
reports/
- co2sio4.cif # data_global — publication metadata
- # data_co2sio4_overall — combined refinement stats
- # data_co2sio4_phase_0 — Co2SiO4 structure
- # data_co2sio4_pwd_0 — NPD pattern,
+ co2sio4.cif # data_global — audit, software, chemistry
+ # data_overall — combined refinement stats
+ # data_co2sio4 — Co2SiO4 structure
+ # data_npd_300K — NPD pattern,
# _pd_block_diffractogram_id='npd_300K'
- # data_co2sio4_pwd_1 — XRD pattern,
+ # data_xrd_300K — XRD pattern,
# _pd_block_diffractogram_id='xrd_300K'
```
@@ -440,28 +441,27 @@ co2sio4_t_series/
analysis/
analysis.cif # _sequential_fit configuration
reports/
- co2sio4_t_series.cif # data_global — publication metadata
- # data_co2sio4_t_series_overall
- # data_co2sio4_t_series_phase_0
- # data_co2sio4_t_series_pwd_0 — TOF 5K,
+ co2sio4_t_series.cif # data_global — audit, software, chemistry
+ # data_overall
+ # data_co2sio4
+ # data_tof_5K — TOF 5K,
# _pd_meas.time_of_flight,
# _pd_calib_d_to_tof loop
- # data_co2sio4_t_series_pwd_1 — TOF 100K
- # data_co2sio4_t_series_pwd_2 — TOF 165K
- # data_co2sio4_t_series_pwd_3 — TOF 200K
+ # data_tof_100K — TOF 100K
+ # data_tof_165K — TOF 165K
+ # data_tof_200K — TOF 200K
```
#### 2.3 Multi-datablock layout inside the export file
-**Every export file starts with a `data_global` block carrying
-publication metadata** (§2.3a). Subsequent blocks depend on analysis
-topology. Block content uses dotted DDLm form throughout. The
+**Every export file starts with a `data_global` block carrying audit,
+software, and chemistry metadata** (§2.3a). Subsequent blocks depend on
+analysis topology. Block content uses dotted DDLm form throughout. The
single-block-name rule is uniform across topologies; topology-specific
GSAS-II-style suffix conventions seen in some example files (e.g.
`data__publ`, `data__overall`) are folded into
-`data_global` for the publication header and `data__overall`
-for refinement metadata, leaving no ambiguity about where the
-journal-required publication items live.
+`data_global` for global metadata and `data_overall` for refinement
+metadata, leaving no ambiguity about block roles.
- **Single-crystal, single structure (single experiment).**
`data_global` + `data_` (or `data_I` if no name is set).
@@ -473,33 +473,46 @@ journal-required publication items live.
`bp5014.cif`: `data_global + data_300K + data_55K + data_2point5K`.
- **Powder Rietveld (single or multi-experiment, single or
- multi-phase).** GSAS-II-style block split, with the publication block
- renamed to `data_global` per the invariant above:
- - `data_global` (publication metadata, per §2.3a),
- - `data__overall` (refinement-level metadata — Rietveld
- R-factors, profile/background function descriptors, parameter
- counts),
- - `data__phase_N` (one per phase — structural data per
+ multi-phase).** GSAS-II-style block split, with the global metadata
+ block named `data_global` per the invariant above:
+ - `data_global` (audit, software, and chemistry metadata per §2.3a),
+ - `data_overall` (refinement-level metadata — Rietveld R-factors,
+ profile/background function descriptors, parameter counts),
+ - `data_` (one per phase — structural data per
`_pd_phase_block.id`),
- - `data__pwd_N` (one per diffraction pattern — measurement
+ - `data_` (one per diffraction pattern — measurement
metadata, profile data loop, reflections loop).
This deviates from the `data__publ` GSAS-II convention seen
in `hb8206.cif`; the deviation buys a uniform rule across
single-crystal and powder exports and matches the single-crystal
corpus (`bal5004`, etc.) which uses `data_global` universally.
+ `data_overall` is deliberately unprefixed because the generated report
+ CIF is already scoped to one project. `global` means file/report
+ metadata; `overall` means combined refinement summary. Phase and
+ diffractogram blocks use the existing structure and experiment names
+ after CIF block-code normalization; if two normalized names collide,
+ append a short numeric suffix to preserve uniqueness.
- **Multi-experiment joint Rietveld.** Same shape as the
- single-experiment Rietveld block split above, with additional `_pwd_N`
- blocks per pattern, all cross-referenced via
- `_pd_block_diffractogram_id` and `_pd_block_id` pipe-delimited
- identifiers (e.g. `2025-12-06T14:46|binimetinib_3|noname|PubInfo`,
- format mirrored from `hb8206.cif`).
+ single-experiment Rietveld block split above, with one
+ `data_` block per pattern, all cross-referenced via
+ `_pd_block_diffractogram_id` and `_pd_block_id`.
+
+ `_pd_block_id` identifies phase/model blocks; in this report that is
+ the `data_` block ID. `_pd_block_diffractogram_id`
+ identifies diffractogram/pattern blocks; in this report that is the
+ `data_` block ID. For single references, emit the
+ scalar block ID directly (for example `_pd_block_id lbco`, not
+ `_pd_block_id |lbco|`). User preference recorded for future
+ multi-block expansion: "If multiple phases/patterns are needed, I'd
+ rather use a proper loop or move toward the newer pdCIF replacement
+ fields, not encode lists in one scalar with pipes."
- **Sequential fit.** One file per step is **not** the IUCr convention;
- sequential refinements emit one `data__pwd_N` block per step
- inside the same `reports/.cif`. Natural sequential ordering
- matches the multi-pattern Rietveld pattern above.
+ sequential refinements emit one `data_` block per
+ step inside the same `reports/.cif`. Natural sequential
+ ordering matches the multi-pattern Rietveld pattern above.
#### 2.3a `data_global` block content
@@ -517,37 +530,26 @@ the project has source data, otherwise `?`.
- `_easydiffraction_software.*` triple holding the same three roles in
structured form, plus `_easydiffraction_software.fit_datetime` when a
fit timestamp is available (see §2.3a-i below).
-- `_journal.*` placeholders, written as `?` when the project has no
- source data: `_journal.name_full`, `_journal.year`, `_journal.volume`,
- `_journal.issue`, `_journal.page_first`, `_journal.page_last`,
- `_journal.paper_category`, `_journal.paper_DOI`,
- `_journal.coden_ASTM`, `_journal.suppl_publ_number`.
-- `_journal_date.*` placeholders: `_journal_date.accepted`,
- `_journal_date.from_coeditor`, `_journal_date.printers_final`, etc.
-- `_journal_coeditor.*` placeholders: `_journal_coeditor.code`,
- `_journal_coeditor.name`, `_journal_coeditor.notes`.
-- `_publ_contact_author.*` placeholders: `_publ_contact_author.name`,
- `_publ_contact_author.address`, `_publ_contact_author.email`,
- `_publ_contact_author.phone`, `_publ_contact_author.id_ORCID`,
- `_publ_contact_author.id_IUCr`.
-- `_publ_author.*` loop placeholders (`_publ_author.name`,
- `_publ_author.address`, `_publ_author.footnote`,
- `_publ_author.id_ORCID`, `_publ_author.id_IUCr`).
-- `_publ_body.*` for section content (`_publ_body.title`,
- `_publ_body.contents`).
+- No `_journal.*`, `_journal_date.*`, `_journal_coeditor.*`,
+ `_publ_contact_author.*`, `_publ_author.*`, `_publ_body.*`, or
+ `_pd_meas.info_author_*` placeholders are emitted in v1. The clean
+ report policy and deferred tag list live in
+ [`project-summary-rendering.md`](project-summary-rendering.md) §5.
- `_chemical_formula.*` chemistry summary derived from atom-site data
where possible: `_chemical_formula.sum`, `_chemical_formula.moiety`,
`_chemical_formula.weight`, `_chemical_formula.IUPAC` (uppercase IUPAC
per dictionary).
-User-supplied publication metadata override (`publ_info.json` or
-similar) is deferred — see Deferred Work.
+User-supplied publication metadata (`publ_info.json`, `publ_info.toml`,
+or a Python `project.publication` owner) is deferred — see Deferred Work
+and the clean report policy in `project-summary-rendering.md` §5.
#### 2.3a-i `_easydiffraction_software` framework
-The IUCr submission needs to identify the analysis stack. The project
-emits one structured category in `data_global` from `analysis.software`,
-carrying three role-keyed strings and an optional fit timestamp:
+The IUCr-aligned report needs to identify the analysis stack. The
+project emits one structured category in `data_global` from
+`analysis.software`, carrying three role-keyed strings and an optional
+fit timestamp:
```
_easydiffraction_software.framework 'EasyDiffraction 0.17.0'
@@ -581,8 +583,8 @@ identification triple above.
#### 2.3b Structure-block content (per-block)
-For each `data_` (single-crystal) or `data__phase_N`
-(powder Rietveld) block:
+For each `data_` (single-crystal) or `data_`
+(powder Rietveld phase) block:
- `_chemical_formula.{moiety, sum, weight, IUPAC}` summary.
- `_cell.*` (`length_a`, `angle_alpha`, `volume`,
@@ -664,7 +666,7 @@ For TOF experiments, the `_pd_meas.2theta_scan` column is replaced by
`_pd_meas.time_of_flight`. Verified against `bal5001.cif` (content set;
tag form follows `cif_pow.dic`).
-#### 2.3f `data__overall` block (Rietveld only)
+#### 2.3f `data_overall` block (Rietveld only)
For powder Rietveld files, an `_overall` block carries refinement-level
metadata that applies across all phases and patterns:
@@ -678,17 +680,21 @@ metadata that applies across all phases and patterns:
is applied).
- `_refine_ls.number_parameters`, `_refine_ls.number_restraints`,
`_refine_ls.number_constraints`.
-- `_pd_block_id` pipe-delimited cross-reference values pointing to the
- phase and pattern blocks.
+- `_pd_block_id` references to phase/model blocks and
+ `_pd_block_diffractogram_id` references to diffractogram/pattern
+ blocks. Single references are plain scalar IDs. Multiple
+ phases/patterns should use a proper loop or newer pdCIF replacement
+ fields, not pipe-delimited scalar lists.
-#### 2.3g `data__pwd_N` block (Rietveld only — constant wavelength)
+#### 2.3g `data_` block (Rietveld only — constant wavelength)
For each constant-wavelength (CWL) diffraction pattern:
- `_pd_meas.*` measurement metadata (`_pd_meas.scan_method`,
`_pd_meas.2theta_range_min/max/inc`, `_pd_meas.number_of_points`,
- `_pd_meas.datetime_initiated`,
- `_pd_meas.info_author_{name, email, phone}` placeholders).
+ `_pd_meas.datetime_initiated`). The
+ `_pd_meas.info_author_{name, email, phone}` placeholders are omitted
+ by the clean report policy.
- `_diffrn.*` and `_diffrn_radiation_wavelength.*` (radiation type,
probe, wavelength).
- `_pd_proc.2theta_range_min/max/inc`, `_pd_proc.info_data_reduction`,
@@ -697,7 +703,7 @@ For each constant-wavelength (CWL) diffraction pattern:
- The `_pd_meas.*` profile-data loop (§2.3e).
- The `_refln.*` reflections loop (§2.3d).
-#### 2.3h `data__pwd_N` block (Rietveld only — TOF)
+#### 2.3h `data_` block (Rietveld only — TOF)
For time-of-flight (TOF) diffraction patterns the block has the same
shape as §2.3g, with three TOF-specific substitutions — **all defined in
@@ -819,8 +825,9 @@ raising `EasyDiffractionWriterError`), value-type matching against
loop columns, and well-formed DDLm dotted form. It never covered
crystallographic sanity checks (bond lengths, void volumes, density
plausibility, missed-symmetry detection, ADP positive-definiteness) or
-whether `?` placeholders in `_journal.*` / `_publ_*` had been filled —
-those remain a separate IUCr-server concern.
+whether future journal-submission metadata is complete — that remains a
+separate IUCr-server concern. The v1 clean report CIF does not emit the
+empty `_journal.*` / `_publ_*` placeholders.
### 3. Handler mechanism — `iucr_name` + `IucrCategoryTransformer`
@@ -984,17 +991,18 @@ Policy:
recognisable to scientists familiar with `_refine_ls.*` /
`_pd_proc_ls.*` from Rietveld publications; the IUCr export carries
the matching dictionary-canonical category prefixes per topology.
-- IUCr submission becomes a single explicit report command, with no
- manual editing required: `project.report.save_cif()` produces an
- upload-ready file at `reports/.cif` matching the
- multi-datablock publication convention. Users who want CIF reports on
- every project save can set `project.report.cif = True`.
-- Publication-metadata placeholders are emitted as `?` in `data_global`
- so users know where to fill in journal-required info before
- submission.
-- External IUCr tooling (publCIF, checkCIF, pdCIFplotter,
- journal-submission pipelines) consumes the submission file cleanly;
- the day-to-day saved files are not a tooling target.
+- The report CIF becomes a single explicit report command, with no
+ manual editing required for the refinement data:
+ `project.report.save_cif()` produces a clean file at
+ `reports/.cif` matching the multi-datablock publication
+ convention for structural and fit content. Users who want CIF reports
+ on every project save can set `project.report.cif = True`.
+- Journal and author metadata placeholders are omitted from
+ `data_global`; a future submission-specific surface can add them when
+ a concrete journal workflow requires them.
+- External IUCr tooling (publCIF, checkCIF, pdCIFplotter) can consume
+ the report file cleanly; the day-to-day saved files are not a tooling
+ target.
- `_easydiffraction_*` prefix appears only in the IUCr export, where the
explicit namespacing aids journal reviewers. It does not bloat
day-to-day CIFs.
@@ -1122,12 +1130,12 @@ it.
## Deferred Work
-- **Publication-metadata override hook.** A user-supplied
- `reports/publ_info.json` (or `publ_info.toml`) read by the IUCr report
- writer to replace the `?` placeholders in `data_global` (`_journal.*`,
- `_publ_*`, `_publ_author.*` loop entries). Out of scope for the first
- pass; revisit once the IUCr export is shipping and users have feedback
- on workflow friction.
+- **Journal-submission metadata surface.** A future ADR may introduce a
+ user-supplied `reports/publ_info.json` / `publ_info.toml` file or a
+ Python `project.publication` owner for `_journal.*`, `_publ_*`, and
+ `_publ_author.*` entries. V1 deliberately omits those placeholders so
+ generated report CIFs stay clean; revisit only with concrete user or
+ journal-portal requirements.
- **Crystallographic sanity validation.** The §2.5 validator covers spec
compliance only. A future pass could integrate IUCr's web checkCIF
(HTTP POST to the checkCIF endpoint) or bundle a local subset of its
diff --git a/docs/dev/adrs/accepted/minimizer-category-consolidation.md b/docs/dev/adrs/accepted/minimizer-category-consolidation.md
index f7a1cdb30..b0f374f19 100644
--- a/docs/dev/adrs/accepted/minimizer-category-consolidation.md
+++ b/docs/dev/adrs/accepted/minimizer-category-consolidation.md
@@ -115,7 +115,7 @@ owner, category as a read-only attribute that gets swapped.
### 3. Per-parameter posterior data lives on `Parameter.posterior`
Adopt the proposal from
-[`parameter-posterior-summary.md`](parameter-posterior-summary.md):
+[`parameter-posterior-summary.md`](../suggestions/parameter-posterior-summary.md):
`GenericParameter.posterior` is `None` for deterministic fits and a
`PosteriorParameterSummary` for Bayesian fits. The
`_bayesian_parameter_posterior` CIF loop is removed; posterior summary
@@ -457,9 +457,9 @@ category's class-level `_engine_metadata` dict.
### Suggestions superseded or absorbed
-- [`parameter-posterior-summary.md`](parameter-posterior-summary.md) —
- absorbed by §3 of this ADR. When this ADR is accepted, that suggestion
- can be closed and a pointer added.
+- [`parameter-posterior-summary.md`](../suggestions/parameter-posterior-summary.md)
+ — absorbed by §3 of this ADR. When this ADR is accepted, that
+ suggestion can be closed and a pointer added.
## Alternatives Considered
diff --git a/docs/dev/adrs/accepted/project-facade-and-persistence.md b/docs/dev/adrs/accepted/project-facade-and-persistence.md
index 6d16f7118..7a3fcf66c 100644
--- a/docs/dev/adrs/accepted/project-facade-and-persistence.md
+++ b/docs/dev/adrs/accepted/project-facade-and-persistence.md
@@ -16,8 +16,8 @@ Persistence.
`Project` is the top-level user facade. It owns project metadata,
structures, experiments, rendering preferences, display helpers,
-analysis, report helpers, publication metadata, verbosity, and save/load
-behavior.
+analysis, report helpers, verbosity, and save/load behavior. Journal and
+publication metadata are deferred from the v1 facade.
A later proposal considered renaming this facade to `Workspace` so that
`project` could be reserved for the scientific project information
@@ -57,12 +57,14 @@ hybrid surface: its scalar output configuration persists to
under `reports/`. The previous `project.summary` placeholder and its
`summary.cif` output are not part of the persistence layout.
-Expose journal-submission metadata as `project.publication`. It is a
-top-level owner with CIF-aligned sibling categories for `_journal.*`,
+Do not expose journal-submission metadata as `project.publication` in
+v1. The clean report policy in
+[`project-summary-rendering.md`](project-summary-rendering.md) §5 keeps
+`project.cif` and generated report CIFs free of empty `_journal.*`,
`_journal_date.*`, `_journal_coeditor.*`, `_publ_contact_author.*`,
-`_publ_body.*`, and the `_publ_author.*` loop. These singleton
-publication categories persist in `project.cif` and feed report exports;
-`reports/.cif` remains export-only.
+`_publ_body.*`, `_publ_author.*`, and `_pd_meas.info_author_*` fields.
+Those tags are deferred for a future journal-submission metadata
+surface.
Keep project information available as `project.info`. The Python name
avoids a confusing `project.project` access path, while the persisted
@@ -90,9 +92,8 @@ serialized project-information field. If the path is exposed in Python,
it must not emit a `_project.path` CIF item.
The project-level singleton categories currently persisted in
-`project.cif` are `_project.*`, `_chart.*`, `_report.*`, `_table.*`,
-`_verbosity.*`, `_journal.*`, `_journal_date.*`, `_journal_coeditor.*`,
-`_publ_contact_author.*`, `_publ_body.*`, and the `_publ_author.*` loop.
+`project.cif` are `_project.*`, `_rendering_plot.*`, `_report.*`,
+`_rendering_table.*`, and `_verbosity.*`.
## Consequences
diff --git a/docs/dev/adrs/accepted/project-summary-rendering.md b/docs/dev/adrs/accepted/project-summary-rendering.md
index 8098b6d12..85e9e72b0 100644
--- a/docs/dev/adrs/accepted/project-summary-rendering.md
+++ b/docs/dev/adrs/accepted/project-summary-rendering.md
@@ -18,20 +18,20 @@ Runs alongside, and **extends**, the accepted
- A `project.save(report=True)` opt-in flag for the IUCr CIF.
That ADR currently scopes `project.report` to **CIF only** — the
-multi-datablock IUCr submission CIF written to `reports/.cif`.
-This ADR keeps the facade and adds a **`project.report` configuration
-category** with five scalar persisted fields (`cif`, `html`, `tex`,
-`pdf`, `html_offline`) on `project.cif`, plus ad-hoc per-format methods
-(`save_html()`, `save_cif()`, `save_tex()`, `save_pdf()`). The
-Python-side API uses those same boolean descriptors directly, matching
-the persisted CIF shape. The LaTeX writer hardcodes `iucrjournals` as
-its document class — there is no style selector, no `_report.style`
-field, no `style=` arg on `save_tex()` / `save_pdf()`. The accepted IUCr
-`project.save(report=True)` flag is **removed**; reports come from the
-config category, not from boolean flags. All four format booleans
-default to `False` so `project.save()` writes nothing under `reports/`
-until the user configures otherwise, preserving the "no surprise files"
-property.
+multi-datablock IUCr-aligned report CIF written to
+`reports/.cif`. This ADR keeps the facade and adds a
+**`project.report` configuration category** with five scalar persisted
+fields (`cif`, `html`, `tex`, `pdf`, `html_offline`) on `project.cif`,
+plus ad-hoc per-format methods (`save_html()`, `save_cif()`,
+`save_tex()`, `save_pdf()`). The Python-side API uses those same boolean
+descriptors directly, matching the persisted CIF shape. The LaTeX writer
+hardcodes `iucrjournals` as its document class — there is no style
+selector, no `_report.style` field, no `style=` arg on `save_tex()` /
+`save_pdf()`. The accepted IUCr `project.save(report=True)` flag is
+**removed**; reports come from the config category, not from boolean
+flags. All four format booleans default to `False` so `project.save()`
+writes nothing under `reports/` until the user configures otherwise,
+preserving the "no surprise files" property.
Coordination points with the alignment ADR (no blocking conflicts; its
Open Questions section is empty):
@@ -50,18 +50,17 @@ Open Questions section is empty):
`project.save()` (§1.4). HTML, TeX, and PDF outputs are not
gemmi-validatable and get no pre-write validation; LaTeX errors
surface at PDF-compile time via the TeX engine. A writer that emits
- non-compliant CIF raises `EasyDiffractionWriterError` instead. This
- ADR's deferred `check_completeness()` (publication-side completeness)
- is a separate concern that stays in Deferred Work.
-- **Publication metadata source** — the alignment ADR's Deferred Work
- proposes a user-supplied `reports/publ_info.{toml,json}` to replace
- `?` placeholders. Both write paths read the same Python attribute,
- **`project.publication`** — a new top-level on `Project`, sibling to
- `project.info` and `project.analysis`. The schema is defined in §5 of
- this ADR: six CIF-aligned sibling categories (`journal`,
- `journal_date`, `journal_coeditor`, `contact_author`, `body`,
- `authors`) with full IUCr-tag fidelity. The loader accepts TOML
- (primary) and JSON (fallback); selection is by file extension.
+ non-compliant CIF raises `EasyDiffractionWriterError` instead.
+ Completeness checks for a future journal-submission metadata surface
+ stay in Deferred Work and are not part of the v1 clean report CIF.
+- **Clean report metadata** — the alignment ADR's Deferred Work proposed
+ user-supplied `reports/publ_info.{toml,json}` data to replace `?`
+ placeholders. This ADR rejects that v1 surface. There is no
+ `project.publication` owner in v1, `project.cif` does not persist
+ journal/publication metadata, and the report CIF does not emit empty
+ journal, author, publication-body, or powder-measurement author
+ placeholders. Section 5 records the deferred tags so they can be
+ reconsidered later without keeping empty fields in current reports.
Also touches:
@@ -74,26 +73,25 @@ Also touches:
- [`project-facade-and-persistence.md`](../accepted/project-facade-and-persistence.md)
— two changes: `project.report` gains a persisted configuration
category (`_report.*` in `project.cif`, see §1.3), turning the facade
- into a hybrid of helper methods plus persisted config; and a new
- top-level `project.publication` owner is added alongside the existing
- `project.info`, `project.structures`, `project.experiments`,
- `project.analysis`, `project.report` facade slots (see §5).
+ into a hybrid of helper methods plus persisted config; and the
+ previously proposed `project.publication` owner is rejected for v1 so
+ empty journal metadata stays out of `project.cif` (see §5).
- [`python-cif-category-correspondence.md`](python-cif-category-correspondence.md)
- — owns the Python↔CIF correspondence rule for **two** new
- project-level singleton surfaces: `project.report.* ↔ _report.*` (five
- scalar items, §1.3) and `project.publication.*` sibling categories ↔
- `_journal.*`, `_publ_author.*`, `_publ_contact_author.*`, etc. (§5).
+ — owns the Python↔CIF correspondence rule for the new
+ `project.report.* ↔ _report.*` project-level singleton surface (five
+ scalar items, §1.3). The rejected `project.publication.*` surface is
+ recorded in §5 for future reconsideration, not accepted for v1.
## Context
The library today has four shapes of summary output:
-- `Report.show_report()` and friends — terminal/Jupyter rendering of
- project metadata, crystallographic data per phase, experimental
+- `project.report` — report configuration and per-format export methods
+ for project metadata, crystallographic data per phase, experimental
configuration, and fit metrics
- ([report.py](../../../../src/easydiffraction/report/report.py)).
- (Pre-PR #184 this was `Summary.show_report()` on `project.summary`;
- the IUCr alignment ADR replaced the unimplemented placeholder.)
+ ([default.py](../../../../src/easydiffraction/project/categories/report/default.py)).
+ The IUCr alignment ADR replaced the earlier unimplemented
+ `project.summary` placeholder with this facade.
- `summary.cif` — was written into the project root on every
`project.save()` as the literal string `"To be added..."` until PR
#184 removed both the writer call and the placeholder method. Not a
@@ -127,18 +125,16 @@ the unresolved design question. The alignment ADR has since replaced the
unimplemented `project.summary` slot with a `project.report` facade
scoped to IUCr CIF generation (`reports/.cif`). That resolves
the CIF half of the question but leaves the GUI Summary tab, the
-terminal `show_report()`, the human-readable HTML, and the
-manuscript-bound LaTeX/PDF without a definition. This ADR fills the gap
-by extending the same `project.report` facade with non-CIF rendering
-surfaces.
+human-readable HTML, and the manuscript-bound LaTeX/PDF without a
+definition. This ADR fills the gap by extending the same
+`project.report` facade with non-CIF rendering surfaces.
## Scope
In scope:
-- Extend the alignment ADR's `project.report` facade with
- terminal/Jupyter, HTML, and LaTeX rendering surfaces, a configuration
- category (five scalar fields —
+- Extend the alignment ADR's `project.report` facade with HTML and LaTeX
+ rendering surfaces, a configuration category (five scalar fields —
`project.report.{cif, html, tex, pdf, html_offline}` — persisted in
`project.cif`), and ad-hoc per-format save methods. **All report
formats are opt-in via the configuration; every format defaults to
@@ -152,12 +148,11 @@ In scope:
Persisted in `analysis/analysis.cif` (amends the IUCr ADR's "Analysis
— unchanged" stance for these fields; see §4 and the ADRs-amended
list).
-- Add a new top-level `project.publication` owner on `Project` (sibling
- to `project.info`, `project.structures`, `project.experiments`,
- `project.analysis`, `project.report`) carrying the `_publ_*` /
- `_journal_*` publication metadata the IUCr writer otherwise emits as
- `?` placeholders. See §5; amends `project-facade-and-persistence.md`
- and complements `python-cif-category-correspondence.md`.
+- Reject the previously proposed top-level `project.publication` owner
+ for v1. Journal, author, publication-body, and powder-measurement
+ author tags are not represented in code, are not persisted in
+ `project.cif`, and are not emitted as empty fields in the report CIF.
+ See §5 for the deferred tag list and the retained report-CIF fields.
- Ship exactly one LaTeX style (`iucrjournals`) — no style selector, no
`ReportStyleEnum`, no `_report.style` field. Multi-style support
(REVTeX, Elsevier, etc.) is deferred to a follow-up ADR; see "Deferred
@@ -168,11 +163,12 @@ Out of scope:
- CIF tag-name decisions for any serialised field. Those are the
alignment ADR's job; this ADR notes recommended mappings and
cross-references.
-- The IUCr CIF submission export tag policy and multi-datablock layout.
- Covered by the alignment ADR; the output file lives at
+- The IUCr-aligned report CIF export tag policy and multi-datablock
+ layout. Covered by the alignment ADR; the output file lives at
`reports/.cif` and is opt-in via `project.report.cif = True`.
- Pre-existing project-level singleton categories (`_info.*`,
- `_chart.*`, `_table.*`, `_verbosity.*`). Covered by the in-flight
+ `_rendering_plot.*`, `_rendering_table.*`, `_verbosity.*`). Covered by
+ the in-flight
[`python-cif-category-correspondence.md`](python-cif-category-correspondence.md).
This ADR **does** add one new project-level singleton category,
`_report.*`, alongside them (see §1.3 and the ADRs-amended list); that
@@ -213,9 +209,9 @@ The alignment ADR has already created the `project.report` facade
extends it along two axes:
- A new **configuration category** on `project.report` — persisted in
- `project.cif`, matching the existing `project.chart`, `project.table`,
- `project.verbosity` config pattern — that records _which_ report
- formats `project.save()` emits and _how_.
+ `project.cif`, matching the existing `project.rendering_plot`,
+ `project.rendering_table`, `project.verbosity` config pattern — that
+ records _which_ report formats `project.save()` emits and _how_.
- A new set of **ad-hoc per-format methods** for explicit one-off writes
that bypass the configuration.
@@ -242,9 +238,10 @@ read by `project.save()` thereafter:
Four per-format scalar booleans (`cif`, `html`, `tex`, `pdf`) plus
`html_offline` — **five fields total**, all single-row in CIF. Matches
-the existing `project.chart`, `project.table`, `project.verbosity`
-scalar-config shape verbatim. All booleans default to `False`, so an
-unconfigured project produces no `reports/` directory at all.
+the existing `project.rendering_plot`, `project.rendering_table`,
+`project.verbosity` scalar-config shape verbatim. All booleans default
+to `False`, so an unconfigured project produces no `reports/` directory
+at all.
There is no `style` field. The LaTeX output ships exactly one class
(`iucrjournals`); adding another style is deferred work, not a v1
@@ -321,13 +318,6 @@ project.report.as_tex() -> str
# Shared data context (for GUI Summary tab + Jinja templates):
project.report.data_context() -> dict
-# Terminal / Jupyter renderers (existing methods, migrated to
-# project.report by PR #184 — names preserved):
-project.report.show_report() # full report — sections below
-project.report.show_project_info()
-project.report.show_crystallographic_data()
-project.report.show_experimental_data()
-project.report.show_fitting_details()
```
Per-format method signatures only carry the args that apply to that
@@ -388,9 +378,9 @@ unconditionally. They are explicit one-offs.
#### 1.3 CIF persistence of the configuration
The configuration category serialises to `project.cif` next to the other
-project-level singleton categories (`_info.*`, `_chart.*`, `_table.*`,
-`_verbosity.*`). The CIF tag prefix is `_report.*` — a Set category with
-five scalar items, no loops:
+project-level singleton categories (`_info.*`, `_rendering_plot.*`,
+`_rendering_table.*`, `_verbosity.*`). The CIF tag prefix is `_report.*`
+— a Set category with five scalar items, no loops:
```text
data_
@@ -402,8 +392,8 @@ _info.created 2026-05-26T12:00:00
_info.last_modified 2026-05-26T15:42:00
# ---- Chart / table / verbosity selectors (existing config) ----
-_chart.type plotly
-_table.type plotly
+_rendering_plot.type plotly
+_rendering_table.type plotly
_verbosity.fit short
# ---- Report configuration (this ADR §1.3) ----
@@ -416,9 +406,9 @@ _report.html_offline no
All five items are scalar DDLm dotted entries — the category is declared
`_definition.class Set` so a single value per item, no loops permitted.
-Matches the existing `_chart.*`, `_table.*`, `_verbosity.*` category
-shape exactly. The `yes`/`no` boolean encoding follows the project's
-existing CIF boolean convention.
+Matches the existing `_rendering_plot.*`, `_rendering_table.*`,
+`_verbosity.*` category shape exactly. The `yes`/`no` boolean encoding
+follows the project's existing CIF boolean convention.
The default unconfigured state writes four explicit `no` values for the
format booleans (not an absent or empty representation), so the "no
@@ -460,17 +450,17 @@ The project already has two distinct facade patterns for top-level
config — not Pattern B — heavy datablock owner with its own CIF file.
The split is summarised below.
-| Slot | Pattern | CIF location | Python shape |
-| ------------------------------------ | ------- | ---------------------------------------- | ---------------------------------------------------- |
-| `project.info` | A | `project.cif` (`_info.*`) | small `CategoryItem` |
-| `project.chart` | A | `project.cif` (`_chart.*`) | `CategoryItem` (one field) |
-| `project.table` | A | `project.cif` (`_table.*`) | `CategoryItem` (one field) |
-| `project.verbosity` | A | `project.cif` (`_verbosity.*`) | `CategoryItem` (one field) |
-| **`project.report`** (this ADR) | **A** | **`project.cif` (`_report.*`)** | **`CategoryItem` (five fields) plus action methods** |
-| `project.publication` (this ADR, §5) | A | `project.cif` (`_publ_*` / `_journal_*`) | `CategoryOwner` of six sibling categories |
-| `project.analysis` | B | `analysis/analysis.cif` | `CategoryOwner` (heavy datablock) |
-| `project.structures[name]` | B | `structures/.cif` | `CategoryOwner` (heavy datablock) |
-| `project.experiments[name]` | B | `experiments/.cif` | `CategoryOwner` (heavy datablock) |
+| Slot | Pattern | CIF location | Python shape |
+| ------------------------------------ | ------- | ------------------------------------ | ---------------------------------------------------- |
+| `project.info` | A | `project.cif` (`_info.*`) | small `CategoryItem` |
+| `project.rendering_plot` | A | `project.cif` (`_rendering_plot.*`) | `CategoryItem` (one field) |
+| `project.rendering_table` | A | `project.cif` (`_rendering_table.*`) | `CategoryItem` (one field) |
+| `project.verbosity` | A | `project.cif` (`_verbosity.*`) | `CategoryItem` (one field) |
+| **`project.report`** (this ADR) | **A** | **`project.cif` (`_report.*`)** | **`CategoryItem` (five fields) plus action methods** |
+| `project.publication` (rejected, §5) | n/a | none | no v1 Python surface |
+| `project.analysis` | B | `analysis/analysis.cif` | `CategoryOwner` (heavy datablock) |
+| `project.structures[name]` | B | `structures/.cif` | `CategoryOwner` (heavy datablock) |
+| `project.experiments[name]` | B | `experiments/.cif` | `CategoryOwner` (heavy datablock) |
Reasons `project.report` is Pattern A, not Pattern B:
@@ -483,14 +473,13 @@ Reasons `project.report` is Pattern A, not Pattern B:
configuration, which already share `project.cif` for the same reason —
they are all project-level preferences, not domain data.
-What makes `project.report` look heavier than `project.chart` /
-`project.table` / `project.verbosity` is the action methods on the
-facade (`save_cif()`, `save_html()`, `show_report()`, `data_context()`,
-etc.). Those live on the Python class alongside the configuration
-fields, which is the facade-hybrid amendment to
-`project-facade-and-persistence.md` already recorded in the ADRs-amended
-list. The action methods do not change where the configuration persists
-— that stays in `project.cif`.
+What makes `project.report` look heavier than `project.rendering_plot` /
+`project.rendering_table` / `project.verbosity` is the action methods on
+the facade (`save_cif()`, `save_html()`, `data_context()`, etc.). Those
+live on the Python class alongside the configuration fields, which is
+the facade-hybrid amendment to `project-facade-and-persistence.md`
+already recorded in the ADRs-amended list. The action methods do not
+change where the configuration persists — that stays in `project.cif`.
#### 1.4 Validation moves internal — CIF only, writer-correctness only
@@ -550,11 +539,10 @@ overhead to a one-time ~200 ms session cost. The
`EasyDiffractionWriterError` includes the full gemmi diagnostic so bug
reports are actionable.
-A separate, _completeness_-oriented check
-(`project.report.check_completeness()`) — flagging unfilled `_publ_*` /
-`_journal_*` placeholders for journal submission, which is a
-publication-readiness question rather than a writer-correctness one — is
-a different concern and stays in Deferred Work.
+A separate, _completeness_-oriented check for a future
+journal-submission metadata surface is a different concern and stays in
+Deferred Work. The v1 report does not expose or fill `_publ_*` /
+`_journal_*` placeholders.
#### 1.5 Descriptor display metadata — `DisplayHandler`
@@ -651,7 +639,7 @@ self._u_iso = Parameter(
| ---------------------------------------- | --------------------------- | ---------------------------- |
| LaTeX (`save_tex`) | `$U_{\mathrm{iso}}$` | `\AA$^2$` |
| HTML (`save_html`, MathJax-rendered) | `$U_{\mathrm{iso}}$` | `\AA$^2$` |
-| HTML pre-MathJax / GUI / `show_report()` | `Uiso` | `Ų` |
+| HTML pre-MathJax / GUI | `Uiso` | `Ų` |
| `project.report.data_context()` raw dict | both available | both available |
| CIF emission | `_atom_site.U_iso_or_equiv` | (no `_units.code` row today) |
| Python code / repr | `u_iso` | `angstrom_squared` |
@@ -670,9 +658,9 @@ per-context fallback chain:
additionally surrounds `handler.latex_name` / `handler.latex_units`
with `\(...\)` math delimiters so MathJax picks them up where the
descriptor has typeset variants — i.e., HTML can show the same
- `$U_{\mathrm{iso}}$` the PDF shows, while a GUI tooltip or
- `show_report()` printout falls back to `display_*`.
-- **GUI / terminal / `show_*()` context**:
+ `$U_{\mathrm{iso}}$` the PDF shows, while a GUI tooltip falls back to
+ `display_*`.
+- **GUI / plain-text context**:
`handler.display_name or descriptor.name`,
`handler.display_units or descriptor.units`.
@@ -830,9 +818,11 @@ rather than maintaining hand-written summary tables:
descriptors first as key-value tables and loop items as loop tables
with headers. Experiment data categories (`pd_data`, `total_data`,
`refln`) are skipped because they are plotted or too large for report
- tables. The fit-quality plot remains the first experiment
- sub-subsection, and publication metadata remains source data only — it
- is not added to HTML, TeX, or PDF reports.
+ tables. The structure view and the fit-quality plot sit directly under
+ their parent subsection — the structure or experiment record (e.g.
+ `3.1 lbco`, `4.1 hrpt`) — as its first content, with no extra
+ sub-subsection heading. Publication metadata remains source data only
+ — it is not added to HTML, TeX, or PDF reports.
- **DisplayHandler names and units.** All table labels and units use the
per-context `DisplayHandler` resolution chain, so TeX sees LaTeX names
(`$2\theta$ offset`, `$U_{\mathrm{iso}}$`), HTML sees MathJax-capable
@@ -867,6 +857,14 @@ rather than maintaining hand-written summary tables:
report styling code and passed to HTML CSS, TeX tables, and
Plotly/pgfplots figures. Body rows alternate with the first body row
filled, regardless of whether the table has a header.
+- **Framed structure figure.** In the TeX/PDF report the structure view
+ is wrapped in an `\fcolorbox` that is always the full line width; the
+ raster PNG is scaled to fit within half the text height or the line
+ width, whichever binds first (aspect preserved), so the box height
+ follows the image automatically. The frame uses the same light grey as
+ the interactive view's container, keeping the static report figure and
+ the Jupyter view visually consistent. The HTML report keeps the
+ interactive Three.js view, which already draws that container border.
- **Predictable table widths.** HTML and TeX key-value tables use at
least half of the available text width. Loop tables are classified
from their rendered content: compact loops use half width, while wider
@@ -893,9 +891,9 @@ Rationale for the config category (replacing the earlier flag-based and
"auto on every save" positions):
- Reports are a _project preference_, not a per-call argument.
- `project.chart.type`, `project.table.type`, `project.verbosity.fit`
- follow the same pattern — set once, persisted in `project.cif`,
- applied on every save.
+ `project.rendering_plot.type`, `project.rendering_table.type`,
+ `project.verbosity.fit` follow the same pattern — set once, persisted
+ in `project.cif`, applied on every save.
- `project.save()` has one job: save the project. With all report
booleans `False`, the report behaviour is unchanged from before this
ADR; with `project.report.html = True`, HTML appears on every save
@@ -1061,7 +1059,7 @@ Single style (`iucrjournals`) — no multi-style infrastructure.
# reports/ directory does not exist
```
-`project.report.cif = True` (journal-submission CIF only):
+`project.report.cif = True` (clean IUCr-aligned report CIF only):
```
/
@@ -1086,7 +1084,7 @@ inspection page):
.html # ~3 MB, Plotly inlined
```
-`project.report.cif/html/pdf = True` (typical pre-submission bundle):
+`project.report.cif/html/pdf = True` (typical review bundle):
```
/
@@ -1095,7 +1093,7 @@ inspection page):
experiments/<...>.cif
analysis/analysis.cif
reports/
- .cif # journal-submission CIF
+ .cif # clean IUCr-aligned report CIF
.html # interactive inspection page
.pdf # typeset PDF, iucrjournals class
tex/ # source for the PDF (kept editable)
@@ -1154,9 +1152,8 @@ when there is a concrete second style to ship.
The generated document uses the project title directly in `\title{...}`
and emits an empty `\author{}`. If `project.info.description` is
non-empty, that text becomes the document abstract; if it is empty, the
-abstract environment is omitted. Publication metadata
-(`project.publication.*`) is not included in the human-readable HTML or
-TeX/PDF reports.
+abstract environment is omitted. Journal/publication metadata is not
+included in the human-readable HTML or TeX/PDF reports.
#### 3.2.1 Source provenance and bundled files
@@ -1556,155 +1553,110 @@ successful return — pre-fit calls, failed fits, and projects loaded from
a save predating this ADR all start out **without** the snapshot. The
public API surface treats missing provenance uniformly:
-- **Rendering (`project.report.show_report()`, HTML, TeX).** Each
- role-row prints `"(not available)"` for `name`, omits version and URL,
- and adds a one-line footer "Software-provenance snapshot not yet
- recorded — call `Analysis.fit()` once to populate." No warning, no
- exception; the report still renders end-to-end so users iterating on a
- configuration before fitting see the rest of the page.
+- **Rendering (HTML, TeX).** Each role-row prints `"(not available)"`
+ for `name`, omits version and URL, and adds a one-line footer
+ "Software-provenance snapshot not yet recorded — call `Analysis.fit()`
+ once to populate." No warning, no exception; the report still renders
+ end-to-end so users iterating on a configuration before fitting see
+ the rest of the page.
- **IUCr CIF export (`project.report.cif = True`).** The
- `_easydiffraction_software.{framework, calculator, minimizer}` triple
- emits `?` placeholders consistent with the IUCr ADR's unset-field
- convention. The derived `_computing.structure_refinement` string falls
- back to `"EasyDiffraction "` (framework only). The
- `_easydiffraction_software.fit_datetime` tag is omitted entirely (no
- `?` — the absence is the signal).
+ `_computing.structure_refinement` string falls back to
+ `"EasyDiffraction "` when calculator or minimizer provenance
+ is unavailable. `_easydiffraction_software.framework` is emitted from
+ the framework label; `_easydiffraction_software.calculator`,
+ `_easydiffraction_software.minimizer`, and
+ `_easydiffraction_software.fit_datetime` are emitted only when the
+ corresponding fit snapshot values exist.
- **Internal validation (the §1.4 pre-write gemmi pass).** Does **not**
detect missing provenance. The gemmi pass validates that emitted tags
and types match the IUCr core / pdCIF dictionaries, and it explicitly
skips the `_easydiffraction_*` extension namespace. So the
fallback-filled `_computing.structure_refinement`
(`"EasyDiffraction "`) is not flagged as missing — it's a
- valid string — and the `?` placeholders on
- `_easydiffraction_software.{framework, calculator, minimizer}` are not
- flagged either, because gemmi does not validate the extension
- namespace. Detecting "publication-grade provenance is incomplete" is a
- different concern from dictionary-spec compliance and falls to the
- deferred `project.report.check_completeness()` listed in Deferred
- Work. Users who must guarantee complete provenance before submission
- should run that check (once it lands) or inspect the rendered report
- manually.
+ valid string — and omitted optional `_easydiffraction_software.*`
+ fields are not flagged because gemmi does not validate the extension
+ namespace. Detecting complete provenance is a different concern from
+ dictionary-spec compliance and falls to a deferred completeness check.
+ Users who must guarantee complete provenance should run that check
+ (once it lands) or inspect the rendered report manually.
- **Old projects.** Loading a project saved before this ADR produces an
`analysis.software` with all fields unset and the timestamp `None`. No
migration step is run; the user populates the snapshot by re-running
the fit.
This rule applies wholesale — no flag toggles it, no targeted exception
-is raised. Publication-grade users who need the provenance can re-run
-the fit; users producing draft / preview reports keep working without
-interruption.
-
-### 5. Publication-metadata category on `project`
-
-New top-level category on `Project`, sibling to `project.info` and
-`project.analysis`. Populated by the user (directly in Python, or loaded
-from a `publ_info.{toml,json}` file). Feeds the `_publ_*` / `_journal_*`
-/ `_publ_author.*` placeholders that the alignment ADR's `data_global`
-block currently emits as `?` (alignment ADR §2.3a).
-
-#### 5.1 Structure — CIF-aligned sibling categories
-
-`Publication` is a category-owner (like `Experiment`) hosting sibling
-sub-categories that map **1:1 to CIF category prefixes**. No artificial
-groupings; the CIF dictionaries already provide the natural shape:
-
-| Python attribute | CIF category | Audience |
-| ------------------------------ | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- |
-| `publication.journal` | `_journal.*` | User-set at submission (`name_full`, `paper_category`); editor-set post-acceptance (`year`, `volume`, `issue`, `page_*`, `paper_doi`) |
-| `publication.journal_date` | `_journal_date.*` | Editor (`accepted`, `from_coeditor`, `printers_final`) |
-| `publication.journal_coeditor` | `_journal_coeditor.*` | Editor (`code`, `name`, `notes`) |
-| `publication.contact_author` | `_publ_contact_author.*` | User (`name`, `address`, `email`, `phone`, `id_orcid`, `id_iucr`) |
-| `publication.body` | `_publ_body.*` | User (`title`, `synopsis`, `abstract`, `keywords`) |
-| `publication.authors` | `_publ_author.*` (loop) | User (per author: `name`, `address`, `footnote`, `id_orcid`, `id_iucr`) |
-
-Access pattern:
-
-```python
-project.publication.journal.name_full = "Acta Crystallographica E"
-project.publication.journal.paper_category = "structure-report"
-project.publication.journal.paper_doi = "10.1107/S2056989026..." # post-acceptance
-project.publication.contact_author.name = "Jane Doe"
-project.publication.contact_author.email = "jane@example.com"
-project.publication.contact_author.id_orcid = "0000-0001-..."
-project.publication.body.title = "Crystal structure of ..."
-project.publication.body.synopsis = "Short summary..."
-project.publication.body.abstract = "..."
-project.publication.body.keywords = ["powder diffraction", "Rietveld", ...]
-project.publication.authors.add(name="Jane Doe", id_orcid="0000-0001-...")
-project.publication.authors.add(name="John Smith", id_orcid="0000-0002-...")
-```
-
-Python attributes are lowercase snake_case (`id_orcid`); CIF tags retain
-dictionary casing (`_publ_contact_author.id_ORCID`). Loops use the
-project's existing `CategoryCollection` pattern (`add()`, indexed
-access, etc.).
-
-The editor-side categories (`journal_date`, `journal_coeditor`) exist so
-the schema can carry editor-supplied fields when a user manually copies
-them in (typically by editing `project.publication.*` in Python after a
-referee round) — not because users typically fill them at submission
-time. Defaults are `None`; the IUCr writer emits `?` for unset fields.
-Round-trip is on the **`project.cif`** axis only (`project.publication`
-reads and writes there per the project-facade-and-persistence contract);
-**the report CIF at `reports/.cif` stays export-only** per the
-accepted IUCr ADR. A reader for `reports/.cif` is explicitly
-out of scope.
-
-#### 5.2 Discrete `body` fields, not a markdown blob
-
-`_publ_body.*` in coreCIF supports nested section content via an
-`element` / `format` / `contents` trio. For the refinement-table
-appendix use case (user pastes content into a full manuscript later),
-discrete top-level slots are more discoverable than a generic markdown
-blob:
-
-- `publication.body.title` — manuscript title (single string)
-- `publication.body.synopsis` — short summary (IUCr Acta E requires
- this)
-- `publication.body.abstract` — abstract text
-- `publication.body.keywords` — list of strings (loop on CIF side)
-
-A future v2 could add free-form section support
-(`publication.body.sections[]` with element/format/contents trios) when
-users produce full manuscripts from the library. Out of scope for v1.
-
-#### 5.3 Input mechanism — TOML primary, JSON fallback
-
-Two import paths, both writing into the same in-memory
-`project.publication` object:
-
-```python
-# Direct Python edit (preferred for notebook / interactive use):
-project.publication.contact_author.email = "jane@example.com"
-
-# File-based load (preferred for collaborative / batch workflows):
-project.publication.load("reports/publ_info.toml") # TOML, by extension
-project.publication.load("reports/publ_info.json") # JSON, by extension
-```
-
-TOML is the primary format:
-
-- **Comment support** — users can document why a field is set / unset.
-- **Multi-line strings** — abstracts, addresses, and synopses without
- escaping.
-- **Familiar** — the project already uses `pyproject.toml` and
- `pixi.toml`.
-- **Standard library** `tomllib` (Python 3.11+); no new dependency.
-
-JSON is supported as a fallback for programmatic generation (e.g. a user
-script that dumps publication data from a database; an external tool
-that produces JSON output). Standard library `json`.
-
-Format selection is by file extension. Unknown extensions raise
-`ValueError("Unsupported publication-info format: . " "Use .toml or .json.")`.
-No YAML support — adds a dependency for no gain.
-
-Reading from the report CIF (`reports/.cif`) back into
-`project.publication` is **explicitly out of scope** here and remains
-the accepted IUCr ADR's "Export only — no round-trip" contract. Users
-who edit the report file by hand should also update
-`project.publication` (or its TOML/JSON source) so the next save
-reflects the edits; the library does not auto-import.
+is raised. Users who need full provenance can re-run the fit; users
+producing draft / preview reports keep working without interruption.
+
+### 5. Clean report-CIF metadata policy
+
+The v1 report CIF is a clean refinement report, not a journal-submission
+manuscript stub. It must not create a `project.publication` API surface,
+must not persist journal/publication metadata in `project.cif`, and must
+not emit report-CIF sections that only contain `?` placeholders.
+
+The report writer keeps only data-backed scientific content and
+generation/provenance metadata. Optional tags with no source value are
+omitted rather than written as empty fields. CIF unknown markers remain
+allowed only inside an otherwise useful emitted category or loop row
+where the dictionary shape requires a cell and dropping the row would
+lose real project data.
+
+#### 5.1 Deferred journal and author tags
+
+These tags are explicitly **not** part of the v1 code surface, default
+`project.cif`, or generated report CIF. They are recorded here for a
+future journal-submission ADR to reconsider against a concrete target
+journal or portal.
+
+- `_journal.name_full`, `_journal.year`, `_journal.volume`,
+ `_journal.issue`, `_journal.page_first`, `_journal.page_last`,
+ `_journal.paper_category`, `_journal.paper_DOI`,
+ `_journal.coden_ASTM`, `_journal.suppl_publ_number`.
+- `_journal_date.accepted`, `_journal_date.from_coeditor`,
+ `_journal_date.printers_final`.
+- `_journal_coeditor.code`, `_journal_coeditor.name`,
+ `_journal_coeditor.notes`.
+- `_publ_contact_author.name`, `_publ_contact_author.address`,
+ `_publ_contact_author.email`, `_publ_contact_author.phone`,
+ `_publ_contact_author.id_ORCID`, `_publ_contact_author.id_IUCr`.
+- `_publ_author.name`, `_publ_author.address`, `_publ_author.footnote`,
+ `_publ_author.id_ORCID`, `_publ_author.id_IUCr`.
+- `_publ_body.title`, `_publ_body.synopsis`, `_publ_body.abstract`,
+ `_publ_body.keywords`, `_publ_body.contents`.
+- `_pd_meas.info_author_name`, `_pd_meas.info_author_email`,
+ `_pd_meas.info_author_phone`.
+
+The previously proposed `publ_info.{toml,json}` loader is deferred with
+these tags. It would be useful only when the library commits to a
+submission-oriented publication metadata surface; it is unnecessary for
+a clean report CIF.
+
+#### 5.2 Kept report-CIF fields and rationale
+
+The report CIF keeps the following tag families when the project has
+source data for them.
+
+| Tag family | Tags retained | Rationale |
+| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Audit | `_audit.creation_method`, `_audit.creation_date` | Identifies the EasyDiffraction writer and report-generation time. These are generated by the library, not user-authored empty metadata. |
+| Software provenance | `_computing.structure_refinement`; `_easydiffraction_software.framework`, `_easydiffraction_software.calculator`, `_easydiffraction_software.minimizer`, `_easydiffraction_software.fit_datetime` | Records the analysis stack in a standard IUCr text field plus structured EasyDiffraction fields. `fit_datetime` is emitted only when a fit snapshot exists. |
+| Chemical formula | `_chemical_formula.sum`, `_chemical_formula.moiety`, `_chemical_formula.weight`, `_chemical_formula.IUPAC` | Summarises chemistry derived from structure atom sites. These fields describe the refined model, not journal administration. |
+| Unit cell | `_cell.length_a`, `_cell.length_b`, `_cell.length_c`, `_cell.angle_alpha`, `_cell.angle_beta`, `_cell.angle_gamma` | Core crystallographic model parameters needed to understand and reuse a structure block. |
+| Space group | `_space_group.name_H-M_alt`, `_space_group.IT_coordinate_system_code`, `_space_group.crystal_system`; `_space_group_symop.id`, `_space_group_symop.operation_xyz` | Gives symmetry in standard coreCIF form and includes explicit operations for downstream tools. |
+| Atom sites | `_atom_site.label`, `_atom_site.type_symbol`, `_atom_site.fract_x`, `_atom_site.fract_y`, `_atom_site.fract_z`, `_atom_site.occupancy`, `_atom_site.ADP_type`, `_atom_site.B_iso_or_equiv` or `_atom_site.U_iso_or_equiv`, `_atom_site.Wyckoff_symbol` | Carries the refined structural model. The B/U split follows the accepted ADP policy and dictionary names. |
+| Anisotropic ADPs | `_atom_site_aniso.label`, `_atom_site_aniso.B_11`, `_atom_site_aniso.B_22`, `_atom_site_aniso.B_33`, `_atom_site_aniso.B_12`, `_atom_site_aniso.B_13`, `_atom_site_aniso.B_23`, or the matching `U_*` items | Carries anisotropic displacement parameters when present, using one ADP family per emitted loop. |
+| Diffraction conditions | `_diffrn.ambient_temperature`, `_diffrn.ambient_pressure`, `_diffrn_radiation.probe`, `_diffrn_radiation_wavelength.id`, `_diffrn_radiation_wavelength.value`, `_diffrn_radiation_wavelength.wt` | Describes measurement conditions and wavelength/probe information needed to interpret the refinement. |
+| Single-crystal refinement | `_refine_ls.R_factor_all`, `_refine_ls.wR_factor_all`, `_refine_ls.R_factor_gt`, `_refine_ls.wR_factor_gt`, `_refine_ls.number_parameters`, `_refine_ls.number_restraints`, `_refine_ls.number_constraints`, `_refine_ls.extinction_method`, `_refine_ls.extinction_coef`, `_refine.special_details` | Reports standard single-crystal fit quality and extinction details produced by the refinement state. |
+| Reflection summary | `_reflns.number_total`, `_reflns.number_gt`, `_reflns.threshold_expression` | Summarises the reflection set used for single-crystal quality metrics. |
+| Single-crystal reflections | `_refln.index_h`, `_refln.index_k`, `_refln.index_l`, `_refln.F_squared_meas`, `_refln.F_squared_calc`, `_refln.F_squared_meas_su`, `_refln.include_status` | Provides the measured/calculated reflection data needed to inspect the fit. |
+| Powder block cross-references | `_pd_block_id`, `_pd_block_diffractogram_id`, `_pd_phase_block.id`, `_pd_phase_block.scale` | Links `data_overall`, phase/model blocks, and diffractogram/pattern blocks in multi-block powder reports. The block-name and scalar-reference policy is defined in the CIF-alignment ADR §2.3. |
+| Powder measurement and profile | `_pd_meas.scan_method`, `_pd_meas.number_of_points`, `_pd_meas.2theta_scan` or `_pd_meas.time_of_flight`, `_pd_meas.intensity_total`, `_pd_calc.intensity_total`, `_pd_proc.intensity_bkg_calc`, `_pd_proc_ls.weight` | Carries the observed, calculated, background, and weight arrays for profile inspection. The author-info placeholders are excluded by §5.1. |
+| Powder processing | `_pd_proc.info_data_reduction`, `_pd_proc.info_datetime`, `_pd_proc.info_excluded_regions` | Documents processing state that affects the profile fit. Empty free-text fields are omitted until real source data exists. |
+| Powder refinement | `_pd_calc.method`, `_pd_proc_ls.prof_R_factor`, `_pd_proc_ls.prof_wR_factor`, `_pd_proc_ls.prof_wR_expected`, `_pd_proc_ls.profile_function`, `_pd_proc_ls.background_function`, `_refine_ls.number_parameters`, `_refine_ls.number_restraints`, `_refine_ls.number_constraints` | Reports Rietveld method, profile quality metrics, and model-size counts in standard pdCIF/coreCIF fields. |
+| Powder reflections | `_refln.index_h`, `_refln.index_k`, `_refln.index_l`, `_refln.F_squared_meas`, `_refln.F_squared_calc`, `_pd_refln.phase_id`, `_refln.d_spacing` | Keeps calculated powder reflection information tied to the contributing phase. |
+| TOF calibration | `_pd_calib_d_to_tof.id`, `_pd_calib_d_to_tof.power`, `_pd_calib_d_to_tof.coeff`, `_pd_calib_d_to_tof.coeff_su`, `_pd_calib_d_to_tof.diffractogram_id` | Required for time-of-flight powder reports when non-zero calibration coefficients are present. |
+| EasyDiffraction extensions | `_easydiffraction_experiment_type.sample_form`, `_easydiffraction_experiment_type.beam_mode`, `_easydiffraction_experiment_type.radiation_probe`, `_easydiffraction_experiment_type.scattering_type`, `_easydiffraction_calculator.type`, `_easydiffraction_peak.type`, `_easydiffraction_background.type`, `_easydiffraction_sc_crystal_block.id`, `_easydiffraction_sc_crystal_block.scale`, `_easydiffraction_diffrn.ambient_magnetic_field`, `_easydiffraction_diffrn.ambient_electric_field`, `_easydiffraction_extinction.type`, `_easydiffraction_extinction.model`, `_easydiffraction_extinction.mosaicity`, `_easydiffraction_extinction.radius` | Preserves EasyDiffraction-specific state that has no exact coreCIF/pdCIF equivalent but is needed to trace how the reported fit was configured. |
### 6. Shared `ReportDataContext` + Jinja templates
@@ -1791,14 +1743,6 @@ def data_context(self) -> dict:
'constraints': ...,
},
'software': {...}, # from §4
- 'publication': { # from project.publication (§5); unset fields → None
- 'journal': {...}, # _journal.*
- 'journal_date': {...}, # _journal_date.* (editor-side)
- 'journal_coeditor': {...}, # _journal_coeditor.* (editor-side)
- 'contact_author': {...}, # _publ_contact_author.*
- 'body': {...}, # _publ_body.{title, synopsis, abstract, keywords}
- 'authors': [...], # _publ_author.* loop
- },
'metadata': {
'easydiffraction_version': ...,
'generated_at': ...,
@@ -1955,8 +1899,8 @@ every renderer (HTML, PDF, terminal, GUI) simultaneously.
`_report.html_offline`). Set the configuration once through those
booleans; `project.save()` applies it on every save thereafter.
Replaces the flag with persisted configuration, matching the
- existing `project.chart`, `project.table`, `project.verbosity`
- pattern.
+ existing `project.rendering_plot`, `project.rendering_table`,
+ `project.verbosity` pattern.
2. **`project.report.save()` surface redesigned.** The accepted
`project.report.save()` is now a no-argument convenience that reads
the configuration category (raises `ValueError` when no formats are
@@ -1990,25 +1934,15 @@ every renderer (HTML, PDF, terminal, GUI) simultaneously.
existing `_audit.creation_date` keeps its
`_iso_creation_datetime()` source (report-generation time) and is
**not** overwritten — fit time and report time are distinct events.
- 5. **Publication metadata in the default save.** The alignment ADR's
- Scope explicitly excluded "Adding new CIF categories the project
- does not currently track (`_chemical.*`, `_publ.*`, `_journal.*`)
- **for the default save**" (alignment ADR §Scope, lines 101-110).
- This ADR adds `project.publication.*` (§5) and persists it to
- `project.cif` — a different file from `reports/.cif`, but
- still a default-save change that the alignment ADR did not
- anticipate. Specifically:
- - `_publ_contact_author.*`, `_publ_author.*`, `_publ_body.*`,
- `_journal.*`, `_journal_date.*`, `_journal_coeditor.*` are now in
- scope for `project.cif`.
- - The accepted IUCr export still reads these from
- `project.publication.*` and emits them in `data_global` per
- §2.3a; the `?` placeholder semantics for unset fields are
- unchanged.
- - The accepted "Export only — no round-trip" rule for
- `reports/.cif` is **unaffected** — `project.publication`
- round-trips through `project.cif`, not through the report CIF
- (see §5.1 / §5.3).
+ 5. **Publication metadata excluded from the default save and report
+ CIF.** The alignment ADR's Scope explicitly excluded "Adding new
+ CIF categories the project does not currently track (`_chemical.*`,
+ `_publ.*`, `_journal.*`) **for the default save**" (alignment ADR
+ §Scope, lines 101-110). This ADR keeps that exclusion for `_publ_*`
+ and `_journal_*`: no `project.publication` owner is added, no
+ journal/publication metadata is persisted to `project.cif`, and the
+ report CIF omits the empty publication and powder-measurement
+ author placeholders listed in §5.1.
All other IUCr-export decisions in the alignment ADR (multi-datablock
layout, tag-name policy, gemmi as the validation engine) are
@@ -2021,7 +1955,7 @@ every renderer (HTML, PDF, terminal, GUI) simultaneously.
- [`analysis-cif-fit-state.md`](../accepted/analysis-cif-fit-state.md) —
adds `analysis.software` to the persisted analysis state.
- [`project-facade-and-persistence.md`](../accepted/project-facade-and-persistence.md)
- — three changes:
+ — three points:
1. **`project.report` gains a persisted configuration category.** The
accepted ADR scoped `project.report` as a CIF-write helper (single
output: `reports/.cif`). This ADR extends it with a
@@ -2030,34 +1964,25 @@ every renderer (HTML, PDF, terminal, GUI) simultaneously.
every save. The facade becomes a hybrid — helper methods
(`save_*()`) **and** persisted configuration on the same Python
object.
- 2. **New top-level `project.publication` facade slot (§5).** Sibling
- to `project.info`, `project.structures`, `project.experiments`,
- `project.analysis`, `project.report`. Persisted to `project.cif`
- next to the other project-level singleton categories.
- 3. **Project-level singleton category enumeration extended.** The
- accepted ADR enumerates `_info.*`, `_chart.*`, `_table.*`,
- `_verbosity.*` as the project-level singleton categories owned by
- `project.cif`. This ADR adds two more to that enumeration:
- `_report.*` (this ADR §1.3) and `_publication.*` family (this ADR
- §5; concrete sub-prefixes are `_publ_*` and `_journal_*` per IUCr
- coreCIF).
+ 2. **No top-level `project.publication` facade slot in v1 (§5).** The
+ previously considered journal/publication metadata surface is
+ deferred so `project.cif` stays free of empty manuscript and
+ journal-administration fields.
+ 3. **Project-level singleton category enumeration extended only by
+ `_report.*`.** The accepted ADR enumerates `_info.*`,
+ `_rendering_plot.*`, `_rendering_table.*`, `_verbosity.*` as the
+ project-level singleton categories owned by `project.cif`. This ADR
+ adds `_report.*` (this ADR §1.3) and explicitly does not add
+ `_publ_*`, `_journal_*`, or any `_publication.*` family.
- [`python-cif-category-correspondence.md`](python-cif-category-correspondence.md)
- — owns the Python-to-CIF correspondence rule for two new project-level
- singleton surfaces:
+ — owns the Python-to-CIF correspondence rule for one new project-level
+ singleton surface:
- `project.report.*` ↔ `_report.*` — five scalar items (four format
booleans plus `html_offline`) per §1.3.
- - `project.publication.*` ↔ `_publ_*` / `_journal_*` sibling
- categories per §5. Python attributes are lowercase snake_case
- (`id_orcid`); CIF tags retain dictionary casing
- (`_publ_contact_author.id_ORCID`).
-
- Both follow the correspondence ADR's existing 1:1 mapping pattern. The
- correspondence ADR's enumeration of "currently persisted Python
- category surfaces" gains two rows for these additions.
- No conflict with the correspondence ADR's project-level category list
- because `_publ_*` / `_journal_*` are publication-domain, not
- project-level singleton categories.
+ The rejected `project.publication.*` ↔ `_publ_*` / `_journal_*`
+ mapping is deferred in §5 and must not be added to the current
+ project-level category list.
## Open Questions
@@ -2158,12 +2083,10 @@ browser anyway. Deferred.
matplotlib-rendered PDF figures for the LaTeX path while keeping
pgfplots as the default. Tune when needed.
- Tab/accordion navigation in HTML for projects with many experiments.
-- `project.report.check_completeness()` — complements the internal gemmi
- pass from §1.4 (dictionary spec compliance, enforced before every CIF
- write). The completeness check would flag whether the user has filled
- in `_publ_*` / `_journal_*` placeholders for their target journal,
- which dictionary validation cannot determine. Different concern,
- different layer.
+- A future journal-submission completeness check — complements the
+ internal gemmi pass from §1.4 (dictionary spec compliance, enforced
+ before every CIF write). It would belong with a future
+ journal-metadata surface, not with the v1 clean report CIF.
- A pinned-snapshot variant of `.html` (timestamped, kept next
to fit-run-specific artifacts) for users who want to track refinement
history visually across saves.
@@ -2180,7 +2103,7 @@ browser anyway. Deferred.
**Description:**
Builds on the IUCr CIF alignment work by filling in the non-CIF half of
-the publication bundle. The `project.report` facade covers four output
+the report bundle. The `project.report` facade covers four output
formats — CIF, HTML, TeX, PDF — chosen via a configuration category on
the project (persisted in `project.cif`) and applied automatically on
every save:
diff --git a/docs/dev/adrs/suggestions/python-cif-category-correspondence.md b/docs/dev/adrs/accepted/python-cif-category-correspondence.md
similarity index 61%
rename from docs/dev/adrs/suggestions/python-cif-category-correspondence.md
rename to docs/dev/adrs/accepted/python-cif-category-correspondence.md
index f7fbe77c5..a9963270e 100644
--- a/docs/dev/adrs/suggestions/python-cif-category-correspondence.md
+++ b/docs/dev/adrs/accepted/python-cif-category-correspondence.md
@@ -1,6 +1,6 @@
# ADR: Python and CIF Category Correspondence
-**Status:** Proposed
+**Status:** Accepted
**Date:** 2026-05-17
## Context
@@ -16,16 +16,19 @@ saved in:
project.cif
```
-Inside that file, generic category names such as `_info.*`, `_chart.*`,
-`_table.*`, and `_verbosity.*` are less ambiguous than they would be in
-a single monolithic CIF file. This opens the option of a strict
-one-to-one correspondence for project-owned singleton categories:
+Inside that file, project-owned category names such as
+`_rendering_plot.*`, `_report.*`, `_structure_view.*`,
+`_structure_style.*`, and `_verbosity.*` are less ambiguous than they
+would be in a single monolithic CIF file. This opens the option of a
+scoped one-to-one correspondence for EasyDiffraction-owned singleton
+configuration categories:
```text
-project.info.title -> project.cif: _info.title
-project.chart.type -> project.cif: _chart.type
-project.table.type -> project.cif: _table.type
-project.verbosity.fit -> project.cif: _verbosity.fit
+project.rendering_plot.type -> project.cif: _rendering_plot.type
+project.report.cif -> project.cif: _report.cif
+project.structure_view.range_a_min -> project.cif: _structure_view.range_a_min
+project.structure_style.atom_view -> project.cif: _structure_style.atom_view
+project.verbosity.fit -> project.cif: _verbosity.fit
```
The design question is whether this rule should be applied only to
@@ -36,8 +39,16 @@ The accepted project-facade decision keeps `Project` as the public root
and keeps `project.cif` as the singleton project configuration file. It
also keeps `_project.*` as the semantic CIF category for scientific
project information and rejects `_meta.*` for that purpose. This ADR
-therefore must not reintroduce the rejected `Workspace` rename,
-`workspace.cif`, or `_meta.project_*` tags as incidental cleanup.
+therefore does **not** reintroduce the rejected `Workspace` rename,
+`workspace.cif`, `_meta.project_*` tags, or a broad `_info.*` rewrite as
+incidental cleanup.
+
+The accepted project-summary-rendering ADR also rejected a v1
+`project.publication` owner. Journal, author, publication-body, and
+powder-measurement author metadata are not represented in code, are not
+persisted in `project.cif`, and are not emitted as empty report-CIF
+placeholders. This ADR records that as an intentional correspondence
+gap, not as a missing mapping.
## Scope Of Comparison
@@ -53,55 +64,69 @@ to objects reached from the current `Project` root, for example
## Current Persistence Layout
-| Current Python surface | Current saved location | Current CIF block form | Notes |
-| ------------------------------------------------ | ------------------------ | ---------------------- | ------------------------------------------------------------------------------------- |
-| `project.info`, `project.chart`, `project.table` | `project.cif` | bare categories | Project-level singleton config. |
-| `project.report` | `project.cif` | bare category | Project-owned report-output config; report methods render artifacts under `reports/`. |
-| `project.publication` | `project.cif` | bare categories + loop | Journal-submission metadata under `_journal_*` / `_publ_*` categories. |
-| `project.verbosity` | `project.cif` | bare category | Project-owned fit-output verbosity category backed by `VerbosityEnum`. |
-| `project.structures[name]` | `structures/.cif` | `data_` | Each structure is one CIF data block. |
-| `project.experiments[name]` | `experiments/.cif` | `data_` | Each experiment is one CIF data block. |
-| `project.analysis` | `analysis/analysis.cif` | bare categories | Loader also accepts legacy root-level `analysis.cif`. |
-| `project.summary` | `summary.cif` | placeholder text | Summary persistence exists as a file but `summary_to_cif()` is not implemented yet. |
+| Current Python surface | Current saved location | Current CIF block form | Notes |
+| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------ | ----------------------------------------- | ---------------------------------------------------------------------------- |
+| `project.info`, `project.rendering_plot`, `project.report`, `project.rendering_table`, `project.rendering_structure`, `project.structure_view`, `project.structure_style`, `project.verbosity` | `project.cif` | bare categories | Project-level singleton config. |
+| `project.structures[name]` | `structures/.cif` | `data_` | Each structure is one CIF data block. |
+| `project.experiments[name]` | `experiments/.cif` | `data_` | Each experiment is one CIF data block. |
+| `project.analysis` | `analysis/analysis.cif` | bare categories | Loader also accepts legacy root-level `analysis.cif`. |
+| `project.report.save_*()` / `project.report.{cif,html,tex,pdf}` | `reports/` | multi-datablock CIF or rendered artifacts | Derived report outputs; generated only when configured or called explicitly. |
## Current Correspondence
### Project-Level Configuration
-| Current Python path | Current CIF path | Match? | Notes |
-| ---------------------------- | ------------------------ | ------ | -------------------------------------------------------------------------------------------------- |
-| `project.info.name` | `_project.id` | No | Python uses user-facing `name`; CIF uses `id`; category is `info` in Python but `_project` in CIF. |
-| `project.info.title` | `_project.title` | Partly | Field name matches, category name does not. |
-| `project.info.description` | `_project.description` | Partly | Field name matches, category name does not. |
-| `project.info.created` | `_project.created` | Partly | Field name matches, category name does not. |
-| `project.info.last_modified` | `_project.last_modified` | Partly | Field name matches, category name does not. |
-| `project.info.path` | none | No | Runtime storage path, not a CIF field. |
-| `project.chart.type` | `_chart.type` | Yes | Direct category-owned selector mapping. |
-| `project.report.*` | `_report.*` | Yes | Direct project-owned report-output configuration mapping. |
-| `project.publication.*` | `_journal.*` / `_publ_*` | Partly | Python keeps one owner with sibling categories; CIF uses journal and publication dictionary names. |
-| `project.table.type` | `_table.type` | Yes | Direct category-owned selector mapping. |
-| `project.verbosity.fit` | `_verbosity.fit` | Yes | Direct category and field mapping for fitting process output verbosity. |
+| Current Python path | Current CIF path | Match? | Notes |
+| ------------------------------------------------ | ----------------------------------------- | ------ | ----------------------------------------------------------------------------------- |
+| `project.info.name` | `_project.id` | No | Accepted exception: Python uses user-facing `name`; CIF uses semantic project `id`. |
+| `project.info.title` | `_project.title` | Partly | Accepted exception: field name matches, category name is semantic `_project`. |
+| `project.info.description` | `_project.description` | Partly | Accepted exception: field name matches, category name is semantic `_project`. |
+| `project.info.created` | `_project.created` | Partly | Accepted exception: field name matches, category name is semantic `_project`. |
+| `project.info.last_modified` | `_project.last_modified` | Partly | Accepted exception: field name matches, category name is semantic `_project`. |
+| `project.info.path` | none | No | Runtime storage path, not a CIF field. |
+| `project.rendering_plot.type` | `_rendering_plot.type` | Yes | Direct category-owned selector mapping. |
+| `project.report.cif` | `_report.cif` | Yes | Direct project-owned report-output configuration mapping. |
+| `project.report.html` | `_report.html` | Yes | Direct project-owned report-output configuration mapping. |
+| `project.report.tex` | `_report.tex` | Yes | Direct project-owned report-output configuration mapping. |
+| `project.report.pdf` | `_report.pdf` | Yes | Direct project-owned report-output configuration mapping. |
+| `project.report.html_offline` | `_report.html_offline` | Yes | Direct project-owned report-output configuration mapping. |
+| `project.rendering_table.type` | `_rendering_table.type` | Yes | Direct category-owned selector mapping. |
+| `project.rendering_structure.type` | `_rendering_structure.type` | Yes | Direct category-owned selector mapping. |
+| `project.structure_view.show_labels` | `_structure_view.show_labels` | Yes | Direct project-owned structure-view state mapping. |
+| `project.structure_view.show_moments` | `_structure_view.show_moments` | Yes | Direct project-owned structure-view state mapping. |
+| `project.structure_view.range_{a,b,c}_{min,max}` | `_structure_view.range_{a,b,c}_{min,max}` | Yes | Six scalar bounds; direct project-owned structure-view state mapping. |
+| `project.structure_style.atom_view` | `_structure_style.atom_view` | Yes | Direct project-owned structure-style value selector mapping. |
+| `project.structure_style.color_scheme` | `_structure_style.color_scheme` | Yes | Direct project-owned structure-style value selector mapping. |
+| `project.structure_style.adp_probability` | `_structure_style.adp_probability` | Yes | Direct project-owned structure-style numeric setting. |
+| `project.structure_style.atom_scale` | `_structure_style.atom_scale` | Yes | Direct project-owned structure-style numeric setting. |
+| `project.verbosity.fit` | `_verbosity.fit` | Yes | Direct category and field mapping for fitting process output verbosity. |
+| `project.verbosity = 'short'` | `_verbosity.fit` | Alias | Convenience setter only; canonical persisted path remains `project.verbosity.fit`. |
### Analysis Configuration
-| Current Python path | Current CIF path | Match? | Notes |
-| ------------------------------------------------- | ---------------------------------- | ------ | ------------------------------------------------------------------------------------------------ |
-| `analysis.minimizer.type` | `_minimizer.type` | Yes | Direct category-owned selector mapping. |
-| `analysis.fitting_mode.type` | `_fitting_mode.type` | Yes | Direct category-owned active-sibling selector mapping. |
-| `analysis.joint_fit[experiment_id].experiment_id` | `_joint_fit.experiment_id` | Yes | Collection key is also stored as a field. |
-| `analysis.joint_fit[experiment_id].weight` | `_joint_fit.weight` | Yes | Direct field mapping. |
-| `analysis.sequential_fit.data_dir` | `_sequential_fit.data_dir` | Yes | Direct category mapping. |
-| `analysis.sequential_fit.file_pattern` | `_sequential_fit.file_pattern` | Yes | Direct category mapping. |
-| `analysis.sequential_fit.max_workers` | `_sequential_fit.max_workers` | Yes | Direct category mapping. |
-| `analysis.sequential_fit.chunk_size` | `_sequential_fit.chunk_size` | Yes | Direct category mapping. |
-| `analysis.sequential_fit.reverse` | `_sequential_fit.reverse` | Yes | Direct category mapping. |
-| `analysis.sequential_fit_extract[id].id` | `_sequential_fit_extract.id` | Yes | Direct collection mapping. |
-| `analysis.sequential_fit_extract[id].target` | `_sequential_fit_extract.target` | Yes | Direct collection mapping. |
-| `analysis.sequential_fit_extract[id].pattern` | `_sequential_fit_extract.pattern` | Yes | Direct collection mapping. |
-| `analysis.sequential_fit_extract[id].required` | `_sequential_fit_extract.required` | Yes | Direct collection mapping. |
-| `analysis.aliases[label].label` | `_alias.label` | Partly | Python collection is plural; CIF row category is singular. |
-| `analysis.aliases[label].param_unique_name` | `_alias.param_unique_name` | Partly | Python collection is plural; CIF row category is singular. |
-| `analysis.constraints[lhs_alias].expression` | `_constraint.expression` | Partly | Collection key is derived from the expression; there is no separate `_constraint.lhs_alias` tag. |
+| Current Python path | Current CIF path | Match? | Notes |
+| ------------------------------------------------- | ---------------------------------- | ------ | --------------------------------------------------------------------------------------------------- |
+| `analysis.minimizer.type` | `_minimizer.type` | Yes | Direct category-owned selector mapping. |
+| `analysis.fitting_mode.type` | `_fitting_mode.type` | Yes | Direct category-owned active-sibling selector mapping. |
+| `analysis.fit_result.*` | `_fit_result.*` | Yes | Direct category mapping for scalar fit-result state; IUCr report export may use transformed tags. |
+| `analysis.fit_parameters[param].*` | `_fit_parameter.*` | Yes | Direct loop mapping for persisted per-parameter fit state. |
+| `analysis.fit_parameter_correlations[id].*` | `_fit_parameter_correlation.*` | Yes | Direct loop mapping for deterministic and posterior correlation summaries. |
+| `analysis.joint_fit[experiment_id].experiment_id` | `_joint_fit.experiment_id` | Yes | Collection key is also stored as a field. |
+| `analysis.joint_fit[experiment_id].weight` | `_joint_fit.weight` | Yes | Direct field mapping. |
+| `analysis.software.*` | `_software.*` | Yes | Direct analysis-tier software provenance mapping stamped at fit time. |
+| `analysis.sequential_fit.data_dir` | `_sequential_fit.data_dir` | Yes | Direct category mapping. |
+| `analysis.sequential_fit.file_pattern` | `_sequential_fit.file_pattern` | Yes | Direct category mapping. |
+| `analysis.sequential_fit.max_workers` | `_sequential_fit.max_workers` | Yes | Direct category mapping. |
+| `analysis.sequential_fit.chunk_size` | `_sequential_fit.chunk_size` | Yes | Direct category mapping. |
+| `analysis.sequential_fit.reverse` | `_sequential_fit.reverse` | Yes | Direct category mapping. |
+| `analysis.sequential_fit_extract[id].id` | `_sequential_fit_extract.id` | Yes | Direct collection mapping. |
+| `analysis.sequential_fit_extract[id].target` | `_sequential_fit_extract.target` | Yes | Direct collection mapping. |
+| `analysis.sequential_fit_extract[id].pattern` | `_sequential_fit_extract.pattern` | Yes | Direct collection mapping. |
+| `analysis.sequential_fit_extract[id].required` | `_sequential_fit_extract.required` | Yes | Direct collection mapping. |
+| `analysis.aliases[label].label` | `_alias.label` | Partly | Python collection is plural; CIF row category is singular. |
+| `analysis.aliases[label].param_unique_name` | `_alias.param_unique_name` | Partly | Python collection is plural; CIF row category is singular. |
+| `analysis.constraints[id].id` | `_constraint.id` | Yes | Direct explicit row-key mapping; older CIFs may backfill the id from the expression left-hand side. |
+| `analysis.constraints[id].expression` | `_constraint.expression` | Yes | Direct row-field mapping; `lhs_alias` and `rhs_expr` are derived Python helpers. |
### Experiment Configuration
@@ -184,85 +209,85 @@ to objects reached from the current `Project` root, for example
### Structure Configuration
-| Current Python path | Current CIF path | Match? | Notes |
-| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | --------------------------------------------------------------------------------- |
-| `structure.cell.length_a` | `_cell.length_a` | Yes | Direct category mapping. |
-| `structure.cell.length_b` | `_cell.length_b` | Yes | Direct category mapping. |
-| `structure.cell.length_c` | `_cell.length_c` | Yes | Direct category mapping. |
-| `structure.cell.angle_alpha` | `_cell.angle_alpha` | Yes | Direct category mapping. |
-| `structure.cell.angle_beta` | `_cell.angle_beta` | Yes | Direct category mapping. |
-| `structure.cell.angle_gamma` | `_cell.angle_gamma` | Yes | Direct category mapping. |
-| `structure.space_group.name_h_m` | `_space_group.name_H-M_alt`, `_space_group_name_H-M_alt`, `_symmetry.space_group_name_H-M`, or `_symmetry_space_group_name_H-M` | Partly | CIF naming follows crystallographic conventions and supports legacy alternatives. |
-| `structure.space_group.it_coordinate_system_code` | `_space_group.IT_coordinate_system_code`, `_space_group_IT_coordinate_system_code`, `_symmetry.IT_coordinate_system_code`, or `_symmetry_IT_coordinate_system_code` | Partly | CIF naming follows crystallographic conventions and supports legacy alternatives. |
-| `structure.atom_sites[label].label` | `_atom_site.label` | Yes | Direct row-field mapping. |
-| `structure.atom_sites[label].type_symbol` | `_atom_site.type_symbol` | Yes | Direct row-field mapping. |
-| `structure.atom_sites[label].fract_x` | `_atom_site.fract_x` | Yes | Direct row-field mapping. |
-| `structure.atom_sites[label].fract_y` | `_atom_site.fract_y` | Yes | Direct row-field mapping. |
-| `structure.atom_sites[label].fract_z` | `_atom_site.fract_z` | Yes | Direct row-field mapping. |
-| `structure.atom_sites[label].wyckoff_letter` | `_atom_site.Wyckoff_letter` or `_atom_site.Wyckoff_symbol` | Partly | CIF uses capitalized/legacy Wyckoff tags. |
-| `structure.atom_sites[label].occupancy` | `_atom_site.occupancy` | Yes | Direct row-field mapping. |
-| `structure.atom_sites[label].adp_iso` | `_atom_site.B_iso_or_equiv` or `_atom_site.U_iso_or_equiv` | No | Python uses type-neutral ADP name; CIF uses B/U-specific tags. |
-| `structure.atom_sites[label].adp_type` | `_atom_site.adp_type` | Yes | Direct row-field mapping. |
-| `structure.atom_site_aniso[label].label` | `_atom_site_aniso.label` | Yes | Direct row-field mapping. |
-| `structure.atom_site_aniso[label].adp_11` | `_atom_site_aniso.B_11` or `_atom_site_aniso.U_11` | No | Python uses type-neutral ADP name; CIF uses B/U-specific tags. |
-| `structure.atom_site_aniso[label].adp_22` | `_atom_site_aniso.B_22` or `_atom_site_aniso.U_22` | No | Python uses type-neutral ADP name; CIF uses B/U-specific tags. |
-| `structure.atom_site_aniso[label].adp_33` | `_atom_site_aniso.B_33` or `_atom_site_aniso.U_33` | No | Python uses type-neutral ADP name; CIF uses B/U-specific tags. |
-| `structure.atom_site_aniso[label].adp_12` | `_atom_site_aniso.B_12` or `_atom_site_aniso.U_12` | No | Python uses type-neutral ADP name; CIF uses B/U-specific tags. |
-| `structure.atom_site_aniso[label].adp_13` | `_atom_site_aniso.B_13` or `_atom_site_aniso.U_13` | No | Python uses type-neutral ADP name; CIF uses B/U-specific tags. |
-| `structure.atom_site_aniso[label].adp_23` | `_atom_site_aniso.B_23` or `_atom_site_aniso.U_23` | No | Python uses type-neutral ADP name; CIF uses B/U-specific tags. |
-
-### Not Yet Mapped
-
-| Current Python path | Current CIF status | Notes |
-| ------------------- | --------------------- | -------------------------------------------- |
-| `project.summary` | placeholder text only | `summary_to_cif()` currently returns a stub. |
-
-## Decision To Discuss
-
-Adopt a scoped one-to-one rule for project-level configuration:
+| Current Python path | Current CIF path | Match? | Notes |
+| ------------------------------------------------- | ---------------------------------------------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------- |
+| `structure.cell.length_a` | `_cell.length_a` | Yes | Direct category mapping. |
+| `structure.cell.length_b` | `_cell.length_b` | Yes | Direct category mapping. |
+| `structure.cell.length_c` | `_cell.length_c` | Yes | Direct category mapping. |
+| `structure.cell.angle_alpha` | `_cell.angle_alpha` | Yes | Direct category mapping. |
+| `structure.cell.angle_beta` | `_cell.angle_beta` | Yes | Direct category mapping. |
+| `structure.cell.angle_gamma` | `_cell.angle_gamma` | Yes | Direct category mapping. |
+| `structure.space_group.name_h_m` | `_space_group.name_H-M_alt` | Partly | Default write uses dictionary-canonical casing; legacy `_space_group_name_H-M_alt` and `_symmetry*` alternatives are accepted on read. |
+| `structure.space_group.it_coordinate_system_code` | `_space_group.IT_coordinate_system_code` | Partly | Default write uses dictionary-canonical casing; legacy underscore-form and `_symmetry*` alternatives are accepted on read. |
+| `structure.atom_sites[label].label` | `_atom_site.label` | Yes | Direct row-field mapping. |
+| `structure.atom_sites[label].type_symbol` | `_atom_site.type_symbol` | Yes | Direct row-field mapping. |
+| `structure.atom_sites[label].fract_x` | `_atom_site.fract_x` | Yes | Direct row-field mapping. |
+| `structure.atom_sites[label].fract_y` | `_atom_site.fract_y` | Yes | Direct row-field mapping. |
+| `structure.atom_sites[label].fract_z` | `_atom_site.fract_z` | Yes | Direct row-field mapping. |
+| `structure.atom_sites[label].wyckoff_letter` | `_atom_site.Wyckoff_symbol` | Partly | Default write uses dictionary-canonical tag; legacy `_atom_site.Wyckoff_letter` is accepted on read. |
+| `structure.atom_sites[label].occupancy` | `_atom_site.occupancy` | Yes | Direct row-field mapping. |
+| `structure.atom_sites[label].adp_iso` | `_atom_site.B_iso_or_equiv` or `_atom_site.U_iso_or_equiv` | No | Python uses type-neutral ADP name; CIF uses B/U-specific tags. |
+| `structure.atom_sites[label].adp_type` | `_atom_site.ADP_type` | Partly | Default write uses dictionary-canonical capitalization; legacy `_atom_site.adp_type` is accepted on read. |
+| `structure.atom_site_aniso[label].label` | `_atom_site_aniso.label` | Yes | Direct row-field mapping. |
+| `structure.atom_site_aniso[label].adp_11` | `_atom_site_aniso.B_11` or `_atom_site_aniso.U_11` | No | Python uses type-neutral ADP name; CIF uses B/U-specific tags. |
+| `structure.atom_site_aniso[label].adp_22` | `_atom_site_aniso.B_22` or `_atom_site_aniso.U_22` | No | Python uses type-neutral ADP name; CIF uses B/U-specific tags. |
+| `structure.atom_site_aniso[label].adp_33` | `_atom_site_aniso.B_33` or `_atom_site_aniso.U_33` | No | Python uses type-neutral ADP name; CIF uses B/U-specific tags. |
+| `structure.atom_site_aniso[label].adp_12` | `_atom_site_aniso.B_12` or `_atom_site_aniso.U_12` | No | Python uses type-neutral ADP name; CIF uses B/U-specific tags. |
+| `structure.atom_site_aniso[label].adp_13` | `_atom_site_aniso.B_13` or `_atom_site_aniso.U_13` | No | Python uses type-neutral ADP name; CIF uses B/U-specific tags. |
+| `structure.atom_site_aniso[label].adp_23` | `_atom_site_aniso.B_23` or `_atom_site_aniso.U_23` | No | Python uses type-neutral ADP name; CIF uses B/U-specific tags. |
+
+### Not Represented In V1
+
+| Candidate surface | Current CIF status | Notes |
+| ------------------------ | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `project.summary` | removed | Replaced by `project.report`; no `summary.cif` placeholder is written. |
+| `project.publication` | none | Rejected for v1 by the accepted project-summary-rendering ADR. |
+| journal/publication tags | not emitted | `_journal.*`, `_journal_date.*`, `_journal_coeditor.*`, `_publ_contact_author.*`, `_publ_author.*`, `_publ_body.*`, and `_pd_meas.info_author_*` placeholders are deferred and intentionally omitted while empty. |
+
+## Decision
+
+Adopt a scoped one-to-one rule for EasyDiffraction-owned project-level
+singleton configuration:
```text
project.. -> project.cif: _.
```
-This ADR does not propose renaming the public root object. The current
-root object is already `Project`; the proposal is about category and tag
-correspondence inside project-owned singleton configuration.
+The rule applies to:
+
+- `project.rendering_plot.type -> _rendering_plot.type`
+- `project.report.{cif,html,tex,pdf,html_offline} -> _report.*`
+- `project.rendering_table.type -> _rendering_table.type`
+- `project.rendering_structure.type -> _rendering_structure.type`
+- `project.structure_view.* -> _structure_view.*`
+- `project.structure_style.* -> _structure_style.*`
+- `project.verbosity.fit -> _verbosity.fit`
-The accepted baseline is:
+Keep `project.info` as the accepted exception:
```text
-project.info. -> project.cif: _project.
+project.info.name -> _project.id
+project.info.title -> _project.title
+project.info.description -> _project.description
+project.info.created -> _project.created
+project.info.last_modified -> _project.last_modified
```
-Future one-to-one correspondence work may still discuss whether the
-public identity field should be `name` or `id`, and whether verbosity
-should gain additional coverage-specific fields.
-
-Possible strict-correspondence target if a future ADR explicitly changes
-the accepted `_project.*` baseline:
+The exception is deliberate. `_project.*` is the semantic CIF category
+for scientific project identity in this project file, and `name` remains
+the user-facing Python property. This ADR does not rename `name` to
+`id`, does not rename `_project.*` to `_info.*`, and does not add an
+`_info.*` compatibility layer.
-| Python path | Target CIF path | Current state |
-| ---------------------------- | --------------------- | ------------------------------------------------ |
-| `project.info.name` | `_info.name` | Currently `_project.id`. |
-| `project.info.title` | `_info.title` | Currently `_project.title`. |
-| `project.info.description` | `_info.description` | Currently `_project.description`. |
-| `project.info.created` | `_info.created` | Currently `_project.created`. |
-| `project.info.last_modified` | `_info.last_modified` | Currently `_project.last_modified`. |
-| `project.chart.type` | `_chart.type` | Already matches. |
-| `project.table.type` | `_table.type` | Already matches. |
-| `project.verbosity.fit` | `_verbosity.fit` | Implemented direct fit-output verbosity mapping. |
+Do not force strict one-to-one correspondence globally. Analysis,
+experiment, structure, measured-data, calculated-data, and report-export
+categories may keep CIF-domain names where those names are clearer,
+dictionary-aligned, or intentionally different from Python convenience
+names.
-Alternative target if the project identity field should be called `id`
-rather than `name`:
-
-```text
-project.info.id -> _info.id
-```
-
-Do not force strict one-to-one correspondence globally where CIF-domain
-names are clearer or where the Python API intentionally abstracts over
-CIF details.
+Do not add a v1 `project.publication` owner or empty publication tags to
+make the correspondence table appear complete. Publication metadata is a
+future feature with its own sourcing and completeness questions.
## Rationale
@@ -272,12 +297,13 @@ Project-level configuration categories are not external crystallographic
CIF categories. They are EasyDiffraction project-file categories, so the
repository can optimize them for API/persistence symmetry.
-### `project.cif` Scopes Generic Categories
+### `project.cif` Scopes Project-Owned Categories
-`_info.title` is generic in isolation, but inside `project.cif` it reads
-as project information. This is similar to `_verbosity.fit`: the file
-scope tells the reader this is project-level verbosity, and the field
-name identifies the fitting-process coverage.
+`_report.cif`, `_structure_view.show_labels`, and `_verbosity.fit` are
+generic in isolation, but inside `project.cif` they read as
+project-level report, structure-view, and verbosity configuration. The
+file scope supplies the project root; the category and field names
+identify the specific setting.
### The Current `Project` Root Already Matches User Language
@@ -294,6 +320,14 @@ say that directly, while `_meta.project_id` and `_meta.project_title`
make the CIF less domain-oriented and repeat the concept in every item
name.
+### `project.info` Is A Deliberate Exception
+
+`project.info` is the user-facing Python grouping, but `_project.*` is
+the accepted CIF grouping for project identity. Preserving this
+exception avoids a beta-period churn-only rename from `name` to `id` in
+Python and avoids a persistence migration from `_project.*` to `_info.*`
+without a scientific benefit.
+
### Scientific CIF/Domain Categories Should Stay Domain-Oriented
For structures, experiments, measured data, and calculated results, many
@@ -312,6 +346,13 @@ switchable-category, backend, and active-sibling selector families.
These should remain exceptions unless a separate ADR changes the
underlying API pattern.
+### Convenience Aliases Do Not Define Persistence
+
+`project.verbosity = 'short'` remains acceptable as a user-facing
+shortcut because it writes the canonical `project.verbosity.fit` value.
+The persistence contract is still the category/field path, not every
+convenience setter that happens to reach it.
+
## Consequences
### Positive
@@ -320,25 +361,22 @@ underlying API pattern.
- Users can predict project-level CIF tags from Python paths.
- The decision can focus on project-owned singleton config without
forcing scientific CIF categories to mirror Python convenience names.
+- The clean-report decision remains intact: empty journal/publication
+ placeholders stay out of both `project.cif` and generated report CIFs.
### Trade-Offs
-- `_info.*` is less self-describing if copied out of `project.cif`.
-- Existing `_project.*` project files would need migration or a
- deliberate compatibility decision.
-- Persisted verbosity is now a category object. The initial field is
+- `project.info` does not follow the strict category-name rule; this
+ exception must be explained alongside the other project config.
+- Future publication metadata needs a separate ADR rather than a quiet
+ extension of this correspondence table.
+- Persisted verbosity remains a category object. The initial field is
`project.verbosity.fit`, leaving room for future coverage-specific
verbosity fields.
- Chart and table renderers are separate selector categories
- (`project.chart.type`, `project.table.type`), so a future collapsed
- renderer setting would need a separate ADR.
+ (`project.rendering_plot.type`, `project.rendering_table.type`), so a
+ future collapsed renderer setting would need a separate ADR.
## Open Questions
-- Should the project identity remain `project.info.name`, or should it
- become `project.info.id` to mirror the saved identifier field?
-- Should `project.chart.type` and `project.table.type` remain separate,
- or should the public API and CIF collapse to one renderer field?
-- Should `project.verbosity = 'short'` remain as a convenience alias for
- `project.verbosity.fit = 'short'`, or should strict correspondence
- remove the alias?
+None for this ADR.
diff --git a/docs/dev/adrs/accepted/selector-families.md b/docs/dev/adrs/accepted/selector-families.md
index 66fb655ff..1d84f0a33 100644
--- a/docs/dev/adrs/accepted/selector-families.md
+++ b/docs/dev/adrs/accepted/selector-families.md
@@ -38,11 +38,11 @@ or activates sibling categories.
Recognize three selector families:
-| Family | User intent | Examples |
-| ---------------------------- | ------------------------------- | ------------------------------------------------------------------------------- |
-| Backend selector | Pick an execution backend | `experiment.calculator.type`, `project.chart.type`, `project.table.type` |
-| Switchable-category selector | Swap a category implementation | `analysis.minimizer.type`, `experiment.background.type`, `experiment.peak.type` |
-| Active-sibling selector | Pick the active sibling surface | `analysis.fitting_mode.type` |
+| Family | User intent | Examples |
+| ---------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------- |
+| Backend selector | Pick an execution backend | `experiment.calculator.type`, `project.rendering_plot.type`, `project.rendering_table.type` |
+| Switchable-category selector | Swap a category implementation | `analysis.minimizer.type`, `experiment.background.type`, `experiment.peak.type` |
+| Active-sibling selector | Pick the active sibling surface | `analysis.fitting_mode.type` |
Backend selectors live on dedicated configuration categories.
Switchable-category selectors live on the category they replace, and the
diff --git a/docs/dev/adrs/accepted/switchable-category-owned-selectors.md b/docs/dev/adrs/accepted/switchable-category-owned-selectors.md
index 9207992e5..2d3ca926a 100644
--- a/docs/dev/adrs/accepted/switchable-category-owned-selectors.md
+++ b/docs/dev/adrs/accepted/switchable-category-owned-selectors.md
@@ -52,7 +52,7 @@ Three problems have accumulated since that ADR landed:
swapped category writes; `_calculation.calculator_type` lives inside
its category block but the descriptor name awkwardly repeats the noun
("calculator") instead of using a uniform `.type` selector.
- [`python-cif-category-correspondence.md`](../suggestions/python-cif-category-correspondence.md)
+ [`python-cif-category-correspondence.md`](python-cif-category-correspondence.md)
notes the inconsistency under §"Owner-level switchable selectors" and
tags it for a future ADR.
@@ -140,16 +140,16 @@ the `Calculation` category is renamed (see §8); one is new
(`_fitting_mode`) because the active-sibling selector is promoted to its
own category (also §8).
-| Today | Replacement | Mechanism family ¹ |
-| ------------------------------------- | -------------------- | ------------------ |
-| `_fitting.minimizer_type` | `_minimizer.type` | A |
-| `_peak.profile_type` | `_peak.type` | A |
-| (none — only `_pd_background.*` loop) | `_background.type` | A |
-| (none — only active-class fields) | `_extinction.type` | A |
-| `_calculation.calculator_type` | `_calculator.type` | B (and §8 rename) |
-| `_rendering.chart_engine` | `_chart.type` | B (and §8 split) |
-| `_rendering.table_engine` | `_table.type` | B (and §8 split) |
-| `_fitting.mode_type` | `_fitting_mode.type` | C (and §8 promote) |
+| Today | Replacement | Mechanism family ¹ |
+| ------------------------------------- | ----------------------- | ------------------ |
+| `_fitting.minimizer_type` | `_minimizer.type` | A |
+| `_peak.profile_type` | `_peak.type` | A |
+| (none — only `_pd_background.*` loop) | `_background.type` | A |
+| (none — only active-class fields) | `_extinction.type` | A |
+| `_calculation.calculator_type` | `_calculator.type` | B (and §8 rename) |
+| `_rendering.chart_engine` | `_rendering_plot.type` | B (and §8 split) |
+| `_rendering.table_engine` | `_rendering_table.type` | B (and §8 split) |
+| `_fitting.mode_type` | `_fitting_mode.type` | C (and §8 promote) |
¹ Mechanism family per [`selector-families.md`](selector-families.md): A
swaps the category instance, B swaps the live engine behind a singleton
@@ -461,11 +461,11 @@ Owners contribute only the filter dict via
`_supported_filters_for(category)`.
The Family-B swap hooks (e.g. `Experiment._swap_calculator`,
-`Project._swap_chart`, `Project._swap_table`) follow the same shape but
-rebind the live engine rather than the category instance. The Family-C
-swap hook (`Analysis._swap_fitting_mode`) performs the existing
-sibling-activation logic. The mixin does not care which mechanism the
-owner uses; it only routes the writable surface.
+`Project._swap_rendering_plot`, `Project._swap_rendering_table`) follow
+the same shape but rebind the live engine rather than the category
+instance. The Family-C swap hook (`Analysis._swap_fitting_mode`)
+performs the existing sibling-activation logic. The mixin does not care
+which mechanism the owner uses; it only routes the writable surface.
CIF read path becomes:
@@ -599,12 +599,13 @@ levels).
The `Rendering` category is **removed**. Two new sibling categories
appear on `Project`:
-- `project.chart` — `CategoryItem` with one writable selector `type`
- (`PlotterEngineEnum` plus the `'auto'` sentinel) and the live
- `Plotter` facade as a private internal. CIF block: `_chart.*`.
-- `project.table` — `CategoryItem` with one writable selector `type`
- (`TableEngineEnum` plus `'auto'`) and the live `TableRenderer` facade
- as a private internal. CIF block: `_table.*`.
+- `project.rendering_plot` — `CategoryItem` with one writable selector
+ `type` (`PlotterEngineEnum` plus the `'auto'` sentinel) and the live
+ `Plotter` facade as a private internal. CIF block:
+ `_rendering_plot.*`.
+- `project.rendering_table` — `CategoryItem` with one writable selector
+ `type` (`TableEngineEnum` plus `'auto'`) and the live `TableRenderer`
+ facade as a private internal. CIF block: `_rendering_table.*`.
Both follow the §4 mechanism — Family B (engine swap), `category.type`
surface — and become natural homes for future chart-only and table-only
@@ -614,8 +615,8 @@ descriptors (e.g. `chart.height`, `chart.theme`, `table.max_rows`,
The owner-level `project.rendering.show_chart_engines()`,
`project.rendering.show_table_engines()`, and
`project.rendering.show_config()` methods are deleted. Their
-replacements are `project.chart.show_supported()`,
-`project.table.show_supported()`, and (if needed) a thin
+replacements are `project.rendering_plot.show_supported()`,
+`project.rendering_table.show_supported()`, and (if needed) a thin
`project.show_config()` that prints both categories' current state.
#### 8b. `analysis.fitting_mode_type` → `analysis.fitting_mode.type`
@@ -662,7 +663,7 @@ binding — this is a Family-B engine-swap mechanism; only the user-facing
surface and the CIF block name change.
Affected ADRs: this rename adds a small amendment to
-[`python-cif-category-correspondence.md`](../suggestions/python-cif-category-correspondence.md)
+[`python-cif-category-correspondence.md`](python-cif-category-correspondence.md)
(category and CIF tag both move from `calculation` to `calculator`) and
to [`selector-families.md`](selector-families.md) (the example row for
Family B).
@@ -685,16 +686,16 @@ separate, smaller table.
### In scope — every selector with a writable type surface
-| # | Owner | Today | Proposed Python | CIF today | CIF proposed | Mech | Source |
-| --- | ---------- | ---------------------------------------------- | ---------------------------------- | --------------------------------------- | -------------------- | -------------- | ------------------------------------------------- |
-| 1 | analysis | `analysis.minimizer_type = 'X'` | `analysis.minimizer.type = 'X'` | `_fitting.minimizer_type` | `_minimizer.type` | A | `analysis/analysis.py:1022` |
-| 2 | experiment | `experiment.peak_profile_type = 'X'` | `experiment.peak.type = 'X'` | `_peak.profile_type` | `_peak.type` | A | `experiment/item/base.py:514` |
-| 3 | experiment | `experiment.background_type = 'X'` | `experiment.background.type = 'X'` | (none — only `_pd_background.*` loop) | `_background.type` | A | `experiment/item/bragg_pd.py:184` |
-| 4 | experiment | `experiment.extinction_type = 'X'` | `experiment.extinction.type = 'X'` | (none — only active class's own fields) | `_extinction.type` | A | `experiment/item/base.py:312` |
-| 5 | experiment | `experiment.calculation.calculator_type = 'X'` | `experiment.calculator.type = 'X'` | `_calculation.calculator_type` | `_calculator.type` | B + §8 rename | `experiment/categories/calculation/default.py:50` |
-| 6 | project | `project.rendering.chart_engine = 'X'` | `project.chart.type = 'X'` | `_rendering.chart_engine` | `_chart.type` | B + §8 split | `project/categories/rendering/default.py:100` |
-| 7 | project | `project.rendering.table_engine = 'X'` | `project.table.type = 'X'` | `_rendering.table_engine` | `_table.type` | B + §8 split | `project/categories/rendering/default.py:109` |
-| 8 | analysis | `analysis.fitting_mode_type = 'X'` | `analysis.fitting_mode.type = 'X'` | `_fitting.mode_type` | `_fitting_mode.type` | C + §8 promote | `analysis/analysis.py:960` |
+| # | Owner | Today | Proposed Python | CIF today | CIF proposed | Mech | Source |
+| --- | ---------- | ---------------------------------------------- | ------------------------------------ | --------------------------------------- | ----------------------- | -------------- | ------------------------------------------------- |
+| 1 | analysis | `analysis.minimizer_type = 'X'` | `analysis.minimizer.type = 'X'` | `_fitting.minimizer_type` | `_minimizer.type` | A | `analysis/analysis.py:1022` |
+| 2 | experiment | `experiment.peak_profile_type = 'X'` | `experiment.peak.type = 'X'` | `_peak.profile_type` | `_peak.type` | A | `experiment/item/base.py:514` |
+| 3 | experiment | `experiment.background_type = 'X'` | `experiment.background.type = 'X'` | (none — only `_pd_background.*` loop) | `_background.type` | A | `experiment/item/bragg_pd.py:184` |
+| 4 | experiment | `experiment.extinction_type = 'X'` | `experiment.extinction.type = 'X'` | (none — only active class's own fields) | `_extinction.type` | A | `experiment/item/base.py:312` |
+| 5 | experiment | `experiment.calculation.calculator_type = 'X'` | `experiment.calculator.type = 'X'` | `_calculation.calculator_type` | `_calculator.type` | B + §8 rename | `experiment/categories/calculation/default.py:50` |
+| 6 | project | `project.rendering.chart_engine = 'X'` | `project.rendering_plot.type = 'X'` | `_rendering.chart_engine` | `_rendering_plot.type` | B + §8 split | `project/categories/rendering/default.py:100` |
+| 7 | project | `project.rendering.table_engine = 'X'` | `project.rendering_table.type = 'X'` | `_rendering.table_engine` | `_rendering_table.type` | B + §8 split | `project/categories/rendering/default.py:109` |
+| 8 | analysis | `analysis.fitting_mode_type = 'X'` | `analysis.fitting_mode.type = 'X'` | `_fitting.mode_type` | `_fitting_mode.type` | C + §8 promote | `analysis/analysis.py:960` |
Mechanism legend (recap):
@@ -820,7 +821,7 @@ member and exposes `category.type` plus `category.show_supported()`.
Replace the `analysis.fitting_mode_type` description with
`analysis.fitting_mode.type` and document the new `FittingMode`
category (§8b).
-- [`python-cif-category-correspondence.md`](../suggestions/python-cif-category-correspondence.md)
+- [`python-cif-category-correspondence.md`](python-cif-category-correspondence.md)
— the §"Owner-level switchable selectors" table becomes obsolete;
remove the "deliberate abstraction" exception. Update every entry to
the `_.type` form.
@@ -837,15 +838,15 @@ member and exposes `category.type` plus `category.show_supported()`.
- [`display-ux.md`](display-ux.md) — replace every reference to
`project.rendering`, `_rendering.chart_engine`, and
`_rendering.table_engine` with the post-§8a shape:
- `project.chart.type`, `project.table.type`, CIF blocks `_chart.*` and
- `_table.*`. Drop the writable-selector contract that puts chart/table
- engines on the `rendering` category; document instead that each
- renderer lives on its own category with the canonical `category.type`
- surface.
+ `project.rendering_plot.type`, `project.rendering_table.type`, CIF
+ blocks `_rendering_plot.*` and `_rendering_table.*`. Drop the
+ writable-selector contract that puts chart/table engines on the
+ `rendering` category; document instead that each renderer lives on its
+ own category with the canonical `category.type` surface.
- [`category-owner-sections.md`](category-owner-sections.md) — update
the `ProjectConfig` children list: drop `Rendering`; add `Chart` and
`Table` as siblings. Update the `_rendering.*` CIF block reference to
- `_chart.*` and `_table.*`.
+ `_rendering_plot.*` and `_rendering_table.*`.
(A grep against `docs/dev/adrs/accepted/` for the renamed Python names
and CIF tags surfaced four additional hits that turned out to be generic
@@ -918,8 +919,8 @@ visible at a glance.
```
data_project
-_chart.type plotly
-_table.type rich
+_rendering_plot.type plotly
+_rendering_table.type rich
```
The `_rendering.*` block is gone; two single-purpose blocks replace it
@@ -1040,11 +1041,11 @@ project.experiments['hrpt'].background.create(id='1', order=0, coef=0.42)
project.experiments['hrpt'].calculator.show_supported()
project.experiments['hrpt'].calculator.type = 'cryspy'
-project.chart.show_supported()
-project.chart.type = 'plotly'
+project.rendering_plot.show_supported()
+project.rendering_plot.type = 'plotly'
-project.table.show_supported()
-project.table.type = 'rich'
+project.rendering_table.show_supported()
+project.rendering_table.type = 'rich'
# Family C — active-sibling selector (same surface)
project.analysis.fitting_mode.show_supported()
diff --git a/docs/dev/adrs/accepted/value-selector-discovery.md b/docs/dev/adrs/accepted/value-selector-discovery.md
new file mode 100644
index 000000000..93f982e52
--- /dev/null
+++ b/docs/dev/adrs/accepted/value-selector-discovery.md
@@ -0,0 +1,223 @@
+# ADR: Value-Selector Discovery
+
+## Status
+
+Accepted.
+
+## Date
+
+2026-05-31
+
+## Group
+
+User-facing API.
+
+## Implementation Note
+
+This ADR was accepted as part of the structure-view settings cleanup,
+which also amended
+[`crysview-structure-visualization.md`](crysview-structure-visualization.md)
+for the structure-view settings split; no separate
+`structure-view-settings` ADR exists.
+
+## Context
+
+EasyDiffraction is used by scientists who explore the API in notebooks,
+so every "pick one of a fixed set" choice should be discoverable in
+place.
+
+Two accepted ADRs set up that expectation but only half-deliver it:
+
+- [`enum-backed-closed-values.md`](enum-backed-closed-values.md)
+ requires every finite closed set to be a `(str, Enum)` and names
+ "finite choices are discoverable" as a consequence. It also makes enum
+ members the source of truth for **descriptions**.
+- [`switchable-category-owned-selectors.md`](switchable-category-owned-selectors.md)
+ gives the three category-level selector families from
+ [`selector-families.md`](selector-families.md) — backend,
+ switchable-category, and active-sibling — a uniform public shape:
+
+ ```python
+ category.type = 'new-type'
+ category.show_supported()
+ ```
+
+ All three are _category-level_ selectors: each has an owner
+ `_swap_` hook, the category **is** the single selector, and
+ `show_supported()` lives on the category.
+
+But many enumerated choices are **not** category-level selectors. They
+are plain enumerated _fields_ inside a category — there is no backend or
+category to swap, only a value the consumer reads. Examples today:
+
+- `structure_style.atom_view`, `structure_style.color_scheme`
+- the `experiment.experiment_type` axes (`sample_form`, `beam_mode`,
+ `radiation_probe`, `scattering_type`)
+- `verbosity` levels, and similar project-owned closed sets
+
+These value fields have **no discovery surface**. A scientist must read
+source or trigger a validation error to learn the options — exactly the
+gap the enum-backed ADR promised to close. They are declared ad hoc as
+`StringDescriptor(... MembershipValidator(allowed=[m.value for m in E]))`,
+duplicating the enum-to-allowed wiring at every site.
+
+Not every `MembershipValidator`-backed field qualifies. Some validate
+against **dynamic or external** sets rather than a project-owned closed
+enum: `atom_sites.type_symbol` (CrySPY isotope symbols from
+`DATABASE['Isotopes']`), `atom_sites.wyckoff_letter` (space-group
+dependent), `space_group.name_h_m` (CrySPY H-M symbols), and
+`space_group.it_coordinate_system_code` (derived from the current H-M
+symbol). These are boundary-facing CIF/science values, not `(str, Enum)`
+closed sets with a static `.default()`/`.description()`; they are out of
+scope here (see Decision and Deferred Work).
+
+## Decision
+
+Recognize a fourth selector shape — the **value selector** — and give it
+a discovery surface symmetric with the three category-level families.
+
+1. **Definition.** A value selector is an enumerated descriptor field
+ over a **project-owned, static `(str, Enum)`** closed set (per
+ [`enum-backed-closed-values.md`](enum-backed-closed-values.md)) whose
+ assignment sets a value (no class swap, no `_swap_` hook). It
+ is distinct from the three category-level families in
+ [`selector-families.md`](selector-families.md), which swap a category
+ instance, rebind a backend, or activate siblings. A field whose
+ allowed set is **dynamic, external, or context-dependent** (validated
+ against a database or another field rather than a closed enum) is
+ **not** a value selector and is out of scope — see Decision 4.
+
+2. **Discovery lives on the descriptor.** A value selector exposes
+ `show_supported()` on the descriptor itself, reusing the **same**
+ `render_table` presentation and `*`-marks-current convention that
+ category-level `show_supported()` uses, with the value column and
+ descriptions sourced from the enum (per
+ [`enum-backed-closed-values.md`](enum-backed-closed-values.md)):
+
+ ```python
+ project.structure_style.color_scheme = 'vesta' # set (string or member)
+ project.structure_style.color_scheme.show_supported()
+ # Color Scheme types
+ # Value Description
+ # jmol Jmol / CPK colour scheme
+ # * vesta VESTA colour scheme
+ ```
+
+ This sits beside the category-level shape, not replacing it:
+
+ ```python
+ project.rendering_structure.type = 'threejs' # category-level selector
+ project.rendering_structure.show_supported()
+ ```
+
+3. **One descriptor, one source of truth.** Introduce a core
+ `EnumDescriptor` bound to a `(str, Enum)` class. It derives the
+ `MembershipValidator` and the default (`Enum.default()`) from the
+ enum, stores the enum, and renders `show_supported()` from
+ `Enum.description`. It replaces the manual
+ `StringDescriptor + MembershipValidator(allowed=[...])` pattern at
+ every value-selector site, so the enum is wired once.
+
+4. **Scope.** A non-switchable field adopts `EnumDescriptor` **only when
+ its allowed set is a project-owned, static `(str, Enum)`** — e.g.
+ `atom_view`, `color_scheme`, the `experiment_type` axes, `verbosity`.
+ Each such enum must expose `.default()` and `.description`; add them
+ where missing. Explicitly **out of scope**:
+ - Category-level `.type` selectors (the three families) — unchanged;
+ they keep their category-level `show_supported()`.
+ - **Dynamic / external / context-dependent** membership validators —
+ `atom_sites.type_symbol`, `atom_sites.wyckoff_letter`,
+ `space_group.name_h_m`, `space_group.it_coordinate_system_code`,
+ and any field whose allowed values come from a database, another
+ field, or runtime context. These keep their existing
+ `MembershipValidator` and current validation behavior; they do
+ **not** become `EnumDescriptor`s and do **not** gain
+ `show_supported()` under this ADR. A separate dynamic-choice
+ discovery surface is deferred.
+
+ An implementation audit classifies each `MembershipValidator` field
+ as value selector, category-level selector, or dynamic/external
+ before any migration.
+
+5. **No category-level `show_supported()` on bundle categories.** A
+ category that holds several value selectors (e.g. `structure_style`,
+ `experiment.experiment_type`) does **not** gain a category-level
+ `show_supported()`. "Supported values of what?" has no single answer
+ on a multi-field bundle; discovery is per value selector. (A
+ clearly-named grouped overview may be added later for tightly-related
+ axis bundles — see Deferred Work.)
+
+6. **Numbers are not selectors.** Bounded numeric fields
+ (`adp_probability`, `atom_scale`, `range_*`, …) keep plain numeric
+ descriptors. Their limits live in the docstring and the validation
+ error; they have nothing to enumerate, so no `show_supported()`.
+
+7. **`help()` integration.** Because descriptors already expose `help()`
+ (per [`help-discoverability.md`](help-discoverability.md)),
+ `show_supported()` is surfaced automatically when a user calls
+ `help()` on the descriptor — `help()` says what the field is,
+ `show_supported()` lists its values.
+
+## Consequences
+
+- Every finite choice is discoverable at its own selector, finally
+ delivering the enum-backed ADR's "discoverable" promise for value
+ fields, with one table format shared across all selector shapes.
+- The public surface is uniform: _every_ selector answers
+ `show_supported()`. Category-level selectors answer at
+ `category.show_supported()`; value selectors at
+ `category.field.show_supported()`. The asymmetry is intentional — a
+ category-level selector _is_ the category, whereas a value field is
+ one of several on its category.
+- No deeper category tree: `show_supported()` is a method on the
+ descriptor the getter already returns, not a nested sub-category.
+- Immutable categories (per
+ [`immutable-experiment-type.md`](immutable-experiment-type.md)) still
+ expose `show_supported()` as read-only discovery, even where the value
+ is creation-time only.
+- This is a project-wide migration of the **enum-backed** value
+ selectors only (the audit pins the exact set; the dynamic/external
+ validators above are excluded and keep their current behavior).
+ Affected enums gain `.default()`/`.description` where missing. The
+ phased rollout also reorganized the structure-view categories that
+ motivated this ADR.
+
+## Alternatives Considered
+
+- **Promote each value selector to a switchable category.** Rejected:
+ the three families in [`selector-families.md`](selector-families.md)
+ swap a category instance, a backend, or siblings via a `_swap_`
+ hook; a colour scheme or atom-view mode swaps nothing. Forcing that
+ machinery would either nest a category under a category
+ (`structure_style.color_scheme.type` — a deeper tree, which violates
+ the flat-sibling rule) or pollute the top level
+ (`project.color_scheme`).
+- **Category-level `show_supported()` listing every enumerated field on
+ the bundle.** Rejected as the primary surface: ambiguous on a
+ multi-field category and redundant with the per-descriptor method.
+ Kept open only as an optional, clearly-named grouped overview for
+ related axis bundles (Deferred Work).
+- **Rely on `help()`, docstrings, and validation errors.** Rejected:
+ `help()` describes the field but does not enumerate accepted values
+ with the active one marked, and users should not have to trigger an
+ error to learn the options.
+- **Keep `StringDescriptor + MembershipValidator(allowed=[...])` and
+ bolt on `show_supported()` per site.** Rejected: duplicates the
+ enum-to-allowed wiring, is easy to forget, and has no single source of
+ truth. `EnumDescriptor` binds the enum once and derives validation,
+ default, and discovery together.
+
+## Deferred Work
+
+- A separate **dynamic-choice descriptor** giving a discovery surface (a
+ `show_supported()`-style listing) to fields whose allowed set is
+ dynamic, external, or context-dependent — `atom_sites.type_symbol`,
+ `atom_sites.wyckoff_letter`, `space_group.name_h_m`,
+ `space_group.it_coordinate_system_code`, and similar. Out of scope
+ here; these keep their current `MembershipValidator` until such a
+ descriptor exists.
+- An optional, clearly-named grouped overview for tightly-related axis
+ bundles (notably `experiment.experiment_type`'s four axes shown
+ together). Not part of the initial rollout.
+- Adopting `EnumDescriptor` at value-selector sites beyond those the
+ motivating plan touches, if any remain after the audit.
diff --git a/docs/dev/adrs/index.md b/docs/dev/adrs/index.md
index 2f86cd54b..9a04a6a6c 100644
--- a/docs/dev/adrs/index.md
+++ b/docs/dev/adrs/index.md
@@ -13,40 +13,43 @@ folders.
## ADR Index
-| Group | Status | Title | Short description | Link |
-| -------------------- | ---------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------- |
-| Analysis and fitting | Accepted | Fit Mode Categories and Fit Execution API | Splits fitting configuration from execution and defines active sibling fit-mode categories. | [`fit-mode-categories.md`](accepted/fit-mode-categories.md) |
-| Analysis and fitting | Accepted | Runtime Fit Results | Keeps full fit outputs runtime-only in the current design unless a narrower persistence ADR is accepted. | [`runtime-fit-results.md`](accepted/runtime-fit-results.md) |
-| Analysis and fitting | Accepted | Analysis CIF Fit State | Defines the persisted fit-state projection in `analysis/analysis.cif` and `analysis/results.h5`. | [`analysis-cif-fit-state.md`](accepted/analysis-cif-fit-state.md) |
-| Analysis and fitting | Accepted | Parameter Correlation Persistence | Persists deterministic and posterior correlation summaries in `_fit_parameter_correlation` | [`parameter-correlation-persistence.md`](accepted/parameter-correlation-persistence.md) |
-| Analysis and fitting | Suggestion | Fit Output Files and Data Exports | Narrows remaining archive/export questions after adopting `results.csv` and `results.h5`. | [`fit-output-files-and-data-exports.md`](suggestions/fit-output-files-and-data-exports.md) |
-| Analysis and fitting | Accepted | Minimizer Category Consolidation | Collapses the seven Bayesian categories into one owner-level switchable `minimizer` category with HDF5 sidecar. | [`minimizer-category-consolidation.md`](accepted/minimizer-category-consolidation.md) |
-| Analysis and fitting | Accepted | Minimizer Input/Output Split | Keeps `analysis.minimizer` input-only and moves scalar fit outputs to paired `analysis.fit_result` classes. | [`minimizer-input-output-split.md`](accepted/minimizer-input-output-split.md) |
-| Analysis and fitting | Superseded | Parameter-Level Posterior Projection | Superseded by minimizer-category consolidation; kept as historical context for `parameter.posterior`. | [`parameter-posterior-summary.md`](suggestions/parameter-posterior-summary.md) |
-| Analysis and fitting | Accepted | Undo Fit | Builds rollback semantics and CLI behavior on already-persisted pre-fit scalar snapshots. | [`undo-fit.md`](accepted/undo-fit.md) |
-| Core model | Accepted | Category Owners and Real Datablocks | Introduces `CategoryOwner` so singleton sections do not pretend to be real CIF datablocks. | [`category-owner-sections.md`](accepted/category-owner-sections.md) |
-| Core model | Accepted | Enum-Backed Closed Value Sets | Requires finite option sets to use `(str, Enum)` classes for validation and dispatch. | [`enum-backed-closed-values.md`](accepted/enum-backed-closed-values.md) |
-| Core model | Accepted | Guarded Public Properties | Uses property setters as the public writability contract for guarded objects. | [`guarded-public-properties.md`](accepted/guarded-public-properties.md) |
-| Core model | Accepted | Two-Level Category Parameter Access | Keeps parameter access to `datablock.category.parameter` or `datablock.collection[id].parameter`. | [`category-parameter-access.md`](accepted/category-parameter-access.md) |
-| Documentation | Accepted | Descriptor Property Docstring Template | Makes descriptor metadata the source of truth for public property docstrings and annotations. | [`property-docstring-template.md`](accepted/property-docstring-template.md) |
-| Documentation | Accepted | Development Documentation Structure | Defines the `docs/dev` layout for ADRs, issues, plans, package structure, and roadmap. | [`development-docs-structure.md`](accepted/development-docs-structure.md) |
-| Documentation | Accepted | Help Method Discoverability | Requires primary public objects and facades to expose consistent `help()` output. | [`help-discoverability.md`](accepted/help-discoverability.md) |
-| Documentation | Accepted | Notebook Generation Source of Truth | Treats tutorial `.py` files as editable sources and notebooks as generated artifacts. | [`notebook-generation.md`](accepted/notebook-generation.md) |
-| Experiment model | Accepted | Immutable Experiment Type | Makes experiment type axes creation-time state rather than mutable runtime state. | [`immutable-experiment-type.md`](accepted/immutable-experiment-type.md) |
-| Factories | Accepted | Factory Contracts and Metadata | Standardizes factory construction, metadata, compatibility, and registration behavior. | [`factory-contracts.md`](accepted/factory-contracts.md) |
-| Naming | Accepted | Factory Tag Naming | Defines canonical factory tag style and standard abbreviations. | [`factory-tag-naming.md`](accepted/factory-tag-naming.md) |
-| Persistence | Accepted | Free-Flag CIF Encoding | Encodes fit free/fixed state through CIF uncertainty syntax instead of a separate free list. | [`free-flag-cif-encoding.md`](accepted/free-flag-cif-encoding.md) |
-| Persistence | Accepted | Loop Category Keys and Identity Naming | Documents loop collection keys and naming rules aligned with CIF category keys. | [`loop-category-key-identity.md`](accepted/loop-category-key-identity.md) |
-| Persistence | Accepted | Project Facade and Persistence Layout | Documents the current `Project` facade and saved directory layout. | [`project-facade-and-persistence.md`](accepted/project-facade-and-persistence.md) |
-| Persistence | Accepted | IUCr CIF Tag Alignment | Aligns default CIF tags with IUCr dictionaries and adds an IUCr submission report export. | [`iucr-cif-tag-alignment.md`](accepted/iucr-cif-tag-alignment.md) |
-| Persistence | Suggestion | Python and CIF Category Correspondence | Compares current Python paths and CIF tags, then proposes scoped one-to-one mapping for project-level categories. | [`python-cif-category-correspondence.md`](suggestions/python-cif-category-correspondence.md) |
-| Quality | Accepted | Lint Complexity Thresholds | Treats ruff PLR complexity limits as design guardrails that should not be bypassed. | [`lint-complexity-thresholds.md`](accepted/lint-complexity-thresholds.md) |
-| Quality | Accepted | Test Strategy | Defines layered unit, functional, integration, script, and notebook testing. | [`test-strategy.md`](accepted/test-strategy.md) |
-| Structure model | Accepted | Type-Neutral ADP Parameters | Keeps ADP parameter object identities stable across B/U and iso/ani switches. | [`type-neutral-adp-parameters.md`](accepted/type-neutral-adp-parameters.md) |
-| User-facing API | Accepted | Display UX Facade | Defines `project.display` and `project.rendering` responsibilities and display method names. | [`display-ux.md`](accepted/display-ux.md) |
-| User-facing API | Accepted | Fit Results Display Naming | Short, IUCr/GUM-aligned column headers (`s.u.`, `value`, `95% CI`) with a footnote glossary on every fit table. | [`fit-results-display-naming.md`](accepted/fit-results-display-naming.md) |
-| User-facing API | Accepted | Project Summary Rendering | Defines project report configuration plus terminal, HTML, TeX, PDF, and publication metadata surfaces. | [`project-summary-rendering.md`](accepted/project-summary-rendering.md) |
-| User-facing API | Accepted | Selector Families | Distinguishes backend selectors, switchable-category selectors, and active-sibling selectors. | [`selector-families.md`](accepted/selector-families.md) |
-| User-facing API | Accepted | String Paths and Live Descriptors | Separates persisted field selectors from references to live model parameters. | [`string-paths-and-live-descriptors.md`](accepted/string-paths-and-live-descriptors.md) |
-| User-facing API | Accepted | Switchable Category API | Places multi-type category selectors on the owner and omits public selectors for fixed or single-type categories. | [`switchable-category-api.md`](accepted/switchable-category-api.md) |
-| User-facing API | Accepted | Switchable Category Owned Selectors | Moves the writable `type` selector and `show_supported()` onto the category itself; collapses the CIF duplication. | [`switchable-category-owned-selectors.md`](accepted/switchable-category-owned-selectors.md) |
+| Group | Status | Title | Short description | Link |
+| -------------------- | ---------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
+| Analysis and fitting | Accepted | Fit Mode Categories and Fit Execution API | Splits fitting configuration from execution and defines active sibling fit-mode categories. | [`fit-mode-categories.md`](accepted/fit-mode-categories.md) |
+| Analysis and fitting | Accepted | Runtime Fit Results | Keeps full fit outputs runtime-only in the current design unless a narrower persistence ADR is accepted. | [`runtime-fit-results.md`](accepted/runtime-fit-results.md) |
+| Analysis and fitting | Accepted | Analysis CIF Fit State | Defines the persisted fit-state projection in `analysis/analysis.cif` and `analysis/results.h5`. | [`analysis-cif-fit-state.md`](accepted/analysis-cif-fit-state.md) |
+| Analysis and fitting | Accepted | Parameter Correlation Persistence | Persists deterministic and posterior correlation summaries in `_fit_parameter_correlation` | [`parameter-correlation-persistence.md`](accepted/parameter-correlation-persistence.md) |
+| Analysis and fitting | Suggestion | Fit Output Files and Data Exports | Narrows remaining archive/export questions after adopting `results.csv` and `results.h5`. | [`fit-output-files-and-data-exports.md`](suggestions/fit-output-files-and-data-exports.md) |
+| Analysis and fitting | Accepted | Minimizer Category Consolidation | Collapses the seven Bayesian categories into one owner-level switchable `minimizer` category with HDF5 sidecar. | [`minimizer-category-consolidation.md`](accepted/minimizer-category-consolidation.md) |
+| Analysis and fitting | Accepted | Minimizer Input/Output Split | Keeps `analysis.minimizer` input-only and moves scalar fit outputs to paired `analysis.fit_result` classes. | [`minimizer-input-output-split.md`](accepted/minimizer-input-output-split.md) |
+| Analysis and fitting | Superseded | Parameter-Level Posterior Projection | Superseded by minimizer-category consolidation; kept as historical context for `parameter.posterior`. | [`parameter-posterior-summary.md`](suggestions/parameter-posterior-summary.md) |
+| Analysis and fitting | Accepted | Undo Fit | Builds rollback semantics and CLI behavior on already-persisted pre-fit scalar snapshots. | [`undo-fit.md`](accepted/undo-fit.md) |
+| Core model | Accepted | Category Owners and Real Datablocks | Introduces `CategoryOwner` so singleton sections do not pretend to be real CIF datablocks. | [`category-owner-sections.md`](accepted/category-owner-sections.md) |
+| Core model | Accepted | Enum-Backed Closed Value Sets | Requires finite option sets to use `(str, Enum)` classes for validation and dispatch. | [`enum-backed-closed-values.md`](accepted/enum-backed-closed-values.md) |
+| Core model | Accepted | Guarded Public Properties | Uses property setters as the public writability contract for guarded objects. | [`guarded-public-properties.md`](accepted/guarded-public-properties.md) |
+| Core model | Accepted | Two-Level Category Parameter Access | Keeps parameter access to `datablock.category.parameter` or `datablock.collection[id].parameter`. | [`category-parameter-access.md`](accepted/category-parameter-access.md) |
+| Documentation | Accepted | Descriptor Property Docstring Template | Makes descriptor metadata the source of truth for public property docstrings and annotations. | [`property-docstring-template.md`](accepted/property-docstring-template.md) |
+| Documentation | Accepted | Development Documentation Structure | Defines the `docs/dev` layout for ADRs, issues, plans, package structure, and roadmap. | [`development-docs-structure.md`](accepted/development-docs-structure.md) |
+| Documentation | Accepted | Help Method Discoverability | Requires primary public objects and facades to expose consistent `help()` output. | [`help-discoverability.md`](accepted/help-discoverability.md) |
+| Documentation | Accepted | Notebook Generation Source of Truth | Treats tutorial `.py` files as editable sources and notebooks as generated artifacts. | [`notebook-generation.md`](accepted/notebook-generation.md) |
+| Documentation | Suggestion | Documentation CI and Build Verification | Proposes strict MkDocs builds, API-derived docs, snippet smoke tests, link checks, and prose/spelling checks. | [`documentation-ci-build.md`](suggestions/documentation-ci-build.md) |
+| Experiment model | Accepted | Immutable Experiment Type | Makes experiment type axes creation-time state rather than mutable runtime state. | [`immutable-experiment-type.md`](accepted/immutable-experiment-type.md) |
+| Factories | Accepted | Factory Contracts and Metadata | Standardizes factory construction, metadata, compatibility, and registration behavior. | [`factory-contracts.md`](accepted/factory-contracts.md) |
+| Naming | Accepted | Factory Tag Naming | Defines canonical factory tag style and standard abbreviations. | [`factory-tag-naming.md`](accepted/factory-tag-naming.md) |
+| Persistence | Accepted | Free-Flag CIF Encoding | Encodes fit free/fixed state through CIF uncertainty syntax instead of a separate free list. | [`free-flag-cif-encoding.md`](accepted/free-flag-cif-encoding.md) |
+| Persistence | Accepted | Loop Category Keys and Identity Naming | Documents loop collection keys and naming rules aligned with CIF category keys. | [`loop-category-key-identity.md`](accepted/loop-category-key-identity.md) |
+| Persistence | Accepted | Project Facade and Persistence Layout | Documents the current `Project` facade and saved directory layout. | [`project-facade-and-persistence.md`](accepted/project-facade-and-persistence.md) |
+| Persistence | Accepted | IUCr CIF Tag Alignment | Aligns default CIF tags with IUCr dictionaries and adds a clean IUCr-aligned report export. | [`iucr-cif-tag-alignment.md`](accepted/iucr-cif-tag-alignment.md) |
+| Persistence | Accepted | Python and CIF Category Correspondence | Compares current Python paths and CIF tags, then records scoped one-to-one mapping for project-level categories. | [`python-cif-category-correspondence.md`](accepted/python-cif-category-correspondence.md) |
+| Quality | Accepted | Lint Complexity Thresholds | Treats ruff PLR complexity limits as design guardrails that should not be bypassed. | [`lint-complexity-thresholds.md`](accepted/lint-complexity-thresholds.md) |
+| Quality | Accepted | Test Strategy | Defines layered unit, functional, integration, script, and notebook testing. | [`test-strategy.md`](accepted/test-strategy.md) |
+| Structure model | Accepted | Type-Neutral ADP Parameters | Keeps ADP parameter object identities stable across B/U and iso/ani switches. | [`type-neutral-adp-parameters.md`](accepted/type-neutral-adp-parameters.md) |
+| User-facing API | Accepted | Crystal Structure 3D Visualization | Adds a renderer-neutral scene model drawn by ASCII and interactive Three.js engines for viewing crystal structures. | [`crysview-structure-visualization.md`](accepted/crysview-structure-visualization.md) |
+| User-facing API | Accepted | Display UX Facade | Defines `project.display` and `project.rendering` responsibilities and display method names. | [`display-ux.md`](accepted/display-ux.md) |
+| User-facing API | Accepted | Fit Results Display Naming | Short, IUCr/GUM-aligned column headers (`s.u.`, `value`, `95% CI`) with a footnote glossary on every fit table. | [`fit-results-display-naming.md`](accepted/fit-results-display-naming.md) |
+| User-facing API | Accepted | Project Summary Rendering | Defines project report configuration plus terminal, HTML, TeX, PDF, and clean report-CIF metadata policy. | [`project-summary-rendering.md`](accepted/project-summary-rendering.md) |
+| User-facing API | Accepted | Selector Families | Distinguishes backend selectors, switchable-category selectors, and active-sibling selectors. | [`selector-families.md`](accepted/selector-families.md) |
+| User-facing API | Accepted | String Paths and Live Descriptors | Separates persisted field selectors from references to live model parameters. | [`string-paths-and-live-descriptors.md`](accepted/string-paths-and-live-descriptors.md) |
+| User-facing API | Accepted | Switchable Category API | Places multi-type category selectors on the owner and omits public selectors for fixed or single-type categories. | [`switchable-category-api.md`](accepted/switchable-category-api.md) |
+| User-facing API | Accepted | Switchable Category Owned Selectors | Moves the writable `type` selector and `show_supported()` onto the category itself; collapses the CIF duplication. | [`switchable-category-owned-selectors.md`](accepted/switchable-category-owned-selectors.md) |
+| User-facing API | Accepted | Value-Selector Discovery | Gives enumerated value fields a per-descriptor `show_supported()`, beside the three category-level selector families. | [`value-selector-discovery.md`](accepted/value-selector-discovery.md) |
diff --git a/docs/dev/adrs/suggestions/documentation-ci-build.md b/docs/dev/adrs/suggestions/documentation-ci-build.md
new file mode 100644
index 000000000..f3a858832
--- /dev/null
+++ b/docs/dev/adrs/suggestions/documentation-ci-build.md
@@ -0,0 +1,189 @@
+# ADR: Documentation CI and Build Verification
+
+**Status:** Proposed
+**Date:** 2026-05-31
+
+## Group
+
+Documentation.
+
+## Context
+
+User-facing documentation can drift from the Python API, persisted CIF
+tags, tutorial outputs, and MkDocs navigation. Recent examples included
+stale selector methods, an outdated minimizer list, missing Quick
+Reference navigation, and code snippets that no longer matched current
+category-owned selectors.
+
+The documentation build should catch more of this drift before review.
+The checks should remain understandable to scientific contributors and
+should distinguish source documentation from generated notebooks.
+
+## Decision
+
+Add documentation verification in layers, starting with checks that are
+cheap, deterministic, and useful in local development.
+
+### 1. Build the MkDocs site in strict mode
+
+Run the docs build in CI with:
+
+```shell
+mkdocs build --strict
+```
+
+or the equivalent `pixi` task once one is added. This should catch
+missing navigation entries, broken internal references reported by
+MkDocs, and warnings that should fail documentation CI.
+
+### 2. Keep API reference generation source-driven
+
+Continue using source-driven API documentation. If the current API
+reference generation is not already based on `mkdocstrings`, adopt or
+standardize on `mkdocstrings[python]` so public signatures and docstring
+content are pulled from the installed package rather than copied into
+manual Markdown.
+
+### 3. Add snippet smoke tests for user-facing examples
+
+Add a small documentation smoke-test script that extracts or imports
+selected Python snippets from:
+
+- `docs/docs/quick-reference/index.md`
+- `docs/docs/user-guide/first-steps.md`
+- `docs/docs/user-guide/analysis-workflow/*.md`
+
+The smoke tests should focus on API shape, not full calculations. They
+should instantiate small projects, check public method names, and avoid
+network, notebooks, and real calculator backends unless explicitly
+covered by slower script or integration tests.
+
+### 4. Check generated tutorial freshness separately
+
+Tutorial notebooks remain generated artifacts. CI should verify that
+`pixi run notebook-prepare` leaves generated `.ipynb` files unchanged,
+or expose an explicit `notebook-prepare-check` task if the project wants
+a faster no-write mode.
+
+### 5. Check links with a dedicated link checker
+
+Use `lychee` or an equivalent link checker for Markdown and generated
+HTML links. Configure it with an allowlist for intentionally unstable or
+rate-limited external domains, and cache results where practical.
+
+### 6. Add prose and spelling checks incrementally
+
+Use `codespell` first for low-noise spelling checks. Consider `Vale`
+after the project has a small EasyDiffraction style vocabulary and an
+allowlist for crystallographic terms, package names, and CIF tags.
+
+## Options Considered
+
+### MkDocs strict build
+
+Pros:
+
+- aligns with the existing MkDocs build path
+- catches navigation and internal-reference problems early
+- low conceptual overhead for contributors
+
+Cons:
+
+- does not execute Python snippets
+- external URLs require a separate checker
+
+### mkdocstrings for API pages
+
+Pros:
+
+- keeps API reference tied to source signatures and docstrings
+- supports Python docstring styles already used by the project
+- reduces manual API copy/paste drift
+
+Cons:
+
+- requires a docs dependency if not already present
+- only helps reference pages, not narrative examples
+
+### Documentation snippet smoke tests
+
+Pros:
+
+- directly catches renamed methods and stale public API examples
+- can stay fast if limited to no-backend API construction
+- complements unit tests because it validates documented workflows
+
+Cons:
+
+- snippet extraction needs conventions or explicit markers
+- examples involving downloaded data or calculators need fixtures or
+ slower test tiers
+
+### lychee link checking
+
+Pros:
+
+- checks Markdown, HTML, and external URLs
+- has a GitHub Action and CLI workflow
+- can run on a schedule for external-link rot
+
+Cons:
+
+- external sites can be flaky or rate-limited
+- needs ignore rules for intentionally unreachable example URLs
+
+### Vale prose linting
+
+Pros:
+
+- catches grammar/style issues beyond spelling
+- supports project-specific style rules
+- can make docs more consistent for non-programmer users
+
+Cons:
+
+- needs careful configuration to avoid noisy scientific false positives
+- style-rule debates can slow feature reviews if introduced too broadly
+
+### codespell spelling checks
+
+Pros:
+
+- fast, simple, and available through pre-commit and CI
+- catches common typos in docs and code comments
+- lower adoption cost than full prose linting
+
+Cons:
+
+- needs ignore words for crystallography, CIF tags, names, and package
+ identifiers
+- does not catch grammar or stale API examples
+
+## Consequences
+
+### Positive
+
+- Documentation drift becomes visible before merge.
+- User-facing examples are more likely to match the current API.
+- Generated notebooks remain controlled by their existing
+ source-of-truth workflow.
+- Link and prose quality can improve without blocking on a full
+ documentation-system redesign.
+
+### Trade-offs
+
+- CI gains more jobs or longer docs jobs.
+- New tools require dependency and configuration maintenance.
+- External link checking can produce intermittent failures unless
+ scheduled, cached, or configured with retries and an allowlist.
+
+## Deferred Work
+
+- Decide whether link checking runs on every pull request, nightly, or
+ both.
+- Decide whether snippet smoke tests extract fenced code blocks
+ automatically or rely on explicitly named snippets.
+- Decide whether docs CI should build only source Markdown or also build
+ rendered notebooks.
+- Add the chosen checks to `pixi.toml`, CI configuration, and developer
+ documentation after this ADR is accepted.
diff --git a/docs/dev/benchmarking/20260531-230149_darwin-arm64_py314_tutorial-benchmarks.csv b/docs/dev/benchmarking/20260531-230149_darwin-arm64_py314_tutorial-benchmarks.csv
new file mode 100644
index 000000000..cb80e3bb9
--- /dev/null
+++ b/docs/dev/benchmarking/20260531-230149_darwin-arm64_py314_tutorial-benchmarks.csv
@@ -0,0 +1,26 @@
+tutorial_name,elapsed_seconds,status
+ed-1.py,15.289,ok
+ed-2.py,20.224,ok
+ed-3.py,35.924,ok
+ed-4.py,4.900,ok
+ed-5.py,44.665,ok
+ed-6.py,72.056,ok
+ed-7.py,123.342,ok
+ed-8.py,122.295,ok
+ed-9.py,9.181,ok
+ed-10.py,39.141,ok
+ed-11.py,10.198,ok
+ed-12.py,8.380,ok
+ed-13.py,25.059,ok
+ed-14.py,19.839,ok
+ed-15.py,27.564,ok
+ed-16.py,63.262,ok
+ed-17.py,83.813,ok
+ed-18.py,7.138,ok
+ed-20.py,40.791,ok
+ed-21.py,78.117,ok
+ed-22.py,38.354,ok
+ed-23.py,23.290,ok
+ed-24.py,4.912,ok
+ed-25.py,27.338,ok
+ed-26.py,28.586,ok
diff --git a/docs/dev/issues/closed.md b/docs/dev/issues/closed.md
index 4186146ed..c3574c2f2 100644
--- a/docs/dev/issues/closed.md
+++ b/docs/dev/issues/closed.md
@@ -6,24 +6,23 @@ Issues that have been fully resolved. Kept for historical reference.
## 103. Make `_sync_engine_from_minimizer_category` Skip-Keys Declarative
-Closed by [`emcee-minimizer.md`](../plans/emcee-minimizer.md). Minimizer
-categories now declare `_engine_sync_skip_keys`, and analysis sync
-filters against that set instead of hardcoding skipped keys.
+Closed by the emcee minimizer implementation. Minimizer categories now
+declare `_engine_sync_skip_keys`, and analysis sync filters against that
+set instead of hardcoding skipped keys.
---
## 101. Remove Dead Branch in `_fit_state_categories`
-Closed by [`emcee-minimizer.md`](../plans/emcee-minimizer.md). The
-deterministic branch that returned the same category list as the
-fallthrough path was removed while preserving unsupported `result_kind`
-warning behavior.
+Closed by the emcee minimizer implementation. The deterministic branch
+that returned the same category list as the fallthrough path was removed
+while preserving unsupported `result_kind` warning behavior.
---
## 100. Collapse Duplicate Predictive-Cache-Key Helpers
-Closed by [`emcee-minimizer.md`](../plans/emcee-minimizer.md).
+Closed by the emcee minimizer implementation.
`posterior_predictive_cache_key()` in `analysis.fit_helpers.bayesian` is
now the single helper used by analysis, plotting, and project display
code.
diff --git a/docs/dev/issues/open.md b/docs/dev/issues/open.md
index 81bbd9cb3..d1d400759 100644
--- a/docs/dev/issues/open.md
+++ b/docs/dev/issues/open.md
@@ -1790,6 +1790,120 @@ Requirements:
---
+## 108. 🟢 Smarter Automatic Bond Detection (Near-Neighbour Analysis)
+
+**Type:** UX / Visualization
+
+crysview generates bonds with the cif_core distance rule
+(`min_bond_distance_cutoff ≤ d ≤ r_bond(A) + r_bond(B) + bond_distance_incr`),
+then prunes to the first coordination shell — a contact survives only if
+it is within `1.3×` the nearer atom's nearest-neighbour distance
+(`COORDINATION_SHELL_FACTOR` in `display/structure/builder.py`). This
+stop-gap handles the common cases (e.g. LBCO renders just the Co–O
+octahedron) without a new dependency, but the fixed factor is still a
+heuristic: it can over-prune strongly distorted shells (e.g. elongated
+Jahn–Teller octahedra) or under-prune others, and it is not yet
+user-configurable.
+
+**Fix:** consider a robust, configurable near-neighbour algorithm for
+automatic "reasonable" bonding — e.g. a Voronoi / solid-angle method
+such as pymatgen's `CrystalNN` or `VoronoiNN`, which weights neighbours
+by solid angle instead of a single relative cutoff. The Voronoi route is
+the most robust across arbitrary structures but introduces a heavyweight
+dependency (pymatgen), so it needs a dependency decision; an
+ASE/Jmol-style multiplicative covalent tolerance is lighter but, like
+the current factor, cannot separate shells when ionic-cation covalent
+radii are large.
+
+**Depends on:** dependency decision for pymatgen (if the Voronoi route
+is chosen).
+
+---
+
+## 109. 🟢 Let More Tables Adapt to Terminal Width
+
+**Type:** UX / Display
+
+`list_tutorials` now renders its table at the real terminal width via a
+new optional `width` parameter threaded through the table render path
+(`render_table` → `TableRenderer.render` → backend `render`; Rich
+applies it, the HTML backend ignores it). Every other table and all log
+output still go through the shared Rich console, whose width is floored
+at `ConsoleManager._MIN_CONSOLE_WIDTH = 130` ("to avoid cramped
+layouts"). On a standard ~80-column terminal that floor makes wide
+tables overflow and soft-wrap badly.
+
+**Fix:** decide on a global policy — either have `_detect_width` trust
+the detected terminal width (keeping 130 only as a fallback when
+detection fails), or pass the terminal width into more table call sites
+the way `list_tutorials` now does. A global change affects every table
+(fit results, parameters, ...) and all logs, so weigh it against the
+deliberate minimum-width choice.
+
+**Depends on:** related to issue 62.
+
+---
+
+## 110. 🟢 Render Styled Multi-Line Table Cells in the HTML Backend
+
+**Type:** Display / Notebook parity
+
+`list_tutorials` shows a two-line cell in the terminal — a colored title
+on the first line and a dimmed description on the second — using Rich
+markup and an embedded newline. The Jupyter table backend
+(`PandasTableBackend`) cannot render this: `_strip_rich_markup` only
+matches a single full-cell `[color]text[/color]`, and HTML collapses the
+newline, so the markup would show as literal text. `list_tutorials` is
+therefore gated via `in_jupyter()` to show only the plain title in
+notebooks, which drops the description and the color there.
+
+**Fix:** teach the HTML backend to render the same styling — translate
+embedded newlines to ` `, map `[dim]` to reduced opacity, and accept
+multiple/mixed markup tags per cell — then remove the terminal-only gate
+in `list_tutorials` so notebooks also get the styled two-line entry.
+
+**Depends on:** related to issue 62.
+
+---
+
+## 111. 🟢 Add Test Coverage for `list_tutorials` Two-Line Rendering
+
+**Type:** Test coverage
+
+The `list_tutorials` table gained a styled two-line cell (colored title
+plus dimmed description), a terminal-only `in_jupyter()` gate that falls
+back to the plain title, and a new optional `width` parameter on the
+table render path. Existing tests only assert that titles appear in the
+output.
+
+**Fix:** add unit tests for the description line appearing in the
+terminal (non-Jupyter) path, the Jupyter-gated path showing the plain
+title with no literal Rich markup, and the `width` parameter sizing the
+rendered Rich table. Run `pixi run fix` / `check` / `unit-tests` to
+confirm the shared-renderer signature change.
+
+**Depends on:** nothing.
+
+---
+
+## 112. 🟢 Suppress the Redundant Row-Index Column in Tables
+
+**Type:** Display / UX
+
+`TableRenderer._prepare_dataframe` bumps the DataFrame index to 1-based,
+and both the Rich and pandas backends always render it as the first
+column. For tables that already carry an explicit identifier — e.g.
+`list_tutorials`, whose `id` column duplicates that 1-based counter —
+the leading index column is redundant and reads as a duplicate.
+
+**Fix:** add an opt-out (e.g. a `show_index` flag on the render path) so
+callers with their own id column can hide the auto-generated index, or
+only render the index column when no explicit id column is present.
+
+**Depends on:** nothing.
+
+---
+
## Summary
| # | Issue | Severity | Type |
@@ -1880,3 +1994,8 @@ Requirements:
| 105 | Remove orphaned fit-result reset helper | 🟢 Low | Cleanup |
| 106 | Document `FitResultBase.result_kind` default | 🟢 Low | Code readability |
| 107 | Validate CIF report vs IUCr dictionaries | 🟡 Med | Test coverage |
+| 108 | Smarter automatic bond detection (near-neighbour) | 🟢 Low | UX / Visualization |
+| 109 | Let more tables adapt to terminal width | 🟢 Low | UX / Display |
+| 110 | Styled multi-line table cells in HTML backend | 🟢 Low | Display / Notebook parity |
+| 111 | Test coverage for `list_tutorials` rendering | 🟢 Low | Test coverage |
+| 112 | Suppress redundant row-index column in tables | 🟢 Low | Display / UX |
diff --git a/docs/dev/package-structure/full.md b/docs/dev/package-structure/full.md
index c6d7fb0e7..1b3e42e91 100644
--- a/docs/dev/package-structure/full.md
+++ b/docs/dev/package-structure/full.md
@@ -254,6 +254,7 @@
│ ├── 🏷️ class GenericIntegerDescriptor
│ ├── 🏷️ class GenericParameter
│ ├── 🏷️ class StringDescriptor
+│ ├── 🏷️ class EnumDescriptor
│ ├── 🏷️ class BoolDescriptor
│ ├── 🏷️ class NumericDescriptor
│ ├── 🏷️ class IntegerDescriptor
@@ -452,6 +453,12 @@
│ │ │ │ │ └── 🏷️ class Cell
│ │ │ │ └── 📄 factory.py
│ │ │ │ └── 🏷️ class CellFactory
+│ │ │ ├── 📁 geom
+│ │ │ │ ├── 📄 __init__.py
+│ │ │ │ ├── 📄 default.py
+│ │ │ │ │ └── 🏷️ class Geom
+│ │ │ │ └── 📄 factory.py
+│ │ │ │ └── 🏷️ class GeomFactory
│ │ │ ├── 📁 space_group
│ │ │ │ ├── 📄 __init__.py
│ │ │ │ ├── 📄 default.py
@@ -482,6 +489,53 @@
│ │ └── 📄 plotly.py
│ │ ├── 🏷️ class PowderCompositeRows
│ │ └── 🏷️ class PlotlyPlotter
+│ ├── 📁 structure
+│ │ ├── 📁 assets
+│ │ │ ├── 📄 __init__.py
+│ │ │ ├── 📄 colors.py
+│ │ │ ├── 📄 elements.py
+│ │ │ └── 📄 radii.py
+│ │ ├── 📁 renderers
+│ │ │ ├── 📁 vendor
+│ │ │ │ └── 📁 threejs
+│ │ │ ├── 📄 __init__.py
+│ │ │ ├── 📄 ascii.py
+│ │ │ │ ├── 🏷️ class _Orientation
+│ │ │ │ └── 🏷️ class AsciiStructureRenderer
+│ │ │ ├── 📄 base.py
+│ │ │ │ └── 🏷️ class StructureRendererBase
+│ │ │ ├── 📄 raster.py
+│ │ │ │ ├── 🏷️ class _Canvas
+│ │ │ │ └── 🏷️ class RasterStructureRenderer
+│ │ │ └── 📄 threejs.py
+│ │ │ └── 🏷️ class ThreeJsStructureRenderer
+│ │ ├── 📁 templates
+│ │ ├── 📄 __init__.py
+│ │ ├── 📄 builder.py
+│ │ │ ├── 🏷️ class FeatureAvailability
+│ │ │ ├── 🏷️ class _RenderContext
+│ │ │ └── 🏷️ class _SceneAtom
+│ │ ├── 📄 enums.py
+│ │ │ ├── 🏷️ class ViewerEngineEnum
+│ │ │ ├── 🏷️ class AtomViewEnum
+│ │ │ └── 🏷️ class ColorSchemeEnum
+│ │ ├── 📄 scene.py
+│ │ │ ├── 🏷️ class AtomSphere
+│ │ │ ├── 🏷️ class OccupancyWedge
+│ │ │ ├── 🏷️ class OccupancyWedgeSphere
+│ │ │ ├── 🏷️ class AdpEllipsoid
+│ │ │ ├── 🏷️ class Bond
+│ │ │ ├── 🏷️ class MomentArrow
+│ │ │ ├── 🏷️ class CellEdge
+│ │ │ ├── 🏷️ class CellEdges
+│ │ │ ├── 🏷️ class AxisArrow
+│ │ │ ├── 🏷️ class AxisTriad
+│ │ │ ├── 🏷️ class TextLabel
+│ │ │ ├── 🏷️ class LegendEntry
+│ │ │ └── 🏷️ class StructureScene
+│ │ └── 📄 viewing.py
+│ │ ├── 🏷️ class ViewerFactory
+│ │ └── 🏷️ class Viewer
│ ├── 📁 tablers
│ │ ├── 📄 __init__.py
│ │ ├── 📄 base.py
@@ -514,6 +568,8 @@
│ │ ├── 🏷️ class TableEngineEnum
│ │ ├── 🏷️ class TableRenderer
│ │ └── 🏷️ class TableRendererFactory
+│ ├── 📄 theme.py
+│ │ └── 🏷️ class DisplayThemeColors
│ └── 📄 utils.py
│ └── 🏷️ class JupyterScrollManager
├── 📁 io
@@ -541,12 +597,6 @@
│ └── 📄 results_sidecar.py
├── 📁 project
│ ├── 📁 categories
-│ │ ├── 📁 chart
-│ │ │ ├── 📄 __init__.py
-│ │ │ ├── 📄 default.py
-│ │ │ │ └── 🏷️ class Chart
-│ │ │ └── 📄 factory.py
-│ │ │ └── 🏷️ class ChartFactory
│ │ ├── 📁 info
│ │ │ ├── 📄 __init__.py
│ │ │ ├── 📄 default.py
@@ -554,32 +604,43 @@
│ │ │ └── 📄 factory.py
│ │ │ └── 🏷️ class ProjectInfoFactory
│ │ ├── 📁 publication
+│ │ ├── 📁 rendering
+│ │ ├── 📁 rendering_plot
│ │ │ ├── 📄 __init__.py
│ │ │ ├── 📄 default.py
-│ │ │ │ ├── 🏷️ class PublicationItemBase
-│ │ │ │ ├── 🏷️ class PublicationJournal
-│ │ │ │ ├── 🏷️ class PublicationJournalDate
-│ │ │ │ ├── 🏷️ class PublicationJournalCoeditor
-│ │ │ │ ├── 🏷️ class PublicationContactAuthor
-│ │ │ │ ├── 🏷️ class PublicationBody
-│ │ │ │ ├── 🏷️ class PublicationAuthor
-│ │ │ │ ├── 🏷️ class PublicationAuthors
-│ │ │ │ └── 🏷️ class Publication
+│ │ │ │ └── 🏷️ class RenderingPlot
│ │ │ └── 📄 factory.py
-│ │ │ └── 🏷️ class PublicationFactory
-│ │ ├── 📁 rendering
+│ │ │ └── 🏷️ class RenderingPlotFactory
+│ │ ├── 📁 rendering_structure
+│ │ │ ├── 📄 __init__.py
+│ │ │ ├── 📄 default.py
+│ │ │ │ └── 🏷️ class RenderingStructure
+│ │ │ └── 📄 factory.py
+│ │ │ └── 🏷️ class RenderingStructureFactory
+│ │ ├── 📁 rendering_table
+│ │ │ ├── 📄 __init__.py
+│ │ │ ├── 📄 default.py
+│ │ │ │ └── 🏷️ class RenderingTable
+│ │ │ └── 📄 factory.py
+│ │ │ └── 🏷️ class RenderingTableFactory
│ │ ├── 📁 report
│ │ │ ├── 📄 __init__.py
│ │ │ ├── 📄 default.py
│ │ │ │ └── 🏷️ class Report
│ │ │ └── 📄 factory.py
│ │ │ └── 🏷️ class ReportFactory
-│ │ ├── 📁 table
+│ │ ├── 📁 structure_style
+│ │ │ ├── 📄 __init__.py
+│ │ │ ├── 📄 default.py
+│ │ │ │ └── 🏷️ class StructureStyle
+│ │ │ └── 📄 factory.py
+│ │ │ └── 🏷️ class StructureStyleFactory
+│ │ ├── 📁 structure_view
│ │ │ ├── 📄 __init__.py
│ │ │ ├── 📄 default.py
-│ │ │ │ └── 🏷️ class Table
+│ │ │ │ └── 🏷️ class StructureView
│ │ │ └── 📄 factory.py
-│ │ │ └── 🏷️ class TableFactory
+│ │ │ └── 🏷️ class StructureViewFactory
│ │ ├── 📁 verbosity
│ │ │ ├── 📄 __init__.py
│ │ │ ├── 📄 default.py
@@ -598,8 +659,7 @@
│ │ └── 🏷️ class Project
│ ├── 📄 project_config.py
│ │ └── 🏷️ class ProjectConfig
-│ ├── 📄 project_info.py
-│ └── 📄 publication_loader.py
+│ └── 📄 project_info.py
├── 📁 report
│ ├── 📁 templates
│ │ ├── 📁 html
diff --git a/docs/dev/package-structure/short.md b/docs/dev/package-structure/short.md
index 26f42e63d..23c0e68b8 100644
--- a/docs/dev/package-structure/short.md
+++ b/docs/dev/package-structure/short.md
@@ -214,6 +214,10 @@
│ │ │ │ ├── 📄 __init__.py
│ │ │ │ ├── 📄 default.py
│ │ │ │ └── 📄 factory.py
+│ │ │ ├── 📁 geom
+│ │ │ │ ├── 📄 __init__.py
+│ │ │ │ ├── 📄 default.py
+│ │ │ │ └── 📄 factory.py
│ │ │ ├── 📁 space_group
│ │ │ │ ├── 📄 __init__.py
│ │ │ │ ├── 📄 default.py
@@ -232,6 +236,26 @@
│ │ ├── 📄 ascii.py
│ │ ├── 📄 base.py
│ │ └── 📄 plotly.py
+│ ├── 📁 structure
+│ │ ├── 📁 assets
+│ │ │ ├── 📄 __init__.py
+│ │ │ ├── 📄 colors.py
+│ │ │ ├── 📄 elements.py
+│ │ │ └── 📄 radii.py
+│ │ ├── 📁 renderers
+│ │ │ ├── 📁 vendor
+│ │ │ │ └── 📁 threejs
+│ │ │ ├── 📄 __init__.py
+│ │ │ ├── 📄 ascii.py
+│ │ │ ├── 📄 base.py
+│ │ │ ├── 📄 raster.py
+│ │ │ └── 📄 threejs.py
+│ │ ├── 📁 templates
+│ │ ├── 📄 __init__.py
+│ │ ├── 📄 builder.py
+│ │ ├── 📄 enums.py
+│ │ ├── 📄 scene.py
+│ │ └── 📄 viewing.py
│ ├── 📁 tablers
│ │ ├── 📄 __init__.py
│ │ ├── 📄 base.py
@@ -242,6 +266,7 @@
│ ├── 📄 plotting.py
│ ├── 📄 progress.py
│ ├── 📄 tables.py
+│ ├── 📄 theme.py
│ └── 📄 utils.py
├── 📁 io
│ ├── 📁 cif
@@ -256,24 +281,33 @@
│ └── 📄 results_sidecar.py
├── 📁 project
│ ├── 📁 categories
-│ │ ├── 📁 chart
+│ │ ├── 📁 info
│ │ │ ├── 📄 __init__.py
│ │ │ ├── 📄 default.py
│ │ │ └── 📄 factory.py
-│ │ ├── 📁 info
+│ │ ├── 📁 publication
+│ │ ├── 📁 rendering
+│ │ ├── 📁 rendering_plot
│ │ │ ├── 📄 __init__.py
│ │ │ ├── 📄 default.py
│ │ │ └── 📄 factory.py
-│ │ ├── 📁 publication
+│ │ ├── 📁 rendering_structure
+│ │ │ ├── 📄 __init__.py
+│ │ │ ├── 📄 default.py
+│ │ │ └── 📄 factory.py
+│ │ ├── 📁 rendering_table
│ │ │ ├── 📄 __init__.py
│ │ │ ├── 📄 default.py
│ │ │ └── 📄 factory.py
-│ │ ├── 📁 rendering
│ │ ├── 📁 report
│ │ │ ├── 📄 __init__.py
│ │ │ ├── 📄 default.py
│ │ │ └── 📄 factory.py
-│ │ ├── 📁 table
+│ │ ├── 📁 structure_style
+│ │ │ ├── 📄 __init__.py
+│ │ │ ├── 📄 default.py
+│ │ │ └── 📄 factory.py
+│ │ ├── 📁 structure_view
│ │ │ ├── 📄 __init__.py
│ │ │ ├── 📄 default.py
│ │ │ └── 📄 factory.py
@@ -286,8 +320,7 @@
│ ├── 📄 display.py
│ ├── 📄 project.py
│ ├── 📄 project_config.py
-│ ├── 📄 project_info.py
-│ └── 📄 publication_loader.py
+│ └── 📄 project_info.py
├── 📁 report
│ ├── 📁 templates
│ │ ├── 📁 html
diff --git a/docs/dev/plans/iucr-cif-tag-alignment.md b/docs/dev/plans/iucr-cif-tag-alignment.md
deleted file mode 100644
index d1169e3c3..000000000
--- a/docs/dev/plans/iucr-cif-tag-alignment.md
+++ /dev/null
@@ -1,633 +0,0 @@
-# Plan: IUCr CIF Tag Alignment
-
-Implementation plan for the
-[`iucr-cif-tag-alignment`](../adrs/accepted/iucr-cif-tag-alignment.md)
-ADR. Follows [`AGENTS.md`](../../../AGENTS.md) — no deliberate
-exceptions to those instructions.
-
-## ADR cross-reference
-
-- Primary ADR: `iucr-cif-tag-alignment.md` (accepted; this plan promoted
- it from `suggestions/` during Phase 1).
-- Amends (per the ADR's "ADRs amended by this ADR" section):
- - [`analysis-cif-fit-state.md`](../adrs/accepted/analysis-cif-fit-state.md)
- — new `_fit_result.*` fields; topology-neutral default save.
- - [`minimizer-input-output-split.md`](../adrs/accepted/minimizer-input-output-split.md)
- — new `_fit_result.*` examples.
- - [`project-facade-and-persistence.md`](../adrs/accepted/project-facade-and-persistence.md)
- — `project.summary` removed and replaced by `project.report`;
- `summary.cif` no longer written.
- - [`help-discoverability.md`](../adrs/accepted/help-discoverability.md)
- — `project.summary.help()` → `project.report.help()`.
-
-## Branch and PR
-
-- Branch: `iucr-cif-tag-alignment` (already checked out, matches the
- slug).
-- PR target: `develop`.
-- Do not push the branch until both Phase 1 and Phase 2 review cycles
- close.
-
-## Decisions already made (in the ADR)
-
-These are settled by the accepted ADR — the plan does not re-litigate
-them, only implements them:
-
-- **Three-tier default save:** structure-tier IUCr alignment with casing
- fixes; analysis-tier topology-neutral `_fit_result.*` with
- dictionary-canonical _item_ names (uppercase R / wR / DOI);
- experiment-tier unchanged. Per-topology category split (`_refine_ls.*`
- / `_pd_proc_ls.*` / `_reflns.*`) happens only in the IUCr export.
-- **Reports system:** new `project.report` facade slot replaces the
- unimplemented `project.summary` placeholder; single
- `reports/.cif` file with multi-datablock layout always
- starting with `data_global`. `project.save(report=True)` and
- `project.report.save()` produce the file; `project.report.check()`
- validates via gemmi against `cif_core.dic` / `cif_pow.dic`.
-- **Handler mechanism:** per-field `iucr_name` (optional, falls back to
- `names[0]`) plus category-level `IucrCategoryTransformer` subclasses
- for wavelength, TOF calibration, excluded regions, symmetry
- operations, and extinction reshaping.
-- **ADP single-tag emission:** emit `B_*` xor `U_*` per row based on
- `_atom_site.ADP_type`; both forms still accepted on read.
-- **Loop-tag style:** dotted DDLm everywhere on write; both forms on
- read.
-- **`_easydiffraction_software`** umbrella category with three free-text
- fields (`framework`, `calculator`, `minimizer`), plus a derived
- `_computing.structure_refinement` string in the IUCr export.
-- **gemmi** is already a project dependency (`pyproject.toml:39`); no
- new dependencies needed.
-
-## Open questions to resolve during implementation
-
-- **Pixi env:** confirm `gemmi.cif.read_doc` and the dictionary
- validation path are available in the project's pinned gemmi version
- before P1.16 (validator implementation). If the pinned version is too
- old, bump the constraint in `pyproject.toml` (counts as a plan-named
- dependency change — pre-approved per AGENTS.md §Architecture because
- the package is already named).
-- **Tutorial scope:** identify every tutorial source under
- `docs/docs/tutorials/*.py` that references `project.summary` and
- update them in P1.17. If a tutorial currently uses
- `project.summary.help()` as a discoverability example, it becomes
- `project.report.help()`; if a tutorial expects `summary.cif`, swap to
- `project.save(report=True)` and reference `reports/.cif`.
-- **Extinction transformer detail:** the `(type, model)` →
- `_refine_ls.extinction_method` descriptive string in §3 of the ADR
- uses a Becker-Coppens taxonomy table; the implementation needs the
- project's existing extinction-model selector enums to map cleanly.
- Verify enum values during P1.14.
-
-## Concrete files likely to change
-
-Foundation:
-
-- `src/easydiffraction/io/cif/handler.py` — `CifHandler` gains
- `iucr_name` parameter.
-
-Structure tier (casing):
-
-- `src/easydiffraction/datablocks/structure/categories/atom_sites/default.py`
-- `src/easydiffraction/datablocks/structure/categories/space_group/default.py`
-
-ADP write-side:
-
-- `src/easydiffraction/io/cif/serialize.py` (atom_site / atom_site_aniso
- write path)
-
-Analysis tier (new `_fit_result.*` fields):
-
-- `src/easydiffraction/analysis/categories/fit_result/lsq.py`
-- `src/easydiffraction/analysis/categories/fit_result/base.py`
-- `src/easydiffraction/analysis/fit/` (residual / aggregate computation
- site; exact module determined during P1.4)
-
-Project-extension `iucr_name` settings (P1.7):
-
-- The descriptor files enumerated in P1.7 (one `cif_handler` call per
- descriptor across the analysis / experiment categories — no new
- modules, no new packages). The software triple itself is built inline
- by the IUCr writer (P1.11) and does **not** introduce a new category
- class or default-save persistence.
-
-Facade rename `project.summary` → `project.report` (P1.8):
-
-- `src/easydiffraction/project/project.py` (replace the `summary`
- property and `summary.cif` write with the new `report` facade; drop
- the `as_cif()` caller; add the `report: bool = False` keyword to
- `Project.save()`).
-- `src/easydiffraction/summary/` → migrated to
- `src/easydiffraction/report/` (either rename the package and class, or
- add a fresh `report/` package whose `Report` delegates to the migrated
- display methods — implementer's choice). Every live display method
- (`show_report`, `show_project_info`, `show_crystallographic_data`,
- `show_experimental_data`, `show_fitting_details`) is preserved
- verbatim; only the placeholder `as_cif()` is dropped.
-- `src/easydiffraction/report/__init__.py`, `report.py` — destination of
- the migration.
-
-IUCr writer:
-
-- `src/easydiffraction/io/cif/iucr_writer.py` (new)
-- `src/easydiffraction/io/cif/iucr_transformers.py` (new — holds
- `IucrCategoryTransformer` subclasses)
-
-Validation:
-
-- `src/easydiffraction/report/check.py` (new — wraps `gemmi`)
-
-Amended ADRs:
-
-- `docs/dev/adrs/accepted/analysis-cif-fit-state.md`
-- `docs/dev/adrs/accepted/minimizer-input-output-split.md`
-- `docs/dev/adrs/accepted/project-facade-and-persistence.md`
-- `docs/dev/adrs/accepted/help-discoverability.md`
-
-ADR promotion:
-
-- `docs/dev/adrs/suggestions/iucr-cif-tag-alignment.md` → moved to
- `docs/dev/adrs/accepted/iucr-cif-tag-alignment.md` with status flipped
- to Accepted.
-- `docs/dev/adrs/index.md` (index row updated).
-
-Tutorials / CLI:
-
-- `docs/docs/tutorials/*.py` (regenerate notebooks after edits via
- `pixi run notebook-prepare`).
-- `src/easydiffraction/cli/` (any command that surfaces
- `project.summary`).
-
-## Commit discipline
-
-When an AI agent follows this plan, **every completed Phase 1
-implementation step must be staged with explicit paths and committed
-locally before moving to the next implementation step or the Phase 1
-review gate.** Follow the rules in [`AGENTS.md`](../../../AGENTS.md) →
-**Commits**. Keep commits atomic, single-purpose, and aligned with the
-plan steps. Do not include generated artifacts (data CIFs, project
-directories, benchmark CSVs) unless the step explicitly produces them —
-see **Workflow** in [`AGENTS.md`](../../../AGENTS.md) for the
-generated-artifact exceptions.
-
-## Implementation steps (Phase 1)
-
-- [x] **P1.1 — Extend `CifHandler` with `iucr_name`**
- - File: `src/easydiffraction/io/cif/handler.py`.
- - Add an optional keyword `iucr_name: str | None = None` to
- `CifHandler.__init__`.
- - Add a public property / method returning the IUCr-side tag:
- `iucr_name` when set, else `names[0]`.
- - No call sites change in this step — the default fallback leaves
- every existing handler emitting its current name.
- - Commit: `Add iucr_name to CifHandler`.
-
-- [x] **P1.2 — Structure-tier casing fixes**
- - Files:
- `src/easydiffraction/datablocks/structure/categories/atom_sites/default.py`,
- `src/easydiffraction/datablocks/structure/categories/space_group/default.py`.
- - Rename canonical CIF tags to dictionary-canonical casing
- (`_atom_site.ADP_type`, `_atom_site.Wyckoff_symbol`,
- `_space_group.name_H-M_alt`,
- `_space_group.IT_coordinate_system_code`). Python attribute names
- stay lowercase.
- - Keep the old (lowercase / `wyckoff_letter`) forms in each
- `CifHandler.names` list as read-only aliases so loading legacy files
- still works.
- - Commit: `Adopt IUCr casing for atom_site and space_group CIF tags`.
-
-- [x] **P1.3 — ADP single-tag emission per row**
- - File: `src/easydiffraction/io/cif/serialize.py` (and any helper
- called by it).
- - When emitting `_atom_site_aniso.*` and `_atom_site.B_iso_or_equiv` /
- `_atom_site.U_iso_or_equiv`, choose `B_*` or `U_*` per row based on
- `_atom_site.ADP_type`; omit the other family for that row.
- - Read side unchanged (both families still accepted).
- - Commit: `Emit one ADP family per atom_site row on save`.
-
-- [x] **P1.4 — Analysis tier: new `_fit_result.*` fields**
- - Files: `src/easydiffraction/analysis/categories/fit_result/lsq.py`,
- `src/easydiffraction/analysis/categories/fit_result/base.py`, plus
- the fit-computation site (search for where `n_data_points`,
- `reduced_chi_square` are currently populated).
- - Declare new descriptors under `_fit_result.*` with
- dictionary-canonical _item_ names (uppercase R / wR):
- `R_factor_all`, `wR_factor_all`, `R_factor_gt`, `wR_factor_gt`,
- `prof_R_factor`, `prof_wR_factor`, `prof_wR_expected`,
- `number_restraints`, `number_constraints`, `shift_over_su_max`,
- `shift_over_su_mean`, `profile_function`, `background_function`,
- `threshold_expression`, `number_reflns_total`, `number_reflns_gt`.
- - Wire computation: R-factors from residuals; restraint / constraint
- counts from the analysis model; profile / background descriptors
- from the active peak / background categories; reflns aggregates from
- refln data.
- - Fields not meaningful for a given fit (e.g. `prof_R_factor` for SC)
- stay unset / `None`.
- - Commit:
- `Add IUCr-canonical fit_result fields to LeastSquaresFitResult`.
-
-- [x] **P1.5 — Amend `analysis-cif-fit-state.md`**
- - File: `docs/dev/adrs/accepted/analysis-cif-fit-state.md`.
- - Document the new `_fit_result.*` fields, the topology-neutral
- default-save policy, and the per-topology IUCr-export remapping
- deferred to §3 of the iucr-cif-tag-alignment ADR.
- - Commit:
- `Amend analysis-cif-fit-state ADR for new fit_result fields`.
-
-- [x] **P1.6 — Amend `minimizer-input-output-split.md`**
- - File: `docs/dev/adrs/accepted/minimizer-input-output-split.md`.
- - Update the `_fit_result.*` examples in §3 to reflect the new field
- set from P1.4.
- - Commit: `Amend minimizer-input-output-split ADR examples`.
-
-- [x] **P1.7 — Set `iucr_name` on project-extension descriptors**
- - No new category. The ADR's `_easydiffraction_software` triple is a
- **report-only projection**: it is derived inline by the IUCr writer
- in P1.11 from existing state (`easydiffraction` package version,
- `project.analysis.calculator.type`,
- `project.analysis.minimizer.type`, and per-backend version
- metadata). It is **not** persisted to `analysis/analysis.cif` and no
- new category class is added.
- - This step's actual work is the `_easydiffraction_*` prefix rename
- for existing project-extension descriptors at IUCr export time. For
- each project-extension category, set the matching descriptor's
- `cif_handler` `iucr_name` to the prefixed form so the IUCr writer
- emits `_easydiffraction_.` while the default save
- keeps the bare-category form (`_minimizer.*`, `_calculator.*`,
- etc.).
- - Touched descriptors:
- - `_minimizer.*` → `iucr_name='_easydiffraction_minimizer.*'`
- (settings only — type, tolerance, max_iter, …).
- - `_calculator.*` → `iucr_name='_easydiffraction_calculator.*'`.
- - `_fitting_mode.*` → `iucr_name='_easydiffraction_fitting_mode.*'`.
- - `_alias.*` → `iucr_name='_easydiffraction_alias.*'`.
- - `_constraint.*` → `iucr_name='_easydiffraction_constraint.*'`.
- - `_joint_fit.*` → `iucr_name='_easydiffraction_joint_fit.*'`.
- - `_sequential_fit.*`, `_sequential_fit_extract.*` →
- `iucr_name='_easydiffraction_sequential_fit*.*'`.
- - `_expt_type.*` → `iucr_name='_easydiffraction_experiment_type.*'`.
- - `_excluded_region.*` →
- `iucr_name='_easydiffraction_excluded_region.*'`.
- - `_peak.*` → `iucr_name='_easydiffraction_peak.*'`.
- - `_extinction.*` (project-side selectors and parameters) →
- `iucr_name='_easydiffraction_extinction.*'` (the transformer in
- P1.14 also dual-emits the coreCIF `_refine_ls.extinction_*`
- triple).
- - `_background.type` →
- `iucr_name='_easydiffraction_background.type'`.
- - `_sc_crystal_block.*` →
- `iucr_name='_easydiffraction_sc_crystal_block.*'`.
- - Bayesian-only `_fit_result.*` fields →
- `iucr_name='_easydiffraction_fit_result.*'` (project extensions
- per ADR §3.3).
- - The non-IUCr-counterpart `_diffrn.ambient_magnetic_field` and
- `_diffrn.ambient_electric_field` descriptors get
- `iucr_name='_easydiffraction_diffrn.ambient_magnetic_field'` /
- `…electric_field`.
- - Default-save behaviour is unchanged for every touched descriptor —
- only the IUCr-export emission picks up the new prefix.
- - Commit: `Set iucr_name on project-extension descriptors`.
-
-- [x] **P1.8 — Rename `project.summary` → `project.report`, preserve
- display methods**
- - Files: `src/easydiffraction/project/project.py`,
- `src/easydiffraction/summary/` (renamed / migrated),
- `src/easydiffraction/report/` (new).
- - **Preserve every live `Summary` method.** The existing `Summary`
- class (at `src/easydiffraction/summary/summary.py`) has live
- user-facing display methods that tutorials call: `show_report()`,
- `show_project_info()`, `show_crystallographic_data()`,
- `show_experimental_data()`, `show_fitting_details()`. These are not
- placeholders and must not be removed. Move them verbatim onto the
- new `Report` class (rename of the class only, with no behavioural
- change), so `project.report.show_report()` and friends remain
- available with the same signatures and output.
- - **Drop only the placeholder CIF method.** The `as_cif()` method on
- `Summary` returns a stub string and is the only placeholder being
- removed. Its caller — the `summary.cif` write at `Project.save()`
- (currently at `src/easydiffraction/project/project.py:464`-ish) — is
- removed in the same commit. The real CIF emission for journal
- submission lives in `Report.save()` (writes `reports/.cif`,
- real implementation lands in P1.15) — the two are independent:
- dropping `as_cif()` does not break any user-visible behaviour
- because nothing meaningful was being written.
- - Migration mechanics:
- - Either rename the package directory
- (`src/easydiffraction/summary/` → `src/easydiffraction/report/`)
- and class (`Summary` → `Report`) in one move, **or** add a new
- `src/easydiffraction/report/report.py` whose `Report` class
- inherits / delegates to the migrated display methods. Pick the
- approach that produces the smallest diff at review time; both keep
- the display behaviour intact.
- - Update `__init__.py` re-exports accordingly.
- - `Project` gains a `report` property returning the `Report` instance;
- the old `summary` property is removed. The `summary.cif` write call
- in `Project.save()` is removed.
- - `Project.save()` gains a `report: bool = False` keyword (no
- behaviour yet beyond passing through to `Report.save()` when truthy;
- the real `Report.save()` lands in P1.15).
- - Tutorial / CLI call sites that invoke
- `project.summary.show_report()` are updated in **P1.17**.
- - Commit: `Replace project.summary with project.report facade`.
-
-- [x] **P1.9 — Amend `project-facade-and-persistence.md`**
- - File: `docs/dev/adrs/accepted/project-facade-and-persistence.md`.
- - Document the `project.report` facade slot, removal of
- `project.summary`, removal of `summary.cif` from default saves, and
- the new `reports/.cif` output path.
- - Commit:
- `Amend project-facade-and-persistence ADR for project.report`.
-
-- [x] **P1.10 — Amend `help-discoverability.md`**
- - File: `docs/dev/adrs/accepted/help-discoverability.md`.
- - Replace `project.summary.help()` with `project.report.help()` in the
- help-surface enumeration.
- - Commit: `Amend help-discoverability ADR for project.report`.
-
-- [x] **P1.11 — IUCr writer foundation + `data_global` content**
- - New file: `src/easydiffraction/io/cif/iucr_writer.py`.
- - Implement the multi-datablock orchestrator skeleton: a
- `write_iucr_cif(project, path)` entry point that opens
- `reports/.cif`, emits `data_global` first, then delegates
- topology-specific blocks to subordinate writers (added in P1.12 /
- P1.13).
- - Emit `data_global` content per §2.3a of the ADR:
- `_audit.creation_method` / `_audit.creation_date`,
- `_computing.structure_refinement` (derived from the
- `_easydiffraction_software` triple),
- `_easydiffraction_software.{framework, calculator, minimizer}`,
- `_journal.*` and `_publ_*` placeholders (written as `?`),
- `_chemical_formula.*` derived from atom sites where possible.
- - Apply the §2.4 formatting rules: blank line between categories,
- `# ---- ----` headers, 80-char wrap on long strings,
- dotted DDLm form throughout, project extensions grouped at the end
- of each block.
- - Wire `Report.save()` (stubbed in P1.8) to call `write_iucr_cif`.
- - Commit: `Add IUCr CIF writer with data_global block`.
-
-- [x] **P1.12 — Single-crystal block layout**
- - Same file as P1.11; add `_write_sc_block` helper.
- - Per-structure block emission per §2.3b: `_chemical_formula.*`,
- `_cell.*`, `_space_group.*` + `_space_group_symop.*` loop,
- `_diffrn.*`, `_diffrn_radiation_wavelength.*` (scalar / single-row
- category for monochromatic per the wavelength transformer),
- `_atom_site.*` + `_atom_site_aniso.*` loops with ADP single-tag
- emission, `_refine_ls.*`, `_reflns.*`, the `_refln.*` loop per §2.3c
- (`index_h/k/l`, `F_squared_meas`, `F_squared_calc`,
- `F_squared_meas_su`, `include_status`).
- - For SC the project-extension `_easydiffraction_extinction.*` block +
- the dual `_refine_ls.extinction_*` triple are emitted via the
- transformer (added in P1.14).
- - Commit: `Emit single-crystal blocks in IUCr CIF writer`.
-
-- [x] **P1.13 — Powder Rietveld block layout (CWL + TOF)**
- - Same writer file; add `_write_rietveld_blocks` helper.
- - Emit `data__overall`, `data__phase_N` (one per
- phase), `data__pwd_N` (one per pattern) per §2.3f / §2.3g /
- §2.3h.
- - Profile-data loop columns: CWL form uses `_pd_meas.2theta_scan`; TOF
- form uses `_pd_meas.time_of_flight`. Other columns:
- `_pd_meas.intensity_total`, `_pd_calc.intensity_total`,
- `_pd_proc.intensity_bkg_calc`, `_pd_proc_ls.weight`.
- - Powder reflections loop per §2.3d:
- `_refln.{index_h/k/l, F_squared_meas, F_squared_calc, d_spacing}`
- plus `_pd_refln.phase_id` for the powder phase identifier.
- - Cross-block reference markers (`_pd_block_id`,
- `_pd_block_diffractogram_id`) emitted with pipe-delimited
- identifiers matching the §2.3 examples.
- - Joint Rietveld and sequential fits emit one `_pwd_N` per pattern /
- step inside the same file.
- - Commit: `Emit powder Rietveld blocks in IUCr CIF writer`.
-
-- [x] **P1.14 — `IucrCategoryTransformer` subclasses**
- - New file: `src/easydiffraction/io/cif/iucr_transformers.py`.
- - Implement and register five transformers per §3 of the ADR:
- - **Wavelength** — monochromatic scalar / single-row category;
- multi-row loop when applicable.
- - **TOF calibration** — four-row
- `_pd_calib_d_to_tof.{id, coeff, power, coeff_su, diffractogram_id}`
- loop with EasyDiffraction attribute names as `id` codes (`offset`,
- `linear`, `quad`, `recip`) and powers 0, 1, 2, −1 respectively per
- the §2.3h cif_pow.dic equation.
- - **Excluded regions** — free-text rendering as
- `_pd_proc.info_excluded_regions`.
- - **Symmetry operations** — `_space_group_symop.*` loop derived from
- the active space group.
- - **Extinction** — dual emit `_easydiffraction_extinction.*` + the
- coreCIF `_refine_ls.extinction_{method,coef,expression}` triple,
- with the descriptive string built from the project's
- `(type, model)` selectors per the ADR §3 mapping table.
- - Wire transformers into the writer from P1.12 / P1.13 (the writer
- asks each per-block category for its IUCr representation, which is
- either a direct `iucr_name` rename or a transformer call).
- - Commit: `Add IUCr category transformers for restructured emissions`.
-
-- [x] **P1.15 — Wire `Project.save(report=True)` end-to-end**
- - File: `src/easydiffraction/project/project.py`,
- `src/easydiffraction/report/report.py`.
- - `Project.save(report=False)` continues to write the regular project
- files. `Project.save(report=True)` additionally writes
- `reports/.cif`.
- - `Project.save(report=True, check=False)` is the default for the
- report path; `check=True` is wired in P1.16.
- - Make the `reports/` directory if absent; overwrite an existing
- report file (no round-trip).
- - Commit: `Wire report=True kwarg on Project.save`.
-
-- [x] **P1.16 — Submission-side validation via gemmi**
- - New file: `src/easydiffraction/report/check.py`.
- - Implement `Report.check()` using `gemmi.cif.read_doc` against the
- shipped (or downloaded) `cif_core.dic` and `cif_pow.dic`. Skip
- `_easydiffraction_*` from unknown-tag warnings.
- - Wire `Project.save(report=True, check=True)` to run validation after
- the write and surface any errors / warnings to the user.
- - Verify gemmi version supports the validation calls in the project's
- pinned env; if not, bump the constraint in `pyproject.toml` /
- `pixi.toml` / `pixi.lock` (pre-approved per AGENTS.md §Architecture
- because gemmi is already a named dependency).
- - Commit: `Add Report.check() validation via gemmi`.
-
-- [x] **P1.17 — Update tutorials / CLI / docs references**
- - Source files in `docs/docs/tutorials/*.py` and CLI commands in
- `src/easydiffraction/cli/`.
- - Replace every `project.summary.*` call site with the matching
- `project.report.*` call:
- - `project.summary.show_report()` → `project.report.show_report()`
- (currently used by `docs/docs/tutorials/ed-3.py`,
- `docs/docs/tutorials/ed-5.py`, `docs/docs/tutorials/ed-6.py`,
- `docs/docs/tutorials/ed-8.py` — confirm the full list via
- `git grep -n 'project\.summary'` at the start of the step and
- update every match).
- - Other `project.summary.*` accessors (`show_project_info`,
- `show_crystallographic_data`, `show_experimental_data`,
- `show_fitting_details`) — replace each call with the
- `project.report.*` equivalent.
- - `project.summary.help()` in any tutorial or doc page becomes
- `project.report.help()`.
- - Demonstrate `project.save(report=True)` in at least one tutorial
- that finishes a fit; reference `reports/.cif` in the prose.
- - Regenerate notebooks with `pixi run notebook-prepare` (per AGENTS.md
- §Tutorials).
- - Commit: `Update tutorials and CLI for project.report rename`.
-
-- [x] **P1.18 — Promote ADR to `accepted/`**
- - Move `docs/dev/adrs/suggestions/iucr-cif-tag-alignment.md` to
- `docs/dev/adrs/accepted/iucr-cif-tag-alignment.md`.
- - Flip the front-matter `**Status:**` line from `Proposed` to
- `Accepted`. Update the date to today (Phase 1 acceptance date).
- - Update `docs/dev/adrs/index.md`: the row's status column changes
- from `Suggestion` to `Accepted`, and the link target changes from
- `suggestions/` to `accepted/`.
- - Commit: `Promote iucr-cif-tag-alignment ADR to accepted`.
-
-- [x] **P1.19 — Reach Phase 1 review gate**
- - No-code step. Mark every `[ ]` above as `[x]`; commit the plan-file
- update alone.
- - Commit: `Reach Phase 1 review gate`.
-
-## Test plan (Phase 2)
-
-Per AGENTS.md §Testing, every new module, class, and bug fix ships with
-tests; unit tests mirror the source tree. Before running the
-verification commands below, add or update:
-
-- [x] **`tests/unit/easydiffraction/io/cif/test_handler.py`** —
- `CifHandler.iucr_name` resolver: explicit value used when set,
- fallback to `names[0]` when unset. P1.1 surface.
-- [x] **`tests/unit/easydiffraction/io/cif/test_iucr_writer.py`** —
- fixture-driven golden tests for each topology covered in §2.3 /
- §2.2 worked examples: single-crystal (Example A), single-
- experiment Rietveld CWL (Example B), joint Rietveld multi-
- experiment (Example C), sequential TOF Rietveld (Example D). Each
- golden compares the emitted file against a checked-in reference
- and asserts: block names, block order, `data_global` content,
- profile-data / reflections loop columns, project-extension
- `_easydiffraction_*` grouping at end of block, 80-char wrap.
- P1.11–P1.13 surface.
-- [x] **`tests/unit/easydiffraction/io/cif/test_iucr_transformers.py`**
- — per-transformer unit tests: wavelength scalar vs loop based on
- multiplicity; TOF calibration loop with
- `id = offset / linear / quad / recip` and powers 0, 1, 2, −1;
- range-form excluded regions rendered to
- `_pd_proc.info_excluded_regions`; `_space_group_symop.*` loop
- derived from the active space group; extinction `(type, model)` →
- `_refine_ls.extinction_method` descriptive string and
- `_refine_ls.extinction_coef` value per the ADR §3 mapping table
- (Becker-Coppens type 1 / type 2 / mixed, Zachariasen). P1.14
- surface.
-- [x] **`tests/unit/easydiffraction/io/cif/test_serialize.py`** — update
- to cover ADP single-tag emission: rows with `ADP_type='Biso'` /
- `'Bani'` emit only the `B_*` family; rows with `ADP_type='Uiso'` /
- `'Uani'` emit only the `U_*` family. P1.3 surface.
-- [x] **`tests/unit/easydiffraction/datablocks/structure/categories/atom_sites/test_default.py`**
- — update for the casing fixes from P1.2: `_atom_site.ADP_type`
- (uppercase ADP), `_atom_site.Wyckoff_symbol` (uppercase W,
- "symbol"); legacy lowercase forms still loadable on read.
-- [x] **`tests/unit/easydiffraction/datablocks/structure/categories/space_group/test_default.py`**
- — update for `_space_group.name_H-M_alt` and
- `_space_group.IT_coordinate_system_code` casing fixes (P1.2).
-- [x] **`tests/unit/easydiffraction/analysis/categories/fit_result/test_lsq.py`**
- — update for the new `_fit_result.*` fields from P1.4: each new
- descriptor (`R_factor_all`, `wR_factor_all`, `R_factor_gt`,
- `wR_factor_gt`, `prof_R_factor`, `prof_wR_factor`,
- `prof_wR_expected`, `number_restraints`, `number_constraints`,
- `shift_over_su_max`, `shift_over_su_mean`, `profile_function`,
- `background_function`, `threshold_expression`,
- `number_reflns_total`, `number_reflns_gt`) is read / written
- round-trip; fields unset for inapplicable experiment families
- remain `None`.
-- [x] **`tests/unit/easydiffraction/report/test_report.py`** — new
- `Report` class: `save()` writes `reports/.cif`; preserved
- display methods (`show_report`, `show_project_info`,
- `show_crystallographic_data`, `show_experimental_data`,
- `show_fitting_details`) keep their existing behaviour (port the
- relevant assertions from the current
- `tests/unit/easydiffraction/summary/`-equivalent tests if any
- exist; otherwise add coverage). P1.8 surface.
-- [x] **`tests/unit/easydiffraction/report/test_check.py`** —
- `Report.check()` validates the generated CIF against
- `cif_core.dic` / `cif_pow.dic` via gemmi; surfaces unknown- tag
- warnings for non-extension categories; ignores the
- `_easydiffraction_*` namespace per the configured skip list. P1.16
- surface.
-- [x] **`tests/unit/easydiffraction/project/test_project.py`** — update
- / extend: `Project.save()` no longer writes `summary.cif`;
- `Project.save(report=True)` writes `reports/.cif`;
- `Project.save(report=True, check=True)` runs validation; the
- `project.report` facade exposes `save`, `check`, and the preserved
- display methods. P1.8, P1.15, P1.16 surface.
-- [x] **`tests/unit/easydiffraction/analysis/test_analysis.py`** —
- update for the project-extension `iucr_name` settings on
- `_minimizer.*`, `_calculator.*`, `_fitting_mode.*` (P1.7):
- default-save CIF tags unchanged; IUCr-export `iucr_name` resolves
- to `_easydiffraction_*` prefix.
-- [x] **Script / tutorial coverage.** Verify `pixi run script-tests`
- exercises at least one tutorial that calls
- `project.save(report=True)` and the resulting
- `reports/.cif` is non-empty and gemmi-valid. If no
- tutorial exercises this, extend the relevant tutorial source per
- P1.17 and regenerate the notebook.
-
-Use `pixi run test-structure-check` to confirm the unit-test layout
-mirrors the source tree per AGENTS.md §Testing.
-
-## Verification commands (Phase 2)
-
-Per AGENTS.md §Workflow, save any required check output with the
-zsh-safe pattern. Variable names per-task:
-
-```sh
-pixi run fix > /tmp/easydiffraction-fix.log 2>&1; fix_exit_code=$?; tail -n 200 /tmp/easydiffraction-fix.log; exit $fix_exit_code
-pixi run check > /tmp/easydiffraction-check.log 2>&1; check_exit_code=$?; tail -n 200 /tmp/easydiffraction-check.log; exit $check_exit_code
-pixi run unit-tests > /tmp/easydiffraction-unit-tests.log 2>&1; unit_tests_exit_code=$?; tail -n 200 /tmp/easydiffraction-unit-tests.log; exit $unit_tests_exit_code
-pixi run integration-tests > /tmp/easydiffraction-integration-tests.log 2>&1; integration_tests_exit_code=$?; tail -n 200 /tmp/easydiffraction-integration-tests.log; exit $integration_tests_exit_code
-pixi run script-tests > /tmp/easydiffraction-script-tests.log 2>&1; script_tests_exit_code=$?; tail -n 200 /tmp/easydiffraction-script-tests.log; exit $script_tests_exit_code
-```
-
-Run in order; each must complete clean before the next. The
-`pixi run script-tests` pass may surface tutorial path collisions or
-stale tutorials — apply the tutorial-source fix from AGENTS.md §Workflow
-("If `pixi run script-tests` fails because two tutorials write to the
-same project directory…") rather than deleting project output. Benchmark
-CSVs under `docs/dev/benchmarking/` produced by `pixi run script-tests`
-are untracked verification artifacts; do not stage them.
-
-## Suggested Pull Request
-
-**Title:**
-`[scope] Align CIF tags with IUCr dictionaries and add journal-submission export`
-
-**Description:**
-
-EasyDiffraction now writes day-to-day project CIFs in a form that
-matches the IUCr core and powder dictionaries where it makes sense,
-while keeping the experiment-side names friendly for users who edit CIFs
-by hand. Structure CIFs adopt the dictionary casing
-(`_atom_site.ADP_type`, `_atom_site.Wyckoff_symbol`,
-`_space_group.name_H-M_alt`, `_space_group.IT_coordinate_system_code`),
-and atomic displacement parameters are written using a single family per
-row (`B_*` or `U_*`) based on `_atom_site.ADP_type`. Analysis CIFs gain
-a richer set of fit-output statistics — the standard Rietveld R-factor
-family, restraint and constraint counts, shift / σ diagnostics, profile
-and background function descriptors — all under the existing
-topology-neutral `_fit_result.*` category, with item names matching IUCr
-casing so they are immediately recognisable to anyone familiar with
-`_refine_ls.*` / `_pd_proc_ls.*` from publications.
-
-A new `project.report` facade replaces the previously empty
-`project.summary` placeholder. Calling `project.save(report=True)` (or
-`project.report.save()`) generates a single journal-submission CIF at
-`reports/.cif` — one multi- datablock file ready to upload to
-IUCr journals, with the publication-metadata `data_global` block holding
-`?` placeholders the user fills in before submission. The new
-`project.report.check()` runs the generated file through `gemmi` against
-the IUCr dictionaries to surface any tag, category, or type issues
-before the upload.
-
-The PR also amends four accepted ADRs (`analysis-cif-fit-state`,
-`minimizer-input-output-split`, `project-facade-and-persistence`,
-`help-discoverability`) to reflect the new facade and field set, and
-promotes the `iucr-cif-tag-alignment` ADR to `accepted/`.
-
-**Scope label:** `[analysis]` or `[io]` — pick whichever the maintainers
-prefer for the IUCr-export work; the field renames in `analysis.cif`
-lean `[analysis]`, the new writer leans `[io]`.
diff --git a/docs/dev/plans/project-summary-rendering.md b/docs/dev/plans/project-summary-rendering.md
deleted file mode 100644
index dcd812972..000000000
--- a/docs/dev/plans/project-summary-rendering.md
+++ /dev/null
@@ -1,1346 +0,0 @@
-# Plan: Project Summary Rendering — migration to single-style + pgfplots + DisplayHandler
-
-Implementation plan for the
-[`project-summary-rendering`](../adrs/accepted/project-summary-rendering.md)
-ADR. Follows [`AGENTS.md`](../../../AGENTS.md) — no deliberate
-exceptions to those instructions.
-
-> **Context for this plan.** The branch `project-summary-rendering`
-> already carries an end-to-end Phase 1 implementation of an earlier
-> version of the same ADR (PR-ready as of commit
-> `f354fa435 Reach Phase 1 review gate`). That earlier implementation
-> supported two LaTeX styles (`iucr` + `revtex`), used `kaleido` + a
-> Chrome bootstrap to render fit-quality figures, and had no
-> `DisplayHandler` for descriptor display metadata. The ADR has since
-> been substantially rewritten (single `iucrjournals` style only;
-> `pgfplots` with external CSV in place of `kaleido`; new
-> `DisplayHandler` value object; new ASCII units vocabulary aligned to
-> CIF DDLm `_units.code`; MathJax bundled under `html_offline=True`;
-> descriptor-driven `fit_data.x` payload in `ReportDataContext`).
->
-> This plan is the **migration delta**: the diff between the committed
-> implementation and the rewritten ADR. It does **not** re-derive the
-> surfaces already shipped (config category, per-format methods,
-> `analysis.software`, `project.publication`, and CLI report saving via
-> `fit`); those stay as-is except for the later approved removal of the
-> standalone `save` / `save-report` commands. Every step below either
-> deletes or replaces something the previous implementation introduced,
-> or adds a new surface the rewritten ADR requires.
-
-## ADR cross-reference
-
-- **Primary ADR:**
- [`project-summary-rendering.md`](../adrs/accepted/project-summary-rendering.md)
- (Accepted; ADR review cycle closed at review 5 sentinel).
-- The ADR's "ADRs amended by this ADR" section is unchanged by this
- migration — the amendments to
- [`iucr-cif-tag-alignment`](../adrs/accepted/iucr-cif-tag-alignment.md),
- [`analysis-cif-fit-state`](../adrs/accepted/analysis-cif-fit-state.md),
- [`project-facade-and-persistence`](../adrs/accepted/project-facade-and-persistence.md),
- and the suggested
- [`python-cif-category-correspondence`](../adrs/suggestions/python-cif-category-correspondence.md)
- were applied in the earlier P1 walk and remain valid.
-- No new ADR is required for this migration. The rewritten ADR is the
- authoritative reference.
-
-## Branch and PR
-
-- **Branch:** `project-summary-rendering` (already checked out; carries
- the earlier implementation commits).
-- **PR target:** `develop`.
-- Do not push the branch until both Phase 1 and Phase 2 review cycles
- close.
-
-## Decisions already made (in the ADR)
-
-These are settled by the accepted, rewritten ADR — this plan does not
-re-litigate them:
-
-- **Five-field config, no `style`** (§1.1, §1.3): the `Report`
- `CategoryItem` carries exactly five persisted descriptors — `cif`,
- `html`, `tex`, `pdf`, `html_offline` — written as `_report.*` in
- `project.cif`. No `style` field, no `ReportStyleEnum`, no `--style`
- CLI flag. Multi-style support is deferred to a follow-up ADR (see ADR
- "Deferred Work").
-- **Single LaTeX style — `iucrjournals` hardcoded** (§3.2): the LaTeX
- renderer emits one document class (`iucrjournals`). The vendored TeX
- bundle drops from 12 files (previous design) to 2: `iucrjournals.cls`
- and `harvard.sty`, both CC0 1.0 from the IUCr upstream.
-- **No `kaleido`, no `chromium`** (§3.3): each fit-quality figure is a
- **standalone `pgfplots` `.tex` document**
- (`reports/tex/data/fit_.tex`, reading its sibling
- `fit_.csv`), compiled independently to `fit_.pdf`
- and pulled into the main report via `\includegraphics` — no inline
- pgfplots in `.tex`, and per-figure compiles isolate the TeX
- memory pool. The fit-quality figure uses the Plotly geometry, colors,
- legend structure, grid colors, and line widths where `pgfplots` can
- support them without exceeding TeX memory. The PDF figure deliberately
- does **not** include the background curve or measured error bars, and
- it keeps every measured point with the small pgfplots marker size from
- the accepted design. The TeX engine (Tectonic / TeX Live / MiKTeX)
- supplies `pgfplots` + `standalone` + TikZ deps from CTAN or its
- default sets.
-- **`DisplayHandler` value object** (§1.5): new
- `@dataclass(frozen=True, slots=True)` at
- `src/easydiffraction/core/display_handler.py` carrying four optional
- fields — `display_name`, `display_units`, `latex_name`, `latex_units`.
- Renderers consult it via a per-context fallback chain (LaTeX context →
- `latex_*` fields, HTML context → `display_*` fields, GUI/terminal →
- `display_*` fields), each falling back to the descriptor's plain
- `name` / `units`. **Table-rendering paths MUST read through the
- resolution chain, not the raw `descriptor.units` field**, because
- `units=` now holds ASCII CIF DDLm codes.
-- **Units vocabulary aligned to `_units.code`** (§1.5): every `units=`
- string on a descriptor is the ASCII value the CIF DDLm dictionary
- defines verbatim (`angstroms`, `angstrom_squared`, `degrees`,
- `kelvins`, `kilopascals`, `microseconds`, `dalton`, `megagray`,
- `reciprocal_angstroms`, `reciprocal_angstrom_squared`, `none`). The
- single project-internal code in scope is `degrees_squared` (no
- `_units.code` round-trip). A new `units_vocabulary.py` module
- enumerates every valid code for a declaration-time validator. The
- Unicode-symbol form (`Ų`, `°`, `Å`) moves into `display_units`; the
- LaTeX form (`\AA$^2$`, `$\deg$`, `\AA`) into `latex_units`.
-- **MathJax bundling under `html_offline`** (§2): `html_offline=True`
- inline-bundles **both** Plotly (~3 MB, `include_plotlyjs=True`) and
- the vendored MathJax `tex-mml-chtml.js` (~1.5 MB, Apache-2.0). When
- `html_offline=False`, both load from CDN. No new Python dependency;
- MathJax is a static JS asset under
- `src/easydiffraction/report/templates/html/vendor/` packaged by
- hatchling.
-- **Descriptor-driven `fit_data` shape** (§6): `data_context()`'s
- `experiments[i].fit_data` payload carries an `x` sub-dict (values +
- descriptor `name`, `units`, `display_name`, `latex_name`,
- `display_units`, `latex_units` resolved at builder time) and a
- `series` sub-dict (`meas`, `calc`, `diff`, optional `bkg`, each with
- `values`, optional `su`, `label`). One descriptor path, two renderers
- (Plotly for HTML, pgfplots CSV for TeX), all experiment types — Bragg
- powder `two_theta`, TOF `time_of_flight`, total-scattering `r`, future
- `q`, …
-- **CIF persistence unchanged in shape** (§1.3): the existing
- `category_owner_to_cif` walker continues to emit `_report.*`; the
- read-side hook in `project_config_from_cif` continues to restore the
- five fields. Removing `_report.style` from the write surface is the
- only persistence change required.
-
-## Open questions to resolve during implementation
-
-- **`DisplayHandler` attachment point.** The ADR says the handler
- attaches to the descriptor as an optional slot. Confirm during P1
- whether the right surface is the base `Descriptor` class (every
- descriptor type inherits the slot) or the more specific `Parameter` /
- `StringDescriptor` subclasses. Default assumption: base `Descriptor` —
- uniform across all descriptor kinds.
-- **MathJax bundle version.** Pick a specific MathJax 3.x release for
- `tex-mml-chtml.js` and pin its source URL in `LICENSES.md` for
- traceability. Assume the latest 3.x release tagged on jsdelivr at
- vendoring time.
-- **`reports/tex/data/fit_.csv` schema.** The CSV emitter
- writes one row per data point with columns `x`, `meas`, optional
- `meas_su`, `calc`, `diff`. Background is not emitted to the pgfplots
- CSV because the PDF figure does not plot it. Confirm `figure.tex.j2`'s
- column references match this schema exactly during P1.
-- **Style-bundle cleanup vs. wheel size.** The 10 REVTeX files dropped
- from the bundle reduce the wheel by ~350 KB. The Phase 2 verification
- step covers the actual `pixi run dist-build` check
- (`iucrjournals.cls` + `harvard.sty` still ship; MathJax vendored
- bundle included); Phase 1 only edits
- `[tool.hatch.build.targets.wheel]` packaging rules if hatchling's
- defaults don't pick the files up.
-- **Tutorial coverage.** The two tutorial files already modified in the
- worktree (`ed-3.py`, `ed-14.py`) reference the old `style=` API; they
- need re-editing or reverting in P1.17 once the new surface is in
- place.
-
-## Concrete files likely to change
-
-**Surface shrink — drop `style` everywhere:**
-
-- `src/easydiffraction/report/enums.py` (existing — delete
- `ReportStyleEnum`; keep only `ReportFormatEnum`).
-- `src/easydiffraction/report/__init__.py` (existing — remove
- `ReportStyleEnum` re-export).
-- `src/easydiffraction/project/categories/report/default.py` (existing —
- delete the `style` `StringDescriptor`; drop the `style=` parameter
- from `save_tex()`, `save_pdf()`, any `Report.save()` dispatch; update
- docstrings).
-- `src/easydiffraction/__main__.py` (existing — remove standalone
- report-export CLI options; report saving is driven by persisted
- `_report.*` flags during `fit`).
-- `src/easydiffraction/report/tex_renderer.py` (existing — remove style
- dispatch; emit a single template).
-- `src/easydiffraction/io/cif/serialize.py` (existing — no code change
- expected; the walker emits whatever descriptors are declared, so
- removing the `style` descriptor drops the `_report.style` row
- automatically).
-
-**Style-bundle shrink (12 → 2):**
-
-- `src/easydiffraction/report/templates/tex/styles/` — delete:
- `revtex4-2.cls`, `ltxgrid.sty`, `ltxutil.sty`, `ltxfront.sty`,
- `ltxdocext.sty`, `revsymb4-2.sty`, `aps4-2.rtx`, `aps10pt4-2.rtx`,
- `aps11pt4-2.rtx`, `aps12pt4-2.rtx`. Keep: `iucrjournals.cls`,
- `harvard.sty`.
-- `src/easydiffraction/report/templates/tex/styles/LICENSES.md`
- (existing — rewrite to cover only the two CC0 1.0 files; remove the
- LPPL 1.3c REVTeX section).
-- `THIRD_PARTY_LICENSES.md` (existing at repo root — shrink index to one
- entry).
-- `src/easydiffraction/report/templates/tex/iucr.tex.j2` (existing —
- rename to a single canonical template name, e.g. `report.tex.j2`).
-- `src/easydiffraction/report/templates/tex/revtex.tex.j2` (existing —
- delete).
-
-**Drop `kaleido`:**
-
-- `pyproject.toml` (existing — remove `kaleido` from the runtime
- dependency list).
-- `pixi.lock` (regenerated).
-- `src/easydiffraction/report/tex_renderer.py` (existing — remove
- kaleido-based PDF figure emission and `figures/` output; replace with
- pgfplots CSV emission).
-- `docs/docs/user-guide/analysis-workflow/report.md` (existing — drop
- the kaleido bootstrap section).
-
-**Add `DisplayHandler`:**
-
-- `src/easydiffraction/core/display_handler.py` (new — frozen dataclass
- with four `str | None` fields).
-- `src/easydiffraction/core/__init__.py` (existing — export
- `DisplayHandler` alongside `CifHandler` etc., per AGENTS.md
- `__init__.py` rule).
-- `src/easydiffraction/core/descriptor.py` (or `parameter.py` /
- `base_descriptor.py` — whichever owns the descriptor base) (existing —
- add an optional `display_handler: DisplayHandler | None = None` slot;
- define resolution helpers `resolve_display_name(context)` /
- `resolve_display_units(context)` per the ADR's per-context fallback
- chain).
-
-**Units vocabulary sweep:**
-
-- `src/easydiffraction/core/units_vocabulary.py` (new — enumerate every
- valid `units=` code; raise `ValueError` with the offending code on
- validation failure; called from the descriptor base class at
- construction time).
-- Every descriptor declaration in `src/easydiffraction/` that currently
- passes a Unicode units string (`'Ų'`, `'°'`, `'Å'`, …) — rewrite to
- use the ASCII DDLm code and attach a `DisplayHandler` with the Unicode
- / LaTeX forms. Concrete file list resolved during P1.7's sweep; expect
- ≥20 sites across `datablocks/structure/`, `datablocks/experiment/`,
- `analysis/categories/`.
-
-**Table-rendering migration:**
-
-- `src/easydiffraction/project/categories/report/default.py` (existing —
- every `show_*()` method that builds a unit column reads through
- `resolve_display_units(...)` instead of `descriptor.units` directly).
-- `src/easydiffraction/report/html_renderer.py` (existing — same
- migration for Jinja context inputs).
-- `src/easydiffraction/report/tex_renderer.py` (existing — same
- migration for the TeX context).
-- `src/easydiffraction/report/data_context.py` (existing — every label /
- unit string baked into the context is resolved per-context here so
- renderers don't re-derive).
-
-**MathJax bundling:**
-
-- `src/easydiffraction/report/templates/html/vendor/mathjax-tex-mml-chtml.js`
- (new — vendored static asset, Apache-2.0).
-- `src/easydiffraction/report/templates/html/vendor/LICENSES.md` (new —
- Apache-2.0 text + upstream URL + version).
-- `THIRD_PARTY_LICENSES.md` (existing at repo root — extend index with
- MathJax entry alongside the IUCr TeX bundle).
-- `src/easydiffraction/report/templates/html/report.html.j2` (existing —
- switch the MathJax ``.
- - `False`:
- ``.
- - When `html_offline=True`, the renderer copies the vendored file next
- to the emitted `.html` so the relative
- `
+
diff --git a/src/easydiffraction/display/structure/viewing.py b/src/easydiffraction/display/structure/viewing.py
new file mode 100644
index 000000000..7548f2ef8
--- /dev/null
+++ b/src/easydiffraction/display/structure/viewing.py
@@ -0,0 +1,74 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+"""Viewer facade and engine factory for the structure view."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from easydiffraction.display.base import RendererBase
+from easydiffraction.display.base import RendererFactoryBase
+from easydiffraction.display.structure.enums import ViewerEngineEnum
+from easydiffraction.display.structure.renderers.ascii import AsciiStructureRenderer
+from easydiffraction.display.structure.renderers.threejs import ThreeJsStructureRenderer
+
+if TYPE_CHECKING:
+ from easydiffraction.display.structure.scene import StructureScene
+
+
+class ViewerFactory(RendererFactoryBase):
+ """Factory for structure-view renderer engines."""
+
+ @classmethod
+ def _registry(cls) -> dict:
+ return {
+ ViewerEngineEnum.ASCII.value: {
+ 'description': ViewerEngineEnum.ASCII.description(),
+ 'class': AsciiStructureRenderer,
+ },
+ ViewerEngineEnum.THREEJS.value: {
+ 'description': ViewerEngineEnum.THREEJS.description(),
+ 'class': ThreeJsStructureRenderer,
+ },
+ }
+
+
+class Viewer(RendererBase):
+ """Switchable facade that draws a scene with the active engine."""
+
+ @classmethod
+ def _factory(cls) -> type[RendererFactoryBase]:
+ """Return the structure-view engine factory."""
+ return ViewerFactory
+
+ @classmethod
+ def _default_engine(cls) -> str:
+ """Return the default engine name (Three.js)."""
+ return ViewerEngineEnum.default().value
+
+ def show_config(self) -> None:
+ """Display the active structure-view engine."""
+ self.show_current_engine()
+
+ def render(self, scene: StructureScene, *, features: frozenset[str]) -> str:
+ """
+ Draw the scene with the active engine.
+
+ Parameters
+ ----------
+ scene : StructureScene
+ The renderer-neutral primitives to draw.
+ features : frozenset[str]
+ The content-resolved feature set from the display facade.
+
+ Returns
+ -------
+ str
+ ASCII text or an HTML document, depending on the active
+ engine.
+ """
+ return self._backend.render(scene, features=features)
+
+ def supported_features(self) -> frozenset[str]:
+ """Return the feature names the active engine can draw."""
+ return self._backend.supported_features()
diff --git a/src/easydiffraction/display/tablers/base.py b/src/easydiffraction/display/tablers/base.py
index 0bc2bddfb..decb2282f 100644
--- a/src/easydiffraction/display/tablers/base.py
+++ b/src/easydiffraction/display/tablers/base.py
@@ -15,6 +15,8 @@
from IPython import get_ipython
from rich.color import Color
+from easydiffraction.display.theme import DARK_AXIS_FRAME_COLOR
+from easydiffraction.display.theme import LIGHT_AXIS_FRAME_COLOR
from easydiffraction.utils._vendored.theme_detect import is_dark
@@ -96,7 +98,9 @@ def _rich_border_color(self) -> str:
@property
def _pandas_border_color(self) -> str:
- return self._rich_to_hex(self._rich_border_color)
+ if self._is_dark_theme():
+ return DARK_AXIS_FRAME_COLOR
+ return LIGHT_AXIS_FRAME_COLOR
@abstractmethod
def build_renderable(
@@ -127,6 +131,7 @@ def render(
alignments: object,
df: object,
display_handle: object | None = None,
+ width: int | None = None,
) -> object:
"""
Render the provided DataFrame with backend-specific styling.
@@ -141,6 +146,9 @@ def render(
display_handle : object | None, default=None
Optional environment-specific handle to enable in-place
updates.
+ width : int | None, default=None
+ Optional target table width. Honored by fixed-width backends
+ (e.g. Rich); ignored by reflowing ones (e.g. HTML).
Returns
-------
diff --git a/src/easydiffraction/display/tablers/pandas.py b/src/easydiffraction/display/tablers/pandas.py
index 823c2cb09..25a013902 100644
--- a/src/easydiffraction/display/tablers/pandas.py
+++ b/src/easydiffraction/display/tablers/pandas.py
@@ -14,15 +14,27 @@
import re
from easydiffraction.display.tablers.base import TableBackendBase
+from easydiffraction.display.theme import DARK_AXIS_FRAME_COLOR
+from easydiffraction.display.theme import LIGHT_AXIS_FRAME_COLOR
+from easydiffraction.display.theme import TABLE_AXIS_FRAME_CSS_VAR
from easydiffraction.utils.environment import can_use_ipython_display
from easydiffraction.utils.logging import log
_RICH_COLOR_RE = re.compile(r'\[(\w+)\](.*?)\[/\1\]')
+PANDAS_TABLE_THEME_CLASS = 'ed-themed-table'
+PANDAS_AXIS_FRAME_COLOR = f'var({TABLE_AXIS_FRAME_CSS_VAR}, {LIGHT_AXIS_FRAME_COLOR})'
class PandasTableBackend(TableBackendBase):
"""Render tables using the pandas Styler in Jupyter environments."""
+ def _table_attributes(self) -> str:
+ """Return HTML table attributes for themed pandas tables."""
+ return (
+ f'class="dataframe {PANDAS_TABLE_THEME_CLASS}" '
+ f'style="{TABLE_AXIS_FRAME_CSS_VAR}: {self._pandas_border_color};"'
+ )
+
@staticmethod
def _build_base_styles(color: str) -> list[dict]:
"""
@@ -172,7 +184,7 @@ def _apply_styling(self, df: object, alignments: object, color: str) -> object:
styler = df.style.format(precision=self.FLOAT_PRECISION)
if color_styles is not None:
styler = styler.apply(lambda _: color_styles, axis=None)
- styler = styler.set_table_attributes('class="dataframe"') # For mkdocs-jupyter
+ styler = styler.set_table_attributes(self._table_attributes())
styler = styler.set_table_styles(table_styles + header_alignment_styles)
for column, align in zip(df.columns, alignments, strict=False):
@@ -182,8 +194,7 @@ def _apply_styling(self, df: object, alignments: object, color: str) -> object:
)
return styler
- @staticmethod
- def _update_display(styler: object, display_handle: object) -> None:
+ def _update_display(self, styler: object, display_handle: object) -> None:
"""
Single, consistent update path for Jupyter.
@@ -203,7 +214,7 @@ def _update_display(styler: object, display_handle: object) -> None:
# IPython DisplayHandle path
if can_use_ipython_display(display_handle) and HTML is not None:
try:
- html = styler.to_html()
+ html = self._themed_html(styler)
display_handle.update(HTML(html))
except (TypeError, ValueError, AttributeError, RuntimeError, OSError) as err:
log.debug(f'Pandas DisplayHandle update failed: {err!r}')
@@ -215,13 +226,17 @@ def _update_display(styler: object, display_handle: object) -> None:
pass
# Normal display
- display(styler)
+ if HTML is not None:
+ display(HTML(self._themed_html(styler)))
+ else:
+ display(styler)
def render(
self,
alignments: object,
df: object,
display_handle: object | None = None,
+ width: int | None = None,
) -> object:
"""
Render a styled DataFrame.
@@ -235,12 +250,16 @@ def render(
display_handle : object | None, default=None
Optional IPython DisplayHandle to update an existing output
area in place when running in Jupyter.
+ width : int | None, default=None
+ Ignored. HTML tables reflow to the available width, so no
+ fixed table width is applied.
Returns
-------
object
Backend-defined return value (commonly ``None``).
"""
+ del width
styler = self._build_styler(alignments, df)
self._update_display(styler, display_handle)
@@ -266,7 +285,7 @@ def build_renderable(
HTML string representation of the styled table.
"""
styler = self._build_styler(alignments, df)
- return styler.to_html()
+ return self._themed_html(styler)
def _build_styler(
self,
@@ -274,5 +293,84 @@ def _build_styler(
df: object,
) -> object:
"""Return a configured pandas Styler for the provided table."""
- color = self._pandas_border_color
+ color = PANDAS_AXIS_FRAME_COLOR
return self._apply_styling(df, alignments, color)
+
+ @classmethod
+ def _themed_html(cls, styler: object) -> str:
+ """Return styled table HTML plus the theme-sync script."""
+ return styler.to_html() + cls._theme_sync_post_script()
+
+ @staticmethod
+ def _theme_sync_post_script() -> str:
+ """Return client-side code for host table theme changes."""
+ return f"""
+
+"""
diff --git a/src/easydiffraction/display/tablers/rich.py b/src/easydiffraction/display/tablers/rich.py
index e23334be3..ceeb8dd9e 100644
--- a/src/easydiffraction/display/tablers/rich.py
+++ b/src/easydiffraction/display/tablers/rich.py
@@ -157,6 +157,7 @@ def render(
alignments: object,
df: object,
display_handle: object = None,
+ width: int | None = None,
) -> object:
"""
Render a styled table using Rich.
@@ -169,6 +170,9 @@ def render(
Index-aware DataFrame to render.
display_handle : object, default=None
Optional environment handle for in-place updates.
+ width : int | None, default=None
+ Optional target table width. When set, the table is sized to
+ this width so long cells wrap to fit the terminal.
Returns
-------
@@ -176,4 +180,6 @@ def render(
Backend-defined return value (commonly ``None``).
"""
table = self.build_renderable(alignments, df)
+ if width is not None:
+ table.width = width
self._update_display(table, display_handle)
diff --git a/src/easydiffraction/display/tables.py b/src/easydiffraction/display/tables.py
index 574d245a0..b6d4697fc 100644
--- a/src/easydiffraction/display/tables.py
+++ b/src/easydiffraction/display/tables.py
@@ -116,7 +116,12 @@ def build_renderable(self, df: object) -> object:
alignments, prepared_df = self._prepare_dataframe(df)
return self._backend.build_renderable(alignments, prepared_df)
- def render(self, df: object, display_handle: object | None = None) -> object:
+ def render(
+ self,
+ df: object,
+ display_handle: object | None = None,
+ width: int | None = None,
+ ) -> object:
"""
Render a DataFrame as a table using the active backend.
@@ -129,6 +134,9 @@ def render(self, df: object, display_handle: object | None = None) -> object:
Optional environment-specific handle used to update an
existing output area in-place (e.g., an IPython
DisplayHandle or a terminal live handle).
+ width : int | None, default=None
+ Optional target table width passed to the backend. Honored
+ by fixed-width backends (Rich); ignored by HTML.
Returns
-------
@@ -136,7 +144,7 @@ def render(self, df: object, display_handle: object | None = None) -> object:
Backend-specific return value (usually ``None``).
"""
alignments, prepared_df = self._prepare_dataframe(df)
- return self._backend.render(alignments, prepared_df, display_handle)
+ return self._backend.render(alignments, prepared_df, display_handle, width)
class TableRendererFactory(RendererFactoryBase):
diff --git a/src/easydiffraction/display/theme.py b/src/easydiffraction/display/theme.py
new file mode 100644
index 000000000..727dbf36f
--- /dev/null
+++ b/src/easydiffraction/display/theme.py
@@ -0,0 +1,109 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+"""Shared display theme colors for plots and notebook tables."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+LIGHT_BACKGROUND_COLOR = 'rgba(0, 0, 0, 0)'
+DARK_BACKGROUND_COLOR = 'rgba(0, 0, 0, 0)'
+LIGHT_FOREGROUND_COLOR = '#222222'
+DARK_FOREGROUND_COLOR = '#e6e8ee'
+LIGHT_AXIS_FRAME_COLOR = '#e0e0e0'
+DARK_AXIS_FRAME_COLOR = '#333'
+LIGHT_INNER_TICK_GRID_COLOR = '#f2f2f2'
+DARK_INNER_TICK_GRID_COLOR = '#1c1c1c'
+LIGHT_HOVER_BACKGROUND_COLOR = '#ffffff'
+DARK_HOVER_BACKGROUND_COLOR = '#212121'
+# Legend background mirrors the opaque theme base surface at 50% opacity
+LIGHT_LEGEND_BACKGROUND_COLOR = 'rgba(255, 255, 255, 0.5)'
+DARK_LEGEND_BACKGROUND_COLOR = 'rgba(33, 33, 33, 0.5)'
+TABLE_AXIS_FRAME_CSS_VAR = '--ed-axis-frame-color'
+
+
+@dataclass(frozen=True)
+class DisplayThemeColors:
+ """
+ Theme colors shared by interactive display outputs.
+
+ Attributes
+ ----------
+ background : str
+ Plot or output background color.
+ foreground : str
+ Primary text color.
+ axis_frame : str
+ Axis rectangle and table border color.
+ inner_tick_grid : str
+ Inner Plotly tick-grid color.
+ hover_background : str
+ Plotly hover label background color.
+ legend_background : str
+ Plotly legend background color.
+ """
+
+ background: str
+ foreground: str
+ axis_frame: str
+ inner_tick_grid: str
+ hover_background: str
+ legend_background: str
+
+
+LIGHT_THEME_COLORS = DisplayThemeColors(
+ background=LIGHT_BACKGROUND_COLOR,
+ foreground=LIGHT_FOREGROUND_COLOR,
+ axis_frame=LIGHT_AXIS_FRAME_COLOR,
+ inner_tick_grid=LIGHT_INNER_TICK_GRID_COLOR,
+ hover_background=LIGHT_HOVER_BACKGROUND_COLOR,
+ legend_background=LIGHT_LEGEND_BACKGROUND_COLOR,
+)
+DARK_THEME_COLORS = DisplayThemeColors(
+ background=DARK_BACKGROUND_COLOR,
+ foreground=DARK_FOREGROUND_COLOR,
+ axis_frame=DARK_AXIS_FRAME_COLOR,
+ inner_tick_grid=DARK_INNER_TICK_GRID_COLOR,
+ hover_background=DARK_HOVER_BACKGROUND_COLOR,
+ legend_background=DARK_LEGEND_BACKGROUND_COLOR,
+)
+
+
+def display_theme_colors(*, is_dark_theme: bool) -> DisplayThemeColors:
+ """
+ Return the display colors for the requested theme.
+
+ Parameters
+ ----------
+ is_dark_theme : bool
+ Whether to return the dark theme colors.
+
+ Returns
+ -------
+ DisplayThemeColors
+ Shared colors for the requested theme.
+ """
+ if is_dark_theme:
+ return DARK_THEME_COLORS
+ return LIGHT_THEME_COLORS
+
+
+def display_theme_colors_for_template(template: str) -> DisplayThemeColors | None:
+ """
+ Return display colors for a Plotly template name.
+
+ Parameters
+ ----------
+ template : str
+ Plotly template name.
+
+ Returns
+ -------
+ DisplayThemeColors | None
+ Theme colors for known Plotly templates, otherwise ``None``.
+ """
+ if template == 'plotly_white':
+ return LIGHT_THEME_COLORS
+ if template == 'plotly_dark':
+ return DARK_THEME_COLORS
+ return None
diff --git a/src/easydiffraction/io/cif/iucr_writer.py b/src/easydiffraction/io/cif/iucr_writer.py
index 1ee4f4f97..b0d5d9513 100644
--- a/src/easydiffraction/io/cif/iucr_writer.py
+++ b/src/easydiffraction/io/cif/iucr_writer.py
@@ -23,54 +23,6 @@
_TEXT_WRAP_WIDTH = 80
_ITEM_WIDTH = 38
-_JOURNAL_ITEMS = (
- ('_journal.name_full', 'name_full'),
- ('_journal.year', 'year'),
- ('_journal.volume', 'volume'),
- ('_journal.issue', 'issue'),
- ('_journal.page_first', 'page_first'),
- ('_journal.page_last', 'page_last'),
- ('_journal.paper_category', 'paper_category'),
- ('_journal.paper_DOI', 'paper_doi'),
- ('_journal.coden_ASTM', 'coden_astm'),
- ('_journal.suppl_publ_number', 'suppl_publ_number'),
-)
-
-_JOURNAL_DATE_ITEMS = (
- ('_journal_date.accepted', 'accepted'),
- ('_journal_date.from_coeditor', 'from_coeditor'),
- ('_journal_date.printers_final', 'printers_final'),
-)
-
-_JOURNAL_COEDITOR_ITEMS = (
- ('_journal_coeditor.code', 'code'),
- ('_journal_coeditor.name', 'name'),
- ('_journal_coeditor.notes', 'notes'),
-)
-
-_PUBL_CONTACT_AUTHOR_ITEMS = (
- ('_publ_contact_author.name', 'name'),
- ('_publ_contact_author.address', 'address'),
- ('_publ_contact_author.email', 'email'),
- ('_publ_contact_author.phone', 'phone'),
- ('_publ_contact_author.id_ORCID', 'id_orcid'),
- ('_publ_contact_author.id_IUCr', 'id_iucr'),
-)
-
-_PUBL_AUTHOR_ITEMS = (
- ('_publ_author.name', 'name'),
- ('_publ_author.address', 'address'),
- ('_publ_author.footnote', 'footnote'),
- ('_publ_author.id_ORCID', 'id_orcid'),
- ('_publ_author.id_IUCr', 'id_iucr'),
-)
-_PUBL_AUTHOR_TAGS = tuple(tag for tag, _ in _PUBL_AUTHOR_ITEMS)
-
-_PUBL_BODY_ITEMS = (
- ('_publ_body.title', 'title'),
- ('_publ_body.contents', 'contents'),
-)
-
_PACKAGE_BY_ENGINE = {
'cryspy': 'cryspy',
'crysfml': 'crysfml',
@@ -133,10 +85,11 @@ def iucr_report_path(
def _render_iucr_cif(project: object) -> str:
"""Render all IUCr CIF blocks for *project*."""
+ used_block_names = {'global'}
blocks = [_write_global_block(project)]
- blocks.extend(_write_sc_blocks(project))
- blocks.extend(_write_rietveld_blocks(project))
- return f'\n{_BLOCK_SEPARATOR}\n'.join(blocks) + '\n'
+ blocks.extend(_write_sc_blocks(project, used_block_names))
+ blocks.extend(_write_rietveld_blocks(project, used_block_names))
+ return f'\n\n{_BLOCK_SEPARATOR}\n'.join(blocks) + '\n'
def _write_global_block(project: object) -> str:
@@ -144,7 +97,6 @@ def _write_global_block(project: object) -> str:
lines = ['data_global']
_write_audit_section(lines)
_write_computing_section(lines, project)
- _write_publication_sections(lines, project)
_write_formula_section(lines, project)
return '\n'.join(lines)
@@ -176,45 +128,6 @@ def _write_computing_section(lines: list[str], project: object) -> None:
_write_item(lines, '_easydiffraction_software.fit_datetime', fit_datetime)
-def _write_publication_sections(lines: list[str], project: object) -> None:
- """
- Append publication metadata from the project publication owner.
- """
- publication = getattr(project, 'publication', None)
- _write_publication_item_section(
- lines,
- 'Journal',
- getattr(publication, 'journal', None),
- _JOURNAL_ITEMS,
- )
- _write_publication_item_section(
- lines,
- 'Journal dates',
- getattr(publication, 'journal_date', None),
- _JOURNAL_DATE_ITEMS,
- )
- _write_publication_item_section(
- lines,
- 'Journal coeditor',
- getattr(publication, 'journal_coeditor', None),
- _JOURNAL_COEDITOR_ITEMS,
- )
- _write_publication_item_section(
- lines,
- 'Publication contact author',
- getattr(publication, 'contact_author', None),
- _PUBL_CONTACT_AUTHOR_ITEMS,
- )
-
- _section(lines, 'Publication authors')
- _write_loop(lines, _PUBL_AUTHOR_TAGS, _publication_author_rows(publication))
-
- _write_publication_body_section(
- lines,
- getattr(publication, 'body', None),
- )
-
-
def _write_formula_section(lines: list[str], project: object) -> None:
"""Append chemical-formula summary metadata."""
formula = _chemical_formula_values(project)
@@ -233,12 +146,16 @@ def _write_chemical_formula_section(
_write_item(lines, '_chemical_formula.IUPAC', formula.iupac)
-def _write_sc_blocks(project: object) -> list[str]:
+def _write_sc_blocks(project: object, used_block_names: set[str]) -> list[str]:
"""Render single-crystal structure/experiment blocks."""
blocks: list[str] = []
for experiment in _single_crystal_experiments(project):
structure = _linked_structure(project, experiment)
- blocks.append(_write_sc_block(project, structure, experiment))
+ block_name = _unique_block_name(
+ getattr(structure, 'name', None) or 'I',
+ used_block_names,
+ )
+ blocks.append(_write_sc_block(project, structure, experiment, block_name))
return blocks
@@ -246,9 +163,9 @@ def _write_sc_block(
project: object,
structure: object,
experiment: object,
+ block_name: str,
) -> str:
"""Render one single-crystal data block."""
- block_name = _block_name(getattr(structure, 'name', None) or 'I')
lines = [f'data_{block_name}']
_write_chemical_formula_section(lines, _structure_formula_values(structure))
_write_cell_section(lines, structure)
@@ -474,16 +391,17 @@ def _write_sc_project_extensions(lines: list[str], experiment: object) -> None:
_write_item(lines, tag, value)
-def _write_rietveld_blocks(project: object) -> list[str]:
+def _write_rietveld_blocks(project: object, used_block_names: set[str]) -> list[str]:
"""Render powder Rietveld overall, phase, and pattern blocks."""
experiments = _powder_rietveld_experiments(project)
if not experiments:
return []
- phases = _powder_phases(project, experiments)
- patterns = _powder_patterns(project, experiments)
+ overall_block_name = _unique_block_name('overall', used_block_names)
+ phases = _powder_phases(project, experiments, used_block_names)
+ patterns = _powder_patterns(experiments, used_block_names)
return [
- _write_rietveld_overall_block(project, phases, patterns),
+ _write_rietveld_overall_block(project, phases, patterns, overall_block_name),
*[_write_powder_phase_block(phase) for phase in phases],
*[_write_powder_pattern_block(project, pattern, phases) for pattern in patterns],
]
@@ -493,22 +411,19 @@ def _write_rietveld_overall_block(
project: object,
phases: list[_PowderPhase],
patterns: list[_PowderPattern],
+ block_name: str,
) -> str:
"""Render the powder Rietveld overall block."""
- lines = [f'data_{_block_name(project.name)}_overall']
+ lines = [f'data_{block_name}']
fit_result = _fit_result(project)
_section(lines, 'Rietveld overall')
_write_item(lines, '_pd_calc.method', 'Rietveld Refinement')
- _write_item(
- lines,
- '_pd_block_id',
- _pipe_ids([phase.block_name for phase in phases]),
- )
- _write_item(
+ _write_reference_values(lines, '_pd_block_id', [phase.block_name for phase in phases])
+ _write_reference_values(
lines,
'_pd_block_diffractogram_id',
- _pipe_ids([pattern.block_name for pattern in patterns]),
+ [pattern.block_name for pattern in patterns],
)
_section(lines, 'Powder refinement')
@@ -587,7 +502,7 @@ def _write_powder_pattern_reference_section(
phases,
)
_section(lines, 'Powder pattern')
- _write_item(lines, '_pd_block_id', _pipe_ids(phase_ids))
+ _write_reference_values(lines, '_pd_block_id', phase_ids)
_write_item(lines, '_pd_block_diffractogram_id', pattern.block_name)
@@ -601,9 +516,6 @@ def _write_powder_measurement_section(
_section(lines, 'Powder measurement')
_write_item(lines, '_pd_meas.scan_method', _attribute_value(expt_type, 'beam_mode'))
_write_item(lines, '_pd_meas.number_of_points', len(data_items))
- _write_item(lines, '_pd_meas.info_author_name', '?')
- _write_item(lines, '_pd_meas.info_author_email', '?')
- _write_item(lines, '_pd_meas.info_author_phone', '?')
def _write_powder_proc_section(lines: list[str], experiment: object) -> None:
@@ -704,65 +616,6 @@ def _write_tof_calibration_loop(lines: list[str], experiment: object) -> None:
_write_loop(lines, loop.tags, loop.rows)
-def _write_publication_item_section(
- lines: list[str],
- title: str,
- category: object,
- items: Iterable[tuple[str, str]],
-) -> None:
- """Append one scalar publication metadata section."""
- _section(lines, title)
- for tag, attr_name in items:
- _write_item(lines, tag, _attribute_value(category, attr_name))
-
-
-def _write_publication_body_section(lines: list[str], body: object) -> None:
- """Append publication body metadata."""
- _section(lines, 'Publication body')
- for tag, attr_name in _PUBL_BODY_ITEMS:
- _write_item(lines, tag, _publication_body_value(body, attr_name))
-
-
-def _publication_body_value(body: object, attr_name: str) -> object:
- """Return one publication-body value."""
- if attr_name != 'contents':
- return _attribute_value(body, attr_name)
-
- return _publication_body_contents(body)
-
-
-def _publication_body_contents(body: object) -> str | None:
- """Return IUCr publication body contents from discrete fields."""
- if body is None:
- return None
-
- sections = []
- for attr_name in ('synopsis', 'abstract'):
- value = _attribute_value(body, attr_name)
- if value not in {None, ''}:
- sections.append(str(value))
-
- keywords = getattr(body, 'keywords', [])
- if keywords:
- sections.append(f'Keywords: {", ".join(keywords)}')
-
- if not sections:
- return None
- return '\n\n'.join(sections)
-
-
-def _publication_author_rows(publication: object) -> list[tuple[object, ...]]:
- """Return publication author rows or one empty placeholder row."""
- authors = getattr(publication, 'authors', None)
- rows = [
- tuple(_attribute_value(author, attr_name) for _, attr_name in _PUBL_AUTHOR_ITEMS)
- for author in _collection_values(authors)
- ]
- if rows:
- return rows
- return [tuple(None for _ in _PUBL_AUTHOR_ITEMS)]
-
-
def _write_loop(
lines: list[str],
tags: Iterable[str],
@@ -1178,6 +1031,7 @@ def _powder_rietveld_experiments(project: object) -> list[object]:
def _powder_phases(
project: object,
experiments: list[object],
+ used_block_names: set[str],
) -> list[_PowderPhase]:
"""Return unique powder phase blocks for the given experiments."""
phases: list[_PowderPhase] = []
@@ -1188,10 +1042,9 @@ def _powder_phases(
if structure_name in seen_structure_names:
continue
seen_structure_names.add(structure_name)
- block_name = f'{_block_name(project.name)}_phase_{len(phases) + 1}'
phases.append(
_PowderPhase(
- block_name=block_name,
+ block_name=_unique_block_name(structure_name, used_block_names),
structure=structure,
linked_phase=linked_phase,
)
@@ -1200,13 +1053,16 @@ def _powder_phases(
def _powder_patterns(
- project: object,
experiments: list[object],
+ used_block_names: set[str],
) -> list[_PowderPattern]:
"""Return powder pattern blocks for the given experiments."""
return [
_PowderPattern(
- block_name=f'{_block_name(project.name)}_pwd_{index}',
+ block_name=_unique_block_name(
+ getattr(experiment, 'name', None) or f'pattern_{index}',
+ used_block_names,
+ ),
experiment=experiment,
)
for index, experiment in enumerate(experiments, start=1)
@@ -1244,11 +1100,27 @@ def _linked_powder_structures(
raise ValueError(msg)
-def _pipe_ids(values: list[str]) -> str:
- """Return pipe-delimited block identifiers."""
+def _write_reference_values(lines: list[str], tag: str, values: list[str]) -> None:
+ """Append scalar or loop block references."""
if not values:
- return '?'
- return '|' + '|'.join(values) + '|'
+ _write_item(lines, tag, '?')
+ return
+ if len(values) == 1:
+ _write_item(lines, tag, values[0])
+ return
+ _write_loop(lines, (tag,), [(value,) for value in values])
+
+
+def _unique_block_name(value: object, used_block_names: set[str]) -> str:
+ """Return a CIF block name unique in the report."""
+ block_name = _block_name(value)
+ candidate = block_name
+ suffix = 2
+ while candidate in used_block_names:
+ candidate = f'{block_name}_{suffix}'
+ suffix += 1
+ used_block_names.add(candidate)
+ return candidate
def _phase_block_names_for_experiment(
diff --git a/src/easydiffraction/io/cif/serialize.py b/src/easydiffraction/io/cif/serialize.py
index e3080aa97..4adea6aa4 100644
--- a/src/easydiffraction/io/cif/serialize.py
+++ b/src/easydiffraction/io/cif/serialize.py
@@ -566,7 +566,7 @@ def _as_cif_text(section: object) -> str:
def project_config_to_cif(project: object) -> str:
"""Render project-level configuration to ``project.cif`` text."""
sections: list[str] = []
- for attr_name in ('info', 'chart', 'report'):
+ for attr_name in ('info', 'rendering_plot', 'report'):
section = getattr(project, attr_name, None)
if section is not None:
sections.append(_as_cif_text(section))
@@ -575,7 +575,13 @@ def project_config_to_cif(project: object) -> str:
if publication is not None:
sections.append(category_owner_to_cif(publication))
- for attr_name in ('table', 'verbosity'):
+ for attr_name in (
+ 'rendering_table',
+ 'rendering_structure',
+ 'structure_view',
+ 'structure_style',
+ 'verbosity',
+ ):
section = getattr(project, attr_name, None)
if section is not None:
sections.append(_as_cif_text(section))
@@ -684,9 +690,9 @@ def project_config_from_cif(project: object, cif_text: str) -> None:
_populate_project_info_from_block(project.info, block)
- chart = getattr(project, 'chart', None)
- if chart is not None:
- chart.from_cif(block)
+ rendering_plot = getattr(project, 'rendering_plot', None)
+ if rendering_plot is not None:
+ rendering_plot.from_cif(block)
report = getattr(project, 'report', None)
if report is not None:
@@ -697,14 +703,26 @@ def project_config_from_cif(project: object, cif_text: str) -> None:
if publication is not None:
publication.from_cif(block)
- table = getattr(project, 'table', None)
- if table is not None:
- table.from_cif(block)
+ rendering_table = getattr(project, 'rendering_table', None)
+ if rendering_table is not None:
+ rendering_table.from_cif(block)
verbosity = getattr(project, 'verbosity', None)
if verbosity is not None:
verbosity.from_cif(block)
+ rendering_structure = getattr(project, 'rendering_structure', None)
+ if rendering_structure is not None:
+ rendering_structure.from_cif(block)
+
+ structure_view = getattr(project, 'structure_view', None)
+ if structure_view is not None:
+ structure_view.from_cif(block)
+
+ structure_style = getattr(project, 'structure_style', None)
+ if structure_style is not None:
+ structure_style.from_cif(block)
+
def analysis_from_cif(analysis: object, cif_text: str) -> None:
"""
diff --git a/src/easydiffraction/project/categories/chart/__init__.py b/src/easydiffraction/project/categories/chart/__init__.py
deleted file mode 100644
index 1ae8bf874..000000000
--- a/src/easydiffraction/project/categories/chart/__init__.py
+++ /dev/null
@@ -1,8 +0,0 @@
-# SPDX-FileCopyrightText: 2026 EasyScience contributors
-# SPDX-License-Identifier: BSD-3-Clause
-"""Project chart category exports."""
-
-from __future__ import annotations
-
-from easydiffraction.project.categories.chart.default import Chart
-from easydiffraction.project.categories.chart.factory import ChartFactory
diff --git a/src/easydiffraction/project/categories/publication/__init__.py b/src/easydiffraction/project/categories/publication/__init__.py
deleted file mode 100644
index f1aa7a046..000000000
--- a/src/easydiffraction/project/categories/publication/__init__.py
+++ /dev/null
@@ -1,15 +0,0 @@
-# SPDX-FileCopyrightText: 2026 EasyScience contributors
-# SPDX-License-Identifier: BSD-3-Clause
-"""Project publication metadata exports."""
-
-from __future__ import annotations
-
-from easydiffraction.project.categories.publication.default import Publication
-from easydiffraction.project.categories.publication.default import PublicationAuthor
-from easydiffraction.project.categories.publication.default import PublicationAuthors
-from easydiffraction.project.categories.publication.default import PublicationBody
-from easydiffraction.project.categories.publication.default import PublicationContactAuthor
-from easydiffraction.project.categories.publication.default import PublicationJournal
-from easydiffraction.project.categories.publication.default import PublicationJournalCoeditor
-from easydiffraction.project.categories.publication.default import PublicationJournalDate
-from easydiffraction.project.categories.publication.factory import PublicationFactory
diff --git a/src/easydiffraction/project/categories/publication/default.py b/src/easydiffraction/project/categories/publication/default.py
deleted file mode 100644
index e312e2a7d..000000000
--- a/src/easydiffraction/project/categories/publication/default.py
+++ /dev/null
@@ -1,657 +0,0 @@
-# SPDX-FileCopyrightText: 2026 EasyScience contributors
-# SPDX-License-Identifier: BSD-3-Clause
-"""Project publication metadata categories."""
-
-from __future__ import annotations
-
-from typing import TYPE_CHECKING
-
-from easydiffraction.core.category import CategoryCollection
-from easydiffraction.core.category import CategoryItem
-from easydiffraction.core.category_owner import CategoryOwner
-from easydiffraction.core.metadata import TypeInfo
-from easydiffraction.core.validation import AttributeSpec
-from easydiffraction.core.variable import StringDescriptor
-from easydiffraction.io.cif.handler import CifHandler
-from easydiffraction.project.categories.publication.factory import PublicationFactory
-from easydiffraction.project.publication_loader import load_publication
-
-if TYPE_CHECKING:
- import pathlib
-
-
-class PublicationItemBase(CategoryItem):
- """Base for optional publication metadata scalar categories."""
-
- @staticmethod
- def _optional_string(
- *,
- name: str,
- cif_name: str,
- description: str,
- ) -> StringDescriptor:
- """Create a nullable publication string descriptor."""
- return StringDescriptor(
- name=name,
- description=description,
- value_spec=AttributeSpec(default=None, allow_none=True),
- cif_handler=CifHandler(names=[cif_name]),
- )
-
-
-class PublicationJournal(PublicationItemBase):
- """Journal metadata for publication reports."""
-
- _category_code = 'journal'
-
- def __init__(self) -> None:
- super().__init__()
- self._name_full = self._optional_string(
- name='name_full',
- cif_name='_journal.name_full',
- description='Full journal name.',
- )
- self._year = self._optional_string(
- name='year',
- cif_name='_journal.year',
- description='Journal publication year.',
- )
- self._volume = self._optional_string(
- name='volume',
- cif_name='_journal.volume',
- description='Journal volume.',
- )
- self._issue = self._optional_string(
- name='issue',
- cif_name='_journal.issue',
- description='Journal issue.',
- )
- self._page_first = self._optional_string(
- name='page_first',
- cif_name='_journal.page_first',
- description='First journal page.',
- )
- self._page_last = self._optional_string(
- name='page_last',
- cif_name='_journal.page_last',
- description='Last journal page.',
- )
- self._paper_category = self._optional_string(
- name='paper_category',
- cif_name='_journal.paper_category',
- description='Journal paper category.',
- )
- self._paper_doi = self._optional_string(
- name='paper_doi',
- cif_name='_journal.paper_DOI',
- description='Journal paper DOI.',
- )
- self._coden_astm = self._optional_string(
- name='coden_astm',
- cif_name='_journal.coden_ASTM',
- description='Journal CODEN ASTM identifier.',
- )
- self._suppl_publ_number = self._optional_string(
- name='suppl_publ_number',
- cif_name='_journal.suppl_publ_number',
- description='Supplementary publication number.',
- )
-
- @property
- def name_full(self) -> StringDescriptor:
- """Full journal name."""
- return self._name_full
-
- @name_full.setter
- def name_full(self, value: str | None) -> None:
- self._name_full.value = value
-
- @property
- def year(self) -> StringDescriptor:
- """Journal publication year."""
- return self._year
-
- @year.setter
- def year(self, value: str | None) -> None:
- self._year.value = value
-
- @property
- def volume(self) -> StringDescriptor:
- """Journal volume."""
- return self._volume
-
- @volume.setter
- def volume(self, value: str | None) -> None:
- self._volume.value = value
-
- @property
- def issue(self) -> StringDescriptor:
- """Journal issue."""
- return self._issue
-
- @issue.setter
- def issue(self, value: str | None) -> None:
- self._issue.value = value
-
- @property
- def page_first(self) -> StringDescriptor:
- """First journal page."""
- return self._page_first
-
- @page_first.setter
- def page_first(self, value: str | None) -> None:
- self._page_first.value = value
-
- @property
- def page_last(self) -> StringDescriptor:
- """Last journal page."""
- return self._page_last
-
- @page_last.setter
- def page_last(self, value: str | None) -> None:
- self._page_last.value = value
-
- @property
- def paper_category(self) -> StringDescriptor:
- """Journal paper category."""
- return self._paper_category
-
- @paper_category.setter
- def paper_category(self, value: str | None) -> None:
- self._paper_category.value = value
-
- @property
- def paper_doi(self) -> StringDescriptor:
- """Journal paper DOI."""
- return self._paper_doi
-
- @paper_doi.setter
- def paper_doi(self, value: str | None) -> None:
- self._paper_doi.value = value
-
- @property
- def coden_astm(self) -> StringDescriptor:
- """Journal CODEN ASTM identifier."""
- return self._coden_astm
-
- @coden_astm.setter
- def coden_astm(self, value: str | None) -> None:
- self._coden_astm.value = value
-
- @property
- def suppl_publ_number(self) -> StringDescriptor:
- """Supplementary publication number."""
- return self._suppl_publ_number
-
- @suppl_publ_number.setter
- def suppl_publ_number(self, value: str | None) -> None:
- self._suppl_publ_number.value = value
-
-
-class PublicationJournalDate(PublicationItemBase):
- """Journal editorial date metadata."""
-
- _category_code = 'journal_date'
-
- def __init__(self) -> None:
- super().__init__()
- self._accepted = self._optional_string(
- name='accepted',
- cif_name='_journal_date.accepted',
- description='Accepted date.',
- )
- self._from_coeditor = self._optional_string(
- name='from_coeditor',
- cif_name='_journal_date.from_coeditor',
- description='Date sent from coeditor.',
- )
- self._printers_final = self._optional_string(
- name='printers_final',
- cif_name='_journal_date.printers_final',
- description='Final printer date.',
- )
-
- @property
- def accepted(self) -> StringDescriptor:
- """Accepted date."""
- return self._accepted
-
- @accepted.setter
- def accepted(self, value: str | None) -> None:
- self._accepted.value = value
-
- @property
- def from_coeditor(self) -> StringDescriptor:
- """Date sent from coeditor."""
- return self._from_coeditor
-
- @from_coeditor.setter
- def from_coeditor(self, value: str | None) -> None:
- self._from_coeditor.value = value
-
- @property
- def printers_final(self) -> StringDescriptor:
- """Final printer date."""
- return self._printers_final
-
- @printers_final.setter
- def printers_final(self, value: str | None) -> None:
- self._printers_final.value = value
-
-
-class PublicationJournalCoeditor(PublicationItemBase):
- """Journal coeditor metadata."""
-
- _category_code = 'journal_coeditor'
-
- def __init__(self) -> None:
- super().__init__()
- self._code = self._optional_string(
- name='code',
- cif_name='_journal_coeditor.code',
- description='Journal coeditor code.',
- )
- self._name = self._optional_string(
- name='name',
- cif_name='_journal_coeditor.name',
- description='Journal coeditor name.',
- )
- self._notes = self._optional_string(
- name='notes',
- cif_name='_journal_coeditor.notes',
- description='Journal coeditor notes.',
- )
-
- @property
- def code(self) -> StringDescriptor:
- """Journal coeditor code."""
- return self._code
-
- @code.setter
- def code(self, value: str | None) -> None:
- self._code.value = value
-
- @property
- def name(self) -> StringDescriptor:
- """Journal coeditor name."""
- return self._name
-
- @name.setter
- def name(self, value: str | None) -> None:
- self._name.value = value
-
- @property
- def notes(self) -> StringDescriptor:
- """Journal coeditor notes."""
- return self._notes
-
- @notes.setter
- def notes(self, value: str | None) -> None:
- self._notes.value = value
-
-
-class PublicationContactAuthor(PublicationItemBase):
- """Publication contact-author metadata."""
-
- _category_code = 'publ_contact_author'
-
- def __init__(self) -> None:
- super().__init__()
- self._name = self._optional_string(
- name='name',
- cif_name='_publ_contact_author.name',
- description='Contact author name.',
- )
- self._address = self._optional_string(
- name='address',
- cif_name='_publ_contact_author.address',
- description='Contact author address.',
- )
- self._email = self._optional_string(
- name='email',
- cif_name='_publ_contact_author.email',
- description='Contact author email.',
- )
- self._phone = self._optional_string(
- name='phone',
- cif_name='_publ_contact_author.phone',
- description='Contact author phone.',
- )
- self._id_orcid = self._optional_string(
- name='id_orcid',
- cif_name='_publ_contact_author.id_ORCID',
- description='Contact author ORCID identifier.',
- )
- self._id_iucr = self._optional_string(
- name='id_iucr',
- cif_name='_publ_contact_author.id_IUCr',
- description='Contact author IUCr identifier.',
- )
-
- @property
- def name(self) -> StringDescriptor:
- """Contact author name."""
- return self._name
-
- @name.setter
- def name(self, value: str | None) -> None:
- self._name.value = value
-
- @property
- def address(self) -> StringDescriptor:
- """Contact author address."""
- return self._address
-
- @address.setter
- def address(self, value: str | None) -> None:
- self._address.value = value
-
- @property
- def email(self) -> StringDescriptor:
- """Contact author email."""
- return self._email
-
- @email.setter
- def email(self, value: str | None) -> None:
- self._email.value = value
-
- @property
- def phone(self) -> StringDescriptor:
- """Contact author phone."""
- return self._phone
-
- @phone.setter
- def phone(self, value: str | None) -> None:
- self._phone.value = value
-
- @property
- def id_orcid(self) -> StringDescriptor:
- """Contact author ORCID identifier."""
- return self._id_orcid
-
- @id_orcid.setter
- def id_orcid(self, value: str | None) -> None:
- self._id_orcid.value = value
-
- @property
- def id_iucr(self) -> StringDescriptor:
- """Contact author IUCr identifier."""
- return self._id_iucr
-
- @id_iucr.setter
- def id_iucr(self, value: str | None) -> None:
- self._id_iucr.value = value
-
-
-class PublicationBody(PublicationItemBase):
- """Publication body metadata."""
-
- _category_code = 'publ_body'
-
- def __init__(self) -> None:
- super().__init__()
- self._title = self._optional_string(
- name='title',
- cif_name='_publ_body.title',
- description='Publication title.',
- )
- self._synopsis = self._optional_string(
- name='synopsis',
- cif_name='_publ_body.synopsis',
- description='Publication synopsis.',
- )
- self._abstract = self._optional_string(
- name='abstract',
- cif_name='_publ_body.abstract',
- description='Publication abstract.',
- )
- self._keywords = self._optional_string(
- name='keywords',
- cif_name='_publ_body.keywords',
- description='Publication keywords.',
- )
-
- @property
- def title(self) -> StringDescriptor:
- """Publication title."""
- return self._title
-
- @title.setter
- def title(self, value: str | None) -> None:
- self._title.value = value
-
- @property
- def synopsis(self) -> StringDescriptor:
- """Publication synopsis."""
- return self._synopsis
-
- @synopsis.setter
- def synopsis(self, value: str | None) -> None:
- self._synopsis.value = value
-
- @property
- def abstract(self) -> StringDescriptor:
- """Publication abstract."""
- return self._abstract
-
- @abstract.setter
- def abstract(self, value: str | None) -> None:
- self._abstract.value = value
-
- @property
- def keywords(self) -> list[str]:
- """Publication keywords."""
- value = self._keywords.value
- if value in {None, ''}:
- return []
- return str(value).splitlines()
-
- @keywords.setter
- def keywords(self, value: list[str]) -> None:
- self._keywords.value = '\n'.join(value)
-
-
-class PublicationAuthor(PublicationItemBase):
- """Single publication author row."""
-
- _category_code = 'publ_author'
- _category_entry_name = 'name'
-
- def __init__(self) -> None:
- super().__init__()
- self._name = self._optional_string(
- name='name',
- cif_name='_publ_author.name',
- description='Publication author name.',
- )
- self._address = self._optional_string(
- name='address',
- cif_name='_publ_author.address',
- description='Publication author address.',
- )
- self._footnote = self._optional_string(
- name='footnote',
- cif_name='_publ_author.footnote',
- description='Publication author footnote.',
- )
- self._id_orcid = self._optional_string(
- name='id_orcid',
- cif_name='_publ_author.id_ORCID',
- description='Publication author ORCID identifier.',
- )
- self._id_iucr = self._optional_string(
- name='id_iucr',
- cif_name='_publ_author.id_IUCr',
- description='Publication author IUCr identifier.',
- )
-
- @property
- def name(self) -> StringDescriptor:
- """Publication author name."""
- return self._name
-
- @name.setter
- def name(self, value: str | None) -> None:
- self._name.value = value
-
- @property
- def address(self) -> StringDescriptor:
- """Publication author address."""
- return self._address
-
- @address.setter
- def address(self, value: str | None) -> None:
- self._address.value = value
-
- @property
- def footnote(self) -> StringDescriptor:
- """Publication author footnote."""
- return self._footnote
-
- @footnote.setter
- def footnote(self, value: str | None) -> None:
- self._footnote.value = value
-
- @property
- def id_orcid(self) -> StringDescriptor:
- """Publication author ORCID identifier."""
- return self._id_orcid
-
- @id_orcid.setter
- def id_orcid(self, value: str | None) -> None:
- self._id_orcid.value = value
-
- @property
- def id_iucr(self) -> StringDescriptor:
- """Publication author IUCr identifier."""
- return self._id_iucr
-
- @id_iucr.setter
- def id_iucr(self, value: str | None) -> None:
- self._id_iucr.value = value
-
-
-class PublicationAuthors(CategoryCollection):
- """Publication author rows."""
-
- def __init__(self) -> None:
- """Create an empty publication-author collection."""
- super().__init__(item_type=PublicationAuthor)
-
- def add(
- self,
- *,
- name: str,
- address: str | None = None,
- footnote: str | None = None,
- id_orcid: str | None = None,
- id_iucr: str | None = None,
- ) -> PublicationAuthor:
- """
- Add or replace one publication author row.
-
- Parameters
- ----------
- name : str
- Author name.
- address : str | None, default=None
- Author address.
- footnote : str | None, default=None
- Author footnote.
- id_orcid : str | None, default=None
- Author ORCID identifier.
- id_iucr : str | None, default=None
- Author IUCr identifier.
-
- Returns
- -------
- PublicationAuthor
- The inserted author row.
- """
- author = PublicationAuthor()
- author.name = name
- author.address = address
- author.footnote = footnote
- author.id_orcid = id_orcid
- author.id_iucr = id_iucr
- super().add(author)
- return author
-
-
-@PublicationFactory.register
-class Publication(CategoryOwner):
- """Project publication metadata facade."""
-
- type_info = TypeInfo(
- tag='default',
- description='Project publication metadata',
- )
-
- def __init__(self) -> None:
- super().__init__()
- self._journal = PublicationJournal()
- self._journal_date = PublicationJournalDate()
- self._journal_coeditor = PublicationJournalCoeditor()
- self._contact_author = PublicationContactAuthor()
- self._body = PublicationBody()
- self._authors = PublicationAuthors()
-
- @property
- def journal(self) -> PublicationJournal:
- """Journal metadata."""
- return self._journal
-
- @property
- def journal_date(self) -> PublicationJournalDate:
- """Journal editorial date metadata."""
- return self._journal_date
-
- @property
- def journal_coeditor(self) -> PublicationJournalCoeditor:
- """Journal coeditor metadata."""
- return self._journal_coeditor
-
- @property
- def contact_author(self) -> PublicationContactAuthor:
- """Publication contact-author metadata."""
- return self._contact_author
-
- @property
- def body(self) -> PublicationBody:
- """Publication body metadata."""
- return self._body
-
- @property
- def authors(self) -> PublicationAuthors:
- """Publication author rows."""
- return self._authors
-
- @property
- def as_cif(self) -> str:
- """Serialize publication metadata to CIF."""
- from easydiffraction.io.cif.serialize import category_owner_to_cif # noqa: PLC0415
-
- return category_owner_to_cif(self)
-
- def from_cif(self, block: object) -> None:
- """
- Populate publication metadata from a project CIF block.
-
- Parameters
- ----------
- block : object
- Parsed CIF block containing project-level publication tags.
- """
- for category in self.categories:
- category.from_cif(block)
-
- def load(self, path: str | pathlib.Path) -> None:
- """
- Load publication metadata from a TOML or JSON file.
-
- Parameters
- ----------
- path : str | pathlib.Path
- File path ending in ``.toml`` or ``.json``.
- """
- load_publication(self, path)
diff --git a/src/easydiffraction/project/categories/rendering_plot/__init__.py b/src/easydiffraction/project/categories/rendering_plot/__init__.py
new file mode 100644
index 000000000..140a114ec
--- /dev/null
+++ b/src/easydiffraction/project/categories/rendering_plot/__init__.py
@@ -0,0 +1,8 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+"""Project rendering_plot category exports."""
+
+from __future__ import annotations
+
+from easydiffraction.project.categories.rendering_plot.default import RenderingPlot
+from easydiffraction.project.categories.rendering_plot.factory import RenderingPlotFactory
diff --git a/src/easydiffraction/project/categories/chart/default.py b/src/easydiffraction/project/categories/rendering_plot/default.py
similarity index 70%
rename from src/easydiffraction/project/categories/chart/default.py
rename to src/easydiffraction/project/categories/rendering_plot/default.py
index 5d45cdcea..bf058a661 100644
--- a/src/easydiffraction/project/categories/chart/default.py
+++ b/src/easydiffraction/project/categories/rendering_plot/default.py
@@ -1,6 +1,6 @@
# SPDX-FileCopyrightText: 2026 EasyScience contributors
# SPDX-License-Identifier: BSD-3-Clause
-"""Project chart category."""
+"""Project rendering_plot category."""
from __future__ import annotations
@@ -15,25 +15,25 @@
from easydiffraction.display.plotting import PlotterFactory
from easydiffraction.io.cif.handler import CifHandler
from easydiffraction.io.cif.parse import read_cif_str
-from easydiffraction.project.categories.chart.factory import ChartFactory
+from easydiffraction.project.categories.rendering_plot.factory import RenderingPlotFactory
from easydiffraction.utils.logging import log
AUTO_ENGINE = 'auto'
-AUTO_DESCRIPTION = 'Environment default chart engine'
+AUTO_DESCRIPTION = 'Environment default rendering_plot engine'
CHART_ENGINE_OPTIONS = [AUTO_ENGINE, *[member.value for member in PlotterEngineEnum]]
-@ChartFactory.register
-class Chart(CategoryItem, SwitchableCategoryBase):
- """Chart engine selection for a project."""
+@RenderingPlotFactory.register
+class RenderingPlot(CategoryItem, SwitchableCategoryBase):
+ """RenderingPlot engine selection for a project."""
- _category_code = 'chart'
- _owner_attr_name = 'chart'
- _swap_method_name = '_swap_chart'
+ _category_code = 'rendering_plot'
+ _owner_attr_name = 'rendering_plot'
+ _swap_method_name = '_swap_rendering_plot'
type_info = TypeInfo(
tag='default',
- description='Project chart category',
+ description='Project rendering_plot category',
)
def __init__(self) -> None:
@@ -42,14 +42,14 @@ def __init__(self) -> None:
self._plotter = Plotter()
self._type = StringDescriptor(
name='type',
- description='Chart renderer backend type',
+ description='RenderingPlot renderer backend type',
value_spec=AttributeSpec(
default=AUTO_ENGINE,
validator=MembershipValidator(
allowed=CHART_ENGINE_OPTIONS,
),
),
- cif_handler=CifHandler(names=['_chart.type']),
+ cif_handler=CifHandler(names=['_rendering_plot.type']),
)
@staticmethod
@@ -61,9 +61,9 @@ def _resolved_engine(value: str) -> str:
def _set_type(self, value: str, *, strict: bool = True) -> None:
if value not in CHART_ENGINE_OPTIONS:
msg = (
- f"Unsupported chart type '{value}'. "
+ f"Unsupported rendering_plot type '{value}'. "
f'Supported: {CHART_ENGINE_OPTIONS}. '
- f"For more information, use 'chart.show_supported()'"
+ f"For more information, use 'rendering_plot.show_supported()'"
)
if strict:
raise ValueError(msg)
@@ -87,14 +87,14 @@ def plotter(self) -> Plotter:
def _supported_types(
filters: dict[str, object],
) -> list[tuple[str, str]]:
- """Return supported chart renderer backends."""
+ """Return supported rendering_plot renderer backends."""
del filters
return [(AUTO_ENGINE, AUTO_DESCRIPTION), *PlotterFactory.descriptions()]
def from_cif(self, block: object, idx: int = 0) -> None:
- """Populate this chart category from a CIF block."""
+ """Populate this rendering_plot category from a CIF block."""
del idx
- chart_type = read_cif_str(block, '_chart.type')
- if chart_type is None:
+ rendering_plot_type = read_cif_str(block, '_rendering_plot.type')
+ if rendering_plot_type is None:
return
- self._parent._swap_chart(chart_type, strict=False)
+ self._parent._swap_rendering_plot(rendering_plot_type, strict=False)
diff --git a/src/easydiffraction/project/categories/publication/factory.py b/src/easydiffraction/project/categories/rendering_plot/factory.py
similarity index 68%
rename from src/easydiffraction/project/categories/publication/factory.py
rename to src/easydiffraction/project/categories/rendering_plot/factory.py
index 0f2926339..2d7c2ab65 100644
--- a/src/easydiffraction/project/categories/publication/factory.py
+++ b/src/easydiffraction/project/categories/rendering_plot/factory.py
@@ -1,6 +1,6 @@
# SPDX-FileCopyrightText: 2026 EasyScience contributors
# SPDX-License-Identifier: BSD-3-Clause
-"""Factory for project publication metadata owners."""
+"""Factory for project rendering_plot categories."""
from __future__ import annotations
@@ -9,8 +9,8 @@
from easydiffraction.core.factory import FactoryBase
-class PublicationFactory(FactoryBase):
- """Create project publication metadata owners."""
+class RenderingPlotFactory(FactoryBase):
+ """Create project rendering_plot category instances."""
_default_rules: ClassVar[dict] = {
frozenset(): 'default',
diff --git a/src/easydiffraction/project/categories/rendering_structure/__init__.py b/src/easydiffraction/project/categories/rendering_structure/__init__.py
new file mode 100644
index 000000000..228378d45
--- /dev/null
+++ b/src/easydiffraction/project/categories/rendering_structure/__init__.py
@@ -0,0 +1,10 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+"""Project rendering_structure category exports."""
+
+from __future__ import annotations
+
+from easydiffraction.project.categories.rendering_structure.default import RenderingStructure
+from easydiffraction.project.categories.rendering_structure.factory import (
+ RenderingStructureFactory,
+)
diff --git a/src/easydiffraction/project/categories/rendering_structure/default.py b/src/easydiffraction/project/categories/rendering_structure/default.py
new file mode 100644
index 000000000..5a59ec7d4
--- /dev/null
+++ b/src/easydiffraction/project/categories/rendering_structure/default.py
@@ -0,0 +1,98 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+"""Project rendering_structure category (switchable renderer engine)."""
+
+from __future__ import annotations
+
+from easydiffraction.core.category import CategoryItem
+from easydiffraction.core.metadata import TypeInfo
+from easydiffraction.core.switchable import SwitchableCategoryBase
+from easydiffraction.core.validation import AttributeSpec
+from easydiffraction.core.validation import MembershipValidator
+from easydiffraction.core.variable import StringDescriptor
+from easydiffraction.display.structure.enums import ViewerEngineEnum
+from easydiffraction.display.structure.viewing import Viewer
+from easydiffraction.display.structure.viewing import ViewerFactory
+from easydiffraction.io.cif.handler import CifHandler
+from easydiffraction.io.cif.parse import read_cif_str
+from easydiffraction.project.categories.rendering_structure.factory import (
+ RenderingStructureFactory,
+)
+from easydiffraction.utils.logging import log
+
+AUTO_ENGINE = 'auto'
+AUTO_DESCRIPTION = 'Environment default structure-view engine'
+VIEW_ENGINE_OPTIONS = [AUTO_ENGINE, *[member.value for member in ViewerEngineEnum]]
+
+
+@RenderingStructureFactory.register
+class RenderingStructure(CategoryItem, SwitchableCategoryBase):
+ """Renderer engine selection for the project structure view."""
+
+ _category_code = 'rendering_structure'
+ _owner_attr_name = 'rendering_structure'
+ _swap_method_name = '_swap_rendering_structure'
+
+ type_info = TypeInfo(
+ tag='default',
+ description='Project rendering_structure category',
+ )
+
+ def __init__(self) -> None:
+ super().__init__()
+
+ self._viewer = Viewer()
+ self._type = StringDescriptor(
+ name='type',
+ description='Structure-view renderer backend type',
+ value_spec=AttributeSpec(
+ default=AUTO_ENGINE,
+ validator=MembershipValidator(allowed=VIEW_ENGINE_OPTIONS),
+ ),
+ cif_handler=CifHandler(names=['_rendering_structure.type']),
+ )
+
+ @staticmethod
+ def _resolved_engine(value: str) -> str:
+ if value == AUTO_ENGINE:
+ return ViewerEngineEnum.default().value
+ return value
+
+ def _set_type(self, value: str, *, strict: bool = True) -> None:
+ if value not in VIEW_ENGINE_OPTIONS:
+ msg = (
+ f"Unsupported rendering_structure type '{value}'. "
+ f'Supported: {VIEW_ENGINE_OPTIONS}. '
+ f"For more information, use 'rendering_structure.show_supported()'"
+ )
+ if strict:
+ raise ValueError(msg)
+ log.warning(msg)
+ return
+ resolved_engine = self._resolved_engine(value)
+ if self._viewer.engine != resolved_engine:
+ self._viewer.engine = resolved_engine
+ self._type.value = value
+
+ @staticmethod
+ def _supported_types(filters: dict[str, object]) -> list[tuple[str, str]]:
+ """Return supported structure-view renderer backends."""
+ del filters
+ return [(AUTO_ENGINE, AUTO_DESCRIPTION), *ViewerFactory.descriptions()]
+
+ @property
+ def viewer(self) -> Viewer:
+ """Live structure-view facade bound to the active engine."""
+ return self._viewer
+
+ def from_cif(self, block: object, idx: int = 0) -> None:
+ """Populate this category from a CIF block, rebinding engine."""
+ super().from_cif(block, idx)
+ view_type = read_cif_str(block, '_rendering_structure.type')
+ if view_type is not None:
+ self._parent._swap_rendering_structure(view_type, strict=False)
+
+ @property
+ def as_cif(self) -> str:
+ """Return the CIF text for this rendering_structure category."""
+ return super().as_cif
diff --git a/src/easydiffraction/project/categories/rendering_structure/factory.py b/src/easydiffraction/project/categories/rendering_structure/factory.py
new file mode 100644
index 000000000..cdcc6d251
--- /dev/null
+++ b/src/easydiffraction/project/categories/rendering_structure/factory.py
@@ -0,0 +1,17 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+"""Factory for project view categories."""
+
+from __future__ import annotations
+
+from typing import ClassVar
+
+from easydiffraction.core.factory import FactoryBase
+
+
+class RenderingStructureFactory(FactoryBase):
+ """Create project rendering_structure category instances."""
+
+ _default_rules: ClassVar[dict] = {
+ frozenset(): 'default',
+ }
diff --git a/src/easydiffraction/project/categories/rendering_table/__init__.py b/src/easydiffraction/project/categories/rendering_table/__init__.py
new file mode 100644
index 000000000..59a65396a
--- /dev/null
+++ b/src/easydiffraction/project/categories/rendering_table/__init__.py
@@ -0,0 +1,8 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+"""Project rendering_table category exports."""
+
+from __future__ import annotations
+
+from easydiffraction.project.categories.rendering_table.default import RenderingTable
+from easydiffraction.project.categories.rendering_table.factory import RenderingTableFactory
diff --git a/src/easydiffraction/project/categories/table/default.py b/src/easydiffraction/project/categories/rendering_table/default.py
similarity index 76%
rename from src/easydiffraction/project/categories/table/default.py
rename to src/easydiffraction/project/categories/rendering_table/default.py
index 00aa5f47a..98ef5ca47 100644
--- a/src/easydiffraction/project/categories/table/default.py
+++ b/src/easydiffraction/project/categories/rendering_table/default.py
@@ -1,6 +1,6 @@
# SPDX-FileCopyrightText: 2026 EasyScience contributors
# SPDX-License-Identifier: BSD-3-Clause
-"""Project table category."""
+"""Project rendering_table category."""
from __future__ import annotations
@@ -15,7 +15,7 @@
from easydiffraction.display.tables import TableRendererFactory
from easydiffraction.io.cif.handler import CifHandler
from easydiffraction.io.cif.parse import read_cif_str
-from easydiffraction.project.categories.table.factory import TableFactory
+from easydiffraction.project.categories.rendering_table.factory import RenderingTableFactory
from easydiffraction.utils.logging import log
AUTO_ENGINE = 'auto'
@@ -23,17 +23,17 @@
TABLE_ENGINE_OPTIONS = [AUTO_ENGINE, *[member.value for member in TableEngineEnum]]
-@TableFactory.register
-class Table(CategoryItem, SwitchableCategoryBase):
+@RenderingTableFactory.register
+class RenderingTable(CategoryItem, SwitchableCategoryBase):
"""Table engine selection for a project."""
- _category_code = 'table'
- _owner_attr_name = 'table'
- _swap_method_name = '_swap_table'
+ _category_code = 'rendering_table'
+ _owner_attr_name = 'rendering_table'
+ _swap_method_name = '_swap_rendering_table'
type_info = TypeInfo(
tag='default',
- description='Project table category',
+ description='Project rendering_table category',
)
def __init__(self) -> None:
@@ -49,7 +49,7 @@ def __init__(self) -> None:
allowed=TABLE_ENGINE_OPTIONS,
),
),
- cif_handler=CifHandler(names=['_table.type']),
+ cif_handler=CifHandler(names=['_rendering_table.type']),
)
@staticmethod
@@ -61,9 +61,9 @@ def _resolved_engine(value: str) -> str:
def _set_type(self, value: str, *, strict: bool = True) -> None:
if value not in TABLE_ENGINE_OPTIONS:
msg = (
- f"Unsupported table type '{value}'. "
+ f"Unsupported rendering_table type '{value}'. "
f'Supported: {TABLE_ENGINE_OPTIONS}. '
- f"For more information, use 'table.show_supported()'"
+ f"For more information, use 'rendering_table.show_supported()'"
)
if strict:
raise ValueError(msg)
@@ -89,9 +89,9 @@ def _supported_types(
return [(AUTO_ENGINE, AUTO_DESCRIPTION), *TableRendererFactory.descriptions()]
def from_cif(self, block: object, idx: int = 0) -> None:
- """Populate this table category from a CIF block."""
+ """Populate this rendering_table category from a CIF block."""
del idx
- table_type = read_cif_str(block, '_table.type')
+ table_type = read_cif_str(block, '_rendering_table.type')
if table_type is None:
return
- self._parent._swap_table(table_type, strict=False)
+ self._parent._swap_rendering_table(table_type, strict=False)
diff --git a/src/easydiffraction/project/categories/table/factory.py b/src/easydiffraction/project/categories/rendering_table/factory.py
similarity index 78%
rename from src/easydiffraction/project/categories/table/factory.py
rename to src/easydiffraction/project/categories/rendering_table/factory.py
index eac859310..c7b7f0d82 100644
--- a/src/easydiffraction/project/categories/table/factory.py
+++ b/src/easydiffraction/project/categories/rendering_table/factory.py
@@ -9,8 +9,8 @@
from easydiffraction.core.factory import FactoryBase
-class TableFactory(FactoryBase):
- """Create project table category instances."""
+class RenderingTableFactory(FactoryBase):
+ """Create project rendering_table category instances."""
_default_rules: ClassVar[dict] = {
frozenset(): 'default',
diff --git a/src/easydiffraction/project/categories/report/default.py b/src/easydiffraction/project/categories/report/default.py
index 054b0b398..156cd2f98 100644
--- a/src/easydiffraction/project/categories/report/default.py
+++ b/src/easydiffraction/project/categories/report/default.py
@@ -195,7 +195,7 @@ def as_html(self, *, offline: bool = False) -> str:
"""
from easydiffraction.report.html_renderer import render_html_report # noqa: PLC0415
- return render_html_report(self.data_context(), offline=offline)
+ return render_html_report(self.data_context(), offline=offline, project=self.project)
def save_tex(self) -> pathlib.Path:
"""
diff --git a/src/easydiffraction/project/categories/structure_style/__init__.py b/src/easydiffraction/project/categories/structure_style/__init__.py
new file mode 100644
index 000000000..bc92b70c7
--- /dev/null
+++ b/src/easydiffraction/project/categories/structure_style/__init__.py
@@ -0,0 +1,8 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+"""Project structure_style category exports."""
+
+from __future__ import annotations
+
+from easydiffraction.project.categories.structure_style.default import StructureStyle
+from easydiffraction.project.categories.structure_style.factory import StructureStyleFactory
diff --git a/src/easydiffraction/project/categories/structure_style/default.py b/src/easydiffraction/project/categories/structure_style/default.py
new file mode 100644
index 000000000..10ec8ac5f
--- /dev/null
+++ b/src/easydiffraction/project/categories/structure_style/default.py
@@ -0,0 +1,105 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+"""
+Project structure_style category (durable structure-view appearance).
+"""
+
+from __future__ import annotations
+
+from easydiffraction.core.category import CategoryItem
+from easydiffraction.core.metadata import TypeInfo
+from easydiffraction.core.validation import AttributeSpec
+from easydiffraction.core.validation import RangeValidator
+from easydiffraction.core.variable import EnumDescriptor
+from easydiffraction.core.variable import NumericDescriptor
+from easydiffraction.display.structure.enums import AtomViewEnum
+from easydiffraction.display.structure.enums import ColorSchemeEnum
+from easydiffraction.io.cif.handler import CifHandler
+from easydiffraction.project.categories.structure_style.factory import StructureStyleFactory
+
+
+@StructureStyleFactory.register
+class StructureStyle(CategoryItem):
+ """How the structure view looks (appearance, not per-element)."""
+
+ _category_code = 'structure_style'
+
+ type_info = TypeInfo(
+ tag='default',
+ description='Project structure_style category',
+ )
+
+ def __init__(self) -> None:
+ super().__init__()
+
+ self._atom_view = EnumDescriptor(
+ name='atom_view',
+ enum=AtomViewEnum,
+ description='How atoms are sized and shaped in the structure view.',
+ cif_handler=CifHandler(names=['_structure_style.atom_view']),
+ )
+ self._color_scheme = EnumDescriptor(
+ name='color_scheme',
+ enum=ColorSchemeEnum,
+ description='Standard element colour scheme.',
+ cif_handler=CifHandler(names=['_structure_style.color_scheme']),
+ )
+ self._adp_probability = NumericDescriptor(
+ name='adp_probability',
+ description='ORTEP probability level, a fraction in (0, 1).',
+ value_spec=AttributeSpec(
+ default=0.99,
+ validator=RangeValidator(gt=0.0, lt=1.0),
+ ),
+ cif_handler=CifHandler(names=['_structure_style.adp_probability']),
+ )
+ self._atom_scale = NumericDescriptor(
+ name='atom_scale',
+ description='Overall ball-atom size factor (square-root compressed).',
+ value_spec=AttributeSpec(
+ default=0.3,
+ validator=RangeValidator(gt=0.0, le=1.0),
+ ),
+ cif_handler=CifHandler(names=['_structure_style.atom_scale']),
+ )
+
+ @property
+ def atom_view(self) -> EnumDescriptor:
+ """How atoms are sized/shaped (vdw/covalent/ionic/adp)."""
+ return self._atom_view
+
+ @atom_view.setter
+ def atom_view(self, value: str) -> None:
+ self._atom_view.value = AtomViewEnum(value).value
+
+ @property
+ def color_scheme(self) -> EnumDescriptor:
+ """Standard element colour scheme."""
+ return self._color_scheme
+
+ @color_scheme.setter
+ def color_scheme(self, value: str) -> None:
+ self._color_scheme.value = ColorSchemeEnum(value).value
+
+ @property
+ def adp_probability(self) -> NumericDescriptor:
+ """ORTEP probability level (fraction in interval (0, 1))."""
+ return self._adp_probability
+
+ @adp_probability.setter
+ def adp_probability(self, value: float) -> None:
+ self._adp_probability.value = value
+
+ @property
+ def atom_scale(self) -> NumericDescriptor:
+ """Overall ball-atom size factor in (0, 1] (sqrt compressed)."""
+ return self._atom_scale
+
+ @atom_scale.setter
+ def atom_scale(self, value: float) -> None:
+ self._atom_scale.value = value
+
+ @property
+ def as_cif(self) -> str:
+ """Return CIF text for this structure_style category."""
+ return super().as_cif
diff --git a/src/easydiffraction/project/categories/structure_style/factory.py b/src/easydiffraction/project/categories/structure_style/factory.py
new file mode 100644
index 000000000..61df09a43
--- /dev/null
+++ b/src/easydiffraction/project/categories/structure_style/factory.py
@@ -0,0 +1,17 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+"""Factory for project structure_style categories."""
+
+from __future__ import annotations
+
+from typing import ClassVar
+
+from easydiffraction.core.factory import FactoryBase
+
+
+class StructureStyleFactory(FactoryBase):
+ """Create project structure_style category instances."""
+
+ _default_rules: ClassVar[dict] = {
+ frozenset(): 'default',
+ }
diff --git a/src/easydiffraction/project/categories/structure_view/__init__.py b/src/easydiffraction/project/categories/structure_view/__init__.py
new file mode 100644
index 000000000..aafd3e971
--- /dev/null
+++ b/src/easydiffraction/project/categories/structure_view/__init__.py
@@ -0,0 +1,8 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+"""Project structure_view category exports."""
+
+from __future__ import annotations
+
+from easydiffraction.project.categories.structure_view.default import StructureView
+from easydiffraction.project.categories.structure_view.factory import StructureViewFactory
diff --git a/src/easydiffraction/project/categories/structure_view/default.py b/src/easydiffraction/project/categories/structure_view/default.py
new file mode 100644
index 000000000..bf8ef46a4
--- /dev/null
+++ b/src/easydiffraction/project/categories/structure_view/default.py
@@ -0,0 +1,191 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+"""
+Project structure_view category (durable content + region view state).
+"""
+
+from __future__ import annotations
+
+from easydiffraction.core.category import CategoryItem
+from easydiffraction.core.metadata import TypeInfo
+from easydiffraction.core.validation import AttributeSpec
+from easydiffraction.core.variable import BoolDescriptor
+from easydiffraction.core.variable import NumericDescriptor
+from easydiffraction.io.cif.handler import CifHandler
+from easydiffraction.project.categories.structure_view.factory import StructureViewFactory
+from easydiffraction.utils.logging import log
+
+
+def _range_descriptor(name: str, default: float) -> NumericDescriptor:
+ return NumericDescriptor(
+ name=name,
+ description='Per-axis fractional view-range bound.',
+ value_spec=AttributeSpec(default=default),
+ cif_handler=CifHandler(names=[f'_structure_view.{name}']),
+ )
+
+
+@StructureViewFactory.register
+class StructureView(CategoryItem):
+ """What and where to draw in the structure view (engine-neutral)."""
+
+ _category_code = 'structure_view'
+
+ type_info = TypeInfo(
+ tag='default',
+ description='Project structure_view category',
+ )
+
+ def __init__(self) -> None:
+ super().__init__()
+
+ self._show_labels = BoolDescriptor(
+ name='show_labels',
+ description='Show atom labels when the view opens.',
+ value_spec=AttributeSpec(default=False),
+ cif_handler=CifHandler(names=['_structure_view.show_labels']),
+ )
+ self._show_moments = BoolDescriptor(
+ name='show_moments',
+ description='Show magnetic-moment arrows where the data exists.',
+ value_spec=AttributeSpec(default=True),
+ cif_handler=CifHandler(names=['_structure_view.show_moments']),
+ )
+ self._range_a_min = _range_descriptor('range_a_min', 0.0)
+ self._range_a_max = _range_descriptor('range_a_max', 1.0)
+ self._range_b_min = _range_descriptor('range_b_min', 0.0)
+ self._range_b_max = _range_descriptor('range_b_max', 1.0)
+ self._range_c_min = _range_descriptor('range_c_min', 0.0)
+ self._range_c_max = _range_descriptor('range_c_max', 1.0)
+
+ @property
+ def show_labels(self) -> BoolDescriptor:
+ """Whether atom labels are shown when the view opens."""
+ return self._show_labels
+
+ @show_labels.setter
+ def show_labels(self, value: bool) -> None:
+ self._show_labels.value = value
+
+ @property
+ def show_moments(self) -> BoolDescriptor:
+ """Whether moment arrows are shown where the data exists."""
+ return self._show_moments
+
+ @show_moments.setter
+ def show_moments(self, value: bool) -> None:
+ self._show_moments.value = value
+
+ @staticmethod
+ def _set_bound(
+ descriptor: NumericDescriptor,
+ value: float,
+ *,
+ lower: float,
+ upper: float,
+ ) -> None:
+ if not lower < value < upper:
+ log.warning(
+ f"'{descriptor.name}' = {value} violates min < max on its axis; ignored.",
+ )
+ return
+ descriptor.value = value
+
+ @property
+ def range_a_min(self) -> NumericDescriptor:
+ """Lower fractional bound along a."""
+ return self._range_a_min
+
+ @range_a_min.setter
+ def range_a_min(self, value: float) -> None:
+ self._set_bound(
+ self._range_a_min,
+ value,
+ lower=float('-inf'),
+ upper=self._range_a_max.value,
+ )
+
+ @property
+ def range_a_max(self) -> NumericDescriptor:
+ """Upper fractional bound along a."""
+ return self._range_a_max
+
+ @range_a_max.setter
+ def range_a_max(self, value: float) -> None:
+ self._set_bound(
+ self._range_a_max,
+ value,
+ lower=self._range_a_min.value,
+ upper=float('inf'),
+ )
+
+ @property
+ def range_b_min(self) -> NumericDescriptor:
+ """Lower fractional bound along b."""
+ return self._range_b_min
+
+ @range_b_min.setter
+ def range_b_min(self, value: float) -> None:
+ self._set_bound(
+ self._range_b_min,
+ value,
+ lower=float('-inf'),
+ upper=self._range_b_max.value,
+ )
+
+ @property
+ def range_b_max(self) -> NumericDescriptor:
+ """Upper fractional bound along b."""
+ return self._range_b_max
+
+ @range_b_max.setter
+ def range_b_max(self, value: float) -> None:
+ self._set_bound(
+ self._range_b_max,
+ value,
+ lower=self._range_b_min.value,
+ upper=float('inf'),
+ )
+
+ @property
+ def range_c_min(self) -> NumericDescriptor:
+ """Lower fractional bound along c."""
+ return self._range_c_min
+
+ @range_c_min.setter
+ def range_c_min(self, value: float) -> None:
+ self._set_bound(
+ self._range_c_min,
+ value,
+ lower=float('-inf'),
+ upper=self._range_c_max.value,
+ )
+
+ @property
+ def range_c_max(self) -> NumericDescriptor:
+ """Upper fractional bound along c."""
+ return self._range_c_max
+
+ @range_c_max.setter
+ def range_c_max(self, value: float) -> None:
+ self._set_bound(
+ self._range_c_max,
+ value,
+ lower=self._range_c_min.value,
+ upper=float('inf'),
+ )
+
+ def view_range(self) -> tuple[tuple[float, float], tuple[float, float], tuple[float, float]]:
+ """
+ Assemble the per-axis ``((min, max), ...)`` fractional window.
+ """
+ return (
+ (self._range_a_min.value, self._range_a_max.value),
+ (self._range_b_min.value, self._range_b_max.value),
+ (self._range_c_min.value, self._range_c_max.value),
+ )
+
+ @property
+ def as_cif(self) -> str:
+ """Return CIF representation of this structure_view category."""
+ return super().as_cif
diff --git a/src/easydiffraction/project/categories/structure_view/factory.py b/src/easydiffraction/project/categories/structure_view/factory.py
new file mode 100644
index 000000000..8e9ca467b
--- /dev/null
+++ b/src/easydiffraction/project/categories/structure_view/factory.py
@@ -0,0 +1,17 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+"""Factory for project structure_view categories."""
+
+from __future__ import annotations
+
+from typing import ClassVar
+
+from easydiffraction.core.factory import FactoryBase
+
+
+class StructureViewFactory(FactoryBase):
+ """Create project structure_view category instances."""
+
+ _default_rules: ClassVar[dict] = {
+ frozenset(): 'default',
+ }
diff --git a/src/easydiffraction/project/categories/table/__init__.py b/src/easydiffraction/project/categories/table/__init__.py
deleted file mode 100644
index 810cb5c97..000000000
--- a/src/easydiffraction/project/categories/table/__init__.py
+++ /dev/null
@@ -1,8 +0,0 @@
-# SPDX-FileCopyrightText: 2026 EasyScience contributors
-# SPDX-License-Identifier: BSD-3-Clause
-"""Project table category exports."""
-
-from __future__ import annotations
-
-from easydiffraction.project.categories.table.default import Table
-from easydiffraction.project.categories.table.factory import TableFactory
diff --git a/src/easydiffraction/project/categories/verbosity/default.py b/src/easydiffraction/project/categories/verbosity/default.py
index 09b3821c6..ad7d6b78b 100644
--- a/src/easydiffraction/project/categories/verbosity/default.py
+++ b/src/easydiffraction/project/categories/verbosity/default.py
@@ -6,9 +6,7 @@
from easydiffraction.core.category import CategoryItem
from easydiffraction.core.metadata import TypeInfo
-from easydiffraction.core.validation import AttributeSpec
-from easydiffraction.core.validation import MembershipValidator
-from easydiffraction.core.variable import StringDescriptor
+from easydiffraction.core.variable import EnumDescriptor
from easydiffraction.io.cif.handler import CifHandler
from easydiffraction.project.categories.verbosity.factory import VerbosityFactory
from easydiffraction.utils.enums import VerbosityEnum
@@ -28,20 +26,15 @@ class Verbosity(CategoryItem):
def __init__(self) -> None:
super().__init__()
- self._fit = StringDescriptor(
+ self._fit = EnumDescriptor(
name='fit',
+ enum=VerbosityEnum,
description='Fitting process output verbosity',
- value_spec=AttributeSpec(
- default=VerbosityEnum.default().value,
- validator=MembershipValidator(
- allowed=[member.value for member in VerbosityEnum],
- ),
- ),
cif_handler=CifHandler(names=['_verbosity.fit']),
)
@property
- def fit(self) -> StringDescriptor:
+ def fit(self) -> EnumDescriptor:
"""Fitting process output verbosity."""
return self._fit
diff --git a/src/easydiffraction/project/display.py b/src/easydiffraction/project/display.py
index fb5983d8c..3c07970fb 100644
--- a/src/easydiffraction/project/display.py
+++ b/src/easydiffraction/project/display.py
@@ -27,6 +27,9 @@
from easydiffraction.project.project import Project
+StructureViewRange = tuple[tuple[float, float], tuple[float, float], tuple[float, float]]
+
+
_PATTERN_OPTION_DESCRIPTIONS: dict[str, str] = {
'auto': 'Show the most informative available pattern view.',
'measured': 'Measured diffraction intensities.',
@@ -39,6 +42,17 @@
}
+_STRUCTURE_OPTION_DESCRIPTIONS: dict[str, str] = {
+ 'auto': 'Show the features the structure and engine support.',
+ 'atoms': 'Atoms as spheres, occupancy wedges, or ADP ellipsoids.',
+ 'bonds': 'Bonds between atoms within the per-structure cutoffs.',
+ 'cell': 'Unit-cell edges.',
+ 'axes': 'The a/b/c axis triad.',
+ 'moments': 'Magnetic-moment arrows (no moment data in version 1).',
+ 'labels': 'Atom labels at each site.',
+}
+
+
@dataclass(frozen=True, slots=True)
class PatternOptionStatus:
"""Availability metadata for one ``display.pattern`` option."""
@@ -132,7 +146,7 @@ def correlations(
show_diagonal: bool = True,
) -> None:
"""Show parameter correlations from the latest fit."""
- self._project.chart.plotter.plot_param_correlations(
+ self._project.rendering_plot.plotter.plot_param_correlations(
threshold=threshold,
precision=precision,
max_parameters=max_parameters,
@@ -154,9 +168,9 @@ def series(
another.
"""
if param is None:
- self._project.chart.plotter.plot_all_param_series(versus=versus)
+ self._project.rendering_plot.plotter.plot_all_param_series(versus=versus)
else:
- self._project.chart.plotter.plot_param_series(param=param, versus=versus)
+ self._project.rendering_plot.plotter.plot_param_series(param=param, versus=versus)
def help(self) -> None:
"""Print available fit-display methods."""
@@ -199,7 +213,7 @@ def _predictive_needs_processing_indicator(
"""Return whether predictive plotting still needs processing."""
analysis = self._project.analysis
experiment = self._project.experiments[expt_name]
- plotter = self._project.chart.plotter
+ plotter = self._project.rendering_plot.plotter
_, x_axis_name, _, _, _ = plotter._resolve_x_axis(experiment.type, x)
x_axis_name = str(x_axis_name)
require_draws = plotter.engine == PlotterEngineEnum.PLOTLY.value and style in {
@@ -250,7 +264,7 @@ def pairs(
else nullcontext()
)
with indicator_context:
- self._project.chart.plotter.plot_posterior_pairs(
+ self._project.rendering_plot.plotter.plot_posterior_pairs(
parameters=parameters,
style=style,
threshold=threshold,
@@ -261,7 +275,7 @@ def distribution(self, param: object | None = None) -> None:
"""
Plot posterior distributions for one or all free parameters.
"""
- plotter = self._project.chart.plotter
+ plotter = self._project.rendering_plot.plotter
if param is not None:
plotter.plot_param_distribution(param)
return
@@ -298,7 +312,7 @@ def predictive(
else nullcontext()
)
with indicator_context:
- self._project.chart.plotter.plot_posterior_predictive(
+ self._project.rendering_plot.plotter.plot_posterior_predictive(
expt_name=expt_name,
style=style,
x_min=x_min,
@@ -374,7 +388,7 @@ def pattern(
else nullcontext()
)
with indicator_context:
- self._project.chart.plotter._plot_posterior_predictive_request(
+ self._project.rendering_plot.plotter._plot_posterior_predictive_request(
expt_name=expt_name,
style='band',
plot_options=_MeasVsCalcPlotOptions(
@@ -417,7 +431,7 @@ def pattern(
else nullcontext()
)
with indicator_context:
- self._project.chart.plotter._plot_posterior_predictive_request(
+ self._project.rendering_plot.plotter._plot_posterior_predictive_request(
expt_name=expt_name,
style='band',
plot_options=_MeasVsCalcPlotOptions(
@@ -459,6 +473,162 @@ def show_pattern_options(self, expt_name: str) -> None:
],
)
+ def structure(
+ self,
+ struct_name: str,
+ include: str | tuple[str, ...] = 'auto',
+ range: StructureViewRange | None = None,
+ path: str | None = None,
+ ) -> None:
+ """
+ Show a 3D structure view for one structure.
+
+ Parallels :meth:`pattern`: it draws with the active
+ ``project.rendering_structure`` engine and displays directly (no
+ return value). Feature visibility is resolved per ADR section 8;
+ the renderer announces and skips any feature it cannot draw.
+
+ Parameters
+ ----------
+ struct_name : str
+ Name of the structure to draw.
+ include : str | tuple[str, ...], default='auto'
+ ``'auto'`` (default) resolves features from data
+ availability, persisted ``project.rendering_structure``
+ flags, then built-in defaults; an explicit tuple of
+ ``atoms``/``bonds``/``cell``/``axes``/
+ ``moments``/``labels`` wins outright.
+ range : StructureViewRange | None, default=None
+ Optional per-axis ``((min, max), ...)`` window overriding
+ the persisted ``project.rendering_structure`` range for this
+ call only.
+ path : str | None, default=None
+ When given, write the rendered view to this path instead of
+ displaying it (a standalone HTML file for the Three.js
+ engine).
+ """
+ from easydiffraction.display.structure.builder import build_scene # noqa: PLC0415
+ from easydiffraction.display.structure.builder import ( # noqa: PLC0415
+ structure_feature_availability,
+ )
+
+ structure = self._project.structures[struct_name]
+ structure._update_categories()
+ availability = structure_feature_availability(
+ structure, style=self._project.structure_style
+ )
+ features = self._resolve_structure_features(include, availability)
+ window = range if range is not None else self._project.structure_view.view_range()
+ scene = build_scene(
+ structure,
+ style=self._project.structure_style,
+ view_range=window,
+ features=features,
+ )
+ output = self._project.rendering_structure.viewer.render(scene, features=features)
+ if path is not None:
+ import pathlib # noqa: PLC0415
+
+ pathlib.Path(path).write_text(output, encoding='utf-8')
+ return
+ atom_view = self._project.structure_style.atom_view.value
+ console.paragraph(f"Structure 🧩 '{struct_name}' (Atom view type: '{atom_view}')")
+ self._emit_structure_output(output)
+
+ def show_structure_options(self, struct_name: str) -> None:
+ """
+ Show available ``structure(include=...)`` options.
+ """
+ from easydiffraction.display.structure.builder import ( # noqa: PLC0415
+ structure_feature_availability,
+ )
+
+ structure = self._project.structures[struct_name]
+ structure._update_categories()
+ availability = structure_feature_availability(
+ structure, style=self._project.structure_style
+ )
+ supported = self._project.rendering_structure.viewer.supported_features()
+ auto = self._resolve_structure_features('auto', availability)
+
+ rows = []
+ for option in ('atoms', 'bonds', 'cell', 'axes', 'moments', 'labels'):
+ in_data = option in availability.available
+ in_engine = option in supported
+ rows.append([
+ option,
+ _STRUCTURE_OPTION_DESCRIPTIONS[option],
+ 'yes' if (in_data and in_engine) else 'no',
+ 'yes' if (option in auto and in_engine) else 'no',
+ ])
+ render_table(
+ columns_headers=['Option', 'Description', 'Available', 'Auto'],
+ columns_alignment=['left', 'left', 'center', 'center'],
+ columns_data=rows,
+ )
+ if availability.radius_substitutions:
+ console.paragraph('Radius substitutions (fell back to covalent)')
+ console.print(', '.join(availability.radius_substitutions))
+
+ def _resolve_structure_features(
+ self,
+ include: str | tuple[str, ...],
+ availability: object,
+ ) -> frozenset[str]:
+ """
+ Resolve the concrete feature set per ADR section 8 precedence.
+ """
+ normalized = self._normalize_structure_include(include)
+ if normalized != ('auto',):
+ return frozenset(normalized)
+ view = self._project.structure_view
+ resolved = {f for f in ('atoms', 'bonds', 'cell', 'axes') if f in availability.available}
+ if 'labels' in availability.available and view.show_labels.value:
+ resolved.add('labels')
+ if 'moments' in availability.available and view.show_moments.value:
+ resolved.add('moments')
+ return frozenset(resolved)
+
+ @staticmethod
+ def _normalize_structure_include(include: str | tuple[str, ...]) -> tuple[str, ...]:
+ """Validate and normalize a ``structure(include=...)`` value."""
+ values = (include,) if isinstance(include, str) else include
+ if not values:
+ msg = 'include must contain at least one option.'
+ raise ValueError(msg)
+ normalized = tuple(dict.fromkeys(values))
+ unknown = [value for value in normalized if value not in _STRUCTURE_OPTION_DESCRIPTIONS]
+ if unknown:
+ msg = f'Unknown structure include option(s): {unknown}.'
+ raise ValueError(msg)
+ if 'auto' in normalized and len(normalized) > 1:
+ msg = "include='auto' cannot be combined with other options."
+ raise ValueError(msg)
+ return normalized
+
+ def _emit_structure_output(self, output: str) -> None:
+ """Display ASCII text in the console or HTML in a notebook."""
+ from easydiffraction.display.structure.enums import ViewerEngineEnum # noqa: PLC0415
+ from easydiffraction.utils.environment import in_jupyter # noqa: PLC0415
+
+ if self._project.rendering_structure.viewer.engine == ViewerEngineEnum.ASCII.value:
+ # Built-in print keeps the renderer's raw ANSI colour
+ # codes (Jupyter and terminals interpret them); Rich's
+ # console.print would escape and garble them. Mirrors
+ # the ASCII pattern plotter.
+ print(output)
+ return
+ if in_jupyter():
+ from IPython.display import HTML # noqa: PLC0415
+ from IPython.display import display # noqa: PLC0415
+
+ display(HTML(output))
+ return
+ console.print(
+ 'Three.js structure view generated as HTML. Pass path=... to save it, '
+ "or set project.rendering_structure.type = 'ascii' for a terminal view.",
+ )
+
@staticmethod
def _normalize_include(include: str | tuple[str, ...]) -> tuple[str, ...]:
"""Validate and normalize a ``pattern(include=...)`` value."""
@@ -591,7 +761,7 @@ def _show_point_estimate_pattern(
self._validate_requested_include(statuses, include)
include_set = set(include)
if include_set == {'measured'}:
- self._project.chart.plotter.plot_meas(
+ self._project.rendering_plot.plotter.plot_meas(
expt_name=expt_name,
x_min=x_min,
x_max=x_max,
@@ -600,7 +770,7 @@ def _show_point_estimate_pattern(
)
return
if include_set == {'measured', 'excluded'}:
- self._project.chart.plotter.plot_meas(
+ self._project.rendering_plot.plotter.plot_meas(
expt_name=expt_name,
x_min=x_min,
x_max=x_max,
@@ -609,7 +779,7 @@ def _show_point_estimate_pattern(
)
return
if include_set == {'calculated'}:
- self._project.chart.plotter.plot_calc(
+ self._project.rendering_plot.plotter.plot_calc(
expt_name=expt_name,
x_min=x_min,
x_max=x_max,
@@ -618,7 +788,7 @@ def _show_point_estimate_pattern(
)
return
if include_set == {'calculated', 'excluded'}:
- self._project.chart.plotter.plot_calc(
+ self._project.rendering_plot.plotter.plot_calc(
expt_name=expt_name,
x_min=x_min,
x_max=x_max,
@@ -627,7 +797,7 @@ def _show_point_estimate_pattern(
)
return
if {'measured', 'calculated'}.issubset(include_set):
- self._project.chart.plotter._plot_meas_vs_calc_request(
+ self._project.rendering_plot.plotter._plot_meas_vs_calc_request(
expt_name=expt_name,
plot_options=_MeasVsCalcPlotOptions(
x_min=x_min,
@@ -649,7 +819,7 @@ def _show_point_estimate_pattern(
def _pattern_option_statuses(self, expt_name: str) -> list[PatternOptionStatus]:
"""Return availability details for the requested experiment."""
- self._project.chart.plotter._update_project_categories(expt_name)
+ self._project.rendering_plot.plotter._update_project_categories(expt_name)
experiment = self._project.experiments[expt_name]
pattern = intensity_category_for(experiment)
sample_form = experiment.type.sample_form.value
@@ -873,9 +1043,9 @@ def _uncertainty_status(
if not posterior_predictive:
return False, 'Posterior predictive data is unavailable.'
- active_chart_engine = getattr(self._project.chart.plotter, 'engine', None)
+ active_chart_engine = getattr(self._project.rendering_plot.plotter, 'engine', None)
if active_chart_engine is None:
- active_chart_engine = self._project.chart.type
+ active_chart_engine = self._project.rendering_plot.type
if active_chart_engine != PlotterEngineEnum.PLOTLY.value:
return False, 'Uncertainty bands currently require the Plotly chart engine.'
diff --git a/src/easydiffraction/project/project.py b/src/easydiffraction/project/project.py
index c1c42eadb..6cffbd76a 100644
--- a/src/easydiffraction/project/project.py
+++ b/src/easydiffraction/project/project.py
@@ -23,8 +23,6 @@
from easydiffraction.io.cif.serialize import project_to_cif
from easydiffraction.io.results_sidecar import read_analysis_results_sidecar
from easydiffraction.io.results_sidecar import write_analysis_results_sidecar
-from easydiffraction.project.categories.publication import Publication
-from easydiffraction.project.categories.publication import PublicationFactory
from easydiffraction.project.display import ProjectDisplay
from easydiffraction.project.project_config import ProjectConfig
from easydiffraction.utils.enums import VerbosityEnum
@@ -36,8 +34,11 @@
if TYPE_CHECKING:
from collections.abc import Callable
- from easydiffraction.project.categories.chart import Chart
- from easydiffraction.project.categories.table import Table
+ from easydiffraction.project.categories.rendering_plot import RenderingPlot
+ from easydiffraction.project.categories.rendering_structure import RenderingStructure
+ from easydiffraction.project.categories.rendering_table import RenderingTable
+ from easydiffraction.project.categories.structure_style import StructureStyle
+ from easydiffraction.project.categories.structure_view import StructureView
from easydiffraction.project.categories.verbosity import Verbosity
from easydiffraction.project.project_info import ProjectInfo
from easydiffraction.report import Report
@@ -207,11 +208,13 @@ def __init__(
object.__setattr__(self, '_info', self._config.info)
self._structures = Structures()
self._experiments = Experiments()
- object.__setattr__(self, '_chart', self._config.chart)
- object.__setattr__(self, '_table', self._config.table)
+ object.__setattr__(self, '_rendering_plot', self._config.rendering_plot)
+ object.__setattr__(self, '_rendering_table', self._config.rendering_table)
object.__setattr__(self, '_verbosity', self._config.verbosity)
+ object.__setattr__(self, '_rendering_structure', self._config.rendering_structure)
+ object.__setattr__(self, '_structure_view', self._config.structure_view)
+ object.__setattr__(self, '_structure_style', self._config.structure_style)
object.__setattr__(self, '_report', self._config.report)
- self._publication = PublicationFactory.create(PublicationFactory.default_tag())
self._display = ProjectDisplay(self)
self._analysis = Analysis(self)
self._saved = False
@@ -224,10 +227,12 @@ def _attach_category_parents(self) -> None:
self._structures._parent = self
self._experiments._parent = self
self._analysis._parent = self
- self._chart._parent = self
- self._table._parent = self
+ self._rendering_plot._parent = self
+ self._rendering_table._parent = self
+ self._rendering_structure._parent = self
+ self._structure_view._parent = self
+ self._structure_style._parent = self
self._report._parent = self
- self._publication._parent = self
@staticmethod
def _supported_filters_for(category: object) -> dict[str, object]:
@@ -235,13 +240,17 @@ def _supported_filters_for(category: object) -> dict[str, object]:
del category
return {}
- def _swap_chart(self, new_type: str, *, strict: bool = True) -> None:
+ def _swap_rendering_plot(self, new_type: str, *, strict: bool = True) -> None:
"""Switch the active chart renderer."""
- self._chart._set_type(new_type, strict=strict)
+ self._rendering_plot._set_type(new_type, strict=strict)
- def _swap_table(self, new_type: str, *, strict: bool = True) -> None:
+ def _swap_rendering_table(self, new_type: str, *, strict: bool = True) -> None:
"""Switch the active table renderer."""
- self._table._set_type(new_type, strict=strict)
+ self._rendering_table._set_type(new_type, strict=strict)
+
+ def _swap_rendering_structure(self, new_type: str, *, strict: bool = True) -> None:
+ """Switch the active structure-view renderer."""
+ self._rendering_structure._set_type(new_type, strict=strict)
@classmethod
def current_project_path(cls) -> pathlib.Path | None:
@@ -313,14 +322,29 @@ def experiments(self, experiments: Experiments) -> None:
self._experiments = experiments
@property
- def chart(self) -> Chart:
+ def rendering_plot(self) -> RenderingPlot:
"""Chart configuration bound to the project."""
- return self._chart
+ return self._rendering_plot
@property
- def table(self) -> Table:
+ def rendering_table(self) -> RenderingTable:
"""Table configuration bound to the project."""
- return self._table
+ return self._rendering_table
+
+ @property
+ def rendering_structure(self) -> RenderingStructure:
+ """Structure-view configuration bound to the project."""
+ return self._rendering_structure
+
+ @property
+ def structure_view(self) -> StructureView:
+ """Structure-view content and region bound to the project."""
+ return self._structure_view
+
+ @property
+ def structure_style(self) -> StructureStyle:
+ """Structure-view appearance bound to the project."""
+ return self._structure_style
@property
def display(self) -> ProjectDisplay:
@@ -337,11 +361,6 @@ def report(self) -> Report:
"""Submission report builder bound to the project."""
return self._report
- @property
- def publication(self) -> Publication:
- """Publication metadata bound to the project."""
- return self._publication
-
@property
def parameters(self) -> list:
"""Return parameters from all structures and experiments."""
diff --git a/src/easydiffraction/project/project_config.py b/src/easydiffraction/project/project_config.py
index 2c779c8a7..a8f13af08 100644
--- a/src/easydiffraction/project/project_config.py
+++ b/src/easydiffraction/project/project_config.py
@@ -5,14 +5,20 @@
from __future__ import annotations
from easydiffraction.core.category_owner import CategoryOwner
-from easydiffraction.project.categories.chart import Chart
-from easydiffraction.project.categories.chart import ChartFactory
from easydiffraction.project.categories.info import ProjectInfo
from easydiffraction.project.categories.info import ProjectInfoFactory
+from easydiffraction.project.categories.rendering_plot import RenderingPlot
+from easydiffraction.project.categories.rendering_plot import RenderingPlotFactory
+from easydiffraction.project.categories.rendering_structure import RenderingStructure
+from easydiffraction.project.categories.rendering_structure import RenderingStructureFactory
+from easydiffraction.project.categories.rendering_table import RenderingTable
+from easydiffraction.project.categories.rendering_table import RenderingTableFactory
from easydiffraction.project.categories.report import Report
from easydiffraction.project.categories.report import ReportFactory
-from easydiffraction.project.categories.table import Table
-from easydiffraction.project.categories.table import TableFactory
+from easydiffraction.project.categories.structure_style import StructureStyle
+from easydiffraction.project.categories.structure_style import StructureStyleFactory
+from easydiffraction.project.categories.structure_view import StructureView
+from easydiffraction.project.categories.structure_view import StructureViewFactory
from easydiffraction.project.categories.verbosity import Verbosity
from easydiffraction.project.categories.verbosity import VerbosityFactory
@@ -33,10 +39,15 @@ def __init__(
title=title,
description=description,
)
- self._chart = ChartFactory.create(ChartFactory.default_tag())
+ self._rendering_plot = RenderingPlotFactory.create(RenderingPlotFactory.default_tag())
self._report = ReportFactory.create(ReportFactory.default_tag())
- self._table = TableFactory.create(TableFactory.default_tag())
+ self._rendering_table = RenderingTableFactory.create(RenderingTableFactory.default_tag())
self._verbosity = VerbosityFactory.create(VerbosityFactory.default_tag())
+ self._rendering_structure = RenderingStructureFactory.create(
+ RenderingStructureFactory.default_tag()
+ )
+ self._structure_view = StructureViewFactory.create(StructureViewFactory.default_tag())
+ self._structure_style = StructureStyleFactory.create(StructureStyleFactory.default_tag())
@property
def info(self) -> ProjectInfo:
@@ -44,9 +55,9 @@ def info(self) -> ProjectInfo:
return self._info
@property
- def chart(self) -> Chart:
+ def rendering_plot(self) -> RenderingPlot:
"""Chart configuration category."""
- return self._chart
+ return self._rendering_plot
@property
def report(self) -> Report:
@@ -54,15 +65,30 @@ def report(self) -> Report:
return self._report
@property
- def table(self) -> Table:
+ def rendering_table(self) -> RenderingTable:
"""Table configuration category."""
- return self._table
+ return self._rendering_table
@property
def verbosity(self) -> Verbosity:
"""Verbosity configuration category."""
return self._verbosity
+ @property
+ def rendering_structure(self) -> RenderingStructure:
+ """Structure-view configuration category."""
+ return self._rendering_structure
+
+ @property
+ def structure_view(self) -> StructureView:
+ """Structure-view content and region category."""
+ return self._structure_view
+
+ @property
+ def structure_style(self) -> StructureStyle:
+ """Structure-view appearance category."""
+ return self._structure_style
+
@property
def as_cif(self) -> str:
"""Serialize singleton project categories to CIF."""
diff --git a/src/easydiffraction/project/publication_loader.py b/src/easydiffraction/project/publication_loader.py
deleted file mode 100644
index 592b17411..000000000
--- a/src/easydiffraction/project/publication_loader.py
+++ /dev/null
@@ -1,199 +0,0 @@
-# SPDX-FileCopyrightText: 2026 EasyScience contributors
-# SPDX-License-Identifier: BSD-3-Clause
-"""Load publication metadata from TOML or JSON files."""
-
-from __future__ import annotations
-
-import json
-import pathlib
-import tomllib
-from collections.abc import Mapping
-from typing import TYPE_CHECKING
-
-if TYPE_CHECKING:
- from easydiffraction.project.categories.publication import Publication
-
-_FIELD_MAP = {
- 'journal_name_full': ('journal', 'name_full'),
- 'journal_year': ('journal', 'year'),
- 'journal_volume': ('journal', 'volume'),
- 'journal_issue': ('journal', 'issue'),
- 'journal_page_first': ('journal', 'page_first'),
- 'journal_page_last': ('journal', 'page_last'),
- 'journal_paper_category': ('journal', 'paper_category'),
- 'journal_paper_doi': ('journal', 'paper_doi'),
- 'journal_coden_astm': ('journal', 'coden_astm'),
- 'journal_suppl_publ_number': ('journal', 'suppl_publ_number'),
- 'journal_date_accepted': ('journal_date', 'accepted'),
- 'journal_date_from_coeditor': ('journal_date', 'from_coeditor'),
- 'journal_date_printers_final': ('journal_date', 'printers_final'),
- 'journal_coeditor_code': ('journal_coeditor', 'code'),
- 'journal_coeditor_name': ('journal_coeditor', 'name'),
- 'journal_coeditor_notes': ('journal_coeditor', 'notes'),
- 'contact_author_name': ('contact_author', 'name'),
- 'contact_author_address': ('contact_author', 'address'),
- 'contact_author_email': ('contact_author', 'email'),
- 'contact_author_phone': ('contact_author', 'phone'),
- 'contact_author_id_orcid': ('contact_author', 'id_orcid'),
- 'contact_author_id_iucr': ('contact_author', 'id_iucr'),
- 'body_title': ('body', 'title'),
- 'body_synopsis': ('body', 'synopsis'),
- 'body_abstract': ('body', 'abstract'),
-}
-
-_AUTHOR_FIELDS = {
- 'name',
- 'address',
- 'footnote',
- 'id_orcid',
- 'id_iucr',
-}
-
-
-def _read_publication_data(path: pathlib.Path) -> Mapping[str, object]:
- """Read a publication metadata file by extension."""
- ext = path.suffix.lower()
- if ext == '.toml':
- data = tomllib.loads(path.read_text(encoding='utf-8'))
- elif ext == '.json':
- data = json.loads(path.read_text(encoding='utf-8'))
- else:
- msg = f'Unsupported publication-info format: {ext}. Use .toml or .json.'
- raise ValueError(msg)
-
- if not isinstance(data, Mapping):
- msg = 'Publication-info file must contain a top-level object.'
- raise TypeError(msg)
- return data
-
-
-def _optional_text(key: str, value: object) -> str | None:
- """Validate an optional text field from the publication file."""
- if value is None:
- return None
- if isinstance(value, str):
- return value
- if isinstance(value, (int, float)) and not isinstance(value, bool):
- return str(value)
- msg = f'Publication-info field {key!r} must be a string or null.'
- raise ValueError(msg)
-
-
-def _required_text(key: str, value: object) -> str:
- """Validate a required text field from the publication file."""
- text = _optional_text(key, value)
- if text is None or not text:
- msg = f'Publication-info field {key!r} must be a non-empty string.'
- raise ValueError(msg)
- return text
-
-
-def _keywords(value: object) -> list[str]:
- """Validate body keywords from the publication file."""
- if value is None:
- return []
- if not isinstance(value, list):
- msg = "Publication-info field 'body_keywords' must be a list of strings."
- raise TypeError(msg)
-
- keywords: list[str] = []
- for idx, keyword in enumerate(value):
- if not isinstance(keyword, str):
- msg = f"Publication-info field 'body_keywords[{idx}]' must be a string."
- raise TypeError(msg)
- keywords.append(keyword)
- return keywords
-
-
-def _author_rows(value: object) -> list[dict[str, str | None]]:
- """Validate publication author rows from the publication file."""
- if not isinstance(value, list):
- msg = "Publication-info field 'authors' must be a list of objects."
- raise TypeError(msg)
-
- rows: list[dict[str, str | None]] = []
- for idx, row in enumerate(value):
- if not isinstance(row, Mapping):
- msg = f"Publication-info field 'authors[{idx}]' must be an object."
- raise TypeError(msg)
-
- for key in row:
- if key not in _AUTHOR_FIELDS:
- msg = f'authors.{key}'
- raise ValueError(msg)
-
- rows.append({
- 'name': _required_text(f'authors[{idx}].name', row.get('name')),
- 'address': _optional_text(f'authors[{idx}].address', row.get('address')),
- 'footnote': _optional_text(f'authors[{idx}].footnote', row.get('footnote')),
- 'id_orcid': _optional_text(f'authors[{idx}].id_orcid', row.get('id_orcid')),
- 'id_iucr': _optional_text(f'authors[{idx}].id_iucr', row.get('id_iucr')),
- })
- return rows
-
-
-def _validate_publication_data(
- data: Mapping[str, object],
-) -> tuple[
- list[tuple[str, str, str | None]],
- list[str] | None,
- list[dict[str, str | None]] | None,
-]:
- """Validate publication data and return normalized updates."""
- updates: list[tuple[str, str, str | None]] = []
- keywords: list[str] | None = None
- authors: list[dict[str, str | None]] | None = None
-
- for key, value in data.items():
- if key in _FIELD_MAP:
- category_name, attr_name = _FIELD_MAP[key]
- updates.append((category_name, attr_name, _optional_text(key, value)))
- elif key == 'body_keywords':
- keywords = _keywords(value)
- elif key == 'authors':
- authors = _author_rows(value)
- else:
- raise ValueError(key)
-
- return updates, keywords, authors
-
-
-def _apply_author_rows(
- publication: Publication,
- authors: list[dict[str, str | None]],
-) -> None:
- """
- Replace the publication author collection with normalized rows.
- """
- publication.authors._adopt_items([])
- publication.authors._mark_parent_dirty()
- for author in authors:
- publication.authors.add(
- name=author['name'],
- address=author['address'],
- footnote=author['footnote'],
- id_orcid=author['id_orcid'],
- id_iucr=author['id_iucr'],
- )
-
-
-def load_publication(publication: Publication, path: str | pathlib.Path) -> None:
- """
- Load publication metadata into a Publication object.
-
- Parameters
- ----------
- publication : Publication
- Publication metadata facade to populate.
- path : str | pathlib.Path
- TOML or JSON file containing flat publication metadata keys.
- """
- data = _read_publication_data(pathlib.Path(path))
- updates, keywords, authors = _validate_publication_data(data)
-
- for category_name, attr_name, value in updates:
- setattr(getattr(publication, category_name), attr_name, value)
- if keywords is not None:
- publication.body.keywords = keywords
- if authors is not None:
- _apply_author_rows(publication, authors)
diff --git a/src/easydiffraction/report/data_context.py b/src/easydiffraction/report/data_context.py
index 741c77a0e..ce6fe4ca9 100644
--- a/src/easydiffraction/report/data_context.py
+++ b/src/easydiffraction/report/data_context.py
@@ -57,48 +57,6 @@
'ambient_temperature',
'ambient_pressure',
)
-_PUBLICATION_JOURNAL_FIELDS = (
- 'name_full',
- 'year',
- 'volume',
- 'issue',
- 'page_first',
- 'page_last',
- 'paper_category',
- 'paper_doi',
- 'coden_astm',
- 'suppl_publ_number',
-)
-_PUBLICATION_JOURNAL_DATE_FIELDS = (
- 'accepted',
- 'from_coeditor',
- 'printers_final',
-)
-_PUBLICATION_JOURNAL_COEDITOR_FIELDS = (
- 'code',
- 'name',
- 'notes',
-)
-_PUBLICATION_CONTACT_AUTHOR_FIELDS = (
- 'name',
- 'address',
- 'email',
- 'phone',
- 'id_orcid',
- 'id_iucr',
-)
-_PUBLICATION_BODY_FIELDS = (
- 'title',
- 'synopsis',
- 'abstract',
-)
-_PUBLICATION_AUTHOR_FIELDS = (
- 'name',
- 'address',
- 'footnote',
- 'id_orcid',
- 'id_iucr',
-)
_REPORT_LOOP_DISPLAY_LIMIT = DEFAULT_LOOP_DISPLAY_LIMIT
_FULL_WIDTH_TABLE_CHAR_LIMIT = 40
_TRUNCATED_DATA_CATEGORY_CODES = frozenset({'pd_data', 'total_data'})
@@ -126,8 +84,7 @@ class ReportDataContext:
Parameters
----------
project : object
- Project facade that owns structures, experiments, analysis, and
- publication metadata.
+ Project facade that owns structures, experiments, and analysis.
"""
def __init__(self, project: object) -> None:
@@ -151,7 +108,6 @@ def build(self) -> dict[str, object]:
'structures': [self._structure_context(structure) for structure in structures],
'experiments': [self._experiment_context(experiment) for experiment in experiments],
'analysis': self._analysis_context(),
- 'publication': self._publication_context(),
'metadata': {
'easydiffraction_version': package_version('easydiffraction'),
'generated_at': _format_generated_at(datetime.now(tz=UTC)),
@@ -312,37 +268,6 @@ def _software_context(self) -> dict[str, object]:
'fit_datetime': _attr_value(software, 'timestamp'),
}
- def _publication_context(self) -> dict[str, object]:
- """Return journal-publication metadata."""
- publication = _safe_attr(self._project, 'publication')
- body = _safe_attr(publication, 'body')
- return {
- 'journal': _field_values(
- _safe_attr(publication, 'journal'),
- _PUBLICATION_JOURNAL_FIELDS,
- ),
- 'journal_date': _field_values(
- _safe_attr(publication, 'journal_date'),
- _PUBLICATION_JOURNAL_DATE_FIELDS,
- ),
- 'journal_coeditor': _field_values(
- _safe_attr(publication, 'journal_coeditor'),
- _PUBLICATION_JOURNAL_COEDITOR_FIELDS,
- ),
- 'contact_author': _field_values(
- _safe_attr(publication, 'contact_author'),
- _PUBLICATION_CONTACT_AUTHOR_FIELDS,
- ),
- 'body': {
- **_field_values(body, _PUBLICATION_BODY_FIELDS),
- 'keywords': list(_safe_attr(body, 'keywords') or []),
- },
- 'authors': [
- _field_values(author, _PUBLICATION_AUTHOR_FIELDS)
- for author in _collection_values(_safe_attr(publication, 'authors'))
- ],
- }
-
def build_report_data_context(project: object) -> dict[str, object]:
"""
diff --git a/src/easydiffraction/report/fit_plot.py b/src/easydiffraction/report/fit_plot.py
index fca30f9ad..1f60fc677 100644
--- a/src/easydiffraction/report/fit_plot.py
+++ b/src/easydiffraction/report/fit_plot.py
@@ -21,11 +21,14 @@
from easydiffraction.display.plotters.plotly import COMPOSITE_MARGIN_TOP
from easydiffraction.display.plotters.plotly import COMPOSITE_VERTICAL_SPACING
from easydiffraction.display.plotters.plotly import DEFAULT_COLORS
+from easydiffraction.display.plotters.plotly import DIAGONAL_LINE_RGB
from easydiffraction.display.plotters.plotly import DISPLAY_TICK_FRACTIONS
from easydiffraction.display.plotters.plotly import MAIN_INTENSITY_RANGE_MARGIN_FRACTION
from easydiffraction.display.plotters.plotly import MEASURED_LINE_WIDTH
from easydiffraction.display.plotters.plotly import PLOTLY_HEIGHT_PER_UNIT
from easydiffraction.display.plotters.plotly import RESIDUAL_LINE_WIDTH
+from easydiffraction.display.plotters.plotly import single_crystal_axis_range
+from easydiffraction.display.plotters.plotly import single_crystal_tick_step
from easydiffraction.display.plotting import DEFAULT_RESIDUAL_HEIGHT_FRACTION
from easydiffraction.report.style import REPORT_AXIS_RGB
from easydiffraction.report.style import REPORT_CHART_GRID_RGB
@@ -148,6 +151,7 @@ def fit_plot_axis_styles() -> dict[str, str]:
return {
'axis_rgb': _style_rgb_channels(REPORT_AXIS_RGB),
'grid_rgb': _style_rgb_channels(REPORT_CHART_GRID_RGB),
+ 'diag_rgb': _style_rgb_channels(DIAGONAL_LINE_RGB),
}
@@ -160,28 +164,29 @@ def fit_scatter_geometry() -> dict[str, float]:
def fit_scatter_ranges(fit_data: dict[str, Any]) -> dict[str, float]:
- """Return x/y ranges and the y=x diagonal span for an SC scatter."""
+ """
+ Return the shared x/y range, tick step, and y=x diagonal span.
+
+ The x and y axes share one range (computed across the calculated
+ values and the measured values widened by their uncertainties) so
+ the diagonal is a true y=x line and the ticks can match.
+ """
x_values = _numeric_values(fit_data['x']['values'])
meas = fit_data['series']['meas']
y_values = _numeric_values(meas['values'])
su = meas.get('su')
- if su is not None:
- su_values = _numeric_values(su)
- y_low = [value - error for value, error in zip(y_values, su_values, strict=True)]
- y_high = [value + error for value, error in zip(y_values, su_values, strict=True)]
- else:
- y_low = y_values
- y_high = y_values
-
- x_min, x_max = _padded_range(*_data_range([x_values]))
- y_min, y_max = _padded_range(*_data_range([y_low, y_high]))
+ su_values = _numeric_values(su) if su is not None else None
+
+ axis_min, axis_max = single_crystal_axis_range(x_values, y_values, su_values)
+ tick_step = single_crystal_tick_step(axis_min, axis_max)
return {
- 'x_min': x_min,
- 'x_max': x_max,
- 'y_min': y_min,
- 'y_max': y_max,
- 'diag_min': min(x_min, y_min),
- 'diag_max': max(x_max, y_max),
+ 'x_min': axis_min,
+ 'x_max': axis_max,
+ 'y_min': axis_min,
+ 'y_max': axis_max,
+ 'diag_min': axis_min,
+ 'diag_max': axis_max,
+ 'tick_step': tick_step,
}
@@ -296,14 +301,6 @@ def _style_rgb_channels(rgb: tuple[int, int, int]) -> str:
return ','.join(str(channel) for channel in rgb)
-def _padded_range(minimum: float, maximum: float) -> tuple[float, float]:
- """Return a range padded by the main-intensity margin fraction."""
- margin = max(maximum - minimum, 0.0) * MAIN_INTENSITY_RANGE_MARGIN_FRACTION
- if margin <= 0.0:
- margin = 1.0
- return minimum - margin, maximum + margin
-
-
def _data_range(series_list: list[list[float]]) -> tuple[float, float]:
values = [value for series in series_list for value in series]
if not values:
diff --git a/src/easydiffraction/report/html_renderer.py b/src/easydiffraction/report/html_renderer.py
index dc557a60e..b7080b49c 100644
--- a/src/easydiffraction/report/html_renderer.py
+++ b/src/easydiffraction/report/html_renderer.py
@@ -63,6 +63,7 @@ def render_html_report(
context: dict[str, object],
*,
offline: bool = False,
+ project: object | None = None,
) -> str:
"""
Render a report data context as HTML.
@@ -73,6 +74,9 @@ def render_html_report(
Data returned by ``Report.data_context()``.
offline : bool, default=False
Whether Plotly figures should embed JavaScript assets.
+ project : object | None, default=None
+ Live project used to build interactive 3D structure figures.
+ When ``None``, the report omits structure views.
Returns
-------
@@ -87,6 +91,9 @@ def render_html_report(
context,
offline=offline,
)
+ template_context['structure_figures'] = (
+ _structure_figure_html_context(project, offline=offline) if project is not None else {}
+ )
return _environment().get_template(_TEMPLATE_NAME).render(**template_context)
@@ -119,7 +126,7 @@ def save_html_report(
output_path = html_report_path(project, path)
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(
- render_html_report(context, offline=offline),
+ render_html_report(context, offline=offline, project=project),
encoding='utf-8',
)
if offline:
@@ -175,7 +182,7 @@ def _fit_figure_html_context(
if fit_data is None:
continue
experiment_id = str(experiment.get('id') or 'experiment')
- figure = _fit_data_figure(experiment_id, fit_data, experiment)
+ figure = _fit_data_figure(experiment_id, fit_data)
rendered[experiment_id] = _figure_html(
figure,
include_plotlyjs=include_plotlyjs,
@@ -185,10 +192,46 @@ def _fit_figure_html_context(
return rendered
+def _structure_figure_html_context(
+ project: object,
+ *,
+ offline: bool,
+) -> dict[str, str]:
+ """
+ Return interactive structure-view HTML snippets by structure name.
+ """
+ from easydiffraction.display.structure.builder import build_scene # noqa: PLC0415
+ from easydiffraction.display.structure.builder import ( # noqa: PLC0415
+ structure_feature_availability,
+ )
+ from easydiffraction.display.structure.renderers.threejs import ( # noqa: PLC0415
+ ThreeJsStructureRenderer,
+ )
+
+ renderer = ThreeJsStructureRenderer()
+ window = project.structure_view.view_range()
+ rendered: dict[str, str] = {}
+ for structure in project.structures.values():
+ availability = structure_feature_availability(structure, style=project.structure_style)
+ features = project.display._resolve_structure_features('auto', availability)
+ scene = build_scene(
+ structure,
+ style=project.structure_style,
+ view_range=window,
+ features=features,
+ )
+ rendered[str(structure.name)] = renderer.render(
+ scene,
+ features=features,
+ offline=offline,
+ dark=False,
+ )
+ return rendered
+
+
def _fit_data_figure(
experiment_id: str,
fit_data: dict[str, object],
- experiment: dict[str, object],
) -> object:
"""Build a Plotly fit figure from one fit-data payload."""
x_data = fit_data['x']
@@ -230,7 +273,7 @@ def _fit_data_figure(
y_resid=np.asarray(y_diff, dtype=float),
bragg_tick_sets=tuple(fit_data.get('bragg_tick_sets') or ()),
axes_labels=list(fit_data.get('axes_labels') or [_axis_title(x_data), 'Intensity']),
- title=_fit_figure_title(experiment_id, experiment),
+ title=_fit_figure_title(experiment_id),
residual_height_fraction=DEFAULT_RESID_HEIGHT,
bragg_peaks_height_fraction=DEFAULT_BRAGG_ROW,
y_bkg=np.asarray(y_bkg, dtype=float) if y_bkg is not None else None,
@@ -259,25 +302,13 @@ def _single_crystal_fit_data_figure(
y_meas=y_meas_array,
y_meas_su=y_meas_su_array,
axes_labels=list(fit_data.get('axes_labels') or ['I²calc', 'I²meas']),
- title=f"Measured vs Calculated data for experiment 🔬 '{experiment_id}'",
+ title=_fit_figure_title(experiment_id),
)
-def _fit_figure_title(experiment_id: str, experiment: dict[str, object]) -> str:
+def _fit_figure_title(experiment_id: str) -> str:
"""Return a report title matching the direct plotting API."""
- experiment_type = experiment.get('type')
- if _is_powder_bragg_context(experiment_type):
- return f"Measured vs Calculated data for experiment 🔬 '{experiment_id}'"
- return f'Measured vs calculated: {experiment_id}'
-
-
-def _is_powder_bragg_context(experiment_type: object) -> bool:
- if not isinstance(experiment_type, dict):
- return False
- return (
- experiment_type.get('sample_form') == 'powder'
- and experiment_type.get('scattering_type') == 'bragg'
- )
+ return f"Diffraction pattern for experiment 🔬 '{experiment_id}'"
def _figure_html(
diff --git a/src/easydiffraction/report/style.py b/src/easydiffraction/report/style.py
index f88b27c6a..ab1ddae01 100644
--- a/src/easydiffraction/report/style.py
+++ b/src/easydiffraction/report/style.py
@@ -4,8 +4,10 @@
from __future__ import annotations
+from easydiffraction.display.theme import LIGHT_AXIS_FRAME_COLOR
+
REPORT_AXIS_RGB = (190, 199, 208)
-REPORT_TABLE_INNER_RGB = (217, 223, 228)
+REPORT_TABLE_INNER_HEX = LIGHT_AXIS_FRAME_COLOR
REPORT_CHART_GRID_RGB = (235, 240, 248)
REPORT_ROW_RGB = (235, 240, 248)
REPORT_LINK_RGB = (36, 90, 155) # mirrors --link (#245a9b) in html/style.css
@@ -20,8 +22,8 @@ def report_style_context() -> dict[str, object]:
return {
'axis_hex': _rgb_hex(REPORT_AXIS_RGB),
'axis_rgb': _rgb_channels(REPORT_AXIS_RGB),
- 'grid_hex': _rgb_hex(REPORT_TABLE_INNER_RGB),
- 'grid_rgb': _rgb_channels(REPORT_TABLE_INNER_RGB),
+ 'grid_hex': REPORT_TABLE_INNER_HEX,
+ 'grid_rgb': _hex_channels(REPORT_TABLE_INNER_HEX),
'chart_grid_hex': _rgb_hex(REPORT_CHART_GRID_RGB),
'chart_grid_rgb': _rgb_channels(REPORT_CHART_GRID_RGB),
'row_hex': _rgb_hex(REPORT_ROW_RGB),
@@ -43,3 +45,12 @@ def _rgb_hex(rgb: tuple[int, int, int]) -> str:
def _rgb_channels(rgb: tuple[int, int, int]) -> str:
"""Return comma-separated channels for TeX RGB definitions."""
return ','.join(str(channel) for channel in rgb)
+
+
+def _hex_channels(color: str) -> str:
+ """Return comma-separated RGB channels for a CSS hex color."""
+ return _rgb_channels((
+ int(color[1:3], 16),
+ int(color[3:5], 16),
+ int(color[5:7], 16),
+ ))
diff --git a/src/easydiffraction/report/templates/html/report.html.j2 b/src/easydiffraction/report/templates/html/report.html.j2
index f380782d2..1f9069788 100644
--- a/src/easydiffraction/report/templates/html/report.html.j2
+++ b/src/easydiffraction/report/templates/html/report.html.j2
@@ -140,6 +140,10 @@
{{ structure.id }}
+ {% if structure_figures[structure.id] %}
+ {{ structure_figures[structure.id] | safe }}
+ {% endif %}
+
{% for category in structure.categories %}
{{ category.title }}
{% if category.kind == "item" %}
@@ -201,7 +205,6 @@
{{ experiment.id }}
{% if fit_figures[experiment.id] %}
- Fit quality
{{ fit_figures[experiment.id] | safe }}
{% endif %}
diff --git a/src/easydiffraction/report/templates/tex/figure_sc.tex.j2 b/src/easydiffraction/report/templates/tex/figure_sc.tex.j2
index 8d6d1b169..2e2d0dfac 100644
--- a/src/easydiffraction/report/templates/tex/figure_sc.tex.j2
+++ b/src/easydiffraction/report/templates/tex/figure_sc.tex.j2
@@ -9,6 +9,7 @@
\definecolor{ {{- style.color_name -}} }{RGB}{ {{- style.rgb -}} }
\definecolor{edAxis}{RGB}{ {{- axis_styles.axis_rgb -}} }
\definecolor{edGrid}{RGB}{ {{- axis_styles.grid_rgb -}} }
+\definecolor{edDiag}{RGB}{ {{- axis_styles.diag_rgb -}} }
{% set axes_labels = fit_data.axes_labels | default(["Icalc", "Imeas"], true) -%}
{% set x_label = axes_labels[0] | tex_axis_label -%}
{% set y_label = axes_labels[1] | tex_axis_label -%}
@@ -22,6 +23,8 @@ xmin={{ ranges.x_min | tex_number }},
xmax={{ ranges.x_max | tex_number }},
ymin={{ ranges.y_min | tex_number }},
ymax={{ ranges.y_max | tex_number }},
+xtick distance={{ ranges.tick_step | tex_number }},
+ytick distance={{ ranges.tick_step | tex_number }},
xlabel={ {{ x_label }} },
ylabel={ {{ y_label }} },
axis line style={draw=edAxis},
@@ -32,7 +35,7 @@ major grid style={draw=edGrid, line width=0.4pt},
xmajorgrids=true,
ymajorgrids=true,
]
-\addplot[domain={{ ranges.diag_min | tex_number }}:{{ ranges.diag_max | tex_number }}, samples=2, color=edAxis, line width=0.4pt, forget plot] {x};
+\addplot[domain={{ ranges.diag_min | tex_number }}:{{ ranges.diag_max | tex_number }}, samples=2, color=edDiag, line width=0.4pt, forget plot] {x};
\addplot[only marks, mark=*, mark size={{ style.marker_size_pt | tex_number }}pt, color={{ style.color_name }}, mark options={fill={{ style.color_name }}, draw={{ style.color_name }}, line width={{ style.marker_line_width_pt | tex_number }}pt}, error bars/.cd, y dir=both, y explicit] table[x={ {{- fit_csv.x -}} }, y={ {{- fit_csv.meas -}} }, y error={ {{- fit_csv.meas_su -}} }, col sep=comma] { {{- csv_filename -}} };
\end{axis}
\end{tikzpicture}
diff --git a/src/easydiffraction/report/templates/tex/report.tex.j2 b/src/easydiffraction/report/templates/tex/report.tex.j2
index 7e0c997d6..548dfbcd4 100644
--- a/src/easydiffraction/report/templates/tex/report.tex.j2
+++ b/src/easydiffraction/report/templates/tex/report.tex.j2
@@ -7,7 +7,7 @@
% ====================================================================
\documentclass[11pt]{article}
-\usepackage[margin=2.5cm]{geometry}
+\usepackage[margin=2cm]{geometry}
% --------------------------------------------------------------------
% Packages
@@ -41,6 +41,7 @@
\definecolor{rowshade}{RGB}{ {{- report_style.row_rgb -}} }
\definecolor{tableborder}{RGB}{ {{- report_style.axis_rgb -}} }
\definecolor{linkblue}{RGB}{ {{- report_style.link_rgb -}} }
+\definecolor{structframe}{RGB}{217, 217, 217} % structure-figure frame; matches the interactive view
\arrayrulecolor{tableborder}
\hypersetup{colorlinks=true, urlcolor=linkblue}
@@ -194,6 +195,17 @@ Minimizer & {{ tex_software_name(analysis.software.minimizer) }} & {{ analysis.s
% --------------------------------------------------------------------
\subsection{ {{- structure.id | tex -}} }
+{% if tex.structure_figure_paths[structure.id] %}
+\begin{figure}[H]
+\centering
+\begingroup
+\setlength{\fboxsep}{0pt}
+\setlength{\fboxrule}{0.4pt}
+\fcolorbox{structframe}{white}{\makebox[\dimexpr\linewidth-2\fboxrule\relax][c]{\includegraphics[width=\dimexpr\linewidth-2\fboxrule\relax,height=0.5\textheight,keepaspectratio]{ {{- tex.structure_figure_paths[structure.id] -}} }}}
+\endgroup
+\end{figure}
+{% endif %}
+
{% for category in structure.categories %}
\subsubsection*{ {{- category.title | tex -}} }
@@ -240,8 +252,6 @@ Minimizer & {{ tex_software_name(analysis.software.minimizer) }} & {{ analysis.s
\subsection{ {{- experiment.id | tex -}} }
{% if experiment.fit_data and tex.fit_figure_paths[experiment.id] %}
-\subsubsection*{Fit quality}
-
\begin{figure}[H]
\centering
Measured and calculated fit for {{ experiment.id | tex }}.
diff --git a/src/easydiffraction/report/tex_renderer.py b/src/easydiffraction/report/tex_renderer.py
index 9346b5b5b..c30d6243c 100644
--- a/src/easydiffraction/report/tex_renderer.py
+++ b/src/easydiffraction/report/tex_renderer.py
@@ -124,6 +124,7 @@ def render_tex_report(context: dict[str, object]) -> str:
context,
fit_csv_paths=_fit_csv_paths(context),
fit_figure_paths=_fit_figure_paths(context),
+ structure_figure_paths=_structure_figure_paths(context),
)
return _environment().get_template(_TEMPLATE_NAME).render(**template_context)
@@ -159,10 +160,12 @@ def save_tex_report(
template_context = dict(context)
template_context['report_style'] = report_style_context()
fit_asset_paths = _write_fit_assets(project, context, tex_dir)
+ structure_figure_paths = _write_structure_assets(project, context, tex_dir)
template_context['tex'] = _tex_context(
context,
fit_csv_paths=fit_asset_paths['csv'],
fit_figure_paths=fit_asset_paths['figure'],
+ structure_figure_paths=structure_figure_paths,
)
output_path.write_text(
_render_prepared_context(template_context),
@@ -264,11 +267,13 @@ def _tex_context(
*,
fit_csv_paths: dict[str, str],
fit_figure_paths: dict[str, str],
+ structure_figure_paths: dict[str, str],
) -> dict[str, object]:
"""Return TeX-specific render context."""
return {
'fit_csv_paths': fit_csv_paths,
'fit_figure_paths': fit_figure_paths,
+ 'structure_figure_paths': structure_figure_paths,
'fit_bragg_tick_styles': fit_bragg_tick_styles(),
'fit_plot_ranges': _fit_plot_ranges(context),
'fit_plot_styles': fit_plot_styles(),
@@ -414,6 +419,60 @@ def _fit_figure_paths(context: dict[str, object]) -> dict[str, str]:
return paths
+def _structure_asset_stem(struct_id: str) -> str:
+ """Return a filesystem-safe stem for a structure figure asset."""
+ return f'struct_{_safe_asset_stem(struct_id)}'
+
+
+def _structure_figure_paths(context: dict[str, object]) -> dict[str, str]:
+ """Return expected structure-figure PNG paths for TeX rendering."""
+ paths: dict[str, str] = {}
+ for structure in context.get('structures') or []:
+ if not isinstance(structure, dict):
+ continue
+ struct_id = str(structure.get('id') or 'structure')
+ paths[struct_id] = f'data/{_structure_asset_stem(struct_id)}.png'
+ return paths
+
+
+def _write_structure_assets(
+ project: object,
+ context: dict[str, object],
+ out_dir: pathlib.Path,
+) -> dict[str, str]:
+ """Write one z-buffered PNG structure figure per structure."""
+ from easydiffraction.display.structure.builder import build_scene # noqa: PLC0415
+ from easydiffraction.display.structure.builder import ( # noqa: PLC0415
+ structure_feature_availability,
+ )
+ from easydiffraction.display.structure.renderers.raster import ( # noqa: PLC0415
+ RasterStructureRenderer,
+ )
+
+ del context
+ structures = getattr(project, 'structures', None)
+ values = getattr(structures, 'values', None)
+ if not callable(values):
+ return {}
+
+ renderer = RasterStructureRenderer()
+ window = project.structure_view.view_range()
+ style = project.structure_style
+ data_dir = out_dir / 'data'
+ data_dir.mkdir(parents=True, exist_ok=True)
+
+ figure_paths: dict[str, str] = {}
+ for structure in values():
+ struct_id = str(getattr(structure, 'name', '') or 'structure')
+ availability = structure_feature_availability(structure, style=style)
+ features = project.display._resolve_structure_features('auto', availability)
+ scene = build_scene(structure, style=style, view_range=window, features=features)
+ figure_path = data_dir / f'{_structure_asset_stem(struct_id)}.png'
+ figure_path.write_bytes(renderer.render_png(scene, features=features))
+ figure_paths[struct_id] = f'data/{figure_path.name}'
+ return figure_paths
+
+
def _project_experiments_by_id(project: object) -> dict[str, object]:
"""Return project experiment objects keyed by datablock id."""
experiments = getattr(project, 'experiments', None)
diff --git a/src/easydiffraction/utils/enums.py b/src/easydiffraction/utils/enums.py
index 64d980177..44c3ac4dc 100644
--- a/src/easydiffraction/utils/enums.py
+++ b/src/easydiffraction/utils/enums.py
@@ -28,3 +28,15 @@ class VerbosityEnum(StrEnum):
def default(cls) -> VerbosityEnum:
"""Return the default verbosity (FULL)."""
return cls.FULL
+
+ def description(self) -> str:
+ """
+ Return a human-readable description of this verbosity level.
+ """
+ if self is VerbosityEnum.FULL:
+ return 'Multi-line output with headers, tables, and details.'
+ if self is VerbosityEnum.SHORT:
+ return 'Single-line status messages per action.'
+ if self is VerbosityEnum.SILENT:
+ return 'No console output.'
+ return ''
diff --git a/src/easydiffraction/utils/logging.py b/src/easydiffraction/utils/logging.py
index a185205fb..1f6e4ddb8 100644
--- a/src/easydiffraction/utils/logging.py
+++ b/src/easydiffraction/utils/logging.py
@@ -41,6 +41,8 @@
from easydiffraction.utils.environment import in_pytest
from easydiffraction.utils.environment import in_warp
+CONSOLE_PARAGRAPH_STYLE = 'bold deep_sky_blue3'
+
# ======================================================================
# HANDLERS
# ======================================================================
@@ -721,7 +723,7 @@ def paragraph(cls, title: str) -> None:
if part.startswith("'") and part.endswith("'"):
text.append(part)
else:
- text.append(part, style='bold deep_sky_blue3')
+ text.append(part, style=CONSOLE_PARAGRAPH_STYLE)
formatted = f'{text.markup}'
if not in_jupyter():
formatted = f'\n{formatted}'
diff --git a/src/easydiffraction/utils/utils.py b/src/easydiffraction/utils/utils.py
index 31615f9d0..5155bf571 100644
--- a/src/easydiffraction/utils/utils.py
+++ b/src/easydiffraction/utils/utils.py
@@ -16,13 +16,16 @@
import pandas as pd
import pooch
from packaging.version import Version
+from rich.markup import escape
from uncertainties import UFloat
from uncertainties import ufloat
from uncertainties import ufloat_fromstr
from easydiffraction.display.tables import TableRenderer
from easydiffraction.io.ascii import extract_project_from_zip
+from easydiffraction.utils.environment import in_jupyter
from easydiffraction.utils.environment import resolve_artifact_path
+from easydiffraction.utils.logging import CONSOLE_PARAGRAPH_STYLE
from easydiffraction.utils.logging import console
from easydiffraction.utils.logging import log
@@ -558,8 +561,11 @@ def list_tutorials() -> None:
"""
Display a table of available tutorial notebooks.
- Shows tutorial ID, filename and title for all tutorials available
- for the current version of easydiffraction.
+ In the terminal each row shows the tutorial ID, filename, and a
+ combined entry with the title on the first line and a dimmed
+ description on the second. In Jupyter the table shows the plain
+ title only, since the HTML backend cannot render the terminal
+ styling.
"""
index = _fetch_tutorials_index()
if not index:
@@ -569,20 +575,33 @@ def list_tutorials() -> None:
version = _get_version_for_url()
console.paragraph(f'Tutorials available for easydiffraction v{version}:')
- columns_headers = ['id', 'file', 'title']
+ columns_headers = ['id', 'file', 'tutorial']
columns_alignment = ['right', 'left', 'left']
columns_data = []
+ use_markup = not in_jupyter()
for tutorial_id in index:
record = index[tutorial_id]
filename = f'ed-{tutorial_id}.ipynb'
title = record.get('title', '')
- columns_data.append([tutorial_id, filename, title])
+ description = record.get('description', '')
+ if not use_markup:
+ # Jupyter uses the HTML table backend, which would show Rich
+ # markup as literal text; keep the plain title there.
+ details = title
+ else:
+ styled_title = f'[{CONSOLE_PARAGRAPH_STYLE}]{escape(title)}[/]'
+ if description:
+ details = f'{styled_title}\n[dim]{escape(description)}[/dim]'
+ else:
+ details = styled_title
+ columns_data.append([tutorial_id, filename, details])
render_table(
columns_headers=columns_headers,
columns_data=columns_data,
columns_alignment=columns_alignment,
+ width=shutil.get_terminal_size().columns,
)
@@ -733,6 +752,7 @@ def render_table(
columns_alignment: object,
columns_headers: object = None,
display_handle: object = None,
+ width: int | None = None,
) -> None:
"""
Render tabular data to the active display backend.
@@ -749,6 +769,9 @@ def render_table(
display_handle : object, default=None
Optional display handle for in-place updates (e.g. in Jupyter or
a terminal Live context).
+ width : int | None, default=None
+ Optional target table width. Honored by fixed-width backends
+ (Rich); ignored by reflowing ones (HTML).
"""
headers = [
(col, align) for col, align in zip(columns_headers, columns_alignment, strict=False)
@@ -756,7 +779,7 @@ def render_table(
df = pd.DataFrame(columns_data, columns=pd.MultiIndex.from_tuples(headers))
tabler = TableRenderer.get()
- tabler.render(df, display_handle=display_handle)
+ tabler.render(df, display_handle=display_handle, width=width)
def build_table_renderable(
diff --git a/tests/integration/fitting/test_bayesian_helper_support.py b/tests/integration/fitting/test_bayesian_helper_support.py
index c8028d3b7..8aac2e2ef 100644
--- a/tests/integration/fitting/test_bayesian_helper_support.py
+++ b/tests/integration/fitting/test_bayesian_helper_support.py
@@ -23,7 +23,15 @@ def __init__(self) -> None:
class Param:
- def __init__(self, unique_name: str, start: float, value: float, uncertainty: float) -> None:
+ def __init__(
+ self,
+ unique_name: str,
+ start: float,
+ value: float,
+ uncertainty: float,
+ *,
+ display_units: str | None = None,
+ ) -> None:
self._identity = Identity()
self._fit_start_value = start
self.unique_name = unique_name
@@ -31,6 +39,11 @@ def __init__(self, unique_name: str, start: float, value: float, uncertainty: fl
self.value = value
self.uncertainty = uncertainty
self.units = 'arb'
+ self._display_units = display_units
+
+ def resolve_display_units(self, context: str) -> str:
+ assert context == 'gui'
+ return self._display_units or self.units
def test_posterior_samples_flatten():
@@ -353,7 +366,13 @@ def test_build_posterior_summary_row_restores_identifier_columns():
from easydiffraction.analysis.fit_helpers.bayesian import PosteriorParameterSummary
from easydiffraction.analysis.fit_helpers.bayesian import _build_posterior_summary_row
- parameter = Param(unique_name='a', start=1.0, value=1.2, uncertainty=0.05)
+ parameter = Param(
+ unique_name='a',
+ start=1.0,
+ value=1.2,
+ uncertainty=0.05,
+ display_units='Ų',
+ )
summary = PosteriorParameterSummary(
unique_name='a',
display_name='a',
@@ -373,7 +392,7 @@ def test_build_posterior_summary_row_restores_identifier_columns():
'cat',
'entry',
'a',
- 'arb',
+ 'Ų',
'1.1500',
'[1.0000, 1.3000]',
'[red]1.107[/red]',
@@ -394,7 +413,13 @@ def fake_render_table(*, columns_headers, columns_alignment, columns_data):
monkeypatch.setattr(bayesian, 'render_table', fake_render_table)
bayesian._render_committed_parameter_table([
- Param(unique_name='a', start=1.0, value=1.2, uncertainty=0.05)
+ Param(
+ unique_name='a',
+ start=1.0,
+ value=1.2,
+ uncertainty=0.05,
+ display_units='Ų',
+ )
])
assert captured['columns_headers'] == [
@@ -425,7 +450,7 @@ def fake_render_table(*, columns_headers, columns_alignment, columns_data):
'cat',
'entry',
'a',
- 'arb',
+ 'Ų',
'1.0000',
'1.2000',
'0.0500',
@@ -448,7 +473,15 @@ def fake_render_table(*, columns_headers, columns_alignment, columns_data):
monkeypatch.setattr(bayesian, 'render_table', fake_render_table)
bayesian._render_posterior_summary_table(
- parameters=[Param(unique_name='a', start=1.0, value=1.2, uncertainty=0.05)],
+ parameters=[
+ Param(
+ unique_name='a',
+ start=1.0,
+ value=1.2,
+ uncertainty=0.05,
+ display_units='Ų',
+ )
+ ],
posterior_parameter_summaries=[
PosteriorParameterSummary(
unique_name='a',
@@ -492,7 +525,7 @@ def fake_render_table(*, columns_headers, columns_alignment, columns_data):
'cat',
'entry',
'a',
- 'arb',
+ 'Ų',
'1.1500',
'[1.0000, 1.3000]',
'[red]1.107[/red]',
@@ -612,7 +645,15 @@ def fake_render_table(*, columns_headers, columns_alignment, columns_data):
reporting.FitResults(
success=True,
- parameters=[Param(unique_name='a', start=1.0, value=1.2, uncertainty=0.05)],
+ parameters=[
+ Param(
+ unique_name='a',
+ start=1.0,
+ value=1.2,
+ uncertainty=0.05,
+ display_units='Ų',
+ )
+ ],
).display_results()
assert captured['columns_headers'] == [
@@ -643,7 +684,7 @@ def fake_render_table(*, columns_headers, columns_alignment, columns_data):
'cat',
'entry',
'a',
- 'arb',
+ 'Ų',
'1.0000',
'1.2000',
'0.0500',
diff --git a/tests/unit/easydiffraction/analysis/fit_helpers/test_bayesian.py b/tests/unit/easydiffraction/analysis/fit_helpers/test_bayesian.py
index da29f65ba..556d931ce 100644
--- a/tests/unit/easydiffraction/analysis/fit_helpers/test_bayesian.py
+++ b/tests/unit/easydiffraction/analysis/fit_helpers/test_bayesian.py
@@ -15,7 +15,15 @@ def __init__(self) -> None:
class Param:
- def __init__(self, unique_name: str, start: float, value: float, uncertainty: float) -> None:
+ def __init__(
+ self,
+ unique_name: str,
+ start: float,
+ value: float,
+ uncertainty: float,
+ *,
+ display_units: str | None = None,
+ ) -> None:
self._identity = Identity()
self._fit_start_value = start
self.unique_name = unique_name
@@ -23,6 +31,11 @@ def __init__(self, unique_name: str, start: float, value: float, uncertainty: fl
self.value = value
self.uncertainty = uncertainty
self.units = 'arb'
+ self._display_units = display_units
+
+ def resolve_display_units(self, context: str) -> str:
+ assert context == 'gui'
+ return self._display_units or self.units
def test_module_import():
@@ -221,7 +234,13 @@ def test_build_posterior_summary_row_restores_identifier_columns():
from easydiffraction.analysis.fit_helpers.bayesian import PosteriorParameterSummary
from easydiffraction.analysis.fit_helpers.bayesian import _build_posterior_summary_row
- parameter = Param(unique_name='a', start=1.0, value=1.2, uncertainty=0.05)
+ parameter = Param(
+ unique_name='a',
+ start=1.0,
+ value=1.2,
+ uncertainty=0.05,
+ display_units='Ų',
+ )
summary = PosteriorParameterSummary(
unique_name='a',
display_name='a',
@@ -241,7 +260,7 @@ def test_build_posterior_summary_row_restores_identifier_columns():
'cat',
'entry',
'a',
- 'arb',
+ 'Ų',
'1.1500',
'[1.0000, 1.3000]',
'[red]1.107[/red]',
@@ -262,7 +281,13 @@ def fake_render_table(*, columns_headers, columns_alignment, columns_data):
monkeypatch.setattr(bayesian, 'render_table', fake_render_table)
bayesian._render_committed_parameter_table([
- Param(unique_name='a', start=1.0, value=1.2, uncertainty=0.05)
+ Param(
+ unique_name='a',
+ start=1.0,
+ value=1.2,
+ uncertainty=0.05,
+ display_units='Ų',
+ )
])
assert captured['columns_headers'] == [
@@ -293,7 +318,7 @@ def fake_render_table(*, columns_headers, columns_alignment, columns_data):
'cat',
'entry',
'a',
- 'arb',
+ 'Ų',
'1.0000',
'1.2000',
'0.0500',
@@ -316,7 +341,15 @@ def fake_render_table(*, columns_headers, columns_alignment, columns_data):
monkeypatch.setattr(bayesian, 'render_table', fake_render_table)
bayesian._render_posterior_summary_table(
- parameters=[Param(unique_name='a', start=1.0, value=1.2, uncertainty=0.05)],
+ parameters=[
+ Param(
+ unique_name='a',
+ start=1.0,
+ value=1.2,
+ uncertainty=0.05,
+ display_units='Ų',
+ )
+ ],
posterior_parameter_summaries=[
PosteriorParameterSummary(
unique_name='a',
@@ -360,7 +393,7 @@ def fake_render_table(*, columns_headers, columns_alignment, columns_data):
'cat',
'entry',
'a',
- 'arb',
+ 'Ų',
'1.1500',
'[1.0000, 1.3000]',
'[red]1.107[/red]',
diff --git a/tests/unit/easydiffraction/analysis/fit_helpers/test_reporting.py b/tests/unit/easydiffraction/analysis/fit_helpers/test_reporting.py
index d5a241d17..bba7ee45b 100644
--- a/tests/unit/easydiffraction/analysis/fit_helpers/test_reporting.py
+++ b/tests/unit/easydiffraction/analysis/fit_helpers/test_reporting.py
@@ -84,7 +84,11 @@ def __init__(self):
self.value = 1.2
self.uncertainty = 0.05
self.name = 'a'
- self.units = 'arb'
+ self.units = 'angstrom_squared'
+
+ def resolve_display_units(self, context):
+ assert context == 'gui'
+ return 'Ų'
from easydiffraction.analysis.fit_helpers import reporting
@@ -127,7 +131,7 @@ def fake_render_table(*, columns_headers, columns_alignment, columns_data):
'cat',
'entry',
'a',
- 'arb',
+ 'Ų',
'1.0000',
'1.2000',
'0.0500',
diff --git a/tests/unit/easydiffraction/analysis/test_analysis_access_params.py b/tests/unit/easydiffraction/analysis/test_analysis_access_params.py
index 96667689b..af3072e34 100644
--- a/tests/unit/easydiffraction/analysis/test_analysis_access_params.py
+++ b/tests/unit/easydiffraction/analysis/test_analysis_access_params.py
@@ -9,17 +9,25 @@ def _make_param(
name,
val,
*,
+ units='none',
+ display_units=None,
user_constrained=False,
symmetry_constrained=False,
):
+ from easydiffraction.core.display_handler import DisplayHandler
from easydiffraction.core.validation import AttributeSpec
from easydiffraction.core.variable import Parameter
from easydiffraction.io.cif.handler import CifHandler
+ display_handler = (
+ DisplayHandler(display_units=display_units) if display_units is not None else None
+ )
param = Parameter(
name=name,
+ units=units,
value_spec=AttributeSpec(default=0.0),
cif_handler=CifHandler(names=[f'_{cat}.{name}']),
+ display_handler=display_handler,
)
param.value = val
param._identity.datablock_entry_name = lambda: db
@@ -295,3 +303,55 @@ def render(self, df):
structure_df = rendered[0]
assert structure_df['parameter', 'left'].tolist() == ['length_a']
+
+
+def test_free_params_uses_display_units_for_structures_and_experiments(monkeypatch):
+ import easydiffraction.analysis.analysis as analysis_mod
+ from easydiffraction.analysis.analysis import Analysis
+
+ structure_param = _make_param(
+ 's1',
+ 'cell',
+ '',
+ 'length_a',
+ 4.0,
+ units='angstrom_squared',
+ display_units='Ų',
+ )
+ experiment_param = _make_param(
+ 'e1',
+ 'time_of_flight',
+ '',
+ 'time_offset',
+ 12.0,
+ units='microseconds',
+ display_units='μs',
+ )
+
+ class Coll:
+ def __init__(self, params):
+ self.parameters = params
+ self.free_parameters = params
+
+ def __iter__(self):
+ return iter(())
+
+ class Project:
+ def __init__(self):
+ self.structures = Coll([structure_param])
+ self.experiments = Coll([experiment_param])
+
+ rendered = []
+
+ class FakeTableRenderer:
+ def render(self, df):
+ rendered.append(df)
+
+ monkeypatch.setattr(
+ analysis_mod.TableRenderer, 'get', staticmethod(lambda: FakeTableRenderer())
+ )
+ Analysis(Project()).display.free_params()
+
+ free_df = rendered[0]
+ assert free_df['parameter', 'left'].tolist() == ['length_a', 'time_offset']
+ assert free_df['units', 'left'].tolist() == ['Ų', 'μs']
diff --git a/tests/unit/easydiffraction/analysis/test_analysis_coverage.py b/tests/unit/easydiffraction/analysis/test_analysis_coverage.py
index 1c52735fe..70c2f8e28 100644
--- a/tests/unit/easydiffraction/analysis/test_analysis_coverage.py
+++ b/tests/unit/easydiffraction/analysis/test_analysis_coverage.py
@@ -194,7 +194,11 @@ class FakeParam:
unique_name = 'p1'
value = 1.23
uncertainty = 0.01
- units = 'Å'
+ units = 'angstroms'
+
+ def resolve_display_units(self, context):
+ assert context == 'gui'
+ return 'Å'
class FakeResults:
parameters = [FakeParam()]
@@ -203,6 +207,7 @@ class FakeResults:
assert 'expt1' in a._parameter_snapshots
assert a._parameter_snapshots['expt1']['p1']['value'] == 1.23
assert a._parameter_snapshots['expt1']['p1']['uncertainty'] == 0.01
+ assert a._parameter_snapshots['expt1']['p1']['units'] == 'Å'
class TestBayesianProjection:
@@ -261,7 +266,7 @@ def __getitem__(self, name):
project = SimpleNamespace(
experiments=Experiments(),
structures=object(),
- chart=SimpleNamespace(plotter=Plotter()),
+ rendering_plot=SimpleNamespace(plotter=Plotter()),
_varname='proj',
)
analysis = Analysis(project=project)
diff --git a/tests/unit/easydiffraction/crystallography/test_crystallography.py b/tests/unit/easydiffraction/crystallography/test_crystallography.py
index 73e1a1342..e8608f18d 100644
--- a/tests/unit/easydiffraction/crystallography/test_crystallography.py
+++ b/tests/unit/easydiffraction/crystallography/test_crystallography.py
@@ -8,3 +8,18 @@ def test_module_import():
expected_module_name = 'easydiffraction.crystallography.crystallography'
actual_module_name = MUT.__name__
assert expected_module_name == actual_module_name
+
+
+def test_symmetry_operators_falls_back_to_identity_for_unlisted_group():
+ import numpy as np
+
+ from easydiffraction.crystallography.crystallography import symmetry_operators
+
+ # P 1 is absent from the local SPACE_GROUPS table (the default-structure
+ # case that previously raised while building a structure scene).
+ ops = symmetry_operators('P 1', '')
+
+ assert len(ops) == 1
+ rotation, translation = ops[0]
+ assert np.array_equal(rotation, np.eye(3, dtype=int))
+ assert np.array_equal(translation, np.zeros(3))
diff --git a/tests/unit/easydiffraction/datablocks/experiment/item/test_base_coverage.py b/tests/unit/easydiffraction/datablocks/experiment/item/test_base_coverage.py
index 92b399504..272781845 100644
--- a/tests/unit/easydiffraction/datablocks/experiment/item/test_base_coverage.py
+++ b/tests/unit/easydiffraction/datablocks/experiment/item/test_base_coverage.py
@@ -145,8 +145,11 @@ def test_show_peak_profile_types(self, capsys):
out = capsys.readouterr().out
assert len(out) > 0
- def test_show_peak_profile_types_includes_current(self, capsys):
+ def test_show_peak_profile_types_uses_context_aliases(self, capsys):
ex = ConcretePd(name='pd1', type=_mk_type_powder_cwl_bragg())
ex.peak.show_supported()
out = capsys.readouterr().out
- assert str(ex.peak.type) in out
+ assert 'Alias' not in out
+ assert 'cwl-pseudo-voigt' not in out
+ assert 'pseudo-voigt' in out
+ assert 'pseudo-voigt + empirical asymmetry' in out
diff --git a/tests/unit/easydiffraction/datablocks/structure/categories/geom/test_default.py b/tests/unit/easydiffraction/datablocks/structure/categories/geom/test_default.py
new file mode 100644
index 000000000..e8e514cce
--- /dev/null
+++ b/tests/unit/easydiffraction/datablocks/structure/categories/geom/test_default.py
@@ -0,0 +1,250 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+"""Tests for the geom category default module (Geom)."""
+
+from __future__ import annotations
+
+import pytest
+
+from easydiffraction.core.category import CategoryItem
+from easydiffraction.core.metadata import TypeInfo
+from easydiffraction.core.variable import NumericDescriptor
+from easydiffraction.datablocks.structure.categories.geom.default import Geom
+from easydiffraction.utils.logging import Logger
+
+
+def test_module_import():
+ import easydiffraction.datablocks.structure.categories.geom.default as MUT
+
+ expected_module_name = 'easydiffraction.datablocks.structure.categories.geom.default'
+ assert MUT.__name__ == expected_module_name
+
+
+# ----------------------------------------------------------------------
+# Class-level metadata
+# ----------------------------------------------------------------------
+
+
+class TestGeomClass:
+ def test_is_category_item_subclass(self):
+ assert issubclass(Geom, CategoryItem)
+
+ def test_category_code(self):
+ assert Geom._category_code == 'geom'
+
+ def test_type_info_is_type_info(self):
+ assert isinstance(Geom.type_info, TypeInfo)
+
+ def test_type_info_tag(self):
+ assert Geom.type_info.tag == 'default'
+
+ def test_type_info_description(self):
+ assert Geom.type_info.description == 'Structure bond-geometry cutoffs'
+
+
+# ----------------------------------------------------------------------
+# Construction and defaults
+# ----------------------------------------------------------------------
+
+
+class TestGeomConstruction:
+ def test_instantiation(self):
+ geom = Geom()
+ assert geom is not None
+
+ def test_instantiation_returns_fresh_instances(self):
+ first = Geom()
+ second = Geom()
+ assert first is not second
+
+ def test_identity_category_code(self):
+ geom = Geom()
+ assert geom._identity.category_code == 'geom'
+
+ def test_min_bond_distance_cutoff_is_numeric_descriptor(self):
+ geom = Geom()
+ assert isinstance(geom.min_bond_distance_cutoff, NumericDescriptor)
+
+ def test_bond_distance_incr_is_numeric_descriptor(self):
+ geom = Geom()
+ assert isinstance(geom.bond_distance_incr, NumericDescriptor)
+
+ def test_default_min_bond_distance_cutoff(self):
+ geom = Geom()
+ assert geom.min_bond_distance_cutoff.value == 0.0
+
+ def test_default_bond_distance_incr(self):
+ geom = Geom()
+ assert geom.bond_distance_incr.value == 0.25
+
+ def test_descriptor_names(self):
+ geom = Geom()
+ assert geom.min_bond_distance_cutoff.name == 'min_bond_distance_cutoff'
+ assert geom.bond_distance_incr.name == 'bond_distance_incr'
+
+ def test_descriptor_descriptions(self):
+ geom = Geom()
+ assert geom.min_bond_distance_cutoff.description == (
+ 'Minimum permitted bonded distance (angstrom).'
+ )
+ assert geom.bond_distance_incr.description == (
+ 'Increment added to the summed bonding radii (angstrom).'
+ )
+
+ def test_parameters_lists_both_descriptors(self):
+ geom = Geom()
+ names = {p.name for p in geom.parameters}
+ assert names == {'min_bond_distance_cutoff', 'bond_distance_incr'}
+
+
+# ----------------------------------------------------------------------
+# CIF handler names (cif_core ``_geom``)
+# ----------------------------------------------------------------------
+
+
+class TestGeomCifHandlers:
+ def test_min_bond_distance_cutoff_cif_name(self):
+ geom = Geom()
+ assert geom.min_bond_distance_cutoff._cif_handler.names == [
+ '_geom.min_bond_distance_cutoff',
+ ]
+
+ def test_bond_distance_incr_cif_name(self):
+ geom = Geom()
+ assert geom.bond_distance_incr._cif_handler.names == [
+ '_geom.bond_distance_incr',
+ ]
+
+
+# ----------------------------------------------------------------------
+# Setters — valid values
+# ----------------------------------------------------------------------
+
+
+class TestGeomSettersValid:
+ def test_set_min_bond_distance_cutoff(self):
+ geom = Geom()
+ geom.min_bond_distance_cutoff = 0.5
+ assert geom.min_bond_distance_cutoff.value == 0.5
+
+ def test_set_bond_distance_incr(self):
+ geom = Geom()
+ geom.bond_distance_incr = 0.4
+ assert geom.bond_distance_incr.value == 0.4
+
+ def test_set_min_bond_distance_cutoff_to_zero_boundary(self):
+ # The validator allows ge=0.0, so the lower boundary is valid.
+ geom = Geom()
+ geom.min_bond_distance_cutoff = 1.0
+ geom.min_bond_distance_cutoff = 0.0
+ assert geom.min_bond_distance_cutoff.value == 0.0
+
+ def test_set_bond_distance_incr_to_zero_boundary(self):
+ geom = Geom()
+ geom.bond_distance_incr = 0.0
+ assert geom.bond_distance_incr.value == 0.0
+
+ def test_set_min_bond_distance_cutoff_accepts_int(self):
+ geom = Geom()
+ geom.min_bond_distance_cutoff = 2
+ assert geom.min_bond_distance_cutoff.value == 2
+
+
+# ----------------------------------------------------------------------
+# Setters — invalid values
+#
+# RangeValidator routes out-of-range values through
+# ``Diagnostics.range_mismatch`` -> ``log.error(..., exc_type=TypeError)``.
+# In RAISE mode this raises ``TypeError``; in WARN mode the current
+# value is kept. Tests pin the Logger reaction with monkeypatch so they
+# do not depend on global logger state leaked by other tests.
+# ----------------------------------------------------------------------
+
+
+class TestGeomSettersInvalid:
+ def test_negative_min_bond_distance_cutoff_raises_in_raise_mode(self, monkeypatch):
+ monkeypatch.setattr(Logger, '_reaction', Logger.Reaction.RAISE, raising=True)
+ geom = Geom()
+ with pytest.raises(TypeError):
+ geom.min_bond_distance_cutoff = -1.0
+
+ def test_negative_bond_distance_incr_raises_in_raise_mode(self, monkeypatch):
+ monkeypatch.setattr(Logger, '_reaction', Logger.Reaction.RAISE, raising=True)
+ geom = Geom()
+ with pytest.raises(TypeError):
+ geom.bond_distance_incr = -0.5
+
+ def test_negative_min_bond_distance_cutoff_kept_in_warn_mode(self, monkeypatch):
+ monkeypatch.setattr(Logger, '_reaction', Logger.Reaction.WARN, raising=True)
+ geom = Geom()
+ geom.min_bond_distance_cutoff = -1.0
+ # The out-of-range write is rejected; the default is retained.
+ assert geom.min_bond_distance_cutoff.value == 0.0
+
+ def test_negative_bond_distance_incr_kept_in_warn_mode(self, monkeypatch):
+ monkeypatch.setattr(Logger, '_reaction', Logger.Reaction.WARN, raising=True)
+ geom = Geom()
+ geom.bond_distance_incr = -0.5
+ assert geom.bond_distance_incr.value == 0.25
+
+ def test_wrong_type_min_bond_distance_cutoff_kept_in_warn_mode(self, monkeypatch):
+ monkeypatch.setattr(Logger, '_reaction', Logger.Reaction.WARN, raising=True)
+ geom = Geom()
+ geom.min_bond_distance_cutoff = 'not-a-number'
+ assert geom.min_bond_distance_cutoff.value == 0.0
+
+ def test_wrong_type_bond_distance_incr_raises_in_raise_mode(self, monkeypatch):
+ monkeypatch.setattr(Logger, '_reaction', Logger.Reaction.RAISE, raising=True)
+ geom = Geom()
+ with pytest.raises(TypeError):
+ geom.bond_distance_incr = 'not-a-number'
+
+
+# ----------------------------------------------------------------------
+# CIF serialisation and round-trip
+# ----------------------------------------------------------------------
+
+
+class TestGeomAsCif:
+ def test_as_cif_is_string(self):
+ geom = Geom()
+ assert isinstance(geom.as_cif, str)
+
+ def test_as_cif_contains_both_tags(self):
+ geom = Geom()
+ cif = geom.as_cif
+ assert '_geom.min_bond_distance_cutoff' in cif
+ assert '_geom.bond_distance_incr' in cif
+
+ def test_as_cif_default_lines(self):
+ geom = Geom()
+ lines = geom.as_cif.splitlines()
+ assert lines == [
+ '_geom.min_bond_distance_cutoff 0.',
+ '_geom.bond_distance_incr 0.25',
+ ]
+
+ def test_as_cif_reflects_updated_values(self):
+ geom = Geom()
+ geom.min_bond_distance_cutoff = 0.8
+ geom.bond_distance_incr = 0.3
+ cif = geom.as_cif
+ assert '_geom.min_bond_distance_cutoff 0.8' in cif
+ assert '_geom.bond_distance_incr 0.3' in cif
+
+ def test_from_cif_round_trip(self):
+ import gemmi
+
+ # All values are in range, so no validation error is triggered;
+ # the round-trip is independent of the global Logger reaction.
+ source = Geom()
+ source.min_bond_distance_cutoff = 0.6
+ source.bond_distance_incr = 0.45
+
+ block = gemmi.cif.read_string(f'data_test\n\n{source.as_cif}\n').sole_block()
+
+ restored = Geom()
+ restored.from_cif(block)
+
+ assert restored.min_bond_distance_cutoff.value == 0.6
+ assert restored.bond_distance_incr.value == 0.45
diff --git a/tests/unit/easydiffraction/datablocks/structure/categories/geom/test_factory.py b/tests/unit/easydiffraction/datablocks/structure/categories/geom/test_factory.py
new file mode 100644
index 000000000..f023c2d7e
--- /dev/null
+++ b/tests/unit/easydiffraction/datablocks/structure/categories/geom/test_factory.py
@@ -0,0 +1,112 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+"""Tests for the geom category factory."""
+
+from __future__ import annotations
+
+import pytest
+
+from easydiffraction.core.factory import FactoryBase
+from easydiffraction.datablocks.structure.categories.geom.default import Geom
+from easydiffraction.datablocks.structure.categories.geom.factory import GeomFactory
+
+
+def test_module_import():
+ import easydiffraction.datablocks.structure.categories.geom.factory as MUT
+
+ expected_module_name = 'easydiffraction.datablocks.structure.categories.geom.factory'
+ assert MUT.__name__ == expected_module_name
+
+
+class TestGeomFactoryClass:
+ def test_is_factory_base_subclass(self):
+ assert issubclass(GeomFactory, FactoryBase)
+
+ def test_default_rules_universal_fallback(self):
+ # The only rule is the universal (empty-condition) fallback.
+ assert GeomFactory._default_rules == {frozenset(): 'default'}
+
+ def test_registry_is_independent_from_base(self):
+ # __init_subclass__ gives each subclass its own registry list.
+ assert GeomFactory._registry is not FactoryBase._registry
+
+ def test_geom_is_registered(self):
+ assert Geom in GeomFactory._registry
+
+
+class TestSupportedTags:
+ def test_supported_tags_returns_list(self):
+ tags = GeomFactory.supported_tags()
+ assert isinstance(tags, list)
+
+ def test_default_tag_is_supported(self):
+ assert 'default' in GeomFactory.supported_tags()
+
+ def test_supported_map_links_tag_to_class(self):
+ supported = GeomFactory._supported_map()
+ assert supported['default'] is Geom
+
+
+class TestDefaultTag:
+ def test_default_tag_no_conditions(self):
+ assert GeomFactory.default_tag() == 'default'
+
+ def test_default_tag_ignores_extra_conditions(self):
+ # The universal fallback still wins for unrelated conditions.
+ assert GeomFactory.default_tag(scattering_type='bragg') == 'default'
+
+
+class TestCreate:
+ def test_create_default_returns_geom(self):
+ obj = GeomFactory.create('default')
+ assert isinstance(obj, Geom)
+
+ def test_create_returns_fresh_instances(self):
+ first = GeomFactory.create('default')
+ second = GeomFactory.create('default')
+ assert first is not second
+
+ def test_create_unknown_tag_raises_value_error(self):
+ with pytest.raises(ValueError, match=r"Unsupported type: 'missing'"):
+ GeomFactory.create('missing')
+
+ def test_create_default_has_expected_defaults(self):
+ geom = GeomFactory.create('default')
+ assert geom.min_bond_distance_cutoff.value == 0.0
+ assert geom.bond_distance_incr.value == 0.25
+
+
+class TestCreateDefaultFor:
+ def test_create_default_for_no_conditions(self):
+ obj = GeomFactory.create_default_for()
+ assert isinstance(obj, Geom)
+
+
+class TestSupportedFor:
+ def test_supported_for_no_filters_includes_geom(self):
+ result = GeomFactory.supported_for()
+ assert Geom in result
+
+ def test_supported_for_calculator_filter_does_not_exclude(self):
+ # Geom declares no calculator_support, so the filter cannot
+ # exclude it.
+ result = GeomFactory.supported_for(calculator='cryspy')
+ assert Geom in result
+
+ def test_supported_for_sample_form_filter_does_not_exclude(self):
+ # Geom declares no compatibility, so axis filters cannot
+ # exclude it.
+ result = GeomFactory.supported_for(sample_form='powder')
+ assert Geom in result
+
+
+class TestShowSupported:
+ def test_show_supported_prints_table(self, capsys):
+ GeomFactory.show_supported()
+ out = capsys.readouterr().out
+ assert 'Supported types' in out
+
+ def test_show_supported_lists_default_tag(self, capsys):
+ GeomFactory.show_supported()
+ out = capsys.readouterr().out
+ assert 'default' in out
diff --git a/tests/unit/easydiffraction/datablocks/structure/item/test_base_coverage.py b/tests/unit/easydiffraction/datablocks/structure/item/test_base_coverage.py
index 6d8967703..f7f968f01 100644
--- a/tests/unit/easydiffraction/datablocks/structure/item/test_base_coverage.py
+++ b/tests/unit/easydiffraction/datablocks/structure/item/test_base_coverage.py
@@ -93,11 +93,6 @@ def test_atom_sites_setter_replaces_instance(self, structure):
class TestStructureDisplay:
- def test_show(self, structure, capsys):
- structure.show()
- out = capsys.readouterr().out
- assert 'test_struct' in out
-
def test_show_as_cif(self, structure, capsys):
structure.show_as_cif()
out = capsys.readouterr().out
diff --git a/tests/unit/easydiffraction/display/plotters/test_ascii.py b/tests/unit/easydiffraction/display/plotters/test_ascii.py
index 809cbc772..16dea0113 100644
--- a/tests/unit/easydiffraction/display/plotters/test_ascii.py
+++ b/tests/unit/easydiffraction/display/plotters/test_ascii.py
@@ -98,6 +98,23 @@ def test_ascii_plotter_plot_single_crystal(capsys):
assert '·' in out
+def test_ascii_plotter_single_crystal_marker_uses_paragraph_style():
+ from easydiffraction.display.plotters.ascii import AsciiPlotter
+ from easydiffraction.display.plotters.ascii import SINGLE_CRYSTAL_SCATTER_SYMBOL
+ from easydiffraction.utils.logging import CONSOLE_PARAGRAPH_STYLE
+
+ line = AsciiPlotter._single_crystal_grid_line([
+ ' ',
+ SINGLE_CRYSTAL_SCATTER_SYMBOL,
+ '·',
+ ])
+
+ marker_start = line.plain.index(SINGLE_CRYSTAL_SCATTER_SYMBOL)
+ assert line.spans[0].start == marker_start
+ assert line.spans[0].end == marker_start + 1
+ assert line.spans[0].style == CONSOLE_PARAGRAPH_STYLE
+
+
def test_ascii_plotter_plot_powder_meas_vs_calc_announces_plotly_only_bragg_row(capsys):
from easydiffraction.display.plotters.ascii import AsciiPlotter
from easydiffraction.display.plotters.base import BraggTickSet
diff --git a/tests/unit/easydiffraction/display/plotters/test_plotly.py b/tests/unit/easydiffraction/display/plotters/test_plotly.py
index 35fd5f2b8..812ec9157 100644
--- a/tests/unit/easydiffraction/display/plotters/test_plotly.py
+++ b/tests/unit/easydiffraction/display/plotters/test_plotly.py
@@ -13,14 +13,78 @@ def test_module_import():
assert expected_module_name == actual_module_name
-def test_get_layout_sets_title_and_axis_title_font_sizes():
+@pytest.mark.parametrize(
+ 'is_dark_mode',
+ [False, True],
+)
+def test_get_layout_sets_title_axis_and_theme_colors(
+ monkeypatch,
+ is_dark_mode,
+):
import easydiffraction.display.plotters.plotly as pp
+ if is_dark_mode:
+ background_color = pp.DARK_BACKGROUND_COLOR
+ axis_color = pp.DARK_AXIS_FRAME_COLOR
+ grid_color = pp.DARK_INNER_TICK_GRID_COLOR
+ else:
+ background_color = pp.LIGHT_BACKGROUND_COLOR
+ axis_color = pp.LIGHT_AXIS_FRAME_COLOR
+ grid_color = pp.LIGHT_INNER_TICK_GRID_COLOR
+
+ monkeypatch.setattr(
+ pp.PlotlyPlotter,
+ '_is_dark_mode',
+ classmethod(lambda cls: is_dark_mode),
+ )
+
layout = pp.PlotlyPlotter._get_layout('Title', ['x axis', 'y axis'])
assert layout.title.font.size == pp.TITLE_FONT_SIZE
assert layout.xaxis.title.font.size == pp.AXIS_TITLE_FONT_SIZE
assert layout.yaxis.title.font.size == pp.AXIS_TITLE_FONT_SIZE
+ assert layout.paper_bgcolor == background_color
+ assert layout.plot_bgcolor == background_color
+ assert layout.xaxis.linecolor == axis_color
+ assert layout.yaxis.linecolor == axis_color
+ assert layout.xaxis.gridcolor == grid_color
+ assert layout.yaxis.gridcolor == grid_color
+ assert layout.xaxis.ticklabelstandoff == pp.X_AXIS_TICK_LABEL_STANDOFF
+ assert layout.yaxis.ticklabelstandoff == pp.Y_AXIS_TICK_LABEL_STANDOFF
+
+
+@pytest.mark.parametrize(
+ ('is_dark_mode', 'background_color'),
+ [
+ (False, 'light-background'),
+ (True, 'dark-background'),
+ ],
+)
+def test_correlation_colorscale_uses_theme_background(
+ monkeypatch,
+ is_dark_mode,
+ background_color,
+):
+ import easydiffraction.display.plotters.plotly as pp
+
+ monkeypatch.setattr(
+ pp.PlotlyPlotter,
+ '_is_dark_mode',
+ classmethod(lambda cls: is_dark_mode),
+ )
+ monkeypatch.setattr(
+ pp.PlotlyPlotter,
+ '_background_color',
+ classmethod(lambda cls: background_color),
+ )
+
+ colorscale = pp.PlotlyPlotter._correlation_colorscale()
+
+ assert colorscale == [
+ (0.0, '#d73027'),
+ (0.5, background_color),
+ (1.0, '#4575b4'),
+ ]
def test_get_trace_and_plot(monkeypatch):
@@ -166,6 +230,34 @@ def __init__(self, html):
assert captured.get('show_called') is not True
assert captured['config']['displayModeBar'] is True
assert captured['config']['displaylogo'] is False
+ assert captured['config']['responsive'] is True
+ assert 'data-jp-theme-light' in captured['post_script']
+ assert 'data-md-color-scheme' in captured['post_script']
+ assert 'graphDiv.dataset.edPlotlyTheme' in captured['post_script']
+ assert f"background: '{pp.DARK_BACKGROUND_COLOR}'" in captured['post_script']
+ assert f"background: '{pp.LIGHT_BACKGROUND_COLOR}'" in captured['post_script']
+ assert f"axisFrame: '{pp.DARK_AXIS_FRAME_COLOR}'" in captured['post_script']
+ assert f"axisFrame: '{pp.LIGHT_AXIS_FRAME_COLOR}'" in captured['post_script']
+ assert f"innerTickGrid: '{pp.DARK_INNER_TICK_GRID_COLOR}'" in captured['post_script']
+ assert f"innerTickGrid: '{pp.LIGHT_INNER_TICK_GRID_COLOR}'" in captured['post_script']
+ assert f"hoverBackground: '{pp.DARK_HOVER_BACKGROUND_COLOR}'" in captured['post_script']
+ assert f"legend: '{pp.DARK_LEGEND_BACKGROUND_COLOR}'" in captured['post_script']
+ assert 'ed-plotly-modebar-theme-style' in captured['post_script']
+ assert 'ed-plotly-themed-modebar' in captured['post_script']
+ assert '--ed-plotly-modebar-icon-color' in captured['post_script']
+ assert '--ed-plotly-modebar-icon-hover-opacity' in captured['post_script']
+ assert 'const correlationColorscale = function (colors) {' in captured['post_script']
+ assert 'const themeSync = meta.ed_plotly_theme_sync;' in captured['post_script']
+ assert 'const applyAnnotationTheme = function (update, colors) {' in captured['post_script']
+ assert 'const shapeIndexes = themeSync.axis_frame_shape_indexes;' in captured['post_script']
+ assert 'if (themeSync.correlation_heatmap !== true) {' in captured['post_script']
+ assert 'window.Plotly.restyle(' in captured['post_script']
+ assert 'window.Plotly.relayout(graphDiv, update)' in captured['post_script']
+ assert 'Promise.all(pending).then(function () {' in captured['post_script']
+ assert 'window.Plotly.Plots.resize(graphDiv)' in captured['post_script']
+ assert "document.addEventListener('visibilitychange'" in captured['post_script']
+ assert "window.addEventListener('focus', scheduleResize);" in captured['post_script']
+ assert 'new ResizeObserver(scheduleResize)' in captured['post_script']
assert 'data-legend-toggle="true"' in captured['post_script']
assert 'Toggle legend' in captured['post_script']
assert 'graphDiv.dataset.legendVisible' in captured['post_script']
@@ -229,7 +321,9 @@ def __init__(self, html):
plotter._show_figure(DummyFig())
assert captured.get('show_called') is not True
- assert captured['post_script'] is None
+ assert captured['post_script'] is not None
+ assert 'data-jp-theme-light' in captured['post_script']
+ assert 'data-legend-toggle="true"' not in captured['post_script']
assert captured['displayed_html'] == 'plot
'
@@ -278,7 +372,8 @@ def __init__(self, html):
plotter._show_figure(DummyFig())
assert captured.get('show_called') is not True
- assert captured['post_script'] is None
+ assert captured['post_script'] is not None
+ assert 'data-jp-theme-light' in captured['post_script']
assert 'aspect-ratio: 1 / 1;' in captured['displayed_html']
assert 'ed-fixed-aspect-plotly-wrapper' in captured['displayed_html']
assert 'plot
' in captured['displayed_html']
@@ -350,12 +445,20 @@ def __init__(self, html):
assert trace.kwargs['y'] == y_meas
assert trace.kwargs['mode'] == 'markers'
assert 'error_y' in trace.kwargs
+ assert trace.kwargs['marker']['size'] == pp.MEASURED_MARKER_SIZE
+ assert trace.kwargs['marker']['line']['color'] == pp.DEFAULT_COLORS['meas']
+ assert trace.kwargs['error_y']['thickness'] == pp.MEASURED_ERROR_BAR_THICKNESS
+ assert trace.kwargs['error_y']['width'] == pp.MEASURED_ERROR_BAR_WIDTH
- # Exercise _get_diagonal_shape
- shape = plotter._get_diagonal_shape()
+ # Exercise _get_diagonal_shape (now a data-coordinate y=x line)
+ shape = plotter._get_diagonal_shape(0.0, 10.0)
assert shape['type'] == 'line'
- assert shape['xref'] == 'paper'
- assert shape['yref'] == 'paper'
+ assert shape['xref'] == 'x'
+ assert shape['yref'] == 'y'
+ assert (shape['x0'], shape['y0']) == (0.0, 0.0)
+ assert (shape['x1'], shape['y1']) == (10.0, 10.0)
+ assert shape['line']['color'] == pp.DIAGONAL_LINE_COLOR
+ assert shape['line']['width'] == pp.DIAGONAL_LINE_WIDTH
# Exercise plot_single_crystal
plotter.plot_single_crystal(
@@ -370,6 +473,44 @@ def __init__(self, html):
assert dummy_display_calls['count'] == 1 or shown['count'] == 1
+def test_single_crystal_axis_range_unions_calc_and_meas_with_uncertainty():
+ import easydiffraction.display.plotters.plotly as pp
+
+ minimum, maximum = pp.single_crystal_axis_range(
+ x_calc=[2.0, 8.0],
+ y_meas=[1.0, 10.0],
+ y_meas_su=[0.5, 1.0],
+ )
+ # Spans meas-su minimum (0.5) to meas+su maximum (11.0); calc 2..8 is
+ # inside. A 5% margin of the 10.5 span pads both ends symmetrically.
+ assert minimum == pytest.approx(0.5 - 0.525)
+ assert maximum == pytest.approx(11.0 + 0.525)
+
+
+def test_single_crystal_axis_range_handles_missing_uncertainty():
+ import easydiffraction.display.plotters.plotly as pp
+
+ minimum, maximum = pp.single_crystal_axis_range(
+ x_calc=[0.0, 4.0],
+ y_meas=[1.0, 3.0],
+ y_meas_su=None,
+ )
+ assert minimum < 0.0
+ assert maximum > 4.0
+
+
+def test_single_crystal_tick_step_rounds_to_nice_value():
+ import easydiffraction.display.plotters.plotly as pp
+
+ # Span 3000 over ~6 intervals -> raw 500 -> nice 500.
+ assert pp.single_crystal_tick_step(0.0, 3000.0) == pytest.approx(500.0)
+ # Padded heidi-like range (span just above the 500 threshold) rounds to
+ # 500, not 750 (regression for the old round-up logic).
+ assert pp.single_crystal_tick_step(-158.275, 2841.73) == pytest.approx(500.0)
+ # Degenerate span falls back to 1.0.
+ assert pp.single_crystal_tick_step(5.0, 5.0) == pytest.approx(1.0)
+
+
def test_get_bragg_tick_trace_includes_peak_metadata():
from easydiffraction.display.plotters.base import BraggTickSet
from easydiffraction.display.plotters.plotly import PlotlyPlotter
@@ -452,6 +593,12 @@ def fake_show_figure(self, fig):
assert fig.layout.xaxis.matches == 'x'
assert fig.layout.xaxis2.matches == 'x'
assert fig.layout.xaxis3.matches == 'x'
+ assert fig.layout.xaxis.ticklabelstandoff == pp.X_AXIS_TICK_LABEL_STANDOFF
+ assert fig.layout.xaxis2.ticklabelstandoff == pp.X_AXIS_TICK_LABEL_STANDOFF
+ assert fig.layout.xaxis3.ticklabelstandoff == pp.X_AXIS_TICK_LABEL_STANDOFF
+ assert fig.layout.yaxis.ticklabelstandoff == pp.Y_AXIS_TICK_LABEL_STANDOFF
+ assert fig.layout.yaxis2.ticklabelstandoff == pp.Y_AXIS_TICK_LABEL_STANDOFF
+ assert fig.layout.yaxis3.ticklabelstandoff == pp.Y_AXIS_TICK_LABEL_STANDOFF
assert fig.layout.yaxis3.scaleanchor == 'y'
assert fig.layout.yaxis3.scaleratio == pytest.approx(1.0)
diff --git a/tests/unit/easydiffraction/display/structure/assets/test_colors.py b/tests/unit/easydiffraction/display/structure/assets/test_colors.py
new file mode 100644
index 000000000..2d723716b
--- /dev/null
+++ b/tests/unit/easydiffraction/display/structure/assets/test_colors.py
@@ -0,0 +1,194 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+"""Tests for the structure-view colour palette module."""
+
+from __future__ import annotations
+
+import pytest
+
+from easydiffraction.display.structure.assets import colors
+from easydiffraction.display.structure.assets.colors import AXIS_COLORS
+from easydiffraction.display.structure.assets.colors import DARK_THEME
+from easydiffraction.display.structure.assets.colors import DEFAULT_COLOR
+from easydiffraction.display.structure.assets.colors import LIGHT_THEME
+from easydiffraction.display.structure.assets.colors import VACANCY_COLOR
+from easydiffraction.display.structure.assets.colors import color_for
+from easydiffraction.display.structure.assets.colors import theme_colors
+from easydiffraction.display.structure.assets.elements import ELEMENT_COLORS
+
+
+def _is_rgb(value):
+ """Return True when value is an in-range 0-255 RGB triple."""
+ return (
+ isinstance(value, tuple)
+ and len(value) == 3
+ and all(isinstance(channel, int) for channel in value)
+ and all(0 <= channel <= 255 for channel in value)
+ )
+
+
+# ------------------------------------------------------------------
+# Module surface
+# ------------------------------------------------------------------
+
+
+class TestModule:
+ def test_module_import(self):
+ expected_module_name = 'easydiffraction.display.structure.assets.colors'
+ assert colors.__name__ == expected_module_name
+
+ def test_public_callables_present(self):
+ assert callable(colors.color_for)
+ assert callable(colors.theme_colors)
+
+
+# ------------------------------------------------------------------
+# Module-level constants
+# ------------------------------------------------------------------
+
+
+class TestConstants:
+ def test_default_color_value(self):
+ assert DEFAULT_COLOR == (255, 192, 203)
+
+ def test_default_color_is_rgb(self):
+ assert _is_rgb(DEFAULT_COLOR)
+
+ def test_vacancy_color_value(self):
+ assert VACANCY_COLOR == (210, 210, 210)
+
+ def test_vacancy_color_is_rgb(self):
+ assert _is_rgb(VACANCY_COLOR)
+
+ def test_axis_colors_keys(self):
+ assert set(AXIS_COLORS) == {'a', 'b', 'c'}
+
+ def test_axis_colors_values(self):
+ assert AXIS_COLORS['a'] == (220, 40, 40)
+ assert AXIS_COLORS['b'] == (40, 180, 40)
+ assert AXIS_COLORS['c'] == (40, 80, 220)
+
+ def test_axis_colors_all_rgb(self):
+ assert all(_is_rgb(value) for value in AXIS_COLORS.values())
+
+ def test_axis_colors_red_green_blue_dominance(self):
+ # a=red, b=green, c=blue (VESTA convention): the named channel
+ # is the strongest component of each axis colour.
+ assert AXIS_COLORS['a'][0] == max(AXIS_COLORS['a'])
+ assert AXIS_COLORS['b'][1] == max(AXIS_COLORS['b'])
+ assert AXIS_COLORS['c'][2] == max(AXIS_COLORS['c'])
+
+ def test_light_theme_keys(self):
+ assert set(LIGHT_THEME) == {'background', 'foreground'}
+
+ def test_dark_theme_keys(self):
+ assert set(DARK_THEME) == {'background', 'foreground'}
+
+ def test_light_theme_values(self):
+ assert LIGHT_THEME['background'] == (255, 255, 255)
+ assert LIGHT_THEME['foreground'] == (33, 33, 33)
+
+ def test_dark_theme_values(self):
+ assert DARK_THEME['background'] == (33, 33, 33)
+ assert DARK_THEME['foreground'] == (235, 235, 235)
+
+ def test_theme_values_all_rgb(self):
+ for theme in (LIGHT_THEME, DARK_THEME):
+ assert all(_is_rgb(value) for value in theme.values())
+
+ def test_light_and_dark_backgrounds_differ(self):
+ assert LIGHT_THEME['background'] != DARK_THEME['background']
+
+ def test_rgb_type_alias(self):
+ assert colors.Rgb == tuple[int, int, int]
+
+
+# ------------------------------------------------------------------
+# color_for
+# ------------------------------------------------------------------
+
+
+class TestColorFor:
+ def test_known_element_known_scheme_returns_scheme_color(self):
+ # Si has distinct jmol and vesta entries; requesting vesta must
+ # return the vesta value, not the jmol fallback.
+ assert color_for('Si', 'vesta') == (27, 59, 250)
+
+ def test_jmol_scheme_returns_jmol_color(self):
+ assert color_for('Si', 'jmol') == (240, 200, 160)
+
+ def test_scheme_colors_can_differ(self):
+ assert color_for('Si', 'jmol') != color_for('Si', 'vesta')
+
+ def test_returns_exact_palette_reference(self):
+ # The function returns the stored tuple, not a copy or recolour.
+ assert color_for('Fe', 'vesta') == ELEMENT_COLORS['Fe']['vesta']
+ assert color_for('Fe', 'jmol') == ELEMENT_COLORS['Fe']['jmol']
+
+ def test_unknown_scheme_falls_back_to_jmol(self):
+ # 'cpk' is not a stored scheme key, so the element's jmol colour
+ # is used as the fallback.
+ assert color_for('Fe', 'cpk') == ELEMENT_COLORS['Fe']['jmol']
+
+ def test_none_scheme_value_falls_back_to_jmol(self):
+ # Some heavy elements store vesta=None; requesting vesta must
+ # fall through to the jmol colour rather than return None.
+ element = next(el for el, data in ELEMENT_COLORS.items() if data.get('vesta') is None)
+ result = color_for(element, 'vesta')
+ assert result == ELEMENT_COLORS[element]['jmol']
+ assert result is not None
+
+ def test_unknown_element_returns_default(self):
+ assert color_for('Xx', 'jmol') == DEFAULT_COLOR
+
+ def test_unknown_element_returns_default_for_any_scheme(self):
+ assert color_for('Zz', 'vesta') == DEFAULT_COLOR
+
+ def test_empty_element_returns_default(self):
+ assert color_for('', 'jmol') == DEFAULT_COLOR
+
+ def test_case_sensitive_lookup(self):
+ # Symbols are stored title-cased; a lower-cased symbol is unknown.
+ assert color_for('fe', 'jmol') == DEFAULT_COLOR
+
+ @pytest.mark.parametrize('scheme', ['jmol', 'vesta'])
+ def test_all_elements_return_rgb(self, scheme):
+ for element in ELEMENT_COLORS:
+ assert _is_rgb(color_for(element, scheme))
+
+ def test_never_returns_none(self):
+ # Covers the `entry.get('jmol') or DEFAULT_COLOR` guard for every
+ # element under both schemes.
+ for element in ELEMENT_COLORS:
+ assert color_for(element, 'vesta') is not None
+ assert color_for(element, 'jmol') is not None
+
+
+# ------------------------------------------------------------------
+# theme_colors
+# ------------------------------------------------------------------
+
+
+class TestThemeColors:
+ def test_dark_returns_dark_theme(self):
+ assert theme_colors(dark=True) == DARK_THEME
+
+ def test_light_returns_light_theme(self):
+ assert theme_colors(dark=False) == LIGHT_THEME
+
+ def test_dark_returns_module_dict_reference(self):
+ assert theme_colors(dark=True) is DARK_THEME
+
+ def test_light_returns_module_dict_reference(self):
+ assert theme_colors(dark=False) is LIGHT_THEME
+
+ def test_result_has_background_and_foreground(self):
+ result = theme_colors(dark=False)
+ assert set(result) == {'background', 'foreground'}
+
+ def test_dark_is_keyword_only(self):
+ # `dark` is a keyword-only parameter; passing it positionally
+ # is a TypeError.
+ dark_flag = True
+ with pytest.raises(TypeError):
+ theme_colors(dark_flag)
diff --git a/tests/unit/easydiffraction/display/structure/assets/test_elements.py b/tests/unit/easydiffraction/display/structure/assets/test_elements.py
new file mode 100644
index 000000000..559be523e
--- /dev/null
+++ b/tests/unit/easydiffraction/display/structure/assets/test_elements.py
@@ -0,0 +1,221 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+"""Tests for the bundled per-element radii and colour palettes."""
+
+from __future__ import annotations
+
+import easydiffraction.display.structure.assets.elements as elements_module
+from easydiffraction.display.structure.assets.elements import ELEMENT_COLORS
+from easydiffraction.display.structure.assets.elements import ELEMENT_RADII
+
+# Model/scheme keys every entry must carry, matching the consumers in
+# radii.py (radius_for) and colors.py (color_for).
+RADIUS_MODELS = ('vdw', 'covalent', 'ionic', 'atomic')
+COLOR_SCHEMES = ('jmol', 'vesta')
+
+
+def test_module_import():
+ expected_module_name = 'easydiffraction.display.structure.assets.elements'
+ actual_module_name = elements_module.__name__
+ assert expected_module_name == actual_module_name
+
+
+# ------------------------------------------------------------------
+# ELEMENT_RADII — container shape
+# ------------------------------------------------------------------
+
+
+class TestElementRadiiContainer:
+ def test_is_dict(self):
+ assert isinstance(ELEMENT_RADII, dict)
+
+ def test_not_empty(self):
+ assert len(ELEMENT_RADII) > 0
+
+ def test_covers_full_periodic_table(self):
+ # Hydrogen through oganesson: 118 elements.
+ assert len(ELEMENT_RADII) == 118
+
+ def test_first_and_last_symbols(self):
+ symbols = list(ELEMENT_RADII)
+ assert symbols[0] == 'H'
+ assert symbols[-1] == 'Og'
+
+ def test_known_symbols_present(self):
+ for symbol in ('H', 'C', 'N', 'O', 'Fe', 'Si', 'U', 'Og'):
+ assert symbol in ELEMENT_RADII
+
+ def test_keys_are_titlecase_element_symbols(self):
+ for symbol in ELEMENT_RADII:
+ assert isinstance(symbol, str)
+ assert 1 <= len(symbol) <= 2
+ assert symbol[0].isupper()
+ assert symbol.istitle()
+
+
+# ------------------------------------------------------------------
+# ELEMENT_RADII — per-entry contract
+# ------------------------------------------------------------------
+
+
+class TestElementRadiiEntries:
+ def test_every_entry_is_a_dict(self):
+ for entry in ELEMENT_RADII.values():
+ assert isinstance(entry, dict)
+
+ def test_every_entry_has_exactly_the_model_keys(self):
+ expected_keys = set(RADIUS_MODELS)
+ for symbol, entry in ELEMENT_RADII.items():
+ assert set(entry) == expected_keys, symbol
+
+ def test_values_are_float_or_none(self):
+ for symbol, entry in ELEMENT_RADII.items():
+ for model in RADIUS_MODELS:
+ value = entry[model]
+ assert value is None or isinstance(value, (int, float)), (symbol, model)
+
+ def test_non_none_values_are_positive(self):
+ for symbol, entry in ELEMENT_RADII.items():
+ for model in RADIUS_MODELS:
+ value = entry[model]
+ if value is not None:
+ assert value > 0.0, (symbol, model)
+
+ def test_covalent_radius_always_present(self):
+ # radius_for() relies on covalent as the universal fallback, so
+ # no bundled element may have a None covalent radius.
+ for symbol, entry in ELEMENT_RADII.items():
+ assert entry['covalent'] is not None, symbol
+ assert entry['covalent'] > 0.0, symbol
+
+
+# ------------------------------------------------------------------
+# ELEMENT_RADII — spot-checked known values
+# ------------------------------------------------------------------
+
+
+class TestElementRadiiKnownValues:
+ def test_hydrogen(self):
+ assert ELEMENT_RADII['H'] == {
+ 'vdw': 1.1,
+ 'covalent': 0.31,
+ 'ionic': None,
+ 'atomic': 0.25,
+ }
+
+ def test_iron(self):
+ assert ELEMENT_RADII['Fe'] == {
+ 'vdw': 1.72,
+ 'covalent': 1.32,
+ 'ionic': 0.78,
+ 'atomic': 1.4,
+ }
+
+ def test_helium_has_no_ionic_or_atomic(self):
+ assert ELEMENT_RADII['He']['ionic'] is None
+ assert ELEMENT_RADII['He']['atomic'] is None
+ assert ELEMENT_RADII['He']['covalent'] == 0.28
+
+
+# ------------------------------------------------------------------
+# ELEMENT_COLORS — container shape
+# ------------------------------------------------------------------
+
+
+class TestElementColorsContainer:
+ def test_is_dict(self):
+ assert isinstance(ELEMENT_COLORS, dict)
+
+ def test_not_empty(self):
+ assert len(ELEMENT_COLORS) > 0
+
+ def test_covers_full_periodic_table(self):
+ assert len(ELEMENT_COLORS) == 118
+
+ def test_first_and_last_symbols(self):
+ symbols = list(ELEMENT_COLORS)
+ assert symbols[0] == 'H'
+ assert symbols[-1] == 'Og'
+
+ def test_keys_are_titlecase_element_symbols(self):
+ for symbol in ELEMENT_COLORS:
+ assert isinstance(symbol, str)
+ assert 1 <= len(symbol) <= 2
+ assert symbol[0].isupper()
+ assert symbol.istitle()
+
+
+# ------------------------------------------------------------------
+# ELEMENT_COLORS — per-entry contract
+# ------------------------------------------------------------------
+
+
+def _assert_valid_rgb(value, context):
+ assert isinstance(value, tuple), context
+ assert len(value) == 3, context
+ for component in value:
+ assert isinstance(component, int), context
+ assert 0 <= component <= 255, context
+
+
+class TestElementColorsEntries:
+ def test_every_entry_is_a_dict(self):
+ for entry in ELEMENT_COLORS.values():
+ assert isinstance(entry, dict)
+
+ def test_every_entry_has_exactly_the_scheme_keys(self):
+ expected_keys = set(COLOR_SCHEMES)
+ for symbol, entry in ELEMENT_COLORS.items():
+ assert set(entry) == expected_keys, symbol
+
+ def test_jmol_always_present_and_valid_rgb(self):
+ # color_for() falls back to the jmol colour, so every element
+ # must have a usable jmol RGB triple.
+ for symbol, entry in ELEMENT_COLORS.items():
+ assert entry['jmol'] is not None, symbol
+ _assert_valid_rgb(entry['jmol'], symbol)
+
+ def test_vesta_is_rgb_or_none(self):
+ for symbol, entry in ELEMENT_COLORS.items():
+ value = entry['vesta']
+ if value is not None:
+ _assert_valid_rgb(value, symbol)
+
+
+# ------------------------------------------------------------------
+# ELEMENT_COLORS — spot-checked known values
+# ------------------------------------------------------------------
+
+
+class TestElementColorsKnownValues:
+ def test_hydrogen_is_white_in_jmol(self):
+ assert ELEMENT_COLORS['H']['jmol'] == (255, 255, 255)
+
+ def test_oxygen_is_red_in_jmol(self):
+ assert ELEMENT_COLORS['O']['jmol'] == (255, 13, 13)
+
+ def test_nobelium_has_no_vesta_entry(self):
+ # No has a jmol colour but no VESTA override (falls back to jmol).
+ assert ELEMENT_COLORS['No']['vesta'] is None
+ assert ELEMENT_COLORS['No']['jmol'] == (189, 13, 135)
+
+ def test_some_elements_have_a_vesta_override(self):
+ # At least one element must carry a non-None VESTA colour,
+ # otherwise the second scheme would be dead data.
+ with_vesta = [s for s, e in ELEMENT_COLORS.items() if e['vesta'] is not None]
+ assert with_vesta
+
+
+# ------------------------------------------------------------------
+# Cross-dictionary consistency
+# ------------------------------------------------------------------
+
+
+class TestRadiiAndColorsConsistency:
+ def test_same_element_set(self):
+ # Every element resolvable for a radius must also resolve for a
+ # colour, and vice versa.
+ assert set(ELEMENT_RADII) == set(ELEMENT_COLORS)
+
+ def test_same_ordering(self):
+ assert list(ELEMENT_RADII) == list(ELEMENT_COLORS)
diff --git a/tests/unit/easydiffraction/display/structure/assets/test_radii.py b/tests/unit/easydiffraction/display/structure/assets/test_radii.py
new file mode 100644
index 000000000..a38aa41a9
--- /dev/null
+++ b/tests/unit/easydiffraction/display/structure/assets/test_radii.py
@@ -0,0 +1,174 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+"""Tests for per-element radius lookup with covalent fallback."""
+
+from __future__ import annotations
+
+import easydiffraction.display.structure.assets.radii as radii_module
+from easydiffraction.display.structure.assets.elements import ELEMENT_RADII
+from easydiffraction.display.structure.assets.radii import DEFAULT_RADIUS
+from easydiffraction.display.structure.assets.radii import radius_for
+
+
+def test_module_import():
+ expected_module_name = 'easydiffraction.display.structure.assets.radii'
+ actual_module_name = radii_module.__name__
+ assert expected_module_name == actual_module_name
+
+
+class TestDefaultRadius:
+ def test_value(self):
+ assert DEFAULT_RADIUS == 1.0
+
+ def test_is_float(self):
+ assert isinstance(DEFAULT_RADIUS, float)
+
+
+class TestRadiusForDirectModelHit:
+ """Element present and the requested model has a value: no fallback."""
+
+ def test_vdw(self):
+ radius, substituted = radius_for('Fe', 'vdw')
+ assert radius == ELEMENT_RADII['Fe']['vdw']
+ assert radius == 1.72
+ assert substituted is False
+
+ def test_covalent(self):
+ radius, substituted = radius_for('Fe', 'covalent')
+ assert radius == ELEMENT_RADII['Fe']['covalent']
+ assert radius == 1.32
+ assert substituted is False
+
+ def test_ionic(self):
+ radius, substituted = radius_for('Fe', 'ionic')
+ assert radius == ELEMENT_RADII['Fe']['ionic']
+ assert radius == 0.78
+ assert substituted is False
+
+ def test_atomic(self):
+ radius, substituted = radius_for('Fe', 'atomic')
+ assert radius == ELEMENT_RADII['Fe']['atomic']
+ assert radius == 1.4
+ assert substituted is False
+
+ def test_returns_tuple_of_float_and_bool(self):
+ result = radius_for('Fe', 'vdw')
+ assert isinstance(result, tuple)
+ assert len(result) == 2
+ radius, substituted = result
+ assert isinstance(radius, float)
+ assert isinstance(substituted, bool)
+
+
+class TestRadiusForCovalentFallback:
+ """Element present but the requested model is None: covalent is used."""
+
+ def test_ionic_missing_falls_back_to_covalent(self):
+ # H has ionic=None but covalent=0.31.
+ assert ELEMENT_RADII['H']['ionic'] is None
+ radius, substituted = radius_for('H', 'ionic')
+ assert radius == ELEMENT_RADII['H']['covalent']
+ assert radius == 0.31
+ assert substituted is True
+
+ def test_atomic_missing_falls_back_to_covalent(self):
+ # He has atomic=None but covalent=0.28.
+ assert ELEMENT_RADII['He']['atomic'] is None
+ radius, substituted = radius_for('He', 'atomic')
+ assert radius == ELEMENT_RADII['He']['covalent']
+ assert radius == 0.28
+ assert substituted is True
+
+
+class TestRadiusForUnknownElement:
+ """Element absent from the database: DEFAULT_RADIUS, substituted."""
+
+ def test_unknown_symbol(self):
+ radius, substituted = radius_for('Zz', 'vdw')
+ assert radius == DEFAULT_RADIUS
+ assert substituted is True
+
+ def test_empty_symbol(self):
+ radius, substituted = radius_for('', 'vdw')
+ assert radius == DEFAULT_RADIUS
+ assert substituted is True
+
+ def test_unknown_element_ignores_model(self):
+ # Model is irrelevant once the element is unknown.
+ for model in ('vdw', 'covalent', 'ionic', 'atomic', 'nonsense'):
+ radius, substituted = radius_for('Qq', model)
+ assert radius == DEFAULT_RADIUS
+ assert substituted is True
+
+
+class TestRadiusForUnknownModel:
+ """Element present but the model key is unknown: covalent fallback."""
+
+ def test_unknown_model_falls_back_to_covalent(self):
+ # 'bogus' is not a key in any entry, so entry.get returns None
+ # and the covalent radius is substituted.
+ radius, substituted = radius_for('Fe', 'bogus')
+ assert radius == ELEMENT_RADII['Fe']['covalent']
+ assert substituted is True
+
+
+class TestRadiusForDefaultFallback:
+ """Entry exists, requested model None, and covalent also None.
+
+ No real element has a None covalent radius, so this last-resort
+ branch is exercised by patching the database with a synthetic
+ entry whose every radius is None.
+ """
+
+ def test_default_radius_when_covalent_is_none(self, monkeypatch):
+ patched = dict(ELEMENT_RADII)
+ patched['Xx'] = {'vdw': None, 'covalent': None, 'ionic': None, 'atomic': None}
+ monkeypatch.setattr(radii_module, 'ELEMENT_RADII', patched)
+
+ radius, substituted = radius_for('Xx', 'vdw')
+ assert radius == DEFAULT_RADIUS
+ assert substituted is True
+
+ def test_default_radius_when_model_and_covalent_none(self, monkeypatch):
+ patched = dict(ELEMENT_RADII)
+ patched['Xx'] = {'vdw': 2.0, 'covalent': None, 'ionic': None, 'atomic': None}
+ monkeypatch.setattr(radii_module, 'ELEMENT_RADII', patched)
+
+ # ionic is None and covalent is None -> last-resort default.
+ radius, substituted = radius_for('Xx', 'ionic')
+ assert radius == DEFAULT_RADIUS
+ assert substituted is True
+
+ def test_still_uses_model_value_when_present(self, monkeypatch):
+ patched = dict(ELEMENT_RADII)
+ patched['Xx'] = {'vdw': 2.0, 'covalent': None, 'ionic': None, 'atomic': None}
+ monkeypatch.setattr(radii_module, 'ELEMENT_RADII', patched)
+
+ # The requested model has a value, so no fallback happens.
+ radius, substituted = radius_for('Xx', 'vdw')
+ assert radius == 2.0
+ assert substituted is False
+
+
+class TestRadiusForAllRealElements:
+ """Smoke check across the whole bundled database."""
+
+ def test_every_element_resolves_to_positive_radius(self):
+ # A handful of bundled entries store integer literals (e.g. Pa
+ # covalent=2, Cn vdw=2), and radius_for passes the stored value
+ # through unchanged, so accept any real number here.
+ for element in ELEMENT_RADII:
+ for model in ('vdw', 'covalent', 'ionic', 'atomic'):
+ radius, substituted = radius_for(element, model)
+ assert isinstance(radius, (int, float))
+ assert radius > 0.0
+ assert isinstance(substituted, bool)
+
+ def test_substituted_flag_matches_database(self):
+ # When the model value is present, substituted must be False;
+ # otherwise (covalent fallback) it must be True, because no
+ # bundled element has a None covalent radius.
+ for element, entry in ELEMENT_RADII.items():
+ for model in ('vdw', 'covalent', 'ionic', 'atomic'):
+ _, substituted = radius_for(element, model)
+ assert substituted is (entry.get(model) is None)
diff --git a/tests/unit/easydiffraction/display/structure/renderers/test_ascii.py b/tests/unit/easydiffraction/display/structure/renderers/test_ascii.py
new file mode 100644
index 000000000..042d5463c
--- /dev/null
+++ b/tests/unit/easydiffraction/display/structure/renderers/test_ascii.py
@@ -0,0 +1,547 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+"""Tests for the ASCII single-cell structure renderer."""
+
+from __future__ import annotations
+
+import re
+
+import numpy as np
+
+from easydiffraction.display.structure.renderers import ascii as MUT
+from easydiffraction.display.structure.renderers.ascii import AsciiStructureRenderer
+from easydiffraction.display.structure.renderers.base import StructureRendererBase
+from easydiffraction.display.structure.scene import AdpEllipsoid
+from easydiffraction.display.structure.scene import AtomSphere
+from easydiffraction.display.structure.scene import OccupancyWedge
+from easydiffraction.display.structure.scene import OccupancyWedgeSphere
+from easydiffraction.display.structure.scene import StructureScene
+
+# ANSI escape sequence matcher used to strip colour tinting from output.
+_ANSI = re.compile(r'\x1b\[[0-9;]*m')
+
+# A cubic 5 Angstrom cell basis (rows are the cell vectors).
+_CUBIC_BASIS = ((5.0, 0.0, 0.0), (0.0, 5.0, 0.0), (0.0, 0.0, 5.0))
+
+ALL_FEATURES = frozenset({'atoms', 'cell', 'axes'})
+
+
+def _strip_ansi(text: str) -> str:
+ """Remove ANSI colour codes so glyphs can be asserted directly."""
+ return _ANSI.sub('', text)
+
+
+def _atom(centre=(0.0, 0.0, 0.0), radius=1.0, colour=(255, 0, 0), label='Fe'):
+ return AtomSphere(centre=centre, radius=radius, colour=colour, label=label)
+
+
+def _scene(atoms=(), occupancy_spheres=(), ellipsoids=(), basis=_CUBIC_BASIS):
+ return StructureScene(
+ cell_basis=basis,
+ atoms=tuple(atoms),
+ occupancy_spheres=tuple(occupancy_spheres),
+ ellipsoids=tuple(ellipsoids),
+ )
+
+
+# ----------------------------------------------------------------------
+# Module + class basics
+# ----------------------------------------------------------------------
+
+
+def test_module_import():
+ expected_module_name = 'easydiffraction.display.structure.renderers.ascii'
+ assert MUT.__name__ == expected_module_name
+
+
+def test_renderer_is_subclass_of_base():
+ assert issubclass(AsciiStructureRenderer, StructureRendererBase)
+
+
+def test_renderer_instantiates():
+ assert AsciiStructureRenderer() is not None
+
+
+def test_module_constants():
+ assert MUT.GLYPH_RAMP == ('·', '•', '●')
+ assert MUT.GRID_WIDTH == 56
+ assert MUT.PAD == 2
+ assert MUT.CHAR_ASPECT == 0.5
+
+
+# ----------------------------------------------------------------------
+# supported_features
+# ----------------------------------------------------------------------
+
+
+def test_supported_features_value():
+ renderer = AsciiStructureRenderer()
+ assert renderer.supported_features() == frozenset({'atoms', 'cell', 'axes'})
+
+
+def test_supported_features_matches_class_attribute():
+ renderer = AsciiStructureRenderer()
+ assert renderer.supported_features() is AsciiStructureRenderer.SUPPORTED
+
+
+def test_supported_features_is_frozenset():
+ assert isinstance(AsciiStructureRenderer().supported_features(), frozenset)
+
+
+# ----------------------------------------------------------------------
+# render — feature gating
+# ----------------------------------------------------------------------
+
+
+def test_render_returns_str():
+ renderer = AsciiStructureRenderer()
+ out = renderer.render(_scene(atoms=[_atom()]), features=ALL_FEATURES)
+ assert isinstance(out, str)
+
+
+def test_render_with_no_features_is_blank():
+ renderer = AsciiStructureRenderer()
+ out = renderer.render(_scene(atoms=[_atom()]), features=frozenset())
+ # No cell, no atoms, no axes: only whitespace rows remain.
+ assert _strip_ansi(out).strip() == ''
+
+
+def test_render_cell_only_draws_border_not_atoms():
+ renderer = AsciiStructureRenderer()
+ out = renderer.render(_scene(atoms=[_atom()]), features=frozenset({'cell'}))
+ plain = _strip_ansi(out)
+ # Cell corners present, no legend (atoms feature is off).
+ for corner in ('╭', '╮', '╰', '╯'):
+ assert corner in plain
+ assert 'Legend:' not in plain
+
+
+def test_render_atoms_only_has_glyph_and_legend_but_no_border():
+ renderer = AsciiStructureRenderer()
+ out = renderer.render(_scene(atoms=[_atom()]), features=frozenset({'atoms'}))
+ plain = _strip_ansi(out)
+ assert 'Legend:' in plain
+ assert any(g in plain for g in MUT.GLYPH_RAMP)
+ assert '╭' not in plain
+
+
+def test_render_axes_only_adds_vertical_label():
+ # With an empty body (no cell, no drawn atoms) only the vertical
+ # axis header survives; the horizontal label needs a non-blank
+ # bottom border row to attach to.
+ renderer = AsciiStructureRenderer()
+ out = renderer.render(_scene(atoms=[]), features=frozenset({'axes'}))
+ plain = _strip_ansi(out)
+ # Cubic cell: longest axis horizontal, second-longest (b) vertical.
+ assert 'b' in plain
+ assert '↑' in plain
+
+
+def test_render_axes_with_atoms_adds_both_labels():
+ renderer = AsciiStructureRenderer()
+ out = renderer.render(_scene(atoms=[_atom()]), features=frozenset({'atoms', 'axes'}))
+ plain = _strip_ansi(out)
+ assert '↑' in plain
+ assert '→' in plain
+
+
+def test_render_all_features_includes_border_atoms_axes_legend():
+ renderer = AsciiStructureRenderer()
+ out = renderer.render(_scene(atoms=[_atom()]), features=ALL_FEATURES)
+ plain = _strip_ansi(out)
+ assert '╭' in plain
+ assert any(g in plain for g in MUT.GLYPH_RAMP)
+ assert '↑' in plain
+ assert '→' in plain
+ assert 'Legend:' in plain
+
+
+def test_render_atoms_feature_without_atoms_skips_legend():
+ renderer = AsciiStructureRenderer()
+ out = renderer.render(_scene(atoms=[]), features=ALL_FEATURES)
+ plain = _strip_ansi(out)
+ # Atoms requested but the scene has none: border + axes only.
+ assert 'Legend:' not in plain
+ assert '╭' in plain
+
+
+def test_render_output_is_ansi_tinted():
+ renderer = AsciiStructureRenderer()
+ out = renderer.render(_scene(atoms=[_atom()]), features=ALL_FEATURES)
+ # Coloured glyphs must carry ANSI escape codes.
+ assert '\x1b[' in out
+
+
+# ----------------------------------------------------------------------
+# render — scene primitive coverage
+# ----------------------------------------------------------------------
+
+
+def test_render_includes_atom_label_in_legend():
+ renderer = AsciiStructureRenderer()
+ out = renderer.render(_scene(atoms=[_atom(label='Fe1')]), features=ALL_FEATURES)
+ plain = _strip_ansi(out)
+ # Digits are stripped from the legend element name.
+ assert 'Fe' in plain
+ assert 'Fe1' not in plain
+
+
+def test_render_occupancy_sphere_uses_first_wedge_colour():
+ sphere = OccupancyWedgeSphere(
+ centre=(2.5, 2.5, 2.5),
+ radius=1.2,
+ wedges=(OccupancyWedge(0.6, (10, 20, 30)), OccupancyWedge(0.4, (40, 50, 60))),
+ label='La/Ba',
+ )
+ renderer = AsciiStructureRenderer()
+ out = renderer.render(_scene(occupancy_spheres=[sphere]), features=ALL_FEATURES)
+ plain = _strip_ansi(out)
+ # The slash is preserved so a shared site reads 'La/Ba'.
+ assert 'La/Ba' in plain
+ assert 'Legend:' in plain
+
+
+def test_render_ellipsoid_uses_mean_semi_axes():
+ ellipsoid = AdpEllipsoid(
+ centre=(1.0, 1.0, 1.0),
+ semi_axes=(0.3, 0.5, 0.7),
+ orientation=((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0)),
+ colour=(0, 200, 0),
+ label='O',
+ )
+ renderer = AsciiStructureRenderer()
+ out = renderer.render(_scene(ellipsoids=[ellipsoid]), features=ALL_FEATURES)
+ plain = _strip_ansi(out)
+ assert 'O' in plain
+ assert any(g in plain for g in MUT.GLYPH_RAMP)
+
+
+def test_render_mixed_primitives_all_appear_in_legend():
+ atoms = [_atom(centre=(0.0, 0.0, 0.0), label='Fe', colour=(255, 0, 0))]
+ spheres = [
+ OccupancyWedgeSphere(
+ centre=(2.5, 2.5, 2.5),
+ radius=1.0,
+ wedges=(OccupancyWedge(1.0, (0, 0, 255)),),
+ label='Na',
+ )
+ ]
+ ellipsoids = [
+ AdpEllipsoid(
+ centre=(4.0, 4.0, 4.0),
+ semi_axes=(0.4, 0.4, 0.4),
+ orientation=((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0)),
+ colour=(0, 255, 0),
+ label='O',
+ )
+ ]
+ renderer = AsciiStructureRenderer()
+ out = renderer.render(
+ _scene(atoms=atoms, occupancy_spheres=spheres, ellipsoids=ellipsoids),
+ features=ALL_FEATURES,
+ )
+ plain = _strip_ansi(out)
+ for element in ('Fe', 'Na', 'O'):
+ assert element in plain
+
+
+def test_render_axes_letters_track_axis_lengths():
+ # Make c the longest axis and a the shortest; b stays second-longest.
+ basis = ((2.0, 0.0, 0.0), (0.0, 5.0, 0.0), (0.0, 0.0, 9.0))
+ renderer = AsciiStructureRenderer()
+ out = renderer.render(
+ _scene(atoms=[_atom()], basis=basis),
+ features=frozenset({'atoms', 'axes'}),
+ )
+ plain = _strip_ansi(out)
+ # Longest (c) horizontal, second-longest (b) vertical.
+ assert '→ c' in plain
+ assert 'b' in plain
+
+
+# ----------------------------------------------------------------------
+# _collect_atoms
+# ----------------------------------------------------------------------
+
+
+def test_collect_atoms_flattens_every_primitive():
+ sphere = OccupancyWedgeSphere(
+ centre=(1.0, 1.0, 1.0),
+ radius=0.9,
+ wedges=(OccupancyWedge(1.0, (1, 2, 3)),),
+ label='Na',
+ )
+ ellipsoid = AdpEllipsoid(
+ centre=(2.0, 2.0, 2.0),
+ semi_axes=(0.2, 0.4, 0.6),
+ orientation=((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0)),
+ colour=(4, 5, 6),
+ label='O',
+ )
+ scene = _scene(atoms=[_atom(label='Fe')], occupancy_spheres=[sphere], ellipsoids=[ellipsoid])
+
+ flattened = MUT._collect_atoms(scene)
+
+ assert len(flattened) == 3
+ labels = [item[3] for item in flattened]
+ assert labels == ['Fe', 'Na', 'O']
+ # Occupancy sphere inherits its first wedge colour.
+ assert flattened[1][2] == (1, 2, 3)
+ # Ellipsoid radius is the mean of its semi-axes.
+ assert flattened[2][1] == np.mean((0.2, 0.4, 0.6))
+
+
+def test_collect_atoms_ellipsoid_zero_mean_falls_back_to_default_radius():
+ ellipsoid = AdpEllipsoid(
+ centre=(0.0, 0.0, 0.0),
+ semi_axes=(0.0, 0.0, 0.0),
+ orientation=((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0)),
+ colour=(7, 8, 9),
+ label='X',
+ )
+ flattened = MUT._collect_atoms(_scene(ellipsoids=[ellipsoid]))
+ assert flattened[0][1] == 0.4
+
+
+def test_collect_atoms_empty_scene_returns_empty_list():
+ assert MUT._collect_atoms(_scene()) == []
+
+
+# ----------------------------------------------------------------------
+# _ansi256 / _tint
+# ----------------------------------------------------------------------
+
+
+def test_ansi256_black_and_white_bounds():
+ assert MUT._ansi256((0, 0, 0)) == 16
+ assert MUT._ansi256((255, 255, 255)) == 231
+
+
+def test_ansi256_pure_red():
+ # 16 + 36*5 = 196 for full-red, no green, no blue.
+ assert MUT._ansi256((255, 0, 0)) == 196
+
+
+def test_tint_wraps_text_in_escape_codes():
+ tinted = MUT._tint((255, 0, 0), 'X')
+ assert tinted.startswith('\x1b[38;5;196m')
+ assert tinted.endswith('\x1b[0m')
+ assert _strip_ansi(tinted) == 'X'
+
+
+# ----------------------------------------------------------------------
+# _make_grid
+# ----------------------------------------------------------------------
+
+
+def test_make_grid_size_and_placement():
+ points = [(0.0, 0.0), (10.0, 4.0)]
+ grid, place = MUT._make_grid(points)
+ height, width = grid['_size']
+ assert width == MUT.GRID_WIDTH
+ assert height >= 8
+ # The extreme corners land within the padded interior.
+ r0, c0 = place((0.0, 0.0))
+ r1, c1 = place((10.0, 4.0))
+ assert c0 == MUT.PAD
+ assert MUT.PAD <= c1 <= width - 1
+ # Higher y maps to a smaller row (top of the grid).
+ assert r1 < r0
+
+
+def test_make_grid_handles_empty_points():
+ grid, place = MUT._make_grid([])
+ height, width = grid['_size']
+ assert width == MUT.GRID_WIDTH
+ assert height >= 8
+ # A degenerate span must not raise and should place at the origin pad.
+ assert place((0.0, 0.0)) == (MUT.PAD, MUT.PAD)
+
+
+def test_make_grid_handles_zero_span():
+ # All points identical: span guards prevent division by zero.
+ _grid, place = MUT._make_grid([(3.0, 3.0), (3.0, 3.0)])
+ assert place((3.0, 3.0)) == (MUT.PAD, MUT.PAD)
+
+
+# ----------------------------------------------------------------------
+# _draw_cell
+# ----------------------------------------------------------------------
+
+
+def test_draw_cell_writes_closed_rectangle_with_corners():
+ corners = [(0.0, 0.0), (10.0, 0.0), (0.0, 4.0), (10.0, 4.0)]
+ grid, place = MUT._make_grid(corners)
+ MUT._draw_cell(grid, place, corners)
+ glyphs = {cell: val[0] for cell, val in grid.items() if cell != '_size'}
+ assert '╭' in glyphs.values()
+ assert '╮' in glyphs.values()
+ assert '╰' in glyphs.values()
+ assert '╯' in glyphs.values()
+ assert '─' in glyphs.values()
+ assert '│' in glyphs.values()
+
+
+def test_draw_cell_corner_overrides_border():
+ corners = [(0.0, 0.0), (10.0, 0.0), (0.0, 4.0), (10.0, 4.0)]
+ grid, place = MUT._make_grid(corners)
+ MUT._draw_cell(grid, place, corners)
+ cells = [place(c) for c in corners]
+ rows = [r for r, _ in cells]
+ cols = [c for _, c in cells]
+ top_left = (min(rows), min(cols))
+ # The shared corner cell is a corner glyph, never a straight edge.
+ assert grid[top_left][0] == '╭'
+
+
+# ----------------------------------------------------------------------
+# _draw_atoms
+# ----------------------------------------------------------------------
+
+
+def test_draw_atoms_places_glyph_by_radius_bucket():
+ # Two atoms: the larger radius gets the densest glyph.
+ atoms = [
+ ((0.0, 0.0, 0.0), 0.2, (255, 0, 0), 'A'),
+ ((10.0, 4.0, 0.0), 1.0, (0, 0, 255), 'B'),
+ ]
+ points = [(0.0, 0.0), (10.0, 4.0)]
+ grid, place = MUT._make_grid(points)
+ MUT._draw_atoms(grid, place, atoms)
+ small = grid[place((0.0, 0.0))]
+ large = grid[place((10.0, 4.0))]
+ assert large[0] == '●' # densest bucket for the largest radius
+ assert MUT.GLYPH_RAMP.index(small[0]) <= MUT.GLYPH_RAMP.index(large[0])
+
+
+def test_draw_atoms_skips_same_colour_duplicate_in_adjacent_cell():
+ # Two same-colour atoms projecting to the same cell: only one drawn.
+ atoms = [
+ ((0.0, 0.0, 0.0), 1.0, (255, 0, 0), 'A'),
+ ((0.0, 0.0, 0.0), 1.0, (255, 0, 0), 'A'),
+ ]
+ grid, place = MUT._make_grid([(0.0, 0.0)])
+ MUT._draw_atoms(grid, place, atoms)
+ drawn = [cell for cell in grid if cell != '_size']
+ assert len(drawn) == 1
+
+
+def test_draw_atoms_keeps_different_colour_in_same_cell():
+ # Different colours are not treated as duplicates.
+ atoms = [
+ ((0.0, 0.0, 0.0), 1.0, (255, 0, 0), 'A'),
+ ((0.0, 0.0, 0.0), 1.0, (0, 0, 255), 'B'),
+ ]
+ grid, place = MUT._make_grid([(0.0, 0.0)])
+ MUT._draw_atoms(grid, place, atoms)
+ drawn = [cell for cell in grid if cell != '_size']
+ # Same cell, different colour: the second overwrites the first, but
+ # it is not skipped as a duplicate (one occupied cell, last colour).
+ assert len(drawn) == 1
+ assert grid[place((0.0, 0.0))][1] == (0, 0, 255)
+
+
+# ----------------------------------------------------------------------
+# _grid_to_lines
+# ----------------------------------------------------------------------
+
+
+def test_grid_to_lines_dimensions_and_blank_fill():
+ grid = {'_size': (3, 6)}
+ grid[0, 0] = ('●', (255, 0, 0))
+ lines = MUT._grid_to_lines(grid)
+ assert len(lines) == 3
+ # Empty rows are right-stripped to the empty string.
+ assert lines[1] == ''
+ assert lines[2] == ''
+ # The tinted glyph survives in the first row.
+ assert '●' in _strip_ansi(lines[0])
+
+
+def test_grid_to_lines_rstrips_trailing_blanks():
+ grid = {'_size': (1, 10)}
+ grid[0, 2] = ('•', (1, 2, 3))
+ lines = MUT._grid_to_lines(grid)
+ # Trailing blank cells past the glyph are removed.
+ assert not lines[0].endswith(' ')
+
+
+# ----------------------------------------------------------------------
+# _annotate_axes
+# ----------------------------------------------------------------------
+
+
+def test_annotate_axes_adds_vertical_header_and_horizontal_label():
+ lines = ['row0', 'row1', 'row2']
+ out = MUT._annotate_axes(lines, left_col=3, bottom_row=2, v_letter='b', h_letter='a')
+ # Header: blank line, letter, arrow; all indented by left_col.
+ assert out[0] == ''
+ assert out[1] == ' b'
+ assert out[2] == ' ↑'
+ # Horizontal label appended to the bottom row.
+ assert out[-1].endswith('→ a')
+
+
+def test_annotate_axes_out_of_range_bottom_row_leaves_body_unchanged():
+ lines = ['only']
+ out = MUT._annotate_axes(lines, left_col=0, bottom_row=99, v_letter='c', h_letter='a')
+ # Header still added, but no horizontal label since row is out of range.
+ assert out[:3] == ['', 'c', '↑']
+ assert out[3] == 'only'
+ assert '→' not in out[3]
+
+
+# ----------------------------------------------------------------------
+# _legend
+# ----------------------------------------------------------------------
+
+
+def test_legend_strips_digits_keeps_slash():
+ atoms = [
+ ((0.0, 0.0, 0.0), 1.0, (255, 0, 0), 'Fe1'),
+ ((0.0, 0.0, 0.0), 1.0, (0, 0, 255), 'La/Ba'),
+ ]
+ legend = MUT._legend(atoms)
+ plain = _strip_ansi(legend)
+ assert plain.startswith('Legend:')
+ assert 'Fe' in plain
+ assert 'Fe1' not in plain
+ assert 'La/Ba' in plain
+
+
+def test_legend_deduplicates_by_element():
+ atoms = [
+ ((0.0, 0.0, 0.0), 1.0, (255, 0, 0), 'Fe1'),
+ ((1.0, 1.0, 1.0), 1.0, (255, 0, 0), 'Fe2'),
+ ]
+ legend = MUT._legend(atoms)
+ plain = _strip_ansi(legend)
+ # Two Fe sites collapse to a single 'Fe' legend entry.
+ assert plain.count('Fe') == 1
+
+
+def test_legend_glyph_scales_with_radius():
+ atoms = [
+ ((0.0, 0.0, 0.0), 0.2, (255, 0, 0), 'H'),
+ ((1.0, 1.0, 1.0), 1.0, (0, 0, 255), 'U'),
+ ]
+ legend = MUT._legend(atoms)
+ plain = _strip_ansi(legend)
+ # Largest radius element uses the densest glyph.
+ assert '● U' in plain
+ assert '· H' in plain or '• H' in plain
+
+
+def test_legend_all_zero_radius_does_not_divide_by_zero():
+ atoms = [((0.0, 0.0, 0.0), 0.0, (1, 2, 3), 'X')]
+ legend = MUT._legend(atoms)
+ plain = _strip_ansi(legend)
+ assert 'X' in plain
+
+
+def test_legend_label_with_no_alpha_falls_back_to_label():
+ # A purely numeric label keeps the raw label (the 'or label' branch).
+ atoms = [((0.0, 0.0, 0.0), 1.0, (1, 2, 3), '123')]
+ legend = MUT._legend(atoms)
+ plain = _strip_ansi(legend)
+ assert '123' in plain
diff --git a/tests/unit/easydiffraction/display/structure/renderers/test_base.py b/tests/unit/easydiffraction/display/structure/renderers/test_base.py
new file mode 100644
index 000000000..239c1d1e2
--- /dev/null
+++ b/tests/unit/easydiffraction/display/structure/renderers/test_base.py
@@ -0,0 +1,199 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+"""Tests for display/structure/renderers/base.py (StructureRendererBase)."""
+
+from __future__ import annotations
+
+from abc import ABC
+
+import pytest
+
+from easydiffraction.display.structure.renderers.base import StructureRendererBase
+from easydiffraction.display.structure.scene import AtomSphere
+from easydiffraction.display.structure.scene import StructureScene
+
+
+# ------------------------------------------------------------------
+# Test doubles
+# ------------------------------------------------------------------
+
+
+class _CompleteRenderer(StructureRendererBase):
+ """A minimal concrete renderer overriding both abstract methods."""
+
+ def render(self, scene: StructureScene, *, features: frozenset[str]) -> str:
+ return f'atoms={len(scene.atoms)} features={sorted(features)}'
+
+ def supported_features(self) -> frozenset[str]:
+ return frozenset({'atoms', 'cell'})
+
+
+class _RenderOnlyRenderer(StructureRendererBase):
+ """Overrides only ``render`` so the class stays abstract."""
+
+ def render(self, scene: StructureScene, *, features: frozenset[str]) -> str:
+ return ''
+
+
+class _FeaturesOnlyRenderer(StructureRendererBase):
+ """Overrides only ``supported_features`` so the class stays abstract."""
+
+ def supported_features(self) -> frozenset[str]:
+ return frozenset()
+
+
+class _DelegatingRenderer(StructureRendererBase):
+ """Calls the base-class bodies via ``super()`` to hit their raises."""
+
+ def render(self, scene: StructureScene, *, features: frozenset[str]) -> str:
+ return super().render(scene, features=features)
+
+ def supported_features(self) -> frozenset[str]:
+ return super().supported_features()
+
+
+# ------------------------------------------------------------------
+# Fixtures
+# ------------------------------------------------------------------
+
+
+def _minimal_scene() -> StructureScene:
+ """Build a tiny renderer-neutral scene without any engine."""
+ basis = (
+ (1.0, 0.0, 0.0),
+ (0.0, 1.0, 0.0),
+ (0.0, 0.0, 1.0),
+ )
+ atom = AtomSphere(centre=(0.0, 0.0, 0.0), radius=0.5, colour=(255, 0, 0), label='Fe')
+ return StructureScene(cell_basis=basis, atoms=(atom,))
+
+
+# ------------------------------------------------------------------
+# Module / class identity
+# ------------------------------------------------------------------
+
+
+def test_module_import():
+ import easydiffraction.display.structure.renderers.base as MUT
+
+ expected_module_name = 'easydiffraction.display.structure.renderers.base'
+ actual_module_name = MUT.__name__
+ assert expected_module_name == actual_module_name
+
+
+def test_is_abstract_base_class():
+ assert issubclass(StructureRendererBase, ABC)
+
+
+def test_abstractmethods_are_exactly_render_and_supported_features():
+ assert StructureRendererBase.__abstractmethods__ == frozenset({'render', 'supported_features'})
+
+
+def test_render_marked_abstract():
+ assert StructureRendererBase.render.__isabstractmethod__ is True
+
+
+def test_supported_features_marked_abstract():
+ assert StructureRendererBase.supported_features.__isabstractmethod__ is True
+
+
+# ------------------------------------------------------------------
+# Instantiation contract
+# ------------------------------------------------------------------
+
+
+def test_cannot_instantiate_base_directly():
+ with pytest.raises(TypeError):
+ StructureRendererBase()
+
+
+def test_cannot_instantiate_with_only_render_overridden():
+ with pytest.raises(TypeError):
+ _RenderOnlyRenderer()
+
+
+def test_cannot_instantiate_with_only_supported_features_overridden():
+ with pytest.raises(TypeError):
+ _FeaturesOnlyRenderer()
+
+
+def test_complete_subclass_instantiates():
+ renderer = _CompleteRenderer()
+ assert isinstance(renderer, StructureRendererBase)
+
+
+# ------------------------------------------------------------------
+# Subclass honours the contract
+# ------------------------------------------------------------------
+
+
+def test_supported_features_returns_frozenset():
+ renderer = _CompleteRenderer()
+ result = renderer.supported_features()
+ assert isinstance(result, frozenset)
+ assert result == frozenset({'atoms', 'cell'})
+
+
+def test_render_returns_string_using_scene_and_features():
+ renderer = _CompleteRenderer()
+ scene = _minimal_scene()
+ output = renderer.render(scene, features=frozenset({'atoms', 'cell'}))
+ assert isinstance(output, str)
+ assert 'atoms=1' in output
+ assert "features=['atoms', 'cell']" in output
+
+
+def test_render_features_is_keyword_only():
+ renderer = _CompleteRenderer()
+ scene = _minimal_scene()
+ with pytest.raises(TypeError):
+ renderer.render(scene, frozenset({'atoms'}))
+
+
+# ------------------------------------------------------------------
+# Base-class method bodies raise NotImplementedError
+# ------------------------------------------------------------------
+
+
+def test_super_render_raises_not_implemented():
+ renderer = _DelegatingRenderer()
+ scene = _minimal_scene()
+ with pytest.raises(NotImplementedError):
+ renderer.render(scene, features=frozenset())
+
+
+def test_super_supported_features_raises_not_implemented():
+ renderer = _DelegatingRenderer()
+ with pytest.raises(NotImplementedError):
+ renderer.supported_features()
+
+
+# ------------------------------------------------------------------
+# Real concrete renderer satisfies the contract (no engine needed)
+# ------------------------------------------------------------------
+
+
+def test_ascii_renderer_is_a_structure_renderer_base():
+ from easydiffraction.display.structure.renderers.ascii import AsciiStructureRenderer
+
+ renderer = AsciiStructureRenderer()
+ assert isinstance(renderer, StructureRendererBase)
+
+
+def test_ascii_renderer_supported_features_is_a_subset_of_the_documented_names():
+ from easydiffraction.display.structure.renderers.ascii import AsciiStructureRenderer
+
+ documented = frozenset({'atoms', 'bonds', 'cell', 'axes', 'moments', 'labels'})
+ features = AsciiStructureRenderer().supported_features()
+ assert isinstance(features, frozenset)
+ assert features <= documented
+
+
+def test_ascii_renderer_render_returns_text():
+ from easydiffraction.display.structure.renderers.ascii import AsciiStructureRenderer
+
+ renderer = AsciiStructureRenderer()
+ scene = _minimal_scene()
+ output = renderer.render(scene, features=renderer.supported_features())
+ assert isinstance(output, str)
+ assert output != ''
diff --git a/tests/unit/easydiffraction/display/structure/renderers/test_raster.py b/tests/unit/easydiffraction/display/structure/renderers/test_raster.py
new file mode 100644
index 000000000..bfa426634
--- /dev/null
+++ b/tests/unit/easydiffraction/display/structure/renderers/test_raster.py
@@ -0,0 +1,441 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+"""Tests for the z-buffered raster structure renderer."""
+
+from __future__ import annotations
+
+import io
+
+import numpy as np
+import pytest
+from PIL import Image
+
+from easydiffraction.display.structure.renderers import raster as MUT
+from easydiffraction.display.structure.renderers.raster import RasterStructureRenderer
+from easydiffraction.display.structure.scene import AdpEllipsoid
+from easydiffraction.display.structure.scene import AtomSphere
+from easydiffraction.display.structure.scene import AxisArrow
+from easydiffraction.display.structure.scene import AxisTriad
+from easydiffraction.display.structure.scene import Bond
+from easydiffraction.display.structure.scene import CellEdge
+from easydiffraction.display.structure.scene import CellEdges
+from easydiffraction.display.structure.scene import LegendEntry
+from easydiffraction.display.structure.scene import OccupancyWedge
+from easydiffraction.display.structure.scene import OccupancyWedgeSphere
+from easydiffraction.display.structure.scene import StructureScene
+
+# The 8-byte PNG file signature.
+PNG_MAGIC = b'\x89PNG\r\n\x1a\n'
+
+# A simple orthogonal 5x5x5 cell shared by most scenes.
+CUBIC_BASIS = ((5.0, 0.0, 0.0), (0.0, 5.0, 0.0), (0.0, 0.0, 5.0))
+
+
+def _open(png: bytes) -> Image.Image:
+ """Decode rendered PNG bytes into a Pillow image."""
+ return Image.open(io.BytesIO(png))
+
+
+def _pixels(png: bytes) -> np.ndarray:
+ """Return the rendered image as an (H, W, 3) uint8 array."""
+ return np.asarray(_open(png))
+
+
+def _has_drawn_pixels(png: bytes) -> bool:
+ """True when any pixel departs from the white background."""
+ return bool((_pixels(png) != 255).any())
+
+
+def _atom_scene() -> StructureScene:
+ """A single red atom centred in the cubic cell."""
+ return StructureScene(
+ cell_basis=CUBIC_BASIS,
+ atoms=(AtomSphere(centre=(0.0, 0.0, 0.0), radius=1.0, colour=(255, 0, 0), label='Fe'),),
+ )
+
+
+def _axis_triad() -> AxisTriad:
+ """An a/b/c axis triad along the cubic cell edges."""
+ return AxisTriad(
+ origin=(0.0, 0.0, 0.0),
+ axes=(
+ AxisArrow(vector=(5.0, 0.0, 0.0), colour=(255, 0, 0), letter='a'),
+ AxisArrow(vector=(0.0, 5.0, 0.0), colour=(0, 255, 0), letter='b'),
+ AxisArrow(vector=(0.0, 0.0, 5.0), colour=(0, 0, 255), letter='c'),
+ ),
+ )
+
+
+def _full_scene() -> StructureScene:
+ """A scene exercising every supported primitive plus a legend."""
+ return StructureScene(
+ cell_basis=CUBIC_BASIS,
+ atoms=(
+ AtomSphere(centre=(0.0, 0.0, 0.0), radius=0.8, colour=(255, 0, 0), label='Fe'),
+ AtomSphere(centre=(5.0, 5.0, 5.0), radius=0.8, colour=(0, 0, 255), label='O'),
+ ),
+ occupancy_spheres=(
+ OccupancyWedgeSphere(
+ centre=(2.5, 2.5, 2.5),
+ radius=0.7,
+ wedges=(
+ OccupancyWedge(fraction=0.5, colour=(255, 0, 0)),
+ OccupancyWedge(fraction=0.5, colour=(0, 255, 0)),
+ ),
+ label='Mix',
+ ),
+ ),
+ ellipsoids=(
+ AdpEllipsoid(
+ centre=(1.0, 1.0, 1.0),
+ semi_axes=(0.5, 0.4, 0.3),
+ orientation=((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0)),
+ colour=(0, 200, 0),
+ label='Ell',
+ ),
+ ),
+ bonds=(
+ Bond(
+ start=(0.0, 0.0, 0.0),
+ end=(5.0, 5.0, 5.0),
+ start_colour=(255, 0, 0),
+ end_colour=(0, 0, 255),
+ ),
+ ),
+ cell_edges=CellEdges(
+ edges=(
+ CellEdge(start=(0.0, 0.0, 0.0), end=(5.0, 0.0, 0.0)),
+ CellEdge(start=(0.0, 0.0, 0.0), end=(0.0, 5.0, 0.0)),
+ CellEdge(start=(0.0, 0.0, 0.0), end=(0.0, 0.0, 5.0)),
+ )
+ ),
+ axes=_axis_triad(),
+ legend=(
+ LegendEntry(symbol='Fe', colour=(255, 0, 0)),
+ LegendEntry(symbol='O', colour=(0, 0, 255)),
+ ),
+ )
+
+
+def test_module_import():
+ import easydiffraction.display.structure.renderers.raster as MUT
+
+ expected_module_name = 'easydiffraction.display.structure.renderers.raster'
+ actual_module_name = MUT.__name__
+ assert expected_module_name == actual_module_name
+
+
+class TestSupported:
+ def test_supported_is_frozenset(self):
+ assert isinstance(RasterStructureRenderer.SUPPORTED, frozenset)
+
+ def test_supported_members(self):
+ assert frozenset({'atoms', 'bonds', 'cell', 'axes'}) == RasterStructureRenderer.SUPPORTED
+
+ def test_supported_shared_across_instances(self):
+ # SUPPORTED is a class-level constant, not rebuilt per instance.
+ assert RasterStructureRenderer().SUPPORTED is RasterStructureRenderer.SUPPORTED
+
+
+class TestConstruction:
+ def test_instantiation(self):
+ renderer = RasterStructureRenderer()
+ assert renderer is not None
+
+ def test_render_png_is_callable(self):
+ assert callable(RasterStructureRenderer().render_png)
+
+
+class TestRenderPngOutput:
+ def test_returns_bytes(self):
+ png = RasterStructureRenderer().render_png(_atom_scene(), features=frozenset({'atoms'}))
+ assert isinstance(png, bytes)
+
+ def test_has_png_signature(self):
+ png = RasterStructureRenderer().render_png(_atom_scene(), features=frozenset({'atoms'}))
+ assert png[:8] == PNG_MAGIC
+
+ def test_decodes_as_png(self):
+ png = RasterStructureRenderer().render_png(_atom_scene(), features=frozenset({'atoms'}))
+ assert _open(png).format == 'PNG'
+
+ def test_canvas_dimensions(self):
+ # The supersampled buffer is downsampled to a fixed 1800x1800 frame.
+ png = RasterStructureRenderer().render_png(_atom_scene(), features=frozenset({'atoms'}))
+ assert _open(png).size == (1800, 1800)
+
+ def test_rgb_mode(self):
+ png = RasterStructureRenderer().render_png(_atom_scene(), features=frozenset({'atoms'}))
+ assert _open(png).mode == 'RGB'
+
+ def test_deterministic_for_same_scene(self):
+ scene = _atom_scene()
+ first = RasterStructureRenderer().render_png(scene, features=frozenset({'atoms'}))
+ second = RasterStructureRenderer().render_png(scene, features=frozenset({'atoms'}))
+ assert first == second
+
+
+class TestRenderPngFeatures:
+ def test_atom_is_drawn(self):
+ png = RasterStructureRenderer().render_png(_atom_scene(), features=frozenset({'atoms'}))
+ assert _has_drawn_pixels(png)
+
+ def test_empty_scene_is_blank(self):
+ # No primitives and no features -> a pristine white canvas.
+ scene = StructureScene(cell_basis=CUBIC_BASIS)
+ png = RasterStructureRenderer().render_png(scene, features=frozenset())
+ assert bool((_pixels(png) == 255).all())
+
+ def test_feature_gating_skips_unrequested_atoms(self):
+ # The scene has an atom, but 'atoms' is absent from the feature
+ # set, so nothing is drawn.
+ scene = _atom_scene()
+ png = RasterStructureRenderer().render_png(scene, features=frozenset({'cell'}))
+ assert bool((_pixels(png) == 255).all())
+
+ def test_unknown_feature_names_are_ignored(self):
+ # Feature names outside SUPPORTED never trigger a draw and never
+ # raise; only 'atoms' here does any work.
+ scene = _atom_scene()
+ png = RasterStructureRenderer().render_png(
+ scene, features=frozenset({'atoms', 'bogus', 'labels', 'moments'})
+ )
+ assert _has_drawn_pixels(png)
+
+ def test_cell_only_is_drawn(self):
+ scene = StructureScene(
+ cell_basis=CUBIC_BASIS,
+ cell_edges=CellEdges(edges=(CellEdge(start=(0.0, 0.0, 0.0), end=(5.0, 0.0, 0.0)),)),
+ )
+ png = RasterStructureRenderer().render_png(scene, features=frozenset({'cell'}))
+ assert _has_drawn_pixels(png)
+
+ def test_bonds_only_is_drawn(self):
+ scene = StructureScene(
+ cell_basis=CUBIC_BASIS,
+ bonds=(
+ Bond(
+ start=(0.0, 0.0, 0.0),
+ end=(5.0, 5.0, 5.0),
+ start_colour=(255, 0, 0),
+ end_colour=(0, 0, 255),
+ ),
+ ),
+ )
+ png = RasterStructureRenderer().render_png(scene, features=frozenset({'bonds'}))
+ assert _has_drawn_pixels(png)
+
+ def test_axes_only_is_drawn(self):
+ scene = StructureScene(
+ cell_basis=CUBIC_BASIS,
+ atoms=(AtomSphere(centre=(0.0, 0.0, 0.0), radius=1.0, colour=(0, 0, 0), label='X'),),
+ axes=_axis_triad(),
+ )
+ png = RasterStructureRenderer().render_png(scene, features=frozenset({'axes'}))
+ assert _has_drawn_pixels(png)
+
+ def test_axes_feature_without_triad_is_blank(self):
+ # 'axes' requested but scene.axes is None -> no crash, blank.
+ scene = StructureScene(cell_basis=CUBIC_BASIS)
+ png = RasterStructureRenderer().render_png(scene, features=frozenset({'axes'}))
+ assert bool((_pixels(png) == 255).all())
+
+ def test_cell_feature_without_edges_is_blank(self):
+ # 'cell' requested but scene.cell_edges is None -> no crash.
+ scene = StructureScene(cell_basis=CUBIC_BASIS)
+ png = RasterStructureRenderer().render_png(scene, features=frozenset({'cell'}))
+ assert bool((_pixels(png) == 255).all())
+
+ def test_full_scene_renders(self):
+ png = RasterStructureRenderer().render_png(
+ _full_scene(), features=RasterStructureRenderer.SUPPORTED
+ )
+ assert png[:8] == PNG_MAGIC
+ assert _open(png).size == (1800, 1800)
+ assert _has_drawn_pixels(png)
+
+
+class TestRenderPngPrimitives:
+ def test_occupancy_wedge_sphere_renders(self):
+ scene = StructureScene(
+ cell_basis=CUBIC_BASIS,
+ occupancy_spheres=(
+ OccupancyWedgeSphere(
+ centre=(2.5, 2.5, 2.5),
+ radius=1.0,
+ wedges=(
+ OccupancyWedge(fraction=0.6, colour=(255, 0, 0)),
+ OccupancyWedge(fraction=0.4, colour=(0, 0, 255)),
+ ),
+ label='Mix',
+ ),
+ ),
+ )
+ png = RasterStructureRenderer().render_png(scene, features=frozenset({'atoms'}))
+ assert _has_drawn_pixels(png)
+
+ def test_ellipsoid_renders(self):
+ scene = StructureScene(
+ cell_basis=CUBIC_BASIS,
+ ellipsoids=(
+ AdpEllipsoid(
+ centre=(2.5, 2.5, 2.5),
+ semi_axes=(1.0, 0.6, 0.4),
+ orientation=((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0)),
+ colour=(0, 200, 0),
+ label='Ell',
+ ),
+ ),
+ )
+ png = RasterStructureRenderer().render_png(scene, features=frozenset({'atoms'}))
+ assert _has_drawn_pixels(png)
+
+ def test_ellipsoid_with_wedges_renders(self):
+ scene = StructureScene(
+ cell_basis=CUBIC_BASIS,
+ ellipsoids=(
+ AdpEllipsoid(
+ centre=(2.5, 2.5, 2.5),
+ semi_axes=(1.0, 1.0, 1.0),
+ orientation=((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0)),
+ colour=(0, 200, 0),
+ label='Ell',
+ wedges=(
+ OccupancyWedge(fraction=0.5, colour=(255, 0, 0)),
+ OccupancyWedge(fraction=0.5, colour=(0, 0, 255)),
+ ),
+ ),
+ ),
+ )
+ png = RasterStructureRenderer().render_png(scene, features=frozenset({'atoms'}))
+ assert _has_drawn_pixels(png)
+
+ def test_tiny_atom_is_skipped_without_error(self):
+ # A sub-pixel radius atom is dropped by the minimum-size guard;
+ # the larger atom still renders and the call succeeds.
+ scene = StructureScene(
+ cell_basis=CUBIC_BASIS,
+ atoms=(
+ AtomSphere(centre=(0.0, 0.0, 0.0), radius=1.0, colour=(255, 0, 0), label='Fe'),
+ AtomSphere(centre=(5.0, 5.0, 5.0), radius=1e-6, colour=(0, 0, 255), label='O'),
+ ),
+ )
+ png = RasterStructureRenderer().render_png(scene, features=frozenset({'atoms'}))
+ assert png[:8] == PNG_MAGIC
+ assert _has_drawn_pixels(png)
+
+
+class TestLegend:
+ def test_legend_is_drawn_independent_of_features(self):
+ # The legend panel is composited regardless of the feature set
+ # (it is not gated by 'atoms'/'bonds'/...).
+ scene = StructureScene(
+ cell_basis=CUBIC_BASIS,
+ legend=(LegendEntry(symbol='Fe', colour=(255, 0, 0)),),
+ )
+ png = RasterStructureRenderer().render_png(scene, features=frozenset())
+ assert _has_drawn_pixels(png)
+
+ def test_no_legend_leaves_top_left_white(self):
+ # Without a legend, the empty scene stays fully white.
+ scene = StructureScene(cell_basis=CUBIC_BASIS)
+ png = RasterStructureRenderer().render_png(scene, features=frozenset())
+ assert bool((_pixels(png) == 255).all())
+
+
+class TestViewBasis:
+ def test_projection_shifts_content_down_in_report_frame(self):
+ view_dir = np.array([0.0, 0.0, 1.0])
+ right = np.array([1.0, 0.0, 0.0])
+ up = np.array([0.0, 1.0, 0.0])
+
+ _canvas, project, _extent, _pad = RasterStructureRenderer._make_canvas(
+ _atom_scene(), view_dir, right, up
+ )
+
+ _x, y, _depth = project((0.0, 0.0, 0.0))
+ size = MUT._CANVAS * MUT._SUPERSAMPLE
+ assert y == pytest.approx(size * (0.5 + MUT._VERTICAL_SHIFT_FRAC))
+
+ def test_axis_labels_follow_rendered_arrow_tips(self):
+ class TextRecorder:
+ def __init__(self) -> None:
+ self.calls = []
+
+ def text(self, xy, text, font, fill, anchor) -> None:
+ del font, fill, anchor
+ self.calls.append((xy, text))
+
+ def project(point: tuple[float, float, float]) -> tuple[float, float, float]:
+ return (
+ (200.0 + point[0] * 100.0) * MUT._SUPERSAMPLE,
+ (200.0 - point[1] * 100.0) * MUT._SUPERSAMPLE,
+ point[2],
+ )
+
+ draw = TextRecorder()
+ axes = AxisTriad(
+ origin=(0.0, 0.0, 0.0),
+ axes=(
+ AxisArrow(vector=(6.5, 0.0, 0.0), colour=(255, 0, 0), letter='a'),
+ AxisArrow(vector=(0.0, 6.5, 0.0), colour=(0, 255, 0), letter='b'),
+ AxisArrow(vector=(0.0, 0.0, 6.5), colour=(0, 0, 255), letter='c'),
+ ),
+ )
+ extent = 10.0
+
+ RasterStructureRenderer._draw_axis_labels(draw, axes, project, extent, 0.0)
+
+ max_axis = 6.5 / 1.3
+ overhang = max(
+ MUT._AXIS_OVERHANG_FRAC * extent,
+ MUT._AXIS_HEAD_LENGTH_FRAC * extent + MUT._AXIS_GAP_FRAC * extent,
+ )
+ label_x = 200.0 + (6.5 - 0.3 * max_axis + overhang) * 100.0
+ label_x += MUT._AXIS_LABEL_GAP_FRAC * extent * 100.0
+ a_xy = next(xy for xy, text in draw.calls if text == 'a')
+ assert a_xy[0] == pytest.approx(label_x)
+
+ def test_anisotropic_cell_renders(self):
+ # An 8x5x3 cell drives the longest/middle/shortest axis ordering
+ # in the default-view basis selection.
+ scene = StructureScene(
+ cell_basis=((8.0, 0.0, 0.0), (0.0, 5.0, 0.0), (0.0, 0.0, 3.0)),
+ atoms=(
+ AtomSphere(centre=(4.0, 2.5, 1.5), radius=1.0, colour=(10, 20, 30), label='X'),
+ ),
+ axes=AxisTriad(
+ origin=(0.0, 0.0, 0.0),
+ axes=(
+ AxisArrow(vector=(8.0, 0.0, 0.0), colour=(255, 0, 0), letter='a'),
+ AxisArrow(vector=(0.0, 5.0, 0.0), colour=(0, 255, 0), letter='b'),
+ AxisArrow(vector=(0.0, 0.0, 3.0), colour=(0, 0, 255), letter='c'),
+ ),
+ ),
+ )
+ png = RasterStructureRenderer().render_png(
+ scene, features=RasterStructureRenderer.SUPPORTED
+ )
+ assert png[:8] == PNG_MAGIC
+ assert _has_drawn_pixels(png)
+
+ def test_scene_without_axes_uses_default_basis(self):
+ # No axis triad -> the fixed fallback camera basis is used; the
+ # atom still renders.
+ png = RasterStructureRenderer().render_png(_atom_scene(), features=frozenset({'atoms'}))
+ assert _has_drawn_pixels(png)
+
+
+class TestRenderPngSignature:
+ def test_features_is_keyword_only(self):
+ # render_png(scene, *, features=...) — passing features
+ # positionally is a TypeError.
+ with pytest.raises(TypeError):
+ RasterStructureRenderer().render_png(
+ StructureScene(cell_basis=CUBIC_BASIS), frozenset()
+ )
+
+ def test_features_is_required(self):
+ # Omitting the keyword-only 'features' argument is a TypeError.
+ with pytest.raises(TypeError):
+ RasterStructureRenderer().render_png(StructureScene(cell_basis=CUBIC_BASIS))
diff --git a/tests/unit/easydiffraction/display/structure/renderers/test_threejs.py b/tests/unit/easydiffraction/display/structure/renderers/test_threejs.py
new file mode 100644
index 000000000..f951b48c0
--- /dev/null
+++ b/tests/unit/easydiffraction/display/structure/renderers/test_threejs.py
@@ -0,0 +1,782 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+"""Tests for the Three.js structure renderer."""
+
+from __future__ import annotations
+
+import json
+
+import pytest
+
+from easydiffraction.display.structure.enums import ColorSchemeEnum
+from easydiffraction.display.structure.renderers import threejs as MUT
+from easydiffraction.display.structure.renderers.base import StructureRendererBase
+from easydiffraction.display.structure.renderers.threejs import ThreeJsStructureRenderer
+from easydiffraction.display.structure.scene import AdpEllipsoid
+from easydiffraction.display.structure.scene import AtomSphere
+from easydiffraction.display.structure.scene import AxisArrow
+from easydiffraction.display.structure.scene import AxisTriad
+from easydiffraction.display.structure.scene import Bond
+from easydiffraction.display.structure.scene import CellEdge
+from easydiffraction.display.structure.scene import CellEdges
+from easydiffraction.display.structure.scene import LegendEntry
+from easydiffraction.display.structure.scene import OccupancyWedge
+from easydiffraction.display.structure.scene import OccupancyWedgeSphere
+from easydiffraction.display.structure.scene import StructureScene
+from easydiffraction.display.structure.scene import TextLabel
+
+# A representative theme palette returned by the patched ``theme_colors``.
+_PATCHED_THEME = {'background': (10, 20, 30), 'foreground': (240, 240, 240)}
+
+# Identity cell basis shared by the lightweight scenes below.
+_IDENTITY_BASIS = ((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0))
+
+
+def _identity_scene() -> StructureScene:
+ """Return the minimal valid scene (only a cell basis)."""
+ return StructureScene(cell_basis=_IDENTITY_BASIS)
+
+
+def _rich_scene() -> StructureScene:
+ """Return a scene exercising every primitive the payload reads."""
+ atom = AtomSphere(
+ centre=(0.0, 0.0, 0.0),
+ radius=0.5,
+ colour=(255, 0, 0),
+ label='Fe',
+ asymmetric=True,
+ )
+ wedge_sphere = OccupancyWedgeSphere(
+ centre=(0.5, 0.5, 0.5),
+ radius=0.4,
+ wedges=(
+ OccupancyWedge(fraction=0.6, colour=(0, 0, 255)),
+ OccupancyWedge(fraction=0.4, colour=(210, 210, 210)),
+ ),
+ label='La/Ba',
+ )
+ ellipsoid = AdpEllipsoid(
+ centre=(0.25, 0.25, 0.25),
+ semi_axes=(0.3, 0.2, 0.1),
+ orientation=((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0)),
+ colour=(0, 255, 0),
+ label='O',
+ wedges=(OccupancyWedge(fraction=1.0, colour=(0, 255, 0)),),
+ asymmetric=True,
+ )
+ bond = Bond(
+ start=(0.0, 0.0, 0.0),
+ end=(0.5, 0.5, 0.5),
+ start_colour=(255, 0, 0),
+ end_colour=(0, 0, 255),
+ start_element='Fe',
+ end_element='O',
+ )
+ edges = CellEdges(
+ edges=(
+ CellEdge(start=(0.0, 0.0, 0.0), end=(1.0, 0.0, 0.0)),
+ CellEdge(start=(0.0, 0.0, 0.0), end=(0.0, 1.0, 0.0)),
+ ),
+ )
+ axes = AxisTriad(
+ origin=(0.0, 0.0, 0.0),
+ axes=(
+ AxisArrow(vector=(1.0, 0.0, 0.0), colour=(220, 40, 40), letter='a'),
+ AxisArrow(vector=(0.0, 1.0, 0.0), colour=(40, 180, 40), letter='b'),
+ AxisArrow(vector=(0.0, 0.0, 1.0), colour=(40, 80, 220), letter='c'),
+ ),
+ )
+ return StructureScene(
+ cell_basis=_IDENTITY_BASIS,
+ atoms=(atom,),
+ occupancy_spheres=(wedge_sphere,),
+ ellipsoids=(ellipsoid,),
+ bonds=(bond,),
+ cell_edges=edges,
+ axes=axes,
+ labels=(TextLabel(anchor=(0.1, 0.2, 0.3), text='Fe1'),),
+ legend=(
+ LegendEntry(symbol='Fe', colour=(255, 0, 0)),
+ LegendEntry(symbol='O', colour=(0, 255, 0)),
+ ),
+ )
+
+
+@pytest.fixture
+def patched_theme(monkeypatch):
+ """Patch the module-level ``theme_colors`` to a fixed test palette.
+
+ Isolates the renderer's own templating logic from the concrete
+ ``LIGHT_THEME``/``DARK_THEME`` values so the happy-path assertions
+ below depend only on the renderer. The un-patched integration with
+ the real ``theme_colors`` is covered by
+ :class:`TestRenderUnpatchedIntegration`.
+ """
+ monkeypatch.setattr(MUT, 'theme_colors', lambda *, dark: dict(_PATCHED_THEME))
+
+
+def test_module_import():
+ import easydiffraction.display.structure.renderers.threejs as imported
+
+ expected_module_name = 'easydiffraction.display.structure.renderers.threejs'
+ assert imported.__name__ == expected_module_name
+
+
+# ------------------------------------------------------------------
+# Module-level constants
+# ------------------------------------------------------------------
+
+
+class TestModuleConstants:
+ def test_cdn_pins_three_version(self):
+ assert MUT._CDN == 'https://cdn.jsdelivr.net/npm/three@0.160.0'
+
+ def test_addon_specifiers(self):
+ assert MUT._ADDON_CONTROLS == 'three/addons/controls/OrbitControls.js'
+ assert MUT._ADDON_CSS2D == 'three/addons/renderers/CSS2DRenderer.js'
+
+ def test_vendor_dir_points_at_threejs_assets(self):
+ assert MUT._VENDOR.name == 'threejs'
+ assert MUT._VENDOR.parent.name == 'vendor'
+
+ def test_vendor_dir_holds_pinned_assets(self):
+ # The offline import map inlines these three vendored files.
+ names = {p.name for p in MUT._VENDOR.iterdir()}
+ assert {'three.module.js', 'OrbitControls.js', 'CSS2DRenderer.js'} <= names
+
+
+# ------------------------------------------------------------------
+# _environment
+# ------------------------------------------------------------------
+
+
+class TestEnvironment:
+ def test_returns_jinja_environment(self):
+ from jinja2 import Environment
+
+ assert isinstance(MUT._environment(), Environment)
+
+ def test_autoescape_disabled_for_html_payload(self):
+ # The payload is pre-escaped JSON; Jinja must not re-escape it.
+ env = MUT._environment()
+ assert env.trim_blocks is True
+ assert env.lstrip_blocks is True
+
+ def test_can_load_structure_template(self):
+ env = MUT._environment()
+ template = env.get_template(ThreeJsStructureRenderer.TEMPLATE_NAME)
+ assert template is not None
+
+
+# ------------------------------------------------------------------
+# _data_url
+# ------------------------------------------------------------------
+
+
+class TestDataUrl:
+ def test_encodes_file_as_base64_javascript_data_url(self, tmp_path):
+ import base64
+
+ source = tmp_path / 'snippet.js'
+ payload = b'console.log("hi");'
+ source.write_bytes(payload)
+
+ url = MUT._data_url(source)
+
+ prefix = 'data:text/javascript;base64,'
+ assert url.startswith(prefix)
+ decoded = base64.b64decode(url[len(prefix) :])
+ assert decoded == payload
+
+ def test_roundtrips_arbitrary_bytes(self, tmp_path):
+ import base64
+
+ source = tmp_path / 'bytes.js'
+ payload = bytes(range(256))
+ source.write_bytes(payload)
+
+ url = MUT._data_url(source)
+ decoded = base64.b64decode(url.split('base64,', 1)[1])
+ assert decoded == payload
+
+
+# ------------------------------------------------------------------
+# _import_map
+# ------------------------------------------------------------------
+
+
+class TestImportMap:
+ def test_online_uses_cdn_urls(self):
+ mapping = MUT._import_map(offline=False)
+
+ assert mapping['three'] == f'{MUT._CDN}/build/three.module.js'
+ assert mapping[MUT._ADDON_CONTROLS].startswith(MUT._CDN)
+ assert mapping[MUT._ADDON_CSS2D].startswith(MUT._CDN)
+ assert mapping[MUT._ADDON_CONTROLS].endswith('OrbitControls.js')
+ assert mapping[MUT._ADDON_CSS2D].endswith('CSS2DRenderer.js')
+
+ def test_offline_inlines_data_urls(self):
+ mapping = MUT._import_map(offline=True)
+
+ prefix = 'data:text/javascript;base64,'
+ assert mapping['three'].startswith(prefix)
+ assert mapping[MUT._ADDON_CONTROLS].startswith(prefix)
+ assert mapping[MUT._ADDON_CSS2D].startswith(prefix)
+
+ def test_both_modes_share_the_same_keys(self):
+ online = MUT._import_map(offline=False)
+ offline = MUT._import_map(offline=True)
+
+ expected_keys = {'three', MUT._ADDON_CONTROLS, MUT._ADDON_CSS2D}
+ assert set(online) == expected_keys
+ assert set(offline) == expected_keys
+
+
+# ------------------------------------------------------------------
+# _rgb_css
+# ------------------------------------------------------------------
+
+
+class TestRgbCss:
+ def test_formats_triple_as_css_rgb(self):
+ assert MUT._rgb_css((1, 2, 3)) == 'rgb(1, 2, 3)'
+
+ def test_handles_channel_bounds(self):
+ assert MUT._rgb_css((0, 0, 0)) == 'rgb(0, 0, 0)'
+ assert MUT._rgb_css((255, 255, 255)) == 'rgb(255, 255, 255)'
+
+ def test_formats_triple_and_alpha_as_css_rgba(self):
+ assert MUT._rgba_css((1, 2, 3), 0.5) == 'rgba(1, 2, 3, 0.5)'
+
+
+# ------------------------------------------------------------------
+# _scene_payload
+# ------------------------------------------------------------------
+
+
+class TestScenePayloadKeys:
+ def test_payload_exposes_documented_keys(self):
+ payload = MUT._scene_payload(_rich_scene())
+
+ expected_keys = {
+ 'atoms',
+ 'wedgeSpheres',
+ 'ellipsoids',
+ 'bonds',
+ 'cellEdges',
+ 'axes',
+ 'labels',
+ 'legend',
+ 'palettes',
+ }
+ assert set(payload) == expected_keys
+
+ def test_payload_is_json_serialisable(self):
+ # The renderer embeds this dict via json.dumps; it must round-trip.
+ payload = MUT._scene_payload(_rich_scene())
+ assert json.loads(json.dumps(payload)) is not None
+
+
+class TestScenePayloadPrimitives:
+ def test_atoms_carry_geometry_colour_and_flags(self):
+ payload = MUT._scene_payload(_rich_scene())
+
+ atom = payload['atoms'][0]
+ assert atom['centre'] == (0.0, 0.0, 0.0)
+ assert atom['radius'] == 0.5
+ assert atom['colour'] == (255, 0, 0)
+ assert atom['label'] == 'Fe'
+ assert atom['asymmetric'] is True
+
+ def test_wedge_spheres_expand_fraction_colour_wedges(self):
+ payload = MUT._scene_payload(_rich_scene())
+
+ sphere = payload['wedgeSpheres'][0]
+ assert sphere['centre'] == (0.5, 0.5, 0.5)
+ assert sphere['radius'] == 0.4
+ assert sphere['label'] == 'La/Ba'
+ assert sphere['asymmetric'] is False
+ assert sphere['wedges'] == [
+ {'fraction': 0.6, 'colour': (0, 0, 255)},
+ {'fraction': 0.4, 'colour': (210, 210, 210)},
+ ]
+
+ def test_ellipsoids_use_camelcase_semi_axes_and_row_lists(self):
+ payload = MUT._scene_payload(_rich_scene())
+
+ ellipsoid = payload['ellipsoids'][0]
+ assert ellipsoid['centre'] == (0.25, 0.25, 0.25)
+ assert ellipsoid['semiAxes'] == (0.3, 0.2, 0.1)
+ # Orientation rows are converted to plain lists for JSON.
+ assert ellipsoid['orientation'] == [
+ [1.0, 0.0, 0.0],
+ [0.0, 1.0, 0.0],
+ [0.0, 0.0, 1.0],
+ ]
+ assert ellipsoid['colour'] == (0, 255, 0)
+ assert ellipsoid['label'] == 'O'
+ assert ellipsoid['asymmetric'] is True
+ assert ellipsoid['wedges'] == [{'fraction': 1.0, 'colour': (0, 255, 0)}]
+
+ def test_bonds_split_colour_and_element_at_both_ends(self):
+ payload = MUT._scene_payload(_rich_scene())
+
+ bond = payload['bonds'][0]
+ assert bond['start'] == (0.0, 0.0, 0.0)
+ assert bond['end'] == (0.5, 0.5, 0.5)
+ assert bond['startColour'] == (255, 0, 0)
+ assert bond['endColour'] == (0, 0, 255)
+ assert bond['startElement'] == 'Fe'
+ assert bond['endElement'] == 'O'
+
+ def test_cell_edges_expand_to_start_end_segments(self):
+ payload = MUT._scene_payload(_rich_scene())
+
+ assert payload['cellEdges'] == [
+ {'start': (0.0, 0.0, 0.0), 'end': (1.0, 0.0, 0.0)},
+ {'start': (0.0, 0.0, 0.0), 'end': (0.0, 1.0, 0.0)},
+ ]
+
+ def test_axes_expose_origin_and_lettered_arrows(self):
+ payload = MUT._scene_payload(_rich_scene())
+
+ axes = payload['axes']
+ assert axes['origin'] == (0.0, 0.0, 0.0)
+ letters = [arrow['letter'] for arrow in axes['arrows']]
+ assert letters == ['a', 'b', 'c']
+ assert axes['arrows'][0]['vector'] == (1.0, 0.0, 0.0)
+ assert axes['arrows'][0]['colour'] == (220, 40, 40)
+
+ def test_labels_expose_anchor_and_text(self):
+ payload = MUT._scene_payload(_rich_scene())
+
+ assert payload['labels'] == [{'anchor': (0.1, 0.2, 0.3), 'text': 'Fe1'}]
+
+ def test_labels_fallback_to_atom_primitives(self):
+ scene = StructureScene(
+ cell_basis=_IDENTITY_BASIS,
+ atoms=(
+ AtomSphere(
+ centre=(0.0, 0.0, 0.0),
+ radius=0.5,
+ colour=(255, 0, 0),
+ label='Fe',
+ ),
+ ),
+ occupancy_spheres=(
+ OccupancyWedgeSphere(
+ centre=(0.5, 0.5, 0.5),
+ radius=0.4,
+ wedges=(OccupancyWedge(fraction=1.0, colour=(0, 0, 255)),),
+ label='La/Ba',
+ ),
+ ),
+ ellipsoids=(
+ AdpEllipsoid(
+ centre=(0.25, 0.25, 0.25),
+ semi_axes=(0.3, 0.2, 0.1),
+ orientation=((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0)),
+ colour=(0, 255, 0),
+ label='O',
+ ),
+ ),
+ )
+
+ payload = MUT._scene_payload(scene)
+
+ assert payload['labels'] == [
+ {'anchor': (0.0, 0.0, 0.0), 'text': 'Fe'},
+ {'anchor': (0.5, 0.5, 0.5), 'text': 'La/Ba'},
+ {'anchor': (0.25, 0.25, 0.25), 'text': 'O'},
+ ]
+
+ def test_legend_exposes_symbol_and_colour(self):
+ payload = MUT._scene_payload(_rich_scene())
+
+ assert payload['legend'] == [
+ {'symbol': 'Fe', 'colour': (255, 0, 0)},
+ {'symbol': 'O', 'colour': (0, 255, 0)},
+ ]
+
+
+class TestScenePayloadPalettes:
+ def test_palettes_cover_every_colour_scheme(self):
+ payload = MUT._scene_payload(_rich_scene())
+
+ expected_schemes = {scheme.value for scheme in ColorSchemeEnum}
+ assert set(payload['palettes']) == expected_schemes
+
+ def test_each_palette_maps_every_legend_symbol(self):
+ payload = MUT._scene_payload(_rich_scene())
+
+ for scheme in ColorSchemeEnum:
+ palette = payload['palettes'][scheme.value]
+ assert set(palette) == {'Fe', 'O'}
+ for rgb in palette.values():
+ assert len(rgb) == 3
+
+ def test_palette_uses_color_for_lookup(self):
+ from easydiffraction.display.structure.assets.colors import color_for
+
+ payload = MUT._scene_payload(_rich_scene())
+ for scheme in ColorSchemeEnum:
+ palette = payload['palettes'][scheme.value]
+ assert palette['Fe'] == color_for('Fe', scheme.value)
+
+
+class TestScenePayloadEmptyScene:
+ def test_empty_scene_yields_empty_collections(self):
+ payload = MUT._scene_payload(_identity_scene())
+
+ assert payload['atoms'] == []
+ assert payload['wedgeSpheres'] == []
+ assert payload['ellipsoids'] == []
+ assert payload['bonds'] == []
+ assert payload['cellEdges'] == []
+ assert payload['labels'] == []
+ assert payload['legend'] == []
+
+ def test_empty_scene_has_no_axes(self):
+ payload = MUT._scene_payload(_identity_scene())
+ assert payload['axes'] is None
+
+ def test_empty_scene_palettes_are_empty_per_scheme(self):
+ payload = MUT._scene_payload(_identity_scene())
+
+ for scheme in ColorSchemeEnum:
+ assert payload['palettes'][scheme.value] == {}
+
+
+# ------------------------------------------------------------------
+# ThreeJsStructureRenderer — class surface
+# ------------------------------------------------------------------
+
+
+class TestRendererClass:
+ def test_is_structure_renderer_subclass(self):
+ assert issubclass(ThreeJsStructureRenderer, StructureRendererBase)
+
+ def test_instantiation(self):
+ assert ThreeJsStructureRenderer() is not None
+
+ def test_supported_features_returns_frozenset(self):
+ renderer = ThreeJsStructureRenderer()
+ features = renderer.supported_features()
+ assert isinstance(features, frozenset)
+
+ def test_supported_features_match_class_constant(self):
+ renderer = ThreeJsStructureRenderer()
+ assert renderer.supported_features() == ThreeJsStructureRenderer.SUPPORTED
+
+ def test_supported_feature_names(self):
+ expected = frozenset({'atoms', 'bonds', 'cell', 'axes', 'moments', 'labels'})
+ assert expected == ThreeJsStructureRenderer.SUPPORTED
+
+ def test_template_name(self):
+ assert ThreeJsStructureRenderer.TEMPLATE_NAME == 'structure.html.j2'
+
+
+# ------------------------------------------------------------------
+# ThreeJsStructureRenderer.render — happy path (theme_colors patched)
+# ------------------------------------------------------------------
+
+
+class TestRenderHtmlDocument:
+ def test_returns_complete_html_document(self, patched_theme):
+ html = ThreeJsStructureRenderer().render(
+ _rich_scene(),
+ features=frozenset({'atoms', 'bonds'}),
+ offline=True,
+ dark=False,
+ )
+ assert isinstance(html, str)
+ assert '<' in html
+ assert '>' in html
+ assert len(html) > 0
+
+ def test_embeds_unique_container_id(self, patched_theme):
+ renderer = ThreeJsStructureRenderer()
+ scene = _rich_scene()
+ first = renderer.render(scene, features=frozenset({'atoms'}), offline=True, dark=False)
+ second = renderer.render(scene, features=frozenset({'atoms'}), offline=True, dark=False)
+
+ assert 'crysview-' in first
+ assert 'crysview-' in second
+ # Each render mints a fresh uuid4-based container id.
+ assert first != second
+
+ def test_light_theme_marks_document_light(self, patched_theme):
+ html = ThreeJsStructureRenderer().render(
+ _identity_scene(),
+ features=frozenset(),
+ offline=True,
+ dark=False,
+ )
+ assert 'light' in html
+
+ def test_dark_theme_marks_document_dark(self, patched_theme):
+ html = ThreeJsStructureRenderer().render(
+ _identity_scene(),
+ features=frozenset(),
+ offline=True,
+ dark=True,
+ )
+ assert 'dark' in html
+ assert '--cv-panel-bg: rgba(10, 20, 30, 0.5);' in html
+ assert "stroke='%23ebebeb'" in html
+
+ def test_offline_embeds_inlined_module(self, patched_theme):
+ html = ThreeJsStructureRenderer().render(
+ _identity_scene(),
+ features=frozenset(),
+ offline=True,
+ dark=False,
+ )
+ # Offline mode inlines the vendored module as a data URL.
+ assert 'data:text/javascript;base64,' in html
+
+ def test_online_links_cdn(self, patched_theme):
+ html = ThreeJsStructureRenderer().render(
+ _identity_scene(),
+ features=frozenset(),
+ offline=False,
+ dark=False,
+ )
+ assert MUT._CDN in html
+
+ def test_offline_defaults_to_true(self, patched_theme):
+ # Omitting ``offline`` must inline assets, not link the CDN.
+ html = ThreeJsStructureRenderer().render(
+ _identity_scene(),
+ features=frozenset(),
+ dark=False,
+ )
+ assert 'data:text/javascript;base64,' in html
+
+ def test_dark_none_autodetects_via_is_dark(self, patched_theme, monkeypatch):
+ calls = []
+
+ def fake_is_dark():
+ calls.append(True)
+ return True
+
+ monkeypatch.setattr(MUT, 'is_dark', fake_is_dark)
+ html = ThreeJsStructureRenderer().render(
+ _identity_scene(),
+ features=frozenset(),
+ offline=True,
+ )
+ assert calls == [True]
+ assert 'dark' in html
+
+ def test_features_are_embedded_sorted(self, patched_theme, monkeypatch):
+ captured = {}
+
+ real_dumps = json.dumps
+
+ def spy_dumps(obj, *args, **kwargs):
+ if isinstance(obj, list):
+ captured['features'] = obj
+ return real_dumps(obj, *args, **kwargs)
+
+ monkeypatch.setattr(MUT.json, 'dumps', spy_dumps)
+ ThreeJsStructureRenderer().render(
+ _identity_scene(),
+ features=frozenset({'bonds', 'atoms', 'cell'}),
+ offline=True,
+ dark=False,
+ )
+ assert captured['features'] == ['atoms', 'bonds', 'cell']
+
+ def test_download_button_is_bottom_right_overlay(self, patched_theme):
+ html = ThreeJsStructureRenderer().render(
+ _identity_scene(),
+ features=frozenset(),
+ offline=True,
+ dark=False,
+ )
+
+ assert '
' in html
+ assert 'right: 10px; bottom: 8px;' in html
+ assert "iconButton(downloadHost, ICONS.camera, 'Download PNG')" in html
+
+ def test_viewer_is_isolated_from_page_header_stacking(self, patched_theme):
+ html = ThreeJsStructureRenderer().render(
+ _identity_scene(),
+ features=frozenset(),
+ offline=True,
+ dark=False,
+ )
+
+ assert 'overflow:hidden;isolation:isolate;z-index:0;' in html
+ assert 'style="position:absolute;inset:0;z-index:1;"' in html
+ assert 'position: absolute; z-index: 4; background: var(--cv-panel-bg);' in html
+
+ def test_legend_and_hint_overlay_axis_letters(self, patched_theme):
+ html = ThreeJsStructureRenderer().render(
+ _rich_scene(),
+ features=frozenset({'axes'}),
+ offline=True,
+ dark=False,
+ )
+
+ assert '.cv-legend { z-index: 5;' in html
+ assert '.cv-hint {\n position: absolute; z-index: 5;' in html
+ assert "className = 'cv-axis-letter';" in html
+
+ def test_legend_and_hint_use_half_transparent_background(self, patched_theme):
+ html = ThreeJsStructureRenderer().render(
+ _identity_scene(),
+ features=frozenset(),
+ offline=True,
+ dark=False,
+ )
+
+ assert '--cv-panel-bg: rgba(10, 20, 30, 0.5);' in html
+ assert 'color: var(--cv-panel-fg); background: var(--cv-panel-bg);' in html
+
+ def test_perspective_projection_uses_reduced_field_of_view(self, patched_theme):
+ html = ThreeJsStructureRenderer().render(
+ _identity_scene(),
+ features=frozenset(),
+ offline=True,
+ dark=False,
+ )
+
+ assert 'const PERSPECTIVE_FOV_DEG = 30;' in html
+ assert 'new THREE.PerspectiveCamera(PERSPECTIVE_FOV_DEG,' in html
+
+ def test_reset_view_resets_orthographic_zoom(self, patched_theme):
+ html = ThreeJsStructureRenderer().render(
+ _identity_scene(),
+ features=frozenset(),
+ offline=True,
+ dark=False,
+ )
+
+ assert "iconButton(cameraGroup, ICONS.home, 'Reset view');" in html
+ assert 'perspective.zoom = 1;' in html
+ assert 'perspective.updateProjectionMatrix();' in html
+ assert 'ortho.zoom = 1;' in html
+ assert 'ortho.updateProjectionMatrix();' in html
+ assert 'camera.position.copy(home);' in html
+ assert 'controls.target.copy(target);' in html
+
+ def test_axis_view_buttons_flip_camera_to_keep_secondary_axis_up(self, patched_theme):
+ html = ThreeJsStructureRenderer().render(
+ _rich_scene(),
+ features=frozenset({'axes'}),
+ offline=True,
+ dark=False,
+ )
+
+ assert 'let secondarySource = remaining[1];' in html
+ assert '[horizontalSource, secondarySource] = [secondarySource, horizontalSource];' in html
+ assert 'const cameraAxis = viewAxis.clone();' in html
+ assert 'if (secondary.dot(up) < 0) {' in html
+ assert 'cameraAxis.negate();' in html
+ assert 'up.negate();' in html
+ assert 'viewAlong(cameraAxis, up);' in html
+ assert 'viewAlong(viewAxis, up);' not in html
+
+ def test_colour_scheme_select_matches_button_height(self, patched_theme):
+ html = ThreeJsStructureRenderer().render(
+ _rich_scene(),
+ features=frozenset({'atoms'}),
+ offline=True,
+ dark=False,
+ )
+
+ assert 'height: calc(var(--cv-control-h) + 2px);' in html
+ assert 'min-height: calc(var(--cv-control-h) + 2px);' in html
+ assert 'max-height: calc(var(--cv-control-h) + 2px);' in html
+ assert 'display: inline-flex; align-items: center;' in html
+ assert 'vertical-align: top;' in html
+ assert 'font-family: inherit;' in html
+ assert '--cv-axis-letter-size: 18px;\n }\n #' in html
+
+ def test_exposes_host_theme_sync_hook(self, patched_theme):
+ html = ThreeJsStructureRenderer().render(
+ _identity_scene(),
+ features=frozenset(),
+ offline=True,
+ dark=False,
+ )
+
+ assert 'root.__crysviewApplyTheme = applyTheme;' in html
+ assert 'data-md-color-scheme' in html
+ assert 'data-jp-theme-light' in html
+ assert "document.body.getAttribute('data-md-color-scheme')" in html
+
+
+# ------------------------------------------------------------------
+# ThreeJsStructureRenderer.render — invalid inputs
+# ------------------------------------------------------------------
+
+
+class TestRenderInvalidInputs:
+ def test_non_iterable_features_raises_type_error(self, patched_theme):
+ # ``render`` sorts ``features``; a non-iterable cannot be sorted.
+ with pytest.raises(TypeError):
+ ThreeJsStructureRenderer().render(
+ _identity_scene(),
+ features=None, # type: ignore[arg-type]
+ offline=True,
+ dark=False,
+ )
+
+
+# ------------------------------------------------------------------
+# ThreeJsStructureRenderer.render — un-patched theme_colors integration
+# ------------------------------------------------------------------
+
+
+class TestRenderUnpatchedIntegration:
+ """Exercise ``render`` against the real ``theme_colors`` collaborator.
+
+ These tests deliberately omit the ``patched_theme`` fixture so the
+ production ``theme_colors(dark=dark)`` call runs for real, proving the
+ keyword-only signature is invoked correctly and its light/dark
+ contrast colours reach the document.
+ """
+
+ def test_render_returns_html_document(self):
+ # No ``patched_theme``: the real, keyword-only ``theme_colors``
+ # must accept the call and yield a complete HTML document.
+ html = ThreeJsStructureRenderer().render(
+ _rich_scene(),
+ features=frozenset({'atoms', 'bonds'}),
+ offline=True,
+ dark=False,
+ )
+ assert isinstance(html, str)
+ assert '<' in html
+ assert '>' in html
+ assert 'crysview-' in html
+
+ def test_light_theme_embeds_transparent_canvas_and_contrast_colours(self):
+ # ``theme_colors(dark=False)`` returns LIGHT_THEME; its background
+ # and foreground must be wired into the document for labels.
+ html = ThreeJsStructureRenderer().render(
+ _identity_scene(),
+ features=frozenset(),
+ offline=True,
+ dark=False,
+ )
+ assert '--cv-scene-bg: transparent;' in html
+ assert '--cv-label-shadow-bg: rgb(255, 255, 255);' in html
+ assert 'rgb(33, 33, 33)' in html # LIGHT_THEME foreground
+ assert 'light' in html
+
+ def test_dark_theme_embeds_transparent_canvas_and_contrast_colours(self):
+ # ``theme_colors(dark=True)`` returns DARK_THEME instead.
+ html = ThreeJsStructureRenderer().render(
+ _identity_scene(),
+ features=frozenset(),
+ offline=True,
+ dark=True,
+ )
+ assert '--cv-scene-bg: transparent;' in html
+ assert '--cv-label-shadow-bg: rgb(33, 33, 33);' in html
+ assert 'rgb(235, 235, 235)' in html # DARK_THEME foreground
+ assert 'dark' in html
diff --git a/tests/unit/easydiffraction/display/structure/test_builder.py b/tests/unit/easydiffraction/display/structure/test_builder.py
new file mode 100644
index 000000000..d9880da60
--- /dev/null
+++ b/tests/unit/easydiffraction/display/structure/test_builder.py
@@ -0,0 +1,688 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+"""Tests for display/structure/builder.py (scene builder)."""
+
+from __future__ import annotations
+
+import numpy as np
+import pytest
+
+from easydiffraction.datablocks.structure.item.base import Structure
+from easydiffraction.display.structure import builder as MUT
+from easydiffraction.display.structure.builder import ALL_FEATURES
+from easydiffraction.display.structure.builder import FeatureAvailability
+from easydiffraction.display.structure.builder import build_scene
+from easydiffraction.display.structure.builder import structure_feature_availability
+from easydiffraction.display.structure.enums import AtomViewEnum
+from easydiffraction.display.structure.enums import ColorSchemeEnum
+from easydiffraction.display.structure.scene import AdpEllipsoid
+from easydiffraction.display.structure.scene import AtomSphere
+from easydiffraction.display.structure.scene import AxisTriad
+from easydiffraction.display.structure.scene import Bond
+from easydiffraction.display.structure.scene import CellEdges
+from easydiffraction.display.structure.scene import LegendEntry
+from easydiffraction.display.structure.scene import OccupancyWedgeSphere
+from easydiffraction.display.structure.scene import StructureScene
+from easydiffraction.display.structure.scene import TextLabel
+from easydiffraction.project.categories.structure_style.default import StructureStyle
+
+# A view range covering only the asymmetric unit (no lattice images),
+# keeping P 1 scene-atom counts deterministic and equal to the number
+# of in-range sites.
+ASYMMETRIC_UNIT = ((0.0, 0.5), (0.0, 0.5), (0.0, 0.5))
+# The full conventional cell; in P 1 the cell-corner lattice images of a
+# site at the origin are deduplicated, so counts grow predictably.
+FULL_CELL = ((0.0, 1.0), (0.0, 1.0), (0.0, 1.0))
+
+
+def _make_structure(name='test', *, space_group='P 1'):
+ """Build a minimal cubic P 1 structure (no calculation engine)."""
+ structure = Structure(name=name)
+ structure.cell.length_a = 5.0
+ structure.cell.length_b = 5.0
+ structure.cell.length_c = 5.0
+ structure.space_group.name_h_m = space_group
+ return structure
+
+
+def _add_atom(
+ structure,
+ *,
+ label,
+ type_symbol,
+ fract_x=0.0,
+ fract_y=0.0,
+ fract_z=0.0,
+ occupancy=1.0,
+ adp_type='Biso',
+ adp_iso=0.5,
+):
+ structure.atom_sites.create(
+ label=label,
+ type_symbol=type_symbol,
+ fract_x=fract_x,
+ fract_y=fract_y,
+ fract_z=fract_z,
+ occupancy=occupancy,
+ adp_type=adp_type,
+ adp_iso=adp_iso,
+ )
+
+
+def _two_atom_structure(name='two', *, view_positions=True):
+ """Two distinct in-range sites (Fe + O) for bond/atom tests."""
+ structure = _make_structure(name)
+ offset = (0.1, 0.1, 0.1) if view_positions else (0.0, 0.0, 0.0)
+ _add_atom(
+ structure,
+ label='Fe1',
+ type_symbol='Fe',
+ fract_x=offset[0],
+ fract_y=offset[1],
+ fract_z=offset[2],
+ )
+ _add_atom(
+ structure,
+ label='O1',
+ type_symbol='O',
+ fract_x=offset[0] + 0.2,
+ fract_y=offset[1],
+ fract_z=offset[2],
+ )
+ structure._sync_atom_site_aniso()
+ return structure
+
+
+def _anisotropic_structure(name='aniso'):
+ """One anisotropic (Uani) Fe site with a non-spherical U tensor.
+
+ Created isotropic first, then flipped to ``Uani`` on the existing
+ site (the supported edit path) before the aniso components are set.
+ """
+ structure = _make_structure(name)
+ _add_atom(
+ structure,
+ label='Fe1',
+ type_symbol='Fe',
+ fract_x=0.1,
+ fract_y=0.1,
+ fract_z=0.1,
+ adp_type='Uiso',
+ adp_iso=0.01,
+ )
+ structure.atom_sites['Fe1'].adp_type = 'Uani'
+ structure._sync_atom_site_aniso()
+ aniso = structure.atom_site_aniso['Fe1']
+ aniso.adp_11 = 0.01
+ aniso.adp_22 = 0.02
+ aniso.adp_33 = 0.03
+ return structure
+
+
+# ======================================================================
+# Module import + public surface
+# ======================================================================
+
+
+class TestModule:
+ def test_module_import(self):
+ assert MUT.__name__ == 'easydiffraction.display.structure.builder'
+
+ def test_all_features_constant(self):
+ # The resolved feature names the facade can ask the builder for.
+ assert set(ALL_FEATURES) == {'atoms', 'bonds', 'cell', 'axes', 'moments', 'labels'}
+
+ def test_all_features_is_tuple(self):
+ # A stable, immutable ordering for UI listings.
+ assert isinstance(ALL_FEATURES, tuple)
+
+
+# ======================================================================
+# FeatureAvailability dataclass
+# ======================================================================
+
+
+class TestFeatureAvailability:
+ def test_construction_and_fields(self):
+ availability = FeatureAvailability(
+ available=frozenset({'atoms', 'cell'}),
+ radius_substitutions=('H', 'He'),
+ )
+ assert availability.available == frozenset({'atoms', 'cell'})
+ assert availability.radius_substitutions == ('H', 'He')
+
+ def test_is_frozen(self):
+ availability = FeatureAvailability(available=frozenset(), radius_substitutions=())
+ with pytest.raises((AttributeError, TypeError)):
+ availability.available = frozenset({'atoms'})
+
+
+# ======================================================================
+# structure_feature_availability
+# ======================================================================
+
+
+class TestStructureFeatureAvailability:
+ def test_returns_feature_availability(self):
+ structure = _two_atom_structure()
+ result = structure_feature_availability(structure, style=StructureStyle())
+ assert isinstance(result, FeatureAvailability)
+
+ def test_populated_structure_supports_all_features(self):
+ structure = _two_atom_structure()
+ result = structure_feature_availability(structure, style=StructureStyle())
+ assert result.available == frozenset({'atoms', 'bonds', 'cell', 'axes', 'labels'})
+
+ def test_empty_structure_supports_only_cell_and_axes(self):
+ # With no atom sites, only the cell box and axis triad are drawable.
+ structure = _make_structure('empty')
+ result = structure_feature_availability(structure, style=StructureStyle())
+ assert result.available == frozenset({'cell', 'axes'})
+
+ def test_no_substitutions_for_well_covered_elements(self):
+ structure = _two_atom_structure()
+ result = structure_feature_availability(structure, style=StructureStyle())
+ assert result.radius_substitutions == ()
+
+ def test_reports_ionic_substitution_for_hydrogen(self):
+ # H has no ionic radius, so the ionic view falls back to covalent
+ # and reports the substitution.
+ structure = _make_structure('hydride')
+ _add_atom(structure, label='H1', type_symbol='H', fract_x=0.1, fract_y=0.1, fract_z=0.1)
+ structure._sync_atom_site_aniso()
+ style = StructureStyle()
+ style.atom_view = 'ionic'
+ result = structure_feature_availability(structure, style=style)
+ assert result.radius_substitutions == ('H',)
+
+ def test_substitutions_sorted_and_deduplicated(self):
+ # Two H atoms and one He atom under the ionic view -> one entry per
+ # element, alphabetically sorted.
+ structure = _make_structure('light')
+ _add_atom(structure, label='H1', type_symbol='H', fract_x=0.1, fract_y=0.1, fract_z=0.1)
+ _add_atom(structure, label='H2', type_symbol='H', fract_x=0.2, fract_y=0.2, fract_z=0.2)
+ _add_atom(structure, label='He1', type_symbol='He', fract_x=0.3, fract_y=0.3, fract_z=0.3)
+ structure._sync_atom_site_aniso()
+ style = StructureStyle()
+ style.atom_view = 'ionic'
+ result = structure_feature_availability(structure, style=style)
+ assert result.radius_substitutions == ('H', 'He')
+
+
+# ======================================================================
+# build_scene — return type and cell basis
+# ======================================================================
+
+
+class TestBuildSceneBasics:
+ def test_returns_structure_scene(self):
+ scene = build_scene(
+ _two_atom_structure(),
+ style=StructureStyle(),
+ view_range=ASYMMETRIC_UNIT,
+ features=frozenset(ALL_FEATURES),
+ )
+ assert isinstance(scene, StructureScene)
+
+ def test_cell_basis_matches_cubic_cell(self):
+ scene = build_scene(
+ _two_atom_structure(),
+ style=StructureStyle(),
+ view_range=ASYMMETRIC_UNIT,
+ features=frozenset(),
+ )
+ # Cubic 5 angstrom cell -> orthogonal basis with 5 on the diagonal.
+ basis = np.array(scene.cell_basis)
+ assert np.allclose(basis, np.diag([5.0, 5.0, 5.0]), atol=1e-6)
+
+
+# ======================================================================
+# build_scene — feature gating
+# ======================================================================
+
+
+class TestBuildSceneFeatureGating:
+ def _scene(self, features):
+ return build_scene(
+ _two_atom_structure(),
+ style=StructureStyle(),
+ view_range=ASYMMETRIC_UNIT,
+ features=frozenset(features),
+ )
+
+ def test_empty_features_emits_nothing_drawable(self):
+ scene = self._scene(frozenset())
+ assert scene.atoms == ()
+ assert scene.occupancy_spheres == ()
+ assert scene.ellipsoids == ()
+ assert scene.bonds == ()
+ assert scene.labels == ()
+ assert scene.legend == ()
+ assert scene.cell_edges is None
+ assert scene.axes is None
+ # cell_basis is always present regardless of features.
+ assert scene.cell_basis is not None
+
+ def test_atoms_feature_emits_atoms_and_legend(self):
+ scene = self._scene({'atoms'})
+ assert len(scene.atoms) == 2
+ assert len(scene.legend) == 2
+ # No other features requested.
+ assert scene.bonds == ()
+ assert scene.cell_edges is None
+ assert scene.axes is None
+ assert scene.labels == ()
+
+ def test_legend_omitted_without_atoms_feature(self):
+ scene = self._scene({'cell'})
+ assert scene.legend == ()
+
+ def test_bonds_feature_emits_bonds(self):
+ scene = self._scene({'atoms', 'bonds'})
+ assert len(scene.bonds) == 1
+
+ def test_bonds_omitted_without_bonds_feature(self):
+ scene = self._scene({'atoms'})
+ assert scene.bonds == ()
+
+ def test_cell_feature_emits_twelve_edges(self):
+ scene = self._scene({'cell'})
+ assert isinstance(scene.cell_edges, CellEdges)
+ assert len(scene.cell_edges.edges) == 12
+
+ def test_axes_feature_emits_triad(self):
+ scene = self._scene({'axes'})
+ assert isinstance(scene.axes, AxisTriad)
+ assert len(scene.axes.axes) == 3
+
+ def test_labels_feature_emits_one_label_per_atom(self):
+ scene = self._scene({'labels'})
+ assert len(scene.labels) == 2
+ assert all(isinstance(label, TextLabel) for label in scene.labels)
+
+ def test_full_features_emit_every_section(self):
+ scene = self._scene(ALL_FEATURES)
+ assert len(scene.atoms) == 2
+ assert len(scene.bonds) == 1
+ assert len(scene.labels) == 2
+ assert len(scene.legend) == 2
+ assert scene.cell_edges is not None
+ assert scene.axes is not None
+
+
+# ======================================================================
+# build_scene — atom primitives
+# ======================================================================
+
+
+class TestBuildSceneAtomPrimitives:
+ def test_ball_view_emits_atom_spheres(self):
+ style = StructureStyle()
+ style.atom_view = 'vdw'
+ scene = build_scene(
+ _two_atom_structure(),
+ style=style,
+ view_range=ASYMMETRIC_UNIT,
+ features=frozenset({'atoms'}),
+ )
+ assert all(isinstance(atom, AtomSphere) for atom in scene.atoms)
+ assert all(atom.radius > 0.0 for atom in scene.atoms)
+ assert scene.ellipsoids == ()
+
+ def test_atom_scale_grows_ball_radius(self):
+ small = StructureStyle()
+ small.atom_view = 'vdw'
+ small.atom_scale = 0.2
+ large = StructureStyle()
+ large.atom_view = 'vdw'
+ large.atom_scale = 0.8
+ structure = _two_atom_structure()
+ scene_small = build_scene(
+ structure, style=small, view_range=ASYMMETRIC_UNIT, features=frozenset({'atoms'})
+ )
+ scene_large = build_scene(
+ structure, style=large, view_range=ASYMMETRIC_UNIT, features=frozenset({'atoms'})
+ )
+ assert scene_large.atoms[0].radius > scene_small.atoms[0].radius
+
+ def test_adp_view_anisotropic_atom_emits_ellipsoid(self):
+ structure = _anisotropic_structure()
+ style = StructureStyle()
+ style.atom_view = 'adp'
+ scene = build_scene(
+ structure, style=style, view_range=ASYMMETRIC_UNIT, features=frozenset({'atoms'})
+ )
+ assert len(scene.ellipsoids) == 1
+ assert scene.atoms == ()
+ ellipsoid = scene.ellipsoids[0]
+ assert isinstance(ellipsoid, AdpEllipsoid)
+ assert all(axis > 0.0 for axis in ellipsoid.semi_axes)
+
+ def test_adp_probability_scales_ellipsoid_size(self):
+ structure = _anisotropic_structure()
+ low = StructureStyle()
+ low.atom_view = 'adp'
+ low.adp_probability = 0.5
+ high = StructureStyle()
+ high.atom_view = 'adp'
+ high.adp_probability = 0.99
+ scene_low = build_scene(
+ structure, style=low, view_range=ASYMMETRIC_UNIT, features=frozenset({'atoms'})
+ )
+ scene_high = build_scene(
+ structure, style=high, view_range=ASYMMETRIC_UNIT, features=frozenset({'atoms'})
+ )
+ # Higher probability -> larger principal semi-axes.
+ assert scene_high.ellipsoids[0].semi_axes[0] > scene_low.ellipsoids[0].semi_axes[0]
+
+ def test_reference_atom_marked_asymmetric(self):
+ # The atom at the asymmetric-unit reference position is flagged so
+ # renderers can offer an asymmetric-unit-only toggle.
+ structure = _two_atom_structure()
+ scene = build_scene(
+ structure,
+ style=StructureStyle(),
+ view_range=ASYMMETRIC_UNIT,
+ features=frozenset({'atoms'}),
+ )
+ assert any(atom.asymmetric for atom in scene.atoms)
+
+
+# ======================================================================
+# build_scene — mixed-occupancy (wedge) sites
+# ======================================================================
+
+
+class TestBuildSceneOccupancyWedges:
+ def _shared_site_structure(self):
+ structure = _make_structure('mixed')
+ _add_atom(
+ structure,
+ label='Fe1',
+ type_symbol='Fe',
+ fract_x=0.1,
+ fract_y=0.1,
+ fract_z=0.1,
+ occupancy=0.6,
+ )
+ _add_atom(
+ structure,
+ label='Mn1',
+ type_symbol='Mn',
+ fract_x=0.1,
+ fract_y=0.1,
+ fract_z=0.1,
+ occupancy=0.4,
+ )
+ structure._sync_atom_site_aniso()
+ return structure
+
+ def test_shared_site_emits_occupancy_wedge_sphere(self):
+ style = StructureStyle()
+ style.atom_view = 'vdw'
+ scene = build_scene(
+ self._shared_site_structure(),
+ style=style,
+ view_range=ASYMMETRIC_UNIT,
+ features=frozenset({'atoms'}),
+ )
+ assert scene.atoms == ()
+ assert len(scene.occupancy_spheres) == 1
+ assert isinstance(scene.occupancy_spheres[0], OccupancyWedgeSphere)
+
+ def test_wedge_label_joins_member_labels(self):
+ scene = build_scene(
+ self._shared_site_structure(),
+ style=StructureStyle(),
+ view_range=ASYMMETRIC_UNIT,
+ features=frozenset({'atoms'}),
+ )
+ sphere = scene.occupancy_spheres[0]
+ assert sphere.label == 'Fe1/Mn1'
+
+ def test_wedges_use_relative_proportions(self):
+ scene = build_scene(
+ self._shared_site_structure(),
+ style=StructureStyle(),
+ view_range=ASYMMETRIC_UNIT,
+ features=frozenset({'atoms'}),
+ )
+ fractions = [wedge.fraction for wedge in scene.occupancy_spheres[0].wedges]
+ assert pytest.approx(sum(fractions), abs=1e-9) == 1.0
+ assert pytest.approx(fractions[0], abs=1e-9) == 0.6
+ assert pytest.approx(fractions[1], abs=1e-9) == 0.4
+
+
+# ======================================================================
+# build_scene — bonds
+# ======================================================================
+
+
+class TestBuildSceneBonds:
+ def test_single_atom_has_no_bonds(self):
+ structure = _make_structure('single')
+ _add_atom(structure, label='Fe1', type_symbol='Fe', fract_x=0.1, fract_y=0.1, fract_z=0.1)
+ structure._sync_atom_site_aniso()
+ scene = build_scene(
+ structure,
+ style=StructureStyle(),
+ view_range=ASYMMETRIC_UNIT,
+ features=frozenset({'bonds'}),
+ )
+ assert scene.bonds == ()
+
+ def test_close_atoms_bond(self):
+ scene = build_scene(
+ _two_atom_structure(),
+ style=StructureStyle(),
+ view_range=ASYMMETRIC_UNIT,
+ features=frozenset({'bonds'}),
+ )
+ assert len(scene.bonds) == 1
+ bond = scene.bonds[0]
+ assert isinstance(bond, Bond)
+ assert {bond.start_element, bond.end_element} == {'Fe', 'O'}
+
+ def test_min_distance_cutoff_suppresses_bond(self):
+ # Raising the minimum bonded distance above the actual Fe-O
+ # distance removes the bond.
+ structure = _two_atom_structure()
+ structure.geom.min_bond_distance_cutoff = 5.0
+ scene = build_scene(
+ structure,
+ style=StructureStyle(),
+ view_range=ASYMMETRIC_UNIT,
+ features=frozenset({'bonds'}),
+ )
+ assert scene.bonds == ()
+
+ def test_bond_distance_incr_enables_distant_bond(self):
+ # Two atoms 2.5 angstrom apart (beyond summed covalent radii)
+ # bond only once the increment is generous enough.
+ structure = _make_structure('stretch')
+ _add_atom(structure, label='Fe1', type_symbol='Fe', fract_x=0.0, fract_y=0.0, fract_z=0.0)
+ _add_atom(structure, label='Fe2', type_symbol='Fe', fract_x=0.5, fract_y=0.0, fract_z=0.0)
+ structure._sync_atom_site_aniso()
+
+ structure.geom.bond_distance_incr = 0.0
+ scene_tight = build_scene(
+ structure,
+ style=StructureStyle(),
+ view_range=ASYMMETRIC_UNIT,
+ features=frozenset({'bonds'}),
+ )
+
+ structure.geom.bond_distance_incr = 5.0
+ scene_loose = build_scene(
+ structure,
+ style=StructureStyle(),
+ view_range=ASYMMETRIC_UNIT,
+ features=frozenset({'bonds'}),
+ )
+
+ assert len(scene_loose.bonds) >= len(scene_tight.bonds)
+ assert len(scene_loose.bonds) >= 1
+
+
+# ======================================================================
+# build_scene — colour scheme + legend
+# ======================================================================
+
+
+class TestBuildSceneColours:
+ def test_legend_has_one_entry_per_element(self):
+ scene = build_scene(
+ _two_atom_structure(),
+ style=StructureStyle(),
+ view_range=ASYMMETRIC_UNIT,
+ features=frozenset({'atoms'}),
+ )
+ symbols = [entry.symbol for entry in scene.legend]
+ assert symbols == ['Fe', 'O']
+ assert all(isinstance(entry, LegendEntry) for entry in scene.legend)
+
+ def test_colour_scheme_changes_atom_colour(self):
+ structure = _two_atom_structure()
+ jmol = StructureStyle()
+ jmol.atom_view = 'vdw'
+ jmol.color_scheme = 'jmol'
+ vesta = StructureStyle()
+ vesta.atom_view = 'vdw'
+ vesta.color_scheme = 'vesta'
+ scene_jmol = build_scene(
+ structure, style=jmol, view_range=ASYMMETRIC_UNIT, features=frozenset({'atoms'})
+ )
+ scene_vesta = build_scene(
+ structure, style=vesta, view_range=ASYMMETRIC_UNIT, features=frozenset({'atoms'})
+ )
+ jmol_colours = {atom.label: atom.colour for atom in scene_jmol.atoms}
+ vesta_colours = {atom.label: atom.colour for atom in scene_vesta.atoms}
+ # At least one element is coloured differently between schemes.
+ assert jmol_colours != vesta_colours
+
+
+# ======================================================================
+# build_scene — symmetry expansion
+# ======================================================================
+
+
+class TestBuildSceneSymmetry:
+ def test_higher_symmetry_generates_more_atoms(self):
+ # The same single site expands to more scene atoms under a
+ # higher-symmetry space group than under P 1.
+ p1 = _make_structure('p1', space_group='P 1')
+ _add_atom(p1, label='Fe1', type_symbol='Fe', fract_x=0.3, fract_y=0.1, fract_z=0.2)
+ p1._sync_atom_site_aniso()
+ scene_p1 = build_scene(
+ p1, style=StructureStyle(), view_range=FULL_CELL, features=frozenset({'atoms'})
+ )
+
+ cubic = _make_structure('cubic', space_group='P m -3 m')
+ _add_atom(cubic, label='Fe1', type_symbol='Fe', fract_x=0.3, fract_y=0.1, fract_z=0.2)
+ cubic._sync_atom_site_aniso()
+ scene_cubic = build_scene(
+ cubic, style=StructureStyle(), view_range=FULL_CELL, features=frozenset({'atoms'})
+ )
+
+ n_cubic = (
+ len(scene_cubic.atoms)
+ + len(scene_cubic.occupancy_spheres)
+ + len(scene_cubic.ellipsoids)
+ )
+ n_p1 = len(scene_p1.atoms) + len(scene_p1.occupancy_spheres) + len(scene_p1.ellipsoids)
+ assert n_cubic > n_p1
+
+ def test_p1_emits_one_atom_per_in_range_site(self):
+ # In P 1 the only operator is the identity, so within the
+ # asymmetric-unit range each site yields exactly one scene atom.
+ structure = _make_structure('p1', space_group='P 1')
+ _add_atom(structure, label='Fe1', type_symbol='Fe', fract_x=0.1, fract_y=0.1, fract_z=0.1)
+ _add_atom(structure, label='O1', type_symbol='O', fract_x=0.3, fract_y=0.2, fract_z=0.2)
+ structure._sync_atom_site_aniso()
+ scene = build_scene(
+ structure,
+ style=StructureStyle(),
+ view_range=ASYMMETRIC_UNIT,
+ features=frozenset({'atoms'}),
+ )
+ assert len(scene.atoms) == 2
+
+
+# ======================================================================
+# Value selectors (EnumDescriptor.show_supported) driving the builder
+# ======================================================================
+
+
+class TestValueSelectors:
+ def test_atom_view_show_supported_lists_all_values(self, capsys):
+ StructureStyle().atom_view.show_supported()
+ out = capsys.readouterr().out
+ for member in AtomViewEnum:
+ assert member.value in out
+
+ def test_color_scheme_show_supported_lists_all_values(self, capsys):
+ StructureStyle().color_scheme.show_supported()
+ out = capsys.readouterr().out
+ for member in ColorSchemeEnum:
+ assert member.value in out
+
+ def test_atom_view_enum_backing(self):
+ # The selector is bound to the closed AtomViewEnum value set.
+ assert StructureStyle().atom_view.enum is AtomViewEnum
+
+ def test_color_scheme_enum_backing(self):
+ assert StructureStyle().color_scheme.enum is ColorSchemeEnum
+
+
+# ======================================================================
+# StructureStyle wiring (the style object the builder reads)
+# ======================================================================
+
+
+class TestStructureStyleInputs:
+ def test_default_atom_view_is_covalent(self):
+ assert StructureStyle().atom_view.value == AtomViewEnum.COVALENT.value
+
+ def test_default_color_scheme_is_jmol(self):
+ assert StructureStyle().color_scheme.value == ColorSchemeEnum.JMOL.value
+
+ def test_atom_view_cif_handler_name(self):
+ assert StructureStyle().atom_view._cif_handler.names == ['_structure_style.atom_view']
+
+ def test_color_scheme_cif_handler_name(self):
+ assert StructureStyle().color_scheme._cif_handler.names == [
+ '_structure_style.color_scheme'
+ ]
+
+ def test_invalid_atom_view_rejected(self):
+ with pytest.raises(ValueError, match='not a valid AtomViewEnum'):
+ StructureStyle().atom_view = 'not-a-view'
+
+ def test_invalid_color_scheme_rejected(self):
+ with pytest.raises(ValueError, match='not a valid ColorSchemeEnum'):
+ StructureStyle().color_scheme = 'not-a-scheme'
+
+
+# ======================================================================
+# Pure helper functions (load-bearing for element/colour mapping)
+# ======================================================================
+
+
+class TestHelpers:
+ @pytest.mark.parametrize(
+ ('type_symbol', 'expected'),
+ [
+ ('Fe', 'Fe'),
+ ('Fe3+', 'Fe'),
+ ('O2-', 'O'),
+ ('Cl', 'Cl'),
+ (' Na+ ', 'Na'),
+ ],
+ )
+ def test_element_symbol_extraction(self, type_symbol, expected):
+ assert MUT._element_symbol(type_symbol) == expected
+
+ def test_vec3_casts_to_float_tuple(self):
+ result = MUT._vec3(np.array([1, 2, 3]))
+ assert result == (1.0, 2.0, 3.0)
+ assert all(isinstance(component, float) for component in result)
diff --git a/tests/unit/easydiffraction/display/structure/test_enums.py b/tests/unit/easydiffraction/display/structure/test_enums.py
new file mode 100644
index 000000000..508f2989e
--- /dev/null
+++ b/tests/unit/easydiffraction/display/structure/test_enums.py
@@ -0,0 +1,162 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+"""Tests for the crysview structure-view enumerations."""
+
+from __future__ import annotations
+
+from enum import StrEnum
+
+import pytest
+
+import easydiffraction.display.structure.enums as enums_mod
+from easydiffraction.display.structure.enums import AtomViewEnum
+from easydiffraction.display.structure.enums import ColorSchemeEnum
+from easydiffraction.display.structure.enums import ViewerEngineEnum
+
+
+def test_module_import():
+ expected_module_name = 'easydiffraction.display.structure.enums'
+ assert enums_mod.__name__ == expected_module_name
+
+
+# ----------------------------------------------------------------------
+# ViewerEngineEnum
+# ----------------------------------------------------------------------
+
+
+class TestViewerEngineEnum:
+ def test_is_str_enum(self):
+ assert issubclass(ViewerEngineEnum, StrEnum)
+
+ def test_members(self):
+ assert ViewerEngineEnum.ASCII == 'ascii'
+ assert ViewerEngineEnum.THREEJS == 'threejs'
+
+ def test_member_count(self):
+ assert {member.value for member in ViewerEngineEnum} == {'ascii', 'threejs'}
+
+ def test_from_string(self):
+ assert ViewerEngineEnum('ascii') is ViewerEngineEnum.ASCII
+ assert ViewerEngineEnum('threejs') is ViewerEngineEnum.THREEJS
+
+ def test_invalid_string(self):
+ with pytest.raises(ValueError, match='is not a valid ViewerEngineEnum'):
+ ViewerEngineEnum('opengl')
+
+ def test_default_is_ascii_outside_jupyter(self, monkeypatch):
+ monkeypatch.setattr(enums_mod, 'in_jupyter', lambda: False)
+ assert ViewerEngineEnum.default() is ViewerEngineEnum.ASCII
+
+ def test_default_is_threejs_in_jupyter(self, monkeypatch):
+ monkeypatch.setattr(enums_mod, 'in_jupyter', lambda: True)
+ assert ViewerEngineEnum.default() is ViewerEngineEnum.THREEJS
+
+ def test_description_ascii(self):
+ assert ViewerEngineEnum.ASCII.description() == 'Console ASCII schematic structure view'
+
+ def test_description_threejs(self):
+ assert ViewerEngineEnum.THREEJS.description() == 'Interactive Three.js 3D structure view'
+
+ def test_every_member_has_nonempty_description(self):
+ for member in ViewerEngineEnum:
+ assert member.description()
+
+
+# ----------------------------------------------------------------------
+# AtomViewEnum
+# ----------------------------------------------------------------------
+
+
+class TestAtomViewEnum:
+ def test_is_str_enum(self):
+ assert issubclass(AtomViewEnum, StrEnum)
+
+ def test_members(self):
+ assert AtomViewEnum.VDW == 'vdw'
+ assert AtomViewEnum.COVALENT == 'covalent'
+ assert AtomViewEnum.IONIC == 'ionic'
+ assert AtomViewEnum.ADP == 'adp'
+
+ def test_member_count(self):
+ assert {member.value for member in AtomViewEnum} == {
+ 'vdw',
+ 'covalent',
+ 'ionic',
+ 'adp',
+ }
+
+ def test_from_string(self):
+ assert AtomViewEnum('vdw') is AtomViewEnum.VDW
+ assert AtomViewEnum('covalent') is AtomViewEnum.COVALENT
+ assert AtomViewEnum('ionic') is AtomViewEnum.IONIC
+ assert AtomViewEnum('adp') is AtomViewEnum.ADP
+
+ def test_invalid_string(self):
+ with pytest.raises(ValueError, match='is not a valid AtomViewEnum'):
+ AtomViewEnum('atomic')
+
+ def test_default_is_covalent(self):
+ assert AtomViewEnum.default() is AtomViewEnum.COVALENT
+
+ def test_is_adp_true_only_for_adp(self):
+ assert AtomViewEnum.ADP.is_adp is True
+ assert AtomViewEnum.VDW.is_adp is False
+ assert AtomViewEnum.COVALENT.is_adp is False
+ assert AtomViewEnum.IONIC.is_adp is False
+
+ def test_radius_model_radius_views_return_own_value(self):
+ assert AtomViewEnum.VDW.radius_model() == 'vdw'
+ assert AtomViewEnum.COVALENT.radius_model() == 'covalent'
+ assert AtomViewEnum.IONIC.radius_model() == 'ionic'
+
+ def test_radius_model_adp_falls_back_to_covalent(self):
+ assert AtomViewEnum.ADP.radius_model() == AtomViewEnum.COVALENT.value
+ assert AtomViewEnum.ADP.radius_model() == 'covalent'
+
+ def test_description_per_member(self):
+ assert AtomViewEnum.VDW.description() == 'Van der Waals radius balls'
+ assert AtomViewEnum.COVALENT.description() == 'Covalent radius balls'
+ assert AtomViewEnum.IONIC.description() == 'Ionic (Shannon) radius balls'
+ assert AtomViewEnum.ADP.description() == 'ADP probability surfaces (spheres / ellipsoids)'
+
+ def test_every_member_has_nonempty_description(self):
+ for member in AtomViewEnum:
+ assert member.description()
+
+
+# ----------------------------------------------------------------------
+# ColorSchemeEnum
+# ----------------------------------------------------------------------
+
+
+class TestColorSchemeEnum:
+ def test_is_str_enum(self):
+ assert issubclass(ColorSchemeEnum, StrEnum)
+
+ def test_members(self):
+ assert ColorSchemeEnum.JMOL == 'jmol'
+ assert ColorSchemeEnum.VESTA == 'vesta'
+
+ def test_member_count(self):
+ assert {member.value for member in ColorSchemeEnum} == {'jmol', 'vesta'}
+
+ def test_from_string(self):
+ assert ColorSchemeEnum('jmol') is ColorSchemeEnum.JMOL
+ assert ColorSchemeEnum('vesta') is ColorSchemeEnum.VESTA
+
+ def test_invalid_string(self):
+ with pytest.raises(ValueError, match='is not a valid ColorSchemeEnum'):
+ ColorSchemeEnum('cpk')
+
+ def test_default_is_jmol(self):
+ assert ColorSchemeEnum.default() is ColorSchemeEnum.JMOL
+
+ def test_description_jmol(self):
+ assert ColorSchemeEnum.JMOL.description() == 'Jmol / CPK colour scheme'
+
+ def test_description_vesta(self):
+ assert ColorSchemeEnum.VESTA.description() == 'VESTA colour scheme'
+
+ def test_every_member_has_nonempty_description(self):
+ for member in ColorSchemeEnum:
+ assert member.description()
diff --git a/tests/unit/easydiffraction/display/structure/test_scene.py b/tests/unit/easydiffraction/display/structure/test_scene.py
new file mode 100644
index 000000000..1dcdde0cd
--- /dev/null
+++ b/tests/unit/easydiffraction/display/structure/test_scene.py
@@ -0,0 +1,511 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+"""Tests for renderer-neutral structure scene primitives."""
+
+from __future__ import annotations
+
+import dataclasses
+
+import pytest
+
+import easydiffraction.display.structure.scene as scene_module
+from easydiffraction.display.structure.scene import AdpEllipsoid
+from easydiffraction.display.structure.scene import AtomSphere
+from easydiffraction.display.structure.scene import AxisArrow
+from easydiffraction.display.structure.scene import AxisTriad
+from easydiffraction.display.structure.scene import Bond
+from easydiffraction.display.structure.scene import CellEdge
+from easydiffraction.display.structure.scene import CellEdges
+from easydiffraction.display.structure.scene import LegendEntry
+from easydiffraction.display.structure.scene import MomentArrow
+from easydiffraction.display.structure.scene import OccupancyWedge
+from easydiffraction.display.structure.scene import OccupancyWedgeSphere
+from easydiffraction.display.structure.scene import StructureScene
+from easydiffraction.display.structure.scene import TextLabel
+
+# ------------------------------------------------------------------
+# Shared minimal building blocks (no engine, no domain types)
+# ------------------------------------------------------------------
+
+ORIGIN = (0.0, 0.0, 0.0)
+IDENTITY_BASIS = (
+ (1.0, 0.0, 0.0),
+ (0.0, 1.0, 0.0),
+ (0.0, 0.0, 1.0),
+)
+RED = (255, 0, 0)
+GREEN = (0, 255, 0)
+BLUE = (0, 0, 255)
+
+# Every primitive dataclass declared in the module, used by the
+# cross-cutting structural tests below.
+ALL_PRIMITIVES = (
+ AtomSphere,
+ OccupancyWedge,
+ OccupancyWedgeSphere,
+ AdpEllipsoid,
+ Bond,
+ MomentArrow,
+ CellEdge,
+ CellEdges,
+ AxisArrow,
+ AxisTriad,
+ TextLabel,
+ LegendEntry,
+ StructureScene,
+)
+
+
+# ------------------------------------------------------------------
+# Module identity and import surface
+# ------------------------------------------------------------------
+
+
+def test_module_import():
+ expected_module_name = 'easydiffraction.display.structure.scene'
+ actual_module_name = scene_module.__name__
+ assert expected_module_name == actual_module_name
+
+
+def test_type_aliases_present():
+ # The plain coordinate/colour aliases are part of the public surface
+ # and must stay free of numpy/domain types (they are plain tuples).
+ assert scene_module.Vec3 == tuple[float, float, float]
+ assert scene_module.Rgb == tuple[int, int, int]
+ assert scene_module.Mat3 == tuple[scene_module.Vec3, scene_module.Vec3, scene_module.Vec3]
+
+
+# ------------------------------------------------------------------
+# Cross-cutting dataclass contract: frozen + slots
+# ------------------------------------------------------------------
+
+
+class TestDataclassContract:
+ """Every primitive is a frozen, slotted dataclass."""
+
+ @pytest.mark.parametrize('cls', ALL_PRIMITIVES)
+ def test_is_dataclass(self, cls):
+ assert dataclasses.is_dataclass(cls)
+
+ @pytest.mark.parametrize('cls', ALL_PRIMITIVES)
+ def test_is_frozen(self, cls):
+ params = cls.__dataclass_params__
+ assert params.frozen is True
+
+ @pytest.mark.parametrize('cls', ALL_PRIMITIVES)
+ def test_uses_slots(self, cls):
+ # slots=True classes have a __slots__ and no per-instance __dict__.
+ assert hasattr(cls, '__slots__')
+ assert '__dict__' not in cls.__slots__
+
+
+# ------------------------------------------------------------------
+# AtomSphere
+# ------------------------------------------------------------------
+
+
+class TestAtomSphere:
+ def test_construction_and_fields(self):
+ atom = AtomSphere(centre=(1.0, 2.0, 3.0), radius=0.5, colour=RED, label='Fe')
+ assert atom.centre == (1.0, 2.0, 3.0)
+ assert atom.radius == 0.5
+ assert atom.colour == RED
+ assert atom.label == 'Fe'
+
+ def test_asymmetric_defaults_false(self):
+ atom = AtomSphere(centre=ORIGIN, radius=1.0, colour=BLUE, label='O')
+ assert atom.asymmetric is False
+
+ def test_asymmetric_can_be_set(self):
+ atom = AtomSphere(centre=ORIGIN, radius=1.0, colour=BLUE, label='O', asymmetric=True)
+ assert atom.asymmetric is True
+
+ def test_frozen_rejects_mutation(self):
+ atom = AtomSphere(centre=ORIGIN, radius=1.0, colour=RED, label='Fe')
+ with pytest.raises(dataclasses.FrozenInstanceError):
+ atom.radius = 2.0
+
+ def test_equality_by_value(self):
+ a = AtomSphere(centre=ORIGIN, radius=1.0, colour=RED, label='Fe')
+ b = AtomSphere(centre=ORIGIN, radius=1.0, colour=RED, label='Fe')
+ assert a == b
+
+
+# ------------------------------------------------------------------
+# OccupancyWedge
+# ------------------------------------------------------------------
+
+
+class TestOccupancyWedge:
+ def test_construction_and_fields(self):
+ wedge = OccupancyWedge(fraction=0.25, colour=GREEN)
+ assert wedge.fraction == 0.25
+ assert wedge.colour == GREEN
+
+ def test_frozen_rejects_mutation(self):
+ wedge = OccupancyWedge(fraction=0.25, colour=GREEN)
+ with pytest.raises(dataclasses.FrozenInstanceError):
+ wedge.fraction = 0.5
+
+
+# ------------------------------------------------------------------
+# OccupancyWedgeSphere
+# ------------------------------------------------------------------
+
+
+class TestOccupancyWedgeSphere:
+ def test_construction_and_fields(self):
+ wedges = (
+ OccupancyWedge(fraction=0.7, colour=RED),
+ OccupancyWedge(fraction=0.3, colour=BLUE),
+ )
+ sphere = OccupancyWedgeSphere(
+ centre=(0.5, 0.5, 0.5),
+ radius=0.8,
+ wedges=wedges,
+ label='Fe/Mn',
+ )
+ assert sphere.centre == (0.5, 0.5, 0.5)
+ assert sphere.radius == 0.8
+ assert sphere.wedges == wedges
+ assert sphere.label == 'Fe/Mn'
+
+ def test_asymmetric_defaults_false(self):
+ sphere = OccupancyWedgeSphere(centre=ORIGIN, radius=1.0, wedges=(), label='X')
+ assert sphere.asymmetric is False
+
+ def test_asymmetric_can_be_set(self):
+ sphere = OccupancyWedgeSphere(
+ centre=ORIGIN,
+ radius=1.0,
+ wedges=(),
+ label='X',
+ asymmetric=True,
+ )
+ assert sphere.asymmetric is True
+
+ def test_frozen_rejects_mutation(self):
+ sphere = OccupancyWedgeSphere(centre=ORIGIN, radius=1.0, wedges=(), label='X')
+ with pytest.raises(dataclasses.FrozenInstanceError):
+ sphere.radius = 2.0
+
+
+# ------------------------------------------------------------------
+# AdpEllipsoid
+# ------------------------------------------------------------------
+
+
+class TestAdpEllipsoid:
+ def test_construction_and_fields(self):
+ ellipsoid = AdpEllipsoid(
+ centre=(0.1, 0.2, 0.3),
+ semi_axes=(0.4, 0.5, 0.6),
+ orientation=IDENTITY_BASIS,
+ colour=RED,
+ label='Fe',
+ )
+ assert ellipsoid.centre == (0.1, 0.2, 0.3)
+ assert ellipsoid.semi_axes == (0.4, 0.5, 0.6)
+ assert ellipsoid.orientation == IDENTITY_BASIS
+ assert ellipsoid.colour == RED
+ assert ellipsoid.label == 'Fe'
+
+ def test_wedges_default_empty(self):
+ ellipsoid = AdpEllipsoid(
+ centre=ORIGIN,
+ semi_axes=(1.0, 1.0, 1.0),
+ orientation=IDENTITY_BASIS,
+ colour=RED,
+ label='Fe',
+ )
+ assert ellipsoid.wedges == ()
+
+ def test_asymmetric_defaults_false(self):
+ ellipsoid = AdpEllipsoid(
+ centre=ORIGIN,
+ semi_axes=(1.0, 1.0, 1.0),
+ orientation=IDENTITY_BASIS,
+ colour=RED,
+ label='Fe',
+ )
+ assert ellipsoid.asymmetric is False
+
+ def test_optional_fields_can_be_set(self):
+ wedges = (OccupancyWedge(fraction=0.5, colour=RED),)
+ ellipsoid = AdpEllipsoid(
+ centre=ORIGIN,
+ semi_axes=(1.0, 1.0, 1.0),
+ orientation=IDENTITY_BASIS,
+ colour=RED,
+ label='Fe',
+ wedges=wedges,
+ asymmetric=True,
+ )
+ assert ellipsoid.wedges == wedges
+ assert ellipsoid.asymmetric is True
+
+ def test_frozen_rejects_mutation(self):
+ ellipsoid = AdpEllipsoid(
+ centre=ORIGIN,
+ semi_axes=(1.0, 1.0, 1.0),
+ orientation=IDENTITY_BASIS,
+ colour=RED,
+ label='Fe',
+ )
+ with pytest.raises(dataclasses.FrozenInstanceError):
+ ellipsoid.colour = BLUE
+
+
+# ------------------------------------------------------------------
+# Bond
+# ------------------------------------------------------------------
+
+
+class TestBond:
+ def test_construction_and_fields(self):
+ bond = Bond(
+ start=ORIGIN,
+ end=(1.0, 1.0, 1.0),
+ start_colour=RED,
+ end_colour=BLUE,
+ )
+ assert bond.start == ORIGIN
+ assert bond.end == (1.0, 1.0, 1.0)
+ assert bond.start_colour == RED
+ assert bond.end_colour == BLUE
+
+ def test_element_fields_default_empty(self):
+ bond = Bond(start=ORIGIN, end=(1.0, 0.0, 0.0), start_colour=RED, end_colour=BLUE)
+ assert bond.start_element == ''
+ assert bond.end_element == ''
+
+ def test_element_fields_can_be_set(self):
+ bond = Bond(
+ start=ORIGIN,
+ end=(1.0, 0.0, 0.0),
+ start_colour=RED,
+ end_colour=BLUE,
+ start_element='Fe',
+ end_element='O',
+ )
+ assert bond.start_element == 'Fe'
+ assert bond.end_element == 'O'
+
+ def test_frozen_rejects_mutation(self):
+ bond = Bond(start=ORIGIN, end=(1.0, 0.0, 0.0), start_colour=RED, end_colour=BLUE)
+ with pytest.raises(dataclasses.FrozenInstanceError):
+ bond.start_colour = GREEN
+
+
+# ------------------------------------------------------------------
+# MomentArrow
+# ------------------------------------------------------------------
+
+
+class TestMomentArrow:
+ def test_construction_and_fields(self):
+ moment = MomentArrow(origin=ORIGIN, vector=(0.0, 0.0, 1.0), colour=RED)
+ assert moment.origin == ORIGIN
+ assert moment.vector == (0.0, 0.0, 1.0)
+ assert moment.colour == RED
+
+ def test_frozen_rejects_mutation(self):
+ moment = MomentArrow(origin=ORIGIN, vector=(0.0, 0.0, 1.0), colour=RED)
+ with pytest.raises(dataclasses.FrozenInstanceError):
+ moment.vector = (1.0, 0.0, 0.0)
+
+
+# ------------------------------------------------------------------
+# CellEdge / CellEdges
+# ------------------------------------------------------------------
+
+
+class TestCellEdge:
+ def test_construction_and_fields(self):
+ edge = CellEdge(start=ORIGIN, end=(1.0, 0.0, 0.0))
+ assert edge.start == ORIGIN
+ assert edge.end == (1.0, 0.0, 0.0)
+
+ def test_frozen_rejects_mutation(self):
+ edge = CellEdge(start=ORIGIN, end=(1.0, 0.0, 0.0))
+ with pytest.raises(dataclasses.FrozenInstanceError):
+ edge.end = (2.0, 0.0, 0.0)
+
+
+class TestCellEdges:
+ def test_construction_and_fields(self):
+ edges = (
+ CellEdge(start=ORIGIN, end=(1.0, 0.0, 0.0)),
+ CellEdge(start=ORIGIN, end=(0.0, 1.0, 0.0)),
+ )
+ cell_edges = CellEdges(edges=edges)
+ assert cell_edges.edges == edges
+ assert len(cell_edges.edges) == 2
+
+ def test_frozen_rejects_mutation(self):
+ cell_edges = CellEdges(edges=())
+ with pytest.raises(dataclasses.FrozenInstanceError):
+ cell_edges.edges = (CellEdge(start=ORIGIN, end=ORIGIN),)
+
+
+# ------------------------------------------------------------------
+# AxisArrow / AxisTriad
+# ------------------------------------------------------------------
+
+
+class TestAxisArrow:
+ def test_construction_and_fields(self):
+ arrow = AxisArrow(vector=(1.0, 0.0, 0.0), colour=RED, letter='a')
+ assert arrow.vector == (1.0, 0.0, 0.0)
+ assert arrow.colour == RED
+ assert arrow.letter == 'a'
+
+ def test_frozen_rejects_mutation(self):
+ arrow = AxisArrow(vector=(1.0, 0.0, 0.0), colour=RED, letter='a')
+ with pytest.raises(dataclasses.FrozenInstanceError):
+ arrow.letter = 'b'
+
+
+class TestAxisTriad:
+ def _triad(self):
+ return AxisTriad(
+ origin=ORIGIN,
+ axes=(
+ AxisArrow(vector=(1.0, 0.0, 0.0), colour=RED, letter='a'),
+ AxisArrow(vector=(0.0, 1.0, 0.0), colour=GREEN, letter='b'),
+ AxisArrow(vector=(0.0, 0.0, 1.0), colour=BLUE, letter='c'),
+ ),
+ )
+
+ def test_construction_and_fields(self):
+ triad = self._triad()
+ assert triad.origin == ORIGIN
+ assert len(triad.axes) == 3
+ assert [arrow.letter for arrow in triad.axes] == ['a', 'b', 'c']
+
+ def test_frozen_rejects_mutation(self):
+ triad = self._triad()
+ with pytest.raises(dataclasses.FrozenInstanceError):
+ triad.origin = (1.0, 1.0, 1.0)
+
+
+# ------------------------------------------------------------------
+# TextLabel
+# ------------------------------------------------------------------
+
+
+class TestTextLabel:
+ def test_construction_and_fields(self):
+ label = TextLabel(anchor=(0.5, 0.5, 0.5), text='Fe1')
+ assert label.anchor == (0.5, 0.5, 0.5)
+ assert label.text == 'Fe1'
+
+ def test_frozen_rejects_mutation(self):
+ label = TextLabel(anchor=ORIGIN, text='Fe1')
+ with pytest.raises(dataclasses.FrozenInstanceError):
+ label.text = 'O1'
+
+
+# ------------------------------------------------------------------
+# LegendEntry
+# ------------------------------------------------------------------
+
+
+class TestLegendEntry:
+ def test_construction_and_fields(self):
+ entry = LegendEntry(symbol='Fe', colour=RED)
+ assert entry.symbol == 'Fe'
+ assert entry.colour == RED
+
+ def test_frozen_rejects_mutation(self):
+ entry = LegendEntry(symbol='Fe', colour=RED)
+ with pytest.raises(dataclasses.FrozenInstanceError):
+ entry.symbol = 'O'
+
+
+# ------------------------------------------------------------------
+# StructureScene (the aggregate root)
+# ------------------------------------------------------------------
+
+
+class TestStructureScene:
+ def test_minimal_construction_only_basis(self):
+ scene = StructureScene(cell_basis=IDENTITY_BASIS)
+ assert scene.cell_basis == IDENTITY_BASIS
+
+ def test_collection_fields_default_empty(self):
+ scene = StructureScene(cell_basis=IDENTITY_BASIS)
+ assert scene.atoms == ()
+ assert scene.occupancy_spheres == ()
+ assert scene.ellipsoids == ()
+ assert scene.bonds == ()
+ assert scene.moments == ()
+ assert scene.labels == ()
+ assert scene.legend == ()
+
+ def test_optional_singletons_default_none(self):
+ scene = StructureScene(cell_basis=IDENTITY_BASIS)
+ assert scene.cell_edges is None
+ assert scene.axes is None
+
+ def test_fully_populated_scene(self):
+ atom = AtomSphere(centre=ORIGIN, radius=0.5, colour=RED, label='Fe')
+ occ = OccupancyWedgeSphere(
+ centre=(0.5, 0.5, 0.5),
+ radius=0.6,
+ wedges=(OccupancyWedge(fraction=1.0, colour=BLUE),),
+ label='Mn',
+ )
+ ellipsoid = AdpEllipsoid(
+ centre=(0.25, 0.25, 0.25),
+ semi_axes=(0.1, 0.2, 0.3),
+ orientation=IDENTITY_BASIS,
+ colour=GREEN,
+ label='O',
+ )
+ bond = Bond(start=ORIGIN, end=(0.5, 0.5, 0.5), start_colour=RED, end_colour=BLUE)
+ moment = MomentArrow(origin=ORIGIN, vector=(0.0, 0.0, 1.0), colour=RED)
+ cell_edges = CellEdges(edges=(CellEdge(start=ORIGIN, end=(1.0, 0.0, 0.0)),))
+ axes = AxisTriad(
+ origin=ORIGIN,
+ axes=(
+ AxisArrow(vector=(1.0, 0.0, 0.0), colour=RED, letter='a'),
+ AxisArrow(vector=(0.0, 1.0, 0.0), colour=GREEN, letter='b'),
+ AxisArrow(vector=(0.0, 0.0, 1.0), colour=BLUE, letter='c'),
+ ),
+ )
+ label = TextLabel(anchor=ORIGIN, text='Fe1')
+ legend = (LegendEntry(symbol='Fe', colour=RED),)
+
+ scene = StructureScene(
+ cell_basis=IDENTITY_BASIS,
+ atoms=(atom,),
+ occupancy_spheres=(occ,),
+ ellipsoids=(ellipsoid,),
+ bonds=(bond,),
+ moments=(moment,),
+ cell_edges=cell_edges,
+ axes=axes,
+ labels=(label,),
+ legend=legend,
+ )
+
+ assert scene.atoms == (atom,)
+ assert scene.occupancy_spheres == (occ,)
+ assert scene.ellipsoids == (ellipsoid,)
+ assert scene.bonds == (bond,)
+ assert scene.moments == (moment,)
+ assert scene.cell_edges is cell_edges
+ assert scene.axes is axes
+ assert scene.labels == (label,)
+ assert scene.legend == legend
+
+ def test_frozen_rejects_mutation(self):
+ scene = StructureScene(cell_basis=IDENTITY_BASIS)
+ with pytest.raises(dataclasses.FrozenInstanceError):
+ scene.atoms = (AtomSphere(centre=ORIGIN, radius=1.0, colour=RED, label='Fe'),)
+
+ def test_equality_by_value(self):
+ a = StructureScene(cell_basis=IDENTITY_BASIS)
+ b = StructureScene(cell_basis=IDENTITY_BASIS)
+ assert a == b
diff --git a/tests/unit/easydiffraction/display/structure/test_viewing.py b/tests/unit/easydiffraction/display/structure/test_viewing.py
new file mode 100644
index 000000000..ad423b281
--- /dev/null
+++ b/tests/unit/easydiffraction/display/structure/test_viewing.py
@@ -0,0 +1,296 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+"""Tests for display/structure/viewing.py (Viewer and ViewerFactory)."""
+
+from __future__ import annotations
+
+import pytest
+
+from easydiffraction.core.singleton import SingletonBase
+from easydiffraction.display.base import RendererBase
+from easydiffraction.display.base import RendererFactoryBase
+from easydiffraction.display.structure.enums import ViewerEngineEnum
+from easydiffraction.display.structure.renderers.ascii import AsciiStructureRenderer
+from easydiffraction.display.structure.renderers.threejs import ThreeJsStructureRenderer
+from easydiffraction.display.structure.scene import AtomSphere
+from easydiffraction.display.structure.scene import StructureScene
+from easydiffraction.display.structure.viewing import Viewer
+from easydiffraction.display.structure.viewing import ViewerFactory
+
+
+# ------------------------------------------------------------------
+# Test doubles and fixtures
+# ------------------------------------------------------------------
+
+
+class _StubBackend:
+ """A renderer-shaped stub used to verify facade delegation."""
+
+ def render(self, scene: StructureScene, *, features: frozenset[str]) -> str:
+ return f'stub atoms={len(scene.atoms)} features={sorted(features)}'
+
+ def supported_features(self) -> frozenset[str]:
+ return frozenset({'atoms', 'cell'})
+
+
+@pytest.fixture
+def viewer(monkeypatch):
+ """Yield a fresh Viewer with the singleton reset before and after."""
+ monkeypatch.setattr(Viewer, '_instance', None)
+ yield Viewer()
+ monkeypatch.setattr(Viewer, '_instance', None)
+
+
+def _minimal_scene() -> StructureScene:
+ """Build a tiny renderer-neutral scene without any engine."""
+ basis = (
+ (1.0, 0.0, 0.0),
+ (0.0, 1.0, 0.0),
+ (0.0, 0.0, 1.0),
+ )
+ atom = AtomSphere(centre=(0.0, 0.0, 0.0), radius=0.5, colour=(255, 0, 0), label='Fe')
+ return StructureScene(cell_basis=basis, atoms=(atom,))
+
+
+# ------------------------------------------------------------------
+# Module / class identity
+# ------------------------------------------------------------------
+
+
+def test_module_import():
+ import easydiffraction.display.structure.viewing as MUT
+
+ expected_module_name = 'easydiffraction.display.structure.viewing'
+ actual_module_name = MUT.__name__
+ assert expected_module_name == actual_module_name
+
+
+def test_viewer_is_renderer_base():
+ assert issubclass(Viewer, RendererBase)
+
+
+def test_viewer_is_singleton():
+ assert issubclass(Viewer, SingletonBase)
+
+
+def test_factory_is_renderer_factory_base():
+ assert issubclass(ViewerFactory, RendererFactoryBase)
+
+
+# ------------------------------------------------------------------
+# ViewerFactory
+# ------------------------------------------------------------------
+
+
+class TestViewerFactory:
+ def test_registry_keys(self):
+ registry = ViewerFactory._registry()
+ assert set(registry) == {
+ ViewerEngineEnum.ASCII.value,
+ ViewerEngineEnum.THREEJS.value,
+ }
+
+ def test_registry_entries_have_description_and_class(self):
+ registry = ViewerFactory._registry()
+ for config in registry.values():
+ assert isinstance(config['description'], str)
+ assert config['description']
+ assert isinstance(config['class'], type)
+
+ def test_supported_engines(self):
+ engines = ViewerFactory.supported_engines()
+ assert engines == ['ascii', 'threejs']
+
+ def test_descriptions_pairs(self):
+ descriptions = dict(ViewerFactory.descriptions())
+ assert descriptions['ascii'] == ViewerEngineEnum.ASCII.description()
+ assert descriptions['threejs'] == ViewerEngineEnum.THREEJS.description()
+
+ def test_create_ascii(self):
+ backend = ViewerFactory.create('ascii')
+ assert isinstance(backend, AsciiStructureRenderer)
+
+ def test_create_threejs(self):
+ backend = ViewerFactory.create('threejs')
+ assert isinstance(backend, ThreeJsStructureRenderer)
+
+ def test_create_invalid_raises(self):
+ with pytest.raises(ValueError, match='Unsupported engine'):
+ ViewerFactory.create('nonexistent')
+
+
+# ------------------------------------------------------------------
+# Construction / defaults
+# ------------------------------------------------------------------
+
+
+class TestViewerConstruction:
+ def test_factory_classmethod_returns_viewer_factory(self):
+ assert Viewer._factory() is ViewerFactory
+
+ def test_default_engine_classmethod_matches_enum_default(self):
+ assert Viewer._default_engine() == ViewerEngineEnum.default().value
+
+ def test_default_engine_is_a_supported_engine(self):
+ assert Viewer._default_engine() in ViewerFactory.supported_engines()
+
+ def test_new_instance_uses_default_engine(self, viewer):
+ assert viewer.engine == Viewer._default_engine()
+
+ def test_new_instance_backend_matches_default_engine(self, viewer):
+ # Outside Jupyter the default engine is ASCII.
+ assert viewer.engine == ViewerEngineEnum.ASCII.value
+ assert isinstance(viewer._backend, AsciiStructureRenderer)
+
+
+# ------------------------------------------------------------------
+# engine property (getter / setter)
+# ------------------------------------------------------------------
+
+
+class TestEngineProperty:
+ def test_engine_getter_returns_str(self, viewer):
+ assert isinstance(viewer.engine, str)
+
+ def test_switch_to_threejs_updates_engine_and_backend(self, viewer):
+ viewer.engine = 'threejs'
+ assert viewer.engine == 'threejs'
+ assert isinstance(viewer._backend, ThreeJsStructureRenderer)
+
+ def test_switch_to_ascii_updates_engine_and_backend(self, viewer):
+ viewer.engine = 'threejs'
+ viewer.engine = 'ascii'
+ assert viewer.engine == 'ascii'
+ assert isinstance(viewer._backend, AsciiStructureRenderer)
+
+ def test_switch_emits_change_notice(self, viewer, capsys):
+ viewer.engine = 'threejs'
+ out = capsys.readouterr().out
+ assert 'threejs' in out.lower()
+
+ def test_setting_same_engine_is_a_noop(self, viewer):
+ original_backend = viewer._backend
+ viewer.engine = viewer.engine
+ # Engine unchanged and backend instance not rebuilt.
+ assert viewer.engine == ViewerEngineEnum.ASCII.value
+ assert viewer._backend is original_backend
+
+ def test_invalid_engine_leaves_engine_unchanged(self, viewer):
+ original_engine = viewer.engine
+ original_backend = viewer._backend
+ viewer.engine = 'bogus'
+ assert viewer.engine == original_engine
+ assert viewer._backend is original_backend
+
+ def test_invalid_engine_does_not_raise(self, viewer):
+ # The setter logs a friendly warning instead of raising.
+ viewer.engine = 'bogus'
+
+ def test_non_string_engine_treated_as_unsupported(self, viewer):
+ # The setter is not @typechecked: a non-string value is simply an
+ # unsupported engine, so it warns and leaves the engine unchanged.
+ original_engine = viewer.engine
+ original_backend = viewer._backend
+ viewer.engine = 123
+ assert viewer.engine == original_engine
+ assert viewer._backend is original_backend
+
+
+# ------------------------------------------------------------------
+# render (delegation + real ASCII engine)
+# ------------------------------------------------------------------
+
+
+class TestRender:
+ def test_render_delegates_to_backend(self, viewer):
+ viewer._backend = _StubBackend()
+ scene = _minimal_scene()
+ output = viewer.render(scene, features=frozenset({'atoms'}))
+ assert output == "stub atoms=1 features=['atoms']"
+
+ def test_render_features_is_keyword_only(self, viewer):
+ viewer._backend = _StubBackend()
+ scene = _minimal_scene()
+ with pytest.raises(TypeError):
+ viewer.render(scene, frozenset({'atoms'}))
+
+ def test_render_with_real_ascii_engine_returns_text(self, viewer):
+ # Default engine is ASCII outside Jupyter; render end to end with
+ # no calculation engine and no network.
+ scene = _minimal_scene()
+ output = viewer.render(scene, features=viewer.supported_features())
+ assert isinstance(output, str)
+ assert output != ''
+
+
+# ------------------------------------------------------------------
+# supported_features (delegation + real engines)
+# ------------------------------------------------------------------
+
+
+class TestSupportedFeatures:
+ def test_supported_features_delegates_to_backend(self, viewer):
+ viewer._backend = _StubBackend()
+ assert viewer.supported_features() == frozenset({'atoms', 'cell'})
+
+ def test_ascii_supported_features(self, viewer):
+ assert viewer.engine == ViewerEngineEnum.ASCII.value
+ features = viewer.supported_features()
+ assert isinstance(features, frozenset)
+ assert features == AsciiStructureRenderer.SUPPORTED
+
+ def test_threejs_supported_features(self, viewer):
+ viewer.engine = 'threejs'
+ features = viewer.supported_features()
+ assert isinstance(features, frozenset)
+ assert features == ThreeJsStructureRenderer.SUPPORTED
+
+
+# ------------------------------------------------------------------
+# show_config and inherited inspection helpers
+# ------------------------------------------------------------------
+
+
+class TestShowHelpers:
+ def test_show_config_reports_current_engine(self, viewer, capsys):
+ viewer.show_config()
+ out = capsys.readouterr().out
+ assert ViewerEngineEnum.ASCII.value in out.lower()
+
+ def test_show_config_reflects_active_engine(self, viewer, capsys):
+ viewer.engine = 'threejs'
+ capsys.readouterr() # drop the engine-change notice
+ viewer.show_config()
+ out = capsys.readouterr().out
+ assert 'threejs' in out.lower()
+
+ def test_show_current_engine_outputs_engine(self, viewer, capsys):
+ viewer.show_current_engine()
+ out = capsys.readouterr().out
+ assert viewer.engine in out.lower()
+
+ def test_show_supported_engines_lists_every_engine(self, viewer, capsys):
+ viewer.show_supported_engines()
+ out = capsys.readouterr().out.lower()
+ for member in ViewerEngineEnum:
+ assert member.value in out
+
+
+# ------------------------------------------------------------------
+# ViewerEngineEnum (the value selector backing the engine setting)
+# ------------------------------------------------------------------
+
+
+class TestViewerEngineEnum:
+ def test_members(self):
+ assert ViewerEngineEnum.ASCII == 'ascii'
+ assert ViewerEngineEnum.THREEJS == 'threejs'
+
+ def test_default_is_a_member(self):
+ assert ViewerEngineEnum.default() in set(ViewerEngineEnum)
+
+ def test_every_member_has_a_nonempty_description(self):
+ for member in ViewerEngineEnum:
+ description = member.description()
+ assert isinstance(description, str)
+ assert description
diff --git a/tests/unit/easydiffraction/display/tablers/test_base.py b/tests/unit/easydiffraction/display/tablers/test_base.py
index 8dd7d1e92..2402a6bb9 100644
--- a/tests/unit/easydiffraction/display/tablers/test_base.py
+++ b/tests/unit/easydiffraction/display/tablers/test_base.py
@@ -48,8 +48,9 @@ def test_rich_border_color_property(self):
assert isinstance(color, str)
def test_pandas_border_color_property(self):
+ from easydiffraction.display.theme import DARK_AXIS_FRAME_COLOR
from easydiffraction.display.tablers.rich import RichTableBackend
backend = RichTableBackend()
color = backend._pandas_border_color
- assert color.startswith('#')
+ assert color == DARK_AXIS_FRAME_COLOR
diff --git a/tests/unit/easydiffraction/display/tablers/test_pandas.py b/tests/unit/easydiffraction/display/tablers/test_pandas.py
index e4a77c5b6..f8b5d93fe 100644
--- a/tests/unit/easydiffraction/display/tablers/test_pandas.py
+++ b/tests/unit/easydiffraction/display/tablers/test_pandas.py
@@ -8,14 +8,18 @@
class TestPandasTableBackend:
def test_build_base_styles(self):
+ from easydiffraction.display.tablers.pandas import PANDAS_AXIS_FRAME_COLOR
from easydiffraction.display.tablers.pandas import PandasTableBackend
backend = PandasTableBackend()
- styles = backend._build_base_styles('#aabbcc')
+ styles = backend._build_base_styles(PANDAS_AXIS_FRAME_COLOR)
assert isinstance(styles, list)
assert len(styles) > 0
selectors = [s['selector'] for s in styles]
assert 'thead' in selectors
+ assert any(
+ PANDAS_AXIS_FRAME_COLOR in value for style in styles for _, value in style['props']
+ )
def test_build_header_alignment_styles(self):
from easydiffraction.display.tablers.pandas import PandasTableBackend
@@ -35,7 +39,9 @@ def test_apply_styling_returns_styler(self):
assert hasattr(styler, 'to_html')
def test_build_renderable_returns_html(self):
+ from easydiffraction.display.tablers.pandas import PANDAS_TABLE_THEME_CLASS
from easydiffraction.display.tablers.pandas import PandasTableBackend
+ from easydiffraction.display.theme import TABLE_AXIS_FRAME_CSS_VAR
pytest.importorskip('jinja2')
backend = PandasTableBackend()
@@ -45,3 +51,6 @@ def test_build_renderable_returns_html(self):
assert isinstance(html, str)
assert 'scale',
+ 'phase. cell. length_c',
+ 'phase. scale',
+ 'phase. cell. length_c',
+ ],
+ cell_size_pixels=Plotter._correlation_cell_size_pixels(),
+ cap_width=True,
+ )['fixed_aspect_wrapper']
assert (
fig.layout.meta['fixed_aspect_wrapper']['aspect_ratio']
- == Plotter._square_matrix_layout_meta(
- n_parameters=2,
- annotation_labels=[
- 'phase. scale',
- 'phase. cell. length_c',
- 'phase. scale',
- 'phase. cell. length_c',
- ],
- )['fixed_aspect_wrapper']['aspect_ratio']
+ == correlation_wrapper_meta['aspect_ratio']
+ )
+ # Cells are capped to ~16 label characters wide via the wrapper max-width.
+ assert (
+ fig.layout.meta['fixed_aspect_wrapper']['max_width_pixels']
+ == correlation_wrapper_meta['max_width_pixels']
)
+ theme_sync = fig.layout.meta['ed_plotly_theme_sync']
+ assert theme_sync['correlation_heatmap'] is True
+ assert theme_sync['axis_frame_shape_indexes'] == list(range(len(fig.layout.shapes)))
assert fig.layout.xaxis.showline is False
assert fig.layout.xaxis.mirror is False
assert fig.layout.yaxis.showline is False
@@ -2119,6 +2146,9 @@ class Project:
assert fig.layout.plot_bgcolor is None
assert len(fig.layout.shapes) == 3
assert all(shape.type == 'rect' for shape in fig.layout.shapes)
+ assert {shape.line.color for shape in fig.layout.shapes} == {
+ plotly_mod.PlotlyPlotter._axis_frame_color(),
+ }
def test_plot_param_correlations_plotly_labels_respect_threshold(monkeypatch):
diff --git a/tests/unit/easydiffraction/display/test_theme.py b/tests/unit/easydiffraction/display/test_theme.py
new file mode 100644
index 000000000..e1ce1b99e
--- /dev/null
+++ b/tests/unit/easydiffraction/display/test_theme.py
@@ -0,0 +1,26 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+
+from __future__ import annotations
+
+
+def test_display_theme_colors_returns_light_and_dark_constants():
+ import easydiffraction.display.theme as theme
+
+ light = theme.display_theme_colors(is_dark_theme=False)
+ dark = theme.display_theme_colors(is_dark_theme=True)
+
+ assert light.background == theme.LIGHT_BACKGROUND_COLOR
+ assert light.axis_frame == theme.LIGHT_AXIS_FRAME_COLOR
+ assert light.inner_tick_grid == theme.LIGHT_INNER_TICK_GRID_COLOR
+ assert dark.background == theme.DARK_BACKGROUND_COLOR
+ assert dark.axis_frame == theme.DARK_AXIS_FRAME_COLOR
+ assert dark.inner_tick_grid == theme.DARK_INNER_TICK_GRID_COLOR
+
+
+def test_display_theme_colors_for_template_maps_plotly_templates():
+ import easydiffraction.display.theme as theme
+
+ assert theme.display_theme_colors_for_template('plotly_white') is theme.LIGHT_THEME_COLORS
+ assert theme.display_theme_colors_for_template('plotly_dark') is theme.DARK_THEME_COLORS
+ assert theme.display_theme_colors_for_template('custom') is None
diff --git a/tests/unit/easydiffraction/io/cif/test_iucr_writer.py b/tests/unit/easydiffraction/io/cif/test_iucr_writer.py
index 12217863d..6ba55e93e 100644
--- a/tests/unit/easydiffraction/io/cif/test_iucr_writer.py
+++ b/tests/unit/easydiffraction/io/cif/test_iucr_writer.py
@@ -277,6 +277,8 @@ def test_write_iucr_cif_writes_global_block(tmp_path):
assert '_computing.structure_refinement' in text
assert '_easydiffraction_software.framework' in text
assert '_easydiffraction_software.minimizer' in text
+ assert '_journal.' not in text
+ assert '_publ_' not in text
def test_write_iucr_cif_emits_single_crystal_block(tmp_path):
@@ -312,15 +314,20 @@ def test_write_iucr_cif_emits_powder_cwl_blocks(tmp_path):
text = write_iucr_cif(project).read_text(encoding='utf-8')
- assert 'data_powder_overall' in text
- assert 'data_powder_phase_1' in text
- assert 'data_powder_pwd_1' in text
+ assert 'data_overall' in text
+ assert 'data_phase1' in text
+ assert 'data_cwl' in text
+ assert '_pd_block_id phase1' in text
+ assert '_pd_block_diffractogram_id cwl' in text
+ assert '|phase1|' not in text
+ assert '|cwl|' not in text
assert '_pd_meas.2theta_scan' in text
assert '_pd_meas.time_of_flight' not in text
assert '_pd_refln.phase_id' in text
assert '_refln.phase_calc' not in text
assert '_pd_proc.info_excluded_regions' in text
assert '_easydiffraction_background.type' in text
+ assert '_pd_meas.info_author_' not in text
def test_write_iucr_cif_emits_joint_tof_pattern_blocks(tmp_path):
@@ -338,14 +345,56 @@ def test_write_iucr_cif_emits_joint_tof_pattern_blocks(tmp_path):
text = write_iucr_cif(project).read_text(encoding='utf-8')
- assert 'data_joint_pwd_1' in text
- assert 'data_joint_pwd_2' in text
+ assert 'data_tof1' in text
+ assert 'data_tof2' in text
+ assert '\n_pd_block_diffractogram_id\n tof1\n tof2\n' in text
assert '_pd_meas.time_of_flight' in text
assert '_pd_calib_d_to_tof.power' in text
assert 'recip' in text
assert '-1' in text
+def test_write_iucr_cif_keeps_powder_block_names_unique(tmp_path):
+ from easydiffraction.io.cif.iucr_writer import write_iucr_cif
+
+ project = _project(
+ 'demo',
+ tmp_path,
+ _collection(_structure(name='overall')),
+ _collection(_powder_experiment('overall')),
+ )
+
+ text = write_iucr_cif(project).read_text(encoding='utf-8')
+
+ assert 'data_overall' in text
+ assert 'data_overall_2' in text
+ assert 'data_overall_3' in text
+
+
+def test_write_iucr_cif_keeps_mixed_topology_block_names_unique(tmp_path):
+ from easydiffraction.io.cif.iucr_writer import write_iucr_cif
+
+ project = _project(
+ 'mixed',
+ tmp_path,
+ _collection(_structure(name='phase1')),
+ _collection(
+ _single_crystal_experiment('sc'),
+ _powder_experiment('cwl'),
+ ),
+ )
+
+ text = write_iucr_cif(project).read_text(encoding='utf-8')
+
+ block_names = [
+ line.removeprefix('data_') for line in text.splitlines() if line.startswith('data_')
+ ]
+ assert len(block_names) == len(set(block_names))
+ assert 'phase1' in block_names
+ assert 'phase1_2' in block_names
+ assert '_pd_block_id phase1_2' in text
+
+
def test_iucr_loop_rows_are_not_padded_to_tag_width():
from easydiffraction.io.cif.iucr_writer import _write_loop
diff --git a/tests/unit/easydiffraction/project/categories/chart/test_default.py b/tests/unit/easydiffraction/project/categories/chart/test_default.py
deleted file mode 100644
index 6cf297dec..000000000
--- a/tests/unit/easydiffraction/project/categories/chart/test_default.py
+++ /dev/null
@@ -1,104 +0,0 @@
-# SPDX-FileCopyrightText: 2026 EasyScience contributors
-# SPDX-License-Identifier: BSD-3-Clause
-
-import gemmi
-
-
-def test_chart_defaults():
- from easydiffraction.display.plotting import PlotterEngineEnum
- from easydiffraction.project.categories.chart.default import Chart
-
- chart = Chart()
-
- assert chart.type_info.tag == 'default'
- assert chart._identity.category_code == 'chart'
- assert chart.type == 'auto'
- assert chart.plotter.engine in [member.value for member in PlotterEngineEnum]
-
-
-def test_chart_plotter_binds_parent():
- from easydiffraction.project.categories.chart.default import Chart
-
- chart = Chart()
- parent = object()
- chart._parent = parent
-
- plotter = chart.plotter
-
- assert plotter._project is parent
-
-
-def test_chart_selector_updates_engine():
- from easydiffraction.display.plotting import PlotterEngineEnum
- from easydiffraction.project.categories.chart.default import Chart
-
- chart = Chart()
-
- chart._set_type('plotly')
-
- assert chart.type == 'plotly'
- assert chart.plotter.engine == 'plotly'
-
- chart._set_type('auto')
-
- assert chart.type == 'auto'
- assert chart.plotter.engine == PlotterEngineEnum.default().value
-
-
-def test_chart_from_cif_restores_type():
- from easydiffraction.project.categories.chart.default import Chart
-
- chart = Chart()
-
- swapped: list[tuple[str, dict]] = []
-
- class _Parent:
- def _swap_chart(self, new_type, *, strict):
- swapped.append((new_type, {'strict': strict}))
- chart._set_type(new_type, strict=strict)
-
- chart._parent = _Parent()
- block = gemmi.cif.read_string(
- 'data_test\n_chart.type plotly\n',
- ).sole_block()
-
- chart.from_cif(block)
-
- assert swapped == [('plotly', {'strict': False})]
- assert chart.type == 'plotly'
-
-
-def test_chart_invalid_type_assignment_raises():
- import pytest
-
- from easydiffraction.project.categories.chart.default import Chart
-
- chart = Chart()
- initial_type = chart.type
-
- with pytest.raises(ValueError, match='Unsupported chart type'):
- chart._set_type('bogus-engine')
-
- assert chart.type == initial_type
-
-
-def test_chart_from_cif_tolerates_invalid_type(monkeypatch):
- from easydiffraction.project.categories.chart import default as chart_mod
- from easydiffraction.project.categories.chart.default import Chart
-
- chart = Chart()
- chart._parent = type(
- 'P',
- (),
- {'_swap_chart': lambda self, t, *, strict: chart._set_type(t, strict=strict)},
- )()
- block = gemmi.cif.read_string(
- 'data_test\n_chart.type bogus-engine\n',
- ).sole_block()
-
- warnings: list[str] = []
- monkeypatch.setattr(chart_mod.log, 'warning', warnings.append)
- chart.from_cif(block)
-
- assert chart.type == 'auto'
- assert any('Unsupported chart type' in w for w in warnings)
diff --git a/tests/unit/easydiffraction/project/categories/chart/test_factory.py b/tests/unit/easydiffraction/project/categories/chart/test_factory.py
deleted file mode 100644
index e659835fa..000000000
--- a/tests/unit/easydiffraction/project/categories/chart/test_factory.py
+++ /dev/null
@@ -1,23 +0,0 @@
-# SPDX-FileCopyrightText: 2026 EasyScience contributors
-# SPDX-License-Identifier: BSD-3-Clause
-
-import pytest
-
-
-def test_chart_factory_default_and_create():
- from easydiffraction.project.categories.chart.default import Chart
- from easydiffraction.project.categories.chart.factory import ChartFactory
-
- assert ChartFactory.default_tag() == 'default'
- assert 'default' in ChartFactory.supported_tags()
-
- chart = ChartFactory.create('default')
-
- assert isinstance(chart, Chart)
-
-
-def test_chart_factory_rejects_unknown_tag():
- from easydiffraction.project.categories.chart.factory import ChartFactory
-
- with pytest.raises(ValueError, match=r"Unsupported type: 'missing'"):
- ChartFactory.create('missing')
diff --git a/tests/unit/easydiffraction/project/categories/publication/test_default.py b/tests/unit/easydiffraction/project/categories/publication/test_default.py
deleted file mode 100644
index 4fe0b4462..000000000
--- a/tests/unit/easydiffraction/project/categories/publication/test_default.py
+++ /dev/null
@@ -1,20 +0,0 @@
-# SPDX-FileCopyrightText: 2026 EasyScience contributors
-# SPDX-License-Identifier: BSD-3-Clause
-
-from __future__ import annotations
-
-
-def test_publication_instantiates_and_serializes_to_cif():
- from easydiffraction.project.categories.publication.default import Publication
-
- publication = Publication()
- publication.body.title = 'Refinement report'
- publication.authors.add(name='Ada Lovelace')
-
- cif_text = publication.as_cif
-
- assert not cif_text.startswith('data_')
- assert '_publ_body.title' in cif_text
- assert 'Refinement report' in cif_text
- assert '_publ_author.name' in cif_text
- assert 'Ada Lovelace' in cif_text
diff --git a/tests/unit/easydiffraction/project/categories/publication/test_factory.py b/tests/unit/easydiffraction/project/categories/publication/test_factory.py
deleted file mode 100644
index 872399697..000000000
--- a/tests/unit/easydiffraction/project/categories/publication/test_factory.py
+++ /dev/null
@@ -1,25 +0,0 @@
-# SPDX-FileCopyrightText: 2026 EasyScience contributors
-# SPDX-License-Identifier: BSD-3-Clause
-
-from __future__ import annotations
-
-import pytest
-
-
-def test_publication_factory_default_and_create():
- from easydiffraction.project.categories.publication.default import Publication
- from easydiffraction.project.categories.publication.factory import PublicationFactory
-
- assert PublicationFactory.default_tag() == 'default'
- assert 'default' in PublicationFactory.supported_tags()
-
- publication = PublicationFactory.create('default')
-
- assert isinstance(publication, Publication)
-
-
-def test_publication_factory_rejects_unknown_tag():
- from easydiffraction.project.categories.publication.factory import PublicationFactory
-
- with pytest.raises(ValueError, match=r"Unsupported type: 'missing'"):
- PublicationFactory.create('missing')
diff --git a/tests/unit/easydiffraction/project/categories/rendering_plot/test_default.py b/tests/unit/easydiffraction/project/categories/rendering_plot/test_default.py
new file mode 100644
index 000000000..8686b65e3
--- /dev/null
+++ b/tests/unit/easydiffraction/project/categories/rendering_plot/test_default.py
@@ -0,0 +1,114 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+
+import gemmi
+
+
+def test_rendering_plot_defaults():
+ from easydiffraction.display.plotting import PlotterEngineEnum
+ from easydiffraction.project.categories.rendering_plot.default import RenderingPlot
+
+ rendering_plot = RenderingPlot()
+
+ assert rendering_plot.type_info.tag == 'default'
+ assert rendering_plot._identity.category_code == 'rendering_plot'
+ assert rendering_plot.type == 'auto'
+ assert rendering_plot.plotter.engine in [member.value for member in PlotterEngineEnum]
+
+
+def test_rendering_plot_plotter_binds_parent():
+ from easydiffraction.project.categories.rendering_plot.default import RenderingPlot
+
+ rendering_plot = RenderingPlot()
+ parent = object()
+ rendering_plot._parent = parent
+
+ plotter = rendering_plot.plotter
+
+ assert plotter._project is parent
+
+
+def test_rendering_plot_selector_updates_engine():
+ from easydiffraction.display.plotting import PlotterEngineEnum
+ from easydiffraction.project.categories.rendering_plot.default import RenderingPlot
+
+ rendering_plot = RenderingPlot()
+
+ rendering_plot._set_type('plotly')
+
+ assert rendering_plot.type == 'plotly'
+ assert rendering_plot.plotter.engine == 'plotly'
+
+ rendering_plot._set_type('auto')
+
+ assert rendering_plot.type == 'auto'
+ assert rendering_plot.plotter.engine == PlotterEngineEnum.default().value
+
+
+def test_rendering_plot_from_cif_restores_type():
+ from easydiffraction.project.categories.rendering_plot.default import RenderingPlot
+
+ rendering_plot = RenderingPlot()
+
+ swapped: list[tuple[str, dict]] = []
+
+ class _Parent:
+ def _swap_rendering_plot(self, new_type, *, strict):
+ swapped.append((new_type, {'strict': strict}))
+ rendering_plot._set_type(new_type, strict=strict)
+
+ rendering_plot._parent = _Parent()
+ block = gemmi.cif.read_string(
+ 'data_test\n_rendering_plot.type plotly\n',
+ ).sole_block()
+
+ rendering_plot.from_cif(block)
+
+ assert swapped == [('plotly', {'strict': False})]
+ assert rendering_plot.type == 'plotly'
+
+
+def test_rendering_plot_invalid_type_assignment_raises():
+ import pytest
+
+ from easydiffraction.project.categories.rendering_plot.default import RenderingPlot
+
+ rendering_plot = RenderingPlot()
+ initial_type = rendering_plot.type
+
+ with pytest.raises(ValueError, match='Unsupported rendering_plot type'):
+ rendering_plot._set_type('bogus-engine')
+
+ assert rendering_plot.type == initial_type
+
+
+def test_rendering_plot_from_cif_tolerates_invalid_type(monkeypatch):
+ from easydiffraction.project.categories.rendering_plot import default as rendering_plot_mod
+ from easydiffraction.project.categories.rendering_plot.default import RenderingPlot
+ from easydiffraction.utils.logging import Logger
+
+ # The descriptor's own from_cif validation routes through the
+ # Logger; force WARN so it logs rather than raises (another test
+ # may have left the Logger in RAISE mode).
+ monkeypatch.setattr(Logger, '_reaction', Logger.Reaction.WARN, raising=True)
+
+ rendering_plot = RenderingPlot()
+ rendering_plot._parent = type(
+ 'P',
+ (),
+ {
+ '_swap_rendering_plot': lambda self, t, *, strict: rendering_plot._set_type(
+ t, strict=strict
+ )
+ },
+ )()
+ block = gemmi.cif.read_string(
+ 'data_test\n_rendering_plot.type bogus-engine\n',
+ ).sole_block()
+
+ warnings: list[str] = []
+ monkeypatch.setattr(rendering_plot_mod.log, 'warning', warnings.append)
+ rendering_plot.from_cif(block)
+
+ assert rendering_plot.type == 'auto'
+ assert any('Unsupported rendering_plot type' in w for w in warnings)
diff --git a/tests/unit/easydiffraction/project/categories/rendering_plot/test_factory.py b/tests/unit/easydiffraction/project/categories/rendering_plot/test_factory.py
new file mode 100644
index 000000000..46f8c8944
--- /dev/null
+++ b/tests/unit/easydiffraction/project/categories/rendering_plot/test_factory.py
@@ -0,0 +1,23 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+
+import pytest
+
+
+def test_rendering_plot_factory_default_and_create():
+ from easydiffraction.project.categories.rendering_plot.default import RenderingPlot
+ from easydiffraction.project.categories.rendering_plot.factory import RenderingPlotFactory
+
+ assert RenderingPlotFactory.default_tag() == 'default'
+ assert 'default' in RenderingPlotFactory.supported_tags()
+
+ rendering_plot = RenderingPlotFactory.create('default')
+
+ assert isinstance(rendering_plot, RenderingPlot)
+
+
+def test_rendering_plot_factory_rejects_unknown_tag():
+ from easydiffraction.project.categories.rendering_plot.factory import RenderingPlotFactory
+
+ with pytest.raises(ValueError, match=r"Unsupported type: 'missing'"):
+ RenderingPlotFactory.create('missing')
diff --git a/tests/unit/easydiffraction/project/categories/rendering_structure/test_default.py b/tests/unit/easydiffraction/project/categories/rendering_structure/test_default.py
new file mode 100644
index 000000000..68152b9a1
--- /dev/null
+++ b/tests/unit/easydiffraction/project/categories/rendering_structure/test_default.py
@@ -0,0 +1,394 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+"""Tests for rendering_structure category (default switchable engine)."""
+
+from __future__ import annotations
+
+import gemmi
+import pytest
+
+from easydiffraction.display.structure.enums import ViewerEngineEnum
+from easydiffraction.display.structure.viewing import Viewer
+from easydiffraction.display.structure.viewing import ViewerFactory
+from easydiffraction.project.categories.rendering_structure import default as rs_mod
+from easydiffraction.project.categories.rendering_structure.default import AUTO_DESCRIPTION
+from easydiffraction.project.categories.rendering_structure.default import AUTO_ENGINE
+from easydiffraction.project.categories.rendering_structure.default import VIEW_ENGINE_OPTIONS
+from easydiffraction.project.categories.rendering_structure.default import RenderingStructure
+from easydiffraction.project.categories.rendering_structure.factory import (
+ RenderingStructureFactory,
+)
+from easydiffraction.utils.logging import Logger
+
+
+def _default_engine() -> str:
+ """Resolve the environment default engine deterministically."""
+ return ViewerEngineEnum.default().value
+
+
+# ----------------------------------------------------------------------
+# Module-level constants
+# ----------------------------------------------------------------------
+
+
+class TestModuleConstants:
+ def test_auto_engine_constant(self):
+ assert AUTO_ENGINE == 'auto'
+
+ def test_auto_description_is_nonempty_str(self):
+ assert isinstance(AUTO_DESCRIPTION, str)
+ assert AUTO_DESCRIPTION
+
+ def test_view_engine_options_lists_auto_first(self):
+ assert VIEW_ENGINE_OPTIONS[0] == AUTO_ENGINE
+
+ def test_view_engine_options_includes_every_enum_member(self):
+ for member in ViewerEngineEnum:
+ assert member.value in VIEW_ENGINE_OPTIONS
+
+ def test_view_engine_options_has_no_extra_entries(self):
+ expected = {AUTO_ENGINE, *(m.value for m in ViewerEngineEnum)}
+ assert set(VIEW_ENGINE_OPTIONS) == expected
+
+
+# ----------------------------------------------------------------------
+# Factory registration
+# ----------------------------------------------------------------------
+
+
+class TestRenderingStructureFactory:
+ def test_supported_tags(self):
+ assert 'default' in RenderingStructureFactory.supported_tags()
+
+ def test_default_tag(self):
+ assert RenderingStructureFactory.default_tag() == 'default'
+
+ def test_create_returns_rendering_structure(self):
+ obj = RenderingStructureFactory.create('default')
+ assert isinstance(obj, RenderingStructure)
+
+
+# ----------------------------------------------------------------------
+# Construction / defaults
+# ----------------------------------------------------------------------
+
+
+class TestConstructionAndDefaults:
+ def test_type_info_tag(self):
+ assert RenderingStructure.type_info.tag == 'default'
+
+ def test_category_class_attrs(self):
+ assert RenderingStructure._category_code == 'rendering_structure'
+ assert RenderingStructure._owner_attr_name == 'rendering_structure'
+ assert RenderingStructure._swap_method_name == '_swap_rendering_structure'
+
+ def test_identity_category_code(self):
+ rs = RenderingStructure()
+ assert rs._identity.category_code == 'rendering_structure'
+
+ def test_default_type_is_auto(self):
+ rs = RenderingStructure()
+ assert rs.type == AUTO_ENGINE
+
+ def test_default_type_descriptor_value(self):
+ rs = RenderingStructure()
+ assert rs._type.value == AUTO_ENGINE
+
+ def test_viewer_is_viewer_instance(self):
+ rs = RenderingStructure()
+ assert isinstance(rs.viewer, Viewer)
+
+ def test_viewer_engine_is_environment_default(self):
+ rs = RenderingStructure()
+ assert rs.viewer.engine == _default_engine()
+
+ def test_viewer_engine_is_supported(self):
+ rs = RenderingStructure()
+ assert rs.viewer.engine in ViewerFactory.supported_engines()
+
+ def test_type_cif_handler_name(self):
+ rs = RenderingStructure()
+ assert rs._type._cif_handler.names == ['_rendering_structure.type']
+
+ def test_parent_starts_detached(self):
+ rs = RenderingStructure()
+ assert rs._parent is None
+
+
+# ----------------------------------------------------------------------
+# viewer property
+# ----------------------------------------------------------------------
+
+
+class TestViewerProperty:
+ def test_viewer_is_stable_reference(self):
+ rs = RenderingStructure()
+ assert rs.viewer is rs.viewer
+
+ def test_distinct_instances_have_distinct_viewers(self):
+ first = RenderingStructure()
+ second = RenderingStructure()
+ # Viewer is constructed (not fetched as singleton) per category,
+ # so each category owns an independent facade.
+ assert first.viewer is not second.viewer
+
+
+# ----------------------------------------------------------------------
+# _resolved_engine
+# ----------------------------------------------------------------------
+
+
+class TestResolvedEngine:
+ def test_auto_resolves_to_environment_default(self):
+ assert RenderingStructure._resolved_engine(AUTO_ENGINE) == _default_engine()
+
+ def test_explicit_ascii_passes_through(self):
+ assert RenderingStructure._resolved_engine('ascii') == 'ascii'
+
+ def test_explicit_threejs_passes_through(self):
+ assert RenderingStructure._resolved_engine('threejs') == 'threejs'
+
+
+# ----------------------------------------------------------------------
+# _set_type (validator: valid + invalid)
+# ----------------------------------------------------------------------
+
+
+class TestSetType:
+ def test_set_explicit_engine_updates_type_and_viewer(self):
+ rs = RenderingStructure()
+ rs._set_type('threejs')
+ assert rs.type == 'threejs'
+ assert rs.viewer.engine == 'threejs'
+
+ def test_set_ascii_updates_viewer_engine(self):
+ rs = RenderingStructure()
+ rs._set_type('ascii')
+ assert rs.type == 'ascii'
+ assert rs.viewer.engine == 'ascii'
+
+ def test_set_auto_resolves_viewer_to_environment_default(self):
+ rs = RenderingStructure()
+ rs._set_type('threejs')
+ rs._set_type('auto')
+ assert rs.type == 'auto'
+ assert rs.viewer.engine == _default_engine()
+
+ def test_invalid_type_strict_raises_value_error(self):
+ rs = RenderingStructure()
+ initial = rs.type
+ with pytest.raises(ValueError, match='Unsupported rendering_structure type'):
+ rs._set_type('bogus-engine')
+ assert rs.type == initial
+
+ def test_invalid_type_strict_leaves_viewer_unchanged(self):
+ rs = RenderingStructure()
+ engine_before = rs.viewer.engine
+ with pytest.raises(ValueError, match='Unsupported rendering_structure type'):
+ rs._set_type('bogus-engine')
+ assert rs.viewer.engine == engine_before
+
+ def test_invalid_type_non_strict_warns_and_keeps_value(self, monkeypatch):
+ rs = RenderingStructure()
+ warnings: list[str] = []
+ monkeypatch.setattr(rs_mod.log, 'warning', warnings.append)
+ rs._set_type('bogus-engine', strict=False)
+ assert rs.type == AUTO_ENGINE
+ assert any('Unsupported rendering_structure type' in w for w in warnings)
+
+ def test_strict_error_message_points_to_show_supported(self):
+ rs = RenderingStructure()
+ with pytest.raises(ValueError, match=r'rendering_structure\.show_supported'):
+ rs._set_type('nope')
+
+
+# ----------------------------------------------------------------------
+# _supported_types
+# ----------------------------------------------------------------------
+
+
+class TestSupportedTypes:
+ def test_includes_auto_pair_first(self):
+ pairs = RenderingStructure._supported_types({})
+ assert pairs[0] == (AUTO_ENGINE, AUTO_DESCRIPTION)
+
+ def test_lists_every_engine_from_factory(self):
+ pairs = RenderingStructure._supported_types({})
+ tags = [tag for tag, _ in pairs]
+ for engine in ViewerFactory.supported_engines():
+ assert engine in tags
+
+ def test_descriptions_are_strings(self):
+ pairs = RenderingStructure._supported_types({})
+ assert all(isinstance(desc, str) and desc for _, desc in pairs)
+
+ def test_filters_argument_is_ignored(self):
+ with_filters = RenderingStructure._supported_types({'anything': object()})
+ without_filters = RenderingStructure._supported_types({})
+ assert with_filters == without_filters
+
+
+# ----------------------------------------------------------------------
+# show_supported (inherited; lists enum values)
+# ----------------------------------------------------------------------
+
+
+class TestShowSupported:
+ def test_runs_without_parent(self, capsys):
+ rs = RenderingStructure()
+ rs.show_supported()
+ out = capsys.readouterr().out
+ assert out # produced a table
+
+ def test_lists_auto_and_every_engine(self, capsys):
+ rs = RenderingStructure()
+ rs.show_supported()
+ out = capsys.readouterr().out
+ assert AUTO_ENGINE in out
+ for member in ViewerEngineEnum:
+ assert member.value in out
+
+ def test_marks_active_type(self, capsys):
+ rs = RenderingStructure()
+ rs._set_type('threejs')
+ rs.show_supported()
+ out = capsys.readouterr().out
+ assert '*' in out
+ assert 'threejs' in out
+
+
+# ----------------------------------------------------------------------
+# type setter (SwitchableCategoryBase contract via parent)
+# ----------------------------------------------------------------------
+
+
+class TestTypeSetter:
+ def test_detached_instance_raises_runtime_error(self):
+ rs = RenderingStructure()
+ with pytest.raises(RuntimeError, match='detached'):
+ rs.type = 'threejs'
+
+ def test_setter_routes_through_owner_swap(self):
+ rs = RenderingStructure()
+
+ class _Owner:
+ rendering_structure = rs
+
+ @staticmethod
+ def _supported_filters_for(_category):
+ return {}
+
+ def _swap_rendering_structure(self, new_type, *, strict=True):
+ rs._set_type(new_type, strict=strict)
+
+ rs._parent = _Owner()
+ rs.type = 'threejs'
+ assert rs.type == 'threejs'
+ assert rs.viewer.engine == 'threejs'
+
+ def test_setter_on_stale_instance_raises(self):
+ rs = RenderingStructure()
+ live = RenderingStructure()
+
+ class _Owner:
+ # Owner's live category is a different instance.
+ rendering_structure = live
+
+ def _swap_rendering_structure(self, new_type, *, strict=True): # pragma: no cover
+ live._set_type(new_type, strict=strict)
+
+ rs._parent = _Owner()
+ with pytest.raises(RuntimeError, match='no longer the live category'):
+ rs.type = 'threejs'
+
+
+# ----------------------------------------------------------------------
+# from_cif
+# ----------------------------------------------------------------------
+
+
+class TestFromCif:
+ def _block(self, cif_text: str):
+ return gemmi.cif.read_string(cif_text).sole_block()
+
+ def test_restores_type_via_parent_swap(self):
+ rs = RenderingStructure()
+ swapped: list[tuple[str, dict]] = []
+
+ class _Parent:
+ def _swap_rendering_structure(self, new_type, *, strict):
+ swapped.append((new_type, {'strict': strict}))
+ rs._set_type(new_type, strict=strict)
+
+ rs._parent = _Parent()
+ block = self._block('data_test\n_rendering_structure.type threejs\n')
+ rs.from_cif(block)
+ assert swapped == [('threejs', {'strict': False})]
+ assert rs.type == 'threejs'
+ assert rs.viewer.engine == 'threejs'
+
+ def test_tolerates_invalid_type(self, monkeypatch):
+ # The descriptor's own from_cif validation routes through the
+ # Logger; force WARN so it logs rather than raises (another test
+ # may have left the Logger in RAISE mode).
+ monkeypatch.setattr(Logger, '_reaction', Logger.Reaction.WARN, raising=True)
+
+ rs = RenderingStructure()
+
+ class _Parent:
+ def _swap_rendering_structure(self, new_type, *, strict):
+ rs._set_type(new_type, strict=strict)
+
+ rs._parent = _Parent()
+ block = self._block('data_test\n_rendering_structure.type bogus-engine\n')
+ warnings: list[str] = []
+ monkeypatch.setattr(rs_mod.log, 'warning', warnings.append)
+ rs.from_cif(block)
+ assert rs.type == AUTO_ENGINE
+ assert any('Unsupported rendering_structure type' in w for w in warnings)
+
+ def test_missing_type_leaves_default(self):
+ rs = RenderingStructure()
+ called: list[str] = []
+
+ class _Parent:
+ def _swap_rendering_structure(self, new_type, *, strict): # pragma: no cover
+ called.append(new_type)
+
+ rs._parent = _Parent()
+ block = self._block('data_test\n_other.value 1\n')
+ rs.from_cif(block)
+ assert rs.type == AUTO_ENGINE
+ assert called == []
+
+
+# ----------------------------------------------------------------------
+# as_cif (round-trip)
+# ----------------------------------------------------------------------
+
+
+class TestAsCif:
+ def test_as_cif_contains_handler_name(self):
+ rs = RenderingStructure()
+ assert '_rendering_structure.type' in rs.as_cif
+
+ def test_as_cif_reflects_explicit_engine(self):
+ rs = RenderingStructure()
+ rs._set_type('threejs')
+ assert 'threejs' in rs.as_cif
+
+ def test_as_cif_round_trip_restores_type(self):
+ rs = RenderingStructure()
+ rs._set_type('threejs')
+ cif_text = rs.as_cif
+
+ restored = RenderingStructure()
+
+ class _Parent:
+ def _swap_rendering_structure(self, new_type, *, strict):
+ restored._set_type(new_type, strict=strict)
+
+ restored._parent = _Parent()
+ block = gemmi.cif.read_string(f'data_test\n{cif_text}\n').sole_block()
+ restored.from_cif(block)
+ assert restored.type == 'threejs'
diff --git a/tests/unit/easydiffraction/project/categories/rendering_structure/test_factory.py b/tests/unit/easydiffraction/project/categories/rendering_structure/test_factory.py
new file mode 100644
index 000000000..40e0bb399
--- /dev/null
+++ b/tests/unit/easydiffraction/project/categories/rendering_structure/test_factory.py
@@ -0,0 +1,113 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+"""Tests for the project rendering_structure factory."""
+
+from __future__ import annotations
+
+import pytest
+
+
+def test_module_import():
+ import easydiffraction.project.categories.rendering_structure.factory as MUT
+
+ expected_module_name = 'easydiffraction.project.categories.rendering_structure.factory'
+ assert MUT.__name__ == expected_module_name
+
+
+def test_default_rules_universal_fallback():
+ from easydiffraction.project.categories.rendering_structure.factory import (
+ RenderingStructureFactory,
+ )
+
+ # The factory declares a single universal-fallback rule.
+ assert RenderingStructureFactory._default_rules == {frozenset(): 'default'}
+
+
+def test_supported_tags_lists_default():
+ from easydiffraction.project.categories.rendering_structure.factory import (
+ RenderingStructureFactory,
+ )
+
+ tags = RenderingStructureFactory.supported_tags()
+ assert isinstance(tags, list)
+ assert 'default' in tags
+
+
+def test_default_tag_without_conditions():
+ from easydiffraction.project.categories.rendering_structure.factory import (
+ RenderingStructureFactory,
+ )
+
+ assert RenderingStructureFactory.default_tag() == 'default'
+
+
+def test_default_tag_with_unmatched_conditions_falls_back():
+ from easydiffraction.project.categories.rendering_structure.factory import (
+ RenderingStructureFactory,
+ )
+
+ # Extra conditions still match the empty-key universal fallback.
+ assert RenderingStructureFactory.default_tag(scattering_type='bragg') == 'default'
+
+
+def test_create_returns_rendering_structure():
+ from easydiffraction.project.categories.rendering_structure.default import RenderingStructure
+ from easydiffraction.project.categories.rendering_structure.factory import (
+ RenderingStructureFactory,
+ )
+
+ rendering_structure = RenderingStructureFactory.create('default')
+ assert isinstance(rendering_structure, RenderingStructure)
+
+
+def test_create_rejects_unknown_tag():
+ from easydiffraction.project.categories.rendering_structure.factory import (
+ RenderingStructureFactory,
+ )
+
+ with pytest.raises(ValueError, match=r"Unsupported type: 'missing'"):
+ RenderingStructureFactory.create('missing')
+
+
+def test_create_default_for_returns_rendering_structure():
+ from easydiffraction.project.categories.rendering_structure.default import RenderingStructure
+ from easydiffraction.project.categories.rendering_structure.factory import (
+ RenderingStructureFactory,
+ )
+
+ rendering_structure = RenderingStructureFactory.create_default_for()
+ assert isinstance(rendering_structure, RenderingStructure)
+
+
+def test_supported_for_includes_registered_class():
+ from easydiffraction.project.categories.rendering_structure.default import RenderingStructure
+ from easydiffraction.project.categories.rendering_structure.factory import (
+ RenderingStructureFactory,
+ )
+
+ supported = RenderingStructureFactory.supported_for()
+ assert RenderingStructure in supported
+
+
+def test_show_supported_lists_default(capsys):
+ from easydiffraction.project.categories.rendering_structure.factory import (
+ RenderingStructureFactory,
+ )
+
+ RenderingStructureFactory.show_supported()
+ out = capsys.readouterr().out
+ assert 'Supported types' in out
+ assert 'default' in out
+
+
+def test_registry_is_independent_from_base():
+ from easydiffraction.core.factory import FactoryBase
+ from easydiffraction.project.categories.rendering_structure.default import RenderingStructure
+ from easydiffraction.project.categories.rendering_structure.factory import (
+ RenderingStructureFactory,
+ )
+
+ # __init_subclass__ gives each factory its own registry; the
+ # registered concrete class must not leak onto the shared base.
+ assert RenderingStructure in RenderingStructureFactory._registry
+ assert RenderingStructure not in FactoryBase._registry
diff --git a/tests/unit/easydiffraction/project/categories/table/test_default.py b/tests/unit/easydiffraction/project/categories/rendering_table/test_default.py
similarity index 64%
rename from tests/unit/easydiffraction/project/categories/table/test_default.py
rename to tests/unit/easydiffraction/project/categories/rendering_table/test_default.py
index 50dbccf8e..943b21d12 100644
--- a/tests/unit/easydiffraction/project/categories/table/test_default.py
+++ b/tests/unit/easydiffraction/project/categories/rendering_table/test_default.py
@@ -6,21 +6,21 @@
def test_table_defaults():
from easydiffraction.display.tables import TableEngineEnum
- from easydiffraction.project.categories.table.default import Table
+ from easydiffraction.project.categories.rendering_table.default import RenderingTable
- table = Table()
+ table = RenderingTable()
assert table.type_info.tag == 'default'
- assert table._identity.category_code == 'table'
+ assert table._identity.category_code == 'rendering_table'
assert table.type == 'auto'
assert table.tabler.engine in [member.value for member in TableEngineEnum]
def test_table_selector_updates_engine():
from easydiffraction.display.tables import TableEngineEnum
- from easydiffraction.project.categories.table.default import Table
+ from easydiffraction.project.categories.rendering_table.default import RenderingTable
- table = Table()
+ table = RenderingTable()
table._set_type('rich')
@@ -34,20 +34,20 @@ def test_table_selector_updates_engine():
def test_table_from_cif_restores_type():
- from easydiffraction.project.categories.table.default import Table
+ from easydiffraction.project.categories.rendering_table.default import RenderingTable
- table = Table()
+ table = RenderingTable()
swapped: list[tuple[str, dict]] = []
class _Parent:
- def _swap_table(self, new_type, *, strict):
+ def _swap_rendering_table(self, new_type, *, strict):
swapped.append((new_type, {'strict': strict}))
table._set_type(new_type, strict=strict)
table._parent = _Parent()
block = gemmi.cif.read_string(
- 'data_test\n_table.type rich\n',
+ 'data_test\n_rendering_table.type rich\n',
).sole_block()
table.from_cif(block)
@@ -59,12 +59,12 @@ def _swap_table(self, new_type, *, strict):
def test_table_invalid_type_assignment_raises():
import pytest
- from easydiffraction.project.categories.table.default import Table
+ from easydiffraction.project.categories.rendering_table.default import RenderingTable
- table = Table()
+ table = RenderingTable()
initial_type = table.type
- with pytest.raises(ValueError, match='Unsupported table type'):
+ with pytest.raises(ValueError, match='Unsupported rendering_table type'):
table._set_type('bogus-engine')
assert table.type == initial_type
diff --git a/tests/unit/easydiffraction/project/categories/rendering_table/test_factory.py b/tests/unit/easydiffraction/project/categories/rendering_table/test_factory.py
new file mode 100644
index 000000000..6624e40b7
--- /dev/null
+++ b/tests/unit/easydiffraction/project/categories/rendering_table/test_factory.py
@@ -0,0 +1,23 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+
+import pytest
+
+
+def test_table_factory_default_and_create():
+ from easydiffraction.project.categories.rendering_table.default import RenderingTable
+ from easydiffraction.project.categories.rendering_table.factory import RenderingTableFactory
+
+ assert RenderingTableFactory.default_tag() == 'default'
+ assert 'default' in RenderingTableFactory.supported_tags()
+
+ table = RenderingTableFactory.create('default')
+
+ assert isinstance(table, RenderingTable)
+
+
+def test_table_factory_rejects_unknown_tag():
+ from easydiffraction.project.categories.rendering_table.factory import RenderingTableFactory
+
+ with pytest.raises(ValueError, match=r"Unsupported type: 'missing'"):
+ RenderingTableFactory.create('missing')
diff --git a/tests/unit/easydiffraction/project/categories/structure_style/test_default.py b/tests/unit/easydiffraction/project/categories/structure_style/test_default.py
new file mode 100644
index 000000000..a2b7d8190
--- /dev/null
+++ b/tests/unit/easydiffraction/project/categories/structure_style/test_default.py
@@ -0,0 +1,387 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+"""Tests for the project structure_style category (default)."""
+
+from __future__ import annotations
+
+import gemmi
+import pytest
+
+
+def test_module_import():
+ import easydiffraction.project.categories.structure_style.default as MUT
+
+ expected_module_name = 'easydiffraction.project.categories.structure_style.default'
+ assert MUT.__name__ == expected_module_name
+
+
+# ----------------------------------------------------------------------
+# Factory registration
+# ----------------------------------------------------------------------
+
+
+class TestStructureStyleFactory:
+ def test_supported_tags(self):
+ from easydiffraction.project.categories.structure_style.factory import (
+ StructureStyleFactory,
+ )
+
+ assert 'default' in StructureStyleFactory.supported_tags()
+
+ def test_default_tag(self):
+ from easydiffraction.project.categories.structure_style.factory import (
+ StructureStyleFactory,
+ )
+
+ assert StructureStyleFactory.default_tag() == 'default'
+
+ def test_create_returns_structure_style(self):
+ from easydiffraction.project.categories.structure_style.default import StructureStyle
+ from easydiffraction.project.categories.structure_style.factory import (
+ StructureStyleFactory,
+ )
+
+ obj = StructureStyleFactory.create('default')
+ assert isinstance(obj, StructureStyle)
+
+
+# ----------------------------------------------------------------------
+# Identity, type_info and defaults
+# ----------------------------------------------------------------------
+
+
+class TestStructureStyleIdentityAndDefaults:
+ def test_type_info(self):
+ from easydiffraction.project.categories.structure_style.default import StructureStyle
+
+ assert StructureStyle.type_info.tag == 'default'
+ assert StructureStyle.type_info.description != ''
+
+ def test_category_code(self):
+ from easydiffraction.project.categories.structure_style.default import StructureStyle
+
+ style = StructureStyle()
+ assert style._identity.category_code == 'structure_style'
+
+ def test_instantiation(self):
+ from easydiffraction.project.categories.structure_style.default import StructureStyle
+
+ style = StructureStyle()
+ assert style is not None
+
+ def test_default_values(self):
+ from easydiffraction.project.categories.structure_style.default import StructureStyle
+
+ style = StructureStyle()
+ assert style.atom_view.value == 'covalent'
+ assert style.color_scheme.value == 'jmol'
+ assert style.adp_probability.value == 0.99
+ assert style.atom_scale.value == 0.3
+
+ def test_defaults_track_enum_defaults(self):
+ from easydiffraction.display.structure.enums import AtomViewEnum
+ from easydiffraction.display.structure.enums import ColorSchemeEnum
+ from easydiffraction.project.categories.structure_style.default import StructureStyle
+
+ style = StructureStyle()
+ assert style.atom_view.value == AtomViewEnum.default().value
+ assert style.color_scheme.value == ColorSchemeEnum.default().value
+
+ def test_enum_descriptors_expose_backing_enum(self):
+ from easydiffraction.display.structure.enums import AtomViewEnum
+ from easydiffraction.display.structure.enums import ColorSchemeEnum
+ from easydiffraction.project.categories.structure_style.default import StructureStyle
+
+ style = StructureStyle()
+ assert style.atom_view.enum is AtomViewEnum
+ assert style.color_scheme.enum is ColorSchemeEnum
+
+ def test_parameters_collects_all_descriptors(self):
+ from easydiffraction.project.categories.structure_style.default import StructureStyle
+
+ style = StructureStyle()
+ names = {param.name for param in style.parameters}
+ assert names == {'atom_view', 'color_scheme', 'adp_probability', 'atom_scale'}
+
+
+# ----------------------------------------------------------------------
+# CIF handler names
+# ----------------------------------------------------------------------
+
+
+class TestStructureStyleCifHandlerNames:
+ def test_cif_handler_names(self):
+ from easydiffraction.project.categories.structure_style.default import StructureStyle
+
+ style = StructureStyle()
+ assert style.atom_view._cif_handler.names == ['_structure_style.atom_view']
+ assert style.color_scheme._cif_handler.names == ['_structure_style.color_scheme']
+ assert style.adp_probability._cif_handler.names == ['_structure_style.adp_probability']
+ assert style.atom_scale._cif_handler.names == ['_structure_style.atom_scale']
+
+
+# ----------------------------------------------------------------------
+# atom_view selector
+# ----------------------------------------------------------------------
+
+
+class TestAtomViewSelector:
+ def test_setter_accepts_valid_values(self):
+ from easydiffraction.project.categories.structure_style.default import StructureStyle
+
+ style = StructureStyle()
+ for value in ('vdw', 'covalent', 'ionic', 'adp'):
+ style.atom_view = value
+ assert style.atom_view.value == value
+
+ def test_setter_rejects_invalid_value(self):
+ from easydiffraction.project.categories.structure_style.default import StructureStyle
+
+ style = StructureStyle()
+ with pytest.raises(ValueError, match='not a valid AtomViewEnum'):
+ style.atom_view = 'bogus'
+
+ def test_invalid_value_keeps_previous(self):
+ from easydiffraction.project.categories.structure_style.default import StructureStyle
+
+ style = StructureStyle()
+ style.atom_view = 'vdw'
+ with pytest.raises(ValueError, match='not a valid AtomViewEnum'):
+ style.atom_view = 'bogus'
+ assert style.atom_view.value == 'vdw'
+
+ def test_show_supported_lists_all_enum_values(self, capsys):
+ from easydiffraction.display.structure.enums import AtomViewEnum
+ from easydiffraction.project.categories.structure_style.default import StructureStyle
+
+ style = StructureStyle()
+ style.atom_view.show_supported()
+
+ out = capsys.readouterr().out
+ for member in AtomViewEnum:
+ assert member.value in out
+
+ def test_show_supported_marks_active_value(self, capsys):
+ from easydiffraction.project.categories.structure_style.default import StructureStyle
+
+ style = StructureStyle()
+ style.atom_view = 'ionic'
+ style.atom_view.show_supported()
+
+ out = capsys.readouterr().out
+ # Active value is annotated with an asterisk in its row.
+ marked_line = next(line for line in out.splitlines() if 'ionic' in line)
+ assert '*' in marked_line
+
+
+# ----------------------------------------------------------------------
+# color_scheme selector
+# ----------------------------------------------------------------------
+
+
+class TestColorSchemeSelector:
+ def test_setter_accepts_valid_values(self):
+ from easydiffraction.project.categories.structure_style.default import StructureStyle
+
+ style = StructureStyle()
+ for value in ('jmol', 'vesta'):
+ style.color_scheme = value
+ assert style.color_scheme.value == value
+
+ def test_setter_rejects_invalid_value(self):
+ from easydiffraction.project.categories.structure_style.default import StructureStyle
+
+ style = StructureStyle()
+ with pytest.raises(ValueError, match='not a valid ColorSchemeEnum'):
+ style.color_scheme = 'bogus'
+
+ def test_invalid_value_keeps_previous(self):
+ from easydiffraction.project.categories.structure_style.default import StructureStyle
+
+ style = StructureStyle()
+ style.color_scheme = 'vesta'
+ with pytest.raises(ValueError, match='not a valid ColorSchemeEnum'):
+ style.color_scheme = 'bogus'
+ assert style.color_scheme.value == 'vesta'
+
+ def test_show_supported_lists_all_enum_values(self, capsys):
+ from easydiffraction.display.structure.enums import ColorSchemeEnum
+ from easydiffraction.project.categories.structure_style.default import StructureStyle
+
+ style = StructureStyle()
+ style.color_scheme.show_supported()
+
+ out = capsys.readouterr().out
+ for member in ColorSchemeEnum:
+ assert member.value in out
+
+
+# ----------------------------------------------------------------------
+# adp_probability numeric descriptor
+# ----------------------------------------------------------------------
+
+
+class TestAdpProbability:
+ def test_setter_accepts_value_in_open_interval(self):
+ from easydiffraction.project.categories.structure_style.default import StructureStyle
+
+ style = StructureStyle()
+ style.adp_probability = 0.5
+ assert style.adp_probability.value == 0.5
+
+ def test_out_of_range_raises_in_raise_mode(self, monkeypatch):
+ from easydiffraction.project.categories.structure_style.default import StructureStyle
+ from easydiffraction.utils.logging import Logger
+
+ monkeypatch.setattr(Logger, '_reaction', Logger.Reaction.RAISE, raising=True)
+ style = StructureStyle()
+
+ with pytest.raises(TypeError, match='outside'):
+ style.adp_probability = 1.5
+
+ def test_boundaries_are_exclusive(self, monkeypatch):
+ from easydiffraction.project.categories.structure_style.default import StructureStyle
+ from easydiffraction.utils.logging import Logger
+
+ monkeypatch.setattr(Logger, '_reaction', Logger.Reaction.RAISE, raising=True)
+ style = StructureStyle()
+
+ with pytest.raises(TypeError, match='outside'):
+ style.adp_probability = 0.0
+ with pytest.raises(TypeError, match='outside'):
+ style.adp_probability = 1.0
+
+ def test_out_of_range_keeps_current_in_warn_mode(self, monkeypatch):
+ from easydiffraction.project.categories.structure_style.default import StructureStyle
+ from easydiffraction.utils.logging import Logger
+
+ monkeypatch.setattr(Logger, '_reaction', Logger.Reaction.WARN, raising=True)
+ style = StructureStyle()
+
+ style.adp_probability = 1.5 # rejected, current value kept
+ assert style.adp_probability.value == 0.99
+
+ def test_wrong_type_raises_in_raise_mode(self, monkeypatch):
+ from easydiffraction.project.categories.structure_style.default import StructureStyle
+ from easydiffraction.utils.logging import Logger
+
+ monkeypatch.setattr(Logger, '_reaction', Logger.Reaction.RAISE, raising=True)
+ style = StructureStyle()
+
+ with pytest.raises(TypeError, match='Type mismatch'):
+ style.adp_probability = 'not-a-number'
+
+
+# ----------------------------------------------------------------------
+# atom_scale numeric descriptor
+# ----------------------------------------------------------------------
+
+
+class TestAtomScale:
+ def test_setter_accepts_value_in_range(self):
+ from easydiffraction.project.categories.structure_style.default import StructureStyle
+
+ style = StructureStyle()
+ style.atom_scale = 0.8
+ assert style.atom_scale.value == 0.8
+
+ def test_upper_boundary_is_inclusive(self):
+ from easydiffraction.project.categories.structure_style.default import StructureStyle
+
+ style = StructureStyle()
+ style.atom_scale = 1.0
+ assert style.atom_scale.value == 1.0
+
+ def test_lower_boundary_is_exclusive(self, monkeypatch):
+ from easydiffraction.project.categories.structure_style.default import StructureStyle
+ from easydiffraction.utils.logging import Logger
+
+ monkeypatch.setattr(Logger, '_reaction', Logger.Reaction.RAISE, raising=True)
+ style = StructureStyle()
+
+ with pytest.raises(TypeError, match='outside'):
+ style.atom_scale = 0.0
+
+ def test_above_upper_boundary_raises_in_raise_mode(self, monkeypatch):
+ from easydiffraction.project.categories.structure_style.default import StructureStyle
+ from easydiffraction.utils.logging import Logger
+
+ monkeypatch.setattr(Logger, '_reaction', Logger.Reaction.RAISE, raising=True)
+ style = StructureStyle()
+
+ with pytest.raises(TypeError, match='outside'):
+ style.atom_scale = 1.5
+
+ def test_out_of_range_keeps_current_in_warn_mode(self, monkeypatch):
+ from easydiffraction.project.categories.structure_style.default import StructureStyle
+ from easydiffraction.utils.logging import Logger
+
+ monkeypatch.setattr(Logger, '_reaction', Logger.Reaction.WARN, raising=True)
+ style = StructureStyle()
+
+ style.atom_scale = 5.0 # rejected, current value kept
+ assert style.atom_scale.value == 0.3
+
+
+# ----------------------------------------------------------------------
+# CIF serialization / round-trip
+# ----------------------------------------------------------------------
+
+
+class TestStructureStyleCif:
+ def test_as_cif_default_output(self):
+ from easydiffraction.project.categories.structure_style.default import StructureStyle
+
+ style = StructureStyle()
+ cif = style.as_cif
+ assert '_structure_style.atom_view covalent' in cif
+ assert '_structure_style.color_scheme jmol' in cif
+ assert '_structure_style.adp_probability 0.99' in cif
+ assert '_structure_style.atom_scale 0.3' in cif
+
+ def test_as_cif_reflects_updated_values(self):
+ from easydiffraction.project.categories.structure_style.default import StructureStyle
+
+ style = StructureStyle()
+ style.atom_view = 'vdw'
+ style.color_scheme = 'vesta'
+
+ cif = style.as_cif
+ assert '_structure_style.atom_view vdw' in cif
+ assert '_structure_style.color_scheme vesta' in cif
+
+ def test_from_cif_restores_all_fields(self):
+ from easydiffraction.project.categories.structure_style.default import StructureStyle
+
+ style = StructureStyle()
+ block = gemmi.cif.read_string(
+ 'data_test\n'
+ '_structure_style.atom_view vdw\n'
+ '_structure_style.color_scheme vesta\n'
+ '_structure_style.adp_probability 0.5\n'
+ '_structure_style.atom_scale 0.7\n',
+ ).sole_block()
+
+ style.from_cif(block)
+
+ assert style.atom_view.value == 'vdw'
+ assert style.color_scheme.value == 'vesta'
+ assert style.adp_probability.value == 0.5
+ assert style.atom_scale.value == 0.7
+
+ def test_cif_round_trip_is_stable(self):
+ from easydiffraction.project.categories.structure_style.default import StructureStyle
+
+ style = StructureStyle()
+ style.atom_view = 'covalent'
+ style.color_scheme = 'vesta'
+ style.adp_probability = 0.5
+ style.atom_scale = 0.7
+
+ first_cif = style.as_cif
+ block = gemmi.cif.read_string(f'data_test\n{first_cif}\n').sole_block()
+
+ restored = StructureStyle()
+ restored.from_cif(block)
+
+ assert restored.as_cif == first_cif
diff --git a/tests/unit/easydiffraction/project/categories/structure_style/test_factory.py b/tests/unit/easydiffraction/project/categories/structure_style/test_factory.py
new file mode 100644
index 000000000..06b3195af
--- /dev/null
+++ b/tests/unit/easydiffraction/project/categories/structure_style/test_factory.py
@@ -0,0 +1,334 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+"""Tests for the project structure_style factory."""
+
+from __future__ import annotations
+
+import gemmi
+import pytest
+
+from easydiffraction.utils.logging import Logger
+
+
+@pytest.fixture
+def raise_on_error(monkeypatch):
+ """Force Logger into RAISE mode for invalid-input assertions.
+
+ Another test may have leaked WARN mode onto the shared Logger,
+ so validators that route through ``log.error()`` would silently
+ fall back instead of raising. Pin RAISE for the test body.
+ """
+ monkeypatch.setattr(Logger, '_reaction', Logger.Reaction.RAISE, raising=True)
+
+
+# ----------------------------------------------------------------------
+# Module / factory surface
+# ----------------------------------------------------------------------
+
+
+def test_module_import():
+ import easydiffraction.project.categories.structure_style.factory as MUT
+
+ expected_module_name = 'easydiffraction.project.categories.structure_style.factory'
+ assert MUT.__name__ == expected_module_name
+
+
+def test_default_rules_universal_fallback():
+ from easydiffraction.project.categories.structure_style.factory import StructureStyleFactory
+
+ # The factory declares a single universal-fallback rule.
+ assert StructureStyleFactory._default_rules == {frozenset(): 'default'}
+
+
+def test_supported_tags_lists_default():
+ from easydiffraction.project.categories.structure_style.factory import StructureStyleFactory
+
+ tags = StructureStyleFactory.supported_tags()
+ assert isinstance(tags, list)
+ assert tags == ['default']
+
+
+def test_default_tag_without_conditions():
+ from easydiffraction.project.categories.structure_style.factory import StructureStyleFactory
+
+ assert StructureStyleFactory.default_tag() == 'default'
+
+
+def test_default_tag_with_unmatched_conditions_falls_back():
+ from easydiffraction.project.categories.structure_style.factory import StructureStyleFactory
+
+ # Extra conditions still match the empty-key universal fallback.
+ assert StructureStyleFactory.default_tag(scattering_type='bragg') == 'default'
+
+
+def test_create_returns_structure_style():
+ from easydiffraction.project.categories.structure_style.default import StructureStyle
+ from easydiffraction.project.categories.structure_style.factory import StructureStyleFactory
+
+ structure_style = StructureStyleFactory.create('default')
+ assert isinstance(structure_style, StructureStyle)
+
+
+def test_create_rejects_unknown_tag():
+ from easydiffraction.project.categories.structure_style.factory import StructureStyleFactory
+
+ with pytest.raises(ValueError, match=r"Unsupported type: 'missing'"):
+ StructureStyleFactory.create('missing')
+
+
+def test_create_default_for_returns_structure_style():
+ from easydiffraction.project.categories.structure_style.default import StructureStyle
+ from easydiffraction.project.categories.structure_style.factory import StructureStyleFactory
+
+ structure_style = StructureStyleFactory.create_default_for()
+ assert isinstance(structure_style, StructureStyle)
+
+
+def test_supported_for_includes_registered_class():
+ from easydiffraction.project.categories.structure_style.default import StructureStyle
+ from easydiffraction.project.categories.structure_style.factory import StructureStyleFactory
+
+ supported = StructureStyleFactory.supported_for()
+ assert StructureStyle in supported
+
+
+def test_show_supported_lists_default(capsys):
+ from easydiffraction.project.categories.structure_style.factory import StructureStyleFactory
+
+ StructureStyleFactory.show_supported()
+ out = capsys.readouterr().out
+ assert 'Supported types' in out
+ assert 'default' in out
+
+
+def test_registry_is_independent_from_base():
+ from easydiffraction.core.factory import FactoryBase
+ from easydiffraction.project.categories.structure_style.default import StructureStyle
+ from easydiffraction.project.categories.structure_style.factory import StructureStyleFactory
+
+ # __init_subclass__ gives each factory its own registry; the
+ # registered concrete class must not leak onto the shared base.
+ assert StructureStyle in StructureStyleFactory._registry
+ assert StructureStyle not in FactoryBase._registry
+
+
+# ----------------------------------------------------------------------
+# Created StructureStyle instance: identity, defaults, CIF handlers
+# ----------------------------------------------------------------------
+
+
+def _make_style():
+ from easydiffraction.project.categories.structure_style.factory import StructureStyleFactory
+
+ return StructureStyleFactory.create('default')
+
+
+def test_created_instance_identity():
+ structure_style = _make_style()
+
+ assert structure_style.type_info.tag == 'default'
+ assert structure_style._category_code == 'structure_style'
+
+
+def test_created_instance_defaults():
+ structure_style = _make_style()
+
+ assert structure_style.atom_view.value == 'covalent'
+ assert structure_style.color_scheme.value == 'jmol'
+ assert structure_style.adp_probability.value == 0.99
+ assert structure_style.atom_scale.value == 0.3
+
+
+def test_cif_handler_names():
+ structure_style = _make_style()
+
+ assert structure_style.atom_view._cif_handler.names == ['_structure_style.atom_view']
+ assert structure_style.color_scheme._cif_handler.names == ['_structure_style.color_scheme']
+ assert structure_style.adp_probability._cif_handler.names == [
+ '_structure_style.adp_probability',
+ ]
+ assert structure_style.atom_scale._cif_handler.names == ['_structure_style.atom_scale']
+
+
+# ----------------------------------------------------------------------
+# Enum-backed selectors: valid setters and show_supported()
+# ----------------------------------------------------------------------
+
+
+def test_atom_view_enum_backing():
+ from easydiffraction.display.structure.enums import AtomViewEnum
+
+ structure_style = _make_style()
+
+ assert structure_style.atom_view.enum is AtomViewEnum
+
+
+def test_color_scheme_enum_backing():
+ from easydiffraction.display.structure.enums import ColorSchemeEnum
+
+ structure_style = _make_style()
+
+ assert structure_style.color_scheme.enum is ColorSchemeEnum
+
+
+def test_atom_view_setter_accepts_each_member():
+ from easydiffraction.display.structure.enums import AtomViewEnum
+
+ structure_style = _make_style()
+ for member in AtomViewEnum:
+ structure_style.atom_view = member.value
+ assert structure_style.atom_view.value == member.value
+
+
+def test_color_scheme_setter_accepts_each_member():
+ from easydiffraction.display.structure.enums import ColorSchemeEnum
+
+ structure_style = _make_style()
+ for member in ColorSchemeEnum:
+ structure_style.color_scheme = member.value
+ assert structure_style.color_scheme.value == member.value
+
+
+def test_atom_view_setter_rejects_unknown_value():
+ structure_style = _make_style()
+
+ # The setter wraps the value in the enum constructor, which rejects
+ # unknown strings with ValueError before the descriptor is touched.
+ with pytest.raises(ValueError, match='not a valid AtomViewEnum'):
+ structure_style.atom_view = 'nope'
+
+
+def test_color_scheme_setter_rejects_unknown_value():
+ structure_style = _make_style()
+
+ with pytest.raises(ValueError, match='not a valid ColorSchemeEnum'):
+ structure_style.color_scheme = 'nope'
+
+
+def test_atom_view_show_supported_lists_all_members(capsys):
+ from easydiffraction.display.structure.enums import AtomViewEnum
+
+ structure_style = _make_style()
+ structure_style.atom_view.show_supported()
+ out = capsys.readouterr().out
+
+ assert 'Atom View types' in out
+ for member in AtomViewEnum:
+ assert member.value in out
+ # The active (default) value is marked.
+ assert '*' in out
+
+
+def test_color_scheme_show_supported_lists_all_members(capsys):
+ from easydiffraction.display.structure.enums import ColorSchemeEnum
+
+ structure_style = _make_style()
+ structure_style.color_scheme.show_supported()
+ out = capsys.readouterr().out
+
+ assert 'Color Scheme types' in out
+ for member in ColorSchemeEnum:
+ assert member.value in out
+ assert '*' in out
+
+
+# ----------------------------------------------------------------------
+# Numeric selectors: range validation (valid + invalid)
+# ----------------------------------------------------------------------
+
+
+def test_adp_probability_setter_accepts_in_range():
+ structure_style = _make_style()
+
+ structure_style.adp_probability = 0.5
+ assert structure_style.adp_probability.value == 0.5
+
+
+def test_atom_scale_setter_accepts_in_range():
+ structure_style = _make_style()
+
+ structure_style.atom_scale = 1.0 # le=1.0 boundary is allowed
+ assert structure_style.atom_scale.value == 1.0
+
+
+def test_adp_probability_rejects_value_at_or_above_one(raise_on_error):
+ structure_style = _make_style()
+
+ with pytest.raises(TypeError, match=r'structure_style\.adp_probability'):
+ structure_style.adp_probability = 1.0
+
+
+def test_adp_probability_rejects_value_at_or_below_zero(raise_on_error):
+ structure_style = _make_style()
+
+ with pytest.raises(TypeError, match=r'structure_style\.adp_probability'):
+ structure_style.adp_probability = 0.0
+
+
+def test_atom_scale_rejects_value_above_one(raise_on_error):
+ structure_style = _make_style()
+
+ with pytest.raises(TypeError, match=r'structure_style\.atom_scale'):
+ structure_style.atom_scale = 2.0
+
+
+def test_atom_scale_rejects_value_at_or_below_zero(raise_on_error):
+ structure_style = _make_style()
+
+ with pytest.raises(TypeError, match=r'structure_style\.atom_scale'):
+ structure_style.atom_scale = 0.0
+
+
+# ----------------------------------------------------------------------
+# CIF serialisation round-trip
+# ----------------------------------------------------------------------
+
+
+def test_as_cif_emits_all_handlers():
+ structure_style = _make_style()
+ structure_style.atom_view = 'vdw'
+ structure_style.color_scheme = 'vesta'
+ structure_style.adp_probability = 0.5
+ structure_style.atom_scale = 0.8
+
+ cif = structure_style.as_cif
+
+ assert '_structure_style.atom_view vdw' in cif
+ assert '_structure_style.color_scheme vesta' in cif
+ assert '_structure_style.adp_probability 0.5' in cif
+ assert '_structure_style.atom_scale 0.8' in cif
+
+
+def test_from_cif_restores_all_fields():
+ structure_style = _make_style()
+ block = gemmi.cif.read_string(
+ 'data_t\n'
+ '_structure_style.atom_view covalent\n'
+ '_structure_style.color_scheme vesta\n'
+ '_structure_style.adp_probability 0.5\n'
+ '_structure_style.atom_scale 0.8\n'
+ ).sole_block()
+
+ structure_style.from_cif(block)
+
+ assert structure_style.atom_view.value == 'covalent'
+ assert structure_style.color_scheme.value == 'vesta'
+ assert structure_style.adp_probability.value == 0.5
+ assert structure_style.atom_scale.value == 0.8
+
+
+def test_cif_round_trip_preserves_values():
+ structure_style = _make_style()
+ structure_style.atom_view = 'ionic'
+ structure_style.color_scheme = 'vesta'
+ structure_style.adp_probability = 0.75
+ structure_style.atom_scale = 0.6
+
+ block = gemmi.cif.read_string(f'data_t\n{structure_style.as_cif}\n').sole_block()
+ restored = _make_style()
+ restored.from_cif(block)
+
+ assert restored.atom_view.value == 'ionic'
+ assert restored.color_scheme.value == 'vesta'
+ assert restored.adp_probability.value == 0.75
+ assert restored.atom_scale.value == 0.6
diff --git a/tests/unit/easydiffraction/project/categories/structure_view/test_default.py b/tests/unit/easydiffraction/project/categories/structure_view/test_default.py
new file mode 100644
index 000000000..0655ebc28
--- /dev/null
+++ b/tests/unit/easydiffraction/project/categories/structure_view/test_default.py
@@ -0,0 +1,359 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+"""Tests for the project structure_view default category."""
+
+from __future__ import annotations
+
+import pytest
+
+from easydiffraction.core.variable import BoolDescriptor
+from easydiffraction.core.variable import NumericDescriptor
+from easydiffraction.project.categories.structure_view.default import StructureView
+from easydiffraction.utils.logging import Logger
+
+
+@pytest.fixture
+def view() -> StructureView:
+ """Return a freshly constructed StructureView."""
+ return StructureView()
+
+
+@pytest.fixture
+def raise_mode(monkeypatch) -> None:
+ """Force the shared Logger into RAISE mode for this test.
+
+ Another test may have leaked WARN mode into the process-global
+ Logger, so validation-failure tests pin RAISE explicitly.
+ """
+ monkeypatch.setattr(Logger, '_reaction', Logger.Reaction.RAISE, raising=True)
+
+
+# ----------------------------------------------------------------------
+# Module / class identity
+# ----------------------------------------------------------------------
+
+
+def test_module_import():
+ import easydiffraction.project.categories.structure_view.default as MUT
+
+ expected_module_name = 'easydiffraction.project.categories.structure_view.default'
+ assert MUT.__name__ == expected_module_name
+
+
+def test_type_info_tag():
+ assert StructureView.type_info.tag == 'default'
+
+
+def test_type_info_description():
+ assert StructureView.type_info.description == 'Project structure_view category'
+
+
+def test_category_code_class_attr():
+ assert StructureView._category_code == 'structure_view'
+
+
+def test_instantiation(view):
+ assert view is not None
+
+
+def test_identity_category_code(view):
+ assert view._identity.category_code == 'structure_view'
+
+
+def test_registered_with_factory():
+ from easydiffraction.project.categories.structure_view.factory import (
+ StructureViewFactory,
+ )
+
+ # The @StructureViewFactory.register decorator in default.py must
+ # register the concrete class under its type_info tag.
+ assert StructureView in StructureViewFactory._registry
+
+
+# ----------------------------------------------------------------------
+# Descriptor types
+# ----------------------------------------------------------------------
+
+
+def test_boolean_descriptor_types(view):
+ assert isinstance(view.show_labels, BoolDescriptor)
+ assert isinstance(view.show_moments, BoolDescriptor)
+
+
+def test_range_descriptor_types(view):
+ for descriptor in (
+ view.range_a_min,
+ view.range_a_max,
+ view.range_b_min,
+ view.range_b_max,
+ view.range_c_min,
+ view.range_c_max,
+ ):
+ assert isinstance(descriptor, NumericDescriptor)
+
+
+# ----------------------------------------------------------------------
+# Defaults
+# ----------------------------------------------------------------------
+
+
+def test_default_show_labels(view):
+ assert view.show_labels.value is False
+
+
+def test_default_show_moments(view):
+ assert view.show_moments.value is True
+
+
+def test_default_range_minimums(view):
+ assert view.range_a_min.value == 0.0
+ assert view.range_b_min.value == 0.0
+ assert view.range_c_min.value == 0.0
+
+
+def test_default_range_maximums(view):
+ assert view.range_a_max.value == 1.0
+ assert view.range_b_max.value == 1.0
+ assert view.range_c_max.value == 1.0
+
+
+# ----------------------------------------------------------------------
+# CIF handler names
+# ----------------------------------------------------------------------
+
+
+def test_boolean_cif_handler_names(view):
+ assert view.show_labels._cif_handler.names == ['_structure_view.show_labels']
+ assert view.show_moments._cif_handler.names == ['_structure_view.show_moments']
+
+
+def test_range_cif_handler_names(view):
+ expected = {
+ 'range_a_min': ['_structure_view.range_a_min'],
+ 'range_a_max': ['_structure_view.range_a_max'],
+ 'range_b_min': ['_structure_view.range_b_min'],
+ 'range_b_max': ['_structure_view.range_b_max'],
+ 'range_c_min': ['_structure_view.range_c_min'],
+ 'range_c_max': ['_structure_view.range_c_max'],
+ }
+ for attr, names in expected.items():
+ assert getattr(view, attr)._cif_handler.names == names
+
+
+def test_descriptor_names_match_attribute(view):
+ assert view.show_labels.name == 'show_labels'
+ assert view.show_moments.name == 'show_moments'
+ assert view.range_a_min.name == 'range_a_min'
+ assert view.range_c_max.name == 'range_c_max'
+
+
+# ----------------------------------------------------------------------
+# Boolean setters
+# ----------------------------------------------------------------------
+
+
+def test_show_labels_setter(view):
+ view.show_labels = True
+ assert view.show_labels.value is True
+
+
+def test_show_moments_setter(view):
+ view.show_moments = False
+ assert view.show_moments.value is False
+
+
+def test_show_labels_setter_rejects_non_bool(view, raise_mode):
+ with pytest.raises(TypeError):
+ view.show_labels = 'nope'
+
+
+def test_show_moments_setter_rejects_non_bool(view, raise_mode):
+ with pytest.raises(TypeError):
+ view.show_moments = 'yes'
+
+
+def test_show_labels_setter_keeps_current_in_warn_mode(view, monkeypatch):
+ # In WARN mode a bad-type assignment logs and keeps the current
+ # value rather than raising.
+ monkeypatch.setattr(Logger, '_reaction', Logger.Reaction.WARN, raising=True)
+ view.show_labels = 'nope'
+ assert view.show_labels.value is False
+
+
+# ----------------------------------------------------------------------
+# Range setters — valid values
+# ----------------------------------------------------------------------
+
+
+def test_range_a_setters_valid(view):
+ view.range_a_min = 0.25
+ view.range_a_max = 0.75
+ assert view.range_a_min.value == 0.25
+ assert view.range_a_max.value == 0.75
+
+
+def test_range_b_setters_valid(view):
+ view.range_b_min = 0.1
+ view.range_b_max = 0.9
+ assert view.range_b_min.value == 0.1
+ assert view.range_b_max.value == 0.9
+
+
+def test_range_c_setters_valid(view):
+ view.range_c_min = 0.2
+ view.range_c_max = 0.8
+ assert view.range_c_min.value == 0.2
+ assert view.range_c_max.value == 0.8
+
+
+# ----------------------------------------------------------------------
+# Range setters — ordering guard (min < max, strict)
+# ----------------------------------------------------------------------
+
+
+def test_range_min_above_max_is_ignored(view, raise_mode):
+ # Default window is (0.0, 1.0); a min of 0.9 still satisfies
+ # min < max, so set max low first to create the violation.
+ view.range_a_max = 0.3
+ # 0.5 is not < 0.3, so the assignment must be rejected and the
+ # ordering guard must NOT raise (it only warns).
+ view.range_a_min = 0.5
+ assert view.range_a_min.value == 0.0
+
+
+def test_range_max_below_min_is_ignored(view, raise_mode):
+ view.range_a_min = 0.6
+ # 0.3 is not > 0.6, so the assignment is rejected; value unchanged.
+ view.range_a_max = 0.3
+ assert view.range_a_max.value == 1.0
+
+
+def test_range_equal_bounds_are_ignored(view, raise_mode):
+ # Strict inequality: min == max must be rejected.
+ view.range_a_min = 0.4
+ view.range_a_max = 0.4
+ assert view.range_a_max.value == 1.0
+
+
+def test_ordering_guard_does_not_raise_even_in_raise_mode(view, raise_mode):
+ # The ordering guard logs a warning without exc_type, so it must
+ # never raise regardless of the Logger reaction mode.
+ view.range_b_max = 0.2
+ view.range_b_min = 0.9 # rejected, but no exception
+ assert view.range_b_min.value == 0.0
+
+
+def test_ordering_guard_warns(view, monkeypatch, capsys):
+ monkeypatch.setattr(Logger, '_reaction', Logger.Reaction.WARN, raising=True)
+ view.range_c_max = 0.2
+ view.range_c_min = 0.9
+ out = capsys.readouterr().out
+ assert 'range_c_min' in out
+ assert 'min < max' in out
+
+
+# ----------------------------------------------------------------------
+# Range setters — non-numeric input
+# ----------------------------------------------------------------------
+
+
+def test_range_setter_rejects_non_numeric(view, raise_mode):
+ # A non-numeric value fails the ``lower < value < upper`` comparison
+ # inside the guard, raising a plain TypeError.
+ with pytest.raises(TypeError):
+ view.range_a_min = 'oops'
+
+
+def test_range_setter_rejects_non_numeric_in_warn_mode(view, monkeypatch):
+ # The comparison TypeError is raised by Python itself, so it
+ # surfaces irrespective of the Logger reaction mode.
+ monkeypatch.setattr(Logger, '_reaction', Logger.Reaction.WARN, raising=True)
+ with pytest.raises(TypeError):
+ view.range_b_max = 'oops'
+
+
+# ----------------------------------------------------------------------
+# view_range()
+# ----------------------------------------------------------------------
+
+
+def test_view_range_defaults(view):
+ assert view.view_range() == (
+ (0.0, 1.0),
+ (0.0, 1.0),
+ (0.0, 1.0),
+ )
+
+
+def test_view_range_reflects_updates(view):
+ view.range_a_min = 0.1
+ view.range_a_max = 0.6
+ view.range_b_min = 0.2
+ view.range_b_max = 0.7
+ view.range_c_min = 0.3
+ view.range_c_max = 0.8
+ assert view.view_range() == (
+ (0.1, 0.6),
+ (0.2, 0.7),
+ (0.3, 0.8),
+ )
+
+
+def test_view_range_is_per_axis_min_max(view):
+ view.range_b_min = 0.25
+ view.range_b_max = 0.75
+ axis_a, axis_b, axis_c = view.view_range()
+ assert axis_a == (0.0, 1.0)
+ assert axis_b == (0.25, 0.75)
+ assert axis_c == (0.0, 1.0)
+
+
+# ----------------------------------------------------------------------
+# as_cif
+# ----------------------------------------------------------------------
+
+
+def test_as_cif_returns_str(view):
+ assert isinstance(view.as_cif, str)
+
+
+def test_as_cif_contains_all_handlers(view):
+ cif = view.as_cif
+ for name in (
+ '_structure_view.show_labels',
+ '_structure_view.show_moments',
+ '_structure_view.range_a_min',
+ '_structure_view.range_a_max',
+ '_structure_view.range_b_min',
+ '_structure_view.range_b_max',
+ '_structure_view.range_c_min',
+ '_structure_view.range_c_max',
+ ):
+ assert name in cif
+
+
+def test_as_cif_reflects_boolean_values(view):
+ view.show_labels = True
+ view.show_moments = False
+ cif = view.as_cif
+ assert '_structure_view.show_labels true' in cif
+ assert '_structure_view.show_moments false' in cif
+
+
+# ----------------------------------------------------------------------
+# parameters collection
+# ----------------------------------------------------------------------
+
+
+def test_parameters_lists_all_descriptors(view):
+ names = {param.name for param in view.parameters}
+ assert names == {
+ 'show_labels',
+ 'show_moments',
+ 'range_a_min',
+ 'range_a_max',
+ 'range_b_min',
+ 'range_b_max',
+ 'range_c_min',
+ 'range_c_max',
+ }
diff --git a/tests/unit/easydiffraction/project/categories/structure_view/test_factory.py b/tests/unit/easydiffraction/project/categories/structure_view/test_factory.py
new file mode 100644
index 000000000..aca39d25f
--- /dev/null
+++ b/tests/unit/easydiffraction/project/categories/structure_view/test_factory.py
@@ -0,0 +1,113 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+"""Tests for the project structure_view factory."""
+
+from __future__ import annotations
+
+import pytest
+
+
+def test_module_import():
+ import easydiffraction.project.categories.structure_view.factory as MUT
+
+ expected_module_name = 'easydiffraction.project.categories.structure_view.factory'
+ assert MUT.__name__ == expected_module_name
+
+
+def test_default_rules_universal_fallback():
+ from easydiffraction.project.categories.structure_view.factory import (
+ StructureViewFactory,
+ )
+
+ # The factory declares a single universal-fallback rule.
+ assert StructureViewFactory._default_rules == {frozenset(): 'default'}
+
+
+def test_supported_tags_lists_default():
+ from easydiffraction.project.categories.structure_view.factory import (
+ StructureViewFactory,
+ )
+
+ tags = StructureViewFactory.supported_tags()
+ assert isinstance(tags, list)
+ assert 'default' in tags
+
+
+def test_default_tag_without_conditions():
+ from easydiffraction.project.categories.structure_view.factory import (
+ StructureViewFactory,
+ )
+
+ assert StructureViewFactory.default_tag() == 'default'
+
+
+def test_default_tag_with_unmatched_conditions_falls_back():
+ from easydiffraction.project.categories.structure_view.factory import (
+ StructureViewFactory,
+ )
+
+ # Extra conditions still match the empty-key universal fallback.
+ assert StructureViewFactory.default_tag(scattering_type='bragg') == 'default'
+
+
+def test_create_returns_structure_view():
+ from easydiffraction.project.categories.structure_view.default import StructureView
+ from easydiffraction.project.categories.structure_view.factory import (
+ StructureViewFactory,
+ )
+
+ structure_view = StructureViewFactory.create('default')
+ assert isinstance(structure_view, StructureView)
+
+
+def test_create_rejects_unknown_tag():
+ from easydiffraction.project.categories.structure_view.factory import (
+ StructureViewFactory,
+ )
+
+ with pytest.raises(ValueError, match=r"Unsupported type: 'missing'"):
+ StructureViewFactory.create('missing')
+
+
+def test_create_default_for_returns_structure_view():
+ from easydiffraction.project.categories.structure_view.default import StructureView
+ from easydiffraction.project.categories.structure_view.factory import (
+ StructureViewFactory,
+ )
+
+ structure_view = StructureViewFactory.create_default_for()
+ assert isinstance(structure_view, StructureView)
+
+
+def test_supported_for_includes_registered_class():
+ from easydiffraction.project.categories.structure_view.default import StructureView
+ from easydiffraction.project.categories.structure_view.factory import (
+ StructureViewFactory,
+ )
+
+ supported = StructureViewFactory.supported_for()
+ assert StructureView in supported
+
+
+def test_show_supported_lists_default(capsys):
+ from easydiffraction.project.categories.structure_view.factory import (
+ StructureViewFactory,
+ )
+
+ StructureViewFactory.show_supported()
+ out = capsys.readouterr().out
+ assert 'Supported types' in out
+ assert 'default' in out
+
+
+def test_registry_is_independent_from_base():
+ from easydiffraction.core.factory import FactoryBase
+ from easydiffraction.project.categories.structure_view.default import StructureView
+ from easydiffraction.project.categories.structure_view.factory import (
+ StructureViewFactory,
+ )
+
+ # __init_subclass__ gives each factory its own registry; the
+ # registered concrete class must not leak onto the shared base.
+ assert StructureView in StructureViewFactory._registry
+ assert StructureView not in FactoryBase._registry
diff --git a/tests/unit/easydiffraction/project/categories/table/test_factory.py b/tests/unit/easydiffraction/project/categories/table/test_factory.py
deleted file mode 100644
index 72c9baa95..000000000
--- a/tests/unit/easydiffraction/project/categories/table/test_factory.py
+++ /dev/null
@@ -1,23 +0,0 @@
-# SPDX-FileCopyrightText: 2026 EasyScience contributors
-# SPDX-License-Identifier: BSD-3-Clause
-
-import pytest
-
-
-def test_table_factory_default_and_create():
- from easydiffraction.project.categories.table.default import Table
- from easydiffraction.project.categories.table.factory import TableFactory
-
- assert TableFactory.default_tag() == 'default'
- assert 'default' in TableFactory.supported_tags()
-
- table = TableFactory.create('default')
-
- assert isinstance(table, Table)
-
-
-def test_table_factory_rejects_unknown_tag():
- from easydiffraction.project.categories.table.factory import TableFactory
-
- with pytest.raises(ValueError, match=r"Unsupported type: 'missing'"):
- TableFactory.create('missing')
diff --git a/tests/unit/easydiffraction/project/test_display.py b/tests/unit/easydiffraction/project/test_display.py
index f80d243e4..45d30efee 100644
--- a/tests/unit/easydiffraction/project/test_display.py
+++ b/tests/unit/easydiffraction/project/test_display.py
@@ -11,8 +11,11 @@
from easydiffraction.datablocks.experiment.item.enums import SampleFormEnum
from easydiffraction.datablocks.experiment.item.enums import ScatteringTypeEnum
+from easydiffraction.datablocks.structure.item.base import Structure
from easydiffraction.display.progress import ACTIVITY_LABEL_PROCESSING
from easydiffraction.display.plotting import _MeasVsCalcPlotOptions
+from easydiffraction.display.structure.builder import FeatureAvailability
+from easydiffraction.project.categories.structure_style.default import StructureStyle
from easydiffraction.project.display import PatternOptionStatus
from easydiffraction.project.display import ProjectDisplay
from easydiffraction.utils.enums import VerbosityEnum
@@ -63,7 +66,7 @@ def _recorder(*args, **kwargs):
bayesian_predictive_datasets=[],
_persisted_fit_state_sidecar={},
),
- chart=SimpleNamespace(plotter=plotter),
+ rendering_plot=SimpleNamespace(plotter=plotter),
experiments={'hrpt': SimpleNamespace(type=SimpleNamespace())},
free_parameters=[],
verbosity=SimpleNamespace(fit=SimpleNamespace(value='full')),
@@ -71,6 +74,24 @@ def _recorder(*args, **kwargs):
return project, calls
+def _make_structure_display_project(structure: object) -> SimpleNamespace:
+ return SimpleNamespace(
+ structures={'lbco': structure},
+ structure_style=StructureStyle(),
+ structure_view=SimpleNamespace(
+ view_range=lambda: ((0.0, 1.0), (0.0, 1.0), (0.0, 1.0)),
+ show_labels=SimpleNamespace(value=False),
+ show_moments=SimpleNamespace(value=False),
+ ),
+ rendering_structure=SimpleNamespace(
+ viewer=SimpleNamespace(
+ render=lambda scene, *, features: '',
+ supported_features=lambda: frozenset({'atoms', 'bonds', 'cell', 'axes'}),
+ ),
+ ),
+ )
+
+
def _make_statuses(
*,
measured: bool = False,
@@ -301,8 +322,8 @@ def test_posterior_predictive_skips_processing_indicator_for_restored_cache(monk
},
)
project.experiments = {'hrpt': SimpleNamespace(type=SimpleNamespace())}
- project.chart.plotter.engine = 'plotly'
- project.chart.plotter._resolve_x_axis = lambda expt_type, x: (
+ project.rendering_plot.plotter.engine = 'plotly'
+ project.rendering_plot.plotter._resolve_x_axis = lambda expt_type, x: (
'two_theta',
'two_theta',
None,
@@ -341,7 +362,7 @@ def fake_activity_indicator(label, *, verbosity):
def test_posterior_distribution_without_param_plots_all_free_parameters():
project, calls = _make_project_stub()
project.free_parameters = ['a', 'b']
- project.chart.plotter.engine = 'plotly'
+ project.rendering_plot.plotter.engine = 'plotly'
display = ProjectDisplay(project)
display.posterior.distribution()
@@ -355,7 +376,7 @@ def test_posterior_distribution_without_param_plots_all_free_parameters():
def test_posterior_distribution_without_param_plots_all_free_parameters_for_ascii():
project, calls = _make_project_stub()
project.free_parameters = ['a', 'b']
- project.chart.plotter.engine = 'asciichartpy'
+ project.rendering_plot.plotter.engine = 'asciichartpy'
display = ProjectDisplay(project)
display.posterior.distribution()
@@ -546,7 +567,7 @@ def test_pattern_option_statuses_ignore_placeholder_arrays_without_usable_state(
experiments={'hrpt': experiment},
structures=SimpleNamespace(names=['phase-a']),
analysis=SimpleNamespace(fit_results=None),
- chart=SimpleNamespace(
+ rendering_plot=SimpleNamespace(
plotter=SimpleNamespace(_update_project_categories=lambda expt_name: None),
type='plotly',
),
@@ -592,7 +613,7 @@ def _recorder(*args, **kwargs):
experiments={'heidi': experiment},
structures=SimpleNamespace(names=['si']),
analysis=SimpleNamespace(fit_results=None),
- chart=SimpleNamespace(
+ rendering_plot=SimpleNamespace(
plotter=SimpleNamespace(
_update_project_categories=lambda expt_name: None,
_plot_meas_vs_calc_request=record('_plot_meas_vs_calc_request'),
@@ -663,3 +684,94 @@ def fake_render_table(*, columns_headers, columns_alignment, columns_data):
assert captured['columns_alignment'] == ['left', 'left', 'center', 'center', 'left']
assert captured['columns_data'][0][0] == 'auto'
assert captured['columns_data'][1][0] == 'measured'
+
+
+def test_structure_updates_categories_before_building_scene(monkeypatch, tmp_path):
+ structure = Structure(name='lbco')
+ structure.space_group.name_h_m = 'P m -3 m'
+ structure.cell.length_a = 3.88
+ assert structure.cell.length_b.value == 10.0
+
+ project = _make_structure_display_project(structure)
+ display = ProjectDisplay(project)
+ captured: dict[str, object] = {}
+
+ def fake_build_scene(structure_arg, *, style, view_range, features):
+ captured['cell_lengths'] = (
+ structure_arg.cell.length_a.value,
+ structure_arg.cell.length_b.value,
+ structure_arg.cell.length_c.value,
+ )
+ captured['style'] = style
+ captured['view_range'] = view_range
+ captured['features'] = features
+ return SimpleNamespace()
+
+ monkeypatch.setattr(
+ 'easydiffraction.display.structure.builder.build_scene',
+ fake_build_scene,
+ )
+
+ display.structure('lbco', path=str(tmp_path / 'lbco.html'))
+
+ assert captured['cell_lengths'] == pytest.approx((3.88, 3.88, 3.88))
+ assert captured['style'] is project.structure_style
+ assert captured['view_range'] == ((0.0, 1.0), (0.0, 1.0), (0.0, 1.0))
+ assert captured['features'] == frozenset({'cell', 'axes'})
+
+
+def test_show_structure_options_updates_categories_before_availability(monkeypatch):
+ calls: list[str] = []
+ structure = SimpleNamespace(updated=False)
+
+ def update_categories():
+ calls.append('update')
+ structure.updated = True
+
+ def fake_structure_feature_availability(structure_arg, *, style):
+ calls.append('availability')
+ assert structure_arg.updated is True
+ return FeatureAvailability(frozenset({'cell', 'axes'}), ())
+
+ structure._update_categories = update_categories
+ project = _make_structure_display_project(structure)
+ display = ProjectDisplay(project)
+
+ monkeypatch.setattr(
+ 'easydiffraction.display.structure.builder.structure_feature_availability',
+ fake_structure_feature_availability,
+ )
+ monkeypatch.setattr('easydiffraction.project.display.render_table', lambda **kwargs: None)
+
+ display.show_structure_options('lbco')
+
+ assert calls == ['update', 'availability']
+
+
+def test_show_structure_options_omits_reason_column(monkeypatch):
+ structure = Structure(name='lbco')
+ project = _make_structure_display_project(structure)
+ display = ProjectDisplay(project)
+ captured: dict[str, object] = {}
+
+ def fake_structure_feature_availability(structure_arg, *, style):
+ assert structure_arg is structure
+ assert style is project.structure_style
+ return FeatureAvailability(frozenset({'cell', 'axes'}), ())
+
+ def fake_render_table(*, columns_headers, columns_alignment, columns_data):
+ captured['columns_headers'] = columns_headers
+ captured['columns_alignment'] = columns_alignment
+ captured['columns_data'] = columns_data
+
+ monkeypatch.setattr(
+ 'easydiffraction.display.structure.builder.structure_feature_availability',
+ fake_structure_feature_availability,
+ )
+ monkeypatch.setattr('easydiffraction.project.display.render_table', fake_render_table)
+
+ display.show_structure_options('lbco')
+
+ assert captured['columns_headers'] == ['Option', 'Description', 'Available', 'Auto']
+ assert captured['columns_alignment'] == ['left', 'left', 'center', 'center']
+ assert all(len(row) == 4 for row in captured['columns_data'])
diff --git a/tests/unit/easydiffraction/project/test_project.py b/tests/unit/easydiffraction/project/test_project.py
index 153fd5687..92b336483 100644
--- a/tests/unit/easydiffraction/project/test_project.py
+++ b/tests/unit/easydiffraction/project/test_project.py
@@ -67,19 +67,20 @@ def test_project_free_params_aggregate_structures_and_experiments():
def test_project_exposes_chart_table_and_display_facades():
- from easydiffraction.project.categories.chart import Chart
- from easydiffraction.project.categories.table import Table
+ from easydiffraction.project.categories.rendering_plot import RenderingPlot
+ from easydiffraction.project.categories.rendering_table import RenderingTable
from easydiffraction.project.display import ProjectDisplay
from easydiffraction.project.project import Project
from easydiffraction.report import Report
project = Project()
- assert isinstance(project.chart, Chart)
- assert isinstance(project.table, Table)
+ assert isinstance(project.rendering_plot, RenderingPlot)
+ assert isinstance(project.rendering_table, RenderingTable)
assert isinstance(project.display, ProjectDisplay)
assert isinstance(project.report, Report)
assert hasattr(project.report, 'save')
+ assert 'publication' not in Project._public_attrs()
def test_apply_params_from_csv_resolves_relative_file_paths(tmp_path):
diff --git a/tests/unit/easydiffraction/project/test_project_config.py b/tests/unit/easydiffraction/project/test_project_config.py
index c921b3199..6c9a4beeb 100644
--- a/tests/unit/easydiffraction/project/test_project_config.py
+++ b/tests/unit/easydiffraction/project/test_project_config.py
@@ -8,9 +8,9 @@
def test_project_config_exposes_project_info_chart_and_table_categories():
from easydiffraction.core.category_owner import CategoryOwner
- from easydiffraction.project.categories.chart import Chart
+ from easydiffraction.project.categories.rendering_plot import RenderingPlot
from easydiffraction.project.categories.report import Report
- from easydiffraction.project.categories.table import Table
+ from easydiffraction.project.categories.rendering_table import RenderingTable
from easydiffraction.project.project_config import ProjectConfig
from easydiffraction.project.project_info import ProjectInfo
@@ -18,13 +18,13 @@ def test_project_config_exposes_project_info_chart_and_table_categories():
assert isinstance(config, CategoryOwner)
assert isinstance(config.info, ProjectInfo)
- assert isinstance(config.chart, Chart)
+ assert isinstance(config.rendering_plot, RenderingPlot)
assert isinstance(config.report, Report)
- assert isinstance(config.table, Table)
+ assert isinstance(config.rendering_table, RenderingTable)
assert config.info._parent is config
- assert config.chart._parent is config
+ assert config.rendering_plot._parent is config
assert config.report._parent is config
- assert config.table._parent is config
+ assert config.rendering_table._parent is config
assert config.info.name == 'beer'
assert config.info.title == 'Beer title'
assert config.info.description == 'Some description'
@@ -35,17 +35,23 @@ def test_project_config_exposes_project_info_chart_and_table_categories():
assert config.verbosity.fit.value == 'full'
assert config.categories == [
config.info,
- config.chart,
+ config.rendering_plot,
config.report,
- config.table,
+ config.rendering_table,
config.verbosity,
+ config.rendering_structure,
+ config.structure_view,
+ config.structure_style,
]
assert config.parameters == (
config.info.parameters
- + config.chart.parameters
+ + config.rendering_plot.parameters
+ config.report.parameters
- + config.table.parameters
+ + config.rendering_table.parameters
+ config.verbosity.parameters
+ + config.rendering_structure.parameters
+ + config.structure_view.parameters
+ + config.structure_style.parameters
)
@@ -62,16 +68,18 @@ def test_project_config_as_cif_has_project_chart_and_table_sections_without_data
assert '_project.description' in cif_text
assert '_project.created' in cif_text
assert '_project.last_modified' in cif_text
- assert '_chart.type' in cif_text
+ assert '_rendering_plot.type' in cif_text
assert '_report.cif' in cif_text
assert '_report.html' in cif_text
assert '_report.tex' in cif_text
assert '_report.pdf' in cif_text
assert '_report.html_offline' in cif_text
- assert '_table.type' in cif_text
- assert '_chart.type auto' in cif_text
- assert '_table.type auto' in cif_text
+ assert '_rendering_table.type' in cif_text
+ assert '_rendering_plot.type auto' in cif_text
+ assert '_rendering_table.type auto' in cif_text
assert '_verbosity.fit full' in cif_text
+ assert '_journal.' not in cif_text
+ assert '_publ_' not in cif_text
def test_project_save_and_load_use_auto_display_defaults_when_unset(tmp_path):
@@ -83,15 +91,17 @@ def test_project_save_and_load_use_auto_display_defaults_when_unset(tmp_path):
project_cif = (tmp_path / 'proj' / 'project.cif').read_text()
assert not project_cif.startswith('data_')
- assert '_chart.type auto' in project_cif
+ assert '_rendering_plot.type auto' in project_cif
assert '_report.cif false' in project_cif
- assert '_table.type auto' in project_cif
+ assert '_rendering_table.type auto' in project_cif
assert '_verbosity.fit full' in project_cif
+ assert '_journal.' not in project_cif
+ assert '_publ_' not in project_cif
loaded = Project.load(str(tmp_path / 'proj'))
- assert loaded.chart.type == 'auto'
- assert loaded.table.type == 'auto'
+ assert loaded.rendering_plot.type == 'auto'
+ assert loaded.rendering_table.type == 'auto'
assert loaded.verbosity.fit.value == 'full'
@@ -99,16 +109,16 @@ def test_project_save_and_load_keep_project_config_section_format(tmp_path):
from easydiffraction.project.project import Project
project = Project(name='beer', title='Beer title', description='Some description')
- project.chart.type = 'asciichartpy'
- project.table.type = 'rich'
+ project.rendering_plot.type = 'asciichartpy'
+ project.rendering_table.type = 'rich'
project.save_as(str(tmp_path / 'proj'))
project_cif = (tmp_path / 'proj' / 'project.cif').read_text()
assert not project_cif.startswith('data_')
assert '_project.id beer' in project_cif
- assert '_chart.type asciichartpy' in project_cif
+ assert '_rendering_plot.type asciichartpy' in project_cif
assert '_report.cif false' in project_cif
- assert '_table.type rich' in project_cif
+ assert '_rendering_table.type rich' in project_cif
assert '_verbosity.fit full' in project_cif
loaded = Project.load(str(tmp_path / 'proj'))
@@ -117,8 +127,8 @@ def test_project_save_and_load_keep_project_config_section_format(tmp_path):
assert loaded.info.description == 'Some description'
assert isinstance(loaded.info.created, datetime.datetime)
assert isinstance(loaded.info.last_modified, datetime.datetime)
- assert loaded.chart.type == 'asciichartpy'
- assert loaded.table.type == 'rich'
+ assert loaded.rendering_plot.type == 'asciichartpy'
+ assert loaded.rendering_table.type == 'rich'
assert loaded.verbosity.fit.value == 'full'
diff --git a/tests/unit/easydiffraction/project/test_project_load.py b/tests/unit/easydiffraction/project/test_project_load.py
index cfbd7652d..2a5c1b4a0 100644
--- a/tests/unit/easydiffraction/project/test_project_load.py
+++ b/tests/unit/easydiffraction/project/test_project_load.py
@@ -83,14 +83,18 @@ def test_round_trips_fit_mode(self, tmp_path):
def test_round_trips_display_engine_configuration(self, tmp_path):
original = Project(name='d1')
- original.chart.type = 'asciichartpy'
- original.table.type = 'rich'
+ original.rendering_plot.type = 'asciichartpy'
+ original.rendering_table.type = 'rich'
+ original.rendering_structure.type = 'ascii'
+ original.structure_style.atom_view = 'vdw'
original.save_as(str(tmp_path / 'proj'))
loaded = Project.load(str(tmp_path / 'proj'))
- assert loaded.chart.type == 'asciichartpy'
- assert loaded.table.type == 'rich'
+ assert loaded.rendering_plot.type == 'asciichartpy'
+ assert loaded.rendering_table.type == 'rich'
+ assert loaded.rendering_structure.type == 'ascii'
+ assert loaded.structure_style.atom_view.value == 'vdw'
def test_round_trips_constraints(self, tmp_path):
original = Project(name='c1')
diff --git a/tests/unit/easydiffraction/project/test_publication_loader.py b/tests/unit/easydiffraction/project/test_publication_loader.py
deleted file mode 100644
index 565c5afab..000000000
--- a/tests/unit/easydiffraction/project/test_publication_loader.py
+++ /dev/null
@@ -1,57 +0,0 @@
-# SPDX-FileCopyrightText: 2026 EasyScience contributors
-# SPDX-License-Identifier: BSD-3-Clause
-
-from __future__ import annotations
-
-import pytest
-
-
-def test_load_publication_reads_toml_metadata(tmp_path):
- from easydiffraction.project.categories.publication.default import Publication
- from easydiffraction.project.publication_loader import load_publication
-
- path = tmp_path / 'publication.toml'
- path.write_text(
- """
-journal_name_full = "Journal of Testing"
-body_title = "Refinement report"
-body_keywords = ["diffraction", "neutron"]
-
-[[authors]]
-name = "Ada Lovelace"
-address = "London"
-""".lstrip(),
- encoding='utf-8',
- )
- publication = Publication()
-
- load_publication(publication, path)
-
- assert publication.journal.name_full.value == 'Journal of Testing'
- assert publication.body.title.value == 'Refinement report'
- assert publication.body.keywords == ['diffraction', 'neutron']
- assert len(publication.authors) == 1
- assert publication.authors[0].name.value == 'Ada Lovelace'
- assert publication.authors[0].address.value == 'London'
-
-
-def test_load_publication_rejects_unknown_extension(tmp_path):
- from easydiffraction.project.categories.publication.default import Publication
- from easydiffraction.project.publication_loader import load_publication
-
- path = tmp_path / 'publication.txt'
- path.write_text('body_title = "x"', encoding='utf-8')
-
- with pytest.raises(ValueError, match='Unsupported publication-info format'):
- load_publication(Publication(), path)
-
-
-def test_load_publication_rejects_invalid_author_shape(tmp_path):
- from easydiffraction.project.categories.publication.default import Publication
- from easydiffraction.project.publication_loader import load_publication
-
- path = tmp_path / 'publication.json'
- path.write_text('{"authors": [{"address": "missing name"}]}', encoding='utf-8')
-
- with pytest.raises(ValueError, match=r'authors\[0\]\.name'):
- load_publication(Publication(), path)
diff --git a/tests/unit/easydiffraction/report/test_data_context.py b/tests/unit/easydiffraction/report/test_data_context.py
index 8034e756f..ff34c0be8 100644
--- a/tests/unit/easydiffraction/report/test_data_context.py
+++ b/tests/unit/easydiffraction/report/test_data_context.py
@@ -141,7 +141,6 @@ def _project() -> SimpleNamespace:
software=SimpleNamespace(),
constraints=[],
),
- publication=SimpleNamespace(),
)
@@ -151,6 +150,7 @@ def test_report_data_context_builds_fit_data():
context = build_report_data_context(_project())
fit_data = context['experiments'][0]['fit_data']
+ assert 'publication' not in context
assert fit_data['axes_labels'] == ['I²calc', 'I²meas']
assert list(fit_data['series']['meas']['su']) == [0.5, 0.7]
assert list(fit_data['series']['calc']['values']) == [10.0, 20.0]
diff --git a/tests/unit/easydiffraction/report/test_fit_plot.py b/tests/unit/easydiffraction/report/test_fit_plot.py
index c041f1948..0a231f1a8 100644
--- a/tests/unit/easydiffraction/report/test_fit_plot.py
+++ b/tests/unit/easydiffraction/report/test_fit_plot.py
@@ -45,3 +45,30 @@ def test_fit_plot_ranges_match_plotly_main_intensity_margin():
assert ranges['y_max'] == pytest.approx(31.25)
assert ranges['residual_y_min'] == pytest.approx(-3.4375)
assert ranges['residual_y_max'] == pytest.approx(3.4375)
+
+
+def test_fit_scatter_ranges_share_one_range_and_add_tick_step():
+ from easydiffraction.report.fit_plot import fit_scatter_ranges
+
+ fit_data = {
+ 'x': {'values': [10.0, 90.0]},
+ 'series': {'meas': {'values': [0.0, 80.0], 'su': [5.0, 5.0]}},
+ }
+
+ ranges = fit_scatter_ranges(fit_data)
+
+ # x and y share one range (so the diagonal is a true y=x line), and it
+ # unions calc (10..90) with meas +/- su (-5..85), padded both ends.
+ assert ranges['x_min'] == ranges['y_min'] == ranges['diag_min']
+ assert ranges['x_max'] == ranges['y_max'] == ranges['diag_max']
+ assert ranges['x_min'] < -5.0
+ assert ranges['x_max'] > 90.0
+ assert ranges['tick_step'] > 0.0
+
+
+def test_fit_plot_axis_styles_expose_shared_diagonal_color():
+ from easydiffraction.report.fit_plot import fit_plot_axis_styles
+
+ styles = fit_plot_axis_styles()
+
+ assert styles['diag_rgb'] == '190,199,208'
diff --git a/tests/unit/easydiffraction/report/test_html_renderer.py b/tests/unit/easydiffraction/report/test_html_renderer.py
index d07d6bd40..4a965055a 100644
--- a/tests/unit/easydiffraction/report/test_html_renderer.py
+++ b/tests/unit/easydiffraction/report/test_html_renderer.py
@@ -84,12 +84,6 @@ def _context() -> dict[str, object]:
'n_phases': 1,
'n_experiments': 0,
},
- 'publication': {
- 'body': {'title': '', 'abstract': '', 'synopsis': '', 'keywords': ''},
- 'authors': [],
- 'journal': {'name_full': '', 'year': '', 'paper_doi': ''},
- 'contact_author': {'name': '', 'email': ''},
- },
'metadata': {
'generated_at': '2026-05-26T00:00:00Z',
'easydiffraction_version': '0.0',
@@ -355,6 +349,7 @@ def test_render_html_report_uses_plotly_fit_style_order():
html = render_html_report(context)
+ assert 'Diffraction pattern for experiment' in html
measured = html.index('"name":"Measured (Imeas)"')
background = html.index('"name":"Background (Ibkg)"')
calculated = html.index('"name":"Total calculated (Icalc)"')
diff --git a/tests/unit/easydiffraction/report/test_style.py b/tests/unit/easydiffraction/report/test_style.py
index bd4f07ea2..826789c9c 100644
--- a/tests/unit/easydiffraction/report/test_style.py
+++ b/tests/unit/easydiffraction/report/test_style.py
@@ -11,7 +11,8 @@ def test_report_style_context_exposes_hex_and_rgb_values():
assert context['axis_hex'] == '#bec7d0'
assert context['axis_rgb'] == '190,199,208'
- assert context['grid_hex'] == '#d9dfe4'
+ assert context['grid_hex'] == '#e0e0e0'
+ assert context['grid_rgb'] == '224,224,224'
assert context['chart_grid_rgb'] == '235,240,248'
assert context['subtitle'] == 'EasyDiffraction Report'
assert 'PT Sans' in context['html_font_family']
diff --git a/tests/unit/easydiffraction/report/test_tex_renderer.py b/tests/unit/easydiffraction/report/test_tex_renderer.py
index 41eedca56..61efd39a7 100644
--- a/tests/unit/easydiffraction/report/test_tex_renderer.py
+++ b/tests/unit/easydiffraction/report/test_tex_renderer.py
@@ -16,24 +16,6 @@ def _minimal_context() -> dict[str, object]:
'n_phases': 0,
'n_experiments': 0,
},
- 'publication': {
- 'body': {
- 'title': '',
- 'abstract': '',
- 'synopsis': '',
- 'keywords': '',
- },
- 'authors': [],
- 'journal': {
- 'name_full': '',
- 'year': '',
- 'paper_doi': '',
- },
- 'contact_author': {
- 'name': '',
- 'email': '',
- },
- },
'metadata': {
'generated_at': '2026-05-26T00:00:00Z',
'easydiffraction_version': '0.0',
@@ -143,7 +125,7 @@ def test_render_tex_report_renders_default_document():
tex = render_tex_report(context)
assert r'\documentclass[11pt]{article}' in tex
- assert r'\usepackage[margin=2.5cm]{geometry}' in tex
+ assert r'\usepackage[margin=2cm]{geometry}' in tex
assert r'\usepackage{fourier}' in tex
assert r'\usepackage{longtable}' in tex
assert r'\usepackage{paratype}' in tex
@@ -459,3 +441,36 @@ def test_save_tex_report_removes_stale_managed_bundle_dirs(tmp_path):
assert not (tex_dir / 'data').exists()
assert not (tex_dir / 'figures').exists()
assert not (tex_dir / 'styles').exists()
+
+
+def test_save_tex_report_writes_structure_figure_png(tmp_path):
+ import easydiffraction as ed
+
+ from easydiffraction.report.tex_renderer import save_tex_report
+
+ project = ed.Project(name='struct_fig')
+ project.structures.create(name='nacl')
+ structure = project.structures['nacl']
+ structure.cell.length_a = 5.64
+ structure.cell.length_b = 5.64
+ structure.cell.length_c = 5.64
+ structure.atom_sites.create(
+ label='Na', type_symbol='Na', fract_x=0, fract_y=0, fract_z=0, adp_iso=0.5, occupancy=1
+ )
+ structure.atom_sites.create(
+ label='Cl',
+ type_symbol='Cl',
+ fract_x=0.5,
+ fract_y=0.5,
+ fract_z=0.5,
+ adp_iso=0.5,
+ occupancy=1,
+ )
+
+ tex_path = tmp_path / 'report.tex'
+ save_tex_report(project, project.report.data_context(), path=tex_path)
+
+ figure_path = tex_path.parent / 'data' / 'struct_nacl.png'
+ assert figure_path.exists()
+ assert figure_path.read_bytes().startswith(b'\x89PNG\r\n\x1a\n')
+ assert 'data/struct_nacl.png' in tex_path.read_text(encoding='utf-8')
diff --git a/tools/tweak_notebooks.py b/tools/tweak_notebooks.py
index 628305672..766c6431c 100644
--- a/tools/tweak_notebooks.py
+++ b/tools/tweak_notebooks.py
@@ -1,12 +1,16 @@
-"""Insert a bootstrap code cell as the first cell of every notebook.
+"""Post-process generated tutorial notebooks.
Usage::
python tools/tweak_notebooks.py tutorials/ [more_paths ...]
-The bootstrap cell:
+Inserts a bootstrap code cell as the first cell of every notebook. The
+bootstrap cell:
- Checks if ``easydiffraction`` is importable; if not, installs it.
- Adds the tag ``hide-in-docs``.
- Idempotent: skipped if already present and identical.
+
+Also sorts each notebook's jupytext ``cell_metadata_filter`` so its entry
+order stays stable across runs and does not create noisy diffs.
"""
from __future__ import annotations
@@ -94,7 +98,33 @@ def ensure_bootstrap(nb, bootstrap_source: str) -> bool:
return True
-def process_notebook(path: Path, bootstrap_source: str) -> int:
+def normalize_cell_metadata_filter(nb) -> bool:
+ """Sort the jupytext ``cell_metadata_filter`` for a stable order.
+
+ jupytext derives this filter from an unordered set, so multi-entry
+ values (e.g. ``title,tags``) can swap order between runs and produce
+ noisy diffs. Sort the positive entries alphabetically and keep any
+ ``-``-prefixed directives (e.g. ``-all``) last.
+
+ Returns True if the stored value changed.
+ """
+ jupytext_meta = nb.metadata.get('jupytext')
+ if not isinstance(jupytext_meta, dict):
+ return False
+ current = jupytext_meta.get('cell_metadata_filter')
+ if not isinstance(current, str):
+ return False
+ entries = [part.strip() for part in current.split(',') if part.strip()]
+ positives = sorted(entry for entry in entries if not entry.startswith('-'))
+ negatives = sorted(entry for entry in entries if entry.startswith('-'))
+ normalized = ','.join(positives + negatives)
+ if normalized == current:
+ return False
+ jupytext_meta['cell_metadata_filter'] = normalized
+ return True
+
+
+def process_notebook(path: Path, bootstrap_source: str) -> list[str]:
nb = nbformat.read(path, as_version=4)
# Remove all 'tags' metadata from cells
@@ -102,16 +132,17 @@ def process_notebook(path: Path, bootstrap_source: str) -> int:
if 'tags' in cell.metadata:
cell.metadata.pop('tags')
- # Add the bootstrap cell if needed
- changed = 0
+ reasons: list[str] = []
if ensure_bootstrap(nb, bootstrap_source):
- changed += 1
+ reasons.append('inserted bootstrap cell')
+ if normalize_cell_metadata_filter(nb):
+ reasons.append('sorted cell_metadata_filter')
# Normalize to ensure cell ids exist and structure is valid
- if changed or any('id' not in c for c in nb.cells):
+ if reasons or any('id' not in c for c in nb.cells):
normalize(nb)
nbformat.write(nb, path)
- return changed
+ return reasons
def main(argv: list[str]) -> int:
@@ -131,9 +162,9 @@ def main(argv: list[str]) -> int:
updated = 0
for nb_path in targets:
- changes = process_notebook(nb_path, bootstrap_source)
- if changes:
- print(f'UPDATED: {nb_path} (inserted bootstrap cell)')
+ reasons = process_notebook(nb_path, bootstrap_source)
+ if reasons:
+ print(f'UPDATED: {nb_path} ({"; ".join(reasons)})')
updated += 1
if updated == 0: