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