midas-2d 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
midas_2d/__init__.py ADDED
@@ -0,0 +1,211 @@
1
+ """midas-2d -- differentiable diffraction for 2D / few-layer & ultrafast work.
2
+
3
+ Phase 1 (finite-size forward core):
4
+ from midas_2d import (
5
+ interference_factor, truncation_rod, nanoplatelet_rod,
6
+ rod_intensity, structure_factor_q,
7
+ cdse_zincblende, build_crystal_tensor,
8
+ make_spectrum, monochromatic,
9
+ )
10
+
11
+ Builds on midas-hkls (structure factors, form factors, lattice, hkl gen) and
12
+ midas-pink (energy spectrum). See MIDAS_2D_ULTRAFAST_DIFFRACTION_PLAN.md.
13
+ """
14
+ from .coherent import bcdi_forward, coherent_speckle, phase_retrieval
15
+ from .debye import (
16
+ atomic_form_factors,
17
+ coherent_amplitude,
18
+ coherent_intensity,
19
+ debye_intensity,
20
+ ensemble_intensity,
21
+ )
22
+ from .disorder import (
23
+ AnisotropicMSD,
24
+ TransientMSD,
25
+ dwf_amplitude,
26
+ msd_tensor_from_frames,
27
+ )
28
+ from .dynamics import (
29
+ msd_from_stiffness,
30
+ recover_stiffness,
31
+ stiffness_from_msd,
32
+ thermal_ensemble,
33
+ )
34
+ from .energy import make_spectrum, monochromatic
35
+ from .instrument import (
36
+ add_poisson_noise,
37
+ poisson_nll,
38
+ project_to_detector,
39
+ resolution_convolve,
40
+ solid_angle_polarization,
41
+ )
42
+ from .inverse import cosine_loss, fit, laplace_uncertainty, relative_l2_loss
43
+ from .ml import ParameterMLP, make_dataset, train_surrogate
44
+ from .phonon import (
45
+ apply_out_of_plane_strain,
46
+ bragg_timeseries,
47
+ fit_coherent_phonon,
48
+ strain_wave,
49
+ )
50
+ from .realdata import debye_reference_numpy, load_profile
51
+ from .active_learning import (
52
+ fisher_information,
53
+ next_best_measurement,
54
+ rank_measurements,
55
+ sensitivity,
56
+ )
57
+ from .ensemble import polydisperse_rod, recover_thickness_distribution
58
+ from .latent_dynamics import discover_eom, integrate_latent_ode, library_terms
59
+ from .md_integrator import (
60
+ bragg_from_trajectory,
61
+ coherent_mode_kick,
62
+ harmonic_force,
63
+ recover_potential_from_movie,
64
+ velocity_verlet,
65
+ )
66
+ from .multimodal import (
67
+ carrier_density,
68
+ fit_multimodal,
69
+ lattice_temperature_from_carriers,
70
+ optical_signal,
71
+ strain_two_channel,
72
+ xray_only_degeneracy,
73
+ )
74
+ from .strain_profile import (
75
+ acoustic_pulse,
76
+ apply_depth_displacement,
77
+ depth_resolved_amplitude,
78
+ depth_resolved_intensity,
79
+ exponential_strain,
80
+ linear_strain,
81
+ recover_depth_strain,
82
+ strain_to_displacement,
83
+ temperature_to_displacement,
84
+ temperature_to_msd,
85
+ thermal_rod,
86
+ )
87
+ from .thermal_transport import (
88
+ fit_electron_phonon_coupling,
89
+ fit_thermal_diffusivity,
90
+ heat_diffusion_1d,
91
+ lattice_T_to_intensity_ratio,
92
+ two_temperature_model,
93
+ )
94
+ from .rocking import (
95
+ analytic_rod_model,
96
+ fwhm,
97
+ md_rod_model,
98
+ reciprocal_space_map,
99
+ rocking_curve,
100
+ thickness_from_fwhm,
101
+ thickness_loss_scan,
102
+ )
103
+ from .forward import rod_intensity, structure_factor_q
104
+ from .io import (
105
+ build_crystal_tensor,
106
+ cdse_supercell,
107
+ cdse_zincblende,
108
+ load_xyz_frames,
109
+ supercell_from_crystal,
110
+ )
111
+ from .shape_factor import (
112
+ interference_factor,
113
+ laue_interference_1d,
114
+ nanoplatelet_rod,
115
+ truncation_rod,
116
+ )
117
+
118
+ __version__ = "0.1.0"
119
+
120
+ __all__ = [
121
+ "interference_factor",
122
+ "laue_interference_1d",
123
+ "nanoplatelet_rod",
124
+ "truncation_rod",
125
+ "rod_intensity",
126
+ "structure_factor_q",
127
+ "cdse_zincblende",
128
+ "build_crystal_tensor",
129
+ "cdse_supercell",
130
+ "supercell_from_crystal",
131
+ "load_xyz_frames",
132
+ "atomic_form_factors",
133
+ "coherent_amplitude",
134
+ "coherent_intensity",
135
+ "debye_intensity",
136
+ "ensemble_intensity",
137
+ "AnisotropicMSD",
138
+ "TransientMSD",
139
+ "dwf_amplitude",
140
+ "msd_tensor_from_frames",
141
+ "analytic_rod_model",
142
+ "md_rod_model",
143
+ "rocking_curve",
144
+ "fwhm",
145
+ "thickness_from_fwhm",
146
+ "reciprocal_space_map",
147
+ "thickness_loss_scan",
148
+ "fit",
149
+ "laplace_uncertainty",
150
+ "relative_l2_loss",
151
+ "cosine_loss",
152
+ "coherent_speckle",
153
+ "bcdi_forward",
154
+ "phase_retrieval",
155
+ "stiffness_from_msd",
156
+ "msd_from_stiffness",
157
+ "thermal_ensemble",
158
+ "recover_stiffness",
159
+ "strain_wave",
160
+ "apply_out_of_plane_strain",
161
+ "bragg_timeseries",
162
+ "fit_coherent_phonon",
163
+ "make_dataset",
164
+ "ParameterMLP",
165
+ "train_surrogate",
166
+ "project_to_detector",
167
+ "solid_angle_polarization",
168
+ "poisson_nll",
169
+ "add_poisson_noise",
170
+ "resolution_convolve",
171
+ "load_profile",
172
+ "debye_reference_numpy",
173
+ "depth_resolved_amplitude",
174
+ "depth_resolved_intensity",
175
+ "apply_depth_displacement",
176
+ "linear_strain",
177
+ "exponential_strain",
178
+ "acoustic_pulse",
179
+ "strain_to_displacement",
180
+ "temperature_to_displacement",
181
+ "temperature_to_msd",
182
+ "thermal_rod",
183
+ "recover_depth_strain",
184
+ "two_temperature_model",
185
+ "lattice_T_to_intensity_ratio",
186
+ "fit_electron_phonon_coupling",
187
+ "heat_diffusion_1d",
188
+ "fit_thermal_diffusivity",
189
+ "harmonic_force",
190
+ "velocity_verlet",
191
+ "bragg_from_trajectory",
192
+ "coherent_mode_kick",
193
+ "recover_potential_from_movie",
194
+ "carrier_density",
195
+ "lattice_temperature_from_carriers",
196
+ "strain_two_channel",
197
+ "optical_signal",
198
+ "fit_multimodal",
199
+ "xray_only_degeneracy",
200
+ "library_terms",
201
+ "integrate_latent_ode",
202
+ "discover_eom",
203
+ "polydisperse_rod",
204
+ "recover_thickness_distribution",
205
+ "sensitivity",
206
+ "fisher_information",
207
+ "rank_measurements",
208
+ "next_best_measurement",
209
+ "make_spectrum",
210
+ "monochromatic",
211
+ ]
@@ -0,0 +1,13 @@
1
+ """Experiment design -- now provided by the shared ``midas_invert`` leaf.
2
+
3
+ Thin re-export so existing ``midas_2d`` imports are unchanged.
4
+ """
5
+ from midas_invert.design import (
6
+ fisher_information,
7
+ next_best_measurement,
8
+ rank_measurements,
9
+ sensitivity,
10
+ )
11
+
12
+ __all__ = ["sensitivity", "fisher_information", "rank_measurements",
13
+ "next_best_measurement"]
midas_2d/coherent.py ADDED
@@ -0,0 +1,120 @@
1
+ """Coherent diffraction & differentiable phase retrieval (BCDI thrust).
2
+
3
+ Two faces of coherence:
4
+
5
+ * :func:`coherent_speckle` -- finite-size *speckle* straight from atomic
6
+ coordinates: ``|A(Q)|^2`` on a 2-D/3-D Q grid (reuses
7
+ :func:`midas_2d.debye.coherent_intensity`). This is the coordinate-level
8
+ coherent forward, differentiable w.r.t. positions.
9
+
10
+ * :func:`bcdi_forward` + :func:`phase_retrieval` -- the object-level BCDI model
11
+ used by the Hruszkewycz/Cherukara thrust: a complex object
12
+ ``psi(r) = rho(r) exp(i Q0 . u(r))`` (amplitude = electron density / support,
13
+ phase = lattice displacement projected on the Bragg vector) produces speckle
14
+ ``|FFT(psi)|^2``. :func:`phase_retrieval` inverts it by autograd -- the
15
+ differentiable alternative to iterative ER/HIO, and the natural place to drop
16
+ in a learned (Cherukara-style) prior.
17
+
18
+ All complex-valued and torch-differentiable; CPU/CUDA/MPS portable.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ from typing import TYPE_CHECKING
23
+
24
+ if TYPE_CHECKING: # pragma: no cover
25
+ import torch
26
+
27
+ __all__ = [
28
+ "coherent_speckle",
29
+ "bcdi_forward",
30
+ "phase_retrieval",
31
+ ]
32
+
33
+
34
+ def coherent_speckle(coords, elements, q_grid):
35
+ """``|A(Q)|^2`` over an arbitrary grid of Q-vectors (shape (..., 3))."""
36
+ from .debye import coherent_intensity
37
+ return coherent_intensity(coords, elements, q_grid)
38
+
39
+
40
+ def bcdi_forward(psi, *, shift=True):
41
+ """Far-field coherent intensity ``|FFT(psi)|^2`` of a complex object.
42
+
43
+ Parameters
44
+ ----------
45
+ psi : complex tensor
46
+ The complex object (1-, 2-, or 3-D).
47
+ shift : bool
48
+ Apply ``fftshift`` so the zero frequency is centred.
49
+
50
+ Returns
51
+ -------
52
+ real tensor, same shape as ``psi``.
53
+ """
54
+ import torch
55
+ F = torch.fft.fftn(psi)
56
+ if shift:
57
+ F = torch.fft.fftshift(F)
58
+ return F.real * F.real + F.imag * F.imag
59
+
60
+
61
+ def phase_retrieval(measured_intensity, support, *, init=None, steps=600,
62
+ lr=0.05, beta_tv=0.0):
63
+ """Differentiable (autograd) phase retrieval of a complex object.
64
+
65
+ Minimises ``|| |FFT(psi)|^2 - measured ||^2`` over a complex object
66
+ constrained to live inside ``support`` (a 0/1 mask, applied every forward
67
+ so the constraint is exact and differentiable).
68
+
69
+ Parameters
70
+ ----------
71
+ measured_intensity : real tensor
72
+ Measured speckle ``|FFT(psi_true)|^2`` (fftshifted), same shape as the
73
+ object.
74
+ support : real tensor (0/1)
75
+ Object-domain support mask.
76
+ init : complex tensor, optional
77
+ Starting object; defaults to a random object inside the support seeded
78
+ from the measured magnitude.
79
+ steps, lr : optimisation controls.
80
+ beta_tv : float
81
+ Optional total-variation weight on |psi| (smoothness prior).
82
+
83
+ Returns
84
+ -------
85
+ dict: ``psi`` (recovered complex object), ``history`` (loss list).
86
+ """
87
+ import torch
88
+
89
+ measured = torch.as_tensor(measured_intensity)
90
+ support = torch.as_tensor(support, dtype=measured.dtype, device=measured.device)
91
+
92
+ if init is None:
93
+ # Random phase on a magnitude guess derived from the measured speckle.
94
+ mag = torch.sqrt(torch.clamp(measured.mean()) + 0.0) * support
95
+ phase = 2 * torch.pi * torch.rand(measured.shape, device=measured.device,
96
+ dtype=measured.dtype)
97
+ init = torch.complex(mag * torch.cos(phase), mag * torch.sin(phase))
98
+
99
+ re = (init.real * support).clone().detach().requires_grad_(True)
100
+ im = (init.imag * support).clone().detach().requires_grad_(True)
101
+ opt = torch.optim.Adam([re, im], lr=lr)
102
+
103
+ history: list[float] = []
104
+ obs_norm = measured.pow(2).sum() + 1e-12
105
+ for _ in range(steps):
106
+ opt.zero_grad()
107
+ psi = torch.complex(re * support, im * support) # hard support constraint
108
+ pred = bcdi_forward(psi)
109
+ loss = ((pred - measured) ** 2).sum() / obs_norm
110
+ if beta_tv > 0:
111
+ amp = psi.abs()
112
+ tv = sum((torch.diff(amp, dim=d).abs().sum()
113
+ for d in range(amp.dim())))
114
+ loss = loss + beta_tv * tv
115
+ loss.backward()
116
+ opt.step()
117
+ history.append(float(loss))
118
+
119
+ psi = torch.complex(re * support, im * support).detach()
120
+ return {"psi": psi, "history": history}
midas_2d/debye.py ADDED
@@ -0,0 +1,166 @@
1
+ """MD-coupled differentiable diffraction: scatter straight from atomic coordinates.
2
+
3
+ Two forward paths, both autograd-differentiable **w.r.t. the atomic coordinates
4
+ themselves** (this is the novel hook -- gradients flow from a measured pattern
5
+ back to per-atom positions / displacement statistics, and onward to an MD force
6
+ field):
7
+
8
+ * :func:`coherent_amplitude` -- oriented, complex
9
+ A(Q) = sum_i f_i(|Q|) exp(i Q . r_i), I = |A|^2
10
+ Sampling ``Q`` along c* reproduces the analytic shape-function fringes of
11
+ Phase 1 (see the cross-check test) -- but here the fringes, the thermal
12
+ Debye-Waller falloff, and any *anisotropic* disordering all emerge from the
13
+ coordinates rather than from a phenomenological factor.
14
+
15
+ * :func:`debye_intensity` -- orientationally averaged (the colloidal-solution
16
+ case for the Schaller/Flanders nanoplatelets)
17
+ I(q) = sum_i sum_j f_i(q) f_j(q) sinc(q r_ij)
18
+
19
+ :func:`ensemble_intensity` averages the incoherent intensity over MD frames;
20
+ the spread of atomic positions across the trajectory *is* the disorder, so the
21
+ anisotropic out-of-plane vs in-plane Debye-Waller falloff is reproduced with no
22
+ DWF assumption at all.
23
+
24
+ Form factors come from ``midas_hkls`` (Cromer-Mann), evaluated at the physical
25
+ ``s = |Q| / (4 pi)``.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import math
30
+ from typing import TYPE_CHECKING, Sequence
31
+
32
+ from midas_hkls.form_factors import form_factor_batch
33
+
34
+ if TYPE_CHECKING: # pragma: no cover
35
+ import torch
36
+
37
+ __all__ = [
38
+ "atomic_form_factors",
39
+ "coherent_amplitude",
40
+ "coherent_intensity",
41
+ "debye_intensity",
42
+ "ensemble_intensity",
43
+ ]
44
+
45
+ _FOUR_PI = 4.0 * math.pi
46
+
47
+
48
+ def atomic_form_factors(elements, q_mag):
49
+ """Per-atom Cromer-Mann form factors at scattering magnitude ``q_mag`` (1/A).
50
+
51
+ ``q_mag`` may have any shape; returns shape ``(*q_mag.shape, M)`` with M the
52
+ number of atoms in ``elements``.
53
+ """
54
+ import torch
55
+ q_mag = torch.as_tensor(q_mag)
56
+ s2 = (q_mag / _FOUR_PI) ** 2 # s = |Q|/(4 pi)
57
+ return form_factor_batch(s2, list(elements)) # (..., M)
58
+
59
+
60
+ def coherent_amplitude(coords, elements, q_vec):
61
+ """Complex coherent amplitude ``A(Q) = sum_i f_i(|Q|) exp(i Q . r_i)``.
62
+
63
+ Parameters
64
+ ----------
65
+ coords : tensor (M, 3)
66
+ Cartesian atomic coordinates (Angstrom). Differentiable.
67
+ elements : sequence[str], length M
68
+ q_vec : tensor (..., 3)
69
+ Scattering vectors ``Q = 2 pi (h a* + k b* + l c*)`` in 1/A.
70
+
71
+ Returns
72
+ -------
73
+ tensor (...) complex
74
+ """
75
+ import torch
76
+ coords = torch.as_tensor(coords)
77
+ q_vec = torch.as_tensor(q_vec, dtype=coords.dtype, device=coords.device)
78
+ qmag = torch.linalg.vector_norm(q_vec, dim=-1) # (...)
79
+ f = atomic_form_factors(elements, qmag) # (..., M)
80
+ phase = q_vec @ coords.T # (..., M)
81
+ re = (f * torch.cos(phase)).sum(dim=-1)
82
+ im = (f * torch.sin(phase)).sum(dim=-1)
83
+ return torch.complex(re, im)
84
+
85
+
86
+ def coherent_intensity(coords, elements, q_vec):
87
+ """``|A(Q)|^2`` for the coherent (oriented) path."""
88
+ A = coherent_amplitude(coords, elements, q_vec)
89
+ return A.real * A.real + A.imag * A.imag
90
+
91
+
92
+ def debye_intensity(coords, elements, q_mag, *, tile=2048):
93
+ """Orientationally-averaged intensity via the Debye scattering equation.
94
+
95
+ I(q) = sum_i sum_j f_i(q) f_j(q) sinc(q r_ij), sinc(x)=sin(x)/x
96
+
97
+ Parameters
98
+ ----------
99
+ coords : tensor (M, 3)
100
+ Cartesian coordinates (A). Differentiable.
101
+ elements : sequence[str], length M
102
+ q_mag : tensor (Q,)
103
+ Scattering-vector magnitudes |Q| (1/A).
104
+ tile : int
105
+ Row-tile size over atoms to bound memory (the pair matrix is M x M).
106
+
107
+ Returns
108
+ -------
109
+ tensor (Q,)
110
+ """
111
+ import torch
112
+ coords = torch.as_tensor(coords)
113
+ q_mag = torch.as_tensor(q_mag, dtype=coords.dtype, device=coords.device)
114
+ M = coords.shape[0]
115
+ f = atomic_form_factors(elements, q_mag) # (Q, M)
116
+
117
+ out = torch.zeros(q_mag.shape, dtype=coords.dtype, device=coords.device)
118
+ for start in range(0, M, tile):
119
+ stop = min(start + tile, M)
120
+ ci = coords[start:stop] # (b, 3)
121
+ # pairwise distances block (b, M)
122
+ rij = torch.cdist(ci, coords) # (b, M)
123
+ qr = q_mag[:, None, None] * rij[None, :, :] # (Q, b, M)
124
+ sinc = torch.sinc(qr / math.pi) # sin(qr)/(qr), =1 at 0
125
+ fi = f[:, start:stop] # (Q, b)
126
+ # sum_j f_j sinc -> (Q, b); then weight by f_i and sum over i
127
+ contrib = (fi * torch.einsum('qm,qbm->qb', f, sinc)).sum(dim=-1)
128
+ out = out + contrib
129
+ return out
130
+
131
+
132
+ def ensemble_intensity(frames, elements, q, *, coherent=False, q_is_vector=None,
133
+ tile=2048):
134
+ """Average intensity over a stack of MD frames (the disorder = the spread).
135
+
136
+ Parameters
137
+ ----------
138
+ frames : tensor (F, M, 3)
139
+ F snapshots of M atoms (Angstrom).
140
+ elements : sequence[str], length M
141
+ q : tensor
142
+ ``(Q, 3)`` Q-vectors if ``coherent`` (or ``q_is_vector``), else ``(Q,)``
143
+ magnitudes for the Debye path.
144
+ coherent : bool
145
+ If True, average ``|A(Q)|^2`` over frames (oriented). Else average the
146
+ Debye powder intensity.
147
+
148
+ Returns
149
+ -------
150
+ tensor (Q,)
151
+ Frame-averaged intensity.
152
+ """
153
+ import torch
154
+ frames = torch.as_tensor(frames)
155
+ if frames.dim() == 2:
156
+ frames = frames.unsqueeze(0)
157
+ use_vec = coherent if q_is_vector is None else q_is_vector
158
+
159
+ acc = None
160
+ for fr in frames:
161
+ if use_vec:
162
+ I = coherent_intensity(fr, elements, q)
163
+ else:
164
+ I = debye_intensity(fr, elements, q, tile=tile)
165
+ acc = I if acc is None else acc + I
166
+ return acc / frames.shape[0]
midas_2d/disorder.py ADDED
@@ -0,0 +1,166 @@
1
+ """Transient, anisotropic disorder for few-layer materials.
2
+
3
+ Schaller/Flanders observe that optical excitation disorders a CdSe nanoplatelet
4
+ *anisotropically* -- the out-of-plane mean-square displacement grows differently
5
+ from the in-plane one, and it evolves with pump-probe delay. This module gives
6
+ that signature two equivalent faces:
7
+
8
+ 1. **Analytic Debye-Waller** (:class:`AnisotropicMSD`, :class:`TransientMSD`):
9
+ a differentiable temperature factor ``T(Q) = exp(-1/2 Q . U . Q)`` with a
10
+ diagonal Cartesian MSD tensor ``U = diag(u_par, u_par, u_perp)`` (Angstrom^2),
11
+ optionally a function of time. ``u_par``/``u_perp`` are fittable.
12
+
13
+ 2. **MD-derived** (:func:`msd_tensor_from_frames`): read the *same* tensor
14
+ straight off an MD trajectory as the covariance of atomic displacements.
15
+ This closes the loop with :mod:`midas_2d.debye` -- the ensemble spread of
16
+ coordinates *is* the disorder, and this function recovers the effective ``U``
17
+ that an analytic DWF would need to reproduce it.
18
+
19
+ Convention: ``Q`` in 1/A, ``U`` in A^2, platelet normal = +z (the c axis).
20
+ ``T`` multiplies the structure-factor *amplitude*; intensity carries ``T^2``.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ from typing import TYPE_CHECKING, Optional
25
+
26
+ if TYPE_CHECKING: # pragma: no cover
27
+ import torch
28
+
29
+ __all__ = [
30
+ "dwf_amplitude",
31
+ "AnisotropicMSD",
32
+ "TransientMSD",
33
+ "msd_tensor_from_frames",
34
+ ]
35
+
36
+
37
+ def dwf_amplitude(q_vec, u_par, u_perp):
38
+ """Anisotropic Debye-Waller *amplitude* factor ``exp(-1/2 Q.U.Q)``.
39
+
40
+ Parameters
41
+ ----------
42
+ q_vec : tensor (..., 3)
43
+ Scattering vectors (1/A).
44
+ u_par, u_perp : float or tensor
45
+ In-plane (x, y) and out-of-plane (z) mean-square displacements (A^2).
46
+
47
+ Returns
48
+ -------
49
+ tensor (...)
50
+ The amplitude multiplier; square it for the intensity multiplier.
51
+ """
52
+ import torch
53
+ q_vec = torch.as_tensor(q_vec)
54
+ u_par = torch.as_tensor(u_par, dtype=q_vec.dtype, device=q_vec.device)
55
+ u_perp = torch.as_tensor(u_perp, dtype=q_vec.dtype, device=q_vec.device)
56
+ qx, qy, qz = q_vec[..., 0], q_vec[..., 1], q_vec[..., 2]
57
+ quad = u_par * (qx * qx + qy * qy) + u_perp * (qz * qz)
58
+ return torch.exp(-0.5 * quad)
59
+
60
+
61
+ class AnisotropicMSD:
62
+ """Fittable static anisotropic MSD tensor (positivity via softplus).
63
+
64
+ ``u_par``/``u_perp`` are stored as unconstrained ``raw_*`` parameters; the
65
+ physical values are ``softplus(raw)`` so optimisation stays unconstrained.
66
+ """
67
+ def __init__(self, u_par=1e-3, u_perp=1e-3, *, dtype=None, device=None,
68
+ requires_grad=True):
69
+ import torch
70
+ dtype = dtype or torch.float64
71
+ self.raw_par = _inv_softplus(torch.as_tensor(u_par, dtype=dtype, device=device))
72
+ self.raw_perp = _inv_softplus(torch.as_tensor(u_perp, dtype=dtype, device=device))
73
+ if requires_grad:
74
+ self.raw_par = self.raw_par.clone().detach().requires_grad_(True)
75
+ self.raw_perp = self.raw_perp.clone().detach().requires_grad_(True)
76
+
77
+ @property
78
+ def u_par(self):
79
+ import torch
80
+ return torch.nn.functional.softplus(self.raw_par)
81
+
82
+ @property
83
+ def u_perp(self):
84
+ import torch
85
+ return torch.nn.functional.softplus(self.raw_perp)
86
+
87
+ def parameters(self):
88
+ return [self.raw_par, self.raw_perp]
89
+
90
+ def amplitude(self, q_vec):
91
+ return dwf_amplitude(q_vec, self.u_par, self.u_perp)
92
+
93
+
94
+ class TransientMSD:
95
+ """Per-delay anisotropic MSD: ``u_par(t)``, ``u_perp(t)`` over T pump-probe
96
+ delays. Each delay has its own fittable (softplus-positive) pair, so a full
97
+ time series of patterns is inverted jointly with shared structure.
98
+ """
99
+ def __init__(self, n_delays, u_par0=1e-3, u_perp0=1e-3, *, dtype=None,
100
+ device=None, requires_grad=True):
101
+ import torch
102
+ dtype = dtype or torch.float64
103
+ rp = _inv_softplus(torch.full((n_delays,), float(u_par0), dtype=dtype, device=device))
104
+ rq = _inv_softplus(torch.full((n_delays,), float(u_perp0), dtype=dtype, device=device))
105
+ if requires_grad:
106
+ rp = rp.clone().detach().requires_grad_(True)
107
+ rq = rq.clone().detach().requires_grad_(True)
108
+ self.raw_par = rp
109
+ self.raw_perp = rq
110
+ self.n_delays = int(n_delays)
111
+
112
+ @property
113
+ def u_par(self):
114
+ import torch
115
+ return torch.nn.functional.softplus(self.raw_par)
116
+
117
+ @property
118
+ def u_perp(self):
119
+ import torch
120
+ return torch.nn.functional.softplus(self.raw_perp)
121
+
122
+ def parameters(self):
123
+ return [self.raw_par, self.raw_perp]
124
+
125
+ def amplitude(self, q_vec, delay_index):
126
+ return dwf_amplitude(q_vec, self.u_par[delay_index], self.u_perp[delay_index])
127
+
128
+ def anisotropy(self):
129
+ """``u_perp / u_par`` per delay -- the out-of-plane/in-plane ratio that
130
+ is the experimental disordering signature."""
131
+ return self.u_perp / self.u_par
132
+
133
+
134
+ def msd_tensor_from_frames(frames, *, reference=None):
135
+ """Effective MSD tensor ``U`` (A^2) from an MD trajectory.
136
+
137
+ ``U = < (r - <r>) (r - <r>)^T >`` averaged over atoms and frames. The
138
+ diagonal entries are ``(u_xx, u_yy, u_zz)``; for a platelet,
139
+ ``u_zz`` is ``u_perp`` and ``(u_xx + u_yy)/2`` is ``u_par``.
140
+
141
+ Parameters
142
+ ----------
143
+ frames : tensor (F, M, 3)
144
+ reference : tensor (M, 3), optional
145
+ Mean positions; defaults to the per-atom time average.
146
+
147
+ Returns
148
+ -------
149
+ U : tensor (3, 3)
150
+ """
151
+ import torch
152
+ frames = torch.as_tensor(frames)
153
+ if frames.dim() == 2:
154
+ frames = frames.unsqueeze(0)
155
+ ref = reference if reference is not None else frames.mean(dim=0) # (M,3)
156
+ disp = (frames - ref[None]).reshape(-1, 3) # (F*M, 3)
157
+ U = (disp.T @ disp) / disp.shape[0] # (3,3)
158
+ return U
159
+
160
+
161
+ def _inv_softplus(y):
162
+ """Inverse of softplus so that softplus(raw) == y for the init value."""
163
+ import torch
164
+ y = torch.as_tensor(y)
165
+ # raw = log(exp(y) - 1); stable for small/large y.
166
+ return torch.where(y > 20, y, torch.log(torch.expm1(y.clamp(min=1e-12))))