diff --git a/docs/dev/adr_analysis-cif-fit-state.md b/docs/dev/ADR-suggestions/adr_analysis-cif-fit-state.md
similarity index 100%
rename from docs/dev/adr_analysis-cif-fit-state.md
rename to docs/dev/ADR-suggestions/adr_analysis-cif-fit-state.md
diff --git a/docs/dev/adr_parameter-correlation-persistence.md b/docs/dev/ADR-suggestions/adr_parameter-correlation-persistence.md
similarity index 100%
rename from docs/dev/adr_parameter-correlation-persistence.md
rename to docs/dev/ADR-suggestions/adr_parameter-correlation-persistence.md
diff --git a/docs/dev/adr_parameter-posterior-summary.md b/docs/dev/ADR-suggestions/adr_parameter-posterior-summary.md
similarity index 100%
rename from docs/dev/adr_parameter-posterior-summary.md
rename to docs/dev/ADR-suggestions/adr_parameter-posterior-summary.md
diff --git a/docs/dev/adr_undo-fit.md b/docs/dev/ADR-suggestions/adr_undo-fit.md
similarity index 100%
rename from docs/dev/adr_undo-fit.md
rename to docs/dev/ADR-suggestions/adr_undo-fit.md
diff --git a/docs/dev/adr_display-ux.md b/docs/dev/ADRs/adr_display-ux.md
similarity index 80%
rename from docs/dev/adr_display-ux.md
rename to docs/dev/ADRs/adr_display-ux.md
index 264092e22..47e9b5c28 100644
--- a/docs/dev/adr_display-ux.md
+++ b/docs/dev/ADRs/adr_display-ux.md
@@ -2,12 +2,12 @@
## Status
-Accepted.
+Accepted and implemented.
## Context
-The current user-facing display API mixes presentation actions, analysis
-reports, and renderer configuration:
+The previous user-facing display API mixed presentation actions,
+analysis reports, and renderer configuration:
```python
project.display.plotter.plot_meas(expt_name='hrpt')
@@ -32,7 +32,7 @@ This has several UX problems:
- `plot_meas`, `plot_calc`, and `plot_meas_vs_calc` force users to
choose a plot state that the project can often infer.
- Bayesian and deterministic chart names are not systematic.
-- The existing `project.display` category is serialized to CIF, so it
+- The previous `project.display` category was serialized to CIF, so it
should not also become a broad transient display facade.
EasyDiffraction is aimed at scientists, often non-programmers, so the
@@ -55,7 +55,7 @@ project.rendering.show_table_engines()
project.rendering.show_config()
```
-Suggested CIF names:
+CIF names:
- `_rendering.chart_engine`
- `_rendering.table_engine`
@@ -87,7 +87,8 @@ project.display.show_pattern_options(expt_name='hrpt')
```
`project.analysis.display` is removed from the primary public API. Its
-current responsibilities move to clearer homes:
+current responsibilities move to clearer homes, while the implementation
+may keep the existing helpers as internal delegation targets:
| Current method | New home |
| ---------------------------- | -------------------------------------------------------------- |
@@ -100,7 +101,7 @@ current responsibilities move to clearer homes:
| `constraints()` | `project.analysis.constraints.show()` |
| `as_cif()` | `project.analysis.as_cif` and `project.analysis.show_as_cif()` |
-`project.analysis` and `project.info` should follow the same CIF display
+`project.analysis` and `project.info` follow the same CIF display
pattern as structures and experiments:
- `as_cif` is a read-only property returning CIF text as a string.
@@ -119,13 +120,17 @@ By default, `pattern()` uses `include='auto'` and displays as much
useful information as the project state supports:
- measured data if present
-- calculated data if a model/calculation is available
-- background if defined and relevant
-- Bragg ticks if phases/reflections are available
+- calculated data if linked structure state and calculated intensities
+ are available
+- background if powder Bragg measured and calculated data plus defined
+ background points are available
+- Bragg ticks if powder Bragg measured and calculated data plus
+ reflection rows are available
- residual if both measured and calculated data are available and the
experiment type supports a residual panel
-- excluded regions if available
-- uncertainty bands where posterior predictive data exists
+- excluded regions if available on the experiment
+- uncertainty bands where posterior predictive data exists and the chart
+ engine supports them
Specific subsets are selected with `include`:
@@ -156,11 +161,11 @@ Add discovery for supported pattern content:
project.display.show_pattern_options(expt_name='hrpt')
```
-The table should show option name, description, availability for the
+The table shows option name, description, availability for the
experiment, whether `include='auto'` includes it, and the reason an
option is unavailable.
-Initial option names:
+Pattern option names:
- `auto`
- `measured`
@@ -171,9 +176,17 @@ Initial option names:
- `excluded`
- `uncertainty`
-`uncertainty` should be implemented immediately where posterior
-predictive data exists. It should be unavailable, with a clear reason,
-when no posterior predictive data is present.
+`uncertainty` is available where posterior predictive data exists for a
+supported experiment and the active chart engine can render bands. It is
+unavailable, with a clear reason, when no posterior predictive data is
+present.
+
+Explicit combinations are validated against the same project state used
+by `include='auto'`. `background`, `bragg`, and `residual` require both
+measured and calculated data in the same view. `excluded` requires
+measured, calculated, or uncertainty content in the same view, and
+excluded-region overlays currently require the experiment's default
+x-axis.
## Deterministic And Bayesian Consistency
@@ -223,5 +236,8 @@ they duplicate `pattern(..., include=...)`.
- Constraints remain owned by the analysis constraints category.
- There is no legacy CIF compatibility path for `_display.plotter_type`
or `_display.tabler_type`.
-- `project.analysis` and `project.info` need CIF access cleanup for
+- `project.analysis` and `project.info` CIF access is standardized for
consistency with structure and experiment objects.
+- Pattern option availability is computed from live project state,
+ linked structures, calculated intensities, and experiment-specific
+ content instead of placeholder arrays alone.
diff --git a/docs/dev/ADRs/adr_help-discoverability.md b/docs/dev/ADRs/adr_help-discoverability.md
new file mode 100644
index 000000000..8fd019f39
--- /dev/null
+++ b/docs/dev/ADRs/adr_help-discoverability.md
@@ -0,0 +1,65 @@
+# ADR: Help Method Discoverability
+
+## Status
+
+Accepted and implemented.
+
+## Context
+
+EasyDiffraction is used by scientists who often explore the API in
+notebooks. The main object graph already exposes many focused objects:
+projects, project metadata, structures, experiments, categories,
+parameters, analysis helpers, summaries, and display facades. Users need
+a consistent way to discover the next useful operation from any of these
+objects without reading source code.
+
+Most model objects inherit `GuardedBase`, `CategoryItem`,
+`CategoryCollection`, `DatablockItem`, or `DatablockCollection`, which
+already provide `help()` output. Plain facade classes such as display
+namespaces and summaries do not inherit those base classes, so they need
+the same discovery behavior explicitly.
+
+## Decision
+
+Every primary public object should provide a `help()` method. This
+includes:
+
+- parameters and descriptors
+- category items and category collections
+- datablock items and datablock collections
+- project-level objects such as `Project`, `ProjectInfo`, `Analysis`,
+ `Summary`, and `Rendering`
+- display facades such as `project.display`,
+ `project.display.parameters`, `project.display.fit`,
+ `project.display.posterior`, and `analysis.display`
+
+`help()` output uses the existing console/table presentation style. It
+lists public properties and methods discovered from the class MRO, uses
+the first docstring paragraph as the description, and skips private
+names. Specialized containers can append domain-specific tables, such as
+collection items or datablock categories, after the generic section.
+
+Plain helper and facade classes use `render_object_help()` so their
+output stays consistent with `GuardedBase.help()` without forcing those
+classes into the guarded object hierarchy.
+
+## Consequences
+
+Users can call `help()` while navigating through the object graph:
+
+```python
+project.help()
+project.display.help()
+project.display.parameters.help()
+project.analysis.display.help()
+project.summary.help()
+project.experiments.help()
+project.experiments['hrpt'].help()
+project.experiments['hrpt'].background.help()
+project.experiments['hrpt'].background['1'].help()
+```
+
+New user-facing objects should either inherit an existing help-capable
+base class or define `help()` by delegating to `render_object_help()`.
+When a class represents a collection or owner, its help output should
+guide users to the next object level where practical.
diff --git a/docs/dev/issues_closed.md b/docs/dev/Issues/issues_closed.md
similarity index 90%
rename from docs/dev/issues_closed.md
rename to docs/dev/Issues/issues_closed.md
index a5ae2621a..fee4f327b 100644
--- a/docs/dev/issues_closed.md
+++ b/docs/dev/Issues/issues_closed.md
@@ -4,6 +4,18 @@ Issues that have been fully resolved. Kept for historical reference.
---
+## 77. Add Help Methods to Public Discovery Facades
+
+Added consistent `help()` methods for plain user-facing facade classes
+that do not inherit the guarded object hierarchy: `project.display`,
+`project.display.parameters`, `project.display.fit`,
+`project.display.posterior`, `analysis.display`, and `project.summary`.
+Introduced `render_object_help()` so these helpers share the same
+property and method table style as `GuardedBase.help()`. Documented the
+convention in `docs/dev/ADRs/adr_help-discoverability.md`.
+
+---
+
## Restore Minimiser Variant Support
Used thin subclasses (approach A) to restore lmfit algorithm variants.
diff --git a/docs/dev/issues_open.md b/docs/dev/Issues/issues_open.md
similarity index 97%
rename from docs/dev/issues_open.md
rename to docs/dev/Issues/issues_open.md
index 21976afde..bb41887f7 100644
--- a/docs/dev/issues_open.md
+++ b/docs/dev/Issues/issues_open.md
@@ -689,12 +689,13 @@ the archived planning notes left two follow-up questions open:
---
-## 40. 🟢 Implement Resetting `.constrained` to `False`
+## 40. 🟢 Implement Resetting `.user_constrained` to `False`
**Type:** Feature
-`ConstraintsHandler` has a TODO to implement changing the `.constrained`
-attribute back to `False` when constraints are removed.
+`ConstraintsHandler` has a TODO to implement changing the
+`.user_constrained` attribute back to `False` when constraints are
+removed.
**TODOs:**
@@ -1263,27 +1264,6 @@ deviate: e.g. `show_minimizer_types()` instead of
---
-## 77. 🟡 Add `help()` to `Project` and Enrich Existing `help()` Methods
-
-**Type:** API discoverability
-
-`help()` exists on `CategoryItem`, `CollectionBase`, `DatablockItem`,
-and `Analysis`, but **not on `Project`**. The user's primary entry point
-lacks discoverability. Additionally, each `help()` level should guide
-the user to the next level:
-
-1. `project.help()` → attributes: info, experiments, structures,
- analysis, summary.
-2. `project.experiments.help()` → list experiments and how to select.
-3. `project.experiments['name'].help()` → list categories.
-4. `experiment.peak.help()` → list public attributes.
-5. `experiment.background.help()` → list items + array accessors.
-6. `experiment.background['id'].help()` → list attributes.
-
-**Depends on:** nothing.
-
----
-
## 79. 🟢 Verify Completeness of Analysis CIF Serialisation
**Type:** Correctness
@@ -1518,7 +1498,7 @@ operation is possible (e.g. in automated pipelines or tests).
| 37 | Rename experiment `.type` property | 🟢 Low | Naming |
| 38 | Fix `@typechecked`/gemmi in factories | 🟡 Med | Bug |
| 39 | Improve `_update_priority` handling | 🟢 Low | Design |
-| 40 | Implement resetting `.constrained` to `False` | 🟢 Low | Feature |
+| 40 | Reset `.user_constrained` to `False` | 🟢 Low | Feature |
| 41 | Check `_mark_dirty` in `_set_value` | 🟢 Low | Cleanup |
| 42 | MkDocs type unpacking in validation | 🟢 Low | Docs |
| 43 | Fix summary display inconsistencies | 🟢 Low | UX |
@@ -1555,7 +1535,6 @@ operation is possible (e.g. in automated pipelines or tests).
| 74 | Sync property type hints + custom lint rules | 🟡 Med | Tooling |
| 75 | `show_supported_calculators()` on Analysis | 🟢 Low | API completeness |
| 76 | Consistent `_type` suffix in switchable APIs | 🟡 Med | Naming |
-| 77 | Add `help()` to Project + enrich existing | 🟡 Med | Discoverability |
| 79 | Verify analysis CIF serialisation completeness | 🟢 Low | Correctness |
| 80 | Resolve `Any` vs `object` annotation policy | 🟢 Low | Code style |
| 81 | Enforce docstrings on all public methods | 🟡 Med | Code quality |
diff --git a/docs/dev/architecture.md b/docs/dev/architecture.md
index 85fc0ad4e..7cc3abe92 100644
--- a/docs/dev/architecture.md
+++ b/docs/dev/architecture.md
@@ -187,16 +187,16 @@ execution order within a datablock (e.g. background before data).
### 2.4 DatablockItem and DatablockCollection
-| Aspect | `DatablockItem` | `DatablockCollection` |
-| ------------------ | ------------------------------------------- | ------------------------------ |
-| CIF analogy | A single `data_` block | Collection of data blocks |
-| Examples | Structure, BraggPdExperiment | Structures, Experiments |
-| Category discovery | Scans `vars(self)` for categories | N/A |
-| Update cascade | `_update_categories()` — sorted by priority | N/A |
-| Parameters | Aggregated from all categories | Aggregated from all datablocks |
-| Fittable params | N/A | Non-constrained `Parameter`s |
-| Free params | N/A | Fittable + `free == True` |
-| Dirty flag | `_need_categories_update` | N/A |
+| Aspect | `DatablockItem` | `DatablockCollection` |
+| ------------------ | ------------------------------------------- | -------------------------------------------------------- |
+| CIF analogy | A single `data_` block | Collection of data blocks |
+| Examples | Structure, BraggPdExperiment | Structures, Experiments |
+| Category discovery | Scans `vars(self)` for categories | N/A |
+| Update cascade | `_update_categories()` — sorted by priority | N/A |
+| Parameters | Aggregated from all categories | Aggregated from all datablocks |
+| Fittable params | N/A | `Parameter`s not blocked by user or symmetry constraints |
+| Free params | N/A | Fittable + `free == True` |
+| Dirty flag | `_need_categories_update` | N/A |
When any `Parameter.value` is set, it propagates
`_need_categories_update = True` up to the owning `DatablockItem`.
@@ -210,7 +210,7 @@ GuardedBase
└── GenericDescriptorBase # name, value (validated via AttributeSpec), description
├── GenericStringDescriptor # _value_type = DataTypes.STRING
└── GenericNumericDescriptor # _value_type = DataTypes.NUMERIC, + units
- └── GenericParameter # + free, uncertainty, fit_min, fit_max, constrained, symmetry_fixed
+ └── GenericParameter # + free, uncertainty, fit_min, fit_max, user_constrained, symmetry_constrained
```
CIF-bound concrete classes add a `CifHandler` for serialisation:
@@ -325,14 +325,15 @@ via the `crystallography` module during `_update_categories()`.
Parameters that are fully determined by symmetry (e.g. `lattice_b` in
cubic, `fract_y` of an atom on a 4-fold axis, off-diagonal ADPs forced
-to zero by site symmetry) are flagged as `symmetry_fixed = True` on the
-`Parameter`. This forces `free = False`; any subsequent attempt to set
-`free = True` on such a parameter is ignored with a warning. Flags are
-recomputed on every `_update_categories()` so that changing the space
-group, Wyckoff letter, or ADP type re-evaluates which parameters are
-fixed. Surface helpers `cell_symmetry_fixed_flags(...)` and
-`atom_site_symmetry_fixed_flags(...)` in `crystallography` expose the
-per-key flags.
+to zero by site symmetry) are flagged as `symmetry_constrained = True`
+on the `Parameter`. This forces `free = False`; any subsequent attempt
+to set `free = True` on such a parameter is ignored with a warning.
+Flags are recomputed on every `_update_categories()` so that changing
+the space group, Wyckoff letter, or ADP type re-evaluates which
+parameters are symmetry constrained. Surface helpers
+`cell_symmetry_constrained_flags(...)` and
+`atom_site_symmetry_constrained_flags(...)` in `crystallography` expose
+the per-key flags.
### 4.2 Atomic Displacement Parameters (ADP)
@@ -830,7 +831,10 @@ workflow:
object. This is `FitResults` for deterministic fits and
`BayesianFitResults` for Bayesian DREAM runs.
- Parameter tables: `show_all_params()`, `show_fittable_params()`,
- `show_free_params()`, `how_to_access_parameters()`
+ `show_free_params()`, `how_to_access_parameters()` Compact
+ summary-style parameter displays intentionally hide the large
+ loop-backed experiment categories `pd_data`, `total_data`, and `refln`
+ in `all()`, `access()`, and `cif_uids()` so the output stays readable.
- Fitting: `fit()` dispatches single/joint through the callable `fit`
category; `fit_sequential()` handles sequential mode (sets `fit.mode`
to `'sequential'` internally). `fit()` accepts optional `random_seed`
diff --git a/docs/dev/implementation-plans/progress-activity-indicator.md b/docs/dev/implementation-plans/progress-activity-indicator.md
new file mode 100644
index 000000000..e776e0127
--- /dev/null
+++ b/docs/dev/implementation-plans/progress-activity-indicator.md
@@ -0,0 +1,407 @@
+# Progress Activity Indicator Implementation Plan
+
+**Status:** Proposed
+**Date:** 2026-05-14
+
+## Goal
+
+Add a small activity indicator for fitting and other long-running
+calculations. The indicator should read as the same feature in terminal
+and Jupyter, while using environment-appropriate rendering underneath.
+
+This is an activity indicator, not a numeric progress bar. Most
+deterministic minimizers do not expose a reliable total work estimate,
+and the existing fit progress table updates only when meaningful fit
+state changes. A spinner-style indicator communicates that work is
+continuing without implying a percentage that may be unavailable.
+
+## User-Facing Behavior
+
+### Visibility
+
+The indicator is controlled by existing verbosity:
+
+| Verbosity | Behavior |
+| --------- | ---------------------------------------------------------------------------------------------------- |
+| `silent` | Show nothing. No table, no activity indicator, no status line. |
+| `short` | Show the activity indicator, but not the detailed fit progress table. Keep existing short summaries. |
+| `full` | Show the detailed progress table and the activity indicator below it. |
+
+This changes the current fit tracker behavior where `short` returns
+before creating any live progress output.
+
+### Labels
+
+Use the following labels:
+
+| Work type | Label |
+| --------------------------------- | ------------ |
+| Deterministic single fit | `fitting` |
+| Sequential fit | `fitting` |
+| Bayesian DREAM burn phase | `burn-in` |
+| Bayesian DREAM sampling phase | `sampling` |
+| Posterior predictive plots/checks | `processing` |
+| Posterior pair plots | `processing` |
+| Other long calculations | `processing` |
+
+The label must be updateable while work is running. DREAM should switch
+from `burn-in` to `sampling` when sampler progress reports the phase
+change.
+
+### Visual Style
+
+Use compact Unicode spinner frames:
+
+```text
+⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏
+```
+
+Examples:
+
+```text
+⠋ fitting
+⠴ burn-in
+⠇ sampling
+⠙ processing
+```
+
+The indicator should be a single line. In `full` mode it appears below
+the progress table. In `short` mode it appears as the only live progress
+element.
+
+## Current Code Paths
+
+### Fit Progress
+
+The single-fit and sampler progress lifecycle is owned by
+`src/easydiffraction/analysis/fit_helpers/tracking.py`.
+
+Important methods:
+
+- `FitProgressTracker.start_tracking(...)`
+- `FitProgressTracker.add_tracking_info(...)`
+- `FitProgressTracker.track_sampler_progress(...)`
+- `FitProgressTracker.finish_tracking(...)`
+- `_make_display_handle()`
+- `_TerminalLiveHandle`
+
+The existing table update path uses `render_table(...)`, which delegates
+to `TableRenderer` and then to either:
+
+- `PandasTableBackend` in Jupyter
+- `RichTableBackend` in terminal
+
+### Sequential Fit
+
+Sequential fitting currently has separate progress output in
+`src/easydiffraction/analysis/sequential.py`.
+
+Important functions:
+
+- `fit_sequential(...)`
+- `_run_fit_loop(...)`
+- `_report_chunk_progress(...)`
+
+Sequential fit should reuse the same activity indicator abstraction
+rather than adding a separate spinner implementation.
+
+### Posterior And Other Long Calculations
+
+Posterior display work is routed through:
+
+- `src/easydiffraction/project/display.py`
+- `src/easydiffraction/display/plotting.py`
+
+Important entry points include:
+
+- `PosteriorDisplay.pairs(...)`
+- `PosteriorDisplay.predictive(...)`
+- `Plotter.plot_posterior_pairs(...)`
+- `Plotter.plot_posterior_predictive(...)`
+
+These should use the generic `processing` label if an activity indicator
+is added to those paths.
+
+## Architecture
+
+### Add A Shared Activity Indicator
+
+Add a small shared display helper, preferably:
+
+```text
+src/easydiffraction/display/progress.py
+```
+
+Recommended public/internal shape:
+
+```python
+class ActivityIndicator:
+ def __init__(self, label: str = "processing", *, verbosity: VerbosityEnum) -> None: ...
+ def start(self) -> None: ...
+ def update(self, *, label: str | None = None, content: object | None = None) -> None: ...
+ def stop(self, *, final_label: str | None = None) -> None: ...
+```
+
+The exact class name can change during implementation, but it should
+provide these capabilities:
+
+- no output in `silent`
+- live output in `short` and `full`
+- label updates while running
+- optional table/content rendering above the indicator in `full`
+- terminal and Jupyter implementations behind one API
+- safe cleanup on exceptions
+
+### Terminal Rendering
+
+Use Rich for terminal rendering.
+
+Preferred implementation:
+
+- keep using `rich.live.Live`
+- render a `rich.console.Group`
+- group content should be:
+ - the progress table renderable, when present
+ - the activity indicator line
+
+The indicator line can be either:
+
+- Rich's built-in `Spinner`, if it works cleanly inside the existing
+ `Live` setup, or
+- a local unicode-frame renderable driven by the same frame list.
+
+Do not create a second independent `Live` instance for the same output
+area. The table and spinner should be refreshed together through one
+live handle.
+
+### Jupyter Rendering
+
+Use an IPython `DisplayHandle` and HTML.
+
+The Jupyter spinner should be browser-driven CSS animation, not a
+Python-loop animation. This matters because Python may not regain
+control during expensive calculations, but CSS keeps animating once the
+HTML has been displayed.
+
+Recommended HTML structure:
+
+```html
+
+
+ fitting
+
+```
+
+The table HTML and spinner HTML can be updated together in the same
+display handle, or the spinner can have its own display handle below the
+table. Prefer a single display handle if it keeps table-and-spinner
+replacement simpler and avoids duplicated output cells.
+
+### Table Rendering Refactor
+
+The existing table backends mostly print/update directly. For a clean
+combined table-plus-spinner render, add a way to build table renderables
+without immediately displaying them.
+
+Possible approach:
+
+1. Keep `render_table(...)` working for existing callers.
+2. Add a backend method that returns a renderable representation:
+ - Rich: return `rich.table.Table`
+ - Pandas: return HTML from `Styler.to_html()`
+3. Let the activity indicator compose that renderable with the spinner.
+
+This avoids hard-coding table internals in the tracker and keeps normal
+table rendering backwards compatible.
+
+## Fit Tracker Integration
+
+### State
+
+Add fields to `FitProgressTracker`:
+
+- `_activity_indicator`
+- `_activity_label`
+
+The label is derived from tracking mode:
+
+- fit mode -> `fitting`
+- sampler mode -> initial label from sampler phase if known, otherwise
+ `sampling`
+
+### `start_tracking(...)`
+
+Update behavior:
+
+1. Set tracking mode.
+2. Return immediately only for `silent`.
+3. Print the existing start/header messages only where appropriate:
+ - keep current full messages
+ - keep short mode concise
+4. Create the activity indicator for `short` and `full`.
+5. In `full`, render the initial empty progress table plus the
+ indicator.
+6. In `short`, render only the indicator.
+
+### `add_tracking_info(...)`
+
+Update behavior:
+
+1. Always store row state for `full` mode.
+2. In `full`, refresh the table plus the indicator.
+3. In `short`, do not render the table; keep the indicator running.
+
+### `track_sampler_progress(...)`
+
+Update the activity label from `SamplerProgressUpdate.phase`:
+
+- phase `burn-in` -> label `burn-in`
+- phase `sampling` -> label `sampling`
+- any other phase -> normalized phase string if user-facing, otherwise
+ `processing`
+
+The existing sampler table's `phase` column remains unchanged.
+
+### `finish_tracking(...)`
+
+Update behavior:
+
+1. Finalize the last table row as today.
+2. Stop the activity indicator for `short` and `full`.
+3. In `full`, print the current completion summary.
+4. In `short`, keep or add only a concise completion line if the current
+ short behavior expects one.
+5. In `silent`, print nothing.
+
+Use `try/finally` in minimizer execution paths so the indicator is
+stopped when a solver raises.
+
+## Sequential Fit Integration
+
+Sequential fit should use the same `ActivityIndicator`.
+
+Recommended behavior:
+
+- `silent`: no output.
+- `short`: show one activity indicator labelled `fitting`; keep concise
+ chunk summaries when chunks finish.
+- `full`: show one activity indicator labelled `fitting`; keep detailed
+ chunk summaries.
+
+Implementation points:
+
+1. Create the indicator in `fit_sequential(...)` after preflight checks
+ and before `_run_fit_loop(...)`.
+2. Pass it into `_run_fit_loop(...)`, or wrap `_run_fit_loop(...)` in a
+ context manager.
+3. Update the indicator content after each chunk if the implementation
+ supports content text, for example:
+
+ ```text
+ ⠼ fitting chunk 3/20
+ ```
+
+4. Stop the indicator in a `finally` block before printing final
+ completion output.
+
+Do not duplicate spinner frame logic in `sequential.py`.
+
+## Posterior And Generic Processing Integration
+
+The first implementation can focus on fitting and sequential fitting.
+After that, add a small context helper for generic long calculations:
+
+```python
+with activity_indicator("processing", verbosity=VerbosityEnum(project.verbosity)):
+ ...
+```
+
+Use this for:
+
+- posterior predictive summary generation
+- posterior pair plot construction when sample thinning, density grids,
+ or figure construction take noticeable time
+- any future calculation where total progress is unknown
+
+The generic helper should default to `processing`.
+
+## Testing Plan
+
+### Unit Tests
+
+Add tests for the shared progress helper:
+
+- `silent` does not create display handles or print output.
+- `short` starts the indicator.
+- `full` can compose content plus indicator.
+- label updates replace the visible label.
+- `stop()` suppresses cleanup errors.
+
+Extend tracker tests:
+
+- `FitProgressTracker.start_tracking(...)` starts the indicator in
+ `short`.
+- `silent` still shows nothing.
+- full fit mode uses label `fitting`.
+- sampler updates switch labels from `burn-in` to `sampling`.
+- finalization stops the indicator on success.
+- finalization stops the indicator when solver preparation or solver
+ execution raises.
+
+Extend sequential tests:
+
+- `fit_sequential(..., verbosity="short")` starts and stops the shared
+ indicator.
+- `fit_sequential(..., verbosity="silent")` does not start it.
+- chunk progress does not create a separate spinner.
+
+### Rendering Tests
+
+Terminal:
+
+- fake or monkeypatch `Live` and assert one live handle receives grouped
+ table-plus-indicator content.
+
+Jupyter:
+
+- fake `DisplayHandle` and assert generated HTML contains the activity
+ container and the selected label.
+- assert CSS animation is included once, not duplicated on every table
+ update if avoidable.
+
+### Regression Tests
+
+Keep existing tests passing:
+
+- `tests/unit/easydiffraction/analysis/fit_helpers/test_tracking.py`
+- `tests/integration/fitting/test_bayesian_tracker_and_base.py`
+- sequential tests under `tests/integration/fitting/test_sequential.py`
+
+## Implementation Sequence
+
+1. Add `display/progress.py` with a minimal `ActivityIndicator`.
+2. Add tests for verbosity behavior and label updates.
+3. Refactor table rendering just enough to allow Rich renderable and
+ Jupyter HTML composition.
+4. Wire `FitProgressTracker` to the activity indicator.
+5. Update tracker tests for `short`, `full`, `silent`, and sampler phase
+ labels.
+6. Wire sequential fitting to the shared activity indicator.
+7. Update sequential tests.
+8. Add generic `processing` context helper.
+9. Add the helper to posterior predictive and posterior pairs if
+ profiling or user feedback shows those operations need visible
+ activity feedback.
+10. Run focused unit tests, then the relevant integration tests.
+
+## Open Design Checks
+
+- Whether `short` mode should print the existing start line before the
+ spinner or show only the spinner until completion.
+- Whether terminal output should use Rich's built-in `Spinner` or the
+ explicit EasyDiffraction frame list. Prefer the explicit list if
+ consistency with Jupyter matters more than Rich defaults.
+- Whether the table and spinner should share one Jupyter display handle.
+ Prefer one handle unless it complicates the existing pandas backend.
+- Whether generic display/plot operations need a public verbosity
+ argument, or should only read `project.verbosity`.
diff --git a/docs/dev/plan_display-ux.md b/docs/dev/plan_display-ux.md
deleted file mode 100644
index 4088ac657..000000000
--- a/docs/dev/plan_display-ux.md
+++ /dev/null
@@ -1,238 +0,0 @@
-# Plan: Display UX Facade
-
-## Goal
-
-Implement the display UX approach described in
-`docs/dev/adr_display-ux.md`.
-
-The end-user API should become:
-
-```python
-project.display.pattern(expt_name='hrpt')
-
-project.display.parameters.free()
-project.display.parameters.fittable()
-project.display.parameters.all()
-project.display.parameters.access()
-project.display.parameters.cif_uids()
-
-project.display.fit.results()
-project.display.fit.correlations()
-project.display.fit.series(param, versus=temperature)
-
-project.display.posterior.pairs()
-project.display.posterior.distribution(param)
-project.display.posterior.predictive(expt_name='hrpt')
-
-project.display.show_pattern_options(expt_name='hrpt')
-```
-
-Renderer configuration should move to:
-
-```python
-project.rendering.chart_engine = 'plotly'
-project.rendering.table_engine = 'pandas'
-project.rendering.show_chart_engines()
-project.rendering.show_table_engines()
-project.rendering.show_config()
-```
-
-## Branch
-
-Use implementation branch:
-
-```text
-feature/display-ux
-```
-
-## Decisions
-
-- Use grouped display namespaces under `project.display`.
-- Use `project.display.fit.series(param, versus=...)` for sequential fit
- parameter plots.
-- Use `pattern(..., include='auto')` as the default experiment chart.
-- Use `include` rather than `layers`, `components`, `content`, `view`,
- `series`, or boolean flags.
-- Do not add `project.display.constraints()`.
-- Move constraint reporting to `project.analysis.constraints.show()`.
-- Rename the serialized project display category to `rendering`.
-- Do not add legacy CIF loading for `_display.plotter_type` or
- `_display.tabler_type`.
-- Standardize CIF display helpers on `project.analysis` and
- `project.info`: `as_cif` should be a read-only property returning CIF
- text, and `show_as_cif()` should pretty-print CIF text with a header.
-- Implement `include='uncertainty'` immediately where posterior
- predictive data exists.
-- No compatibility aliases or deprecation warnings are required unless
- release policy separately requires them.
-
-## Likely Files To Change
-
-- `src/easydiffraction/project/project.py`
-- `src/easydiffraction/project/project_info.py`
-- `src/easydiffraction/project/categories/display/default.py`
-- `src/easydiffraction/project/categories/display/factory.py`
-- `src/easydiffraction/project/categories/display/__init__.py`
-- new `src/easydiffraction/project/categories/rendering/...`
-- `src/easydiffraction/display/plotting.py`
-- `src/easydiffraction/display/tables.py`
-- `src/easydiffraction/analysis/analysis.py`
-- `src/easydiffraction/analysis/categories/constraints/default.py`
-- `src/easydiffraction/__main__.py`
-- `docs/dev/architecture.md`
-- `docs/docs/user-guide/**/*.md`
-- `docs/docs/tutorials/*.py`
-- tests under `tests/unit/easydiffraction/`
-- tests under `tests/integration/fitting/`
-
-Do not edit generated tutorial notebooks directly. Update tutorial `.py`
-files and regenerate notebooks in Phase 2 if required.
-
-## Resolved Questions
-
-- Legacy CIF `_display.plotter_type` and `_display.tabler_type` do not
- need to load into `project.rendering`. No legacy code is required.
-- `project.analysis` and `project.info` need consistency with structure
- and experiment objects: `as_cif` should be a read-only property that
- returns CIF text, and `show_as_cif()` should pretty-print CIF text
- with a header.
-- `include='uncertainty'` should be implemented immediately where
- posterior predictive data exists.
-
-## Phase 1 - Implementation
-
-Do not create or run tests in Phase 1 unless explicitly requested. 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. Use atomic commits, inspect the worktree before
-each commit, and stage only the files changed for that step.
-
-- [x] Rename the serialized project display category to rendering.
- - Move or recreate the category package as
- `src/easydiffraction/project/categories/rendering/`.
- - Rename user-facing settings from `plotter_type` and `tabler_type` to
- `chart_engine` and `table_engine`.
- - Add `show_chart_engines()`, `show_table_engines()`, and
- `show_config()`.
- - Update CIF names to `_rendering.chart_engine` and
- `_rendering.table_engine`.
- - Do not add legacy loading for `_display.plotter_type` or
- `_display.tabler_type`.
-
-- [x] Add the new `project.display` facade.
- - Add a facade object that is not the serialized rendering category.
- - Add `pattern(...)` and `show_pattern_options(...)`.
- - Add `parameters`, `fit`, and `posterior` namespace objects.
-
-- [x] Implement `pattern(..., include='auto')`.
- - Replace common user-facing calls to `plot_meas`, `plot_calc`, and
- `plot_meas_vs_calc` with one state-aware method.
- - Support explicit includes for `measured`, `calculated`,
- `background`, `residual`, `bragg`, `excluded`, and `uncertainty`
- where data is available.
- - Implement `uncertainty` immediately for experiments with posterior
- predictive data.
- - Render a clear warning or error when a requested include is not
- available.
-
-- [x] Move parameter table displays under `project.display.parameters`.
- - Implement `all()`, `fittable()`, `free()`, `access()`, and
- `cif_uids()`.
- - Remove the primary public need for `project.analysis.display`.
-
-- [x] Move fit displays under `project.display.fit`.
- - Implement `results()`.
- - Implement `correlations()`.
- - Implement `series(param, versus=...)`.
-
-- [x] Move Bayesian displays under `project.display.posterior`.
- - Implement `pairs()`.
- - Implement `distribution(param)`.
- - Implement `predictive(expt_name=...)`.
-
-- [x] Move constraint reporting to
- `project.analysis.constraints.show()`.
- - Do not add `project.display.constraints()`.
-
-- [x] Standardize CIF display helpers.
- - Convert `project.analysis.as_cif()` to a read-only
- `project.analysis.as_cif` property.
- - Ensure `project.analysis.show_as_cif()` pretty-prints CIF text with
- a header.
- - Convert `project.info.as_cif()` to a read-only `project.info.as_cif`
- property.
- - Ensure `project.info.show_as_cif()` pretty-prints CIF text with a
- header.
-
-- [x] Update docs, tutorials, and architecture text.
- - Replace old public display examples with the selected API.
- - Update `docs/dev/architecture.md`.
- - Update tutorial `.py` files only; regenerate notebooks in Phase 2 if
- required.
-
-- [x] Stop at the Phase 1 review gate.
- - Summarize changed files and open questions.
- - Suggest next verification commands.
- - Wait for user approval before Phase 2.
-
-Suggested Phase 1 commit messages:
-
-```text
-Rename display settings category to rendering
-Add grouped display facade
-Implement state-aware pattern display
-Move analysis display reports to display facade
-Standardize CIF display helpers
-Update display UX documentation
-```
-
-## Phase 2 - Verification
-
-After Phase 1 is reviewed and approved:
-
-- [x] Add or update unit tests for the rendering category.
-- [x] Add or update unit tests for the display facade namespaces.
-- [x] Add or update plotting integration tests for `pattern(...)`.
-- [x] Add or update analysis display integration tests for parameter and
- fit report methods.
-- [x] Regenerate tutorial notebooks if tutorial `.py` files changed.
-- [x] Run formatting and checks.
-- [x] Run unit tests.
-- [x] Run integration tests.
-- [x] Run script tests.
-
-Verification commands:
-
-```sh
-pixi run notebook-prepare
-pixi run fix
-pixi run check
-pixi run unit-tests
-pixi run integration-tests
-pixi run script-tests
-```
-
-Run `pixi run notebook-prepare` only if tutorial `.py` files changed.
-Run `pixi run integration-tests` only after the relevant unit and
-focused integration tests are passing.
-
-## Suggested Commit Message
-
-```text
-Plan display UX facade implementation
-```
-
-## Suggested Pull Request
-
-Title:
-
-```text
-Improve chart and table display API
-```
-
-Description:
-
-This change makes display commands easier to discover and use in
-notebooks. Experiment patterns, parameter tables, fit reports, and
-Bayesian plots are grouped under `project.display`, while renderer
-settings move to `project.rendering`.
diff --git a/docs/docs/quick-reference/index.md b/docs/docs/quick-reference/index.md
new file mode 100644
index 000000000..e0d3fd202
--- /dev/null
+++ b/docs/docs/quick-reference/index.md
@@ -0,0 +1,371 @@
+---
+icon: material/clipboard-text-outline
+---
+
+# :material-clipboard-text-outline: Quick Reference
+
+This page is a short refresher for day-to-day EasyDiffraction work. It
+collects the commands you are most likely to need when returning to a
+project, preparing a quick refinement, or checking how to inspect
+parameters and results.
+
+For complete explanations, use the [User Guide](../user-guide/index.md)
+and [Tutorials](../tutorials/index.md).
+
+## Start a Session
+
+Import the package and create or load a project:
+
+```python
+import easydiffraction as ed
+
+project = ed.Project(name='lbco_hrpt')
+```
+
+```python
+from easydiffraction import Project
+
+project = Project.load('lbco_hrpt')
+```
+
+Check the installed version:
+
+```python
+ed.show_version()
+```
+
+## Get Example Data
+
+Download a dataset by ID into a local directory:
+
+```python
+structure_path = ed.download_data(id=1, destination='data')
+data_path = ed.download_data(id=3, destination='data')
+```
+
+For tutorial notebooks:
+
+```python
+ed.list_tutorials()
+ed.download_tutorial(id=1, destination='tutorials')
+ed.download_all_tutorials(destination='tutorials')
+```
+
+## Build a Project
+
+Load a structure from CIF:
+
+```python
+project.structures.add_from_cif_path(structure_path)
+project.structures.show_names()
+
+structure = project.structures['lbco']
+```
+
+Create a structure directly:
+
+```python
+project.structures.create(name='lbco')
+structure = project.structures['lbco']
+
+structure.space_group.name_h_m = 'P m -3 m'
+structure.space_group.it_coordinate_system_code = '1'
+structure.cell.length_a = 3.88
+```
+
+Add an atom site:
+
+```python
+structure.atom_sites.create(
+ label='O',
+ type_symbol='O',
+ fract_x=0,
+ fract_y=0.5,
+ fract_z=0.5,
+ wyckoff_letter='c',
+ adp_iso=0.5,
+)
+```
+
+Load an experiment from measured data:
+
+```python
+project.experiments.add_from_data_path(
+ name='hrpt',
+ data_path=data_path,
+ sample_form='powder',
+ beam_mode='constant wavelength',
+ radiation_probe='neutron',
+)
+
+experiment = project.experiments['hrpt']
+```
+
+Set common experiment parameters:
+
+```python
+experiment.instrument.setup_wavelength = 1.494
+experiment.instrument.calib_twotheta_offset = 0.6
+
+experiment.peak.broad_gauss_u = 0.1
+experiment.peak.broad_gauss_v = -0.1
+experiment.peak.broad_gauss_w = 0.1
+experiment.peak.broad_lorentz_y = 0.1
+```
+
+Add background points and excluded regions:
+
+```python
+experiment.background.create(id='1', x=10, y=170)
+experiment.background.create(id='2', x=30, y=170)
+
+experiment.excluded_regions.create(id='1', start=0, end=5)
+experiment.excluded_regions.create(id='2', start=165, end=180)
+```
+
+Link a structure to an experiment:
+
+```python
+experiment.linked_phases.create(id='lbco', scale=10.0)
+```
+
+## Inspect the Project
+
+Show names, CIF text, and plotting options:
+
+```python
+project.structures.show_names()
+project.experiments.show_names()
+
+structure.show_as_cif()
+experiment.show_as_cif()
+
+project.display.show_pattern_options(expt_name='hrpt')
+```
+
+Open the main display views:
+
+```python
+project.display.pattern(expt_name='hrpt')
+project.display.parameters.all()
+project.display.parameters.fittable()
+project.display.parameters.free()
+project.display.parameters.access()
+project.display.parameters.cif_uids()
+```
+
+## Show Tables and Select Types
+
+EasyDiffraction uses two related display patterns:
+
+- `show_*()` usually lists supported choices or displays a current
+ configuration.
+- `.show()` on a loop-style object usually prints rows you have already
+ created.
+
+Show created loop contents:
+
+```python
+experiment.background.show()
+experiment.excluded_regions.show()
+project.analysis.constraints.show()
+```
+
+List supported type choices. The current selection is marked in the
+output:
+
+```python
+experiment.show_peak_profile_types()
+experiment.show_background_types()
+experiment.calculation.show_calculator_types()
+
+project.analysis.fit.show_modes()
+project.analysis.fit.show_minimizer_types()
+
+project.rendering.show_chart_engines()
+project.rendering.show_table_engines()
+project.rendering.show_config()
+```
+
+Change the active type by assigning the corresponding `*_type` property:
+
+```python
+experiment.peak_profile_type = 'pseudo-voigt'
+experiment.background_type = 'line-segment'
+experiment.calculation.calculator_type = 'cryspy'
+
+project.analysis.fit.mode = 'single'
+project.analysis.fit.minimizer_type = 'lmfit'
+
+project.rendering.chart_engine = 'plotly'
+project.rendering.table_engine = 'rich'
+```
+
+For single-crystal experiments, extinction uses the same pattern:
+
+```python
+experiment.show_extinction_types()
+experiment.extinction_type = 'becker-coppens'
+```
+
+## Find Commands with Help
+
+Use help methods when you do not remember the exact command. The most
+useful starting points are the project-level display and analysis
+facades:
+
+```python
+project.display.help()
+project.display.parameters.help()
+project.display.fit.help()
+project.analysis.help()
+```
+
+Drill into project collections to see what they contain:
+
+```python
+project.structures.help()
+project.experiments.help()
+```
+
+Then inspect one structure or experiment:
+
+```python
+structure = project.structures['lbco']
+experiment = project.experiments['hrpt']
+
+structure.help()
+experiment.help()
+```
+
+Category-level help shows available parameters and methods. This is
+often the fastest way to remember exact names:
+
+```python
+structure.cell.help()
+structure.atom_sites.help()
+structure.atom_sites['O'].help()
+
+experiment.instrument.help()
+experiment.peak.help()
+experiment.background.help()
+experiment.background['1'].help()
+```
+
+Individual parameters also expose help. Use this when you need to check
+whether a parameter is writable, free, constrained, or has fit bounds:
+
+```python
+structure.cell.length_a.help()
+structure.atom_sites['O'].adp_iso.help()
+
+experiment.instrument.calib_twotheta_offset.help()
+experiment.linked_phases['lbco'].scale.help()
+```
+
+The usual navigation pattern is:
+
+```text
+project → structures/experiments → structure/experiment → category → item → parameter
+```
+
+## Refine Parameters
+
+Mark parameters as free:
+
+```python
+structure.cell.length_a.free = True
+structure.atom_sites['O'].adp_iso.free = True
+
+experiment.instrument.calib_twotheta_offset.free = True
+experiment.peak.broad_gauss_u.free = True
+experiment.background['1'].y.free = True
+experiment.linked_phases['lbco'].scale.free = True
+```
+
+Choose calculators and minimizers:
+
+```python
+experiment.calculation.show_calculator_types()
+experiment.calculation.calculator_type = 'cryspy'
+
+project.analysis.fit.show_modes()
+project.analysis.fit.mode = 'single'
+
+project.analysis.fit.show_minimizer_types()
+project.analysis.fit.minimizer_type = 'lmfit'
+```
+
+Run a fit and inspect the result:
+
+```python
+project.analysis.fit()
+
+project.display.fit.results()
+project.display.fit.correlations()
+project.display.pattern(expt_name='hrpt')
+```
+
+## Add Simple Constraints
+
+Create aliases from parameter objects, then define a constraint
+expression using those aliases:
+
+```python
+project.analysis.aliases.create(
+ label='biso_la',
+ param=project.structures['lbco'].atom_sites['La'].adp_iso,
+)
+project.analysis.aliases.create(
+ label='biso_ba',
+ param=project.structures['lbco'].atom_sites['Ba'].adp_iso,
+)
+
+project.analysis.constraints.create(expression='biso_ba = biso_la')
+```
+
+Show the created constraints:
+
+```python
+project.analysis.constraints.show()
+```
+
+Then fit again:
+
+```python
+project.analysis.fit()
+project.display.fit.results()
+```
+
+## Save and Reuse Work
+
+Save a project directory for later:
+
+```python
+project.save_as(dir_path='lbco_hrpt')
+project.save()
+```
+
+Load it again:
+
+```python
+project = ed.Project.load('lbco_hrpt')
+```
+
+Run a saved project from the command line:
+
+```bash
+python -m easydiffraction fit lbco_hrpt
+python -m easydiffraction fit lbco_hrpt --dry
+```
+
+## Command-Line Reminders
+
+```bash
+python -m easydiffraction --help
+python -m easydiffraction --version
+python -m easydiffraction list-tutorials
+python -m easydiffraction download-tutorial 1 --destination tutorials
+python -m easydiffraction download-all-tutorials --destination tutorials
+python -m easydiffraction fit PROJECT_DIR
+```
diff --git a/docs/docs/tutorials/ed-3.ipynb b/docs/docs/tutorials/ed-3.ipynb
index 03fba5148..0f103f8aa 100644
--- a/docs/docs/tutorials/ed-3.ipynb
+++ b/docs/docs/tutorials/ed-3.ipynb
@@ -822,7 +822,7 @@
"metadata": {},
"outputs": [],
"source": [
- "# project.display.parameters.all()"
+ "project.display.parameters.all()"
]
},
{
@@ -876,7 +876,7 @@
"metadata": {},
"outputs": [],
"source": [
- "# project.display.parameters.access()"
+ "project.display.parameters.access()"
]
},
{
@@ -1068,24 +1068,6 @@
"cell_type": "markdown",
"id": "103",
"metadata": {},
- "source": [
- "#### Save Project State"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "id": "104",
- "metadata": {},
- "outputs": [],
- "source": [
- "project.save_as(dir_path='lbco_hrpt', temporary=True)"
- ]
- },
- {
- "cell_type": "markdown",
- "id": "105",
- "metadata": {},
"source": [
"### Perform Fit 2/5\n",
"\n",
@@ -1095,7 +1077,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "106",
+ "id": "104",
"metadata": {},
"outputs": [],
"source": [
@@ -1107,7 +1089,7 @@
},
{
"cell_type": "markdown",
- "id": "107",
+ "id": "105",
"metadata": {},
"source": [
"Show free parameters after selection."
@@ -1116,7 +1098,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "108",
+ "id": "106",
"metadata": {},
"outputs": [],
"source": [
@@ -1125,7 +1107,7 @@
},
{
"cell_type": "markdown",
- "id": "109",
+ "id": "107",
"metadata": {},
"source": [
"#### Run Fitting"
@@ -1134,7 +1116,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "110",
+ "id": "108",
"metadata": {},
"outputs": [],
"source": [
@@ -1144,7 +1126,7 @@
},
{
"cell_type": "markdown",
- "id": "111",
+ "id": "109",
"metadata": {},
"source": [
"#### Plot Measured vs Calculated"
@@ -1153,7 +1135,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "112",
+ "id": "110",
"metadata": {},
"outputs": [],
"source": [
@@ -1163,7 +1145,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "113",
+ "id": "111",
"metadata": {},
"outputs": [],
"source": [
@@ -1172,7 +1154,7 @@
},
{
"cell_type": "markdown",
- "id": "114",
+ "id": "112",
"metadata": {},
"source": [
"#### Save Project State"
@@ -1181,16 +1163,16 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "115",
+ "id": "113",
"metadata": {},
"outputs": [],
"source": [
- "project.save_as(dir_path='lbco_hrpt', temporary=True)"
+ "project.save()"
]
},
{
"cell_type": "markdown",
- "id": "116",
+ "id": "114",
"metadata": {},
"source": [
"### Perform Fit 3/5\n",
@@ -1201,7 +1183,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "117",
+ "id": "115",
"metadata": {},
"outputs": [],
"source": [
@@ -1213,7 +1195,7 @@
},
{
"cell_type": "markdown",
- "id": "118",
+ "id": "116",
"metadata": {},
"source": [
"Show free parameters after selection."
@@ -1222,7 +1204,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "119",
+ "id": "117",
"metadata": {},
"outputs": [],
"source": [
@@ -1231,7 +1213,7 @@
},
{
"cell_type": "markdown",
- "id": "120",
+ "id": "118",
"metadata": {},
"source": [
"#### Run Fitting"
@@ -1240,7 +1222,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "121",
+ "id": "119",
"metadata": {},
"outputs": [],
"source": [
@@ -1250,7 +1232,7 @@
},
{
"cell_type": "markdown",
- "id": "122",
+ "id": "120",
"metadata": {},
"source": [
"#### Plot Measured vs Calculated"
@@ -1259,7 +1241,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "123",
+ "id": "121",
"metadata": {},
"outputs": [],
"source": [
@@ -1269,7 +1251,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "124",
+ "id": "122",
"metadata": {},
"outputs": [],
"source": [
@@ -1278,25 +1260,7 @@
},
{
"cell_type": "markdown",
- "id": "125",
- "metadata": {},
- "source": [
- "#### Save Project State"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "id": "126",
- "metadata": {},
- "outputs": [],
- "source": [
- "project.save_as(dir_path='lbco_hrpt', temporary=True)"
- ]
- },
- {
- "cell_type": "markdown",
- "id": "127",
+ "id": "123",
"metadata": {},
"source": [
"### Perform Fit 4/5\n",
@@ -1309,7 +1273,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "128",
+ "id": "124",
"metadata": {},
"outputs": [],
"source": [
@@ -1325,7 +1289,7 @@
},
{
"cell_type": "markdown",
- "id": "129",
+ "id": "125",
"metadata": {},
"source": [
"Set constraints."
@@ -1334,7 +1298,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "130",
+ "id": "126",
"metadata": {},
"outputs": [],
"source": [
@@ -1343,7 +1307,7 @@
},
{
"cell_type": "markdown",
- "id": "131",
+ "id": "127",
"metadata": {},
"source": [
"Show defined constraints."
@@ -1352,7 +1316,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "132",
+ "id": "128",
"metadata": {},
"outputs": [],
"source": [
@@ -1361,7 +1325,7 @@
},
{
"cell_type": "markdown",
- "id": "133",
+ "id": "129",
"metadata": {},
"source": [
"Show free parameters."
@@ -1370,7 +1334,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "134",
+ "id": "130",
"metadata": {},
"outputs": [],
"source": [
@@ -1379,7 +1343,7 @@
},
{
"cell_type": "markdown",
- "id": "135",
+ "id": "131",
"metadata": {},
"source": [
"#### Run Fitting"
@@ -1388,7 +1352,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "136",
+ "id": "132",
"metadata": {},
"outputs": [],
"source": [
@@ -1398,7 +1362,7 @@
},
{
"cell_type": "markdown",
- "id": "137",
+ "id": "133",
"metadata": {},
"source": [
"#### Plot Measured vs Calculated"
@@ -1407,7 +1371,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "138",
+ "id": "134",
"metadata": {},
"outputs": [],
"source": [
@@ -1417,7 +1381,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "139",
+ "id": "135",
"metadata": {},
"outputs": [],
"source": [
@@ -1426,25 +1390,7 @@
},
{
"cell_type": "markdown",
- "id": "140",
- "metadata": {},
- "source": [
- "#### Save Project State"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "id": "141",
- "metadata": {},
- "outputs": [],
- "source": [
- "project.save_as(dir_path='lbco_hrpt', temporary=True)"
- ]
- },
- {
- "cell_type": "markdown",
- "id": "142",
+ "id": "136",
"metadata": {},
"source": [
"### Perform Fit 5/5\n",
@@ -1457,7 +1403,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "143",
+ "id": "137",
"metadata": {},
"outputs": [],
"source": [
@@ -1473,7 +1419,7 @@
},
{
"cell_type": "markdown",
- "id": "144",
+ "id": "138",
"metadata": {},
"source": [
"Set more constraints."
@@ -1482,7 +1428,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "145",
+ "id": "139",
"metadata": {},
"outputs": [],
"source": [
@@ -1493,7 +1439,7 @@
},
{
"cell_type": "markdown",
- "id": "146",
+ "id": "140",
"metadata": {},
"source": [
"Show defined constraints."
@@ -1502,7 +1448,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "147",
+ "id": "141",
"metadata": {
"lines_to_next_cell": 2
},
@@ -1513,7 +1459,7 @@
},
{
"cell_type": "markdown",
- "id": "148",
+ "id": "142",
"metadata": {},
"source": [
"Set structure parameters to be refined."
@@ -1522,7 +1468,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "149",
+ "id": "143",
"metadata": {},
"outputs": [],
"source": [
@@ -1531,7 +1477,7 @@
},
{
"cell_type": "markdown",
- "id": "150",
+ "id": "144",
"metadata": {},
"source": [
"Show free parameters after selection."
@@ -1540,7 +1486,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "151",
+ "id": "145",
"metadata": {},
"outputs": [],
"source": [
@@ -1549,7 +1495,7 @@
},
{
"cell_type": "markdown",
- "id": "152",
+ "id": "146",
"metadata": {},
"source": [
"#### Run Fitting"
@@ -1558,7 +1504,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "153",
+ "id": "147",
"metadata": {},
"outputs": [],
"source": [
@@ -1569,7 +1515,7 @@
},
{
"cell_type": "markdown",
- "id": "154",
+ "id": "148",
"metadata": {},
"source": [
"#### Plot Measured vs Calculated"
@@ -1578,7 +1524,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "155",
+ "id": "149",
"metadata": {},
"outputs": [],
"source": [
@@ -1588,7 +1534,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "156",
+ "id": "150",
"metadata": {},
"outputs": [],
"source": [
@@ -1597,7 +1543,7 @@
},
{
"cell_type": "markdown",
- "id": "157",
+ "id": "151",
"metadata": {},
"source": [
"#### Save Project State"
@@ -1606,16 +1552,16 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "158",
+ "id": "152",
"metadata": {},
"outputs": [],
"source": [
- "project.save_as(dir_path='lbco_hrpt', temporary=True)"
+ "project.save()"
]
},
{
"cell_type": "markdown",
- "id": "159",
+ "id": "153",
"metadata": {},
"source": [
"## Step 5: Summary\n",
@@ -1625,7 +1571,7 @@
},
{
"cell_type": "markdown",
- "id": "160",
+ "id": "154",
"metadata": {},
"source": [
"#### Show Project Summary"
@@ -1634,7 +1580,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "161",
+ "id": "155",
"metadata": {},
"outputs": [],
"source": [
diff --git a/docs/docs/tutorials/ed-3.py b/docs/docs/tutorials/ed-3.py
index 271b8e8f0..fcccb5968 100644
--- a/docs/docs/tutorials/ed-3.py
+++ b/docs/docs/tutorials/ed-3.py
@@ -345,7 +345,7 @@
# Show all parameters of the project.
# %%
-# project.display.parameters.all()
+project.display.parameters.all()
# %% [markdown]
# Show all fittable parameters.
@@ -363,7 +363,7 @@
# Show how to access parameters in the code.
# %%
-# project.display.parameters.access()
+project.display.parameters.access()
# %% [markdown]
# #### Set Fit Mode
@@ -435,12 +435,6 @@
# %%
project.display.pattern(expt_name='hrpt', x_min=38, x_max=41)
-# %% [markdown]
-# #### Save Project State
-
-# %%
-project.save_as(dir_path='lbco_hrpt', temporary=True)
-
# %% [markdown]
# ### Perform Fit 2/5
#
@@ -478,7 +472,7 @@
# #### Save Project State
# %%
-project.save_as(dir_path='lbco_hrpt', temporary=True)
+project.save()
# %% [markdown]
# ### Perform Fit 3/5
@@ -513,12 +507,6 @@
# %%
project.display.pattern(expt_name='hrpt', x_min=38, x_max=41)
-# %% [markdown]
-# #### Save Project State
-
-# %%
-project.save_as(dir_path='lbco_hrpt', temporary=True)
-
# %% [markdown]
# ### Perform Fit 4/5
#
@@ -570,12 +558,6 @@
# %%
project.display.pattern(expt_name='hrpt', x_min=38, x_max=41)
-# %% [markdown]
-# #### Save Project State
-
-# %%
-project.save_as(dir_path='lbco_hrpt', temporary=True)
-
# %% [markdown]
# ### Perform Fit 5/5
#
@@ -641,7 +623,7 @@
# #### Save Project State
# %%
-project.save_as(dir_path='lbco_hrpt', temporary=True)
+project.save()
# %% [markdown]
# ## Step 5: Summary
diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml
index 16091a5d6..fdd6a30b0 100644
--- a/docs/mkdocs.yml
+++ b/docs/mkdocs.yml
@@ -176,6 +176,8 @@ nav:
- Introduction: introduction/index.md
- Installation & Setup:
- Installation & Setup: installation-and-setup/index.md
+ - Quick Reference:
+ - Quick Reference: quick-reference/index.md
- User Guide:
- User Guide: user-guide/index.md
- Glossary: user-guide/glossary.md
@@ -221,6 +223,8 @@ nav:
- Tb2TiO7 Bayesian: tutorials/ed-22.ipynb
- Workshops & Schools:
- DMSC Summer School: tutorials/ed-13.ipynb
+ - Command-Line:
+ - Command-Line: cli/index.md
- API Reference:
- API Reference: api-reference/index.md
- analysis: api-reference/analysis.md
@@ -234,5 +238,3 @@ nav:
- project: api-reference/project.md
- summary: api-reference/summary.md
- utils: api-reference/utils.md
- - Command-Line:
- - Command-Line: cli/index.md
diff --git a/src/easydiffraction/analysis/analysis.py b/src/easydiffraction/analysis/analysis.py
index e3c01607f..484c3e03d 100644
--- a/src/easydiffraction/analysis/analysis.py
+++ b/src/easydiffraction/analysis/analysis.py
@@ -16,7 +16,6 @@
from easydiffraction.analysis.categories.joint_fit_experiments import JointFitExperiments
from easydiffraction.analysis.fit_helpers.tracking import _make_display_handle
from easydiffraction.analysis.fitting import Fitter
-from easydiffraction.core.guard import GuardedBase
from easydiffraction.core.singleton import ConstraintsHandler
from easydiffraction.core.variable import NumericDescriptor
from easydiffraction.core.variable import Parameter
@@ -26,75 +25,23 @@
from easydiffraction.utils.enums import VerbosityEnum
from easydiffraction.utils.logging import console
from easydiffraction.utils.logging import log
+from easydiffraction.utils.utils import _help_method_rows
+from easydiffraction.utils.utils import _help_property_rows
from easydiffraction.utils.utils import render_cif
+from easydiffraction.utils.utils import render_object_help
from easydiffraction.utils.utils import render_table
+_SUMMARY_HIDDEN_PARAMETER_CATEGORIES = frozenset({'pd_data', 'total_data', 'refln'})
-def _discover_property_rows(cls: type) -> list[list[str]]:
- """
- Discover public properties from the class MRO.
- Parameters
- ----------
- cls : type
- The class to inspect.
-
- Returns
- -------
- list[list[str]]
- Table rows with ``[index, name, writable, description]``.
- """
- seen: dict = {}
- for base in cls.mro():
- for key, attr in base.__dict__.items():
- if key.startswith('_') or not isinstance(attr, property):
- continue
- if key not in seen:
- seen[key] = attr
-
- rows = []
- for i, key in enumerate(sorted(seen), 1):
- prop = seen[key]
- writable = '✓' if prop.fset else '✗'
- doc = GuardedBase._first_sentence(prop.fget.__doc__ if prop.fget else None)
- rows.append([str(i), key, writable, doc])
- return rows
+def _discover_property_rows(cls: type) -> list[list[str]]:
+ """Return public property rows for analysis help tables."""
+ return _help_property_rows(cls)
def _discover_method_rows(cls: type) -> list[list[str]]:
- """
- Discover public methods from the class MRO.
-
- Parameters
- ----------
- cls : type
- The class to inspect.
-
- Returns
- -------
- list[list[str]]
- Table rows with ``[index, name(), description]``.
- """
- seen_methods: set = set()
- methods_list: list = []
- for base in cls.mro():
- for key, attr in base.__dict__.items():
- if key.startswith('_') or key in seen_methods:
- continue
- if isinstance(attr, property):
- continue
- raw = attr
- if isinstance(raw, (staticmethod, classmethod)):
- raw = raw.__func__
- if callable(raw):
- seen_methods.add(key)
- methods_list.append((key, raw))
-
- rows = []
- for i, (key, method) in enumerate(sorted(methods_list), 1):
- doc = GuardedBase._first_sentence(getattr(method, '__doc__', None))
- rows.append([str(i), f'{key}()', doc])
- return rows
+ """Return public method rows for analysis help tables."""
+ return _help_method_rows(cls)
class AnalysisDisplay:
@@ -107,6 +54,10 @@ class AnalysisDisplay:
def __init__(self, analysis: Analysis) -> None:
self._analysis = analysis
+ def help(self) -> None:
+ """Print available analysis-display methods."""
+ render_object_help(self)
+
def _flush_structure_categories(self) -> None:
"""
Flush pending category updates so symmetry flags are fresh.
@@ -116,12 +67,23 @@ def _flush_structure_categories(self) -> None:
structure._need_categories_update = True
structure._update_categories()
+ @staticmethod
+ def _summary_parameters(
+ params: list[StringDescriptor | NumericDescriptor | Parameter],
+ ) -> list[StringDescriptor | NumericDescriptor | Parameter]:
+ """Return parameters suitable for compact summary displays."""
+ return [
+ param
+ for param in params
+ if param._identity.category_code not in _SUMMARY_HIDDEN_PARAMETER_CATEGORIES
+ ]
+
def all_params(self) -> None:
"""Print all parameters for structures and experiments."""
project = self._analysis.project
self._flush_structure_categories()
- structures_params = project.structures.parameters
- experiments_params = project.experiments.parameters
+ structures_params = self._summary_parameters(project.structures.parameters)
+ experiments_params = self._summary_parameters(project.experiments.parameters)
if not structures_params and not experiments_params:
log.warning('No parameters found.')
@@ -138,15 +100,17 @@ def all_params(self) -> None:
'fittable',
]
- console.paragraph('All parameters for all structures (🧩 data blocks)')
- df = Analysis._get_params_as_dataframe(structures_params)
- filtered_df = df[filtered_headers]
- tabler.render(filtered_df)
+ if structures_params:
+ console.paragraph('All parameters for all structures (🧩 data blocks)')
+ df = Analysis._get_params_as_dataframe(structures_params)
+ filtered_df = df[filtered_headers]
+ tabler.render(filtered_df)
- console.paragraph('All parameters for all experiments (🔬 data blocks)')
- df = Analysis._get_params_as_dataframe(experiments_params)
- filtered_df = df[filtered_headers]
- tabler.render(filtered_df)
+ if experiments_params:
+ console.paragraph('All parameters for all experiments (🔬 data blocks)')
+ df = Analysis._get_params_as_dataframe(experiments_params)
+ filtered_df = df[filtered_headers]
+ tabler.render(filtered_df)
def fittable_params(self) -> None:
"""Print all fittable parameters."""
@@ -172,15 +136,17 @@ def fittable_params(self) -> None:
'free',
]
- console.paragraph('Fittable parameters for all structures (🧩 data blocks)')
- df = Analysis._get_params_as_dataframe(structures_params)
- filtered_df = df[filtered_headers]
- tabler.render(filtered_df)
+ if structures_params:
+ console.paragraph('Fittable parameters for all structures (🧩 data blocks)')
+ df = Analysis._get_params_as_dataframe(structures_params)
+ filtered_df = df[filtered_headers]
+ tabler.render(filtered_df)
- console.paragraph('Fittable parameters for all experiments (🔬 data blocks)')
- df = Analysis._get_params_as_dataframe(experiments_params)
- filtered_df = df[filtered_headers]
- tabler.render(filtered_df)
+ if experiments_params:
+ console.paragraph('Fittable parameters for all experiments (🔬 data blocks)')
+ df = Analysis._get_params_as_dataframe(experiments_params)
+ filtered_df = df[filtered_headers]
+ tabler.render(filtered_df)
def free_params(self) -> None:
"""Print only currently free (varying) parameters."""
@@ -225,14 +191,14 @@ def how_to_access_parameters(self) -> None:
code.
"""
project = self._analysis.project
- structures_params = project.structures.parameters
- experiments_params = project.experiments.parameters
+ structures_params = self._summary_parameters(project.structures.parameters)
+ experiments_params = self._summary_parameters(project.experiments.parameters)
all_params = {
'structures': structures_params,
'experiments': experiments_params,
}
- if not all_params:
+ if not structures_params and not experiments_params:
log.warning('No parameters found.')
return
@@ -291,14 +257,14 @@ def parameter_cif_uids(self) -> None:
creating CIF-based constraints.
"""
project = self._analysis.project
- structures_params = project.structures.parameters
- experiments_params = project.experiments.parameters
+ structures_params = self._summary_parameters(project.structures.parameters)
+ experiments_params = self._summary_parameters(project.experiments.parameters)
all_params = {
'structures': structures_params,
'experiments': experiments_params,
}
- if not all_params:
+ if not structures_params and not experiments_params:
log.warning('No parameters found.')
return
@@ -412,27 +378,7 @@ def display(self) -> AnalysisDisplay:
def help(self) -> None:
"""Print a summary of analysis properties and methods."""
- console.paragraph("Help for 'Analysis'")
-
- cls = type(self)
-
- prop_rows = _discover_property_rows(cls)
- if prop_rows:
- console.paragraph('Properties')
- render_table(
- columns_headers=['#', 'Name', 'Writable', 'Description'],
- columns_alignment=['right', 'left', 'center', 'left'],
- columns_data=prop_rows,
- )
-
- method_rows = _discover_method_rows(cls)
- if method_rows:
- console.paragraph('Methods')
- render_table(
- columns_headers=['#', 'Name', 'Description'],
- columns_alignment=['right', 'left', 'left'],
- columns_data=method_rows,
- )
+ render_object_help(self)
# ------------------------------------------------------------------
# Parameter helpers
@@ -475,7 +421,8 @@ def _get_params_as_dataframe(
}
if isinstance(param, Parameter):
record |= {
- ('fittable', 'left'): True,
+ ('fittable', 'left'): not param.user_constrained
+ and not param.symmetry_constrained,
('free', 'left'): param.free,
('min', 'right'): param.fit_min,
('max', 'right'): param.fit_max,
@@ -551,7 +498,7 @@ def _run_fit(
log.warning('No experiments found in the project. Cannot run fit.')
return
- # Apply constraints before fitting so that constrained
+ # Apply constraints before fitting so that user-constrained
# parameters are marked and excluded from the free parameter
# list built by the fitter.
self._update_categories()
diff --git a/src/easydiffraction/analysis/fitting.py b/src/easydiffraction/analysis/fitting.py
index db5590e1b..343c15742 100644
--- a/src/easydiffraction/analysis/fitting.py
+++ b/src/easydiffraction/analysis/fitting.py
@@ -81,7 +81,7 @@ def fit(
expt_free_params.extend(
p
for p in expt.parameters
- if isinstance(p, Parameter) and not p.constrained and p.free
+ if isinstance(p, Parameter) and not p.user_constrained and p.free
)
params = structures.free_parameters + expt_free_params
diff --git a/src/easydiffraction/analysis/sequential.py b/src/easydiffraction/analysis/sequential.py
index 220179f89..25feb184e 100644
--- a/src/easydiffraction/analysis/sequential.py
+++ b/src/easydiffraction/analysis/sequential.py
@@ -452,7 +452,7 @@ def _build_template(project: object) -> SequentialFitTemplate:
free_names: list[str] = []
initial_params: dict[str, float] = {}
for p in all_params:
- if isinstance(p, Parameter) and not p.constrained and p.free:
+ if isinstance(p, Parameter) and not p.user_constrained and p.free:
free_names.append(p.unique_name)
initial_params[p.unique_name] = p.value
@@ -612,7 +612,9 @@ def _check_seq_preconditions(project: object) -> list[str]:
from easydiffraction.core.variable import Parameter # noqa: PLC0415
free_params = [
- p for p in project.parameters if isinstance(p, Parameter) and not p.constrained and p.free
+ p
+ for p in project.parameters
+ if isinstance(p, Parameter) and not p.user_constrained and p.free
]
if not free_params:
msg = 'No free parameters found. Mark at least one parameter as free.'
diff --git a/src/easydiffraction/core/collection.py b/src/easydiffraction/core/collection.py
index 520bf94f9..356469354 100644
--- a/src/easydiffraction/core/collection.py
+++ b/src/easydiffraction/core/collection.py
@@ -79,6 +79,19 @@ def __setitem__(self, name: str, item: GuardedBase) -> None:
self._items.append(item)
self._rebuild_index()
+ def _adopt_items(self, items: list[GuardedBase]) -> None:
+ """
+ Replace items and link each child to this collection.
+ """
+ for item in self._items:
+ item._parent = None
+
+ for item in items:
+ item._parent = self
+
+ self._items = items
+ self._rebuild_index()
+
def __delitem__(self, name: str) -> None:
"""Delete an item by key or raise ``KeyError`` if missing."""
for i, item in enumerate(self._items):
diff --git a/src/easydiffraction/core/datablock.py b/src/easydiffraction/core/datablock.py
index f6f7ae056..0ac201760 100644
--- a/src/easydiffraction/core/datablock.py
+++ b/src/easydiffraction/core/datablock.py
@@ -187,8 +187,12 @@ def parameters(self) -> list:
@property
def fittable_parameters(self) -> list:
- """All non-constrained Parameters in this collection."""
- return [p for p in self.parameters if isinstance(p, Parameter) and not p.constrained]
+ """All Parameters not blocked by constraints or symmetry."""
+ return [
+ p
+ for p in self.parameters
+ if isinstance(p, Parameter) and not p.user_constrained and not p.symmetry_constrained
+ ]
@property
def free_parameters(self) -> list:
diff --git a/src/easydiffraction/core/singleton.py b/src/easydiffraction/core/singleton.py
index 59ade7c38..9f997e535 100644
--- a/src/easydiffraction/core/singleton.py
+++ b/src/easydiffraction/core/singleton.py
@@ -34,7 +34,7 @@ def get(cls) -> Self:
# ======================================================================
-# TODO: Implement changing atrr '.constrained' back to False
+# TODO: Implement changing attr '.user_constrained' back to False
# when removing constraints
class ConstraintsHandler(SingletonBase):
"""
@@ -95,7 +95,7 @@ def apply(self) -> None:
For each constraint:
- Evaluate RHS using current values of aliased parameters
- Locate the dependent parameter via direct alias reference
- - Update its value and mark it as constrained
+ - Update its value and mark it as user constrained
"""
if not self._parsed_constraints:
return # Nothing to apply
@@ -132,8 +132,8 @@ def apply(self) -> None:
# Get the actual parameter object we want to update
param = self._alias_to_param[lhs_alias].param
- # Update its value and mark it as constrained
- param._set_value_constrained(rhs_value)
+ # Update its value and mark it as user constrained
+ param._set_value_user_constrained(rhs_value)
except (ValueError, TypeError, ArithmeticError, KeyError, AttributeError) as error:
print(f"Failed to apply constraint '{lhs_alias} = {rhs_expr}': {error}")
diff --git a/src/easydiffraction/core/variable.py b/src/easydiffraction/core/variable.py
index 303c5bbe5..55af6cba9 100644
--- a/src/easydiffraction/core/variable.py
+++ b/src/easydiffraction/core/variable.py
@@ -285,10 +285,10 @@ def __init__(
self._fit_bounds_uncertainty_multiplier: float | None = None
self._start_value_spec = AttributeSpec(data_type=DataTypes.NUMERIC, default=0.0)
self._start_value = self._start_value_spec.default
- self._constrained_spec = self._BOOL_SPEC_TEMPLATE
- self._constrained = self._constrained_spec.default
- self._symmetry_fixed_spec = self._BOOL_SPEC_TEMPLATE
- self._symmetry_fixed = self._symmetry_fixed_spec.default
+ self._user_constrained_spec = self._BOOL_SPEC_TEMPLATE
+ self._user_constrained = self._user_constrained_spec.default
+ self._symmetry_constrained_spec = self._BOOL_SPEC_TEMPLATE
+ self._symmetry_constrained = self._symmetry_constrained_spec.default
def _physical_lower_bound(self) -> float:
"""
@@ -325,22 +325,22 @@ def _minimizer_uid(self) -> str:
return self.unique_name.replace('.', '__')
@property
- def constrained(self) -> bool:
+ def user_constrained(self) -> bool:
"""Whether this parameter is part of a constraint expression."""
- return self._constrained
+ return self._user_constrained
- def _set_value_constrained(self, v: object) -> None:
+ def _set_value_user_constrained(self, v: object) -> None:
"""
Set the value from a constraint expression.
Bypasses validation and marks the parent datablock dirty, like
``_set_value_from_minimizer``, because constraints are applied
inside the minimizer loop where trial values may exceed
- physical-range validators. Flags the parameter as constrained.
- Used exclusively by ``ConstraintsHandler.apply()``.
+ physical-range validators. Flags the parameter as user
+ constrained. Used exclusively by ``ConstraintsHandler.apply()``.
"""
self._value = v
- self._constrained = True
+ self._user_constrained = True
parent_datablock = self._datablock_item()
if parent_datablock is not None:
parent_datablock._need_categories_update = True
@@ -356,24 +356,24 @@ def free(self, v: bool) -> None:
validated = self._free_spec.validated(
v, name=f'{self.unique_name}.free', current=self._free
)
- if validated and self._symmetry_fixed:
+ if validated and self._symmetry_constrained:
log.warning(
- f"Parameter '{self.unique_name}' is fixed by symmetry. Ignoring free=True."
+ f"Parameter '{self.unique_name}' is constrained by symmetry. Ignoring free=True."
)
self._free = False
return
self._free = validated
@property
- def symmetry_fixed(self) -> bool:
+ def symmetry_constrained(self) -> bool:
"""
- Whether this parameter is fixed by crystallographic symmetry.
+ Return whether symmetry constrains this parameter.
"""
- return self._symmetry_fixed
+ return self._symmetry_constrained
- def _set_symmetry_fixed(self, *, value: bool) -> None:
+ def _set_symmetry_constrained(self, *, value: bool) -> None:
"""
- Mark or unmark this parameter as fixed by symmetry.
+ Mark or unmark this parameter as constrained by symmetry.
When set to True, ``free`` is forced to False and any subsequent
attempt to set ``free = True`` is ignored with a warning. When
@@ -383,14 +383,14 @@ def _set_symmetry_fixed(self, *, value: bool) -> None:
Parameters
----------
value : bool
- New symmetry-fixed state.
+ New symmetry-constrained state.
"""
- validated = self._symmetry_fixed_spec.validated(
+ validated = self._symmetry_constrained_spec.validated(
value,
- name=f'{self.unique_name}.symmetry_fixed',
- current=self._symmetry_fixed,
+ name=f'{self.unique_name}.symmetry_constrained',
+ current=self._symmetry_constrained,
)
- self._symmetry_fixed = validated
+ self._symmetry_constrained = validated
if validated:
self._free = False
diff --git a/src/easydiffraction/crystallography/crystallography.py b/src/easydiffraction/crystallography/crystallography.py
index 7e97be444..4bc0dba5c 100644
--- a/src/easydiffraction/crystallography/crystallography.py
+++ b/src/easydiffraction/crystallography/crystallography.py
@@ -118,7 +118,7 @@ def _crystal_system_from_name_hm(name_hm: str) -> str | None:
return crystal_system
-_CELL_FIXED_AXES_BY_SYSTEM: dict[str, set[str]] = {
+_CELL_CONSTRAINED_AXES_BY_SYSTEM: dict[str, set[str]] = {
'cubic': {'lattice_b', 'lattice_c', 'angle_alpha', 'angle_beta', 'angle_gamma'},
'tetragonal': {'lattice_b', 'angle_alpha', 'angle_beta', 'angle_gamma'},
'orthorhombic': {'angle_alpha', 'angle_beta', 'angle_gamma'},
@@ -129,7 +129,7 @@ def _crystal_system_from_name_hm(name_hm: str) -> str | None:
}
-def _cell_fixed_axes(crystal_system: str) -> set[str]:
+def _cell_constrained_axes(crystal_system: str) -> set[str]:
"""
Return cell keys that are dependent on others for a crystal system.
@@ -145,14 +145,14 @@ def _cell_fixed_axes(crystal_system: str) -> set[str]:
Returns
-------
set[str]
- Subset of cell keys that are fixed by symmetry.
+ Subset of cell keys that are constrained by symmetry.
"""
- return _CELL_FIXED_AXES_BY_SYSTEM.get(crystal_system, set())
+ return _CELL_CONSTRAINED_AXES_BY_SYSTEM.get(crystal_system, set())
-def cell_symmetry_fixed_flags(name_hm: str) -> dict[str, bool]:
+def cell_symmetry_constrained_flags(name_hm: str) -> dict[str, bool]:
"""
- Return per-key flags indicating which cell parameters are fixed.
+ Return cell-parameter symmetry-constraint flags.
Parameters
----------
@@ -162,16 +162,16 @@ def cell_symmetry_fixed_flags(name_hm: str) -> dict[str, bool]:
Returns
-------
dict[str, bool]
- Mapping of cell key to ``True`` when the parameter is fixed by
- symmetry (dependent on another parameter or set to a fixed
- angle), ``False`` when it is independent. Returns all keys
- ``False`` when the space group cannot be resolved.
+ Mapping of cell key to ``True`` when the parameter is
+ constrained by symmetry (dependent on another parameter or set
+ to a fixed angle), ``False`` when it is independent. Returns all
+ keys ``False`` when the space group cannot be resolved.
"""
crystal_system = _crystal_system_from_name_hm(name_hm)
if crystal_system is None:
return dict.fromkeys(_CELL_KEYS, False)
- fixed = _cell_fixed_axes(crystal_system)
- return {key: key in fixed for key in _CELL_KEYS}
+ constrained = _cell_constrained_axes(crystal_system)
+ return {key: key in constrained for key in _CELL_KEYS}
def _get_wyckoff_exprs(
@@ -218,13 +218,13 @@ def _get_wyckoff_exprs(
return [sympify(comp.strip()) for comp in components]
-def _fract_fixed_flags(parsed_exprs: list[Expr]) -> dict[str, bool]:
+def _fract_constrained_flags(parsed_exprs: list[Expr]) -> dict[str, bool]:
"""
- Return per-axis flags marking coordinates fixed by site symmetry.
+ Return fractional-coordinate symmetry-constraint flags.
- For each axis (x, y, z), the coordinate is considered fixed when the
- corresponding symbol does not appear as a free symbol in any of the
- Wyckoff position expressions.
+ For each axis (x, y, z), the coordinate is considered constrained
+ when the corresponding symbol does not appear as a free symbol in
+ any of the Wyckoff position expressions.
Parameters
----------
@@ -235,7 +235,7 @@ def _fract_fixed_flags(parsed_exprs: list[Expr]) -> dict[str, bool]:
-------
dict[str, bool]
Mapping ``'fract_x' / 'fract_y' / 'fract_z'`` to ``True`` if
- that axis is fixed by symmetry.
+ that axis is constrained by symmetry.
"""
x, y, z = symbols('x y z')
symbols_xyz = (x, y, z)
@@ -271,10 +271,10 @@ def _apply_fract_constraints(
'y': sympify(atom_site['fract_y']),
'z': sympify(atom_site['fract_z']),
}
- fixed_flags = _fract_fixed_flags(parsed_exprs)
+ constrained_flags = _fract_constrained_flags(parsed_exprs)
for i, axis in enumerate(axes):
- if fixed_flags[f'fract_{axis}']:
+ if constrained_flags[f'fract_{axis}']:
evaluated = simplify(parsed_exprs[i].subs(substitutions))
atom_site[f'fract_{axis}'] = float(evaluated)
@@ -312,13 +312,13 @@ def apply_atom_site_symmetry_constraints(
return atom_site
-def atom_site_symmetry_fixed_flags(
+def atom_site_symmetry_constrained_flags(
name_hm: str,
coord_code: int,
wyckoff_letter: str,
) -> dict[str, bool]:
"""
- Return per-axis flags marking coordinates fixed by site symmetry.
+ Return atom-site symmetry-constraint flags.
Parameters
----------
@@ -339,7 +339,7 @@ def atom_site_symmetry_fixed_flags(
parsed_exprs = _get_wyckoff_exprs(name_hm, coord_code, wyckoff_letter)
if parsed_exprs is None:
return {'fract_x': False, 'fract_y': False, 'fract_z': False}
- return _fract_fixed_flags(parsed_exprs)
+ return _fract_constrained_flags(parsed_exprs)
# ------------------------------------------------------------------
diff --git a/src/easydiffraction/datablocks/experiment/categories/data/bragg_pd.py b/src/easydiffraction/datablocks/experiment/categories/data/bragg_pd.py
index 0f8633010..b1e30aa0a 100644
--- a/src/easydiffraction/datablocks/experiment/categories/data/bragg_pd.py
+++ b/src/easydiffraction/datablocks/experiment/categories/data/bragg_pd.py
@@ -572,7 +572,7 @@ def _create_items_set_xcoord_and_id(self, values: object) -> None:
# TODO: split into multiple methods
# Create items
- self._items = [self._item_type() for _ in range(values.size)]
+ self._adopt_items([self._item_type() for _ in range(values.size)])
# Set two-theta values
for p, v in zip(self._items, values, strict=True):
@@ -651,7 +651,7 @@ def _create_items_set_xcoord_and_id(self, values: object) -> None:
# TODO: split into multiple methods
# Create items
- self._items = [self._item_type() for _ in range(values.size)]
+ self._adopt_items([self._item_type() for _ in range(values.size)])
# Set time-of-flight values
for p, v in zip(self._items, values, strict=True):
diff --git a/src/easydiffraction/datablocks/experiment/categories/data/total_pd.py b/src/easydiffraction/datablocks/experiment/categories/data/total_pd.py
index 42b3ca8ab..31ac92e76 100644
--- a/src/easydiffraction/datablocks/experiment/categories/data/total_pd.py
+++ b/src/easydiffraction/datablocks/experiment/categories/data/total_pd.py
@@ -349,7 +349,7 @@ def _create_items_set_xcoord_and_id(self, values: object) -> None:
# TODO: split into multiple methods
# Create items
- self._items = [self._item_type() for _ in range(values.size)]
+ self._adopt_items([self._item_type() for _ in range(values.size)])
# Set r values
for p, v in zip(self._items, values, strict=True):
diff --git a/src/easydiffraction/datablocks/experiment/categories/refln/bragg_sc.py b/src/easydiffraction/datablocks/experiment/categories/refln/bragg_sc.py
index 52329abb0..ada32c70f 100644
--- a/src/easydiffraction/datablocks/experiment/categories/refln/bragg_sc.py
+++ b/src/easydiffraction/datablocks/experiment/categories/refln/bragg_sc.py
@@ -271,7 +271,7 @@ def _create_items_set_hkl_and_id(
# TODO: split into multiple methods
# Create items
- self._items = [self._item_type() for _ in range(indices_h.size)]
+ self._adopt_items([self._item_type() for _ in range(indices_h.size)])
# Set indices
for item, index_h, index_k, index_l in zip(
diff --git a/src/easydiffraction/datablocks/structure/categories/atom_sites/default.py b/src/easydiffraction/datablocks/structure/categories/atom_sites/default.py
index 562ad18d6..6c91a8561 100644
--- a/src/easydiffraction/datablocks/structure/categories/atom_sites/default.py
+++ b/src/easydiffraction/datablocks/structure/categories/atom_sites/default.py
@@ -558,8 +558,8 @@ def _apply_atomic_coordinates_symmetry_constraints(self) -> None:
Uses the parent structure's space-group symbol, IT coordinate
system code and each atom's Wyckoff letter. Atoms without a
Wyckoff letter are silently skipped. Coordinates fully
- determined by site symmetry are flagged as ``symmetry_fixed`` so
- they cannot be marked refinable.
+ determined by site symmetry are flagged as
+ ``symmetry_constrained`` so they cannot be marked refinable.
"""
structure = self._parent
space_group_name = structure.space_group.name_h_m.value
@@ -568,7 +568,7 @@ def _apply_atomic_coordinates_symmetry_constraints(self) -> None:
wl = atom.wyckoff_letter.value
if not wl:
# TODO: Decide how to handle this case
- self._clear_fract_symmetry_fixed(atom)
+ self._clear_fract_symmetry_constrained(atom)
continue
dummy_atom = {
'fract_x': atom.fract_x.value,
@@ -581,7 +581,7 @@ def _apply_atomic_coordinates_symmetry_constraints(self) -> None:
coord_code=space_group_coord_code,
wyckoff_letter=wl,
)
- fixed_flags = ecr.atom_site_symmetry_fixed_flags(
+ constrained_flags = ecr.atom_site_symmetry_constrained_flags(
name_hm=space_group_name,
coord_code=space_group_coord_code,
wyckoff_letter=wl,
@@ -589,17 +589,17 @@ def _apply_atomic_coordinates_symmetry_constraints(self) -> None:
atom.fract_x.value = dummy_atom['fract_x']
atom.fract_y.value = dummy_atom['fract_y']
atom.fract_z.value = dummy_atom['fract_z']
- atom._fract_x._set_symmetry_fixed(value=fixed_flags['fract_x'])
- atom._fract_y._set_symmetry_fixed(value=fixed_flags['fract_y'])
- atom._fract_z._set_symmetry_fixed(value=fixed_flags['fract_z'])
+ atom._fract_x._set_symmetry_constrained(value=constrained_flags['fract_x'])
+ atom._fract_y._set_symmetry_constrained(value=constrained_flags['fract_y'])
+ atom._fract_z._set_symmetry_constrained(value=constrained_flags['fract_z'])
@staticmethod
- def _clear_fract_symmetry_fixed(atom: AtomSite) -> None:
+ def _clear_fract_symmetry_constrained(atom: AtomSite) -> None:
"""
- Reset the ``symmetry_fixed`` flag on all fract coordinates.
+ Clear fractional-coordinate symmetry constraints.
"""
for axis_param in (atom._fract_x, atom._fract_y, atom._fract_z):
- axis_param._set_symmetry_fixed(value=False)
+ axis_param._set_symmetry_constrained(value=False)
def _apply_adp_symmetry_constraints(self) -> None:
"""
@@ -607,9 +607,9 @@ def _apply_adp_symmetry_constraints(self) -> None:
For each atom with an anisotropic ADP type and a Wyckoff letter,
enforces the tensor constraints dictated by the site symmetry.
- Tensor components fixed by symmetry are flagged as
- ``symmetry_fixed`` (which also forces ``free = False``), and
- ``adp_iso`` is flagged as fixed for all anisotropic atoms.
+ Tensor components constrained by symmetry are flagged as
+ ``symmetry_constrained`` (which also forces ``free = False``),
+ and ``adp_iso`` is flagged as fixed for all anisotropic atoms.
"""
structure = self._parent
aniso_types = {AdpTypeEnum.BANI.value, AdpTypeEnum.UANI.value}
@@ -620,7 +620,7 @@ def _apply_adp_symmetry_constraints(self) -> None:
for atom in self._items:
is_aniso = atom.adp_type.value in aniso_types
# Isotropic ADP is not refinable for aniso atoms
- atom._adp_iso._set_symmetry_fixed(value=is_aniso)
+ atom._adp_iso._set_symmetry_constrained(value=is_aniso)
if not is_aniso:
continue
wl = atom.wyckoff_letter.value
@@ -654,7 +654,7 @@ def _apply_adp_symmetry_constraints(self) -> None:
for key, is_free in zip(adp_keys, ref_i, strict=False):
param = getattr(aniso_entry, key)
param.value = dummy[key]
- param._set_symmetry_fixed(value=not is_free)
+ param._set_symmetry_constrained(value=not is_free)
def _sync_iso_from_aniso(self) -> None:
"""
diff --git a/src/easydiffraction/datablocks/structure/categories/cell/default.py b/src/easydiffraction/datablocks/structure/categories/cell/default.py
index 4c02ab713..712629501 100644
--- a/src/easydiffraction/datablocks/structure/categories/cell/default.py
+++ b/src/easydiffraction/datablocks/structure/categories/cell/default.py
@@ -106,7 +106,7 @@ def _apply_cell_symmetry_constraints(self) -> None:
Uses the parent structure's space-group symbol to determine
which lattice parameters are dependent and sets them
accordingly. Dependent parameters are also flagged as
- ``symmetry_fixed`` so they cannot be marked refinable.
+ ``symmetry_constrained`` so they cannot be marked refinable.
"""
dummy_cell = {
'lattice_a': self.length_a.value,
@@ -122,7 +122,7 @@ def _apply_cell_symmetry_constraints(self) -> None:
cell=dummy_cell,
name_hm=space_group_name,
)
- fixed_flags = ecr.cell_symmetry_fixed_flags(name_hm=space_group_name)
+ constrained_flags = ecr.cell_symmetry_constrained_flags(name_hm=space_group_name)
param_by_key = {
'lattice_a': self._length_a,
@@ -134,7 +134,7 @@ def _apply_cell_symmetry_constraints(self) -> None:
}
for key, param in param_by_key.items():
param.value = dummy_cell[key]
- param._set_symmetry_fixed(value=fixed_flags[key])
+ param._set_symmetry_constrained(value=constrained_flags[key])
def _update(
self,
diff --git a/src/easydiffraction/io/cif/serialize.py b/src/easydiffraction/io/cif/serialize.py
index b71e69e22..0527ac690 100644
--- a/src/easydiffraction/io/cif/serialize.py
+++ b/src/easydiffraction/io/cif/serialize.py
@@ -87,7 +87,7 @@ def format_param_value(param: object) -> str:
- Free parameter with uncertainty: value with esd in brackets,
e.g. ``3.89(20)``
- Constrained (dependent) parameters are always written without
+ User-constrained (dependent) parameters are always written without
brackets, even if their ``free`` flag is ``True``, because they are
not independently varied by the minimizer.
@@ -98,7 +98,7 @@ def format_param_value(param: object) -> str:
----------
param : object
A descriptor or parameter exposing ``.value`` and optionally
- ``.free``, ``.constrained``, and ``.uncertainty``.
+ ``.free``, ``.user_constrained``, and ``.uncertainty``.
Returns
-------
@@ -108,10 +108,10 @@ def format_param_value(param: object) -> str:
from easydiffraction.core.variable import Parameter # noqa: PLC0415
is_free = param.free if isinstance(param, Parameter) else False
- is_constrained = param.constrained if isinstance(param, Parameter) else False
+ is_user_constrained = param.user_constrained if isinstance(param, Parameter) else False
value = param.value # type: ignore[attr-defined]
- if not is_free or is_constrained or not isinstance(value, (int, float)):
+ if not is_free or is_user_constrained or not isinstance(value, (int, float)):
return format_value(value)
precision = 8
@@ -279,13 +279,21 @@ def datablock_item_to_cif(
parts: list[str] = [header]
# First categories
- parts.extend(v.as_cif for v in vars(datablock).values() if isinstance(v, CategoryItem))
+ parts.extend(
+ cif_text
+ for cif_text in (v.as_cif for v in vars(datablock).values() if isinstance(v, CategoryItem))
+ if cif_text
+ )
# Then collections
parts.extend(
- category_collection_to_cif(v, max_display=max_loop_display)
- for v in vars(datablock).values()
- if isinstance(v, CategoryCollection)
+ cif_text
+ for cif_text in (
+ category_collection_to_cif(v, max_display=max_loop_display)
+ for v in vars(datablock).values()
+ if isinstance(v, CategoryCollection)
+ )
+ if cif_text
)
return '\n\n'.join(parts)
@@ -715,11 +723,7 @@ def category_collection_from_cif(
array = np.array(loop.values, dtype=str).reshape(num_rows, num_cols)
# Pre-create default items in the collection
- self._items = [self._item_type() for _ in range(num_rows)]
-
- # Set parent for each item to enable identity resolution
- for item in self._items:
- object.__setattr__(item, '_parent', self) # noqa: PLC2801
+ self._adopt_items([self._item_type() for _ in range(num_rows)])
# Set those items' parameters, which are present in the loop
for row_idx in range(num_rows):
diff --git a/src/easydiffraction/project/display.py b/src/easydiffraction/project/display.py
index 74c510fac..2071463d5 100644
--- a/src/easydiffraction/project/display.py
+++ b/src/easydiffraction/project/display.py
@@ -13,6 +13,7 @@
from easydiffraction.display.plotting import PlotterEngineEnum
from easydiffraction.display.plotting import PosteriorPairPlotStyleEnum
from easydiffraction.display.plotting import _MeasVsCalcPlotOptions
+from easydiffraction.utils.utils import render_object_help
from easydiffraction.utils.utils import render_table
if TYPE_CHECKING:
@@ -68,6 +69,10 @@ def cif_uids(self) -> None:
"""Show CIF unique identifiers for all parameters."""
self._project.analysis.display.parameter_cif_uids()
+ def help(self) -> None:
+ """Print available parameter-display methods."""
+ render_object_help(self)
+
class FitDisplay:
"""Fit-report namespace under ``project.display``."""
@@ -103,6 +108,10 @@ def series(
"""Plot one fitted parameter across sequential results."""
self._project.rendering.plotter.plot_param_series(param=param, versus=versus)
+ def help(self) -> None:
+ """Print available fit-display methods."""
+ render_object_help(self)
+
class PosteriorDisplay:
"""Posterior-plot namespace under ``project.display``."""
@@ -150,6 +159,10 @@ def predictive(
x=x,
)
+ def help(self) -> None:
+ """Print available posterior-display methods."""
+ render_object_help(self)
+
class ProjectDisplay:
"""Grouped display facade exposed as ``project.display``."""
@@ -175,6 +188,10 @@ def posterior(self) -> PosteriorDisplay:
"""Posterior-plot namespace."""
return self._posterior
+ def help(self) -> None:
+ """Print display namespaces and methods."""
+ render_object_help(self)
+
def pattern(
self,
expt_name: str,
diff --git a/src/easydiffraction/project/project.py b/src/easydiffraction/project/project.py
index 3f3ac4fb4..e7508201f 100644
--- a/src/easydiffraction/project/project.py
+++ b/src/easydiffraction/project/project.py
@@ -342,7 +342,7 @@ def save(self) -> None:
console.print(self.info.path.resolve())
# Apply constraints so dependent parameters are flagged
- # before serialization (constrained params are written
+ # before serialization (user-constrained params are written
# without brackets).
self._analysis._update_categories()
diff --git a/src/easydiffraction/summary/summary.py b/src/easydiffraction/summary/summary.py
index 768abb4b2..27826dab7 100644
--- a/src/easydiffraction/summary/summary.py
+++ b/src/easydiffraction/summary/summary.py
@@ -6,6 +6,7 @@
from easydiffraction.core.variable import Parameter
from easydiffraction.io.cif.serialize import summary_to_cif
from easydiffraction.utils.logging import console
+from easydiffraction.utils.utils import render_object_help
from easydiffraction.utils.utils import render_table
@@ -28,6 +29,10 @@ def __init__(self, project: object) -> None:
"""
self.project = project
+ def help(self) -> None:
+ """Print available summary-report methods."""
+ render_object_help(self)
+
@staticmethod
def _fmt_row(
pretty_name: str,
diff --git a/src/easydiffraction/utils/utils.py b/src/easydiffraction/utils/utils.py
index 0e301e30c..a81f1cb0b 100644
--- a/src/easydiffraction/utils/utils.py
+++ b/src/easydiffraction/utils/utils.py
@@ -563,6 +563,91 @@ def render_table(
tabler.render(df, display_handle=display_handle)
+def _help_first_sentence(docstring: str | None) -> str:
+ """Return the first paragraph of a docstring on one line."""
+ if not docstring:
+ return ''
+ first_para = docstring.strip().split('\n\n')[0]
+ return ' '.join(line.strip() for line in first_para.splitlines())
+
+
+def _help_property_rows(cls: type) -> list[list[str]]:
+ """Return public property rows for object help tables."""
+ seen: dict[str, property] = {}
+ for base in cls.mro():
+ for key, attr in base.__dict__.items():
+ if key.startswith('_') or not isinstance(attr, property):
+ continue
+ if key not in seen:
+ seen[key] = attr
+
+ rows = []
+ for i, key in enumerate(sorted(seen), 1):
+ prop = seen[key]
+ writable = '✓' if prop.fset else '✗'
+ doc = _help_first_sentence(prop.fget.__doc__ if prop.fget else None)
+ rows.append([str(i), key, writable, doc])
+ return rows
+
+
+def _help_method_rows(cls: type) -> list[list[str]]:
+ """Return public method rows for object help tables."""
+ seen: set[str] = set()
+ methods = []
+ for base in cls.mro():
+ for key, attr in base.__dict__.items():
+ if key.startswith('_') or key in seen:
+ continue
+ if isinstance(attr, property):
+ continue
+ raw = attr
+ if isinstance(raw, (staticmethod, classmethod)):
+ raw = raw.__func__
+ if callable(raw):
+ seen.add(key)
+ methods.append((key, raw))
+
+ rows = []
+ for i, (key, method) in enumerate(sorted(methods), 1):
+ doc = _help_first_sentence(getattr(method, '__doc__', None))
+ rows.append([str(i), f'{key}()', doc])
+ return rows
+
+
+def render_object_help(obj: object, title: str | None = None) -> None:
+ """
+ Print public properties and methods for a plain helper object.
+
+ Parameters
+ ----------
+ obj : object
+ Object whose public API should be summarized.
+ title : str | None, default=None
+ Optional display name. Uses the class name when omitted.
+ """
+ cls = type(obj)
+ display_title = title or cls.__name__
+ console.paragraph(f"Help for '{display_title}'")
+
+ prop_rows = _help_property_rows(cls)
+ if prop_rows:
+ console.paragraph('Properties')
+ render_table(
+ columns_headers=['#', 'Name', 'Writable', 'Description'],
+ columns_alignment=['right', 'left', 'center', 'left'],
+ columns_data=prop_rows,
+ )
+
+ method_rows = _help_method_rows(cls)
+ if method_rows:
+ console.paragraph('Methods')
+ render_table(
+ columns_headers=['#', 'Name', 'Description'],
+ columns_alignment=['right', 'left', 'left'],
+ columns_data=method_rows,
+ )
+
+
def render_cif(cif_text: str) -> None:
"""
Display CIF text as a formatted table in Jupyter or terminal.
diff --git a/tests/functional/test_structure_workflow.py b/tests/functional/test_structure_workflow.py
index a128e73d9..f2e4a9193 100644
--- a/tests/functional/test_structure_workflow.py
+++ b/tests/functional/test_structure_workflow.py
@@ -162,9 +162,9 @@ def test_special_position_fract_cannot_be_freed(self, monkeypatch):
s._update_categories()
atom = s.atom_sites['La']
- assert atom.fract_x.symmetry_fixed is True
- assert atom.fract_y.symmetry_fixed is True
- assert atom.fract_z.symmetry_fixed is True
+ assert atom.fract_x.symmetry_constrained is True
+ assert atom.fract_y.symmetry_constrained is True
+ assert atom.fract_z.symmetry_constrained is True
monkeypatch.setattr(Logger, '_reaction', Logger.Reaction.WARN, raising=True)
for parameter in ('fract_x', 'fract_y', 'fract_z'):
@@ -183,12 +183,12 @@ def test_cubic_cell_has_only_a_free(self):
s._need_categories_update = True
s._update_categories()
- assert s.cell.length_a.symmetry_fixed is False
- assert s.cell.length_b.symmetry_fixed is True
- assert s.cell.length_c.symmetry_fixed is True
- assert s.cell.angle_alpha.symmetry_fixed is True
- assert s.cell.angle_beta.symmetry_fixed is True
- assert s.cell.angle_gamma.symmetry_fixed is True
+ assert s.cell.length_a.symmetry_constrained is False
+ assert s.cell.length_b.symmetry_constrained is True
+ assert s.cell.length_c.symmetry_constrained is True
+ assert s.cell.angle_alpha.symmetry_constrained is True
+ assert s.cell.angle_beta.symmetry_constrained is True
+ assert s.cell.angle_gamma.symmetry_constrained is True
def test_general_position_remains_refinable(self):
project = _make_project()
@@ -208,7 +208,7 @@ def test_general_position_remains_refinable(self):
s._update_categories()
atom = s.atom_sites['La']
- assert atom.fract_x.symmetry_fixed is False
+ assert atom.fract_x.symmetry_constrained is False
atom.fract_x.free = True
assert atom.fract_x.free is True
@@ -222,14 +222,14 @@ def test_changing_space_group_updates_flags(self, monkeypatch):
# Start in P 1: cell free
s._need_categories_update = True
s._update_categories()
- assert s.cell.length_b.symmetry_fixed is False
+ assert s.cell.length_b.symmetry_constrained is False
s.cell.length_b.free = True
assert s.cell.length_b.free is True
# Switch to cubic: length_b becomes fixed
s.space_group.name_h_m = 'P m -3 m'
s._update_categories()
- assert s.cell.length_b.symmetry_fixed is True
+ assert s.cell.length_b.symmetry_constrained is True
assert s.cell.length_b.free is False
# Setting free=True is now ignored with a warning
diff --git a/tests/integration/fitting/test_exploration_help.py b/tests/integration/fitting/test_exploration_help.py
index d86004691..08e2bcf54 100644
--- a/tests/integration/fitting/test_exploration_help.py
+++ b/tests/integration/fitting/test_exploration_help.py
@@ -65,6 +65,27 @@ def test_experiment_show_as_cif(lbco_fitted_project):
expt.show_as_cif()
+def test_experiment_show_as_cif_omits_empty_category_gaps(lbco_fitted_project, monkeypatch):
+ import re
+
+ import easydiffraction.datablocks.experiment.item.base as experiment_base
+
+ captured = {}
+
+ def fake_render_cif(cif_text):
+ captured['cif_text'] = cif_text
+
+ monkeypatch.setattr(experiment_base, 'render_cif', fake_render_cif)
+
+ project = lbco_fitted_project
+ expt = project.experiments['hrpt']
+ expt.show_as_cif()
+
+ cif_text = captured['cif_text']
+ assert re.search(r'_pd_phase_block\.scale\n[^\n]+\n\nloop_', cif_text) is not None
+ assert '\n\n\n' not in cif_text
+
+
def test_experiment_as_cif(lbco_fitted_project):
project = lbco_fitted_project
expt = project.experiments['hrpt']
diff --git a/tests/unit/easydiffraction/analysis/test_analysis.py b/tests/unit/easydiffraction/analysis/test_analysis.py
index a4d2c7faa..c5b2ef248 100644
--- a/tests/unit/easydiffraction/analysis/test_analysis.py
+++ b/tests/unit/easydiffraction/analysis/test_analysis.py
@@ -67,6 +67,18 @@ def test_analysis_help(capsys):
assert 'fit_sequential()' in out
+def test_analysis_display_help(capsys):
+ from easydiffraction.analysis.analysis import Analysis
+
+ a = Analysis(project=_make_project_with_names([]))
+ a.display.help()
+ out = capsys.readouterr().out
+ assert "Help for 'AnalysisDisplay'" in out
+ assert 'all_params()' in out
+ assert 'fit_results()' in out
+ assert 'how_to_access_parameters()' in out
+
+
def test_display_fit_results_warns_when_no_results(capsys):
"""Test that display.fit_results logs a warning when fit() has not been run."""
from easydiffraction.analysis.analysis import Analysis
diff --git a/tests/unit/easydiffraction/analysis/test_analysis_access_params.py b/tests/unit/easydiffraction/analysis/test_analysis_access_params.py
index b7d9f8956..96667689b 100644
--- a/tests/unit/easydiffraction/analysis/test_analysis_access_params.py
+++ b/tests/unit/easydiffraction/analysis/test_analysis_access_params.py
@@ -2,32 +2,45 @@
# SPDX-License-Identifier: BSD-3-Clause
-def test_how_to_access_parameters_prints_paths_and_uids(capsys, monkeypatch):
- import easydiffraction.analysis.analysis as analysis_mod
- from easydiffraction.analysis.analysis import Analysis
+def _make_param(
+ db,
+ cat,
+ entry,
+ name,
+ val,
+ *,
+ user_constrained=False,
+ symmetry_constrained=False,
+):
from easydiffraction.core.validation import AttributeSpec
from easydiffraction.core.variable import Parameter
from easydiffraction.io.cif.handler import CifHandler
- # Build two parameters with identity metadata set directly
- def make_param(db, cat, entry, name, val):
- p = Parameter(
- name=name,
- value_spec=AttributeSpec(default=0.0),
- cif_handler=CifHandler(names=[f'_{cat}.{name}']),
- )
- p.value = val
- # Inject identity metadata (avoid parent chain)
- p._identity.datablock_entry_name = lambda: db
- p._identity.category_code = cat
- if entry:
- p._identity.category_entry_name = lambda: entry
- else:
- p._identity.category_entry_name = lambda: ''
- return p
-
- p1 = make_param('db1', 'catA', '', 'alpha', 1.0)
- p2 = make_param('db2', 'catB', 'row1', 'beta', 2.0)
+ param = Parameter(
+ name=name,
+ value_spec=AttributeSpec(default=0.0),
+ cif_handler=CifHandler(names=[f'_{cat}.{name}']),
+ )
+ param.value = val
+ param._identity.datablock_entry_name = lambda: db
+ param._identity.category_code = cat
+ if entry:
+ param._identity.category_entry_name = lambda: entry
+ else:
+ param._identity.category_entry_name = lambda: ''
+ if user_constrained:
+ param._user_constrained = True
+ if symmetry_constrained:
+ param._set_symmetry_constrained(value=True)
+ return param
+
+
+def test_how_to_access_parameters_prints_paths_and_uids(capsys, monkeypatch):
+ import easydiffraction.analysis.analysis as analysis_mod
+ from easydiffraction.analysis.analysis import Analysis
+
+ p1 = _make_param('db1', 'catA', '', 'alpha', 1.0)
+ p2 = _make_param('db2', 'catB', 'row1', 'beta', 2.0)
class Coll:
def __init__(self, params):
@@ -82,3 +95,203 @@ def fake_render_table2(**kwargs):
# Unique names are datablock.category[.entry].parameter
assert any('db1 catA alpha' in r.replace('.', ' ') for r in flat_rows2)
assert any('db2 catB row1 beta' in r.replace('.', ' ') for r in flat_rows2)
+
+
+def test_how_to_access_parameters_skips_large_loop_categories(capsys, monkeypatch):
+ import easydiffraction.analysis.analysis as analysis_mod
+ from easydiffraction.analysis.analysis import Analysis
+
+ visible = _make_param('db1', 'catA', '', 'alpha', 1.0)
+ data_param = _make_param('db2', 'pd_data', '1', 'intensity_meas', 2.0)
+ refln_param = _make_param('db2', 'refln', '1', 'f_calc', 3.0)
+
+ class Coll:
+ def __init__(self, params):
+ self.parameters = params
+
+ class Project:
+ _varname = 'proj'
+
+ def __init__(self):
+ self.structures = Coll([visible])
+ self.experiments = Coll([data_param, refln_param])
+
+ captured = {}
+
+ def fake_render_table(**kwargs):
+ captured.update(kwargs)
+
+ monkeypatch.setattr(analysis_mod, 'render_table', fake_render_table)
+ Analysis(Project()).display.how_to_access_parameters()
+
+ out = capsys.readouterr().out
+ assert 'How to access parameters' in out
+
+ flat_rows = [' '.join(map(str, row)) for row in captured.get('columns_data') or []]
+ assert any("proj.structures['db1'].catA.alpha" in row for row in flat_rows)
+ assert not any('pd_data' in row for row in flat_rows)
+ assert not any('refln' in row for row in flat_rows)
+
+
+def test_parameter_cif_uids_skips_large_loop_categories(monkeypatch):
+ import easydiffraction.analysis.analysis as analysis_mod
+ from easydiffraction.analysis.analysis import Analysis
+
+ visible = _make_param('db1', 'catA', '', 'alpha', 1.0)
+ data_param = _make_param('db2', 'pd_data', '1', 'intensity_meas', 2.0)
+ refln_param = _make_param('db2', 'refln', '1', 'f_calc', 3.0)
+
+ class Coll:
+ def __init__(self, params):
+ self.parameters = params
+
+ class Project:
+ _varname = 'proj'
+
+ def __init__(self):
+ self.structures = Coll([visible])
+ self.experiments = Coll([data_param, refln_param])
+
+ captured = {}
+
+ def fake_render_table(**kwargs):
+ captured.update(kwargs)
+
+ monkeypatch.setattr(analysis_mod, 'render_table', fake_render_table)
+ Analysis(Project()).display.parameter_cif_uids()
+
+ flat_rows = [' '.join(map(str, row)) for row in captured.get('columns_data') or []]
+ assert any('db1 catA alpha' in row.replace('.', ' ') for row in flat_rows)
+ assert not any('pd_data' in row for row in flat_rows)
+ assert not any('refln' in row for row in flat_rows)
+
+
+def test_all_params_skips_large_loop_categories(monkeypatch):
+ import easydiffraction.analysis.analysis as analysis_mod
+ from easydiffraction.analysis.analysis import Analysis
+
+ structure_param = _make_param('s1', 'cell', '', 'length_a', 4.0)
+ visible_experiment_param = _make_param('e1', 'instrument', '', 'wavelength', 1.5)
+ data_param = _make_param('e1', 'pd_data', '1', 'intensity_meas', 10.0)
+ refln_param = _make_param('e1', 'refln', '1', 'f_calc', 12.0)
+
+ class Coll:
+ def __init__(self, params):
+ self.parameters = params
+
+ def __iter__(self):
+ return iter(())
+
+ class Project:
+ def __init__(self):
+ self.structures = Coll([structure_param])
+ self.experiments = Coll([visible_experiment_param, data_param, refln_param])
+
+ rendered = []
+
+ class FakeTableRenderer:
+ def render(self, df):
+ rendered.append(df)
+
+ monkeypatch.setattr(
+ analysis_mod.TableRenderer, 'get', staticmethod(lambda: FakeTableRenderer())
+ )
+ Analysis(Project()).display.all_params()
+
+ assert len(rendered) == 2
+ experiment_categories = rendered[1]['category', 'left'].tolist()
+ experiment_parameters = rendered[1]['parameter', 'left'].tolist()
+ assert experiment_categories == ['instrument']
+ assert experiment_parameters == ['wavelength']
+
+
+def test_all_params_marks_constrained_parameters_not_fittable(monkeypatch):
+ import easydiffraction.analysis.analysis as analysis_mod
+ from easydiffraction.analysis.analysis import Analysis
+
+ refinable_param = _make_param('s1', 'cell', '', 'length_a', 4.0)
+ user_constrained_param = _make_param('s1', 'cell', '', 'length_b', 4.0, user_constrained=True)
+ symmetry_constrained_param = _make_param(
+ 's1',
+ 'cell',
+ '',
+ 'length_c',
+ 4.0,
+ symmetry_constrained=True,
+ )
+
+ class Coll:
+ def __init__(self, params):
+ self.parameters = params
+
+ def __iter__(self):
+ return iter(())
+
+ class Project:
+ def __init__(self):
+ self.structures = Coll([
+ refinable_param,
+ user_constrained_param,
+ symmetry_constrained_param,
+ ])
+ self.experiments = Coll([])
+
+ rendered = []
+
+ class FakeTableRenderer:
+ def render(self, df):
+ rendered.append(df)
+
+ monkeypatch.setattr(
+ analysis_mod.TableRenderer, 'get', staticmethod(lambda: FakeTableRenderer())
+ )
+ Analysis(Project()).display.all_params()
+
+ structure_df = rendered[0]
+ assert structure_df['parameter', 'left'].tolist() == ['length_a', 'length_b', 'length_c']
+ assert structure_df['fittable', 'left'].tolist() == [True, False, False]
+
+
+def test_fittable_params_excludes_symmetry_constrained_parameters(monkeypatch):
+ import easydiffraction.analysis.analysis as analysis_mod
+ from easydiffraction.analysis.analysis import Analysis
+
+ visible_param = _make_param('s1', 'cell', '', 'length_a', 4.0)
+ symmetry_constrained_param = _make_param(
+ 's1',
+ 'cell',
+ '',
+ 'length_c',
+ 4.0,
+ symmetry_constrained=True,
+ )
+
+ class Coll:
+ def __init__(self, params, fittable_params):
+ self.parameters = params
+ self.fittable_parameters = fittable_params
+
+ def __iter__(self):
+ return iter(())
+
+ class Project:
+ def __init__(self):
+ self.structures = Coll(
+ [visible_param, symmetry_constrained_param],
+ [visible_param],
+ )
+ self.experiments = Coll([], [])
+
+ rendered = []
+
+ class FakeTableRenderer:
+ def render(self, df):
+ rendered.append(df)
+
+ monkeypatch.setattr(
+ analysis_mod.TableRenderer, 'get', staticmethod(lambda: FakeTableRenderer())
+ )
+ Analysis(Project()).display.fittable_params()
+
+ structure_df = rendered[0]
+ assert structure_df['parameter', 'left'].tolist() == ['length_a']
diff --git a/tests/unit/easydiffraction/core/test_datablock.py b/tests/unit/easydiffraction/core/test_datablock.py
index b41f68b74..98d0dc252 100644
--- a/tests/unit/easydiffraction/core/test_datablock.py
+++ b/tests/unit/easydiffraction/core/test_datablock.py
@@ -33,8 +33,8 @@ def __init__(self):
# Set actual values via setter
self._p1.value = 1.0
self._p2.value = 2.0
- # Make p2 constrained and not free
- self._p2._constrained = True
+ # Make p2 user constrained and not free
+ self._p2._user_constrained = True
self._p2._free = False
# Mark p1 free to be included in free_parameters
self._p1.free = True
@@ -67,7 +67,7 @@ def cat(self):
# parameters collection aggregates from both blocks (p1 & p2 each)
params = coll.parameters
assert len(params) == 4
- # fittable excludes constrained parameters
+ # fittable excludes user-constrained parameters
fittable = coll.fittable_parameters
assert all(isinstance(p, Parameter) for p in fittable)
assert len(fittable) == 2 # only p1 from each block
@@ -76,6 +76,65 @@ def cat(self):
assert free_params == fittable
+def test_datablock_collection_fittable_excludes_symmetry_constrained_parameters():
+ from easydiffraction.core.category import CategoryItem
+ from easydiffraction.core.datablock import DatablockCollection
+ from easydiffraction.core.datablock import DatablockItem
+ from easydiffraction.core.validation import AttributeSpec
+ from easydiffraction.core.variable import Parameter
+ from easydiffraction.io.cif.handler import CifHandler
+
+ class Cat(CategoryItem):
+ def __init__(self):
+ super().__init__()
+ self._identity.category_code = 'cat'
+ self._identity.category_entry_name = 'e1'
+ self._free_param = Parameter(
+ name='free_param',
+ description='',
+ value_spec=AttributeSpec(default=0.0),
+ units='',
+ cif_handler=CifHandler(names=['_cat.free_param']),
+ )
+ self._fixed_param = Parameter(
+ name='fixed_param',
+ description='',
+ value_spec=AttributeSpec(default=0.0),
+ units='',
+ cif_handler=CifHandler(names=['_cat.fixed_param']),
+ )
+ self._free_param.value = 1.0
+ self._fixed_param.value = 2.0
+ self._free_param.free = True
+ self._fixed_param._set_symmetry_constrained(value=True)
+
+ @property
+ def free_param(self):
+ return self._free_param
+
+ @property
+ def fixed_param(self):
+ return self._fixed_param
+
+ class Block(DatablockItem):
+ def __init__(self, name):
+ super().__init__()
+ self._identity.datablock_entry_name = lambda: name
+ self._cat = Cat()
+
+ @property
+ def cat(self):
+ return self._cat
+
+ coll = DatablockCollection(item_type=Block)
+ coll.add(Block('A'))
+
+ fittable = coll.fittable_parameters
+
+ assert all(isinstance(p, Parameter) for p in fittable)
+ assert [p.name for p in fittable] == ['free_param']
+
+
def test_datablock_item_help(capsys):
from easydiffraction.core.category import CategoryItem
from easydiffraction.core.datablock import DatablockItem
diff --git a/tests/unit/easydiffraction/core/test_parameters.py b/tests/unit/easydiffraction/core/test_parameters.py
index a6c5b7adf..f81e35e87 100644
--- a/tests/unit/easydiffraction/core/test_parameters.py
+++ b/tests/unit/easydiffraction/core/test_parameters.py
@@ -199,43 +199,43 @@ def _make_param() -> object:
)
-def test_parameter_symmetry_fixed_default_is_false():
+def test_parameter_symmetry_constrained_default_is_false():
p = _make_param()
- assert p.symmetry_fixed is False
+ assert p.symmetry_constrained is False
-def test_parameter_set_symmetry_fixed_forces_free_false():
+def test_parameter_set_symmetry_constrained_forces_free_false():
p = _make_param()
p.free = True
assert p.free is True
- p._set_symmetry_fixed(value=True)
- assert p.symmetry_fixed is True
+ p._set_symmetry_constrained(value=True)
+ assert p.symmetry_constrained is True
assert p.free is False
-def test_parameter_free_true_ignored_when_symmetry_fixed(monkeypatch):
+def test_parameter_free_true_ignored_when_symmetry_constrained(monkeypatch):
from easydiffraction.utils.logging import Logger
p = _make_param()
- p._set_symmetry_fixed(value=True)
+ p._set_symmetry_constrained(value=True)
monkeypatch.setattr(Logger, '_reaction', Logger.Reaction.WARN, raising=True)
p.free = True
assert p.free is False
- assert p.symmetry_fixed is True
+ assert p.symmetry_constrained is True
monkeypatch.setattr(Logger, '_reaction', Logger.Reaction.RAISE, raising=True)
-def test_parameter_free_false_allowed_when_symmetry_fixed():
+def test_parameter_free_false_allowed_when_symmetry_constrained():
p = _make_param()
- p._set_symmetry_fixed(value=True)
+ p._set_symmetry_constrained(value=True)
p.free = False # should not warn or raise
assert p.free is False
-def test_parameter_clearing_symmetry_fixed_allows_free_true():
+def test_parameter_clearing_symmetry_constrained_allows_free_true():
p = _make_param()
- p._set_symmetry_fixed(value=True)
- p._set_symmetry_fixed(value=False)
+ p._set_symmetry_constrained(value=True)
+ p._set_symmetry_constrained(value=False)
p.free = True
assert p.free is True
- assert p.symmetry_fixed is False
+ assert p.symmetry_constrained is False
diff --git a/tests/unit/easydiffraction/crystallography/test_crystallography_coverage.py b/tests/unit/easydiffraction/crystallography/test_crystallography_coverage.py
index 13590dbea..7b548aca0 100644
--- a/tests/unit/easydiffraction/crystallography/test_crystallography_coverage.py
+++ b/tests/unit/easydiffraction/crystallography/test_crystallography_coverage.py
@@ -91,25 +91,29 @@ def test_triclinic(self):
assert result['angle_beta'] == 85.0
assert result['angle_gamma'] == 75.0
- def test_invalid_name_hm_returns_cell_unchanged(self):
+ def test_invalid_name_hm_returns_cell_unchanged(self, monkeypatch):
+ from easydiffraction.utils.logging import Logger
+
cell = _make_cell()
original = dict(cell)
+ monkeypatch.setattr(Logger, '_reaction', Logger.Reaction.WARN, raising=True)
result = apply_cell_symmetry_constraints(cell, 'NOT A REAL SG')
assert result == original
+ monkeypatch.setattr(Logger, '_reaction', Logger.Reaction.RAISE, raising=True)
# ------------------------------------------------------------------
-# cell_symmetry_fixed_flags
+# cell_symmetry_constrained_flags
# ------------------------------------------------------------------
-class TestCellSymmetryFixedFlags:
+class TestCellSymmetryConstrainedFlags:
def test_cubic_only_a_is_free(self):
from easydiffraction.crystallography.crystallography import (
- cell_symmetry_fixed_flags,
+ cell_symmetry_constrained_flags,
)
- flags = cell_symmetry_fixed_flags('F m -3 m')
+ flags = cell_symmetry_constrained_flags('F m -3 m')
assert flags == {
'lattice_a': False,
'lattice_b': True,
@@ -121,10 +125,10 @@ def test_cubic_only_a_is_free(self):
def test_monoclinic_b_and_beta_free(self):
from easydiffraction.crystallography.crystallography import (
- cell_symmetry_fixed_flags,
+ cell_symmetry_constrained_flags,
)
- flags = cell_symmetry_fixed_flags('P 21/c')
+ flags = cell_symmetry_constrained_flags('P 21/c')
assert flags['lattice_a'] is False
assert flags['lattice_b'] is False
assert flags['lattice_c'] is False
@@ -134,19 +138,19 @@ def test_monoclinic_b_and_beta_free(self):
def test_triclinic_all_free(self):
from easydiffraction.crystallography.crystallography import (
- cell_symmetry_fixed_flags,
+ cell_symmetry_constrained_flags,
)
- flags = cell_symmetry_fixed_flags('P 1')
+ flags = cell_symmetry_constrained_flags('P 1')
assert all(v is False for v in flags.values())
def test_invalid_returns_all_false(self, monkeypatch):
from easydiffraction.crystallography.crystallography import (
- cell_symmetry_fixed_flags,
+ cell_symmetry_constrained_flags,
)
from easydiffraction.utils.logging import Logger
monkeypatch.setattr(Logger, '_reaction', Logger.Reaction.WARN, raising=True)
- flags = cell_symmetry_fixed_flags('NOT A REAL SG')
+ flags = cell_symmetry_constrained_flags('NOT A REAL SG')
assert all(v is False for v in flags.values())
monkeypatch.setattr(Logger, '_reaction', Logger.Reaction.RAISE, raising=True)
diff --git a/tests/unit/easydiffraction/crystallography/test_crystallography_wyckoff.py b/tests/unit/easydiffraction/crystallography/test_crystallography_wyckoff.py
index 952f3004c..377ed7f2f 100644
--- a/tests/unit/easydiffraction/crystallography/test_crystallography_wyckoff.py
+++ b/tests/unit/easydiffraction/crystallography/test_crystallography_wyckoff.py
@@ -55,32 +55,32 @@ def test_valid_applies_constraints(self):
assert result is not None
-class TestAtomSiteSymmetryFixedFlags:
+class TestAtomSiteSymmetryConstrainedFlags:
def test_special_position_all_fixed(self):
from easydiffraction.crystallography.crystallography import (
- atom_site_symmetry_fixed_flags,
+ atom_site_symmetry_constrained_flags,
)
# P m -3 m (IT 221), Wyckoff 'a' = (0,0,0): all three axes fixed
- flags = atom_site_symmetry_fixed_flags('P m -3 m', '1', 'a')
+ flags = atom_site_symmetry_constrained_flags('P m -3 m', '1', 'a')
assert flags == {'fract_x': True, 'fract_y': True, 'fract_z': True}
def test_general_position_all_free(self):
from easydiffraction.crystallography.crystallography import (
- atom_site_symmetry_fixed_flags,
+ atom_site_symmetry_constrained_flags,
)
# P 1 (IT 1), Wyckoff 'a' is the general position
- flags = atom_site_symmetry_fixed_flags('P 1', '1', 'a')
+ flags = atom_site_symmetry_constrained_flags('P 1', '1', 'a')
assert flags == {'fract_x': False, 'fract_y': False, 'fract_z': False}
def test_invalid_returns_all_false(self, monkeypatch):
from easydiffraction.crystallography.crystallography import (
- atom_site_symmetry_fixed_flags,
+ atom_site_symmetry_constrained_flags,
)
from easydiffraction.utils.logging import Logger
monkeypatch.setattr(Logger, '_reaction', Logger.Reaction.WARN, raising=True)
- flags = atom_site_symmetry_fixed_flags('NOT REAL', None, 'a')
+ flags = atom_site_symmetry_constrained_flags('NOT REAL', None, 'a')
assert flags == {'fract_x': False, 'fract_y': False, 'fract_z': False}
monkeypatch.setattr(Logger, '_reaction', Logger.Reaction.RAISE, raising=True)
diff --git a/tests/unit/easydiffraction/datablocks/experiment/categories/data/test_bragg_pd.py b/tests/unit/easydiffraction/datablocks/experiment/categories/data/test_bragg_pd.py
index 1a3f233cb..c50a095e5 100644
--- a/tests/unit/easydiffraction/datablocks/experiment/categories/data/test_bragg_pd.py
+++ b/tests/unit/easydiffraction/datablocks/experiment/categories/data/test_bragg_pd.py
@@ -4,6 +4,18 @@
import numpy as np
+def _experiment_stub(name='exp1'):
+ from easydiffraction.core.identity import Identity
+
+ class ExperimentStub:
+ def __init__(self):
+ self._parent = None
+ self._identity = Identity(owner=self)
+ self._identity.datablock_entry_name = lambda: name
+
+ return ExperimentStub()
+
+
def test_pd_cwl_data_point_defaults():
from easydiffraction.datablocks.experiment.categories.data.bragg_pd import PdCwlDataPoint
@@ -95,6 +107,19 @@ def test_pd_tof_data_collection_create_and_properties():
assert coll._items[2].point_id.value == '3'
+def test_pd_data_items_resolve_experiment_datablock_name():
+ from easydiffraction.datablocks.experiment.categories.data.bragg_pd import PdCwlData
+
+ coll = PdCwlData()
+ coll._parent = _experiment_stub('hrpt')
+
+ coll._create_items_set_xcoord_and_id(np.array([10.0, 20.0]))
+
+ param = coll._items[0].intensity_meas
+ assert param._identity.datablock_entry_name == 'hrpt'
+ assert param.unique_name == 'hrpt.pd_data.1.intensity_meas'
+
+
def test_pd_data_calc_status_exclusion():
from easydiffraction.datablocks.experiment.categories.data.bragg_pd import PdCwlData
diff --git a/tests/unit/easydiffraction/datablocks/experiment/categories/data/test_total_pd.py b/tests/unit/easydiffraction/datablocks/experiment/categories/data/test_total_pd.py
index 41e638e58..3198158e2 100644
--- a/tests/unit/easydiffraction/datablocks/experiment/categories/data/test_total_pd.py
+++ b/tests/unit/easydiffraction/datablocks/experiment/categories/data/test_total_pd.py
@@ -4,6 +4,18 @@
import numpy as np
+def _experiment_stub(name='exp1'):
+ from easydiffraction.core.identity import Identity
+
+ class ExperimentStub:
+ def __init__(self):
+ self._parent = None
+ self._identity = Identity(owner=self)
+ self._identity.datablock_entry_name = lambda: name
+
+ return ExperimentStub()
+
+
def test_total_data_point_defaults():
from easydiffraction.datablocks.experiment.categories.data.total_pd import TotalDataPoint
@@ -89,3 +101,16 @@ def test_total_data_type_info():
assert TotalData.type_info.tag == 'total-pd'
assert TotalData.type_info.description == 'Total scattering (PDF) data'
+
+
+def test_total_data_items_resolve_experiment_datablock_name():
+ from easydiffraction.datablocks.experiment.categories.data.total_pd import TotalData
+
+ coll = TotalData()
+ coll._parent = _experiment_stub('pdf-exp')
+
+ coll._create_items_set_xcoord_and_id(np.array([1.0, 2.0]))
+
+ param = coll._items[0].g_r_meas
+ assert param._identity.datablock_entry_name == 'pdf-exp'
+ assert param.unique_name == 'pdf-exp.total_data.1.g_r_meas'
diff --git a/tests/unit/easydiffraction/datablocks/experiment/categories/refln/test_bragg_sc.py b/tests/unit/easydiffraction/datablocks/experiment/categories/refln/test_bragg_sc.py
index fc7175d4a..ffae191b7 100644
--- a/tests/unit/easydiffraction/datablocks/experiment/categories/refln/test_bragg_sc.py
+++ b/tests/unit/easydiffraction/datablocks/experiment/categories/refln/test_bragg_sc.py
@@ -4,6 +4,18 @@
import numpy as np
+def _experiment_stub(name='exp1'):
+ from easydiffraction.core.identity import Identity
+
+ class ExperimentStub:
+ def __init__(self):
+ self._parent = None
+ self._identity = Identity(owner=self)
+ self._identity.datablock_entry_name = lambda: name
+
+ return ExperimentStub()
+
+
def test_refln_data_point_defaults():
from easydiffraction.datablocks.experiment.categories.refln.bragg_sc import Refln
@@ -74,6 +86,23 @@ def test_refln_data_d_spacing_and_stol():
np.testing.assert_array_almost_equal(coll.sin_theta_over_lambda, stol)
+def test_refln_items_resolve_experiment_datablock_name():
+ from easydiffraction.datablocks.experiment.categories.refln.bragg_sc import ReflnData
+
+ coll = ReflnData()
+ coll._parent = _experiment_stub('sc-exp')
+
+ coll._create_items_set_hkl_and_id(
+ np.array([1.0, 2.0]),
+ np.array([0.0, 0.0]),
+ np.array([0.0, 1.0]),
+ )
+
+ param = coll._items[0].intensity_meas
+ assert param._identity.datablock_entry_name == 'sc-exp'
+ assert param.unique_name == 'sc-exp.refln.1.intensity_meas'
+
+
def test_refln_data_type_info():
from easydiffraction.datablocks.experiment.categories.refln.bragg_sc import ReflnData
diff --git a/tests/unit/easydiffraction/io/cif/test_serialize_more.py b/tests/unit/easydiffraction/io/cif/test_serialize_more.py
index 0f98fe1a7..1add7692d 100644
--- a/tests/unit/easydiffraction/io/cif/test_serialize_more.py
+++ b/tests/unit/easydiffraction/io/cif/test_serialize_more.py
@@ -40,6 +40,50 @@ def __init__(self):
assert '7' in out
+def test_datablock_item_to_cif_skips_empty_category_fragments():
+ import easydiffraction.io.cif.serialize as MUT
+ from easydiffraction.core.category import CategoryCollection
+ from easydiffraction.core.category import CategoryItem
+ from easydiffraction.io.cif.handler import CifHandler
+
+ class Item(CategoryItem):
+ def __init__(self, val):
+ super().__init__()
+ self._p = type('P', (), {})()
+ self._p._cif_handler = CifHandler(names=['_aa'])
+ self._p.value = val
+
+ @property
+ def parameters(self):
+ return [self._p]
+
+ @property
+ def as_cif(self) -> str:
+ return MUT.category_item_to_cif(self)
+
+ class EmptyItem(CategoryItem):
+ @property
+ def parameters(self):
+ return []
+
+ @property
+ def as_cif(self) -> str:
+ return ''
+
+ class DB:
+ def __init__(self):
+ self._identity = type('I', (), {'datablock_entry_name': 'block1'})()
+ self.item = Item(42)
+ self.empty_item = EmptyItem()
+ self.coll = CategoryCollection(item_type=Item)
+ self.coll['row1'] = Item(7)
+ self.empty_coll = CategoryCollection(item_type=Item)
+
+ out = MUT.datablock_item_to_cif(DB())
+ assert out == 'data_block1\n\n_aa 42.\n\nloop_\n_aa\n7.'
+ assert '\n\n\n' not in out
+
+
def test_datablock_collection_to_cif_concatenates_blocks():
import easydiffraction.io.cif.serialize as MUT
diff --git a/tests/unit/easydiffraction/project/test_display.py b/tests/unit/easydiffraction/project/test_display.py
index 46f22296a..8e8f7695d 100644
--- a/tests/unit/easydiffraction/project/test_display.py
+++ b/tests/unit/easydiffraction/project/test_display.py
@@ -141,6 +141,41 @@ def test_parameter_display_delegates_to_analysis_display():
]
+def test_project_display_help_lists_namespaces_and_methods(capsys):
+ project, _calls = _make_project_stub()
+ display = ProjectDisplay(project)
+
+ display.help()
+ out = capsys.readouterr().out
+
+ assert "Help for 'ProjectDisplay'" in out
+ assert 'parameters' in out
+ assert 'fit' in out
+ assert 'posterior' in out
+ assert 'pattern()' in out
+ assert 'show_pattern_options()' in out
+
+
+def test_nested_project_display_help_lists_methods(capsys):
+ project, _calls = _make_project_stub()
+ display = ProjectDisplay(project)
+
+ display.parameters.help()
+ display.fit.help()
+ display.posterior.help()
+ out = capsys.readouterr().out
+
+ assert "Help for 'ParameterDisplay'" in out
+ assert 'all()' in out
+ assert 'access()' in out
+ assert "Help for 'FitDisplay'" in out
+ assert 'results()' in out
+ assert 'correlations()' in out
+ assert "Help for 'PosteriorDisplay'" in out
+ assert 'pairs()' in out
+ assert 'predictive()' in out
+
+
def test_fit_display_delegates_to_analysis_and_rendering():
project, calls = _make_project_stub()
display = ProjectDisplay(project)
diff --git a/tests/unit/easydiffraction/summary/test_summary.py b/tests/unit/easydiffraction/summary/test_summary.py
index b8af02667..6c34baf11 100644
--- a/tests/unit/easydiffraction/summary/test_summary.py
+++ b/tests/unit/easydiffraction/summary/test_summary.py
@@ -50,6 +50,21 @@ class R:
assert 'FITTING' in out
+def test_summary_help(capsys):
+ from easydiffraction.summary.summary import Summary
+
+ class P:
+ pass
+
+ s = Summary(P())
+ s.help()
+ out = capsys.readouterr().out
+ assert "Help for 'Summary'" in out
+ assert 'show_report()' in out
+ assert 'show_project_info()' in out
+ assert 'show_fitting_details()' in out
+
+
def test_module_import():
import easydiffraction.summary.summary as MUT
diff --git a/tests/unit/easydiffraction/utils/test_utils.py b/tests/unit/easydiffraction/utils/test_utils.py
index 13564ca44..8c3d2e760 100644
--- a/tests/unit/easydiffraction/utils/test_utils.py
+++ b/tests/unit/easydiffraction/utils/test_utils.py
@@ -142,6 +142,33 @@ def test_render_table_terminal_branch(capsys, monkeypatch):
assert ('╒' in out and '╕' in out) or ('┌' in out and '┐' in out)
+def test_render_object_help_prints_public_api(capsys):
+ import easydiffraction.utils.utils as MUT
+
+ class Example:
+ @property
+ def value(self):
+ """Visible value."""
+ return 1
+
+ def run(self):
+ """Run visible action."""
+
+ def _hidden(self):
+ """Hidden action."""
+
+ MUT.render_object_help(Example())
+ out = capsys.readouterr().out
+ assert "Help for 'Example'" in out
+ assert 'Properties' in out
+ assert 'value' in out
+ assert 'Visible value.' in out
+ assert 'Methods' in out
+ assert 'run()' in out
+ assert 'Run visible action.' in out
+ assert '_hidden' not in out
+
+
def test_is_dev_version_with_dev_suffix(monkeypatch):
import easydiffraction.utils.utils as MUT