Source code for ariane.app.tas.utils

from ...lib.utils.math import normalized_linear_interpolator

import json
import numpy as np
import math


D2R = np.pi / 180
R2D = 180 / np.pi
# conversion from THz to A^-2
K = 1.9958584
THZ2MEV = 4.1356675


[docs] def x2qe(x, axes, offset): """Transform coordinates from 2D plane to 4D Q-E space.""" return np.dot(x, axes) + offset
[docs] def qe2x(qe, axes, offset): """Transform coordinates from 4D Q-E space to 2D plane.""" axes = np.atleast_2d(axes) return np.linalg.solve(axes @ axes.T, np.dot(qe - offset, axes.T).T).T
[docs] class Sample: """Class representing a sample.""" def __init__(self, name: str, lattice, angles) -> None: self.name = name self.lattice = np.asarray(lattice) self.angles = np.asarray(angles) def __repr__(self) -> str: return f"name: {self.name}, lattice: {self.lattice}, angles: {self.angles}"
[docs] class Orientation: """Class representing an orientation of a sample.""" def __init__(self, orient1, orient2, psi0: float) -> None: self.orient1 = np.asarray(orient1) self.orient2 = np.asarray(orient2) self.psi0 = psi0 def __repr__(self) -> str: return f"orient1: {self.orient1}, orient2: {self.orient2}, psi0: {self.psi0}"
[docs] class InstrumentConfiguration: """Class representing an instrument configuration.""" def __init__(self, scan_mode: str, scan_constant: float, senses, coupled=False, psi360=True) -> None: assert scan_constant > 0 self.scan_mode = scan_mode self.scan_constant = scan_constant self.senses = np.asarray(senses) self.coupled = coupled self.psi360 = psi360 def __repr__(self) -> str: return f"scan mode: {self.scan_mode}, scan_constant: {self.scan_constant}\n" \ f"senses: {self.senses}, coupled: {self.coupled}, psi360: {self.psi360}"
[docs] class Instrument: """Class representing an instrument.""" def __init__(self, angle_velocities, d_mono: float, d_ana: float) -> None: self.angle_velocities = np.asarray(angle_velocities) self.d_mono = d_mono self.d_ana = d_ana def __repr__(self) -> str: return f"angle velocities: {self.angle_velocities}, \n" \ f"d_mono: {self.d_mono}, d_ana: {self.d_ana}"
[docs] class AngleMap: """Class for mapping Q-E points to corresponding angles of instrument axes.""" def __init__(self, sample: Sample, orientation: Orientation, instrument_config: InstrumentConfiguration, instrument: Instrument) -> None: self._sample = sample self._orientation = orientation self._instrument_config = instrument_config self._instrument = instrument self._lattice = self._sample.lattice self._angles = self._sample.angles self._orient1 = self._orientation.orient1 self._orient2 = self._orientation.orient2 self._psi0 = self._orientation.psi0 self._scan_mode = self._instrument_config.scan_mode self._scan_constant = self._instrument_config.scan_constant self._senses = self._instrument_config.senses self._coupled = self._instrument_config.coupled self._psi360 = self._instrument_config.psi360 self._d_mono = self._instrument.d_mono self._d_ana = self._instrument.d_ana # calculate reciprocal lattice self._lattice_rec, self._angles_rec = self._lattice_rec_f() # matrix for rotation about z lab system with psi self._matrix = self._matrix_crystal2lab() # matrix for rotation about sgx, sgy for psi = 0; not changed so far self._matrix_cardan = np.identity(3) def __repr__(self) -> str: return f'direct lattice: {self._lattice}\n' \ f'direct angles: {self._angles}\n' \ f'plane vectors: {self._orient1} {self._orient2}\n' \ f'psi0: {self._psi0 * R2D}\n' \ f'recip. lattice: {self._lattice_rec}\n' \ f'recip. angles: {[x * R2D for x in self._angles_rec]}\n' \ f'zone axis: {self._cal_zone()}\n' \ f'cardan matrix: {self._matrix_cardan}\n' \ f'hkl2qcart matrix: {self._matrix}' def __call__(self, qe): qhkl = qe[:3] e = qe[3] / THZ2MEV # convert from meV to THz # compute phi, psi, alpha qlab = self._hkl2qlab(qhkl[0], qhkl[1], qhkl[2]) Y = self._cal_Y(self._orient1, self._orient2, qhkl) if self._scan_mode in ['CKI', 'DIFF']: ki = self._scan_constant kf = self._cal_kf(e, ki) phi = self._cal_phi(qlab, ki, kf, self._senses[1]) alpha = self._cal_alpha1(qlab, e, ki, self._senses[1]) psi = self._cal_psi(Y, alpha) elif self._scan_mode == 'CKF': kf = self._scan_constant ki = self._cal_ki1(e, kf) phi = self._cal_phi(qlab, ki, kf, self._senses[1]) alpha = self._cal_alpha1(qlab, e, ki, self._senses[1]) psi = self._cal_psi(Y, alpha) elif self._scan_mode == 'CPSI': psi = self._scan_constant alpha = self._cal_alpha2(Y, psi) ki = self._cal_ki3(qlab, e, alpha) sphi = 1 if alpha > 0 else -1 kf = self._cal_kf(e, ki) phi = self._cal_phi(qlab, ki, kf, sphi) elif self._scan_mode == 'CPHI': phi = self._scan_constant sphi = np.sign(phi) ki = self._cal_ki2(qlab, e, phi) kf = self._cal_kf(e, ki) alpha = self._cal_alpha1(qlab, e, ki, sphi) psi = self._cal_psi(Y, alpha) psi -= self._psi0 if self._coupled: psi -= phi while psi < -180: psi += 360 while psi > 180: psi -= 360 if self._psi360 and psi < 0: psi += 360 # transform ki and kf to angles ki_angle = self._senses[0] * 2 * self._cal_k_angle(self._d_mono, order=1, k=ki) kf_angle = self._senses[2] * 2 * self._cal_k_angle(self._d_ana, order=1, k=kf) return [ki_angle, phi, psi, kf_angle] def is_valid(self, qe, *, with_angles=False): try: angles = self(qe) return True if with_angles is False else (True, angles) except: return False if with_angles is False else (False, None) def _lattice_rec_f(self): astar = np.zeros(3) alphastar = np.zeros(3) V = self._cal_volume_real() co = np.cos(self._angles * D2R) si = np.sin(self._angles * D2R) astar[0] = self._lattice[1] * self._lattice[2] * si[0] / V astar[1] = self._lattice[2] * self._lattice[0] * si[1] / V astar[2] = self._lattice[0] * self._lattice[1] * si[2] / V alphastar[0] = np.arccos((co[1] * co[2] - co[0]) / (si[1] * si[2])) alphastar[1] = np.arccos((co[0] * co[2] - co[1]) / (si[0] * si[2])) alphastar[2] = np.arccos((co[0] * co[1] - co[2]) / (si[0] * si[1])) return [astar, alphastar] def _cal_volume_real(self): co = np.cos(self._angles * D2R) mul = self._lattice[0] * self._lattice[1] * self._lattice[2] V = mul * np.sqrt(1 - np.dot(co, co) + 2 * co[0] * co[1] * co[2]) return V def _matrix_crystal2lab(self): B = np.zeros((3, 3)) U = np.zeros((3, 3)) B[0, 0] = self._lattice_rec[0] * 2 * np.pi B[0, 1] = self._lattice_rec[1] * np.cos(self._angles_rec[2]) * 2 * np.pi B[0, 2] = self._lattice_rec[2] * np.cos(self._angles_rec[1]) * 2 * np.pi B[1, 0] = 0 B[1, 1] = self._lattice_rec[1] * np.sin(self._angles_rec[2]) * 2 * np.pi B[1, 2] = -self._lattice_rec[2] * np.sin(self._angles_rec[1]) * \ np.cos(self._angles[0] * D2R) * 2 * np.pi B[2, 0] = 0 B[2, 1] = 0 B[2, 2] = 2 * np.pi / self._lattice[2] for i in range(3): for j in range(3): if -0.000001 < B[i, j] < 0.000001: B[i, j] = 0.0 vec1 = np.dot(B, self._orient1) vec2 = np.dot(B, self._orient2) vec3 = np.cross(vec1, vec2) vec2 = np.cross(vec3, vec1) # xXX use self.coordinatesystem instead of hardcoded 1? U = self._cal_Umatrix(vec1, vec2, vec3, 1) return np.dot(U, B) def _cal_Umatrix(self, vec1, vec2, vec3, direction): U = np.zeros((3, 3)) U[2] = vec3 / np.linalg.norm(vec3) if direction == 1: U[0] = vec1 / np.linalg.norm(vec1) U[1] = vec2 / np.linalg.norm(vec2) elif direction == 2: U[0] = vec2 / np.linalg.norm(vec2) U[1] = vec1 / np.linalg.norm(vec1) elif direction == -1: U[0] = -vec1 / np.linalg.norm(vec1) U[1] = vec2 / np.linalg.norm(vec2) elif direction == -2: U[0] = -vec2 / np.linalg.norm(vec2) U[1] = vec1 / np.linalg.norm(vec1) return U def _hkl2qcart(self, h, k, l): """Return the cartesian coordinates of given (h,k,l) Miller indices.""" return np.dot(self._matrix, np.array([h, k, l], float)) def _hkl2qlab(self, h, k, l): """Transform a vector given in real lattice with (h,k,l) Miller indices in Qlab with coordinates in system: x in beam direction, z direction upwards, y making a right handed system. """ hklcart = self._hkl2qcart(h, k, l) result = np.dot(hklcart, self._matrix_cardan) if abs(result[2]) > 0.001: raise Exception('out of plane vector; check your scattering plane') return result def _cal_Y(self, r1, r2, hkl): """Calculate angle between 1st orientation reflection and Q vector.""" # this function doesn't use r1 or r2... crit = 0.000001 hkl = self._hkl2qlab(hkl[0], hkl[1], hkl[2]) qs = hkl[0]**2 + hkl[1]**2 qabs = np.sqrt(qs) Y = hkl[1] / qabs a1 = np.arcsin(Y) if -crit < hkl[0] < crit: Y = np.pi / 2 if hkl[1] < crit: Y = -Y elif hkl[0] < -crit: Y = -a1 if hkl[1] > crit: Y += np.pi else: Y -= np.pi elif hkl[0] > crit: Y = a1 Y *= R2D return Y def _cal_kf(self, e, ki): """Calculate the outgoing wavevector for given energy transfer and incoming wavevector. """ kf = ki**2 - K * e if kf > 0.000001: kf = np.sqrt(kf) return kf else: raise Exception('energy transfer of %s THz not possible ' 'with k_i = %s' % (e, ki)) def _cal_phi(self, q, ki, kf, sense): """Return the sample scattering angle.""" qabs = np.linalg.norm(q) temp = (ki**2 + kf**2 - qabs**2) / (2.0 * ki * kf) if -1 <= temp <= 1: phi = np.arctan2(np.sqrt(1 - temp**2), temp) * sense * R2D return phi else: raise Exception('scattering triangle not closed when ' 'calculating phi angle') def _cal_alpha1(self, qlab, e, ki, sense): """Calculate the angle alpha (ki, Q) for given Qlab vector, energy transfer, incoming wavevector and scattering sense (sample). """ qabs = np.linalg.norm(qlab) temp = (qabs**2 + K * e) / (2 * qabs * ki) if -1 <= temp < 1: alpha = np.arctan2(np.sqrt(1 - temp**2), temp) * sense * R2D return alpha else: raise Exception('energy transfer of %s THz not possible;' ' scattering triangle not closed' % e) def _cal_alpha2(self, y, psi): """Calculate the angle alpha (ki, Q) for given angle Y (ki, r1) and rotation angle (sample). """ alpha = y - psi if alpha < -180: alpha += 360 elif alpha > 180: alpha -= 360 return alpha def _cal_psi(self, y, alpha): """Calculate the rotation angle sample for given angle Y (ki, r1) and angle alpha (ki, Q). """ psi = y - alpha if psi < -180: psi += 360 if psi > 180: psi -= 360 return psi def _cal_ki1(self, e, kf): """Calculate the incoming wavevector for given energy transfer and outgoing wavevector. """ ki = kf**2 + K * e if ki > 0.000001: ki = np.sqrt(ki) return ki else: raise Exception('energy transfer of %s THz not possible ' 'with k_f = %s' % (e, kf)) def _cal_ki2(self, qlab, e, phi): """Calculate the incoming wavevector for given Qlab vector, energy transfer and sample scattering angle. """ ki = 0 phi *= D2R qabs = np.linalg.norm(qlab) a1 = (qabs / np.sin(phi))**2 a2 = K * e a3 = a2 / np.sin(phi) a3 = a1**2 - a3**2 if a3 > 0: ki = a1 + a2 + np.cos(phi) * np.sqrt(a3) ki /= 2 if ki > 0.000001: ki = np.sqrt(ki) return ki else: raise Exception('energy transfer of %s THz not possible ' 'with phi = %s' % (e, phi)) def _cal_ki3(self, qlab, e, alpha): """Calculate the incoming wavevector for given Qlab vector, energy transfer and angle alpha(ki, Q). """ alpha *= D2R qabs = np.linalg.norm(qlab) ki = (qabs**2 + K * e) / (2.0 * qabs * np.cos(alpha)) if ki > 0.000001: return ki else: raise Exception('energy transfer of %s THz not possible;' ' scattering triangle not closed' % e) def _cal_zone(self): return np.cross(self._orient1, self._orient2) def _cal_k_angle(self, dvalue, order, k): return math.degrees(math.asin(math.pi * order / (k * dvalue)))
[docs] class AngleMapInducedMetric: """Class representing a metric on 2D plane induced by an angle map.""" def __init__(self, angle_map: AngleMap, axes, offset, instrument: Instrument) -> None: self.angle_map = angle_map self.axes = axes self.offset = offset self.instrument = instrument def __call__(self, x1, x2) -> float: qe_1 = x2qe(x1, self.axes, self.offset) qe_2 = x2qe(x2, self.axes, self.offset) angles_1 = np.asarray(self.angle_map(qe_1)) valid_2, angles_2 = self.angle_map.is_valid(qe_2, with_angles=True) return max(abs((angles_1 - angles_2) / self.instrument.angle_velocities)) \ if valid_2 else np.Inf
[docs] class Scenario: """Class representing an experimental scenario.""" def __init__(self, name: str, sample: Sample, orientation: Orientation, axes, offset, limits, instrument_config: InstrumentConfiguration, instrument: Instrument, intensity_threshold: float, counting_time: float, intensity_function) -> None: self.name = name self.sample = sample self.orientation = orientation self.axes = np.asarray(axes) self.offset = np.asarray(offset) self.limits = np.asarray(limits) self.instrument_config = instrument_config self.instrument = instrument self.intensity_threshold = intensity_threshold self.counting_time = counting_time self.intensity_function = intensity_function self.angle_map = AngleMap(sample=self.sample, orientation=self.orientation, instrument_config=self.instrument_config, instrument=self.instrument) self.angle_map_metric = AngleMapInducedMetric(angle_map=self.angle_map, axes=self.axes, offset=self.offset, instrument=self.instrument) @staticmethod def load(filename: str): with open(filename, 'r') as file: json_test_case = json.load(file) name = json_test_case['name'] json_sample = json_test_case['sample'] sample = Sample(name=json_sample['name'], lattice=json_sample['lattice'], angles=json_sample['angles']) json_orientation = json_test_case['orientation'] orientation = Orientation(orient1=json_orientation['orient1'], orient2=json_orientation['orient2'], psi0=json_orientation['psi0']) axes = json_test_case['axes'] offset = json_test_case['offset'] limits = json_test_case['limits'] json_instrument_config = json_test_case['instrument_configuration'] instrument_config = InstrumentConfiguration(scan_mode=json_instrument_config['scan_mode'], scan_constant=json_instrument_config['scan_constant'], senses=json_instrument_config['senses']) json_instrument = json_test_case['instrument'] instrument = Instrument(angle_velocities=json_instrument['angle_velocities'], d_mono=json_instrument['d_mono'], d_ana=json_instrument['d_ana']) intensity_threshold = json_test_case['intensity_threshold'] counting_time = json_test_case['counting_time'] json_intensity_function = json_test_case['intensity_function'] locations = json_intensity_function['locations'] intensities = json_intensity_function['intensities'] intensity_function = normalized_linear_interpolator(locations, intensities) scenario = Scenario(name=name, sample=sample, orientation=orientation, axes=axes, offset=offset, limits=limits, instrument_config=instrument_config, instrument=instrument, intensity_threshold=intensity_threshold, counting_time=counting_time, intensity_function=intensity_function) return scenario
def prepare_locs_plot(locs, *, axes, offset=None): """Merge the coordinates in `locs` with `axes` and `offset` according to TAS conventions.""" if offset is None: offset = 4 * [0] assert np.shape(locs)[1] == len(axes) assert np.shape(axes)[1] == len(offset) == 4 locs_plot = np.array(locs) # copy `locs` axes = np.asarray(axes) offset = np.asarray(offset) if _is_inelastic_scan(axes): if _is_classical_inelastic_scan(axes): axis_q = axes[0, :3] offset_q = offset[:3] # Q axis if _q_axis_compatible_with_offset(axis_q, offset_q): locs_plot[:, 0] += _compatibility_factor_q_axis_offset(axis_q, offset_q) # energy axis locs_plot[:, 1] += offset[3] else: axis_q1 = axes[0, :3] axis_q2 = axes[1, :3] offset_q = offset[:3] if _q_axis_compatible_with_offset(axis_q1, offset_q): locs_plot[:, 0] += _compatibility_factor_q_axis_offset(axis_q1, offset_q) if _q_axis_compatible_with_offset(axis_q2, offset_q): locs_plot[:, 1] += _compatibility_factor_q_axis_offset(axis_q2, offset_q) return locs_plot def _q_axis_compatible_with_offset(axis, offset): """Check whether Q `axis` is compatible with `offset`. Compatibility is equal to whether the vector of non-zero entries in `axis` and the vector of correponding entries in `offset` are linearly dependent.""" return np.linalg.matrix_rank([axis[axis != 0], offset[axis != 0]]) == 1 def _compatibility_factor_q_axis_offset(axis, offset): """Compute coordinate of the point `offset` along the `axis`.""" return offset[axis != 0][0] / axis[axis != 0][0] def _is_inelastic_scan(axes): """Check whether `axes` represents a scan with zero energy component.""" return np.any(axes[:, 3] == 1) def _is_classical_inelastic_scan(axes): """Check whether `axes` represents a scan with one plain energy axis.""" return len([axis for axis in axes if _is_plain_energy_axis(axis)]) == 1 def _is_plain_energy_axis(axis): """Check whether only the energy component of `axis` is non-zero.""" return np.all(axis[:3] == 0) and axis[3] != 0 def axes_plot_labels(axes, *, offset=None, units=None): """Compute plotting labels for `axes`.""" if offset is None: offset = len(axes[0]) * [0] axes = np.asarray(axes) offset = np.array(offset) # copy `offset` assert np.shape(axes)[1] == len(offset) == 4 if _is_inelastic_scan(axes): if _is_classical_inelastic_scan(axes): axis_q = axes[0, :3] offset_q = offset[:3] if units is None: units = ["r.l.u.", "meV"] if _q_axis_compatible_with_offset(axis_q, offset_q): offset_q[axis_q != 0] = 0 x1_label = _format_plot_label(_merge_axis_offset(axis_q, offset_q), unit=units[0]) x2_label = f"Energy transfer [{units[1]}]" else: x1_label = _format_plot_label(_merge_axis_offset(axes[0], offset)) x2_label = _format_plot_label(_merge_axis_offset(axes[1], offset)) else: axis_q1 = axes[0, :3] axis_q2 = axes[1, :3] offset_q1 = np.array(offset[:3]) offset_q2 = np.array(offset[:3]) if units is None: units = "r.l.u." if _q_axis_compatible_with_offset(axis_q1, offset_q1): offset_q1[axis_q1 != 0] = 0 if _q_axis_compatible_with_offset(axis_q2, offset_q2): offset_q2[axis_q2 != 0] = 0 # ignore offset components belonging to the other axis offset_q1[axis_q2 != 0] = 0 offset_q2[axis_q1 != 0] = 0 x1_label = _format_plot_label(_merge_axis_offset(axis_q1, offset_q1), unit=units) x2_label = _format_plot_label(_merge_axis_offset(axis_q2, offset_q2), unit=units) return x1_label, x2_label _hkle_names = ['h', 'k', 'l', 'e'] def _merge_axis_offset(axis, offset): """Merge `axis` and `offset` for plotting label.""" def _merge_axis_offset_i(axis_i, offset_i, hkl_name): # make integers if possible if axis_i == int(axis_i): axis_i = int(axis_i) if offset_i == int(offset_i): offset_i = int(offset_i) s = "" if axis_i != 0: if offset_i != 0: s += f"{offset_i}" if axis_i > 0: s += '+' if abs(axis_i) != 1: s += f"{axis_i}" elif axis_i == -1: s += '-' s += hkl_name else: s += f"{offset_i}" return s i_hkl_name = np.argmax(axis != 0) return [_merge_axis_offset_i(axis[i], offset[i], _hkle_names[i_hkl_name if i < 3 else 3]) for i in range(len(axis))] def _format_plot_label(comps, unit=None): return f"$({','.join(comps)})$" + (f" [{unit}]" if unit is not None else "")