Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,18 @@ All notable changes are recorded here. The project follows semantic versioning a
- Console entry point `diffractscout-quick-export` and GUI entry point `diffractscout-gui` documented alongside `diffractscout`.
- Documentation for CIF2Peaks parity features (lab Excel views, *d*-range filters, bilingual lab sheets, quick-export, figures) in README / README.zh-CN, GUI controls in `docs/GUI.md`, and module mapping in `docs/SOURCE_LINEAGE.md`.

### Fixed

- Validated run-wide diffraction and discovery settings before copying local inputs, contacting providers, or starting GUI workers.
- Snapshotted GUI run options on the Tk thread so background workers do not access mutable Tk state.
- Required explicit overwrite authorization for existing quick-export workbooks and replaced authorized workbooks atomically.
- Rechecked bundle and benchmark targets immediately before commit so files created during a long-running calculation are not silently replaced.
- Rejected malformed or non-finite provider elasticity matrices instead of truncating oversized arrays to 6×6.
- Applied Materials Project stability thresholds before server-side result limits, with strict local verification so qualifying candidates are not lost to post-filtering.
- Normalized common Unicode dash characters and case-insensitive chemical-system input during composition parsing.
- Kept the optional spglib cross-check warning-only across its 2.7-to-2.8 exception transition without emitting repeated deprecation noise.
- Exported an empty Cu Kα convenience angle, rather than a false `0°`, when a reflection is inaccessible at that wavelength.

### Notes

- Awaiting the first public GitHub release, archived software DOI, and external validation cases.
Expand Down
18 changes: 10 additions & 8 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,15 @@ for diagnostic in result.diagnostics:

`analyze_cifs`:

1. resolves and deduplicates CIF inputs;
2. rejects overlap between input and output trees;
3. copies source artifacts into a staging result directory;
4. pairs elastic sidecars only when `include_elasticity=True`;
5. analyzes each readable phase while recording per-phase diagnostics;
6. writes CSV, optional XLSX, provenance, and a manifest;
7. verifies the staged bundle;
8. atomically moves it into the requested target.
1. validates run-wide radiation, scan, profile, and resource settings before writes;
2. resolves and deduplicates CIF inputs;
3. rejects overlap between input and output trees;
4. copies source artifacts into a staging result directory;
5. pairs elastic sidecars only when `include_elasticity=True`;
6. analyzes each readable phase while recording per-phase diagnostics;
7. writes CSV, optional XLSX, provenance, and a manifest;
8. verifies the staged bundle;
9. atomically moves it into the requested target.

It returns a `PipelineResult`. An invalid phase can be recorded in `diagnostics` while other phases complete. CLI exit status is `0` when all analyzable items complete, `3` when a usable bundle contains error diagnostics for one or more items, and `2` when no phase is analyzable or a fatal input/configuration error occurs.

Expand Down Expand Up @@ -139,6 +140,7 @@ Downloads above `confirm_above` require `authorize_large_download=True`. Automat
- `diffractscout.composition.parse_composition_text(text)`
- `diffractscout.composition.chemsys_subsystems(elements, max_order=None)`
- `diffractscout.selection.validate_discovery_settings(settings)`
- `diffractscout.diffraction.validate_analysis_settings(settings)`
- `diffractscout.structure.load_structure(path)`
- `diffractscout.elasticity.discover_elastic_tensor(cif_path)`
- `diffractscout.elasticity.validate_elastic_tensor(matrix_GPa, ...)`
Expand Down
8 changes: 7 additions & 1 deletion docs/GUI.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ A normal Python installation with Tk support is required. On Linux, the operatin

Optional extra `.[gui-dnd]` installs `tkinterdnd2` for future drag-and-drop enhancements; the current GUI does not require it.

## Layout and scrolling

Dense forms (radiation, Cij, export options) live in **vertically scrollable** columns: use the mouse wheel or the right-hand scrollbar. Primary **Analyze / Run** actions stay **pinned under** the scroll area so they remain visible. The Activity log is in a **resizable vertical split** under the notebook—drag the sash to give the form more height on small screens. Default window size is about `1200×820` with a lower minimum (`900×640`).

## Local CIF analysis

![Local CIF analysis interface](assets/gui-local.png)
Expand Down Expand Up @@ -99,6 +103,8 @@ diffractscout quick-export path/to/cifs -o path/to/bundle_dir

On Windows, drag CIF files or folders onto `quick_export_diffractscout.bat`. The script writes `<first-stem>_diffractscout.xlsx` next to the first input (bundle: `<stem>_diffractscout_bundle/`).

An existing workbook is never replaced implicitly. Choose a different path or enable the explicit overwrite option; an authorized replacement is written through a temporary file so a failed copy does not expose a partial workbook.

## Activity log and completion states

The Activity panel reports timestamps and separates informational, warning, and error diagnostics. A completed bundle can contain diagnostic errors for individual phases that failed while other phases succeeded. Completion messages therefore distinguish:
Expand All @@ -111,7 +117,7 @@ The `Open result folder` action is enabled after a result bundle has been writte

## Threading and window closure

One pipeline task can run at a time. Run buttons are disabled while a worker thread is active, preventing duplicate downloads or simultaneous writes to the same target. Closing the window during a task requires confirmation. The scientific output transaction remains responsible for preserving an existing valid bundle when a run fails.
One pipeline task can run at a time. Run buttons are disabled while a worker thread is active, preventing duplicate downloads or simultaneous writes to the same target. All run options are validated and snapshotted on the GUI thread before the worker starts, so later interface edits cannot change an in-flight run and the worker never reads Tk state. Closing the window during a task requires confirmation. The scientific output transaction remains responsible for preserving an existing valid bundle when a run fails.

## Headless smoke test

Expand Down
2 changes: 1 addition & 1 deletion docs/SCHEMA_ALIASES.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ DiffractScout therefore:
| `d_A` | `d_spacing_A` | \(d\)-spacing in Å |
| `theta_deg` | `theta_deg` | Bragg angle \(\theta\) |
| `two_theta_current_deg` / `two_theta_deg` | `two_theta_deg` | \(2\theta\) for the active wavelength |
| `two_theta_cu_ka_deg` | `two_theta_cu_ka_deg` | Convenience \(2\theta\) at Cu Kα (\(\lambda=1.5406\) Å) |
| `two_theta_cu_ka_deg` | `two_theta_cu_ka_deg` | Convenience \(2\theta\) at Cu Kα (\(\lambda=1.5406\) Å); blank when the reflection is inaccessible at that wavelength |
| `q_1_over_A` | `q_invA` | \(q = 2\pi / d\) |
| `g_1_over_A` | `g_invA` | \(g = 1 / d\) |

Expand Down
1 change: 1 addition & 0 deletions src/diffractscout/benchmark.py
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,7 @@ def run_reference_benchmarks(
"Analytic benchmark bundle failed integrity verification: "
+ "; ".join(verification["errors"])
)
_prepare_target(output_dir, overwrite=overwrite)
_commit_directory(target, staging)
except Exception:
shutil.rmtree(staging, ignore_errors=True)
Expand Down
38 changes: 31 additions & 7 deletions src/diffractscout/composition.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,20 @@
r"(\d+(?:\.\d+)?)\s*([A-Z][a-z]?))\s*(?:wt%|at%|mass%|%)?",
re.IGNORECASE,
)
_INPUT_TRANSLATION = str.maketrans(
{
"‐": "-", # hyphen
"‑": "-", # non-breaking hyphen
"‒": "-", # figure dash
"–": "-", # en dash
"—": "-", # em dash
"−": "-", # mathematical minus
"-": "-", # full-width hyphen-minus
"+": "+",
",": ",",
";": ";",
}
)


def normalize_element(symbol: str) -> str | None:
Expand Down Expand Up @@ -89,32 +103,42 @@ def parse_composition_text(text: str) -> ParsedComposition:
elements: list[str] = []
labels: list[str] = []
notes: list[str] = []
material_ids = [item.lower() for item in MP_ID_RE.findall(raw)]
normalized_raw = raw.translate(_INPUT_TRANSLATION)
material_ids = [item.lower() for item in MP_ID_RE.findall(normalized_raw)]
material_ids = list(dict.fromkeys(material_ids))

normalized_text = raw.lower().replace("+", "+").replace(",", ",")
normalized_text = normalized_raw.lower()
compact = re.sub(r"[^a-z0-9]+", "", normalized_text)
for alias, alias_elements in ALLOY_ALIASES.items():
if alias in compact:
_append_unique(elements, alias_elements)
labels.append(alias)
notes.append(f"alias_element_set_only:{alias}")

for match in CHEMSYS_RE.finditer(raw):
# Accept a complete chemical-system expression case-insensitively while
# keeping formula/prose token parsing conservative.
if re.fullmatch(r"[A-Za-z]{1,2}(?:-[A-Za-z]{1,2})+", normalized_raw):
parts = normalized_raw.split("-")
normalized = [normalize_element(part) for part in parts]
if normalized and all(normalized):
_append_unique(elements, [item for item in normalized if item])
labels.append("".join(item for item in normalized if item))

for match in CHEMSYS_RE.finditer(normalized_raw):
parts = match.group(1).split("-")
normalized = [normalize_element(part) for part in parts]
if normalized and all(normalized):
_append_unique(elements, [item for item in normalized if item])
labels.append(match.group(1).replace("-", ""))

for match in PERCENT_PAIR_RE.finditer(raw):
for match in PERCENT_PAIR_RE.finditer(normalized_raw):
symbol = match.group(1) or match.group(4) or ""
normalized = normalize_element(symbol)
if normalized:
_append_unique(elements, [normalized])

# Formula/grade tokens such as Ti6Al4V. Require at least two recognized elements.
for match in FORMULA_TOKEN_RE.finditer(raw.replace("-", "")):
for match in FORMULA_TOKEN_RE.finditer(normalized_raw.replace("-", "")):
token = match.group(0)
parsed = formula_elements(token)
if len(parsed) >= 2:
Expand All @@ -124,7 +148,7 @@ def parse_composition_text(text: str) -> ParsedComposition:
# Explicit additive notation, e.g. Ti-6Al-4V + Cu.
for match in re.finditer(
r"(?:\+|+|/|、|和|加|with)\s*([A-Z][a-z]?)\b",
raw,
normalized_raw,
flags=re.IGNORECASE,
):
normalized = normalize_element(match.group(1))
Expand All @@ -133,7 +157,7 @@ def parse_composition_text(text: str) -> ParsedComposition:

# Last-resort token parse for input such as "Ti Al V".
if not elements and not material_ids:
for token in re.findall(r"\b[A-Z][a-z]?\b", raw):
for token in re.findall(r"\b[A-Z][a-z]?\b", normalized_raw):
normalized = normalize_element(token)
if normalized:
_append_unique(elements, [normalized])
Expand Down
25 changes: 22 additions & 3 deletions src/diffractscout/diffraction.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,13 @@ def _safe_inverse(value: float) -> float | None:
return float(inverse) if np.isfinite(inverse) else None


def _validate_settings(settings: AnalysisSettings) -> None:
def validate_analysis_settings(settings: AnalysisSettings) -> None:
"""Validate run-wide diffraction settings before any input or provider work.

This is intentionally structure-independent so callers can fail fast before
copying local inputs or downloading provider records.
"""

values = (
settings.two_theta_min_deg,
settings.two_theta_max_deg,
Expand Down Expand Up @@ -162,6 +168,17 @@ def _validate_settings(settings: AnalysisSettings) -> None:
value = getattr(settings, name)
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
raise ValueError(f"{name} must be a positive integer.")
# Validate the mode-specific radiation value as part of the same preflight.
resolve_wavelength(settings)
if settings.include_figures:
from .plotting import FIGURE_EXPORT_PRESETS

preset = settings.figure_preset or "publication"
if preset not in FIGURE_EXPORT_PRESETS:
raise ValueError(
f"Unknown figure export preset: {preset}. Valid presets: "
+ ", ".join(sorted(FIGURE_EXPORT_PRESETS))
)


def _profile_point_count(settings: AnalysisSettings) -> int:
Expand Down Expand Up @@ -307,7 +324,7 @@ def simulate_powder_pattern(
*,
elastic_tensor: ElasticTensor | None = None,
) -> PhaseAnalysis:
_validate_settings(settings)
validate_analysis_settings(settings)
wavelength, energy, wavelength_source = resolve_wavelength(settings)
# Narrow 2θ by Bragg intersection with optional d bounds, then filter by d.
settings = apply_d_range_to_settings(settings)
Expand Down Expand Up @@ -423,7 +440,9 @@ def simulate_powder_pattern(
elastic_status=elastic_status,
elastic_note=elastic_note,
i=index_i,
two_theta_cu_ka_deg=float(cu_ka_two_theta) if cu_ka_two_theta is not None else 0.0,
two_theta_cu_ka_deg=(
float(cu_ka_two_theta) if cu_ka_two_theta is not None else None
),
inverse_R_hkl=_safe_inverse(r_with_lp),
inverse_R_hkl_no_lp=_safe_inverse(r_no_lp),
sin_theta=sin_theta,
Expand Down
Loading