Repository: matplotlib/matplotlib

Issue:
[Bug]: Minor issue - Drawing an axline sets slopes less than 1E-8 to 0
### Bug summary

Very minor issue in which calling Axes.axline() with a sufficiently small slope creates a horizontal line. axline() uses numpy.close() with default parameters to set slopes with absolute value less than 1E-8 (numpy's default) to 0.

### Code for reproduction

```Python
import matplotlib
import matplotlib.pyplot as plt 
matplotlib.use('TkAgg')
plt.axline(xy1=(0, 0), slope=1E-8)
plt.show()

# compare

import matplotlib
import matplotlib.pyplot as plt 
matplotlib.use('TkAgg')
plt.axline(xy1=(0, 0), slope=1.1E-8)
plt.show()
```


### Actual outcome

![image](https://github.com/matplotlib/matplotlib/assets/106111518/8729d0f7-eab3-4d2d-becc-c174e242087c)


### Expected outcome

Similar to this image (from the 2nd code snippet):
![image](https://github.com/matplotlib/matplotlib/assets/106111518/dab0e9ae-99e1-4d4d-b33b-9cb1e4a4726b)


### Additional information

I came across this issue when plotting some particularly terrible electrochemical data - measuring a current response in the nanoamps (10^-9) versus voltage in the 0-5 V range. It seems like `axline.get_transform(`) sets the slope to 0 if it satisfies `np.isclose()`. I am not sure why this is done (maybe numerical stability reasons?), since `axhline()` exists if the user _intentionally_ wants to make a horizontal line. It seems safe to me to remove the check or at least shrink the `atol` of that `np.isclose()` call. 
  

### Operating system

Windows

### Matplotlib Version

3.9.0 

### Matplotlib Backend

TkAgg (probably all) 

### Python version

3.11.5

### Jupyter version

_No response_

### Installation

pip


Discussion:
Function is below. I tend to think that all of the `isclose` and the `allclose` comparisons here should just be exact comparisons, to avoid issues like above. Floating point equality checks should be fine here IMO since this is visual, and degrades gracefully.

https://github.com/matplotlib/matplotlib/blob/b19794e126c1077b83eba90fe74ab1f67362b1ba/lib/matplotlib/lines.py#L1498-L1537
### Good first issue - notes for new contributors

This issue is suited to new contributors because it does not require understanding of the
Matplotlib internals. To get started, please see our [contributing
guide](https://matplotlib.org/stable/devel/index).

**We do not assign issues**. Check the *Development* section in the sidebar for linked pull
requests (PRs). If there are none, feel free to start working on it. If there is an open PR, please
collaborate on the work by reviewing it rather than duplicating it in a competing PR.

If something is unclear, please reach out on any of our [communication
channels](https://matplotlib.org/stable/devel/contributing.html#get-connected).
I agree, I think `if np.isclose(slope, 0):` should be `if slope == 0` or we need to adjust the `np.isclose` to be aware of the current view limits (as if the y limits are (-100, 100) and we only have 100 pixels, then a slope of 1e-8 is indeed almost 0 but if the view limits are (-1e-8, 1e8) it is very clearly not and a slope of 1000 is basically 0 if the ylimits are (-1e15, 1e15)).  We need some check here because of the `.../slope` it is protecting (we could also catch the divide by 0 exception, but in this case I think the code is clearer if we proactively check the special case).


The work here:

 - adjust the test on L1528 of lines.py to either be an explicit 0 test (which maybe has the risk of some weird issues with the `/ slope` for sub-normal values etc) or to feed the current view limits into the `isclose`.  I can see arguments both ways, explain why you picked the one you did
 - add a test (the values in the OP are a good test case).  See the existing tests in `tests/test_axes`.  I think that the right test here is to use `check_figures_equal` using `axline` on the test figure and `ax.plot(...)` on the other.  For bonus points parameterize the test to hit a bunch of slopes and ylimit scales.
> hit a bunch of slopes and ylimit scales.

AFAICS, we don’t have slope- or scale-dependent code (other than the special case 0), I.e. we don’t expect the behavior to depend on them. In that case I would strongly discourage such shotgun testing with parametrization and `check_figures_equal`. Unfortunately, figures are expensive (some 100ms IIRC). That adds up quickly to noticeable test suite runtimes.
I was wondering if I could work on this?
Yes, it looks like the other PR for this (#28431) has gone dormant.
I reviewed #28754 before realizing that there was a discussion in the original issue. My suggested approach was to detect that the slope was explicitly given and in that case skip the isclose-check (but I should update it to include == 0).  

@scottshambaugh
 @oscargus  What’s the semantic difference between an explicit slope and a calculated one? AFAICS it’s just how the user has defined the line and small values can equally happen for two points or point-and-slope.

I suspect the guard was only introduced to prevent division by 0. An explicit == 0 check should be sufficient, or if we are really concerned about tiny numbers messing with the numerical, something like < 1e-14 (or a small multiple of `np.finfo().resolution`).

Though I’m undecided whether we should coerce to prevent numerical artifacts or just let the floats run their way (I.e. only catch the exact == 0 case). In the end, we cannot shield users completely from numerical artifacts.
I do not disagree that checking for 0 should be enough. But hard to tell if there are corner cases where one would like to avoid small numbers.

My rationale is that if one specifies a small slope, it should not be touched and one will have to live with the results. While if one computes a small slope it may be caused by numerical errors and therefore it *should* have been zero. Without going into too much detail, it may be that the transformed y-coordinates may be slightly different due to numerical issues in the transform (for some transforms) leading to a very small slope which should have been zero. But I'm only guessing why the check was added to start with.
The check was added in #18647 without any discussion (as far as I can see), so hard to tell why.
I am uncomfortable with the idea that you get different results depending how you specify the line.  In the OP’s case they know they are working with small numbers and are expecting to see those small numbers represented in the plot.  That should still be the case if they pass two points.
I'm inclined to go with the simple == 0 approach.

We don't know what cases a small tolerance would help with. But we know that using tolerances here will modify the data and may surprise users that have real small slopes. We should err on the side of not tinkering with user values if we aren't sure that's the right thing to do.

As said, we cannot fully shield users from numeric effects. The strategy should be to do as little as possible initially. When such effects are reported back, we'll investigate whether we can better handle the concrete case and only act then.
The first approach involves computing the `tolerance` using the axis limits to make sure that even very small slopes can pass through. For example, the second plot in the first post has a y-axis range of `2E-8` and an x-axis range of `2`. The corresponding tolerance is  `((2E-8)/2)*(1E-8)=1E-16`, meaning that no slope greater than `1E-16` will be set to `0`. Heuristically, it will work because the minimum slope that the user might be able to physically see with those axis ranges would be no less than `1E-10`. And if the user wants to plot an even smaller slope, the ratio `(y-axis range)/(x-axis range)` will be be even smaller, and the corresponding tolerance will be  smaller than `1E-16`, although unnecessarily, because of machine error. The benefit of this fix is that both ways of specifying the slope are treated equally, though it's not elegant.
I'm not clear what the "first approach" is.

I've suggested to simply replace `if np.isclose(slope, 0)` by `if slope == 0`. I.e.

- only prevent the division by zero for exact horizontal lines. Let numerics take its way for all other cases.
- do not handle "manual_slope" differently - both variants of specifying a line (two points, or point + slope) are equivalent and we should not handle them differently.

Do you see a problem with this?



Patch:
diff --git a/lib/matplotlib/lines.py b/lib/matplotlib/lines.py
index acaf6328ac49..9629a821368c 100644
--- a/lib/matplotlib/lines.py
+++ b/lib/matplotlib/lines.py
@@ -1521,7 +1521,7 @@ def get_transform(self):
         (vxlo, vylo), (vxhi, vyhi) = ax.transScale.transform(ax.viewLim)
         # General case: find intersections with view limits in either
         # direction, and draw between the middle two points.
-        if np.isclose(slope, 0):
+        if slope == 0:
             start = vxlo, y1
             stop = vxhi, y1
         elif np.isinf(slope):


Test patch:
diff --git a/lib/matplotlib/tests/test_lines.py b/lib/matplotlib/tests/test_lines.py
index 531237b2ba28..902b7aa2c02d 100644
--- a/lib/matplotlib/tests/test_lines.py
+++ b/lib/matplotlib/tests/test_lines.py
@@ -436,3 +436,14 @@ def test_axline_setters():
     with pytest.raises(ValueError,
                        match="Cannot set a 'slope' value while 'xy2' is set"):
         line2.set_slope(3)
+
+
+def test_axline_small_slope():
+    """Test that small slopes are not coerced to zero in the transform."""
+    line = plt.axline((0, 0), slope=1e-14)
+    p1 = line.get_transform().transform_point((0, 0))
+    p2 = line.get_transform().transform_point((1, 1))
+    # y-values must be slightly different
+    dy = p2[1] - p1[1]
+    assert dy > 0
+    assert dy < 4e-12