Repository: Princeton-LSI-ResearchComputing/tracebase
Pull request #1528: Cryptic MissingSample Errors
Author: login: hepcat72
Labels: name: type:bug
description: Something isn't working
name: priority:2-high
description: Must be implemented in order to meet release goals
name: component:loading
description: data loading
name: effort:3-under1wk
description: A decent amount of work that can be implemented in a day to a week of focussed effort
Created: 2025-05-02T23:25:19Z | Merged: 2025-05-05T16:22:54Z

## Summary Change Description

This is a work in progress.

## Affected Issues/Pull Requests

- Resolves #1526
- Resolves #1527
- Merges into branch `main`

## Reviewer Notes/Requests
<!-- Notes to help the reviewer or requests for specific scrutiny.
E.g. Please make sure I have accounted for all possible inputs. -->
See comments in-line.

## Checklist
<!-- If any requirements are not met, uncheck and explain.
E.g. Linting errors pre-date this PR. -->
This pull request will be merged once the following requirements are met.  The
author and/or reviewers should uncheck any unmet requirements:
<!-- Check/complete items if not applicable. -->
- Review Requirements
  - Minimum approvals: 1 <!-- Edit based on complexity. -->
  - No changes requested
  - All blocking issues resolved by reviewers
  - Specific reviewers: @__add_username_here__ <!-- Approvals required even if minimum met. -->
  - Review period: 2 days <!-- Edit based on complexity. -->
- Associated Issue/PR Requirements: <!-- Assert resolved issues/PRs are done/merged. -->
  - [x] All issue requirements are satisfied
  - [x] All PR dependencies are merged
- Basic Requirements <!-- Uncheck to indicate items you are yet to address. -->
  - [x] [Linters pass](https://github.com/Princeton-LSI-ResearchComputing/tracebase/blob/main/CONTRIBUTING.md#linting)
  - [x] [Tests pass](https://github.com/Princeton-LSI-ResearchComputing/tracebase/blob/main/CONTRIBUTING.md#quality-control)
  - [x] Conflicts resolved
- Overhead Requirements <!-- Requirements indirectly related to the issues. -->
  - [x] [Tests implemented/updated](https://github.com/Princeton-LSI-ResearchComputing/tracebase/blob/main/CONTRIBUTING.md#test-implementation)
  - [ ] [Updated `CHANGELOG.md` *Unreleased* section](https://github.com/Princeton-LSI-ResearchComputing/tracebase/blob/main/changelog.md)


Linked issue #1526: Create an `AllUnexpectedLabels` exception class and make `UexpectedLabels` inherit from `SummarizableError`
## ISSUE SUBMITTER SECTION

### Feature Inspiration <!-- REQUIRED -->

Anna Oschmann's Acute Stress study has tons of `UnexpectedLabels` warnings.  The aggregated errors summary is unwieldy.

### Feature Description <!-- REQUIRED -->

Create an `AllUnexpectedLabels` exception class and make `UexpectedLabels` inherit from `SummarizableError`.

### Alternatives
<!-- A brief description of any alternative features that could accomplish the
same ultimate goal, either for consideration or considered and rejected. -->
None

### Dependencies
<!-- This issue cannot be started until the completion of the following:
- #issue_number_1
- #pull_request_1
-->
None

### Comment
<!-- Add any other context or screenshots about the feature request here. -->
None

-----

## ISSUE OWNER SECTION <!-- ALL REQUIRED -->

### Assumptions
<!-- List of assumptions that the code will not explicitly address/check. E.g.
1. The input is correct
-->
None

### Limitations
<!-- A list of things this work will specifically not do. E.g.
1. This feature will only handle the most frequent use case X.
2. This feature will not handle edge case Y.
-->
None

### Affected Components
<!-- A tentative list of anticipated repository items that will be changed,
labeled with "add", "delete", or "change".  One item per line.  Components can
be anything, like files, environment variables, cron jobs, etc. E.g.
- change: `path/to/code.py`
- add: `DATABASE_HOST` environment variable
- delete: cron job to backup the database
-->
None provided

### Requirements
<!-- A hierarchical list of discrete requirements for the feature. List numbers
are for reference & checkboxes for progress tracking. E.g.
- [ ] `1.` Every column/row must display a value, i.e. cannot be empty
- [ ] `2.` Column X must link to the referenced record
  - [ ] `2.1.` Raise an error if column X's value is None
  - [ ] `2.2.` The link must not be line-wrapped
-->
None provided

### DESIGN

#### Interface Change description
<!-- Describe changes to usage. *E.g. new config variables or input files* -->
None provided

#### Code Change Description
<!-- Describe code changes planned for the feature. Pseudocode/outline/etc. -->
None provided

#### Tests
<!-- A description of at least one test for each requirement above. Numbers are
for reference & checkboxes for progress tracking. E.g.
- [ ] `1.` Test that there's an exception when display value is ''
-->
None provided

Linked issue comments: user: login: hepcat72
created_at: 2025-05-06T20:14:26Z
body: Added in #1528
author_association: COLLABORATOR

Diff:
diff --git a/DataRepo/loaders/base/table_loader.py b/DataRepo/loaders/base/table_loader.py
index 89777f36e..36a6a9513 100644
--- a/DataRepo/loaders/base/table_loader.py
+++ b/DataRepo/loaders/base/table_loader.py
@@ -2305,35 +2305,65 @@ def load_wrapper(self: TableLoader, *args, **kwargs):
                     # Summarize multiple types of exceptions that are subclasses of SummarizableError
                     for exc_cls in self.aggregated_errors_object.get_exception_types():
                         if issubclass(exc_cls, SummarizableError):
-                            # TODO: Change this behavior to always leave exceptions unmodified, so that their
-                            # application to Excel shows their unchanged severity
-
                             # Get the exceptions with their original attributes
-                            orig_excs = (
+                            orig_errors = (
                                 self.aggregated_errors_object.get_exception_type(
-                                    exc_cls, modify=False
+                                    exc_cls, modify=False, is_error=True
                                 )
                             )
-                            # Determine the severity of the summarized exception
-                            is_fatal = False
-                            is_error = False
-                            for exc in orig_excs:
-                                if exc.is_error:
-                                    is_error = True
-                                if exc.is_fatal:
-                                    is_fatal = True
-                                if is_error and is_fatal:
-                                    break
-                            # Remove the exceptions (this modifies them to warnings (to de-emphasize them))
-                            excs = self.aggregated_errors_object.remove_exception_type(
-                                exc_cls
-                            )
-                            # Buffer the summarized exception
-                            self.aggregated_errors_object.buffer_exception(
-                                exc_cls.SummarizerExceptionClass(excs),
-                                is_error=is_error,
-                                is_fatal=is_fatal,
+                            if len(orig_errors) > 0:
+                                # Determine the severity of the summarized exception
+                                is_fatal = False
+                                is_error = False
+                                for exc in orig_errors:
+                                    if exc.is_error:
+                                        is_error = True
+                                    if exc.is_fatal:
+                                        is_fatal = True
+                                    if is_error and is_fatal:
+                                        break
+                                # Remove the exceptions (this modifies them to warnings (to de-emphasize them))
+                                errors = (
+                                    self.aggregated_errors_object.remove_exception_type(
+                                        exc_cls, is_error=True
+                                    )
+                                )
+                                # Buffer the summarized exception
+                                self.aggregated_errors_object.buffer_exception(
+                                    exc_cls.SummarizerExceptionClass(errors),
+                                    is_error=is_error,
+                                    is_fatal=is_fatal,
+                                )
+
+                            # Get the exceptions with their original attributes
+                            orig_warnings = (
+                                self.aggregated_errors_object.get_exception_type(
+                                    exc_cls, modify=False, is_error=False
+                                )
                             )
+                            if len(orig_warnings) > 0:
+                                # Determine the severity of the summarized exception
+                                is_fatal = False
+                                is_error = False
+                                for exc in orig_warnings:
+                                    if exc.is_error:
+                                        is_error = True
+                                    if exc.is_fatal:
+                                        is_fatal = True
+                                    if is_error and is_fatal:
+                                        break
+                                # Remove the exceptions (this modifies them to warnings (to de-emphasize them))
+                                warnings = (
+                                    self.aggregated_errors_object.remove_exception_type(
+                                        exc_cls, is_error=False
+                                    )
+                                )
+                                # Buffer the summarized exception
+                                self.aggregated_errors_object.buffer_exception(
+                                    exc_cls.SummarizerExceptionClass(warnings),
+                                    is_error=is_error,
+                                    is_fatal=is_fatal,
+                                )
 
                     if aes_set is not None:
                         # Right now, individual loader classes set the load key manually to the friendly filename.  This
diff --git a/DataRepo/loaders/msruns_loader.py b/DataRepo/loaders/msruns_loader.py
index 0a760877c..8bd950a51 100644
--- a/DataRepo/loaders/msruns_loader.py
+++ b/DataRepo/loaders/msruns_loader.py
@@ -32,6 +32,7 @@
 )
 from DataRepo.utils.exceptions import (
     AggregatedErrors,
+    AssumedMzxmlSampleMatch,
     ConditionallyRequiredArgs,
     DefaultSequenceNotFound,
     InfileError,
@@ -49,11 +50,13 @@
     MzXMLSkipRowError,
     NoSamples,
     NoScans,
-    PossibleDuplicateSamples,
+    PossibleDuplicateSample,
     RecordDoesNotExist,
     RequiredColumnValue,
     RequiredColumnValues,
     RollbackException,
+    UnmatchedBlankMzXML,
+    UnmatchedMzXML,
     UnskippedBlanks,
 )
 from DataRepo.utils.file_utils import is_excel, read_from_file, string_to_date
@@ -262,6 +265,8 @@ def __init__(self, *args, **kwargs):
             Superclass Args:
                 df (Optional[pandas dataframe]): Data, e.g. as parsed from a table-like file.
                 dry_run (Optional[boolean]) [False]: Dry run mode.
+                debug (bool) [False]: Debug mode causes all buffered exception traces to be printed.  Normally, if an
+                    exception is a subclass of SummarizableError, the printing of its trace is suppressed.
                 defer_rollback (Optional[boolean]) [False]: Defer rollback mode.  DO NOT USE MANUALLY - A PARENT SCRIPT
                     MUST HANDLE THE ROLLBACK.
                 data_sheet (Optional[str]): Sheet name (for error reporting).
@@ -298,7 +303,13 @@ def __init__(self, *args, **kwargs):
                     name that matches the sample header, it will consider dashes and underscores as equal.  When True,
                     only sample headers that exactly match the file name will be considered matches.
         Exceptions:
-            None
+            Buffered:
+                InfileError when a paths to a peak annotation file is absolute
+                MutuallyExclusiveArgs
+                DefaultSequenceNotFound
+                MultipleDefaultSequencesFound
+            Raised:
+                AggregatedErrors
         Returns:
             None
         """
@@ -726,7 +737,8 @@ def load_data(self):
                                     "be included for each mzXML file in the mzXML file name column so that we can tell "
                                     "which ones to load and which to skip)."
                                 ),
-                            )
+                            ),
+                            is_fatal=self.validate,
                         )
                         continue
 
@@ -936,7 +948,7 @@ def check_sample_headers(self):
                     sample_names.append(sn)
                     rows.extend(self.header_to_sample_name[sample_header][sn])
                 self.aggregated_errors_object.buffer_exception(
-                    PossibleDuplicateSamples(
+                    PossibleDuplicateSample(
                         sample_header,
                         sample_names,
                         file=self.friendly_file,
@@ -970,7 +982,7 @@ def report_discrepant_headers(self):
         """
         # Extract exceptions about missing Sample records
         sample_dnes = self.aggregated_errors_object.remove_matching_exceptions(
-            RecordDoesNotExist, "model", Sample
+            RecordDoesNotExist, "model", Sample, is_error=True
         )
 
         # Separate the exceptions based on whether they appear to be blanks or not
@@ -999,24 +1011,44 @@ def report_discrepant_headers(self):
             )
 
             if num_samples == len(likely_missing_sample_names):
-                self.aggregated_errors_object.buffer_error(
+                self.aggregated_errors_object.buffer_exception(
                     NoSamples(
                         likely_missing_dnes,
                         suggestion=(
                             f"Did you forget to include these {self.headers.SAMPLENAME}s in the "
                             f"{SamplesLoader.DataHeaders.SAMPLE} column of the {SamplesLoader.DataSheetName} sheet?"
                         ),
-                    )
+                    ),
+                    is_error=any(
+                        e.is_error
+                        for e in likely_missing_dnes
+                        if hasattr(e, "is_error")
+                    ),
+                    is_fatal=any(
+                        e.is_fatal
+                        for e in likely_missing_dnes
+                        if hasattr(e, "is_fatal")
+                    ),
                 )
             else:
-                self.aggregated_errors_object.buffer_error(
+                self.aggregated_errors_object.buffer_exception(
                     MissingSamples(
                         likely_missing_dnes,
                         suggestion=(
                             f"Did you forget to include these {self.headers.SAMPLENAME}s in the "
                             f"'{SamplesLoader.DataHeaders.SAMPLE}' column of the {SamplesLoader.DataSheetName} sheet?"
                         ),
-                    )
+                    ),
+                    is_error=any(
+                        e.is_error
+                        for e in likely_missing_dnes
+                        if hasattr(e, "is_error")
+                    ),
+                    is_fatal=any(
+                        e.is_fatal
+                        for e in likely_missing_dnes
+                        if hasattr(e, "is_fatal")
+                    ),
                 )
 
         if len(possible_blank_dnes) > 0:
@@ -1028,7 +1060,8 @@ def report_discrepant_headers(self):
                         f"'{SamplesLoader.DataHeaders.SAMPLE}' column of the '{SamplesLoader.DataSheetName}' sheet or "
                         f"must have 'skip' in the '{self.headers.SKIP}' column."
                     ),
-                )
+                ),
+                is_fatal=self.validate,
             )
 
     def get_loaded_msrun_sample_dict(self, peak_annot_file: str) -> dict:
@@ -1514,7 +1547,6 @@ def get_or_create_msrun_sample_from_row(self, row):
                 self.existed(MSRunSample.__name__)
 
         except Exception as e:
-            print(f"Skipping because a {type(e).__name__} occurred: {mzxml_metadata}")
             self.handle_load_db_errors(e, MSRunSample, msrs_rec_dict)
             self.errored(MSRunSample.__name__)
             raise RollbackException()
@@ -1635,6 +1667,7 @@ def get_sample_by_name(self, sample_name, from_mzxml=None):
                 None
             Buffers:
                 RecordDoesNotExist
+                MultipleRecordsReturned
         Returns:
             Optional[Sample]
         """
@@ -1672,7 +1705,7 @@ def get_sample_by_name(self, sample_name, from_mzxml=None):
                         # see if the user potentially prepended the sample name and the rest of it happens to be
                         # unique
                         rec = Sample.objects.get(name__endswith=sample_name)
-                    except Sample.DoesNotExist or Sample.MultipleObjectsReturned:
+                    except Sample.DoesNotExist:
                         # It's possible the dash was already replaced in sample_name, but the researcher manually
                         # included the dash in the DB sample name, so try WITH the dash.
                         if orig_mzxml_sample_name != sample_name:
@@ -1684,56 +1717,117 @@ def get_sample_by_name(self, sample_name, from_mzxml=None):
                             except Sample.DoesNotExist:
                                 # Ignore this attempt and press on with processing the original exception
                                 pass
+                            except Sample.MultipleObjectsReturned:
+                                matching_samples = list(
+                                    Sample.objects.filter(
+                                        name__endswith=orig_mzxml_sample_name
+                                    ).values_list("name", flat=True)
+                                )
+                                self.aggregated_errors_object.buffer_exception(
+                                    MultipleRecordsReturned(
+                                        Sample,
+                                        {"name": sample_name},
+                                        file=self.friendly_file,
+                                        sheet=self.sheet,
+                                        column=self.headers.MZXMLNAME,
+                                        rownum="no row - sample name was derived from an mzXML filename",
+                                        message=(
+                                            f"{Sample.__name__} record matching the exact mzXML file's basename "
+                                            f"[{sample_name_used}] does not exist, but the filename starts with a "
+                                            "number, which means that the peak annotation software would have "
+                                            "required the name to be prepended with a letter.  There are multiple "
+                                            f"samples that end with this name: {matching_samples}.  Please "
+                                            f"identify the associated sample(s) and add a the file '{from_mzxml}' to "
+                                            "%s.  If no such row exists and this is an unanalyzed mzXML file, add a "
+                                            f"row and fill in the '{self.headers.SKIP}' column."
+                                        ),
+                                    ),
+                                    is_error=not Sample.is_a_blank(sample_name),
+                                    is_fatal=not Sample.is_a_blank(sample_name)
+                                    or self.validate,
+                                    orig_exception=dne,
+                                )
+                                return None
+                    except Sample.MultipleObjectsReturned:
+                        matching_samples = list(
+                            Sample.objects.filter(
+                                name__endswith=sample_name
+                            ).values_list("name", flat=True)
+                        )
+                        self.aggregated_errors_object.buffer_exception(
+                            MultipleRecordsReturned(
+                                Sample,
+                                {"name": sample_name},
+                                file=self.friendly_file,
+                                sheet=self.sheet,
+                                column=self.headers.MZXMLNAME,
+                                rownum="no row - sample name was derived from an mzXML filename",
+                                message=(
+                                    f"{Sample.__name__} record matching the exact mzXML file's basename "
+                                    f"[{sample_name}] does not exist, but the filename starts with a number, which "
+                                    "means that the peak annotation software would have required the name to be "
+                                    "prepended with a letter.  There are multiple samples that end with this name: "
+                                    f"{matching_samples}.  Please identify the associated sample(s) and add a the "
+                                    f"file '{from_mzxml}' to %s.  If no such row exists and this is an unanalyzed "
+                                    f"mzXML file, add a row and fill in the '{self.headers.SKIP}' column."
+                                ),
+                            ),
+                            is_error=not Sample.is_a_blank(sample_name),
+                            is_fatal=not Sample.is_a_blank(sample_name)
+                            or self.validate,
+                            orig_exception=dne,
+                        )
+                        return None
                     finally:
                         if rec is not None:
 
                             # Buffer an error that says that we're going to proceed assuming the found sample is a
                             # match
-                            self.aggregated_errors_object.buffer_error(
-                                RecordDoesNotExist(
-                                    Sample,
-                                    {"name": sample_name},
+                            self.aggregated_errors_object.buffer_warning(
+                                AssumedMzxmlSampleMatch(
+                                    sample_name=rec.name,
+                                    mzxml_file=from_mzxml,
                                     file=self.friendly_file,
                                     sheet=self.sheet,
                                     column=self.headers.MZXMLNAME,
                                     rownum="no row - sample name was derived from an mzXML filename",
-                                    message=(
-                                        f"{Sample.__name__} record matching the mzXML file's basename "
-                                        f"[{sample_name_used}] does not exist, but the filename starts with a number "
-                                        f"and happens to otherwise uniquely match sample '{rec.name}'.  Please "
-                                        f"identify the associated sample(s) and add a the file '{from_mzxml}' to %s.  "
-                                        "If no such row exists and this is an unanalyzed mzXML file, add a row and "
-                                        f"fill in the '{self.headers.SKIP}' column."
-                                    ),
-                                    suggestion=(
-                                        "Proceeding with the assumption that the mzXML-derived sample name "
-                                        f"'{sample_name}' is intended to match sample '{rec.name}'."
-                                    ),
                                 ),
+                                is_fatal=self.validate,
                                 orig_exception=dne,
                             )
 
                             return rec
 
-                self.aggregated_errors_object.buffer_error(
-                    RecordDoesNotExist(
-                        Sample,
-                        {"name": sample_name},
-                        file=self.friendly_file,
-                        sheet=self.sheet,
-                        column=self.headers.MZXMLNAME,
-                        rownum="no row - sample name was derived from an mzXML filename",
-                        message=(
-                            f"{Sample.__name__} record matching the mzXML file's{' exact' if self.exact_mode else ''} "
-                            f"basename [{sample_name}] does not exist.  Please identify the associated sample(s) and "
-                            f"add a the file '{from_mzxml}' to %s.  If no such row exists and this is an unanalyzed "
-                            f"mzXML file, add a row and fill in the '{self.headers.SKIP}' column."
+                if Sample.is_a_blank(sample_name):
+                    self.aggregated_errors_object.buffer_warning(
+                        UnmatchedBlankMzXML(
+                            from_mzxml,
+                            self.headers.SAMPLEHEADER,
+                            self.headers.SKIP,
+                            file=self.friendly_file,
+                            sheet=self.sheet,
+                            column=self.headers.MZXMLNAME,
+                            rownum="no row - sample name was derived from an mzXML filename",
+                            suggestion="This file will not be linked to any sample.",
                         ),
-                    ),
-                    orig_exception=dne,
-                )
+                        is_fatal=self.validate,
+                        orig_exception=dne,
+                    )
+                else:
+                    self.aggregated_errors_object.buffer_error(
+                        UnmatchedMzXML(
+                            from_mzxml,
+                            self.headers.SAMPLEHEADER,
+                            self.headers.SKIP,
+                            file=self.friendly_file,
+                            sheet=self.sheet,
+                            column=self.headers.MZXMLNAME,
+                            rownum="no row - sample name was derived from an mzXML filename",
+                        ),
+                        orig_exception=dne,
+                    )
             else:
-                self.aggregated_errors_object.buffer_error(
+                self.aggregated_errors_object.buffer_exception(
                     RecordDoesNotExist(
                         Sample,
                         {"name": sample_name},
@@ -1742,8 +1836,11 @@ def get_sample_by_name(self, sample_name, from_mzxml=None):
                         column=self.headers.SAMPLENAME,
                         rownum=self.rownum,
                     ),
+                    is_error=not Sample.is_a_blank(sample_name),
+                    is_fatal=not Sample.is_a_blank(sample_name) or self.validate,
                     orig_exception=dne,
                 )
+
         return rec
 
     @transaction.atomic
@@ -1907,7 +2004,8 @@ def get_msrun_sequence_from_dir(
                         annot_dirs=list(self.annotdir_to_seq_dict.keys()),
                         file=os.path.join(mzxml_dir, mzxml_filename),
                         suggestion=f"Using the default sequence '{default_msrun_sequence.sequence_name}'.",
-                    )
+                    ),
+                    is_fatal=self.validate,
                 )
                 msrun_sequence = default_msrun_sequence
             else:
@@ -2195,6 +2293,7 @@ def get_matching_mzxml_metadata(
             (bool): Whether there were multiple matching mzXML files
         """
         mzxml_string_dir = ""
+        mzxml_basename = ""
         mzxml_name = None
         multiple_mzxml_dict = None
         # Placeholder MSRunSample records have no polarity/mz/archivefile recs/file, but we do want to track if they've
diff --git a/DataRepo/loaders/peak_annotation_files_loader.py b/DataRepo/loaders/peak_annotation_files_loader.py
index da30d1147..7f55de958 100644
--- a/DataRepo/loaders/peak_annotation_files_loader.py
+++ b/DataRepo/loaders/peak_annotation_files_loader.py
@@ -6,13 +6,7 @@
 
 from DataRepo.loaders.base.table_column import ColumnReference, TableColumn
 from DataRepo.loaders.base.table_loader import TableLoader
-from DataRepo.loaders.peak_annotations_loader import (
-    AccucorLoader,
-    IsoautocorrLoader,
-    IsocorrLoader,
-    PeakAnnotationsLoader,
-    UnicorrLoader,
-)
+from DataRepo.loaders.peak_annotations_loader import PeakAnnotationsLoader
 from DataRepo.loaders.peak_group_conflicts import PeakGroupConflicts
 from DataRepo.loaders.sequences_loader import SequencesLoader
 from DataRepo.models.archive_file import ArchiveFile, DataFormat, DataType
@@ -496,14 +490,14 @@ def load_peak_annotations(
             )
             return
 
-        if format_code == AccucorLoader.format_code:
-            peak_annot_loader_class = AccucorLoader
-        elif format_code == IsocorrLoader.format_code:
-            peak_annot_loader_class = IsocorrLoader
-        elif format_code == IsoautocorrLoader.format_code:
-            peak_annot_loader_class = IsoautocorrLoader
-        elif format_code == UnicorrLoader.format_code:
-            peak_annot_loader_class = UnicorrLoader
+        for subcls in PeakAnnotationsLoader.__subclasses__():
+            if format_code == subcls.format_code:
+                peak_annot_loader_class = subcls
+                break
+        else:
+            raise NotImplementedError(
+                f"format_code: {format_code} is not one of {PeakAnnotationsLoader.get_supported_formats()}"
+            )
 
         # Get the peak annotation details
         peak_annotation_details_file = None
@@ -569,6 +563,7 @@ def load_peak_annotations(
             # Pass-alongs
             _validate=self.validate,
             defer_rollback=self.defer_rollback,
+            debug=self.debug,
         )
 
         try:
diff --git a/DataRepo/loaders/peak_annotations_loader.py b/DataRepo/loaders/peak_annotations_loader.py
index 9147ea8da..eaaa49520 100644
--- a/DataRepo/loaders/peak_annotations_loader.py
+++ b/DataRepo/loaders/peak_annotations_loader.py
@@ -50,7 +50,7 @@
     RecordDoesNotExist,
     ReplacingPeakGroupRepresentation,
     RollbackException,
-    UnexpectedLabels,
+    UnexpectedLabel,
     UnexpectedSamples,
     UnskippedBlanks,
     generate_file_location_string,
@@ -1407,7 +1407,7 @@ def get_label_observations(self, row, pgrec: Optional[PeakGroup]):
             label_observations = parse_isotope_label(
                 isotope_label, possible_isotope_observations
             )
-        except UnexpectedLabels as olnp:
+        except UnexpectedLabel as olnp:
             suggestion = None
             if pgrec is not None:
                 suggestion = (
@@ -1551,7 +1551,7 @@ def report_discrepant_headers(self):
         """
         # Extract exceptions about missing Sample records
         sample_dnes = self.aggregated_errors_object.remove_matching_exceptions(
-            RecordDoesNotExist, "model", Sample
+            RecordDoesNotExist, "model", Sample, is_error=True
         )
 
         # Separate the exceptions based on whether they appear to be blanks or not
@@ -1580,18 +1580,38 @@ def report_discrepant_headers(self):
             )
 
             if num_found_samples == 0:
-                self.aggregated_errors_object.buffer_error(
+                self.aggregated_errors_object.buffer_exception(
                     NoSamples(
                         likely_missing_dnes,
                         suggestion=self.missing_msrs_suggestion,
-                    )
+                    ),
+                    is_error=any(
+                        e.is_error
+                        for e in likely_missing_dnes
+                        if hasattr(e, "is_error")
+                    ),
+                    is_fatal=any(
+                        e.is_fatal
+                        for e in likely_missing_dnes
+                        if hasattr(e, "is_fatal")
+                    ),
                 )
             else:
-                self.aggregated_errors_object.buffer_error(
+                self.aggregated_errors_object.buffer_exception(
                     MissingSamples(
                         likely_missing_dnes,
                         suggestion=self.missing_msrs_suggestion,
-                    )
+                    ),
+                    is_error=any(
+                        e.is_error
+                        for e in likely_missing_dnes
+                        if hasattr(e, "is_error")
+                    ),
+                    is_fatal=any(
+                        e.is_fatal
+                        for e in likely_missing_dnes
+                        if hasattr(e, "is_fatal")
+                    ),
                 )
 
         if len(possible_blank_dnes) > 0:
diff --git a/DataRepo/loaders/study_loader.py b/DataRepo/loaders/study_loader.py
index d7fef2860..76ecf13bc 100644
--- a/DataRepo/loaders/study_loader.py
+++ b/DataRepo/loaders/study_loader.py
@@ -65,6 +65,7 @@
     AllMissingTissues,
     AllMissingTreatments,
     AllMultiplePeakGroupRepresentations,
+    AllUnexpectedLabels,
     BlankRemoved,
     BlanksRemoved,
     ConditionallyRequiredArgs,
@@ -84,6 +85,7 @@
     PlaceholderAdded,
     PlaceholdersAdded,
     RecordDoesNotExist,
+    UnexpectedLabels,
     UnknownStudyDocVersion,
 )
 from DataRepo.utils.file_utils import (
@@ -349,6 +351,7 @@ def __init__(self, *args, **kwargs):
         self.no_sample_record_exceptions = []
         self.missing_tissue_record_exceptions = []
         self.missing_treatment_record_exceptions = []
+        self.unexpected_labels_exceptions = []
         self.missing_compound_record_exceptions = []
         self.multiple_pg_reps_exceptions = []
         self.load_statuses = MultiLoadStatus()
@@ -830,6 +833,14 @@ def extract_repeated_exceptions(self, aes: AggregatedErrors):
             mpgr_exc.set_formatted_message(suggestion=self.representations_suggestion)
             self.multiple_pg_reps_exceptions.extend(mpgr_exc.exceptions)
 
+        # Unexpected labels exceptions
+        uel_excs = aes.modify_exception_type(
+            UnexpectedLabels, is_fatal=False, is_error=False
+        )
+        uel_exc: UnexpectedLabels
+        for uel_exc in uel_excs:
+            self.unexpected_labels_exceptions.extend(uel_exc.exceptions)
+
     def extract_missing_records_exception(
         self,
         aes: AggregatedErrors,
@@ -911,6 +922,13 @@ def create_grouped_exceptions(self):
                 True,
                 self.representations_suggestion,
             ),
+            (
+                AllUnexpectedLabels,
+                self.unexpected_labels_exceptions,
+                "Contamination Check",
+                True,
+                None,
+            ),
         ]:
             # Add this load key (if not already present anong the load keys).
             # This is what causes passes green status checks on the validation status report.
@@ -922,6 +940,12 @@ def create_grouped_exceptions(self):
                     exc_cls(exc_lst, succinct=succinct, suggestion=suggestion),
                     load_key,
                     top=True,
+                    default_is_error=any(
+                        e.is_error for e in exc_lst if hasattr(e, "is_error")
+                    ),
+                    default_is_fatal=any(
+                        e.is_fatal for e in exc_lst if hasattr(e, "is_fatal")
+                    ),
                 )
 
     @classmethod
@@ -1155,6 +1179,7 @@ def get_derived_class(cls, df_dict, version=None):
             loader_class (StudyLoader): A derived class of StudyLoader matching the study doc version
         """
         loader_class: TableLoader
+        match_data = {}
 
         if version is not None:
             matching_version_numbers = [version]
diff --git a/DataRepo/tests/loaders/test_msruns_loader.py b/DataRepo/tests/loaders/test_msruns_loader.py
index aab469a78..91d079e4b 100644
--- a/DataRepo/tests/loaders/test_msruns_loader.py
+++ b/DataRepo/tests/loaders/test_msruns_loader.py
@@ -36,7 +36,7 @@
     MzxmlColocatedWithMultipleAnnot,
     MzxmlNotColocatedWithAnnot,
     NoSamples,
-    PossibleDuplicateSamples,
+    PossibleDuplicateSample,
     RecordDoesNotExist,
     RequiredColumnValue,
     RollbackException,
@@ -1887,7 +1887,7 @@ def test_check_sample_headers(self):
         msrl.check_sample_headers()
         self.assertEqual(1, len(msrl.aggregated_errors_object.exceptions))
         self.assertIsInstance(
-            msrl.aggregated_errors_object.exceptions[0], PossibleDuplicateSamples
+            msrl.aggregated_errors_object.exceptions[0], PossibleDuplicateSample
         )
         self.assertFalse(msrl.aggregated_errors_object.exceptions[0].is_error)
         self.assertFalse(msrl.aggregated_errors_object.exceptions[0].is_fatal)
diff --git a/DataRepo/tests/loaders/test_study_loader.py b/DataRepo/tests/loaders/test_study_loader.py
index 846137cdf..7fe4f7e6c 100644
--- a/DataRepo/tests/loaders/test_study_loader.py
+++ b/DataRepo/tests/loaders/test_study_loader.py
@@ -232,7 +232,7 @@ def test_study_loader_create_grouped_exceptions(self):
         ]
         sl.create_grouped_exceptions()
         self.assertEqual(
-            8,
+            9,
             len(sl.load_statuses.statuses.keys()),
             msg=f"Load status keys: {list(sl.load_statuses.statuses.keys())}",
         )
diff --git a/DataRepo/tests/management/commands/test_load_peak_annotation_files.py b/DataRepo/tests/management/commands/test_load_peak_annotation_files.py
index aec2b25ef..c9d3f081f 100644
--- a/DataRepo/tests/management/commands/test_load_peak_annotation_files.py
+++ b/DataRepo/tests/management/commands/test_load_peak_annotation_files.py
@@ -58,6 +58,7 @@ def test_load_singly_labeled_isocorr_load(self):
         call_command(
             "load_peak_annotation_files",
             infile="DataRepo/data/tests/singly_labeled_isocorr/small_cor_peak_annot_files.tsv",
+            debug=True,
         )
         post_pg_load_count = PeakGroup.objects.count()
         self.assertGreater(post_pg_load_count, pre_pg_load_count)
diff --git a/DataRepo/tests/management/commands/test_load_peak_annotations.py b/DataRepo/tests/management/commands/test_load_peak_annotations.py
index d023cb214..55ba31dd4 100644
--- a/DataRepo/tests/management/commands/test_load_peak_annotations.py
+++ b/DataRepo/tests/management/commands/test_load_peak_annotations.py
@@ -186,6 +186,7 @@ def test_blank_warn(self):
         call_command(
             "load_msruns",
             infile="DataRepo/data/tests/blank_samples/blanks1/blank_sample_warn_study.xlsx",
+            debug=True,
         )
 
         # This should also skip a sample *column* named "blank" in blank_samples/blanks1/accucor_with_blank.xlsx with a
diff --git a/DataRepo/tests/utils/test_exceptions.py b/DataRepo/tests/utils/test_exceptions.py
index 031b51b6f..0c7d51d5a 100644
--- a/DataRepo/tests/utils/test_exceptions.py
+++ b/DataRepo/tests/utils/test_exceptions.py
@@ -40,8 +40,8 @@
     NoTracerLabeledElements,
     ObservedIsotopeUnbalancedError,
     OptionsNotAvailable,
+    PossibleDuplicateSample,
     PossibleDuplicateSamples,
-    PossibleDuplicateSamplesError,
     RecordDoesNotExist,
     RequiredArgument,
     RequiredColumnValue,
@@ -54,7 +54,7 @@
     SummarizableError,
     SummarizedInfileError,
     UnequalColumnGroups,
-    UnexpectedLabels,
+    UnexpectedLabel,
     UnexpectedSamples,
     UnknownHeader,
     UnskippedBlanks,
@@ -1098,11 +1098,14 @@ def test_ObservedIsotopeUnbalancedError(self):
         )
 
     def test_UnexpectedLabels(self):
-        exc = UnexpectedLabels(["D"], ["C", "N"])
+        exc = UnexpectedLabel(["D"], ["C", "N"])
         self.assertIn(
-            "label(s) ['D'] were not among the labels in the tracer(s): ['C', 'N']",
+            "One or more observed peak labels were not among the label(s) in the tracer(s)",
             str(exc),
         )
+        self.assertIn("Observed: ['D']", str(exc))
+        self.assertIn("Expected: ['C', 'N']", str(exc))
+        self.assertIn("There may be contamination", str(exc))
 
     def test_MzxmlSampleHeaderMismatch(self):
         exc = MzxmlSampleHeaderMismatch("sample", "location/sample_neg.mzXML")
@@ -1330,13 +1333,13 @@ def test_MissingC12ParentPeak(self):
         self.assertIn("neglect to include the C12 PARENT peak", str(mcpp))
 
     def test_PossibleDuplicateSamplesError(self):
-        pds = PossibleDuplicateSamples("s1", ["s1_pos", "s1_neg"])
-        pdse = PossibleDuplicateSamplesError([pds])
+        pds = PossibleDuplicateSample("s1", ["s1_pos", "s1_neg"])
+        pdse = PossibleDuplicateSamples([pds])
         # Check problem described
         self.assertIn(
             "same name that are associated with different database samples", str(pdse)
         )
         # Check data included
-        self.assertIn("s1: ['s1_pos', 's1_neg']", str(pdse))
+        self.assertIn("header 's1' maps to samples: ['s1_pos', 's1_neg']", str(pdse))
         # Check suggestion exists
         self.assertIn("associated with the same tracebase sample", str(pdse))
diff --git a/DataRepo/tests/utils/test_infusate_parser.py b/DataRepo/tests/utils/test_infusate_parser.py
index 8e81899b7..0473edc64 100644
--- a/DataRepo/tests/utils/test_infusate_parser.py
+++ b/DataRepo/tests/utils/test_infusate_parser.py
@@ -7,7 +7,7 @@
     IsotopeStringDupe,
     ObservedIsotopeParsingError,
     ObservedIsotopeUnbalancedError,
-    UnexpectedLabels,
+    UnexpectedLabel,
 )
 from DataRepo.utils.infusate_name_parser import (
     InfusateData,
@@ -556,7 +556,7 @@ def test_parse_isotope_label_UnexpectedLabels(self):
                 parent=True,
             ),
         ]
-        with self.assertRaises(UnexpectedLabels):
+        with self.assertRaises(UnexpectedLabel):
             parse_isotope_label(label, possible_obs)
 
     def test_parse_isotope_label_IsotopeStringDupe(self):
diff --git a/DataRepo/tests/views/upload/test_submission.py b/DataRepo/tests/views/upload/test_submission.py
index 542c410f1..f21447f83 100644
--- a/DataRepo/tests/views/upload/test_submission.py
+++ b/DataRepo/tests/views/upload/test_submission.py
@@ -1314,11 +1314,11 @@ def test_accucor_validation_error(self):
         self.assertEqual("AllMissingSamples", exceptions[groupkey][0]["type"])
         self.assertEqual(0, num_warnings[groupkey])
 
-        self.assertEqual(9, len(results.keys()), msg=f"Keys: {results.keys()}")
-        self.assertEqual(9, len(exceptions.keys()), msg=f"Keys: {exceptions.keys()}")
-        self.assertEqual(9, len(num_errors.keys()), msg=f"Keys: {num_errors.keys()}")
+        self.assertEqual(10, len(results.keys()), msg=f"Keys: {results.keys()}")
+        self.assertEqual(10, len(exceptions.keys()), msg=f"Keys: {exceptions.keys()}")
+        self.assertEqual(10, len(num_errors.keys()), msg=f"Keys: {num_errors.keys()}")
         self.assertEqual(
-            9, len(num_warnings.keys()), msg=f"Keys: {num_warnings.keys()}"
+            10, len(num_warnings.keys()), msg=f"Keys: {num_warnings.keys()}"
         )
 
         self.assertFalse(valid)
diff --git a/DataRepo/utils/exceptions.py b/DataRepo/utils/exceptions.py
index e81aa199a..f0c48875f 100644
--- a/DataRepo/utils/exceptions.py
+++ b/DataRepo/utils/exceptions.py
@@ -1853,6 +1853,7 @@ def get_exception_type(
         modify=True,
         attr_name=None,
         attr_val=None,
+        is_error=None,
     ):
         """
         This method is provided to retrieve exceptions (if they exist in the exceptions list) from this object and
@@ -1864,20 +1865,27 @@ def get_exception_type(
             modify (boolean): Whether the convert a removed exception to a warning
             attr_name (str): An attribute required to be in the exception in order to match
             attr_val (object): The value of attr_name required to match in the exception
+            is_error (Optional[bool]): The exceptions' is_error attribute must have this value (if not None)
+        Exceptions:
+            None
         Returns:
             matched_exceptions (List[Exception]): Exceptions from self.exception that matched the search criteria
         """
         matched_exceptions = []
         unmatched_exceptions = []
-        is_fatal = False
-        is_error = False
+        final_is_fatal = False
+        final_is_error = False
         num_errors = 0
         num_warnings = 0
 
         # Look for exceptions to remove and recompute new object values
         for exception in self.exceptions:
             if self.exception_matches(
-                exception, exception_class, attr_name=attr_name, attr_val=attr_val
+                exception,
+                exception_class,
+                attr_name=attr_name,
+                attr_val=attr_val,
+                is_error=is_error,
             ):
                 if remove and modify:
                     # Change every removed exception to a non-fatal warning
@@ -1891,16 +1899,16 @@ def get_exception_type(
                 else:
                     num_warnings += 1
                 if exception.is_fatal:
-                    is_fatal = True
+                    final_is_fatal = True
                 if exception.is_error:
-                    is_error = True
+                    final_is_error = True
                 unmatched_exceptions.append(exception)
 
         if remove:
             self.num_errors = num_errors
             self.num_warnings = num_warnings
-            self.is_fatal = is_fatal
-            self.is_error = is_error
+            self.is_fatal = final_is_fatal
+            self.is_error = final_is_error
 
             # Reinitialize this object
             self.exceptions = unmatched_exceptions
@@ -1932,7 +1940,8 @@ def modify_exception_type(
         Args:
             exception_class (Exception): The class of exceptions to modify
             is_fatal (bool): Change matching exceptions' is_fatal attribute to this value
-            is_error (bool): Change matching exceptions' is_error attribute to this value
+            is_error (bool): Change matching exceptions' is_error attribute to this value (but only match the exception
+                if it is NOT is_error [os is_error is None])
             status_message (str): Change matching exceptions' aes_status_message attribute to this value
             attr_name (str): An attribute required to be in the exception in order to match
             attr_val (object): The value of attr_name required to match in the exception
@@ -1948,7 +1957,11 @@ def modify_exception_type(
         # Look for exceptions to remove and recompute new object values
         for exception in self.exceptions:
             if self.exception_matches(
-                exception, exception_class, attr_name=attr_name, attr_val=attr_val
+                exception,
+                exception_class,
+                attr_name=attr_name,
+                attr_val=attr_val,
+                is_error=(is_error if is_error is None else not is_error),
             ):
                 if is_error is not None:
                     exception.is_error = is_error
@@ -2042,7 +2055,7 @@ def modify_exception(
         if not self.custom_message:
             super().__init__(self.get_default_message())
 
-    def remove_exception_type(self, exception_class, modify=True):
+    def remove_exception_type(self, exception_class, modify=True, is_error=None):
         """
         To support consolidation of errors across files (like MissingCompounds, MissingSamples, etc), this method
         is provided to remove such exceptions (if they exist in the exceptions list) from this object and return them
@@ -2051,8 +2064,15 @@ def remove_exception_type(self, exception_class, modify=True):
         Args:
             exception_class (Exception): The class of exceptions to remove
             modify (boolean): Whether to convert the removed exception to a warning
+            is_error (Optional[bool]): Restricts the summarized exceptions to errors or warnings (or either when None)
+        Exceptions:
+            None
+        Returns:
+            (List[Exception])
         """
-        return self.get_exception_type(exception_class, remove=True, modify=modify)
+        return self.get_exception_type(
+            exception_class, remove=True, modify=modify, is_error=is_error
+        )
 
     def get_default_message(self):
         should_raise_message = (
@@ -2283,25 +2303,45 @@ def get_exception_types(self):
     def exception_type_exists(self, exc_cls):
         return exc_cls in [type(exc) for exc in self.exceptions]
 
-    def exception_matches(self, exception, cls, attr_name=None, attr_val=None):
+    def exception_matches(
+        self, exception, cls, attr_name=None, attr_val=None, is_error=None
+    ):
+        """Returns whether the supplied exception matches the supplied class, has an attribute with a specific value and
+        is an error or warning.
+
+        Args:
+            exception (Exception)
+            cls (Type[Exception]): The exception's type must exactly match this class.
+            attr_name (Optional[str]): The exception must have this attribute (if not None)
+            attr_val (Optional[Any]): The exceptions attribute must have this value (if the attr_name is not None)
+            is_error (Optional[bool]): The exception's is_error attribute must have this value (if not None)
+        Exceptions:
+            None
+        Returns:
+            (bool)
+        """
         # Intentionally looks for exact type (not isinstance).  isinstance is not what we want here, because I cannot
         # separate MissingSamples exceptions from NoSamples exceptions (because NoSamples is a subclass of
         # MissingSamples))
-        return type(exception).__name__ == cls.__name__ and (
-            attr_name is None
-            or (
-                hasattr(exception, attr_name)
-                and (
-                    (
-                        not type(attr_val).__name__ == "function"
-                        and getattr(exception, attr_name) == attr_val
-                    )
-                    or (
-                        type(attr_val).__name__ == "function"
-                        and attr_val(getattr(exception, attr_name))
+        return (
+            type(exception).__name__ == cls.__name__
+            and (
+                attr_name is None
+                or (
+                    hasattr(exception, attr_name)
+                    and (
+                        (
+                            not type(attr_val).__name__ == "function"
+                            and getattr(exception, attr_name) == attr_val
+                        )
+                        or (
+                            type(attr_val).__name__ == "function"
+                            and attr_val(getattr(exception, attr_name))
+                        )
                     )
                 )
             )
+            and (is_error is None or exception.is_error == is_error)
         )
 
     def exception_exists(self, cls, attr_name, attr_val):
@@ -2322,7 +2362,7 @@ def exception_exists(self, cls, attr_name, attr_val):
                 return True
         return False
 
-    def remove_matching_exceptions(self, cls, attr_name, attr_val):
+    def remove_matching_exceptions(self, cls, attr_name, attr_val, is_error=None):
         """
         To support consolidation of errors across files (like MissingCompounds, MissingSamples, etc), this method
         is provided to remove such exceptions (if they exist in the exceptions list) from this object and return them
@@ -2333,6 +2373,7 @@ def remove_matching_exceptions(self, cls, attr_name, attr_val):
             attr_name (str): An attribute the buffered exception class has.
             attr_val (object): The value of the attribute the buffered exception class has.  If this is a function, it
                 must take a single argument (the value of the attribute) and return a boolean.
+            is_error (Optional[bool]): Only match when the exception is or is not an error(/warning).
         Exceptions:
             None
         Returns (List[Exception]): A list of exceptions of the supplied type, and containing the supplied attribute with
@@ -2340,14 +2381,16 @@ def remove_matching_exceptions(self, cls, attr_name, attr_val):
         """
         matched_exceptions = []
         unmatched_exceptions = []
-        is_fatal = False
-        is_error = False
+        final_is_fatal = False
+        final_is_error = False
         num_errors = 0
         num_warnings = 0
 
         # Look for exceptions to remove and recompute new object values
         for exception in self.exceptions:
-            if self.exception_matches(exception, cls, attr_name, attr_val):
+            if (
+                is_error is None or exception.is_error == is_error
+            ) and self.exception_matches(exception, cls, attr_name, attr_val):
                 matched_exceptions.append(exception)
             else:
                 if exception.is_error:
@@ -2355,15 +2398,15 @@ def remove_matching_exceptions(self, cls, attr_name, attr_val):
                 else:
                     num_warnings += 1
                 if exception.is_fatal:
-                    is_fatal = True
+                    final_is_fatal = True
                 if exception.is_error:
-                    is_error = True
+                    final_is_error = True
                 unmatched_exceptions.append(exception)
 
         self.num_errors = num_errors
         self.num_warnings = num_warnings
-        self.is_fatal = is_fatal
-        self.is_error = is_error
+        self.is_fatal = final_is_fatal
+        self.is_error = final_is_error
 
         # Reinitialize this object
         self.exceptions = unmatched_exceptions
@@ -2855,11 +2898,50 @@ def __init__(self, elements, mass_numbers, counts, label, **kwargs):
         self.label = label
 
 
-class UnexpectedLabels(InfileError):
+class AllUnexpectedLabels(Exception):
+    def __init__(self, exceptions: List[UnexpectedLabel]):
+        counts: Dict[str, dict] = defaultdict(lambda: {"files": [], "observations": 0})
+        for exc in exceptions:
+            for element in exc.unexpected:
+                if exc.file not in counts[element]["files"]:
+                    counts[element]["files"].append(exc.file)
+                counts[element]["observations"] += 1
+        message = "The following peak label observations were not among the label(s) in the tracer(s):\n"
+        for element, stats in counts.items():
+            message += (
+                f"\t{element}: observed {stats['observations']} times across {len(stats['files'])} peak annotation "
+                "files\n"
+            )
+        message += "There may be contamination."
+        super().__init__(message)
+        self.exceptions = exceptions
+        self.counts = counts
+
+
+class UnexpectedLabels(Exception):
+    def __init__(self, exceptions: List[UnexpectedLabel]):
+        counts: Dict[str, int] = defaultdict(int)
+        for exc in exceptions:
+            for element in exc.unexpected:
+                counts[element] += 1
+        message = "The following peak label observations were not among the label(s) in the tracer(s):\n"
+        for element, count in counts.items():
+            message += f"\t{element}: observed {count} times\n"
+        message += "There may be contamination."
+        super().__init__(message)
+        self.exceptions = exceptions
+        self.counts = counts
+
+
+class UnexpectedLabel(InfileError, SummarizableError):
+    SummarizerExceptionClass = UnexpectedLabels
+
     def __init__(self, unexpected, possible, **kwargs):
         message = (
-            f"Observed peak label(s) {unexpected} were not among the labels in the tracer(s): {possible}.  There may "
-            "be contamination."
+            f"One or more observed peak labels were not among the label(s) in the tracer(s):\n"
+            f"\tObserved: {unexpected}\n"
+            f"\tExpected: {possible}\n"
+            "There may be contamination.  (Note, the reported observed are only the unexpected labels.)"
         )
         super().__init__(message, **kwargs)
         self.possible = possible
@@ -3281,6 +3363,138 @@ def __init__(self, header, mzxml_file, **kwargs):
         self.mzxml_file = mzxml_file
 
 
+class AssumedMzxmlSampleMatches(Exception):
+    """Takes a list of AssumedMzxmlSampleMatch exceptions and summarizes them in a single exception."""
+
+    def __init__(self, exceptions: List[AssumedMzxmlSampleMatch], message=None):
+        if message is None:
+            message = (
+                "Assuming the following imperfect (but unique) mzXML to sample name matches are due to peak annotation "
+                "file header edits, and that they are correctly mapped:\n"
+            )
+            matches_by_annot_file = defaultdict(list)
+            exc: AssumedMzxmlSampleMatch
+            for exc in exceptions:
+                loc = generate_file_location_string(
+                    file=exc.file,
+                    sheet=exc.sheet,
+                )
+                matches_by_annot_file[loc].append(exc)
+            for loc, exc_list in sorted(
+                matches_by_annot_file.items(), key=lambda tpl: tpl[0]
+            ):
+                message += f"\t{loc}\n"
+                for exc in sorted(exc_list, key=lambda e: e.mzxml_name):
+                    message += f"\t\t'{exc.sample_name}' <- '{exc.mzxml_name}' ('{exc.mzxml_file}')\n"
+        super().__init__(message)
+        self.exceptions = exceptions
+
+
+class AssumedMzxmlSampleMatch(InfileError, SummarizableError):
+    SummarizerExceptionClass = AssumedMzxmlSampleMatches
+
+    def __init__(self, sample_name, mzxml_file, **kwargs):
+        mzxml_name, _ = os.path.splitext(os.path.basename(mzxml_file))
+        message = (
+            "Sample uniquely but imprecisely matches the mzXML filename in %s:\n"
+            f"\tSample: [{sample_name}]\n"
+            f"\tmzXML:  [{mzxml_name}] (path: [{mzxml_file}])\n"
+            "Assuming that the sample header was modified and that this is the intended sample."
+        )
+        super().__init__(message, **kwargs)
+        self.sample_name = sample_name
+        self.mzxml_name = mzxml_name
+        self.mzxml_file = mzxml_file
+
+
+class UnmatchedMzXMLs(Exception):
+    """Takes a list of UnmatchedMzXML exceptions and summarizes them in a single exception."""
+
+    def __init__(self, exceptions: List[UnmatchedMzXML]):
+        # Assumes that the file/sheet/column are all the same and that there is at least 1 exception
+        loc = generate_file_location_string(
+            file=exceptions[0].file,
+            sheet=exceptions[0].sheet,
+            column=exceptions[0].column,
+        )
+        message = f"{len(exceptions)} mzXMLs could not be mapped to a {exceptions[0].sample_header_col}:\n"
+        for exc in exceptions:
+            message += f"\t{exc.mzxml_name} (file: '{exc.mzxml_file}')\n"
+        message += (
+            "Either these files were not included in any peak annotation analysis or the sample headers were modified "
+            f"and could not be automatically matched to existing sample headers in {loc}.\n"
+            "Either update rows to include these files or if any are unanalyzed mzXMLs, add a row and fill in the "
+            f"'{exceptions[0].skip_col}' column."
+        )
+        super().__init__(message)
+        self.exceptions = exceptions
+
+
+class UnmatchedMzXML(InfileError, SummarizableError):
+    SummarizerExceptionClass = UnmatchedMzXMLs
+
+    def __init__(
+        self, mzxml_file: str, sample_header_col: str, skip_col: str, **kwargs
+    ):
+        mzxml_name = os.path.splitext(os.path.basename(mzxml_file))[0]
+        message = (
+            f"mzXML name '{mzxml_name}' (from file '{mzxml_file}') could not be mapped to a {sample_header_col}.\n"
+            "Either this file was not included in a peak annotation analysis or the sample header was modified and "
+            "could not be automatically matched.\n"
+            f"Either update a row that includes '{mzxml_file}' or if this is an unanalyzed mzXML, add a row and fill "
+            f"in the '{skip_col}' column, including %s to resolve this."
+        )
+        super().__init__(message, **kwargs)
+        self.skip_col = skip_col
+        self.mzxml_name = mzxml_name
+        self.mzxml_file = mzxml_file
+        self.sample_header_col = sample_header_col
+
+
+class UnmatchedBlankMzXMLs(Exception):
+    """Takes a list of UnmatchedMzXML exceptions and summarizes them in a single exception."""
+
+    def __init__(self, exceptions: List[UnmatchedBlankMzXML]):
+        # Assumes that the file/sheet/column are all the same and that there is at least 1 exception
+        loc = generate_file_location_string(
+            file=exceptions[0].file,
+            sheet=exceptions[0].sheet,
+            column=exceptions[0].column,
+        )
+        message = f"{len(exceptions)} mzXMLs could not be mapped to a {exceptions[0].sample_header_col}:\n"
+        for exc in exceptions:
+            message += f"\t{exc.mzxml_name} (file: '{exc.mzxml_file}')\n"
+        message += (
+            "These mzXML files, that appear to be for blank samples, could not be automatically matched to existing "
+            f"skipped blank sample headers in {loc}.\n"
+            f"Either update or add rows and fill in the '{exceptions[0].column}' and '{exceptions[0].skip_col}' "
+            "columns for each file."
+        )
+        super().__init__(message)
+        self.exceptions = exceptions
+
+
+class UnmatchedBlankMzXML(InfileError, SummarizableError):
+    SummarizerExceptionClass = UnmatchedBlankMzXMLs
+
+    def __init__(
+        self, mzxml_file: str, sample_header_col: str, skip_col: str, **kwargs
+    ):
+        mzxml_name = os.path.splitext(os.path.basename(mzxml_file))[0]
+        message = (
+            f"mzXML name '{mzxml_name}' (from file '{mzxml_file}'), that appears to be for a blank sample, could not "
+            f"be mapped to a {sample_header_col}.\n"
+            "The file could not be automatically matched to an existing skipped blank sample header.\n"
+            f"Either update a row that includes '{mzxml_name}' or add a row and fill in the '{skip_col}' column, "
+            "including %s to resolve this."
+        )
+        super().__init__(message, **kwargs)
+        self.skip_col = skip_col
+        self.mzxml_name = mzxml_name
+        self.mzxml_file = mzxml_file
+        self.sample_header_col = sample_header_col
+
+
 class InvalidHeaders(InfileError, ValidationError):
     def __init__(self, headers, expected_headers=None, fileformat=None, **kwargs):
         if expected_headers is None:
@@ -3754,7 +3968,7 @@ def __str__(self):
         return self.message
 
 
-class PossibleDuplicateSamplesError(SummarizedInfileError, Exception):
+class PossibleDuplicateSamples(SummarizedInfileError, Exception):
     """Summary of all PossibleDuplicateSamples errors
 
     Attributes:
@@ -3763,13 +3977,13 @@ class PossibleDuplicateSamplesError(SummarizedInfileError, Exception):
 
     def __init__(
         self,
-        exceptions: list[PossibleDuplicateSamples],
+        exceptions: list[PossibleDuplicateSample],
         suggestion=None,
     ):
         SummarizedInfileError.__init__(self, exceptions)
         headers_str = ""
         include_loc = len(self.file_dict.keys()) > 1
-        exc: PossibleDuplicateSamples
+        exc: PossibleDuplicateSample
         for loc, exc_list in self.file_dict.items():
             if include_loc:
                 headers_str += f"\t{loc}\n"
@@ -3778,7 +3992,7 @@ def __init__(
                     headers_str += "\t"
                 rowlist = summarize_int_list(exc.rownum)
                 rowstr = "" if len(rowlist) == 0 else f" on rows: {rowlist}"
-                headers_str += f"\t{exc.sample_header}: {exc.sample_names}{rowstr}\n"
+                headers_str += f"\theader '{exc.sample_header}' maps to samples: {exc.sample_names}{rowstr}\n"
         loc = ""
         if not include_loc:
             loc = " in " + list(self.file_dict.keys())[0]
@@ -3793,8 +4007,8 @@ def __init__(
         Exception.__init__(self, message)
 
 
-class PossibleDuplicateSamples(InfileError, SummarizableError):
-    SummarizerExceptionClass = PossibleDuplicateSamplesError
+class PossibleDuplicateSample(InfileError, SummarizableError):
+    SummarizerExceptionClass = PossibleDuplicateSamples
 
     def __init__(self, sample_header: str, sample_names: List[str], **kwargs):
         nlt = "\n\t"
diff --git a/DataRepo/utils/infusate_name_parser.py b/DataRepo/utils/infusate_name_parser.py
index c985c6299..8591294cc 100644
--- a/DataRepo/utils/infusate_name_parser.py
+++ b/DataRepo/utils/infusate_name_parser.py
@@ -14,7 +14,7 @@
     ObservedIsotopeParsingError,
     ObservedIsotopeUnbalancedError,
     TracerParsingError,
-    UnexpectedLabels,
+    UnexpectedLabel,
 )
 
 KNOWN_ISOTOPES = "".join(ElementLabel.labeled_elements_list())
@@ -418,9 +418,7 @@ def parse_isotope_label(
                         if element not in parent_elements:
                             unexpected_observations.append(element)
                     if len(unexpected_observations) > 0:
-                        raise UnexpectedLabels(
-                            unexpected_observations, possible_observations
-                        )
+                        raise UnexpectedLabel(unexpected_observations, parent_elements)
 
                 if len(dupe_indexes) > 0:
                     # If there are multiple isotope measurements that match the same parent tracer labeled element


Reviews:
user: login: hepcat72
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-04T22:52:29Z
user: login: hepcat72
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-04T22:57:54Z
user: login: hepcat72
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-04T23:12:29Z
user: login: hepcat72
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-04T23:13:28Z
user: login: hepcat72
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-04T23:14:00Z
user: login: hepcat72
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-04T23:15:50Z
user: login: hepcat72
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-04T23:16:33Z
user: login: hepcat72
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-04T23:17:06Z
user: login: hepcat72
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-04T23:19:02Z
user: login: hepcat72
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-04T23:21:44Z
user: login: hepcat72
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-04T23:23:54Z
user: login: hepcat72
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-04T23:25:40Z
user: login: hepcat72
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-04T23:27:57Z
user: login: hepcat72
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-04T23:31:58Z
user: login: hepcat72
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-04T23:34:16Z

Review comments:
diff_hunk: @@ -2305,35 +2305,65 @@ def load_wrapper(self: TableLoader, *args, **kwargs):
                     # Summarize multiple types of exceptions that are subclasses of SummarizableError
                     for exc_cls in self.aggregated_errors_object.get_exception_types():
                         if issubclass(exc_cls, SummarizableError):
-                            # TODO: Change this behavior to always leave exceptions unmodified, so that their
-                            # application to Excel shows their unchanged severity
path: DataRepo/loaders/base/table_loader.py
user: login: hepcat72
body: C. This is done.
created_at: 2025-05-04T22:52:28Z
author_association: COLLABORATOR
diff_hunk: @@ -2305,35 +2305,65 @@ def load_wrapper(self: TableLoader, *args, **kwargs):
                     # Summarize multiple types of exceptions that are subclasses of SummarizableError
                     for exc_cls in self.aggregated_errors_object.get_exception_types():
                         if issubclass(exc_cls, SummarizableError):
-                            # TODO: Change this behavior to always leave exceptions unmodified, so that their
-                            # application to Excel shows their unchanged severity
-
                             # Get the exceptions with their original attributes
path: DataRepo/loaders/base/table_loader.py
user: login: hepcat72
body: C. The changes below basically split the exception summary in 2: one for errors and one for warnings.  In the case of the missing samples, it was confusing that exceptions about blanks (which should have been warnings) were mixed in with samples whose absence needed to be fixed.  The overall error status was governed by whether there was at least 1 error in the group.  I wasn't sure if the blank samples were being misidentified as errors.  This was complicated by unrepresented mzXMLs whose errors/warnings were being represented as missing samples (because the mzXML name was being used to search for a sample).  Splitting this up allowed me to tease the cases apart to understand what was going on.  It also made for an easy way to copy unanalyzed samples/mzXML files into the peak annotation details sheet.
created_at: 2025-05-04T22:57:54Z
author_association: COLLABORATOR
diff_hunk: @@ -32,6 +32,7 @@
 )
 from DataRepo.utils.exceptions import (
     AggregatedErrors,
+    AssumedMzxmlSampleMatch,
path: DataRepo/loaders/msruns_loader.py
user: login: hepcat72
body: C. Another confusing thing that was happening was that whenever a sample name started with a number and the exact sample name wasn't found, the code attempts to look for existing samples that uniquely "end with" a sample name derived from the mzXML.  But it was still buffering an error with the intent of strongly suggesting that the user add the mzXML filename to the peak annotation details sheet, since it differed.  I had thought that this was originally coded to be a warning, or maybe it is turned into a warning if the row existed and this case was when the row didn't exist.  Whatever the case was, what was particularly confusing was that the sample name reported as missing was the one without any scan labels (because those are removed for the search, as they should be).  Normally, this is unambiguous in the case where the mzXML files all have scan labels (e.g. either `_pos` or `_neg`) but Anna's Acute Stress data had `_neg` paired with **no** scan label.  So I was not seeing or visually associating the `_neg` at all, thus didn't realize that a row was missing from the peak annotation details sheet.

The fix was to represent the error from the perspective of the mzXML file, and in this case, I decided to make it a warning, to match the existing row case.  Regardless of whatever was happening before, now it's simple:

In the case of `mzXML`s, if there's a unique match (but endswith was used), it is an `AssumedMzxmlSampleMatch` warning.  Otherwise, it's an `UnmatchedMzXML` error (unless it contains 'blank' - then it's an `UnmatchedBlankMzXML`).
created_at: 2025-05-04T23:12:29Z
author_association: COLLABORATOR
diff_hunk: @@ -262,6 +265,8 @@ def __init__(self, *args, **kwargs):
             Superclass Args:
                 df (Optional[pandas dataframe]): Data, e.g. as parsed from a table-like file.
                 dry_run (Optional[boolean]) [False]: Dry run mode.
+                debug (bool) [False]: Debug mode causes all buffered exception traces to be printed.  Normally, if an
+                    exception is a subclass of SummarizableError, the printing of its trace is suppressed.
path: DataRepo/loaders/msruns_loader.py
user: login: hepcat72
body: C. Noticed this new superclass option was missing. Having it in the docstring is helpful.
created_at: 2025-05-04T23:13:28Z
author_association: COLLABORATOR
diff_hunk: @@ -298,7 +303,13 @@ def __init__(self, *args, **kwargs):
                     name that matches the sample header, it will consider dashes and underscores as equal.  When True,
                     only sample headers that exactly match the file name will be considered matches.
         Exceptions:
-            None
+            Buffered:
+                InfileError when a paths to a peak annotation file is absolute
+                MutuallyExclusiveArgs
+                DefaultSequenceNotFound
+                MultipleDefaultSequencesFound
+            Raised:
+                AggregatedErrors
path: DataRepo/loaders/msruns_loader.py
user: login: hepcat72
body: C. I noted the docstring exceptions list was stale.
created_at: 2025-05-04T23:14:00Z
author_association: COLLABORATOR
diff_hunk: @@ -726,7 +737,8 @@ def load_data(self):
                                     "be included for each mzXML file in the mzXML file name column so that we can tell "
                                     "which ones to load and which to skip)."
                                 ),
-                            )
+                            ),
+                            is_fatal=self.validate,
path: DataRepo/loaders/msruns_loader.py
user: login: hepcat72
body: C. Added this in a number of cases.  Peak annotation files and `mzXML`s are never validated on the validation page, but checking their names is coming down the pike, so I thought adding it when I see a warning missing it would be beneficial down the road.
created_at: 2025-05-04T23:15:49Z
author_association: COLLABORATOR
diff_hunk: @@ -936,7 +948,7 @@ def check_sample_headers(self):
                     sample_names.append(sn)
                     rows.extend(self.header_to_sample_name[sample_header][sn])
                 self.aggregated_errors_object.buffer_exception(
-                    PossibleDuplicateSamples(
+                    PossibleDuplicateSample(
path: DataRepo/loaders/msruns_loader.py
user: login: hepcat72
body: C. Renamed a few exceptions to follow summarization conventions I've established.
created_at: 2025-05-04T23:16:33Z
author_association: COLLABORATOR
diff_hunk: @@ -970,7 +982,7 @@ def report_discrepant_headers(self):
         """
         # Extract exceptions about missing Sample records
         sample_dnes = self.aggregated_errors_object.remove_matching_exceptions(
-            RecordDoesNotExist, "model", Sample
+            RecordDoesNotExist, "model", Sample, is_error=True
         )
path: DataRepo/loaders/msruns_loader.py
user: login: hepcat72
body: C. Added the ability to match the error status in the match.
created_at: 2025-05-04T23:17:06Z
author_association: COLLABORATOR
diff_hunk: @@ -1684,56 +1717,117 @@ def get_sample_by_name(self, sample_name, from_mzxml=None):
                             except Sample.DoesNotExist:
                                 # Ignore this attempt and press on with processing the original exception
                                 pass
path: DataRepo/loaders/msruns_loader.py
user: login: hepcat72
body: C. I wasn't sure whether the "missing samples" were actually missing or whether I was dealing with multiple endswith matches, so I decided to handle them separately.
created_at: 2025-05-04T23:19:01Z
author_association: COLLABORATOR
diff_hunk: @@ -1684,56 +1717,117 @@ def get_sample_by_name(self, sample_name, from_mzxml=None):
                             except Sample.DoesNotExist:
                                 # Ignore this attempt and press on with processing the original exception
                                 pass
+                            except Sample.MultipleObjectsReturned:
+                                matching_samples = list(
+                                    Sample.objects.filter(
+                                        name__endswith=orig_mzxml_sample_name
+                                    ).values_list("name", flat=True)
+                                )
+                                self.aggregated_errors_object.buffer_exception(
+                                    MultipleRecordsReturned(
+                                        Sample,
+                                        {"name": sample_name},
+                                        file=self.friendly_file,
+                                        sheet=self.sheet,
+                                        column=self.headers.MZXMLNAME,
+                                        rownum="no row - sample name was derived from an mzXML filename",
+                                        message=(
+                                            f"{Sample.__name__} record matching the exact mzXML file's basename "
+                                            f"[{sample_name_used}] does not exist, but the filename starts with a "
+                                            "number, which means that the peak annotation software would have "
+                                            "required the name to be prepended with a letter.  There are multiple "
+                                            f"samples that end with this name: {matching_samples}.  Please "
+                                            f"identify the associated sample(s) and add a the file '{from_mzxml}' to "
+                                            "%s.  If no such row exists and this is an unanalyzed mzXML file, add a "
+                                            f"row and fill in the '{self.headers.SKIP}' column."
+                                        ),
+                                    ),
+                                    is_error=not Sample.is_a_blank(sample_name),
+                                    is_fatal=not Sample.is_a_blank(sample_name)
+                                    or self.validate,
+                                    orig_exception=dne,
+                                )
+                                return None
+                    except Sample.MultipleObjectsReturned:
+                        matching_samples = list(
+                            Sample.objects.filter(
+                                name__endswith=sample_name
+                            ).values_list("name", flat=True)
+                        )
+                        self.aggregated_errors_object.buffer_exception(
+                            MultipleRecordsReturned(
+                                Sample,
+                                {"name": sample_name},
+                                file=self.friendly_file,
+                                sheet=self.sheet,
+                                column=self.headers.MZXMLNAME,
+                                rownum="no row - sample name was derived from an mzXML filename",
+                                message=(
+                                    f"{Sample.__name__} record matching the exact mzXML file's basename "
+                                    f"[{sample_name}] does not exist, but the filename starts with a number, which "
+                                    "means that the peak annotation software would have required the name to be "
+                                    "prepended with a letter.  There are multiple samples that end with this name: "
+                                    f"{matching_samples}.  Please identify the associated sample(s) and add a the "
+                                    f"file '{from_mzxml}' to %s.  If no such row exists and this is an unanalyzed "
+                                    f"mzXML file, add a row and fill in the '{self.headers.SKIP}' column."
+                                ),
+                            ),
+                            is_error=not Sample.is_a_blank(sample_name),
+                            is_fatal=not Sample.is_a_blank(sample_name)
+                            or self.validate,
+                            orig_exception=dne,
+                        )
+                        return None
                     finally:
                         if rec is not None:
 
                             # Buffer an error that says that we're going to proceed assuming the found sample is a
                             # match
-                            self.aggregated_errors_object.buffer_error(
-                                RecordDoesNotExist(
-                                    Sample,
-                                    {"name": sample_name},
+                            self.aggregated_errors_object.buffer_warning(
+                                AssumedMzxmlSampleMatch(
+                                    sample_name=rec.name,
+                                    mzxml_file=from_mzxml,
                                     file=self.friendly_file,
                                     sheet=self.sheet,
                                     column=self.headers.MZXMLNAME,
                                     rownum="no row - sample name was derived from an mzXML filename",
-                                    message=(
-                                        f"{Sample.__name__} record matching the mzXML file's basename "
-                                        f"[{sample_name_used}] does not exist, but the filename starts with a number "
-                                        f"and happens to otherwise uniquely match sample '{rec.name}'.  Please "
-                                        f"identify the associated sample(s) and add a the file '{from_mzxml}' to %s.  "
-                                        "If no such row exists and this is an unanalyzed mzXML file, add a row and "
-                                        f"fill in the '{self.headers.SKIP}' column."
-                                    ),
-                                    suggestion=(
-                                        "Proceeding with the assumption that the mzXML-derived sample name "
-                                        f"'{sample_name}' is intended to match sample '{rec.name}'."
-                                    ),
                                 ),
+                                is_fatal=self.validate,
                                 orig_exception=dne,
                             )
 
                             return rec
 
path: DataRepo/loaders/msruns_loader.py
user: login: hepcat72
body: C. Reframed the errors as unmatched mzXMLs and since that meant the exceptions wouldn't be split into blanks and missing samples during clean-up, I had to split the warning for blanks explicitly here.
created_at: 2025-05-04T23:21:44Z
author_association: COLLABORATOR
diff_hunk: @@ -1684,56 +1717,117 @@ def get_sample_by_name(self, sample_name, from_mzxml=None):
                             except Sample.DoesNotExist:
                                 # Ignore this attempt and press on with processing the original exception
                                 pass
+                            except Sample.MultipleObjectsReturned:
+                                matching_samples = list(
+                                    Sample.objects.filter(
+                                        name__endswith=orig_mzxml_sample_name
+                                    ).values_list("name", flat=True)
+                                )
+                                self.aggregated_errors_object.buffer_exception(
+                                    MultipleRecordsReturned(
+                                        Sample,
+                                        {"name": sample_name},
+                                        file=self.friendly_file,
+                                        sheet=self.sheet,
+                                        column=self.headers.MZXMLNAME,
+                                        rownum="no row - sample name was derived from an mzXML filename",
+                                        message=(
+                                            f"{Sample.__name__} record matching the exact mzXML file's basename "
+                                            f"[{sample_name_used}] does not exist, but the filename starts with a "
+                                            "number, which means that the peak annotation software would have "
+                                            "required the name to be prepended with a letter.  There are multiple "
+                                            f"samples that end with this name: {matching_samples}.  Please "
+                                            f"identify the associated sample(s) and add a the file '{from_mzxml}' to "
+                                            "%s.  If no such row exists and this is an unanalyzed mzXML file, add a "
+                                            f"row and fill in the '{self.headers.SKIP}' column."
+                                        ),
+                                    ),
+                                    is_error=not Sample.is_a_blank(sample_name),
+                                    is_fatal=not Sample.is_a_blank(sample_name)
+                                    or self.validate,
+                                    orig_exception=dne,
+                                )
+                                return None
+                    except Sample.MultipleObjectsReturned:
+                        matching_samples = list(
+                            Sample.objects.filter(
+                                name__endswith=sample_name
+                            ).values_list("name", flat=True)
+                        )
+                        self.aggregated_errors_object.buffer_exception(
+                            MultipleRecordsReturned(
+                                Sample,
+                                {"name": sample_name},
+                                file=self.friendly_file,
+                                sheet=self.sheet,
+                                column=self.headers.MZXMLNAME,
+                                rownum="no row - sample name was derived from an mzXML filename",
+                                message=(
+                                    f"{Sample.__name__} record matching the exact mzXML file's basename "
+                                    f"[{sample_name}] does not exist, but the filename starts with a number, which "
+                                    "means that the peak annotation software would have required the name to be "
+                                    "prepended with a letter.  There are multiple samples that end with this name: "
+                                    f"{matching_samples}.  Please identify the associated sample(s) and add a the "
+                                    f"file '{from_mzxml}' to %s.  If no such row exists and this is an unanalyzed "
+                                    f"mzXML file, add a row and fill in the '{self.headers.SKIP}' column."
+                                ),
+                            ),
+                            is_error=not Sample.is_a_blank(sample_name),
+                            is_fatal=not Sample.is_a_blank(sample_name)
+                            or self.validate,
+                            orig_exception=dne,
+                        )
+                        return None
                     finally:
                         if rec is not None:
 
                             # Buffer an error that says that we're going to proceed assuming the found sample is a
                             # match
-                            self.aggregated_errors_object.buffer_error(
-                                RecordDoesNotExist(
-                                    Sample,
-                                    {"name": sample_name},
+                            self.aggregated_errors_object.buffer_warning(
+                                AssumedMzxmlSampleMatch(
+                                    sample_name=rec.name,
+                                    mzxml_file=from_mzxml,
                                     file=self.friendly_file,
                                     sheet=self.sheet,
                                     column=self.headers.MZXMLNAME,
                                     rownum="no row - sample name was derived from an mzXML filename",
-                                    message=(
-                                        f"{Sample.__name__} record matching the mzXML file's basename "
-                                        f"[{sample_name_used}] does not exist, but the filename starts with a number "
-                                        f"and happens to otherwise uniquely match sample '{rec.name}'.  Please "
-                                        f"identify the associated sample(s) and add a the file '{from_mzxml}' to %s.  "
-                                        "If no such row exists and this is an unanalyzed mzXML file, add a row and "
-                                        f"fill in the '{self.headers.SKIP}' column."
-                                    ),
-                                    suggestion=(
-                                        "Proceeding with the assumption that the mzXML-derived sample name "
-                                        f"'{sample_name}' is intended to match sample '{rec.name}'."
-                                    ),
                                 ),
+                                is_fatal=self.validate,
                                 orig_exception=dne,
                             )
 
                             return rec
 
-                self.aggregated_errors_object.buffer_error(
-                    RecordDoesNotExist(
-                        Sample,
-                        {"name": sample_name},
-                        file=self.friendly_file,
-                        sheet=self.sheet,
-                        column=self.headers.MZXMLNAME,
-                        rownum="no row - sample name was derived from an mzXML filename",
-                        message=(
-                            f"{Sample.__name__} record matching the mzXML file's{' exact' if self.exact_mode else ''} "
-                            f"basename [{sample_name}] does not exist.  Please identify the associated sample(s) and "
-                            f"add a the file '{from_mzxml}' to %s.  If no such row exists and this is an unanalyzed "
-                            f"mzXML file, add a row and fill in the '{self.headers.SKIP}' column."
+                if Sample.is_a_blank(sample_name):
+                    self.aggregated_errors_object.buffer_warning(
+                        UnmatchedBlankMzXML(
+                            from_mzxml,
+                            self.headers.SAMPLEHEADER,
+                            self.headers.SKIP,
+                            file=self.friendly_file,
+                            sheet=self.sheet,
+                            column=self.headers.MZXMLNAME,
+                            rownum="no row - sample name was derived from an mzXML filename",
+                            suggestion="This file will not be linked to any sample.",
                         ),
-                    ),
-                    orig_exception=dne,
-                )
+                        is_fatal=self.validate,
+                        orig_exception=dne,
+                    )
+                else:
+                    self.aggregated_errors_object.buffer_error(
+                        UnmatchedMzXML(
+                            from_mzxml,
+                            self.headers.SAMPLEHEADER,
+                            self.headers.SKIP,
+                            file=self.friendly_file,
+                            sheet=self.sheet,
+                            column=self.headers.MZXMLNAME,
+                            rownum="no row - sample name was derived from an mzXML filename",
+                        ),
+                        orig_exception=dne,
+                    )
             else:
-                self.aggregated_errors_object.buffer_error(
path: DataRepo/loaders/msruns_loader.py
user: login: hepcat72
body: C. These exceptions are later sorted-through to be divided into missing samples and unskipped blanks, but I decided that (even though that will still happen), it would be less confusing if the base level exception displayed as a warning too.
created_at: 2025-05-04T23:23:54Z
author_association: COLLABORATOR
diff_hunk: @@ -496,14 +490,14 @@ def load_peak_annotations(
             )
             return
 
-        if format_code == AccucorLoader.format_code:
-            peak_annot_loader_class = AccucorLoader
-        elif format_code == IsocorrLoader.format_code:
-            peak_annot_loader_class = IsocorrLoader
-        elif format_code == IsoautocorrLoader.format_code:
-            peak_annot_loader_class = IsoautocorrLoader
-        elif format_code == UnicorrLoader.format_code:
-            peak_annot_loader_class = UnicorrLoader
+        for subcls in PeakAnnotationsLoader.__subclasses__():
+            if format_code == subcls.format_code:
+                peak_annot_loader_class = subcls
+                break
+        else:
+            raise NotImplementedError(
+                f"format_code: {format_code} is not one of {PeakAnnotationsLoader.get_supported_formats()}"
+            )
path: DataRepo/loaders/peak_annotation_files_loader.py
user: login: hepcat72
body: C. My newer command line linter version chided me about `peak_annot_loader_class` potentially not being defined, so while I was at it, I decided to use `__subclasses__()` to reduce future code maintenance.
created_at: 2025-05-04T23:25:40Z
author_association: COLLABORATOR
diff_hunk: @@ -569,6 +563,7 @@ def load_peak_annotations(
             # Pass-alongs
             _validate=self.validate,
             defer_rollback=self.defer_rollback,
+            debug=self.debug,
path: DataRepo/loaders/peak_annotation_files_loader.py
user: login: hepcat72
body: C. Turned out that the newer superclass debug option wasn't being passed along.  `--debug` is used on the command line to print the individual exceptions that are later summarized.  Without it, all the curator sees is the summarized error, but for debugging, it's useful to see that stack trace, so adding `--debug` helped me to debug this issue.
created_at: 2025-05-04T23:27:57Z
author_association: COLLABORATOR
diff_hunk: @@ -349,6 +351,7 @@ def __init__(self, *args, **kwargs):
         self.no_sample_record_exceptions = []
         self.missing_tissue_record_exceptions = []
         self.missing_treatment_record_exceptions = []
+        self.unexpected_labels_exceptions = []
path: DataRepo/loaders/study_loader.py
user: login: hepcat72
body: C. Each loader has its own summarized exception.  I renamed the original `UnexpectedLabels` to `UnexpectedLabel` and then added `UnexpectedLabels` as a summarization exception.  But the `StudyLoader` is different.  It summarizes exceptions across loaders, so that summarization exception class is `AllUnexpectedLabels`.  This member (`self.unexpected_labels_exceptions`) keeps track of the original `UnexpectedLabel` exceptions to re-collate them into a single `AllUnexpectedLabels` exception reported by input file.
created_at: 2025-05-04T23:31:57Z
author_association: COLLABORATOR
diff_hunk: @@ -1853,6 +1853,7 @@ def get_exception_type(
         modify=True,
         attr_name=None,
         attr_val=None,
+        is_error=None,
path: DataRepo/utils/exceptions.py
user: login: hepcat72
body: C. Each exception added to an `AggregatedErrors` object is given an `is_error` attribute.  This argument allows us to require that contained exceptions only match if that attribute is either true or false.
created_at: 2025-05-04T23:34:16Z
author_association: COLLABORATOR

PR comments:
user: login: hepcat72
created_at: 2025-05-02T23:32:49Z
body: Note, I got everything to run without error using these 2 commits:

tracebase: 228f7b1f8776566eab3442f4fa9ae48f896d1731
rabinowitz: bdbe57479cfddbe9263071fe9b7111592a8e7bda

But I decided to try and fix it better and things are broken again after fleshing the mzXML column and removing some skip rows from the peak annotation details sheet.  The idea was that I wanted to not have added rows for mzXML files that should go with rows above.  Now there are errors coming from the peak annotations loader about missing samples.  Specifically, it's 6 missing samples that have unanalyzed mzXML files.  Only the neg versions are analyzed.  I thought there were more this way than just these 6 samples, so I haven't figured out what's going on.

It might be overall better to go back to the drawing board and not create RecordDoesNotExist *warnings* when I find a matching sample using `endswith` when the file name starts with a number.
author_association: COLLABORATOR
user: login: hepcat72
created_at: 2025-05-02T23:41:10Z
body: Oh my gosh, wait! That last fix worked! I have no errors again.  I know I just described how it was broken, but I had just added the 6 non-neg files as skip rows and reran, and now there are no errors again.

So the idea behind this fix was that if I could add the mzXML files and I still didn't have any errors, I could return those `RecordDoesNotExist` warnings back into only employing them as errors and create a separate warning for the `endswith` case, when there is a match.  That would make the code cleaner.

So it looks like I can now do that.  I also need to do it for the blanks.  It is described in the last 2 paragraphs of [this comment](https://github.com/Princeton-LSI-ResearchComputing/tracebase/issues/1527#issuecomment-2848174831) on the linked issue.
author_association: COLLABORATOR
user: login: hepcat72
created_at: 2025-05-04T23:38:10Z
body: Going to test this tomorrow on the Acute Stress study.  It worked on the code before I addressed the tests, so it should still work, but just to be on the safe side...
author_association: COLLABORATOR