Repository: OpenMDAO/OpenMDAO

Issue:
Control printed jacobian report with `coloring_info`
### Desired capability or behavior.

The options `show_summary` and `show_sparsity` are available for `driver.declare_coloring()`. This allows for control of printouts for quieter/more verbose operation.

A report on building the jacobian is currently not controlled by any options and will always print (line 2844 `coloring.py`).
Example output:
> Full total jacobian for problem 'test_bench' was computed 3 times, taking 0.699827100004768 seconds.
Total jacobian shape: (186, 148)

Either adding control of this printout to `show_summary` or a new toggle would be helpful for reducing the amount of information printed to the terminal, especially when running multiple problems.

### Associated POEM

_No response_

Hints from issue comments:

Solution patch:
diff --git a/openmdao/approximation_schemes/complex_step.py b/openmdao/approximation_schemes/complex_step.py
index 6f2126cbd..c09c24a58 100644
--- a/openmdao/approximation_schemes/complex_step.py
+++ b/openmdao/approximation_schemes/complex_step.py
@@ -177,7 +177,7 @@ class ComplexStep(ApproximationScheme):
         """
         return array.imag
 
-    def _run_point(self, system, idx_info, delta, result_array, total, idx_start=0):
+    def _run_point(self, system, idx_info, delta, result_array, total, idx_range=range(1)):
         """
         Perturb the system inputs with a complex step, run, and return the results.
 
@@ -193,8 +193,8 @@ class ComplexStep(ApproximationScheme):
             An array used to store the results.
         total : bool
             If True total derivatives are being approximated, else partials.
-        idx_start : int
-            Vector index of the first element of this wrt variable.
+        idx_range : range
+            Range of vector indices for this wrt variable.
 
         Returns
         -------
diff --git a/openmdao/approximation_schemes/finite_difference.py b/openmdao/approximation_schemes/finite_difference.py
index 72b8745d2..0b65d573f 100644
--- a/openmdao/approximation_schemes/finite_difference.py
+++ b/openmdao/approximation_schemes/finite_difference.py
@@ -291,7 +291,7 @@ class FiniteDifference(ApproximationScheme):
         """
         return array.real
 
-    def _run_point(self, system, idx_info, data, results_array, total, idx_start=0):
+    def _run_point(self, system, idx_info, data, results_array, total, idx_range=range(1)):
         """
         Alter the specified inputs by the given deltas, run the system, and return the results.
 
@@ -307,8 +307,8 @@ class FiniteDifference(ApproximationScheme):
             Where the results will be stored.
         total : bool
             If True total derivatives are being approximated, else partials.
-        idx_start : int
-            Vector index of the first element of this wrt variable.
+        idx_range : range
+            Range of vector indices for this wrt variable.
 
         Returns
         -------
@@ -330,7 +330,7 @@ class FiniteDifference(ApproximationScheme):
                 for vec, idxs in idx_info:
                     if vec is not None and idxs is not None:
 
-                        results_array *= current_coeff[idxs - idx_start[0]]
+                        results_array *= current_coeff[idxs - idx_range[0]]
                         # We don't allow mixed fd forms, so first one is all we need.
                         break
 
@@ -347,13 +347,13 @@ class FiniteDifference(ApproximationScheme):
 
         # Run the Finite Difference
         for delta, coeff in zip(deltas, coeffs):
-            results = self._run_sub_point(system, idx_info, delta, total, idx_start=idx_start,
+            results = self._run_sub_point(system, idx_info, delta, total, idx_range=idx_range,
                                           rel_element=rel_element)
 
             if rel_element:
                 for vec, idxs in idx_info:
                     if vec is not None and idxs is not None:
-                        results *= coeff[idxs - idx_start[0]]
+                        results *= coeff[idxs - idx_range[0]]
                         break
             else:
                 results *= coeff
@@ -362,7 +362,7 @@ class FiniteDifference(ApproximationScheme):
 
         return results_array
 
-    def _run_sub_point(self, system, idx_info, delta, total, idx_start=0, rel_element=False):
+    def _run_sub_point(self, system, idx_info, delta, total, idx_range, rel_element=False):
         """
         Alter the specified inputs by the given delta, run the system, and return the results.
 
@@ -376,8 +376,8 @@ class FiniteDifference(ApproximationScheme):
             Perturbation amount.
         total : bool
             If True total derivatives are being approximated, else partials.
-        idx_start : int
-            Vector index of the first element of this wrt variable.
+        idx_range : range
+            Range of vector indices for this wrt variable.
         rel_element : bool
             If True, then each element has a different delta.
 
@@ -391,7 +391,7 @@ class FiniteDifference(ApproximationScheme):
 
                 # Support rel_element stepsizing
                 if rel_element:
-                    local_delta = delta[idxs - idx_start[0]]
+                    local_delta = delta[idxs - idx_range[0]]
                 else:
                     local_delta = delta
 
diff --git a/openmdao/components/exec_comp.py b/openmdao/components/exec_comp.py
index 1fded7065..3306b50cc 100644
--- a/openmdao/components/exec_comp.py
+++ b/openmdao/components/exec_comp.py
@@ -969,7 +969,7 @@ class ExecComp(ExplicitComponent):
         in_offsets *= info['perturb_size']
 
         # use special sparse jacobian to collect sparsity info
-        jac = _ColSparsityJac(self, info)
+        jac = _ColSparsityJac(self)
 
         for i in range(info['num_full_jacs']):
             inarr[:] = starting_inputs + in_offsets * get_random_arr(in_offsets.size, self.comm)
@@ -983,7 +983,7 @@ class ExecComp(ExplicitComponent):
         if not self._relcopy:
             self._inputs.set_val(starting_inputs)
 
-        sparsity, sp_info = jac.get_sparsity(self)
+        sparsity, sp_info = jac.get_sparsity()
         sparsity_time = time.perf_counter() - sparsity_start_time
 
         coloring = _compute_coloring(sparsity, 'fwd')
diff --git a/openmdao/core/component.py b/openmdao/core/component.py
index 3c11d69ab..b010e14d0 100644
--- a/openmdao/core/component.py
+++ b/openmdao/core/component.py
@@ -5,19 +5,20 @@ import types
 from collections import defaultdict
 from collections.abc import Iterable
 from itertools import product
+from io import StringIO
 
 from numbers import Integral
 import numpy as np
 from numpy import ndarray, isscalar, ndim, atleast_1d, atleast_2d, promote_types
-from scipy.sparse import issparse, coo_matrix
+from scipy.sparse import issparse, coo_matrix, csr_matrix
 
 from openmdao.core.system import System, _supported_methods, _DEFAULT_COLORING_META, \
     global_meta_names, collect_errors
-from openmdao.core.constants import INT_DTYPE
+from openmdao.core.constants import INT_DTYPE, _DEFAULT_OUT_STREAM
 from openmdao.jacobians.dictionary_jacobian import DictionaryJacobian
-from openmdao.utils.array_utils import shape_to_len, submat_sparsity_iter
+from openmdao.utils.array_utils import shape_to_len, submat_sparsity_iter, sparsity_diff_viz
 from openmdao.utils.units import simplify_unit
-from openmdao.utils.name_maps import abs_key_iter, abs_key2rel_key
+from openmdao.utils.name_maps import abs_key_iter, abs_key2rel_key, rel_key2abs_key
 from openmdao.utils.mpi import MPI
 from openmdao.utils.general_utils import format_as_float_or_array, ensure_compatible, \
     find_matches, make_set, inconsistent_across_procs
@@ -1454,6 +1455,23 @@ class Component(System):
 
         return opts
 
+    def _get_approx_partial_options(self, key, method='fd', checkopts=None):
+        # TODO: extend this to Groups
+        approx = self._get_approx_scheme(method)
+        options = approx.DEFAULT_OPTIONS.copy()
+        if checkopts is None:
+            options.update(approx._wrt_meta)
+        else:
+            options.update(checkopts)
+            options.update(approx._wrt_meta)
+
+        abs_key = rel_key2abs_key(self, key)
+        if abs_key in self._subjacs_info:
+            meta = self._subjacs_info[abs_key]
+            options.update({k: v for k, v in meta.items() if v is not None and k in options})
+
+        return options
+
     def _resolve_partials_patterns(self, of, wrt, pattern_meta):
         """
         Store subjacobian metadata for specific of, wrt pairs after resolving glob patterns.
@@ -1528,6 +1546,10 @@ class Component(System):
             if abs_key in self._subjacs_info:
                 meta = self._subjacs_info[abs_key]
                 meta.update(patmeta_not_none)
+                if 'rows' in meta and meta['rows'] is not None:
+                    rows = meta['rows']
+                if 'cols' in meta and meta['cols'] is not None:
+                    cols = meta['cols']
             else:
                 meta = patmeta.copy()
 
@@ -1650,11 +1672,9 @@ class Component(System):
             of_pattern, of_matches = of_bundle
             wrt_pattern, wrt_matches = wrt_bundle
             if not of_matches:
-                raise ValueError('{}: No matches were found for of="{}"'.format(self.msginfo,
-                                                                                of_pattern))
+                raise ValueError(f'{self.msginfo}: No matches were found for of="{of_pattern}"')
             if not wrt_matches:
-                raise ValueError('{}: No matches were found for wrt="{}"'.format(self.msginfo,
-                                                                                 wrt_pattern))
+                raise ValueError(f'{self.msginfo}: No matches were found for wrt="{wrt_pattern}"')
             yield from abs_key_iter(self, of_matches, wrt_matches)
 
     def _find_of_matches(self, pattern, use_resname=False):
@@ -1905,42 +1925,154 @@ class Component(System):
         meta['base'] = 'ExplicitComponent' if self.is_explicit() else 'ImplicitComponent'
         return meta
 
-    def check_subjac_sparsity(self):
+    def compute_fd_jac(self, jac, method='fd'):
+        """
+        Force the use of finite difference to compute a jacobian.
+
+        This can be used to compute sparsity for a component that computes derivatives analytically
+        in order to check the accuracy of the declared sparsity.
+
+        Parameters
+        ----------
+        jac : Jacobian
+            The Jacobian object that will contain the computed jacobian.
+        method : str
+            The type of finite difference to perform. Valid options are 'fd' for forward difference,
+            or 'cs' for complex step.
+        """
+        fd_methods = {'fd': _supported_methods['fd'], 'cs': _supported_methods['cs']}
+        try:
+            approximation = fd_methods[method]()
+        except KeyError:
+            raise ValueError(f"Method '{method}' is not a recognized finite difference method.")
+
+        # these are relative names
+        of = self._get_partials_ofs()
+        wrt = self._get_partials_wrts()
+
+        local_opts = self._get_check_partial_options()
+        added_wrts = set()
+
+        for rel_key in product(of, wrt):
+            fd_options = self._get_approx_partial_options(rel_key, method=method,
+                                                          checkopts=local_opts)
+            abs_key = rel_key2abs_key(self, rel_key)
+
+            # prevent adding multiple approxs with same wrt (and confusing users with warnings)
+            if abs_key[1] not in added_wrts:
+                approximation.add_approximation(abs_key, self, fd_options)
+                added_wrts.add(abs_key[1])
+
+        # Perform the FD here.
+        with self._unscaled_context(outputs=[self._outputs], residuals=[self._residuals]):
+            approximation.compute_approximations(self, jac=jac)
+
+    def compute_fd_sparsity(self, method='fd', num_full_jacs=2, perturb_size=1e-9):
+        """
+        Use finite difference to compute a sparsity matrix.
+
+        Parameters
+        ----------
+        method : str
+            The type of finite difference to perform. Valid options are 'fd' for forward difference,
+            or 'cs' for complex step.
+        num_full_jacs : int
+            Number of times to repeat jacobian computation using random perturbations.
+        perturb_size : float
+            Size of the random perturbation.
+
+        Returns
+        -------
+        coo_matrix
+            The sparsity matrix.
+        """
+        jac = coloring_mod._ColSparsityJac(self)
+        for _ in self._perturbation_iter(num_full_jacs, perturb_size):
+            self.compute_fd_jac(jac=jac, method=method)
+        return jac.get_sparsity()
+
+    def check_sparsity(self, method='fd', max_nz=90., out_stream=_DEFAULT_OUT_STREAM):
         """
-        Check the declared sparsity of the sub-jacobians vs. the computed sparsity.
+        Check the sparsity of the computed jacobian against the declared sparsity.
+
+        Check is skipped if one of the dimensions of the jacobian is 1 or if the percentage of
+        nonzeros in the computed jacobian is greater than max_nz%.
+
+        Parameters
+        ----------
+        method : str
+            The type of finite difference to perform. Valid options are 'fd' for forward difference,
+            or 'cs' for complex step.
+        max_nz : float
+            If the percentage of nonzeros in a sub-jacobian exceeds this, no warning is issued if
+            the computed sparsity does not match the declared sparsity.
+        out_stream : file-like object
+            Where to send the output.  If None, output will be suppressed.
+
+        Returns
+        -------
+        list
+            A list of tuples, one for each subjacobian that has a mismatch between the computed
+            sparsity and the declared sparsity.  Each tuple has the form (of, wrt, computed_rows,
+            computed_cols, declared_rows, declared_cols, shape, pct_nonzero).
         """
-        sparsity, _ = self.compute_sparsity()
-        full_nzrows = sparsity.row
-        full_nzcols = sparsity.col
+        if out_stream == _DEFAULT_OUT_STREAM:
+            out_stream = sys.stdout
 
-        def row_size_iter():
+        def rowsizeiter():
             for of, start, end, _, _ in self._jac_of_iter():
                 yield of, end - start
 
-        def col_size_iter():
+        def colsizeiter():
             for wrt, start, end, _, _, _ in self._jac_wrt_iter():
                 yield wrt, end - start
 
-        prefix_len = len(self.pathname) + 1
-        for of, wrt, sjrows, sjcols, _ in submat_sparsity_iter(row_size_iter(), col_size_iter(),
-                                                               full_nzrows, full_nzcols,
-                                                               (len(self._outputs),
-                                                                len(self._inputs))):
-            if (of, wrt) in self._subjacs_info:
-                meta = self._subjacs_info[of, wrt]
-                if meta['rows'] is None and sjrows is not None:
-                    issue_warning(f"Sparsity pattern for {of} wrt {wrt} was declared with "
-                                  "rows=None, but a sparsity pattern has been computed.\n"
-                                  f"rows = {sjrows}\ncols = {sjcols}\n")
-                elif not np.all(np.asarray(meta['rows']) == sjrows):
-                    issue_warning(f"Sparsity pattern for {of} wrt {wrt} was declared with "
-                                  f"rows={meta['rows']} and col={meta['cols']}, but a different "
-                                  "sparsity pattern has been computed:\n"
-                                  f"rows = {sjrows}\ncols = {sjcols}\n")
-            else:
-                issue_warning(f"{self.msginfo}: Partial for {of[prefix_len:]} wrt "
-                              f"{wrt[prefix_len:]} was not declared but is nonzero.\n"
-                              f"rows = {sjrows}\ncols = {sjcols}\n")
+        sparsity, _ = self.compute_fd_sparsity(method=method)
+
+        prefix = self.pathname + '.'
+        plen = len(prefix)
+        ret = []
+        for of, wrt, nzrows, nzcols, shape in submat_sparsity_iter(rowsizeiter(), colsizeiter(),
+                                                                   sparsity.row, sparsity.col,
+                                                                   sparsity.shape):
+            if 1 in shape:
+                continue
+            key = (of, wrt)
+            if key in self._subjacs_info:
+                meta = self._subjacs_info[key]
+                computed = sorted(zip(nzrows, nzcols))
+                if meta['rows'] is None:
+                    rows = []
+                    cols = []
+                    declared = []
+                else:
+                    rows = meta['rows']
+                    cols = meta['cols']
+                    declared = sorted(zip(rows, cols))
+                if declared != computed:
+                    pct_nonzero = 100. * len(nzrows) / (shape[0] * shape[1])
+                    if pct_nonzero > max_nz:
+                        continue
+                    if shape[0] > 200 or shape[1] > 200:
+                        mstr = "Sparsity matrix too large to show."
+                    else:
+                        stream = StringIO()
+                        val_map = {0: '.', 1: 'C', 3: 'D', 4: 'x'}
+                        sparsity_diff_viz(csr_matrix((np.ones(len(nzrows)), (nzrows, nzcols)),
+                                                     shape=shape, dtype=bool),
+                                          csr_matrix((np.ones(len(rows)), (rows, cols)),
+                                                     shape=shape, dtype=bool),
+                                          val_map=val_map,
+                                          stream=stream)
+                        mstr = stream.getvalue()
+                    wrn = (f"{self.msginfo}:\n(D)eclared sparsity pattern != (c)omputed sparsity "
+                           f"pattern for sub-jacobian ({of[plen:]}, {wrt[plen:]}) with shape "
+                           f"{shape} and {pct_nonzero:.2f}% nonzeros:\n{mstr}\n")
+                    ret.append((of, wrt, nzrows, nzcols, rows, cols, shape, pct_nonzero, wrn))
+                    if out_stream is not None:
+                        print(wrn, file=out_stream)
+
+        return ret
 
 
 class _DictValues(object):
diff --git a/openmdao/core/problem.py b/openmdao/core/problem.py
index 6cbc0dc2e..5d136939c 100644
--- a/openmdao/core/problem.py
+++ b/openmdao/core/problem.py
@@ -3609,10 +3609,8 @@ def _format_error(error, tol):
     return f'{error:.6e} *'
 
 
-def _get_fd_options(var, global_method, local_opts, global_step, global_form, global_step_calc,
-                    alloc_complex, global_minimum_step):
-    local_wrt = var
-
+def _get_fd_options(local_wrt, global_method, local_opts, global_step, global_form,
+                    global_step_calc, alloc_complex, global_minimum_step):
     # Determine if fd or cs.
     method = global_method
     if local_wrt in local_opts:
diff --git a/openmdao/core/system.py b/openmdao/core/system.py
index 2692bf9db..d2d0ee406 100644
--- a/openmdao/core/system.py
+++ b/openmdao/core/system.py
@@ -709,9 +709,6 @@ class System(object, metaclass=SystemMetaclass):
         """
         Iterate over (name, offset, end, slice, dist_sizes) for each 'of' (row) var in the jacobian.
 
-        The slice is internal to the given variable in the result, and this is always a full
-        slice except when indices are defined for the 'of' variable.
-
         Yields
         ------
         str
@@ -721,7 +718,7 @@ class System(object, metaclass=SystemMetaclass):
         int
             Ending index.
         slice or ndarray
-            A full slice or indices for the 'of' variable.
+            A full slice.
         ndarray or None
             Distributed sizes if var is distributed else None
         """
@@ -1653,7 +1650,7 @@ class System(object, metaclass=SystemMetaclass):
         info.set_coloring(coloring, msginfo=self.msginfo)
         if info._failed:
             if not info.per_instance:
-                # save the class coloring for so resources won't be wasted computing
+                # save the class coloring so resources won't be wasted computing
                 # a bad coloring
                 fname = self.get_coloring_fname(mode='output')
                 coloring_mod._CLASS_COLORINGS[fname] = None
@@ -1693,59 +1690,50 @@ class System(object, metaclass=SystemMetaclass):
 
         return True
 
-    def compute_sparsity(self):
+    def _perturbation_iter(self, num_full_jacs, perturb_size):
         """
-        Compute the sparsity of the partial jacobian.
-
-        Returns
-        -------
-        coo_matrix
-            The sparsity matrix.
-        dict
-            Metadata about the sparsity computation.
-        """
-        use_jax = self.options['derivs_method'] == 'jax'
-        if not use_jax:
-            approx_scheme = self._get_approx_scheme(self._coloring_info['method'])
+        Iterate over random perturbations of the inputs array.
 
-        save_first_call = self._first_call_to_linearize
-        self._first_call_to_linearize = False
-
-        # for groups, this does some setup of approximations
-        self._setup_approx_coloring()
+        For implicit components, we also randomize the outputs.  The perturbation is relative
+        to the starting value of the input or output, unless that value is 0.0, in which case
+        the perturbation is absolute.  The final value of the input or output is the perturbation
+        multiplied by a random number between 0 and 1 added to the starting value.
 
-        # tell approx scheme to limit itself to only colored columns
-        if not use_jax:
-            approx_scheme._reset()
-            self._during_sparsity = True
-
-        self._coloring_info._update_wrt_matches(self)
+        Inputs, outputs, and residuals are all restored to their starting values at the end of
+        the iterations.
 
-        save_jac = self._jacobian
-
-        # use special sparse jacobian to collect sparsity info
-        self._jacobian = _ColSparsityJac(self, self._coloring_info)
+        Parameters
+        ----------
+        num_full_jacs : int
+            Number of full jacobians to compute.
+        perturb_size : float
+            Size of relative perturbation.  If base value is 0.0, perturbation is absolute.
 
+        Yields
+        ------
+        int
+            The current iteration number.
+        """
         from openmdao.core.group import Group
         is_total = isinstance(self, Group)
+        use_jax = self.options['derivs_method'] == 'jax'
         is_explicit = self.is_explicit()
 
-        # compute perturbations
         starting_inputs = self._inputs.asarray(copy=True)
+        starting_outputs = self._outputs.asarray(copy=True)
+        starting_resids = self._residuals.asarray(copy=True)
+
+        # compute perturbations
         in_offsets = starting_inputs.copy()
         in_offsets[in_offsets == 0.0] = 1.0
-        in_offsets *= self._coloring_info['perturb_size']
-
-        starting_outputs = self._outputs.asarray(copy=True)
+        in_offsets *= perturb_size
 
         if not is_explicit:
             out_offsets = starting_outputs.copy()
             out_offsets[out_offsets == 0.0] = 1.0
-            out_offsets *= self._coloring_info['perturb_size']
+            out_offsets *= perturb_size
 
-        starting_resids = self._residuals.asarray(copy=True)
-
-        for i in range(self._coloring_info['num_full_jacs']):
+        for i in range(num_full_jacs):
             # randomize inputs (and outputs if implicit)
             if i > 0:
                 self._inputs.set_val(starting_inputs +
@@ -1762,28 +1750,75 @@ class System(object, metaclass=SystemMetaclass):
                 if not use_jax:
                     for scheme in self._approx_schemes.values():
                         scheme._reset()  # force a re-initialization of approx
+            elif is_explicit and not is_total:
+                self._apply_nonlinear()  # need this to get the output values into the resids
 
-            if use_jax:
-                self._jax_linearize()
-            else:
-                self.run_linearize(sub_do_ln=False)
-
-        sparsity, sp_info = self._jacobian.get_sparsity(self)
-
-        self._jacobian = save_jac
+            yield i
 
         if not use_jax:
-            self._during_sparsity = False
-
             # revert uncolored approx back to normal
             for scheme in self._approx_schemes.values():
                 scheme._reset()
 
-        # restore original inputs/outputs
+        # restore original inputs/outputs/resids
         self._inputs.set_val(starting_inputs)
         self._outputs.set_val(starting_outputs)
         self._residuals.set_val(starting_resids)
 
+    def compute_sparsity(self):
+        """
+        Compute the sparsity of the partial jacobian.
+
+        Returns
+        -------
+        coo_matrix
+            The sparsity matrix.
+        dict
+            Metadata about the sparsity computation.
+        """
+        use_jax = self.options['derivs_method'] == 'jax'
+        if not use_jax:
+            approx_scheme = self._get_approx_scheme(self._coloring_info['method'])
+
+        save_first_call = self._first_call_to_linearize
+        self._first_call_to_linearize = False
+
+        # for groups, this does some setup of approximations
+        self._setup_approx_coloring()
+
+        # tell approx scheme to limit itself to only colored columns
+        if not use_jax:
+            approx_scheme._reset()
+            self._during_sparsity = True
+
+        self._coloring_info._update_wrt_matches(self)
+
+        save_jac = self._jacobian
+
+        # use special sparse jacobian to collect sparsity info
+        self._jacobian = _ColSparsityJac(self)
+
+        from openmdao.core.group import Group
+        is_total = isinstance(self, Group)
+
+        for i in self._perturbation_iter(self._coloring_info['num_full_jacs'],
+                                         self._coloring_info['perturb_size']):
+            if use_jax:
+                self._jax_linearize()
+                sparsity, sp_info = self._jacobian.get_sparsity()
+            elif is_total:
+                self.run_linearize(sub_do_ln=False)
+                sparsity, sp_info = self._jacobian.get_sparsity()
+            else:  # for components
+                # this avoids calling any compute_partials/linearize methods which will fail
+                # because _ColSparsityJac only supports set_col and not dict access.
+                sparsity, sp_info = self.compute_fd_sparsity()
+
+        self._jacobian = save_jac
+
+        if not use_jax:
+            self._during_sparsity = False
+
         self._first_call_to_linearize = save_first_call
 
         return sparsity, sp_info
@@ -1838,22 +1873,28 @@ class System(object, metaclass=SystemMetaclass):
             for meta in self._subjacs_info.values():
                 if 'method' in meta and meta['method']:
                     break
-            else:  # no approx derivs found
-                if not (self._owns_approx_of or self._owns_approx_wrt):
-                    issue_warning("No partials found but coloring was requested.  "
-                                  "Declaring ALL partials as dense "
-                                  "(method='{}')".format(info['method']),
-                                  prefix=self.msginfo, category=DerivativesWarning)
-                    try:
-                        self.declare_partials('*', '*', method=info['method'])
-                    except AttributeError:  # assume system is a group
-                        from openmdao.core.component import Component
-                        from openmdao.core.indepvarcomp import IndepVarComp
-                        from openmdao.components.exec_comp import ExecComp
-                        for s in self.system_iter(recurse=True, typ=Component):
-                            if not isinstance(s, ExecComp) and not isinstance(s, IndepVarComp):
-                                s.declare_partials('*', '*', method=info['method'])
-                    self._setup_partials()
+            else:  # no approx or jax partials found
+                method = info['method']
+                if self._subjacs_info:
+                    for meta in self._subjacs_info.values():
+                        meta['method'] = method
+
+                else:  # declare all derivs as approx
+                    if not (self._owns_approx_of or self._owns_approx_wrt):
+                        issue_warning("No approx or jax partials found but coloring was requested. "
+                                      "Declaring ALL partials as dense "
+                                      "(method='{}')".format(info['method']),
+                                      prefix=self.msginfo, category=DerivativesWarning)
+                        try:
+                            self.declare_partials('*', '*', method=info['method'])
+                        except AttributeError:  # assume system is a group
+                            from openmdao.core.component import Component
+                            from openmdao.core.indepvarcomp import IndepVarComp
+                            from openmdao.components.exec_comp import ExecComp
+                            for s in self.system_iter(recurse=True, typ=Component):
+                                if not isinstance(s, ExecComp) and not isinstance(s, IndepVarComp):
+                                    s.declare_partials('*', '*', method=info['method'])
+                        self._setup_partials()
 
         if not use_jax:
             approx_scheme = self._get_approx_scheme(info['method'])
diff --git a/openmdao/error_checking/check_config.py b/openmdao/error_checking/check_config.py
index 57b33607e..427ca465b 100644
--- a/openmdao/error_checking/check_config.py
+++ b/openmdao/error_checking/check_config.py
@@ -11,6 +11,7 @@ import pickle
 from openmdao.core.group import Group
 from openmdao.core.component import Component
 from openmdao.core.implicitcomponent import ImplicitComponent
+from openmdao.utils.array_utils import array_hash
 from openmdao.utils.graph_utils import get_sccs_topo
 from openmdao.utils.logger_utils import get_logger, TestLogger
 from openmdao.utils.mpi import MPI
@@ -637,6 +638,31 @@ def _check_explicitly_connected_promoted_inputs(problem, logger):
                            "promoted up from %s." % (inp, man_group, man_prom, s))
 
 
+def _check_bad_sparsity(problem, logger):
+    """
+    Check for any declared sparsity patterns that don't match the computed sparsity pattern.
+
+    Parameters
+    ----------
+    problem : <Problem>
+        The problem being checked.
+    logger : object
+        The object that manages logging output.
+    """
+    seen = set()
+    for comp in problem.model.system_iter(include_self=True, recurse=True, typ=Component):
+        plen = len(comp.pathname) + 1
+        for of, wrt, computed_rows, computed_cols, rows, cols, _, _, wrn in \
+                comp.check_sparsity(out_stream=None):
+            # don't repeat same class over if diffs are the same
+            chk = (type(comp).__name__, of[plen:], wrt[plen:],
+                   array_hash(np.asarray(rows)), array_hash(np.asarray(cols)),
+                   array_hash(np.asarray(computed_rows)), array_hash(np.asarray(computed_cols)))
+            if chk not in seen:
+                seen.add(chk)
+                logger.warning(wrn)
+
+
 # Dict of all checks by name, mapped to the corresponding function that performs the check
 # Each function must be of the form  f(problem, logger).
 _default_checks = {
@@ -656,6 +682,7 @@ _all_checks.update({
     'unconnected_inputs': _check_hanging_inputs,
     'promotions': _check_explicitly_connected_promoted_inputs,
     'all_unserializable_options': _check_all_unserializable_options,
+    'sparsity': _check_bad_sparsity,
 })
 
 _all_non_redundant_checks = _all_checks.copy()
diff --git a/openmdao/utils/array_utils.py b/openmdao/utils/array_utils.py
index 9aef8c618..bd714d04d 100644
--- a/openmdao/utils/array_utils.py
+++ b/openmdao/utils/array_utils.py
@@ -7,8 +7,7 @@ import hashlib
 
 import numpy as np
 
-from scipy.sparse import coo_matrix, csr_matrix
-
+from scipy.sparse import coo_matrix, csr_matrix, issparse
 from openmdao.core.constants import INT_DTYPE
 from openmdao.utils.omnumba import numba
 
@@ -188,6 +187,108 @@ def take_nth(rank, size, seq):
                     return
 
 
+def csr_array_viz(arr, val_map=None, stream=sys.stdout):
+    """
+    Display the structure of a boolean array in a compact form.
+
+    Parameters
+    ----------
+    arr : ndarray
+        Array being visualized.
+    val_map : dict or None
+        Mapping of array values to characters.
+    stream : file-like
+        Stream where output will be written.
+    """
+    if len(arr.shape) != 2:
+        raise RuntimeError("simple_array_viz only works for 2d arrays.")
+
+    if val_map is None:
+        val_map = {1: 'x', 0: '.'}
+
+    final = arr.tocsr() if issparse(arr) else csr_matrix(arr, dtype=np.int8)
+    final = final.astype(np.int8, copy=final.dtype is not np.int8)
+    rowarr = np.zeros(final.shape[1], dtype=np.int8)
+
+    for r in range(final.shape[0]):
+        row = final.getrow(r)
+        rowinds = row.indices
+        rowarr[:] = 0
+        rowarr[rowinds] = row.data
+        stream.write(''.join(val_map[c] for c in rowarr))
+        stream.write(f'  {r}\n')
+
+
+def get_sparsity_diff_array(sparsity1, sparsity2):
+    """
+    Return an array showing the difference between two sparsity patterns.
+
+    Parameters
+    ----------
+    sparsity1 : bool ndarray or sparse array or None
+        First sparsity pattern.
+    sparsity2 : bool ndarray or sparse array or None
+        Second sparsity pattern.
+
+    Returns
+    -------
+    csr_array
+        Sparse array of dtype int8 where:
+        0: zero val in both
+        1: non-zero val in sparsity1
+        3: non-zero val in sparsity2
+        4: non-zero val in both.
+    """
+    assert not (sparsity1 is None and sparsity2 is None), \
+        'At least one sparsity pattern must be provided.'
+    if ((sparsity1 is not None and sparsity1.dtype != bool) or
+            (sparsity2 is not None and sparsity2.dtype != bool)):
+        raise ValueError('Sparsity patterns must be boolean.')
+
+    if issparse(sparsity1):
+        sp1 = sparsity1.tocsr().astype(np.int8)
+    elif sparsity1 is None:
+        sp1 = csr_matrix(([], ([], [])), shape=sparsity2.shape, dtype=np.int8)
+    else:
+        sp1 = csr_matrix(sparsity1, dtype=np.int8)
+
+    if issparse(sparsity2):
+        sp2 = sparsity2.tocsr().astype(np.int8)
+    elif sparsity2 is None:  # build empty sparse matrix of same shape as sp1
+        sp2 = csr_matrix(([], ([], [])), shape=sp1.shape, dtype=np.int8)
+    else:
+        sp2 = csr_matrix(sparsity2, dtype=np.int8)
+
+    assert sp1.shape == sp2.shape, 'Sparsity patterns must have the same shape.'
+
+    # set so that we get unique values for their sum:
+    sp1.data[:] = 1
+    sp2.data[:] = 3
+
+    return sp1 + sp2
+
+
+def sparsity_diff_viz(arr1, arr2, val_map=None, stream=sys.stdout):
+    """
+    Display the difference between two sparsity patterns in a compact form.
+
+    Parameters
+    ----------
+    arr1 : ndarray
+        First sparsity pattern.
+    arr2 : ndarray
+        Second sparsity pattern.
+    val_map : dict or None
+        Mapping of array values to characters.
+    stream : file-like
+        Stream where output will be written.
+    """
+    if val_map is None:
+        val_map = {0: '.', 1: '1', 3: '2', 4: 'x'}
+    spdiff = get_sparsity_diff_array(arr1, arr2)
+    csr_array_viz(spdiff, val_map=val_map, stream=stream)
+
+
 def array_viz(arr, prob=None, of=None, wrt=None, stream=sys.stdout):
     """
     Display the structure of a boolean array in a compact form.
@@ -219,15 +320,8 @@ def array_viz(arr, prob=None, of=None, wrt=None, stream=sys.stdout):
             wrt = list(prob.driver._designvars)
 
     if prob is None or of is None or wrt is None:
-        for r in range(arr.shape[0]):
-            for c in range(arr.shape[1]):
-                if arr[r, c]:
-                    stream.write('x')
-                else:
-                    stream.write('.')
-            stream.write(' %d\n' % r)
+        csr_array_viz(arr, stream=stream)
     else:
-
         row = 0
         for res in of:
             for r in range(row, row + prob.driver._responses[res]['size']):
diff --git a/openmdao/utils/coloring.py b/openmdao/utils/coloring.py
index 8a2c3ad00..a21d2a5aa 100644
--- a/openmdao/utils/coloring.py
+++ b/openmdao/utils/coloring.py
@@ -11,6 +11,7 @@ import sys
 import tempfile
 import traceback
 import webbrowser
+import inspect
 from itertools import combinations, groupby
 from contextlib import contextmanager
 from pprint import pprint
@@ -600,8 +601,8 @@ class Coloring(object):
 
     Parameters
     ----------
-    sparsity : ndarray
-        Full jacobian sparsity matrix (dense bool form).
+    sparsity : ndarray or coo_matrix or csc_matrix or csr_matrix
+        Jacobian sparsity matrix, dtype=bool.
     row_vars : list of str or None
         Names of variables corresponding to rows.
     row_var_sizes : ndarray or None
@@ -1139,8 +1140,8 @@ class Coloring(object):
         out_stream : file-like or _DEFAULT_OUT_STREAM
             The destination stream to which the text representation of coloring is to be written.
         """
-        nrows = self._shape[0] if self._shape else -1
-        ncols = self._shape[1] if self._shape else -1
+        nrows = self._shape[0]
+        ncols = self._shape[1]
 
         if out_stream == _DEFAULT_OUT_STREAM:
             out_stream = sys.stdout
@@ -1164,16 +1165,17 @@ class Coloring(object):
         print('', file=out_stream)
         good_tol = meta.get('good_tol')
         if good_tol is not None:
-            print("Sparsity computed using tolerance: %g" % meta['good_tol'], file=out_stream)
+            print(f"Sparsity computed using tolerance: {meta['good_tol']}.", file=out_stream)
             if meta['n_tested'] > 1:
-                print("Most common number of nonzero entries (%d of %d) repeated %d times out "
-                      "of %d tolerances tested.\n" % (meta['J_size'] - meta['zero_entries'],
-                                                      meta['J_size'],
-                                                      meta['nz_matches'], meta['n_tested']),
-                      file=out_stream)
+                jsize = np.prod(self._shape)
+                print(f"Most common number of nonzero entries ({meta['nz_entries']} of {jsize}) "
+                      f"repeated {meta['nz_matches']} times out of {meta['n_tested']} tolerances "
+                      "tested.\n", file=out_stream)
 
         sparsity_time = meta.get('sparsity_time', None)
         if sparsity_time is not None:
+            print(f"Dense {meta['type']} jacobian for {meta['class']} '{meta['pathname']}' was "
+                  f"computed {meta['num_full_jacs']} times.", file=out_stream)
             print(f"Time to compute sparsity: {sparsity_time:8.4f} sec", file=out_stream)
 
         coloring_time = meta.get('coloring_time', None)
@@ -2707,8 +2709,6 @@ def _tol_sweep(arr, tol=_DEF_COMP_SPARSITY_ARGS['tol'], orders=_DEF_COMP_SPARSIT
         'good_tol': good_tol,
         'nz_matches': nz_matches,
         'n_tested': n_tested,
-        'zero_entries': arr[arr <= good_tol].size,
-        'J_size': arr.size,
     }
 
     return info
@@ -2813,8 +2813,9 @@ def _get_total_jac_sparsity(prob, num_full_jacs=_DEF_COMP_SPARSITY_ARGS['num_ful
     else:
         colorinfo = None
 
+    start_time = time.perf_counter()
+
     with _compute_total_coloring_context(prob, colorinfo):
-        start_time = time.perf_counter()
         fullJ = None
         for _ in range(num_full_jacs):
             if needs_scaling:
@@ -2828,7 +2829,6 @@ def _get_total_jac_sparsity(prob, num_full_jacs=_DEF_COMP_SPARSITY_ARGS['num_ful
                 fullJ += np.abs(Jabs)
 
         Jabs = None
-        elapsed = time.perf_counter() - start_time
 
     if driver:
         # force driver to recreate total jacobian using coloring
@@ -2837,14 +2837,14 @@ def _get_total_jac_sparsity(prob, num_full_jacs=_DEF_COMP_SPARSITY_ARGS['num_ful
     fullJ *= (1.0 / np.max(fullJ))
 
     spmeta = _tol_sweep(fullJ, tol, orders)
+    spmeta['J_shape'] = fullJ.shape
+    spmeta['class'] = type(prob).__name__
+    spmeta['pathname'] = prob._metadata['pathname']
+    spmeta['nz_entries'] = np.count_nonzero(fullJ)
     spmeta['num_full_jacs'] = num_full_jacs
-    spmeta['sparsity_time'] = elapsed
+    spmeta['sparsity_time'] = time.perf_counter() - start_time
     spmeta['type'] = 'total'
 
-    print(f"Full total jacobian for problem '{prob._metadata['pathname']}' was computed "
-          f"{num_full_jacs} times, taking {elapsed} seconds.")
-    print("Total jacobian shape:", fullJ.shape, "\n")
-
     nzrows, nzcols = np.nonzero(fullJ > spmeta['good_tol'])
     shape = fullJ.shape
     fullJ = None
@@ -3260,7 +3260,7 @@ def _get_partial_coloring_kwargs(system, options):
         if getattr(options, name) is not None:
             kwargs[name] = getattr(options, name)
 
-    kwargs['recurse'] = not options.norecurse and not system._subsystems_allprocs
+    kwargs['recurse'] = not options.norecurse and system._subsystems_allprocs
 
     per_instance = getattr(options, 'per_instance')
     kwargs['per_instance'] = (per_instance is None or
@@ -3299,7 +3299,7 @@ def _partial_coloring_cmd(options, user_args):
             coloring.display_bokeh(show=True)
             print('\n')
 
-        if not coloring._meta.get('show_summary'):
+        if not coloring._meta.get('show_summary'):  # avoids double printing of summary
             print("\nApprox coloring for '%s' (class %s)" % (system.pathname,
                                                              type(system).__name__))
             coloring.summary()
@@ -3319,7 +3319,8 @@ def _partial_coloring_cmd(options, user_args):
             with profiling('coloring_profile.out') if options.profile else do_nothing_context():
                 if options.system == '':
                     system = prob.model
-                    _initialize_model_approx(system, prob.driver)
+                    if not options.classes:
+                        _initialize_model_approx(system, prob.driver)
                 else:
                     system = prob.model._get_subsystem(options.system)
                 if system is None:
@@ -3333,22 +3334,30 @@ def _partial_coloring_cmd(options, user_args):
                     kwargs['recurse'] = False
                     for s in system.system_iter(include_self=True, recurse=True):
                         for c in options.classes:
-                            klass = s.__class__.__name__
-                            mod = s.__class__.__module__
-                            if c == klass or c == '.'.join([mod, klass]):
-                                if c in to_find:
+                            klasses = [m.__name__ for m in inspect.getmro(s.__class__)][:-1]
+                            mods = [m.__module__ for m in inspect.getmro(s.__class__)][:-1]
+                            for klass, mod in zip(klasses, mods):
+                                mod = s.__class__.__module__
+                                if c == klass or c == '.'.join([mod, klass]):
                                     found.add(c)
-                                try:
-                                    coloring = s._compute_coloring(**kwargs)[0]
-                                except Exception:
-                                    tb = traceback.format_exc()
-                                    print("The following error occurred while attempting to "
-                                          "compute coloring for %s:\n %s" % (s.pathname, tb))
-                                else:
-                                    if coloring is not None:
-                                        _show(s, options, coloring)
-                                if options.norecurse:
-                                    break
+                                    if len(s._var_allprocs_abs2meta['input']) == 0 or \
+                                            len(s._var_allprocs_abs2meta['output']) == 0:
+                                        print(f"Skipping {s.pathname} <class {klass}> because it "
+                                              "has either no inputs or no outputs.")
+                                        continue
+                                    try:
+                                        coloring = s._compute_coloring(**kwargs)[0]
+                                    except Exception:
+                                        tb = traceback.format_exc()
+                                        print("The following error occurred while attempting to "
+                                              f"compute coloring for {s.pathname}:\n {tb}")
+                                    else:
+                                        if coloring is not None:
+                                            _show(s, options, coloring)
+                                    if options.norecurse:
+                                        break
+                            if c in found:
+                                break
                     else:
                         if to_find - found:
                             raise RuntimeError("Failed to find any instance of classes %s" %
@@ -3453,15 +3462,17 @@ class _ColSparsityJac(object):
     A class to manage the assembly of a sparsity matrix by columns without allocating a dense jac.
     """
 
-    def __init__(self, system, coloring_info):
-        self._coloring_info = coloring_info
+    def __init__(self, system):
+        self._coloring_info = system._coloring_info
 
         nrows = sum([end - start for _, start, end, _, _ in system._jac_of_iter()])
-        for _, _, end, _, _, _ in system._jac_wrt_iter(coloring_info.wrt_matches):
+        end = 0
+        for _, _, end, _, _, _ in system._jac_wrt_iter(self._coloring_info.wrt_matches):
             pass
 
         self._nrows = nrows
         self._ncols = end
+        self.shape = (nrows, end)
         self._col_list = [None] * self._ncols
 
     def set_col(self, system, i, column):
@@ -3500,15 +3511,10 @@ class _ColSparsityJac(object):
         # ignore any setting of subjacs based on analytic derivs
         pass
 
-    def get_sparsity(self, system):
+    def get_sparsity(self):
         """
         Assemble the sparsity matrix (COO) based on data collected earlier via set_col.
 
-        Parameters
-        ----------
-        system : System
-            The system that owns this jacobian.
-
         Returns
         -------
         coo_matrix
@@ -3537,6 +3543,8 @@ class _ColSparsityJac(object):
             data *= (1. / np.max(data))
 
             info = _tol_sweep(data, coloring_info.tol, coloring_info.orders)
+            info['J_shape'] = self.shape
+            info['nz_entries'] = np.count_nonzero(data)
             data = data > info['good_tol']  # data is now a bool
             rows = rows[data]
             cols = cols[data]
@@ -3551,8 +3559,8 @@ class _ColSparsityJac(object):
                 'good_tol': coloring_info.tol,
                 'nz_matches': 0,
                 'n_tested': 0,
-                'zero_entries': 0,
-                'J_size': 0,
+                'nz_entries': 0,
+                'J_shape': (0, 0),
             }
 
         return coo_matrix((data, (rows, cols)), shape=(self._nrows, self._ncols)), info
diff --git a/openmdao/utils/name_maps.py b/openmdao/utils/name_maps.py
index 01c561479..3d9f7d9cd 100644
--- a/openmdao/utils/name_maps.py
+++ b/openmdao/utils/name_maps.py
@@ -289,8 +289,8 @@ def abs_key_iter(system, rel_ofs, rel_wrts):
     abs_wrt
         Absolute 'wrt' name.
     """
-    pname = system.pathname + '.' if system.pathname else ''
-    if pname:
+    if system.pathname:
+        pname = system.pathname + '.'
         abs_wrts = [pname + r for r in rel_wrts]
         for rel_of in rel_ofs:
             abs_of = pname + rel_of


Test patch:
diff --git a/openmdao/core/tests/test_coloring.py b/openmdao/core/tests/test_coloring.py
index 68b794439..e44eecbcb 100644
--- a/openmdao/core/tests/test_coloring.py
+++ b/openmdao/core/tests/test_coloring.py
@@ -1,6 +1,5 @@
 
 import os
-import sys
 import pathlib
 import itertools
 import pickle
@@ -1060,13 +1059,9 @@ class SimulColoringRevScipyTestCase(unittest.TestCase):
     def test_summary(self):
         p_color = run_opt(om.ScipyOptimizeDriver, 'auto', optimizer='SLSQP', disp=False, dynamic_total_coloring=True)
         coloring = p_color.driver._coloring_info.coloring
-        save_out = sys.stdout
-        sys.stdout = StringIO()
-        try:
-            coloring.summary()
-            summary = sys.stdout.getvalue()
-        finally:
-            sys.stdout = save_out
+        out = StringIO()
+        coloring.summary(out_stream=out)
+        summary = out.getvalue()
 
         self.assertTrue('Jacobian shape: (22, 21)  (13.42% nonzero)' in summary)
         self.assertTrue('FWD solves: 3   REV solves: 1' in summary)
@@ -1076,12 +1071,9 @@ class SimulColoringRevScipyTestCase(unittest.TestCase):
 
         dense_J = np.ones((50, 50), dtype=bool)
         coloring = _compute_coloring(dense_J, 'auto')
-        sys.stdout = StringIO()
-        try:
-            coloring.summary()
-            summary = sys.stdout.getvalue()
-        finally:
-            sys.stdout = save_out
+        out = StringIO()
+        coloring.summary(out_stream=out)
+        summary = out.getvalue()
 
         self.assertTrue('Jacobian shape: (50, 50)  (100.00% nonzero)' in summary)
         self.assertTrue('FWD solves: 50   REV solves: 0' in summary)
diff --git a/openmdao/error_checking/tests/test_check_config.py b/openmdao/error_checking/tests/test_check_config.py
index 740635dc1..7746c2518 100644
--- a/openmdao/error_checking/tests/test_check_config.py
+++ b/openmdao/error_checking/tests/test_check_config.py
@@ -559,6 +559,35 @@ class TestCheckConfig(unittest.TestCase):
         testlogger.find_in('warning', msg4)
         testlogger.find_in('warning', msg5)
 
+    def test_sparsity(self):
+        p = om.Problem()
+        root = p.model
+
+        root.add_subsystem("C1", om.ExecComp("y = 2.*x", shape=10))
+        root.add_subsystem("C2", om.ExecComp("y = 3.*x", shape=10, has_diag_partials=True))
+
+        root.connect("C1.y", "C2.x")
+
+        testlogger = TestLogger()
+        p.setup(check=['sparsity'], logger=testlogger)
+        p.run_model()
+        full = ''.join(testlogger.get('warning'))
+        self.assertTrue("'C1' <class ExecComp>:" in full)
+        self.assertTrue("(D)eclared sparsity pattern != (c)omputed sparsity pattern for sub-jacobian (y, x) with shape (10, 10) and 10.00% nonzeros:" in full)
+        arraystr = """
+C.........  0
+.C........  1
+..C.......  2
+...C......  3
+....C.....  4
+.....C....  5
+......C...  6
+.......C..  7
+........C.  8
+.........C  9
+""".strip()
+        self.assertTrue(arraystr in full)
+        self.assertFalse("'C2'" in full)
 
 @use_tempdirs
 class TestRecorderCheckConfig(unittest.TestCase):