Skip to content

transData.inverted().transform does not work as expected with datetime barchart axes #18220

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
aathan opened this issue Aug 11, 2020 · 9 comments
Labels
Documentation status: closed as inactive Issues closed by the "Stale" Github Action. Please comment on any you think should still be open. status: inactive Marked by the “Stale” Github Action

Comments

@aathan
Copy link

aathan commented Aug 11, 2020

Bug report

Transforming from data or axis space back to data space does not work as expected

Code for reproduction

import matplotlib.pyplot as plt
import matplotlib.lines as mlines
from matplotlib.dates import date2num
from datetime import datetime,timedelta
import io

import matplotlib
print('VERSION INFO')
print(matplotlib.__version__)
import platform
print(platform.python_version())

def bug():
	bar_duration = 60
	start = datetime(year=2020,month=7,day=6,hour=6)
	quotes_x = []
	quotes_low = []
	quotes_height = []
	for m in range(60):
		td = timedelta(minutes=m)
		quotes_x.append(start+td)
		quotes_low.append(9000.0+m*10.0)
		quotes_height.append(m*20.0)

	ppi = 72.0 #standard
	dpi = 200
	ppd = ppi/float(dpi)

	aspect = 0.5
	plot_width_perc_of_fig = 0.8 #standard matplot value

	fig_width_inch = 10
	fig_width_dot = dpi*fig_width_inch

	fig, ax = plt.subplots(figsize=(fig_width_inch,fig_width_inch*aspect),dpi=dpi)

	plot_width_sec = float((quotes_x[-1]-quotes_x[0]).total_seconds())
	bar_width = (1.0/(24.0*60.0*60.0)*bar_duration)

	plt.bar(x		= quotes_x,
			  height	= quotes_height,
			  bottom	= quotes_low,
			  width	= bar_width,
			  align	= 'edge',
			  zorder	= 0)

	ax = plt.gca()
	trans = ax.transData.transform
	inv = ax.transData.inverted().transform

	xlim = ax.get_xlim()
	ylim = ax.get_ylim()
	print('ax',ax,xlim,ylim)
	print('bar_width',bar_width)
	print('ppd',ppd)

	aa = quotes_x[0]
	bb = quotes_x[-1]
	print('aa bb',aa,bb,date2num(aa),date2num(bb))
	tr = [(date2num(aa),ylim[0]),(date2num(bb),ylim[0])]
	print('tr_x input',tr)
	tr_x = trans(tr)
	print('tr_x',tr_x)
	print('inv tr_x',inv(tr_x))
	ta = ax.transAxes.transform(([0,0],[1,0]))
	print('transAxes (0,0) (1,0)',ta)
	print('inv(transAxes (0,0) (1,0))',inv(ta))

	a = inv([(0,0),(1,0)])
	print('1 pixel data width',a[1][0]-a[0][0],a)

	#bar_width = (bar_duration*(fig_width_inch*plot_width_perc_of_fig) / plot_width_sec) / 72.0
	#reading online it appears bar_width is unitless and represents "the size of one bar" = 1.0


	fig.autofmt_xdate(rotation=45) #https://www.delftstack.com/howto/matplotlib/how-to-rotate-x-axis-tick-label-text-in-matplotlib/

	image_memory_file = io.BytesIO()
	plt.savefig(image_memory_file)
	image_binary = image_memory_file.getvalue()
	image_memory_file.close()

	fname='bug.png'
	if image_binary is not None:
		ff=open(fname,'wb')
		ff.write(image_binary)
		ff.close()

bug()

Actual outcome

VERSION INFO
3.3.0
3.7.5
ax AxesSubplot(0.125,0.11;0.775x0.77) (18449.247916666667, 18449.293749999997) (9000.0, 10858.5)
bar_width 0.0006944444444444444
ppd 0.36
aa bb 2020-07-06 06:00:00 2020-07-06 06:59:00 18449.25 18449.29097222222
tr_x input [(18449.25, 9000.0), (18449.29097222222, 9000.0)]
tr_x [[ 320.4545455   110.        ]
 [1706.06060612  110.        ]]
inv tr_x [[11123.04545465     0.        ]
 [14388.60606075     0.        ]]
transAxes (0,0) (1,0) [[ 250.  110.]
 [1800.  110.]]
inv(transAxes (0,0) (1,0)) [[10957.     0.]
 [14610.     0.]]
1 pixel data width 2.356774193547608 [[ 1.03678065e+04 -1.42857143e-01]
 [ 1.03701632e+04 -1.42857143e-01]]

Expected outcome

As you can see on the tr_x lines, transforming x's in the 18449 xlim range maps to ~320 and ~1706 pixel space, and 9000.0 on y to 110 in pixel space. Inverting that transform using transData.inverted().transform() goes back to ~11123 and ~14388 on x, and 0 on y.

Expected: exactly the inputs.

As you can see the on the transAxes output lines, x=1.0 is pixel = 1800. On the "ax" line we also see that the xlim is ~18449. However, when passing the transAxes output to transData.inverted().transform() we get back 10957 - 14610.

Expected: We should have translated back to ~18449.

Matplotlib version
matplotlib 3.3.0
python 3.7.5

Installed via the following command, with requirements.txt containing "matplotlib"

pip3 install -U --upgrade-strategy eager -r requirements.txt

@tacaswell
Copy link
Member

Can you reduce this example a bit? There is a lot going on here

My knee-jerk guess is you are either getting bitten by some of the auto-limiting getting lazier or the change epoch on the datetime.

@tacaswell tacaswell added this to the v3.3.2 milestone Aug 11, 2020
@tacaswell tacaswell added the status: needs clarification Issues that need more information to resolve. label Aug 11, 2020
@aathan
Copy link
Author

aathan commented Aug 11, 2020

There's not actually much going on when it comes to the transforms. I've removed some things. If you look at the lines commented HERE, and AND HERE you'll see data being transformed then inverted, and you don't get back the same coordinates as you put in.

I'm not sure what causes the bug, so I left much of the "setup" intact ... in case it's an artifact of the specific configuration and inputs.

import matplotlib.pyplot as plt
import matplotlib.lines as mlines
from matplotlib.dates import date2num
from datetime import datetime,timedelta
import io

import matplotlib
print('VERSION INFO')
print(matplotlib.__version__)
import platform
print(platform.python_version())

def bug():
	bar_duration = 60
	start = datetime(year=2020,month=7,day=6,hour=6)
	quotes_x = []
	quotes_low = []
	quotes_height = []
	for m in range(60):
		td = timedelta(minutes=m)
		quotes_x.append(start+td)
		quotes_low.append(9000.0+m*10.0)
		quotes_height.append(m*20.0)

	fig, ax = plt.subplots(figsize=(10.0,5.0),dpi=200)
	bar_width = (1.0/(24.0*60.0*60.0)*bar_duration)


	plt.bar(x		= quotes_x,
			  height	= quotes_height,
			  bottom	= quotes_low,
			  width	= bar_width,
			  align	= 'edge',
			  zorder	= 0)

	ax = plt.gca()
	trans = ax.transData.transform
	inv = ax.transData.inverted().transform

	xlim = ax.get_xlim()
	ylim = ax.get_ylim()
	print('ax',ax,xlim,ylim)
	print('ax bound',ax.get_xbound())

	aa = quotes_x[0]
	bb = quotes_x[-1]
	print('aa bb',aa,bb,date2num(aa),date2num(bb))
	tr = [(date2num(aa),ylim[0]),(date2num(bb),ylim[0])]
	print('tr_x input',tr)
	tr_x = trans(tr) //<-------------------------   HERE
	print('tr_x',tr_x)
	print('inv tr_x',inv(tr_x))  //<----------------------- AND HERE

bug()

@QuLogic
Copy link
Member

QuLogic commented Aug 11, 2020

The problem is two-fold:

  • The result of .inverted() is fixed and temporary; it does not update if the original forward transform does.
  • The Axes limits are lazy; they are re-computed when needed. In this case, that's when you call ax.get_xlim(). This will update the forward transform.

Thus, if you set trans and inv after refreshing the limits, your result will be correct. Either put that after the ax.get_xlim() / ax.get_ylim() / ax.get_xbound() or call fig.canvas.draw() to ensure the figure is completely updated first.

VERSION INFO
3.3.0rc1.post670+g65f1bbbcc
3.7.6
ax AxesSubplot(0.125,0.11;0.775x0.77) (18449.247916666667, 18449.293749999997) (9000.0, 10858.5)
ax bound (18449.247916666667, 18449.293749999997)
aa bb 2020-07-06 06:00:00 2020-07-06 06:59:00 18449.25 18449.29097222222
tr_x input [(18449.25, 9000.0), (18449.29097222222, 9000.0)]
tr_x [[ 320.4545455   110.        ]
 [1706.06060612  110.        ]]
inv tr_x [[18449.25        9000.        ]
 [18449.29097222  9000.        ]]

@aathan
Copy link
Author

aathan commented Aug 11, 2020

Well, in my case, I am usng these calls is to get some idea about the display characteristics of the chart, so having these transforms "settle" into the right values too late in the game is not optimal. Of course, all the necessary information may not be available too early in the game, but I wanted to mention the use-case so that you may consider it.

For example, I want a way to determine if the bar width, expressed as a timedelta on input, may turn out to be less than one pixel wide on this chart, so that I can requery or otherwise adjust the bar data ... thus my goal in transforming from data space to pixel space is to see if the delta x is <= 1 ...

@aathan
Copy link
Author

aathan commented Aug 11, 2020

The tutorial here: https://matplotlib.org/3.3.0/tutorials/advanced/transforms_tutorial.html

... mentions transformation changes when the xlim/ylim is changed explicitly, but (I believe) makes no mention of calls such as get_xlim() potentially changing the transforms, nor of the general laziness effects that might affect these. I think a very prominent mention of this issue is deserved both in this tutorial and in the API docs. Whatever the case may be, it left me with the impression that I needed to be careful when actually changing aspects of the plot explicitly, but not that the transforms may change out from under me just by drawing or querying the plot after it is set up.

@tacaswell
Copy link
Member

We recently tried to make the warnings a bit more glaring (see https://matplotlib.org/devdocs/tutorials/advanced/transforms_tutorial.html / https://github.com/matplotlib/matplotlib/pull/18178/files). Would that have helped?

On one hand people want us to be as fast as possible, which means we have been trying to defer doing work until as late as possible. In this case, when you have not explicitly set the limits, we defer computing the auto-limits until we must (so if you add 5k lines we only compute the limits once instead of 5k times). In this case, you were triggering the auto-scaling by calling get_xlim() which resolves the "stale" limits and then pushes the updated information down into the transforms.

For performance reasons we accept storing information in two places so that we do not have to query the view limits every time we use it. Similarly if you are using any of tight_layout or constrained_layout, we don't update the position of anything until draw time. In general if you want to know anything about screen space, do fig.canvas.draw() to make sure everything has been resolved.

When I say there is a lot going on you are using bar, datetimes, generating very specific data, etc. While I am sure it is very clear to you, I find even the reduced one hard to parse. A minimal case that reproduces the problem is:

def simple():
    fig, ax = plt.subplots()
    ax.plot(range(5))
    tr = ax.transData
    tr_inv = tr.inverted()
    fig.canvas.draw()
    print(tr_inv.transform(tr.transform((0, 1))))

This is already noted in the docs that the return value from inverted() should be considered temporary, I guess we could put that in a warning box?


This is the second person to become very frustrated at the some what sharp edges of the transform stack (also see #18170) in the past week. Is this just bad luck or is there some new resource pointing people at transforms?

@tacaswell tacaswell modified the milestones: v3.3.2, unassigned Aug 12, 2020
@aathan
Copy link
Author

aathan commented Aug 12, 2020

I was not confused about that. I respectfully suggest that:

  • The API docs and the tutorial should have a warning box stating that matplotlib has many values which are lazily calculated and that not only setter, but also getter, and draw() etc functions may change the internal state of objects. I.e., your statement that " In general if you want to know anything about screen space, do fig.canvas.draw() to make sure everything has been resolved." should be in the docs in big bold letters.

NOTE: All of the examples in the transform tutorial discuss setter/explict plot-changing calls to illustrate when transforms can become stale. Nowhere does it say "even a call like get_xlim() can cause a transform to become stale," (let alone the foregoing warning).

  • There should be a commented example where inverted() and or other transform fetches occur after a call to draw() or get_xlim() or other passive-looking calls, which are glaringly commented as potentially affecting the transform fetch

  • The docs should explicitly state that transforms may already be stale immediately upon fetching, due to the aforementioned laziness behaviors

  • You may also want to consider adding an explicit function called freshen() or resolve() to matplotlib. or draw() may be it, but if draw() is "it" then it should also carry some documentation about how to call draw() multiple times without unscafolding/closing/clearing your plot.

  • You may also want to consider adding (optioned) behaviors to getters of potentially stale things like transforms, so that the freshen()/draw() will be done for you at the time of the fetch.

If any of this is already documented elsewhere, I'd say it should be repeated frequently throughout the docs, particularly in APIs that suffer from staleness. Users don't always come in through the front door.

Thank you for a great package and for all the work!

PS: I appreciate that a minimal code sample existed to show the behavior. There were too many degrees of freedom and too little time for me to create it without a lot of trial and error, especially given lack of familiarity with the APIs and potential side effects. The fact that the example produced the incorrect inversion in its last two lines seemed enough. In other words writing simple() took you 5 minutes, but would have taken me 45. All the best.

@aathan
Copy link
Author

aathan commented Aug 12, 2020

Oh, I forgot. Regarding: " Is this just bad luck or is there some new resource pointing people at transforms?" --> The way I got there is that I was trying to figure out how line widths, bar widths, etc are specified. Is it points? pixels? data units? The answer to that question is a well hidden secret in matplotlib. You look at the API docs and things like linewidth simply say "the width of the line" or similar. Ok. What's "1"? inches? pixels? what? Can datetimes be passed natively? Can a width be a timedelta? trial and error ...

If you search on related keywords you will frequently hit this: #13236 and transforms.

If it doesn't already exist, matplotlib could probably use a document about units, and how sizes and positions are specified when setting up plots -- particularly when dealing with cardinal vs ordinal data, etc etc (a la https://matplotlib.programmingpedia.net/en/tutorial/4566/coordinates-systems ).

@jklymak jklymak added Documentation and removed status: needs clarification Issues that need more information to resolve. labels Sep 7, 2020
@story645 story645 modified the milestones: unassigned, needs sorting Oct 6, 2022
@github-actions
Copy link

This issue has been marked "inactive" because it has been 365 days since the last comment. If this issue is still present in recent Matplotlib releases, or the feature request is still wanted, please leave a comment and this label will be removed. If there are no updates in another 30 days, this issue will be automatically closed, but you are free to re-open or create a new issue if needed. We value issue reports, and this procedure is meant to help us resurface and prioritize issues that have not been addressed yet, not make them disappear. Thanks for your help!

@github-actions github-actions bot added the status: inactive Marked by the “Stale” Github Action label Oct 13, 2023
@github-actions github-actions bot added the status: closed as inactive Issues closed by the "Stale" Github Action. Please comment on any you think should still be open. label Nov 13, 2023
@github-actions github-actions bot closed this as not planned Won't fix, can't repro, duplicate, stale Nov 13, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Documentation status: closed as inactive Issues closed by the "Stale" Github Action. Please comment on any you think should still be open. status: inactive Marked by the “Stale” Github Action
Projects
None yet
Development

No branches or pull requests

5 participants