Repository: astropy/astropy

Issue:
Deprecate check_broadcast
### Blocked by

* https://github.com/astropy/astropy/pull/15121

### Description

Numpy 1.20.0 added a function called [broadcast_shapes](https://numpy.org/doc/stable/reference/generated/numpy.broadcast_shapes.html#numpy.broadcast_shapes) which can replace check_broadcast from astropy.utils. Deprecate check_broadcast.

### Expected behavior

N/A

### How to Reproduce

N/A

### Versions

Astropy 5.3.2

Discussion:
github-actions[bot]: 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](https://docs.astropy.org/en/latest/development/codeguide.html)?
  - [ ] Are tests added/updated as required? If so, do they follow the [Astropy testing guidelines](https://docs.astropy.org/en/latest/development/testguide.html)?
  - [ ] Are docs added/updated as required? If so, do they follow the [Astropy documentation guidelines](https://docs.astropy.org/en/latest/development/docguide.html#astropy-documentation-rules-and-guidelines)?
  - [ ] Is rebase and/or squash necessary? If so, please provide the author with appropriate instructions. Also see instructions for [rebase](https://docs.astropy.org/en/latest/development/workflow/development_workflow.html#rebase-if-necessary) and [squash](https://docs.astropy.org/en/latest/development/workflow/development_workflow.html#squash-if-necessary).
  - [ ] 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](https://docs.astropy.org/en/latest/development/workflow/development_workflow.html#pre-commit).
  - [ ] 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.
github-actions[bot]: 👋 Thank you for your draft pull request! Do you know that you can use `[ci skip]` or `[skip ci]` in your commit messages to skip running continuous integration tests until you are ready?
neutrinoceros: @mhvk thanks for your review. I think I addressed all your minor comments. I'll circle back to [the more important one](https://github.com/astropy/astropy/pull/16346#discussion_r1581833541) on Monday !
neutrinoceros: Thanks for reminding me. I updated the test with your suggestion then squashed all commits together.

Patch:
diff --git a/astropy/coordinates/baseframe.py b/astropy/coordinates/baseframe.py
index 83ec945b2c..459caefb9c 100644
--- a/astropy/coordinates/baseframe.py
+++ b/astropy/coordinates/baseframe.py
@@ -21,9 +21,9 @@ from typing import TYPE_CHECKING, NamedTuple
 import numpy as np
 
 from astropy import units as u
-from astropy.utils import ShapedLikeNDArray, check_broadcast
+from astropy.utils import ShapedLikeNDArray
 from astropy.utils.decorators import deprecated, format_doc, lazyproperty
-from astropy.utils.exceptions import AstropyWarning
+from astropy.utils.exceptions import AstropyWarning, _add_note_to_exception
 
 from . import representation as r
 from .angles import Angle, position_angle
@@ -344,11 +344,13 @@ class BaseCoordinateFrame(ShapedLikeNDArray):
 
         # Determine the overall shape of the frame.
         try:
-            self._shape = check_broadcast(*shapes)
-        except ValueError as err:
-            raise ValueError(
-                f"non-scalar data and/or attributes with inconsistent shapes: {shapes}"
-            ) from err
+            self._shape = np.broadcast_shapes(*shapes)
+        except ValueError as exc:
+            _add_note_to_exception(
+                exc,
+                f"non-scalar data and/or attributes with inconsistent shapes: {shapes}",
+            )
+            raise exc
 
         # Broadcast the data if necessary and set it
         if data is not None and data.shape != self._shape:
diff --git a/astropy/modeling/core.py b/astropy/modeling/core.py
index 7d21ed52a6..b469f32cdf 100644
--- a/astropy/modeling/core.py
+++ b/astropy/modeling/core.py
@@ -19,6 +19,8 @@ import copy
 import functools
 import inspect
 import operator
+import re
+import warnings
 from collections import defaultdict, deque
 from inspect import signature
 from textwrap import indent
@@ -30,8 +32,6 @@ from astropy.table import Table
 from astropy.units import Quantity, UnitsError, dimensionless_unscaled
 from astropy.units.utils import quantity_asanyarray
 from astropy.utils import (
-    IncompatibleShapeError,
-    check_broadcast,
     find_current_module,
     isiterable,
     metadata,
@@ -39,6 +39,7 @@ from astropy.utils import (
 )
 from astropy.utils.codegen import make_function_with_signature
 from astropy.utils.compat import COPY_IF_NEEDED
+from astropy.utils.exceptions import _add_note_to_exception
 
 from .bounding_box import CompoundBoundingBox, ModelBoundingBox
 from .parameters import InputParameterError, Parameter, _tofloat, param_repr_oneline
@@ -1063,11 +1064,12 @@ class Model(metaclass=_ModelMeta):
             )
 
         try:
-            input_shape = check_broadcast(*all_shapes)
-        except IncompatibleShapeError as e:
-            raise ValueError(
-                "All inputs must have identical shapes or must be scalars."
-            ) from e
+            input_shape = np.broadcast_shapes(*all_shapes)
+        except ValueError as exc:
+            _add_note_to_exception(
+                exc, "All inputs must have identical shapes or must be scalars."
+            )
+            raise exc
 
         return input_shape
 
@@ -1949,15 +1951,17 @@ class Model(metaclass=_ModelMeta):
             for param in params:
                 try:
                     if self.standard_broadcasting:
-                        broadcast = check_broadcast(input_shape, param.shape)
+                        broadcast = np.broadcast_shapes(input_shape, param.shape)
                     else:
                         broadcast = input_shape
-                except IncompatibleShapeError:
-                    raise ValueError(
+                except ValueError as exc:
+                    _add_note_to_exception(
+                        exc,
                         f"self input argument {self.inputs[idx]!r} of shape"
                         f" {input_shape!r} cannot be broadcast with parameter"
-                        f" {param.name!r} of shape {param.shape!r}."
+                        f" {param.name!r} of shape {param.shape!r}.",
                     )
+                    raise exc
 
                 if len(broadcast) > len(max_broadcast):
                     max_broadcast = broadcast
@@ -2012,17 +2016,19 @@ class Model(metaclass=_ModelMeta):
 
             for param in params:
                 try:
-                    check_broadcast(
+                    np.broadcast_shapes(
                         input_shape,
                         self._remove_axes_from_shape(param.shape, model_set_axis_param),
                     )
-                except IncompatibleShapeError:
-                    raise ValueError(
+                except ValueError as exc:
+                    _add_note_to_exception(
+                        exc,
                         f"Model input argument {self.inputs[idx]!r} of shape"
                         f" {input_shape!r} "
                         f"cannot be broadcast with parameter {param.name!r} of shape "
-                        f"{self._remove_axes_from_shape(param.shape, model_set_axis_param)!r}."
+                        f"{self._remove_axes_from_shape(param.shape, model_set_axis_param)!r}.",
                     )
+                    raise exc
 
                 if len(param.shape) - 1 > len(max_param_shape):
                     max_param_shape = self._remove_axes_from_shape(
@@ -2227,11 +2233,23 @@ class Model(metaclass=_ModelMeta):
 
     def _prepare_outputs_single_model(self, outputs, broadcasted_shapes):
         outputs = list(outputs)
+        shapes = broadcasted_shapes[0]
         for idx, output in enumerate(outputs):
-            try:
-                broadcast_shape = check_broadcast(*broadcasted_shapes[0])
-            except (IndexError, TypeError):
-                broadcast_shape = broadcasted_shapes[0][idx]
+            if None in shapes:
+                # Previously, we used our own function (check_broadcast) instead
+                # of np.broadcast_shapes in the following try block
+                # - check_broadcast raised an exception when passed a None.
+                # - as of numpy 1.26, np.broadcast raises a deprecation warning
+                # when passed a `None` value, but returns an empty tuple.
+                #
+                # Since () and None have different effects downstream of this function,
+                # and to preserve backward-compatibility, we handle this special here
+                broadcast_shape = shapes[idx]
+            else:
+                try:
+                    broadcast_shape = np.broadcast_shapes(*shapes)
+                except Exception:
+                    broadcast_shape = shapes[idx]
 
             outputs[idx] = self._prepare_output_single_model(output, broadcast_shape)
 
@@ -2742,18 +2760,45 @@ class Model(metaclass=_ModelMeta):
 
         # Now check mutual broadcastability of all shapes
         try:
-            check_broadcast(*all_shapes)
-        except IncompatibleShapeError as exc:
-            shape_a, shape_a_idx, shape_b, shape_b_idx = exc.args
-            param_a = self.param_names[shape_a_idx]
-            param_b = self.param_names[shape_b_idx]
-
-            raise InputParameterError(
-                f"Parameter {param_a!r} of shape {shape_a!r} cannot be broadcast with "
-                f"parameter {param_b!r} of shape {shape_b!r}.  All parameter arrays "
+            np.broadcast_shapes(*all_shapes)
+        except ValueError as exc:
+            # In a previous version, we used to have our own version of
+            # np.broadcast_shapes (check_broadcast). In order to preserve
+            # backward compatibility, we now have to go the extra mile and
+            # parse an error message controlled by numpy.
+            base_message = (
+                "All parameter arrays "
                 "must have shapes that are mutually compatible according "
                 "to the broadcasting rules."
             )
+            broadcast_shapes_error_re = re.compile(
+                r"shape mismatch: objects cannot be broadcast to a single shape\.  "
+                r"Mismatch is between "
+                r"arg (?P<argno_a>\d+) with shape (?P<shape_a>\((\d+(, ?)?)+\)) and "
+                r"arg (?P<argno_b>\d+) with shape (?P<shape_b>\((\d+(, ?)?)+\))\."
+            )
+            if (match := broadcast_shapes_error_re.fullmatch(str(exc))) is not None:
+                shape_a = match.group("shape_a")
+                shape_b = match.group("shape_b")
+                shape_a_idx = int(match.group("argno_a"))
+                shape_b_idx = int(match.group("argno_b"))
+                param_a = self.param_names[shape_a_idx]
+                param_b = self.param_names[shape_b_idx]
+                message = (
+                    f"Parameter {param_a!r} of shape {shape_a} cannot be broadcast with "
+                    f"parameter {param_b!r} of shape {shape_b}."
+                )
+            else:
+                warnings.warn(
+                    "Failed to parse error message from np.broadcast_shapes. "
+                    "Please report this at "
+                    "https://github.com/astropy/astropy/issues/new/choose",
+                    category=RuntimeWarning,
+                    stacklevel=1,
+                )
+                message = "Some parameters failed to broadcast with each other."
+
+            raise InputParameterError(f"{message} {base_message}") from None
 
     def _param_sets(self, raw=False, units=False):
         """
diff --git a/astropy/modeling/polynomial.py b/astropy/modeling/polynomial.py
index 07cdf0f25a..d67cfb3154 100644
--- a/astropy/modeling/polynomial.py
+++ b/astropy/modeling/polynomial.py
@@ -10,7 +10,6 @@ from textwrap import indent
 
 import numpy as np
 
-from astropy.utils import check_broadcast
 from astropy.utils.compat import COPY_IF_NEEDED
 
 from .core import FittableModel, Model
@@ -1189,7 +1188,7 @@ class Polynomial2D(PolynomialModel):
         # still as expected by the broadcasting rules, even though the x and y
         # inputs are not used in the evaluation
         if self.degree == 0:
-            output_shape = check_broadcast(np.shape(coeffs[0]), x.shape)
+            output_shape = np.broadcast_shapes(np.shape(coeffs[0]), x.shape)
             if output_shape:
                 new_result = np.empty(output_shape)
                 new_result[:] = result
diff --git a/astropy/utils/exceptions.py b/astropy/utils/exceptions.py
index 6a301ecc94..bcf7291a21 100644
--- a/astropy/utils/exceptions.py
+++ b/astropy/utils/exceptions.py
@@ -75,6 +75,17 @@ class _NoValue:
 NoValue = _NoValue()
 
 
+def _add_note_to_exception(exc: Exception, note: str) -> None:
+    import sys
+
+    if sys.version_info >= (3, 11):
+        exc.add_note(note)
+    else:
+        # mimic Python 3.11 behavior:
+        # preserve error message and traceback
+        exc.args += ("\n", note)
+
+
 def __getattr__(name: str):
     if name in ("ErfaError", "ErfaWarning"):
         import warnings
diff --git a/astropy/utils/shapes.py b/astropy/utils/shapes.py
index b2f2114f29..e419ad2e17 100644
--- a/astropy/utils/shapes.py
+++ b/astropy/utils/shapes.py
@@ -11,6 +11,7 @@ from typing import TYPE_CHECKING
 import numpy as np
 
 from astropy.utils.compat import NUMPY_LT_2_0
+from astropy.utils.decorators import deprecated
 
 if NUMPY_LT_2_0:
     import numpy.core as np_core
@@ -356,6 +357,7 @@ class IncompatibleShapeError(ValueError):
         super().__init__(shape_a, shape_a_idx, shape_b, shape_b_idx)
 
 
+@deprecated("7.0", alternative="np.broadcast_shapes")
 def check_broadcast(*shapes: tuple[int, ...]) -> tuple[int, ...]:
     """
     Determines whether two or more Numpy arrays can be broadcast with each
diff --git a/docs/changes/utils/16346.api.rst b/docs/changes/utils/16346.api.rst
new file mode 100644
index 0000000000..79e99ed688
--- /dev/null
+++ b/docs/changes/utils/16346.api.rst
@@ -0,0 +1,2 @@
+``astropy.utils.check_broadcast`` is now deprecated in favor of
+``numpy.broadcast_shapes``


Test patch:
diff --git a/astropy/coordinates/tests/test_frames.py b/astropy/coordinates/tests/test_frames.py
index c42d6f65b6..bf378976e3 100644
--- a/astropy/coordinates/tests/test_frames.py
+++ b/astropy/coordinates/tests/test_frames.py
@@ -1,6 +1,7 @@
 # Licensed under a 3-clause BSD style license - see LICENSE.rst
 
 import re
+import sys
 from copy import deepcopy
 
 import numpy as np
@@ -38,6 +39,7 @@ from astropy.coordinates.representation import (
     REPRESENTATION_CLASSES,
     CartesianDifferential,
 )
+from astropy.tests.helper import PYTEST_LT_8_0
 from astropy.tests.helper import assert_quantity_allclose as assert_allclose
 from astropy.time import Time
 from astropy.units import allclose
@@ -254,12 +256,22 @@ def test_no_data_nonscalar_frames():
     assert a1.obstime.shape == (3, 10)
     assert a1.temperature.shape == (3, 10)
     assert a1.shape == (3, 10)
-    with pytest.raises(ValueError) as exc:
+
+    if sys.version_info >= (3, 11) and PYTEST_LT_8_0:
+        # Exception.__notes__ are available (and used here) but ignored in matching,
+        # so we'll match manually and post-mortem instead
+        match = None
+    else:
+        match = r".*inconsistent shapes.*"
+
+    with pytest.raises(ValueError, match=match) as exc:
         AltAz(
             obstime=Time("2012-01-01") + np.arange(10.0) * u.day,
             temperature=np.ones((3,)) * u.deg_C,
         )
-    assert "inconsistent shapes" in str(exc.value)
+
+    if match is None:
+        assert "inconsistent shapes" in "\n".join(exc.value.__notes__)
 
 
 def test_frame_repr():
diff --git a/astropy/modeling/tests/test_input.py b/astropy/modeling/tests/test_input.py
index 28ac50b9f5..c42dc94995 100644
--- a/astropy/modeling/tests/test_input.py
+++ b/astropy/modeling/tests/test_input.py
@@ -436,11 +436,7 @@ class TestSingleInputSingleOutputSingleModel:
         assert np.shape(y2) == (2, 2)
         assert np.all(y2 == [[111, 122], [211, 222]])
 
-        MESSAGE = (
-            r"self input argument 'x' of shape .* cannot be broadcast with parameter"
-            r" 'p1' of shape .*"
-        )
-        with pytest.raises(ValueError, match=MESSAGE):
+        with pytest.raises(ValueError, match="broadcast"):
             # Doesn't broadcast
             t([100, 200, 300])
 
@@ -466,11 +462,7 @@ class TestSingleInputSingleOutputSingleModel:
             ]
         )
 
-        MESSAGE = (
-            r"self input argument .* of shape .* cannot be broadcast with parameter .*"
-            r" of shape .*"
-        )
-        with pytest.raises(ValueError, match=MESSAGE):
+        with pytest.raises(ValueError, match="broadcast"):
             # Doesn't broadcast
             t([[100, 200, 300], [400, 500, 600]])
 
@@ -654,11 +646,7 @@ class TestSingleInputSingleOutputTwoModel:
         assert np.shape(y1) == (2, 3)
         assert np.all(y1 == [[111, 122, 133], [244, 255, 266]])
 
-        MESSAGE = (
-            r"Model input argument .* of shape .* cannot be broadcast with parameter .*"
-            r" of shape .*"
-        )
-        with pytest.raises(ValueError, match=MESSAGE):
+        with pytest.raises(ValueError, match="broadcast"):
             # Doesn't broadcast with the shape of the parameters, (3,)
             y2 = t([100, 200], model_set_axis=False)
 
@@ -682,11 +670,7 @@ class TestSingleInputSingleOutputTwoModel:
             ]
         )
 
-        MESSAGE = (
-            r"Model input argument .* of shape .* cannot be broadcast with parameter .*"
-            r" of shape .*"
-        )
-        with pytest.raises(ValueError, match=MESSAGE):
+        with pytest.raises(ValueError, match="broadcast"):
             y2 = t([[100, 200, 300], [400, 500, 600]])
 
         y2 = t([[[100, 200], [300, 400]], [[500, 600], [700, 800]]])
@@ -845,11 +829,7 @@ class TestSingleInputDoubleOutputSingleModel:
         assert np.all(y2 == [[111, 122], [211, 222]])
         assert np.all(z2 == [[1111, 2122], [1211, 2222]])
 
-        MESSAGE = (
-            r"self input argument .* of shape .* cannot be broadcast with parameter .*"
-            r" of shape .*"
-        )
-        with pytest.raises(ValueError, match=MESSAGE):
+        with pytest.raises(ValueError, match="broadcast"):
             # Doesn't broadcast
             y3, z3 = t([100, 200, 300])
 
@@ -885,11 +865,7 @@ class TestSingleInputDoubleOutputSingleModel:
             ]
         )
 
-        MESSAGE = (
-            r"self input argument .* of shape .* cannot be broadcast with parameter .*"
-            r" of shape .*"
-        )
-        with pytest.raises(ValueError, match=MESSAGE):
+        with pytest.raises(ValueError, match="broadcast"):
             # Doesn't broadcast
             y3, z3 = t([[100, 200, 300], [400, 500, 600]])
 
diff --git a/astropy/modeling/tests/test_models.py b/astropy/modeling/tests/test_models.py
index bb6b4d1d17..04fa9999f4 100644
--- a/astropy/modeling/tests/test_models.py
+++ b/astropy/modeling/tests/test_models.py
@@ -125,9 +125,7 @@ def test_inconsistent_input_shapes():
     g = Gaussian2D()
     x = np.arange(-1.0, 1, 0.2)
     y = np.arange(-1.0, 1, 0.1)
-    with pytest.raises(
-        ValueError, match="All inputs must have identical shapes or must be scalars"
-    ):
+    with pytest.raises(ValueError, match="broadcast"):
         g(x, y)
 
 
diff --git a/astropy/utils/tests/test_shapes.py b/astropy/utils/tests/test_shapes.py
index 1173fd22ac..b4f1b0456b 100644
--- a/astropy/utils/tests/test_shapes.py
+++ b/astropy/utils/tests/test_shapes.py
@@ -6,9 +6,16 @@ from hypothesis import given
 from hypothesis.extra.numpy import basic_indices
 from numpy.testing import assert_equal
 
+from astropy.utils.exceptions import AstropyDeprecationWarning
 from astropy.utils.shapes import check_broadcast, simplify_basic_index, unbroadcast
 
 
+def test_check_broadcast_deprecation():
+    with pytest.warns(AstropyDeprecationWarning):
+        check_broadcast((1,), (2,))
+
+
+@pytest.mark.filterwarnings("ignore")
 def test_check_broadcast():
     assert check_broadcast((10, 1), (3,)) == (10, 3)
     assert check_broadcast((10, 1), (3,), (4, 1, 1, 3)) == (4, 1, 10, 3)