Repository: astropy/astropy

Issue:
BUG: crash when instantiating a `Longitude` from large angle with `dtype=int`
### Description

We have at least one test showing that instantiating `Longitude` with an integer value is explicitly supported,
however it doesn't exercise wrapping, which is actually broken.
This came up while digging into #13479

### Expected behavior

No crash.
Alternatively, we could drop support for integer-valued angles altogether, but this seems uncalled for at this point.

### How to Reproduce

```python
import numpy as np
import astropy.units as u
from astropy.coordinates import Longitude

# this is explicitly tested in astropy/coordinates/tests/test_angles::test_longitude
assert Longitude(0, u.deg, dtype=int).dtype == np.dtype("int")

# but this crashes
Longitude(1234, u.deg, dtype=int).dtype
```

```python-traceback
Traceback (most recent call last):
  File "/Users/clm/dev/astropy-project/coordinated/astropy/untitled.py", line 9, in <module>
    Longitude(1234, u.deg, dtype=int).dtype
    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/clm/dev/astropy-project/coordinated/astropy/astropy/coordinates/angles/core.py", line 716, in __new__
    self.wrap_angle = wrap_angle  # angle-like b/c property setter
    ^^^^^^^^^^^^^^^
  File "/Users/clm/dev/astropy-project/coordinated/astropy/astropy/coordinates/angles/core.py", line 733, in wrap_angle
    self._wrap_at(self.wrap_angle)
  File "/Users/clm/dev/astropy-project/coordinated/astropy/astropy/coordinates/angles/core.py", line 422, in _wrap_at
    self_angle -= wraps * a360
numpy._core._exceptions._UFuncOutputCastingError: Cannot cast ufunc 'subtract' output from dtype('float64') to dtype('int64') with casting rule 'same_kind'
```

### Versions

```python
import platform; print(platform.platform())
import sys; print("Python", sys.version)
import astropy; print("astropy", astropy.__version__)
import numpy; print("Numpy", numpy.__version__)
import erfa; print("pyerfa", erfa.__version__)
try:
    import scipy
    print("Scipy", scipy.__version__)
except ImportError:
    print("Scipy not installed")
try:
    import matplotlib
    print("Matplotlib", matplotlib.__version__)
except ImportError:
    print("Matplotlib not installed")
```
```
astropy 6.1.dev627+gce7673a9db.d20240319
Numpy 2.1.0.dev0+git20240318.6924f8b
pyerfa 2.0.1.2.dev19+g4a637c2
Scipy not installed
Matplotlib not installed
```


Discussion:

Patch:
diff --git a/astropy/table/groups.py b/astropy/table/groups.py
index 43a2897195..8de301b88c 100644
--- a/astropy/table/groups.py
+++ b/astropy/table/groups.py
@@ -118,7 +118,11 @@ def _table_group_by(table, keys):
     # Make a new table and set the _groups to the appropriate TableGroups object.
     # Take the subset of the original keys at the indices values (group boundaries).
     out = table.__class__(table[idx_sort])
-    out_keys = table_keys[indices[:-1]]
+    if len(table) == 0:
+        out_keys = table_keys
+        indices = np.array([], dtype=int)
+    else:
+        out_keys = table_keys[indices[:-1]]
     if isinstance(out_keys, Table):
         out_keys.meta["grouped_by_table_cols"] = grouped_by_table_cols
     out._groups = TableGroups(out, indices=indices, keys=out_keys)
diff --git a/astropy/table/table.py b/astropy/table/table.py
index 7bf228c2f5..e0c217318f 100644
--- a/astropy/table/table.py
+++ b/astropy/table/table.py
@@ -318,7 +318,7 @@ class TableColumns(OrderedDict):
         new_names = [mapper.get(name, name) for name in self]
         cols = list(self.values())
         self.clear()
-        self.update(list(zip(new_names, cols)))
+        super().update(zip(new_names, cols))
 
     def __delitem__(self, name):
         # Remove column names from pprint include/exclude attributes as needed.
@@ -364,6 +364,19 @@ class TableColumns(OrderedDict):
         cols = [col for col in self.values() if not isinstance(col, cls)]
         return cols
 
+    # When the deprecation period of setdefault() and update() is over then they
+    # need to be rewritten to raise an error, not removed.
+
+    @deprecated(
+        since="6.1", alternative="t.setdefault()", name="t.columns.setdefault()"
+    )
+    def setdefault(self, key, default):
+        return super().setdefault(key, default)
+
+    @deprecated(since="6.1", alternative="t.update()", name="t.columns.update()")
+    def update(self, *args, **kwargs):
+        return super().update(*args, **kwargs)
+
 
 class TableAttribute(MetaAttribute):
     """
diff --git a/astropy/time/formats.py b/astropy/time/formats.py
index c07ad24484..c924aa90fc 100644
--- a/astropy/time/formats.py
+++ b/astropy/time/formats.py
@@ -5,7 +5,6 @@ import functools
 import re
 import time
 import warnings
-from collections import OrderedDict
 from decimal import Decimal
 
 import erfa
@@ -61,10 +60,10 @@ __all__ = [
 __doctest_skip__ = ["TimePlotDate"]
 
 # These both get filled in at end after TimeFormat subclasses defined.
-# Use an OrderedDict to fix the order in which formats are tried.
+# It is important that these get populated by insertion order.
 # This ensures, e.g., that 'isot' gets tried before 'fits'.
-TIME_FORMATS = OrderedDict()
-TIME_DELTA_FORMATS = OrderedDict()
+TIME_FORMATS = {}
+TIME_DELTA_FORMATS = {}
 
 # Translations between deprecated FITS timescales defined by
 # Rots et al. 2015, A&A 574:A36, and timescales used here.
diff --git a/astropy/units/core.py b/astropy/units/core.py
index 8cc06304ec..31a8c16e16 100644
--- a/astropy/units/core.py
+++ b/astropy/units/core.py
@@ -1043,7 +1043,7 @@ class UnitBase:
 
     def _apply_equivalencies(self, unit, other, equivalencies):
         """
-        Internal function (used from `_get_converter`) to apply
+        Internal function (used from `get_converter`) to apply
         equivalence pairs.
         """
 
@@ -1089,12 +1089,37 @@ class UnitBase:
 
         raise UnitConversionError(f"{unit_str} and {other_str} are not convertible")
 
-    def _get_converter(self, other, equivalencies=[]):
-        """Get a converter for values in ``self`` to ``other``.
+    def get_converter(self, other, equivalencies=[]):
+        """
+        Create a function that converts values from this unit to another.
+
+        Parameters
+        ----------
+        other : unit-like
+            The unit to convert to.
+        equivalencies : list of tuple
+            A list of equivalence pairs to try if the units are not
+            directly convertible.  See :ref:`astropy:unit_equivalencies`.
+            This list is in addition to possible global defaults set by, e.g.,
+            `set_enabled_equivalencies`.
+            Use `None` to turn off all equivalencies.
 
-        If no conversion is necessary, returns ``unit_scale_converter``
-        (which is used as a check in quantity helpers).
+        Returns
+        -------
+        func : callable
+            A callable that takes an array-like argument and returns
+            it converted from units of self to units of other.
+
+        Raises
+        ------
+        UnitsError
+            If the units cannot be converted to each other.
 
+        Notes
+        -----
+        This method is used internally in `Quantity` to convert to
+        different units. Note that the function returned takes
+        and returns values, not quantities.
         """
         # First see if it is just a scaling.
         try:
@@ -1103,6 +1128,8 @@ class UnitBase:
             pass
         else:
             if scale == 1.0:
+                # If no conversion is necessary, returns ``unit_scale_converter``
+                # (which is used as a check in quantity helpers).
                 return unit_scale_converter
             else:
                 return lambda val: scale * _condition_arg(val)
@@ -1120,7 +1147,7 @@ class UnitBase:
                 for funit, tunit, a, b in other.equivalencies:
                     if other is funit:
                         try:
-                            converter = self._get_converter(tunit, equivalencies)
+                            converter = self.get_converter(tunit, equivalencies)
                         except Exception:
                             pass
                         else:
@@ -1197,7 +1224,7 @@ class UnitBase:
         if other is self and value is UNITY:
             return UNITY
         else:
-            return self._get_converter(Unit(other), equivalencies)(value)
+            return self.get_converter(Unit(other), equivalencies)(value)
 
     def in_units(self, other, value=1.0, equivalencies=[]):
         """
@@ -2017,7 +2044,7 @@ class UnrecognizedUnit(IrreducibleUnit):
         self._normalize_equivalencies(equivalencies)
         return self == other
 
-    def _get_converter(self, other, equivalencies=None):
+    def get_converter(self, other, equivalencies=None):
         self._normalize_equivalencies(equivalencies)
         raise ValueError(
             f"The unit {self.name!r} is unrecognized.  It can not be converted "
diff --git a/astropy/units/quantity_helper/converters.py b/astropy/units/quantity_helper/converters.py
index ff34f661cb..6618aae840 100644
--- a/astropy/units/quantity_helper/converters.py
+++ b/astropy/units/quantity_helper/converters.py
@@ -230,7 +230,7 @@ def converters_and_unit(function, method, *args):
                     # Changing the unit does not work for, e.g., array-shaped
                     # power, but this is OK if we're (scaled) dimensionless.
                     try:
-                        converters[0] = units[0]._get_converter(dimensionless_unscaled)
+                        converters[0] = units[0].get_converter(dimensionless_unscaled)
                     except UnitConversionError:
                         raise exc
                     else:
diff --git a/astropy/units/quantity_helper/helpers.py b/astropy/units/quantity_helper/helpers.py
index 4061d1aa7e..e79f20e1ed 100644
--- a/astropy/units/quantity_helper/helpers.py
+++ b/astropy/units/quantity_helper/helpers.py
@@ -18,7 +18,7 @@ from astropy.units.core import (
     dimensionless_unscaled,
     unit_scale_converter,
 )
-from astropy.utils.compat.numpycompat import NUMPY_LT_2_0
+from astropy.utils.compat.numpycompat import NUMPY_LT_2_0, NUMPY_LT_2_1
 
 if NUMPY_LT_2_0:
     from numpy.core import umath as np_umath
@@ -36,10 +36,10 @@ def _d(unit):
 
 
 def get_converter(from_unit, to_unit):
-    """Like Unit._get_converter, except returns None if no scaling is needed,
+    """Like Unit.get_converter, except returns None if no scaling is needed,
     i.e., if the inferred scale is unity.
     """
-    converter = from_unit._get_converter(to_unit)
+    converter = from_unit.get_converter(to_unit)
     return None if converter is unit_scale_converter else converter
 
 
@@ -390,6 +390,9 @@ if not NUMPY_LT_2_0:
         np._core.umath._replace,
         np._core.umath._expandtabs,
         np._core.umath._expandtabs_length,
+    }
+if not NUMPY_LT_2_1:
+    UNSUPPORTED_UFUNCS |= {
         np._core.umath._ljust,
         np._core.umath._rjust,
         np._core.umath._center,
diff --git a/astropy/units/structured.py b/astropy/units/structured.py
index 0eb2fac73f..1073edd687 100644
--- a/astropy/units/structured.py
+++ b/astropy/units/structured.py
@@ -257,7 +257,7 @@ class StructuredUnit:
         This is useful since ``np.array(value)`` would treat tuples as lower
         levels of the array, rather than as elements of a structured array.
         The routine does presume that the type of the first tuple is
-        representative of the rest.  Used in ``_get_converter``.
+        representative of the rest.  Used in ``get_converter``.
 
         For the special value of ``UNITY``, all fields are assumed to be 1.0,
         and hence this will return an all-float dtype.
@@ -358,12 +358,12 @@ class StructuredUnit:
 
         return True
 
-    def _get_converter(self, other, equivalencies=[]):
+    def get_converter(self, other, equivalencies=[]):
         if not isinstance(other, type(self)):
             other = self.__class__(other, names=self)
 
         converters = [
-            self_part._get_converter(other_part, equivalencies=equivalencies)
+            self_part.get_converter(other_part, equivalencies=equivalencies)
             for (self_part, other_part) in zip(self.values(), other.values())
         ]
 
@@ -378,6 +378,8 @@ class StructuredUnit:
 
         return converter
 
+    get_converter.__doc__ = UnitBase.get_converter.__doc__
+
     def to(self, other, value=np._NoValue, equivalencies=[]):
         """Return values converted to the specified unit.
 
@@ -413,7 +415,7 @@ class StructuredUnit:
             # We do not have UNITY as a default, since then the docstring
             # would list 1.0 as default, yet one could not pass that in.
             value = UNITY
-        return self._get_converter(other, equivalencies=equivalencies)(value)
+        return self.get_converter(other, equivalencies=equivalencies)(value)
 
     def to_string(self, format="generic"):
         """Output the unit in the given format as a string.
diff --git a/astropy/utils/compat/numpycompat.py b/astropy/utils/compat/numpycompat.py
index 3adc008404..bde2e1ca00 100644
--- a/astropy/utils/compat/numpycompat.py
+++ b/astropy/utils/compat/numpycompat.py
@@ -13,6 +13,7 @@ __all__ = [
     "NUMPY_LT_1_25",
     "NUMPY_LT_1_26",
     "NUMPY_LT_2_0",
+    "NUMPY_LT_2_1",
     "COPY_IF_NEEDED",
     "sanitize_copy_arg",
 ]
@@ -24,6 +25,7 @@ NUMPY_LT_1_24 = not minversion(np, "1.24")
 NUMPY_LT_1_25 = not minversion(np, "1.25")
 NUMPY_LT_1_26 = not minversion(np, "1.26")
 NUMPY_LT_2_0 = not minversion(np, "2.0.dev")
+NUMPY_LT_2_1 = not minversion(np, "2.1.dev")
 
 
 COPY_IF_NEEDED = False if NUMPY_LT_2_0 else None
diff --git a/docs/changes/table/16093.bugfix.rst b/docs/changes/table/16093.bugfix.rst
new file mode 100644
index 0000000000..56ce2ed28d
--- /dev/null
+++ b/docs/changes/table/16093.bugfix.rst
@@ -0,0 +1,1 @@
+Calling ``Table.group_by`` on an empty table no longer raises an exception.
diff --git a/docs/changes/table/16154.api.rst b/docs/changes/table/16154.api.rst
new file mode 100644
index 0000000000..390d914d19
--- /dev/null
+++ b/docs/changes/table/16154.api.rst
@@ -0,0 +1,5 @@
+``TableColumns.setdefault()``  and ``TableColumns.update()`` methods (which
+would typically be called as ``Table.columns.setdefault()`` and
+``Table.columns.update()``) have been deprecated because they can easily
+corrupt the ``Table`` instance the ``TableColumns`` instance is attached to.
+The ``Table.setdefault()`` and ``Table.update()`` methods are safe.
diff --git a/docs/changes/time/15491.api.rst b/docs/changes/time/15491.api.rst
new file mode 100644
index 0000000000..2693e1721e
--- /dev/null
+++ b/docs/changes/time/15491.api.rst
@@ -0,0 +1,2 @@
+``TIME_FORMATS`` and ``TIME_DELTA_FORMATS`` in ``astropy.time.formats``
+are changed from ``OrderedDict`` to Python ``dict``.
diff --git a/docs/changes/units/16139.api.rst b/docs/changes/units/16139.api.rst
new file mode 100644
index 0000000000..895dc97628
--- /dev/null
+++ b/docs/changes/units/16139.api.rst
@@ -0,0 +1,5 @@
+Units now exposes ``get_converter`` which returns a function that
+will convert a scalar or array from one unit to another. This can be
+useful to speed up code that converts many quantities with the same
+unit to another one, especially if the quantity has not many elements,
+so that the overhead of creating a conversion function is relatively large.
diff --git a/docs/coordinates/angles.rst b/docs/coordinates/angles.rst
index 528b845ff8..f049484e4e 100644
--- a/docs/coordinates/angles.rst
+++ b/docs/coordinates/angles.rst
@@ -175,6 +175,12 @@ angles and wrapping to be within a single 360 degree slice. The
 :meth:`~astropy.coordinates.Angle.is_within_bounds` method returns a
 boolean indicating whether an angle or angles is within the specified bounds.
 
+.. Note::
+    While creating |Angle| instances from arrays with integral data types
+    is technically possible (for example with ``dtype=int``), it is very
+    limited in functionality and in particular wrapping is not supported for
+    such objects.
+
 
 Longitude and Latitude Objects
 ==============================
diff --git a/docs/units/conversion.rst b/docs/units/conversion.rst
index 4bf1e5f4b3..90e2e2c7a3 100644
--- a/docs/units/conversion.rst
+++ b/docs/units/conversion.rst
@@ -27,6 +27,30 @@ Arrays are permitted as arguments.
 
 .. EXAMPLE END
 
+Obtaining a Conversion Function
+-------------------------------
+
+.. EXAMPLE START: Obtaining a Conversion Function
+
+Finally, one may obtain a function that can be used to convert to the
+new unit. Normally this may seem like overkill when all one needs to
+do is multiply by a scale factor, but there are cases when the
+transformation between units may not be as simple as a single scale
+factor, for example when a custom equivalency table is in use.
+
+Conversion to different units involves obtaining a conversion function
+and then applying it to the value, or values to be converted.
+
+  >>> cms = u.cm / u.s
+  >>> cms_to_kmph = cms.get_converter(u.km / u.hour)
+  >>> cms_to_kmph(125.)
+  4.5
+  >>> cms_to_kmph([1000, 2000])
+  array([36., 72.])
+
+.. EXAMPLE END
+
+
 Incompatible Conversions
 ========================
 
diff --git a/docs/units/equivalencies.rst b/docs/units/equivalencies.rst
index e689610bbc..8fb87a63ec 100644
--- a/docs/units/equivalencies.rst
+++ b/docs/units/equivalencies.rst
@@ -15,8 +15,10 @@ define new equivalencies.
 
 Equivalencies are used by passing a list of equivalency pairs to the
 ``equivalencies`` keyword argument of `Quantity.to()
-<astropy.units.quantity.Quantity.to>` or `Unit.to()
-<astropy.units.core.UnitBase.to>` methods. The list can be supplied directly,
+<astropy.units.quantity.Quantity.to>` `Unit.to()
+<astropy.units.core.UnitBase.to>` or `Unit.get_converter()
+<astropy.units.core.UnitBase.get_converter>` methods.
+The list can be supplied directly,
 but ``astropy`` contains several functions that return appropriate lists so
 constructing them is often not necessary. Alternatively, if a larger piece of
 code needs the same equivalencies, you can set them for a :ref:`given context


Test patch:
diff --git a/astropy/table/tests/test_table.py b/astropy/table/tests/test_table.py
index 94cef55e08..a15f8e3763 100644
--- a/astropy/table/tests/test_table.py
+++ b/astropy/table/tests/test_table.py
@@ -31,7 +31,7 @@ from astropy.time import Time, TimeDelta
 from astropy.utils.compat import NUMPY_LT_1_25
 from astropy.utils.compat.optional_deps import HAS_PANDAS
 from astropy.utils.data import get_pkg_data_filename
-from astropy.utils.exceptions import AstropyUserWarning
+from astropy.utils.exceptions import AstropyDeprecationWarning, AstropyUserWarning
 from astropy.utils.metadata.tests.test_metadata import MetaBaseTest
 
 from .conftest import MIXIN_COLS, MaskedTable
@@ -3310,6 +3310,19 @@ def test_rows_with_mixins():
     t.group_by("obs")
 
 
+def test_group_by_empty_table():
+    # see https://github.com/astropy/astropy/issues/11884
+    t = Table(names=["a", "b"])
+    tg = t.group_by("a")
+
+    assert len(tg.groups.indices) == 0
+
+    keys = tg.groups.keys
+    assert isinstance(keys, Table)
+    assert keys.colnames == ["a"]
+    assert len(keys) == 0
+
+
 def test_iterrows():
     dat = [
         (1, 2, 3),
@@ -3493,3 +3506,27 @@ def test_table_hasattr_iloc():
 
     with pytest.raises(ValueError, match="for a table with indices"):
         t.loc[0]
+
+
+def test_table_columns_setdefault_deprecation():
+    with pytest.warns(
+        AstropyDeprecationWarning,
+        match=(
+            r"^The t\.columns\.setdefault\(\) function is deprecated and may be "
+            "removed in a future version.\n"
+            r"        Use t\.setdefault\(\) instead\.$"
+        ),
+    ):
+        Table().columns.setdefault("a", [0])
+
+
+def test_table_columns_update_deprecation():
+    with pytest.warns(
+        AstropyDeprecationWarning,
+        match=(
+            r"^The t\.columns\.update\(\) function is deprecated and may be "
+            "removed in a future version.\n"
+            r"        Use t\.update\(\) instead\.$"
+        ),
+    ):
+        Table().columns.update({"a": [0]})
diff --git a/astropy/units/tests/test_quantity_erfa_ufuncs.py b/astropy/units/tests/test_quantity_erfa_ufuncs.py
index 94dd0c2cae..6a4716b038 100644
--- a/astropy/units/tests/test_quantity_erfa_ufuncs.py
+++ b/astropy/units/tests/test_quantity_erfa_ufuncs.py
@@ -542,7 +542,7 @@ class TestGeodetic:
     def test_unit_errors(self):
         """Test unit errors when dimensionless parameters are used"""
 
-        msg = "'NoneType' object has no attribute '_get_converter'"
+        msg = "'NoneType' object has no attribute 'get_converter'"
         with pytest.raises(AttributeError, match=msg):
             erfa_ufunc.gc2gde(self.equatorial_radius_value, self.flattening, self.xyz)
         with pytest.raises(AttributeError, match=msg):
diff --git a/astropy/units/tests/test_units.py b/astropy/units/tests/test_units.py
index 8679965495..d2b7284be9 100644
--- a/astropy/units/tests/test_units.py
+++ b/astropy/units/tests/test_units.py
@@ -59,7 +59,17 @@ def test_invalid_compare():
 
 
 def test_convert():
-    assert u.h._get_converter(u.s)(1) == 3600
+    assert u.h.get_converter(u.s)(1) == 3600
+
+
+def test_convert_roundtrip():
+    c1 = u.cm.get_converter(u.m)
+    c2 = u.m.get_converter(u.cm)
+    np.isclose(c1(c2(10.0)), c2(c1(10.0)), atol=0, rtol=1e-15)
+
+    c1 = u.arcsec.get_converter(u.pc, u.parallax())
+    c2 = u.pc.get_converter(u.arcsec, u.parallax())
+    np.isclose(c1(c2(10.0)), c2(c1(10.0)), atol=0, rtol=1e-15)
 
 
 def test_convert_fail():
@@ -70,7 +80,7 @@ def test_convert_fail():
 
 
 def test_composite():
-    assert (u.cm / u.s * u.h)._get_converter(u.m)(1) == 36
+    assert (u.cm / u.s * u.h).get_converter(u.m)(1) == 36
     assert u.cm * u.cm == u.cm**2
 
     assert u.cm * u.cm * u.cm == u.cm**3
@@ -186,7 +196,7 @@ def test_unknown_unit3():
     assert unit not in (None, u.m)
 
     with pytest.raises(ValueError):
-        unit._get_converter(unit3)
+        unit.get_converter(unit3)
 
     _ = unit.to_string("latex")
     _ = unit2.to_string("cgs")