Skip to content

Commit 498f2d5

Browse files
committed
Add single-crystal scatter comparison plotting
1 parent c7f2b31 commit 498f2d5

8 files changed

Lines changed: 218 additions & 46 deletions

File tree

src/easydiffraction/display/plotters/ascii.py

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"""
99

1010
import asciichartpy
11+
import numpy as np
1112

1213
from easydiffraction.display.plotters.base import DEFAULT_HEIGHT
1314
from easydiffraction.display.plotters.base import SERIES_CONFIG
@@ -43,7 +44,7 @@ def _get_legend_item(self, label):
4344
item = f'{color_start}{line}{color_end} {name}'
4445
return item
4546

46-
def plot(
47+
def plot_pattern(
4748
self,
4849
x,
4950
y_series,
@@ -52,7 +53,10 @@ def plot(
5253
title,
5354
height=None,
5455
):
55-
"""Render a compact ASCII chart in the terminal.
56+
"""Render a compact ASCII line chart in the terminal.
57+
58+
Suitable for powder diffraction data where intensity is plotted
59+
against an x-axis variable (2θ, TOF, d-spacing).
5660
5761
Args:
5862
x: 1D array-like of x values (only used for range
@@ -84,3 +88,74 @@ def plot(
8488
padded = '\n'.join(' ' + line for line in chart.splitlines())
8589

8690
print(padded)
91+
92+
def plot_scatter_comparison(
93+
self,
94+
x_calc,
95+
y_meas,
96+
y_meas_su,
97+
axes_labels,
98+
title,
99+
height=None,
100+
):
101+
"""Render a scatter comparison plot in the terminal.
102+
103+
Creates an ASCII scatter plot showing measured vs calculated
104+
values with a diagonal reference line.
105+
106+
Args:
107+
x_calc: 1D array-like of calculated values (x-axis).
108+
y_meas: 1D array-like of measured values (y-axis).
109+
y_meas_su: 1D array-like of measurement uncertainties
110+
(ignored in ASCII mode).
111+
axes_labels: Pair of strings for the x and y titles.
112+
title: Figure title.
113+
height: Number of text rows for the chart (default: 15).
114+
"""
115+
# Intentionally unused; ASCII scatter doesn't show error bars
116+
del y_meas_su
117+
118+
if height is None:
119+
height = DEFAULT_HEIGHT
120+
width = 60 # TODO: Make width configurable
121+
122+
# Determine axis limits
123+
vmin = float(min(np.min(y_meas), np.min(x_calc)))
124+
vmax = float(max(np.max(y_meas), np.max(x_calc)))
125+
pad = 0.05 * (vmax - vmin) if vmax > vmin else 1.0
126+
vmin -= pad
127+
vmax += pad
128+
129+
# Create empty grid
130+
grid = [[' ' for _ in range(width)] for _ in range(height)]
131+
132+
# Draw diagonal line (calc == meas)
133+
for i in range(min(width, height)):
134+
row = height - 1 - int(i * height / width)
135+
col = i
136+
if 0 <= row < height and 0 <= col < width:
137+
grid[row][col] = '·'
138+
139+
# Plot data points
140+
for xv, yv in zip(x_calc, y_meas, strict=False):
141+
col = int((xv - vmin) / (vmax - vmin) * (width - 1))
142+
row = height - 1 - int((yv - vmin) / (vmax - vmin) * (height - 1))
143+
if 0 <= row < height and 0 <= col < width:
144+
grid[row][col] = '●'
145+
146+
# Build chart string with axes
147+
chart_lines = []
148+
for row in grid:
149+
label = '│'
150+
chart_lines.append(label + ''.join(row))
151+
152+
# X-axis
153+
x_axis = '└' + '─' * width
154+
155+
# Print output
156+
console.paragraph(f'{title}')
157+
console.print(f'{axes_labels[1]}')
158+
for line in chart_lines:
159+
print(f' {line}')
160+
print(f' {x_axis}')
161+
console.print(f'{" " * (width - 3)}{axes_labels[0]}')

src/easydiffraction/display/plotters/base.py

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from easydiffraction.experiments.experiment.enums import BeamModeEnum
1111
from easydiffraction.experiments.experiment.enums import ScatteringTypeEnum
1212

13-
DEFAULT_HEIGHT = 9
13+
DEFAULT_HEIGHT = 25
1414
DEFAULT_MIN = -np.inf
1515
DEFAULT_MAX = np.inf
1616

@@ -58,10 +58,16 @@ class PlotterBase(ABC):
5858
5959
Implementations accept x values, multiple y-series, optional labels
6060
and render a plot to the chosen medium.
61+
62+
Two main plot types are supported:
63+
- ``plot_pattern``: Line plots for powder diffraction patterns
64+
(intensity vs. 2θ/TOF/d-spacing).
65+
- ``plot_scatter_comparison``: Scatter plots comparing measured vs.
66+
calculated values (e.g., F²meas vs F²calc for single crystal).
6167
"""
6268

6369
@abstractmethod
64-
def plot(
70+
def plot_pattern(
6571
self,
6672
x,
6773
y_series,
@@ -70,7 +76,10 @@ def plot(
7076
title,
7177
height,
7278
):
73-
"""Render a plot.
79+
"""Render a pattern line plot.
80+
81+
Suitable for powder diffraction data where intensity is plotted
82+
against an x-axis variable (2θ, TOF, d-spacing).
7483
7584
Args:
7685
x: 1D array of x-axis values.
@@ -81,3 +90,52 @@ def plot(
8190
height: Backend-specific height (text rows or pixels).
8291
"""
8392
pass
93+
94+
@abstractmethod
95+
def plot_scatter_comparison(
96+
self,
97+
x_calc,
98+
y_meas,
99+
y_meas_su,
100+
axes_labels,
101+
title,
102+
height,
103+
):
104+
"""Render a scatter comparison plot.
105+
106+
Suitable for single crystal data where measured values are
107+
plotted against calculated values with error bars.
108+
109+
Args:
110+
x_calc: 1D array of calculated values (x-axis).
111+
y_meas: 1D array of measured values (y-axis).
112+
y_meas_su: 1D array of measurement uncertainties.
113+
axes_labels: Pair of strings for the x and y titles.
114+
title: Figure title.
115+
height: Backend-specific height (text rows or pixels).
116+
"""
117+
pass
118+
119+
def plot(
120+
self,
121+
x,
122+
y_series,
123+
labels,
124+
axes_labels,
125+
title,
126+
height,
127+
):
128+
"""Render a pattern plot (backward-compatible alias).
129+
130+
.. deprecated::
131+
Use :meth:`plot_pattern` instead.
132+
133+
Args:
134+
x: 1D array of x-axis values.
135+
y_series: Sequence of y arrays to plot.
136+
labels: Identifiers corresponding to y_series.
137+
axes_labels: Pair of strings for the x and y titles.
138+
title: Figure title.
139+
height: Backend-specific height (text rows or pixels).
140+
"""
141+
return self.plot_pattern(x, y_series, labels, axes_labels, title, height)

src/easydiffraction/display/plotters/plotly.py

Lines changed: 30 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ def _get_trace(self, x, y, label):
6363

6464
return trace
6565

66-
def plot(
66+
def plot_pattern(
6767
self,
6868
x,
6969
y_series,
@@ -72,7 +72,10 @@ def plot(
7272
title,
7373
height=None,
7474
):
75-
"""Render an interactive Plotly figure.
75+
"""Render an interactive Plotly line plot for pattern data.
76+
77+
Suitable for powder diffraction data where intensity is plotted
78+
against an x-axis variable (2θ, TOF, d-spacing).
7679
7780
Args:
7881
x: 1D array-like of x-axis values.
@@ -155,33 +158,35 @@ def plot(
155158
)
156159
display(HTML(html_fig))
157160

158-
# TODO: Temporary method for SG plotting
159-
# refactor and move to a more appropriate location
160-
def plot_sc(
161+
def plot_scatter_comparison(
161162
self,
162-
experiment,
163+
x_calc,
164+
y_meas,
165+
y_meas_su,
166+
axes_labels,
167+
title,
168+
height=None,
163169
):
164-
"""Plot measured vs calculated structure factor squared data for
165-
a single crystal experiment.
170+
"""Render a scatter comparison plot.
171+
172+
Suitable for single crystal data where measured values are
173+
plotted against calculated values with error bars and a
174+
diagonal reference line.
166175
167176
Args:
168-
experiment: Experiment instance with data to plot.
177+
x_calc: 1D array-like of calculated values (x-axis).
178+
y_meas: 1D array-like of measured values (y-axis).
179+
y_meas_su: 1D array-like of measurement uncertainties.
180+
axes_labels: Pair of strings for the x and y titles.
181+
title: Figure title.
182+
height: Ignored; Plotly auto-sizes based on renderer.
169183
"""
170-
# Calculate/update data
171-
# experiment.data._update() # done before calling this method
172-
173-
# Extract data
174-
meas = experiment.data.meas
175-
meas_su = experiment.data.meas_su
176-
calc = experiment.data.calc
177-
178-
# Setup figure title and axes labels
179-
title = f"Measured vs Calculated data for experiment 🔬 '{experiment.name}'"
180-
axes_labels = ['F²calc', 'F²meas']
184+
# Intentionally unused; accepted for API compatibility
185+
del height
181186

182187
# Determine axis limits
183-
vmin = float(min(meas.min(), calc.min()))
184-
vmax = float(max(meas.max(), calc.max()))
188+
vmin = float(min(y_meas.min(), x_calc.min()))
189+
vmax = float(max(y_meas.max(), x_calc.max()))
185190

186191
# Update limits with some padding
187192
pad = 0.05 * (vmax - vmin) if vmax > vmin else 1.0
@@ -191,8 +196,8 @@ def plot_sc(
191196
# Create data trace
192197
data = [
193198
go.Scatter(
194-
x=calc,
195-
y=meas,
199+
x=x_calc,
200+
y=y_meas,
196201
mode='markers',
197202
marker=dict(
198203
symbol='circle',
@@ -205,7 +210,7 @@ def plot_sc(
205210
),
206211
error_y=dict(
207212
type='data',
208-
array=meas_su,
213+
array=y_meas_su,
209214
visible=True,
210215
),
211216
hovertemplate=('calc: %{x}<br>meas: %{y}<br><extra></extra>'),

src/easydiffraction/display/plotting.py

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,7 @@ def plot_meas(
205205
]
206206

207207
# TODO: Before, it was self._plotter.plot. Check what is better.
208-
self._backend.plot(
208+
self._backend.plot_pattern(
209209
x=x,
210210
y_series=y_series,
211211
labels=y_labels,
@@ -286,7 +286,7 @@ def plot_calc(
286286
)
287287
]
288288

289-
self._backend.plot(
289+
self._backend.plot_pattern(
290290
x=x,
291291
y_series=y_series,
292292
labels=y_labels,
@@ -388,7 +388,7 @@ def plot_meas_vs_calc(
388388
y_series.append(y_resid)
389389
y_labels.append('resid')
390390

391-
self._backend.plot(
391+
self._backend.plot_pattern(
392392
x=x,
393393
y_series=y_series,
394394
labels=y_labels,
@@ -397,6 +397,47 @@ def plot_meas_vs_calc(
397397
height=self.height,
398398
)
399399

400+
def plot_sc_meas_vs_calc(
401+
self,
402+
pattern,
403+
expt_name,
404+
):
405+
"""Plot measured vs calculated comparison for single crystal
406+
data.
407+
408+
Renders a scatter plot of F²meas vs F²calc with error bars and
409+
a diagonal reference line.
410+
411+
Args:
412+
pattern: Object with ``meas``, ``calc``, and ``meas_su``
413+
arrays for structure factor squared data.
414+
expt_name: Experiment name for the title.
415+
"""
416+
if pattern.meas is None:
417+
log.error(f'No measured data available for experiment {expt_name}')
418+
return
419+
if pattern.calc is None:
420+
log.error(f'No calculated data available for experiment {expt_name}')
421+
return
422+
if pattern.meas_su is None:
423+
log.warning(f'No measurement uncertainties for experiment {expt_name}')
424+
# Use zeros if no uncertainties available
425+
meas_su = np.zeros_like(pattern.meas)
426+
else:
427+
meas_su = pattern.meas_su
428+
429+
title = f"Measured vs Calculated data for experiment 🔬 '{expt_name}'"
430+
axes_labels = ['F²calc', 'F²meas']
431+
432+
self._backend.plot_scatter_comparison(
433+
x_calc=pattern.calc,
434+
y_meas=pattern.meas,
435+
y_meas_su=meas_su,
436+
axes_labels=axes_labels,
437+
title=title,
438+
height=self.height,
439+
)
440+
400441
def _filtered_y_array(
401442
self,
402443
y_array,

src/easydiffraction/project/project.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010

1111
from easydiffraction.analysis.analysis import Analysis
1212
from easydiffraction.core.guard import GuardedBase
13-
from easydiffraction.display.plotters.plotly import PlotlyPlotter
1413
from easydiffraction.display.plotting import Plotter
1514
from easydiffraction.display.tables import TableRenderer
1615
from easydiffraction.experiments.experiment.enums import SampleFormEnum
@@ -280,11 +279,11 @@ def plot_meas_vs_calc(
280279
self._update_categories(expt_name)
281280
experiment = self.experiments[expt_name]
282281

283-
# TODO: Temporary method for SG plotting
284-
# refactor and move to a more appropriate location
285282
if experiment.type.sample_form.value == SampleFormEnum.SINGLE_CRYSTAL:
286-
plotter = PlotlyPlotter()
287-
plotter.plot_sc(experiment)
283+
self.plotter.plot_sc_meas_vs_calc(
284+
experiment.data,
285+
expt_name,
286+
)
288287
elif experiment.type.sample_form.value == SampleFormEnum.POWDER:
289288
self.plotter.plot_meas_vs_calc(
290289
experiment.data,

0 commit comments

Comments
 (0)