diff --git a/utu/spectrum/_plots.py b/utu/spectrum/_plots.py index 2c070b9..44faefa 100644 --- a/utu/spectrum/_plots.py +++ b/utu/spectrum/_plots.py @@ -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, @@ -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 @@ -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)}] @@ -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, } diff --git a/utu/spectrum/_tests/test_plots.py b/utu/spectrum/_tests/test_plots.py index d0d7578..095f9fb 100644 --- a/utu/spectrum/_tests/test_plots.py +++ b/utu/spectrum/_tests/test_plots.py @@ -1,3 +1,5 @@ +import itertools + import astropy.units as u import matplotlib import matplotlib.pyplot as plt @@ -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]))