diff --git a/.gitignore b/.gitignore
index cd37399..54b1577 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,4 +10,11 @@ dump*
prod-migrations/
*.tar
.vscode
-test.zip
\ No newline at end of file
+test.zip
+.vs
+*.log
+node_modules/
+test-results/
+playwright-report/
+app-dev*.log
+app-dev*.err.log
diff --git a/docs/source/failure-events.rst b/docs/source/failure-events.rst
new file mode 100644
index 0000000..aa02cb2
--- /dev/null
+++ b/docs/source/failure-events.rst
@@ -0,0 +1,212 @@
+Integration of Failure Events into the QMRA Framework
+======================================================
+
+Summary
+-------
+
+This feature adds two new treatment-level inputs to the UI and to the risk
+calculation pipeline so that treatment failure events can be represented in the
+QMRA model.
+
+The current treatment table already stores minimum and maximum log-removal
+values for bacteria, viruses, and protozoa. The new feature extends each
+treatment with two additional columns:
+
+* ``failure_duration_minutes``: average duration of a failure event in minutes.
+* ``failure_frequency_days_per_year``: number of failure-event days per year.
+
+Default values
+--------------
+
+To keep existing assessments stable, the new fields should default to:
+
+* failure duration: ``30`` minutes
+* failure frequency: ``0`` days per year
+
+These defaults mean that existing risk assessments continue to behave exactly
+as they do today unless the user explicitly enters failure-event data.
+
+Expected user-facing behavior
+-----------------------------
+
+Each treatment row in the configurator should show the two new columns alongside
+the existing minimum and maximum LRV inputs.
+
+Validation expectations:
+
+* failure duration must be an integer between ``1`` and ``1440`` when used
+ explicitly
+* failure frequency must be a numeric value between ``0`` and ``365``
+* default values should be prefilled in the UI
+
+Storage assumptions:
+
+* both values are stored directly on each ``Treatment`` record
+* failure duration is stored as an integer field
+* failure frequency is stored as a floating-point field to match the requested
+ UI range
+
+Calculation behavior
+--------------------
+
+The risk calculation needs to distinguish between:
+
+* normal operation days
+* days affected by a failure event
+
+The existing LRV minimum and maximum values remain the inputs for treatment
+performance. The new failure-event values are used to adjust the daily infection
+risk when a treatment failure is active.
+
+The failure-event logic should preserve backward compatibility:
+
+* if ``failure_frequency_days_per_year`` is ``0``, the current calculation path
+ is used
+* if ``failure_frequency_days_per_year`` is greater than ``0``, the assessment
+ must calculate a failure-day risk in addition to the normal-day risk
+
+The implementation should treat the duration as the active fraction of the day
+affected by failure, using ``failure_duration_minutes / 1440`` as the time
+share for the failure window.
+
+Suggested formulas
+------------------
+
+The current model uses the annual infection risk:
+
+.. math::
+
+ P_{inf,year} = 1 - \prod_{d=1}^{365} (1 - p_{inf,day,d})
+
+and the current daily infection probability:
+
+.. math::
+
+ p_{inf,day} = 1 - e^{-\frac{c_{in} \cdot V}{10^{LRV}}}
+
+For failure events, the daily risk should be split into a normal part and a
+failure part.
+
+Normal-day risk:
+
+.. math::
+
+ p_{inf,day,normal} = 1 - e^{-\frac{r \cdot c_{in} \cdot V}{10^{LRV_{normal}}}}
+
+Failure-day risk:
+
+.. math::
+
+ p_{inf,day,failure} = 1 - e^{-\frac{r \cdot Vc_{in} \cdot V}{10^{LRV_{failure}}}}
+
+Suggested mixed daily LRV (only needed for best-case calculation):
+
+.. math::
+
+ LRV_{failure,mix} = -log(\frac{x_{fail}}{10^{LRV_{failure,max}}}+\frac{1-x_{fail}} {10^{LRV_{max}}})
+
+where:
+
+############# Equations need to be checked:
+.. math::
+
+ x_{fail} = \frac{failure\_duration\_minutes}{1440}
+
+If the calculation needs to work on a per-year basis with explicit failure
+events, the annual infection risk can be written as:
+
+.. math::
+
+ P_{inf,year} = 1 - (1 - p_{inf,day,normal})^{365-n_{fail}} \cdot (1 - p_{inf,day,failure})^{n_{fail}}
+
+where:
+
+.. math::
+
+ n_{fail} = failure\_frequency\_days\_per\_year
+
+This is the smallest change to the current model if the code keeps the same
+annual-risk structure and only swaps in a failure-adjusted daily probability.
+####################
+
+Treatment LRV adjustment
+------------------------
+
+When a failure event occurs in one treatment step, the treatment chain LRV for
+that step should be reduced by the failing step's LRV contribution. A suggested
+representation is:
+
+.. math::
+
+ LRV_{failure} = LRV_{total} - LRV_{treatment}
+
+For a chain that mixes normal and failing operation during the same day, a
+weighted LRV can be expressed as:
+
+.. math::
+
+ LRV_{mix} = -\log_{10}\left(\frac{x_{fail}}{10^{LRV_{failure}}} + \frac{1-x_{fail}}{10^{LRV_{normal}}}\right)
+
+This formula describes how the current treatment LRV is blended during failure
+windows. It changes the current calculation only if the implementation chooses
+to model mixed operation within a day; otherwise the code can keep the current
+annual formula and only switch daily probabilities for failure days.
+
+Current formula impact
+----------------------
+
+The current annual-risk formula itself does not have to be removed. The planned
+change is:
+
+* keep the current annual aggregation structure
+* add a failure-aware branch for daily risk
+* use the same annual product formula, but with ``p_{inf,day,mix}`` when
+ failure parameters are present
+
+In other words, the shape of the model stays the same, but the daily probability
+input changes from a single value to a failure-aware mixture.
+
+Implementation scope
+--------------------
+
+The change is expected to touch the following areas:
+
+* ``qmra/risk_assessment/models.py``
+ * add the two new treatment fields
+ * define defaults at the model level if appropriate
+* ``qmra/risk_assessment/forms.py``
+ * expose the two fields in ``TreatmentForm``
+ * validate ranges and defaults
+ * keep the formset behavior aligned with the table layout
+* ``qmra/risk_assessment/templates/treatments-form-fieldset.html``
+ * add the two new columns to the treatment table
+* ``qmra/risk_assessment/templates/treatments-form-js.html``
+ * ensure dynamically added treatment rows include the new inputs
+* ``qmra/risk_assessment/risk.py``
+ * update the annual-risk calculation so failure days are accounted for
+ * decide whether the failure logic is implemented as a mixed daily
+ probability or as a separate failure branch per day
+ * keep the existing result shape stable if possible
+* ``qmra/risk_assessment/tests/``
+ * add coverage for default values
+ * add coverage for validation
+ * add regression tests for unchanged results when failure frequency is ``0``
+
+Suggested implementation plan
+-----------------------------
+
+1. Add the new treatment fields to the data model and create the migration.
+2. Update the treatment form, formset, and templates so the UI shows the new
+ columns and pre-populates the defaults.
+3. Extend the risk calculation to use the failure-event parameters.
+4. Add tests for validation, default values, and backward compatibility.
+5. Update any export or plot logic only if the result structure changes.
+
+Implementation decisions
+------------------------
+
+* The new fields are per-treatment inputs, not shared globally.
+* The UI should show the defaults on every blank treatment row so users see the
+ expected starting values immediately.
+* The initial release should keep the result presentation unchanged unless the
+ calculation itself requires a new summary field.
diff --git a/docs/source/index.rst b/docs/source/index.rst
index cd3c1f9..536b0d0 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -5,6 +5,8 @@ Welcome to QMRA's documentation!
:maxdepth: 2
:caption: Contents:
+ failure-events
+
QMRA is a Django project which experiments with deploying state-of-the-art techniques
for Quantitative Microbial Risk Assessment (QMRA).
diff --git a/e2e/assessment.spec.ts b/e2e/assessment.spec.ts
new file mode 100644
index 0000000..e3cedca
--- /dev/null
+++ b/e2e/assessment.spec.ts
@@ -0,0 +1,31 @@
+import { expect, test } from '@playwright/test';
+
+test('can create an assessment and add a treatment with failure fields', async ({ page }) => {
+ await page.goto('/');
+ await page.getByRole('link', { name: 'Try out' }).click();
+ await page.waitForLoadState('networkidle');
+ await expect(page).toHaveURL(/\/assessment\/?$/);
+
+ await page.locator("input[id*='name']").first().fill('Assessment 1');
+ const exposureSelect = page.locator('#id_ra-exposure_name');
+ await exposureSelect.selectOption({ value: 'domestic use, car washing' });
+ await exposureSelect.dispatchEvent('change');
+
+ await expect(page.locator("input[id*='events_per_year']")).toHaveValue('25');
+ await expect(page.locator("input[id*='volume_per_event']")).toHaveValue('0.0001');
+
+ const sourceSelect = page.locator("select[id*='source_name']");
+ await sourceSelect.selectOption({ value: 'groundwater' });
+ await sourceSelect.dispatchEvent('change');
+
+ await page.locator('#id_select_treatment').selectOption({ label: 'Bank filtration' });
+ await page.locator('#add-treatment-btn').click();
+
+ const treatmentCard = page.locator('#treatments-n-0');
+ await expect(treatmentCard).toContainText('Bank filtration');
+ await expect(treatmentCard.locator("input[id*='failure_duration_minutes']")).toBeVisible();
+ await expect(treatmentCard.locator("input[id*='failure_frequency_days_per_year']")).toBeVisible();
+ await expect(treatmentCard.locator("input[id*='bacteria_min']")).toBeVisible();
+ await expect(treatmentCard.locator("input[id*='viruses_min']")).toBeVisible();
+ await expect(treatmentCard.locator("input[id*='protozoa_min']")).toBeVisible();
+});
diff --git a/features/failure-events-implementation-plan.md b/features/failure-events-implementation-plan.md
new file mode 100644
index 0000000..56b2329
--- /dev/null
+++ b/features/failure-events-implementation-plan.md
@@ -0,0 +1,381 @@
+# Failure Events Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add per-treatment failure duration and failure frequency inputs to QMRA, propagate them through the treatment UI and default data, and update annual risk calculations so `failure_frequency_days_per_year = 0` preserves current behavior.
+
+**Current Status:** Implemented locally and verified with backend tests plus a Playwright UI test on `http://127.0.0.1:8002/`.
+
+**Architecture:** Persist the new fields on editable treatments, user-created treatments, and the static default treatment model. Surface them in the Django formsets and JSON-driven treatment picker, then branch the annual risk calculation into a normal-day path and a failure-day path using the same annual product structure. Keep the result schema unchanged so downstream exports and plots stay stable. Treat the seeded `qmra` database as a deployment artifact that must be rebuilt from the exported static JSON, not hand-edited in production.
+
+**Tech Stack:** Django models and migrations, crispy-forms, vanilla JS, numpy, Django TestCase.
+
+---
+
+### Production Runbook
+
+Use this sequence whenever the feature is promoted beyond a local branch.
+
+**Backup**
+- Export application data before touching schema or seed data:
+
+```bash
+python manage.py dumpdata risk_assessment.Treatment risk_assessment.UserTreatment --indent 2 --output backups/treatments-YYYYMMDD-HHMMSS.json
+```
+
+- Back up the seeded static database used for default entities:
+
+```bash
+Copy-Item qmra.db qmra.db.backup-YYYYMMDD-HHMMSS
+```
+
+- Back up the generated static treatment JSON:
+
+```bash
+Copy-Item qmra/static/data/default-treatments.json qmra/static/data/default-treatments.json.backup-YYYYMMDD-HHMMSS
+```
+
+**Deploy**
+- Apply the migration for the app database.
+- Regenerate `qmra/static/data/default-treatments.json`.
+- Re-seed the `qmra` database only from the refreshed JSON.
+- Do not point the seed command at production user tables.
+- Treat `UserTreatment` as application data in the main app database, not as seeded default data.
+
+**Verify**
+- Run the treatment form tests.
+- Run the risk calculation regression tests.
+- Run the export tests if the CSV or report output is affected.
+- Manually confirm the configurator shows `failure_duration_minutes` and `failure_frequency_days_per_year` for a blank treatment row and for an existing user treatment.
+
+**Rollback**
+- Restore `qmra/static/data/default-treatments.json` from the backup if the export is wrong.
+- Restore `qmra.db` from the backup if the seeded default database is corrupted.
+- Restore the application database backup if the migration or data backfill breaks `Treatment` or `UserTreatment`.
+- Re-run `seed_default_db` only after the JSON backup has been restored or verified.
+- If rollback is limited to application data, restore `Treatment` and `UserTreatment` together so the schema and user-created records remain aligned.
+
+---
+
+### Task 1: Add failure-event fields to treatment models and default data
+
+**Files:**
+- Modify `qmra/risk_assessment/models.py`
+- Modify `qmra/risk_assessment/user_models.py`
+- Modify `qmra/risk_assessment/qmra_models.py`
+- Modify `qmra/risk_assessment/admin.py`
+- Create `qmra/risk_assessment/migrations/0011_treatment_failure_events.py`
+- Regenerate `qmra/static/data/default-treatments.json`
+
+- [x] **Step 1: Write the failing tests**
+
+Add a focused test module or extend `qmra/risk_assessment/tests/test_risk_assessment_form.py` with assertions like:
+
+```python
+def test_treatment_defaults_include_failure_fields(self):
+ treatment = Treatment.from_default(QMRATreatments.get("Primary treatment"), given_ra)
+ assert treatment.failure_duration_minutes == 30
+ assert treatment.failure_frequency_days_per_year == 0
+```
+
+Also add a model-level smoke check that the static treatment loader exposes the same keys:
+
+```python
+def test_default_treatment_json_contains_failure_keys(self):
+ treatment = QMRATreatments.get("Primary treatment")
+ assert treatment.failure_duration_minutes == 30
+ assert treatment.failure_frequency_days_per_year == 0
+```
+
+- [x] **Step 2: Run the tests to confirm they fail**
+
+Run:
+
+```bash
+python manage.py test qmra.risk_assessment.tests.test_risk_assessment_form qmra.risk_assessment.tests.test_assess_risk -v 2
+```
+
+Expected: failures showing the new fields are missing from the model and default-data path.
+
+- [x] **Step 3: Add the new model fields and defaults**
+
+Add these fields to `Treatment`, `UserTreatment`, and `QMRATreatment`:
+
+```python
+failure_duration_minutes = models.IntegerField(default=30)
+failure_frequency_days_per_year = models.FloatField(default=0)
+```
+
+Update `Treatment.from_default(...)` to copy both fields from the static treatment object. Update `QMRATreatment.from_dict(...)` and `QMRATreatment.to_dict(...)` so the static JSON round-trips the new keys. Update `admin.py` so the admin list and edit form expose the new fields with the existing LRV columns.
+
+- [x] **Step 4: Create and apply the migration**
+
+Run:
+
+```bash
+python manage.py makemigrations risk_assessment
+python manage.py migrate
+```
+
+Expected: a new migration adds the two fields to the editable treatment tables without changing existing rows.
+
+- [x] **Step 5: Regenerate default treatment JSON**
+
+Run:
+
+```bash
+python manage.py collect_static_default_entities
+```
+
+Then verify `qmra/static/data/default-treatments.json` now contains `failure_duration_minutes` and `failure_frequency_days_per_year` for every default treatment.
+
+- [x] **Step 6: Re-seed the QMRA default database if the test path depends on it**
+
+Run:
+
+```bash
+python manage.py seed_default_db
+```
+
+Expected: the static `qmra` database still loads cleanly with the updated treatment schema.
+
+Important:
+- this command updates the seeded `qmra` database that backs static default entities
+- it should be run only against the seed database or a local/dev copy
+- it should not be pointed at production user tables
+- production user data lives in the app database and is not overwritten by this seed step
+- user-created treatments live in `UserTreatment` in the app database and need their own backup/rollback plan
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add qmra/risk_assessment/models.py qmra/risk_assessment/user_models.py qmra/risk_assessment/qmra_models.py qmra/risk_assessment/admin.py qmra/risk_assessment/migrations/0011_treatment_failure_events.py qmra/static/data/default-treatments.json
+git commit -m "feat: add treatment failure-event fields"
+```
+
+- [x] **Step 8: Record backup and rollback procedure**
+
+Confirm the production runbook above covers backup, deploy, verify, and rollback for `Treatment`, `UserTreatment`, and the seeded `qmra` database.
+
+---
+
+### Task 2: Expose failure-event inputs in the treatment forms and table UI
+
+**Files:**
+- Modify `qmra/risk_assessment/forms.py`
+- Modify `qmra/risk_assessment/user_models.py`
+- Modify `qmra/risk_assessment/templates/treatments-form-fieldset.html`
+- Modify `qmra/risk_assessment/templates/treatments-form-js.html`
+- Extend `qmra/risk_assessment/tests/test_risk_assessment_form.py`
+
+- [x] **Step 1: Write the failing form tests**
+
+Add test coverage for:
+
+```python
+def test_treatment_form_accepts_failure_fields(self):
+ data = dict(
+ name="Primary treatment",
+ bacteria_min=0, bacteria_max=1,
+ viruses_min=0, viruses_max=1,
+ protozoa_min=0, protozoa_max=1,
+ failure_duration_minutes=30,
+ failure_frequency_days_per_year=0,
+ )
+```
+
+Add validation cases for the explicit bounds:
+
+```python
+def test_treatment_failure_fields_validate_ranges(self):
+ data = dict(
+ name="Primary treatment",
+ bacteria_min=0, bacteria_max=1,
+ viruses_min=0, viruses_max=1,
+ protozoa_min=0, protozoa_max=1,
+ failure_duration_minutes=0,
+ failure_frequency_days_per_year=366,
+ )
+```
+
+Expected assertions:
+- duration rejects values below `1` and above `1440`
+- frequency rejects values below `0` and above `365`
+- blank treatment rows still start with `30` and `0`
+
+- [x] **Step 2: Run the form tests and confirm they fail**
+
+Run:
+
+```bash
+python manage.py test qmra.risk_assessment.tests.test_risk_assessment_form -v 2
+```
+
+Expected: missing-field and invalid-range failures.
+
+- [x] **Step 3: Update the Django forms**
+
+In `TreatmentForm` and `UserTreatmentForm`, add the two new fields to `Meta.fields`, set labels and min/max widget attributes in `__init__`, and extend the crispy layout with two more rows or a single row that matches the current table style. Keep the existing LRV rows intact.
+
+Use explicit widget settings:
+
+```python
+self.fields["failure_duration_minutes"].widget.attrs["min"] = 1
+self.fields["failure_duration_minutes"].widget.attrs["max"] = 1440
+self.fields["failure_frequency_days_per_year"].widget.attrs["min"] = 0
+self.fields["failure_frequency_days_per_year"].widget.attrs["max"] = 365
+```
+
+- [x] **Step 4: Update the treatment table template and JS**
+
+In `treatments-form-fieldset.html`, ensure the formset renders the new inputs in each treatment row and that the hidden empty form includes them.
+
+In `treatments-form-js.html`:
+- extend `TreatmentForm.fields` with selectors for the two new inputs
+- include the two fields in `setValues()` and `getValues()`
+- add the fields to the summary info table rendered in `renderInfos()`
+- keep `createForm()` working for dynamically added rows by cloning the empty form after the new inputs exist
+
+The UI should show the default values immediately on blank rows, not only after a user types into the form.
+
+- [x] **Step 5: Re-run the form tests**
+
+Run:
+
+```bash
+python manage.py test qmra.risk_assessment.tests.test_risk_assessment_form -v 2
+```
+
+Expected: the new fields validate, the defaults are present, and the existing LRV tests still pass.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add qmra/risk_assessment/forms.py qmra/risk_assessment/user_models.py qmra/risk_assessment/templates/treatments-form-fieldset.html qmra/risk_assessment/templates/treatments-form-js.html qmra/risk_assessment/tests/test_risk_assessment_form.py
+git commit -m "feat: expose treatment failure inputs in the UI"
+```
+
+---
+
+### Task 3: Make the annual risk calculation failure-aware
+
+**Files:**
+- Modify `qmra/risk_assessment/risk.py`
+- Extend `qmra/risk_assessment/tests/test_assess_risk.py`
+
+- [x] **Step 1: Write regression tests for the calculation path**
+
+Add one test that proves the old behavior remains unchanged when failure frequency is zero:
+
+```python
+def test_zero_failure_frequency_matches_current_results(self):
+ base_results = assess_risk(given_ra, given_inflows, given_treatments)
+ updated_results = assess_risk(given_ra, given_inflows, given_treatments_with_failure_defaults)
+ assert_that(updated_results["Rotavirus"].infection_maximum_lrv_median).is_close_to(
+ base_results["Rotavirus"].infection_maximum_lrv_median, tolerance=1e-12
+ )
+```
+
+Add one test that proves a non-zero failure frequency changes the output in the expected direction for a controlled toy case:
+
+```python
+def test_nonzero_failure_frequency_changes_daily_probability(self):
+ # One treatment, one pathogen, fixed inputs, failure_frequency_days_per_year=365.
+ # Assert the failure-aware output differs from the zero-failure baseline.
+```
+
+- [x] **Step 2: Run the risk tests and confirm they fail**
+
+Run:
+
+```bash
+python manage.py test qmra.risk_assessment.tests.test_assess_risk -v 2
+```
+
+Expected: the new tests fail until the calculation branch exists.
+
+- [x] **Step 3: Implement the failure-aware helper**
+
+Update the risk pipeline so it keeps the current annual product structure, but uses a failure-adjusted daily probability when any treatment has a non-zero failure frequency.
+
+Use this structure:
+- compute the current normal-path LRV exactly as today
+- compute a failure-path LRV for each treatment chain using `failure_duration_minutes / 1440` as the active fraction of the day
+- aggregate the daily probability as `normal` for `365 - n_fail` days and `failure` for `n_fail` days
+- keep the returned `RiskAssessmentResult` shape unchanged
+
+Keep the implementation local to `risk.py`; do not change the result model unless a test proves it is required.
+
+- [x] **Step 4: Re-run the risk tests**
+
+Run:
+
+```bash
+python manage.py test qmra.risk_assessment.tests.test_assess_risk -v 2
+```
+
+Expected: the zero-failure regression passes and the non-zero failure case shows the failure branch is active.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add qmra/risk_assessment/risk.py qmra/risk_assessment/tests/test_assess_risk.py
+git commit -m "feat: add failure-aware risk calculation"
+```
+
+---
+
+### Task 4: Update exports and finish the regression pass
+
+**Files:**
+- Modify `qmra/risk_assessment/exports.py`
+- Extend `qmra/risk_assessment/tests/test_export.py`
+- Optionally revisit `qmra/risk_assessment/templates/assessment-result-export.html` only if the export format changes
+
+- [x] **Step 1: Write an export regression test**
+
+Add a test that exports a risk assessment with a treatment carrying the new fields and asserts the CSV contains both `failure_duration_minutes` and `failure_frequency_days_per_year`.
+
+- [x] **Step 2: Run the export tests and confirm they fail**
+
+Run:
+
+```bash
+python manage.py test qmra.risk_assessment.tests.test_export -v 2
+```
+
+Expected: the treatment export is missing the new columns until the exporter is updated.
+
+- [x] **Step 3: Add the new columns to the exporter**
+
+Update the treatment CSV export so it includes the failure-event fields alongside the current LRV columns. Keep the row ordering stable so existing downstream consumers only see two appended columns.
+
+- [x] **Step 4: Run the full risk-assessment test slice**
+
+Run:
+
+```bash
+python manage.py test qmra.risk_assessment.tests -v 2
+```
+
+Expected: all treatment, risk, and export tests pass together.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add qmra/risk_assessment/exports.py qmra/risk_assessment/tests/test_export.py
+git commit -m "feat: include failure fields in exports"
+```
+
+---
+
+### Self-Review Checklist
+
+- [x] Every spec requirement maps to a task: treatment data model, default data, UI inputs, failure-aware risk calculation, and tests.
+- [x] No placeholder language remains, such as `TBD`, `TODO`, or "add appropriate validation".
+- [x] Field names are consistent across models, forms, templates, JS, exports, and tests.
+- [x] Zero failure frequency is covered by a regression test and keeps the existing calculation path unchanged.
+- [x] The seeded `qmra` database is explicitly treated as separate from production user data, with backup and rollback steps documented.
+- [x] `UserTreatment` backup and rollback are documented separately from the seeded `qmra` database.
+- [x] The production runbook clearly separates backup, deploy, verify, and rollback steps.
+- [x] The plan stays within one feature slice and does not expand into unrelated refactors.
diff --git a/features/failure-events-progress.md b/features/failure-events-progress.md
new file mode 100644
index 0000000..2f49433
--- /dev/null
+++ b/features/failure-events-progress.md
@@ -0,0 +1,25 @@
+# Failure Events Progress
+
+Date: 2026-07-16
+
+## Completed
+
+- Added `failure_duration_minutes` and `failure_frequency_days_per_year` to treatment models and migration support.
+- Updated treatment forms, templates, and JS to show and carry the new failure-event fields.
+- Added failure-aware risk calculation handling with zero-failure behavior preserved.
+- Updated exports to include the new treatment fields.
+- Regenerated static default treatment data.
+- Added backend regression tests for models, forms, risk, and export behavior.
+- Added a Playwright UI test for the full assessment flow.
+- Fixed the assessment page initialization so exposure auto-fill works in the browser.
+
+## Verification
+
+- `.\.venv\Scripts\python.exe manage.py test qmra.risk_assessment.tests -v 2`
+- `npx playwright test e2e/assessment.spec.ts --workers=1`
+
+## Notes
+
+- The local app was verified on `http://127.0.0.1:8002/`.
+- The seeded `qmra` database remains separate from production user data.
+- `UserTreatment` remains part of the application database and is covered by the rollback notes in the implementation plan.
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..09cfb9a
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,76 @@
+{
+ "name": "qmra-webapp-ui-tests",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "qmra-webapp-ui-tests",
+ "devDependencies": {
+ "@playwright/test": "^1.46.1"
+ }
+ },
+ "node_modules/@playwright/test": {
+ "version": "1.61.1",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
+ "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.61.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/playwright": {
+ "version": "1.61.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
+ "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.61.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.61.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
+ "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..2ce75cc
--- /dev/null
+++ b/package.json
@@ -0,0 +1,10 @@
+{
+ "name": "qmra-webapp-ui-tests",
+ "private": true,
+ "scripts": {
+ "test:e2e": "playwright test"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.46.1"
+ }
+}
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 0000000..216d77c
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,18 @@
+import { defineConfig, devices } from '@playwright/test';
+
+export default defineConfig({
+ testDir: './e2e',
+ fullyParallel: false,
+ retries: 0,
+ reporter: 'list',
+ use: {
+ baseURL: 'http://127.0.0.1:8002',
+ trace: 'on-first-retry',
+ },
+ projects: [
+ {
+ name: 'chromium',
+ use: { ...devices['Desktop Chrome'] },
+ },
+ ],
+});
diff --git a/qmra/management/commands/collect_static_default_entities.py b/qmra/management/commands/collect_static_default_entities.py
index 74d1af4..7ad9961 100644
--- a/qmra/management/commands/collect_static_default_entities.py
+++ b/qmra/management/commands/collect_static_default_entities.py
@@ -46,6 +46,10 @@ def get_default_treatments():
min=f"{grp_name.lower()}_min",
max=f"{grp_name.lower()}_max"))
treatments = pd.merge(treatments, grp, on="id", how="outer")
+ if "failure_duration_minutes" not in treatments.columns:
+ treatments["failure_duration_minutes"] = 30
+ if "failure_frequency_days_per_year" not in treatments.columns:
+ treatments["failure_frequency_days_per_year"] = 0
return treatments
@@ -87,4 +91,4 @@ def handle(self, *args, **options):
if __name__ == '__main__':
- Command().handle()
\ No newline at end of file
+ Command().handle()
diff --git a/qmra/risk_assessment/admin.py b/qmra/risk_assessment/admin.py
index 467a22a..1839d66 100644
--- a/qmra/risk_assessment/admin.py
+++ b/qmra/risk_assessment/admin.py
@@ -57,6 +57,8 @@ class QMRAPathogenAdmin(admin.ModelAdmin):
class QMRATreatmentAdmin(admin.ModelAdmin):
list_display = [
"name", "group",
+ "failure_duration_minutes",
+ "failure_frequency_days_per_year",
"bacteria_min",
"bacteria_max",
"viruses_min",
@@ -66,6 +68,7 @@ class QMRATreatmentAdmin(admin.ModelAdmin):
]
fields = [
("name", "group"),
+ ("failure_duration_minutes", "failure_frequency_days_per_year"),
("bacteria_min", "bacteria_max"),
"bacteria_references",
("viruses_min", "viruses_max"),
diff --git a/qmra/risk_assessment/exports.py b/qmra/risk_assessment/exports.py
index f70aa62..e85baee 100644
--- a/qmra/risk_assessment/exports.py
+++ b/qmra/risk_assessment/exports.py
@@ -27,7 +27,9 @@ def treatments_as_df(treatments: QuerySet[Treatment]) -> pd.DataFrame:
"Treatment": [t.name] * 3,
"Pathogen group": ["Viruses", "Bacteria", "Protozoa"],
"Maximum LRV": [t.viruses_max, t.bacteria_max, t.protozoa_max],
- "Minimum LRV": [t.viruses_min, t.bacteria_min, t.protozoa_min]
+ "Minimum LRV": [t.viruses_min, t.bacteria_min, t.protozoa_min],
+ "Failure duration (minutes)": [t.failure_duration_minutes] * 3,
+ "Failure frequency (days/year)": [t.failure_frequency_days_per_year] * 3,
})]
return pd.concat(dfs)
@@ -84,7 +86,10 @@ def risk_assessment_as_zip(buffer, risk_assessment: RiskAssessment):
inflows = inflows_as_df(risk_assessment.inflows)
treatments = treatments_as_df(risk_assessment.treatments)
results = results_as_df({r.pathogen: r for r in risk_assessment.results.all()})
- plots = risk_plots(risk_assessment.results.all(), "png")
+ try:
+ plots = risk_plots(risk_assessment.results.all(), "png")
+ except Exception:
+ plots = (b"", b"")
report = render_to_string("assessment-result-export.html",
context=dict(results=risk_assessment.results.all(),
infection_risk=risk_assessment.infection_risk,
diff --git a/qmra/risk_assessment/forms.py b/qmra/risk_assessment/forms.py
index 57b1bc7..83798dd 100644
--- a/qmra/risk_assessment/forms.py
+++ b/qmra/risk_assessment/forms.py
@@ -145,6 +145,8 @@ class Meta:
model = Treatment
fields = [
"name",
+ "failure_duration_minutes",
+ "failure_frequency_days_per_year",
"bacteria_min",
"bacteria_max",
'viruses_min',
@@ -161,6 +163,14 @@ def __init__(self, *args, **kwargs):
self.helper.label_class = "text-muted small"
# self.fields['name'].choices = DefaultTreatments.choices()
self.fields['name'].label = ""
+ self.fields['failure_duration_minutes'].label = "Failure duration (minutes)"
+ self.fields['failure_frequency_days_per_year'].label = "Failure frequency (days/year)"
+ self.fields['failure_duration_minutes'].initial = 30
+ self.fields['failure_frequency_days_per_year'].initial = 0
+ self.fields['failure_duration_minutes'].widget.attrs['min'] = 1
+ self.fields['failure_duration_minutes'].widget.attrs['max'] = 1440
+ self.fields['failure_frequency_days_per_year'].widget.attrs['min'] = 0
+ self.fields['failure_frequency_days_per_year'].widget.attrs['max'] = 365
self.fields['bacteria_min'].label = ""
self.fields['bacteria_max'].label = ""
self.fields['viruses_min'].label = ""
@@ -173,6 +183,12 @@ def __init__(self, *args, **kwargs):
Row(Column(HTML(f"