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 (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: