Skip to content

Vectorize string output for Angle.to_string - #20130

Open
veyron-kairo wants to merge 1 commit into
astropy:mainfrom
veyron-kairo:vectorize-angle-tostring
Open

veyron-kairo wants to merge 1 commit into
astropy:mainfrom
veyron-kairo:vectorize-angle-tostring

Conversation

@veyron-kairo

@veyron-kairo veyron-kairo commented Jul 27, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #19992.

Angle.to_string formatted every element in a Python loop (np.vectorize), so large arrays were slow. This builds the sexagesimal strings with array operations instead, the same way #19942 did for Time.

  • The fixed-width string builder from TimeString._value_fast is moved into _format_template in astropy.time.formats (kept private there, per @mhvk), and coordinates uses it.
  • Fields are rounded exactly as in the per-element path, so the output is identical.
  • Arrays under 8 elements, NumPy < 2.1, degrees of 1e18 and up, and a sep that is not a string or contains a {name:spec} field still use the loop. Masked angles keep their mask.
  • SkyCoord.to_string formats both angles for the whole array and joins them, except for masked coordinates.

For 1,000,000 elements on NumPy 2.4.6:

call main this PR
Angle.to_string(sep="dms") 4.8 s 0.40 s
SkyCoord.to_string("hmsdms") 24 s 0.83 s

Scalars are unchanged. Tests compare the new path against the per-element one, including NaN, inf, masked input and every fallback. Changelog and What's New entry included.

AI Disclosure

I used Claude (Opus) to help write the tests in this PR. I went through all of it myself, ran the tests and the benchmarks locally, and I'm responsible for every change here.

  • I certify that I am human and that I take full responsibility for this pull request including all interactions with reviewers.

@github-actions

Copy link
Copy Markdown
Contributor

Thank you for your contribution to Astropy! 🌌 This checklist is meant to remind the package maintainers who will review this pull request of some common things to look for.

  • Do the proposed changes actually accomplish desired goals?
  • Do the proposed changes follow the Astropy coding guidelines?
  • Are tests added/updated as required? If so, do they follow the Astropy testing guidelines?
  • Are docs added/updated as required? If so, do they follow the Astropy documentation guidelines?
  • Is rebase and/or squash necessary? If so, please provide the author with appropriate instructions. Also see instructions for rebase and squash.
  • Did the CI pass? If no, are the failures related? If you need to run daily and weekly cron jobs as part of the PR, please apply the "Extra CI" label. Codestyle issues can be fixed by the bot.
  • Is a change log needed? If yes, did the change log check pass? If no, add the "no-changelog-entry-needed" label. If this is a manual backport, use the "skip-changelog-checks" label unless special changelog handling is necessary.
  • Is this a big PR that makes a "What's new?" entry worthwhile and if so, is (1) a "what's new" entry included in this PR and (2) the "whatsnew-needed" label applied?
  • At the time of adding the milestone, if the milestone set requires a backport to release branch(es), apply the appropriate "backport-X.Y.x" label(s) before merge.

@veyron-kairo
veyron-kairo force-pushed the vectorize-angle-tostring branch from 69ce3be to cfb70b6 Compare July 27, 2026 18:33
@veyron-kairo

veyron-kairo commented Jul 27, 2026 •

Copy link
Copy Markdown
Contributor Author

@taldcroft @mhvk — this is the coordinates follow-up to the Time string work in #19942, doing what @mhvk suggested over on #19992.

It's the same idea as before: the sexagesimal math in _decimal_to_sexagesimal was already vectorized, but to_string then looped over the elements to build the strings, so I moved that assembly onto NumPy string ops. The integer degree/minute fields reuse the _write_decimal helper from the Time PR, and the fractional seconds go through np.strings.mod so the rounding stays bit-for-bit identical to the old per-element output. Anything it can't handle (old NumPy, out-of-range degrees) just falls back to the existing path. I also stopped SkyCoord.to_string from formatting the coordinates one at a time.

Roughly ~10x faster for Angle.to_string on large arrays, and SkyCoord.to_string("hmsdms") on a million coordinates drops from ~23s to ~0.9s. The tests compare the fast output against the per-element path across the various options (including NaN/inf, latex and the fallbacks), and there's a changelog + What's New entry.

whenever you have a chance to look please review this once . Thanks!

@pllim

This comment was marked as resolved.

@pllim

This comment was marked as resolved.

@veyron-kairo

Copy link
Copy Markdown
Contributor Author

Thanks @pllim — fair on both counts.

On the number of tests: to_string wasn't under-tested before. These are new because the PR adds a second, vectorized path alongside the existing per-element one, and the goal is byte-identical output — so most of them just pin "fast path == old path" across the options (separators, padding, fields, precision, sign, latex/unicode, plus NaN/inf and the fallbacks). But you're right that it's more than needed: I ended up checking that same equivalence twice — once through to_string and again directly against the private helper — and over-parametrized the direct one. I've consolidated it down to one equivalence test through the public API plus a small direct/fallback test.

On the oldest-deps failures: those are all coming from that over-parametrized test, and it's a mistake in the test rather than an incompatibility. The fast path deliberately falls back to the per-element code on numpy < 2.1 (np.strings.zfill is buggy there), so the helper returns None — and the test was comparing against that None instead of skipping. Trimming it and adding a numpy < 2.1 skip clears all 96. I'll push that shortly.

@veyron-kairo

veyron-kairo commented Jul 30, 2026 •

Copy link
Copy Markdown
Contributor Author

Thanks @pllim, you're right that there was redundancy. I was checking the same fast-vs-per-element equivalence both through to_string and directly against the private helper, and over-parametrized the direct one into 96 cases. Those 96 were also the oldest-deps failures: the fast path falls back on numpy < 2.1 (np.strings.zfill is buggy there) so the helper returns None, and the test compared against it instead of skipping. Trimmed and skipped now, fixed in the latest push.

@pllim
pllim requested review from mhvk and taldcroft July 30, 2026 15:06
@pllim

pllim commented Jul 30, 2026

Copy link
Copy Markdown
Member

devdeps failures are unrelated, and benchmark decrease might also be unrelated

@taldcroft taldcroft left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@veyron-kairo - this looks great to me. It would be best to get @mhvk to have a look since I'm not a coordinates maintainer. Although I looked through all the code and it looked about right, I didn't do a deep line-by-line. The testing looks solid to me though.

I pushed one commit to re-organize the code a bit to put all the sexagesmal processing (the fast-path or the per-element path setup) within the same initial if-block. In the normal fast-path case then this avoid running unnecessary per-element code. This change is pretty straightforward but if you have concerns we can discuss and back it out.

@taldcroft taldcroft mentioned this pull request Aug 4, 2026
@veyron-kairo

Copy link
Copy Markdown
Contributor Author

Sorry for the slow reply, I was away for a few days.

Thanks for the review, and for pushing that commit. I pulled it down and looked it over, and I have no concerns at all. Folding the fast path and the per-element setup into the one sexagesimal if-block reads a lot better than the split version I had, and it also means the fast path stops building the functools.partial it was never going to use. Happy to keep it.

I re-ran the coordinates tests locally on top of your commit and they all pass. The only red job is the allowed-failure devdeps one that @pllim already flagged as unrelated.

@mhvk, no rush, but this is ready whenever you have time for a look.

@mhvk mhvk left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was reviewing this in some detail and lhaving mostly comments about readability (replace np.where usage, use a + b instead of np.strings.add(a, b)when I started wondering whether there really isn't more overlap with how it is done inastropy.time. There, I really liked how your code used the existing format strings. I think it would be much nicer to do that here too (effectively using septo create a fixed format string), and perhaps factor out the common code to something inastropy/utils`.

But looking at Time, I was reminded also that there we use an erfa routine to calculate parts. In contrast, here we do things ourselves, which is silly and risky--indeed, as you'll see in one of the in-line comments, I now noticed we round seconds incorrectly to 60!!

A quick look at pyerfa shows there is in fact no need to do things ourselves; one can just convert the angle to radian and use their routines (which are ufuncs wrapped around C code, so pretty fast):

  • a2af(ndp, angle): Decompose radians Alternativelyinto degrees, arcminutes, arcseconds, fraction.
  • a2tf(ndp, angle): Decompose radians into hours, minutes, seconds, fraction.

Alternatively, one could also divide either the degrees or hours by 24 and use

  • d2tf(ndp, days): Decompose days to hours, minutes, seconds, fraction.

With those, I think a refactoring out common code with Time makes even more sense. The only downside of those routines is that they use int32 for the degrees/hours, so the maximum is smaller, 2**31.

But that is still unlikely enough that falling back to the slow route is fine.

Which brings me to a second thought: is there really a reason to keep the slow route at all (once NUMPY 2.0 is dropped...)? Speed is not so important for small arrays, and for the large numbers it is also not obvious: there are really only 15 significant digits in a float, and if you have minutes and seconds, you already used about 4 with precision=0, so really giving more than 9 digits for degrees or hours is pretty pointless. I'd personally be quite happy with deciding that the maximum number of digits for hour/deg is 8 and replacing anything that doesn't fit with an exponential representation.

Comment thread astropy/coordinates/angles/formats.py Outdated
)

# Carry rounding upwards through the fields, as in the per-element version.
rounding_thresh = 60.0 - (10.0 ** -(8 if precision is None else precision))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yikes, this is not your problem, but is definitely wrong:

In [8]: Angle("1d2m59.49999s").to_string(precision=0)
Out[8]: np.str_('1d03m00s')

There is a divide by 2 missing...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, thanks for spotting it. It is not rare either: over 40,000 random angles it hits 322 at precision=0 and 30 at precision=1. Adding the factor of 2 fixes it, and I checked it against correct rounding over a 180,000 value sweep.

I have left it out of this branch for the moment, since it is the scoping question in my comment above and I did not want to pre-empt your answer. The fix, a regression test and a changelog entry are ready to push wherever you would like them.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update on this one: it went into #20227, which is now merged, so the fix and its regression test are on main rather than in this branch.

Comment thread astropy/coordinates/angles/formats.py Outdated
else:
d = np.where(m >= 30.0, d + 1.0, d)

# Degrees have a variable number of digits, so size the buffer from the

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should be done before the degrees are even calculated, on angle itself.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Taking the mask from angle also let the second np.where on the minutes further down go away.

One subtlety worth noting: the sign has to be taken before the non-finite values are zeroed, otherwise -inf loses it. The existing tests caught that.

Comment thread astropy/coordinates/angles/formats.py Outdated
rounding_thresh = 60.0 - (10.0 ** -(8 if precision is None else precision))
if fields == 3:
carry = s >= rounding_thresh
s = np.where(carry, 0.0, s)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this reads better as,

m[carry] += 1
s[carry] = 0

Since it is in-place, this will be faster for large arrays (say, >1e4 or 1e5).

Same below, also for the string operations.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, and applied to the string concatenation below as well.

For what it is worth, the commit as a whole is worth about 5% on a million elements (424 ms to 402 ms) and nothing measurable at 10k, so it is mostly a readability win at the sizes people actually use.

@veyron-kairo

Copy link
Copy Markdown
Contributor Author

Thanks @mhvk, this is a really useful review.

On the seconds rounding: confirmed, and it's not specific to the fast path.

>>> Angle("1d2m59.49999s").to_string(precision=0)
'1d03m00s'

rounding_thresh = 60 - 10**-precision is missing the factor of 2, so at precision=0 anything at or above 59s carries into the next minute. The vectorized code just reproduces the existing behaviour faithfully.

On erfa: I hadn't looked at a2af/a2tf, and you're right that they're a much better foundation than doing the decomposition by hand. erfa.a2af(0, ...) gives (1, 2, 59, 0) for the case above, so it also rounds correctly. The int32 limit on degrees/hours seems fine to me given we'd fall back anyway.

Before I start moving things around, two questions on scope:

  1. Would you rather the rounding fix went in as its own small PR? It changes output for anyone currently formatting at low precision, so it seemed worth a separate changelog entry and an easier backport, rather than being folded into a performance change. Happy either way.

  2. For the erfa rewrite, do you want it in this PR or as a follow-up? If it lands here I'd replace the decomposition on both paths rather than only the vectorized one, since keeping two different rounding behaviours around would be worse than what we have now. That makes this PR meaningfully bigger, so I wanted to check rather than surprise you with it.

On dropping the slow path once numpy 2.0 goes: I like the idea, and the 8 digit cap with exponential beyond it sounds reasonable to me. I'd rather do that as a separate step after the erfa work, so the behaviour change isn't mixed in with the refactor.

I'll do the in-place updates and move the finiteness check onto angle regardless, since those hold whichever way the rest goes.

@veyron-kairo

Copy link
Copy Markdown
Contributor Author

I did a bit of measuring while waiting, in case any of it is useful.

The rounding problem turns out to be more common than I expected. Over 40,000 random angles at precision=0, 322 of them come out wrong, plus another 30 at precision=1. For example 57.23317860021386 degrees has 59.443 seconds and prints as 57:14:00 rather than 57:13:59. Adding the missing factor of 2 does fix it properly: I swept 180,000 values across precisions 0 to 8, and the current threshold disagrees with correct rounding 5,742 times where the corrected one disagrees twice, both of those being exact ULP ties like 59.995 at precision 2, which I don't think any threshold comparison can get right.

Your point about erfa being the safer foundation also holds up in a way I hadn't expected. For 67.94486111111111 degrees, our own decomposition gives 41.49999999999295 seconds where erfa.a2af gives 41.500000000, so we lose around 7e-12 s just doing the arithmetic ourselves.

The one thing I wanted to raise before starting is that moving to erfa isn't output preserving, over and above fixing the bug. erfa breaks ties away from zero while Python's format breaks them to even, and together with the accuracy difference above that becomes visible at low precision. Comparing current output against an erfa version, 40,000 angles per case:

precision round seconds in random in
0 8958 differ (22%) 322 differ, all the bug
1 0 30 differ, all the bug
3 0 0
6 0 3 differ, last digit

So something entered as 67:56:41.5 starts printing as 67:56:42 instead of 67:56:41. I do think that's the better answer, but it's a change people will notice if they print coordinates at whole second precision, so I'd rather put it in front of you now than fold it in quietly.

One small practical note on the erfa routines: the fraction field is int32 too, so ndp overflows silently past 9 with no error or warning (ndp=10 gave me 174082148 and ndp=12 gave -89799995 for a value whose correct 9 digit fraction is 123456789). Since to_string(precision=15) works today, precision above 9 would need a guarded fallback alongside the 2**31 cap on degrees. NaN and inf also come back saturated with a RuntimeWarning, so they'd want masking first.

None of this changes my mind that erfa is the right way to go. It mainly affects how the work is best split up and what the changelog needs to say.

@veyron-kairo

Copy link
Copy Markdown
Contributor Author

Pushed the in-place rounding, the + string concatenation and moving the finiteness check onto angle.

I've left the seconds rounding alone for now, since that's one of the questions above and I didn't want to pre-empt your answer on whether it belongs here or in its own PR. It's ready to go either way.

@mhvk

mhvk commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

@veyron-kairo - on separate PRs, I think I agree that a small PR just fixing the rounding error is better, as it allows us also to put at test in place for it. It will also make review of this PR easier. On the other hand, I don't want to make extra work, so I'm OK to go with what you decide.

@mhvk

mhvk commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

p.s. On erfa, I do think we should go with it. I agree that its rounding is better, and also since we are doing astronomy, it behooves us to be consistent with "SOFA=Standards of Fundamental Astronomy" which has the blessing of the International Astronomical Union (ERFA is just a copy of SOFA that allows some modifications).

@veyron-kairo

Copy link
Copy Markdown
Contributor Author

Quick update: I have the erfa version working locally and will push it in a bit, once I have tidied up the commits.

First, a correction to my numbers above. The 22% I quoted was measured with a2af on the angle converted to radians, and that round trip was inflating the difference. I ended up using d2tf(value / 24) instead, which you also suggested. It avoids the radian conversion, keeps one code path for degrees and hours, and comes out closer to exact decimal arithmetic: over 4,000 angles at 8 decimals it is wrong 20 times against 44 for a2af.

So the real change is smaller than I made it sound. Comparing the branch against main over 1.2 million formatted values (both units, precisions None/0/1/3/6, all three field counts), 0.911% differ. The worst case is precision=0, fields=3 for angles entered at whole or half seconds, where 17.2% change, which is the tie breaking you would expect. From precision 3 upwards it is a handful in 40,000.

Something else fell out of that comparison which I do not think has come up yet: fields=1 has a rounding bug of its own, separate from the seconds one.

>>> Angle(285.49493427578034, unit="deg").to_string(fields=1)
'286d'

The angle is 285d29m41.76s, so it should be 285d. The current code carries the seconds into the minutes at 30 first, which takes the minutes to exactly 30, and then rounds the minutes into the degrees at 30 as well, so it rounds twice. It affects 295 of 40,000 random angles. erfa gets it right because it is handed the whole angle and a single resolution.

Would you like that in the same small bugfix PR as the seconds rounding? It is the same kind of mistake and it seems odd to fix one and leave the other.

Last thing, on scope. To use a format string here the way Time does, I moved the template parsing and the code point buffer out of TimeString into astropy/utils, along with their tests, so that coordinates does not have to import from astropy.time. That is what you suggested, but it does mean the PR now touches astropy/time too. I am happy to keep it all inside coordinates instead if you would rather this stayed in one subsystem.

For what it is worth, on a million angles to_string is now about 95 ms, against 400 ms for the numpy string version and roughly 4.2 s on main. Scalars do not regress either, since erfa turns out to be slightly quicker than the decomposition we were doing ourselves.

@mhvk

mhvk commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

@veyron-kairo - wow, that's very nice!

Your plan sounds good: let's do the fix first (both seconds and minutes), and then the larger one to vectorize. I see no problem at all with touching time just to move code to utils (probably in misc.py, unless you found a better place...; since it's star-important the level above, that means we can import from astropy.utils itself)

@veyron-kairo

Copy link
Copy Markdown
Contributor Author

Thanks, that all makes sense. I'll put the fix up first with both the seconds and the minutes, then rebase this one on top of it.

On where the shared code goes, I ended up giving it a small private module of its own rather than misc.py, since it is a fair bit of numpy buffer machinery and misc.py is public. Easy to move it there instead if you would prefer.

@mhvk

mhvk commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

My sense is to put it in misc.py with a docstring that makes clear that it involves hackery and that no error checking on the inputs is performed. But we can discuss when we are in that state!

@mhvk

mhvk commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Actually, let me retract that: why don't we first just extract more of the code in the right function in time itself, and use that in angles. That way we can separate making it useful from making it semi-public.

Also, yet another thing, I'm not sure why in the slow path we do not use python's round instead of that whole 10**-precision stuff.

@veyron-kairo

Copy link
Copy Markdown
Contributor Author

Good call on round, it drops the threshold entirely and is exact where the old one was not. One catch: it needs round(float(s), n), since numpy's round scales by ten and back and so calls 59.995 at precision 2 a carry when the formatting gives 59.99.

Both fixes are in, PR coming shortly. And noted on keeping the extraction inside time for now, that is simpler anyway.

@veyron-kairo

Copy link
Copy Markdown
Contributor Author

Pushed the erfa version. I kept the extraction inside astropy/time as you suggested rather than moving anything into utils: the template handling from TimeString is now _format_template in astropy/time/formats.py, with _field_width lifted alongside it, and angles imports that.

I also rebased on main, so the seconds and minutes rounding fix from #20227 now sits underneath this. That covers the 1d2m59.49999s case you spotted inline.

On a million angles to_string comes out at 96 ms against 4.5 s on main, and scalars are slightly quicker than before, 7.5 µs against 8.3, since erfa beats the decomposition we were doing ourselves.

@mhvk mhvk left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

apologies for not responding earlier, but with term rapidly approaching, I have only limited time for review. As you'll see inline, my main request now is to keep things simpler.

Comment thread astropy/coordinates/angles/core.py Outdated
)
if result is not None:
if alwayssign:
result = np.where(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would still prefer to use direct operators over np.strings.add...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, the latex delimiters are "$" + result + "$" now. The alwayssign one has gone entirely, since the sign is added where the fields come from, as you suggest below.

Comment thread astropy/coordinates/angles/core.py Outdated
)
if format == "latex":
result = np.strings.add("$", np.strings.add(result, "$"))
is_nan = np.isnan(values)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not just:

results[np.isnan(values)] = "nan"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. It fits in place because the non-finite values were written as inf first, so the dtype is always wide enough for nan.

Comment thread astropy/coordinates/angles/core.py Outdated
result = np.where(is_nan, "nan", result)
# NumPy string ops can collapse a scalar to a 0-d string; make
# sure we return a bare Python string in that case, as before.
result = np.asarray(result)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this asarray needed?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is not. The fast path only runs when there are more than _VECTORIZE_MIN_SIZE elements, so the result is always an array. The asarray and the ndim check on the return are both gone.

Comment thread astropy/coordinates/angles/formats.py Outdated
if angle.size and np.abs(angle).max() >= _ERFA_MAX_FIELD:
return None

_, parts = erfa.d2tf(_COARSE_RESOLUTION.get(fields, ndp), angle / 24.0)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we might as well keep the sign here, and use it below, i.e., do sign, parts = ...

I also think we should pass alwayssign down to here since then we can just deal with adding the sign once, using

if not alwayssign:
    sign[sign == "+"] = ""

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, it is sign, parts = erfa.d2tf(...) now and alwayssign is passed down, so the sign goes on in one place and the extra pass in core.py is gone.

The one thing that still needs the sign bit is negative zero: ERFA reports it as +, and to_string has always written it as -0, so sign[negative] = "-" stays.

Comment thread astropy/coordinates/angles/formats.py Outdated
# its own column and stripped, rather than going into the template.
degrees = parts["h"]
width = len(str(int(degrees.max()))) if degrees.size else 1
out = _format_template(f"{{deg:0{width}d}}", {"deg": degrees}, degrees.shape)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm very confused why there are two calls to _format_template rather than just one that uses minutes and seconds as well (as appropriate for fields).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The leading field is the only one with a variable number of digits. _format_template writes fixed width columns, so the degrees come out as 005 and the zeros have to come off afterwards, and stripping them off the joined string is not safe: with an empty separator and zero degrees, the 000 runs straight into the 05 of the minutes and the strip eats that zero too. So the leading field gets its own call, and the minutes and seconds, which keep their zeros, go in the second one.

There is a way to do it in one call: build the whole template, then take the leading zeros off with np.strings.slice and a per element start worked out from the digit count of the degrees. I tried it and the output is identical, it costs about 6% (143 ms now against 151 ms for a million elements), but np.strings.slice is NumPy 2.3 while the rest of the fast path only needs 2.1, so on 2.0 to 2.2 it would mean no speedup at all. That is the only reason I did not go with it. Happy to switch if you would rather have the single call.

Comment thread astropy/coordinates/angles/formats.py Outdated
# large for its 32-bit fields, and its integer fields have no room for the
# non-finite values, all of which are left to the slower code below.
if 0 <= ndp <= 9 and abs(angle) < _ERFA_MAX_FIELD:
_, parts = erfa.d2tf(_COARSE_RESOLUTION.get(fields, ndp), angle / 24.0)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think performance of conversion to string is not worth duplicating code unless it necessary, so I'd prefer to just calling the string version. With good use of operators instead of np.strings functions, it might in fact work almost as well anyway.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried it, and the fixed cost of going through the array version for one value is high: the helper goes from 3.1 to 31 us, Angle.to_string on a scalar from 7.6 to 36 us, and an eight element array (just under the vectorization threshold) from 34 to 255 us. That is slower than main as well, where the helper is 3.8 us. It is not the np.strings calls, it is the half dozen array operations and the two buffers, so operators do not win it back.

What is actually duplicated is the guard and the erfa.d2tf call, four lines or so. What follows differs anyway, since one builds a Python str and the other writes into a code point buffer.

Going the other way and taking ERFA back out of the scalar path is not an option, since then the two disagree. Over 30,000 random angles they differ in the last digit for about 0.4% of them at the default precision, and on ties at precision=0 and 1, and it is ERFA that is right there: -247.51375 deg is exactly -247d30m49.5s, which ERFA rounds to 50 and the float decomposition gives as 49.

So I would rather keep it, but it is your call, and if you still prefer the one implementation I will make the change.

@veyron-kairo

Copy link
Copy Markdown
Contributor Author

sorry for the delay @mhvk , got busy in internals and exams , didn't got enough time to resolve this !

@veyron-kairo

Copy link
Copy Markdown
Contributor Author

Sorry for the slow reply on this one. I have pushed the changes for the straightforward points, and left notes on the two about the structure of the helper, where I would rather have your call than guess.

@veyron-kairo
veyron-kairo force-pushed the vectorize-angle-tostring branch from b5f6bc1 to 0d7f7a4 Compare September 10, 2026 07:55
@veyron-kairo
veyron-kairo requested a review from mhvk September 10, 2026 08:18
@mhvk

mhvk commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

@veyron-kairo - just to let you know my teaching started this week and that means I'll be slow to review; may have some time after next week.

@veyron-kairo

Copy link
Copy Markdown
Contributor Author

Thanks for letting me know @mhvk , please take you time 😊

@astrofrog

Copy link
Copy Markdown
Member

@mhvk - just a ping, might you have time to review this?

@mhvk

mhvk commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

Thanks for the ping. I fear that my tardiness in replying is an answer in itself -- my teaching leaves little time this fall, and I don't think I'll get to this soon, sadly.

@taldcroft

Copy link
Copy Markdown
Member

@veyron-kairo - can you add an AI disclosure to the description to comply with current policy? It helps the review to know what model(s) you used to help out with this PR.

@veyron-kairo

Copy link
Copy Markdown
Contributor Author

@taldcroft , did updated the pr description with Ai disclosure ☺️

@taldcroft

Copy link
Copy Markdown
Member

This is not independent, but I asked Claude Fable 5.1 for a review and there are some issues that should be addressed or at least discussed.

Thanks for this, the speedup for large arrays is real and the approach of building a template once and filling it with array ops is sound. I checked out the branch, ran the coordinates tests (734 passed), and compared the output against main over a large random sample. There is one blocking issue, three correctness bugs, and some smaller items.

1. Rounding of the last digit changed (blocking)

The new scalar path (and therefore the array path) goes through erfa.d2tf, which computes round(resolution * 86400 * |angle/24|). That is several roundings at magnitude ~1e14, where the float spacing is ~0.016, whereas the old modf decomposition plus Python's correctly rounded '%.8f' loses nothing.

Comparing against main over 200,000 uniform angles in [-360, 360] at default precision, 1032 outputs differ. Checking those with exact decimal arithmetic on the binary value, the main string is the correctly rounded one in all 1032 cases and the new string is wrong in all of them. Some examples:

input main this PR
Angle(357.9911537682319, 'deg').to_string() 357d59m28.15356563s 357d59m28.15356564s
Angle(-187.475, 'deg').to_string(fields=2) -187d28m -187d29m
Angle(0.03125, 'deg').to_string(precision=0) 0d01m52s 0d01m53s

The second is an exact value (187d28.4999999999997m) that d2tf rounds up because 3600 * |a| lands on a tie. The third is a true tie that flips from half-even to half-away.

This changes str(Angle), Angle.to_string() and SkyCoord.to_string() for ~0.5% of ordinary inputs, in a PR labelled perf-only, and the changelog fragment does not mention it. Users' regression tests and doctests comparing these strings will break with no pointer to the cause.

I think the vectorized path needs to reproduce the old decomposition with numpy (np.modf on degrees, then minutes, then a correctly rounded seconds string via np.char.mod or similar) rather than route through d2tf. If the new rounding is kept deliberately, it needs an api fragment that says so, but given the old values are the correct ones I would not go that way.

2. Masked Angles lose their mask on the fast path

np.asarray(angle, dtype=float) in _decimal_to_sexagesimal_string_array strips the mask, and the result is returned without re-wrapping:

a = Angle(Masked(np.linspace(0, 100, 12), mask=[True, False] * 6), 'deg')
a.to_string()
# main:  MaskedNDArray([———, '9d05m27.27272727s', ———, ...])
# PR:    array(['0d00m00s', '9d05m27.27272727s', '18d10m54.54545455s', ...])
a[:3].to_string()
# PR:    MaskedNDArray([———, '9d05m27.27272727s', ———])   (loop path, still correct)

So the same array formats differently depending on its length. SkyCoord.to_string() inherits this: a masked lon prints 00h00m00s instead of ———. Either skip the fast path for Masked input or re-apply the mask to the result.

3. Overflow guard is applied before rounding

The abs(angle) < 2**31 check passes for a value that rounds up to 2**31, which then overflows ERFA's int32 field:

Angle(2**31 - 0.5, 'deg').to_string(fields=1)
# RuntimeWarning: invalid value encountered in d2tf   (an error under astropy's filterwarnings)
# returns '2147483647' on arm64 (saturating cast); on x86 the cast of 2^31 is undefined
# main returns '2147483648'

Same on the array path. Guard with a margin, or check the returned h field for overflow.

4. A sep containing braces crashes the array path

The separators are interpolated into the template as raw text, so _SUBFMT_FIELD matches inside them and _format_template returns None:

Angle(np.linspace(0, 10, 12), 'deg').to_string(sep=('{h:d}', 'm', 's'))
# UFuncTypeError: ufunc '_rstrip_chars' did not contain a loop with signature matching types (ObjectDType, StrDType)
Angle(1.5, 'deg').to_string(sep=('{h:d}', 'm', 's'))
# '1{h:d}30m00s'   (scalar path is fine)

Escape braces in sep before building the template, or return None from the helper so to_string falls back to the loop.

5. The vectorized test cannot catch item 1

test_to_string_vectorized_matches_per_element compares the array path to the per-element path, but both now use d2tf, so the reference shares the same rounding error and the assertion is tautological. A test with fixed expected strings generated on main for the three values in the table above would have failed on this branch and is the missing regression guard.

Smaller items

  • NaN handled outside the helper. _decimal_to_sexagesimal_string_array formats NaN as infd00m00s and relies on Angle.to_string to overwrite those entries afterwards (and on the output dtype being at least U3). That breaks the helper's own "vectorized equivalent" contract and is a hidden hazard for any other caller. The direct test excludes NaN for this reason. Handling it inside the helper (np.where(np.isnan(angle), 'nan', ...)) would make the two functions agree.
  • Cross-package private import. astropy.coordinates now imports _format_template from astropy.time.formats. Any rename there breaks import astropy.coordinates. The helper is generic string-column rendering and could live in astropy/utils with both packages importing it.
  • Docstring says "int64 buffer" but the real limit is ERFA's 231.** The docstring in test_formatting.py, the comment in core.py, and the PR text all describe the fallback as being for values too large for an int64 buffer. In fact _decimal_to_sexagesimal_string_array(np.full(10, 3e9)) returns None because 3e9 >= 2**31. The test only exercises 1e19, so the real boundary is untested. Worth naming the ERFA limit and adding a test at 2**31.
  • _VECTORIZE_MIN_SIZE is off by one from its comment. The comment calls it the minimum size for the vectorized path, but the check is values.size > _VECTORIZE_MIN_SIZE, so an 8-element array takes the loop. Use >= or describe it as a strict threshold.

Happy to re-run the comparison once the rounding is addressed.

Disclosure: this review was prepared with assistance from Claude Code. All reproductions above were run against the branch and against main.

@veyron-kairo

Copy link
Copy Markdown
Contributor Author

@taldcroft , thanks for pointing out the issues I'll fix it ASAP

@veyron-kairo
veyron-kairo force-pushed the vectorize-angle-tostring branch from 0d7f7a4 to 292f7c0 Compare November 1, 2026 17:19
@veyron-kairo

Copy link
Copy Markdown
Contributor Author

@taldcroft all nine were real, all fixed now.

First a correction to what I told @mhvk earlier: -247.51375 is not exactly -247d30m49.5s, it is -247d30m49.49999999995s, so main's 49 was right and ERFA's 50 was not. I had compared against the decimal literal instead of the float. With Fraction on the binary value, d2tf gets the last digit wrong on 267 of 50,000 angles at the default precision, the decomposition on none.

So ERFA is out. The array path is back on the modf decomposition, printf does the seconds, and the carry comes from the 60 it can produce. Output matches main byte for byte over 960 keyword combinations and 12,005 values. A million angles take 0.39 s against 4.4 s on main, SkyCoord.to_string 0.83 s against 23 s.

The rest are in as well: masked angles keep their mask (SkyCoord stays on the per-element path, the mask has to sit inside the joined string), the int64 boundary is tested now, a sep with braces falls back to the loop, NaN is written in the helper, the size check uses >=, and the new rounding test pins your three values plus two more and fails on the old branch. The cross-package import I left alone, @mhvk wanted the extraction kept inside astropy.time for now.

@taldcroft

Copy link
Copy Markdown
Member

@veyron-kairo - with apologies, I'm just copying another Claude review. I honestly don't have time to give this the attention it needs, nor am I a coordinates maintainer. But hopefully this helps in getting the PR to a good place where @mhvk can give a real review. The Claude review is basically good now with only some small cleanups that are not strictly required.

----------------------------------------------------------------------------

@veyron-kairo - thanks, this is a big improvement. I reviewed 2ae5f0b06b, and all the items from my earlier review are resolved. The numeric output now matches main everywhere I tested, so I have no blocking concerns about correctness. There are a few small items below.

Earlier review items

Item Status How I checked
1. Last-digit rounding Fixed Back on the modf decomposition; differential run below
2. Masked angles lose the mask Fixed 1-D and 2-D, including NaN, inf and huge values under the mask, all match main
3. Overflow guard before rounding No longer applies ERFA is gone, and values from 1e18 up fall back to the loop
4. sep with braces crashes Fixed _TEMPLATE_FIELD catches anything _SUBFMT_FIELD could match, including {min:02d}, which would otherwise have rendered as a real field
5. Tautological test Fixed All five cases of test_to_string_rounds_the_last_field fail on the previous ERFA version of this branch
NaN outside the helper Fixed Tested directly
Cross-package import Kept Agreed, this follows @mhvk's request to keep the extraction in astropy.time for now
"int64 buffer" description Fixed The docstring is now accurate, and the 1e18 boundary is tested
_VECTORIZE_MIN_SIZE off by one Fixed Timing below also supports the value of 8

What I ran

I compared this branch against its merge base, using a separate script from the tests in this PR:

  • Values: 14,547 values in degrees and hourangle, over 120 combinations of precision, fields, pad and alwayssign. They included half-second and half-minute ties, values just below a carry, nextafter of integers, values up to 1e25, and all-NaN and all-inf arrays.
  • Other inputs: format, 24 sep variants, float16/32/longdouble input, ND and strided arrays, masked input, and SkyCoord in all three styles (scalar, empty, masked, ND).
  • NumPy versions: 2.1.3, 2.2.3 and 2.5.4, on Python 3.13 on arm64.

All valid numeric output was identical, and warnings matched in a separate run on NumPy 2.2.3. The only differences were items 1 and 2 below, plus an empty SkyCoord with style="decimal", sep=":", which now raises the same ValueError as a non-empty one. I think that one is an improvement.

I also confirmed your correction on ERFA. Checked with Fraction against the exact binary value, for 50,000 random angles at precisions 0 to 12, the decomposition was wrong 0 times. d2tf was wrong 2 times at precision 6 and 256 at precision 8, and fails entirely from precision 10 because of the int32 fraction field. And -247.51375 deg really is 49.49999999995 s.

Speed, compared to main on my machine:

Call main this PR Speedup
Angle.to_string(sep="dms"), 1e6 7.7 s 0.58 s 13x
SkyCoord.to_string("hmsdms"), 1e5 3.35 s 0.115 s 29x
Angle.to_string(), scalar 12.5 µs 12.8 µs none

The array path is first faster at 8 elements (48 µs against 67 µs).

Small items

  1. Non-string sep now depends on array length. Angle(np.arange(8.), 'deg').to_string(sep=(1, 2, 3)) raises TypeError from _TEMPLATE_FIELD.search, while the same call with 7 values works, as it does on main. The docstring only promises strings, so this is minor, but the fast path shouldn't change behavior based on size. Falling back is one extra condition in _decimal_to_sexagesimal_string_array:

    if not all(isinstance(s, str) for s in sep) or any(
        _TEMPLATE_FIELD.search(s) for s in sep
    ):
        return None
  2. precision=2.0 changes exception type. On arrays of 8 or more it raises ValueError instead of TypeError, and the messages for other invalid precisions now come from printf. It's an error either way, so I wouldn't change anything; I mention it only for completeness.

  3. The output dtype is wider than on main. With precision=None, Angle(np.arange(20.), 'deg').to_string() is <U19 here and <U9 on main, because the trailing-zero strip doesn't shrink the dtype. The values are the same, and this would only show up as wider fixed-width columns (for example in FITS). I wouldn't spend time fixing it, but it is a small exception to "byte for byte".

  4. Unreachable branches.

    • if tail is None: return None can't trigger now: _TEMPLATE_FIELD has ruled out stray fields and m is always below 60.
    • The width == 0 branch in _format_template isn't reached by Angle or by the built-in Time templates, only by its own new test.
    • The np.asarray for 0-d input in the helper isn't needed on the to_string path, which requires at least 8 elements.

    I'd drop all three, or say why each one should stay.

  5. SkyCoord.to_string doesn't need the ravel. The two string arrays already have the same shape, so lon + " " + lat keeps it, and only the tolist() for the 1-D case is still needed.

  6. Typo: sexagmesimal in the core.py comment. (This may well be from my commit.)

Before merging

  • PR description: please update it, since it still describes an earlier version. It says the code uses _write_decimal directly rather than the _format_template extraction, and doesn't mention the astropy.time refactor. It also says the fast path runs "above a handful" and describes the fallback only as old NumPy or int64, leaving out the sep case. The AI disclosure should be checked too: it mentions only the tests, so please make sure it still covers how the current code was written.
  • History: the 22 commits include ERFA going in and coming back out, so please squash them. It doesn't need a rebase right now.
  • Dropping ERFA: @mhvk, this reverses your preference for ERFA, so it's worth a quick look if you have time. That preference was partly based on the -247.51375 example, which turned out to be wrong. Your other request, having the scalar path call the array version, is still open. The rounding logic now lives in both paths, with the equivalence tests guarding against drift. Going through the array path would make scalars about 4x slower (roughly 48 µs against 13 µs), so keeping both seems reasonable to me, but it's your call.

Disclosure: this review was prepared with assistance from Claude Code (Opus 5.5). All results above were run against the branch and its merge base with main.

Angle.to_string formatted every element through np.vectorize, so large
arrays paid the full Python overhead per value. Build the strings with
array operations instead, reusing the fixed-width string builder from
the Time formatting, which is moved into module-level helpers in
astropy.time.formats for this.

The fields are decomposed and rounded exactly as in the per-element
formatter, so the output is identical. Small arrays, NumPy < 2.1,
degrees of 1e18 and up, and separators the template cannot take still
go through the loop. SkyCoord.to_string formats both angles for the
whole array and joins them, except for masked coordinates.

Co-authored-by: Tom Aldcroft <[email protected]>
Signed-off-by: Shridhar Panigrahi <[email protected]>
@veyron-kairo
veyron-kairo force-pushed the vectorize-angle-tostring branch from 2ae5f0b to 803e5d5 Compare November 6, 2026 05:30
@veyron-kairo

Copy link
Copy Markdown
Contributor Author

@taldcroft thanks a lot for going through it again, really appreciate it.

  1. Good catch on the non-string sep, it falls back to the loop now and I added (1, 2, 3) to the fallback test.
  2. and 3. Left these as they are, like you said.
  3. Removed all three. I also dropped the empty template assert from the _format_template test since nothing calls it that way anymore.
  4. Removed the ravel.
  5. Fixed the typo.

I've squashed everything into one commit and kept you as co-author for your part. Will update the PR description next.

@mhvk whenever you get time, the two things left are dropping ERFA and whether the scalar path should call the array one.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Angle.to_string() is slow for large arrays (per-element string formatting)

5 participants