Design Decisions
Rationale, tradeoffs, and known gotchas for Microbiology.
Design Decisions
Separate Through-Table for RIS Breakpoints vs Molecular Mapping
Decision: The OrganismAntibiotics through-table (Microbiology Ranges) is separate from MolecularOrganismAntibiotics (Molecular Mapping). Both map organism ↔ antibiotic, but they carry different data and serve different workflows.
Rationale:
OrganismAntibioticscarries 12 float columns of RIS breakpoints (diameter and MIC for R, I, S) and has strict range validation. Its primary consumer is the microbiology susceptibility workflow.MolecularOrganismAntibioticscarries sequence order and an active flag. Its primary consumer is the molecular Antibiotic Resistance summary component. It has no RIS data.- Mixing both concerns into a single table would create nullable columns on both sides and make the validation logic significantly more complex.
- Two tables also allow independent cache invalidation: a molecular priority change does not need to invalidate RIS breakpoint data and vice versa.
Tradeoff: Labs using both workflows must set up the antibiotic relationship in two places (the Microbiology Ranges sub-tab and the Molecular Mapping sub-tab). The UI exposes both as sub-tabs inside the same Antibiotic Mapping tab to reduce navigation friction.
Bulk Replace Strategy for OrganismAntibiotics
Decision: When saving an organism, all existing OrganismAntibiotics records for that organism are deleted and re-created from the incoming payload (OrganismAntibiotics.bulk_create(...)).
Rationale:
- The microbiology ranges grid in the UI sends the complete intended mapping state, not a delta.
- A bulk-replace pattern avoids the complexity of tracking additions, deletions, and edits separately in the API layer.
- Since the organism's antibiotic mappings are always re-validated before creation, data consistency is maintained even on a full replace.
Tradeoff: If a save fails mid-operation, there is a risk of transient data loss. The transaction.atomic decorator on the POST view mitigates this by wrapping the full save in a single database transaction.
Interpretation Only Method Type Reduces Displayed Columns
Decision: When the method type is set to Interpretation Only, the result_1, result_r, result_i, and result_s columns are hidden from the microbiology antibiotic grid at both configuration time and report entry.
Rationale:
Interpretation Onlyis intended for scenarios where labs manually categorize susceptibility without entering a numeric result (e.g. direct S/I/R assignment from clinical assessment or external source).- Showing numeric input and RIS reference range columns in this mode would be visually misleading.
- The
updateMicroEditables(cols, type, isNewReport)function handles the hiding logic:- For
Interpretation Onlyon an existing report: hides result/RIS columns in-place. - For
Interpretation Onlyon a new report: reduces the column set to onlynameandresult_2(renamed toManual Interpretation) withINTERPRETATION_OPTIONSdropdown.
- For
Tradeoff: Labs that later switch from Interpretation Only to Detection Window or MIC must revisit column configuration as the hidden columns must be manually re-added.
max_allowed_microorganism Enforcement Is Frontend-Only at Report Entry
Decision: The maximum organism count check (capping the Add Organism action at report entry) is performed on the frontend using the max_allowed_microorganism value from the component's meta object.
Rationale:
- Report entry is a high-frequency, real-time UI interaction. Enforcing the cap via a round-trip API call per organism add would introduce unnecessary latency and server load.
- The
max_allowed_microorganismvalue is already loaded with the report format and is available in Redux state. - Backend validation for this constraint is not the bottleneck; what matters is the user-facing experience of being immediately informed when the cap is reached.
Tradeoff: A motivated user who manipulates client-side state could bypass the cap at the browser level. Backend validation on report submission could be added if this becomes a compliance requirement.
max_allowed_microorganism = 1 Disables the Pivot PDF Checkbox
Decision: When Max Number Of Micro Organisms is 1, the Render Pivot Table on PDF checkbox is automatically disabled and meta.should_pivot is set to false.
Rationale:
- A pivot table view is only meaningful when multiple organisms are expected; pivoting a single-organism antibiogram gives no added value.
- Disabling the checkbox prevents a misleading configuration that would produce an empty pivot with one column.
Organism Count is Computed from group_by_id Groups, Not Row Count
Decision: At report entry, the number of organisms currently added is computed as Object.keys(groupBy(value?.value, "group_by_id")).length (distinct organism groups), not as value?.value.length (total antibiotic rows).
Rationale:
- Each organism in the microbiology grid is represented by multiple rows — one per antibiotic added to that organism.
- If the count was computed using the raw row count, adding antibiotics to an organism would inadvertently consume the organism limit.
- Using the distinct
group_by_idcount correctly measures how many organisms are in the report section, regardless of how many antibiotics each organism has.
RIS Range Fields Are All Nullable But Validation Requires Consistency
Decision: All 12 RIS breakpoint columns (resistance_diameter_upper, sensitive_mic_lower, etc.) in OrganismAntibiotics are nullable at the database level, but the before_save(...) hook enforces that either all diameter fields or all MIC fields must be populated (not a mix).
Rationale:
- Nullable fields allow the through-table to be created initially empty before any breakpoints are assigned.
- Consistency enforcement is deferred to
before_save(...)rather than database constraints because the mix-validation rule (all diameters or all MICs) cannot be expressed cleanly as a database CHECK constraint across 12 columns. - The
has_valid_patternscheck ensures upper ≥ lower before persisting any range.
Error messages raised:
| Condition | Error message |
|---|---|
| Missing any R, I, or S diameter or MIC group | "Mandatory intermediate/resistance/sensitive diameter or mic values are absent!" |
| Mixing diameter and MIC fields | "Sensitivity pattern can either be diameters or mics!" |
| Upper < lower for any range | "Pattern ranges are not valid!" |
Duplicate Antibiotic Mapping Prevention Uses Python-Level Check
Decision: Duplicate antibiotic_id values in the antibiotic_mappings payload are rejected by checking len(set(antibiotic_ids)) != len(antibiotic_ids) in Organism.after_save(...).
Rationale:
- The
OrganismAntibioticstable has no database-level unique constraint on(organism_id, antibiotic_id). - Checking at the Python level allows a descriptive error to be returned before any DB operation begins.
Known gotcha: Because the table has no unique constraint, a race condition between two concurrent save requests could theoretically create duplicate mappings. This is mitigated by transaction.atomic on the POST view.
Caching Organism and Antibiotic Data for Report Entry Performance
Decision: The full organism catalog (including nested antibiotic_mappings with RIS breakpoints) is loaded once into Redux MODEL.allOrganisms and used for all downstream operations including antibiotic dropdown resolution and range display at report entry.
Rationale:
- Loading per-organism antibiotic lists on demand at report entry would require N+1 API calls.
- Antibiotic RIS breakpoints are relatively static master data; loading them upfront does not cause stale-data concerns in practice.
- The backend also caches these records via
ReportingCacheBaseto avoid DB round-trips per request.
Tradeoff: If an organism's RIS breakpoints are updated while a user has the report entry modal open, the user will see the old breakpoints until they reload the page or refetch the organism catalog. This is acceptable for master data that changes infrequently.
RIS Interpretation Precedence: Last-Matched Wins
Decision: The calculateValueForMicro function evaluates all three conditions (S, I, R) sequentially without else if. The last match overrides the previous one.
Current behavior:
if (...sensitive check...) result = t("Sensitive");
if (...intermediate check...) result = t("Intermediate");
if (...resistant check...) result = t("Resistant");Implication: If breakpoints are configured so that a value falls into multiple categories (overlapping ranges), the interpretation displayed will be Resistant (the last evaluated category). This is consistent with clinical convention where resistant interpretation takes the highest clinical priority.
Known gotcha: If ranges are non-overlapping and disjoint, a value that falls outside all three ranges returns an empty string for result_2. The UI will display no interpretation rather than an error.
Microbiology vs Molecular Parameter Separation
Decision: The Microbiology Parameter component (type: 20) is distinct from Molecular components (GENE: 23, ORGANISM: 22, ANTIBIOTIC_RESISTANCE: 24). Each has its own form, column options, and validation path.
Rationale:
- Microbiology susceptibility workflows (culture + MIC/disk diffusion) and Molecular workflows (PCR gene detection + antibiotic resistance pivots) have different data models, display requirements, and interpretation logic.
- Separating the components allows each to evolve independently. For example, the
Interpretation Onlymode in Microbiology has no equivalent in Molecular. - Validation for Microbiology (
component_type === MICROBIOLOGY && type === 20) is a separate block inreportParamterValidation(...)and coverslinked_model,method_type, andmax_allowed_microorganism.