diff --git a/CHANGES.rst b/CHANGES.rst
index 67d0d76684a9..085cc9382632 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -71,7 +71,12 @@ astropy.modeling
 - Add ``Tabular1D.inverse`` [#9083]
 - Significant reorganization of the documentation. [#9078, #9171]

-- ``Model.rename`` was changed to add the ability to rename ``Model.inputs`` and ``Model.outputs``. [#9220]
+- ``Model.rename`` was changed to add the ability to rename ``Model.inputs``
+  and ``Model.outputs``. [#9220]
+
+- New function ``fix_inputs`` to generate new models from others by fixing
+  specific inputs variable values to constants. [#9135]
+

 astropy.nddata
 ^^^^^^^^^^^^^^
diff --git a/astropy/io/misc/asdf/tags/transform/compound.py b/astropy/io/misc/asdf/tags/transform/compound.py
index b228c0f9de6d..aa41693138d9 100644
--- a/astropy/io/misc/asdf/tags/transform/compound.py
+++ b/astropy/io/misc/asdf/tags/transform/compound.py
@@ -22,7 +22,8 @@
     '/'  : 'divide',
     '**' : 'power',
     '|'  : 'compose',
-    '&'  : 'concatenate'
+    '&'  : 'concatenate',
+    'fix_inputs': 'fix_inputs'
 }

@@ -33,7 +34,8 @@
     'divide'      : '__truediv__',
     'power'       : '__pow__',
     'compose'     : '__or__',
-    'concatenate' : '__and__'
+    'concatenate' : '__and__',
+    'fix_inputs'  : 'fix_inputs'
 }

@@ -54,10 +56,15 @@ def from_tree_tagged(cls, node, ctx):
                 node['forward'][0]._tag))
         right = yamlutil.tagged_tree_to_custom_tree(
             node['forward'][1], ctx)
-        if not isinstance(right, Model):
+        if not isinstance(right, Model) and \
+            not (oper == 'fix_inputs' and isinstance(right, dict)):
             raise TypeError("Unknown model type '{0}'".format(
                 node['forward'][1]._tag))
-        model = getattr(left, oper)(right)
+        if oper == 'fix_inputs':
+            right = dict(zip(right['keys'], right['values']))
+            model = CompoundModel('fix_inputs', left, right)
+        else:
+            model = getattr(left, oper)(right)

         model = cls._from_tree_base_transform_members(model, node, ctx)
         model.map_parameters()
@@ -73,8 +80,13 @@ def _to_tree_from_model_tree(cls, tree, ctx):
             left = cls._to_tree_from_model_tree(tree.left, ctx)

         if not isinstance(tree.right, CompoundModel):
-            right = yamlutil.custom_tree_to_tagged_tree(
-                tree.right, ctx)
+            if isinstance(tree.right, dict):
+                right = {'keys': list(tree.right.keys()),
+                         'values': list(tree.right.values())
+                        }
+            else:
+                right = yamlutil.custom_tree_to_tagged_tree(
+                    tree.right, ctx)
         else:
             right = cls._to_tree_from_model_tree(tree.right, ctx)

diff --git a/astropy/modeling/core.py b/astropy/modeling/core.py
index debfa057e753..6af24eee49eb 100644
--- a/astropy/modeling/core.py
+++ b/astropy/modeling/core.py
@@ -47,7 +47,7 @@

 __all__ = ['Model', 'FittableModel', 'Fittable1DModel', 'Fittable2DModel',
-           'CompoundModel', 'custom_model', 'ModelDefinitionError']
+           'CompoundModel', 'fix_inputs', 'custom_model', 'ModelDefinitionError']

 def _model_oper(oper, **kwargs):
@@ -103,6 +103,7 @@ def __new__(mcls, name, bases, members):
             ('__pow__', _model_oper('**')),
             ('__or__', _model_oper('|')),
             ('__and__', _model_oper('&')),
+            ('_fix_inputs', _model_oper('fix_inputs'))
         ]

         for opermethod, opercall in opermethods:
@@ -455,6 +456,7 @@ def __init__(self, *params, **kwargs):
     __pow__ = _model_oper('**')
     __or__ = _model_oper('|')
     __and__ = _model_oper('&')
+    _fix_inputs = _model_oper('fix_inputs')

     # *** Other utilities ***

@@ -2315,14 +2317,13 @@ def __init__(self, op, left, right, name=None, inverse=None):
         self._has_inverse = False  # may be set to True in following code
         if inverse:
             self.inverse = inverse
-        # else:
-        #     self._user_inverse = None
-        if len(left) != len(right):
+
+        if op != 'fix_inputs' and len(left) != len(right):
             raise ValueError(
                 'Both operands must have equal values for n_models')
         else:
             self._n_models = len(left)
-        if ((left.model_set_axis != right.model_set_axis)
+        if op != 'fix_inputs' and ((left.model_set_axis != right.model_set_axis)
                 or left.model_set_axis):  # not False and not 0
             raise ValueError("model_set_axis must be False or 0 and consistent for operands")
         else:
@@ -2368,15 +2369,62 @@ def __init__(self, op, left, right, name=None, inverse=None):
                                               self.right.inverse,
                                               self.left.inverse,
                                               inverse=self)
+        elif op == 'fix_inputs':
+            if not isinstance(left, Model):
+                raise ValueError('First argument to "fix_inputs" must be an instance of an astropy Model.')
+            if not isinstance(right, dict):
+                raise ValueError('Expected a dictionary for second argument of "fix_inputs".')
+
+            # Dict keys must match either possible indices
+            # for model on left side, or names for inputs.
+            self.n_inputs = left.n_inputs - len(right)
+            self.outputs = left.outputs
+            self.n_outputs = left.n_outputs
+            newinputs = list(left.inputs)
+            keys = right.keys()
+            input_ind = []
+            for key in keys:
+                if isinstance(key, int):
+                    if key >= left.n_inputs or key < 0:
+                        raise ValueError(
+                            'Substitution key integer value '
+                            'not among possible input choices.')
+                    else:
+                        if key in input_ind:
+                            raise ValueError("Duplicate specification of "
+                                             "same input (index/name).")
+                        else:
+                            input_ind.append(key)
+                elif isinstance(key, str):
+                    if key not in left.inputs:
+                        raise ValueError(
+                            'Substitution key string not among possible '
+                            'input choices.')
+                    # Check to see it doesn't match positional
+                    # specification.
+                    ind = left.inputs.index(key)
+                    if ind in input_ind:
+                        raise ValueError("Duplicate specification of "
+                                         "same input (index/name).")
+                    else:
+                        input_ind.append(ind)
+            # Remove substituted inputs
+            input_ind.sort()
+            input_ind.reverse()
+            for ind in input_ind:
+                del newinputs[ind]
+            self.inputs = tuple(newinputs)
+            # Now check to see if the input model has bounding_box defined.
+            # If so, remove the appropriate dimensions and set it for this
+            # instance.
+            try:
+                bounding_box = self.left.bounding_box
+                self._fix_input_bounding_box(input_ind)
+            except NotImplementedError:
+                pass
+
         else:
-            #raise ModelDefinitionError('Illegal operator: ', self.op)
-            raise ModelDefinitionError(
-                "Unsupported operands for {0}: {1} (n_inputs={2}, "
-                "n_outputs={3}) and {4} (n_inputs={5}, n_outputs={6}); "
-                "models must have the same n_inputs and the same "
-                "n_outputs for this operator".format(
-                    operator, left.name, left.n_inputs, left.n_outputs,
-                    right.name, right.n_inputs, right.n_outputs))
+            raise ModelDefinitionError('Illegal operator: ', self.op)
         self.name = name
         self._fittable = None
         self.fit_deriv = None
@@ -2475,38 +2523,81 @@ def __call__(self, *args, **kw):

     def _evaluate(self, *args, **kw):
         op = self.op
-        if op != '&':
-            leftval = self.left(*args, **kw)
-            if op != '|':
-                rightval = self.right(*args, **kw)
-        else:
-            leftval = self.left(*(args[:self.left.n_inputs]), **kw)
-            rightval = self.right(*(args[self.left.n_inputs:]), **kw)
-        if op == '+':
-            return binary_operation(operator.add, leftval, rightval)
-        elif op == '-':
-            return binary_operation(operator.sub, leftval, rightval)
-        elif op == '*':
-            return binary_operation(operator.mul, leftval, rightval)
-        elif op == '/':
-            return binary_operation(operator.truediv, leftval, rightval)
-        elif op == '**':
-            return binary_operation(operator.pow, leftval, rightval)
-        elif op == '&':
-            if not isinstance(leftval, tuple):
-                leftval = (leftval,)
-            if not isinstance(rightval, tuple):
-                rightval = (rightval,)
-            return leftval + rightval
-        elif op == '|':
-            if isinstance(leftval, tuple):
-                return self.right(*leftval, **kw)
+        if op != 'fix_inputs':
+            if op != '&':
+                leftval = self.left(*args, **kw)
+                if op != '|':
+                    rightval = self.right(*args, **kw)
+
+            else:
+                leftval = self.left(*(args[:self.left.n_inputs]), **kw)
+                rightval = self.right(*(args[self.left.n_inputs:]), **kw)
+            if op == '+':
+                return binary_operation(operator.add, leftval, rightval)
+            elif op == '-':
+                return binary_operation(operator.sub, leftval, rightval)
+            elif op == '*':
+                return binary_operation(operator.mul, leftval, rightval)
+            elif op == '/':
+                return binary_operation(operator.truediv, leftval, rightval)
+            elif op == '**':
+                return binary_operation(operator.pow, leftval, rightval)
+            elif op == '&':
+                if not isinstance(leftval, tuple):
+                    leftval = (leftval,)
+                if not isinstance(rightval, tuple):
+                    rightval = (rightval,)
+                return leftval + rightval
+            elif op == '|':
+                if isinstance(leftval, tuple):
+                    return self.right(*leftval, **kw)
+                else:
+                    return self.right(leftval, **kw)
+            elif op in SPECIAL_OPERATORS:
+                return binary_operation(SPECIAL_OPERATORS[op], leftval, rightval)
             else:
-                return self.right(leftval, **kw)
-        elif op in SPECIAL_OPERATORS:
-            return binary_operation(SPECIAL_OPERATORS[op], leftval, rightval)
+
+                raise ModelDefinitionError('Unrecognized operator {op}')
         else:
-            raise ModelDefinitionError('Unrecognized operator {op}')
+            subs = self.right
+            newargs = list(args)
+            subinds = []
+            subvals = []
+            for key in subs.keys():
+                if isinstance(key, int):
+                    subinds.append(key)
+                elif isinstance(key, str):
+                    ind = self.left.inputs.index(key)
+                    subinds.append(ind)
+                subvals.append(subs[key])
+            # Turn inputs specified in kw into positional indices.
+            # Names for compound inputs do not propagate to sub models.
+            kwind = []
+            kwval = []
+            for kwkey in list(kw.keys()):
+                if kwkey in self.inputs:
+                    ind = self.inputs.index(kwkey)
+                    if ind < len(args):
+                        raise ValueError("Keyword argument duplicates "
+                                        "positional value supplied.")
+                    kwind.append(ind)
+                    kwval.append(kw[kwkey])
+                    del kw[kwkey]
+            # Build new argument list
+            # Append keyword specified args first
+            if kwind:
+                kwargs = list(zip(kwind, kwval))
+                kwargs.sort()
+                kwindsorted, kwvalsorted = list(zip(*kwargs))
+                newargs = newargs + list(kwvalsorted)
+            if subinds:
+                subargs = list(zip(subinds, subvals))
+                subargs.sort()
+                subindsorted, subvalsorted = list(zip(*subargs))
+            # The substitutions must be inserted in order
+            for ind, val in subargs:
+                newargs.insert(ind, val)
+            return self.left(*newargs, **kw)

     @property
     def param_names(self):
@@ -2735,14 +2826,6 @@ def isleaf(self):
     def has_inverse(self):
         return self._has_inverse

-    @property
-    def bounding_box(self):
-        return self._bounding_box
-
-    @bounding_box.setter
-    def bounding_box(self, bounding_box):
-        self._bounding_box = bounding_box
-
     @property
     def inverse(self):
         if self.has_inverse():
@@ -2864,13 +2947,14 @@ def map_parameters(self):
         param_map = {}
         self._param_names = []
         for lindex, leaf in enumerate(self._leaflist):
-            for param_name in leaf.param_names:
-                param = getattr(leaf, param_name)
-                new_param_name = "{}_{}".format(param_name, lindex)
-                self.__dict__[new_param_name] = param
-                self._parameters_[new_param_name] = param
-                self._param_names.append(new_param_name)
-                param_map[new_param_name] = (lindex, param_name)
+            if not isinstance(leaf, dict):
+                for param_name in leaf.param_names:
+                    param = getattr(leaf, param_name)
+                    new_param_name = "{}_{}".format(param_name, lindex)
+                    self.__dict__[new_param_name] = param
+                    self._parameters_[new_param_name] = param
+                    self._param_names.append(new_param_name)
+                    param_map[new_param_name] = (lindex, param_name)
         self._param_metrics = {}
         self._param_map =  param_map
         self._param_map_inverse = dict((v, k) for k, v in param_map.items())
@@ -3207,6 +3291,21 @@ def bounding_box(self, bounding_box):
     def bounding_box(self):
         self._user_bounding_box = None

+    def _fix_input_bounding_box(self, input_ind):
+        """
+        If the ``fix_inputs`` operator is used and the model it is applied to
+        has a bounding box definition, delete the corresponding inputs from
+        that bounding box. This method presumes the bounding_box is not None.
+        This also presumes that the list of input indices to remove (i.e.,
+        input_ind has already been put in reverse sorted order).
+        """
+        bounding_box = list(self.left.bounding_box)
+        for ind in input_ind:
+            del bounding_box[ind]
+        if self.n_inputs == 1:
+            bounding_box = bounding_box[0]
+        self.bounding_box = bounding_box
+
     @property
     def has_user_bounding_box(self):
         """
@@ -3403,7 +3502,7 @@ def make_subtree_dict(tree, nodepath, tdict, leaflist):
         tdict[nodepath] = (tree, leftmostind, rightmostind)

-_ORDER_OF_OPERATORS = [('|',), ('&',), ('+', '-'), ('*', '/'), ('**',)]
+_ORDER_OF_OPERATORS = [('fix_inputs',),('|',), ('&',), ('+', '-'), ('*', '/'), ('**',)]
 OPERATOR_PRECEDENCE = {}
 for idx, ops in enumerate(_ORDER_OF_OPERATORS):
     for op in ops:
@@ -3411,6 +3510,31 @@ def make_subtree_dict(tree, nodepath, tdict, leaflist):
 del idx, op, ops

+def fix_inputs(modelinstance, values):
+    """
+    This function creates a compound model with one or more of the input
+    values of the input model assigned fixed values (scalar or array).
+
+    Parameters
+    ----------
+    modelinstance : Model instance. This is the model that one or more of the
+        model input values will be fixed to some constant value.
+    values : A dictionary where the key identifies which input to fix
+        and its value is the value to fix it at. The key may either be the
+        name of the input or a number reflecting its order in the inputs.
+
+    Examples
+    --------
+
+    >>> from astropy.modeling.models import Gaussian2D
+    >>> g = Gaussian2D(1, 2, 3, 4, 5)
+    >>> gv = fix_inputs(g, {0: 2.5})
+
+    Results in a 1D function equivalent to Gaussian2D(1, 2, 3, 4, 5)(x=2.5, y)
+    """
+    return CompoundModel('fix_inputs', modelinstance, values)
+
+
 def custom_model(*args, fit_deriv=None, **kwargs):
     """
     Create a model from a user defined function. The inputs and parameters of
diff --git a/astropy/modeling/models.py b/astropy/modeling/models.py
index 6f6dbf348977..87f9ff07019e 100644
--- a/astropy/modeling/models.py
+++ b/astropy/modeling/models.py
@@ -5,7 +5,7 @@
 """

-from .core import custom_model, hide_inverse  # pylint: disable=W0611
+from .core import custom_model, hide_inverse, fix_inputs # pylint: disable=W0611
 from .mappings import *
 from .projections import *
 from .rotations import *
diff --git a/docs/modeling/compound-models.rst b/docs/modeling/compound-models.rst
index 54c35cce1532..a509bdf5d430 100644
--- a/docs/modeling/compound-models.rst
+++ b/docs/modeling/compound-models.rst
@@ -1369,3 +1369,34 @@ This opens up the possibility of essentially arbitrarily complex transformation
 graphs.  Currently the tools do not exist to make it easy to navigate and
 reason about highly complex compound models that use these mappings, but that
 is a possible enhancement for future versions.
+
+
+Model Reduction
+---------------
+
+In order to save much duplication in the construction of complex models, it is
+possible to define one complex model that covers all cases where the
+variables that distinguish the models are made part of the model's input
+variables. The ``fix_inputs`` function allows defining models derived from
+the more complex one by setting one or more of the inputs to a constant
+value. Examples of this sort of situation arise when working out
+the transformations from detector pixel to RA, Dec, and lambda for
+spectrographs when the slit locations may be moved (e.g., fiber fed or
+commandable slit masks), or different orders may be selected (e.g., Eschelle).
+In the case of order, one may have a function of pixel ``x``, ``y``, ``spectral_order``
+that map into ``RA``, ``Dec`` and ``lambda``. Without specifying ``spectral_order``, it is
+ambiguious what ``RA``, ``Dec`` and ``Lambda`` corresponds to a pixel location. It
+is usually possible to define a function of all three inputs. Presuming
+this model is ``general_transform`` then ``fix_inputs`` may be used to define
+the transform for a specific order as follows:
+
+::
+     >>> order1_transform = fix_inputs(general_transform, {'order': 1})  # doctest: +SKIP
+
+creates a new compound model that takes only pixel position and generates
+``RA``, ``Dec``, and ``lambda``. The ``fix_inputs`` function can be used to set input
+values by position (0 is the first) or by input variable name, and more
+than one can be set in the dictionary supplied.
+
+If the input model has a bounding_box, the generated model will have the
+bounding for the input coordinate removed.

diff --git a/astropy/io/misc/asdf/tags/transform/tests/test_transform.py b/astropy/io/misc/asdf/tags/transform/tests/test_transform.py
index e3ba12bfc0a2..173e642d30d1 100644
--- a/astropy/io/misc/asdf/tags/transform/tests/test_transform.py
+++ b/astropy/io/misc/asdf/tags/transform/tests/test_transform.py
@@ -11,6 +11,7 @@
 from asdf.tests import helpers

 import astropy.units as u
+from astropy.modeling.core import fix_inputs
 from astropy.modeling import models as astmodels

 test_models = [
@@ -165,3 +166,27 @@ def test_tabular_model_units(tmpdir):
                                  method='nearest')
     tree = {'model': model2}
     helpers.assert_roundtrip_tree(tree, tmpdir)
+
+
+def test_fix_inputs(tmpdir):
+    model = astmodels.Pix2Sky_TAN() | astmodels.Rotation2D()
+    tree = {
+        'compound': fix_inputs(model, {'x': 45}),
+        'compound1': fix_inputs(model, {0: 45})
+    }
+
+    helpers.assert_roundtrip_tree(tree, tmpdir)
+
+
+def test_fix_inputs_type():
+    with pytest.raises(TypeError):
+        tree = {
+        'compound': fix_inputs(3, {'x': 45})
+        }
+        helpers.assert_roundtrip_tree(tree, tmpdir)
+
+    with pytest.raises(AttributeError):
+        tree = {
+        'compound': astmodels.Pix2Sky_TAN() & {'x': 45}
+        }
+        helpers.assert_roundtrip_tree(tree, tmpdir)
diff --git a/astropy/modeling/tests/test_compound.py b/astropy/modeling/tests/test_compound.py
index 096b66c4b269..a7feb59db0a6 100644
--- a/astropy/modeling/tests/test_compound.py
+++ b/astropy/modeling/tests/test_compound.py
@@ -10,13 +10,13 @@
 from numpy.testing import assert_allclose, assert_array_equal

 from astropy.utils import minversion
-from astropy.modeling.core import Model, ModelDefinitionError
+from astropy.modeling.core import Model, ModelDefinitionError, CompoundModel
 from astropy.modeling.parameters import Parameter
 from astropy.modeling.models import (Const1D, Shift, Scale, Rotation2D, Gaussian1D,
                                      Gaussian2D, Polynomial1D, Polynomial2D,
                                      Chebyshev2D, Legendre2D, Chebyshev1D, Legendre1D,
                                      Identity, Mapping,
-                                     Tabular1D)
+                                     Tabular1D, fix_inputs)
 import astropy.units as u
 from ..core import CompoundModel

@@ -307,6 +307,63 @@ def test_compound_with_polynomials(poly):
     assert_allclose(result, result_compound)

+def test_fix_inputs():
+    g1 = Gaussian2D(1, 0, 0, 1, 2)
+    g2 = Gaussian2D(1.5, .5, -.2, .5, .3)
+    sg1_1 = fix_inputs(g1, {1: 0})
+    assert_allclose(sg1_1(0), g1(0, 0))
+    assert_allclose(sg1_1([0,1,3]), g1([0,1,3],[0,0,0]))
+    sg1_2 = fix_inputs(g1, {'x': 1})
+    assert_allclose(sg1_2(1.5), g1(1, 1.5))
+    gg1 = g1 & g2
+    sgg1_1 = fix_inputs(gg1, {1: 0.1, 3: 0.2})
+    assert_allclose(sgg1_1(0, 0), gg1(0, 0.1, 0, 0.2))
+    sgg1_2 = fix_inputs(gg1, {'x0': -.1, 2: .1})
+    assert_allclose(sgg1_2(1,1), gg1(-0.1, 1, 0.1, 1))
+    assert_allclose(sgg1_2(y0=1, y1=1), gg1(-0.1, 1, 0.1, 1))
+
+
+def test_fix_inputs_invalid():
+    g1 = Gaussian2D(1, 0, 0, 1, 2)
+    with pytest.raises(ValueError):
+        fix_inputs(g1, {'x0': 0, 0: 0})
+
+    with pytest.raises(ValueError):
+        fix_inputs(g1, (0, 1))
+
+    with pytest.raises(ValueError):
+        fix_inputs(g1, {3: 2})
+
+    with pytest.raises(ValueError):
+        fix_inputs(g1, {'w': 2})
+
+    with pytest.raises(ModelDefinitionError):
+        CompoundModel('#', g1, g1)
+
+    with pytest.raises(ValueError):
+        gg1 = fix_inputs(g1, {0: 1})
+        gg1(2, y=2)
+
+
+def test_fix_inputs_with_bounding_box():
+    g1 = Gaussian2D(1, 0, 0, 1, 1)
+    g2 = Gaussian2D(1, 0, 0, 1, 1)
+    assert g1.bounding_box == ((-5.5,5.5), (-5.5,5.5))
+
+    gg1 = g1 & g2
+    gg1.bounding_box = ((-5.5,5.5), (-5.4,5.4), (-5.3,5.3), (-5.2,5.2))
+    assert gg1.bounding_box == ((-5.5,5.5), (-5.4,5.4), (-5.3,5.3), (-5.2,5.2))
+
+    sg = fix_inputs(gg1, {0: 0, 2: 0})
+    assert sg.bounding_box == ((-5.4,5.4), (-5.2,5.2))
+
+    g1 = Gaussian1D(10, 3, 1)
+    g = g1 & g1
+    g.bounding_box = ((1, 4), (6, 8))
+    gf = fix_inputs(g, {0: 1})
+    assert gf.bounding_box == (6, 8)
+
+
 def test_indexing_on_instance():
     """Test indexing on compound model instances."""

diff --git a/astropy/modeling/tests/test_model_sets.py b/astropy/modeling/tests/test_model_sets.py
index a24c52c81b0b..808e5afbe1e2 100644
--- a/astropy/modeling/tests/test_model_sets.py
+++ b/astropy/modeling/tests/test_model_sets.py
@@ -244,3 +244,8 @@ def test_model_set_axis_outputs():
     assert_allclose(y0[:, 1], y1[1])
     with pytest.raises(ValueError):
         model_set(x)
+
+
+def test_compound_model_sets():
+    with pytest.raises(ValueError):
+        Polynomial1D(1, n_models=2, model_set_axis=1) | Polynomial1D(1, n_models=2, model_set_axis=0)

diff --git a/docs/modeling/compound-models.rst b/docs/modeling/compound-models.rst
index 54c35cce15..a509bdf5d4 100644
--- a/docs/modeling/compound-models.rst
+++ b/docs/modeling/compound-models.rst
@@ -1369,3 +1369,34 @@ This opens up the possibility of essentially arbitrarily complex transformation
 graphs.  Currently the tools do not exist to make it easy to navigate and
 reason about highly complex compound models that use these mappings, but that
 is a possible enhancement for future versions.
+
+
+Model Reduction
+---------------
+
+In order to save much duplication in the construction of complex models, it is
+possible to define one complex model that covers all cases where the
+variables that distinguish the models are made part of the model's input
+variables. The ``fix_inputs`` function allows defining models derived from
+the more complex one by setting one or more of the inputs to a constant
+value. Examples of this sort of situation arise when working out
+the transformations from detector pixel to RA, Dec, and lambda for
+spectrographs when the slit locations may be moved (e.g., fiber fed or
+commandable slit masks), or different orders may be selected (e.g., Eschelle).
+In the case of order, one may have a function of pixel ``x``, ``y``, ``spectral_order``
+that map into ``RA``, ``Dec`` and ``lambda``. Without specifying ``spectral_order``, it is
+ambiguious what ``RA``, ``Dec`` and ``Lambda`` corresponds to a pixel location. It
+is usually possible to define a function of all three inputs. Presuming
+this model is ``general_transform`` then ``fix_inputs`` may be used to define
+the transform for a specific order as follows:
+
+::
+     >>> order1_transform = fix_inputs(general_transform, {'order': 1})  # doctest: +SKIP
+
+creates a new compound model that takes only pixel position and generates
+``RA``, ``Dec``, and ``lambda``. The ``fix_inputs`` function can be used to set input
+values by position (0 is the first) or by input variable name, and more
+than one can be set in the dictionary supplied.
+
+If the input model has a bounding_box, the generated model will have the
+bounding for the input coordinate removed.


diff --git a/CHANGES.rst b/CHANGES.rst
index 9cc0c28c2d96..5d5bf3f541a2 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -10,6 +10,9 @@ astropy.config
 astropy.constants
 ^^^^^^^^^^^^^^^^^

+- The version of constants can be specified via ScienceState in a way
+  that ``constants`` and ``units`` will be consistent. [#8517]
+
 astropy.convolution
 ^^^^^^^^^^^^^^^^^^^

diff --git a/astropy/__init__.py b/astropy/__init__.py
index ab8d1736ad27..edfea0da948d 100644
--- a/astropy/__init__.py
+++ b/astropy/__init__.py
@@ -157,6 +157,80 @@ class Conf(_config.ConfigNamespace):

 conf = Conf()

+
+# Define a base ScienceState for configuring constants and units
+from .utils.state import ScienceState
+class base_constants_version(ScienceState):
+    """
+    Base class for the real version-setters below
+    """
+    _value = 'test'
+
+    _versions = dict(test='test')
+
+    @classmethod
+    def validate(cls, value):
+        if value not in cls._versions:
+            raise ValueError('Must be one of {}'
+                             .format(list(cls._versions.keys())))
+        return cls._versions[value]
+
+    @classmethod
+    def set(cls, value):
+        """
+        Set the current constants value.
+        """
+        import sys
+        if 'astropy.units' in sys.modules:
+            raise RuntimeError('astropy.units is already imported')
+        if 'astropy.constants' in sys.modules:
+            raise RuntimeError('astropy.constants is already imported')
+
+        class _Context:
+            def __init__(self, parent, value):
+                self._value = value
+                self._parent = parent
+
+            def __enter__(self):
+                pass
+
+            def __exit__(self, type, value, tb):
+                self._parent._value = self._value
+
+            def __repr__(self):
+                return ('<ScienceState {0}: {1!r}>'
+                        .format(self._parent.__name__, self._parent._value))
+
+        ctx = _Context(cls, cls._value)
+        value = cls.validate(value)
+        cls._value = value
+        return ctx
+
+
+class physical_constants(base_constants_version):
+    """
+    The version of physical constants to use
+    """
+    # Maintainers: update when new constants are added
+    _value = 'codata2014'
+
+    _versions = dict(codata2018='codata2018', codata2014='codata2014',
+                     codata2010='codata2010', astropyconst40='codata2018',
+                     astropyconst20='codata2014', astropyconst13='codata2010')
+
+
+class astronomical_constants(base_constants_version):
+    """
+    The version of astronomical constants to use
+    """
+    # Maintainers: update when new constants are added
+    _value = 'iau2015'
+
+    _versions = dict(iau2015='iau2015', iau2012='iau2012',
+                     astropyconst40='iau2015', astropyconst20='iau2015',
+                     astropyconst13='iau2012')
+
+
 # Create the test() function
 from .tests.runner import TestRunner
 test = TestRunner.make_test_runner_in(__path__[0])
@@ -320,7 +394,8 @@ def online_help(query):

 __dir_inc__ = ['__version__', '__githash__', '__minimum_numpy_version__',
                '__bibtex__', 'test', 'log', 'find_api_page', 'online_help',
-               'online_docs_root', 'conf']
+               'online_docs_root', 'conf', 'physical_constants',
+               'astronomical_constants']

 from types import ModuleType as __module_type__
diff --git a/astropy/constants/__init__.py b/astropy/constants/__init__.py
index 65350120f9d9..7607e642de77 100644
--- a/astropy/constants/__init__.py
+++ b/astropy/constants/__init__.py
@@ -13,21 +13,21 @@
     <Quantity 0.510998927603161 MeV>

 """
+import warnings
 from contextlib import contextmanager

 from astropy.utils import find_current_module

 # Hack to make circular imports with units work
-try:
-    from astropy import units
-    del units
-except ImportError:
-    pass
+from astropy import units
+del units

+# These lines import some namespaces into the top level
 from .constant import Constant, EMConstant  # noqa
 from . import si  # noqa
 from . import cgs  # noqa
-from . import codata2014, iau2015  # noqa
+from .config import codata, iaudata
+
 from . import utils as _utils

 # for updating the constants module docstring
@@ -38,9 +38,11 @@
     '========== ============== ================ =========================',
 ]

-# NOTE: Update this when default changes.
-_utils._set_c(codata2014, iau2015, find_current_module(),
-              not_in_module_only=True, doclines=_lines, set_class=True)
+# Catch warnings about "already has a definition in the None system"
+with warnings.catch_warnings():
+    warnings.filterwarnings('ignore', 'Constant .*already has a definition')
+    _utils._set_c(codata, iaudata, find_current_module(),
+                  not_in_module_only=False, doclines=_lines, set_class=True)

 _lines.append(_lines[1])

@@ -65,38 +67,42 @@ def set_enabled_constants(modname):
     """

     # Re-import here because these were deleted from namespace on init.
+    import importlib
     import warnings
     from astropy.utils import find_current_module
     from . import utils as _utils

-    # NOTE: Update this when default changes.
-    if modname == 'astropyconst13':
-        from .astropyconst13 import codata2010 as codata
-        from .astropyconst13 import iau2012 as iaudata
-    else:
-        raise ValueError(
-            'Context manager does not currently handle {}'.format(modname))
+    try:
+        modmodule = importlib.import_module('.constants.' + modname, 'astropy')
+        codata_context = modmodule.codata
+        iaudata_context = modmodule.iaudata
+    except ImportError as exc:
+        exc.args += ('Context manager does not currently handle {}'
+                     .format(modname),)
+        raise

     module = find_current_module()

     # Ignore warnings about "Constant xxx already has a definition..."
     with warnings.catch_warnings():
-        warnings.simplefilter('ignore')
-        _utils._set_c(codata, iaudata, module,
+        warnings.filterwarnings('ignore',
+                                'Constant .*already has a definition')
+        _utils._set_c(codata_context, iaudata_context, module,
                       not_in_module_only=False, set_class=True)

     try:
         yield
     finally:
         with warnings.catch_warnings():
-            warnings.simplefilter('ignore')
-            # NOTE: Update this when default changes.
-            _utils._set_c(codata2014, iau2015, module,
+            warnings.filterwarnings('ignore',
+                                    'Constant .*already has a definition')
+            _utils._set_c(codata, iaudata, module,
                           not_in_module_only=False, set_class=True)

 # Clean up namespace
 del find_current_module
+del warnings
 del contextmanager
 del _utils
 del _lines
diff --git a/astropy/constants/astropyconst13.py b/astropy/constants/astropyconst13.py
index 4e19a0f883d5..b22fca4ccd67 100644
--- a/astropy/constants/astropyconst13.py
+++ b/astropy/constants/astropyconst13.py
@@ -8,7 +8,10 @@
 from . import utils as _utils
 from . import codata2010, iau2012

-_utils._set_c(codata2010, iau2012, find_current_module())
+codata = codata2010
+iaudata = iau2012
+
+_utils._set_c(codata, iaudata, find_current_module())

 # Clean up namespace
 del find_current_module
diff --git a/astropy/constants/astropyconst20.py b/astropy/constants/astropyconst20.py
index 5a04ec7d6196..050db5d0af16 100644
--- a/astropy/constants/astropyconst20.py
+++ b/astropy/constants/astropyconst20.py
@@ -7,7 +7,11 @@
 from . import utils as _utils
 from . import codata2014, iau2015

-_utils._set_c(codata2014, iau2015, find_current_module())
+
+codata = codata2014
+iaudata = iau2015
+
+_utils._set_c(codata, iaudata, find_current_module())

 # Clean up namespace
 del find_current_module
diff --git a/astropy/constants/cgs.py b/astropy/constants/cgs.py
index adca1b3411c3..0c7aeb536765 100644
--- a/astropy/constants/cgs.py
+++ b/astropy/constants/cgs.py
@@ -3,14 +3,13 @@
 Astronomical and physics constants in cgs units.  See :mod:`astropy.constants`
 for a complete listing of constants defined in Astropy.
 """
-
 import itertools

 from .constant import Constant
-from . import codata2014, iau2015
+from .config import codata, iaudata

-for _nm, _c in itertools.chain(sorted(vars(codata2014).items()),
-                               sorted(vars(iau2015).items())):
+for _nm, _c in itertools.chain(sorted(vars(codata).items()),
+                               sorted(vars(iaudata).items())):
     if (isinstance(_c, Constant) and _c.abbrev not in locals()
-         and _c.system in ['esu', 'gauss', 'emu']):
+            and _c.system in ['esu', 'gauss', 'emu']):
         locals()[_c.abbrev] = _c
diff --git a/astropy/constants/config.py b/astropy/constants/config.py
new file mode 100644
index 000000000000..c163b820ec98
--- /dev/null
+++ b/astropy/constants/config.py
@@ -0,0 +1,14 @@
+# Licensed under a 3-clause BSD style license - see LICENSE.rst
+"""
+Configures the codata and iaudata used, possibly using user configuration.
+"""
+# Note: doing this in __init__ causes import problems with units,
+# as si.py and cgs.py have to import the result.
+import importlib
+import astropy
+
+phys_version = astropy.physical_constants.get()
+astro_version = astropy.astronomical_constants.get()
+
+codata = importlib.import_module('.constants.' + phys_version, 'astropy')
+iaudata = importlib.import_module('.constants.' + astro_version, 'astropy')
diff --git a/astropy/constants/si.py b/astropy/constants/si.py
index 65e5a2d13eb8..c8f83a841110 100644
--- a/astropy/constants/si.py
+++ b/astropy/constants/si.py
@@ -3,16 +3,13 @@
 Astronomical and physics constants in SI units.  See :mod:`astropy.constants`
 for a complete listing of constants defined in Astropy.
 """
-
-
-
 import itertools

 from .constant import Constant
-from . import codata2014, iau2015
+from .config import codata, iaudata

-for _nm, _c in itertools.chain(sorted(vars(codata2014).items()),
-                               sorted(vars(iau2015).items())):
+for _nm, _c in itertools.chain(sorted(vars(codata).items()),
+                               sorted(vars(iaudata).items())):
     if (isinstance(_c, Constant) and _c.abbrev not in locals()
-         and _c.system == 'si'):
+            and _c.system == 'si'):
         locals()[_c.abbrev] = _c
diff --git a/docs/constants/index.rst b/docs/constants/index.rst
index de4e4709f299..d31aa1656a40 100644
--- a/docs/constants/index.rst
+++ b/docs/constants/index.rst
@@ -118,9 +118,57 @@ Union (IAU) are collected in modules with names like ``iau2012`` or ``iau2015``:
       Reference = IAU 2015 Resolution B 3

 The astronomical and physical constants are combined into modules with
-names like ``astropyconst13`` and ``astropyconst20``. To temporarily set
-constants to an older version (e.g., for regression testing), a context
-manager is available as follows:
+names like ``astropyconst13`` and ``astropyconst20`` for different versions.
+However, importing these prior version modules directly will lead to
+inconsistencies with other subpackages that have already imported
+`astropy.constants`. Notably, `astropy.units` will have already used
+the default version of constants. When using prior versions of the constants
+in this manner, quantities should be constructed with constants instead of units.
+
+To ensure consistent use of a prior version of constants in other Astropy
+packages (such as `astropy.units`) that import constants, the physical and
+astronomical constants versions should be set via ScienceState classes.
+These must be set before the first import of either `astropy.constants` or
+`astropy.units`.  For example, you can use the CODATA2010 physical constants and the
+IAU 2012 astronomical constants:
+
+    >>> from astropy import physical_constants, astronomical_constants
+    >>> physical_constants.set('codata2010')  # doctest: +SKIP
+    <ScienceState physical_constants: 'codata2010'>
+    >>> physical_constants.get()  # doctest: +SKIP
+    'codata2010'
+    >>> astronomical_constants.set('iau2012')  # doctest: +SKIP
+    <ScienceState astronomical_constants: 'iau2012'>
+    >>> astronomical_constants.get()  # doctest: +SKIP
+    'iau2012'
+
+Then all other packages that import `astropy.constants` will self-consistently
+initialize with that prior version of constants.
+
+The versions may also be set using values referring to the version modules:
+
+    >>> from astropy import physical_constants, astronomical_constants
+    >>> physical_constants.set('astropyconst13')  # doctest: +SKIP
+    <ScienceState physical_constants: 'codata2010'>
+    >>> physical_constants.get()  # doctest: +SKIP
+    'codata2010'
+    >>> astronomical_constants.set('astropyconst13')  # doctest: +SKIP
+    <ScienceState astronomical_constants: 'iau2012'>
+    >>> astronomical_constants.get()  # doctest: +SKIP
+    'iau2012'
+
+If either `astropy.constants` or `astropy.units` have already been imported, a
+``RuntimeError`` will be raised.
+
+    >>> import astropy.units
+    >>> from astropy import physical_constants, astronomical_constants
+    >>> astronomical_constants.set('astropyconst13')
+    Traceback (most recent call last):
+        ...
+    RuntimeError: astropy.units is already imported
+
+To temporarily set constants to an older version (e.g.,
+for regression testing), a context manager is available, as follows:

     >>> from astropy import constants as const
     >>> with const.set_enabled_constants('astropyconst13'):
@@ -137,11 +185,10 @@ manager is available as follows:
       Unit  = J s
       Reference = CODATA 2014

-.. warning::
+The context manager may be used at any time in a Python session, but it
+uses the prior version only for `astropy.constants`, and not for any
+other subpackage such as `astropy.units`.

-    Units such as ``u.M_sun`` will use the current version of the
-    corresponding constant. When using prior versions of the constants,
-    quantities should be constructed with constants instead of units.

 .. note that if this section gets too long, it should be moved to a separate
    doc page - see the top of performance.inc.rst for the instructions on how to
diff --git a/docs/units/constants_versions.rst b/docs/units/constants_versions.rst
new file mode 100644
index 000000000000..4663b3257f24
--- /dev/null
+++ b/docs/units/constants_versions.rst
@@ -0,0 +1,29 @@
+Using prior versions of constants
+*********************************
+
+By default, `astropy.units` are initialized upon first import to use
+the current versions of `astropy.constants`. For units to initialize
+properly to a prior version of constants, the constants versions must
+be set before the first import of `astropy.units` or `astropy.constants`.
+
+This is accomplished using ScienceState classes in the top-level package.
+Setting the prior versions at the start of a Python session will allow
+consistent units, as follows:
+
+>>> import astropy
+>>> astropy.physical_constants.set('codata2010')  # doctest: +SKIP
+<ScienceState physical_constants: 'codata2010'>
+>>> astropy.astronomical_constants.set('iau2012')  # doctest: +SKIP
+<ScienceState astronomical_constants: 'iau2012'>
+>>> import astropy.units as u
+>>> import astropy.constants as const
+>>> (const.M_sun / u.M_sun).to(u.dimensionless_unscaled) - 1  # doctest: +SKIP
+<Quantity 0.>
+>>> const.M_sun  # doctest: +SKIP
+  Name   = Solar mass
+  Value  = 1.9891e+30
+  Uncertainty  = 5e+25
+  Unit  = kg
+  Reference = Allen's Astrophysical Quantities 4th Ed.
+
+If `astropy.units` has already been imported, a RuntimeError is raised.
diff --git a/docs/units/index.rst b/docs/units/index.rst
index 4267de423490..82a7d412d664 100644
--- a/docs/units/index.rst
+++ b/docs/units/index.rst
@@ -165,6 +165,7 @@ Using `astropy.units`
    logarithmic_units
    format
    equivalencies
+   constants_versions
    conversion

 See Also

diff --git a/astropy/constants/tests/test_prior_version.py b/astropy/constants/tests/test_prior_version.py
index 5d13c66a7b98..c7f54ef75b60 100644
--- a/astropy/constants/tests/test_prior_version.py
+++ b/astropy/constants/tests/test_prior_version.py
@@ -163,6 +163,6 @@ def test_context_manager():

     assert const.h.value == 6.626070040e-34  # CODATA2014

-    with pytest.raises(ValueError):
+    with pytest.raises(ImportError):
         with const.set_enabled_constants('notreal'):
             const.h
diff --git a/astropy/constants/tests/test_sciencestate.py b/astropy/constants/tests/test_sciencestate.py
new file mode 100644
index 000000000000..1fa32b6909e1
--- /dev/null
+++ b/astropy/constants/tests/test_sciencestate.py
@@ -0,0 +1,21 @@
+import pytest
+
+from astropy import physical_constants, astronomical_constants
+import astropy.constants as const
+
+
+def test_version_match():
+    pversion = physical_constants.get()
+    refpversion = const.h.__class__.__name__.lower()
+    assert pversion == refpversion
+    aversion = astronomical_constants.get()
+    refaversion = const.M_sun.__class__.__name__.lower()
+    assert aversion == refaversion
+
+
+def test_previously_imported():
+    with pytest.raises(RuntimeError):
+        physical_constants.set('codata2018')
+
+    with pytest.raises(RuntimeError):
+        astronomical_constants.set('iau2015')

diff --git a/docs/constants/index.rst b/docs/constants/index.rst
index de4e4709f2..d31aa1656a 100644
--- a/docs/constants/index.rst
+++ b/docs/constants/index.rst
@@ -118,9 +118,57 @@ Union (IAU) are collected in modules with names like ``iau2012`` or ``iau2015``:
       Reference = IAU 2015 Resolution B 3

 The astronomical and physical constants are combined into modules with
-names like ``astropyconst13`` and ``astropyconst20``. To temporarily set
-constants to an older version (e.g., for regression testing), a context
-manager is available as follows:
+names like ``astropyconst13`` and ``astropyconst20`` for different versions.
+However, importing these prior version modules directly will lead to
+inconsistencies with other subpackages that have already imported
+`astropy.constants`. Notably, `astropy.units` will have already used
+the default version of constants. When using prior versions of the constants
+in this manner, quantities should be constructed with constants instead of units.
+
+To ensure consistent use of a prior version of constants in other Astropy
+packages (such as `astropy.units`) that import constants, the physical and
+astronomical constants versions should be set via ScienceState classes.
+These must be set before the first import of either `astropy.constants` or
+`astropy.units`.  For example, you can use the CODATA2010 physical constants and the
+IAU 2012 astronomical constants:
+
+    >>> from astropy import physical_constants, astronomical_constants
+    >>> physical_constants.set('codata2010')  # doctest: +SKIP
+    <ScienceState physical_constants: 'codata2010'>
+    >>> physical_constants.get()  # doctest: +SKIP
+    'codata2010'
+    >>> astronomical_constants.set('iau2012')  # doctest: +SKIP
+    <ScienceState astronomical_constants: 'iau2012'>
+    >>> astronomical_constants.get()  # doctest: +SKIP
+    'iau2012'
+
+Then all other packages that import `astropy.constants` will self-consistently
+initialize with that prior version of constants.
+
+The versions may also be set using values referring to the version modules:
+
+    >>> from astropy import physical_constants, astronomical_constants
+    >>> physical_constants.set('astropyconst13')  # doctest: +SKIP
+    <ScienceState physical_constants: 'codata2010'>
+    >>> physical_constants.get()  # doctest: +SKIP
+    'codata2010'
+    >>> astronomical_constants.set('astropyconst13')  # doctest: +SKIP
+    <ScienceState astronomical_constants: 'iau2012'>
+    >>> astronomical_constants.get()  # doctest: +SKIP
+    'iau2012'
+
+If either `astropy.constants` or `astropy.units` have already been imported, a
+``RuntimeError`` will be raised.
+
+    >>> import astropy.units
+    >>> from astropy import physical_constants, astronomical_constants
+    >>> astronomical_constants.set('astropyconst13')
+    Traceback (most recent call last):
+        ...
+    RuntimeError: astropy.units is already imported
+
+To temporarily set constants to an older version (e.g.,
+for regression testing), a context manager is available, as follows:

     >>> from astropy import constants as const
     >>> with const.set_enabled_constants('astropyconst13'):
@@ -137,11 +185,10 @@ manager is available as follows:
       Unit  = J s
       Reference = CODATA 2014

-.. warning::
+The context manager may be used at any time in a Python session, but it
+uses the prior version only for `astropy.constants`, and not for any
+other subpackage such as `astropy.units`.

-    Units such as ``u.M_sun`` will use the current version of the
-    corresponding constant. When using prior versions of the constants,
-    quantities should be constructed with constants instead of units.

 .. note that if this section gets too long, it should be moved to a separate
    doc page - see the top of performance.inc.rst for the instructions on how to

diff --git a/docs/units/constants_versions.rst b/docs/units/constants_versions.rst
new file mode 100644
index 0000000000..4663b3257f
--- /dev/null
+++ b/docs/units/constants_versions.rst
@@ -0,0 +1,29 @@
+Using prior versions of constants
+*********************************
+
+By default, `astropy.units` are initialized upon first import to use
+the current versions of `astropy.constants`. For units to initialize
+properly to a prior version of constants, the constants versions must
+be set before the first import of `astropy.units` or `astropy.constants`.
+
+This is accomplished using ScienceState classes in the top-level package.
+Setting the prior versions at the start of a Python session will allow
+consistent units, as follows:
+
+>>> import astropy
+>>> astropy.physical_constants.set('codata2010')  # doctest: +SKIP
+<ScienceState physical_constants: 'codata2010'>
+>>> astropy.astronomical_constants.set('iau2012')  # doctest: +SKIP
+<ScienceState astronomical_constants: 'iau2012'>
+>>> import astropy.units as u
+>>> import astropy.constants as const
+>>> (const.M_sun / u.M_sun).to(u.dimensionless_unscaled) - 1  # doctest: +SKIP
+<Quantity 0.>
+>>> const.M_sun  # doctest: +SKIP
+  Name   = Solar mass
+  Value  = 1.9891e+30
+  Uncertainty  = 5e+25
+  Unit  = kg
+  Reference = Allen's Astrophysical Quantities 4th Ed.
+
+If `astropy.units` has already been imported, a RuntimeError is raised.

diff --git a/docs/units/index.rst b/docs/units/index.rst
index 4267de4234..82a7d412d6 100644
--- a/docs/units/index.rst
+++ b/docs/units/index.rst
@@ -165,6 +165,7 @@ Using `astropy.units`
    logarithmic_units
    format
    equivalencies
+   constants_versions
    conversion

 See Also

diff --git a/docs/constants/index.rst b/docs/constants/index.rst
index de4e4709f299..d31aa1656a40 100644
--- a/docs/constants/index.rst
+++ b/docs/constants/index.rst
@@ -118,9 +118,57 @@ Union (IAU) are collected in modules with names like ``iau2012`` or ``iau2015``:
       Reference = IAU 2015 Resolution B 3

 The astronomical and physical constants are combined into modules with
-names like ``astropyconst13`` and ``astropyconst20``. To temporarily set
-constants to an older version (e.g., for regression testing), a context
-manager is available as follows:
+names like ``astropyconst13`` and ``astropyconst20`` for different versions.
+However, importing these prior version modules directly will lead to
+inconsistencies with other subpackages that have already imported
+`astropy.constants`. Notably, `astropy.units` will have already used
+the default version of constants. When using prior versions of the constants
+in this manner, quantities should be constructed with constants instead of units.
+
+To ensure consistent use of a prior version of constants in other Astropy
+packages (such as `astropy.units`) that import constants, the physical and
+astronomical constants versions should be set via ScienceState classes.
+These must be set before the first import of either `astropy.constants` or
+`astropy.units`.  For example, you can use the CODATA2010 physical constants and the
+IAU 2012 astronomical constants:
+
+    >>> from astropy import physical_constants, astronomical_constants
+    >>> physical_constants.set('codata2010')  # doctest: +SKIP
+    <ScienceState physical_constants: 'codata2010'>
+    >>> physical_constants.get()  # doctest: +SKIP
+    'codata2010'
+    >>> astronomical_constants.set('iau2012')  # doctest: +SKIP
+    <ScienceState astronomical_constants: 'iau2012'>
+    >>> astronomical_constants.get()  # doctest: +SKIP
+    'iau2012'
+
+Then all other packages that import `astropy.constants` will self-consistently
+initialize with that prior version of constants.
+
+The versions may also be set using values referring to the version modules:
+
+    >>> from astropy import physical_constants, astronomical_constants
+    >>> physical_constants.set('astropyconst13')  # doctest: +SKIP
+    <ScienceState physical_constants: 'codata2010'>
+    >>> physical_constants.get()  # doctest: +SKIP
+    'codata2010'
+    >>> astronomical_constants.set('astropyconst13')  # doctest: +SKIP
+    <ScienceState astronomical_constants: 'iau2012'>
+    >>> astronomical_constants.get()  # doctest: +SKIP
+    'iau2012'
+
+If either `astropy.constants` or `astropy.units` have already been imported, a
+``RuntimeError`` will be raised.
+
+    >>> import astropy.units
+    >>> from astropy import physical_constants, astronomical_constants
+    >>> astronomical_constants.set('astropyconst13')
+    Traceback (most recent call last):
+        ...
+    RuntimeError: astropy.units is already imported
+
+To temporarily set constants to an older version (e.g.,
+for regression testing), a context manager is available, as follows:

     >>> from astropy import constants as const
     >>> with const.set_enabled_constants('astropyconst13'):
@@ -137,11 +185,10 @@ manager is available as follows:
       Unit  = J s
       Reference = CODATA 2014

-.. warning::
+The context manager may be used at any time in a Python session, but it
+uses the prior version only for `astropy.constants`, and not for any
+other subpackage such as `astropy.units`.

-    Units such as ``u.M_sun`` will use the current version of the
-    corresponding constant. When using prior versions of the constants,
-    quantities should be constructed with constants instead of units.

 .. note that if this section gets too long, it should be moved to a separate
    doc page - see the top of performance.inc.rst for the instructions on how to

diff --git a/docs/units/constants_versions.rst b/docs/units/constants_versions.rst
new file mode 100644
index 000000000000..4663b3257f24
--- /dev/null
+++ b/docs/units/constants_versions.rst
@@ -0,0 +1,29 @@
+Using prior versions of constants
+*********************************
+
+By default, `astropy.units` are initialized upon first import to use
+the current versions of `astropy.constants`. For units to initialize
+properly to a prior version of constants, the constants versions must
+be set before the first import of `astropy.units` or `astropy.constants`.
+
+This is accomplished using ScienceState classes in the top-level package.
+Setting the prior versions at the start of a Python session will allow
+consistent units, as follows:
+
+>>> import astropy
+>>> astropy.physical_constants.set('codata2010')  # doctest: +SKIP
+<ScienceState physical_constants: 'codata2010'>
+>>> astropy.astronomical_constants.set('iau2012')  # doctest: +SKIP
+<ScienceState astronomical_constants: 'iau2012'>
+>>> import astropy.units as u
+>>> import astropy.constants as const
+>>> (const.M_sun / u.M_sun).to(u.dimensionless_unscaled) - 1  # doctest: +SKIP
+<Quantity 0.>
+>>> const.M_sun  # doctest: +SKIP
+  Name   = Solar mass
+  Value  = 1.9891e+30
+  Uncertainty  = 5e+25
+  Unit  = kg
+  Reference = Allen's Astrophysical Quantities 4th Ed.
+
+If `astropy.units` has already been imported, a RuntimeError is raised.

diff --git a/docs/units/index.rst b/docs/units/index.rst
index 4267de423490..82a7d412d664 100644
--- a/docs/units/index.rst
+++ b/docs/units/index.rst
@@ -165,6 +165,7 @@ Using `astropy.units`
    logarithmic_units
    format
    equivalencies
+   constants_versions
    conversion

 See Also

If completing this task requires creating new files, classes, fields, or error messages, you may consider using the following suggested entity names:
[{'type': 'file', 'name': 'astropy/constants/config.py'}]


diff --git a/CHANGES.rst b/CHANGES.rst
index 9b04db77f3ac..5a642df49854 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -144,6 +144,10 @@ astropy.utils

 - ``get_free_space_in_dir`` now takes a new ``unit`` keyword and
   ``check_free_space_in_dir`` takes ``size`` defined as ``Quantity``. [#10627]
+- New ``astropy.utils.data.conf.allow_internet`` configuration item to
+  control downloading data from the Internet. Setting ``allow_internet=False``
+  is the same as ``remote_timeout=0``. Using ``remote_timeout=0`` to control
+  internet access will stop working in a future release. [#10632]

 astropy.visualization
 ^^^^^^^^^^^^^^^^^^^^^
diff --git a/astropy/utils/data.py b/astropy/utils/data.py
index fcb5009f409f..0f7e8bf5aa1e 100644
--- a/astropy/utils/data.py
+++ b/astropy/utils/data.py
@@ -70,8 +70,12 @@ class Conf(_config.ConfigNamespace):
     remote_timeout = _config.ConfigItem(
         10.,
         'Time to wait for remote data queries (in seconds). Set this to zero '
-        'to prevent any attempt to download anything.',
+        'to prevent any attempt to download anything (this will stop working '
+        'in a future release, use allow_internet=False instead).',
         aliases=['astropy.coordinates.name_resolve.name_resolve_timeout'])
+    allow_internet = _config.ConfigItem(
+        True,
+        'If False, prevents any attempt to download from Internet.')
     compute_hash_block_size = _config.ConfigItem(
         2 ** 16,  # 64K
         'Block size for computing file hashes.')
@@ -187,8 +191,7 @@ def get_readable_fileobj(name_or_obj, encoding=None, cache=False,

     remote_timeout : float
         Timeout for remote requests in seconds (default is the configurable
-        `astropy.utils.data.Conf.remote_timeout`, which is 3s by default).
-        Set this to zero to prevent any attempt at downloading.
+        `astropy.utils.data.Conf.remote_timeout`).

     sources : list of str, optional
         If provided, a list of URLs to try to obtain the file from. The
@@ -486,7 +489,7 @@ def get_pkg_data_fileobj(data_name, package=None, encoding=None, cache=True):
     --------
     get_pkg_data_contents : returns the contents of a file or url as a bytes object
     get_pkg_data_filename : returns a local name for a file containing the data
-    """
+    """  # noqa

     datafn = _find_pkg_data_path(data_name, package=package)
     if os.path.isdir(datafn):
@@ -550,9 +553,7 @@ def get_pkg_data_filename(data_name, package=None, show_progress=True,

     remote_timeout : float
         Timeout for the requests in seconds (default is the
-        configurable `astropy.utils.data.Conf.remote_timeout`, which
-        is 3s by default). Set this to zero to prevent any attempt
-        at downloading.
+        configurable `astropy.utils.data.Conf.remote_timeout`).

     Raises
     ------
@@ -998,8 +999,16 @@ def _download_file_from_source(source_url, show_progress=True, timeout=None,
     if timeout == 0:
         raise urllib.error.URLError(
             f"URL {remote_url} was supposed to be downloaded but timeout was set to 0; "
-            f"if this is unexpected check the astropy config file for the option "
-            f"remote_timeout")
+            f"if this is unexpected check the astropy.cfg file for the option "
+            f"remote_timeout. If this is intentional, this will stop working "
+            f"in a future release. Use astropy.utils.data.conf.allow_internet=False instead.")
+
+    if not conf.allow_internet:
+        raise urllib.error.URLError(
+            f"URL {remote_url} was supposed to be downloaded but "
+            f"allow_internet is {conf.allow_internet}; "
+            f"if this is unexpected check the astropy.cfg file for the option "
+            f"allow_internet")

     if remote_url is None:
         remote_url = source_url
@@ -1120,9 +1129,8 @@ def download_file(remote_url, cache=False, show_progress=True, timeout=None,
         displayed when outputting to a terminal.

     timeout : float, optional
-        The timeout, in seconds.  Otherwise, use
-        `astropy.utils.data.Conf.remote_timeout`. Set this to zero to prevent
-        any attempt to download anything.
+        Timeout for remote requests in seconds (default is the configurable
+        `astropy.utils.data.Conf.remote_timeout`).

     sources : list of str, optional
         If provided, a list of URLs to try to obtain the file from. The
@@ -1356,8 +1364,7 @@ def download_files_in_parallel(urls,

     timeout : float, optional
         Timeout for each individual requests in seconds (default is the
-        configurable `astropy.utils.data.Conf.remote_timeout`). Set this to
-        zero to prevent any attempt to download anything.
+        configurable `astropy.utils.data.Conf.remote_timeout`).

     sources : dict, optional
         If provided, for each URL a list of URLs to try to obtain the
diff --git a/docs/utils/data.rst b/docs/utils/data.rst
index 6e94642ecaf2..578fc1659a42 100644
--- a/docs/utils/data.rst
+++ b/docs/utils/data.rst
@@ -257,7 +257,7 @@ therefore recommend the following guidelines:
    not suddenly notice an out-of-date table all at once and frantically attempt
    to download it.

- * Optionally, in this file, set ``astropy.utils.data.conf.remote_timeout = 0`` to
+ * Optionally, in this file, set ``astropy.utils.data.conf.allow_internet = False`` to
    prevent any attempt to download any file from the worker nodes; if you do this,
    you will need to override this setting in your script that does the actual
    downloading.

diff --git a/astropy/utils/tests/test_data.py b/astropy/utils/tests/test_data.py
index 15b28eb3f356..88903fc097da 100644
--- a/astropy/utils/tests/test_data.py
+++ b/astropy/utils/tests/test_data.py
@@ -2061,6 +2061,17 @@ def test_zero_remote_timeout(temp_cache, valid_urls):
         download_file(TESTURL, timeout=0)

+def test_no_allow_internet(temp_cache, valid_urls):
+    u, c = next(valid_urls)
+    with conf.set_temp('allow_internet', False):
+        with pytest.raises(urllib.error.URLError):
+            download_file(u)
+        assert not is_url_in_cache(u)
+        with pytest.raises(urllib.error.URLError):
+            # This will trigger the remote data error if it's allowed to touch the internet
+            download_file(TESTURL)
+
+
 def test_clear_download_cache_not_too_aggressive(temp_cache, valid_urls):
     u, c = next(valid_urls)
     download_file(u, cache=True)

diff --git a/docs/utils/data.rst b/docs/utils/data.rst
index 6e94642eca..578fc1659a 100644
--- a/docs/utils/data.rst
+++ b/docs/utils/data.rst
@@ -257,7 +257,7 @@ therefore recommend the following guidelines:
    not suddenly notice an out-of-date table all at once and frantically attempt
    to download it.

- * Optionally, in this file, set ``astropy.utils.data.conf.remote_timeout = 0`` to
+ * Optionally, in this file, set ``astropy.utils.data.conf.allow_internet = False`` to
    prevent any attempt to download any file from the worker nodes; if you do this,
    you will need to override this setting in your script that does the actual
    downloading.


diff --git a/astropy/time/core.py b/astropy/time/core.py
index 0cc559ccce14..44deaf7e175f 100644
--- a/astropy/time/core.py
+++ b/astropy/time/core.py
@@ -545,6 +545,11 @@ def _set_scale(self, scale):
             raise ValueError("Scale {!r} is not in the allowed scales {}"
                              .format(scale, sorted(self.SCALES)))

+        if scale == 'utc' or self.scale == 'utc':
+            # If doing a transform involving UTC then check that the leap
+            # seconds table is up to date.
+            _check_leapsec()
+
         # Determine the chain of scale transformations to get from the current
         # scale to the new scale.  MULTI_HOPS contains a dict of all
         # transformations (xforms) that require intermediate xforms.
@@ -1484,27 +1489,6 @@ def __new__(cls, val, val2=None, format=None, scale=None,
                 precision=None, in_subfmt=None, out_subfmt=None,
                 location=None, copy=False):

-        # Because of import problems, this can only be done on
-        # first call of Time. The initialization is complicated because
-        # update_leap_seconds uses Time.
-        # In principle, this may cause wrong leap seconds in
-        # update_leap_seconds itself, but since expiration is in
-        # units of days, that is fine.
-        global _LEAP_SECONDS_CHECK
-        if _LEAP_SECONDS_CHECK != _LeapSecondsCheck.DONE:
-            with _LEAP_SECONDS_LOCK:
-                # There are three ways we can get here:
-                # 1. First call (NOT_STARTED).
-                # 2. Re-entrant call (RUNNING). We skip the initialisation
-                #    and don't worry about leap second errors.
-                # 3. Another thread which raced with the first call
-                #    (RUNNING). The first thread has relinquished the
-                #    lock to us, so initialization is complete.
-                if _LEAP_SECONDS_CHECK == _LeapSecondsCheck.NOT_STARTED:
-                    _LEAP_SECONDS_CHECK = _LeapSecondsCheck.RUNNING
-                    update_leap_seconds()
-                    _LEAP_SECONDS_CHECK = _LeapSecondsCheck.DONE
-
         if isinstance(val, Time):
             self = val.replicate(format=format, copy=copy, cls=cls)
         else:
@@ -2654,6 +2638,24 @@ def __init__(self, left, right, op=None):
                                    right.__class__.__name__))

+def _check_leapsec():
+    global _LEAP_SECONDS_CHECK
+    if _LEAP_SECONDS_CHECK != _LeapSecondsCheck.DONE:
+        from astropy.utils import iers
+        with _LEAP_SECONDS_LOCK:
+            # There are three ways we can get here:
+            # 1. First call (NOT_STARTED).
+            # 2. Re-entrant call (RUNNING). We skip the initialisation
+            #    and don't worry about leap second errors.
+            # 3. Another thread which raced with the first call
+            #    (RUNNING). The first thread has relinquished the
+            #    lock to us, so initialization is complete.
+            if _LEAP_SECONDS_CHECK == _LeapSecondsCheck.NOT_STARTED:
+                _LEAP_SECONDS_CHECK = _LeapSecondsCheck.RUNNING
+                update_leap_seconds()
+                _LEAP_SECONDS_CHECK = _LeapSecondsCheck.DONE
+
+
 def update_leap_seconds(files=None):
     """If the current ERFA leap second table is out of date, try to update it.

diff --git a/astropy/utils/iers/iers.py b/astropy/utils/iers/iers.py
index 01a4717503b6..704e724ea205 100644
--- a/astropy/utils/iers/iers.py
+++ b/astropy/utils/iers/iers.py
@@ -881,6 +881,11 @@ class LeapSeconds(QTable):
     ``iers.LEAP_SECONDS_LIST_URL``.  Many systems also store a version
     of ``leap-seconds.list`` for use with ``ntp`` (e.g., on Debian/Ubuntu
     systems, ``/usr/share/zoneinfo/leap-seconds.list``).
+
+    To prevent querying internet resources if the available local leap second
+    file(s) are out of date, set ``iers.conf.auto_download = False``. This
+    must be done prior to performing any ``Time`` scale transformations related
+    to UTC (e.g. converting from UTC to TAI).
     """
     # Note: Time instances in this class should use scale='tai' to avoid
     # needing leap seconds in their creation or interpretation.
diff --git a/docs/changes/utils/11638.feature.rst b/docs/changes/utils/11638.feature.rst
new file mode 100644
index 000000000000..656f0b38f95b
--- /dev/null
+++ b/docs/changes/utils/11638.feature.rst
@@ -0,0 +1,8 @@
+Change the Time and IERS leap second handling so that the leap second table is
+updated only when a Time transform involving UTC is performed. Previously this
+update check was done the first time a ``Time`` object was created, which in
+practice occured when importing common astropy subpackages like
+``astropy.coordinates``. Now you can prevent querying internet resources (for
+instance on a cluster) by setting ``iers.conf.auto_download = False``. This can
+be done after importing astrpoy but prior to performing any ``Time`` scale
+transformations related to UTC.
diff --git a/docs/utils/iers.rst b/docs/utils/iers.rst
index 57d136e0e2a9..92e6b12ce28b 100644
--- a/docs/utils/iers.rst
+++ b/docs/utils/iers.rst
@@ -76,8 +76,9 @@ of the automatic IERS downloading:
   auto_download:
     Enable auto-downloading of the latest IERS data.  If set to ``False`` then
     the local IERS-B file will be used by default (even if the full IERS file
-    with predictions was already downloaded and cached).  This replicates the
-    behavior prior to astropy 1.2.  (default=True)
+    with predictions was already downloaded and cached).  This parameter also
+    controls whether internet resources will be queried to update the leap
+    second table if the installed version is out of date.

   auto_max_age:
     Maximum age of predictive data before auto-downloading (days).  See

diff --git a/astropy/timeseries/tests/test_downsample.py b/astropy/timeseries/tests/test_downsample.py
index 067561b774e2..36b5e644f3cf 100644
--- a/astropy/timeseries/tests/test_downsample.py
+++ b/astropy/timeseries/tests/test_downsample.py
@@ -12,8 +12,6 @@

 INPUT_TIME = Time(['2016-03-22T12:30:31', '2016-03-22T12:30:32',
                    '2016-03-22T12:30:33', '2016-03-22T12:30:34'])
-ts = TimeSeries(time=INPUT_TIME, data=[[1, 2, 3, 4]], names=['a'])
-ts_units = TimeSeries(time=INPUT_TIME, data=[[1, 2, 3, 4] * u.count], names=['a'])

 def test_reduceat():
@@ -41,6 +39,9 @@ def test_timeseries_invalid():

 def test_downsample():
+    ts = TimeSeries(time=INPUT_TIME, data=[[1, 2, 3, 4]], names=['a'])
+    ts_units = TimeSeries(time=INPUT_TIME, data=[[1, 2, 3, 4] * u.count], names=['a'])
+
     down_1 = aggregate_downsample(ts, time_bin_size=1*u.second)
     u.isclose(down_1.time_bin_size, [1, 1, 1, 1]*u.second)
     assert_equal(down_1.time_bin_start.isot, Time(['2016-03-22T12:30:31.000', '2016-03-22T12:30:32.000',
diff --git a/astropy/utils/iers/tests/test_leap_second.py b/astropy/utils/iers/tests/test_leap_second.py
index d9c86033f950..19981d3aa18c 100644
--- a/astropy/utils/iers/tests/test_leap_second.py
+++ b/astropy/utils/iers/tests/test_leap_second.py
@@ -12,7 +12,26 @@
 from astropy.time import Time, TimeDelta
 from astropy.utils.iers import iers
 from astropy.utils.data import get_pkg_data_filename
-
+from astropy.tests.tests.test_imports import test_imports
+
+# Import every top-level astropy module as a test that the ERFA leap second
+# table is not updated for normal imports.
+test_imports()
+
+# Now test that the erfa leap_seconds table has not been udpated. This must be
+# done at the module level, which unfortunately will abort the entire test run
+# if if fails. Running within a normal pytest test will not work because the
+# other tests will end up updating this attribute by virtue of doing Time UTC
+# transformations.
+assert erfa.leap_seconds._expires is None
+
+# Tests in this module assume that the erfa.leap_seconds attribute has been
+# updated from the `erfa` package built-in table to the astropy built-in
+# leap-second table. That has the effect of ensuring that the
+# `erfa.leap_seconds.expires` property is sufficiently in the future.
+iers_table = iers.LeapSeconds.auto_open()
+erfa.leap_seconds.update(iers_table)
+assert erfa.leap_seconds._expires is not None

 SYSTEM_FILE = '/usr/share/zoneinfo/leap-seconds.list'

diff --git a/docs/changes/utils/#####.feature.rst b/docs/changes/utils/#####.feature.rst
new file mode 100644
index 0000000000..656f0b38f9
--- /dev/null
+++ b/docs/changes/utils/#####.feature.rst
@@ -0,0 +1,8 @@
+Change the Time and IERS leap second handling so that the leap second table is
+updated only when a Time transform involving UTC is performed. Previously this
+update check was done the first time a ``Time`` object was created, which in
+practice occured when importing common astropy subpackages like
+``astropy.coordinates``. Now you can prevent querying internet resources (for
+instance on a cluster) by setting ``iers.conf.auto_download = False``. This can
+be done after importing astrpoy but prior to performing any ``Time`` scale
+transformations related to UTC.

diff --git a/docs/utils/iers.rst b/docs/utils/iers.rst
index 57d136e0e2..92e6b12ce2 100644
--- a/docs/utils/iers.rst
+++ b/docs/utils/iers.rst
@@ -76,8 +76,9 @@ of the automatic IERS downloading:
   auto_download:
     Enable auto-downloading of the latest IERS data.  If set to ``False`` then
     the local IERS-B file will be used by default (even if the full IERS file
-    with predictions was already downloaded and cached).  This replicates the
-    behavior prior to astropy 1.2.  (default=True)
+    with predictions was already downloaded and cached).  This parameter also
+    controls whether internet resources will be queried to update the leap
+    second table if the installed version is out of date.

   auto_max_age:
     Maximum age of predictive data before auto-downloading (days).  See


diff --git a/astropy/units/si.py b/astropy/units/si.py
index f2bebef5a60f..1e8f96e8365d 100644
--- a/astropy/units/si.py
+++ b/astropy/units/si.py
@@ -34,6 +34,7 @@

 def_unit(['Angstrom', 'AA', 'angstrom'], 0.1 * nm, namespace=_ns,
          doc="ångström: 10 ** -10 m",
+         prefixes=[(['m', 'milli'], ['milli', 'm'], 1.e-3)],
          format={'latex': r'\mathring{A}', 'unicode': 'Å',
                  'vounit': 'Angstrom'})

diff --git a/docs/changes/units/11788.feature.rst b/docs/changes/units/11788.feature.rst
new file mode 100644
index 000000000000..8431c639a324
--- /dev/null
+++ b/docs/changes/units/11788.feature.rst
@@ -0,0 +1,1 @@
+The milli- prefix has been added to ``astropy.units.Angstrom``.
diff --git a/docs/units/format.rst b/docs/units/format.rst
index 90d85c50d387..1d83f2496fef 100644
--- a/docs/units/format.rst
+++ b/docs/units/format.rst
@@ -273,11 +273,11 @@ particular alias is used::
     >>> u.Unit("Angstroem") == u.Angstrom
     Traceback (most recent call last):
       ...
-    ValueError: 'Angstroem' did not parse as unit: At col 0, Angstroem is not a
-    valid unit. Did you mean Angstrom or angstrom? If this is meant to be a
-    custom unit, define it with 'u.def_unit'. To have it recognized inside a
-    file reader or other code, enable it with 'u.add_enabled_units'. For
-    details, see
+    ValueError: 'Angstroem' did not parse as unit: At col 0, Angstroem is not
+    a valid unit. Did you mean Angstrom, angstrom, mAngstrom or mangstrom? If
+    this is meant to be a custom unit, define it with 'u.def_unit'. To have it
+    recognized inside a file reader or other code, enable it with
+    'u.add_enabled_units'. For details, see
     https://docs.astropy.org/en/latest/units/combining_and_defining.html

 .. EXAMPLE END

diff --git a/astropy/units/tests/test_format.py b/astropy/units/tests/test_format.py
index bae51be43848..7c2816360c29 100644
--- a/astropy/units/tests/test_format.py
+++ b/astropy/units/tests/test_format.py
@@ -606,6 +606,7 @@ def test_powers(power, expected):
     ('\N{ANGSTROM SIGN} \N{OHM SIGN}', u.Angstrom * u.Ohm),
     ('\N{LATIN CAPITAL LETTER A WITH RING ABOVE}', u.Angstrom),
     ('\N{LATIN CAPITAL LETTER A}\N{COMBINING RING ABOVE}', u.Angstrom),
+    ('m\N{ANGSTROM SIGN}', u.milliAngstrom),
     ('°C', u.deg_C),
     ('°', u.deg),
 ])
@@ -620,7 +621,7 @@ def test_unicode(string, unit):
     'm\N{SUPERSCRIPT MINUS}1',
     'm+\N{SUPERSCRIPT ONE}',
     'm\N{MINUS SIGN}\N{SUPERSCRIPT ONE}',
-    'm\N{ANGSTROM SIGN}',
+    'k\N{ANGSTROM SIGN}',
 ])
 def test_unicode_failures(string):
     with pytest.raises(ValueError):
diff --git a/astropy/units/tests/test_units.py b/astropy/units/tests/test_units.py
index 83ab11a8932e..a044243187dd 100644
--- a/astropy/units/tests/test_units.py
+++ b/astropy/units/tests/test_units.py
@@ -639,7 +639,7 @@ def test_suggestions():
             ('metre', 'meter'),
             ('angstroms', 'Angstrom or angstrom'),
             ('milimeter', 'millimeter'),
-            ('ångström', 'Angstrom or angstrom'),
+            ('ångström', 'Angstrom, angstrom, mAngstrom or mangstrom'),
             ('kev', 'EV, eV, kV or keV')]:
         with pytest.raises(ValueError, match=f'Did you mean {matches}'):
             u.Unit(search)

diff --git a/docs/changes/units/#####.feature.rst b/docs/changes/units/#####.feature.rst
new file mode 100644
index 0000000000..8431c639a3
--- /dev/null
+++ b/docs/changes/units/#####.feature.rst
@@ -0,0 +1,1 @@
+The milli- prefix has been added to ``astropy.units.Angstrom``.

diff --git a/docs/units/format.rst b/docs/units/format.rst
index 90d85c50d3..1d83f2496f 100644
--- a/docs/units/format.rst
+++ b/docs/units/format.rst
@@ -273,11 +273,11 @@ particular alias is used::
     >>> u.Unit("Angstroem") == u.Angstrom
     Traceback (most recent call last):
       ...
-    ValueError: 'Angstroem' did not parse as unit: At col 0, Angstroem is not a
-    valid unit. Did you mean Angstrom or angstrom? If this is meant to be a
-    custom unit, define it with 'u.def_unit'. To have it recognized inside a
-    file reader or other code, enable it with 'u.add_enabled_units'. For
-    details, see
+    ValueError: 'Angstroem' did not parse as unit: At col 0, Angstroem is not
+    a valid unit. Did you mean Angstrom, angstrom, mAngstrom or mangstrom? If
+    this is meant to be a custom unit, define it with 'u.def_unit'. To have it
+    recognized inside a file reader or other code, enable it with
+    'u.add_enabled_units'. For details, see
     https://docs.astropy.org/en/latest/units/combining_and_defining.html

 .. EXAMPLE END


diff --git a/astropy/units/physical.py b/astropy/units/physical.py
index c19c6e8f2b01..5c13020da786 100644
--- a/astropy/units/physical.py
+++ b/astropy/units/physical.py
@@ -3,6 +3,7 @@
 """Defines the physical types that correspond to different units."""

 import numbers
+import sys
 import warnings

 from . import core
@@ -129,6 +130,8 @@
 _physical_unit_mapping = {}
 _unit_physical_mapping = {}
 _name_physical_mapping = {}
+# mapping from attribute-accessible name (no spaces, etc.) to the actual name.
+_attrname_physical_mapping = {}

 def _physical_type_from_str(name):
@@ -139,7 +142,9 @@ def _physical_type_from_str(name):
     if name == "unknown":
         raise ValueError("cannot uniquely identify an 'unknown' physical type.")

-    if name in _name_physical_mapping:
+    elif name in _attrname_physical_mapping:
+        return _attrname_physical_mapping[name]  # convert attribute-accessible
+    elif name in _name_physical_mapping:
         return _name_physical_mapping[name]
     else:
         raise ValueError(f"{name!r} is not a known physical type.")
@@ -476,6 +481,9 @@ def def_physical_type(unit, name):

     for ptype_name in physical_type_names:
         _name_physical_mapping[ptype_name] = physical_type
+        # attribute-accessible name
+        attr_name = ptype_name.replace(' ', '_').replace('(', '').replace(')', '')
+        _attrname_physical_mapping[attr_name] = physical_type

 def get_physical_type(obj):
@@ -542,10 +550,48 @@ def get_physical_type(obj):
         return PhysicalType(unit, "unknown")

+# ------------------------------------------------------------------------------
+# Script section creating the physical types and the documentation
+
+# define the physical types
 for unit, physical_type in _units_and_physical_types:
     def_physical_type(unit, physical_type)

+# For getting the physical types.
+def __getattr__(name):
+    """Checks for physical types using lazy import.
+
+    This also allows user-defined physical types to be accessible from the
+    :mod:`astropy.units.physical` module.
+    See `PEP 562 <https://www.python.org/dev/peps/pep-0562/>`_
+
+    Parameters
+    ----------
+    name : str
+        The name of the attribute in this module. If it is already defined,
+        then this function is not called.
+
+    Returns
+    -------
+    ptype : `~astropy.units.physical.PhysicalType`
+
+    Raises
+    ------
+    AttributeError
+        If the ``name`` does not correspond to a physical type
+    """
+    if name in _attrname_physical_mapping:
+        return _attrname_physical_mapping[name]
+
+    raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+
+
+def __dir__():
+    """Return contents directory (__all__ + all physical type names)."""
+    return list(set(__all__) | set(_attrname_physical_mapping.keys()))
+
+
 # This generates a docstring addition for this module that describes all of the
 # standard physical types defined here.
 if __doc__ is not None:
diff --git a/docs/changes/units/11691.feature.rst b/docs/changes/units/11691.feature.rst
new file mode 100644
index 000000000000..fd8a04efed7b
--- /dev/null
+++ b/docs/changes/units/11691.feature.rst
@@ -0,0 +1,4 @@
+Each physical type is added to ``astropy.units.physical``
+(e.g., ``physical.length`` or ``physical.electrical_charge_ESU``).
+The attribute-accessible names (underscored, without parenthesis) also
+work with ``astropy.units.physical.get_physical_type``.

diff --git a/astropy/units/tests/test_physical.py b/astropy/units/tests/test_physical.py
index ce285464ccc9..506fc573988e 100644
--- a/astropy/units/tests/test_physical.py
+++ b/astropy/units/tests/test_physical.py
@@ -153,7 +153,9 @@ def test_physical_type_names(unit, physical_type):
         (u.m, "length"),
         ("work", "work"),
         (5 * u.m, "length"),
-        (length, length)
+        (length, length),
+        (u.Pa, "energy_density"),  # attribute-accessible name
+        ("energy_density", "energy_density")  # attribute-accessible name
     ],
 )
 def test_getting_physical_type(physical_type_representation, physical_type_name):
@@ -439,16 +441,31 @@ def test_expanding_names_for_physical_type(self):
         weird_name = "weird name"
         strange_name = "strange name"

-        physical.def_physical_type(self.weird_unit, weird_name)
-        assert (
-            self.weird_unit.physical_type == weird_name
-        ), f"unable to set physical type for {self.weird_unit}"
-
-        physical.def_physical_type(self.weird_unit, strange_name)
-        assert set((self.weird_unit).physical_type) == {
-            weird_name,
-            strange_name,
-        }, f"did not correctly append a new physical type name."
+        try:
+            physical.def_physical_type(self.weird_unit, weird_name)
+            assert (
+                self.weird_unit.physical_type == weird_name
+            ), f"unable to set physical type for {self.weird_unit}"
+        except Exception:
+            raise
+        finally:  # cleanup added name
+            physical._attrname_physical_mapping.pop(weird_name.replace(' ', '_'), None)
+            physical._name_physical_mapping.pop(weird_name, None)
+
+        # add both strange_name and weird_name
+        try:
+            physical.def_physical_type(self.weird_unit, strange_name)
+            assert set((self.weird_unit).physical_type) == {
+                weird_name,
+                strange_name,
+            }, f"did not correctly append a new physical type name."
+        except Exception:
+            raise
+        finally:  # cleanup added names
+            physical._attrname_physical_mapping.pop(strange_name.replace(' ', '_'), None)
+            physical._name_physical_mapping.pop(strange_name, None)
+            physical._attrname_physical_mapping.pop(weird_name.replace(' ', '_'), None)
+            physical._name_physical_mapping.pop(weird_name, None)

     def test_redundant_physical_type(self):
         """
@@ -513,3 +530,19 @@ def test_pickling(ptype_name):
     pkl = pickle.dumps(ptype)
     other = pickle.loads(pkl)
     assert other == ptype
+
+
+def test_physical_types_module_access():
+    # all physical type names in dir
+    assert set(dir(physical)).issuperset(physical._attrname_physical_mapping.keys())
+    assert set(dir(physical)).issuperset(physical.__all__)
+
+    # all physical type can be accessed by name
+    for pname in physical._attrname_physical_mapping.keys():
+        ptype = physical._attrname_physical_mapping[pname]
+        assert hasattr(physical, pname)  # make sure works in lazy load
+        assert getattr(physical, pname) is ptype
+
+    # a failed access
+    with pytest.raises(AttributeError, match="has no attribute"):
+        physical.not_a_valid_physical_type_name

diff --git a/docs/changes/units/#####.feature.rst b/docs/changes/units/#####.feature.rst
new file mode 100644
index 0000000000..fd8a04efed
--- /dev/null
+++ b/docs/changes/units/#####.feature.rst
@@ -0,0 +1,4 @@
+Each physical type is added to ``astropy.units.physical``
+(e.g., ``physical.length`` or ``physical.electrical_charge_ESU``).
+The attribute-accessible names (underscored, without parenthesis) also
+work with ``astropy.units.physical.get_physical_type``.

diff --git a/docs/changes/units/11691.feature.rst b/docs/changes/units/11691.feature.rst
new file mode 100644
index 000000000000..fd8a04efed7b
--- /dev/null
+++ b/docs/changes/units/11691.feature.rst
@@ -0,0 +1,4 @@
+Each physical type is added to ``astropy.units.physical``
+(e.g., ``physical.length`` or ``physical.electrical_charge_ESU``).
+The attribute-accessible names (underscored, without parenthesis) also
+work with ``astropy.units.physical.get_physical_type``.

If completing this task requires creating new files, classes, fields, or error messages, you may consider using the following suggested entity names:
[{'type': 'field', 'name': '_attrname_physical_mapping'}]