From 8307a7aa6e7e329614449c51555a1c9219bda25b Mon Sep 17 00:00:00 2001 From: ma-z-am Date: Fri, 25 Sep 2026 09:52:07 +0000 Subject: [PATCH 01/26] docs: add C1 tickets 04-06 (#21, #22, #23) [skip ci] Co-Authored-By: Claude Opus 5.5 (1M context) --- .../04-c1-failure-inputs-in-configurator.md | 24 +++++++++++++++++++ ...y-needs-positive-lrv-and-guest-messages.md | 20 ++++++++++++++++ ...-personal-treatment-step-failure-inputs.md | 24 +++++++++++++++++++ 3 files changed, 68 insertions(+) create mode 100644 docs/tickets/04-c1-failure-inputs-in-configurator.md create mode 100644 docs/tickets/05-c1-failure-frequency-needs-positive-lrv-and-guest-messages.md create mode 100644 docs/tickets/06-c1-personal-treatment-step-failure-inputs.md 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..8903651 --- /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:** ready-for-agent + +- [ ] 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. +- [ ] 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. +- [ ] 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. +- [ ] 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. +- [ ] A fractional failure frequency such as 0.5 is accepted. +- [ ] Saving an assessment and reopening it gives back the same failure values for each step. Editing them and saving again stores the new values. +- [ ] Each value stays with its own step when other steps are removed. Two copies of the same bundled step can have different values. +- [ ] An assessment created without failure values, as before the migration, reopens with failure frequency 0 and failure duration 30. +- [ ] An assessment's result and export package are the same with and without failure inputs. +- [ ] 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..e2abd8e --- /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:** ready-for-agent + +- [ ] 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. +- [ ] A step whose only positive LRV is one maximum (for example protozoa 0–2) accepts a failure frequency above 0. +- [ ] A step with only negative LRVs (recontamination) or only empty LRVs keeps failure frequency 0. A higher value is rejected. +- [ ] A guest sees the failure fields, can run an assessment with valid failure inputs, and nothing is stored. +- [ ] 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. +- [ ] 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..60ecebb --- /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:** ready-for-agent + +- [ ] 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. +- [ ] 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. +- [ ] 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. +- [ ] 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. +- [ ] Changing the copied values in an assessment and saving leaves the personal treatment step unchanged. +- [ ] 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. +- [ ] 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. From 90780b3e3cdc29690c2ef01b85409711ab0be27c Mon Sep 17 00:00:00 2001 From: ma-z-am Date: Fri, 25 Sep 2026 10:01:06 +0000 Subject: [PATCH 02/26] docs: add C2 tickets 07-09 (#24, #25, #26); combined-failure arrangement per pathogen [skip ci] The best-case arrangement of combined failure events longer than a day is chosen separately for each reference pathogen; failure days stay shared. Recorded in the C2 Spec and in CONTEXT.md (Combined failure). Co-Authored-By: Claude Opus 5.5 (1M context) --- CONTEXT.md | 2 +- docs/specs/c2-failure-calculation.md | 8 +++-- .../07-c2-calculation-per-exposure-event.md | 18 +++++++++++ .../08-c2-failure-days-and-worst-case.md | 21 +++++++++++++ docs/tickets/09-c2-best-case-mixed-water.md | 30 +++++++++++++++++++ 5 files changed, 76 insertions(+), 3 deletions(-) create mode 100644 docs/tickets/07-c2-calculation-per-exposure-event.md create mode 100644 docs/tickets/08-c2-failure-days-and-worst-case.md create mode 100644 docs/tickets/09-c2-best-case-mixed-water.md diff --git a/CONTEXT.md b/CONTEXT.md index f44d7be..f77a33e 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -175,7 +175,7 @@ The best-case assumption that water consumed on a failure day is a mixture of wa _Avoid_: mixed concentration, dilution, storage effect **Combined failure**: -Failure events in two or more treatment steps on the same day. Worst-case loses all their LRVs for the whole day. Under the mixed-water assumption the failure events are arranged within the day so that the mixed water has the lowest pathogen concentration. This means they do not overlap if their durations fit into one day, and otherwise overlap only by the minutes that exceed a day, shared by the steps whose joint loss matters least. +Failure events in two or more treatment steps on the same day. Worst-case loses all their LRVs for the whole day. Under the mixed-water assumption the failure events are arranged within the day so that the mixed water has the lowest pathogen concentration. This means they do not overlap if their durations fit into one day, and otherwise overlap only by the minutes that exceed a day, shared by the steps whose joint loss matters least. The arrangement is chosen separately for each reference pathogen, since which joint loss matters least depends on the LRVs of its pathogen group. **Next phase**: Capabilities identified for future development and not present in the application today. 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/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. From 57bdd218a4a19d73c2b64e2c032d66da2205def6 Mon Sep 17 00:00:00 2001 From: ma-z-am Date: Fri, 25 Sep 2026 10:15:42 +0000 Subject: [PATCH 03/26] feat: failure inputs on treatment steps in the configurator (#21) Each treatment step of an assessment gets a failure frequency (failure days per year, 0-365, default 0) and a failure duration (whole minutes, 1-1440, default 30). The configurator shows both fields with their units below the LRVs, rejects values outside the ranges with a message, and keeps the values with their step through save, reopen and edit. Existing steps get the defaults via the migration. The calculation is unchanged, so results and export packages stay the same (C2 adds the effect). Tidy-up: the min <= max LRV check duplicated in the configurator and the personal treatment step form is now one shared rule, which also holds the failure ranges. The two existing treatment form tests pass the two new required fields in their input data; their assertions are unchanged. Adds the first HTTP-level tests (Django test client, logged-in user). Refs #21 Co-Authored-By: Claude Opus 5.5 (1M context) --- qmra/risk_assessment/forms.py | 49 ++-- .../0011_treatment_failure_inputs.py | 23 ++ qmra/risk_assessment/models.py | 2 + .../tests/test_failure_inputs.py | 232 ++++++++++++++++++ .../tests/test_risk_assessment_form.py | 8 +- qmra/risk_assessment/user_models.py | 16 +- 6 files changed, 300 insertions(+), 30 deletions(-) create mode 100644 qmra/risk_assessment/migrations/0011_treatment_failure_inputs.py create mode 100644 qmra/risk_assessment/tests/test_failure_inputs.py diff --git a/qmra/risk_assessment/forms.py b/qmra/risk_assessment/forms.py index 57b1bc7..ce589e7 100644 --- a/qmra/risk_assessment/forms.py +++ b/qmra/risk_assessment/forms.py @@ -14,6 +14,32 @@ 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"), +} + + +def check_treatment_step(form, cleaned_data): + """Minimum LRV at most maximum LRV per pathogen group, and failure inputs within their ranges. + + 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}") + + class RiskAssessmentForm(forms.ModelForm): source_name = forms.ChoiceField() exposure_name = forms.ChoiceField() @@ -150,7 +176,9 @@ class Meta: 'viruses_min', 'viruses_max', "protozoa_min", - "protozoa_max" + "protozoa_max", + "failure_frequency", + "failure_duration", ] def __init__(self, *args, **kwargs): @@ -167,6 +195,10 @@ 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)" + # leave whole minutes to the server check: the browser blocks hidden step cards without a message + self.fields['failure_duration'].widget.attrs['step'] = "any" 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 +211,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")), # 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/models.py b/qmra/risk_assessment/models.py index 030def7..eaeb936 100644 --- a/qmra/risk_assessment/models.py +++ b/qmra/risk_assessment/models.py @@ -35,6 +35,8 @@ 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 @classmethod def from_default(cls, default: QMRATreatment, risk_assessment): diff --git a/qmra/risk_assessment/tests/test_failure_inputs.py b/qmra/risk_assessment/tests/test_failure_inputs.py new file mode 100644 index 0000000..ffc038a --- /dev/null +++ b/qmra/risk_assessment/tests/test_failure_inputs.py @@ -0,0 +1,232 @@ +"""failure inputs of treatment steps, tested over HTTP like a browser uses the configurator""" +import io +from html.parser import HTMLParser +from zipfile import ZipFile + +from assertpy import assert_that +from django.test import TestCase +from django.urls import reverse + +from qmra.risk_assessment.models import RiskAssessment, Inflow, Treatment +from qmra.user.models import User + + +class ConfiguratorFormParser(HTMLParser): + """Collects what a browser would submit from the configurator form.""" + + def __init__(self): + super().__init__() + self.data = {} + self.in_configurator = False + self.select_name = None + self.first_option = None + + def handle_starttag(self, tag, attrs): + attrs = dict(attrs) + if tag == "form": + self.in_configurator = attrs.get("id") == "configurator" + if not self.in_configurator: + return + if tag == "input" and attrs.get("name"): + if attrs.get("type") in ("submit", "button"): + return + if attrs.get("type") == "checkbox" and "checked" not in attrs: + return + self.data[attrs["name"]] = attrs.get("value", "") + elif tag == "select": + self.select_name = attrs.get("name") + self.first_option = None + elif tag == "option" and self.select_name: + if self.first_option is None: + self.first_option = attrs.get("value", "") + self.data.setdefault(self.select_name, self.first_option) + if "selected" in attrs: + self.data[self.select_name] = attrs.get("value", "") + + def handle_endtag(self, tag): + if tag == "form": + self.in_configurator = False + elif tag == "select": + self.select_name = None + + +def configurator_data(response) -> dict: + parser = ConfiguratorFormParser() + parser.feed(response.content.decode()) + return parser.data + + +def add_step(data: dict, name: str, **values): + """Add a treatment step card the way the configurator's script does: copy the empty form.""" + n = int(data["treatments-TOTAL_FORMS"]) + for key, value in list(data.items()): + if key.startswith("treatments-__prefix__-") and not key.endswith("-DELETE"): + data[key.replace("__prefix__", str(n))] = value + data[f"treatments-{n}-name"] = name + for field, value in values.items(): + data[f"treatments-{n}-{field}"] = value + data["treatments-TOTAL_FORMS"] = str(n + 1) + + +def failure_inputs_per_step(data: dict) -> list[dict]: + """The treatment step cards of a configurator, in order, with their failure inputs.""" + return [ + dict(name=data[f"treatments-{i}-name"], + failure_frequency=float(data[f"treatments-{i}-failure_frequency"]), + failure_duration=int(data[f"treatments-{i}-failure_duration"])) + for i in range(int(data["treatments-TOTAL_FORMS"])) + ] + + +LRVS = dict(bacteria_min=1, bacteria_max=2, viruses_min=1, viruses_max=2, protozoa_min=1, protozoa_max=2) + + +class TestFailureInputs(TestCase): + + def setUp(self): + self.user = User.objects.create_user("test-user", "test-user@test.com", "password") + self.client.force_login(self.user) + + def new_configurator(self) -> dict: + data = configurator_data(self.client.get(reverse("assessment"))) + for i in range(3): + data[f"inflow-{i}-min"] = "10" + data[f"inflow-{i}-max"] = "100" + data["ra-source_name"] = "groundwater" + data["ra-exposure_name"] = "drinking water" + data["ra-events_per_year"] = "365" + data["ra-volume_per_event"] = "1" + return data + + def post_configurator(self, data: dict, assessment: RiskAssessment = None): + url = reverse("assessment") if assessment is None else reverse("assessment", args=[assessment.id]) + return self.client.post(url, data) + + def save_new(self, data: dict) -> RiskAssessment: + response = self.post_configurator(data) + assert_that(response.status_code).is_equal_to(302) + return RiskAssessment.objects.get(user=self.user) + + def reopen(self, assessment: RiskAssessment) -> dict: + return configurator_data(self.client.get(reverse("assessment", args=[assessment.id]))) + + def results(self, assessment: RiskAssessment) -> dict: + return {r.pathogen: r.as_dict() for r in RiskAssessment.objects.get(id=assessment.id).results.all()} + + def export(self, assessment: RiskAssessment) -> dict: + response = self.client.get(reverse("assessment-export", args=[assessment.id])) + with ZipFile(io.BytesIO(response.content)) as archive: + return {name: archive.read(name) for name in archive.namelist()} + + def test_new_step_card_shows_failure_fields_with_units_and_defaults(self): + response = self.client.get(reverse("assessment")) + + assert_that(response.content.decode()).contains( + "Failure frequency (days per year)", "Failure duration (minutes)") + data = configurator_data(response) + assert_that(float(data["treatments-__prefix__-failure_frequency"])).is_equal_to(0) + assert_that(int(data["treatments-__prefix__-failure_duration"])).is_equal_to(30) + + def test_bundled_step_starts_at_defaults(self): + data = self.new_configurator() + add_step(data, "Primary treatment", **LRVS) + + assessment = self.save_new(data) + + assert_that(failure_inputs_per_step(self.reopen(assessment))).is_equal_to([ + dict(name="Primary treatment", failure_frequency=0, failure_duration=30) + ]) + + def test_failure_inputs_survive_save_and_reopen_per_step(self): + data = self.new_configurator() + add_step(data, "Primary treatment", **LRVS, failure_frequency="0.5", failure_duration="15") + add_step(data, "Primary treatment", **LRVS, failure_frequency="12", failure_duration="600") + add_step(data, "Slow sand filtration", **LRVS, failure_frequency="365", failure_duration="1440") + + assessment = self.save_new(data) + + assert_that(failure_inputs_per_step(self.reopen(assessment))).is_equal_to([ + dict(name="Primary treatment", failure_frequency=0.5, failure_duration=15), + dict(name="Primary treatment", failure_frequency=12, failure_duration=600), + dict(name="Slow sand filtration", failure_frequency=365, failure_duration=1440), + ]) + + def test_editing_stores_new_values_and_values_stay_with_their_step(self): + data = self.new_configurator() + add_step(data, "Primary treatment", **LRVS, failure_frequency="1", failure_duration="10") + add_step(data, "Slow sand filtration", **LRVS, failure_frequency="2", failure_duration="20") + add_step(data, "Primary treatment", **LRVS, failure_frequency="3", failure_duration="30") + assessment = self.save_new(data) + + data = self.reopen(assessment) + data["treatments-0-DELETE"] = "on" + data["treatments-2-failure_frequency"] = "4.5" + data["treatments-2-failure_duration"] = "45" + response = self.post_configurator(data, assessment) + + assert_that(response.status_code).is_equal_to(302) + assert_that(failure_inputs_per_step(self.reopen(assessment))).is_equal_to([ + dict(name="Slow sand filtration", failure_frequency=2, failure_duration=20), + dict(name="Primary treatment", failure_frequency=4.5, failure_duration=45), + ]) + + def test_out_of_range_or_fractional_values_are_rejected_with_a_message(self): + cases = [ + ("-1", "30", "failure frequency must be between 0 and 365 days per year"), + ("365.5", "30", "failure frequency must be between 0 and 365 days per year"), + ("0", "0", "failure duration must be between 1 and 1440 minutes"), + ("0", "1441", "failure duration must be between 1 and 1440 minutes"), + ("1", "2.5", "Enter a whole number."), + ("1", "", "This field is required."), + ("", "30", "This field is required."), + ] + for frequency, duration, message in cases: + with self.subTest(frequency=frequency, duration=duration): + data = self.new_configurator() + add_step(data, "Primary treatment", **LRVS, + failure_frequency=frequency, failure_duration=duration) + + response = self.post_configurator(data) + + assert_that(response.status_code).is_equal_to(200) + assert_that(response.content.decode()).contains(message) + assert_that(RiskAssessment.objects.filter(user=self.user).count()).is_equal_to(0) + + def test_range_bounds_are_accepted(self): + for frequency, duration in [("0", "1"), ("365", "1440"), ("0.5", "30")]: + with self.subTest(frequency=frequency, duration=duration): + data = self.new_configurator() + add_step(data, "Primary treatment", **LRVS, + failure_frequency=frequency, failure_duration=duration) + + response = self.post_configurator(data) + + assert_that(response.status_code).is_equal_to(302) + + def test_assessment_from_before_failure_inputs_reopens_with_defaults(self): + assessment = RiskAssessment.objects.create(user=self.user, events_per_year=365, volume_per_event=1) + for pathogen in ["Rotavirus", "Campylobacter jejuni", "Cryptosporidium parvum"]: + Inflow.objects.create(risk_assessment=assessment, pathogen=pathogen, min=10, max=100) + Treatment.objects.create(risk_assessment=assessment, name="Primary treatment", **LRVS) + + assert_that(failure_inputs_per_step(self.reopen(assessment))).is_equal_to([ + dict(name="Primary treatment", failure_frequency=0, failure_duration=30) + ]) + + def test_result_and_export_do_not_change_with_failure_inputs(self): + data = self.new_configurator() + add_step(data, "Primary treatment", **LRVS) + add_step(data, "Slow sand filtration", **LRVS) + assessment = self.save_new(data) + results_before = self.results(assessment) + export_before = self.export(assessment) + + data = self.reopen(assessment) + data["treatments-0-failure_frequency"] = "20" + data["treatments-0-failure_duration"] = "120" + data["treatments-1-failure_frequency"] = "365" + self.post_configurator(data, assessment) + + assert_that(failure_inputs_per_step(self.reopen(assessment))[0]["failure_frequency"]).is_equal_to(20) + assert_that(self.results(assessment)).is_equal_to(results_before) + assert_that(self.export(assessment)).is_equal_to(export_before) diff --git a/qmra/risk_assessment/tests/test_risk_assessment_form.py b/qmra/risk_assessment/tests/test_risk_assessment_form.py index e28527d..d1ae84b 100644 --- a/qmra/risk_assessment/tests/test_risk_assessment_form.py +++ b/qmra/risk_assessment/tests/test_risk_assessment_form.py @@ -87,7 +87,9 @@ def test_that_negative_are_allowed(self): viruses_min=-2, viruses_max=-1, protozoa_min=-2, - protozoa_max=-1 + protozoa_max=-1, + failure_frequency=0, + failure_duration=30, ) given_form = TreatmentForm(data=data) # ugly hack to work around dynamic choices... @@ -104,7 +106,9 @@ def test_that_min_needs_to_be_less_than_max(self): viruses_min=0, viruses_max=0, protozoa_min=0, - protozoa_max=0 + protozoa_max=0, + failure_frequency=0, + failure_duration=30, ) for prfx in ["bacteria", "viruses", "protozoa"]: mn = prfx + "_min" diff --git a/qmra/risk_assessment/user_models.py b/qmra/risk_assessment/user_models.py index 3b5f481..6f356c3 100644 --- a/qmra/risk_assessment/user_models.py +++ b/qmra/risk_assessment/user_models.py @@ -6,7 +6,7 @@ from crispy_forms.helper import FormHelper from crispy_forms.layout import Layout, Field, Row, Column, HTML, Submit -from qmra.risk_assessment.forms import _zero_if_none +from qmra.risk_assessment.forms import check_treatment_step from qmra.user.models import User @@ -184,17 +184,5 @@ def __init__(self, *args, **kwargs): 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 From 52e608788572594801104012f1a1ec5c94f6f0c7 Mon Sep 17 00:00:00 2001 From: ma-z-am Date: Fri, 25 Sep 2026 10:29:37 +0000 Subject: [PATCH 04/26] feat: failure only on steps with a positive LRV, and messages for guests (#22) A treatment step can have a failure frequency above 0 only if at least one of its six LRVs is positive (an empty LRV counts as 0), since a failure removes only positive LRVs (D4). The shared treatment step rule rejects it with a message on the failure frequency field. Guests: an invalid guest result now answers 422 with the error messages keyed by input name instead of an empty body. The configurator marks the affected inputs and treatment step and explains in the result pane why nothing ran; the marks clear on the next run. Registered users keep their path (the saved configurator re-renders with the errors). This also makes existing LRV errors visible to guests. Also fixes the treatments section from #21: its fixed height hid the failure fields and their messages under the footer; it now grows with its content. Refs #22 Co-Authored-By: Claude Opus 5.5 (1M context) --- qmra/risk_assessment/forms.py | 7 +- .../templates/risk-assessment-form-js.html | 57 ++++++- .../templates/treatments-form-fieldset.html | 2 +- .../tests/test_failure_inputs.py | 151 ++++++++++++++++-- qmra/risk_assessment/views.py | 24 ++- 5 files changed, 218 insertions(+), 23 deletions(-) diff --git a/qmra/risk_assessment/forms.py b/qmra/risk_assessment/forms.py index ce589e7..d08f4df 100644 --- a/qmra/risk_assessment/forms.py +++ b/qmra/risk_assessment/forms.py @@ -26,7 +26,8 @@ def _zero_if_none(x): return x if x is not None else 0 def check_treatment_step(form, cleaned_data): - """Minimum LRV at most maximum LRV per pathogen group, and failure inputs within their ranges. + """Minimum LRV at most maximum LRV per pathogen group, failure inputs within their ranges, + 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. @@ -38,6 +39,10 @@ def check_treatment_step(form, cleaned_data): 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}") + # 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): 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 %}