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
27 changes: 26 additions & 1 deletion utu/spectrum/_plots.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ def stem(
num_label: None | int = None,
latex: bool = False,
headroom: float = 1.45,
num_floor: int = 200,
axis: str = "line",
kwargs_line: None | dict = None,
kwargs_text: None | dict = None,
Expand Down Expand Up @@ -55,6 +56,10 @@ def stem(
headroom
How much taller than the brightest line to make the axes, so that
the labels have somewhere to be pushed into.
num_floor
How many points to lay along the baseline for the labels to be
pushed off. Fewer than the labels are wide leaves gaps for one to
settle into.
axis
The name of the axis along the lines of ``spectrum``.
kwargs_line
Expand Down Expand Up @@ -127,6 +132,14 @@ def stem(
y_static.append(y)
x_static.append(np.broadcast_to(wavelength[i], y.shape))

# And a floor of them along the baseline. The lines stand on it but do
# not cover it, so without this the strip of axis between two lines is
# empty as far as the solver can tell, and the label of a faint line is
# left lying along the bottom of the plot, which is where it started.
x_floor = np.linspace(wavelength.min(), wavelength.max(), num=num_floor)
x_static.append(x_floor)
y_static.append(np.zeros_like(x_floor))

# brightest first, so that taking the first few takes the brightest few
order = np.argsort(spectrum.outputs, axis=axis)
brightest = spectrum[order][{axis: slice(None, None, -1)}]
Expand Down Expand Up @@ -170,7 +183,19 @@ def stem(
},
"force_static": (0.4, 0.6),
"force_text": (0.4, 0.6),
"expand": (1.15, 1.4),
# A label is an ion and a wavelength, so it is several times
# wider than it is tall, and two of them need far more room
# beside one another than above. Kept level with the default
# otherwise: two lines a hundredth of an angstrom apart, as
# Mg X and O IV are at 609.8, stack one directly above the
# other, and the leader of the upper one then runs down behind
# the lower one and is lost.
#
# Wider still than that would suggest, because the solver
# measures a label by the glyphs in it while what is drawn is
# the box around them, larger by its padding, so a label placed
# flush against a line covers a sliver of it.
"expand": (2.0, 1.4),
"max_move": (30, 30),
"time_lim": 10,
}
Expand Down
133 changes: 133 additions & 0 deletions utu/spectrum/_tests/test_plots.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import itertools

import astropy.units as u
import matplotlib
import matplotlib.pyplot as plt
Expand Down Expand Up @@ -81,3 +83,134 @@ def test_stem_kwargs():
assert np.all(ax.collections[0].get_color() == np.array([[1, 0, 0, 1]]))

plt.close(fig)


# The eight brightest lines of the quiet Sun in the passband of ESIS, which is
# the arrangement `stem` was written for and the one which found every way it
# had of going wrong. Two of them are a hundredth of an angstrom apart.
passband = na.FunctionArray(
inputs=na.CartesianNdVectorArray(
components={
"wavelength": na.ScalarArray(
ndarray=np.array(
[562.80, 584.33, 599.59, 608.40, 609.79, 609.83, 624.94, 629.73]
)
* u.AA,
axes=("line",),
),
"ion": na.ScalarArray(
ndarray=np.array(
["Ne 6", "He 1", "O 3", "O 4", "Mg 10", "O 4", "Mg 10", "O 5"]
),
axes=("line",),
),
},
),
outputs=na.ScalarArray(
ndarray=np.array([15.8, 148.5, 26.8, 12.6, 54.6, 23.4, 26.8, 219.2])
* u.erg
/ u.s
/ u.cm**2
/ u.sr,
axes=("line",),
),
)


def _boxes(texts, renderer) -> dict:
"""
What each label covers on the page.

The box which is drawn rather than the glyphs inside it, since it is the
box which hides whatever is under it, and it is larger than the glyphs by
its padding.
"""
result = {}
for text in texts:
patch = text.get_bbox_patch()
extent = patch.get_window_extent(renderer) if patch else None
result[text] = extent or text.get_window_extent(renderer)
return result


def _touches(box, a, b) -> bool:
"""Whether the segment from ``a`` to ``b`` passes through ``box``."""
num = max(int(np.hypot(*(b - a))), 8)
for s in np.linspace(0, 1, num):
x, y = a + s * (b - a)
if box.x0 <= x <= box.x1 and box.y0 <= y <= box.y1:
return True
return False


def test_stem_collisions():
"""
Nothing a label covers is anything a reader needs.

A label lying along the axis, a label over the leader of another, and a
label over a line have each been drawn by this function, and each was
found by looking at the figure rather than by anything here. The four of
them are what this measures.

At the width this is drawn at, which is the width of the text of a
journal page. Eight labels do not fit into much less than that without
one of them covering something: at four inches this same spectrum still
hides a leader and crosses a line, and no arrangement of the solver
tried here avoids it. What is asserted is therefore what is achievable,
not what would be ideal.
"""
fig, ax = plt.subplots(figsize=(7.1, 2.4), constrained_layout=True)
texts = utu.spectrum.stem(passband, ax=ax, kwargs_text={"fontsize": 6})
fig.canvas.draw()

renderer = fig.canvas.get_renderer()
box = _boxes(texts, renderer)

# no label lying along the axis the lines stand on
floor = ax.transData.transform([[0, 0]])[0][1]
for text in texts:
assert box[text].y0 >= floor

# no label over another
for a, b in itertools.combinations(texts, 2):
overlap = matplotlib.transforms.Bbox.intersection(box[a], box[b])
assert overlap is None or overlap.width <= 0 or overlap.height <= 0

# no label over a leader which is not its own. The leader is clipped to
# start at the label it belongs to, so it can only ever strike another.
leaders = [
(p.patchA, p.get_path().transformed(p.get_transform()).vertices)
for p in ax.patches
if isinstance(p, matplotlib.patches.FancyArrowPatch)
]
assert len(leaders) == len(texts)
for text in texts:
for owner, vertices in leaders:
if owner is text:
continue
for a, b in zip(vertices[:-1], vertices[1:]):
assert not _touches(box[text], a, b)

# no label over a line
for collection in ax.collections:
for segment in collection.get_segments():
a, b = ax.transData.transform(segment)[[0, -1]]
for text in texts:
assert not _touches(box[text], a, b)

plt.close(fig)


def test_touches():
"""
The measurement above is only worth as much as this.

Its whole assertion is that ``_touches`` is false of everything, which
is also what it would report if it were false of everything whatever it
was given. That has happened here before, in a measurement written the
same way and believed for it, so the two cases are checked.
"""
box = matplotlib.transforms.Bbox([[0, 0], [10, 10]])

assert _touches(box, np.array([5.0, -5.0]), np.array([5.0, 15.0]))
assert not _touches(box, np.array([20.0, -5.0]), np.array([20.0, 15.0]))
Loading