Repository: pybamm-team/PyBaMM (python)

Issue:
Tag PyBaMM citations
**Summary**
The way citations are printed now (all together) can make it quite hard for an inexperienced user to know why the need to cite each reference. It would be good to add a tag when registering PyBaMM citations and also some extra functionality that, when printing the citations, reads the tags and groups them in a more meaningful way. For example, we could print some sort of list or table that lists:

- Model: Fuller1994
- Parameters: Chen2020
- Solver: Andersson 2019
and so on...

Please add your comments and suggestions

 **Motivation**
 Make it easier for new or inexperienced users to know what's the purpose of each reference, which might lead in them just dumping the citations in their papers all together. Adding this functionality should lead to higher quality citations in the papers.


Pull request description:
Issue 1182 verbose outputs and tagging PyBaMM citations
# Description

Adds a caller method for `citations.py` to tag PyBaMM citations to their use of particular models or solvers and adds verbose output while printing citations

Fixes #1182 

## Type of change

Please add a line in the relevant section of [CHANGELOG.md](https://github.com/pybamm-team/PyBaMM/blob/develop/CHANGELOG.md) to document the change (include PR #) - note reverse order of PR #s. If necessary, also add to the list of breaking changes.

- [x] New feature (non-breaking change which adds functionality)
- [ ] Optimization (back-end change that speeds up the code)
- [ ] Bug fix (non-breaking change which fixes an issue)

# Key checklist:

- [x] No style issues: `$ pre-commit run` (see [CONTRIBUTING.md](https://github.com/pybamm-team/PyBaMM/blob/develop/CONTRIBUTING.md#installing-and-using-pre-commit) for how to set this up to run automatically when committing locally, in just two lines of code)
- [x] All tests pass: `$ python run-tests.py --all`
- [x] The documentation builds: `$ python run-tests.py --doctest`

You can run unit and doctests together at once, using `$ python run-tests.py --quick`.

## Further checks:

- [x] Code is commented, particularly in hard-to-understand areas
- [x] Tests added that prove fix is effective or that feature works


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8e7fe5dc7..be92f8302 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,7 @@
 
 # Features
 
+- Added verbose logging to `pybamm.print_citations()` and citation tags for the `pybamm.Citations` class so that users can now see where the citations were registered when running simulations ([#2862](https://github.com/pybamm-team/PyBaMM/pull/2862))
 - PyBaMM is now natively supported on Apple silicon chips (`M1/M2`) ([#2435](https://github.com/pybamm-team/PyBaMM/pull/2435))
 - PyBaMM is now supported on Python `3.10` and `3.11` ([#2435](https://github.com/pybamm-team/PyBaMM/pull/2435))
 - Updated to casadi 3.6, which required some changes to the casadi integrator. ([#2859](https://github.com/pybamm-team/PyBaMM/pull/2859))
diff --git a/pybamm/citations.py b/pybamm/citations.py
index f236da8d7..f3f4ed06e 100644
--- a/pybamm/citations.py
+++ b/pybamm/citations.py
@@ -7,6 +7,7 @@ import pybamm
 import os
 import warnings
 import pybtex
+from inspect import stack
 from pybtex.database import parse_file, parse_string, Entry
 from pybtex.scanner import PybtexError
 
@@ -35,6 +36,9 @@ class Citations:
         # Dict mapping citations keys to BibTex entries
         self._all_citations: dict[str, str] = dict()
 
+        # Dict mapping citation tags for use when registering citations
+        self._citation_tags = dict()
+
         # store citation error
         self._citation_err_msg = None
 
@@ -48,10 +52,21 @@ class Citations:
         """Reset citations to default only (only for testing purposes)"""
         # Initialize empty papers to cite
         self._papers_to_cite = set()
+        # Initialize empty citation tags
+        self._citation_tags = dict()
         # Register the PyBaMM paper and the numpy paper
         self.register("Sulzer2021")
         self.register("Harris2020")
 
+    def _caller_name():
+        """
+        Returns the qualified name of classes that call :meth:`register` internally.
+        This is used for tagging citations but only for verbose output
+        """
+        # Attributed to https://stackoverflow.com/a/17065634
+        caller_name = stack()[2][0].f_locals["self"].__class__.__qualname__
+        return caller_name
+
     def read_citations(self):
         """Reads the citations in `pybamm.CITATIONS.txt`. Other works can be cited
         by passing a BibTeX citation to :meth:`register`.
@@ -78,6 +93,14 @@ class Citations:
         # Add to database
         self._all_citations[key] = new_citation
 
+    def _add_citation_tag(self, key, entry):
+        """Adds a tag for a citation key which represents the name of the class that
+        called :meth:`register`"""
+
+        # Add a citation tag to the citation_tags ordered dictionary with
+        # the key being the citation itself and the value being the name of the class
+        self._citation_tags[key] = entry
+
     @property
     def _cited(self):
         """Return a list of the BibTeX entries that have been cited"""
@@ -101,6 +124,14 @@ class Citations:
             # Check if citation is a known key
             if key in self._all_citations:
                 self._papers_to_cite.add(key)
+                # Add citation tags for the key
+                # This is used for verbose output
+                try:
+                    caller = Citations._caller_name()
+                    self._add_citation_tag(key, entry=caller)
+                # Don't add citation tags if the citation is registered manually
+                except KeyError:  # pragma: no cover
+                    pass
                 return
 
             # Try to parse the citation using pybtex
@@ -119,14 +150,54 @@ class Citations:
                 # Unable to parse / unknown key
                 raise KeyError(f"Not a bibtex citation or known citation: {key}")
 
-    def print(self, filename=None, output_format="text"):
-        """Print all citations that were used for running simulations.
+    def tag_citations(self):  # pragma: no cover
+        """Prints the citations tags for the citations that have been registered
+        (non-manually). This is used for verbose output when printing citations
+        such that it can be seen which citations were registered by PyBaMM classes.
+
+        To use, either call :meth:`tag_citations` after calling :meth:`register`
+        for all citations, or enable verbose output with :meth:`print_citations`
+        or :meth:`print`.
+
+        .. note::
+            If a citation is registered manually, it will not be tagged.
+
+        Examples
+        --------
+        .. code-block:: python
+            :linenos:
+
+            pybamm.citations.register("Doyle1993")
+            pybamm.citations.print() or pybamm.print_citations()
+
+        will print the following:
+
+        .. code-block::
+
+            Citations registered:
+            Sulzer2021 was cited due to the use of
+            pybamm.models.full_battery_models.lithium_ion.dfn
+
+        """
+        if self._citation_tags:
+            print("\n Citations registered: \n")
+            for key, entry in self._citation_tags.items():
+                print(f"{key} was cited due to the use of {entry}")
+
+    def print(self, filename=None, output_format="text", verbose=False):
+        """Print all citations that were used for running simulations. The verbose
+        option is provided to print the citation tags for the citations that have
+        been registered non-manually. This is available only upon printing
+        to the terminal.
 
         Parameters
         ----------
         filename : str, optional
-            Filename to which to print citations. If None, citations are printed to the
-            terminal.
+            Filename to which to print citations. If None, citations are printed
+            to the terminal.
+        verbose: bool, optional
+            If True, prints the citation tags for the citations that have been
+            registered
         """
         if output_format == "text":
             citations = pybtex.format_from_strings(
@@ -142,12 +213,14 @@ class Citations:
 
         if filename is None:
             print(citations)
+            if verbose:
+                self.tag_citations()  # pragma: no cover
         else:
             with open(filename, "w") as f:
                 f.write(citations)
 
 
-def print_citations(filename=None, output_format="text"):
+def print_citations(filename=None, output_format="text", verbose=False):
     """See :meth:`Citations.print`"""
     if citations._citation_err_msg is not None:
         raise ImportError(
@@ -159,7 +232,13 @@ def print_citations(filename=None, output_format="text"):
             f"{citations._citation_err_msg}"
         )
     else:
-        pybamm.citations.print(filename, output_format)
+        if verbose:  # pragma: no cover
+            warnings.warn(
+                "Verbose output is not available for printing to files, only to the terminal"  # noqa: E501
+            )
+            pybamm.citations.print(filename, output_format, verbose=True)
+        else:
+            pybamm.citations.print(filename, output_format)
 
 
 citations = Citations()


Test patch:
diff --git a/tests/unit/test_citations.py b/tests/unit/test_citations.py
index 5480182e6..91302f176 100644
--- a/tests/unit/test_citations.py
+++ b/tests/unit/test_citations.py
@@ -13,7 +13,7 @@ from tempfile import NamedTemporaryFile
 
 @contextlib.contextmanager
 def temporary_filename():
-    """Create a temporary-file and return yield it's filename"""
+    """Create a temporary-file and return yield its filename"""
 
     f = NamedTemporaryFile(delete=False)
     try:
@@ -116,6 +116,7 @@ class TestCitations(unittest.TestCase):
         self.assertNotIn("Marquis2019", citations._papers_to_cite)
         pybamm.lithium_ion.SPM(build=False)
         self.assertIn("Marquis2019", citations._papers_to_cite)
+        self.assertIn("Marquis2019", citations._citation_tags.keys())
 
         citations._reset()
         pybamm.lithium_ion.SPMe(build=False)
@@ -127,6 +128,7 @@ class TestCitations(unittest.TestCase):
         self.assertNotIn("Doyle1993", citations._papers_to_cite)
         pybamm.lithium_ion.DFN(build=False)
         self.assertIn("Doyle1993", citations._papers_to_cite)
+        self.assertIn("Doyle1993", citations._citation_tags.keys())
 
     def test_sulzer_2019(self):
         # Test that calling relevant bits of code adds the right paper to citations
@@ -136,10 +138,12 @@ class TestCitations(unittest.TestCase):
         self.assertNotIn("Sulzer2019asymptotic", citations._papers_to_cite)
         pybamm.lead_acid.LOQS(build=False)
         self.assertIn("Sulzer2019asymptotic", citations._papers_to_cite)
+        self.assertIn("Sulzer2019asymptotic", citations._citation_tags.keys())
 
         citations._reset()
         pybamm.lead_acid.Full(build=False)
         self.assertIn("Sulzer2019physical", citations._papers_to_cite)
+        self.assertIn("Sulzer2019physical", citations._citation_tags.keys())
 
     def test_timms_2021(self):
         # Test that calling relevant bits of code adds the right paper to citations
@@ -149,36 +153,43 @@ class TestCitations(unittest.TestCase):
         self.assertNotIn("Timms2021", citations._papers_to_cite)
         pybamm.current_collector.BasePotentialPair(param=None)
         self.assertIn("Timms2021", citations._papers_to_cite)
+        self.assertIn("Timms2021", citations._citation_tags.keys())
 
         citations._reset()
         self.assertNotIn("Timms2021", citations._papers_to_cite)
         pybamm.current_collector.EffectiveResistance()
         self.assertIn("Timms2021", citations._papers_to_cite)
+        self.assertIn("Timms2021", citations._citation_tags.keys())
 
         citations._reset()
         self.assertNotIn("Timms2021", citations._papers_to_cite)
         pybamm.current_collector.AlternativeEffectiveResistance2D()
         self.assertIn("Timms2021", citations._papers_to_cite)
+        self.assertIn("Timms2021", citations._citation_tags.keys())
 
         citations._reset()
         self.assertNotIn("Timms2021", citations._papers_to_cite)
         pybamm.thermal.pouch_cell.CurrentCollector1D(param=None)
         self.assertIn("Timms2021", citations._papers_to_cite)
+        self.assertIn("Timms2021", citations._citation_tags.keys())
 
         citations._reset()
         self.assertNotIn("Timms2021", citations._papers_to_cite)
         pybamm.thermal.pouch_cell.CurrentCollector2D(param=None)
         self.assertIn("Timms2021", citations._papers_to_cite)
+        self.assertIn("Timms2021", citations._citation_tags.keys())
 
         citations._reset()
         self.assertNotIn("Timms2021", citations._papers_to_cite)
         pybamm.thermal.Lumped(param=None)
         self.assertIn("Timms2021", citations._papers_to_cite)
+        self.assertIn("Timms2021", citations._citation_tags.keys())
 
         citations._reset()
         self.assertNotIn("Timms2021", citations._papers_to_cite)
         pybamm.thermal.OneDimensionalX(param=None)
         self.assertIn("Timms2021", citations._papers_to_cite)
+        self.assertIn("Timms2021", citations._citation_tags.keys())
 
     def test_subramanian_2005(self):
         # Test that calling relevant bits of code adds the right paper to citations
@@ -190,6 +201,7 @@ class TestCitations(unittest.TestCase):
             None, "negative", {"particle": "quadratic profile"}, "primary"
         )
         self.assertIn("Subramanian2005", citations._papers_to_cite)
+        self.assertIn("Subramanian2005", citations._citation_tags.keys())
 
         citations._reset()
         self.assertNotIn("Subramanian2005", citations._papers_to_cite)
@@ -197,6 +209,7 @@ class TestCitations(unittest.TestCase):
             None, "negative", {"particle": "quadratic profile"}, "primary"
         )
         self.assertIn("Subramanian2005", citations._papers_to_cite)
+        self.assertIn("Subramanian2005", citations._citation_tags.keys())
 
     def test_brosaplanella_2021(self):
         # Test that calling relevant bits of code adds the right paper to citations
@@ -206,6 +219,7 @@ class TestCitations(unittest.TestCase):
         self.assertNotIn("BrosaPlanella2021", citations._papers_to_cite)
         pybamm.electrolyte_conductivity.Integrated(None)
         self.assertIn("BrosaPlanella2021", citations._papers_to_cite)
+        self.assertIn("BrosaPlanella2021", citations._citation_tags.keys())
 
     def test_brosaplanella_2022(self):
         # Test that calling relevant bits of code adds the right paper to citations
@@ -235,6 +249,7 @@ class TestCitations(unittest.TestCase):
             build=False, options={"lithium plating": "irreversible"}
         )
         self.assertIn("BrosaPlanella2022", citations._papers_to_cite)
+        self.assertIn("BrosaPlanella2022", citations._citation_tags.keys())
         citations._reset()
 
     def test_newman_tobias(self):
@@ -246,7 +261,9 @@ class TestCitations(unittest.TestCase):
         self.assertNotIn("Chu2020", citations._papers_to_cite)
         pybamm.lithium_ion.NewmanTobias()
         self.assertIn("Newman1962", citations._papers_to_cite)
+        self.assertIn("Newman1962", citations._citation_tags.keys())
         self.assertIn("Chu2020", citations._papers_to_cite)
+        self.assertIn("Chu2020", citations._citation_tags.keys())
 
     def test_scikit_fem(self):
         citations = pybamm.citations
@@ -255,6 +272,7 @@ class TestCitations(unittest.TestCase):
         self.assertNotIn("Gustafsson2020", citations._papers_to_cite)
         pybamm.ScikitFiniteElement()
         self.assertIn("Gustafsson2020", citations._papers_to_cite)
+        self.assertIn("Gustafsson2020", citations._citation_tags.keys())
 
     def test_reniers_2019(self):
         # Test that calling relevant bits of code adds the right paper to citations
@@ -264,6 +282,7 @@ class TestCitations(unittest.TestCase):
         self.assertNotIn("Reniers2019", citations._papers_to_cite)
         pybamm.active_material.LossActiveMaterial(None, "negative", None, True)
         self.assertIn("Reniers2019", citations._papers_to_cite)
+        self.assertIn("Reniers2019", citations._citation_tags.keys())
 
     def test_mohtat_2019(self):
         citations = pybamm.citations
@@ -274,6 +293,7 @@ class TestCitations(unittest.TestCase):
             pybamm.ParameterValues("Marquis2019")
         )._get_electrode_soh_sims_full()
         self.assertIn("Mohtat2019", citations._papers_to_cite)
+        self.assertIn("Mohtat2019", citations._citation_tags.keys())
 
     def test_mohtat_2021(self):
         citations = pybamm.citations
@@ -282,6 +302,7 @@ class TestCitations(unittest.TestCase):
         self.assertNotIn("Mohtat2021", citations._papers_to_cite)
         pybamm.external_circuit.CCCVFunctionControl(None, None)
         self.assertIn("Mohtat2021", citations._papers_to_cite)
+        self.assertIn("Mohtat2021", citations._citation_tags.keys())
 
     def test_sripad_2020(self):
         citations = pybamm.citations
@@ -290,11 +311,13 @@ class TestCitations(unittest.TestCase):
         self.assertNotIn("Sripad2020", citations._papers_to_cite)
         pybamm.kinetics.Marcus(None, None, None, None, None)
         self.assertIn("Sripad2020", citations._papers_to_cite)
+        self.assertIn("Sripad2020", citations._citation_tags.keys())
 
         citations._reset()
         self.assertNotIn("Sripad2020", citations._papers_to_cite)
         pybamm.kinetics.MarcusHushChidsey(None, None, None, None, None)
         self.assertIn("Sripad2020", citations._papers_to_cite)
+        self.assertIn("Sripad2020", citations._citation_tags.keys())
 
     def test_parameter_citations(self):
         citations = pybamm.citations
@@ -302,30 +325,40 @@ class TestCitations(unittest.TestCase):
         citations._reset()
         pybamm.ParameterValues("Chen2020")
         self.assertIn("Chen2020", citations._papers_to_cite)
+        self.assertIn("Chen2020", citations._citation_tags.keys())
 
         citations._reset()
         pybamm.ParameterValues("NCA_Kim2011")
         self.assertIn("Kim2011", citations._papers_to_cite)
+        self.assertIn("Kim2011", citations._citation_tags.keys())
 
         citations._reset()
         pybamm.ParameterValues("Marquis2019")
         self.assertIn("Marquis2019", citations._papers_to_cite)
+        self.assertIn("Marquis2019", citations._citation_tags.keys())
 
         citations._reset()
         pybamm.ParameterValues("Sulzer2019")
         self.assertIn("Sulzer2019physical", citations._papers_to_cite)
+        self.assertIn("Sulzer2019physical", citations._citation_tags.keys())
 
         citations._reset()
         pybamm.ParameterValues("Ecker2015")
         self.assertIn("Ecker2015i", citations._papers_to_cite)
+        self.assertIn("Ecker2015i", citations._citation_tags.keys())
         self.assertIn("Ecker2015ii", citations._papers_to_cite)
+        self.assertIn("Ecker2015ii", citations._citation_tags.keys())
         self.assertIn("Zhao2018", citations._papers_to_cite)
+        self.assertIn("Zhao2018", citations._citation_tags.keys())
         self.assertIn("Hales2019", citations._papers_to_cite)
+        self.assertIn("Hales2019", citations._citation_tags.keys())
         self.assertIn("Richardson2020", citations._papers_to_cite)
+        self.assertIn("Richardson2020", citations._citation_tags.keys())
 
         citations._reset()
         pybamm.ParameterValues("ORegan2022")
         self.assertIn("ORegan2022", citations._papers_to_cite)
+        self.assertIn("ORegan2022", citations._citation_tags.keys())
 
     def test_solver_citations(self):
         # Test that solving each solver adds the right citations
@@ -335,28 +368,33 @@ class TestCitations(unittest.TestCase):
         self.assertNotIn("Virtanen2020", citations._papers_to_cite)
         pybamm.ScipySolver()
         self.assertIn("Virtanen2020", citations._papers_to_cite)
+        self.assertIn("Virtanen2020", citations._citation_tags.keys())
 
         citations._reset()
         self.assertNotIn("Virtanen2020", citations._papers_to_cite)
         pybamm.AlgebraicSolver()
         self.assertIn("Virtanen2020", citations._papers_to_cite)
+        self.assertIn("Virtanen2020", citations._citation_tags.keys())
 
         if pybamm.have_scikits_odes():
             citations._reset()
             self.assertNotIn("Malengier2018", citations._papers_to_cite)
             pybamm.ScikitsOdeSolver()
             self.assertIn("Malengier2018", citations._papers_to_cite)
+            self.assertIn("Malengier2018", citations._citation_tags.keys())
 
             citations._reset()
             self.assertNotIn("Malengier2018", citations._papers_to_cite)
             pybamm.ScikitsDaeSolver()
             self.assertIn("Malengier2018", citations._papers_to_cite)
+            self.assertIn("Malengier2018", citations._citation_tags.keys())
 
         if pybamm.have_idaklu():
             citations._reset()
             self.assertNotIn("Hindmarsh2005", citations._papers_to_cite)
             pybamm.IDAKLUSolver()
             self.assertIn("Hindmarsh2005", citations._papers_to_cite)
+            self.assertIn("Hindmarsh2005", citations._citation_tags.keys())
 
     @unittest.skipIf(not pybamm.have_jax(), "jax or jaxlib is not installed")
     def test_jax_citations(self):
@@ -365,6 +403,7 @@ class TestCitations(unittest.TestCase):
         self.assertNotIn("jax2018", citations._papers_to_cite)
         pybamm.JaxSolver()
         self.assertIn("jax2018", citations._papers_to_cite)
+        self.assertIn("jax2018", citations._citation_tags.keys())
 
 
 if __name__ == "__main__":