From 7f4d97f05c5c7f6b71086278f0da2167eca91e24 Mon Sep 17 00:00:00 2001 From: "Roy T. Smart" Date: Mon, 31 Aug 2026 08:54:43 -0600 Subject: [PATCH 1/4] Keep the labels off the axis The label of a faint line was left lying along the bottom of the plot, across the axis it was drawn on, where it is hard to read and looks like a mistake. The lines are handed to the solver as points along their length, and a label is pushed away from those points. The lines stand on the baseline but do not cover it, so the strip of axis between two of them holds no points at all, and is as good a place as any as far as the solver can tell. It is the place a label of a faint line starts, its line being short, and so it is where the label stays. A row of points along the baseline says otherwise. Nothing else changes: the labels of the article's figure of the passband move off the axis and nowhere else. Widening the moves the solver may make and giving it more room above were each tried first, and each made the figure worse rather than better: the labels cleared the axis and settled on the curve of the effective area instead, which is data, where the axis is not. --- utu/spectrum/_plots.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/utu/spectrum/_plots.py b/utu/spectrum/_plots.py index 2c070b9..b3e1644 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)}] From cf831e2b66456b9675ba3c3ed4e93c153bc8b285 Mon Sep 17 00:00:00 2001 From: "Roy T. Smart" Date: Mon, 31 Aug 2026 10:19:43 -0600 Subject: [PATCH 2/4] Keep the labels off each other's lines Two lines a hundredth of an angstrom apart, as Mg X and O IV are at 609.8, must be labelled one above the other. The leader of the upper one then runs straight down behind the lower label and is lost, so the reader sees a name with nothing to attach it to. A label here is an ion and a wavelength: several times wider than it is tall. So two of them need far more room beside one another than above, and the solver was being told to keep the same fraction of a label's width and of its height, which is a lot of one and very little of the other. Widening only the first lets the two lines at 609.8 sit side by side, and the leader has somewhere to go. Wider still than that alone would ask for, because the solver measures a label by the glyphs in it, while what is drawn is the box around them, larger by its padding. A label placed flush against a line therefore covers a sliver of it: the label of Mg X 624.9 overhung the line of O V 629.7 by a third of a pixel, which is visible, and which no arrangement the solver considers acceptable would have avoided, since as far as it knows the two do not touch. Measured on the article's figure, against the boxes which are drawn rather than the glyphs inside them: no label over another, none over a leader which is not its own, and none over a line. --- utu/spectrum/_plots.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/utu/spectrum/_plots.py b/utu/spectrum/_plots.py index b3e1644..44faefa 100644 --- a/utu/spectrum/_plots.py +++ b/utu/spectrum/_plots.py @@ -183,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, } From c06b26bbb4220a9476fb70a4f2cffe55683fb2e6 Mon Sep 17 00:00:00 2001 From: "Roy T. Smart" Date: Mon, 31 Aug 2026 11:23:05 -0600 Subject: [PATCH 3/4] Measure what the labels cover Every fault in the placement of these labels was found by looking at the figure: one lying along the axis, one covering the leader of its neighbour, one overhanging a line by a third of a pixel. Nothing in the tests would have found any of them, and the last two were found only after the first was fixed and the figure looked at again. So this measures the four of them, on the eight brightest lines of the quiet Sun in the passband of ESIS, which is the arrangement that produced all three and which includes two lines a hundredth of an angstrom apart. Measured against the box which is drawn rather than the glyphs inside it. The box is larger by its padding, and the difference is exactly what hid the last fault: as far as the solver knew, and as far as a measurement of the glyphs knew, the label and the line did not touch. A leader is attributed to its label by `patchA` rather than by which label it starts nearest. Guessing by proximity is what hid the second fault: the two labels at 609.8 are a hundredth of an angstrom apart, the guess went to the wrong one of them, and the collision was then skipped as a label crossing its own leader, which cannot happen. The width is the width this is drawn at. Eight labels do not fit into much less: at four inches this spectrum still hides a leader and crosses a line, and none of the settings tried here avoid it. --- utu/spectrum/_tests/test_plots.py | 118 ++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/utu/spectrum/_tests/test_plots.py b/utu/spectrum/_tests/test_plots.py index d0d7578..cc8c726 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,119 @@ 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) From 975d8483430cae4f662eb58e5c7d0c620520ce59 Mon Sep 17 00:00:00 2001 From: "Roy T. Smart" Date: Mon, 31 Aug 2026 11:35:22 -0600 Subject: [PATCH 4/4] Check that the measurement can fail The test above asserts that nothing touches anything, and passes. It would pass just the same if the function it asks were false of everything it was ever given, and Codecov noticed: the branch which reports a collision was never once taken. That is not a hypothetical failure. Two measurements written this way were believed today, and both reported nothing where there was something: one attributed a leader to the wrong label and skipped the collision as a label crossing its own, and one measured the glyphs of a label where what covers a line is the box around them. So the function is asked about a segment which does cross and one which does not. --- utu/spectrum/_tests/test_plots.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/utu/spectrum/_tests/test_plots.py b/utu/spectrum/_tests/test_plots.py index cc8c726..095f9fb 100644 --- a/utu/spectrum/_tests/test_plots.py +++ b/utu/spectrum/_tests/test_plots.py @@ -199,3 +199,18 @@ def test_stem_collisions(): 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]))