Skip to content

Commit d74815d

Browse files
committed
ENH: script for determining plausible detector placement
1 parent 238d447 commit d74815d

1 file changed

Lines changed: 287 additions & 0 deletions

File tree

Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
# ---
2+
# jupyter:
3+
# jupytext:
4+
# text_representation:
5+
# extension: .py
6+
# format_name: percent
7+
# format_version: '1.3'
8+
# jupytext_version: 1.16.1
9+
# kernelspec:
10+
# display_name: Python 3
11+
# language: python
12+
# name: python3
13+
# ---
14+
15+
# %% [markdown]
16+
# # Detector Distance Study
17+
#
18+
# This notebook studies the effect of varying the total distance from sample to detector
19+
# on the maximum phi angle and the inherent error in diffraction measurements.
20+
21+
# %% [markdown]
22+
# ## Import Libraries
23+
24+
# %%
25+
import numpy as np
26+
import matplotlib.pyplot as plt
27+
import matplotlib as mpl
28+
from dataclasses import dataclass
29+
30+
from hrd_tools.detector_stats import Detector, detectors
31+
from multihead.corrections import tth_from_z
32+
from multihead.config import AnalyzerConfig
33+
34+
plt.switch_backend('qtagg')
35+
# %% [markdown]
36+
# ## Analysis Function
37+
#
38+
# The main function computes for each arm 2theta angle:
39+
# - Maximum phi visible on the detector
40+
# - Phi where error crosses the threshold (or max phi if always below threshold)
41+
42+
# %%
43+
@dataclass
44+
class DetectorGeometryResult:
45+
"""Results from detector geometry analysis."""
46+
twotheta: np.ndarray
47+
max_phi: np.ndarray
48+
error_threshold_phi: np.ndarray
49+
total_distance: float
50+
detector_name: str
51+
52+
53+
# %%
54+
def analyze_detector_geometry(
55+
total_distance: float,
56+
detector: Detector,
57+
*,
58+
crystal_to_detector: float = 120.0,
59+
twotheta_range: tuple[float, float] = (0, 90),
60+
n_steps: int = 512,
61+
error_threshold: float = 1e-4,
62+
) -> DetectorGeometryResult:
63+
"""
64+
Analyze detector geometry for varying 2theta angles.
65+
66+
Parameters
67+
----------
68+
total_distance : float
69+
Total distance from sample to detector (mm)
70+
detector : Detector
71+
Detector object with specifications
72+
crystal_to_detector : float
73+
Distance from crystal to detector (mm), default 120mm
74+
twotheta_range : tuple[float, float]
75+
Range of arm 2theta angles (degrees), default (0, 120)
76+
n_steps : int
77+
Number of steps for analysis, default 512
78+
error_threshold : float
79+
Error threshold for phi cutoff, default 1e-4
80+
81+
Returns
82+
-------
83+
DetectorGeometryResult
84+
Dataclass containing arrays of 2theta, max_phi, and error_threshold_phi
85+
"""
86+
# Create configuration object for tth_from_z
87+
# R is sample to analyzer (crystal), Rd is analyzer to detector
88+
config = AnalyzerConfig(
89+
total_distance - crystal_to_detector, # R: sample to analyzer
90+
crystal_to_detector, # Rd: analyzer to detector
91+
np.rad2deg(np.arcsin(0.8 / (2 * 3.1355))), # theta_B (Bragg angle)
92+
2 * np.rad2deg(np.arcsin(0.8 / (2 * 3.1355))), # tth_B (2*Bragg angle)
93+
detector_roll=0,
94+
)
95+
96+
# Generate 2theta angles
97+
twotheta_vals = np.linspace(twotheta_range[0], twotheta_range[1], n_steps)
98+
99+
# Convert pixel pitch from µm to mm
100+
pixel_size_mm = detector.pixel_pitch / 1000.0
101+
102+
# Create array of pixel positions from center to edge
103+
n_pixels_half = detector.sensor_shape[0] // 2
104+
pixel_positions = np.arange(0, n_pixels_half + 1) * pixel_size_mm
105+
106+
# Vectorized calculation: tth_from_z can handle both z and arm_tth arrays
107+
# Shape: (n_tth, n_positions)
108+
(tth_vals), (phi_vals) = tth_from_z(
109+
pixel_positions.reshape(1, -1),
110+
twotheta_vals.reshape(-1, 1),
111+
config,
112+
)
113+
114+
# Maximum phi is at the detector edge (last pixel) for each 2theta
115+
max_phi = np.abs(phi_vals[:, -1])
116+
117+
# Calculate uncertainty as the 2theta difference across each pixel
118+
# Shape: (n_tth, n_positions-1)
119+
pixel_uncertainties = np.abs(np.diff(tth_vals, axis=1))
120+
121+
# Find where pixel uncertainty crosses threshold for each 2theta
122+
# Use argmax to find first True value (crossing threshold)
123+
# argmax returns 0 if all False, so check if any values exceed threshold
124+
crosses_threshold = pixel_uncertainties > error_threshold
125+
first_crossing = np.argmax(crosses_threshold, axis=1)
126+
127+
# If no crossing (all False), argmax returns 0, so use max_phi
128+
# Otherwise use phi at first crossing position
129+
error_phi = np.where(
130+
np.any(crosses_threshold, axis=1),
131+
np.abs(phi_vals[np.arange(n_steps), first_crossing]),
132+
max_phi
133+
)
134+
135+
return DetectorGeometryResult(
136+
twotheta=twotheta_vals,
137+
max_phi=max_phi,
138+
error_threshold_phi=error_phi,
139+
total_distance=total_distance,
140+
detector_name=detector.name,
141+
)
142+
143+
# %% [markdown]
144+
# ## Parameter Sweep
145+
#
146+
# Sweep through different total distances and analyze the effect on phi coverage.
147+
148+
# %%
149+
# Select detector
150+
detector = detectors["medipix4"]
151+
152+
# Distance sweep parameters
153+
distances = np.linspace(1000, 1500, 9) # mm
154+
results_list = []
155+
156+
for dist in distances:
157+
result = analyze_detector_geometry(
158+
dist,
159+
detector,
160+
crystal_to_detector=120.0,
161+
twotheta_range=(1, 90),
162+
n_steps=512,
163+
error_threshold=1e-4,
164+
)
165+
results_list.append(result)
166+
167+
# %% [markdown]
168+
# ## Visualization
169+
#
170+
# Plot the maximum phi and error-limited phi as a function of 2theta for different distances.
171+
172+
# %%
173+
fig, axes = plt.subplots(1, 2, figsize=(14, 5), layout="constrained")
174+
175+
# Plot 1: Maximum Phi vs 2theta for different distances
176+
ax1 = axes[0]
177+
for result in results_list:
178+
ax1.plot(result.twotheta, result.max_phi,
179+
label=f"{result.total_distance:.0f} mm")
180+
181+
ax1.set_xlabel('Arm 2θ (degrees)', fontsize=12)
182+
ax1.set_ylabel(r'Maximum $\phi$ (degrees)', fontsize=12)
183+
ax1.set_title(r'Maximum $\phi$ Coverage' + f'\n{detector.name}', fontsize=13)
184+
ax1.legend(title='Total Distance', fontsize=9)
185+
ax1.grid(True, alpha=0.3)
186+
187+
# Plot 2: Error-limited Phi vs 2theta
188+
ax2 = axes[1]
189+
for result in results_list:
190+
ax2.plot(result.twotheta, result.error_threshold_phi,
191+
label=f"{result.total_distance:.0f} mm")
192+
193+
ax2.set_xlabel('Arm 2θ (degrees)', fontsize=12)
194+
ax2.set_ylabel(r'Error-Limited $\phi$ (degrees)', fontsize=12)
195+
ax2.set_title(r'$\phi$ at Error Threshold (1e-4)' + f'\n{detector.name}', fontsize=13)
196+
ax2.legend(title='Total Distance', fontsize=9)
197+
ax2.grid(True, alpha=0.3)
198+
199+
plt.show()
200+
201+
# %% [markdown]
202+
# ## Distance Effect Summary
203+
#
204+
# Plot how the maximum phi varies with total distance at specific 2theta values.
205+
206+
# %%
207+
fig, ax = plt.subplots(figsize=(10, 6), layout="constrained")
208+
209+
# Extract specific 2theta values
210+
twotheta_targets = [30, 60, 90, 120]
211+
colors = mpl.colormaps['viridis'](np.linspace(0, 1, len(twotheta_targets)))
212+
213+
for twotheta_target, color in zip(twotheta_targets, colors):
214+
max_phi_at_target = []
215+
error_phi_at_target = []
216+
217+
for result in results_list:
218+
# Find closest 2theta value
219+
idx = np.argmin(np.abs(result.twotheta - twotheta_target))
220+
max_phi_at_target.append(result.max_phi[idx])
221+
error_phi_at_target.append(result.error_threshold_phi[idx])
222+
223+
ax.plot(distances, max_phi_at_target, 'o-', color=color,
224+
label=f'2θ = {twotheta_target}° (max)', linewidth=2)
225+
ax.plot(distances, error_phi_at_target, 's--', color=color,
226+
label=f'2θ = {twotheta_target}° (error limit)', linewidth=1.5, alpha=0.7)
227+
228+
ax.set_xlabel('Total Distance (mm)', fontsize=12)
229+
ax.set_ylabel(r'$\phi$ Coverage (degrees)', fontsize=12)
230+
ax.set_title(r'Effect of Total Distance on $\phi$ Coverage' + f'\n{detector.name}', fontsize=13)
231+
ax.legend(fontsize=9, ncol=2)
232+
ax.grid(True, alpha=0.3)
233+
234+
plt.show()
235+
236+
# %% [markdown]
237+
# ## Compare Detectors
238+
#
239+
# Compare different detector types at a fixed distance.
240+
241+
# %%
242+
fixed_distance = 1000.0 # mm
243+
detector_comparison = {}
244+
245+
for det_name, det in detectors.items():
246+
result = analyze_detector_geometry(
247+
fixed_distance,
248+
det,
249+
crystal_to_detector=120.0,
250+
twotheta_range=(1, 90),
251+
n_steps=512,
252+
error_threshold=1e-4,
253+
)
254+
detector_comparison[det_name] = result
255+
256+
# %%
257+
fig, axes = plt.subplots(1, 2, figsize=(14, 5), layout="constrained")
258+
259+
# Plot detector comparison
260+
ax1 = axes[0]
261+
for det_name, result in detector_comparison.items():
262+
ax1.plot(result.twotheta, result.max_phi,
263+
label=result.detector_name, linewidth=2)
264+
265+
ax1.set_xlabel('Arm 2θ (degrees)', fontsize=12)
266+
ax1.set_ylabel(r'Maximum $\phi$ (degrees)', fontsize=12)
267+
ax1.set_title(r'Detector Comparison: Maximum $\phi$' + f'\nTotal Distance = {fixed_distance} mm',
268+
fontsize=13)
269+
ax1.legend(fontsize=9)
270+
ax1.grid(True, alpha=0.3)
271+
272+
# Plot error-limited comparison
273+
ax2 = axes[1]
274+
for det_name, result in detector_comparison.items():
275+
ax2.plot(result.twotheta, result.error_threshold_phi,
276+
label=result.detector_name, linewidth=2)
277+
278+
ax2.set_xlabel('Arm 2θ (degrees)', fontsize=12)
279+
ax2.set_ylabel(r'Error-Limited $\phi$ (degrees)', fontsize=12)
280+
ax2.set_title(r'Detector Comparison: Error-Limited $\phi$' + f'\nTotal Distance = {fixed_distance} mm',
281+
fontsize=13)
282+
ax2.legend(fontsize=9)
283+
ax2.grid(True, alpha=0.3)
284+
285+
plt.show()
286+
287+
# %%

0 commit comments

Comments
 (0)