From 724a3997d7a23be448d176ac3075fb9d8f2bc9a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Buczy=C5=84ski?= Date: Wed, 30 Sep 2026 09:40:43 +0200 Subject: [PATCH 01/18] switch crispy forms to the bootstrap5 template pack The pages load Bootstrap 5 CSS, but forms were rendered with the bootstrap4 pack. --- cs_app/cs_app/settings.py | 5 +++-- cs_app/cs_app/test_settings.py | 5 +++-- pyproject.toml | 2 +- uv.lock | 12 ++++++------ 4 files changed, 13 insertions(+), 11 deletions(-) diff --git a/cs_app/cs_app/settings.py b/cs_app/cs_app/settings.py index 0a42ffe..f581d8b 100644 --- a/cs_app/cs_app/settings.py +++ b/cs_app/cs_app/settings.py @@ -46,7 +46,7 @@ "django.contrib.staticfiles", "import_export", "crispy_forms", - "crispy_bootstrap4", + "crispy_bootstrap5", "storages", "main.apps.MainConfig", "register.apps.RegisterConfig", @@ -121,7 +121,8 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" -CRISPY_TEMPLATE_PACK = "bootstrap4" +CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5" +CRISPY_TEMPLATE_PACK = "bootstrap5" LOGIN_REDIRECT_URL = "/" LOGOUT_REDIRECT_URL = "/" diff --git a/cs_app/cs_app/test_settings.py b/cs_app/cs_app/test_settings.py index 6d16e6a..e119823 100644 --- a/cs_app/cs_app/test_settings.py +++ b/cs_app/cs_app/test_settings.py @@ -35,7 +35,7 @@ "django.contrib.staticfiles", "import_export", "crispy_forms", - "crispy_bootstrap4", + "crispy_bootstrap5", "storages", "main.apps.MainConfig", "register.apps.RegisterConfig", @@ -109,7 +109,8 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" -CRISPY_TEMPLATE_PACK = "bootstrap4" +CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5" +CRISPY_TEMPLATE_PACK = "bootstrap5" LOGIN_REDIRECT_URL = "/" LOGOUT_REDIRECT_URL = "/" diff --git a/pyproject.toml b/pyproject.toml index 415d94d..010c87c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ dev = [ web = [ "Django>=4.2,<5.0", "Werkzeug==3.1.5", - "crispy-bootstrap4==2022.1", + "crispy-bootstrap5==2024.2", "python-dotenv==1.0.0", "django-crispy-forms==2.0", "django-import-export==3.2.0", diff --git a/uv.lock b/uv.lock index 8e151a5..c7dd06b 100644 --- a/uv.lock +++ b/uv.lock @@ -115,7 +115,7 @@ e2e = [ { name = "pytest-playwright" }, ] web = [ - { name = "crispy-bootstrap4" }, + { name = "crispy-bootstrap5" }, { name = "dj-database-url" }, { name = "django" }, { name = "django-crispy-forms" }, @@ -133,7 +133,7 @@ web = [ [package.metadata] requires-dist = [ - { name = "crispy-bootstrap4", marker = "extra == 'web'", specifier = "==2022.1" }, + { name = "crispy-bootstrap5", marker = "extra == 'web'", specifier = "==2024.2" }, { name = "dj-database-url", marker = "extra == 'web'", specifier = "==2.0.0" }, { name = "django", marker = "extra == 'web'", specifier = ">=4.2,<5.0" }, { name = "django-crispy-forms", marker = "extra == 'web'", specifier = "==2.0" }, @@ -401,16 +401,16 @@ toml = [ ] [[package]] -name = "crispy-bootstrap4" -version = "2022.1" +name = "crispy-bootstrap5" +version = "2024.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, { name = "django-crispy-forms" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5e/be/d8203e4dacbcee891875c741eb6f1d615da2f26a1367085adf278e2929d5/crispy-bootstrap4-2022.1.tar.gz", hash = "sha256:f18386c4d96180c1bb1212487d6e8f5a3dd1a9bb58d3032183973d4d130b55f9", size = 10941, upload-time = "2022-12-17T07:31:27.836Z" } +sdist = { url = "https://files.pythonhosted.org/packages/75/45/af5c7a13742650b1187a1a6db5197577b57f2b1ca3a042c3b386ddfbebfc/crispy-bootstrap5-2024.2.tar.gz", hash = "sha256:7d1fa40c6faf472e30e85c72551a3d2c9eedbf0abfff920683315e4e6f670f2b", size = 23411, upload-time = "2024-02-25T08:46:04.075Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/91/71/cbfa04246b137f69eefa58029e9247064284ea722ba140b07a4244cc8478/crispy_bootstrap4-2022.1-py3-none-any.whl", hash = "sha256:5241ab1dc2188c95560aa786439bcbedec7416e6b0f5a52dded82380810367ec", size = 22974, upload-time = "2022-12-17T07:31:26.408Z" }, + { url = "https://files.pythonhosted.org/packages/13/11/a1c22b4c803125901af7d2fa683f4c6eb14d511644da5fc0b45a0cb44cae/crispy_bootstrap5-2024.2-py3-none-any.whl", hash = "sha256:3867e320920a6ef156e94f9e0f06a80344c453e1b3bd96cd9dc0522ae9e9afb8", size = 24351, upload-time = "2024-02-25T08:46:02.171Z" }, ] [[package]] From ce82031f1fa951c9eaa40eff2a7e7dce902af6f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Buczy=C5=84ski?= Date: Wed, 30 Sep 2026 09:41:03 +0200 Subject: [PATCH 02/18] document the units of simulated timeseries rainfall and infiltration_loss are in mm/h (in/h), evaporation_loss in mm/day (in/day), runoff and runon in the model's FLOW_UNITS. --- README.md | 2 ++ src/catchment_simulation/catchment_features_simulation.py | 8 ++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0b50c48..3b940af 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,8 @@ model = FeaturesSimulation(subcatchment_id=subcatchment_id, raw_file=raw_file) ts = model.calculate_timeseries() # ts is a DataFrame with DatetimeIndex and columns: # rainfall, runoff, infiltration_loss, evaporation_loss, runon +# rainfall and infiltration_loss are in mm/h (in/h for US-unit models), +# evaporation_loss in mm/day (in/day), runoff and runon in the model's FLOW_UNITS ``` #### Collect timeseries for varying subcatchment parameter values. diff --git a/src/catchment_simulation/catchment_features_simulation.py b/src/catchment_simulation/catchment_features_simulation.py index 702ee43..fb5f825 100644 --- a/src/catchment_simulation/catchment_features_simulation.py +++ b/src/catchment_simulation/catchment_features_simulation.py @@ -195,7 +195,10 @@ def calculate_timeseries(self) -> pd.DataFrame: ------- pd.DataFrame DataFrame with a ``DatetimeIndex`` (name ``"datetime"``) and columns - corresponding to ``TIMESERIES_KEYS``. + corresponding to ``TIMESERIES_KEYS``, in SWMM's reporting units: + ``rainfall`` and ``infiltration_loss`` in mm/h (in/h for US-unit + models), ``evaporation_loss`` in mm/day (in/day), ``runoff`` and + ``runon`` in the model's ``FLOW_UNITS``. """ records: list[dict[str, Any]] = [] with Simulation(self.file) as sim: @@ -277,7 +280,8 @@ def simulate_subcatchment_timeseries( Returns ------- dict[float, pd.DataFrame] - Mapping of parameter values to DataFrames with timeseries data. + Mapping of parameter values to DataFrames with timeseries data, shaped + and in the units described in ``calculate_timeseries``. """ self._validate_simulation_params(start, stop, step) self.file = self.copy_file(self.raw_file) From 88032d303765a81b3b52e0d9ac3b076ba6e3b73f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Buczy=C5=84ski?= Date: Wed, 30 Sep 2026 09:41:23 +0200 Subject: [PATCH 03/18] keep session and CSRF cookies usable on the plain-HTTP dev server Secure cookies now default to on only when DEBUG is off: Safari drops Secure cookies over plain HTTP, so every POST on the dev server failed the CSRF check. Explicit settings still win. --- cs_app/cs_app/settings.py | 9 ++++-- cs_app/register/tests.py | 63 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/cs_app/cs_app/settings.py b/cs_app/cs_app/settings.py index f581d8b..2818624 100644 --- a/cs_app/cs_app/settings.py +++ b/cs_app/cs_app/settings.py @@ -149,8 +149,13 @@ # Production Security Settings (Fail-Closed) SECURE_SSL_REDIRECT = os.environ.get("SECURE_SSL_REDIRECT", "False").lower() == "true" -SESSION_COOKIE_SECURE = os.environ.get("SESSION_COOKIE_SECURE", "True").lower() == "true" -CSRF_COOKIE_SECURE = os.environ.get("CSRF_COOKIE_SECURE", "True").lower() == "true" +# Secure cookies unless DEBUG: the dev server speaks plain HTTP, and browsers +# that drop Secure cookies there (Safari) would reject every POST with a CSRF 403. +_SECURE_COOKIES_DEFAULT = "False" if DEBUG else "True" +SESSION_COOKIE_SECURE = ( + os.environ.get("SESSION_COOKIE_SECURE", _SECURE_COOKIES_DEFAULT).lower() == "true" +) +CSRF_COOKIE_SECURE = os.environ.get("CSRF_COOKIE_SECURE", _SECURE_COOKIES_DEFAULT).lower() == "true" SECURE_REFERRER_POLICY = "same-origin" diff --git a/cs_app/register/tests.py b/cs_app/register/tests.py index 7ce503c..9353dcb 100644 --- a/cs_app/register/tests.py +++ b/cs_app/register/tests.py @@ -1,3 +1,62 @@ -from django.test import TestCase +"""Tests for the session and CSRF cookie settings.""" -# Create your tests here. +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +# --------------------------------------------------------------------------- +# Production settings: cookie flags +# --------------------------------------------------------------------------- + +_READ_COOKIE_FLAGS = """ +import json, dotenv +dotenv.load_dotenv = lambda *args, **kwargs: False # only the env given below counts +from cs_app import settings +print(json.dumps([settings.SESSION_COOKIE_SECURE, settings.CSRF_COOKIE_SECURE])) +""" + + +def _cookie_flags(**env: str) -> list[bool]: + """Import cs_app.settings (not the test settings) in a clean process.""" + clean_env = { + key: value + for key, value in os.environ.items() + if key not in {"DEBUG", "SESSION_COOKIE_SECURE", "CSRF_COOKIE_SECURE"} + } + clean_env.update(SECRET_KEY="test", DATABASE_URL="sqlite:///:memory:", **env) + result = subprocess.run( + [sys.executable, "-c", _READ_COOKIE_FLAGS], + cwd=Path(__file__).resolve().parent.parent, + env=clean_env, + capture_output=True, + text=True, + check=True, + ) + return json.loads(result.stdout) + + +@pytest.mark.parametrize( + ("env", "expected"), + [ + # Plain-HTTP dev server: Secure cookies would be dropped by Safari -> CSRF 403. + ({"DEBUG": "True"}, [False, False]), + # Production fails closed. + ({}, [True, True]), + ({"DEBUG": "False"}, [True, True]), + # Explicit settings always win. + ( + {"DEBUG": "True", "SESSION_COOKIE_SECURE": "True", "CSRF_COOKIE_SECURE": "True"}, + [True, True], + ), + ( + {"DEBUG": "False", "SESSION_COOKIE_SECURE": "False", "CSRF_COOKIE_SECURE": "False"}, + [False, False], + ), + ], +) +def test_cookies_are_secure_unless_debug(env, expected): + assert _cookie_flags(**env) == expected From d82e10de8b9ffd16bac7f192b36a27ec699d32a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Buczy=C5=84ski?= Date: Wed, 30 Sep 2026 09:41:42 +0200 Subject: [PATCH 04/18] log in with a username or email and start signed in after sign-up A backend replacing ModelBackend accepts a username or an email, ignoring case, and refuses an email shared by two accounts. Registration rejects an email that is already in use and logs the new user in. --- cs_app/cs_app/settings.py | 3 + cs_app/cs_app/test_settings.py | 2 + cs_app/cs_app/urls.py | 10 +- cs_app/main/tests/test_content_pages.py | 29 ++++ cs_app/register/backends.py | 67 +++++++++ cs_app/register/forms.py | 49 +++++- cs_app/register/tests.py | 189 +++++++++++++++++++++++- cs_app/register/views.py | 38 +++-- 8 files changed, 359 insertions(+), 28 deletions(-) create mode 100644 cs_app/main/tests/test_content_pages.py create mode 100644 cs_app/register/backends.py diff --git a/cs_app/cs_app/settings.py b/cs_app/cs_app/settings.py index 2818624..cd0989c 100644 --- a/cs_app/cs_app/settings.py +++ b/cs_app/cs_app/settings.py @@ -109,6 +109,9 @@ }, ] +# Replaces ModelBackend (not alongside it): login() without backend= needs exactly one. +AUTHENTICATION_BACKENDS = ["register.backends.UsernameOrEmailBackend"] + LOGIN_URL = "/accounts/login/" LANGUAGE_CODE = "en-us" diff --git a/cs_app/cs_app/test_settings.py b/cs_app/cs_app/test_settings.py index e119823..d43bd19 100644 --- a/cs_app/cs_app/test_settings.py +++ b/cs_app/cs_app/test_settings.py @@ -96,6 +96,8 @@ }, ] +AUTHENTICATION_BACKENDS = ["register.backends.UsernameOrEmailBackend"] + LOGIN_URL = "/accounts/login/" LANGUAGE_CODE = "en-us" diff --git a/cs_app/cs_app/urls.py b/cs_app/cs_app/urls.py index 10154d9..e8ba4cc 100644 --- a/cs_app/cs_app/urls.py +++ b/cs_app/cs_app/urls.py @@ -1,14 +1,18 @@ -from django.contrib import admin -from django.urls import path, include -from django.conf.urls.static import static from django.conf import settings +from django.conf.urls.static import static +from django.contrib import admin +from django.contrib.auth.views import LoginView from django.contrib.staticfiles.urls import staticfiles_urlpatterns +from django.urls import include, path +from register.forms import LoginForm urlpatterns = ( [ path("admin/", admin.site.urls), path("", include("main.urls")), path("", include("register.urls")), + # Ahead of django.contrib.auth.urls so it wins the "login" name and path. + path("accounts/login/", LoginView.as_view(authentication_form=LoginForm), name="login"), path("accounts/", include("django.contrib.auth.urls")), ] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) diff --git a/cs_app/main/tests/test_content_pages.py b/cs_app/main/tests/test_content_pages.py new file mode 100644 index 0000000..5889c0e --- /dev/null +++ b/cs_app/main/tests/test_content_pages.py @@ -0,0 +1,29 @@ +"""The account pages.""" + +import uuid + +import pytest +from django.urls import reverse + +# Random per run, like the e2e credentials, so no password is written in the source. +SIGNUP_PASSWORD = f"T!{uuid.uuid4().hex}" + + +@pytest.mark.django_db +def test_register_success_logs_in_and_welcomes(client): + response = client.post( + reverse("register:register"), + data={ + "username": "newbie", + "email": "newbie@example.com", + "first_name": "New", + "last_name": "Bie", + "password1": SIGNUP_PASSWORD, + "password2": SIGNUP_PASSWORD, + }, + follow=True, + ) + + assert response.redirect_chain == [(reverse("main:main_view"), 302)] + assert response.context["user"].username == "newbie" + assert b"Welcome, New. Your account is ready." in response.content diff --git a/cs_app/register/backends.py b/cs_app/register/backends.py new file mode 100644 index 0000000..4f43114 --- /dev/null +++ b/cs_app/register/backends.py @@ -0,0 +1,67 @@ +""" +Authentication backend that accepts either a username or an email address. + +People sign up with both and routinely log in with whichever they remember, +so the login identifier is resolved to a username before the standard +ModelBackend password check runs. +""" + +from django.contrib.auth import get_user_model +from django.contrib.auth.backends import ModelBackend +from django.http import HttpRequest + +UserModel = get_user_model() + + +def resolve_username(identifier: str) -> str: + """ + Map a login identifier to the username of the account it names. + + Tried in order: the exact username, then the username and the email + address compared case-insensitively. A case-insensitive match counts only + when exactly one account has it; an ambiguous identifier is returned + unchanged, so it matches no account rather than an arbitrary one. + + Parameters + ---------- + identifier : str + What the user typed into the login field. + + Returns + ------- + str + The matching username, or ``identifier`` when no single account matches. + """ + identifier = identifier.strip() + users = UserModel._default_manager + username_field = UserModel.USERNAME_FIELD + if users.filter(**{username_field: identifier}).exists(): + return identifier + + lookups = [f"{username_field}__iexact"] + if "@" in identifier: + lookups.append(f"{UserModel.get_email_field_name()}__iexact") + for lookup in lookups: + matches = list(users.filter(**{lookup: identifier})[:2]) + if len(matches) == 1: + return matches[0].get_username() + return identifier + + +class UsernameOrEmailBackend(ModelBackend): + """ModelBackend whose login identifier may be a username or an email address.""" + + def authenticate( + self, + request: HttpRequest | None, + username: str | None = None, + password: str | None = None, + **kwargs, + ): + if username is None: + username = kwargs.get(UserModel.USERNAME_FIELD) + if username: + username = resolve_username(username) + # ModelBackend still runs the password hasher when no account matches, + # so a miss takes as long as a wrong password. + return super().authenticate(request, username=username, password=password, **kwargs) diff --git a/cs_app/register/forms.py b/cs_app/register/forms.py index 9da2fa4..77104a3 100644 --- a/cs_app/register/forms.py +++ b/cs_app/register/forms.py @@ -1,19 +1,21 @@ """ -This module defines a custom registration form for user sign-up, extending -the built-in Django UserCreationForm. +This module defines the account forms: sign-up and log in. -The custom form adds additional fields (email, first_name, and last_name) to -the standard registration form. +RegisterForm extends the built-in Django UserCreationForm with email, +first_name and last_name fields; the email must not belong to another account, +because it can be used to log in. LoginForm extends AuthenticationForm so the +login field accepts a username or an email address (see register.backends). """ + from django import forms -from django.contrib.auth.forms import UserCreationForm +from django.contrib.auth.forms import AuthenticationForm, UserCreationForm from django.contrib.auth.models import User class RegisterForm(UserCreationForm): - email = forms.EmailField() # Additional email field - first_name = forms.CharField() # Additional first_name field - last_name = forms.CharField() # Additional last_name field + email = forms.EmailField(widget=forms.EmailInput(attrs={"autocomplete": "email"})) + first_name = forms.CharField(widget=forms.TextInput(attrs={"autocomplete": "given-name"})) + last_name = forms.CharField(widget=forms.TextInput(attrs={"autocomplete": "family-name"})) class Meta: model = User # The User model from Django's authentication framework @@ -25,3 +27,34 @@ class Meta: "password1", # Password field, required by UserCreationForm "password2", # Password confirmation field, required by UserCreationForm ] + + def clean_email(self) -> str: + """Reject an email address already used by another account, ignoring case.""" + email = self.cleaned_data["email"] + if User.objects.filter(email__iexact=email).exists(): + raise forms.ValidationError( + "An account with this email already exists. Log in instead.", + code="duplicate_email", + ) + return email + + +class LoginForm(AuthenticationForm): + """Log in with a username or an email address and a password.""" + + error_messages = { + **AuthenticationForm.error_messages, + "invalid_login": ( + "Please enter a correct username or email and password. " + "Note that the password is case-sensitive." + ), + } + + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + username = self.fields["username"] + username.label = "Username or email" + # Room for a full email address; the stock limit is the username column length. + username.max_length = User._meta.get_field("email").max_length + username.widget.attrs["maxlength"] = username.max_length + username.widget.attrs["autocomplete"] = "username" diff --git a/cs_app/register/tests.py b/cs_app/register/tests.py index 9353dcb..7479905 100644 --- a/cs_app/register/tests.py +++ b/cs_app/register/tests.py @@ -1,12 +1,199 @@ -"""Tests for the session and CSRF cookie settings.""" +"""Tests for sign-up, log in (by username or email) and log out.""" import json import os import subprocess import sys +import uuid from pathlib import Path import pytest +from django.contrib.auth import SESSION_KEY, authenticate +from django.contrib.auth.models import User +from django.urls import reverse + +from register.forms import LoginForm, RegisterForm + +# Random per run, like the e2e credentials, so no password is written in the source. +PASSWORD = f"T!{uuid.uuid4().hex}" +OTHER_PASSWORD = f"T!{uuid.uuid4().hex}" +WRONG_PASSWORD = f"T!{uuid.uuid4().hex}" + + +@pytest.fixture +def account(db) -> User: + return User.objects.create_user(username="test1", email="test1@example.com", password=PASSWORD) + + +def _logged_in_user_id(client) -> str | None: + return client.session.get(SESSION_KEY) + + +# --------------------------------------------------------------------------- +# Authentication backend +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("identifier", ["test1", "TEST1", " test1 "]) +def test_authenticate_by_username_ignoring_case_and_padding(account, identifier): + assert authenticate(username=identifier, password=PASSWORD) == account + + +@pytest.mark.parametrize("identifier", ["test1@example.com", "Test1@Example.COM"]) +def test_authenticate_by_email_ignoring_case(account, identifier): + assert authenticate(username=identifier, password=PASSWORD) == account + + +@pytest.mark.parametrize( + "identifier", ["test1", "test1@example.com", "nobody", "nobody@example.com"] +) +def test_authenticate_rejects_wrong_password(account, identifier): + assert authenticate(username=identifier, password=WRONG_PASSWORD) is None + + +def test_authenticate_rejects_inactive_account(account): + account.is_active = False + account.save() + + assert authenticate(username="test1@example.com", password=PASSWORD) is None + + +def test_authenticate_refuses_email_shared_by_two_accounts(db): + # Accounts created before emails were unique (or by an admin) may share one. + User.objects.create_user(username="first", email="shared@example.com", password=PASSWORD) + second = User.objects.create_user( + username="second", email="SHARED@example.com", password=PASSWORD + ) + + assert authenticate(username="shared@example.com", password=PASSWORD) is None + assert authenticate(username="second", password=PASSWORD) == second + + +def test_exact_username_wins_over_another_accounts_email(account): + # "@" is a valid username character, so a username can look like an email. + owner = User.objects.create_user(username="test1@example.com", password=OTHER_PASSWORD) + + assert authenticate(username="test1@example.com", password=OTHER_PASSWORD) == owner + assert authenticate(username="test1@example.com", password=PASSWORD) is None + + +def test_usernames_differing_only_in_case_need_the_exact_username(db): + # Sign-up forbids this since Django 4.2; older databases can still hold both. + lower = User.objects.create_user(username="anna", password=PASSWORD) + upper = User.objects.create_user(username="Anna", password=PASSWORD) + + assert authenticate(username="anna", password=PASSWORD) == lower + assert authenticate(username="Anna", password=PASSWORD) == upper + assert authenticate(username="ANNA", password=PASSWORD) is None + + +# --------------------------------------------------------------------------- +# Log in / log out views +# --------------------------------------------------------------------------- + + +def test_login_form_asks_for_username_or_email(client): + html = client.get(reverse("login")).content.decode() + + assert "Username or email" in html + assert 'autocomplete="username"' in html + assert 'maxlength="254"' in html + + +@pytest.mark.parametrize("identifier", ["test1", "test1@example.com"]) +def test_login_with_username_or_email_redirects_home(client, account, identifier): + response = client.post(reverse("login"), {"username": identifier, "password": PASSWORD}) + + assert response.status_code == 302 + assert response.url == "/" + assert _logged_in_user_id(client) == str(account.pk) + + +def test_login_honours_next(client, account): + response = client.post( + reverse("login"), + {"username": "test1@example.com", "password": PASSWORD, "next": "/about"}, + ) + + assert response.status_code == 302 + assert response.url == "/about" + + +def test_login_with_wrong_password_shows_error(client, account): + response = client.post( + reverse("login"), {"username": "test1@example.com", "password": WRONG_PASSWORD} + ) + + assert response.status_code == 200 + assert _logged_in_user_id(client) is None + assert "Please enter a correct username or email and password." in response.content.decode() + + +def test_login_form_is_the_username_or_email_form(client): + response = client.get(reverse("login")) + + assert isinstance(response.context["form"], LoginForm) + + +def test_logout_requires_post(client, account): + client.force_login(account) + + response = client.post(reverse("logout")) + + assert response.status_code == 302 + assert response.url == "/" + assert _logged_in_user_id(client) is None + + +def test_login_enforces_csrf(account): + from django.test import Client + + csrf_client = Client(enforce_csrf_checks=True) + response = csrf_client.post(reverse("login"), {"username": "test1", "password": PASSWORD}) + + assert response.status_code == 403 + + +# --------------------------------------------------------------------------- +# Sign-up +# --------------------------------------------------------------------------- + + +def _signup_data(**overrides) -> dict: + data = { + "username": "test1", + "email": "test1@example.com", + "first_name": "Test", + "last_name": "User", + "password1": PASSWORD, + "password2": PASSWORD, + } + data.update(overrides) + return data + + +def test_register_rejects_email_used_by_another_account_ignoring_case(account): + form = RegisterForm(data=_signup_data(username="someone-else", email="TEST1@example.com")) + + assert not form.is_valid() + assert form.errors["email"] == ["An account with this email already exists. Log in instead."] + + +def test_register_then_logout_then_login_with_email(client, db): + response = client.post(reverse("register:register"), _signup_data()) + assert response.status_code == 302 + user = User.objects.get(username="test1") + assert _logged_in_user_id(client) == str(user.pk) + + client.post(reverse("logout")) + assert _logged_in_user_id(client) is None + + response = client.post( + reverse("login"), {"username": "test1@example.com", "password": PASSWORD} + ) + assert response.status_code == 302 + assert _logged_in_user_id(client) == str(user.pk) + # --------------------------------------------------------------------------- # Production settings: cookie flags diff --git a/cs_app/register/views.py b/cs_app/register/views.py index 886f575..1108ddb 100644 --- a/cs_app/register/views.py +++ b/cs_app/register/views.py @@ -5,25 +5,28 @@ using a custom registration form (RegisterForm). """ -from django.shortcuts import render -from django.shortcuts import redirect -from django.urls import reverse -from django.contrib.auth.models import Permission +import logging + +from django.contrib import messages +from django.contrib.auth import login from django.http import HttpRequest, HttpResponse +from django.shortcuts import redirect, render + from .forms import RegisterForm +logger = logging.getLogger(__name__) + -def register(response: HttpRequest) -> HttpResponse: +def register(request: HttpRequest) -> HttpResponse: """ Render the registration form and handle form submission. - The registration form is validated upon submission. If the form is valid, - the user is saved and redirected to the main view. If the form is invalid, - any errors are printed and the user remains on the registration page. + A valid submission creates the user, logs them in and redirects to the + home page. An invalid one re-renders the form with inline field errors. Parameters ---------- - response : HttpRequest + request : HttpRequest The incoming HTTP request. Returns @@ -31,13 +34,16 @@ def register(response: HttpRequest) -> HttpResponse: HttpResponse The HTTP response with the rendered registration template. """ - if response.method == "POST": - form = RegisterForm(response.POST) + if request.method == "POST": + form = RegisterForm(request.POST) if form.is_valid(): - form.save() - return redirect(reverse("main:main_view")) - else: - print("Form errors:", form.errors) + user = form.save() + login(request, user) + name = user.get_short_name() or user.get_username() + messages.success(request, f"Welcome, {name}. Your account is ready.") + return redirect("main:main_view") + # Field names only: the submitted values (and passwords) never reach the log. + logger.info("Registration rejected: invalid fields %s", sorted(form.errors)) else: form = RegisterForm() - return render(response, "account/register.html", {"form": form}) + return render(request, "account/register.html", {"form": form}) From 3e316e5c569626b15811da7da61b96a34ff5041a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Buczy=C5=84ski?= Date: Wed, 30 Sep 2026 09:42:07 +0200 Subject: [PATCH 05/18] rebuild the app shell with design tokens, dark mode and shared scripts Bootstrap 5.3 with SRI, design tokens and components, a light/dark/auto theme without a flash of the wrong one, a sticky navbar with a mobile toggler, an account menu, a skip link and toasts. Vendor scripts load only on the pages that use them. Replaces base.css and base_ui.js. --- cs_app/cs_app/templates/base.html | 194 ++-- cs_app/e2e/pages/nav_component.py | 112 ++- cs_app/e2e/test_core_js.py | 446 +++++++++ cs_app/e2e/test_navigation.py | 151 ++- cs_app/e2e/test_responsive.py | 79 +- cs_app/main/static/main/css/app.css | 943 ++++++++++++++++++ cs_app/main/static/main/css/base.css | 31 - cs_app/main/static/main/css/tokens.css | 436 ++++++++ cs_app/main/static/main/js/base_ui.js | 20 - cs_app/main/static/main/js/core/async-form.js | 350 +++++++ cs_app/main/static/main/js/core/auth-guard.js | 29 + cs_app/main/static/main/js/core/clipboard.js | 26 + cs_app/main/static/main/js/core/format.js | 70 ++ cs_app/main/static/main/js/core/forms.js | 104 ++ cs_app/main/static/main/js/core/table.js | 114 +++ cs_app/main/static/main/js/core/theme-init.js | 50 + cs_app/main/static/main/js/core/theme.js | 100 ++ cs_app/main/static/main/js/core/toast.js | 47 + cs_app/main/static/main/js/core/workbench.js | 36 + cs_app/main/tests/test_views.py | 165 +++ 20 files changed, 3361 insertions(+), 142 deletions(-) create mode 100644 cs_app/e2e/test_core_js.py create mode 100644 cs_app/main/static/main/css/app.css delete mode 100644 cs_app/main/static/main/css/base.css create mode 100644 cs_app/main/static/main/css/tokens.css delete mode 100644 cs_app/main/static/main/js/base_ui.js create mode 100644 cs_app/main/static/main/js/core/async-form.js create mode 100644 cs_app/main/static/main/js/core/auth-guard.js create mode 100644 cs_app/main/static/main/js/core/clipboard.js create mode 100644 cs_app/main/static/main/js/core/format.js create mode 100644 cs_app/main/static/main/js/core/forms.js create mode 100644 cs_app/main/static/main/js/core/table.js create mode 100644 cs_app/main/static/main/js/core/theme-init.js create mode 100644 cs_app/main/static/main/js/core/theme.js create mode 100644 cs_app/main/static/main/js/core/toast.js create mode 100644 cs_app/main/static/main/js/core/workbench.js diff --git a/cs_app/cs_app/templates/base.html b/cs_app/cs_app/templates/base.html index 7e52971..9b2dd23 100644 --- a/cs_app/cs_app/templates/base.html +++ b/cs_app/cs_app/templates/base.html @@ -1,77 +1,125 @@ {% load static %} - - + - - + - - + {% block title %}{% endblock %} - - - + + - - - - - - - {% block title %}{% endblock %} - + - - - - - - + + + {% block head_scripts %}{% endblock %} {% block style %}{% endblock %} -
-
-
- - Logo + Skip to content + +
+
-
+
{% if messages %} -
+
{% for message in messages %} - - -
-
-
-

© Rafał Buczyński 2023

- - Logo - - -
+
+ +
+ + - - + + + + + + + + + + {% block additional_scripts %}{% endblock %} diff --git a/cs_app/e2e/pages/nav_component.py b/cs_app/e2e/pages/nav_component.py index 6d71ddd..d9af8e5 100644 --- a/cs_app/e2e/pages/nav_component.py +++ b/cs_app/e2e/pages/nav_component.py @@ -1,4 +1,8 @@ -"""Navigation component POM — header navbar and footer.""" +"""Navigation component POM — header navbar, theme menu, account menu and footer. + +Below the ``lg`` breakpoint the navbar collapses behind a toggler; every +action opens it first, so callers do not need to care about the viewport. +""" from __future__ import annotations @@ -9,6 +13,8 @@ if TYPE_CHECKING: from playwright.sync_api import Page +THEME_MODES = ("light", "dark", "auto") + class NavComponent: """Header and footer navigation shared across all pages.""" @@ -17,49 +23,124 @@ def __init__(self, page: Page) -> None: self.page = page # ------------------------------------------------------------------ - # Header nav links + # Header structure # ------------------------------------------------------------------ @property def header(self) -> Locator: return self.page.locator("header") + @property + def toggler(self) -> Locator: + return self.header.locator(".navbar-toggler") + + @property + def menu(self) -> Locator: + return self.header.locator("#main-nav") + + def is_collapsed(self) -> bool: + """True when the hamburger is shown and the menu is closed.""" + return self.toggler.is_visible() and self.toggler.get_attribute("aria-expanded") != "true" + + def open_menu(self) -> None: + """Expand the collapsed navbar (no-op on wide viewports).""" + if self.is_collapsed(): + self.toggler.click() + self.header.locator("#main-nav.show").wait_for(state="visible") + + def link(self, name: str) -> Locator: + return self.header.get_by_role("link", name=name, exact=True) + + def current_link(self) -> Locator: + return self.header.locator("a[aria-current='page']") + + # ------------------------------------------------------------------ + # Header nav links + # ------------------------------------------------------------------ + + def _click_link(self, name: str) -> None: + self.open_menu() + self.link(name).click() + def click_home(self) -> None: - self.header.get_by_role("link", name="Home").click() + self._click_link("Home") def click_simulation(self) -> None: - self.header.get_by_role("link", name="Simulation").click() + self._click_link("Simulation") def click_timeseries(self) -> None: - self.header.get_by_role("link", name="Timeseries").click() + self._click_link("Timeseries") def click_calculations(self) -> None: - self.header.get_by_role("link", name="Calculations").click() + self._click_link("Calculations") def click_about(self) -> None: - self.header.get_by_role("link", name="About").click() + self._click_link("About") def click_logo(self) -> None: self.header.locator("a img[alt='Logo']").first.click() # ------------------------------------------------------------------ - # Auth buttons + # Auth / account # ------------------------------------------------------------------ + @property + def account_toggle(self) -> Locator: + return self.header.locator("#account-menu") + + @property + def logout_button(self) -> Locator: + """Logout submits a POST form, so it is a button rather than a link.""" + return self.header.get_by_role("button", name="Logout", exact=True) + + def open_account_menu(self) -> None: + self.open_menu() + if self.account_toggle.get_attribute("aria-expanded") != "true": + self.account_toggle.click() + self.logout_button.wait_for(state="visible") + def click_login(self) -> None: - self.header.get_by_role("link", name="Login").click() + self._click_link("Log in") def click_signup(self) -> None: - self.header.get_by_role("link", name="Sign-up").click() + self._click_link("Create account") + + def click_profile(self) -> None: + self.open_account_menu() + self.link("Profile").click() def click_logout(self) -> None: - self.header.get_by_role("link", name="Logout").click() + self.open_account_menu() + self.logout_button.click() def is_logged_in(self) -> bool: - return self.header.get_by_role("link", name="Logout").is_visible() + """Server rendered the account menu (independent of viewport/menu state).""" + return self.account_toggle.count() == 1 def is_logged_out(self) -> bool: - return self.header.get_by_role("link", name="Login").is_visible() + return self.link("Log in").count() == 1 and self.account_toggle.count() == 0 + + # ------------------------------------------------------------------ + # Theme + # ------------------------------------------------------------------ + + @property + def theme_toggle(self) -> Locator: + return self.header.locator("#theme-menu") + + def theme_option(self, mode: str) -> Locator: + return self.header.locator(f"[data-cs-theme-value='{mode}']") + + def set_theme(self, mode: str) -> None: + """Pick light/dark/auto from the theme menu.""" + if mode not in THEME_MODES: + raise ValueError(f"Unknown theme mode: {mode}") + self.open_menu() + self.theme_toggle.click() + self.theme_option(mode).click() + + def resolved_theme(self) -> str | None: + return self.page.locator("html").get_attribute("data-bs-theme") # ------------------------------------------------------------------ # Footer @@ -69,8 +150,11 @@ def is_logged_out(self) -> bool: def footer(self) -> Locator: return self.page.locator("footer") + def get_footer_link(self, name: str) -> Locator: + return self.footer.get_by_role("link", name=name, exact=True) + def get_footer_about_link(self) -> Locator: - return self.footer.get_by_role("link", name="About") + return self.get_footer_link("About") def get_footer_copyright(self) -> str: return self.footer.locator("p.text-muted").inner_text() diff --git a/cs_app/e2e/test_core_js.py b/cs_app/e2e/test_core_js.py new file mode 100644 index 0000000..a2b13cc --- /dev/null +++ b/cs_app/e2e/test_core_js.py @@ -0,0 +1,446 @@ +"""Contract tests for the shared core scripts (static/main/js/core/*). + +The run endpoints are mocked with Playwright routing so the client side of the +AJAX contract (DESIGN_SPEC §4) is exercised in isolation from the views. +""" + +from __future__ import annotations + +import json +import re + +import pytest +from playwright.sync_api import Page, Route, expect + +pytestmark = pytest.mark.e2e + +RUN_URL = "**/__cs_test_run__" +DOWNLOAD_URL = "**/__cs_test_download__" +TIMEOUT_TEXT = "The server did not finish the run." +ERROR_TOAST_TEXT = "The run did not complete." + +# Builds a run form on the current page and enhances it with CS.asyncForm. +MOUNT_FORM = """() => { + const main = document.getElementById("main-content"); + main.insertAdjacentHTML("beforeend", ` +
+ + + + + + +
+
`); + window.__events = []; + CS.asyncForm({ + form: document.getElementById("t-form"), + submitButton: document.getElementById("t-run"), + target: document.getElementById("t-results"), + loadingEl: document.getElementById("t-loading"), + successMessage: "Simulation finished", + onSuccess: (target) => window.__events.push(["success", target.id]), + onError: (error) => window.__events.push(["error", error.status, error.message]), + }); +}""" + + +def _mount(page: Page, live_server) -> None: + page.goto(f"{live_server.url}/about") + page.evaluate(MOUNT_FORM) + + +def _wait_for_route(page: Page, pending: list[Route]) -> None: + """Pump Playwright events until the intercepted request reaches the handler.""" + for _ in range(100): + if pending: + return + page.wait_for_timeout(20) + raise AssertionError("request was never routed") + + +def _json(route: Route, status: int, payload: dict) -> None: + route.fulfill(status=status, content_type="application/json", body=json.dumps(payload)) + + +class TestAsyncFormSuccess: + def test_busy_state_while_running_then_fragment_swapped_and_focused( + self, page: Page, live_server + ) -> None: + pending: list[Route] = [] + page.route(RUN_URL, lambda route: pending.append(route)) + _mount(page, live_server) + + page.locator("#t-run").click() + expect(page.locator("#t-run")).to_be_disabled() + expect(page.locator("#t-run")).to_have_attribute("aria-busy", "true") + expect(page.locator("#t-results")).to_have_attribute("aria-busy", "true") + expect(page.locator("#t-loading")).to_be_visible() + expect(page.locator("#t-loading [data-cs-elapsed]")).to_have_text(re.compile(r"^\d+ s$")) + + _wait_for_route(page, pending) + request = pending[0].request + assert request.method == "POST" + assert request.headers.get("x-requested-with") == "XMLHttpRequest" + assert 'name="csrfmiddlewaretoken"\r\n\r\nt\r\n' in (request.post_data or "") + pending[0].fulfill(status=200, content_type="text/html", body="

Results

42

") + + expect(page.locator("#t-results h3")).to_have_text("Results") + expect(page.locator("#t-results h3")).to_be_focused() + expect(page.locator("#t-run")).to_be_enabled() + expect(page.locator("#t-run")).not_to_have_attribute("aria-busy", "true") + expect(page.locator("#t-results")).not_to_have_attribute("aria-busy", "true") + expect(page.locator("#t-loading")).to_be_hidden() + assert page.evaluate("window.__events") == [["success", "t-results"]] + expect(page.locator(".toast-container .cs-toast--success")).to_have_text( + "Simulation finished" + ) + + def test_second_submit_while_running_is_ignored(self, page: Page, live_server) -> None: + pending: list[Route] = [] + page.route(RUN_URL, lambda route: pending.append(route)) + _mount(page, live_server) + + fetch_calls = page.evaluate( + """() => { + let calls = 0; + const realFetch = window.fetch; + window.fetch = (...args) => { calls += 1; return realFetch(...args); }; + const form = document.getElementById("t-form"); + form.requestSubmit(); + form.requestSubmit(); + return calls; + }""" + ) + assert fetch_calls == 1 + expect(page.locator("#t-run")).to_be_disabled() + _wait_for_route(page, pending) + pending[0].fulfill(status=200, content_type="text/html", body="

Done

") + expect(page.locator("#t-results h3")).to_have_text("Done") + + +class TestAsyncFormErrors: + def test_field_and_general_errors_render_inline_and_clear( + self, page: Page, live_server + ) -> None: + page.route( + RUN_URL, + lambda route: _json( + route, + 400, + { + "message": "Please correct the highlighted fields.", + "field_errors": {"start": ["Too large."], "__all__": ["Range too long."]}, + }, + ), + ) + _mount(page, live_server) + page.locator("#t-run").click() + + field = page.locator("#id_start") + expect(field).to_have_class(re.compile(r"\bis-invalid\b")) + expect(field).to_have_attribute("aria-invalid", "true") + expect(page.locator("#t-form .invalid-feedback")).to_have_text("Too large.") + expect(field).to_have_attribute("aria-describedby", "id_start-async-error") + alert = page.locator("#t-form .alert.alert-danger[role='alert']") + expect(alert).to_contain_text("Please correct the highlighted fields.") + expect(alert).to_contain_text("Range too long.") + expect(field).to_be_focused() + expect(page.locator("#t-run")).to_be_enabled() + expect(page.locator(".toast-container .cs-toast--danger")).to_contain_text(ERROR_TOAST_TEXT) + assert page.evaluate("window.__events") == [ + ["error", 400, "Please correct the highlighted fields."] + ] + + field.fill("2") + expect(field).not_to_have_class(re.compile(r"\bis-invalid\b")) + expect(page.locator("#t-form .invalid-feedback")).to_have_count(0) + + page.locator("#t-run").click() + expect(page.locator("#t-form .alert")).to_have_count(1) + + def test_field_errors_without_message_get_a_fields_summary( + self, page: Page, live_server + ) -> None: + page.route( + RUN_URL, + lambda route: _json(route, 400, {"message": "", "field_errors": {"start": ["Bad."]}}), + ) + _mount(page, live_server) + page.locator("#t-run").click() + + expect(page.locator("#t-form .alert-danger")).to_have_text( + "Please correct the highlighted fields." + ) + + @pytest.mark.parametrize( + "respond", + [ + lambda route: route.fulfill( + status=502, content_type="text/html", body="Bad gateway" + ), + lambda route: route.abort(), + ], + ids=["proxy-502-html", "network-failure"], + ) + def test_non_json_failures_show_timeout_message(self, page: Page, live_server, respond) -> None: + page.route(RUN_URL, respond) + _mount(page, live_server) + page.locator("#t-run").click() + + expect(page.locator("#t-form .alert-danger")).to_contain_text(TIMEOUT_TEXT) + expect(page.locator("#t-run")).to_be_enabled() + expect(page.locator("#t-run")).to_be_focused() + expect(page.locator("#t-loading")).to_be_hidden() + + @pytest.mark.parametrize( + ("content_type", "body"), + [ + ("application/json", json.dumps({"message": "Log in", "field_errors": {}})), + ("text/html", "

Unauthorized

"), + ], + ids=["json", "html"], + ) + def test_unauthenticated_redirects_to_login_with_next( + self, page: Page, live_server, content_type: str, body: str + ) -> None: + page.route( + RUN_URL, + lambda route: route.fulfill(status=401, content_type=content_type, body=body), + ) + _mount(page, live_server) + page.locator("#t-run").click() + + page.wait_for_url(re.compile(r".*/accounts/login/\?next=%2Fabout$")) + + +class TestAsyncFormReruns: + def test_failed_rerun_marks_kept_results_and_next_run_clears_previous_feedback( + self, page: Page, live_server + ) -> None: + responses = [ + lambda route: route.fulfill( + status=200, + content_type="text/html", + body="

First

", + ), + lambda route: _json(route, 400, {"message": "Bad range.", "field_errors": {}}), + lambda route: route.fulfill( + status=200, + content_type="text/html", + body="

Second

", + ), + ] + page.route(RUN_URL, lambda route: responses.pop(0)(route)) + _mount(page, live_server) + page.evaluate( + """() => document.getElementById("t-results").addEventListener( + "cs:beforeswap", (event) => window.__events.push(["beforeswap", event.target.id]))""" + ) + success_toasts = page.locator(".toast-container .cs-toast--success") + danger_toasts = page.locator(".toast-container .cs-toast--danger") + notice = page.locator("#t-results > .cs-stale-notice:first-child") + + page.locator("#t-run").click() + expect(page.locator("#t-results h3")).to_have_text("First") + expect(success_toasts).to_have_count(1) + expect(notice).to_have_count(0) + + page.locator("#t-run").click() + expect(notice).to_have_text(re.compile(r"^Showing results from the previous run")) + expect(page.locator("#t-results h3")).to_have_text("First") + expect(danger_toasts).to_have_count(1) + expect(success_toasts).to_have_count(0) + + page.locator("#t-run").click() + expect(page.locator("#t-results h3")).to_have_text("Second") + expect(page.locator("#t-results .cs-stale-notice")).to_have_count(0) + expect(danger_toasts).to_have_count(0) + expect(success_toasts).to_have_count(1) + assert [e for e in page.evaluate("window.__events") if e[0] == "beforeswap"] == [ + ["beforeswap", "t-results"], + ["beforeswap", "t-results"], + ] + + def test_failed_first_run_adds_no_stale_notice(self, page: Page, live_server) -> None: + page.route( + RUN_URL, lambda route: _json(route, 400, {"message": "Bad.", "field_errors": {}}) + ) + _mount(page, live_server) + page.locator("#t-run").click() + + expect(page.locator("#t-form .alert-danger")).to_have_text("Bad.") + expect(page.locator(".cs-stale-notice")).to_have_count(0) + + +class TestAsyncFormNativeSubmitters: + def test_download_button_submits_natively_without_loading( + self, page: Page, live_server + ) -> None: + run_calls: list[Route] = [] + page.route(RUN_URL, lambda route: run_calls.append(route)) + downloads: list[dict] = [] + + def fulfil_download(route: Route) -> None: + downloads.append( + { + "navigation": route.request.is_navigation_request(), + "xhr": route.request.headers.get("x-requested-with"), + } + ) + route.fulfill(status=200, content_type="text/html", body="

file

") + + page.route(DOWNLOAD_URL, fulfil_download) + _mount(page, live_server) + + page.locator("#t-download").click() + page.wait_for_url(re.compile(r".*/__cs_test_download__$")) + assert downloads == [{"navigation": True, "xhr": None}] + assert run_calls == [] + + +class TestAuthGuard: + def test_anonymous_action_button_redirects_to_login_with_next( + self, page: Page, live_server + ) -> None: + page.goto(f"{live_server.url}/about?tab=1") + page.evaluate( + """() => document.getElementById("main-content").insertAdjacentHTML( + "beforeend", '')""" + ) + page.get_by_role("button", name="Run", exact=True).click() + page.wait_for_url(re.compile(r".*/accounts/login/\?next=%2Fabout%3Ftab%3D1$")) + + +class TestToast: + def test_toast_renders_text_safely_and_can_be_dismissed(self, page: Page, live_server) -> None: + page.goto(f"{live_server.url}/about") + page.evaluate("CS.toast(' done', 'success')") + + toast = page.locator(".toast-container .toast.cs-toast--success") + expect(toast).to_be_visible() + expect(toast.locator(".toast-body")).to_have_text(" done") + expect(toast.locator("img")).to_have_count(0) + + toast.get_by_role("button", name="Close").click() + expect(page.locator(".toast-container .toast")).to_have_count(0) + + def test_unknown_level_falls_back_to_info(self, page: Page, live_server) -> None: + page.goto(f"{live_server.url}/about") + page.evaluate("CS.toast('hello', 'bogus')") + expect(page.locator(".toast-container .cs-toast--info")).to_be_visible() + + +class TestTableAndFormat: + MOUNT_TABLE = """() => { + document.getElementById("main-content").insertAdjacentHTML("beforeend", ` + + + + + + + +
NameRunoff
S102
S210
=SUM(A1), "x"-1.5
`); + CS.table.sortable(document.getElementById("t-table")); + }""" + + def _column(self, page: Page, index: int) -> list[str]: + return page.locator(f"#t-table tbody tr td:nth-child({index})").all_inner_texts() + + def test_numeric_aware_sorting_toggles_and_sets_aria_sort( + self, page: Page, live_server + ) -> None: + page.goto(f"{live_server.url}/about") + page.evaluate(self.MOUNT_TABLE) + runoff_header = page.locator("#t-table th").nth(1) + sort_button = runoff_header.get_by_role("button", name="Runoff") + + sort_button.click() + expect(runoff_header).to_have_attribute("aria-sort", "ascending") + assert self._column(page, 2) == ["-1.5", "2", "10"] + + sort_button.press("Enter") + expect(runoff_header).to_have_attribute("aria-sort", "descending") + assert self._column(page, 2) == ["10", "2", "-1.5"] + + name_header = page.locator("#t-table th").nth(0) + name_header.get_by_role("button", name="Name").click() + expect(name_header).to_have_attribute("aria-sort", "ascending") + expect(runoff_header).not_to_have_attribute("aria-sort", re.compile(".*")) + assert self._column(page, 1) == ['=SUM(A1), "x"', "S2", "S10"] + + def test_sortable_twice_binds_once(self, page: Page, live_server) -> None: + page.goto(f"{live_server.url}/about") + page.evaluate(self.MOUNT_TABLE) + page.evaluate("CS.table.sortable(document.getElementById('t-table'))") + header = page.locator("#t-table th").nth(1) + + header.get_by_role("button", name="Runoff").click() + expect(header).to_have_attribute("aria-sort", "ascending") + expect(header.locator(".cs-sort")).to_have_count(1) + + def test_csv_export_escapes_and_neutralises_formulas(self, page: Page, live_server) -> None: + page.goto(f"{live_server.url}/about") + page.evaluate(self.MOUNT_TABLE) + csv = page.evaluate("CS.table.toCSV(document.getElementById('t-table'))") + assert csv.split("\r\n") == [ + "Name,Runoff", + "S10,2", + "S2,10", + '"\'=SUM(A1), ""x""",-1.5', + ] + + def test_number_format(self, page: Page, live_server) -> None: + page.goto(f"{live_server.url}/about") + assert page.evaluate("CS.format.number(1234.5, 1)") == "1,234.5" + assert page.evaluate("CS.format.number('3.14159')") == "3.14" + assert page.evaluate("CS.format.number(null)") == "—" + + +class TestClipboard: + def test_copy_writes_text_and_confirms(self, page: Page, live_server) -> None: + page.context.grant_permissions(["clipboard-read", "clipboard-write"]) + page.goto(f"{live_server.url}/about") + + assert page.evaluate("CS.clipboard.copy('pip install catchment-simulation')") is True + assert page.evaluate("navigator.clipboard.readText()") == "pip install catchment-simulation" + expect(page.locator(".toast-container .cs-toast--success")).to_contain_text("Copied") + + +class TestChartsLifecycle: + """Charts about to be swapped out or already detached are purged (no leaked listeners).""" + + DRAW = """async () => { + const wrap = document.createElement("div"); + wrap.id = "t-charts"; + wrap.innerHTML = '
'; + document.getElementById("main-content").append(wrap); + const rows = [{x: 1, y: 2}, {x: 2, y: 3}]; + await CS.charts.line("t-a", rows, "x", "y"); + await CS.charts.line("t-b", rows, "x", "y"); + }""" + ATTACHED = "id => Boolean(document.getElementById(id)._responsiveChartHandler)" + + def test_before_swap_purges_charts_inside_the_target(self, page: Page, live_server) -> None: + page.goto(f"{live_server.url}/") + page.evaluate(self.DRAW) + assert page.evaluate(self.ATTACHED, "t-a") + + page.evaluate( + """() => document.getElementById("t-charts").dispatchEvent( + new CustomEvent("cs:beforeswap", {bubbles: true}))""" + ) + assert not page.evaluate(self.ATTACHED, "t-a") + assert not page.evaluate(self.ATTACHED, "t-b") + + def test_detached_chart_is_purged_on_next_render(self, page: Page, live_server) -> None: + page.goto(f"{live_server.url}/") + page.evaluate(self.DRAW) + detached = page.evaluate_handle( + """() => { const el = document.getElementById("t-a"); el.remove(); return el; }""" + ) + page.evaluate("""() => CS.charts.line("t-b", [{x: 1, y: 1}], "x", "y")""") + + assert not detached.evaluate("el => Boolean(el._responsiveChartHandler)") + assert page.evaluate(self.ATTACHED, "t-b") diff --git a/cs_app/e2e/test_navigation.py b/cs_app/e2e/test_navigation.py index 71122d8..b77bc49 100644 --- a/cs_app/e2e/test_navigation.py +++ b/cs_app/e2e/test_navigation.py @@ -1,4 +1,4 @@ -"""Navigation tests — header, footer, logo links. +"""Navigation tests — header, footer, logo links, theme and account menus. Note: /simulation and /timeseries have @login_required, so clicking those links as anonymous navigates to /accounts/login/?next=/simulation. The nav @@ -33,7 +33,7 @@ def test_simulation_link_redirects_anonymous_to_login(self, page: Page, live_ser page.goto(f"{live_server.url}/") nav = NavComponent(page) nav.click_simulation() - page.wait_for_load_state("domcontentloaded") + page.wait_for_url(re.compile(r".*/accounts/login/\?")) parsed = urlparse(page.url) assert parsed.path == "/accounts/login/" assert parse_qs(parsed.query).get("next") == ["/simulation"] @@ -43,7 +43,7 @@ def test_timeseries_link_redirects_anonymous_to_login(self, page: Page, live_ser page.goto(f"{live_server.url}/") nav = NavComponent(page) nav.click_timeseries() - page.wait_for_load_state("domcontentloaded") + page.wait_for_url(re.compile(r".*/accounts/login/\?")) parsed = urlparse(page.url) assert parsed.path == "/accounts/login/" assert parse_qs(parsed.query).get("next") == ["/timeseries"] @@ -90,12 +90,33 @@ def test_anonymous_user_sees_login_and_signup(self, page: Page, live_server) -> page.goto(f"{live_server.url}/") nav = NavComponent(page) assert nav.is_logged_out() - expect(page.get_by_role("link", name="Sign-up")).to_be_visible() + expect(page.get_by_role("link", name="Create account")).to_be_visible() def test_authenticated_user_sees_logout(self, auth_page: Page, live_server) -> None: auth_page.goto(f"{live_server.url}/") nav = NavComponent(auth_page) assert nav.is_logged_in() + nav.open_account_menu() + expect(nav.logout_button).to_be_visible() + + def test_account_menu_links_to_own_profile( + self, auth_page: Page, live_server, test_user + ) -> None: + auth_page.goto(f"{live_server.url}/") + nav = NavComponent(auth_page) + expect(nav.account_toggle).to_contain_text(test_user.username) + nav.click_profile() + expect(auth_page).to_have_url(re.compile(rf".*/user/{test_user.id}/profile$")) + + def test_account_menu_is_keyboard_operable(self, auth_page: Page, live_server) -> None: + auth_page.goto(f"{live_server.url}/") + nav = NavComponent(auth_page) + nav.account_toggle.focus() + auth_page.keyboard.press("Enter") + expect(nav.account_toggle).to_have_attribute("aria-expanded", "true") + expect(nav.link("Profile")).to_be_visible() + auth_page.keyboard.press("Escape") + expect(nav.account_toggle).to_have_attribute("aria-expanded", "false") def test_login_button_navigates_to_login_page(self, page: Page, live_server) -> None: page.goto(f"{live_server.url}/") @@ -110,6 +131,117 @@ def test_signup_button_navigates_to_register_page(self, page: Page, live_server) expect(page).to_have_url(re.compile(r".*/register/$")) +class TestActivePage: + """The current section is exposed with aria-current, not only colour.""" + + @pytest.mark.parametrize( + ("path", "name"), + [("/", "Home"), ("/about", "About"), ("/calculations", "Calculations")], + ) + def test_current_link_is_marked(self, page: Page, live_server, path: str, name: str) -> None: + page.goto(f"{live_server.url}{path}") + nav = NavComponent(page) + expect(nav.current_link()).to_have_count(1) + expect(nav.current_link()).to_have_text(name) + + def test_authenticated_simulation_is_marked(self, auth_page: Page, live_server) -> None: + auth_page.goto(f"{live_server.url}/simulation") + expect(NavComponent(auth_page).current_link()).to_have_text("Simulation") + + +class TestSkipLink: + def test_skip_link_is_first_tab_stop_and_targets_main(self, page: Page, live_server) -> None: + page.goto(f"{live_server.url}/about") + page.keyboard.press("Tab") + skip = page.get_by_role("link", name="Skip to content") + expect(skip).to_be_focused() + expect(skip).to_be_visible() + page.keyboard.press("Enter") + expect(page).to_have_url(re.compile(r".*/about#main-content$")) + expect(page.locator("main#main-content")).to_be_focused() + + +class TestThemeToggle: + """Light/dark/auto colour modes, persistence and the cs:themechange contract.""" + + def test_dark_mode_applies_persists_and_marks_choice(self, page: Page, live_server) -> None: + page.emulate_media(color_scheme="light") + page.goto(f"{live_server.url}/about") + nav = NavComponent(page) + assert nav.resolved_theme() == "light" + + nav.set_theme("dark") + assert nav.resolved_theme() == "dark" + expect(nav.theme_option("dark")).to_have_attribute("aria-pressed", "true") + expect(nav.theme_option("light")).to_have_attribute("aria-pressed", "false") + + page.reload() + assert nav.resolved_theme() == "dark" + assert page.evaluate("localStorage.getItem('cs-theme')") == "dark" + + def test_keyboard_choice_returns_focus_to_the_toggle(self, page: Page, live_server) -> None: + page.goto(f"{live_server.url}/about") + toggle = page.locator("#theme-menu") + toggle.focus() + page.keyboard.press("Enter") + page.keyboard.press("ArrowDown") + page.keyboard.press("ArrowDown") + expect(NavComponent(page).theme_option("dark")).to_be_focused() + + page.keyboard.press("Enter") + + assert NavComponent(page).resolved_theme() == "dark" + expect(toggle).to_be_focused() + + def test_theme_change_event_reports_resolved_theme(self, page: Page, live_server) -> None: + page.emulate_media(color_scheme="light") + page.goto(f"{live_server.url}/about") + page.evaluate( + """() => { + window.__themeEvents = []; + document.addEventListener("cs:themechange", (e) => window.__themeEvents.push(e.detail.theme)); + }""" + ) + nav = NavComponent(page) + nav.set_theme("dark") + nav.set_theme("auto") + assert page.evaluate("window.__themeEvents") == ["dark", "light"] + + def test_auto_follows_system_preference_live(self, page: Page, live_server) -> None: + page.emulate_media(color_scheme="light") + page.goto(f"{live_server.url}/about") + nav = NavComponent(page) + nav.set_theme("auto") + assert page.evaluate("localStorage.getItem('cs-theme')") is None + page.evaluate( + """() => { + window.__themeEvents = []; + document.addEventListener("cs:themechange", (e) => window.__themeEvents.push(e.detail.theme)); + }""" + ) + + page.emulate_media(color_scheme="dark") + page.wait_for_function("document.documentElement.dataset.bsTheme === 'dark'") + assert page.evaluate("window.__themeEvents") == ["dark"] + + def test_stored_theme_applies_before_body_is_parsed(self, page: Page, live_server) -> None: + """theme-init.js runs in , so the theme is set before any content can paint.""" + page.emulate_media(color_scheme="light") + page.add_init_script( + """try { localStorage.setItem('cs-theme', 'dark'); } catch (e) {} + new MutationObserver((mutations, observer) => { + if (document.body) { + window.__themeAtBody = document.documentElement.dataset.bsTheme; + observer.disconnect(); + } + }).observe(document, { childList: true, subtree: true });""" + ) + page.goto(f"{live_server.url}/about") + assert page.evaluate("window.__themeAtBody") == "dark" + bg = page.evaluate("getComputedStyle(document.body).backgroundColor") + assert bg == "rgb(11, 22, 32)" + + class TestFooter: """Footer link and content tests.""" @@ -130,3 +262,14 @@ def test_footer_logo_navigates_home(self, page: Page, live_server) -> None: nav = NavComponent(page) nav.click_footer_logo() expect(page).to_have_url(re.compile(r".*/$")) + + def test_footer_project_links(self, page: Page, live_server) -> None: + page.goto(f"{live_server.url}/") + nav = NavComponent(page) + expect(nav.get_footer_link("Contact")).to_have_attribute("href", "/contact") + expect(nav.get_footer_link("GitHub")).to_have_attribute( + "href", "https://github.com/BuczynskiRafal/catchments_simulation" + ) + expect(nav.get_footer_link("PyPI")).to_have_attribute( + "href", "https://pypi.org/project/catchment-simulation/" + ) diff --git a/cs_app/e2e/test_responsive.py b/cs_app/e2e/test_responsive.py index 1ee7a81..a88b4b5 100644 --- a/cs_app/e2e/test_responsive.py +++ b/cs_app/e2e/test_responsive.py @@ -1,8 +1,8 @@ """Responsive layout tests. Verifies that the app works correctly at different viewport sizes. -The current nav uses ``flex-wrap`` (not a collapsible hamburger menu), -so we test that content wraps properly instead of checking for a toggler. +The navbar is ``navbar-expand-lg``: below 992px the links collapse behind a +toggler, at and above it they are always visible. Simulation/Timeseries pages require @login_required, so responsive tests for those pages use auth_page. @@ -10,10 +10,13 @@ from __future__ import annotations +import re + import pytest from playwright.sync_api import Page, expect from .pages.home_page import HomePage +from .pages.nav_component import NavComponent pytestmark = pytest.mark.e2e @@ -23,6 +26,7 @@ "desktop": {"width": 1920, "height": 1080}, "bootstrap_breakpoint": {"width": 992, "height": 768}, } +COLLAPSED = {"mobile", "tablet"} class TestResponsiveHome: @@ -46,15 +50,25 @@ def test_heading_visible_at_all_viewports( VIEWPORTS.items(), ids=VIEWPORTS.keys(), ) - def test_nav_links_visible_at_all_viewports( + def test_nav_links_reachable_at_all_viewports( self, page: Page, live_server, viewport_name: str, viewport: dict ) -> None: - """Nav uses flex-wrap, so all links should remain visible (wrapped, not collapsed).""" + """Links are visible on lg+, and one toggler click away below it.""" page.set_viewport_size(viewport) page.goto(f"{live_server.url}/") - # Use header-scoped selector to avoid ambiguity with footer "About" link - expect(page.locator("header").get_by_role("link", name="Home")).to_be_visible() - expect(page.locator("header").get_by_role("link", name="About")).to_be_visible() + nav = NavComponent(page) + + if viewport_name in COLLAPSED: + expect(nav.toggler).to_be_visible() + expect(nav.toggler).to_have_attribute("aria-expanded", "false") + expect(nav.link("Home")).to_be_hidden() + nav.open_menu() + expect(nav.toggler).to_have_attribute("aria-expanded", "true") + else: + expect(nav.toggler).to_be_hidden() + + for name in ("Home", "Simulation", "Timeseries", "Calculations", "About", "Log in"): + expect(nav.link(name)).to_be_visible() def test_no_horizontal_overflow_mobile(self, page: Page, live_server) -> None: page.set_viewport_size(VIEWPORTS["mobile"]) @@ -66,6 +80,32 @@ def test_no_horizontal_overflow_mobile(self, page: Page, live_server) -> None: body_width <= viewport_width + 5 ), f"Horizontal overflow detected: body={body_width}px > viewport={viewport_width}px" + def test_mobile_menu_navigates_and_closes(self, page: Page, live_server) -> None: + page.set_viewport_size(VIEWPORTS["mobile"]) + page.goto(f"{live_server.url}/") + nav = NavComponent(page) + nav.click_about() + expect(page).to_have_url(re.compile(r".*/about$")) + expect(nav.toggler).to_have_attribute("aria-expanded", "false") + + def test_mobile_theme_menu_is_usable(self, page: Page, live_server) -> None: + page.set_viewport_size(VIEWPORTS["mobile"]) + page.emulate_media(color_scheme="light") + page.goto(f"{live_server.url}/") + nav = NavComponent(page) + nav.set_theme("dark") + assert nav.resolved_theme() == "dark" + + def test_no_horizontal_overflow_mobile_with_menu_open( + self, auth_page: Page, live_server + ) -> None: + auth_page.set_viewport_size(VIEWPORTS["mobile"]) + auth_page.goto(f"{live_server.url}/") + nav = NavComponent(auth_page) + nav.open_account_menu() + overflow = auth_page.evaluate("document.body.scrollWidth - window.innerWidth") + assert overflow <= 5, f"Horizontal overflow with menu open: {overflow}px" + class TestResponsiveSimulation: """Simulation page form layout at different viewports (requires auth).""" @@ -83,3 +123,28 @@ def test_simulation_form_usable( # Form and button should be visible expect(auth_page.locator("#id_option")).to_be_visible() expect(auth_page.locator("#run-simulation-button")).to_be_visible() + + +class TestWorkbenchPanel: + """The control panel sticks only while it fits; it never scrolls inside itself.""" + + @pytest.mark.parametrize( + ("height", "sticky"), [(1080, True), (600, False)], ids=["fits", "taller"] + ) + def test_panel_sticks_only_when_it_fits( + self, auth_page: Page, live_server, height: int, sticky: bool + ) -> None: + auth_page.set_viewport_size({"width": 1366, "height": height}) + auth_page.goto(f"{live_server.url}/simulation") + panel = auth_page.locator(".cs-panel") + + if sticky: + expect(panel).to_have_class(re.compile(r"\bis-sticky\b")) + else: + expect(panel).not_to_have_class(re.compile(r"\bis-sticky\b")) + assert panel.evaluate("el => el.scrollHeight <= el.clientHeight") + + def test_panel_is_not_sticky_below_lg(self, auth_page: Page, live_server) -> None: + auth_page.set_viewport_size({"width": 768, "height": 1024}) + auth_page.goto(f"{live_server.url}/simulation") + expect(auth_page.locator(".cs-panel")).not_to_have_class(re.compile(r"\bis-sticky\b")) diff --git a/cs_app/main/static/main/css/app.css b/cs_app/main/static/main/css/app.css new file mode 100644 index 0000000..6d82a5f --- /dev/null +++ b/cs_app/main/static/main/css/app.css @@ -0,0 +1,943 @@ +/* + * App shell and shared components. Tokens live in tokens.css. + * + * Shell + * .cs-skip-link "Skip to content" link (with .visually-hidden-focusable) + * .cs-header / .cs-brand sticky navbar; nav links mark the current page with aria-current="page" + * .cs-theme-toggle theme dropdown; items are [data-cs-theme-value] buttons with aria-pressed + * .content-wrapper
; grows so the footer sits at the bottom + * .cs-footer site footer + * .cs-toast toast created by CS.toast(); level via .cs-toast--success|info|warning|danger + * + * Page scaffolding + * .cs-page-header page intro block: .cs-eyebrow (small caps label), .cs-page-header__title + * (h1/h2), .cs-page-header__lead (one-line purpose) + * .cs-card surface card; .cs-card__title for its heading + * .cs-workbench two-column grid ≥ lg: .cs-panel (360px controls; sticky via .is-sticky from + * core/workbench.js while it fits the viewport) + .cs-canvas (results); + * stacks below lg + * .cs-step numbered panel section: .cs-step__header > .cs-step__num + .cs-step__title, + * then content; consecutive steps are divided by a rule + * .cs-hint muted note in a panel; --note adds a side rule, --count sets a live run count + * in mono; .is-warning turns it into an ochre warning + * .cs-segmented segmented control: .btn-check radios + .cs-segmented__option labels + * .cs-progress run progress row: spinner, .cs-progress__elapsed, [role=status] text + * + * Results + * .cs-metrics responsive grid of .cs-metric tiles + * .cs-metric tile: .cs-metric__label, .cs-metric__value (mono, tabular), .cs-metric__unit + * [data-cs-results] root of rendered results; dimmed while its async target is aria-busy + * .cs-stale-notice note above results kept after a failed re-run (added by CS.asyncForm) + * .cs-empty-state placeholder before the first run: .cs-empty-state__icon, __title, __text + * .cs-chart-card card for a chart: .cs-chart-card__toolbar (title + actions), .cs-chart-card__body + * .cs-table-wrap scroll container that keeps the .cs-table header sticky; shadows mark + * edges with more table to scroll to + * .cs-table data table (use with .table): sticky thead with wrapping body-font labels, + * .cs-num cells right-aligned (mono in the body); sortable headers hold a .cs-sort button, th[aria-sort] drives the indicator + * .cs-actions row of export/secondary actions under results + * .cs-reveal entrance animation for a freshly rendered results region; its .cs-metric + * tiles are staggered by 40ms + * + * Code + * .cs-code wrapper for a code block with an optional .cs-code__copy button slot + * .code-snippet
 code block; Prism token colours for both themes live here
+ *                            (only prism.js is loaded, no Prism theme stylesheet)
+ *
+ * Utilities
+ *   .cs-mono                 IBM Plex Mono with tabular numerals (file names, parameter values)
+ */
+
+/* ------------------------------------------------------------------ */
+/* Base                                                                */
+/* ------------------------------------------------------------------ */
+
+html {
+    scroll-padding-top: calc(var(--cs-header-h) + 1rem);
+}
+
+body {
+    display: flex;
+    flex-direction: column;
+    min-height: 100vh;
+    background-color: var(--cs-paper);
+    color: var(--cs-ink);
+    -webkit-font-smoothing: antialiased;
+}
+
+h1, h2, h3,
+.h1, .h2, .h3 {
+    font-family: var(--cs-font-display);
+    font-stretch: var(--cs-display-stretch);
+    font-weight: 650;
+    line-height: var(--cs-lh-display);
+    letter-spacing: -0.01em;
+}
+
+h1, .h1 { font-size: var(--cs-fs-h1); }
+h2, .h2 { font-size: var(--cs-fs-h2); }
+h3, .h3 { font-size: var(--cs-fs-h3); }
+
+h4, .h4 {
+    font-size: var(--cs-fs-h4);
+    font-weight: 600;
+}
+
+h5, .h5,
+h6, .h6 {
+    font-weight: 600;
+}
+
+small, .small {
+    font-size: var(--cs-fs-small);
+}
+
+a,
+.btn,
+.nav-link,
+.dropdown-item {
+    transition: color var(--cs-transition), background-color var(--cs-transition),
+        border-color var(--cs-transition);
+}
+
+code, kbd, samp, pre {
+    font-family: var(--cs-font-mono);
+}
+
+.cs-mono {
+    font-family: var(--cs-font-mono);
+    font-variant-numeric: tabular-nums;
+}
+
+/* One focus ring everywhere (spec §2.3). Bootstrap replaces the outline with
+   component box-shadows in its own :focus rules, so those are listed explicitly. */
+:focus-visible,
+.btn:focus-visible,
+.btn-close:focus-visible,
+.nav-link:focus-visible,
+.navbar-toggler:focus-visible,
+.dropdown-item:focus-visible,
+.accordion-button:focus-visible,
+.page-link:focus-visible,
+.form-control:focus-visible,
+.form-select:focus-visible,
+.form-check-input:focus-visible,
+.form-range:focus-visible {
+    outline: 3px solid var(--cs-focus-ring);
+    outline-offset: 2px;
+    box-shadow: none;
+}
+
+.btn-close:focus,
+.navbar-toggler:focus,
+.accordion-button:focus {
+    box-shadow: none;
+}
+
+.cs-skip-link:focus {
+    position: fixed !important;
+    top: 0.75rem;
+    left: 0.75rem;
+    z-index: 1090;
+    padding: 0.5rem 1rem;
+    border-radius: var(--cs-radius-sm);
+    background-color: var(--cs-water);
+    color: var(--cs-on-accent);
+    font-weight: 600;
+    text-decoration: none;
+}
+
+/* ------------------------------------------------------------------ */
+/* Header                                                              */
+/* ------------------------------------------------------------------ */
+
+.cs-header {
+    background-color: var(--cs-surface);
+    border-bottom: 1px solid var(--cs-line);
+}
+
+.cs-header .navbar {
+    --bs-navbar-color: var(--cs-ink-muted);
+    --bs-navbar-hover-color: var(--cs-ink);
+    --bs-navbar-active-color: var(--cs-ink);
+    --bs-navbar-brand-color: var(--cs-ink);
+    --bs-navbar-brand-hover-color: var(--cs-ink);
+    --bs-navbar-toggler-border-color: var(--cs-line);
+    min-height: var(--cs-header-h);
+}
+
+.cs-brand {
+    display: inline-flex;
+    align-items: center;
+    gap: 0.625rem;
+}
+
+.cs-brand img {
+    width: auto;
+    height: 2.25rem;
+}
+
+.cs-brand__wordmark {
+    font-family: var(--cs-font-display);
+    font-stretch: 125%;
+    font-weight: 700;
+    font-size: 1.0625rem;
+    letter-spacing: -0.01em;
+}
+
+.cs-header .nav-link {
+    position: relative;
+    font-weight: 500;
+}
+
+.cs-header .nav-link[aria-current="page"] {
+    color: var(--cs-ink);
+}
+
+/* Accent underline on the current page; a left bar in the collapsed menu. */
+.cs-header .nav-link[aria-current="page"]::after {
+    content: "";
+    position: absolute;
+    left: 0;
+    top: 0.5rem;
+    bottom: 0.5rem;
+    width: 2px;
+    border-radius: 2px;
+    background-color: var(--cs-water);
+}
+
+.cs-header .navbar-nav .nav-link[aria-current="page"] {
+    padding-left: 0.75rem;
+}
+
+@media (min-width: 992px) {
+    .cs-header .navbar-nav .nav-link,
+    .cs-header .navbar-nav .nav-link[aria-current="page"] {
+        padding-inline: 0.75rem;
+    }
+
+    .cs-header .nav-link[aria-current="page"]::after {
+        top: auto;
+        right: 0.75rem;
+        bottom: -0.2rem;
+        left: 0.75rem;
+        width: auto;
+        height: 2px;
+    }
+}
+
+.cs-nav-tools {
+    gap: 0.5rem;
+}
+
+/* Between lg and xl the row is only just wide enough: the wordmark stacks on two lines. */
+@media (min-width: 992px) and (max-width: 1199.98px) {
+    .cs-brand__wordmark {
+        width: min-content;
+        white-space: normal; /* .navbar-brand sets nowrap */
+        font-size: 0.9375rem;
+        line-height: 1.1;
+    }
+
+    .cs-header .navbar > .container {
+        column-gap: 0.75rem;
+    }
+
+    .cs-header .navbar-nav .nav-link,
+    .cs-header .navbar-nav .nav-link[aria-current="page"] {
+        padding-inline: 0.5rem;
+    }
+
+    .cs-header .nav-link[aria-current="page"]::after {
+        right: 0.5rem;
+        left: 0.5rem;
+    }
+}
+
+@media (max-width: 991.98px) {
+    /* The header is sticky, so an open menu taller than the viewport must scroll itself. */
+    .cs-header .navbar-collapse {
+        max-height: calc(100vh - var(--cs-header-h));
+        overflow-y: auto;
+        margin-inline: -0.375rem;
+    }
+
+    /* Room for the 5px focus ring, which the scroll container would clip. */
+    .cs-header .navbar-collapse > .navbar-nav {
+        padding-inline: 0.375rem;
+    }
+
+    .cs-header .navbar-collapse > .navbar-nav:first-child {
+        padding-top: 0.375rem;
+    }
+
+    .cs-nav-tools {
+        align-items: flex-start;
+        padding: 0.75rem 0 1rem;
+        border-top: 1px solid var(--cs-line);
+    }
+}
+
+.cs-theme-toggle {
+    display: inline-flex;
+    align-items: center;
+    gap: 0.5rem;
+}
+
+.cs-theme-toggle svg,
+.cs-theme-menu svg {
+    width: 1.125rem;
+    height: 1.125rem;
+    flex: none;
+}
+
+/* The icon alone carries the control on wide screens; the label stays for AT. */
+@media (min-width: 992px) {
+    .cs-theme-toggle__text {
+        position: absolute;
+        width: 1px;
+        height: 1px;
+        margin: -1px;
+        overflow: hidden;
+        clip: rect(0, 0, 0, 0);
+        white-space: nowrap;
+        border: 0;
+    }
+}
+
+.cs-theme-menu .dropdown-item {
+    display: flex;
+    align-items: center;
+    gap: 0.5rem;
+}
+
+.cs-theme-menu .cs-check {
+    margin-left: auto;
+    visibility: hidden;
+}
+
+.cs-theme-menu [aria-pressed="true"] {
+    font-weight: 600;
+}
+
+.cs-theme-menu [aria-pressed="true"] .cs-check {
+    visibility: visible;
+}
+
+/* ------------------------------------------------------------------ */
+/* Main, messages, footer, toasts                                      */
+/* ------------------------------------------------------------------ */
+
+.content-wrapper {
+    flex: 1 0 auto;
+    padding-top: 2rem;
+    padding-bottom: 4rem;
+}
+
+/* 
is only focusable as the skip-link target; it is not a control, so no ring. */ +#main-content:focus { + outline: none; +} + +.cs-messages .alert { + border-radius: var(--cs-radius); +} + +.alert { + --bs-alert-border-radius: var(--cs-radius); +} + +.cs-footer { + padding-block: 1.5rem; + border-top: 1px solid var(--cs-line); + background-color: var(--cs-surface); + font-size: var(--cs-fs-small); +} + +.cs-footer .nav-link { + --bs-nav-link-color: var(--cs-ink-muted); + --bs-nav-link-hover-color: var(--cs-water); + padding-inline: 0.5rem; +} + +.cs-footer__logo img { + width: auto; + height: 1.75rem; +} + +.cs-toast { + border-left: 4px solid var(--cs-toast-accent, var(--cs-water)); +} + +.cs-toast--success { --cs-toast-accent: var(--cs-success); } +.cs-toast--warning { --cs-toast-accent: var(--cs-ochre); } +.cs-toast--danger { --cs-toast-accent: var(--cs-danger); } + +.cs-toast .toast-body { + color: var(--cs-ink); +} + +/* ------------------------------------------------------------------ */ +/* Page scaffolding */ +/* ------------------------------------------------------------------ */ + +.cs-eyebrow { + margin-bottom: 0.5rem; + color: var(--cs-ink-muted); + font-family: var(--cs-font-body); + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.cs-page-header { + margin-bottom: 2rem; +} + +.cs-page-header__title { + margin-bottom: 0.5rem; +} + +.cs-page-header__lead { + max-width: 64ch; + margin-bottom: 0; + color: var(--cs-ink-muted); + font-size: 1.0625rem; +} + +.cs-card { + padding: 1.5rem; + border: 1px solid var(--cs-line); + border-radius: var(--cs-radius); + background-color: var(--cs-surface); + box-shadow: var(--cs-shadow); +} + +.cs-card__title { + margin-bottom: 1rem; + font-size: var(--cs-fs-h4); +} + +.cs-workbench { + display: grid; + gap: 2rem; + align-items: start; +} + +.cs-panel, +.cs-canvas { + min-width: 0; +} + +.cs-panel { + padding: 1.5rem; + border: 1px solid var(--cs-line); + border-radius: var(--cs-radius); + background-color: var(--cs-surface); + box-shadow: var(--cs-shadow); +} + +@media (min-width: 992px) { + .cs-workbench { + grid-template-columns: 360px minmax(0, 1fr); + } + + /* Set by core/workbench.js only while the whole panel fits the viewport, so the + run button is never out of reach inside a scrolled panel. */ + .cs-panel.is-sticky { + position: sticky; + top: calc(var(--cs-header-h) + 1rem); + } +} + +.cs-canvas { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.cs-step + .cs-step { + margin-top: 1.5rem; + padding-top: 1.5rem; + border-top: 1px solid var(--cs-line); +} + +.cs-step__header { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 1rem; +} + +.cs-step__num { + display: inline-grid; + place-items: center; + flex: none; + width: 1.75rem; + height: 1.75rem; + border: 1.5px solid var(--cs-water); + border-radius: 50%; + color: var(--cs-water); + font-family: var(--cs-font-mono); + font-size: var(--cs-fs-caption); + font-weight: 500; +} + +.cs-step__title { + margin: 0; + font-size: 1.0625rem; +} + +.cs-hint { + margin: 0 0 1rem; + color: var(--cs-ink-muted); + font-size: var(--cs-fs-caption); +} + +.cs-hint--note { + padding: 0.5rem 0.75rem; + border-left: 3px solid var(--cs-water); + border-radius: 0 var(--cs-radius-sm) var(--cs-radius-sm) 0; + background-color: var(--cs-surface-sunken); +} + +.cs-hint--count { + font-family: var(--cs-font-mono); + font-variant-numeric: tabular-nums; +} + +/* A live run count gives way to the server's field errors, which say the same thing. */ +fieldset:has(.is-invalid) > .cs-hint--count { + display: none; +} + +/* A warning, not an error: the server makes the final call. */ +.cs-hint.is-warning { + color: var(--cs-ochre-text); + font-family: var(--cs-font-body); + font-weight: 500; +} + +/* Number fields keep their spinner: in narrow range fields the invalid icon would crowd + it, and the red border and message already mark the error. */ +.form-control[type="number"].is-invalid { + padding-right: 0.75rem; + background-image: none; +} + +/* Segmented control: .btn-check radios, each followed by its .cs-segmented__option label. */ +.cs-segmented { + display: grid; + grid-auto-columns: minmax(0, 1fr); + grid-auto-flow: column; + padding: 0.25rem; + border: 1px solid var(--cs-line); + border-radius: var(--cs-radius); + background-color: var(--cs-surface-sunken); +} + +.cs-segmented__option { + --bs-btn-padding-y: 0.4rem; + --bs-btn-font-size: 0.9375rem; + --bs-btn-border-width: 0; + --bs-btn-color: var(--cs-ink-muted); + --bs-btn-hover-color: var(--cs-ink); + --bs-btn-active-color: var(--cs-ink); + --bs-btn-active-bg: var(--cs-surface); + border-radius: var(--cs-radius-sm); + font-weight: 500; +} + +.btn-check:checked + .cs-segmented__option { + background-color: var(--cs-surface); + color: var(--cs-ink); + box-shadow: var(--cs-shadow); +} + +.btn-check:focus-visible + .cs-segmented__option { + outline: 3px solid var(--cs-focus-ring); + outline-offset: 2px; + box-shadow: none; +} + +/* Run progress under the run button (toggled with [hidden] by CS.asyncForm). */ +.cs-progress { + display: flex; + align-items: flex-start; + gap: 0.625rem; + margin-top: 0.875rem; + color: var(--cs-ink-muted); + font-size: var(--cs-fs-caption); +} + +.cs-progress .spinner-border { + flex: none; + margin-top: 0.125rem; + color: var(--cs-water); +} + +/* Before the status text, so a wrapped sentence is never split by the counter. */ +.cs-progress__elapsed { + flex: none; + color: var(--cs-ink); + font-family: var(--cs-font-mono); + font-variant-numeric: tabular-nums; +} + +/* ------------------------------------------------------------------ */ +/* Results */ +/* ------------------------------------------------------------------ */ + +.cs-metrics { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); + gap: 1rem; +} + +.cs-metric { + padding: 1rem 1.25rem; + border: 1px solid var(--cs-line); + border-radius: var(--cs-radius); + background-color: var(--cs-surface); +} + +.cs-metric__label { + margin-bottom: 0.25rem; + color: var(--cs-ink-muted); + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.cs-metric__value { + margin: 0; + color: var(--cs-ink); + font-family: var(--cs-font-mono); + font-size: 1.5rem; + font-weight: 500; + font-variant-numeric: tabular-nums; + line-height: 1.2; +} + +/* Phones fit two tiles per row; a smaller value keeps "8 h 55 min" on one line there. */ +@media (max-width: 575.98px) { + .cs-metric__value { + font-size: 1.25rem; + } +} + +.cs-metric__unit { + margin-left: 0.25rem; + color: var(--cs-ink-muted); + font-size: var(--cs-fs-caption); + font-weight: 400; +} + +/* Previous results ([data-cs-results]) stay readable but visibly stale during a re-run. */ +[aria-busy="true"] [data-cs-results] { + opacity: 0.55; + transition: opacity var(--cs-transition); +} + +/* Added by CS.asyncForm above results kept from an earlier run after a failed re-run. */ +.cs-stale-notice { + margin: 0 0 1rem; + padding: 0.5rem 0.75rem; + border-left: 3px solid var(--cs-ochre); + border-radius: 0 var(--cs-radius-sm) var(--cs-radius-sm) 0; + background-color: var(--cs-surface-sunken); + color: var(--cs-ink-muted); + font-size: var(--cs-fs-small); +} + +.cs-empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.5rem; + min-height: 18rem; + padding: 2.5rem 1.5rem; + border: 1.5px dashed var(--cs-line); + border-radius: var(--cs-radius-lg); + color: var(--cs-ink-muted); + text-align: center; +} + +.cs-empty-state__icon { + width: 2.5rem; + height: 2.5rem; + color: var(--cs-water); +} + +.cs-empty-state__title { + margin: 0; + color: var(--cs-ink); + font-size: var(--cs-fs-h4); +} + +.cs-empty-state__text { + max-width: 44ch; + margin: 0; +} + +.cs-chart-card { + border: 1px solid var(--cs-line); + border-radius: var(--cs-radius); + background-color: var(--cs-surface); + box-shadow: var(--cs-shadow); +} + +.cs-chart-card__toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.5rem 1rem; + padding: 0.875rem 1.25rem; + border-bottom: 1px solid var(--cs-line); +} + +.cs-chart-card__title { + margin: 0; + font-size: 1.0625rem; +} + +.cs-chart-card__body { + padding: 0.75rem; +} + +/* + * Scroll cue: a shadow darkens each edge that has more table behind it. The + * surface-coloured covers scroll with the content (local) and hide the fixed + * (scroll) shadows once an end is reached; the cells are transparent (tokens.css). + */ +.cs-table-wrap { + max-height: 32rem; + overflow: auto; + border: 1px solid var(--cs-line); + border-radius: var(--cs-radius); + background: + linear-gradient(to right, var(--cs-surface) 30%, transparent) left center / 2.5rem 100% no-repeat local, + linear-gradient(to left, var(--cs-surface) 30%, transparent) right center / 2.5rem 100% no-repeat local, + radial-gradient(farthest-side at 0 50%, var(--cs-scroll-shadow), transparent) left center / 1rem 100% no-repeat scroll, + radial-gradient(farthest-side at 100% 50%, var(--cs-scroll-shadow), transparent) right center / 1rem 100% no-repeat scroll, + var(--cs-surface); +} + +.cs-table { + margin-bottom: 0; + font-size: var(--cs-fs-small); +} + +.cs-table thead th { + position: sticky; + top: 0; + z-index: 1; + background-color: var(--cs-surface-sunken); + color: var(--cs-ink-muted); + font-weight: 600; + vertical-align: bottom; + box-shadow: inset 0 -1px 0 var(--cs-line); +} + +/* Row headers (a name, a timestamp, a swept value) identify the row; they need no extra weight. */ +.cs-table tbody th { + font-weight: 500; +} + +.cs-table .cs-num { + text-align: right; +} + +/* Only values are set in mono; header labels ("Runoff volume [m³]") wrap in the body font. */ +.cs-table tbody .cs-num { + font-family: var(--cs-font-mono); + font-variant-numeric: tabular-nums; +} + +.cs-sort { + display: inline-flex; + align-items: center; + gap: 0.375rem; + width: 100%; + padding: 0; + border: 0; + background: none; + color: inherit; + font: inherit; + text-align: inherit; + cursor: pointer; +} + +.cs-num .cs-sort { + justify-content: flex-end; +} + +.cs-sort:hover { + color: var(--cs-ink); +} + +/* Sort indicator: both arrows faded when unsorted, the active one solid. + A mask (not text) so it never enters the button's accessible name. */ +.cs-sort::after { + content: ""; + flex: none; + width: 0.5rem; + height: 0.75rem; + background-color: currentColor; + -webkit-mask: var(--cs-sort-icon) center / contain no-repeat; + mask: var(--cs-sort-icon) center / contain no-repeat; + opacity: 0.35; + --cs-sort-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 12'%3e%3cpath d='M4 0 8 5H0zM4 12 0 7h8z'/%3e%3c/svg%3e"); +} + +th[aria-sort="ascending"] .cs-sort::after { + --cs-sort-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 12'%3e%3cpath d='M4 0 8 5H0z'/%3e%3c/svg%3e"); + opacity: 1; +} + +th[aria-sort="descending"] .cs-sort::after { + --cs-sort-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 12'%3e%3cpath d='M4 12 0 7h8z'/%3e%3c/svg%3e"); + opacity: 1; +} + +th[aria-sort] .cs-sort { + color: var(--cs-ink); +} + +.cs-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + padding-top: 1rem; + border-top: 1px solid var(--cs-line); +} + +@keyframes cs-rise { + from { + opacity: 0; + transform: translateY(8px); + } + + to { + opacity: 1; + transform: none; + } +} + +.cs-reveal, +.cs-reveal .cs-metric { + animation: cs-rise 220ms ease-out both; +} + +.cs-reveal .cs-metric:nth-child(2) { animation-delay: 40ms; } +.cs-reveal .cs-metric:nth-child(3) { animation-delay: 80ms; } +.cs-reveal .cs-metric:nth-child(4) { animation-delay: 120ms; } +.cs-reveal .cs-metric:nth-child(5) { animation-delay: 160ms; } +.cs-reveal .cs-metric:nth-child(n + 6) { animation-delay: 200ms; } + +/* ------------------------------------------------------------------ */ +/* Code */ +/* ------------------------------------------------------------------ */ + +.cs-code { + position: relative; +} + +.cs-code__copy { + position: absolute; + top: 0.5rem; + right: 0.5rem; +} + +pre.code-snippet { + margin: 0 0 1rem; + padding: 1rem 1.25rem; + overflow: auto; + border: 1px solid var(--cs-line); + border-radius: var(--cs-radius); + background: var(--cs-surface-sunken); + color: var(--cs-ink); + font-family: var(--cs-font-mono); + font-size: 0.875rem; + line-height: 1.6; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.cs-code pre.code-snippet { + padding-right: 4.5rem; +} + +pre.code-snippet code { + background: none; + color: inherit; + font-family: inherit; + font-size: inherit; + white-space: inherit; +} + +.code-snippet .token.comment, +.code-snippet .token.prolog { + color: var(--cs-ink-muted); + font-style: italic; +} + +.code-snippet .token.keyword, +.code-snippet .token.builtin, +.code-snippet .token.boolean { + color: var(--cs-water-strong); + font-weight: 500; +} + +.code-snippet .token.string, +.code-snippet .token.triple-quoted-string { + color: var(--bs-success-text-emphasis); +} + +.code-snippet .token.number { + color: var(--cs-ochre-text); +} + +.code-snippet .token.function, +.code-snippet .token.class-name, +.code-snippet .token.decorator { + color: var(--cs-water); +} + +.code-snippet .token.operator, +.code-snippet .token.punctuation { + color: var(--cs-ink-muted); +} + +/* ------------------------------------------------------------------ */ +/* Motion preferences */ +/* ------------------------------------------------------------------ */ + +/* Set by core/theme.js for one frame while the colour mode changes. */ +.cs-theme-switching *, +.cs-theme-switching *::before, +.cs-theme-switching *::after { + transition: none !important; +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-delay: 0ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } + + .cs-reveal, + .cs-reveal .cs-metric { + animation: none; + transform: none; + } +} diff --git a/cs_app/main/static/main/css/base.css b/cs_app/main/static/main/css/base.css deleted file mode 100644 index 32b46ab..0000000 --- a/cs_app/main/static/main/css/base.css +++ /dev/null @@ -1,31 +0,0 @@ -html, -body { - height: 100%; - background-color: #fff; -} - -.content-wrapper { - min-height: calc(90% - 100px); -} - -/* Default dropzone reset. Upload page styles provide the full look. */ -.dropzone { - border-radius: 0.5rem; -} - -.dz-message { - border-radius: 0.25rem; -} - -.code-snippet { - border: 1px solid #ccc; - border-radius: 4px; - padding: 1em; - white-space: pre-wrap; - font-family: monospace; - background-color: #f0f0f0; -} - -pre[class*="code-snippet"] { - background-color: #f0f0f0; -} diff --git a/cs_app/main/static/main/css/tokens.css b/cs_app/main/static/main/css/tokens.css new file mode 100644 index 0000000..b981cfe --- /dev/null +++ b/cs_app/main/static/main/css/tokens.css @@ -0,0 +1,436 @@ +/* + * Design tokens — "survey sheet" palette (docs/ui-modernization/DESIGN_SPEC.md §2). + * + * --cs-* tokens are the source of truth; the --bs-* block under each theme maps + * them onto Bootstrap 5.3 so stock components inherit the palette. Must load + * after bootstrap.min.css (same selectors, later wins). Theme is selected by + * , set before paint by core/theme-init.js. + */ + +:root, +[data-bs-theme="light"] { + /* Palette */ + --cs-ink: #0F2233; + --cs-ink-rgb: 15, 34, 51; + --cs-ink-muted: #4A5E6D; + --cs-ink-muted-rgb: 74, 94, 109; + --cs-paper: #F4F7F9; + --cs-paper-rgb: 244, 247, 249; + --cs-surface: #FFFFFF; + --cs-surface-sunken: #EBF1F5; + --cs-line: #D6E1E8; + --cs-water: #0B6E8A; + --cs-water-rgb: 11, 110, 138; + --cs-water-strong: #08566C; + --cs-water-strong-rgb: 8, 86, 108; + --cs-on-accent: #FFFFFF; /* text on water/danger/success fills */ + --cs-field-border: #808E99; /* 70% ink-muted over surface: 3:1 field edge */ + --cs-rain: #5A6FD6; + --cs-ochre: #B7791F; + --cs-ochre-text: #8A5A12; + --cs-danger: #B42318; + --cs-danger-rgb: 180, 35, 24; + --cs-success: #1F7A4D; + --cs-success-rgb: 31, 122, 77; + /* Chart-only categorical series (§2.1) */ + --cs-soil: #346400; + --cs-evap: #8A5A9E; + + /* Bootstrap mapping */ + --bs-body-color: var(--cs-ink); + --bs-body-color-rgb: var(--cs-ink-rgb); + --bs-body-bg: var(--cs-paper); + --bs-body-bg-rgb: var(--cs-paper-rgb); + --bs-emphasis-color: var(--cs-ink); + --bs-emphasis-color-rgb: var(--cs-ink-rgb); + --bs-heading-color: var(--cs-ink); + --bs-secondary-color: var(--cs-ink-muted); + --bs-secondary-color-rgb: var(--cs-ink-muted-rgb); + --bs-secondary-bg: var(--cs-surface-sunken); + --bs-tertiary-color: var(--cs-ink-muted); + --bs-tertiary-bg: var(--cs-surface-sunken); + --bs-tertiary-bg-rgb: 235, 241, 245; + --bs-border-color: var(--cs-line); + --bs-border-color-translucent: var(--cs-line); + + --bs-primary: var(--cs-water); + --bs-primary-rgb: var(--cs-water-rgb); + --bs-primary-text-emphasis: var(--cs-water-strong); + --bs-primary-bg-subtle: #E4F1F5; + --bs-primary-border-subtle: #B3D6E1; + --bs-link-color: var(--cs-water); + --bs-link-color-rgb: var(--cs-water-rgb); + --bs-link-hover-color: var(--cs-water-strong); + --bs-link-hover-color-rgb: var(--cs-water-strong-rgb); + --bs-code-color: var(--cs-ochre-text); + + --bs-danger: var(--cs-danger); + --bs-danger-rgb: var(--cs-danger-rgb); + --bs-danger-text-emphasis: #8F1C13; + --bs-danger-bg-subtle: #FDECEA; + --bs-danger-border-subtle: #F5C2BD; + --bs-success: var(--cs-success); + --bs-success-rgb: var(--cs-success-rgb); + --bs-success-text-emphasis: #175C3A; + --bs-success-bg-subtle: #E6F4EC; + --bs-success-border-subtle: #B5DCC6; + --bs-warning-text-emphasis: #6E480E; + --bs-warning-bg-subtle: #FBF1E1; + --bs-warning-border-subtle: #EBCB95; + --bs-info-text-emphasis: var(--cs-water-strong); + --bs-info-bg-subtle: #E4F1F5; + --bs-info-border-subtle: #B3D6E1; + + --bs-form-valid-color: var(--cs-success); + --bs-form-valid-border-color: var(--cs-success); + --bs-form-invalid-color: var(--cs-danger); + --bs-form-invalid-border-color: var(--cs-danger); + + --cs-shadow: 0 1px 2px rgb(15 34 51 / .06), 0 4px 12px rgb(15 34 51 / .05); + --cs-scroll-shadow: rgb(15 34 51 / .3); + --bs-box-shadow: var(--cs-shadow); + --bs-box-shadow-sm: 0 1px 2px rgb(15 34 51 / .06); +} + +[data-bs-theme="dark"] { + --cs-ink: #E3ECF2; + --cs-ink-rgb: 227, 236, 242; + --cs-ink-muted: #9DB1BF; + --cs-ink-muted-rgb: 157, 177, 191; + --cs-paper: #0B1620; + --cs-paper-rgb: 11, 22, 32; + --cs-surface: #122330; + --cs-surface-sunken: #0E1C27; + --cs-line: #23394A; + --cs-water: #3FB8D6; + --cs-water-rgb: 63, 184, 214; + --cs-water-strong: #6FCDE3; + --cs-water-strong-rgb: 111, 205, 227; + --cs-on-accent: #0B1620; + --cs-field-border: #738694; + --cs-rain: #8C9CF0; + --cs-ochre: #E0A546; + --cs-ochre-text: #E9B96A; + --cs-danger: #F97066; + --cs-danger-rgb: 249, 112, 102; + --cs-success: #4CC38A; + --cs-success-rgb: 76, 195, 138; + --cs-soil: #C1EB95; + --cs-evap: #C29AD6; + + --bs-body-color: var(--cs-ink); + --bs-body-color-rgb: var(--cs-ink-rgb); + --bs-body-bg: var(--cs-paper); + --bs-body-bg-rgb: var(--cs-paper-rgb); + --bs-emphasis-color: var(--cs-ink); + --bs-emphasis-color-rgb: var(--cs-ink-rgb); + --bs-heading-color: var(--cs-ink); + --bs-secondary-color: var(--cs-ink-muted); + --bs-secondary-color-rgb: var(--cs-ink-muted-rgb); + --bs-secondary-bg: var(--cs-surface-sunken); + --bs-tertiary-color: var(--cs-ink-muted); + --bs-tertiary-bg: var(--cs-surface-sunken); + --bs-tertiary-bg-rgb: 14, 28, 39; + --bs-border-color: var(--cs-line); + --bs-border-color-translucent: var(--cs-line); + + --bs-primary: var(--cs-water); + --bs-primary-rgb: var(--cs-water-rgb); + --bs-primary-text-emphasis: var(--cs-water-strong); + --bs-primary-bg-subtle: #0E2733; + --bs-primary-border-subtle: #1E4E61; + --bs-link-color: var(--cs-water); + --bs-link-color-rgb: var(--cs-water-rgb); + --bs-link-hover-color: var(--cs-water-strong); + --bs-link-hover-color-rgb: var(--cs-water-strong-rgb); + --bs-code-color: var(--cs-ochre-text); + + --bs-danger: var(--cs-danger); + --bs-danger-rgb: var(--cs-danger-rgb); + --bs-danger-text-emphasis: #FCA69F; + --bs-danger-bg-subtle: #2A1517; + --bs-danger-border-subtle: #6B2A26; + --bs-success: var(--cs-success); + --bs-success-rgb: var(--cs-success-rgb); + --bs-success-text-emphasis: #7FD9AE; + --bs-success-bg-subtle: #0F2A1F; + --bs-success-border-subtle: #1F5A3D; + --bs-warning-text-emphasis: var(--cs-ochre-text); + --bs-warning-bg-subtle: #2A2012; + --bs-warning-border-subtle: #6B4F1E; + --bs-info-text-emphasis: var(--cs-water-strong); + --bs-info-bg-subtle: #0E2733; + --bs-info-border-subtle: #1E4E61; + + --bs-form-valid-color: var(--cs-success); + --bs-form-valid-border-color: var(--cs-success); + --bs-form-invalid-color: var(--cs-danger); + --bs-form-invalid-border-color: var(--cs-danger); + + /* Dark mode relies on borders, not shadows (§2.3). */ + --cs-shadow: none; + /* A scroll cue, not elevation, so it stays in dark mode. */ + --cs-scroll-shadow: rgb(0 0 0 / .75); + --bs-box-shadow: none; + --bs-box-shadow-sm: none; +} + +/* + * Theme-independent tokens. Also matched on [data-bs-theme] so nested theme + * scopes keep the fonts (Bootstrap resets them there) and re-resolve the ring. + */ +:root, +[data-bs-theme] { + --cs-font-display: "Archivo", "Public Sans", system-ui, sans-serif; + --cs-font-body: "Public Sans", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + --cs-font-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + --cs-display-stretch: 118%; + + --cs-fs-caption: 0.8125rem; + --cs-fs-small: 0.9375rem; + --cs-fs-body: 1rem; + --cs-fs-h4: 1.25rem; + --cs-fs-h3: 1.5rem; + --cs-fs-h2: clamp(1.625rem, 1.3rem + 1.2vw, 2rem); + --cs-fs-h1: clamp(2rem, 1.4rem + 2.4vw, 2.75rem); + --cs-lh-body: 1.55; + --cs-lh-display: 1.15; + + --cs-radius-sm: 6px; + --cs-radius: 10px; + --cs-radius-lg: 16px; + + --cs-header-h: 4rem; + --cs-transition: 120ms ease; + + --bs-body-font-family: var(--cs-font-body); + --bs-body-font-size: var(--cs-fs-body); + --bs-body-line-height: var(--cs-lh-body); + --bs-font-sans-serif: var(--cs-font-body); + --bs-font-monospace: var(--cs-font-mono); + --bs-border-radius-sm: var(--cs-radius-sm); + --bs-border-radius: var(--cs-radius-sm); + --bs-border-radius-lg: var(--cs-radius); + --bs-border-radius-xl: var(--cs-radius-lg); + --bs-focus-ring-width: 3px; + --cs-focus-ring: color-mix(in srgb, var(--cs-water) 45%, transparent); + --bs-focus-ring-color: var(--cs-focus-ring); +} + +/* + * Bootstrap component mappings. These components compile the default blue + * into literals or component variables, so --bs-primary alone does not reach + * them. Values reference --cs-* tokens and therefore follow the theme. + */ +.btn-primary { + --bs-btn-color: var(--cs-on-accent); + --bs-btn-bg: var(--cs-water); + --bs-btn-border-color: var(--cs-water); + --bs-btn-hover-color: var(--cs-on-accent); + --bs-btn-hover-bg: var(--cs-water-strong); + --bs-btn-hover-border-color: var(--cs-water-strong); + --bs-btn-active-color: var(--cs-on-accent); + --bs-btn-active-bg: var(--cs-water-strong); + --bs-btn-active-border-color: var(--cs-water-strong); + --bs-btn-disabled-color: var(--cs-on-accent); + --bs-btn-disabled-bg: var(--cs-water); + --bs-btn-disabled-border-color: var(--cs-water); +} + +.btn-outline-primary { + --bs-btn-color: var(--cs-water); + --bs-btn-border-color: var(--cs-water); + --bs-btn-hover-color: var(--cs-on-accent); + --bs-btn-hover-bg: var(--cs-water); + --bs-btn-hover-border-color: var(--cs-water); + --bs-btn-active-color: var(--cs-on-accent); + --bs-btn-active-bg: var(--cs-water-strong); + --bs-btn-active-border-color: var(--cs-water-strong); + --bs-btn-disabled-color: var(--cs-water); + --bs-btn-disabled-border-color: var(--cs-water); +} + +.btn-outline-secondary { + --bs-btn-color: var(--cs-ink-muted); + --bs-btn-border-color: var(--cs-field-border); + --bs-btn-hover-color: var(--cs-ink); + --bs-btn-hover-bg: var(--cs-surface-sunken); + --bs-btn-hover-border-color: var(--cs-ink-muted); + --bs-btn-active-color: var(--cs-ink); + --bs-btn-active-bg: var(--cs-line); + --bs-btn-active-border-color: var(--cs-ink-muted); + --bs-btn-disabled-color: var(--cs-ink-muted); + --bs-btn-disabled-border-color: var(--cs-line); +} + +.btn-danger { + --bs-btn-color: var(--cs-on-accent); + --bs-btn-bg: var(--cs-danger); + --bs-btn-border-color: var(--cs-danger); + --bs-btn-hover-color: var(--cs-on-accent); + --bs-btn-hover-bg: var(--bs-danger-text-emphasis); + --bs-btn-hover-border-color: var(--bs-danger-text-emphasis); + --bs-btn-active-color: var(--cs-on-accent); + --bs-btn-active-bg: var(--bs-danger-text-emphasis); + --bs-btn-disabled-color: var(--cs-on-accent); + --bs-btn-disabled-bg: var(--cs-danger); + --bs-btn-disabled-border-color: var(--cs-danger); +} + +.btn-success { + --bs-btn-color: var(--cs-on-accent); + --bs-btn-bg: var(--cs-success); + --bs-btn-border-color: var(--cs-success); + --bs-btn-hover-color: var(--cs-on-accent); + --bs-btn-hover-bg: var(--bs-success-text-emphasis); + --bs-btn-hover-border-color: var(--bs-success-text-emphasis); + --bs-btn-active-color: var(--cs-on-accent); + --bs-btn-active-bg: var(--bs-success-text-emphasis); + --bs-btn-disabled-color: var(--cs-on-accent); + --bs-btn-disabled-bg: var(--cs-success); + --bs-btn-disabled-border-color: var(--cs-success); +} + +.text-bg-primary { + color: var(--cs-on-accent) !important; + background-color: var(--cs-water) !important; +} + +.form-control, +.form-select { + background-color: var(--cs-surface-sunken); + border-color: var(--cs-field-border); +} + +.form-control:focus, +.form-select:focus { + background-color: var(--cs-surface-sunken); + border-color: var(--cs-water); + box-shadow: none; +} + +.form-control:disabled, +.form-select:disabled { + background-color: var(--cs-line); +} + +.form-check-input { + background-color: var(--cs-surface-sunken); + border-color: var(--cs-field-border); +} + +.form-check-input:focus { + border-color: var(--cs-water); + box-shadow: none; +} + +.form-check-input:checked { + background-color: var(--cs-water); + border-color: var(--cs-water); +} + +/* Bootstrap draws white marks; on the light dark-theme water they need ink. */ +[data-bs-theme="dark"] .form-check-input:checked[type="checkbox"] { + --bs-form-check-bg-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%230B1620' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='m6 10 3 3 6-6'/%3e%3c/svg%3e"); +} + +[data-bs-theme="dark"] .form-check-input:checked[type="radio"] { + --bs-form-check-bg-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='%230B1620'/%3e%3c/svg%3e"); +} + +.form-range::-webkit-slider-thumb { + background-color: var(--cs-water); +} + +.form-range::-moz-range-thumb { + background-color: var(--cs-water); +} + +.form-range::-webkit-slider-thumb:active { + background-color: var(--cs-water-strong); +} + +.form-range::-moz-range-thumb:active { + background-color: var(--cs-water-strong); +} + +/* Kept as separate rules: one unknown vendor pseudo-element would void a shared selector list. + The ring is drawn on the range itself (app.css). */ +.form-range:focus::-webkit-slider-thumb { + box-shadow: none; +} + +.form-range:focus::-moz-range-thumb { + box-shadow: none; +} + +[data-bs-theme="dark"] .form-switch .form-check-input:checked { + --bs-form-switch-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%230B1620'/%3e%3c/svg%3e"); +} + +.accordion { + --bs-accordion-bg: var(--cs-surface); + --bs-accordion-btn-focus-box-shadow: none; +} + +/* Chevrons are SVG data URIs, so the colours are the literal token values per theme. */ +.accordion-button::after { + --bs-accordion-btn-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%230F2233' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='m2 5 6 6 6-6'/%3e%3c/svg%3e"); + --bs-accordion-btn-active-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%2308566C' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='m2 5 6 6 6-6'/%3e%3c/svg%3e"); +} + +[data-bs-theme="dark"] .accordion-button::after { + --bs-accordion-btn-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23E3ECF2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='m2 5 6 6 6-6'/%3e%3c/svg%3e"); + --bs-accordion-btn-active-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%236FCDE3' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='m2 5 6 6 6-6'/%3e%3c/svg%3e"); +} + +.dropdown-menu { + --bs-dropdown-bg: var(--cs-surface); + --bs-dropdown-border-color: var(--cs-line); + --bs-dropdown-link-color: var(--cs-ink); + --bs-dropdown-link-hover-color: var(--cs-ink); + --bs-dropdown-link-hover-bg: var(--cs-surface-sunken); + --bs-dropdown-link-active-color: var(--cs-on-accent); + --bs-dropdown-link-active-bg: var(--cs-water); + --bs-dropdown-box-shadow: var(--cs-shadow); +} + +.nav-pills { + --bs-nav-pills-link-active-color: var(--cs-on-accent); + --bs-nav-pills-link-active-bg: var(--cs-water); +} + +.pagination { + --bs-pagination-active-color: var(--cs-on-accent); + --bs-pagination-active-bg: var(--cs-water); + --bs-pagination-active-border-color: var(--cs-water); +} + +.progress, +.progress-stacked { + --bs-progress-bar-color: var(--cs-on-accent); + --bs-progress-bar-bg: var(--cs-water); +} + +.list-group { + --bs-list-group-active-color: var(--cs-on-accent); + --bs-list-group-active-bg: var(--cs-water); + --bs-list-group-active-border-color: var(--cs-water); +} + +.card { + --bs-card-bg: var(--cs-surface); + --bs-card-border-color: var(--cs-line); + --bs-card-cap-bg: var(--cs-surface-sunken); +} + +.table { + --bs-table-bg: transparent; +} + +.toast { + --bs-toast-bg: var(--cs-surface); + --bs-toast-border-color: var(--cs-line); + --bs-toast-header-bg: var(--cs-surface-sunken); + --bs-toast-header-color: var(--cs-ink); +} diff --git a/cs_app/main/static/main/js/base_ui.js b/cs_app/main/static/main/js/base_ui.js deleted file mode 100644 index 352c263..0000000 --- a/cs_app/main/static/main/js/base_ui.js +++ /dev/null @@ -1,20 +0,0 @@ -"use strict"; - -document.addEventListener("DOMContentLoaded", function () { - var body = document.body; - var loginUrl = body ? body.dataset.loginUrl : ""; - if (!loginUrl) { - return; - } - - var actionButtons = document.querySelectorAll("button[data-authenticated]"); - actionButtons.forEach(function (button) { - button.addEventListener("click", function (event) { - var isAuthenticated = button.getAttribute("data-authenticated") === "True"; - if (!isAuthenticated) { - event.preventDefault(); - window.location.href = loginUrl; - } - }); - }); -}); diff --git a/cs_app/main/static/main/js/core/async-form.js b/cs_app/main/static/main/js/core/async-form.js new file mode 100644 index 0000000..e2ac8da --- /dev/null +++ b/cs_app/main/static/main/js/core/async-form.js @@ -0,0 +1,350 @@ +/* + * CS.asyncForm(options) — progressive enhancement for run forms (DESIGN_SPEC §4). + * + * options: + * form
to enhance (without JS it keeps its native POST) + * submitButton the run button; only submits from it (or implicit/requestSubmit + * submits without a submitter) are intercepted, so download buttons + * with formaction keep submitting natively and never show loading + * target element whose innerHTML receives the 200 results fragment; focus then + * moves to its first heading (tabindex=-1 added when needed). A bubbling + * `cs:beforeswap` event is dispatched on it just before the swap (charts.js + * releases the charts about to be replaced) + * loadingEl optional progress element, toggled with the `hidden` attribute; + * a descendant [data-cs-elapsed] shows elapsed seconds + * successMessage optional toast text shown after a successful run ("Simulation finished") + * onSuccess optional (target) => void, called after the fragment is swapped in + * onError optional ({status, message, field_errors}) => void, after the inline + * errors and the error toast are shown (do not toast again) + * + * Error responses: 4xx/5xx JSON {message, field_errors} render inline Bootstrap + * field errors plus a role="alert" summary in the form, and a short danger toast; + * 401 redirects to login; anything else (non-JSON body, network failure) shows + * TIMEOUT_MESSAGE. Results kept from an earlier run (a [data-cs-results] element in + * target) get a .cs-stale-notice above them. Every new run dismisses the toasts and + * the notice left by the previous one. + * + * Loading another model or removing it (the upload zone's cs:model-changed, unless + * `restored`, and cs:model-cleared) marks the results shown as stale too, and a run + * still in flight is discarded when it returns: both belong to the previous model. + */ +(function (CS) { + "use strict"; + + var TIMEOUT_MESSAGE = + "The server did not finish the run. It may have timed out — try a smaller range " + + "or reload the page to check for results."; + var FIELDS_MESSAGE = "Please correct the highlighted fields."; + var FAILED_MESSAGE = "The run could not be completed. Please try again."; + var ERROR_TOAST = "The run did not complete. See the form for details."; + var STALE_MESSAGE = "Showing results from the previous run: the latest run did not complete."; + var MODEL_STALE_MESSAGE = "These results are for the previously loaded model. Run again to update them."; + var DISCARDED_MESSAGE = "A run for the previously loaded model finished; its results were discarded."; + var ERROR_MARK = "data-cs-async-error"; + var INVALID_MARK = "data-cs-async-invalid"; + var STALE_CLASS = "cs-stale-notice"; + + function startElapsed(loadingEl) { + if (!loadingEl) { + return function () {}; + } + var counter = loadingEl.querySelector("[data-cs-elapsed]"); + var startedAt = Date.now(); + function render() { + if (counter) { + counter.textContent = Math.floor((Date.now() - startedAt) / 1000) + " s"; + } + } + render(); + loadingEl.hidden = false; + var timer = window.setInterval(render, 1000); + return function stop() { + window.clearInterval(timer); + loadingEl.hidden = true; + }; + } + + function focusResults(container) { + var focusTarget = container.querySelector("h1, h2, h3, h4, h5, h6") || container; + if (!focusTarget.hasAttribute("tabindex")) { + focusTarget.setAttribute("tabindex", "-1"); + } + focusTarget.focus(); + } + + function fieldFor(form, name) { + var field = form.elements.namedItem(name); + if (field instanceof RadioNodeList) { + return field[field.length - 1] || null; + } + return field; + } + + function clearFieldError(field) { + var feedbackId = field.getAttribute(INVALID_MARK); + if (feedbackId === null) { + return; + } + var feedback = document.getElementById(feedbackId); + if (feedback) { + feedback.remove(); + } + var describedBy = (field.getAttribute("aria-describedby") || "") + .split(/\s+/) + .filter(function (id) { + return id && id !== feedbackId; + }); + if (describedBy.length) { + field.setAttribute("aria-describedby", describedBy.join(" ")); + } else { + field.removeAttribute("aria-describedby"); + } + field.classList.remove("is-invalid"); + field.removeAttribute("aria-invalid"); + field.removeAttribute(INVALID_MARK); + } + + function clearErrors(form) { + form.querySelectorAll("[" + INVALID_MARK + "]").forEach(clearFieldError); + form.querySelectorAll("[" + ERROR_MARK + "]").forEach(function (el) { + el.remove(); + }); + } + + function showFieldError(field, messages) { + var feedbackId = (field.id || field.name) + "-async-error"; + var feedback = document.createElement("div"); + feedback.id = feedbackId; + feedback.className = "invalid-feedback"; + feedback.setAttribute(ERROR_MARK, ""); + feedback.textContent = messages.join(" "); + field.insertAdjacentElement("afterend", feedback); + + field.classList.add("is-invalid"); + field.setAttribute("aria-invalid", "true"); + field.setAttribute(INVALID_MARK, feedbackId); + var describedBy = field.getAttribute("aria-describedby"); + field.setAttribute("aria-describedby", describedBy ? describedBy + " " + feedbackId : feedbackId); + } + + function showSummary(form, message, details) { + var alert = document.createElement("div"); + alert.className = "alert alert-danger"; + alert.setAttribute("role", "alert"); + alert.setAttribute(ERROR_MARK, ""); + + var text = document.createElement("p"); + text.className = "mb-0"; + text.textContent = message; + alert.append(text); + + if (details.length) { + var list = document.createElement("ul"); + list.className = "mb-0 mt-1"; + details.forEach(function (detail) { + var item = document.createElement("li"); + item.textContent = detail; + list.append(item); + }); + alert.append(list); + } + form.prepend(alert); + } + + /** Returns the first field marked invalid, if any. */ + function renderErrors(form, error) { + var unplaced = []; + var firstInvalid = null; + Object.keys(error.field_errors).forEach(function (name) { + var messages = [].concat(error.field_errors[name]).map(String); + var field = name === "__all__" ? null : fieldFor(form, name); + if (field && field.nodeType === Node.ELEMENT_NODE) { + showFieldError(field, messages); + firstInvalid = firstInvalid || field; + } else { + unplaced = unplaced.concat(messages); + } + }); + var summary = error.message || unplaced.shift() || (firstInvalid ? FIELDS_MESSAGE : FAILED_MESSAGE); + showSummary(form, summary, unplaced); + return firstInvalid; + } + + async function readError(response) { + var isJson = (response.headers.get("Content-Type") || "").indexOf("application/json") !== -1; + var payload = null; + if (isJson) { + try { + payload = await response.json(); + } catch (parseError) { + payload = null; + } + } + if (!payload || typeof payload !== "object") { + return { status: response.status, message: TIMEOUT_MESSAGE, field_errors: {} }; + } + return { + status: response.status, + message: typeof payload.message === "string" ? payload.message : "", + field_errors: + payload.field_errors && typeof payload.field_errors === "object" ? payload.field_errors : {}, + }; + } + + async function send(form, submitter) { + var body = new FormData(form); + if (submitter && submitter.name) { + body.append(submitter.name, submitter.value); + } + var response; + try { + response = await fetch(form.getAttribute("action") || window.location.href, { + method: "POST", + body: body, + headers: { "X-Requested-With": "XMLHttpRequest" }, + credentials: "same-origin", + }); + } catch (networkError) { + return { ok: false, error: { status: 0, message: TIMEOUT_MESSAGE, field_errors: {} } }; + } + if (response.ok) { + return { ok: true, html: await response.text() }; + } + return { ok: false, error: await readError(response) }; + } + + function hideToast(toastEl) { + var toast = window.bootstrap && window.bootstrap.Toast.getInstance(toastEl); + if (toast) { + toast.hide(); + } + } + + function removeStaleNotices(target) { + target.querySelectorAll("." + STALE_CLASS).forEach(function (notice) { + notice.remove(); + }); + } + + function showStaleNotice(target, message) { + if (!target.querySelector("[data-cs-results]")) { + return; + } + removeStaleNotices(target); + var notice = document.createElement("p"); + notice.className = STALE_CLASS; + notice.textContent = message; + target.prepend(notice); + } + + function swap(target, html) { + target.dispatchEvent(new CustomEvent("cs:beforeswap", { bubbles: true })); + target.innerHTML = html; + } + + CS.asyncForm = function (options) { + var form = options.form; + var button = options.submitButton; + var target = options.target; + var running = false; + var runToasts = []; + // Bumped whenever the model changes, so a run started before can tell it is outdated. + var modelVersion = 0; + + function toast(message, level) { + var toastEl = CS.toast(message, level); + if (toastEl) { + runToasts.push(toastEl); + } + } + + function clearPreviousRun() { + clearErrors(form); + runToasts.splice(0).forEach(hideToast); + removeStaleNotices(target); + } + + function setRunning(isRunning) { + running = isRunning; + button.disabled = isRunning; + [button, target].forEach(function (el) { + if (isRunning) { + el.setAttribute("aria-busy", "true"); + } else { + el.removeAttribute("aria-busy"); + } + }); + } + + async function run(submitter) { + clearPreviousRun(); + setRunning(true); + var runModelVersion = modelVersion; + var stopElapsed = startElapsed(options.loadingEl); + var result; + try { + result = await send(form, submitter); + } catch (readFailure) { + // The body stream broke mid-read: same outcome as a dropped connection. + result = { ok: false, error: { status: 0, message: TIMEOUT_MESSAGE, field_errors: {} } }; + } finally { + stopElapsed(); + setRunning(false); + } + + if (runModelVersion !== modelVersion) { + toast(DISCARDED_MESSAGE, "info"); + return; + } + if (result.ok) { + swap(target, result.html); + if (options.onSuccess) { + options.onSuccess(target); + } + focusResults(target); + if (options.successMessage) { + toast(options.successMessage, "success"); + } + return; + } + if (result.error.status === 401 && CS.redirectToLogin()) { + return; + } + // Disabling the button dropped its focus; return it to where the fix is needed. + (renderErrors(form, result.error) || button).focus(); + showStaleNotice(target, STALE_MESSAGE); + toast(ERROR_TOAST, "danger"); + if (options.onError) { + options.onError(result.error); + } + } + + form.addEventListener("submit", function (event) { + var submitter = event.submitter; + if (event.defaultPrevented || (submitter && submitter !== button)) { + return; + } + event.preventDefault(); + if (!running) { + run(submitter); + } + }); + + function onModelSwitch(event) { + if (event.detail && event.detail.restored) { + return; + } + modelVersion += 1; + showStaleNotice(target, MODEL_STALE_MESSAGE); + } + document.addEventListener("cs:model-changed", onModelSwitch); + document.addEventListener("cs:model-cleared", onModelSwitch); + + form.addEventListener("input", function (event) { + var field = event.target.closest("[" + INVALID_MARK + "]"); + if (field) { + clearFieldError(field); + } + }); + }; +})(window.CS); diff --git a/cs_app/main/static/main/js/core/auth-guard.js b/cs_app/main/static/main/js/core/auth-guard.js new file mode 100644 index 0000000..3151917 --- /dev/null +++ b/cs_app/main/static/main/js/core/auth-guard.js @@ -0,0 +1,29 @@ +/* + * CS.redirectToLogin() sends the user to body[data-login-url] with ?next= set to + * the current page. Buttons rendered with data-authenticated="False" use it + * instead of submitting (delegated, so buttons swapped in later are covered). + */ +(function (CS) { + "use strict"; + + CS.redirectToLogin = function () { + var loginUrl = document.body.dataset.loginUrl; + if (!loginUrl) { + return false; + } + var url = new URL(loginUrl, window.location.href); + url.searchParams.set("next", window.location.pathname + window.location.search); + window.location.assign(url.href); + return true; + }; + + document.addEventListener("click", function (event) { + var button = event.target.closest("button[data-authenticated]"); + if (!button || button.getAttribute("data-authenticated") === "True") { + return; + } + if (CS.redirectToLogin()) { + event.preventDefault(); + } + }); +})(window.CS); diff --git a/cs_app/main/static/main/js/core/clipboard.js b/cs_app/main/static/main/js/core/clipboard.js new file mode 100644 index 0000000..d60aa1b --- /dev/null +++ b/cs_app/main/static/main/js/core/clipboard.js @@ -0,0 +1,26 @@ +/* + * CS.clipboard.copy(text, successMessage) — copies text and confirms with a toast. + * Resolves to true on success, false otherwise (never rejects). + */ +(function (CS) { + "use strict"; + + CS.clipboard = { + copy: function (text, successMessage) { + if (!navigator.clipboard || !window.isSecureContext) { + CS.toast("Copying is not available here. Select the text and copy it manually.", "warning"); + return Promise.resolve(false); + } + return navigator.clipboard.writeText(text).then( + function () { + CS.toast(successMessage || "Copied to clipboard.", "success"); + return true; + }, + function () { + CS.toast("Could not copy. Select the text and copy it manually.", "danger"); + return false; + } + ); + }, + }; +})(window.CS); diff --git a/cs_app/main/static/main/js/core/format.js b/cs_app/main/static/main/js/core/format.js new file mode 100644 index 0000000..45d242b --- /dev/null +++ b/cs_app/main/static/main/js/core/format.js @@ -0,0 +1,70 @@ +/* + * CS.format.number(value, digits) — locale-stable display of numeric results. + * CS.format.sweepRange(start, stop, step, maxSteps) — the live run-count hint of a + * parameter sweep form (simulation and timeseries pages). + */ +(function (CS) { + "use strict"; + + var formatters = {}; + + function formatterFor(digits) { + if (!formatters[digits]) { + formatters[digits] = new Intl.NumberFormat("en-US", { + minimumFractionDigits: digits, + maximumFractionDigits: digits, + }); + } + return formatters[digits]; + } + + /** Same text as the server's swept-value labels: 0.30000000000000004 → "0.3". */ + function parameter(value) { + return String(Number(value.toPrecision(10))); + } + + CS.format = { + /** + * Format `value` with exactly `digits` decimals (default 2) and + * thousands separators. Non-numeric input yields an em dash. + */ + number: function (value, digits) { + var number = typeof value === "number" ? value : parseFloat(value); + if (!Number.isFinite(number)) { + return "—"; + } + return formatterFor(digits === undefined ? 2 : digits).format(number); + }, + + /** + * Describe a sweep range as "N runs · start → stop, step s", or say what is + * wrong with it. The server remains authoritative; this only warns early. + * + * @returns {{text: string, warning: boolean}} + */ + sweepRange: function (start, stop, step, maxSteps) { + if (![start, stop, step].every(Number.isFinite)) { + return { text: "Enter start, stop and step to see the number of runs.", warning: false }; + } + if (step <= 0) { + return { text: "Step must be greater than zero.", warning: true }; + } + if (start > stop) { + return { text: "Stop must be greater than or equal to start.", warning: true }; + } + // The same values as the package's np.arange(start, stop + step / 2, step). + var runs = Math.ceil((stop - start + step / 2) / step); + var summary = runs + (runs === 1 ? " run" : " runs") + " · " + parameter(start) + " → " + + parameter(stop) + ", step " + parameter(step); + // The forms' own limit: (stop - start) / step < max steps. + if ((stop - start) / step >= maxSteps) { + return { + text: summary + ". That is over the limit of " + maxSteps + + " runs: increase the step or narrow the range.", + warning: true, + }; + } + return { text: summary, warning: false }; + }, + }; +})(window.CS); diff --git a/cs_app/main/static/main/js/core/forms.js b/cs_app/main/static/main/js/core/forms.js new file mode 100644 index 0000000..8a2bf5c --- /dev/null +++ b/cs_app/main/static/main/js/core/forms.js @@ -0,0 +1,104 @@ +/* + * Server-rendered forms (log in, create account, contact, profile), marked + * with [data-cs-form]. Progressive enhancement only; the forms work without it. + * - Field errors rendered by crispy are tied to their inputs (aria-invalid, + * aria-describedby) and the first invalid field receives focus. + * - Password inputs get a show/hide toggle button (aria-pressed). + */ +(function () { + "use strict"; + + var EYE_ICON = + '' + + ''; + + function describeBy(input, id) { + var ids = (input.getAttribute("aria-describedby") || "").split(/\s+/).filter(Boolean); + if (ids.indexOf(id) === -1) { + ids.push(id); + input.setAttribute("aria-describedby", ids.join(" ")); + } + } + + function firstField(form) { + return form.querySelector("input:not([type=hidden]):not([disabled]), select:not([disabled]), textarea:not([disabled])"); + } + + /** Crispy places `.invalid-feedback#error_N_` and `#_helptext` next to each field. */ + function linkErrors(form) { + form.querySelectorAll(".is-invalid").forEach(function (input) { + input.setAttribute("aria-invalid", "true"); + form.querySelectorAll('.invalid-feedback[id$="_' + input.id + '"]').forEach(function (feedback) { + describeBy(input, feedback.id); + }); + }); + form.querySelectorAll("input[id], select[id], textarea[id]").forEach(function (input) { + if (document.getElementById(input.id + "_helptext")) { + describeBy(input, input.id + "_helptext"); + } + }); + + // Errors that belong to no field (e.g. wrong credentials) render as one alert. + var summary = form.querySelector(".alert-danger"); + var target = form.querySelector(".is-invalid") || (summary && firstField(form)); + if (summary && target) { + summary.id = summary.id || form.id + "-errors"; + describeBy(target, summary.id); + } + if (target) { + target.focus(); + } + } + + function labelText(input) { + var label = input.labels && input.labels[0]; + return label ? label.textContent.replace("*", "").trim().toLowerCase() : "password"; + } + + function addPasswordToggle(input) { + var group = document.createElement("div"); + group.className = "input-group has-validation"; + input.parentNode.insertBefore(group, input); + group.appendChild(input); + + var button = document.createElement("button"); + button.type = "button"; + button.className = "btn cs-password-toggle"; + button.setAttribute("aria-controls", input.id); + button.setAttribute("aria-pressed", "false"); + button.setAttribute("aria-label", "Show " + labelText(input)); + button.title = button.getAttribute("aria-label"); + button.innerHTML = EYE_ICON; + group.appendChild(button); + + // Bootstrap only shows feedback that follows the invalid input inside the group. + var sibling = group.nextElementSibling; + while (sibling && sibling.classList.contains("invalid-feedback")) { + var next = sibling.nextElementSibling; + group.appendChild(sibling); + sibling = next; + } + + button.addEventListener("click", function () { + var reveal = input.type === "password"; + input.type = reveal ? "text" : "password"; + button.setAttribute("aria-pressed", String(reveal)); + }); + } + + function concealPasswords(form) { + form.querySelectorAll(".cs-password-toggle[aria-pressed='true']").forEach(function (button) { + button.click(); + }); + } + + document.querySelectorAll("form[data-cs-form]").forEach(function (form, index) { + form.id = form.id || "cs-form-" + index; + form.querySelectorAll("input[type=password][id]").forEach(addPasswordToggle); + // Submit the password as a password field so browsers offer to save it. + form.addEventListener("submit", function () { + concealPasswords(form); + }); + linkErrors(form); + }); +})(); diff --git a/cs_app/main/static/main/js/core/table.js b/cs_app/main/static/main/js/core/table.js new file mode 100644 index 0000000..7c5f741 --- /dev/null +++ b/cs_app/main/static/main/js/core/table.js @@ -0,0 +1,114 @@ +/* + * CS.table.sortable(tableEl) — click/Enter/Space on a header sorts the first + * tbody by that column (numeric-aware, ascending then descending) and sets + * aria-sort on the sorted . Header text is wrapped in a .cs-sort button + * unless the header already contains one; opts out. + * A cell's data-sort-value overrides its text as the sort key. Calling it again + * on the same table is a no-op. + * CS.table.toCSV(tableEl) — RFC 4180 CSV of the header and body rows. + */ +(function (CS) { + "use strict"; + + var collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }); + + function cellKey(cell) { + return cell ? (cell.dataset.sortValue || cell.textContent).trim() : ""; + } + + function toNumber(text) { + var normalized = text.replace(/[,\s ]/g, ""); + return /^[-+]?(\d+\.?\d*|\.\d+)(e[-+]?\d+)?$/i.test(normalized) ? parseFloat(normalized) : NaN; + } + + // Numbers before text; text compared naturally ("S2" < "S10"). + function compareKeys(a, b) { + var numA = toNumber(a); + var numB = toNumber(b); + var aIsNum = !Number.isNaN(numA); + var bIsNum = !Number.isNaN(numB); + if (aIsNum && bIsNum) { + return numA - numB; + } + if (aIsNum !== bIsNum) { + return aIsNum ? -1 : 1; + } + return collator.compare(a, b); + } + + function ensureSortButton(th) { + var button = th.querySelector(".cs-sort"); + if (button) { + return button; + } + button = document.createElement("button"); + button.type = "button"; + button.className = "cs-sort"; + button.append.apply(button, Array.prototype.slice.call(th.childNodes)); + th.append(button); + return button; + } + + function sortBy(table, headers, th) { + var body = table.tBodies[0]; + if (!body) { + return; + } + var direction = th.getAttribute("aria-sort") === "ascending" ? "descending" : "ascending"; + var sign = direction === "ascending" ? 1 : -1; + var column = th.cellIndex; + + Array.prototype.slice + .call(body.rows) + .sort(function (rowA, rowB) { + return sign * compareKeys(cellKey(rowA.cells[column]), cellKey(rowB.cells[column])); + }) + .forEach(function (row) { + body.appendChild(row); + }); + + headers.forEach(function (header) { + header.removeAttribute("aria-sort"); + }); + th.setAttribute("aria-sort", direction); + } + + function csvField(text) { + var value = text.trim().replace(/\s+/g, " "); + // Neutralise spreadsheet formulas in text cells (numbers such as -1.5 stay intact). + if (/^[=+\-@]/.test(value) && Number.isNaN(toNumber(value))) { + value = "'" + value; + } + return /[",\r\n]/.test(value) ? '"' + value.replace(/"/g, '""') + '"' : value; + } + + CS.table = { + sortable: function (tableEl) { + var headerRow = tableEl.tHead && tableEl.tHead.rows[0]; + if (!headerRow || tableEl.hasAttribute("data-cs-sortable")) { + return; + } + tableEl.setAttribute("data-cs-sortable", ""); + var headers = Array.prototype.filter.call(headerRow.cells, function (th) { + return th.dataset.csSort !== "false"; + }); + headers.forEach(function (th) { + ensureSortButton(th).addEventListener("click", function () { + sortBy(tableEl, headers, th); + }); + }); + }, + + toCSV: function (tableEl) { + return Array.prototype.map + .call(tableEl.querySelectorAll("thead tr, tbody tr"), function (row) { + return Array.prototype.map + .call(row.cells, function (cell) { + return csvField(cell.textContent); + }) + .join(","); + }) + .join("\r\n"); + }, + }; +})(window.CS); diff --git a/cs_app/main/static/main/js/core/theme-init.js b/cs_app/main/static/main/js/core/theme-init.js new file mode 100644 index 0000000..7989604 --- /dev/null +++ b/cs_app/main/static/main/js/core/theme-init.js @@ -0,0 +1,50 @@ +/* + * Loaded synchronously in so the colour mode is set before first paint. + * Defines the minimal CS.theme primitives that core/theme.js builds on. + */ +(function () { + "use strict"; + + var STORAGE_KEY = "cs-theme"; + var MODES = ["light", "dark", "auto"]; + var darkQuery = window.matchMedia("(prefers-color-scheme: dark)"); + + function storedMode() { + var value = null; + try { + value = window.localStorage.getItem(STORAGE_KEY); + } catch (error) { + // Storage can be blocked (privacy mode); fall back to "auto". + } + return MODES.indexOf(value) !== -1 ? value : "auto"; + } + + function resolve(mode) { + if (mode === "auto") { + return darkQuery.matches ? "dark" : "light"; + } + return mode; + } + + function apply(mode) { + var resolved = resolve(mode); + document.documentElement.setAttribute("data-bs-theme", resolved); + return resolved; + } + + var CS = window.CS = window.CS || {}; + CS.theme = { + STORAGE_KEY: STORAGE_KEY, + MODES: MODES, + darkQuery: darkQuery, + storedMode: storedMode, + resolve: resolve, + apply: apply, + /** Resolved theme currently on : "light" | "dark". */ + current: function () { + return document.documentElement.getAttribute("data-bs-theme") === "dark" ? "dark" : "light"; + }, + }; + + apply(storedMode()); +})(); diff --git a/cs_app/main/static/main/js/core/theme.js b/cs_app/main/static/main/js/core/theme.js new file mode 100644 index 0000000..550c54f --- /dev/null +++ b/cs_app/main/static/main/js/core/theme.js @@ -0,0 +1,100 @@ +/* + * Theme toggle (light / dark / auto). Persists the chosen mode in localStorage + * and dispatches `cs:themechange` on document with {detail: {theme}} whenever + * the resolved theme changes, including system changes while in "auto". + * Controls: any button with data-cs-theme-value="light|dark|auto"; its + * aria-pressed reflects the active mode. [data-cs-theme-label] shows the mode. + */ +(function (CS) { + "use strict"; + + var theme = CS.theme; + var LABELS = { light: "Light", dark: "Dark", auto: "Auto" }; + var mode = theme.storedMode(); + + function dispatchChange(resolved) { + document.dispatchEvent(new CustomEvent("cs:themechange", { detail: { theme: resolved } })); + } + + // Switch colours in one step: without this, elements with colour transitions + // briefly show light-theme text on the dark background (and vice versa). + var restoreFrame = null; + + function applyWithoutTransitions() { + var root = document.documentElement; + window.cancelAnimationFrame(restoreFrame); + root.classList.add("cs-theme-switching"); + var resolved = theme.apply(mode); + window.getComputedStyle(root).getPropertyValue("color"); // flush styles before re-enabling + restoreFrame = window.requestAnimationFrame(function () { + root.classList.remove("cs-theme-switching"); + }); + return resolved; + } + + function applyMode() { + var previous = theme.current(); + if (theme.resolve(mode) === previous) { + return; + } + dispatchChange(applyWithoutTransitions()); + } + + function persist() { + try { + if (mode === "auto") { + window.localStorage.removeItem(theme.STORAGE_KEY); + } else { + window.localStorage.setItem(theme.STORAGE_KEY, mode); + } + } catch (error) { + // Not persisted when storage is blocked; the choice still applies to this page. + } + } + + function syncControls() { + document.querySelectorAll("[data-cs-theme-value]").forEach(function (control) { + control.setAttribute("aria-pressed", String(control.getAttribute("data-cs-theme-value") === mode)); + }); + document.querySelectorAll("[data-cs-theme-label]").forEach(function (label) { + label.textContent = LABELS[mode]; + }); + } + + /** Selected mode: "light" | "dark" | "auto". */ + theme.mode = function () { + return mode; + }; + + theme.set = function (nextMode) { + if (theme.MODES.indexOf(nextMode) === -1) { + return; + } + mode = nextMode; + persist(); + applyMode(); + syncControls(); + }; + + theme.darkQuery.addEventListener("change", function () { + if (mode === "auto") { + applyMode(); + } + }); + + document.addEventListener("click", function (event) { + var control = event.target.closest("[data-cs-theme-value]"); + if (!control) { + return; + } + theme.set(control.getAttribute("data-cs-theme-value")); + // The dropdown closes and hides the chosen item; keep focus on its toggle, not . + var menu = control.closest(".dropdown-menu[aria-labelledby]"); + var toggle = menu && document.getElementById(menu.getAttribute("aria-labelledby")); + if (toggle) { + toggle.focus(); + } + }); + + syncControls(); +})(window.CS); diff --git a/cs_app/main/static/main/js/core/toast.js b/cs_app/main/static/main/js/core/toast.js new file mode 100644 index 0000000..9958c62 --- /dev/null +++ b/cs_app/main/static/main/js/core/toast.js @@ -0,0 +1,47 @@ +/* + * CS.toast(message, level) — transient feedback for async flows. + * level: "success" | "info" | "warning" | "danger" (default "info"). + * Renders into the base template's .toast-container (aria-live="polite"). + * The message is set via textContent, so user data is never parsed as HTML. + */ +(function (CS) { + "use strict"; + + var LEVELS = ["success", "info", "warning", "danger"]; + var DELAY_MS = { success: 4000, info: 5000, warning: 7000, danger: 8000 }; + + CS.toast = function (message, level) { + var region = document.querySelector(".toast-container"); + if (!region || !window.bootstrap) { + return null; + } + var safeLevel = LEVELS.indexOf(level) !== -1 ? level : "info"; + + var toastEl = document.createElement("div"); + toastEl.className = "toast cs-toast cs-toast--" + safeLevel; + toastEl.setAttribute("aria-atomic", "true"); + + var row = document.createElement("div"); + row.className = "d-flex align-items-start"; + + var body = document.createElement("div"); + body.className = "toast-body"; + body.textContent = message; + + var close = document.createElement("button"); + close.type = "button"; + close.className = "btn-close me-2 mt-2"; + close.setAttribute("data-bs-dismiss", "toast"); + close.setAttribute("aria-label", "Close"); + + row.append(body, close); + toastEl.append(row); + region.append(toastEl); + + toastEl.addEventListener("hidden.bs.toast", function () { + toastEl.remove(); + }); + window.bootstrap.Toast.getOrCreateInstance(toastEl, { delay: DELAY_MS[safeLevel] }).show(); + return toastEl; + }; +})(window.CS); diff --git a/cs_app/main/static/main/js/core/workbench.js b/cs_app/main/static/main/js/core/workbench.js new file mode 100644 index 0000000..a34566f --- /dev/null +++ b/cs_app/main/static/main/js/core/workbench.js @@ -0,0 +1,36 @@ +/* + * Workbench control panel (.cs-panel): on wide screens it sticks below the + * header (.is-sticky, see app.css) only while it fits the viewport. A taller + * panel scrolls with the page instead of inside itself, so none of its + * controls can end up out of reach. + */ +(function () { + "use strict"; + + var WIDE_QUERY = window.matchMedia("(min-width: 992px)"); + // Room kept above and below a sticky panel (1rem each, as in app.css). + var GUTTER_PX = 32; + + var panels = document.querySelectorAll(".cs-panel"); + if (!panels.length) { + return; + } + var header = document.querySelector(".cs-header"); + + function update() { + var available = window.innerHeight - (header ? header.offsetHeight : 0) - GUTTER_PX; + panels.forEach(function (panel) { + panel.classList.toggle("is-sticky", WIDE_QUERY.matches && panel.offsetHeight <= available); + }); + } + + // The panel grows and shrinks with its content (sweep fields, error summaries, the model chip). + if (window.ResizeObserver) { + var observer = new ResizeObserver(update); + panels.forEach(function (panel) { + observer.observe(panel); + }); + } + window.addEventListener("resize", update); + update(); +})(); diff --git a/cs_app/main/tests/test_views.py b/cs_app/main/tests/test_views.py index 4e88604..afc5bcb 100644 --- a/cs_app/main/tests/test_views.py +++ b/cs_app/main/tests/test_views.py @@ -15,6 +15,7 @@ from django.core.files.uploadedfile import SimpleUploadedFile from django.test import RequestFactory from django.urls import reverse +from django.utils import timezone from main.views import ( SIM_RESULT_TOKEN_SESSION_KEY, @@ -1988,6 +1989,10 @@ def test_refactored_pages_do_not_reference_legacy_chart_assets( ("url_name", "requires_login"), [ ("main:main_view", False), + ("main:about", False), + ("main:contact", False), + ("login", False), + ("register:register", False), ("main:simulation", True), ("main:timeseries", True), ("main:calculations", True), @@ -2716,3 +2721,163 @@ def test_subcatchment_ids_cached_in_session(user): # Second call should use cache (same result) ids2 = _get_subcatchment_ids(request) assert ids1 == ids2 + + +# ---------------------------------------------------------------------- +# App shell (base.html) +# ---------------------------------------------------------------------- + +SHELL_PAGES = [ + ("main:main_view", False), + ("main:about", False), + ("main:contact", False), + ("main:simulation", True), + ("main:timeseries", True), + ("main:calculations", True), +] + + +def _get_page(client, user, url_name, requires_login): + if requires_login: + client.force_login(user) + response = client.get(reverse(url_name)) + assert response.status_code == 200 + return response.content.decode() + + +def _head(html): + return html.split("", 1)[0] + + +@pytest.mark.django_db +@pytest.mark.parametrize( + ("url_name", "requires_login"), + [(name, login) for name, login in SHELL_PAGES if name != "main:contact"], +) +def test_nav_marks_only_the_current_page(client, user, url_name, requires_login): + """Exactly one nav link carries aria-current="page" and it points at the page itself.""" + html = _get_page(client, user, url_name, requires_login) + + current = re.findall(r']*href="([^"]+)"[^>]*aria-current="page"', html) + assert current == [reverse(url_name)] + + +@pytest.mark.django_db +def test_nav_has_no_current_page_outside_primary_sections(client): + """Pages that are not in the primary nav (e.g. contact) highlight nothing.""" + html = client.get(reverse("main:contact")).content.decode() + + assert 'aria-current="page"' not in html + + +@pytest.mark.django_db +@pytest.mark.parametrize(("url_name", "requires_login"), SHELL_PAGES) +def test_theme_init_runs_synchronously_in_head_before_styles( + client, user, url_name, requires_login +): + """Colour mode is applied before paint: blocking script in ahead of stylesheets.""" + head = _head(_get_page(client, user, url_name, requires_login)) + + match = re.search(r' + + +
+

SWMM model

+
+ + +
{% csrf_token %}
- - + + +
-
- - - - - -
-
-

Drag & drop your file or browse

-

SWMM input file for catchment simulation

-
- .inp — max 10 MB + +

SWMM input file, up to 10 MB

- -
diff --git a/cs_app/main/tests/test_upload_integration.py b/cs_app/main/tests/test_upload_integration.py new file mode 100644 index 0000000..41ca29c --- /dev/null +++ b/cs_app/main/tests/test_upload_integration.py @@ -0,0 +1,86 @@ +"""The upload endpoint through the full middleware stack, with CSRF enforced. + +The unit tests in test_views.py call the view directly or use a Client that +skips CSRF, so CsrfViewMiddleware never parses the multipart body before the +view. A browser request does; these tests cover that path. +""" + +import os + +import pytest +from django.conf import settings +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import Client +from django.urls import reverse + +from main.views import BodySizeLimitUploadHandler, _user_upload_dir + +INP_CONTENT = b"[TITLE]\nIntegration\n\n[OPTIONS]\nFLOW_UNITS CMS\n" + + +@pytest.fixture +def csrf_client(user): + """Logged-in client that enforces CSRF, holding the token a page load hands out.""" + client = Client(enforce_csrf_checks=True) + client.force_login(user) + client.get(reverse("main:simulation")) + return client + + +@pytest.fixture +def upload_path(user): + path = os.path.join(_user_upload_dir(user.id), "integration.inp") + yield path + if os.path.exists(path): + os.remove(path) + + +def _post_upload(client, content=INP_CONTENT, **headers): + return client.post( + reverse("main:upload"), + {"file": SimpleUploadedFile("integration.inp", content, content_type="text/plain")}, + HTTP_X_REQUESTED_WITH="XMLHttpRequest", + **headers, + ) + + +@pytest.mark.django_db +def test_upload_with_csrf_header_stores_the_file(csrf_client, upload_path): + token = csrf_client.cookies[settings.CSRF_COOKIE_NAME].value + + response = _post_upload(csrf_client, HTTP_X_CSRFTOKEN=token) + + assert response.status_code == 200 + assert response.json() == {"message": "File was sent."} + with open(upload_path, "rb") as saved: + assert saved.read() == INP_CONTENT + assert csrf_client.session["uploaded_file_path"] == upload_path + + +@pytest.mark.django_db +def test_upload_without_csrf_token_is_rejected(csrf_client, upload_path): + response = _post_upload(csrf_client) + + assert response.status_code == 403 + assert not os.path.exists(upload_path) + assert "uploaded_file_path" not in csrf_client.session + + +@pytest.mark.django_db +def test_upload_body_is_parsed_through_the_size_limit_handler( + csrf_client, upload_path, monkeypatch +): + received = [] + + class SpyHandler(BodySizeLimitUploadHandler): + def receive_data_chunk(self, raw_data, start): + received.append(raw_data) + return super().receive_data_chunk(raw_data, start) + + monkeypatch.setattr("main.views.BodySizeLimitUploadHandler", SpyHandler) + token = csrf_client.cookies[settings.CSRF_COOKIE_NAME].value + + response = _post_upload(csrf_client, HTTP_X_CSRFTOKEN=token) + + assert response.status_code == 200 + assert b"".join(received) == INP_CONTENT diff --git a/cs_app/main/tests/test_views.py b/cs_app/main/tests/test_views.py index afc5bcb..bb8586a 100644 --- a/cs_app/main/tests/test_views.py +++ b/cs_app/main/tests/test_views.py @@ -9,6 +9,7 @@ import pytest from django.conf import settings from django.contrib.auth.models import AnonymousUser +from django.contrib.messages import get_messages from django.contrib.messages.middleware import MessageMiddleware from django.contrib.sessions.middleware import SessionMiddleware from django.core.cache import cache @@ -649,6 +650,12 @@ def test_calculations_anonymous_post_returns_200(client): assert response.status_code == 200 +def _upload(request): + """Call the upload view directly, skipping the CSRF check as django.test.Client does.""" + request._dont_enforce_csrf_checks = True + return upload(request) + + @pytest.mark.django_db def test_upload_unauthenticated_ajax_returns_401(): """ @@ -666,7 +673,7 @@ def test_upload_unauthenticated_ajax_returns_401(): request.user = AnonymousUser() - response = upload(request) + response = _upload(request) assert response.status_code == 401 data = json.loads(response.content) @@ -689,7 +696,7 @@ def test_upload_unauthenticated_regular_request_redirects(): request.user = AnonymousUser() - response = upload(request) + response = _upload(request) assert response.status_code == 302 assert settings.LOGIN_URL in response.url @@ -734,7 +741,7 @@ def test_upload_returns_413_when_content_length_exceeds_body_limit(user): request.session.save() request.user = user - response = upload(request) + response = _upload(request) assert response.status_code == 413 data = json.loads(response.content) @@ -758,7 +765,7 @@ def test_upload_returns_413_when_uploaded_file_size_exceeds_limit(user): request.session.save() request.user = user - response = upload(request) + response = _upload(request) assert response.status_code == 413 data = json.loads(response.content) @@ -786,7 +793,7 @@ def test_upload_returns_413_when_content_length_is_spoofed_low(user, monkeypatch request.session.save() request.user = user - response = upload(request) + response = _upload(request) assert response.status_code == 413 data = json.loads(response.content) @@ -818,7 +825,7 @@ def test_upload_handles_invalid_or_negative_content_length(user, raw_content_len settings.MEDIA_ROOT, "uploaded_files", str(user.id), "content_length_variants.inp" ) try: - response = upload(request) + response = _upload(request) assert response.status_code == 400 finally: if os.path.exists(expected_path): @@ -847,13 +854,53 @@ def test_upload_handles_missing_content_length(user): settings.MEDIA_ROOT, "uploaded_files", str(user.id), "missing_length.inp" ) try: - response = upload(request) + response = _upload(request) assert response.status_code == 400 finally: if os.path.exists(expected_path): os.remove(expected_path) +@pytest.mark.django_db +@pytest.mark.parametrize( + ("filename", "referer", "expected_url", "expected_message"), + [ + ( + "fallback.inp", + "http://testserver/timeseries", + "http://testserver/timeseries", + "File was sent.", + ), + ("fallback.txt", "/calculations", "/calculations", "Invalid file type."), + ("fallback.inp", "https://evil.example/", "/simulation", "File was sent."), + ("fallback.inp", None, "/simulation", "File was sent."), + ], +) +def test_upload_without_js_redirects_back_with_a_message( + client, user, filename, referer, expected_url, expected_message +): + """The no-JS fallback form gets POST -> redirect -> GET, never a JSON page.""" + client.force_login(user) + inp_content = b"[TITLE]\nFallback\n\n[OPTIONS]\nFLOW_UNITS LPS\n" + headers = {"HTTP_REFERER": referer} if referer else {} + + try: + response = client.post( + reverse("main:upload"), + {"file": SimpleUploadedFile(filename, inp_content, content_type="text/plain")}, + **headers, + ) + + assert response.status_code == 302 + assert response.url == expected_url + [message] = get_messages(response.wsgi_request) + assert str(message).startswith(expected_message) + finally: + path = client.session.get("uploaded_file_path") + if path and os.path.exists(path): + os.remove(path) + + @pytest.mark.django_db def test_upload_authenticated_user_can_upload(user): """ @@ -880,7 +927,7 @@ def test_upload_authenticated_user_can_upload(user): settings.MEDIA_ROOT, "uploaded_files", str(user.id), "test_upload.inp" ) try: - response = upload(request) + response = _upload(request) assert response.status_code == 200 data = json.loads(response.content) @@ -932,7 +979,7 @@ def fail_bytes_validator(_file_content): settings.MEDIA_ROOT, "uploaded_files", str(user.id), "streamed_validation.inp" ) try: - response = upload(request) + response = _upload(request) assert response.status_code == 200 assert called_stream_validator["called"] is True @@ -962,7 +1009,7 @@ def test_upload_valid_small_inp_still_succeeds(user): settings.MEDIA_ROOT, "uploaded_files", str(user.id), "valid_small.inp" ) try: - response = upload(request) + response = _upload(request) assert response.status_code == 200 data = json.loads(response.content) @@ -992,7 +1039,7 @@ def test_upload_invalid_content_returns_400(user): request.session.save() request.user = user - response = upload(request) + response = _upload(request) assert response.status_code == 400 data = json.loads(response.content) @@ -1018,7 +1065,7 @@ def test_upload_binary_blob_with_single_marker_returns_400(user): request.session.save() request.user = user - response = upload(request) + response = _upload(request) assert response.status_code == 400 data = json.loads(response.content) @@ -1047,7 +1094,7 @@ def test_upload_valid_utf16_bom_file_succeeds(user): settings.MEDIA_ROOT, "uploaded_files", str(user.id), "utf16_valid.inp" ) try: - response = upload(request) + response = _upload(request) assert response.status_code == 200 assert request.session.get("uploaded_file_path") == expected_path @@ -1106,7 +1153,7 @@ def test_upload_size_equal_limit_is_allowed(user, monkeypatch): settings.MEDIA_ROOT, "uploaded_files", str(user.id), "equal_limit.inp" ) try: - response = upload(request) + response = _upload(request) assert response.status_code == 200 finally: if os.path.exists(expected_path): @@ -1138,7 +1185,7 @@ def test_upload_body_length_equal_limit_is_allowed(user, monkeypatch): settings.MEDIA_ROOT, "uploaded_files", str(user.id), "equal_body_limit.inp" ) try: - response = upload(request) + response = _upload(request) assert response.status_code == 200 finally: if os.path.exists(expected_path): @@ -1246,7 +1293,7 @@ def test_upload_clears_timeseries_form_state(user): settings.MEDIA_ROOT, "uploaded_files", str(user.id), "test_upload.inp" ) try: - response = upload(request) + response = _upload(request) assert response.status_code == 200 assert "ts_form_state" not in request.session @@ -1278,7 +1325,7 @@ def test_upload_clears_simulation_form_state(user): settings.MEDIA_ROOT, "uploaded_files", str(user.id), "test_upload.inp" ) try: - response = upload(request) + response = _upload(request) assert response.status_code == 200 assert "sim_form_state" not in request.session @@ -1312,7 +1359,7 @@ def test_upload_failure_preserves_existing_form_state_and_subcatchment_cache(use request.session.save() request.user = user - response = upload(request) + response = _upload(request) assert response.status_code == 400 assert request.session["uploaded_file_path"] == "uploaded_files/original.inp" @@ -2488,64 +2535,6 @@ def test_upload_status_rejects_post(user): assert response.status_code == 405 -# ── Core flow test (#8) ────────────────────────────────────────────────── - - -@pytest.mark.django_db -def test_upload_persists_after_clear_session_and_new_simulation(user): - """ - Core flow: upload file -> clear_session_variables (as simulation does) - -> file path remains in session -> upload_status still returns it. - """ - factory = RequestFactory() - - # 1) Upload a file - inp_content = b"[TITLE]\nPersistence Test\n\n[OPTIONS]\nFLOW_UNITS LPS\n" - uploaded_file = SimpleUploadedFile("persist_test.inp", inp_content, content_type="text/plain") - request = factory.post( - "/upload/", - {"file": uploaded_file}, - HTTP_X_REQUESTED_WITH="XMLHttpRequest", - ) - session_middleware = SessionMiddleware(lambda req: None) - session_middleware.process_request(request) - request.session.save() - request.user = user - - saved_path = None - try: - response = upload(request) - assert response.status_code == 200 - saved_path = request.session.get("uploaded_file_path") - assert saved_path is not None - - # 2) Simulate what happens after a simulation run – clear results - request.session["show_download_button"] = True - request.session["chart_config"] = {"data": []} - clear_session_variables(request) - - # 3) Verify file path survived - assert request.session.get("uploaded_file_path") == saved_path - assert os.path.exists(saved_path) - - # 4) Verify upload_status reports the file - status_request = factory.get( - "/upload/status/", - HTTP_X_REQUESTED_WITH="XMLHttpRequest", - ) - session_middleware.process_request(status_request) - status_request.session = request.session - status_request.user = user - - status_response = upload_status(status_request) - data = json.loads(status_response.content) - assert data["has_file"] is True - assert data["filename"] == "persist_test.inp" - finally: - if saved_path and os.path.exists(saved_path): - os.remove(saved_path) - - @pytest.mark.django_db def test_upload_replaces_old_file_on_disk(user): """Uploading a new file removes the old file from disk (#2, #9).""" @@ -2564,7 +2553,7 @@ def test_upload_replaces_old_file_on_disk(user): request.user = user path_b = None try: - upload(request) + _upload(request) path_a = request.session["uploaded_file_path"] assert os.path.exists(path_a) @@ -2578,7 +2567,7 @@ def test_upload_replaces_old_file_on_disk(user): session_middleware.process_request(request2) request2.session = request.session request2.user = user - upload(request2) + _upload(request2) path_b = request2.session["uploaded_file_path"] # Old file should be gone, new file present @@ -2667,6 +2656,19 @@ def test_get_catchment_choices_no_file(): assert "Upload" in choices[0][1] +def test_get_catchment_choices_model_without_subcatchments(tmp_path): + """A loaded model with no subcatchments is not reported as a missing upload.""" + model_path = tmp_path / "empty.inp" + model_path.write_text("[TITLE]\n[OPTIONS]\nFLOW_UNITS CMS\n", encoding="utf-8") + request = RequestFactory().get("/") + SessionMiddleware(lambda req: None).process_request(request) + request.session["uploaded_file_path"] = str(model_path) + + choices = _get_catchment_choices(request) + + assert choices == [("", "--- No subcatchments found in this model ---")] + + @pytest.mark.django_db def test_subcatchments_with_real_inp_file(user): """subcatchments returns correct IDs from a real INP file.""" diff --git a/cs_app/main/views.py b/cs_app/main/views.py index 5df52ec..d5c90a8 100644 --- a/cs_app/main/views.py +++ b/cs_app/main/views.py @@ -38,6 +38,8 @@ from django.http.multipartparser import MultiPartParserError from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse +from django.utils.http import url_has_allowed_host_and_scheme +from django.views.decorators.csrf import csrf_exempt, csrf_protect from django.views.decorators.http import require_GET, require_POST from pydantic import ValidationError as PydanticValidationError from pyswmm import Simulation @@ -73,6 +75,10 @@ class InputValidationError(ValueError): """Raised for user-correctable input issues.""" +def _is_ajax(request: HttpRequest) -> bool: + return request.headers.get("X-Requested-With") == "XMLHttpRequest" + + def ajax_login_required(view_func): """ Decorator that checks authentication for AJAX requests. @@ -498,15 +504,50 @@ def file_complete(self, file_size: int) -> None: return None +def _upload_response(request: HttpRequest, payload: dict, status: int = 200) -> HttpResponse: + """ + Answer an upload: JSON for the upload zone's XHR; for the no-JS fallback form, a + flash message and a redirect back to the page it was sent from (POST -> redirect -> GET). + """ + if _is_ajax(request): + return JsonResponse(payload, status=status) + if status < 400: + messages.success(request, payload["message"]) + else: + messages.error(request, payload["error"]) + referer = request.headers.get("Referer") + if url_has_allowed_host_and_scheme( + referer, allowed_hosts={request.get_host()}, require_https=request.is_secure() + ): + return redirect(referer) + return redirect("main:simulation") + + +def _upload_error(request: HttpRequest, message: str, status: int) -> HttpResponse: + return _upload_response(request, {"error": message}, status) + + +def _upload_too_large(request: HttpRequest) -> HttpResponse: + return _upload_error( + request, f"File too large. Maximum size is {MAX_UPLOAD_SIZE // (1024 * 1024)} MB.", 413 + ) + + +@csrf_exempt @require_POST @ajax_login_required -def upload(request: HttpRequest) -> JsonResponse: +def upload(request: HttpRequest) -> HttpResponse: """ Upload a .inp file to the server. Requires user authentication. For AJAX requests (like Dropzone.js), returns a 401 JSON response if not authenticated. + The size-limiting upload handler must be installed before anything reads + the body, and CsrfViewMiddleware reads it to look for the token. So, as + Django documents for changing upload handlers, the middleware skips this + view and ``_store_upload`` runs the CSRF check once the handler is in place. + Parameters ---------- request : HttpRequest @@ -514,67 +555,57 @@ def upload(request: HttpRequest) -> JsonResponse: Returns ------- - JsonResponse - JSON response containing a success message if the file was uploaded successfully, or an error message if not. + HttpResponse + See ``_upload_response``: a success message if the file was uploaded, an error message if not. """ raw_content_length = request.META.get("CONTENT_LENGTH") if raw_content_length in ("",): - return JsonResponse({"error": "Invalid Content-Length header."}, status=400) + return _upload_error(request, "Invalid Content-Length header.", 400) try: content_length = int(raw_content_length or 0) except (TypeError, ValueError): - return JsonResponse({"error": "Invalid Content-Length header."}, status=400) + return _upload_error(request, "Invalid Content-Length header.", 400) if content_length < 0: - return JsonResponse({"error": "Invalid Content-Length header."}, status=400) + return _upload_error(request, "Invalid Content-Length header.", 400) if content_length > MAX_UPLOAD_BODY_SIZE: - return JsonResponse( - {"error": (f"File too large. Maximum size is {MAX_UPLOAD_SIZE // (1024 * 1024)} MB.")}, - status=413, - ) + return _upload_too_large(request) + + request.upload_handlers.insert(0, BodySizeLimitUploadHandler(request, MAX_UPLOAD_BODY_SIZE)) + return _store_upload(request) - request.upload_handlers = [ - BodySizeLimitUploadHandler(request, MAX_UPLOAD_BODY_SIZE), - *request.upload_handlers, - ] + +@csrf_protect +def _store_upload(request: HttpRequest) -> HttpResponse: + """Parse, validate and save the uploaded file (the CSRF check parses the body first).""" try: files = request.FILES except MultiPartParserError: - return JsonResponse({"error": "Malformed multipart request."}, status=400) + return _upload_error(request, "Malformed multipart request.", 400) if getattr(request, "_upload_body_too_large", False): - return JsonResponse( - {"error": (f"File too large. Maximum size is {MAX_UPLOAD_SIZE // (1024 * 1024)} MB.")}, - status=413, - ) + return _upload_too_large(request) if "file" not in files: - return JsonResponse({"error": "No file provided."}, status=400) + return _upload_error(request, "No file provided.", 400) uploaded_file = files["file"] filename, file_extension = os.path.splitext(uploaded_file.name) # Check file extension if file_extension.lower() != ".inp": - return JsonResponse( - {"error": "Invalid file type. Please upload a .inp file."}, - status=400, - ) + return _upload_error(request, "Invalid file type. Please upload a .inp file.", 400) # Check file size if uploaded_file.size > MAX_UPLOAD_SIZE: - return JsonResponse( - {"error": f"File too large. Maximum size is {MAX_UPLOAD_SIZE // (1024 * 1024)} MB."}, - status=413, - ) + return _upload_too_large(request) # Validate file content if not _validate_inp_file_stream(uploaded_file): logger.warning(f"Invalid .inp file content uploaded: {uploaded_file.name}") - return JsonResponse( - { - "error": "Invalid file content. The file does not appear to be a valid SWMM .inp file." - }, - status=400, + return _upload_error( + request, + "Invalid file content. The file does not appear to be a valid SWMM .inp file.", + 400, ) # Sanitize filename and scope to user @@ -602,7 +633,7 @@ def upload(request: HttpRequest) -> JsonResponse: request.session.pop("_subcatchment_ids_file", None) logger.info(f"File uploaded successfully: {file_path}") - return JsonResponse({"message": "File was sent."}) + return _upload_response(request, {"message": "File was sent."}) @require_POST @@ -756,12 +787,14 @@ def _get_catchment_choices(request: HttpRequest) -> list[tuple[str, str]]: """ Extract subcatchment IDs from the uploaded INP file stored in the session. - Returns a list of (id, id) tuples suitable for a Select widget, or a - placeholder when no file is available. + Returns a list of (id, id) tuples suitable for a Select widget, or a single + placeholder saying why there is nothing to choose (same texts as upload_zone.js). """ ids = _get_subcatchment_ids(request) if ids: - return [("", "--- Select catchment ---")] + [(sid, sid) for sid in ids] + return [("", "--- Select subcatchment ---")] + [(sid, sid) for sid in ids] + if request.session.get("uploaded_file_path"): + return [("", "--- No subcatchments found in this model ---")] return [("", "--- Upload a file first ---")] From 080ea890c274ce5ab6ee6f651e2b44efcbce211b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Buczy=C5=84ski?= Date: Wed, 30 Sep 2026 09:43:34 +0200 Subject: [PATCH 08/18] run simulations without page reloads in a workbench layout Runs are sent with fetch and answered with a results fragment rendered from the same partial as the full page; failures come back as {message, field_errors} JSON without flash messages, and the no-JS path keeps working. Adds a live range hint, a sortable results table and summary tiles. --- cs_app/e2e/pages/simulation_page.py | 110 ++++- cs_app/e2e/test_simulation.py | 394 ++++++++++++++---- cs_app/main/forms.py | 58 ++- cs_app/main/static/main/css/simulation.css | 103 +++++ .../main/static/main/js/pages/simulation.js | 294 ++++++++++--- .../main/partials/_simulation_results.html | 89 ++++ cs_app/main/templates/main/simulation.html | 146 +++---- cs_app/main/tests/test_async_views.py | 369 ++++++++++++++++ cs_app/main/tests/test_views.py | 27 +- cs_app/main/views.py | 273 +++++++----- 10 files changed, 1519 insertions(+), 344 deletions(-) create mode 100644 cs_app/main/static/main/css/simulation.css create mode 100644 cs_app/main/templates/main/partials/_simulation_results.html create mode 100644 cs_app/main/tests/test_async_views.py diff --git a/cs_app/e2e/pages/simulation_page.py b/cs_app/e2e/pages/simulation_page.py index 51835f9..1982a98 100644 --- a/cs_app/e2e/pages/simulation_page.py +++ b/cs_app/e2e/pages/simulation_page.py @@ -7,11 +7,14 @@ from .base_page import BasePage from .upload_component import UploadComponent +WINDOW_MARKER = "__simulationPageMarker" + class SimulationPage(BasePage): """POM for the simulation page (``/simulation``).""" PATH = "/simulation" + RUN_TIMEOUT_MS = 120_000 def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) @@ -24,6 +27,10 @@ def navigate_to(self) -> None: # Form fields (public — tests assert on these) # ------------------------------------------------------------------ + @property + def form(self) -> Locator: + return self.page.locator("#simulation-form") + @property def option_select(self) -> Locator: return self.page.locator("#id_option") @@ -44,6 +51,22 @@ def step_input(self) -> Locator: def catchment_name_select(self) -> Locator: return self.page.locator("#id_catchment_name") + @property + def range_fields(self) -> Locator: + return self.page.locator("#simulation-range") + + @property + def range_hint(self) -> Locator: + return self.page.locator("#simulation-range-hint") + + @property + def literature_note(self) -> Locator: + return self.page.locator("#simulation-literature-note") + + @property + def model_hint(self) -> Locator: + return self.page.locator("#simulation-model-hint") + @property def run_button(self) -> Locator: return self.page.locator("#run-simulation-button") @@ -52,6 +75,58 @@ def run_button(self) -> Locator: def loading_spinner(self) -> Locator: return self.page.locator("#simulation-loading-state") + @property + def elapsed_counter(self) -> Locator: + return self.loading_spinner.locator("[data-cs-elapsed]") + + @property + def error_summary(self) -> Locator: + return self.form.locator(".alert-danger[role='alert']") + + # ------------------------------------------------------------------ + # Results canvas + # ------------------------------------------------------------------ + + @property + def canvas(self) -> Locator: + return self.page.locator("#simulation-canvas") + + @property + def empty_state(self) -> Locator: + return self.canvas.locator(".cs-empty-state") + + @property + def results_heading(self) -> Locator: + return self.page.locator("#simulation-results-heading") + + @property + def metric_tiles(self) -> Locator: + return self.page.locator("#simulation-metrics .cs-metric") + + @property + def chart(self) -> Locator: + return self.page.locator("#simulation-chart") + + @property + def results_table(self) -> Locator: + return self.page.locator("#simulation-table") + + @property + def results_rows(self) -> Locator: + return self.results_table.locator("tbody tr") + + @property + def download_button(self) -> Locator: + return self.page.locator("#download-simulation-results-button") + + @property + def download_png_button(self) -> Locator: + return self.page.locator("[data-sim-action='download-png']") + + @property + def copy_table_button(self) -> Locator: + return self.page.locator("[data-sim-action='copy-table']") + # ------------------------------------------------------------------ # Actions # ------------------------------------------------------------------ @@ -68,12 +143,22 @@ def set_stop(self, value: int | str) -> None: def set_step(self, value: int | str) -> None: self.step_input.fill(str(value)) + def set_range(self, start: int | str, stop: int | str, step: int | str) -> None: + self.set_start(start) + self.set_stop(stop) + self.set_step(step) + def set_catchment_name(self, value: str) -> None: self.catchment_name_select.select_option(value) def run_simulation(self) -> None: self.run_button.click() + def wait_for_results(self) -> None: + """Wait until an async run has swapped in the results fragment and drawn the chart.""" + self.results_heading.wait_for(timeout=self.RUN_TIMEOUT_MS) + self.chart.locator(".main-svg").first.wait_for(state="attached") + def wait_for_catchment_options(self) -> None: """Wait until the catchment dropdown has real options (not just placeholder).""" self.page.wait_for_function( @@ -81,6 +166,21 @@ def wait_for_catchment_options(self) -> None: timeout=10_000, ) + def load_sample_and_select_catchment(self) -> str: + """Load the bundled sample model and select its first subcatchment; return its id.""" + self.upload.click_sample_data() + self.wait_for_catchment_options() + catchment = next(option for option in self.get_catchment_options() if option) + self.set_catchment_name(catchment) + return catchment + + def mark_window(self) -> None: + """Tag the current document; the tag survives only if no full page load happens.""" + self.page.evaluate(f"window.{WINDOW_MARKER} = true") + + def window_is_marked(self) -> bool: + return self.page.evaluate(f"window.{WINDOW_MARKER} === true") + # ------------------------------------------------------------------ # Queries # ------------------------------------------------------------------ @@ -92,13 +192,13 @@ def is_loading(self) -> bool: return self.loading_spinner.is_visible() def has_results_table(self) -> bool: - return self.page.locator("table.table-bordered").is_visible() + return self.results_table.is_visible() def get_results_row_count(self) -> int: - return self.page.locator("table.table-bordered tbody tr").count() + return self.results_rows.count() def has_chart(self) -> bool: - return self.page.locator("#simulation-chart").is_visible() + return self.chart.is_visible() def has_download_button(self) -> bool: return self.page.get_by_role("button", name="Download Results").is_visible() @@ -110,3 +210,7 @@ def get_catchment_options(self) -> list[str]: def is_run_button_enabled(self) -> bool: return self.run_button.is_enabled() + + def column_values(self, index: int) -> list[str]: + """Text of the given column (0 = swept parameter) in current row order.""" + return self.results_rows.locator(f":scope > :nth-child({index + 1})").all_inner_texts() diff --git a/cs_app/e2e/test_simulation.py b/cs_app/e2e/test_simulation.py index 883232d..2f2df20 100644 --- a/cs_app/e2e/test_simulation.py +++ b/cs_app/e2e/test_simulation.py @@ -6,20 +6,30 @@ from __future__ import annotations +import re + import pytest -from playwright.sync_api import Page, expect +from playwright.sync_api import Page, Route, expect +from .pages.nav_component import NavComponent from .pages.simulation_page import SimulationPage pytestmark = pytest.mark.e2e +SIMULATION_ROUTE = re.compile(r".*/simulation$") + + +def _open(page: Page, live_server) -> SimulationPage: + sp = SimulationPage(page, live_server.url) + sp.navigate_to() + return sp + class TestSimulationFormRendering: """Form fields render correctly (requires authentication).""" def test_form_fields_present(self, auth_page: Page, live_server) -> None: - sp = SimulationPage(auth_page, live_server.url) - sp.navigate_to() + sp = _open(auth_page, live_server) assert sp.has_form() expect(sp.option_select).to_be_visible() expect(sp.start_input).to_be_visible() @@ -27,110 +37,340 @@ def test_form_fields_present(self, auth_page: Page, live_server) -> None: expect(sp.step_input).to_be_visible() expect(sp.catchment_name_select).to_be_visible() + def test_page_heading(self, auth_page: Page, live_server) -> None: + sp = _open(auth_page, live_server) + assert sp.get_heading(level=1) == "Parameter sweep" + def test_default_catchment_placeholder(self, auth_page: Page, live_server) -> None: - sp = SimulationPage(auth_page, live_server.url) - sp.navigate_to() - options = sp.get_catchment_options() - assert any("Upload a file first" in opt or opt == "" for opt in options) + sp = _open(auth_page, live_server) + expect(sp.catchment_name_select.locator("option")).to_have_text( + ["--- Upload a file first ---"] + ) + assert sp.get_catchment_options() == [""] def test_predefined_method_hides_range_fields(self, auth_page: Page, live_server) -> None: - """When a predefined method is selected, start/stop/step should be hidden.""" - sp = SimulationPage(auth_page, live_server.url) - sp.navigate_to() + """Literature-value methods need no range: the fields give way to a note.""" + sp = _open(auth_page, live_server) + expect(sp.range_fields).to_be_visible() + expect(sp.literature_note).to_be_hidden() + sp.set_option("simulate_n_imperv") - col = auth_page.locator("#id_start").locator( - "xpath=ancestor::div[contains(@class,'col-md-2')]" - ) - expect(col).to_be_hidden() + expect(sp.start_input).to_be_hidden() + expect(sp.range_fields).to_be_hidden() + expect(sp.literature_note).to_be_visible() + + sp.set_option("simulate_area") + expect(sp.start_input).to_be_visible() + expect(sp.literature_note).to_be_hidden() def test_run_button_text(self, auth_page: Page, live_server) -> None: - sp = SimulationPage(auth_page, live_server.url) - sp.navigate_to() + sp = _open(auth_page, live_server) expect(sp.run_button).to_have_text("Run Simulation") + def test_empty_state_before_first_run(self, auth_page: Page, live_server) -> None: + sp = _open(auth_page, live_server) + expect(sp.empty_state).to_be_visible() + expect(sp.empty_state).to_contain_text("No results yet") + expect(sp.results_heading).to_have_count(0) + expect(sp.loading_spinner).to_be_hidden() -class TestSimulationExecution: - """Full simulation execution tests (require sample data + SWMM).""" - @pytest.mark.slow - def test_full_simulation_run(self, auth_page: Page, live_server) -> None: - """Load sample → select catchment → run → verify results table and chart.""" - sp = SimulationPage(auth_page, live_server.url) +class TestSimulationRangeHint: + """Live run-count hint under the range fields.""" + + def test_hint_counts_runs(self, auth_page: Page, live_server) -> None: + sp = _open(auth_page, live_server) + sp.set_range(0, 100, 10) + expect(sp.range_hint).to_have_text("11 runs · 0 → 100, step 10") + expect(sp.range_hint).not_to_have_class(re.compile(r"\bis-warning\b")) + # The package sweeps np.arange(start, stop + step / 2, step): 0, 4, 8 and 12. + sp.set_range(0, 11, 4) + expect(sp.range_hint).to_have_text("4 runs · 0 → 11, step 4") + + def test_hint_warns_over_server_limit(self, auth_page: Page, live_server) -> None: + """Warns (without blocking) once (stop - start) / step reaches the form's limit of 100.""" + sp = _open(auth_page, live_server) + sp.set_range(0, 99, 1) + expect(sp.range_hint).to_have_text("100 runs · 0 → 99, step 1") + + sp.set_stop(100) + expect(sp.range_hint).to_contain_text("over the limit of 100 runs") + expect(sp.range_hint).to_have_class(re.compile(r"\bis-warning\b")) + expect(sp.run_button).to_be_enabled() + + def test_hint_flags_reversed_range(self, auth_page: Page, live_server) -> None: + sp = _open(auth_page, live_server) + sp.set_range(10, 1, 1) + expect(sp.range_hint).to_have_text("Stop must be greater than or equal to start.") + + def test_hidden_invalid_range_does_not_block_a_literature_run( + self, auth_page: Page, live_server + ) -> None: + """Leftovers in the hidden range are neither checked by the browser nor sent.""" + sp = _open(auth_page, live_server) + sp.load_sample_and_select_catchment() + sp.set_range(-5, 1, 1) + sp.set_option("simulate_n_imperv") + auth_page.route( + "**/simulation", + lambda route: route.fulfill( + status=400, json={"message": "Stubbed.", "field_errors": {}} + ), + ) + + with auth_page.expect_request(lambda request: request.method == "POST") as posted: + sp.run_simulation() + + assert 'name="start"' not in (posted.value.post_data or "") + expect(sp.error_summary).to_contain_text("Stubbed.") + + +class TestSimulationModelHint: + """Step 1 explains what to do while no model is loaded.""" + + def test_hint_follows_model_state(self, auth_page: Page, live_server) -> None: + sp = _open(auth_page, live_server) + expect(sp.model_hint).to_be_visible() + + sp.load_sample_and_select_catchment() + expect(sp.model_hint).to_be_hidden() + + sp.upload.clear_upload() + expect(sp.model_hint).to_be_visible() + + def test_hint_hidden_when_model_restored(self, auth_page: Page, live_server) -> None: + sp = _open(auth_page, live_server) + sp.load_sample_and_select_catchment() sp.navigate_to() + expect(sp.model_hint).to_be_hidden() + + def test_sample_button_keeps_keyboard_focus(self, auth_page: Page, live_server) -> None: + sp = _open(auth_page, live_server) + sample = auth_page.get_by_role("button", name="Try sample data") + sample.focus() + + auth_page.keyboard.press("Enter") - sp.upload.click_sample_data() sp.wait_for_catchment_options() + expect(sample).to_be_enabled() + expect(sample).to_be_focused() + + +class TestSimulationModelChange: + """Results belong to the model they were computed from.""" + + @pytest.mark.slow + def test_new_model_marks_results_stale_and_drops_them( + self, auth_page: Page, live_server + ) -> None: + sp = _open(auth_page, live_server) + sp.load_sample_and_select_catchment() + sp.set_range(1, 2, 1) + sp.run_simulation() + sp.wait_for_results() + stale = sp.canvas.locator(".cs-stale-notice") + + # The model restored on page load is the one the results came from. + sp.navigate_to() + sp.wait_for_catchment_options() + expect(sp.results_heading).to_be_visible() + expect(stale).to_have_count(0) + + auth_page.get_by_role("button", name="Try sample data").click() + expect(stale).to_contain_text("previously loaded model") + + sp.navigate_to() + expect(sp.empty_state).to_be_visible() + - options = sp.get_catchment_options() - real_options = [o for o in options if o and "Upload" not in o and "Select" not in o] - assert len(real_options) > 0, "No catchments available after sample upload" - sp.set_catchment_name(real_options[0]) +class TestSimulationValidation: + """Server-side validation errors are shown inline without reloading the page.""" + + def test_start_greater_than_stop_shows_field_error(self, auth_page: Page, live_server) -> None: + sp = _open(auth_page, live_server) + sp.load_sample_and_select_catchment() + sp.set_range(10, 1, 1) + sp.mark_window() + + sp.run_simulation() + + expect(sp.stop_input).to_have_class(re.compile(r"\bis-invalid\b")) + expect(sp.page.locator("#id_stop-async-error")).to_have_text( + "Stop must be greater than or equal to start." + ) + expect(sp.error_summary).to_contain_text("Please correct the highlighted fields.") + expect(sp.stop_input).to_be_focused() + expect(sp.run_button).to_be_enabled() + expect(sp.loading_spinner).to_be_hidden() + expect(sp.empty_state).to_be_visible() + assert sp.window_is_marked() + + def test_field_error_clears_on_input(self, auth_page: Page, live_server) -> None: + sp = _open(auth_page, live_server) + sp.load_sample_and_select_catchment() + sp.set_range(10, 1, 1) + sp.run_simulation() + expect(sp.stop_input).to_have_class(re.compile(r"\bis-invalid\b")) + + sp.set_stop(20) + expect(sp.stop_input).not_to_have_class(re.compile(r"\bis-invalid\b")) + + +class TestSimulationProgress: + """Loading state while the server works (response held back by a route).""" + + def test_loading_state_while_running(self, auth_page: Page, live_server) -> None: + sp = _open(auth_page, live_server) + sp.load_sample_and_select_catchment() + sp.set_range(1, 3, 1) + held: list[Route] = [] + auth_page.route( + SIMULATION_ROUTE, + lambda route: held.append(route) + if route.request.method == "POST" + else route.continue_(), + ) + + sp.run_simulation() + + expect(sp.loading_spinner).to_be_visible() + expect(sp.elapsed_counter).to_have_text(re.compile(r"^\d+ s$")) + expect(sp.run_button).to_be_disabled() + expect(sp.run_button).to_have_attribute("aria-busy", "true") + expect(sp.canvas).to_have_attribute("aria-busy", "true") + assert len(held) == 1 + assert held[0].request.headers.get("x-requested-with") == "XMLHttpRequest" + + held[0].fulfill( + status=500, + content_type="application/json", + body='{"message": "An error occurred while running the simulation.", "field_errors": {}}', + ) + expect(sp.loading_spinner).to_be_hidden() + expect(sp.run_button).to_be_enabled() + expect(sp.error_summary).to_contain_text("An error occurred while running the simulation.") + +class TestSimulationExecution: + """Full simulation execution tests (require sample data + SWMM).""" + + @pytest.mark.slow + def test_full_simulation_run(self, auth_page: Page, live_server) -> None: + """Load sample → select catchment → run → results swap in without a page load.""" + sp = _open(auth_page, live_server) + sp.load_sample_and_select_catchment() sp.set_option("simulate_percent_slope") - sp.set_start(1) - sp.set_stop(5) - sp.set_step(1) + sp.set_range(1, 5, 1) + sp.mark_window() sp.run_simulation() - auth_page.wait_for_load_state("domcontentloaded", timeout=60_000) + sp.wait_for_results() + assert sp.window_is_marked(), "the run reloaded the page" + expect(sp.results_heading).to_be_focused() + expect(auth_page.locator(".toast", has_text="Simulation finished")).to_be_visible() + expect(sp.empty_state).to_have_count(0) + expect(sp.loading_spinner).to_be_hidden() assert sp.has_results_table() - assert sp.get_results_row_count() > 0 + assert sp.get_results_row_count() == 5 assert sp.has_chart() + expect(sp.chart).to_have_attribute("role", "img") + expect(sp.metric_tiles).to_have_count(4) + expect(sp.metric_tiles.first).to_contain_text("Runoff at lowest value") assert sp.has_download_button() + expect(sp.copy_table_button).to_be_visible() + expect(sp.download_png_button).to_be_visible() @pytest.mark.slow - def test_loading_spinner_appears(self, auth_page: Page, live_server) -> None: - """Spinner should appear immediately after clicking Run Simulation.""" - sp = SimulationPage(auth_page, live_server.url) - sp.navigate_to() - sp.upload.click_sample_data() - sp.wait_for_catchment_options() + def test_results_table_sorts_and_copies(self, auth_page: Page, live_server) -> None: + auth_page.context.grant_permissions(["clipboard-read", "clipboard-write"]) + sp = _open(auth_page, live_server) + sp.load_sample_and_select_catchment() + sp.set_range(1, 3, 1) + sp.run_simulation() + sp.wait_for_results() - options = sp.get_catchment_options() - real_options = [o for o in options if o and "Upload" not in o and "Select" not in o] - if not real_options: - pytest.skip("No catchments available") - sp.set_catchment_name(real_options[0]) - sp.set_option("simulate_percent_slope") - sp.set_start(1) - sp.set_stop(3) - sp.set_step(1) - - # Prevent actual form submission so the page stays and the spinner - # (shown by the existing simulation.js submit handler) remains visible. - # Note: the page has two
elements (upload zone + simulation), - # so we target the simulation form via the run button's parent form. - auth_page.evaluate( - "document.getElementById('run-simulation-button').closest('form')" - ".addEventListener('submit', e => e.preventDefault())" - ) + swept_header = sp.results_table.locator("thead th").first + swept_header.locator(".cs-sort").click() + expect(swept_header).to_have_attribute("aria-sort", "ascending") + swept_header.locator(".cs-sort").click() + expect(swept_header).to_have_attribute("aria-sort", "descending") + assert sp.column_values(0) == ["3", "2", "1"] + + sp.copy_table_button.click() + expect(auth_page.locator(".toast", has_text="Table copied as CSV.")).to_be_visible() + csv = auth_page.evaluate("navigator.clipboard.readText()") + lines = csv.split("\r\n") + assert lines[0].startswith("Percent Slope [%],Total Runoff Volume") + assert [line.split(",")[0] for line in lines[1:]] == ["3", "2", "1"] + + @pytest.mark.slow + def test_download_does_not_start_a_run(self, auth_page: Page, live_server) -> None: + """Audit S2: the download submits its own form and leaves the run form idle.""" + sp = _open(auth_page, live_server) + sp.load_sample_and_select_catchment() + sp.set_range(1, 3, 1) sp.run_simulation() - expect(sp.loading_spinner).to_be_visible(timeout=2000) + sp.wait_for_results() + with auth_page.expect_download() as download_info: + sp.download_button.click() -class TestSimulationValidation: - """Form validation edge cases (require auth + sample data).""" + assert download_info.value.suggested_filename.endswith(".xlsx") + expect(sp.loading_spinner).to_be_hidden() + expect(sp.run_button).to_be_enabled() + expect(sp.results_heading).to_be_visible() @pytest.mark.slow - def test_start_greater_than_stop_shows_error(self, auth_page: Page, live_server) -> None: - """start > stop should show a validation error.""" - sp = SimulationPage(auth_page, live_server.url) - sp.navigate_to() - sp.upload.click_sample_data() - sp.wait_for_catchment_options() + def test_second_run_replaces_results(self, auth_page: Page, live_server) -> None: + sp = _open(auth_page, live_server) + sp.load_sample_and_select_catchment() + sp.set_range(1, 3, 1) + sp.run_simulation() + sp.wait_for_results() + sp.mark_window() - options = sp.get_catchment_options() - real_options = [o for o in options if o and "Upload" not in o and "Select" not in o] - if not real_options: - pytest.skip("No catchments available") - sp.set_catchment_name(real_options[0]) - sp.set_option("simulate_percent_slope") - sp.set_start(10) - sp.set_stop(1) - sp.set_step(1) + sp.set_option("simulate_area") + sp.set_range(1, 4, 1) + sp.run_simulation() + expect(sp.results_rows).to_have_count(4, timeout=SimulationPage.RUN_TIMEOUT_MS) + + assert sp.window_is_marked() + expect(sp.results_heading).to_have_count(1) + expect(sp.results_table.locator("thead th").first).to_contain_text("Area") + expect(sp.chart.locator(".main-svg").first).to_be_attached() + + @pytest.mark.slow + def test_theme_switch_keeps_chart(self, auth_page: Page, live_server) -> None: + sp = _open(auth_page, live_server) + NavComponent(auth_page).set_theme("light") + sp.load_sample_and_select_catchment() + sp.set_range(1, 3, 1) sp.run_simulation() - auth_page.wait_for_load_state("domcontentloaded") + sp.wait_for_results() + chart_font = "document.getElementById('simulation-chart').layout.font.color" + light_color = auth_page.evaluate(chart_font) + + NavComponent(auth_page).set_theme("dark") + + auth_page.wait_for_function(f"{chart_font} !== {light_color!r}") + expect(sp.chart.locator(".main-svg").first).to_be_attached() + expect(sp.results_rows).to_have_count(3) + + @pytest.mark.slow + @pytest.mark.parametrize("theme", ["light", "dark"]) + def test_results_have_no_serious_a11y_violations( + self, auth_page: Page, live_server, theme: str + ) -> None: + # No reveal animation: axe would measure contrast mid-fade. + auth_page.emulate_media(reduced_motion="reduce") + sp = _open(auth_page, live_server) + NavComponent(auth_page).set_theme(theme) + sp.load_sample_and_select_catchment() + sp.set_range(1, 3, 1) + sp.run_simulation() + sp.wait_for_results() + + report = sp.run_axe_audit() - errors = auth_page.locator(".invalid-feedback") - expect(errors.first).to_be_visible() + assert not report.critical + report.serious, report.critical + report.serious diff --git a/cs_app/main/forms.py b/cs_app/main/forms.py index 2440219..8d0e8e5 100644 --- a/cs_app/main/forms.py +++ b/cs_app/main/forms.py @@ -15,6 +15,9 @@ from .models import UserProfile +# Same wording as the live range hint on the simulation and timeseries pages. +STOP_BEFORE_START_MESSAGE = "Stop must be greater than or equal to start." + class ContactForm(forms.Form): """ @@ -110,8 +113,13 @@ class SimulationForm(forms.Form): MAX_SWEEP_STEPS = 100 - option = forms.ChoiceField(choices=OPTIONS, widget=forms.Select(attrs={"class": "form-select"})) + option = forms.ChoiceField( + label="Parameter to vary", + choices=OPTIONS, + widget=forms.Select(attrs={"class": "form-select"}), + ) start = forms.IntegerField( + label="Start", min_value=0, max_value=10000, initial=1, @@ -119,6 +127,7 @@ class SimulationForm(forms.Form): widget=forms.NumberInput(attrs={"class": "form-control"}), ) stop = forms.IntegerField( + label="Stop", min_value=0, max_value=10000, initial=10, @@ -126,6 +135,7 @@ class SimulationForm(forms.Form): widget=forms.NumberInput(attrs={"class": "form-control"}), ) step = forms.IntegerField( + label="Step", min_value=1, max_value=10000, initial=1, @@ -133,40 +143,44 @@ class SimulationForm(forms.Form): widget=forms.NumberInput(attrs={"class": "form-control"}), ) catchment_name = forms.CharField( + label="Subcatchment", widget=forms.Select( choices=[("", "--- Upload a file first ---")], attrs={"class": "form-select"}, ), ) + RANGE_FIELDS = ("start", "stop", "step") + def __init__(self, *args, catchment_choices=None, **kwargs): super().__init__(*args, **kwargs) if catchment_choices is not None: self.fields["catchment_name"].widget.choices = catchment_choices - self.helper = FormHelper() - self.helper.form_method = "post" - self.helper.form_action = "simulation_view" - self.helper.add_input(Submit("submit", "Run Simulation")) def clean(self): cleaned_data = super().clean() - option = cleaned_data.get("option") - if option not in self.PREDEFINED_METHODS: - for field in ("start", "stop", "step"): - if cleaned_data.get(field) is None: - self.add_error(field, "This field is required for the selected method.") - start = cleaned_data.get("start") - stop = cleaned_data.get("stop") - step = cleaned_data.get("step") - if start is not None and stop is not None and start > stop: - self.add_error("stop", "Stop must be >= start.") - if start is not None and stop is not None and step and step > 0: - if (stop - start) / step >= self.MAX_SWEEP_STEPS: - self.add_error( - "step", - f"Too many steps (max {self.MAX_SWEEP_STEPS}). " - "Increase step size or reduce range.", - ) + if cleaned_data.get("option") in self.PREDEFINED_METHODS: + # Literature-value methods take no range, so values the user left in the + # range fields (even invalid ones) must not block the run. + for field in self.RANGE_FIELDS: + self.errors.pop(field, None) + return cleaned_data + + for field in self.RANGE_FIELDS: + if cleaned_data.get(field) is None and field not in self.errors: + self.add_error(field, "This field is required for the selected method.") + start = cleaned_data.get("start") + stop = cleaned_data.get("stop") + step = cleaned_data.get("step") + if start is not None and stop is not None and start > stop: + self.add_error("stop", STOP_BEFORE_START_MESSAGE) + if start is not None and stop is not None and step and step > 0: + if (stop - start) / step >= self.MAX_SWEEP_STEPS: + self.add_error( + "step", + f"Too many steps (max {self.MAX_SWEEP_STEPS}). " + "Increase step size or reduce range.", + ) return cleaned_data diff --git a/cs_app/main/static/main/css/simulation.css b/cs_app/main/static/main/css/simulation.css new file mode 100644 index 0000000..c7924df --- /dev/null +++ b/cs_app/main/static/main/css/simulation.css @@ -0,0 +1,103 @@ +/* + * Simulation page (main/simulation.html). Shared layout and result components + * live in app.css; this file only holds what the parameter sweep adds. + * + * .sim-range borderless fieldset with start/stop/step (.sim-range__fields) and the + * live run-count hint + * .sim-results results column: header, key figures, chart card, table, actions + * .sim-metrics
of .cs-metric tiles; .cs-metric__note names the swept value + * .sim-chart chart container; holds the no-JS fallback until Plotly draws + * .sim-empty empty state before the first run, with a list of what a run gives + */ + +/* ── Panel ────────────────────────────────────────────────────────── */ + +.sim-range { + min-width: 0; + margin: 0 0 1rem; + padding: 0; + border: 0; +} + +.sim-range__fields { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.75rem; +} + +/* crispy wraps each field in .mb-3; the grid gap spaces them instead. */ +.sim-range__fields > .mb-3 { + margin-bottom: 0 !important; +} + +/* ── Results ──────────────────────────────────────────────────────── */ + +.sim-results { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.sim-results__header { + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: space-between; + gap: 0.25rem 1rem; +} + +.sim-results__title { + margin: 0; + font-size: var(--cs-fs-h3); +} + +.sim-results__meta { + margin: 0; + color: var(--cs-ink-muted); + font-family: var(--cs-font-mono); + font-size: var(--cs-fs-caption); +} + +.sim-metrics { + margin: 0; +} + +.sim-metrics dd { + margin: 0; +} + +.sim-metrics .cs-metric__note { + margin-top: 0.375rem; + color: var(--cs-ink-muted); + font-size: var(--cs-fs-caption); + overflow-wrap: anywhere; +} + +.sim-chart { + min-height: 300px; +} + +.sim-chart__fallback { + margin: 0; + padding: 2rem 1rem; + color: var(--cs-ink-muted); + text-align: center; +} + +.sim-table__title { + margin: 0 0 0.75rem; + font-size: 1.0625rem; +} + +/* ── Empty state ──────────────────────────────────────────────────── */ + +.sim-empty__list { + margin: 0.75rem 0 0; + padding-left: 1.25rem; + font-size: var(--cs-fs-small); + text-align: left; +} + +.sim-empty__list li + li { + margin-top: 0.25rem; +} diff --git a/cs_app/main/static/main/js/pages/simulation.js b/cs_app/main/static/main/js/pages/simulation.js index fff1ce4..3956c9c 100644 --- a/cs_app/main/static/main/js/pages/simulation.js +++ b/cs_app/main/static/main/js/pages/simulation.js @@ -1,84 +1,256 @@ -"use strict"; +/* + * Simulation page (main/simulation.html). + * + * Panel: a hint in step 1 while no model is loaded; the range fields only for + * range-based methods, with a live "N runs" hint that warns before the server's + * step limit is hit. The run form is enhanced by CS.asyncForm; the results + * fragment is initialised by initResults() on load and after every swap + * (key figures, chart, sortable table). Result actions are delegated from the + * canvas, so they keep working across swaps. + */ +(function (CS) { + "use strict"; -document.addEventListener("DOMContentLoaded", function () { - var optionSelect = document.getElementById("id_option"); - var predefinedMethods = ["simulate_n_imperv", "simulate_n_perv", "simulate_s_imperv", "simulate_s_perv"]; + var RUNOFF = "runoff"; + var PEAK_RATE = "peak_runoff_rate"; - function toggleRangeFields() { - if (!optionSelect) { - return; + // ── Parameters panel ──────────────────────────────────────────────── + + function readNumber(input) { + return input.value.trim() === "" ? NaN : Number(input.value); + } + + function initRange(form) { + var option = form.elements.namedItem("option"); + var range = document.getElementById("simulation-range"); + var hint = document.getElementById("simulation-range-hint"); + var note = document.getElementById("simulation-literature-note"); + var inputs = ["start", "stop", "step"].map(function (name) { + return form.elements.namedItem(name); + }); + var predefined = form.dataset.predefinedMethods.split(" "); + var maxSteps = Number(form.dataset.maxSteps); + + function updateHint() { + var values = inputs.map(readNumber); + var result = CS.format.sweepRange(values[0], values[1], values[2], maxSteps); + hint.textContent = result.text; + hint.classList.toggle("is-warning", result.warning); + hint.hidden = false; } - var isPredefined = predefinedMethods.indexOf(optionSelect.value) !== -1; - ["id_start", "id_stop", "id_step"].forEach(function (fieldId) { - var field = document.getElementById(fieldId); - if (!field) { - return; - } - var wrapper = field.closest(".col-md-2"); - if (wrapper) { - wrapper.style.display = isPredefined ? "none" : ""; - } + + // Disabled as well as hidden: leftover values in the unused range are then neither + // sent nor checked by constraint validation (an invalid hidden input would + // silently block the submit). + function toggleRange() { + var isPredefined = predefined.indexOf(option.value) !== -1; + range.hidden = isPredefined; + range.disabled = isPredefined; + note.hidden = !isPredefined; + } + + option.addEventListener("change", toggleRange); + inputs.forEach(function (input) { + input.addEventListener("input", updateHint); }); + toggleRange(); + updateHint(); } - if (optionSelect) { - optionSelect.addEventListener("change", toggleRangeFields); - toggleRangeFields(); + function initModelHint() { + var hint = document.getElementById("simulation-model-hint"); + document.addEventListener("cs:model-changed", function () { + hint.hidden = true; + }); + document.addEventListener("cs:model-cleared", function () { + hint.hidden = false; + }); } - var runSimulationButton = document.getElementById("run-simulation-button"); - var simulationLoadingState = document.getElementById("simulation-loading-state"); - var simulationForm = runSimulationButton ? runSimulationButton.closest("form") : null; - var isSubmitting = false; + // ── Key figures ───────────────────────────────────────────────────── - function resetSimulationSubmitState() { - isSubmitting = false; - if (runSimulationButton) { - runSimulationButton.disabled = false; - runSimulationButton.removeAttribute("aria-disabled"); + /** "Total Runoff Volume [m³]" → {name: "Total Runoff Volume", unit: "m³"}; "[-]" has no unit. */ + function splitLabel(label) { + var match = /^(.*?)\s*\[([^\]]*)\]\s*$/.exec(label || ""); + if (!match) { + return { name: label || "", unit: "" }; } - if (simulationLoadingState) { - simulationLoadingState.classList.add("d-none"); + return { name: match[1], unit: match[2] === "-" ? "" : match[2] }; + } + + /** About four significant digits, so volumes and small rates both read well. */ + function formatValue(value) { + var magnitude = Math.abs(value); + return CS.format.number(value, magnitude >= 100 ? 1 : magnitude >= 1 ? 2 : 4); + } + + /** "Percent Slope = 4 %": the swept value a figure refers to, as the table prints it. */ + function describeSwept(value, xLabel) { + var label = splitLabel(xLabel); + // Non-breaking spaces keep "= 10 %" together when the tile wraps. + var text = label.name + "\u00a0=\u00a0" + Number(value.toFixed(3)); + return label.unit ? text + "\u00a0" + label.unit : text; + } + + function percentChange(from, to) { + if (!from) { + return "—"; } + var change = ((to - from) / Math.abs(from)) * 100; + return (change > 0 ? "+" : "") + CS.format.number(change, 1); } - if (simulationForm && runSimulationButton) { - simulationForm.addEventListener("submit", function (event) { - var submitter = event.submitter || document.activeElement; - if (submitter && submitter.id && submitter.id !== "run-simulation-button") { - return; - } - if (isSubmitting) { - event.preventDefault(); - return; - } - isSubmitting = true; - runSimulationButton.disabled = true; - runSimulationButton.setAttribute("aria-disabled", "true"); - if (simulationLoadingState) { - simulationLoadingState.classList.remove("d-none"); + function finiteRows(rows, field) { + return rows.filter(function (row) { + return Number.isFinite(row[field]); + }); + } + + /** Tiles for the outputs present in the data, rows ordered by the swept value. */ + function keyFigures(config) { + var x = config.x; + var yLabels = config.yLabels || {}; + var rows = finiteRows(config.data, x).sort(function (a, b) { + return a[x] - b[x]; + }); + var tiles = []; + + var runoffRows = finiteRows(rows, RUNOFF); + if (runoffRows.length > 1) { + var lowest = runoffRows[0]; + var highest = runoffRows[runoffRows.length - 1]; + var runoffUnit = splitLabel(yLabels[RUNOFF]).unit; + tiles.push( + { label: "Runoff at lowest value", value: formatValue(lowest[RUNOFF]), unit: runoffUnit, + note: "At " + describeSwept(lowest[x], config.xLabel) }, + { label: "Runoff at highest value", value: formatValue(highest[RUNOFF]), unit: runoffUnit, + note: "At " + describeSwept(highest[x], config.xLabel) }, + { label: "Runoff change", value: percentChange(lowest[RUNOFF], highest[RUNOFF]), unit: "%", + note: "From lowest to highest value" } + ); + } + + var peakRows = finiteRows(rows, PEAK_RATE); + if (peakRows.length) { + var peak = peakRows.reduce(function (best, row) { + return row[PEAK_RATE] > best[PEAK_RATE] ? row : best; + }); + tiles.push({ label: "Highest peak runoff rate", value: formatValue(peak[PEAK_RATE]), + unit: splitLabel(yLabels[PEAK_RATE]).unit, note: "At " + describeSwept(peak[x], config.xLabel) }); + } + return tiles; + } + + function element(tag, className, text) { + var el = document.createElement(tag); + el.className = className; + if (text) { + el.textContent = text; + } + return el; + } + + function renderKeyFigures(list, tiles) { + list.replaceChildren.apply(list, tiles.map(function (tile) { + var value = element("dd", "cs-metric__value", tile.value); + if (tile.unit && tile.value !== "—") { + value.append(element("span", "cs-metric__unit", tile.unit)); } + var group = element("div", "cs-metric"); + group.append(element("dt", "cs-metric__label", tile.label), value, + element("dd", "cs-metric__note", tile.note)); + return group; + })); + list.hidden = tiles.length === 0; + } + + // ── Results ───────────────────────────────────────────────────────── + + function readConfig(root) { + var script = root.querySelector("#chart-config"); + if (!script) { + return null; + } + try { + var config = JSON.parse(script.textContent); + return config && Array.isArray(config.data) ? config : null; + } catch (error) { + console.error("Invalid simulation chart config:", error); + return null; + } + } + + function chartLabel(config) { + var outputs = config.y.map(function (field) { + return splitLabel((config.yLabels || {})[field] || field).name; }); - window.addEventListener("pageshow", resetSimulationSubmitState); + return "Line charts of " + outputs.join(", ") + " against " + config.xLabel + + ". The results table lists the same values."; } - var configElement = document.getElementById("chart-config"); - if (!configElement) { - return; + function reveal(button, isAvailable) { + if (button) { + button.hidden = !isAvailable; + } } - try { - var config = JSON.parse(configElement.textContent); - renderLineChart("simulation-chart", config.data, config.x, config.y, { - title: config.title, + function initResults(root) { + var table = root.querySelector("#simulation-table"); + if (table) { + CS.table.sortable(table); + reveal(root.querySelector("[data-sim-action='copy-table']"), true); + } + var config = readConfig(root); + if (!config) { + return; + } + renderKeyFigures(root.querySelector("#simulation-metrics"), keyFigures(config)); + var pngButton = root.querySelector("[data-sim-action='download-png']"); + var drawn = CS.charts.line(root.querySelector("#simulation-chart"), config.data, config.x, config.y, { xLabel: config.xLabel, yLabels: config.yLabels, + ariaLabel: chartLabel(config), + fileName: pngButton.dataset.filename, + }); + reveal(pngButton, drawn !== null); + } + + var ACTIONS = { + "copy-table": function (canvas) { + CS.clipboard.copy(CS.table.toCSV(canvas.querySelector("#simulation-table")), "Table copied as CSV."); + }, + "download-png": function (canvas, button) { + CS.charts.downloadPng(canvas.querySelector("#simulation-chart"), button.dataset.filename); + }, + }; + + function initActions(canvas) { + canvas.addEventListener("click", function (event) { + var button = event.target.closest("[data-sim-action]"); + if (button && ACTIONS[button.dataset.simAction]) { + ACTIONS[button.dataset.simAction](canvas, button); + } }); - } catch (error) { - console.error("Failed to render chart:", error); - var chartElement = document.getElementById("simulation-chart"); - if (chartElement) { - chartElement.innerHTML = '

Failed to load chart.

'; - } } -}); + + document.addEventListener("DOMContentLoaded", function () { + var form = document.getElementById("simulation-form"); + var canvas = document.getElementById("simulation-canvas"); + if (!form || !canvas) { + return; + } + initModelHint(); + initRange(form); + initActions(canvas); + initResults(canvas); + + CS.asyncForm({ + form: form, + submitButton: document.getElementById("run-simulation-button"), + target: canvas, + loadingEl: document.getElementById("simulation-loading-state"), + successMessage: "Simulation finished", + onSuccess: initResults, + }); + }); +})(window.CS); diff --git a/cs_app/main/templates/main/partials/_simulation_results.html b/cs_app/main/templates/main/partials/_simulation_results.html new file mode 100644 index 0000000..f3c9b13 --- /dev/null +++ b/cs_app/main/templates/main/partials/_simulation_results.html @@ -0,0 +1,89 @@ +{% comment %} +Results fragment of the simulation page. Rendered inside #simulation-canvas on a full +page load and returned alone to async runs (the canvas receives it as innerHTML), so +nothing may precede #simulation-results-heading: the client focuses the first heading. +{% endcomment %} +{% load l10n %} +
+ {% if show_download_button %} +
+
+

Simulation results

+

+ {{ results_data|length }} run{{ results_data|length|pluralize }} · {{ chart_config.xLabel }} +

+
+ + {# Key figures, filled by simulation.js from #chart-config. #} + + +
+
+

Response to {{ chart_config.xLabel }}

+ {# Script-only actions stay hidden until simulation.js can perform them. #} + +
+
+
+

The chart needs JavaScript. The table below lists the same values.

+
+
+
+ {{ chart_config|json_script:"chart-config" }} + + {# Not a
: the scrollable wrapper below is the labelled region. #} +
+

Results table

+
+ + + + + {% for label in chart_config.yLabels.values %} + + {% endfor %} + + + + {% for row in results_data %} + + {% for value in row %} + {% if forloop.first %} + + {% else %} + + {% endif %} + {% endfor %} + + {% endfor %} + +
{{ chart_config.xLabel }}{{ label }}
{{ value|floatformat:"-3" }}{{ value|floatformat:4 }}
+
+
+ +
+ {% if output_file_name %} + {# Submits #simulation-download-form (on the page), never the async run form. #} + + + {% endif %} + +
+
+ {% else %} +
+ +

No results yet

+

+ Load a model, pick a subcatchment and a parameter, then select Run Simulation. +

+
    +
  • Key figures: runoff at both ends of the range and how much it changes
  • +
  • A chart for every output, from total runoff to evaporation
  • +
  • A sortable table you can copy or download as Excel
  • +
+
+ {% endif %} +
diff --git a/cs_app/main/templates/main/simulation.html b/cs_app/main/templates/main/simulation.html index 70ef1dd..521369d 100644 --- a/cs_app/main/templates/main/simulation.html +++ b/cs_app/main/templates/main/simulation.html @@ -1,7 +1,7 @@ {% extends 'base.html' %} -{% load static %} +{% load static crispy_forms_tags %} -{% block title %}Catchment Simulations{% endblock %} +{% block title %}Parameter sweep · Catchment Simulation{% endblock %} {% block head_scripts %} + +{% endblock %} {% block style %} + {% endblock %} {% block content %} - -
-
-
- -
-

Catchment Area Neural Network Model

-

The Catchment Area Neural Network Model is a machine learning model designed to analyze and predict the behavior of water flow in catchment areas. The model takes into account various features of the catchment area, such as the land surface, slope, imperviousness, and infiltration properties. By understanding the relationships between these features, the model can provide valuable insights for water resource management, flood control, and environmental conservation efforts.

-

-

Features


-

The model utilizes the following features as inputs:

-
    -
  • Area (ha): The actual area of the catchment
  • -
  • Width (m): The characteristic width of the overland flow path for surface runoff
  • -
  • Slope (%): The average percentage slope of the catchment
  • -
  • Imperv (%): The percentage of land surface (excluding any LID) that is impervious
  • -
  • N-Imperv (-): Manning's n coefficient for overland flow through the impervious portion of the partial catchment
  • -
  • N-Perv (-): Manning's n coefficient for overland flow through the pervious portion of the partial catchment
  • -
  • Dstore-Imperv (m): Depth of depression storage on the impervious portion of the partial catchment (inches or millimeters)
  • -
  • Dstore-Perv (m): Depth of depression storage on the pervious portion of the partial catchment (inches or millimeters)
  • -
  • Zero-Imperv (%): Percentage of impervious area without depression storage
  • -
  • Infiltration Data (-): Infiltration model (Horton, Modified Horton, Green-Ampt, Modified Green-Ampt, Curve Number (SCS))
  • -
-
-

Model Architecture:

- The model is a fully connected neural network with the following architecture:
-
- Opis obrazu - -
    -
  • Input layer with 8 neurons and ReLU activation function
  • -
  • Hidden layer with 8 neurons and ReLU activation function
  • -
  • Output layer with 1 neuron and ReLU activation function
  • -
  • The model is compiled using the Adam optimizer and Mean Squared Error (MSE) as the loss function. Additional metrics used for evaluation are Mean Absolute Error (MAE) and Mean Squared Error (MSE).
  • -
-
-

Applications

- This model can be used for various applications, including:
-
-
    -
  • Water resource management: By predicting the behavior of water flow in catchment areas, the model can help inform decision-making for water allocation and conservation efforts.
  • -
  • Flood control: Understanding the factors that contribute to flooding can help identify areas at risk and inform the design of effective flood mitigation measures.
  • -
  • Environmental conservation: Predicting the impact of changes in land use, climate, or other factors on catchment areas can help inform conservation strategies and preserve valuable ecosystems.
  • -
-

- +
+

Model comparison

+

SWMM vs neural network runoff

+

Run your SWMM model and see how closely the neural network reproduces its runoff for every subcatchment.

+
+ +
+
+
+
+ +

Step 1: Model

+ {% include 'main/_upload_zone.html' with compact=True %}
-
- -
-

Comparison of SWMM and ANN in runoff calculation.

+
+
+ +

Step 2: Compare

+
+ + {% csrf_token %} +

SWMM runs the model, then the network predicts runoff from the same subcatchment properties.

+ + + +
+ +
+

Validity range

+

Trained on a small data set, the network is reliable only within a limited precipitation range. Treat results outside it as indicative.

+
+
+
+
- {% include 'main/_upload_zone.html' %} +
+ {# CS.asyncForm replaces this wrapper's contents with the results fragment. #} +
+ {% include 'main/partials/_calculations_results.html' %} +
- -
- {% csrf_token %} -
-
- -
+
+

About the model

+

Catchment Area Neural Network Model

+

A small fully connected network that estimates subcatchment runoff from the same properties SWMM uses, so it can be checked against a full SWMM run.

+ +
+
+ Features +
+

The network reads eight properties of each subcatchment from the SWMM model:

+
+
Imperv (%)
Share of the land surface, excluding LID, that is impervious.
+
Width
Characteristic width of the overland flow path.
+
Slope (%)
Average surface slope.
+
N-Imperv
Manning's n for overland flow over the impervious area.
+
N-Perv
Manning's n for overland flow over the pervious area.
+
Dstore-Imperv
Depression storage depth on the impervious area.
+
Dstore-Perv
Depression storage depth on the pervious area.
+
Zero-Imperv (%)
Share of the impervious area without depression storage.
+
- - - -
-
Warning!
-

Sensitivity analysis indicates that the model works correctly only within a certain range of precipitation. This is due to the small training set. -

-

Results Comparison

- - - - - - - - - - - - - {% if not df_is_empty %} - {% for index, row in df.iterrows %} - - - - - - {% endfor %} - {% else %} - - - - - - {% endif %} - -
NameSWMM Runoff [m3]ANN Runoff [m3]
{{ row.Name }}{{ row.SWMM_Runoff_m3|floatformat:2 }}{{ row.ANN_Runoff_m3|floatformat:2 }}
---
- - +
+
+ Architecture +
+
+ Diagram of the neural network: 8 inputs, 8 hidden neurons, 1 output +
+
    +
  • Input layer: 8 neurons, ReLU activation.
  • +
  • Hidden layer: 8 neurons, ReLU activation.
  • +
  • Output layer: 1 neuron, ReLU activation.
  • +
  • Trained with the Adam optimiser and mean squared error (MSE) loss; evaluated with mean absolute error (MAE) and MSE.
  • +
+
+
+
+ Applications +
+
    +
  • Water resource management: predicting catchment runoff informs water allocation and conservation decisions.
  • +
  • Flood control: understanding what drives runoff helps locate areas at risk and design mitigation measures.
  • +
  • Environmental conservation: estimating the effect of land-use or climate changes supports conservation strategies.
  • +
+
+
-
- +
+{% endblock %} +{% block additional_scripts %} + {% endblock %} diff --git a/cs_app/main/templates/main/partials/_calculations_results.html b/cs_app/main/templates/main/partials/_calculations_results.html new file mode 100644 index 0000000..df0b866 --- /dev/null +++ b/cs_app/main/templates/main/partials/_calculations_results.html @@ -0,0 +1,99 @@ +{% load l10n %} +{# Numbers come from views._summarise_comparison; the charts read the same rows from #calculations-chart-data. #} +
+ {% if comparison %} +
+

Results comparison

+ + {% with metrics=comparison.metrics %} +
+
+
Mean absolute error
+
{{ metrics.mae|floatformat:2|default:"—" }}{{ comparison.unit }}
+
+
+
Root mean square error
+
{{ metrics.rmse|floatformat:2|default:"—" }}{{ comparison.unit }}
+
+
+
Mean absolute % error
+
{{ metrics.mape|floatformat:1|default:"—" }}{% if metrics.mape is not None %}%{% endif %}
+ {% if metrics.mape_excluded %} +
Excludes {{ metrics.mape_excluded }} subcatchment{{ metrics.mape_excluded|pluralize }} with zero SWMM runoff
+ {% endif %} +
+
+
Subcatchments
+
{{ metrics.count }}
+
+
+ {% endwith %} + + {# Shown by calculations.js once the charts can be drawn; without JS the table carries the data. #} + + +
+
+

Per-subcatchment comparison

+ +
+
+ + + + + + + + + + + + {% for row in comparison.rows %} + + + + + + + + {% endfor %} + +
NameSWMM Runoff [{{ comparison.unit }}]ANN Runoff [{{ comparison.unit }}]Difference [{{ comparison.unit }}]Difference [%]
{{ row.Name }}{{ row.SWMM_Runoff|floatformat:2|default:"—" }}{{ row.ANN_Runoff|floatformat:2|default:"—" }}{{ row.Difference|floatformat:2|default:"—" }}{{ row.Difference_pct|floatformat:1|default:"—" }}
+
+ {# Outside the scroll wrapper, so it wraps to the column width instead of the table's. #} +

Difference = ANN − SWMM: a positive value means the network predicts more runoff than SWMM.

+
+ {{ comparison.rows|json_script:"calculations-chart-data" }} +
+ {% else %} +
+ +

No comparison yet

+

Load a model and run the calculations to see error metrics, charts and a per-subcatchment table.

+
+ {% endif %} +
diff --git a/cs_app/main/tests/test_async_views.py b/cs_app/main/tests/test_async_views.py index 224dace..8f002a9 100644 --- a/cs_app/main/tests/test_async_views.py +++ b/cs_app/main/tests/test_async_views.py @@ -1,6 +1,6 @@ """Tests for the async (fetch) contract of the run views. -Run views (simulation, timeseries) answer requests sent with +Run views (simulation, timeseries, calculations) answer requests sent with ``X-Requested-With: XMLHttpRequest`` with the results fragment on success and a ``{"message", "field_errors"}`` JSON body on failure, without flash messages. """ @@ -8,6 +8,7 @@ import json import os import re +from types import SimpleNamespace import numpy as np import pandas as pd @@ -17,16 +18,19 @@ from django.urls import reverse from main.views import ( + CALC_RESULT_TOKEN_SESSION_KEY, FORM_INVALID_MESSAGE, NO_MODEL_MESSAGE, SIM_RESULT_TOKEN_SESSION_KEY, TS_RESULT_TOKEN_SESSION_KEY, _result_cache_key, + _user_upload_dir, ) AJAX = {"HTTP_X_REQUESTED_WITH": "XMLHttpRequest"} SIMULATION_PARTIAL = "main/partials/_simulation_results.html" TIMESERIES_PARTIAL = "main/partials/_timeseries_results.html" +CALCULATIONS_PARTIAL = "main/partials/_calculations_results.html" SIMULATION_POST = { "option": "simulate_percent_slope", "start": "1", @@ -114,6 +118,59 @@ def fake_simulation(monkeypatch): monkeypatch.setattr("main.views.FeaturesSimulation", DummyFeaturesSimulation) +@pytest.fixture +def fake_calculations(monkeypatch): + """Replace the SWMM run and ANN prediction with a one-subcatchment SI (CMS) result.""" + + class FakeSimulation: + def __init__(self, _path): + pass + + def __enter__(self): + return self + + def __iter__(self): + return iter(()) + + def __exit__(self, exc_type, exc, tb): + return False + + class FakeModel: + def __init__(self, _path): + dataframe = pd.DataFrame(index=["S1"], data={"TotalRunoffMG": [12.34]}) + self.subcatchments = SimpleNamespace(dataframe=dataframe) + options = pd.DataFrame(index=["FLOW_UNITS"], data={"Value": ["CMS"]}) + self.inp = SimpleNamespace(options=options) + + monkeypatch.setattr("main.views.Simulation", FakeSimulation) + monkeypatch.setattr("main.views.swmmio.Model", FakeModel) + monkeypatch.setattr("main.views.predict_runoff", lambda _model: np.array([4.56])) + monkeypatch.setattr("main.views._cleanup_swmm_side_files", lambda _path: None) + + +@pytest.fixture +def calc_model_path(user): + """A stub model file in the user's upload directory (the SWMM run itself is faked).""" + user_dir = _user_upload_dir(user.id) + os.makedirs(user_dir, exist_ok=True) + path = os.path.join(user_dir, "test.inp") + with open(path, "w", encoding="utf-8") as model_file: + model_file.write("[TITLE]\n[OPTIONS]\n") + yield path + if os.path.exists(path): + os.remove(path) + + +@pytest.fixture +def calc_client(client, user, calc_model_path): + """Logged-in client whose session points at ``calc_model_path``.""" + client.force_login(user) + session = client.session + session["uploaded_file_path"] = calc_model_path + session.save() + return client + + def _template_names(response) -> list[str]: return [template.name for template in response.templates] @@ -525,6 +582,174 @@ def test_timeseries_ajax_unexpected_error_returns_500(run_client, monkeypatch): _assert_json_error(response, 500, "An error occurred while running the analysis.") +# --------------------------------------------------------------------------- +# Calculations +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +def test_calculations_ajax_success_returns_results_fragment(calc_client, fake_calculations): + response = calc_client.post(reverse("main:calculations"), **AJAX) + + _assert_fragment(response, CALCULATIONS_PARTIAL, "calculations-results") + content = response.content.decode() + assert 'id="calculations-results-heading"' in content + assert "Results comparison" in content + assert "S1" in content + # The report's 10^6 L become m³: 12.34 -> 12,340 and the ANN's 4.56 -> 4,560. + assert "12340.00" in content + assert "4560.00" in content + assert "SWMM Runoff [m³]" in content + assert 'data-calc-unit="m³"' in content + assert 'id="calculations-table"' in content + assert 'id="calculations-chart-data"' in content + assert "cs-empty-state" not in content + + +@pytest.mark.django_db +def test_calculations_non_ajax_success_redirects_to_results_page(calc_client, fake_calculations): + """Post/Redirect/Get: refreshing the results page does not run the comparison again.""" + response = calc_client.post(reverse("main:calculations"), follow=True) + + assert response.redirect_chain == [(reverse("main:calculations"), 302)] + names = _template_names(response) + assert "main/calculations.html" in names + assert CALCULATIONS_PARTIAL in names + assert b"12340.00" in response.content + + +@pytest.mark.django_db +def test_calculations_page_shows_last_async_comparison(calc_client, fake_calculations): + calc_client.post(reverse("main:calculations"), **AJAX) + + response = calc_client.get(reverse("main:calculations")) + + assert b'id="calculations-table"' in response.content + assert b"12340.00" in response.content + + +@pytest.mark.django_db +def test_calculations_page_drops_an_expired_comparison(calc_client, fake_calculations): + calc_client.post(reverse("main:calculations"), **AJAX) + cache.clear() + + response = calc_client.get(reverse("main:calculations")) + + assert b"No comparison yet" in response.content + assert CALC_RESULT_TOKEN_SESSION_KEY not in calc_client.session + + +@pytest.mark.django_db +def test_calculations_get_renders_empty_state(client): + response = client.get(reverse("main:calculations")) + + assert response.status_code == 200 + assert CALCULATIONS_PARTIAL in _template_names(response) + assert b'id="calculations-results-heading"' not in response.content + assert b'

No comparison yet

' in response.content + assert b'id="calculations-table"' not in response.content + assert b'id="calculations-chart-data"' not in response.content + + +@pytest.mark.django_db +def test_calculations_ajax_without_upload_returns_400_without_flash(client, user): + client.force_login(user) + message = "Please upload a file first." + + response = client.post(reverse("main:calculations"), **AJAX) + + _assert_json_error(response, 400, message) + _assert_no_flash_on_next_page(client, reverse("main:calculations"), message) + + +@pytest.mark.django_db +def test_calculations_with_deleted_model_returns_400(calc_client, calc_model_path): + os.remove(calc_model_path) + message = "Input file is missing. Please upload the model again." + + ajax_response = calc_client.post(reverse("main:calculations"), **AJAX) + page_response = calc_client.post(reverse("main:calculations")) + + _assert_json_error(ajax_response, 400, message) + assert b"alert-danger" in page_response.content + assert message.encode() in page_response.content + + +@pytest.mark.django_db +def test_calculations_model_without_subcatchments_returns_400(calc_client, calc_model_path): + """A real SWMM run of the example model with its subcatchment sections removed.""" + with open(os.path.join(settings.BASE_DIR, "data", "example.inp"), encoding="utf-8") as src: + sections = re.split(r"(?m)^(?=\[)", src.read()) + dropped = ("[SUBCATCHMENTS]", "[SUBAREAS]", "[INFILTRATION]", "[Polygons]") + with open(calc_model_path, "w", encoding="utf-8") as model_file: + model_file.writelines(s for s in sections if not s.startswith(dropped)) + + response = calc_client.post(reverse("main:calculations"), **AJAX) + + _assert_json_error(response, 400, "The model has no subcatchments to compare.") + + +@pytest.mark.django_db +def test_calculations_ajax_rejects_out_of_bounds_path(client, user): + client.force_login(user) + session = client.session + session["uploaded_file_path"] = "/etc/passwd" + session.save() + + response = client.post(reverse("main:calculations"), **AJAX) + + _assert_json_error(response, 400, "Invalid file path detected.") + _assert_no_flash_on_next_page( + client, reverse("main:calculations"), "Invalid file path detected." + ) + + +@pytest.mark.django_db +def test_calculations_ajax_oversized_result_returns_413( + calc_client, fake_calculations, monkeypatch +): + monkeypatch.setattr("main.views.MAX_RESULT_CACHE_BYTES", 10) + + response = calc_client.post(reverse("main:calculations"), **AJAX) + + _assert_json_error( + response, 413, "The comparison is too large to keep. Use a model with fewer subcatchments." + ) + assert CALC_RESULT_TOKEN_SESSION_KEY not in calc_client.session + + +@pytest.mark.django_db +def test_calculations_ajax_failure_returns_500(calc_client, monkeypatch): + def failing_simulation(_path): + raise RuntimeError("swmm crashed") + + monkeypatch.setattr("main.views.Simulation", failing_simulation) + monkeypatch.setattr("main.views._cleanup_swmm_side_files", lambda _path: None) + + response = calc_client.post(reverse("main:calculations"), **AJAX) + + _assert_json_error(response, 500, "An error occurred while performing calculations.") + + +@pytest.mark.django_db +def test_calculations_anonymous_ajax_post_returns_401_json(client): + response = client.post(reverse("main:calculations"), **AJAX) + + body = _assert_json_error(response, 401, "Authentication required.") + assert body["login_url"] == settings.LOGIN_URL + assert body["error"] == "Authentication required." + + +@pytest.mark.django_db +def test_calculations_anonymous_post_redirects_to_login(client): + calc_url = reverse("main:calculations") + + response = client.post(calc_url) + + assert response.status_code == 302 + assert response.url == f"{settings.LOGIN_URL}?next={calc_url}" + + # --------------------------------------------------------------------------- # Failed no-JS runs keep the previous results on the page # --------------------------------------------------------------------------- @@ -579,3 +804,21 @@ def test_non_ajax_crashed_run_keeps_previous_results( assert response.status_code == 200 assert "An error occurred" in response.content.decode() assert f'id="{heading_id}"' in response.content.decode() + + +@pytest.mark.django_db +def test_non_ajax_crashed_calculations_keep_previous_comparison( + calc_client, fake_calculations, monkeypatch +): + calc_client.post(reverse("main:calculations"), **AJAX) + + def crash(_path): + raise RuntimeError("boom") + + monkeypatch.setattr("main.views._compare_swmm_and_ann", crash) + response = calc_client.post(reverse("main:calculations")) + + assert response.status_code == 200 + assert b"An error occurred" in response.content + assert b'id="calculations-results-heading"' in response.content + assert b"12340.00" in response.content diff --git a/cs_app/main/tests/test_views.py b/cs_app/main/tests/test_views.py index 489a2e3..daeedd3 100644 --- a/cs_app/main/tests/test_views.py +++ b/cs_app/main/tests/test_views.py @@ -23,8 +23,10 @@ TS_RESULT_TOKEN_SESSION_KEY, _get_catchment_choices, _get_subcatchment_ids, + _load_comparison, _result_cache_key, _safe_download_filename, + _summarise_comparison, _user_upload_dir, _validate_inp_file_stream, calculations, @@ -560,6 +562,7 @@ def test_calculations_get(user): """ factory = RequestFactory() request = factory.get("calculations") + SessionMiddleware(lambda req: None).process_request(request) request.user = user response = calculations(request) @@ -593,6 +596,8 @@ def __init__(self, _path): if state["report_ready"]: base["TotalRunoffMG"] = [12.34] self.subcatchments = SimpleNamespace(dataframe=base) + options = pd.DataFrame(index=["FLOW_UNITS"], data={"Value": ["CMS"]}) + self.inp = SimpleNamespace(options=options) monkeypatch.setattr("main.views.Simulation", FakeSimulation) monkeypatch.setattr("main.views.swmmio.Model", FakeModel) @@ -606,7 +611,11 @@ def __init__(self, _path): session_middleware.process_request(request) user_dir = _user_upload_dir(user.id) - request.session["uploaded_file_path"] = os.path.join(user_dir, "test.inp") + model_path = os.path.join(user_dir, "test.inp") + os.makedirs(user_dir, exist_ok=True) + with open(model_path, "w", encoding="utf-8") as model_file: + model_file.write("[TITLE]\n[OPTIONS]\n") + request.session["uploaded_file_path"] = model_path request.session.save() message_middleware = MessageMiddleware(lambda req: None) @@ -614,11 +623,14 @@ def __init__(self, _path): request.user = user - response = calculations(request) + try: + response = calculations(request) + finally: + os.remove(model_path) - assert response.status_code == 200 - assert b"S1" in response.content - assert b"12.34" in response.content + assert response.status_code == 302 + [row] = _load_comparison(request)["rows"] + assert (row["Name"], row["SWMM_Runoff"]) == ("S1", pytest.approx(12340.0)) @pytest.mark.django_db @@ -643,11 +655,94 @@ def test_calculations_anonymous_get_returns_200(client): @pytest.mark.django_db -def test_calculations_anonymous_post_returns_200(client): - """Anonymous POST to /calculations returns 200 with 'upload a file' error (no redirect).""" +def test_calculations_anonymous_post_redirects_to_login(client): + """Running calculations requires login server-side; only the GET page is public.""" calc_url = reverse("main:calculations") response = client.post(calc_url) - assert response.status_code == 200 + assert response.status_code == 302 + assert response.url == f"{settings.LOGIN_URL}?next={calc_url}" + + +def _comparison_frame(swmm, ann): + return pd.DataFrame( + { + "Name": [f"S{i + 1}" for i in range(len(swmm))], + "SWMM_Runoff": swmm, + "ANN_Runoff": np.array(ann, dtype=np.float32), + } + ) + + +def test_summarise_comparison_rows_and_metrics(): + summary = _summarise_comparison(_comparison_frame([10.0, 20.0], [12.0, 17.0]), "m³") + + assert summary["unit"] == "m³" + first, second = summary["rows"] + assert first == { + "Name": "S1", + "SWMM_Runoff": 10.0, + "ANN_Runoff": 12.0, + "Difference": 2.0, + "Difference_pct": 20.0, + } + assert second["Difference"] == -3.0 + assert second["Difference_pct"] == -15.0 + # Plain floats: json_script cannot serialise numpy scalars. + assert all(type(value) is float for value in first.values() if value != "S1") + metrics = summary["metrics"] + assert metrics["count"] == 2 + assert metrics["mae"] == pytest.approx(2.5) + assert metrics["rmse"] == pytest.approx(np.sqrt((4 + 9) / 2)) + assert metrics["mape"] == pytest.approx(17.5) + assert metrics["mape_excluded"] == 0 + + +def test_summarise_comparison_skips_zero_swmm_runoff_in_percentages(): + summary = _summarise_comparison(_comparison_frame([0.0, 10.0], [1.0, 11.0]), "m³") + + assert summary["rows"][0]["Difference_pct"] is None + assert summary["rows"][0]["Difference"] == 1.0 + assert summary["metrics"]["mape"] == pytest.approx(10.0) + assert summary["metrics"]["mape_excluded"] == 1 + assert summary["metrics"]["mae"] == pytest.approx(1.0) + + +def test_summarise_comparison_without_nonzero_swmm_runoff_has_no_mape(): + summary = _summarise_comparison(_comparison_frame([0.0], [0.5]), "m³") + + assert summary["metrics"]["mape"] is None + assert summary["metrics"]["mape_excluded"] == 1 + + +def test_summarise_comparison_of_empty_frame_is_none(): + assert _summarise_comparison(_comparison_frame([], []), "m³") is None + + +@pytest.mark.django_db +def test_calculations_results_render_metrics_and_differences(client, user, monkeypatch): + """Full-page (no-JS) POST shows the metric tiles, difference columns and chart rows.""" + monkeypatch.setattr( + "main.views._compare_swmm_and_ann", + lambda _path: (_comparison_frame([0.0, 10.0], [1.0, 12.5]), "m³"), + ) + monkeypatch.setattr("main.views._cleanup_swmm_side_files", lambda _path: None) + client.force_login(user) + session = client.session + session["uploaded_file_path"] = os.path.join(_user_upload_dir(user.id), "test.inp") + session.save() + + response = client.post(reverse("main:calculations"), follow=True) + + html = response.content.decode() + assert response.redirect_chain == [(reverse("main:calculations"), 302)] + assert "Mean absolute error" in html + assert "Excludes 1 subcatchment with zero SWMM runoff" in html + assert 'data-sort-value="25.0">25.0' in html + assert "Difference [%]" in html + chart_rows = re.search( + r'', html + ) + assert json.loads(chart_rows.group(1))[0]["Difference_pct"] is None def _upload(request): @@ -2360,6 +2455,26 @@ def test_timeseries_axis_labels_give_each_series_its_swmm_unit( assert evaporation_rate != depth_rate +@pytest.mark.parametrize( + ("flow_units", "factor", "unit"), + [ + ("CMS", 1e3, "m³"), + ("lps", 1e3, "m³"), + ("CFS", 1e6 * 0.133680556, "ft³"), + ("MGD", 1e6 * 0.133680556, "ft³"), + (None, 1.0, "10⁶ L or gal"), + ], +) +def test_report_runoff_unit_converts_report_volumes(flow_units, factor, unit): + """The report's total runoff is 10^6 L (SI) or 10^6 US gal (US).""" + from main.views import _report_runoff_unit + + actual_factor, actual_unit = _report_runoff_unit(flow_units) + + assert actual_factor == pytest.approx(factor) + assert actual_unit == unit + + @pytest.mark.parametrize( ("seconds", "text"), [ diff --git a/cs_app/main/views.py b/cs_app/main/views.py index f65af13..58b985a 100644 --- a/cs_app/main/views.py +++ b/cs_app/main/views.py @@ -61,6 +61,7 @@ EXCEL_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" SIM_RESULT_TOKEN_SESSION_KEY = "sim_result_token" TS_RESULT_TOKEN_SESSION_KEY = "ts_result_token" +CALC_RESULT_TOKEN_SESSION_KEY = "calc_result_token" RESULT_CACHE_TTL_SECONDS = 30 * 60 MAX_RESULT_CACHE_BYTES = 2 * 1024 * 1024 UPLOAD_SUBDIR = "uploaded_files" @@ -70,6 +71,8 @@ SIMULATION_RESULTS_TEMPLATE = "main/partials/_simulation_results.html" TIMESERIES_TEMPLATE = "main/timeseries.html" TIMESERIES_RESULTS_TEMPLATE = "main/partials/_timeseries_results.html" +CALCULATIONS_TEMPLATE = "main/calculations.html" +CALCULATIONS_RESULTS_TEMPLATE = "main/partials/_calculations_results.html" AUTH_REQUIRED_MESSAGE = "Authentication required." FORM_INVALID_MESSAGE = "Please correct the highlighted fields." NO_MODEL_MESSAGE = "Please upload a file first." @@ -1481,6 +1484,19 @@ def download_timeseries_csv(request: HttpRequest) -> HttpResponse: } +# SWMM reports a subcatchment's total runoff in 10^6 L (SI models) or 10^6 US gal +# (US models); swmmio names that column TotalRunoffMG either way. +REPORT_RUNOFF_FACTORS = {"SI": 1e3, "US": 1e6 * _US_GALLON_FT3} + + +def _report_runoff_unit(flow_units: str | None) -> tuple[float, str]: + """Return the factor from the report's total runoff to the display volume unit, and that unit.""" + system = _unit_system(flow_units) + if system in REPORT_RUNOFF_FACTORS: + return REPORT_RUNOFF_FACTORS[system], _unit_labels(flow_units)["volume"] + return 1.0, "10⁶ L or gal" + + def _runoff_volume_unit(flow_units: str | None) -> tuple[float, str]: """Return the factor from runoff_volume() to the display volume unit, and that unit.""" normalized = _normalize_flow_units(flow_units) @@ -1713,7 +1729,10 @@ def _cleanup_swmm_side_files(inp_path: str) -> None: def calculations(request: HttpRequest) -> HttpResponse: """ - Perform calculations on the uploaded file. + Render the SWMM vs ANN comparison page and run the comparison on POST. + + GET is public and shows the session's last comparison; POST requires + authentication (see ``_run_calculations``). Parameters ---------- @@ -1725,52 +1744,148 @@ def calculations(request: HttpRequest) -> HttpResponse: HttpResponse The HTTP response with the rendered calculations template. """ - df = None if request.method == "POST": - uploaded_file_path = request.session.get("uploaded_file_path", None) + return _run_calculations(request) + return render(request, CALCULATIONS_TEMPLATE, {"comparison": _load_comparison(request)}) - if not uploaded_file_path: - messages.error(request, "Please upload a file first.") - else: - user_dir = os.path.realpath(_user_upload_dir(request.user.id)) - abs_uploaded_file = os.path.realpath(uploaded_file_path) - try: - common_path = os.path.commonpath([abs_uploaded_file, user_dir]) - except ValueError: - common_path = None +def _load_comparison(request: HttpRequest) -> dict | None: + """Return the comparison last run in this session, while it is still cached.""" + if not request.user.is_authenticated: + return None + token = request.session.get(CALC_RESULT_TOKEN_SESSION_KEY) + comparison = _load_cached_result("calc", request.user.id, token) + if token and not comparison: + request.session.pop(CALC_RESULT_TOKEN_SESSION_KEY, None) + return comparison - if common_path != user_dir: - logger.warning( - f"File path traversal attempt or cross-user access: {uploaded_file_path}" - ) - messages.error(request, "Invalid file path detected.") - else: - try: - with Simulation(uploaded_file_path) as sim: - for _ in sim: - pass - # Build the model after SWMM run so report-derived columns are available. - swmmio_model = swmmio.Model(uploaded_file_path) - ann_predictions = predict_runoff(swmmio_model).transpose() - df = pd.DataFrame( - data={ - "Name": swmmio_model.subcatchments.dataframe.index, - "SWMM_Runoff_m3": swmmio_model.subcatchments.dataframe[ - "TotalRunoffMG" - ].values, - "ANN_Runoff_m3": np.round(ann_predictions, 2), - }, - ) - except Exception: - logger.exception("Error while performing calculations.") - messages.error( - request, - "An error occurred while performing calculations.", - ) - finally: - _cleanup_swmm_side_files(uploaded_file_path) - - df_is_empty = df is None or df.empty - - return render(request, "main/calculations.html", {"df": df, "df_is_empty": df_is_empty}) + +def _check_in_user_upload_dir(path: str, user_id: int) -> None: + """Refuse a model path outside the user's upload directory.""" + user_dir = os.path.realpath(_user_upload_dir(user_id)) + try: + common_path = os.path.commonpath([os.path.realpath(path), user_dir]) + except ValueError: + common_path = None + if common_path != user_dir: + logger.warning(f"File path traversal attempt or cross-user access: {path}") + raise InputValidationError("invalid_path") + + +def _compare_swmm_and_ann(inp_path: str) -> tuple[pd.DataFrame, str]: + """Run SWMM on the model and pair its runoff with the ANN prediction, in the model's volume unit.""" + # pyswmm reports a missing file as a bare Exception, so check up front. + if not os.path.isfile(inp_path): + raise InputValidationError("missing_file") + try: + with Simulation(inp_path) as sim: + for _ in sim: + pass + # Build the model after SWMM run so report-derived columns are available. + swmmio_model = swmmio.Model(inp_path) + subcatchments = swmmio_model.subcatchments.dataframe + if subcatchments.empty: + raise InputValidationError("no_subcatchments") + ann_predictions = predict_runoff(swmmio_model) + factor, unit = _report_runoff_unit(_read_flow_units(inp_path)) + comparison = pd.DataFrame( + data={ + "Name": subcatchments.index, + "SWMM_Runoff": subcatchments["TotalRunoffMG"].to_numpy(dtype=float) * factor, + # The network was trained on the report's runoff, so it converts the same way. + "ANN_Runoff": np.round(ann_predictions.astype(float) * factor, 2), + }, + ) + return comparison, unit + finally: + _cleanup_swmm_side_files(inp_path) + + +def _finite_or_none(value) -> float | None: + """Return ``value`` as a plain float, or None when it is missing, NaN or infinite.""" + number = float(value) + return number if np.isfinite(number) else None + + +def _summarise_comparison(df: pd.DataFrame, unit: str) -> dict | None: + """ + Per-subcatchment differences and error metrics of a SWMM vs ANN comparison. + + The single place these numbers are computed: the results partial renders them + and hands ``rows`` to the charts through ``json_script``. The difference is + ANN minus SWMM. Its percentage is None where SWMM runoff is 0, and the mean + absolute percentage error skips those rows (``mape_excluded`` counts them). + ``unit`` is the volume unit of every runoff value and is kept with the result. + + Returns None for an empty comparison. + """ + if df.empty: + return None + swmm = df["SWMM_Runoff"].astype(float) + ann = df["ANN_Runoff"].astype(float) + difference = ann - swmm + difference_pct = difference / swmm.where(swmm != 0) * 100 + rows = [ + { + "Name": str(name), + "SWMM_Runoff": _finite_or_none(swmm_value), + "ANN_Runoff": _finite_or_none(ann_value), + "Difference": _finite_or_none(diff), + "Difference_pct": _finite_or_none(pct), + } + for name, swmm_value, ann_value, diff, pct in zip( + df["Name"], swmm, ann, difference, difference_pct, strict=True + ) + ] + return { + "unit": unit, + "rows": rows, + "metrics": { + "count": len(rows), + "mae": _finite_or_none(difference.abs().mean()), + "rmse": _finite_or_none(np.sqrt((difference**2).mean())), + "mape": _finite_or_none(difference_pct.abs().mean()), + "mape_excluded": int((swmm == 0).sum()), + }, + } + + +def _run_comparison(request: HttpRequest) -> None: + """Compare SWMM and ANN runoff on the session's model and store the result for the session.""" + uploaded_file_path = _uploaded_model_path(request) + _check_in_user_upload_dir(uploaded_file_path, request.user.id) + comparison = _summarise_comparison(*_compare_swmm_and_ann(uploaded_file_path)) + _replace_session_result(request, "calc", CALC_RESULT_TOKEN_SESSION_KEY, comparison) + + +@ajax_login_required +def _run_calculations(request: HttpRequest) -> HttpResponse: + """ + Run the comparison on the user's uploaded model. + + A regular POST redirects back to the page (PRG); an async one gets the + results fragment. Errors follow the ``_run_failed`` contract. + """ + try: + _run_comparison(request) + except Exception as error: + message, status = _classify_run_error( + error, + "Calculations", + "The comparison is too large to keep. Use a model with fewer subcatchments.", + "An error occurred while performing calculations.", + ) + # A failed run leaves the previous comparison in place, so the page keeps showing it. + return _run_failed( + request, + message, + status, + CALCULATIONS_TEMPLATE, + {"comparison": _load_comparison(request)}, + ) + + if _is_ajax(request): + return render( + request, CALCULATIONS_RESULTS_TEMPLATE, {"comparison": _load_comparison(request)} + ) + return redirect("main:calculations") From 8fa44a9f18dc3b98cb65859e0e8f7e01a69a6222 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Buczy=C5=84ski?= Date: Wed, 30 Sep 2026 09:45:14 +0200 Subject: [PATCH 11/18] drop every tool's results when the model changes Loading or clearing a model drops the saved form values, the cached subcatchment IDs and each tool's cached result, so results are never shown next to a model they were not computed from. --- cs_app/main/tests/test_views.py | 60 ++++++++++++------------- cs_app/main/views.py | 77 +++++++++++---------------------- 2 files changed, 53 insertions(+), 84 deletions(-) diff --git a/cs_app/main/tests/test_views.py b/cs_app/main/tests/test_views.py index daeedd3..d81a2a8 100644 --- a/cs_app/main/tests/test_views.py +++ b/cs_app/main/tests/test_views.py @@ -1,6 +1,7 @@ import json import os import re +import uuid from io import BytesIO, StringIO from types import SimpleNamespace @@ -19,6 +20,7 @@ from django.utils import timezone from main.views import ( + RESULT_SESSION_KEYS, SIM_RESULT_TOKEN_SESSION_KEY, TS_RESULT_TOKEN_SESSION_KEY, _get_catchment_choices, @@ -30,7 +32,6 @@ _user_upload_dir, _validate_inp_file_stream, calculations, - clear_session_variables, simulation_view, subcatchments, timeseries_view, @@ -2579,40 +2580,33 @@ def test_upload_status_stale_reference(user): @pytest.mark.django_db -def test_clear_session_preserves_uploaded_file(user): - """Test that clear_session_variables does not remove uploaded_file_path.""" - factory = RequestFactory() - request = factory.get("/") - - session_middleware = SessionMiddleware(lambda req: None) - session_middleware.process_request(request) - request.session["uploaded_file_path"] = "uploaded_files/test.inp" - request.session["show_download_button"] = True - request.session["chart_config"] = {"data": []} - request.session[SIM_RESULT_TOKEN_SESSION_KEY] = "cccccccccccccccccccccccccccccccc" - request.session[TS_RESULT_TOKEN_SESSION_KEY] = "dddddddddddddddddddddddddddddddd" - request.session["sim_form_state"] = {"option": "simulate_percent_slope", "catchment_name": "S1"} - request.session["ts_form_state"] = {"mode": "sweep", "catchment_name": "S1"} - request.session.save() - request.user = user - cache.set( - _result_cache_key("sim", user.id, "cccccccccccccccccccccccccccccccc"), - json.dumps({"value": 1}), - ) - cache.set( - _result_cache_key("ts", user.id, "dddddddddddddddddddddddddddddddd"), - json.dumps({"value": 1}), - ) +@pytest.mark.parametrize("url_name", ["main:upload", "main:upload_sample", "main:upload_clear"]) +def test_changing_the_model_drops_every_tools_results(client, user, url_name): + """Results of the previous model must not stay current next to a new (or no) model.""" + client.force_login(user) + session = client.session + tokens = {} + for scope, session_key in RESULT_SESSION_KEYS: + tokens[scope] = uuid.uuid4().hex + session[session_key] = tokens[scope] + cache.set(_result_cache_key(scope, user.id, tokens[scope]), json.dumps({"value": 1})) + session.save() + data = {} + if url_name == "main:upload": + inp_content = b"[TITLE]\nNew model\n\n[OPTIONS]\nFLOW_UNITS LPS\n" + data["file"] = SimpleUploadedFile("new_model.inp", inp_content, content_type="text/plain") - clear_session_variables(request) + try: + response = client.post(reverse(url_name), data, HTTP_X_REQUESTED_WITH="XMLHttpRequest") - assert request.session.get("uploaded_file_path") == "uploaded_files/test.inp" - assert "show_download_button" not in request.session - assert "chart_config" not in request.session - assert "sim_form_state" not in request.session - assert "ts_form_state" not in request.session - assert cache.get(_result_cache_key("sim", user.id, "cccccccccccccccccccccccccccccccc")) is None - assert cache.get(_result_cache_key("ts", user.id, "dddddddddddddddddddddddddddddddd")) is None + assert response.status_code == 200 + for scope, session_key in RESULT_SESSION_KEYS: + assert session_key not in client.session + assert cache.get(_result_cache_key(scope, user.id, tokens[scope])) is None + finally: + path = client.session.get("uploaded_file_path") + if path and os.path.exists(path): + os.remove(path) # ── upload_clear tests (#4) ────────────────────────────────────────────── diff --git a/cs_app/main/views.py b/cs_app/main/views.py index 58b985a..60fd715 100644 --- a/cs_app/main/views.py +++ b/cs_app/main/views.py @@ -62,6 +62,12 @@ SIM_RESULT_TOKEN_SESSION_KEY = "sim_result_token" TS_RESULT_TOKEN_SESSION_KEY = "ts_result_token" CALC_RESULT_TOKEN_SESSION_KEY = "calc_result_token" +# Cache scope and session key of each tool's latest result. +RESULT_SESSION_KEYS = ( + ("sim", SIM_RESULT_TOKEN_SESSION_KEY), + ("ts", TS_RESULT_TOKEN_SESSION_KEY), + ("calc", CALC_RESULT_TOKEN_SESSION_KEY), +) RESULT_CACHE_TTL_SECONDS = 30 * 60 MAX_RESULT_CACHE_BYTES = 2 * 1024 * 1024 UPLOAD_SUBDIR = "uploaded_files" @@ -590,6 +596,23 @@ def file_complete(self, file_size: int) -> None: return None +def _forget_model_state(request: HttpRequest) -> None: + """ + Drop what belongs to the session's model once it is replaced or removed: the saved + form values, the cached subcatchment IDs and every tool's results, which would + otherwise be shown next to a model they were not computed from. + """ + for key in ( + SIM_FORM_STATE_SESSION_KEY, + TS_FORM_STATE_SESSION_KEY, + "_subcatchment_ids", + "_subcatchment_ids_file", + ): + request.session.pop(key, None) + for scope, session_key in RESULT_SESSION_KEYS: + _delete_cached_result(scope, request.user.id, request.session.pop(session_key, None)) + + def _upload_response(request: HttpRequest, payload: dict, status: int = 200) -> HttpResponse: """ Answer an upload: JSON for the upload zone's XHR; for the no-JS fallback form, a @@ -712,11 +735,7 @@ def _store_upload(request: HttpRequest) -> HttpResponse: destination.write(chunk) request.session["uploaded_file_path"] = file_path - request.session.pop(SIM_FORM_STATE_SESSION_KEY, None) - request.session.pop(TS_FORM_STATE_SESSION_KEY, None) - # Invalidate cached subcatchment IDs so they are re-read from new file - request.session.pop("_subcatchment_ids", None) - request.session.pop("_subcatchment_ids_file", None) + _forget_model_state(request) logger.info(f"File uploaded successfully: {file_path}") return _upload_response(request, {"message": "File was sent."}) @@ -755,10 +774,7 @@ def upload_sample(request: HttpRequest) -> JsonResponse: _safe_remove_file(old_path) request.session["uploaded_file_path"] = file_path - request.session.pop(SIM_FORM_STATE_SESSION_KEY, None) - request.session.pop(TS_FORM_STATE_SESSION_KEY, None) - request.session.pop("_subcatchment_ids", None) - request.session.pop("_subcatchment_ids_file", None) + _forget_model_state(request) try: sample_size = os.path.getsize(file_path) except OSError: @@ -827,12 +843,7 @@ def upload_clear(request: HttpRequest) -> JsonResponse: file_path = request.session.pop("uploaded_file_path", None) _safe_remove_file(file_path) - - request.session.pop(SIM_FORM_STATE_SESSION_KEY, None) - request.session.pop(TS_FORM_STATE_SESSION_KEY, None) - # Clear cached subcatchment IDs - request.session.pop("_subcatchment_ids", None) - request.session.pop("_subcatchment_ids_file", None) + _forget_model_state(request) return JsonResponse({"message": "Upload cleared."}) @@ -1138,42 +1149,6 @@ def get_session_variables(request: HttpRequest) -> dict: } -def clear_session_variables(request: HttpRequest) -> None: - """ - Clear the session variables. - - Parameters - ---------- - request : HttpRequest - The incoming HTTP request. - """ - user = getattr(request, "user", None) - user_id = user.id if getattr(user, "is_authenticated", False) else None - if user_id is not None: - _delete_cached_result("sim", user_id, request.session.get(SIM_RESULT_TOKEN_SESSION_KEY)) - _delete_cached_result("ts", user_id, request.session.get(TS_RESULT_TOKEN_SESSION_KEY)) - - for variable in [ - "show_download_button", - "chart_config", - "results_columns", - "results_data", - "feature_name", - "output_file_name", - "ts_chart_config", - "ts_time_to_peak", - "ts_runoff_volume", - "ts_show_results", - "ts_output_file_name", - SIM_RESULT_TOKEN_SESSION_KEY, - TS_RESULT_TOKEN_SESSION_KEY, - SIM_FORM_STATE_SESSION_KEY, - TS_FORM_STATE_SESSION_KEY, - ]: - if variable in request.session: - del request.session[variable] - - def _save_form_state( request: HttpRequest, session_key: str, From e901271218e03451a03d1f9a619c6b52f59f78e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Buczy=C5=84ski?= Date: Wed, 30 Sep 2026 09:45:50 +0200 Subject: [PATCH 12/18] rebuild the home page around the bundled example model The hero hydrograph and the sweep charts are precomputed from data/example.inp by data/build_home_data.py (the old data came from another model), and a test keeps them in sync. --- cs_app/data/build_home_data.py | 68 ++++ cs_app/data/df_area.json | 2 +- cs_app/data/df_slope.json | 2 +- cs_app/data/df_width.json | 2 +- cs_app/data/example_hydrograph.json | 1 + cs_app/e2e/pages/home_page.py | 38 +- cs_app/e2e/test_home.py | 219 ++++++++++-- cs_app/main/static/main/css/home.css | 324 ++++++++++++++++++ cs_app/main/static/main/js/pages/main_view.js | 94 +++-- cs_app/main/templates/main/main_view.html | 323 +++++++++++++---- .../main/partials/_home_sweep_chart.html | 25 ++ cs_app/main/tests/test_content_pages.py | 109 +++++- cs_app/main/tests/test_views.py | 8 + cs_app/main/views.py | 121 +++++-- 14 files changed, 1155 insertions(+), 181 deletions(-) create mode 100644 cs_app/data/build_home_data.py create mode 100644 cs_app/data/example_hydrograph.json create mode 100644 cs_app/main/static/main/css/home.css create mode 100644 cs_app/main/templates/main/partials/_home_sweep_chart.html diff --git a/cs_app/data/build_home_data.py b/cs_app/data/build_home_data.py new file mode 100644 index 0000000..6e8c599 --- /dev/null +++ b/cs_app/data/build_home_data.py @@ -0,0 +1,68 @@ +"""Regenerate the example data shown on the home page from ``example.inp``. + +Every chart on the home page is computed from the bundled example model +(subcatchment ``S1``), so the numbers match what users get from the code +snippets next to them. Run after changing ``example.inp`` or the package: + + cd cs_app && ../.venv/bin/python data/build_home_data.py + +Writes, next to this file: + df_slope.json, df_area.json, df_width.json parameter sweeps (total runoff volume) + example_hydrograph.json ``calculate_timeseries()`` records +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from catchment_simulation.catchment_features_simulation import FeaturesSimulation + +DATA_DIR = Path(__file__).resolve().parent +MODEL = DATA_DIR / "example.inp" +SUBCATCHMENT_ID = "S1" +SIGNIFICANT_DIGITS = 6 + +# Output file -> (x key in the records, sweep method, start, stop, step). +# Keep the ranges in sync with the snippets in main/templates/main/main_view.html. +SWEEPS = { + "df_slope.json": ("slope", "simulate_percent_slope", 1, 100, 1), + "df_area.json": ("area", "simulate_area", 1, 100, 1), + "df_width.json": ("width", "simulate_width", 10, 1000, 10), +} + + +def _round(value: float) -> float: + return float(f"{value:.{SIGNIFICANT_DIGITS}g}") + + +def _write(name: str, records: list[dict]) -> None: + (DATA_DIR / name).write_text(json.dumps(records, separators=(",", ":")), encoding="utf-8") + + +def main() -> None: + for name, (x_key, method, start, stop, step) in SWEEPS.items(): + with FeaturesSimulation(subcatchment_id=SUBCATCHMENT_ID, raw_file=str(MODEL)) as model: + frame = getattr(model, method)(start=start, stop=stop, step=step) + feature_column = frame.columns[-1] # the swept parameter is the last column + _write( + name, + [ + {x_key: _round(row[feature_column]), "runoff": _round(row["runoff"])} + for _, row in frame.iterrows() + ], + ) + + with FeaturesSimulation(subcatchment_id=SUBCATCHMENT_ID, raw_file=str(MODEL)) as model: + timeseries = model.calculate_timeseries() + _write( + "example_hydrograph.json", + [ + {"datetime": moment.isoformat(), **{key: _round(value) for key, value in row.items()}} + for moment, row in timeseries.iterrows() + ], + ) + + +if __name__ == "__main__": + main() diff --git a/cs_app/data/df_area.json b/cs_app/data/df_area.json index 531a7ce..d48497d 100644 --- a/cs_app/data/df_area.json +++ b/cs_app/data/df_area.json @@ -1 +1 @@ -[{"Unnamed: 0":0,"runoff":43.7143490025,"area":1},{"Unnamed: 0":1,"runoff":82.5854598568,"area":2},{"Unnamed: 0":2,"runoff":118.6683490505,"area":3},{"Unnamed: 0":3,"runoff":152.7121369686,"area":4},{"Unnamed: 0":4,"runoff":185.1407843889,"area":5},{"Unnamed: 0":5,"runoff":216.2335935081,"area":6},{"Unnamed: 0":6,"runoff":246.1907517416,"area":7},{"Unnamed: 0":7,"runoff":275.1636561128,"area":8},{"Unnamed: 0":8,"runoff":303.2712756888,"area":9},{"Unnamed: 0":9,"runoff":330.6095973487,"area":10},{"Unnamed: 0":10,"runoff":357.2577577371,"area":11},{"Unnamed: 0":11,"runoff":383.2822394987,"area":12},{"Unnamed: 0":12,"runoff":408.7395645796,"area":13},{"Unnamed: 0":13,"runoff":433.6784049317,"area":14},{"Unnamed: 0":14,"runoff":458.1410650203,"area":15},{"Unnamed: 0":15,"runoff":482.164640941,"area":16},{"Unnamed: 0":16,"runoff":505.7818433641,"area":17},{"Unnamed: 0":17,"runoff":529.0217858316,"area":18},{"Unnamed: 0":18,"runoff":551.9104841225,"area":19},{"Unnamed: 0":19,"runoff":574.4712088353,"area":20},{"Unnamed: 0":20,"runoff":596.7251451078,"area":21},{"Unnamed: 0":21,"runoff":618.6912959965,"area":22},{"Unnamed: 0":22,"runoff":640.3870180854,"area":23},{"Unnamed: 0":23,"runoff":661.8281887273,"area":24},{"Unnamed: 0":24,"runoff":683.0292876182,"area":25},{"Unnamed: 0":25,"runoff":704.0036129683,"area":26},{"Unnamed: 0":26,"runoff":724.7634206256,"area":27},{"Unnamed: 0":27,"runoff":745.3199758518,"area":28},{"Unnamed: 0":28,"runoff":765.6837047665,"area":29},{"Unnamed: 0":29,"runoff":785.8643061741,"area":30},{"Unnamed: 0":30,"runoff":805.8708070537,"area":31},{"Unnamed: 0":31,"runoff":825.7114605248,"area":32},{"Unnamed: 0":32,"runoff":845.3941660865,"area":33},{"Unnamed: 0":33,"runoff":864.9260745456,"area":34},{"Unnamed: 0":34,"runoff":884.3140931435,"area":35},{"Unnamed: 0":35,"runoff":903.5644989019,"area":36},{"Unnamed: 0":36,"runoff":922.6832627065,"area":37},{"Unnamed: 0":37,"runoff":941.676039339,"area":38},{"Unnamed: 0":38,"runoff":960.5480423251,"area":39},{"Unnamed: 0":39,"runoff":979.3042130804,"area":40},{"Unnamed: 0":40,"runoff":997.9492232591,"area":41},{"Unnamed: 0":41,"runoff":1016.4875039836,"area":42},{"Unnamed: 0":42,"runoff":1034.9231964895,"area":43},{"Unnamed: 0":43,"runoff":1053.2602216441,"area":44},{"Unnamed: 0":44,"runoff":1071.5022932745,"area":45},{"Unnamed: 0":45,"runoff":1089.652929493,"area":46},{"Unnamed: 0":46,"runoff":1107.7154650866,"area":47},{"Unnamed: 0":47,"runoff":1125.6930635958,"area":48},{"Unnamed: 0":48,"runoff":1143.5887288777,"area":49},{"Unnamed: 0":49,"runoff":1161.4053162712,"area":50},{"Unnamed: 0":50,"runoff":1179.1455441717,"area":51},{"Unnamed: 0":51,"runoff":1196.8120058161,"area":52},{"Unnamed: 0":52,"runoff":1214.4071385187,"area":53},{"Unnamed: 0":53,"runoff":1231.9332793013,"area":54},{"Unnamed: 0":54,"runoff":1249.3926649623,"area":55},{"Unnamed: 0":55,"runoff":1266.7874366393,"area":56},{"Unnamed: 0":56,"runoff":1284.1196579707,"area":57},{"Unnamed: 0":57,"runoff":1301.3912497859,"area":58},{"Unnamed: 0":58,"runoff":1318.6040431901,"area":59},{"Unnamed: 0":59,"runoff":1335.7598374599,"area":60},{"Unnamed: 0":60,"runoff":1352.8603805121,"area":61},{"Unnamed: 0":61,"runoff":1369.9072397118,"area":62},{"Unnamed: 0":62,"runoff":1386.9019766526,"area":63},{"Unnamed: 0":63,"runoff":1403.846150715,"area":64},{"Unnamed: 0":64,"runoff":1420.7411389733,"area":65},{"Unnamed: 0":65,"runoff":1437.588312861,"area":66},{"Unnamed: 0":66,"runoff":1454.3890677734,"area":67},{"Unnamed: 0":67,"runoff":1471.1445821684,"area":68},{"Unnamed: 0":68,"runoff":1487.8561285133,"area":69},{"Unnamed: 0":69,"runoff":1504.5248855905,"area":70},{"Unnamed: 0":70,"runoff":1521.1519243789,"area":71},{"Unnamed: 0":71,"runoff":1537.7384218465,"area":72},{"Unnamed: 0":72,"runoff":1554.2853158674,"area":73},{"Unnamed: 0":73,"runoff":1570.7937119384,"area":74},{"Unnamed: 0":74,"runoff":1587.2644886401,"area":75},{"Unnamed: 0":75,"runoff":1603.6986557349,"area":76},{"Unnamed: 0":76,"runoff":1620.0970464221,"area":77},{"Unnamed: 0":77,"runoff":1636.4605859844,"area":78},{"Unnamed: 0":78,"runoff":1652.7900553254,"area":79},{"Unnamed: 0":79,"runoff":1669.0863179547,"area":80},{"Unnamed: 0":80,"runoff":1685.3500897622,"area":81},{"Unnamed: 0":81,"runoff":1701.5821946323,"area":82},{"Unnamed: 0":82,"runoff":1717.7832751554,"area":83},{"Unnamed: 0":83,"runoff":1733.9541277867,"area":84},{"Unnamed: 0":84,"runoff":1750.09532762,"area":85},{"Unnamed: 0":85,"runoff":1766.2076353276,"area":86},{"Unnamed: 0":86,"runoff":1782.2915944911,"area":87},{"Unnamed: 0":87,"runoff":1798.3478755019,"area":88},{"Unnamed: 0":88,"runoff":1814.377058716,"area":89},{"Unnamed: 0":89,"runoff":1830.3796788453,"area":90},{"Unnamed: 0":90,"runoff":1846.3563714272,"area":91},{"Unnamed: 0":91,"runoff":1862.3075818122,"area":92},{"Unnamed: 0":92,"runoff":1878.2339118817,"area":93},{"Unnamed: 0":93,"runoff":1894.135833394,"area":94},{"Unnamed: 0":94,"runoff":1910.0137984413,"area":95},{"Unnamed: 0":95,"runoff":1925.8683668025,"area":96},{"Unnamed: 0":96,"runoff":1941.6999237495,"area":97},{"Unnamed: 0":97,"runoff":1957.5089330463,"area":98},{"Unnamed: 0":98,"runoff":1973.2958718584,"area":99}] \ No newline at end of file +[{"area":1.0,"runoff":832.609},{"area":2.0,"runoff":1563.6},{"area":3.0,"runoff":2218.85},{"area":4.0,"runoff":2813.91},{"area":5.0,"runoff":3360.36},{"area":6.0,"runoff":3867.14},{"area":7.0,"runoff":4341.18},{"area":8.0,"runoff":4787.94},{"area":9.0,"runoff":5211.77},{"area":10.0,"runoff":5616.13},{"area":11.0,"runoff":6003.84},{"area":12.0,"runoff":6377.21},{"area":13.0,"runoff":6738.14},{"area":14.0,"runoff":7088.19},{"area":15.0,"runoff":7428.7},{"area":16.0,"runoff":7760.78},{"area":17.0,"runoff":8085.36},{"area":18.0,"runoff":8403.27},{"area":19.0,"runoff":8715.18},{"area":20.0,"runoff":9021.71},{"area":21.0,"runoff":9323.38},{"area":22.0,"runoff":9620.63},{"area":23.0,"runoff":9913.86},{"area":24.0,"runoff":10203.4},{"area":25.0,"runoff":10489.6},{"area":26.0,"runoff":10772.8},{"area":27.0,"runoff":11053.1},{"area":28.0,"runoff":11330.7},{"area":29.0,"runoff":11606.0},{"area":30.0,"runoff":11879.0},{"area":31.0,"runoff":12149.8},{"area":32.0,"runoff":12418.8},{"area":33.0,"runoff":12685.9},{"area":34.0,"runoff":12951.3},{"area":35.0,"runoff":13215.1},{"area":36.0,"runoff":13477.4},{"area":37.0,"runoff":13738.3},{"area":38.0,"runoff":13997.9},{"area":39.0,"runoff":14256.2},{"area":40.0,"runoff":14513.3},{"area":41.0,"runoff":14769.3},{"area":42.0,"runoff":15024.2},{"area":43.0,"runoff":15278.0},{"area":44.0,"runoff":15531.0},{"area":45.0,"runoff":15783.0},{"area":46.0,"runoff":16034.1},{"area":47.0,"runoff":16284.4},{"area":48.0,"runoff":16533.8},{"area":49.0,"runoff":16782.5},{"area":50.0,"runoff":17030.5},{"area":51.0,"runoff":17277.8},{"area":52.0,"runoff":17524.4},{"area":53.0,"runoff":17770.3},{"area":54.0,"runoff":18015.6},{"area":55.0,"runoff":18260.3},{"area":56.0,"runoff":18504.4},{"area":57.0,"runoff":18748.0},{"area":58.0,"runoff":18991.0},{"area":59.0,"runoff":19233.5},{"area":60.0,"runoff":19475.4},{"area":61.0,"runoff":19716.9},{"area":62.0,"runoff":19957.9},{"area":63.0,"runoff":20198.5},{"area":64.0,"runoff":20438.6},{"area":65.0,"runoff":20678.2},{"area":66.0,"runoff":20917.4},{"area":67.0,"runoff":21156.2},{"area":68.0,"runoff":21394.7},{"area":69.0,"runoff":21632.7},{"area":70.0,"runoff":21870.3},{"area":71.0,"runoff":22107.6},{"area":72.0,"runoff":22344.5},{"area":73.0,"runoff":22581.0},{"area":74.0,"runoff":22817.2},{"area":75.0,"runoff":23053.1},{"area":76.0,"runoff":23288.6},{"area":77.0,"runoff":23523.8},{"area":78.0,"runoff":23758.7},{"area":79.0,"runoff":23993.3},{"area":80.0,"runoff":24227.6},{"area":81.0,"runoff":24461.5},{"area":82.0,"runoff":24695.2},{"area":83.0,"runoff":24928.6},{"area":84.0,"runoff":25161.7},{"area":85.0,"runoff":25394.5},{"area":86.0,"runoff":25627.1},{"area":87.0,"runoff":25859.4},{"area":88.0,"runoff":26091.4},{"area":89.0,"runoff":26323.2},{"area":90.0,"runoff":26554.7},{"area":91.0,"runoff":26785.9},{"area":92.0,"runoff":27016.9},{"area":93.0,"runoff":27247.7},{"area":94.0,"runoff":27478.2},{"area":95.0,"runoff":27708.5},{"area":96.0,"runoff":27938.5},{"area":97.0,"runoff":28168.3},{"area":98.0,"runoff":28397.9},{"area":99.0,"runoff":28627.3},{"area":100.0,"runoff":28856.4}] \ No newline at end of file diff --git a/cs_app/data/df_slope.json b/cs_app/data/df_slope.json index b58e9b1..8383798 100644 --- a/cs_app/data/df_slope.json +++ b/cs_app/data/df_slope.json @@ -1 +1 @@ -[{"runoff":1376.3921185939,"slope":1},{"runoff":1454.4855661212,"slope":2},{"runoff":1507.3032367051,"slope":3},{"runoff":1548.4483124306,"slope":4},{"runoff":1582.5912383385,"slope":5},{"runoff":1611.9777793015,"slope":6},{"runoff":1637.8850748541,"slope":7},{"runoff":1661.118190416,"slope":8},{"runoff":1682.2214378451,"slope":9},{"runoff":1701.5821946323,"slope":10},{"runoff":1719.4871057191,"slope":11},{"runoff":1736.1549269153,"slope":12},{"runoff":1751.7568421027,"slope":13},{"runoff":1766.429443334,"slope":14},{"runoff":1780.2839420057,"slope":15},{"runoff":1793.4118500663,"slope":16},{"runoff":1805.8897607359,"slope":17},{"runoff":1817.7823888939,"slope":18},{"runoff":1829.1448671277,"slope":19},{"runoff":1840.0246716392,"slope":20},{"runoff":1850.4630030675,"slope":21},{"runoff":1860.4958452753,"slope":22},{"runoff":1870.1548272527,"slope":23},{"runoff":1879.4679109954,"slope":24},{"runoff":1888.4599459773,"slope":25},{"runoff":1897.153120366,"slope":26},{"runoff":1905.5673471112,"slope":27},{"runoff":1913.7206086995,"slope":28},{"runoff":1921.6291026431,"slope":29},{"runoff":1929.3075173301,"slope":30},{"runoff":1936.769212193,"slope":31},{"runoff":1944.0264668056,"slope":32},{"runoff":1951.0905301399,"slope":33},{"runoff":1957.9715978777,"slope":34},{"runoff":1964.6791141958,"slope":35},{"runoff":1971.2219546847,"slope":36},{"runoff":1977.6080657383,"slope":37},{"runoff":1983.8449358402,"slope":38},{"runoff":1989.9396363014,"slope":39},{"runoff":1995.8984465182,"slope":40},{"runoff":2001.7275868463,"slope":41},{"runoff":2007.4325540005,"slope":42},{"runoff":2013.0186288089,"slope":43},{"runoff":2018.4908091217,"slope":44},{"runoff":2023.8535849113,"slope":45},{"runoff":2029.1114729272,"slope":46},{"runoff":2034.2683555301,"slope":47},{"runoff":2039.3282945961,"slope":48},{"runoff":2044.2946933008,"slope":49},{"runoff":2049.1711976746,"slope":50},{"runoff":2053.9608284951,"slope":51},{"runoff":2058.6668434651,"slope":52},{"runoff":2063.2919768447,"slope":53},{"runoff":2067.8390919642,"slope":54},{"runoff":2072.3107554981,"slope":55},{"runoff":2076.7093922358,"slope":56},{"runoff":2081.0375275496,"slope":57},{"runoff":2085.2971620493,"slope":58},{"runoff":2089.4906901179,"slope":59},{"runoff":2093.6199213137,"slope":60},{"runoff":2097.6869169208,"slope":61},{"runoff":2101.6935716037,"slope":62},{"runoff":2105.6414850271,"slope":63},{"runoff":2109.5325899771,"slope":64},{"runoff":2113.3682979042,"slope":65},{"runoff":2117.1502130202,"slope":66},{"runoff":2120.8799787577,"slope":67},{"runoff":2124.5587664728,"slope":68},{"runoff":2128.1881047733,"slope":69},{"runoff":2131.7693427558,"slope":70},{"runoff":2135.303549945,"slope":71},{"runoff":2138.7921250833,"slope":72},{"runoff":2142.2361923883,"slope":73},{"runoff":2145.6367370609,"slope":74},{"runoff":2148.995011794,"slope":75},{"runoff":2152.3120165886,"slope":76},{"runoff":2155.5886118334,"slope":77},{"runoff":2158.8259082811,"slope":78},{"runoff":2162.0248734406,"slope":79},{"runoff":2165.1862050193,"slope":80},{"runoff":2168.310885236,"slope":81},{"runoff":2171.3998732569,"slope":82},{"runoff":2174.453744918,"slope":83},{"runoff":2177.4733432165,"slope":84},{"runoff":2180.4595768707,"slope":85},{"runoff":2183.4130296602,"slope":86},{"runoff":2186.3343548223,"slope":87},{"runoff":2189.224341911,"slope":88},{"runoff":2192.0837379169,"slope":89},{"runoff":2194.9129652168,"slope":90},{"runoff":2197.7127058776,"slope":91},{"runoff":2200.4836903459,"slope":92},{"runoff":2203.2264391695,"slope":93},{"runoff":2205.9413968935,"slope":94},{"runoff":2208.6291872876,"slope":95},{"runoff":2211.2904708718,"slope":96},{"runoff":2213.9256315837,"slope":97},{"runoff":2216.53511094,"slope":98},{"runoff":2219.1194669211,"slope":99}] \ No newline at end of file +[{"slope":1.0,"runoff":2434.56},{"slope":2.0,"runoff":2715.56},{"slope":3.0,"runoff":2883.67},{"slope":4.0,"runoff":3002.17},{"slope":5.0,"runoff":3092.73},{"slope":6.0,"runoff":3165.4},{"slope":7.0,"runoff":3225.71},{"slope":8.0,"runoff":3276.97},{"slope":9.0,"runoff":3321.36},{"slope":10.0,"runoff":3360.36},{"slope":11.0,"runoff":3395.04},{"slope":12.0,"runoff":3426.17},{"slope":13.0,"runoff":3454.35},{"slope":14.0,"runoff":3480.04},{"slope":15.0,"runoff":3503.61},{"slope":16.0,"runoff":3525.33},{"slope":17.0,"runoff":3545.46},{"slope":18.0,"runoff":3564.18},{"slope":19.0,"runoff":3581.66},{"slope":20.0,"runoff":3598.04},{"slope":21.0,"runoff":3613.43},{"slope":22.0,"runoff":3627.93},{"slope":23.0,"runoff":3641.64},{"slope":24.0,"runoff":3654.61},{"slope":25.0,"runoff":3666.92},{"slope":26.0,"runoff":3678.62},{"slope":27.0,"runoff":3689.77},{"slope":28.0,"runoff":3700.41},{"slope":29.0,"runoff":3710.57},{"slope":30.0,"runoff":3720.3},{"slope":31.0,"runoff":3729.62},{"slope":32.0,"runoff":3738.57},{"slope":33.0,"runoff":3747.16},{"slope":34.0,"runoff":3755.43},{"slope":35.0,"runoff":3763.39},{"slope":36.0,"runoff":3771.06},{"slope":37.0,"runoff":3778.47},{"slope":38.0,"runoff":3785.62},{"slope":39.0,"runoff":3792.53},{"slope":40.0,"runoff":3799.21},{"slope":41.0,"runoff":3805.68},{"slope":42.0,"runoff":3811.95},{"slope":43.0,"runoff":3818.03},{"slope":44.0,"runoff":3823.93},{"slope":45.0,"runoff":3829.66},{"slope":46.0,"runoff":3835.22},{"slope":47.0,"runoff":3840.63},{"slope":48.0,"runoff":3845.89},{"slope":49.0,"runoff":3851.01},{"slope":50.0,"runoff":3855.99},{"slope":51.0,"runoff":3860.85},{"slope":52.0,"runoff":3865.58},{"slope":53.0,"runoff":3870.19},{"slope":54.0,"runoff":3874.69},{"slope":55.0,"runoff":3879.09},{"slope":56.0,"runoff":3883.38},{"slope":57.0,"runoff":3887.57},{"slope":58.0,"runoff":3891.66},{"slope":59.0,"runoff":3895.67},{"slope":60.0,"runoff":3899.58},{"slope":61.0,"runoff":3903.41},{"slope":62.0,"runoff":3907.16},{"slope":63.0,"runoff":3910.82},{"slope":64.0,"runoff":3914.42},{"slope":65.0,"runoff":3917.94},{"slope":66.0,"runoff":3921.38},{"slope":67.0,"runoff":3924.76},{"slope":68.0,"runoff":3928.08},{"slope":69.0,"runoff":3931.33},{"slope":70.0,"runoff":3934.51},{"slope":71.0,"runoff":3937.64},{"slope":72.0,"runoff":3940.71},{"slope":73.0,"runoff":3943.73},{"slope":74.0,"runoff":3946.69},{"slope":75.0,"runoff":3949.59},{"slope":76.0,"runoff":3952.45},{"slope":77.0,"runoff":3955.26},{"slope":78.0,"runoff":3958.02},{"slope":79.0,"runoff":3960.73},{"slope":80.0,"runoff":3963.4},{"slope":81.0,"runoff":3966.02},{"slope":82.0,"runoff":3968.6},{"slope":83.0,"runoff":3971.14},{"slope":84.0,"runoff":3973.64},{"slope":85.0,"runoff":3976.1},{"slope":86.0,"runoff":3978.52},{"slope":87.0,"runoff":3980.9},{"slope":88.0,"runoff":3983.25},{"slope":89.0,"runoff":3985.56},{"slope":90.0,"runoff":3987.84},{"slope":91.0,"runoff":3990.08},{"slope":92.0,"runoff":3992.29},{"slope":93.0,"runoff":3994.47},{"slope":94.0,"runoff":3996.62},{"slope":95.0,"runoff":3998.74},{"slope":96.0,"runoff":4000.82},{"slope":97.0,"runoff":4002.88},{"slope":98.0,"runoff":4004.92},{"slope":99.0,"runoff":4006.92},{"slope":100.0,"runoff":4008.9}] \ No newline at end of file diff --git a/cs_app/data/df_width.json b/cs_app/data/df_width.json index d8d332e..1ffec7e 100644 --- a/cs_app/data/df_width.json +++ b/cs_app/data/df_width.json @@ -1 +1 @@ -[{"runoff":38.9144818959,"width":1},{"runoff":75.7976436879,"width":2},{"runoff":110.7964074731,"width":3},{"runoff":144.0442920819,"width":4},{"runoff":175.6628794515,"width":5},{"runoff":205.7630949176,"width":6},{"runoff":234.4463281658,"width":7},{"runoff":261.8054173214,"width":8},{"runoff":287.9255151162,"width":9},{"runoff":312.8848531577,"width":10},{"runoff":336.7554178948,"width":11},{"runoff":359.6035498592,"width":12},{"runoff":381.4904760625,"width":13},{"runoff":402.4727840164,"width":14},{"runoff":422.6028446429,"width":15},{"runoff":441.929190335,"width":16},{"runoff":460.4968535705,"width":17},{"runoff":478.3476707571,"width":18},{"runoff":495.520555366,"width":19},{"runoff":512.0517438841,"width":20},{"runoff":527.9750176592,"width":21},{"runoff":543.3219033281,"width":22},{"runoff":558.1218541773,"width":23},{"runoff":572.4024145025,"width":24},{"runoff":586.189368779,"width":25},{"runoff":599.5068772425,"width":26},{"runoff":612.3775992893,"width":27},{"runoff":624.8228059439,"width":28},{"runoff":636.8624824795,"width":29},{"runoff":648.5154221636,"width":30},{"runoff":659.7993121378,"width":31},{"runoff":670.7308119717,"width":32},{"runoff":681.3256257348,"width":33},{"runoff":691.5985681669,"width":34},{"runoff":701.5636254989,"width":35},{"runoff":711.2340114146,"width":36},{"runoff":720.6222185983,"width":37},{"runoff":729.7400662648,"width":38},{"runoff":738.5987440275,"width":39},{"runoff":747.208852429,"width":40},{"runoff":755.5804404216,"width":41},{"runoff":763.7230400611,"width":42},{"runoff":771.6456986495,"width":43},{"runoff":779.3570085411,"width":44},{"runoff":786.865134806,"width":45},{"runoff":794.1778409267,"width":46},{"runoff":801.3025126869,"width":47},{"runoff":808.2461803993,"width":48},{"runoff":815.0155396023,"width":49},{"runoff":821.6169703475,"width":50},{"runoff":828.0565551879,"width":51},{"runoff":834.3400959652,"width":52},{"runoff":840.4731294914,"width":53},{"runoff":846.4609422152,"width":54},{"runoff":852.3085838425,"width":55},{"runoff":858.020880227,"width":56},{"runoff":863.6024454337,"width":57},{"runoff":869.0576929531,"width":58},{"runoff":874.3908462591,"width":59},{"runoff":879.6059487164,"width":60},{"runoff":884.7068728829,"width":61},{"runoff":889.6973292495,"width":62},{"runoff":894.5808744558,"width":63},{"runoff":899.3609190186,"width":64},{"runoff":904.0407346048,"width":65},{"runoff":908.6234608812,"width":66},{"runoff":913.1121119673,"width":67},{"runoff":917.5095825192,"width":68},{"runoff":921.8186534682,"width":69},{"runoff":926.0419974356,"width":70},{"runoff":930.1821838463,"width":71},{"runoff":934.2416837586,"width":72},{"runoff":938.2228744295,"width":73},{"runoff":942.1280436319,"width":74},{"runoff":945.9593937385,"width":75},{"runoff":949.7190455875,"width":76},{"runoff":953.4090421436,"width":77},{"runoff":957.0313519666,"width":78},{"runoff":960.5878725012,"width":79},{"runoff":964.0804332112,"width":80},{"runoff":967.5107984136,"width":81},{"runoff":970.8806701751,"width":82},{"runoff":974.1916910494,"width":83},{"runoff":977.4454465406,"width":84},{"runoff":980.6434675074,"width":85},{"runoff":983.7872324481,"width":86},{"runoff":986.8781696735,"width":87},{"runoff":989.9176593743,"width":88},{"runoff":992.9070355878,"width":89},{"runoff":995.8475880717,"width":90},{"runoff":998.7405640871,"width":91},{"runoff":1001.587170098,"width":92},{"runoff":1004.3885733916,"width":93},{"runoff":1007.1459036219,"width":94},{"runoff":1009.8602542824,"width":95},{"runoff":1012.532684112,"width":96},{"runoff":1015.1642184355,"width":97},{"runoff":1017.7558504438,"width":98},{"runoff":1020.3085424175,"width":99},{"runoff":1022.8232268942,"width":100},{"runoff":1025.3008077857,"width":101},{"runoff":1027.742161445,"width":102},{"runoff":1030.1481376876,"width":103},{"runoff":1032.5195607682,"width":104},{"runoff":1034.8572303178,"width":105},{"runoff":1037.1619222496,"width":106},{"runoff":1039.4343895697,"width":107},{"runoff":1041.6753630725,"width":108},{"runoff":1043.8855524179,"width":109},{"runoff":1046.065646746,"width":110},{"runoff":1048.216315395,"width":111},{"runoff":1050.3382085923,"width":112},{"runoff":1052.4319581174,"width":113},{"runoff":1054.4981779381,"width":114},{"runoff":1056.5374648201,"width":115},{"runoff":1058.5503989131,"width":116},{"runoff":1060.5375443128,"width":117},{"runoff":1062.4994496009,"width":118},{"runoff":1064.4366483634,"width":119},{"runoff":1066.349659689,"width":120},{"runoff":1068.2389886476,"width":121},{"runoff":1070.1051267506,"width":122},{"runoff":1071.9485523937,"width":123},{"runoff":1073.7697312816,"width":124},{"runoff":1075.5691168382,"width":125},{"runoff":1077.3471505996,"width":126},{"runoff":1079.1042625939,"width":127},{"runoff":1080.8408717056,"width":128},{"runoff":1082.5573860276,"width":129},{"runoff":1084.2542032,"width":130},{"runoff":1085.9317107373,"width":131},{"runoff":1087.5902863478,"width":132},{"runoff":1089.2302982638,"width":133},{"runoff":1090.8521053334,"width":134},{"runoff":1092.4560574608,"width":135},{"runoff":1094.0424959831,"width":136},{"runoff":1095.6117538281,"width":137},{"runoff":1097.1641557665,"width":138},{"runoff":1098.700018655,"width":139},{"runoff":1100.2196516704,"width":140},{"runoff":1101.7233565368,"width":141},{"runoff":1103.2114277434,"width":142},{"runoff":1104.6841527559,"width":143},{"runoff":1106.1418122201,"width":144},{"runoff":1107.5846801589,"width":145},{"runoff":1109.0130241624,"width":146},{"runoff":1110.4271055716,"width":147},{"runoff":1111.827179656,"width":148},{"runoff":1113.2134957852,"width":149},{"runoff":1114.5862975951,"width":150},{"runoff":1115.9458231483,"width":151},{"runoff":1117.292305089,"width":152},{"runoff":1118.6259707938,"width":153},{"runoff":1119.9470425168,"width":154},{"runoff":1121.2557375302,"width":155},{"runoff":1122.5522682614,"width":156},{"runoff":1123.8368424248,"width":157},{"runoff":1125.109663152,"width":158},{"runoff":1126.3709291208,"width":159},{"runoff":1127.6208347082,"width":160},{"runoff":1128.859569868,"width":161},{"runoff":1130.0873204379,"width":162},{"runoff":1131.3042683664,"width":163},{"runoff":1132.5105916987,"width":164},{"runoff":1133.7064646782,"width":165},{"runoff":1134.8920578456,"width":166},{"runoff":1136.0675381343,"width":167},{"runoff":1137.2330689638,"width":168},{"runoff":1138.3888103293,"width":169},{"runoff":1139.5349188895,"width":170},{"runoff":1140.6715480509,"width":171},{"runoff":1141.7988480505,"width":172},{"runoff":1142.9169660353,"width":173},{"runoff":1144.0260461396,"width":174},{"runoff":1145.1262295604,"width":175},{"runoff":1146.2176546304,"width":176},{"runoff":1147.3004568886,"width":177},{"runoff":1148.3747691491,"width":178},{"runoff":1149.4407215683,"width":179},{"runoff":1150.4984417095,"width":180},{"runoff":1151.5480546062,"width":181},{"runoff":1152.5896828234,"width":182},{"runoff":1153.623446518,"width":183},{"runoff":1154.6494634968,"width":184},{"runoff":1155.6678492751,"width":185},{"runoff":1156.6787171354,"width":186},{"runoff":1157.6821782013,"width":187},{"runoff":1158.6783414086,"width":188},{"runoff":1159.6673133617,"width":189},{"runoff":1160.6491988606,"width":190},{"runoff":1161.6241007191,"width":191},{"runoff":1162.5921198064,"width":192},{"runoff":1163.5533550913,"width":193},{"runoff":1164.5079036863,"width":194},{"runoff":1165.45586089,"width":195},{"runoff":1166.3973202281,"width":196},{"runoff":1167.3323734942,"width":197},{"runoff":1168.2611107881,"width":198},{"runoff":1169.1836205549,"width":199},{"runoff":1170.0999896212,"width":200},{"runoff":1171.0103032316,"width":201},{"runoff":1171.9146450841,"width":202},{"runoff":1172.8130973638,"width":203},{"runoff":1173.7057407769,"width":204},{"runoff":1174.5926545829,"width":205},{"runoff":1175.4739166263,"width":206},{"runoff":1176.3496033676,"width":207},{"runoff":1177.2197899139,"width":208},{"runoff":1178.0845500477,"width":209},{"runoff":1178.9439562563,"width":210},{"runoff":1179.7980797604,"width":211},{"runoff":1180.646990542,"width":212},{"runoff":1181.4907573739,"width":213},{"runoff":1182.3294478529,"width":214},{"runoff":1183.1631284677,"width":215},{"runoff":1183.9918643259,"width":216},{"runoff":1184.8157194061,"width":217},{"runoff":1185.6347567562,"width":218},{"runoff":1186.4490383535,"width":219},{"runoff":1187.2586251277,"width":220},{"runoff":1188.0635769817,"width":221},{"runoff":1188.8639528133,"width":222},{"runoff":1189.6598105356,"width":223},{"runoff":1190.451207097,"width":224},{"runoff":1191.2381985009,"width":225},{"runoff":1192.0208398249,"width":226},{"runoff":1192.7991852394,"width":227},{"runoff":1193.5732880261,"width":228},{"runoff":1194.3432005956,"width":229},{"runoff":1195.1089745047,"width":230},{"runoff":1195.870660474,"width":231},{"runoff":1196.6283084038,"width":232},{"runoff":1197.3819673909,"width":233},{"runoff":1198.1316857444,"width":234},{"runoff":1198.8775110012,"width":235},{"runoff":1199.6194899414,"width":236},{"runoff":1200.3576686031,"width":237},{"runoff":1201.0920922977,"width":238},{"runoff":1201.8228056241,"width":239},{"runoff":1202.5498524847,"width":240},{"runoff":1203.2732761008,"width":241},{"runoff":1203.9931190344,"width":242},{"runoff":1204.7094232476,"width":243},{"runoff":1205.4222298061,"width":244},{"runoff":1206.1315790943,"width":245},{"runoff":1206.8375110657,"width":246},{"runoff":1207.5400650607,"width":247},{"runoff":1208.2392798187,"width":248},{"runoff":1208.9351934893,"width":249},{"runoff":1209.6278436428,"width":250},{"runoff":1210.3172672815,"width":251},{"runoff":1211.0035008497,"width":252},{"runoff":1211.6865802446,"width":253},{"runoff":1212.3665408258,"width":254},{"runoff":1213.0434174256,"width":255},{"runoff":1213.7172443581,"width":256},{"runoff":1214.3880554295,"width":257},{"runoff":1215.0558839463,"width":258},{"runoff":1215.7207627253,"width":259},{"runoff":1216.3827241019,"width":260},{"runoff":1217.0417999391,"width":261},{"runoff":1217.6980216359,"width":262},{"runoff":1218.351420136,"width":263},{"runoff":1219.0020259355,"width":264},{"runoff":1219.6498690918,"width":265},{"runoff":1220.2949792313,"width":266},{"runoff":1220.937385558,"width":267},{"runoff":1221.5771168617,"width":268},{"runoff":1222.214201528,"width":269},{"runoff":1222.8486675509,"width":270},{"runoff":1223.480542562,"width":271},{"runoff":1224.1098537859,"width":272},{"runoff":1224.7366276767,"width":273},{"runoff":1225.3608906305,"width":274},{"runoff":1225.9826686959,"width":275},{"runoff":1226.6019875591,"width":276},{"runoff":1227.2188725493,"width":277},{"runoff":1227.833348645,"width":278},{"runoff":1228.4454404803,"width":279},{"runoff":1229.05517235,"width":280},{"runoff":1229.6625682161,"width":281},{"runoff":1230.2676517128,"width":282},{"runoff":1230.8704461525,"width":283},{"runoff":1231.4709745309,"width":284},{"runoff":1232.0692595322,"width":285},{"runoff":1232.6653235348,"width":286},{"runoff":1233.2591886157,"width":287},{"runoff":1233.8508765563,"width":288},{"runoff":1234.4404088466,"width":289},{"runoff":1235.0278066907,"width":290},{"runoff":1235.6130910111,"width":291},{"runoff":1236.1962824538,"width":292},{"runoff":1236.7774013929,"width":293},{"runoff":1237.3564679352,"width":294},{"runoff":1237.9335019254,"width":295},{"runoff":1238.5085229507,"width":296},{"runoff":1239.0815503469,"width":297},{"runoff":1239.6526032051,"width":298},{"runoff":1240.2217003839,"width":299},{"runoff":1240.7888605566,"width":300},{"runoff":1241.3541019829,"width":301},{"runoff":1241.9174424569,"width":302},{"runoff":1242.4788998452,"width":303},{"runoff":1243.0384917858,"width":304},{"runoff":1243.5962356915,"width":305},{"runoff":1244.1521487539,"width":306},{"runoff":1244.7062479465,"width":307},{"runoff":1245.2585500281,"width":308},{"runoff":1245.8090715465,"width":309},{"runoff":1246.3578288413,"width":310},{"runoff":1246.9048380474,"width":311},{"runoff":1247.4501150983,"width":312},{"runoff":1247.9936757288,"width":313},{"runoff":1248.5355354786,"width":314},{"runoff":1249.0757096947,"width":315},{"runoff":1249.6142135352,"width":316},{"runoff":1250.1510619713,"width":317},{"runoff":1250.6862697909,"width":318},{"runoff":1251.2198516011,"width":319},{"runoff":1251.7518218314,"width":320},{"runoff":1252.2821947361,"width":321},{"runoff":1252.8109843973,"width":322},{"runoff":1253.3382047282,"width":323},{"runoff":1253.8638694755,"width":324},{"runoff":1254.3879922232,"width":325},{"runoff":1254.9105863963,"width":326},{"runoff":1255.4316652662,"width":327},{"runoff":1255.9512419603,"width":328},{"runoff":1256.4693294976,"width":329},{"runoff":1256.9859406602,"width":330},{"runoff":1257.5010876933,"width":331},{"runoff":1258.0147830668,"width":332},{"runoff":1258.5270391104,"width":333},{"runoff":1259.0378680091,"width":334},{"runoff":1259.547281805,"width":335},{"runoff":1260.0552923996,"width":336},{"runoff":1260.5619115555,"width":337},{"runoff":1261.0671508986,"width":338},{"runoff":1261.5710219204,"width":339},{"runoff":1262.0735359792,"width":340},{"runoff":1262.5747043027,"width":341},{"runoff":1263.0745379894,"width":342},{"runoff":1263.573048011,"width":343},{"runoff":1264.0702452136,"width":344},{"runoff":1264.56614032,"width":345},{"runoff":1265.0607439312,"width":346},{"runoff":1265.5540665283,"width":347},{"runoff":1266.0461184744,"width":348},{"runoff":1266.536910016,"width":349},{"runoff":1267.0264512853,"width":350},{"runoff":1267.5147523014,"width":351},{"runoff":1268.0018229727,"width":352},{"runoff":1268.4876730985,"width":353},{"runoff":1268.9723123714,"width":354},{"runoff":1269.45575038,"width":355},{"runoff":1269.9379966122,"width":356},{"runoff":1270.4190604617,"width":357},{"runoff":1270.898951244,"width":358},{"runoff":1271.3776782536,"width":359},{"runoff":1271.8552502132,"width":360},{"runoff":1272.3316759203,"width":361},{"runoff":1272.8069642331,"width":362},{"runoff":1273.2811239128,"width":363},{"runoff":1273.7541636259,"width":364},{"runoff":1274.2260919449,"width":365},{"runoff":1274.6969173498,"width":366},{"runoff":1275.1666482292,"width":367},{"runoff":1275.6352928816,"width":368},{"runoff":1276.1028595169,"width":369},{"runoff":1276.5693562569,"width":370},{"runoff":1277.0347911373,"width":371},{"runoff":1277.4991721082,"width":372},{"runoff":1277.9625070357,"width":373},{"runoff":1278.4248037028,"width":374},{"runoff":1278.8860698106,"width":375},{"runoff":1279.3463129796,"width":376},{"runoff":1279.8055407506,"width":377},{"runoff":1280.2637605858,"width":378},{"runoff":1280.7209798703,"width":379},{"runoff":1281.1772059127,"width":380},{"runoff":1281.632445947,"width":381},{"runoff":1282.0867071334,"width":382},{"runoff":1282.5399965599,"width":383},{"runoff":1282.992321244,"width":384},{"runoff":1283.4436881348,"width":385},{"runoff":1283.8941041168,"width":386},{"runoff":1284.3435760159,"width":387},{"runoff":1284.7921106189,"width":388},{"runoff":1285.2397147134,"width":389},{"runoff":1285.68639448,"width":390},{"runoff":1286.1321563169,"width":391},{"runoff":1286.5770066743,"width":392},{"runoff":1287.0209519373,"width":393},{"runoff":1287.4639984268,"width":394},{"runoff":1287.9061524,"width":395},{"runoff":1288.3474200515,"width":396},{"runoff":1288.7878075141,"width":397},{"runoff":1289.2273208593,"width":398},{"runoff":1289.6659660983,"width":399},{"runoff":1290.1037491827,"width":400},{"runoff":1290.5406760055,"width":401},{"runoff":1290.9767524013,"width":402},{"runoff":1291.4119841477,"width":403},{"runoff":1291.8463769653,"width":404},{"runoff":1292.2799365194,"width":405},{"runoff":1292.7126684198,"width":406},{"runoff":1293.1445782221,"width":407},{"runoff":1293.5756714283,"width":408},{"runoff":1294.0059534877,"width":409},{"runoff":1294.4354297974,"width":410},{"runoff":1294.8641057034,"width":411},{"runoff":1295.2919865017,"width":412},{"runoff":1295.7190774389,"width":413},{"runoff":1296.1453837137,"width":414},{"runoff":1296.5709104791,"width":415},{"runoff":1296.9956628443,"width":416},{"runoff":1297.4196458807,"width":417},{"runoff":1297.8428646352,"width":418},{"runoff":1298.2653242035,"width":419},{"runoff":1298.6870292077,"width":420},{"runoff":1299.1079842335,"width":421},{"runoff":1299.5281940833,"width":422},{"runoff":1299.9476635143,"width":423},{"runoff":1300.3663972391,"width":424},{"runoff":1300.7843999264,"width":425},{"runoff":1301.2016762011,"width":426},{"runoff":1301.6182306451,"width":427},{"runoff":1302.0340677978,"width":428},{"runoff":1302.4491921565,"width":429},{"runoff":1302.8636081772,"width":430},{"runoff":1303.2773202745,"width":431},{"runoff":1303.6903328229,"width":432},{"runoff":1304.1026501566,"width":433},{"runoff":1304.5142765705,"width":434},{"runoff":1304.9252163203,"width":435},{"runoff":1305.3354736232,"width":436},{"runoff":1305.7450526586,"width":437},{"runoff":1306.1539575683,"width":438},{"runoff":1306.5621924569,"width":439},{"runoff":1306.9697613931,"width":440},{"runoff":1307.3766684095,"width":441},{"runoff":1307.7829175036,"width":442},{"runoff":1308.1885126387,"width":443},{"runoff":1308.5934577446,"width":444},{"runoff":1308.997756719,"width":445},{"runoff":1309.4014134294,"width":446},{"runoff":1309.8044317162,"width":447},{"runoff":1310.2068153996,"width":448},{"runoff":1310.6085683027,"width":449},{"runoff":1311.0096942784,"width":450},{"runoff":1311.4101965371,"width":451},{"runoff":1311.8100786185,"width":452},{"runoff":1312.2093441368,"width":453},{"runoff":1312.6079966747,"width":454},{"runoff":1313.0060397835,"width":455},{"runoff":1313.4034769837,"width":456},{"runoff":1313.800311765,"width":457},{"runoff":1314.1965475869,"width":458},{"runoff":1314.592187879,"width":459},{"runoff":1314.9872360412,"width":460},{"runoff":1315.3816954445,"width":461},{"runoff":1315.7755694306,"width":462},{"runoff":1316.1688613129,"width":463},{"runoff":1316.5615743765,"width":464},{"runoff":1316.9537118787,"width":465},{"runoff":1317.3452770493,"width":466},{"runoff":1317.7362730909,"width":467},{"runoff":1318.1267031793,"width":468},{"runoff":1318.5165704638,"width":469},{"runoff":1318.9058780678,"width":470},{"runoff":1319.2946290892,"width":471},{"runoff":1319.6828266004,"width":472},{"runoff":1320.0704736497,"width":473},{"runoff":1320.457573261,"width":474},{"runoff":1320.8441284353,"width":475},{"runoff":1321.2301421517,"width":476},{"runoff":1321.6156173688,"width":477},{"runoff":1322.0005570283,"width":478},{"runoff":1322.3849640611,"width":479},{"runoff":1322.7688414092,"width":480},{"runoff":1323.1521920743,"width":481},{"runoff":1323.5350184111,"width":482},{"runoff":1323.9173230694,"width":483},{"runoff":1324.2991088207,"width":484},{"runoff":1324.6803784137,"width":485},{"runoff":1325.0611345745,"width":486},{"runoff":1325.4413800072,"width":487},{"runoff":1325.8211173939,"width":488},{"runoff":1326.2003493948,"width":489},{"runoff":1326.5790786488,"width":490},{"runoff":1326.9573077735,"width":491},{"runoff":1327.3350393656,"width":492},{"runoff":1327.7122760007,"width":493},{"runoff":1328.0890202343,"width":494},{"runoff":1328.4652746013,"width":495},{"runoff":1328.8410416166,"width":496},{"runoff":1329.2163237755,"width":497},{"runoff":1329.5911235536,"width":498},{"runoff":1329.9654434073,"width":499},{"runoff":1330.3392857738,"width":500},{"runoff":1330.712653072,"width":501},{"runoff":1331.085547702,"width":502},{"runoff":1331.4579720461,"width":503},{"runoff":1331.8299284692,"width":504},{"runoff":1332.2014193187,"width":505},{"runoff":1332.5724469258,"width":506},{"runoff":1332.9430136063,"width":507},{"runoff":1333.3131216617,"width":508},{"runoff":1333.6827733816,"width":509},{"runoff":1334.0519710484,"width":510},{"runoff":1334.4207169491,"width":511},{"runoff":1334.7890134374,"width":512},{"runoff":1335.1568626055,"width":513},{"runoff":1335.5242661709,"width":514},{"runoff":1335.8912262943,"width":515},{"runoff":1336.25774512,"width":516},{"runoff":1336.623824776,"width":517},{"runoff":1336.9894673738,"width":518},{"runoff":1337.3546750092,"width":519},{"runoff":1337.7194497617,"width":520},{"runoff":1338.0837936955,"width":521},{"runoff":1338.4477088589,"width":522},{"runoff":1338.8111972851,"width":523},{"runoff":1339.1742609919,"width":524},{"runoff":1339.5369019822,"width":525},{"runoff":1339.8991222439,"width":526},{"runoff":1340.2609237503,"width":527},{"runoff":1340.6223084603,"width":528},{"runoff":1340.9832783183,"width":529},{"runoff":1341.3438352547,"width":530},{"runoff":1341.7039811858,"width":531},{"runoff":1342.0637180146,"width":532},{"runoff":1342.4230476303,"width":533},{"runoff":1342.781971909,"width":534},{"runoff":1343.140492714,"width":535},{"runoff":1343.4986118961,"width":536},{"runoff":1343.8563312941,"width":537},{"runoff":1344.2136527355,"width":538},{"runoff":1344.5705780372,"width":539},{"runoff":1344.9271090071,"width":540},{"runoff":1345.2832474468,"width":541},{"runoff":1345.6389951562,"width":542},{"runoff":1345.9943539478,"width":543},{"runoff":1346.3493257273,"width":544},{"runoff":1346.7039119964,"width":545},{"runoff":1347.0581140698,"width":546},{"runoff":1347.4119336457,"width":547},{"runoff":1347.7653724103,"width":548},{"runoff":1348.1184320373,"width":549},{"runoff":1348.471114189,"width":550},{"runoff":1348.8234205155,"width":551},{"runoff":1349.1753526554,"width":552},{"runoff":1349.5269122356,"width":553},{"runoff":1349.8781008717,"width":554},{"runoff":1350.2289201679,"width":555},{"runoff":1350.5793717173,"width":556},{"runoff":1350.9294571016,"width":557},{"runoff":1351.2791778921,"width":558},{"runoff":1351.6285356489,"width":559},{"runoff":1351.9775319214,"width":560},{"runoff":1352.3261682489,"width":561},{"runoff":1352.6744461598,"width":562},{"runoff":1353.0223671726,"width":563},{"runoff":1353.3699327959,"width":564},{"runoff":1353.717144528,"width":565},{"runoff":1354.0640038581,"width":566},{"runoff":1354.4105122657,"width":567},{"runoff":1354.7566712215,"width":568},{"runoff":1355.1024821876,"width":569},{"runoff":1355.4479466178,"width":570},{"runoff":1355.7930659589,"width":571},{"runoff":1356.1378416517,"width":572},{"runoff":1356.482275133,"width":573},{"runoff":1356.8263678398,"width":574},{"runoff":1357.1701212192,"width":575},{"runoff":1357.513536769,"width":576},{"runoff":1357.85661593,"width":577},{"runoff":1358.1993594761,"width":578},{"runoff":1358.5417687294,"width":579},{"runoff":1358.8838450352,"width":580},{"runoff":1359.2255897298,"width":581},{"runoff":1359.5670041404,"width":582},{"runoff":1359.9080895857,"width":583},{"runoff":1360.2488473753,"width":584},{"runoff":1360.5892788105,"width":585},{"runoff":1360.9293851838,"width":586},{"runoff":1361.2691677793,"width":587},{"runoff":1361.6086278728,"width":588},{"runoff":1361.9477667318,"width":589},{"runoff":1362.2865856156,"width":590},{"runoff":1362.6250857753,"width":591},{"runoff":1362.9632684542,"width":592},{"runoff":1363.3011348878,"width":593},{"runoff":1363.6386863037,"width":594},{"runoff":1363.9759239219,"width":595},{"runoff":1364.312848955,"width":596},{"runoff":1364.6494626083,"width":597},{"runoff":1364.9857660799,"width":598},{"runoff":1365.321760561,"width":599},{"runoff":1365.6574472362,"width":600},{"runoff":1365.9928272837,"width":601},{"runoff":1366.3279018757,"width":602},{"runoff":1366.6626721794,"width":603},{"runoff":1366.9971393574,"width":604},{"runoff":1367.3313045691,"width":605},{"runoff":1367.6651689737,"width":606},{"runoff":1367.9987337349,"width":607},{"runoff":1368.332000035,"width":608},{"runoff":1368.6649691527,"width":609},{"runoff":1368.99764202,"width":610},{"runoff":1369.3300192562,"width":611},{"runoff":1369.662101949,"width":612},{"runoff":1369.9938911795,"width":613},{"runoff":1370.325388022,"width":614},{"runoff":1370.6565935439,"width":615},{"runoff":1370.9875088064,"width":616},{"runoff":1371.3181348636,"width":617},{"runoff":1371.6484727637,"width":618},{"runoff":1371.978523548,"width":619},{"runoff":1372.3082882517,"width":620},{"runoff":1372.6377679036,"width":621},{"runoff":1372.9669635264,"width":622},{"runoff":1373.2958761366,"width":623},{"runoff":1373.6245067447,"width":624},{"runoff":1373.9528563553,"width":625},{"runoff":1374.280925967,"width":626},{"runoff":1374.6087165727,"width":627},{"runoff":1374.9362291596,"width":628},{"runoff":1375.2634647095,"width":629},{"runoff":1375.5904241986,"width":630},{"runoff":1375.9171085981,"width":631},{"runoff":1376.2435188738,"width":632},{"runoff":1376.5696559871,"width":633},{"runoff":1376.8955208946,"width":634},{"runoff":1377.2211145488,"width":635},{"runoff":1377.5464378988,"width":636},{"runoff":1377.871491891,"width":637},{"runoff":1378.1962774701,"width":638},{"runoff":1378.520795582,"width":639},{"runoff":1378.8450471779,"width":640},{"runoff":1379.1690332262,"width":641},{"runoff":1379.4927547621,"width":642},{"runoff":1379.8162127016,"width":643},{"runoff":1380.1394073209,"width":644},{"runoff":1380.4623394901,"width":645},{"runoff":1380.7850100895,"width":646},{"runoff":1381.1074199939,"width":647},{"runoff":1381.4295700734,"width":648},{"runoff":1381.7514611928,"width":649},{"runoff":1382.073094212,"width":650},{"runoff":1382.3944699859,"width":651},{"runoff":1382.7155893647,"width":652},{"runoff":1383.0364531936,"width":653},{"runoff":1383.357062313,"width":654},{"runoff":1383.6774175588,"width":655},{"runoff":1383.997519762,"width":656},{"runoff":1384.3173697493,"width":657},{"runoff":1384.6369683426,"width":658},{"runoff":1384.9563163595,"width":659},{"runoff":1385.2754146132,"width":660},{"runoff":1385.5942639127,"width":661},{"runoff":1385.9128650629,"width":662},{"runoff":1386.2312188643,"width":663},{"runoff":1386.5493261139,"width":664},{"runoff":1386.8671876047,"width":665},{"runoff":1387.1848041262,"width":666},{"runoff":1387.5021764647,"width":667},{"runoff":1387.8193054032,"width":668},{"runoff":1388.1361917226,"width":669},{"runoff":1388.4528362016,"width":670},{"runoff":1388.7692396181,"width":671},{"runoff":1389.0854027508,"width":672},{"runoff":1389.4013263822,"width":673},{"runoff":1389.7170113047,"width":674},{"runoff":1390.0324583398,"width":675},{"runoff":1390.3476684367,"width":676},{"runoff":1390.6626419345,"width":677},{"runoff":1390.9773792183,"width":678},{"runoff":1391.2918810135,"width":679},{"runoff":1391.6061480414,"width":680},{"runoff":1391.9201810195,"width":681},{"runoff":1392.2339806614,"width":682},{"runoff":1392.5475476768,"width":683},{"runoff":1392.8608827715,"width":684},{"runoff":1393.1739866478,"width":685},{"runoff":1393.4868600041,"width":686},{"runoff":1393.7995035352,"width":687},{"runoff":1394.1119179321,"width":688},{"runoff":1394.4241038826,"width":689},{"runoff":1394.7360620705,"width":690},{"runoff":1395.0477931766,"width":691},{"runoff":1395.359297878,"width":692},{"runoff":1395.6705768485,"width":693},{"runoff":1395.9816307586,"width":694},{"runoff":1396.2924602758,"width":695},{"runoff":1396.6030660643,"width":696},{"runoff":1396.9134487854,"width":697},{"runoff":1397.2236090974,"width":698},{"runoff":1397.5335476561,"width":699},{"runoff":1397.8432651147,"width":700},{"runoff":1398.1527621241,"width":701},{"runoff":1398.4620393331,"width":702},{"runoff":1398.7710973892,"width":703},{"runoff":1399.0799369389,"width":704},{"runoff":1399.3885586289,"width":705},{"runoff":1399.6969631074,"width":706},{"runoff":1400.0051510274,"width":707},{"runoff":1400.3131230527,"width":708},{"runoff":1400.6208798751,"width":709},{"runoff":1400.9284223146,"width":710},{"runoff":1401.235750653,"width":711},{"runoff":1401.5428650778,"width":712},{"runoff":1401.8497661903,"width":713},{"runoff":1402.1564545888,"width":714},{"runoff":1402.4629308686,"width":715},{"runoff":1402.769195622,"width":716},{"runoff":1403.0752494384,"width":717},{"runoff":1403.3810929041,"width":718},{"runoff":1403.6867266026,"width":719},{"runoff":1403.9921511146,"width":720},{"runoff":1404.2973670179,"width":721},{"runoff":1404.6023748876,"width":722},{"runoff":1404.9071752958,"width":723},{"runoff":1405.2117688123,"width":724},{"runoff":1405.5161560039,"width":725},{"runoff":1405.8203374349,"width":726},{"runoff":1406.1243136672,"width":727},{"runoff":1406.42808526,"width":728},{"runoff":1406.7316527702,"width":729},{"runoff":1407.0350167523,"width":730},{"runoff":1407.3381777586,"width":731},{"runoff":1407.6411363394,"width":732},{"runoff":1407.9438930426,"width":733},{"runoff":1408.2464484147,"width":734},{"runoff":1408.5488030005,"width":735},{"runoff":1408.8509573431,"width":736},{"runoff":1409.152911985,"width":737},{"runoff":1409.4546674682,"width":738},{"runoff":1409.7562243348,"width":739},{"runoff":1410.0575831287,"width":740},{"runoff":1410.3587443976,"width":741},{"runoff":1410.6597086966,"width":742},{"runoff":1410.9604765989,"width":743},{"runoff":1411.2610487298,"width":744},{"runoff":1411.5614257644,"width":745},{"runoff":1411.861607536,"width":746},{"runoff":1412.1615944428,"width":747},{"runoff":1412.4613869865,"width":748},{"runoff":1412.7609856665,"width":749},{"runoff":1413.0603909799,"width":750},{"runoff":1413.3596034215,"width":751},{"runoff":1413.6586234837,"width":752},{"runoff":1413.9574516568,"width":753},{"runoff":1414.2560884287,"width":754},{"runoff":1414.5545342851,"width":755},{"runoff":1414.8527897099,"width":756},{"runoff":1415.1508551843,"width":757},{"runoff":1415.4487311878,"width":758},{"runoff":1415.7464181978,"width":759},{"runoff":1416.0439166895,"width":760},{"runoff":1416.3412271362,"width":761},{"runoff":1416.6383500095,"width":762},{"runoff":1416.9352857788,"width":763},{"runoff":1417.232034912,"width":764},{"runoff":1417.528597875,"width":765},{"runoff":1417.8249751323,"width":766},{"runoff":1418.1211671468,"width":767},{"runoff":1418.41717438,"width":768},{"runoff":1418.7129972921,"width":769},{"runoff":1419.0086363424,"width":770},{"runoff":1419.3040919894,"width":771},{"runoff":1419.5993646914,"width":772},{"runoff":1419.8944549065,"width":773},{"runoff":1420.1893630941,"width":774},{"runoff":1420.4840897158,"width":775},{"runoff":1420.7786352374,"width":776},{"runoff":1421.0730001335,"width":777},{"runoff":1421.3671848972,"width":778},{"runoff":1421.6611900759,"width":779},{"runoff":1421.9550162699,"width":780},{"runoff":1422.2486632163,"width":781},{"runoff":1422.5421312303,"width":782},{"runoff":1422.8354207352,"width":783},{"runoff":1423.1285321524,"width":784},{"runoff":1423.4214659015,"width":785},{"runoff":1423.7142224003,"width":786},{"runoff":1424.0068020648,"width":787},{"runoff":1424.2992053094,"width":788},{"runoff":1424.5914325465,"width":789},{"runoff":1424.8834841869,"width":790},{"runoff":1425.1753606398,"width":791},{"runoff":1425.4670623127,"width":792},{"runoff":1425.7585896115,"width":793},{"runoff":1426.0499429404,"width":794},{"runoff":1426.3411227023,"width":795},{"runoff":1426.6321292982,"width":796},{"runoff":1426.9229631281,"width":797},{"runoff":1427.2136245903,"width":798},{"runoff":1427.5041140817,"width":799},{"runoff":1427.7944319982,"width":800},{"runoff":1428.0845787342,"width":801},{"runoff":1428.3745546831,"width":802},{"runoff":1428.6643602375,"width":803},{"runoff":1428.9539957889,"width":804},{"runoff":1429.2434617282,"width":805},{"runoff":1429.532758446,"width":806},{"runoff":1429.8218863326,"width":807},{"runoff":1430.110845779,"width":808},{"runoff":1430.399637177,"width":809},{"runoff":1430.6882609205,"width":810},{"runoff":1430.9767174071,"width":811},{"runoff":1431.2650070414,"width":812},{"runoff":1431.5531302413,"width":813},{"runoff":1431.8410874563,"width":814},{"runoff":1432.1288792744,"width":815},{"runoff":1432.4165057269,"width":816},{"runoff":1432.7039667115,"width":817},{"runoff":1432.9912625889,"width":818},{"runoff":1433.2783937183,"width":819},{"runoff":1433.5653604575,"width":820},{"runoff":1433.852163163,"width":821},{"runoff":1434.1388021896,"width":822},{"runoff":1434.4252778911,"width":823},{"runoff":1434.7115906196,"width":824},{"runoff":1434.997740726,"width":825},{"runoff":1435.2837285601,"width":826},{"runoff":1435.5695544699,"width":827},{"runoff":1435.8552188027,"width":828},{"runoff":1436.1407219041,"width":829},{"runoff":1436.4260641189,"width":830},{"runoff":1436.7112457903,"width":831},{"runoff":1436.9962672607,"width":832},{"runoff":1437.2811288713,"width":833},{"runoff":1437.5658309622,"width":834},{"runoff":1437.8503738727,"width":835},{"runoff":1438.1347579411,"width":836},{"runoff":1438.4189835047,"width":837},{"runoff":1438.7030509003,"width":838},{"runoff":1438.9869604639,"width":839},{"runoff":1439.2707125312,"width":840},{"runoff":1439.5543074376,"width":841},{"runoff":1439.8377455183,"width":842},{"runoff":1440.121027109,"width":843},{"runoff":1440.404152546,"width":844},{"runoff":1440.6871221671,"width":845},{"runoff":1440.9699363129,"width":846},{"runoff":1441.2525953278,"width":847},{"runoff":1441.5350995641,"width":848},{"runoff":1441.8174493884,"width":849},{"runoff":1442.0996452015,"width":850},{"runoff":1442.3816875497,"width":851},{"runoff":1442.6635763576,"width":852},{"runoff":1442.945311497,"width":853},{"runoff":1443.2268932768,"width":854},{"runoff":1443.5083220051,"width":855},{"runoff":1443.7895979888,"width":856},{"runoff":1444.0707215336,"width":857},{"runoff":1444.3516929441,"width":858},{"runoff":1444.632512524,"width":859},{"runoff":1444.9131805756,"width":860},{"runoff":1445.1936974005,"width":861},{"runoff":1445.4740632991,"width":862},{"runoff":1445.7542785708,"width":863},{"runoff":1446.0343435141,"width":864},{"runoff":1446.3142584265,"width":865},{"runoff":1446.5940236045,"width":866},{"runoff":1446.8736393438,"width":867},{"runoff":1447.1531059393,"width":868},{"runoff":1447.4324236849,"width":869},{"runoff":1447.711592874,"width":870},{"runoff":1447.9906137989,"width":871},{"runoff":1448.2694867517,"width":872},{"runoff":1448.5482120234,"width":873},{"runoff":1448.8267899051,"width":874},{"runoff":1449.1052206869,"width":875},{"runoff":1449.3835046591,"width":876},{"runoff":1449.6616421118,"width":877},{"runoff":1449.939633335,"width":878},{"runoff":1450.2174786195,"width":879},{"runoff":1450.4951782566,"width":880},{"runoff":1450.7727325394,"width":881},{"runoff":1451.0501417629,"width":882},{"runoff":1451.3274062261,"width":883},{"runoff":1451.6045262341,"width":884},{"runoff":1451.881502103,"width":885},{"runoff":1452.1583341721,"width":886},{"runoff":1452.4350228492,"width":887},{"runoff":1452.7115685293,"width":888},{"runoff":1452.9879707244,"width":889},{"runoff":1453.2642296377,"width":890},{"runoff":1453.5403455358,"width":891},{"runoff":1453.8163186841,"width":892},{"runoff":1454.0921493475,"width":893},{"runoff":1454.3678377899,"width":894},{"runoff":1454.6433842741,"width":895},{"runoff":1454.9187890624,"width":896},{"runoff":1455.1940524162,"width":897},{"runoff":1455.4691745959,"width":898},{"runoff":1455.7441558613,"width":899},{"runoff":1456.0189964714,"width":900},{"runoff":1456.2936966845,"width":901},{"runoff":1456.5682567581,"width":902},{"runoff":1456.8426769489,"width":903},{"runoff":1457.1169575133,"width":904},{"runoff":1457.3910987066,"width":905},{"runoff":1457.6651007839,"width":906},{"runoff":1457.9389639997,"width":907},{"runoff":1458.2126886077,"width":908},{"runoff":1458.4862748616,"width":909},{"runoff":1458.7597230144,"width":910},{"runoff":1459.033033319,"width":911},{"runoff":1459.3062060283,"width":912},{"runoff":1459.5792413947,"width":913},{"runoff":1459.8521396713,"width":914},{"runoff":1460.1249011113,"width":915},{"runoff":1460.3975259685,"width":916},{"runoff":1460.670014498,"width":917},{"runoff":1460.9423669564,"width":918},{"runoff":1461.2145836033,"width":919},{"runoff":1461.486664702,"width":920},{"runoff":1461.7586105226,"width":921},{"runoff":1462.0304213472,"width":922},{"runoff":1462.3020974823,"width":923},{"runoff":1462.5736393103,"width":924},{"runoff":1462.8450471607,"width":925},{"runoff":1463.1163204976,"width":926},{"runoff":1463.3874595072,"width":927},{"runoff":1463.6584644214,"width":928},{"runoff":1463.9293354715,"width":929},{"runoff":1464.2000728879,"width":930},{"runoff":1464.4706769005,"width":931},{"runoff":1464.7411477383,"width":932},{"runoff":1465.0114856298,"width":933},{"runoff":1465.2816908029,"width":934},{"runoff":1465.5517634849,"width":935},{"runoff":1465.8217039022,"width":936},{"runoff":1466.0915122809,"width":937},{"runoff":1466.3611888465,"width":938},{"runoff":1466.6307338239,"width":939},{"runoff":1466.9001474373,"width":940},{"runoff":1467.1694299108,"width":941},{"runoff":1467.4385814677,"width":942},{"runoff":1467.707602331,"width":943},{"runoff":1467.9764927234,"width":944},{"runoff":1468.2452528671,"width":945},{"runoff":1468.513882984,"width":946},{"runoff":1468.7823832961,"width":947},{"runoff":1469.0507540249,"width":948},{"runoff":1469.3189953921,"width":949},{"runoff":1469.5871076193,"width":950},{"runoff":1469.8550909286,"width":951},{"runoff":1470.1229455424,"width":952},{"runoff":1470.3906716838,"width":953},{"runoff":1470.658269577,"width":954},{"runoff":1470.9257394479,"width":955},{"runoff":1471.1930815246,"width":956},{"runoff":1471.4602960388,"width":957},{"runoff":1471.727383228,"width":958},{"runoff":1471.9943433387,"width":959},{"runoff":1472.261176635,"width":960},{"runoff":1472.5278834235,"width":961},{"runoff":1472.7944641663,"width":962},{"runoff":1473.0609185095,"width":963},{"runoff":1473.3272463129,"width":964},{"runoff":1473.5934477799,"width":965},{"runoff":1473.8595231135,"width":966},{"runoff":1474.1254725159,"width":967},{"runoff":1474.3912961892,"width":968},{"runoff":1474.6569943345,"width":969},{"runoff":1474.9225671526,"width":970},{"runoff":1475.1880148439,"width":971},{"runoff":1475.4533376082,"width":972},{"runoff":1475.7185356446,"width":973},{"runoff":1475.9836091522,"width":974},{"runoff":1476.2485583292,"width":975},{"runoff":1476.5133833737,"width":976},{"runoff":1476.7780844832,"width":977},{"runoff":1477.042661855,"width":978},{"runoff":1477.3071156859,"width":979},{"runoff":1477.5714461725,"width":980},{"runoff":1477.835653511,"width":981},{"runoff":1478.0997378974,"width":982},{"runoff":1478.3636995275,"width":983},{"runoff":1478.6275385972,"width":984},{"runoff":1478.8912553019,"width":985},{"runoff":1479.1548498373,"width":986},{"runoff":1479.4183223992,"width":987},{"runoff":1479.6816731836,"width":988},{"runoff":1479.9449023869,"width":989},{"runoff":1480.208010206,"width":990},{"runoff":1480.470996839,"width":991},{"runoff":1480.7338624848,"width":992},{"runoff":1480.9966073444,"width":993},{"runoff":1481.2592316213,"width":994},{"runoff":1481.5217355228,"width":995},{"runoff":1481.7841192619,"width":996},{"runoff":1482.0463830615,"width":997},{"runoff":1482.3085271626,"width":998},{"runoff":1482.5705518503,"width":999}] \ No newline at end of file +[{"width":10.0,"runoff":1703.05},{"width":20.0,"runoff":2097.93},{"width":30.0,"runoff":2393.38},{"width":40.0,"runoff":2623.65},{"width":50.0,"runoff":2808.06},{"width":60.0,"runoff":2958.95},{"width":70.0,"runoff":3084.59},{"width":80.0,"runoff":3190.79},{"width":90.0,"runoff":3281.69},{"width":100.0,"runoff":3360.36},{"width":110.0,"runoff":3429.11},{"width":120.0,"runoff":3489.71},{"width":130.0,"runoff":3543.51},{"width":140.0,"runoff":3591.61},{"width":150.0,"runoff":3634.88},{"width":160.0,"runoff":3674.01},{"width":170.0,"runoff":3709.58},{"width":180.0,"runoff":3742.05},{"width":190.0,"runoff":3771.82},{"width":200.0,"runoff":3799.21},{"width":210.0,"runoff":3824.51},{"width":220.0,"runoff":3847.95},{"width":230.0,"runoff":3869.74},{"width":240.0,"runoff":3890.04},{"width":250.0,"runoff":3909.0},{"width":260.0,"runoff":3926.76},{"width":270.0,"runoff":3943.43},{"width":280.0,"runoff":3959.11},{"width":290.0,"runoff":3973.88},{"width":300.0,"runoff":3987.84},{"width":310.0,"runoff":4001.03},{"width":320.0,"runoff":4013.53},{"width":330.0,"runoff":4025.4},{"width":340.0,"runoff":4036.67},{"width":350.0,"runoff":4047.93},{"width":360.0,"runoff":4057.61},{"width":370.0,"runoff":4067.35},{"width":380.0,"runoff":4076.67},{"width":390.0,"runoff":4085.58},{"width":400.0,"runoff":4094.11},{"width":410.0,"runoff":4102.29},{"width":420.0,"runoff":4110.14},{"width":430.0,"runoff":4117.68},{"width":440.0,"runoff":4124.92},{"width":450.0,"runoff":4131.88},{"width":460.0,"runoff":4138.58},{"width":470.0,"runoff":4145.03},{"width":480.0,"runoff":4151.25},{"width":490.0,"runoff":4157.26},{"width":500.0,"runoff":4163.05},{"width":510.0,"runoff":4168.65},{"width":520.0,"runoff":4174.07},{"width":530.0,"runoff":4179.32},{"width":540.0,"runoff":4184.4},{"width":550.0,"runoff":4189.32},{"width":560.0,"runoff":4194.09},{"width":570.0,"runoff":4198.72},{"width":580.0,"runoff":4203.19},{"width":590.0,"runoff":4207.54},{"width":600.0,"runoff":4211.77},{"width":610.0,"runoff":4215.88},{"width":620.0,"runoff":4219.87},{"width":630.0,"runoff":4223.75},{"width":640.0,"runoff":4227.53},{"width":650.0,"runoff":4231.21},{"width":660.0,"runoff":4234.8},{"width":670.0,"runoff":4238.29},{"width":680.0,"runoff":4241.69},{"width":690.0,"runoff":4245.0},{"width":700.0,"runoff":4248.24},{"width":710.0,"runoff":4251.39},{"width":720.0,"runoff":4254.47},{"width":730.0,"runoff":4257.48},{"width":740.0,"runoff":4260.41},{"width":750.0,"runoff":4263.28},{"width":760.0,"runoff":4266.08},{"width":770.0,"runoff":4268.81},{"width":780.0,"runoff":4271.49},{"width":790.0,"runoff":4274.12},{"width":800.0,"runoff":4276.69},{"width":810.0,"runoff":4279.2},{"width":820.0,"runoff":4281.66},{"width":830.0,"runoff":4284.06},{"width":840.0,"runoff":4286.42},{"width":850.0,"runoff":4288.73},{"width":860.0,"runoff":4291.0},{"width":870.0,"runoff":4293.21},{"width":880.0,"runoff":4295.38},{"width":890.0,"runoff":4297.51},{"width":900.0,"runoff":4299.62},{"width":910.0,"runoff":4301.68},{"width":920.0,"runoff":4303.7},{"width":930.0,"runoff":4305.67},{"width":940.0,"runoff":4307.62},{"width":950.0,"runoff":4309.51},{"width":960.0,"runoff":4311.39},{"width":970.0,"runoff":4313.22},{"width":980.0,"runoff":4315.02},{"width":990.0,"runoff":4316.79},{"width":1000.0,"runoff":4318.53}] \ No newline at end of file diff --git a/cs_app/data/example_hydrograph.json b/cs_app/data/example_hydrograph.json new file mode 100644 index 0000000..182c6ea --- /dev/null +++ b/cs_app/data/example_hydrograph.json @@ -0,0 +1 @@ +[{"datetime":"2022-06-17T00:01:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:02:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:03:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:04:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:05:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:06:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:07:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:08:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:09:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:10:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:11:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:12:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:13:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:14:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:15:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:16:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:17:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:18:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:19:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:20:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:21:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:22:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:23:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:24:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:25:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:26:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:27:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:28:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:29:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:30:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:31:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:32:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:33:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:34:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:35:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:36:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:37:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:38:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:39:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:40:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:41:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:42:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:43:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:44:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:45:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:46:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:47:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:48:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:49:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:50:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:51:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:52:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:53:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:54:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:55:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:56:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:57:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:58:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:59:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:00:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:01:00","rainfall":1.0,"runoff":0.000195919,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:02:00","rainfall":1.0,"runoff":0.000195919,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:03:00","rainfall":1.0,"runoff":0.000195919,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:04:00","rainfall":1.0,"runoff":0.000195919,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:05:00","rainfall":1.0,"runoff":0.000195919,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:06:00","rainfall":1.0,"runoff":0.000876633,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:07:00","rainfall":1.0,"runoff":0.000876633,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:08:00","rainfall":1.0,"runoff":0.000876633,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:09:00","rainfall":1.0,"runoff":0.000876633,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:10:00","rainfall":1.0,"runoff":0.000876633,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:11:00","rainfall":1.0,"runoff":0.00163422,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:12:00","rainfall":1.0,"runoff":0.00163422,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:13:00","rainfall":1.0,"runoff":0.00163422,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:14:00","rainfall":1.0,"runoff":0.00163422,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:15:00","rainfall":1.0,"runoff":0.00163422,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:16:00","rainfall":1.0,"runoff":0.00225809,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:17:00","rainfall":1.0,"runoff":0.00225809,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:18:00","rainfall":1.0,"runoff":0.00225809,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:19:00","rainfall":1.0,"runoff":0.00225809,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:20:00","rainfall":1.0,"runoff":0.00225809,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:21:00","rainfall":1.0,"runoff":0.00270409,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:22:00","rainfall":1.0,"runoff":0.00270409,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:23:00","rainfall":1.0,"runoff":0.00270409,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:24:00","rainfall":1.0,"runoff":0.00270409,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:25:00","rainfall":1.0,"runoff":0.00270409,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:26:00","rainfall":1.0,"runoff":0.00299897,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:27:00","rainfall":1.0,"runoff":0.00299897,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:28:00","rainfall":1.0,"runoff":0.00299897,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:29:00","rainfall":1.0,"runoff":0.00299897,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:30:00","rainfall":1.0,"runoff":0.00299897,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:31:00","rainfall":1.0,"runoff":0.00318532,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:32:00","rainfall":1.0,"runoff":0.00318532,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:33:00","rainfall":1.0,"runoff":0.00318532,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:34:00","rainfall":1.0,"runoff":0.00318532,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:35:00","rainfall":1.0,"runoff":0.00318532,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:36:00","rainfall":1.0,"runoff":0.00330002,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:37:00","rainfall":1.0,"runoff":0.00330002,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:38:00","rainfall":1.0,"runoff":0.00330002,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:39:00","rainfall":1.0,"runoff":0.00330002,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:40:00","rainfall":1.0,"runoff":0.00330002,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:41:00","rainfall":1.0,"runoff":0.00336951,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:42:00","rainfall":1.0,"runoff":0.00336951,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:43:00","rainfall":1.0,"runoff":0.00336951,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:44:00","rainfall":1.0,"runoff":0.00336951,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:45:00","rainfall":1.0,"runoff":0.00336951,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:46:00","rainfall":1.0,"runoff":0.00341123,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:47:00","rainfall":1.0,"runoff":0.00341123,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:48:00","rainfall":1.0,"runoff":0.00341123,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:49:00","rainfall":1.0,"runoff":0.00341123,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:50:00","rainfall":1.0,"runoff":0.00341123,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:51:00","rainfall":1.0,"runoff":0.00343615,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:52:00","rainfall":1.0,"runoff":0.00343615,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:53:00","rainfall":1.0,"runoff":0.00343615,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:54:00","rainfall":1.0,"runoff":0.00343615,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:55:00","rainfall":1.0,"runoff":0.00343615,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:56:00","rainfall":1.0,"runoff":0.00345097,"infiltration_loss":0.732955,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:57:00","rainfall":1.0,"runoff":0.00345097,"infiltration_loss":0.732955,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:58:00","rainfall":1.0,"runoff":0.00345097,"infiltration_loss":0.732955,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:59:00","rainfall":1.0,"runoff":0.00345097,"infiltration_loss":0.732955,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:00:00","rainfall":1.0,"runoff":0.00345097,"infiltration_loss":0.732955,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:01:00","rainfall":2.0,"runoff":0.00496575,"infiltration_loss":0.703926,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:02:00","rainfall":2.0,"runoff":0.00496575,"infiltration_loss":0.703926,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:03:00","rainfall":2.0,"runoff":0.00496575,"infiltration_loss":0.703926,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:04:00","rainfall":2.0,"runoff":0.00496575,"infiltration_loss":0.703926,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:05:00","rainfall":2.0,"runoff":0.00496575,"infiltration_loss":0.703926,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:06:00","rainfall":2.0,"runoff":0.00589845,"infiltration_loss":0.687727,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:07:00","rainfall":2.0,"runoff":0.00589845,"infiltration_loss":0.687727,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:08:00","rainfall":2.0,"runoff":0.00589845,"infiltration_loss":0.687727,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:09:00","rainfall":2.0,"runoff":0.00589845,"infiltration_loss":0.687727,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:10:00","rainfall":2.0,"runoff":0.00589845,"infiltration_loss":0.687727,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:11:00","rainfall":2.0,"runoff":0.00642393,"infiltration_loss":0.674333,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:12:00","rainfall":2.0,"runoff":0.00642393,"infiltration_loss":0.674333,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:13:00","rainfall":2.0,"runoff":0.00642393,"infiltration_loss":0.674333,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:14:00","rainfall":2.0,"runoff":0.00642393,"infiltration_loss":0.674333,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:15:00","rainfall":2.0,"runoff":0.00642393,"infiltration_loss":0.674333,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:16:00","rainfall":2.0,"runoff":0.0067128,"infiltration_loss":0.66306,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:17:00","rainfall":2.0,"runoff":0.0067128,"infiltration_loss":0.66306,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:18:00","rainfall":2.0,"runoff":0.0067128,"infiltration_loss":0.66306,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:19:00","rainfall":2.0,"runoff":0.0067128,"infiltration_loss":0.66306,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:20:00","rainfall":2.0,"runoff":0.0067128,"infiltration_loss":0.66306,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:21:00","rainfall":2.0,"runoff":0.00687577,"infiltration_loss":0.65343,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:22:00","rainfall":2.0,"runoff":0.00687577,"infiltration_loss":0.65343,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:23:00","rainfall":2.0,"runoff":0.00687577,"infiltration_loss":0.65343,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:24:00","rainfall":2.0,"runoff":0.00687577,"infiltration_loss":0.65343,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:25:00","rainfall":2.0,"runoff":0.00687577,"infiltration_loss":0.65343,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:26:00","rainfall":2.0,"runoff":0.0069752,"infiltration_loss":0.645104,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:27:00","rainfall":2.0,"runoff":0.0069752,"infiltration_loss":0.645104,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:28:00","rainfall":2.0,"runoff":0.0069752,"infiltration_loss":0.645104,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:29:00","rainfall":2.0,"runoff":0.0069752,"infiltration_loss":0.645104,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:30:00","rainfall":2.0,"runoff":0.0069752,"infiltration_loss":0.645104,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:31:00","rainfall":2.0,"runoff":0.00704412,"infiltration_loss":0.637827,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:32:00","rainfall":2.0,"runoff":0.00704412,"infiltration_loss":0.637827,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:33:00","rainfall":2.0,"runoff":0.00704412,"infiltration_loss":0.637827,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:34:00","rainfall":2.0,"runoff":0.00704412,"infiltration_loss":0.637827,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:35:00","rainfall":2.0,"runoff":0.00704412,"infiltration_loss":0.637827,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:36:00","rainfall":2.0,"runoff":0.00709945,"infiltration_loss":0.631408,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:37:00","rainfall":2.0,"runoff":0.00709945,"infiltration_loss":0.631408,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:38:00","rainfall":2.0,"runoff":0.00709945,"infiltration_loss":0.631408,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:39:00","rainfall":2.0,"runoff":0.00709945,"infiltration_loss":0.631408,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:40:00","rainfall":2.0,"runoff":0.00709945,"infiltration_loss":0.631408,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:41:00","rainfall":2.0,"runoff":0.0071497,"infiltration_loss":0.625701,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:42:00","rainfall":2.0,"runoff":0.0071497,"infiltration_loss":0.625701,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:43:00","rainfall":2.0,"runoff":0.0071497,"infiltration_loss":0.625701,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:44:00","rainfall":2.0,"runoff":0.0071497,"infiltration_loss":0.625701,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:45:00","rainfall":2.0,"runoff":0.0071497,"infiltration_loss":0.625701,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:46:00","rainfall":2.0,"runoff":0.00719912,"infiltration_loss":0.620589,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:47:00","rainfall":2.0,"runoff":0.00719912,"infiltration_loss":0.620589,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:48:00","rainfall":2.0,"runoff":0.00719912,"infiltration_loss":0.620589,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:49:00","rainfall":2.0,"runoff":0.00719912,"infiltration_loss":0.620589,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:50:00","rainfall":2.0,"runoff":0.00719912,"infiltration_loss":0.620589,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:51:00","rainfall":2.0,"runoff":0.00724976,"infiltration_loss":0.61598,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:52:00","rainfall":2.0,"runoff":0.00724976,"infiltration_loss":0.61598,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:53:00","rainfall":2.0,"runoff":0.00724976,"infiltration_loss":0.61598,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:54:00","rainfall":2.0,"runoff":0.00724976,"infiltration_loss":0.61598,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:55:00","rainfall":2.0,"runoff":0.00724976,"infiltration_loss":0.61598,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:56:00","rainfall":2.0,"runoff":0.00730259,"infiltration_loss":0.6118,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:57:00","rainfall":2.0,"runoff":0.00730259,"infiltration_loss":0.6118,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:58:00","rainfall":2.0,"runoff":0.00730259,"infiltration_loss":0.6118,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:59:00","rainfall":2.0,"runoff":0.00730259,"infiltration_loss":0.6118,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:00:00","rainfall":2.0,"runoff":0.00730259,"infiltration_loss":0.6118,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:01:00","rainfall":4.0,"runoff":0.0111719,"infiltration_loss":0.607989,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:02:00","rainfall":4.0,"runoff":0.0111719,"infiltration_loss":0.607989,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:03:00","rainfall":4.0,"runoff":0.0111719,"infiltration_loss":0.607989,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:04:00","rainfall":4.0,"runoff":0.0111719,"infiltration_loss":0.607989,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:05:00","rainfall":4.0,"runoff":0.0111719,"infiltration_loss":0.607989,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:06:00","rainfall":4.0,"runoff":0.013203,"infiltration_loss":0.612578,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:07:00","rainfall":4.0,"runoff":0.013203,"infiltration_loss":0.612578,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:08:00","rainfall":4.0,"runoff":0.013203,"infiltration_loss":0.612578,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:09:00","rainfall":4.0,"runoff":0.013203,"infiltration_loss":0.612578,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:10:00","rainfall":4.0,"runoff":0.013203,"infiltration_loss":0.612578,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:11:00","rainfall":4.0,"runoff":0.0142119,"infiltration_loss":0.616707,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:12:00","rainfall":4.0,"runoff":0.0142119,"infiltration_loss":0.616707,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:13:00","rainfall":4.0,"runoff":0.0142119,"infiltration_loss":0.616707,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:14:00","rainfall":4.0,"runoff":0.0142119,"infiltration_loss":0.616707,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:15:00","rainfall":4.0,"runoff":0.0142119,"infiltration_loss":0.616707,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:16:00","rainfall":4.0,"runoff":0.0147639,"infiltration_loss":0.62042,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:17:00","rainfall":4.0,"runoff":0.0147639,"infiltration_loss":0.62042,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:18:00","rainfall":4.0,"runoff":0.0147639,"infiltration_loss":0.62042,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:19:00","rainfall":4.0,"runoff":0.0147639,"infiltration_loss":0.62042,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:20:00","rainfall":4.0,"runoff":0.0147639,"infiltration_loss":0.62042,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:21:00","rainfall":4.0,"runoff":0.0151317,"infiltration_loss":0.623757,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:22:00","rainfall":4.0,"runoff":0.0151317,"infiltration_loss":0.623757,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:23:00","rainfall":4.0,"runoff":0.0151317,"infiltration_loss":0.623757,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:24:00","rainfall":4.0,"runoff":0.0151317,"infiltration_loss":0.623757,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:25:00","rainfall":4.0,"runoff":0.0151317,"infiltration_loss":0.623757,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:26:00","rainfall":4.0,"runoff":0.0154324,"infiltration_loss":0.626751,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:27:00","rainfall":4.0,"runoff":0.0154324,"infiltration_loss":0.626751,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:28:00","rainfall":4.0,"runoff":0.0154324,"infiltration_loss":0.626751,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:29:00","rainfall":4.0,"runoff":0.0154324,"infiltration_loss":0.626751,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:30:00","rainfall":4.0,"runoff":0.0154324,"infiltration_loss":0.626751,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:31:00","rainfall":4.0,"runoff":0.0157136,"infiltration_loss":0.629433,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:32:00","rainfall":4.0,"runoff":0.0157136,"infiltration_loss":0.629433,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:33:00","rainfall":4.0,"runoff":0.0157136,"infiltration_loss":0.629433,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:34:00","rainfall":4.0,"runoff":0.0157136,"infiltration_loss":0.629433,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:35:00","rainfall":4.0,"runoff":0.0157136,"infiltration_loss":0.629433,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:36:00","rainfall":4.0,"runoff":0.0159941,"infiltration_loss":0.63183,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:37:00","rainfall":4.0,"runoff":0.0159941,"infiltration_loss":0.63183,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:38:00","rainfall":4.0,"runoff":0.0159941,"infiltration_loss":0.63183,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:39:00","rainfall":4.0,"runoff":0.0159941,"infiltration_loss":0.63183,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:40:00","rainfall":4.0,"runoff":0.0159941,"infiltration_loss":0.63183,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:41:00","rainfall":4.0,"runoff":0.0162811,"infiltration_loss":0.633968,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:42:00","rainfall":4.0,"runoff":0.0162811,"infiltration_loss":0.633968,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:43:00","rainfall":4.0,"runoff":0.0162811,"infiltration_loss":0.633968,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:44:00","rainfall":4.0,"runoff":0.0162811,"infiltration_loss":0.633968,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:45:00","rainfall":4.0,"runoff":0.0162811,"infiltration_loss":0.633968,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:46:00","rainfall":4.0,"runoff":0.016577,"infiltration_loss":0.635866,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:47:00","rainfall":4.0,"runoff":0.016577,"infiltration_loss":0.635866,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:48:00","rainfall":4.0,"runoff":0.016577,"infiltration_loss":0.635866,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:49:00","rainfall":4.0,"runoff":0.016577,"infiltration_loss":0.635866,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:50:00","rainfall":4.0,"runoff":0.016577,"infiltration_loss":0.635866,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:51:00","rainfall":4.0,"runoff":0.0168821,"infiltration_loss":0.637546,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:52:00","rainfall":4.0,"runoff":0.0168821,"infiltration_loss":0.637546,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:53:00","rainfall":4.0,"runoff":0.0168821,"infiltration_loss":0.637546,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:54:00","rainfall":4.0,"runoff":0.0168821,"infiltration_loss":0.637546,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:55:00","rainfall":4.0,"runoff":0.0168821,"infiltration_loss":0.637546,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:56:00","rainfall":4.0,"runoff":0.0171962,"infiltration_loss":0.639024,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:57:00","rainfall":4.0,"runoff":0.0171962,"infiltration_loss":0.639024,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:58:00","rainfall":4.0,"runoff":0.0171962,"infiltration_loss":0.639024,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:59:00","rainfall":4.0,"runoff":0.0171962,"infiltration_loss":0.639024,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:00:00","rainfall":4.0,"runoff":0.0171962,"infiltration_loss":0.639024,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:01:00","rainfall":4.0,"runoff":0.0175188,"infiltration_loss":0.640317,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:02:00","rainfall":4.0,"runoff":0.0175188,"infiltration_loss":0.640317,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:03:00","rainfall":4.0,"runoff":0.0175188,"infiltration_loss":0.640317,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:04:00","rainfall":4.0,"runoff":0.0175188,"infiltration_loss":0.640317,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:05:00","rainfall":4.0,"runoff":0.0175188,"infiltration_loss":0.640317,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:06:00","rainfall":4.0,"runoff":0.0178493,"infiltration_loss":0.641439,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:07:00","rainfall":4.0,"runoff":0.0178493,"infiltration_loss":0.641439,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:08:00","rainfall":4.0,"runoff":0.0178493,"infiltration_loss":0.641439,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:09:00","rainfall":4.0,"runoff":0.0178493,"infiltration_loss":0.641439,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:10:00","rainfall":4.0,"runoff":0.0178493,"infiltration_loss":0.641439,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:11:00","rainfall":4.0,"runoff":0.018187,"infiltration_loss":0.642402,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:12:00","rainfall":4.0,"runoff":0.018187,"infiltration_loss":0.642402,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:13:00","rainfall":4.0,"runoff":0.018187,"infiltration_loss":0.642402,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:14:00","rainfall":4.0,"runoff":0.018187,"infiltration_loss":0.642402,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:15:00","rainfall":4.0,"runoff":0.018187,"infiltration_loss":0.642402,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:16:00","rainfall":4.0,"runoff":0.0185313,"infiltration_loss":0.643218,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:17:00","rainfall":4.0,"runoff":0.0185313,"infiltration_loss":0.643218,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:18:00","rainfall":4.0,"runoff":0.0185313,"infiltration_loss":0.643218,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:19:00","rainfall":4.0,"runoff":0.0185313,"infiltration_loss":0.643218,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:20:00","rainfall":4.0,"runoff":0.0185313,"infiltration_loss":0.643218,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:21:00","rainfall":4.0,"runoff":0.0188816,"infiltration_loss":0.643899,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:22:00","rainfall":4.0,"runoff":0.0188816,"infiltration_loss":0.643899,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:23:00","rainfall":4.0,"runoff":0.0188816,"infiltration_loss":0.643899,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:24:00","rainfall":4.0,"runoff":0.0188816,"infiltration_loss":0.643899,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:25:00","rainfall":4.0,"runoff":0.0188816,"infiltration_loss":0.643899,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:26:00","rainfall":4.0,"runoff":0.0192373,"infiltration_loss":0.644454,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:27:00","rainfall":4.0,"runoff":0.0192373,"infiltration_loss":0.644454,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:28:00","rainfall":4.0,"runoff":0.0192373,"infiltration_loss":0.644454,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:29:00","rainfall":4.0,"runoff":0.0192373,"infiltration_loss":0.644454,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:30:00","rainfall":4.0,"runoff":0.0192373,"infiltration_loss":0.644454,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:31:00","rainfall":4.0,"runoff":0.0195979,"infiltration_loss":0.644891,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:32:00","rainfall":4.0,"runoff":0.0195979,"infiltration_loss":0.644891,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:33:00","rainfall":4.0,"runoff":0.0195979,"infiltration_loss":0.644891,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:34:00","rainfall":4.0,"runoff":0.0195979,"infiltration_loss":0.644891,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:35:00","rainfall":4.0,"runoff":0.0195979,"infiltration_loss":0.644891,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:36:00","rainfall":4.0,"runoff":0.0199627,"infiltration_loss":0.645219,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:37:00","rainfall":4.0,"runoff":0.0199627,"infiltration_loss":0.645219,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:38:00","rainfall":4.0,"runoff":0.0199627,"infiltration_loss":0.645219,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:39:00","rainfall":4.0,"runoff":0.0199627,"infiltration_loss":0.645219,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:40:00","rainfall":4.0,"runoff":0.0199627,"infiltration_loss":0.645219,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:41:00","rainfall":4.0,"runoff":0.0203313,"infiltration_loss":0.645445,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:42:00","rainfall":4.0,"runoff":0.0203313,"infiltration_loss":0.645445,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:43:00","rainfall":4.0,"runoff":0.0203313,"infiltration_loss":0.645445,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:44:00","rainfall":4.0,"runoff":0.0203313,"infiltration_loss":0.645445,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:45:00","rainfall":4.0,"runoff":0.0203313,"infiltration_loss":0.645445,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:46:00","rainfall":4.0,"runoff":0.0207032,"infiltration_loss":0.645577,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:47:00","rainfall":4.0,"runoff":0.0207032,"infiltration_loss":0.645577,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:48:00","rainfall":4.0,"runoff":0.0207032,"infiltration_loss":0.645577,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:49:00","rainfall":4.0,"runoff":0.0207032,"infiltration_loss":0.645577,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:50:00","rainfall":4.0,"runoff":0.0207032,"infiltration_loss":0.645577,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:51:00","rainfall":4.0,"runoff":0.0210778,"infiltration_loss":0.64562,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:52:00","rainfall":4.0,"runoff":0.0210778,"infiltration_loss":0.64562,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:53:00","rainfall":4.0,"runoff":0.0210778,"infiltration_loss":0.64562,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:54:00","rainfall":4.0,"runoff":0.0210778,"infiltration_loss":0.64562,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:55:00","rainfall":4.0,"runoff":0.0210778,"infiltration_loss":0.64562,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:56:00","rainfall":4.0,"runoff":0.0214548,"infiltration_loss":0.645581,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:57:00","rainfall":4.0,"runoff":0.0214548,"infiltration_loss":0.645581,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:58:00","rainfall":4.0,"runoff":0.0214548,"infiltration_loss":0.645581,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:59:00","rainfall":4.0,"runoff":0.0214548,"infiltration_loss":0.645581,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:00:00","rainfall":4.0,"runoff":0.0214548,"infiltration_loss":0.645581,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:01:00","rainfall":12.0,"runoff":0.0420187,"infiltration_loss":0.645464,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:02:00","rainfall":12.0,"runoff":0.0420187,"infiltration_loss":0.645464,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:03:00","rainfall":12.0,"runoff":0.0420187,"infiltration_loss":0.645464,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:04:00","rainfall":12.0,"runoff":0.0420187,"infiltration_loss":0.645464,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:05:00","rainfall":12.0,"runoff":0.0420187,"infiltration_loss":0.645464,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:06:00","rainfall":12.0,"runoff":0.0503878,"infiltration_loss":0.662373,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:07:00","rainfall":12.0,"runoff":0.0503878,"infiltration_loss":0.662373,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:08:00","rainfall":12.0,"runoff":0.0503878,"infiltration_loss":0.662373,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:09:00","rainfall":12.0,"runoff":0.0503878,"infiltration_loss":0.662373,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:10:00","rainfall":12.0,"runoff":0.0503878,"infiltration_loss":0.662373,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:11:00","rainfall":12.0,"runoff":0.0540043,"infiltration_loss":0.678073,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:12:00","rainfall":12.0,"runoff":0.0540043,"infiltration_loss":0.678073,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:13:00","rainfall":12.0,"runoff":0.0540043,"infiltration_loss":0.678073,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:14:00","rainfall":12.0,"runoff":0.0540043,"infiltration_loss":0.678073,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:15:00","rainfall":12.0,"runoff":0.0540043,"infiltration_loss":0.678073,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:16:00","rainfall":12.0,"runoff":0.0563839,"infiltration_loss":0.692607,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:17:00","rainfall":12.0,"runoff":0.0563839,"infiltration_loss":0.692607,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:18:00","rainfall":12.0,"runoff":0.0563839,"infiltration_loss":0.692607,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:19:00","rainfall":12.0,"runoff":0.0563839,"infiltration_loss":0.692607,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:20:00","rainfall":12.0,"runoff":0.0563839,"infiltration_loss":0.692607,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:21:00","rainfall":12.0,"runoff":0.0584983,"infiltration_loss":0.706023,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:22:00","rainfall":12.0,"runoff":0.0584983,"infiltration_loss":0.706023,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:23:00","rainfall":12.0,"runoff":0.0584983,"infiltration_loss":0.706023,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:24:00","rainfall":12.0,"runoff":0.0584983,"infiltration_loss":0.706023,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:25:00","rainfall":12.0,"runoff":0.0584983,"infiltration_loss":0.706023,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:26:00","rainfall":12.0,"runoff":0.0605849,"infiltration_loss":0.718369,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:27:00","rainfall":12.0,"runoff":0.0605849,"infiltration_loss":0.718369,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:28:00","rainfall":12.0,"runoff":0.0605849,"infiltration_loss":0.718369,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:29:00","rainfall":12.0,"runoff":0.0605849,"infiltration_loss":0.718369,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:30:00","rainfall":12.0,"runoff":0.0605849,"infiltration_loss":0.718369,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:31:00","rainfall":12.0,"runoff":0.0626951,"infiltration_loss":0.729699,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:32:00","rainfall":12.0,"runoff":0.0626951,"infiltration_loss":0.729699,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:33:00","rainfall":12.0,"runoff":0.0626951,"infiltration_loss":0.729699,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:34:00","rainfall":12.0,"runoff":0.0626951,"infiltration_loss":0.729699,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:35:00","rainfall":12.0,"runoff":0.0626951,"infiltration_loss":0.729699,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:36:00","rainfall":12.0,"runoff":0.0648349,"infiltration_loss":0.740064,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:37:00","rainfall":12.0,"runoff":0.0648349,"infiltration_loss":0.740064,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:38:00","rainfall":12.0,"runoff":0.0648349,"infiltration_loss":0.740064,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:39:00","rainfall":12.0,"runoff":0.0648349,"infiltration_loss":0.740064,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:40:00","rainfall":12.0,"runoff":0.0648349,"infiltration_loss":0.740064,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:41:00","rainfall":12.0,"runoff":0.0669999,"infiltration_loss":0.749515,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:42:00","rainfall":12.0,"runoff":0.0669999,"infiltration_loss":0.749515,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:43:00","rainfall":12.0,"runoff":0.0669999,"infiltration_loss":0.749515,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:44:00","rainfall":12.0,"runoff":0.0669999,"infiltration_loss":0.749515,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:45:00","rainfall":12.0,"runoff":0.0669999,"infiltration_loss":0.749515,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:46:00","rainfall":12.0,"runoff":0.0691834,"infiltration_loss":0.758105,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:47:00","rainfall":12.0,"runoff":0.0691834,"infiltration_loss":0.758105,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:48:00","rainfall":12.0,"runoff":0.0691834,"infiltration_loss":0.758105,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:49:00","rainfall":12.0,"runoff":0.0691834,"infiltration_loss":0.758105,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:50:00","rainfall":12.0,"runoff":0.0691834,"infiltration_loss":0.758105,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:51:00","rainfall":12.0,"runoff":0.0713788,"infiltration_loss":0.765882,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:52:00","rainfall":12.0,"runoff":0.0713788,"infiltration_loss":0.765882,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:53:00","rainfall":12.0,"runoff":0.0713788,"infiltration_loss":0.765882,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:54:00","rainfall":12.0,"runoff":0.0713788,"infiltration_loss":0.765882,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:55:00","rainfall":12.0,"runoff":0.0713788,"infiltration_loss":0.765882,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:56:00","rainfall":12.0,"runoff":0.0735796,"infiltration_loss":0.772897,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:57:00","rainfall":12.0,"runoff":0.0735796,"infiltration_loss":0.772897,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:58:00","rainfall":12.0,"runoff":0.0735796,"infiltration_loss":0.772897,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:59:00","rainfall":12.0,"runoff":0.0735796,"infiltration_loss":0.772897,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:00:00","rainfall":12.0,"runoff":0.0735796,"infiltration_loss":0.772897,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:01:00","rainfall":13.0,"runoff":0.078709,"infiltration_loss":0.779196,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:02:00","rainfall":13.0,"runoff":0.078709,"infiltration_loss":0.779196,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:03:00","rainfall":13.0,"runoff":0.078709,"infiltration_loss":0.779196,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:04:00","rainfall":13.0,"runoff":0.078709,"infiltration_loss":0.779196,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:05:00","rainfall":13.0,"runoff":0.078709,"infiltration_loss":0.779196,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:06:00","rainfall":13.0,"runoff":0.0818254,"infiltration_loss":0.786499,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:07:00","rainfall":13.0,"runoff":0.0818254,"infiltration_loss":0.786499,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:08:00","rainfall":13.0,"runoff":0.0818254,"infiltration_loss":0.786499,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:09:00","rainfall":13.0,"runoff":0.0818254,"infiltration_loss":0.786499,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:10:00","rainfall":13.0,"runoff":0.0818254,"infiltration_loss":0.786499,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:11:00","rainfall":13.0,"runoff":0.0844515,"infiltration_loss":0.793046,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:12:00","rainfall":13.0,"runoff":0.0844515,"infiltration_loss":0.793046,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:13:00","rainfall":13.0,"runoff":0.0844515,"infiltration_loss":0.793046,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:14:00","rainfall":13.0,"runoff":0.0844515,"infiltration_loss":0.793046,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:15:00","rainfall":13.0,"runoff":0.0844515,"infiltration_loss":0.793046,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:16:00","rainfall":13.0,"runoff":0.0869517,"infiltration_loss":0.798886,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:17:00","rainfall":13.0,"runoff":0.0869517,"infiltration_loss":0.798886,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:18:00","rainfall":13.0,"runoff":0.0869517,"infiltration_loss":0.798886,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:19:00","rainfall":13.0,"runoff":0.0869517,"infiltration_loss":0.798886,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:20:00","rainfall":13.0,"runoff":0.0869517,"infiltration_loss":0.798886,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:21:00","rainfall":13.0,"runoff":0.0894078,"infiltration_loss":0.804062,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:22:00","rainfall":13.0,"runoff":0.0894078,"infiltration_loss":0.804062,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:23:00","rainfall":13.0,"runoff":0.0894078,"infiltration_loss":0.804062,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:24:00","rainfall":13.0,"runoff":0.0894078,"infiltration_loss":0.804062,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:25:00","rainfall":13.0,"runoff":0.0894078,"infiltration_loss":0.804062,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:26:00","rainfall":13.0,"runoff":0.0918353,"infiltration_loss":0.80862,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:27:00","rainfall":13.0,"runoff":0.0918353,"infiltration_loss":0.80862,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:28:00","rainfall":13.0,"runoff":0.0918353,"infiltration_loss":0.80862,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:29:00","rainfall":13.0,"runoff":0.0918353,"infiltration_loss":0.80862,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:30:00","rainfall":13.0,"runoff":0.0918353,"infiltration_loss":0.80862,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:31:00","rainfall":13.0,"runoff":0.0942343,"infiltration_loss":0.8126,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:32:00","rainfall":13.0,"runoff":0.0942343,"infiltration_loss":0.8126,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:33:00","rainfall":13.0,"runoff":0.0942343,"infiltration_loss":0.8126,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:34:00","rainfall":13.0,"runoff":0.0942343,"infiltration_loss":0.8126,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:35:00","rainfall":13.0,"runoff":0.0942343,"infiltration_loss":0.8126,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:36:00","rainfall":13.0,"runoff":0.0966021,"infiltration_loss":0.816043,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:37:00","rainfall":13.0,"runoff":0.0966021,"infiltration_loss":0.816043,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:38:00","rainfall":13.0,"runoff":0.0966021,"infiltration_loss":0.816043,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:39:00","rainfall":13.0,"runoff":0.0966021,"infiltration_loss":0.816043,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:40:00","rainfall":13.0,"runoff":0.0966021,"infiltration_loss":0.816043,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:41:00","rainfall":13.0,"runoff":0.0989353,"infiltration_loss":0.818985,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:42:00","rainfall":13.0,"runoff":0.0989353,"infiltration_loss":0.818985,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:43:00","rainfall":13.0,"runoff":0.0989353,"infiltration_loss":0.818985,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:44:00","rainfall":13.0,"runoff":0.0989353,"infiltration_loss":0.818985,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:45:00","rainfall":13.0,"runoff":0.0989353,"infiltration_loss":0.818985,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:46:00","rainfall":13.0,"runoff":0.101231,"infiltration_loss":0.821461,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:47:00","rainfall":13.0,"runoff":0.101231,"infiltration_loss":0.821461,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:48:00","rainfall":13.0,"runoff":0.101231,"infiltration_loss":0.821461,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:49:00","rainfall":13.0,"runoff":0.101231,"infiltration_loss":0.821461,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:50:00","rainfall":13.0,"runoff":0.101231,"infiltration_loss":0.821461,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:51:00","rainfall":13.0,"runoff":0.103486,"infiltration_loss":0.823506,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:52:00","rainfall":13.0,"runoff":0.103486,"infiltration_loss":0.823506,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:53:00","rainfall":13.0,"runoff":0.103486,"infiltration_loss":0.823506,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:54:00","rainfall":13.0,"runoff":0.103486,"infiltration_loss":0.823506,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:55:00","rainfall":13.0,"runoff":0.103486,"infiltration_loss":0.823506,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:56:00","rainfall":13.0,"runoff":0.105699,"infiltration_loss":0.82515,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:57:00","rainfall":13.0,"runoff":0.105699,"infiltration_loss":0.82515,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:58:00","rainfall":13.0,"runoff":0.105699,"infiltration_loss":0.82515,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:59:00","rainfall":13.0,"runoff":0.105699,"infiltration_loss":0.82515,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:00:00","rainfall":13.0,"runoff":0.105699,"infiltration_loss":0.82515,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:01:00","rainfall":11.0,"runoff":0.10189,"infiltration_loss":0.826422,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:02:00","rainfall":11.0,"runoff":0.10189,"infiltration_loss":0.826422,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:03:00","rainfall":11.0,"runoff":0.10189,"infiltration_loss":0.826422,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:04:00","rainfall":11.0,"runoff":0.10189,"infiltration_loss":0.826422,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:05:00","rainfall":11.0,"runoff":0.10189,"infiltration_loss":0.826422,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:06:00","rainfall":11.0,"runoff":0.102048,"infiltration_loss":0.824647,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:07:00","rainfall":11.0,"runoff":0.102048,"infiltration_loss":0.824647,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:08:00","rainfall":11.0,"runoff":0.102048,"infiltration_loss":0.824647,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:09:00","rainfall":11.0,"runoff":0.102048,"infiltration_loss":0.824647,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:10:00","rainfall":11.0,"runoff":0.102048,"infiltration_loss":0.824647,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:11:00","rainfall":11.0,"runoff":0.103112,"infiltration_loss":0.822762,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:12:00","rainfall":11.0,"runoff":0.103112,"infiltration_loss":0.822762,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:13:00","rainfall":11.0,"runoff":0.103112,"infiltration_loss":0.822762,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:14:00","rainfall":11.0,"runoff":0.103112,"infiltration_loss":0.822762,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:15:00","rainfall":11.0,"runoff":0.103112,"infiltration_loss":0.822762,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:16:00","rainfall":11.0,"runoff":0.104376,"infiltration_loss":0.820775,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:17:00","rainfall":11.0,"runoff":0.104376,"infiltration_loss":0.820775,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:18:00","rainfall":11.0,"runoff":0.104376,"infiltration_loss":0.820775,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:19:00","rainfall":11.0,"runoff":0.104376,"infiltration_loss":0.820775,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:20:00","rainfall":11.0,"runoff":0.104376,"infiltration_loss":0.820775,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:21:00","rainfall":11.0,"runoff":0.105664,"infiltration_loss":0.818698,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:22:00","rainfall":11.0,"runoff":0.105664,"infiltration_loss":0.818698,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:23:00","rainfall":11.0,"runoff":0.105664,"infiltration_loss":0.818698,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:24:00","rainfall":11.0,"runoff":0.105664,"infiltration_loss":0.818698,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:25:00","rainfall":11.0,"runoff":0.105664,"infiltration_loss":0.818698,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:26:00","rainfall":11.0,"runoff":0.106932,"infiltration_loss":0.816539,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:27:00","rainfall":11.0,"runoff":0.106932,"infiltration_loss":0.816539,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:28:00","rainfall":11.0,"runoff":0.106932,"infiltration_loss":0.816539,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:29:00","rainfall":11.0,"runoff":0.106932,"infiltration_loss":0.816539,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:30:00","rainfall":11.0,"runoff":0.106932,"infiltration_loss":0.816539,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:31:00","rainfall":11.0,"runoff":0.108168,"infiltration_loss":0.814308,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:32:00","rainfall":11.0,"runoff":0.108168,"infiltration_loss":0.814308,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:33:00","rainfall":11.0,"runoff":0.108168,"infiltration_loss":0.814308,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:34:00","rainfall":11.0,"runoff":0.108168,"infiltration_loss":0.814308,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:35:00","rainfall":11.0,"runoff":0.108168,"infiltration_loss":0.814308,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:36:00","rainfall":11.0,"runoff":0.109371,"infiltration_loss":0.812011,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:37:00","rainfall":11.0,"runoff":0.109371,"infiltration_loss":0.812011,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:38:00","rainfall":11.0,"runoff":0.109371,"infiltration_loss":0.812011,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:39:00","rainfall":11.0,"runoff":0.109371,"infiltration_loss":0.812011,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:40:00","rainfall":11.0,"runoff":0.109371,"infiltration_loss":0.812011,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:41:00","rainfall":11.0,"runoff":0.110539,"infiltration_loss":0.809657,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:42:00","rainfall":11.0,"runoff":0.110539,"infiltration_loss":0.809657,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:43:00","rainfall":11.0,"runoff":0.110539,"infiltration_loss":0.809657,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:44:00","rainfall":11.0,"runoff":0.110539,"infiltration_loss":0.809657,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:45:00","rainfall":11.0,"runoff":0.110539,"infiltration_loss":0.809657,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:46:00","rainfall":11.0,"runoff":0.111673,"infiltration_loss":0.807252,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:47:00","rainfall":11.0,"runoff":0.111673,"infiltration_loss":0.807252,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:48:00","rainfall":11.0,"runoff":0.111673,"infiltration_loss":0.807252,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:49:00","rainfall":11.0,"runoff":0.111673,"infiltration_loss":0.807252,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:50:00","rainfall":11.0,"runoff":0.111673,"infiltration_loss":0.807252,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:51:00","rainfall":11.0,"runoff":0.112773,"infiltration_loss":0.804802,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:52:00","rainfall":11.0,"runoff":0.112773,"infiltration_loss":0.804802,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:53:00","rainfall":11.0,"runoff":0.112773,"infiltration_loss":0.804802,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:54:00","rainfall":11.0,"runoff":0.112773,"infiltration_loss":0.804802,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:55:00","rainfall":11.0,"runoff":0.112773,"infiltration_loss":0.804802,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:56:00","rainfall":11.0,"runoff":0.113841,"infiltration_loss":0.802313,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:57:00","rainfall":11.0,"runoff":0.113841,"infiltration_loss":0.802313,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:58:00","rainfall":11.0,"runoff":0.113841,"infiltration_loss":0.802313,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:59:00","rainfall":11.0,"runoff":0.113841,"infiltration_loss":0.802313,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:00:00","rainfall":11.0,"runoff":0.113841,"infiltration_loss":0.802313,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:01:00","rainfall":20.0,"runoff":0.143065,"infiltration_loss":0.799791,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:02:00","rainfall":20.0,"runoff":0.143065,"infiltration_loss":0.799791,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:03:00","rainfall":20.0,"runoff":0.143065,"infiltration_loss":0.799791,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:04:00","rainfall":20.0,"runoff":0.143065,"infiltration_loss":0.799791,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:05:00","rainfall":20.0,"runoff":0.143065,"infiltration_loss":0.799791,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:06:00","rainfall":20.0,"runoff":0.152937,"infiltration_loss":0.807438,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:07:00","rainfall":20.0,"runoff":0.152937,"infiltration_loss":0.807438,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:08:00","rainfall":20.0,"runoff":0.152937,"infiltration_loss":0.807438,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:09:00","rainfall":20.0,"runoff":0.152937,"infiltration_loss":0.807438,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:10:00","rainfall":20.0,"runoff":0.152937,"infiltration_loss":0.807438,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:11:00","rainfall":20.0,"runoff":0.158318,"infiltration_loss":0.814325,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:12:00","rainfall":20.0,"runoff":0.158318,"infiltration_loss":0.814325,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:13:00","rainfall":20.0,"runoff":0.158318,"infiltration_loss":0.814325,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:14:00","rainfall":20.0,"runoff":0.158318,"infiltration_loss":0.814325,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:15:00","rainfall":20.0,"runoff":0.158318,"infiltration_loss":0.814325,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:16:00","rainfall":20.0,"runoff":0.162799,"infiltration_loss":0.820496,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:17:00","rainfall":20.0,"runoff":0.162799,"infiltration_loss":0.820496,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:18:00","rainfall":20.0,"runoff":0.162799,"infiltration_loss":0.820496,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:19:00","rainfall":20.0,"runoff":0.162799,"infiltration_loss":0.820496,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:20:00","rainfall":20.0,"runoff":0.162799,"infiltration_loss":0.820496,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:21:00","rainfall":20.0,"runoff":0.167034,"infiltration_loss":0.825991,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:22:00","rainfall":20.0,"runoff":0.167034,"infiltration_loss":0.825991,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:23:00","rainfall":20.0,"runoff":0.167034,"infiltration_loss":0.825991,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:24:00","rainfall":20.0,"runoff":0.167034,"infiltration_loss":0.825991,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:25:00","rainfall":20.0,"runoff":0.167034,"infiltration_loss":0.825991,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:26:00","rainfall":20.0,"runoff":0.171138,"infiltration_loss":0.830851,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:27:00","rainfall":20.0,"runoff":0.171138,"infiltration_loss":0.830851,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:28:00","rainfall":20.0,"runoff":0.171138,"infiltration_loss":0.830851,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:29:00","rainfall":20.0,"runoff":0.171138,"infiltration_loss":0.830851,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:30:00","rainfall":20.0,"runoff":0.171138,"infiltration_loss":0.830851,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:31:00","rainfall":20.0,"runoff":0.175126,"infiltration_loss":0.835114,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:32:00","rainfall":20.0,"runoff":0.175126,"infiltration_loss":0.835114,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:33:00","rainfall":20.0,"runoff":0.175126,"infiltration_loss":0.835114,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:34:00","rainfall":20.0,"runoff":0.175126,"infiltration_loss":0.835114,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:35:00","rainfall":20.0,"runoff":0.175126,"infiltration_loss":0.835114,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:36:00","rainfall":20.0,"runoff":0.179,"infiltration_loss":0.83882,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:37:00","rainfall":20.0,"runoff":0.179,"infiltration_loss":0.83882,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:38:00","rainfall":20.0,"runoff":0.179,"infiltration_loss":0.83882,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:39:00","rainfall":20.0,"runoff":0.179,"infiltration_loss":0.83882,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:40:00","rainfall":20.0,"runoff":0.179,"infiltration_loss":0.83882,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:41:00","rainfall":20.0,"runoff":0.182759,"infiltration_loss":0.842003,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:42:00","rainfall":20.0,"runoff":0.182759,"infiltration_loss":0.842003,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:43:00","rainfall":20.0,"runoff":0.182759,"infiltration_loss":0.842003,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:44:00","rainfall":20.0,"runoff":0.182759,"infiltration_loss":0.842003,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:45:00","rainfall":20.0,"runoff":0.182759,"infiltration_loss":0.842003,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:46:00","rainfall":20.0,"runoff":0.186399,"infiltration_loss":0.844699,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:47:00","rainfall":20.0,"runoff":0.186399,"infiltration_loss":0.844699,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:48:00","rainfall":20.0,"runoff":0.186399,"infiltration_loss":0.844699,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:49:00","rainfall":20.0,"runoff":0.186399,"infiltration_loss":0.844699,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:50:00","rainfall":20.0,"runoff":0.186399,"infiltration_loss":0.844699,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:51:00","rainfall":20.0,"runoff":0.189922,"infiltration_loss":0.846941,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:52:00","rainfall":20.0,"runoff":0.189922,"infiltration_loss":0.846941,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:53:00","rainfall":20.0,"runoff":0.189922,"infiltration_loss":0.846941,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:54:00","rainfall":20.0,"runoff":0.189922,"infiltration_loss":0.846941,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:55:00","rainfall":20.0,"runoff":0.189922,"infiltration_loss":0.846941,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:56:00","rainfall":20.0,"runoff":0.193325,"infiltration_loss":0.84876,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:57:00","rainfall":20.0,"runoff":0.193325,"infiltration_loss":0.84876,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:58:00","rainfall":20.0,"runoff":0.193325,"infiltration_loss":0.84876,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:59:00","rainfall":20.0,"runoff":0.193325,"infiltration_loss":0.84876,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:00:00","rainfall":20.0,"runoff":0.193325,"infiltration_loss":0.84876,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:01:00","rainfall":15.0,"runoff":0.180251,"infiltration_loss":0.850187,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:02:00","rainfall":15.0,"runoff":0.180251,"infiltration_loss":0.850187,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:03:00","rainfall":15.0,"runoff":0.180251,"infiltration_loss":0.850187,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:04:00","rainfall":15.0,"runoff":0.180251,"infiltration_loss":0.850187,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:05:00","rainfall":15.0,"runoff":0.180251,"infiltration_loss":0.850187,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:06:00","rainfall":15.0,"runoff":0.178481,"infiltration_loss":0.846404,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:07:00","rainfall":15.0,"runoff":0.178481,"infiltration_loss":0.846404,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:08:00","rainfall":15.0,"runoff":0.178481,"infiltration_loss":0.846404,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:09:00","rainfall":15.0,"runoff":0.178481,"infiltration_loss":0.846404,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:10:00","rainfall":15.0,"runoff":0.178481,"infiltration_loss":0.846404,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:11:00","rainfall":15.0,"runoff":0.178784,"infiltration_loss":0.842654,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:12:00","rainfall":15.0,"runoff":0.178784,"infiltration_loss":0.842654,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:13:00","rainfall":15.0,"runoff":0.178784,"infiltration_loss":0.842654,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:14:00","rainfall":15.0,"runoff":0.178784,"infiltration_loss":0.842654,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:15:00","rainfall":15.0,"runoff":0.178784,"infiltration_loss":0.842654,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:16:00","rainfall":15.0,"runoff":0.179486,"infiltration_loss":0.838939,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:17:00","rainfall":15.0,"runoff":0.179486,"infiltration_loss":0.838939,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:18:00","rainfall":15.0,"runoff":0.179486,"infiltration_loss":0.838939,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:19:00","rainfall":15.0,"runoff":0.179486,"infiltration_loss":0.838939,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:20:00","rainfall":15.0,"runoff":0.179486,"infiltration_loss":0.838939,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:21:00","rainfall":15.0,"runoff":0.180248,"infiltration_loss":0.835261,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:22:00","rainfall":15.0,"runoff":0.180248,"infiltration_loss":0.835261,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:23:00","rainfall":15.0,"runoff":0.180248,"infiltration_loss":0.835261,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:24:00","rainfall":15.0,"runoff":0.180248,"infiltration_loss":0.835261,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:25:00","rainfall":15.0,"runoff":0.180248,"infiltration_loss":0.835261,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:26:00","rainfall":15.0,"runoff":0.180998,"infiltration_loss":0.83162,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:27:00","rainfall":15.0,"runoff":0.180998,"infiltration_loss":0.83162,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:28:00","rainfall":15.0,"runoff":0.180998,"infiltration_loss":0.83162,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:29:00","rainfall":15.0,"runoff":0.180998,"infiltration_loss":0.83162,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:30:00","rainfall":15.0,"runoff":0.180998,"infiltration_loss":0.83162,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:31:00","rainfall":15.0,"runoff":0.181722,"infiltration_loss":0.828018,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:32:00","rainfall":15.0,"runoff":0.181722,"infiltration_loss":0.828018,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:33:00","rainfall":15.0,"runoff":0.181722,"infiltration_loss":0.828018,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:34:00","rainfall":15.0,"runoff":0.181722,"infiltration_loss":0.828018,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:35:00","rainfall":15.0,"runoff":0.181722,"infiltration_loss":0.828018,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:36:00","rainfall":15.0,"runoff":0.182418,"infiltration_loss":0.824454,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:37:00","rainfall":15.0,"runoff":0.182418,"infiltration_loss":0.824454,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:38:00","rainfall":15.0,"runoff":0.182418,"infiltration_loss":0.824454,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:39:00","rainfall":15.0,"runoff":0.182418,"infiltration_loss":0.824454,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:40:00","rainfall":15.0,"runoff":0.182418,"infiltration_loss":0.824454,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:41:00","rainfall":15.0,"runoff":0.183085,"infiltration_loss":0.82093,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:42:00","rainfall":15.0,"runoff":0.183085,"infiltration_loss":0.82093,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:43:00","rainfall":15.0,"runoff":0.183085,"infiltration_loss":0.82093,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:44:00","rainfall":15.0,"runoff":0.183085,"infiltration_loss":0.82093,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:45:00","rainfall":15.0,"runoff":0.183085,"infiltration_loss":0.82093,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:46:00","rainfall":15.0,"runoff":0.183725,"infiltration_loss":0.817445,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:47:00","rainfall":15.0,"runoff":0.183725,"infiltration_loss":0.817445,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:48:00","rainfall":15.0,"runoff":0.183725,"infiltration_loss":0.817445,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:49:00","rainfall":15.0,"runoff":0.183725,"infiltration_loss":0.817445,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:50:00","rainfall":15.0,"runoff":0.183725,"infiltration_loss":0.817445,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:51:00","rainfall":15.0,"runoff":0.184339,"infiltration_loss":0.814001,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:52:00","rainfall":15.0,"runoff":0.184339,"infiltration_loss":0.814001,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:53:00","rainfall":15.0,"runoff":0.184339,"infiltration_loss":0.814001,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:54:00","rainfall":15.0,"runoff":0.184339,"infiltration_loss":0.814001,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:55:00","rainfall":15.0,"runoff":0.184339,"infiltration_loss":0.814001,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:56:00","rainfall":15.0,"runoff":0.184928,"infiltration_loss":0.810598,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:57:00","rainfall":15.0,"runoff":0.184928,"infiltration_loss":0.810598,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:58:00","rainfall":15.0,"runoff":0.184928,"infiltration_loss":0.810598,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:59:00","rainfall":15.0,"runoff":0.184928,"infiltration_loss":0.810598,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:00:00","rainfall":15.0,"runoff":0.184928,"infiltration_loss":0.810598,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:01:00","rainfall":10.0,"runoff":0.169834,"infiltration_loss":0.807236,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:02:00","rainfall":10.0,"runoff":0.169834,"infiltration_loss":0.807236,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:03:00","rainfall":10.0,"runoff":0.169834,"infiltration_loss":0.807236,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:04:00","rainfall":10.0,"runoff":0.169834,"infiltration_loss":0.807236,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:05:00","rainfall":10.0,"runoff":0.169834,"infiltration_loss":0.807236,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:06:00","rainfall":10.0,"runoff":0.165055,"infiltration_loss":0.799665,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:07:00","rainfall":10.0,"runoff":0.165055,"infiltration_loss":0.799665,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:08:00","rainfall":10.0,"runoff":0.165055,"infiltration_loss":0.799665,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:09:00","rainfall":10.0,"runoff":0.165055,"infiltration_loss":0.799665,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:10:00","rainfall":10.0,"runoff":0.165055,"infiltration_loss":0.799665,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:11:00","rainfall":10.0,"runoff":0.162627,"infiltration_loss":0.792439,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:12:00","rainfall":10.0,"runoff":0.162627,"infiltration_loss":0.792439,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:13:00","rainfall":10.0,"runoff":0.162627,"infiltration_loss":0.792439,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:14:00","rainfall":10.0,"runoff":0.162627,"infiltration_loss":0.792439,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:15:00","rainfall":10.0,"runoff":0.162627,"infiltration_loss":0.792439,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:16:00","rainfall":10.0,"runoff":0.16086,"infiltration_loss":0.785536,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:17:00","rainfall":10.0,"runoff":0.16086,"infiltration_loss":0.785536,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:18:00","rainfall":10.0,"runoff":0.16086,"infiltration_loss":0.785536,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:19:00","rainfall":10.0,"runoff":0.16086,"infiltration_loss":0.785536,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:20:00","rainfall":10.0,"runoff":0.16086,"infiltration_loss":0.785536,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:21:00","rainfall":10.0,"runoff":0.159328,"infiltration_loss":0.778936,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:22:00","rainfall":10.0,"runoff":0.159328,"infiltration_loss":0.778936,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:23:00","rainfall":10.0,"runoff":0.159328,"infiltration_loss":0.778936,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:24:00","rainfall":10.0,"runoff":0.159328,"infiltration_loss":0.778936,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:25:00","rainfall":10.0,"runoff":0.159328,"infiltration_loss":0.778936,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:26:00","rainfall":10.0,"runoff":0.157913,"infiltration_loss":0.772621,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:27:00","rainfall":10.0,"runoff":0.157913,"infiltration_loss":0.772621,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:28:00","rainfall":10.0,"runoff":0.157913,"infiltration_loss":0.772621,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:29:00","rainfall":10.0,"runoff":0.157913,"infiltration_loss":0.772621,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:30:00","rainfall":10.0,"runoff":0.157913,"infiltration_loss":0.772621,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:31:00","rainfall":10.0,"runoff":0.156581,"infiltration_loss":0.766571,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:32:00","rainfall":10.0,"runoff":0.156581,"infiltration_loss":0.766571,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:33:00","rainfall":10.0,"runoff":0.156581,"infiltration_loss":0.766571,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:34:00","rainfall":10.0,"runoff":0.156581,"infiltration_loss":0.766571,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:35:00","rainfall":10.0,"runoff":0.156581,"infiltration_loss":0.766571,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:36:00","rainfall":10.0,"runoff":0.155321,"infiltration_loss":0.760773,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:37:00","rainfall":10.0,"runoff":0.155321,"infiltration_loss":0.760773,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:38:00","rainfall":10.0,"runoff":0.155321,"infiltration_loss":0.760773,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:39:00","rainfall":10.0,"runoff":0.155321,"infiltration_loss":0.760773,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:40:00","rainfall":10.0,"runoff":0.155321,"infiltration_loss":0.760773,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:41:00","rainfall":10.0,"runoff":0.154126,"infiltration_loss":0.755211,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:42:00","rainfall":10.0,"runoff":0.154126,"infiltration_loss":0.755211,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:43:00","rainfall":10.0,"runoff":0.154126,"infiltration_loss":0.755211,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:44:00","rainfall":10.0,"runoff":0.154126,"infiltration_loss":0.755211,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:45:00","rainfall":10.0,"runoff":0.154126,"infiltration_loss":0.755211,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:46:00","rainfall":10.0,"runoff":0.152992,"infiltration_loss":0.749871,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:47:00","rainfall":10.0,"runoff":0.152992,"infiltration_loss":0.749871,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:48:00","rainfall":10.0,"runoff":0.152992,"infiltration_loss":0.749871,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:49:00","rainfall":10.0,"runoff":0.152992,"infiltration_loss":0.749871,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:50:00","rainfall":10.0,"runoff":0.152992,"infiltration_loss":0.749871,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:51:00","rainfall":10.0,"runoff":0.151915,"infiltration_loss":0.744741,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:52:00","rainfall":10.0,"runoff":0.151915,"infiltration_loss":0.744741,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:53:00","rainfall":10.0,"runoff":0.151915,"infiltration_loss":0.744741,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:54:00","rainfall":10.0,"runoff":0.151915,"infiltration_loss":0.744741,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:55:00","rainfall":10.0,"runoff":0.151915,"infiltration_loss":0.744741,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:56:00","rainfall":10.0,"runoff":0.150892,"infiltration_loss":0.739809,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:57:00","rainfall":10.0,"runoff":0.150892,"infiltration_loss":0.739809,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:58:00","rainfall":10.0,"runoff":0.150892,"infiltration_loss":0.739809,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:59:00","rainfall":10.0,"runoff":0.150892,"infiltration_loss":0.739809,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:00:00","rainfall":10.0,"runoff":0.150892,"infiltration_loss":0.739809,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:01:00","rainfall":5.0,"runoff":0.135556,"infiltration_loss":0.735063,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:02:00","rainfall":5.0,"runoff":0.135556,"infiltration_loss":0.735063,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:03:00","rainfall":5.0,"runoff":0.135556,"infiltration_loss":0.735063,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:04:00","rainfall":5.0,"runoff":0.135556,"infiltration_loss":0.735063,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:05:00","rainfall":5.0,"runoff":0.135556,"infiltration_loss":0.735063,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:06:00","rainfall":5.0,"runoff":0.129043,"infiltration_loss":0.726673,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:07:00","rainfall":5.0,"runoff":0.129043,"infiltration_loss":0.726673,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:08:00","rainfall":5.0,"runoff":0.129043,"infiltration_loss":0.726673,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:09:00","rainfall":5.0,"runoff":0.129043,"infiltration_loss":0.726673,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:10:00","rainfall":5.0,"runoff":0.129043,"infiltration_loss":0.726673,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:11:00","rainfall":5.0,"runoff":0.12499,"infiltration_loss":0.718693,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:12:00","rainfall":5.0,"runoff":0.12499,"infiltration_loss":0.718693,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:13:00","rainfall":5.0,"runoff":0.12499,"infiltration_loss":0.718693,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:14:00","rainfall":5.0,"runoff":0.12499,"infiltration_loss":0.718693,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:15:00","rainfall":5.0,"runoff":0.12499,"infiltration_loss":0.718693,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:16:00","rainfall":5.0,"runoff":0.121841,"infiltration_loss":0.711098,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:17:00","rainfall":5.0,"runoff":0.121841,"infiltration_loss":0.711098,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:18:00","rainfall":5.0,"runoff":0.121841,"infiltration_loss":0.711098,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:19:00","rainfall":5.0,"runoff":0.121841,"infiltration_loss":0.711098,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:20:00","rainfall":5.0,"runoff":0.121841,"infiltration_loss":0.711098,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:21:00","rainfall":5.0,"runoff":0.1191,"infiltration_loss":0.703862,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:22:00","rainfall":5.0,"runoff":0.1191,"infiltration_loss":0.703862,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:23:00","rainfall":5.0,"runoff":0.1191,"infiltration_loss":0.703862,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:24:00","rainfall":5.0,"runoff":0.1191,"infiltration_loss":0.703862,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:25:00","rainfall":5.0,"runoff":0.1191,"infiltration_loss":0.703862,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:26:00","rainfall":5.0,"runoff":0.116591,"infiltration_loss":0.696962,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:27:00","rainfall":5.0,"runoff":0.116591,"infiltration_loss":0.696962,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:28:00","rainfall":5.0,"runoff":0.116591,"infiltration_loss":0.696962,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:29:00","rainfall":5.0,"runoff":0.116591,"infiltration_loss":0.696962,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:30:00","rainfall":5.0,"runoff":0.116591,"infiltration_loss":0.696962,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:31:00","rainfall":5.0,"runoff":0.114244,"infiltration_loss":0.690378,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:32:00","rainfall":5.0,"runoff":0.114244,"infiltration_loss":0.690378,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:33:00","rainfall":5.0,"runoff":0.114244,"infiltration_loss":0.690378,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:34:00","rainfall":5.0,"runoff":0.114244,"infiltration_loss":0.690378,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:35:00","rainfall":5.0,"runoff":0.114244,"infiltration_loss":0.690378,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:36:00","rainfall":5.0,"runoff":0.11203,"infiltration_loss":0.684091,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:37:00","rainfall":5.0,"runoff":0.11203,"infiltration_loss":0.684091,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:38:00","rainfall":5.0,"runoff":0.11203,"infiltration_loss":0.684091,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:39:00","rainfall":5.0,"runoff":0.11203,"infiltration_loss":0.684091,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:40:00","rainfall":5.0,"runoff":0.11203,"infiltration_loss":0.684091,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:41:00","rainfall":5.0,"runoff":0.109932,"infiltration_loss":0.678083,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:42:00","rainfall":5.0,"runoff":0.109932,"infiltration_loss":0.678083,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:43:00","rainfall":5.0,"runoff":0.109932,"infiltration_loss":0.678083,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:44:00","rainfall":5.0,"runoff":0.109932,"infiltration_loss":0.678083,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:45:00","rainfall":5.0,"runoff":0.109932,"infiltration_loss":0.678083,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:46:00","rainfall":5.0,"runoff":0.107941,"infiltration_loss":0.672338,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:47:00","rainfall":5.0,"runoff":0.107941,"infiltration_loss":0.672338,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:48:00","rainfall":5.0,"runoff":0.107941,"infiltration_loss":0.672338,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:49:00","rainfall":5.0,"runoff":0.107941,"infiltration_loss":0.672338,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:50:00","rainfall":5.0,"runoff":0.107941,"infiltration_loss":0.672338,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:51:00","rainfall":5.0,"runoff":0.106049,"infiltration_loss":0.666839,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:52:00","rainfall":5.0,"runoff":0.106049,"infiltration_loss":0.666839,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:53:00","rainfall":5.0,"runoff":0.106049,"infiltration_loss":0.666839,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:54:00","rainfall":5.0,"runoff":0.106049,"infiltration_loss":0.666839,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:55:00","rainfall":5.0,"runoff":0.106049,"infiltration_loss":0.666839,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:56:00","rainfall":5.0,"runoff":0.10425,"infiltration_loss":0.661573,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:57:00","rainfall":5.0,"runoff":0.10425,"infiltration_loss":0.661573,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:58:00","rainfall":5.0,"runoff":0.10425,"infiltration_loss":0.661573,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:59:00","rainfall":5.0,"runoff":0.10425,"infiltration_loss":0.661573,"evaporation_loss":0.0,"runon":0.0}] \ No newline at end of file diff --git a/cs_app/e2e/pages/home_page.py b/cs_app/e2e/pages/home_page.py index 61acaba..06cd828 100644 --- a/cs_app/e2e/pages/home_page.py +++ b/cs_app/e2e/pages/home_page.py @@ -2,11 +2,13 @@ from __future__ import annotations +from playwright.sync_api import Locator + from .base_page import BasePage class HomePage(BasePage): - """POM for the home page (``/``).""" + """POM for the home page (``/``): hero chart, tool cards and documentation.""" PATH = "/" @@ -14,20 +16,46 @@ def navigate_to(self) -> None: self.navigate(self.PATH) # ------------------------------------------------------------------ - # Content + # Hero # ------------------------------------------------------------------ def get_main_heading(self) -> str | None: - return self.get_heading(level=2) + return self.get_heading(level=1) + + @property + def hydrograph(self) -> Locator: + return self.page.locator("#plot-hydrograph") + + @property + def hero_metrics(self) -> Locator: + return self.page.locator("#hero-metrics .cs-metric") + + def wait_for_chart(self, chart: Locator) -> None: + """Wait until Plotly has drawn into *chart*.""" + chart.locator(".main-svg").first.wait_for(state="attached") + + # ------------------------------------------------------------------ + # Tools and documentation + # ------------------------------------------------------------------ + + def tool_link(self, name: str) -> Locator: + return self.page.locator(".home-tools").get_by_role("link", name=name, exact=True) - def get_plotly_charts(self): + def toc_link(self, name: str) -> Locator: + return self.page.locator("#docs-toc").get_by_role("link", name=name, exact=True) + + def get_plotly_charts(self) -> Locator: """Return locator for all Plotly chart containers.""" return self.page.locator("[id^='plot-']") - def get_code_examples(self): + def get_code_examples(self) -> Locator: """Return locator for code snippet blocks.""" return self.page.locator("pre.code-snippet") + def copy_button(self, example_id: str) -> Locator: + """Copy button of the snippet inside the ``#`` section.""" + return self.page.locator(f"#{example_id} .cs-code__copy") + def has_chart_data_script(self) -> bool: """Check whether the embedded chart-data JSON script tag exists.""" return self.page.locator("#chart-data").count() > 0 diff --git a/cs_app/e2e/test_home.py b/cs_app/e2e/test_home.py index b8ae2b2..258c758 100644 --- a/cs_app/e2e/test_home.py +++ b/cs_app/e2e/test_home.py @@ -1,49 +1,192 @@ -"""Home page tests.""" +"""Home page tests: hero hydrograph, tool cards, documentation and example charts.""" from __future__ import annotations +import re + import pytest from playwright.sync_api import Page, expect from .pages.home_page import HomePage +from .pages.nav_component import NavComponent pytestmark = pytest.mark.e2e +HERO_HEADING = "See how a subcatchment turns rain into runoff." +PHONE = {"width": 360, "height": 780} + + +@pytest.fixture() +def home(page: Page, live_server) -> HomePage: + hp = HomePage(page, live_server.url) + hp.navigate_to() + return hp + + +class TestHomeContent: + def test_heading_visible(self, home: HomePage) -> None: + assert home.get_main_heading() == HERO_HEADING + + def test_page_title(self, home: HomePage) -> None: + assert "Catchment Simulation" in home.get_title() + + def test_plotly_chart_containers_present(self, home: HomePage) -> None: + for chart_id in ("plot-hydrograph", "plot-slope", "plot-area", "plot-width"): + expect(home.page.locator(f"#{chart_id}")).to_have_count(1) + + def test_code_examples_present(self, home: HomePage) -> None: + expect(home.get_code_examples()).to_have_count(7) + + def test_chart_data_script_tag_exists(self, home: HomePage) -> None: + assert home.has_chart_data_script() + + def test_section_headings_are_present(self, home: HomePage) -> None: + expect(home.page.locator("h3", has_text="Simulation methods")).to_be_visible() + expect(home.page.locator("h3", has_text="Analysis functions")).to_be_visible() + + @pytest.mark.parametrize( + ("name", "path"), + [ + ("Parameter sweep", "/simulation"), + ("Hydrograph", "/timeseries"), + ("SWMM vs. neural network", "/calculations"), + ], + ) + def test_tool_cards_link_to_tools(self, home: HomePage, name: str, path: str) -> None: + expect(home.tool_link(name)).to_have_attribute("href", path) + + +class TestHeroChart: + def test_hydrograph_draws_rainfall_and_runoff(self, home: HomePage) -> None: + home.wait_for_chart(home.hydrograph) + + traces = home.page.evaluate( + "() => document.getElementById('plot-hydrograph').data.map(t => t.name)" + ) + assert "Rainfall Intensity [mm/h]" in traces + assert "Runoff Rate [CMS]" in traces + expect(home.hydrograph).to_have_attribute("role", "img") + expect(home.hydrograph).to_have_attribute("aria-describedby", "hero-metrics") + + def test_metrics_state_the_numbers_with_units(self, home: HomePage) -> None: + metrics = home.hero_metrics + expect(metrics).to_have_count(4) + expect(metrics.nth(0)).to_contain_text("Peak rainfall") + expect(metrics.nth(0)).to_contain_text("mm/h") + expect(metrics.nth(1)).to_contain_text("CMS") + expect(metrics.nth(2)).to_contain_text(re.compile(r"\d+\s*h\s*\d+\s*min")) + expect(metrics.nth(3)).to_contain_text("m³") + + def test_theme_switch_redraws_chart_in_place(self, page: Page, live_server) -> None: + page.emulate_media(color_scheme="light") + home = HomePage(page, live_server.url) + home.navigate_to() + home.wait_for_chart(home.hydrograph) + font_color = "() => document.getElementById('plot-hydrograph').layout.font.color" + light_ink = page.evaluate(font_color) + + NavComponent(page).set_theme("dark") + + expect(page.locator("html")).to_have_attribute("data-bs-theme", "dark") + page.wait_for_function(f"({font_color})() !== {light_ink!r}") + expect(home.hydrograph.locator(".main-svg").first).to_be_attached() + expect(home.hydrograph.locator(".cs-empty-state")).to_have_count(0) + + +class TestExampleCharts: + @pytest.mark.parametrize( + ("key", "x_label"), + [("slope", "Percent Slope [%]"), ("area", "Area [ha]"), ("width", "Width [m]")], + ) + def test_sweep_chart_uses_server_axis_labels( + self, home: HomePage, key: str, x_label: str + ) -> None: + chart = home.page.locator(f"#plot-{key}") + home.wait_for_chart(chart) + + expect(chart.locator(".xtitle")).to_have_text(x_label) + expect(chart.locator(".ytitle")).to_have_text("Total Runoff Volume [m³]") + + def test_each_chart_has_a_numeric_summary(self, home: HomePage) -> None: + summary = home.page.locator("#plot-slope-summary") + expect(home.page.locator("#plot-slope")).to_have_attribute( + "aria-describedby", "plot-slope-summary" + ) + expect(summary).to_contain_text("Percent Slope [%]") + expect(summary).to_contain_text("1 → 100") + + +class TestCodeCopy: + def test_copy_button_copies_snippet(self, page: Page, live_server) -> None: + page.context.grant_permissions(["clipboard-read", "clipboard-write"]) + home = HomePage(page, live_server.url) + home.navigate_to() + + home.copy_button("example-slope").click() + + expect(page.locator(".toast-container .cs-toast--success")).to_contain_text("copied") + copied = page.evaluate("navigator.clipboard.readText()") + assert copied.startswith("from catchment_simulation import FeaturesSimulation") + assert "simulate_percent_slope(start=1, stop=100, step=1)" in copied + + def test_copy_buttons_have_distinct_names(self, home: HomePage) -> None: + expect( + home.page.get_by_role("button", name="Copy the install command", exact=True) + ).to_be_visible() + expect( + home.page.get_by_role("button", name="Copy the width example", exact=True) + ).to_be_visible() + + +class TestTableOfContents: + def test_scrollspy_marks_the_visible_section(self, page: Page, live_server) -> None: + page.set_viewport_size({"width": 1366, "height": 900}) + home = HomePage(page, live_server.url) + home.navigate_to() + page.wait_for_load_state("load") + + page.locator("#analysis-functions").scroll_into_view_if_needed() + page.mouse.wheel(0, 200) + + expect(home.toc_link("Analysis functions")).to_have_class(re.compile(r"\bactive\b")) + expect(home.toc_link("The Python package")).not_to_have_class(re.compile(r"\bactive\b")) + + def test_toc_link_jumps_to_section(self, page: Page, live_server) -> None: + page.set_viewport_size({"width": 1366, "height": 900}) + home = HomePage(page, live_server.url) + home.navigate_to() + + home.toc_link("Width sweep").click() + + expect(page).to_have_url(re.compile(r"#example-width$")) + expect(page.locator("#example-width-title")).to_be_in_viewport() + + def test_toc_is_hidden_on_phones(self, page: Page, live_server) -> None: + page.set_viewport_size(PHONE) + home = HomePage(page, live_server.url) + home.navigate_to() + expect(page.locator("#docs-toc")).to_be_hidden() + + +class TestHomeLayoutAndA11y: + def test_no_horizontal_overflow_at_360px(self, page: Page, live_server) -> None: + page.set_viewport_size(PHONE) + home = HomePage(page, live_server.url) + home.navigate_to() + home.wait_for_chart(home.hydrograph) + assert page.evaluate( + "document.documentElement.scrollWidth <= window.innerWidth" + ), "home page scrolls horizontally at 360px" + + @pytest.mark.parametrize("scheme", ["light", "dark"]) + def test_no_serious_axe_violations(self, page: Page, live_server, scheme: str) -> None: + page.emulate_media(color_scheme=scheme) + home = HomePage(page, live_server.url) + home.navigate_to() + home.wait_for_chart(home.hydrograph) + + report = home.run_axe_audit() -class TestHomePage: - """Home / main_view page content tests.""" - - def test_heading_visible(self, page: Page, live_server) -> None: - hp = HomePage(page, live_server.url) - hp.navigate_to() - assert hp.get_main_heading() == "Catchment Simulation" - - def test_page_title(self, page: Page, live_server) -> None: - hp = HomePage(page, live_server.url) - hp.navigate_to() - assert "Catchment Simulation" in hp.get_title() - - def test_plotly_chart_containers_present(self, page: Page, live_server) -> None: - hp = HomePage(page, live_server.url) - hp.navigate_to() - charts = hp.get_plotly_charts() - # Should have at least 3 chart divs (slope, area, width) - assert charts.count() >= 3 - - def test_code_examples_present(self, page: Page, live_server) -> None: - hp = HomePage(page, live_server.url) - hp.navigate_to() - examples = hp.get_code_examples() - assert examples.count() >= 1 - - def test_chart_data_script_tag_exists(self, page: Page, live_server) -> None: - hp = HomePage(page, live_server.url) - hp.navigate_to() - assert hp.has_chart_data_script() - - def test_section_headings_are_present(self, page: Page, live_server) -> None: - hp = HomePage(page, live_server.url) - hp.navigate_to() - # Key section headings - expect(page.locator("h3", has_text="Simulation Methods")).to_be_visible() - expect(page.locator("h3", has_text="Analysis Functions")).to_be_visible() + assert not report.critical + report.serious, [ + v.id for v in report.critical + report.serious + ] diff --git a/cs_app/main/static/main/css/home.css b/cs_app/main/static/main/css/home.css new file mode 100644 index 0000000..112b282 --- /dev/null +++ b/cs_app/main/static/main/css/home.css @@ -0,0 +1,324 @@ +/* + * Home page: hero with the example storm, tool cards, documentation with a + * sticky table of contents (≥ lg). Shared components come from app.css. + */ + +/* ------------------------------------------------------------------ */ +/* Hero */ +/* ------------------------------------------------------------------ */ + +.home-hero { + display: grid; + gap: 2rem; + align-items: center; + margin-bottom: 4rem; +} + +@media (min-width: 992px) { + .home-hero { + grid-template-columns: minmax(0, 5fr) minmax(0, 7fr); + gap: 3rem; + padding-top: 1rem; + } +} + +.home-hero__title { + max-width: 18ch; + margin-bottom: 1rem; +} + +.home-hero__lead { + max-width: 46ch; + margin-bottom: 1.75rem; + color: var(--cs-ink-muted); + font-size: 1.125rem; +} + +.home-hero__actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.home-hero__chart { + margin: 0; +} + +.home-hero__source { + color: var(--cs-ink-muted); + font-size: var(--cs-fs-caption); +} + +/* Two by two until the card is wide enough for one row of four. */ +.home-hero__metrics { + margin: 0; + padding: 0 1.25rem 1.25rem; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.625rem; +} + +.home-hero__metrics .cs-metric { + padding: 0.75rem 0.875rem; + background-color: var(--cs-surface-sunken); + border-color: transparent; +} + +/* One row only where every tile fits its label and value ("8 h 55 min") on one line. */ +@media (min-width: 768px) and (max-width: 991.98px), (min-width: 1200px) { + .home-hero__metrics { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .home-hero__metrics .cs-metric { + padding-inline: 0.625rem; + } +} + +.home-hero__metrics .cs-metric__label { + letter-spacing: 0.04em; + white-space: nowrap; +} + +.home-hero__metrics dd { + margin: 0; +} + +.home-hero__metrics .cs-metric__value { + font-size: 1.125rem; +} + +.home-hero__metrics .cs-metric__unit { + margin-left: 0.125rem; +} + +.home-hero__empty { + min-height: 22rem; + margin: 1.25rem; +} + +/* Reserve the plot height so the page does not jump when Plotly draws. */ +.home-chart { + min-height: 320px; +} + +.home-chart--hero { + min-height: 360px; +} + +.home-chart__noscript { + margin: 0 0 0.5rem; + color: var(--cs-ink-muted); + font-size: var(--cs-fs-small); +} + +/* ------------------------------------------------------------------ */ +/* Tool cards */ +/* ------------------------------------------------------------------ */ + +.home-tools { + margin-bottom: 4rem; +} + +.home-tools__list { + display: grid; + gap: 1.25rem; + margin: 0; + padding: 0; + list-style: none; +} + +@media (min-width: 768px) { + .home-tools__list { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +.home-tool { + position: relative; + transition: border-color var(--cs-transition); +} + +.home-tool:hover { + border-color: var(--cs-water); +} + +/* The whole card is the link target, so the ring goes around the card. */ +.home-tool:focus-within { + outline: 3px solid var(--cs-focus-ring); + outline-offset: 2px; +} + +.home-tool .stretched-link:focus-visible { + outline: none; +} + +.home-tool__icon { + width: 2rem; + height: 2rem; + margin-bottom: 1rem; + color: var(--cs-water); +} + +.home-tool .cs-eyebrow { + margin-bottom: 0.25rem; +} + +.home-tool__title { + margin-bottom: 0.5rem; + font-size: var(--cs-fs-h4); +} + +.home-tool__title a { + color: var(--cs-ink); + text-decoration: none; +} + +.home-tool:hover .home-tool__title a { + color: var(--cs-water-strong); +} + +.home-tool__text { + margin: 0; + color: var(--cs-ink-muted); +} + +/* ------------------------------------------------------------------ */ +/* Documentation */ +/* ------------------------------------------------------------------ */ + +.home-docs { + padding-top: 3rem; + border-top: 1px solid var(--cs-line); +} + +@media (min-width: 992px) { + .home-docs { + display: grid; + grid-template-columns: 13.5rem minmax(0, 1fr); + gap: 3.5rem; + align-items: start; + } +} + +.home-docs__body { + min-width: 0; +} + +.home-toc { + position: sticky; + top: calc(var(--cs-header-h) + 1.5rem); + max-height: calc(100vh - var(--cs-header-h) - 3rem); + overflow-y: auto; + /* Room for the focus ring, which the scroll container would clip. */ + padding: 0.25rem; +} + +.home-toc .nav { + flex-wrap: nowrap; +} + +.home-toc .nav-link { + padding: 0.3rem 0.75rem; + border-left: 2px solid var(--cs-line); + color: var(--cs-ink-muted); + font-size: var(--cs-fs-small); +} + +.home-toc .nav-link:hover { + color: var(--cs-ink); +} + +.home-toc .nav-link.active { + border-left-color: var(--cs-water); + color: var(--cs-ink); + font-weight: 600; +} + +.home-toc .nav .nav .nav-link { + padding-left: 1.5rem; +} + +.home-doc + .home-doc { + margin-top: 3.5rem; +} + +.home-doc > p { + max-width: 72ch; +} + +.home-doc h3 { + margin-bottom: 0.75rem; +} + +.home-methods__group { + margin: 2rem 0 0.75rem; + font-size: 1.0625rem; +} + +.home-methods { + max-width: 60rem; + margin: 0; + border-bottom: 1px solid var(--cs-line); +} + +.home-methods dt, +.home-methods dd { + margin: 0; + padding: 0.75rem 0; + border-top: 1px solid var(--cs-line); +} + +.home-methods dt { + font-weight: 400; +} + +.home-methods dt code { + overflow-wrap: anywhere; +} + +.home-methods dd { + padding-top: 0; + border-top: 0; + color: var(--cs-ink-muted); +} + +@media (min-width: 768px) { + .home-methods { + display: grid; + grid-template-columns: minmax(0, 2fr) minmax(0, 3fr); + column-gap: 2rem; + } + + .home-methods dd { + padding-top: 0.75rem; + border-top: 1px solid var(--cs-line); + } +} + +.home-example { + margin-top: 3rem; +} + +.home-example__chart { + margin: 0; +} + +.home-example__summary { + display: flex; + flex-wrap: wrap; + gap: 0.5rem 2.5rem; + margin: 0; + padding: 0.75rem 1.25rem; + border-top: 1px solid var(--cs-line); + font-size: var(--cs-fs-small); +} + +.home-example__summary dt { + color: var(--cs-ink-muted); + font-size: var(--cs-fs-caption); + font-weight: 500; +} + +.home-example__summary dd { + margin: 0; +} diff --git a/cs_app/main/static/main/js/pages/main_view.js b/cs_app/main/static/main/js/pages/main_view.js index b9a0b99..6afc40c 100644 --- a/cs_app/main/static/main/js/pages/main_view.js +++ b/cs_app/main/static/main/js/pages/main_view.js @@ -1,38 +1,70 @@ -"use strict"; +/* + * Home page: the example hyetograph/hydrograph, the example sweep charts and + * the Copy buttons of the code snippets. Chart data and axis labels come from + * the server (#chart-data); the table of contents is Bootstrap scrollspy + * (data attributes in the template). + */ +(function (CS) { + "use strict"; -document.addEventListener("DOMContentLoaded", function () { - var chartDataElement = document.getElementById("chart-data"); - if (!chartDataElement) { - return; - } + var SWEEP_CHART_HEIGHT = 320; + var HERO_CHART_HEIGHT = 360; - try { - var data = JSON.parse(chartDataElement.textContent); + function readChartData() { + var element = document.getElementById("chart-data"); + return element ? JSON.parse(element.textContent) : null; + } - renderLineChart("plot-slope", data.slope, "slope", "runoff", { - title: "Dependence of runoff on subcatchment slope.", - xLabel: "Percent Slope [-]", - yLabel: "Runoff [m3]", - xRange: [0, 100], - }); - renderLineChart("plot-area", data.area, "area", "runoff", { - title: "Dependence of runoff on subcatchment area.", - xLabel: "Area [ha]", - yLabel: "Runoff [m3]", + function renderHydrograph(hydrograph) { + var element = document.getElementById("plot-hydrograph"); + if (!element || !hydrograph) { + return; + } + // Runoff only: the example model has no runon, and the loss rates would + // crowd the hero chart. + CS.charts.hydrograph(element, hydrograph.records, { + flowFields: ["runoff"], + yLabels: hydrograph.yLabels, + xLabel: hydrograph.xLabel, + height: HERO_CHART_HEIGHT, + fileName: "example-storm-S1", }); - renderLineChart("plot-width", data.width, "width", "runoff", { - title: "Dependence of runoff on subcatchment width.", - xLabel: "Width [m]", - yLabel: "Runoff [m3]", - xRange: [0, 1000], - }); - } catch (error) { - console.error("Failed to render charts:", error); - ["plot-slope", "plot-area", "plot-width"].forEach(function (id) { - var element = document.getElementById(id); - if (element) { - element.innerHTML = '

Failed to load chart.

'; + } + + function renderSweeps(sweeps) { + document.querySelectorAll("[data-home-sweep]").forEach(function (element) { + var key = element.getAttribute("data-home-sweep"); + var sweep = sweeps && sweeps[key]; + if (!sweep) { + return; } + CS.charts.line(element, sweep.records, sweep.xField, "runoff", { + xLabel: sweep.xLabel, + yLabel: sweep.yLabel, + xRange: sweep.xRange, + height: SWEEP_CHART_HEIGHT, + fileName: "example-" + key + "-sweep", + }); }); } -}); + + function copySnippet(event) { + var button = event.target.closest(".cs-code__copy"); + if (!button) { + return; + } + var code = button.closest(".cs-code").querySelector("pre code"); + CS.clipboard.copy(code.textContent, "Code copied to the clipboard."); + } + + document.addEventListener("click", copySnippet); + + document.addEventListener("DOMContentLoaded", function () { + var data = readChartData(); + if (!data) { + return; + } + renderHydrograph(data.hydrograph); + renderSweeps(data.sweeps); + }); +})(window.CS); diff --git a/cs_app/main/templates/main/main_view.html b/cs_app/main/templates/main/main_view.html index 0dcb2a9..fb24b3b 100644 --- a/cs_app/main/templates/main/main_view.html +++ b/cs_app/main/templates/main/main_view.html @@ -1,116 +1,291 @@ {% extends 'base.html' %} {% load static %} -{% block title %}Catchment Simulation — Documentation{% endblock %} +{% block title %}Catchment Simulation — SWMM subcatchment toolkit{% endblock %} {% block head_scripts %} + + + +{% endblock %} + +{% block style %} + {% endblock %} {% block content %} -

Catchment Simulation

- -

The catchment_simulation package includes methods for simulating subcatchments with different feature values using the Storm Water Management Model. The FeaturesSimulation class contains all the methods.

-

The class constructor takes two arguments — subcatchment_id and raw_file. The subcatchment_id is the unique identifier of the subcatchment that you want to simulate. The raw_file argument is the path to the SWMM input file. The constructor raises FileNotFoundError if the file does not exist and ValueError if the subcatchment ID is not found in the model. The class supports the context manager protocol (with statement) for automatic cleanup of temporary files created during simulation.

- -

Install the package with:

-
pip install catchment_simulation
- -

Simulation Methods

-

The package has the following methods:

-
    -
  • get_section(section="subcatchments") — Returns a pandas DataFrame of the specified section from the SWMM input file loaded during construction.
  • -
  • copy_file(copy=None, suffix="copy") — Creates a copy of the SWMM input file with a suffix added to the file name. Defaults to copying the raw file.
  • -
  • calculate() — Runs a SWMM simulation and returns summary statistics for the configured subcatchment as a dict with keys: runoff, peak_runoff_rate, infiltration, evaporation.
  • -
  • calculate_timeseries() — Runs a simulation and collects per-timestep data for the subcatchment. Returns a pd.DataFrame with a DatetimeIndex and columns: rainfall, runoff, infiltration_loss, evaporation_loss, runon.
  • -
  • simulate_subcatchment(feature, start, stop, step) — Varies a subcatchment feature over a range and returns a pd.DataFrame with summary statistics for each value.
  • -
  • simulate_subcatchment_timeseries(feature, start, stop, step) — Like simulate_subcatchment but returns per-timestep timeseries data instead of summary statistics. Returns a dict[float, pd.DataFrame] mapping parameter values to DataFrames.
  • -
  • simulate_area(start=1, stop=10, step=1) — Simulates the subcatchment area in a selected range.
  • -
  • simulate_percent_impervious(start=0, stop=100, step=10) — Simulates the percent impervious of a subcatchment.
  • -
  • simulate_percent_slope(start=0, stop=100, step=10) — Simulates the subcatchment's percent slope.
  • -
  • simulate_width(start=0, stop=100, step=10) — Simulates the width of the subcatchment.
  • -
  • simulate_curb_length(start=0, stop=100, step=10) — Simulates the curb length of a subcatchment.
  • -
  • simulate_percent_zero_imperv(start=0, stop=100, step=10) — Simulates varying percent impervious area with no depression storage.
  • -
  • simulate_manning_n(param) — Simulates a subcatchment using predefined Manning's n literature values (McCuen et al., 1996). Does not accept start/stop/step.
  • -
  • simulate_n_imperv() — Simulates Manning's n for impervious area using predefined literature values.
  • -
  • simulate_n_perv() — Simulates Manning's n for pervious area using predefined literature values.
  • -
  • simulate_destore(param) — Simulates varying depth of depression storage using predefined values (ASCE, 1992). Does not accept start/stop/step.
  • -
  • simulate_s_imperv() — Simulates the impervious depth of depression storage using predefined literature values.
  • -
  • simulate_s_perv() — Simulates the pervious depth of depression storage using predefined literature values.
  • -
+{% with units=home.units summary=home.hydrograph_summary sweeps=home.chart_data.sweeps %} +
+ -

Analysis Functions

-

The catchment_simulation.analysis module provides analytical helpers for hydrograph data returned by calculate_timeseries:

-
    -
  • time_to_peak(timeseries, column="runoff") — Calculates the elapsed time from simulation start to the peak value. Returns a pd.Timedelta.
  • -
  • runoff_volume(timeseries, column="runoff", start=None, end=None) — Computes total runoff volume by integrating flow rate over time using the trapezoidal rule. Supports optional start/end time window. Returns volume in flow-unit × seconds.
  • +
    +
    +

    Example storm on subcatchment S1

    + Bundled example model +
    + {% if home %} +
    + + +
    +
    +
    +
    +
    Peak rainfall
    +
    {{ summary.peak_rainfall|floatformat:1 }}{{ units.depth_rate }}
    +
    +
    +
    Peak runoff
    +
    {{ summary.peak|floatformat:3 }}{{ units.flow_rate }}
    +
    +
    +
    Time to peak
    +
    {{ summary.time_to_peak|default:"—" }}
    +
    +
    +
    Runoff volume
    +
    {{ summary.volume|floatformat:"0g" }}{{ units.volume }}
    +
    +
    +
    + {% else %} +
    +

    The example data is not available

    +

    Load the sample model in any tool to see the same storm.

    +
    + {% endif %} +
    +
+ +
+
+

Tools

+

One model, three ways to read it

+

Upload a SWMM .inp file once, or start from the sample data, and every tool works on the same subcatchments.

+
+
    +
  • + +

    Simulation

    +

    Parameter sweep

    +

    Vary one subcatchment parameter over a range and see how runoff volume, peak rate and losses respond.

    +
  • +
  • + +

    Timeseries

    +

    Hydrograph

    +

    Follow rainfall and runoff minute by minute, read the time to peak and runoff volume, and export the series.

    +
  • +
  • + +

    Calculations

    +

    SWMM vs. neural network

    +

    Compare the runoff SWMM computes for every subcatchment with the prediction of a trained neural network.

    +
+
+ +
+ + +
+
+

Documentation

+

The Python package

+

Everything this site does comes from the open-source catchment_simulation package, which drives the Storm Water Management Model (SWMM) through pyswmm and swmmio. Its FeaturesSimulation class holds all the methods.

+

The constructor takes two arguments: subcatchment_id, the identifier of the subcatchment you want to simulate, and raw_file, the path to the SWMM input file. It raises FileNotFoundError when the file does not exist and ValueError when the subcatchment is not in the model. Use it as a context manager (with statement) so the temporary files it creates are removed afterwards.

+

Install it with pip:

+
+
pip install catchment-simulation
+ +
+
-

To use the package, install it and import it into your Python code. It is recommended to use the context manager (with statement) to ensure temporary files are cleaned up automatically. You can then call any of the methods on the instance, passing in any necessary arguments. Methods return a pandas DataFrame with the results of the simulation.

+
+

Simulation methods

+

Every method runs SWMM on a temporary copy of your model, so the original file is never changed. Sweeps return one row per simulated value.

-

Examples of How To Use

+

Model and single runs

+
+
get_section(section="subcatchments")
+
Returns the given section of the input file as a pandas DataFrame.
+
copy_file(copy=None, suffix="copy")
+
Copies the input file, adding a suffix to its name. Copies the raw file by default.
+
calculate()
+
Runs one simulation and returns summary statistics for the subcatchment as a dict with the keys runoff, peak_runoff_rate, infiltration and evaporation.
+
calculate_timeseries()
+
Runs one simulation and collects every time step. Returns a DataFrame with a DatetimeIndex and the columns rainfall, runoff, infiltration_loss, evaporation_loss and runon. Rainfall and infiltration loss are in mm/h (in/h in US-unit models), evaporation loss in mm/day (in/day), runoff and runon in the model's FLOW_UNITS.
+
-

Simulate catchment slope in selected range.

-

-from catchment_simulation import FeaturesSimulation
+            

Parameter sweeps

+
+
simulate_subcatchment(feature, start, stop, step)
+
Varies any subcatchment feature over a range and returns summary statistics for each value.
+
simulate_subcatchment_timeseries(feature, start, stop, step)
+
Like simulate_subcatchment, but keeps the full timeseries of every run: returns a dict[float, pd.DataFrame] keyed by parameter value.
+
simulate_area(start=1, stop=10, step=1)
+
Sweeps the subcatchment area.
+
simulate_percent_impervious(start=0, stop=100, step=10)
+
Sweeps the percentage of impervious area.
+
simulate_percent_slope(start=0, stop=100, step=10)
+
Sweeps the percent slope.
+
simulate_width(start=0, stop=100, step=10)
+
Sweeps the characteristic width of the overland flow path.
+
simulate_curb_length(start=0, stop=100, step=10)
+
Sweeps the total curb length.
+
simulate_percent_zero_imperv(start=0, stop=100, step=10)
+
Sweeps the percentage of impervious area with no depression storage.
+
+ +

Literature values

+
+
simulate_manning_n(param)
+
Runs the subcatchment with Manning's n values from the literature (McCuen et al., 1996). Takes no range.
+
simulate_n_imperv() · simulate_n_perv()
+
Manning's n for the impervious or the pervious area.
+
simulate_destore(param)
+
Runs the subcatchment with depression storage depths from the literature (ASCE, 1992). Takes no range.
+
simulate_s_imperv() · simulate_s_perv()
+
Depression storage depth for the impervious or the pervious area.
+
+
+ +
+

Analysis functions

+

The catchment_simulation.analysis module reads the DataFrame returned by calculate_timeseries().

+
+
time_to_peak(timeseries, column="runoff")
+
Time from the first time step to the peak value, as a pd.Timedelta.
+
runoff_volume(timeseries, column="runoff", start=None, end=None)
+
Total volume from integrating the flow rate over time (trapezoidal rule), optionally between start and end. The result is in flow units × seconds, so cubic metres for a model in CMS.
+
+
+ +
+

Examples

+

Examples on the bundled model

+

Each chart below comes from running the snippet next to it on the example model that ships with this site (subcatchment S1). Load the same model in any tool with Try sample data.

+ +
+

Sweep the slope

+
+
from catchment_simulation import FeaturesSimulation
 
 with FeaturesSimulation(subcatchment_id="S1", raw_file="example.inp") as model:
-    df = model.simulate_percent_slope(start=1, stop=10, step=1)
-
- - + df = model.simulate_percent_slope(start=1, stop=100, step=1)
+ +
+ {% include 'main/partials/_home_sweep_chart.html' with key='slope' sweep=sweeps.slope title='Runoff volume against slope' %} + -

Simulate catchment area in selected range.

-

-from catchment_simulation import FeaturesSimulation
+            
+

Sweep the area

+
+
from catchment_simulation import FeaturesSimulation
 
 with FeaturesSimulation(subcatchment_id="S1", raw_file="example.inp") as model:
-    df = model.simulate_area(start=1, stop=100, step=1)
-
- + df = model.simulate_area(start=1, stop=100, step=1)
+ +
+ {% include 'main/partials/_home_sweep_chart.html' with key='area' sweep=sweeps.area title='Runoff volume against area' %} + -

Simulate catchment width in selected range.

-

-from catchment_simulation import FeaturesSimulation
+            
+

Sweep the width

+
+
from catchment_simulation import FeaturesSimulation
 
 with FeaturesSimulation(subcatchment_id="S1", raw_file="example.inp") as model:
-    df = model.simulate_width(start=1, stop=1000, step=1)
-
- + df = model.simulate_width(start=10, stop=1000, step=10)
+ +
+ {% include 'main/partials/_home_sweep_chart.html' with key='width' sweep=sweeps.width title='Runoff volume against width' %} + -

Simulate Manning's n using predefined literature values.

-

-from catchment_simulation import FeaturesSimulation
+            
+

Use literature values for Manning's n

+
+
from catchment_simulation import FeaturesSimulation
 
 with FeaturesSimulation(subcatchment_id="S1", raw_file="example.inp") as model:
-    df = model.simulate_n_imperv()
-
+ df = model.simulate_n_imperv()
+ + + -

Collect timeseries data and compute analytical metrics.

-

-from catchment_simulation import FeaturesSimulation, time_to_peak, runoff_volume
+            
+

Collect a timeseries and its metrics

+

This is how the storm at the top of the page was computed.

+
+
from catchment_simulation import FeaturesSimulation, runoff_volume, time_to_peak
 
 with FeaturesSimulation(subcatchment_id="S1", raw_file="example.inp") as model:
     ts = model.calculate_timeseries()
-    ttp = time_to_peak(ts, column="runoff")
-    vol = runoff_volume(ts, column="runoff")
-
-

Sweep a parameter and collect timeseries for each value.

-

-from catchment_simulation import FeaturesSimulation
+ttp = time_to_peak(ts, column="runoff")
+vol = runoff_volume(ts, column="runoff")
+ +
+
+ +
+

Sweep a parameter and keep every timeseries

+
+
from catchment_simulation import FeaturesSimulation
 
 with FeaturesSimulation(subcatchment_id="S1", raw_file="example.inp") as model:
     results = model.simulate_subcatchment_timeseries(
         feature="PercImperv", start=0, stop=100, step=10
     )
-    # results is dict[float, pd.DataFrame]
-
+# results: dict[float, pd.DataFrame], one timeseries per value
+ + + + + + +{% endwith %} -{{ chart_data|json_script:"chart-data" }} +{{ home.chart_data|json_script:"chart-data" }} {% endblock %} {% block additional_scripts %} diff --git a/cs_app/main/templates/main/partials/_home_sweep_chart.html b/cs_app/main/templates/main/partials/_home_sweep_chart.html new file mode 100644 index 0000000..d30104f --- /dev/null +++ b/cs_app/main/templates/main/partials/_home_sweep_chart.html @@ -0,0 +1,25 @@ +{# Home page example chart. Context: key (plot- id), sweep (chart_data.sweeps entry), title. #} +{% if sweep %} +
+
+

{{ title }}

+
+
+ + +
+
+
+
+
{{ sweep.xLabel }}
+
{{ sweep.xRange.0|floatformat:"-2g" }} → {{ sweep.xRange.1|floatformat:"-2g" }}
+
+
+
{{ sweep.yLabel }}
+
{{ sweep.runoffRange.0|floatformat:"0g" }} → {{ sweep.runoffRange.1|floatformat:"0g" }}
+
+
+
+
+{% endif %} diff --git a/cs_app/main/tests/test_content_pages.py b/cs_app/main/tests/test_content_pages.py index 5889c0e..a4f1277 100644 --- a/cs_app/main/tests/test_content_pages.py +++ b/cs_app/main/tests/test_content_pages.py @@ -1,14 +1,121 @@ -"""The account pages.""" +"""Home page data and the account pages.""" +import json +import os import uuid import pytest +from django.conf import settings from django.urls import reverse +from catchment_simulation.catchment_features_simulation import FeaturesSimulation +from main import views + # Random per run, like the e2e credentials, so no password is written in the source. SIGNUP_PASSWORD = f"T!{uuid.uuid4().hex}" +@pytest.fixture() +def fresh_home_cache(): + views._load_home_data_cached.cache_clear() + yield + views._load_home_data_cached.cache_clear() + + +# --------------------------------------------------------------------------- +# Home page data +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +def test_home_context_carries_charts_labels_and_numbers(client, fresh_home_cache): + home = client.get(reverse("main:main_view")).context["home"] + + hydrograph = home["chart_data"]["hydrograph"] + assert set(hydrograph["records"][0]) == {"datetime", "rainfall", "runoff"} + assert hydrograph["yLabels"] == { + "rainfall": "Rainfall Intensity [mm/h]", + "runoff": "Runoff Rate [CMS]", + } + slope = home["chart_data"]["sweeps"]["slope"] + assert (slope["xLabel"], slope["yLabel"]) == ("Percent Slope [%]", "Total Runoff Volume [m³]") + assert slope["xRange"] == [1, 100] + assert home["units"]["volume"] == "m³" + summary = home["hydrograph_summary"] + assert summary["peak"] == max(row["runoff"] for row in hydrograph["records"]) + assert summary["time_to_peak"] == "8 h 55 min" + # The example model is in CMS, so the integrated flow is already in m³. + assert summary["volume"] == pytest.approx(3350.98, abs=0.01) + + +@pytest.mark.django_db +def test_home_renders_chart_json_and_metrics(client, fresh_home_cache): + html = client.get(reverse("main:main_view")).content.decode() + + assert 'id="chart-data"' in html + assert 'id="plot-hydrograph"' in html + assert 'aria-describedby="plot-width-summary"' in html + assert "3,351" in html # runoff volume of the example storm, grouped + + +def test_home_data_matches_bundled_model(): + """The precomputed charts must come from data/example.inp (regenerate with build_home_data.py).""" + model_path = os.path.join(settings.BASE_DIR, "data", "example.inp") + with FeaturesSimulation(subcatchment_id="S1", raw_file=model_path) as model: + baseline = model.calculate() # S1 as stored: slope 10 %, area 5 ha, width 100 m + + data = views._build_home_data() + sweeps = data["chart_data"]["sweeps"] + for key, value in (("slope", 10), ("area", 5), ("width", 100)): + row = next(r for r in sweeps[key]["records"] if r[sweeps[key]["xField"]] == value) + assert row["runoff"] == pytest.approx(baseline["runoff"], rel=1e-5), key + assert data["hydrograph_summary"]["peak"] == pytest.approx( + baseline["peak_runoff_rate"], rel=1e-5 + ) + + +@pytest.mark.django_db +def test_home_renders_without_example_data(client, fresh_home_cache, monkeypatch): + def broken(): + raise ValueError("corrupt data file") + + monkeypatch.setattr(views, "_build_home_data", broken) + + response = client.get(reverse("main:main_view")) + + assert response.status_code == 200 + assert response.context["home"] is None + html = response.content.decode() + assert "The example data is not available" in html + assert 'id="plot-hydrograph"' not in html + assert 'id="plot-slope"' not in html + + +@pytest.mark.parametrize("bad_value", [float("nan"), float("inf"), True, "12", None], ids=repr) +def test_chart_json_rejects_non_finite_or_non_numeric(tmp_path, monkeypatch, bad_value): + (tmp_path / "sweep.json").write_text(json.dumps([{"slope": 1, "runoff": bad_value}])) + monkeypatch.setattr(views, "HOME_DATA_DIR", str(tmp_path)) + + with pytest.raises(ValueError): + views._load_chart_json("sweep.json", "slope", "runoff") + + +def test_chart_json_keeps_only_plotted_keys(tmp_path, monkeypatch): + (tmp_path / "sweep.json").write_text(json.dumps([{"Unnamed: 0": 0, "slope": 1, "runoff": 2.5}])) + monkeypatch.setattr(views, "HOME_DATA_DIR", str(tmp_path)) + + assert views._load_chart_json("sweep.json", "slope", "runoff") == [{"slope": 1, "runoff": 2.5}] + + +def test_hydrograph_json_rejects_rows_without_datetime(tmp_path, monkeypatch): + rows = [{"datetime": None, "rainfall": 0.0, "runoff": 0.0}] + (tmp_path / views.HOME_HYDROGRAPH_FILE).write_text(json.dumps(rows)) + monkeypatch.setattr(views, "HOME_DATA_DIR", str(tmp_path)) + + with pytest.raises(ValueError): + views._load_hydrograph_json() + + @pytest.mark.django_db def test_register_success_logs_in_and_welcomes(client): response = client.post( diff --git a/cs_app/main/tests/test_views.py b/cs_app/main/tests/test_views.py index d81a2a8..982112a 100644 --- a/cs_app/main/tests/test_views.py +++ b/cs_app/main/tests/test_views.py @@ -3104,6 +3104,14 @@ def test_prism_is_loaded_only_on_home(client, user, url_name, requires_login): assert "prismjs" not in html +@pytest.mark.django_db +def test_home_loads_prism_python(client): + html = client.get(reverse("main:main_view")).content.decode() + + assert "prismjs@1.30.0/prism.min.js" in html + assert "prismjs@1.30.0/components/prism-python.min.js" in html + + @pytest.mark.django_db @pytest.mark.parametrize("url_name", ["main:main_view", "main:about", "main:contact", "login"]) def test_dropzone_is_not_loaded_on_pages_without_upload(client, url_name): diff --git a/cs_app/main/views.py b/cs_app/main/views.py index 60fd715..c584652 100644 --- a/cs_app/main/views.py +++ b/cs_app/main/views.py @@ -167,48 +167,109 @@ def wrapper(request, *args, **kwargs): return wrapper +# Home page example data: precomputed from data/example.inp by data/build_home_data.py. +HOME_DATA_DIR = os.path.join(settings.BASE_DIR, "data") +HOME_EXAMPLE_MODEL = os.path.join(HOME_DATA_DIR, "example.inp") +HOME_HYDROGRAPH_FILE = "example_hydrograph.json" +HOME_HYDROGRAPH_FIELDS = ("rainfall", "runoff") +# Chart key -> (data file, x field, SWMM feature name used for the axis label). +HOME_SWEEPS = { + "slope": ("df_slope.json", "slope", "PercSlope"), + "area": ("df_area.json", "area", "Area"), + "width": ("df_width.json", "width", "Width"), +} + + def _is_finite_number(value: object) -> bool: return isinstance(value, int | float) and not isinstance(value, bool) and np.isfinite(value) -def _load_chart_json(filename: str, x_key: str, y_key: str) -> list[dict]: - """Load and validate chart data JSON structure.""" - data_dir = os.path.join(settings.BASE_DIR, "data") - path = os.path.join(data_dir, filename) - with open(path, encoding="utf-8") as file: +def _read_data_records(filename: str) -> list[dict]: + """Read a non-empty JSON list of objects from the data directory.""" + with open(os.path.join(HOME_DATA_DIR, filename), encoding="utf-8") as file: payload = json.load(file) + if not isinstance(payload, list) or not payload: + raise ValueError(f"{filename} must contain a non-empty JSON list") + if not all(isinstance(row, dict) for row in payload): + raise ValueError(f"{filename} must contain only JSON objects") + return payload - if not isinstance(payload, list): - raise ValueError(f"{filename} must contain a JSON list") - for idx, row in enumerate(payload): - if not isinstance(row, dict): - raise ValueError(f"{filename} row {idx} is not an object") - if x_key not in row or y_key not in row: - raise ValueError(f"{filename} row {idx} missing required keys") - if not isinstance(row[x_key], int | float) or not isinstance(row[y_key], int | float): - raise ValueError(f"{filename} row {idx} contains non-numeric values") +def _load_chart_json(filename: str, x_key: str, y_key: str) -> list[dict]: + """Load chart records, keeping only the two plotted keys, which must be finite numbers.""" + records = [] + for idx, row in enumerate(_read_data_records(filename)): + if not (_is_finite_number(row.get(x_key)) and _is_finite_number(row.get(y_key))): + raise ValueError(f"{filename} row {idx} needs numeric '{x_key}' and '{y_key}'") + records.append({x_key: row[x_key], y_key: row[y_key]}) + return records + + +def _load_hydrograph_json() -> list[dict]: + """Load the example timeseries, keeping the datetime and the plotted fields.""" + records = [] + for idx, row in enumerate(_read_data_records(HOME_HYDROGRAPH_FILE)): + values = [row.get(field) for field in HOME_HYDROGRAPH_FIELDS] + if not isinstance(row.get("datetime"), str) or not all(map(_is_finite_number, values)): + raise ValueError(f"{HOME_HYDROGRAPH_FILE} row {idx} is malformed") + plotted = dict(zip(HOME_HYDROGRAPH_FIELDS, values, strict=True)) + records.append({"datetime": row["datetime"], **plotted}) + return records + + +def _hydrograph_summary(records: list[dict], volume_factor: float) -> dict: + """Headline numbers of the example event, computed like a timeseries run's metrics.""" + frame = pd.DataFrame(records) + frame.index = pd.to_datetime(frame.pop("datetime")) + return { + "peak_rainfall": float(frame["rainfall"].max()), + **_hydrograph_metrics(frame, volume_factor), + } - return payload + +def _build_home_data() -> dict: + """Chart data (for main_view.js) and headline numbers (for the template).""" + flow_units = _read_flow_units(HOME_EXAMPLE_MODEL) + volume_factor, volume_unit = _runoff_volume_unit(flow_units) + hydrograph = _load_hydrograph_json() + time_label, flow_labels = _build_timeseries_axis_labels( + list(HOME_HYDROGRAPH_FIELDS), flow_units + ) + sweeps = {} + for key, (filename, x_field, feature) in HOME_SWEEPS.items(): + records = _load_chart_json(filename, x_field, "runoff") + x_label, y_labels = _build_simulation_axis_labels(feature, ["runoff"], flow_units) + sweeps[key] = { + "records": records, + "xField": x_field, + "xLabel": x_label, + "yLabel": y_labels["runoff"], + "xRange": [records[0][x_field], records[-1][x_field]], + "runoffRange": [records[0]["runoff"], records[-1]["runoff"]], + } + return { + "chart_data": { + "hydrograph": {"records": hydrograph, "xLabel": time_label, "yLabels": flow_labels}, + "sweeps": sweeps, + }, + "hydrograph_summary": _hydrograph_summary(hydrograph, volume_factor), + "units": {**_unit_labels(flow_units), "volume": volume_unit}, + } @lru_cache(maxsize=1) -def _load_static_chart_data_cached() -> dict: - """Load static chart data once per process.""" +def _load_home_data_cached() -> dict | None: + """Load the home page example data once per process; None when it is unavailable.""" try: - return { - "slope": _load_chart_json("df_slope.json", "slope", "runoff"), - "area": _load_chart_json("df_area.json", "area", "runoff"), - "width": _load_chart_json("df_width.json", "width", "runoff"), - } + return _build_home_data() except Exception: - logger.exception("Failed to load static chart data from JSON files") - return {"slope": [], "area": [], "width": []} + logger.exception("Failed to load the home page example data") + return None -def _load_static_chart_data() -> dict: +def _load_home_data() -> dict | None: """Return a defensive copy so cache data cannot be mutated by callers.""" - return deepcopy(_load_static_chart_data_cached()) + return deepcopy(_load_home_data_cached()) def _result_cache_key(scope: str, user_id: int, token: str) -> str: @@ -371,7 +432,10 @@ def _uploaded_model_path(request: HttpRequest) -> str: def main_view(request: HttpRequest) -> HttpResponse: """ - Render the main view with interactive plots. + Render the home page: example hydrograph, tool overview and package docs. + + ``home`` is None when the precomputed example data cannot be loaded; the + page then renders without the example charts. Parameters ---------- @@ -383,8 +447,7 @@ def main_view(request: HttpRequest) -> HttpResponse: HttpResponse The HTTP response with the rendered main view template. """ - context = {"chart_data": _load_static_chart_data()} - return render(request, "main/main_view.html", context) + return render(request, "main/main_view.html", {"home": _load_home_data()}) def about(request: HttpRequest) -> HttpResponse: From 9252015f19c81597b33d78761242017ac435c752 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Buczy=C5=84ski?= Date: Wed, 30 Sep 2026 09:46:25 +0200 Subject: [PATCH 13/18] restyle content and account pages and fix the contact and profile forms Contact redirected to a URL name that does not exist and reported success even when sending failed; the profile form listed every account and accepted another user's id. The interface text is now consistently English. --- .../cs_app/templates/registration/login.html | 24 +- cs_app/e2e/pages/about_page.py | 2 +- cs_app/e2e/pages/contact_page.py | 10 +- cs_app/e2e/pages/login_page.py | 13 +- cs_app/e2e/pages/register_page.py | 8 +- cs_app/e2e/pages/user_profile_page.py | 25 +- cs_app/e2e/test_about.py | 52 +++ cs_app/e2e/test_auth.py | 164 ++++++++-- cs_app/e2e/test_contact.py | 96 +++--- cs_app/e2e/test_profile.py | 87 +++--- cs_app/e2e/test_smoke_auth_redirect.py | 12 +- cs_app/main/forms.py | 40 ++- cs_app/main/static/main/css/content.css | 295 ++++++++++++++++++ cs_app/main/templates/main/about.html | 91 +++++- cs_app/main/templates/main/contact.html | 24 +- cs_app/main/templates/main/userprofile.html | 51 ++- cs_app/main/tests/test_async_views.py | 82 ++++- cs_app/main/tests/test_content_pages.py | 125 +++++++- cs_app/main/views.py | 9 +- .../register/templates/account/register.html | 22 +- 20 files changed, 1058 insertions(+), 174 deletions(-) create mode 100644 cs_app/e2e/test_about.py create mode 100644 cs_app/main/static/main/css/content.css diff --git a/cs_app/cs_app/templates/registration/login.html b/cs_app/cs_app/templates/registration/login.html index c1ff34e..f5e1ed3 100644 --- a/cs_app/cs_app/templates/registration/login.html +++ b/cs_app/cs_app/templates/registration/login.html @@ -1,13 +1,25 @@ {% extends "base.html" %} -{% load crispy_forms_tags %} +{% load crispy_forms_tags static %} -{% block title %} Logowanie {% endblock %} +{% block title %}Log in — Catchment Simulation{% endblock %} + +{% block style %} + +{% endblock %} {% block content %} -
+
+

Welcome back

+

Log in

+

Log in to run simulations on your own SWMM models.

+ + {% csrf_token %} {{ form|crispy }} -
- + + -{% endblock %} \ No newline at end of file + +

New here? Create an account

+
+{% endblock %} diff --git a/cs_app/e2e/pages/about_page.py b/cs_app/e2e/pages/about_page.py index f30dfa2..c4ab182 100644 --- a/cs_app/e2e/pages/about_page.py +++ b/cs_app/e2e/pages/about_page.py @@ -14,7 +14,7 @@ def navigate_to(self) -> None: self.navigate(self.PATH) def get_main_heading(self) -> str | None: - return self.get_heading(level=2) + return self.get_heading(level=1) def get_body_text(self) -> str: return self.page.locator(".content-wrapper").inner_text() diff --git a/cs_app/e2e/pages/contact_page.py b/cs_app/e2e/pages/contact_page.py index 696012a..73ccf10 100644 --- a/cs_app/e2e/pages/contact_page.py +++ b/cs_app/e2e/pages/contact_page.py @@ -10,11 +10,7 @@ class ContactPage(BasePage): """POM for the contact form page (``/contact``). - Form labels are in Polish: - - email → "Adres email" - - title → "Tytuł" - - content → "Treść" - - submit → "Wyślij" + Labels: "Email", "Subject", "Message", "Send me a copy"; submit "Send message". """ PATH = "/contact" @@ -44,7 +40,7 @@ def send_to_me_checkbox(self) -> Locator: @property def submit_button(self) -> Locator: - return self.page.get_by_role("button", name="Wyślij") + return self.page.get_by_role("button", name="Send message", exact=True) def fill_form(self, email: str, title: str, content: str, *, send_to_me: bool = False) -> None: self.email_input.fill(email) @@ -64,4 +60,4 @@ def has_form(self) -> bool: return self.email_input.is_visible() and self.title_input.is_visible() def get_error_messages(self) -> list[str]: - return self.page.locator(".errorlist li").all_inner_texts() + return self.page.locator(".invalid-feedback").all_inner_texts() diff --git a/cs_app/e2e/pages/login_page.py b/cs_app/e2e/pages/login_page.py index 24cbe9d..3cacff1 100644 --- a/cs_app/e2e/pages/login_page.py +++ b/cs_app/e2e/pages/login_page.py @@ -29,7 +29,11 @@ def password_input(self) -> Locator: @property def submit_button(self) -> Locator: - return self.page.get_by_role("button", name="Login") + return self.page.get_by_role("button", name="Log in", exact=True) + + @property + def password_toggle(self) -> Locator: + return self.page.get_by_role("button", name="Show password", exact=True) def fill_credentials(self, username: str, password: str) -> None: self.username_input.fill(username) @@ -51,8 +55,5 @@ def has_form(self) -> bool: return self.username_input.is_visible() and self.password_input.is_visible() def get_error_messages(self) -> list[str]: - """Return non-field error messages shown by crispy forms. - - Uses ``all_inner_texts()`` to avoid race conditions. - """ - return self.page.locator(".errorlist li").all_inner_texts() + """Return the non-field error messages (wrong credentials) shown above the fields.""" + return self.page.locator("form .alert-danger li").all_inner_texts() diff --git a/cs_app/e2e/pages/register_page.py b/cs_app/e2e/pages/register_page.py index 1674eaf..9065b9b 100644 --- a/cs_app/e2e/pages/register_page.py +++ b/cs_app/e2e/pages/register_page.py @@ -46,7 +46,10 @@ def password2_input(self) -> Locator: @property def submit_button(self) -> Locator: - return self.page.get_by_role("button", name="Register") + return self.page.get_by_role("button", name="Create account", exact=True) + + def password_toggle(self, label: str = "password") -> Locator: + return self.page.get_by_role("button", name=f"Show {label}", exact=True) def fill_form( self, @@ -88,4 +91,5 @@ def has_form(self) -> bool: return self.username_input.is_visible() and self.password1_input.is_visible() def get_error_messages(self) -> list[str]: - return self.page.locator(".errorlist li").all_inner_texts() + """Inline field errors rendered under the inputs.""" + return self.page.locator(".invalid-feedback").all_inner_texts() diff --git a/cs_app/e2e/pages/user_profile_page.py b/cs_app/e2e/pages/user_profile_page.py index e3d760c..a56bf5d 100644 --- a/cs_app/e2e/pages/user_profile_page.py +++ b/cs_app/e2e/pages/user_profile_page.py @@ -10,29 +10,36 @@ class UserProfilePage(BasePage): """POM for the user profile page (``/user//profile``). - The profile view is public for GET, but the form is read-only when - viewed by a user other than the owner (fields disabled, submit hidden). - Only the owner can POST updates. + The profile view is public for GET. The owner gets an editable bio form; + everyone else sees the bio as text, without a form or submit button. """ def navigate_to(self, user_id: int) -> None: self.navigate(f"/user/{user_id}/profile") # ------------------------------------------------------------------ - # Form fields + # Elements # ------------------------------------------------------------------ @property - def user_field(self) -> Locator: - return self.page.locator("#id_user") + def profile_name(self) -> Locator: + return self.page.locator("#profile-name") + + @property + def profile_meta(self) -> Locator: + return self.page.locator(".profile-card__meta") @property def bio_field(self) -> Locator: return self.page.locator("#id_bio") + @property + def bio_text(self) -> Locator: + return self.page.locator(".profile-card__bio") + @property def submit_button(self) -> Locator: - return self.page.get_by_role("button", name="Wyślij") + return self.page.get_by_role("button", name="Save profile", exact=True) # ------------------------------------------------------------------ # Actions @@ -52,8 +59,8 @@ def has_form(self) -> bool: return self.bio_field.is_visible() def is_read_only(self) -> bool: - """Check if the form is in read-only mode (fields disabled).""" - return self.bio_field.is_disabled() + """The bio is shown as text and there is no form to edit it.""" + return self.bio_field.count() == 0 and self.bio_text.is_visible() def has_submit_button(self) -> bool: return self.submit_button.count() > 0 and self.submit_button.is_visible() diff --git a/cs_app/e2e/test_about.py b/cs_app/e2e/test_about.py new file mode 100644 index 0000000..8618b1c --- /dev/null +++ b/cs_app/e2e/test_about.py @@ -0,0 +1,52 @@ +"""About page tests.""" + +from __future__ import annotations + +import re + +import pytest +from playwright.sync_api import Page, expect + +from .pages.about_page import AboutPage + +pytestmark = pytest.mark.e2e + + +@pytest.fixture() +def about(page: Page, live_server) -> AboutPage: + ap = AboutPage(page, live_server.url) + ap.navigate_to() + return ap + + +def test_heading_and_sections(about: AboutPage) -> None: + assert about.get_main_heading() == "About" + for heading in ("What it is", "Who it is for", "How it works", "Open source"): + expect(about.page.get_by_role("heading", level=2, name=heading, exact=True)).to_be_visible() + + +def test_open_source_links(about: AboutPage) -> None: + main = about.page.locator("main") + expected = { + "GitHub": "https://github.com/BuczynskiRafal/catchments_simulation", + "PyPI": "https://pypi.org/project/catchment-simulation/", + "Documentation": "https://catchments-simulation.readthedocs.io", + } + for name, href in expected.items(): + expect(main.get_by_role("link", name=re.compile(f"^{name}"))).to_have_attribute( + "href", href + ) + + +def test_contact_call_to_action(about: AboutPage) -> None: + about.page.locator("main").get_by_role("link", name="Get in touch", exact=True).click() + expect(about.page).to_have_url(re.compile(r".*/contact$")) + + +@pytest.mark.parametrize("scheme", ["light", "dark"]) +def test_no_serious_axe_violations(page: Page, live_server, scheme: str) -> None: + page.emulate_media(color_scheme=scheme) + ap = AboutPage(page, live_server.url) + ap.navigate_to() + report = ap.run_axe_audit() + assert not report.critical + report.serious, [v.id for v in report.critical + report.serious] diff --git a/cs_app/e2e/test_auth.py b/cs_app/e2e/test_auth.py index 1c50733..1fbf2fd 100644 --- a/cs_app/e2e/test_auth.py +++ b/cs_app/e2e/test_auth.py @@ -38,7 +38,7 @@ def test_register_success_redirects_to_home(self, page: Page, live_server, db) - password1=STRONG_ALT_PASSWORD, password2=STRONG_ALT_PASSWORD, ) - expect(page).to_have_url(re.compile(r".*/$")) + expect(page).to_have_url(f"{live_server.url}/") def test_register_mismatched_passwords_shows_error(self, page: Page, live_server, db) -> None: rp = RegisterPage(page, live_server.url) @@ -51,14 +51,8 @@ def test_register_mismatched_passwords_shows_error(self, page: Page, live_server password1=STRONG_ALT_PASSWORD, password2=OTHER_USER_PASSWORD, ) - page.wait_for_load_state("domcontentloaded") - # After invalid submit, we should still be on the register page - expect(page).to_have_url(re.compile(r".*/register/")) - # Page body should contain error feedback about passwords - body_text = page.locator("body").inner_text() - assert ( - "password" in body_text.lower() or "match" in body_text.lower() - ), "Expected password mismatch error feedback on page" + expect(page.locator("#error_1_id_password2")).to_contain_text("didn’t match") + expect(page).to_have_url(re.compile(r".*/register/$")) def test_register_duplicate_username_shows_error( self, page: Page, live_server, test_user @@ -74,10 +68,8 @@ def test_register_duplicate_username_shows_error( password1=STRONG_ALT_PASSWORD, password2=STRONG_ALT_PASSWORD, ) - page.wait_for_load_state("domcontentloaded") - expect(page).to_have_url(re.compile(r".*/register/")) - body_text = page.locator("body").inner_text() - assert "username" in body_text.lower() or "already" in body_text.lower() + expect(page.locator("#error_1_id_username")).to_contain_text("already exists") + expect(page).to_have_url(re.compile(r".*/register/$")) def test_register_weak_password_shows_error(self, page: Page, live_server, db) -> None: """Too simple password should be rejected by Django validators.""" @@ -91,15 +83,51 @@ def test_register_weak_password_shows_error(self, page: Page, live_server, db) - password1="123", password2="123", ) - page.wait_for_load_state("domcontentloaded") - expect(page).to_have_url(re.compile(r".*/register/")) - body_text = page.locator("body").inner_text() - assert "password" in body_text.lower() + expect(page.locator("#error_1_id_password2")).to_contain_text("too short") + expect(page).to_have_url(re.compile(r".*/register/$")) def test_register_page_title(self, page: Page, live_server) -> None: rp = RegisterPage(page, live_server.url) rp.navigate_to() - assert "Register" in rp.get_title() + assert "Create account" in rp.get_title() + assert rp.get_heading(level=1) == "Create account" + + def test_register_success_logs_the_user_in(self, page: Page, live_server, db) -> None: + rp = RegisterPage(page, live_server.url) + rp.navigate_to() + rp.register( + "fresh", "fresh@example.com", "Fresh", "User", STRONG_ALT_PASSWORD, STRONG_ALT_PASSWORD + ) + + expect(page.locator(".alert-success")).to_contain_text("Welcome, Fresh.") + assert NavComponent(page).is_logged_in() + + def test_invalid_fields_are_marked_inline_and_focused( + self, page: Page, live_server, db + ) -> None: + rp = RegisterPage(page, live_server.url) + rp.navigate_to() + rp.register( + "mismatch2", "m2@example.com", "Mis", "Match", STRONG_ALT_PASSWORD, OTHER_USER_PASSWORD + ) + + feedback = page.locator("#error_1_id_password2") + expect(feedback).to_be_visible() + expect(feedback).to_contain_text("didn’t match") + expect(rp.password2_input).to_have_attribute("aria-invalid", "true") + expect(rp.password2_input).to_have_attribute( + "aria-describedby", re.compile(r"\berror_1_id_password2\b") + ) + expect(rp.password2_input).to_be_focused() + # Valid fields keep their value and are not flagged. + expect(rp.email_input).to_have_value("m2@example.com") + expect(rp.email_input).not_to_have_attribute("aria-invalid", "true") + + def test_links_to_login(self, page: Page, live_server) -> None: + rp = RegisterPage(page, live_server.url) + rp.navigate_to() + page.locator("main").get_by_role("link", name="Log in", exact=True).click() + expect(page).to_have_url(re.compile(r".*/accounts/login/$")) class TestLogin: @@ -115,27 +143,105 @@ def test_login_success_redirects(self, page: Page, live_server, test_user) -> No lp.navigate_to() lp.login(TEST_USERNAME, TEST_PASSWORD) # Should redirect to home after successful login - expect(page).to_have_url(re.compile(r".*/$")) + expect(page).to_have_url(f"{live_server.url}/") def test_login_invalid_credentials_shows_error(self, page: Page, live_server, db) -> None: lp = LoginPage(page, live_server.url) lp.navigate_to() lp.login("nonexistent", "wrongpass") - page.wait_for_load_state("domcontentloaded") - # Should stay on login page - expect(page).to_have_url(re.compile(r".*/accounts/login/")) - # The login page should contain error feedback - content = page.locator(".content-wrapper").inner_text() - assert ( - "username" in content.lower() - or "password" in content.lower() - or "correct" in content.lower() + expect(page.locator("form .alert-danger")).to_contain_text( + "Please enter a correct username or email and password" ) + expect(page).to_have_url(re.compile(r".*/accounts/login/$")) def test_login_page_title(self, page: Page, live_server) -> None: lp = LoginPage(page, live_server.url) lp.navigate_to() - assert "Logowanie" in lp.get_title() + assert "Log in" in lp.get_title() + assert lp.get_heading(level=1) == "Log in" + + def test_wrong_credentials_error_is_tied_to_the_form(self, page: Page, live_server, db) -> None: + lp = LoginPage(page, live_server.url) + lp.navigate_to() + lp.login("nobody", "wrong-password") + + assert any("correct username or email and password" in m for m in lp.get_error_messages()) + error_id = page.locator("form .alert-danger").get_attribute("id") + expect(lp.username_input).to_have_attribute( + "aria-describedby", re.compile(rf"\b{error_id}\b") + ) + expect(lp.username_input).to_be_focused() + + def test_login_returns_to_next_page(self, page: Page, live_server, test_user) -> None: + page.goto(f"{live_server.url}/accounts/login/?next=/about") + LoginPage(page, live_server.url).login(TEST_USERNAME, TEST_PASSWORD) + expect(page).to_have_url(re.compile(r".*/about$")) + + def test_links_to_register(self, page: Page, live_server) -> None: + lp = LoginPage(page, live_server.url) + lp.navigate_to() + page.get_by_role("link", name="Create an account", exact=True).click() + expect(page).to_have_url(re.compile(r".*/register/$")) + + +class TestPasswordToggle: + def test_toggle_reveals_and_hides_password(self, page: Page, live_server) -> None: + lp = LoginPage(page, live_server.url) + lp.navigate_to() + lp.password_input.fill(STRONG_ALT_PASSWORD) + toggle = lp.password_toggle + expect(toggle).to_have_attribute("aria-pressed", "false") + expect(toggle).to_have_attribute("aria-controls", "id_password") + + toggle.click() + expect(lp.password_input).to_have_attribute("type", "text") + expect(toggle).to_have_attribute("aria-pressed", "true") + + toggle.press("Enter") + expect(lp.password_input).to_have_attribute("type", "password") + expect(toggle).to_have_attribute("aria-pressed", "false") + expect(lp.password_input).to_have_value(STRONG_ALT_PASSWORD) + + def test_each_register_password_gets_its_own_toggle(self, page: Page, live_server) -> None: + rp = RegisterPage(page, live_server.url) + rp.navigate_to() + rp.password_toggle("password confirmation").click() + + expect(rp.password2_input).to_have_attribute("type", "text") + expect(rp.password1_input).to_have_attribute("type", "password") + + def test_revealed_password_is_hidden_again_on_submit(self, page: Page, live_server) -> None: + lp = LoginPage(page, live_server.url) + lp.navigate_to() + lp.fill_credentials("someone", STRONG_ALT_PASSWORD) + lp.password_toggle.click() + # Registered after the page's handler, so it observes the state it leaves behind. + page.evaluate( + """() => document.querySelector("form[data-cs-form]").addEventListener("submit", (e) => { + e.preventDefault(); + window.__typeOnSubmit = document.getElementById("id_password").type; + })""" + ) + + lp.submit() + + assert page.evaluate("window.__typeOnSubmit") == "password" + expect(lp.password_toggle).to_have_attribute("aria-pressed", "false") + + +class TestAuthA11y: + @pytest.mark.parametrize("scheme", ["light", "dark"]) + @pytest.mark.parametrize("path", ["/accounts/login/", "/register/"]) + def test_no_serious_axe_violations( + self, page: Page, live_server, db, scheme: str, path: str + ) -> None: + page.emulate_media(color_scheme=scheme) + lp = LoginPage(page, live_server.url) + lp.navigate(path) + report = lp.run_axe_audit() + assert not report.critical + report.serious, [ + v.id for v in report.critical + report.serious + ] class TestLogout: diff --git a/cs_app/e2e/test_contact.py b/cs_app/e2e/test_contact.py index 483c16e..b007cf8 100644 --- a/cs_app/e2e/test_contact.py +++ b/cs_app/e2e/test_contact.py @@ -12,54 +12,66 @@ pytestmark = pytest.mark.e2e +@pytest.fixture() +def contact(page: Page, live_server) -> ContactPage: + cp = ContactPage(page, live_server.url) + cp.navigate_to() + return cp + + class TestContactForm: """Contact form rendering and submission.""" - def test_form_renders_with_polish_labels(self, page: Page, live_server) -> None: - cp = ContactPage(page, live_server.url) - cp.navigate_to() - assert cp.has_form() - # Labels are in Polish - expect(page.locator("label", has_text="Adres email")).to_be_visible() - expect(page.locator("label", has_text="Tytuł")).to_be_visible() - expect(page.locator("label", has_text="Treść")).to_be_visible() + def test_form_renders_with_english_labels(self, contact: ContactPage) -> None: + assert contact.has_form() + page = contact.page + expect(page.get_by_label("Email")).to_be_visible() + expect(page.get_by_label("Subject")).to_be_visible() + expect(page.get_by_label("Message")).to_be_visible() + expect(page.get_by_label("Send me a copy")).to_be_visible() - def test_submit_button_text_is_polish(self, page: Page, live_server) -> None: - cp = ContactPage(page, live_server.url) - cp.navigate_to() - expect(page.get_by_role("button", name="Wyślij")).to_be_visible() - - def test_valid_submission_posts_form(self, page: Page, live_server, db) -> None: - """After valid POST the view attempts ``HttpResponseRedirect(reverse('contact'))``. - - Note: The view has a bug — it uses ``reverse('contact')`` instead of - ``reverse('main:contact')``, which returns a 500 in test mode. This - test therefore only verifies the form submits without client-side - validation errors (i.e. all required fields are filled and the POST - is sent). Once the view bug is fixed, this test should be updated - to verify the redirect. - """ - cp = ContactPage(page, live_server.url) - cp.navigate_to() - cp.fill_form( - email="test@example.com", - title="Test Subject", - content="Test message body.", + def test_submit_button_text(self, contact: ContactPage) -> None: + expect(contact.submit_button).to_be_visible() + + def test_valid_submission_redirects_with_success_message( + self, contact: ContactPage, db + ) -> None: + contact.fill_form( + email="test@example.com", title="Test Subject", content="Test message body." ) - cp.submit() - page.wait_for_load_state("domcontentloaded") - # The form was submitted — we reached the server (not blocked by HTML5 validation) - # In current state this returns 500 due to the reverse() bug. + contact.submit() - def test_empty_submission_shows_validation_errors(self, page: Page, live_server) -> None: - cp = ContactPage(page, live_server.url) - cp.navigate_to() - cp.submit() - # HTML5 validation or crispy form errors should prevent empty submission - # The form should still be on the contact page - expect(page).to_have_url(re.compile(r".*/contact")) + expect(contact.page).to_have_url(re.compile(r".*/contact$")) + expect(contact.page.locator(".alert-success")).to_contain_text("Message sent.") + expect(contact.email_input).to_have_value("") + + def test_empty_submission_is_blocked_by_the_browser(self, contact: ContactPage) -> None: + contact.submit() + expect(contact.page).to_have_url(re.compile(r".*/contact$")) + expect(contact.page.locator(".alert-success")).to_have_count(0) + + def test_server_errors_are_inline_and_focused(self, contact: ContactPage, db) -> None: + """Server-side validation (the browser's is bypassed) marks and focuses the field.""" + page = contact.page + page.evaluate("() => document.querySelector('form[data-cs-form]').noValidate = true") + contact.fill_form(email="not-an-email", title="Subject", content="Body") + contact.submit() + + expect(page.locator("#error_1_id_email")).to_contain_text("Enter a valid email address.") + expect(contact.email_input).to_have_attribute("aria-invalid", "true") + expect(contact.email_input).to_be_focused() + expect(contact.content_input).to_have_value("Body") + + def test_page_title_and_heading(self, contact: ContactPage) -> None: + assert "Contact" in contact.get_title() + assert contact.get_heading(level=1) == "Contact" - def test_page_title(self, page: Page, live_server) -> None: + @pytest.mark.parametrize("scheme", ["light", "dark"]) + def test_no_serious_axe_violations(self, page: Page, live_server, scheme: str) -> None: + page.emulate_media(color_scheme=scheme) cp = ContactPage(page, live_server.url) cp.navigate_to() - assert "Kontakt" in cp.get_title() + report = cp.run_axe_audit() + assert not report.critical + report.serious, [ + v.id for v in report.critical + report.serious + ] diff --git a/cs_app/e2e/test_profile.py b/cs_app/e2e/test_profile.py index 2f75782..9e73abb 100644 --- a/cs_app/e2e/test_profile.py +++ b/cs_app/e2e/test_profile.py @@ -1,71 +1,76 @@ """User profile page tests. Profile page (``/user//profile``) is public for GET, but only the -owner can edit and submit the form. Other users see a read-only view. +owner can edit the bio. Everyone else sees it as text. """ from __future__ import annotations +import re + import pytest -from playwright.sync_api import Page +from django.contrib.auth.models import User +from playwright.sync_api import Page, expect -from .conftest import OTHER_USER_PASSWORD +from .conftest import OTHER_USER_PASSWORD, TEST_EMAIL, TEST_FIRST_NAME, TEST_LAST_NAME from .pages.user_profile_page import UserProfilePage pytestmark = pytest.mark.e2e -class TestProfileRendering: - """Profile page renders for the owner.""" +@pytest.fixture() +def other_user(db) -> User: + return User.objects.create_user( + username="other_user", email="other@example.com", password=OTHER_USER_PASSWORD + ) + - def test_own_profile_shows_form(self, auth_page: Page, live_server, test_user) -> None: +class TestOwnProfile: + def test_shows_identity_and_editable_bio(self, auth_page: Page, live_server, test_user) -> None: pp = UserProfilePage(auth_page, live_server.url) pp.navigate_to(test_user.id) + + assert pp.get_heading(level=1) == "Profile" + expect(pp.profile_name).to_have_text(f"{TEST_FIRST_NAME} {TEST_LAST_NAME}") + expect(pp.profile_meta).to_contain_text(TEST_EMAIL) assert pp.has_form() + assert not pp.is_read_only() + assert pp.has_submit_button() + expect(auth_page.locator("#id_user")).to_have_count(0) - def test_own_profile_has_submit_button(self, auth_page: Page, live_server, test_user) -> None: + def test_save_bio_round_trip(self, auth_page: Page, live_server, test_user) -> None: pp = UserProfilePage(auth_page, live_server.url) pp.navigate_to(test_user.id) - assert pp.has_submit_button() - def test_own_profile_fields_are_editable(self, auth_page: Page, live_server, test_user) -> None: + pp.fill_bio("Urban drainage engineer.") + pp.submit() + + expect(auth_page).to_have_url(re.compile(rf".*/user/{test_user.id}/profile$")) + expect(pp.bio_field).to_have_value("Urban drainage engineer.") + + def test_page_title(self, auth_page: Page, live_server, test_user) -> None: pp = UserProfilePage(auth_page, live_server.url) pp.navigate_to(test_user.id) - assert not pp.is_read_only() + assert "Profile" in pp.get_title() -class TestProfileReadOnly: - """Other users see the profile in read-only mode.""" - +class TestOtherProfile: def test_other_user_profile_is_read_only( - self, auth_page: Page, live_server, test_user, db + self, auth_page: Page, live_server, test_user, other_user ) -> None: - """Create a second user and view their profile — should be read-only.""" - from django.contrib.auth.models import User - - other = User.objects.create_user( - username="other_user", - email="other@example.com", - password=OTHER_USER_PASSWORD, - ) pp = UserProfilePage(auth_page, live_server.url) - pp.navigate_to(other.id) - assert pp.has_form() + pp.navigate_to(other_user.id) + expect(pp.profile_name).to_have_text("other_user") assert pp.is_read_only() + expect(pp.bio_text).to_have_text("No bio yet.") - def test_other_user_profile_hides_submit( - self, auth_page: Page, live_server, test_user, db + def test_other_user_profile_hides_submit_and_email( + self, auth_page: Page, live_server, test_user, other_user ) -> None: - from django.contrib.auth.models import User - - other = User.objects.create_user( - username="other_user2", - email="other2@example.com", - password=OTHER_USER_PASSWORD, - ) pp = UserProfilePage(auth_page, live_server.url) - pp.navigate_to(other.id) + pp.navigate_to(other_user.id) assert not pp.has_submit_button() + expect(pp.profile_meta).not_to_contain_text("other@example.com") class TestProfileAnonymous: @@ -74,5 +79,17 @@ class TestProfileAnonymous: def test_anonymous_can_view_profile(self, page: Page, live_server, test_user) -> None: pp = UserProfilePage(page, live_server.url) pp.navigate_to(test_user.id) - assert pp.has_form() + expect(pp.profile_name).to_be_visible() assert pp.is_read_only() + + @pytest.mark.parametrize("scheme", ["light", "dark"]) + def test_no_serious_axe_violations( + self, page: Page, live_server, test_user, scheme: str + ) -> None: + page.emulate_media(color_scheme=scheme) + pp = UserProfilePage(page, live_server.url) + pp.navigate_to(test_user.id) + report = pp.run_axe_audit() + assert not report.critical + report.serious, [ + v.id for v in report.critical + report.serious + ] diff --git a/cs_app/e2e/test_smoke_auth_redirect.py b/cs_app/e2e/test_smoke_auth_redirect.py index af8c6fd..ad8fce6 100644 --- a/cs_app/e2e/test_smoke_auth_redirect.py +++ b/cs_app/e2e/test_smoke_auth_redirect.py @@ -11,23 +11,25 @@ def _assert_login_redirect(page: Page, expected_next: str) -> None: parsed = urlparse(page.url) assert parsed.path == "/accounts/login/" assert parse_qs(parsed.query).get("next") == [expected_next] - expect(page.get_by_role("button", name="Login")).to_be_visible() + expect(page.get_by_role("button", name="Log in", exact=True)).to_be_visible() def test_home_page_smoke(page: Page, live_server) -> None: page.goto(f"{live_server.url}/") - expect(page.get_by_role("heading", name="Catchment Simulation")).to_be_visible() - expect(page.get_by_role("link", name="Simulation")).to_be_visible() + expect(page.get_by_role("heading", level=1)).to_be_visible() + header = page.locator("header") + expect(header.get_by_role("link", name="Catchment Simulation home", exact=True)).to_be_visible() + expect(header.get_by_role("link", name="Simulation", exact=True)).to_be_visible() def test_navigation_to_about(page: Page, live_server) -> None: page.goto(f"{live_server.url}/") - page.get_by_role("link", name="About").first.click() + page.locator("header").get_by_role("link", name="About", exact=True).click() expect(page).to_have_url(re.compile(r".*/about$")) - expect(page.get_by_role("heading", name="About")).to_be_visible() + expect(page.get_by_role("heading", level=1, name="About")).to_be_visible() def test_simulation_redirects_anonymous_user_to_login(page: Page, live_server) -> None: diff --git a/cs_app/main/forms.py b/cs_app/main/forms.py index 1cae8bf..6c9b938 100644 --- a/cs_app/main/forms.py +++ b/cs_app/main/forms.py @@ -10,7 +10,7 @@ """ from crispy_forms.helper import FormHelper -from crispy_forms.layout import Submit +from crispy_forms.layout import Layout, Submit from django import forms from .models import UserProfile @@ -30,17 +30,23 @@ class ContactForm(forms.Form): send_to_me: A boolean field to indicate whether to send the message to the sender. """ - email = forms.EmailField(label="Adres email") - title = forms.CharField(label="Tytuł") - content = forms.CharField(widget=forms.Textarea, label="Treść") - send_to_me = forms.BooleanField(required=False, label="Prześlij") + # Length limits mirror main.schemas.ContactMessage. + email = forms.EmailField( + label="Email", widget=forms.EmailInput(attrs={"autocomplete": "email"}) + ) + title = forms.CharField(label="Subject", max_length=200) + content = forms.CharField( + label="Message", max_length=5000, widget=forms.Textarea(attrs={"rows": 6}) + ) + send_to_me = forms.BooleanField(required=False, label="Send me a copy") def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.helper = FormHelper() self.helper.form_method = "post" - self.helper.form_action = "contact" - self.helper.add_input(Submit("submit", "Wyślij")) + self.helper.form_action = "main:contact" + self.helper.attrs = {"data-cs-form": ""} # progressive enhancement hook (pages/forms.js) + self.helper.add_input(Submit("submit", "Send message")) class UserProfileForm(forms.ModelForm): @@ -55,13 +61,29 @@ class UserProfileForm(forms.ModelForm): class Meta: model = UserProfile fields = ["user", "bio"] + labels = {"bio": "Bio"} + help_texts = { + "bio": "A few words about you and your work. Anyone with the link can read it." + } + widgets = {"bio": forms.Textarea(attrs={"rows": 5})} def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + # The profile's account comes from the URL (via instance or initial), never + # from the request: the field is locked to that one account and not rendered. + initial_user = self.initial.get("user") + owner_pk = self.instance.user_id or getattr(initial_user, "pk", initial_user) + user_field = self.fields["user"] + user_field.disabled = True + user_field.queryset = user_field.queryset.filter(pk=owner_pk) + self.owner = user_field.queryset.first() + self.helper = FormHelper() + # No form_action: the form posts back to the profile URL it was rendered on. self.helper.form_method = "post" - self.helper.form_action = "userprofile" - self.helper.add_input(Submit("submit", "Wyślij")) + self.helper.attrs = {"data-cs-form": ""} # progressive enhancement hook (pages/forms.js) + self.helper.layout = Layout("bio") + self.helper.add_input(Submit("submit", "Save profile")) class SimulationForm(forms.Form): diff --git a/cs_app/main/static/main/css/content.css b/cs_app/main/static/main/css/content.css new file mode 100644 index 0000000..9973b5c --- /dev/null +++ b/cs_app/main/static/main/css/content.css @@ -0,0 +1,295 @@ +/* + * Content and account pages: About, Contact, Profile, Log in, Create account. + * Shared components (.cs-page-header, .cs-card, .cs-step__num) come from app.css. + */ + +/* ------------------------------------------------------------------ */ +/* Page frame */ +/* ------------------------------------------------------------------ */ + +.content-page { + max-width: 60rem; + margin-inline: auto; +} + +.content-page--form { + max-width: 38rem; +} + +.content-page__aside { + margin: 1.25rem 0 0; + color: var(--cs-ink-muted); + font-size: var(--cs-fs-small); +} + +/* Crispy renders the submit as input.btn inside div.mb-3; keep it snug under the fields. */ +[data-cs-form] .btn[type="submit"] { + min-width: 10rem; +} + +/* ------------------------------------------------------------------ */ +/* Log in / Create account */ +/* ------------------------------------------------------------------ */ + +.auth-card { + max-width: 28rem; + margin: 1rem auto 0; + padding: 2rem; +} + +@media (max-width: 575.98px) { + .auth-card { + margin-top: 0; + padding: 1.5rem 1.25rem; + } +} + +.auth-card__title { + margin-bottom: 0.5rem; + font-size: var(--cs-fs-h2); +} + +.auth-card__lead { + margin-bottom: 1.5rem; + color: var(--cs-ink-muted); +} + +.auth-card__form .form-text ul { + margin: 0.25rem 0 0; + padding-left: 1.125rem; +} + +.auth-card__switch { + margin: 1.5rem 0 0; + padding-top: 1.25rem; + border-top: 1px solid var(--cs-line); + color: var(--cs-ink-muted); + text-align: center; +} + +/* Password visibility toggle added by pages/forms.js. */ +.cs-password-toggle { + display: inline-flex; + align-items: center; + border-color: var(--cs-field-border); + color: var(--cs-ink-muted); +} + +.cs-password-toggle svg { + width: 1.125rem; + height: 1.125rem; +} + +.cs-password-toggle .cs-password-toggle__hide, +.cs-password-toggle[aria-pressed="true"] .cs-password-toggle__show { + display: none; +} + +.cs-password-toggle[aria-pressed="true"] .cs-password-toggle__hide { + display: inline; +} + +/* ------------------------------------------------------------------ */ +/* Profile */ +/* ------------------------------------------------------------------ */ + +.profile-card__identity { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1.5rem; + padding-bottom: 1.5rem; + border-bottom: 1px solid var(--cs-line); +} + +.profile-card__avatar { + display: inline-grid; + place-items: center; + flex: none; + width: 3.5rem; + height: 3.5rem; + border-radius: 50%; + background-color: var(--cs-water); + color: var(--cs-on-accent); + font-family: var(--cs-font-display); + font-size: 1.5rem; + font-weight: 700; +} + +.profile-card__name { + margin: 0 0 0.25rem; + font-size: var(--cs-fs-h3); + overflow-wrap: anywhere; +} + +.profile-card__meta { + display: flex; + flex-direction: column; + margin: 0; + color: var(--cs-ink-muted); + font-size: var(--cs-fs-small); + overflow-wrap: anywhere; +} + +/* One line with decorative separators where it fits; stacked on phones. */ +@media (min-width: 576px) { + .profile-card__meta { + flex-direction: row; + gap: 0.5rem; + } + + .profile-card__meta > span + span::before { + content: "·" / ""; + margin-right: 0.5rem; + } +} + +.profile-card__section-title { + margin-bottom: 0.5rem; + color: var(--cs-ink-muted); + font-family: var(--cs-font-body); + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.profile-card__bio { + margin: 0; + white-space: normal; + overflow-wrap: anywhere; +} + +.profile-card__bio--empty { + color: var(--cs-ink-muted); +} + +/* ------------------------------------------------------------------ */ +/* About */ +/* ------------------------------------------------------------------ */ + +.about-grid { + display: grid; + gap: 0 3rem; +} + +@media (min-width: 768px) { + .about-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.about-section { + margin-bottom: 2.5rem; +} + +.about-section--wide { + padding-top: 2.5rem; + border-top: 1px solid var(--cs-line); +} + +.about-section__title { + margin-bottom: 0.75rem; + font-size: var(--cs-fs-h3); +} + +.about-section p { + max-width: 68ch; +} + +.about-steps { + display: grid; + gap: 1.5rem; + margin: 1.25rem 0 0; + padding: 0; + list-style: none; +} + +@media (min-width: 768px) { + .about-steps { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1.75rem 3rem; + } +} + +.about-steps__item { + display: flex; + gap: 1rem; +} + +.about-steps__title { + margin: 0.125rem 0 0.25rem; + font-family: var(--cs-font-body); + font-size: 1rem; + font-weight: 600; + font-stretch: normal; + letter-spacing: 0; +} + +.about-steps__item p { + margin: 0; + color: var(--cs-ink-muted); +} + +.about-links { + display: grid; + gap: 1rem; + margin: 1.25rem 0 0; + padding: 0; + list-style: none; +} + +@media (min-width: 768px) { + .about-links { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +.about-links__item { + display: flex; + flex-direction: column; + gap: 0.25rem; + height: 100%; + padding: 1rem 1.25rem; + border: 1px solid var(--cs-line); + border-radius: var(--cs-radius); + background-color: var(--cs-surface); + color: var(--cs-ink); + text-decoration: none; +} + +.about-links__item:hover { + border-color: var(--cs-water); +} + +.about-links__name { + color: var(--cs-water-strong); + font-weight: 600; + text-decoration: underline; + text-underline-offset: 0.2em; +} + +.about-links__text { + color: var(--cs-ink-muted); + font-size: var(--cs-fs-small); + overflow-wrap: anywhere; +} + +.about-cta { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 1rem 2rem; + margin-top: 1rem; +} + +.about-cta__title { + margin-bottom: 0.25rem; + font-size: var(--cs-fs-h4); +} + +.about-cta__text { + margin: 0; + color: var(--cs-ink-muted); +} diff --git a/cs_app/main/templates/main/about.html b/cs_app/main/templates/main/about.html index 16fe1a1..8d6560d 100644 --- a/cs_app/main/templates/main/about.html +++ b/cs_app/main/templates/main/about.html @@ -1,10 +1,91 @@ {% extends 'base.html' %} +{% load static %} {% block title %}About — Catchment Simulation{% endblock %} +{% block style %} + +{% endblock %} + {% block content %} -

About

-

The catchment_simulation package is a Python library for simulating subcatchments with different feature values using the Storm Water Management Model. The package was developed with the aim of providing a simple and user-friendly interface for simulating subcatchments with different feature values. The library offers various methods for simulating the subcatchment features, such as percent impervious, percent slope, width, and area. The package also provides methods for simulating the Manning's n value and the depth of depression storage on area for both impervious and pervious areas.

-

The package is useful for urban and environmental planners, hydrologists, and civil engineers who are involved in stormwater management. It allows them to simulate and analyze different scenarios of subcatchment features, which can help them make informed decisions regarding the design and operation of stormwater management systems. The package is open-source and can be used freely by anyone who needs it.

-

If you have any questions or suggestions regarding the package, please feel free to contact us.

-{% endblock %} \ No newline at end of file +
+
+

About

+

About

+

Catchment Simulation shows how a stormwater subcatchment responds when you change one of its parameters, without writing a line of code.

+
+ +
+
+

What it is

+

An open-source Python package, catchment_simulation, and this web app built on it. Both run the EPA Storm Water Management Model (SWMM) for one subcatchment of your model while varying a parameter such as slope, area, width, imperviousness, Manning's n or depression storage.

+
+ +
+

Who it is for

+

Hydrologists, civil and environmental engineers, urban planners and students working on stormwater management. Use it to check how sensitive a design is to a parameter, compare scenarios before changing a model, or show a class how a catchment reacts to rain.

+
+
+ +
+

How it works

+
    +
  1. + +
    +

    Load a model

    +

    Upload a SWMM .inp file or use the bundled sample. Your original file is never changed.

    +
    +
  2. +
  3. + +
    +

    Change one parameter

    +

    swmmio writes each value of the range into a temporary copy of the model.

    +
    +
  4. +
  5. + +
    +

    Run SWMM

    +

    pyswmm runs the SWMM engine for every copy and collects runoff, infiltration and evaporation.

    +
    +
  6. +
  7. + +
    +

    Read the answer

    +

    Charts, tables and metrics show the response; export the numbers to Excel or CSV.

    +
    +
  8. +
+
+ +
+

Open source

+

The package and this app are free to use, study and extend.

+ +
+ + +
+{% endblock %} diff --git a/cs_app/main/templates/main/contact.html b/cs_app/main/templates/main/contact.html index fe1a91b..b58e9da 100644 --- a/cs_app/main/templates/main/contact.html +++ b/cs_app/main/templates/main/contact.html @@ -1,8 +1,24 @@ {% extends 'base.html' %} -{% load crispy_forms_tags %} +{% load crispy_forms_tags static %} -{% block title %}Kontakt{% endblock %} +{% block title %}Contact — Catchment Simulation{% endblock %} + +{% block style %} + +{% endblock %} {% block content %} -{% crispy form form.helper %} -{% endblock %} \ No newline at end of file +
+
+

Contact

+

Contact

+

Ask about the package, report a problem with a model or suggest a tool. You will get a reply by email.

+
+ +
+ {% crispy form form.helper %} +
+ +

Found a bug in the package? You can also open an issue on GitHub.

+
+{% endblock %} diff --git a/cs_app/main/templates/main/userprofile.html b/cs_app/main/templates/main/userprofile.html index 44f885b..ca647ed 100644 --- a/cs_app/main/templates/main/userprofile.html +++ b/cs_app/main/templates/main/userprofile.html @@ -1,6 +1,49 @@ {% extends 'base.html' %} -{% load crispy_forms_tags %} -{% block title %}Kontakt{% endblock %} +{% load crispy_forms_tags static %} + +{% block title %}Profile — Catchment Simulation{% endblock %} + +{% block style %} + +{% endblock %} + {% block content %} -{% crispy form %} -{% endblock %} \ No newline at end of file +{% with owner=form.owner %} +
+
+

{% if user == owner %}Your account{% else %}Member{% endif %}

+

Profile

+

+ {% if user == owner %}Tell other engineers who you are. Your bio is public; your email stays private.{% else %}A Catchment Simulation member.{% endif %} +

+
+ +
+
+ +
+

{{ owner.get_full_name|default:owner.get_username }}

+

+ @{{ owner.get_username }} + Member since {{ owner.date_joined|date:"F Y" }} + {% if user == owner and owner.email %}{{ owner.email }}{% endif %} +

+
+
+ + {% if user == owner %} + {% crispy form %} + {% else %} +

Bio

+ {% with bio=form.bio.value %} + {% if bio %} +

{{ bio|linebreaksbr }}

+ {% else %} +

No bio yet.

+ {% endif %} + {% endwith %} + {% endif %} +
+
+{% endwith %} +{% endblock %} diff --git a/cs_app/main/tests/test_async_views.py b/cs_app/main/tests/test_async_views.py index 8f002a9..9ec2aed 100644 --- a/cs_app/main/tests/test_async_views.py +++ b/cs_app/main/tests/test_async_views.py @@ -1,4 +1,4 @@ -"""Tests for the async (fetch) contract of the run views. +"""Tests for the async (fetch) contract of the run views and the contact/profile fixes. Run views (simulation, timeseries, calculations) answer requests sent with ``X-Requested-With: XMLHttpRequest`` with the results fragment on success and a @@ -14,9 +14,11 @@ import pandas as pd import pytest from django.conf import settings +from django.core import mail from django.core.cache import cache from django.urls import reverse +from main.models import UserProfile from main.views import ( CALC_RESULT_TOKEN_SESSION_KEY, FORM_INVALID_MESSAGE, @@ -750,6 +752,84 @@ def test_calculations_anonymous_post_redirects_to_login(client): assert response.url == f"{settings.LOGIN_URL}?next={calc_url}" +# --------------------------------------------------------------------------- +# Contact and profile +# --------------------------------------------------------------------------- + +CONTACT_POST = { + "email": "sender@example.com", + "title": "Question", + "content": "Hello there.", +} + + +@pytest.mark.django_db +def test_contact_success_sends_mail_and_redirects_with_message(client): + response = client.post(reverse("main:contact"), data=CONTACT_POST, follow=True) + + assert response.redirect_chain == [(reverse("main:contact"), 302)] + assert len(mail.outbox) == 1 + assert mail.outbox[0].subject == "Question" + assert b"alert-success" in response.content + assert b"Message sent." in response.content + + +@pytest.mark.django_db +def test_contact_send_failure_rerenders_form_with_error(client, monkeypatch): + monkeypatch.setattr("main.views.send_message", lambda _message: False) + + response = client.post(reverse("main:contact"), data=CONTACT_POST) + + assert response.status_code == 200 + assert b"alert-danger" in response.content + assert b"Your message could not be sent. Please try again later." in response.content + assert b'value="sender@example.com"' in response.content + assert b"Hello there." in response.content + + +@pytest.mark.django_db +def test_contact_form_posts_to_contact_url(client): + response = client.get(reverse("main:contact")) + + assert f'action="{reverse("main:contact")}"'.encode() in response.content + + +@pytest.mark.django_db +def test_profile_owner_post_redirects_to_own_profile(client, user): + client.force_login(user) + profile_url = reverse("main:userprofile", args=[user.id]) + + response = client.post(profile_url, data={"user": user.id, "bio": "Hydrologist."}) + + assert response.status_code == 302 + assert response.url == profile_url + assert UserProfile.objects.get(user=user).bio == "Hydrologist." + + +@pytest.mark.django_db +def test_profile_save_confirms_with_a_success_message(client, user): + client.force_login(user) + profile_url = reverse("main:userprofile", args=[user.id]) + + response = client.post(profile_url, data={"user": user.id, "bio": "Hydrologist."}, follow=True) + + assert response.redirect_chain == [(profile_url, 302)] + assert b"alert-success" in response.content + assert b"Profile updated." in response.content + + +@pytest.mark.django_db +def test_profile_form_posts_back_to_profile_url(client, user): + """Without an action attribute the browser posts to the profile URL itself.""" + client.force_login(user) + + content = client.get(reverse("main:userprofile", args=[user.id])).content.decode() + + form_tag = re.findall(r"]*>", content[: content.index('id="id_bio"')])[-1] + assert 'method="post"' in form_tag + assert "action=" not in form_tag + + # --------------------------------------------------------------------------- # Failed no-JS runs keep the previous results on the page # --------------------------------------------------------------------------- diff --git a/cs_app/main/tests/test_content_pages.py b/cs_app/main/tests/test_content_pages.py index a4f1277..de74c03 100644 --- a/cs_app/main/tests/test_content_pages.py +++ b/cs_app/main/tests/test_content_pages.py @@ -1,15 +1,19 @@ -"""Home page data and the account pages.""" +"""Home page data, content pages (contact, profile) and the account pages.""" import json +import logging import os import uuid import pytest from django.conf import settings +from django.contrib.auth.models import User from django.urls import reverse from catchment_simulation.catchment_features_simulation import FeaturesSimulation from main import views +from main.forms import ContactForm, UserProfileForm +from main.models import UserProfile # Random per run, like the e2e credentials, so no password is written in the source. SIGNUP_PASSWORD = f"T!{uuid.uuid4().hex}" @@ -116,6 +120,109 @@ def test_hydrograph_json_rejects_rows_without_datetime(tmp_path, monkeypatch): views._load_hydrograph_json() +# --------------------------------------------------------------------------- +# Contact +# --------------------------------------------------------------------------- + + +def test_contact_form_is_english_and_matches_schema_limits(): + form = ContactForm() + + assert [form[name].label for name in ("email", "title", "content", "send_to_me")] == [ + "Email", + "Subject", + "Message", + "Send me a copy", + ] + assert form.helper.inputs[0].value == "Send message" + assert form.fields["title"].max_length == 200 + assert form.fields["content"].max_length == 5000 + + +@pytest.mark.django_db +def test_contact_overlong_subject_is_a_form_error_not_a_crash(client): + response = client.post( + reverse("main:contact"), + data={"email": "a@example.com", "title": "x" * 201, "content": "Hi"}, + ) + + assert response.status_code == 200 + assert "title" in response.context["form"].errors + + +@pytest.mark.django_db +def test_contact_page_heading_and_title(client): + html = client.get(reverse("main:contact")).content.decode() + + assert "Contact — Catchment Simulation" in html + assert '

Contact

' in html + + +# --------------------------------------------------------------------------- +# Profile +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +def test_profile_form_never_lists_other_accounts(client, user): + User.objects.create_user(username="someone-else", password="x") + client.force_login(user) + + html = client.get(reverse("main:userprofile", args=[user.id])).content.decode() + + assert "someone-else" not in html + assert 'id="id_user"' not in html + assert 'value="Save profile"' in html + assert "Profile — Catchment Simulation" in html + + +@pytest.mark.django_db +def test_profile_post_cannot_reassign_the_account(client, user): + other = User.objects.create_user(username="other", password="x") + client.force_login(user) + + client.post( + reverse("main:userprofile", args=[user.id]), data={"user": other.id, "bio": "Mine."} + ) + + assert UserProfile.objects.get(bio="Mine.").user == user + assert not UserProfile.objects.filter(user=other).exists() + + +@pytest.mark.django_db +def test_profile_form_owner_comes_from_instance_or_initial(user): + assert UserProfileForm(initial={"user": user, "bio": ""}).owner == user + profile = UserProfile.objects.create(user=user, bio="Hi") + assert UserProfileForm(instance=profile).owner == user + + +@pytest.mark.django_db +def test_non_owner_sees_bio_as_text_without_email(client, user): + owner = User.objects.create_user(username="owner", email="owner@example.com", password="x") + UserProfile.objects.create(user=owner, bio="Hydrologist.") + client.force_login(user) + + html = client.get(reverse("main:userprofile", args=[owner.id])).content.decode() + + assert '

Hydrologist.

' in html + assert 'id="id_bio"' not in html + assert "owner@example.com" not in html + + +# --------------------------------------------------------------------------- +# Log in / Create account +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +def test_login_page_keeps_next_and_links_to_register(client): + html = client.get(reverse("login") + "?next=/timeseries").content.decode() + + assert "Log in — Catchment Simulation" in html + assert '' in html + assert f'href="{reverse("register:register")}">Create an account' in html + + @pytest.mark.django_db def test_register_success_logs_in_and_welcomes(client): response = client.post( @@ -134,3 +241,19 @@ def test_register_success_logs_in_and_welcomes(client): assert response.redirect_chain == [(reverse("main:main_view"), 302)] assert response.context["user"].username == "newbie" assert b"Welcome, New. Your account is ready." in response.content + + +@pytest.mark.django_db +def test_register_invalid_logs_field_names_but_not_values(client, caplog, capsys): + password1, password2 = f"T!{uuid.uuid4().hex}", f"T!{uuid.uuid4().hex}" + with caplog.at_level(logging.INFO, logger="register.views"): + response = client.post( + reverse("register:register"), + data={"username": "x", "password1": password1, "password2": password2}, + ) + + assert response.status_code == 200 + assert "Create account" in response.content.decode() + assert "password2" in caplog.text + assert password1 not in caplog.text and password2 not in caplog.text + assert capsys.readouterr().out == "" diff --git a/cs_app/main/views.py b/cs_app/main/views.py index c584652..bc0bc30 100644 --- a/cs_app/main/views.py +++ b/cs_app/main/views.py @@ -485,8 +485,10 @@ def contact(request: HttpRequest) -> HttpResponse: form = ContactForm(data=request.POST) if form.is_valid(): message = ContactMessage.model_validate(form.cleaned_data) - send_message(message) - return HttpResponseRedirect(reverse("contact")) + if send_message(message): + messages.success(request, "Message sent.") + return HttpResponseRedirect(reverse("main:contact")) + messages.error(request, "Your message could not be sent. Please try again later.") else: form = ContactForm() return render(request, "main/contact.html", {"form": form}) @@ -520,7 +522,8 @@ def user_profile(request: HttpRequest, user_id: int) -> HttpResponse: form = UserProfileForm(request.POST, initial={"user": user, "bio": ""}) if form.is_valid(): form.save() - return HttpResponseRedirect(reverse("userprofile", args=[user_id])) + messages.success(request, "Profile updated.") + return HttpResponseRedirect(reverse("main:userprofile", args=[user_id])) else: try: profile = user.userprofile diff --git a/cs_app/register/templates/account/register.html b/cs_app/register/templates/account/register.html index b355553..bc66a22 100644 --- a/cs_app/register/templates/account/register.html +++ b/cs_app/register/templates/account/register.html @@ -1,12 +1,24 @@ {% extends "base.html" %} -{% load crispy_forms_tags %} +{% load crispy_forms_tags static %} -{% block title %} Register {% endblock %} +{% block title %}Create account — Catchment Simulation{% endblock %} + +{% block style %} + +{% endblock %} {% block content %} -
+
+

Get started

+

Create account

+

An account keeps your uploaded model and results between visits.

+ + {% csrf_token %} {{ form|crispy }} - + -{% endblock %} \ No newline at end of file + +

Already have an account? Log in

+
+{% endblock %} From 0bcefd5d27a397c5afba6b8c01f30da82f7859a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Buczy=C5=84ski?= Date: Wed, 30 Sep 2026 09:47:03 +0200 Subject: [PATCH 14/18] keep analysis results across restarts and same-model reloads The in-memory cache was emptied by every runserver autoreload and was separate for each gunicorn worker. Results now live in a file-based cache (DJANGO_CACHE_DIR) for 12 hours, and loading content identical to the session's model keeps its results. --- .dockerignore | 1 + cs_app/.env.example | 2 + cs_app/cs_app/settings.py | 12 +++ cs_app/cs_app/test_settings.py | 7 ++ cs_app/e2e/test_result_persistence.py | 98 ++++++++++++++++++++ cs_app/e2e/test_simulation.py | 10 +- cs_app/main/static/main/js/upload_zone.js | 10 +- cs_app/main/tests/test_upload_integration.py | 2 +- cs_app/main/tests/test_views.py | 87 +++++++++++++++++ cs_app/main/views.py | 43 +++++++-- 10 files changed, 259 insertions(+), 13 deletions(-) create mode 100644 cs_app/e2e/test_result_persistence.py diff --git a/.dockerignore b/.dockerignore index 69e8d74..88cefd4 100644 --- a/.dockerignore +++ b/.dockerignore @@ -9,6 +9,7 @@ __pycache__/ db.sqlite3 cs_app/db.sqlite3 cs_app/media/ +cs_app/.cache/ cs_app/data/db/ docs/_build/ output_files/ diff --git a/cs_app/.env.example b/cs_app/.env.example index 6577d00..12e0878 100644 --- a/cs_app/.env.example +++ b/cs_app/.env.example @@ -2,6 +2,8 @@ SECRET_KEY=change-me-for-local-dev DEBUG=True ALLOWED_HOSTS=localhost,127.0.0.1 DATABASE_URL=sqlite:///db.sqlite3 +# Analysis results cache (file-based); defaults to cs_app/.cache/django +# DJANGO_CACHE_DIR= # E2E test credentials (optional — defaults are baked into conftest.py) # E2E_TEST_USERNAME= diff --git a/cs_app/cs_app/settings.py b/cs_app/cs_app/settings.py index cd0989c..39a08c1 100644 --- a/cs_app/cs_app/settings.py +++ b/cs_app/cs_app/settings.py @@ -94,6 +94,18 @@ "default": dj_database_url.parse(db_url, conn_max_age=600), } +# Analysis results are kept in the cache between page visits. The file backend survives +# a server restart (runserver's autoreload, a deploy) and is shared by every worker +# process on the host; the default in-memory cache is neither. A deployment running +# several hosts needs a shared backend instead (Redis, database). +CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.filebased.FileBasedCache", + "LOCATION": os.environ.get("DJANGO_CACHE_DIR", os.path.join(BASE_DIR, ".cache", "django")), + "OPTIONS": {"MAX_ENTRIES": 1000}, + } +} + AUTH_PASSWORD_VALIDATORS = [ { "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", diff --git a/cs_app/cs_app/test_settings.py b/cs_app/cs_app/test_settings.py index d43bd19..64733b3 100644 --- a/cs_app/cs_app/test_settings.py +++ b/cs_app/cs_app/test_settings.py @@ -80,6 +80,13 @@ } } +# Per-process and empty at start, so tests never see each other's (or a dev server's) results. +CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + } +} + AUTH_PASSWORD_VALIDATORS = [ { diff --git a/cs_app/e2e/test_result_persistence.py b/cs_app/e2e/test_result_persistence.py new file mode 100644 index 0000000..0df340d --- /dev/null +++ b/cs_app/e2e/test_result_persistence.py @@ -0,0 +1,98 @@ +"""Results of every tool stay on its page while the user moves between tools. + +Each run's result is kept server-side for the session, so a tool's page renders its +latest results again when the user comes back to it. Loading the same model again +(the sample data, from another tool's page) keeps them too. +""" + +from __future__ import annotations + +import re + +import pytest +from playwright.sync_api import Page, expect + +from .pages.calculations_page import CalculationsPage +from .pages.nav_component import NavComponent +from .pages.simulation_page import SimulationPage +from .pages.timeseries_page import TimeseriesPage + +pytestmark = [pytest.mark.e2e, pytest.mark.slow] + +STALE_NOTICE = ".cs-stale-notice" + + +def _expect_simulation_results(page: Page, sp: SimulationPage) -> None: + expect(page).to_have_url(re.compile(r".*/simulation$")) + expect(sp.results_heading).to_be_visible() + sp.chart.locator(".main-svg").first.wait_for(state="attached") + assert sp.get_results_row_count() > 0 + expect(page.locator(STALE_NOTICE)).to_have_count(0) + + +def _expect_timeseries_results(page: Page, tp: TimeseriesPage) -> None: + expect(page).to_have_url(re.compile(r".*/timeseries$")) + expect(tp.results_heading).to_be_visible() + tp.chart.locator(".main-svg").first.wait_for(state="attached") + expect(page.locator(STALE_NOTICE)).to_have_count(0) + + +def test_results_survive_moving_between_tools(auth_page: Page, live_server) -> None: + nav = NavComponent(auth_page) + sp = SimulationPage(auth_page, live_server.url) + tp = TimeseriesPage(auth_page, live_server.url) + cp = CalculationsPage(auth_page, live_server.url) + + sp.navigate_to() + sp.load_sample_and_select_catchment() + sp.set_option("simulate_n_imperv") + sp.run_simulation() + sp.wait_for_results() + + nav.click_timeseries() + expect(auth_page).to_have_url(re.compile(r".*/timeseries$")) + tp.wait_for_catchment_options() + tp.set_catchment_name(next(o for o in tp.get_catchment_options() if o)) + tp.set_mode("single") + tp.run_analysis() + tp.wait_for_results() + + nav.click_calculations() + expect(auth_page).to_have_url(re.compile(r".*/calculations$")) + cp.run_and_wait_for_results() + + nav.click_simulation() + _expect_simulation_results(auth_page, sp) + nav.click_timeseries() + _expect_timeseries_results(auth_page, tp) + nav.click_calculations() + expect(auth_page).to_have_url(re.compile(r".*/calculations$")) + expect(cp.results_heading).to_be_visible() + assert cp.get_results_row_count() > 0 + + # The browser's back button renders the same results. + auth_page.go_back() + _expect_timeseries_results(auth_page, tp) + + +def test_loading_the_same_model_again_keeps_results(auth_page: Page, live_server) -> None: + sp = SimulationPage(auth_page, live_server.url) + sp.navigate_to() + sp.load_sample_and_select_catchment() + sp.set_option("simulate_n_imperv") + sp.run_simulation() + sp.wait_for_results() + + NavComponent(auth_page).click_timeseries() + expect(auth_page).to_have_url(re.compile(r".*/timeseries$")) + tp = TimeseriesPage(auth_page, live_server.url) + tp.load_sample_and_pick_catchment() + NavComponent(auth_page).click_simulation() + + _expect_simulation_results(auth_page, sp) + + # On the page itself, reloading the model it shows does not mark its results stale. + sp.upload.record_model_events() + sp.upload.sample_button.click() + assert sp.upload.wait_for_model_changed()["restored"] is True + expect(auth_page.locator(STALE_NOTICE)).to_have_count(0) diff --git a/cs_app/e2e/test_simulation.py b/cs_app/e2e/test_simulation.py index 2f2df20..6648543 100644 --- a/cs_app/e2e/test_simulation.py +++ b/cs_app/e2e/test_simulation.py @@ -7,6 +7,7 @@ from __future__ import annotations import re +from pathlib import Path import pytest from playwright.sync_api import Page, Route, expect @@ -17,6 +18,7 @@ pytestmark = pytest.mark.e2e SIMULATION_ROUTE = re.compile(r".*/simulation$") +SAMPLE_INP = Path(__file__).resolve().parent.parent / "data" / "example.inp" def _open(page: Page, live_server) -> SimulationPage: @@ -161,7 +163,7 @@ class TestSimulationModelChange: @pytest.mark.slow def test_new_model_marks_results_stale_and_drops_them( - self, auth_page: Page, live_server + self, auth_page: Page, live_server, tmp_path ) -> None: sp = _open(auth_page, live_server) sp.load_sample_and_select_catchment() @@ -176,7 +178,11 @@ def test_new_model_marks_results_stale_and_drops_them( expect(sp.results_heading).to_be_visible() expect(stale).to_have_count(0) - auth_page.get_by_role("button", name="Try sample data").click() + # A different model (the sample with one more line); the same content again would + # keep the results (see test_result_persistence.py). + other_model = tmp_path / "other_model.inp" + other_model.write_bytes(SAMPLE_INP.read_bytes() + b"\n;; edited copy\n") + sp.upload.upload_file(str(other_model)) expect(stale).to_contain_text("previously loaded model") sp.navigate_to() diff --git a/cs_app/main/static/main/js/upload_zone.js b/cs_app/main/static/main/js/upload_zone.js index 7fbeaa1..e8e69db 100644 --- a/cs_app/main/static/main/js/upload_zone.js +++ b/cs_app/main/static/main/js/upload_zone.js @@ -7,7 +7,9 @@ * "cs:model-changed" detail: {filename, size, subcatchments: [name, ...], restored} * fired once the subcatchment list of a newly loaded model * (upload, sample data or restored session) is known; - * `restored` is true for the session's model shown on page load. + * `restored` is true when the session's model is unchanged: shown + * on page load, or the same content loaded again (the server + * answers `unchanged`), so results computed from it stay valid. * "cs:model-cleared" fired after the user removes the loaded model. */ document.addEventListener("DOMContentLoaded", function () { @@ -256,8 +258,8 @@ document.addEventListener("DOMContentLoaded", function () { "X-CSRFToken": csrfToken, }, init: function () { - this.on("success", function (file) { - setModel(file.name, file.size, Boolean(file.restored)); + this.on("success", function (file, response) { + setModel(file.name, file.size, Boolean(file.restored || (response && response.unchanged))); }); this.on("removedfile", function () { // Dismissing a preview removes the focused button; keep focus in the upload zone. @@ -371,7 +373,7 @@ document.addEventListener("DOMContentLoaded", function () { }) .then(function (data) { if (data) { - showMockFile(data.filename || "example.inp", data.size, false); + showMockFile(data.filename || "example.inp", data.size, data.unchanged === true); } }) .catch(function (error) { diff --git a/cs_app/main/tests/test_upload_integration.py b/cs_app/main/tests/test_upload_integration.py index 41ca29c..0a6da87 100644 --- a/cs_app/main/tests/test_upload_integration.py +++ b/cs_app/main/tests/test_upload_integration.py @@ -51,7 +51,7 @@ def test_upload_with_csrf_header_stores_the_file(csrf_client, upload_path): response = _post_upload(csrf_client, HTTP_X_CSRFTOKEN=token) assert response.status_code == 200 - assert response.json() == {"message": "File was sent."} + assert response.json() == {"message": "File was sent.", "unchanged": False} with open(upload_path, "rb") as saved: assert saved.read() == INP_CONTENT assert csrf_client.session["uploaded_file_path"] == upload_path diff --git a/cs_app/main/tests/test_views.py b/cs_app/main/tests/test_views.py index 982112a..9d41bf0 100644 --- a/cs_app/main/tests/test_views.py +++ b/cs_app/main/tests/test_views.py @@ -20,6 +20,7 @@ from django.utils import timezone from main.views import ( + MODEL_DIGEST_SESSION_KEY, RESULT_SESSION_KEYS, SIM_RESULT_TOKEN_SESSION_KEY, TS_RESULT_TOKEN_SESSION_KEY, @@ -2609,6 +2610,92 @@ def test_changing_the_model_drops_every_tools_results(client, user, url_name): os.remove(path) +def _seed_results(client, user) -> dict: + session = client.session + tokens = {} + for scope, session_key in RESULT_SESSION_KEYS: + tokens[scope] = uuid.uuid4().hex + session[session_key] = tokens[scope] + cache.set(_result_cache_key(scope, user.id, tokens[scope]), json.dumps({"value": 1})) + session["sim_form_state"] = {"option": "simulate_percent_slope", "catchment_name": "S1"} + session.save() + return tokens + + +def _remove_session_model(client) -> None: + path = client.session.get("uploaded_file_path") + if path and os.path.exists(path): + os.remove(path) + + +@pytest.mark.django_db +def test_loading_the_same_sample_again_keeps_every_tools_results(client, user): + """The sample loaded again from another tool's page is the same model: keep its results.""" + client.force_login(user) + try: + first = client.post(reverse("main:upload_sample"), HTTP_X_REQUESTED_WITH="XMLHttpRequest") + assert first.json()["unchanged"] is False + tokens = _seed_results(client, user) + + again = client.post(reverse("main:upload_sample"), HTTP_X_REQUESTED_WITH="XMLHttpRequest") + + assert again.status_code == 200 + assert again.json()["unchanged"] is True + for scope, session_key in RESULT_SESSION_KEYS: + assert client.session[session_key] == tokens[scope] + assert cache.get(_result_cache_key(scope, user.id, tokens[scope])) is not None + assert client.session["sim_form_state"]["catchment_name"] == "S1" + finally: + _remove_session_model(client) + + +@pytest.mark.django_db +def test_uploading_identical_content_keeps_results_and_different_content_drops_them(client, user): + client.force_login(user) + content = b"[TITLE]\nSame model\n\n[OPTIONS]\nFLOW_UNITS CMS\n" + + def upload(name: str, body: bytes): + return client.post( + reverse("main:upload"), + {"file": SimpleUploadedFile(name, body, content_type="text/plain")}, + HTTP_X_REQUESTED_WITH="XMLHttpRequest", + ) + + try: + assert upload("model.inp", content).json()["unchanged"] is False + tokens = _seed_results(client, user) + + # Same content under another name: the results were computed from this model. + response = upload("model_copy.inp", content) + assert response.json()["unchanged"] is True + assert client.session[RESULT_SESSION_KEYS[0][1]] == tokens["sim"] + + response = upload("model_copy.inp", content + b"\n[JUNCTIONS]\n") + assert response.json()["unchanged"] is False + for scope, session_key in RESULT_SESSION_KEYS: + assert session_key not in client.session + assert cache.get(_result_cache_key(scope, user.id, tokens[scope])) is None + finally: + _remove_session_model(client) + + +@pytest.mark.django_db +def test_clearing_the_model_forgets_its_digest(client, user): + """After removing the model, loading it again is a new model (its results are gone).""" + client.force_login(user) + try: + client.post(reverse("main:upload_sample"), HTTP_X_REQUESTED_WITH="XMLHttpRequest") + client.post(reverse("main:upload_clear"), HTTP_X_REQUESTED_WITH="XMLHttpRequest") + assert MODEL_DIGEST_SESSION_KEY not in client.session + + response = client.post( + reverse("main:upload_sample"), HTTP_X_REQUESTED_WITH="XMLHttpRequest" + ) + assert response.json()["unchanged"] is False + finally: + _remove_session_model(client) + + # ── upload_clear tests (#4) ────────────────────────────────────────────── diff --git a/cs_app/main/views.py b/cs_app/main/views.py index bc0bc30..f2fc518 100644 --- a/cs_app/main/views.py +++ b/cs_app/main/views.py @@ -4,6 +4,7 @@ """ import datetime +import hashlib import json import logging import os @@ -68,9 +69,12 @@ ("ts", TS_RESULT_TOKEN_SESSION_KEY), ("calc", CALC_RESULT_TOKEN_SESSION_KEY), ) -RESULT_CACHE_TTL_SECONDS = 30 * 60 +# Long enough to leave a tool and come back to its results later the same day. +RESULT_CACHE_TTL_SECONDS = 12 * 60 * 60 MAX_RESULT_CACHE_BYTES = 2 * 1024 * 1024 UPLOAD_SUBDIR = "uploaded_files" +# SHA-256 of the session model's content: loading the same model again keeps its results. +MODEL_DIGEST_SESSION_KEY = "uploaded_file_sha256" SI_FLOW_UNITS = frozenset({"CMS", "LPS", "MLD"}) US_FLOW_UNITS = frozenset({"CFS", "GPM", "MGD"}) SIMULATION_TEMPLATE = "main/simulation.html" @@ -671,6 +675,7 @@ def _forget_model_state(request: HttpRequest) -> None: for key in ( SIM_FORM_STATE_SESSION_KEY, TS_FORM_STATE_SESSION_KEY, + MODEL_DIGEST_SESSION_KEY, "_subcatchment_ids", "_subcatchment_ids_file", ): @@ -679,6 +684,33 @@ def _forget_model_state(request: HttpRequest) -> None: _delete_cached_result(scope, request.user.id, request.session.pop(session_key, None)) +def _file_sha256(path: str) -> str | None: + digest = hashlib.sha256() + try: + with open(path, "rb") as model_file: + for chunk in iter(lambda: model_file.read(1024 * 1024), b""): + digest.update(chunk) + except OSError: + return None + return digest.hexdigest() + + +def _adopt_model(request: HttpRequest, file_path: str) -> bool: + """ + Make ``file_path`` the session's model. Its results and form values are kept only + when the content is identical to the model it replaces (e.g. the sample data loaded + again from another tool's page); returns whether that was the case. + """ + digest = _file_sha256(file_path) + unchanged = digest is not None and request.session.get(MODEL_DIGEST_SESSION_KEY) == digest + request.session["uploaded_file_path"] = file_path + if not unchanged: + _forget_model_state(request) + if digest is not None: + request.session[MODEL_DIGEST_SESSION_KEY] = digest + return unchanged + + def _upload_response(request: HttpRequest, payload: dict, status: int = 200) -> HttpResponse: """ Answer an upload: JSON for the upload zone's XHR; for the no-JS fallback form, a @@ -800,11 +832,10 @@ def _store_upload(request: HttpRequest) -> HttpResponse: for chunk in uploaded_file.chunks(): destination.write(chunk) - request.session["uploaded_file_path"] = file_path - _forget_model_state(request) + unchanged = _adopt_model(request, file_path) logger.info(f"File uploaded successfully: {file_path}") - return _upload_response(request, {"message": "File was sent."}) + return _upload_response(request, {"message": "File was sent.", "unchanged": unchanged}) @require_POST @@ -839,8 +870,7 @@ def upload_sample(request: HttpRequest) -> JsonResponse: if old_path and old_path != file_path: _safe_remove_file(old_path) - request.session["uploaded_file_path"] = file_path - _forget_model_state(request) + unchanged = _adopt_model(request, file_path) try: sample_size = os.path.getsize(file_path) except OSError: @@ -851,6 +881,7 @@ def upload_sample(request: HttpRequest) -> JsonResponse: "message": "Sample data loaded.", "filename": os.path.basename(file_path), "size": sample_size, + "unchanged": unchanged, } ) From 759345d5c3429f44c98074f604a43a7e71df804f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Buczy=C5=84ski?= Date: Wed, 30 Sep 2026 09:47:40 +0200 Subject: [PATCH 15/18] centre the navigation links on wide screens Brand, links and tools sit on a three-column grid, so the links are on the page's centre line; between 992 and 1199px the wordmark stacks on two lines so the auth buttons do not wrap. --- cs_app/cs_app/templates/base.html | 2 +- cs_app/e2e/test_responsive.py | 37 +++++++++++++++++++++++++++++ cs_app/main/static/main/css/app.css | 36 ++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 1 deletion(-) diff --git a/cs_app/cs_app/templates/base.html b/cs_app/cs_app/templates/base.html index 9b2dd23..7774025 100644 --- a/cs_app/cs_app/templates/base.html +++ b/cs_app/cs_app/templates/base.html @@ -48,7 +48,7 @@