diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml index 8fa0d2afa..8c6b8f550 100644 --- a/.github/workflows/deployment.yml +++ b/.github/workflows/deployment.yml @@ -43,7 +43,7 @@ jobs: shell: bash run: | oc login "${{ secrets.OPENSHIFT_CLUSTER }}" --token="${{ secrets.OC4_TOOL_TOKEN }}" - GIT_BRANCH=${GITHUB_HEAD_REF} MODULE_NAME=web DOCKER_FILE=Dockerfile.web PATH_BC=openshift/templates/build.web.bc.yaml SENTRY_AUTH_TOKEN="${{ secrets.SENTRY_AUTH_TOKEN }}" ARTIFACTORY_SVCACCT_TOKEN="${{ secrets.ARTIFACTORY_SVCACCT_TOKEN}}" bash openshift/scripts/oc_build.sh ${SUFFIX} apply + GIT_BRANCH=${GITHUB_HEAD_REF} MODULE_NAME=web DOCKER_FILE=Dockerfile.web PATH_BC=openshift/templates/build.web.bc.yaml SENTRY_AUTH_TOKEN="${{ secrets.SENTRY_AUTH_TOKEN }}" bash openshift/scripts/oc_build.sh ${SUFFIX} apply build-api-image: name: Build API Image @@ -61,7 +61,7 @@ jobs: shell: bash run: | oc login "${{ secrets.OPENSHIFT_CLUSTER }}" --token="${{ secrets.OC4_TOOL_TOKEN }}" - GIT_BRANCH=${GITHUB_HEAD_REF} MODULE_NAME=api ARTIFACTORY_PYPI_USERNAME=${{ secrets.ARTIFACTORY_PYPI_USERNAME }} ARTIFACTORY_PYPI_PASSWORD=${{ secrets.ARTIFACTORY_PYPI_PASSWORD }} bash openshift/scripts/oc_build.sh ${SUFFIX} apply + GIT_BRANCH=${GITHUB_HEAD_REF} MODULE_NAME=api bash openshift/scripts/oc_build.sh ${SUFFIX} apply # TODO: Delete once pmtiles has run for some time # build-tileserv-image: # name: Build tileserv Image @@ -249,7 +249,7 @@ jobs: - name: ZAP Scan uses: zaproxy/action-baseline@v0.12.0 with: - target: "https://wps-pr-${{ github.event.number }}.apps.silver.devops.gov.bc.ca" + target: "https://wps-pr-${{ github.event.number }}-e1e498-dev.apps.silver.devops.gov.bc.ca" rules_file_name: ".zap/rules.tsv" # Do not return failure on warnings - TODO: this has to be resolved! cmd_options: "-I" @@ -294,67 +294,3 @@ jobs: run: | oc login "${{ secrets.OPENSHIFT_CLUSTER }}" --token="${{ secrets.OC4_DEV_TOKEN }}" PROJ_TARGET="e1e498-dev" PROJ_TOOLS="e1e498-tools" PROJ_DEV="e1e498-dev" bash openshift/scripts/oc_provision_c_haines_cronjob.sh ${SUFFIX} apply - - prepare-test-database: - name: Prepare Test Database - runs-on: ubuntu-22.04 - steps: - - name: Set Variables - shell: bash - run: | - echo "SUFFIX=pr-${{ github.event.number }}" >> $GITHUB_ENV - - - name: Checkout - uses: actions/checkout@v4 - - - name: Deploy PostGIS instance - shell: bash - run: | - oc login "${{ secrets.OPENSHIFT_CLUSTER }}" --token="${{ secrets.OC4_TEST_TOKEN }}" - EPHEMERAL_STORAGE=True PROJ_TARGET=e1e498-test APP_USER="wps" IMAGE_STREAM_NAMESPACE=e1e498-tools bash openshift/scripts/oc_provision_db.sh ${SUFFIX} apply - - deploy-test: - name: Deploy to Test - if: github.triggering_actor != 'renovate' - needs: [build-api-image, build-web-image, prepare-test-database] - runs-on: ubuntu-22.04 - steps: - - name: Set Variables - shell: bash - run: | - echo "SUFFIX=pr-${{ github.event.number }}" >> $GITHUB_ENV - - - name: Checkout - uses: actions/checkout@v4 - - - name: Deploy API to Test - shell: bash - run: | - oc login "${{ secrets.OPENSHIFT_CLUSTER }}" --token="${{ secrets.OC4_TEST_TOKEN }}" - MODULE_NAME=api PROJ_TARGET="e1e498-test" ENVIRONMENT="-test" VANITY_DOMAIN="${SUFFIX}-test-psu.apps.silver.devops.gov.bc.ca" SECOND_LEVEL_DOMAIN="apps.silver.devops.gov.bc.ca" USE_WFWX="True" bash openshift/scripts/oc_deploy.sh ${SUFFIX} apply - - # Just run 1/3 EnvCan cronjobs so there's some model data in DB for comparison against P3 actuals and forecasts - # Don't need all model data - - name: Environment Canada RDPS cronjob (Donald) - shell: bash - run: | - oc login "${{ secrets.OPENSHIFT_CLUSTER }}" --token="${{ secrets.OC4_TEST_TOKEN }}" - PROJ_TARGET="e1e498-test" bash openshift/scripts/oc_provision_ec_rdps_cronjob.sh ${SUFFIX} apply - - test-configure-nats-server-name: - name: Configure nats server name in test - runs-on: ubuntu-22.04 - steps: - - name: Set Variables - shell: bash - run: | - echo "SUFFIX=pr-${{ github.event.number }}" >> $GITHUB_ENV - - - name: Checkout - uses: actions/checkout@v4 - - - name: Configure - shell: bash - run: | - oc login "${{ secrets.OPENSHIFT_CLUSTER }}" --token="${{ secrets.OC4_TEST_TOKEN }}" - PROJ_TARGET="e1e498-test" bash openshift/scripts/oc_provision_nats_server_config.sh ${SUFFIX} apply diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 25a04e9bf..2f93c7212 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -63,7 +63,6 @@ jobs: if: steps.cache-venv.outputs.cache-hit != 'true' working-directory: ./api run: | - poetry config http-basic.psu ${{ secrets.ARTIFACTORY_PYPI_USERNAME }} ${{ secrets.ARTIFACTORY_PYPI_PASSWORD }} poetry run python -m pip install --upgrade pip poetry install poetry run python -m pip install gdal==$(gdal-config --version) @@ -139,7 +138,6 @@ jobs: if: steps.cache-venv.outputs.cache-hit != 'true' working-directory: ./api run: | - poetry config http-basic.psu ${{ secrets.ARTIFACTORY_PYPI_USERNAME }} ${{ secrets.ARTIFACTORY_PYPI_PASSWORD }} poetry run python -m pip install --upgrade pip poetry install poetry run python -m pip install gdal==$(gdal-config --version) @@ -175,7 +173,7 @@ jobs: poetry run coverage report poetry run coverage xml -o coverage-reports/coverage-report.xml - name: Archive coverage report (api) - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: api-coverage-report path: ./api/coverage-reports/coverage-report.xml @@ -191,8 +189,6 @@ jobs: with: # For sonar-scanner to work properly we can't use a shallow fetch. fetch-depth: 0 - - name: Setup kernel for react, increase watchers - run: echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf && sudo sysctl -p - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 with: @@ -207,9 +203,6 @@ jobs: run: corepack enable - name: use new yarn run: yarn set version berry - - name: Setup yarn for scoped artifactory packages - working-directory: ./web - run: yarn config set npmScopes.psu.npmRegistryServer https://artifacts.developer.gov.bc.ca/artifactory/api/npm/pe1e-psu-npm-local/ && yarn config set npmScopes.psu.npmAlwaysAuth true && yarn config set npmScopes.psu.npmAuthToken ${{ secrets.ARTIFACTORY_SVCACCT_TOKEN }} - name: Install node dependencies (web) working-directory: ./web if: steps.yarn-cache.outputs.cache-hit != 'true' @@ -217,9 +210,6 @@ jobs: - name: Lint (web) working-directory: ./web run: yarn run lint - # "Error: ENOSPC: System limit for number of file watchers reached" can be addressed - # with this: https://github.com/guard/listen/wiki/Increasing-the-amount-of-inotify-watchers#the-technical-details - # It seems unnecessary at the moment because tests pass anyway - name: Cypress tests with coverage (web) working-directory: ./web run: yarn run cypress:ci @@ -229,9 +219,8 @@ jobs: - name: Merge and finalize test coverage (web) working-directory: ./web run: yarn run finalizeCoverage - - name: Archive coverage report - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: web-coverage-report path: ./web/finalCoverage @@ -244,7 +233,7 @@ jobs: - name: Checkout repo uses: actions/checkout@v4 - name: Download all workflow run artifacts - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 - name: Upload test coverage to Codecov uses: codecov/codecov-action@v4 with: diff --git a/.github/workflows/post_merge_integration.yml b/.github/workflows/post_merge_integration.yml index 8a3d91dbc..7c097466b 100644 --- a/.github/workflows/post_merge_integration.yml +++ b/.github/workflows/post_merge_integration.yml @@ -60,9 +60,6 @@ jobs: with: path: ~/work/wps/wps/api/.venv key: ${{ runner.os }}-venv-poetry-1.6.1-${{ hashFiles('**/poetry.lock') }} - - name: Configure artifactory creds for poetry - working-directory: ./api - run: poetry config http-basic.psu ${{ secrets.ARTIFACTORY_PYPI_USERNAME }} ${{ secrets.ARTIFACTORY_PYPI_PASSWORD }} - name: Install python dependencies using poetry (api) if: steps.cache-venv.outputs.cache-hit != 'true' working-directory: ./api @@ -138,9 +135,6 @@ jobs: with: path: ~/work/wps/wps/api/.venv key: ${{ runner.os }}-venv-poetry-1.6.1-${{ hashFiles('**/poetry.lock') }} - - name: Configure artifactory creds for poetry - working-directory: ./api - run: poetry config http-basic.psu ${{ secrets.ARTIFACTORY_PYPI_USERNAME }} ${{ secrets.ARTIFACTORY_PYPI_PASSWORD }} - name: Install python dependencies using poetry (api) if: steps.cache-venv.outputs.cache-hit != 'true' working-directory: ./api @@ -174,7 +168,7 @@ jobs: poetry run coverage report poetry run coverage xml -o coverage-reports/coverage-report.xml - name: Archive coverage report (api) - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: api-coverage-report path: ./api/coverage-reports/coverage-report.xml @@ -206,9 +200,6 @@ jobs: run: corepack enable - name: use new yarn run: yarn set version berry - - name: Setup yarn for scoped artifactory packages - working-directory: ./web - run: yarn config set npmScopes.psu.npmRegistryServer https://artifacts.developer.gov.bc.ca/artifactory/api/npm/pe1e-psu-npm-local/ && yarn config set npmScopes.psu.npmAlwaysAuth true && yarn config set npmScopes.psu.npmAuthToken ${{ secrets.ARTIFACTORY_SVCACCT_TOKEN }} - name: Install node dependencies (web) working-directory: ./web if: steps.yarn-cache.outputs.cache-hit != 'true' @@ -230,7 +221,7 @@ jobs: run: yarn run finalizeCoverage - name: Archive coverage report (web) - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: web-coverage-report path: ./web/finalCoverage @@ -243,7 +234,7 @@ jobs: - name: Checkout repo uses: actions/checkout@v4 - name: Download all workflow run artifacts - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 - name: Upload test coverage to Codecov uses: codecov/codecov-action@v4 with: diff --git a/.github/workflows/renovate.yml b/.github/workflows/renovate.yml index 4cbaa1e89..282a8aca9 100644 --- a/.github/workflows/renovate.yml +++ b/.github/workflows/renovate.yml @@ -12,7 +12,7 @@ jobs: - name: Checkout uses: actions/checkout@v4.1.7 - name: Self-hosted Renovate - uses: renovatebot/github-action@v40.2.5 + uses: renovatebot/github-action@v40.2.10 with: configurationFile: renovate.json token: ${{ secrets.RENOVATE_TOKEN }} diff --git a/.gitignore b/.gitignore index 6408381c9..0379abe08 100644 --- a/.gitignore +++ b/.gitignore @@ -74,11 +74,7 @@ web/typings/ .yarn-integrity # dotenv environment variables file -api/app/.env -api/env/.env.test -api/app/.env.docker -web/.env -tileserv/tools/.env +**/.env # parcel-bundler cache (https://parceljs.org/) .cache diff --git a/.vscode/launch.json b/.vscode/launch.json index 905bb37f8..c025513d9 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -117,12 +117,19 @@ "console": "integratedTerminal", }, { - "name": "app.jobs.rdps_sfms ", + "name": "app.jobs.rdps_sfms", "type": "python", "request": "launch", "module": "app.jobs.rdps_sfms", "console": "integratedTerminal" }, + { + "name": "local critical hours", + "type": "python", + "request": "launch", + "module": "app.auto_spatial_advisory.critical_hours", + "console": "integratedTerminal" + }, { "name": "Chrome", "type": "pwa-chrome", diff --git a/.vscode/settings.json b/.vscode/settings.json index 9c08beda9..844895d45 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -8,7 +8,6 @@ ".github": false, "**/.pytest_cache": true, "**/__pycache__**": true, - "web/node_modules": true, "api/python_cache": true, "api/.venv": true }, @@ -66,11 +65,14 @@ "Behaviour", "botocore", "cffdrs", + "colour", "cutline", + "CWFIS", "determinates", "excinfo", "fastapi", "FBAN", + "ffmc", "fireweather", "firezone", "GDPS", @@ -87,6 +89,7 @@ "HRDPS", "idir", "Indeterminates", + "Kamloops", "luxon", "maxx", "maxy", @@ -107,13 +110,16 @@ "PROJCS", "pydantic", "RDPS", + "reduxjs", "reproject", "rocketchat", "rollup", + "rtol", "sessionmaker", "sfms", "sqlalchemy", "starlette", + "testid", "tobytes", "upsampled", "uvicorn", diff --git a/Dockerfile b/Dockerfile index 30ead8b4b..14cc9b194 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,8 +2,7 @@ ARG DOCKER_IMAGE=image-registry.openshift-image-registry.svc:5000/e1e498-tools/w # To build locally, point to a local base image you've already built (see openshift/wps-api-base) # e.g. : docker build --build-arg DOCKER_IMAGE=wps-api-base:my-tag . -# Stage 1: Install Python packages, including internally published cffdrs. Installation from artifactory -# requires a username/password which we don't want in our final image, so we use a multi-stage build. +# Stage 1: Install Python packages FROM ${DOCKER_IMAGE} AS builder # We don't want to run our app as root, so we define a worker user. @@ -29,11 +28,6 @@ RUN python -m pip install --upgrade pip # Copy poetry files. COPY --chown=$USERNAME:$USERNAME ./api/pyproject.toml ./api/poetry.lock /app/ -ARG ARTIFACTORY_PYPI_USERNAME -ARG ARTIFACTORY_PYPI_PASSWORD - -RUN poetry config http-basic.psu "$ARTIFACTORY_PYPI_USERNAME" "$ARTIFACTORY_PYPI_PASSWORD" - # Install dependencies. RUN poetry install --without dev # Get a python binding for gdal that matches the version of gdal we have installed. diff --git a/Dockerfile.web b/Dockerfile.web index 60bc629e1..88ad1eb07 100644 --- a/Dockerfile.web +++ b/Dockerfile.web @@ -6,30 +6,20 @@ ARG NODE_OPTIONS="--v8-pool-size=4" # PHASE 1 - build frontend. -# Pull from local registry - we can't pull from docker due to limits. -# see https://catalog.redhat.com/software/containers/ubi9/nodejs-20/64770ac7a835530172eee6a9 for -# details -FROM registry.access.redhat.com/ubi9/nodejs-20 as static - +FROM node:20-alpine AS static # Switch to root user for package installs USER 0 +WORKDIR /app COPY web/package.json . COPY web/yarn.lock . COPY web/.yarnrc.yml . # do install first so it will be cached -RUN npm install -g --ignore-scripts corepack -RUN corepack enable -RUN yarn set version berry -RUN yarn config set npmScopes.psu.npmRegistryServer https://artifacts.developer.gov.bc.ca/artifactory/api/npm/pe1e-psu-npm-local/ -RUN yarn config set npmScopes.psu.npmAlwaysAuth true -RUN yarn config set npmScopes.psu.npmAuthToken ${ARTIFACTORY_SVCACCT_TOKEN} -RUN CYPRESS_INSTALL_BINARY=0 yarn install --immutable +RUN corepack enable \ + && yarn set version berry \ + && CYPRESS_INSTALL_BINARY=0 yarn install --immutable COPY web . -RUN yarn run build:prod - -# Remove sourcemaps after they've been uploaded to sentry -RUN rm build/static/js/**.map +RUN yarn run build:prod # Switch back to default user USER 1001 @@ -42,7 +32,7 @@ FROM registry.access.redhat.com/ubi8/nginx-120 ADD ./openshift/nginx.conf "${NGINX_CONF_PATH}" # Copy the static content: -COPY --from=static /opt/app-root/src/build . +COPY --from=static /app/build . EXPOSE 3000 CMD nginx -g "daemon off;" \ No newline at end of file diff --git a/api/alembic/versions/c5bea0920d53_adds_placename_labels_to_advisory_shapes.py b/api/alembic/versions/c5bea0920d53_adds_placename_labels_to_advisory_shapes.py new file mode 100644 index 000000000..189bf8c37 --- /dev/null +++ b/api/alembic/versions/c5bea0920d53_adds_placename_labels_to_advisory_shapes.py @@ -0,0 +1,47 @@ +"""Adds placename labels to advisory_shapes + +Revision ID: c5bea0920d53 +Revises: c9e46d098c73 +Create Date: 2024-09-10 12:54:07.552418 + +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.orm.session import Session +from app.db.models.auto_spatial_advisory import Shape +from sqlalchemy import update +from app.utils.zone_units import get_zone_units_geojson + +# revision identifiers, used by Alembic. +revision = "c5bea0920d53" +down_revision = "c9e46d098c73" +branch_labels = None +depends_on = None + + +def upgrade(): + session = Session(bind=op.get_bind()) + + op.add_column("advisory_shapes", sa.Column("placename_label", sa.String(), nullable=True)) + + fire_zone_units = get_zone_units_geojson() + for feature in fire_zone_units.get("features", []): + properties = feature.get("properties", {}) + object_id = properties.get("OBJECTID") + + prefix = properties.get("FIRE_ZONE_") + suffix = properties.get("FIRE_ZON_1") + placename_label = f"{prefix}-{suffix}" + + stmt = update(Shape).where(Shape.source_identifier == str(object_id)).values(placename_label=placename_label) + + session.execute(stmt) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic ### + op.drop_column("advisory_shapes", "placename_label") + # ### end Alembic commands ### diff --git a/api/alembic/versions/c9e46d098c73_critical_hours.py b/api/alembic/versions/c9e46d098c73_critical_hours.py new file mode 100644 index 000000000..6aa3ebf56 --- /dev/null +++ b/api/alembic/versions/c9e46d098c73_critical_hours.py @@ -0,0 +1,56 @@ +"""Critical hours + +Revision ID: c9e46d098c73 +Revises: 6910d017b626 +Create Date: 2024-08-12 16:24:00.489375 + +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "c9e46d098c73" +down_revision = "6910d017b626" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "critical_hours", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("advisory_shape_id", sa.Integer(), nullable=False), + sa.Column("threshold", sa.Enum("advisory", "warning", name="hficlassificationthresholdenum"), nullable=False), + sa.Column("run_parameters", sa.Integer(), nullable=False), + sa.Column("fuel_type", sa.Integer(), nullable=False), + sa.Column("start_hour", sa.Integer(), nullable=False), + sa.Column("end_hour", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["advisory_shape_id"], + ["advisory_shapes.id"], + ), + sa.ForeignKeyConstraint( + ["fuel_type"], + ["sfms_fuel_types.id"], + ), + sa.ForeignKeyConstraint( + ["run_parameters"], + ["run_parameters.id"], + ), + sa.PrimaryKeyConstraint("id"), + comment="Critical hours by firezone unit, fuel type and sfms run parameters.", + ) + op.create_index(op.f("ix_critical_hours_advisory_shape_id"), "critical_hours", ["advisory_shape_id"], unique=False) + op.create_index(op.f("ix_critical_hours_fuel_type"), "critical_hours", ["fuel_type"], unique=False) + op.create_index(op.f("ix_critical_hours_id"), "critical_hours", ["id"], unique=False) + op.create_index(op.f("ix_critical_hours_run_parameters"), "critical_hours", ["run_parameters"], unique=False) + + +def downgrade(): + op.drop_index(op.f("ix_critical_hours_run_parameters"), table_name="critical_hours") + op.drop_index(op.f("ix_critical_hours_id"), table_name="critical_hours") + op.drop_index(op.f("ix_critical_hours_fuel_type"), table_name="critical_hours") + op.drop_index(op.f("ix_critical_hours_advisory_shape_id"), table_name="critical_hours") + op.drop_table("critical_hours") diff --git a/api/app/.env.example b/api/app/.env.example index ec235c083..2fbf297d4 100644 --- a/api/app/.env.example +++ b/api/app/.env.example @@ -7,9 +7,6 @@ WFWX_BASE_URL=https://somewhere WFWX_USER=someusear WFWX_SECRET=somesecret WFWX_MAX_PAGE_SIZE=1000 -BC_FIRE_WEATHER_USER=user -BC_FIRE_WEATHER_SECRET=password -BC_FIRE_WEATHER_FILTER_ID=0 KEYCLOAK_PUBLIC_KEY=thisispublickey KEYCLOAK_CLIENT=client # POSTGRES_WRITE_HOST=host.docker.internal diff --git a/api/app/auto_spatial_advisory/critical_hours.py b/api/app/auto_spatial_advisory/critical_hours.py new file mode 100644 index 000000000..deb981ab5 --- /dev/null +++ b/api/app/auto_spatial_advisory/critical_hours.py @@ -0,0 +1,462 @@ +import argparse +import asyncio +from collections import defaultdict +from datetime import date, datetime, timedelta +import math +from typing import Dict, List, Tuple, Any +import numpy as np +import os +import sys +from time import perf_counter +import logging +from dataclasses import dataclass +from aiohttp import ClientSession +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from app import configure_logging +from app.auto_spatial_advisory.run_type import RunType +from app.db.crud.auto_spatial_advisory import ( + get_all_sfms_fuel_type_records, + get_containing_zone, + get_fuel_type_stats_in_advisory_area, + get_run_parameters_by_id, + get_run_parameters_id, + save_all_critical_hours, +) +from app.db.database import get_async_write_session_scope +from app.db.models.auto_spatial_advisory import AdvisoryFuelStats, CriticalHours, HfiClassificationThresholdEnum, RunTypeEnum, SFMSFuelType +from app.fire_behaviour import cffdrs +from app.fire_behaviour.fuel_types import FUEL_TYPE_DEFAULTS, FuelTypeEnum +from app.fire_behaviour.prediction import build_hourly_rh_dict, calculate_cfb, get_critical_hours +from app.hourlies import get_hourly_readings_in_time_interval +from app.schemas.fba_calc import CriticalHoursHFI, WindResult +from app.schemas.observations import WeatherStationHourlyReadings +from app.stations import get_stations_asynchronously +from app.utils.geospatial import PointTransformer +from app.utils.time import get_hour_20_from_date, get_julian_date +from app.wildfire_one import wfwx_api +from app.wildfire_one.schema_parsers import WFWXWeatherStation + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class CriticalHoursInputs: + """ + Encapsulates the dailies, yesterday dailies and hourlies for a set of stations required for calculating critical hours. + Since daily data comes from WF1 as JSON, we treat the values as Any types for now. + """ + + dailies_by_station_id: Dict[str, Any] + yesterday_dailies_by_station_id: Dict[str, Any] + hourly_observations_by_station_code: Dict[int, WeatherStationHourlyReadings] + + +def determine_start_time(times: list[float]) -> float: + """ + Returns a single start time based on a naive heuristic. + + :param times: A list of potential critical hour start times. + :return: A single start time. + """ + if len(times) < 3: + return min(times) + return math.floor(np.percentile(times, 25)) + + +def determine_end_time(times: list[float]) -> float: + """ + Returns a single end time based on a naive heuristic. + + :param times: A list of potential critical hour end times. + :return: A single end time. + """ + if len(times) < 3: + return max(times) + return math.ceil(np.percentile(times, 75)) + + +def calculate_representative_hours(critical_hours: List[CriticalHoursHFI]): + """ + Naively determines start and end times from a list of CriticalHours objects. + + :param critical_hours: A list of CriticalHours objects. + :return: Representative start and end time. + """ + start_times = [] + end_times = [] + for hours in critical_hours: + start_times.append(hours.start) + end_times.append(hours.end) + start_time = determine_start_time(start_times) + end_time = determine_end_time(end_times) + return (start_time, end_time) + + +async def get_fuel_types_dict(db_session: AsyncSession): + """ + Gets a dictionary of fuel types keyed by fuel type code. + + :param db_session: An async database session. + :return: A dictionary of fuel types keyed by fuel type code. + """ + sfms_fuel_types = await get_all_sfms_fuel_type_records(db_session) + fuel_types_dict = defaultdict() + for fuel_type in sfms_fuel_types: + fuel_types_dict[fuel_type[0].fuel_type_code] = fuel_type[0].id + return fuel_types_dict + + +async def save_critical_hours(db_session: AsyncSession, zone_unit_id: int, critical_hours_by_fuel_type: dict, run_parameters_id: int): + """ + Saves CriticalHours records to the API database. + + :param db_session: An async database session. + :param zone_id: A zone unit id. + :param critical_hours_by_fuel_type: A dictionary of critical hours for the specified zone unit keyed by fuel type code. + :param run_parameters_id: The RunParameters id associated with these critical hours (ie an SFMS run). + """ + sfms_fuel_types_dict = await get_fuel_types_dict(db_session) + critical_hours_to_save: list[CriticalHours] = [] + for fuel_type, critical_hours in critical_hours_by_fuel_type.items(): + start_time, end_time = calculate_representative_hours(critical_hours) + critical_hours_record = CriticalHours( + advisory_shape_id=zone_unit_id, + threshold=HfiClassificationThresholdEnum.ADVISORY.value, + run_parameters=run_parameters_id, + fuel_type=sfms_fuel_types_dict[fuel_type], + start_hour=start_time, + end_hour=end_time, + ) + critical_hours_to_save.append(critical_hours_record) + await save_all_critical_hours(db_session, critical_hours_to_save) + + +def calculate_wind_speed_result(yesterday: dict, raw_daily: dict) -> WindResult: + """ + Calculates new FWIs based on observed and forecast daily data from WF1. + + :param yesterday: Weather parameter observations and FWIs from yesterday. + :param raw_daily: Forecasted weather parameters from WF1. + :return: A WindResult object with calculated FWIs. + """ + # extract variables from wf1 that we need to calculate the fire behaviour advisory. + bui = cffdrs.bui_calc(raw_daily.get("duffMoistureCode", None), raw_daily.get("droughtCode", None)) + temperature = raw_daily.get("temperature", None) + relative_humidity = raw_daily.get("relativeHumidity", None) + precipitation = raw_daily.get("precipitation", None) + + wind_speed = raw_daily.get("windSpeed", None) + status = raw_daily.get("recordType").get("id") + + ffmc = cffdrs.fine_fuel_moisture_code(yesterday.get("fineFuelMoistureCode", None), temperature, relative_humidity, precipitation, wind_speed) + isi = cffdrs.initial_spread_index(ffmc, wind_speed) + fwi = cffdrs.fire_weather_index(isi, bui) + return WindResult(ffmc=ffmc, isi=isi, bui=bui, wind_speed=wind_speed, fwi=fwi, status=status) + + +def calculate_critical_hours_for_station_by_fuel_type( + wfwx_station: WFWXWeatherStation, + critical_hours_inputs: CriticalHoursInputs, + fuel_type: FuelTypeEnum, + for_date: datetime, +): + """ + Calculate the critical hours for a fuel type - station pair. + + :param wfwx_station: The WFWXWeatherStation. + :param critical_hours_inputs: Dailies, yesterday dailies, hourlies required to calculate critical hours + :param fuel_type: The fuel type of interest. + :param for_date: The date critical hours are being calculated for. + :return: The critical hours for the station and fuel type. + """ + raw_daily = critical_hours_inputs.dailies_by_station_id[wfwx_station.wfwx_id] + raw_observations = critical_hours_inputs.hourly_observations_by_station_code[wfwx_station.code] + yesterday = critical_hours_inputs.yesterday_dailies_by_station_id[wfwx_station.wfwx_id] + last_observed_morning_rh_values = build_hourly_rh_dict(raw_observations.values) + + wind_result = calculate_wind_speed_result(yesterday, raw_daily) + bui = wind_result.bui + ffmc = wind_result.ffmc + isi = wind_result.isi + fuel_type_info = FUEL_TYPE_DEFAULTS[fuel_type] + percentage_conifer = fuel_type_info.get("PC", None) + percentage_dead_balsam_fir = fuel_type_info.get("PDF", None) + crown_base_height = fuel_type_info.get("CBH", None) + cfl = fuel_type_info.get("CFL", None) + grass_cure = yesterday.get("grasslandCuring", None) + wind_speed = wind_result.wind_speed + yesterday_ffmc = yesterday.get("fineFuelMoistureCode", None) + julian_date = get_julian_date(for_date) + fmc = cffdrs.foliar_moisture_content(int(wfwx_station.lat), int(wfwx_station.long), wfwx_station.elevation, julian_date) + sfc = cffdrs.surface_fuel_consumption(fuel_type, bui, ffmc, percentage_conifer) + ros = cffdrs.rate_of_spread( + fuel_type, + isi=isi, + bui=bui, + fmc=fmc, + sfc=sfc, + pc=percentage_conifer, + cc=grass_cure, + pdf=percentage_dead_balsam_fir, + cbh=crown_base_height, + ) + cfb = calculate_cfb(fuel_type, fmc, sfc, ros, crown_base_height) + + critical_hours = get_critical_hours( + 4000, + fuel_type, + percentage_conifer, + percentage_dead_balsam_fir, + bui, + grass_cure, + crown_base_height, + ffmc, + fmc, + cfb, + cfl, + wind_speed, + yesterday_ffmc, + last_observed_morning_rh_values, + ) + + return critical_hours + + +def calculate_critical_hours_by_fuel_type(wfwx_stations: List[WFWXWeatherStation], critical_hours_inputs: CriticalHoursInputs, fuel_types_by_area, for_date): + """ + Calculates the critical hours for each fuel type for all stations in a fire zone unit. + + :param wfwx_stations: A list of WFWXWeatherStations in a single fire zone unit. + :param dailies_by_station_id: Today's weather observations (or forecasts) keyed by station guid. + :param yesterday_dailies_by_station_id: Yesterday's weather observations and FWIs keyed by station guid. + :param hourly_observations_by_station_id: Hourly observations from the past 4 days keyed by station guid. + :param fuel_types_by_area: The fuel types and their areas exceeding a high HFI threshold. + :param for_date: The date critical hours are being calculated for. + :return: A dictionary of lists of critical hours keyed by fuel type code. + """ + critical_hours_by_fuel_type = defaultdict(list) + for wfwx_station in wfwx_stations: + if check_station_valid(wfwx_station, critical_hours_inputs): + for fuel_type_key in fuel_types_by_area.keys(): + if fuel_type_key.startswith("O"): + # Raster fuel grid doesn't differentiate between O1A and O1B so we default to O1B for now. + fuel_type_enum = FuelTypeEnum.O1B + else: + fuel_type_enum = FuelTypeEnum(fuel_type_key.replace("-", "")) + try: + # Placing critical hours calculation in a try/except block as failure to calculate critical hours for a single station/fuel type pair + # shouldn't prevent us from continuing with other stations and fuel types. + + critical_hours = calculate_critical_hours_for_station_by_fuel_type(wfwx_station, critical_hours_inputs, fuel_type_enum, for_date) + if critical_hours is not None and critical_hours.start is not None and critical_hours.end is not None: + logger.info(f"Calculated critical hours for fuel type key: {fuel_type_key}, start: {critical_hours.start}, end: {critical_hours.end}") + critical_hours_by_fuel_type[fuel_type_key].append(critical_hours) + except Exception as exc: + logger.warning(f"An error occurred when calculating critical hours for station code: {wfwx_station.code} and fuel type: {fuel_type_key}: {exc} ") + return critical_hours_by_fuel_type + + +def check_station_valid(wfwx_station: WFWXWeatherStation, critical_hours_inputs: CriticalHoursInputs) -> bool: + """ + Checks if there is sufficient information to calculate critical hours for the specified station. + + :param wfwx_station: The station of interest. + :param yesterdays: Yesterday's station data based on observations and FWI calculations. + :param hourlies: Hourly observations from yesterday. + :return: True if the station can be used for critical hours calculations, otherwise false. + """ + if wfwx_station.wfwx_id not in critical_hours_inputs.dailies_by_station_id or wfwx_station.code not in critical_hours_inputs.hourly_observations_by_station_code: + logger.info(f"Station with code: {wfwx_station.code} is missing dailies or hourlies") + return False + daily = critical_hours_inputs.dailies_by_station_id[wfwx_station.wfwx_id] + if daily["duffMoistureCode"] is None or daily["droughtCode"] is None or daily["fineFuelMoistureCode"] is None: + logger.info(f"Station with code: {wfwx_station.code} is missing DMC, DC or FFMC") + return False + return True + + +async def get_hourly_observations(station_codes: List[int], start_time: datetime, end_time: datetime): + """ + Gets hourly weather observations from WF1. + + :param station_codes: A list of weather station codes. + :param start_time: The start time of interest. + :param end_time: The end time of interest. + :return: Hourly weather observations from WF1 for all specified station codes. + """ + hourly_observations = await get_hourly_readings_in_time_interval(station_codes, start_time, end_time) + # also turn hourly obs data into a dict indexed by station id + hourly_observations_by_station_code = {raw_hourly.station.code: raw_hourly for raw_hourly in hourly_observations} + return hourly_observations_by_station_code + + +async def get_dailies_by_station_id(client_session: ClientSession, header: dict, wfwx_stations: List[WFWXWeatherStation], time_of_interest: datetime): + """ + Gets daily observations or forecasts from WF1. + + :param client_session: A client session for making web requests. + :param header: An authorization header for making requests to WF1. + :param wfwx_stations: A list of WFWXWeatherStations. + :param time_of_interest: The time of interest (typically at 20:00 UTC). + :return: Daily observations or forecasts from WF1. + """ + dailies = await wfwx_api.get_dailies_generator(client_session, header, wfwx_stations, time_of_interest, time_of_interest) + # turn it into a dictionary so we can easily get at data using a station id + dailies_by_station_id = {raw_daily.get("stationId"): raw_daily async for raw_daily in dailies} + return dailies_by_station_id + + +def get_fuel_types_by_area(advisory_fuel_stats: List[Tuple[AdvisoryFuelStats, SFMSFuelType]]) -> Dict[str, float]: + """ + Aggregates high HFI area for zone units. + + :param advisory_fuel_stats: A list of fire zone units that includes area exceeding 4K kW/m and 10K kW/m. + :return: Fuel types and the total area exceeding an HFI threshold of 4K kW/m. + """ + fuel_types_by_area = {} + for row in advisory_fuel_stats: + advisory_fuel_stat = row[0] + sfms_fuel_type = row[1] + key = sfms_fuel_type.fuel_type_code + if key == "Non-fuel": + continue + if key in fuel_types_by_area: + fuel_types_by_area[key] += advisory_fuel_stat.area + else: + fuel_types_by_area[key] = advisory_fuel_stat.area + return fuel_types_by_area + + +async def get_inputs_for_critical_hours(for_date: date, header: dict, wfwx_stations: List[WFWXWeatherStation]) -> CriticalHoursInputs: + """ + Retrieves the inputs required for computing critical hours based on the station list and for date + + :param for_date: date of interest for looking up dailies and hourlies + :param header: auth header for requesting data from WF1 + :param wfwx_stations: list of stations to compute critical hours for + :return: critical hours inputs + """ + unique_station_codes = list(set(station.code for station in wfwx_stations)) + time_of_interest = get_hour_20_from_date(for_date) + + # get the dailies for all the stations + async with ClientSession() as client_session: + dailies_by_station_id = await get_dailies_by_station_id(client_session, header, wfwx_stations, time_of_interest) + # must retrieve the previous day's observed/forecasted FFMC value from WFWX + prev_day = time_of_interest - timedelta(days=1) + # get the "daily" data for the station for the previous day + yesterday_dailies_by_station_id = await get_dailies_by_station_id(client_session, header, wfwx_stations, prev_day) + # get hourly observation history from our API (used for calculating morning diurnal FFMC) + hourly_observations_by_station_code = await get_hourly_observations(unique_station_codes, time_of_interest - timedelta(days=4), time_of_interest) + + return CriticalHoursInputs( + dailies_by_station_id=dailies_by_station_id, + yesterday_dailies_by_station_id=yesterday_dailies_by_station_id, + hourly_observations_by_station_code=hourly_observations_by_station_code, + ) + + +async def calculate_critical_hours_by_zone(db_session: AsyncSession, header: dict, stations_by_zone: Dict[int, List[WFWXWeatherStation]], run_parameters_id: int, for_date: date): + """ + Calculates critical hours for fire zone units by heuristically determining critical hours for each station in the fire zone unit that are under advisory conditions (>4k HFI). + + :param db_session: An async database session. + :param header: An authorization header for making requests to WF1. + :param stations_by_zone: A dictionary of lists of stations in fire zone units keyed by fire zone unit id. + :param run_parameters_id: The RunParameters object (ie. the SFMS run). + :param for_date: The date critical hours are being calculated for. + """ + critical_hours_by_zone_and_fuel_type = defaultdict(str, defaultdict(list)) + for zone_key in stations_by_zone.keys(): + advisory_fuel_stats = await get_fuel_type_stats_in_advisory_area(db_session, zone_key, run_parameters_id) + fuel_types_by_area = get_fuel_types_by_area(advisory_fuel_stats) + wfwx_stations = stations_by_zone[zone_key] + critical_hours_inputs = await get_inputs_for_critical_hours(for_date, header, wfwx_stations) + critical_hours_by_fuel_type = calculate_critical_hours_by_fuel_type( + wfwx_stations, + critical_hours_inputs, + fuel_types_by_area, + for_date, + ) + + if len(critical_hours_by_fuel_type) > 0: + critical_hours_by_zone_and_fuel_type[zone_key] = critical_hours_by_fuel_type + + for zone_id, critical_hours_by_fuel_type in critical_hours_by_zone_and_fuel_type.items(): + await save_critical_hours(db_session, zone_id, critical_hours_by_fuel_type, run_parameters_id) + + +async def calculate_critical_hours(run_type: RunType, run_datetime: datetime, for_date: date): + """ + Entry point for calculating critical hours. + + :param run_type: The run type, either forecast or actual. + :param run_datetime: The date and time of the sfms run. + :param for_date: The date critical hours are being calculated for. + """ + + logger.info(f"Calculating critical hours for {run_type} run type on run date: {run_datetime}, for date: {for_date}") + perf_start = perf_counter() + + async with get_async_write_session_scope() as db_session: + run_parameters_id = await get_run_parameters_id(db_session, RunType(run_type), run_datetime, for_date) + stmt = select(CriticalHours).where(CriticalHours.run_parameters == run_parameters_id) + exists = (await db_session.execute(stmt)).scalars().first() is not None + + if exists: + logger.info("Critical hours already processed.") + return + + async with ClientSession() as client_session: + header = await wfwx_api.get_auth_header(client_session) + all_stations = await get_stations_asynchronously() + station_codes = list(station.code for station in all_stations) + stations = await wfwx_api.get_wfwx_stations_from_station_codes(client_session, header, station_codes) + stations_by_zone: Dict[int, List[WFWXWeatherStation]] = defaultdict(list) + transformer = PointTransformer(4326, 3005) + for station in stations: + (x, y) = transformer.transform_coordinate(station.lat, station.long) + zone_id = await get_containing_zone(db_session, f"POINT({x} {y})", 3005) + if zone_id is not None: + stations_by_zone[zone_id[0]].append(station) + + await calculate_critical_hours_by_zone(db_session, header, stations_by_zone, run_parameters_id, for_date) + + perf_end = perf_counter() + delta = perf_end - perf_start + logger.info(f"delta count before and after calculating critical hours: {delta}") + + +#### - Helper functions for local testing of critical hours calculations. + + +async def start_critical_hours(args: argparse.Namespace): + async with get_async_write_session_scope() as db_session: + run_parameters = await get_run_parameters_by_id(db_session, int(args.run_parameters_id)) + await calculate_critical_hours(run_parameters[0].run_type, run_parameters[0].run_datetime, run_parameters[0].for_date) + + +def main(): + """Kicks off asynchronous calculation of critical hours.""" + try: + logger.debug("Begin calculating critical hours.") + parser = argparse.ArgumentParser(description="Process critical hours from command line") + parser.add_argument("-r", "--run_parameters_id", help="The id of the run parameters of interest from the run_parameters table") + args = parser.parse_args() + + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + loop.run_until_complete(start_critical_hours(args)) + + # Exit with 0 - success. + sys.exit(os.EX_OK) + except Exception as exception: + # Exit non 0 - failure. + logger.error("An error occurred while processing critical hours.", exc_info=exception) + sys.exit(os.EX_SOFTWARE) + + +if __name__ == "__main__": + configure_logging() + main() diff --git a/api/app/auto_spatial_advisory/nats_consumer.py b/api/app/auto_spatial_advisory/nats_consumer.py index 72de143dc..3a9a59ee0 100644 --- a/api/app/auto_spatial_advisory/nats_consumer.py +++ b/api/app/auto_spatial_advisory/nats_consumer.py @@ -11,6 +11,7 @@ import nats from nats.js.api import StreamConfig, RetentionPolicy from nats.aio.msg import Msg +from app.auto_spatial_advisory.critical_hours import calculate_critical_hours from app.auto_spatial_advisory.nats_config import server, stream_name, sfms_file_subject, subjects, hfi_classify_durable_group from app.auto_spatial_advisory.process_elevation_hfi import process_hfi_elevation from app.auto_spatial_advisory.process_hfi import RunType, process_hfi @@ -77,6 +78,7 @@ async def closed_cb(): await process_hfi_elevation(run_type, run_date, run_datetime, for_date) await process_high_hfi_area(run_type, run_datetime, for_date) await process_fuel_type_hfi_by_shape(run_type, run_datetime, for_date) + await calculate_critical_hours(run_type, run_datetime, for_date) except Exception as e: logger.error("Error processing HFI message: %s, adding back to queue", msg.data, exc_info=e) background_tasks = BackgroundTasks() diff --git a/api/app/db/crud/auto_spatial_advisory.py b/api/app/db/crud/auto_spatial_advisory.py index ab361b08a..1d955a466 100644 --- a/api/app/db/crud/auto_spatial_advisory.py +++ b/api/app/db/crud/auto_spatial_advisory.py @@ -2,7 +2,7 @@ from enum import Enum import logging from time import perf_counter -from typing import List +from typing import List, Optional, Tuple from sqlalchemy import and_, select, func, cast, String from sqlalchemy.dialects.postgresql import insert from sqlalchemy.ext.asyncio import AsyncSession @@ -10,6 +10,8 @@ from app.auto_spatial_advisory.run_type import RunType from app.db.models.auto_spatial_advisory import ( AdvisoryFuelStats, + CriticalHours, + HfiClassificationThresholdEnum, Shape, ClassifiedHfi, HfiClassificationThreshold, @@ -115,44 +117,83 @@ async def get_all_hfi_thresholds(session: AsyncSession) -> List[HfiClassificatio async def get_all_sfms_fuel_types(session: AsyncSession) -> List[SFMSFuelType]: """ - Retrieve all records from sfms_fuel_types table + Retrieve all records from sfms_fuel_types table excluding record IDs. """ logger.info("retrieving SFMS fuel types info...") - stmt = select(SFMSFuelType) - result = await session.execute(stmt) + result = await get_all_sfms_fuel_type_records(session) fuel_types = [] - for row in result.all(): + for row in result: fuel_type_object = row[0] fuel_types.append(SFMSFuelType(fuel_type_id=fuel_type_object.fuel_type_id, fuel_type_code=fuel_type_object.fuel_type_code, description=fuel_type_object.description)) return fuel_types -async def get_precomputed_high_hfi_fuel_type_areas_for_shape(session: AsyncSession, run_type: RunTypeEnum, run_datetime: datetime, for_date: date, advisory_shape_id: int) -> List[Row]: +async def get_zone_ids_in_centre(session: AsyncSession, fire_centre_name: str): + logger.info(f"retrieving fire zones within {fire_centre_name} from advisory_shapes table") + + stmt = select(Shape.source_identifier).join(FireCentre, FireCentre.id == Shape.fire_centre).where(FireCentre.name == fire_centre_name) + result = await session.execute(stmt) + + all_results = result.scalars().all() + + return all_results + + +async def get_all_sfms_fuel_type_records(session: AsyncSession) -> List[SFMSFuelType]: + """ + Retrieve all records from the sfms_fuel_types table. + + :param session: An async database session. + :return: A list of all SFMSFuelType records. + """ + stmt = select(SFMSFuelType) + result = await session.execute(stmt) + return result.all() + + +async def get_precomputed_stats_for_shape(session: AsyncSession, run_type: RunTypeEnum, run_datetime: datetime, for_date: date, advisory_shape_id: int) -> List[Row]: perf_start = perf_counter() stmt = ( - select(AdvisoryFuelStats.advisory_shape_id, AdvisoryFuelStats.fuel_type, AdvisoryFuelStats.threshold, AdvisoryFuelStats.area, AdvisoryFuelStats.run_parameters) - .join_from(AdvisoryFuelStats, RunParameters, AdvisoryFuelStats.run_parameters == RunParameters.id) - .join_from(AdvisoryFuelStats, Shape, AdvisoryFuelStats.advisory_shape_id == Shape.id) + select( + CriticalHours.start_hour, + CriticalHours.end_hour, + AdvisoryFuelStats.fuel_type, + AdvisoryFuelStats.threshold, + AdvisoryFuelStats.area, + ) + .distinct(AdvisoryFuelStats.fuel_type, AdvisoryFuelStats.run_parameters) + .outerjoin(RunParameters, AdvisoryFuelStats.run_parameters == RunParameters.id) + .outerjoin(CriticalHours, CriticalHours.run_parameters == RunParameters.id) + .outerjoin(Shape, AdvisoryFuelStats.advisory_shape_id == Shape.id) .where( Shape.source_identifier == str(advisory_shape_id), RunParameters.run_type == run_type.value, RunParameters.run_datetime == run_datetime, RunParameters.for_date == for_date, ) - .order_by(AdvisoryFuelStats.fuel_type) - .order_by(AdvisoryFuelStats.threshold) ) + result = await session.execute(stmt) all_results = result.all() perf_end = perf_counter() delta = perf_end - perf_start - logger.info("%f delta count before and after fuel types/high hfi/zone query", delta) + logger.info("%f delta count before and after advisory stats query", delta) return all_results +async def get_fuel_type_stats_in_advisory_area(session: AsyncSession, advisory_shape_id: int, run_parameters_id: int) -> List[Tuple[AdvisoryFuelStats, SFMSFuelType]]: + stmt = ( + select(AdvisoryFuelStats, SFMSFuelType) + .join_from(AdvisoryFuelStats, SFMSFuelType, AdvisoryFuelStats.fuel_type == SFMSFuelType.id) + .filter(AdvisoryFuelStats.advisory_shape_id == advisory_shape_id, AdvisoryFuelStats.run_parameters == run_parameters_id) + ) + result = await session.execute(stmt) + return result.all() + + async def get_high_hfi_fuel_types_for_shape(session: AsyncSession, run_type: RunTypeEnum, run_datetime: datetime, for_date: date, shape_id: int) -> List[Row]: """ Union of fuel types by fuel_type_id (1 multipolygon for each fuel type) @@ -243,6 +284,33 @@ async def get_run_datetimes(session: AsyncSession, run_type: RunTypeEnum, for_da return result.all() +async def get_most_recent_run_parameters(session: AsyncSession, run_type: RunTypeEnum, for_date: date) -> List[Row]: + """ + Retrieve the most recent sfms run parameters record for the specified run type and for date. + + :param session: Async database read session. + :param run_type: Type of run (forecast or actual). + :param for_date: The date of interest. + :return: The most recent sfms run parameters record for the specified run type and for date, otherwise return None. + """ + stmt = select(RunParameters).where(RunParameters.run_type == run_type.value, RunParameters.for_date == for_date).distinct().order_by(RunParameters.run_datetime.desc()).limit(1) + result = await session.execute(stmt) + return result.first() + + +async def get_run_parameters_by_id(session: AsyncSession, id: int) -> RunParameters: + """ + Retrieve the RunParameters record with the specified id. + + :param session: Async database session. + :param id: The id of the RunParameters record. + :return: The RunParameters with the specified id. + """ + stmt = select(RunParameters).where(RunParameters.id == id) + result = await session.execute(stmt) + return result.first() + + async def get_high_hfi_area(session: AsyncSession, run_type: RunTypeEnum, run_datetime: datetime, for_date: date) -> List[Row]: """For each fire zone, get the area of HFI polygons in that zone that fall within the 4000 - 10000 range and the area of HFI polygons that exceed the 10000 threshold. @@ -267,7 +335,7 @@ async def store_advisory_fuel_stats(session: AsyncSession, fuel_type_areas: dict :param : A dictionary keyed by fuel type code with value representing an area in square meters. :param threshold: The current threshold being processed, 1 = 4k-10k, 2 = > 10k. :param run_parameters_id: The RunParameter object id associated with the run_type, for_date and run_datetime of interest. - :param advisory_shape_id: The id of advisory shape (eg. fire zone unit) the fuel type area has been calcualted for. + :param advisory_shape_id: The id of advisory shape (eg. fire zone unit) the fuel type area has been calculated for. """ advisory_fuel_stats = [] for key in fuel_type_areas: @@ -352,23 +420,41 @@ async def get_zonal_elevation_stats(session: AsyncSession, fire_zone_id: int, ru return await session.execute(stmt) -async def get_zonal_tpi_stats(session: AsyncSession, fire_zone_id: int, run_type: RunType, run_datetime: datetime, for_date: date) -> AdvisoryTPIStats: +async def get_zonal_tpi_stats(session: AsyncSession, fire_zone_id: int, run_type: RunType, run_datetime: datetime, for_date: date) -> Optional[AdvisoryTPIStats]: run_parameters_id = await get_run_parameters_id(session, run_type, run_datetime, for_date) stmt = select(Shape.id).where(Shape.source_identifier == str(fire_zone_id)) result = await session.execute(stmt) shape_id = result.scalar() stmt = select( - AdvisoryTPIStats.advisory_shape_id, - AdvisoryTPIStats.valley_bottom, - AdvisoryTPIStats.mid_slope, - AdvisoryTPIStats.upper_slope, + AdvisoryTPIStats.advisory_shape_id, AdvisoryTPIStats.valley_bottom, AdvisoryTPIStats.mid_slope, AdvisoryTPIStats.upper_slope, AdvisoryTPIStats.pixel_size_metres ).where(AdvisoryTPIStats.advisory_shape_id == shape_id, AdvisoryTPIStats.run_parameters == run_parameters_id) result = await session.execute(stmt) return result.first() +async def get_centre_tpi_stats(session: AsyncSession, fire_centre_name: str, run_type: RunType, run_datetime: datetime, for_date: date) -> AdvisoryTPIStats: + run_parameters_id = await get_run_parameters_id(session, run_type, run_datetime, for_date) + + stmt = ( + select( + AdvisoryTPIStats.advisory_shape_id, + Shape.source_identifier, + AdvisoryTPIStats.valley_bottom, + AdvisoryTPIStats.mid_slope, + AdvisoryTPIStats.upper_slope, + AdvisoryTPIStats.pixel_size_metres, + ) + .join(Shape, Shape.id == AdvisoryTPIStats.advisory_shape_id) + .join(FireCentre, FireCentre.id == Shape.fire_centre) + .where(FireCentre.name == fire_centre_name, AdvisoryTPIStats.run_parameters == run_parameters_id) + ) + + result = await session.execute(stmt) + return result.all() + + async def get_provincial_rollup(session: AsyncSession, run_type: RunTypeEnum, run_datetime: datetime, for_date: date) -> List[Row]: logger.info("gathering provincial rollup") run_parameter_id = await get_run_parameters_id(session, run_type, run_datetime, for_date) @@ -377,7 +463,7 @@ async def get_provincial_rollup(session: AsyncSession, run_type: RunTypeEnum, ru Shape.id, Shape.source_identifier, Shape.combustible_area, - Shape.label, + Shape.placename_label, FireCentre.name.label("fire_centre_name"), HighHfiArea.id, HighHfiArea.advisory_shape_id, @@ -389,3 +475,29 @@ async def get_provincial_rollup(session: AsyncSession, run_type: RunTypeEnum, ru ) result = await session.execute(stmt) return result.all() + + +async def get_containing_zone(session: AsyncSession, geometry: str, srid: int): + geom = func.ST_Transform(func.ST_GeomFromText(geometry, srid), 3005) + stmt = select(Shape.id).filter(func.ST_Contains(Shape.geom, geom)) + result = await session.execute(stmt) + return result.first() + + +async def save_all_critical_hours(session: AsyncSession, critical_hours: List[CriticalHours]): + session.add_all(critical_hours) + + +async def get_critical_hours_for_run_parameters(session: AsyncSession, run_type: RunTypeEnum, run_datetime: datetime, for_date: date): + stmt = ( + select(CriticalHours) + .join_from(CriticalHours, RunParameters, CriticalHours.run_parameters == RunParameters.id) + .where( + RunParameters.run_type == run_type.value, + RunParameters.run_datetime == run_datetime, + RunParameters.for_date == for_date, + ) + .group_by(CriticalHours.advisory_shape_id) + ) + result = await session.execute(stmt) + return result diff --git a/api/app/db/models/__init__.py b/api/app/db/models/__init__.py index 44f30fe48..c44495148 100644 --- a/api/app/db/models/__init__.py +++ b/api/app/db/models/__init__.py @@ -20,8 +20,18 @@ ModelRunForSFMS, ) from app.db.models.hfi_calc import (FireCentre, FuelType, PlanningArea, PlanningWeatherStation) -from app.db.models.auto_spatial_advisory import (Shape, ShapeType, HfiClassificationThreshold, - ClassifiedHfi, RunTypeEnum, ShapeTypeEnum, FuelType, HighHfiArea, RunParameters) +from app.db.models.auto_spatial_advisory import ( + Shape, + ShapeType, + HfiClassificationThreshold, + ClassifiedHfi, + RunTypeEnum, + ShapeTypeEnum, + FuelType, + HighHfiArea, + RunParameters, + CriticalHours, +) from app.db.models.morecast_v2 import MorecastForecastRecord from app.db.models.snow import ProcessedSnow, SnowSourceEnum from app.db.models.grass_curing import PercentGrassCuring diff --git a/api/app/db/models/auto_spatial_advisory.py b/api/app/db/models/auto_spatial_advisory.py index ecee5e1e6..1c248613d 100644 --- a/api/app/db/models/auto_spatial_advisory.py +++ b/api/app/db/models/auto_spatial_advisory.py @@ -8,6 +8,13 @@ from sqlalchemy.dialects import postgresql +class HfiClassificationThresholdEnum(enum.Enum): + """Enum for the different HFI classification thresholds.""" + + ADVISORY = "advisory" + WARNING = "warning" + + class ShapeTypeEnum(enum.Enum): """Define different shape types. e.g. "Zone", "Fire Centre" - later we may add "Incident"/"Fire", "Custom" etc. etc.""" @@ -57,6 +64,7 @@ class Shape(Base): combustible_area = Column(Float, nullable=True) geom = Column(Geometry("MULTIPOLYGON", spatial_index=False, srid=NAD83_BC_ALBERS), nullable=False) label = Column(String, nullable=True, index=False) + placename_label = Column(String, nullable=True, index=False) fire_centre = Column(Integer, ForeignKey(FireCentre.id), nullable=True, index=True) @@ -203,3 +211,19 @@ class AdvisoryTPIStats(Base): mid_slope = Column(Integer, nullable=False, index=False) upper_slope = Column(Integer, nullable=False, index=False) pixel_size_metres = Column(Integer, nullable=False, index=False) + + +class CriticalHours(Base): + """ + Critical hours for a fuel type in a firezone unit. + """ + + __tablename__ = "critical_hours" + __table_args__ = {"comment": "Critical hours by firezone unit, fuel type and sfms run parameters."} + id = Column(Integer, primary_key=True, index=True) + advisory_shape_id = Column(Integer, ForeignKey(Shape.id), nullable=False, index=True) + threshold = Column(postgresql.ENUM("advisory", "warning", name="hficlassificationthresholdenum", create_type=False), nullable=False) + run_parameters = Column(Integer, ForeignKey(RunParameters.id), nullable=False, index=True) + fuel_type = Column(Integer, ForeignKey(SFMSFuelType.id), nullable=False, index=True) + start_hour = Column(Integer, nullable=False) + end_hour = Column(Integer, nullable=False) diff --git a/api/app/main.py b/api/app/main.py index 4779f481b..2d8ddc8ae 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -16,8 +16,7 @@ from app import health from app import hourlies from app.rocketchat_notifications import send_rocketchat_notification -from app.routers import (fba, forecasts, weather_models, c_haines, stations, hfi_calc, - fba_calc, sfms, morecast_v2, snow) +from app.routers import fba, forecasts, weather_models, c_haines, stations, hfi_calc, fba_calc, sfms, morecast_v2 from app.fire_behaviour.cffdrs import CFFDRS @@ -123,7 +122,6 @@ async def catch_exception_middleware(request: Request, call_next): api.include_router(fba.router, tags=["Auto Spatial Advisory"]) api.include_router(sfms.router, tags=["SFMS", "Auto Spatial Advisory"]) api.include_router(morecast_v2.router, tags=["Morecast v2"]) -api.include_router(snow.router, tags=['Snow']) @api.get('/ready') diff --git a/api/app/routers/fba.py b/api/app/routers/fba.py index 7af041a04..05082643f 100644 --- a/api/app/routers/fba.py +++ b/api/app/routers/fba.py @@ -11,21 +11,20 @@ get_all_sfms_fuel_types, get_all_hfi_thresholds, get_hfi_area, - get_precomputed_high_hfi_fuel_type_areas_for_shape, + get_precomputed_stats_for_shape, get_provincial_rollup, get_run_datetimes, - get_zonal_elevation_stats, get_zonal_tpi_stats, + get_centre_tpi_stats, + get_zone_ids_in_centre, ) from app.db.models.auto_spatial_advisory import RunTypeEnum from app.schemas.fba import ( + AdvisoryCriticalHours, ClassifiedHfiThresholdFuelTypeArea, FireCenterListResponse, FireShapeAreaListResponse, FireShapeArea, - FireZoneElevationStats, - FireZoneElevationStatsByThreshold, - FireZoneElevationStatsListResponse, FireZoneTPIStats, SFMSFuelType, HfiThreshold, @@ -92,7 +91,7 @@ async def get_provincial_summary(run_type: RunType, run_datetime: datetime, for_ fire_shape_area_details.append( FireShapeAreaDetail( fire_shape_id=row.source_identifier, - fire_shape_name=row.label, + fire_shape_name=row.placename_label, fire_centre_name=row.fire_centre_name, threshold=row.threshold, combustible_area=row.combustible_area, @@ -103,42 +102,46 @@ async def get_provincial_summary(run_type: RunType, run_datetime: datetime, for_ return ProvincialSummaryResponse(provincial_summary=fire_shape_area_details) -@router.get("/hfi-fuels/{run_type}/{for_date}/{run_datetime}/{zone_id}", response_model=dict[int, List[ClassifiedHfiThresholdFuelTypeArea]]) -async def get_hfi_fuels_data_for_fire_zone(run_type: RunType, for_date: date, run_datetime: datetime, zone_id: int): +@router.get("/fire-centre-hfi-stats/{run_type}/{for_date}/{run_datetime}/{fire_centre_name}", response_model=dict[str, dict[int, List[ClassifiedHfiThresholdFuelTypeArea]]]) +async def get_hfi_fuels_data_for_fire_centre(run_type: RunType, for_date: date, run_datetime: datetime, fire_centre_name: str): """ - Fetch rollup of fuel type/HFI threshold/area data for a specified fire zone. + Fetch fuel type and critical hours data for all fire zones in a fire centre for a given date """ - logger.info("hfi-fuels/%s/%s/%s/%s", run_type.value, for_date, run_datetime, zone_id) + logger.info("fire-centre-hfi-stats/%s/%s/%s/%s", run_type.value, for_date, run_datetime, fire_centre_name) async with get_async_read_session_scope() as session: # get thresholds data thresholds = await get_all_hfi_thresholds(session) # get fuel type ids data fuel_types = await get_all_sfms_fuel_types(session) - - # get HFI/fuels data for specific zone - hfi_fuel_type_ids_for_zone = await get_precomputed_high_hfi_fuel_type_areas_for_shape( - session, run_type=RunTypeEnum(run_type.value), for_date=for_date, run_datetime=run_datetime, advisory_shape_id=zone_id - ) - data = [] - - for record in hfi_fuel_type_ids_for_zone: - fuel_type_id = record[1] - threshold_id = record[2] - # area is stored in square metres in DB. For user convenience, convert to hectares - # 1 ha = 10,000 sq.m. - area = record[3] / 10000 - fuel_type_obj = next((ft for ft in fuel_types if ft.fuel_type_id == fuel_type_id), None) - threshold_obj = next((th for th in thresholds if th.id == threshold_id), None) - data.append( - ClassifiedHfiThresholdFuelTypeArea( - fuel_type=SFMSFuelType(fuel_type_id=fuel_type_obj.fuel_type_id, fuel_type_code=fuel_type_obj.fuel_type_code, description=fuel_type_obj.description), - threshold=HfiThreshold(id=threshold_obj.id, name=threshold_obj.name, description=threshold_obj.description), - area=area, - ) + # get fire zone id's within a fire centre + zone_ids = await get_zone_ids_in_centre(session, fire_centre_name) + + all_zone_data = {} + for zone_id in zone_ids: + # get HFI/fuels data for specific zone + hfi_fuel_type_ids_for_zone = await get_precomputed_stats_for_shape( + session, run_type=RunTypeEnum(run_type.value), for_date=for_date, run_datetime=run_datetime, advisory_shape_id=zone_id ) + zone_data = [] + + for critical_hour_start, critical_hour_end, fuel_type_id, threshold_id, area in hfi_fuel_type_ids_for_zone: + # area is stored in square metres in DB. For user convenience, convert to hectares + # 1 ha = 10,000 sq.m. + area = area / 10000 + fuel_type_obj = next((ft for ft in fuel_types if ft.fuel_type_id == fuel_type_id), None) + threshold_obj = next((th for th in thresholds if th.id == threshold_id), None) + zone_data.append( + ClassifiedHfiThresholdFuelTypeArea( + fuel_type=SFMSFuelType(fuel_type_id=fuel_type_obj.fuel_type_id, fuel_type_code=fuel_type_obj.fuel_type_code, description=fuel_type_obj.description), + threshold=HfiThreshold(id=threshold_obj.id, name=threshold_obj.name, description=threshold_obj.description), + critical_hours=AdvisoryCriticalHours(start_time=critical_hour_start, end_time=critical_hour_end), + area=area, + ) + ) + all_zone_data[zone_id] = zone_data - return {zone_id: data} + return {fire_centre_name: all_zone_data} @router.get("/sfms-run-datetimes/{run_type}/{for_date}", response_model=List[datetime]) @@ -156,29 +159,39 @@ async def get_run_datetimes_for_date_and_runtype(run_type: RunType, for_date: da return datetimes -@router.get("/fire-zone-elevation-info/{run_type}/{for_date}/{run_datetime}/{fire_zone_id}", response_model=FireZoneElevationStatsListResponse) -async def get_fire_zone_elevation_stats(fire_zone_id: int, run_type: RunType, run_datetime: datetime, for_date: date, _=Depends(authentication_required)): - """Return the elevation statistics for each advisory threshold""" - async with get_async_read_session_scope() as session: - data = [] - rows = await get_zonal_elevation_stats(session, fire_zone_id, run_type, run_datetime, for_date) - for row in rows: - stats = FireZoneElevationStats(minimum=row.minimum, quartile_25=row.quartile_25, median=row.median, quartile_75=row.quartile_75, maximum=row.maximum) - stats_by_threshold = FireZoneElevationStatsByThreshold(threshold=row.threshold, elevation_info=stats) - data.append(stats_by_threshold) - return FireZoneElevationStatsListResponse(hfi_elevation_info=data) - - @router.get("/fire-zone-tpi-stats/{run_type}/{for_date}/{run_datetime}/{fire_zone_id}", response_model=FireZoneTPIStats) async def get_fire_zone_tpi_stats(fire_zone_id: int, run_type: RunType, run_datetime: datetime, for_date: date, _=Depends(authentication_required)): """Return the elevation TPI statistics for each advisory threshold""" logger.info("/fba/fire-zone-tpi-stats/") async with get_async_read_session_scope() as session: stats = await get_zonal_tpi_stats(session, fire_zone_id, run_type, run_datetime, for_date) - square_metres = math.pow(stats.pixel_size_metres, 2) + square_metres = math.pow(stats.pixel_size_metres, 2) if stats is not None else None return FireZoneTPIStats( fire_zone_id=fire_zone_id, - valley_bottom=stats.valley_bottom * square_metres, - mid_slope=stats.mid_slope * square_metres, - upper_slope=stats.upper_slope * square_metres, + valley_bottom=stats.valley_bottom * square_metres if stats is not None else None, + mid_slope=stats.mid_slope * square_metres if stats is not None else None, + upper_slope=stats.upper_slope * square_metres if stats is not None else None, ) + + +@router.get("/fire-centre-tpi-stats/{run_type}/{for_date}/{run_datetime}/{fire_centre_name}", response_model=dict[str, List[FireZoneTPIStats]]) +async def get_fire_centre_tpi_stats(fire_centre_name: str, run_type: RunType, run_datetime: datetime, for_date: date, _=Depends(authentication_required)): + """Return the elevation TPI statistics for each advisory threshold for a fire centre""" + logger.info("/fba/fire-centre-tpi-stats/") + async with get_async_read_session_scope() as session: + tpi_stats_for_centre = await get_centre_tpi_stats(session, fire_centre_name, run_type, run_datetime, for_date) + + data = [] + for row in tpi_stats_for_centre: + square_metres = math.pow(row.pixel_size_metres, 2) + + data.append( + FireZoneTPIStats( + fire_zone_id=row.source_identifier, + valley_bottom=row.valley_bottom * square_metres, + mid_slope=row.mid_slope * square_metres, + upper_slope=row.upper_slope * square_metres, + ) + ) + + return {fire_centre_name: data} diff --git a/api/app/routers/snow.py b/api/app/routers/snow.py deleted file mode 100644 index 48e9e4537..000000000 --- a/api/app/routers/snow.py +++ /dev/null @@ -1,30 +0,0 @@ -""" Routers for Snow related data -""" - -import logging -from datetime import date, datetime -from fastapi import APIRouter, Depends -from app.auth import authentication_required, audit -from app.db.crud.snow import get_most_recent_processed_snow_by_date -from app.db.database import get_async_read_session_scope -from app.schemas.snow import ProcessedSnowModel, ProcessedSnowResponse -from app.utils.time import vancouver_tz - -logger = logging.getLogger(__name__) - -router = APIRouter( - prefix="/snow", - dependencies=[Depends(authentication_required), Depends(audit)], -) - - -@router.get('/most-recent-by-date/{for_date}', response_model=ProcessedSnowResponse | None) -async def get_most_recent_by_date(for_date: date, _=Depends(authentication_required)): - """ Returns the most recent processed snow record before or equal to the provided date. """ - logger.info('/snow/most-recent-by-date/') - tz_aware_datetime = vancouver_tz.localize(datetime.combine(for_date, datetime.min.time())) - async with get_async_read_session_scope() as session: - result = await get_most_recent_processed_snow_by_date(session, tz_aware_datetime) - if result is not None: - processed_snow = result[0] - return ProcessedSnowResponse(processed_snow=ProcessedSnowModel(for_date=processed_snow.for_date, processed_date=processed_snow.processed_date, snow_source=processed_snow.snow_source)) diff --git a/api/app/schemas/fba.py b/api/app/schemas/fba.py index 4a3c60a82..ef5583aa7 100644 --- a/api/app/schemas/fba.py +++ b/api/app/schemas/fba.py @@ -92,6 +92,13 @@ class SFMSFuelType(BaseModel): description: str +class AdvisoryCriticalHours(BaseModel): + """Critical Hours for an advisory.""" + + start_time: Optional[float] + end_time: Optional[float] + + class ClassifiedHfiThresholdFuelTypeArea(BaseModel): """Collection of data objects recording the area within an advisory shape that meets a particular HfiThreshold for a specific SFMSFuelType @@ -99,6 +106,7 @@ class ClassifiedHfiThresholdFuelTypeArea(BaseModel): fuel_type: SFMSFuelType threshold: HfiThreshold + critical_hours: AdvisoryCriticalHours area: float @@ -116,9 +124,9 @@ class FireZoneTPIStats(BaseModel): """Classified TPI areas of the fire zone contributing to the HFI >4k. Each area is in square metres.""" fire_zone_id: int - valley_bottom: int - mid_slope: int - upper_slope: int + valley_bottom: Optional[int] + mid_slope: Optional[int] + upper_slope: Optional[int] class FireZoneElevationStatsByThreshold(BaseModel): diff --git a/api/app/tests/auto_spatial_advisory/__init__.py b/api/app/tests/auto_spatial_advisory/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/api/app/tests/auto_spatial_advisory/test_critical_hours.py b/api/app/tests/auto_spatial_advisory/test_critical_hours.py new file mode 100644 index 000000000..eb3ee4b93 --- /dev/null +++ b/api/app/tests/auto_spatial_advisory/test_critical_hours.py @@ -0,0 +1,140 @@ +import os +import pytest +import math +import numpy as np +import json +from app.auto_spatial_advisory.critical_hours import CriticalHoursInputs, calculate_representative_hours, check_station_valid, determine_start_time, determine_end_time +from app.schemas.fba_calc import CriticalHoursHFI +from app.wildfire_one.schema_parsers import WFWXWeatherStation + +dirname = os.path.dirname(__file__) +dailies_fixture = os.path.join(dirname, "wf1-dailies.json") +hourlies_fixture = os.path.join(dirname, "wf1-hourlies.json") +mock_station = WFWXWeatherStation(wfwx_id="bb7cb089-286a-4734-e053-1d09228eeca8", code=169, name="UPPER FULTON", latitude=55.03395, longitude=-126.799667, elevation=900, zone_code=45) + + +def test_check_station_valid(): + with open(dailies_fixture, "r") as dailies: + raw_dailies = json.load(dailies)["_embedded"]["dailies"] + dailies_by_station_id = {raw_dailies[0]["stationId"]: raw_dailies[0]} + hourlies_by_station_code = {raw_dailies[0]["stationData"]["stationCode"]: []} + assert ( + check_station_valid( + mock_station, + critical_hours_inputs=CriticalHoursInputs( + dailies_by_station_id=dailies_by_station_id, yesterday_dailies_by_station_id={}, hourly_observations_by_station_code=hourlies_by_station_code + ), + ) + == True + ) + + +@pytest.mark.parametrize( + "index_key", + ["duffMoistureCode", "droughtCode", "fineFuelMoistureCode"], +) +def test_check_station_invalid_missing_indices(index_key): + """ + When a daily is missing DMC, DC or FFMC it is invalid + + :param index_key: DMC, DC or FFMC key for WF1 daily + """ + with open(dailies_fixture, "r") as dailies: + raw_dailies = json.load(dailies)["_embedded"]["dailies"] + daily = raw_dailies[0] + daily[index_key] = None + dailies_by_station_id = {raw_dailies[0]["stationId"]: daily} + hourlies_by_station_code = {raw_dailies[0]["stationData"]["stationCode"]: []} + assert ( + check_station_valid( + mock_station, + critical_hours_inputs=CriticalHoursInputs( + dailies_by_station_id=dailies_by_station_id, yesterday_dailies_by_station_id={}, hourly_observations_by_station_code=hourlies_by_station_code + ), + ) + == False + ) + + +def test_check_station_invalid_missing_daily(): + """ + When a station daily is missing for a station it is invalid + """ + with open(hourlies_fixture, "r") as hourlies: + raw_hourlies = json.load(hourlies)["_embedded"]["hourlies"] + dailies_by_station_id = {} + hourlies_by_station_code = {raw_hourlies[0]["stationData"]["stationCode"]: raw_hourlies[0]} + assert ( + check_station_valid( + mock_station, + critical_hours_inputs=CriticalHoursInputs( + dailies_by_station_id=dailies_by_station_id, yesterday_dailies_by_station_id={}, hourly_observations_by_station_code=hourlies_by_station_code + ), + ) + == False + ) + + +def test_check_station_invalid_missing_hourly(): + """ + When a station hourly is missing for a station it is invalid + """ + with open(dailies_fixture, "r") as dailies: + raw_dailies = json.load(dailies)["_embedded"]["dailies"] + dailies_by_station_id = {raw_dailies[0]["stationId"]: raw_dailies[0]} + hourlies_by_station_code = {} + assert ( + check_station_valid( + mock_station, + critical_hours_inputs=CriticalHoursInputs( + dailies_by_station_id=dailies_by_station_id, yesterday_dailies_by_station_id={}, hourly_observations_by_station_code=hourlies_by_station_code + ), + ) + == False + ) + + +@pytest.mark.parametrize( + "start_times, expected_start_time", + [ + ([1, 2], 1), + ([1, 2, 3], math.floor(np.percentile([1, 2, 3], 25))), + ], +) +def test_determine_start_time(start_times, expected_start_time): + """ + Given a list of start times, choose the minimum if less than 3, otherwise the 25th percentile + """ + assert determine_start_time(start_times) == expected_start_time + + +@pytest.mark.parametrize( + "start_times, expected_start_time", + [ + ([1, 2], 2), + ([1, 2, 3], math.ceil(np.percentile([1, 2, 3], 75))), + ], +) +def test_determine_end_time(start_times, expected_start_time): + """ + Given a list of end times, choose them maximum if less than 3, otherwise the 75th percentile + """ + assert determine_end_time(start_times) == expected_start_time + + +@pytest.mark.parametrize( + "critical_hours, expected_start_end", + [ + ([CriticalHoursHFI(start=1, end=2), CriticalHoursHFI(start=1, end=2)], (1, 2)), + ([CriticalHoursHFI(start=1, end=2), CriticalHoursHFI(start=2, end=14)], (1, 14)), + ( + [CriticalHoursHFI(start=1, end=1), CriticalHoursHFI(start=2, end=2), CriticalHoursHFI(start=1, end=3)], + (math.floor(np.percentile([1, 2, 1], 25)), math.ceil(np.percentile([1, 2, 3], 75))), + ), + ], +) +def test_representative_hours(critical_hours, expected_start_end): + """ + Given a list of critical hours, return the representative critical hours + """ + assert calculate_representative_hours(critical_hours) == expected_start_end diff --git a/api/app/tests/auto_spatial_advisory/wf1-dailies.json b/api/app/tests/auto_spatial_advisory/wf1-dailies.json new file mode 100644 index 000000000..e0e46a818 --- /dev/null +++ b/api/app/tests/auto_spatial_advisory/wf1-dailies.json @@ -0,0 +1,216 @@ +{ + "_embedded": { + "dailies": [ + { + "id": "8c1ce233-3ac4-4061-86a8-fcf412c65567", + "createdBy": "WFWX_PREDICTIVE_SERVICES", + "lastEntityUpdateTimestamp": 1723063114051, + "updateDate": "2024-08-07T20:38:34.000+0000", + "lastModifiedBy": "WFWX_PREDICTIVE_SERVICES", + "archive": false, + "station": "https://t1bcwsapi.nrs.gov.bc.ca/wfwx-fireweather-api/v1/stations/bb7cb089-286a-4734-e053-1d09228eeca8", + "stationId": "bb7cb089-286a-4734-e053-1d09228eeca8", + "stationData": { + "id": "bb7cb089-286a-4734-e053-1d09228eeca8", + "displayLabel": "UPPER FULTON", + "createdBy": "LEGACY_DATA_LOAD", + "lastModifiedBy": "DATAFIX - WFWX-892", + "lastEntityUpdateTimestamp": 1613486000000, + "updateDate": "2021-12-07T18:37:45.000+0000", + "stationCode": 169, + "stationAcronym": "FUF", + "stationStatus": { + "id": "ACTIVE", + "displayLabel": "Active", + "displayOrder": 1, + "effectiveDate": "2000-01-01T08:00:00.000+0000", + "expiryDate": "9999-12-31T08:00:00.000+0000" + }, + "dataSource": { + "id": "HRLY_POLL", + "displayLabel": "Hourly Polling", + "displayOrder": 2, + "effectiveDate": "1950-01-01T08:00:00.000+0000", + "expiryDate": "9999-12-31T08:00:00.000+0000", + "createdBy": "DATA_LOAD", + "createdDate": "2020-02-25T08:00:00.000+0000", + "lastModifiedBy": "DATA_LOAD", + "lastModifiedDate": "2020-02-25T08:00:00.000+0000" + }, + "dataLoggerType": { + "id": "FWS12S", + "displayLabel": "FWS12S", + "displayOrder": 6, + "effectiveDate": "2000-01-01T08:00:00.000+0000", + "expiryDate": "9999-12-31T08:00:00.000+0000", + "createdBy": "DATA_LOAD", + "createdDate": "2020-02-25T08:00:00.000+0000", + "lastModifiedBy": "DATA_LOAD", + "lastModifiedDate": "2020-02-25T08:00:00.000+0000" + }, + "siteType": { + "id": "WXSTN_UHF", + "displayLabel": "Weather Station - UHF", + "displayOrder": 7, + "effectiveDate": "2000-01-01T08:00:00.000+0000", + "expiryDate": "9999-12-31T08:00:00.000+0000", + "createdBy": "DATA_LOAD", + "createdDate": "2020-02-25T08:00:00.000+0000", + "lastModifiedBy": "DATA_LOAD", + "lastModifiedDate": "2020-02-25T08:00:00.000+0000" + }, + "weatherZone": { + "id": 13, + "displayLabel": "Zone 13", + "dangerRegion": 1, + "displayOrder": 13 + }, + "stationAccessTypeCode": { + "id": "4WD", + "displayLabel": "4 Wheel Drive", + "displayOrder": 1, + "effectiveDate": "2000-01-01T08:00:00.000+0000", + "expiryDate": "9999-12-31T08:00:00.000+0000", + "createdBy": "DATA_LOAD", + "createdDate": "2020-02-25T08:00:00.000+0000", + "lastModifiedBy": "DATA_LOAD", + "lastModifiedDate": "2020-02-25T08:00:00.000+0000" + }, + "hourly": true, + "pluvioSnowGuage": false, + "latitude": 55.03395, + "longitude": -126.799667, + "geometry": { + "crs": { + "type": "name", + "properties": { + "name": "EPSG:4326" + } + }, + "coordinates": [ + -126.799667, + 55.03395 + ], + "type": "Point" + }, + "elevation": 900, + "windspeedAdjRoughness": 1.0, + "windspeedHeight": { + "id": "9.0_11.9", + "displayLabel": "9.0 - 11.9 (m)", + "displayOrder": 7, + "effectiveDate": "2000-01-01T08:00:00.000+0000", + "expiryDate": "9999-12-31T08:00:00.000+0000", + "createdBy": "DATA_LOAD", + "createdDate": "2020-02-25T08:00:00.000+0000", + "lastModifiedBy": "DATA_LOAD", + "lastModifiedDate": "2020-02-25T08:00:00.000+0000" + }, + "surfaceType": null, + "overWinterPrecipAdj": false, + "influencingSlope": 5, + "installationDate": 672562800000, + "decommissionDate": null, + "influencingAspect": null, + "owner": { + "id": "a9843bb7-0f44-4a80-8a62-79cca2a5c758", + "displayLabel": "BC Wildfire Service", + "displayOrder": 2, + "ownerName": "BCWS", + "ownerDisplayName": "BC Wildfire Service", + "ownerComment": null, + "createdBy": "DATA_LOAD", + "lastModifiedBy": "DATAFIX - WFWX-891", + "effectiveDate": "1950-01-01T08:00:00.000+0000", + "expiryDate": "9999-12-31T08:00:00.000+0000" + }, + "fireCentre": { + "id": 42, + "displayLabel": "Northwest Fire Centre", + "alias": 3, + "expiryDate": "9999-12-31T08:00:00.000+0000" + }, + "zone": { + "id": 45, + "displayLabel": "Bulkley Zone", + "alias": 3, + "fireCentre": "Northwest Fire Centre", + "fireCentreAlias": 3, + "expiryDate": "9999-12-31T08:00:00.000+0000" + }, + "comments": "1st Daily 19860610;", + "ecccFileBaseUrlTemplate": null, + "lastProcessedSourceTime": null, + "crdStationName": null, + "stationAccessDescription": "Proceed North from Smithers on the Babine Lake Rd 37.5km to the3000Road just before you get to Chapman Lake - Turn Left (NW) onto the 3000Rd - Proceed for 21.3km -WALK IN FROM HERE (Site is quad accessible) - Take Left (W) onto a secondary Rd into a plantation for 400meters - Station is on the South side of the Rd ***SITE TURN OFF FROM THE UPPER FULTON IS APROX 0.5KM PAST THE FULTON RIVER BRIDGE (FIRST MAIN TURNOFFTO THE WEST)" + }, + "weatherTimestamp": 1723752000000, + "temperature": 22.0, + "dewPoint": 13.2, + "temperatureMin": null, + "temperatureMax": null, + "relativeHumidity": 57.0, + "relativeHumidityMin": null, + "relativeHumidityMax": null, + "windSpeed": 10.0, + "adjustedWindSpeed": 10.0, + "precipitation": 0.0, + "dangerForest": null, + "dangerGrassland": null, + "dangerScrub": null, + "recordType": { + "id": "FORECAST", + "displayLabel": "Forecast", + "displayOrder": 2, + "createdBy": "DATA_LOAD", + "lastModifiedBy": "DATA_LOAD" + }, + "windDirection": null, + "fineFuelMoistureCode": 1, + "duffMoistureCode": 1, + "droughtCode": 1, + "initialSpreadIndex": null, + "buildUpIndex": null, + "fireWeatherIndex": null, + "dailySeverityRating": null, + "grasslandCuring": null, + "observationValidInd": true, + "observationValidComment": null, + "missingHourlyData": null, + "previousState": null, + "businessKey": "1723752000000-bb7cb089-286a-4734-e053-1d09228eeca8", + "_links": { + "self": { + "href": "https://t1bcwsapi.nrs.gov.bc.ca/wfwx-fireweather-api/v1/dailies/8c1ce233-3ac4-4061-86a8-fcf412c65567" + }, + "daily": { + "href": "https://t1bcwsapi.nrs.gov.bc.ca/wfwx-fireweather-api/v1/dailies/8c1ce233-3ac4-4061-86a8-fcf412c65567" + }, + "station": { + "href": "https://t1bcwsapi.nrs.gov.bc.ca/wfwx-fireweather-api/v1/stations/bb7cb089-286a-4734-e053-1d09228eeca8" + } + } + } + ] + }, + "_links": { + "first": { + "href": "https://t1bcwsapi.nrs.gov.bc.ca/wfwx-fireweather-api/v1/dailies/search/findDailiesByStationId?stationId=bb7cb089-286a-4734-e053-1d09228eeca8&page=0&size=20" + }, + "self": { + "href": "https://t1bcwsapi.nrs.gov.bc.ca/wfwx-fireweather-api/v1/dailies/search/findDailiesByStationId?stationId=bb7cb089-286a-4734-e053-1d09228eeca8&page=0&size=20" + }, + "next": { + "href": "https://t1bcwsapi.nrs.gov.bc.ca/wfwx-fireweather-api/v1/dailies/search/findDailiesByStationId?stationId=bb7cb089-286a-4734-e053-1d09228eeca8&page=1&size=20" + }, + "last": { + "href": "https://t1bcwsapi.nrs.gov.bc.ca/wfwx-fireweather-api/v1/dailies/search/findDailiesByStationId?stationId=bb7cb089-286a-4734-e053-1d09228eeca8&page=89&size=20" + } + }, + "page": { + "size": 1, + "totalElements": 1, + "totalPages": 1, + "number": 0 + } +} \ No newline at end of file diff --git a/api/app/tests/auto_spatial_advisory/wf1-hourlies.json b/api/app/tests/auto_spatial_advisory/wf1-hourlies.json new file mode 100644 index 000000000..0b8a8d3dc --- /dev/null +++ b/api/app/tests/auto_spatial_advisory/wf1-hourlies.json @@ -0,0 +1,213 @@ +{ + "_embedded": { + "hourlies": [ + { + "id": "bb7cb092-4427-4734-e053-1d09228eeca8", + "station": "https://t1bcwsapi.nrs.gov.bc.ca/wfwx-fireweather-api/v1/stations/bb7cb089-286a-4734-e053-1d09228eeca8", + "stationId": "bb7cb089-286a-4734-e053-1d09228eeca8", + "stationData": { + "id": "bb7cb089-286a-4734-e053-1d09228eeca8", + "displayLabel": "UPPER FULTON", + "createdBy": "LEGACY_DATA_LOAD", + "lastModifiedBy": "DATAFIX - WFWX-892", + "lastEntityUpdateTimestamp": 1613486000000, + "updateDate": "2021-12-07T18:37:45.000+0000", + "stationCode": 169, + "stationAcronym": "FUF", + "stationStatus": { + "id": "ACTIVE", + "displayLabel": "Active", + "displayOrder": 1, + "effectiveDate": "2000-01-01T08:00:00.000+0000", + "expiryDate": "9999-12-31T08:00:00.000+0000" + }, + "dataSource": { + "id": "HRLY_POLL", + "displayLabel": "Hourly Polling", + "displayOrder": 2, + "effectiveDate": "1950-01-01T08:00:00.000+0000", + "expiryDate": "9999-12-31T08:00:00.000+0000", + "createdBy": "DATA_LOAD", + "createdDate": "2020-02-25T08:00:00.000+0000", + "lastModifiedBy": "DATA_LOAD", + "lastModifiedDate": "2020-02-25T08:00:00.000+0000" + }, + "dataLoggerType": { + "id": "FWS12S", + "displayLabel": "FWS12S", + "displayOrder": 6, + "effectiveDate": "2000-01-01T08:00:00.000+0000", + "expiryDate": "9999-12-31T08:00:00.000+0000", + "createdBy": "DATA_LOAD", + "createdDate": "2020-02-25T08:00:00.000+0000", + "lastModifiedBy": "DATA_LOAD", + "lastModifiedDate": "2020-02-25T08:00:00.000+0000" + }, + "siteType": { + "id": "WXSTN_UHF", + "displayLabel": "Weather Station - UHF", + "displayOrder": 7, + "effectiveDate": "2000-01-01T08:00:00.000+0000", + "expiryDate": "9999-12-31T08:00:00.000+0000", + "createdBy": "DATA_LOAD", + "createdDate": "2020-02-25T08:00:00.000+0000", + "lastModifiedBy": "DATA_LOAD", + "lastModifiedDate": "2020-02-25T08:00:00.000+0000" + }, + "weatherZone": { + "id": 13, + "displayLabel": "Zone 13", + "dangerRegion": 1, + "displayOrder": 13 + }, + "stationAccessTypeCode": { + "id": "4WD", + "displayLabel": "4 Wheel Drive", + "displayOrder": 1, + "effectiveDate": "2000-01-01T08:00:00.000+0000", + "expiryDate": "9999-12-31T08:00:00.000+0000", + "createdBy": "DATA_LOAD", + "createdDate": "2020-02-25T08:00:00.000+0000", + "lastModifiedBy": "DATA_LOAD", + "lastModifiedDate": "2020-02-25T08:00:00.000+0000" + }, + "hourly": true, + "pluvioSnowGuage": false, + "latitude": 55.03395, + "longitude": -126.799667, + "geometry": { + "crs": { + "type": "name", + "properties": { + "name": "EPSG:4326" + } + }, + "coordinates": [ + -126.799667, + 55.03395 + ], + "type": "Point" + }, + "elevation": 900, + "windspeedAdjRoughness": 1.0, + "windspeedHeight": { + "id": "9.0_11.9", + "displayLabel": "9.0 - 11.9 (m)", + "displayOrder": 7, + "effectiveDate": "2000-01-01T08:00:00.000+0000", + "expiryDate": "9999-12-31T08:00:00.000+0000", + "createdBy": "DATA_LOAD", + "createdDate": "2020-02-25T08:00:00.000+0000", + "lastModifiedBy": "DATA_LOAD", + "lastModifiedDate": "2020-02-25T08:00:00.000+0000" + }, + "surfaceType": null, + "overWinterPrecipAdj": false, + "influencingSlope": 5, + "installationDate": 672562800000, + "decommissionDate": null, + "influencingAspect": null, + "owner": { + "id": "a9843bb7-0f44-4a80-8a62-79cca2a5c758", + "displayLabel": "BC Wildfire Service", + "displayOrder": 2, + "ownerName": "BCWS", + "ownerDisplayName": "BC Wildfire Service", + "ownerComment": null, + "createdBy": "DATA_LOAD", + "lastModifiedBy": "DATAFIX - WFWX-891", + "effectiveDate": "1950-01-01T08:00:00.000+0000", + "expiryDate": "9999-12-31T08:00:00.000+0000" + }, + "fireCentre": { + "id": 42, + "displayLabel": "Northwest Fire Centre", + "alias": 3, + "expiryDate": "9999-12-31T08:00:00.000+0000" + }, + "zone": { + "id": 45, + "displayLabel": "Bulkley Zone", + "alias": 3, + "fireCentre": "Northwest Fire Centre", + "fireCentreAlias": 3, + "expiryDate": "9999-12-31T08:00:00.000+0000" + }, + "comments": "1st Daily 19860610;", + "ecccFileBaseUrlTemplate": null, + "lastProcessedSourceTime": null, + "crdStationName": null, + "stationAccessDescription": "Proceed North from Smithers on the Babine Lake Rd 37.5km to the3000Road just before you get to Chapman Lake - Turn Left (NW) onto the 3000Rd - Proceed for 21.3km -WALK IN FROM HERE (Site is quad accessible) - Take Left (W) onto a secondary Rd into a plantation for 400meters - Station is on the South side of the Rd ***SITE TURN OFF FROM THE UPPER FULTON IS APROX 0.5KM PAST THE FULTON RIVER BRIDGE (FIRST MAIN TURNOFFTO THE WEST)" + }, + "createdBy": "LEGACY_DATA_LOAD", + "lastModifiedBy": "WFWX-787", + "lastEntityUpdateTimestamp": 1455753994000, + "updateDate": "2022-08-23T23:20:53.000+0000", + "archive": false, + "weatherTimestamp": 1455753600000, + "temperature": -0.9, + "dewPoint": -2.1, + "relativeHumidity": 92.0, + "windSpeed": 0.0, + "adjustedWindSpeed": 0.0, + "hourlyMeasurementTypeCode": { + "id": "ACTUAL", + "displayLabel": "Actual", + "displayOrder": 1, + "createdBy": "DATA_LOAD", + "lastModifiedBy": "DATA_LOAD" + }, + "windDirection": 160.0, + "barometricPressure": 0.0, + "precipitation": 0.0, + "observationValidInd": true, + "observationValidComment": null, + "batterySupplyCurrent": null, + "batterySupplyVoltage": null, + "fuelStickMoisture": null, + "fuelStickTemperature": null, + "precipPluvio1Status": null, + "precipPluvio1Total": null, + "precipPluvio2Status": null, + "precipPluvio2Total": null, + "precipRitStatus": null, + "precipRitTotal": null, + "precipRgt": 0.0, + "precipPC2Status": null, + "precipPC2": null, + "rn1PC2": null, + "relativeHumidityHourlyAvg": 0.0, + "rn1Pluvio1": null, + "rn1Pluvio2": null, + "rn1Rit": null, + "snowDepth": null, + "snowDepthQuality": 0.0, + "solarRadiationCm3": 0.0, + "solarRadiationLicor": null, + "solarSupplyCurrent": null, + "solarSupplyVoltage": null, + "temperatureHourlyAverage": 0.0, + "windSpeedHourlyAverage": 0.0, + "windDirectionHourlyAverage": 0.0, + "windSpeedMax10Minute": 0.0, + "windGust": 0.0, + "calculate": true, + "businessKey": "1455753600000-bb7cb089-286a-4734-e053-1d09228eeca8", + "fineFuelMoistureCode": 17.648, + "initialSpreadIndex": 0.0, + "fireWeatherIndex": 0.0, + "_links": { + "self": { + "href": "https://t1bcwsapi.nrs.gov.bc.ca/wfwx-fireweather-api/v1/hourlies/bb7cb092-4427-4734-e053-1d09228eeca8" + }, + "hourly": { + "href": "https://t1bcwsapi.nrs.gov.bc.ca/wfwx-fireweather-api/v1/hourlies/bb7cb092-4427-4734-e053-1d09228eeca8" + }, + "station": { + "href": "https://t1bcwsapi.nrs.gov.bc.ca/wfwx-fireweather-api/v1/stations/bb7cb089-286a-4734-e053-1d09228eeca8" + } + } + } + ] + } +} \ No newline at end of file diff --git a/api/app/tests/conftest.py b/api/app/tests/conftest.py index e026be3d5..fe069c8fd 100644 --- a/api/app/tests/conftest.py +++ b/api/app/tests/conftest.py @@ -1,4 +1,5 @@ -""" Global fixtures """ +"""Global fixtures""" + from datetime import timezone, datetime import logging from typing import Optional @@ -12,9 +13,13 @@ from app.utils.time import get_pst_tz, get_utc_now from app import auth from app.tests.common import ( - MockJWTDecode, default_aiobotocore_get_session, default_mock_requests_get, - default_mock_requests_post, default_mock_requests_session_get, - default_mock_requests_session_post) + MockJWTDecode, + default_aiobotocore_get_session, + default_mock_requests_get, + default_mock_requests_post, + default_mock_requests_session_get, + default_mock_requests_session_post, +) import app.db.database from app.weather_models import ModelEnum, ProjectionEnum import app.jobs.env_canada @@ -29,32 +34,26 @@ @pytest.fixture def anyio_backend(): - """ Specifies asyncio as the anyio backend """ - return 'asyncio' + """Specifies asyncio as the anyio backend""" + return "asyncio" @pytest.fixture(autouse=True) def mock_env(monkeypatch): - """ Automatically mock environment variable """ + """Automatically mock environment variable""" monkeypatch.setenv("BASE_URI", "https://python-test-base-uri") monkeypatch.setenv("USE_WFWX", "False") monkeypatch.setenv("WFWX_USER", "user") monkeypatch.setenv("WFWX_SECRET", "secret") - monkeypatch.setenv( - "WFWX_AUTH_URL", "https://wf1/pub/oauth2/v1/oauth/token") + monkeypatch.setenv("WFWX_AUTH_URL", "https://wf1/pub/oauth2/v1/oauth/token") monkeypatch.setenv("WFWX_BASE_URL", "https://wf1/wfwx") monkeypatch.setenv("WFWX_MAX_PAGE_SIZE", "1000") monkeypatch.setenv("KEYCLOAK_PUBLIC_KEY", "public_key") - monkeypatch.setenv("BC_FIRE_WEATHER_USER", "someuser") - monkeypatch.setenv("BC_FIRE_WEATHER_SECRET", "password") - monkeypatch.setenv("BC_FIRE_WEATHER_FILTER_ID", "1") - monkeypatch.setenv("OPENSHIFT_BASE_URI", - "https://console.pathfinder.gov.bc.ca:8443") + monkeypatch.setenv("OPENSHIFT_BASE_URI", "https://console.pathfinder.gov.bc.ca:8443") monkeypatch.setenv("PROJECT_NAMESPACE", "project_namespace") monkeypatch.setenv("STATUS_CHECKER_SECRET", "some_secret") monkeypatch.setenv("PATRONI_CLUSTER_NAME", "some_suffix") - monkeypatch.setenv("ROCKET_URL_POST_MESSAGE", - "https://rc-notifications-test.ca/api/v1/chat.postMessage") + monkeypatch.setenv("ROCKET_URL_POST_MESSAGE", "https://rc-notifications-test.ca/api/v1/chat.postMessage") monkeypatch.setenv("ROCKET_AUTH_TOKEN", "sometoken") monkeypatch.setenv("ROCKET_USER_ID", "someid") monkeypatch.setenv("ROCKET_CHANNEL", "#channel") @@ -67,55 +66,53 @@ def mock_env(monkeypatch): @pytest.fixture(autouse=True) def mock_aiobotocore_get_session(monkeypatch): - """ Patch the session by default """ - monkeypatch.setattr(app.utils.s3, 'get_session', default_aiobotocore_get_session) + """Patch the session by default""" + monkeypatch.setattr(app.utils.s3, "get_session", default_aiobotocore_get_session) @pytest.fixture(autouse=True) def mock_requests(monkeypatch): - """ Patch all calls to request.get by default. - """ - monkeypatch.setattr(requests, 'get', default_mock_requests_get) - monkeypatch.setattr(requests, 'post', default_mock_requests_post) + """Patch all calls to request.get by default.""" + monkeypatch.setattr(requests, "get", default_mock_requests_get) + monkeypatch.setattr(requests, "post", default_mock_requests_post) @pytest.fixture(autouse=True) def mock_redis(monkeypatch): - """ Patch redis by default """ - class MockRedis(): - """ mocked redis class """ + """Patch redis by default""" + + class MockRedis: + """mocked redis class""" def __init__(self) -> None: - """ Mock init """ + """Mock init""" def get(self, name): - """ mock get """ + """mock get""" return None - def set(self, - name, value, - ex=None, px=None, nx=False, xx=False, keepttl=False): - """ mock set """ + def set(self, name, value, ex=None, px=None, nx=False, xx=False, keepttl=False): + """mock set""" def delete(self, name): - """ mock delete """ + """mock delete""" def create_mock_redis(): return MockRedis() - monkeypatch.setattr(app.utils.redis, '_create_redis', create_mock_redis) + + monkeypatch.setattr(app.utils.redis, "_create_redis", create_mock_redis) @pytest.fixture(autouse=True) def mock_get_now(monkeypatch): - """ Patch all calls to app.util.time: get_utc_now and get_pst_now """ + """Patch all calls to app.util.time: get_utc_now and get_pst_now""" # May 21, 2020 timestamp = 1590076213962 / 1000 # The default value for WeatherDataRequest cannot be mocked out, as it # is declared prior to test mocks being loaded. We manipulate the class # directly in order to have the desire default be deterministic. - WeatherDataRequest.__fields__['time_of_interest'].default = datetime.fromtimestamp( - timestamp, tz=timezone.utc) + WeatherDataRequest.__fields__["time_of_interest"].default = datetime.fromtimestamp(timestamp, tz=timezone.utc) def mock_utc_now(): return datetime.fromtimestamp(timestamp, tz=timezone.utc) @@ -123,32 +120,29 @@ def mock_utc_now(): def mock_pst_now(): return datetime.fromtimestamp(timestamp, tz=get_pst_tz()) - monkeypatch.setattr(app.utils.time, '_get_utc_now', mock_utc_now) - monkeypatch.setattr(app.utils.time, '_get_pst_now', mock_pst_now) + monkeypatch.setattr(app.utils.time, "_get_utc_now", mock_utc_now) + monkeypatch.setattr(app.utils.time, "_get_pst_now", mock_pst_now) @pytest.fixture(autouse=True) def mock_get_pst_today_start_and_end(monkeypatch): - """ Patch all calls to app.util.time: get_pst_today_start_and_end """ + """Patch all calls to app.util.time: get_pst_today_start_and_end""" def mock_get_pst_today(): start = datetime.fromtimestamp(1623974400, tz=get_pst_tz()) end = datetime.fromtimestamp(1624060800, tz=get_pst_tz()) return start, end - monkeypatch.setattr(app.utils.time, 'get_pst_today_start_and_end', mock_get_pst_today) + monkeypatch.setattr(app.utils.time, "get_pst_today_start_and_end", mock_get_pst_today) @pytest.fixture(autouse=True) def mock_session(monkeypatch): - """ Ensure that all unit tests mock out the database session by default! """ - monkeypatch.setattr(app.db.database, '_get_write_session', MagicMock()) - monkeypatch.setattr(app.db.database, '_get_read_session', MagicMock()) + """Ensure that all unit tests mock out the database session by default!""" + monkeypatch.setattr(app.db.database, "_get_write_session", MagicMock()) + monkeypatch.setattr(app.db.database, "_get_read_session", MagicMock()) - prediction_model = PredictionModel(id=1, - abbreviation='GDPS', - projection='latlon.15x.15', - name='Global Deterministic Prediction System') + prediction_model = PredictionModel(id=1, abbreviation="GDPS", projection="latlon.15x.15", name="Global Deterministic Prediction System") def mock_get_prediction_model(session, model, projection) -> Optional[PredictionModel]: if model == ModelEnum.GDPS and projection == ProjectionEnum.LATLON_15X_15: @@ -156,74 +150,70 @@ def mock_get_prediction_model(session, model, projection) -> Optional[Prediction return None def mock_get_prediction_run(session, prediction_model_id: int, prediction_run_timestamp: datetime): - return PredictionModelRunTimestamp( - id=1, prediction_model_id=1, prediction_run_timestamp=get_utc_now(), - prediction_model=prediction_model, complete=True) + return PredictionModelRunTimestamp(id=1, prediction_model_id=1, prediction_run_timestamp=get_utc_now(), prediction_model=prediction_model, complete=True) - monkeypatch.setattr(app.jobs.env_canada, 'get_prediction_model', mock_get_prediction_model) - monkeypatch.setattr(app.weather_models.process_grib, 'get_prediction_model', mock_get_prediction_model) + monkeypatch.setattr(app.jobs.env_canada, "get_prediction_model", mock_get_prediction_model) + monkeypatch.setattr(app.weather_models.process_grib, "get_prediction_model", mock_get_prediction_model) - monkeypatch.setattr(app.jobs.env_canada, 'get_prediction_run', mock_get_prediction_run) + monkeypatch.setattr(app.jobs.env_canada, "get_prediction_run", mock_get_prediction_run) @pytest.fixture() def mock_env_with_use_wfwx(monkeypatch): - """ Set environment variable USE_WFWX to 'True' """ - monkeypatch.setenv("USE_WFWX", 'True') + """Set environment variable USE_WFWX to 'True'""" + monkeypatch.setenv("USE_WFWX", "True") @pytest.fixture() def mock_jwt_decode(monkeypatch): - """ Mock pyjwt's decode method """ + """Mock pyjwt's decode method""" def mock_function(*args, **kwargs): return MockJWTDecode() monkeypatch.setattr("jwt.decode", mock_function) + @pytest.fixture(autouse=True) def mock_sentry(monkeypatch): - """ Mock sentrys' set_user function """ + """Mock sentrys' set_user function""" def mock_sentry(*args, **kwargs): pass - monkeypatch.setattr('app.auth.set_user', mock_sentry) + monkeypatch.setattr("app.auth.set_user", mock_sentry) @pytest.fixture() def mock_requests_session(monkeypatch): - """ Patch all calls to requests.Session.* """ - monkeypatch.setattr(requests.Session, 'get', - default_mock_requests_session_get) - monkeypatch.setattr(requests.Session, 'post', - default_mock_requests_session_post) + """Patch all calls to requests.Session.*""" + monkeypatch.setattr(requests.Session, "get", default_mock_requests_session_get) + monkeypatch.setattr(requests.Session, "post", default_mock_requests_session_post) return monkeypatch @pytest.fixture(autouse=True) def spy_access_logging(mocker: MockerFixture): """Spies on access audting logging for tests""" - return mocker.spy(auth, 'create_api_access_audit_log') + return mocker.spy(auth, "create_api_access_audit_log") -@then(parsers.parse('the response status code is {status}'), converters={'status': int}) +@then(parsers.parse("the response status code is {status}"), converters={"status": int}) def assert_status_code(response, status: int): - """ Assert that we receive the expected status code """ - assert response['response'].status_code == status + """Assert that we receive the expected status code""" + assert response["response"].status_code == status @then("the response isn't cached") def then_response_not_cached(response): - """ Check that the response isn't being cached """ - if response['response'].status_code == 200: - assert response['response'].headers.get('cache-control', None) == 'max-age=0' + """Check that the response isn't being cached""" + if response["response"].status_code == 200: + assert response["response"].headers.get("cache-control", None) == "max-age=0" -@then(parsers.parse("the response is {response_json}"), - converters={'response_json': load_json_file(__file__)}) +@then(parsers.parse("the response is {response_json}"), converters={"response_json": load_json_file(__file__)}) def then_response(response, response_json: dict): - """ Check entire response """ + """Check entire response""" if response_json is not None: # it's very useful having this code hang around: # import json @@ -232,4 +222,4 @@ def then_response(response, response_json: dict): # actual_filename = get_complete_filename(__file__, 'actual.json') # with open(actual_filename, 'w', encoding="utf-8") as file_pointer: # json.dump(actual, file_pointer, indent=2) - assert response['response'].json() == response_json + assert response["response"].json() == response_json diff --git a/api/app/tests/fba/test_fba_endpoint.py b/api/app/tests/fba/test_fba_endpoint.py index 191d7d984..5f2d6fe4d 100644 --- a/api/app/tests/fba/test_fba_endpoint.py +++ b/api/app/tests/fba/test_fba_endpoint.py @@ -3,22 +3,30 @@ import pytest from fastapi.testclient import TestClient from datetime import date, datetime, timezone -from app.db.models.auto_spatial_advisory import AdvisoryElevationStats, AdvisoryTPIStats, RunParameters +from collections import namedtuple +from app.db.models.auto_spatial_advisory import AdvisoryTPIStats, HfiClassificationThreshold, RunParameters, SFMSFuelType + +mock_fire_centre_name = "PGFireCentre" get_fire_centres_url = "/api/fba/fire-centers" get_fire_zone_areas_url = "/api/fba/fire-shape-areas/forecast/2022-09-27/2022-09-27" get_fire_zone_tpi_stats_url = "/api/fba/fire-zone-tpi-stats/forecast/2022-09-27/2022-09-27/1" +get_fire_centre_info_url = "/api/fba/fire-centre-hfi-stats/forecast/2022-09-27/2022-09-27/Kamloops%20Fire%20Centre" get_fire_zone_elevation_info_url = "/api/fba/fire-zone-elevation-info/forecast/2022-09-27/2022-09-27/1" +get_fire_centre_tpi_stats_url = f"/api/fba/fire-centre-tpi-stats/forecast/2024-08-10/2024-08-10/{mock_fire_centre_name}" get_sfms_run_datetimes_url = "/api/fba/sfms-run-datetimes/forecast/2022-09-27" - decode_fn = "jwt.decode" mock_tpi_stats = AdvisoryTPIStats(id=1, advisory_shape_id=1, valley_bottom=1, mid_slope=2, upper_slope=3, pixel_size_metres=50) -mock_elevation_info = [AdvisoryElevationStats(id=1, advisory_shape_id=1, threshold=1, minimum=1.0, quartile_25=2.0, median=3.0, quartile_75=4.0, maximum=5.0)] +mock_fire_centre_info = [(9.0, 11.0, 1, 1, 50)] mock_sfms_run_datetimes = [ RunParameters(id=1, run_type="forecast", run_datetime=datetime(year=2024, month=1, day=1, hour=1, tzinfo=timezone.utc), for_date=date(year=2024, month=1, day=2)) ] +CentreHFIFuelResponse = namedtuple("CentreHFIFuelResponse", ["advisory_shape_id", "source_identifier", "valley_bottom", "mid_slope", "upper_slope", "pixel_size_metres"]) +mock_centre_tpi_stats_1 = CentreHFIFuelResponse(advisory_shape_id=1, source_identifier=1, valley_bottom=1, mid_slope=2, upper_slope=3, pixel_size_metres=2) +mock_centre_tpi_stats_2 = CentreHFIFuelResponse(advisory_shape_id=2, source_identifier=2, valley_bottom=1, mid_slope=2, upper_slope=3, pixel_size_metres=2) + async def mock_get_fire_centres(*_, **__): return [] @@ -36,8 +44,16 @@ async def mock_get_tpi_stats(*_, **__): return mock_tpi_stats -async def mock_get_elevation_info(*_, **__): - return mock_elevation_info +async def mock_get_tpi_stats_none(*_, **__): + return None + + +async def mock_get_fire_centre_info(*_, **__): + return mock_fire_centre_info + + +async def mock_get_centre_tpi_stats(*_, **__): + return [mock_centre_tpi_stats_1, mock_centre_tpi_stats_2] async def mock_get_sfms_run_datetimes(*_, **__): @@ -54,7 +70,7 @@ def client(): @pytest.mark.parametrize( "endpoint", - [get_fire_centres_url, get_fire_zone_areas_url, get_fire_zone_tpi_stats_url, get_fire_zone_elevation_info_url, get_sfms_run_datetimes_url], + [get_fire_centres_url, get_fire_zone_areas_url, get_fire_zone_tpi_stats_url, get_fire_centre_info_url, get_sfms_run_datetimes_url], ) def test_get_endpoints_unauthorized(client: TestClient, endpoint: str): """Forbidden to get fire zone areas when unauthorized""" @@ -72,19 +88,32 @@ def test_get_fire_centres_authorized(client: TestClient): assert response.status_code == 200 +async def mock_hfi_thresholds(*_, **__): + return [HfiClassificationThreshold(id=1, description="4000 < hfi < 10000", name="advisory")] + + +async def mock_sfms_fuel_types(*_, **__): + return [SFMSFuelType(id=1, fuel_type_id=1, fuel_type_code="C2", description="test fuel type c2")] + + +async def mock_zone_ids_in_centre(*_, **__): + return [1] + + @patch("app.routers.fba.get_auth_header", mock_get_auth_header) -@patch("app.routers.fba.get_zonal_elevation_stats", mock_get_elevation_info) +@patch("app.routers.fba.get_precomputed_stats_for_shape", mock_get_fire_centre_info) +@patch("app.routers.fba.get_all_hfi_thresholds", mock_hfi_thresholds) +@patch("app.routers.fba.get_all_sfms_fuel_types", mock_sfms_fuel_types) +@patch("app.routers.fba.get_zone_ids_in_centre", mock_zone_ids_in_centre) @pytest.mark.usefixtures("mock_jwt_decode") -def test_get_fire_zone_elevation_info_authorized(client: TestClient): - """Allowed to get fire zone elevation info when authorized""" - response = client.get(get_fire_zone_elevation_info_url) +def test_get_fire_center_info_authorized(client: TestClient): + """Allowed to get fire centre info when authorized""" + response = client.get(get_fire_centre_info_url) assert response.status_code == 200 - assert response.json()["hfi_elevation_info"][0]["threshold"] == mock_elevation_info[0].threshold - assert response.json()["hfi_elevation_info"][0]["elevation_info"]["minimum"] == mock_elevation_info[0].minimum - assert response.json()["hfi_elevation_info"][0]["elevation_info"]["quartile_25"] == mock_elevation_info[0].quartile_25 - assert response.json()["hfi_elevation_info"][0]["elevation_info"]["median"] == mock_elevation_info[0].median - assert response.json()["hfi_elevation_info"][0]["elevation_info"]["quartile_75"] == mock_elevation_info[0].quartile_75 - assert response.json()["hfi_elevation_info"][0]["elevation_info"]["maximum"] == mock_elevation_info[0].maximum + assert response.json()["Kamloops Fire Centre"]["1"][0]["fuel_type"]["fuel_type_id"] == 1 + assert response.json()["Kamloops Fire Centre"]["1"][0]["threshold"]["id"] == 1 + assert response.json()["Kamloops Fire Centre"]["1"][0]["critical_hours"]["start_time"] == 9.0 + assert response.json()["Kamloops Fire Centre"]["1"][0]["critical_hours"]["end_time"] == 11.0 @patch("app.routers.fba.get_auth_header", mock_get_auth_header) @@ -109,3 +138,35 @@ def test_get_fire_zone_tpi_stats_authorized(client: TestClient): assert response.json()["valley_bottom"] == mock_tpi_stats.valley_bottom * square_metres assert response.json()["mid_slope"] == mock_tpi_stats.mid_slope * square_metres assert response.json()["upper_slope"] == mock_tpi_stats.upper_slope * square_metres + + +@patch("app.routers.fba.get_auth_header", mock_get_auth_header) +@patch("app.routers.fba.get_zonal_tpi_stats", mock_get_tpi_stats_none) +@pytest.mark.usefixtures("mock_jwt_decode") +def test_get_fire_zone_tpi_stats_authorized_none(client: TestClient): + """Returns none for TPI stats when there are no stats available""" + response = client.get(get_fire_zone_tpi_stats_url) + assert response.status_code == 200 + assert response.json()["fire_zone_id"] == 1 + assert response.json()["valley_bottom"] == None + assert response.json()["mid_slope"] == None + assert response.json()["upper_slope"] == None + + +@patch("app.routers.fba.get_auth_header", mock_get_auth_header) +@patch("app.routers.fba.get_centre_tpi_stats", mock_get_centre_tpi_stats) +@pytest.mark.usefixtures("mock_jwt_decode") +def test_get_fire_centre_tpi_stats_authorized(client: TestClient): + """Allowed to get fire zone tpi stats when authorized""" + response = client.get(get_fire_centre_tpi_stats_url) + square_metres = math.pow(mock_centre_tpi_stats_2.pixel_size_metres, 2) + assert response.status_code == 200 + assert response.json()[mock_fire_centre_name][0]["fire_zone_id"] == 1 + assert response.json()[mock_fire_centre_name][0]["valley_bottom"] == mock_centre_tpi_stats_1.valley_bottom * square_metres + assert response.json()[mock_fire_centre_name][0]["mid_slope"] == mock_centre_tpi_stats_1.mid_slope * square_metres + assert response.json()[mock_fire_centre_name][0]["upper_slope"] == mock_centre_tpi_stats_1.upper_slope * square_metres + + assert response.json()[mock_fire_centre_name][1]["fire_zone_id"] == 2 + assert response.json()[mock_fire_centre_name][1]["valley_bottom"] == mock_centre_tpi_stats_2.valley_bottom * square_metres + assert response.json()[mock_fire_centre_name][1]["mid_slope"] == mock_centre_tpi_stats_2.mid_slope * square_metres + assert response.json()[mock_fire_centre_name][1]["upper_slope"] == mock_centre_tpi_stats_2.upper_slope * square_metres diff --git a/api/app/utils/geospatial.py b/api/app/utils/geospatial.py index ee4124736..a80ebca5d 100644 --- a/api/app/utils/geospatial.py +++ b/api/app/utils/geospatial.py @@ -1,5 +1,6 @@ import logging -from osgeo import gdal +from typing import Tuple +from osgeo import gdal, ogr, osr logger = logging.getLogger(__name__) @@ -83,3 +84,21 @@ def raster_mul(tpi_ds: gdal.Dataset, hfi_ds: gdal.Dataset, chunk_size=256) -> gd hfi_chunk = None return out_ds + + +class PointTransformer: + """ + Transforms the coordinates of a point from one spatial reference to another. + """ + + def __init__(self, source_srs: int, target_srs: int): + source = osr.SpatialReference() + source.ImportFromEPSG(source_srs) + target = osr.SpatialReference() + target.ImportFromEPSG(target_srs) + self.transform = osr.CoordinateTransformation(source, target) + + def transform_coordinate(self, x: float, y: float) -> Tuple[float, float]: + point = ogr.CreateGeometryFromWkt(f"POINT ({x} {y})") + point.Transform(self.transform) + return (point.GetX(), point.GetY()) diff --git a/api/poetry.lock b/api/poetry.lock index fd2105c08..c7dd21d33 100644 --- a/api/poetry.lock +++ b/api/poetry.lock @@ -61,92 +61,119 @@ files = [ {file = "aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c"}, ] +[[package]] +name = "aiohappyeyeballs" +version = "2.4.0" +description = "Happy Eyeballs for asyncio" +optional = false +python-versions = ">=3.8" +files = [ + {file = "aiohappyeyeballs-2.4.0-py3-none-any.whl", hash = "sha256:7ce92076e249169a13c2f49320d1967425eaf1f407522d707d59cac7628d62bd"}, + {file = "aiohappyeyeballs-2.4.0.tar.gz", hash = "sha256:55a1714f084e63d49639800f95716da97a1f173d46a16dfcfda0016abb93b6b2"}, +] + [[package]] name = "aiohttp" -version = "3.9.5" +version = "3.10.5" description = "Async http client/server framework (asyncio)" optional = false python-versions = ">=3.8" files = [ - {file = "aiohttp-3.9.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fcde4c397f673fdec23e6b05ebf8d4751314fa7c24f93334bf1f1364c1c69ac7"}, - {file = "aiohttp-3.9.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5d6b3f1fabe465e819aed2c421a6743d8debbde79b6a8600739300630a01bf2c"}, - {file = "aiohttp-3.9.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6ae79c1bc12c34082d92bf9422764f799aee4746fd7a392db46b7fd357d4a17a"}, - {file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4d3ebb9e1316ec74277d19c5f482f98cc65a73ccd5430540d6d11682cd857430"}, - {file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84dabd95154f43a2ea80deffec9cb44d2e301e38a0c9d331cc4aa0166fe28ae3"}, - {file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c8a02fbeca6f63cb1f0475c799679057fc9268b77075ab7cf3f1c600e81dd46b"}, - {file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c26959ca7b75ff768e2776d8055bf9582a6267e24556bb7f7bd29e677932be72"}, - {file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:714d4e5231fed4ba2762ed489b4aec07b2b9953cf4ee31e9871caac895a839c0"}, - {file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e7a6a8354f1b62e15d48e04350f13e726fa08b62c3d7b8401c0a1314f02e3558"}, - {file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:c413016880e03e69d166efb5a1a95d40f83d5a3a648d16486592c49ffb76d0db"}, - {file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:ff84aeb864e0fac81f676be9f4685f0527b660f1efdc40dcede3c251ef1e867f"}, - {file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:ad7f2919d7dac062f24d6f5fe95d401597fbb015a25771f85e692d043c9d7832"}, - {file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:702e2c7c187c1a498a4e2b03155d52658fdd6fda882d3d7fbb891a5cf108bb10"}, - {file = "aiohttp-3.9.5-cp310-cp310-win32.whl", hash = "sha256:67c3119f5ddc7261d47163ed86d760ddf0e625cd6246b4ed852e82159617b5fb"}, - {file = "aiohttp-3.9.5-cp310-cp310-win_amd64.whl", hash = "sha256:471f0ef53ccedec9995287f02caf0c068732f026455f07db3f01a46e49d76bbb"}, - {file = "aiohttp-3.9.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e0ae53e33ee7476dd3d1132f932eeb39bf6125083820049d06edcdca4381f342"}, - {file = "aiohttp-3.9.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c088c4d70d21f8ca5c0b8b5403fe84a7bc8e024161febdd4ef04575ef35d474d"}, - {file = "aiohttp-3.9.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:639d0042b7670222f33b0028de6b4e2fad6451462ce7df2af8aee37dcac55424"}, - {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f26383adb94da5e7fb388d441bf09c61e5e35f455a3217bfd790c6b6bc64b2ee"}, - {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66331d00fb28dc90aa606d9a54304af76b335ae204d1836f65797d6fe27f1ca2"}, - {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4ff550491f5492ab5ed3533e76b8567f4b37bd2995e780a1f46bca2024223233"}, - {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f22eb3a6c1080d862befa0a89c380b4dafce29dc6cd56083f630073d102eb595"}, - {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a81b1143d42b66ffc40a441379387076243ef7b51019204fd3ec36b9f69e77d6"}, - {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f64fd07515dad67f24b6ea4a66ae2876c01031de91c93075b8093f07c0a2d93d"}, - {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:93e22add827447d2e26d67c9ac0161756007f152fdc5210277d00a85f6c92323"}, - {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:55b39c8684a46e56ef8c8d24faf02de4a2b2ac60d26cee93bc595651ff545de9"}, - {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4715a9b778f4293b9f8ae7a0a7cef9829f02ff8d6277a39d7f40565c737d3771"}, - {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:afc52b8d969eff14e069a710057d15ab9ac17cd4b6753042c407dcea0e40bf75"}, - {file = "aiohttp-3.9.5-cp311-cp311-win32.whl", hash = "sha256:b3df71da99c98534be076196791adca8819761f0bf6e08e07fd7da25127150d6"}, - {file = "aiohttp-3.9.5-cp311-cp311-win_amd64.whl", hash = "sha256:88e311d98cc0bf45b62fc46c66753a83445f5ab20038bcc1b8a1cc05666f428a"}, - {file = "aiohttp-3.9.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:c7a4b7a6cf5b6eb11e109a9755fd4fda7d57395f8c575e166d363b9fc3ec4678"}, - {file = "aiohttp-3.9.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:0a158704edf0abcac8ac371fbb54044f3270bdbc93e254a82b6c82be1ef08f3c"}, - {file = "aiohttp-3.9.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d153f652a687a8e95ad367a86a61e8d53d528b0530ef382ec5aaf533140ed00f"}, - {file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82a6a97d9771cb48ae16979c3a3a9a18b600a8505b1115cfe354dfb2054468b4"}, - {file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60cdbd56f4cad9f69c35eaac0fbbdf1f77b0ff9456cebd4902f3dd1cf096464c"}, - {file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8676e8fd73141ded15ea586de0b7cda1542960a7b9ad89b2b06428e97125d4fa"}, - {file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da00da442a0e31f1c69d26d224e1efd3a1ca5bcbf210978a2ca7426dfcae9f58"}, - {file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:18f634d540dd099c262e9f887c8bbacc959847cfe5da7a0e2e1cf3f14dbf2daf"}, - {file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:320e8618eda64e19d11bdb3bd04ccc0a816c17eaecb7e4945d01deee2a22f95f"}, - {file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:2faa61a904b83142747fc6a6d7ad8fccff898c849123030f8e75d5d967fd4a81"}, - {file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:8c64a6dc3fe5db7b1b4d2b5cb84c4f677768bdc340611eca673afb7cf416ef5a"}, - {file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:393c7aba2b55559ef7ab791c94b44f7482a07bf7640d17b341b79081f5e5cd1a"}, - {file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:c671dc117c2c21a1ca10c116cfcd6e3e44da7fcde37bf83b2be485ab377b25da"}, - {file = "aiohttp-3.9.5-cp312-cp312-win32.whl", hash = "sha256:5a7ee16aab26e76add4afc45e8f8206c95d1d75540f1039b84a03c3b3800dd59"}, - {file = "aiohttp-3.9.5-cp312-cp312-win_amd64.whl", hash = "sha256:5ca51eadbd67045396bc92a4345d1790b7301c14d1848feaac1d6a6c9289e888"}, - {file = "aiohttp-3.9.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:694d828b5c41255e54bc2dddb51a9f5150b4eefa9886e38b52605a05d96566e8"}, - {file = "aiohttp-3.9.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0605cc2c0088fcaae79f01c913a38611ad09ba68ff482402d3410bf59039bfb8"}, - {file = "aiohttp-3.9.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4558e5012ee03d2638c681e156461d37b7a113fe13970d438d95d10173d25f78"}, - {file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dbc053ac75ccc63dc3a3cc547b98c7258ec35a215a92bd9f983e0aac95d3d5b"}, - {file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4109adee842b90671f1b689901b948f347325045c15f46b39797ae1bf17019de"}, - {file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a6ea1a5b409a85477fd8e5ee6ad8f0e40bf2844c270955e09360418cfd09abac"}, - {file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3c2890ca8c59ee683fd09adf32321a40fe1cf164e3387799efb2acebf090c11"}, - {file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3916c8692dbd9d55c523374a3b8213e628424d19116ac4308e434dbf6d95bbdd"}, - {file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:8d1964eb7617907c792ca00b341b5ec3e01ae8c280825deadbbd678447b127e1"}, - {file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:d5ab8e1f6bee051a4bf6195e38a5c13e5e161cb7bad83d8854524798bd9fcd6e"}, - {file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:52c27110f3862a1afbcb2af4281fc9fdc40327fa286c4625dfee247c3ba90156"}, - {file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:7f64cbd44443e80094309875d4f9c71d0401e966d191c3d469cde4642bc2e031"}, - {file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8b4f72fbb66279624bfe83fd5eb6aea0022dad8eec62b71e7bf63ee1caadeafe"}, - {file = "aiohttp-3.9.5-cp38-cp38-win32.whl", hash = "sha256:6380c039ec52866c06d69b5c7aad5478b24ed11696f0e72f6b807cfb261453da"}, - {file = "aiohttp-3.9.5-cp38-cp38-win_amd64.whl", hash = "sha256:da22dab31d7180f8c3ac7c7635f3bcd53808f374f6aa333fe0b0b9e14b01f91a"}, - {file = "aiohttp-3.9.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:1732102949ff6087589408d76cd6dea656b93c896b011ecafff418c9661dc4ed"}, - {file = "aiohttp-3.9.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c6021d296318cb6f9414b48e6a439a7f5d1f665464da507e8ff640848ee2a58a"}, - {file = "aiohttp-3.9.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:239f975589a944eeb1bad26b8b140a59a3a320067fb3cd10b75c3092405a1372"}, - {file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b7b30258348082826d274504fbc7c849959f1989d86c29bc355107accec6cfb"}, - {file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd2adf5c87ff6d8b277814a28a535b59e20bfea40a101db6b3bdca7e9926bc24"}, - {file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e9a3d838441bebcf5cf442700e3963f58b5c33f015341f9ea86dcd7d503c07e2"}, - {file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e3a1ae66e3d0c17cf65c08968a5ee3180c5a95920ec2731f53343fac9bad106"}, - {file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9c69e77370cce2d6df5d12b4e12bdcca60c47ba13d1cbbc8645dd005a20b738b"}, - {file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0cbf56238f4bbf49dab8c2dc2e6b1b68502b1e88d335bea59b3f5b9f4c001475"}, - {file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d1469f228cd9ffddd396d9948b8c9cd8022b6d1bf1e40c6f25b0fb90b4f893ed"}, - {file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:45731330e754f5811c314901cebdf19dd776a44b31927fa4b4dbecab9e457b0c"}, - {file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:3fcb4046d2904378e3aeea1df51f697b0467f2aac55d232c87ba162709478c46"}, - {file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8cf142aa6c1a751fcb364158fd710b8a9be874b81889c2bd13aa8893197455e2"}, - {file = "aiohttp-3.9.5-cp39-cp39-win32.whl", hash = "sha256:7b179eea70833c8dee51ec42f3b4097bd6370892fa93f510f76762105568cf09"}, - {file = "aiohttp-3.9.5-cp39-cp39-win_amd64.whl", hash = "sha256:38d80498e2e169bc61418ff36170e0aad0cd268da8b38a17c4cf29d254a8b3f1"}, - {file = "aiohttp-3.9.5.tar.gz", hash = "sha256:edea7d15772ceeb29db4aff55e482d4bcfb6ae160ce144f2682de02f6d693551"}, + {file = "aiohttp-3.10.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:18a01eba2574fb9edd5f6e5fb25f66e6ce061da5dab5db75e13fe1558142e0a3"}, + {file = "aiohttp-3.10.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:94fac7c6e77ccb1ca91e9eb4cb0ac0270b9fb9b289738654120ba8cebb1189c6"}, + {file = "aiohttp-3.10.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2f1f1c75c395991ce9c94d3e4aa96e5c59c8356a15b1c9231e783865e2772699"}, + {file = "aiohttp-3.10.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f7acae3cf1a2a2361ec4c8e787eaaa86a94171d2417aae53c0cca6ca3118ff6"}, + {file = "aiohttp-3.10.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:94c4381ffba9cc508b37d2e536b418d5ea9cfdc2848b9a7fea6aebad4ec6aac1"}, + {file = "aiohttp-3.10.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c31ad0c0c507894e3eaa843415841995bf8de4d6b2d24c6e33099f4bc9fc0d4f"}, + {file = "aiohttp-3.10.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0912b8a8fadeb32ff67a3ed44249448c20148397c1ed905d5dac185b4ca547bb"}, + {file = "aiohttp-3.10.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0d93400c18596b7dc4794d48a63fb361b01a0d8eb39f28800dc900c8fbdaca91"}, + {file = "aiohttp-3.10.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d00f3c5e0d764a5c9aa5a62d99728c56d455310bcc288a79cab10157b3af426f"}, + {file = "aiohttp-3.10.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:d742c36ed44f2798c8d3f4bc511f479b9ceef2b93f348671184139e7d708042c"}, + {file = "aiohttp-3.10.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:814375093edae5f1cb31e3407997cf3eacefb9010f96df10d64829362ae2df69"}, + {file = "aiohttp-3.10.5-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8224f98be68a84b19f48e0bdc14224b5a71339aff3a27df69989fa47d01296f3"}, + {file = "aiohttp-3.10.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d9a487ef090aea982d748b1b0d74fe7c3950b109df967630a20584f9a99c0683"}, + {file = "aiohttp-3.10.5-cp310-cp310-win32.whl", hash = "sha256:d9ef084e3dc690ad50137cc05831c52b6ca428096e6deb3c43e95827f531d5ef"}, + {file = "aiohttp-3.10.5-cp310-cp310-win_amd64.whl", hash = "sha256:66bf9234e08fe561dccd62083bf67400bdbf1c67ba9efdc3dac03650e97c6088"}, + {file = "aiohttp-3.10.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8c6a4e5e40156d72a40241a25cc226051c0a8d816610097a8e8f517aeacd59a2"}, + {file = "aiohttp-3.10.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2c634a3207a5445be65536d38c13791904fda0748b9eabf908d3fe86a52941cf"}, + {file = "aiohttp-3.10.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4aff049b5e629ef9b3e9e617fa6e2dfeda1bf87e01bcfecaf3949af9e210105e"}, + {file = "aiohttp-3.10.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1942244f00baaacaa8155eca94dbd9e8cc7017deb69b75ef67c78e89fdad3c77"}, + {file = "aiohttp-3.10.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e04a1f2a65ad2f93aa20f9ff9f1b672bf912413e5547f60749fa2ef8a644e061"}, + {file = "aiohttp-3.10.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7f2bfc0032a00405d4af2ba27f3c429e851d04fad1e5ceee4080a1c570476697"}, + {file = "aiohttp-3.10.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:424ae21498790e12eb759040bbb504e5e280cab64693d14775c54269fd1d2bb7"}, + {file = "aiohttp-3.10.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:975218eee0e6d24eb336d0328c768ebc5d617609affaca5dbbd6dd1984f16ed0"}, + {file = "aiohttp-3.10.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4120d7fefa1e2d8fb6f650b11489710091788de554e2b6f8347c7a20ceb003f5"}, + {file = "aiohttp-3.10.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:b90078989ef3fc45cf9221d3859acd1108af7560c52397ff4ace8ad7052a132e"}, + {file = "aiohttp-3.10.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:ba5a8b74c2a8af7d862399cdedce1533642fa727def0b8c3e3e02fcb52dca1b1"}, + {file = "aiohttp-3.10.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:02594361128f780eecc2a29939d9dfc870e17b45178a867bf61a11b2a4367277"}, + {file = "aiohttp-3.10.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8fb4fc029e135859f533025bc82047334e24b0d489e75513144f25408ecaf058"}, + {file = "aiohttp-3.10.5-cp311-cp311-win32.whl", hash = "sha256:e1ca1ef5ba129718a8fc827b0867f6aa4e893c56eb00003b7367f8a733a9b072"}, + {file = "aiohttp-3.10.5-cp311-cp311-win_amd64.whl", hash = "sha256:349ef8a73a7c5665cca65c88ab24abe75447e28aa3bc4c93ea5093474dfdf0ff"}, + {file = "aiohttp-3.10.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:305be5ff2081fa1d283a76113b8df7a14c10d75602a38d9f012935df20731487"}, + {file = "aiohttp-3.10.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3a1c32a19ee6bbde02f1cb189e13a71b321256cc1d431196a9f824050b160d5a"}, + {file = "aiohttp-3.10.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:61645818edd40cc6f455b851277a21bf420ce347baa0b86eaa41d51ef58ba23d"}, + {file = "aiohttp-3.10.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c225286f2b13bab5987425558baa5cbdb2bc925b2998038fa028245ef421e75"}, + {file = "aiohttp-3.10.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8ba01ebc6175e1e6b7275c907a3a36be48a2d487549b656aa90c8a910d9f3178"}, + {file = "aiohttp-3.10.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8eaf44ccbc4e35762683078b72bf293f476561d8b68ec8a64f98cf32811c323e"}, + {file = "aiohttp-3.10.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1c43eb1ab7cbf411b8e387dc169acb31f0ca0d8c09ba63f9eac67829585b44f"}, + {file = "aiohttp-3.10.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:de7a5299827253023c55ea549444e058c0eb496931fa05d693b95140a947cb73"}, + {file = "aiohttp-3.10.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4790f0e15f00058f7599dab2b206d3049d7ac464dc2e5eae0e93fa18aee9e7bf"}, + {file = "aiohttp-3.10.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:44b324a6b8376a23e6ba25d368726ee3bc281e6ab306db80b5819999c737d820"}, + {file = "aiohttp-3.10.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:0d277cfb304118079e7044aad0b76685d30ecb86f83a0711fc5fb257ffe832ca"}, + {file = "aiohttp-3.10.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:54d9ddea424cd19d3ff6128601a4a4d23d54a421f9b4c0fff740505813739a91"}, + {file = "aiohttp-3.10.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4f1c9866ccf48a6df2b06823e6ae80573529f2af3a0992ec4fe75b1a510df8a6"}, + {file = "aiohttp-3.10.5-cp312-cp312-win32.whl", hash = "sha256:dc4826823121783dccc0871e3f405417ac116055bf184ac04c36f98b75aacd12"}, + {file = "aiohttp-3.10.5-cp312-cp312-win_amd64.whl", hash = "sha256:22c0a23a3b3138a6bf76fc553789cb1a703836da86b0f306b6f0dc1617398abc"}, + {file = "aiohttp-3.10.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7f6b639c36734eaa80a6c152a238242bedcee9b953f23bb887e9102976343092"}, + {file = "aiohttp-3.10.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f29930bc2921cef955ba39a3ff87d2c4398a0394ae217f41cb02d5c26c8b1b77"}, + {file = "aiohttp-3.10.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f489a2c9e6455d87eabf907ac0b7d230a9786be43fbe884ad184ddf9e9c1e385"}, + {file = "aiohttp-3.10.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:123dd5b16b75b2962d0fff566effb7a065e33cd4538c1692fb31c3bda2bfb972"}, + {file = "aiohttp-3.10.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b98e698dc34966e5976e10bbca6d26d6724e6bdea853c7c10162a3235aba6e16"}, + {file = "aiohttp-3.10.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c3b9162bab7e42f21243effc822652dc5bb5e8ff42a4eb62fe7782bcbcdfacf6"}, + {file = "aiohttp-3.10.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1923a5c44061bffd5eebeef58cecf68096e35003907d8201a4d0d6f6e387ccaa"}, + {file = "aiohttp-3.10.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d55f011da0a843c3d3df2c2cf4e537b8070a419f891c930245f05d329c4b0689"}, + {file = "aiohttp-3.10.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:afe16a84498441d05e9189a15900640a2d2b5e76cf4efe8cbb088ab4f112ee57"}, + {file = "aiohttp-3.10.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f8112fb501b1e0567a1251a2fd0747baae60a4ab325a871e975b7bb67e59221f"}, + {file = "aiohttp-3.10.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1e72589da4c90337837fdfe2026ae1952c0f4a6e793adbbfbdd40efed7c63599"}, + {file = "aiohttp-3.10.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:4d46c7b4173415d8e583045fbc4daa48b40e31b19ce595b8d92cf639396c15d5"}, + {file = "aiohttp-3.10.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:33e6bc4bab477c772a541f76cd91e11ccb6d2efa2b8d7d7883591dfb523e5987"}, + {file = "aiohttp-3.10.5-cp313-cp313-win32.whl", hash = "sha256:c58c6837a2c2a7cf3133983e64173aec11f9c2cd8e87ec2fdc16ce727bcf1a04"}, + {file = "aiohttp-3.10.5-cp313-cp313-win_amd64.whl", hash = "sha256:38172a70005252b6893088c0f5e8a47d173df7cc2b2bd88650957eb84fcf5022"}, + {file = "aiohttp-3.10.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:f6f18898ace4bcd2d41a122916475344a87f1dfdec626ecde9ee802a711bc569"}, + {file = "aiohttp-3.10.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:5ede29d91a40ba22ac1b922ef510aab871652f6c88ef60b9dcdf773c6d32ad7a"}, + {file = "aiohttp-3.10.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:673f988370f5954df96cc31fd99c7312a3af0a97f09e407399f61583f30da9bc"}, + {file = "aiohttp-3.10.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:58718e181c56a3c02d25b09d4115eb02aafe1a732ce5714ab70326d9776457c3"}, + {file = "aiohttp-3.10.5-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4b38b1570242fbab8d86a84128fb5b5234a2f70c2e32f3070143a6d94bc854cf"}, + {file = "aiohttp-3.10.5-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:074d1bff0163e107e97bd48cad9f928fa5a3eb4b9d33366137ffce08a63e37fe"}, + {file = "aiohttp-3.10.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd31f176429cecbc1ba499d4aba31aaccfea488f418d60376b911269d3b883c5"}, + {file = "aiohttp-3.10.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7384d0b87d4635ec38db9263e6a3f1eb609e2e06087f0aa7f63b76833737b471"}, + {file = "aiohttp-3.10.5-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:8989f46f3d7ef79585e98fa991e6ded55d2f48ae56d2c9fa5e491a6e4effb589"}, + {file = "aiohttp-3.10.5-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:c83f7a107abb89a227d6c454c613e7606c12a42b9a4ca9c5d7dad25d47c776ae"}, + {file = "aiohttp-3.10.5-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:cde98f323d6bf161041e7627a5fd763f9fd829bcfcd089804a5fdce7bb6e1b7d"}, + {file = "aiohttp-3.10.5-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:676f94c5480d8eefd97c0c7e3953315e4d8c2b71f3b49539beb2aa676c58272f"}, + {file = "aiohttp-3.10.5-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:2d21ac12dc943c68135ff858c3a989f2194a709e6e10b4c8977d7fcd67dfd511"}, + {file = "aiohttp-3.10.5-cp38-cp38-win32.whl", hash = "sha256:17e997105bd1a260850272bfb50e2a328e029c941c2708170d9d978d5a30ad9a"}, + {file = "aiohttp-3.10.5-cp38-cp38-win_amd64.whl", hash = "sha256:1c19de68896747a2aa6257ae4cf6ef59d73917a36a35ee9d0a6f48cff0f94db8"}, + {file = "aiohttp-3.10.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7e2fe37ac654032db1f3499fe56e77190282534810e2a8e833141a021faaab0e"}, + {file = "aiohttp-3.10.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f5bf3ead3cb66ab990ee2561373b009db5bc0e857549b6c9ba84b20bc462e172"}, + {file = "aiohttp-3.10.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1b2c16a919d936ca87a3c5f0e43af12a89a3ce7ccbce59a2d6784caba945b68b"}, + {file = "aiohttp-3.10.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad146dae5977c4dd435eb31373b3fe9b0b1bf26858c6fc452bf6af394067e10b"}, + {file = "aiohttp-3.10.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8c5c6fa16412b35999320f5c9690c0f554392dc222c04e559217e0f9ae244b92"}, + {file = "aiohttp-3.10.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:95c4dc6f61d610bc0ee1edc6f29d993f10febfe5b76bb470b486d90bbece6b22"}, + {file = "aiohttp-3.10.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da452c2c322e9ce0cfef392e469a26d63d42860f829026a63374fde6b5c5876f"}, + {file = "aiohttp-3.10.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:898715cf566ec2869d5cb4d5fb4be408964704c46c96b4be267442d265390f32"}, + {file = "aiohttp-3.10.5-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:391cc3a9c1527e424c6865e087897e766a917f15dddb360174a70467572ac6ce"}, + {file = "aiohttp-3.10.5-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:380f926b51b92d02a34119d072f178d80bbda334d1a7e10fa22d467a66e494db"}, + {file = "aiohttp-3.10.5-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ce91db90dbf37bb6fa0997f26574107e1b9d5ff939315247b7e615baa8ec313b"}, + {file = "aiohttp-3.10.5-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:9093a81e18c45227eebe4c16124ebf3e0d893830c6aca7cc310bfca8fe59d857"}, + {file = "aiohttp-3.10.5-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:ee40b40aa753d844162dcc80d0fe256b87cba48ca0054f64e68000453caead11"}, + {file = "aiohttp-3.10.5-cp39-cp39-win32.whl", hash = "sha256:03f2645adbe17f274444953bdea69f8327e9d278d961d85657cb0d06864814c1"}, + {file = "aiohttp-3.10.5-cp39-cp39-win_amd64.whl", hash = "sha256:d17920f18e6ee090bdd3d0bfffd769d9f2cb4c8ffde3eb203777a3895c128862"}, + {file = "aiohttp-3.10.5.tar.gz", hash = "sha256:f071854b47d39591ce9a17981c46790acb30518e2f83dfca8db2dfa091178691"}, ] [package.dependencies] +aiohappyeyeballs = ">=2.3.0" aiosignal = ">=1.1.2" async-timeout = {version = ">=4.0,<5.0", markers = "python_version < \"3.11\""} attrs = ">=17.3.0" @@ -155,7 +182,7 @@ multidict = ">=4.5,<7.0" yarl = ">=1.0,<2.0" [package.extras] -speedups = ["Brotli", "aiodns", "brotlicffi"] +speedups = ["Brotli", "aiodns (>=3.2.0)", "brotlicffi"] [[package]] name = "aioitertools" @@ -543,16 +570,15 @@ name = "cffdrs" version = "0.1.2" description = "" optional = false -python-versions = ">=3.10,<4.0" -files = [ - {file = "cffdrs-0.1.2-py3-none-any.whl", hash = "sha256:a124e09bb963670bc22cfa25de5e8d4b63f582b24bdeffd4f413afe8da7a8507"}, - {file = "cffdrs-0.1.2.tar.gz", hash = "sha256:d655ad3b2648ea2200d093c17d2098a1f49fc26617b50a33157baa38dcf8079b"}, -] +python-versions = ">=3.7" +files = [] +develop = false [package.source] -type = "legacy" -url = "https://artifacts.developer.gov.bc.ca/artifactory/api/pypi/pe1e-gen-python-local/simple" -reference = "psu" +type = "git" +url = "https://github.com/cffdrs/cffdrs_py.git" +reference = "c760307" +resolved_reference = "c7603073264906242a2c8d46ceecb01d1f539975" [[package]] name = "cffi" @@ -920,38 +946,38 @@ toml = ["tomli"] [[package]] name = "cryptography" -version = "43.0.0" +version = "43.0.1" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false python-versions = ">=3.7" files = [ - {file = "cryptography-43.0.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:64c3f16e2a4fc51c0d06af28441881f98c5d91009b8caaff40cf3548089e9c74"}, - {file = "cryptography-43.0.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3dcdedae5c7710b9f97ac6bba7e1052b95c7083c9d0e9df96e02a1932e777895"}, - {file = "cryptography-43.0.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d9a1eca329405219b605fac09ecfc09ac09e595d6def650a437523fcd08dd22"}, - {file = "cryptography-43.0.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ea9e57f8ea880eeea38ab5abf9fbe39f923544d7884228ec67d666abd60f5a47"}, - {file = "cryptography-43.0.0-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:9a8d6802e0825767476f62aafed40532bd435e8a5f7d23bd8b4f5fd04cc80ecf"}, - {file = "cryptography-43.0.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:cc70b4b581f28d0a254d006f26949245e3657d40d8857066c2ae22a61222ef55"}, - {file = "cryptography-43.0.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4a997df8c1c2aae1e1e5ac49c2e4f610ad037fc5a3aadc7b64e39dea42249431"}, - {file = "cryptography-43.0.0-cp37-abi3-win32.whl", hash = "sha256:6e2b11c55d260d03a8cf29ac9b5e0608d35f08077d8c087be96287f43af3ccdc"}, - {file = "cryptography-43.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:31e44a986ceccec3d0498e16f3d27b2ee5fdf69ce2ab89b52eaad1d2f33d8778"}, - {file = "cryptography-43.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:7b3f5fe74a5ca32d4d0f302ffe6680fcc5c28f8ef0dc0ae8f40c0f3a1b4fca66"}, - {file = "cryptography-43.0.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac1955ce000cb29ab40def14fd1bbfa7af2017cca696ee696925615cafd0dce5"}, - {file = "cryptography-43.0.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:299d3da8e00b7e2b54bb02ef58d73cd5f55fb31f33ebbf33bd00d9aa6807df7e"}, - {file = "cryptography-43.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ee0c405832ade84d4de74b9029bedb7b31200600fa524d218fc29bfa371e97f5"}, - {file = "cryptography-43.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:cb013933d4c127349b3948aa8aaf2f12c0353ad0eccd715ca789c8a0f671646f"}, - {file = "cryptography-43.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fdcb265de28585de5b859ae13e3846a8e805268a823a12a4da2597f1f5afc9f0"}, - {file = "cryptography-43.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2905ccf93a8a2a416f3ec01b1a7911c3fe4073ef35640e7ee5296754e30b762b"}, - {file = "cryptography-43.0.0-cp39-abi3-win32.whl", hash = "sha256:47ca71115e545954e6c1d207dd13461ab81f4eccfcb1345eac874828b5e3eaaf"}, - {file = "cryptography-43.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:0663585d02f76929792470451a5ba64424acc3cd5227b03921dab0e2f27b1709"}, - {file = "cryptography-43.0.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:2c6d112bf61c5ef44042c253e4859b3cbbb50df2f78fa8fae6747a7814484a70"}, - {file = "cryptography-43.0.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:844b6d608374e7d08f4f6e6f9f7b951f9256db41421917dfb2d003dde4cd6b66"}, - {file = "cryptography-43.0.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:51956cf8730665e2bdf8ddb8da0056f699c1a5715648c1b0144670c1ba00b48f"}, - {file = "cryptography-43.0.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:aae4d918f6b180a8ab8bf6511a419473d107df4dbb4225c7b48c5c9602c38c7f"}, - {file = "cryptography-43.0.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:232ce02943a579095a339ac4b390fbbe97f5b5d5d107f8a08260ea2768be8cc2"}, - {file = "cryptography-43.0.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5bcb8a5620008a8034d39bce21dc3e23735dfdb6a33a06974739bfa04f853947"}, - {file = "cryptography-43.0.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:08a24a7070b2b6804c1940ff0f910ff728932a9d0e80e7814234269f9d46d069"}, - {file = "cryptography-43.0.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:e9c5266c432a1e23738d178e51c2c7a5e2ddf790f248be939448c0ba2021f9d1"}, - {file = "cryptography-43.0.0.tar.gz", hash = "sha256:b88075ada2d51aa9f18283532c9f60e72170041bba88d7f37e49cbb10275299e"}, + {file = "cryptography-43.0.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:8385d98f6a3bf8bb2d65a73e17ed87a3ba84f6991c155691c51112075f9ffc5d"}, + {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27e613d7077ac613e399270253259d9d53872aaf657471473ebfc9a52935c062"}, + {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68aaecc4178e90719e95298515979814bda0cbada1256a4485414860bd7ab962"}, + {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:de41fd81a41e53267cb020bb3a7212861da53a7d39f863585d13ea11049cf277"}, + {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f98bf604c82c416bc829e490c700ca1553eafdf2912a91e23a79d97d9801372a"}, + {file = "cryptography-43.0.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:61ec41068b7b74268fa86e3e9e12b9f0c21fcf65434571dbb13d954bceb08042"}, + {file = "cryptography-43.0.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:014f58110f53237ace6a408b5beb6c427b64e084eb451ef25a28308270086494"}, + {file = "cryptography-43.0.1-cp37-abi3-win32.whl", hash = "sha256:2bd51274dcd59f09dd952afb696bf9c61a7a49dfc764c04dd33ef7a6b502a1e2"}, + {file = "cryptography-43.0.1-cp37-abi3-win_amd64.whl", hash = "sha256:666ae11966643886c2987b3b721899d250855718d6d9ce41b521252a17985f4d"}, + {file = "cryptography-43.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:ac119bb76b9faa00f48128b7f5679e1d8d437365c5d26f1c2c3f0da4ce1b553d"}, + {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bbcce1a551e262dfbafb6e6252f1ae36a248e615ca44ba302df077a846a8806"}, + {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58d4e9129985185a06d849aa6df265bdd5a74ca6e1b736a77959b498e0505b85"}, + {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d03a475165f3134f773d1388aeb19c2d25ba88b6a9733c5c590b9ff7bbfa2e0c"}, + {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:511f4273808ab590912a93ddb4e3914dfd8a388fed883361b02dea3791f292e1"}, + {file = "cryptography-43.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:80eda8b3e173f0f247f711eef62be51b599b5d425c429b5d4ca6a05e9e856baa"}, + {file = "cryptography-43.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38926c50cff6f533f8a2dae3d7f19541432610d114a70808f0926d5aaa7121e4"}, + {file = "cryptography-43.0.1-cp39-abi3-win32.whl", hash = "sha256:a575913fb06e05e6b4b814d7f7468c2c660e8bb16d8d5a1faf9b33ccc569dd47"}, + {file = "cryptography-43.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:d75601ad10b059ec832e78823b348bfa1a59f6b8d545db3a24fd44362a1564cb"}, + {file = "cryptography-43.0.1-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ea25acb556320250756e53f9e20a4177515f012c9eaea17eb7587a8c4d8ae034"}, + {file = "cryptography-43.0.1-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c1332724be35d23a854994ff0b66530119500b6053d0bd3363265f7e5e77288d"}, + {file = "cryptography-43.0.1-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fba1007b3ef89946dbbb515aeeb41e30203b004f0b4b00e5e16078b518563289"}, + {file = "cryptography-43.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:5b43d1ea6b378b54a1dc99dd8a2b5be47658fe9a7ce0a58ff0b55f4b43ef2b84"}, + {file = "cryptography-43.0.1-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:88cce104c36870d70c49c7c8fd22885875d950d9ee6ab54df2745f83ba0dc365"}, + {file = "cryptography-43.0.1-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:9d3cdb25fa98afdd3d0892d132b8d7139e2c087da1712041f6b762e4f807cc96"}, + {file = "cryptography-43.0.1-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e710bf40870f4db63c3d7d929aa9e09e4e7ee219e703f949ec4073b4294f6172"}, + {file = "cryptography-43.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7c05650fe8023c5ed0d46793d4b7d7e6cd9c04e68eabe5b0aeea836e37bdcec2"}, + {file = "cryptography-43.0.1.tar.gz", hash = "sha256:203e92a75716d8cfb491dc47c79e17d0d9207ccffcbcb35f598fbe463ae3444d"}, ] [package.dependencies] @@ -964,7 +990,7 @@ nox = ["nox"] pep8test = ["check-sdist", "click", "mypy", "ruff"] sdist = ["build"] ssh = ["bcrypt (>=3.1.5)"] -test = ["certifi", "cryptography-vectors (==43.0.0)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] +test = ["certifi", "cryptography-vectors (==43.0.1)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] test-randomorder = ["pytest-randomly"] [[package]] @@ -2769,13 +2795,13 @@ files = [ [[package]] name = "notebook" -version = "7.2.0" +version = "7.2.2" description = "Jupyter Notebook - A web-based notebook environment for interactive computing" optional = false python-versions = ">=3.8" files = [ - {file = "notebook-7.2.0-py3-none-any.whl", hash = "sha256:b4752d7407d6c8872fc505df0f00d3cae46e8efb033b822adacbaa3f1f3ce8f5"}, - {file = "notebook-7.2.0.tar.gz", hash = "sha256:34a2ba4b08ad5d19ec930db7484fb79746a1784be9e1a5f8218f9af8656a141f"}, + {file = "notebook-7.2.2-py3-none-any.whl", hash = "sha256:c89264081f671bc02eec0ed470a627ed791b9156cad9285226b31611d3e9fe1c"}, + {file = "notebook-7.2.2.tar.gz", hash = "sha256:2ef07d4220421623ad3fe88118d687bc0450055570cdd160814a59cf3a1c516e"}, ] [package.dependencies] @@ -4124,6 +4150,7 @@ files = [ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, + {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, @@ -4131,8 +4158,16 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, + {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, + {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, + {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, + {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, @@ -4149,6 +4184,7 @@ files = [ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, + {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, @@ -4156,6 +4192,7 @@ files = [ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, + {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, @@ -4750,13 +4787,13 @@ win32 = ["pywin32"] [[package]] name = "sentry-sdk" -version = "2.3.1" +version = "2.14.0" description = "Python client for Sentry (https://sentry.io)" optional = false python-versions = ">=3.6" files = [ - {file = "sentry_sdk-2.3.1-py2.py3-none-any.whl", hash = "sha256:c5aeb095ba226391d337dd42a6f9470d86c9fc236ecc71cfc7cd1942b45010c6"}, - {file = "sentry_sdk-2.3.1.tar.gz", hash = "sha256:139a71a19f5e9eb5d3623942491ce03cf8ebc14ea2e39ba3e6fe79560d8a5b1f"}, + {file = "sentry_sdk-2.14.0-py2.py3-none-any.whl", hash = "sha256:b8bc3dc51d06590df1291b7519b85c75e2ced4f28d9ea655b6d54033503b5bf4"}, + {file = "sentry_sdk-2.14.0.tar.gz", hash = "sha256:1e0e2eaf6dad918c7d1e0edac868a7bf20017b177f242cefe2a6bcd47955961d"}, ] [package.dependencies] @@ -4784,10 +4821,11 @@ httpx = ["httpx (>=0.16.0)"] huey = ["huey (>=2)"] huggingface-hub = ["huggingface-hub (>=0.22)"] langchain = ["langchain (>=0.0.210)"] +litestar = ["litestar (>=2.0.0)"] loguru = ["loguru (>=0.5)"] openai = ["openai (>=1.0.0)", "tiktoken (>=0.3.0)"] opentelemetry = ["opentelemetry-distro (>=0.35b0)"] -opentelemetry-experimental = ["opentelemetry-distro (>=0.40b0,<1.0)", "opentelemetry-instrumentation-aiohttp-client (>=0.40b0,<1.0)", "opentelemetry-instrumentation-django (>=0.40b0,<1.0)", "opentelemetry-instrumentation-fastapi (>=0.40b0,<1.0)", "opentelemetry-instrumentation-flask (>=0.40b0,<1.0)", "opentelemetry-instrumentation-requests (>=0.40b0,<1.0)", "opentelemetry-instrumentation-sqlite3 (>=0.40b0,<1.0)", "opentelemetry-instrumentation-urllib (>=0.40b0,<1.0)"] +opentelemetry-experimental = ["opentelemetry-distro"] pure-eval = ["asttokens", "executing", "pure-eval"] pymongo = ["pymongo (>=3.1)"] pyspark = ["pyspark (>=2.4.4)"] @@ -4797,23 +4835,27 @@ sanic = ["sanic (>=0.8)"] sqlalchemy = ["sqlalchemy (>=1.2)"] starlette = ["starlette (>=0.19.1)"] starlite = ["starlite (>=1.48)"] -tornado = ["tornado (>=5)"] +tornado = ["tornado (>=6)"] [[package]] name = "setuptools" -version = "72.1.0" +version = "75.1.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.8" files = [ - {file = "setuptools-72.1.0-py3-none-any.whl", hash = "sha256:5a03e1860cf56bb6ef48ce186b0e557fdba433237481a9a625176c2831be15d1"}, - {file = "setuptools-72.1.0.tar.gz", hash = "sha256:8d243eff56d095e5817f796ede6ae32941278f542e0f941867cc05ae52b162ec"}, + {file = "setuptools-75.1.0-py3-none-any.whl", hash = "sha256:35ab7fd3bcd95e6b7fd704e4a1539513edad446c097797f2985e0e4b960772f2"}, + {file = "setuptools-75.1.0.tar.gz", hash = "sha256:d59a21b17a275fb872a9c3dae73963160ae079f1049ed956880cd7c09b120538"}, ] [package.extras] -core = ["importlib-metadata (>=6)", "importlib-resources (>=5.10.2)", "jaraco.text (>=3.7)", "more-itertools (>=8.8)", "ordered-set (>=3.1.1)", "packaging (>=24)", "platformdirs (>=2.6.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] -test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test", "mypy (==1.11.*)", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "pytest-perf", "pytest-ruff (<0.4)", "pytest-ruff (>=0.2.1)", "pytest-ruff (>=0.3.2)", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)", "ruff (>=0.5.2)"] +core = ["importlib-metadata (>=6)", "importlib-resources (>=5.10.2)", "jaraco.collections", "jaraco.functools", "jaraco.text (>=3.7)", "more-itertools", "more-itertools (>=8.8)", "packaging", "packaging (>=24)", "platformdirs (>=2.6.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] +type = ["importlib-metadata (>=7.0.2)", "jaraco.develop (>=7.21)", "mypy (==1.11.*)", "pytest-mypy"] [[package]] name = "shapely" @@ -5903,4 +5945,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = ">=3.10.4,<3.11" -content-hash = "945b819456849123fd2859c11f4ae1714527490a3d73265d1ae070dbf7b48343" +content-hash = "efdbf82f9882ba9fcf50fc5c88d9619968d56249cf60d90cf84b353d4acf951c" diff --git a/api/pyproject.toml b/api/pyproject.toml index 86ec04238..f8a05383d 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -34,7 +34,7 @@ pdfkit = "^1.0.0" asyncpg = "^0.29.0" python-multipart = "^0.0.9" gunicorn = "^23.0.0" -setuptools = "^72.0.0" +setuptools = "^75.0.0" nats-py = "^2.1.7" affine = "^2.3.1" rasterio = "^1.3.2" @@ -42,9 +42,9 @@ scikit-learn = "^1.1.3" httpx = "^0.27.0" orjson = "^3.9.0" greenlet = "^3.0.0" -sentry-sdk = {extras = ["fastapi"], version = "^2.0.1"} +sentry-sdk = { extras = ["fastapi"], version = "^2.0.1" } numba = "^0.59.1" -cffdrs = {version = "^0.1.1", source = "psu"} +cffdrs = {git = "https://github.com/cffdrs/cffdrs_py.git", rev = "c760307"} geopandas = "^1.0.1" shapely = "^2.0.5" @@ -65,12 +65,6 @@ pytest-watch = "^4.2.0" pytest-testmon = "^2.0.0" ruff = "^0.4.0" - -[[tool.poetry.source]] -name = "psu" -url = "https://artifacts.developer.gov.bc.ca/artifactory/api/pypi/pe1e-gen-python-local/simple" -priority = "supplemental" - [build-system] requires = ["poetry>=1.1.11"] build-backend = "poetry.masonry.api" @@ -105,4 +99,3 @@ per-file-ignores = { "alembic/versions/00df3c7b5cba_rethink_classification.py" = ] } line-length = 185 ignore = ["E712", "F401"] - diff --git a/openshift/scripts/common/envars b/openshift/scripts/common/envars index 48877f69a..b0e2504f2 100644 --- a/openshift/scripts/common/envars +++ b/openshift/scripts/common/envars @@ -6,7 +6,7 @@ PROJ_DEV="${PROJ_DEV:-e1e498-dev}" PROJ_PROD="${PROJ_PROD:-e1e498-prod}" TAG_PROD="${TAG_PROD:-prod}" PATH_BC="${PATH_BC:-$(dirname ${0})/../templates/build.bc.yaml}" -PATH_DC="${PATH_DC:-$(dirname ${0})/../templates/deploy.dc.yaml}" +PATH_DEPLOY="${PATH_DEPLOY:-$(dirname ${0})/../templates/deploy.yaml}" PATH_NATS="${PATH_NATS:-$(dirname ${0})/../templates/nats.yaml}" PATH_NATS_SERVER_CONFIG="${PATH_NATS_SERVER_CONFIG:-$(dirname ${0})/../templates/nats_server.yaml}" TEMPLATE_PATH="${TEMPLATE_PATH:-$(dirname ${0})/../templates}" diff --git a/openshift/scripts/oc_build.sh b/openshift/scripts/oc_build.sh index 7ae735ba3..627502448 100755 --- a/openshift/scripts/oc_build.sh +++ b/openshift/scripts/oc_build.sh @@ -30,9 +30,6 @@ OC_PROCESS="oc -n ${PROJ_TOOLS} process -f ${PATH_BC} \ -p SUFFIX=${SUFFIX} \ -p GIT_BRANCH=${GIT_BRANCH} \ ${SENTRY_AUTH_TOKEN:+ "-p SENTRY_AUTH_TOKEN=${SENTRY_AUTH_TOKEN}"} \ - ${ARTIFACTORY_SVCACCT_TOKEN:+ "-p ARTIFACTORY_SVCACCT_TOKEN=${ARTIFACTORY_SVCACCT_TOKEN}"} \ - ${ARTIFACTORY_PYPI_USERNAME:+ "-p ARTIFACTORY_PYPI_USERNAME=${ARTIFACTORY_PYPI_USERNAME}"} \ - ${ARTIFACTORY_PYPI_PASSWORD:+ "-p ARTIFACTORY_PYPI_PASSWORD=${ARTIFACTORY_PYPI_PASSWORD}"} \ ${DOCKER_IMAGE:+ "-p DOCKER_IMAGE=${DOCKER_IMAGE}"} \ ${DOCKER_FILE:+ "-p DOCKER_FILE=${DOCKER_FILE}"}" diff --git a/openshift/scripts/oc_deploy.sh b/openshift/scripts/oc_deploy.sh index 43d838d15..27eba2992 100755 --- a/openshift/scripts/oc_deploy.sh +++ b/openshift/scripts/oc_deploy.sh @@ -31,7 +31,7 @@ OBJ_NAME="${APP_NAME}-${SUFFIX}" # Process a template (mostly variable substition) # -OC_PROCESS="oc -n ${PROJ_TARGET} process -f ${PATH_DC} \ +OC_PROCESS="oc -n ${PROJ_TARGET} process -f ${PATH_DEPLOY} \ -p SUFFIX=${SUFFIX} \ -p PROJECT_NAMESPACE=${PROJ_TARGET} \ -p POSTGRES_USER=wps-crunchydb-${SUFFIX} \ @@ -55,48 +55,15 @@ OC_PROCESS="oc -n ${PROJ_TARGET} process -f ${PATH_DC} \ OC_APPLY="oc -n ${PROJ_TARGET} apply -f -" [ "${APPLY}" ] || OC_APPLY="${OC_APPLY} --dry-run=client" -# Cancel all previous deployments -# -OC_CANCEL_ALL_PREV_DEPLOY="oc -n ${PROJ_TARGET} rollout cancel dc/${OBJ_NAME} || true" - -# Deploy and follow the progress -# -OC_DEPLOY="oc -n ${PROJ_TARGET} rollout latest dc/${OBJ_NAME}" -OC_LOG="oc -n ${PROJ_TARGET} logs -f --pod-running-timeout=2m dc/${OBJ_NAME}" -if [ ! "${APPLY}" ]; then - OC_CANCEL_ALL_PREV_DEPLOY="" - OC_DEPLOY="${OC_DEPLOY} --dry-run=client || true" # in case there is no previous rollout - OC_LOG="" -fi +# Run the OC_PROCESS command +eval ${OC_PROCESS} -# Execute commands -# -eval "${OC_PROCESS}" +# Run OC_PROCESS and pipe it to OC_APPLY eval "${OC_PROCESS} | ${OC_APPLY}" -if [ "${APPLY}" ]; then - echo "canceling previous deployments..." - eval "${OC_CANCEL_ALL_PREV_DEPLOY}" - count=1 - timeout=10 - # Check previous deployment statuses before moving onto new deploying - while [ $count -le $timeout ]; do - sleep 1 - PENDINGS="$(oc -n ${PROJ_TARGET} rollout history dc/${OBJ_NAME} | awk '{print $2}' | grep -c Pending || true)" - RUNNINGS="$(oc -n ${PROJ_TARGET} rollout history dc/${OBJ_NAME} | awk '{print $2}' | grep -c Running || true)" - if [ "${PENDINGS}" == 0 ] && [ "${RUNNINGS}" == 0 ]; then - # No pending or running replica controllers so exit the while loop - break 2 - fi - count=$(( $count + 1 )) - done - if [ $count -gt $timeout ]; then - echo "\n*** timeout for canceling deployment ***\n" - exit 1 - fi -fi -eval "${OC_DEPLOY}" -eval "${OC_LOG}" + +# Wait for rollout to finish +oc -n ${PROJ_TARGET} rollout status deployment/${OBJ_NAME} # Provide oc command instruction # -display_helper "${OC_PROCESS} | ${OC_APPLY}" $OC_CANCEL_ALL_PREV_DEPLOY $OC_DEPLOY $OC_LOG +display_helper "${OC_PROCESS} | ${OC_APPLY}" diff --git a/openshift/templates/build.bc.yaml b/openshift/templates/build.bc.yaml index 10f54d9e0..a7a347a35 100644 --- a/openshift/templates/build.bc.yaml +++ b/openshift/templates/build.bc.yaml @@ -37,12 +37,6 @@ parameters: description: Dockerfile to use required: true value: Dockerfile - - name: ARTIFACTORY_PYPI_USERNAME - description: Username for internal pypi artifactory instance - required: true - - name: ARTIFACTORY_PYPI_PASSWORD - description: Password for internal pypi artifactory instance - required: true objects: - apiVersion: v1 kind: ImageStream @@ -86,12 +80,5 @@ objects: contextDir: ./ strategy: dockerStrategy: - buildArgs: - - name: "DOCKER_IMAGE" - value: "${{DOCKER_IMAGE}}" - - name: "ARTIFACTORY_PYPI_USERNAME" - value: "${{ARTIFACTORY_PYPI_USERNAME}}" - - name: "ARTIFACTORY_PYPI_PASSWORD" - value: "${{ARTIFACTORY_PYPI_PASSWORD}}" dockerfilePath: ${DOCKER_FILE} triggers: [] diff --git a/openshift/templates/build.web.bc.yaml b/openshift/templates/build.web.bc.yaml index 7bbe19dc9..f8a91308b 100644 --- a/openshift/templates/build.web.bc.yaml +++ b/openshift/templates/build.web.bc.yaml @@ -36,9 +36,6 @@ parameters: - name: SENTRY_AUTH_TOKEN description: Sentry auth token for uploading source maps required: true - - name: ARTIFACTORY_SVCACCT_TOKEN - description: Arctifactory service account token - required: true objects: - apiVersion: v1 kind: ImageStream @@ -85,7 +82,5 @@ objects: env: - name: "SENTRY_AUTH_TOKEN" value: ${SENTRY_AUTH_TOKEN} - - name: "ARTIFACTORY_SVCACCT_TOKEN" - value: ${ARTIFACTORY_SVCACCT_TOKEN} dockerfilePath: ${DOCKER_FILE} triggers: [] diff --git a/openshift/templates/deploy.dc.yaml b/openshift/templates/deploy.yaml similarity index 95% rename from openshift/templates/deploy.dc.yaml rename to openshift/templates/deploy.yaml index a2c3c80a7..323b540ae 100644 --- a/openshift/templates/deploy.dc.yaml +++ b/openshift/templates/deploy.yaml @@ -77,19 +77,40 @@ parameters: description: "Number of gunicorn workers" value: "4" objects: - - apiVersion: v1 - kind: DeploymentConfig + - apiVersion: apps/v1 + kind: Deployment metadata: labels: app: ${APP_NAME}-${SUFFIX} name: ${APP_NAME}-${SUFFIX} + annotations: + # These annotations trigger a new rollout if either the web or api images change + image.openshift.io/triggers: |- + [ + { + "from": { + "kind": "ImageStreamTag", + "name": "${APP_NAME}-web-${SUFFIX}:${SUFFIX}", + "namespace": "${PROJ_TOOLS}" + }, + "fieldPath": "spec.template.spec.containers[0].image" + }, + { + "from": { + "kind": "ImageStreamTag", + "name": "${APP_NAME}-api-${SUFFIX}:${SUFFIX}", + "namespace": "${PROJ_TOOLS}" + }, + "fieldPath": "spec.template.spec.containers[1].image" + } + ] spec: replicas: ${{REPLICAS}} selector: - name: ${APP_NAME}-${SUFFIX} + matchLabels: + name: ${APP_NAME}-${SUFFIX} strategy: - type: Rolling - triggers: [] + type: RollingUpdate template: metadata: labels: @@ -429,8 +450,8 @@ objects: name: vpa-recommender-${SUFFIX} spec: targetRef: - apiVersion: "apps.openshift.io/v1" - kind: DeploymentConfig + apiVersion: "apps/v1" + kind: Deployment name: ${APP_NAME}-${SUFFIX} updatePolicy: updateMode: "Off" diff --git a/openshift/templates/nats.yaml b/openshift/templates/nats.yaml index 48f0f2640..14a898dfa 100644 --- a/openshift/templates/nats.yaml +++ b/openshift/templates/nats.yaml @@ -252,6 +252,18 @@ objects: name: ${APP_NAME}-${SUFFIX}-nats-consumer labels: app: ${APP_NAME}-${SUFFIX} + annotations: + image.openshift.io/triggers: |- + [ + { + "from": { + "kind": "ImageStreamTag", + "name": "${IMAGE_NAME}:${IMAGE_TAG}", + "namespace": "${PROJ_TOOLS}" + }, + "fieldPath": "spec.template.spec.containers[0].image" + } + ] spec: replicas: 3 selector: @@ -341,6 +353,61 @@ objects: secretKeyRef: name: ${GLOBAL_NAME} key: object-store-bucket + - name: REDIS_USE + valueFrom: + configMapKeyRef: + name: ${GLOBAL_NAME} + key: env.redis-use + - name: REDIS_HOST + valueFrom: + configMapKeyRef: + name: ${GLOBAL_NAME} + key: env.redis-host + - name: REDIS_PORT + valueFrom: + configMapKeyRef: + name: ${GLOBAL_NAME} + key: env.redis-port + - name: REDIS_PASSWORD + valueFrom: + secretKeyRef: + name: wps-redis + key: database-password + - name: REDIS_STATION_CACHE_EXPIRY + valueFrom: + configMapKeyRef: + name: ${GLOBAL_NAME} + key: env.redis-station-cache-expiry + - name: REDIS_HOURLIES_BY_STATION_CODE_CACHE_EXPIRY + valueFrom: + configMapKeyRef: + name: ${GLOBAL_NAME} + key: env.redis-hourlies-by-station-code-cache-expiry + - name: REDIS_AUTH_CACHE_EXPIRY + valueFrom: + configMapKeyRef: + name: ${GLOBAL_NAME} + key: env.redis-auth-cache-expiry + - name: WFWX_BASE_URL + valueFrom: + configMapKeyRef: + name: ${GLOBAL_NAME} + key: env.wfwx-base-url + - name: WFWX_AUTH_URL + valueFrom: + configMapKeyRef: + name: ${GLOBAL_NAME} + key: env.wfwx-auth-url + - name: WFWX_USER + valueFrom: + configMapKeyRef: + name: ${GLOBAL_NAME} + key: env.wfwx-user + - name: WFWX_SECRET + valueFrom: + secretKeyRef: + name: ${GLOBAL_NAME} + key: wfwx-secret - name: DEM_NAME valueFrom: configMapKeyRef: diff --git a/openshift/templates/redis.dc.yaml b/openshift/templates/redis.dc.yaml deleted file mode 100644 index 5cd979eac..000000000 --- a/openshift/templates/redis.dc.yaml +++ /dev/null @@ -1,295 +0,0 @@ -kind: DeploymentConfig -apiVersion: apps.openshift.io/v1 -metadata: - annotations: - template.alpha.openshift.io/wait-for-ready: 'true' - resourceVersion: '3697714227' - name: wps-redis - uid: 993faf9d-08fc-47cb-994f-4cb2055da786 - creationTimestamp: '2021-06-18T18:50:06Z' - generation: 19 - managedFields: - - manager: Mozilla - operation: Update - apiVersion: apps.openshift.io/v1 - time: '2021-07-26T22:59:42Z' - fieldsType: FieldsV1 - fieldsV1: - 'f:spec': - 'f:template': - 'f:spec': - 'f:containers': - 'k:{"name":"redis"}': - 'f:resources': - 'f:limits': - 'f:memory': {} - 'f:requests': - .: {} - 'f:memory': {} - - manager: openshift-controller-manager - operation: Update - apiVersion: apps.openshift.io/v1 - time: '2022-06-06T18:15:22Z' - fieldsType: FieldsV1 - fieldsV1: - 'f:metadata': - 'f:annotations': - .: {} - 'f:template.alpha.openshift.io/wait-for-ready': {} - 'f:labels': - .: {} - 'f:template': {} - 'f:template.openshift.io/template-instance-owner': {} - 'f:spec': - 'f:replicas': {} - 'f:selector': - .: {} - 'f:name': {} - 'f:strategy': - 'f:activeDeadlineSeconds': {} - 'f:recreateParams': - .: {} - 'f:timeoutSeconds': {} - 'f:type': {} - 'f:template': - .: {} - 'f:metadata': - .: {} - 'f:creationTimestamp': {} - 'f:labels': - .: {} - 'f:name': {} - 'f:spec': - .: {} - 'f:containers': - .: {} - 'k:{"name":"redis"}': - 'f:image': {} - 'f:volumeMounts': - .: {} - 'k:{"mountPath":"/var/lib/redis/data"}': - .: {} - 'f:mountPath': {} - 'f:name': {} - 'f:terminationMessagePolicy': {} - .: {} - 'f:resources': - .: {} - 'f:limits': - .: {} - 'f:memory': {} - 'f:livenessProbe': - .: {} - 'f:failureThreshold': {} - 'f:initialDelaySeconds': {} - 'f:periodSeconds': {} - 'f:successThreshold': {} - 'f:tcpSocket': - .: {} - 'f:port': {} - 'f:timeoutSeconds': {} - 'f:env': - .: {} - 'k:{"name":"REDIS_PASSWORD"}': - .: {} - 'f:name': {} - 'f:valueFrom': - .: {} - 'f:secretKeyRef': {} - 'f:readinessProbe': - .: {} - 'f:exec': - .: {} - 'f:command': {} - 'f:failureThreshold': {} - 'f:initialDelaySeconds': {} - 'f:periodSeconds': {} - 'f:successThreshold': {} - 'f:timeoutSeconds': {} - 'f:securityContext': - .: {} - 'f:capabilities': {} - 'f:privileged': {} - 'f:terminationMessagePath': {} - 'f:imagePullPolicy': {} - 'f:ports': - .: {} - 'k:{"containerPort":6379,"protocol":"TCP"}': - .: {} - 'f:containerPort': {} - 'f:protocol': {} - 'f:name': {} - 'f:dnsPolicy': {} - 'f:restartPolicy': {} - 'f:schedulerName': {} - 'f:securityContext': {} - 'f:terminationGracePeriodSeconds': {} - 'f:volumes': - .: {} - 'k:{"name":"wps-redis-data"}': - .: {} - 'f:emptyDir': {} - 'f:name': {} - 'f:triggers': {} - 'f:status': - 'f:conditions': - .: {} - 'k:{"type":"Available"}': - .: {} - 'f:type': {} - 'k:{"type":"Progressing"}': - .: {} - 'f:type': {} - 'f:details': - .: {} - 'f:message': {} - - manager: openshift-controller-manager - operation: Update - apiVersion: apps.openshift.io/v1 - time: '2022-06-07T11:55:02Z' - fieldsType: FieldsV1 - fieldsV1: - 'f:status': - 'f:updatedReplicas': {} - 'f:readyReplicas': {} - 'f:conditions': - 'k:{"type":"Available"}': - 'f:lastTransitionTime': {} - 'f:lastUpdateTime': {} - 'f:message': {} - 'f:status': {} - 'k:{"type":"Progressing"}': - 'f:lastTransitionTime': {} - 'f:lastUpdateTime': {} - 'f:message': {} - 'f:reason': {} - 'f:status': {} - 'f:details': - 'f:causes': {} - 'f:replicas': {} - 'f:availableReplicas': {} - 'f:observedGeneration': {} - 'f:unavailableReplicas': {} - 'f:latestVersion': {} - subresource: status - namespace: e1e498-prod - labels: - template: redis-ephemeral-template - template.openshift.io/template-instance-owner: 31445217-3081-42d2-88f6-97ef32b19ff1 -spec: - strategy: - type: Recreate - recreateParams: - timeoutSeconds: 600 - resources: {} - activeDeadlineSeconds: 21600 - triggers: - - type: ImageChange - imageChangeParams: - automatic: true - containerNames: - - redis - from: - kind: ImageStreamTag - namespace: openshift - name: 'redis:5-el8' - lastTriggeredImage: >- - image-registry.openshift-image-registry.svc:5000/openshift/redis@sha256:0b6f2072bb6ef3d182cd7fbd534bb00d838d23e17c0f0b3e7b3fd6b7ac1901cc - - type: ConfigChange - replicas: 1 - revisionHistoryLimit: 10 - test: false - selector: - name: wps-redis - template: - metadata: - creationTimestamp: null - labels: - name: wps-redis - spec: - volumes: - - name: wps-redis-data - emptyDir: {} - containers: - - resources: - limits: - memory: 8Gi - requests: - memory: 512Mi - readinessProbe: - exec: - command: - - /bin/sh - - '-i' - - '-c' - - >- - test "$(redis-cli -h 127.0.0.1 -a $REDIS_PASSWORD ping)" == - "PONG" - initialDelaySeconds: 5 - timeoutSeconds: 1 - periodSeconds: 10 - successThreshold: 1 - failureThreshold: 3 - terminationMessagePath: /dev/termination-log - name: redis - livenessProbe: - tcpSocket: - port: 6379 - initialDelaySeconds: 30 - timeoutSeconds: 1 - periodSeconds: 10 - successThreshold: 1 - failureThreshold: 3 - env: - - name: REDIS_PASSWORD - valueFrom: - secretKeyRef: - name: wps-redis - key: database-password - securityContext: - capabilities: {} - privileged: false - ports: - - containerPort: 6379 - protocol: TCP - imagePullPolicy: IfNotPresent - volumeMounts: - - name: wps-redis-data - mountPath: /var/lib/redis/data - terminationMessagePolicy: File - image: >- - image-registry.openshift-image-registry.svc:5000/openshift/redis@sha256:0b6f2072bb6ef3d182cd7fbd534bb00d838d23e17c0f0b3e7b3fd6b7ac1901cc - restartPolicy: Always - terminationGracePeriodSeconds: 30 - dnsPolicy: ClusterFirst - securityContext: {} - schedulerName: default-scheduler -status: - observedGeneration: 19 - details: - message: image change - causes: - - type: ImageChange - imageTrigger: - from: - kind: DockerImage - name: >- - image-registry.openshift-image-registry.svc:5000/openshift/redis@sha256:0b6f2072bb6ef3d182cd7fbd534bb00d838d23e17c0f0b3e7b3fd6b7ac1901cc - availableReplicas: 1 - unavailableReplicas: 0 - latestVersion: 16 - updatedReplicas: 1 - conditions: - - type: Available - status: 'True' - lastUpdateTime: '2022-06-07T11:55:02Z' - lastTransitionTime: '2022-06-07T11:55:02Z' - message: Deployment config has minimum availability. - - type: Progressing - status: 'True' - lastUpdateTime: '2022-06-07T11:55:08Z' - lastTransitionTime: '2022-06-07T11:54:41Z' - reason: NewReplicationControllerAvailable - message: replication controller "wps-redis-16" successfully rolled out - replicas: 1 - readyReplicas: 1 diff --git a/openshift/templates/redis.yaml b/openshift/templates/redis.yaml new file mode 100644 index 000000000..1aff5de47 --- /dev/null +++ b/openshift/templates/redis.yaml @@ -0,0 +1,97 @@ +kind: Deployment +apiVersion: apps/v1 +metadata: + annotations: + template.alpha.openshift.io/wait-for-ready: "true" + image.openshift.io/triggers: |- + [ + { + "from": { + "kind": "ImageStreamTag", + "name": "redis:6-el9", + "namespace": openshift + }, + "fieldPath": "spec.template.spec.containers[0].image" + } + ] + resourceVersion: "3697714227" + name: wps-redis + uid: 993faf9d-08fc-47cb-994f-4cb2055da786 + creationTimestamp: "2021-06-18T18:50:06Z" + generation: 19 + labels: + template: redis-ephemeral-template + template.openshift.io/template-instance-owner: 31445217-3081-42d2-88f6-97ef32b19ff1 +spec: + replicas: 1 + selector: + matchLabels: + name: wps-redis + strategy: + type: Recreate + resources: {} + activeDeadlineSeconds: 21600 + template: + metadata: + labels: + name: wps-redis + spec: + automountServiceAccountToken: false + volumes: + - name: wps-redis-data + emptyDir: {} + containers: + - name: redis + image: >- + image-registry.openshift-image-registry.svc:5000/openshift/redis@sha256:e12fc5970148659b3f3ac9d80799beb36138d559830f36ac2319e6ff606cefc3 + ports: + - containerPort: 6379 + protocol: TCP + env: + - name: REDIS_PASSWORD + valueFrom: + secretKeyRef: + name: wps-redis + key: database-password + resources: + limits: + memory: 2Gi + ephemeral-storage: "1Gi" + cpu: 75m + requests: + memory: 512Mi + ephemeral-storage: "512Mi" + cpu: 25m + volumeMounts: + - name: wps-redis-data + mountPath: /var/lib/redis/data + readinessProbe: + exec: + command: + - /bin/sh + - "-i" + - "-c" + - >- + test "$(redis-cli -h 127.0.0.1 -a $REDIS_PASSWORD ping)" == "PONG" + initialDelaySeconds: 5 + timeoutSeconds: 1 + periodSeconds: 10 + successThreshold: 1 + failureThreshold: 3 + livenessProbe: + tcpSocket: + port: 6379 + initialDelaySeconds: 30 + timeoutSeconds: 1 + periodSeconds: 10 + successThreshold: 1 + failureThreshold: 3 + securityContext: + capabilities: {} + privileged: false + imagePullPolicy: IfNotPresent + restartPolicy: Always + terminationGracePeriodSeconds: 30 + dnsPolicy: ClusterFirst + securityContext: {} + schedulerName: default-scheduler diff --git a/setup/vsc-extensions-install.sh b/setup/vsc-extensions-install.sh index ca9c8157b..2ed5d6048 100755 --- a/setup/vsc-extensions-install.sh +++ b/setup/vsc-extensions-install.sh @@ -24,3 +24,4 @@ code --install-extension SonarSource.sonarlint-vscode code --install-extension tamasfe.even-better-toml code --install-extension streetsidesoftware.code-spell-checker code --install-extension njpwerner.autodocstring +code --install-extension vitest.explorer diff --git a/web/.babelrc b/web/.babelrc deleted file mode 100644 index 2b7bafa5f..000000000 --- a/web/.babelrc +++ /dev/null @@ -1,3 +0,0 @@ -{ - "presets": ["@babel/preset-env", "@babel/preset-react"] -} diff --git a/web/.env.cypress b/web/.env.cypress index 1146fd0fb..b9cd13058 100644 --- a/web/.env.cypress +++ b/web/.env.cypress @@ -1,9 +1,9 @@ BROWSER=none PORT=3030 -REACT_APP_API_BASE_URL=http://localhost:8080/api -REACT_APP_HIDE_DISCLAIMER=false -REACT_APP_KEYCLOAK_AUTH_URL=https://keycloak-auth-url.ca -REACT_APP_SM_LOGOUT_URL=https://sm-logout-url.ca -REACT_APP_KEYCLOAK_REALM=8wl6x4cp -REACT_APP_KEYCLOAK_CLIENT=wps-web -REACT_APP_PMTILES_BUCKET=https://nrs.objectstore.gov.bc.ca/gpdqha/psu/pmtiles/ \ No newline at end of file +VITE_API_BASE_URL=http://localhost:8080/api +VITE_HIDE_DISCLAIMER=false +VITE_KEYCLOAK_AUTH_URL=https://keycloak-auth-url.ca +VITE_SM_LOGOUT_URL=https://sm-logout-url.ca +VITE_KEYCLOAK_REALM=8wl6x4cp +VITE_KEYCLOAK_CLIENT=wps-web +VITE_PMTILES_BUCKET=https://nrs.objectstore.gov.bc.ca/gpdqha/psu/pmtiles/ \ No newline at end of file diff --git a/web/.env.example b/web/.env.example index 9b0049b39..47c0a47a5 100644 --- a/web/.env.example +++ b/web/.env.example @@ -1,13 +1,11 @@ -REACT_APP_API_BASE_URL=http://localhost:8080/api -REACT_APP_RASTER_SERVER_BASE_URL=https://wps-dev-raster-tileserver.apps.silver.devops.gov.bc.ca/v0.0.1 -REACT_APP_HIDE_DISCLAIMER=true -REACT_APP_KEYCLOAK_AUTH_URL=https://dev.oidc.gov.bc.ca/auth -REACT_APP_SM_LOGOUT_URL=https://logontest7.gov.bc.ca/clp-cgi/logoff.cgi?retnow=1&returl= -REACT_APP_KEYCLOAK_REALM=8wl6x4cp -REACT_APP_KEYCLOAK_CLIENT=wps-web -REACT_APP_WF1_AUTH_URL=https://wf1/auth -REACT_APP_MS_TEAMS_SPRINT_REVIEW_URL=http://localhost:3000 -REACT_APP_MIRO_SPRINT_REVIEW_BOARD_URL=http://localhost:3000 -REACT_APP_PMTILES_BUCKET=https://My_S3_Bucket -REACT_APP_SENTRY_DSN=123 -REACT_APP_SENTRY_ENV=development \ No newline at end of file +VITE_API_BASE_URL=http://localhost:8080/api +VITE_HIDE_DISCLAIMER=true +VITE_KEYCLOAK_AUTH_URL=https://dev.loginproxy.gov.bc.ca/auth +VITE_KEYCLOAK_REALM=standard +VITE_KEYCLOAK_CLIENT=wps-web +VITE_MS_TEAMS_SPRINT_REVIEW_URL=http://localhost:3000 +VITE_MIRO_SPRINT_REVIEW_BOARD_URL=http://localhost:3000 +VITE_PMTILES_BUCKET=https://My_S3_Bucket +VITE_SENTRY_DSN=123 +VITE_SENTRY_ENV=development +VITE_MUI_LICENSE_KEY=mui diff --git a/web/.gitignore b/web/.gitignore index f4d405f02..cb55ef735 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -7,4 +7,5 @@ !.yarn/plugins !.yarn/releases !.yarn/sdks -!.yarn/versions \ No newline at end of file +!.yarn/versions +cypress/downloads \ No newline at end of file diff --git a/web/README.md b/web/README.md index 1f59033f0..139a638e4 100644 --- a/web/README.md +++ b/web/README.md @@ -67,9 +67,9 @@ It correctly bundles React in production mode and optimizes the build for the be ## Config -In `openshift/templates/global.config.yaml` there is a template for a global ConfigMap. This template can be applied to the Openshift project from the command line. For example, to apply the global.config template and pass a value for the REACT_APP_KEYCLOAK_REALM parameter, run +In `openshift/templates/global.config.yaml` there is a template for a global ConfigMap. This template can be applied to the Openshift project from the command line. For example, to apply the global.config template and pass a value for the VITE_KEYCLOAK_REALM parameter, run -`oc -n process -f openshift/templates/global.config.yaml -p REACT_APP_KEYCLOAK_REALM= | oc create -f -` +`oc -n process -f openshift/templates/global.config.yaml -p VITE_KEYCLOAK_REALM= | oc create -f -` ## License diff --git a/web/craco.config.js b/web/craco.config.js deleted file mode 100644 index bf6e50408..000000000 --- a/web/craco.config.js +++ /dev/null @@ -1,20 +0,0 @@ -module.exports = { - // create react app wants to run eslint itself. but since our ci is always running it anyway - eslint: { - enable: false - }, - webpack: { - configure: { - module: { - rules: [ - { - test: /\.m?js$/, - resolve: { - fullySpecified: false - } - } - ] - } - } - } -} diff --git a/web/cypress.config.cjs b/web/cypress.config.cjs new file mode 100644 index 000000000..21f995727 --- /dev/null +++ b/web/cypress.config.cjs @@ -0,0 +1,16 @@ +const { defineConfig } = require('cypress'); + +module.exports = defineConfig({ + screenshotOnRunFailure: false, + retries: 1, + video: false, + defaultCommandTimeout: 10000, + e2e: { + setupNodeEvents(on, config) { + require('@cypress/code-coverage/task')(on, config) + return config + + }, + baseUrl: 'http://localhost:3030', + }, +}); \ No newline at end of file diff --git a/web/cypress.config.ts b/web/cypress.config.ts deleted file mode 100644 index 8c1e62d81..000000000 --- a/web/cypress.config.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { defineConfig } from 'cypress' - -export default defineConfig({ - screenshotOnRunFailure: false, - video: false, - e2e: { - // We've imported your old cypress plugins here. - // You may want to clean this up later by importing these. - setupNodeEvents(on, config) { - return require('./cypress/plugins/index.js')(on, config) - }, - baseUrl: 'http://localhost:3030', - }, -}) diff --git a/web/cypress/e2e/fba-map-page.cy.ts b/web/cypress/e2e/fba-map-page.cy.ts index 7a8b990ca..1379ef409 100644 --- a/web/cypress/e2e/fba-map-page.cy.ts +++ b/web/cypress/e2e/fba-map-page.cy.ts @@ -11,7 +11,6 @@ describe('Fire Behaviour Advisory Page', () => { }, { fixture: 'fba/vectors.json' } ).as('getVectors') - cy.intercept('GET', 'api/snow/most-recent-by-date/*', { fixture: 'fba/processedSnow.json' }).as('processedSnow') cy.visit(FIRE_BEHAVIOUR_ADVISORY_ROUTE) }) diff --git a/web/cypress/plugins/index.js b/web/cypress/plugins/index.js deleted file mode 100644 index 423954733..000000000 --- a/web/cypress/plugins/index.js +++ /dev/null @@ -1,27 +0,0 @@ -/// -// *********************************************************** -// This example plugins/index.js can be used to load plugins -// -// You can change the location of this file or turn off loading -// the plugins file with the 'pluginsFile' configuration option. -// -// You can read more here: -// https://on.cypress.io/plugins-guide -// *********************************************************** - -// This function is called when a project is opened or re-opened (e.g. due to -// the project's config changing) - -/** - * @type {Cypress.PluginConfig} - * @param on is used to hook into various events Cypress emits - * @param config is the resolved Cypress config - */ -module.exports = (on, config) => { - require('@cypress/code-coverage/task')(on, config) - // include any other plugin code... - - // It's IMPORTANT to return the config object - // with any changed environment variables - return config -} diff --git a/web/eslint.config.js b/web/eslint.config.cjs similarity index 100% rename from web/eslint.config.js rename to web/eslint.config.cjs diff --git a/web/public/index.html b/web/index.html similarity index 65% rename from web/public/index.html rename to web/index.html index c0bd84bee..612092663 100644 --- a/web/public/index.html +++ b/web/index.html @@ -2,10 +2,6 @@ - - + diff --git a/web/public/manifest.json b/web/manifest.json similarity index 100% rename from web/public/manifest.json rename to web/manifest.json diff --git a/web/mergeCoverage.js b/web/mergeCoverage.cjs similarity index 100% rename from web/mergeCoverage.js rename to web/mergeCoverage.cjs diff --git a/web/package.json b/web/package.json index e9c78ec66..616541d1b 100644 --- a/web/package.json +++ b/web/package.json @@ -5,6 +5,7 @@ "node": ">=20", "npm": ">=10.7.0" }, + "type": "module", "license": "Apache-2.0", "licenses": [ { @@ -19,10 +20,10 @@ "@mui/material": "5.15.20", "@mui/x-data-grid-pro": "^6.0.0", "@mui/x-date-pickers": "^7.0.0", - "@psu/cffdrs_ts": "^0.1.0", + "@psu/cffdrs_ts": "git+https://github.com/cffdrs/cffdrs_ts#b9afdabc89dd4bdf04ccf1e406a4a5d8d552ff51", "@reduxjs/toolkit": "^1.8.0", - "@sentry/cli": "^2.31.2", "@sentry/react": "^8.2.1", + "@sentry/vite-plugin": "^2.22.4", "@types/esri-leaflet": "^3.0.0", "@types/leaflet": "^1.7.0", "@types/lodash": "^4.14.173", @@ -34,13 +35,15 @@ "@types/webpack-env": "^1.15.1", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", - "axios": "1.7.4", - "date-fns": "^3.0.0", + "@vitest/coverage-istanbul": "^2.0.5", + "axios": "1.7.7", + "date-fns": "^4.0.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.1.3", "eslint-plugin-react": "^7.34.4", "esri-leaflet": "3.0.12", "filefy": "^0.1.11", + "jsdom": "^25.0.0", "jwt-decode": "^4.0.0", "keycloak-js": "^25.0.0", "leaflet": "^1.7.1", @@ -49,52 +52,37 @@ "match-sorter": "^6.3.1", "nyc": "^17.0.0", "ol": "10.0.0", - "ol-pmtiles": "^0.5.0", + "ol-pmtiles": "^1.0.2", "prettier": "^3.3.3", - "react": "^18.2.0", - "react-dom": "^18.2.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", "react-redux": "^8.0.0", "react-router-dom": "^6.22.3", "recharts": "^2.1.8", - "typescript": "^5.0.0" + "typescript": "^5.2.2", + "vitest": "^2.0.5", + "whatwg-fetch": "^3.6.20" }, "scripts": { - "start": "CI=true craco start", - "start:cypress": "export $(cat .env.cypress | xargs) && craco --max_old_space_size=4096 -r @cypress/instrument-cra start", - "build": "craco --max_old_space_size=2048 build", - "build:prod": "craco --max_old_space_size=2048 build && yarn sentry:sourcemaps", - "test": "craco test --transformIgnorePatterns \"node_modules/(?!ol)/\"", + "start": "vite", + "start:cypress": "export $(cat .env.cypress | xargs) && yarn start", + "build": "tsc -b && vite build", + "build:prod": "tsc -b && vite build", + "test": "VITE_PMTILES_BUCKET=https://nrs.objectstore.gov.bc.ca/gpdqha/psu/pmtiles/ vitest", "test:ci": "CI=true npm test", "coverage": "npm test -- --coverage --watchAll=false", - "coverage:ci": "CI=true REACT_APP_KEYCLOAK_CLIENT=wps-web npm test -- --coverage --watchAll=false", + "coverage:ci": "CI=true VITE_KEYCLOAK_CLIENT=wps-web VITE_PMTILES_BUCKET=https://nrs.objectstore.gov.bc.ca/gpdqha/psu/pmtiles/ vitest run --coverage", "cy:open": "cypress open", "cy:run": "cypress run --browser chrome --config watchForFileChanges=false", - "cypress": "start-server-and-test start:cypress 3030 cy:open", - "cypress:ci": "start-server-and-test start:cypress 3030 cy:run", - "eject": "react-scripts eject", + "cypress:ci": "start-server-and-test start:cypress http://localhost:3030 'cy:run'", "lint": "eslint", "lint:fix": "eslint --fix", "format": "prettier --write \"**/*.+(js|jsx|json|yml|yaml|css|md)\"", - "finalizeCoverage": "node mergeCoverage.js", - "sentry:sourcemaps": "sentry-cli sourcemaps inject --org bcps-wps --project frontend ./build && sentry-cli sourcemaps upload --org bcps-wps --project frontend ./build" - }, - "browserslist": { - "production": [ - ">0.2%", - "not dead", - "not op_mini all" - ], - "development": [ - "last 1 chrome version", - "last 1 firefox version", - "last 1 safari version" - ] + "finalizeCoverage": "yarn node mergeCoverage.cjs", + "preview": "vite preview" }, "devDependencies": { - "@babel/plugin-proposal-private-property-in-object": "^7.21.11", - "@craco/craco": "^7.1.0", "@cypress/code-coverage": "^3.10.0", - "@cypress/instrument-cra": "^1.4.0", "@eslint/compat": "^1.1.1", "@testing-library/dom": "^10.1.0", "@testing-library/jest-dom": "^6.4.2", @@ -102,22 +90,17 @@ "@testing-library/user-event": "^14.5.2", "@types/jest": "^29.5.12", "@types/recharts": "^1.8.23", - "cypress": "^13.0.0", + "@vitejs/plugin-react": "^4.3.1", + "cypress": "^13.13.3", "eslint": "^9.7.0", "eslint-plugin-react-hooks": "^4.6.2", + "eslint-plugin-react-refresh": "^0.4.7", "globals": "^15.8.0", - "react-scripts": "^5.0.1", "start-server-and-test": "^2.0.0", - "ts-sinon": "^2.0.2" - }, - "jest": { - "collectCoverageFrom": [ - "src/**/*.{ts,tsx}", - "!src/**/*.d.ts", - "!src/index.tsx", - "!src/serviceWorker.ts", - "!src/app/*.{ts,tsx}" - ] + "ts-sinon": "^2.0.2", + "vite": "^5.3.4", + "vite-plugin-istanbul": "^6.0.2", + "vite-plugin-svgr": "^4.2.0" }, "nyc": { "report-dir": "coverage-cypress", diff --git a/web/public/config.js b/web/public/config.js deleted file mode 100644 index 2a2f72c0a..000000000 --- a/web/public/config.js +++ /dev/null @@ -1,20 +0,0 @@ -/** - * This is a placeholder configuration file. In Openshift/dockerfile, this file is replaced by - * a mapping. - * - * In local development values are taken from process.env - * REACT_APP_KEYCLOAK_AUTH_URL is an exception - because of the way keycloak is loaded, it's - * needed before process.env is available. - */ -const config = { - REACT_APP_SM_LOGOUT_URL: 'https://logontest7.gov.bc.ca/clp-cgi/logoff.cgi?retnow=1&returl=', - REACT_APP_KEYCLOAK_REALM: 'standard', - REACT_APP_KEYCLOAK_CLIENT: 'wps-3981', - REACT_APP_KEYCLOAK_AUTH_URL: 'https://dev.loginproxy.gov.bc.ca/auth', - API_BASE_URL: 'http://localhost:8080/api', - RASTER_SERVER_BASE_URL: 'https://wps-dev-raster-tileserver.apps.silver.devops.gov.bc.ca/v0.0.1', - REACT_APP_MS_TEAMS_SPRINT_REVIEW_URL: 'http://localhost:3000', - REACT_APP_MIRO_SPRINT_REVIEW_BOARD_URL: 'http://localhost:3000', - REACT_APP_PMTILES_BUCKET: 'https://My_S3_Bucket', - REACT_APP_MUI_LICENSE_KEY: 'key' -} diff --git a/web/public/favicon.ico b/web/public/favicon.ico deleted file mode 100644 index bcd5dfd67..000000000 Binary files a/web/public/favicon.ico and /dev/null differ diff --git a/web/public/robots.txt b/web/public/robots.txt deleted file mode 100644 index 01b0f9a10..000000000 --- a/web/public/robots.txt +++ /dev/null @@ -1,2 +0,0 @@ -# https://www.robotstxt.org/robotstxt.html -User-agent: * diff --git a/web/public/sitemap.xml b/web/public/sitemap.xml deleted file mode 100644 index fc00eb825..000000000 --- a/web/public/sitemap.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/web/src/api/fbaAPI.ts b/web/src/api/fbaAPI.ts index c97099ab2..191714980 100644 --- a/web/src/api/fbaAPI.ts +++ b/web/src/api/fbaAPI.ts @@ -1,7 +1,11 @@ import axios, { raster } from 'api/axios' -import { RunType } from 'features/fba/pages/FireBehaviourAdvisoryPage' import { DateTime } from 'luxon' +export enum RunType { + FORECAST = 'FORECAST', + ACTUAL = 'ACTUAL' +} + export interface FireCenterStation { code: number name: string @@ -17,7 +21,7 @@ export interface FireCenter { export interface FireShape { fire_shape_id: number mof_fire_zone_name: string - mof_fire_centre_name?: string + mof_fire_centre_name: string area_sqm?: number } @@ -25,9 +29,15 @@ export interface FBAResponse { fire_centers: FireCenter[] } -export interface FireZoneThresholdFuelTypeArea { +export interface AdvisoryCriticalHours { + start_time?: number + end_time?: number +} + +export interface FireZoneFuelStats { fuel_type: FuelType threshold: HfiThreshold + critical_hours: AdvisoryCriticalHours area: number } @@ -56,6 +66,13 @@ export interface FireZoneElevationInfoResponse { hfi_elevation_info: ElevationInfoByThreshold[] } +export interface FireZoneTPIStats { + fire_zone_id: number + valley_bottom?: number + mid_slope?: number + upper_slope?: number +} + export interface FireShapeAreaListResponse { shapes: FireShapeArea[] } @@ -89,6 +106,12 @@ export interface FuelType { description: string } +export interface FireCentreHFIStats { + [fire_centre_name: string]: { + [fire_zone_id: number]: FireZoneFuelStats[] + } +} + export async function getFBAFireCenters(): Promise { const url = '/fba/fire-centers' @@ -129,13 +152,13 @@ export async function getAllRunDates(run_type: RunType, for_date: string): Promi return data } -export async function getHFIThresholdsFuelTypesForZone( +export async function getFireCentreHFIStats( run_type: RunType, for_date: string, run_datetime: string, - zone_id: number -): Promise> { - const url = `fba/hfi-fuels/${run_type.toLowerCase()}/${for_date}/${run_datetime}/${zone_id}` + fire_centre: string +): Promise { + const url = `fba/fire-centre-hfi-stats/${run_type.toLowerCase()}/${for_date}/${run_datetime}/${fire_centre}` const { data } = await axios.get(url) return data } @@ -151,6 +174,28 @@ export async function getFireZoneElevationInfo( return data } +export async function getFireZoneTPIStats( + fire_zone_id: number, + run_type: RunType, + run_datetime: string, + for_date: string +): Promise { + const url = `fba/fire-zone-tpi-stats/${run_type.toLowerCase()}/${run_datetime}/${for_date}/${fire_zone_id}` + const { data } = await axios.get(url) + return data +} + +export async function getFireCentreTPIStats( + fire_centre_name: string, + run_type: RunType, + run_datetime: string, + for_date: string +): Promise> { + const url = `fba/fire-centre-tpi-stats/${run_type.toLowerCase()}/${run_datetime}/${for_date}/${fire_centre_name}` + const { data } = await axios.get(url) + return data +} + export async function getValueAtCoordinate( layer: string, latitude: number, diff --git a/web/src/api/moreCast2API.test.ts b/web/src/api/moreCast2API.test.ts index 5576aa3e9..a7c689714 100644 --- a/web/src/api/moreCast2API.test.ts +++ b/web/src/api/moreCast2API.test.ts @@ -7,6 +7,7 @@ import { import axios from 'api/axios' import { DateTime } from 'luxon' import { MoreCast2ForecastRow } from 'features/moreCast2/interfaces' +import { vi } from 'vitest' describe('moreCast2API', () => { const buildMorecast2Forecast = ( @@ -51,7 +52,7 @@ describe('moreCast2API', () => { expect(res[1].grass_curing).toEqual(0) }) it('should call submit endpoint for forecast submission', async () => { - axios.post = jest.fn().mockResolvedValue({ status: 201 }) + axios.post = vi.fn().mockResolvedValue({ status: 201 }) const res = await submitMoreCastForecastRecords([ buildMorecast2Forecast('1', 1, 'one', DateTime.fromObject({ year: 2021, month: 1, day: 1 })), buildMorecast2Forecast('2', 2, 'two', DateTime.fromObject({ year: 2021, month: 1, day: 1 })) @@ -114,7 +115,7 @@ describe('moreCast2API', () => { ] } } - axios.post = jest.fn().mockResolvedValue(response) + axios.post = vi.fn().mockResolvedValue(response) const res = await fetchWeatherIndeterminates( [1, 2, 3], DateTime.fromObject({ year: 1970, month: 1, day: 1 }), diff --git a/web/src/api/snow.ts b/web/src/api/snow.ts deleted file mode 100644 index be93024a6..000000000 --- a/web/src/api/snow.ts +++ /dev/null @@ -1,42 +0,0 @@ -import axios from 'api/axios' -import { DateTime } from 'luxon' - -enum SnowSource { - VIIRS = 'viirs' -} - -// The shape of processed snow data. -interface ProcessedSnowPayload { - for_date: string - processed_date: string - snow_source: SnowSource -} - -// Response object from our API. -interface ProcessedSnowResponse { - processed_snow: ProcessedSnowPayload -} - -// Client side representation of processed snow data. -export interface ProcessedSnow { - forDate: DateTime - processedDate: DateTime - snowSource: SnowSource -} - -export async function getMostRecentProcessedSnowByDate(forDate: DateTime): Promise { - if (!forDate) { - return null - } - const url = `snow/most-recent-by-date/${forDate.toISODate()}` - const { data } = await axios.get(url, {}) - if (data) { - const processedSnow = data.processed_snow - return { - forDate: DateTime.fromISO(processedSnow.for_date), - processedDate: DateTime.fromISO(processedSnow.processed_date), - snowSource: processedSnow.snow_source - } - } - return data -} diff --git a/web/src/api/stationAPI.test.ts b/web/src/api/stationAPI.test.ts index 54704ce01..231aed55e 100644 --- a/web/src/api/stationAPI.test.ts +++ b/web/src/api/stationAPI.test.ts @@ -1,6 +1,6 @@ import { StationGroupMember, getStationGroupsMembers } from 'api/stationAPI' import axios from 'api/axios' - +import { vi } from 'vitest' describe('stationAPI', () => { it('should return groups from group endpoint', async () => { const mockMemberStation: StationGroupMember = { @@ -11,7 +11,7 @@ describe('stationAPI', () => { station_code: 0, station_status: 'ACTIVE' } - axios.post = jest.fn().mockResolvedValue({ data: { stations: [mockMemberStation] } }) + axios.post = vi.fn().mockResolvedValue({ data: { stations: [mockMemberStation] } }) const res = await getStationGroupsMembers(['1']) expect(res).toHaveLength(1) expect(res[0].id).toBe('1') diff --git a/web/src/app/rootReducer.ts b/web/src/app/rootReducer.ts index 52f49e00f..b73479127 100644 --- a/web/src/app/rootReducer.ts +++ b/web/src/app/rootReducer.ts @@ -13,13 +13,15 @@ import fireCentersSlice from 'commonSlices/fireCentersSlice' import fireShapeAreasSlice from 'features/fba/slices/fireZoneAreasSlice' import valueAtCoordinateSlice from 'features/fba/slices/valueAtCoordinateSlice' import runDatesSlice from 'features/fba/slices/runDatesSlice' -import hfiFuelTypesSlice from 'features/fba/slices/hfiFuelTypesSlice' import fireZoneElevationInfoSlice from 'features/fba/slices/fireZoneElevationInfoSlice' import stationGroupsSlice from 'commonSlices/stationGroupsSlice' import selectedStationGroupsMembersSlice from 'commonSlices/selectedStationGroupMembers' import dataSlice from 'features/moreCast2/slices/dataSlice' +import morecastInputValidSlice from 'features/moreCast2/slices/validInputSlice' import selectedStationsSlice from 'features/moreCast2/slices/selectedStationsSlice' import provincialSummarySlice from 'features/fba/slices/provincialSummarySlice' +import fireCentreTPIStatsSlice from 'features/fba/slices/fireCentreTPIStatsSlice' +import fireCentreHFIFuelStatsSlice from 'features/fba/slices/fireCentreHFIFuelStatsSlice' const rootReducer = combineReducers({ percentileStations: stationReducer, @@ -36,13 +38,15 @@ const rootReducer = combineReducers({ fireShapeAreas: fireShapeAreasSlice, runDates: runDatesSlice, valueAtCoordinate: valueAtCoordinateSlice, - hfiFuelTypes: hfiFuelTypesSlice, + fireCentreHFIFuelStats: fireCentreHFIFuelStatsSlice, fireZoneElevationInfo: fireZoneElevationInfoSlice, + fireCentreTPIStats: fireCentreTPIStatsSlice, stationGroups: stationGroupsSlice, stationGroupsMembers: selectedStationGroupsMembersSlice, weatherIndeterminates: dataSlice, selectedStations: selectedStationsSlice, - provincialSummary: provincialSummarySlice + provincialSummary: provincialSummarySlice, + morecastInputValid: morecastInputValidSlice }) // Infer whatever gets returned from rootReducer and use it as the type of the root state @@ -50,7 +54,6 @@ export type RootState = ReturnType export default rootReducer -/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ export const selectPercentileStations = (state: RootState) => state.percentileStations export const selectHFIDailies = (state: RootState) => state.hfiCalculatorDailies export const selectFireWeatherStations = (state: RootState) => state.fireWeatherStations @@ -65,9 +68,9 @@ export const selectFireCenters = (state: RootState) => state.fireCenters export const selectFireShapeAreas = (state: RootState) => state.fireShapeAreas export const selectRunDates = (state: RootState) => state.runDates export const selectValueAtCoordinate = (state: RootState) => state.valueAtCoordinate -export const selectHFIFuelTypes = (state: RootState) => state.hfiFuelTypes +export const selectFireCentreHFIFuelStats = (state: RootState) => state.fireCentreHFIFuelStats export const selectFireZoneElevationInfo = (state: RootState) => state.fireZoneElevationInfo - +export const selectFireCentreTPIStats = (state: RootState) => state.fireCentreTPIStats export const selectHFIDailiesLoading = (state: RootState): boolean => state.hfiCalculatorDailies.fireCentresLoading export const selectHFICalculatorState = (state: RootState): HFICalculatorState => state.hfiCalculatorDailies export const selectHFIStationsLoading = (state: RootState): boolean => state.hfiStations.loading diff --git a/web/src/app/store.ts b/web/src/app/store.ts index 8789bd51f..6cb060c28 100644 --- a/web/src/app/store.ts +++ b/web/src/app/store.ts @@ -14,16 +14,6 @@ const store = configureStore({ getDefaultMiddleware({ immutableCheck: false, serializableCheck: false }).concat(thunkMiddleware) }) -if (process.env.NODE_ENV === 'development' && module.hot) { - // By using the module.hot API for reloading, we can re-import - // the new version of the root reducer function whenever it's been recompiled, - // and tell the store to use the new version instead. - module.hot.accept('app/rootReducer', () => { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const newRootReducer = require('app/rootReducer').default - store.replaceReducer(newRootReducer) - }) -} export type AppDispatch = typeof store.dispatch diff --git a/web/src/app/theme.ts b/web/src/app/theme.ts index 6e1240c66..f6fbb784f 100644 --- a/web/src/app/theme.ts +++ b/web/src/app/theme.ts @@ -96,8 +96,8 @@ export const DARK_GREY = '#A7A7A7' export const LIGHT_GREY = '#DADADA' export const MEDIUM_GREY = '#B5B5B5' -export const INFO_PANEL_HEADER_BACKGROUND = '#e4e4e5' -export const INFO_PANEL_CONTENT_BACKGROUND = '#f0f0f0' +export const INFO_PANEL_HEADER_BACKGROUND = '#BFBFBF' +export const INFO_PANEL_CONTENT_BACKGROUND = '#EEEEEE' export const TRANSPARENT_COLOUR = '#0000' interface WeatherParams { @@ -134,16 +134,16 @@ export const MORECAST_MODEL_COLORS: ModelDetails = { } export type MoreCastModelColors = typeof MORECAST_MODEL_COLORS -export const modelColorClass = (params: Pick) => { +export const modelColorClass = (params: Pick) => { if (params.field.includes('Actual')) { return '' } const stringKeys = Object.keys(MORECAST_MODEL_COLORS) - const modelKey = stringKeys.find(key => params.field.includes(key.toUpperCase())) + const modelKey = stringKeys.find(key => params.colDef.headerName?.startsWith(key.toUpperCase())) return modelKey ? modelKey : '' } -export const modelHeaderColorClass = (params: Pick) => { +export const modelHeaderColorClass = (params: Pick) => { const modelClass = modelColorClass(params) return modelClass === '' ? modelClass : `${modelClass}-header` } diff --git a/web/src/commonSlices/fireCentersSlice.ts b/web/src/commonSlices/fireCentersSlice.ts index cc71028e6..2e4f6ed00 100644 --- a/web/src/commonSlices/fireCentersSlice.ts +++ b/web/src/commonSlices/fireCentersSlice.ts @@ -4,13 +4,13 @@ import { AppThunk } from 'app/store' import { logError } from 'utils/error' import { FBAResponse, FireCenter, getFBAFireCenters } from 'api/fbaAPI' -interface State { +export interface FireCentresState { loading: boolean error: string | null fireCenters: FireCenter[] } -const initialState: State = { +const initialState: FireCentresState = { loading: false, error: null, fireCenters: [] @@ -20,16 +20,16 @@ const fireCentersSlice = createSlice({ name: 'fireCenters', initialState, reducers: { - getFireCentersStart(state: State) { + getFireCentersStart(state: FireCentresState) { state.error = null state.loading = true state.fireCenters = [] }, - getFireCentersFailed(state: State, action: PayloadAction) { + getFireCentersFailed(state: FireCentresState, action: PayloadAction) { state.error = action.payload state.loading = false }, - getFireCentersSuccess(state: State, action: PayloadAction) { + getFireCentersSuccess(state: FireCentresState, action: PayloadAction) { state.error = null state.fireCenters = action.payload.fire_centers state.loading = false diff --git a/web/src/commonSlices/selectedStationGroupMembers.ts b/web/src/commonSlices/selectedStationGroupMembers.ts index e7b09190c..a7c439b8a 100644 --- a/web/src/commonSlices/selectedStationGroupMembers.ts +++ b/web/src/commonSlices/selectedStationGroupMembers.ts @@ -4,13 +4,13 @@ import { AppThunk } from 'app/store' import { logError } from 'utils/error' import { getStationGroupsMembers, StationGroupMember } from 'api/stationAPI' -interface State { +export interface SelectedStationGroupState { loading: boolean error: string | null members: StationGroupMember[] } -export const initialState: State = { +export const initialState: SelectedStationGroupState = { loading: false, error: null, members: [] @@ -20,15 +20,15 @@ const selectedStationGroupsMembersSlice = createSlice({ name: 'selectedStationGroupsMembers', initialState, reducers: { - getStationGroupsMembersStart(state: State) { + getStationGroupsMembersStart(state: SelectedStationGroupState) { state.error = null state.loading = true }, - getStationGroupsMembersFailed(state: State, action: PayloadAction) { + getStationGroupsMembersFailed(state: SelectedStationGroupState, action: PayloadAction) { state.error = action.payload state.loading = false }, - getStationGroupsMembersSuccess(state: State, action: PayloadAction) { + getStationGroupsMembersSuccess(state: SelectedStationGroupState, action: PayloadAction) { state.error = null state.members = action.payload state.loading = false diff --git a/web/src/commonSlices/stationGroupsSlice.ts b/web/src/commonSlices/stationGroupsSlice.ts index 7e811f4e2..4c8159daa 100644 --- a/web/src/commonSlices/stationGroupsSlice.ts +++ b/web/src/commonSlices/stationGroupsSlice.ts @@ -4,13 +4,13 @@ import { AppThunk } from 'app/store' import { logError } from 'utils/error' import { getStationGroups, StationGroup } from 'api/stationAPI' -interface State { +export interface StationGroupsState { loading: boolean error: string | null groups: StationGroup[] } -const initialState: State = { +const initialState: StationGroupsState = { loading: false, error: null, groups: [] @@ -20,15 +20,15 @@ const stationGroupsSlice = createSlice({ name: 'stationGroups', initialState, reducers: { - getStationGroupsStart(state: State) { + getStationGroupsStart(state: StationGroupsState) { state.error = null state.loading = true }, - getStationGroupsFailed(state: State, action: PayloadAction) { + getStationGroupsFailed(state: StationGroupsState, action: PayloadAction) { state.error = action.payload state.loading = false }, - getStationGroupsSuccess(state: State, action: PayloadAction) { + getStationGroupsSuccess(state: StationGroupsState, action: PayloadAction) { state.error = null state.groups = action.payload state.loading = false diff --git a/web/src/components/FireCenterDropdown.tsx b/web/src/components/FireCenterDropdown.tsx index 8dd43655e..a15ef4314 100644 --- a/web/src/components/FireCenterDropdown.tsx +++ b/web/src/components/FireCenterDropdown.tsx @@ -1,6 +1,6 @@ import { TextField, Autocomplete } from '@mui/material' import { FireCenter, FireShape } from 'api/fbaAPI' -import { isEqual } from 'lodash' +import { isEqual, isNull } from 'lodash' import React from 'react' interface FireCenterDropdownProps { @@ -19,6 +19,9 @@ const FireCenterDropdown = (props: FireCenterDropdownProps) => { props.setSelectedFireCenter(value) props.setZoomSource('fireCenter') } + if (isNull(value)) { + localStorage.removeItem('preferredFireCenter') + } } return ( diff --git a/web/src/components/dateRangePicker/dateRangePickerWrapper.test.tsx b/web/src/components/dateRangePicker/dateRangePickerWrapper.test.tsx index df9ea660c..1fc92672a 100644 --- a/web/src/components/dateRangePicker/dateRangePickerWrapper.test.tsx +++ b/web/src/components/dateRangePicker/dateRangePickerWrapper.test.tsx @@ -1,7 +1,7 @@ import { render } from '@testing-library/react' import DateRangePickerWrapper from 'components/dateRangePicker/DateRangePickerWrapper' import { DateRange } from 'components/dateRangePicker/types' -import React from 'react' +import { vi } from 'vitest' const setup = (open: boolean, toggleMock: () => void, initialDateRange: DateRange, onChangeMock: () => void) => { const { getByTestId, getByRole } = render( @@ -17,10 +17,10 @@ const setup = (open: boolean, toggleMock: () => void, initialDateRange: DateRang describe('DateRangePickerWrapper', () => { const startDate = new Date('2021/2/21') const endDate = new Date('2021/2/25') - const toggleMock = jest.fn((): void => { + const toggleMock = vi.fn((): void => { /** no op */ }) - const onChangeMock = jest.fn((): void => { + const onChangeMock = vi.fn((): void => { /** no op */ }) diff --git a/web/src/components/dateRangePicker/menu.test.tsx b/web/src/components/dateRangePicker/menu.test.tsx index 951535bd6..eb4a8e979 100644 --- a/web/src/components/dateRangePicker/menu.test.tsx +++ b/web/src/components/dateRangePicker/menu.test.tsx @@ -2,7 +2,7 @@ import { render } from '@testing-library/react' import { format } from 'date-fns' import Menu from 'components/dateRangePicker/Menu' import { DateRange, NavigationAction, Setter } from 'components/dateRangePicker/types' -import React from 'react' +import { vi } from 'vitest' /* eslint-disable @typescript-eslint/no-unused-vars */ const setup = ( @@ -49,30 +49,30 @@ describe('Menu', () => { const firstMonth = new Date('2021/2/1') const secondMonth = new Date('2021/3/1') - const inHoverRangeMock = jest.fn((date: Date): boolean => { + const inHoverRangeMock = vi.fn((date: Date): boolean => { return false }) - const onDayClickMock = jest.fn((date: Date): void => { + const onDayClickMock = vi.fn((date: Date): void => { /** no op */ }) - const onDayHoverMock = jest.fn((date: Date): void => { + const onDayHoverMock = vi.fn((date: Date): void => { /** no op */ }) - const onMonthNavigateMock = jest.fn((marker: symbol, action: NavigationAction): void => { + const onMonthNavigateMock = vi.fn((marker: symbol, action: NavigationAction): void => { /** no op */ }) - const setFirstMonthMock = jest.fn((date: Date): void => { + const setFirstMonthMock = vi.fn((date: Date): void => { /** no op */ }) - const setSecondMonthMock = jest.fn((date: Date): void => { + const setSecondMonthMock = vi.fn((date: Date): void => { /** no op */ }) - const resetDateRangeMock = jest.fn((): void => { + const resetDateRangeMock = vi.fn((): void => { /** no op */ }) - const toggleMock = jest.fn((): void => { + const toggleMock = vi.fn((): void => { /** no op */ }) diff --git a/web/src/components/dateRangePicker/month.test.tsx b/web/src/components/dateRangePicker/month.test.tsx index aaa6b0325..596f3c273 100644 --- a/web/src/components/dateRangePicker/month.test.tsx +++ b/web/src/components/dateRangePicker/month.test.tsx @@ -2,8 +2,7 @@ import { fireEvent, render, waitFor, within } from '@testing-library/react' import { MARKERS } from 'components/dateRangePicker/DateRangePicker' import Month from 'components/dateRangePicker/Month' import { DateRange, NavigationAction } from 'components/dateRangePicker/types' -import React from 'react' -/* eslint-disable @typescript-eslint/no-unused-vars */ +import { vi } from 'vitest' const setup = ( value: Date, @@ -41,19 +40,19 @@ const setup = ( describe('Month', () => { const startDate = new Date('2021/2/21') const endDate = new Date('2021/2/25') - const setValueMock = jest.fn((date: Date): void => { + const setValueMock = vi.fn((date: Date): void => { /** no op */ }) - const inHoverRangeMock = jest.fn((date: Date): boolean => { + const inHoverRangeMock = vi.fn((date: Date): boolean => { return false }) - const onDayClickMock = jest.fn((date: Date): void => { + const onDayClickMock = vi.fn((date: Date): void => { /** no op */ }) - const onDayHoverMock = jest.fn((date: Date): void => { + const onDayHoverMock = vi.fn((date: Date): void => { /** no op */ }) - const onMonthNavigateMock = jest.fn((marker: symbol, action: NavigationAction): void => { + const onMonthNavigateMock = vi.fn((marker: symbol, action: NavigationAction): void => { /** no op */ }) diff --git a/web/src/components/fireTable.test.tsx b/web/src/components/fireTable.test.tsx index e507ddaed..e5a0d0f6e 100644 --- a/web/src/components/fireTable.test.tsx +++ b/web/src/components/fireTable.test.tsx @@ -1,7 +1,6 @@ import { TableHead, TableRow } from '@mui/material' import { render } from '@testing-library/react' import FireTable from 'components/FireTable' -import React from 'react' describe('FireTable', () => { it('should render the table', () => { diff --git a/web/src/components/hfiCell.test.tsx b/web/src/components/hfiCell.test.tsx index 469b2e749..94ec2fb14 100644 --- a/web/src/components/hfiCell.test.tsx +++ b/web/src/components/hfiCell.test.tsx @@ -1,7 +1,6 @@ import { TableContainer, Table, TableBody, TableRow } from '@mui/material' import { render } from '@testing-library/react' import HFICell from 'components/HFICell' -import React from 'react' describe('HFICell', () => { it('should render without color when HFI is undefined', () => { diff --git a/web/src/components/stickyCell.test.tsx b/web/src/components/stickyCell.test.tsx index e607ff5e3..528b483b3 100644 --- a/web/src/components/stickyCell.test.tsx +++ b/web/src/components/stickyCell.test.tsx @@ -2,7 +2,6 @@ import { TableContainer, Table, TableBody, TableRow } from '@mui/material' import { render, screen } from '@testing-library/react' import StickyCell from 'components/StickyCell' import { theme } from 'app/theme' -import React from 'react' describe('StickyCell', () => { it('should have a sticky position, left and zIndex set', () => { diff --git a/web/src/features/auth/keycloak.ts b/web/src/features/auth/keycloak.ts index 666998acb..0321a120f 100644 --- a/web/src/features/auth/keycloak.ts +++ b/web/src/features/auth/keycloak.ts @@ -4,7 +4,7 @@ import Keycloak, { KeycloakInitOptions } from 'keycloak-js' export const kcInitOptions: KeycloakInitOptions = { onLoad: 'login-required', checkLoginIframe: false, - enableLogging: process.env.NODE_ENV !== 'production', + enableLogging: import.meta.env.MODE !== 'production', pkceMethod: 'S256' } diff --git a/web/src/features/auth/slices/authenticationSlice.test.ts b/web/src/features/auth/slices/authenticationSlice.test.ts index 0bb7cebe0..edf916dc5 100644 --- a/web/src/features/auth/slices/authenticationSlice.test.ts +++ b/web/src/features/auth/slices/authenticationSlice.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import authReducer, { decodeRoles, initialState, diff --git a/web/src/features/cHaines/slices/cHainesModelRunsSlice.tsx b/web/src/features/cHaines/slices/cHainesModelRunsSlice.tsx index 558bfcfd0..840184724 100644 --- a/web/src/features/cHaines/slices/cHainesModelRunsSlice.tsx +++ b/web/src/features/cHaines/slices/cHainesModelRunsSlice.tsx @@ -4,7 +4,7 @@ import { AppThunk } from 'app/store' import { logError } from 'utils/error' import { FeatureCollection } from 'geojson' -interface State { +export interface CHainesModelState { loading: boolean error: string | null model_runs: ModelRun[] @@ -22,7 +22,7 @@ interface GeoJSONContext { result: FeatureCollection } -const initialState: State = { +const initialState: CHainesModelState = { loading: false, error: null, model_runs: [], @@ -37,11 +37,11 @@ const cHainesModelRunsSlice = createSlice({ name: 'c-haines-model-runs', initialState: initialState, reducers: { - getModelRunsStart(state: State) { + getModelRunsStart(state: CHainesModelState) { state.loading = true state.selected_prediction_timestamp = '' }, - getModelRunsSuccess(state: State, action: PayloadAction) { + getModelRunsSuccess(state: CHainesModelState, action: PayloadAction) { state.model_runs = action.payload.model_runs if (state.model_runs.length > 0) { state.selected_model_abbreviation = state.model_runs[0].model.abbrev @@ -56,11 +56,11 @@ const cHainesModelRunsSlice = createSlice({ state.loading = false state.error = null }, - getModelRunsFailed(state: State, action: PayloadAction) { + getModelRunsFailed(state: CHainesModelState, action: PayloadAction) { state.loading = false state.error = action.payload }, - setSelectedModel(state: State, action: PayloadAction) { + setSelectedModel(state: CHainesModelState, action: PayloadAction) { state.selected_model_abbreviation = action.payload const model_run = state.model_runs.find(instance => instance.model.abbrev === action.payload) if (model_run) { @@ -72,7 +72,7 @@ const cHainesModelRunsSlice = createSlice({ state.selected_model_run_timestamp = '' } }, - setSelectedModelRun(state: State, action: PayloadAction) { + setSelectedModelRun(state: CHainesModelState, action: PayloadAction) { state.selected_model_run_timestamp = action.payload const model_run = state.model_runs.find( instance => @@ -84,13 +84,13 @@ const cHainesModelRunsSlice = createSlice({ state.selected_prediction_timestamp = '' } }, - setSelectedPrediction(state: State, action: PayloadAction) { + setSelectedPrediction(state: CHainesModelState, action: PayloadAction) { state.selected_prediction_timestamp = action.payload }, - getPredictionStart(state: State) { + getPredictionStart(state: CHainesModelState) { state.loading = true }, - getPredictionSuccess(state: State, action: PayloadAction) { + getPredictionSuccess(state: CHainesModelState, action: PayloadAction) { if (!(action.payload.model in state.model_run_predictions)) { state.model_run_predictions[action.payload.model] = {} } @@ -101,7 +101,7 @@ const cHainesModelRunsSlice = createSlice({ action.payload.prediction_timestamp ] = action.payload.result }, - getPredictionFailed(state: State, action: PayloadAction) { + getPredictionFailed(state: CHainesModelState, action: PayloadAction) { state.loading = false state.error = action.payload } diff --git a/web/src/features/cHaines/slices/cHainesPredictionsSlice.tsx b/web/src/features/cHaines/slices/cHainesPredictionsSlice.tsx index 3b5cff60d..c8c1bd1bd 100644 --- a/web/src/features/cHaines/slices/cHainesPredictionsSlice.tsx +++ b/web/src/features/cHaines/slices/cHainesPredictionsSlice.tsx @@ -4,7 +4,7 @@ import { AppThunk } from 'app/store' import { logError } from 'utils/error' import { FeatureCollection } from 'geojson' -interface State { +export interface CHainesPredictionState { loading: boolean error: string | null model_runs: Record> @@ -16,7 +16,7 @@ interface GeoJSONContext { result: FeatureCollection } -const initialState: State = { +const initialState: CHainesPredictionState = { loading: false, error: null, model_runs: {} @@ -26,13 +26,13 @@ const cHainesPredictionsSlice = createSlice({ name: 'c-haines-predictions', initialState: initialState, reducers: { - getPredictionStart(state: State) { + getPredictionStart(state: CHainesPredictionState) { state.loading = true }, - getPredictionSuccess(state: State, action: PayloadAction) { + getPredictionSuccess(state: CHainesPredictionState, action: PayloadAction) { state.model_runs[action.payload.model_run_timestamp][action.payload.prediction_timestamp] = action.payload.result }, - getPredictionFailed(state: State, action: PayloadAction) { + getPredictionFailed(state: CHainesPredictionState, action: PayloadAction) { state.loading = false state.error = action.payload } diff --git a/web/src/features/fba/calculateZoneStatus.ts b/web/src/features/fba/calculateZoneStatus.ts new file mode 100644 index 000000000..fd0ae9c7e --- /dev/null +++ b/web/src/features/fba/calculateZoneStatus.ts @@ -0,0 +1,53 @@ +import { FireShapeAreaDetail } from '@/api/fbaAPI' +import { ADVISORY_ORANGE_FILL, ADVISORY_RED_FILL } from '@/features/fba/components/map/featureStylers' +import { AdvisoryStatus } from '@/utils/constants' +import { isUndefined } from 'lodash' + +export const calculateStatusColour = ( + details: FireShapeAreaDetail[], + advisoryThreshold: number, + defaultColour: string +) => { + let status = defaultColour + + if (details.length === 0) { + return status + } + + const advisoryThresholdDetail = details.find(detail => detail.threshold == 1) + const warningThresholdDetail = details.find(detail => detail.threshold == 2) + const advisoryPercentage = advisoryThresholdDetail?.elevated_hfi_percentage ?? 0 + const warningPercentage = warningThresholdDetail?.elevated_hfi_percentage ?? 0 + + if (advisoryPercentage + warningPercentage > advisoryThreshold) { + status = ADVISORY_ORANGE_FILL + } + + if (warningPercentage > advisoryThreshold) { + status = ADVISORY_RED_FILL + } + + return status +} + +export const calculateStatusText = ( + details: FireShapeAreaDetail[], + advisoryThreshold: number +): AdvisoryStatus | undefined => { + if (isUndefined(details) || details.length === 0) { + return undefined + } + + const advisoryThresholdDetail = details.find(detail => detail.threshold == 1) + const warningThresholdDetail = details.find(detail => detail.threshold == 2) + const advisoryPercentage = advisoryThresholdDetail?.elevated_hfi_percentage ?? 0 + const warningPercentage = warningThresholdDetail?.elevated_hfi_percentage ?? 0 + + if (warningPercentage > advisoryThreshold) { + return AdvisoryStatus.WARNING + } + + if (advisoryPercentage + warningPercentage > advisoryThreshold) { + return AdvisoryStatus.ADVISORY + } +} diff --git a/web/src/features/fba/components/AboutDataPopover.tsx b/web/src/features/fba/components/AboutDataPopover.tsx new file mode 100644 index 000000000..7f4861c8a --- /dev/null +++ b/web/src/features/fba/components/AboutDataPopover.tsx @@ -0,0 +1,82 @@ +import * as React from 'react' +import Popover from '@mui/material/Popover' +import Typography from '@mui/material/Typography' +import InfoIcon from '@mui/icons-material/Info' +import { INFO_PANEL_CONTENT_BACKGROUND, theme } from 'app/theme' + +interface AboutDataProps { + advisoryThreshold: number +} + +const AboutDataPopover = ({ advisoryThreshold }: AboutDataProps) => { + const [anchorEl, setAnchorEl] = React.useState(null) + + const handlePopoverOpen = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget) + } + + const handlePopoverClose = () => { + setAnchorEl(null) + } + + const open = Boolean(anchorEl) + + return ( +
+ + + About this data + + + + About This Data + +
+
    +
  • + A Fire Zone is under a Fire Behaviour Advisory if greater than {advisoryThreshold}% of the combustible + land (trees, grass, slash) is forecast to have a Head Fire Intensity between 4,000 and 10,000 kW/m. +
  • +
    +
  • + A Fire Zone is under a Fire Behaviour Warning if greater than {advisoryThreshold}% of the combustible land + is forecast to have a Head Fire Intensity greater than 10,000 kW/m. +
  • +
    +
  • + The fuel types chosen for the text bulletin are the three most common fuel types in a zone that meet or + exceed the Fire Behaviour Advisory threshold of 4,000 kW/m. +
  • +
+
+
+
+ ) +} + +export default AboutDataPopover diff --git a/web/src/features/fba/components/ActualForecastControl.tsx b/web/src/features/fba/components/ActualForecastControl.tsx new file mode 100644 index 000000000..66a6c7319 --- /dev/null +++ b/web/src/features/fba/components/ActualForecastControl.tsx @@ -0,0 +1,61 @@ +import { FormControl, FormControlLabel, FormLabel, Radio, RadioGroup } from '@mui/material' +import React from 'react' +import { isNull } from 'lodash' +import { theme } from 'app/theme' +import { RunType } from '@/api/fbaAPI' + +export interface ActualForecastControlProps { + runType: RunType + setRunType: React.Dispatch> +} +const ActualForecastControl = ({ runType, setRunType }: ActualForecastControlProps) => { + const changeHandler = (_: React.ChangeEvent<{}>, value: string) => { + if (!isNull(value)) { + setRunType(value as RunType) + } + } + return ( + + + Time Frame + + + } /> + } + label="Actual" + /> + } /> + } + label="Forecast" + /> + + + ) +} + +export default React.memo(ActualForecastControl) diff --git a/web/src/features/fba/components/AdvisoryMetadata.tsx b/web/src/features/fba/components/AdvisoryMetadata.tsx deleted file mode 100644 index c3f2178bd..000000000 --- a/web/src/features/fba/components/AdvisoryMetadata.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { Autocomplete, TextField } from '@mui/material' -import React from 'react' -import { RunType } from 'features/fba/pages/FireBehaviourAdvisoryPage' -import { isNull } from 'lodash' - -export interface AdvisoryMetadataProps { - testId?: string - runType: string - setRunType: React.Dispatch> -} -const AdvisoryMetadata = ({ runType, setRunType }: AdvisoryMetadataProps) => { - const changeHandler = (_: React.ChangeEvent<{}>, value: any | null) => { - if (!isNull(value)) { - setRunType(value) - } - } - return ( - } - /> - ) -} - -export default React.memo(AdvisoryMetadata) diff --git a/web/src/features/fba/components/aboutDataPopover.test.tsx b/web/src/features/fba/components/aboutDataPopover.test.tsx new file mode 100644 index 000000000..c504b2881 --- /dev/null +++ b/web/src/features/fba/components/aboutDataPopover.test.tsx @@ -0,0 +1,39 @@ +import React from 'react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import AboutDataPopover from 'features/fba/components/AboutDataPopover' + +const ADVISORY_THRESHOLD = 20 + +describe('AboutDataPopover', () => { + it('should render the About Data Popover', () => { + const { getByTestId } = render() + const aboutData = getByTestId('about-data-popover') + expect(aboutData).toBeInTheDocument() + }) + it('should open the popover when clicked', () => { + render() + + fireEvent.click(screen.getByTestId('about-data-trigger')) + + expect(screen.getByTestId('about-data-content')).toBeVisible() + expect(screen.getByTestId('about-data-content')).toHaveTextContent(`${ADVISORY_THRESHOLD}%`) + }) + it('should close the popover when clicking outside of it', async () => { + render() + fireEvent.click(screen.getByTestId('about-data-trigger')) + expect(screen.getByTestId('about-data-content')).toBeVisible() + + fireEvent.click(document.body) + await waitFor(() => { + expect(screen.queryByTestId('popover-title')).not.toBeInTheDocument() + }) + }) + it('should contain the advisory threshold as a percent', () => { + render() + + fireEvent.click(screen.getByTestId('about-data-trigger')) + + expect(screen.getByTestId('about-data-content')).toBeVisible() + expect(screen.getByTestId('about-data-content')).toHaveTextContent(`${ADVISORY_THRESHOLD}%`) + }) +}) diff --git a/web/src/features/fba/components/actualForecastControl.test.tsx b/web/src/features/fba/components/actualForecastControl.test.tsx new file mode 100644 index 000000000..b853cb1ce --- /dev/null +++ b/web/src/features/fba/components/actualForecastControl.test.tsx @@ -0,0 +1,29 @@ +import React from 'react' +import { render, fireEvent } from '@testing-library/react' +import ActualForecastControl from './ActualForecastControl' +import { vi } from 'vitest' +import { RunType } from '@/api/fbaAPI' + +describe('ActualForecastControl', () => { + const mockSetRunType = vi.fn() + + it('should render the radio button selector with the correct default', () => { + const { getByTestId } = render() + const forecastButton = getByTestId('forecast-radio') + expect(forecastButton).toBeChecked() + }) + + it('should call setRunType with the correct value when a radio button is selected', () => { + const { getByTestId, rerender } = render( + + ) + fireEvent.click(getByTestId('actual-radio')) + expect(mockSetRunType).toHaveBeenCalledWith(RunType.ACTUAL) + expect(mockSetRunType).toHaveBeenCalledTimes(1) + + rerender() + fireEvent.click(getByTestId('forecast-radio')) + expect(mockSetRunType).toHaveBeenCalledTimes(2) + expect(mockSetRunType).toHaveBeenCalledWith(RunType.FORECAST) + }) +}) diff --git a/web/src/features/fba/components/infoPanel/AdvisoryReport.tsx b/web/src/features/fba/components/infoPanel/AdvisoryReport.tsx index 4ef800252..70186a8ca 100644 --- a/web/src/features/fba/components/infoPanel/AdvisoryReport.tsx +++ b/web/src/features/fba/components/infoPanel/AdvisoryReport.tsx @@ -1,5 +1,5 @@ import { Box, Tabs, Tab, Grid } from '@mui/material' -import { FireCenter } from 'api/fbaAPI' +import { FireCenter, FireShape } from 'api/fbaAPI' import { INFO_PANEL_CONTENT_BACKGROUND } from 'app/theme' import AdvisoryText from 'features/fba/components/infoPanel/AdvisoryText' import InfoAccordion from 'features/fba/components/infoPanel/InfoAccordion' @@ -11,6 +11,7 @@ interface AdvisoryReportProps { forDate: DateTime advisoryThreshold: number selectedFireCenter?: FireCenter + selectedFireZoneUnit?: FireShape } interface TabPanelProps { @@ -27,7 +28,7 @@ const TabPanel = ({ children, index, value }: TabPanelProps) => { ) } -const AdvisoryReport = ({ issueDate, forDate, advisoryThreshold, selectedFireCenter }: AdvisoryReportProps) => { +const AdvisoryReport = ({ issueDate, forDate, advisoryThreshold, selectedFireCenter, selectedFireZoneUnit }: AdvisoryReportProps) => { const [tabNumber, setTabNumber] = useState(0) const handleTabChange = (event: React.SyntheticEvent, newValue: number) => { @@ -54,6 +55,7 @@ const AdvisoryReport = ({ issueDate, forDate, advisoryThreshold, selectedFireCen forDate={forDate} advisoryThreshold={advisoryThreshold} selectedFireCenter={selectedFireCenter} + selectedFireZoneUnit={selectedFireZoneUnit} > diff --git a/web/src/features/fba/components/infoPanel/AdvisoryText.tsx b/web/src/features/fba/components/infoPanel/AdvisoryText.tsx index 1368db89e..027d4a53e 100644 --- a/web/src/features/fba/components/infoPanel/AdvisoryText.tsx +++ b/web/src/features/fba/components/infoPanel/AdvisoryText.tsx @@ -1,53 +1,96 @@ import { Box, Typography } from '@mui/material' -import { FireCenter, FireShapeAreaDetail } from 'api/fbaAPI' +import { FireCenter, FireShape, FireZoneFuelStats } from 'api/fbaAPI' import { DateTime } from 'luxon' -import React from 'react' +import React, { useEffect, useState } from 'react' import { useSelector } from 'react-redux' import { selectProvincialSummary } from 'features/fba/slices/provincialSummarySlice' +import { selectFireCentreHFIFuelStats } from '@/app/rootReducer' import { AdvisoryStatus } from 'utils/constants' -import { groupBy } from 'lodash' +import { isEmpty, isNil, isUndefined, take } from 'lodash' +import { calculateStatusText } from '@/features/fba/calculateZoneStatus' interface AdvisoryTextProps { issueDate: DateTime | null forDate: DateTime selectedFireCenter?: FireCenter advisoryThreshold: number + selectedFireZoneUnit?: FireShape } -const AdvisoryText = ({ issueDate, forDate, advisoryThreshold, selectedFireCenter }: AdvisoryTextProps) => { +const AdvisoryText = ({ + issueDate, + forDate, + selectedFireCenter, + advisoryThreshold, + selectedFireZoneUnit +}: AdvisoryTextProps) => { const provincialSummary = useSelector(selectProvincialSummary) + const { fireCentreHFIFuelStats } = useSelector(selectFireCentreHFIFuelStats) + const [selectedFireZoneUnitTopFuels, setSelectedFireZoneUnitTopFuels] = useState([]) - const calculateStatus = (details: FireShapeAreaDetail[]): AdvisoryStatus | undefined => { - const advisoryThresholdDetail = details.find(detail => detail.threshold == 1) - const warningThresholdDetail = details.find(detail => detail.threshold == 2) - const advisoryPercentage = advisoryThresholdDetail?.elevated_hfi_percentage ?? 0 - const warningPercentage = warningThresholdDetail?.elevated_hfi_percentage ?? 0 + const [minStartTime, setMinStartTime] = useState(undefined) + const [maxEndTime, setMaxEndTime] = useState(undefined) - if (warningPercentage > advisoryThreshold) { - return AdvisoryStatus.WARNING + const sortByArea = (a: FireZoneFuelStats, b: FireZoneFuelStats) => { + if (a.area > b.area) { + return -1 } - - if (advisoryPercentage + warningPercentage > advisoryThreshold) { - return AdvisoryStatus.ADVISORY + if (a.area < b.area) { + return 1 } + return 0 } - const getZoneStatusMap = (fireZoneUnitDetails: Record) => { - const zoneStatusMap: Record = { - [AdvisoryStatus.ADVISORY]: [], - [AdvisoryStatus.WARNING]: [] + useEffect(() => { + if ( + isUndefined(fireCentreHFIFuelStats) || + isEmpty(fireCentreHFIFuelStats) || + isUndefined(selectedFireCenter) || + isUndefined(selectedFireZoneUnit) + ) { + setSelectedFireZoneUnitTopFuels([]) + setMinStartTime(undefined) + setMaxEndTime(undefined) + return } + const allZoneUnitFuelStats = fireCentreHFIFuelStats?.[selectedFireCenter.name] + const selectedZoneUnitFuelStats = allZoneUnitFuelStats?.[selectedFireZoneUnit.fire_shape_id] ?? [] + const sortedFuelStats = [...selectedZoneUnitFuelStats].sort(sortByArea) + let topFuels = take(sortedFuelStats, 3) + setSelectedFireZoneUnitTopFuels(topFuels) + }, [fireCentreHFIFuelStats]) - for (const zoneUnit in fireZoneUnitDetails) { - const fireShapeAreaDetails: FireShapeAreaDetail[] = fireZoneUnitDetails[zoneUnit] - const status = calculateStatus(fireShapeAreaDetails) - - if (status) { - zoneStatusMap[status].push(zoneUnit) + useEffect(() => { + let startTime: number | undefined = undefined + let endTime: number | undefined = undefined + for (const fuel of selectedFireZoneUnitTopFuels) { + if (!isUndefined(fuel.critical_hours.start_time)) { + if (isUndefined(startTime) || fuel.critical_hours.start_time < startTime) { + startTime = fuel.critical_hours.start_time + } + } + if (!isUndefined(fuel.critical_hours.end_time)) { + if (isUndefined(endTime) || fuel.critical_hours.end_time > endTime) { + endTime = fuel.critical_hours.end_time + } } } + setMinStartTime(startTime) + setMaxEndTime(endTime) + }, [selectedFireZoneUnitTopFuels]) - return zoneStatusMap + const getTopFuelsString = () => { + const topFuelCodes = selectedFireZoneUnitTopFuels.map(topFuel => topFuel.fuel_type.fuel_type_code) + switch (topFuelCodes.length) { + case 1: + return `fuel type ${topFuelCodes[0]}` + case 2: + return `fuel types ${topFuelCodes[0]} and ${topFuelCodes[1]}` + case 3: + return `fuel types ${topFuelCodes[0]}, ${topFuelCodes[1]} and ${topFuelCodes[2]}` + default: + return '' + } } const renderDefaultMessage = () => { @@ -56,19 +99,26 @@ const AdvisoryText = ({ issueDate, forDate, advisoryThreshold, selectedFireCente {issueDate?.isValid ? ( Please select a fire center. ) : ( - No advisory data available for today. + No advisory data available for the selected date. )}{' '} ) } const renderAdvisoryText = () => { - const forToday = issueDate?.toISODate() === forDate.toISODate() + const forToday = forDate.toISODate() === DateTime.now().toISODate() const displayForDate = forToday ? 'today' : forDate.toLocaleString({ month: 'short', day: 'numeric' }) const fireCenterSummary = provincialSummary[selectedFireCenter!.name] - const groupedFireZoneUnitInfos = groupBy(fireCenterSummary, 'fire_shape_name') - const zoneStatusMap = getZoneStatusMap(groupedFireZoneUnitInfos) + const fireZoneUnitInfos = fireCenterSummary?.filter(fc => fc.fire_shape_id === selectedFireZoneUnit?.fire_shape_id) + const zoneStatus = calculateStatusText(fireZoneUnitInfos, advisoryThreshold) + const hasCriticalHours = !isNil(minStartTime) && !isNil(maxEndTime) && selectFireCentreHFIFuelStats.length > 0 + let message = '' + if (hasCriticalHours) { + message = `There is a fire behaviour ${zoneStatus} in effect for ${selectedFireZoneUnit?.mof_fire_zone_name} between ${minStartTime}:00 and ${maxEndTime}:00 for ${getTopFuelsString()}.` + } else { + message = `There is a fire behaviour ${zoneStatus} in effect for ${selectedFireZoneUnit?.mof_fire_zone_name}.` + } return ( <> @@ -77,33 +127,20 @@ const AdvisoryText = ({ issueDate, forDate, advisoryThreshold, selectedFireCente sx={{ whiteSpace: 'pre-wrap' }} >{`Issued on ${issueDate?.toLocaleString(DateTime.DATE_MED)} for ${displayForDate}.\n\n`} )} - {zoneStatusMap[AdvisoryStatus.WARNING].length > 0 && ( - <> - {`There is a fire behaviour ${AdvisoryStatus.WARNING} in effect in the following areas:`} -
    - {zoneStatusMap[AdvisoryStatus.WARNING].map(zone => ( -
  • - {zone} -
  • - ))} -
- + {!isUndefined(zoneStatus) && zoneStatus === AdvisoryStatus.ADVISORY && ( + {message} + )} + {!isUndefined(zoneStatus) && zoneStatus === AdvisoryStatus.WARNING && ( + {message} )} - {zoneStatusMap[AdvisoryStatus.ADVISORY].length > 0 && ( - <> - {`There is a fire behaviour ${AdvisoryStatus.ADVISORY} in effect in the following areas:`} -
    - {zoneStatusMap[AdvisoryStatus.ADVISORY].map(zone => ( -
  • - {zone} -
  • - ))} -
- + {!hasCriticalHours && !isUndefined(zoneStatus) && ( + + No critical hours available. + )} - {zoneStatusMap[AdvisoryStatus.WARNING].length === 0 && zoneStatusMap[AdvisoryStatus.ADVISORY].length === 0 && ( + {isUndefined(zoneStatus) && ( - No advisories or warnings issued for the selected fire center. + No advisories or warnings issued for the selected fire zone unit. )} @@ -123,7 +160,9 @@ const AdvisoryText = ({ issueDate, forDate, advisoryThreshold, selectedFireCente backgroundColor: 'white' }} > - {!selectedFireCenter || !issueDate?.isValid ? renderDefaultMessage() : renderAdvisoryText()} + {!selectedFireCenter || !issueDate?.isValid || !selectedFireZoneUnit + ? renderDefaultMessage() + : renderAdvisoryText()} ) diff --git a/web/src/features/fba/components/infoPanel/FireZoneUnitInfo.tsx b/web/src/features/fba/components/infoPanel/FireZoneUnitInfo.tsx index ab35f9c5b..ee2063787 100644 --- a/web/src/features/fba/components/infoPanel/FireZoneUnitInfo.tsx +++ b/web/src/features/fba/components/infoPanel/FireZoneUnitInfo.tsx @@ -1,9 +1,9 @@ import React from 'react' import { Box, ListItem, ListItemIcon, Typography } from '@mui/material' -import { ADVISORY_ORANGE_FILL, ADVISORY_RED_FILL } from 'features/fba/components/map/featureStylers' import { FireShapeAreaDetail } from 'api/fbaAPI' import { useTheme } from '@mui/material/styles' import { TRANSPARENT_COLOUR } from 'app/theme' +import { calculateStatusColour } from '@/features/fba/calculateZoneStatus' interface FireZoneUnitInfoProps { advisoryThreshold: number @@ -13,31 +13,6 @@ interface FireZoneUnitInfoProps { const FireZoneUnitInfo = ({ advisoryThreshold, fireZoneUnitName, fireZoneUnitDetails }: FireZoneUnitInfoProps) => { const theme = useTheme() - const calculateStatus = (details: FireShapeAreaDetail[]) => { - // Default is transparent - let status = TRANSPARENT_COLOUR - - if (details.length === 0) { - return status - } - - const advisoryThresholdDetail = details.find(detail => detail.threshold == 1) - const warningThresholdDetail = details.find(detail => detail.threshold == 2) - const advisoryPercentage = advisoryThresholdDetail?.elevated_hfi_percentage ?? 0 - const warningPercentage = warningThresholdDetail?.elevated_hfi_percentage ?? 0 - - if (advisoryPercentage + warningPercentage > advisoryThreshold) { - // advisory color orange - status = ADVISORY_ORANGE_FILL - } - - if (warningPercentage > advisoryThreshold) { - // advisory color red - status = ADVISORY_RED_FILL - } - - return status - } return ( @@ -45,7 +20,7 @@ const FireZoneUnitInfo = ({ advisoryThreshold, fireZoneUnitName, fireZoneUnitDet - hfiElevationInfo: ElevationInfoByThreshold[] - fireShapeAreas: FireShapeArea[] + fireZoneFuelStats: Record + fireZoneTPIStats: FireZoneTPIStats | undefined +} + +function hasRequiredFields(stats: FireZoneTPIStats): stats is Required { + return ( + !isUndefined(stats.mid_slope) && + !isNull(stats.mid_slope) && + !isUndefined(stats.upper_slope) && + !isNull(stats.upper_slope) && + !isUndefined(stats.valley_bottom) && + !isNull(stats.valley_bottom) && + stats.mid_slope + stats.upper_slope + stats.mid_slope !== 0 + ) } const FireZoneUnitSummary = ({ - fireShapeAreas, - fuelTypeInfo, - hfiElevationInfo, + fireZoneFuelStats, + fireZoneTPIStats, selectedFireZoneUnit }: FireZoneUnitSummaryProps) => { const theme = useTheme() @@ -27,21 +36,23 @@ const FireZoneUnitSummary = ({ } return (
- - - - - - - - + + + + + + {fireZoneTPIStats && hasRequiredFields(fireZoneTPIStats) ? ( + + ) : ( + No elevation information available. + )} - +
) } diff --git a/web/src/features/fba/components/infoPanel/FireZoneUnitTabs.tsx b/web/src/features/fba/components/infoPanel/FireZoneUnitTabs.tsx new file mode 100644 index 000000000..c476d4dc1 --- /dev/null +++ b/web/src/features/fba/components/infoPanel/FireZoneUnitTabs.tsx @@ -0,0 +1,152 @@ +import { selectFireCentreHFIFuelStats, selectFireCentreTPIStats } from '@/app/rootReducer' +import { calculateStatusColour } from '@/features/fba/calculateZoneStatus' +import { Box, Grid, Tab, Tabs, Tooltip, Typography } from '@mui/material' +import { FireCenter, FireShape } from 'api/fbaAPI' +import { INFO_PANEL_CONTENT_BACKGROUND, theme } from 'app/theme' +import FireZoneUnitSummary from 'features/fba/components/infoPanel/FireZoneUnitSummary' +import InfoAccordion from 'features/fba/components/infoPanel/InfoAccordion' +import TabPanel from 'features/fba/components/infoPanel/TabPanel' +import { useFireCentreDetails } from 'features/fba/hooks/useFireCentreDetails' +import { isEmpty, isNull, isUndefined } from 'lodash' +import React, { useEffect, useMemo, useState } from 'react' +import { useSelector } from 'react-redux' + +interface FireZoneUnitTabs { + selectedFireZoneUnit: FireShape | undefined + setZoomSource: React.Dispatch> + selectedFireCenter: FireCenter | undefined + advisoryThreshold: number + setSelectedFireShape: React.Dispatch> +} + +const FireZoneUnitTabs = ({ + selectedFireZoneUnit, + setZoomSource, + selectedFireCenter, + advisoryThreshold, + setSelectedFireShape +}: FireZoneUnitTabs) => { + const { fireCentreTPIStats } = useSelector(selectFireCentreTPIStats) + const { fireCentreHFIFuelStats } = useSelector(selectFireCentreHFIFuelStats) + const [tabNumber, setTabNumber] = useState(0) + + const sortedGroupedFireZoneUnits = useFireCentreDetails(selectedFireCenter) + + useEffect(() => { + if (selectedFireZoneUnit) { + const newIndex = sortedGroupedFireZoneUnits.findIndex( + zone => zone.fire_shape_id === selectedFireZoneUnit.fire_shape_id + ) + if (newIndex !== -1) { + setTabNumber(newIndex) + } + } else { + setTabNumber(0) + setSelectedFireShape(getTabFireShape(0)) // if no selected FireShape, select the first one in the sorted tabs + } + }, [selectedFireZoneUnit, sortedGroupedFireZoneUnits]) + + const getTabFireShape = (tabNumber: number): FireShape | undefined => { + if (sortedGroupedFireZoneUnits.length > 0) { + const selectedTabZone = sortedGroupedFireZoneUnits[tabNumber] + + const fireShape: FireShape = { + fire_shape_id: selectedTabZone.fire_shape_id, + mof_fire_centre_name: selectedTabZone.fire_centre_name, + mof_fire_zone_name: selectedTabZone.fire_shape_name + } + + return fireShape + } + } + + const handleTabChange = (event: React.SyntheticEvent, newValue: number) => { + setTabNumber(newValue) + + const fireShape = getTabFireShape(newValue) + setSelectedFireShape(fireShape) + setZoomSource('fireShape') + } + + const tpiStatsArray = useMemo(() => { + if (selectedFireCenter) { + return fireCentreTPIStats?.[selectedFireCenter.name] + } + }, [fireCentreTPIStats, selectedFireCenter]) + + const hfiFuelStats = useMemo(() => { + if (selectedFireCenter) { + return fireCentreHFIFuelStats?.[selectedFireCenter?.name] + } + }, [fireCentreHFIFuelStats, selectedFireCenter]) + + if (isUndefined(selectedFireCenter) || isNull(selectedFireCenter)) { + return
+ } + + return ( +
+ + {isEmpty(sortedGroupedFireZoneUnits) && ( + + No advisory data available for the selected date. + + )} + + + + + {sortedGroupedFireZoneUnits.map((zone, index) => { + const isActive = tabNumber === index + const key = zone.fire_shape_id + return ( + + + + ) + })} + + + {sortedGroupedFireZoneUnits.map((zone, index) => ( + + stats.fire_zone_id == zone.fire_shape_id) : undefined + } + selectedFireZoneUnit={selectedFireZoneUnit} + /> + + ))} + + + +
+ ) +} + +export default FireZoneUnitTabs diff --git a/web/src/features/fba/components/infoPanel/TabPanel.tsx b/web/src/features/fba/components/infoPanel/TabPanel.tsx new file mode 100644 index 000000000..a49259023 --- /dev/null +++ b/web/src/features/fba/components/infoPanel/TabPanel.tsx @@ -0,0 +1,18 @@ +import { Box } from '@mui/material' +import React from 'react' + +interface TabPanelProps { + children?: React.ReactNode + index: number + value: number +} + +const TabPanel = ({ children, index, value }: TabPanelProps) => { + return ( + + ) +} + +export default TabPanel diff --git a/web/src/features/fba/components/infoPanel/advisoryReport.test.tsx b/web/src/features/fba/components/infoPanel/advisoryReport.test.tsx index 3f92c7ad7..f66adc502 100644 --- a/web/src/features/fba/components/infoPanel/advisoryReport.test.tsx +++ b/web/src/features/fba/components/infoPanel/advisoryReport.test.tsx @@ -1,4 +1,3 @@ -import React from 'react' import AdvisoryReport from 'features/fba/components/infoPanel/AdvisoryReport' import { render } from '@testing-library/react' import { DateTime } from 'luxon' @@ -7,11 +6,16 @@ import provincialSummarySlice, { initialState, ProvincialSummaryState } from 'features/fba/slices/provincialSummarySlice' +import fireCentreHFIFuelStatsSlice from '@/features/fba/slices/fireCentreHFIFuelStatsSlice' + import { combineReducers, configureStore } from '@reduxjs/toolkit' import { Provider } from 'react-redux' const buildTestStore = (initialState: ProvincialSummaryState) => { - const rootReducer = combineReducers({ provincialSummary: provincialSummarySlice }) + const rootReducer = combineReducers({ + provincialSummary: provincialSummarySlice, + fireCentreHFIFuelStats: fireCentreHFIFuelStatsSlice + }) const testStore = configureStore({ reducer: rootReducer, preloadedState: { diff --git a/web/src/features/fba/components/infoPanel/advisoryText.test.tsx b/web/src/features/fba/components/infoPanel/advisoryText.test.tsx index f602d6105..ba1437876 100644 --- a/web/src/features/fba/components/infoPanel/advisoryText.test.tsx +++ b/web/src/features/fba/components/infoPanel/advisoryText.test.tsx @@ -1,21 +1,31 @@ -import React from 'react' import { render } from '@testing-library/react' import { DateTime } from 'luxon' import AdvisoryText from 'features/fba/components/infoPanel/AdvisoryText' -import { FireCenter, FireShapeAreaDetail } from 'api/fbaAPI' +import { FireCenter, FireShape, FireShapeAreaDetail } from 'api/fbaAPI' import provincialSummarySlice, { - initialState, + initialState as provSummaryInitialState, ProvincialSummaryState } from 'features/fba/slices/provincialSummarySlice' +import fireCentreHFIFuelStatsSlice, { + initialState as fuelStatsInitialState, + FireCentreHFIFuelStatsState +} from '@/features/fba/slices/fireCentreHFIFuelStatsSlice' import { combineReducers, configureStore } from '@reduxjs/toolkit' import { Provider } from 'react-redux' -const buildTestStore = (initialState: ProvincialSummaryState) => { - const rootReducer = combineReducers({ provincialSummary: provincialSummarySlice }) +const buildTestStore = ( + provincialSummaryInitialState: ProvincialSummaryState, + fuelStatsInitialState?: FireCentreHFIFuelStatsState +) => { + const rootReducer = combineReducers({ + provincialSummary: provincialSummarySlice, + fireCentreHFIFuelStats: fireCentreHFIFuelStatsSlice + }) const testStore = configureStore({ reducer: rootReducer, preloadedState: { - provincialSummary: initialState + provincialSummary: provincialSummaryInitialState, + fireCentreHFIFuelStats: fuelStatsInitialState } }) return testStore @@ -31,6 +41,20 @@ const mockFireCenter: FireCenter = { stations: [] } +const mockFireZoneUnit: FireShape = { + fire_shape_id: 20, + mof_fire_zone_name: 'C2-Central Cariboo Fire Zone', + mof_fire_centre_name: 'Cariboo Fire Centre', + area_sqm: undefined +} + +const mockAdvisoryFireZoneUnit: FireShape = { + fire_shape_id: 18, + mof_fire_zone_name: 'C4-100 Mile House Fire Zone', + mof_fire_centre_name: 'Cariboo Fire Centre', + area_sqm: undefined +} + const advisoryDetails: FireShapeAreaDetail[] = [ { fire_shape_id: 18, @@ -96,19 +120,14 @@ const noAdvisoryDetails: FireShapeAreaDetail[] = [ describe('AdvisoryText', () => { const testStore = buildTestStore({ - ...initialState, + ...provSummaryInitialState, fireShapeAreaDetails: advisoryDetails }) it('should render the advisory text container', () => { const { getByTestId } = render( - + ) const advisoryText = getByTestId('advisory-text') @@ -125,7 +144,22 @@ describe('AdvisoryText', () => { expect(message).toBeInTheDocument() }) - it('should render no data message when the issueDate is invalid selected', () => { + it('should render default message when no fire zone unit is selected', () => { + const { getByTestId } = render( + + + + ) + const message = getByTestId('default-message') + expect(message).toBeInTheDocument() + }) + + it('should render no data message when the issueDate is invalid', () => { const { getByTestId } = render( @@ -135,26 +169,33 @@ describe('AdvisoryText', () => { expect(message).toBeInTheDocument() }) - it('should only render advisory status if there is only advisory data', () => { + it('should render a no advisories message when there are no advisories/warnings', () => { + const noAdvisoryStore = buildTestStore({ + ...provSummaryInitialState, + fireShapeAreaDetails: noAdvisoryDetails + }) const { queryByTestId } = render( - + ) - const advisoryMessage = queryByTestId('advisory-message-advisory') const warningMessage = queryByTestId('advisory-message-warning') - expect(advisoryMessage).toBeInTheDocument() + const advisoryMessage = queryByTestId('advisory-message-advisory') + const noAdvisoryMessage = queryByTestId('no-advisory-message') + expect(advisoryMessage).not.toBeInTheDocument() expect(warningMessage).not.toBeInTheDocument() + expect(noAdvisoryMessage).toBeInTheDocument() }) - it('should only render warning status if there is only warning data', () => { + it('should render warning status', () => { const warningStore = buildTestStore({ - ...initialState, + ...provSummaryInitialState, fireShapeAreaDetails: warningDetails }) const { queryByTestId } = render( @@ -164,56 +205,141 @@ describe('AdvisoryText', () => { forDate={forDate} advisoryThreshold={advisoryThreshold} selectedFireCenter={mockFireCenter} + selectedFireZoneUnit={mockFireZoneUnit} /> ) - const warningMessage = queryByTestId('advisory-message-warning') const advisoryMessage = queryByTestId('advisory-message-advisory') + const warningMessage = queryByTestId('advisory-message-warning') expect(advisoryMessage).not.toBeInTheDocument() expect(warningMessage).toBeInTheDocument() }) - it('should render both warning and advisory text if data for both exists', () => { - const warningAdvisoryStore = buildTestStore({ - ...initialState, - fireShapeAreaDetails: warningDetails.concat(advisoryDetails) - }) + it('should render advisory status', () => { const { queryByTestId } = render( - + ) + const advisoryMessage = queryByTestId('advisory-message-advisory') const warningMessage = queryByTestId('advisory-message-warning') + expect(advisoryMessage).toBeInTheDocument() + expect(warningMessage).not.toBeInTheDocument() + }) + + it('should render critical hours missing message when critical hours start time is missing', () => { + const store = buildTestStore( + { + ...provSummaryInitialState, + fireShapeAreaDetails: advisoryDetails + }, + { + ...fuelStatsInitialState, + fireCentreHFIFuelStats: missingCriticalHoursStartFuelStatsState.fireCentreHFIFuelStats + } + ) + const { queryByTestId } = render( + + + + ) const advisoryMessage = queryByTestId('advisory-message-advisory') + const criticalHoursMessage = queryByTestId('advisory-message-no-critical-hours') expect(advisoryMessage).toBeInTheDocument() - expect(warningMessage).toBeInTheDocument() + expect(criticalHoursMessage).toBeInTheDocument() }) - it('should render a no advisories message when there are no advisories/warnings', () => { - const noAdvisoryStore = buildTestStore({ - ...initialState, - fireShapeAreaDetails: noAdvisoryDetails - }) + it('should render critical hours missing message when critical hours end time is missing', () => { + const store = buildTestStore( + { + ...provSummaryInitialState, + fireShapeAreaDetails: advisoryDetails + }, + { + ...fuelStatsInitialState, + fireCentreHFIFuelStats: missingCriticalHoursEndFuelStatsState.fireCentreHFIFuelStats + } + ) const { queryByTestId } = render( - + ) - const warningMessage = queryByTestId('advisory-message-warning') const advisoryMessage = queryByTestId('advisory-message-advisory') - const noAdvisoryMessage = queryByTestId('no-advisory-message') - expect(advisoryMessage).not.toBeInTheDocument() - expect(warningMessage).not.toBeInTheDocument() - expect(noAdvisoryMessage).toBeInTheDocument() + const criticalHoursMessage = queryByTestId('advisory-message-no-critical-hours') + expect(advisoryMessage).toBeInTheDocument() + expect(criticalHoursMessage).toBeInTheDocument() }) }) + +const missingCriticalHoursStartFuelStatsState: FireCentreHFIFuelStatsState = { + error: null, + fireCentreHFIFuelStats: { + 'Prince George Fire Centre': { + '25': [ + { + fuel_type: { + fuel_type_id: 2, + fuel_type_code: 'C-2', + description: 'Boreal Spruce' + }, + threshold: { + id: 1, + name: 'advisory', + description: '4000 < hfi < 10000' + }, + critical_hours: { + start_time: undefined, + end_time: 13 + }, + area: 4000 + } + ] + } + } +} + +const missingCriticalHoursEndFuelStatsState: FireCentreHFIFuelStatsState = { + error: null, + fireCentreHFIFuelStats: { + 'Prince George Fire Centre': { + '25': [ + { + fuel_type: { + fuel_type_id: 2, + fuel_type_code: 'C-2', + description: 'Boreal Spruce' + }, + threshold: { + id: 1, + name: 'advisory', + description: '4000 < hfi < 10000' + }, + critical_hours: { + start_time: 9, + end_time: undefined + }, + area: 4000 + } + ] + } + } +} diff --git a/web/src/features/fba/components/infoPanel/fireCentreInfo.test.tsx b/web/src/features/fba/components/infoPanel/fireCentreInfo.test.tsx index 5b93ba0f3..1cc4e8ae4 100644 --- a/web/src/features/fba/components/infoPanel/fireCentreInfo.test.tsx +++ b/web/src/features/fba/components/infoPanel/fireCentreInfo.test.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import { vi } from 'vitest' import { render } from '@testing-library/react' import FireCentreInfo from 'features/fba/components/infoPanel/FireCentreInfo' import { FireShapeAreaDetail } from 'api/fbaAPI' @@ -11,7 +11,7 @@ describe('FireCentreInfo', () => { expanded={false} fireCentreName="foo" fireZoneUnitInfos={[]} - onChangeExpanded={jest.fn()} + onChangeExpanded={vi.fn()} /> ) const fireCentreInfo = getByTestId('fire-centre-info') @@ -24,7 +24,7 @@ describe('FireCentreInfo', () => { expanded={false} fireCentreName="foo" fireZoneUnitInfos={[]} - onChangeExpanded={jest.fn()} + onChangeExpanded={vi.fn()} /> ) const fireCentreInfo = getByTestId('fire-centre-info') @@ -49,7 +49,7 @@ describe('FireCentreInfo', () => { expanded={true} fireCentreName="foo" fireZoneUnitInfos={fireShapeAreaDetails} - onChangeExpanded={jest.fn()} + onChangeExpanded={vi.fn()} /> ) const fireZoneUnitInfo = getByTestId('fire-zone-unit-info') diff --git a/web/src/features/fba/components/infoPanel/fireZoneUnitInfo.test.tsx b/web/src/features/fba/components/infoPanel/fireZoneUnitInfo.test.tsx index e9e5b6824..3172f2ce5 100644 --- a/web/src/features/fba/components/infoPanel/fireZoneUnitInfo.test.tsx +++ b/web/src/features/fba/components/infoPanel/fireZoneUnitInfo.test.tsx @@ -1,4 +1,3 @@ -import React from 'react' import { render } from '@testing-library/react' import FireZoneUnitInfo from 'features/fba/components/infoPanel/FireZoneUnitInfo' import { ADVISORY_ORANGE_FILL, ADVISORY_RED_FILL } from 'features/fba/components/map/featureStylers' diff --git a/web/src/features/fba/components/infoPanel/fireZoneUnitSummary.test.tsx b/web/src/features/fba/components/infoPanel/fireZoneUnitSummary.test.tsx index 8c7e87c84..a1c586914 100644 --- a/web/src/features/fba/components/infoPanel/fireZoneUnitSummary.test.tsx +++ b/web/src/features/fba/components/infoPanel/fireZoneUnitSummary.test.tsx @@ -1,8 +1,14 @@ -import React from 'react' import FireZoneUnitSummary from 'features/fba/components/infoPanel/FireZoneUnitSummary' import { FireShape } from 'api/fbaAPI' import { render } from '@testing-library/react' +const fireZoneTPIStats = { + fire_zone_id: 0, + valley_bottom: 0, + mid_slope: 100, + upper_slope: 0 +} + describe('FireZoneUnitSummary', () => { class ResizeObserver { observe() { @@ -18,12 +24,7 @@ describe('FireZoneUnitSummary', () => { window.ResizeObserver = ResizeObserver it('should not render empty div if selectedFireZoneUnit is undefined', () => { const { getByTestId } = render( - + ) const fireZoneUnitInfo = getByTestId('fire-zone-unit-summary-empty') expect(fireZoneUnitInfo).toBeInTheDocument() @@ -36,14 +37,58 @@ describe('FireZoneUnitSummary', () => { area_sqm: 10 } const { getByTestId } = render( + + ) + const fireZoneUnitInfo = getByTestId('fire-zone-unit-summary') + expect(fireZoneUnitInfo).toBeInTheDocument() + }) + it('should not render TPI stats if null', () => { + const fireShape: FireShape = { + fire_shape_id: 1, + mof_fire_zone_name: 'foo', + mof_fire_centre_name: 'fizz', + area_sqm: 10 + } + const { queryByTestId } = render( + + ) + const fireZoneUnitInfo = queryByTestId('elevation-status') + expect(fireZoneUnitInfo).not.toBeInTheDocument() + }) + it('should render TPI stats if not null', () => { + const fireShape: FireShape = { + fire_shape_id: 1, + mof_fire_zone_name: 'foo', + mof_fire_centre_name: 'fizz', + area_sqm: 10 + } + const { getByTestId } = render( + + ) + const fireZoneUnitInfo = getByTestId('elevation-status') + expect(fireZoneUnitInfo).toBeInTheDocument() + }) + + it('should not render TPI stats all zero', () => { + const fireShape: FireShape = { + fire_shape_id: 1, + mof_fire_zone_name: 'foo', + mof_fire_centre_name: 'fizz', + area_sqm: 10 + } + const { queryByTestId } = render( ) - const fireZoneUnitInfo = getByTestId('fire-zone-unit-summary') - expect(fireZoneUnitInfo).toBeInTheDocument() + const fireZoneUnitInfo = queryByTestId('elevation-status') + expect(fireZoneUnitInfo).not.toBeInTheDocument() }) }) diff --git a/web/src/features/fba/components/infoPanel/fireZoneUnitTabs.test.tsx b/web/src/features/fba/components/infoPanel/fireZoneUnitTabs.test.tsx new file mode 100644 index 000000000..b0c90b036 --- /dev/null +++ b/web/src/features/fba/components/infoPanel/fireZoneUnitTabs.test.tsx @@ -0,0 +1,198 @@ +import React from 'react' +import { render, screen, fireEvent } from '@testing-library/react' +import FireZoneUnitTabs from './FireZoneUnitTabs' +import { FireCenter, FireCentreHFIStats, FireShape, FireShapeAreaDetail, FireZoneTPIStats } from 'api/fbaAPI' +import { vi } from 'vitest' +import { ADVISORY_ORANGE_FILL, ADVISORY_RED_FILL } from '@/features/fba/components/map/featureStylers' +import { combineReducers, configureStore } from '@reduxjs/toolkit' +import fireCentreTPIStatsSlice, { + CentreTPIStatsState, + initialState as tpiInitialState +} from '@/features/fba/slices/fireCentreTPIStatsSlice' +import fireCentreHFIFuelStatsSlice, { + FireCentreHFIFuelStatsState, + initialState as hfiInitialState +} from '@/features/fba/slices/fireCentreHFIFuelStatsSlice' +import { Provider } from 'react-redux' + +const getAdvisoryDetails = ( + fireZoneName: string, + fireShapeId: number, + advisoryPercent: number, + warningPercent: number +): FireShapeAreaDetail[] => { + return [ + { + fire_shape_id: fireShapeId, + threshold: 1, + combustible_area: 1, + elevated_hfi_area: 2, + elevated_hfi_percentage: advisoryPercent, + fire_shape_name: fireZoneName, + fire_centre_name: fireCentre1 + }, + { + fire_shape_id: fireShapeId, + threshold: 2, + combustible_area: 1, + elevated_hfi_area: 2, + elevated_hfi_percentage: warningPercent, + fire_shape_name: fireZoneName, + fire_centre_name: fireCentre1 + } + ] +} + +const buildTestStore = (hfiInitialState: FireCentreHFIFuelStatsState, tpiInitialState: CentreTPIStatsState) => { + const rootReducer = combineReducers({ + fireCentreHFIFuelStats: fireCentreHFIFuelStatsSlice, + fireCentreTPIStats: fireCentreTPIStatsSlice + }) + const testStore = configureStore({ + reducer: rootReducer, + preloadedState: { + fireCentreHFIFuelStats: hfiInitialState, + fireCentreTPIStats: tpiInitialState + } + }) + return testStore +} + +const fireCentre1 = 'Centre 1' +const zoneA = 'A Zone' +const zoneB = 'B Zone' + +const mockSelectedFireZoneUnitA: FireShape = { + fire_shape_id: 1, + mof_fire_centre_name: fireCentre1, + mof_fire_zone_name: zoneA +} + +const mockSelectedFireCenter: FireCenter = { + id: 1, + name: fireCentre1, + stations: [] +} + +const mockFireCentreTPIStats: Record = { + [fireCentre1]: [{ fire_zone_id: 1, valley_bottom: 10, mid_slope: 90, upper_slope: 10 }] +} + +const mockFireCentreHFIFuelStats: FireCentreHFIStats = { + 'Centre 1': { + 1: [ + { + fuel_type: { fuel_type_id: 1, fuel_type_code: 'C', description: 'fuel type' }, + area: 10, + threshold: { id: 1, name: 'threshold', description: 'description' }, + critical_hours: {start_time: 8, end_time: 11} + } + ] + } +} + +const mockSortedGroupedFireZoneUnits = [ + { + fire_shape_id: 1, + fire_shape_name: zoneA, + fire_centre_name: fireCentre1, + fireShapeDetails: getAdvisoryDetails(zoneA, 1, 30, 10) + }, + { + fire_shape_id: 2, + fire_shape_name: zoneB, + fire_centre_name: fireCentre1, + fireShapeDetails: getAdvisoryDetails(zoneB, 2, 30, 30) + } +] + +vi.mock('features/fba/hooks/useFireCentreDetails', () => ({ + useFireCentreDetails: () => mockSortedGroupedFireZoneUnits +})) + +const setSelectedFireShapeMock = vi.fn() +const setZoomSourceMock = vi.fn() + +const renderComponent = (testStore: any) => + render( + + + + ) + +describe('FireZoneUnitTabs', () => { + const testStore = buildTestStore( + { ...hfiInitialState, fireCentreHFIFuelStats: mockFireCentreHFIFuelStats }, + { ...tpiInitialState, fireCentreTPIStats: mockFireCentreTPIStats } + ) + it('should render', () => { + const { getByTestId } = renderComponent(testStore) + + const summaryTabs = getByTestId('firezone-summary-tabs') + expect(summaryTabs).toBeInTheDocument() + }) + + it('should render tabs for each zone in a centre', () => { + const { getByTestId } = renderComponent(testStore) + + const tab1 = getByTestId('zone-1-tab') + expect(tab1).toBeInTheDocument() + const tab2 = getByTestId('zone-2-tab') + expect(tab2).toBeInTheDocument() + const tabs = screen.getAllByRole('tab') + expect(tabs.length).toBe(2) + }) + + it('should select the first zone tab of a fire centre alphabetically if no zone is selected, but not zoom to it', () => { + renderComponent(testStore) + + expect(setSelectedFireShapeMock).toHaveBeenCalledWith(mockSelectedFireZoneUnitA) + expect(setZoomSourceMock).not.toHaveBeenCalled() + }) + + it('should switch to a different tab when clicked and set the map zoom source', () => { + renderComponent(testStore) + + const tab2 = screen.getByTestId('zone-2-tab') + fireEvent.click(tab2) + + expect(setSelectedFireShapeMock).toHaveBeenCalledWith({ + fire_shape_id: 2, + mof_fire_centre_name: fireCentre1, + mof_fire_zone_name: zoneB + }) + expect(setZoomSourceMock).toHaveBeenCalledWith('fireShape') + }) + + it('should render empty if there is no selected Fire Centre', () => { + const { getByTestId } = render( + + + + ) + + const emptyTabs = getByTestId('fire-zone-unit-tabs-empty') + expect(emptyTabs).toBeInTheDocument() + }) + + it('should render tabs with the correct advisory colour', () => { + const { getByTestId } = renderComponent(testStore) + + const tab1 = getByTestId('zone-1-tab') + expect(tab1).toHaveStyle(`backgroundColor: ${ADVISORY_ORANGE_FILL}`) + const tab2 = getByTestId('zone-2-tab') + expect(tab2).toHaveStyle(`backgroundColor: ${ADVISORY_RED_FILL}`) + }) +}) diff --git a/web/src/features/fba/components/infoPanel/infoAccordion.test.tsx b/web/src/features/fba/components/infoPanel/infoAccordion.test.tsx index d335ff77a..5b9048047 100644 --- a/web/src/features/fba/components/infoPanel/infoAccordion.test.tsx +++ b/web/src/features/fba/components/infoPanel/infoAccordion.test.tsx @@ -1,4 +1,4 @@ -import React from 'react' + import InfoAccordion from 'features/fba/components/infoPanel/InfoAccordion' import { render } from '@testing-library/react' diff --git a/web/src/features/fba/components/infoPanel/infoPanel.test.tsx b/web/src/features/fba/components/infoPanel/infoPanel.test.tsx index 6baf59a77..ba378bc02 100644 --- a/web/src/features/fba/components/infoPanel/infoPanel.test.tsx +++ b/web/src/features/fba/components/infoPanel/infoPanel.test.tsx @@ -1,4 +1,4 @@ -import React from 'react' + import InfoPanel from 'features/fba/components/infoPanel/InfoPanel' import { render } from '@testing-library/react' diff --git a/web/src/features/fba/components/infoPanel/provincialSummary.test.tsx b/web/src/features/fba/components/infoPanel/provincialSummary.test.tsx index 618fac3ac..deeef1a05 100644 --- a/web/src/features/fba/components/infoPanel/provincialSummary.test.tsx +++ b/web/src/features/fba/components/infoPanel/provincialSummary.test.tsx @@ -1,4 +1,4 @@ -import React from 'react' + import { Provider } from 'react-redux' import { render } from '@testing-library/react' import ProvincialSummary, { NO_DATA_MESSAGE } from 'features/fba/components/infoPanel/ProvincialSummary' diff --git a/web/src/features/fba/components/map/AdvisoryThresholdSlider.tsx b/web/src/features/fba/components/map/AdvisoryThresholdSlider.tsx deleted file mode 100644 index 136f5ee2f..000000000 --- a/web/src/features/fba/components/map/AdvisoryThresholdSlider.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { Box, Grid, Input, Slider } from '@mui/material' -import React from 'react' - -export interface AdvisoryThresholdSliderProps { - testId?: string - advisoryThreshold: number - setAdvisoryThreshold: React.Dispatch> -} -const AdvisoryThresholdSlider = ({ advisoryThreshold, setAdvisoryThreshold }: AdvisoryThresholdSliderProps) => { - const handleSliderChange = (event: Event, newValue: number | number[]) => { - if (Array.isArray(newValue)) { - setAdvisoryThreshold(newValue[0]) - } else { - setAdvisoryThreshold(newValue) - } - } - const handleInputChange = (event: React.ChangeEvent) => { - setAdvisoryThreshold(event.target.value === '' ? 0 : Number(event.target.value)) - } - return ( - - - - - - - - - - - ) -} - -export default React.memo(AdvisoryThresholdSlider) diff --git a/web/src/features/fba/components/map/FBAMap.tsx b/web/src/features/fba/components/map/FBAMap.tsx index 07d63412d..15991a362 100644 --- a/web/src/features/fba/components/map/FBAMap.tsx +++ b/web/src/features/fba/components/map/FBAMap.tsx @@ -1,13 +1,11 @@ -import * as ol from 'ol' +import { PMTilesVectorSource } from 'ol-pmtiles' +import { Map, View } from 'ol' import 'ol/ol.css' -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore -import * as olpmtiles from 'ol-pmtiles' import { defaults as defaultControls, FullScreen } from 'ol/control' import { fromLonLat } from 'ol/proj' import { boundingExtent } from 'ol/extent' import ScaleLine from 'ol/control/ScaleLine' -import OLVectorLayer from 'ol/layer/Vector' +import VectorLayer from 'ol/layer/Vector' import VectorTileLayer from 'ol/layer/VectorTile' import VectorSource from 'ol/source/Vector' import GeoJSON from 'ol/format/GeoJSON' @@ -16,31 +14,30 @@ import React, { useEffect, useRef, useState } from 'react' import { ErrorBoundary } from 'components' import { selectFireWeatherStations, selectRunDates } from 'app/rootReducer' import { source as baseMapSource } from 'features/fireWeather/components/maps/constants' -import Tile from 'ol/layer/Tile' -import { FireCenter, FireShape, FireShapeArea } from 'api/fbaAPI' +import TileLayer from 'ol/layer/Tile' +import { FireCenter, FireShape, FireShapeArea, RunType } from 'api/fbaAPI' import { extentsMap } from 'features/fba/fireCentreExtents' import { fireCentreStyler, fireCentreLabelStyler, fireShapeStyler, - fireShapeHighlightStyler, + fireShapeLineStyler, fireShapeLabelStyler, stationStyler, hfiStyler, - snowStyler + fireCentreLineStyler } from 'features/fba/components/map/featureStylers' import { BC_EXTENT, CENTER_OF_BC } from 'utils/constants' import { DateTime } from 'luxon' import { PMTILES_BUCKET } from 'utils/env' -import { RunType } from 'features/fba/pages/FireBehaviourAdvisoryPage' -import { buildPMTilesURL, buildSnowPMTilesURL } from 'features/fba/pmtilesBuilder' +import { buildPMTilesURL } from 'features/fba/pmtilesBuilder' import { isUndefined, cloneDeep, isNull } from 'lodash' import { Box } from '@mui/material' import Legend from 'features/fba/components/map/Legend' import ScalebarContainer from 'features/fba/components/map/ScaleBarContainer' -export const MapContext = React.createContext(null) +import { fireZoneExtentsMap } from 'features/fba/fireZoneUnitExtents' +export const MapContext = React.createContext(null) -const zoom = 5.5 const bcExtent = boundingExtent(BC_EXTENT.map(coord => fromLonLat(coord))) export interface FBAMapProps { @@ -52,12 +49,11 @@ export interface FBAMapProps { fireShapeAreas: FireShapeArea[] runType: RunType advisoryThreshold: number - snowDate: DateTime | null zoomSource?: 'fireCenter' | 'fireShape' setZoomSource: React.Dispatch> } -const removeLayerByName = (map: ol.Map, layerName: string) => { +const removeLayerByName = (map: Map, layerName: string) => { const layer = map .getLayers() .getArray() @@ -71,22 +67,21 @@ const FBAMap = (props: FBAMapProps) => { const { stations } = useSelector(selectFireWeatherStations) const [showShapeStatus, setShowShapeStatus] = useState(true) const [showHFI, setShowHFI] = useState(false) - const [showSnow, setShowSnow] = useState(false) - const [map, setMap] = useState(null) + const [map, setMap] = useState(null) const mapRef = useRef(null) as React.MutableRefObject const scaleRef = useRef(null) as React.MutableRefObject const { mostRecentRunDate } = useSelector(selectRunDates) - const fireCentreVectorSource = new olpmtiles.PMTilesVectorSource({ + const fireCentreVectorSource = new PMTilesVectorSource({ url: `${PMTILES_BUCKET}fireCentres.pmtiles` }) - const fireShapeVectorSource = new olpmtiles.PMTilesVectorSource({ + const fireShapeVectorSource = new PMTilesVectorSource({ url: `${PMTILES_BUCKET}fireZoneUnits.pmtiles` }) - const fireCentreLabelVectorSource = new olpmtiles.PMTilesVectorSource({ + const fireCentreLabelVectorSource = new PMTilesVectorSource({ url: `${PMTILES_BUCKET}fireCentreLabels.pmtiles` }) - const fireShapeLabelVectorSource = new olpmtiles.PMTilesVectorSource({ + const fireShapeLabelVectorSource = new PMTilesVectorSource({ url: `${PMTILES_BUCKET}fireZoneUnitLabels.pmtiles` }) @@ -108,10 +103,19 @@ const FBAMap = (props: FBAMapProps) => { const [fireCentreVTL] = useState( new VectorTileLayer({ source: fireCentreVectorSource, - style: fireCentreStyler, - zIndex: 49 + style: fireCentreStyler(props.selectedFireCenter), + zIndex: 51 + }) + ) + + const [fireCentreLineVTL] = useState( + new VectorTileLayer({ + source: fireCentreVectorSource, + style: fireCentreLineStyler(props.selectedFireCenter), + zIndex: 52 }) ) + const [fireShapeVTL] = useState( new VectorTileLayer({ source: fireShapeVectorSource, @@ -123,12 +127,8 @@ const FBAMap = (props: FBAMapProps) => { const [fireShapeHighlightVTL] = useState( new VectorTileLayer({ source: fireShapeVectorSource, - style: fireShapeHighlightStyler( - cloneDeep(props.fireShapeAreas), - props.advisoryThreshold, - props.selectedFireShape - ), - zIndex: 51, + style: fireShapeLineStyler(cloneDeep(props.fireShapeAreas), props.advisoryThreshold, props.selectedFireShape), + zIndex: 53, properties: { name: 'fireShapeVector' } }) ) @@ -164,10 +164,7 @@ const FBAMap = (props: FBAMapProps) => { if (!feature) { return } - const zoneExtent = feature.getGeometry()?.getExtent() - if (!isUndefined(zoneExtent)) { - map.getView().fit(zoneExtent, { duration: 400, padding: [50, 50, 50, 50], maxZoom: 7.4 }) - } + const fireZone: FireShape = { fire_shape_id: feature.getProperties().OBJECTID, mof_fire_zone_name: feature.getProperties().FIRE_ZONE, @@ -182,6 +179,7 @@ const FBAMap = (props: FBAMapProps) => { }, [map]) // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { + // zoom to fire center or whole province if (!map) return if (props.selectedFireCenter && props.zoomSource === 'fireCenter') { @@ -193,21 +191,38 @@ const FBAMap = (props: FBAMapProps) => { // reset map view to full province map.getView().fit(bcExtent, { duration: 600, padding: [50, 50, 50, 50] }) } - }, [props.selectedFireCenter]) // eslint-disable-line react-hooks/exhaustive-deps + }, [props.selectedFireCenter]) + + useEffect(() => { + // zoom to fire zone + if (!map) return + + if (props.selectedFireShape && props.zoomSource === 'fireShape') { + const zoneExtent = fireZoneExtentsMap.get(props.selectedFireShape.fire_shape_id.toString()) + if (!isUndefined(zoneExtent)) { + map.getView().fit(zoneExtent, { duration: 400, padding: [100, 100, 100, 100], maxZoom: 8 }) + } + } + }, [props.selectedFireShape]) useEffect(() => { if (!map) return + fireCentreVTL.setStyle(fireCentreStyler(props.selectedFireCenter)) fireShapeVTL.setStyle(fireShapeStyler(cloneDeep(props.fireShapeAreas), props.advisoryThreshold, showShapeStatus)) fireShapeLabelVTL.setStyle(fireShapeLabelStyler(props.selectedFireShape)) fireShapeHighlightVTL.setStyle( - fireShapeHighlightStyler(cloneDeep(props.fireShapeAreas), props.advisoryThreshold, props.selectedFireShape) + fireShapeLineStyler(cloneDeep(props.fireShapeAreas), props.advisoryThreshold, props.selectedFireShape) ) + fireCentreLineVTL.setStyle(fireCentreLineStyler(props.selectedFireCenter)) + fireShapeVTL.changed() fireShapeHighlightVTL.changed() fireShapeLabelVTL.changed() + fireCentreLineVTL.changed() + fireCentreVTL.changed() // eslint-disable-next-line react-hooks/exhaustive-deps - }, [props.selectedFireShape, props.fireShapeAreas, props.advisoryThreshold]) + }, [props.selectedFireCenter, props.selectedFireShape, props.fireShapeAreas, props.advisoryThreshold]) useEffect(() => { if (!map) return @@ -217,7 +232,7 @@ const FBAMap = (props: FBAMapProps) => { // The runDate for forecasts is the mostRecentRunDate. For Actuals, our API expects the runDate to be // the same as the forDate. const runDate = props.runType === RunType.FORECAST ? DateTime.fromISO(mostRecentRunDate) : props.forDate - const hfiSource = new olpmtiles.PMTilesVectorSource({ + const hfiSource = new PMTilesVectorSource({ url: buildPMTilesURL(props.forDate, props.runType, runDate) }) @@ -233,27 +248,6 @@ const FBAMap = (props: FBAMapProps) => { } }, [showHFI, mostRecentRunDate]) // eslint-disable-line react-hooks/exhaustive-deps - useEffect(() => { - if (!map) return - const layerName = 'snowVector' - removeLayerByName(map, layerName) - if (!isNull(props.snowDate)) { - const snowPMTilesSource = new olpmtiles.PMTilesVectorSource({ - url: buildSnowPMTilesURL(props.snowDate) - }) - - const latestSnowPMTilesLayer = new VectorTileLayer({ - source: snowPMTilesSource, - style: snowStyler, - zIndex: 40, - minZoom: 4, - properties: { name: layerName }, - visible: showSnow - }) - map.addLayer(latestSnowPMTilesLayer) - } - }, [props.snowDate]) // eslint-disable-line react-hooks/exhaustive-deps - useEffect(() => { // The React ref is used to attach to the div rendered in our // return statement of which this map's target is set to. @@ -264,16 +258,17 @@ const FBAMap = (props: FBAMapProps) => { // Create the map with the options above and set the target // To the ref above so that it is rendered in that div - const mapObject = new ol.Map({ - view: new ol.View({ + const mapObject = new Map({ + view: new View({ zoom: 5, center: fromLonLat(CENTER_OF_BC) }), layers: [ - new Tile({ + new TileLayer({ source: baseMapSource }), fireCentreVTL, + fireCentreLineVTL, fireShapeVTL, fireShapeHighlightVTL, fireCentreLabelVTL, @@ -309,7 +304,7 @@ const FBAMap = (props: FBAMapProps) => { } ) }) - const stationsLayer = new OLVectorLayer({ + const stationsLayer = new VectorLayer({ source: stationsSource, minZoom: 6, style: stationStyler, @@ -319,17 +314,6 @@ const FBAMap = (props: FBAMapProps) => { map?.addLayer(stationsLayer) }, [stations]) // eslint-disable-line react-hooks/exhaustive-deps - // Generate a message to display about the snow layer in the legend. - const getSnowDateMessage = () => { - if (!showSnow) { - return null - } - if (isNull(props.snowDate)) { - return 'No data available' - } - return `as of ${props.snowDate?.toISODate()}` - } - return ( @@ -349,9 +333,6 @@ const FBAMap = (props: FBAMapProps) => { setShowShapeStatus={setShowShapeStatus} showHFI={showHFI} setShowHFI={setShowHFI} - showSnow={showSnow} - setShowSnow={setShowSnow} - snowDescription={getSnowDateMessage()} />
diff --git a/web/src/features/fba/components/map/Legend.tsx b/web/src/features/fba/components/map/Legend.tsx index 3f2ec357e..694aba1b2 100644 --- a/web/src/features/fba/components/map/Legend.tsx +++ b/web/src/features/fba/components/map/Legend.tsx @@ -98,9 +98,6 @@ interface LegendProps { setShowShapeStatus: React.Dispatch> showHFI: boolean setShowHFI: React.Dispatch> - showSnow: boolean - setShowSnow: React.Dispatch> - snowDescription: string | null } const Legend = ({ @@ -108,10 +105,7 @@ const Legend = ({ showShapeStatus, setShowShapeStatus, showHFI, - setShowHFI, - showSnow, - setShowSnow, - snowDescription + setShowHFI }: LegendProps) => { const handleLayerChange = ( layerName: string, @@ -148,13 +142,6 @@ const Legend = ({ onChange={() => handleLayerChange('hfiVector', showHFI, setShowHFI)} subItems={hfiSubItems} /> - handleLayerChange('snowVector', showSnow, setShowSnow)} - description={snowDescription} - renderEmptyDescription={true} - > ) } diff --git a/web/src/features/fba/components/map/fbaMap.test.tsx b/web/src/features/fba/components/map/fbaMap.test.tsx index d1dd90eba..90777afd3 100644 --- a/web/src/features/fba/components/map/fbaMap.test.tsx +++ b/web/src/features/fba/components/map/fbaMap.test.tsx @@ -1,9 +1,9 @@ +import { RunType } from '@/api/fbaAPI' import { render } from '@testing-library/react' import store from 'app/store' import FBAMap from 'features/fba/components/map/FBAMap' -import { RunType } from 'features/fba/pages/FireBehaviourAdvisoryPage' import { DateTime } from 'luxon' -import React from 'react' + import { Provider } from 'react-redux' describe('FBAMap', () => { @@ -32,11 +32,10 @@ describe('FBAMap', () => { setSelectedFireShape={function (): void { throw new Error('Function not implemented.') }} - zoomSource={undefined} + zoomSource={'fireCenter'} setZoomSource={function (): void { throw new Error('Function not implemented.') }} - snowDate={DateTime.now()} /> ) diff --git a/web/src/features/fba/components/map/featureStylers.ts b/web/src/features/fba/components/map/featureStylers.ts index d0972f628..b25d69249 100644 --- a/web/src/features/fba/components/map/featureStylers.ts +++ b/web/src/features/fba/components/map/featureStylers.ts @@ -5,10 +5,10 @@ import CircleStyle from 'ol/style/Circle' import { Fill, Stroke, Text } from 'ol/style' import Style from 'ol/style/Style' import { range, startCase, lowerCase, isUndefined } from 'lodash' -import { FireShape, FireShapeArea } from 'api/fbaAPI' +import { FireCenter, FireShape, FireShapeArea } from 'api/fbaAPI' +const GREY_FILL = 'rgba(128, 128, 128, 0.8)' const EMPTY_FILL = 'rgba(0, 0, 0, 0.0)' -const SNOW_FILL = 'rgba(255, 255, 255, 0.75)' export const ADVISORY_ORANGE_FILL = 'rgba(255, 147, 38, 0.4)' export const ADVISORY_RED_FILL = 'rgba(128, 0, 0, 0.4)' @@ -38,13 +38,31 @@ export const fireCentreLabelStyler = (feature: RenderFeature | ol.Feature { - return new Style({ - stroke: new Stroke({ - color: 'black', - width: 3 +export const fireCentreStyler = (selectedFireCenter: FireCenter | undefined) => { + return (feature: RenderFeature | ol.Feature): Style => { + const fireCenterId = feature.getProperties().MOF_FIRE_CENTRE_NAME + const isSelected = selectedFireCenter && fireCenterId == selectedFireCenter.name + + const fillColour = isSelected ? new Fill({ color: EMPTY_FILL }) : new Fill({ color: GREY_FILL }) + + return new Style({ + fill: selectedFireCenter ? fillColour : undefined }) - }) + } +} + +export const fireCentreLineStyler = (selectedFireCenter: FireCenter | undefined) => { + return (feature: RenderFeature | ol.Feature): Style => { + const fireCenterId = feature.getProperties().MOF_FIRE_CENTRE_NAME + const isSelected = selectedFireCenter && fireCenterId == selectedFireCenter.name + + return new Style({ + stroke: new Stroke({ + color: 'black', + width: isSelected ? 8 : 3 + }) + }) + } } export const fireShapeStyler = ( @@ -68,7 +86,7 @@ export const fireShapeStyler = ( return a } -export const fireShapeHighlightStyler = ( +export const fireShapeLineStyler = ( fireShapeAreas: FireShapeArea[], advisoryThreshold: number, selectedFireShape: FireShape | undefined @@ -81,10 +99,9 @@ export const fireShapeHighlightStyler = ( return new Style({ stroke: new Stroke({ - color: selected ? getFireShapeStrokeColor(status) : [0, 0, 0, 0], - width: selected ? 8 : 0 - }), - fill: new Fill({ color: EMPTY_FILL }) + color: selected ? getFireShapeStrokeColor(status) : EMPTY_FILL, + width: selected ? 8 : 1 + }) }) } return a @@ -118,7 +135,7 @@ const getFireShapeStrokeColor = (fireShapeStatus: FireShapeStatus) => { case FireShapeStatus.WARNING: return [227, 0, 1, 0.99] default: - return 'black' + return '#7f7f7f' } } @@ -133,9 +150,29 @@ export const getAdvisoryFillColor = (fireShapeStatus: FireShapeStatus) => { } } +/** + * Given an OpenLayers feature from the fire zone unit label layer, return a label to display on the map. + * @param feature The feature of interest from the fire zone unit layer. + * @returns A string to be used as a label on the map. + */ +const getFireZoneUnitLabel = (feature: RenderFeature | ol.Feature) => { + const fireZoneId = feature.getProperties().FIRE_ZONE_ + let fireZoneUnit = feature.getProperties().FIRE_ZON_1 + // Fire zone unit labels sometimes include a geographic place name as a reference. eg. Skeena Zone (Kalum). + // If present, we want to display the geographic location on the second line of the label. + if (fireZoneUnit && fireZoneUnit.indexOf('(') > 0) { + const index = fireZoneUnit.indexOf('(') + const prefix = fireZoneUnit.substring(0, index).trim() + const suffix = fireZoneUnit.substring(index) + fireZoneUnit = `${prefix}\n${suffix}` + } + + return `${fireZoneId}-${fireZoneUnit}` +} + export const fireShapeLabelStyler = (selectedFireShape: FireShape | undefined) => { const a = (feature: RenderFeature | ol.Feature): Style => { - const text = feature.getProperties().FIRE_ZONE.replace(' Fire Zone', '\nFire Zone') + const text = getFireZoneUnitLabel(feature) const feature_fire_shape_id = feature.getProperties().OBJECTID const selected = !isUndefined(selectedFireShape) && feature_fire_shape_id === selectedFireShape.fire_shape_id ? true : false @@ -212,15 +249,3 @@ export const hfiStyler = (feature: RenderFeature | ol.Feature): Style } return hfiStyle } - -// A styling function for the snow coverage pmtiles layer. -export const snowStyler = (feature: RenderFeature | ol.Feature): Style => { - const snow = feature.get('snow') - const snowStyle = new Style({}) - if (snow === 1) { - snowStyle.setFill(new Fill({ color: SNOW_FILL })) - } else { - snowStyle.setFill(new Fill({ color: EMPTY_FILL })) - } - return snowStyle -} diff --git a/web/src/features/fba/components/map/legend.test.tsx b/web/src/features/fba/components/map/legend.test.tsx index 2e614409e..aba3de710 100644 --- a/web/src/features/fba/components/map/legend.test.tsx +++ b/web/src/features/fba/components/map/legend.test.tsx @@ -1,14 +1,14 @@ import Legend from 'features/fba/components/map/Legend' import { render, waitFor, within } from '@testing-library/react' import { userEvent } from '@testing-library/user-event' -import React from 'react' +import { vi } from 'vitest' describe('Legend', () => { it('should render with the default layer visibility', async () => { - const onToggleLayer = jest.fn() - const setShowZoneStatus = jest.fn() - const setShowHFI = jest.fn() - const setShowSnow = jest.fn() + const onToggleLayer = vi.fn() + const setShowZoneStatus = vi.fn() + const setShowHFI = vi.fn() + const { getByTestId } = render( { setShowHFI={setShowHFI} showHFI={false} showShapeStatus={true} - showSnow={false} - setShowSnow={setShowSnow} - snowDescription="foo" /> ) const legendComponent = getByTestId('asa-map-legend') @@ -36,10 +33,10 @@ describe('Legend', () => { }) it('should call click handlers on checkboxes', async () => { - const onToggleLayer = jest.fn() - const setShowZoneStatus = jest.fn() - const setShowHFI = jest.fn() - const setShowSnow = jest.fn() + const onToggleLayer = vi.fn() + const setShowZoneStatus = vi.fn() + const setShowHFI = vi.fn() + const { getByTestId } = render( { setShowHFI={setShowHFI} showHFI={false} showShapeStatus={true} - showSnow={false} - setShowSnow={setShowSnow} - snowDescription="foo" /> ) diff --git a/web/src/features/fba/components/map/scalebarContainer.test.tsx b/web/src/features/fba/components/map/scalebarContainer.test.tsx index e67a6a895..0970012cb 100644 --- a/web/src/features/fba/components/map/scalebarContainer.test.tsx +++ b/web/src/features/fba/components/map/scalebarContainer.test.tsx @@ -1,6 +1,7 @@ -import React from 'react' + import { render } from '@testing-library/react' import ScalebarContainer from 'features/fba/components/map/ScaleBarContainer' +import React from 'react' describe('ScalebarContainer', () => { it('should render', () => { diff --git a/web/src/features/fba/components/viz/CriticalHours.tsx b/web/src/features/fba/components/viz/CriticalHours.tsx new file mode 100644 index 000000000..02a1cfbbf --- /dev/null +++ b/web/src/features/fba/components/viz/CriticalHours.tsx @@ -0,0 +1,20 @@ +import { Typography } from '@mui/material' +import React from 'react' +import { isNull, isUndefined } from 'lodash' + +interface CriticalHoursProps { + start?: number + end?: number +} + +const CriticalHours = ({ start, end }: CriticalHoursProps) => { + const formattedCriticalHours = + isNull(start) || isUndefined(start) || isNull(end) || isUndefined(end) ? '-' : `${start}:00 - ${end}:00` + return ( + + {formattedCriticalHours} + + ) +} + +export default React.memo(CriticalHours) diff --git a/web/src/features/fba/components/viz/ElevationFlag.tsx b/web/src/features/fba/components/viz/ElevationFlag.tsx new file mode 100644 index 000000000..94dcc06a3 --- /dev/null +++ b/web/src/features/fba/components/viz/ElevationFlag.tsx @@ -0,0 +1,42 @@ +import React from 'react' +import { Box, Grid, Typography } from '@mui/material' + +const FLAG_COLOUR = '#CCCCCC' + +interface ElevationFlagProps { + testId?: string + percent: number +} + +const ElevationFlag = ({ percent, testId }: ElevationFlagProps) => { + return ( + + + + + {percent}% + + + + + ) +} + +export default React.memo(ElevationFlag) diff --git a/web/src/features/fba/components/viz/ElevationLabel.tsx b/web/src/features/fba/components/viz/ElevationLabel.tsx new file mode 100644 index 000000000..c2efeb139 --- /dev/null +++ b/web/src/features/fba/components/viz/ElevationLabel.tsx @@ -0,0 +1,16 @@ +import React from 'react' +import { Grid, Typography } from '@mui/material' + +interface ElevationLabelProps { + label: string +} + +const ElevationLabel = ({ label }: ElevationLabelProps) => { + return ( + + {label} + + ) +} + +export default React.memo(ElevationLabel) diff --git a/web/src/features/fba/components/viz/ElevationStatus.tsx b/web/src/features/fba/components/viz/ElevationStatus.tsx new file mode 100644 index 000000000..503c0ef60 --- /dev/null +++ b/web/src/features/fba/components/viz/ElevationStatus.tsx @@ -0,0 +1,74 @@ +import React from 'react' +import { useTheme } from '@mui/material/styles' +import Grid from '@mui/material/Unstable_Grid2' +import Typography from '@mui/material/Typography' +import ElevationFlag from 'features/fba/components/viz/ElevationFlag' +import ElevationLabel from 'features/fba/components/viz/ElevationLabel' +import TPIMountain from 'features/fba/components/viz/TPIMountain' +import { Box } from '@mui/material' +import { FireZoneTPIStats } from '@/api/fbaAPI' + +enum ElevationOption { + BOTTOM = 'Valley Bottom', + MID = 'Mid Slope', + Upper = 'Upper Slope' +} + +interface ElevationStatusProps { + tpiStats: Required +} + +const ElevationStatus = ({ tpiStats }: ElevationStatusProps) => { + const theme = useTheme() + const total = tpiStats.mid_slope + tpiStats.upper_slope + tpiStats.valley_bottom + const mid_percent = tpiStats.mid_slope === 0 ? 0 : Math.round((tpiStats.mid_slope / total) * 100) + const upper_percent = tpiStats.upper_slope === 0 ? 0 : Math.round((tpiStats.upper_slope / total) * 100) + const bottom_percent = tpiStats.valley_bottom === 0 ? 0 : Math.round((tpiStats.valley_bottom / total) * 100) + return ( + + + + + + Topographic Position: + + + + + + + + + + + + + + + Proportion of Advisory Area: + + + + + + + + + ) +} + +export default ElevationStatus diff --git a/web/src/features/fba/components/viz/FuelDistribution.tsx b/web/src/features/fba/components/viz/FuelDistribution.tsx index 84b67e26b..2c6de17ca 100644 --- a/web/src/features/fba/components/viz/FuelDistribution.tsx +++ b/web/src/features/fba/components/viz/FuelDistribution.tsx @@ -1,4 +1,4 @@ -import { Box } from '@mui/material' +import { Box, Tooltip } from '@mui/material' import React from 'react' import { getColorByFuelTypeCode } from 'features/fba/components/viz/color' @@ -10,10 +10,12 @@ interface FuelDistributionProps { // Represents the percent contribution of the given fuel type to the overall high HFI area. const FuelDistribution = ({ code, percent }: FuelDistributionProps) => { return ( - + + + ) } diff --git a/web/src/features/fba/components/viz/FuelSummary.tsx b/web/src/features/fba/components/viz/FuelSummary.tsx index 1a7b3ed52..09394449c 100644 --- a/web/src/features/fba/components/viz/FuelSummary.tsx +++ b/web/src/features/fba/components/viz/FuelSummary.tsx @@ -1,16 +1,16 @@ import React, { useEffect, useState } from 'react' -import { FireShape, FireZoneThresholdFuelTypeArea } from 'api/fbaAPI' +import { FireShape, FireZoneFuelStats } from 'api/fbaAPI' import { Box, Tooltip, Typography } from '@mui/material' import { groupBy, isUndefined } from 'lodash' -import { DateTime } from 'luxon' import FuelDistribution from 'features/fba/components/viz/FuelDistribution' -import { DataGridPro, GridColDef, GridRenderCellParams } from '@mui/x-data-grid-pro' -import { useTheme } from '@mui/material/styles' +import { DataGridPro, GridColDef, GridColumnHeaderParams, GridRenderCellParams } from '@mui/x-data-grid-pro' +import { styled, useTheme } from '@mui/material/styles' +import CriticalHours from 'features/fba/components/viz/CriticalHours' export interface FuelTypeInfoSummary { area: number - criticalHoursStart?: DateTime - criticalHoursEnd?: DateTime + criticalHoursStart?: number + criticalHoursEnd?: number id: number code: string description: string @@ -19,18 +19,27 @@ export interface FuelTypeInfoSummary { } interface FuelSummaryProps { - fuelTypeInfo: Record + fireZoneFuelStats: Record selectedFireZoneUnit: FireShape | undefined } -// Column definitions for fire zone unit fuel summary table +const StyledHeader = styled('div')({ + whiteSpace: 'normal', + wordWrap: 'break-word', + textAlign: 'center', + fontSize: '0.75rem', + fontWeight: '700' +}) + +// Column definitions for fire zone unit fuel summary table const columns: GridColDef[] = [ { field: 'code', headerClassName: 'fuel-summary-header', headerName: 'Fuel Type', sortable: false, - width: 75, + minWidth: 80, + renderHeader: (params: GridColumnHeaderParams) => {params.colDef.headerName}, renderCell: (params: GridRenderCellParams) => ( {params.row[params.field]} @@ -39,28 +48,39 @@ const columns: GridColDef[] = [ }, { field: 'area', - flex: 3, + flex: 1, headerClassName: 'fuel-summary-header', headerName: 'Distribution > 4k kW/m', - minWidth: 200, sortable: false, + renderHeader: (params: GridColumnHeaderParams) => {params.colDef.headerName}, renderCell: (params: GridRenderCellParams) => { return } + }, + { + field: 'criticalHours', + headerClassName: 'fuel-summary-header', + headerName: 'Critical Hours', + minWidth: 110, + sortable: false, + renderHeader: (params: GridColumnHeaderParams) => {params.colDef.headerName}, + renderCell: (params: GridRenderCellParams) => { + return + } } ] -const FuelSummary = ({ fuelTypeInfo, selectedFireZoneUnit }: FuelSummaryProps) => { +const FuelSummary = ({ fireZoneFuelStats, selectedFireZoneUnit }: FuelSummaryProps) => { const theme = useTheme() const [fuelTypeInfoRollup, setFuelTypeInfoRollup] = useState([]) useEffect(() => { - if (isUndefined(fuelTypeInfo) || isUndefined(selectedFireZoneUnit)) { + if (isUndefined(fireZoneFuelStats) || isUndefined(selectedFireZoneUnit)) { setFuelTypeInfoRollup([]) return } const shapeId = selectedFireZoneUnit.fire_shape_id - const fuelDetails = fuelTypeInfo[shapeId] + const fuelDetails = fireZoneFuelStats[shapeId] if (isUndefined(fuelDetails)) { setFuelTypeInfoRollup([]) return @@ -77,10 +97,14 @@ const FuelSummary = ({ fuelTypeInfo, selectedFireZoneUnit }: FuelSummaryProps) = if (groupedFuelDetail.length) { const area = groupedFuelDetail.reduce((acc, { area }) => acc + area, 0) const fuelType = groupedFuelDetail[0].fuel_type + const startTime = groupedFuelDetail[0].critical_hours.start_time + const endTime = groupedFuelDetail[0].critical_hours.end_time const fuelInfo: FuelTypeInfoSummary = { area, code: fuelType.fuel_type_code, description: fuelType.description, + criticalHoursStart: startTime, + criticalHoursEnd: endTime, id: fuelType.fuel_type_id, percent: totalHFIArea4K ? (area / totalHFIArea4K) * 100 : 0, selected: false @@ -89,13 +113,10 @@ const FuelSummary = ({ fuelTypeInfo, selectedFireZoneUnit }: FuelSummaryProps) = } } setFuelTypeInfoRollup(rollUp) - }, [fuelTypeInfo]) // eslint-disable-line react-hooks/exhaustive-deps + }, [fireZoneFuelStats]) // eslint-disable-line react-hooks/exhaustive-deps return ( - - HFI Distribution by Fuel Type - {fuelTypeInfoRollup.length === 0 ? ( No fuel type information available. ) : ( @@ -115,6 +136,7 @@ const FuelSummary = ({ fuelTypeInfo, selectedFireZoneUnit }: FuelSummaryProps) = showCellVerticalBorder showColumnVerticalBorder sx={{ + backgroundColor: 'white', maxHeight: '147px', minHeight: '100px', overflow: 'hidden', diff --git a/web/src/features/fba/components/viz/TPIMountain.tsx b/web/src/features/fba/components/viz/TPIMountain.tsx new file mode 100644 index 000000000..64f59355d --- /dev/null +++ b/web/src/features/fba/components/viz/TPIMountain.tsx @@ -0,0 +1,30 @@ +import React from 'react' + +export const TPIMountain = () => { + return ( + + + + + + + + ) +} + +export default React.memo(TPIMountain) diff --git a/web/src/features/fba/components/viz/criticalHours.test.tsx b/web/src/features/fba/components/viz/criticalHours.test.tsx new file mode 100644 index 000000000..006612b7c --- /dev/null +++ b/web/src/features/fba/components/viz/criticalHours.test.tsx @@ -0,0 +1,25 @@ +import React from 'react' +import { render } from '@testing-library/react' +import CriticalHours from '@/features/fba/components/viz/CriticalHours' + +describe('CriticalHours', () => { + it('should render hours in 24 hour format', () => { + const { getByTestId } = render( + + ) + + const element = getByTestId('critical-hours') + expect(element).toBeInTheDocument() + expect(element).toHaveTextContent("8:00 - 11:00") + }) + + it('should render no critical hours', () => { + const { getByTestId } = render( + + ) + + const element = getByTestId('critical-hours') + expect(element).toBeInTheDocument() + expect(element).toHaveTextContent("-") + }) +}) diff --git a/web/src/features/fba/components/viz/elevationFlag.test.tsx b/web/src/features/fba/components/viz/elevationFlag.test.tsx new file mode 100644 index 000000000..b663af312 --- /dev/null +++ b/web/src/features/fba/components/viz/elevationFlag.test.tsx @@ -0,0 +1,15 @@ +import React from 'react' +import { render } from '@testing-library/react' +import ElevationFlag from 'features/fba/components/viz/ElevationFlag' + +describe('ElevationFlag', () => { + it('should have width relative to parent', () => { + const { getByTestId } = render( + + ) + + const element = getByTestId('valley-bottom') + expect(element).toBeInTheDocument() + expect(element).toHaveTextContent("50%") + }) +}) diff --git a/web/src/features/fba/components/viz/elevationStatus.test.tsx b/web/src/features/fba/components/viz/elevationStatus.test.tsx new file mode 100644 index 000000000..78c1d7832 --- /dev/null +++ b/web/src/features/fba/components/viz/elevationStatus.test.tsx @@ -0,0 +1,44 @@ +import React from 'react' +import { render } from '@testing-library/react' +import ElevationStatus from 'features/fba/components/viz/ElevationStatus' + +describe('ElevationStatus', () => { + it('should render all classifications and svg', () => { + const { getByTestId } = render( + + ) + + const tpiMountain = getByTestId('tpi-mountain') + expect(tpiMountain).toBeInTheDocument() + + const valleyBottom = getByTestId('valley-bottom') + expect(valleyBottom).toBeInTheDocument() + expect(valleyBottom).toHaveTextContent('0%') + + const midSlope = getByTestId('mid-slope') + expect(midSlope).toBeInTheDocument() + expect(midSlope).toHaveTextContent('33%') + + const upperSlope = getByTestId('upper-slope') + expect(upperSlope).toBeInTheDocument() + expect(upperSlope).toHaveTextContent('67%') + }) + + it('should render all zero classifications', () => { + const { getByTestId } = render( + + ) + + const valleyBottom = getByTestId('valley-bottom') + expect(valleyBottom).toBeInTheDocument() + expect(valleyBottom).toHaveTextContent('0%') + + const midSlope = getByTestId('mid-slope') + expect(midSlope).toBeInTheDocument() + expect(midSlope).toHaveTextContent('0%') + + const upperSlope = getByTestId('upper-slope') + expect(upperSlope).toBeInTheDocument() + expect(upperSlope).toHaveTextContent('0%') + }) +}) diff --git a/web/src/features/fba/components/viz/fuelDistribution.test.tsx b/web/src/features/fba/components/viz/fuelDistribution.test.tsx index 9217fe146..2a1255277 100644 --- a/web/src/features/fba/components/viz/fuelDistribution.test.tsx +++ b/web/src/features/fba/components/viz/fuelDistribution.test.tsx @@ -1,4 +1,4 @@ -import React from 'react' + import { render } from '@testing-library/react' import FuelDistribution from 'features/fba/components/viz/FuelDistribution' diff --git a/web/src/features/fba/cqlBuilder.ts b/web/src/features/fba/cqlBuilder.ts index f3da1d198..81ba2c128 100644 --- a/web/src/features/fba/cqlBuilder.ts +++ b/web/src/features/fba/cqlBuilder.ts @@ -1,4 +1,4 @@ -import { RunType } from 'features/fba/pages/FireBehaviourAdvisoryPage' +import { RunType } from '@/api/fbaAPI' import { DateTime } from 'luxon' /** diff --git a/web/src/features/fba/fireZoneUnitExtents.ts b/web/src/features/fba/fireZoneUnitExtents.ts new file mode 100644 index 000000000..81bd1dbb1 --- /dev/null +++ b/web/src/features/fba/fireZoneUnitExtents.ts @@ -0,0 +1,47 @@ +// Generated with postGIS query: +// SELECT source_identifier, ST_Extent(ST_Transform(geom, 3857)) AS bbox +// FROM advisory_shapes +// GROUP BY source_identifier + +export const fireZoneExtentsMap = new Map([ + ['9', [-1.2990446453409778e7, 6274667.515325945, -1.2696450026696604e7, 6548411.326476511]], + ['8', [-1.4239393527168721e7, 6354692.628401642, -1.3885628823239345e7, 6614726.5444521075]], + ['7', [-1.3804737218859635e7, 6344805.590225804, -1.3585455009676876e7, 6593252.999027362]], + ['6', [-1.3507294147147883e7, 6274897.967954165, -1.334774839441576e7, 6537705.402908283]], + ['5', [-1.3409245893438928e7, 6274766.742380009, -1.3220235499035008e7, 6481949.463285208]], + ['40', [-1.5480234287311064e7, 7577642.392997014, -1.4112365951461505e7, 8400167.169926997]], + ['4', [-1.3751776569011472e7, 6274402.126349792, -1.3424911887489706e7, 6472043.652311066]], + ['39', [-1.4351962980972972e7, 7853828.243202188, -1.3358477755491769e7, 8400802.80211162]], + ['38', [-1.3827793521976218e7, 7576304.957512168, -1.3358479304175606e7, 8088009.006763926]], + ['37', [-1.4219800072231779e7, 7336065.278003179, -1.360854082549229e7, 8109561.9143451]], + ['36', [-1.4336781245129095e7, 7204442.79685761, -1.371169548188525e7, 7820500.999762873]], + ['35', [-1.450768537050546e7, 6924210.901583922, -1.416150856031427e7, 7798283.80675702]], + ['34', [-1.3693165305696633e7, 7189897.212373013, -1.3358474603134027e7, 7625578.3247031225]], + ['33', [-1.4342555408019697e7, 7325141.207085127, -1.4118247090158451e7, 7608285.9692941625]], + ['32', [-1.4264130993376458e7, 7251562.216603748, -1.4069094425244572e7, 7559646.143026362]], + ['31', [-1.465995968053417e7, 6791108.103214191, -1.4265017487204237e7, 7547089.7768202685]], + ['30', [-1.425624634417718e7, 7053861.425825395, -1.3984738982186943e7, 7463618.604611745]], + ['3', [-1.3280559041253906e7, 6274868.297736709, -1.3139838132862406e7, 6431584.604443101]], + ['29', [-1.3814924725325251e7, 7005134.697469937, -1.3358483054930253e7, 7402698.959496517]], + ['28', [-1.4178582016564626e7, 6977090.497498701, -1.3900327488148177e7, 7357120.120208108]], + ['27', [-1.4830653531238658e7, 6661969.272163259, -1.4438514540166311e7, 7303487.644342587]], + ['26', [-1.4001085155298334e7, 6967533.04054967, -1.373221467010641e7, 7256396.031430607]], + ['25', [-1.3473946106509669e7, 6800425.7877606945, -1.3157325389145028e7, 7133536.671827988]], + ['24', [-1.3980018237322483e7, 6868194.473228056, -1.3417375254083173e7, 7071337.897894138]], + ['23', [-1.4454616078859169e7, 6648018.459231251, -1.3979012866164453e7, 7039647.156581514]], + ['22', [-1.3594776668727398e7, 6798564.728984073, -1.3373863782521151e7, 6981899.065810441]], + ['21', [-1.4001084621837731e7, 6599377.316962451, -1.365754947442412e7, 6975295.812021961]], + ['20', [-1.3752430898725286e7, 6633727.7494424265, -1.353171475899193e7, 6930819.026353851]], + ['2', [-1.4102327737002596e7, 6190322.438244796, -1.3739154606700154e7, 6398124.051320654]], + ['19', [-1.3246186938489951e7, 6521438.3495329935, -1.289470341755373e7, 6890528.452262035]], + ['18', [-1.3613238848690292e7, 6613230.764917851, -1.3383569376688499e7, 6834856.728304669]], + ['17', [-1.4439282220115436e7, 6427871.498074888, -1.39374621994989e7, 6789082.8767122775]], + ['16', [-1.3551993942676567e7, 6500313.4256845005, -1.3230031995602995e7, 6960226.369767584]], + ['15', [-1.30612924356551e7, 6406284.152275828, -1.280735574931457e7, 6672210.129222219]], + ['14', [-1.318278203880094e7, 6274659.0787313515, -1.3025107275702618e7, 6641088.49173979]], + ['13', [-1.3086349552941706e7, 6274700.946005805, -1.288249024528788e7, 6634238.803499517]], + ['12', [-1.3773818093438104e7, 6450265.879861334, -1.3501267252202304e7, 6671039.04498325]], + ['11', [-1.395267371568761e7, 6313474.478500485, -1.372014151941535e7, 6679343.359690739]], + ['10', [-1.3370320686968567e7, 6415289.471154898, -1.3151381999755923e7, 6745929.533973716]], + ['1', [-1.3885884887820601e7, 6144341.120806807, -1.3693291829990938e7, 6304097.619536648]] +]) diff --git a/web/src/features/fba/hooks/useFireCentreDetails.ts b/web/src/features/fba/hooks/useFireCentreDetails.ts new file mode 100644 index 000000000..5c85ccb0a --- /dev/null +++ b/web/src/features/fba/hooks/useFireCentreDetails.ts @@ -0,0 +1,39 @@ +import { useMemo } from 'react' +import { useSelector } from 'react-redux' +import { groupBy } from 'lodash' +import { FireCenter, FireShapeAreaDetail } from 'api/fbaAPI' +import { selectProvincialSummary } from 'features/fba/slices/provincialSummarySlice' + +export interface GroupedFireZoneUnitDetails { + fire_shape_id: number + fire_shape_name: string + fire_centre_name: string + fireShapeDetails: FireShapeAreaDetail[] +} + +/** + * Hook for grabbing a fire centre from the provincial summary, grouping by unique 'fire_shape_id' and + * providing easy access to the shape name, centre, and FireShapeAreaDetails for calculating zone status + * + * @param selectedFireCenter + * @returns + */ +export const useFireCentreDetails = (selectedFireCenter: FireCenter | undefined): GroupedFireZoneUnitDetails[] => { + const provincialSummary = useSelector(selectProvincialSummary) + + return useMemo(() => { + if (!selectedFireCenter) return [] + + const fireCenterSummary = provincialSummary[selectedFireCenter.name] || [] + const groupedFireZoneUnits = groupBy(fireCenterSummary, 'fire_shape_id') + + return Object.values(groupedFireZoneUnits) + .map(group => ({ + fire_shape_id: group[0].fire_shape_id, + fire_shape_name: group[0].fire_shape_name, + fire_centre_name: group[0].fire_centre_name, + fireShapeDetails: group + })) + .sort((a, b) => a.fire_shape_name.localeCompare(b.fire_shape_name)) + }, [selectedFireCenter, provincialSummary]) +} diff --git a/web/src/features/fba/pages/FireBehaviourAdvisoryPage.tsx b/web/src/features/fba/pages/FireBehaviourAdvisoryPage.tsx index 29939ceba..e81dcf506 100644 --- a/web/src/features/fba/pages/FireBehaviourAdvisoryPage.tsx +++ b/web/src/features/fba/pages/FireBehaviourAdvisoryPage.tsx @@ -1,63 +1,45 @@ -import { Box, FormControl, FormControlLabel, Grid, styled } from '@mui/material' +import { Box, FormControl, Grid, styled } from '@mui/material' import { GeneralHeader, ErrorBoundary } from 'components' import React, { useEffect, useState } from 'react' import FBAMap from 'features/fba/components/map/FBAMap' import FireCenterDropdown from 'components/FireCenterDropdown' import { DateTime } from 'luxon' -import { - selectFireZoneElevationInfo, - selectFireCenters, - selectHFIFuelTypes, - selectRunDates, - selectFireShapeAreas -} from 'app/rootReducer' +import { selectFireCenters, selectRunDates, selectFireShapeAreas } from 'app/rootReducer' import { useDispatch, useSelector } from 'react-redux' import { fetchFireCenters } from 'commonSlices/fireCentersSlice' import { theme } from 'app/theme' import { fetchWxStations } from 'features/stations/slices/stationsSlice' import { getStations, StationSource } from 'api/stationAPI' -import { FireCenter, FireShape } from 'api/fbaAPI' +import { FireCenter, FireShape, RunType } from 'api/fbaAPI' import { ASA_DOC_TITLE, FIRE_BEHAVIOUR_ADVISORY_NAME, PST_UTC_OFFSET } from 'utils/constants' import WPSDatePicker from 'components/WPSDatePicker' import { AppDispatch } from 'app/store' -import AdvisoryThresholdSlider from 'features/fba/components/map/AdvisoryThresholdSlider' -import AdvisoryMetadata from 'features/fba/components/AdvisoryMetadata' +import ActualForecastControl from 'features/fba/components/ActualForecastControl' import { fetchSFMSRunDates } from 'features/fba/slices/runDatesSlice' import { isNull, isUndefined } from 'lodash' -import { fetchHighHFIFuels } from 'features/fba/slices/hfiFuelTypesSlice' import { fetchFireShapeAreas } from 'features/fba/slices/fireZoneAreasSlice' -import { fetchfireZoneElevationInfo } from 'features/fba/slices/fireZoneElevationInfoSlice' import { StyledFormControl } from 'components/StyledFormControl' -import { getMostRecentProcessedSnowByDate } from 'api/snow' import InfoPanel from 'features/fba/components/infoPanel/InfoPanel' -import FireZoneUnitSummary from 'features/fba/components/infoPanel/FireZoneUnitSummary' import { fetchProvincialSummary } from 'features/fba/slices/provincialSummarySlice' import AdvisoryReport from 'features/fba/components/infoPanel/AdvisoryReport' +import FireZoneUnitTabs from 'features/fba/components/infoPanel/FireZoneUnitTabs' +import { fetchFireCentreTPIStats } from 'features/fba/slices/fireCentreTPIStatsSlice' +import AboutDataPopover from 'features/fba/components/AboutDataPopover' +import { fetchFireCentreHFIFuelStats } from 'features/fba/slices/fireCentreHFIFuelStatsSlice' -export enum RunType { - FORECAST = 'FORECAST', - ACTUAL = 'ACTUAL' -} +const ADVISORY_THRESHOLD = 20 export const FireCentreFormControl = styled(FormControl)({ margin: theme.spacing(1), minWidth: 280 }) -export const ForecastActualDropdownFormControl = styled(FormControl)({ - margin: theme.spacing(1), - minWidth: 280 -}) - const FireBehaviourAdvisoryPage: React.FunctionComponent = () => { const dispatch: AppDispatch = useDispatch() const { fireCenters } = useSelector(selectFireCenters) - const { hfiThresholdsFuelTypes } = useSelector(selectHFIFuelTypes) - const { fireZoneElevationInfo } = useSelector(selectFireZoneElevationInfo) const [fireCenter, setFireCenter] = useState(undefined) - const [advisoryThreshold, setAdvisoryThreshold] = useState(20) const [selectedFireShape, setSelectedFireShape] = useState(undefined) const [zoomSource, setZoomSource] = useState<'fireCenter' | 'fireShape' | undefined>('fireCenter') const [dateOfInterest, setDateOfInterest] = useState( @@ -66,21 +48,9 @@ const FireBehaviourAdvisoryPage: React.FunctionComponent = () => { : DateTime.now().setZone(`UTC${PST_UTC_OFFSET}`).plus({ days: 1 }) ) const [runType, setRunType] = useState(RunType.FORECAST) - const [snowDate, setSnowDate] = useState(null) const { mostRecentRunDate } = useSelector(selectRunDates) const { fireShapeAreas } = useSelector(selectFireShapeAreas) - // Query our API for the most recently processed snow coverage date <= the currently selected date. - const fetchLastProcessedSnow = async (selectedDate: DateTime) => { - const data = await getMostRecentProcessedSnowByDate(selectedDate) - if (isNull(data)) { - setSnowDate(null) - } else { - const newSnowDate = data.forDate - setSnowDate(newSnowDate) - } - } - useEffect(() => { const findCenter = (id: string | null): FireCenter | undefined => { return fireCenters.find(center => center.id.toString() == id) @@ -94,6 +64,16 @@ const FireBehaviourAdvisoryPage: React.FunctionComponent = () => { } }, [fireCenter]) + useEffect(() => { + if (selectedFireShape?.mof_fire_centre_name) { + const matchingFireCenter = fireCenters.find(center => center.name === selectedFireShape.mof_fire_centre_name) + + if (matchingFireCenter) { + setFireCenter(matchingFireCenter) + } + } + }, [selectedFireShape, fireCenters]) + const updateDate = (newDate: DateTime) => { if (newDate !== dateOfInterest) { setDateOfInterest(newDate) @@ -121,7 +101,6 @@ const FireBehaviourAdvisoryPage: React.FunctionComponent = () => { if (!isNull(doiISODate)) { dispatch(fetchSFMSRunDates(runType, doiISODate)) } - fetchLastProcessedSnow(dateOfInterest) }, [dateOfInterest]) // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { @@ -130,14 +109,13 @@ const FireBehaviourAdvisoryPage: React.FunctionComponent = () => { !isNull(mostRecentRunDate) && !isNull(doiISODate) && !isUndefined(mostRecentRunDate) && - !isUndefined(selectedFireShape) + !isUndefined(fireCenter) && + !isNull(fireCenter) ) { - dispatch(fetchHighHFIFuels(runType, doiISODate, mostRecentRunDate.toString(), selectedFireShape.fire_shape_id)) - dispatch( - fetchfireZoneElevationInfo(selectedFireShape.fire_shape_id, runType, doiISODate, mostRecentRunDate.toString()) - ) + dispatch(fetchFireCentreTPIStats(fireCenter.name, runType, doiISODate, mostRecentRunDate.toString())) + dispatch(fetchFireCentreHFIFuelStats(fireCenter.name, runType, doiISODate, mostRecentRunDate.toString())) } - }, [mostRecentRunDate, selectedFireShape]) // eslint-disable-line react-hooks/exhaustive-deps + }, [fireCenter, mostRecentRunDate]) useEffect(() => { const doiISODate = dateOfInterest.toISODate() @@ -147,16 +125,6 @@ const FireBehaviourAdvisoryPage: React.FunctionComponent = () => { } }, [mostRecentRunDate]) // eslint-disable-line react-hooks/exhaustive-deps - useEffect(() => { - if (selectedFireShape?.mof_fire_centre_name) { - const matchingFireCenter = fireCenters.find(center => center.name === selectedFireShape.mof_fire_centre_name) - - if (matchingFireCenter) { - setFireCenter(matchingFireCenter) - } - } - }, [selectedFireShape, fireCenters]) - useEffect(() => { document.title = ASA_DOC_TITLE }, []) @@ -170,12 +138,17 @@ const FireBehaviourAdvisoryPage: React.FunctionComponent = () => { productName={FIRE_BEHAVIOUR_ADVISORY_NAME} /> - + + + + + + { /> - - - - - - - - - - - } - /> - + + @@ -216,14 +170,16 @@ const FireBehaviourAdvisoryPage: React.FunctionComponent = () => { - @@ -232,10 +188,9 @@ const FireBehaviourAdvisoryPage: React.FunctionComponent = () => { runType={runType} selectedFireShape={selectedFireShape} selectedFireCenter={fireCenter} - advisoryThreshold={advisoryThreshold} + advisoryThreshold={ADVISORY_THRESHOLD} setSelectedFireShape={setSelectedFireShape} fireShapeAreas={fireShapeAreas} - snowDate={snowDate} zoomSource={zoomSource} setZoomSource={setZoomSource} /> diff --git a/web/src/features/fba/pmtilesBuilder.ts b/web/src/features/fba/pmtilesBuilder.ts index b39c61b6f..1adabd071 100644 --- a/web/src/features/fba/pmtilesBuilder.ts +++ b/web/src/features/fba/pmtilesBuilder.ts @@ -1,4 +1,4 @@ -import { RunType } from 'features/fba/pages/FireBehaviourAdvisoryPage' +import { RunType } from '@/api/fbaAPI' import { DateTime } from 'luxon' import { PMTILES_BUCKET } from 'utils/env' @@ -16,15 +16,3 @@ export const buildPMTilesURL = (for_date: DateTime, run_type: RunType, run_date: return PMTilesURL } - -/** - * Builds the URL for snow coverage pmtiles layers. - * @param snowDate The target date for snow coverage. - * @returns A URL to the snow coverage PMTiles stored in S3 - */ -export const buildSnowPMTilesURL = (snowDate: DateTime) => { - const snowPMTilesUrl = `${PMTILES_BUCKET}snow/${snowDate.toISODate()}/snowCoverage${snowDate.toISODate({ - format: 'basic' - })}.pmtiles` - return snowPMTilesUrl -} diff --git a/web/src/features/fba/slices/fireCentreHFIFuelStatsSlice.ts b/web/src/features/fba/slices/fireCentreHFIFuelStatsSlice.ts new file mode 100644 index 000000000..f02cb2ee2 --- /dev/null +++ b/web/src/features/fba/slices/fireCentreHFIFuelStatsSlice.ts @@ -0,0 +1,51 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit' + +import { AppThunk } from 'app/store' +import { logError } from 'utils/error' +import { FireCentreHFIStats, getFireCentreHFIStats, RunType } from 'api/fbaAPI' + +export interface FireCentreHFIFuelStatsState { + error: string | null + fireCentreHFIFuelStats: FireCentreHFIStats +} + +export const initialState: FireCentreHFIFuelStatsState = { + error: null, + fireCentreHFIFuelStats: {} +} + +const fireCentreHFIFuelStatsSlice = createSlice({ + name: 'fireCentreHfiFuelStats', + initialState, + reducers: { + getFireCentreHFIFuelStatsStart(state: FireCentreHFIFuelStatsState) { + state.error = null + state.fireCentreHFIFuelStats = {} + }, + getFireCentreHFIFuelStatsFailed(state: FireCentreHFIFuelStatsState, action: PayloadAction) { + state.error = action.payload + }, + getFireCentreHFIFuelStatsSuccess(state: FireCentreHFIFuelStatsState, action: PayloadAction) { + state.error = null + state.fireCentreHFIFuelStats = action.payload + } + } +}) + +export const { getFireCentreHFIFuelStatsStart, getFireCentreHFIFuelStatsFailed, getFireCentreHFIFuelStatsSuccess } = + fireCentreHFIFuelStatsSlice.actions + +export default fireCentreHFIFuelStatsSlice.reducer + +export const fetchFireCentreHFIFuelStats = + (fireCentre: string, runType: RunType, forDate: string, runDatetime: string): AppThunk => + async dispatch => { + try { + dispatch(getFireCentreHFIFuelStatsStart()) + const data = await getFireCentreHFIStats(runType, forDate, runDatetime, fireCentre) + dispatch(getFireCentreHFIFuelStatsSuccess(data)) + } catch (err) { + dispatch(getFireCentreHFIFuelStatsFailed((err as Error).toString())) + logError(err) + } + } diff --git a/web/src/features/fba/slices/fireCentreTPIStatsSlice.ts b/web/src/features/fba/slices/fireCentreTPIStatsSlice.ts new file mode 100644 index 000000000..3f28f3c93 --- /dev/null +++ b/web/src/features/fba/slices/fireCentreTPIStatsSlice.ts @@ -0,0 +1,54 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit' + +import { AppThunk } from 'app/store' +import { logError } from 'utils/error' +import { FireZoneTPIStats, getFireCentreTPIStats, RunType } from 'api/fbaAPI' + +export interface CentreTPIStatsState { + error: string | null + fireCentreTPIStats: Record | null +} + +export const initialState: CentreTPIStatsState = { + error: null, + fireCentreTPIStats: null +} + +const fireCentreTPIStatsSlice = createSlice({ + name: 'fireCentreTPIStats', + initialState, + reducers: { + getFireCentreTPIStatsStart(state: CentreTPIStatsState) { + state.error = null + state.fireCentreTPIStats = null + }, + getFireCentreTPIStatsFailed(state: CentreTPIStatsState, action: PayloadAction) { + state.error = action.payload + }, + getFireCentreTPIStatsSuccess( + state: CentreTPIStatsState, + action: PayloadAction> + ) { + state.error = null + state.fireCentreTPIStats = action.payload + } + } +}) + +export const { getFireCentreTPIStatsStart, getFireCentreTPIStatsFailed, getFireCentreTPIStatsSuccess } = + fireCentreTPIStatsSlice.actions + +export default fireCentreTPIStatsSlice.reducer + +export const fetchFireCentreTPIStats = + (fireCentre: string, runType: RunType, forDate: string, runDatetime: string): AppThunk => + async dispatch => { + try { + dispatch(getFireCentreTPIStatsStart()) + const fireCentreTPIStats = await getFireCentreTPIStats(fireCentre, runType, forDate, runDatetime) + dispatch(getFireCentreTPIStatsSuccess(fireCentreTPIStats)) + } catch (err) { + dispatch(getFireCentreTPIStatsFailed((err as Error).toString())) + logError(err) + } + } diff --git a/web/src/features/fba/slices/fireZoneAreasSlice.ts b/web/src/features/fba/slices/fireZoneAreasSlice.ts index 4d0f89b73..27044ada2 100644 --- a/web/src/features/fba/slices/fireZoneAreasSlice.ts +++ b/web/src/features/fba/slices/fireZoneAreasSlice.ts @@ -2,17 +2,16 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit' import { AppThunk } from 'app/store' import { logError } from 'utils/error' -import { FireShapeArea, FireShapeAreaListResponse, getFireShapeAreas } from 'api/fbaAPI' -import { RunType } from 'features/fba/pages/FireBehaviourAdvisoryPage' +import { FireShapeArea, FireShapeAreaListResponse, getFireShapeAreas, RunType } from 'api/fbaAPI' import { isNull, isUndefined } from 'lodash' -interface State { +export interface FireZoneAreasState { loading: boolean error: string | null fireShapeAreas: FireShapeArea[] } -const initialState: State = { +const initialState: FireZoneAreasState = { loading: false, error: null, fireShapeAreas: [] @@ -22,16 +21,16 @@ const fireShapeAreasSlice = createSlice({ name: 'fireShapeAreas', initialState, reducers: { - getFireShapeAreasStart(state: State) { + getFireShapeAreasStart(state: FireZoneAreasState) { state.error = null state.loading = true state.fireShapeAreas = [] }, - getFireShapeAreasFailed(state: State, action: PayloadAction) { + getFireShapeAreasFailed(state: FireZoneAreasState, action: PayloadAction) { state.error = action.payload state.loading = false }, - getFireShapeAreasSuccess(state: State, action: PayloadAction) { + getFireShapeAreasSuccess(state: FireZoneAreasState, action: PayloadAction) { state.error = null state.fireShapeAreas = action.payload.shapes state.loading = false diff --git a/web/src/features/fba/slices/fireZoneElevationInfoSlice.ts b/web/src/features/fba/slices/fireZoneElevationInfoSlice.ts index 37be24350..4ac10bab3 100644 --- a/web/src/features/fba/slices/fireZoneElevationInfoSlice.ts +++ b/web/src/features/fba/slices/fireZoneElevationInfoSlice.ts @@ -2,16 +2,15 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit' import { AppThunk } from 'app/store' import { logError } from 'utils/error' -import { ElevationInfoByThreshold, FireZoneElevationInfoResponse, getFireZoneElevationInfo } from 'api/fbaAPI' -import { RunType } from 'features/fba/pages/FireBehaviourAdvisoryPage' +import { ElevationInfoByThreshold, FireZoneElevationInfoResponse, getFireZoneElevationInfo, RunType } from 'api/fbaAPI' -interface State { +export interface ZoneElevationInfoState { loading: boolean error: string | null fireZoneElevationInfo: ElevationInfoByThreshold[] } -const initialState: State = { +const initialState: ZoneElevationInfoState = { loading: false, error: null, fireZoneElevationInfo: [] @@ -21,16 +20,19 @@ const fireZoneElevationInfoSlice = createSlice({ name: 'fireZoneElevationInfo', initialState, reducers: { - getFireZoneElevationInfoStart(state: State) { + getFireZoneElevationInfoStart(state: ZoneElevationInfoState) { state.error = null state.fireZoneElevationInfo = [] state.loading = true }, - getFireZoneElevationInfoFailed(state: State, action: PayloadAction) { + getFireZoneElevationInfoFailed(state: ZoneElevationInfoState, action: PayloadAction) { state.error = action.payload state.loading = false }, - getFireZoneElevationInfoStartSuccess(state: State, action: PayloadAction) { + getFireZoneElevationInfoStartSuccess( + state: ZoneElevationInfoState, + action: PayloadAction + ) { state.error = null state.fireZoneElevationInfo = action.payload.hfi_elevation_info state.loading = false diff --git a/web/src/features/fba/slices/hfiFuelTypesSlice.ts b/web/src/features/fba/slices/hfiFuelTypesSlice.ts deleted file mode 100644 index ab801ab5c..000000000 --- a/web/src/features/fba/slices/hfiFuelTypesSlice.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { createSlice, PayloadAction } from '@reduxjs/toolkit' - -import { AppThunk } from 'app/store' -import { logError } from 'utils/error' -import { FireZoneThresholdFuelTypeArea, getHFIThresholdsFuelTypesForZone } from 'api/fbaAPI' -import { RunType } from 'features/fba/pages/FireBehaviourAdvisoryPage' - -interface State { - loading: boolean - error: string | null - hfiThresholdsFuelTypes: Record -} - -const initialState: State = { - loading: false, - error: null, - hfiThresholdsFuelTypes: {} -} - -const hfiFuelTypesSlice = createSlice({ - name: 'runDates', - initialState, - reducers: { - getHFIFuelsStart(state: State) { - state.error = null - state.loading = true - state.hfiThresholdsFuelTypes = {} - }, - getHFIFuelsFailed(state: State, action: PayloadAction) { - state.error = action.payload - state.loading = false - }, - getHFIFuelsStartSuccess(state: State, action: PayloadAction>) { - state.error = null - state.hfiThresholdsFuelTypes = action.payload - state.loading = false - } - } -}) - -export const { getHFIFuelsStart, getHFIFuelsFailed, getHFIFuelsStartSuccess } = hfiFuelTypesSlice.actions - -export default hfiFuelTypesSlice.reducer - -export const fetchHighHFIFuels = - (runType: RunType, forDate: string, runDatetime: string, zoneID: number): AppThunk => - async dispatch => { - try { - dispatch(getHFIFuelsStart()) - const data = await getHFIThresholdsFuelTypesForZone(runType, forDate, runDatetime, zoneID) - dispatch(getHFIFuelsStartSuccess(data)) - } catch (err) { - dispatch(getHFIFuelsFailed((err as Error).toString())) - logError(err) - } - } diff --git a/web/src/features/fba/slices/provincialSummarySlice.ts b/web/src/features/fba/slices/provincialSummarySlice.ts index 66590b7b3..f2da19214 100644 --- a/web/src/features/fba/slices/provincialSummarySlice.ts +++ b/web/src/features/fba/slices/provincialSummarySlice.ts @@ -2,8 +2,7 @@ import { createSelector, createSlice, PayloadAction } from '@reduxjs/toolkit' import { AppThunk } from 'app/store' import { logError } from 'utils/error' import { groupBy, isNull, isUndefined } from 'lodash' -import { FireShapeAreaDetail, getProvincialSummary, ProvincialSummaryResponse } from 'api/fbaAPI' -import { RunType } from 'features/fba/pages/FireBehaviourAdvisoryPage' +import { FireShapeAreaDetail, getProvincialSummary, ProvincialSummaryResponse, RunType } from 'api/fbaAPI' import { RootState } from 'app/rootReducer' export interface ProvincialSummaryState { diff --git a/web/src/features/fba/slices/runDatesSlice.ts b/web/src/features/fba/slices/runDatesSlice.ts index adb179037..51fcf65f5 100644 --- a/web/src/features/fba/slices/runDatesSlice.ts +++ b/web/src/features/fba/slices/runDatesSlice.ts @@ -2,18 +2,17 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit' import { AppThunk } from 'app/store' import { logError } from 'utils/error' -import { getAllRunDates, getMostRecentRunDate } from 'api/fbaAPI' +import { getAllRunDates, getMostRecentRunDate, RunType } from 'api/fbaAPI' import { DateTime } from 'luxon' -import { RunType } from 'features/fba/pages/FireBehaviourAdvisoryPage' -interface State { +export interface RunDateState { loading: boolean error: string | null runDates: DateTime[] mostRecentRunDate: string | null } -const initialState: State = { +const initialState: RunDateState = { loading: false, error: null, runDates: [], @@ -24,17 +23,20 @@ const runDatesSlice = createSlice({ name: 'runDates', initialState, reducers: { - getRunDatesStart(state: State) { + getRunDatesStart(state: RunDateState) { state.error = null state.loading = true state.runDates = [] state.mostRecentRunDate = null }, - getRunDatesFailed(state: State, action: PayloadAction) { + getRunDatesFailed(state: RunDateState, action: PayloadAction) { state.error = action.payload state.loading = false }, - getRunDatesSuccess(state: State, action: PayloadAction<{ runDates: DateTime[]; mostRecentRunDate: string }>) { + getRunDatesSuccess( + state: RunDateState, + action: PayloadAction<{ runDates: DateTime[]; mostRecentRunDate: string }> + ) { state.error = null state.runDates = action.payload.runDates state.mostRecentRunDate = action.payload.mostRecentRunDate diff --git a/web/src/features/fba/slices/valueAtCoordinateSlice.ts b/web/src/features/fba/slices/valueAtCoordinateSlice.ts index adc69f063..e04affccf 100644 --- a/web/src/features/fba/slices/valueAtCoordinateSlice.ts +++ b/web/src/features/fba/slices/valueAtCoordinateSlice.ts @@ -8,13 +8,13 @@ export interface IValueAtCoordinate { value: string | undefined description: string } -interface State { +export interface ValueAtCoordState { loading: boolean error: string | null values: IValueAtCoordinate[] } -const initialState: State = { +const initialState: ValueAtCoordState = { loading: false, error: null, values: [] @@ -24,16 +24,16 @@ const valueAtCoordinateSlice = createSlice({ name: 'valueAtCoordinate', initialState, reducers: { - getValueAtCoordinateStart(state: State) { + getValueAtCoordinateStart(state: ValueAtCoordState) { state.error = null state.loading = true state.values = [] }, - getValueAtCoordinateFailed(state: State, action: PayloadAction) { + getValueAtCoordinateFailed(state: ValueAtCoordState, action: PayloadAction) { state.error = action.payload state.loading = false }, - getValueAtCoordinateSuccess(state: State, action: PayloadAction) { + getValueAtCoordinateSuccess(state: ValueAtCoordState, action: PayloadAction) { state.error = null state.values = action.payload state.loading = false diff --git a/web/src/features/fbaCalculator/components/fbaProgressRow.test.tsx b/web/src/features/fbaCalculator/components/fbaProgressRow.test.tsx index c8cc12fc0..ce77e909a 100644 --- a/web/src/features/fbaCalculator/components/fbaProgressRow.test.tsx +++ b/web/src/features/fbaCalculator/components/fbaProgressRow.test.tsx @@ -1,7 +1,8 @@ +import React from 'react' import { TableContainer, Table, TableHead } from '@mui/material' import { render } from '@testing-library/react' import { theme } from 'app/theme' -import React from 'react' + import FBAProgressRow from 'features/fbaCalculator/components/FBAProgressRow' describe('FBAProgressRow', () => { diff --git a/web/src/features/fbaCalculator/components/grassCureCell.test.tsx b/web/src/features/fbaCalculator/components/grassCureCell.test.tsx index c09c8442c..95aa303a5 100644 --- a/web/src/features/fbaCalculator/components/grassCureCell.test.tsx +++ b/web/src/features/fbaCalculator/components/grassCureCell.test.tsx @@ -3,7 +3,7 @@ import GrassCureCell, { GrassCureCellProps } from 'features/fbaCalculator/compon import { FBAFuelType, FuelTypes } from 'features/fbaCalculator/fuelTypes' import { FBATableRow } from 'features/fbaCalculator/RowManager' import { isNull } from 'lodash' -import React from 'react' + describe('GrassCureCell', () => { const buildProps = (inputRow: FBATableRow, rowId: number, value?: number): GrassCureCellProps => ({ inputRows: [inputRow], diff --git a/web/src/features/fbaCalculator/components/loadingIndicatorCell.test.tsx b/web/src/features/fbaCalculator/components/loadingIndicatorCell.test.tsx index ecac84166..c75acb348 100644 --- a/web/src/features/fbaCalculator/components/loadingIndicatorCell.test.tsx +++ b/web/src/features/fbaCalculator/components/loadingIndicatorCell.test.tsx @@ -1,6 +1,6 @@ import { TableContainer, Table, TableBody, TableRow, TableCell } from '@mui/material' import { render } from '@testing-library/react' -import React from 'react' + import LoadingIndicatorCell, { LoadingIndicatorCellProps } from 'features/fbaCalculator/components/LoadingIndicatorCell' describe('LoadingIndicatorCell', () => { diff --git a/web/src/features/fbaCalculator/components/selectionCell.test.tsx b/web/src/features/fbaCalculator/components/selectionCell.test.tsx index 1225c9155..a659e30b6 100644 --- a/web/src/features/fbaCalculator/components/selectionCell.test.tsx +++ b/web/src/features/fbaCalculator/components/selectionCell.test.tsx @@ -1,6 +1,6 @@ import { TableContainer, Table, TableBody, TableRow, TableCell } from '@mui/material' import { render, within } from '@testing-library/react' -import React from 'react' + import SelectionCell from 'features/fbaCalculator/components/SelectionCell' describe('SelectionCell', () => { diff --git a/web/src/features/fbaCalculator/components/windSpeedCell.test.tsx b/web/src/features/fbaCalculator/components/windSpeedCell.test.tsx index 7da165488..69af5c423 100644 --- a/web/src/features/fbaCalculator/components/windSpeedCell.test.tsx +++ b/web/src/features/fbaCalculator/components/windSpeedCell.test.tsx @@ -1,7 +1,7 @@ import { render, screen } from '@testing-library/react' import WindSpeedCell, { WindSpeedCellProps } from 'features/fbaCalculator/components/WindSpeedCell' import { FBATableRow } from 'features/fbaCalculator/RowManager' -import React from 'react' + describe('WindSpeedCell', () => { const buildProps = (inputRow: FBATableRow, calculatedValue?: number): WindSpeedCellProps => ({ inputRows: [inputRow], diff --git a/web/src/features/fbaCalculator/slices/fbaCalculatorSlice.ts b/web/src/features/fbaCalculator/slices/fbaCalculatorSlice.ts index 0a2f9115d..185c64967 100644 --- a/web/src/features/fbaCalculator/slices/fbaCalculatorSlice.ts +++ b/web/src/features/fbaCalculator/slices/fbaCalculatorSlice.ts @@ -4,20 +4,20 @@ import { FBAStation, FBAWeatherStationsResponse, postFBAStations } from 'api/fba import { AppThunk } from 'app/store' import { logError } from 'utils/error' import { FuelTypes } from '../fuelTypes' -import { isEmpty, isEqual, isNull, isUndefined } from 'lodash' +import { isEmpty, isNil } from 'lodash' import { FBATableRow } from 'features/fbaCalculator/RowManager' import { DateTime } from 'luxon' import { PST_UTC_OFFSET } from 'utils/constants' import { pstFormatter } from 'utils/date' -interface State { +export interface FBACalcState { loading: boolean error: string | null fireBehaviourResultStations: FBAStation[] date: string | null } -const initialState: State = { +const initialState: FBACalcState = { loading: false, error: null, fireBehaviourResultStations: [], @@ -28,17 +28,17 @@ const fireBehaviourStationsSlice = createSlice({ name: 'fireBehaviourStations', initialState, reducers: { - getFireBehaviourStationsStart(state: State) { + getFireBehaviourStationsStart(state: FBACalcState) { state.error = null state.loading = true state.fireBehaviourResultStations = [] state.date = null }, - getFireBehaviourStationsFailed(state: State, action: PayloadAction) { + getFireBehaviourStationsFailed(state: FBACalcState, action: PayloadAction) { state.error = action.payload state.loading = false }, - getFireBehaviourStationsSuccess(state: State, action: PayloadAction) { + getFireBehaviourStationsSuccess(state: FBACalcState, action: PayloadAction) { state.error = null state.fireBehaviourResultStations = action.payload.stations state.date = DateTime.fromFormat(action.payload.date, 'yyyy/MM/dd') @@ -60,12 +60,7 @@ export const fetchFireBehaviourStations = async dispatch => { const fetchableFireStations = fbcInputRows.flatMap(row => { const fuelTypeDetails = FuelTypes.lookup(row.fuelType?.value) - if ( - isNull(fuelTypeDetails) || - isUndefined(fuelTypeDetails) || - isUndefined(row.weatherStation) || - isEqual(row.weatherStation, 'undefined') - ) { + if (isNil(fuelTypeDetails) || isNil(row.weatherStation)) { return [] } return { diff --git a/web/src/features/hfiCalculator/components/DayHeaders.test.tsx b/web/src/features/hfiCalculator/components/DayHeaders.test.tsx index 9ed01b89a..d9f1aae5b 100644 --- a/web/src/features/hfiCalculator/components/DayHeaders.test.tsx +++ b/web/src/features/hfiCalculator/components/DayHeaders.test.tsx @@ -5,7 +5,7 @@ import { DateTime } from 'luxon' import { range } from 'lodash' import { PrepDateRange } from 'features/hfiCalculator/slices/hfiCalculatorSlice' import { calculateNumPrepDays } from 'features/hfiCalculator/util' -import React from 'react' + import { pstFormatter } from 'utils/date' const prepCycleIteration = (dateRange: PrepDateRange) => { diff --git a/web/src/features/hfiCalculator/components/dailyHFICell.test.tsx b/web/src/features/hfiCalculator/components/dailyHFICell.test.tsx index 176425485..7d3af644c 100644 --- a/web/src/features/hfiCalculator/components/dailyHFICell.test.tsx +++ b/web/src/features/hfiCalculator/components/dailyHFICell.test.tsx @@ -1,7 +1,7 @@ import { Table, TableBody, TableContainer, TableRow } from '@mui/material' import { render } from '@testing-library/react' import { DailyHFICell } from 'features/hfiCalculator/components/DailyHFICell' -import React from 'react' + describe('DailyHFICell', () => { it('should render a calculated cell if there is an error', () => { diff --git a/web/src/features/hfiCalculator/components/dayIndexHeaders.test.tsx b/web/src/features/hfiCalculator/components/dayIndexHeaders.test.tsx index 06e13a17f..c40163e63 100644 --- a/web/src/features/hfiCalculator/components/dayIndexHeaders.test.tsx +++ b/web/src/features/hfiCalculator/components/dayIndexHeaders.test.tsx @@ -2,7 +2,7 @@ import { Table, TableBody, TableContainer, TableRow } from '@mui/material' import { render } from '@testing-library/react' import DayIndexHeaders from 'features/hfiCalculator/components/DayIndexHeaders' import { range } from 'lodash' -import React from 'react' + describe('DayIndexHeaders', () => { it('should render day index headers for each day of the week', () => { diff --git a/web/src/features/hfiCalculator/components/emptyFireCentre.test.tsx b/web/src/features/hfiCalculator/components/emptyFireCentre.test.tsx index 1ed75f5a9..c0c7ca955 100644 --- a/web/src/features/hfiCalculator/components/emptyFireCentre.test.tsx +++ b/web/src/features/hfiCalculator/components/emptyFireCentre.test.tsx @@ -1,7 +1,7 @@ import { Table, TableBody } from '@mui/material' import { render, waitFor } from '@testing-library/react' import EmptyFireCentreRow from 'features/hfiCalculator/components/EmptyFireCentre' -import React from 'react' + describe('EmptyFireCentre', () => { it('should render with the default value', async () => { const { getByTestId } = render( diff --git a/web/src/features/hfiCalculator/components/fireStartsDropdown.test.tsx b/web/src/features/hfiCalculator/components/fireStartsDropdown.test.tsx index 0bb8904aa..7d699fe79 100644 --- a/web/src/features/hfiCalculator/components/fireStartsDropdown.test.tsx +++ b/web/src/features/hfiCalculator/components/fireStartsDropdown.test.tsx @@ -1,7 +1,8 @@ import { render, within, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import FireStartsDropdown from 'features/hfiCalculator/components/FireStartsDropdown' -import React from 'react' +import { vi } from 'vitest' + describe('FireStartsDropdown', () => { const testAreaId = 1 const dayOffset = 0 @@ -10,7 +11,7 @@ describe('FireStartsDropdown', () => { const fireStartRanges = [lowestFireStarts, highestFireStarts] it('should render with the default value', async () => { - const setFireStartsMock = jest.fn() + const setFireStartsMock = vi.fn() const { getByTestId } = render( { await waitFor(() => expect(setFireStartsMock).toHaveBeenCalledTimes(0)) }) it('should change value on change and call parent callback', async () => { - const setFireStartsMock = jest.fn() + const setFireStartsMock = vi.fn() const { getByTestId } = render( { await waitFor(() => expect(setFireStartsMock).toHaveBeenCalledWith(testAreaId, dayOffset, highestFireStarts)) }) it('should be disabled when fire starts are not enabled', async () => { - const setFireStartsMock = jest.fn() + const setFireStartsMock = vi.fn() const { getByTestId } = render( { const fuelTypes: FuelType[] = [] @@ -27,7 +28,7 @@ describe('FuelTypeDropdown', () => { order_of_appearance_in_planning_area_list: 1 } it('should render with the default value', async () => { - const setFuelTypeMock = jest.fn() + const setFuelTypeMock = vi.fn() const { getByTestId } = render( { await waitFor(() => expect(input.value).toBe(fuelType?.abbrev)) }) it('should change value on change and call parent callback', async () => { - const setFuelTypeMock = jest.fn() + const setFuelTypeMock = vi.fn() const user = userEvent.setup() const { getByTestId } = render( { await waitFor(() => expect(setFuelTypeMock).toHaveBeenCalledWith(testStation.code, fuelTypes[5].id)) }) it('should be disabled when set fuel type is disabled', async () => { - const setFuelTypeMock = jest.fn() + const setFuelTypeMock = vi.fn() const { getByTestId } = render( { it('should return cell in error state for grass cure fuel type without grass cure set', () => { const { getByTestId, queryAllByTestId } = render( diff --git a/web/src/features/hfiCalculator/components/headerRowCell.test.tsx b/web/src/features/hfiCalculator/components/headerRowCell.test.tsx index 1e2d14038..ff9aeced5 100644 --- a/web/src/features/hfiCalculator/components/headerRowCell.test.tsx +++ b/web/src/features/hfiCalculator/components/headerRowCell.test.tsx @@ -1,7 +1,7 @@ import { TableContainer, Table, TableRow, TableBody } from '@mui/material' import { render } from '@testing-library/react' import HeaderRowCell, { COLSPAN } from 'features/hfiCalculator/components/HeaderRowCell' -import React from 'react' + describe('HFI - HeaderRowCell', () => { it('should render the table cell with the expected colspan', () => { const { getByTestId } = render( diff --git a/web/src/features/hfiCalculator/components/hfiErrorAlert.test.tsx b/web/src/features/hfiCalculator/components/hfiErrorAlert.test.tsx index 9c8f49971..bce2cde46 100644 --- a/web/src/features/hfiCalculator/components/hfiErrorAlert.test.tsx +++ b/web/src/features/hfiCalculator/components/hfiErrorAlert.test.tsx @@ -1,6 +1,6 @@ import { render } from '@testing-library/react' import HFIErrorAlert from 'features/hfiCalculator/components/HFIErrorAlert' -import React from 'react' + describe('HFIErrorAlert', () => { it('should render an alert', () => { diff --git a/web/src/features/hfiCalculator/components/hfiLoadingDataView.test.tsx b/web/src/features/hfiCalculator/components/hfiLoadingDataView.test.tsx index 7e2f1749c..0e84f7f4e 100644 --- a/web/src/features/hfiCalculator/components/hfiLoadingDataView.test.tsx +++ b/web/src/features/hfiCalculator/components/hfiLoadingDataView.test.tsx @@ -2,7 +2,7 @@ import { render } from '@testing-library/react' import { FireCentre } from 'api/hfiCalculatorAPI' import HFILoadingDataContainer from 'features/hfiCalculator/components/HFILoadingDataContainer' import { PrepDateRange } from 'features/hfiCalculator/slices/hfiCalculatorSlice' -import React from 'react' + describe('HFILoadingDataView', () => { const child = ( diff --git a/web/src/features/hfiCalculator/components/intensityGroupCell.test.tsx b/web/src/features/hfiCalculator/components/intensityGroupCell.test.tsx index 350d1ae11..7f219d105 100644 --- a/web/src/features/hfiCalculator/components/intensityGroupCell.test.tsx +++ b/web/src/features/hfiCalculator/components/intensityGroupCell.test.tsx @@ -1,7 +1,7 @@ import { TableContainer, Table, TableRow, TableBody } from '@mui/material' import { render } from '@testing-library/react' import IntensityGroupCell from 'features/hfiCalculator/components/IntensityGroupCell' -import React from 'react' + describe('IntensityGroupCell', () => { it('should return cell with value 1 and color code 1', () => { const { getByTestId, getByText } = render( diff --git a/web/src/features/hfiCalculator/components/meanIntensityGroupRollup.test.tsx b/web/src/features/hfiCalculator/components/meanIntensityGroupRollup.test.tsx index 30dc7bec1..9760ffaf5 100644 --- a/web/src/features/hfiCalculator/components/meanIntensityGroupRollup.test.tsx +++ b/web/src/features/hfiCalculator/components/meanIntensityGroupRollup.test.tsx @@ -2,7 +2,7 @@ import { TableContainer, Table, TableRow, TableBody } from '@mui/material' import { render } from '@testing-library/react' import MeanIntensityGroupRollup from 'features/hfiCalculator/components/MeanIntensityGroupRollup' import { PlanningArea } from 'api/hfiCalculatorAPI' -import React from 'react' + describe('Mean Intensity Group Rollup', () => { const planningArea: PlanningArea = { id: 1, diff --git a/web/src/features/hfiCalculator/components/meanPrepLevelCell.test.tsx b/web/src/features/hfiCalculator/components/meanPrepLevelCell.test.tsx index f872b949d..c794bd7de 100644 --- a/web/src/features/hfiCalculator/components/meanPrepLevelCell.test.tsx +++ b/web/src/features/hfiCalculator/components/meanPrepLevelCell.test.tsx @@ -1,7 +1,7 @@ import { Table, TableBody, TableContainer, TableRow } from '@mui/material' import { render } from '@testing-library/react' import MeanPrepLevelCell from 'features/hfiCalculator/components/MeanPrepLevelCell' -import React from 'react' + const renderMeanPrepLevel = (prepLevel: number | undefined, invalidForecast: boolean) => { return render( diff --git a/web/src/features/hfiCalculator/components/planningAreaReadyToggle.test.tsx b/web/src/features/hfiCalculator/components/planningAreaReadyToggle.test.tsx index aa0859171..c7c1e6fbf 100644 --- a/web/src/features/hfiCalculator/components/planningAreaReadyToggle.test.tsx +++ b/web/src/features/hfiCalculator/components/planningAreaReadyToggle.test.tsx @@ -3,7 +3,8 @@ import userEvent from '@testing-library/user-event' import { ReadyPlanningAreaDetails } from 'api/hfiCalculatorAPI' import PlanningAreaReadyToggle from 'features/hfiCalculator/components/PlanningAreaReadyToggle' import { DateTime } from 'luxon' -import React from 'react' +import { vi } from 'vitest' + describe('PlanningAreaReadyToggle', () => { const readyDetails: ReadyPlanningAreaDetails = { @@ -15,7 +16,7 @@ describe('PlanningAreaReadyToggle', () => { update_timestamp: DateTime.fromObject({ year: 2021, month: 1, day: 1 }), update_user: 'test' } - const toggleMockFn = jest.fn((): void => { + const toggleMockFn = vi.fn((): void => { /** no op */ }) beforeEach(() => { diff --git a/web/src/features/hfiCalculator/components/prepLevelCell.test.tsx b/web/src/features/hfiCalculator/components/prepLevelCell.test.tsx index c03511d66..202cf5e70 100644 --- a/web/src/features/hfiCalculator/components/prepLevelCell.test.tsx +++ b/web/src/features/hfiCalculator/components/prepLevelCell.test.tsx @@ -1,7 +1,7 @@ import { Table, TableBody, TableContainer, TableRow } from '@mui/material' import { render } from '@testing-library/react' import PrepLevelCell from 'features/hfiCalculator/components/PrepLevelCell' -import React from 'react' + const renderPrepLevel = (prepLevel: number | undefined) => { return render( diff --git a/web/src/features/hfiCalculator/components/stationAdmin/adminFuelTypeDropdown.test.tsx b/web/src/features/hfiCalculator/components/stationAdmin/adminFuelTypeDropdown.test.tsx index 65b77e412..ebf221602 100644 --- a/web/src/features/hfiCalculator/components/stationAdmin/adminFuelTypeDropdown.test.tsx +++ b/web/src/features/hfiCalculator/components/stationAdmin/adminFuelTypeDropdown.test.tsx @@ -3,13 +3,14 @@ import userEvent from '@testing-library/user-event' import { FuelType } from 'api/hfiCalculatorAPI' import { StationAdminRow } from 'features/hfiCalculator/components/stationAdmin/ManageStationsModal' import { AdminFuelTypesDropdown } from 'features/hfiCalculator/components/stationAdmin/AdminFuelTypesDropdown' -import React from 'react' +import { vi } from 'vitest' + describe('AdminFuelTypesDropdown', () => { it('should call edit handler callback with fuel type option when submitted', async () => { const stationAdminRow: StationAdminRow = { planningAreaId: 1, rowId: 1 } const fuelTypes: Pick[] = [{ id: 2, abbrev: 'c2' }] - const handleEditStationMock = jest.fn() + const handleEditStationMock = vi.fn() const { getByTestId } = render( { it('should call remove handler callback with planning area id and row id', async () => { const stationAdminRow: StationAdminRow = { planningAreaId: 1, rowId: 1, station: { code: 1, name: 'test' } } - const removeMock = jest.fn() + const removeMock = vi.fn() const { getByTestId } = render( { it('should call edit handler callback with station option when submitted', async () => { const stationAdminRow: StationAdminRow = { planningAreaId: 1, rowId: 1 } const stationOptions: BasicWFWXStation[] = [{ name: 'test', code: 2 }] - const handleEditStationMock = jest.fn() + const handleEditStationMock = vi.fn() const { getByTestId } = render( { const idir = 'test@idir' diff --git a/web/src/features/hfiCalculator/components/stationAdmin/manageStationsButton.test.tsx b/web/src/features/hfiCalculator/components/stationAdmin/manageStationsButton.test.tsx index 465043534..aa3b4f9e2 100644 --- a/web/src/features/hfiCalculator/components/stationAdmin/manageStationsButton.test.tsx +++ b/web/src/features/hfiCalculator/components/stationAdmin/manageStationsButton.test.tsx @@ -1,7 +1,7 @@ import { fireEvent, render, waitFor } from '@testing-library/react' import store from 'app/store' import ManageStationsButton from 'features/hfiCalculator/components/stationAdmin/ManageStationsButton' -import React from 'react' + import { Provider } from 'react-redux' describe('ManageStationsButton', () => { diff --git a/web/src/features/hfiCalculator/components/stationAdmin/planningAreaAdmin.test.tsx b/web/src/features/hfiCalculator/components/stationAdmin/planningAreaAdmin.test.tsx index 758f2a041..3830def1d 100644 --- a/web/src/features/hfiCalculator/components/stationAdmin/planningAreaAdmin.test.tsx +++ b/web/src/features/hfiCalculator/components/stationAdmin/planningAreaAdmin.test.tsx @@ -3,26 +3,27 @@ import userEvent from '@testing-library/user-event' import { StationAdminRow } from 'features/hfiCalculator/components/stationAdmin/ManageStationsModal' import PlanningAreaAdmin from 'features/hfiCalculator/components/stationAdmin/PlanningAreaAdmin' import { AdminHandlers } from 'features/hfiCalculator/components/stationAdmin/StationListAdmin' -import React from 'react' +import { vi } from 'vitest' + describe('PlanningAreaAdmin', () => { const planningArea = { id: 1, name: 'testPlanningArea' } const stationAdminRow: StationAdminRow = { planningAreaId: 1, rowId: 1 } const existingStations: { [key: string]: StationAdminRow[] } = { '1': [stationAdminRow] } - const mockAdd = jest.fn((): void => { + const mockAdd = vi.fn((): void => { /** no op */ }) - const mockRemove = jest.fn((): void => { + const mockRemove = vi.fn((): void => { /** no op */ }) - const mockEdit = jest.fn((): void => { + const mockEdit = vi.fn((): void => { /** no op */ }) - const mockRemoveExisting = jest.fn((): void => { + const mockRemoveExisting = vi.fn((): void => { /** no op */ }) const adminHandlers: AdminHandlers = { diff --git a/web/src/features/hfiCalculator/components/stationAdmin/saveStationUpdatesButton.test.tsx b/web/src/features/hfiCalculator/components/stationAdmin/saveStationUpdatesButton.test.tsx index 46bb82120..c8db7644b 100644 --- a/web/src/features/hfiCalculator/components/stationAdmin/saveStationUpdatesButton.test.tsx +++ b/web/src/features/hfiCalculator/components/stationAdmin/saveStationUpdatesButton.test.tsx @@ -1,11 +1,12 @@ import { render, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import SaveStationUpdatesButton from 'features/hfiCalculator/components/stationAdmin/SaveStationUpdatesButton' -import React from 'react' +import { vi } from 'vitest' + describe('SaveStationUpdatesButton', () => { it('should render button enabled when there is a removed station', () => { - const handleSaveMock = jest.fn() + const handleSaveMock = vi.fn() const { getByTestId } = render( { expect(saveButton).toBeInTheDocument() }) it('should render button enabled when there is an added station with all required fields', () => { - const handleSaveMock = jest.fn() + const handleSaveMock = vi.fn() const { getByTestId } = render( { }) it('should not be enabled when there is no added or removed stations', () => { - const handleSaveMock = jest.fn() + const handleSaveMock = vi.fn() const { getByTestId } = render( @@ -43,7 +44,7 @@ describe('SaveStationUpdatesButton', () => { expect(saveButton).toBeDisabled() }) it('should be enabled when an added station has all fields selected', () => { - const handleSaveMock = jest.fn() + const handleSaveMock = vi.fn() const { getByTestId } = render( { expect(saveButton).toBeEnabled() }) it('should not be enabled when an added station has nothing selected', () => { - const handleSaveMock = jest.fn() + const handleSaveMock = vi.fn() const { getByTestId } = render( { expect(saveButton).toBeDisabled() }) it('should not be enabled when an added station name is not selected', () => { - const handleSaveMock = jest.fn() + const handleSaveMock = vi.fn() const { getByTestId } = render( { expect(saveButton).toBeDisabled() }) it('should not be enabled when an added station fuel type is not selected', () => { - const handleSaveMock = jest.fn() + const handleSaveMock = vi.fn() const { getByTestId } = render( { expect(saveButton).toBeDisabled() }) it('should call save callback when clicked', async () => { - const handleSaveMock = jest.fn() + const handleSaveMock = vi.fn() const { getByTestId } = render( { const station: WeatherStation = { code: 1, @@ -15,7 +16,7 @@ describe('StationSelectCell', () => { } it('should render the checkbox and call click handler when clicked', async () => { - const toggleSelectedStationMock = jest.fn() + const toggleSelectedStationMock = vi.fn() render( @@ -44,7 +45,7 @@ describe('StationSelectCell', () => { }) it('should be disabled when station is selected and not change checked value when clicked', async () => { - const toggleSelectedStationMock = jest.fn() + const toggleSelectedStationMock = vi.fn() render(
diff --git a/web/src/features/hfiCalculator/components/statusCell.test.tsx b/web/src/features/hfiCalculator/components/statusCell.test.tsx index 2c501905c..22ef4aca9 100644 --- a/web/src/features/hfiCalculator/components/statusCell.test.tsx +++ b/web/src/features/hfiCalculator/components/statusCell.test.tsx @@ -4,7 +4,7 @@ import { StationDaily } from 'api/hfiCalculatorAPI' import StatusCell from 'features/hfiCalculator/components/StatusCell' import { ValidatedStationDaily } from 'features/hfiCalculator/slices/hfiCalculatorSlice' import { DateTime } from 'luxon' -import React from 'react' + describe('StatusCell', () => { const daily: StationDaily = { code: 1, diff --git a/web/src/features/hfiCalculator/components/weeklyRosCell.test.tsx b/web/src/features/hfiCalculator/components/weeklyRosCell.test.tsx index b21ddfa76..86210950d 100644 --- a/web/src/features/hfiCalculator/components/weeklyRosCell.test.tsx +++ b/web/src/features/hfiCalculator/components/weeklyRosCell.test.tsx @@ -3,7 +3,7 @@ import { render } from '@testing-library/react' import { StationDaily } from 'api/hfiCalculatorAPI' import WeeklyROSCell from 'features/hfiCalculator/components/WeeklyROSCell' import { buildStationDaily } from 'features/hfiCalculator/components/testHelpers' -import React from 'react' + const renderWeeklyRos = (daily: StationDaily, testId: string, error: boolean, isRowSelected: boolean) => { return render( diff --git a/web/src/features/hfiCalculator/slices/stationsSlice.ts b/web/src/features/hfiCalculator/slices/stationsSlice.ts index f8c65d710..f1ad5a874 100644 --- a/web/src/features/hfiCalculator/slices/stationsSlice.ts +++ b/web/src/features/hfiCalculator/slices/stationsSlice.ts @@ -4,13 +4,13 @@ import { FireCentre, getHFIStations, HFIWeatherStationsResponse } from 'api/hfiC import { AppThunk } from 'app/store' import { logError } from 'utils/error' -interface State { +export interface StationsState { loading: boolean error: string | null fireCentres: FireCentre[] } -const initialState: State = { +const initialState: StationsState = { loading: false, error: null, fireCentres: [] @@ -20,16 +20,16 @@ const stationsSlice = createSlice({ name: 'hfiStations', initialState, reducers: { - getHFIStationsStart(state: State) { + getHFIStationsStart(state: StationsState) { state.error = null state.loading = true state.fireCentres = [] }, - getHFIStationsFailed(state: State, action: PayloadAction) { + getHFIStationsFailed(state: StationsState, action: PayloadAction) { state.error = action.payload state.loading = false }, - getHFIStationsSuccess(state: State, action: PayloadAction) { + getHFIStationsSuccess(state: StationsState, action: PayloadAction) { state.error = null state.fireCentres = action.payload.fire_centres state.loading = false diff --git a/web/src/features/landingPage/components/Sidebar.tsx b/web/src/features/landingPage/components/Sidebar.tsx index 8ea533bf3..9ab07bb3b 100644 --- a/web/src/features/landingPage/components/Sidebar.tsx +++ b/web/src/features/landingPage/components/Sidebar.tsx @@ -1,3 +1,4 @@ +/// import React from 'react' import Box from '@mui/material/Box' import Button from '@mui/material/Button' @@ -15,8 +16,8 @@ import Typography from '@mui/material/Typography' import useMediaQuery from '@mui/material/useMediaQuery' import SidebarToolList from 'features/landingPage/components/SidebarToolList' import Subheading from 'features/landingPage/components/Subheading' -import { ReactComponent as MsTeamsIcon } from 'features/landingPage/images/msTeams.svg' -import { ReactComponent as MiroIcon } from 'features/landingPage/images/miro.svg' +import MsTeamsIcon from 'features/landingPage/images/msTeams.svg?react' +import MiroIcon from 'features/landingPage/images/miro.svg?react' import { MIRO_SPRINT_REVIEW_BOARD_URL, MS_TEAMS_SPRINT_REVIEW_URL } from 'utils/env' const PREFIX = 'Sidebar' diff --git a/web/src/features/moreCast2/components/ColumnDefBuilder.tsx b/web/src/features/moreCast2/components/ColumnDefBuilder.tsx index db2537f93..559d9d865 100644 --- a/web/src/features/moreCast2/components/ColumnDefBuilder.tsx +++ b/web/src/features/moreCast2/components/ColumnDefBuilder.tsx @@ -3,7 +3,9 @@ import { GridCellParams, GridColDef, GridColumnHeaderParams, + GridPreProcessEditCellProps, GridRenderCellParams, + GridRenderEditCellParams, GridValueFormatterParams, GridValueGetterParams, GridValueSetterParams @@ -12,6 +14,7 @@ import { WeatherDeterminate, WeatherDeterminateType } from 'api/moreCast2API' import { modelColorClass, modelHeaderColorClass } from 'app/theme' import { GridComponentRenderer } from 'features/moreCast2/components/GridComponentRenderer' import { ColumnClickHandlerProps } from 'features/moreCast2/components/TabbedDataGrid' +import { EditInputCell } from '@/features/moreCast2/components/EditInputCell' export const DEFAULT_COLUMN_WIDTH = 80 export const DEFAULT_FORECAST_COLUMN_WIDTH = 145 @@ -44,17 +47,29 @@ export const GC_HEADER = 'GC' export interface ForecastColDefGenerator { getField: () => string - generateForecastColDef: (columnClickHandlerProps: ColumnClickHandlerProps, headerName?: string) => GridColDef - generateForecastSummaryColDef: (columnClickHandlerProps: ColumnClickHandlerProps) => GridColDef + generateForecastColDef: ( + columnClickHandlerProps: ColumnClickHandlerProps, + headerName?: string, + validator?: (value: string) => string + ) => GridColDef + generateForecastSummaryColDef: ( + columnClickHandlerProps: ColumnClickHandlerProps, + validator?: (value: string) => string + ) => GridColDef } export interface ColDefGenerator { getField: () => string - generateColDef: (columnClickHandlerProps: ColumnClickHandlerProps, headerName?: string) => GridColDef + generateColDef: ( + columnClickHandlerProps: ColumnClickHandlerProps, + headerName?: string, + validator?: (value: string) => string + ) => GridColDef generateColDefs: ( columnClickHandlerProps: ColumnClickHandlerProps, headerName?: string, - includeBiasFields?: boolean + includeBiasFields?: boolean, + validator?: (value: string) => string ) => GridColDef[] } @@ -73,34 +88,48 @@ export class ColumnDefBuilder implements ColDefGenerator, ForecastColDefGenerato return this.generateColDefWith(this.field, this.headerName, this.precision, DEFAULT_COLUMN_WIDTH) } - public generateForecastColDef = (columnClickHandlerProps: ColumnClickHandlerProps, headerName?: string) => { + private renderEditCell(params: GridRenderEditCellParams) { + return + } + + public generateForecastColDef = ( + columnClickHandlerProps: ColumnClickHandlerProps, + headerName?: string, + validator?: (value: string) => string + ) => { return this.generateForecastColDefWith( `${this.field}${WeatherDeterminate.FORECAST}`, headerName ?? this.headerName, this.precision, columnClickHandlerProps, - DEFAULT_FORECAST_COLUMN_WIDTH + DEFAULT_FORECAST_COLUMN_WIDTH, + validator ) } - public generateForecastSummaryColDef = (columnClickHandlerProps: ColumnClickHandlerProps) => { + public generateForecastSummaryColDef = ( + columnClickHandlerProps: ColumnClickHandlerProps, + validator?: (value: string) => string + ) => { return this.generateForecastSummaryColDefWith( `${this.field}${WeatherDeterminate.FORECAST}`, this.headerName, this.precision, columnClickHandlerProps, - DEFAULT_FORECAST_SUMMARY_COLUMN_WIDTH + DEFAULT_FORECAST_SUMMARY_COLUMN_WIDTH, + validator ) } public generateColDefs = ( columnClickHandlerProps: ColumnClickHandlerProps, headerName?: string, - includeBiasFields = true + includeBiasFields = true, + validator?: (value: string) => string ) => { const gridColDefs: GridColDef[] = [] // Forecast columns have unique requirement (eg. column header menu, editable, etc.) - const forecastColDef = this.generateForecastColDef(columnClickHandlerProps, headerName) + const forecastColDef = this.generateForecastColDef(columnClickHandlerProps, headerName, validator) gridColDefs.push(forecastColDef) for (const colDef of this.generateNonForecastColDefs(includeBiasFields)) { @@ -119,7 +148,13 @@ export class ColumnDefBuilder implements ColDefGenerator, ForecastColDefGenerato ) } - public generateColDefWith = (field: string, headerName: string, precision: number, width?: number) => { + public generateColDefWith = ( + field: string, + headerName: string, + precision: number, + width?: number, + validator?: (value: string) => string + ) => { return { field, disableColumnMenu: true, @@ -129,10 +164,14 @@ export class ColumnDefBuilder implements ColDefGenerator, ForecastColDefGenerato sortable: false, type: 'number', width: width ?? DEFAULT_COLUMN_WIDTH, - cellClassName: (params: Pick) => { + renderEditCell: this.renderEditCell, + preProcessEditCellProps: (params: GridPreProcessEditCellProps) => { + return { ...params.props, error: validator ? validator(params.props.value) : '' } + }, + cellClassName: (params: Pick) => { return modelColorClass(params) }, - headerClassName: (params: Pick) => { + headerClassName: (params: Pick) => { return modelHeaderColorClass(params) }, renderCell: (params: Pick) => { @@ -154,7 +193,8 @@ export class ColumnDefBuilder implements ColDefGenerator, ForecastColDefGenerato headerName: string, precision: number, columnClickHandlerProps: ColumnClickHandlerProps, - width?: number + width?: number, + validator?: (value: string) => string ) => { const isGrassField = field.includes('grass') const isCalcField = field.includes('Calc') @@ -171,6 +211,10 @@ export class ColumnDefBuilder implements ColDefGenerator, ForecastColDefGenerato sortable: false, type: 'number', width: width ?? DEFAULT_FORECAST_COLUMN_WIDTH, + renderEditCell: this.renderEditCell, + preProcessEditCellProps: (params: GridPreProcessEditCellProps) => { + return { ...params.props, error: validator ? validator(params.props.value) : '' } + }, renderHeader: (params: GridColumnHeaderParams) => { return isCalcField || isGrassField ? this.gridComponentRenderer.renderHeaderWith(params) @@ -179,7 +223,7 @@ export class ColumnDefBuilder implements ColDefGenerator, ForecastColDefGenerato renderCell: (params: Pick) => { return isCalcField ? this.gridComponentRenderer.renderCellWith(params) - : this.gridComponentRenderer.renderForecastCellWith(params, field) + : this.gridComponentRenderer.renderForecastCellWith(params, field, validator) }, valueFormatter: (params: Pick) => { return this.valueFormatterWith(params, precision) @@ -196,7 +240,8 @@ export class ColumnDefBuilder implements ColDefGenerator, ForecastColDefGenerato headerName: string, precision: number, columnClickHandlerProps: ColumnClickHandlerProps, - width?: number + width?: number, + validator?: (value: string) => string ) => { const isGrassField = field.includes('grass') const isCalcField = field.includes('Calc') @@ -213,6 +258,10 @@ export class ColumnDefBuilder implements ColDefGenerator, ForecastColDefGenerato sortable: false, type: 'number', width: width ?? DEFAULT_FORECAST_SUMMARY_COLUMN_WIDTH, + preProcessEditCellProps: (params: GridPreProcessEditCellProps) => { + return { ...params.props, error: validator ? validator(params.props.value) : '' } + }, + renderEditCell: this.renderEditCell, renderHeader: (params: GridColumnHeaderParams) => { return isCalcField || isGrassField ? this.gridComponentRenderer.renderHeaderWith(params) diff --git a/web/src/features/moreCast2/components/EditInputCell.tsx b/web/src/features/moreCast2/components/EditInputCell.tsx new file mode 100644 index 000000000..873680c28 --- /dev/null +++ b/web/src/features/moreCast2/components/EditInputCell.tsx @@ -0,0 +1,78 @@ +import { GridRenderEditCellParams, useGridApiContext } from '@mui/x-data-grid-pro' +import React, { useRef, useEffect } from 'react' +import { TextField } from '@mui/material' +import { theme } from '@/app/theme' +import { isEmpty } from 'lodash' +import { AppDispatch } from '@/app/store' +import { useDispatch } from 'react-redux' +import { setInputValid } from '@/features/moreCast2/slices/validInputSlice' +import InvalidCellToolTip from '@/features/moreCast2/components/InvalidCellToolTip' + +export const EditInputCell = (props: GridRenderEditCellParams) => { + const { id, value, field, hasFocus, error } = props + const apiRef = useGridApiContext() + const inputRef = useRef(null) + const dispatch: AppDispatch = useDispatch() + + dispatch(setInputValid(isEmpty(error))) + + useEffect(() => { + if (hasFocus && inputRef.current) { + inputRef.current.focus() + } + }, [hasFocus]) + + const handleValueChange = (event: React.ChangeEvent) => { + const newValue = event.target.value + apiRef.current.setEditCellValue({ id, field, value: newValue }) + } + + const handleBlur = () => { + apiRef.current.stopCellEditMode({ id, field }) + } + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + event.stopPropagation() + if (isEmpty(error)) { + apiRef.current.stopCellEditMode({ id, field }) + } else { + event.stopPropagation() + } + } + } + + return ( + + + + ) +} diff --git a/web/src/features/moreCast2/components/ForecastCell.tsx b/web/src/features/moreCast2/components/ForecastCell.tsx index 36649bbf5..fcfd20e6f 100644 --- a/web/src/features/moreCast2/components/ForecastCell.tsx +++ b/web/src/features/moreCast2/components/ForecastCell.tsx @@ -1,9 +1,9 @@ -import React from 'react' -import { Grid, TextField, Tooltip } from '@mui/material' +import { Grid, Tooltip } from '@mui/material' import { GridRenderCellParams } from '@mui/x-data-grid-pro' import RemoveCircleIcon from '@mui/icons-material/RemoveCircle' import AddBoxIcon from '@mui/icons-material/AddBox' -import { MEDIUM_GREY, theme } from 'app/theme' +import { MEDIUM_GREY } from 'app/theme' +import ValidatedForecastCell from '@/features/moreCast2/components/ValidatedForecastCell' interface ForecastCellProps { disabled: boolean @@ -11,9 +11,10 @@ interface ForecastCellProps { showGreaterThan: boolean showLessThan: boolean value: Pick + validator?: (value: string) => string } -const ForecastCell = ({ disabled, label, showGreaterThan, showLessThan, value }: ForecastCellProps) => { +const ForecastCell = ({ disabled, label, showGreaterThan, showLessThan, value, validator }: ForecastCellProps) => { // We should never display both less than and greater than icons at the same time if (showGreaterThan && showLessThan) { throw Error('ForecastCell cannot show both greater than and less than icons at the same time.') @@ -31,30 +32,7 @@ const ForecastCell = ({ disabled, label, showGreaterThan, showLessThan, value }: )} - + {showGreaterThan && ( diff --git a/web/src/features/moreCast2/components/GridComponentRenderer.tsx b/web/src/features/moreCast2/components/GridComponentRenderer.tsx index 31f64c0e9..957397a9a 100644 --- a/web/src/features/moreCast2/components/GridComponentRenderer.tsx +++ b/web/src/features/moreCast2/components/GridComponentRenderer.tsx @@ -22,6 +22,7 @@ import ForecastHeader from 'features/moreCast2/components/ForecastHeader' import { ColumnClickHandlerProps } from 'features/moreCast2/components/TabbedDataGrid' import { cloneDeep, isNumber } from 'lodash' import ForecastCell from 'features/moreCast2/components/ForecastCell' +import ValidatedForecastCell from '@/features/moreCast2/components/ValidatedForecastCell' export const NOT_AVAILABLE = 'N/A' export const NOT_REPORTING = 'N/R' @@ -92,7 +93,11 @@ export class GridComponentRenderer { } else return isNaN(value) ? noDataField : Number(value).toFixed(precision) } - public renderForecastCellWith = (params: Pick, field: string) => { + public renderForecastCellWith = ( + params: Pick, + field: string, + validator?: (value: string) => string + ) => { // If a single cell in a row contains an Actual, no Forecast will be entered into the row anymore, so we can disable the whole row. const isActual = rowContainsActual(params.row) // We can disable a cell if an Actual exists or the forDate is before today. @@ -115,20 +120,12 @@ export class GridComponentRenderer { // The grass curing 'forecast' field is rendered differently if (isGrassField) { return ( - + validator={validator} + /> ) } else { // Forecast fields (except wind direction) have plus and minus icons indicating if the forecast was @@ -140,6 +137,7 @@ export class GridComponentRenderer { showGreaterThan={showGreaterThan} showLessThan={showLessThan} value={params.formattedValue} + validator={validator} /> ) } diff --git a/web/src/features/moreCast2/components/InvalidCellToolTip.tsx b/web/src/features/moreCast2/components/InvalidCellToolTip.tsx new file mode 100644 index 000000000..2a59a5054 --- /dev/null +++ b/web/src/features/moreCast2/components/InvalidCellToolTip.tsx @@ -0,0 +1,30 @@ +import React from 'react' +import Tooltip from '@mui/material/Tooltip' +import { isEmpty } from 'lodash' +import { theme } from '@/app/theme' + +export interface InvalidCellToolTipProps { + error: string + children: React.ReactNode +} + +const InvalidCellToolTip = ({ error, children }: InvalidCellToolTipProps) => { + return ( + + {children} + + ) +} + +export default React.memo(InvalidCellToolTip) diff --git a/web/src/features/moreCast2/components/MoreCast2Column.tsx b/web/src/features/moreCast2/components/MoreCast2Column.tsx index ffcbd4afc..7d1b04302 100644 --- a/web/src/features/moreCast2/components/MoreCast2Column.tsx +++ b/web/src/features/moreCast2/components/MoreCast2Column.tsx @@ -1,4 +1,4 @@ -import { GridValueFormatterParams } from '@mui/x-data-grid-pro' +import { GridPreProcessEditCellProps, GridValueFormatterParams } from '@mui/x-data-grid-pro' import { DateTime } from 'luxon' import { ColDefGenerator, @@ -136,7 +136,8 @@ export class IndeterminateField implements ColDefGenerator, ForecastColDefGenera readonly headerName: string, readonly type: 'string' | 'number', readonly precision: number, - readonly includeBias: boolean + readonly includeBias: boolean, + readonly validator?: (value: string) => string ) { this.colDefBuilder = new ColumnDefBuilder( this.field, @@ -152,29 +153,72 @@ export class IndeterminateField implements ColDefGenerator, ForecastColDefGenera } public generateForecastColDef = (columnClickHandlerProps: ColumnClickHandlerProps, headerName?: string) => { return { - ...this.colDefBuilder.generateForecastColDef(columnClickHandlerProps, headerName ?? this.headerName) + ...this.colDefBuilder.generateForecastColDef( + columnClickHandlerProps, + headerName ?? this.headerName, + this.validator + ) } } public generateForecastSummaryColDef = (columnClickHandlerProps: ColumnClickHandlerProps) => { - return this.colDefBuilder.generateForecastColDef(columnClickHandlerProps) + return this.colDefBuilder.generateForecastColDef(columnClickHandlerProps, undefined, this.validator) } public generateColDef = () => { - return this.colDefBuilder.generateColDefWith(this.field, this.headerName, this.precision) + return this.colDefBuilder.generateColDefWith(this.field, this.headerName, this.precision, undefined, this.validator) } public generateColDefs = (columnClickHandlerProps: ColumnClickHandlerProps, headerName?: string) => { - return this.colDefBuilder.generateColDefs(columnClickHandlerProps, headerName ?? this.headerName, this.includeBias) + return this.colDefBuilder.generateColDefs( + columnClickHandlerProps, + headerName ?? this.headerName, + this.includeBias, + this.validator + ) } } -export const tempForecastField = new IndeterminateField('temp', TEMP_HEADER, 'number', 0, true) -export const rhForecastField = new IndeterminateField('rh', RH_HEADER, 'number', 0, true) -export const windDirForecastField = new IndeterminateField('windDirection', WIND_DIR_HEADER, 'number', 0, true) -export const windSpeedForecastField = new IndeterminateField('windSpeed', WIND_SPEED_HEADER, 'number', 0, true) -export const precipForecastField = new IndeterminateField('precip', PRECIP_HEADER, 'number', 1, true) -export const gcForecastField = new IndeterminateField('grassCuring', GC_HEADER, 'number', 0, false) +export const tempForecastField = new IndeterminateField('temp', TEMP_HEADER, 'number', 0, true, (value: string) => { + return Number(value) < -60 || Number(value) > 60 ? 'Temp must be between -60°C and 60°C' : '' +}) +export const windDirForecastField = new IndeterminateField( + 'windDirection', + WIND_DIR_HEADER, + 'number', + 0, + true, + (value: string) => { + return Number(value) < 0 || Number(value) > 360 ? 'Wind direction must be between 0 and 360 degrees' : '' + } +) + +export const rhForecastField = new IndeterminateField('rh', RH_HEADER, 'number', 0, true, (value: string) => { + return value !== '' && (Number(value) < 1 || Number(value) > 100) ? 'RH must be between 1 and 100' : '' +}) +export const windSpeedForecastField = new IndeterminateField( + 'windSpeed', + WIND_SPEED_HEADER, + 'number', + 0, + true, + (value: string) => { + return Number(value) < 0 || Number(value) > 99 ? 'Wind speed must be between 0 and 99 kph' : '' + } +) +export const precipForecastField = new IndeterminateField( + 'precip', + PRECIP_HEADER, + 'number', + 1, + true, + (value: string) => { + return Number(value) < 0.0 || Number(value) > 200.0 ? 'Precip must be between 0 and 200 mm' : '' + } +) +export const gcForecastField = new IndeterminateField('grassCuring', GC_HEADER, 'number', 0, false, (value: string) => { + return Number(value) < 0 || Number(value) > 100 ? 'Grass curing must be between 0 and 100' : '' +}) export const buiField = new IndeterminateField('buiCalc', 'BUI', 'number', 0, false) export const isiField = new IndeterminateField('isiCalc', 'ISI', 'number', 1, false) diff --git a/web/src/features/moreCast2/components/SaveForecastButton.tsx b/web/src/features/moreCast2/components/SaveForecastButton.tsx index 140ad97da..5b75139f8 100644 --- a/web/src/features/moreCast2/components/SaveForecastButton.tsx +++ b/web/src/features/moreCast2/components/SaveForecastButton.tsx @@ -1,6 +1,8 @@ import React from 'react' import SaveIcon from '@mui/icons-material/Save' import { Button } from '@mui/material' +import { useSelector } from 'react-redux' +import { selectMorecastInputValid } from '@/features/moreCast2/slices/validInputSlice' export interface SubmitForecastButtonProps { className?: string @@ -10,12 +12,14 @@ export interface SubmitForecastButtonProps { } const SaveForecastButton = ({ className, enabled, label, onClick }: SubmitForecastButtonProps) => { + const isValid = useSelector(selectMorecastInputValid) + return (