diff --git a/docs/review-2026-10-dev-scenarios.md b/docs/review-2026-10-dev-scenarios.md
index 67b9bdb..601e5c4 100644
--- a/docs/review-2026-10-dev-scenarios.md
+++ b/docs/review-2026-10-dev-scenarios.md
@@ -135,4 +135,5 @@ Answers (2026-10-16):
| Ticket | Finding | Change | Branch |
|--------|---------|--------|--------|
-| | | | |
+| 14 (#36) Number of parallel units on a treatment step in the configurator | F2 (D7) | C1 | `c1-failure-inputs` |
+| 15 (#37) Personal treatment steps store the number of parallel units | F2 (D7) | C1 | `c1-failure-inputs` |
diff --git a/docs/specs/c1-failure-inputs.md b/docs/specs/c1-failure-inputs.md
index c8e50af..f609f0e 100644
--- a/docs/specs/c1-failure-inputs.md
+++ b/docs/specs/c1-failure-inputs.md
@@ -1,32 +1,47 @@
# Spec C1: Failure inputs
-Status: draft, 2026-09-24. Change C1 in `docs/failure_roadmap.md` (Large). Spec issue: #___
-Sources: `docs/vision.md` (MVP item 1 and 2), `CONTEXT.md`, ADR-0001, roadmap decisions D4
-and D5.
+Status: draft, 2026-09-24, revised 2026-10-16 for D7 (number of parallel units). Change C1
+in `docs/failure_roadmap.md` (Large). Spec issue: #___
+Sources: `docs/vision.md` (MVP item 1 and 2), `CONTEXT.md`, ADR-0001, ADR-0002, roadmap
+decisions D4, D5 and D7.
+
+The first version (failure frequency and failure duration) is built and was tested on the
+dev environment (tickets 04–06). This revision adds the third failure input, the number of
+parallel units, and keeps everything else. Requirements that are new in this revision are
+marked *(D7)*.
## Problem Statement
A utility operator or engineer who wants to know how failures of their treatment steps
-affect the yearly risk has nowhere to say how often a step fails and for how long. The
-assessment configurator only knows LRVs, and a personal treatment step only stores a name
-and LRVs. Before the calculation can include treatment failures (C2), users need a place to
-enter a failure frequency and a failure duration for each treatment step, and the
-application needs to keep those values with the scenario.
+affect the yearly risk has nowhere to say how often a step fails, for how long, and how much
+of the step stops when it fails. The assessment configurator only knew LRVs, and a personal
+treatment step only stored a name and LRVs. Before the calculation can include treatment
+failures (C2), users need a place to enter a failure frequency, a failure duration and a
+number of parallel units for each treatment step, and the application needs to keep those
+values with the scenario.
+
+In most plants the LRV of a step does not drop to 0 during a failure: a step often has
+several parallel units, and a failure event stops only one of them (D7, ADR-0002). Without
+the number of parallel units, every failure would be counted as a complete loss of the
+step's positive LRVs.
## Solution
-Every treatment step in the assessment configurator gets two more fields next to its LRVs:
-**failure frequency** (failure days per year) and **failure duration** (minutes). They
-start at 0 days per year and 30 minutes, so a step does not fail unless the user says so.
-The application checks the values when the assessment is run or saved and explains what is
-wrong. A step can only have a failure frequency above 0 if at least one of its LRVs is
-positive, since a failure removes only positive LRVs (D4).
+Every treatment step in the assessment configurator has three failure fields next to its
+LRVs: **failure frequency** (failure days per year), **failure duration** (minutes) and
+**number of parallel units** *(D7)*. They start at 0 days per year, 30 minutes and 1 unit,
+so a step does not fail unless the user says so, and a step with one unit loses all its
+positive LRVs when it fails, as before. The application checks the values when the
+assessment is run or saved and explains what is wrong. A step can only have a failure
+frequency above 0 if at least one of its LRVs is positive, since a failure removes only
+positive LRVs (D4).
-Registered users can also store both values on a personal treatment step. When they add
-that step to an assessment, its failure frequency and failure duration are copied in like
-its LRVs. Saved assessments keep the values, and reopening or editing an assessment shows
-them again. Assessments saved before this change reopen with failure frequency 0 and
-failure duration 30, so nothing about them changes.
+Registered users can also store the three values on a personal treatment step. When they
+add that step to an assessment, its failure inputs are copied in like its LRVs. Saved
+assessments keep the values, and reopening or editing an assessment shows them again.
+Assessments and personal treatment steps saved before a failure input existed read back its
+default: failure frequency 0, failure duration 30 and 1 parallel unit, so nothing about
+them changes.
In this Change the values do not yet affect the result. The calculation follows in C2, and
nothing reaches production before C1–C5 are released together (D5).
@@ -37,109 +52,142 @@ nothing reaches production before C1–C5 are released together (D5).
assessment, so that the assessment reflects how often each step fails at my plant.
2. As a utility operator, I want to enter a failure duration for each treatment step, so
that the assessment reflects how long a typical failure event lasts.
-3. As a user, I want the failure frequency to be given in failure days per year, so that I
+3. As a utility operator, I want to enter the number of parallel units of each treatment
+ step, so that a failure of one unit is not counted as a failure of the whole step.
+ *(D7)*
+4. As a user, I want the failure frequency to be given in failure days per year, so that I
can use the numbers from my plant's operating records directly.
-4. As a user, I want the failure duration to be given in minutes, so that short failure
+5. As a user, I want the failure duration to be given in minutes, so that short failure
events can be expressed without fractions.
-5. As a user, I want the unit of each failure field shown next to it, so that I do not
- enter hours instead of minutes or events instead of days.
-6. As a user, I want every treatment step to start with failure frequency 0, so that an
+6. As a user, I want the number of parallel units to be a plain count of units, so that I
+ can take it from the plant layout. *(D7)*
+7. As a user, I want the unit or meaning of each failure field shown in its label, so that I
+ do not enter hours instead of minutes, events instead of days, or a percentage instead of
+ a count of units.
+8. As a user, I want every treatment step to start with failure frequency 0, so that an
assessment without failure information behaves as before.
-7. As a user, I want every treatment step to start with failure duration 30 minutes, so
+9. As a user, I want every treatment step to start with failure duration 30 minutes, so
that I only change the duration when I know a better value.
-8. As a user, I want to enter a fractional failure frequency such as 0.5 days per year, so
- that I can describe a step that fails once every two years.
-9. As a user, I want a failure frequency below 0 or above 365 to be rejected with a clear
- message, so that I notice a typing mistake.
-10. As a user, I want a failure duration below 1 or above 1440 minutes, or a non-whole
+10. As a user, I want every treatment step to start with 1 parallel unit, so that a step I do
+ not describe further loses all its positive LRVs during a failure, as before. *(D7)*
+11. As a user, I want to enter a fractional failure frequency such as 0.5 days per year, so
+ that I can describe a step that fails once every two years.
+12. As a user, I want a failure frequency below 0 or above 365 to be rejected with a clear
+ message, so that I notice a typing mistake.
+13. As a user, I want a failure duration below 1 or above 1440 minutes, or a non-whole
number of minutes, to be rejected with a clear message, so that I notice a typing
mistake or a wrong unit.
-11. As a user, I want to be told when I give a failure frequency above 0 to a step that has
+14. As a user, I want a number of parallel units below 1, or one that is not a whole number,
+ to be rejected with a clear message, so that I notice a typing mistake. *(D7)*
+15. As a user of a large plant, I want to enter any number of parallel units, however large,
+ so that the application does not stop me from describing my plant. *(D7)*
+16. As a user, I want to be told when I give a failure frequency above 0 to a step that has
no positive LRV, so that I understand that such a step cannot fail.
-12. As a user modelling recontamination with a negative LRV, I want that step to keep
+17. As a user modelling recontamination with a negative LRV, I want that step to keep
failure frequency 0, so that recontamination is never treated as a failing step.
-13. As a user, I want a step with a positive LRV for one pathogen group and a zero or
+18. As a user, I want a step with a positive LRV for one pathogen group and a zero or
negative LRV for another to accept failure inputs, so that steps like chlorination
(protozoa minimum 0) or a storage tank with regrowth can fail.
-14. As a user, I want the failure fields to appear in the same card as the step's LRVs, so
+19. As a user, I want the number of parallel units shown for every treatment step, next to
+ failure frequency and failure duration, so that I see all failure inputs of a step
+ together and the form does not change shape while I type. *(D7)*
+20. As a user describing a non-technical measure such as a hygiene practice, I want the same
+ three failure fields, so that I can leave the number of units at 1 and the measure is
+ lost completely during a failure. *(D7)*
+21. As a user whose units fail together, for example through a shared power supply, I want
+ to enter 1 unit, so that such a failure is counted as a loss of the whole step. *(D7)*
+22. As a user, I want the failure fields to appear in the same card as the step's LRVs, so
that I see all values of a step together.
-15. As a user, I want the failure values of each step to stay with that step when I
- reorder or remove other steps, so that values do not move to the wrong step.
-16. As a user adding the same bundled step twice, I want to give each copy its own failure
- values, so that two parallel units can have different failure records.
-17. As a guest, I want to enter failure inputs and run an assessment, so that I can try the
- feature without an account.
-18. As a guest, I want invalid failure inputs to be rejected in the same way as for
+23. As a user, I want the failure values of each step to stay with that step when I reorder
+ or remove other steps, so that values do not move to the wrong step.
+24. As a user adding the same bundled step twice, I want to give each copy its own failure
+ values, so that I can describe two separate installations of the same technology.
+25. As a guest, I want to enter all three failure inputs and run an assessment, so that I
+ can try the feature without an account.
+26. As a guest, I want invalid failure inputs to be rejected in the same way as for
registered users, so that the rules do not depend on having an account.
-19. As a registered user, I want the failure values of each step saved with my assessment,
+27. As a registered user, I want the failure values of each step saved with my assessment,
so that I do not have to enter them again.
-20. As a registered user, I want to see the saved failure values when I reopen an
+28. As a registered user, I want to see the saved failure values when I reopen an
assessment, so that I can check what the assessment is based on.
-21. As a registered user, I want to change failure values when I edit an assessment and
+29. As a registered user, I want to change failure values when I edit an assessment and
have the new values saved, so that I can correct or update them.
-22. As a registered user, I want assessments I saved before this change to reopen with
- failure frequency 0 and failure duration 30, so that their results do not change.
-23. As a registered user, I want to store a failure frequency and failure duration on a
- personal treatment step, so that my plant's failure record is reused in every
- assessment that uses the step.
-24. As a registered user, I want the same checks on a personal treatment step as in the
+30. As a registered user, I want assessments I saved before a failure input existed to
+ reopen with its default (failure frequency 0, failure duration 30, 1 parallel unit), so
+ that their results do not change.
+31. As a registered user, I want to store all three failure inputs on a personal treatment
+ step, so that my plant's failure record is reused in every assessment that uses the
+ step.
+32. As a registered user, I want the same checks on a personal treatment step as in the
configurator, so that a personal step never carries values an assessment would reject.
-25. As a registered user, I want a personal treatment step's failure values copied into the
- assessment when I add the step, so that I do not type them again.
-26. As a registered user, I want to change the copied failure values in one assessment
+33. As a registered user, I want a personal treatment step's failure values, including its
+ number of parallel units, copied into the assessment when I add the step, so that I do
+ not type them again.
+34. As a registered user, I want to change the copied failure values in one assessment
without changing the personal treatment step, so that I can try other values in one
scenario.
-27. As a registered user, I want personal treatment steps I created before this change to
- offer failure frequency 0 and failure duration 30, so that they keep working.
-28. As a registered user, I want my personal treatment steps and their failure values to be
+35. As a registered user, I want personal treatment steps I created before a failure input
+ existed to offer its default, so that they keep working.
+36. As a registered user, I want my personal treatment steps and their failure values to be
visible only to me, so that my plant's failure record stays private.
-29. As a user comparing scenarios, I want two saved assessments to differ only in their
- failure inputs, so that I can later compare the effect of different failure
- frequencies.
-30. As a user, I want bundled treatment steps to come without failure values, so that I am
+37. As a user comparing scenarios, I want two saved assessments to differ only in their
+ failure inputs, for example only in the number of parallel units of one step, so that I
+ can later compare the effect of that input.
+38. As a user, I want bundled treatment steps to come without failure values, so that I am
not given literature failure data the application does not have.
-31. As a developer of C2, I want the failure frequency and failure duration of every step in
- an assessment to be available where the calculation reads the treatment train, so that
- C2 only has to change the calculation.
-32. As a developer of C3, I want the stored failure values to be readable from a saved
- assessment, so that C3 can add them to the export package and report.
+39. As a developer of C2, I want the failure frequency, failure duration and number of
+ parallel units of every step in an assessment to be available where the calculation
+ reads the treatment train, so that C2 only has to change the calculation.
+40. As a developer of C3, I want the stored failure values to be readable from a saved
+ assessment, so that C3 can add them to the export package.
## Implementation Decisions
-- **Two new attributes on two kinds of treatment step.** The treatment step of an
- assessment and the personal treatment step each get *failure frequency* (a decimal
- number of failure days per year) and *failure duration* (a whole number of minutes).
- Both are required, with defaults 0 and 30. A schema migration adds them to both, and
- existing rows receive the defaults.
+- **Three attributes on two kinds of treatment step.** The treatment step of an assessment
+ and the personal treatment step each carry *failure frequency* (a decimal number of
+ failure days per year), *failure duration* (a whole number of minutes) and *number of
+ parallel units* (a whole number) *(D7)*. All three are required, with defaults 0, 30 and
+ 1. Failure frequency and failure duration exist since tickets 04–06. A new schema
+ migration adds the number of parallel units to both kinds of step, and existing rows
+ receive 1.
- **Bundled treatment steps are unchanged.** They carry no failure data (failure inputs are
user input only). A bundled step added to an assessment starts at the defaults.
- **One validation rule, used by both forms.** The treatment step form of the configurator
- and the personal treatment step form apply the same rule, defined once:
+ and the personal treatment step form apply the same shared rule, which already checks
+ minimum ≤ maximum LRV and the first two failure inputs. It is extended, not duplicated:
- failure frequency from 0 to 365 inclusive, decimals allowed;
- failure duration a whole number from 1 to 1440 inclusive;
+ - number of parallel units a whole number of 1 or more, with no upper limit *(D7)*;
- failure frequency above 0 only if at least one of the step's six LRVs (minimum and
maximum for bacteria, viruses and protozoa) is above 0; an empty LRV counts as 0 (D4).
The error is attached to the failure frequency field.
- The duration range is checked even when the frequency is 0, so stored values are always
- valid. The existing minimum ≤ maximum LRV check, which both forms currently duplicate,
- may move into the same shared rule.
-- **Configurator.** Each treatment step card shows the two fields below its LRV rows,
- labelled with their units ("Failure frequency (days per year)", "Failure duration
- (minutes)"). The fields are always shown; the D4 rule is enforced by validation, since the
- user can change LRVs after entering a frequency. Guests see and use the same fields.
-- **Saving and reopening.** The treatment step formset saves the two values with each step,
- and reopening an assessment fills them in again. Each value belongs to its step, not to a
- position in the train.
-- **Personal treatment steps.** The personal treatment step form gets the two fields. The
- list of personal treatment steps that the configurator loads includes both values, and
- the configurator copies them into a newly added step together with its LRVs. The copy is
- independent: editing it in an assessment leaves the personal treatment step unchanged.
+ The ranges are checked even when the frequency is 0, so stored values are always valid.
+ The messages follow the existing pattern and name the field and its range, for example
+ "number of parallel units must be a whole number of 1 or more".
+- **Configurator.** Each treatment step card shows the three failure fields below its LRV
+ rows, labelled "Failure frequency (days per year)", "Failure duration (minutes)" and
+ "Number of parallel units". The fields are always shown, also when the failure frequency
+ is 0; the D4 rule is enforced by validation, since the user can change LRVs after
+ entering a frequency. Guests see and use the same fields. The labels say nothing more;
+ what a parallel unit is, that the failure duration affects both best-case and
+ worst-case, and that units failing together are entered as 1 unit are explained in C4.
+- **Saving and reopening.** The treatment step formset saves the three values with each
+ step, and reopening an assessment fills them in again. Each value belongs to its step,
+ not to a position in the train.
+- **Personal treatment steps.** The personal treatment step form gets the number of
+ parallel units next to the two existing failure fields. The list of personal treatment
+ steps that the configurator loads includes all three values, and the configurator copies
+ them into a newly added step together with its LRVs. The copy is independent: editing it
+ in an assessment leaves the personal treatment step unchanged.
- **Guest path.** The guest result submits the same treatment step formset. Failure values
are validated there too and are not stored.
- **No effect on results.** The calculation, the result page and the export package are not
- changed in C1. The values are stored so that C2 and C3 can use them.
-- **Delivery.** C1 stays an open pull request and is tested on the dev environment; it is
- not merged into `main` before the release (D5). C2 and C3 build on it.
+ changed in C1. The values are stored so that C2 and C3 can use them; C2 reads the number
+ of parallel units to calculate the failure LRV (ADR-0002).
+- **Delivery.** The number of parallel units is added in new tickets on the existing C1 pull
+ request (roadmap decision "Rework of C1–C3"). C1 stays an open pull request and is tested
+ on the dev environment again; it is not merged into `main` before the release (D5). C2
+ and C3 build on it.
## Testing Decisions
@@ -150,71 +198,94 @@ nothing reaches production before C1–C5 are released together (D5).
- **One seam: HTTP through the Django test client.** Tests post the configurator form (save
and guest result) and the personal treatment step form, then read the saved assessment,
the reopened configurator or the personal treatment step list. This covers form
- validation, saving, reopening and ownership without a separate form-level seam.
-- **Behaviour covered at that seam:**
- - saving an assessment with failure inputs and reopening it returns the same values;
- editing and saving again stores the new values;
+ validation, saving, reopening and ownership without a separate form-level seam. The
+ number of parallel units is tested at the same seam as the other two failure inputs.
+- **Behaviour covered at that seam** (cases new in this revision marked *(D7)*):
+ - a new step card, the guest form and the personal treatment step form show all three
+ failure fields with their labels and defaults 0, 30 and 1 *(D7: the third field)*;
+ - saving an assessment with failure inputs and reopening it returns the same values,
+ including the number of parallel units; editing and saving again stores the new values,
+ per step *(D7)*;
- out-of-range frequency or duration, a non-whole duration, and a frequency above 0 on a
step without a positive LRV are rejected with a message, on the saved configurator, on
the guest result and on the personal treatment step form;
+ - a number of parallel units of 0, a negative number or a fraction such as 1.5 is rejected
+ with a message on all three forms; 1, 2 and a large value such as 1000 are accepted
+ *(D7)*;
- a step whose only positive LRV is one maximum (for example protozoa 0–2) accepts a
frequency above 0; a step with only negative or empty LRVs does not;
- a guest result with valid failure inputs is accepted;
- - a personal treatment step saved with failure inputs appears with them in its owner's
- list; another user's list does not contain it;
- - an assessment and a personal treatment step created without failure values (as before
- the migration) come back with frequency 0 and duration 30;
- - the result of an assessment is identical with and without failure inputs (C1 does not
- change the calculation).
-- **Prior art.** The form tests in the risk-assessment test module (min ≤ max, negative LRVs
- allowed) show the validation cases to extend; the calculation regression test shows how
- fixed results are compared. There are no HTTP-level tests yet; C1 adds the first ones,
- using Django's test client and a logged-in test user.
+ - a personal treatment step saved with failure inputs appears with all three in its
+ owner's list; another user's list does not contain it *(D7: the third value)*;
+ - an assessment and a personal treatment step created without the number of parallel
+ units (as before its migration) come back with 1 *(D7)*, and without any failure values
+ with frequency 0, duration 30 and 1 unit;
+ - the result and the export package of an assessment are identical with and without
+ failure inputs, including a number of parallel units above 1 (C1 does not change the
+ calculation) *(D7)*.
+- **Prior art.** The C1 HTTP tests in the risk-assessment test package (failure inputs on
+ the configurator, guest result and personal treatment steps, with a logged-in test user
+ and a parser for the configurator form) are extended with the cases above. The form tests
+ in the same package show the minimum ≤ maximum LRV cases; the calculation regression test
+ shows how fixed results are compared.
- **Manual check on the dev environment.** Copying a personal treatment step's failure
values into the configurator happens in the browser and is not covered by the HTTP seam.
- Before C1 is done, a tester on the dev environment creates a personal treatment step with
- failure inputs, adds it to an assessment, sees the copied values, changes them, saves,
- reopens, and confirms the personal treatment step itself is unchanged.
+ Before C1 is done again, a tester on the dev environment creates a personal treatment step
+ with all three failure inputs (including a number of parallel units above 1), adds it to
+ an assessment, sees the copied values, changes them, saves, reopens, and confirms the
+ personal treatment step itself is unchanged.
- The existing test suite passes unchanged.
## Acceptance criteria
1. Each treatment step in the configurator, for guests and registered users, shows failure
- frequency and failure duration with their units, starting at 0 and 30.
+ frequency, failure duration and number of parallel units with their labels, starting at
+ 0, 30 and 1.
2. The validation rules above reject invalid values with a message on the configurator,
the guest result and the personal treatment step form, and accept every valid
- combination, including the D4 cases.
-3. Failure values survive save, reopen and edit of an assessment, per step.
-4. Personal treatment steps store failure values, the configurator copies them into an
- added step, and the copy can be changed independently (manual check on the dev
+ combination, including the D4 cases and any whole number of parallel units of 1 or more.
+3. All three failure values survive save, reopen and edit of an assessment, per step.
+4. Personal treatment steps store all three failure values, the configurator copies them
+ into an added step, and the copy can be changed independently (manual check on the dev
environment).
-5. Assessments and personal treatment steps that existed before C1 read back failure
- frequency 0 and failure duration 30.
-6. Results and export packages are the same as before C1 for every assessment.
+5. Assessments and personal treatment steps that existed before a failure input read back
+ its default: failure frequency 0, failure duration 30, 1 parallel unit.
+6. Results and export packages are the same as before C1 for every assessment, whatever its
+ failure inputs.
7. All HTTP-level tests listed above pass, and the existing suite passes unchanged.
Quality goals from `docs/vision.md`, one criterion each:
| Quality goal | Criterion in C1 |
|---|---|
-| Input validity | Every failure input shows its unit, and a value outside 0–365 days per year or 1–1440 whole minutes, or a frequency above 0 on a step without a positive LRV, is rejected with a message (criteria 1–2). |
-| Reproducibility | Reopening a saved assessment returns exactly the stored failure values, and assessments from before C1 keep their results, since they read back frequency 0 (criteria 3, 5, 6). |
+| Input validity | Every failure input shows its unit or meaning in its label; a value outside 0–365 days per year or 1–1440 whole minutes, a number of parallel units that is not a whole number of 1 or more, or a frequency above 0 on a step without a positive LRV is rejected with a message (criteria 1–2). |
+| Reproducibility | Reopening a saved assessment returns exactly the stored failure values, and assessments from before C1 or before the number of parallel units keep their results, since they read back the defaults (criteria 3, 5, 6). |
| Export stability | The export package of any assessment is unchanged by C1 (criterion 6); adding failure values to it is C3's job. |
| Privacy of registered-user data | A personal treatment step and its failure values appear only in its owner's list; a test requests the list as another user and finds nothing of it (criterion 7). |
+The number of parallel units has a lower bound but no upper bound (D7), although the Input
+validity goal asks for a plausible range. That is intended: a mistyped large value can only
+raise the failure LRV towards log10 of the number of units, at most to the step's normal
+LRV (ADR-0002), so it cannot produce an implausible result. ADR-0002 is not reopened.
+
Not relevant to C1: *Scientific correctness* (the calculation is untouched; C2 and C5) and
-*Traceability* (showing failure inputs in the export package and report is C3).
+*Traceability* (showing failure inputs in the export package is C3).
## Out of Scope
- Any effect of failure inputs on the calculation, the result page or the reference-level
- exceedance (C2).
-- Failure inputs in the export package and the HTML report (C3).
-- Explanations of failure frequency, failure duration and the two cases beyond field
- labels and units: FAQ, guided tour, Sphinx documentation (C4).
+ exceedance, including the failure LRV and the daily LRV (C2, ADR-0002).
+- Failure inputs in the export package (C3). The HTML report shows no inputs and does not
+ get failure inputs either (decided 2026-09-25).
+- Explanations beyond field labels and units: what a parallel unit is, that the failure
+ duration affects both cases, that units failing together are entered as 1 unit, and the
+ rest of the FAQ, guided tour and Sphinx documentation (C4).
- Benchmark scenarios (C5).
-- Failure data for bundled treatment steps.
-- Partial loss of removal (not planned).
+- Failure data for bundled treatment steps, including a number of parallel units.
+- Units of one step that fail together; such a step is entered with 1 unit (D7).
+- A failure frequency per unit; it stays per step (D7).
+- Marking treatment steps as non-technical measures; every step that accepts failure
+ inputs gets the same three fields (D7).
- Hiding the failure fields in production; D5 keeps C1 out of `main` instead.
- A browser test setup; the one browser-only behaviour is checked manually.
- The naming follow-ups in `docs/domain-model-open-questions.md` and the deferred UI
@@ -222,9 +293,12 @@ Not relevant to C1: *Scientific correctness* (the calculation is untouched; C2 a
## Further Notes
-- Until C2 is done, failure inputs have no effect on results. That is acceptable only
- because of D5: C1 stays out of `main` and production until the release.
-- While exploring: saving a personal treatment step checks the name for uniqueness against
- the user's personal *exposures*, not their personal treatment steps. That is an existing
- bug outside this Change; it is noted here so it is not mistaken for C1 behaviour.
+- Until C2 is reworked, the number of parallel units has no effect on results, and C2's
+ current code still uses the rules ADR-0002 replaces. That is acceptable only because of
+ D5: C1–C3 stay out of `main` and production until the release.
+- The default failure duration stays 30 minutes (roadmap, "Rework of C1–C3"). Since
+ ADR-0002 it affects worst-case too; C4 explains this, the configurator label does not.
+- Saving a personal treatment step still checks the name for uniqueness against the user's
+ personal *exposures*, not their personal treatment steps. That is an existing bug outside
+ this Change; it is noted here so it is not mistaken for C1 behaviour.
- The open roadmap decisions D1–D3 do not affect C1.
diff --git a/docs/specs/c2-failure-calculation.md b/docs/specs/c2-failure-calculation.md
index 093203e..59e8da9 100644
--- a/docs/specs/c2-failure-calculation.md
+++ b/docs/specs/c2-failure-calculation.md
@@ -116,7 +116,9 @@ failure frequency is 0 gives exactly the same results as before C2.
train minus the removable maximum LRVs of the steps down in it. The failure events of the
day are arranged within 1,440 minutes so that this average concentration is lowest. This
gives no overlap when the durations fit into one day and otherwise the minimal overlap,
- placed on the steps whose joint loss matters least (`CONTEXT.md`, *Combined failure*). For
+ placed on the steps whose joint loss matters least (`CONTEXT.md`, *Combined failure*).
+ Because the LRVs differ per pathogen group, the arrangement is chosen separately for each
+ reference pathogen; the failure days themselves stay shared. For
a single failing step it equals Eq. 5 of the failure approach. With the few failing steps
per day that occur in practice, the arrangement can be found exactly, for example by
choosing the time share of each combination of failing steps.
@@ -163,7 +165,9 @@ failure frequency is 0 gives exactly the same results as before C2.
by the constant mixed LRV from Eq. 5, computed by hand in the test.
- **Combined failures:** two steps always failing with 900 + 900 minutes equal the
hand-computed constant LRV with 360 minutes of overlap; three steps always failing with
- durations above a day in total equal the hand-computed lowest-concentration arrangement.
+ durations above a day in total equal the hand-computed lowest-concentration arrangement;
+ with three steps whose least important pair differs between two pathogen groups, each
+ reference pathogen equals its own hand-computed arrangement.
- **Monotonicity:** raising a failure frequency never lowers any mean; raising a failure
duration never lowers a best-case mean and leaves worst-case unchanged.
- **Case order:** worst-case mean ≥ best-case mean for every pathogen and risk measure,
diff --git a/docs/tickets/04-c1-failure-inputs-in-configurator.md b/docs/tickets/04-c1-failure-inputs-in-configurator.md
new file mode 100644
index 0000000..f8b3109
--- /dev/null
+++ b/docs/tickets/04-c1-failure-inputs-in-configurator.md
@@ -0,0 +1,24 @@
+# 04: Failure inputs on a treatment step in the configurator: enter, check ranges, save, reopen, edit
+
+**What to build:** Every treatment step in the assessment configurator gets two more fields: **failure frequency** (failure days per year) and **failure duration** (minutes). Registered users can save an assessment with these values, and when they reopen or edit it they see the same values again. The values do not change the result yet; the calculation follows in C2.
+
+Tidy-up comes first. The configurator's treatment step form and the personal treatment step form both contain the same minimum ≤ maximum LRV check. It becomes one shared rule that both forms use, and the failure rules are added to it later. This Ticket also adds the first HTTP-level tests. They use Django's test client and a logged-in test user, and they check what a browser or a later request can see.
+
+Delivered on branch `c1-failure-inputs` as part of the single C1 pull request. The pull request stays open until the release (D5).
+
+Source: Spec C1 Failure inputs (`docs/specs/c1-failure-inputs.md`), Change C1 in `docs/failure_roadmap.md`. The Spec issue does not exist yet. GitHub issue #21.
+
+**Blocked by:** None (can start immediately)
+
+**Status:** in-progress
+
+- [x] The minimum ≤ maximum LRV check is defined once and used by both treatment step forms. Nothing changes that a user can notice, and the existing test suite passes unchanged.
+- [x] A schema migration adds failure frequency (a decimal number) and failure duration (a whole number) to the treatment steps of an assessment. Existing rows get failure frequency 0 and failure duration 30.
+- [x] Each treatment step card in the configurator shows both fields below its LRV rows, labelled "Failure frequency (days per year)" and "Failure duration (minutes)". New steps start at 0 and 30, bundled steps included.
+- [x] Failure frequency outside 0–365, and failure duration outside 1–1440 or not a whole number, are rejected with a message on the saved configurator. The duration range is checked even when the frequency is 0.
+- [x] A fractional failure frequency such as 0.5 is accepted.
+- [x] Saving an assessment and reopening it gives back the same failure values for each step. Editing them and saving again stores the new values.
+- [x] Each value stays with its own step when other steps are removed. Two copies of the same bundled step can have different values.
+- [x] An assessment created without failure values, as before the migration, reopens with failure frequency 0 and failure duration 30.
+- [x] An assessment's result and export package are the same with and without failure inputs.
+- [x] All of the above is covered by HTTP-level tests through Django's test client, and the existing test suite passes unchanged.
diff --git a/docs/tickets/05-c1-failure-frequency-needs-positive-lrv-and-guest-messages.md b/docs/tickets/05-c1-failure-frequency-needs-positive-lrv-and-guest-messages.md
new file mode 100644
index 0000000..efb39d5
--- /dev/null
+++ b/docs/tickets/05-c1-failure-frequency-needs-positive-lrv-and-guest-messages.md
@@ -0,0 +1,20 @@
+# 05: Failure frequency only on steps with a positive LRV (D4), with messages for guests too
+
+**What to build:** A treatment step can only have a failure frequency above 0 if at least one of its LRVs is positive, because a failure removes only positive LRVs (D4). The configurator checks this when the assessment is run or saved, and the error appears on the failure frequency field.
+
+Guests can use the failure fields too, and the same rules apply to them. Today, when a guest runs an assessment with invalid input, the guest result sends back an empty error and nothing appears on the page. With this Ticket, the guest result sends back the error messages and the configurator shows them on the affected treatment step, as it does for registered users. This also makes the existing LRV errors, such as minimum above maximum, visible to guests.
+
+Delivered on branch `c1-failure-inputs` as part of the single C1 pull request.
+
+Source: Spec C1 Failure inputs (`docs/specs/c1-failure-inputs.md`), roadmap decision D4. The Spec issue does not exist yet. GitHub issue #22.
+
+**Blocked by:** 04 (#21)
+
+**Status:** in-progress
+
+- [x] A failure frequency above 0 is rejected, with a message on the failure frequency field, if none of the step's six LRVs (minimum and maximum for bacteria, viruses and protozoa) is above 0. An empty LRV counts as 0.
+- [x] A step whose only positive LRV is one maximum (for example protozoa 0–2) accepts a failure frequency above 0.
+- [x] A step with only negative LRVs (recontamination) or only empty LRVs keeps failure frequency 0. A higher value is rejected.
+- [x] A guest sees the failure fields, can run an assessment with valid failure inputs, and nothing is stored.
+- [x] When a guest's input is invalid, whether a failure input or an LRV, the guest result sends back the error messages and the configurator shows them on the affected step. The rules are the same as for registered users.
+- [x] HTTP-level tests cover the D4 cases on the saved configurator and on the guest result, and cover rejection with a message on the guest result. The existing test suite passes unchanged.
diff --git a/docs/tickets/06-c1-personal-treatment-step-failure-inputs.md b/docs/tickets/06-c1-personal-treatment-step-failure-inputs.md
new file mode 100644
index 0000000..72b599a
--- /dev/null
+++ b/docs/tickets/06-c1-personal-treatment-step-failure-inputs.md
@@ -0,0 +1,24 @@
+# 06: Personal treatment steps store failure inputs and the configurator copies them in
+
+**What to build:** A registered user can store a failure frequency and a failure duration on a personal treatment step, with the same rules as in the configurator. When they add the step to an assessment, its failure values are copied in together with its LRVs. The copy can then be changed in that one assessment without changing the personal treatment step.
+
+Today the personal treatment step form sends back an empty error for invalid input, so the user sees nothing. With this Ticket it shows the messages, the same way it already does for a duplicate name.
+
+Delivered on branch `c1-failure-inputs` and closes the C1 pull request. The pull request is tested on the dev environment and not merged before the release (D5).
+
+Source: Spec C1 Failure inputs (`docs/specs/c1-failure-inputs.md`). The Spec issue does not exist yet. GitHub issue #23.
+
+**Blocked by:** 04 (#21), 05 (#22)
+
+**Status:** in-progress
+
+- [x] A schema migration adds failure frequency and failure duration to personal treatment steps. Existing personal treatment steps get failure frequency 0 and failure duration 30.
+- [x] The personal treatment step form shows both fields with their units and applies the same shared rule as the configurator: the ranges, whole minutes, D4 and minimum ≤ maximum LRV. Invalid input is rejected and the message is shown in the form.
+- [x] A personal treatment step saved with failure inputs appears with them in its owner's list of personal treatment steps. Another user's list does not contain it.
+- [x] Adding a personal treatment step to an assessment copies its failure frequency and failure duration into the new step. Bundled steps still start at 0 and 30.
+- [x] Changing the copied values in an assessment and saving leaves the personal treatment step unchanged.
+- [x] HTTP-level tests cover the form rules, old personal treatment steps reading back 0 and 30, and owner-only visibility. The existing test suite passes unchanged.
+- [x] Manual check on the dev environment, announced to the team first:
+ - Create a personal treatment step with failure inputs and add it to an assessment.
+ - See the copied values, change them, save and reopen.
+ - Confirm that the personal treatment step itself is unchanged.
diff --git a/docs/tickets/07-c2-calculation-per-exposure-event.md b/docs/tickets/07-c2-calculation-per-exposure-event.md
new file mode 100644
index 0000000..5dc714c
--- /dev/null
+++ b/docs/tickets/07-c2-calculation-per-exposure-event.md
@@ -0,0 +1,18 @@
+# 07: Tidy-up: the calculation works per exposure event, with results unchanged
+
+**What to build:** Tidy-up before failures enter the calculation. No result changes.
+
+The Monte Carlo simulation is reorganised to work per simulated exposure event. Each event picks its concentration sample by position instead of by value, which uses exactly the same random numbers as today, and each event gets its own LRV. For now every event still gets the same LRV. Best-case and worst-case use the same building blocks, and the summary statistics (mean, minimum, quartiles, median, maximum) and the reference-level exceedance are computed in one place for both cases and both risk measures. The calculation's public entry point keeps its interface.
+
+Delivered on branch `c2-failure-calculation`, based on `c1-failure-inputs`, as part of the single C2 pull request. The pull request stays open until the release (D5).
+
+Source: Spec C2 Failure calculation (`docs/specs/c2-failure-calculation.md`), Change C2 in `docs/failure_roadmap.md`. The Spec issue does not exist yet. GitHub issue #24.
+
+**Blocked by:** None (can start immediately)
+
+**Status:** ready-for-agent
+
+- [ ] Every simulated exposure event has its own concentration sample, picked by position, and its own LRV. The random numbers are the same as today's.
+- [ ] Best-case and worst-case share the same calculation steps. Summary statistics and the reference-level exceedance are computed in one place.
+- [ ] The existing regression test passes with exactly the same numbers, and the existing test suite passes unchanged.
+- [ ] A new test at the calculation's public entry point shows that calculating the same scenario twice gives identical results.
diff --git a/docs/tickets/08-c2-failure-days-and-worst-case.md b/docs/tickets/08-c2-failure-days-and-worst-case.md
new file mode 100644
index 0000000..a2e3428
--- /dev/null
+++ b/docs/tickets/08-c2-failure-days-and-worst-case.md
@@ -0,0 +1,21 @@
+# 08: Failure days drawn per exposure event; worst-case loses the positive LRVs of failing steps
+
+**What to build:** The worst-case result includes treatment failures. For every simulated exposure event, and for every treatment step with a failure frequency above 0, the step fails that day with probability failure frequency / 365 (ADR-0001). On a failure day, worst-case calculates the event with the positive minimum LRVs of every failing step removed, whatever the failure duration. LRVs of 0, below 0 or empty stay in place (D4).
+
+The failure draws come from their own fixed-seed random generator and are shared by both cases and all reference pathogens. Raising a failure frequency therefore only adds failure days. If every failure frequency is 0, no failure draws are made and the results are exactly today's. Best-case does not change in this Ticket; the mixed-water assumption follows in the next one.
+
+The result page, the reference-level exceedance, saved results and the export's result table show whatever the calculation returns. Their layout does not change.
+
+Source: Spec C2 Failure calculation (`docs/specs/c2-failure-calculation.md`), ADR-0001, roadmap decision D4. The Spec issue does not exist yet. GitHub issue #25.
+
+**Blocked by:** 07 (#24), 04 (#21, C1 stored failure inputs)
+
+**Status:** ready-for-agent
+
+- [ ] With every failure frequency at 0, all results equal today's exactly, including the existing regression test's numbers.
+- [ ] A step with failure frequency 365 gives the same worst-case results as the same scenario without that step's positive minimum LRVs.
+- [ ] Raising a failure frequency never lowers any mean. Changing a failure duration leaves worst-case unchanged.
+- [ ] Worst-case mean ≥ best-case mean for every reference pathogen and risk measure, with and without failures.
+- [ ] D4: a failing step with a negative LRV in one pathogen group and a positive LRV in another leaves the first group's results unchanged. A group with LRV 0 is unaffected.
+- [ ] Calculating the same scenario with failures twice gives identical results.
+- [ ] All tests go through the calculation's public entry point and check only the returned results. The existing test suite passes unchanged.
diff --git a/docs/tickets/09-c2-best-case-mixed-water.md b/docs/tickets/09-c2-best-case-mixed-water.md
new file mode 100644
index 0000000..1485cd4
--- /dev/null
+++ b/docs/tickets/09-c2-best-case-mixed-water.md
@@ -0,0 +1,30 @@
+# 09: Best-case applies the mixed-water assumption, including combined failures
+
+**What to build:** The best-case result includes treatment failures under the mixed-water assumption. On a failure day, the consumed water is a mixture of water treated during the failure events and water treated in normal operation, in proportion to the failure durations. The event's LRV is −log₁₀ of the time-weighted average of 10^(−LRV) over the segments of the day, where a segment's LRV is the best-case LRV of the train minus the positive maximum LRVs of the steps down in that segment.
+
+The failure events of the day are arranged within 1,440 minutes so that the mixed concentration is lowest:
+- if their durations fit into one day, they do not overlap;
+- otherwise they overlap only as much as necessary, on the steps whose joint loss matters least.
+
+The arrangement is chosen separately for each reference pathogen, because the LRVs differ per pathogen group. The failure days themselves stay shared. For a single failing step, the result equals Eq. 5 of the failure approach. Work per failure day is reused for repeated combinations of failing steps.
+
+This Ticket closes C2: it includes the manual check on the dev environment and the time measurement.
+
+Source: Spec C2 Failure calculation (`docs/specs/c2-failure-calculation.md`), ADR-0001, `CONTEXT.md` (*Mixed-water assumption*, *Combined failure*), decision 6 in `docs/QMRA_Failure_Approach.md`. The Spec issue does not exist yet. GitHub issue #26.
+
+**Blocked by:** 08 (#25)
+
+**Status:** ready-for-agent
+
+- [ ] A step with failure frequency 365 and failure duration 1,440 gives the same best-case results as the same scenario without that step's positive maximum LRVs.
+- [ ] A step with failure frequency 365 and failure duration 60 gives the same best-case results as the scenario with the train's best-case LRV replaced by the Eq. 5 mixed LRV, computed by hand in the test.
+- [ ] Two steps always failing with 900 + 900 minutes equal the hand-computed result with 360 minutes of overlap.
+- [ ] Three steps always failing with more than a day in total equal the hand-computed lowest-concentration arrangement.
+- [ ] Three steps whose least important pair differs between two pathogen groups: each reference pathogen equals its own hand-computed arrangement.
+- [ ] Raising a failure duration never lowers a best-case mean, and raising a failure frequency never lowers any mean.
+- [ ] Worst-case mean ≥ best-case mean for every reference pathogen and risk measure, with failures.
+- [ ] With every failure frequency at 0, all results equal today's exactly. The same scenario gives identical results every time. The existing test suite passes unchanged.
+- [ ] Manual check on the dev environment, announced to the team first:
+ - Enter a scenario with failures and run it as a guest and as a registered user.
+ - Confirm that the result page, the saved-assessments page, the assessment comparison and the export's result table show the changed results, with unchanged layout.
+- [ ] The calculation time for a drinking-water scenario (365 events per year) with failures is measured on the dev environment and recorded in the pull request.
diff --git a/docs/tickets/14-c1-parallel-units-in-configurator.md b/docs/tickets/14-c1-parallel-units-in-configurator.md
new file mode 100644
index 0000000..340f459
--- /dev/null
+++ b/docs/tickets/14-c1-parallel-units-in-configurator.md
@@ -0,0 +1,24 @@
+# 14: Number of parallel units on a treatment step in the configurator: enter, check, save, reopen, edit
+
+**What to build:** Every treatment step in the assessment configurator gets a third failure input next to failure frequency and failure duration: the **number of parallel units**, starting at 1. Guests and registered users see and use it. Registered users can save an assessment with it, and when they reopen or edit the assessment they see the same value again, for each step. The value does not change the result yet; the failure LRV follows in C2 (ADR-0002).
+
+The shared validation rule that both treatment step forms use is extended with the new input, not duplicated. A number of parallel units is a whole number of 1 or more, with no upper limit (D7).
+
+Delivered on branch `c1-failure-inputs` as part of the C1 pull request (#28). The pull request is tested on the dev environment again and not merged before the release (D5).
+
+Source: Spec C1 Failure inputs, revision for D7 (`docs/specs/c1-failure-inputs.md`), Change C1 in `docs/failure_roadmap.md`, roadmap decision D7, ADR-0002. The Spec issue does not exist yet. GitHub issue #36.
+
+**Blocked by:** None (can start immediately)
+
+**Status:** ready-for-agent
+
+- [x] A schema migration adds the number of parallel units to the treatment steps of assessments. Existing treatment steps get 1.
+- [x] Each treatment step card in the configurator shows the field "Number of parallel units" below the LRV rows, next to failure frequency and failure duration, starting at 1. It is always shown, also when the failure frequency is 0. Guests see the same field.
+- [x] A bundled treatment step added to an assessment starts at 1 parallel unit.
+- [x] The shared rule rejects a number of parallel units of 0, a negative number or a fraction such as 1.5 with a message that names the field and its range, on the saved configurator and on the guest result. 1, 2 and a large value such as 1000 are accepted.
+- [x] Saving an assessment and reopening it returns the same number of parallel units per step. Editing and saving again stores the new value, and values stay with their step when other steps are reordered or removed.
+- [x] An assessment saved before this Ticket reopens with 1 parallel unit, failure frequency 0 and failure duration 30, and its result is unchanged.
+- [x] The result and the export package of an assessment are identical with and without failure inputs, including a number of parallel units above 1.
+- [x] HTTP-level tests through Django's test client cover the cases above, extending the existing C1 failure input tests. The existing test suite passes unchanged.
+
+Quality goals (`docs/vision.md`): Input validity (label and checked range), Reproducibility (saved values and old assessments read back unchanged), Export stability (export package unchanged by C1).
diff --git a/docs/tickets/15-c1-personal-treatment-step-parallel-units.md b/docs/tickets/15-c1-personal-treatment-step-parallel-units.md
new file mode 100644
index 0000000..879848e
--- /dev/null
+++ b/docs/tickets/15-c1-personal-treatment-step-parallel-units.md
@@ -0,0 +1,24 @@
+# 15: Personal treatment steps store the number of parallel units and the configurator copies it in
+
+**What to build:** A registered user can store a number of parallel units on a personal treatment step, next to its failure frequency and failure duration, with the same rule as in the configurator. When they add the step to an assessment, its number of parallel units is copied in together with its other failure inputs and its LRVs. The copy can then be changed in that one assessment without changing the personal treatment step.
+
+Delivered on branch `c1-failure-inputs` and completes the D7 rework of the C1 pull request (#28). The pull request is tested on the dev environment again and not merged before the release (D5).
+
+Source: Spec C1 Failure inputs, revision for D7 (`docs/specs/c1-failure-inputs.md`), roadmap decision D7, ADR-0002. The Spec issue does not exist yet. GitHub issue #37.
+
+**Blocked by:** 14 (#36)
+
+**Status:** ready-for-agent
+
+- [x] A schema migration adds the number of parallel units to personal treatment steps. Existing personal treatment steps get 1.
+- [x] The personal treatment step form shows "Number of parallel units", starting at 1, next to the two other failure fields, and applies the same shared rule as the configurator. Invalid input is rejected and the message is shown in the form.
+- [x] A personal treatment step saved with a number of parallel units appears with it in its owner's list of personal treatment steps. Another user's list does not contain it.
+- [x] Adding a personal treatment step to an assessment copies its number of parallel units into the new step, together with its failure frequency, failure duration and LRVs. Bundled steps still start at 1.
+- [x] Changing the copied value in an assessment and saving leaves the personal treatment step unchanged.
+- [x] HTTP-level tests cover the form rule, personal treatment steps from before this Ticket reading back 1, and owner-only visibility. The existing test suite passes unchanged.
+- [x] Manual check on the dev environment, announced to the team first (2026-10-17, passed):
+ - Create a personal treatment step with all three failure inputs, including a number of parallel units above 1, and add it to an assessment.
+ - See the copied values, change them, save and reopen.
+ - Confirm that the personal treatment step itself is unchanged.
+
+Quality goals (`docs/vision.md`): Input validity (the same rule as the configurator), Privacy of registered-user data (the step and its failure inputs appear only in its owner's list).
diff --git a/qmra/risk_assessment/forms.py b/qmra/risk_assessment/forms.py
index 57b1bc7..a648fc7 100644
--- a/qmra/risk_assessment/forms.py
+++ b/qmra/risk_assessment/forms.py
@@ -14,6 +14,42 @@
def _zero_if_none(x): return x if x is not None else 0
+LRV_FIELDS = [
+ ("bacteria_min", "bacteria_max"),
+ ("viruses_min", "viruses_max"),
+ ("protozoa_min", "protozoa_max"),
+]
+FAILURE_INPUT_RANGES = {
+ "failure_frequency": (0, 365, "days per year"),
+ "failure_duration": (1, 1440, "minutes"),
+}
+PARALLEL_UNITS_MESSAGE = "number of parallel units must be a whole number of 1 or more"
+
+
+def check_treatment_step(form, cleaned_data):
+ """Minimum LRV at most maximum LRV per pathogen group, failure inputs within their ranges,
+ at least one parallel unit, and a failure frequency above 0 only for a step with a positive LRV.
+
+ Shared by the configurator and the personal treatment step form. Whole minutes are enforced
+ by the failure duration's field type; failure inputs a form does not have are skipped.
+ """
+ for min_field, max_field in LRV_FIELDS:
+ if _zero_if_none(cleaned_data.get(min_field, 0)) > _zero_if_none(cleaned_data.get(max_field, 0)):
+ form.add_error(min_field, "min. must be less than max")
+ for field, (low, high, unit) in FAILURE_INPUT_RANGES.items():
+ value = cleaned_data.get(field)
+ if value is not None and not low <= value <= high:
+ form.add_error(field, f"{field.replace('_', ' ')} must be between {low} and {high} {unit}")
+ # no upper limit: more units only raise the LRV during a failure (D7)
+ parallel_units = cleaned_data.get("parallel_units")
+ if parallel_units is not None and parallel_units < 1:
+ form.add_error("parallel_units", PARALLEL_UNITS_MESSAGE)
+ # a failure removes only positive LRVs, so a step without one cannot fail (D4)
+ has_positive_lrv = any(_zero_if_none(cleaned_data.get(field)) > 0 for pair in LRV_FIELDS for field in pair)
+ if _zero_if_none(cleaned_data.get("failure_frequency")) > 0 and not has_positive_lrv:
+ form.add_error("failure_frequency", "failure frequency must be 0 for a treatment step without a positive LRV")
+
+
class RiskAssessmentForm(forms.ModelForm):
source_name = forms.ChoiceField()
exposure_name = forms.ChoiceField()
@@ -150,7 +186,10 @@ class Meta:
'viruses_min',
'viruses_max',
"protozoa_min",
- "protozoa_max"
+ "protozoa_max",
+ "failure_frequency",
+ "failure_duration",
+ "parallel_units",
]
def __init__(self, *args, **kwargs):
@@ -167,6 +206,13 @@ def __init__(self, *args, **kwargs):
self.fields['viruses_max'].label = ""
self.fields['protozoa_min'].label = ""
self.fields['protozoa_max'].label = ""
+ self.fields['failure_frequency'].label = "Failure frequency (days per year)"
+ self.fields['failure_duration'].label = "Failure duration (minutes)"
+ self.fields['parallel_units'].label = "Number of parallel units"
+ # leave whole numbers to the server check: the browser blocks hidden step cards without a message
+ self.fields['failure_duration'].widget.attrs['step'] = "any"
+ self.fields['parallel_units'].widget.attrs['step'] = "any"
+ self.fields['parallel_units'].error_messages['invalid'] = PARALLEL_UNITS_MESSAGE
label_style = "class='text-muted text-center w-100' style='margin-top: .4em;'"
self.helper.layout = Layout(
Field("name", css_class="disabled-input d-none"),
@@ -179,24 +225,13 @@ def __init__(self, *args, **kwargs):
Column("viruses_min"), Column("viruses_max")),
Row(Column(HTML(f"")),
Column("protozoa_min"), Column("protozoa_max")),
+ Row(Column("failure_frequency"), Column("failure_duration"), Column("parallel_units")),
# Row(Column("DELETE"))
)
def clean(self):
cleaned_data = super().clean()
- b_min = _zero_if_none(cleaned_data.get("bacteria_min", 0))
- b_max = _zero_if_none(cleaned_data.get("bacteria_max", 0))
- v_min = _zero_if_none(cleaned_data.get("viruses_min", 0))
- v_max = _zero_if_none(cleaned_data.get("viruses_max", 0))
- p_min = _zero_if_none(cleaned_data.get("protozoa_min", 0))
- p_max = _zero_if_none(cleaned_data.get("protozoa_max", 0))
- msg = "min. must be less than max"
- if b_min > b_max:
- self.add_error("bacteria_min", msg)
- if v_min > v_max:
- self.add_error("viruses_min", msg)
- if p_min > p_max:
- self.add_error("protozoa_min", msg)
+ check_treatment_step(self, cleaned_data)
return cleaned_data
diff --git a/qmra/risk_assessment/migrations/0011_treatment_failure_inputs.py b/qmra/risk_assessment/migrations/0011_treatment_failure_inputs.py
new file mode 100644
index 0000000..b05f235
--- /dev/null
+++ b/qmra/risk_assessment/migrations/0011_treatment_failure_inputs.py
@@ -0,0 +1,23 @@
+# Generated by Django 5.0.14 on 2026-09-25 10:10
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('risk_assessment', '0010_remove_qmratreatment_bacteria_reference_and_more'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='treatment',
+ name='failure_duration',
+ field=models.IntegerField(default=30),
+ ),
+ migrations.AddField(
+ model_name='treatment',
+ name='failure_frequency',
+ field=models.FloatField(default=0),
+ ),
+ ]
diff --git a/qmra/risk_assessment/migrations/0012_usertreatment_failure_inputs.py b/qmra/risk_assessment/migrations/0012_usertreatment_failure_inputs.py
new file mode 100644
index 0000000..56c0ebd
--- /dev/null
+++ b/qmra/risk_assessment/migrations/0012_usertreatment_failure_inputs.py
@@ -0,0 +1,23 @@
+# Generated by Django 5.0.14 on 2026-09-25 10:34
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('risk_assessment', '0011_treatment_failure_inputs'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='usertreatment',
+ name='failure_duration',
+ field=models.IntegerField(default=30),
+ ),
+ migrations.AddField(
+ model_name='usertreatment',
+ name='failure_frequency',
+ field=models.FloatField(default=0),
+ ),
+ ]
diff --git a/qmra/risk_assessment/migrations/0013_treatment_parallel_units.py b/qmra/risk_assessment/migrations/0013_treatment_parallel_units.py
new file mode 100644
index 0000000..92657c9
--- /dev/null
+++ b/qmra/risk_assessment/migrations/0013_treatment_parallel_units.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.0.14 on 2026-10-16 16:11
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('risk_assessment', '0012_usertreatment_failure_inputs'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='treatment',
+ name='parallel_units',
+ field=models.IntegerField(default=1),
+ ),
+ ]
diff --git a/qmra/risk_assessment/migrations/0014_usertreatment_parallel_units.py b/qmra/risk_assessment/migrations/0014_usertreatment_parallel_units.py
new file mode 100644
index 0000000..7b23805
--- /dev/null
+++ b/qmra/risk_assessment/migrations/0014_usertreatment_parallel_units.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.0.14 on 2026-10-16 16:43
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('risk_assessment', '0013_treatment_parallel_units'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='usertreatment',
+ name='parallel_units',
+ field=models.IntegerField(default=1),
+ ),
+ ]
diff --git a/qmra/risk_assessment/models.py b/qmra/risk_assessment/models.py
index 030def7..70a6918 100644
--- a/qmra/risk_assessment/models.py
+++ b/qmra/risk_assessment/models.py
@@ -35,6 +35,9 @@ class Treatment(models.Model):
viruses_max = models.FloatField(blank=True, null=True)
protozoa_min = models.FloatField(blank=True, null=True)
protozoa_max = models.FloatField(blank=True, null=True)
+ failure_frequency = models.FloatField(default=0) # failure days per year
+ failure_duration = models.IntegerField(default=30) # minutes
+ parallel_units = models.IntegerField(default=1) # a failure event stops one of them (D7)
@classmethod
def from_default(cls, default: QMRATreatment, risk_assessment):
diff --git a/qmra/risk_assessment/templates/risk-assessment-form-js.html b/qmra/risk_assessment/templates/risk-assessment-form-js.html
index c414676..403bc5f 100644
--- a/qmra/risk_assessment/templates/risk-assessment-form-js.html
+++ b/qmra/risk_assessment/templates/risk-assessment-form-js.html
@@ -1,6 +1,57 @@
{% load static %}