Well, that last post pretty much documented my going around in circles trying to sort some geometry, matplotlib, and numpy considerations, methods and such. But, the path to discovery is often rather circuitous. And, in this case, it was more or less successful, m’thinks. Now to see if it was worth the trouble.

Let’s go back to the data point labels and see if we can use the information available to us to determine, programmatically, where they should be placed. That is, where on the plot to avoid any overlap with other plot items (e.g. the state of charge curve).

Check Label Locations (Try 2?)

There are, as near as I can see, only a few locations that present issues for the data label placement. And, I expect I could easily code something with hard-coded values for those spots to sort things out. But, I would like to avoid that. Perhaps get something that might work with different curves and lines. Let’s see what can be done.

And new function. I will for each label, take it’s starting point and it’s inverse point, and see if the label overlaps either of those points. If so, I will take a shot a determining where it should go. I expect the function is going to need a lot of parameters. Though I will likely leave some of the information gathering to the function which will hopefully reduce some of the parameters.

As an initial set of parameters I plan to pass the axis, the starting point, the label text, the curve function and its inverse function. Let’s give the initial bits a shot.

def calc_pt_txt_pos(ax, tx, ty, txt, fn, i_fn):
  ''' Calculate the point at which to place text, possibly including alignment(S)

  :param ax: matplotlib plot axes
  :param tx: x-axis value for point of interest
  :param ty: y-axis value for point of interest
  :param txt: actual label text to add to plot
  :param fn: function for curve of interest; probably bad choice
  :param i_fn: inverse function for curve of interest; probably bad choice
  
  Returns: x and y coordinates for text position, plus dict of text style paramaters
    dict empty if don't need style parameters

  :bad choice: this requires the function to know what parameters each function
    requires. That is definitely not ensuring the separation of interests.
'''
  ...

Well! Already need a rethink. Let’s see if we can do this without the text positioning function needing access to those two functions.

def calc_pt_txt_pos(ax, a_txt, tx1, ty1, tx2):
  ''' Calculate the point at which to place text, possibly including alignment(S)

  :param ax: matplotlib plot axes
  :param a_txt: the artist for the text label under consideration
  :param tx1: x-axis value for point of interest
  :param ty1: y-axis value for point of interest
  :param tx2: the inverse x value if it exists, i.e. closest x where fn(x) = ty1

  Returns: x and y coordinates for text position, plus dict of text style paramaters
    dict empty if don't need style parameters
  '''
  ...

At this point, the function only needs to know how to get the bounding box and text from the artist. I am for now willing to live with that. Doesn’t seem unreasonable for the function to be coded to do that.

And, another refactoring, just like that. I have come to the conclusion that there is no way to make this function independent of the plot’s purpose and members. We are dealing with a cyclical curve (sine). We have to deal with a minimum and maximum value for the % charge. We are going to be plotting one or more tangents on that curve. There aren’t too many other cases that are going to look anything like that. But, I’ve come this far, so going to try and write something that can position the text for me for any point on the state of charge curve and for any tangent.

And because this function is project specific, I am not going to pass the text string to be plotted. I will determine that internally from the passed parameters.

I am thinking, I will not plot the text then get the function to check it. I will pass the axis and the 3 x/y values and let the function determine the text string and where it should go. I am also going to pass the estimated differential for the point(s) in question. That will allow me to determine if the point is a minimum or maximum on the curve. It will return the text plot location and any style paramters. Though I am thinking of just having it plot the text at the spot it determines to be best.

def calc_pt_txt_pos(ax, px1, py1, px2, slp, p_font):
  ''' Calculate the point at which to place text, possibly including alignment(s)

  :param ax: matplotlib plot axes
  :param px1: x-axis value for point of interest
  :param py1: y-axis value for point of interest
  :param px2: the inverse x value if it exists, i.e. closest x where fn(x) = ty1
  :param slp: differential for (tx1, ty1), i.e. slope of tangent line
  :param p_font: initial font dict to use for point related text
                 keys expected: 'color', 'size', 'va'
                 'color' and 'size' values will not change,
                 'ha' may be added, and 'va' changed

  Returns: x and y coordinates for text position, plus dict of text style paramaters
    dict empty if don't need style parameters
  '''
  p_txt = f"({tx1:.2f}, {ty1:.2f})"
  ...

Test Putting Text to the Right of Point

We will need the function to temporarily plot the text to the specified axis. Then check for overlaps and proceed accordingly if any found.

So, let’s start by temporarily adding the text to the plot and get its properties (well the ones we might be interested in). And since we will likely obtain the text properties a few times, let’s add a function to handle that. A bit of debug code included in the code that follows. Will eventually remove.

... ...
def get_txt_info(a_fig, a_txt):
  """ Get and return bbox, text styles for passed in text artist

    :param a_fig: reference to current figure
    :param a_txt: text artist for which to obtain bbox and such

    return: text bbox, text horizontal and vertical alignments
  """
  r = a_fig.canvas.get_renderer()
  bbp = a_txt.get_window_extent(renderer=r).transformed(plt.gca().transData.inverted())
  t_ha, t_va = plt.getp(a_txt, "ha"), plt.getp(a_txt, "va")
  # t_info = {
  #   'px0': bbp.x0,
  #   'py0': bbp.y0,
  #   'px1': bbp.x1,
  #   'py1': bbp.y1,
  #   'txt': plt.getp(a_txt, "text"),
  #   'ha': plt.getp(a_txt, "ha"),
  #   'va': plt.getp(a_txt, "va"),
  # }
  return bbp, t_ha, t_va


# Another rethink/plan
def calc_pt_txt_pos(ax, px1, py1, px2, slp, p_font):
  ''' Calculate the point at which to place text, possibly including alignment(s)

  :param ax: matplotlib plot axes
  :param px1: x-axis value for point of interest
  :param py1: y-axis value for point of interest
  :param px2: the inverse x value if it exists, i.e. closest x where fn(x) = ty1
  :param slp: differential for (tx1, ty1), i.e. slope of tangent line
  :param p_font: initial font dict to use for point related text
                 keys expected: 'color', 'size', 'va'
                 'color' and 'size' values will not change,
                 'ha' may be added, and 'va' changed

  Was originally thinking I would return the text location and font dict.
  In the end, chose to plot point and draw text. Nothing returned.

  Side effect: draws dot and text at data point location
  Returns: nothing
  '''
  do_dbg = True
  # we could use ax.get_window_extent to determine these values, but...
  x_min, x_max = 0, 24
  x_sep = .3
  t_try = {"l": False, "m": False, "r": False}
  is_ok = False
  t_font = copy.deepcopy(p_font)

  p_txt = f"({px1:.2f}, {py1:.2f})"
  if do_dbg:
    print(f"\ntest point location at: {p_txt}")

  if do_dbg:
    print(f"try to right of point:")
  r_pt = ax.text(px1 + x_sep, py1, p_txt, fontdict=p_font)

  # get info we need
  bbp, t_ha, t_va = get_txt_info(fig, r_pt)
  if do_dbg:
    s_ti = f"bbox: lr ({bbp.x0}, {bbp.y0}), ul ({bbp.x1}, {bbp.y1}),\n\t  align: ({t_info["ha"]}, {t_info["va"]}))"
    print(f"\t{s_ti}")
... ...
if blk_2b["txt3"]:
  # plot state of charge function
  fig, ax = plt.subplots(figsize=(8, 6))
  t = np.arange(0.0, 24.0, 0.05)
  s = soc_fn(t)
  xl = (0, 24)
  yl = (0, 100)
  socp = soc_plot(ax, t, s, xl, yl, params={"c": "k"})

  # plot point of interest
  px = 16.25
  py, p_df = soc_fn(px), est_df(soc_fn, px)
  px2 = soc_inv(py, px)
  print(f"(px, py): ({px}, {py}), px2: {px2}, p_df; {p_df}")
  p_font = {
    'color': 'b',
    'size': 9,
    'va': "center",
  }
  ax.scatter(px, py, alpha=.6, c=p_font["color"], s=40, clip_on=False)

  # sort placement of 
  calc_pt_txt_pos(ax, px, py, px2, p_df, p_font)

  if blk_2b["sv_plt"]:
    fig.savefig(f"img/text_3_{i_blk + 1}.png")
  plt.show()

In the terminal I got the following.

(mc-3.14) PS R:\learn\mc\calculus> python txt_loc.py -c txt3
(px, py): (16.25, 77.28741869517678), px2: 19.75, p_df; 4.168472518231283

test point location at: (16.25, 77.29)
try to right of point:
        bbox: lr (16.550000000000004, 75.88049228825038), ul (20.0241935483871, 78.6943451021032),
          align: (left, center)

And the current plot, clearly showing the text overlapping the curve on the right.

plot showing the point info text plotted to right of curve for 1st test location

Special Case

Okay, I am going to rework the text location function to deal with the minimum and maximum y-values. These are at the bottom or top of a curve, where the tangent will have slope of zero. So the text can not be to the left or right of the data point being looked at. It will be placed below or above the curve with some suitable spacing.

That means we need to control the steps the function takes in determining where to place the text. E.G. if we are at minima or maxima, no point doing any further checks. Ditto, if first attempt at placing text does not end up overlapping the curve or going out of bounds.

Instead of using the slope, I could likely use ax.get_window_extent to determine the minimum and maximum y-values and do some comparisons. Using the slope of the tangent just seems easier, as we already have a function to get that value.

... ...
  # test for maxima/minima, special case
  if round(slp, 0) == 0:
    if do_bg:
      print("handling min/max curve point")
    y_sep = 0.6
    if px1 > 12:
      # maxima
      t_y = py1 + y_sep
      t_font["va"] = "bottom"
    else:
      t_y = py1 - y_sep
      t_font["va"] = "top"
    t_font["ha"] = "center"
    if do_dbg:
      r_pt = ax.text(px1, t_y, p_txt, fontdict=t_font)
      t_try["m"] = True
    is_ok = True

  # try print text to right of point
  if not is_ok:
... ...
  # plot point of interest
  px = 6
... ...

In the terminal, for \(x=18\) and \(x=6\), I got the following.

(mc-3.14) PS R:\learn\mc\calculus> python txt_loc.py -c txt3
(px, py): (18, 81.0), px2: 18.0, p_df; 0.0

test point location at: (18.00, 81.00)
handling min/max curve point

(mc-3.14) PS R:\learn\mc\calculus> python txt_loc.py -c txt3
(px, py): (6, 8.999999999999998), px2: 6.0, p_df; 0.0

test point location at: (6.00, 9.00)
handling min/max curve point

And only the plot showing the text positioned for \(x=6\).

plot showing the point info text plotted for the current global minima

Text to Right Overlaps Curve or Goes Out of Bounds

In this case I am going to try plotting the point text to the left of the curve and check for any problems with that location. In production situation, I would remove the text at the previous test location. But, for debug purposes I am going to leave it where it is. And, plot the text at the new location in red rather than the colour it should be in.

  # if not is_ok:
  if not is_ok
    # move to left of curve and check left edge outside axis
    if do_dbg:
      print(f"\ntry to left of point:")
    x_sep = 0.3
    t_font["color"] = "r" # for testing only
    if px1 <= 12:
      t_font["va"] = "top"
    else:
      t_font["va"] = "center"
    t_font["ha"] = "right"
    l_pt = ax.text(px1 - x_sep, py1, p_txt, fontdict=t_font)
    bbp, t_ha, t_va = get_txt_info(fig, l_pt)
    if do_dbg:
      s_ti = f"bbox: lr ({bbp.x0}, {bbp.y0}), ul ({bbp.x1}, {bbp.y1}),\n\t  align: ({t_ha}, {t_va})"
      print(f"\t{s_ti}")
    if px1 < px2:
      is_ovrlp = (bbp.x1 >= px1) or (bbp.x1 >= px2)
    else:
      is_ovrlp = (bbp.x1 >= px1) or (bbp.x0 <= px2)
    is_outbd = (bbp.x1 <= x_min)
    if do_dbg:
      print(f"\tis_overlp: {is_ovrlp}, is_outbd: {is_outbd}")
    is_ok = not (is_ovrlp or is_outbd)

Truly messy code in this function. Not sure how to fix it.

(mc-3.14) PS R:\learn\mc\calculus> python txt_loc.py -c txt3
args: Namespace(code_block='txt3', item=None, save=False)
(px, py): (16.25, 77.28741869517678), px2: 19.75, p_df; 4.168472518231283

test point location at: (16.25, 77.29)
try to right of point:
        bbox: lr (16.550000000000004, 75.88049228825038), ul (20.0241935483871, 78.6943451021032),
          align: (left, center)
        is_overlp: True, is_outbd: False

try to left of point:
        bbox: lr (12.475806451612904, 75.88049228825038), ul (15.95, 78.6943451021032),
          align: (right, center)
        is_overlp: False, is_outbd: False
plot showing text test to right not good, so try text to left

And that seems to work. Let’s try another location.

(mc-3.14) PS R:\learn\mc\calculus> python txt_loc.py -c txt3 -s
(px, py): (5.75, 9.077078763410272), px2: 6.250000000000002, p_df; -0.6164098634910715

test point location at: (5.75, 9.08)
try to right of point:
        bbox: lr (6.049999999999999, 7.670152356483863), ul (8.904838709677419, 10.484005170336678),
          align: (left, center)
        is_overlp: True, is_outbd: False

try to left of point:
        bbox: lr (2.5951612903225802, 6.263225949557457), ul (5.449999999999999, 9.077078763410269),
          align: (right, top)
        is_overlp: False, is_outbd: False
plot showing text test to right not good, so try text to left

And, one more.

(mc-3.14) PS R:\learn\mc\calculus> python txt_loc.py -c txt3 -s
(px, py): (22.0, 63.000000000000014), px2: 14.0, p_df; -8.16209705014262

test point location at: (22.00, 63.00)
try to right of point:
        bbox: lr (22.300000000000004, 61.593073593073605), ul (25.7741935483871, 64.40692640692643),
          align: (left, center)
        is_overlp: False, is_outbd: True

try to left of point:
        bbox: lr (18.2258064516129, 61.593073593073605), ul (21.7, 64.40692640692643),
          align: (right, center)
        is_overlp: False, is_outbd: False
plot showing text test to right not good, so try text to left

Okay, things seem to be working more or less as wanted. I am not going to worry about whether or not the text to the left is troublesome. Just looking at things, it seems to me that will not be a problem. Time will tell when we move on to plotting the tangent and adding the text of the equation to the plot.

Let’s fix a thing or two.

... ...
    # remove the test text to right
    r_pt.remove()

  if not is_ok:
    # remove the previous test text
    r_pt.remove()
    # move to left of curve and check left edge outside axis
    if do_dbg:
      print(f"\ntry to left of point:")
    x_sep = 0.3
    # remove the following line as for testing only
    # t_font["color"] = "r" # for testing only
... ...
    # remove test text to left
    l_pt.remove

And, I assure you, the text on the left is now in the correct colour and the text on the right is removed if text is moved to the left.

How to Finish Function?

Not quite done though. I need to decide whether the function will plot the text or return the information the caller needs to plot the text.

After some time away from the code and a night’s rest, I have decided to have the function plot the point and draw the text to the provided figure axis. Seems to make sense to me. Everything related to plotting the point and its associated text in one place.

So I am going to rename the function, and rework it to plot the point and draw the text. Don’t think we need to change the function parameters in any way.

Here’s the changed bits of code (I hope).

... ...
def draw_dpt_txt(ax, px1, py1, px2, slp, p_font):
  ''' Calculate the point at which to place text, possibly including alignment(s).
      Plot point and draw text to supplied figure axis.
    ... ...
  '''
... ...
  p_txt = f"({px1:.2f}, {py1:.2f})"
  # plot point using colouer in passed font dict
  a_pt = ax.scatter(px1, py1, alpha=.6, c=p_font["color"], s=40, clip_on=False)
... ...
  # test for maxima/minima, special case
  if round(slp, 0) == 0:
... ...
    r_pt = ax.text(px1, t_y, p_txt, fontdict=t_font)
    is_ok = True
... ...
    # remove the test text to right if not in a good location
    if not is_ok:
      r_pt.remove()
... ...
    # the following to be removed, for purpose of post just commenting out
    # remove test text to left
    # l_pt.remove
... ...
  # ax.scatter(px, py, alpha=.6, c=p_font["color"], s=40, clip_on=False)

  # sort placement of 
  # calc_pt_txt_pos(ax, px, py, px2, p_df, p_font)
  draw_dpt_txt(ax, px, py, px2, p_df, p_font)

And, with some simple tests, that appears to work as intended.

Once More, Done

Well, this text related coding and post(s) is certainly taking longer and more posts than I expected. And, looks like there will be at least one more post. It will look at coding a function to plot the tangent and draw the text showing its equation. Expect it will be just as muddled up as the one for the data point and its text.

‘Til next time, do try to enjoy wherever your projects take you. No matter how convoluted that trip may prove.